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