mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-08 02:36:43 +00:00
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
1344 lines
49 KiB
Go
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
|
|
}
|