Skip to content

Commit dc24e6a

Browse files
committed
fix(thinking): clamp unsupported levels by model info
1 parent 30dc2e7 commit dc24e6a

7 files changed

Lines changed: 101 additions & 60 deletions

File tree

internal/thinking/convert.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,11 @@ func HasLevel(levels []string, target string) bool {
108108
}
109109

110110
// MapToClaudeEffort maps a generic thinking level string to a Claude adaptive
111-
// thinking effort value (low/medium/high/max).
111+
// thinking effort value using the target model's advertised levels.
112112
//
113-
// supportsMax indicates whether the target model supports "max" effort.
114-
// Returns the mapped effort and true if the level is valid, or ("", false) otherwise.
115-
func MapToClaudeEffort(level string, supportsMax bool) (string, bool) {
113+
// When the model natively supports xhigh, xhigh is preserved. Otherwise xhigh falls
114+
// back to max when available, then high.
115+
func MapToClaudeEffort(level string, supportedLevels []string) (string, bool) {
116116
level = strings.ToLower(strings.TrimSpace(level))
117117
switch level {
118118
case "":
@@ -121,9 +121,17 @@ func MapToClaudeEffort(level string, supportsMax bool) (string, bool) {
121121
return "low", true
122122
case "low", "medium", "high":
123123
return level, true
124-
case "xhigh", "max":
125-
if supportsMax {
126-
return "max", true
124+
case "xhigh":
125+
if HasLevel(supportedLevels, string(LevelXHigh)) {
126+
return string(LevelXHigh), true
127+
}
128+
if HasLevel(supportedLevels, string(LevelMax)) {
129+
return string(LevelMax), true
130+
}
131+
return "high", true
132+
case "max":
133+
if HasLevel(supportedLevels, string(LevelMax)) {
134+
return string(LevelMax), true
127135
}
128136
return "high", true
129137
case "auto":

internal/thinking/convert_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package thinking
2+
3+
import "testing"
4+
5+
func TestMapToClaudeEffortPreservesNativeXHigh(t *testing.T) {
6+
levels := []string{"low", "medium", "high", "xhigh", "max"}
7+
got, ok := MapToClaudeEffort("xhigh", levels)
8+
if !ok || got != "xhigh" {
9+
t.Fatalf("MapToClaudeEffort(xhigh, opus48 levels) = (%q, %v), want (xhigh, true)", got, ok)
10+
}
11+
}
12+
13+
func TestMapToClaudeEffortFallsBackToMaxWhenXHighMissing(t *testing.T) {
14+
levels := []string{"low", "medium", "high", "max"}
15+
got, ok := MapToClaudeEffort("xhigh", levels)
16+
if !ok || got != "max" {
17+
t.Fatalf("MapToClaudeEffort(xhigh, opus46 levels) = (%q, %v), want (max, true)", got, ok)
18+
}
19+
}

internal/thinking/validate.go

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,6 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFo
5353
return &config, nil
5454
}
5555

56-
// allowClampUnsupported determines whether to clamp unsupported levels instead of returning an error.
57-
// This applies when crossing provider families (e.g., openai→gemini, claude→gemini) and the target
58-
// model supports discrete levels. Same-family conversions require strict validation.
59-
toCapability := detectModelCapability(modelInfo)
60-
toHasLevelSupport := toCapability == CapabilityLevelOnly || toCapability == CapabilityHybrid
61-
allowClampUnsupported := toHasLevelSupport && !isSameProviderFamily(fromFormat, toFormat)
62-
6356
// strictBudget determines whether to enforce strict budget range validation.
6457
// This applies when: (1) config comes from request body (not suffix), (2) source format is known,
6558
// 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
115108

116109
if len(support.Levels) > 0 && config.Mode == ModeLevel {
117110
if !isLevelSupported(string(config.Level), support.Levels) {
118-
if allowClampUnsupported {
111+
// Claude adaptive effort uses provider-specific aliases based on model levels.
112+
if toFormat == "claude" {
113+
if mapped, ok := MapToClaudeEffort(string(config.Level), support.Levels); ok && isLevelSupported(mapped, support.Levels) {
114+
config.Level = ThinkingLevel(mapped)
115+
}
116+
}
117+
if !isLevelSupported(string(config.Level), support.Levels) {
118+
// Clamp against the target model's advertised levels, regardless of translator family.
119119
config.Level = clampLevel(config.Level, modelInfo, toFormat)
120120
}
121121
if !isLevelSupported(string(config.Level), support.Levels) {
122-
// User explicitly specified an unsupported level - return error
123-
// (budget-derived levels may be clamped based on source format)
122+
// User explicitly specified an unsupported level - return error.
124123
validLevels := normalizeLevels(support.Levels)
125124
message := fmt.Sprintf("level %q not supported, valid levels: %s", strings.ToLower(string(config.Level)), strings.Join(validLevels, ", "))
126125
return nil, NewThinkingError(ErrLevelNotSupported, message)

internal/translator/claude/gemini/claude_gemini_request.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
118118
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
119119
mi := registry.LookupModelInfo(modelName, "claude")
120120
supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0
121-
supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))
122121

123122
// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid
124123
// validation errors since validate treats same-provider unsupported levels as errors.
@@ -136,7 +135,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
136135
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
137136
out, _ = sjson.DeleteBytes(out, "output_config.effort")
138137
default:
139-
if mapped, ok := thinking.MapToClaudeEffort(level, supportsMax); ok {
138+
if mapped, ok := thinking.MapToClaudeEffort(level, mi.Thinking.Levels); ok {
140139
level = mapped
141140
}
142141
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
@@ -175,7 +174,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
175174
default:
176175
level, ok := thinking.ConvertBudgetToLevel(budget)
177176
if ok {
178-
if mapped, okM := thinking.MapToClaudeEffort(level, supportsMax); okM {
177+
if mapped, okM := thinking.MapToClaudeEffort(level, mi.Thinking.Levels); okM {
179178
level = mapped
180179
}
181180
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")

internal/translator/claude/openai/chat-completions/claude_openai_request.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
7171
if effort != "" {
7272
mi := registry.LookupModelInfo(modelName, "claude")
7373
supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0
74-
supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))
7574

7675
// Claude 4.6 supports adaptive thinking with output_config.effort.
7776
// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid
@@ -87,7 +86,7 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
8786
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
8887
out, _ = sjson.DeleteBytes(out, "output_config.effort")
8988
default:
90-
if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {
89+
if mapped, ok := thinking.MapToClaudeEffort(effort, mi.Thinking.Levels); ok {
9190
effort = mapped
9291
}
9392
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")

internal/translator/claude/openai/responses/claude_openai-responses_request.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
6060
if effort != "" {
6161
mi := registry.LookupModelInfo(modelName, "claude")
6262
supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0
63-
supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))
6463

6564
// Claude 4.6 supports adaptive thinking with output_config.effort.
6665
// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid
@@ -76,7 +75,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
7675
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
7776
out, _ = sjson.DeleteBytes(out, "output_config.effort")
7877
default:
79-
if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {
78+
if mapped, ok := thinking.MapToClaudeEffort(effort, mi.Thinking.Levels); ok {
8079
effort = mapped
8180
}
8281
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")

test/thinking_conversion_test.go

Lines changed: 54 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,16 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) {
7373
expectValue: "medium",
7474
expectErr: false,
7575
},
76-
// Case 3: Specified xhigh → out of range error
76+
// Case 3: Specified xhigh → clamped to high
7777
{
7878
name: "3",
7979
from: "openai",
8080
to: "codex",
8181
model: "level-model(xhigh)",
8282
inputJSON: `{"model":"level-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`,
83-
expectField: "",
84-
expectErr: true,
83+
expectField: "reasoning.effort",
84+
expectValue: "high",
85+
expectErr: false,
8586
},
8687
// Case 4: Level none → clamped to minimal (ZeroAllowed=false)
8788
{
@@ -963,15 +964,16 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) {
963964
expectValue: "high",
964965
expectErr: false,
965966
},
966-
// Case 81: OpenAI to OpenAI, level xhigh → out of range error
967+
// Case 81: OpenAI to OpenAI, level xhigh → clamped to high
967968
{
968969
name: "81",
969970
from: "openai",
970971
to: "openai",
971972
model: "level-model(xhigh)",
972973
inputJSON: `{"model":"level-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`,
973-
expectField: "",
974-
expectErr: true,
974+
expectField: "reasoning_effort",
975+
expectValue: "high",
976+
expectErr: false,
975977
},
976978
// Case 82: OpenAI-Response to Codex, level high → passthrough reasoning.effort
977979
{
@@ -984,15 +986,16 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) {
984986
expectValue: "high",
985987
expectErr: false,
986988
},
987-
// Case 83: OpenAI-Response to Codex, level xhigh → out of range error
989+
// Case 83: OpenAI-Response to Codex, level xhigh → clamped to high
988990
{
989991
name: "83",
990992
from: "openai-response",
991993
to: "codex",
992994
model: "level-model(xhigh)",
993995
inputJSON: `{"model":"level-model(xhigh)","input":[{"role":"user","content":"hi"}]}`,
994-
expectField: "",
995-
expectErr: true,
996+
expectField: "reasoning.effort",
997+
expectValue: "high",
998+
expectErr: false,
996999
},
9971000
// Case 84: Gemini to Gemini, budget 8192 → passthrough thinkingBudget
9981001
{
@@ -1179,15 +1182,16 @@ func TestThinkingE2EMatrix_Body(t *testing.T) {
11791182
expectValue: "medium",
11801183
expectErr: false,
11811184
},
1182-
// Case 3: reasoning_effort=xhigh → out of range error
1185+
// Case 3: reasoning_effort=xhigh → clamped to high
11831186
{
11841187
name: "3",
11851188
from: "openai",
11861189
to: "codex",
11871190
model: "level-model",
11881191
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`,
1189-
expectField: "",
1190-
expectErr: true,
1192+
expectField: "reasoning.effort",
1193+
expectValue: "high",
1194+
expectErr: false,
11911195
},
11921196
// Case 4: reasoning_effort=none → clamped to minimal
11931197
{
@@ -2069,15 +2073,16 @@ func TestThinkingE2EMatrix_Body(t *testing.T) {
20692073
expectValue: "high",
20702074
expectErr: false,
20712075
},
2072-
// Case 81: OpenAI to OpenAI, reasoning_effort=xhigh → out of range error
2076+
// Case 81: OpenAI to OpenAI, reasoning_effort=xhigh → clamped to high
20732077
{
20742078
name: "81",
20752079
from: "openai",
20762080
to: "openai",
20772081
model: "level-model",
20782082
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`,
2079-
expectField: "",
2080-
expectErr: true,
2083+
expectField: "reasoning_effort",
2084+
expectValue: "high",
2085+
expectErr: false,
20812086
},
20822087
// Case 82: OpenAI-Response to Codex, reasoning.effort=high → passthrough
20832088
{
@@ -2090,15 +2095,16 @@ func TestThinkingE2EMatrix_Body(t *testing.T) {
20902095
expectValue: "high",
20912096
expectErr: false,
20922097
},
2093-
// Case 83: OpenAI-Response to Codex, reasoning.effort=xhigh → out of range error
2098+
// Case 83: OpenAI-Response to Codex, reasoning.effort=xhigh → clamped to high
20942099
{
20952100
name: "83",
20962101
from: "openai-response",
20972102
to: "codex",
20982103
model: "level-model",
20992104
inputJSON: `{"model":"level-model","input":[{"role":"user","content":"hi"}],"reasoning":{"effort":"xhigh"}}`,
2100-
expectField: "",
2101-
expectErr: true,
2105+
expectField: "reasoning.effort",
2106+
expectValue: "high",
2107+
expectErr: false,
21022108
},
21032109
// Case 84: Gemini to Gemini, thinkingBudget=8192 → passthrough
21042110
{
@@ -2697,12 +2703,16 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) {
26972703
expectErr: false,
26982704
},
26992705
{
2700-
name: "C24",
2701-
from: "claude",
2702-
to: "claude",
2703-
model: "claude-opus-4-6-model",
2704-
inputJSON: `{"model":"claude-opus-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`,
2705-
expectErr: true,
2706+
name: "C24",
2707+
from: "claude",
2708+
to: "claude",
2709+
model: "claude-opus-4-6-model",
2710+
inputJSON: `{"model":"claude-opus-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`,
2711+
expectField: "thinking.type",
2712+
expectValue: "adaptive",
2713+
expectField2: "output_config.effort",
2714+
expectValue2: "max",
2715+
expectErr: false,
27062716
},
27072717
{
27082718
name: "C25",
@@ -2717,20 +2727,28 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) {
27172727
expectErr: false,
27182728
},
27192729
{
2720-
name: "C26",
2721-
from: "claude",
2722-
to: "claude",
2723-
model: "claude-sonnet-4-6-model",
2724-
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`,
2725-
expectErr: true,
2730+
name: "C26",
2731+
from: "claude",
2732+
to: "claude",
2733+
model: "claude-sonnet-4-6-model",
2734+
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`,
2735+
expectField: "thinking.type",
2736+
expectValue: "adaptive",
2737+
expectField2: "output_config.effort",
2738+
expectValue2: "high",
2739+
expectErr: false,
27262740
},
27272741
{
2728-
name: "C27",
2729-
from: "claude",
2730-
to: "claude",
2731-
model: "claude-sonnet-4-6-model",
2732-
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`,
2733-
expectErr: true,
2742+
name: "C27",
2743+
from: "claude",
2744+
to: "claude",
2745+
model: "claude-sonnet-4-6-model",
2746+
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`,
2747+
expectField: "thinking.type",
2748+
expectValue: "adaptive",
2749+
expectField2: "output_config.effort",
2750+
expectValue2: "high",
2751+
expectErr: false,
27342752
},
27352753
}
27362754

0 commit comments

Comments
 (0)