Skip to content

Commit 7b40c06

Browse files
committed
fix(providers/anthropic): convert reasoning effort to budget thinking on pre-4.6 models
Effort previously always sent thinking type "adaptive" plus output_config.effort, which models older than Claude 4.6 reject with an HTTP 400 ("adaptive thinking is not supported on this model"). - Gate the adaptive path on supportsAdaptiveThinking (Claude >= 4.6, parsed from the model ID, tolerant of Bedrock prefixes and both name orders; unparseable names are treated as legacy). - On legacy models, convert effort into enabled thinking with a budget derived from max_tokens (ratios mirror the coder/aibridge shim), omitting thinking when the budget would fall below the API's 1024-token minimum. - Normalize effort values outside the output_config.effort enum: minimal maps to low, xhigh maps to max, and none disables thinking entirely.
1 parent 6da0c3b commit 7b40c06

3 files changed

Lines changed: 456 additions & 35 deletions

File tree

providers/anthropic/anthropic.go

Lines changed: 153 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,136 @@ func requiresAdaptiveThinking(model string) bool {
5858
return defaultsToAdaptiveThinking(model) || defaultsToOmittedOpusThinkingDisplay(model)
5959
}
6060

61+
// supportsAdaptiveThinking reports whether the model accepts thinking
62+
// type "adaptive". Adaptive thinking shipped with Claude 4.6; older
63+
// models only accept enabled thinking with budget_tokens and reject
64+
// adaptive requests with an HTTP 400. Models whose version cannot be
65+
// parsed are treated as legacy unless explicitly allowlisted.
66+
func supportsAdaptiveThinking(model string) bool {
67+
if defaultsToAdaptiveThinking(model) {
68+
return true
69+
}
70+
major, minor, ok := claudeVersion(model)
71+
if !ok {
72+
return false
73+
}
74+
return major > 4 || (major == 4 && minor >= 6)
75+
}
76+
77+
// claudeVersion extracts the Claude version from a model ID, tolerating
78+
// Bedrock prefixes/suffixes (us.anthropic.claude-haiku-4-5-20251001-v1:0)
79+
// and both name orders (claude-sonnet-4-6, claude-3-5-haiku). Segments
80+
// longer than two digits, such as dates, are not version components.
81+
func claudeVersion(model string) (major, minor int, ok bool) {
82+
model = strings.ToLower(strings.TrimSpace(model))
83+
_, rest, found := strings.Cut(model, "claude-")
84+
if !found {
85+
return 0, 0, false
86+
}
87+
parts := strings.Split(rest, "-")
88+
for i, part := range parts {
89+
v, valid := shortVersionComponent(part)
90+
if !valid {
91+
continue
92+
}
93+
major = v
94+
if i+1 < len(parts) {
95+
if m, validMinor := shortVersionComponent(parts[i+1]); validMinor {
96+
minor = m
97+
}
98+
}
99+
return major, minor, true
100+
}
101+
return 0, 0, false
102+
}
103+
104+
func shortVersionComponent(s string) (int, bool) {
105+
if len(s) == 0 || len(s) > 2 {
106+
return 0, false
107+
}
108+
v, err := strconv.Atoi(s)
109+
if err != nil || v < 0 {
110+
return 0, false
111+
}
112+
return v, true
113+
}
114+
115+
// normalizeEffort maps the wider Effort scale onto the values accepted
116+
// by output_config.effort (low|medium|high|max).
117+
func normalizeEffort(effort Effort) Effort {
118+
switch effort {
119+
case EffortMinimal:
120+
return EffortLow
121+
case EffortXHigh:
122+
return EffortMax
123+
default:
124+
return effort
125+
}
126+
}
127+
128+
// legacyEffortBudget converts an effort level into an enabled-thinking
129+
// token budget for models without adaptive thinking. The ratios mirror
130+
// the coder/aibridge Anthropic shim. Returns 0 when the budget would
131+
// fall below the API's 1024-token minimum, in which case thinking must
132+
// be omitted entirely.
133+
func legacyEffortBudget(effort Effort, maxTokens int64) int64 {
134+
const minBudget = 1024
135+
var budget int64
136+
switch effort {
137+
case EffortMinimal:
138+
budget = minBudget
139+
case EffortLow:
140+
budget = int64(float64(maxTokens) * 0.2)
141+
case EffortHigh:
142+
budget = int64(float64(maxTokens) * 0.8)
143+
case EffortXHigh:
144+
budget = int64(float64(maxTokens) * 0.9)
145+
case EffortMax:
146+
budget = int64(float64(maxTokens) * 0.95)
147+
default:
148+
// EffortMedium and unrecognized values.
149+
budget = int64(float64(maxTokens) * 0.5)
150+
}
151+
// budget_tokens must be strictly less than max_tokens.
152+
if budget >= maxTokens {
153+
budget = maxTokens - 1
154+
}
155+
if budget < minBudget {
156+
return 0
157+
}
158+
return budget
159+
}
160+
161+
// stripThinkingUnsupportedParams clears sampling parameters that the
162+
// API rejects when thinking is enabled, emitting a warning for each.
163+
func stripThinkingUnsupportedParams(params *anthropic.MessageNewParams, call fantasy.Call, warnings []fantasy.CallWarning) []fantasy.CallWarning {
164+
if call.Temperature != nil {
165+
params.Temperature = param.Opt[float64]{}
166+
warnings = append(warnings, fantasy.CallWarning{
167+
Type: fantasy.CallWarningTypeUnsupportedSetting,
168+
Setting: "temperature",
169+
Details: "temperature is not supported when thinking is enabled",
170+
})
171+
}
172+
if call.TopP != nil {
173+
params.TopP = param.Opt[float64]{}
174+
warnings = append(warnings, fantasy.CallWarning{
175+
Type: fantasy.CallWarningTypeUnsupportedSetting,
176+
Setting: "TopP",
177+
Details: "TopP is not supported when thinking is enabled",
178+
})
179+
}
180+
if call.TopK != nil {
181+
params.TopK = param.Opt[int64]{}
182+
warnings = append(warnings, fantasy.CallWarning{
183+
Type: fantasy.CallWarningTypeUnsupportedSetting,
184+
Setting: "TopK",
185+
Details: "TopK is not supported when thinking is enabled",
186+
})
187+
}
188+
return warnings
189+
}
190+
61191
func setThinkingDisplay(param interface{ SetExtraFields(map[string]any) }, display ThinkingDisplay) {
62192
param.SetExtraFields(map[string]any{"display": string(display)})
63193
}
@@ -392,14 +522,29 @@ func (a languageModel) prepareParams(call fantasy.Call) (
392522
switch {
393523
case providerOptions.Effort != nil:
394524
effort := *providerOptions.Effort
395-
params.OutputConfig = anthropic.OutputConfigParam{
396-
Effort: anthropic.OutputConfigEffort(effort),
397-
}
398-
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
399-
if display, ok := thinkingDisplay(providerOptions, a.modelID); ok {
400-
setThinkingDisplay(&adaptive, display)
525+
switch {
526+
case effort == EffortNone:
527+
// Reasoning is explicitly disabled: send no thinking
528+
// configuration. Omitting thinking is valid even for models
529+
// that default to adaptive thinking.
530+
case supportsAdaptiveThinking(a.modelID):
531+
params.OutputConfig = anthropic.OutputConfigParam{
532+
Effort: anthropic.OutputConfigEffort(normalizeEffort(effort)),
533+
}
534+
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
535+
if display, ok := thinkingDisplay(providerOptions, a.modelID); ok {
536+
setThinkingDisplay(&adaptive, display)
537+
}
538+
params.Thinking.OfAdaptive = &adaptive
539+
default:
540+
if budget := legacyEffortBudget(effort, params.MaxTokens); budget > 0 {
541+
params.Thinking = anthropic.ThinkingConfigParamOfEnabled(budget)
542+
if display, ok := thinkingDisplay(providerOptions, a.modelID); ok {
543+
setThinkingDisplay(params.Thinking.OfEnabled, display)
544+
}
545+
warnings = stripThinkingUnsupportedParams(params, call, warnings)
546+
}
401547
}
402-
params.Thinking.OfAdaptive = &adaptive
403548
case providerOptions.Thinking != nil:
404549
if providerOptions.Thinking.BudgetTokens == 0 {
405550
return nil, nil, nil, nil, &fantasy.Error{Title: "no budget", Message: "thinking requires budget"}
@@ -416,30 +561,7 @@ func (a languageModel) prepareParams(call fantasy.Call) (
416561
setThinkingDisplay(params.Thinking.OfEnabled, display)
417562
}
418563
}
419-
if call.Temperature != nil {
420-
params.Temperature = param.Opt[float64]{}
421-
warnings = append(warnings, fantasy.CallWarning{
422-
Type: fantasy.CallWarningTypeUnsupportedSetting,
423-
Setting: "temperature",
424-
Details: "temperature is not supported when thinking is enabled",
425-
})
426-
}
427-
if call.TopP != nil {
428-
params.TopP = param.Opt[float64]{}
429-
warnings = append(warnings, fantasy.CallWarning{
430-
Type: fantasy.CallWarningTypeUnsupportedSetting,
431-
Setting: "TopP",
432-
Details: "TopP is not supported when thinking is enabled",
433-
})
434-
}
435-
if call.TopK != nil {
436-
params.TopK = param.Opt[int64]{}
437-
warnings = append(warnings, fantasy.CallWarning{
438-
Type: fantasy.CallWarningTypeUnsupportedSetting,
439-
Setting: "TopK",
440-
Details: "TopK is not supported when thinking is enabled",
441-
})
442-
}
564+
warnings = stripThinkingUnsupportedParams(params, call, warnings)
443565
case defaultsToAdaptiveThinking(a.modelID):
444566
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
445567
if display, ok := thinkingDisplay(providerOptions, a.modelID); ok {

0 commit comments

Comments
 (0)