feat(translator): support strict tool mode mapping for gemini and claude

- Map strict tools to `VALIDATED` function calling mode in Gemini translators when tool choice is auto or omitted
- Preserve tool `strict` setting when translating OpenAI requests to Claude

Closes: #5958
This commit is contained in:
Luis Pater
2026-09-19 16:18:14 +08:00
parent 49eec664f4
commit f247e2b017
6 changed files with 431 additions and 6 deletions

View File

@@ -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)
}

View File

@@ -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)
}
})
}

View File

@@ -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

View File

@@ -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)
}
})
}

View File

@@ -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:

View File

@@ -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)
}
})
}