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..d202035f 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") @@ -114,28 +116,30 @@ 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 } 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") budget := config.Budget - includeThoughts := false - switch config.Mode { - case thinking.ModeNone: - includeThoughts = false - case thinking.ModeAuto: - includeThoughts = true - default: - includeThoughts = budget > 0 - } - // Apply Claude-specific constraints + // 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 @@ -144,6 +148,37 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, } } + // 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 + 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.ModeAuto: + includeThoughts = true + default: + includeThoughts = budget > 0 + } + } + 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 c8560f19..39bb4231 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") @@ -138,29 +140,58 @@ 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 } 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") 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 + + // 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 + 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 + switch config.Mode { + 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 75d9242a..5908b6bc 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") @@ -99,25 +101,58 @@ 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 } 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") budget := config.Budget - includeThoughts := false - switch config.Mode { - case thinking.ModeNone: - includeThoughts = false - case thinking.ModeAuto: - includeThoughts = true - default: - includeThoughts = budget > 0 + + // 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 + 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.ModeAuto: + includeThoughts = true + default: + includeThoughts = budget > 0 + } } result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget) 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) + } } } }