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 != "" {