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