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