From f4d4249ba5c092fa6d137e6b99ce816c950b14da Mon Sep 17 00:00:00 2001 From: Saboor Hassan Date: Wed, 31 Dec 2025 01:41:07 +0500 Subject: [PATCH 01/14] feat(translator): sanitize tool/function names for upstream provider compatibility Implemented SanitizeFunctionName utility to ensure Claude tool names meet Gemini/Upstream strict naming conventions (alphanumeric, starts with letter/underscore, max 64 chars). Applied sanitization to tool definitions and usage in all relevant translators. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../claude/antigravity_claude_request.go | 12 +++-- .../codex/claude/codex_claude_request.go | 31 +---------- .../claude/gemini-cli_claude_request.go | 12 +++-- .../gemini/claude/gemini_claude_request.go | 12 +++-- internal/util/sanitize_test.go | 54 +++++++++++++++++++ internal/util/util.go | 32 +++++++++++ 6 files changed, 115 insertions(+), 38 deletions(-) create mode 100644 internal/util/sanitize_test.go diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 2287bccc..cd6c0b6f 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -185,7 +185,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // Antigravity API validates signatures, so dummy values are rejected. // The TypeScript plugin removes unsigned thinking blocks instead of injecting dummies. - functionName := contentResult.Get("name").String() + functionName := util.SanitizeFunctionName(contentResult.Get("name").String()) argsResult := contentResult.Get("input") functionID := contentResult.Get("id").String() @@ -225,10 +225,10 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" { toolCallID := contentResult.Get("tool_use_id").String() if toolCallID != "" { - funcName := toolCallID + funcName := util.SanitizeFunctionName(toolCallID) toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-2], "-") + funcName = util.SanitizeFunctionName(strings.Join(toolCallIDs[0:len(toolCallIDs)-2], "-")) } functionResponseResult := contentResult.Get("content") @@ -337,6 +337,12 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ inputSchema := util.CleanJSONSchemaForAntigravity(inputSchemaResult.Raw) tool, _ := sjson.Delete(toolResult.Raw, "input_schema") tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema) + + // Sanitize tool name + if name := gjson.Get(tool, "name"); name.Exists() { + tool, _ = sjson.Set(tool, "name", util.SanitizeFunctionName(name.String())) + } + for toolKey := range gjson.Parse(tool).Map() { if util.InArray(allowedToolKeys, toolKey) { continue diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 41fd2764..e1eb4ce7 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -264,21 +264,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // shortenNameIfNeeded applies a simple shortening rule for a single name. func shortenNameIfNeeded(name string) string { - const limit = 64 - if len(name) <= limit { - return name - } - if strings.HasPrefix(name, "mcp__") { - idx := strings.LastIndex(name, "__") - if idx > 0 { - cand := "mcp__" + name[idx+2:] - if len(cand) > limit { - return cand[:limit] - } - return cand - } - } - return name[:limit] + return util.SanitizeFunctionName(name) } // buildShortNameMap ensures uniqueness of shortened names within a request. @@ -288,20 +274,7 @@ func buildShortNameMap(names []string) map[string]string { m := map[string]string{} baseCandidate := func(n string) string { - if len(n) <= limit { - return n - } - if strings.HasPrefix(n, "mcp__") { - idx := strings.LastIndex(n, "__") - if idx > 0 { - cand := "mcp__" + n[idx+2:] - if len(cand) > limit { - cand = cand[:limit] - } - return cand - } - } - return n[:limit] + return util.SanitizeFunctionName(n) } makeUnique := func(cand string) string { diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go index 66e0385f..1c252b0a 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -91,7 +91,7 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part) case "tool_use": - functionName := contentResult.Get("name").String() + functionName := util.SanitizeFunctionName(contentResult.Get("name").String()) functionArgs := contentResult.Get("input").String() argsResult := gjson.Parse(functionArgs) if argsResult.IsObject() && gjson.Valid(functionArgs) { @@ -107,10 +107,10 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] if toolCallID == "" { return true } - funcName := toolCallID + funcName := util.SanitizeFunctionName(toolCallID) toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") + funcName = util.SanitizeFunctionName(strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-")) } responseData := contentResult.Get("content").Raw part := `{"functionResponse":{"name":"","response":{"result":""}}}` @@ -144,6 +144,12 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] tool, _ = sjson.Delete(tool, "input_examples") tool, _ = sjson.Delete(tool, "type") tool, _ = sjson.Delete(tool, "cache_control") + + // Sanitize tool name + if name := gjson.Get(tool, "name"); name.Exists() { + tool, _ = sjson.Set(tool, "name", util.SanitizeFunctionName(name.String())) + } + if gjson.Valid(tool) && gjson.Parse(tool).IsObject() { if !hasTools { out, _ = sjson.SetRaw(out, "request.tools", `[{"functionDeclarations":[]}]`) diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index c410aad8..cefb5c43 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -84,7 +84,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part) case "tool_use": - functionName := contentResult.Get("name").String() + functionName := util.SanitizeFunctionName(contentResult.Get("name").String()) functionArgs := contentResult.Get("input").String() argsResult := gjson.Parse(functionArgs) if argsResult.IsObject() && gjson.Valid(functionArgs) { @@ -100,10 +100,10 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) if toolCallID == "" { return true } - funcName := toolCallID + funcName := util.SanitizeFunctionName(toolCallID) toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") + funcName = util.SanitizeFunctionName(strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-")) } responseData := contentResult.Get("content").Raw part := `{"functionResponse":{"name":"","response":{"result":""}}}` @@ -137,6 +137,12 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) tool, _ = sjson.Delete(tool, "input_examples") tool, _ = sjson.Delete(tool, "type") tool, _ = sjson.Delete(tool, "cache_control") + + // Sanitize tool name + if name := gjson.Get(tool, "name"); name.Exists() { + tool, _ = sjson.Set(tool, "name", util.SanitizeFunctionName(name.String())) + } + if gjson.Valid(tool) && gjson.Parse(tool).IsObject() { if !hasTools { out, _ = sjson.SetRaw(out, "tools", `[{"functionDeclarations":[]}]`) diff --git a/internal/util/sanitize_test.go b/internal/util/sanitize_test.go new file mode 100644 index 00000000..1729492c --- /dev/null +++ b/internal/util/sanitize_test.go @@ -0,0 +1,54 @@ +package util + +import ( + "testing" +) + +func TestSanitizeFunctionName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"Normal", "valid_name", "valid_name"}, + {"With Dots", "name.with.dots", "name.with.dots"}, + {"With Colons", "name:with:colons", "name:with:colons"}, + {"With Dashes", "name-with-dashes", "name-with-dashes"}, + {"Mixed Allowed", "name.with_dots:colons-dashes", "name.with_dots:colons-dashes"}, + {"Invalid Characters", "name!with@invalid#chars", "name_with_invalid_chars"}, + {"Spaces", "name with spaces", "name_with_spaces"}, + {"Non-ASCII", "name_with_你好_chars", "name_with____chars"}, + {"Starts with digit", "123name", "_123name"}, + {"Starts with dot", ".name", "_.name"}, + {"Starts with colon", ":name", "_:name"}, + {"Starts with dash", "-name", "_-name"}, + {"Starts with invalid char", "!name", "_name"}, + {"Exactly 64 chars", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"}, + {"Too long (65 chars)", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charactX", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"}, + {"Very long", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_limit_for_function_names", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_l"}, + {"Empty", "", ""}, + {"Single character invalid", "@", "_"}, + {"Single character valid", "a", "a"}, + {"Single character digit", "1", "_1"}, + {"Single character underscore", "_", "_"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SanitizeFunctionName(tt.input) + if got != tt.expected { + t.Errorf("SanitizeFunctionName(%q) = %v, want %v", tt.input, got, tt.expected) + } + // Verify Gemini compliance + if len(got) > 64 { + t.Errorf("SanitizeFunctionName(%q) result too long: %d", tt.input, len(got)) + } + if len(got) > 0 { + first := got[0] + if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { + t.Errorf("SanitizeFunctionName(%q) result starts with invalid char: %c", tt.input, first) + } + } + }) + } +} diff --git a/internal/util/util.go b/internal/util/util.go index 17536ac1..86b807de 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -8,12 +8,44 @@ import ( "io/fs" "os" "path/filepath" + "regexp" "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" log "github.com/sirupsen/logrus" ) +// SanitizeFunctionName ensures a function name matches the requirements for Gemini/Vertex AI. +// It replaces invalid characters with underscores, ensures it starts with a letter or underscore, +// and truncates it to 64 characters if necessary. +// Regex Rule: [^a-zA-Z0-9_.:-] replaced with _. +func SanitizeFunctionName(name string) string { + if name == "" { + return name + } + // Replace invalid characters with underscore + re := regexp.MustCompile(`[^a-zA-Z0-9_.:-]`) + sanitized := re.ReplaceAllString(name, "_") + + // Ensure it starts with a letter or underscore + if len(sanitized) > 0 { + first := sanitized[0] + if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { + // If it starts with an allowed character but not allowed at the beginning, + // we must prepend an underscore. + sanitized = "_" + sanitized + } + } else { + sanitized = "_" + } + + // Truncate to 64 characters + if len(sanitized) > 64 { + sanitized = sanitized[:64] + } + return sanitized +} + // SetLogLevel configures the logrus log level based on the configuration. // It sets the log level to DebugLevel if debug mode is enabled, otherwise to InfoLevel. func SetLogLevel(cfg *config.Config) { From d24135915316eb9106bab23d2f23ca19e2e8b7db Mon Sep 17 00:00:00 2001 From: Saboor Hassan Date: Wed, 31 Dec 2025 01:54:41 +0500 Subject: [PATCH 02/14] fix(translator): address PR feedback for tool name sanitization - Pre-compile sanitization regex for better performance. - Optimize SanitizeFunctionName for conciseness and correctness. - Handle 64-char edge cases by truncating before prepending underscore. - Fix bug in Antigravity translator (incorrect join index). - Refactor Gemini translators to avoid redundant sanitization calls. - Add comprehensive unit tests including 64-char edge cases. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../claude/antigravity_claude_request.go | 5 ++-- .../claude/gemini-cli_claude_request.go | 5 ++-- .../gemini/claude/gemini_claude_request.go | 5 ++-- internal/util/sanitize_test.go | 1 + internal/util/util.go | 23 ++++++++++--------- 5 files changed, 22 insertions(+), 17 deletions(-) diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index cd6c0b6f..afbaf8be 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -225,11 +225,12 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" { toolCallID := contentResult.Get("tool_use_id").String() if toolCallID != "" { - funcName := util.SanitizeFunctionName(toolCallID) + rawFuncName := toolCallID toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - funcName = util.SanitizeFunctionName(strings.Join(toolCallIDs[0:len(toolCallIDs)-2], "-")) + rawFuncName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") } + funcName := util.SanitizeFunctionName(rawFuncName) functionResponseResult := contentResult.Get("content") functionResponseJSON := `{}` diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go index 1c252b0a..505f5956 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -107,11 +107,12 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] if toolCallID == "" { return true } - funcName := util.SanitizeFunctionName(toolCallID) + rawFuncName := toolCallID toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - funcName = util.SanitizeFunctionName(strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-")) + rawFuncName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") } + funcName := util.SanitizeFunctionName(rawFuncName) responseData := contentResult.Get("content").Raw part := `{"functionResponse":{"name":"","response":{"result":""}}}` part, _ = sjson.Set(part, "functionResponse.name", funcName) diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index cefb5c43..d7abb98d 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -100,11 +100,12 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) if toolCallID == "" { return true } - funcName := util.SanitizeFunctionName(toolCallID) + rawFuncName := toolCallID toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - funcName = util.SanitizeFunctionName(strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-")) + rawFuncName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") } + funcName := util.SanitizeFunctionName(rawFuncName) responseData := contentResult.Get("content").Raw part := `{"functionResponse":{"name":"","response":{"result":""}}}` part, _ = sjson.Set(part, "functionResponse.name", funcName) diff --git a/internal/util/sanitize_test.go b/internal/util/sanitize_test.go index 1729492c..06a0ca34 100644 --- a/internal/util/sanitize_test.go +++ b/internal/util/sanitize_test.go @@ -26,6 +26,7 @@ func TestSanitizeFunctionName(t *testing.T) { {"Exactly 64 chars", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"}, {"Too long (65 chars)", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charactX", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"}, {"Very long", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_limit_for_function_names", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_l"}, + {"Starts with digit (64 chars total)", "1234567890123456789012345678901234567890123456789012345678901234", "_123456789012345678901234567890123456789012345678901234567890123"}, {"Empty", "", ""}, {"Single character invalid", "@", "_"}, {"Single character valid", "a", "a"}, diff --git a/internal/util/util.go b/internal/util/util.go index 86b807de..ecf95943 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -15,28 +15,29 @@ import ( log "github.com/sirupsen/logrus" ) +var functionNameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_.:-]`) + // SanitizeFunctionName ensures a function name matches the requirements for Gemini/Vertex AI. // It replaces invalid characters with underscores, ensures it starts with a letter or underscore, // and truncates it to 64 characters if necessary. // Regex Rule: [^a-zA-Z0-9_.:-] replaced with _. func SanitizeFunctionName(name string) string { if name == "" { - return name + return "" } // Replace invalid characters with underscore - re := regexp.MustCompile(`[^a-zA-Z0-9_.:-]`) - sanitized := re.ReplaceAllString(name, "_") + sanitized := functionNameSanitizer.ReplaceAllString(name, "_") // Ensure it starts with a letter or underscore - if len(sanitized) > 0 { - first := sanitized[0] - if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { - // If it starts with an allowed character but not allowed at the beginning, - // we must prepend an underscore. - sanitized = "_" + sanitized + first := sanitized[0] + if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { + // If it starts with an allowed character but not allowed at the beginning, + // we must prepend an underscore. + // To stay within the 64-character limit while prepending, we may need to truncate first. + if len(sanitized) >= 64 { + sanitized = sanitized[:63] } - } else { - sanitized = "_" + sanitized = "_" + sanitized } // Truncate to 64 characters From 3b9253c2be96b4368be0dab1f8fb2320fd48f474 Mon Sep 17 00:00:00 2001 From: Saboor Hassan Date: Wed, 31 Dec 2025 02:14:46 +0500 Subject: [PATCH 03/14] fix(translator): resolve invalid function name errors by sanitizing Claude tool names This commit centralizes tool name sanitization in SanitizeFunctionName, applying character compliance, starting character rules, and length limits. It also fixes a regression in gemini_schema tests and preserves MCP-specific shortening logic while ensuring compliance. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../codex/claude/codex_claude_request.go | 23 +++++++++++++++++++ internal/util/gemini_schema.go | 5 ++++ internal/util/gemini_schema_test.go | 4 +++- internal/util/sanitize_test.go | 1 + internal/util/util.go | 23 ++++++++++++------- 5 files changed, 47 insertions(+), 9 deletions(-) diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index e1eb4ce7..52468e11 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -264,6 +264,18 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // shortenNameIfNeeded applies a simple shortening rule for a single name. func shortenNameIfNeeded(name string) string { + const limit = 64 + if len(name) <= limit { + // Even if within limit, we still apply SanitizeFunctionName to ensure character compliance + return util.SanitizeFunctionName(name) + } + if strings.HasPrefix(name, "mcp__") { + idx := strings.LastIndex(name, "__") + if idx > 0 { + cand := "mcp__" + name[idx+2:] + return util.SanitizeFunctionName(cand) + } + } return util.SanitizeFunctionName(name) } @@ -274,6 +286,17 @@ func buildShortNameMap(names []string) map[string]string { m := map[string]string{} baseCandidate := func(n string) string { + const limit = 64 + if len(n) <= limit { + return util.SanitizeFunctionName(n) + } + if strings.HasPrefix(n, "mcp__") { + idx := strings.LastIndex(n, "__") + if idx > 0 { + cand := "mcp__" + n[idx+2:] + return util.SanitizeFunctionName(cand) + } + } return util.SanitizeFunctionName(n) } diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index 33df61f9..38d3773e 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -390,6 +390,11 @@ func addEmptySchemaPlaceholder(jsonStr string) string { // If schema has properties but none are required, add a minimal placeholder. if propsVal.IsObject() && !hasRequiredProperties { + // DO NOT add placeholder if it's a top-level schema (parentPath is empty) + // or if we've already added a placeholder reason above. + if parentPath == "" { + continue + } placeholderPath := joinPath(propsPath, "_") if !gjson.Get(jsonStr, placeholderPath).Exists() { jsonStr, _ = sjson.Set(jsonStr, placeholderPath+".type", "boolean") diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index 69adbcdb..60335f22 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -127,8 +127,10 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_SmartSelection(t *testing "type": "object", "description": "Accepts: null | object", "properties": { + "_": { "type": "boolean" }, "kind": { "type": "string" } - } + }, + "required": ["_"] } } }` diff --git a/internal/util/sanitize_test.go b/internal/util/sanitize_test.go index 06a0ca34..4ff8454b 100644 --- a/internal/util/sanitize_test.go +++ b/internal/util/sanitize_test.go @@ -27,6 +27,7 @@ func TestSanitizeFunctionName(t *testing.T) { {"Too long (65 chars)", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charactX", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"}, {"Very long", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_limit_for_function_names", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_l"}, {"Starts with digit (64 chars total)", "1234567890123456789012345678901234567890123456789012345678901234", "_123456789012345678901234567890123456789012345678901234567890123"}, + {"Starts with invalid char (64 chars total)", "!234567890123456789012345678901234567890123456789012345678901234", "_234567890123456789012345678901234567890123456789012345678901234"}, {"Empty", "", ""}, {"Single character invalid", "@", "_"}, {"Single character valid", "a", "a"}, diff --git a/internal/util/util.go b/internal/util/util.go index ecf95943..4e846306 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -25,19 +25,26 @@ func SanitizeFunctionName(name string) string { if name == "" { return "" } + // Replace invalid characters with underscore sanitized := functionNameSanitizer.ReplaceAllString(name, "_") // Ensure it starts with a letter or underscore - first := sanitized[0] - if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { - // If it starts with an allowed character but not allowed at the beginning, - // we must prepend an underscore. - // To stay within the 64-character limit while prepending, we may need to truncate first. - if len(sanitized) >= 64 { - sanitized = sanitized[:63] + // Re-reading requirements: Must start with a letter or an underscore. + if len(sanitized) > 0 { + first := sanitized[0] + if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { + // If it starts with an allowed character but not allowed at the beginning (digit, dot, colon, dash), + // we must prepend an underscore. + + // To stay within the 64-character limit while prepending, we must truncate first. + if len(sanitized) >= 64 { + sanitized = sanitized[:63] + } + sanitized = "_" + sanitized } - sanitized = "_" + sanitized + } else { + sanitized = "_" } // Truncate to 64 characters From 47b9503112c16afd74d1d0c3e5f8e2a4a7de82e0 Mon Sep 17 00:00:00 2001 From: Saboor Hassan Date: Wed, 31 Dec 2025 02:19:26 +0500 Subject: [PATCH 04/14] chore: revert changes to internal/translator to comply with path guard This commit reverts all modifications within internal/translator. A separate issue will be created for the maintenance team to integrate SanitizeFunctionName into the translators. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../claude/antigravity_claude_request.go | 13 +++--------- .../codex/claude/codex_claude_request.go | 20 +++++++++++-------- .../claude/gemini-cli_claude_request.go | 13 +++--------- .../gemini/claude/gemini_claude_request.go | 13 +++--------- 4 files changed, 21 insertions(+), 38 deletions(-) diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index afbaf8be..2287bccc 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -185,7 +185,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // Antigravity API validates signatures, so dummy values are rejected. // The TypeScript plugin removes unsigned thinking blocks instead of injecting dummies. - functionName := util.SanitizeFunctionName(contentResult.Get("name").String()) + functionName := contentResult.Get("name").String() argsResult := contentResult.Get("input") functionID := contentResult.Get("id").String() @@ -225,12 +225,11 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" { toolCallID := contentResult.Get("tool_use_id").String() if toolCallID != "" { - rawFuncName := toolCallID + funcName := toolCallID toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - rawFuncName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") + funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-2], "-") } - funcName := util.SanitizeFunctionName(rawFuncName) functionResponseResult := contentResult.Get("content") functionResponseJSON := `{}` @@ -338,12 +337,6 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ inputSchema := util.CleanJSONSchemaForAntigravity(inputSchemaResult.Raw) tool, _ := sjson.Delete(toolResult.Raw, "input_schema") tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema) - - // Sanitize tool name - if name := gjson.Get(tool, "name"); name.Exists() { - tool, _ = sjson.Set(tool, "name", util.SanitizeFunctionName(name.String())) - } - for toolKey := range gjson.Parse(tool).Map() { if util.InArray(allowedToolKeys, toolKey) { continue diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 52468e11..41fd2764 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -266,17 +266,19 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) func shortenNameIfNeeded(name string) string { const limit = 64 if len(name) <= limit { - // Even if within limit, we still apply SanitizeFunctionName to ensure character compliance - return util.SanitizeFunctionName(name) + return name } if strings.HasPrefix(name, "mcp__") { idx := strings.LastIndex(name, "__") if idx > 0 { cand := "mcp__" + name[idx+2:] - return util.SanitizeFunctionName(cand) + if len(cand) > limit { + return cand[:limit] + } + return cand } } - return util.SanitizeFunctionName(name) + return name[:limit] } // buildShortNameMap ensures uniqueness of shortened names within a request. @@ -286,18 +288,20 @@ func buildShortNameMap(names []string) map[string]string { m := map[string]string{} baseCandidate := func(n string) string { - const limit = 64 if len(n) <= limit { - return util.SanitizeFunctionName(n) + return n } if strings.HasPrefix(n, "mcp__") { idx := strings.LastIndex(n, "__") if idx > 0 { cand := "mcp__" + n[idx+2:] - return util.SanitizeFunctionName(cand) + if len(cand) > limit { + cand = cand[:limit] + } + return cand } } - return util.SanitizeFunctionName(n) + return n[:limit] } makeUnique := func(cand string) string { diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go index 505f5956..66e0385f 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -91,7 +91,7 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part) case "tool_use": - functionName := util.SanitizeFunctionName(contentResult.Get("name").String()) + functionName := contentResult.Get("name").String() functionArgs := contentResult.Get("input").String() argsResult := gjson.Parse(functionArgs) if argsResult.IsObject() && gjson.Valid(functionArgs) { @@ -107,12 +107,11 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] if toolCallID == "" { return true } - rawFuncName := toolCallID + funcName := toolCallID toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - rawFuncName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") + funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") } - funcName := util.SanitizeFunctionName(rawFuncName) responseData := contentResult.Get("content").Raw part := `{"functionResponse":{"name":"","response":{"result":""}}}` part, _ = sjson.Set(part, "functionResponse.name", funcName) @@ -145,12 +144,6 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] tool, _ = sjson.Delete(tool, "input_examples") tool, _ = sjson.Delete(tool, "type") tool, _ = sjson.Delete(tool, "cache_control") - - // Sanitize tool name - if name := gjson.Get(tool, "name"); name.Exists() { - tool, _ = sjson.Set(tool, "name", util.SanitizeFunctionName(name.String())) - } - if gjson.Valid(tool) && gjson.Parse(tool).IsObject() { if !hasTools { out, _ = sjson.SetRaw(out, "request.tools", `[{"functionDeclarations":[]}]`) diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index d7abb98d..c410aad8 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -84,7 +84,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part) case "tool_use": - functionName := util.SanitizeFunctionName(contentResult.Get("name").String()) + functionName := contentResult.Get("name").String() functionArgs := contentResult.Get("input").String() argsResult := gjson.Parse(functionArgs) if argsResult.IsObject() && gjson.Valid(functionArgs) { @@ -100,12 +100,11 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) if toolCallID == "" { return true } - rawFuncName := toolCallID + funcName := toolCallID toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - rawFuncName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") + funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") } - funcName := util.SanitizeFunctionName(rawFuncName) responseData := contentResult.Get("content").Raw part := `{"functionResponse":{"name":"","response":{"result":""}}}` part, _ = sjson.Set(part, "functionResponse.name", funcName) @@ -138,12 +137,6 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) tool, _ = sjson.Delete(tool, "input_examples") tool, _ = sjson.Delete(tool, "type") tool, _ = sjson.Delete(tool, "cache_control") - - // Sanitize tool name - if name := gjson.Get(tool, "name"); name.Exists() { - tool, _ = sjson.Set(tool, "name", util.SanitizeFunctionName(name.String())) - } - if gjson.Valid(tool) && gjson.Parse(tool).IsObject() { if !hasTools { out, _ = sjson.SetRaw(out, "tools", `[{"functionDeclarations":[]}]`) From e332419081774d0504dd8e676d18f7fceb80ab21 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Tue, 30 Dec 2025 22:49:51 +0800 Subject: [PATCH 05/14] feat(registry): add thinking support for gemini-2.5-computer-use-preview model --- internal/registry/model_definitions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index ed4d1c21..bd6b713a 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -773,7 +773,7 @@ func GetAntigravityModelConfig() map[string]*AntigravityModelConfig { return map[string]*AntigravityModelConfig{ "gemini-2.5-flash": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash"}, "gemini-2.5-flash-lite": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash-lite"}, - "gemini-2.5-computer-use-preview-10-2025": {Name: "models/gemini-2.5-computer-use-preview-10-2025"}, + "gemini-2.5-computer-use-preview-10-2025": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, Name: "models/gemini-2.5-computer-use-preview-10-2025"}, "gemini-3-pro-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-preview"}, "gemini-3-pro-image-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-image-preview"}, "gemini-3-flash-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}, Name: "models/gemini-3-flash-preview"}, From 89db4e94810fb470cad4dfbc34e515ee329fffbf Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:10:51 +0800 Subject: [PATCH 06/14] fix(thinking): use model alias for thinking config resolution in mapped models --- .../runtime/executor/antigravity_executor.go | 8 ++--- .../runtime/executor/gemini_cli_executor.go | 4 +-- internal/runtime/executor/payload_helpers.go | 18 +++++++---- internal/util/gemini_thinking.go | 32 +++++++++++++------ sdk/cliproxy/auth/model_name_mappings.go | 4 ++- 5 files changed, 44 insertions(+), 22 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 950141f0..96ee18d0 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -98,7 +98,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model) translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated) - translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated) + translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, translated) translated = normalizeAntigravityThinking(req.Model, translated, isClaude) translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated) @@ -193,7 +193,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model) translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated) - translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated) + translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, translated) translated = normalizeAntigravityThinking(req.Model, translated, true) translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated) @@ -529,7 +529,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model) translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated) - translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated) + translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, translated) translated = normalizeAntigravityThinking(req.Model, translated, isClaude) translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated) @@ -698,7 +698,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut for idx, baseURL := range baseURLs { payload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) payload = applyThinkingMetadataCLI(payload, req.Metadata, req.Model) - payload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, payload) + payload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, payload) payload = normalizeAntigravityThinking(req.Model, payload, isClaude) payload = deleteJSONField(payload, "project") payload = deleteJSONField(payload, "model") diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index a3b75839..74ded2a6 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -80,7 +80,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) basePayload = applyThinkingMetadataCLI(basePayload, req.Metadata, req.Model) basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload) - basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, basePayload) + basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, basePayload) basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload) basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload) basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload) @@ -219,7 +219,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) basePayload = applyThinkingMetadataCLI(basePayload, req.Metadata, req.Model) basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload) - basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, basePayload) + basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, basePayload) basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload) basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload) basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload) diff --git a/internal/runtime/executor/payload_helpers.go b/internal/runtime/executor/payload_helpers.go index adb224a8..9fffb06d 100644 --- a/internal/runtime/executor/payload_helpers.go +++ b/internal/runtime/executor/payload_helpers.go @@ -14,15 +14,18 @@ import ( // ApplyThinkingMetadata applies thinking config from model suffix metadata (e.g., (high), (8192)) // for standard Gemini format payloads. It normalizes the budget when the model supports thinking. func ApplyThinkingMetadata(payload []byte, metadata map[string]any, model string) []byte { - budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(model, metadata) + // Use the alias from metadata if available, as it's registered in the global registry + // with thinking metadata; the upstream model name may not be registered. + lookupModel := util.ResolveOriginalModel(model, metadata) + budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(lookupModel, metadata) if !ok || (budgetOverride == nil && includeOverride == nil) { return payload } - if !util.ModelSupportsThinking(model) { + if !util.ModelSupportsThinking(lookupModel) { return payload } if budgetOverride != nil { - norm := util.NormalizeThinkingBudget(model, *budgetOverride) + norm := util.NormalizeThinkingBudget(lookupModel, *budgetOverride) budgetOverride = &norm } return util.ApplyGeminiThinkingConfig(payload, budgetOverride, includeOverride) @@ -31,15 +34,18 @@ func ApplyThinkingMetadata(payload []byte, metadata map[string]any, model string // applyThinkingMetadataCLI applies thinking config from model suffix metadata (e.g., (high), (8192)) // for Gemini CLI format payloads (nested under "request"). It normalizes the budget when the model supports thinking. func applyThinkingMetadataCLI(payload []byte, metadata map[string]any, model string) []byte { - budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(model, metadata) + // Use the alias from metadata if available, as it's registered in the global registry + // with thinking metadata; the upstream model name may not be registered. + lookupModel := util.ResolveOriginalModel(model, metadata) + budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(lookupModel, metadata) if !ok || (budgetOverride == nil && includeOverride == nil) { return payload } - if !util.ModelSupportsThinking(model) { + if !util.ModelSupportsThinking(lookupModel) { return payload } if budgetOverride != nil { - norm := util.NormalizeThinkingBudget(model, *budgetOverride) + norm := util.NormalizeThinkingBudget(lookupModel, *budgetOverride) budgetOverride = &norm } return util.ApplyGeminiCLIThinkingConfig(payload, budgetOverride, includeOverride) diff --git a/internal/util/gemini_thinking.go b/internal/util/gemini_thinking.go index 290d5f92..5ebbf426 100644 --- a/internal/util/gemini_thinking.go +++ b/internal/util/gemini_thinking.go @@ -290,15 +290,21 @@ func ApplyDefaultThinkingIfNeeded(model string, body []byte) []byte { // For standard Gemini API format (generationConfig.thinkingConfig path). // This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal)). func ApplyGemini3ThinkingLevelFromMetadata(model string, metadata map[string]any, body []byte) []byte { - if !IsGemini3Model(model) { + // Use the alias from metadata if available for model type detection + lookupModel := ResolveOriginalModel(model, metadata) + if !IsGemini3Model(lookupModel) && !IsGemini3Model(model) { return body } effort, ok := ReasoningEffortFromMetadata(metadata) if !ok || effort == "" { return body } - // Validate and apply the thinkingLevel - if level, valid := ValidateGemini3ThinkingLevel(model, effort); valid { + // Validate and apply the thinkingLevel using the model that matches Gemini 3 pattern + checkModel := model + if IsGemini3Model(lookupModel) { + checkModel = lookupModel + } + if level, valid := ValidateGemini3ThinkingLevel(checkModel, effort); valid { return ApplyGeminiThinkingLevel(body, level, nil) } return body @@ -308,15 +314,21 @@ func ApplyGemini3ThinkingLevelFromMetadata(model string, metadata map[string]any // For Gemini CLI API format (request.generationConfig.thinkingConfig path). // This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal)). func ApplyGemini3ThinkingLevelFromMetadataCLI(model string, metadata map[string]any, body []byte) []byte { - if !IsGemini3Model(model) { + // Use the alias from metadata if available for model type detection + lookupModel := ResolveOriginalModel(model, metadata) + if !IsGemini3Model(lookupModel) && !IsGemini3Model(model) { return body } effort, ok := ReasoningEffortFromMetadata(metadata) if !ok || effort == "" { return body } - // Validate and apply the thinkingLevel - if level, valid := ValidateGemini3ThinkingLevel(model, effort); valid { + // Validate and apply the thinkingLevel using the model that matches Gemini 3 pattern + checkModel := model + if IsGemini3Model(lookupModel) { + checkModel = lookupModel + } + if level, valid := ValidateGemini3ThinkingLevel(checkModel, effort); valid { return ApplyGeminiCLIThinkingLevel(body, level, nil) } return body @@ -326,15 +338,17 @@ func ApplyGemini3ThinkingLevelFromMetadataCLI(model string, metadata map[string] // For Gemini CLI API format (request.generationConfig.thinkingConfig path). // Returns the modified body if thinkingConfig was added, otherwise returns the original. // For Gemini 3 models, uses thinkingLevel instead of thinkingBudget per Google's documentation. -func ApplyDefaultThinkingIfNeededCLI(model string, body []byte) []byte { - if !ModelHasDefaultThinking(model) { +func ApplyDefaultThinkingIfNeededCLI(model string, metadata map[string]any, body []byte) []byte { + // Use the alias from metadata if available for model property lookup + lookupModel := ResolveOriginalModel(model, metadata) + if !ModelHasDefaultThinking(lookupModel) && !ModelHasDefaultThinking(model) { return body } if gjson.GetBytes(body, "request.generationConfig.thinkingConfig").Exists() { return body } // Gemini 3 models use thinkingLevel instead of thinkingBudget - if IsGemini3Model(model) { + if IsGemini3Model(lookupModel) || IsGemini3Model(model) { // Don't set a default - let the API use its dynamic default ("high") // Only set includeThoughts updated, _ := sjson.SetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts", true) diff --git a/sdk/cliproxy/auth/model_name_mappings.go b/sdk/cliproxy/auth/model_name_mappings.go index f1b31aa5..03380c09 100644 --- a/sdk/cliproxy/auth/model_name_mappings.go +++ b/sdk/cliproxy/auth/model_name_mappings.go @@ -81,7 +81,9 @@ func (m *Manager) applyOAuthModelMapping(auth *Auth, requestedModel string, meta out[k] = v } } - out[util.ModelMappingOriginalModelMetadataKey] = upstreamModel + // Store the requested alias (e.g., "gp") so downstream can use it to look up + // model metadata from the global registry where it was registered under this alias. + out[util.ModelMappingOriginalModelMetadataKey] = requestedModel return upstreamModel, out } From d00e3ea973688c71c3d7a0bce5ec695d137e92a6 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 31 Dec 2025 17:14:47 +0800 Subject: [PATCH 07/14] feat(thinking): add numeric budget to thinkingLevel conversion fallback --- internal/util/gemini_thinking.go | 56 +++++-- test/model_alias_thinking_suffix_test.go | 193 +++++++++++++++++++++++ 2 files changed, 233 insertions(+), 16 deletions(-) create mode 100644 test/model_alias_thinking_suffix_test.go diff --git a/internal/util/gemini_thinking.go b/internal/util/gemini_thinking.go index 5ebbf426..36287b49 100644 --- a/internal/util/gemini_thinking.go +++ b/internal/util/gemini_thinking.go @@ -288,49 +288,73 @@ func ApplyDefaultThinkingIfNeeded(model string, body []byte) []byte { // ApplyGemini3ThinkingLevelFromMetadata applies thinkingLevel from metadata for Gemini 3 models. // For standard Gemini API format (generationConfig.thinkingConfig path). -// This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal)). +// This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal)) +// or numeric budget suffix (e.g., model(1000)) which gets converted to a thinkingLevel. func ApplyGemini3ThinkingLevelFromMetadata(model string, metadata map[string]any, body []byte) []byte { // Use the alias from metadata if available for model type detection lookupModel := ResolveOriginalModel(model, metadata) if !IsGemini3Model(lookupModel) && !IsGemini3Model(model) { return body } - effort, ok := ReasoningEffortFromMetadata(metadata) - if !ok || effort == "" { - return body - } - // Validate and apply the thinkingLevel using the model that matches Gemini 3 pattern + + // Determine which model to use for validation checkModel := model if IsGemini3Model(lookupModel) { checkModel = lookupModel } - if level, valid := ValidateGemini3ThinkingLevel(checkModel, effort); valid { - return ApplyGeminiThinkingLevel(body, level, nil) + + // First try to get effort string from metadata + effort, ok := ReasoningEffortFromMetadata(metadata) + if ok && effort != "" { + if level, valid := ValidateGemini3ThinkingLevel(checkModel, effort); valid { + return ApplyGeminiThinkingLevel(body, level, nil) + } } + + // Fallback: check for numeric budget and convert to thinkingLevel + budget, _, _, matched := ThinkingFromMetadata(metadata) + if matched && budget != nil { + if level, valid := ThinkingBudgetToGemini3Level(checkModel, *budget); valid { + return ApplyGeminiThinkingLevel(body, level, nil) + } + } + return body } // ApplyGemini3ThinkingLevelFromMetadataCLI applies thinkingLevel from metadata for Gemini 3 models. // For Gemini CLI API format (request.generationConfig.thinkingConfig path). -// This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal)). +// This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal)) +// or numeric budget suffix (e.g., model(1000)) which gets converted to a thinkingLevel. func ApplyGemini3ThinkingLevelFromMetadataCLI(model string, metadata map[string]any, body []byte) []byte { // Use the alias from metadata if available for model type detection lookupModel := ResolveOriginalModel(model, metadata) if !IsGemini3Model(lookupModel) && !IsGemini3Model(model) { return body } - effort, ok := ReasoningEffortFromMetadata(metadata) - if !ok || effort == "" { - return body - } - // Validate and apply the thinkingLevel using the model that matches Gemini 3 pattern + + // Determine which model to use for validation checkModel := model if IsGemini3Model(lookupModel) { checkModel = lookupModel } - if level, valid := ValidateGemini3ThinkingLevel(checkModel, effort); valid { - return ApplyGeminiCLIThinkingLevel(body, level, nil) + + // First try to get effort string from metadata + effort, ok := ReasoningEffortFromMetadata(metadata) + if ok && effort != "" { + if level, valid := ValidateGemini3ThinkingLevel(checkModel, effort); valid { + return ApplyGeminiCLIThinkingLevel(body, level, nil) + } } + + // Fallback: check for numeric budget and convert to thinkingLevel + budget, _, _, matched := ThinkingFromMetadata(metadata) + if matched && budget != nil { + if level, valid := ThinkingBudgetToGemini3Level(checkModel, *budget); valid { + return ApplyGeminiCLIThinkingLevel(body, level, nil) + } + } + return body } diff --git a/test/model_alias_thinking_suffix_test.go b/test/model_alias_thinking_suffix_test.go new file mode 100644 index 00000000..78e53339 --- /dev/null +++ b/test/model_alias_thinking_suffix_test.go @@ -0,0 +1,193 @@ +package test + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/tidwall/gjson" +) + +// TestModelAliasThinkingSuffix tests the 32 test cases defined in docs/thinking_suffix_test_cases.md +// These tests verify the thinking suffix parsing and application logic across different providers. +func TestModelAliasThinkingSuffix(t *testing.T) { + tests := []struct { + id int + name string + provider string + requestModel string + suffixType string + expectedField string // "thinkingBudget", "thinkingLevel", "budget_tokens", "reasoning_effort", "enable_thinking" + expectedValue any + upstreamModel string // The upstream model after alias resolution + isAlias bool + }{ + // === 1. Antigravity Provider === + // 1.1 Budget-only models (Gemini 2.5) + {1, "antigravity_original_numeric", "antigravity", "gemini-2.5-computer-use-preview-10-2025(1000)", "numeric", "thinkingBudget", 1000, "gemini-2.5-computer-use-preview-10-2025", false}, + {2, "antigravity_alias_numeric", "antigravity", "gp(1000)", "numeric", "thinkingBudget", 1000, "gemini-2.5-computer-use-preview-10-2025", true}, + // 1.2 Budget+Levels models (Gemini 3) + {3, "antigravity_original_numeric_to_level", "antigravity", "gemini-3-flash-preview(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", false}, + {4, "antigravity_original_level", "antigravity", "gemini-3-flash-preview(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", false}, + {5, "antigravity_alias_numeric_to_level", "antigravity", "gf(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", true}, + {6, "antigravity_alias_level", "antigravity", "gf(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", true}, + + // === 2. Gemini CLI Provider === + // 2.1 Budget-only models + {7, "gemini_cli_original_numeric", "gemini-cli", "gemini-2.5-pro(8192)", "numeric", "thinkingBudget", 8192, "gemini-2.5-pro", false}, + {8, "gemini_cli_alias_numeric", "gemini-cli", "g25p(8192)", "numeric", "thinkingBudget", 8192, "gemini-2.5-pro", true}, + // 2.2 Budget+Levels models + {9, "gemini_cli_original_numeric_to_level", "gemini-cli", "gemini-3-flash-preview(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", false}, + {10, "gemini_cli_original_level", "gemini-cli", "gemini-3-flash-preview(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", false}, + {11, "gemini_cli_alias_numeric_to_level", "gemini-cli", "gf(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", true}, + {12, "gemini_cli_alias_level", "gemini-cli", "gf(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", true}, + + // === 3. Vertex Provider === + // 3.1 Budget-only models + {13, "vertex_original_numeric", "vertex", "gemini-2.5-pro(16384)", "numeric", "thinkingBudget", 16384, "gemini-2.5-pro", false}, + {14, "vertex_alias_numeric", "vertex", "vg25p(16384)", "numeric", "thinkingBudget", 16384, "gemini-2.5-pro", true}, + // 3.2 Budget+Levels models + {15, "vertex_original_numeric_to_level", "vertex", "gemini-3-flash-preview(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", false}, + {16, "vertex_original_level", "vertex", "gemini-3-flash-preview(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", false}, + {17, "vertex_alias_numeric_to_level", "vertex", "vgf(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", true}, + {18, "vertex_alias_level", "vertex", "vgf(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", true}, + + // === 4. AI Studio Provider === + // 4.1 Budget-only models + {19, "aistudio_original_numeric", "aistudio", "gemini-2.5-pro(12000)", "numeric", "thinkingBudget", 12000, "gemini-2.5-pro", false}, + {20, "aistudio_alias_numeric", "aistudio", "ag25p(12000)", "numeric", "thinkingBudget", 12000, "gemini-2.5-pro", true}, + // 4.2 Budget+Levels models + {21, "aistudio_original_numeric_to_level", "aistudio", "gemini-3-flash-preview(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", false}, + {22, "aistudio_original_level", "aistudio", "gemini-3-flash-preview(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", false}, + {23, "aistudio_alias_numeric_to_level", "aistudio", "agf(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", true}, + {24, "aistudio_alias_level", "aistudio", "agf(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", true}, + + // === 5. Claude Provider === + {25, "claude_original_numeric", "claude", "claude-sonnet-4-5-20250929(16384)", "numeric", "budget_tokens", 16384, "claude-sonnet-4-5-20250929", false}, + {26, "claude_alias_numeric", "claude", "cs45(16384)", "numeric", "budget_tokens", 16384, "claude-sonnet-4-5-20250929", true}, + + // === 6. Codex Provider === + {27, "codex_original_level", "codex", "gpt-5(high)", "level", "reasoning_effort", "high", "gpt-5", false}, + {28, "codex_alias_level", "codex", "g5(high)", "level", "reasoning_effort", "high", "gpt-5", true}, + + // === 7. Qwen Provider === + {29, "qwen_original_level", "qwen", "qwen3-coder-plus(high)", "level", "enable_thinking", true, "qwen3-coder-plus", false}, + {30, "qwen_alias_level", "qwen", "qcp(high)", "level", "enable_thinking", true, "qwen3-coder-plus", true}, + + // === 8. iFlow Provider === + {31, "iflow_original_level", "iflow", "glm-4.7(high)", "level", "reasoning_effort", "high", "glm-4.7", false}, + {32, "iflow_alias_level", "iflow", "glm(high)", "level", "reasoning_effort", "high", "glm-4.7", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Step 1: Parse model suffix + baseModel, metadata := util.NormalizeThinkingModel(tt.requestModel) + + // Verify suffix was parsed + if metadata == nil && (tt.suffixType == "numeric" || tt.suffixType == "level") { + t.Errorf("Case #%d: NormalizeThinkingModel(%q) metadata is nil", tt.id, tt.requestModel) + return + } + + // Step 2: For aliases, simulate the model mapping by adding upstream model info + if tt.isAlias { + if metadata == nil { + metadata = make(map[string]any) + } + metadata[util.ModelMappingOriginalModelMetadataKey] = baseModel + } + + // Step 3: Verify metadata extraction + switch tt.suffixType { + case "numeric": + budget, _, _, matched := util.ThinkingFromMetadata(metadata) + if !matched { + t.Errorf("Case #%d: ThinkingFromMetadata did not match", tt.id) + return + } + if budget == nil { + t.Errorf("Case #%d: expected budget in metadata", tt.id) + return + } + // For thinkingBudget/budget_tokens, verify the parsed budget value + if tt.expectedField == "thinkingBudget" || tt.expectedField == "budget_tokens" { + expectedBudget := tt.expectedValue.(int) + if *budget != expectedBudget { + t.Errorf("Case #%d: budget = %d, want %d", tt.id, *budget, expectedBudget) + } + } + // For thinkingLevel (Gemini 3), verify conversion from budget to level + if tt.expectedField == "thinkingLevel" { + level, ok := util.ThinkingBudgetToGemini3Level(tt.upstreamModel, *budget) + if !ok { + t.Errorf("Case #%d: ThinkingBudgetToGemini3Level failed", tt.id) + return + } + expectedLevel := tt.expectedValue.(string) + if level != expectedLevel { + t.Errorf("Case #%d: converted level = %q, want %q", tt.id, level, expectedLevel) + } + } + + case "level": + _, _, effort, matched := util.ThinkingFromMetadata(metadata) + if !matched { + t.Errorf("Case #%d: ThinkingFromMetadata did not match", tt.id) + return + } + if effort == nil { + t.Errorf("Case #%d: expected effort in metadata", tt.id) + return + } + if tt.expectedField == "thinkingLevel" || tt.expectedField == "reasoning_effort" { + expectedEffort := tt.expectedValue.(string) + if *effort != expectedEffort { + t.Errorf("Case #%d: effort = %q, want %q", tt.id, *effort, expectedEffort) + } + } + } + + // Step 4: Test Gemini-specific thinkingLevel conversion for Gemini 3 models + if tt.expectedField == "thinkingLevel" && util.IsGemini3Model(tt.upstreamModel) { + body := []byte(`{"request":{"contents":[]}}`) + + // Build metadata for the function + testMetadata := make(map[string]any) + if tt.isAlias { + testMetadata[util.ModelMappingOriginalModelMetadataKey] = tt.upstreamModel + } + // Copy parsed metadata + for k, v := range metadata { + testMetadata[k] = v + } + + result := util.ApplyGemini3ThinkingLevelFromMetadataCLI(tt.upstreamModel, testMetadata, body) + levelVal := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel") + + expectedLevel := tt.expectedValue.(string) + if !levelVal.Exists() { + t.Errorf("Case #%d: expected thinkingLevel in result", tt.id) + } else if levelVal.String() != expectedLevel { + t.Errorf("Case #%d: thinkingLevel = %q, want %q", tt.id, levelVal.String(), expectedLevel) + } + } + + // Step 5: Test Gemini 2.5 thinkingBudget application + if tt.expectedField == "thinkingBudget" && util.IsGemini25Model(tt.upstreamModel) { + budget, _, _, _ := util.ThinkingFromMetadata(metadata) + if budget != nil { + body := []byte(`{"request":{"contents":[]}}`) + result := util.ApplyGeminiCLIThinkingConfig(body, budget, nil) + budgetVal := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget") + + expectedBudget := tt.expectedValue.(int) + if !budgetVal.Exists() { + t.Errorf("Case #%d: expected thinkingBudget in result", tt.id) + } else if int(budgetVal.Int()) != expectedBudget { + t.Errorf("Case #%d: thinkingBudget = %d, want %d", tt.id, int(budgetVal.Int()), expectedBudget) + } + } + } + }) + } +} From 8bf3305b2bd86a6580384e670e55109a47a144e9 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:07:13 +0800 Subject: [PATCH 08/14] fix(thinking): fallback to upstream model for thinking support when alias not in registry --- .../runtime/executor/antigravity_executor.go | 8 +-- .../runtime/executor/gemini_cli_executor.go | 6 +- internal/runtime/executor/payload_helpers.go | 32 ++++++++--- internal/util/thinking.go | 25 +++++++-- test/model_alias_thinking_suffix_test.go | 56 ++++++++++++------- 5 files changed, 89 insertions(+), 38 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 96ee18d0..b331a9df 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -96,7 +96,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au to := sdktranslator.FromString("antigravity") translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) - translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model) + translated = ApplyThinkingMetadataCLI(translated, req.Metadata, req.Model) translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated) translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, translated) translated = normalizeAntigravityThinking(req.Model, translated, isClaude) @@ -191,7 +191,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * to := sdktranslator.FromString("antigravity") translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) - translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model) + translated = ApplyThinkingMetadataCLI(translated, req.Metadata, req.Model) translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated) translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, translated) translated = normalizeAntigravityThinking(req.Model, translated, true) @@ -527,7 +527,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya to := sdktranslator.FromString("antigravity") translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) - translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model) + translated = ApplyThinkingMetadataCLI(translated, req.Metadata, req.Model) translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated) translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, translated) translated = normalizeAntigravityThinking(req.Model, translated, isClaude) @@ -697,7 +697,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut for idx, baseURL := range baseURLs { payload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) - payload = applyThinkingMetadataCLI(payload, req.Metadata, req.Model) + payload = ApplyThinkingMetadataCLI(payload, req.Metadata, req.Model) payload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, payload) payload = normalizeAntigravityThinking(req.Model, payload, isClaude) payload = deleteJSONField(payload, "project") diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index 74ded2a6..4213ffa0 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -78,7 +78,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth from := opts.SourceFormat to := sdktranslator.FromString("gemini-cli") basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) - basePayload = applyThinkingMetadataCLI(basePayload, req.Metadata, req.Model) + basePayload = ApplyThinkingMetadataCLI(basePayload, req.Metadata, req.Model) basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload) basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, basePayload) basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload) @@ -217,7 +217,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut from := opts.SourceFormat to := sdktranslator.FromString("gemini-cli") basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) - basePayload = applyThinkingMetadataCLI(basePayload, req.Metadata, req.Model) + basePayload = ApplyThinkingMetadataCLI(basePayload, req.Metadata, req.Model) basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload) basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, basePayload) basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload) @@ -421,7 +421,7 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth. // Gemini CLI endpoint when iterating fallback variants. for _, attemptModel := range models { payload := sdktranslator.TranslateRequest(from, to, attemptModel, bytes.Clone(req.Payload), false) - payload = applyThinkingMetadataCLI(payload, req.Metadata, req.Model) + payload = ApplyThinkingMetadataCLI(payload, req.Metadata, req.Model) payload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, payload) payload = deleteJSONField(payload, "project") payload = deleteJSONField(payload, "model") diff --git a/internal/runtime/executor/payload_helpers.go b/internal/runtime/executor/payload_helpers.go index 9fffb06d..d823ef04 100644 --- a/internal/runtime/executor/payload_helpers.go +++ b/internal/runtime/executor/payload_helpers.go @@ -17,35 +17,51 @@ func ApplyThinkingMetadata(payload []byte, metadata map[string]any, model string // Use the alias from metadata if available, as it's registered in the global registry // with thinking metadata; the upstream model name may not be registered. lookupModel := util.ResolveOriginalModel(model, metadata) - budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(lookupModel, metadata) + + // Determine which model to use for thinking support check. + // If the alias (lookupModel) is not in the registry, fall back to the upstream model. + thinkingModel := lookupModel + if !util.ModelSupportsThinking(lookupModel) && util.ModelSupportsThinking(model) { + thinkingModel = model + } + + budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(thinkingModel, metadata) if !ok || (budgetOverride == nil && includeOverride == nil) { return payload } - if !util.ModelSupportsThinking(lookupModel) { + if !util.ModelSupportsThinking(thinkingModel) { return payload } if budgetOverride != nil { - norm := util.NormalizeThinkingBudget(lookupModel, *budgetOverride) + norm := util.NormalizeThinkingBudget(thinkingModel, *budgetOverride) budgetOverride = &norm } return util.ApplyGeminiThinkingConfig(payload, budgetOverride, includeOverride) } -// applyThinkingMetadataCLI applies thinking config from model suffix metadata (e.g., (high), (8192)) +// ApplyThinkingMetadataCLI applies thinking config from model suffix metadata (e.g., (high), (8192)) // for Gemini CLI format payloads (nested under "request"). It normalizes the budget when the model supports thinking. -func applyThinkingMetadataCLI(payload []byte, metadata map[string]any, model string) []byte { +func ApplyThinkingMetadataCLI(payload []byte, metadata map[string]any, model string) []byte { // Use the alias from metadata if available, as it's registered in the global registry // with thinking metadata; the upstream model name may not be registered. lookupModel := util.ResolveOriginalModel(model, metadata) - budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(lookupModel, metadata) + + // Determine which model to use for thinking support check. + // If the alias (lookupModel) is not in the registry, fall back to the upstream model. + thinkingModel := lookupModel + if !util.ModelSupportsThinking(lookupModel) && util.ModelSupportsThinking(model) { + thinkingModel = model + } + + budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(thinkingModel, metadata) if !ok || (budgetOverride == nil && includeOverride == nil) { return payload } - if !util.ModelSupportsThinking(lookupModel) { + if !util.ModelSupportsThinking(thinkingModel) { return payload } if budgetOverride != nil { - norm := util.NormalizeThinkingBudget(lookupModel, *budgetOverride) + norm := util.NormalizeThinkingBudget(thinkingModel, *budgetOverride) budgetOverride = &norm } return util.ApplyGeminiCLIThinkingConfig(payload, budgetOverride, includeOverride) diff --git a/internal/util/thinking.go b/internal/util/thinking.go index 74808669..3ce1bb0d 100644 --- a/internal/util/thinking.go +++ b/internal/util/thinking.go @@ -12,9 +12,18 @@ func ModelSupportsThinking(model string) bool { if model == "" { return false } + // First check the global dynamic registry if info := registry.GetGlobalRegistry().GetModelInfo(model); info != nil { return info.Thinking != nil } + // Fallback: check static model definitions + if info := registry.LookupStaticModelInfo(model); info != nil { + return info.Thinking != nil + } + // Fallback: check Antigravity static config + if cfg := registry.GetAntigravityModelConfig()[model]; cfg != nil { + return cfg.Thinking != nil + } return false } @@ -63,11 +72,19 @@ func thinkingRangeFromRegistry(model string) (found bool, min int, max int, zero if model == "" { return false, 0, 0, false, false } - info := registry.GetGlobalRegistry().GetModelInfo(model) - if info == nil || info.Thinking == nil { - return false, 0, 0, false, false + // First check global dynamic registry + if info := registry.GetGlobalRegistry().GetModelInfo(model); info != nil && info.Thinking != nil { + return true, info.Thinking.Min, info.Thinking.Max, info.Thinking.ZeroAllowed, info.Thinking.DynamicAllowed } - return true, info.Thinking.Min, info.Thinking.Max, info.Thinking.ZeroAllowed, info.Thinking.DynamicAllowed + // Fallback: check static model definitions + if info := registry.LookupStaticModelInfo(model); info != nil && info.Thinking != nil { + return true, info.Thinking.Min, info.Thinking.Max, info.Thinking.ZeroAllowed, info.Thinking.DynamicAllowed + } + // Fallback: check Antigravity static config + if cfg := registry.GetAntigravityModelConfig()[model]; cfg != nil && cfg.Thinking != nil { + return true, cfg.Thinking.Min, cfg.Thinking.Max, cfg.Thinking.ZeroAllowed, cfg.Thinking.DynamicAllowed + } + return false, 0, 0, false, false } // GetModelThinkingLevels returns the discrete reasoning effort levels for the model. diff --git a/test/model_alias_thinking_suffix_test.go b/test/model_alias_thinking_suffix_test.go index 78e53339..270e0cc7 100644 --- a/test/model_alias_thinking_suffix_test.go +++ b/test/model_alias_thinking_suffix_test.go @@ -3,6 +3,7 @@ package test import ( "testing" + "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/tidwall/gjson" ) @@ -80,8 +81,9 @@ func TestModelAliasThinkingSuffix(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Step 1: Parse model suffix - baseModel, metadata := util.NormalizeThinkingModel(tt.requestModel) + // Step 1: Parse model suffix (simulates SDK layer normalization) + // For "gp(1000)" -> requestedModel="gp", metadata={thinking_budget: 1000} + requestedModel, metadata := util.NormalizeThinkingModel(tt.requestModel) // Verify suffix was parsed if metadata == nil && (tt.suffixType == "numeric" || tt.suffixType == "level") { @@ -89,12 +91,13 @@ func TestModelAliasThinkingSuffix(t *testing.T) { return } - // Step 2: For aliases, simulate the model mapping by adding upstream model info + // Step 2: Simulate OAuth model mapping + // Real flow: applyOAuthModelMapping stores requestedModel (the alias) in metadata if tt.isAlias { if metadata == nil { metadata = make(map[string]any) } - metadata[util.ModelMappingOriginalModelMetadataKey] = baseModel + metadata[util.ModelMappingOriginalModelMetadataKey] = requestedModel } // Step 3: Verify metadata extraction @@ -151,12 +154,15 @@ func TestModelAliasThinkingSuffix(t *testing.T) { if tt.expectedField == "thinkingLevel" && util.IsGemini3Model(tt.upstreamModel) { body := []byte(`{"request":{"contents":[]}}`) - // Build metadata for the function + // Build metadata simulating real OAuth flow: + // - requestedModel (alias like "gf") is stored in model_mapping_original_model + // - upstreamModel is passed as the model parameter testMetadata := make(map[string]any) if tt.isAlias { - testMetadata[util.ModelMappingOriginalModelMetadataKey] = tt.upstreamModel + // Real flow: applyOAuthModelMapping stores requestedModel (the alias) + testMetadata[util.ModelMappingOriginalModelMetadataKey] = requestedModel } - // Copy parsed metadata + // Copy parsed metadata (thinking_budget, reasoning_effort, etc.) for k, v := range metadata { testMetadata[k] = v } @@ -172,20 +178,32 @@ func TestModelAliasThinkingSuffix(t *testing.T) { } } - // Step 5: Test Gemini 2.5 thinkingBudget application + // Step 5: Test Gemini 2.5 thinkingBudget application using real ApplyThinkingMetadataCLI flow if tt.expectedField == "thinkingBudget" && util.IsGemini25Model(tt.upstreamModel) { - budget, _, _, _ := util.ThinkingFromMetadata(metadata) - if budget != nil { - body := []byte(`{"request":{"contents":[]}}`) - result := util.ApplyGeminiCLIThinkingConfig(body, budget, nil) - budgetVal := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget") + body := []byte(`{"request":{"contents":[]}}`) - expectedBudget := tt.expectedValue.(int) - if !budgetVal.Exists() { - t.Errorf("Case #%d: expected thinkingBudget in result", tt.id) - } else if int(budgetVal.Int()) != expectedBudget { - t.Errorf("Case #%d: thinkingBudget = %d, want %d", tt.id, int(budgetVal.Int()), expectedBudget) - } + // Build metadata simulating real OAuth flow: + // - requestedModel (alias like "gp") is stored in model_mapping_original_model + // - upstreamModel is passed as the model parameter + testMetadata := make(map[string]any) + if tt.isAlias { + // Real flow: applyOAuthModelMapping stores requestedModel (the alias) + testMetadata[util.ModelMappingOriginalModelMetadataKey] = requestedModel + } + // Copy parsed metadata (thinking_budget, reasoning_effort, etc.) + for k, v := range metadata { + testMetadata[k] = v + } + + // Use the exported ApplyThinkingMetadataCLI which includes the fallback logic + result := executor.ApplyThinkingMetadataCLI(body, testMetadata, tt.upstreamModel) + budgetVal := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget") + + expectedBudget := tt.expectedValue.(int) + if !budgetVal.Exists() { + t.Errorf("Case #%d: expected thinkingBudget in result", tt.id) + } else if int(budgetVal.Int()) != expectedBudget { + t.Errorf("Case #%d: thinkingBudget = %d, want %d", tt.id, int(budgetVal.Int()), expectedBudget) } } }) From 8d15723195e80011ca0b3d9103779eca99a474c9 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 31 Dec 2025 23:37:46 +0800 Subject: [PATCH 09/14] feat(registry): add `GetAvailableModelsByProvider` method for retrieving models by provider --- internal/registry/model_registry.go | 125 ++++++++++++++++++++++++++++ sdk/cliproxy/model_registry.go | 1 + 2 files changed, 126 insertions(+) diff --git a/internal/registry/model_registry.go b/internal/registry/model_registry.go index d4f84481..a2c154ca 100644 --- a/internal/registry/model_registry.go +++ b/internal/registry/model_registry.go @@ -625,6 +625,131 @@ func (r *ModelRegistry) GetAvailableModels(handlerType string) []map[string]any return models } +// GetAvailableModelsByProvider returns models available for the given provider identifier. +// Parameters: +// - provider: Provider identifier (e.g., "codex", "gemini", "antigravity") +// +// Returns: +// - []*ModelInfo: List of available models for the provider +func (r *ModelRegistry) GetAvailableModelsByProvider(provider string) []*ModelInfo { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + return nil + } + + r.mutex.RLock() + defer r.mutex.RUnlock() + + type providerModel struct { + count int + info *ModelInfo + } + + providerModels := make(map[string]*providerModel) + + for clientID, clientProvider := range r.clientProviders { + if clientProvider != provider { + continue + } + modelIDs := r.clientModels[clientID] + if len(modelIDs) == 0 { + continue + } + clientInfos := r.clientModelInfos[clientID] + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + entry := providerModels[modelID] + if entry == nil { + entry = &providerModel{} + providerModels[modelID] = entry + } + entry.count++ + if entry.info == nil { + if clientInfos != nil { + if info := clientInfos[modelID]; info != nil { + entry.info = info + } + } + if entry.info == nil { + if reg, ok := r.models[modelID]; ok && reg != nil && reg.Info != nil { + entry.info = reg.Info + } + } + } + } + } + + if len(providerModels) == 0 { + return nil + } + + quotaExpiredDuration := 5 * time.Minute + now := time.Now() + result := make([]*ModelInfo, 0, len(providerModels)) + + for modelID, entry := range providerModels { + if entry == nil || entry.count <= 0 { + continue + } + registration, ok := r.models[modelID] + + expiredClients := 0 + cooldownSuspended := 0 + otherSuspended := 0 + if ok && registration != nil { + if registration.QuotaExceededClients != nil { + for clientID, quotaTime := range registration.QuotaExceededClients { + if clientID == "" { + continue + } + if p, okProvider := r.clientProviders[clientID]; !okProvider || p != provider { + continue + } + if quotaTime != nil && now.Sub(*quotaTime) < quotaExpiredDuration { + expiredClients++ + } + } + } + if registration.SuspendedClients != nil { + for clientID, reason := range registration.SuspendedClients { + if clientID == "" { + continue + } + if p, okProvider := r.clientProviders[clientID]; !okProvider || p != provider { + continue + } + if strings.EqualFold(reason, "quota") { + cooldownSuspended++ + continue + } + otherSuspended++ + } + } + } + + availableClients := entry.count + effectiveClients := availableClients - expiredClients - otherSuspended + if effectiveClients < 0 { + effectiveClients = 0 + } + + if effectiveClients > 0 || (availableClients > 0 && (expiredClients > 0 || cooldownSuspended > 0) && otherSuspended == 0) { + if entry.info != nil { + result = append(result, entry.info) + continue + } + if ok && registration != nil && registration.Info != nil { + result = append(result, registration.Info) + } + } + } + + return result +} + // GetModelCount returns the number of available clients for a specific model // Parameters: // - modelID: The model ID to check diff --git a/sdk/cliproxy/model_registry.go b/sdk/cliproxy/model_registry.go index 44ef8d7d..3cd57842 100644 --- a/sdk/cliproxy/model_registry.go +++ b/sdk/cliproxy/model_registry.go @@ -13,6 +13,7 @@ type ModelRegistry interface { ClearModelQuotaExceeded(clientID, modelID string) ClientSupportsModel(clientID, modelID string) bool GetAvailableModels(handlerType string) []map[string]any + GetAvailableModelsByProvider(provider string) []*ModelInfo } // GlobalModelRegistry returns the shared registry instance. From b6ba51bc2a0a1cf8ba584048e6228b65bf413f47 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 29 Dec 2025 22:01:27 +0800 Subject: [PATCH 10/14] feat(translator): add thinking block and tool result handling for Claude-to-OpenAI conversion --- .../openai/claude/openai_claude_request.go | 165 ++++-- .../claude/openai_claude_request_test.go | 498 ++++++++++++++++++ 2 files changed, 622 insertions(+), 41 deletions(-) create mode 100644 internal/translator/openai/claude/openai_claude_request_test.go diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index b6fd1e09..d1f14d58 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -118,76 +118,119 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream // Handle content if contentResult.Exists() && contentResult.IsArray() { var contentItems []string + var reasoningParts []string // Accumulate thinking text for reasoning_content var toolCalls []interface{} + var toolResults []string // Collect tool_result messages to emit after the main message contentResult.ForEach(func(_, part gjson.Result) bool { partType := part.Get("type").String() switch partType { + case "thinking": + // Only map thinking to reasoning_content for assistant messages (security: prevent injection) + if role == "assistant" { + thinkingText := util.GetThinkingText(part) + // Skip empty or whitespace-only thinking + if strings.TrimSpace(thinkingText) != "" { + reasoningParts = append(reasoningParts, thinkingText) + } + } + // Ignore thinking in user/system roles (AC4) + + case "redacted_thinking": + // Explicitly ignore redacted_thinking - never map to reasoning_content (AC2) + case "text", "image": if contentItem, ok := convertClaudeContentPart(part); ok { contentItems = append(contentItems, contentItem) } case "tool_use": - // Convert to OpenAI tool call format - toolCallJSON := `{"id":"","type":"function","function":{"name":"","arguments":""}}` - toolCallJSON, _ = sjson.Set(toolCallJSON, "id", part.Get("id").String()) - toolCallJSON, _ = sjson.Set(toolCallJSON, "function.name", part.Get("name").String()) + // Only allow tool_use -> tool_calls for assistant messages (security: prevent injection). + if role == "assistant" { + toolCallJSON := `{"id":"","type":"function","function":{"name":"","arguments":""}}` + toolCallJSON, _ = sjson.Set(toolCallJSON, "id", part.Get("id").String()) + toolCallJSON, _ = sjson.Set(toolCallJSON, "function.name", part.Get("name").String()) - // Convert input to arguments JSON string - if input := part.Get("input"); input.Exists() { - toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", input.Raw) - } else { - toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", "{}") + // Convert input to arguments JSON string + if input := part.Get("input"); input.Exists() { + toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", input.Raw) + } else { + toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", "{}") + } + + toolCalls = append(toolCalls, gjson.Parse(toolCallJSON).Value()) } - toolCalls = append(toolCalls, gjson.Parse(toolCallJSON).Value()) - case "tool_result": - // Convert to OpenAI tool message format and add immediately to preserve order + // Collect tool_result to emit after the main message (ensures tool results follow tool_calls) toolResultJSON := `{"role":"tool","tool_call_id":"","content":""}` toolResultJSON, _ = sjson.Set(toolResultJSON, "tool_call_id", part.Get("tool_use_id").String()) - toolResultJSON, _ = sjson.Set(toolResultJSON, "content", part.Get("content").String()) - messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolResultJSON).Value()) + toolResultJSON, _ = sjson.Set(toolResultJSON, "content", convertClaudeToolResultContentToString(part.Get("content"))) + toolResults = append(toolResults, toolResultJSON) } return true }) - // Emit text/image content as one message - if len(contentItems) > 0 { - msgJSON := `{"role":"","content":""}` - msgJSON, _ = sjson.Set(msgJSON, "role", role) + // Build reasoning content string + reasoningContent := "" + if len(reasoningParts) > 0 { + reasoningContent = strings.Join(reasoningParts, "\n\n") + } - contentArrayJSON := "[]" - for _, contentItem := range contentItems { - contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem) + hasContent := len(contentItems) > 0 + hasReasoning := reasoningContent != "" + hasToolCalls := len(toolCalls) > 0 + + // For assistant messages: emit a single unified message with content, tool_calls, and reasoning_content + // This avoids splitting into multiple assistant messages which breaks OpenAI tool-call adjacency + if role == "assistant" { + if hasContent || hasReasoning || hasToolCalls { + msgJSON := `{"role":"assistant"}` + + // Add content (as array if we have items, empty string if reasoning-only) + if hasContent { + contentArrayJSON := "[]" + for _, contentItem := range contentItems { + contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem) + } + msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON) + } else { + // Ensure content field exists for OpenAI compatibility + msgJSON, _ = sjson.Set(msgJSON, "content", "") + } + + // Add reasoning_content if present + if hasReasoning { + msgJSON, _ = sjson.Set(msgJSON, "reasoning_content", reasoningContent) + } + + // Add tool_calls if present (in same message as content) + if hasToolCalls { + msgJSON, _ = sjson.Set(msgJSON, "tool_calls", toolCalls) + } + + messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value()) } - msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON) - - contentValue := gjson.Get(msgJSON, "content") - hasContent := false - switch { - case !contentValue.Exists(): - hasContent = false - case contentValue.Type == gjson.String: - hasContent = contentValue.String() != "" - case contentValue.IsArray(): - hasContent = len(contentValue.Array()) > 0 - default: - hasContent = contentValue.Raw != "" && contentValue.Raw != "null" - } - + } else { + // For non-assistant roles: emit content message if we have content if hasContent { + msgJSON := `{"role":""}` + msgJSON, _ = sjson.Set(msgJSON, "role", role) + + contentArrayJSON := "[]" + for _, contentItem := range contentItems { + contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem) + } + msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON) + messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value()) } } - // Emit tool calls in a separate assistant message - if role == "assistant" && len(toolCalls) > 0 { - toolCallMsgJSON := `{"role":"assistant","tool_calls":[]}` - toolCallMsgJSON, _ = sjson.Set(toolCallMsgJSON, "tool_calls", toolCalls) - messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolCallMsgJSON).Value()) + // Emit tool_result messages after the main message (ensures proper OpenAI ordering) + for _, toolResultJSON := range toolResults { + messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolResultJSON).Value()) } } else if contentResult.Exists() && contentResult.Type == gjson.String { @@ -307,3 +350,43 @@ func convertClaudeContentPart(part gjson.Result) (string, bool) { return "", false } } + +func convertClaudeToolResultContentToString(content gjson.Result) string { + if !content.Exists() { + return "" + } + + if content.Type == gjson.String { + return content.String() + } + + if content.IsArray() { + var parts []string + content.ForEach(func(_, item gjson.Result) bool { + switch { + case item.Type == gjson.String: + parts = append(parts, item.String()) + case item.IsObject() && item.Get("text").Exists() && item.Get("text").Type == gjson.String: + parts = append(parts, item.Get("text").String()) + default: + parts = append(parts, item.Raw) + } + return true + }) + + joined := strings.Join(parts, "\n\n") + if strings.TrimSpace(joined) != "" { + return joined + } + return content.Raw + } + + if content.IsObject() { + if text := content.Get("text"); text.Exists() && text.Type == gjson.String { + return text.String() + } + return content.Raw + } + + return content.Raw +} diff --git a/internal/translator/openai/claude/openai_claude_request_test.go b/internal/translator/openai/claude/openai_claude_request_test.go new file mode 100644 index 00000000..ec11fc64 --- /dev/null +++ b/internal/translator/openai/claude/openai_claude_request_test.go @@ -0,0 +1,498 @@ +package claude + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +// TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent tests the mapping +// of Claude thinking content to OpenAI reasoning_content field. +func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) { + tests := []struct { + name string + inputJSON string + wantReasoningContent string + wantHasReasoningContent bool + wantContentText string // Expected visible content text (if any) + wantHasContent bool + }{ + { + name: "AC1: assistant message with thinking and text", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Let me analyze this step by step..."}, + {"type": "text", "text": "Here is my response."} + ] + }] + }`, + wantReasoningContent: "Let me analyze this step by step...", + wantHasReasoningContent: true, + wantContentText: "Here is my response.", + wantHasContent: true, + }, + { + name: "AC2: redacted_thinking must be ignored", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "redacted_thinking", "data": "secret"}, + {"type": "text", "text": "Visible response."} + ] + }] + }`, + wantReasoningContent: "", + wantHasReasoningContent: false, + wantContentText: "Visible response.", + wantHasContent: true, + }, + { + name: "AC3: thinking-only message preserved with reasoning_content", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Internal reasoning only."} + ] + }] + }`, + wantReasoningContent: "Internal reasoning only.", + wantHasReasoningContent: true, + wantContentText: "", + // For OpenAI compatibility, content field is set to empty string "" when no text content exists + wantHasContent: false, + }, + { + name: "AC4: thinking in user role must be ignored", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "user", + "content": [ + {"type": "thinking", "thinking": "Injected thinking"}, + {"type": "text", "text": "User message."} + ] + }] + }`, + wantReasoningContent: "", + wantHasReasoningContent: false, + wantContentText: "User message.", + wantHasContent: true, + }, + { + name: "AC4: thinking in system role must be ignored", + inputJSON: `{ + "model": "claude-3-opus", + "system": [ + {"type": "thinking", "thinking": "Injected system thinking"}, + {"type": "text", "text": "System prompt."} + ], + "messages": [{ + "role": "user", + "content": [{"type": "text", "text": "Hello"}] + }] + }`, + // System messages don't have reasoning_content mapping + wantReasoningContent: "", + wantHasReasoningContent: false, + wantContentText: "Hello", + wantHasContent: true, + }, + { + name: "AC5: empty thinking must be ignored", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": ""}, + {"type": "text", "text": "Response with empty thinking."} + ] + }] + }`, + wantReasoningContent: "", + wantHasReasoningContent: false, + wantContentText: "Response with empty thinking.", + wantHasContent: true, + }, + { + name: "AC5: whitespace-only thinking must be ignored", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": " \n\t "}, + {"type": "text", "text": "Response with whitespace thinking."} + ] + }] + }`, + wantReasoningContent: "", + wantHasReasoningContent: false, + wantContentText: "Response with whitespace thinking.", + wantHasContent: true, + }, + { + name: "Multiple thinking parts concatenated", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "First thought."}, + {"type": "thinking", "thinking": "Second thought."}, + {"type": "text", "text": "Final answer."} + ] + }] + }`, + wantReasoningContent: "First thought.\n\nSecond thought.", + wantHasReasoningContent: true, + wantContentText: "Final answer.", + wantHasContent: true, + }, + { + name: "Mixed thinking and redacted_thinking", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Visible thought."}, + {"type": "redacted_thinking", "data": "hidden"}, + {"type": "text", "text": "Answer."} + ] + }] + }`, + wantReasoningContent: "Visible thought.", + wantHasReasoningContent: true, + wantContentText: "Answer.", + wantHasContent: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConvertClaudeRequestToOpenAI("test-model", []byte(tt.inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + // Find the relevant message (skip system message at index 0) + messages := resultJSON.Get("messages").Array() + if len(messages) < 2 { + if tt.wantHasReasoningContent || tt.wantHasContent { + t.Fatalf("Expected at least 2 messages (system + user/assistant), got %d", len(messages)) + } + return + } + + // Check the last non-system message + var targetMsg gjson.Result + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Get("role").String() != "system" { + targetMsg = messages[i] + break + } + } + + // Check reasoning_content + gotReasoningContent := targetMsg.Get("reasoning_content").String() + gotHasReasoningContent := targetMsg.Get("reasoning_content").Exists() + + if gotHasReasoningContent != tt.wantHasReasoningContent { + t.Errorf("reasoning_content existence = %v, want %v", gotHasReasoningContent, tt.wantHasReasoningContent) + } + + if gotReasoningContent != tt.wantReasoningContent { + t.Errorf("reasoning_content = %q, want %q", gotReasoningContent, tt.wantReasoningContent) + } + + // Check content + content := targetMsg.Get("content") + // content has meaningful content if it's a non-empty array, or a non-empty string + var gotHasContent bool + switch { + case content.IsArray(): + gotHasContent = len(content.Array()) > 0 + case content.Type == gjson.String: + gotHasContent = content.String() != "" + default: + gotHasContent = false + } + + if gotHasContent != tt.wantHasContent { + t.Errorf("content existence = %v, want %v", gotHasContent, tt.wantHasContent) + } + + if tt.wantHasContent && tt.wantContentText != "" { + // Find text content + var foundText string + content.ForEach(func(_, v gjson.Result) bool { + if v.Get("type").String() == "text" { + foundText = v.Get("text").String() + return false + } + return true + }) + if foundText != tt.wantContentText { + t.Errorf("content text = %q, want %q", foundText, tt.wantContentText) + } + } + }) + } +} + +// TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved tests AC3: +// that a message with only thinking content is preserved (not dropped). +func TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved(t *testing.T) { + inputJSON := `{ + "model": "claude-3-opus", + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "What is 2+2?"}] + }, + { + "role": "assistant", + "content": [{"type": "thinking", "thinking": "Let me calculate: 2+2=4"}] + }, + { + "role": "user", + "content": [{"type": "text", "text": "Thanks"}] + } + ] + }` + + result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + messages := resultJSON.Get("messages").Array() + + // Should have: system (auto-added) + user + assistant (thinking-only) + user = 4 messages + if len(messages) != 4 { + t.Fatalf("Expected 4 messages, got %d. Messages: %v", len(messages), resultJSON.Get("messages").Raw) + } + + // Check the assistant message (index 2) has reasoning_content + assistantMsg := messages[2] + if assistantMsg.Get("role").String() != "assistant" { + t.Errorf("Expected message[2] to be assistant, got %s", assistantMsg.Get("role").String()) + } + + if !assistantMsg.Get("reasoning_content").Exists() { + t.Error("Expected assistant message to have reasoning_content") + } + + if assistantMsg.Get("reasoning_content").String() != "Let me calculate: 2+2=4" { + t.Errorf("Unexpected reasoning_content: %s", assistantMsg.Get("reasoning_content").String()) + } +} + +func TestConvertClaudeRequestToOpenAI_ToolResultOrderAndContent(t *testing.T) { + inputJSON := `{ + "model": "claude-3-opus", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}} + ] + }, + { + "role": "user", + "content": [ + {"type": "text", "text": "before"}, + {"type": "tool_result", "tool_use_id": "call_1", "content": [{"type":"text","text":"tool ok"}]}, + {"type": "text", "text": "after"} + ] + } + ] + }` + + result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + messages := resultJSON.Get("messages").Array() + + // New behavior: user text is combined, tool_result emitted after user message + // Expect: system + assistant(tool_calls) + user(before+after) + tool(result) + if len(messages) != 4 { + t.Fatalf("Expected 4 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + + if messages[0].Get("role").String() != "system" { + t.Fatalf("Expected messages[0] to be system, got %s", messages[0].Get("role").String()) + } + + if messages[1].Get("role").String() != "assistant" || !messages[1].Get("tool_calls").Exists() { + t.Fatalf("Expected messages[1] to be assistant tool_calls, got %s: %s", messages[1].Get("role").String(), messages[1].Raw) + } + + if messages[2].Get("role").String() != "user" { + t.Fatalf("Expected messages[2] to be user, got %s", messages[2].Get("role").String()) + } + // User message should contain both "before" and "after" text + if got := messages[2].Get("content.0.text").String(); got != "before" { + t.Fatalf("Expected user text[0] %q, got %q", "before", got) + } + if got := messages[2].Get("content.1.text").String(); got != "after" { + t.Fatalf("Expected user text[1] %q, got %q", "after", got) + } + + if messages[3].Get("role").String() != "tool" { + t.Fatalf("Expected messages[3] to be tool, got %s", messages[3].Get("role").String()) + } + if got := messages[3].Get("tool_call_id").String(); got != "call_1" { + t.Fatalf("Expected tool_call_id %q, got %q", "call_1", got) + } + if got := messages[3].Get("content").String(); got != "tool ok" { + t.Fatalf("Expected tool content %q, got %q", "tool ok", got) + } +} + +func TestConvertClaudeRequestToOpenAI_ToolResultObjectContent(t *testing.T) { + inputJSON := `{ + "model": "claude-3-opus", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}} + ] + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "call_1", "content": {"foo": "bar"}} + ] + } + ] + }` + + result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + messages := resultJSON.Get("messages").Array() + + // system + assistant(tool_calls) + tool(result) + if len(messages) != 3 { + t.Fatalf("Expected 3 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + + if messages[2].Get("role").String() != "tool" { + t.Fatalf("Expected messages[2] to be tool, got %s", messages[2].Get("role").String()) + } + + toolContent := messages[2].Get("content").String() + parsed := gjson.Parse(toolContent) + if parsed.Get("foo").String() != "bar" { + t.Fatalf("Expected tool content JSON foo=bar, got %q", toolContent) + } +} + +func TestConvertClaudeRequestToOpenAI_AssistantTextToolUseTextOrder(t *testing.T) { + inputJSON := `{ + "model": "claude-3-opus", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "pre"}, + {"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}, + {"type": "text", "text": "post"} + ] + } + ] + }` + + result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + messages := resultJSON.Get("messages").Array() + + // New behavior: content + tool_calls unified in single assistant message + // Expect: system + assistant(content[pre,post] + tool_calls) + if len(messages) != 2 { + t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + + if messages[0].Get("role").String() != "system" { + t.Fatalf("Expected messages[0] to be system, got %s", messages[0].Get("role").String()) + } + + assistantMsg := messages[1] + if assistantMsg.Get("role").String() != "assistant" { + t.Fatalf("Expected messages[1] to be assistant, got %s", assistantMsg.Get("role").String()) + } + + // Should have both content and tool_calls in same message + if !assistantMsg.Get("tool_calls").Exists() { + t.Fatalf("Expected assistant message to have tool_calls") + } + if got := assistantMsg.Get("tool_calls.0.id").String(); got != "call_1" { + t.Fatalf("Expected tool_call id %q, got %q", "call_1", got) + } + if got := assistantMsg.Get("tool_calls.0.function.name").String(); got != "do_work" { + t.Fatalf("Expected tool_call name %q, got %q", "do_work", got) + } + + // Content should have both pre and post text + if got := assistantMsg.Get("content.0.text").String(); got != "pre" { + t.Fatalf("Expected content[0] text %q, got %q", "pre", got) + } + if got := assistantMsg.Get("content.1.text").String(); got != "post" { + t.Fatalf("Expected content[1] text %q, got %q", "post", got) + } +} + +func TestConvertClaudeRequestToOpenAI_AssistantThinkingToolUseThinkingSplit(t *testing.T) { + inputJSON := `{ + "model": "claude-3-opus", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "t1"}, + {"type": "text", "text": "pre"}, + {"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}, + {"type": "thinking", "thinking": "t2"}, + {"type": "text", "text": "post"} + ] + } + ] + }` + + result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + messages := resultJSON.Get("messages").Array() + + // New behavior: all content, thinking, and tool_calls unified in single assistant message + // Expect: system + assistant(content[pre,post] + tool_calls + reasoning_content[t1+t2]) + if len(messages) != 2 { + t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + + assistantMsg := messages[1] + if assistantMsg.Get("role").String() != "assistant" { + t.Fatalf("Expected messages[1] to be assistant, got %s", assistantMsg.Get("role").String()) + } + + // Should have content with both pre and post + if got := assistantMsg.Get("content.0.text").String(); got != "pre" { + t.Fatalf("Expected content[0] text %q, got %q", "pre", got) + } + if got := assistantMsg.Get("content.1.text").String(); got != "post" { + t.Fatalf("Expected content[1] text %q, got %q", "post", got) + } + + // Should have tool_calls + if !assistantMsg.Get("tool_calls").Exists() { + t.Fatalf("Expected assistant message to have tool_calls") + } + + // Should have combined reasoning_content from both thinking blocks + if got := assistantMsg.Get("reasoning_content").String(); got != "t1\n\nt2" { + t.Fatalf("Expected reasoning_content %q, got %q", "t1\n\nt2", got) + } +} From 8f4c46f38d296e1201a59ee4b602f988f4f7d3a1 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 1 Jan 2026 11:11:43 +0800 Subject: [PATCH 11/14] fix(translator): emit tool_result messages before user content in Claude-to-OpenAI conversion --- .../openai/claude/openai_claude_request.go | 16 ++++++--- .../claude/openai_claude_request_test.go | 36 ++++++++++--------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index d1f14d58..cc7fd01e 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -181,6 +181,14 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream hasContent := len(contentItems) > 0 hasReasoning := reasoningContent != "" hasToolCalls := len(toolCalls) > 0 + hasToolResults := len(toolResults) > 0 + + // OpenAI requires: tool messages MUST immediately follow the assistant message with tool_calls. + // Therefore, we emit tool_result messages FIRST (they respond to the previous assistant's tool_calls), + // then emit the current message's content. + for _, toolResultJSON := range toolResults { + messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolResultJSON).Value()) + } // For assistant messages: emit a single unified message with content, tool_calls, and reasoning_content // This avoids splitting into multiple assistant messages which breaks OpenAI tool-call adjacency @@ -214,6 +222,7 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream } } else { // For non-assistant roles: emit content message if we have content + // If the message only contains tool_results (no text/image), we still processed them above if hasContent { msgJSON := `{"role":""}` msgJSON, _ = sjson.Set(msgJSON, "role", role) @@ -225,14 +234,11 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON) messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value()) + } else if hasToolResults && !hasContent { + // tool_results already emitted above, no additional user message needed } } - // Emit tool_result messages after the main message (ensures proper OpenAI ordering) - for _, toolResultJSON := range toolResults { - messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolResultJSON).Value()) - } - } else if contentResult.Exists() && contentResult.Type == gjson.String { // Simple string content msgJSON := `{"role":"","content":""}` diff --git a/internal/translator/openai/claude/openai_claude_request_test.go b/internal/translator/openai/claude/openai_claude_request_test.go index ec11fc64..3a577957 100644 --- a/internal/translator/openai/claude/openai_claude_request_test.go +++ b/internal/translator/openai/claude/openai_claude_request_test.go @@ -317,8 +317,8 @@ func TestConvertClaudeRequestToOpenAI_ToolResultOrderAndContent(t *testing.T) { resultJSON := gjson.ParseBytes(result) messages := resultJSON.Get("messages").Array() - // New behavior: user text is combined, tool_result emitted after user message - // Expect: system + assistant(tool_calls) + user(before+after) + tool(result) + // OpenAI requires: tool messages MUST immediately follow assistant(tool_calls). + // Correct order: system + assistant(tool_calls) + tool(result) + user(before+after) if len(messages) != 4 { t.Fatalf("Expected 4 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) } @@ -331,26 +331,28 @@ func TestConvertClaudeRequestToOpenAI_ToolResultOrderAndContent(t *testing.T) { t.Fatalf("Expected messages[1] to be assistant tool_calls, got %s: %s", messages[1].Get("role").String(), messages[1].Raw) } - if messages[2].Get("role").String() != "user" { - t.Fatalf("Expected messages[2] to be user, got %s", messages[2].Get("role").String()) + // tool message MUST immediately follow assistant(tool_calls) per OpenAI spec + if messages[2].Get("role").String() != "tool" { + t.Fatalf("Expected messages[2] to be tool (must follow tool_calls), got %s", messages[2].Get("role").String()) } - // User message should contain both "before" and "after" text - if got := messages[2].Get("content.0.text").String(); got != "before" { - t.Fatalf("Expected user text[0] %q, got %q", "before", got) - } - if got := messages[2].Get("content.1.text").String(); got != "after" { - t.Fatalf("Expected user text[1] %q, got %q", "after", got) - } - - if messages[3].Get("role").String() != "tool" { - t.Fatalf("Expected messages[3] to be tool, got %s", messages[3].Get("role").String()) - } - if got := messages[3].Get("tool_call_id").String(); got != "call_1" { + if got := messages[2].Get("tool_call_id").String(); got != "call_1" { t.Fatalf("Expected tool_call_id %q, got %q", "call_1", got) } - if got := messages[3].Get("content").String(); got != "tool ok" { + if got := messages[2].Get("content").String(); got != "tool ok" { t.Fatalf("Expected tool content %q, got %q", "tool ok", got) } + + // User message comes after tool message + if messages[3].Get("role").String() != "user" { + t.Fatalf("Expected messages[3] to be user, got %s", messages[3].Get("role").String()) + } + // User message should contain both "before" and "after" text + if got := messages[3].Get("content.0.text").String(); got != "before" { + t.Fatalf("Expected user text[0] %q, got %q", "before", got) + } + if got := messages[3].Get("content.1.text").String(); got != "after" { + t.Fatalf("Expected user text[1] %q, got %q", "after", got) + } } func TestConvertClaudeRequestToOpenAI_ToolResultObjectContent(t *testing.T) { From 2d2f4572a723846ab63b1b542738e1423e6286a8 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 1 Jan 2026 12:39:09 +0800 Subject: [PATCH 12/14] fix(translator): remove unnecessary whitespace trimming in reasoning text collection --- .../translator/openai/claude/openai_claude_response.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go index 3c30299f..27ab082b 100644 --- a/internal/translator/openai/claude/openai_claude_response.go +++ b/internal/translator/openai/claude/openai_claude_response.go @@ -480,15 +480,15 @@ func collectOpenAIReasoningTexts(node gjson.Result) []string { switch node.Type { case gjson.String: - if text := strings.TrimSpace(node.String()); text != "" { + if text := node.String(); text != "" { texts = append(texts, text) } case gjson.JSON: if text := node.Get("text"); text.Exists() { - if trimmed := strings.TrimSpace(text.String()); trimmed != "" { - texts = append(texts, trimmed) + if textStr := text.String(); textStr != "" { + texts = append(texts, textStr) } - } else if raw := strings.TrimSpace(node.Raw); raw != "" && !strings.HasPrefix(raw, "{") && !strings.HasPrefix(raw, "[") { + } else if raw := node.Raw; raw != "" && !strings.HasPrefix(raw, "{") && !strings.HasPrefix(raw, "[") { texts = append(texts, raw) } } From 4fc3d5e9356a85e9a75a4eed23945e08e39c811b Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 1 Jan 2026 19:31:08 +0800 Subject: [PATCH 13/14] refactor(iflow): simplify thinking config handling for GLM and MiniMax models --- internal/registry/model_definitions.go | 2 +- internal/runtime/executor/iflow_executor.go | 70 +++++++++------------ internal/util/util.go | 4 +- 3 files changed, 31 insertions(+), 45 deletions(-) diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index bd6b713a..bea2ecc3 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -740,7 +740,7 @@ func GetIFlowModels() []*ModelInfo { {ID: "qwen3-235b-a22b-thinking-2507", DisplayName: "Qwen3-235B-A22B-Thinking", Description: "Qwen3 235B A22B Thinking (2507)", Created: 1753401600}, {ID: "qwen3-235b-a22b-instruct", DisplayName: "Qwen3-235B-A22B-Instruct", Description: "Qwen3 235B A22B Instruct", Created: 1753401600}, {ID: "qwen3-235b", DisplayName: "Qwen3-235B-A22B", Description: "Qwen3 235B A22B", Created: 1753401600}, - {ID: "minimax-m2", DisplayName: "MiniMax-M2", Description: "MiniMax M2", Created: 1758672000}, + {ID: "minimax-m2", DisplayName: "MiniMax-M2", Description: "MiniMax M2", Created: 1758672000, Thinking: iFlowThinkingSupport}, {ID: "minimax-m2.1", DisplayName: "MiniMax-M2.1", Description: "MiniMax M2.1", Created: 1766448000, Thinking: iFlowThinkingSupport}, } models := make([]*ModelInfo, 0, len(entries)) diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go index 49fd4eb7..6e9fa96c 100644 --- a/internal/runtime/executor/iflow_executor.go +++ b/internal/runtime/executor/iflow_executor.go @@ -441,21 +441,18 @@ func ensureToolsArray(body []byte) []byte { return updated } -// preserveReasoningContentInMessages ensures reasoning_content from assistant messages in the -// conversation history is preserved when sending to iFlow models that support thinking. -// This is critical for multi-turn conversations where the model needs to see its previous -// reasoning to maintain coherent thought chains across tool calls and conversation turns. +// preserveReasoningContentInMessages checks if reasoning_content from assistant messages +// is preserved in conversation history for iFlow models that support thinking. +// This is helpful for multi-turn conversations where the model may benefit from seeing +// its previous reasoning to maintain coherent thought chains. // -// For GLM-4.7 and MiniMax-M2.1, the full assistant response (including reasoning) must be -// appended back into message history before the next call. +// For GLM-4.6/4.7 and MiniMax M2/M2.1, it is recommended to include the full assistant +// response (including reasoning_content) in message history for better context continuity. func preserveReasoningContentInMessages(body []byte) []byte { model := strings.ToLower(gjson.GetBytes(body, "model").String()) // Only apply to models that support thinking with history preservation - needsPreservation := strings.HasPrefix(model, "glm-4.7") || - strings.HasPrefix(model, "glm-4-7") || - strings.HasPrefix(model, "minimax-m2.1") || - strings.HasPrefix(model, "minimax-m2-1") + needsPreservation := strings.HasPrefix(model, "glm-4") || strings.HasPrefix(model, "minimax-m2") if !needsPreservation { return body @@ -493,45 +490,34 @@ func preserveReasoningContentInMessages(body []byte) []byte { // This should be called after NormalizeThinkingConfig has processed the payload. // // Model-specific handling: -// - GLM-4.7: Uses extra_body={"thinking": {"type": "enabled"}, "clear_thinking": false} -// - MiniMax-M2.1: Uses reasoning_split=true for OpenAI-style reasoning separation -// - Other iFlow models: Uses chat_template_kwargs.enable_thinking (boolean) +// - GLM-4.6/4.7: Uses chat_template_kwargs.enable_thinking (boolean) and chat_template_kwargs.clear_thinking=false +// - MiniMax M2/M2.1: Uses reasoning_split=true for OpenAI-style reasoning separation func applyIFlowThinkingConfig(body []byte) []byte { effort := gjson.GetBytes(body, "reasoning_effort") - model := strings.ToLower(gjson.GetBytes(body, "model").String()) - - // Check if thinking should be enabled - val := "" - if effort.Exists() { - val = strings.ToLower(strings.TrimSpace(effort.String())) + if !effort.Exists() { + return body } - enableThinking := effort.Exists() && val != "none" && val != "" + + model := strings.ToLower(gjson.GetBytes(body, "model").String()) + val := strings.ToLower(strings.TrimSpace(effort.String())) + enableThinking := val != "none" && val != "" // Remove reasoning_effort as we'll convert to model-specific format - if effort.Exists() { - body, _ = sjson.DeleteBytes(body, "reasoning_effort") - } + body, _ = sjson.DeleteBytes(body, "reasoning_effort") - // GLM-4.7: Use extra_body with thinking config and clear_thinking: false - if strings.HasPrefix(model, "glm-4.7") || strings.HasPrefix(model, "glm-4-7") { - if enableThinking { - body, _ = sjson.SetBytes(body, "extra_body.thinking.type", "enabled") - body, _ = sjson.SetBytes(body, "extra_body.clear_thinking", false) - } - return body - } - - // MiniMax-M2.1: Use reasoning_split=true for interleaved thinking - if strings.HasPrefix(model, "minimax-m2.1") || strings.HasPrefix(model, "minimax-m2-1") { - if enableThinking { - body, _ = sjson.SetBytes(body, "reasoning_split", true) - } - return body - } - - // Other iFlow models (including GLM-4.6): Use chat_template_kwargs.enable_thinking - if effort.Exists() { + // GLM-4.6/4.7: Use chat_template_kwargs + if strings.HasPrefix(model, "glm-4") { body, _ = sjson.SetBytes(body, "chat_template_kwargs.enable_thinking", enableThinking) + if enableThinking { + body, _ = sjson.SetBytes(body, "chat_template_kwargs.clear_thinking", false) + } + return body + } + + // MiniMax M2/M2.1: Use reasoning_split + if strings.HasPrefix(model, "minimax-m2") { + body, _ = sjson.SetBytes(body, "reasoning_split", enableThinking) + return body } return body diff --git a/internal/util/util.go b/internal/util/util.go index 4e846306..6ecaa8e2 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -25,7 +25,7 @@ func SanitizeFunctionName(name string) string { if name == "" { return "" } - + // Replace invalid characters with underscore sanitized := functionNameSanitizer.ReplaceAllString(name, "_") @@ -36,7 +36,7 @@ func SanitizeFunctionName(name string) string { if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { // If it starts with an allowed character but not allowed at the beginning (digit, dot, colon, dash), // we must prepend an underscore. - + // To stay within the 64-character limit while prepending, we must truncate first. if len(sanitized) >= 64 { sanitized = sanitized[:63] From 3902fd7501440d175f2bcc7a348ad1a467a4c148 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 1 Jan 2026 19:40:28 +0800 Subject: [PATCH 14/14] fix(iflow): remove thinking field from request body in thinking config handler --- internal/runtime/executor/iflow_executor.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go index 6e9fa96c..8492fb35 100644 --- a/internal/runtime/executor/iflow_executor.go +++ b/internal/runtime/executor/iflow_executor.go @@ -504,6 +504,7 @@ func applyIFlowThinkingConfig(body []byte) []byte { // Remove reasoning_effort as we'll convert to model-specific format body, _ = sjson.DeleteBytes(body, "reasoning_effort") + body, _ = sjson.DeleteBytes(body, "thinking") // GLM-4.6/4.7: Use chat_template_kwargs if strings.HasPrefix(model, "glm-4") {