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/9] 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 f70d3984..dcf5debf 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 75ea04e1..805d31dd 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 8a5a1d7d..16f1a2f9 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 776ccef6..8374ddbb 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 3c74d514..275be469 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 514ab3f8..85498c01 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 275c0856..7f2959da 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 6ae1e088..5e45fc6b 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 f082ad56..7f5c57c5 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 f94825b2..7155d1e0 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 33a81124..cd1b8885 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 739b39e9..b18cc132 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 e3efb83c..397625cc 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/9] 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 e5792c68..7aca49da 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 f9b005f21f63ac08ddd146c211e5acd8a3abbec8 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 3 Mar 2026 09:37:24 +0800 Subject: [PATCH 3/9] Fixed: #1799 **test(auth): add tests for auth file deletion logic with manager and fallback scenarios** --- .../api/handlers/management/auth_files.go | 84 ++++++++---- .../management/auth_files_delete_test.go | 129 ++++++++++++++++++ 2 files changed, 189 insertions(+), 24 deletions(-) create mode 100644 internal/api/handlers/management/auth_files_delete_test.go diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index bb5606db..dcff98d7 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -186,17 +186,6 @@ func startCallbackForwarder(port int, provider, targetBase string) (*callbackFor return forwarder, nil } -func stopCallbackForwarder(port int) { - callbackForwardersMu.Lock() - forwarder := callbackForwarders[port] - if forwarder != nil { - delete(callbackForwarders, port) - } - callbackForwardersMu.Unlock() - - stopForwarderInstance(port, forwarder) -} - func stopCallbackForwarderInstance(port int, forwarder *callbackForwarder) { if forwarder == nil { return @@ -638,28 +627,66 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) { c.JSON(400, gin.H{"error": "invalid name"}) return } - full := filepath.Join(h.cfg.AuthDir, filepath.Base(name)) - if !filepath.IsAbs(full) { - if abs, errAbs := filepath.Abs(full); errAbs == nil { - full = abs + + targetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name)) + targetID := "" + if targetAuth := h.findAuthForDelete(name); targetAuth != nil { + targetID = strings.TrimSpace(targetAuth.ID) + if path := strings.TrimSpace(authAttribute(targetAuth, "path")); path != "" { + targetPath = path } } - if err := os.Remove(full); err != nil { - if os.IsNotExist(err) { + if !filepath.IsAbs(targetPath) { + if abs, errAbs := filepath.Abs(targetPath); errAbs == nil { + targetPath = abs + } + } + if errRemove := os.Remove(targetPath); errRemove != nil { + if os.IsNotExist(errRemove) { c.JSON(404, gin.H{"error": "file not found"}) } else { - c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", err)}) + c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", errRemove)}) } return } - if err := h.deleteTokenRecord(ctx, full); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) + if errDeleteRecord := h.deleteTokenRecord(ctx, targetPath); errDeleteRecord != nil { + c.JSON(500, gin.H{"error": errDeleteRecord.Error()}) return } - h.disableAuth(ctx, full) + if targetID != "" { + h.disableAuth(ctx, targetID) + } else { + h.disableAuth(ctx, targetPath) + } c.JSON(200, gin.H{"status": "ok"}) } +func (h *Handler) findAuthForDelete(name string) *coreauth.Auth { + if h == nil || h.authManager == nil { + return nil + } + name = strings.TrimSpace(name) + if name == "" { + return nil + } + if auth, ok := h.authManager.GetByID(name); ok { + return auth + } + auths := h.authManager.List() + for _, auth := range auths { + if auth == nil { + continue + } + if strings.TrimSpace(auth.FileName) == name { + return auth + } + if filepath.Base(strings.TrimSpace(authAttribute(auth, "path"))) == name { + return auth + } + } + return nil +} + func (h *Handler) authIDForPath(path string) string { path = strings.TrimSpace(path) if path == "" { @@ -893,10 +920,19 @@ func (h *Handler) disableAuth(ctx context.Context, id string) { if h == nil || h.authManager == nil { return } - authID := h.authIDForPath(id) - if authID == "" { - authID = strings.TrimSpace(id) + id = strings.TrimSpace(id) + if id == "" { + return } + if auth, ok := h.authManager.GetByID(id); ok { + auth.Disabled = true + auth.Status = coreauth.StatusDisabled + auth.StatusMessage = "removed via management API" + auth.UpdatedAt = time.Now() + _, _ = h.authManager.Update(ctx, auth) + return + } + authID := h.authIDForPath(id) if authID == "" { return } diff --git a/internal/api/handlers/management/auth_files_delete_test.go b/internal/api/handlers/management/auth_files_delete_test.go new file mode 100644 index 00000000..7b7b888c --- /dev/null +++ b/internal/api/handlers/management/auth_files_delete_test.go @@ -0,0 +1,129 @@ +package management + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +func TestDeleteAuthFile_UsesAuthPathFromManager(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + tempDir := t.TempDir() + authDir := filepath.Join(tempDir, "auth") + externalDir := filepath.Join(tempDir, "external") + if errMkdirAuth := os.MkdirAll(authDir, 0o700); errMkdirAuth != nil { + t.Fatalf("failed to create auth dir: %v", errMkdirAuth) + } + if errMkdirExternal := os.MkdirAll(externalDir, 0o700); errMkdirExternal != nil { + t.Fatalf("failed to create external dir: %v", errMkdirExternal) + } + + fileName := "codex-user@example.com-plus.json" + shadowPath := filepath.Join(authDir, fileName) + realPath := filepath.Join(externalDir, fileName) + if errWriteShadow := os.WriteFile(shadowPath, []byte(`{"type":"codex","email":"shadow@example.com"}`), 0o600); errWriteShadow != nil { + t.Fatalf("failed to write shadow file: %v", errWriteShadow) + } + if errWriteReal := os.WriteFile(realPath, []byte(`{"type":"codex","email":"real@example.com"}`), 0o600); errWriteReal != nil { + t.Fatalf("failed to write real file: %v", errWriteReal) + } + + manager := coreauth.NewManager(nil, nil, nil) + record := &coreauth.Auth{ + ID: "legacy/" + fileName, + FileName: fileName, + Provider: "codex", + Status: coreauth.StatusError, + Unavailable: true, + Attributes: map[string]string{ + "path": realPath, + }, + Metadata: map[string]any{ + "type": "codex", + "email": "real@example.com", + }, + } + if _, errRegister := manager.Register(context.Background(), record); errRegister != nil { + t.Fatalf("failed to register auth record: %v", errRegister) + } + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager) + h.tokenStore = &memoryAuthStore{} + + deleteRec := httptest.NewRecorder() + deleteCtx, _ := gin.CreateTestContext(deleteRec) + deleteReq := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil) + deleteCtx.Request = deleteReq + h.DeleteAuthFile(deleteCtx) + + if deleteRec.Code != http.StatusOK { + t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, deleteRec.Code, deleteRec.Body.String()) + } + if _, errStatReal := os.Stat(realPath); !os.IsNotExist(errStatReal) { + t.Fatalf("expected managed auth file to be removed, stat err: %v", errStatReal) + } + if _, errStatShadow := os.Stat(shadowPath); errStatShadow != nil { + t.Fatalf("expected shadow auth file to remain, stat err: %v", errStatShadow) + } + + listRec := httptest.NewRecorder() + listCtx, _ := gin.CreateTestContext(listRec) + listReq := httptest.NewRequest(http.MethodGet, "/v0/management/auth-files", nil) + listCtx.Request = listReq + h.ListAuthFiles(listCtx) + + if listRec.Code != http.StatusOK { + t.Fatalf("expected list status %d, got %d with body %s", http.StatusOK, listRec.Code, listRec.Body.String()) + } + var listPayload map[string]any + if errUnmarshal := json.Unmarshal(listRec.Body.Bytes(), &listPayload); errUnmarshal != nil { + t.Fatalf("failed to decode list payload: %v", errUnmarshal) + } + filesRaw, ok := listPayload["files"].([]any) + if !ok { + t.Fatalf("expected files array, payload: %#v", listPayload) + } + if len(filesRaw) != 0 { + t.Fatalf("expected removed auth to be hidden from list, got %d entries", len(filesRaw)) + } +} + +func TestDeleteAuthFile_FallbackToAuthDirPath(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + authDir := t.TempDir() + fileName := "fallback-user.json" + filePath := filepath.Join(authDir, fileName) + if errWrite := os.WriteFile(filePath, []byte(`{"type":"codex"}`), 0o600); errWrite != nil { + t.Fatalf("failed to write auth file: %v", errWrite) + } + + manager := coreauth.NewManager(nil, nil, nil) + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager) + h.tokenStore = &memoryAuthStore{} + + deleteRec := httptest.NewRecorder() + deleteCtx, _ := gin.CreateTestContext(deleteRec) + deleteReq := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil) + deleteCtx.Request = deleteReq + h.DeleteAuthFile(deleteCtx) + + if deleteRec.Code != http.StatusOK { + t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, deleteRec.Code, deleteRec.Body.String()) + } + if _, errStat := os.Stat(filePath); !os.IsNotExist(errStat) { + t.Fatalf("expected auth file to be removed from auth dir, stat err: %v", errStat) + } +} 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 4/9] 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 ea53da05..2d2fee50 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 b18cc132..7846400e 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 b5756d20..7eed1cc7 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 397625cc..4d0f1a1d 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 781a1667..271cc7e5 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 5/9] 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 8374ddbb..89db7745 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 3bed318b..0f336359 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 eaad30ee..c77c1ab8 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 2d2fee50..66914462 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 7155d1e0..2706a73e 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 cd1b8885..9e8f28da 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 6/9] 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 66914462..a8d97b9d 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 2706a73e..1b88bb0e 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 9e8f28da..cb550b09 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 7846400e..a635aba8 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 653bbeb2..3f8921dc 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 7eed1cc7..172884bd 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 4d0f1a1d..ff46a830 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 7/9] 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 7f5c57c5..d1f784c5 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 271cc7e5..7d9b7b86 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 8/9] 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 16f1a2f9..b8a0fcae 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 d1f784c5..4a3ca97c 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": From 9f95b31158fcf79f73037cf29dac26b4c8cd6dc1 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 3 Mar 2026 21:49:41 +0800 Subject: [PATCH 9/9] **fix(translator): enhance handling of mixed output content in Claude requests** --- .../codex/claude/codex_claude_request.go | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index a635aba8..e3ddd0b8 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -160,7 +160,51 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) flushMessage() functionCallOutputMessage := `{"type":"function_call_output"}` functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "call_id", messageContentResult.Get("tool_use_id").String()) - functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "output", messageContentResult.Get("content").String()) + + contentResult := messageContentResult.Get("content") + if contentResult.IsArray() { + toolResultContentIndex := 0 + toolResultContent := `[]` + contentResults := contentResult.Array() + for k := 0; k < len(contentResults); k++ { + toolResultContentType := contentResults[k].Get("type").String() + if toolResultContentType == "image" { + sourceResult := contentResults[k].Get("source") + if sourceResult.Exists() { + data := sourceResult.Get("data").String() + if data == "" { + data = sourceResult.Get("base64").String() + } + if data != "" { + mediaType := sourceResult.Get("media_type").String() + if mediaType == "" { + mediaType = sourceResult.Get("mime_type").String() + } + if mediaType == "" { + mediaType = "application/octet-stream" + } + dataURL := fmt.Sprintf("data:%s;base64,%s", mediaType, data) + + toolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf("%d.type", toolResultContentIndex), "input_image") + toolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf("%d.image_url", toolResultContentIndex), dataURL) + toolResultContentIndex++ + } + } + } else if toolResultContentType == "text" { + toolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf("%d.type", toolResultContentIndex), "input_text") + toolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf("%d.text", toolResultContentIndex), contentResults[k].Get("text").String()) + toolResultContentIndex++ + } + } + if toolResultContent != `[]` { + functionCallOutputMessage, _ = sjson.SetRaw(functionCallOutputMessage, "output", toolResultContent) + } else { + functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "output", messageContentResult.Get("content").String()) + } + } else { + functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "output", messageContentResult.Get("content").String()) + } + template, _ = sjson.SetRaw(template, "input.-1", functionCallOutputMessage) } }