Skip to content

Commit 1118bd7

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 1118bd7

3 files changed

Lines changed: 566 additions & 36 deletions

File tree

providers/anthropic/anthropic.go

Lines changed: 214 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,177 @@ 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+
// Vertex model IDs put the version before an "@<date>" suffix
81+
// (claude-opus-4-7@20260101).
82+
rest, _, _ = strings.Cut(rest, "@")
83+
parts := strings.Split(rest, "-")
84+
for i, part := range parts {
85+
v, valid := shortVersionComponent(part)
86+
if !valid {
87+
continue
88+
}
89+
major = v
90+
if i+1 < len(parts) {
91+
if m, validMinor := shortVersionComponent(parts[i+1]); validMinor {
92+
minor = m
93+
}
94+
}
95+
return major, minor, true
96+
}
97+
return 0, 0, false
98+
}
99+
100+
func shortVersionComponent(s string) (int, bool) {
101+
if len(s) == 0 || len(s) > 2 {
102+
return 0, false
103+
}
104+
v, err := strconv.Atoi(s)
105+
if err != nil {
106+
return 0, false
107+
}
108+
return v, true
109+
}
110+
111+
// Opus 4.5 accepts output_config.effort alongside manual budget thinking,
112+
// unlike other pre-adaptive models.
113+
func isOpus45(model string) bool {
114+
major, minor, ok := claudeVersion(model)
115+
return ok && major == 4 && minor == 5 &&
116+
strings.Contains(strings.ToLower(strings.TrimSpace(model)), "opus")
117+
}
118+
119+
// Opus 4.5 supports only low, medium, and high effort.
120+
func normalizeOpus45Effort(effort Effort) Effort {
121+
switch effort {
122+
case EffortMinimal:
123+
return EffortLow
124+
case EffortXHigh, EffortMax:
125+
return EffortHigh
126+
default:
127+
return effort
128+
}
129+
}
130+
131+
// Mythos and Fable models run adaptive thinking unconditionally and reject
132+
// thinking: {type: "disabled"} with an HTTP 400.
133+
func rejectsDisabledThinking(model string) bool {
134+
model = strings.ToLower(strings.TrimSpace(model))
135+
return strings.Contains(model, "claude-mythos") || strings.Contains(model, "claude-fable")
136+
}
137+
138+
// Claude 5+ models run adaptive thinking when the request omits the thinking
139+
// field, so an explicit opt-out must send thinking: {type: "disabled"}.
140+
func thinkingOnByDefault(model string) bool {
141+
major, _, ok := claudeVersion(model)
142+
return ok && major >= 5
143+
}
144+
145+
// Extended thinking with budget_tokens shipped with Claude 3.7; older
146+
// models reject the thinking field entirely.
147+
func supportsBudgetThinking(model string) bool {
148+
major, minor, ok := claudeVersion(model)
149+
return ok && (major > 3 || (major == 3 && minor >= 7))
150+
}
151+
152+
// The xhigh effort tier shipped with Claude Opus 4.7; 4.6-era adaptive
153+
// models accept only low, medium, high, and max.
154+
func supportsXHighEffort(model string) bool {
155+
major, minor, ok := claudeVersion(model)
156+
return ok && (major > 4 || (major == 4 && minor >= 7))
157+
}
158+
159+
// Anthropic does not accept minimal for output_config.effort, and xhigh
160+
// only on models that support it.
161+
func normalizeEffort(effort Effort, model string) Effort {
162+
switch effort {
163+
case EffortMinimal:
164+
return EffortLow
165+
case EffortXHigh:
166+
if !supportsXHighEffort(model) {
167+
return EffortMax
168+
}
169+
return effort
170+
default:
171+
return effort
172+
}
173+
}
174+
175+
// Anthropic requires at least 1024 thinking tokens. Smaller derived budgets
176+
// disable thinking to preserve the requested output limit.
177+
func legacyEffortBudget(effort Effort, maxTokens int64) int64 {
178+
const minBudget = 1024
179+
var budget int64
180+
switch effort {
181+
case EffortMinimal:
182+
budget = minBudget
183+
case EffortLow:
184+
budget = int64(float64(maxTokens) * 0.2)
185+
case EffortHigh:
186+
budget = int64(float64(maxTokens) * 0.8)
187+
case EffortXHigh:
188+
budget = int64(float64(maxTokens) * 0.9)
189+
case EffortMax:
190+
budget = int64(float64(maxTokens) * 0.95)
191+
default:
192+
budget = int64(float64(maxTokens) * 0.5)
193+
}
194+
// budget_tokens must be strictly less than max_tokens.
195+
if budget >= maxTokens {
196+
budget = maxTokens - 1
197+
}
198+
if budget < minBudget {
199+
return 0
200+
}
201+
return budget
202+
}
203+
204+
func stripThinkingUnsupportedParams(params *anthropic.MessageNewParams, call fantasy.Call, warnings []fantasy.CallWarning) []fantasy.CallWarning {
205+
if call.Temperature != nil {
206+
params.Temperature = param.Opt[float64]{}
207+
warnings = append(warnings, fantasy.CallWarning{
208+
Type: fantasy.CallWarningTypeUnsupportedSetting,
209+
Setting: "temperature",
210+
Details: "temperature is not supported when thinking is enabled",
211+
})
212+
}
213+
if call.TopP != nil {
214+
params.TopP = param.Opt[float64]{}
215+
warnings = append(warnings, fantasy.CallWarning{
216+
Type: fantasy.CallWarningTypeUnsupportedSetting,
217+
Setting: "TopP",
218+
Details: "TopP is not supported when thinking is enabled",
219+
})
220+
}
221+
if call.TopK != nil {
222+
params.TopK = param.Opt[int64]{}
223+
warnings = append(warnings, fantasy.CallWarning{
224+
Type: fantasy.CallWarningTypeUnsupportedSetting,
225+
Setting: "TopK",
226+
Details: "TopK is not supported when thinking is enabled",
227+
})
228+
}
229+
return warnings
230+
}
231+
61232
func setThinkingDisplay(param interface{ SetExtraFields(map[string]any) }, display ThinkingDisplay) {
62233
param.SetExtraFields(map[string]any{"display": string(display)})
63234
}
@@ -392,14 +563,49 @@ func (a languageModel) prepareParams(call fantasy.Call) (
392563
switch {
393564
case providerOptions.Effort != nil:
394565
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)
566+
switch {
567+
case effort == EffortNone:
568+
switch {
569+
case rejectsDisabledThinking(a.modelID):
570+
warnings = append(warnings, fantasy.CallWarning{
571+
Type: fantasy.CallWarningTypeOther,
572+
Message: "thinking cannot be disabled on " + a.modelID,
573+
})
574+
case thinkingOnByDefault(a.modelID):
575+
disabled := anthropic.NewThinkingConfigDisabledParam()
576+
params.Thinking.OfDisabled = &disabled
577+
}
578+
case supportsAdaptiveThinking(a.modelID):
579+
params.OutputConfig = anthropic.OutputConfigParam{
580+
Effort: anthropic.OutputConfigEffort(normalizeEffort(effort, a.modelID)),
581+
}
582+
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
583+
if display, ok := thinkingDisplay(providerOptions, a.modelID); ok {
584+
setThinkingDisplay(&adaptive, display)
585+
}
586+
params.Thinking.OfAdaptive = &adaptive
587+
default:
588+
if isOpus45(a.modelID) {
589+
params.OutputConfig = anthropic.OutputConfigParam{
590+
Effort: anthropic.OutputConfigEffort(normalizeOpus45Effort(effort)),
591+
}
592+
}
593+
if !supportsBudgetThinking(a.modelID) {
594+
warnings = append(warnings, fantasy.CallWarning{
595+
Type: fantasy.CallWarningTypeUnsupportedSetting,
596+
Setting: "effort",
597+
Details: "thinking is not supported on " + a.modelID,
598+
})
599+
break
600+
}
601+
if budget := legacyEffortBudget(effort, params.MaxTokens); budget > 0 {
602+
params.Thinking = anthropic.ThinkingConfigParamOfEnabled(budget)
603+
if display, ok := thinkingDisplay(providerOptions, a.modelID); ok {
604+
setThinkingDisplay(params.Thinking.OfEnabled, display)
605+
}
606+
warnings = stripThinkingUnsupportedParams(params, call, warnings)
607+
}
401608
}
402-
params.Thinking.OfAdaptive = &adaptive
403609
case providerOptions.Thinking != nil:
404610
if providerOptions.Thinking.BudgetTokens == 0 {
405611
return nil, nil, nil, nil, &fantasy.Error{Title: "no budget", Message: "thinking requires budget"}
@@ -416,30 +622,7 @@ func (a languageModel) prepareParams(call fantasy.Call) (
416622
setThinkingDisplay(params.Thinking.OfEnabled, display)
417623
}
418624
}
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-
}
625+
warnings = stripThinkingUnsupportedParams(params, call, warnings)
443626
case defaultsToAdaptiveThinking(a.modelID):
444627
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
445628
if display, ok := thinkingDisplay(providerOptions, a.modelID); ok {

0 commit comments

Comments
 (0)