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