language_model_hooks.go

   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/openai/openai-go/v2"
  15	"github.com/openai/openai-go/v2/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 = &currentReasoningState{}
 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}