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",