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						if store {
 541							// Round-trip provider-executed tools via
 542							// item_reference, letting the API resolve
 543							// the stored output item by ID.
 544							input = append(input, responses.ResponseInputItemParamOfItemReference(toolCallPart.ToolCallID))
 545						}
 546						// When store is disabled, server-side items are
 547						// ephemeral and cannot be referenced. Skip the
 548						// tool call; results are already omitted for
 549						// provider-executed tools.
 550						continue
 551					}
 552
 553					inputJSON, err := json.Marshal(toolCallPart.Input)
 554					if err != nil {
 555						warnings = append(warnings, fantasy.CallWarning{
 556							Type:    fantasy.CallWarningTypeOther,
 557							Message: fmt.Sprintf("failed to marshal tool call input: %v", err),
 558						})
 559						continue
 560					}
 561
 562					input = append(input, responses.ResponseInputItemParamOfFunctionCall(string(inputJSON), toolCallPart.ToolCallID, toolCallPart.ToolName))
 563				case fantasy.ContentTypeSource:
 564					// Source citations from web search are not a
 565					// recognised Responses API input type; skip.
 566					continue
 567				case fantasy.ContentTypeReasoning:
 568					// Reasoning items are always skipped during replay.
 569					// When store is enabled, the API already has them
 570					// persisted server-side. When store is disabled, the
 571					// item IDs are ephemeral and referencing them causes
 572					// "Item not found" errors. In both cases, replaying
 573					// reasoning inline is not supported by the API.
 574					continue
 575				}
 576			}
 577
 578			if !hasVisibleResponsesAssistantContent(input, startIdx) {
 579				warnings = append(warnings, fantasy.CallWarning{
 580					Type:    fantasy.CallWarningTypeOther,
 581					Message: "dropping empty assistant message (contains neither user-facing content nor tool calls)",
 582				})
 583				// Remove any items that were added during this iteration
 584				input = input[:startIdx]
 585				continue
 586			}
 587
 588		case fantasy.MessageRoleTool:
 589			for _, c := range msg.Content {
 590				if c.GetType() != fantasy.ContentTypeToolResult {
 591					warnings = append(warnings, fantasy.CallWarning{
 592						Type:    fantasy.CallWarningTypeOther,
 593						Message: "tool message can only have tool result content",
 594					})
 595					continue
 596				}
 597
 598				toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c)
 599				if !ok {
 600					warnings = append(warnings, fantasy.CallWarning{
 601						Type:    fantasy.CallWarningTypeOther,
 602						Message: "tool message result part does not have the right type",
 603					})
 604					continue
 605				}
 606
 607				// Provider-executed tool results (e.g. web search)
 608				// are already round-tripped via the tool call; skip.
 609				if toolResultPart.ProviderExecuted {
 610					continue
 611				}
 612
 613				var outputStr string
 614
 615				switch toolResultPart.Output.GetType() {
 616				case fantasy.ToolResultContentTypeText:
 617					output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](toolResultPart.Output)
 618					if !ok {
 619						warnings = append(warnings, fantasy.CallWarning{
 620							Type:    fantasy.CallWarningTypeOther,
 621							Message: "tool result output does not have the right type",
 622						})
 623						continue
 624					}
 625					outputStr = output.Text
 626				case fantasy.ToolResultContentTypeError:
 627					output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output)
 628					if !ok {
 629						warnings = append(warnings, fantasy.CallWarning{
 630							Type:    fantasy.CallWarningTypeOther,
 631							Message: "tool result output does not have the right type",
 632						})
 633						continue
 634					}
 635					outputStr = output.Error.Error()
 636				}
 637
 638				input = append(input, responses.ResponseInputItemParamOfFunctionCallOutput(toolResultPart.ToolCallID, outputStr))
 639			}
 640		}
 641	}
 642
 643	return input, warnings
 644}
 645
 646func hasVisibleResponsesUserContent(content responses.ResponseInputMessageContentListParam) bool {
 647	return len(content) > 0
 648}
 649
 650func hasVisibleResponsesAssistantContent(items []responses.ResponseInputItemUnionParam, startIdx int) bool {
 651	// Check if we added any assistant content parts from this message
 652	for i := startIdx; i < len(items); i++ {
 653		if items[i].OfMessage != nil || items[i].OfFunctionCall != nil || items[i].OfItemReference != nil {
 654			return true
 655		}
 656	}
 657	return false
 658}
 659
 660func toResponsesTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice, options *ResponsesProviderOptions) ([]responses.ToolUnionParam, responses.ResponseNewParamsToolChoiceUnion, []fantasy.CallWarning) {
 661	warnings := make([]fantasy.CallWarning, 0)
 662	var openaiTools []responses.ToolUnionParam
 663
 664	if len(tools) == 0 {
 665		return nil, responses.ResponseNewParamsToolChoiceUnion{}, nil
 666	}
 667
 668	strictJSONSchema := false
 669	if options != nil && options.StrictJSONSchema != nil {
 670		strictJSONSchema = *options.StrictJSONSchema
 671	}
 672
 673	for _, tool := range tools {
 674		if tool.GetType() == fantasy.ToolTypeFunction {
 675			ft, ok := tool.(fantasy.FunctionTool)
 676			if !ok {
 677				continue
 678			}
 679			openaiTools = append(openaiTools, responses.ToolUnionParam{
 680				OfFunction: &responses.FunctionToolParam{
 681					Name:        ft.Name,
 682					Description: param.NewOpt(ft.Description),
 683					Parameters:  ft.InputSchema,
 684					Strict:      param.NewOpt(strictJSONSchema),
 685					Type:        "function",
 686				},
 687			})
 688			continue
 689		}
 690		if tool.GetType() == fantasy.ToolTypeProviderDefined {
 691			pt, ok := tool.(fantasy.ProviderDefinedTool)
 692			if !ok {
 693				continue
 694			}
 695			switch pt.ID {
 696			case "web_search":
 697				openaiTools = append(openaiTools, toWebSearchToolParam(pt))
 698				continue
 699			}
 700		}
 701
 702		warnings = append(warnings, fantasy.CallWarning{
 703			Type:    fantasy.CallWarningTypeUnsupportedTool,
 704			Tool:    tool,
 705			Message: "tool is not supported",
 706		})
 707	}
 708
 709	if toolChoice == nil {
 710		return openaiTools, responses.ResponseNewParamsToolChoiceUnion{}, warnings
 711	}
 712
 713	var openaiToolChoice responses.ResponseNewParamsToolChoiceUnion
 714
 715	switch *toolChoice {
 716	case fantasy.ToolChoiceAuto:
 717		openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
 718			OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptionsAuto),
 719		}
 720	case fantasy.ToolChoiceNone:
 721		openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
 722			OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptionsNone),
 723		}
 724	case fantasy.ToolChoiceRequired:
 725		openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
 726			OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptionsRequired),
 727		}
 728	default:
 729		openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
 730			OfFunctionTool: &responses.ToolChoiceFunctionParam{
 731				Type: "function",
 732				Name: string(*toolChoice),
 733			},
 734		}
 735	}
 736
 737	return openaiTools, openaiToolChoice, warnings
 738}
 739
 740func (o responsesLanguageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
 741	params, warnings, err := o.prepareParams(call)
 742	if err != nil {
 743		return nil, err
 744	}
 745
 746	response, err := o.client.Responses.New(ctx, *params, callUARequestOptions(call)...)
 747	if err != nil {
 748		return nil, toProviderErr(err)
 749	}
 750
 751	if response.Error.Message != "" {
 752		return nil, &fantasy.Error{
 753			Title:   "provider error",
 754			Message: fmt.Sprintf("%s (code: %s)", response.Error.Message, response.Error.Code),
 755		}
 756	}
 757
 758	var content []fantasy.Content
 759	hasFunctionCall := false
 760
 761	for _, outputItem := range response.Output {
 762		switch outputItem.Type {
 763		case "message":
 764			for _, contentPart := range outputItem.Content {
 765				if contentPart.Type == "output_text" {
 766					content = append(content, fantasy.TextContent{
 767						Text: contentPart.Text,
 768					})
 769
 770					for _, annotation := range contentPart.Annotations {
 771						switch annotation.Type {
 772						case "url_citation":
 773							content = append(content, fantasy.SourceContent{
 774								SourceType: fantasy.SourceTypeURL,
 775								ID:         uuid.NewString(),
 776								URL:        annotation.URL,
 777								Title:      annotation.Title,
 778							})
 779						case "file_citation":
 780							title := "Document"
 781							if annotation.Filename != "" {
 782								title = annotation.Filename
 783							}
 784							filename := annotation.Filename
 785							if filename == "" {
 786								filename = annotation.FileID
 787							}
 788							content = append(content, fantasy.SourceContent{
 789								SourceType: fantasy.SourceTypeDocument,
 790								ID:         uuid.NewString(),
 791								MediaType:  "text/plain",
 792								Title:      title,
 793								Filename:   filename,
 794							})
 795						}
 796					}
 797				}
 798			}
 799
 800		case "function_call":
 801			hasFunctionCall = true
 802			content = append(content, fantasy.ToolCallContent{
 803				ProviderExecuted: false,
 804				ToolCallID:       outputItem.CallID,
 805				ToolName:         outputItem.Name,
 806				Input:            outputItem.Arguments.OfString,
 807			})
 808
 809		case "web_search_call":
 810			// Provider-executed web search tool call. Emit both
 811			// a ToolCallContent and ToolResultContent as a pair,
 812			// matching the vercel/ai pattern for provider tools.
 813			//
 814			// Note: source citations come from url_citation annotations
 815			// on the message text (handled in the "message" case above),
 816			// not from the web_search_call action.
 817			wsMeta := webSearchCallToMetadata(outputItem.ID, outputItem.Action)
 818			content = append(content, fantasy.ToolCallContent{
 819				ProviderExecuted: true,
 820				ToolCallID:       outputItem.ID,
 821				ToolName:         "web_search",
 822			})
 823			content = append(content, fantasy.ToolResultContent{
 824				ProviderExecuted: true,
 825				ToolCallID:       outputItem.ID,
 826				ToolName:         "web_search",
 827				ProviderMetadata: fantasy.ProviderMetadata{
 828					Name: wsMeta,
 829				},
 830			})
 831		case "reasoning":
 832			metadata := &ResponsesReasoningMetadata{
 833				ItemID: outputItem.ID,
 834			}
 835			if outputItem.EncryptedContent != "" {
 836				metadata.EncryptedContent = &outputItem.EncryptedContent
 837			}
 838
 839			if len(outputItem.Summary) == 0 && metadata.EncryptedContent == nil {
 840				continue
 841			}
 842
 843			// When there are no summary parts, add an empty reasoning part
 844			summaries := outputItem.Summary
 845			if len(summaries) == 0 {
 846				summaries = []responses.ResponseReasoningItemSummary{{Type: "summary_text", Text: ""}}
 847			}
 848
 849			for _, s := range summaries {
 850				metadata.Summary = append(metadata.Summary, s.Text)
 851			}
 852
 853			content = append(content, fantasy.ReasoningContent{
 854				Text: strings.Join(metadata.Summary, "\n"),
 855				ProviderMetadata: fantasy.ProviderMetadata{
 856					Name: metadata,
 857				},
 858			})
 859		}
 860	}
 861
 862	usage := responsesUsage(*response)
 863	finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, hasFunctionCall)
 864
 865	return &fantasy.Response{
 866		Content:          content,
 867		Usage:            usage,
 868		FinishReason:     finishReason,
 869		ProviderMetadata: responsesProviderMetadata(response.ID),
 870		Warnings:         warnings,
 871	}, nil
 872}
 873
 874func mapResponsesFinishReason(reason string, hasFunctionCall bool) fantasy.FinishReason {
 875	if hasFunctionCall {
 876		return fantasy.FinishReasonToolCalls
 877	}
 878
 879	switch reason {
 880	case "":
 881		return fantasy.FinishReasonStop
 882	case "max_tokens", "max_output_tokens":
 883		return fantasy.FinishReasonLength
 884	case "content_filter":
 885		return fantasy.FinishReasonContentFilter
 886	default:
 887		return fantasy.FinishReasonOther
 888	}
 889}
 890
 891func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
 892	params, warnings, err := o.prepareParams(call)
 893	if err != nil {
 894		return nil, err
 895	}
 896
 897	stream := o.client.Responses.NewStreaming(ctx, *params, callUARequestOptions(call)...)
 898
 899	finishReason := fantasy.FinishReasonUnknown
 900	var usage fantasy.Usage
 901	// responseID tracks the server-assigned response ID. It's first set from the
 902	// response.created event and may be overwritten by response.completed or
 903	// response.incomplete events. Per the OpenAI API contract, these IDs are
 904	// identical; the overwrites ensure we have the final value even if an event
 905	// is missed.
 906	responseID := ""
 907	ongoingToolCalls := make(map[int64]*ongoingToolCall)
 908	hasFunctionCall := false
 909	activeReasoning := make(map[string]*reasoningState)
 910
 911	return func(yield func(fantasy.StreamPart) bool) {
 912		if len(warnings) > 0 {
 913			if !yield(fantasy.StreamPart{
 914				Type:     fantasy.StreamPartTypeWarnings,
 915				Warnings: warnings,
 916			}) {
 917				return
 918			}
 919		}
 920
 921		for stream.Next() {
 922			event := stream.Current()
 923
 924			switch event.Type {
 925			case "response.created":
 926				created := event.AsResponseCreated()
 927				responseID = created.Response.ID
 928
 929			case "response.output_item.added":
 930				added := event.AsResponseOutputItemAdded()
 931				switch added.Item.Type {
 932				case "function_call":
 933					ongoingToolCalls[added.OutputIndex] = &ongoingToolCall{
 934						toolName:   added.Item.Name,
 935						toolCallID: added.Item.CallID,
 936					}
 937					if !yield(fantasy.StreamPart{
 938						Type:         fantasy.StreamPartTypeToolInputStart,
 939						ID:           added.Item.CallID,
 940						ToolCallName: added.Item.Name,
 941					}) {
 942						return
 943					}
 944
 945				case "web_search_call":
 946					// Provider-executed web search; emit start.
 947					if !yield(fantasy.StreamPart{
 948						Type:             fantasy.StreamPartTypeToolInputStart,
 949						ID:               added.Item.ID,
 950						ToolCallName:     "web_search",
 951						ProviderExecuted: true,
 952					}) {
 953						return
 954					}
 955
 956				case "message":
 957					if !yield(fantasy.StreamPart{
 958						Type: fantasy.StreamPartTypeTextStart,
 959						ID:   added.Item.ID,
 960					}) {
 961						return
 962					}
 963
 964				case "reasoning":
 965					metadata := &ResponsesReasoningMetadata{
 966						ItemID:  added.Item.ID,
 967						Summary: []string{},
 968					}
 969					if added.Item.EncryptedContent != "" {
 970						metadata.EncryptedContent = &added.Item.EncryptedContent
 971					}
 972
 973					activeReasoning[added.Item.ID] = &reasoningState{
 974						metadata: metadata,
 975					}
 976					if !yield(fantasy.StreamPart{
 977						Type: fantasy.StreamPartTypeReasoningStart,
 978						ID:   added.Item.ID,
 979						ProviderMetadata: fantasy.ProviderMetadata{
 980							Name: metadata,
 981						},
 982					}) {
 983						return
 984					}
 985				}
 986
 987			case "response.output_item.done":
 988				done := event.AsResponseOutputItemDone()
 989				switch done.Item.Type {
 990				case "function_call":
 991					tc := ongoingToolCalls[done.OutputIndex]
 992					if tc != nil {
 993						delete(ongoingToolCalls, done.OutputIndex)
 994						hasFunctionCall = true
 995
 996						if !yield(fantasy.StreamPart{
 997							Type: fantasy.StreamPartTypeToolInputEnd,
 998							ID:   done.Item.CallID,
 999						}) {
1000							return
1001						}
1002						if !yield(fantasy.StreamPart{
1003							Type:          fantasy.StreamPartTypeToolCall,
1004							ID:            done.Item.CallID,
1005							ToolCallName:  done.Item.Name,
1006							ToolCallInput: done.Item.Arguments.OfString,
1007						}) {
1008							return
1009						}
1010					}
1011
1012				case "web_search_call":
1013					// Provider-executed web search completed.
1014					// Source citations come from url_citation annotations
1015					// on the streamed message text, not from the action.
1016					if !yield(fantasy.StreamPart{
1017						Type: fantasy.StreamPartTypeToolInputEnd,
1018						ID:   done.Item.ID,
1019					}) {
1020						return
1021					}
1022					if !yield(fantasy.StreamPart{
1023						Type:             fantasy.StreamPartTypeToolCall,
1024						ID:               done.Item.ID,
1025						ToolCallName:     "web_search",
1026						ProviderExecuted: true,
1027					}) {
1028						return
1029					}
1030					// Emit a ToolResult so the agent framework
1031					// includes it in round-trip messages.
1032					if !yield(fantasy.StreamPart{
1033						Type:             fantasy.StreamPartTypeToolResult,
1034						ID:               done.Item.ID,
1035						ToolCallName:     "web_search",
1036						ProviderExecuted: true,
1037						ProviderMetadata: fantasy.ProviderMetadata{
1038							Name: webSearchCallToMetadata(done.Item.ID, done.Item.Action),
1039						},
1040					}) {
1041						return
1042					}
1043				case "message":
1044					if !yield(fantasy.StreamPart{
1045						Type: fantasy.StreamPartTypeTextEnd,
1046						ID:   done.Item.ID,
1047					}) {
1048						return
1049					}
1050
1051				case "reasoning":
1052					state := activeReasoning[done.Item.ID]
1053					if state != nil {
1054						if !yield(fantasy.StreamPart{
1055							Type: fantasy.StreamPartTypeReasoningEnd,
1056							ID:   done.Item.ID,
1057							ProviderMetadata: fantasy.ProviderMetadata{
1058								Name: state.metadata,
1059							},
1060						}) {
1061							return
1062						}
1063						delete(activeReasoning, done.Item.ID)
1064					}
1065				}
1066
1067			case "response.function_call_arguments.delta":
1068				delta := event.AsResponseFunctionCallArgumentsDelta()
1069				tc := ongoingToolCalls[delta.OutputIndex]
1070				if tc != nil {
1071					if !yield(fantasy.StreamPart{
1072						Type:  fantasy.StreamPartTypeToolInputDelta,
1073						ID:    tc.toolCallID,
1074						Delta: delta.Delta,
1075					}) {
1076						return
1077					}
1078				}
1079
1080			case "response.output_text.delta":
1081				textDelta := event.AsResponseOutputTextDelta()
1082				if !yield(fantasy.StreamPart{
1083					Type:  fantasy.StreamPartTypeTextDelta,
1084					ID:    textDelta.ItemID,
1085					Delta: textDelta.Delta,
1086				}) {
1087					return
1088				}
1089
1090			case "response.output_text.annotation.added":
1091				added := event.AsResponseOutputTextAnnotationAdded()
1092				// The Annotation field is typed as `any` in the SDK;
1093				// it deserializes as map[string]any from JSON.
1094				annotationMap, ok := added.Annotation.(map[string]any)
1095				if !ok {
1096					break
1097				}
1098				annotationType, _ := annotationMap["type"].(string)
1099				switch annotationType {
1100				case "url_citation":
1101					url, _ := annotationMap["url"].(string)
1102					title, _ := annotationMap["title"].(string)
1103					if !yield(fantasy.StreamPart{
1104						Type:       fantasy.StreamPartTypeSource,
1105						ID:         uuid.NewString(),
1106						SourceType: fantasy.SourceTypeURL,
1107						URL:        url,
1108						Title:      title,
1109					}) {
1110						return
1111					}
1112				case "file_citation":
1113					title := "Document"
1114					if fn, ok := annotationMap["filename"].(string); ok && fn != "" {
1115						title = fn
1116					}
1117					if !yield(fantasy.StreamPart{
1118						Type:       fantasy.StreamPartTypeSource,
1119						ID:         uuid.NewString(),
1120						SourceType: fantasy.SourceTypeDocument,
1121						Title:      title,
1122					}) {
1123						return
1124					}
1125				}
1126
1127			case "response.reasoning_summary_part.added":
1128				added := event.AsResponseReasoningSummaryPartAdded()
1129				state := activeReasoning[added.ItemID]
1130				if state != nil {
1131					state.metadata.Summary = append(state.metadata.Summary, "")
1132					activeReasoning[added.ItemID] = state
1133					if !yield(fantasy.StreamPart{
1134						Type:  fantasy.StreamPartTypeReasoningDelta,
1135						ID:    added.ItemID,
1136						Delta: "\n",
1137						ProviderMetadata: fantasy.ProviderMetadata{
1138							Name: state.metadata,
1139						},
1140					}) {
1141						return
1142					}
1143				}
1144
1145			case "response.reasoning_summary_text.delta":
1146				textDelta := event.AsResponseReasoningSummaryTextDelta()
1147				state := activeReasoning[textDelta.ItemID]
1148				if state != nil {
1149					if len(state.metadata.Summary)-1 >= int(textDelta.SummaryIndex) {
1150						state.metadata.Summary[textDelta.SummaryIndex] += textDelta.Delta
1151					}
1152					activeReasoning[textDelta.ItemID] = state
1153					if !yield(fantasy.StreamPart{
1154						Type:  fantasy.StreamPartTypeReasoningDelta,
1155						ID:    textDelta.ItemID,
1156						Delta: textDelta.Delta,
1157						ProviderMetadata: fantasy.ProviderMetadata{
1158							Name: state.metadata,
1159						},
1160					}) {
1161						return
1162					}
1163				}
1164
1165			case "response.completed":
1166				completed := event.AsResponseCompleted()
1167				responseID = completed.Response.ID
1168				finishReason = mapResponsesFinishReason(completed.Response.IncompleteDetails.Reason, hasFunctionCall)
1169				usage = responsesUsage(completed.Response)
1170
1171			case "response.incomplete":
1172				incomplete := event.AsResponseIncomplete()
1173				responseID = incomplete.Response.ID
1174				finishReason = mapResponsesFinishReason(incomplete.Response.IncompleteDetails.Reason, hasFunctionCall)
1175				usage = responsesUsage(incomplete.Response)
1176
1177			case "error":
1178				errorEvent := event.AsError()
1179				if !yield(fantasy.StreamPart{
1180					Type:  fantasy.StreamPartTypeError,
1181					Error: fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code),
1182				}) {
1183					return
1184				}
1185				return
1186			}
1187		}
1188
1189		err := stream.Err()
1190		if err != nil {
1191			yield(fantasy.StreamPart{
1192				Type:  fantasy.StreamPartTypeError,
1193				Error: toProviderErr(err),
1194			})
1195			return
1196		}
1197
1198		yield(fantasy.StreamPart{
1199			Type:             fantasy.StreamPartTypeFinish,
1200			Usage:            usage,
1201			FinishReason:     finishReason,
1202			ProviderMetadata: responsesProviderMetadata(responseID),
1203		})
1204	}, nil
1205}
1206
1207// toWebSearchToolParam converts a ProviderDefinedTool with ID
1208// "web_search" into the OpenAI SDK's WebSearchToolParam.
1209func toWebSearchToolParam(pt fantasy.ProviderDefinedTool) responses.ToolUnionParam {
1210	wst := responses.WebSearchToolParam{
1211		Type: responses.WebSearchToolTypeWebSearch,
1212	}
1213	if pt.Args != nil {
1214		if size, ok := pt.Args["search_context_size"].(SearchContextSize); ok && size != "" {
1215			wst.SearchContextSize = responses.WebSearchToolSearchContextSize(size)
1216		}
1217		// Also accept plain string for search_context_size.
1218		if size, ok := pt.Args["search_context_size"].(string); ok && size != "" {
1219			wst.SearchContextSize = responses.WebSearchToolSearchContextSize(size)
1220		}
1221		if domains, ok := pt.Args["allowed_domains"].([]string); ok && len(domains) > 0 {
1222			wst.Filters.AllowedDomains = domains
1223		}
1224		if loc, ok := pt.Args["user_location"].(*WebSearchUserLocation); ok && loc != nil {
1225			if loc.City != "" {
1226				wst.UserLocation.City = param.NewOpt(loc.City)
1227			}
1228			if loc.Region != "" {
1229				wst.UserLocation.Region = param.NewOpt(loc.Region)
1230			}
1231			if loc.Country != "" {
1232				wst.UserLocation.Country = param.NewOpt(loc.Country)
1233			}
1234			if loc.Timezone != "" {
1235				wst.UserLocation.Timezone = param.NewOpt(loc.Timezone)
1236			}
1237		}
1238	}
1239	return responses.ToolUnionParam{
1240		OfWebSearch: &wst,
1241	}
1242}
1243
1244// webSearchCallToMetadata converts an OpenAI web search call output
1245// into our structured metadata for round-tripping.
1246func webSearchCallToMetadata(itemID string, action responses.ResponseOutputItemUnionAction) *WebSearchCallMetadata {
1247	meta := &WebSearchCallMetadata{ItemID: itemID}
1248	if action.Type != "" {
1249		a := &WebSearchAction{
1250			Type:  action.Type,
1251			Query: action.Query,
1252		}
1253		for _, src := range action.Sources {
1254			a.Sources = append(a.Sources, WebSearchSource{
1255				Type: string(src.Type),
1256				URL:  src.URL,
1257			})
1258		}
1259		meta.Action = a
1260	}
1261	return meta
1262}
1263
1264// GetReasoningMetadata extracts reasoning metadata from provider options for responses models.
1265func GetReasoningMetadata(providerOptions fantasy.ProviderOptions) *ResponsesReasoningMetadata {
1266	if openaiResponsesOptions, ok := providerOptions[Name]; ok {
1267		if reasoning, ok := openaiResponsesOptions.(*ResponsesReasoningMetadata); ok {
1268			return reasoning
1269		}
1270	}
1271	return nil
1272}
1273
1274type ongoingToolCall struct {
1275	toolName   string
1276	toolCallID string
1277}
1278
1279type reasoningState struct {
1280	metadata *ResponsesReasoningMetadata
1281}
1282
1283// GenerateObject implements fantasy.LanguageModel.
1284func (o responsesLanguageModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
1285	switch o.objectMode {
1286	case fantasy.ObjectModeText:
1287		return object.GenerateWithText(ctx, o, call)
1288	case fantasy.ObjectModeTool:
1289		return object.GenerateWithTool(ctx, o, call)
1290	default:
1291		return o.generateObjectWithJSONMode(ctx, call)
1292	}
1293}
1294
1295// StreamObject implements fantasy.LanguageModel.
1296func (o responsesLanguageModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
1297	switch o.objectMode {
1298	case fantasy.ObjectModeTool:
1299		return object.StreamWithTool(ctx, o, call)
1300	case fantasy.ObjectModeText:
1301		return object.StreamWithText(ctx, o, call)
1302	default:
1303		return o.streamObjectWithJSONMode(ctx, call)
1304	}
1305}
1306
1307func (o responsesLanguageModel) generateObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
1308	// Convert our Schema to OpenAI's JSON Schema format
1309	jsonSchemaMap := schema.ToMap(call.Schema)
1310
1311	// Add additionalProperties: false recursively for strict mode (OpenAI requirement)
1312	addAdditionalPropertiesFalse(jsonSchemaMap)
1313
1314	schemaName := call.SchemaName
1315	if schemaName == "" {
1316		schemaName = "response"
1317	}
1318
1319	// Build request using prepareParams
1320	fantasyCall := fantasy.Call{
1321		Prompt:           call.Prompt,
1322		MaxOutputTokens:  call.MaxOutputTokens,
1323		Temperature:      call.Temperature,
1324		TopP:             call.TopP,
1325		PresencePenalty:  call.PresencePenalty,
1326		FrequencyPenalty: call.FrequencyPenalty,
1327		ProviderOptions:  call.ProviderOptions,
1328	}
1329
1330	params, warnings, err := o.prepareParams(fantasyCall)
1331	if err != nil {
1332		return nil, err
1333	}
1334
1335	// Add structured output via Text.Format field
1336	params.Text = responses.ResponseTextConfigParam{
1337		Format: responses.ResponseFormatTextConfigParamOfJSONSchema(schemaName, jsonSchemaMap),
1338	}
1339
1340	// Make request
1341	response, err := o.client.Responses.New(ctx, *params, objectCallUARequestOptions(call)...)
1342	if err != nil {
1343		return nil, toProviderErr(err)
1344	}
1345
1346	if response.Error.Message != "" {
1347		return nil, &fantasy.Error{
1348			Title:   "provider error",
1349			Message: fmt.Sprintf("%s (code: %s)", response.Error.Message, response.Error.Code),
1350		}
1351	}
1352
1353	// Extract JSON text from response
1354	var jsonText string
1355	for _, outputItem := range response.Output {
1356		if outputItem.Type == "message" {
1357			for _, contentPart := range outputItem.Content {
1358				if contentPart.Type == "output_text" {
1359					jsonText = contentPart.Text
1360					break
1361				}
1362			}
1363		}
1364	}
1365
1366	if jsonText == "" {
1367		usage := fantasy.Usage{
1368			InputTokens:  response.Usage.InputTokens,
1369			OutputTokens: response.Usage.OutputTokens,
1370			TotalTokens:  response.Usage.InputTokens + response.Usage.OutputTokens,
1371		}
1372		finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, false)
1373		return nil, &fantasy.NoObjectGeneratedError{
1374			RawText:      "",
1375			ParseError:   fmt.Errorf("no text content in response"),
1376			Usage:        usage,
1377			FinishReason: finishReason,
1378		}
1379	}
1380
1381	// Parse and validate
1382	var obj any
1383	if call.RepairText != nil {
1384		obj, err = schema.ParseAndValidateWithRepair(ctx, jsonText, call.Schema, call.RepairText)
1385	} else {
1386		obj, err = schema.ParseAndValidate(jsonText, call.Schema)
1387	}
1388
1389	usage := responsesUsage(*response)
1390	finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, false)
1391
1392	if err != nil {
1393		// Add usage info to error
1394		if nogErr, ok := err.(*fantasy.NoObjectGeneratedError); ok {
1395			nogErr.Usage = usage
1396			nogErr.FinishReason = finishReason
1397		}
1398		return nil, err
1399	}
1400
1401	return &fantasy.ObjectResponse{
1402		Object:           obj,
1403		RawText:          jsonText,
1404		Usage:            usage,
1405		FinishReason:     finishReason,
1406		Warnings:         warnings,
1407		ProviderMetadata: responsesProviderMetadata(response.ID),
1408	}, nil
1409}
1410
1411func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
1412	// Convert our Schema to OpenAI's JSON Schema format
1413	jsonSchemaMap := schema.ToMap(call.Schema)
1414
1415	// Add additionalProperties: false recursively for strict mode (OpenAI requirement)
1416	addAdditionalPropertiesFalse(jsonSchemaMap)
1417
1418	schemaName := call.SchemaName
1419	if schemaName == "" {
1420		schemaName = "response"
1421	}
1422
1423	// Build request using prepareParams
1424	fantasyCall := fantasy.Call{
1425		Prompt:           call.Prompt,
1426		MaxOutputTokens:  call.MaxOutputTokens,
1427		Temperature:      call.Temperature,
1428		TopP:             call.TopP,
1429		PresencePenalty:  call.PresencePenalty,
1430		FrequencyPenalty: call.FrequencyPenalty,
1431		ProviderOptions:  call.ProviderOptions,
1432	}
1433
1434	params, warnings, err := o.prepareParams(fantasyCall)
1435	if err != nil {
1436		return nil, err
1437	}
1438
1439	// Add structured output via Text.Format field
1440	params.Text = responses.ResponseTextConfigParam{
1441		Format: responses.ResponseFormatTextConfigParamOfJSONSchema(schemaName, jsonSchemaMap),
1442	}
1443
1444	stream := o.client.Responses.NewStreaming(ctx, *params, objectCallUARequestOptions(call)...)
1445
1446	return func(yield func(fantasy.ObjectStreamPart) bool) {
1447		if len(warnings) > 0 {
1448			if !yield(fantasy.ObjectStreamPart{
1449				Type:     fantasy.ObjectStreamPartTypeObject,
1450				Warnings: warnings,
1451			}) {
1452				return
1453			}
1454		}
1455
1456		var accumulated string
1457		var lastParsedObject any
1458		var usage fantasy.Usage
1459		var finishReason fantasy.FinishReason
1460		// responseID tracks the server-assigned response ID. It's first set from the
1461		// response.created event and may be overwritten by response.completed or
1462		// response.incomplete events. Per the OpenAI API contract, these IDs are
1463		// identical; the overwrites ensure we have the final value even if an event
1464		// is missed.
1465		var responseID string
1466		var streamErr error
1467		hasFunctionCall := false
1468
1469		for stream.Next() {
1470			event := stream.Current()
1471
1472			switch event.Type {
1473			case "response.created":
1474				created := event.AsResponseCreated()
1475				responseID = created.Response.ID
1476
1477			case "response.output_text.delta":
1478				textDelta := event.AsResponseOutputTextDelta()
1479				accumulated += textDelta.Delta
1480
1481				// Try to parse the accumulated text
1482				obj, state, parseErr := schema.ParsePartialJSON(accumulated)
1483
1484				// If we successfully parsed, validate and emit
1485				if state == schema.ParseStateSuccessful || state == schema.ParseStateRepaired {
1486					if err := schema.ValidateAgainstSchema(obj, call.Schema); err == nil {
1487						// Only emit if object is different from last
1488						if !reflect.DeepEqual(obj, lastParsedObject) {
1489							if !yield(fantasy.ObjectStreamPart{
1490								Type:   fantasy.ObjectStreamPartTypeObject,
1491								Object: obj,
1492							}) {
1493								return
1494							}
1495							lastParsedObject = obj
1496						}
1497					}
1498				}
1499
1500				// If parsing failed and we have a repair function, try it
1501				if state == schema.ParseStateFailed && call.RepairText != nil {
1502					repairedText, repairErr := call.RepairText(ctx, accumulated, parseErr)
1503					if repairErr == nil {
1504						obj2, state2, _ := schema.ParsePartialJSON(repairedText)
1505						if (state2 == schema.ParseStateSuccessful || state2 == schema.ParseStateRepaired) &&
1506							schema.ValidateAgainstSchema(obj2, call.Schema) == nil {
1507							if !reflect.DeepEqual(obj2, lastParsedObject) {
1508								if !yield(fantasy.ObjectStreamPart{
1509									Type:   fantasy.ObjectStreamPartTypeObject,
1510									Object: obj2,
1511								}) {
1512									return
1513								}
1514								lastParsedObject = obj2
1515							}
1516						}
1517					}
1518				}
1519
1520			case "response.completed":
1521				completed := event.AsResponseCompleted()
1522				responseID = completed.Response.ID
1523				finishReason = mapResponsesFinishReason(completed.Response.IncompleteDetails.Reason, hasFunctionCall)
1524				usage = responsesUsage(completed.Response)
1525
1526			case "response.incomplete":
1527				incomplete := event.AsResponseIncomplete()
1528				responseID = incomplete.Response.ID
1529				finishReason = mapResponsesFinishReason(incomplete.Response.IncompleteDetails.Reason, hasFunctionCall)
1530				usage = responsesUsage(incomplete.Response)
1531
1532			case "error":
1533				errorEvent := event.AsError()
1534				streamErr = fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code)
1535				if !yield(fantasy.ObjectStreamPart{
1536					Type:  fantasy.ObjectStreamPartTypeError,
1537					Error: streamErr,
1538				}) {
1539					return
1540				}
1541				return
1542			}
1543		}
1544
1545		err := stream.Err()
1546		if err != nil {
1547			yield(fantasy.ObjectStreamPart{
1548				Type:  fantasy.ObjectStreamPartTypeError,
1549				Error: toProviderErr(err),
1550			})
1551			return
1552		}
1553
1554		// Final validation and emit
1555		if streamErr == nil && lastParsedObject != nil {
1556			yield(fantasy.ObjectStreamPart{
1557				Type:             fantasy.ObjectStreamPartTypeFinish,
1558				Usage:            usage,
1559				FinishReason:     finishReason,
1560				ProviderMetadata: responsesProviderMetadata(responseID),
1561			})
1562		} else if streamErr == nil && lastParsedObject == nil {
1563			// No object was generated
1564			yield(fantasy.ObjectStreamPart{
1565				Type: fantasy.ObjectStreamPartTypeError,
1566				Error: &fantasy.NoObjectGeneratedError{
1567					RawText:      accumulated,
1568					ParseError:   fmt.Errorf("no valid object generated in stream"),
1569					Usage:        usage,
1570					FinishReason: finishReason,
1571				},
1572			})
1573		}
1574	}, nil
1575}