Skip to content

Commit ad7b86b

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 ad7b86b

3 files changed

Lines changed: 407 additions & 36 deletions

File tree

‎providers/anthropic/anthropic.go‎

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

61+
// Claude models before 4.6 reject adaptive thinking. Unknown versions use
62+
// legacy budget thinking unless explicitly allowlisted.
63+
func supportsAdaptiveThinking(model string) bool {
64+
if defaultsToAdaptiveThinking(model) {
65+
return true
66+
}
67+
major, minor, ok := claudeVersion(model)
68+
if !ok {
69+
return false
70+
}
71+
return major > 4 || (major == 4 && minor >= 6)
72+
}
73+
74+
func claudeVersion(model string) (major, minor int, ok bool) {
75+
model = strings.ToLower(strings.TrimSpace(model))
76+
_, rest, found := strings.Cut(model, "claude-")
77+
if !found {
78+
return 0, 0, false
79+
}
80+
parts := strings.Split(rest, "-")
81+
for i, part := range parts {
82+
v, valid := shortVersionComponent(part)
83+
if !valid {
84+
continue
85+
}
86+
major = v
87+
if i+1 < len(parts) {
88+
if m, validMinor := shortVersionComponent(parts[i+1]); validMinor {
89+
minor = m
90+
}
91+
}
92+
return major, minor, true
93+
}
94+
return 0, 0, false
95+
}
96+
97+
func shortVersionComponent(s string) (int, bool) {
98+
if len(s) == 0 || len(s) > 2 {
99+
return 0, false
100+
}
101+
v, err := strconv.Atoi(s)
102+
if err != nil {
103+
return 0, false
104+
}
105+
return v, true
106+
}
107+
108+
// Anthropic accepts only low, medium, high, or max for output_config.effort.
109+
func normalizeEffort(effort Effort) Effort {
110+
switch effort {
111+
case EffortMinimal:
112+
return EffortLow
113+
case EffortXHigh:
114+
return EffortMax
115+
default:
116+
return effort
117+
}
118+
}
119+
120+
// Anthropic requires at least 1024 thinking tokens. Smaller derived budgets
121+
// disable thinking to preserve the requested output limit.
122+
func legacyEffortBudget(effort Effort, maxTokens int64) int64 {
123+
const minBudget = 1024
124+
var budget int64
125+
switch effort {
126+
case EffortMinimal:
127+
budget = minBudget
128+
case EffortLow:
129+
budget = int64(float64(maxTokens) * 0.2)
130+
case EffortHigh:
131+
budget = int64(float64(maxTokens) * 0.8)
132+
case EffortXHigh:
133+
budget = int64(float64(maxTokens) * 0.9)
134+
case EffortMax:
135+
budget = int64(float64(maxTokens) * 0.95)
136+
default:
137+
budget = int64(float64(maxTokens) * 0.5)
138+
}
139+
// budget_tokens must be strictly less than max_tokens.
140+
if budget >= maxTokens {
141+
budget = maxTokens - 1
142+
}
143+
if budget < minBudget {
144+
return 0
145+
}
146+
return budget
147+
}
148+
149+
func stripThinkingUnsupportedParams(params *anthropic.MessageNewParams, call fantasy.Call, warnings []fantasy.CallWarning) []fantasy.CallWarning {
150+
if call.Temperature != nil {
151+
params.Temperature = param.Opt[float64]{}
152+
warnings = append(warnings, fantasy.CallWarning{
153+
Type: fantasy.CallWarningTypeUnsupportedSetting,
154+
Setting: "temperature",
155+
Details: "temperature is not supported when thinking is enabled",
156+
})
157+
}
158+
if call.TopP != nil {
159+
params.TopP = param.Opt[float64]{}
160+
warnings = append(warnings, fantasy.CallWarning{
161+
Type: fantasy.CallWarningTypeUnsupportedSetting,
162+
Setting: "TopP",
163+
Details: "TopP is not supported when thinking is enabled",
164+
})
165+
}
166+
if call.TopK != nil {
167+
params.TopK = param.Opt[int64]{}
168+
warnings = append(warnings, fantasy.CallWarning{
169+
Type: fantasy.CallWarningTypeUnsupportedSetting,
170+
Setting: "TopK",
171+
Details: "TopK is not supported when thinking is enabled",
172+
})
173+
}
174+
return warnings
175+
}
176+
61177
func setThinkingDisplay(param interface{ SetExtraFields(map[string]any) }, display ThinkingDisplay) {
62178
param.SetExtraFields(map[string]any{"display": string(display)})
63179
}
@@ -392,14 +508,27 @@ func (a languageModel) prepareParams(call fantasy.Call) (
392508
switch {
393509
case providerOptions.Effort != nil:
394510
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)
511+
switch {
512+
case effort == EffortNone:
513+
// An explicit none overrides models that default to adaptive thinking.
514+
case supportsAdaptiveThinking(a.modelID):
515+
params.OutputConfig = anthropic.OutputConfigParam{
516+
Effort: anthropic.OutputConfigEffort(normalizeEffort(effort)),
517+
}
518+
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
519+
if display, ok := thinkingDisplay(providerOptions, a.modelID); ok {
520+
setThinkingDisplay(&adaptive, display)
521+
}
522+
params.Thinking.OfAdaptive = &adaptive
523+
default:
524+
if budget := legacyEffortBudget(effort, params.MaxTokens); budget > 0 {
525+
params.Thinking = anthropic.ThinkingConfigParamOfEnabled(budget)
526+
if display, ok := thinkingDisplay(providerOptions, a.modelID); ok {
527+
setThinkingDisplay(params.Thinking.OfEnabled, display)
528+
}
529+
warnings = stripThinkingUnsupportedParams(params, call, warnings)
530+
}
401531
}
402-
params.Thinking.OfAdaptive = &adaptive
403532
case providerOptions.Thinking != nil:
404533
if providerOptions.Thinking.BudgetTokens == 0 {
405534
return nil, nil, nil, nil, &fantasy.Error{Title: "no budget", Message: "thinking requires budget"}
@@ -416,30 +545,7 @@ func (a languageModel) prepareParams(call fantasy.Call) (
416545
setThinkingDisplay(params.Thinking.OfEnabled, display)
417546
}
418547
}
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-
}
548+
warnings = stripThinkingUnsupportedParams(params, call, warnings)
443549
case defaultsToAdaptiveThinking(a.modelID):
444550
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
445551
if display, ok := thinkingDisplay(providerOptions, a.modelID); ok {

0 commit comments

Comments
 (0)