From 716aa71f6ef54ab26efb8fc6231cbb0ddab2a267 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 14 Dec 2025 14:45:01 +0800 Subject: [PATCH] fix(thinking): centralize reasoning_effort mapping Move OpenAI `reasoning_effort` -> Gemini `thinkingConfig` budget logic into shared helpers used by Gemini, Gemini CLI, and antigravity translators. Normalize Claude thinking handling by preferring positive budgets, applying budget token normalization, and gating by model support. Always convert Gemini `thinkingBudget` back to OpenAI `reasoning_effort` to support allowCompat models, and update tests for normalization behavior. --- .../antigravity_openai_request.go | 21 +------- .../claude/gemini/claude_gemini_request.go | 19 +++---- .../gemini-cli_openai_request.go | 21 +------- .../chat-completions/gemini_openai_request.go | 21 +------- .../gemini_openai-responses_request.go | 24 +-------- .../openai/gemini/openai_gemini_request.go | 13 +++-- internal/util/gemini_thinking.go | 53 +++++++++++++++++++ test/thinking_conversion_test.go | 13 +++-- 8 files changed, 83 insertions(+), 102 deletions(-) diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go index 251357bb..2a4684e2 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -40,26 +40,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ re := gjson.GetBytes(rawJSON, "reasoning_effort") hasOfficialThinking := re.Exists() if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { - switch re.String() { - case "none": - out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingConfig.include_thoughts") - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 0) - case "auto": - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", -1) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true) - case "low": - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 1024) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true) - case "medium": - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 8192) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true) - case "high": - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 32768) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true) - default: - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", -1) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true) - } + out = util.ApplyReasoningEffortToGeminiCLI(out, re.String()) } // Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent) diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index 780dd5f4..6518947b 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -114,15 +114,16 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream } } // Include thoughts configuration for reasoning process visibility - // Only apply for models that use numeric budgets, not discrete levels. - if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() && !util.ModelUsesThinkingLevels(modelName) { - if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() { - if includeThoughts.Type == gjson.True { - out, _ = sjson.Set(out, "thinking.type", "enabled") - if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() { - out, _ = sjson.Set(out, "thinking.budget_tokens", thinkingBudget.Int()) - } - } + // Only apply for models that support thinking and use numeric budgets, not discrete levels. + if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { + // Check for thinkingBudget first - if present, enable thinking with budget + if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() && thinkingBudget.Int() > 0 { + out, _ = sjson.Set(out, "thinking.type", "enabled") + normalizedBudget := util.NormalizeThinkingBudget(modelName, int(thinkingBudget.Int())) + out, _ = sjson.Set(out, "thinking.budget_tokens", normalizedBudget) + } else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True { + // Fallback to include_thoughts if no budget specified + out, _ = sjson.Set(out, "thinking.type", "enabled") } } } diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go index c7560d2f..dc5cf935 100644 --- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go +++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go @@ -40,26 +40,7 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo re := gjson.GetBytes(rawJSON, "reasoning_effort") hasOfficialThinking := re.Exists() if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { - switch re.String() { - case "none": - out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingConfig.include_thoughts") - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 0) - case "auto": - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", -1) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true) - case "low": - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 1024) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true) - case "medium": - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 8192) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true) - case "high": - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 32768) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true) - default: - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", -1) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true) - } + out = util.ApplyReasoningEffortToGeminiCLI(out, re.String()) } // Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent) diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index e754d0f1..54843f0d 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -42,26 +42,7 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) re := gjson.GetBytes(rawJSON, "reasoning_effort") hasOfficialThinking := re.Exists() if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { - switch re.String() { - case "none": - out, _ = sjson.DeleteBytes(out, "generationConfig.thinkingConfig.include_thoughts") - out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", 0) - case "auto": - out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", -1) - out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.include_thoughts", true) - case "low": - out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", 1024) - out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.include_thoughts", true) - case "medium": - out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", 8192) - out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.include_thoughts", true) - case "high": - out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", 32768) - out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.include_thoughts", true) - default: - out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", -1) - out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.include_thoughts", true) - } + out = util.ApplyReasoningEffortToGemini(out, re.String()) } // Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent) diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index b6f471d9..1bf67e7f 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -393,29 +393,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte hasOfficialThinking := root.Get("reasoning.effort").Exists() if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { reasoningEffort := root.Get("reasoning.effort") - switch reasoningEffort.String() { - case "none": - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", false) - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", 0) - case "auto": - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", -1) - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true) - case "minimal": - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", 1024) - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true) - case "low": - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", 4096) - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true) - case "medium": - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", 8192) - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true) - case "high": - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", 32768) - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true) - default: - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", -1) - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true) - } + out = string(util.ApplyReasoningEffortToGemini([]byte(out), reasoningEffort.String())) } // Cherry Studio extension (applies only when official fields are missing) diff --git a/internal/translator/openai/gemini/openai_gemini_request.go b/internal/translator/openai/gemini/openai_gemini_request.go index 1fd20f82..cca6ebf7 100644 --- a/internal/translator/openai/gemini/openai_gemini_request.go +++ b/internal/translator/openai/gemini/openai_gemini_request.go @@ -78,14 +78,13 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream } } - // Convert thinkingBudget to reasoning_effort for level-based models + // Convert thinkingBudget to reasoning_effort + // Always perform conversion to support allowCompat models that may not be in registry if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { - if util.ModelUsesThinkingLevels(modelName) { - if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() { - budget := int(thinkingBudget.Int()) - if effort, ok := util.OpenAIThinkingBudgetToEffort(modelName, budget); ok && effort != "" { - out, _ = sjson.Set(out, "reasoning_effort", effort) - } + if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() { + budget := int(thinkingBudget.Int()) + if effort, ok := util.OpenAIThinkingBudgetToEffort(modelName, budget); ok && effort != "" { + out, _ = sjson.Set(out, "reasoning_effort", effort) } } } diff --git a/internal/util/gemini_thinking.go b/internal/util/gemini_thinking.go index a89aba26..661982cd 100644 --- a/internal/util/gemini_thinking.go +++ b/internal/util/gemini_thinking.go @@ -152,6 +152,59 @@ func NormalizeGeminiCLIThinkingBudget(model string, body []byte) []byte { return updated } +// ReasoningEffortBudgetMapping defines the thinkingBudget values for each reasoning effort level. +var ReasoningEffortBudgetMapping = map[string]int{ + "none": 0, + "auto": -1, + "minimal": 512, + "low": 1024, + "medium": 8192, + "high": 24576, + "xhigh": 32768, +} + +// ApplyReasoningEffortToGemini applies OpenAI reasoning_effort to Gemini thinkingConfig +// for standard Gemini API format (generationConfig.thinkingConfig path). +// Returns the modified body with thinkingBudget and include_thoughts set. +func ApplyReasoningEffortToGemini(body []byte, effort string) []byte { + budget, ok := ReasoningEffortBudgetMapping[effort] + if !ok { + budget = -1 // default to auto + } + + budgetPath := "generationConfig.thinkingConfig.thinkingBudget" + includePath := "generationConfig.thinkingConfig.include_thoughts" + + if effort == "none" { + body, _ = sjson.DeleteBytes(body, "generationConfig.thinkingConfig") + } else { + body, _ = sjson.SetBytes(body, budgetPath, budget) + body, _ = sjson.SetBytes(body, includePath, true) + } + return body +} + +// ApplyReasoningEffortToGeminiCLI applies OpenAI reasoning_effort to Gemini CLI thinkingConfig +// for Gemini CLI API format (request.generationConfig.thinkingConfig path). +// Returns the modified body with thinkingBudget and include_thoughts set. +func ApplyReasoningEffortToGeminiCLI(body []byte, effort string) []byte { + budget, ok := ReasoningEffortBudgetMapping[effort] + if !ok { + budget = -1 // default to auto + } + + budgetPath := "request.generationConfig.thinkingConfig.thinkingBudget" + includePath := "request.generationConfig.thinkingConfig.include_thoughts" + + if effort == "none" { + body, _ = sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig") + } else { + body, _ = sjson.SetBytes(body, budgetPath, budget) + body, _ = sjson.SetBytes(body, includePath, true) + } + return body +} + // ConvertThinkingLevelToBudget checks for "generationConfig.thinkingConfig.thinkingLevel" // and converts it to "thinkingBudget". // "high" -> 32768 diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 60f4a02e..34b344f0 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -746,14 +746,21 @@ func TestRawPayloadThinkingConversions(t *testing.T) { // ThinkingEffortToBudget already returns normalized budget return true, fmt.Sprintf("%d", budget), false } - // Invalid effort - claude may still set thinking with type:enabled - return true, "", false + // Invalid effort - claude sets thinking.type:enabled but no budget_tokens + return false, "", false } return false, "", false case "openai": if allowCompat { if effort, ok := cs.thinkingParam.(string); ok && strings.TrimSpace(effort) != "" { - return true, strings.ToLower(strings.TrimSpace(effort)), false + // For allowCompat models, invalid effort values are normalized to "auto" + normalized := strings.ToLower(strings.TrimSpace(effort)) + switch normalized { + case "none", "auto", "low", "medium", "high", "xhigh": + return true, normalized, false + default: + return true, "auto", false + } } if budget, ok := cs.thinkingParam.(int); ok { if mapped, okM := util.OpenAIThinkingBudgetToEffort(model, budget); okM && mapped != "" {