Files
new-api/relaykit/relayconvert/internal/toolconv/encode.go
T
Calcium-Ion bbd97446c2 fix(relay): follow-up billing integrity and conversion completions (#7170)
Deferred follow-ups from the relaykit-tools review cycle, verified by
live end-to-end billing tests:

- billing: normalize Gemini modality keys consistently between stream
  merge and settlement (case/whitespace variants no longer drop
  independent audio/image pricing) and sum duplicate modality entries
  on both paths
- billing: sync legacy flat Claude cache-creation fields from the
  CacheCreation sub-object (including zeroing) and fall back to flat
  fields only when the snapshot never carried a sub-object, closing a
  stale 1h-cache overcharge path in cascaded deployments
- relay: move Chat-to-Claude and Chat-to-Gemini stream conversion state
  from gin.Context onto RelayInfo and reset it with SendResponseCount in
  InitChannelMeta, so channel retries start clean while per-request
  state (stream error collection, conversion diagnostics, channel
  chain, billing accumulators) survives
- relay: Claude channel now serves Gemini-format clients (request via
  registry conversion, response and stream composed through the Chat
  pivot), removing the last unimplemented conversion direction
- relaykit: recognize legacy pseudo tool names (googleSearch,
  codeExecution, urlContext) in the toolconv decode stage and drop the
  string-matching bypass in the Chat-to-Gemini converter; native Gemini
  tool output is restored and non-Gemini targets follow standard loss
  diagnostics
- relaykit: attach upstream Gemini usage (with billing_usage sidecar)
  to intermediate stream chunks so converted Claude streams report
  upstream truth from message_start, and preserve the sidecar through
  Claude stream usage merges; billing settlement unchanged
- billing: clamp negative Total-Prompt completion derivation, OR the
  Estimated flag across cross-dialect snapshot replacement, and fill
  canonical OpenAI prompt details via field-wise merge
2026-09-03 10:40:05 +08:00

1344 lines
49 KiB
Go

package toolconv
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
func AttachRequest(format types.RelayFormat, request any, set Set, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
if set.Empty() {
return request, nil, nil
}
var (
value any
diagnostics []types.ConversionDiagnostic
err error
)
switch format {
case types.RelayFormatOpenAI:
value, diagnostics, err = attachOpenAIChatRequest(request, set)
case types.RelayFormatOpenAIResponses:
value, diagnostics, err = attachOpenAIResponsesRequest(request, set)
case types.RelayFormatClaude:
value, diagnostics, err = attachClaudeRequest(request, set, options)
case types.RelayFormatGemini:
value, diagnostics, err = attachGeminiRequest(request, set)
default:
value = request
}
if err != nil {
return nil, diagnostics, err
}
for index := range diagnostics {
diagnostics[index].From = set.Source
diagnostics[index].To = format
}
if err := types.RejectConversionLoss(options.EffectiveToolLossPolicy(), diagnostics); err != nil {
return nil, diagnostics, err
}
return value, diagnostics, nil
}
func attachOpenAIChatRequest(request any, set Set) (any, []types.ConversionDiagnostic, error) {
target, ok := request.(*dto.GeneralOpenAIRequest)
if !ok || target == nil {
return nil, nil, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
}
var diagnostics []types.ConversionDiagnostic
for index, definition := range set.Definitions {
switch definition.Kind {
case KindFunction:
if definition.Function == nil {
continue
}
target.Tools = append(target.Tools, dto.ToolCallRequest{
Type: "function",
Function: dto.FunctionRequest{
Name: definition.Function.Name,
Description: definition.Function.Description,
Parameters: definition.Function.Parameters,
Strict: definition.Function.Strict,
},
})
case KindWebSearch:
if set.Source == types.RelayFormatGemini {
diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatOpenAI)...)
}
if target.WebSearchOptions != nil {
return nil, diagnostics, fmt.Errorf("tools[%d]: multiple hosted web-search definitions cannot be represented by Chat Completions", index)
}
options := &dto.WebSearchOptions{}
if definition.WebSearch != nil {
options.SearchContextSize = definition.WebSearch.SearchContextSize
if definition.WebSearch.Location != nil {
location := map[string]any{
"type": "approximate",
"approximate": locationMap(definition.WebSearch.Location),
}
options.UserLocation, _ = kitutil.Marshal(location)
}
diagnostics = append(diagnostics, openAIChatWebSearchDiagnostics(index, definition.WebSearch)...)
}
target.WebSearchOptions = options
default:
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("tools[%d]", index),
"unsupported_hosted_tool",
fmt.Sprintf("OpenAI Chat Completions cannot represent hosted tool %q", definition.NativeType),
))
}
}
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(set.Choice, types.RelayFormatOpenAI)
choice, choiceDiagnostics := encodeOpenAIChatChoice(normalizedChoice)
target.ToolChoice = choice
target.ParallelTooCalls = set.ParallelAllowed
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
diagnostics = append(diagnostics, choiceDiagnostics...)
diagnostics = append(diagnostics, unsupportedHostedHistoryDiagnostics(types.RelayFormatOpenAI, set.History)...)
return target, diagnostics, nil
}
func attachOpenAIResponsesRequest(request any, set Set) (any, []types.ConversionDiagnostic, error) {
target, ok := request.(*dto.OpenAIResponsesRequest)
if !ok || target == nil {
return nil, nil, fmt.Errorf("expected OpenAI Responses request, got %T", request)
}
tools := make([]any, 0, len(set.Definitions))
var diagnostics []types.ConversionDiagnostic
for index, definition := range set.Definitions {
switch definition.Kind {
case KindFunction:
if definition.Function == nil {
continue
}
tool := map[string]any{
"type": "function",
"name": definition.Function.Name,
"description": definition.Function.Description,
"parameters": definition.Function.Parameters,
}
if definition.Function.Strict != nil {
tool["strict"] = *definition.Function.Strict
}
deleteEmptyStrings(tool)
tools = append(tools, tool)
case KindWebSearch:
if set.Source == types.RelayFormatGemini {
diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatOpenAIResponses)...)
}
webSearch := definition.WebSearch
toolType := "web_search"
if set.Source == types.RelayFormatOpenAIResponses && definition.NativeType != "" {
toolType = definition.NativeType
}
tool := map[string]any{"type": toolType}
if webSearch != nil {
if webSearch.SearchContextSize != "" {
tool["search_context_size"] = webSearch.SearchContextSize
}
if webSearch.Location != nil {
location := locationMap(webSearch.Location)
location["type"] = "approximate"
tool["user_location"] = location
}
if len(webSearch.AllowedDomains) > 0 {
tool["filters"] = map[string]any{"allowed_domains": webSearch.AllowedDomains}
}
if webSearch.ExternalWebAccess != nil {
tool["external_web_access"] = *webSearch.ExternalWebAccess
}
if len(webSearch.ReturnTokenBudget) > 0 {
var budget any
if err := kitutil.Unmarshal(webSearch.ReturnTokenBudget, &budget); err != nil {
return nil, diagnostics, fmt.Errorf("tools[%d].return_token_budget: %w", index, err)
}
tool["return_token_budget"] = budget
}
diagnostics = append(diagnostics, openAIResponsesWebSearchDiagnostics(index, webSearch)...)
}
tools = append(tools, tool)
default:
if set.Source == types.RelayFormatOpenAIResponses && len(definition.Raw) > 0 {
var tool any
if err := kitutil.Unmarshal(definition.Raw, &tool); err != nil {
return nil, diagnostics, err
}
tools = append(tools, tool)
continue
}
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("tools[%d]", index),
"unsupported_hosted_tool",
fmt.Sprintf("OpenAI Responses has no verified mapping for hosted tool %q", definition.NativeType),
))
}
}
if len(tools) > 0 {
encoded, err := kitutil.Marshal(tools)
if err != nil {
return nil, diagnostics, err
}
target.Tools = encoded
}
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(set.Choice, types.RelayFormatOpenAIResponses)
choice, choiceDiagnostics, err := encodeOpenAIResponsesChoice(normalizedChoice, set.Source)
if err != nil {
return nil, diagnostics, err
}
target.ToolChoice = choice
if set.ParallelAllowed != nil {
target.ParallelToolCalls, _ = kitutil.Marshal(*set.ParallelAllowed)
}
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
diagnostics = append(diagnostics, choiceDiagnostics...)
historyDiagnostics, err := appendHostedHistoryToOpenAIResponses(target, set)
if err != nil {
return nil, diagnostics, err
}
diagnostics = append(diagnostics, historyDiagnostics...)
return target, diagnostics, nil
}
func attachClaudeRequest(request any, set Set, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
target, ok := request.(*dto.ClaudeRequest)
if !ok || target == nil {
return nil, nil, fmt.Errorf("expected Claude Messages request, got %T", request)
}
tools := make([]any, 0, len(set.Definitions))
var diagnostics []types.ConversionDiagnostic
for index, definition := range set.Definitions {
switch definition.Kind {
case KindFunction:
if definition.Function == nil {
continue
}
inputSchema, err := functionParametersMap(definition.Function.Parameters)
if err != nil {
return nil, diagnostics, fmt.Errorf("tools[%d].input_schema: %w", index, err)
}
tools = append(tools, &dto.Tool{
Name: definition.Function.Name,
Description: definition.Function.Description,
InputSchema: inputSchema,
Strict: definition.Function.Strict,
})
case KindWebSearch:
if set.Source == types.RelayFormatGemini {
diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatClaude)...)
}
toolType := "web_search_20250305"
if set.Source == types.RelayFormatClaude && isKnownClaudeWebSearchType(definition.NativeType) {
toolType = definition.NativeType
} else if options != nil && options.Claude.WebSearchToolVersion != "" {
if !isKnownClaudeWebSearchType(options.Claude.WebSearchToolVersion) {
return nil, diagnostics, fmt.Errorf("unsupported Claude web-search tool version %q", options.Claude.WebSearchToolVersion)
}
toolType = options.Claude.WebSearchToolVersion
}
webSearch := definition.WebSearch
if webSearch != nil && len(webSearch.AllowedDomains) > 0 && len(webSearch.BlockedDomains) > 0 {
return nil, diagnostics, fmt.Errorf("tools[%d]: allowed_domains and blocked_domains are mutually exclusive", index)
}
if webSearch != nil && webSearch.ResponseInclusion != "" && !claudeWebSearchSupportsResponseInclusion(toolType) {
return nil, diagnostics, fmt.Errorf("tools[%d].response_inclusion requires Claude web_search_20260318", index)
}
tool := map[string]any{"type": toolType, "name": "web_search"}
if webSearch != nil {
if webSearch.Location != nil {
location := locationMap(webSearch.Location)
location["type"] = "approximate"
tool["user_location"] = location
}
if len(webSearch.AllowedDomains) > 0 {
tool["allowed_domains"] = webSearch.AllowedDomains
}
if len(webSearch.BlockedDomains) > 0 {
tool["blocked_domains"] = webSearch.BlockedDomains
}
if webSearch.MaxUses != nil {
tool["max_uses"] = *webSearch.MaxUses
}
if len(webSearch.AllowedCallers) > 0 {
tool["allowed_callers"] = webSearch.AllowedCallers
}
if webSearch.ResponseInclusion != "" {
tool["response_inclusion"] = webSearch.ResponseInclusion
}
diagnostics = append(diagnostics, claudeWebSearchDiagnostics(index, webSearch)...)
}
tools = append(tools, tool)
default:
if set.Source == types.RelayFormatClaude && len(definition.Raw) > 0 {
var tool any
if err := kitutil.Unmarshal(definition.Raw, &tool); err != nil {
return nil, diagnostics, err
}
tools = append(tools, tool)
continue
}
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("tools[%d]", index),
"unsupported_hosted_tool",
fmt.Sprintf("Claude Messages has no verified mapping for hosted tool %q", definition.NativeType),
))
}
}
if len(tools) > 0 {
target.Tools = tools
}
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(set.Choice, types.RelayFormatClaude)
choice, choiceDiagnostics := encodeClaudeChoice(normalizedChoice, set.ParallelAllowed, set.Source)
target.ToolChoice = choice
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
diagnostics = append(diagnostics, choiceDiagnostics...)
historyDiagnostics, err := appendHostedHistoryToClaude(target, set)
if err != nil {
return nil, diagnostics, err
}
diagnostics = append(diagnostics, historyDiagnostics...)
return target, diagnostics, nil
}
func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnostic, error) {
target, ok := request.(*dto.GeminiChatRequest)
if !ok || target == nil {
return nil, nil, fmt.Errorf("expected Gemini generateContent request, got %T", request)
}
if set.Source == types.RelayFormatGemini {
tools, err := rebuildGeminiToolGroups(set.Definitions)
if err != nil {
return nil, nil, err
}
if len(tools) > 0 {
target.Tools, err = kitutil.Marshal(tools)
if err != nil {
return nil, nil, err
}
}
if len(set.NativeToolConfig) > 0 {
var config dto.ToolConfig
if err := kitutil.Unmarshal(set.NativeToolConfig, &config); err != nil {
return nil, nil, fmt.Errorf("toolConfig: %w", err)
}
target.ToolConfig = &config
}
return target, unsupportedHostedHistoryDiagnostics(types.RelayFormatGemini, set.History), nil
}
var (
functions []map[string]any
tools []map[string]any
diagnostics []types.ConversionDiagnostic
)
for index, definition := range set.Definitions {
switch definition.Kind {
case KindFunction:
if definition.Function == nil {
continue
}
parameters := definition.Function.Parameters
if parameters != nil {
cloned, err := kitutil.Any2Type[any](parameters)
if err != nil {
return nil, diagnostics, fmt.Errorf("tools[%d].parameters: %w", index, err)
}
if params, ok := cloned.(map[string]any); ok {
if properties, exists := params["properties"].(map[string]any); exists && len(properties) == 0 {
cloned = nil
}
}
parameters = sharedgemini.CleanFunctionParameters(cloned)
}
function := map[string]any{
"name": definition.Function.Name,
"description": definition.Function.Description,
"parameters": parameters,
}
deleteEmptyStrings(function)
functions = append(functions, function)
if definition.Function.Strict != nil {
diagnostics = append(diagnostics, presentationLoss(fmt.Sprintf("tools[%d].strict", index), "unsupported_function_strict", "Gemini does not expose OpenAI function strictness"))
}
case KindWebSearch:
if set.Source == types.RelayFormatGemini && len(definition.Raw) > 0 {
var tool map[string]any
if err := kitutil.Unmarshal(definition.Raw, &tool); err != nil {
return nil, diagnostics, err
}
tools = append(tools, tool)
} else {
tools = append(tools, map[string]any{"googleSearch": map[string]any{}})
}
if definition.WebSearch != nil {
diagnostics = append(diagnostics, geminiWebSearchDiagnostics(index, definition.WebSearch)...)
}
case KindCodeExecution:
if set.Source == types.RelayFormatGemini || definition.NativeType == "codeExecution" {
tools = append(tools, map[string]any{"codeExecution": map[string]any{}})
continue
}
diagnostics = append(diagnostics, semanticLoss(fmt.Sprintf("tools[%d]", index), "unverified_tool_mapping", "code execution semantics differ across providers"))
case KindURLContext:
if set.Source == types.RelayFormatGemini || definition.NativeType == "urlContext" {
tools = append(tools, map[string]any{"urlContext": map[string]any{}})
continue
}
diagnostics = append(diagnostics, semanticLoss(fmt.Sprintf("tools[%d]", index), "unverified_tool_mapping", "URL context has no verified mapping from the source protocol"))
default:
if set.Source == types.RelayFormatGemini && len(definition.Raw) > 0 {
var tool map[string]any
if err := kitutil.Unmarshal(definition.Raw, &tool); err != nil {
return nil, diagnostics, err
}
tools = append(tools, tool)
continue
}
// The established Responses-to-Gemini compatibility path removes
// free-form/unknown tools together with custom call history in
// PrepareOpenAIResponsesRequest. Keep that explicit downgrade as a
// diagnostic; other opaque tools may be server-executed and remain a
// semantic loss under the default Safe policy.
if set.Source == types.RelayFormatOpenAIResponses && (definition.NativeType == "custom" || definition.NativeType == "unknown") {
diagnostics = append(diagnostics, presentationLoss(
fmt.Sprintf("tools[%d]", index),
"custom_tool_omitted",
"Gemini cannot represent this OpenAI free-form or unknown tool; its preprocessed call history and definition were omitted",
))
continue
}
if set.Source == types.RelayFormatOpenAIResponses && definition.Kind == KindNative {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("tools[%d]", index),
"unsupported_opaque_tool",
fmt.Sprintf("Gemini cannot represent OpenAI opaque tool %q; the definition was omitted", definition.NativeType),
))
continue
}
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("tools[%d]", index),
"unsupported_hosted_tool",
fmt.Sprintf("Gemini generateContent has no verified mapping for hosted tool %q", definition.NativeType),
))
}
}
if len(functions) > 0 {
tools = append(tools, map[string]any{"functionDeclarations": functions})
}
if len(tools) > 0 {
encoded, err := kitutil.Marshal(tools)
if err != nil {
return nil, diagnostics, err
}
target.Tools = encoded
}
config, choiceDiagnostics := encodeGeminiChoice(set.Choice)
target.ToolConfig = config
diagnostics = append(diagnostics, choiceDiagnostics...)
if set.ParallelAllowed != nil && !*set.ParallelAllowed {
diagnostics = append(diagnostics, semanticLoss(
"parallel_tool_calls",
"unsupported_parallel_tool_control",
"Gemini generateContent does not expose a request field equivalent to parallel_tool_calls",
))
}
diagnostics = append(diagnostics, unsupportedHostedHistoryDiagnostics(types.RelayFormatGemini, set.History)...)
return target, diagnostics, nil
}
func rebuildGeminiToolGroups(definitions []Definition) ([]map[string]any, error) {
groups := make(map[int]map[string]any)
indexes := make([]int, 0)
for index, definition := range definitions {
if len(definition.Raw) == 0 {
return nil, fmt.Errorf("tools[%d]: missing native Gemini tool payload", index)
}
var fragment map[string]any
if err := kitutil.Unmarshal(definition.Raw, &fragment); err != nil {
return nil, fmt.Errorf("tools[%d]: %w", index, err)
}
group, exists := groups[definition.Group]
if !exists {
group = make(map[string]any)
groups[definition.Group] = group
indexes = append(indexes, definition.Group)
}
for key, value := range fragment {
if key == "functionDeclarations" {
existing, _ := group[key].([]any)
incoming, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("tools[%d].functionDeclarations must be an array", index)
}
group[key] = append(existing, incoming...)
continue
}
group[key] = value
}
}
sort.Ints(indexes)
tools := make([]map[string]any, 0, len(indexes))
for _, index := range indexes {
tools = append(tools, groups[index])
}
return tools, nil
}
func appendHostedHistoryToOpenAIResponses(target *dto.OpenAIResponsesRequest, set Set) ([]types.ConversionDiagnostic, error) {
if len(set.History) == 0 {
return nil, nil
}
input, err := responsesInputItems(target.Input)
if err != nil {
return nil, err
}
itemsBySourceIndex := make(map[int][]map[string]any)
mixedBySourceIndex := make(map[int]bool)
convertedByID := make(map[string]map[string]any)
var diagnostics []types.ConversionDiagnostic
for index, history := range set.History {
if history.MessageHasRegular {
mixedBySourceIndex[history.MessageIndex] = true
}
var item map[string]any
if set.Source == types.RelayFormatOpenAIResponses && len(history.Raw) > 0 {
if err := kitutil.Unmarshal(history.Raw, &item); err != nil {
return nil, fmt.Errorf("hosted_history[%d]: %w", index, err)
}
} else {
outputType := responsesTypeFromHostedKind(history.Kind)
if outputType == "" {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d]", index),
"hosted_tool_history_unsupported",
fmt.Sprintf("%s cannot preserve hosted-tool continuation item %q", types.RelayFormatOpenAIResponses, history.NativeType),
))
continue
}
id := history.ID
if id == "" {
id = history.CallID
}
if strings.HasSuffix(history.NativeType, "_tool_result") || history.NativeType == "mcp_tool_result" {
if call, exists := convertedByID[history.CallID]; exists {
failed := history.Status == "failed"
if failed {
call["status"] = "failed"
} else {
call["status"] = "completed"
}
if history.Kind == KindMCP {
if len(history.Results) > 0 {
var (
encoded json.RawMessage
normalized bool
err error
)
if failed {
encoded, normalized, err = responsesMCPErrorFromClaudeContent(history.Results, "")
} else {
encoded, normalized, err = responsesMCPStringFromClaudeContent(history.Results)
}
if err != nil {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d].content", index),
"mcp_result_unrepresentable",
err.Error(),
))
continue
}
var value string
if err := kitutil.Unmarshal(encoded, &value); err != nil {
return nil, fmt.Errorf("hosted_history[%d].content: %w", index, err)
}
if failed {
call["error"] = value
} else {
call["output"] = value
}
if normalized {
diagnostics = append(diagnostics, presentationLoss(
fmt.Sprintf("hosted_history[%d].content", index),
"mcp_text_result_normalized",
"Claude's single MCP text block was normalized to a Responses output string",
))
}
}
} else if history.Kind == KindWebSearch && len(history.Results) > 0 {
diagnostics = append(diagnostics, presentationLoss(
fmt.Sprintf("hosted_history[%d].content", index),
"web_search_result_omitted",
"Claude web-search result content has no field on a Responses web_search_call",
))
}
continue
}
}
item = map[string]any{
"type": outputType,
"id": id,
"status": history.Status,
}
if item["status"] == "" {
item["status"] = "in_progress"
}
switch history.Kind {
case KindWebSearch:
action, err := dto.NormalizeResponsesWebSearchAction(history.Action)
if err != nil {
return nil, fmt.Errorf("hosted_history[%d].action: %w", index, err)
}
var actionValue any
if err := kitutil.Unmarshal(action, &actionValue); err != nil {
return nil, fmt.Errorf("hosted_history[%d].action: %w", index, err)
}
item["action"] = actionValue
case KindMCP:
if strings.TrimSpace(history.Name) == "" || strings.TrimSpace(history.ServerName) == "" {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d]", index),
"mcp_identity_missing",
"Claude MCP history requires both name and server_name for Responses mapping",
))
continue
}
item["name"] = history.Name
item["server_label"] = history.ServerName
if len(history.Action) > 0 {
arguments, err := responsesMCPArgumentsFromClaude(history.Action)
if err != nil {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d].input", index),
"mcp_arguments_unrepresentable",
err.Error(),
))
continue
}
var value string
if err := kitutil.Unmarshal(arguments, &value); err != nil {
return nil, fmt.Errorf("hosted_history[%d].input: %w", index, err)
}
item["arguments"] = value
} else {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d].input", index),
"mcp_arguments_missing",
"Claude MCP history has no input object",
))
continue
}
if len(history.Results) > 0 {
failed := history.Status == "failed"
var encoded json.RawMessage
var normalized bool
if failed {
encoded, normalized, err = responsesMCPErrorFromClaudeContent(history.Results, "")
} else {
encoded, normalized, err = responsesMCPStringFromClaudeContent(history.Results)
}
if err != nil {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d].content", index),
"mcp_result_unrepresentable",
err.Error(),
))
continue
}
var value string
if err := kitutil.Unmarshal(encoded, &value); err != nil {
return nil, fmt.Errorf("hosted_history[%d].content: %w", index, err)
}
if failed {
item["error"] = value
item["status"] = "failed"
} else {
item["output"] = value
item["status"] = "completed"
}
if normalized {
diagnostics = append(diagnostics, presentationLoss(
fmt.Sprintf("hosted_history[%d].content", index),
"mcp_text_result_normalized",
"Claude's single MCP text block was normalized to a Responses output string",
))
}
}
if len(history.Caller) > 0 {
var caller any
if err := kitutil.Unmarshal(history.Caller, &caller); err != nil {
return nil, fmt.Errorf("hosted_history[%d].caller: %w", index, err)
}
item["caller"] = caller
}
}
if id != "" {
convertedByID[id] = item
}
}
itemsBySourceIndex[history.MessageIndex] = append(itemsBySourceIndex[history.MessageIndex], item)
}
if len(itemsBySourceIndex) == 0 {
if len(diagnostics) > 0 {
return diagnostics, nil
}
return unsupportedHostedHistoryDiagnostics(types.RelayFormatOpenAIResponses, set.History), nil
}
merged := make([]map[string]any, 0, len(input)+len(set.History))
inputIndex := 0
maxSourceIndex := 0
for _, history := range set.History {
if history.MessageIndex > maxSourceIndex {
maxSourceIndex = history.MessageIndex
}
}
for sourceIndex := 0; sourceIndex <= maxSourceIndex || inputIndex < len(input); sourceIndex++ {
if hostedItems := itemsBySourceIndex[sourceIndex]; len(hostedItems) > 0 {
if !mixedBySourceIndex[sourceIndex] {
merged = append(merged, hostedItems...)
continue
}
merged = append(merged, hostedItems...)
if inputIndex < len(input) {
merged = append(merged, input[inputIndex])
inputIndex++
}
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d]", sourceIndex),
"hosted_tool_order_unrepresentable",
"hosted and ordinary blocks share one source message, but Responses represents hosted calls as separate input items",
))
continue
}
if inputIndex < len(input) {
merged = append(merged, input[inputIndex])
inputIndex++
}
}
encoded, err := kitutil.Marshal(merged)
if err != nil {
return nil, err
}
target.Input = encoded
diagnostics = append(diagnostics, presentationLoss(
"input",
"hosted_tool_history_approximated",
"hosted-tool continuation state is preserved, but provider-specific item fields may differ",
))
return diagnostics, nil
}
func appendHostedHistoryToClaude(target *dto.ClaudeRequest, set Set) ([]types.ConversionDiagnostic, error) {
if len(set.History) == 0 {
return nil, nil
}
blocksBySourceIndex := make(map[int][]any)
mixedBySourceIndex := make(map[int]bool)
var diagnostics []types.ConversionDiagnostic
for index, history := range set.History {
if history.MessageHasRegular {
mixedBySourceIndex[history.MessageIndex] = true
}
var blocks []any
if set.Source == types.RelayFormatClaude && len(history.Raw) > 0 {
var block any
if err := kitutil.Unmarshal(history.Raw, &block); err != nil {
return nil, fmt.Errorf("hosted_history[%d]: %w", index, err)
}
blocksBySourceIndex[history.MessageIndex] = append(blocksBySourceIndex[history.MessageIndex], block)
continue
}
if set.Source == types.RelayFormatOpenAIResponses && history.Kind == KindWebSearch {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d]", index),
"web_search_continuation_unrepresentable",
"Responses web-search history cannot reconstruct Claude's encrypted web_search_tool_result continuation state",
))
continue
}
if history.Kind == KindMCP {
if history.Name == "" || history.ServerName == "" {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d]", index),
"mcp_identity_missing",
"Responses MCP history requires both name and server_label for Claude mapping",
))
continue
}
id := history.ID
if id == "" {
id = history.CallID
}
if id == "" {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d].id", index),
"hosted_tool_id_missing",
"Responses MCP history has no id for pairing the call with its result",
))
continue
}
if history.Status != "" && history.Status != "in_progress" && history.Status != "completed" && history.Status != "failed" {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d].status", index),
"hosted_tool_status_unrepresentable",
fmt.Sprintf("Claude cannot preserve Responses MCP status %q", history.Status),
))
}
mcpResult := history.Results
mcpFailed := history.Status == "failed"
if len(history.Raw) > 0 {
var rawFields struct {
ApprovalRequestID string `json:"approval_request_id"`
Output json.RawMessage `json:"output"`
Error json.RawMessage `json:"error"`
}
if err := kitutil.Unmarshal(history.Raw, &rawFields); err != nil {
return nil, fmt.Errorf("hosted_history[%d]: %w", index, err)
}
if strings.TrimSpace(rawFields.ApprovalRequestID) != "" {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d].approval_request_id", index),
"mcp_approval_state_unrepresentable",
"Claude MCP history cannot preserve a Responses approval_request_id",
))
}
if rawJSONPresent(rawFields.Output) && rawJSONPresent(rawFields.Error) {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d]", index),
"mcp_result_ambiguous",
"Responses MCP history contains both output and error",
))
}
if rawJSONPresent(rawFields.Error) {
mcpResult = rawFields.Error
mcpFailed = true
} else if rawJSONPresent(rawFields.Output) {
mcpResult = rawFields.Output
}
}
input, inputErr := claudeMCPInputFromResponses(history.Action)
if inputErr != nil {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d].arguments", index),
"mcp_arguments_unrepresentable",
inputErr.Error(),
))
continue
}
call := map[string]any{
"type": "mcp_tool_use",
"id": id,
"name": history.Name,
"server_name": history.ServerName,
"input": input,
}
if len(history.Caller) > 0 {
var caller any
if err := kitutil.Unmarshal(history.Caller, &caller); err != nil {
return nil, fmt.Errorf("hosted_history[%d].caller: %w", index, err)
}
call["caller"] = caller
}
blocks = append(blocks, call)
if rawJSONPresent(mcpResult) {
content, resultErr := claudeMCPContentFromResponsesString(mcpResult)
if resultErr != nil {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d].output", index),
"mcp_result_unrepresentable",
resultErr.Error(),
))
continue
}
result := map[string]any{"type": "mcp_tool_result", "tool_use_id": id, "content": content}
if mcpFailed {
result["is_error"] = true
}
blocks = append(blocks, result)
} else if history.Status == "completed" || mcpFailed {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d]", index),
"mcp_result_missing",
fmt.Sprintf("Responses MCP history has status %q but no output or error", history.Status),
))
}
blocksBySourceIndex[history.MessageIndex] = append(blocksBySourceIndex[history.MessageIndex], blocks...)
continue
}
name := claudeNameFromHostedKind(history.Kind)
if name == "" {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d]", index),
"hosted_tool_history_unsupported",
fmt.Sprintf("%s cannot preserve hosted-tool continuation item %q", types.RelayFormatClaude, history.NativeType),
))
continue
}
id := history.ID
if id == "" {
id = history.CallID
}
var input any = map[string]any{}
if history.Kind == KindWebSearch {
webInput, inputErr := claudeWebSearchInputFromResponses(history.Action)
if inputErr != nil {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d].action", index),
"web_search_action_unrepresentable",
inputErr.Error(),
))
continue
}
input = webInput
} else if len(history.Action) > 0 {
if err := kitutil.Unmarshal(history.Action, &input); err != nil {
return nil, fmt.Errorf("hosted_history[%d].action: %w", index, err)
}
}
call := map[string]any{
"type": "server_tool_use",
"id": id,
"name": name,
"input": input,
}
if len(history.Caller) > 0 {
var caller any
if err := kitutil.Unmarshal(history.Caller, &caller); err != nil {
return nil, fmt.Errorf("hosted_history[%d].caller: %w", index, err)
}
call["caller"] = caller
}
blocks = append(blocks, call)
if len(history.Results) > 0 && !(set.Source == types.RelayFormatOpenAIResponses && history.Kind == KindWebSearch) {
var content any
if err := kitutil.Unmarshal(history.Results, &content); err != nil {
return nil, fmt.Errorf("hosted_history[%d].results: %w", index, err)
}
blocks = append(blocks, map[string]any{
"type": claudeResultTypeFromHostedKind(history.Kind),
"tool_use_id": id,
"content": content,
})
} else if len(history.Results) > 0 && history.Kind == KindWebSearch {
diagnostics = append(diagnostics, presentationLoss(
fmt.Sprintf("hosted_history[%d].results", index),
"web_search_result_omitted",
"Responses web-search source metadata cannot reconstruct Claude's encrypted web_search_tool_result",
))
}
blocksBySourceIndex[history.MessageIndex] = append(blocksBySourceIndex[history.MessageIndex], blocks...)
}
if len(blocksBySourceIndex) == 0 {
if len(diagnostics) > 0 {
return diagnostics, nil
}
return unsupportedHostedHistoryDiagnostics(types.RelayFormatClaude, set.History), nil
}
messages := make([]dto.ClaudeMessage, 0, len(target.Messages)+len(blocksBySourceIndex))
messageIndex := 0
maxSourceIndex := 0
for _, history := range set.History {
if history.MessageIndex > maxSourceIndex {
maxSourceIndex = history.MessageIndex
}
}
for sourceIndex := 0; sourceIndex <= maxSourceIndex || messageIndex < len(target.Messages); sourceIndex++ {
if blocks := blocksBySourceIndex[sourceIndex]; len(blocks) > 0 {
role := "assistant"
for _, history := range set.History {
if history.MessageIndex == sourceIndex && history.Role != "" {
role = history.Role
break
}
}
if !mixedBySourceIndex[sourceIndex] {
messages = append(messages, dto.ClaudeMessage{Role: role, Content: blocks})
continue
}
messages = append(messages, dto.ClaudeMessage{Role: role, Content: blocks})
if messageIndex < len(target.Messages) {
messages = append(messages, target.Messages[messageIndex])
messageIndex++
}
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d]", sourceIndex),
"hosted_tool_order_unrepresentable",
"hosted and ordinary content cannot be merged after the intermediate converter coalesced source blocks",
))
continue
}
if messageIndex < len(target.Messages) {
messages = append(messages, target.Messages[messageIndex])
messageIndex++
}
}
target.Messages = messages
diagnostics = append(diagnostics, presentationLoss(
"messages",
"hosted_tool_history_approximated",
"hosted-tool continuation state is preserved, but provider-specific item fields may differ",
))
return diagnostics, nil
}
func responsesInputItems(raw json.RawMessage) ([]map[string]any, error) {
if len(raw) == 0 {
return nil, nil
}
switch kitutil.GetJsonType(raw) {
case "array":
var input []map[string]any
if err := kitutil.Unmarshal(raw, &input); err != nil {
return nil, fmt.Errorf("invalid Responses input: %w", err)
}
return input, nil
case "string":
var text string
if err := kitutil.Unmarshal(raw, &text); err != nil {
return nil, fmt.Errorf("invalid Responses input: %w", err)
}
return []map[string]any{{"role": "user", "content": text}}, nil
default:
return nil, fmt.Errorf("cannot append hosted-tool history to Responses input type %q", kitutil.GetJsonType(raw))
}
}
func unsupportedHostedHistoryDiagnostics(format types.RelayFormat, history []HostedHistoryItem) []types.ConversionDiagnostic {
if len(history) == 0 {
return nil
}
diagnostics := make([]types.ConversionDiagnostic, 0, len(history))
for index, item := range history {
diagnostics = append(diagnostics, semanticLoss(
fmt.Sprintf("hosted_history[%d]", index),
"hosted_tool_history_unsupported",
fmt.Sprintf("%s cannot preserve hosted-tool continuation item %q", format, item.NativeType),
))
}
return diagnostics
}
func openAIChatWebSearchDiagnostics(index int, search *WebSearch) []types.ConversionDiagnostic {
if search == nil {
return nil
}
path := fmt.Sprintf("tools[%d]", index)
var diagnostics []types.ConversionDiagnostic
if len(search.AllowedDomains) > 0 || len(search.BlockedDomains) > 0 {
diagnostics = append(diagnostics, semanticLoss(path+".domains", "unsupported_domain_filter", "Chat Completions web_search_options cannot preserve domain access constraints"))
}
if search.MaxUses != nil {
diagnostics = append(diagnostics, semanticLoss(path+".max_uses", "unsupported_search_limit", "Chat Completions cannot preserve Claude max_uses"))
}
if len(search.AllowedCallers) > 0 || search.ExternalWebAccess != nil {
diagnostics = append(diagnostics, semanticLoss(path, "unsupported_search_controls", "Chat Completions cannot preserve caller or external-access constraints"))
}
if search.ResponseInclusion != "" || len(search.ReturnTokenBudget) > 0 {
diagnostics = append(diagnostics, presentationLoss(path, "unsupported_search_tuning", "Chat Completions cannot preserve response-inclusion or return-token tuning"))
}
return diagnostics
}
func openAIResponsesWebSearchDiagnostics(index int, search *WebSearch) []types.ConversionDiagnostic {
if search == nil {
return nil
}
path := fmt.Sprintf("tools[%d]", index)
var diagnostics []types.ConversionDiagnostic
if search.MaxUses != nil {
diagnostics = append(diagnostics, semanticLoss(path+".max_uses", "unsupported_search_limit", "OpenAI Responses cannot preserve Claude max_uses"))
}
if len(search.BlockedDomains) > 0 {
diagnostics = append(diagnostics, semanticLoss(path+".blocked_domains", "unsupported_blocked_domains", "OpenAI Responses web search supports allow filters but not Claude blocked_domains"))
}
if len(search.AllowedCallers) > 0 {
diagnostics = append(diagnostics, semanticLoss(path, "unsupported_search_controls", "OpenAI Responses cannot preserve Claude caller constraints"))
}
if search.ResponseInclusion != "" {
diagnostics = append(diagnostics, presentationLoss(path+".response_inclusion", "unsupported_search_tuning", "OpenAI Responses cannot preserve Claude response-inclusion tuning"))
}
return diagnostics
}
func claudeWebSearchDiagnostics(index int, search *WebSearch) []types.ConversionDiagnostic {
if search == nil {
return nil
}
path := fmt.Sprintf("tools[%d]", index)
var diagnostics []types.ConversionDiagnostic
if search.SearchContextSize != "" {
diagnostics = append(diagnostics, presentationLoss(path+".search_context_size", "unsupported_search_context_size", "Claude has no equivalent for OpenAI search_context_size; max_uses is deliberately not inferred"))
}
if search.ExternalWebAccess != nil {
diagnostics = append(diagnostics, semanticLoss(path+".external_web_access", "unsupported_search_controls", "Claude cannot preserve OpenAI external-web access constraints"))
}
if len(search.ReturnTokenBudget) > 0 {
diagnostics = append(diagnostics, presentationLoss(path+".return_token_budget", "unsupported_search_tuning", "Claude cannot preserve OpenAI return-token tuning"))
}
return diagnostics
}
func geminiWebSearchDiagnostics(index int, search *WebSearch) []types.ConversionDiagnostic {
if search == nil {
return nil
}
path := fmt.Sprintf("tools[%d]", index)
if search.Location == nil && len(search.AllowedDomains) == 0 && len(search.BlockedDomains) == 0 && search.SearchContextSize == "" && search.MaxUses == nil && len(search.AllowedCallers) == 0 && search.ResponseInclusion == "" && search.ExternalWebAccess == nil && len(search.ReturnTokenBudget) == 0 {
return nil
}
var diagnostics []types.ConversionDiagnostic
if len(search.AllowedDomains) > 0 || len(search.BlockedDomains) > 0 || search.ExternalWebAccess != nil || search.MaxUses != nil || len(search.AllowedCallers) > 0 {
diagnostics = append(diagnostics, semanticLoss(path, "unsupported_search_constraints", "Gemini Google Search cannot preserve source web-search access or execution constraints"))
}
if search.Location != nil || search.SearchContextSize != "" || search.ResponseInclusion != "" || len(search.ReturnTokenBudget) > 0 {
diagnostics = append(diagnostics, presentationLoss(path, "unsupported_search_tuning", "Gemini Google Search cannot preserve source web-search location or result tuning"))
}
return diagnostics
}
func geminiNativeWebSearchDiagnostics(index int, definition Definition, target types.RelayFormat) []types.ConversionDiagnostic {
path := fmt.Sprintf("tools[%d]", index)
switch definition.NativeType {
case "googleSearch":
if !geminiNativeToolHasConfiguration(definition) {
return nil
}
return []types.ConversionDiagnostic{semanticLoss(
path+".googleSearch",
"unsupported_native_search_config",
fmt.Sprintf("%s cannot preserve Gemini googleSearch configuration", target),
)}
case "googleSearchRetrieval":
return []types.ConversionDiagnostic{semanticLoss(
path+".googleSearchRetrieval",
"legacy_search_semantics_unrepresentable",
fmt.Sprintf("%s cannot preserve Gemini legacy dynamic-retrieval semantics", target),
)}
case "enterpriseWebSearch":
return []types.ConversionDiagnostic{semanticLoss(
path+".enterpriseWebSearch",
"enterprise_search_semantics_unrepresentable",
fmt.Sprintf("%s cannot replace Gemini enterprise search with public web search without changing its data source", target),
)}
default:
return []types.ConversionDiagnostic{semanticLoss(
path,
"unverified_search_mapping",
fmt.Sprintf("%s has no verified mapping for Gemini search tool %q", target, definition.NativeType),
)}
}
}
func geminiNativeToolHasConfiguration(definition Definition) bool {
if len(definition.Raw) == 0 {
return false
}
var wrapper map[string]json.RawMessage
if kitutil.Unmarshal(definition.Raw, &wrapper) != nil {
return true
}
payload := wrapper[definition.NativeType]
if !rawJSONPresent(payload) {
return false
}
if kitutil.GetJsonType(payload) != "object" {
return true
}
var fields map[string]json.RawMessage
return kitutil.Unmarshal(payload, &fields) != nil || len(fields) > 0
}
func narrowAllowedFunctionChoice(choice *Choice, target types.RelayFormat) (*Choice, []types.ConversionDiagnostic) {
if choice == nil || len(choice.AllowedNames) == 0 {
return choice, nil
}
normalized := *choice
normalized.AllowedNames = nil
if len(choice.AllowedNames) == 1 {
normalized.Mode = ChoiceNamed
normalized.Kind = KindFunction
normalized.Name = choice.AllowedNames[0]
return &normalized, nil
}
return &normalized, []types.ConversionDiagnostic{semanticLoss(
"tool_choice.allowed_function_names",
"allowed_function_subset_unrepresentable",
fmt.Sprintf("%s cannot restrict a required tool call to Gemini's %d-name function subset", target, len(choice.AllowedNames)),
)}
}
func encodeOpenAIChatChoice(choice *Choice) (any, []types.ConversionDiagnostic) {
if choice == nil {
return nil, nil
}
switch choice.Mode {
case ChoiceAuto:
return "auto", nil
case ChoiceNone:
return "none", nil
case ChoiceRequired:
return "required", nil
case ChoiceNamed:
if choice.Kind == KindFunction {
return map[string]any{"type": "function", "function": map[string]any{"name": choice.Name}}, nil
}
if choice.Kind == KindWebSearch {
return nil, []types.ConversionDiagnostic{presentationLoss("tool_choice", "implicit_search_choice", "Chat Completions expresses hosted search through web_search_options instead of tool_choice")}
}
}
return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "unsupported_tool_choice", "Chat Completions cannot represent the source hosted tool choice")}
}
func encodeOpenAIResponsesChoice(choice *Choice, source types.RelayFormat) (json.RawMessage, []types.ConversionDiagnostic, error) {
if choice == nil {
return nil, nil, nil
}
var value any
switch choice.Mode {
case ChoiceAuto, ChoiceNone, ChoiceRequired:
value = string(choice.Mode)
case ChoiceNamed:
if len(choice.Raw) > 0 && choice.NativeType != "" {
return append(json.RawMessage(nil), choice.Raw...), nil, nil
}
switch choice.Kind {
case KindFunction:
value = map[string]any{"type": "function", "name": choice.Name}
case KindWebSearch:
value = map[string]any{"type": "web_search"}
default:
return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "unsupported_tool_choice", "OpenAI Responses has no verified hosted tool-choice mapping")}, nil
}
case ChoiceOpaque:
if source == types.RelayFormatOpenAIResponses && len(choice.Raw) > 0 {
return append(json.RawMessage(nil), choice.Raw...), nil, nil
}
return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "unsupported_tool_choice", "OpenAI Responses cannot reconstruct the source complex tool choice")}, nil
}
encoded, err := kitutil.Marshal(value)
return encoded, nil, err
}
func encodeClaudeChoice(choice *Choice, parallelAllowed *bool, source types.RelayFormat) (any, []types.ConversionDiagnostic) {
if choice == nil && parallelAllowed == nil {
return nil, nil
}
if source == types.RelayFormatClaude && choice != nil && len(choice.Raw) > 0 {
var value any
if err := kitutil.Unmarshal(choice.Raw, &value); err != nil {
return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "invalid_native_tool_choice", "Claude tool_choice could not be restored from its native payload")}
}
return value, nil
}
value := map[string]any{}
if choice != nil {
switch choice.Mode {
case ChoiceAuto:
value["type"] = "auto"
case ChoiceNone:
value["type"] = "none"
case ChoiceRequired:
value["type"] = "any"
case ChoiceNamed:
if choice.Kind != KindFunction && choice.Kind != KindWebSearch {
return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "unsupported_tool_choice", "Claude has no verified hosted tool-choice mapping")}
}
value["type"] = "tool"
value["name"] = choice.Name
case ChoiceOpaque:
return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "unsupported_tool_choice", "Claude cannot represent the source complex tool-choice policy")}
}
}
if value["type"] == nil && parallelAllowed != nil {
value["type"] = "auto"
}
if parallelAllowed != nil && value["type"] != "none" {
value["disable_parallel_tool_use"] = !*parallelAllowed
} else if choice != nil && choice.DisableParallelToolUse != nil && value["type"] != "none" {
value["disable_parallel_tool_use"] = *choice.DisableParallelToolUse
}
return value, nil
}
func encodeGeminiChoice(choice *Choice) (*dto.ToolConfig, []types.ConversionDiagnostic) {
if choice == nil {
return nil, nil
}
config := &dto.ToolConfig{FunctionCallingConfig: &dto.FunctionCallingConfig{}}
if len(choice.AllowedNames) > 0 {
config.FunctionCallingConfig.Mode = "ANY"
config.FunctionCallingConfig.AllowedFunctionNames = append([]string(nil), choice.AllowedNames...)
return config, nil
}
switch choice.Mode {
case ChoiceAuto:
config.FunctionCallingConfig.Mode = "AUTO"
case ChoiceNone:
config.FunctionCallingConfig.Mode = "NONE"
case ChoiceRequired:
config.FunctionCallingConfig.Mode = "ANY"
case ChoiceNamed:
if choice.Kind != KindFunction {
return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "unsupported_tool_choice", "Gemini generateContent does not expose an equivalent hosted-tool choice")}
}
config.FunctionCallingConfig.Mode = "ANY"
config.FunctionCallingConfig.AllowedFunctionNames = []string{choice.Name}
case ChoiceOpaque:
return nil, []types.ConversionDiagnostic{semanticLoss("tool_choice", "unsupported_tool_choice", "Gemini cannot represent the source complex tool-choice policy")}
}
return config, nil
}
func semanticLoss(path string, code string, message string) types.ConversionDiagnostic {
return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticError}
}
func presentationLoss(path string, code string, message string) types.ConversionDiagnostic {
return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticWarning}
}
func locationMap(location *ApproximateLocation) map[string]any {
value := map[string]any{
"city": location.City,
"region": location.Region,
"country": location.Country,
"timezone": location.Timezone,
}
deleteEmptyStrings(value)
return value
}
func deleteEmptyStrings(value map[string]any) {
for key, item := range value {
if text, ok := item.(string); ok && text == "" {
delete(value, key)
}
}
}
func functionParametersMap(parameters any) (map[string]interface{}, error) {
if parameters == nil {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
}, nil
}
converted, err := kitutil.Any2Type[map[string]interface{}](parameters)
if err != nil {
return nil, err
}
if converted["type"] == nil {
converted["type"] = "object"
}
if converted["properties"] == nil {
converted["properties"] = map[string]interface{}{}
}
return converted, nil
}