From 5ef2d59e0591bf9e5f683a0bc769b3ec59bbd973 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 13 Dec 2025 08:18:06 +0800 Subject: [PATCH 1/9] fix(thinking): gate reasoning effort by model support Only map OpenAI reasoning effort to Claude thinking for models that support thinking and use budget tokens (not level-based thinking). Also add "xhigh" effort mapping and adjust minimal/low budgets, with new raw-payload conversion tests across protocols and models. --- .../chat-completions/claude_openai_request.go | 5 +- .../claude_openai-responses_request.go | 9 +- test/thinking_conversion_test.go | 273 +++++++++++++++++- 3 files changed, 281 insertions(+), 6 deletions(-) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index b3384ecc..9825c661 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -16,6 +16,7 @@ import ( "strings" "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -65,7 +66,7 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream root := gjson.ParseBytes(rawJSON) - if v := root.Get("reasoning_effort"); v.Exists() { + if v := root.Get("reasoning_effort"); v.Exists() && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { out, _ = sjson.Set(out, "thinking.type", "enabled") switch v.String() { @@ -77,6 +78,8 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream out, _ = sjson.Set(out, "thinking.budget_tokens", 8192) case "high": out, _ = sjson.Set(out, "thinking.budget_tokens", 24576) + case "xhigh": + out, _ = sjson.Set(out, "thinking.budget_tokens", 32768) } } diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 764bb5c9..4a19bb92 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -52,20 +53,22 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte root := gjson.ParseBytes(rawJSON) - if v := root.Get("reasoning.effort"); v.Exists() { + if v := root.Get("reasoning.effort"); v.Exists() && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { out, _ = sjson.Set(out, "thinking.type", "enabled") switch v.String() { case "none": out, _ = sjson.Set(out, "thinking.type", "disabled") case "minimal": - out, _ = sjson.Set(out, "thinking.budget_tokens", 1024) + out, _ = sjson.Set(out, "thinking.budget_tokens", 512) case "low": - out, _ = sjson.Set(out, "thinking.budget_tokens", 4096) + out, _ = sjson.Set(out, "thinking.budget_tokens", 1024) case "medium": out, _ = sjson.Set(out, "thinking.budget_tokens", 8192) case "high": out, _ = sjson.Set(out, "thinking.budget_tokens", 24576) + case "xhigh": + out, _ = sjson.Set(out, "thinking.budget_tokens", 32768) } } diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index c2f4aa8d..a1462611 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -81,8 +81,10 @@ func applyReasoningEffortMetadataLocal(payload []byte, metadata map[string]any, return payload } if effort, ok := util.ReasoningEffortFromMetadata(metadata); ok && effort != "" { - if updated, err := sjson.SetBytes(payload, field, effort); err == nil { - return updated + if util.ModelUsesThinkingLevels(model) { + if updated, err := sjson.SetBytes(payload, field, effort); err == nil { + return updated + } } } if util.ModelUsesThinkingLevels(model) { @@ -523,6 +525,273 @@ func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) { } } +// buildRawPayloadWithThinking creates a payload with thinking parameters already in the body. +// This tests the path where thinking comes from the raw payload, not model suffix. +func buildRawPayloadWithThinking(fromProtocol, model string, thinkingParam any) []byte { + switch fromProtocol { + case "gemini": + base := fmt.Sprintf(`{"model":"%s","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, model) + if budget, ok := thinkingParam.(int); ok { + base, _ = sjson.Set(base, "generationConfig.thinkingConfig.thinkingBudget", budget) + } + return []byte(base) + case "openai-response": + base := fmt.Sprintf(`{"model":"%s","input":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`, model) + if effort, ok := thinkingParam.(string); ok && effort != "" { + base, _ = sjson.Set(base, "reasoning.effort", effort) + } + return []byte(base) + case "openai": + base := fmt.Sprintf(`{"model":"%s","messages":[{"role":"user","content":"hi"}]}`, model) + if effort, ok := thinkingParam.(string); ok && effort != "" { + base, _ = sjson.Set(base, "reasoning_effort", effort) + } + return []byte(base) + case "claude": + base := fmt.Sprintf(`{"model":"%s","messages":[{"role":"user","content":"hi"}]}`, model) + if budget, ok := thinkingParam.(int); ok && budget > 0 { + base, _ = sjson.Set(base, "thinking.type", "enabled") + base, _ = sjson.Set(base, "thinking.budget_tokens", budget) + } + return []byte(base) + default: + return []byte(fmt.Sprintf(`{"model":"%s","messages":[{"role":"user","content":"hi"}]}`, model)) + } +} + +// buildBodyForProtocolWithRawThinking translates payload with raw thinking params. +func buildBodyForProtocolWithRawThinking(t *testing.T, fromProtocol, toProtocol, model string, thinkingParam any) ([]byte, error) { + t.Helper() + raw := buildRawPayloadWithThinking(fromProtocol, model, thinkingParam) + stream := fromProtocol != toProtocol + + body := sdktranslator.TranslateRequest( + sdktranslator.FromString(fromProtocol), + sdktranslator.FromString(toProtocol), + model, + raw, + stream, + ) + + var err error + switch toProtocol { + case "gemini": + body = util.ApplyDefaultThinkingIfNeeded(model, body) + body = util.NormalizeGeminiThinkingBudget(model, body) + body = util.StripThinkingConfigIfUnsupported(model, body) + case "claude": + // For raw payload, Claude thinking is passed through by translator + // No additional processing needed as thinking is already in body + case "openai": + body = normalizeThinkingConfigLocal(body, model) + err = validateThinkingConfigLocal(body, model) + case "codex": + body, err = normalizeCodexPayload(body, model) + } + + body, _ = sjson.SetBytes(body, "model", model) + body = filterThinkingBody(toProtocol, body, model, model) + return body, err +} + +func TestRawPayloadThinkingConversions(t *testing.T) { + cleanup := registerCoreModels(t) + defer cleanup() + + models := []string{ + "gpt-5", // supports levels (low/medium/high) + "gemini-2.5-pro", // supports numeric budget + "qwen3-coder-flash", // no thinking support + } + fromProtocols := []string{"openai", "claude", "gemini", "openai-response"} + toProtocols := []string{"gemini", "claude", "openai", "codex"} + + type scenario struct { + name string + thinkingParam any // int for budget, string for effort level + } + + for _, model := range models { + supportsThinking := util.ModelSupportsThinking(model) + usesLevels := util.ModelUsesThinkingLevels(model) + + for _, from := range fromProtocols { + var cases []scenario + switch from { + case "openai", "openai-response": + cases = []scenario{ + {name: "no-thinking", thinkingParam: nil}, + {name: "effort-low", thinkingParam: "low"}, + {name: "effort-medium", thinkingParam: "medium"}, + {name: "effort-high", thinkingParam: "high"}, + {name: "effort-invalid-xhigh", thinkingParam: "xhigh"}, + {name: "effort-invalid-foo", thinkingParam: "foo"}, + } + case "gemini": + cases = []scenario{ + {name: "no-thinking", thinkingParam: nil}, + {name: "budget-1024", thinkingParam: 1024}, + {name: "budget-8192", thinkingParam: 8192}, + {name: "budget-16384", thinkingParam: 16384}, + } + case "claude": + cases = []scenario{ + {name: "no-thinking", thinkingParam: nil}, + {name: "budget-1024", thinkingParam: 1024}, + {name: "budget-8192", thinkingParam: 8192}, + {name: "budget-16384", thinkingParam: 16384}, + } + } + + for _, to := range toProtocols { + if from == to { + continue + } + t.Logf("═══════════════════════════════════════════════════════════════════════════════") + t.Logf(" RAW PAYLOAD: %s -> %s | model: %s", from, to, model) + t.Logf("═══════════════════════════════════════════════════════════════════════════════") + + for _, cs := range cases { + from := from + to := to + cs := cs + testName := fmt.Sprintf("raw/%s->%s/%s/%s", from, to, model, cs.name) + t.Run(testName, func(t *testing.T) { + expectPresent, expectValue, expectErr := func() (bool, string, bool) { + if cs.thinkingParam == nil { + // No thinking param provided + if to == "codex" && from != "openai-response" { + // Codex translators default to medium + if supportsThinking && usesLevels { + return true, "medium", false + } + } + return false, "", false + } + if !supportsThinking { + return false, "", false + } + + switch to { + case "gemini": + // Gemini expects numeric budget + if budget, ok := cs.thinkingParam.(int); ok { + norm := util.NormalizeThinkingBudget(model, budget) + return true, fmt.Sprintf("%d", norm), false + } + if effort, ok := cs.thinkingParam.(string); ok && effort != "" { + if b, okB := util.ThinkingEffortToBudget(model, effort); okB { + return true, fmt.Sprintf("%d", b), false + } + } + return false, "", false + case "claude": + // Claude expects numeric budget + if budget, ok := cs.thinkingParam.(int); ok && budget > 0 { + norm := util.NormalizeThinkingBudget(model, budget) + return true, fmt.Sprintf("%d", norm), false + } + if effort, ok := cs.thinkingParam.(string); ok && effort != "" { + if b, okB := util.ThinkingEffortToBudget(model, effort); okB && b > 0 { + return true, fmt.Sprintf("%d", b), false + } + } + return false, "", false + case "openai": + if !usesLevels { + return false, "", false + } + if effort, ok := cs.thinkingParam.(string); ok && effort != "" { + if normalized, okN := util.NormalizeReasoningEffortLevel(model, effort); okN { + return true, normalized, false + } + return false, "", true // invalid level + } + if budget, ok := cs.thinkingParam.(int); ok { + if mapped, okM := util.OpenAIThinkingBudgetToEffort(model, budget); okM && mapped != "" { + return true, mapped, false + } + } + return false, "", false + case "codex": + if !usesLevels { + return false, "", false + } + if effort, ok := cs.thinkingParam.(string); ok && effort != "" { + if normalized, okN := util.NormalizeReasoningEffortLevel(model, effort); okN { + return true, normalized, false + } + return false, "", true + } + if budget, ok := cs.thinkingParam.(int); ok { + if mapped, okM := util.OpenAIThinkingBudgetToEffort(model, budget); okM && mapped != "" { + return true, mapped, false + } + } + // thinkingParam was non-nil but couldn't map - no default medium + return false, "", false + } + return false, "", false + }() + + body, err := buildBodyForProtocolWithRawThinking(t, from, to, model, cs.thinkingParam) + actualPresent, actualValue := func() (bool, string) { + path := "" + switch to { + case "gemini": + path = "generationConfig.thinkingConfig.thinkingBudget" + case "claude": + path = "thinking.budget_tokens" + case "openai": + path = "reasoning_effort" + case "codex": + path = "reasoning.effort" + } + if path == "" { + return false, "" + } + val := gjson.GetBytes(body, path) + if to == "codex" && !val.Exists() { + reasoning := gjson.GetBytes(body, "reasoning") + if reasoning.Exists() { + val = reasoning.Get("effort") + } + } + if !val.Exists() { + return false, "" + } + if val.Type == gjson.Number { + return true, fmt.Sprintf("%d", val.Int()) + } + return true, val.String() + }() + + t.Logf("from=%s to=%s model=%s param=%v present(expect=%v got=%v) value(expect=%s got=%s) err(expect=%v got=%v) body=%s", + from, to, model, cs.thinkingParam, expectPresent, actualPresent, expectValue, actualValue, expectErr, err != nil, string(body)) + + if expectErr { + if err == nil { + t.Fatalf("expected validation error but got none, body=%s", string(body)) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v body=%s", err, string(body)) + } + + if expectPresent != actualPresent { + t.Fatalf("presence mismatch: expect %v got %v body=%s", expectPresent, actualPresent, string(body)) + } + if expectPresent && expectValue != actualValue { + t.Fatalf("value mismatch: expect %s got %s body=%s", expectValue, actualValue, string(body)) + } + }) + } + } + } + } +} + func TestOpenAIThinkingBudgetToEffortRanges(t *testing.T) { cleanup := registerCoreModels(t) defer cleanup() From 8496cc24446d2ac51e9ce2d938a3768f978a5835 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 14 Dec 2025 13:00:34 +0800 Subject: [PATCH 2/9] test(thinking): cover openai-compat reasoning passthrough --- test/thinking_conversion_test.go | 166 ++++++++++++++++++++++--------- 1 file changed, 117 insertions(+), 49 deletions(-) diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index a1462611..60f4a02e 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -24,6 +24,13 @@ type statusErr struct { func (e statusErr) Error() string { return e.msg } +// isOpenAICompatModel returns true if the model is configured as an OpenAI-compatible +// model that should have reasoning effort passed through even if not in registry. +// This simulates the allowCompat behavior from OpenAICompatExecutor. +func isOpenAICompatModel(model string) bool { + return model == "custom-thinking-model" +} + // registerCoreModels loads representative models across providers into the registry // so NormalizeThinkingBudget and level validation use real ranges. func registerCoreModels(t *testing.T) func() { @@ -34,11 +41,28 @@ func registerCoreModels(t *testing.T) func() { reg.RegisterClient(uid+"-claude", "claude", registry.GetClaudeModels()) reg.RegisterClient(uid+"-openai", "codex", registry.GetOpenAIModels()) reg.RegisterClient(uid+"-qwen", "qwen", registry.GetQwenModels()) + // Custom openai-compatible model with forced thinking suffix passthrough. + // No Thinking field - simulates an external model added via openai-compat + // where the registry has no knowledge of its thinking capabilities. + // The allowCompat flag should preserve reasoning effort for such models. + customOpenAIModels := []*registry.ModelInfo{ + { + ID: "custom-thinking-model", + Object: "model", + Created: 1700000000, + OwnedBy: "custom-provider", + Type: "openai", + DisplayName: "Custom Thinking Model", + Description: "OpenAI-compatible model with forced thinking suffix support", + }, + } + reg.RegisterClient(uid+"-custom-openai", "codex", customOpenAIModels) return func() { reg.UnregisterClient(uid + "-gemini") reg.UnregisterClient(uid + "-claude") reg.UnregisterClient(uid + "-openai") reg.UnregisterClient(uid + "-qwen") + reg.UnregisterClient(uid + "-custom-openai") } } @@ -70,24 +94,24 @@ func applyThinkingMetadataLocal(payload []byte, metadata map[string]any, model s } // applyReasoningEffortMetadataLocal mirrors executor.applyReasoningEffortMetadata. -func applyReasoningEffortMetadataLocal(payload []byte, metadata map[string]any, model, field string) []byte { +func applyReasoningEffortMetadataLocal(payload []byte, metadata map[string]any, model, field string, allowCompat bool) []byte { if len(metadata) == 0 { return payload } - if !util.ModelSupportsThinking(model) { - return payload - } if field == "" { return payload } + if !util.ModelSupportsThinking(model) && !allowCompat { + return payload + } if effort, ok := util.ReasoningEffortFromMetadata(metadata); ok && effort != "" { - if util.ModelUsesThinkingLevels(model) { + if util.ModelUsesThinkingLevels(model) || allowCompat { if updated, err := sjson.SetBytes(payload, field, effort); err == nil { return updated } } } - if util.ModelUsesThinkingLevels(model) { + if util.ModelUsesThinkingLevels(model) || allowCompat { if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil { if effort, ok := util.OpenAIThinkingBudgetToEffort(model, *budget); ok && effort != "" { if updated, err := sjson.SetBytes(payload, field, effort); err == nil { @@ -100,12 +124,17 @@ func applyReasoningEffortMetadataLocal(payload []byte, metadata map[string]any, } // normalizeThinkingConfigLocal mirrors executor.normalizeThinkingConfig. -func normalizeThinkingConfigLocal(payload []byte, model string) []byte { +// When allowCompat is true, reasoning fields are preserved even for models +// without thinking support (simulating openai-compat passthrough behavior). +func normalizeThinkingConfigLocal(payload []byte, model string, allowCompat bool) []byte { if len(payload) == 0 || model == "" { return payload } if !util.ModelSupportsThinking(model) { + if allowCompat { + return payload + } return stripThinkingFieldsLocal(payload, false) } @@ -187,8 +216,8 @@ func validateThinkingConfigLocal(payload []byte, model string) error { } // normalizeCodexPayload mirrors codex_executor's reasoning + streaming tweaks. -func normalizeCodexPayload(body []byte, upstreamModel string) ([]byte, error) { - body = normalizeThinkingConfigLocal(body, upstreamModel) +func normalizeCodexPayload(body []byte, upstreamModel string, allowCompat bool) ([]byte, error) { + body = normalizeThinkingConfigLocal(body, upstreamModel, allowCompat) if err := validateThinkingConfigLocal(body, upstreamModel); err != nil { return body, err } @@ -216,6 +245,7 @@ func buildBodyForProtocol(t *testing.T, fromProtocol, toProtocol, modelWithSuffi ) var err error + allowCompat := isOpenAICompatModel(normalizedModel) switch toProtocol { case "gemini": body = applyThinkingMetadataLocal(body, metadata, normalizedModel) @@ -227,13 +257,14 @@ func buildBodyForProtocol(t *testing.T, fromProtocol, toProtocol, modelWithSuffi body = util.ApplyClaudeThinkingConfig(body, budget) } case "openai": - body = applyReasoningEffortMetadataLocal(body, metadata, normalizedModel, "reasoning_effort") - body = normalizeThinkingConfigLocal(body, upstreamModel) + body = applyReasoningEffortMetadataLocal(body, metadata, normalizedModel, "reasoning_effort", allowCompat) + body = normalizeThinkingConfigLocal(body, upstreamModel, allowCompat) err = validateThinkingConfigLocal(body, upstreamModel) case "codex": // OpenAI responses / codex - body = applyReasoningEffortMetadataLocal(body, metadata, normalizedModel, "reasoning.effort") + // Codex does not support allowCompat; always use false. + body = applyReasoningEffortMetadataLocal(body, metadata, normalizedModel, "reasoning.effort", false) // Mirror CodexExecutor final normalization and model override so tests log the final body. - body, err = normalizeCodexPayload(body, upstreamModel) + body, err = normalizeCodexPayload(body, upstreamModel, false) default: } @@ -290,9 +321,10 @@ func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) { defer cleanup() models := []string{ - "gpt-5", // supports levels (low/medium/high) - "gemini-2.5-pro", // supports numeric budget - "qwen3-coder-flash", // no thinking support + "gpt-5", // supports levels (low/medium/high) + "gemini-2.5-pro", // supports numeric budget + "qwen3-coder-flash", // no thinking support + "custom-thinking-model", // openai-compatible model with forced thinking suffix } fromProtocols := []string{"openai", "claude", "gemini", "openai-response"} toProtocols := []string{"gemini", "claude", "openai", "codex"} @@ -404,7 +436,22 @@ func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) { } return true, fmt.Sprintf("%d", *budget), false case "openai": - if !util.ModelSupportsThinking(normalizedModel) { + allowCompat := isOpenAICompatModel(normalizedModel) + if !util.ModelSupportsThinking(normalizedModel) && !allowCompat { + return false, "", false + } + // For allowCompat models, pass through effort directly without validation + if allowCompat { + effort, ok := util.ReasoningEffortFromMetadata(metadata) + if ok && strings.TrimSpace(effort) != "" { + return true, strings.ToLower(strings.TrimSpace(effort)), false + } + // Check numeric budget fallback for allowCompat + if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil { + if mapped, okMap := util.OpenAIThinkingBudgetToEffort(normalizedModel, *budget); okMap && mapped != "" { + return true, mapped, false + } + } return false, "", false } if !util.ModelUsesThinkingLevels(normalizedModel) { @@ -429,14 +476,8 @@ func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) { } return false, "", true // validation would fail case "codex": - if !util.ModelSupportsThinking(normalizedModel) { - return false, "", false - } - if !util.ModelUsesThinkingLevels(normalizedModel) { - // Non-levels models don't support effort strings in codex - if from != "openai-response" { - return false, "", false - } + // Codex does not support allowCompat; require thinking-capable level models. + if !util.ModelSupportsThinking(normalizedModel) || !util.ModelUsesThinkingLevels(normalizedModel) { return false, "", false } effort, ok := util.ReasoningEffortFromMetadata(metadata) @@ -574,6 +615,7 @@ func buildBodyForProtocolWithRawThinking(t *testing.T, fromProtocol, toProtocol, ) var err error + allowCompat := isOpenAICompatModel(model) switch toProtocol { case "gemini": body = util.ApplyDefaultThinkingIfNeeded(model, body) @@ -583,10 +625,11 @@ func buildBodyForProtocolWithRawThinking(t *testing.T, fromProtocol, toProtocol, // For raw payload, Claude thinking is passed through by translator // No additional processing needed as thinking is already in body case "openai": - body = normalizeThinkingConfigLocal(body, model) + body = normalizeThinkingConfigLocal(body, model, allowCompat) err = validateThinkingConfigLocal(body, model) case "codex": - body, err = normalizeCodexPayload(body, model) + // Codex does not support allowCompat; always use false. + body, err = normalizeCodexPayload(body, model, false) } body, _ = sjson.SetBytes(body, "model", model) @@ -599,9 +642,10 @@ func TestRawPayloadThinkingConversions(t *testing.T) { defer cleanup() models := []string{ - "gpt-5", // supports levels (low/medium/high) - "gemini-2.5-pro", // supports numeric budget - "qwen3-coder-flash", // no thinking support + "gpt-5", // supports levels (low/medium/high) + "gemini-2.5-pro", // supports numeric budget + "qwen3-coder-flash", // no thinking support + "custom-thinking-model", // openai-compatible model with forced thinking suffix } fromProtocols := []string{"openai", "claude", "gemini", "openai-response"} toProtocols := []string{"gemini", "claude", "openai", "codex"} @@ -614,6 +658,7 @@ func TestRawPayloadThinkingConversions(t *testing.T) { for _, model := range models { supportsThinking := util.ModelSupportsThinking(model) usesLevels := util.ModelUsesThinkingLevels(model) + allowCompat := isOpenAICompatModel(model) for _, from := range fromProtocols { var cases []scenario @@ -624,7 +669,7 @@ func TestRawPayloadThinkingConversions(t *testing.T) { {name: "effort-low", thinkingParam: "low"}, {name: "effort-medium", thinkingParam: "medium"}, {name: "effort-high", thinkingParam: "high"}, - {name: "effort-invalid-xhigh", thinkingParam: "xhigh"}, + {name: "effort-xhigh", thinkingParam: "xhigh"}, {name: "effort-invalid-foo", thinkingParam: "foo"}, } case "gemini": @@ -659,46 +704,65 @@ func TestRawPayloadThinkingConversions(t *testing.T) { t.Run(testName, func(t *testing.T) { expectPresent, expectValue, expectErr := func() (bool, string, bool) { if cs.thinkingParam == nil { - // No thinking param provided - if to == "codex" && from != "openai-response" { - // Codex translators default to medium - if supportsThinking && usesLevels { - return true, "medium", false - } + if to == "codex" && from != "openai-response" && supportsThinking && usesLevels { + // Codex translators default reasoning.effort to "medium" for thinking-capable level models + return true, "medium", false } return false, "", false } - if !supportsThinking { - return false, "", false - } switch to { case "gemini": - // Gemini expects numeric budget + if !supportsThinking || usesLevels { + return false, "", false + } + // Gemini expects numeric budget (only for non-level models) if budget, ok := cs.thinkingParam.(int); ok { norm := util.NormalizeThinkingBudget(model, budget) return true, fmt.Sprintf("%d", norm), false } + // Convert effort level to budget for non-level models only if effort, ok := cs.thinkingParam.(string); ok && effort != "" { - if b, okB := util.ThinkingEffortToBudget(model, effort); okB { - return true, fmt.Sprintf("%d", b), false + if budget, okB := util.ThinkingEffortToBudget(model, effort); okB { + // ThinkingEffortToBudget already returns normalized budget + return true, fmt.Sprintf("%d", budget), false } + // Invalid effort maps to default/fallback + return true, fmt.Sprintf("%d", -1), false } return false, "", false case "claude": - // Claude expects numeric budget + if !supportsThinking || usesLevels { + return false, "", false + } + // Claude expects numeric budget (only for non-level models) if budget, ok := cs.thinkingParam.(int); ok && budget > 0 { norm := util.NormalizeThinkingBudget(model, budget) return true, fmt.Sprintf("%d", norm), false } + // Convert effort level to budget for non-level models only if effort, ok := cs.thinkingParam.(string); ok && effort != "" { - if b, okB := util.ThinkingEffortToBudget(model, effort); okB && b > 0 { - return true, fmt.Sprintf("%d", b), false + if budget, okB := util.ThinkingEffortToBudget(model, effort); okB { + // 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 } return false, "", false case "openai": - if !usesLevels { + if allowCompat { + if effort, ok := cs.thinkingParam.(string); ok && strings.TrimSpace(effort) != "" { + return true, strings.ToLower(strings.TrimSpace(effort)), false + } + if budget, ok := cs.thinkingParam.(int); ok { + if mapped, okM := util.OpenAIThinkingBudgetToEffort(model, budget); okM && mapped != "" { + return true, mapped, false + } + } + return false, "", false + } + if !supportsThinking || !usesLevels { return false, "", false } if effort, ok := cs.thinkingParam.(string); ok && effort != "" { @@ -714,7 +778,8 @@ func TestRawPayloadThinkingConversions(t *testing.T) { } return false, "", false case "codex": - if !usesLevels { + // Codex does not support allowCompat; require thinking-capable level models. + if !supportsThinking || !usesLevels { return false, "", false } if effort, ok := cs.thinkingParam.(string); ok && effort != "" { @@ -728,7 +793,10 @@ func TestRawPayloadThinkingConversions(t *testing.T) { return true, mapped, false } } - // thinkingParam was non-nil but couldn't map - no default medium + if from != "openai-response" { + // Codex translators default reasoning.effort to "medium" for thinking-capable models + return true, "medium", false + } return false, "", false } return false, "", false From e8976f9898c4ad6c09690678748751e062926392 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 14 Dec 2025 13:18:35 +0800 Subject: [PATCH 3/9] fix(thinking): map budgets to effort for level models --- .../antigravity_openai_request.go | 5 +++-- .../claude/gemini/claude_gemini_request.go | 3 ++- .../codex/claude/codex_claude_request.go | 18 +++++++++++++++++- .../codex/gemini/codex_gemini_request.go | 17 ++++++++++++++++- .../gemini-cli_openai_request.go | 5 +++-- .../gemini/claude/gemini_claude_request.go | 3 ++- .../chat-completions/gemini_openai_request.go | 7 +++++-- .../gemini_openai-responses_request.go | 6 ++++-- .../openai/claude/openai_claude_request.go | 13 +++++++++++++ .../openai/gemini/openai_gemini_request.go | 13 +++++++++++++ 10 files changed, 78 insertions(+), 12 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 717f88f7..251357bb 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -39,7 +39,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ // Note: OpenAI official fields take precedence over extra_body.google.thinking_config re := gjson.GetBytes(rawJSON, "reasoning_effort") hasOfficialThinking := re.Exists() - if hasOfficialThinking && util.ModelSupportsThinking(modelName) { + if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { switch re.String() { case "none": out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingConfig.include_thoughts") @@ -63,7 +63,8 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ } // Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent) - if !hasOfficialThinking && util.ModelSupportsThinking(modelName) { + // Only apply for models that use numeric budgets, not discrete levels. + if !hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { if tc := gjson.GetBytes(rawJSON, "extra_body.google.thinking_config"); tc.Exists() && tc.IsObject() { var setBudget bool var budget int diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index 302c7d66..780dd5f4 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -114,7 +114,8 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream } } // Include thoughts configuration for reasoning process visibility - if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { + // 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") diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 3c86e3cf..414efa89 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -214,7 +215,22 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // Add additional configuration parameters for the Codex API. template, _ = sjson.Set(template, "parallel_tool_calls", true) - template, _ = sjson.Set(template, "reasoning.effort", "medium") + + // Convert thinking.budget_tokens to reasoning.effort for level-based models + reasoningEffort := "medium" // default + if thinking := rootResult.Get("thinking"); thinking.Exists() && thinking.IsObject() { + if thinking.Get("type").String() == "enabled" { + if util.ModelUsesThinkingLevels(modelName) { + if budgetTokens := thinking.Get("budget_tokens"); budgetTokens.Exists() { + budget := int(budgetTokens.Int()) + if effort, ok := util.OpenAIThinkingBudgetToEffort(modelName, budget); ok && effort != "" { + reasoningEffort = effort + } + } + } + } + } + template, _ = sjson.Set(template, "reasoning.effort", reasoningEffort) template, _ = sjson.Set(template, "reasoning.summary", "auto") template, _ = sjson.Set(template, "stream", true) template, _ = sjson.Set(template, "store", false) diff --git a/internal/translator/codex/gemini/codex_gemini_request.go b/internal/translator/codex/gemini/codex_gemini_request.go index 427fd9ad..c2dacd3e 100644 --- a/internal/translator/codex/gemini/codex_gemini_request.go +++ b/internal/translator/codex/gemini/codex_gemini_request.go @@ -245,7 +245,22 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // Fixed flags aligning with Codex expectations out, _ = sjson.Set(out, "parallel_tool_calls", true) - out, _ = sjson.Set(out, "reasoning.effort", "medium") + + // Convert thinkingBudget to reasoning.effort for level-based models + reasoningEffort := "medium" // default + if genConfig := root.Get("generationConfig"); genConfig.Exists() { + 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 != "" { + reasoningEffort = effort + } + } + } + } + } + out, _ = sjson.Set(out, "reasoning.effort", reasoningEffort) out, _ = sjson.Set(out, "reasoning.summary", "auto") out, _ = sjson.Set(out, "stream", true) out, _ = sjson.Set(out, "store", false) 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 b52bf224..c7560d2f 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 @@ -39,7 +39,7 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo // Note: OpenAI official fields take precedence over extra_body.google.thinking_config re := gjson.GetBytes(rawJSON, "reasoning_effort") hasOfficialThinking := re.Exists() - if hasOfficialThinking && util.ModelSupportsThinking(modelName) { + if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { switch re.String() { case "none": out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingConfig.include_thoughts") @@ -63,7 +63,8 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo } // Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent) - if !hasOfficialThinking && util.ModelSupportsThinking(modelName) { + // Only apply for models that use numeric budgets, not discrete levels. + if !hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { if tc := gjson.GetBytes(rawJSON, "extra_body.google.thinking_config"); tc.Exists() && tc.IsObject() { var setBudget bool var budget int diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index 45a5a88f..f626a581 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -154,7 +154,8 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) } // Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when enabled - if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() && util.ModelSupportsThinking(modelName) { + // Only apply for models that use numeric budgets, not discrete levels. + if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { if t.Get("type").String() == "enabled" { if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { budget := int(b.Int()) 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 8c48a5b3..e754d0f1 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -37,9 +37,11 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) // Reasoning effort -> thinkingBudget/include_thoughts // Note: OpenAI official fields take precedence over extra_body.google.thinking_config + // Only convert for models that use numeric budgets (not discrete levels) to avoid + // incorrectly applying thinkingBudget for level-based models like gpt-5. re := gjson.GetBytes(rawJSON, "reasoning_effort") hasOfficialThinking := re.Exists() - if hasOfficialThinking && util.ModelSupportsThinking(modelName) { + if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { switch re.String() { case "none": out, _ = sjson.DeleteBytes(out, "generationConfig.thinkingConfig.include_thoughts") @@ -63,7 +65,8 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) } // Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent) - if !hasOfficialThinking && util.ModelSupportsThinking(modelName) { + // Only apply for models that use numeric budgets, not discrete levels. + if !hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { if tc := gjson.GetBytes(rawJSON, "extra_body.google.thinking_config"); tc.Exists() && tc.IsObject() { var setBudget bool var budget int 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 bdf59785..b6f471d9 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -389,8 +389,9 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte } // OpenAI official reasoning fields take precedence + // Only convert for models that use numeric budgets (not discrete levels). hasOfficialThinking := root.Get("reasoning.effort").Exists() - if hasOfficialThinking && util.ModelSupportsThinking(modelName) { + if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { reasoningEffort := root.Get("reasoning.effort") switch reasoningEffort.String() { case "none": @@ -418,7 +419,8 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte } // Cherry Studio extension (applies only when official fields are missing) - if !hasOfficialThinking && util.ModelSupportsThinking(modelName) { + // Only apply for models that use numeric budgets, not discrete levels. + if !hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { if tc := root.Get("extra_body.google.thinking_config"); tc.Exists() && tc.IsObject() { var setBudget bool var budget int diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index 3521b2e5..0ee8c225 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -10,6 +10,7 @@ import ( "encoding/json" "strings" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -60,6 +61,18 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream // Stream out, _ = sjson.Set(out, "stream", stream) + // Thinking: Convert Claude thinking.budget_tokens to OpenAI reasoning_effort + if thinking := root.Get("thinking"); thinking.Exists() && thinking.IsObject() { + if thinkingType := thinking.Get("type"); thinkingType.Exists() && thinkingType.String() == "enabled" { + if budgetTokens := thinking.Get("budget_tokens"); budgetTokens.Exists() { + budget := int(budgetTokens.Int()) + if effort, ok := util.OpenAIThinkingBudgetToEffort(modelName, budget); ok && effort != "" { + out, _ = sjson.Set(out, "reasoning_effort", effort) + } + } + } + } + // Process messages and system var messagesJSON = "[]" diff --git a/internal/translator/openai/gemini/openai_gemini_request.go b/internal/translator/openai/gemini/openai_gemini_request.go index deedf96a..1fd20f82 100644 --- a/internal/translator/openai/gemini/openai_gemini_request.go +++ b/internal/translator/openai/gemini/openai_gemini_request.go @@ -13,6 +13,7 @@ import ( "math/big" "strings" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -76,6 +77,18 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream out, _ = sjson.Set(out, "stop", stops) } } + + // Convert thinkingBudget to reasoning_effort for level-based models + 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) + } + } + } + } } // Stream parameter 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 4/9] 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 != "" { From a4a3274a5525170d49f02957608b46a20e20f63a Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 14 Dec 2025 18:32:20 +0800 Subject: [PATCH 5/9] test(thinking): expand conversion edge case coverage --- test/thinking_conversion_test.go | 113 +++++++++++++++++-------------- 1 file changed, 63 insertions(+), 50 deletions(-) diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 34b344f0..839fc375 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -28,7 +28,7 @@ func (e statusErr) Error() string { return e.msg } // model that should have reasoning effort passed through even if not in registry. // This simulates the allowCompat behavior from OpenAICompatExecutor. func isOpenAICompatModel(model string) bool { - return model == "custom-thinking-model" + return model == "openai-compat" } // registerCoreModels loads representative models across providers into the registry @@ -47,12 +47,12 @@ func registerCoreModels(t *testing.T) func() { // The allowCompat flag should preserve reasoning effort for such models. customOpenAIModels := []*registry.ModelInfo{ { - ID: "custom-thinking-model", + ID: "openai-compat", Object: "model", Created: 1700000000, OwnedBy: "custom-provider", Type: "openai", - DisplayName: "Custom Thinking Model", + DisplayName: "OpenAI Compatible Model", Description: "OpenAI-compatible model with forced thinking suffix support", }, } @@ -321,10 +321,10 @@ func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) { defer cleanup() models := []string{ - "gpt-5", // supports levels (low/medium/high) - "gemini-2.5-pro", // supports numeric budget - "qwen3-coder-flash", // no thinking support - "custom-thinking-model", // openai-compatible model with forced thinking suffix + "gpt-5", // supports levels (level-based thinking) + "gemini-2.5-pro", // supports numeric budget + "qwen3-code-plus", // no thinking support + "openai-compat", // openai-compatible channel (allowCompat=true) } fromProtocols := []string{"openai", "claude", "gemini", "openai-response"} toProtocols := []string{"gemini", "claude", "openai", "codex"} @@ -357,12 +357,7 @@ func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) { } for _, model := range models { - info := registry.GetGlobalRegistry().GetModelInfo(model) - min, max := 0, 0 - if info != nil && info.Thinking != nil { - min = info.Thinking.Min - max = info.Thinking.Max - } + _ = registry.GetGlobalRegistry().GetModelInfo(model) for _, from := range fromProtocols { // Scenario selection follows protocol semantics: @@ -372,29 +367,29 @@ func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) { {name: "no-suffix", modelSuffix: model, expectFn: func(_ *registry.ModelInfo) (bool, int64) { return false, 0 }}, } if from == "openai" || from == "openai-response" { + // Level-based test cases: auto, none, minimal, low, medium, high, xhigh, foo(invalid) + // Maps to numeric: -1, 0, 512, 1024, 8192, 24576, 32768, invalid cases = append(cases, - scenario{name: "level-low", modelSuffix: fmt.Sprintf("%s(low)", model), expectFn: levelBudgetFn("low")}, - scenario{name: "level-high", modelSuffix: fmt.Sprintf("%s(high)", model), expectFn: levelBudgetFn("high")}, scenario{name: "level-auto", modelSuffix: fmt.Sprintf("%s(auto)", model), expectFn: levelBudgetFn("auto")}, + scenario{name: "level-none", modelSuffix: fmt.Sprintf("%s(none)", model), expectFn: levelBudgetFn("none")}, + scenario{name: "level-minimal", modelSuffix: fmt.Sprintf("%s(minimal)", model), expectFn: levelBudgetFn("minimal")}, + scenario{name: "level-low", modelSuffix: fmt.Sprintf("%s(low)", model), expectFn: levelBudgetFn("low")}, + scenario{name: "level-medium", modelSuffix: fmt.Sprintf("%s(medium)", model), expectFn: levelBudgetFn("medium")}, + scenario{name: "level-high", modelSuffix: fmt.Sprintf("%s(high)", model), expectFn: levelBudgetFn("high")}, + scenario{name: "level-xhigh", modelSuffix: fmt.Sprintf("%s(xhigh)", model), expectFn: levelBudgetFn("xhigh")}, + scenario{name: "level-invalid", modelSuffix: fmt.Sprintf("%s(invalid)", model), expectFn: levelBudgetFn("invalid")}, ) } else { // claude or gemini - if util.ModelUsesThinkingLevels(model) { - // Numeric budgets for level-based models are mapped into levels when needed. - cases = append(cases, - scenario{name: "numeric-0", modelSuffix: fmt.Sprintf("%s(0)", model), expectFn: buildBudgetFn(0)}, - scenario{name: "numeric-1024", modelSuffix: fmt.Sprintf("%s(1024)", model), expectFn: buildBudgetFn(1024)}, - scenario{name: "numeric-1025", modelSuffix: fmt.Sprintf("%s(1025)", model), expectFn: buildBudgetFn(1025)}, - scenario{name: "numeric-8192", modelSuffix: fmt.Sprintf("%s(8192)", model), expectFn: buildBudgetFn(8192)}, - scenario{name: "numeric-8193", modelSuffix: fmt.Sprintf("%s(8193)", model), expectFn: buildBudgetFn(8193)}, - scenario{name: "numeric-24576", modelSuffix: fmt.Sprintf("%s(24576)", model), expectFn: buildBudgetFn(24576)}, - scenario{name: "numeric-24577", modelSuffix: fmt.Sprintf("%s(24577)", model), expectFn: buildBudgetFn(24577)}, - ) - } else { - cases = append(cases, - scenario{name: "numeric-below-min", modelSuffix: fmt.Sprintf("%s(%d)", model, min-10), expectFn: buildBudgetFn(min - 10)}, - scenario{name: "numeric-above-max", modelSuffix: fmt.Sprintf("%s(%d)", model, max+10), expectFn: buildBudgetFn(max + 10)}, - ) - } + // Numeric test cases: -1, 0, 1023, 1025, 8193, 24577 + // Maps to levels: auto, none, low, medium, high, xhigh + cases = append(cases, + scenario{name: "numeric-neg1", modelSuffix: fmt.Sprintf("%s(-1)", model), expectFn: buildBudgetFn(-1)}, + scenario{name: "numeric-0", modelSuffix: fmt.Sprintf("%s(0)", model), expectFn: buildBudgetFn(0)}, + scenario{name: "numeric-1023", modelSuffix: fmt.Sprintf("%s(1023)", model), expectFn: buildBudgetFn(1023)}, + scenario{name: "numeric-1025", modelSuffix: fmt.Sprintf("%s(1025)", model), expectFn: buildBudgetFn(1025)}, + scenario{name: "numeric-8193", modelSuffix: fmt.Sprintf("%s(8193)", model), expectFn: buildBudgetFn(8193)}, + scenario{name: "numeric-24577", modelSuffix: fmt.Sprintf("%s(24577)", model), expectFn: buildBudgetFn(24577)}, + ) } for _, to := range toProtocols { @@ -642,10 +637,10 @@ func TestRawPayloadThinkingConversions(t *testing.T) { defer cleanup() models := []string{ - "gpt-5", // supports levels (low/medium/high) - "gemini-2.5-pro", // supports numeric budget - "qwen3-coder-flash", // no thinking support - "custom-thinking-model", // openai-compatible model with forced thinking suffix + "gpt-5", // supports levels (level-based thinking) + "gemini-2.5-pro", // supports numeric budget + "qwen3-code-plus", // no thinking support + "openai-compat", // openai-compatible channel (allowCompat=true) } fromProtocols := []string{"openai", "claude", "gemini", "openai-response"} toProtocols := []string{"gemini", "claude", "openai", "codex"} @@ -664,27 +659,28 @@ func TestRawPayloadThinkingConversions(t *testing.T) { var cases []scenario switch from { case "openai", "openai-response": + // Level-based test cases: auto, none, minimal, low, medium, high, xhigh, foo(invalid) cases = []scenario{ {name: "no-thinking", thinkingParam: nil}, + {name: "effort-auto", thinkingParam: "auto"}, + {name: "effort-none", thinkingParam: "none"}, + {name: "effort-minimal", thinkingParam: "minimal"}, {name: "effort-low", thinkingParam: "low"}, {name: "effort-medium", thinkingParam: "medium"}, {name: "effort-high", thinkingParam: "high"}, {name: "effort-xhigh", thinkingParam: "xhigh"}, - {name: "effort-invalid-foo", thinkingParam: "foo"}, + {name: "effort-invalid", thinkingParam: "invalid"}, } - case "gemini": + case "gemini", "claude": + // Numeric test cases: -1, 0, 1023, 1025, 8193, 24577 cases = []scenario{ {name: "no-thinking", thinkingParam: nil}, - {name: "budget-1024", thinkingParam: 1024}, - {name: "budget-8192", thinkingParam: 8192}, - {name: "budget-16384", thinkingParam: 16384}, - } - case "claude": - cases = []scenario{ - {name: "no-thinking", thinkingParam: nil}, - {name: "budget-1024", thinkingParam: 1024}, - {name: "budget-8192", thinkingParam: 8192}, - {name: "budget-16384", thinkingParam: 16384}, + {name: "budget-neg1", thinkingParam: -1}, + {name: "budget-0", thinkingParam: 0}, + {name: "budget-1023", thinkingParam: 1023}, + {name: "budget-1025", thinkingParam: 1025}, + {name: "budget-8193", thinkingParam: 8193}, + {name: "budget-24577", thinkingParam: 24577}, } } @@ -723,12 +719,16 @@ func TestRawPayloadThinkingConversions(t *testing.T) { } // Convert effort level to budget for non-level models only if effort, ok := cs.thinkingParam.(string); ok && effort != "" { + // "none" disables thinking - no thinkingBudget in output + if strings.ToLower(effort) == "none" { + return false, "", false + } if budget, okB := util.ThinkingEffortToBudget(model, effort); okB { // ThinkingEffortToBudget already returns normalized budget return true, fmt.Sprintf("%d", budget), false } - // Invalid effort maps to default/fallback - return true, fmt.Sprintf("%d", -1), false + // Invalid effort maps to default auto (-1) + return true, "-1", false } return false, "", false case "claude": @@ -742,6 +742,11 @@ func TestRawPayloadThinkingConversions(t *testing.T) { } // Convert effort level to budget for non-level models only if effort, ok := cs.thinkingParam.(string); ok && effort != "" { + // "none" and "auto" don't produce budget_tokens + lower := strings.ToLower(effort) + if lower == "none" || lower == "auto" { + return false, "", false + } if budget, okB := util.ThinkingEffortToBudget(model, effort); okB { // ThinkingEffortToBudget already returns normalized budget return true, fmt.Sprintf("%d", budget), false @@ -780,6 +785,10 @@ func TestRawPayloadThinkingConversions(t *testing.T) { } if budget, ok := cs.thinkingParam.(int); ok { if mapped, okM := util.OpenAIThinkingBudgetToEffort(model, budget); okM && mapped != "" { + // Check if the mapped effort is valid for this model + if _, validLevel := util.NormalizeReasoningEffortLevel(model, mapped); !validLevel { + return true, mapped, true // expect validation error + } return true, mapped, false } } @@ -797,6 +806,10 @@ func TestRawPayloadThinkingConversions(t *testing.T) { } if budget, ok := cs.thinkingParam.(int); ok { if mapped, okM := util.OpenAIThinkingBudgetToEffort(model, budget); okM && mapped != "" { + // Check if the mapped effort is valid for this model + if _, validLevel := util.NormalizeReasoningEffortLevel(model, mapped); !validLevel { + return true, mapped, true // expect validation error + } return true, mapped, false } } From 712ce9f78189a3a8a5c77ba425f936bbb351964f Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 14 Dec 2025 18:45:03 +0800 Subject: [PATCH 6/9] fix(thinking): drop unsupported none effort When budget 0 maps to "none" for models that use thinking levels but don't support that effort level, strip thinking fields instead of setting an invalid reasoning_effort value. Tests now expect removal for this edge case. --- internal/runtime/executor/payload_helpers.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/runtime/executor/payload_helpers.go b/internal/runtime/executor/payload_helpers.go index 6e352c51..667f29da 100644 --- a/internal/runtime/executor/payload_helpers.go +++ b/internal/runtime/executor/payload_helpers.go @@ -73,6 +73,12 @@ func applyReasoningEffortMetadata(payload []byte, metadata map[string]any, model if util.ModelUsesThinkingLevels(baseModel) || allowCompat { if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil { if effort, ok := util.OpenAIThinkingBudgetToEffort(baseModel, *budget); ok && effort != "" { + if *budget == 0 && effort == "none" && util.ModelUsesThinkingLevels(baseModel) { + if _, supported := util.NormalizeReasoningEffortLevel(baseModel, effort); !supported { + return stripThinkingFields(payload, false) + } + } + if updated, err := sjson.SetBytes(payload, field, effort); err == nil { return updated } From d20b71deb97eb3c14411612e19c9603f8eb0f3a9 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 14 Dec 2025 20:11:30 +0800 Subject: [PATCH 7/9] fix(thinking): normalize effort mapping Route OpenAI reasoning effort through ThinkingEffortToBudget for Claude translators, preserve "minimal" when translating OpenAI Responses, and treat blank/unknown efforts as no-ops for Gemini thinking configs. Also map budget -1 to "auto" and expand cross-protocol thinking tests. --- .../chat-completions/claude_openai_request.go | 29 +-- .../claude_openai-responses_request.go | 31 +-- .../openai_openai-responses_request.go | 2 +- internal/util/gemini_thinking.go | 40 ++-- internal/util/openai_thinking.go | 7 +- test/thinking_conversion_test.go | 193 +++++++++--------- 6 files changed, 160 insertions(+), 142 deletions(-) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index 9825c661..9122b97e 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -67,19 +67,22 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream root := gjson.ParseBytes(rawJSON) if v := root.Get("reasoning_effort"); v.Exists() && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { - out, _ = sjson.Set(out, "thinking.type", "enabled") - - switch v.String() { - case "none": - out, _ = sjson.Set(out, "thinking.type", "disabled") - case "low": - out, _ = sjson.Set(out, "thinking.budget_tokens", 1024) - case "medium": - out, _ = sjson.Set(out, "thinking.budget_tokens", 8192) - case "high": - out, _ = sjson.Set(out, "thinking.budget_tokens", 24576) - case "xhigh": - out, _ = sjson.Set(out, "thinking.budget_tokens", 32768) + effort := strings.ToLower(strings.TrimSpace(v.String())) + if effort != "" { + budget, ok := util.ThinkingEffortToBudget(modelName, effort) + if ok { + switch budget { + case 0: + out, _ = sjson.Set(out, "thinking.type", "disabled") + case -1: + out, _ = sjson.Set(out, "thinking.type", "enabled") + default: + if budget > 0 { + out, _ = sjson.Set(out, "thinking.type", "enabled") + out, _ = sjson.Set(out, "thinking.budget_tokens", budget) + } + } + } } } diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 4a19bb92..b3654ca0 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -54,21 +54,22 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte root := gjson.ParseBytes(rawJSON) if v := root.Get("reasoning.effort"); v.Exists() && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) { - out, _ = sjson.Set(out, "thinking.type", "enabled") - - switch v.String() { - case "none": - out, _ = sjson.Set(out, "thinking.type", "disabled") - case "minimal": - out, _ = sjson.Set(out, "thinking.budget_tokens", 512) - case "low": - out, _ = sjson.Set(out, "thinking.budget_tokens", 1024) - case "medium": - out, _ = sjson.Set(out, "thinking.budget_tokens", 8192) - case "high": - out, _ = sjson.Set(out, "thinking.budget_tokens", 24576) - case "xhigh": - out, _ = sjson.Set(out, "thinking.budget_tokens", 32768) + effort := strings.ToLower(strings.TrimSpace(v.String())) + if effort != "" { + budget, ok := util.ThinkingEffortToBudget(modelName, effort) + if ok { + switch budget { + case 0: + out, _ = sjson.Set(out, "thinking.type", "disabled") + case -1: + out, _ = sjson.Set(out, "thinking.type", "enabled") + default: + if budget > 0 { + out, _ = sjson.Set(out, "thinking.type", "enabled") + out, _ = sjson.Set(out, "thinking.budget_tokens", budget) + } + } + } } } diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index 1b1f071f..8bac0485 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -195,7 +195,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu case "auto": out, _ = sjson.Set(out, "reasoning_effort", "auto") case "minimal": - out, _ = sjson.Set(out, "reasoning_effort", "low") + out, _ = sjson.Set(out, "reasoning_effort", "minimal") case "low": out, _ = sjson.Set(out, "reasoning_effort", "low") case "medium": diff --git a/internal/util/gemini_thinking.go b/internal/util/gemini_thinking.go index 661982cd..9e349d99 100644 --- a/internal/util/gemini_thinking.go +++ b/internal/util/gemini_thinking.go @@ -167,20 +167,26 @@ var ReasoningEffortBudgetMapping = map[string]int{ // 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 + normalized := strings.ToLower(strings.TrimSpace(effort)) + if normalized == "" { + return body } budgetPath := "generationConfig.thinkingConfig.thinkingBudget" includePath := "generationConfig.thinkingConfig.include_thoughts" - if effort == "none" { + if normalized == "none" { body, _ = sjson.DeleteBytes(body, "generationConfig.thinkingConfig") - } else { - body, _ = sjson.SetBytes(body, budgetPath, budget) - body, _ = sjson.SetBytes(body, includePath, true) + return body } + + budget, ok := ReasoningEffortBudgetMapping[normalized] + if !ok { + return body + } + + body, _ = sjson.SetBytes(body, budgetPath, budget) + body, _ = sjson.SetBytes(body, includePath, true) return body } @@ -188,20 +194,26 @@ func ApplyReasoningEffortToGemini(body []byte, effort string) []byte { // 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 + normalized := strings.ToLower(strings.TrimSpace(effort)) + if normalized == "" { + return body } budgetPath := "request.generationConfig.thinkingConfig.thinkingBudget" includePath := "request.generationConfig.thinkingConfig.include_thoughts" - if effort == "none" { + if normalized == "none" { body, _ = sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig") - } else { - body, _ = sjson.SetBytes(body, budgetPath, budget) - body, _ = sjson.SetBytes(body, includePath, true) + return body } + + budget, ok := ReasoningEffortBudgetMapping[normalized] + if !ok { + return body + } + + body, _ = sjson.SetBytes(body, budgetPath, budget) + body, _ = sjson.SetBytes(body, includePath, true) return body } diff --git a/internal/util/openai_thinking.go b/internal/util/openai_thinking.go index 4dda38f6..5ce7e6bf 100644 --- a/internal/util/openai_thinking.go +++ b/internal/util/openai_thinking.go @@ -5,15 +5,18 @@ package util // // Ranges: // - 0 -> "none" +// - -1 -> "auto" // - 1..1024 -> "low" // - 1025..8192 -> "medium" // - 8193..24576 -> "high" // - 24577.. -> highest supported level for the model (defaults to "xhigh") // -// Negative values (except the dynamic -1 handled elsewhere) are treated as unsupported. +// Negative values other than -1 are treated as unsupported. func OpenAIThinkingBudgetToEffort(model string, budget int) (string, bool) { switch { - case budget < 0: + case budget == -1: + return "auto", true + case budget < -1: return "", false case budget == 0: return "none", true diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 839fc375..9d15e128 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -66,6 +66,37 @@ func registerCoreModels(t *testing.T) func() { } } +var ( + thinkingTestModels = []string{ + "gpt-5", // level-based thinking model + "gemini-2.5-pro", // numeric-budget thinking model + "qwen3-code-plus", // no thinking support + "openai-compat", // allowCompat=true (OpenAI-compatible channel) + } + thinkingTestFromProtocols = []string{"openai", "claude", "gemini", "openai-response"} + thinkingTestToProtocols = []string{"gemini", "claude", "openai", "codex"} + + // Numeric budgets and their level equivalents: + // -1 -> auto + // 0 -> none + // 1..1024 -> low + // 1025..8192 -> medium + // 8193..24576 -> high + // >24576 -> model highest level (right-most in Levels) + thinkingNumericSamples = []int{-1, 0, 1023, 1025, 8193, 64000} + + // Levels and their numeric equivalents: + // auto -> -1 + // none -> 0 + // minimal -> 512 + // low -> 1024 + // medium -> 8192 + // high -> 24576 + // xhigh -> 32768 + // invalid -> invalid (no mapping) + thinkingLevelSamples = []string{"auto", "none", "minimal", "low", "medium", "high", "xhigh", "invalid"} +) + func buildRawPayload(fromProtocol, modelWithSuffix string) []byte { switch fromProtocol { case "gemini": @@ -101,19 +132,30 @@ func applyReasoningEffortMetadataLocal(payload []byte, metadata map[string]any, if field == "" { return payload } - if !util.ModelSupportsThinking(model) && !allowCompat { + baseModel := util.ResolveOriginalModel(model, metadata) + if baseModel == "" { + baseModel = model + } + if !util.ModelSupportsThinking(baseModel) && !allowCompat { return payload } if effort, ok := util.ReasoningEffortFromMetadata(metadata); ok && effort != "" { - if util.ModelUsesThinkingLevels(model) || allowCompat { + if util.ModelUsesThinkingLevels(baseModel) || allowCompat { if updated, err := sjson.SetBytes(payload, field, effort); err == nil { return updated } } } - if util.ModelUsesThinkingLevels(model) || allowCompat { + // Fallback: numeric thinking_budget suffix for level-based (OpenAI-style) models. + if util.ModelUsesThinkingLevels(baseModel) || allowCompat { if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil { - if effort, ok := util.OpenAIThinkingBudgetToEffort(model, *budget); ok && effort != "" { + if effort, ok := util.OpenAIThinkingBudgetToEffort(baseModel, *budget); ok && effort != "" { + if *budget == 0 && effort == "none" && util.ModelUsesThinkingLevels(baseModel) { + if _, supported := util.NormalizeReasoningEffortLevel(baseModel, effort); !supported { + return stripThinkingFieldsLocal(payload, false) + } + } + if updated, err := sjson.SetBytes(payload, field, effort); err == nil { return updated } @@ -320,79 +362,46 @@ func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) { cleanup := registerCoreModels(t) defer cleanup() - models := []string{ - "gpt-5", // supports levels (level-based thinking) - "gemini-2.5-pro", // supports numeric budget - "qwen3-code-plus", // no thinking support - "openai-compat", // openai-compatible channel (allowCompat=true) - } - fromProtocols := []string{"openai", "claude", "gemini", "openai-response"} - toProtocols := []string{"gemini", "claude", "openai", "codex"} - type scenario struct { name string modelSuffix string - expectFn func(info *registry.ModelInfo) (present bool, budget int64) } - buildBudgetFn := func(raw int) func(info *registry.ModelInfo) (bool, int64) { - return func(info *registry.ModelInfo) (bool, int64) { - if info == nil || info.Thinking == nil { - return false, 0 - } - return true, int64(util.NormalizeThinkingBudget(info.ID, raw)) + numericName := func(budget int) string { + if budget < 0 { + return "numeric-neg1" } + return fmt.Sprintf("numeric-%d", budget) } - levelBudgetFn := func(level string) func(info *registry.ModelInfo) (bool, int64) { - return func(info *registry.ModelInfo) (bool, int64) { - if info == nil || info.Thinking == nil { - return false, 0 - } - if b, ok := util.ThinkingEffortToBudget(info.ID, level); ok { - return true, int64(b) - } - return false, 0 - } - } - - for _, model := range models { + for _, model := range thinkingTestModels { _ = registry.GetGlobalRegistry().GetModelInfo(model) - for _, from := range fromProtocols { + for _, from := range thinkingTestFromProtocols { // Scenario selection follows protocol semantics: // - OpenAI-style protocols (openai/openai-response) express thinking as levels. // - Claude/Gemini-style protocols express thinking as numeric budgets. cases := []scenario{ - {name: "no-suffix", modelSuffix: model, expectFn: func(_ *registry.ModelInfo) (bool, int64) { return false, 0 }}, + {name: "no-suffix", modelSuffix: model}, } if from == "openai" || from == "openai-response" { - // Level-based test cases: auto, none, minimal, low, medium, high, xhigh, foo(invalid) - // Maps to numeric: -1, 0, 512, 1024, 8192, 24576, 32768, invalid - cases = append(cases, - scenario{name: "level-auto", modelSuffix: fmt.Sprintf("%s(auto)", model), expectFn: levelBudgetFn("auto")}, - scenario{name: "level-none", modelSuffix: fmt.Sprintf("%s(none)", model), expectFn: levelBudgetFn("none")}, - scenario{name: "level-minimal", modelSuffix: fmt.Sprintf("%s(minimal)", model), expectFn: levelBudgetFn("minimal")}, - scenario{name: "level-low", modelSuffix: fmt.Sprintf("%s(low)", model), expectFn: levelBudgetFn("low")}, - scenario{name: "level-medium", modelSuffix: fmt.Sprintf("%s(medium)", model), expectFn: levelBudgetFn("medium")}, - scenario{name: "level-high", modelSuffix: fmt.Sprintf("%s(high)", model), expectFn: levelBudgetFn("high")}, - scenario{name: "level-xhigh", modelSuffix: fmt.Sprintf("%s(xhigh)", model), expectFn: levelBudgetFn("xhigh")}, - scenario{name: "level-invalid", modelSuffix: fmt.Sprintf("%s(invalid)", model), expectFn: levelBudgetFn("invalid")}, - ) + for _, lvl := range thinkingLevelSamples { + cases = append(cases, scenario{ + name: "level-" + lvl, + modelSuffix: fmt.Sprintf("%s(%s)", model, lvl), + }) + } } else { // claude or gemini - // Numeric test cases: -1, 0, 1023, 1025, 8193, 24577 - // Maps to levels: auto, none, low, medium, high, xhigh - cases = append(cases, - scenario{name: "numeric-neg1", modelSuffix: fmt.Sprintf("%s(-1)", model), expectFn: buildBudgetFn(-1)}, - scenario{name: "numeric-0", modelSuffix: fmt.Sprintf("%s(0)", model), expectFn: buildBudgetFn(0)}, - scenario{name: "numeric-1023", modelSuffix: fmt.Sprintf("%s(1023)", model), expectFn: buildBudgetFn(1023)}, - scenario{name: "numeric-1025", modelSuffix: fmt.Sprintf("%s(1025)", model), expectFn: buildBudgetFn(1025)}, - scenario{name: "numeric-8193", modelSuffix: fmt.Sprintf("%s(8193)", model), expectFn: buildBudgetFn(8193)}, - scenario{name: "numeric-24577", modelSuffix: fmt.Sprintf("%s(24577)", model), expectFn: buildBudgetFn(24577)}, - ) + for _, budget := range thinkingNumericSamples { + budget := budget + cases = append(cases, scenario{ + name: numericName(budget), + modelSuffix: fmt.Sprintf("%s(%d)", model, budget), + }) + } } - for _, to := range toProtocols { + for _, to := range thinkingTestToProtocols { if from == to { continue } @@ -585,7 +594,7 @@ func buildRawPayloadWithThinking(fromProtocol, model string, thinkingParam any) return []byte(base) case "claude": base := fmt.Sprintf(`{"model":"%s","messages":[{"role":"user","content":"hi"}]}`, model) - if budget, ok := thinkingParam.(int); ok && budget > 0 { + if budget, ok := thinkingParam.(int); ok { base, _ = sjson.Set(base, "thinking.type", "enabled") base, _ = sjson.Set(base, "thinking.budget_tokens", budget) } @@ -636,55 +645,50 @@ func TestRawPayloadThinkingConversions(t *testing.T) { cleanup := registerCoreModels(t) defer cleanup() - models := []string{ - "gpt-5", // supports levels (level-based thinking) - "gemini-2.5-pro", // supports numeric budget - "qwen3-code-plus", // no thinking support - "openai-compat", // openai-compatible channel (allowCompat=true) - } - fromProtocols := []string{"openai", "claude", "gemini", "openai-response"} - toProtocols := []string{"gemini", "claude", "openai", "codex"} - type scenario struct { name string thinkingParam any // int for budget, string for effort level } - for _, model := range models { + numericName := func(budget int) string { + if budget < 0 { + return "budget-neg1" + } + return fmt.Sprintf("budget-%d", budget) + } + + for _, model := range thinkingTestModels { supportsThinking := util.ModelSupportsThinking(model) usesLevels := util.ModelUsesThinkingLevels(model) allowCompat := isOpenAICompatModel(model) - for _, from := range fromProtocols { + for _, from := range thinkingTestFromProtocols { var cases []scenario switch from { case "openai", "openai-response": - // Level-based test cases: auto, none, minimal, low, medium, high, xhigh, foo(invalid) cases = []scenario{ {name: "no-thinking", thinkingParam: nil}, - {name: "effort-auto", thinkingParam: "auto"}, - {name: "effort-none", thinkingParam: "none"}, - {name: "effort-minimal", thinkingParam: "minimal"}, - {name: "effort-low", thinkingParam: "low"}, - {name: "effort-medium", thinkingParam: "medium"}, - {name: "effort-high", thinkingParam: "high"}, - {name: "effort-xhigh", thinkingParam: "xhigh"}, - {name: "effort-invalid", thinkingParam: "invalid"}, + } + for _, lvl := range thinkingLevelSamples { + cases = append(cases, scenario{ + name: "effort-" + lvl, + thinkingParam: lvl, + }) } case "gemini", "claude": - // Numeric test cases: -1, 0, 1023, 1025, 8193, 24577 cases = []scenario{ {name: "no-thinking", thinkingParam: nil}, - {name: "budget-neg1", thinkingParam: -1}, - {name: "budget-0", thinkingParam: 0}, - {name: "budget-1023", thinkingParam: 1023}, - {name: "budget-1025", thinkingParam: 1025}, - {name: "budget-8193", thinkingParam: 8193}, - {name: "budget-24577", thinkingParam: 24577}, + } + for _, budget := range thinkingNumericSamples { + budget := budget + cases = append(cases, scenario{ + name: numericName(budget), + thinkingParam: budget, + }) } } - for _, to := range toProtocols { + for _, to := range thinkingTestToProtocols { if from == to { continue } @@ -727,8 +731,8 @@ func TestRawPayloadThinkingConversions(t *testing.T) { // ThinkingEffortToBudget already returns normalized budget return true, fmt.Sprintf("%d", budget), false } - // Invalid effort maps to default auto (-1) - return true, "-1", false + // Invalid effort does not map to a budget + return false, "", false } return false, "", false case "claude": @@ -758,14 +762,8 @@ func TestRawPayloadThinkingConversions(t *testing.T) { case "openai": if allowCompat { if effort, ok := cs.thinkingParam.(string); ok && strings.TrimSpace(effort) != "" { - // 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 - } + return true, normalized, false } if budget, ok := cs.thinkingParam.(int); ok { if mapped, okM := util.OpenAIThinkingBudgetToEffort(model, budget); okM && mapped != "" { @@ -891,6 +889,7 @@ func TestOpenAIThinkingBudgetToEffortRanges(t *testing.T) { want string ok bool }{ + {name: "dynamic-auto", model: "gpt-5", budget: -1, want: "auto", ok: true}, {name: "zero-none", model: "gpt-5", budget: 0, want: "none", ok: true}, {name: "low-min", model: "gpt-5", budget: 1, want: "low", ok: true}, {name: "low-max", model: "gpt-5", budget: 1024, want: "low", ok: true}, @@ -898,7 +897,7 @@ func TestOpenAIThinkingBudgetToEffortRanges(t *testing.T) { {name: "medium-max", model: "gpt-5", budget: 8192, want: "medium", ok: true}, {name: "high-min", model: "gpt-5", budget: 8193, want: "high", ok: true}, {name: "high-max", model: "gpt-5", budget: 24576, want: "high", ok: true}, - {name: "over-max-clamps-to-highest", model: "gpt-5", budget: 24577, want: "high", ok: true}, + {name: "over-max-clamps-to-highest", model: "gpt-5", budget: 64000, want: "high", ok: true}, {name: "over-max-xhigh-model", model: "gpt-5.2", budget: 50000, want: "xhigh", ok: true}, {name: "negative-unsupported", model: "gpt-5", budget: -5, want: "", ok: false}, } From 367a05bdf63645b678ee91e0a63ad08d0c1d6043 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 14 Dec 2025 20:12:54 +0800 Subject: [PATCH 8/9] refactor(thinking): export thinking helpers Expose thinking/effort normalization helpers from the executor package so conversion tests use production code and stay aligned with runtime validation behavior. --- .../runtime/executor/aistudio_executor.go | 2 +- internal/runtime/executor/codex_executor.go | 14 +- internal/runtime/executor/gemini_executor.go | 6 +- internal/runtime/executor/iflow_executor.go | 12 +- .../executor/openai_compat_executor.go | 12 +- internal/runtime/executor/payload_helpers.go | 32 ++-- internal/runtime/executor/qwen_executor.go | 12 +- test/thinking_conversion_test.go | 177 +----------------- 8 files changed, 55 insertions(+), 212 deletions(-) diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index 221fb648..ada0af39 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -322,7 +322,7 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c from := opts.SourceFormat to := sdktranslator.FromString("gemini") payload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), stream) - payload = applyThinkingMetadata(payload, req.Metadata, req.Model) + payload = ApplyThinkingMetadata(payload, req.Metadata, req.Model) payload = util.ApplyDefaultThinkingIfNeeded(req.Model, payload) payload = util.ConvertThinkingLevelToBudget(payload) payload = util.NormalizeGeminiThinkingBudget(req.Model, payload) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 2e966237..c3e14701 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -54,9 +54,9 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re from := opts.SourceFormat to := sdktranslator.FromString("codex") body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) - body = applyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning.effort", false) - body = normalizeThinkingConfig(body, upstreamModel, false) - if errValidate := validateThinkingConfig(body, upstreamModel); errValidate != nil { + body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning.effort", false) + body = NormalizeThinkingConfig(body, upstreamModel, false) + if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil { return resp, errValidate } body = applyPayloadConfig(e.cfg, req.Model, body) @@ -152,9 +152,9 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au to := sdktranslator.FromString("codex") body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) - body = applyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning.effort", false) - body = normalizeThinkingConfig(body, upstreamModel, false) - if errValidate := validateThinkingConfig(body, upstreamModel); errValidate != nil { + body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning.effort", false) + body = NormalizeThinkingConfig(body, upstreamModel, false) + if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil { return nil, errValidate } body = applyPayloadConfig(e.cfg, req.Model, body) @@ -254,7 +254,7 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth modelForCounting := req.Model - body = applyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning.effort", false) + body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning.effort", false) body, _ = sjson.SetBytes(body, "model", upstreamModel) body, _ = sjson.DeleteBytes(body, "previous_response_id") body, _ = sjson.SetBytes(body, "stream", false) diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index 8dd3dc3b..f211ba62 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -83,7 +83,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r from := opts.SourceFormat to := sdktranslator.FromString("gemini") body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) - body = applyThinkingMetadata(body, req.Metadata, req.Model) + body = ApplyThinkingMetadata(body, req.Metadata, req.Model) body = util.ApplyDefaultThinkingIfNeeded(req.Model, body) body = util.NormalizeGeminiThinkingBudget(req.Model, body) body = util.StripThinkingConfigIfUnsupported(req.Model, body) @@ -178,7 +178,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A from := opts.SourceFormat to := sdktranslator.FromString("gemini") body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) - body = applyThinkingMetadata(body, req.Metadata, req.Model) + body = ApplyThinkingMetadata(body, req.Metadata, req.Model) body = util.ApplyDefaultThinkingIfNeeded(req.Model, body) body = util.NormalizeGeminiThinkingBudget(req.Model, body) body = util.StripThinkingConfigIfUnsupported(req.Model, body) @@ -290,7 +290,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut from := opts.SourceFormat to := sdktranslator.FromString("gemini") translatedReq := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) - translatedReq = applyThinkingMetadata(translatedReq, req.Metadata, req.Model) + translatedReq = ApplyThinkingMetadata(translatedReq, req.Metadata, req.Model) translatedReq = util.StripThinkingConfigIfUnsupported(req.Model, translatedReq) translatedReq = fixGeminiImageAspectRatio(req.Model, translatedReq) respCtx := context.WithValue(ctx, "alt", opts.Alt) diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go index 03df1be0..ad0b4d2a 100644 --- a/internal/runtime/executor/iflow_executor.go +++ b/internal/runtime/executor/iflow_executor.go @@ -57,13 +57,13 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re from := opts.SourceFormat to := sdktranslator.FromString("openai") body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) - body = applyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false) + body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false) upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata) if upstreamModel != "" { body, _ = sjson.SetBytes(body, "model", upstreamModel) } - body = normalizeThinkingConfig(body, upstreamModel, false) - if errValidate := validateThinkingConfig(body, upstreamModel); errValidate != nil { + body = NormalizeThinkingConfig(body, upstreamModel, false) + if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil { return resp, errValidate } body = applyPayloadConfig(e.cfg, req.Model, body) @@ -148,13 +148,13 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au to := sdktranslator.FromString("openai") body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) - body = applyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false) + body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false) upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata) if upstreamModel != "" { body, _ = sjson.SetBytes(body, "model", upstreamModel) } - body = normalizeThinkingConfig(body, upstreamModel, false) - if errValidate := validateThinkingConfig(body, upstreamModel); errValidate != nil { + body = NormalizeThinkingConfig(body, upstreamModel, false) + if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil { return nil, errValidate } // Ensure tools array exists to avoid provider quirks similar to Qwen's behaviour. diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index 1bbd0c8e..1c57c9b7 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -60,13 +60,13 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A } translated = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", translated) allowCompat := e.allowCompatReasoningEffort(req.Model, auth) - translated = applyReasoningEffortMetadata(translated, req.Metadata, req.Model, "reasoning_effort", allowCompat) + translated = ApplyReasoningEffortMetadata(translated, req.Metadata, req.Model, "reasoning_effort", allowCompat) upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata) if upstreamModel != "" && modelOverride == "" { translated, _ = sjson.SetBytes(translated, "model", upstreamModel) } - translated = normalizeThinkingConfig(translated, upstreamModel, allowCompat) - if errValidate := validateThinkingConfig(translated, upstreamModel); errValidate != nil { + translated = NormalizeThinkingConfig(translated, upstreamModel, allowCompat) + if errValidate := ValidateThinkingConfig(translated, upstreamModel); errValidate != nil { return resp, errValidate } @@ -156,13 +156,13 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy } translated = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", translated) allowCompat := e.allowCompatReasoningEffort(req.Model, auth) - translated = applyReasoningEffortMetadata(translated, req.Metadata, req.Model, "reasoning_effort", allowCompat) + translated = ApplyReasoningEffortMetadata(translated, req.Metadata, req.Model, "reasoning_effort", allowCompat) upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata) if upstreamModel != "" && modelOverride == "" { translated, _ = sjson.SetBytes(translated, "model", upstreamModel) } - translated = normalizeThinkingConfig(translated, upstreamModel, allowCompat) - if errValidate := validateThinkingConfig(translated, upstreamModel); errValidate != nil { + translated = NormalizeThinkingConfig(translated, upstreamModel, allowCompat) + if errValidate := ValidateThinkingConfig(translated, upstreamModel); errValidate != nil { return nil, errValidate } diff --git a/internal/runtime/executor/payload_helpers.go b/internal/runtime/executor/payload_helpers.go index 667f29da..b0eafbb7 100644 --- a/internal/runtime/executor/payload_helpers.go +++ b/internal/runtime/executor/payload_helpers.go @@ -11,9 +11,9 @@ import ( "github.com/tidwall/sjson" ) -// applyThinkingMetadata applies thinking config from model suffix metadata (e.g., (high), (8192)) +// ApplyThinkingMetadata applies thinking config from model suffix metadata (e.g., (high), (8192)) // for standard Gemini format payloads. It normalizes the budget when the model supports thinking. -func applyThinkingMetadata(payload []byte, metadata map[string]any, model string) []byte { +func ApplyThinkingMetadata(payload []byte, metadata map[string]any, model string) []byte { budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(model, metadata) if !ok || (budgetOverride == nil && includeOverride == nil) { return payload @@ -45,10 +45,10 @@ func applyThinkingMetadataCLI(payload []byte, metadata map[string]any, model str return util.ApplyGeminiCLIThinkingConfig(payload, budgetOverride, includeOverride) } -// applyReasoningEffortMetadata applies reasoning effort overrides from metadata to the given JSON path. +// ApplyReasoningEffortMetadata applies reasoning effort overrides from metadata to the given JSON path. // Metadata values take precedence over any existing field when the model supports thinking, intentionally // overwriting caller-provided values to honor suffix/default metadata priority. -func applyReasoningEffortMetadata(payload []byte, metadata map[string]any, model, field string, allowCompat bool) []byte { +func ApplyReasoningEffortMetadata(payload []byte, metadata map[string]any, model, field string, allowCompat bool) []byte { if len(metadata) == 0 { return payload } @@ -75,7 +75,7 @@ func applyReasoningEffortMetadata(payload []byte, metadata map[string]any, model if effort, ok := util.OpenAIThinkingBudgetToEffort(baseModel, *budget); ok && effort != "" { if *budget == 0 && effort == "none" && util.ModelUsesThinkingLevels(baseModel) { if _, supported := util.NormalizeReasoningEffortLevel(baseModel, effort); !supported { - return stripThinkingFields(payload, false) + return StripThinkingFields(payload, false) } } @@ -238,12 +238,12 @@ func matchModelPattern(pattern, model string) bool { return pi == len(pattern) } -// normalizeThinkingConfig normalizes thinking-related fields in the payload +// NormalizeThinkingConfig normalizes thinking-related fields in the payload // based on model capabilities. For models without thinking support, it strips // reasoning fields. For models with level-based thinking, it validates and // normalizes the reasoning effort level. For models with numeric budget thinking, // it strips the effort string fields. -func normalizeThinkingConfig(payload []byte, model string, allowCompat bool) []byte { +func NormalizeThinkingConfig(payload []byte, model string, allowCompat bool) []byte { if len(payload) == 0 || model == "" { return payload } @@ -252,22 +252,22 @@ func normalizeThinkingConfig(payload []byte, model string, allowCompat bool) []b if allowCompat { return payload } - return stripThinkingFields(payload, false) + return StripThinkingFields(payload, false) } if util.ModelUsesThinkingLevels(model) { - return normalizeReasoningEffortLevel(payload, model) + return NormalizeReasoningEffortLevel(payload, model) } // Model supports thinking but uses numeric budgets, not levels. // Strip effort string fields since they are not applicable. - return stripThinkingFields(payload, true) + return StripThinkingFields(payload, true) } -// stripThinkingFields removes thinking-related fields from the payload for +// StripThinkingFields removes thinking-related fields from the payload for // models that do not support thinking. If effortOnly is true, only removes // effort string fields (for models using numeric budgets). -func stripThinkingFields(payload []byte, effortOnly bool) []byte { +func StripThinkingFields(payload []byte, effortOnly bool) []byte { fieldsToRemove := []string{ "reasoning_effort", "reasoning.effort", @@ -284,9 +284,9 @@ func stripThinkingFields(payload []byte, effortOnly bool) []byte { return out } -// normalizeReasoningEffortLevel validates and normalizes the reasoning_effort +// NormalizeReasoningEffortLevel validates and normalizes the reasoning_effort // or reasoning.effort field for level-based thinking models. -func normalizeReasoningEffortLevel(payload []byte, model string) []byte { +func NormalizeReasoningEffortLevel(payload []byte, model string) []byte { out := payload if effort := gjson.GetBytes(out, "reasoning_effort"); effort.Exists() { @@ -304,10 +304,10 @@ func normalizeReasoningEffortLevel(payload []byte, model string) []byte { return out } -// validateThinkingConfig checks for unsupported reasoning levels on level-based models. +// ValidateThinkingConfig checks for unsupported reasoning levels on level-based models. // Returns a statusErr with 400 when an unsupported level is supplied to avoid silently // downgrading requests. -func validateThinkingConfig(payload []byte, model string) error { +func ValidateThinkingConfig(payload []byte, model string) error { if len(payload) == 0 || model == "" { return nil } diff --git a/internal/runtime/executor/qwen_executor.go b/internal/runtime/executor/qwen_executor.go index 3bd61021..1d4ef52d 100644 --- a/internal/runtime/executor/qwen_executor.go +++ b/internal/runtime/executor/qwen_executor.go @@ -51,13 +51,13 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req from := opts.SourceFormat to := sdktranslator.FromString("openai") body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) - body = applyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false) + body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false) upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata) if upstreamModel != "" { body, _ = sjson.SetBytes(body, "model", upstreamModel) } - body = normalizeThinkingConfig(body, upstreamModel, false) - if errValidate := validateThinkingConfig(body, upstreamModel); errValidate != nil { + body = NormalizeThinkingConfig(body, upstreamModel, false) + if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil { return resp, errValidate } body = applyPayloadConfig(e.cfg, req.Model, body) @@ -131,13 +131,13 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut to := sdktranslator.FromString("openai") body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) - body = applyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false) + body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false) upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata) if upstreamModel != "" { body, _ = sjson.SetBytes(body, "model", upstreamModel) } - body = normalizeThinkingConfig(body, upstreamModel, false) - if errValidate := validateThinkingConfig(body, upstreamModel); errValidate != nil { + body = NormalizeThinkingConfig(body, upstreamModel, false) + if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil { return nil, errValidate } toolsResult := gjson.GetBytes(body, "tools") diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 9d15e128..6d156954 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -2,7 +2,6 @@ package test import ( "fmt" - "net/http" "strings" "testing" "time" @@ -10,20 +9,13 @@ import ( _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) -// statusErr mirrors executor.statusErr to keep validation behavior aligned. -type statusErr struct { - code int - msg string -} - -func (e statusErr) Error() string { return e.msg } - // isOpenAICompatModel returns true if the model is configured as an OpenAI-compatible // model that should have reasoning effort passed through even if not in registry. // This simulates the allowCompat behavior from OpenAICompatExecutor. @@ -108,159 +100,10 @@ func buildRawPayload(fromProtocol, modelWithSuffix string) []byte { } } -// applyThinkingMetadataLocal mirrors executor.applyThinkingMetadata. -func applyThinkingMetadataLocal(payload []byte, metadata map[string]any, model string) []byte { - budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(model, metadata) - if !ok || (budgetOverride == nil && includeOverride == nil) { - return payload - } - if !util.ModelSupportsThinking(model) { - return payload - } - if budgetOverride != nil { - norm := util.NormalizeThinkingBudget(model, *budgetOverride) - budgetOverride = &norm - } - return util.ApplyGeminiThinkingConfig(payload, budgetOverride, includeOverride) -} - -// applyReasoningEffortMetadataLocal mirrors executor.applyReasoningEffortMetadata. -func applyReasoningEffortMetadataLocal(payload []byte, metadata map[string]any, model, field string, allowCompat bool) []byte { - if len(metadata) == 0 { - return payload - } - if field == "" { - return payload - } - baseModel := util.ResolveOriginalModel(model, metadata) - if baseModel == "" { - baseModel = model - } - if !util.ModelSupportsThinking(baseModel) && !allowCompat { - return payload - } - if effort, ok := util.ReasoningEffortFromMetadata(metadata); ok && effort != "" { - if util.ModelUsesThinkingLevels(baseModel) || allowCompat { - if updated, err := sjson.SetBytes(payload, field, effort); err == nil { - return updated - } - } - } - // Fallback: numeric thinking_budget suffix for level-based (OpenAI-style) models. - if util.ModelUsesThinkingLevels(baseModel) || allowCompat { - if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil { - if effort, ok := util.OpenAIThinkingBudgetToEffort(baseModel, *budget); ok && effort != "" { - if *budget == 0 && effort == "none" && util.ModelUsesThinkingLevels(baseModel) { - if _, supported := util.NormalizeReasoningEffortLevel(baseModel, effort); !supported { - return stripThinkingFieldsLocal(payload, false) - } - } - - if updated, err := sjson.SetBytes(payload, field, effort); err == nil { - return updated - } - } - } - } - return payload -} - -// normalizeThinkingConfigLocal mirrors executor.normalizeThinkingConfig. -// When allowCompat is true, reasoning fields are preserved even for models -// without thinking support (simulating openai-compat passthrough behavior). -func normalizeThinkingConfigLocal(payload []byte, model string, allowCompat bool) []byte { - if len(payload) == 0 || model == "" { - return payload - } - - if !util.ModelSupportsThinking(model) { - if allowCompat { - return payload - } - return stripThinkingFieldsLocal(payload, false) - } - - if util.ModelUsesThinkingLevels(model) { - return normalizeReasoningEffortLevelLocal(payload, model) - } - - // Model supports thinking but uses numeric budgets, not levels. - // Strip effort string fields since they are not applicable. - return stripThinkingFieldsLocal(payload, true) -} - -// stripThinkingFieldsLocal mirrors executor.stripThinkingFields. -func stripThinkingFieldsLocal(payload []byte, effortOnly bool) []byte { - fieldsToRemove := []string{ - "reasoning_effort", - "reasoning.effort", - } - if !effortOnly { - fieldsToRemove = append([]string{"reasoning"}, fieldsToRemove...) - } - out := payload - for _, field := range fieldsToRemove { - if gjson.GetBytes(out, field).Exists() { - out, _ = sjson.DeleteBytes(out, field) - } - } - return out -} - -// normalizeReasoningEffortLevelLocal mirrors executor.normalizeReasoningEffortLevel. -func normalizeReasoningEffortLevelLocal(payload []byte, model string) []byte { - out := payload - - if effort := gjson.GetBytes(out, "reasoning_effort"); effort.Exists() { - if normalized, ok := util.NormalizeReasoningEffortLevel(model, effort.String()); ok { - out, _ = sjson.SetBytes(out, "reasoning_effort", normalized) - } - } - - if effort := gjson.GetBytes(out, "reasoning.effort"); effort.Exists() { - if normalized, ok := util.NormalizeReasoningEffortLevel(model, effort.String()); ok { - out, _ = sjson.SetBytes(out, "reasoning.effort", normalized) - } - } - - return out -} - -// validateThinkingConfigLocal mirrors executor.validateThinkingConfig. -func validateThinkingConfigLocal(payload []byte, model string) error { - if len(payload) == 0 || model == "" { - return nil - } - if !util.ModelSupportsThinking(model) || !util.ModelUsesThinkingLevels(model) { - return nil - } - - levels := util.GetModelThinkingLevels(model) - checkField := func(path string) error { - if effort := gjson.GetBytes(payload, path); effort.Exists() { - if _, ok := util.NormalizeReasoningEffortLevel(model, effort.String()); !ok { - return statusErr{ - code: http.StatusBadRequest, - msg: fmt.Sprintf("unsupported reasoning effort level %q for model %s (supported: %s)", effort.String(), model, strings.Join(levels, ", ")), - } - } - } - return nil - } - - if err := checkField("reasoning_effort"); err != nil { - return err - } - if err := checkField("reasoning.effort"); err != nil { - return err - } - return nil -} - // normalizeCodexPayload mirrors codex_executor's reasoning + streaming tweaks. func normalizeCodexPayload(body []byte, upstreamModel string, allowCompat bool) ([]byte, error) { - body = normalizeThinkingConfigLocal(body, upstreamModel, allowCompat) - if err := validateThinkingConfigLocal(body, upstreamModel); err != nil { + body = executor.NormalizeThinkingConfig(body, upstreamModel, allowCompat) + if err := executor.ValidateThinkingConfig(body, upstreamModel); err != nil { return body, err } body, _ = sjson.SetBytes(body, "model", upstreamModel) @@ -290,7 +133,7 @@ func buildBodyForProtocol(t *testing.T, fromProtocol, toProtocol, modelWithSuffi allowCompat := isOpenAICompatModel(normalizedModel) switch toProtocol { case "gemini": - body = applyThinkingMetadataLocal(body, metadata, normalizedModel) + body = executor.ApplyThinkingMetadata(body, metadata, normalizedModel) body = util.ApplyDefaultThinkingIfNeeded(normalizedModel, body) body = util.NormalizeGeminiThinkingBudget(normalizedModel, body) body = util.StripThinkingConfigIfUnsupported(normalizedModel, body) @@ -299,12 +142,12 @@ func buildBodyForProtocol(t *testing.T, fromProtocol, toProtocol, modelWithSuffi body = util.ApplyClaudeThinkingConfig(body, budget) } case "openai": - body = applyReasoningEffortMetadataLocal(body, metadata, normalizedModel, "reasoning_effort", allowCompat) - body = normalizeThinkingConfigLocal(body, upstreamModel, allowCompat) - err = validateThinkingConfigLocal(body, upstreamModel) + body = executor.ApplyReasoningEffortMetadata(body, metadata, normalizedModel, "reasoning_effort", allowCompat) + body = executor.NormalizeThinkingConfig(body, upstreamModel, allowCompat) + err = executor.ValidateThinkingConfig(body, upstreamModel) case "codex": // OpenAI responses / codex // Codex does not support allowCompat; always use false. - body = applyReasoningEffortMetadataLocal(body, metadata, normalizedModel, "reasoning.effort", false) + body = executor.ApplyReasoningEffortMetadata(body, metadata, normalizedModel, "reasoning.effort", false) // Mirror CodexExecutor final normalization and model override so tests log the final body. body, err = normalizeCodexPayload(body, upstreamModel, false) default: @@ -629,8 +472,8 @@ func buildBodyForProtocolWithRawThinking(t *testing.T, fromProtocol, toProtocol, // For raw payload, Claude thinking is passed through by translator // No additional processing needed as thinking is already in body case "openai": - body = normalizeThinkingConfigLocal(body, model, allowCompat) - err = validateThinkingConfigLocal(body, model) + body = executor.NormalizeThinkingConfig(body, model, allowCompat) + err = executor.ValidateThinkingConfig(body, model) case "codex": // Codex does not support allowCompat; always use false. body, err = normalizeCodexPayload(body, model, false) From 09c339953dd08b4fec070b5a6434266631eaa5ba Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 14 Dec 2025 20:30:26 +0800 Subject: [PATCH 9/9] fix(openai): forward reasoning.effort value Drop the hardcoded effort mapping in request conversion so unknown values are preserved instead of being coerced to `auto --- .../openai_openai-responses_request.go | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index 8bac0485..f8bcb7b1 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -2,6 +2,7 @@ package responses import ( "bytes" + "strings" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -189,23 +190,9 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu } if reasoningEffort := root.Get("reasoning.effort"); reasoningEffort.Exists() { - switch reasoningEffort.String() { - case "none": - out, _ = sjson.Set(out, "reasoning_effort", "none") - case "auto": - out, _ = sjson.Set(out, "reasoning_effort", "auto") - case "minimal": - out, _ = sjson.Set(out, "reasoning_effort", "minimal") - case "low": - out, _ = sjson.Set(out, "reasoning_effort", "low") - case "medium": - out, _ = sjson.Set(out, "reasoning_effort", "medium") - case "high": - out, _ = sjson.Set(out, "reasoning_effort", "high") - case "xhigh": - out, _ = sjson.Set(out, "reasoning_effort", "xhigh") - default: - out, _ = sjson.Set(out, "reasoning_effort", "auto") + effort := strings.ToLower(strings.TrimSpace(reasoningEffort.String())) + if effort != "" { + out, _ = sjson.Set(out, "reasoning_effort", effort) } }