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}