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