1package vercel
2
3import (
4 "encoding/base64"
5 "encoding/json"
6 "fmt"
7 "maps"
8 "strings"
9
10 "charm.land/fantasy"
11 "charm.land/fantasy/providers/anthropic"
12 "charm.land/fantasy/providers/google"
13 openaipkg "charm.land/fantasy/providers/openai"
14 openaisdk "github.com/openai/openai-go/v2"
15 "github.com/openai/openai-go/v2/packages/param"
16)
17
18const reasoningStartedCtx = "reasoning_started"
19
20type currentReasoningState struct {
21 metadata *openaipkg.ResponsesReasoningMetadata
22 googleMetadata *google.ReasoningMetadata
23 googleText string
24 anthropicSig string
25}
26
27func languagePrepareModelCall(_ fantasy.LanguageModel, params *openaisdk.ChatCompletionNewParams, call fantasy.Call) ([]fantasy.CallWarning, error) {
28 providerOptions := &ProviderOptions{}
29 if v, ok := call.ProviderOptions[Name]; ok {
30 providerOptions, ok = v.(*ProviderOptions)
31 if !ok {
32 return nil, &fantasy.Error{Title: "invalid argument", Message: "vercel provider options should be *vercel.ProviderOptions"}
33 }
34 }
35
36 extraFields := make(map[string]any)
37
38 // Handle reasoning options
39 if providerOptions.Reasoning != nil {
40 data, err := structToMapJSON(providerOptions.Reasoning)
41 if err != nil {
42 return nil, err
43 }
44 extraFields["reasoning"] = data
45 }
46
47 // Handle provider options for gateway routing
48 if providerOptions.ProviderOptions != nil {
49 data, err := structToMapJSON(providerOptions.ProviderOptions)
50 if err != nil {
51 return nil, err
52 }
53 extraFields["providerOptions"] = map[string]any{
54 "gateway": data,
55 }
56 }
57
58 // Handle BYOK (Bring Your Own Key)
59 if providerOptions.BYOK != nil {
60 data, err := structToMapJSON(providerOptions.BYOK)
61 if err != nil {
62 return nil, err
63 }
64 if gatewayOpts, ok := extraFields["providerOptions"].(map[string]any); ok {
65 gatewayOpts["byok"] = data
66 } else {
67 extraFields["providerOptions"] = map[string]any{
68 "gateway": map[string]any{
69 "byok": data,
70 },
71 }
72 }
73 }
74
75 // Handle standard OpenAI options
76 if providerOptions.LogitBias != nil {
77 params.LogitBias = providerOptions.LogitBias
78 }
79 if providerOptions.LogProbs != nil {
80 params.Logprobs = param.NewOpt(*providerOptions.LogProbs)
81 }
82 if providerOptions.TopLogProbs != nil {
83 params.TopLogprobs = param.NewOpt(*providerOptions.TopLogProbs)
84 }
85 if providerOptions.User != nil {
86 params.User = param.NewOpt(*providerOptions.User)
87 }
88 if providerOptions.ParallelToolCalls != nil {
89 params.ParallelToolCalls = param.NewOpt(*providerOptions.ParallelToolCalls)
90 }
91
92 // Handle model fallbacks - direct models field
93 if providerOptions.ProviderOptions != nil && len(providerOptions.ProviderOptions.Models) > 0 {
94 extraFields["models"] = providerOptions.ProviderOptions.Models
95 }
96
97 maps.Copy(extraFields, providerOptions.ExtraBody)
98 params.SetExtraFields(extraFields)
99 return nil, nil
100}
101
102func languageModelExtraContent(choice openaisdk.ChatCompletionChoice) []fantasy.Content {
103 content := make([]fantasy.Content, 0)
104 reasoningData := ReasoningData{}
105 err := json.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData)
106 if err != nil {
107 return content
108 }
109
110 responsesReasoningBlocks := make([]openaipkg.ResponsesReasoningMetadata, 0)
111 anthropicReasoningBlocks := make([]struct {
112 text string
113 metadata *anthropic.ReasoningOptionMetadata
114 }, 0)
115 googleReasoningBlocks := make([]struct {
116 text string
117 metadata *google.ReasoningMetadata
118 }, 0)
119 otherReasoning := make([]string, 0)
120
121 for _, detail := range reasoningData.ReasoningDetails {
122 if strings.HasPrefix(detail.Format, "openai-responses") || strings.HasPrefix(detail.Format, "xai-responses") {
123 var thinkingBlock openaipkg.ResponsesReasoningMetadata
124 if len(responsesReasoningBlocks)-1 >= detail.Index {
125 thinkingBlock = responsesReasoningBlocks[detail.Index]
126 } else {
127 thinkingBlock = openaipkg.ResponsesReasoningMetadata{}
128 responsesReasoningBlocks = append(responsesReasoningBlocks, thinkingBlock)
129 }
130
131 switch detail.Type {
132 case "reasoning.summary":
133 thinkingBlock.Summary = append(thinkingBlock.Summary, detail.Summary)
134 case "reasoning.encrypted":
135 thinkingBlock.EncryptedContent = &detail.Data
136 }
137 if detail.ID != "" {
138 thinkingBlock.ItemID = detail.ID
139 }
140 responsesReasoningBlocks[detail.Index] = thinkingBlock
141 continue
142 }
143
144 if strings.HasPrefix(detail.Format, "google-gemini") {
145 var thinkingBlock struct {
146 text string
147 metadata *google.ReasoningMetadata
148 }
149 if len(googleReasoningBlocks)-1 >= detail.Index {
150 thinkingBlock = googleReasoningBlocks[detail.Index]
151 } else {
152 thinkingBlock = struct {
153 text string
154 metadata *google.ReasoningMetadata
155 }{metadata: &google.ReasoningMetadata{}}
156 googleReasoningBlocks = append(googleReasoningBlocks, thinkingBlock)
157 }
158
159 switch detail.Type {
160 case "reasoning.text":
161 thinkingBlock.text = detail.Text
162 case "reasoning.encrypted":
163 thinkingBlock.metadata.Signature = detail.Data
164 thinkingBlock.metadata.ToolID = detail.ID
165 }
166 googleReasoningBlocks[detail.Index] = thinkingBlock
167 continue
168 }
169
170 if strings.HasPrefix(detail.Format, "anthropic-claude") {
171 anthropicReasoningBlocks = append(anthropicReasoningBlocks, struct {
172 text string
173 metadata *anthropic.ReasoningOptionMetadata
174 }{
175 text: detail.Text,
176 metadata: &anthropic.ReasoningOptionMetadata{
177 Signature: detail.Signature,
178 },
179 })
180 continue
181 }
182
183 otherReasoning = append(otherReasoning, detail.Text)
184 }
185
186 // Fallback to simple reasoning field if no details
187 if reasoningData.Reasoning != "" && len(reasoningData.ReasoningDetails) == 0 {
188 otherReasoning = append(otherReasoning, reasoningData.Reasoning)
189 }
190
191 for _, block := range responsesReasoningBlocks {
192 if len(block.Summary) == 0 {
193 block.Summary = []string{""}
194 }
195 content = append(content, fantasy.ReasoningContent{
196 Text: strings.Join(block.Summary, "\n"),
197 ProviderMetadata: fantasy.ProviderMetadata{
198 openaipkg.Name: &block,
199 },
200 })
201 }
202
203 for _, block := range anthropicReasoningBlocks {
204 content = append(content, fantasy.ReasoningContent{
205 Text: block.text,
206 ProviderMetadata: fantasy.ProviderMetadata{
207 anthropic.Name: block.metadata,
208 },
209 })
210 }
211
212 for _, block := range googleReasoningBlocks {
213 content = append(content, fantasy.ReasoningContent{
214 Text: block.text,
215 ProviderMetadata: fantasy.ProviderMetadata{
216 google.Name: block.metadata,
217 },
218 })
219 }
220
221 for _, reasoning := range otherReasoning {
222 if reasoning != "" {
223 content = append(content, fantasy.ReasoningContent{
224 Text: reasoning,
225 })
226 }
227 }
228
229 return content
230}
231
232func extractReasoningContext(ctx map[string]any) *currentReasoningState {
233 reasoningStarted, ok := ctx[reasoningStartedCtx]
234 if !ok {
235 return nil
236 }
237 state, ok := reasoningStarted.(*currentReasoningState)
238 if !ok {
239 return nil
240 }
241 return state
242}
243
244func languageModelStreamExtra(chunk openaisdk.ChatCompletionChunk, yield func(fantasy.StreamPart) bool, ctx map[string]any) (map[string]any, bool) {
245 if len(chunk.Choices) == 0 {
246 return ctx, true
247 }
248
249 currentState := extractReasoningContext(ctx)
250
251 inx := 0
252 choice := chunk.Choices[inx]
253 reasoningData := ReasoningData{}
254 err := json.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData)
255 if err != nil {
256 yield(fantasy.StreamPart{
257 Type: fantasy.StreamPartTypeError,
258 Error: &fantasy.Error{Title: "stream error", Message: "error unmarshalling delta", Cause: err},
259 })
260 return ctx, false
261 }
262
263 // Reasoning Start
264 if currentState == nil {
265 if len(reasoningData.ReasoningDetails) == 0 && reasoningData.Reasoning == "" {
266 return ctx, true
267 }
268
269 var metadata fantasy.ProviderMetadata
270 currentState = ¤tReasoningState{}
271
272 if len(reasoningData.ReasoningDetails) > 0 {
273 detail := reasoningData.ReasoningDetails[0]
274
275 if strings.HasPrefix(detail.Format, "openai-responses") || strings.HasPrefix(detail.Format, "xai-responses") {
276 currentState.metadata = &openaipkg.ResponsesReasoningMetadata{
277 Summary: []string{detail.Summary},
278 }
279 metadata = fantasy.ProviderMetadata{
280 openaipkg.Name: currentState.metadata,
281 }
282 if detail.Data != "" {
283 shouldContinue := yield(fantasy.StreamPart{
284 Type: fantasy.StreamPartTypeReasoningStart,
285 ID: fmt.Sprintf("%d", inx),
286 Delta: detail.Summary,
287 ProviderMetadata: metadata,
288 })
289 if !shouldContinue {
290 return ctx, false
291 }
292 return ctx, yield(fantasy.StreamPart{
293 Type: fantasy.StreamPartTypeReasoningEnd,
294 ID: fmt.Sprintf("%d", inx),
295 ProviderMetadata: fantasy.ProviderMetadata{
296 openaipkg.Name: &openaipkg.ResponsesReasoningMetadata{
297 Summary: []string{detail.Summary},
298 EncryptedContent: &detail.Data,
299 ItemID: detail.ID,
300 },
301 },
302 })
303 }
304 }
305
306 if strings.HasPrefix(detail.Format, "google-gemini") {
307 if detail.Type == "reasoning.encrypted" {
308 ctx[reasoningStartedCtx] = nil
309 if !yield(fantasy.StreamPart{
310 Type: fantasy.StreamPartTypeReasoningStart,
311 ID: fmt.Sprintf("%d", inx),
312 }) {
313 return ctx, false
314 }
315 return ctx, yield(fantasy.StreamPart{
316 Type: fantasy.StreamPartTypeReasoningEnd,
317 ID: fmt.Sprintf("%d", inx),
318 ProviderMetadata: fantasy.ProviderMetadata{
319 google.Name: &google.ReasoningMetadata{
320 Signature: detail.Data,
321 ToolID: detail.ID,
322 },
323 },
324 })
325 }
326 currentState.googleMetadata = &google.ReasoningMetadata{}
327 currentState.googleText = detail.Text
328 metadata = fantasy.ProviderMetadata{
329 google.Name: currentState.googleMetadata,
330 }
331 }
332
333 if strings.HasPrefix(detail.Format, "anthropic-claude") {
334 currentState.anthropicSig = detail.Signature
335 }
336 }
337
338 ctx[reasoningStartedCtx] = currentState
339 delta := reasoningData.Reasoning
340 if len(reasoningData.ReasoningDetails) > 0 {
341 delta = reasoningData.ReasoningDetails[0].Summary
342 if strings.HasPrefix(reasoningData.ReasoningDetails[0].Format, "google-gemini") {
343 delta = reasoningData.ReasoningDetails[0].Text
344 }
345 if strings.HasPrefix(reasoningData.ReasoningDetails[0].Format, "anthropic-claude") {
346 delta = reasoningData.ReasoningDetails[0].Text
347 }
348 }
349 return ctx, yield(fantasy.StreamPart{
350 Type: fantasy.StreamPartTypeReasoningStart,
351 ID: fmt.Sprintf("%d", inx),
352 Delta: delta,
353 ProviderMetadata: metadata,
354 })
355 }
356
357 if len(reasoningData.ReasoningDetails) == 0 && reasoningData.Reasoning == "" {
358 if choice.Delta.Content != "" || len(choice.Delta.ToolCalls) > 0 {
359 ctx[reasoningStartedCtx] = nil
360 return ctx, yield(fantasy.StreamPart{
361 Type: fantasy.StreamPartTypeReasoningEnd,
362 ID: fmt.Sprintf("%d", inx),
363 })
364 }
365 return ctx, true
366 }
367
368 if len(reasoningData.ReasoningDetails) > 0 {
369 detail := reasoningData.ReasoningDetails[0]
370
371 if strings.HasPrefix(detail.Format, "openai-responses") || strings.HasPrefix(detail.Format, "xai-responses") {
372 if detail.Data != "" {
373 currentState.metadata.EncryptedContent = &detail.Data
374 currentState.metadata.ItemID = detail.ID
375 ctx[reasoningStartedCtx] = nil
376 return ctx, yield(fantasy.StreamPart{
377 Type: fantasy.StreamPartTypeReasoningEnd,
378 ID: fmt.Sprintf("%d", inx),
379 ProviderMetadata: fantasy.ProviderMetadata{
380 openaipkg.Name: currentState.metadata,
381 },
382 })
383 }
384 var textDelta string
385 if len(currentState.metadata.Summary)-1 >= detail.Index {
386 currentState.metadata.Summary[detail.Index] += detail.Summary
387 textDelta = detail.Summary
388 } else {
389 currentState.metadata.Summary = append(currentState.metadata.Summary, detail.Summary)
390 textDelta = "\n" + detail.Summary
391 }
392 ctx[reasoningStartedCtx] = currentState
393 return ctx, yield(fantasy.StreamPart{
394 Type: fantasy.StreamPartTypeReasoningDelta,
395 ID: fmt.Sprintf("%d", inx),
396 Delta: textDelta,
397 ProviderMetadata: fantasy.ProviderMetadata{
398 openaipkg.Name: currentState.metadata,
399 },
400 })
401 }
402
403 if strings.HasPrefix(detail.Format, "anthropic-claude") {
404 if detail.Signature != "" {
405 metadata := fantasy.ProviderMetadata{
406 anthropic.Name: &anthropic.ReasoningOptionMetadata{
407 Signature: detail.Signature,
408 },
409 }
410 shouldContinue := yield(fantasy.StreamPart{
411 Type: fantasy.StreamPartTypeReasoningDelta,
412 ID: fmt.Sprintf("%d", inx),
413 Delta: detail.Text,
414 ProviderMetadata: metadata,
415 })
416 if !shouldContinue {
417 return ctx, false
418 }
419 ctx[reasoningStartedCtx] = nil
420 return ctx, yield(fantasy.StreamPart{
421 Type: fantasy.StreamPartTypeReasoningEnd,
422 ID: fmt.Sprintf("%d", inx),
423 })
424 }
425 return ctx, yield(fantasy.StreamPart{
426 Type: fantasy.StreamPartTypeReasoningDelta,
427 ID: fmt.Sprintf("%d", inx),
428 Delta: detail.Text,
429 })
430 }
431
432 if strings.HasPrefix(detail.Format, "google-gemini") {
433 if detail.Type == "reasoning.text" {
434 currentState.googleText += detail.Text
435 ctx[reasoningStartedCtx] = currentState
436 return ctx, yield(fantasy.StreamPart{
437 Type: fantasy.StreamPartTypeReasoningDelta,
438 ID: fmt.Sprintf("%d", inx),
439 Delta: detail.Text,
440 })
441 }
442 if detail.Type == "reasoning.encrypted" {
443 currentState.googleMetadata.Signature = detail.Data
444 currentState.googleMetadata.ToolID = detail.ID
445 metadata := fantasy.ProviderMetadata{
446 google.Name: currentState.googleMetadata,
447 }
448 ctx[reasoningStartedCtx] = nil
449 return ctx, yield(fantasy.StreamPart{
450 Type: fantasy.StreamPartTypeReasoningEnd,
451 ID: fmt.Sprintf("%d", inx),
452 ProviderMetadata: metadata,
453 })
454 }
455 }
456
457 return ctx, yield(fantasy.StreamPart{
458 Type: fantasy.StreamPartTypeReasoningDelta,
459 ID: fmt.Sprintf("%d", inx),
460 Delta: detail.Text,
461 })
462 }
463
464 if reasoningData.Reasoning != "" {
465 return ctx, yield(fantasy.StreamPart{
466 Type: fantasy.StreamPartTypeReasoningDelta,
467 ID: fmt.Sprintf("%d", inx),
468 Delta: reasoningData.Reasoning,
469 })
470 }
471
472 return ctx, true
473}
474
475func languageModelUsage(response openaisdk.ChatCompletion) (fantasy.Usage, fantasy.ProviderOptionsData) {
476 if len(response.Choices) == 0 {
477 return fantasy.Usage{}, nil
478 }
479
480 usage := response.Usage
481 completionTokenDetails := usage.CompletionTokensDetails
482 promptTokenDetails := usage.PromptTokensDetails
483
484 var provider string
485 if p, ok := response.JSON.ExtraFields["provider"]; ok {
486 provider = p.Raw()
487 }
488
489 providerMetadata := &ProviderMetadata{
490 Provider: provider,
491 }
492
493 return fantasy.Usage{
494 InputTokens: usage.PromptTokens,
495 OutputTokens: usage.CompletionTokens,
496 TotalTokens: usage.TotalTokens,
497 ReasoningTokens: completionTokenDetails.ReasoningTokens,
498 CacheReadTokens: promptTokenDetails.CachedTokens,
499 }, providerMetadata
500}
501
502func languageModelStreamUsage(chunk openaisdk.ChatCompletionChunk, _ map[string]any, metadata fantasy.ProviderMetadata) (fantasy.Usage, fantasy.ProviderMetadata) {
503 usage := chunk.Usage
504 if usage.TotalTokens == 0 {
505 return fantasy.Usage{}, nil
506 }
507
508 streamProviderMetadata := &ProviderMetadata{}
509 if metadata != nil {
510 if providerMetadata, ok := metadata[Name]; ok {
511 converted, ok := providerMetadata.(*ProviderMetadata)
512 if ok {
513 streamProviderMetadata = converted
514 }
515 }
516 }
517
518 if p, ok := chunk.JSON.ExtraFields["provider"]; ok {
519 streamProviderMetadata.Provider = p.Raw()
520 }
521
522 completionTokenDetails := usage.CompletionTokensDetails
523 promptTokenDetails := usage.PromptTokensDetails
524 aiUsage := fantasy.Usage{
525 InputTokens: usage.PromptTokens,
526 OutputTokens: usage.CompletionTokens,
527 TotalTokens: usage.TotalTokens,
528 ReasoningTokens: completionTokenDetails.ReasoningTokens,
529 CacheReadTokens: promptTokenDetails.CachedTokens,
530 }
531
532 return aiUsage, fantasy.ProviderMetadata{
533 Name: streamProviderMetadata,
534 }
535}
536
537func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.ChatCompletionMessageParamUnion, []fantasy.CallWarning) {
538 var messages []openaisdk.ChatCompletionMessageParamUnion
539 var warnings []fantasy.CallWarning
540
541 for _, msg := range prompt {
542 switch msg.Role {
543 case fantasy.MessageRoleSystem:
544 var systemPromptParts []string
545 for _, c := range msg.Content {
546 if c.GetType() != fantasy.ContentTypeText {
547 warnings = append(warnings, fantasy.CallWarning{
548 Type: fantasy.CallWarningTypeOther,
549 Message: "system prompt can only have text content",
550 })
551 continue
552 }
553 textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
554 if !ok {
555 warnings = append(warnings, fantasy.CallWarning{
556 Type: fantasy.CallWarningTypeOther,
557 Message: "system prompt text part does not have the right type",
558 })
559 continue
560 }
561 text := textPart.Text
562 if strings.TrimSpace(text) != "" {
563 systemPromptParts = append(systemPromptParts, textPart.Text)
564 }
565 }
566 if len(systemPromptParts) == 0 {
567 warnings = append(warnings, fantasy.CallWarning{
568 Type: fantasy.CallWarningTypeOther,
569 Message: "system prompt has no text parts",
570 })
571 continue
572 }
573 systemMsg := openaisdk.SystemMessage(strings.Join(systemPromptParts, "\n"))
574 anthropicCache := anthropic.GetCacheControl(msg.ProviderOptions)
575 if anthropicCache != nil {
576 systemMsg.OfSystem.SetExtraFields(map[string]any{
577 "cache_control": map[string]string{
578 "type": anthropicCache.Type,
579 },
580 })
581 }
582 messages = append(messages, systemMsg)
583
584 case fantasy.MessageRoleUser:
585 if len(msg.Content) == 1 && msg.Content[0].GetType() == fantasy.ContentTypeText {
586 textPart, ok := fantasy.AsContentType[fantasy.TextPart](msg.Content[0])
587 if !ok {
588 warnings = append(warnings, fantasy.CallWarning{
589 Type: fantasy.CallWarningTypeOther,
590 Message: "user message text part does not have the right type",
591 })
592 continue
593 }
594 userMsg := openaisdk.UserMessage(textPart.Text)
595 anthropicCache := anthropic.GetCacheControl(msg.ProviderOptions)
596 if anthropicCache != nil {
597 userMsg.OfUser.SetExtraFields(map[string]any{
598 "cache_control": map[string]string{
599 "type": anthropicCache.Type,
600 },
601 })
602 }
603 messages = append(messages, userMsg)
604 continue
605 }
606
607 var content []openaisdk.ChatCompletionContentPartUnionParam
608 for i, c := range msg.Content {
609 isLastPart := i == len(msg.Content)-1
610 cacheControl := anthropic.GetCacheControl(c.Options())
611 if cacheControl == nil && isLastPart {
612 cacheControl = anthropic.GetCacheControl(msg.ProviderOptions)
613 }
614 switch c.GetType() {
615 case fantasy.ContentTypeText:
616 textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
617 if !ok {
618 warnings = append(warnings, fantasy.CallWarning{
619 Type: fantasy.CallWarningTypeOther,
620 Message: "user message text part does not have the right type",
621 })
622 continue
623 }
624 part := openaisdk.ChatCompletionContentPartUnionParam{
625 OfText: &openaisdk.ChatCompletionContentPartTextParam{
626 Text: textPart.Text,
627 },
628 }
629 if cacheControl != nil {
630 part.OfText.SetExtraFields(map[string]any{
631 "cache_control": map[string]string{
632 "type": cacheControl.Type,
633 },
634 })
635 }
636 content = append(content, part)
637 case fantasy.ContentTypeFile:
638 filePart, ok := fantasy.AsContentType[fantasy.FilePart](c)
639 if !ok {
640 warnings = append(warnings, fantasy.CallWarning{
641 Type: fantasy.CallWarningTypeOther,
642 Message: "user message file part does not have the right type",
643 })
644 continue
645 }
646 switch {
647 case strings.HasPrefix(filePart.MediaType, "image/"):
648 base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
649 data := "data:" + filePart.MediaType + ";base64," + base64Encoded
650 imageURL := openaisdk.ChatCompletionContentPartImageImageURLParam{URL: data}
651 if providerOptions, ok := filePart.ProviderOptions[openaipkg.Name]; ok {
652 if detail, ok := providerOptions.(*openaipkg.ProviderFileOptions); ok {
653 imageURL.Detail = detail.ImageDetail
654 }
655 }
656 imageBlock := openaisdk.ChatCompletionContentPartImageParam{ImageURL: imageURL}
657 if cacheControl != nil {
658 imageBlock.SetExtraFields(map[string]any{
659 "cache_control": map[string]string{
660 "type": cacheControl.Type,
661 },
662 })
663 }
664 content = append(content, openaisdk.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
665
666 case filePart.MediaType == "audio/wav":
667 base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
668 audioBlock := openaisdk.ChatCompletionContentPartInputAudioParam{
669 InputAudio: openaisdk.ChatCompletionContentPartInputAudioInputAudioParam{
670 Data: base64Encoded,
671 Format: "wav",
672 },
673 }
674 if cacheControl != nil {
675 audioBlock.SetExtraFields(map[string]any{
676 "cache_control": map[string]string{
677 "type": cacheControl.Type,
678 },
679 })
680 }
681 content = append(content, openaisdk.ChatCompletionContentPartUnionParam{OfInputAudio: &audioBlock})
682
683 case filePart.MediaType == "audio/mpeg" || filePart.MediaType == "audio/mp3":
684 base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
685 audioBlock := openaisdk.ChatCompletionContentPartInputAudioParam{
686 InputAudio: openaisdk.ChatCompletionContentPartInputAudioInputAudioParam{
687 Data: base64Encoded,
688 Format: "mp3",
689 },
690 }
691 if cacheControl != nil {
692 audioBlock.SetExtraFields(map[string]any{
693 "cache_control": map[string]string{
694 "type": cacheControl.Type,
695 },
696 })
697 }
698 content = append(content, openaisdk.ChatCompletionContentPartUnionParam{OfInputAudio: &audioBlock})
699
700 case filePart.MediaType == "application/pdf":
701 dataStr := string(filePart.Data)
702 if strings.HasPrefix(dataStr, "file-") {
703 fileBlock := openaisdk.ChatCompletionContentPartFileParam{
704 File: openaisdk.ChatCompletionContentPartFileFileParam{
705 FileID: param.NewOpt(dataStr),
706 },
707 }
708 if cacheControl != nil {
709 fileBlock.SetExtraFields(map[string]any{
710 "cache_control": map[string]string{
711 "type": cacheControl.Type,
712 },
713 })
714 }
715 content = append(content, openaisdk.ChatCompletionContentPartUnionParam{OfFile: &fileBlock})
716 } else {
717 base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
718 data := "data:application/pdf;base64," + base64Encoded
719 filename := filePart.Filename
720 if filename == "" {
721 filename = fmt.Sprintf("part-%d.pdf", len(content))
722 }
723 fileBlock := openaisdk.ChatCompletionContentPartFileParam{
724 File: openaisdk.ChatCompletionContentPartFileFileParam{
725 Filename: param.NewOpt(filename),
726 FileData: param.NewOpt(data),
727 },
728 }
729 if cacheControl != nil {
730 fileBlock.SetExtraFields(map[string]any{
731 "cache_control": map[string]string{
732 "type": cacheControl.Type,
733 },
734 })
735 }
736 content = append(content, openaisdk.ChatCompletionContentPartUnionParam{OfFile: &fileBlock})
737 }
738
739 default:
740 warnings = append(warnings, fantasy.CallWarning{
741 Type: fantasy.CallWarningTypeOther,
742 Message: fmt.Sprintf("file part media type %s not supported", filePart.MediaType),
743 })
744 }
745 }
746 }
747 if !hasVisibleUserContent(content) {
748 warnings = append(warnings, fantasy.CallWarning{
749 Type: fantasy.CallWarningTypeOther,
750 Message: "dropping empty user message (contains neither user-facing content nor tool results)",
751 })
752 continue
753 }
754 messages = append(messages, openaisdk.UserMessage(content))
755
756 case fantasy.MessageRoleAssistant:
757 if len(msg.Content) == 1 && msg.Content[0].GetType() == fantasy.ContentTypeText {
758 textPart, ok := fantasy.AsContentType[fantasy.TextPart](msg.Content[0])
759 if !ok {
760 warnings = append(warnings, fantasy.CallWarning{
761 Type: fantasy.CallWarningTypeOther,
762 Message: "assistant message text part does not have the right type",
763 })
764 continue
765 }
766 assistantMsg := openaisdk.AssistantMessage(textPart.Text)
767 anthropicCache := anthropic.GetCacheControl(msg.ProviderOptions)
768 if anthropicCache != nil {
769 assistantMsg.OfAssistant.SetExtraFields(map[string]any{
770 "cache_control": map[string]string{
771 "type": anthropicCache.Type,
772 },
773 })
774 }
775 messages = append(messages, assistantMsg)
776 continue
777 }
778
779 assistantMsg := openaisdk.ChatCompletionAssistantMessageParam{
780 Role: "assistant",
781 }
782 for i, c := range msg.Content {
783 isLastPart := i == len(msg.Content)-1
784 cacheControl := anthropic.GetCacheControl(c.Options())
785 if cacheControl == nil && isLastPart {
786 cacheControl = anthropic.GetCacheControl(msg.ProviderOptions)
787 }
788 switch c.GetType() {
789 case fantasy.ContentTypeText:
790 textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
791 if !ok {
792 warnings = append(warnings, fantasy.CallWarning{
793 Type: fantasy.CallWarningTypeOther,
794 Message: "assistant message text part does not have the right type",
795 })
796 continue
797 }
798 if assistantMsg.Content.OfString.Valid() {
799 textPart.Text = assistantMsg.Content.OfString.Value + "\n" + textPart.Text
800 }
801 assistantMsg.Content = openaisdk.ChatCompletionAssistantMessageParamContentUnion{
802 OfString: param.NewOpt(textPart.Text),
803 }
804 if cacheControl != nil {
805 assistantMsg.Content.SetExtraFields(map[string]any{
806 "cache_control": map[string]string{
807 "type": cacheControl.Type,
808 },
809 })
810 }
811 case fantasy.ContentTypeReasoning:
812 reasoningPart, ok := fantasy.AsContentType[fantasy.ReasoningPart](c)
813 if !ok {
814 warnings = append(warnings, fantasy.CallWarning{
815 Type: fantasy.CallWarningTypeOther,
816 Message: "assistant message reasoning part does not have the right type",
817 })
818 continue
819 }
820 var reasoningDetails []ReasoningDetail
821 switch {
822 case strings.HasPrefix(model, "anthropic/") && reasoningPart.Text != "":
823 metadata := anthropic.GetReasoningMetadata(reasoningPart.Options())
824 if metadata == nil {
825 text := fmt.Sprintf("<thoughts>%s</thoughts>", reasoningPart.Text)
826 if assistantMsg.Content.OfString.Valid() {
827 text = assistantMsg.Content.OfString.Value + "\n" + text
828 }
829 assistantMsg.Content = openaisdk.ChatCompletionAssistantMessageParamContentUnion{
830 OfString: param.NewOpt(text),
831 }
832 if cacheControl != nil {
833 assistantMsg.Content.SetExtraFields(map[string]any{
834 "cache_control": map[string]string{
835 "type": cacheControl.Type,
836 },
837 })
838 }
839 continue
840 }
841 reasoningDetails = append(reasoningDetails, ReasoningDetail{
842 Format: "anthropic-claude-v1",
843 Type: "reasoning.text",
844 Text: reasoningPart.Text,
845 Signature: metadata.Signature,
846 })
847 data, _ := json.Marshal(reasoningDetails)
848 reasoningDetailsMap := []map[string]any{}
849 _ = json.Unmarshal(data, &reasoningDetailsMap)
850 assistantMsg.SetExtraFields(map[string]any{
851 "reasoning_details": reasoningDetailsMap,
852 "reasoning": reasoningPart.Text,
853 })
854 case strings.HasPrefix(model, "openai/"):
855 metadata := openaipkg.GetReasoningMetadata(reasoningPart.Options())
856 if metadata == nil {
857 text := fmt.Sprintf("<thoughts>%s</thoughts>", reasoningPart.Text)
858 if assistantMsg.Content.OfString.Valid() {
859 text = assistantMsg.Content.OfString.Value + "\n" + text
860 }
861 assistantMsg.Content = openaisdk.ChatCompletionAssistantMessageParamContentUnion{
862 OfString: param.NewOpt(text),
863 }
864 continue
865 }
866 for inx, summary := range metadata.Summary {
867 if summary == "" {
868 continue
869 }
870 reasoningDetails = append(reasoningDetails, ReasoningDetail{
871 Type: "reasoning.summary",
872 Format: "openai-responses-v1",
873 Summary: summary,
874 Index: inx,
875 })
876 }
877 reasoningDetails = append(reasoningDetails, ReasoningDetail{
878 Type: "reasoning.encrypted",
879 Format: "openai-responses-v1",
880 Data: *metadata.EncryptedContent,
881 ID: metadata.ItemID,
882 })
883 data, _ := json.Marshal(reasoningDetails)
884 reasoningDetailsMap := []map[string]any{}
885 _ = json.Unmarshal(data, &reasoningDetailsMap)
886 assistantMsg.SetExtraFields(map[string]any{
887 "reasoning_details": reasoningDetailsMap,
888 })
889 case strings.HasPrefix(model, "google/"):
890 metadata := google.GetReasoningMetadata(reasoningPart.Options())
891 if metadata == nil {
892 text := fmt.Sprintf("<thoughts>%s</thoughts>", reasoningPart.Text)
893 if assistantMsg.Content.OfString.Valid() {
894 text = assistantMsg.Content.OfString.Value + "\n" + text
895 }
896 assistantMsg.Content = openaisdk.ChatCompletionAssistantMessageParamContentUnion{
897 OfString: param.NewOpt(text),
898 }
899 continue
900 }
901 if reasoningPart.Text != "" {
902 reasoningDetails = append(reasoningDetails, ReasoningDetail{
903 Type: "reasoning.text",
904 Format: "google-gemini-v1",
905 Text: reasoningPart.Text,
906 })
907 }
908 reasoningDetails = append(reasoningDetails, ReasoningDetail{
909 Type: "reasoning.encrypted",
910 Format: "google-gemini-v1",
911 Data: metadata.Signature,
912 ID: metadata.ToolID,
913 })
914 data, _ := json.Marshal(reasoningDetails)
915 reasoningDetailsMap := []map[string]any{}
916 _ = json.Unmarshal(data, &reasoningDetailsMap)
917 assistantMsg.SetExtraFields(map[string]any{
918 "reasoning_details": reasoningDetailsMap,
919 })
920 default:
921 reasoningDetails = append(reasoningDetails, ReasoningDetail{
922 Type: "reasoning.text",
923 Text: reasoningPart.Text,
924 Format: "unknown",
925 })
926 data, _ := json.Marshal(reasoningDetails)
927 reasoningDetailsMap := []map[string]any{}
928 _ = json.Unmarshal(data, &reasoningDetailsMap)
929 assistantMsg.SetExtraFields(map[string]any{
930 "reasoning_details": reasoningDetailsMap,
931 })
932 }
933 case fantasy.ContentTypeToolCall:
934 toolCallPart, ok := fantasy.AsContentType[fantasy.ToolCallPart](c)
935 if !ok {
936 warnings = append(warnings, fantasy.CallWarning{
937 Type: fantasy.CallWarningTypeOther,
938 Message: "assistant message tool part does not have the right type",
939 })
940 continue
941 }
942 tc := openaisdk.ChatCompletionMessageToolCallUnionParam{
943 OfFunction: &openaisdk.ChatCompletionMessageFunctionToolCallParam{
944 ID: toolCallPart.ToolCallID,
945 Type: "function",
946 Function: openaisdk.ChatCompletionMessageFunctionToolCallFunctionParam{
947 Name: toolCallPart.ToolName,
948 Arguments: toolCallPart.Input,
949 },
950 },
951 }
952 if cacheControl != nil {
953 tc.OfFunction.SetExtraFields(map[string]any{
954 "cache_control": map[string]string{
955 "type": cacheControl.Type,
956 },
957 })
958 }
959 assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
960 }
961 }
962 messages = append(messages, openaisdk.ChatCompletionMessageParamUnion{
963 OfAssistant: &assistantMsg,
964 })
965
966 case fantasy.MessageRoleTool:
967 for i, c := range msg.Content {
968 isLastPart := i == len(msg.Content)-1
969 cacheControl := anthropic.GetCacheControl(c.Options())
970 if cacheControl == nil && isLastPart {
971 cacheControl = anthropic.GetCacheControl(msg.ProviderOptions)
972 }
973 if c.GetType() != fantasy.ContentTypeToolResult {
974 warnings = append(warnings, fantasy.CallWarning{
975 Type: fantasy.CallWarningTypeOther,
976 Message: "tool message can only have tool result content",
977 })
978 continue
979 }
980 toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c)
981 if !ok {
982 warnings = append(warnings, fantasy.CallWarning{
983 Type: fantasy.CallWarningTypeOther,
984 Message: "tool message result part does not have the right type",
985 })
986 continue
987 }
988 switch toolResultPart.Output.GetType() {
989 case fantasy.ToolResultContentTypeText:
990 output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](toolResultPart.Output)
991 if !ok {
992 warnings = append(warnings, fantasy.CallWarning{
993 Type: fantasy.CallWarningTypeOther,
994 Message: "tool result output does not have the right type",
995 })
996 continue
997 }
998 tr := openaisdk.ToolMessage(output.Text, toolResultPart.ToolCallID)
999 if cacheControl != nil {
1000 tr.SetExtraFields(map[string]any{
1001 "cache_control": map[string]string{
1002 "type": cacheControl.Type,
1003 },
1004 })
1005 }
1006 messages = append(messages, tr)
1007 case fantasy.ToolResultContentTypeError:
1008 output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output)
1009 if !ok {
1010 warnings = append(warnings, fantasy.CallWarning{
1011 Type: fantasy.CallWarningTypeOther,
1012 Message: "tool result output does not have the right type",
1013 })
1014 continue
1015 }
1016 tr := openaisdk.ToolMessage(output.Error.Error(), toolResultPart.ToolCallID)
1017 if cacheControl != nil {
1018 tr.SetExtraFields(map[string]any{
1019 "cache_control": map[string]string{
1020 "type": cacheControl.Type,
1021 },
1022 })
1023 }
1024 messages = append(messages, tr)
1025 }
1026 }
1027 }
1028 }
1029 return messages, warnings
1030}
1031
1032func hasVisibleUserContent(content []openaisdk.ChatCompletionContentPartUnionParam) bool {
1033 for _, part := range content {
1034 if part.OfText != nil || part.OfImageURL != nil || part.OfInputAudio != nil || part.OfFile != nil {
1035 return true
1036 }
1037 }
1038 return false
1039}
1040
1041func structToMapJSON(s any) (map[string]any, error) {
1042 var result map[string]any
1043 jsonBytes, err := json.Marshal(s)
1044 if err != nil {
1045 return nil, err
1046 }
1047 err = json.Unmarshal(jsonBytes, &result)
1048 if err != nil {
1049 return nil, err
1050 }
1051 return result, nil
1052}