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.reasoning_summary_part.added":
1091				added := event.AsResponseReasoningSummaryPartAdded()
1092				state := activeReasoning[added.ItemID]
1093				if state != nil {
1094					state.metadata.Summary = append(state.metadata.Summary, "")
1095					activeReasoning[added.ItemID] = state
1096					if !yield(fantasy.StreamPart{
1097						Type:  fantasy.StreamPartTypeReasoningDelta,
1098						ID:    added.ItemID,
1099						Delta: "\n",
1100						ProviderMetadata: fantasy.ProviderMetadata{
1101							Name: state.metadata,
1102						},
1103					}) {
1104						return
1105					}
1106				}
1107
1108			case "response.reasoning_summary_text.delta":
1109				textDelta := event.AsResponseReasoningSummaryTextDelta()
1110				state := activeReasoning[textDelta.ItemID]
1111				if state != nil {
1112					if len(state.metadata.Summary)-1 >= int(textDelta.SummaryIndex) {
1113						state.metadata.Summary[textDelta.SummaryIndex] += textDelta.Delta
1114					}
1115					activeReasoning[textDelta.ItemID] = state
1116					if !yield(fantasy.StreamPart{
1117						Type:  fantasy.StreamPartTypeReasoningDelta,
1118						ID:    textDelta.ItemID,
1119						Delta: textDelta.Delta,
1120						ProviderMetadata: fantasy.ProviderMetadata{
1121							Name: state.metadata,
1122						},
1123					}) {
1124						return
1125					}
1126				}
1127
1128			case "response.completed":
1129				completed := event.AsResponseCompleted()
1130				responseID = completed.Response.ID
1131				finishReason = mapResponsesFinishReason(completed.Response.IncompleteDetails.Reason, hasFunctionCall)
1132				usage = responsesUsage(completed.Response)
1133
1134			case "response.incomplete":
1135				incomplete := event.AsResponseIncomplete()
1136				responseID = incomplete.Response.ID
1137				finishReason = mapResponsesFinishReason(incomplete.Response.IncompleteDetails.Reason, hasFunctionCall)
1138				usage = responsesUsage(incomplete.Response)
1139
1140			case "error":
1141				errorEvent := event.AsError()
1142				if !yield(fantasy.StreamPart{
1143					Type:  fantasy.StreamPartTypeError,
1144					Error: fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code),
1145				}) {
1146					return
1147				}
1148				return
1149			}
1150		}
1151
1152		err := stream.Err()
1153		if err != nil {
1154			yield(fantasy.StreamPart{
1155				Type:  fantasy.StreamPartTypeError,
1156				Error: toProviderErr(err),
1157			})
1158			return
1159		}
1160
1161		yield(fantasy.StreamPart{
1162			Type:             fantasy.StreamPartTypeFinish,
1163			Usage:            usage,
1164			FinishReason:     finishReason,
1165			ProviderMetadata: responsesProviderMetadata(responseID),
1166		})
1167	}, nil
1168}
1169
1170// toWebSearchToolParam converts a ProviderDefinedTool with ID
1171// "web_search" into the OpenAI SDK's WebSearchToolParam.
1172func toWebSearchToolParam(pt fantasy.ProviderDefinedTool) responses.ToolUnionParam {
1173	wst := responses.WebSearchToolParam{
1174		Type: responses.WebSearchToolTypeWebSearch,
1175	}
1176	if pt.Args != nil {
1177		if size, ok := pt.Args["search_context_size"].(SearchContextSize); ok && size != "" {
1178			wst.SearchContextSize = responses.WebSearchToolSearchContextSize(size)
1179		}
1180		// Also accept plain string for search_context_size.
1181		if size, ok := pt.Args["search_context_size"].(string); ok && size != "" {
1182			wst.SearchContextSize = responses.WebSearchToolSearchContextSize(size)
1183		}
1184		if domains, ok := pt.Args["allowed_domains"].([]string); ok && len(domains) > 0 {
1185			wst.Filters.AllowedDomains = domains
1186		}
1187		if loc, ok := pt.Args["user_location"].(*WebSearchUserLocation); ok && loc != nil {
1188			if loc.City != "" {
1189				wst.UserLocation.City = param.NewOpt(loc.City)
1190			}
1191			if loc.Region != "" {
1192				wst.UserLocation.Region = param.NewOpt(loc.Region)
1193			}
1194			if loc.Country != "" {
1195				wst.UserLocation.Country = param.NewOpt(loc.Country)
1196			}
1197			if loc.Timezone != "" {
1198				wst.UserLocation.Timezone = param.NewOpt(loc.Timezone)
1199			}
1200		}
1201	}
1202	return responses.ToolUnionParam{
1203		OfWebSearch: &wst,
1204	}
1205}
1206
1207// webSearchCallToMetadata converts an OpenAI web search call output
1208// into our structured metadata for round-tripping.
1209func webSearchCallToMetadata(itemID string, action responses.ResponseOutputItemUnionAction) *WebSearchCallMetadata {
1210	meta := &WebSearchCallMetadata{ItemID: itemID}
1211	if action.Type != "" {
1212		a := &WebSearchAction{
1213			Type:  action.Type,
1214			Query: action.Query,
1215		}
1216		for _, src := range action.Sources {
1217			a.Sources = append(a.Sources, WebSearchSource{
1218				Type: string(src.Type),
1219				URL:  src.URL,
1220			})
1221		}
1222		meta.Action = a
1223	}
1224	return meta
1225}
1226
1227// GetReasoningMetadata extracts reasoning metadata from provider options for responses models.
1228func GetReasoningMetadata(providerOptions fantasy.ProviderOptions) *ResponsesReasoningMetadata {
1229	if openaiResponsesOptions, ok := providerOptions[Name]; ok {
1230		if reasoning, ok := openaiResponsesOptions.(*ResponsesReasoningMetadata); ok {
1231			return reasoning
1232		}
1233	}
1234	return nil
1235}
1236
1237type ongoingToolCall struct {
1238	toolName   string
1239	toolCallID string
1240}
1241
1242type reasoningState struct {
1243	metadata *ResponsesReasoningMetadata
1244}
1245
1246// GenerateObject implements fantasy.LanguageModel.
1247func (o responsesLanguageModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
1248	switch o.objectMode {
1249	case fantasy.ObjectModeText:
1250		return object.GenerateWithText(ctx, o, call)
1251	case fantasy.ObjectModeTool:
1252		return object.GenerateWithTool(ctx, o, call)
1253	default:
1254		return o.generateObjectWithJSONMode(ctx, call)
1255	}
1256}
1257
1258// StreamObject implements fantasy.LanguageModel.
1259func (o responsesLanguageModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
1260	switch o.objectMode {
1261	case fantasy.ObjectModeTool:
1262		return object.StreamWithTool(ctx, o, call)
1263	case fantasy.ObjectModeText:
1264		return object.StreamWithText(ctx, o, call)
1265	default:
1266		return o.streamObjectWithJSONMode(ctx, call)
1267	}
1268}
1269
1270func (o responsesLanguageModel) generateObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
1271	// Convert our Schema to OpenAI's JSON Schema format
1272	jsonSchemaMap := schema.ToMap(call.Schema)
1273
1274	// Add additionalProperties: false recursively for strict mode (OpenAI requirement)
1275	addAdditionalPropertiesFalse(jsonSchemaMap)
1276
1277	schemaName := call.SchemaName
1278	if schemaName == "" {
1279		schemaName = "response"
1280	}
1281
1282	// Build request using prepareParams
1283	fantasyCall := fantasy.Call{
1284		Prompt:           call.Prompt,
1285		MaxOutputTokens:  call.MaxOutputTokens,
1286		Temperature:      call.Temperature,
1287		TopP:             call.TopP,
1288		PresencePenalty:  call.PresencePenalty,
1289		FrequencyPenalty: call.FrequencyPenalty,
1290		ProviderOptions:  call.ProviderOptions,
1291	}
1292
1293	params, warnings, err := o.prepareParams(fantasyCall)
1294	if err != nil {
1295		return nil, err
1296	}
1297
1298	// Add structured output via Text.Format field
1299	params.Text = responses.ResponseTextConfigParam{
1300		Format: responses.ResponseFormatTextConfigParamOfJSONSchema(schemaName, jsonSchemaMap),
1301	}
1302
1303	// Make request
1304	response, err := o.client.Responses.New(ctx, *params, objectCallUARequestOptions(call)...)
1305	if err != nil {
1306		return nil, toProviderErr(err)
1307	}
1308
1309	if response.Error.Message != "" {
1310		return nil, &fantasy.Error{
1311			Title:   "provider error",
1312			Message: fmt.Sprintf("%s (code: %s)", response.Error.Message, response.Error.Code),
1313		}
1314	}
1315
1316	// Extract JSON text from response
1317	var jsonText string
1318	for _, outputItem := range response.Output {
1319		if outputItem.Type == "message" {
1320			for _, contentPart := range outputItem.Content {
1321				if contentPart.Type == "output_text" {
1322					jsonText = contentPart.Text
1323					break
1324				}
1325			}
1326		}
1327	}
1328
1329	if jsonText == "" {
1330		usage := fantasy.Usage{
1331			InputTokens:  response.Usage.InputTokens,
1332			OutputTokens: response.Usage.OutputTokens,
1333			TotalTokens:  response.Usage.InputTokens + response.Usage.OutputTokens,
1334		}
1335		finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, false)
1336		return nil, &fantasy.NoObjectGeneratedError{
1337			RawText:      "",
1338			ParseError:   fmt.Errorf("no text content in response"),
1339			Usage:        usage,
1340			FinishReason: finishReason,
1341		}
1342	}
1343
1344	// Parse and validate
1345	var obj any
1346	if call.RepairText != nil {
1347		obj, err = schema.ParseAndValidateWithRepair(ctx, jsonText, call.Schema, call.RepairText)
1348	} else {
1349		obj, err = schema.ParseAndValidate(jsonText, call.Schema)
1350	}
1351
1352	usage := responsesUsage(*response)
1353	finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, false)
1354
1355	if err != nil {
1356		// Add usage info to error
1357		if nogErr, ok := err.(*fantasy.NoObjectGeneratedError); ok {
1358			nogErr.Usage = usage
1359			nogErr.FinishReason = finishReason
1360		}
1361		return nil, err
1362	}
1363
1364	return &fantasy.ObjectResponse{
1365		Object:           obj,
1366		RawText:          jsonText,
1367		Usage:            usage,
1368		FinishReason:     finishReason,
1369		Warnings:         warnings,
1370		ProviderMetadata: responsesProviderMetadata(response.ID),
1371	}, nil
1372}
1373
1374func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
1375	// Convert our Schema to OpenAI's JSON Schema format
1376	jsonSchemaMap := schema.ToMap(call.Schema)
1377
1378	// Add additionalProperties: false recursively for strict mode (OpenAI requirement)
1379	addAdditionalPropertiesFalse(jsonSchemaMap)
1380
1381	schemaName := call.SchemaName
1382	if schemaName == "" {
1383		schemaName = "response"
1384	}
1385
1386	// Build request using prepareParams
1387	fantasyCall := fantasy.Call{
1388		Prompt:           call.Prompt,
1389		MaxOutputTokens:  call.MaxOutputTokens,
1390		Temperature:      call.Temperature,
1391		TopP:             call.TopP,
1392		PresencePenalty:  call.PresencePenalty,
1393		FrequencyPenalty: call.FrequencyPenalty,
1394		ProviderOptions:  call.ProviderOptions,
1395	}
1396
1397	params, warnings, err := o.prepareParams(fantasyCall)
1398	if err != nil {
1399		return nil, err
1400	}
1401
1402	// Add structured output via Text.Format field
1403	params.Text = responses.ResponseTextConfigParam{
1404		Format: responses.ResponseFormatTextConfigParamOfJSONSchema(schemaName, jsonSchemaMap),
1405	}
1406
1407	stream := o.client.Responses.NewStreaming(ctx, *params, objectCallUARequestOptions(call)...)
1408
1409	return func(yield func(fantasy.ObjectStreamPart) bool) {
1410		if len(warnings) > 0 {
1411			if !yield(fantasy.ObjectStreamPart{
1412				Type:     fantasy.ObjectStreamPartTypeObject,
1413				Warnings: warnings,
1414			}) {
1415				return
1416			}
1417		}
1418
1419		var accumulated string
1420		var lastParsedObject any
1421		var usage fantasy.Usage
1422		var finishReason fantasy.FinishReason
1423		// responseID tracks the server-assigned response ID. It's first set from the
1424		// response.created event and may be overwritten by response.completed or
1425		// response.incomplete events. Per the OpenAI API contract, these IDs are
1426		// identical; the overwrites ensure we have the final value even if an event
1427		// is missed.
1428		var responseID string
1429		var streamErr error
1430		hasFunctionCall := false
1431
1432		for stream.Next() {
1433			event := stream.Current()
1434
1435			switch event.Type {
1436			case "response.created":
1437				created := event.AsResponseCreated()
1438				responseID = created.Response.ID
1439
1440			case "response.output_text.delta":
1441				textDelta := event.AsResponseOutputTextDelta()
1442				accumulated += textDelta.Delta
1443
1444				// Try to parse the accumulated text
1445				obj, state, parseErr := schema.ParsePartialJSON(accumulated)
1446
1447				// If we successfully parsed, validate and emit
1448				if state == schema.ParseStateSuccessful || state == schema.ParseStateRepaired {
1449					if err := schema.ValidateAgainstSchema(obj, call.Schema); err == nil {
1450						// Only emit if object is different from last
1451						if !reflect.DeepEqual(obj, lastParsedObject) {
1452							if !yield(fantasy.ObjectStreamPart{
1453								Type:   fantasy.ObjectStreamPartTypeObject,
1454								Object: obj,
1455							}) {
1456								return
1457							}
1458							lastParsedObject = obj
1459						}
1460					}
1461				}
1462
1463				// If parsing failed and we have a repair function, try it
1464				if state == schema.ParseStateFailed && call.RepairText != nil {
1465					repairedText, repairErr := call.RepairText(ctx, accumulated, parseErr)
1466					if repairErr == nil {
1467						obj2, state2, _ := schema.ParsePartialJSON(repairedText)
1468						if (state2 == schema.ParseStateSuccessful || state2 == schema.ParseStateRepaired) &&
1469							schema.ValidateAgainstSchema(obj2, call.Schema) == nil {
1470							if !reflect.DeepEqual(obj2, lastParsedObject) {
1471								if !yield(fantasy.ObjectStreamPart{
1472									Type:   fantasy.ObjectStreamPartTypeObject,
1473									Object: obj2,
1474								}) {
1475									return
1476								}
1477								lastParsedObject = obj2
1478							}
1479						}
1480					}
1481				}
1482
1483			case "response.completed":
1484				completed := event.AsResponseCompleted()
1485				responseID = completed.Response.ID
1486				finishReason = mapResponsesFinishReason(completed.Response.IncompleteDetails.Reason, hasFunctionCall)
1487				usage = responsesUsage(completed.Response)
1488
1489			case "response.incomplete":
1490				incomplete := event.AsResponseIncomplete()
1491				responseID = incomplete.Response.ID
1492				finishReason = mapResponsesFinishReason(incomplete.Response.IncompleteDetails.Reason, hasFunctionCall)
1493				usage = responsesUsage(incomplete.Response)
1494
1495			case "error":
1496				errorEvent := event.AsError()
1497				streamErr = fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code)
1498				if !yield(fantasy.ObjectStreamPart{
1499					Type:  fantasy.ObjectStreamPartTypeError,
1500					Error: streamErr,
1501				}) {
1502					return
1503				}
1504				return
1505			}
1506		}
1507
1508		err := stream.Err()
1509		if err != nil {
1510			yield(fantasy.ObjectStreamPart{
1511				Type:  fantasy.ObjectStreamPartTypeError,
1512				Error: toProviderErr(err),
1513			})
1514			return
1515		}
1516
1517		// Final validation and emit
1518		if streamErr == nil && lastParsedObject != nil {
1519			yield(fantasy.ObjectStreamPart{
1520				Type:             fantasy.ObjectStreamPartTypeFinish,
1521				Usage:            usage,
1522				FinishReason:     finishReason,
1523				ProviderMetadata: responsesProviderMetadata(responseID),
1524			})
1525		} else if streamErr == nil && lastParsedObject == nil {
1526			// No object was generated
1527			yield(fantasy.ObjectStreamPart{
1528				Type: fantasy.ObjectStreamPartTypeError,
1529				Error: &fantasy.NoObjectGeneratedError{
1530					RawText:      accumulated,
1531					ParseError:   fmt.Errorf("no valid object generated in stream"),
1532					Usage:        usage,
1533					FinishReason: finishReason,
1534				},
1535			})
1536		}
1537	}, nil
1538}