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