From c44793789bef4462a323e29f558e3dec89bad40c Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:05:31 +0800 Subject: [PATCH 1/7] feat(thinking): add adaptive thinking support for Claude models Add support for Claude's "adaptive" and "auto" thinking modes using `output_config.effort`. Introduce support for new effort level "max" in adaptive thinking. Update thinking logic, validate model capabilities, and extend converters and handling to ensure compatibility with adaptive modes. Adjust static model data with supported levels and refine handling across translators and executors. --- .../registry/model_definitions_static_data.go | 4 +- internal/runtime/executor/claude_executor.go | 6 + internal/thinking/apply.go | 20 +++ internal/thinking/convert.go | 4 + internal/thinking/provider/claude/apply.go | 142 +++++++++++++++--- internal/thinking/strip.go | 9 +- internal/thinking/suffix.go | 4 +- internal/thinking/types.go | 3 + internal/thinking/validate.go | 2 +- .../chat-completions/claude_openai_request.go | 63 +++++++- .../claude_openai-responses_request.go | 63 +++++++- .../codex/claude/codex_claude_request.go | 19 ++- .../openai/claude/openai_claude_request.go | 19 ++- 13 files changed, 310 insertions(+), 48 deletions(-) diff --git a/internal/registry/model_definitions_static_data.go b/internal/registry/model_definitions_static_data.go index f70d39846..dcf5debfa 100644 --- a/internal/registry/model_definitions_static_data.go +++ b/internal/registry/model_definitions_static_data.go @@ -37,7 +37,7 @@ func GetClaudeModels() []*ModelInfo { DisplayName: "Claude 4.6 Sonnet", ContextLength: 200000, MaxCompletionTokens: 64000, - Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false}, + Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{"low", "medium", "high"}}, }, { ID: "claude-opus-4-6", @@ -49,7 +49,7 @@ func GetClaudeModels() []*ModelInfo { Description: "Premium model combining maximum intelligence with practical performance", ContextLength: 1000000, MaxCompletionTokens: 128000, - Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false}, + Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{"low", "medium", "high", "max"}}, }, { ID: "claude-opus-4-5-20251101", diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 75ea04e13..805d31ddf 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -634,6 +634,12 @@ func disableThinkingIfToolChoiceForced(body []byte) []byte { if toolChoiceType == "any" || toolChoiceType == "tool" { // Remove thinking configuration entirely to avoid API error body, _ = sjson.DeleteBytes(body, "thinking") + // Adaptive thinking may also set output_config.effort; remove it to avoid + // leaking thinking controls when tool_choice forces tool use. + body, _ = sjson.DeleteBytes(body, "output_config.effort") + if oc := gjson.GetBytes(body, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { + body, _ = sjson.DeleteBytes(body, "output_config") + } } return body } diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index 8a5a1d7d2..16f1a2f9c 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -353,6 +353,26 @@ func extractClaudeConfig(body []byte) ThinkingConfig { if thinkingType == "disabled" { return ThinkingConfig{Mode: ModeNone, Budget: 0} } + if thinkingType == "adaptive" || thinkingType == "auto" { + // Claude adaptive thinking uses output_config.effort (low/medium/high/max). + // We only treat it as a thinking config when effort is explicitly present; + // otherwise we passthrough and let upstream defaults apply. + if effort := gjson.GetBytes(body, "output_config.effort"); effort.Exists() && effort.Type == gjson.String { + value := strings.ToLower(strings.TrimSpace(effort.String())) + if value == "" { + return ThinkingConfig{} + } + switch value { + case "none": + return ThinkingConfig{Mode: ModeNone, Budget: 0} + case "auto": + return ThinkingConfig{Mode: ModeAuto, Budget: -1} + default: + return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)} + } + } + return ThinkingConfig{} + } // Check budget_tokens if budget := gjson.GetBytes(body, "thinking.budget_tokens"); budget.Exists() { diff --git a/internal/thinking/convert.go b/internal/thinking/convert.go index 776ccef60..8374ddbb0 100644 --- a/internal/thinking/convert.go +++ b/internal/thinking/convert.go @@ -16,6 +16,9 @@ var levelToBudgetMap = map[string]int{ "medium": 8192, "high": 24576, "xhigh": 32768, + // "max" is used by Claude adaptive thinking effort. We map it to a large budget + // and rely on per-model clamping when converting to budget-only providers. + "max": 128000, } // ConvertLevelToBudget converts a thinking level to a budget value. @@ -31,6 +34,7 @@ var levelToBudgetMap = map[string]int{ // - medium → 8192 // - high → 24576 // - xhigh → 32768 +// - max → 128000 // // Returns: // - budget: The converted budget value diff --git a/internal/thinking/provider/claude/apply.go b/internal/thinking/provider/claude/apply.go index 3c74d5146..275be4692 100644 --- a/internal/thinking/provider/claude/apply.go +++ b/internal/thinking/provider/claude/apply.go @@ -1,8 +1,10 @@ // Package claude implements thinking configuration scaffolding for Claude models. // -// Claude models use the thinking.budget_tokens format with values in the range -// 1024-128000. Some Claude models support ZeroAllowed (sonnet-4-5, opus-4-5), -// while older models do not. +// Claude models support two thinking control styles: +// - Manual thinking: thinking.type="enabled" with thinking.budget_tokens (token budget) +// - Adaptive thinking (Claude 4.6): thinking.type="adaptive" with output_config.effort (low/medium/high/max) +// +// Some Claude models support ZeroAllowed (sonnet-4-5, opus-4-5), while older models do not. // See: _bmad-output/planning-artifacts/architecture.md#Epic-6 package claude @@ -34,7 +36,11 @@ func init() { // - Budget clamping to model range // - ZeroAllowed constraint enforcement // -// Apply only processes ModeBudget and ModeNone; other modes are passed through unchanged. +// Apply processes: +// - ModeBudget: manual thinking budget_tokens +// - ModeLevel: adaptive thinking effort (Claude 4.6) +// - ModeAuto: provider default adaptive/manual behavior +// - ModeNone: disabled // // Expected output format when enabled: // @@ -45,6 +51,17 @@ func init() { // } // } // +// Expected output format for adaptive: +// +// { +// "thinking": { +// "type": "adaptive" +// }, +// "output_config": { +// "effort": "high" +// } +// } +// // Expected output format when disabled: // // { @@ -60,30 +77,91 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * return body, nil } - // Only process ModeBudget and ModeNone; other modes pass through - // (caller should use ValidateConfig first to normalize modes) - if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeNone { - return body, nil - } - if len(body) == 0 || !gjson.ValidBytes(body) { body = []byte(`{}`) } - // Budget is expected to be pre-validated by ValidateConfig (clamped, ZeroAllowed enforced) - // Decide enabled/disabled based on budget value - if config.Budget == 0 { + supportsAdaptive := modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) > 0 + + switch config.Mode { + case thinking.ModeNone: result, _ := sjson.SetBytes(body, "thinking.type", "disabled") result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") + result, _ = sjson.DeleteBytes(result, "output_config.effort") + if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { + result, _ = sjson.DeleteBytes(result, "output_config") + } return result, nil + + case thinking.ModeLevel: + // Adaptive thinking effort is only valid when the model advertises discrete levels. + // (Claude 4.6 uses output_config.effort.) + if supportsAdaptive && config.Level != "" { + result, _ := sjson.SetBytes(body, "thinking.type", "adaptive") + result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") + result, _ = sjson.SetBytes(result, "output_config.effort", string(config.Level)) + return result, nil + } + + // Fallback for non-adaptive Claude models: convert level to budget_tokens. + if budget, ok := thinking.ConvertLevelToBudget(string(config.Level)); ok { + config.Mode = thinking.ModeBudget + config.Budget = budget + config.Level = "" + } else { + return body, nil + } + fallthrough + + case thinking.ModeBudget: + // Budget is expected to be pre-validated by ValidateConfig (clamped, ZeroAllowed enforced). + // Decide enabled/disabled based on budget value. + if config.Budget == 0 { + result, _ := sjson.SetBytes(body, "thinking.type", "disabled") + result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") + result, _ = sjson.DeleteBytes(result, "output_config.effort") + if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { + result, _ = sjson.DeleteBytes(result, "output_config") + } + return result, nil + } + + result, _ := sjson.SetBytes(body, "thinking.type", "enabled") + result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget) + result, _ = sjson.DeleteBytes(result, "output_config.effort") + if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { + result, _ = sjson.DeleteBytes(result, "output_config") + } + + // Ensure max_tokens > thinking.budget_tokens (Anthropic API constraint). + result = a.normalizeClaudeBudget(result, config.Budget, modelInfo) + return result, nil + + case thinking.ModeAuto: + // For Claude 4.6 models, auto maps to adaptive thinking with upstream defaults. + if supportsAdaptive { + result, _ := sjson.SetBytes(body, "thinking.type", "adaptive") + result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") + // Explicit effort is optional for adaptive thinking; omit it to allow upstream default. + result, _ = sjson.DeleteBytes(result, "output_config.effort") + if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { + result, _ = sjson.DeleteBytes(result, "output_config") + } + return result, nil + } + + // Legacy fallback: enable thinking without specifying budget_tokens. + result, _ := sjson.SetBytes(body, "thinking.type", "enabled") + result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") + result, _ = sjson.DeleteBytes(result, "output_config.effort") + if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { + result, _ = sjson.DeleteBytes(result, "output_config") + } + return result, nil + + default: + return body, nil } - - result, _ := sjson.SetBytes(body, "thinking.type", "enabled") - result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget) - - // Ensure max_tokens > thinking.budget_tokens (Anthropic API constraint) - result = a.normalizeClaudeBudget(result, config.Budget, modelInfo) - return result, nil } // normalizeClaudeBudget applies Claude-specific constraints to ensure max_tokens > budget_tokens. @@ -141,7 +219,7 @@ func (a *Applier) effectiveMaxTokens(body []byte, modelInfo *registry.ModelInfo) } func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte, error) { - if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto { + if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto && config.Mode != thinking.ModeLevel { return body, nil } @@ -153,14 +231,36 @@ func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte, case thinking.ModeNone: result, _ := sjson.SetBytes(body, "thinking.type", "disabled") result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") + result, _ = sjson.DeleteBytes(result, "output_config.effort") + if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { + result, _ = sjson.DeleteBytes(result, "output_config") + } return result, nil case thinking.ModeAuto: result, _ := sjson.SetBytes(body, "thinking.type", "enabled") result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") + result, _ = sjson.DeleteBytes(result, "output_config.effort") + if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { + result, _ = sjson.DeleteBytes(result, "output_config") + } + return result, nil + case thinking.ModeLevel: + // For user-defined models, interpret ModeLevel as Claude adaptive thinking effort. + // Upstream is responsible for validating whether the target model supports it. + if config.Level == "" { + return body, nil + } + result, _ := sjson.SetBytes(body, "thinking.type", "adaptive") + result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") + result, _ = sjson.SetBytes(result, "output_config.effort", string(config.Level)) return result, nil default: result, _ := sjson.SetBytes(body, "thinking.type", "enabled") result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget) + result, _ = sjson.DeleteBytes(result, "output_config.effort") + if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { + result, _ = sjson.DeleteBytes(result, "output_config") + } return result, nil } } diff --git a/internal/thinking/strip.go b/internal/thinking/strip.go index 514ab3f86..85498c010 100644 --- a/internal/thinking/strip.go +++ b/internal/thinking/strip.go @@ -30,7 +30,7 @@ func StripThinkingConfig(body []byte, provider string) []byte { var paths []string switch provider { case "claude": - paths = []string{"thinking"} + paths = []string{"thinking", "output_config.effort"} case "gemini": paths = []string{"generationConfig.thinkingConfig"} case "gemini-cli", "antigravity": @@ -59,5 +59,12 @@ func StripThinkingConfig(body []byte, provider string) []byte { for _, path := range paths { result, _ = sjson.DeleteBytes(result, path) } + + // Avoid leaving an empty output_config object for Claude when effort was the only field. + if provider == "claude" { + if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { + result, _ = sjson.DeleteBytes(result, "output_config") + } + } return result } diff --git a/internal/thinking/suffix.go b/internal/thinking/suffix.go index 275c08568..7f2959da5 100644 --- a/internal/thinking/suffix.go +++ b/internal/thinking/suffix.go @@ -109,7 +109,7 @@ func ParseSpecialSuffix(rawSuffix string) (mode ThinkingMode, ok bool) { // ParseLevelSuffix attempts to parse a raw suffix as a discrete thinking level. // // This function parses the raw suffix content (from ParseSuffix.RawSuffix) as a level. -// Only discrete effort levels are valid: minimal, low, medium, high, xhigh. +// Only discrete effort levels are valid: minimal, low, medium, high, xhigh, max. // Level matching is case-insensitive. // // Special values (none, auto) are NOT handled by this function; use ParseSpecialSuffix @@ -140,6 +140,8 @@ func ParseLevelSuffix(rawSuffix string) (level ThinkingLevel, ok bool) { return LevelHigh, true case "xhigh": return LevelXHigh, true + case "max": + return LevelMax, true default: return "", false } diff --git a/internal/thinking/types.go b/internal/thinking/types.go index 6ae1e088f..5e45fc6b1 100644 --- a/internal/thinking/types.go +++ b/internal/thinking/types.go @@ -54,6 +54,9 @@ const ( LevelHigh ThinkingLevel = "high" // LevelXHigh sets extra-high thinking effort LevelXHigh ThinkingLevel = "xhigh" + // LevelMax sets maximum thinking effort. + // This is currently used by Claude 4.6 adaptive thinking (opus supports "max"). + LevelMax ThinkingLevel = "max" ) // ThinkingConfig represents a unified thinking configuration. diff --git a/internal/thinking/validate.go b/internal/thinking/validate.go index f082ad565..7f5c57c51 100644 --- a/internal/thinking/validate.go +++ b/internal/thinking/validate.go @@ -201,7 +201,7 @@ func convertAutoToMidRange(config ThinkingConfig, support *registry.ThinkingSupp } // standardLevelOrder defines the canonical ordering of thinking levels from lowest to highest. -var standardLevelOrder = []ThinkingLevel{LevelMinimal, LevelLow, LevelMedium, LevelHigh, LevelXHigh} +var standardLevelOrder = []ThinkingLevel{LevelMinimal, LevelLow, LevelMedium, LevelHigh, LevelXHigh, LevelMax} // clampLevel clamps the given level to the nearest supported level. // On tie, prefers the lower level. 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 f94825b2a..7155d1e07 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -14,6 +14,7 @@ import ( "strings" "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -68,17 +69,63 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream if v := root.Get("reasoning_effort"); v.Exists() { effort := strings.ToLower(strings.TrimSpace(v.String())) if effort != "" { - budget, ok := thinking.ConvertLevelToBudget(effort) - if ok { - switch budget { - case 0: + hasLevel := func(levels []string, target string) bool { + for _, level := range levels { + if strings.EqualFold(strings.TrimSpace(level), target) { + return true + } + } + return false + } + mi := registry.LookupModelInfo(modelName, "claude") + supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0 + supportsMax := supportsAdaptive && hasLevel(mi.Thinking.Levels, "max") + + // Claude 4.6 supports adaptive thinking with output_config.effort. + if supportsAdaptive { + switch effort { + case "none": out, _ = sjson.Set(out, "thinking.type", "disabled") - case -1: - out, _ = sjson.Set(out, "thinking.type", "enabled") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + out, _ = sjson.Delete(out, "output_config.effort") + case "auto": + out, _ = sjson.Set(out, "thinking.type", "adaptive") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + out, _ = sjson.Delete(out, "output_config.effort") default: - if budget > 0 { + // Map non-Claude effort levels into Claude 4.6 effort vocabulary. + switch effort { + case "minimal": + effort = "low" + case "xhigh": + if supportsMax { + effort = "max" + } else { + effort = "high" + } + case "max": + if !supportsMax { + effort = "high" + } + } + out, _ = sjson.Set(out, "thinking.type", "adaptive") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + out, _ = sjson.Set(out, "output_config.effort", effort) + } + } else { + // Legacy/manual thinking (budget_tokens). + budget, ok := thinking.ConvertLevelToBudget(effort) + if ok { + switch budget { + case 0: + out, _ = sjson.Set(out, "thinking.type", "disabled") + case -1: out, _ = sjson.Set(out, "thinking.type", "enabled") - out, _ = sjson.Set(out, "thinking.budget_tokens", budget) + 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 33a811245..cd1b88852 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -56,17 +57,63 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte if v := root.Get("reasoning.effort"); v.Exists() { effort := strings.ToLower(strings.TrimSpace(v.String())) if effort != "" { - budget, ok := thinking.ConvertLevelToBudget(effort) - if ok { - switch budget { - case 0: + hasLevel := func(levels []string, target string) bool { + for _, level := range levels { + if strings.EqualFold(strings.TrimSpace(level), target) { + return true + } + } + return false + } + mi := registry.LookupModelInfo(modelName, "claude") + supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0 + supportsMax := supportsAdaptive && hasLevel(mi.Thinking.Levels, "max") + + // Claude 4.6 supports adaptive thinking with output_config.effort. + if supportsAdaptive { + switch effort { + case "none": out, _ = sjson.Set(out, "thinking.type", "disabled") - case -1: - out, _ = sjson.Set(out, "thinking.type", "enabled") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + out, _ = sjson.Delete(out, "output_config.effort") + case "auto": + out, _ = sjson.Set(out, "thinking.type", "adaptive") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + out, _ = sjson.Delete(out, "output_config.effort") default: - if budget > 0 { + // Map non-Claude effort levels into Claude 4.6 effort vocabulary. + switch effort { + case "minimal": + effort = "low" + case "xhigh": + if supportsMax { + effort = "max" + } else { + effort = "high" + } + case "max": + if !supportsMax { + effort = "high" + } + } + out, _ = sjson.Set(out, "thinking.type", "adaptive") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + out, _ = sjson.Set(out, "output_config.effort", effort) + } + } else { + // Legacy/manual thinking (budget_tokens). + budget, ok := thinking.ConvertLevelToBudget(effort) + if ok { + switch budget { + case 0: + out, _ = sjson.Set(out, "thinking.type", "disabled") + case -1: out, _ = sjson.Set(out, "thinking.type", "enabled") - out, _ = sjson.Set(out, "thinking.budget_tokens", budget) + default: + if budget > 0 { + out, _ = sjson.Set(out, "thinking.type", "enabled") + out, _ = sjson.Set(out, "thinking.budget_tokens", budget) + } } } } diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 739b39e92..b18cc132d 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -231,9 +231,22 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) } } case "adaptive", "auto": - // Claude adaptive/auto means "enable with max capacity"; keep it as highest level - // and let ApplyThinking normalize per target model capability. - reasoningEffort = string(thinking.LevelXHigh) + // Adaptive thinking can carry an explicit effort in output_config.effort (Claude 4.6). + // Preserve it when present; otherwise keep the previous "max capacity" sentinel. + effort := "" + if v := rootResult.Get("output_config.effort"); v.Exists() && v.Type == gjson.String { + effort = strings.ToLower(strings.TrimSpace(v.String())) + } + switch effort { + case "low", "medium", "high": + reasoningEffort = effort + case "max": + reasoningEffort = string(thinking.LevelXHigh) + default: + // Keep adaptive/auto as a high level sentinel; ApplyThinking resolves it + // to model-specific max capability. + reasoningEffort = string(thinking.LevelXHigh) + } case "disabled": if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" { reasoningEffort = effort diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index e3efb83c3..397625cc6 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -76,9 +76,22 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream } } case "adaptive", "auto": - // Claude adaptive/auto means "enable with max capacity"; keep it as highest level - // and let ApplyThinking normalize per target model capability. - out, _ = sjson.Set(out, "reasoning_effort", string(thinking.LevelXHigh)) + // Adaptive thinking can carry an explicit effort in output_config.effort (Claude 4.6). + // Preserve it when present; otherwise keep the previous "max capacity" sentinel. + effort := "" + if v := root.Get("output_config.effort"); v.Exists() && v.Type == gjson.String { + effort = strings.ToLower(strings.TrimSpace(v.String())) + } + switch effort { + case "low", "medium", "high": + out, _ = sjson.Set(out, "reasoning_effort", effort) + case "max": + out, _ = sjson.Set(out, "reasoning_effort", string(thinking.LevelXHigh)) + default: + // Keep adaptive/auto as a high level sentinel; ApplyThinking resolves it + // to model-specific max capability. + out, _ = sjson.Set(out, "reasoning_effort", string(thinking.LevelXHigh)) + } case "disabled": if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" { out, _ = sjson.Set(out, "reasoning_effort", effort) From 532107b4fac9a71098363123617028a25baabbfb Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:18:56 +0800 Subject: [PATCH 2/7] test(auth): add global model registry usage to conductor override tests --- sdk/cliproxy/auth/conductor_overrides_test.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/sdk/cliproxy/auth/conductor_overrides_test.go b/sdk/cliproxy/auth/conductor_overrides_test.go index e5792c68c..7aca49da6 100644 --- a/sdk/cliproxy/auth/conductor_overrides_test.go +++ b/sdk/cliproxy/auth/conductor_overrides_test.go @@ -7,6 +7,8 @@ import ( "testing" "time" + "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" ) @@ -115,8 +117,19 @@ func newCredentialRetryLimitTestManager(t *testing.T, maxRetryCredentials int) ( executor := &credentialRetryLimitExecutor{id: "claude"} m.RegisterExecutor(executor) - auth1 := &Auth{ID: "auth-1", Provider: "claude"} - auth2 := &Auth{ID: "auth-2", Provider: "claude"} + baseID := uuid.NewString() + auth1 := &Auth{ID: baseID + "-auth-1", Provider: "claude"} + auth2 := &Auth{ID: baseID + "-auth-2", Provider: "claude"} + + // Auth selection requires that the global model registry knows each credential supports the model. + reg := registry.GetGlobalRegistry() + reg.RegisterClient(auth1.ID, "claude", []*registry.ModelInfo{{ID: "test-model"}}) + reg.RegisterClient(auth2.ID, "claude", []*registry.ModelInfo{{ID: "test-model"}}) + t.Cleanup(func() { + reg.UnregisterClient(auth1.ID) + reg.UnregisterClient(auth2.ID) + }) + if _, errRegister := m.Register(context.Background(), auth1); errRegister != nil { t.Fatalf("register auth1: %v", errRegister) } From d2e5857b82dd626cc0306a724cca3457f663a129 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:00:24 +0800 Subject: [PATCH 3/7] feat(thinking): enhance adaptive thinking support across models and update test cases --- .../claude/gemini/claude_gemini_request.go | 111 +++- .../codex/claude/codex_claude_request.go | 2 +- .../gemini/claude/gemini_claude_request.go | 31 +- .../openai/claude/openai_claude_request.go | 2 +- test/thinking_conversion_test.go | 554 +++++++++++++++--- 5 files changed, 607 insertions(+), 93 deletions(-) diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index ea53da054..2d2fee50f 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -14,6 +14,7 @@ import ( "strings" "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/tidwall/gjson" @@ -115,24 +116,73 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream // Include thoughts configuration for reasoning process visibility // Translator only does format conversion, ApplyThinking handles model capability validation. if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { + hasLevel := func(levels []string, target string) bool { + for _, level := range levels { + if strings.EqualFold(strings.TrimSpace(level), target) { + return true + } + } + return false + } + mi := registry.LookupModelInfo(modelName, "claude") + supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0 + supportsMax := supportsAdaptive && hasLevel(mi.Thinking.Levels, "max") + mapToEffort := func(level string) (string, bool) { + level = strings.ToLower(strings.TrimSpace(level)) + switch level { + case "": + return "", false + case "minimal": + return "low", true + case "low", "medium", "high": + return level, true + case "xhigh", "max": + if supportsMax { + return "max", true + } + return "high", true + case "auto": + return "high", true + default: + return "", false + } + } + thinkingLevel := thinkingConfig.Get("thinkingLevel") if !thinkingLevel.Exists() { thinkingLevel = thinkingConfig.Get("thinking_level") } if thinkingLevel.Exists() { level := strings.ToLower(strings.TrimSpace(thinkingLevel.String())) - switch level { - case "": - case "none": - out, _ = sjson.Set(out, "thinking.type", "disabled") - out, _ = sjson.Delete(out, "thinking.budget_tokens") - case "auto": - out, _ = sjson.Set(out, "thinking.type", "enabled") - out, _ = sjson.Delete(out, "thinking.budget_tokens") - default: - if budget, ok := thinking.ConvertLevelToBudget(level); ok { + if supportsAdaptive { + switch level { + case "": + case "none": + out, _ = sjson.Set(out, "thinking.type", "disabled") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + out, _ = sjson.Delete(out, "output_config.effort") + default: + effort, ok := mapToEffort(level) + if ok { + out, _ = sjson.Set(out, "thinking.type", "adaptive") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + out, _ = sjson.Set(out, "output_config.effort", effort) + } + } + } else { + switch level { + case "": + case "none": + out, _ = sjson.Set(out, "thinking.type", "disabled") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + case "auto": out, _ = sjson.Set(out, "thinking.type", "enabled") - out, _ = sjson.Set(out, "thinking.budget_tokens", budget) + out, _ = sjson.Delete(out, "thinking.budget_tokens") + default: + if budget, ok := thinking.ConvertLevelToBudget(level); ok { + out, _ = sjson.Set(out, "thinking.type", "enabled") + out, _ = sjson.Set(out, "thinking.budget_tokens", budget) + } } } } else { @@ -142,16 +192,35 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream } if thinkingBudget.Exists() { budget := int(thinkingBudget.Int()) - switch budget { - case 0: - out, _ = sjson.Set(out, "thinking.type", "disabled") - out, _ = sjson.Delete(out, "thinking.budget_tokens") - case -1: - out, _ = sjson.Set(out, "thinking.type", "enabled") - out, _ = sjson.Delete(out, "thinking.budget_tokens") - default: - out, _ = sjson.Set(out, "thinking.type", "enabled") - out, _ = sjson.Set(out, "thinking.budget_tokens", budget) + if supportsAdaptive { + switch budget { + case 0: + out, _ = sjson.Set(out, "thinking.type", "disabled") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + out, _ = sjson.Delete(out, "output_config.effort") + default: + level, ok := thinking.ConvertBudgetToLevel(budget) + if ok { + effort, ok := mapToEffort(level) + if ok { + out, _ = sjson.Set(out, "thinking.type", "adaptive") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + out, _ = sjson.Set(out, "output_config.effort", effort) + } + } + } + } else { + switch budget { + case 0: + out, _ = sjson.Set(out, "thinking.type", "disabled") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + case -1: + out, _ = sjson.Set(out, "thinking.type", "enabled") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + default: + out, _ = sjson.Set(out, "thinking.type", "enabled") + out, _ = sjson.Set(out, "thinking.budget_tokens", budget) + } } } else if includeThoughts := thinkingConfig.Get("includeThoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True { out, _ = sjson.Set(out, "thinking.type", "enabled") diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index b18cc132d..7846400ed 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -238,7 +238,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) effort = strings.ToLower(strings.TrimSpace(v.String())) } switch effort { - case "low", "medium", "high": + case "minimal", "low", "medium", "high": reasoningEffort = effort case "max": reasoningEffort = string(thinking.LevelXHigh) diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index b5756d204..7eed1cc7b 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -9,6 +9,7 @@ import ( "bytes" "strings" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -151,7 +152,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) } } - // Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when enabled + // Map Anthropic thinking -> Gemini thinking config when enabled // Translator only does format conversion, ApplyThinking handles model capability validation. if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() { switch t.Get("type").String() { @@ -162,9 +163,31 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) out, _ = sjson.Set(out, "generationConfig.thinkingConfig.includeThoughts", true) } case "adaptive", "auto": - // Keep adaptive/auto as a high level sentinel; ApplyThinking resolves it - // to model-specific max capability. - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingLevel", "high") + // For adaptive thinking: + // - If output_config.effort is explicitly present, map it to thinkingLevel. + // - Otherwise, treat it as "enabled with target-model maximum" and emit thinkingBudget=max. + effort := "" + if v := gjson.GetBytes(rawJSON, "output_config.effort"); v.Exists() && v.Type == gjson.String { + effort = strings.ToLower(strings.TrimSpace(v.String())) + } + if effort != "" { + level := effort + switch level { + case "xhigh", "max": + level = "high" + } + out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingLevel", level) + } else { + maxBudget := 0 + if mi := registry.LookupModelInfo(modelName, "gemini"); mi != nil && mi.Thinking != nil { + maxBudget = mi.Thinking.Max + } + if maxBudget > 0 { + out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", maxBudget) + } else { + out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingLevel", "high") + } + } out, _ = sjson.Set(out, "generationConfig.thinkingConfig.includeThoughts", true) } } diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index 397625cc6..4d0f1a1de 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -83,7 +83,7 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream effort = strings.ToLower(strings.TrimSpace(v.String())) } switch effort { - case "low", "medium", "high": + case "minimal", "low", "medium", "high": out, _ = sjson.Set(out, "reasoning_effort", effort) case "max": out, _ = sjson.Set(out, "reasoning_effort", string(thinking.LevelXHigh)) diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 781a16678..271cc7e51 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -34,6 +34,8 @@ type thinkingTestCase struct { inputJSON string expectField string expectValue string + expectField2 string + expectValue2 string includeThoughts string expectErr bool } @@ -2590,9 +2592,8 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { runThinkingTests(t, cases) } -// TestThinkingE2EClaudeAdaptive_Body tests Claude thinking.type=adaptive extended body-only cases. -// These cases validate that adaptive means "thinking enabled without explicit budget", and -// cross-protocol conversion should resolve to target-model maximum thinking capability. +// TestThinkingE2EClaudeAdaptive_Body covers Group 3 cases in docs/thinking-e2e-test-cases.md. +// It focuses on Claude 4.6 adaptive thinking and effort/level cross-protocol semantics (body-only). func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { reg := registry.GetGlobalRegistry() uid := fmt.Sprintf("thinking-e2e-claude-adaptive-%d", time.Now().UnixNano()) @@ -2601,32 +2602,347 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { defer reg.UnregisterClient(uid) cases := []thinkingTestCase{ - // A1: Claude adaptive to OpenAI level model -> highest supported level + // A subgroup: OpenAI -> Claude (reasoning_effort -> output_config.effort) { name: "A1", + from: "openai", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"minimal"}`, + expectField: "output_config.effort", + expectValue: "low", + expectErr: false, + }, + { + name: "A2", + from: "openai", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"low"}`, + expectField: "output_config.effort", + expectValue: "low", + expectErr: false, + }, + { + name: "A3", + from: "openai", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"medium"}`, + expectField: "output_config.effort", + expectValue: "medium", + expectErr: false, + }, + { + name: "A4", + from: "openai", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high"}`, + expectField: "output_config.effort", + expectValue: "high", + expectErr: false, + }, + { + name: "A5", + from: "openai", + to: "claude", + model: "claude-opus-4-6-model", + inputJSON: `{"model":"claude-opus-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`, + expectField: "output_config.effort", + expectValue: "max", + expectErr: false, + }, + { + name: "A6", + from: "openai", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`, + expectField: "output_config.effort", + expectValue: "high", + expectErr: false, + }, + { + name: "A7", + from: "openai", + to: "claude", + model: "claude-opus-4-6-model", + inputJSON: `{"model":"claude-opus-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"max"}`, + expectField: "output_config.effort", + expectValue: "max", + expectErr: false, + }, + { + name: "A8", + from: "openai", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"max"}`, + expectField: "output_config.effort", + expectValue: "high", + expectErr: false, + }, + + // B subgroup: Gemini -> Claude (thinkingLevel/thinkingBudget -> output_config.effort) + { + name: "B1", + from: "gemini", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"minimal"}}}`, + expectField: "output_config.effort", + expectValue: "low", + expectErr: false, + }, + { + name: "B2", + from: "gemini", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"low"}}}`, + expectField: "output_config.effort", + expectValue: "low", + expectErr: false, + }, + { + name: "B3", + from: "gemini", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"medium"}}}`, + expectField: "output_config.effort", + expectValue: "medium", + expectErr: false, + }, + { + name: "B4", + from: "gemini", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`, + expectField: "output_config.effort", + expectValue: "high", + expectErr: false, + }, + { + name: "B5", + from: "gemini", + to: "claude", + model: "claude-opus-4-6-model", + inputJSON: `{"model":"claude-opus-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"xhigh"}}}`, + expectField: "output_config.effort", + expectValue: "max", + expectErr: false, + }, + { + name: "B6", + from: "gemini", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"xhigh"}}}`, + expectField: "output_config.effort", + expectValue: "high", + expectErr: false, + }, + { + name: "B7", + from: "gemini", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":512}}}`, + expectField: "output_config.effort", + expectValue: "low", + expectErr: false, + }, + { + name: "B8", + from: "gemini", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":1024}}}`, + expectField: "output_config.effort", + expectValue: "low", + expectErr: false, + }, + { + name: "B9", + from: "gemini", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`, + expectField: "output_config.effort", + expectValue: "medium", + expectErr: false, + }, + { + name: "B10", + from: "gemini", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":24576}}}`, + expectField: "output_config.effort", + expectValue: "high", + expectErr: false, + }, + { + name: "B11", + from: "gemini", + to: "claude", + model: "claude-opus-4-6-model", + inputJSON: `{"model":"claude-opus-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":32768}}}`, + expectField: "output_config.effort", + expectValue: "max", + expectErr: false, + }, + { + name: "B12", + from: "gemini", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":32768}}}`, + expectField: "output_config.effort", + expectValue: "high", + expectErr: false, + }, + { + name: "B13", + from: "gemini", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":0}}}`, + expectField: "thinking.type", + expectValue: "disabled", + expectErr: false, + }, + { + name: "B14", + from: "gemini", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, + expectField: "output_config.effort", + expectValue: "high", + expectErr: false, + }, + + // C subgroup: Claude adaptive + effort cross-protocol conversion + { + name: "C1", from: "claude", to: "openai", model: "level-model", - inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"minimal"}}`, + expectField: "reasoning_effort", + expectValue: "minimal", + expectErr: false, + }, + { + name: "C2", + from: "claude", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`, + expectField: "reasoning_effort", + expectValue: "low", + expectErr: false, + }, + { + name: "C3", + from: "claude", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"}}`, + expectField: "reasoning_effort", + expectValue: "medium", + expectErr: false, + }, + { + name: "C4", + from: "claude", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, expectField: "reasoning_effort", expectValue: "high", expectErr: false, }, - // A2: Claude adaptive to Gemini level subset model -> highest supported level { - name: "A2", + name: "C5", + from: "claude", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`, + expectField: "reasoning_effort", + expectValue: "high", + expectErr: false, + }, + { + name: "C6", + from: "claude", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`, + expectField: "reasoning_effort", + expectValue: "high", + expectErr: false, + }, + { + name: "C7", + from: "claude", + to: "openai", + model: "no-thinking-model", + inputJSON: `{"model":"no-thinking-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, + expectField: "", + expectErr: false, + }, + + { + name: "C8", from: "claude", to: "gemini", model: "level-subset-model", - inputJSON: `{"model":"level-subset-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, + inputJSON: `{"model":"level-subset-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "high", includeThoughts: "true", expectErr: false, }, - // A3: Claude adaptive to Gemini budget model -> max budget { - name: "A3", + name: "C9", + from: "claude", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "1024", + includeThoughts: "true", + expectErr: false, + }, + { + name: "C10", + from: "claude", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"}}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + { + name: "C11", + from: "claude", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + { + name: "C12", from: "claude", to: "gemini", model: "gemini-budget-model", @@ -2636,32 +2952,91 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // A4: Claude adaptive to Gemini mixed model -> highest supported level { - name: "A4", + name: "C13", from: "claude", to: "gemini", model: "gemini-mixed-model", - inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, + inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "high", includeThoughts: "true", expectErr: false, }, - // A5: Claude adaptive passthrough for same protocol + { - name: "A5", + name: "C14", from: "claude", - to: "claude", - model: "claude-budget-model", - inputJSON: `{"model":"claude-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, - expectField: "thinking.type", - expectValue: "adaptive", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"minimal"}}`, + expectField: "reasoning.effort", + expectValue: "minimal", expectErr: false, }, - // A6: Claude adaptive to Antigravity budget model -> max budget { - name: "A6", + name: "C15", + from: "claude", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`, + expectField: "reasoning.effort", + expectValue: "low", + expectErr: false, + }, + { + name: "C16", + from: "claude", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, + expectField: "reasoning.effort", + expectValue: "high", + expectErr: false, + }, + { + name: "C17", + from: "claude", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`, + expectField: "reasoning.effort", + expectValue: "high", + expectErr: false, + }, + { + name: "C18", + from: "claude", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`, + expectField: "reasoning.effort", + expectValue: "high", + expectErr: false, + }, + + { + name: "C19", + from: "claude", + to: "iflow", + model: "glm-test", + inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"minimal"}}`, + expectField: "chat_template_kwargs.enable_thinking", + expectValue: "true", + expectErr: false, + }, + { + name: "C20", + from: "claude", + to: "iflow", + model: "minimax-test", + inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, + expectField: "reasoning_split", + expectValue: "true", + expectErr: false, + }, + { + name: "C21", from: "claude", to: "antigravity", model: "antigravity-budget-model", @@ -2671,48 +3046,66 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // A7: Claude adaptive to iFlow GLM -> enabled boolean + { - name: "A7", - from: "claude", - to: "iflow", - model: "glm-test", - inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, - expectField: "chat_template_kwargs.enable_thinking", - expectValue: "true", - expectErr: false, + name: "C22", + from: "claude", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"}}`, + expectField: "thinking.type", + expectValue: "adaptive", + expectField2: "output_config.effort", + expectValue2: "medium", + expectErr: false, }, - // A8: Claude adaptive to iFlow MiniMax -> enabled boolean { - name: "A8", - from: "claude", - to: "iflow", - model: "minimax-test", - inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, - expectField: "reasoning_split", - expectValue: "true", - expectErr: false, + name: "C23", + from: "claude", + to: "claude", + model: "claude-opus-4-6-model", + inputJSON: `{"model":"claude-opus-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`, + expectField: "thinking.type", + expectValue: "adaptive", + expectField2: "output_config.effort", + expectValue2: "max", + expectErr: false, }, - // A9: Claude adaptive to Codex level model -> highest supported level { - name: "A9", - from: "claude", - to: "codex", - model: "level-model", - inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, - expectField: "reasoning.effort", - expectValue: "high", - expectErr: false, + name: "C24", + from: "claude", + to: "claude", + model: "claude-opus-4-6-model", + inputJSON: `{"model":"claude-opus-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`, + expectErr: true, }, - // A10: Claude adaptive on non-thinking model should still be stripped { - name: "A10", - from: "claude", - to: "openai", - model: "no-thinking-model", - inputJSON: `{"model":"no-thinking-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, - expectField: "", - expectErr: false, + name: "C25", + from: "claude", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, + expectField: "thinking.type", + expectValue: "adaptive", + expectField2: "output_config.effort", + expectValue2: "high", + expectErr: false, + }, + { + name: "C26", + from: "claude", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`, + expectErr: true, + }, + { + name: "C27", + from: "claude", + to: "claude", + model: "claude-sonnet-4-6-model", + inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`, + expectErr: true, }, } @@ -2767,6 +3160,29 @@ func getTestModels() []*registry.ModelInfo { DisplayName: "Claude Budget Model", Thinking: ®istry.ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false}, }, + { + ID: "claude-sonnet-4-6-model", + Object: "model", + Created: 1771372800, // 2026-02-17 + OwnedBy: "anthropic", + Type: "claude", + DisplayName: "Claude 4.6 Sonnet", + ContextLength: 200000, + MaxCompletionTokens: 64000, + Thinking: ®istry.ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{"low", "medium", "high"}}, + }, + { + ID: "claude-opus-4-6-model", + Object: "model", + Created: 1770318000, // 2026-02-05 + OwnedBy: "anthropic", + Type: "claude", + DisplayName: "Claude 4.6 Opus", + Description: "Premium model combining maximum intelligence with practical performance", + ContextLength: 1000000, + MaxCompletionTokens: 128000, + Thinking: ®istry.ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{"low", "medium", "high", "max"}}, + }, { ID: "antigravity-budget-model", Object: "model", @@ -2879,17 +3295,23 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) { return } - val := gjson.GetBytes(body, tc.expectField) - if !val.Exists() { - t.Fatalf("expected field %s not found, body=%s", tc.expectField, string(body)) + assertField := func(fieldPath, expected string) { + val := gjson.GetBytes(body, fieldPath) + if !val.Exists() { + t.Fatalf("expected field %s not found, body=%s", fieldPath, string(body)) + } + actualValue := val.String() + if val.Type == gjson.Number { + actualValue = fmt.Sprintf("%d", val.Int()) + } + if actualValue != expected { + t.Fatalf("field %s: expected %q, got %q, body=%s", fieldPath, expected, actualValue, string(body)) + } } - actualValue := val.String() - if val.Type == gjson.Number { - actualValue = fmt.Sprintf("%d", val.Int()) - } - if actualValue != tc.expectValue { - t.Fatalf("field %s: expected %q, got %q, body=%s", tc.expectField, tc.expectValue, actualValue, string(body)) + assertField(tc.expectField, tc.expectValue) + if tc.expectField2 != "" { + assertField(tc.expectField2, tc.expectValue2) } if tc.includeThoughts != "" && (tc.to == "gemini" || tc.to == "gemini-cli" || tc.to == "antigravity") { From 0452b869e81198eee18fb90d8e74a09703edd634 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:16:36 +0800 Subject: [PATCH 4/7] feat(thinking): add HasLevel and MapToClaudeEffort functions for adaptive thinking support --- internal/thinking/convert.go | 37 +++++++++++++++++++ internal/thinking/provider/codex/apply.go | 13 +------ internal/thinking/provider/openai/apply.go | 13 +------ .../claude/gemini/claude_gemini_request.go | 34 ++--------------- .../chat-completions/claude_openai_request.go | 25 ++----------- .../claude_openai-responses_request.go | 25 ++----------- 6 files changed, 48 insertions(+), 99 deletions(-) diff --git a/internal/thinking/convert.go b/internal/thinking/convert.go index 8374ddbb0..89db77457 100644 --- a/internal/thinking/convert.go +++ b/internal/thinking/convert.go @@ -96,6 +96,43 @@ func ConvertBudgetToLevel(budget int) (string, bool) { } } +// HasLevel reports whether the given target level exists in the levels slice. +// Matching is case-insensitive with leading/trailing whitespace trimmed. +func HasLevel(levels []string, target string) bool { + for _, level := range levels { + if strings.EqualFold(strings.TrimSpace(level), target) { + return true + } + } + return false +} + +// MapToClaudeEffort maps a generic thinking level string to a Claude adaptive +// thinking effort value (low/medium/high/max). +// +// supportsMax indicates whether the target model supports "max" effort. +// Returns the mapped effort and true if the level is valid, or ("", false) otherwise. +func MapToClaudeEffort(level string, supportsMax bool) (string, bool) { + level = strings.ToLower(strings.TrimSpace(level)) + switch level { + case "": + return "", false + case "minimal": + return "low", true + case "low", "medium", "high": + return level, true + case "xhigh", "max": + if supportsMax { + return "max", true + } + return "high", true + case "auto": + return "high", true + default: + return "", false + } +} + // ModelCapability describes the thinking format support of a model. type ModelCapability int diff --git a/internal/thinking/provider/codex/apply.go b/internal/thinking/provider/codex/apply.go index 3bed318b0..0f3363595 100644 --- a/internal/thinking/provider/codex/apply.go +++ b/internal/thinking/provider/codex/apply.go @@ -7,8 +7,6 @@ package codex import ( - "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/tidwall/gjson" @@ -68,7 +66,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * effort := "" support := modelInfo.Thinking if config.Budget == 0 { - if support.ZeroAllowed || hasLevel(support.Levels, string(thinking.LevelNone)) { + if support.ZeroAllowed || thinking.HasLevel(support.Levels, string(thinking.LevelNone)) { effort = string(thinking.LevelNone) } } @@ -120,12 +118,3 @@ func applyCompatibleCodex(body []byte, config thinking.ThinkingConfig) ([]byte, result, _ := sjson.SetBytes(body, "reasoning.effort", effort) return result, nil } - -func hasLevel(levels []string, target string) bool { - for _, level := range levels { - if strings.EqualFold(strings.TrimSpace(level), target) { - return true - } - } - return false -} diff --git a/internal/thinking/provider/openai/apply.go b/internal/thinking/provider/openai/apply.go index eaad30ee8..c77c1ab8e 100644 --- a/internal/thinking/provider/openai/apply.go +++ b/internal/thinking/provider/openai/apply.go @@ -6,8 +6,6 @@ package openai import ( - "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/tidwall/gjson" @@ -65,7 +63,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * effort := "" support := modelInfo.Thinking if config.Budget == 0 { - if support.ZeroAllowed || hasLevel(support.Levels, string(thinking.LevelNone)) { + if support.ZeroAllowed || thinking.HasLevel(support.Levels, string(thinking.LevelNone)) { effort = string(thinking.LevelNone) } } @@ -117,12 +115,3 @@ func applyCompatibleOpenAI(body []byte, config thinking.ThinkingConfig) ([]byte, result, _ := sjson.SetBytes(body, "reasoning_effort", effort) return result, nil } - -func hasLevel(levels []string, target string) bool { - for _, level := range levels { - if strings.EqualFold(strings.TrimSpace(level), target) { - return true - } - } - return false -} diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index 2d2fee50f..66914462c 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -116,37 +116,9 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream // Include thoughts configuration for reasoning process visibility // Translator only does format conversion, ApplyThinking handles model capability validation. if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { - hasLevel := func(levels []string, target string) bool { - for _, level := range levels { - if strings.EqualFold(strings.TrimSpace(level), target) { - return true - } - } - return false - } mi := registry.LookupModelInfo(modelName, "claude") supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0 - supportsMax := supportsAdaptive && hasLevel(mi.Thinking.Levels, "max") - mapToEffort := func(level string) (string, bool) { - level = strings.ToLower(strings.TrimSpace(level)) - switch level { - case "": - return "", false - case "minimal": - return "low", true - case "low", "medium", "high": - return level, true - case "xhigh", "max": - if supportsMax { - return "max", true - } - return "high", true - case "auto": - return "high", true - default: - return "", false - } - } + supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax)) thinkingLevel := thinkingConfig.Get("thinkingLevel") if !thinkingLevel.Exists() { @@ -162,7 +134,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream out, _ = sjson.Delete(out, "thinking.budget_tokens") out, _ = sjson.Delete(out, "output_config.effort") default: - effort, ok := mapToEffort(level) + effort, ok := thinking.MapToClaudeEffort(level, supportsMax) if ok { out, _ = sjson.Set(out, "thinking.type", "adaptive") out, _ = sjson.Delete(out, "thinking.budget_tokens") @@ -201,7 +173,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream default: level, ok := thinking.ConvertBudgetToLevel(budget) if ok { - effort, ok := mapToEffort(level) + effort, ok := thinking.MapToClaudeEffort(level, supportsMax) if ok { out, _ = sjson.Set(out, "thinking.type", "adaptive") out, _ = sjson.Delete(out, "thinking.budget_tokens") 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 7155d1e07..2706a73ec 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -69,17 +69,9 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream if v := root.Get("reasoning_effort"); v.Exists() { effort := strings.ToLower(strings.TrimSpace(v.String())) if effort != "" { - hasLevel := func(levels []string, target string) bool { - for _, level := range levels { - if strings.EqualFold(strings.TrimSpace(level), target) { - return true - } - } - return false - } mi := registry.LookupModelInfo(modelName, "claude") supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0 - supportsMax := supportsAdaptive && hasLevel(mi.Thinking.Levels, "max") + supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax)) // Claude 4.6 supports adaptive thinking with output_config.effort. if supportsAdaptive { @@ -94,19 +86,8 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream out, _ = sjson.Delete(out, "output_config.effort") default: // Map non-Claude effort levels into Claude 4.6 effort vocabulary. - switch effort { - case "minimal": - effort = "low" - case "xhigh": - if supportsMax { - effort = "max" - } else { - effort = "high" - } - case "max": - if !supportsMax { - effort = "high" - } + if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok { + effort = mapped } out, _ = sjson.Set(out, "thinking.type", "adaptive") out, _ = sjson.Delete(out, "thinking.budget_tokens") 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 cd1b88852..9e8f28da1 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -57,17 +57,9 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte if v := root.Get("reasoning.effort"); v.Exists() { effort := strings.ToLower(strings.TrimSpace(v.String())) if effort != "" { - hasLevel := func(levels []string, target string) bool { - for _, level := range levels { - if strings.EqualFold(strings.TrimSpace(level), target) { - return true - } - } - return false - } mi := registry.LookupModelInfo(modelName, "claude") supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0 - supportsMax := supportsAdaptive && hasLevel(mi.Thinking.Levels, "max") + supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax)) // Claude 4.6 supports adaptive thinking with output_config.effort. if supportsAdaptive { @@ -82,19 +74,8 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte out, _ = sjson.Delete(out, "output_config.effort") default: // Map non-Claude effort levels into Claude 4.6 effort vocabulary. - switch effort { - case "minimal": - effort = "low" - case "xhigh": - if supportsMax { - effort = "max" - } else { - effort = "high" - } - case "max": - if !supportsMax { - effort = "high" - } + if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok { + effort = mapped } out, _ = sjson.Set(out, "thinking.type", "adaptive") out, _ = sjson.Delete(out, "thinking.budget_tokens") From ce87714ef11fb9e083e3ff0a6d3f76fd944dec22 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:10:47 +0800 Subject: [PATCH 5/7] feat(thinking): normalize effort levels in adaptive thinking requests to prevent validation errors --- .../claude/gemini/claude_gemini_request.go | 22 ++++++++++--------- .../chat-completions/claude_openai_request.go | 3 ++- .../claude_openai-responses_request.go | 3 ++- .../codex/claude/codex_claude_request.go | 11 +++------- .../claude/gemini-cli_claude_request.go | 19 ++++++++++++---- .../gemini/claude/gemini_claude_request.go | 10 +++------ .../openai/claude/openai_claude_request.go | 11 +++------- 7 files changed, 40 insertions(+), 39 deletions(-) diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index 66914462c..a8d97b9d1 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -120,6 +120,8 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0 supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax)) + // MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid + // validation errors since validate treats same-provider unsupported levels as errors. thinkingLevel := thinkingConfig.Get("thinkingLevel") if !thinkingLevel.Exists() { thinkingLevel = thinkingConfig.Get("thinking_level") @@ -134,12 +136,12 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream out, _ = sjson.Delete(out, "thinking.budget_tokens") out, _ = sjson.Delete(out, "output_config.effort") default: - effort, ok := thinking.MapToClaudeEffort(level, supportsMax) - if ok { - out, _ = sjson.Set(out, "thinking.type", "adaptive") - out, _ = sjson.Delete(out, "thinking.budget_tokens") - out, _ = sjson.Set(out, "output_config.effort", effort) + if mapped, ok := thinking.MapToClaudeEffort(level, supportsMax); ok { + level = mapped } + out, _ = sjson.Set(out, "thinking.type", "adaptive") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + out, _ = sjson.Set(out, "output_config.effort", level) } } else { switch level { @@ -173,12 +175,12 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream default: level, ok := thinking.ConvertBudgetToLevel(budget) if ok { - effort, ok := thinking.MapToClaudeEffort(level, supportsMax) - if ok { - out, _ = sjson.Set(out, "thinking.type", "adaptive") - out, _ = sjson.Delete(out, "thinking.budget_tokens") - out, _ = sjson.Set(out, "output_config.effort", effort) + if mapped, okM := thinking.MapToClaudeEffort(level, supportsMax); okM { + level = mapped } + out, _ = sjson.Set(out, "thinking.type", "adaptive") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + out, _ = sjson.Set(out, "output_config.effort", level) } } } else { 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 2706a73ec..1b88bb0e5 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -74,6 +74,8 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax)) // Claude 4.6 supports adaptive thinking with output_config.effort. + // MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid + // validation errors since validate treats same-provider unsupported levels as errors. if supportsAdaptive { switch effort { case "none": @@ -85,7 +87,6 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream out, _ = sjson.Delete(out, "thinking.budget_tokens") out, _ = sjson.Delete(out, "output_config.effort") default: - // Map non-Claude effort levels into Claude 4.6 effort vocabulary. if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok { effort = mapped } 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 9e8f28da1..cb550b09d 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -62,6 +62,8 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax)) // Claude 4.6 supports adaptive thinking with output_config.effort. + // MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid + // validation errors since validate treats same-provider unsupported levels as errors. if supportsAdaptive { switch effort { case "none": @@ -73,7 +75,6 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte out, _ = sjson.Delete(out, "thinking.budget_tokens") out, _ = sjson.Delete(out, "output_config.effort") default: - // Map non-Claude effort levels into Claude 4.6 effort vocabulary. if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok { effort = mapped } diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 7846400ed..a635aba89 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -232,19 +232,14 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) } case "adaptive", "auto": // Adaptive thinking can carry an explicit effort in output_config.effort (Claude 4.6). - // Preserve it when present; otherwise keep the previous "max capacity" sentinel. + // Pass through directly; ApplyThinking handles clamping to target model's levels. effort := "" if v := rootResult.Get("output_config.effort"); v.Exists() && v.Type == gjson.String { effort = strings.ToLower(strings.TrimSpace(v.String())) } - switch effort { - case "minimal", "low", "medium", "high": + if effort != "" { reasoningEffort = effort - case "max": - reasoningEffort = string(thinking.LevelXHigh) - default: - // Keep adaptive/auto as a high level sentinel; ApplyThinking resolves it - // to model-specific max capability. + } else { reasoningEffort = string(thinking.LevelXHigh) } case "disabled": diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go index 653bbeb29..3f8921dc8 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -171,7 +171,8 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] } } - // Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled + // Map Anthropic thinking -> Gemini CLI thinkingConfig when enabled + // Translator only does format conversion, ApplyThinking handles model capability validation. if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() { switch t.Get("type").String() { case "enabled": @@ -181,9 +182,19 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } case "adaptive", "auto": - // Keep adaptive/auto as a high level sentinel; ApplyThinking resolves it - // to model-specific max capability. - out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high") + // For adaptive thinking: + // - If output_config.effort is explicitly present, pass through as thinkingLevel. + // - Otherwise, treat it as "enabled with target-model maximum" and emit high. + // ApplyThinking handles clamping to target model's supported levels. + effort := "" + if v := gjson.GetBytes(rawJSON, "output_config.effort"); v.Exists() && v.Type == gjson.String { + effort = strings.ToLower(strings.TrimSpace(v.String())) + } + if effort != "" { + out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", effort) + } else { + out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high") + } out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } } diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index 7eed1cc7b..172884bd9 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -164,19 +164,15 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) } case "adaptive", "auto": // For adaptive thinking: - // - If output_config.effort is explicitly present, map it to thinkingLevel. + // - If output_config.effort is explicitly present, pass through as thinkingLevel. // - Otherwise, treat it as "enabled with target-model maximum" and emit thinkingBudget=max. + // ApplyThinking handles clamping to target model's supported levels. effort := "" if v := gjson.GetBytes(rawJSON, "output_config.effort"); v.Exists() && v.Type == gjson.String { effort = strings.ToLower(strings.TrimSpace(v.String())) } if effort != "" { - level := effort - switch level { - case "xhigh", "max": - level = "high" - } - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingLevel", level) + out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingLevel", effort) } else { maxBudget := 0 if mi := registry.LookupModelInfo(modelName, "gemini"); mi != nil && mi.Thinking != nil { diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index 4d0f1a1de..ff46a8309 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -77,19 +77,14 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream } case "adaptive", "auto": // Adaptive thinking can carry an explicit effort in output_config.effort (Claude 4.6). - // Preserve it when present; otherwise keep the previous "max capacity" sentinel. + // Pass through directly; ApplyThinking handles clamping to target model's levels. effort := "" if v := root.Get("output_config.effort"); v.Exists() && v.Type == gjson.String { effort = strings.ToLower(strings.TrimSpace(v.String())) } - switch effort { - case "minimal", "low", "medium", "high": + if effort != "" { out, _ = sjson.Set(out, "reasoning_effort", effort) - case "max": - out, _ = sjson.Set(out, "reasoning_effort", string(thinking.LevelXHigh)) - default: - // Keep adaptive/auto as a high level sentinel; ApplyThinking resolves it - // to model-specific max capability. + } else { out, _ = sjson.Set(out, "reasoning_effort", string(thinking.LevelXHigh)) } case "disabled": From c80ab8bf0d22a5fe0117fcecf3416aa46832bc6a Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:05:15 +0800 Subject: [PATCH 6/7] feat(thinking): improve provider family checks and clamp unsupported levels --- internal/thinking/validate.go | 24 +++++++++++++++++++-- test/thinking_conversion_test.go | 36 ++++++++++++++++++-------------- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/internal/thinking/validate.go b/internal/thinking/validate.go index 7f5c57c51..d1f784c57 100644 --- a/internal/thinking/validate.go +++ b/internal/thinking/validate.go @@ -53,7 +53,17 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFo return &config, nil } - allowClampUnsupported := isBudgetBasedProvider(fromFormat) && isLevelBasedProvider(toFormat) + // allowClampUnsupported determines whether to clamp unsupported levels instead of returning an error. + // This applies when crossing provider families (e.g., openai→gemini, claude→gemini) and the target + // model supports discrete levels. Same-family conversions require strict validation. + toCapability := detectModelCapability(modelInfo) + toHasLevelSupport := toCapability == CapabilityLevelOnly || toCapability == CapabilityHybrid + allowClampUnsupported := toHasLevelSupport && !isSameProviderFamily(fromFormat, toFormat) + + // strictBudget determines whether to enforce strict budget range validation. + // This applies when: (1) config comes from request body (not suffix), (2) source format is known, + // and (3) source and target are in the same provider family. Cross-family or suffix-based configs + // are clamped instead of rejected to improve interoperability. strictBudget := !fromSuffix && fromFormat != "" && isSameProviderFamily(fromFormat, toFormat) budgetDerivedFromLevel := false @@ -352,11 +362,21 @@ func isGeminiFamily(provider string) bool { } } +func isOpenAIFamily(provider string) bool { + switch provider { + case "openai", "openai-response", "codex": + return true + default: + return false + } +} + func isSameProviderFamily(from, to string) bool { if from == to { return true } - return isGeminiFamily(from) && isGeminiFamily(to) + return (isGeminiFamily(from) && isGeminiFamily(to)) || + (isOpenAIFamily(from) && isOpenAIFamily(to)) } func abs(x int) int { diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 271cc7e51..7d9b7b867 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -386,15 +386,17 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 30: Effort xhigh → not in low/high → error + // Case 30: Effort xhigh → clamped to high { - name: "30", - from: "openai", - to: "gemini", - model: "gemini-mixed-model(xhigh)", - inputJSON: `{"model":"gemini-mixed-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: true, + name: "30", + from: "openai", + to: "gemini", + model: "gemini-mixed-model(xhigh)", + inputJSON: `{"model":"gemini-mixed-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingLevel", + expectValue: "high", + includeThoughts: "true", + expectErr: false, }, // Case 31: Effort none → clamped to low (min supported) → includeThoughts=false { @@ -1668,15 +1670,17 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 30: reasoning_effort=xhigh → error (not in low/high) + // Case 30: reasoning_effort=xhigh → clamped to high { - name: "30", - from: "openai", - to: "gemini", - model: "gemini-mixed-model", - inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`, - expectField: "", - expectErr: true, + name: "30", + from: "openai", + to: "gemini", + model: "gemini-mixed-model", + inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`, + expectField: "generationConfig.thinkingConfig.thinkingLevel", + expectValue: "high", + includeThoughts: "true", + expectErr: false, }, // Case 31: reasoning_effort=none → clamped to low → includeThoughts=false { From 835ae178d4108df9bff3b79408604d2adb9f02fd Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:49:51 +0800 Subject: [PATCH 7/7] feat(thinking): rename isBudgetBasedProvider to isBudgetCapableProvider and update logic for provider checks --- internal/thinking/apply.go | 2 +- internal/thinking/validate.go | 13 +++---------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index 16f1a2f9c..b8a0fcaee 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -293,7 +293,7 @@ func normalizeUserDefinedConfig(config ThinkingConfig, fromFormat, toFormat stri if config.Mode != ModeLevel { return config } - if !isBudgetBasedProvider(toFormat) || !isLevelBasedProvider(fromFormat) { + if !isBudgetCapableProvider(toFormat) { return config } budget, ok := ConvertLevelToBudget(string(config.Level)) diff --git a/internal/thinking/validate.go b/internal/thinking/validate.go index d1f784c57..4a3ca97ce 100644 --- a/internal/thinking/validate.go +++ b/internal/thinking/validate.go @@ -335,7 +335,9 @@ func normalizeLevels(levels []string) []string { return out } -func isBudgetBasedProvider(provider string) bool { +// isBudgetCapableProvider returns true if the provider supports budget-based thinking. +// These providers may also support level-based thinking (hybrid models). +func isBudgetCapableProvider(provider string) bool { switch provider { case "gemini", "gemini-cli", "antigravity", "claude": return true @@ -344,15 +346,6 @@ func isBudgetBasedProvider(provider string) bool { } } -func isLevelBasedProvider(provider string) bool { - switch provider { - case "openai", "openai-response", "codex": - return true - default: - return false - } -} - func isGeminiFamily(provider string) bool { switch provider { case "gemini", "gemini-cli", "antigravity":