diff --git a/internal/thinking/convert.go b/internal/thinking/convert.go index 31945daa7c..9559be8034 100644 --- a/internal/thinking/convert.go +++ b/internal/thinking/convert.go @@ -108,11 +108,11 @@ func HasLevel(levels []string, target string) bool { } // MapToClaudeEffort maps a generic thinking level string to a Claude adaptive -// thinking effort value (low/medium/high/max). +// thinking effort value using the target model's advertised levels. // -// 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) { +// When the model natively supports xhigh, xhigh is preserved. Otherwise xhigh falls +// back to max when available, then high. +func MapToClaudeEffort(level string, supportedLevels []string) (string, bool) { level = strings.ToLower(strings.TrimSpace(level)) switch level { case "": @@ -121,9 +121,17 @@ func MapToClaudeEffort(level string, supportsMax bool) (string, bool) { return "low", true case "low", "medium", "high": return level, true - case "xhigh", "max": - if supportsMax { - return "max", true + case "xhigh": + if HasLevel(supportedLevels, string(LevelXHigh)) { + return string(LevelXHigh), true + } + if HasLevel(supportedLevels, string(LevelMax)) { + return string(LevelMax), true + } + return "high", true + case "max": + if HasLevel(supportedLevels, string(LevelMax)) { + return string(LevelMax), true } return "high", true case "auto": diff --git a/internal/thinking/convert_test.go b/internal/thinking/convert_test.go new file mode 100644 index 0000000000..5cd79da0d3 --- /dev/null +++ b/internal/thinking/convert_test.go @@ -0,0 +1,19 @@ +package thinking + +import "testing" + +func TestMapToClaudeEffortPreservesNativeXHigh(t *testing.T) { + levels := []string{"low", "medium", "high", "xhigh", "max"} + got, ok := MapToClaudeEffort("xhigh", levels) + if !ok || got != "xhigh" { + t.Fatalf("MapToClaudeEffort(xhigh, opus48 levels) = (%q, %v), want (xhigh, true)", got, ok) + } +} + +func TestMapToClaudeEffortFallsBackToMaxWhenXHighMissing(t *testing.T) { + levels := []string{"low", "medium", "high", "max"} + got, ok := MapToClaudeEffort("xhigh", levels) + if !ok || got != "max" { + t.Fatalf("MapToClaudeEffort(xhigh, opus46 levels) = (%q, %v), want (max, true)", got, ok) + } +} diff --git a/internal/thinking/validate.go b/internal/thinking/validate.go index 909a2eeaa9..7844790129 100644 --- a/internal/thinking/validate.go +++ b/internal/thinking/validate.go @@ -53,13 +53,6 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFo return &config, nil } - // 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 @@ -115,12 +108,18 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFo if len(support.Levels) > 0 && config.Mode == ModeLevel { if !isLevelSupported(string(config.Level), support.Levels) { - if allowClampUnsupported { + // Claude adaptive effort uses provider-specific aliases based on model levels. + if toFormat == "claude" { + if mapped, ok := MapToClaudeEffort(string(config.Level), support.Levels); ok && isLevelSupported(mapped, support.Levels) { + config.Level = ThinkingLevel(mapped) + } + } + if !isLevelSupported(string(config.Level), support.Levels) { + // Clamp against the target model's advertised levels, regardless of translator family. config.Level = clampLevel(config.Level, modelInfo, toFormat) } if !isLevelSupported(string(config.Level), support.Levels) { - // User explicitly specified an unsupported level - return error - // (budget-derived levels may be clamped based on source format) + // User explicitly specified an unsupported level - return error. validLevels := normalizeLevels(support.Levels) message := fmt.Sprintf("level %q not supported, valid levels: %s", strings.ToLower(string(config.Level)), strings.Join(validLevels, ", ")) return nil, NewThinkingError(ErrLevelNotSupported, message) diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index d716d28f35..d5a8e8e132 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -118,7 +118,6 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { mi := registry.LookupModelInfo(modelName, "claude") 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. @@ -136,7 +135,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens") out, _ = sjson.DeleteBytes(out, "output_config.effort") default: - if mapped, ok := thinking.MapToClaudeEffort(level, supportsMax); ok { + if mapped, ok := thinking.MapToClaudeEffort(level, mi.Thinking.Levels); ok { level = mapped } out, _ = sjson.SetBytes(out, "thinking.type", "adaptive") @@ -175,7 +174,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream default: level, ok := thinking.ConvertBudgetToLevel(budget) if ok { - if mapped, okM := thinking.MapToClaudeEffort(level, supportsMax); okM { + if mapped, okM := thinking.MapToClaudeEffort(level, mi.Thinking.Levels); okM { level = mapped } out, _ = sjson.SetBytes(out, "thinking.type", "adaptive") 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 bad56d1273..e69b089a2f 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -71,7 +71,6 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream if effort != "" { mi := registry.LookupModelInfo(modelName, "claude") supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0 - 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 @@ -87,7 +86,7 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens") out, _ = sjson.DeleteBytes(out, "output_config.effort") default: - if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok { + if mapped, ok := thinking.MapToClaudeEffort(effort, mi.Thinking.Levels); ok { effort = mapped } out, _ = sjson.SetBytes(out, "thinking.type", "adaptive") 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 d37b715635..c24cb3cbb3 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -60,7 +60,6 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte if effort != "" { mi := registry.LookupModelInfo(modelName, "claude") supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0 - 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 @@ -76,7 +75,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens") out, _ = sjson.DeleteBytes(out, "output_config.effort") default: - if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok { + if mapped, ok := thinking.MapToClaudeEffort(effort, mi.Thinking.Levels); ok { effort = mapped } out, _ = sjson.SetBytes(out, "thinking.type", "adaptive") diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 9173aa0194..3c39c94bbe 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -73,15 +73,16 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { expectValue: "medium", expectErr: false, }, - // Case 3: Specified xhigh → out of range error + // Case 3: Specified xhigh → clamped to high { name: "3", from: "openai", to: "codex", model: "level-model(xhigh)", inputJSON: `{"model":"level-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: true, + expectField: "reasoning.effort", + expectValue: "high", + expectErr: false, }, // Case 4: Level none → clamped to minimal (ZeroAllowed=false) { @@ -963,15 +964,16 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { expectValue: "high", expectErr: false, }, - // Case 81: OpenAI to OpenAI, level xhigh → out of range error + // Case 81: OpenAI to OpenAI, level xhigh → clamped to high { name: "81", from: "openai", to: "openai", model: "level-model(xhigh)", inputJSON: `{"model":"level-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: true, + expectField: "reasoning_effort", + expectValue: "high", + expectErr: false, }, // Case 82: OpenAI-Response to Codex, level high → passthrough reasoning.effort { @@ -984,15 +986,16 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { expectValue: "high", expectErr: false, }, - // Case 83: OpenAI-Response to Codex, level xhigh → out of range error + // Case 83: OpenAI-Response to Codex, level xhigh → clamped to high { name: "83", from: "openai-response", to: "codex", model: "level-model(xhigh)", inputJSON: `{"model":"level-model(xhigh)","input":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: true, + expectField: "reasoning.effort", + expectValue: "high", + expectErr: false, }, // Case 84: Gemini to Gemini, budget 8192 → passthrough thinkingBudget { @@ -1179,15 +1182,16 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectValue: "medium", expectErr: false, }, - // Case 3: reasoning_effort=xhigh → out of range error + // Case 3: reasoning_effort=xhigh → clamped to high { name: "3", from: "openai", to: "codex", model: "level-model", inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`, - expectField: "", - expectErr: true, + expectField: "reasoning.effort", + expectValue: "high", + expectErr: false, }, // Case 4: reasoning_effort=none → clamped to minimal { @@ -2069,15 +2073,16 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectValue: "high", expectErr: false, }, - // Case 81: OpenAI to OpenAI, reasoning_effort=xhigh → out of range error + // Case 81: OpenAI to OpenAI, reasoning_effort=xhigh → clamped to high { name: "81", from: "openai", to: "openai", model: "level-model", inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`, - expectField: "", - expectErr: true, + expectField: "reasoning_effort", + expectValue: "high", + expectErr: false, }, // Case 82: OpenAI-Response to Codex, reasoning.effort=high → passthrough { @@ -2090,15 +2095,16 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectValue: "high", expectErr: false, }, - // Case 83: OpenAI-Response to Codex, reasoning.effort=xhigh → out of range error + // Case 83: OpenAI-Response to Codex, reasoning.effort=xhigh → clamped to high { name: "83", from: "openai-response", to: "codex", model: "level-model", inputJSON: `{"model":"level-model","input":[{"role":"user","content":"hi"}],"reasoning":{"effort":"xhigh"}}`, - expectField: "", - expectErr: true, + expectField: "reasoning.effort", + expectValue: "high", + expectErr: false, }, // Case 84: Gemini to Gemini, thinkingBudget=8192 → passthrough { @@ -2697,12 +2703,16 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { 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, + 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"}}`, + expectField: "thinking.type", + expectValue: "adaptive", + expectField2: "output_config.effort", + expectValue2: "max", + expectErr: false, }, { name: "C25", @@ -2717,20 +2727,28 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { 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: "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"}}`, + expectField: "thinking.type", + expectValue: "adaptive", + expectField2: "output_config.effort", + expectValue2: "high", + expectErr: false, }, { - 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, + 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"}}`, + expectField: "thinking.type", + expectValue: "adaptive", + expectField2: "output_config.effort", + expectValue2: "high", + expectErr: false, }, }