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