language_model_hooks.go

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