1package openai
2
3import (
4 "context"
5 "encoding/base64"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "reflect"
10 "strings"
11
12 "charm.land/fantasy"
13 "charm.land/fantasy/object"
14 "charm.land/fantasy/schema"
15 "github.com/charmbracelet/openai-go"
16 "github.com/charmbracelet/openai-go/packages/param"
17 "github.com/charmbracelet/openai-go/responses"
18 "github.com/charmbracelet/openai-go/shared"
19 "github.com/google/uuid"
20)
21
22const topLogprobsMax = 20
23
24type responsesLanguageModel struct {
25 provider string
26 modelID string
27 client openai.Client
28 objectMode fantasy.ObjectMode
29}
30
31// newResponsesLanguageModel implements a responses api model.
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
122const (
123 previousResponseIDHistoryError = "cannot combine previous_response_id with replayed conversation history; use either previous_response_id (server-side chaining) or explicit message replay, not both"
124 previousResponseIDStoreError = "previous_response_id requires store to be true; the current response will not be stored and cannot be used for further chaining"
125)
126
127func (o responsesLanguageModel) prepareParams(call fantasy.Call) (*responses.ResponseNewParams, []fantasy.CallWarning, error) {
128 var warnings []fantasy.CallWarning
129 params := &responses.ResponseNewParams{}
130
131 modelConfig := getResponsesModelConfig(o.modelID)
132
133 if call.TopK != nil {
134 warnings = append(warnings, fantasy.CallWarning{
135 Type: fantasy.CallWarningTypeUnsupportedSetting,
136 Setting: "topK",
137 })
138 }
139
140 if call.PresencePenalty != nil {
141 warnings = append(warnings, fantasy.CallWarning{
142 Type: fantasy.CallWarningTypeUnsupportedSetting,
143 Setting: "presencePenalty",
144 })
145 }
146
147 if call.FrequencyPenalty != nil {
148 warnings = append(warnings, fantasy.CallWarning{
149 Type: fantasy.CallWarningTypeUnsupportedSetting,
150 Setting: "frequencyPenalty",
151 })
152 }
153
154 var openaiOptions *ResponsesProviderOptions
155 if opts, ok := call.ProviderOptions[Name]; ok {
156 if typedOpts, ok := opts.(*ResponsesProviderOptions); ok {
157 openaiOptions = typedOpts
158 }
159 }
160
161 if openaiOptions != nil && openaiOptions.Store != nil {
162 params.Store = param.NewOpt(*openaiOptions.Store)
163 } else {
164 params.Store = param.NewOpt(false)
165 }
166
167 if openaiOptions != nil && openaiOptions.PreviousResponseID != nil && *openaiOptions.PreviousResponseID != "" {
168 if err := validatePreviousResponseIDPrompt(call.Prompt); err != nil {
169 return nil, warnings, err
170 }
171 if openaiOptions.Store == nil || !*openaiOptions.Store {
172 return nil, warnings, errors.New(previousResponseIDStoreError)
173 }
174 params.PreviousResponseID = param.NewOpt(*openaiOptions.PreviousResponseID)
175 }
176
177 storeEnabled := openaiOptions != nil && openaiOptions.Store != nil && *openaiOptions.Store
178 input, inputWarnings := toResponsesPrompt(call.Prompt, modelConfig.systemMessageMode, storeEnabled)
179 warnings = append(warnings, inputWarnings...)
180
181 var include []IncludeType
182
183 addInclude := func(key IncludeType) {
184 include = append(include, key)
185 }
186
187 topLogprobs := 0
188 if openaiOptions != nil && openaiOptions.Logprobs != nil {
189 switch v := openaiOptions.Logprobs.(type) {
190 case bool:
191 if v {
192 topLogprobs = topLogprobsMax
193 }
194 case float64:
195 topLogprobs = int(v)
196 case int:
197 topLogprobs = v
198 }
199 }
200
201 if topLogprobs > 0 {
202 addInclude(IncludeMessageOutputTextLogprobs)
203 }
204
205 params.Model = o.modelID
206 params.Input = responses.ResponseNewParamsInputUnion{
207 OfInputItemList: input,
208 }
209
210 if call.Temperature != nil {
211 params.Temperature = param.NewOpt(*call.Temperature)
212 }
213 if call.TopP != nil {
214 params.TopP = param.NewOpt(*call.TopP)
215 }
216 if call.MaxOutputTokens != nil {
217 params.MaxOutputTokens = param.NewOpt(*call.MaxOutputTokens)
218 }
219
220 if openaiOptions != nil {
221 if openaiOptions.MaxToolCalls != nil {
222 params.MaxToolCalls = param.NewOpt(*openaiOptions.MaxToolCalls)
223 }
224 if openaiOptions.Metadata != nil {
225 metadata := make(shared.Metadata)
226 for k, v := range openaiOptions.Metadata {
227 if str, ok := v.(string); ok {
228 metadata[k] = str
229 }
230 }
231 params.Metadata = metadata
232 }
233 if openaiOptions.ParallelToolCalls != nil {
234 params.ParallelToolCalls = param.NewOpt(*openaiOptions.ParallelToolCalls)
235 }
236 if openaiOptions.User != nil {
237 params.User = param.NewOpt(*openaiOptions.User)
238 }
239 if openaiOptions.Instructions != nil {
240 params.Instructions = param.NewOpt(*openaiOptions.Instructions)
241 }
242 if openaiOptions.ServiceTier != nil {
243 params.ServiceTier = responses.ResponseNewParamsServiceTier(*openaiOptions.ServiceTier)
244 }
245 if openaiOptions.PromptCacheKey != nil {
246 params.PromptCacheKey = param.NewOpt(*openaiOptions.PromptCacheKey)
247 }
248 if openaiOptions.SafetyIdentifier != nil {
249 params.SafetyIdentifier = param.NewOpt(*openaiOptions.SafetyIdentifier)
250 }
251 if topLogprobs > 0 {
252 params.TopLogprobs = param.NewOpt(int64(topLogprobs))
253 }
254
255 if len(openaiOptions.Include) > 0 {
256 include = append(include, openaiOptions.Include...)
257 }
258
259 if modelConfig.isReasoningModel && (openaiOptions.ReasoningEffort != nil || openaiOptions.ReasoningSummary != nil) {
260 reasoning := shared.ReasoningParam{}
261 if openaiOptions.ReasoningEffort != nil {
262 reasoning.Effort = shared.ReasoningEffort(*openaiOptions.ReasoningEffort)
263 }
264 if openaiOptions.ReasoningSummary != nil {
265 reasoning.Summary = shared.ReasoningSummary(*openaiOptions.ReasoningSummary)
266 }
267 params.Reasoning = reasoning
268 }
269 }
270
271 if modelConfig.requiredAutoTruncation {
272 params.Truncation = responses.ResponseNewParamsTruncationAuto
273 }
274
275 if len(include) > 0 {
276 includeParams := make([]responses.ResponseIncludable, len(include))
277 for i, inc := range include {
278 includeParams[i] = responses.ResponseIncludable(string(inc))
279 }
280 params.Include = includeParams
281 }
282
283 if modelConfig.isReasoningModel {
284 if call.Temperature != nil {
285 params.Temperature = param.Opt[float64]{}
286 warnings = append(warnings, fantasy.CallWarning{
287 Type: fantasy.CallWarningTypeUnsupportedSetting,
288 Setting: "temperature",
289 Details: "temperature is not supported for reasoning models",
290 })
291 }
292
293 if call.TopP != nil {
294 params.TopP = param.Opt[float64]{}
295 warnings = append(warnings, fantasy.CallWarning{
296 Type: fantasy.CallWarningTypeUnsupportedSetting,
297 Setting: "topP",
298 Details: "topP is not supported for reasoning models",
299 })
300 }
301 } else {
302 if openaiOptions != nil {
303 if openaiOptions.ReasoningEffort != nil {
304 warnings = append(warnings, fantasy.CallWarning{
305 Type: fantasy.CallWarningTypeUnsupportedSetting,
306 Setting: "reasoningEffort",
307 Details: "reasoningEffort is not supported for non-reasoning models",
308 })
309 }
310
311 if openaiOptions.ReasoningSummary != nil {
312 warnings = append(warnings, fantasy.CallWarning{
313 Type: fantasy.CallWarningTypeUnsupportedSetting,
314 Setting: "reasoningSummary",
315 Details: "reasoningSummary is not supported for non-reasoning models",
316 })
317 }
318 }
319 }
320
321 if openaiOptions != nil && openaiOptions.ServiceTier != nil {
322 if *openaiOptions.ServiceTier == ServiceTierFlex && !modelConfig.supportsFlexProcessing {
323 warnings = append(warnings, fantasy.CallWarning{
324 Type: fantasy.CallWarningTypeUnsupportedSetting,
325 Setting: "serviceTier",
326 Details: "flex processing is only available for o3, o4-mini, and gpt-5 models",
327 })
328 params.ServiceTier = ""
329 }
330
331 if *openaiOptions.ServiceTier == ServiceTierPriority && !modelConfig.supportsPriorityProcessing {
332 warnings = append(warnings, fantasy.CallWarning{
333 Type: fantasy.CallWarningTypeUnsupportedSetting,
334 Setting: "serviceTier",
335 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",
336 })
337 params.ServiceTier = ""
338 }
339 }
340
341 tools, toolChoice, toolWarnings := toResponsesTools(call.Tools, call.ToolChoice, openaiOptions)
342 warnings = append(warnings, toolWarnings...)
343
344 if len(tools) > 0 {
345 params.Tools = tools
346 params.ToolChoice = toolChoice
347 }
348
349 return params, warnings, nil
350}
351
352func validatePreviousResponseIDPrompt(prompt fantasy.Prompt) error {
353 for _, msg := range prompt {
354 switch msg.Role {
355 case fantasy.MessageRoleSystem, fantasy.MessageRoleUser:
356 continue
357 default:
358 return errors.New(previousResponseIDHistoryError)
359 }
360 }
361 return nil
362}
363
364func responsesProviderMetadata(responseID string) fantasy.ProviderMetadata {
365 if responseID == "" {
366 return fantasy.ProviderMetadata{}
367 }
368
369 return fantasy.ProviderMetadata{
370 Name: &ResponsesProviderMetadata{
371 ResponseID: responseID,
372 },
373 }
374}
375
376func responsesUsage(resp responses.Response) fantasy.Usage {
377 // OpenAI reports input_tokens INCLUDING cached tokens. Subtract to avoid double-counting.
378 inputTokens := max(resp.Usage.InputTokens-resp.Usage.InputTokensDetails.CachedTokens, 0)
379 usage := fantasy.Usage{
380 InputTokens: inputTokens,
381 OutputTokens: resp.Usage.OutputTokens,
382 TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens,
383 }
384 if resp.Usage.OutputTokensDetails.ReasoningTokens != 0 {
385 usage.ReasoningTokens = resp.Usage.OutputTokensDetails.ReasoningTokens
386 }
387 if resp.Usage.InputTokensDetails.CachedTokens != 0 {
388 usage.CacheReadTokens = resp.Usage.InputTokensDetails.CachedTokens
389 }
390 return usage
391}
392
393func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string, store bool) (responses.ResponseInputParam, []fantasy.CallWarning) {
394 var input responses.ResponseInputParam
395 var warnings []fantasy.CallWarning
396
397 for _, msg := range prompt {
398 switch msg.Role {
399 case fantasy.MessageRoleSystem:
400 var systemText string
401 for _, c := range msg.Content {
402 if c.GetType() != fantasy.ContentTypeText {
403 warnings = append(warnings, fantasy.CallWarning{
404 Type: fantasy.CallWarningTypeOther,
405 Message: "system prompt can only have text content",
406 })
407 continue
408 }
409 textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
410 if !ok {
411 warnings = append(warnings, fantasy.CallWarning{
412 Type: fantasy.CallWarningTypeOther,
413 Message: "system prompt text part does not have the right type",
414 })
415 continue
416 }
417 if strings.TrimSpace(textPart.Text) != "" {
418 systemText += textPart.Text
419 }
420 }
421
422 if systemText == "" {
423 warnings = append(warnings, fantasy.CallWarning{
424 Type: fantasy.CallWarningTypeOther,
425 Message: "system prompt has no text parts",
426 })
427 continue
428 }
429
430 switch systemMessageMode {
431 case "system":
432 input = append(input, responses.ResponseInputItemParamOfMessage(systemText, responses.EasyInputMessageRoleSystem))
433 case "developer":
434 input = append(input, responses.ResponseInputItemParamOfMessage(systemText, responses.EasyInputMessageRoleDeveloper))
435 case "remove":
436 warnings = append(warnings, fantasy.CallWarning{
437 Type: fantasy.CallWarningTypeOther,
438 Message: "system messages are removed for this model",
439 })
440 }
441
442 case fantasy.MessageRoleUser:
443 var contentParts responses.ResponseInputMessageContentListParam
444 for i, c := range msg.Content {
445 switch c.GetType() {
446 case fantasy.ContentTypeText:
447 textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
448 if !ok {
449 warnings = append(warnings, fantasy.CallWarning{
450 Type: fantasy.CallWarningTypeOther,
451 Message: "user message text part does not have the right type",
452 })
453 continue
454 }
455 contentParts = append(contentParts, responses.ResponseInputContentUnionParam{
456 OfInputText: &responses.ResponseInputTextParam{
457 Type: "input_text",
458 Text: textPart.Text,
459 },
460 })
461
462 case fantasy.ContentTypeFile:
463 filePart, ok := fantasy.AsContentType[fantasy.FilePart](c)
464 if !ok {
465 warnings = append(warnings, fantasy.CallWarning{
466 Type: fantasy.CallWarningTypeOther,
467 Message: "user message file part does not have the right type",
468 })
469 continue
470 }
471
472 if strings.HasPrefix(filePart.MediaType, "image/") {
473 base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
474 imageURL := fmt.Sprintf("data:%s;base64,%s", filePart.MediaType, base64Encoded)
475 contentParts = append(contentParts, responses.ResponseInputContentUnionParam{
476 OfInputImage: &responses.ResponseInputImageParam{
477 Type: "input_image",
478 ImageURL: param.NewOpt(imageURL),
479 },
480 })
481 } else if filePart.MediaType == "application/pdf" {
482 base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
483 fileData := fmt.Sprintf("data:application/pdf;base64,%s", base64Encoded)
484 filename := filePart.Filename
485 if filename == "" {
486 filename = fmt.Sprintf("part-%d.pdf", i)
487 }
488 contentParts = append(contentParts, responses.ResponseInputContentUnionParam{
489 OfInputFile: &responses.ResponseInputFileParam{
490 Type: "input_file",
491 Filename: param.NewOpt(filename),
492 FileData: param.NewOpt(fileData),
493 },
494 })
495 } else {
496 warnings = append(warnings, fantasy.CallWarning{
497 Type: fantasy.CallWarningTypeOther,
498 Message: fmt.Sprintf("file part media type %s not supported", filePart.MediaType),
499 })
500 }
501 }
502 }
503
504 if !hasVisibleResponsesUserContent(contentParts) {
505 warnings = append(warnings, fantasy.CallWarning{
506 Type: fantasy.CallWarningTypeOther,
507 Message: "dropping empty user message (contains neither user-facing content nor tool results)",
508 })
509 continue
510 }
511
512 input = append(input, responses.ResponseInputItemParamOfMessage(contentParts, responses.EasyInputMessageRoleUser))
513
514 case fantasy.MessageRoleAssistant:
515 startIdx := len(input)
516 for _, c := range msg.Content {
517 switch c.GetType() {
518 case fantasy.ContentTypeText:
519 textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
520 if !ok {
521 warnings = append(warnings, fantasy.CallWarning{
522 Type: fantasy.CallWarningTypeOther,
523 Message: "assistant message text part does not have the right type",
524 })
525 continue
526 }
527 input = append(input, responses.ResponseInputItemParamOfMessage(textPart.Text, responses.EasyInputMessageRoleAssistant))
528
529 case fantasy.ContentTypeToolCall:
530 toolCallPart, ok := fantasy.AsContentType[fantasy.ToolCallPart](c)
531 if !ok {
532 warnings = append(warnings, fantasy.CallWarning{
533 Type: fantasy.CallWarningTypeOther,
534 Message: "assistant message tool call part does not have the right type",
535 })
536 continue
537 }
538
539 if toolCallPart.ProviderExecuted {
540 if store {
541 // Round-trip provider-executed tools via
542 // item_reference, letting the API resolve
543 // the stored output item by ID.
544 input = append(input, responses.ResponseInputItemParamOfItemReference(toolCallPart.ToolCallID))
545 }
546 // When store is disabled, server-side items are
547 // ephemeral and cannot be referenced. Skip the
548 // tool call; results are already omitted for
549 // provider-executed tools.
550 continue
551 }
552
553 inputJSON, err := json.Marshal(toolCallPart.Input)
554 if err != nil {
555 warnings = append(warnings, fantasy.CallWarning{
556 Type: fantasy.CallWarningTypeOther,
557 Message: fmt.Sprintf("failed to marshal tool call input: %v", err),
558 })
559 continue
560 }
561
562 input = append(input, responses.ResponseInputItemParamOfFunctionCall(string(inputJSON), toolCallPart.ToolCallID, toolCallPart.ToolName))
563 case fantasy.ContentTypeSource:
564 // Source citations from web search are not a
565 // recognised Responses API input type; skip.
566 continue
567 case fantasy.ContentTypeReasoning:
568 // Reasoning items are always skipped during replay.
569 // When store is enabled, the API already has them
570 // persisted server-side. When store is disabled, the
571 // item IDs are ephemeral and referencing them causes
572 // "Item not found" errors. In both cases, replaying
573 // reasoning inline is not supported by the API.
574 continue
575 }
576 }
577
578 if !hasVisibleResponsesAssistantContent(input, startIdx) {
579 warnings = append(warnings, fantasy.CallWarning{
580 Type: fantasy.CallWarningTypeOther,
581 Message: "dropping empty assistant message (contains neither user-facing content nor tool calls)",
582 })
583 // Remove any items that were added during this iteration
584 input = input[:startIdx]
585 continue
586 }
587
588 case fantasy.MessageRoleTool:
589 for _, c := range msg.Content {
590 if c.GetType() != fantasy.ContentTypeToolResult {
591 warnings = append(warnings, fantasy.CallWarning{
592 Type: fantasy.CallWarningTypeOther,
593 Message: "tool message can only have tool result content",
594 })
595 continue
596 }
597
598 toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c)
599 if !ok {
600 warnings = append(warnings, fantasy.CallWarning{
601 Type: fantasy.CallWarningTypeOther,
602 Message: "tool message result part does not have the right type",
603 })
604 continue
605 }
606
607 // Provider-executed tool results (e.g. web search)
608 // are already round-tripped via the tool call; skip.
609 if toolResultPart.ProviderExecuted {
610 continue
611 }
612
613 var outputStr string
614 var followupParts responses.ResponseInputMessageContentListParam
615
616 switch toolResultPart.Output.GetType() {
617 case fantasy.ToolResultContentTypeText:
618 output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](toolResultPart.Output)
619 if !ok {
620 warnings = append(warnings, fantasy.CallWarning{
621 Type: fantasy.CallWarningTypeOther,
622 Message: "tool result output does not have the right type",
623 })
624 continue
625 }
626 outputStr = output.Text
627 case fantasy.ToolResultContentTypeError:
628 output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output)
629 if !ok {
630 warnings = append(warnings, fantasy.CallWarning{
631 Type: fantasy.CallWarningTypeOther,
632 Message: "tool result output does not have the right type",
633 })
634 continue
635 }
636 outputStr = output.Error.Error()
637 case fantasy.ToolResultContentTypeMedia:
638 output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResultPart.Output)
639 if !ok {
640 warnings = append(warnings, fantasy.CallWarning{
641 Type: fantasy.CallWarningTypeOther,
642 Message: "tool result output does not have the right type",
643 })
644 continue
645 }
646 // The Responses API function_call_output only accepts a
647 // string. Emit a text placeholder (preserving any
648 // accompanying text) so the tool_call/tool_result pairing
649 // stays valid, then attach the media as a synthetic user
650 // input_image so vision-capable models still receive it.
651 outputStr = output.Text
652 if outputStr == "" {
653 outputStr = fmt.Sprintf("The tool returned %s content; see the following user message.", output.MediaType)
654 }
655 if strings.HasPrefix(output.MediaType, "image/") {
656 imageURL := fmt.Sprintf("data:%s;base64,%s", output.MediaType, output.Data)
657 followupParts = append(followupParts, responses.ResponseInputContentUnionParam{
658 OfInputImage: &responses.ResponseInputImageParam{
659 Type: "input_image",
660 ImageURL: param.NewOpt(imageURL),
661 },
662 })
663 } else {
664 warnings = append(warnings, fantasy.CallWarning{
665 Type: fantasy.CallWarningTypeOther,
666 Message: fmt.Sprintf("tool result media type %s not supported, sending text placeholder only", output.MediaType),
667 })
668 }
669 default:
670 warnings = append(warnings, fantasy.CallWarning{
671 Type: fantasy.CallWarningTypeOther,
672 Message: fmt.Sprintf("tool result output type %q not supported", toolResultPart.Output.GetType()),
673 })
674 continue
675 }
676
677 input = append(input, responses.ResponseInputItemParamOfFunctionCallOutput(toolResultPart.ToolCallID, outputStr))
678 if len(followupParts) > 0 {
679 input = append(input, responses.ResponseInputItemParamOfMessage(followupParts, responses.EasyInputMessageRoleUser))
680 }
681 }
682 }
683 }
684
685 return input, warnings
686}
687
688func hasVisibleResponsesUserContent(content responses.ResponseInputMessageContentListParam) bool {
689 return len(content) > 0
690}
691
692func hasVisibleResponsesAssistantContent(items []responses.ResponseInputItemUnionParam, startIdx int) bool {
693 // Check if we added any assistant content parts from this message
694 for i := startIdx; i < len(items); i++ {
695 if items[i].OfMessage != nil || items[i].OfFunctionCall != nil || items[i].OfItemReference != nil {
696 return true
697 }
698 }
699 return false
700}
701
702func toResponsesTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice, options *ResponsesProviderOptions) ([]responses.ToolUnionParam, responses.ResponseNewParamsToolChoiceUnion, []fantasy.CallWarning) {
703 warnings := make([]fantasy.CallWarning, 0)
704 var openaiTools []responses.ToolUnionParam
705
706 if len(tools) == 0 {
707 return nil, responses.ResponseNewParamsToolChoiceUnion{}, nil
708 }
709
710 strictJSONSchema := false
711 if options != nil && options.StrictJSONSchema != nil {
712 strictJSONSchema = *options.StrictJSONSchema
713 }
714
715 for _, tool := range tools {
716 if tool.GetType() == fantasy.ToolTypeFunction {
717 ft, ok := tool.(fantasy.FunctionTool)
718 if !ok {
719 continue
720 }
721 openaiTools = append(openaiTools, responses.ToolUnionParam{
722 OfFunction: &responses.FunctionToolParam{
723 Name: ft.Name,
724 Description: param.NewOpt(ft.Description),
725 Parameters: ft.InputSchema,
726 Strict: param.NewOpt(strictJSONSchema),
727 Type: "function",
728 },
729 })
730 continue
731 }
732 if tool.GetType() == fantasy.ToolTypeProviderDefined {
733 pt, ok := tool.(fantasy.ProviderDefinedTool)
734 if !ok {
735 continue
736 }
737 switch pt.ID {
738 case "web_search":
739 openaiTools = append(openaiTools, toWebSearchToolParam(pt))
740 continue
741 }
742 }
743
744 warnings = append(warnings, fantasy.CallWarning{
745 Type: fantasy.CallWarningTypeUnsupportedTool,
746 Tool: tool,
747 Message: "tool is not supported",
748 })
749 }
750
751 if toolChoice == nil {
752 return openaiTools, responses.ResponseNewParamsToolChoiceUnion{}, warnings
753 }
754
755 var openaiToolChoice responses.ResponseNewParamsToolChoiceUnion
756
757 switch *toolChoice {
758 case fantasy.ToolChoiceAuto:
759 openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
760 OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptionsAuto),
761 }
762 case fantasy.ToolChoiceNone:
763 openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
764 OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptionsNone),
765 }
766 case fantasy.ToolChoiceRequired:
767 openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
768 OfToolChoiceMode: param.NewOpt(responses.ToolChoiceOptionsRequired),
769 }
770 default:
771 openaiToolChoice = responses.ResponseNewParamsToolChoiceUnion{
772 OfFunctionTool: &responses.ToolChoiceFunctionParam{
773 Type: "function",
774 Name: string(*toolChoice),
775 },
776 }
777 }
778
779 return openaiTools, openaiToolChoice, warnings
780}
781
782func (o responsesLanguageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
783 params, warnings, err := o.prepareParams(call)
784 if err != nil {
785 return nil, err
786 }
787
788 response, err := o.client.Responses.New(ctx, *params, callUARequestOptions(call)...)
789 if err != nil {
790 return nil, toProviderErr(err)
791 }
792
793 if response.Error.Message != "" {
794 return nil, &fantasy.Error{
795 Title: "provider error",
796 Message: fmt.Sprintf("%s (code: %s)", response.Error.Message, response.Error.Code),
797 }
798 }
799
800 var content []fantasy.Content
801 hasFunctionCall := false
802
803 for _, outputItem := range response.Output {
804 switch outputItem.Type {
805 case "message":
806 for _, contentPart := range outputItem.Content {
807 if contentPart.Type == "output_text" {
808 content = append(content, fantasy.TextContent{
809 Text: contentPart.Text,
810 })
811
812 for _, annotation := range contentPart.Annotations {
813 switch annotation.Type {
814 case "url_citation":
815 content = append(content, fantasy.SourceContent{
816 SourceType: fantasy.SourceTypeURL,
817 ID: uuid.NewString(),
818 URL: annotation.URL,
819 Title: annotation.Title,
820 })
821 case "file_citation":
822 title := "Document"
823 if annotation.Filename != "" {
824 title = annotation.Filename
825 }
826 filename := annotation.Filename
827 if filename == "" {
828 filename = annotation.FileID
829 }
830 content = append(content, fantasy.SourceContent{
831 SourceType: fantasy.SourceTypeDocument,
832 ID: uuid.NewString(),
833 MediaType: "text/plain",
834 Title: title,
835 Filename: filename,
836 })
837 }
838 }
839 }
840 }
841
842 case "function_call":
843 hasFunctionCall = true
844 content = append(content, fantasy.ToolCallContent{
845 ProviderExecuted: false,
846 ToolCallID: outputItem.CallID,
847 ToolName: outputItem.Name,
848 Input: outputItem.Arguments.OfString,
849 })
850
851 case "web_search_call":
852 // Provider-executed web search tool call. Emit both
853 // a ToolCallContent and ToolResultContent as a pair,
854 // matching the vercel/ai pattern for provider tools.
855 //
856 // Note: source citations come from url_citation annotations
857 // on the message text (handled in the "message" case above),
858 // not from the web_search_call action.
859 wsMeta := webSearchCallToMetadata(outputItem.ID, outputItem.Action)
860 content = append(content, fantasy.ToolCallContent{
861 ProviderExecuted: true,
862 ToolCallID: outputItem.ID,
863 ToolName: "web_search",
864 })
865 content = append(content, fantasy.ToolResultContent{
866 ProviderExecuted: true,
867 ToolCallID: outputItem.ID,
868 ToolName: "web_search",
869 ProviderMetadata: fantasy.ProviderMetadata{
870 Name: wsMeta,
871 },
872 })
873 case "reasoning":
874 metadata := &ResponsesReasoningMetadata{
875 ItemID: outputItem.ID,
876 }
877 if outputItem.EncryptedContent != "" {
878 metadata.EncryptedContent = &outputItem.EncryptedContent
879 }
880
881 if len(outputItem.Summary) == 0 && metadata.EncryptedContent == nil {
882 continue
883 }
884
885 // When there are no summary parts, add an empty reasoning part
886 summaries := outputItem.Summary
887 if len(summaries) == 0 {
888 summaries = []responses.ResponseReasoningItemSummary{{Type: "summary_text", Text: ""}}
889 }
890
891 for _, s := range summaries {
892 metadata.Summary = append(metadata.Summary, s.Text)
893 }
894
895 content = append(content, fantasy.ReasoningContent{
896 Text: strings.Join(metadata.Summary, "\n"),
897 ProviderMetadata: fantasy.ProviderMetadata{
898 Name: metadata,
899 },
900 })
901 }
902 }
903
904 usage := responsesUsage(*response)
905 finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, hasFunctionCall)
906
907 return &fantasy.Response{
908 Content: content,
909 Usage: usage,
910 FinishReason: finishReason,
911 ProviderMetadata: responsesProviderMetadata(response.ID),
912 Warnings: warnings,
913 }, nil
914}
915
916func mapResponsesFinishReason(reason string, hasFunctionCall bool) fantasy.FinishReason {
917 if hasFunctionCall {
918 return fantasy.FinishReasonToolCalls
919 }
920
921 switch reason {
922 case "":
923 return fantasy.FinishReasonStop
924 case "max_tokens", "max_output_tokens":
925 return fantasy.FinishReasonLength
926 case "content_filter":
927 return fantasy.FinishReasonContentFilter
928 default:
929 return fantasy.FinishReasonOther
930 }
931}
932
933func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
934 params, warnings, err := o.prepareParams(call)
935 if err != nil {
936 return nil, err
937 }
938
939 stream := o.client.Responses.NewStreaming(ctx, *params, callUARequestOptions(call)...)
940
941 finishReason := fantasy.FinishReasonUnknown
942 var usage fantasy.Usage
943 // responseID tracks the server-assigned response ID. It's first set from the
944 // response.created event and may be overwritten by response.completed or
945 // response.incomplete events. Per the OpenAI API contract, these IDs are
946 // identical; the overwrites ensure we have the final value even if an event
947 // is missed.
948 responseID := ""
949 ongoingToolCalls := make(map[int64]*ongoingToolCall)
950 hasFunctionCall := false
951 activeReasoning := make(map[string]*reasoningState)
952
953 return func(yield func(fantasy.StreamPart) bool) {
954 if len(warnings) > 0 {
955 if !yield(fantasy.StreamPart{
956 Type: fantasy.StreamPartTypeWarnings,
957 Warnings: warnings,
958 }) {
959 return
960 }
961 }
962
963 for stream.Next() {
964 event := stream.Current()
965
966 switch event.Type {
967 case "response.created":
968 created := event.AsResponseCreated()
969 responseID = created.Response.ID
970
971 case "response.output_item.added":
972 added := event.AsResponseOutputItemAdded()
973 switch added.Item.Type {
974 case "function_call":
975 ongoingToolCalls[added.OutputIndex] = &ongoingToolCall{
976 toolName: added.Item.Name,
977 toolCallID: added.Item.CallID,
978 }
979 if !yield(fantasy.StreamPart{
980 Type: fantasy.StreamPartTypeToolInputStart,
981 ID: added.Item.CallID,
982 ToolCallName: added.Item.Name,
983 }) {
984 return
985 }
986
987 case "web_search_call":
988 // Provider-executed web search; emit start.
989 if !yield(fantasy.StreamPart{
990 Type: fantasy.StreamPartTypeToolInputStart,
991 ID: added.Item.ID,
992 ToolCallName: "web_search",
993 ProviderExecuted: true,
994 }) {
995 return
996 }
997
998 case "message":
999 if !yield(fantasy.StreamPart{
1000 Type: fantasy.StreamPartTypeTextStart,
1001 ID: added.Item.ID,
1002 }) {
1003 return
1004 }
1005
1006 case "reasoning":
1007 metadata := &ResponsesReasoningMetadata{
1008 ItemID: added.Item.ID,
1009 Summary: []string{},
1010 }
1011 if added.Item.EncryptedContent != "" {
1012 metadata.EncryptedContent = &added.Item.EncryptedContent
1013 }
1014
1015 activeReasoning[added.Item.ID] = &reasoningState{
1016 metadata: metadata,
1017 }
1018 if !yield(fantasy.StreamPart{
1019 Type: fantasy.StreamPartTypeReasoningStart,
1020 ID: added.Item.ID,
1021 ProviderMetadata: fantasy.ProviderMetadata{
1022 Name: metadata,
1023 },
1024 }) {
1025 return
1026 }
1027 }
1028
1029 case "response.output_item.done":
1030 done := event.AsResponseOutputItemDone()
1031 switch done.Item.Type {
1032 case "function_call":
1033 tc := ongoingToolCalls[done.OutputIndex]
1034 if tc != nil {
1035 delete(ongoingToolCalls, done.OutputIndex)
1036 hasFunctionCall = true
1037
1038 if !yield(fantasy.StreamPart{
1039 Type: fantasy.StreamPartTypeToolInputEnd,
1040 ID: done.Item.CallID,
1041 }) {
1042 return
1043 }
1044 if !yield(fantasy.StreamPart{
1045 Type: fantasy.StreamPartTypeToolCall,
1046 ID: done.Item.CallID,
1047 ToolCallName: done.Item.Name,
1048 ToolCallInput: done.Item.Arguments.OfString,
1049 }) {
1050 return
1051 }
1052 }
1053
1054 case "web_search_call":
1055 // Provider-executed web search completed.
1056 // Source citations come from url_citation annotations
1057 // on the streamed message text, not from the action.
1058 if !yield(fantasy.StreamPart{
1059 Type: fantasy.StreamPartTypeToolInputEnd,
1060 ID: done.Item.ID,
1061 }) {
1062 return
1063 }
1064 if !yield(fantasy.StreamPart{
1065 Type: fantasy.StreamPartTypeToolCall,
1066 ID: done.Item.ID,
1067 ToolCallName: "web_search",
1068 ProviderExecuted: true,
1069 }) {
1070 return
1071 }
1072 // Emit a ToolResult so the agent framework
1073 // includes it in round-trip messages.
1074 if !yield(fantasy.StreamPart{
1075 Type: fantasy.StreamPartTypeToolResult,
1076 ID: done.Item.ID,
1077 ToolCallName: "web_search",
1078 ProviderExecuted: true,
1079 ProviderMetadata: fantasy.ProviderMetadata{
1080 Name: webSearchCallToMetadata(done.Item.ID, done.Item.Action),
1081 },
1082 }) {
1083 return
1084 }
1085 case "message":
1086 if !yield(fantasy.StreamPart{
1087 Type: fantasy.StreamPartTypeTextEnd,
1088 ID: done.Item.ID,
1089 }) {
1090 return
1091 }
1092
1093 case "reasoning":
1094 state := activeReasoning[done.Item.ID]
1095 if state != nil {
1096 if !yield(fantasy.StreamPart{
1097 Type: fantasy.StreamPartTypeReasoningEnd,
1098 ID: done.Item.ID,
1099 ProviderMetadata: fantasy.ProviderMetadata{
1100 Name: state.metadata,
1101 },
1102 }) {
1103 return
1104 }
1105 delete(activeReasoning, done.Item.ID)
1106 }
1107 }
1108
1109 case "response.function_call_arguments.delta":
1110 delta := event.AsResponseFunctionCallArgumentsDelta()
1111 tc := ongoingToolCalls[delta.OutputIndex]
1112 if tc != nil {
1113 if !yield(fantasy.StreamPart{
1114 Type: fantasy.StreamPartTypeToolInputDelta,
1115 ID: tc.toolCallID,
1116 Delta: delta.Delta,
1117 }) {
1118 return
1119 }
1120 }
1121
1122 case "response.output_text.delta":
1123 textDelta := event.AsResponseOutputTextDelta()
1124 if !yield(fantasy.StreamPart{
1125 Type: fantasy.StreamPartTypeTextDelta,
1126 ID: textDelta.ItemID,
1127 Delta: textDelta.Delta,
1128 }) {
1129 return
1130 }
1131
1132 case "response.output_text.annotation.added":
1133 added := event.AsResponseOutputTextAnnotationAdded()
1134 // The Annotation field is typed as `any` in the SDK;
1135 // it deserializes as map[string]any from JSON.
1136 annotationMap, ok := added.Annotation.(map[string]any)
1137 if !ok {
1138 break
1139 }
1140 annotationType, _ := annotationMap["type"].(string)
1141 switch annotationType {
1142 case "url_citation":
1143 url, _ := annotationMap["url"].(string)
1144 title, _ := annotationMap["title"].(string)
1145 if !yield(fantasy.StreamPart{
1146 Type: fantasy.StreamPartTypeSource,
1147 ID: uuid.NewString(),
1148 SourceType: fantasy.SourceTypeURL,
1149 URL: url,
1150 Title: title,
1151 }) {
1152 return
1153 }
1154 case "file_citation":
1155 title := "Document"
1156 if fn, ok := annotationMap["filename"].(string); ok && fn != "" {
1157 title = fn
1158 }
1159 if !yield(fantasy.StreamPart{
1160 Type: fantasy.StreamPartTypeSource,
1161 ID: uuid.NewString(),
1162 SourceType: fantasy.SourceTypeDocument,
1163 Title: title,
1164 }) {
1165 return
1166 }
1167 }
1168
1169 case "response.reasoning_summary_part.added":
1170 added := event.AsResponseReasoningSummaryPartAdded()
1171 state := activeReasoning[added.ItemID]
1172 if state != nil {
1173 state.metadata.Summary = append(state.metadata.Summary, "")
1174 activeReasoning[added.ItemID] = state
1175 if !yield(fantasy.StreamPart{
1176 Type: fantasy.StreamPartTypeReasoningDelta,
1177 ID: added.ItemID,
1178 Delta: "\n",
1179 ProviderMetadata: fantasy.ProviderMetadata{
1180 Name: state.metadata,
1181 },
1182 }) {
1183 return
1184 }
1185 }
1186
1187 case "response.reasoning_summary_text.delta":
1188 textDelta := event.AsResponseReasoningSummaryTextDelta()
1189 state := activeReasoning[textDelta.ItemID]
1190 if state != nil {
1191 if len(state.metadata.Summary)-1 >= int(textDelta.SummaryIndex) {
1192 state.metadata.Summary[textDelta.SummaryIndex] += textDelta.Delta
1193 }
1194 activeReasoning[textDelta.ItemID] = state
1195 if !yield(fantasy.StreamPart{
1196 Type: fantasy.StreamPartTypeReasoningDelta,
1197 ID: textDelta.ItemID,
1198 Delta: textDelta.Delta,
1199 ProviderMetadata: fantasy.ProviderMetadata{
1200 Name: state.metadata,
1201 },
1202 }) {
1203 return
1204 }
1205 }
1206
1207 case "response.completed":
1208 completed := event.AsResponseCompleted()
1209 responseID = completed.Response.ID
1210 finishReason = mapResponsesFinishReason(completed.Response.IncompleteDetails.Reason, hasFunctionCall)
1211 usage = responsesUsage(completed.Response)
1212
1213 case "response.incomplete":
1214 incomplete := event.AsResponseIncomplete()
1215 responseID = incomplete.Response.ID
1216 finishReason = mapResponsesFinishReason(incomplete.Response.IncompleteDetails.Reason, hasFunctionCall)
1217 usage = responsesUsage(incomplete.Response)
1218
1219 case "error":
1220 errorEvent := event.AsError()
1221 if !yield(fantasy.StreamPart{
1222 Type: fantasy.StreamPartTypeError,
1223 Error: fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code),
1224 }) {
1225 return
1226 }
1227 return
1228 }
1229 }
1230
1231 err := stream.Err()
1232 if err != nil {
1233 yield(fantasy.StreamPart{
1234 Type: fantasy.StreamPartTypeError,
1235 Error: toProviderErr(err),
1236 })
1237 return
1238 }
1239
1240 yield(fantasy.StreamPart{
1241 Type: fantasy.StreamPartTypeFinish,
1242 Usage: usage,
1243 FinishReason: finishReason,
1244 ProviderMetadata: responsesProviderMetadata(responseID),
1245 })
1246 }, nil
1247}
1248
1249// toWebSearchToolParam converts a ProviderDefinedTool with ID
1250// "web_search" into the OpenAI SDK's WebSearchToolParam.
1251func toWebSearchToolParam(pt fantasy.ProviderDefinedTool) responses.ToolUnionParam {
1252 wst := responses.WebSearchToolParam{
1253 Type: responses.WebSearchToolTypeWebSearch,
1254 }
1255 if pt.Args != nil {
1256 if size, ok := pt.Args["search_context_size"].(SearchContextSize); ok && size != "" {
1257 wst.SearchContextSize = responses.WebSearchToolSearchContextSize(size)
1258 }
1259 // Also accept plain string for search_context_size.
1260 if size, ok := pt.Args["search_context_size"].(string); ok && size != "" {
1261 wst.SearchContextSize = responses.WebSearchToolSearchContextSize(size)
1262 }
1263 if domains, ok := pt.Args["allowed_domains"].([]string); ok && len(domains) > 0 {
1264 wst.Filters.AllowedDomains = domains
1265 }
1266 if loc, ok := pt.Args["user_location"].(*WebSearchUserLocation); ok && loc != nil {
1267 if loc.City != "" {
1268 wst.UserLocation.City = param.NewOpt(loc.City)
1269 }
1270 if loc.Region != "" {
1271 wst.UserLocation.Region = param.NewOpt(loc.Region)
1272 }
1273 if loc.Country != "" {
1274 wst.UserLocation.Country = param.NewOpt(loc.Country)
1275 }
1276 if loc.Timezone != "" {
1277 wst.UserLocation.Timezone = param.NewOpt(loc.Timezone)
1278 }
1279 }
1280 }
1281 return responses.ToolUnionParam{
1282 OfWebSearch: &wst,
1283 }
1284}
1285
1286// webSearchCallToMetadata converts an OpenAI web search call output
1287// into our structured metadata for round-tripping.
1288func webSearchCallToMetadata(itemID string, action responses.ResponseOutputItemUnionAction) *WebSearchCallMetadata {
1289 meta := &WebSearchCallMetadata{ItemID: itemID}
1290 if action.Type != "" {
1291 a := &WebSearchAction{
1292 Type: action.Type,
1293 Query: action.Query,
1294 }
1295 for _, src := range action.Sources {
1296 a.Sources = append(a.Sources, WebSearchSource{
1297 Type: string(src.Type),
1298 URL: src.URL,
1299 })
1300 }
1301 meta.Action = a
1302 }
1303 return meta
1304}
1305
1306// GetReasoningMetadata extracts reasoning metadata from provider options for responses models.
1307func GetReasoningMetadata(providerOptions fantasy.ProviderOptions) *ResponsesReasoningMetadata {
1308 if openaiResponsesOptions, ok := providerOptions[Name]; ok {
1309 if reasoning, ok := openaiResponsesOptions.(*ResponsesReasoningMetadata); ok {
1310 return reasoning
1311 }
1312 }
1313 return nil
1314}
1315
1316type ongoingToolCall struct {
1317 toolName string
1318 toolCallID string
1319}
1320
1321type reasoningState struct {
1322 metadata *ResponsesReasoningMetadata
1323}
1324
1325// GenerateObject implements fantasy.LanguageModel.
1326func (o responsesLanguageModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
1327 switch o.objectMode {
1328 case fantasy.ObjectModeText:
1329 return object.GenerateWithText(ctx, o, call)
1330 case fantasy.ObjectModeTool:
1331 return object.GenerateWithTool(ctx, o, call)
1332 default:
1333 return o.generateObjectWithJSONMode(ctx, call)
1334 }
1335}
1336
1337// StreamObject implements fantasy.LanguageModel.
1338func (o responsesLanguageModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
1339 switch o.objectMode {
1340 case fantasy.ObjectModeTool:
1341 return object.StreamWithTool(ctx, o, call)
1342 case fantasy.ObjectModeText:
1343 return object.StreamWithText(ctx, o, call)
1344 default:
1345 return o.streamObjectWithJSONMode(ctx, call)
1346 }
1347}
1348
1349func (o responsesLanguageModel) generateObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
1350 // Convert our Schema to OpenAI's JSON Schema format
1351 jsonSchemaMap := schema.ToMap(call.Schema)
1352
1353 // Add additionalProperties: false recursively for strict mode (OpenAI requirement)
1354 addAdditionalPropertiesFalse(jsonSchemaMap)
1355
1356 schemaName := call.SchemaName
1357 if schemaName == "" {
1358 schemaName = "response"
1359 }
1360
1361 // Build request using prepareParams
1362 fantasyCall := fantasy.Call{
1363 Prompt: call.Prompt,
1364 MaxOutputTokens: call.MaxOutputTokens,
1365 Temperature: call.Temperature,
1366 TopP: call.TopP,
1367 PresencePenalty: call.PresencePenalty,
1368 FrequencyPenalty: call.FrequencyPenalty,
1369 ProviderOptions: call.ProviderOptions,
1370 }
1371
1372 params, warnings, err := o.prepareParams(fantasyCall)
1373 if err != nil {
1374 return nil, err
1375 }
1376
1377 // Add structured output via Text.Format field
1378 params.Text = responses.ResponseTextConfigParam{
1379 Format: responses.ResponseFormatTextConfigParamOfJSONSchema(schemaName, jsonSchemaMap),
1380 }
1381
1382 // Make request
1383 response, err := o.client.Responses.New(ctx, *params, objectCallUARequestOptions(call)...)
1384 if err != nil {
1385 return nil, toProviderErr(err)
1386 }
1387
1388 if response.Error.Message != "" {
1389 return nil, &fantasy.Error{
1390 Title: "provider error",
1391 Message: fmt.Sprintf("%s (code: %s)", response.Error.Message, response.Error.Code),
1392 }
1393 }
1394
1395 // Extract JSON text from response
1396 var jsonText string
1397 for _, outputItem := range response.Output {
1398 if outputItem.Type == "message" {
1399 for _, contentPart := range outputItem.Content {
1400 if contentPart.Type == "output_text" {
1401 jsonText = contentPart.Text
1402 break
1403 }
1404 }
1405 }
1406 }
1407
1408 if jsonText == "" {
1409 usage := fantasy.Usage{
1410 InputTokens: response.Usage.InputTokens,
1411 OutputTokens: response.Usage.OutputTokens,
1412 TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens,
1413 }
1414 finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, false)
1415 return nil, &fantasy.NoObjectGeneratedError{
1416 RawText: "",
1417 ParseError: fmt.Errorf("no text content in response"),
1418 Usage: usage,
1419 FinishReason: finishReason,
1420 }
1421 }
1422
1423 // Parse and validate
1424 var obj any
1425 if call.RepairText != nil {
1426 obj, err = schema.ParseAndValidateWithRepair(ctx, jsonText, call.Schema, call.RepairText)
1427 } else {
1428 obj, err = schema.ParseAndValidate(jsonText, call.Schema)
1429 }
1430
1431 usage := responsesUsage(*response)
1432 finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, false)
1433
1434 if err != nil {
1435 // Add usage info to error
1436 if nogErr, ok := err.(*fantasy.NoObjectGeneratedError); ok {
1437 nogErr.Usage = usage
1438 nogErr.FinishReason = finishReason
1439 }
1440 return nil, err
1441 }
1442
1443 return &fantasy.ObjectResponse{
1444 Object: obj,
1445 RawText: jsonText,
1446 Usage: usage,
1447 FinishReason: finishReason,
1448 Warnings: warnings,
1449 ProviderMetadata: responsesProviderMetadata(response.ID),
1450 }, nil
1451}
1452
1453func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
1454 // Convert our Schema to OpenAI's JSON Schema format
1455 jsonSchemaMap := schema.ToMap(call.Schema)
1456
1457 // Add additionalProperties: false recursively for strict mode (OpenAI requirement)
1458 addAdditionalPropertiesFalse(jsonSchemaMap)
1459
1460 schemaName := call.SchemaName
1461 if schemaName == "" {
1462 schemaName = "response"
1463 }
1464
1465 // Build request using prepareParams
1466 fantasyCall := fantasy.Call{
1467 Prompt: call.Prompt,
1468 MaxOutputTokens: call.MaxOutputTokens,
1469 Temperature: call.Temperature,
1470 TopP: call.TopP,
1471 PresencePenalty: call.PresencePenalty,
1472 FrequencyPenalty: call.FrequencyPenalty,
1473 ProviderOptions: call.ProviderOptions,
1474 }
1475
1476 params, warnings, err := o.prepareParams(fantasyCall)
1477 if err != nil {
1478 return nil, err
1479 }
1480
1481 // Add structured output via Text.Format field
1482 params.Text = responses.ResponseTextConfigParam{
1483 Format: responses.ResponseFormatTextConfigParamOfJSONSchema(schemaName, jsonSchemaMap),
1484 }
1485
1486 stream := o.client.Responses.NewStreaming(ctx, *params, objectCallUARequestOptions(call)...)
1487
1488 return func(yield func(fantasy.ObjectStreamPart) bool) {
1489 if len(warnings) > 0 {
1490 if !yield(fantasy.ObjectStreamPart{
1491 Type: fantasy.ObjectStreamPartTypeObject,
1492 Warnings: warnings,
1493 }) {
1494 return
1495 }
1496 }
1497
1498 var accumulated string
1499 var lastParsedObject any
1500 var usage fantasy.Usage
1501 var finishReason fantasy.FinishReason
1502 // responseID tracks the server-assigned response ID. It's first set from the
1503 // response.created event and may be overwritten by response.completed or
1504 // response.incomplete events. Per the OpenAI API contract, these IDs are
1505 // identical; the overwrites ensure we have the final value even if an event
1506 // is missed.
1507 var responseID string
1508 var streamErr error
1509 hasFunctionCall := false
1510
1511 for stream.Next() {
1512 event := stream.Current()
1513
1514 switch event.Type {
1515 case "response.created":
1516 created := event.AsResponseCreated()
1517 responseID = created.Response.ID
1518
1519 case "response.output_text.delta":
1520 textDelta := event.AsResponseOutputTextDelta()
1521 accumulated += textDelta.Delta
1522
1523 // Try to parse the accumulated text
1524 obj, state, parseErr := schema.ParsePartialJSON(accumulated)
1525
1526 // If we successfully parsed, validate and emit
1527 if state == schema.ParseStateSuccessful || state == schema.ParseStateRepaired {
1528 if err := schema.ValidateAgainstSchema(obj, call.Schema); err == nil {
1529 // Only emit if object is different from last
1530 if !reflect.DeepEqual(obj, lastParsedObject) {
1531 if !yield(fantasy.ObjectStreamPart{
1532 Type: fantasy.ObjectStreamPartTypeObject,
1533 Object: obj,
1534 }) {
1535 return
1536 }
1537 lastParsedObject = obj
1538 }
1539 }
1540 }
1541
1542 // If parsing failed and we have a repair function, try it
1543 if state == schema.ParseStateFailed && call.RepairText != nil {
1544 repairedText, repairErr := call.RepairText(ctx, accumulated, parseErr)
1545 if repairErr == nil {
1546 obj2, state2, _ := schema.ParsePartialJSON(repairedText)
1547 if (state2 == schema.ParseStateSuccessful || state2 == schema.ParseStateRepaired) &&
1548 schema.ValidateAgainstSchema(obj2, call.Schema) == nil {
1549 if !reflect.DeepEqual(obj2, lastParsedObject) {
1550 if !yield(fantasy.ObjectStreamPart{
1551 Type: fantasy.ObjectStreamPartTypeObject,
1552 Object: obj2,
1553 }) {
1554 return
1555 }
1556 lastParsedObject = obj2
1557 }
1558 }
1559 }
1560 }
1561
1562 case "response.completed":
1563 completed := event.AsResponseCompleted()
1564 responseID = completed.Response.ID
1565 finishReason = mapResponsesFinishReason(completed.Response.IncompleteDetails.Reason, hasFunctionCall)
1566 usage = responsesUsage(completed.Response)
1567
1568 case "response.incomplete":
1569 incomplete := event.AsResponseIncomplete()
1570 responseID = incomplete.Response.ID
1571 finishReason = mapResponsesFinishReason(incomplete.Response.IncompleteDetails.Reason, hasFunctionCall)
1572 usage = responsesUsage(incomplete.Response)
1573
1574 case "error":
1575 errorEvent := event.AsError()
1576 streamErr = fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code)
1577 if !yield(fantasy.ObjectStreamPart{
1578 Type: fantasy.ObjectStreamPartTypeError,
1579 Error: streamErr,
1580 }) {
1581 return
1582 }
1583 return
1584 }
1585 }
1586
1587 err := stream.Err()
1588 if err != nil {
1589 yield(fantasy.ObjectStreamPart{
1590 Type: fantasy.ObjectStreamPartTypeError,
1591 Error: toProviderErr(err),
1592 })
1593 return
1594 }
1595
1596 // Final validation and emit
1597 if streamErr == nil && lastParsedObject != nil {
1598 yield(fantasy.ObjectStreamPart{
1599 Type: fantasy.ObjectStreamPartTypeFinish,
1600 Usage: usage,
1601 FinishReason: finishReason,
1602 ProviderMetadata: responsesProviderMetadata(responseID),
1603 })
1604 } else if streamErr == nil && lastParsedObject == nil {
1605 // No object was generated
1606 yield(fantasy.ObjectStreamPart{
1607 Type: fantasy.ObjectStreamPartTypeError,
1608 Error: &fantasy.NoObjectGeneratedError{
1609 RawText: accumulated,
1610 ParseError: fmt.Errorf("no valid object generated in stream"),
1611 Usage: usage,
1612 FinishReason: finishReason,
1613 },
1614 })
1615 }
1616 }, nil
1617}