Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions internal/thinking/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "":
Expand All @@ -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":
Expand Down
19 changes: 19 additions & 0 deletions internal/thinking/convert_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
19 changes: 9 additions & 10 deletions internal/thinking/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 2 additions & 3 deletions internal/translator/claude/gemini/claude_gemini_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
90 changes: 54 additions & 36 deletions test/thinking_conversion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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
{
Expand All @@ -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
{
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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
{
Expand All @@ -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
{
Expand Down Expand Up @@ -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",
Expand All @@ -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,
},
}

Expand Down
Loading