responses_language_model.go

   1package openai
   2
   3import (
   4	"context"
   5	"encoding/base64"
   6	"encoding/json"
   7	"errors"
   8	"fmt"
   9	"reflect"
  10	"strings"
  11
  12	"charm.land/fantasy"
  13	"charm.land/fantasy/object"
  14	"charm.land/fantasy/schema"
  15	"github.com/charmbracelet/openai-go"
  16	"github.com/charmbracelet/openai-go/packages/param"
  17	"github.com/charmbracelet/openai-go/responses"
  18	"github.com/charmbracelet/openai-go/shared"
  19	"github.com/google/uuid"
  20)
  21
  22const topLogprobsMax = 20
  23
  24type responsesLanguageModel struct {
  25	provider   string
  26	modelID    string
  27	client     openai.Client
  28	objectMode fantasy.ObjectMode
  29}
  30
  31// newResponsesLanguageModel implements a responses api model.
  32func newResponsesLanguageModel(modelID string, provider string, client openai.Client, objectMode fantasy.ObjectMode) responsesLanguageModel {
  33	return responsesLanguageModel{
  34		modelID:    modelID,
  35		provider:   provider,
  36		client:     client,
  37		objectMode: objectMode,
  38	}
  39}
  40
  41func (o responsesLanguageModel) Model() string {
  42	return o.modelID
  43}
  44
  45func (o responsesLanguageModel) Provider() string {
  46	return o.provider
  47}
  48
  49type responsesModelConfig struct {
  50	isReasoningModel           bool
  51	systemMessageMode          string
  52	requiredAutoTruncation     bool
  53	supportsFlexProcessing     bool
  54	supportsPriorityProcessing bool
  55}
  56
  57func getResponsesModelConfig(modelID string) responsesModelConfig {
  58	supportsFlexProcessing := strings.HasPrefix(modelID, "o3") ||
  59		strings.Contains(modelID, "-o3") || strings.Contains(modelID, "o4-mini") ||
  60		(strings.Contains(modelID, "gpt-5") && !strings.Contains(modelID, "gpt-5-chat"))
  61
  62	supportsPriorityProcessing := strings.Contains(modelID, "gpt-4") ||
  63		strings.Contains(modelID, "gpt-5-mini") ||
  64		(strings.Contains(modelID, "gpt-5") &&
  65			!strings.Contains(modelID, "gpt-5-nano") &&
  66			!strings.Contains(modelID, "gpt-5-chat")) ||
  67		strings.HasPrefix(modelID, "o3") ||
  68		strings.Contains(modelID, "-o3") ||
  69		strings.Contains(modelID, "o4-mini")
  70
  71	defaults := responsesModelConfig{
  72		requiredAutoTruncation:     false,
  73		systemMessageMode:          "system",
  74		supportsFlexProcessing:     supportsFlexProcessing,
  75		supportsPriorityProcessing: supportsPriorityProcessing,
  76	}
  77
  78	if strings.Contains(modelID, "gpt-5-chat") {
  79		return responsesModelConfig{
  80			isReasoningModel:           false,
  81			systemMessageMode:          defaults.systemMessageMode,
  82			requiredAutoTruncation:     defaults.requiredAutoTruncation,
  83			supportsFlexProcessing:     defaults.supportsFlexProcessing,
  84			supportsPriorityProcessing: defaults.supportsPriorityProcessing,
  85		}
  86	}
  87
  88	if strings.HasPrefix(modelID, "o1") || strings.Contains(modelID, "-o1") ||
  89		strings.HasPrefix(modelID, "o3") || strings.Contains(modelID, "-o3") ||
  90		strings.HasPrefix(modelID, "o4") || strings.Contains(modelID, "-o4") ||
  91		strings.HasPrefix(modelID, "oss") || strings.Contains(modelID, "-oss") ||
  92		strings.Contains(modelID, "gpt-5") || strings.Contains(modelID, "codex-") ||
  93		strings.Contains(modelID, "computer-use") {
  94		if strings.Contains(modelID, "o1-mini") || strings.Contains(modelID, "o1-preview") {
  95			return responsesModelConfig{
  96				isReasoningModel:           true,
  97				systemMessageMode:          "remove",
  98				requiredAutoTruncation:     defaults.requiredAutoTruncation,
  99				supportsFlexProcessing:     defaults.supportsFlexProcessing,
 100				supportsPriorityProcessing: defaults.supportsPriorityProcessing,
 101			}
 102		}
 103
 104		return responsesModelConfig{
 105			isReasoningModel:           true,
 106			systemMessageMode:          "developer",
 107			requiredAutoTruncation:     defaults.requiredAutoTruncation,
 108			supportsFlexProcessing:     defaults.supportsFlexProcessing,
 109			supportsPriorityProcessing: defaults.supportsPriorityProcessing,
 110		}
 111	}
 112
 113	return responsesModelConfig{
 114		isReasoningModel:           false,
 115		systemMessageMode:          defaults.systemMessageMode,
 116		requiredAutoTruncation:     defaults.requiredAutoTruncation,
 117		supportsFlexProcessing:     defaults.supportsFlexProcessing,
 118		supportsPriorityProcessing: defaults.supportsPriorityProcessing,
 119	}
 120}
 121
 122const (
 123	previousResponseIDHistoryError = "cannot combine previous_response_id with replayed conversation history; use either previous_response_id (server-side chaining) or explicit message replay, not both"
 124	previousResponseIDStoreError   = "previous_response_id requires store to be true; the current response will not be stored and cannot be used for further chaining"
 125)
 126
 127func (o responsesLanguageModel) prepareParams(call fantasy.Call) (*responses.ResponseNewParams, []fantasy.CallWarning, error) {
 128	var warnings []fantasy.CallWarning
 129	params := &responses.ResponseNewParams{}
 130
 131	modelConfig := getResponsesModelConfig(o.modelID)
 132
 133	if call.TopK != nil {
 134		warnings = append(warnings, fantasy.CallWarning{
 135			Type:    fantasy.CallWarningTypeUnsupportedSetting,
 136			Setting: "topK",
 137		})
 138	}
 139
 140	if call.PresencePenalty != nil {
 141		warnings = append(warnings, fantasy.CallWarning{
 142			Type:    fantasy.CallWarningTypeUnsupportedSetting,
 143			Setting: "presencePenalty",
 144		})
 145	}
 146
 147	if call.FrequencyPenalty != nil {
 148		warnings = append(warnings, fantasy.CallWarning{
 149			Type:    fantasy.CallWarningTypeUnsupportedSetting,
 150			Setting: "frequencyPenalty",
 151		})
 152	}
 153
 154	var openaiOptions *ResponsesProviderOptions
 155	if opts, ok := call.ProviderOptions[Name]; ok {
 156		if typedOpts, ok := opts.(*ResponsesProviderOptions); ok {
 157			openaiOptions = typedOpts
 158		}
 159	}
 160
 161	if openaiOptions != nil && openaiOptions.Store != nil {
 162		params.Store = param.NewOpt(*openaiOptions.Store)
 163	} else {
 164		params.Store = param.NewOpt(false)
 165	}
 166
 167	if openaiOptions != nil && openaiOptions.PreviousResponseID != nil && *openaiOptions.PreviousResponseID != "" {
 168		if err := validatePreviousResponseIDPrompt(call.Prompt); err != nil {
 169			return nil, warnings, err
 170		}
 171		if openaiOptions.Store == nil || !*openaiOptions.Store {
 172			return nil, warnings, errors.New(previousResponseIDStoreError)
 173		}
 174		params.PreviousResponseID = param.NewOpt(*openaiOptions.PreviousResponseID)
 175	}
 176
 177	storeEnabled := openaiOptions != nil && openaiOptions.Store != nil && *openaiOptions.Store
 178	input, inputWarnings := toResponsesPrompt(call.Prompt, modelConfig.systemMessageMode, storeEnabled)
 179	warnings = append(warnings, inputWarnings...)
 180
 181	var include []IncludeType
 182
 183	addInclude := func(key IncludeType) {
 184		include = append(include, key)
 185	}
 186
 187	topLogprobs := 0
 188	if openaiOptions != nil && openaiOptions.Logprobs != nil {
 189		switch v := openaiOptions.Logprobs.(type) {
 190		case bool:
 191			if v {
 192				topLogprobs = topLogprobsMax
 193			}
 194		case float64:
 195			topLogprobs = int(v)
 196		case int:
 197			topLogprobs = v
 198		}
 199	}
 200
 201	if topLogprobs > 0 {
 202		addInclude(IncludeMessageOutputTextLogprobs)
 203	}
 204
 205	params.Model = o.modelID
 206	params.Input = responses.ResponseNewParamsInputUnion{
 207		OfInputItemList: input,
 208	}
 209
 210	if call.Temperature != nil {
 211		params.Temperature = param.NewOpt(*call.Temperature)
 212	}
 213	if call.TopP != nil {
 214		params.TopP = param.NewOpt(*call.TopP)
 215	}
 216	if call.MaxOutputTokens != nil {
 217		params.MaxOutputTokens = param.NewOpt(*call.MaxOutputTokens)
 218	}
 219
 220	if openaiOptions != nil {
 221		if openaiOptions.MaxToolCalls != nil {
 222			params.MaxToolCalls = param.NewOpt(*openaiOptions.MaxToolCalls)
 223		}
 224		if openaiOptions.Metadata != nil {
 225			metadata := make(shared.Metadata)
 226			for k, v := range openaiOptions.Metadata {
 227				if str, ok := v.(string); ok {
 228					metadata[k] = str
 229				}
 230			}
 231			params.Metadata = metadata
 232		}
 233		if openaiOptions.ParallelToolCalls != nil {
 234			params.ParallelToolCalls = param.NewOpt(*openaiOptions.ParallelToolCalls)
 235		}
 236		if openaiOptions.User != nil {
 237			params.User = param.NewOpt(*openaiOptions.User)
 238		}
 239		if openaiOptions.Instructions != nil {
 240			params.Instructions = param.NewOpt(*openaiOptions.Instructions)
 241		}
 242		if openaiOptions.ServiceTier != nil {
 243			params.ServiceTier = responses.ResponseNewParamsServiceTier(*openaiOptions.ServiceTier)
 244		}
 245		if openaiOptions.PromptCacheKey != nil {
 246			params.PromptCacheKey = param.NewOpt(*openaiOptions.PromptCacheKey)
 247		}
 248		if openaiOptions.SafetyIdentifier != nil {
 249			params.SafetyIdentifier = param.NewOpt(*openaiOptions.SafetyIdentifier)
 250		}
 251		if topLogprobs > 0 {
 252			params.TopLogprobs = param.NewOpt(int64(topLogprobs))
 253		}
 254
 255		if len(openaiOptions.Include) > 0 {
 256			include = append(include, openaiOptions.Include...)
 257		}
 258
 259		if modelConfig.isReasoningModel && (openaiOptions.ReasoningEffort != nil || openaiOptions.ReasoningSummary != nil) {
 260			reasoning := shared.ReasoningParam{}
 261			if openaiOptions.ReasoningEffort != nil {
 262				reasoning.Effort = shared.ReasoningEffort(*openaiOptions.ReasoningEffort)
 263			}
 264			if openaiOptions.ReasoningSummary != nil {
 265				reasoning.Summary = shared.ReasoningSummary(*openaiOptions.ReasoningSummary)
 266			}
 267			params.Reasoning = reasoning
 268		}
 269	}
 270
 271	if modelConfig.requiredAutoTruncation {
 272		params.Truncation = responses.ResponseNewParamsTruncationAuto
 273	}
 274
 275	if len(include) > 0 {
 276		includeParams := make([]responses.ResponseIncludable, len(include))
 277		for i, inc := range include {
 278			includeParams[i] = responses.ResponseIncludable(string(inc))
 279		}
 280		params.Include = includeParams
 281	}
 282
 283	if modelConfig.isReasoningModel {
 284		if call.Temperature != nil {
 285			params.Temperature = param.Opt[float64]{}
 286			warnings = append(warnings, fantasy.CallWarning{
 287				Type:    fantasy.CallWarningTypeUnsupportedSetting,
 288				Setting: "temperature",
 289				Details: "temperature is not supported for reasoning models",
 290			})
 291		}
 292
 293		if call.TopP != nil {
 294			params.TopP = param.Opt[float64]{}
 295			warnings = append(warnings, fantasy.CallWarning{
 296				Type:    fantasy.CallWarningTypeUnsupportedSetting,
 297				Setting: "topP",
 298				Details: "topP is not supported for reasoning models",
 299			})
 300		}
 301	} else {
 302		if openaiOptions != nil {
 303			if openaiOptions.ReasoningEffort != nil {
 304				warnings = append(warnings, fantasy.CallWarning{
 305					Type:    fantasy.CallWarningTypeUnsupportedSetting,
 306					Setting: "reasoningEffort",
 307					Details: "reasoningEffort is not supported for non-reasoning models",
 308				})
 309			}
 310
 311			if openaiOptions.ReasoningSummary != nil {
 312				warnings = append(warnings, fantasy.CallWarning{
 313					Type:    fantasy.CallWarningTypeUnsupportedSetting,
 314					Setting: "reasoningSummary",
 315					Details: "reasoningSummary is not supported for non-reasoning models",
 316				})
 317			}
 318		}
 319	}
 320
 321	if openaiOptions != nil && openaiOptions.ServiceTier != nil {
 322		if *openaiOptions.ServiceTier == ServiceTierFlex && !modelConfig.supportsFlexProcessing {
 323			warnings = append(warnings, fantasy.CallWarning{
 324				Type:    fantasy.CallWarningTypeUnsupportedSetting,
 325				Setting: "serviceTier",
 326				Details: "flex processing is only available for o3, o4-mini, and gpt-5 models",
 327			})
 328			params.ServiceTier = ""
 329		}
 330
 331		if *openaiOptions.ServiceTier == ServiceTierPriority && !modelConfig.supportsPriorityProcessing {
 332			warnings = append(warnings, fantasy.CallWarning{
 333				Type:    fantasy.CallWarningTypeUnsupportedSetting,
 334				Setting: "serviceTier",
 335				Details: "priority processing is only available for supported models (gpt-4, gpt-5, gpt-5-mini, o3, o4-mini) and requires Enterprise access. gpt-5-nano is not supported",
 336			})
 337			params.ServiceTier = ""
 338		}
 339	}
 340
 341	tools, toolChoice, toolWarnings := toResponsesTools(call.Tools, call.ToolChoice, openaiOptions)
 342	warnings = append(warnings, toolWarnings...)
 343
 344	if len(tools) > 0 {
 345		params.Tools = tools
 346		params.ToolChoice = toolChoice
 347	}
 348
 349	return params, warnings, nil
 350}
 351
 352func validatePreviousResponseIDPrompt(prompt fantasy.Prompt) error {
 353	for _, msg := range prompt {
 354		switch msg.Role {
 355		case fantasy.MessageRoleSystem, fantasy.MessageRoleUser:
 356			continue
 357		default:
 358			return errors.New(previousResponseIDHistoryError)
 359		}
 360	}
 361	return nil
 362}
 363
 364func responsesProviderMetadata(responseID string) fantasy.ProviderMetadata {
 365	if responseID == "" {
 366		return fantasy.ProviderMetadata{}
 367	}
 368
 369	return fantasy.ProviderMetadata{
 370		Name: &ResponsesProviderMetadata{
 371			ResponseID: responseID,
 372		},
 373	}
 374}
 375
 376func responsesUsage(resp responses.Response) fantasy.Usage {
 377	// OpenAI reports input_tokens INCLUDING cached tokens. Subtract to avoid double-counting.
 378	inputTokens := max(resp.Usage.InputTokens-resp.Usage.InputTokensDetails.CachedTokens, 0)
 379	usage := fantasy.Usage{
 380		InputTokens:  inputTokens,
 381		OutputTokens: resp.Usage.OutputTokens,
 382		TotalTokens:  resp.Usage.InputTokens + resp.Usage.OutputTokens,
 383	}
 384	if resp.Usage.OutputTokensDetails.ReasoningTokens != 0 {
 385		usage.ReasoningTokens = resp.Usage.OutputTokensDetails.ReasoningTokens
 386	}
 387	if resp.Usage.InputTokensDetails.CachedTokens != 0 {
 388		usage.CacheReadTokens = resp.Usage.InputTokensDetails.CachedTokens
 389	}
 390	return usage
 391}
 392
 393func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string, store bool) (responses.ResponseInputParam, []fantasy.CallWarning) {
 394	var input responses.ResponseInputParam
 395	var warnings []fantasy.CallWarning
 396
 397	for _, msg := range prompt {
 398		switch msg.Role {
 399		case fantasy.MessageRoleSystem:
 400			var systemText string
 401			for _, c := range msg.Content {
 402				if c.GetType() != fantasy.ContentTypeText {
 403					warnings = append(warnings, fantasy.CallWarning{
 404						Type:    fantasy.CallWarningTypeOther,
 405						Message: "system prompt can only have text content",
 406					})
 407					continue
 408				}
 409				textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
 410				if !ok {
 411					warnings = append(warnings, fantasy.CallWarning{
 412						Type:    fantasy.CallWarningTypeOther,
 413						Message: "system prompt text part does not have the right type",
 414					})
 415					continue
 416				}
 417				if strings.TrimSpace(textPart.Text) != "" {
 418					systemText += textPart.Text
 419				}
 420			}
 421
 422			if systemText == "" {
 423				warnings = append(warnings, fantasy.CallWarning{
 424					Type:    fantasy.CallWarningTypeOther,
 425					Message: "system prompt has no text parts",
 426				})
 427				continue
 428			}
 429
 430			switch systemMessageMode {
 431			case "system":
 432				input = append(input, responses.ResponseInputItemParamOfMessage(systemText, responses.EasyInputMessageRoleSystem))
 433			case "developer":
 434				input = append(input, responses.ResponseInputItemParamOfMessage(systemText, responses.EasyInputMessageRoleDeveloper))
 435			case "remove":
 436				warnings = append(warnings, fantasy.CallWarning{
 437					Type:    fantasy.CallWarningTypeOther,
 438					Message: "system messages are removed for this model",
 439				})
 440			}
 441
 442		case fantasy.MessageRoleUser:
 443			var contentParts responses.ResponseInputMessageContentListParam
 444			for i, c := range msg.Content {
 445				switch c.GetType() {
 446				case fantasy.ContentTypeText:
 447					textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
 448					if !ok {
 449						warnings = append(warnings, fantasy.CallWarning{
 450							Type:    fantasy.CallWarningTypeOther,
 451							Message: "user message text part does not have the right type",
 452						})
 453						continue
 454					}
 455					contentParts = append(contentParts, responses.ResponseInputContentUnionParam{
 456						OfInputText: &responses.ResponseInputTextParam{
 457							Type: "input_text",
 458							Text: textPart.Text,
 459						},
 460					})
 461
 462				case fantasy.ContentTypeFile:
 463					filePart, ok := fantasy.AsContentType[fantasy.FilePart](c)
 464					if !ok {
 465						warnings = append(warnings, fantasy.CallWarning{
 466							Type:    fantasy.CallWarningTypeOther,
 467							Message: "user message file part does not have the right type",
 468						})
 469						continue
 470					}
 471
 472					if strings.HasPrefix(filePart.MediaType, "image/") {
 473						base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
 474						imageURL := fmt.Sprintf("data:%s;base64,%s", filePart.MediaType, base64Encoded)
 475						contentParts = append(contentParts, responses.ResponseInputContentUnionParam{
 476							OfInputImage: &responses.ResponseInputImageParam{
 477								Type:     "input_image",
 478								ImageURL: param.NewOpt(imageURL),
 479							},
 480						})
 481					} else if filePart.MediaType == "application/pdf" {
 482						base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
 483						fileData := fmt.Sprintf("data:application/pdf;base64,%s", base64Encoded)
 484						filename := filePart.Filename
 485						if filename == "" {
 486							filename = fmt.Sprintf("part-%d.pdf", i)
 487						}
 488						contentParts = append(contentParts, responses.ResponseInputContentUnionParam{
 489							OfInputFile: &responses.ResponseInputFileParam{
 490								Type:     "input_file",
 491								Filename: param.NewOpt(filename),
 492								FileData: param.NewOpt(fileData),
 493							},
 494						})
 495					} else {
 496						warnings = append(warnings, fantasy.CallWarning{
 497							Type:    fantasy.CallWarningTypeOther,
 498							Message: fmt.Sprintf("file part media type %s not supported", filePart.MediaType),
 499						})
 500					}
 501				}
 502			}
 503
 504			if !hasVisibleResponsesUserContent(contentParts) {
 505				warnings = append(warnings, fantasy.CallWarning{
 506					Type:    fantasy.CallWarningTypeOther,
 507					Message: "dropping empty user message (contains neither user-facing content nor tool results)",
 508				})
 509				continue
 510			}
 511
 512			input = append(input, responses.ResponseInputItemParamOfMessage(contentParts, responses.EasyInputMessageRoleUser))
 513
 514		case fantasy.MessageRoleAssistant:
 515			startIdx := len(input)
 516			for _, c := range msg.Content {
 517				switch c.GetType() {
 518				case fantasy.ContentTypeText:
 519					textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
 520					if !ok {
 521						warnings = append(warnings, fantasy.CallWarning{
 522							Type:    fantasy.CallWarningTypeOther,
 523							Message: "assistant message text part does not have the right type",
 524						})
 525						continue
 526					}
 527					input = append(input, responses.ResponseInputItemParamOfMessage(textPart.Text, responses.EasyInputMessageRoleAssistant))
 528
 529				case fantasy.ContentTypeToolCall:
 530					toolCallPart, ok := fantasy.AsContentType[fantasy.ToolCallPart](c)
 531					if !ok {
 532						warnings = append(warnings, fantasy.CallWarning{
 533							Type:    fantasy.CallWarningTypeOther,
 534							Message: "assistant message tool call part does not have the right type",
 535						})
 536						continue
 537					}
 538
 539					if toolCallPart.ProviderExecuted {
 540						// Round-trip provider-executed tools via
 541						// item_reference, letting the API resolve
 542						// the stored output item by ID.
 543						input = append(input, responses.ResponseInputItemParamOfItemReference(toolCallPart.ToolCallID))
 544						continue
 545					}
 546
 547					inputJSON, err := json.Marshal(toolCallPart.Input)
 548					if err != nil {
 549						warnings = append(warnings, fantasy.CallWarning{
 550							Type:    fantasy.CallWarningTypeOther,
 551							Message: fmt.Sprintf("failed to marshal tool call input: %v", err),
 552						})
 553						continue
 554					}
 555
 556					input = append(input, responses.ResponseInputItemParamOfFunctionCall(string(inputJSON), toolCallPart.ToolCallID, toolCallPart.ToolName))
 557				case fantasy.ContentTypeSource:
 558					// Source citations from web search are not a
 559					// recognised Responses API input type; skip.
 560					continue
 561				case fantasy.ContentTypeReasoning:
 562					if store {
 563						// When Store is enabled the API already has the
 564						// reasoning persisted server-side. Replaying the
 565						// full OfReasoning item causes a validation error
 566						// ("reasoning was provided without its required
 567						// following item") because the API cannot pair the
 568						// reconstructed reasoning with the output item
 569						// that followed it.
 570						continue
 571					}
 572					reasoningMetadata := GetReasoningMetadata(c.Options())
 573					if reasoningMetadata == nil || reasoningMetadata.ItemID == "" {
 574						continue
 575					}
 576					if len(reasoningMetadata.Summary) == 0 && reasoningMetadata.EncryptedContent == nil {
 577						warnings = append(warnings, fantasy.CallWarning{
 578							Type:    fantasy.CallWarningTypeOther,
 579							Message: "assistant message reasoning part does is empty",
 580						})
 581						continue
 582					}
 583					// we want to always send an empty array
 584					summary := make([]responses.ResponseReasoningItemSummaryParam, 0, len(reasoningMetadata.Summary))
 585					for _, s := range reasoningMetadata.Summary {
 586						summary = append(summary, responses.ResponseReasoningItemSummaryParam{
 587							Type: "summary_text",
 588							Text: s,
 589						})
 590					}
 591					reasoning := &responses.ResponseReasoningItemParam{
 592						ID:      reasoningMetadata.ItemID,
 593						Summary: summary,
 594					}
 595					if reasoningMetadata.EncryptedContent != nil {
 596						reasoning.EncryptedContent = param.NewOpt(*reasoningMetadata.EncryptedContent)
 597					}
 598					input = append(input, responses.ResponseInputItemUnionParam{
 599						OfReasoning: reasoning,
 600					})
 601				}
 602			}
 603
 604			if !hasVisibleResponsesAssistantContent(input, startIdx) {
 605				warnings = append(warnings, fantasy.CallWarning{
 606					Type:    fantasy.CallWarningTypeOther,
 607					Message: "dropping empty assistant message (contains neither user-facing content nor tool calls)",
 608				})
 609				// Remove any items that were added during this iteration
 610				input = input[:startIdx]
 611				continue
 612			}
 613
 614		case fantasy.MessageRoleTool:
 615			for _, c := range msg.Content {
 616				if c.GetType() != fantasy.ContentTypeToolResult {
 617					warnings = append(warnings, fantasy.CallWarning{
 618						Type:    fantasy.CallWarningTypeOther,
 619						Message: "tool message can only have tool result content",
 620					})
 621					continue
 622				}
 623
 624				toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c)
 625				if !ok {
 626					warnings = append(warnings, fantasy.CallWarning{
 627						Type:    fantasy.CallWarningTypeOther,
 628						Message: "tool message result part does not have the right type",
 629					})
 630					continue
 631				}
 632
 633				// Provider-executed tool results (e.g. web search)
 634				// are already round-tripped via the tool call; skip.
 635				if toolResultPart.ProviderExecuted {
 636					continue
 637				}
 638
 639				var outputStr string
 640
 641				switch toolResultPart.Output.GetType() {
 642				case fantasy.ToolResultContentTypeText:
 643					output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](toolResultPart.Output)
 644					if !ok {
 645						warnings = append(warnings, fantasy.CallWarning{
 646							Type:    fantasy.CallWarningTypeOther,
 647							Message: "tool result output does not have the right type",
 648						})
 649						continue
 650					}
 651					outputStr = output.Text
 652				case fantasy.ToolResultContentTypeError:
 653					output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output)
 654					if !ok {
 655						warnings = append(warnings, fantasy.CallWarning{
 656							Type:    fantasy.CallWarningTypeOther,
 657							Message: "tool result output does not have the right type",
 658						})
 659						continue
 660					}
 661					outputStr = output.Error.Error()
 662				}
 663
 664				input = append(input, responses.ResponseInputItemParamOfFunctionCallOutput(toolResultPart.ToolCallID, outputStr))
 665			}
 666		}
 667	}
 668
 669	return input, warnings
 670}
 671
 672func hasVisibleResponsesUserContent(content responses.ResponseInputMessageContentListParam) bool {
 673	return len(content) > 0
 674}
 675
 676func hasVisibleResponsesAssistantContent(items []responses.ResponseInputItemUnionParam, startIdx int) bool {
 677	// Check if we added any assistant content parts from this message
 678	for i := startIdx; i < len(items); i++ {
 679		if items[i].OfMessage != nil || items[i].OfFunctionCall != nil || items[i].OfItemReference != nil {
 680			return true
 681		}
 682	}
 683	return false
 684}
 685
 686func toResponsesTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice, options *ResponsesProviderOptions) ([]responses.ToolUnionParam, responses.ResponseNewParamsToolChoiceUnion, []fantasy.CallWarning) {
 687	warnings := make([]fantasy.CallWarning, 0)
 688	var openaiTools []responses.ToolUnionParam
 689
 690	if len(tools) == 0 {
 691		return nil, responses.ResponseNewParamsToolChoiceUnion{}, nil
 692	}
 693
 694	strictJSONSchema := false
 695	if options != nil && options.StrictJSONSchema != nil {
 696		strictJSONSchema = *options.StrictJSONSchema
 697	}
 698
 699	for _, tool := range tools {
 700		if tool.GetType() == fantasy.ToolTypeFunction {
 701			ft, ok := tool.(fantasy.FunctionTool)
 702			if !ok {
 703				continue
 704			}
 705			openaiTools = append(openaiTools, responses.ToolUnionParam{
 706				OfFunction: &responses.FunctionToolParam{
 707					Name:        ft.Name,
 708					Description: param.NewOpt(ft.Description),
 709					Parameters:  ft.InputSchema,
 710					Strict:      param.NewOpt(strictJSONSchema),
 711					Type:        "function",
 712				},
 713			})
 714			continue
 715		}
 716		if tool.GetType() == fantasy.ToolTypeProviderDefined {
 717			pt, ok := tool.(fantasy.ProviderDefinedTool)
 718			if !ok {
 719				continue
 720			}
 721			switch pt.ID {
 722			case "web_search":
 723				openaiTools = append(openaiTools, toWebSearchToolParam(pt))
 724				continue
 725			}
 726		}
 727
 728		warnings = append(warnings, fantasy.CallWarning{
 729			Type:    fantasy.CallWarningTypeUnsupportedTool,
 730			Tool:    tool,
 731			Message: "tool is not supported",
 732		})
 733	}
 734
 735	if toolChoice == nil {
 736		return openaiTools, responses.ResponseNewParamsToolChoiceUnion{}, warnings
 737	}
 738
 739	var openaiToolChoice responses.ResponseNewParamsToolChoiceUnion
 740
 741	switch *toolChoice {
 742	case fantasy.ToolChoiceAuto:
 743		openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
 744			OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptionsAuto),
 745		}
 746	case fantasy.ToolChoiceNone:
 747		openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
 748			OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptionsNone),
 749		}
 750	case fantasy.ToolChoiceRequired:
 751		openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
 752			OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptionsRequired),
 753		}
 754	default:
 755		openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
 756			OfFunctionTool: &responses.ToolChoiceFunctionParam{
 757				Type: "function",
 758				Name: string(*toolChoice),
 759			},
 760		}
 761	}
 762
 763	return openaiTools, openaiToolChoice, warnings
 764}
 765
 766func (o responsesLanguageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
 767	params, warnings, err := o.prepareParams(call)
 768	if err != nil {
 769		return nil, err
 770	}
 771
 772	response, err := o.client.Responses.New(ctx, *params, callUARequestOptions(call)...)
 773	if err != nil {
 774		return nil, toProviderErr(err)
 775	}
 776
 777	if response.Error.Message != "" {
 778		return nil, &fantasy.Error{
 779			Title:   "provider error",
 780			Message: fmt.Sprintf("%s (code: %s)", response.Error.Message, response.Error.Code),
 781		}
 782	}
 783
 784	var content []fantasy.Content
 785	hasFunctionCall := false
 786
 787	for _, outputItem := range response.Output {
 788		switch outputItem.Type {
 789		case "message":
 790			for _, contentPart := range outputItem.Content {
 791				if contentPart.Type == "output_text" {
 792					content = append(content, fantasy.TextContent{
 793						Text: contentPart.Text,
 794					})
 795
 796					for _, annotation := range contentPart.Annotations {
 797						switch annotation.Type {
 798						case "url_citation":
 799							content = append(content, fantasy.SourceContent{
 800								SourceType: fantasy.SourceTypeURL,
 801								ID:         uuid.NewString(),
 802								URL:        annotation.URL,
 803								Title:      annotation.Title,
 804							})
 805						case "file_citation":
 806							title := "Document"
 807							if annotation.Filename != "" {
 808								title = annotation.Filename
 809							}
 810							filename := annotation.Filename
 811							if filename == "" {
 812								filename = annotation.FileID
 813							}
 814							content = append(content, fantasy.SourceContent{
 815								SourceType: fantasy.SourceTypeDocument,
 816								ID:         uuid.NewString(),
 817								MediaType:  "text/plain",
 818								Title:      title,
 819								Filename:   filename,
 820							})
 821						}
 822					}
 823				}
 824			}
 825
 826		case "function_call":
 827			hasFunctionCall = true
 828			content = append(content, fantasy.ToolCallContent{
 829				ProviderExecuted: false,
 830				ToolCallID:       outputItem.CallID,
 831				ToolName:         outputItem.Name,
 832				Input:            outputItem.Arguments.OfString,
 833			})
 834
 835		case "web_search_call":
 836			// Provider-executed web search tool call. Emit both
 837			// a ToolCallContent and ToolResultContent as a pair,
 838			// matching the vercel/ai pattern for provider tools.
 839			//
 840			// Note: source citations come from url_citation annotations
 841			// on the message text (handled in the "message" case above),
 842			// not from the web_search_call action.
 843			wsMeta := webSearchCallToMetadata(outputItem.ID, outputItem.Action)
 844			content = append(content, fantasy.ToolCallContent{
 845				ProviderExecuted: true,
 846				ToolCallID:       outputItem.ID,
 847				ToolName:         "web_search",
 848			})
 849			content = append(content, fantasy.ToolResultContent{
 850				ProviderExecuted: true,
 851				ToolCallID:       outputItem.ID,
 852				ToolName:         "web_search",
 853				ProviderMetadata: fantasy.ProviderMetadata{
 854					Name: wsMeta,
 855				},
 856			})
 857		case "reasoning":
 858			metadata := &ResponsesReasoningMetadata{
 859				ItemID: outputItem.ID,
 860			}
 861			if outputItem.EncryptedContent != "" {
 862				metadata.EncryptedContent = &outputItem.EncryptedContent
 863			}
 864
 865			if len(outputItem.Summary) == 0 && metadata.EncryptedContent == nil {
 866				continue
 867			}
 868
 869			// When there are no summary parts, add an empty reasoning part
 870			summaries := outputItem.Summary
 871			if len(summaries) == 0 {
 872				summaries = []responses.ResponseReasoningItemSummary{{Type: "summary_text", Text: ""}}
 873			}
 874
 875			for _, s := range summaries {
 876				metadata.Summary = append(metadata.Summary, s.Text)
 877			}
 878
 879			content = append(content, fantasy.ReasoningContent{
 880				Text: strings.Join(metadata.Summary, "\n"),
 881				ProviderMetadata: fantasy.ProviderMetadata{
 882					Name: metadata,
 883				},
 884			})
 885		}
 886	}
 887
 888	usage := responsesUsage(*response)
 889	finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, hasFunctionCall)
 890
 891	return &fantasy.Response{
 892		Content:          content,
 893		Usage:            usage,
 894		FinishReason:     finishReason,
 895		ProviderMetadata: responsesProviderMetadata(response.ID),
 896		Warnings:         warnings,
 897	}, nil
 898}
 899
 900func mapResponsesFinishReason(reason string, hasFunctionCall bool) fantasy.FinishReason {
 901	if hasFunctionCall {
 902		return fantasy.FinishReasonToolCalls
 903	}
 904
 905	switch reason {
 906	case "":
 907		return fantasy.FinishReasonStop
 908	case "max_tokens", "max_output_tokens":
 909		return fantasy.FinishReasonLength
 910	case "content_filter":
 911		return fantasy.FinishReasonContentFilter
 912	default:
 913		return fantasy.FinishReasonOther
 914	}
 915}
 916
 917func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
 918	params, warnings, err := o.prepareParams(call)
 919	if err != nil {
 920		return nil, err
 921	}
 922
 923	stream := o.client.Responses.NewStreaming(ctx, *params, callUARequestOptions(call)...)
 924
 925	finishReason := fantasy.FinishReasonUnknown
 926	var usage fantasy.Usage
 927	// responseID tracks the server-assigned response ID. It's first set from the
 928	// response.created event and may be overwritten by response.completed or
 929	// response.incomplete events. Per the OpenAI API contract, these IDs are
 930	// identical; the overwrites ensure we have the final value even if an event
 931	// is missed.
 932	responseID := ""
 933	ongoingToolCalls := make(map[int64]*ongoingToolCall)
 934	hasFunctionCall := false
 935	activeReasoning := make(map[string]*reasoningState)
 936
 937	return func(yield func(fantasy.StreamPart) bool) {
 938		if len(warnings) > 0 {
 939			if !yield(fantasy.StreamPart{
 940				Type:     fantasy.StreamPartTypeWarnings,
 941				Warnings: warnings,
 942			}) {
 943				return
 944			}
 945		}
 946
 947		for stream.Next() {
 948			event := stream.Current()
 949
 950			switch event.Type {
 951			case "response.created":
 952				created := event.AsResponseCreated()
 953				responseID = created.Response.ID
 954
 955			case "response.output_item.added":
 956				added := event.AsResponseOutputItemAdded()
 957				switch added.Item.Type {
 958				case "function_call":
 959					ongoingToolCalls[added.OutputIndex] = &ongoingToolCall{
 960						toolName:   added.Item.Name,
 961						toolCallID: added.Item.CallID,
 962					}
 963					if !yield(fantasy.StreamPart{
 964						Type:         fantasy.StreamPartTypeToolInputStart,
 965						ID:           added.Item.CallID,
 966						ToolCallName: added.Item.Name,
 967					}) {
 968						return
 969					}
 970
 971				case "web_search_call":
 972					// Provider-executed web search; emit start.
 973					if !yield(fantasy.StreamPart{
 974						Type:             fantasy.StreamPartTypeToolInputStart,
 975						ID:               added.Item.ID,
 976						ToolCallName:     "web_search",
 977						ProviderExecuted: true,
 978					}) {
 979						return
 980					}
 981
 982				case "message":
 983					if !yield(fantasy.StreamPart{
 984						Type: fantasy.StreamPartTypeTextStart,
 985						ID:   added.Item.ID,
 986					}) {
 987						return
 988					}
 989
 990				case "reasoning":
 991					metadata := &ResponsesReasoningMetadata{
 992						ItemID:  added.Item.ID,
 993						Summary: []string{},
 994					}
 995					if added.Item.EncryptedContent != "" {
 996						metadata.EncryptedContent = &added.Item.EncryptedContent
 997					}
 998
 999					activeReasoning[added.Item.ID] = &reasoningState{
1000						metadata: metadata,
1001					}
1002					if !yield(fantasy.StreamPart{
1003						Type: fantasy.StreamPartTypeReasoningStart,
1004						ID:   added.Item.ID,
1005						ProviderMetadata: fantasy.ProviderMetadata{
1006							Name: metadata,
1007						},
1008					}) {
1009						return
1010					}
1011				}
1012
1013			case "response.output_item.done":
1014				done := event.AsResponseOutputItemDone()
1015				switch done.Item.Type {
1016				case "function_call":
1017					tc := ongoingToolCalls[done.OutputIndex]
1018					if tc != nil {
1019						delete(ongoingToolCalls, done.OutputIndex)
1020						hasFunctionCall = true
1021
1022						if !yield(fantasy.StreamPart{
1023							Type: fantasy.StreamPartTypeToolInputEnd,
1024							ID:   done.Item.CallID,
1025						}) {
1026							return
1027						}
1028						if !yield(fantasy.StreamPart{
1029							Type:          fantasy.StreamPartTypeToolCall,
1030							ID:            done.Item.CallID,
1031							ToolCallName:  done.Item.Name,
1032							ToolCallInput: done.Item.Arguments.OfString,
1033						}) {
1034							return
1035						}
1036					}
1037
1038				case "web_search_call":
1039					// Provider-executed web search completed.
1040					// Source citations come from url_citation annotations
1041					// on the streamed message text, not from the action.
1042					if !yield(fantasy.StreamPart{
1043						Type: fantasy.StreamPartTypeToolInputEnd,
1044						ID:   done.Item.ID,
1045					}) {
1046						return
1047					}
1048					if !yield(fantasy.StreamPart{
1049						Type:             fantasy.StreamPartTypeToolCall,
1050						ID:               done.Item.ID,
1051						ToolCallName:     "web_search",
1052						ProviderExecuted: true,
1053					}) {
1054						return
1055					}
1056					// Emit a ToolResult so the agent framework
1057					// includes it in round-trip messages.
1058					if !yield(fantasy.StreamPart{
1059						Type:             fantasy.StreamPartTypeToolResult,
1060						ID:               done.Item.ID,
1061						ToolCallName:     "web_search",
1062						ProviderExecuted: true,
1063						ProviderMetadata: fantasy.ProviderMetadata{
1064							Name: webSearchCallToMetadata(done.Item.ID, done.Item.Action),
1065						},
1066					}) {
1067						return
1068					}
1069				case "message":
1070					if !yield(fantasy.StreamPart{
1071						Type: fantasy.StreamPartTypeTextEnd,
1072						ID:   done.Item.ID,
1073					}) {
1074						return
1075					}
1076
1077				case "reasoning":
1078					state := activeReasoning[done.Item.ID]
1079					if state != nil {
1080						if !yield(fantasy.StreamPart{
1081							Type: fantasy.StreamPartTypeReasoningEnd,
1082							ID:   done.Item.ID,
1083							ProviderMetadata: fantasy.ProviderMetadata{
1084								Name: state.metadata,
1085							},
1086						}) {
1087							return
1088						}
1089						delete(activeReasoning, done.Item.ID)
1090					}
1091				}
1092
1093			case "response.function_call_arguments.delta":
1094				delta := event.AsResponseFunctionCallArgumentsDelta()
1095				tc := ongoingToolCalls[delta.OutputIndex]
1096				if tc != nil {
1097					if !yield(fantasy.StreamPart{
1098						Type:  fantasy.StreamPartTypeToolInputDelta,
1099						ID:    tc.toolCallID,
1100						Delta: delta.Delta,
1101					}) {
1102						return
1103					}
1104				}
1105
1106			case "response.output_text.delta":
1107				textDelta := event.AsResponseOutputTextDelta()
1108				if !yield(fantasy.StreamPart{
1109					Type:  fantasy.StreamPartTypeTextDelta,
1110					ID:    textDelta.ItemID,
1111					Delta: textDelta.Delta,
1112				}) {
1113					return
1114				}
1115
1116			case "response.reasoning_summary_part.added":
1117				added := event.AsResponseReasoningSummaryPartAdded()
1118				state := activeReasoning[added.ItemID]
1119				if state != nil {
1120					state.metadata.Summary = append(state.metadata.Summary, "")
1121					activeReasoning[added.ItemID] = state
1122					if !yield(fantasy.StreamPart{
1123						Type:  fantasy.StreamPartTypeReasoningDelta,
1124						ID:    added.ItemID,
1125						Delta: "\n",
1126						ProviderMetadata: fantasy.ProviderMetadata{
1127							Name: state.metadata,
1128						},
1129					}) {
1130						return
1131					}
1132				}
1133
1134			case "response.reasoning_summary_text.delta":
1135				textDelta := event.AsResponseReasoningSummaryTextDelta()
1136				state := activeReasoning[textDelta.ItemID]
1137				if state != nil {
1138					if len(state.metadata.Summary)-1 >= int(textDelta.SummaryIndex) {
1139						state.metadata.Summary[textDelta.SummaryIndex] += textDelta.Delta
1140					}
1141					activeReasoning[textDelta.ItemID] = state
1142					if !yield(fantasy.StreamPart{
1143						Type:  fantasy.StreamPartTypeReasoningDelta,
1144						ID:    textDelta.ItemID,
1145						Delta: textDelta.Delta,
1146						ProviderMetadata: fantasy.ProviderMetadata{
1147							Name: state.metadata,
1148						},
1149					}) {
1150						return
1151					}
1152				}
1153
1154			case "response.completed":
1155				completed := event.AsResponseCompleted()
1156				responseID = completed.Response.ID
1157				finishReason = mapResponsesFinishReason(completed.Response.IncompleteDetails.Reason, hasFunctionCall)
1158				usage = responsesUsage(completed.Response)
1159
1160			case "response.incomplete":
1161				incomplete := event.AsResponseIncomplete()
1162				responseID = incomplete.Response.ID
1163				finishReason = mapResponsesFinishReason(incomplete.Response.IncompleteDetails.Reason, hasFunctionCall)
1164				usage = responsesUsage(incomplete.Response)
1165
1166			case "error":
1167				errorEvent := event.AsError()
1168				if !yield(fantasy.StreamPart{
1169					Type:  fantasy.StreamPartTypeError,
1170					Error: fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code),
1171				}) {
1172					return
1173				}
1174				return
1175			}
1176		}
1177
1178		err := stream.Err()
1179		if err != nil {
1180			yield(fantasy.StreamPart{
1181				Type:  fantasy.StreamPartTypeError,
1182				Error: toProviderErr(err),
1183			})
1184			return
1185		}
1186
1187		yield(fantasy.StreamPart{
1188			Type:             fantasy.StreamPartTypeFinish,
1189			Usage:            usage,
1190			FinishReason:     finishReason,
1191			ProviderMetadata: responsesProviderMetadata(responseID),
1192		})
1193	}, nil
1194}
1195
1196// toWebSearchToolParam converts a ProviderDefinedTool with ID
1197// "web_search" into the OpenAI SDK's WebSearchToolParam.
1198func toWebSearchToolParam(pt fantasy.ProviderDefinedTool) responses.ToolUnionParam {
1199	wst := responses.WebSearchToolParam{
1200		Type: responses.WebSearchToolTypeWebSearch,
1201	}
1202	if pt.Args != nil {
1203		if size, ok := pt.Args["search_context_size"].(SearchContextSize); ok && size != "" {
1204			wst.SearchContextSize = responses.WebSearchToolSearchContextSize(size)
1205		}
1206		// Also accept plain string for search_context_size.
1207		if size, ok := pt.Args["search_context_size"].(string); ok && size != "" {
1208			wst.SearchContextSize = responses.WebSearchToolSearchContextSize(size)
1209		}
1210		if domains, ok := pt.Args["allowed_domains"].([]string); ok && len(domains) > 0 {
1211			wst.Filters.AllowedDomains = domains
1212		}
1213		if loc, ok := pt.Args["user_location"].(*WebSearchUserLocation); ok && loc != nil {
1214			if loc.City != "" {
1215				wst.UserLocation.City = param.NewOpt(loc.City)
1216			}
1217			if loc.Region != "" {
1218				wst.UserLocation.Region = param.NewOpt(loc.Region)
1219			}
1220			if loc.Country != "" {
1221				wst.UserLocation.Country = param.NewOpt(loc.Country)
1222			}
1223			if loc.Timezone != "" {
1224				wst.UserLocation.Timezone = param.NewOpt(loc.Timezone)
1225			}
1226		}
1227	}
1228	return responses.ToolUnionParam{
1229		OfWebSearch: &wst,
1230	}
1231}
1232
1233// webSearchCallToMetadata converts an OpenAI web search call output
1234// into our structured metadata for round-tripping.
1235func webSearchCallToMetadata(itemID string, action responses.ResponseOutputItemUnionAction) *WebSearchCallMetadata {
1236	meta := &WebSearchCallMetadata{ItemID: itemID}
1237	if action.Type != "" {
1238		a := &WebSearchAction{
1239			Type:  action.Type,
1240			Query: action.Query,
1241		}
1242		for _, src := range action.Sources {
1243			a.Sources = append(a.Sources, WebSearchSource{
1244				Type: string(src.Type),
1245				URL:  src.URL,
1246			})
1247		}
1248		meta.Action = a
1249	}
1250	return meta
1251}
1252
1253// GetReasoningMetadata extracts reasoning metadata from provider options for responses models.
1254func GetReasoningMetadata(providerOptions fantasy.ProviderOptions) *ResponsesReasoningMetadata {
1255	if openaiResponsesOptions, ok := providerOptions[Name]; ok {
1256		if reasoning, ok := openaiResponsesOptions.(*ResponsesReasoningMetadata); ok {
1257			return reasoning
1258		}
1259	}
1260	return nil
1261}
1262
1263type ongoingToolCall struct {
1264	toolName   string
1265	toolCallID string
1266}
1267
1268type reasoningState struct {
1269	metadata *ResponsesReasoningMetadata
1270}
1271
1272// GenerateObject implements fantasy.LanguageModel.
1273func (o responsesLanguageModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
1274	switch o.objectMode {
1275	case fantasy.ObjectModeText:
1276		return object.GenerateWithText(ctx, o, call)
1277	case fantasy.ObjectModeTool:
1278		return object.GenerateWithTool(ctx, o, call)
1279	default:
1280		return o.generateObjectWithJSONMode(ctx, call)
1281	}
1282}
1283
1284// StreamObject implements fantasy.LanguageModel.
1285func (o responsesLanguageModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
1286	switch o.objectMode {
1287	case fantasy.ObjectModeTool:
1288		return object.StreamWithTool(ctx, o, call)
1289	case fantasy.ObjectModeText:
1290		return object.StreamWithText(ctx, o, call)
1291	default:
1292		return o.streamObjectWithJSONMode(ctx, call)
1293	}
1294}
1295
1296func (o responsesLanguageModel) generateObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
1297	// Convert our Schema to OpenAI's JSON Schema format
1298	jsonSchemaMap := schema.ToMap(call.Schema)
1299
1300	// Add additionalProperties: false recursively for strict mode (OpenAI requirement)
1301	addAdditionalPropertiesFalse(jsonSchemaMap)
1302
1303	schemaName := call.SchemaName
1304	if schemaName == "" {
1305		schemaName = "response"
1306	}
1307
1308	// Build request using prepareParams
1309	fantasyCall := fantasy.Call{
1310		Prompt:           call.Prompt,
1311		MaxOutputTokens:  call.MaxOutputTokens,
1312		Temperature:      call.Temperature,
1313		TopP:             call.TopP,
1314		PresencePenalty:  call.PresencePenalty,
1315		FrequencyPenalty: call.FrequencyPenalty,
1316		ProviderOptions:  call.ProviderOptions,
1317	}
1318
1319	params, warnings, err := o.prepareParams(fantasyCall)
1320	if err != nil {
1321		return nil, err
1322	}
1323
1324	// Add structured output via Text.Format field
1325	params.Text = responses.ResponseTextConfigParam{
1326		Format: responses.ResponseFormatTextConfigParamOfJSONSchema(schemaName, jsonSchemaMap),
1327	}
1328
1329	// Make request
1330	response, err := o.client.Responses.New(ctx, *params, objectCallUARequestOptions(call)...)
1331	if err != nil {
1332		return nil, toProviderErr(err)
1333	}
1334
1335	if response.Error.Message != "" {
1336		return nil, &fantasy.Error{
1337			Title:   "provider error",
1338			Message: fmt.Sprintf("%s (code: %s)", response.Error.Message, response.Error.Code),
1339		}
1340	}
1341
1342	// Extract JSON text from response
1343	var jsonText string
1344	for _, outputItem := range response.Output {
1345		if outputItem.Type == "message" {
1346			for _, contentPart := range outputItem.Content {
1347				if contentPart.Type == "output_text" {
1348					jsonText = contentPart.Text
1349					break
1350				}
1351			}
1352		}
1353	}
1354
1355	if jsonText == "" {
1356		usage := fantasy.Usage{
1357			InputTokens:  response.Usage.InputTokens,
1358			OutputTokens: response.Usage.OutputTokens,
1359			TotalTokens:  response.Usage.InputTokens + response.Usage.OutputTokens,
1360		}
1361		finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, false)
1362		return nil, &fantasy.NoObjectGeneratedError{
1363			RawText:      "",
1364			ParseError:   fmt.Errorf("no text content in response"),
1365			Usage:        usage,
1366			FinishReason: finishReason,
1367		}
1368	}
1369
1370	// Parse and validate
1371	var obj any
1372	if call.RepairText != nil {
1373		obj, err = schema.ParseAndValidateWithRepair(ctx, jsonText, call.Schema, call.RepairText)
1374	} else {
1375		obj, err = schema.ParseAndValidate(jsonText, call.Schema)
1376	}
1377
1378	usage := responsesUsage(*response)
1379	finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, false)
1380
1381	if err != nil {
1382		// Add usage info to error
1383		if nogErr, ok := err.(*fantasy.NoObjectGeneratedError); ok {
1384			nogErr.Usage = usage
1385			nogErr.FinishReason = finishReason
1386		}
1387		return nil, err
1388	}
1389
1390	return &fantasy.ObjectResponse{
1391		Object:           obj,
1392		RawText:          jsonText,
1393		Usage:            usage,
1394		FinishReason:     finishReason,
1395		Warnings:         warnings,
1396		ProviderMetadata: responsesProviderMetadata(response.ID),
1397	}, nil
1398}
1399
1400func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
1401	// Convert our Schema to OpenAI's JSON Schema format
1402	jsonSchemaMap := schema.ToMap(call.Schema)
1403
1404	// Add additionalProperties: false recursively for strict mode (OpenAI requirement)
1405	addAdditionalPropertiesFalse(jsonSchemaMap)
1406
1407	schemaName := call.SchemaName
1408	if schemaName == "" {
1409		schemaName = "response"
1410	}
1411
1412	// Build request using prepareParams
1413	fantasyCall := fantasy.Call{
1414		Prompt:           call.Prompt,
1415		MaxOutputTokens:  call.MaxOutputTokens,
1416		Temperature:      call.Temperature,
1417		TopP:             call.TopP,
1418		PresencePenalty:  call.PresencePenalty,
1419		FrequencyPenalty: call.FrequencyPenalty,
1420		ProviderOptions:  call.ProviderOptions,
1421	}
1422
1423	params, warnings, err := o.prepareParams(fantasyCall)
1424	if err != nil {
1425		return nil, err
1426	}
1427
1428	// Add structured output via Text.Format field
1429	params.Text = responses.ResponseTextConfigParam{
1430		Format: responses.ResponseFormatTextConfigParamOfJSONSchema(schemaName, jsonSchemaMap),
1431	}
1432
1433	stream := o.client.Responses.NewStreaming(ctx, *params, objectCallUARequestOptions(call)...)
1434
1435	return func(yield func(fantasy.ObjectStreamPart) bool) {
1436		if len(warnings) > 0 {
1437			if !yield(fantasy.ObjectStreamPart{
1438				Type:     fantasy.ObjectStreamPartTypeObject,
1439				Warnings: warnings,
1440			}) {
1441				return
1442			}
1443		}
1444
1445		var accumulated string
1446		var lastParsedObject any
1447		var usage fantasy.Usage
1448		var finishReason fantasy.FinishReason
1449		// responseID tracks the server-assigned response ID. It's first set from the
1450		// response.created event and may be overwritten by response.completed or
1451		// response.incomplete events. Per the OpenAI API contract, these IDs are
1452		// identical; the overwrites ensure we have the final value even if an event
1453		// is missed.
1454		var responseID string
1455		var streamErr error
1456		hasFunctionCall := false
1457
1458		for stream.Next() {
1459			event := stream.Current()
1460
1461			switch event.Type {
1462			case "response.created":
1463				created := event.AsResponseCreated()
1464				responseID = created.Response.ID
1465
1466			case "response.output_text.delta":
1467				textDelta := event.AsResponseOutputTextDelta()
1468				accumulated += textDelta.Delta
1469
1470				// Try to parse the accumulated text
1471				obj, state, parseErr := schema.ParsePartialJSON(accumulated)
1472
1473				// If we successfully parsed, validate and emit
1474				if state == schema.ParseStateSuccessful || state == schema.ParseStateRepaired {
1475					if err := schema.ValidateAgainstSchema(obj, call.Schema); err == nil {
1476						// Only emit if object is different from last
1477						if !reflect.DeepEqual(obj, lastParsedObject) {
1478							if !yield(fantasy.ObjectStreamPart{
1479								Type:   fantasy.ObjectStreamPartTypeObject,
1480								Object: obj,
1481							}) {
1482								return
1483							}
1484							lastParsedObject = obj
1485						}
1486					}
1487				}
1488
1489				// If parsing failed and we have a repair function, try it
1490				if state == schema.ParseStateFailed && call.RepairText != nil {
1491					repairedText, repairErr := call.RepairText(ctx, accumulated, parseErr)
1492					if repairErr == nil {
1493						obj2, state2, _ := schema.ParsePartialJSON(repairedText)
1494						if (state2 == schema.ParseStateSuccessful || state2 == schema.ParseStateRepaired) &&
1495							schema.ValidateAgainstSchema(obj2, call.Schema) == nil {
1496							if !reflect.DeepEqual(obj2, lastParsedObject) {
1497								if !yield(fantasy.ObjectStreamPart{
1498									Type:   fantasy.ObjectStreamPartTypeObject,
1499									Object: obj2,
1500								}) {
1501									return
1502								}
1503								lastParsedObject = obj2
1504							}
1505						}
1506					}
1507				}
1508
1509			case "response.completed":
1510				completed := event.AsResponseCompleted()
1511				responseID = completed.Response.ID
1512				finishReason = mapResponsesFinishReason(completed.Response.IncompleteDetails.Reason, hasFunctionCall)
1513				usage = responsesUsage(completed.Response)
1514
1515			case "response.incomplete":
1516				incomplete := event.AsResponseIncomplete()
1517				responseID = incomplete.Response.ID
1518				finishReason = mapResponsesFinishReason(incomplete.Response.IncompleteDetails.Reason, hasFunctionCall)
1519				usage = responsesUsage(incomplete.Response)
1520
1521			case "error":
1522				errorEvent := event.AsError()
1523				streamErr = fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code)
1524				if !yield(fantasy.ObjectStreamPart{
1525					Type:  fantasy.ObjectStreamPartTypeError,
1526					Error: streamErr,
1527				}) {
1528					return
1529				}
1530				return
1531			}
1532		}
1533
1534		err := stream.Err()
1535		if err != nil {
1536			yield(fantasy.ObjectStreamPart{
1537				Type:  fantasy.ObjectStreamPartTypeError,
1538				Error: toProviderErr(err),
1539			})
1540			return
1541		}
1542
1543		// Final validation and emit
1544		if streamErr == nil && lastParsedObject != nil {
1545			yield(fantasy.ObjectStreamPart{
1546				Type:             fantasy.ObjectStreamPartTypeFinish,
1547				Usage:            usage,
1548				FinishReason:     finishReason,
1549				ProviderMetadata: responsesProviderMetadata(responseID),
1550			})
1551		} else if streamErr == nil && lastParsedObject == nil {
1552			// No object was generated
1553			yield(fantasy.ObjectStreamPart{
1554				Type: fantasy.ObjectStreamPartTypeError,
1555				Error: &fantasy.NoObjectGeneratedError{
1556					RawText:      accumulated,
1557					ParseError:   fmt.Errorf("no valid object generated in stream"),
1558					Usage:        usage,
1559					FinishReason: finishReason,
1560				},
1561			})
1562		}
1563	}, nil
1564}