diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index fa6c0451e..a528bbb80 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -368,6 +368,17 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream, if !gjson.GetBytes(anthropicTool, "cache_control").Exists() { anthropicTool = common.AttachCacheControl(anthropicTool, function) } + strict := function.Get("strict") + if !strict.Exists() { + strict = tool.Get("strict") + } + if strict.Exists() { + if strict.Type == gjson.True { + anthropicTool, _ = sjson.SetBytes(anthropicTool, "strict", true) + } else if strict.Type == gjson.False { + anthropicTool, _ = sjson.SetBytes(anthropicTool, "strict", false) + } + } anthropicTools = append(anthropicTools, anthropicTool) } diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go index 012cb2043..b211aed1d 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go @@ -1188,3 +1188,101 @@ func TestConvertOpenAIRequestToClaude_ToolChoice(t *testing.T) { } }) } + +func TestConvertOpenAIRequestToClaude_ToolStrict(t *testing.T) { + t.Run("preserves strict true on function tool", func(t *testing.T) { + inputJSON := `{ + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + { + "type": "function", + "function": { + "name": "tool_a", + "description": "Controlled tool.", + "strict": true, + "parameters": {"type": "object", "properties": {}} + } + } + ] + }` + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false) + toolStrict := gjson.GetBytes(result, "tools.0.strict") + if !toolStrict.Exists() { + t.Fatalf("expected tools.0.strict to exist in Claude output: %s", result) + } + if !toolStrict.Bool() { + t.Fatalf("expected tools.0.strict to be true, got %v", toolStrict.Value()) + } + }) + + t.Run("preserves strict true when on top level tool", func(t *testing.T) { + inputJSON := `{ + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + { + "type": "function", + "strict": true, + "function": { + "name": "tool_b", + "description": "Controlled tool.", + "parameters": {"type": "object", "properties": {}} + } + } + ] + }` + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false) + toolStrict := gjson.GetBytes(result, "tools.0.strict") + if !toolStrict.Exists() || !toolStrict.Bool() { + t.Fatalf("expected tools.0.strict to be true, got %s", result) + } + }) + + t.Run("preserves strict false on function tool", func(t *testing.T) { + inputJSON := `{ + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + { + "type": "function", + "function": { + "name": "tool_c", + "description": "Controlled tool.", + "strict": false, + "parameters": {"type": "object", "properties": {}} + } + } + ] + }` + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false) + toolStrict := gjson.GetBytes(result, "tools.0.strict") + if !toolStrict.Exists() { + t.Fatalf("expected tools.0.strict to exist in Claude output: %s", result) + } + if toolStrict.Bool() { + t.Fatalf("expected tools.0.strict to be false, got %v", toolStrict.Value()) + } + }) + + t.Run("omits strict when not provided", func(t *testing.T) { + inputJSON := `{ + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + { + "type": "function", + "function": { + "name": "tool_d", + "description": "Controlled tool.", + "parameters": {"type": "object", "properties": {}} + } + } + ] + }` + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false) + if gjson.GetBytes(result, "tools.0.strict").Exists() { + t.Fatalf("expected tools.0.strict to be omitted when not provided, got %s", result) + } + }) +} diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index b05058e1b..821938f0a 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -239,9 +239,13 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, } // tools + var toolItems [][]byte + hasStrictTool := false if toolsResult := gjson.GetBytes(rawJSON, "tools"); toolsResult.IsArray() { - var toolItems [][]byte toolsResult.ForEach(func(_, toolResult gjson.Result) bool { + if toolResult.Get("strict").Type == gjson.True { + hasStrictTool = true + } inputSchemaResult := toolResult.Get("input_schema") if inputSchemaResult.Exists() && inputSchemaResult.IsObject() { inputSchema := util.CleanJSONSchemaForGemini(inputSchemaResult.Raw) @@ -281,7 +285,7 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, // tool_choice toolChoiceResult := gjson.GetBytes(rawJSON, "tool_choice") - if toolChoiceResult.Exists() { + if toolChoiceResult.Exists() && toolChoiceResult.Type != gjson.Null { toolChoiceType := "" toolChoiceName := "" if toolChoiceResult.IsObject() { @@ -293,7 +297,11 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, switch toolChoiceType { case "auto": - out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "AUTO") + if hasStrictTool { + out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "VALIDATED") + } else { + out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "AUTO") + } case "none": out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "NONE") case "any": @@ -304,6 +312,8 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames", []string{util.SanitizeFunctionName(toolChoiceName)}) } } + } else if hasStrictTool && len(toolItems) > 0 { + out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "VALIDATED") } // Map Anthropic thinking -> Gemini thinking config when enabled diff --git a/internal/translator/gemini/claude/gemini_claude_request_test.go b/internal/translator/gemini/claude/gemini_claude_request_test.go index 54a052efd..4dea1e13f 100644 --- a/internal/translator/gemini/claude/gemini_claude_request_test.go +++ b/internal/translator/gemini/claude/gemini_claude_request_test.go @@ -1,6 +1,7 @@ package claude import ( + "fmt" "testing" internalsignature "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" @@ -492,3 +493,132 @@ func TestConvertClaudeRequestToGemini_ToolResultWithTrailingSystemReminderReorde t.Fatalf("unexpected functionResponse.id in parts[1]: %q", gotID) } } + +func TestConvertClaudeRequestToGemini_ToolStrictMapsToValidatedMode(t *testing.T) { + tests := []struct { + name string + toolChoice string + expectedMode string + allowedNames []string + }{ + { + name: "absent tool_choice maps to VALIDATED", + toolChoice: "", + expectedMode: "VALIDATED", + }, + { + name: "explicit auto tool_choice maps to VALIDATED", + toolChoice: `"tool_choice": {"type": "auto"},`, + expectedMode: "VALIDATED", + }, + { + name: "null tool_choice maps to VALIDATED", + toolChoice: `"tool_choice": null,`, + expectedMode: "VALIDATED", + }, + { + name: "none tool_choice maps to NONE", + toolChoice: `"tool_choice": {"type": "none"},`, + expectedMode: "NONE", + }, + { + name: "any tool_choice maps to ANY", + toolChoice: `"tool_choice": {"type": "any"},`, + expectedMode: "ANY", + }, + { + name: "specific tool maps to ANY with allowedFunctionNames", + toolChoice: `"tool_choice": {"type": "tool", "name": "tool_a"},`, + expectedMode: "ANY", + allowedNames: []string{"tool_a"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inputJSON := fmt.Sprintf(`{ + "model": "gemini-3.8-flash", + "messages": [{"role": "user", "content": "hi"}], + %s + "tools": [ + { + "name": "tool_a", + "description": "Controlled tool.", + "strict": true, + "input_schema": {"type": "object", "properties": {}} + } + ] + }`, tt.toolChoice) + + result := ConvertClaudeRequestToGemini("gemini-3.8-flash", []byte(inputJSON), false) + if gjson.GetBytes(result, "tools.0.functionDeclarations.0.strict").Exists() { + t.Fatalf("strict must be removed from functionDeclarations: %s", result) + } + mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String() + if mode != tt.expectedMode { + t.Fatalf("expected toolConfig.functionCallingConfig.mode = %q, got %q. Output: %s", tt.expectedMode, mode, result) + } + if len(tt.allowedNames) > 0 { + allowed := gjson.GetBytes(result, "toolConfig.functionCallingConfig.allowedFunctionNames").Array() + if len(allowed) != len(tt.allowedNames) { + t.Fatalf("expected %d allowedFunctionNames, got %d", len(tt.allowedNames), len(allowed)) + } + for i, name := range tt.allowedNames { + if allowed[i].String() != name { + t.Fatalf("allowedFunctionNames[%d] = %q, want %q", i, allowed[i].String(), name) + } + } + } + }) + } + + t.Run("mixed tools where one is strict maps to VALIDATED", func(t *testing.T) { + inputJSON := []byte(`{ + "model": "gemini-3.8-flash", + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + { + "name": "tool_a", + "description": "Loose tool.", + "strict": false, + "input_schema": {"type": "object", "properties": {}} + }, + { + "name": "tool_b", + "description": "Strict tool.", + "strict": true, + "input_schema": {"type": "object", "properties": {}} + } + ] + }`) + result := ConvertClaudeRequestToGemini("gemini-3.8-flash", inputJSON, false) + mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String() + if mode != "VALIDATED" { + t.Fatalf("expected mode = 'VALIDATED' for mixed tools, got %q", mode) + } + }) + + t.Run("non-strict tools omit toolConfig mode", func(t *testing.T) { + inputJSON := []byte(`{ + "model": "gemini-3.8-flash", + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + { + "name": "tool_a", + "description": "Loose tool.", + "strict": false, + "input_schema": {"type": "object", "properties": {}} + }, + { + "name": "tool_b", + "description": "Unspecified tool.", + "input_schema": {"type": "object", "properties": {}} + } + ] + }`) + result := ConvertClaudeRequestToGemini("gemini-3.8-flash", inputJSON, false) + if gjson.GetBytes(result, "toolConfig").Exists() { + t.Fatalf("expected toolConfig not to be set when no strict tools and no tool_choice, got: %s", result) + } + }) +} diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index f69b94ae3..b9ea2deac 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -327,6 +327,7 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) declaredOriginalToSanitized := make(map[string]string) sanitizedToOriginalCounts := make(map[string]int) var functionDeclarations [][]byte + hasStrictTool := false tools := gjson.GetBytes(rawJSON, "tools") toolResults := tools.Array() if tools.IsArray() && len(toolResults) > 0 { @@ -394,8 +395,20 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) fnRawBytes, _ = sjson.SetRawBytes(fnRawBytes, "parametersJsonSchema", []byte(cleanedParameters)) } } - if gjson.GetBytes(fnRawBytes, "strict").Exists() { - fnRawBytes, _ = sjson.DeleteBytes(fnRawBytes, "strict") + strictVal := gjson.GetBytes(fnRawBytes, "strict") + if !strictVal.Exists() { + strictVal = fn.Get("strict") + if !strictVal.Exists() { + strictVal = t.Get("strict") + } + } + if strictVal.Exists() { + if strictVal.Type == gjson.True { + hasStrictTool = true + } + if gjson.GetBytes(fnRawBytes, "strict").Exists() { + fnRawBytes, _ = sjson.DeleteBytes(fnRawBytes, "strict") + } } functionDeclarations = append(functionDeclarations, fnRawBytes) } @@ -468,6 +481,8 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) allowedList = append(allowedList, gjson.GetBytes(fnRaw, "name").String()) } out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames", allowedList) + } else if hasStrictTool { + out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "VALIDATED") } else { // Mode AUTO: functionDeclarations contains only allowed tools, mode is AUTO without allowedFunctionNames. out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "AUTO") @@ -482,7 +497,11 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) switch toolChoiceType { case "auto": - out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "AUTO") + if hasStrictTool { + out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "VALIDATED") + } else { + out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "AUTO") + } case "none": out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "NONE") case "required", "any": @@ -504,6 +523,8 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) // Unrecognized tool_choice type: fail-closed. out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "NONE") } + } else if hasStrictTool && len(functionDeclarations) > 0 { + out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "VALIDATED") } // parallel_tool_calls handling: diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go index e0a21c85c..c865a1c44 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go @@ -1,6 +1,7 @@ package chat_completions import ( + "fmt" "testing" "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" @@ -999,3 +1000,157 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) { } }) } + +func TestConvertOpenAIRequestToGemini_ToolStrictMapsToValidatedMode(t *testing.T) { + tests := []struct { + name string + toolChoice string // empty means absent + expectedMode string + allowedNames []string + }{ + { + name: "absent tool_choice maps to VALIDATED", + toolChoice: "", + expectedMode: "VALIDATED", + }, + { + name: "explicit auto tool_choice maps to VALIDATED", + toolChoice: `"tool_choice": "auto",`, + expectedMode: "VALIDATED", + }, + { + name: "null tool_choice maps to VALIDATED", + toolChoice: `"tool_choice": null,`, + expectedMode: "VALIDATED", + }, + { + name: "required tool_choice maps to ANY", + toolChoice: `"tool_choice": "required",`, + expectedMode: "ANY", + }, + { + name: "none tool_choice maps to NONE", + toolChoice: `"tool_choice": "none",`, + expectedMode: "NONE", + }, + { + name: "specific function tool_choice maps to ANY with allowedFunctionNames", + toolChoice: `"tool_choice": {"type": "function", "function": {"name": "tool_a"}},`, + expectedMode: "ANY", + allowedNames: []string{"tool_a"}, + }, + { + name: "allowed_tools with auto mode and strict tool maps to VALIDATED", + toolChoice: `"tool_choice": {"type": "allowed_tools", "allowed_tools": {"mode": "auto", "tools": [{"type": "function", "function": {"name": "tool_a"}}]}},`, + expectedMode: "VALIDATED", + }, + { + name: "allowed_tools with required mode and strict tool maps to ANY", + toolChoice: `"tool_choice": {"type": "allowed_tools", "allowed_tools": {"mode": "required", "tools": [{"type": "function", "function": {"name": "tool_a"}}]}},`, + expectedMode: "ANY", + allowedNames: []string{"tool_a"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inputJSON := fmt.Sprintf(`{ + "model": "gemini-3.1-pro-high", + "messages": [{"role": "user", "content": "hi"}], + %s + "tools": [ + { + "type": "function", + "function": { + "name": "tool_a", + "description": "Controlled tool.", + "strict": true, + "parameters": {"type": "object", "properties": {}} + } + } + ] + }`, tt.toolChoice) + result := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false) + if gjson.GetBytes(result, "tools.0.functionDeclarations.0.strict").Exists() { + t.Fatalf("tools.0.functionDeclarations.0.strict should be removed from function declaration: %s", result) + } + mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String() + if mode != tt.expectedMode { + t.Fatalf("expected toolConfig.functionCallingConfig.mode = %q, got %q. Output: %s", tt.expectedMode, mode, result) + } + if len(tt.allowedNames) > 0 { + allowed := gjson.GetBytes(result, "toolConfig.functionCallingConfig.allowedFunctionNames").Array() + if len(allowed) != len(tt.allowedNames) { + t.Fatalf("expected %d allowedFunctionNames, got %d", len(tt.allowedNames), len(allowed)) + } + for i, name := range tt.allowedNames { + if allowed[i].String() != name { + t.Fatalf("allowedFunctionNames[%d] = %q, want %q", i, allowed[i].String(), name) + } + } + } + }) + } + + t.Run("mixed tools where one is strict maps to VALIDATED", func(t *testing.T) { + inputJSON := `{ + "model": "gemini-3.1-pro-high", + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + { + "type": "function", + "function": { + "name": "tool_a", + "description": "Loose tool.", + "strict": false, + "parameters": {"type": "object", "properties": {}} + } + }, + { + "type": "function", + "function": { + "name": "tool_b", + "description": "Strict tool.", + "strict": true, + "parameters": {"type": "object", "properties": {}} + } + } + ] + }` + result := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false) + mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String() + if mode != "VALIDATED" { + t.Fatalf("expected mode = 'VALIDATED' for mixed tools, got %q", mode) + } + }) + + t.Run("non-strict tools omit toolConfig mode", func(t *testing.T) { + inputJSON := `{ + "model": "gemini-3.1-pro-high", + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + { + "type": "function", + "function": { + "name": "tool_a", + "description": "Loose tool.", + "strict": false, + "parameters": {"type": "object", "properties": {}} + } + }, + { + "type": "function", + "function": { + "name": "tool_b", + "description": "Unspecified tool.", + "parameters": {"type": "object", "properties": {}} + } + } + ] + }` + result := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false) + if gjson.GetBytes(result, "toolConfig").Exists() { + t.Fatalf("expected toolConfig not to be set when no strict tools and no tool_choice, got: %s", result) + } + }) +}