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