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