1700 lines
63 KiB
Go
1700 lines
63 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, options)
|
|
case types.RelayFormatOpenAIResponses:
|
|
value, diagnostics, err = attachOpenAIResponsesRequest(request, set, options)
|
|
case types.RelayFormatClaude:
|
|
value, diagnostics, err = attachClaudeRequest(request, set, options)
|
|
case types.RelayFormatGemini:
|
|
value, diagnostics, err = attachGeminiRequest(request, set, options)
|
|
default:
|
|
value = request
|
|
}
|
|
diagnostics = append(set.Diagnostics, diagnostics...)
|
|
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, options *convmeta.Options) (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)
|
|
}
|
|
customTools, diagnostics := responsesCustomTools(set)
|
|
// chatWebSearchEncoded reports that the host encoder handled the hosted
|
|
// web search; it may have written a tools[] entry or top-level fields
|
|
// instead of web_search_options.
|
|
chatWebSearchEncoded := false
|
|
for index, definition := range set.Definitions {
|
|
switch definition.Kind {
|
|
case KindFunction:
|
|
if definition.Function == nil {
|
|
continue
|
|
}
|
|
parameters, schemaDiagnostics := jsonSchemaParameters(index, definition.Function)
|
|
diagnostics = append(diagnostics, schemaDiagnostics...)
|
|
target.Tools = append(target.Tools, dto.ToolCallRequest{
|
|
Type: "function",
|
|
Function: dto.FunctionRequest{
|
|
Name: definition.Function.Name,
|
|
Description: definition.Function.Description,
|
|
Parameters: parameters,
|
|
Strict: definition.Function.Strict,
|
|
},
|
|
})
|
|
case KindWebSearch:
|
|
if set.Source == types.RelayFormatGemini {
|
|
diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatOpenAI)...)
|
|
}
|
|
if target.WebSearchOptions != nil || chatWebSearchEncoded {
|
|
diagnostics = append(diagnostics, semanticLoss(
|
|
fmt.Sprintf("tools[%d]", index),
|
|
"duplicate_hosted_web_search",
|
|
"Chat Completions carries one hosted web search; this definition was dropped",
|
|
))
|
|
continue
|
|
}
|
|
call := newWebSearchCall(set, index, definition, types.RelayFormatOpenAI)
|
|
call.Chat = target
|
|
tool, encoderDiagnostics, handled, err := encodeWebSearch(call, options)
|
|
if err != nil {
|
|
return nil, diagnostics, err
|
|
}
|
|
if handled {
|
|
diagnostics = append(diagnostics, encoderDiagnostics...)
|
|
chatWebSearchEncoded = true
|
|
if tool != nil {
|
|
chatTool, ok := tool.(dto.ToolCallRequest)
|
|
if !ok {
|
|
return nil, diagnostics, fmt.Errorf("%s: Chat web-search encoder returned %T, want dto.ToolCallRequest", call.Path(), tool)
|
|
}
|
|
target.Tools = append(target.Tools, chatTool)
|
|
}
|
|
continue
|
|
}
|
|
webSearchOptions := &dto.WebSearchOptions{}
|
|
if definition.WebSearch != nil {
|
|
webSearchOptions.SearchContextSize = definition.WebSearch.SearchContextSize
|
|
if definition.WebSearch.Location != nil {
|
|
location := map[string]any{
|
|
"type": "approximate",
|
|
"approximate": locationMap(definition.WebSearch.Location),
|
|
}
|
|
webSearchOptions.UserLocation, _ = kitutil.Marshal(location)
|
|
}
|
|
diagnostics = append(diagnostics, openAIChatWebSearchDiagnostics(index, definition.WebSearch)...)
|
|
}
|
|
target.WebSearchOptions = webSearchOptions
|
|
default:
|
|
if tool, handled := customTools[index]; handled {
|
|
if tool != nil {
|
|
target.Tools = append(target.Tools, tool.chatTool())
|
|
}
|
|
continue
|
|
}
|
|
diagnostics = append(diagnostics, semanticLoss(
|
|
fmt.Sprintf("tools[%d]", index),
|
|
"unsupported_hosted_tool",
|
|
fmt.Sprintf("OpenAI Chat Completions cannot represent hosted tool %q", definition.NativeType),
|
|
))
|
|
}
|
|
}
|
|
|
|
diagnostics = append(diagnostics, unsupportedHostedHistoryDiagnostics(types.RelayFormatOpenAI, set.History)...)
|
|
if chatWebSearchEncoded && forcedWebSearch(set.Choice) {
|
|
// The encoder received Forced and expressed it in its own encoding.
|
|
set.Choice = nil
|
|
}
|
|
if len(target.Tools) == 0 {
|
|
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
|
}
|
|
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(responsesCustomToolChoice(set.Choice, customTools), types.RelayFormatOpenAI)
|
|
choice, choiceDiagnostics := encodeOpenAIChatChoice(normalizedChoice)
|
|
target.ToolChoice = choice
|
|
target.ParallelTooCalls = set.ParallelAllowed
|
|
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
|
|
diagnostics = append(diagnostics, choiceDiagnostics...)
|
|
return target, diagnostics, nil
|
|
}
|
|
|
|
// newWebSearchCall describes one hosted web-search definition for the host
|
|
// encoder; the caller sets the typed target request.
|
|
func newWebSearchCall(set Set, index int, definition Definition, target types.RelayFormat) convmeta.WebSearchCall {
|
|
call := convmeta.WebSearchCall{
|
|
Source: set.Source,
|
|
Target: target,
|
|
NativeType: definition.NativeType,
|
|
Index: index,
|
|
Forced: forcedWebSearch(set.Choice),
|
|
}
|
|
if definition.WebSearch != nil {
|
|
call.Spec = *definition.WebSearch
|
|
}
|
|
return call
|
|
}
|
|
|
|
func forcedWebSearch(choice *Choice) bool {
|
|
return choice != nil && choice.Mode == ChoiceNamed && choice.Kind == KindWebSearch
|
|
}
|
|
|
|
// encodeWebSearch runs the host web-search encoder for one definition.
|
|
// handled is false when no encoder is set or it returned nil; the caller then
|
|
// keeps its default encoding and default diagnostics. When handled, the
|
|
// encoder's diagnostics replace the default ones and tool (possibly nil) is
|
|
// what the caller appends.
|
|
func encodeWebSearch(call convmeta.WebSearchCall, options *convmeta.Options) (tool any, diagnostics []types.ConversionDiagnostic, handled bool, err error) {
|
|
if options == nil || options.WebSearch == nil {
|
|
return nil, nil, false, nil
|
|
}
|
|
encoding, err := options.WebSearch(call)
|
|
if err != nil {
|
|
return nil, nil, false, fmt.Errorf("%s: %w", call.Path(), err)
|
|
}
|
|
if encoding == nil {
|
|
return nil, nil, false, nil
|
|
}
|
|
if encoding.Omitted {
|
|
return nil, append(encoding.Diagnostics, call.SemanticLoss(
|
|
"hosted_web_search_omitted",
|
|
"the upstream has no hosted web search for this protocol; the web search tool was omitted",
|
|
)), true, nil
|
|
}
|
|
return encoding.Tool, encoding.Diagnostics, true, nil
|
|
}
|
|
|
|
// appendEncodedWebSearch runs the host encoder for a Responses, Claude, or
|
|
// Gemini target. When the encoder handled the definition, its diagnostics and
|
|
// tool (if any) are appended and the caller skips its default encoding.
|
|
func appendEncodedWebSearch(tools *[]any, diagnostics *[]types.ConversionDiagnostic, call convmeta.WebSearchCall, options *convmeta.Options) (bool, error) {
|
|
tool, encoderDiagnostics, handled, err := encodeWebSearch(call, options)
|
|
if err != nil || !handled {
|
|
return false, err
|
|
}
|
|
*diagnostics = append(*diagnostics, encoderDiagnostics...)
|
|
if tool != nil {
|
|
*tools = append(*tools, tool)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// toolChoiceWithoutToolsDiagnostics reports the tool_choice and
|
|
// parallel_tool_calls controls omitted because no tool reached the target;
|
|
// upstreams reject either control in a request without tools.
|
|
func toolChoiceWithoutToolsDiagnostics(set Set) []types.ConversionDiagnostic {
|
|
if set.Choice == nil && set.ParallelAllowed == nil {
|
|
return nil
|
|
}
|
|
return []types.ConversionDiagnostic{presentationLoss("tool_choice", "tool_choice_without_tools",
|
|
"the target request has no function tools, so tool_choice and parallel_tool_calls were omitted")}
|
|
}
|
|
|
|
// jsonSchemaParameters returns function parameters as JSON Schema for a
|
|
// non-Gemini target, rewriting Gemini's OpenAPI schema subset.
|
|
func jsonSchemaParameters(index int, function *Function) (any, []types.ConversionDiagnostic) {
|
|
if !function.OpenAPISchema {
|
|
return function.Parameters, nil
|
|
}
|
|
parameters, dropped := sharedgemini.OpenAPISchemaToJSONSchema(function.Parameters)
|
|
if len(dropped) == 0 {
|
|
return parameters, nil
|
|
}
|
|
return parameters, []types.ConversionDiagnostic{presentationLoss(
|
|
fmt.Sprintf("tools[%d]", index),
|
|
"gemini_schema_keyword_dropped",
|
|
fmt.Sprintf("JSON Schema has no equivalent for Gemini schema keywords %s; they were dropped", strings.Join(dropped, ", ")),
|
|
)}
|
|
}
|
|
|
|
// responsesCustomTool is a Responses custom (freeform) tool sent upstream as
|
|
// a function whose only argument is the raw input string.
|
|
type responsesCustomTool struct {
|
|
name string
|
|
description string
|
|
}
|
|
|
|
// inputSchema is the parameters schema of the function that carries a custom
|
|
// tool: a single required string argument holding the raw input.
|
|
func (tool *responsesCustomTool) inputSchema() map[string]any {
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
convmeta.CustomToolInputArgument: map[string]any{
|
|
"type": "string",
|
|
"description": "Raw input for the tool.",
|
|
},
|
|
},
|
|
"required": []string{convmeta.CustomToolInputArgument},
|
|
"additionalProperties": false,
|
|
}
|
|
}
|
|
|
|
func (tool *responsesCustomTool) chatTool() dto.ToolCallRequest {
|
|
return dto.ToolCallRequest{
|
|
Type: "function",
|
|
Function: dto.FunctionRequest{
|
|
Name: tool.name,
|
|
Description: tool.description,
|
|
Parameters: tool.inputSchema(),
|
|
},
|
|
}
|
|
}
|
|
|
|
const responsesCustomToolType = "custom"
|
|
|
|
// ResponsesCustomToolNames returns the Responses custom tools that
|
|
// AttachRequest sends upstream as functions. The response side restores
|
|
// function calls with these names as custom_tool_call items.
|
|
func ResponsesCustomToolNames(set Set) map[string]struct{} {
|
|
customTools, _ := responsesCustomTools(set)
|
|
var names map[string]struct{}
|
|
for _, tool := range customTools {
|
|
if tool == nil {
|
|
continue
|
|
}
|
|
if names == nil {
|
|
names = make(map[string]struct{}, len(customTools))
|
|
}
|
|
names[tool.name] = struct{}{}
|
|
}
|
|
return names
|
|
}
|
|
|
|
// ResponsesToolNamespaces maps the upstream name of every tool flattened
|
|
// from a non-default Responses tool namespace to that namespace. The response
|
|
// side restores the namespace on calls to these tools.
|
|
func ResponsesToolNamespaces(set Set) map[string]string {
|
|
if set.Source != types.RelayFormatOpenAIResponses {
|
|
return nil
|
|
}
|
|
var namespaces map[string]string
|
|
for _, definition := range set.Definitions {
|
|
if definition.Namespace == "" || definition.Namespace == convmeta.DefaultResponsesToolNamespace || definition.Name == "" {
|
|
continue
|
|
}
|
|
if namespaces == nil {
|
|
namespaces = make(map[string]string)
|
|
}
|
|
namespaces[definition.Name] = definition.Namespace
|
|
}
|
|
return namespaces
|
|
}
|
|
|
|
// responsesCustomTools selects, by definition index, the Responses custom
|
|
// tools that the upstream receives as functions. A nil entry marks a custom
|
|
// tool that was dropped with a diagnostic; indexes without an entry fall back
|
|
// to the generic unsupported-tool loss. Request encoding and
|
|
// ResponsesCustomToolNames share this selection so the restored names always
|
|
// match the functions that were sent.
|
|
func responsesCustomTools(set Set) (map[int]*responsesCustomTool, []types.ConversionDiagnostic) {
|
|
if set.Source != types.RelayFormatOpenAIResponses {
|
|
return nil, nil
|
|
}
|
|
functionNames := make(map[string]struct{})
|
|
for _, definition := range set.Definitions {
|
|
if definition.Kind == KindFunction && definition.Function != nil {
|
|
functionNames[definition.Function.Name] = struct{}{}
|
|
}
|
|
}
|
|
var (
|
|
tools map[int]*responsesCustomTool
|
|
diagnostics []types.ConversionDiagnostic
|
|
)
|
|
for index, definition := range set.Definitions {
|
|
if definition.Kind != KindNative || definition.NativeType != responsesCustomToolType {
|
|
continue
|
|
}
|
|
tool := decodeResponsesCustomTool(definition.Raw)
|
|
if tool == nil {
|
|
continue
|
|
}
|
|
if tools == nil {
|
|
tools = make(map[int]*responsesCustomTool)
|
|
}
|
|
path := fmt.Sprintf("tools[%d]", index)
|
|
if _, exists := functionNames[tool.name]; exists {
|
|
tools[index] = nil
|
|
diagnostics = append(diagnostics, semanticLoss(path, "custom_tool_name_conflict",
|
|
fmt.Sprintf("custom tool %q shares its name with another tool, so the target protocol cannot tell their calls apart", tool.name)))
|
|
continue
|
|
}
|
|
functionNames[tool.name] = struct{}{}
|
|
tools[index] = tool
|
|
diagnostics = append(diagnostics, presentationLoss(path, "custom_tool_as_function",
|
|
fmt.Sprintf("the target protocol has no freeform tools; custom tool %q is sent as a function with one string argument and its input format becomes a description hint", tool.name)))
|
|
}
|
|
return tools, diagnostics
|
|
}
|
|
|
|
func decodeResponsesCustomTool(raw json.RawMessage) *responsesCustomTool {
|
|
var value map[string]any
|
|
if err := kitutil.Unmarshal(raw, &value); err != nil {
|
|
return nil
|
|
}
|
|
name := strings.TrimSpace(kitutil.Interface2String(value["name"]))
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
parts := make([]string, 0, 3)
|
|
if description := strings.TrimSpace(kitutil.Interface2String(value["description"])); description != "" {
|
|
parts = append(parts, description)
|
|
}
|
|
parts = append(parts, `This tool takes freeform text. Put the complete raw text in the "input" argument.`)
|
|
if format, ok := value["format"].(map[string]any); ok && strings.TrimSpace(kitutil.Interface2String(format["type"])) == "grammar" {
|
|
if definition := strings.TrimSpace(kitutil.Interface2String(format["definition"])); definition != "" {
|
|
switch syntax := strings.TrimSpace(kitutil.Interface2String(format["syntax"])); syntax {
|
|
case "lark":
|
|
parts = append(parts, "The input must match this Lark grammar:\n"+definition)
|
|
case "regex":
|
|
parts = append(parts, "The input must match this regular expression:\n"+definition)
|
|
default:
|
|
parts = append(parts, fmt.Sprintf("The input must match this %s grammar:\n%s", syntax, definition))
|
|
}
|
|
}
|
|
}
|
|
return &responsesCustomTool{name: name, description: strings.Join(parts, "\n\n")}
|
|
}
|
|
|
|
// responsesCustomToolChoice rewrites a Responses {"type":"custom"} tool choice
|
|
// into a named function choice when that custom tool was sent as a function.
|
|
// Other choices are returned unchanged.
|
|
func responsesCustomToolChoice(choice *Choice, customTools map[int]*responsesCustomTool) *Choice {
|
|
if choice == nil || choice.Mode != ChoiceOpaque || choice.NativeType != responsesCustomToolType {
|
|
return choice
|
|
}
|
|
var value map[string]any
|
|
if err := kitutil.Unmarshal(choice.Raw, &value); err != nil {
|
|
return choice
|
|
}
|
|
name := convmeta.NamespacedToolName(strings.TrimSpace(kitutil.Interface2String(value["namespace"])), strings.TrimSpace(kitutil.Interface2String(value["name"])))
|
|
for _, tool := range customTools {
|
|
if tool != nil && tool.name == name {
|
|
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}
|
|
}
|
|
}
|
|
return choice
|
|
}
|
|
|
|
func attachOpenAIResponsesRequest(request any, set Set, options *convmeta.Options) (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
|
|
}
|
|
parameters, schemaDiagnostics := jsonSchemaParameters(index, definition.Function)
|
|
diagnostics = append(diagnostics, schemaDiagnostics...)
|
|
tool := map[string]any{
|
|
"type": "function",
|
|
"name": definition.Function.Name,
|
|
"description": definition.Function.Description,
|
|
"parameters": 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)...)
|
|
}
|
|
call := newWebSearchCall(set, index, definition, types.RelayFormatOpenAIResponses)
|
|
call.Responses = target
|
|
handled, err := appendEncodedWebSearch(&tools, &diagnostics, call, options)
|
|
if err != nil {
|
|
return nil, diagnostics, err
|
|
}
|
|
if handled {
|
|
continue
|
|
}
|
|
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),
|
|
))
|
|
}
|
|
}
|
|
historyDiagnostics, err := appendHostedHistoryToOpenAIResponses(target, set)
|
|
if err != nil {
|
|
return nil, diagnostics, err
|
|
}
|
|
diagnostics = append(diagnostics, historyDiagnostics...)
|
|
if len(tools) == 0 {
|
|
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
|
}
|
|
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...)
|
|
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))
|
|
customTools, diagnostics := responsesCustomTools(set)
|
|
for index, definition := range set.Definitions {
|
|
switch definition.Kind {
|
|
case KindFunction:
|
|
if definition.Function == nil {
|
|
continue
|
|
}
|
|
parameters, schemaDiagnostics := jsonSchemaParameters(index, definition.Function)
|
|
diagnostics = append(diagnostics, schemaDiagnostics...)
|
|
inputSchema, err := functionParametersMap(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)...)
|
|
}
|
|
call := newWebSearchCall(set, index, definition, types.RelayFormatClaude)
|
|
call.Claude = target
|
|
handled, err := appendEncodedWebSearch(&tools, &diagnostics, call, options)
|
|
if err != nil {
|
|
return nil, diagnostics, err
|
|
}
|
|
if handled {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
if tool, handled := customTools[index]; handled {
|
|
if tool != nil {
|
|
tools = append(tools, &dto.Tool{
|
|
Name: tool.name,
|
|
Description: tool.description,
|
|
InputSchema: tool.inputSchema(),
|
|
})
|
|
}
|
|
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),
|
|
))
|
|
}
|
|
}
|
|
historyDiagnostics, err := appendHostedHistoryToClaude(target, set)
|
|
if err != nil {
|
|
return nil, diagnostics, err
|
|
}
|
|
diagnostics = append(diagnostics, historyDiagnostics...)
|
|
if len(tools) == 0 {
|
|
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
|
}
|
|
target.Tools = tools
|
|
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(responsesCustomToolChoice(set.Choice, customTools), types.RelayFormatClaude)
|
|
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
|
|
forced := normalizedChoice != nil && (normalizedChoice.Mode == ChoiceRequired || normalizedChoice.Mode == ChoiceNamed)
|
|
manualThinking := target.Thinking != nil && target.Thinking.Type == "enabled"
|
|
if forced && manualThinking && set.Source != types.RelayFormatClaude {
|
|
downgraded := *normalizedChoice
|
|
downgraded.Mode = ChoiceAuto
|
|
normalizedChoice = &downgraded
|
|
diagnostics = append(diagnostics, semanticLoss("tool_choice", "forced_tool_choice_downgraded",
|
|
"Claude rejects forced tool use while manual extended thinking is enabled; tool_choice was sent as auto, so the model may answer without calling a tool"))
|
|
}
|
|
choice, choiceDiagnostics := encodeClaudeChoice(normalizedChoice, set.ParallelAllowed, set.Source)
|
|
target.ToolChoice = choice
|
|
diagnostics = append(diagnostics, choiceDiagnostics...)
|
|
return target, diagnostics, nil
|
|
}
|
|
|
|
func attachGeminiRequest(request any, set Set, options *convmeta.Options) (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 []any
|
|
)
|
|
jsonSchema := options != nil && options.Gemini.SupportsJSONSchemaModel(set.Model)
|
|
customTools, diagnostics := responsesCustomTools(set)
|
|
for index, definition := range set.Definitions {
|
|
switch definition.Kind {
|
|
case KindFunction:
|
|
if definition.Function == nil {
|
|
continue
|
|
}
|
|
parametersField, parameters := "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)
|
|
}
|
|
var misfits []string
|
|
parametersField, parameters, misfits = sharedgemini.FunctionParameters(cloned, jsonSchema)
|
|
if len(misfits) > 0 {
|
|
diagnostics = append(diagnostics, presentationLoss(fmt.Sprintf("tools[%d].parameters", index), "gemini_schema_keyword_dropped",
|
|
fmt.Sprintf("Gemini function parameters cannot carry %s", strings.Join(misfits, ", "))))
|
|
}
|
|
}
|
|
function := map[string]any{
|
|
"name": definition.Function.Name,
|
|
"description": definition.Function.Description,
|
|
parametersField: 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:
|
|
call := newWebSearchCall(set, index, definition, types.RelayFormatGemini)
|
|
call.Gemini = target
|
|
handled, err := appendEncodedWebSearch(&tools, &diagnostics, call, options)
|
|
if err != nil {
|
|
return nil, diagnostics, err
|
|
}
|
|
if handled {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
if tool, handled := customTools[index]; handled {
|
|
if tool != nil {
|
|
functions = append(functions, map[string]any{
|
|
"name": tool.name,
|
|
"description": tool.description,
|
|
"parameters": sharedgemini.CleanFunctionParameters(tool.inputSchema()),
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
// Other opaque tools may be server-executed and remain a semantic
|
|
// loss under the default Safe policy.
|
|
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
|
|
}
|
|
if len(functions) == 0 {
|
|
diagnostics = append(diagnostics, unsupportedHostedHistoryDiagnostics(types.RelayFormatGemini, set.History)...)
|
|
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
|
}
|
|
config, choiceDiagnostics := encodeGeminiChoice(responsesCustomToolChoice(set.Choice, customTools))
|
|
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]any, error) {
|
|
if parameters == nil {
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{},
|
|
}, nil
|
|
}
|
|
converted, err := kitutil.Any2Type[map[string]any](parameters)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if converted["type"] == nil {
|
|
converted["type"] = "object"
|
|
}
|
|
if converted["properties"] == nil {
|
|
converted["properties"] = map[string]any{}
|
|
}
|
|
return converted, nil
|
|
}
|