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.reasoning_summary_part.added":
1091 added := event.AsResponseReasoningSummaryPartAdded()
1092 state := activeReasoning[added.ItemID]
1093 if state != nil {
1094 state.metadata.Summary = append(state.metadata.Summary, "")
1095 activeReasoning[added.ItemID] = state
1096 if !yield(fantasy.StreamPart{
1097 Type: fantasy.StreamPartTypeReasoningDelta,
1098 ID: added.ItemID,
1099 Delta: "\n",
1100 ProviderMetadata: fantasy.ProviderMetadata{
1101 Name: state.metadata,
1102 },
1103 }) {
1104 return
1105 }
1106 }
1107
1108 case "response.reasoning_summary_text.delta":
1109 textDelta := event.AsResponseReasoningSummaryTextDelta()
1110 state := activeReasoning[textDelta.ItemID]
1111 if state != nil {
1112 if len(state.metadata.Summary)-1 >= int(textDelta.SummaryIndex) {
1113 state.metadata.Summary[textDelta.SummaryIndex] += textDelta.Delta
1114 }
1115 activeReasoning[textDelta.ItemID] = state
1116 if !yield(fantasy.StreamPart{
1117 Type: fantasy.StreamPartTypeReasoningDelta,
1118 ID: textDelta.ItemID,
1119 Delta: textDelta.Delta,
1120 ProviderMetadata: fantasy.ProviderMetadata{
1121 Name: state.metadata,
1122 },
1123 }) {
1124 return
1125 }
1126 }
1127
1128 case "response.completed":
1129 completed := event.AsResponseCompleted()
1130 responseID = completed.Response.ID
1131 finishReason = mapResponsesFinishReason(completed.Response.IncompleteDetails.Reason, hasFunctionCall)
1132 usage = responsesUsage(completed.Response)
1133
1134 case "response.incomplete":
1135 incomplete := event.AsResponseIncomplete()
1136 responseID = incomplete.Response.ID
1137 finishReason = mapResponsesFinishReason(incomplete.Response.IncompleteDetails.Reason, hasFunctionCall)
1138 usage = responsesUsage(incomplete.Response)
1139
1140 case "error":
1141 errorEvent := event.AsError()
1142 if !yield(fantasy.StreamPart{
1143 Type: fantasy.StreamPartTypeError,
1144 Error: fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code),
1145 }) {
1146 return
1147 }
1148 return
1149 }
1150 }
1151
1152 err := stream.Err()
1153 if err != nil {
1154 yield(fantasy.StreamPart{
1155 Type: fantasy.StreamPartTypeError,
1156 Error: toProviderErr(err),
1157 })
1158 return
1159 }
1160
1161 yield(fantasy.StreamPart{
1162 Type: fantasy.StreamPartTypeFinish,
1163 Usage: usage,
1164 FinishReason: finishReason,
1165 ProviderMetadata: responsesProviderMetadata(responseID),
1166 })
1167 }, nil
1168}
1169
1170// toWebSearchToolParam converts a ProviderDefinedTool with ID
1171// "web_search" into the OpenAI SDK's WebSearchToolParam.
1172func toWebSearchToolParam(pt fantasy.ProviderDefinedTool) responses.ToolUnionParam {
1173 wst := responses.WebSearchToolParam{
1174 Type: responses.WebSearchToolTypeWebSearch,
1175 }
1176 if pt.Args != nil {
1177 if size, ok := pt.Args["search_context_size"].(SearchContextSize); ok && size != "" {
1178 wst.SearchContextSize = responses.WebSearchToolSearchContextSize(size)
1179 }
1180 // Also accept plain string for search_context_size.
1181 if size, ok := pt.Args["search_context_size"].(string); ok && size != "" {
1182 wst.SearchContextSize = responses.WebSearchToolSearchContextSize(size)
1183 }
1184 if domains, ok := pt.Args["allowed_domains"].([]string); ok && len(domains) > 0 {
1185 wst.Filters.AllowedDomains = domains
1186 }
1187 if loc, ok := pt.Args["user_location"].(*WebSearchUserLocation); ok && loc != nil {
1188 if loc.City != "" {
1189 wst.UserLocation.City = param.NewOpt(loc.City)
1190 }
1191 if loc.Region != "" {
1192 wst.UserLocation.Region = param.NewOpt(loc.Region)
1193 }
1194 if loc.Country != "" {
1195 wst.UserLocation.Country = param.NewOpt(loc.Country)
1196 }
1197 if loc.Timezone != "" {
1198 wst.UserLocation.Timezone = param.NewOpt(loc.Timezone)
1199 }
1200 }
1201 }
1202 return responses.ToolUnionParam{
1203 OfWebSearch: &wst,
1204 }
1205}
1206
1207// webSearchCallToMetadata converts an OpenAI web search call output
1208// into our structured metadata for round-tripping.
1209func webSearchCallToMetadata(itemID string, action responses.ResponseOutputItemUnionAction) *WebSearchCallMetadata {
1210 meta := &WebSearchCallMetadata{ItemID: itemID}
1211 if action.Type != "" {
1212 a := &WebSearchAction{
1213 Type: action.Type,
1214 Query: action.Query,
1215 }
1216 for _, src := range action.Sources {
1217 a.Sources = append(a.Sources, WebSearchSource{
1218 Type: string(src.Type),
1219 URL: src.URL,
1220 })
1221 }
1222 meta.Action = a
1223 }
1224 return meta
1225}
1226
1227// GetReasoningMetadata extracts reasoning metadata from provider options for responses models.
1228func GetReasoningMetadata(providerOptions fantasy.ProviderOptions) *ResponsesReasoningMetadata {
1229 if openaiResponsesOptions, ok := providerOptions[Name]; ok {
1230 if reasoning, ok := openaiResponsesOptions.(*ResponsesReasoningMetadata); ok {
1231 return reasoning
1232 }
1233 }
1234 return nil
1235}
1236
1237type ongoingToolCall struct {
1238 toolName string
1239 toolCallID string
1240}
1241
1242type reasoningState struct {
1243 metadata *ResponsesReasoningMetadata
1244}
1245
1246// GenerateObject implements fantasy.LanguageModel.
1247func (o responsesLanguageModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
1248 switch o.objectMode {
1249 case fantasy.ObjectModeText:
1250 return object.GenerateWithText(ctx, o, call)
1251 case fantasy.ObjectModeTool:
1252 return object.GenerateWithTool(ctx, o, call)
1253 default:
1254 return o.generateObjectWithJSONMode(ctx, call)
1255 }
1256}
1257
1258// StreamObject implements fantasy.LanguageModel.
1259func (o responsesLanguageModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
1260 switch o.objectMode {
1261 case fantasy.ObjectModeTool:
1262 return object.StreamWithTool(ctx, o, call)
1263 case fantasy.ObjectModeText:
1264 return object.StreamWithText(ctx, o, call)
1265 default:
1266 return o.streamObjectWithJSONMode(ctx, call)
1267 }
1268}
1269
1270func (o responsesLanguageModel) generateObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
1271 // Convert our Schema to OpenAI's JSON Schema format
1272 jsonSchemaMap := schema.ToMap(call.Schema)
1273
1274 // Add additionalProperties: false recursively for strict mode (OpenAI requirement)
1275 addAdditionalPropertiesFalse(jsonSchemaMap)
1276
1277 schemaName := call.SchemaName
1278 if schemaName == "" {
1279 schemaName = "response"
1280 }
1281
1282 // Build request using prepareParams
1283 fantasyCall := fantasy.Call{
1284 Prompt: call.Prompt,
1285 MaxOutputTokens: call.MaxOutputTokens,
1286 Temperature: call.Temperature,
1287 TopP: call.TopP,
1288 PresencePenalty: call.PresencePenalty,
1289 FrequencyPenalty: call.FrequencyPenalty,
1290 ProviderOptions: call.ProviderOptions,
1291 }
1292
1293 params, warnings, err := o.prepareParams(fantasyCall)
1294 if err != nil {
1295 return nil, err
1296 }
1297
1298 // Add structured output via Text.Format field
1299 params.Text = responses.ResponseTextConfigParam{
1300 Format: responses.ResponseFormatTextConfigParamOfJSONSchema(schemaName, jsonSchemaMap),
1301 }
1302
1303 // Make request
1304 response, err := o.client.Responses.New(ctx, *params, objectCallUARequestOptions(call)...)
1305 if err != nil {
1306 return nil, toProviderErr(err)
1307 }
1308
1309 if response.Error.Message != "" {
1310 return nil, &fantasy.Error{
1311 Title: "provider error",
1312 Message: fmt.Sprintf("%s (code: %s)", response.Error.Message, response.Error.Code),
1313 }
1314 }
1315
1316 // Extract JSON text from response
1317 var jsonText string
1318 for _, outputItem := range response.Output {
1319 if outputItem.Type == "message" {
1320 for _, contentPart := range outputItem.Content {
1321 if contentPart.Type == "output_text" {
1322 jsonText = contentPart.Text
1323 break
1324 }
1325 }
1326 }
1327 }
1328
1329 if jsonText == "" {
1330 usage := fantasy.Usage{
1331 InputTokens: response.Usage.InputTokens,
1332 OutputTokens: response.Usage.OutputTokens,
1333 TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens,
1334 }
1335 finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, false)
1336 return nil, &fantasy.NoObjectGeneratedError{
1337 RawText: "",
1338 ParseError: fmt.Errorf("no text content in response"),
1339 Usage: usage,
1340 FinishReason: finishReason,
1341 }
1342 }
1343
1344 // Parse and validate
1345 var obj any
1346 if call.RepairText != nil {
1347 obj, err = schema.ParseAndValidateWithRepair(ctx, jsonText, call.Schema, call.RepairText)
1348 } else {
1349 obj, err = schema.ParseAndValidate(jsonText, call.Schema)
1350 }
1351
1352 usage := responsesUsage(*response)
1353 finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, false)
1354
1355 if err != nil {
1356 // Add usage info to error
1357 if nogErr, ok := err.(*fantasy.NoObjectGeneratedError); ok {
1358 nogErr.Usage = usage
1359 nogErr.FinishReason = finishReason
1360 }
1361 return nil, err
1362 }
1363
1364 return &fantasy.ObjectResponse{
1365 Object: obj,
1366 RawText: jsonText,
1367 Usage: usage,
1368 FinishReason: finishReason,
1369 Warnings: warnings,
1370 ProviderMetadata: responsesProviderMetadata(response.ID),
1371 }, nil
1372}
1373
1374func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
1375 // Convert our Schema to OpenAI's JSON Schema format
1376 jsonSchemaMap := schema.ToMap(call.Schema)
1377
1378 // Add additionalProperties: false recursively for strict mode (OpenAI requirement)
1379 addAdditionalPropertiesFalse(jsonSchemaMap)
1380
1381 schemaName := call.SchemaName
1382 if schemaName == "" {
1383 schemaName = "response"
1384 }
1385
1386 // Build request using prepareParams
1387 fantasyCall := fantasy.Call{
1388 Prompt: call.Prompt,
1389 MaxOutputTokens: call.MaxOutputTokens,
1390 Temperature: call.Temperature,
1391 TopP: call.TopP,
1392 PresencePenalty: call.PresencePenalty,
1393 FrequencyPenalty: call.FrequencyPenalty,
1394 ProviderOptions: call.ProviderOptions,
1395 }
1396
1397 params, warnings, err := o.prepareParams(fantasyCall)
1398 if err != nil {
1399 return nil, err
1400 }
1401
1402 // Add structured output via Text.Format field
1403 params.Text = responses.ResponseTextConfigParam{
1404 Format: responses.ResponseFormatTextConfigParamOfJSONSchema(schemaName, jsonSchemaMap),
1405 }
1406
1407 stream := o.client.Responses.NewStreaming(ctx, *params, objectCallUARequestOptions(call)...)
1408
1409 return func(yield func(fantasy.ObjectStreamPart) bool) {
1410 if len(warnings) > 0 {
1411 if !yield(fantasy.ObjectStreamPart{
1412 Type: fantasy.ObjectStreamPartTypeObject,
1413 Warnings: warnings,
1414 }) {
1415 return
1416 }
1417 }
1418
1419 var accumulated string
1420 var lastParsedObject any
1421 var usage fantasy.Usage
1422 var finishReason fantasy.FinishReason
1423 // responseID tracks the server-assigned response ID. It's first set from the
1424 // response.created event and may be overwritten by response.completed or
1425 // response.incomplete events. Per the OpenAI API contract, these IDs are
1426 // identical; the overwrites ensure we have the final value even if an event
1427 // is missed.
1428 var responseID string
1429 var streamErr error
1430 hasFunctionCall := false
1431
1432 for stream.Next() {
1433 event := stream.Current()
1434
1435 switch event.Type {
1436 case "response.created":
1437 created := event.AsResponseCreated()
1438 responseID = created.Response.ID
1439
1440 case "response.output_text.delta":
1441 textDelta := event.AsResponseOutputTextDelta()
1442 accumulated += textDelta.Delta
1443
1444 // Try to parse the accumulated text
1445 obj, state, parseErr := schema.ParsePartialJSON(accumulated)
1446
1447 // If we successfully parsed, validate and emit
1448 if state == schema.ParseStateSuccessful || state == schema.ParseStateRepaired {
1449 if err := schema.ValidateAgainstSchema(obj, call.Schema); err == nil {
1450 // Only emit if object is different from last
1451 if !reflect.DeepEqual(obj, lastParsedObject) {
1452 if !yield(fantasy.ObjectStreamPart{
1453 Type: fantasy.ObjectStreamPartTypeObject,
1454 Object: obj,
1455 }) {
1456 return
1457 }
1458 lastParsedObject = obj
1459 }
1460 }
1461 }
1462
1463 // If parsing failed and we have a repair function, try it
1464 if state == schema.ParseStateFailed && call.RepairText != nil {
1465 repairedText, repairErr := call.RepairText(ctx, accumulated, parseErr)
1466 if repairErr == nil {
1467 obj2, state2, _ := schema.ParsePartialJSON(repairedText)
1468 if (state2 == schema.ParseStateSuccessful || state2 == schema.ParseStateRepaired) &&
1469 schema.ValidateAgainstSchema(obj2, call.Schema) == nil {
1470 if !reflect.DeepEqual(obj2, lastParsedObject) {
1471 if !yield(fantasy.ObjectStreamPart{
1472 Type: fantasy.ObjectStreamPartTypeObject,
1473 Object: obj2,
1474 }) {
1475 return
1476 }
1477 lastParsedObject = obj2
1478 }
1479 }
1480 }
1481 }
1482
1483 case "response.completed":
1484 completed := event.AsResponseCompleted()
1485 responseID = completed.Response.ID
1486 finishReason = mapResponsesFinishReason(completed.Response.IncompleteDetails.Reason, hasFunctionCall)
1487 usage = responsesUsage(completed.Response)
1488
1489 case "response.incomplete":
1490 incomplete := event.AsResponseIncomplete()
1491 responseID = incomplete.Response.ID
1492 finishReason = mapResponsesFinishReason(incomplete.Response.IncompleteDetails.Reason, hasFunctionCall)
1493 usage = responsesUsage(incomplete.Response)
1494
1495 case "error":
1496 errorEvent := event.AsError()
1497 streamErr = fmt.Errorf("response error: %s (code: %s)", errorEvent.Message, errorEvent.Code)
1498 if !yield(fantasy.ObjectStreamPart{
1499 Type: fantasy.ObjectStreamPartTypeError,
1500 Error: streamErr,
1501 }) {
1502 return
1503 }
1504 return
1505 }
1506 }
1507
1508 err := stream.Err()
1509 if err != nil {
1510 yield(fantasy.ObjectStreamPart{
1511 Type: fantasy.ObjectStreamPartTypeError,
1512 Error: toProviderErr(err),
1513 })
1514 return
1515 }
1516
1517 // Final validation and emit
1518 if streamErr == nil && lastParsedObject != nil {
1519 yield(fantasy.ObjectStreamPart{
1520 Type: fantasy.ObjectStreamPartTypeFinish,
1521 Usage: usage,
1522 FinishReason: finishReason,
1523 ProviderMetadata: responsesProviderMetadata(responseID),
1524 })
1525 } else if streamErr == nil && lastParsedObject == nil {
1526 // No object was generated
1527 yield(fantasy.ObjectStreamPart{
1528 Type: fantasy.ObjectStreamPartTypeError,
1529 Error: &fantasy.NoObjectGeneratedError{
1530 RawText: accumulated,
1531 ParseError: fmt.Errorf("no valid object generated in stream"),
1532 Usage: usage,
1533 FinishReason: finishReason,
1534 },
1535 })
1536 }
1537 }, nil
1538}