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