@@ -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+
61191func 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