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}