From 6c65fdf54bf1ce89b2c171246b6d8f411f991340 Mon Sep 17 00:00:00 2001 From: neavo Date: Wed, 4 Feb 2026 21:12:47 +0800 Subject: [PATCH 1/3] fix(gemini): support snake_case thinking config fields from Python SDK Google official Gemini Python SDK sends thinking_level, thinking_budget, and include_thoughts (snake_case) instead of thinkingLevel, thinkingBudget, and includeThoughts (camelCase). This caused thinking configuration to be ignored when using Python SDK. Changes: - Extract layer: extractGeminiConfig now reads snake_case as fallback - Apply layer: Gemini/CLI/Antigravity appliers clean up snake_case fields - Translator layer: Gemini->OpenAI/Claude/Codex translators support fallback - Tests: Added 4 test cases for snake_case field coverage Fixes #1426 --- internal/thinking/apply.go | 14 +++++- .../thinking/provider/antigravity/apply.go | 8 +++- internal/thinking/provider/gemini/apply.go | 8 +++- internal/thinking/provider/geminicli/apply.go | 8 +++- .../claude/gemini/claude_gemini_request.go | 44 +++++++++++------- .../codex/gemini/codex_gemini_request.go | 21 +++++++-- .../openai/gemini/openai_gemini_request.go | 21 +++++++-- test/thinking_conversion_test.go | 46 +++++++++++++++++++ 8 files changed, 135 insertions(+), 35 deletions(-) diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index 58c26286..7c82a029 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -388,7 +388,12 @@ func extractGeminiConfig(body []byte, provider string) ThinkingConfig { } // Check thinkingLevel first (Gemini 3 format takes precedence) - if level := gjson.GetBytes(body, prefix+".thinkingLevel"); level.Exists() { + level := gjson.GetBytes(body, prefix+".thinkingLevel") + if !level.Exists() { + // Google official Gemini Python SDK sends snake_case field names + level = gjson.GetBytes(body, prefix+".thinking_level") + } + if level.Exists() { value := level.String() switch value { case "none": @@ -401,7 +406,12 @@ func extractGeminiConfig(body []byte, provider string) ThinkingConfig { } // Check thinkingBudget (Gemini 2.5 format) - if budget := gjson.GetBytes(body, prefix+".thinkingBudget"); budget.Exists() { + budget := gjson.GetBytes(body, prefix+".thinkingBudget") + if !budget.Exists() { + // Google official Gemini Python SDK sends snake_case field names + budget = gjson.GetBytes(body, prefix+".thinking_budget") + } + if budget.Exists() { value := int(budget.Int()) switch value { case 0: diff --git a/internal/thinking/provider/antigravity/apply.go b/internal/thinking/provider/antigravity/apply.go index 9c1c79f6..a55f808d 100644 --- a/internal/thinking/provider/antigravity/apply.go +++ b/internal/thinking/provider/antigravity/apply.go @@ -94,8 +94,10 @@ func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig, m } func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) { - // Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output + // Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget") + result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget") + result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level") // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts") @@ -119,8 +121,10 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) } func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo, isClaude bool) ([]byte, error) { - // Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output + // Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingLevel") + result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level") + result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget") // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts") diff --git a/internal/thinking/provider/gemini/apply.go b/internal/thinking/provider/gemini/apply.go index c8560f19..2c06a75a 100644 --- a/internal/thinking/provider/gemini/apply.go +++ b/internal/thinking/provider/gemini/apply.go @@ -118,8 +118,10 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) // - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false) // ValidateConfig sets config.Level to the lowest level when ModeNone + Budget > 0. - // Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output + // Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingBudget") + result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_budget") + result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_level") // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts") @@ -143,8 +145,10 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) } func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) { - // Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output + // Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingLevel") + result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_level") + result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_budget") // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts") diff --git a/internal/thinking/provider/geminicli/apply.go b/internal/thinking/provider/geminicli/apply.go index 75d9242a..f60c94a9 100644 --- a/internal/thinking/provider/geminicli/apply.go +++ b/internal/thinking/provider/geminicli/apply.go @@ -79,8 +79,10 @@ func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig) ( } func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) { - // Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output + // Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget") + result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget") + result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level") // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts") @@ -104,8 +106,10 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) } func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) { - // Remove conflicting field to avoid both thinkingLevel and thinkingBudget in output + // Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingLevel") + result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level") + result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget") // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts") diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index a26ac51a..3c1f9ec8 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -116,7 +116,11 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream // Include thoughts configuration for reasoning process visibility // Translator only does format conversion, ApplyThinking handles model capability validation. if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { - if thinkingLevel := thinkingConfig.Get("thinkingLevel"); thinkingLevel.Exists() { + thinkingLevel := thinkingConfig.Get("thinkingLevel") + if !thinkingLevel.Exists() { + thinkingLevel = thinkingConfig.Get("thinking_level") + } + if thinkingLevel.Exists() { level := strings.ToLower(strings.TrimSpace(thinkingLevel.String())) switch level { case "": @@ -132,23 +136,29 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream out, _ = sjson.Set(out, "thinking.budget_tokens", budget) } } - } else if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() { - budget := int(thinkingBudget.Int()) - switch budget { - case 0: - out, _ = sjson.Set(out, "thinking.type", "disabled") - out, _ = sjson.Delete(out, "thinking.budget_tokens") - case -1: - out, _ = sjson.Set(out, "thinking.type", "enabled") - out, _ = sjson.Delete(out, "thinking.budget_tokens") - default: - out, _ = sjson.Set(out, "thinking.type", "enabled") - out, _ = sjson.Set(out, "thinking.budget_tokens", budget) + } else { + thinkingBudget := thinkingConfig.Get("thinkingBudget") + if !thinkingBudget.Exists() { + thinkingBudget = thinkingConfig.Get("thinking_budget") + } + if thinkingBudget.Exists() { + budget := int(thinkingBudget.Int()) + switch budget { + case 0: + out, _ = sjson.Set(out, "thinking.type", "disabled") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + case -1: + out, _ = sjson.Set(out, "thinking.type", "enabled") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + default: + out, _ = sjson.Set(out, "thinking.type", "enabled") + out, _ = sjson.Set(out, "thinking.budget_tokens", budget) + } + } else if includeThoughts := thinkingConfig.Get("includeThoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True { + out, _ = sjson.Set(out, "thinking.type", "enabled") + } else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True { + out, _ = sjson.Set(out, "thinking.type", "enabled") } - } else if includeThoughts := thinkingConfig.Get("includeThoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True { - out, _ = sjson.Set(out, "thinking.type", "enabled") - } else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True { - out, _ = sjson.Set(out, "thinking.type", "enabled") } } } diff --git a/internal/translator/codex/gemini/codex_gemini_request.go b/internal/translator/codex/gemini/codex_gemini_request.go index bfea4c6d..2caa2c4a 100644 --- a/internal/translator/codex/gemini/codex_gemini_request.go +++ b/internal/translator/codex/gemini/codex_gemini_request.go @@ -243,19 +243,30 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) out, _ = sjson.Set(out, "parallel_tool_calls", true) // Convert Gemini thinkingConfig to Codex reasoning.effort. + // Note: Google official Python SDK sends snake_case fields (thinking_level/thinking_budget). effortSet := false if genConfig := root.Get("generationConfig"); genConfig.Exists() { if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { - if thinkingLevel := thinkingConfig.Get("thinkingLevel"); thinkingLevel.Exists() { + thinkingLevel := thinkingConfig.Get("thinkingLevel") + if !thinkingLevel.Exists() { + thinkingLevel = thinkingConfig.Get("thinking_level") + } + if thinkingLevel.Exists() { effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String())) if effort != "" { out, _ = sjson.Set(out, "reasoning.effort", effort) effortSet = true } - } else if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() { - if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok { - out, _ = sjson.Set(out, "reasoning.effort", effort) - effortSet = true + } else { + thinkingBudget := thinkingConfig.Get("thinkingBudget") + if !thinkingBudget.Exists() { + thinkingBudget = thinkingConfig.Get("thinking_budget") + } + if thinkingBudget.Exists() { + if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok { + out, _ = sjson.Set(out, "reasoning.effort", effort) + effortSet = true + } } } } diff --git a/internal/translator/openai/gemini/openai_gemini_request.go b/internal/translator/openai/gemini/openai_gemini_request.go index 5469a123..7700a35d 100644 --- a/internal/translator/openai/gemini/openai_gemini_request.go +++ b/internal/translator/openai/gemini/openai_gemini_request.go @@ -83,16 +83,27 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream } // Map Gemini thinkingConfig to OpenAI reasoning_effort. - // Always perform conversion to support allowCompat models that may not be in registry + // Always perform conversion to support allowCompat models that may not be in registry. + // Note: Google official Python SDK sends snake_case fields (thinking_level/thinking_budget). if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { - if thinkingLevel := thinkingConfig.Get("thinkingLevel"); thinkingLevel.Exists() { + thinkingLevel := thinkingConfig.Get("thinkingLevel") + if !thinkingLevel.Exists() { + thinkingLevel = thinkingConfig.Get("thinking_level") + } + if thinkingLevel.Exists() { effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String())) if effort != "" { out, _ = sjson.Set(out, "reasoning_effort", effort) } - } else if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() { - if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok { - out, _ = sjson.Set(out, "reasoning_effort", effort) + } else { + thinkingBudget := thinkingConfig.Get("thinkingBudget") + if !thinkingBudget.Exists() { + thinkingBudget = thinkingConfig.Get("thinking_budget") + } + if thinkingBudget.Exists() { + if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok { + out, _ = sjson.Set(out, "reasoning_effort", effort) + } } } } diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index fc20199e..83a0e139 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -1441,6 +1441,28 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectValue: "medium", expectErr: false, }, + // Case 9001: thinking_budget=64000 (snake_case) → high (Gemini -> Codex) + { + name: "9001", + from: "gemini", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinking_budget":64000}}}`, + expectField: "reasoning.effort", + expectValue: "high", + expectErr: false, + }, + // Case 9002: thinking_level=high (snake_case) → reasoning_effort=high (Gemini -> OpenAI) + { + name: "9002", + from: "gemini", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinking_level":"high"}}}`, + expectField: "reasoning_effort", + expectValue: "high", + expectErr: false, + }, // Case 11: Claude no param → passthrough (no thinking) { name: "11", @@ -1451,6 +1473,17 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectField: "", expectErr: false, }, + // Case 9003: thinking_budget=8192 (snake_case) → thinking.budget_tokens=8192 (Gemini -> Claude) + { + name: "9003", + from: "gemini", + to: "claude", + model: "level-model", + inputJSON: `{"model":"level-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinking_budget":8192}}}`, + expectField: "thinking.budget_tokens", + expectValue: "8192", + expectErr: false, + }, // Case 12: thinking.budget_tokens=8192 → medium { name: "12", @@ -1524,6 +1557,19 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { // gemini-budget-model (Min=128, Max=20000, ZeroAllowed=false, DynamicAllowed=true) + // Case 9004: thinking_budget=8192 (snake_case) → passthrough+normalize to thinkingBudget (Gemini -> Gemini) + { + name: "9004", + from: "gemini", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinking_budget":8192}}}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 18: No param → passthrough { name: "18", From d86b13c9cb835c605fa4b2660142b7027360380b Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:07:41 +0800 Subject: [PATCH 2/3] fix(thinking): support user-defined includeThoughts setting with camelCase and snake_case variants Fixes #1378 --- .../thinking/provider/antigravity/apply.go | 42 ++++++++++++---- internal/thinking/provider/gemini/apply.go | 50 ++++++++++++++----- internal/thinking/provider/geminicli/apply.go | 42 ++++++++++++---- test/thinking_conversion_test.go | 46 ----------------- 4 files changed, 103 insertions(+), 77 deletions(-) diff --git a/internal/thinking/provider/antigravity/apply.go b/internal/thinking/provider/antigravity/apply.go index a55f808d..7d5a5075 100644 --- a/internal/thinking/provider/antigravity/apply.go +++ b/internal/thinking/provider/antigravity/apply.go @@ -116,7 +116,16 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) level := string(config.Level) result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", level) - result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", true) + + // Respect user's explicit includeThoughts setting from original body; default to true if not set + // Support both camelCase and snake_case variants + includeThoughts := true + if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() { + includeThoughts = inc.Bool() + } else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() { + includeThoughts = inc.Bool() + } + result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) return result, nil } @@ -129,14 +138,29 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts") budget := config.Budget - includeThoughts := false - switch config.Mode { - case thinking.ModeNone: - includeThoughts = false - case thinking.ModeAuto: - includeThoughts = true - default: - includeThoughts = budget > 0 + + // Determine includeThoughts: respect user's explicit setting from original body if provided + // Support both camelCase and snake_case variants + var includeThoughts bool + var userSetIncludeThoughts bool + if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() { + includeThoughts = inc.Bool() + userSetIncludeThoughts = true + } else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() { + includeThoughts = inc.Bool() + userSetIncludeThoughts = true + } + + if !userSetIncludeThoughts { + // No explicit setting, use default logic based on mode + switch config.Mode { + case thinking.ModeNone: + includeThoughts = false + case thinking.ModeAuto: + includeThoughts = true + default: + includeThoughts = budget > 0 + } } // Apply Claude-specific constraints diff --git a/internal/thinking/provider/gemini/apply.go b/internal/thinking/provider/gemini/apply.go index 2c06a75a..39399c09 100644 --- a/internal/thinking/provider/gemini/apply.go +++ b/internal/thinking/provider/gemini/apply.go @@ -140,7 +140,16 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) level := string(config.Level) result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", level) - result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", true) + + // Respect user's explicit includeThoughts setting from original body; default to true if not set + // Support both camelCase and snake_case variants + includeThoughts := true + if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.includeThoughts"); inc.Exists() { + includeThoughts = inc.Bool() + } else if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.include_thoughts"); inc.Exists() { + includeThoughts = inc.Bool() + } + result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", includeThoughts) return result, nil } @@ -153,18 +162,33 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts") budget := config.Budget - // ModeNone semantics: - // - ModeNone + Budget=0: completely disable thinking - // - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false) - // When ZeroAllowed=false, ValidateConfig clamps Budget to Min while preserving ModeNone. - includeThoughts := false - switch config.Mode { - case thinking.ModeNone: - includeThoughts = false - case thinking.ModeAuto: - includeThoughts = true - default: - includeThoughts = budget > 0 + + // Determine includeThoughts: respect user's explicit setting from original body if provided + // Support both camelCase and snake_case variants + var includeThoughts bool + var userSetIncludeThoughts bool + if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.includeThoughts"); inc.Exists() { + includeThoughts = inc.Bool() + userSetIncludeThoughts = true + } else if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.include_thoughts"); inc.Exists() { + includeThoughts = inc.Bool() + userSetIncludeThoughts = true + } + + if !userSetIncludeThoughts { + // No explicit setting, use default logic based on mode + // ModeNone semantics: + // - ModeNone + Budget=0: completely disable thinking + // - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false) + // When ZeroAllowed=false, ValidateConfig clamps Budget to Min while preserving ModeNone. + switch config.Mode { + case thinking.ModeNone: + includeThoughts = false + case thinking.ModeAuto: + includeThoughts = true + default: + includeThoughts = budget > 0 + } } result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget) diff --git a/internal/thinking/provider/geminicli/apply.go b/internal/thinking/provider/geminicli/apply.go index f60c94a9..476e5b6d 100644 --- a/internal/thinking/provider/geminicli/apply.go +++ b/internal/thinking/provider/geminicli/apply.go @@ -101,7 +101,16 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) level := string(config.Level) result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", level) - result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", true) + + // Respect user's explicit includeThoughts setting from original body; default to true if not set + // Support both camelCase and snake_case variants + includeThoughts := true + if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() { + includeThoughts = inc.Bool() + } else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() { + includeThoughts = inc.Bool() + } + result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) return result, nil } @@ -114,14 +123,29 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts") budget := config.Budget - includeThoughts := false - switch config.Mode { - case thinking.ModeNone: - includeThoughts = false - case thinking.ModeAuto: - includeThoughts = true - default: - includeThoughts = budget > 0 + + // Determine includeThoughts: respect user's explicit setting from original body if provided + // Support both camelCase and snake_case variants + var includeThoughts bool + var userSetIncludeThoughts bool + if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() { + includeThoughts = inc.Bool() + userSetIncludeThoughts = true + } else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() { + includeThoughts = inc.Bool() + userSetIncludeThoughts = true + } + + if !userSetIncludeThoughts { + // No explicit setting, use default logic based on mode + switch config.Mode { + case thinking.ModeNone: + includeThoughts = false + case thinking.ModeAuto: + includeThoughts = true + default: + includeThoughts = budget > 0 + } } result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget) diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 83a0e139..fc20199e 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -1441,28 +1441,6 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectValue: "medium", expectErr: false, }, - // Case 9001: thinking_budget=64000 (snake_case) → high (Gemini -> Codex) - { - name: "9001", - from: "gemini", - to: "codex", - model: "level-model", - inputJSON: `{"model":"level-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinking_budget":64000}}}`, - expectField: "reasoning.effort", - expectValue: "high", - expectErr: false, - }, - // Case 9002: thinking_level=high (snake_case) → reasoning_effort=high (Gemini -> OpenAI) - { - name: "9002", - from: "gemini", - to: "openai", - model: "level-model", - inputJSON: `{"model":"level-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinking_level":"high"}}}`, - expectField: "reasoning_effort", - expectValue: "high", - expectErr: false, - }, // Case 11: Claude no param → passthrough (no thinking) { name: "11", @@ -1473,17 +1451,6 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectField: "", expectErr: false, }, - // Case 9003: thinking_budget=8192 (snake_case) → thinking.budget_tokens=8192 (Gemini -> Claude) - { - name: "9003", - from: "gemini", - to: "claude", - model: "level-model", - inputJSON: `{"model":"level-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinking_budget":8192}}}`, - expectField: "thinking.budget_tokens", - expectValue: "8192", - expectErr: false, - }, // Case 12: thinking.budget_tokens=8192 → medium { name: "12", @@ -1557,19 +1524,6 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { // gemini-budget-model (Min=128, Max=20000, ZeroAllowed=false, DynamicAllowed=true) - // Case 9004: thinking_budget=8192 (snake_case) → passthrough+normalize to thinkingBudget (Gemini -> Gemini) - { - name: "9004", - from: "gemini", - to: "gemini", - model: "gemini-budget-model", - inputJSON: `{"model":"gemini-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinking_budget":8192}}}`, - expectField: "generationConfig.thinkingConfig.thinkingBudget", - expectValue: "8192", - includeThoughts: "true", - expectErr: false, - }, - // Case 18: No param → passthrough { name: "18", From 209d74062a0aa1b4d7d5f0be091c6374dce07890 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:24:42 +0800 Subject: [PATCH 3/3] fix(thinking): ensure includeThoughts is false for ModeNone in budget processing --- .../thinking/provider/antigravity/apply.go | 29 ++++++++++++------- internal/thinking/provider/gemini/apply.go | 15 ++++++---- internal/thinking/provider/geminicli/apply.go | 11 +++++-- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/internal/thinking/provider/antigravity/apply.go b/internal/thinking/provider/antigravity/apply.go index 7d5a5075..d202035f 100644 --- a/internal/thinking/provider/antigravity/apply.go +++ b/internal/thinking/provider/antigravity/apply.go @@ -139,6 +139,24 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, budget := config.Budget + // Apply Claude-specific constraints first to get the final budget value + if isClaude && modelInfo != nil { + budget, result = a.normalizeClaudeBudget(budget, result, modelInfo) + // Check if budget was removed entirely + if budget == -2 { + return result, nil + } + } + + // For ModeNone, always set includeThoughts to false regardless of user setting. + // This ensures that when user requests budget=0 (disable thinking output), + // the includeThoughts is correctly set to false even if budget is clamped to min. + if config.Mode == thinking.ModeNone { + result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget) + result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false) + return result, nil + } + // Determine includeThoughts: respect user's explicit setting from original body if provided // Support both camelCase and snake_case variants var includeThoughts bool @@ -154,8 +172,6 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, if !userSetIncludeThoughts { // No explicit setting, use default logic based on mode switch config.Mode { - case thinking.ModeNone: - includeThoughts = false case thinking.ModeAuto: includeThoughts = true default: @@ -163,15 +179,6 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, } } - // Apply Claude-specific constraints - if isClaude && modelInfo != nil { - budget, result = a.normalizeClaudeBudget(budget, result, modelInfo) - // Check if budget was removed entirely - if budget == -2 { - return result, nil - } - } - result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget) result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) return result, nil diff --git a/internal/thinking/provider/gemini/apply.go b/internal/thinking/provider/gemini/apply.go index 39399c09..39bb4231 100644 --- a/internal/thinking/provider/gemini/apply.go +++ b/internal/thinking/provider/gemini/apply.go @@ -163,6 +163,15 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) budget := config.Budget + // For ModeNone, always set includeThoughts to false regardless of user setting. + // This ensures that when user requests budget=0 (disable thinking output), + // the includeThoughts is correctly set to false even if budget is clamped to min. + if config.Mode == thinking.ModeNone { + result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget) + result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", false) + return result, nil + } + // Determine includeThoughts: respect user's explicit setting from original body if provided // Support both camelCase and snake_case variants var includeThoughts bool @@ -177,13 +186,7 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) if !userSetIncludeThoughts { // No explicit setting, use default logic based on mode - // ModeNone semantics: - // - ModeNone + Budget=0: completely disable thinking - // - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false) - // When ZeroAllowed=false, ValidateConfig clamps Budget to Min while preserving ModeNone. switch config.Mode { - case thinking.ModeNone: - includeThoughts = false case thinking.ModeAuto: includeThoughts = true default: diff --git a/internal/thinking/provider/geminicli/apply.go b/internal/thinking/provider/geminicli/apply.go index 476e5b6d..5908b6bc 100644 --- a/internal/thinking/provider/geminicli/apply.go +++ b/internal/thinking/provider/geminicli/apply.go @@ -124,6 +124,15 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) budget := config.Budget + // For ModeNone, always set includeThoughts to false regardless of user setting. + // This ensures that when user requests budget=0 (disable thinking output), + // the includeThoughts is correctly set to false even if budget is clamped to min. + if config.Mode == thinking.ModeNone { + result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget) + result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false) + return result, nil + } + // Determine includeThoughts: respect user's explicit setting from original body if provided // Support both camelCase and snake_case variants var includeThoughts bool @@ -139,8 +148,6 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) if !userSetIncludeThoughts { // No explicit setting, use default logic based on mode switch config.Mode { - case thinking.ModeNone: - includeThoughts = false case thinking.ModeAuto: includeThoughts = true default: