responses_language_model.go

   1package openai
   2
   3import (
   4	"context"
   5	"encoding/base64"
   6	"encoding/json"
   7	"fmt"
   8	"strings"
   9
  10	"charm.land/fantasy"
  11	"github.com/google/uuid"
  12	"github.com/openai/openai-go/v2"
  13	"github.com/openai/openai-go/v2/packages/param"
  14	"github.com/openai/openai-go/v2/responses"
  15	"github.com/openai/openai-go/v2/shared"
  16)
  17
  18const topLogprobsMax = 20
  19
  20type responsesLanguageModel struct {
  21	provider string
  22	modelID  string
  23	client   openai.Client
  24}
  25
  26// newResponsesLanguageModel implements a responses api model
  27// INFO: (kujtim) currently we do not support stored parameter we default it to false.
  28func newResponsesLanguageModel(modelID string, provider string, client openai.Client) responsesLanguageModel {
  29	return responsesLanguageModel{
  30		modelID:  modelID,
  31		provider: provider,
  32		client:   client,
  33	}
  34}
  35
  36func (o responsesLanguageModel) Model() string {
  37	return o.modelID
  38}
  39
  40func (o responsesLanguageModel) Provider() string {
  41	return o.provider
  42}
  43
  44type responsesModelConfig struct {
  45	isReasoningModel           bool
  46	systemMessageMode          string
  47	requiredAutoTruncation     bool
  48	supportsFlexProcessing     bool
  49	supportsPriorityProcessing bool
  50}
  51
  52func getResponsesModelConfig(modelID string) responsesModelConfig {
  53	supportsFlexProcessing := strings.HasPrefix(modelID, "o3") ||
  54		strings.HasPrefix(modelID, "o4-mini") ||
  55		(strings.HasPrefix(modelID, "gpt-5") && !strings.HasPrefix(modelID, "gpt-5-chat"))
  56
  57	supportsPriorityProcessing := strings.HasPrefix(modelID, "gpt-4") ||
  58		strings.HasPrefix(modelID, "gpt-5-mini") ||
  59		(strings.HasPrefix(modelID, "gpt-5") &&
  60			!strings.HasPrefix(modelID, "gpt-5-nano") &&
  61			!strings.HasPrefix(modelID, "gpt-5-chat")) ||
  62		strings.HasPrefix(modelID, "o3") ||
  63		strings.HasPrefix(modelID, "o4-mini")
  64
  65	defaults := responsesModelConfig{
  66		requiredAutoTruncation:     false,
  67		systemMessageMode:          "system",
  68		supportsFlexProcessing:     supportsFlexProcessing,
  69		supportsPriorityProcessing: supportsPriorityProcessing,
  70	}
  71
  72	if strings.HasPrefix(modelID, "gpt-5-chat") {
  73		return responsesModelConfig{
  74			isReasoningModel:           false,
  75			systemMessageMode:          defaults.systemMessageMode,
  76			requiredAutoTruncation:     defaults.requiredAutoTruncation,
  77			supportsFlexProcessing:     defaults.supportsFlexProcessing,
  78			supportsPriorityProcessing: defaults.supportsPriorityProcessing,
  79		}
  80	}
  81
  82	if strings.HasPrefix(modelID, "o") ||
  83		strings.HasPrefix(modelID, "gpt-5") ||
  84		strings.HasPrefix(modelID, "codex-") ||
  85		strings.HasPrefix(modelID, "computer-use") {
  86		if strings.HasPrefix(modelID, "o1-mini") || strings.HasPrefix(modelID, "o1-preview") {
  87			return responsesModelConfig{
  88				isReasoningModel:           true,
  89				systemMessageMode:          "remove",
  90				requiredAutoTruncation:     defaults.requiredAutoTruncation,
  91				supportsFlexProcessing:     defaults.supportsFlexProcessing,
  92				supportsPriorityProcessing: defaults.supportsPriorityProcessing,
  93			}
  94		}
  95
  96		return responsesModelConfig{
  97			isReasoningModel:           true,
  98			systemMessageMode:          "developer",
  99			requiredAutoTruncation:     defaults.requiredAutoTruncation,
 100			supportsFlexProcessing:     defaults.supportsFlexProcessing,
 101			supportsPriorityProcessing: defaults.supportsPriorityProcessing,
 102		}
 103	}
 104
 105	return responsesModelConfig{
 106		isReasoningModel:           false,
 107		systemMessageMode:          defaults.systemMessageMode,
 108		requiredAutoTruncation:     defaults.requiredAutoTruncation,
 109		supportsFlexProcessing:     defaults.supportsFlexProcessing,
 110		supportsPriorityProcessing: defaults.supportsPriorityProcessing,
 111	}
 112}
 113
 114func (o responsesLanguageModel) prepareParams(call fantasy.Call) (*responses.ResponseNewParams, []fantasy.CallWarning) {
 115	var warnings []fantasy.CallWarning
 116	params := &responses.ResponseNewParams{
 117		Store: param.NewOpt(false),
 118	}
 119
 120	modelConfig := getResponsesModelConfig(o.modelID)
 121
 122	if call.TopK != nil {
 123		warnings = append(warnings, fantasy.CallWarning{
 124			Type:    fantasy.CallWarningTypeUnsupportedSetting,
 125			Setting: "topK",
 126		})
 127	}
 128
 129	if call.PresencePenalty != nil {
 130		warnings = append(warnings, fantasy.CallWarning{
 131			Type:    fantasy.CallWarningTypeUnsupportedSetting,
 132			Setting: "presencePenalty",
 133		})
 134	}
 135
 136	if call.FrequencyPenalty != nil {
 137		warnings = append(warnings, fantasy.CallWarning{
 138			Type:    fantasy.CallWarningTypeUnsupportedSetting,
 139			Setting: "frequencyPenalty",
 140		})
 141	}
 142
 143	var openaiOptions *ResponsesProviderOptions
 144	if opts, ok := call.ProviderOptions[Name]; ok {
 145		if typedOpts, ok := opts.(*ResponsesProviderOptions); ok {
 146			openaiOptions = typedOpts
 147		}
 148	}
 149
 150	input, inputWarnings := toResponsesPrompt(call.Prompt, modelConfig.systemMessageMode)
 151	warnings = append(warnings, inputWarnings...)
 152
 153	var include []IncludeType
 154
 155	addInclude := func(key IncludeType) {
 156		include = append(include, key)
 157	}
 158
 159	topLogprobs := 0
 160	if openaiOptions != nil && openaiOptions.Logprobs != nil {
 161		switch v := openaiOptions.Logprobs.(type) {
 162		case bool:
 163			if v {
 164				topLogprobs = topLogprobsMax
 165			}
 166		case float64:
 167			topLogprobs = int(v)
 168		case int:
 169			topLogprobs = v
 170		}
 171	}
 172
 173	if topLogprobs > 0 {
 174		addInclude(IncludeMessageOutputTextLogprobs)
 175	}
 176
 177	params.Model = o.modelID
 178	params.Input = responses.ResponseNewParamsInputUnion{
 179		OfInputItemList: input,
 180	}
 181
 182	if call.Temperature != nil {
 183		params.Temperature = param.NewOpt(*call.Temperature)
 184	}
 185	if call.TopP != nil {
 186		params.TopP = param.NewOpt(*call.TopP)
 187	}
 188	if call.MaxOutputTokens != nil {
 189		params.MaxOutputTokens = param.NewOpt(*call.MaxOutputTokens)
 190	}
 191
 192	if openaiOptions != nil {
 193		if openaiOptions.MaxToolCalls != nil {
 194			params.MaxToolCalls = param.NewOpt(*openaiOptions.MaxToolCalls)
 195		}
 196		if openaiOptions.Metadata != nil {
 197			metadata := make(shared.Metadata)
 198			for k, v := range openaiOptions.Metadata {
 199				if str, ok := v.(string); ok {
 200					metadata[k] = str
 201				}
 202			}
 203			params.Metadata = metadata
 204		}
 205		if openaiOptions.ParallelToolCalls != nil {
 206			params.ParallelToolCalls = param.NewOpt(*openaiOptions.ParallelToolCalls)
 207		}
 208		if openaiOptions.User != nil {
 209			params.User = param.NewOpt(*openaiOptions.User)
 210		}
 211		if openaiOptions.Instructions != nil {
 212			params.Instructions = param.NewOpt(*openaiOptions.Instructions)
 213		}
 214		if openaiOptions.ServiceTier != nil {
 215			params.ServiceTier = responses.ResponseNewParamsServiceTier(*openaiOptions.ServiceTier)
 216		}
 217		if openaiOptions.PromptCacheKey != nil {
 218			params.PromptCacheKey = param.NewOpt(*openaiOptions.PromptCacheKey)
 219		}
 220		if openaiOptions.SafetyIdentifier != nil {
 221			params.SafetyIdentifier = param.NewOpt(*openaiOptions.SafetyIdentifier)
 222		}
 223		if topLogprobs > 0 {
 224			params.TopLogprobs = param.NewOpt(int64(topLogprobs))
 225		}
 226
 227		if len(openaiOptions.Include) > 0 {
 228			include = append(include, openaiOptions.Include...)
 229		}
 230
 231		if modelConfig.isReasoningModel && (openaiOptions.ReasoningEffort != nil || openaiOptions.ReasoningSummary != nil) {
 232			reasoning := shared.ReasoningParam{}
 233			if openaiOptions.ReasoningEffort != nil {
 234				reasoning.Effort = shared.ReasoningEffort(*openaiOptions.ReasoningEffort)
 235			}
 236			if openaiOptions.ReasoningSummary != nil {
 237				reasoning.Summary = shared.ReasoningSummary(*openaiOptions.ReasoningSummary)
 238			}
 239			params.Reasoning = reasoning
 240		}
 241	}
 242
 243	if modelConfig.requiredAutoTruncation {
 244		params.Truncation = responses.ResponseNewParamsTruncationAuto
 245	}
 246
 247	if len(include) > 0 {
 248		includeParams := make([]responses.ResponseIncludable, len(include))
 249		for i, inc := range include {
 250			includeParams[i] = responses.ResponseIncludable(string(inc))
 251		}
 252		params.Include = includeParams
 253	}
 254
 255	if modelConfig.isReasoningModel {
 256		if call.Temperature != nil {
 257			params.Temperature = param.Opt[float64]{}
 258			warnings = append(warnings, fantasy.CallWarning{
 259				Type:    fantasy.CallWarningTypeUnsupportedSetting,
 260				Setting: "temperature",
 261				Details: "temperature is not supported for reasoning models",
 262			})
 263		}
 264
 265		if call.TopP != nil {
 266			params.TopP = param.Opt[float64]{}
 267			warnings = append(warnings, fantasy.CallWarning{
 268				Type:    fantasy.CallWarningTypeUnsupportedSetting,
 269				Setting: "topP",
 270				Details: "topP is not supported for reasoning models",
 271			})
 272		}
 273	} else {
 274		if openaiOptions != nil {
 275			if openaiOptions.ReasoningEffort != nil {
 276				warnings = append(warnings, fantasy.CallWarning{
 277					Type:    fantasy.CallWarningTypeUnsupportedSetting,
 278					Setting: "reasoningEffort",
 279					Details: "reasoningEffort is not supported for non-reasoning models",
 280				})
 281			}
 282
 283			if openaiOptions.ReasoningSummary != nil {
 284				warnings = append(warnings, fantasy.CallWarning{
 285					Type:    fantasy.CallWarningTypeUnsupportedSetting,
 286					Setting: "reasoningSummary",
 287					Details: "reasoningSummary is not supported for non-reasoning models",
 288				})
 289			}
 290		}
 291	}
 292
 293	if openaiOptions != nil && openaiOptions.ServiceTier != nil {
 294		if *openaiOptions.ServiceTier == ServiceTierFlex && !modelConfig.supportsFlexProcessing {
 295			warnings = append(warnings, fantasy.CallWarning{
 296				Type:    fantasy.CallWarningTypeUnsupportedSetting,
 297				Setting: "serviceTier",
 298				Details: "flex processing is only available for o3, o4-mini, and gpt-5 models",
 299			})
 300			params.ServiceTier = ""
 301		}
 302
 303		if *openaiOptions.ServiceTier == ServiceTierPriority && !modelConfig.supportsPriorityProcessing {
 304			warnings = append(warnings, fantasy.CallWarning{
 305				Type:    fantasy.CallWarningTypeUnsupportedSetting,
 306				Setting: "serviceTier",
 307				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",
 308			})
 309			params.ServiceTier = ""
 310		}
 311	}
 312
 313	tools, toolChoice, toolWarnings := toResponsesTools(call.Tools, call.ToolChoice, openaiOptions)
 314	warnings = append(warnings, toolWarnings...)
 315
 316	if len(tools) > 0 {
 317		params.Tools = tools
 318		params.ToolChoice = toolChoice
 319	}
 320
 321	return params, warnings
 322}
 323
 324func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string) (responses.ResponseInputParam, []fantasy.CallWarning) {
 325	var input responses.ResponseInputParam
 326	var warnings []fantasy.CallWarning
 327
 328	for _, msg := range prompt {
 329		switch msg.Role {
 330		case fantasy.MessageRoleSystem:
 331			var systemText string
 332			for _, c := range msg.Content {
 333				if c.GetType() != fantasy.ContentTypeText {
 334					warnings = append(warnings, fantasy.CallWarning{
 335						Type:    fantasy.CallWarningTypeOther,
 336						Message: "system prompt can only have text content",
 337					})
 338					continue
 339				}
 340				textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
 341				if !ok {
 342					warnings = append(warnings, fantasy.CallWarning{
 343						Type:    fantasy.CallWarningTypeOther,
 344						Message: "system prompt text part does not have the right type",
 345					})
 346					continue
 347				}
 348				if strings.TrimSpace(textPart.Text) != "" {
 349					systemText += textPart.Text
 350				}
 351			}
 352
 353			if systemText == "" {
 354				warnings = append(warnings, fantasy.CallWarning{
 355					Type:    fantasy.CallWarningTypeOther,
 356					Message: "system prompt has no text parts",
 357				})
 358				continue
 359			}
 360
 361			switch systemMessageMode {
 362			case "system":
 363				input = append(input, responses.ResponseInputItemParamOfMessage(systemText, responses.EasyInputMessageRoleSystem))
 364			case "developer":
 365				input = append(input, responses.ResponseInputItemParamOfMessage(systemText, responses.EasyInputMessageRoleDeveloper))
 366			case "remove":
 367				warnings = append(warnings, fantasy.CallWarning{
 368					Type:    fantasy.CallWarningTypeOther,
 369					Message: "system messages are removed for this model",
 370				})
 371			}
 372
 373		case fantasy.MessageRoleUser:
 374			var contentParts responses.ResponseInputMessageContentListParam
 375			for i, c := range msg.Content {
 376				switch c.GetType() {
 377				case fantasy.ContentTypeText:
 378					textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
 379					if !ok {
 380						warnings = append(warnings, fantasy.CallWarning{
 381							Type:    fantasy.CallWarningTypeOther,
 382							Message: "user message text part does not have the right type",
 383						})
 384						continue
 385					}
 386					contentParts = append(contentParts, responses.ResponseInputContentUnionParam{
 387						OfInputText: &responses.ResponseInputTextParam{
 388							Type: "input_text",
 389							Text: textPart.Text,
 390						},
 391					})
 392
 393				case fantasy.ContentTypeFile:
 394					filePart, ok := fantasy.AsContentType[fantasy.FilePart](c)
 395					if !ok {
 396						warnings = append(warnings, fantasy.CallWarning{
 397							Type:    fantasy.CallWarningTypeOther,
 398							Message: "user message file part does not have the right type",
 399						})
 400						continue
 401					}
 402
 403					if strings.HasPrefix(filePart.MediaType, "image/") {
 404						base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
 405						imageURL := fmt.Sprintf("data:%s;base64,%s", filePart.MediaType, base64Encoded)
 406						contentParts = append(contentParts, responses.ResponseInputContentUnionParam{
 407							OfInputImage: &responses.ResponseInputImageParam{
 408								Type:     "input_image",
 409								ImageURL: param.NewOpt(imageURL),
 410							},
 411						})
 412					} else if filePart.MediaType == "application/pdf" {
 413						base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
 414						fileData := fmt.Sprintf("data:application/pdf;base64,%s", base64Encoded)
 415						filename := filePart.Filename
 416						if filename == "" {
 417							filename = fmt.Sprintf("part-%d.pdf", i)
 418						}
 419						contentParts = append(contentParts, responses.ResponseInputContentUnionParam{
 420							OfInputFile: &responses.ResponseInputFileParam{
 421								Type:     "input_file",
 422								Filename: param.NewOpt(filename),
 423								FileData: param.NewOpt(fileData),
 424							},
 425						})
 426					} else {
 427						warnings = append(warnings, fantasy.CallWarning{
 428							Type:    fantasy.CallWarningTypeOther,
 429							Message: fmt.Sprintf("file part media type %s not supported", filePart.MediaType),
 430						})
 431					}
 432				}
 433			}
 434
 435			input = append(input, responses.ResponseInputItemParamOfMessage(contentParts, responses.EasyInputMessageRoleUser))
 436
 437		case fantasy.MessageRoleAssistant:
 438			for _, c := range msg.Content {
 439				switch c.GetType() {
 440				case fantasy.ContentTypeText:
 441					textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
 442					if !ok {
 443						warnings = append(warnings, fantasy.CallWarning{
 444							Type:    fantasy.CallWarningTypeOther,
 445							Message: "assistant message text part does not have the right type",
 446						})
 447						continue
 448					}
 449					input = append(input, responses.ResponseInputItemParamOfMessage(textPart.Text, responses.EasyInputMessageRoleAssistant))
 450
 451				case fantasy.ContentTypeToolCall:
 452					toolCallPart, ok := fantasy.AsContentType[fantasy.ToolCallPart](c)
 453					if !ok {
 454						warnings = append(warnings, fantasy.CallWarning{
 455							Type:    fantasy.CallWarningTypeOther,
 456							Message: "assistant message tool call part does not have the right type",
 457						})
 458						continue
 459					}
 460
 461					if toolCallPart.ProviderExecuted {
 462						continue
 463					}
 464
 465					inputJSON, err := json.Marshal(toolCallPart.Input)
 466					if err != nil {
 467						warnings = append(warnings, fantasy.CallWarning{
 468							Type:    fantasy.CallWarningTypeOther,
 469							Message: fmt.Sprintf("failed to marshal tool call input: %v", err),
 470						})
 471						continue
 472					}
 473
 474					input = append(input, responses.ResponseInputItemParamOfFunctionCall(string(inputJSON), toolCallPart.ToolCallID, toolCallPart.ToolName))
 475				case fantasy.ContentTypeReasoning:
 476					reasoningMetadata := GetReasoningMetadata(c.Options())
 477					if reasoningMetadata == nil || reasoningMetadata.ItemID == "" {
 478						continue
 479					}
 480					if len(reasoningMetadata.Summary) == 0 && reasoningMetadata.EncryptedContent == nil {
 481						warnings = append(warnings, fantasy.CallWarning{
 482							Type:    fantasy.CallWarningTypeOther,
 483							Message: "assistant message reasoning part does is empty",
 484						})
 485						continue
 486					}
 487					// we want to always send an empty array
 488					summary := []responses.ResponseReasoningItemSummaryParam{}
 489					for _, s := range reasoningMetadata.Summary {
 490						summary = append(summary, responses.ResponseReasoningItemSummaryParam{
 491							Type: "summary_text",
 492							Text: s,
 493						})
 494					}
 495					reasoning := &responses.ResponseReasoningItemParam{
 496						ID:      reasoningMetadata.ItemID,
 497						Summary: summary,
 498					}
 499					if reasoningMetadata.EncryptedContent != nil {
 500						reasoning.EncryptedContent = param.NewOpt(*reasoningMetadata.EncryptedContent)
 501					}
 502					input = append(input, responses.ResponseInputItemUnionParam{
 503						OfReasoning: reasoning,
 504					})
 505				}
 506			}
 507
 508		case fantasy.MessageRoleTool:
 509			for _, c := range msg.Content {
 510				if c.GetType() != fantasy.ContentTypeToolResult {
 511					warnings = append(warnings, fantasy.CallWarning{
 512						Type:    fantasy.CallWarningTypeOther,
 513						Message: "tool message can only have tool result content",
 514					})
 515					continue
 516				}
 517
 518				toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c)
 519				if !ok {
 520					warnings = append(warnings, fantasy.CallWarning{
 521						Type:    fantasy.CallWarningTypeOther,
 522						Message: "tool message result part does not have the right type",
 523					})
 524					continue
 525				}
 526
 527				var outputStr string
 528				switch toolResultPart.Output.GetType() {
 529				case fantasy.ToolResultContentTypeText:
 530					output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](toolResultPart.Output)
 531					if !ok {
 532						warnings = append(warnings, fantasy.CallWarning{
 533							Type:    fantasy.CallWarningTypeOther,
 534							Message: "tool result output does not have the right type",
 535						})
 536						continue
 537					}
 538					outputStr = output.Text
 539				case fantasy.ToolResultContentTypeError:
 540					output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output)
 541					if !ok {
 542						warnings = append(warnings, fantasy.CallWarning{
 543							Type:    fantasy.CallWarningTypeOther,
 544							Message: "tool result output does not have the right type",
 545						})
 546						continue
 547					}
 548					outputStr = output.Error.Error()
 549				case fantasy.ToolResultContentTypeMedia:
 550					output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResultPart.Output)
 551					if !ok {
 552						warnings = append(warnings, fantasy.CallWarning{
 553							Type:    fantasy.CallWarningTypeOther,
 554							Message: "tool result output does not have the right type",
 555						})
 556						continue
 557					}
 558					// For media content, encode as JSON with data and media type
 559					mediaContent := map[string]string{
 560						"data":       output.Data,
 561						"media_type": output.MediaType,
 562					}
 563					jsonBytes, err := json.Marshal(mediaContent)
 564					if err != nil {
 565						warnings = append(warnings, fantasy.CallWarning{
 566							Type:    fantasy.CallWarningTypeOther,
 567							Message: fmt.Sprintf("failed to marshal tool result: %v", err),
 568						})
 569						continue
 570					}
 571					outputStr = string(jsonBytes)
 572				}
 573
 574				input = append(input, responses.ResponseInputItemParamOfFunctionCallOutput(toolResultPart.ToolCallID, outputStr))
 575			}
 576		}
 577	}
 578
 579	return input, warnings
 580}
 581
 582func toResponsesTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice, options *ResponsesProviderOptions) ([]responses.ToolUnionParam, responses.ResponseNewParamsToolChoiceUnion, []fantasy.CallWarning) {
 583	warnings := make([]fantasy.CallWarning, 0)
 584	var openaiTools []responses.ToolUnionParam
 585
 586	if len(tools) == 0 {
 587		return nil, responses.ResponseNewParamsToolChoiceUnion{}, nil
 588	}
 589
 590	strictJSONSchema := false
 591	if options != nil && options.StrictJSONSchema != nil {
 592		strictJSONSchema = *options.StrictJSONSchema
 593	}
 594
 595	for _, tool := range tools {
 596		if tool.GetType() == fantasy.ToolTypeFunction {
 597			ft, ok := tool.(fantasy.FunctionTool)
 598			if !ok {
 599				continue
 600			}
 601			openaiTools = append(openaiTools, responses.ToolUnionParam{
 602				OfFunction: &responses.FunctionToolParam{
 603					Name:        ft.Name,
 604					Description: param.NewOpt(ft.Description),
 605					Parameters:  ft.InputSchema,
 606					Strict:      param.NewOpt(strictJSONSchema),
 607					Type:        "function",
 608				},
 609			})
 610			continue
 611		}
 612
 613		warnings = append(warnings, fantasy.CallWarning{
 614			Type:    fantasy.CallWarningTypeUnsupportedTool,
 615			Tool:    tool,
 616			Message: "tool is not supported",
 617		})
 618	}
 619
 620	if toolChoice == nil {
 621		return openaiTools, responses.ResponseNewParamsToolChoiceUnion{}, warnings
 622	}
 623
 624	var openaiToolChoice responses.ResponseNewParamsToolChoiceUnion
 625
 626	switch *toolChoice {
 627	case fantasy.ToolChoiceAuto:
 628		openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
 629			OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptionsAuto),
 630		}
 631	case fantasy.ToolChoiceNone:
 632		openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
 633			OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptionsNone),
 634		}
 635	case fantasy.ToolChoiceRequired:
 636		openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
 637			OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptionsRequired),
 638		}
 639	default:
 640		openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
 641			OfFunctionTool: &responses.ToolChoiceFunctionParam{
 642				Type: "function",
 643				Name: string(*toolChoice),
 644			},
 645		}
 646	}
 647
 648	return openaiTools, openaiToolChoice, warnings
 649}
 650
 651func (o responsesLanguageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
 652	params, warnings := o.prepareParams(call)
 653	response, err := o.client.Responses.New(ctx, *params)
 654	if err != nil {
 655		return nil, toProviderErr(err)
 656	}
 657
 658	if response.Error.Message != "" {
 659		return nil, &fantasy.Error{
 660			Title:   "provider error",
 661			Message: fmt.Sprintf("%s (code: %s)", response.Error.Message, response.Error.Code),
 662		}
 663	}
 664
 665	var content []fantasy.Content
 666	hasFunctionCall := false
 667
 668	for _, outputItem := range response.Output {
 669		switch outputItem.Type {
 670		case "message":
 671			for _, contentPart := range outputItem.Content {
 672				if contentPart.Type == "output_text" {
 673					content = append(content, fantasy.TextContent{
 674						Text: contentPart.Text,
 675					})
 676
 677					for _, annotation := range contentPart.Annotations {
 678						switch annotation.Type {
 679						case "url_citation":
 680							content = append(content, fantasy.SourceContent{
 681								SourceType: fantasy.SourceTypeURL,
 682								ID:         uuid.NewString(),
 683								URL:        annotation.URL,
 684								Title:      annotation.Title,
 685							})
 686						case "file_citation":
 687							title := "Document"
 688							if annotation.Filename != "" {
 689								title = annotation.Filename
 690							}
 691							filename := annotation.Filename
 692							if filename == "" {
 693								filename = annotation.FileID
 694							}
 695							content = append(content, fantasy.SourceContent{
 696								SourceType: fantasy.SourceTypeDocument,
 697								ID:         uuid.NewString(),
 698								MediaType:  "text/plain",
 699								Title:      title,
 700								Filename:   filename,
 701							})
 702						}
 703					}
 704				}
 705			}
 706
 707		case "function_call":
 708			hasFunctionCall = true
 709			content = append(content, fantasy.ToolCallContent{
 710				ProviderExecuted: false,
 711				ToolCallID:       outputItem.CallID,
 712				ToolName:         outputItem.Name,
 713				Input:            outputItem.Arguments,
 714			})
 715
 716		case "reasoning":
 717			metadata := &ResponsesReasoningMetadata{
 718				ItemID: outputItem.ID,
 719			}
 720			if outputItem.EncryptedContent != "" {
 721				metadata.EncryptedContent = &outputItem.EncryptedContent
 722			}
 723
 724			if len(outputItem.Summary) == 0 && metadata.EncryptedContent == nil {
 725				continue
 726			}
 727
 728			// When there are no summary parts, add an empty reasoning part
 729			summaries := outputItem.Summary
 730			if len(summaries) == 0 {
 731				summaries = []responses.ResponseReasoningItemSummary{{Type: "summary_text", Text: ""}}
 732			}
 733
 734			for _, s := range summaries {
 735				metadata.Summary = append(metadata.Summary, s.Text)
 736			}
 737
 738			content = append(content, fantasy.ReasoningContent{
 739				Text: strings.Join(metadata.Summary, "\n"),
 740				ProviderMetadata: fantasy.ProviderMetadata{
 741					Name: metadata,
 742				},
 743			})
 744		}
 745	}
 746
 747	usage := fantasy.Usage{
 748		InputTokens:  response.Usage.InputTokens,
 749		OutputTokens: response.Usage.OutputTokens,
 750		TotalTokens:  response.Usage.InputTokens + response.Usage.OutputTokens,
 751	}
 752
 753	if response.Usage.OutputTokensDetails.ReasoningTokens != 0 {
 754		usage.ReasoningTokens = response.Usage.OutputTokensDetails.ReasoningTokens
 755	}
 756	if response.Usage.InputTokensDetails.CachedTokens != 0 {
 757		usage.CacheReadTokens = response.Usage.InputTokensDetails.CachedTokens
 758	}
 759
 760	finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, hasFunctionCall)
 761
 762	return &fantasy.Response{
 763		Content:          content,
 764		Usage:            usage,
 765		FinishReason:     finishReason,
 766		ProviderMetadata: fantasy.ProviderMetadata{},
 767		Warnings:         warnings,
 768	}, nil
 769}
 770
 771func mapResponsesFinishReason(reason string, hasFunctionCall bool) fantasy.FinishReason {
 772	if hasFunctionCall {
 773		return fantasy.FinishReasonToolCalls
 774	}
 775
 776	switch reason {
 777	case "":
 778		return fantasy.FinishReasonStop
 779	case "max_tokens", "max_output_tokens":
 780		return fantasy.FinishReasonLength
 781	case "content_filter":
 782		return fantasy.FinishReasonContentFilter
 783	default:
 784		return fantasy.FinishReasonOther
 785	}
 786}
 787
 788func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
 789	params, warnings := o.prepareParams(call)
 790
 791	stream := o.client.Responses.NewStreaming(ctx, *params)
 792
 793	finishReason := fantasy.FinishReasonUnknown
 794	var usage fantasy.Usage
 795	ongoingToolCalls := make(map[int64]*ongoingToolCall)
 796	hasFunctionCall := false
 797	activeReasoning := make(map[string]*reasoningState)
 798
 799	return func(yield func(fantasy.StreamPart) bool) {
 800		if len(warnings) > 0 {
 801			if !yield(fantasy.StreamPart{
 802				Type:     fantasy.StreamPartTypeWarnings,
 803				Warnings: warnings,
 804			}) {
 805				return
 806			}
 807		}
 808
 809		for stream.Next() {
 810			event := stream.Current()
 811
 812			switch event.Type {
 813			case "response.created":
 814				_ = event.AsResponseCreated()
 815
 816			case "response.output_item.added":
 817				added := event.AsResponseOutputItemAdded()
 818				switch added.Item.Type {
 819				case "function_call":
 820					ongoingToolCalls[added.OutputIndex] = &ongoingToolCall{
 821						toolName:   added.Item.Name,
 822						toolCallID: added.Item.CallID,
 823					}
 824					if !yield(fantasy.StreamPart{
 825						Type:         fantasy.StreamPartTypeToolInputStart,
 826						ID:           added.Item.CallID,
 827						ToolCallName: added.Item.Name,
 828					}) {
 829						return
 830					}
 831
 832				case "message":
 833					if !yield(fantasy.StreamPart{
 834						Type: fantasy.StreamPartTypeTextStart,
 835						ID:   added.Item.ID,
 836					}) {
 837						return
 838					}
 839
 840				case "reasoning":
 841					metadata := &ResponsesReasoningMetadata{
 842						ItemID:  added.Item.ID,
 843						Summary: []string{},
 844					}
 845					if added.Item.EncryptedContent != "" {
 846						metadata.EncryptedContent = &added.Item.EncryptedContent
 847					}
 848
 849					activeReasoning[added.Item.ID] = &reasoningState{
 850						metadata: metadata,
 851					}
 852					if !yield(fantasy.StreamPart{
 853						Type: fantasy.StreamPartTypeReasoningStart,
 854						ID:   added.Item.ID,
 855						ProviderMetadata: fantasy.ProviderMetadata{
 856							Name: metadata,
 857						},
 858					}) {
 859						return
 860					}
 861				}
 862
 863			case "response.output_item.done":
 864				done := event.AsResponseOutputItemDone()
 865				switch done.Item.Type {
 866				case "function_call":
 867					tc := ongoingToolCalls[done.OutputIndex]
 868					if tc != nil {
 869						delete(ongoingToolCalls, done.OutputIndex)
 870						hasFunctionCall = true
 871
 872						if !yield(fantasy.StreamPart{
 873							Type: fantasy.StreamPartTypeToolInputEnd,
 874							ID:   done.Item.CallID,
 875						}) {
 876							return
 877						}
 878						if !yield(fantasy.StreamPart{
 879							Type:          fantasy.StreamPartTypeToolCall,
 880							ID:            done.Item.CallID,
 881							ToolCallName:  done.Item.Name,
 882							ToolCallInput: done.Item.Arguments,
 883						}) {
 884							return
 885						}
 886					}
 887
 888				case "message":
 889					if !yield(fantasy.StreamPart{
 890						Type: fantasy.StreamPartTypeTextEnd,
 891						ID:   done.Item.ID,
 892					}) {
 893						return
 894					}
 895
 896				case "reasoning":
 897					state := activeReasoning[done.Item.ID]
 898					if state != nil {
 899						if !yield(fantasy.StreamPart{
 900							Type: fantasy.StreamPartTypeReasoningEnd,
 901							ID:   done.Item.ID,
 902							ProviderMetadata: fantasy.ProviderMetadata{
 903								Name: state.metadata,
 904							},
 905						}) {
 906							return
 907						}
 908						delete(activeReasoning, done.Item.ID)
 909					}
 910				}
 911
 912			case "response.function_call_arguments.delta":
 913				delta := event.AsResponseFunctionCallArgumentsDelta()
 914				tc := ongoingToolCalls[delta.OutputIndex]
 915				if tc != nil {
 916					if !yield(fantasy.StreamPart{
 917						Type:  fantasy.StreamPartTypeToolInputDelta,
 918						ID:    tc.toolCallID,
 919						Delta: delta.Delta,
 920					}) {
 921						return
 922					}
 923				}
 924
 925			case "response.output_text.delta":
 926				textDelta := event.AsResponseOutputTextDelta()
 927				if !yield(fantasy.StreamPart{
 928					Type:  fantasy.StreamPartTypeTextDelta,
 929					ID:    textDelta.ItemID,
 930					Delta: textDelta.Delta,
 931				}) {
 932					return
 933				}
 934
 935			case "response.reasoning_summary_part.added":
 936				added := event.AsResponseReasoningSummaryPartAdded()
 937				state := activeReasoning[added.ItemID]
 938				if state != nil {
 939					state.metadata.Summary = append(state.metadata.Summary, "")
 940					activeReasoning[added.ItemID] = state
 941					if !yield(fantasy.StreamPart{
 942						Type:  fantasy.StreamPartTypeReasoningDelta,
 943						ID:    added.ItemID,
 944						Delta: "\n",
 945						ProviderMetadata: fantasy.ProviderMetadata{
 946							Name: state.metadata,
 947						},
 948					}) {
 949						return
 950					}
 951				}
 952
 953			case "response.reasoning_summary_text.delta":
 954				textDelta := event.AsResponseReasoningSummaryTextDelta()
 955				state := activeReasoning[textDelta.ItemID]
 956				if state != nil {
 957					if len(state.metadata.Summary)-1 >= int(textDelta.SummaryIndex) {
 958						state.metadata.Summary[textDelta.SummaryIndex] += textDelta.Delta
 959					}
 960					activeReasoning[textDelta.ItemID] = state
 961					if !yield(fantasy.StreamPart{
 962						Type:  fantasy.StreamPartTypeReasoningDelta,
 963						ID:    textDelta.ItemID,
 964						Delta: textDelta.Delta,
 965						ProviderMetadata: fantasy.ProviderMetadata{
 966							Name: state.metadata,
 967						},
 968					}) {
 969						return
 970					}
 971				}
 972
 973			case "response.completed", "response.incomplete":
 974				completed := event.AsResponseCompleted()
 975				finishReason = mapResponsesFinishReason(completed.Response.IncompleteDetails.Reason, hasFunctionCall)
 976				usage = fantasy.Usage{
 977					InputTokens:  completed.Response.Usage.InputTokens,
 978					OutputTokens: completed.Response.Usage.OutputTokens,
 979					TotalTokens:  completed.Response.Usage.InputTokens + completed.Response.Usage.OutputTokens,
 980				}
 981				if completed.Response.Usage.OutputTokensDetails.ReasoningTokens != 0 {
 982					usage.ReasoningTokens = completed.Response.Usage.OutputTokensDetails.ReasoningTokens
 983				}
 984				if completed.Response.Usage.InputTokensDetails.CachedTokens != 0 {
 985					usage.CacheReadTokens = completed.Response.Usage.InputTokensDetails.CachedTokens
 986				}
 987
 988			case "error":
 989				errorEvent := event.AsError()
 990				if !yield(fantasy.StreamPart{
 991					Type:  fantasy.StreamPartTypeError,
 992					Error: fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code),
 993				}) {
 994					return
 995				}
 996				return
 997			}
 998		}
 999
1000		err := stream.Err()
1001		if err != nil {
1002			yield(fantasy.StreamPart{
1003				Type:  fantasy.StreamPartTypeError,
1004				Error: toProviderErr(err),
1005			})
1006			return
1007		}
1008
1009		yield(fantasy.StreamPart{
1010			Type:         fantasy.StreamPartTypeFinish,
1011			Usage:        usage,
1012			FinishReason: finishReason,
1013		})
1014	}, nil
1015}
1016
1017// GetReasoningMetadata extracts reasoning metadata from provider options for responses models.
1018func GetReasoningMetadata(providerOptions fantasy.ProviderOptions) *ResponsesReasoningMetadata {
1019	if openaiResponsesOptions, ok := providerOptions[Name]; ok {
1020		if reasoning, ok := openaiResponsesOptions.(*ResponsesReasoningMetadata); ok {
1021			return reasoning
1022		}
1023	}
1024	return nil
1025}
1026
1027type ongoingToolCall struct {
1028	toolName   string
1029	toolCallID string
1030}
1031
1032type reasoningState struct {
1033	metadata *ResponsesReasoningMetadata
1034}