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