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