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 if response == nil {
793 return nil, &fantasy.Error{Title: "no response", Message: "provider returned nil response"}
794 }
795
796 if response.Error.Message != "" {
797 return nil, &fantasy.Error{
798 Title: "provider error",
799 Message: fmt.Sprintf("%s (code: %s)", response.Error.Message, response.Error.Code),
800 }
801 }
802
803 var content []fantasy.Content
804 hasFunctionCall := false
805
806 for _, outputItem := range response.Output {
807 switch outputItem.Type {
808 case "message":
809 for _, contentPart := range outputItem.Content {
810 if contentPart.Type == "output_text" {
811 content = append(content, fantasy.TextContent{
812 Text: contentPart.Text,
813 })
814
815 for _, annotation := range contentPart.Annotations {
816 switch annotation.Type {
817 case "url_citation":
818 content = append(content, fantasy.SourceContent{
819 SourceType: fantasy.SourceTypeURL,
820 ID: uuid.NewString(),
821 URL: annotation.URL,
822 Title: annotation.Title,
823 })
824 case "file_citation":
825 title := "Document"
826 if annotation.Filename != "" {
827 title = annotation.Filename
828 }
829 filename := annotation.Filename
830 if filename == "" {
831 filename = annotation.FileID
832 }
833 content = append(content, fantasy.SourceContent{
834 SourceType: fantasy.SourceTypeDocument,
835 ID: uuid.NewString(),
836 MediaType: "text/plain",
837 Title: title,
838 Filename: filename,
839 })
840 }
841 }
842 }
843 }
844
845 case "function_call":
846 hasFunctionCall = true
847 content = append(content, fantasy.ToolCallContent{
848 ProviderExecuted: false,
849 ToolCallID: outputItem.CallID,
850 ToolName: outputItem.Name,
851 Input: outputItem.Arguments.OfString,
852 })
853
854 case "web_search_call":
855 // Provider-executed web search tool call. Emit both
856 // a ToolCallContent and ToolResultContent as a pair,
857 // matching the vercel/ai pattern for provider tools.
858 //
859 // Note: source citations come from url_citation annotations
860 // on the message text (handled in the "message" case above),
861 // not from the web_search_call action.
862 wsMeta := webSearchCallToMetadata(outputItem.ID, outputItem.Action)
863 content = append(content, fantasy.ToolCallContent{
864 ProviderExecuted: true,
865 ToolCallID: outputItem.ID,
866 ToolName: "web_search",
867 })
868 content = append(content, fantasy.ToolResultContent{
869 ProviderExecuted: true,
870 ToolCallID: outputItem.ID,
871 ToolName: "web_search",
872 ProviderMetadata: fantasy.ProviderMetadata{
873 Name: wsMeta,
874 },
875 })
876 case "reasoning":
877 metadata := &ResponsesReasoningMetadata{
878 ItemID: outputItem.ID,
879 }
880 if outputItem.EncryptedContent != "" {
881 metadata.EncryptedContent = &outputItem.EncryptedContent
882 }
883
884 if len(outputItem.Summary) == 0 && metadata.EncryptedContent == nil {
885 continue
886 }
887
888 // When there are no summary parts, add an empty reasoning part
889 summaries := outputItem.Summary
890 if len(summaries) == 0 {
891 summaries = []responses.ResponseReasoningItemSummary{{Type: "summary_text", Text: ""}}
892 }
893
894 for _, s := range summaries {
895 metadata.Summary = append(metadata.Summary, s.Text)
896 }
897
898 content = append(content, fantasy.ReasoningContent{
899 Text: strings.Join(metadata.Summary, "\n"),
900 ProviderMetadata: fantasy.ProviderMetadata{
901 Name: metadata,
902 },
903 })
904 }
905 }
906
907 usage := responsesUsage(*response)
908 finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, hasFunctionCall)
909
910 return &fantasy.Response{
911 Content: content,
912 Usage: usage,
913 FinishReason: finishReason,
914 ProviderMetadata: responsesProviderMetadata(response.ID),
915 Warnings: warnings,
916 }, nil
917}
918
919func mapResponsesFinishReason(reason string, hasFunctionCall bool) fantasy.FinishReason {
920 if hasFunctionCall {
921 return fantasy.FinishReasonToolCalls
922 }
923
924 switch reason {
925 case "":
926 return fantasy.FinishReasonStop
927 case "max_tokens", "max_output_tokens":
928 return fantasy.FinishReasonLength
929 case "content_filter":
930 return fantasy.FinishReasonContentFilter
931 default:
932 return fantasy.FinishReasonOther
933 }
934}
935
936func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
937 params, warnings, err := o.prepareParams(call)
938 if err != nil {
939 return nil, err
940 }
941
942 stream := o.client.Responses.NewStreaming(ctx, *params, callUARequestOptions(call)...)
943
944 finishReason := fantasy.FinishReasonUnknown
945 var usage fantasy.Usage
946 // responseID tracks the server-assigned response ID. It's first set from the
947 // response.created event and may be overwritten by response.completed or
948 // response.incomplete events. Per the OpenAI API contract, these IDs are
949 // identical; the overwrites ensure we have the final value even if an event
950 // is missed.
951 responseID := ""
952 ongoingToolCalls := make(map[int64]*ongoingToolCall)
953 hasFunctionCall := false
954 activeReasoning := make(map[string]*reasoningState)
955
956 return func(yield func(fantasy.StreamPart) bool) {
957 if len(warnings) > 0 {
958 if !yield(fantasy.StreamPart{
959 Type: fantasy.StreamPartTypeWarnings,
960 Warnings: warnings,
961 }) {
962 return
963 }
964 }
965
966 for stream.Next() {
967 event := stream.Current()
968
969 switch event.Type {
970 case "response.created":
971 created := event.AsResponseCreated()
972 responseID = created.Response.ID
973
974 case "response.output_item.added":
975 added := event.AsResponseOutputItemAdded()
976 switch added.Item.Type {
977 case "function_call":
978 ongoingToolCalls[added.OutputIndex] = &ongoingToolCall{
979 toolName: added.Item.Name,
980 toolCallID: added.Item.CallID,
981 }
982 if !yield(fantasy.StreamPart{
983 Type: fantasy.StreamPartTypeToolInputStart,
984 ID: added.Item.CallID,
985 ToolCallName: added.Item.Name,
986 }) {
987 return
988 }
989
990 case "web_search_call":
991 // Provider-executed web search; emit start.
992 if !yield(fantasy.StreamPart{
993 Type: fantasy.StreamPartTypeToolInputStart,
994 ID: added.Item.ID,
995 ToolCallName: "web_search",
996 ProviderExecuted: true,
997 }) {
998 return
999 }
1000
1001 case "message":
1002 if !yield(fantasy.StreamPart{
1003 Type: fantasy.StreamPartTypeTextStart,
1004 ID: added.Item.ID,
1005 }) {
1006 return
1007 }
1008
1009 case "reasoning":
1010 metadata := &ResponsesReasoningMetadata{
1011 ItemID: added.Item.ID,
1012 Summary: []string{},
1013 }
1014 if added.Item.EncryptedContent != "" {
1015 metadata.EncryptedContent = &added.Item.EncryptedContent
1016 }
1017
1018 activeReasoning[added.Item.ID] = &reasoningState{
1019 metadata: metadata,
1020 }
1021 if !yield(fantasy.StreamPart{
1022 Type: fantasy.StreamPartTypeReasoningStart,
1023 ID: added.Item.ID,
1024 ProviderMetadata: fantasy.ProviderMetadata{
1025 Name: metadata,
1026 },
1027 }) {
1028 return
1029 }
1030 }
1031
1032 case "response.output_item.done":
1033 done := event.AsResponseOutputItemDone()
1034 switch done.Item.Type {
1035 case "function_call":
1036 tc := ongoingToolCalls[done.OutputIndex]
1037 if tc != nil {
1038 delete(ongoingToolCalls, done.OutputIndex)
1039 hasFunctionCall = true
1040
1041 if !yield(fantasy.StreamPart{
1042 Type: fantasy.StreamPartTypeToolInputEnd,
1043 ID: done.Item.CallID,
1044 }) {
1045 return
1046 }
1047 if !yield(fantasy.StreamPart{
1048 Type: fantasy.StreamPartTypeToolCall,
1049 ID: done.Item.CallID,
1050 ToolCallName: done.Item.Name,
1051 ToolCallInput: done.Item.Arguments.OfString,
1052 }) {
1053 return
1054 }
1055 }
1056
1057 case "web_search_call":
1058 // Provider-executed web search completed.
1059 // Source citations come from url_citation annotations
1060 // on the streamed message text, not from the action.
1061 if !yield(fantasy.StreamPart{
1062 Type: fantasy.StreamPartTypeToolInputEnd,
1063 ID: done.Item.ID,
1064 }) {
1065 return
1066 }
1067 if !yield(fantasy.StreamPart{
1068 Type: fantasy.StreamPartTypeToolCall,
1069 ID: done.Item.ID,
1070 ToolCallName: "web_search",
1071 ProviderExecuted: true,
1072 }) {
1073 return
1074 }
1075 // Emit a ToolResult so the agent framework
1076 // includes it in round-trip messages.
1077 if !yield(fantasy.StreamPart{
1078 Type: fantasy.StreamPartTypeToolResult,
1079 ID: done.Item.ID,
1080 ToolCallName: "web_search",
1081 ProviderExecuted: true,
1082 ProviderMetadata: fantasy.ProviderMetadata{
1083 Name: webSearchCallToMetadata(done.Item.ID, done.Item.Action),
1084 },
1085 }) {
1086 return
1087 }
1088 case "message":
1089 if !yield(fantasy.StreamPart{
1090 Type: fantasy.StreamPartTypeTextEnd,
1091 ID: done.Item.ID,
1092 }) {
1093 return
1094 }
1095
1096 case "reasoning":
1097 state := activeReasoning[done.Item.ID]
1098 if state != nil {
1099 if !yield(fantasy.StreamPart{
1100 Type: fantasy.StreamPartTypeReasoningEnd,
1101 ID: done.Item.ID,
1102 ProviderMetadata: fantasy.ProviderMetadata{
1103 Name: state.metadata,
1104 },
1105 }) {
1106 return
1107 }
1108 delete(activeReasoning, done.Item.ID)
1109 }
1110 }
1111
1112 case "response.function_call_arguments.delta":
1113 delta := event.AsResponseFunctionCallArgumentsDelta()
1114 tc := ongoingToolCalls[delta.OutputIndex]
1115 if tc != nil {
1116 if !yield(fantasy.StreamPart{
1117 Type: fantasy.StreamPartTypeToolInputDelta,
1118 ID: tc.toolCallID,
1119 Delta: delta.Delta,
1120 }) {
1121 return
1122 }
1123 }
1124
1125 case "response.output_text.delta":
1126 textDelta := event.AsResponseOutputTextDelta()
1127 if !yield(fantasy.StreamPart{
1128 Type: fantasy.StreamPartTypeTextDelta,
1129 ID: textDelta.ItemID,
1130 Delta: textDelta.Delta,
1131 }) {
1132 return
1133 }
1134
1135 case "response.output_text.annotation.added":
1136 added := event.AsResponseOutputTextAnnotationAdded()
1137 // The Annotation field is typed as `any` in the SDK;
1138 // it deserializes as map[string]any from JSON.
1139 annotationMap, ok := added.Annotation.(map[string]any)
1140 if !ok {
1141 break
1142 }
1143 annotationType, _ := annotationMap["type"].(string)
1144 switch annotationType {
1145 case "url_citation":
1146 url, _ := annotationMap["url"].(string)
1147 title, _ := annotationMap["title"].(string)
1148 if !yield(fantasy.StreamPart{
1149 Type: fantasy.StreamPartTypeSource,
1150 ID: uuid.NewString(),
1151 SourceType: fantasy.SourceTypeURL,
1152 URL: url,
1153 Title: title,
1154 }) {
1155 return
1156 }
1157 case "file_citation":
1158 title := "Document"
1159 if fn, ok := annotationMap["filename"].(string); ok && fn != "" {
1160 title = fn
1161 }
1162 if !yield(fantasy.StreamPart{
1163 Type: fantasy.StreamPartTypeSource,
1164 ID: uuid.NewString(),
1165 SourceType: fantasy.SourceTypeDocument,
1166 Title: title,
1167 }) {
1168 return
1169 }
1170 }
1171
1172 case "response.reasoning_summary_part.added":
1173 added := event.AsResponseReasoningSummaryPartAdded()
1174 state := activeReasoning[added.ItemID]
1175 if state != nil {
1176 state.metadata.Summary = append(state.metadata.Summary, "")
1177 activeReasoning[added.ItemID] = state
1178 if !yield(fantasy.StreamPart{
1179 Type: fantasy.StreamPartTypeReasoningDelta,
1180 ID: added.ItemID,
1181 Delta: "\n",
1182 ProviderMetadata: fantasy.ProviderMetadata{
1183 Name: state.metadata,
1184 },
1185 }) {
1186 return
1187 }
1188 }
1189
1190 case "response.reasoning_summary_text.delta":
1191 textDelta := event.AsResponseReasoningSummaryTextDelta()
1192 state := activeReasoning[textDelta.ItemID]
1193 if state != nil {
1194 if len(state.metadata.Summary)-1 >= int(textDelta.SummaryIndex) {
1195 state.metadata.Summary[textDelta.SummaryIndex] += textDelta.Delta
1196 }
1197 activeReasoning[textDelta.ItemID] = state
1198 if !yield(fantasy.StreamPart{
1199 Type: fantasy.StreamPartTypeReasoningDelta,
1200 ID: textDelta.ItemID,
1201 Delta: textDelta.Delta,
1202 ProviderMetadata: fantasy.ProviderMetadata{
1203 Name: state.metadata,
1204 },
1205 }) {
1206 return
1207 }
1208 }
1209
1210 case "response.completed":
1211 completed := event.AsResponseCompleted()
1212 responseID = completed.Response.ID
1213 finishReason = mapResponsesFinishReason(completed.Response.IncompleteDetails.Reason, hasFunctionCall)
1214 usage = responsesUsage(completed.Response)
1215
1216 case "response.incomplete":
1217 incomplete := event.AsResponseIncomplete()
1218 responseID = incomplete.Response.ID
1219 finishReason = mapResponsesFinishReason(incomplete.Response.IncompleteDetails.Reason, hasFunctionCall)
1220 usage = responsesUsage(incomplete.Response)
1221
1222 case "error":
1223 errorEvent := event.AsError()
1224 if !yield(fantasy.StreamPart{
1225 Type: fantasy.StreamPartTypeError,
1226 Error: fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code),
1227 }) {
1228 return
1229 }
1230 return
1231 }
1232 }
1233
1234 err := stream.Err()
1235 if err != nil {
1236 yield(fantasy.StreamPart{
1237 Type: fantasy.StreamPartTypeError,
1238 Error: toProviderErr(err),
1239 })
1240 return
1241 }
1242
1243 yield(fantasy.StreamPart{
1244 Type: fantasy.StreamPartTypeFinish,
1245 Usage: usage,
1246 FinishReason: finishReason,
1247 ProviderMetadata: responsesProviderMetadata(responseID),
1248 })
1249 }, nil
1250}
1251
1252// toWebSearchToolParam converts a ProviderDefinedTool with ID
1253// "web_search" into the OpenAI SDK's WebSearchToolParam.
1254func toWebSearchToolParam(pt fantasy.ProviderDefinedTool) responses.ToolUnionParam {
1255 wst := responses.WebSearchToolParam{
1256 Type: responses.WebSearchToolTypeWebSearch,
1257 }
1258 if pt.Args != nil {
1259 if size, ok := pt.Args["search_context_size"].(SearchContextSize); ok && size != "" {
1260 wst.SearchContextSize = responses.WebSearchToolSearchContextSize(size)
1261 }
1262 // Also accept plain string for search_context_size.
1263 if size, ok := pt.Args["search_context_size"].(string); ok && size != "" {
1264 wst.SearchContextSize = responses.WebSearchToolSearchContextSize(size)
1265 }
1266 if domains, ok := pt.Args["allowed_domains"].([]string); ok && len(domains) > 0 {
1267 wst.Filters.AllowedDomains = domains
1268 }
1269 if loc, ok := pt.Args["user_location"].(*WebSearchUserLocation); ok && loc != nil {
1270 if loc.City != "" {
1271 wst.UserLocation.City = param.NewOpt(loc.City)
1272 }
1273 if loc.Region != "" {
1274 wst.UserLocation.Region = param.NewOpt(loc.Region)
1275 }
1276 if loc.Country != "" {
1277 wst.UserLocation.Country = param.NewOpt(loc.Country)
1278 }
1279 if loc.Timezone != "" {
1280 wst.UserLocation.Timezone = param.NewOpt(loc.Timezone)
1281 }
1282 }
1283 }
1284 return responses.ToolUnionParam{
1285 OfWebSearch: &wst,
1286 }
1287}
1288
1289// webSearchCallToMetadata converts an OpenAI web search call output
1290// into our structured metadata for round-tripping.
1291func webSearchCallToMetadata(itemID string, action responses.ResponseOutputItemUnionAction) *WebSearchCallMetadata {
1292 meta := &WebSearchCallMetadata{ItemID: itemID}
1293 if action.Type != "" {
1294 a := &WebSearchAction{
1295 Type: action.Type,
1296 Query: action.Query,
1297 }
1298 for _, src := range action.Sources {
1299 a.Sources = append(a.Sources, WebSearchSource{
1300 Type: string(src.Type),
1301 URL: src.URL,
1302 })
1303 }
1304 meta.Action = a
1305 }
1306 return meta
1307}
1308
1309// GetReasoningMetadata extracts reasoning metadata from provider options for responses models.
1310func GetReasoningMetadata(providerOptions fantasy.ProviderOptions) *ResponsesReasoningMetadata {
1311 if openaiResponsesOptions, ok := providerOptions[Name]; ok {
1312 if reasoning, ok := openaiResponsesOptions.(*ResponsesReasoningMetadata); ok {
1313 return reasoning
1314 }
1315 }
1316 return nil
1317}
1318
1319type ongoingToolCall struct {
1320 toolName string
1321 toolCallID string
1322}
1323
1324type reasoningState struct {
1325 metadata *ResponsesReasoningMetadata
1326}
1327
1328// GenerateObject implements fantasy.LanguageModel.
1329func (o responsesLanguageModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
1330 switch o.objectMode {
1331 case fantasy.ObjectModeText:
1332 return object.GenerateWithText(ctx, o, call)
1333 case fantasy.ObjectModeTool:
1334 return object.GenerateWithTool(ctx, o, call)
1335 default:
1336 return o.generateObjectWithJSONMode(ctx, call)
1337 }
1338}
1339
1340// StreamObject implements fantasy.LanguageModel.
1341func (o responsesLanguageModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
1342 switch o.objectMode {
1343 case fantasy.ObjectModeTool:
1344 return object.StreamWithTool(ctx, o, call)
1345 case fantasy.ObjectModeText:
1346 return object.StreamWithText(ctx, o, call)
1347 default:
1348 return o.streamObjectWithJSONMode(ctx, call)
1349 }
1350}
1351
1352func (o responsesLanguageModel) generateObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
1353 // Convert our Schema to OpenAI's JSON Schema format
1354 jsonSchemaMap := schema.ToMap(call.Schema)
1355
1356 // Add additionalProperties: false recursively for strict mode (OpenAI requirement)
1357 addAdditionalPropertiesFalse(jsonSchemaMap)
1358
1359 schemaName := call.SchemaName
1360 if schemaName == "" {
1361 schemaName = "response"
1362 }
1363
1364 // Build request using prepareParams
1365 fantasyCall := fantasy.Call{
1366 Prompt: call.Prompt,
1367 MaxOutputTokens: call.MaxOutputTokens,
1368 Temperature: call.Temperature,
1369 TopP: call.TopP,
1370 PresencePenalty: call.PresencePenalty,
1371 FrequencyPenalty: call.FrequencyPenalty,
1372 ProviderOptions: call.ProviderOptions,
1373 }
1374
1375 params, warnings, err := o.prepareParams(fantasyCall)
1376 if err != nil {
1377 return nil, err
1378 }
1379
1380 // Add structured output via Text.Format field
1381 params.Text = responses.ResponseTextConfigParam{
1382 Format: responses.ResponseFormatTextConfigParamOfJSONSchema(schemaName, jsonSchemaMap),
1383 }
1384
1385 // Make request
1386 response, err := o.client.Responses.New(ctx, *params, objectCallUARequestOptions(call)...)
1387 if err != nil {
1388 return nil, toProviderErr(err)
1389 }
1390
1391 if response.Error.Message != "" {
1392 return nil, &fantasy.Error{
1393 Title: "provider error",
1394 Message: fmt.Sprintf("%s (code: %s)", response.Error.Message, response.Error.Code),
1395 }
1396 }
1397
1398 // Extract JSON text from response
1399 var jsonText string
1400 for _, outputItem := range response.Output {
1401 if outputItem.Type == "message" {
1402 for _, contentPart := range outputItem.Content {
1403 if contentPart.Type == "output_text" {
1404 jsonText = contentPart.Text
1405 break
1406 }
1407 }
1408 }
1409 }
1410
1411 if jsonText == "" {
1412 usage := fantasy.Usage{
1413 InputTokens: response.Usage.InputTokens,
1414 OutputTokens: response.Usage.OutputTokens,
1415 TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens,
1416 }
1417 finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, false)
1418 return nil, &fantasy.NoObjectGeneratedError{
1419 RawText: "",
1420 ParseError: fmt.Errorf("no text content in response"),
1421 Usage: usage,
1422 FinishReason: finishReason,
1423 }
1424 }
1425
1426 // Parse and validate
1427 var obj any
1428 if call.RepairText != nil {
1429 obj, err = schema.ParseAndValidateWithRepair(ctx, jsonText, call.Schema, call.RepairText)
1430 } else {
1431 obj, err = schema.ParseAndValidate(jsonText, call.Schema)
1432 }
1433
1434 usage := responsesUsage(*response)
1435 finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, false)
1436
1437 if err != nil {
1438 // Add usage info to error
1439 if nogErr, ok := err.(*fantasy.NoObjectGeneratedError); ok {
1440 nogErr.Usage = usage
1441 nogErr.FinishReason = finishReason
1442 }
1443 return nil, err
1444 }
1445
1446 return &fantasy.ObjectResponse{
1447 Object: obj,
1448 RawText: jsonText,
1449 Usage: usage,
1450 FinishReason: finishReason,
1451 Warnings: warnings,
1452 ProviderMetadata: responsesProviderMetadata(response.ID),
1453 }, nil
1454}
1455
1456func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
1457 // Convert our Schema to OpenAI's JSON Schema format
1458 jsonSchemaMap := schema.ToMap(call.Schema)
1459
1460 // Add additionalProperties: false recursively for strict mode (OpenAI requirement)
1461 addAdditionalPropertiesFalse(jsonSchemaMap)
1462
1463 schemaName := call.SchemaName
1464 if schemaName == "" {
1465 schemaName = "response"
1466 }
1467
1468 // Build request using prepareParams
1469 fantasyCall := fantasy.Call{
1470 Prompt: call.Prompt,
1471 MaxOutputTokens: call.MaxOutputTokens,
1472 Temperature: call.Temperature,
1473 TopP: call.TopP,
1474 PresencePenalty: call.PresencePenalty,
1475 FrequencyPenalty: call.FrequencyPenalty,
1476 ProviderOptions: call.ProviderOptions,
1477 }
1478
1479 params, warnings, err := o.prepareParams(fantasyCall)
1480 if err != nil {
1481 return nil, err
1482 }
1483
1484 // Add structured output via Text.Format field
1485 params.Text = responses.ResponseTextConfigParam{
1486 Format: responses.ResponseFormatTextConfigParamOfJSONSchema(schemaName, jsonSchemaMap),
1487 }
1488
1489 stream := o.client.Responses.NewStreaming(ctx, *params, objectCallUARequestOptions(call)...)
1490
1491 return func(yield func(fantasy.ObjectStreamPart) bool) {
1492 if len(warnings) > 0 {
1493 if !yield(fantasy.ObjectStreamPart{
1494 Type: fantasy.ObjectStreamPartTypeObject,
1495 Warnings: warnings,
1496 }) {
1497 return
1498 }
1499 }
1500
1501 var accumulated string
1502 var lastParsedObject any
1503 var usage fantasy.Usage
1504 var finishReason fantasy.FinishReason
1505 // responseID tracks the server-assigned response ID. It's first set from the
1506 // response.created event and may be overwritten by response.completed or
1507 // response.incomplete events. Per the OpenAI API contract, these IDs are
1508 // identical; the overwrites ensure we have the final value even if an event
1509 // is missed.
1510 var responseID string
1511 var streamErr error
1512 hasFunctionCall := false
1513
1514 for stream.Next() {
1515 event := stream.Current()
1516
1517 switch event.Type {
1518 case "response.created":
1519 created := event.AsResponseCreated()
1520 responseID = created.Response.ID
1521
1522 case "response.output_text.delta":
1523 textDelta := event.AsResponseOutputTextDelta()
1524 accumulated += textDelta.Delta
1525
1526 // Try to parse the accumulated text
1527 obj, state, parseErr := schema.ParsePartialJSON(accumulated)
1528
1529 // If we successfully parsed, validate and emit
1530 if state == schema.ParseStateSuccessful || state == schema.ParseStateRepaired {
1531 if err := schema.ValidateAgainstSchema(obj, call.Schema); err == nil {
1532 // Only emit if object is different from last
1533 if !reflect.DeepEqual(obj, lastParsedObject) {
1534 if !yield(fantasy.ObjectStreamPart{
1535 Type: fantasy.ObjectStreamPartTypeObject,
1536 Object: obj,
1537 }) {
1538 return
1539 }
1540 lastParsedObject = obj
1541 }
1542 }
1543 }
1544
1545 // If parsing failed and we have a repair function, try it
1546 if state == schema.ParseStateFailed && call.RepairText != nil {
1547 repairedText, repairErr := call.RepairText(ctx, accumulated, parseErr)
1548 if repairErr == nil {
1549 obj2, state2, _ := schema.ParsePartialJSON(repairedText)
1550 if (state2 == schema.ParseStateSuccessful || state2 == schema.ParseStateRepaired) &&
1551 schema.ValidateAgainstSchema(obj2, call.Schema) == nil {
1552 if !reflect.DeepEqual(obj2, lastParsedObject) {
1553 if !yield(fantasy.ObjectStreamPart{
1554 Type: fantasy.ObjectStreamPartTypeObject,
1555 Object: obj2,
1556 }) {
1557 return
1558 }
1559 lastParsedObject = obj2
1560 }
1561 }
1562 }
1563 }
1564
1565 case "response.completed":
1566 completed := event.AsResponseCompleted()
1567 responseID = completed.Response.ID
1568 finishReason = mapResponsesFinishReason(completed.Response.IncompleteDetails.Reason, hasFunctionCall)
1569 usage = responsesUsage(completed.Response)
1570
1571 case "response.incomplete":
1572 incomplete := event.AsResponseIncomplete()
1573 responseID = incomplete.Response.ID
1574 finishReason = mapResponsesFinishReason(incomplete.Response.IncompleteDetails.Reason, hasFunctionCall)
1575 usage = responsesUsage(incomplete.Response)
1576
1577 case "error":
1578 errorEvent := event.AsError()
1579 streamErr = fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code)
1580 if !yield(fantasy.ObjectStreamPart{
1581 Type: fantasy.ObjectStreamPartTypeError,
1582 Error: streamErr,
1583 }) {
1584 return
1585 }
1586 return
1587 }
1588 }
1589
1590 err := stream.Err()
1591 if err != nil {
1592 yield(fantasy.ObjectStreamPart{
1593 Type: fantasy.ObjectStreamPartTypeError,
1594 Error: toProviderErr(err),
1595 })
1596 return
1597 }
1598
1599 // Final validation and emit
1600 if streamErr == nil && lastParsedObject != nil {
1601 yield(fantasy.ObjectStreamPart{
1602 Type: fantasy.ObjectStreamPartTypeFinish,
1603 Usage: usage,
1604 FinishReason: finishReason,
1605 ProviderMetadata: responsesProviderMetadata(responseID),
1606 })
1607 } else if streamErr == nil && lastParsedObject == nil {
1608 // No object was generated
1609 yield(fantasy.ObjectStreamPart{
1610 Type: fantasy.ObjectStreamPartTypeError,
1611 Error: &fantasy.NoObjectGeneratedError{
1612 RawText: accumulated,
1613 ParseError: fmt.Errorf("no valid object generated in stream"),
1614 Usage: usage,
1615 FinishReason: finishReason,
1616 },
1617 })
1618 }
1619 }, nil
1620}