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