responses_language_model.go

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