Skip to main content

vtcode_llm/providers/anthropic/
capabilities.rs

1//! Model capability detection for Anthropic Claude models
2//!
3//! Provides methods to determine what features each Claude model supports:
4//! - Reasoning/extended thinking
5//! - Vision (image inputs)
6//! - Structured outputs
7//! - Parallel tool configuration
8//! - Context window sizes
9
10use crate::providers::anthropic_types::{ThinkingConfig, ThinkingDisplay};
11use vtcode_config::constants::{models, reasoning};
12
13const CLAUDE_OPUS_4_8: &str = "claude-opus-4-8";
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub(crate) enum ClaudeThinkingMode {
17    ManualBudget,
18    Adaptive,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub(crate) struct ClaudeThinkingProfile {
23    pub mode: ClaudeThinkingMode,
24    pub supports_manual_budget: bool,
25    pub adaptive_only: bool,
26    pub default_thinking_enabled: bool,
27    pub manual_interleaved_beta: bool,
28    pub supports_effort: bool,
29    pub supports_task_budget: bool,
30    pub default_display: ThinkingDisplay,
31    pub default_effort: &'static str,
32    pub supports_xhigh_effort: bool,
33    pub supports_max_effort: bool,
34    /// `max_tokens` sent when the caller does not set one. It caps thinking
35    /// plus response text together, so it must leave room for adaptive
36    /// thinking on agentic turns while staying within the model's output limit.
37    pub default_max_tokens: u32,
38    /// Whether `tool_choice` `any`/`tool` is rejected with a 400 regardless
39    /// of the thinking config. Such requests must fall back to `auto`.
40    pub rejects_forced_tool_choice: bool,
41    /// Whether the model accepts server-side refusal fallbacks (the
42    /// `fallbacks` request parameter). Sonnet 5 is not listed as supported.
43    pub supports_server_side_fallback: bool,
44    /// Whether `thinking: {"type": "disabled"}` is rejected with a 400. Such
45    /// models take `{"type": "between_tools"}` as their lowest setting, which
46    /// keeps up-front thinking off while still returning the progress notes
47    /// written between tool calls. Adaptive-only models reject every
48    /// "thinking off" request, so they leave this `false`.
49    pub rejects_disabled_thinking: bool,
50}
51
52/// Default `max_tokens` for Claude 5.x models: a starting point for agentic
53/// coding that stays within their 128K output limit.
54const CLAUDE_5_DEFAULT_MAX_TOKENS: u32 = 64_000;
55/// Default `max_tokens` for unprofiled models when thinking is enabled.
56const LEGACY_THINKING_DEFAULT_MAX_TOKENS: u32 = 16_000;
57/// Default `max_tokens` for unprofiled models when thinking is disabled.
58const LEGACY_DEFAULT_MAX_TOKENS: u32 = 4_096;
59
60const ANTHROPIC_EFFORTS_UP_TO_HIGH: &[&str] = &[reasoning::LOW, reasoning::MEDIUM, reasoning::HIGH];
61const ANTHROPIC_EFFORTS_UP_TO_MAX: &[&str] = &[reasoning::LOW, reasoning::MEDIUM, reasoning::HIGH, reasoning::MAX];
62const ANTHROPIC_EFFORTS_UP_TO_XHIGH_AND_MAX: &[&str] = &[
63    reasoning::LOW,
64    reasoning::MEDIUM,
65    reasoning::HIGH,
66    reasoning::XHIGH,
67    reasoning::MAX,
68];
69
70pub(crate) fn resolve_model_name<'a>(model: &'a str, default_model: &'a str) -> &'a str {
71    if model.trim().is_empty() { default_model } else { model }
72}
73
74pub(crate) fn matches_model(model: &str, candidate: &str) -> bool {
75    model == candidate || model.contains(candidate)
76}
77
78pub(crate) fn claude_thinking_profile(model: &str, default_model: &str) -> Option<ClaudeThinkingProfile> {
79    let requested = resolve_model_name(model, default_model);
80
81    // Check most specific models first – `matches_model` uses `contains`,
82    // so `claude-fable-5-1` contains `claude-fable-5`. The 5.1 variants
83    // must be checked before their 5 counterparts.
84    if matches_model(requested, models::anthropic::CLAUDE_FABLE_5_1) {
85        return Some(ClaudeThinkingProfile {
86            mode: ClaudeThinkingMode::Adaptive,
87            supports_manual_budget: false,
88            adaptive_only: true,
89            default_thinking_enabled: true,
90            manual_interleaved_beta: false,
91            supports_effort: true,
92            supports_task_budget: true,
93            default_display: ThinkingDisplay::Omitted,
94            default_effort: reasoning::HIGH,
95            supports_xhigh_effort: true,
96            supports_max_effort: true,
97            default_max_tokens: CLAUDE_5_DEFAULT_MAX_TOKENS,
98            rejects_forced_tool_choice: true,
99            supports_server_side_fallback: true,
100            rejects_disabled_thinking: false,
101        });
102    }
103
104    // `claude-sonnet-5-5` contains `claude-sonnet-5`, so the 5.5 profile must
105    // be checked before the 5 profile. Sonnet 5.5 runs adaptive thinking by
106    // default but rejects `disabled`: its lowest setting is `between_tools`,
107    // which also fails at `xhigh`/`max` effort. Forced tool use and sampling
108    // parameters are rejected outright.
109    if matches_model(requested, models::anthropic::CLAUDE_SONNET_5_5) {
110        return Some(ClaudeThinkingProfile {
111            mode: ClaudeThinkingMode::Adaptive,
112            supports_manual_budget: false,
113            adaptive_only: false,
114            default_thinking_enabled: true,
115            manual_interleaved_beta: false,
116            supports_effort: true,
117            supports_task_budget: false,
118            default_display: ThinkingDisplay::Omitted,
119            default_effort: reasoning::HIGH,
120            supports_xhigh_effort: true,
121            supports_max_effort: true,
122            default_max_tokens: CLAUDE_5_DEFAULT_MAX_TOKENS,
123            rejects_forced_tool_choice: true,
124            supports_server_side_fallback: true,
125            rejects_disabled_thinking: true,
126        });
127    }
128
129    if matches_model(requested, models::anthropic::CLAUDE_SONNET_5) {
130        return Some(ClaudeThinkingProfile {
131            mode: ClaudeThinkingMode::Adaptive,
132            supports_manual_budget: false,
133            adaptive_only: false,
134            default_thinking_enabled: true,
135            manual_interleaved_beta: false,
136            supports_effort: true,
137            supports_task_budget: false,
138            default_display: ThinkingDisplay::Omitted,
139            default_effort: reasoning::HIGH,
140            supports_xhigh_effort: true,
141            supports_max_effort: true,
142            default_max_tokens: CLAUDE_5_DEFAULT_MAX_TOKENS,
143            rejects_forced_tool_choice: false,
144            supports_server_side_fallback: false,
145            rejects_disabled_thinking: false,
146        });
147    }
148
149    // Claude Haiku 5.5 runs adaptive thinking by default with a `medium`
150    // default effort. Unlike Sonnet 5.5/Opus 5.5/Fable 5.1 it accepts forced
151    // `tool_choice` (the response starts with the tool call and has no
152    // thinking block) and it has no server-side refusal fallback.
153    if matches_model(requested, models::anthropic::CLAUDE_HAIKU_5_5) {
154        return Some(ClaudeThinkingProfile {
155            mode: ClaudeThinkingMode::Adaptive,
156            supports_manual_budget: false,
157            adaptive_only: false,
158            default_thinking_enabled: true,
159            manual_interleaved_beta: false,
160            supports_effort: true,
161            supports_task_budget: false,
162            default_display: ThinkingDisplay::Omitted,
163            default_effort: reasoning::MEDIUM,
164            supports_xhigh_effort: true,
165            supports_max_effort: true,
166            default_max_tokens: CLAUDE_5_DEFAULT_MAX_TOKENS,
167            rejects_forced_tool_choice: false,
168            supports_server_side_fallback: false,
169            rejects_disabled_thinking: false,
170        });
171    }
172
173    if matches_model(requested, models::anthropic::CLAUDE_FABLE_5) {
174        return Some(ClaudeThinkingProfile {
175            mode: ClaudeThinkingMode::Adaptive,
176            supports_manual_budget: false,
177            adaptive_only: true,
178            default_thinking_enabled: true,
179            manual_interleaved_beta: false,
180            supports_effort: true,
181            supports_task_budget: true,
182            default_display: ThinkingDisplay::Omitted,
183            default_effort: reasoning::HIGH,
184            supports_xhigh_effort: true,
185            supports_max_effort: true,
186            default_max_tokens: CLAUDE_5_DEFAULT_MAX_TOKENS,
187            rejects_forced_tool_choice: false,
188            supports_server_side_fallback: true,
189            rejects_disabled_thinking: false,
190        });
191    }
192
193    // `claude-opus-5-5` contains `claude-opus-5`, so the 5.5 profile must be
194    // checked before the 5 profile. Opus 5.5 runs adaptive thinking always on
195    // (it cannot be disabled) with a `medium` default effort.
196    if matches_model(requested, models::anthropic::CLAUDE_OPUS_5_5) {
197        return Some(ClaudeThinkingProfile {
198            mode: ClaudeThinkingMode::Adaptive,
199            supports_manual_budget: false,
200            adaptive_only: true,
201            default_thinking_enabled: true,
202            manual_interleaved_beta: false,
203            supports_effort: true,
204            supports_task_budget: true,
205            default_display: ThinkingDisplay::Omitted,
206            default_effort: reasoning::MEDIUM,
207            supports_xhigh_effort: true,
208            supports_max_effort: true,
209            default_max_tokens: CLAUDE_5_DEFAULT_MAX_TOKENS,
210            rejects_forced_tool_choice: true,
211            supports_server_side_fallback: true,
212            rejects_disabled_thinking: false,
213        });
214    }
215
216    if matches_model(requested, models::anthropic::CLAUDE_OPUS_5) {
217        return Some(ClaudeThinkingProfile {
218            mode: ClaudeThinkingMode::Adaptive,
219            supports_manual_budget: false,
220            adaptive_only: false,
221            default_thinking_enabled: true,
222            manual_interleaved_beta: false,
223            supports_effort: true,
224            supports_task_budget: true,
225            default_display: ThinkingDisplay::Omitted,
226            default_effort: reasoning::HIGH,
227            supports_xhigh_effort: true,
228            supports_max_effort: true,
229            default_max_tokens: CLAUDE_5_DEFAULT_MAX_TOKENS,
230            rejects_forced_tool_choice: false,
231            supports_server_side_fallback: true,
232            rejects_disabled_thinking: false,
233        });
234    }
235
236    None
237}
238
239/// Claude 5.x family ids. `matches_model` uses `contains`, so the Fable 5 id
240/// also matches `claude-fable-5-1`, the Opus 5 id matches `claude-opus-5-5`,
241/// and the Sonnet 5 id matches `claude-sonnet-5-5`. The 5.5 ids (including
242/// Haiku 5.5) are listed explicitly so the family stays readable as it grows.
243const CLAUDE_5_FAMILY: &[&str] = &[
244    models::anthropic::CLAUDE_SONNET_5_5,
245    models::anthropic::CLAUDE_SONNET_5,
246    models::anthropic::CLAUDE_HAIKU_5_5,
247    models::anthropic::CLAUDE_FABLE_5,
248    models::anthropic::CLAUDE_OPUS_5_5,
249    models::anthropic::CLAUDE_OPUS_5,
250];
251
252fn is_claude_5_family(model: &str) -> bool {
253    CLAUDE_5_FAMILY.iter().any(|candidate| matches_model(model, candidate))
254}
255
256fn supports_native_1m_context(model: &str) -> bool {
257    is_claude_5_family(model)
258}
259
260pub(crate) fn supports_reasoning(model: &str, default_model: &str) -> bool {
261    let requested = resolve_model_name(model, default_model);
262    if claude_thinking_profile(requested, default_model).is_some() {
263        return true;
264    }
265
266    models::minimax::SUPPORTED_MODELS.contains(&requested)
267}
268
269pub(crate) fn supports_reasoning_effort(model: &str, default_model: &str) -> bool {
270    let requested = resolve_model_name(model, default_model);
271
272    if claude_thinking_profile(requested, default_model).is_some() {
273        return true;
274    }
275
276    if models::minimax::SUPPORTED_MODELS.contains(&requested) {
277        return true;
278    }
279
280    models::anthropic::REASONING_MODELS.contains(&requested)
281}
282
283pub(crate) fn supports_effort(model: &str, default_model: &str) -> bool {
284    claude_thinking_profile(model, default_model).is_some_and(|profile| profile.supports_effort)
285}
286
287pub(crate) fn supports_task_budget(model: &str, default_model: &str) -> bool {
288    claude_thinking_profile(model, default_model).is_some_and(|profile| profile.supports_task_budget)
289}
290
291pub(crate) fn supports_manual_thinking_budget(model: &str, default_model: &str) -> bool {
292    claude_thinking_profile(model, default_model).is_some_and(|profile| profile.supports_manual_budget)
293}
294
295pub(crate) fn supports_manual_interleaved_beta(model: &str, default_model: &str) -> bool {
296    claude_thinking_profile(model, default_model).is_some_and(|profile| profile.manual_interleaved_beta)
297}
298
299/// Whether the model accepts a request whose last message is an assistant
300/// turn (assistant-message prefill). A trailing assistant turn returns 400 on
301/// Claude Opus/Sonnet 4.6 and later, which covers every profiled Claude 5.x
302/// model. Older Claude models and non-Claude Anthropic-compatible backends
303/// (MiniMax and similar) accept it, so their history is sent unchanged. A
304/// Claude id without a recognizable version is treated as a current model.
305pub(crate) fn supports_assistant_prefill(model: &str, default_model: &str) -> bool {
306    let requested = resolve_model_name(model, default_model);
307    if claude_thinking_profile(requested, default_model).is_some() {
308        return false;
309    }
310
311    let lowered = requested.to_ascii_lowercase();
312    let Some(start) = lowered.find("claude") else {
313        return true;
314    };
315    claude_version(&lowered[start..]).is_some_and(|version| version < (4, 6))
316}
317
318/// Extracts `(major, minor)` from a Claude model id such as
319/// `claude-opus-4-1`, `claude-3-5-sonnet-20240620`, `claude-sonnet-4@20250514`
320/// or `claude-2.1`. The first short numeric segment is the major version and a
321/// directly following short numeric segment is the minor version; eight-digit
322/// date snapshots are not version segments.
323fn claude_version(id: &str) -> Option<(u32, u32)> {
324    let mut segments = id
325        .split(['-', '.', '@', ':', '_'])
326        .skip_while(|segment| !is_version_segment(segment));
327    let major = segments.next()?.parse().ok()?;
328    let minor = segments
329        .next()
330        .filter(|segment| is_version_segment(segment))
331        .and_then(|segment| segment.parse().ok())
332        .unwrap_or(0);
333    Some((major, minor))
334}
335
336fn is_version_segment(segment: &str) -> bool {
337    (1..=2).contains(&segment.len()) && segment.bytes().all(|b| b.is_ascii_digit())
338}
339
340pub(crate) fn supports_mid_conversation_system_messages(model: &str, default_model: &str) -> bool {
341    supports_turn_scoped_system_messages(model, default_model)
342}
343
344/// Whether the model accepts Anthropic's turn-scoped `clear_at` system-message
345/// field. The current beta is available to the model families that accept
346/// mid-conversation system messages, but not Sonnet 5 (Sonnet 5.5 adds it
347/// alongside per-message effort and mid-conversation tool changes).
348pub(crate) fn supports_turn_scoped_system_messages(model: &str, default_model: &str) -> bool {
349    let requested = resolve_model_name(model, default_model);
350    matches_model(requested, models::anthropic::CLAUDE_FABLE_5)
351        || matches_model(requested, CLAUDE_OPUS_4_8)
352        || matches_model(requested, models::anthropic::CLAUDE_OPUS_5)
353        || matches_model(requested, models::anthropic::CLAUDE_SONNET_5_5)
354}
355
356pub(crate) fn adaptive_thinking_always_on(model: &str, default_model: &str) -> bool {
357    claude_thinking_profile(model, default_model).is_some_and(|profile| profile.adaptive_only)
358}
359
360/// The `max_tokens` to send when the request does not set one. Profiled
361/// models use their profile default regardless of the thinking field, since
362/// they think adaptively even when it is omitted; other models fall back to
363/// the legacy defaults.
364pub(crate) fn default_max_tokens_for_model(model: &str, default_model: &str, thinking_enabled: bool) -> u32 {
365    match claude_thinking_profile(model, default_model) {
366        Some(profile) => profile.default_max_tokens,
367        None if thinking_enabled => LEGACY_THINKING_DEFAULT_MAX_TOKENS,
368        None => LEGACY_DEFAULT_MAX_TOKENS,
369    }
370}
371
372/// Whether `model` rejects forced tool use (`tool_choice` `any`/`tool`)
373/// even when thinking is off.
374pub(crate) fn rejects_forced_tool_choice(model: &str, default_model: &str) -> bool {
375    claude_thinking_profile(model, default_model).is_some_and(|profile| profile.rejects_forced_tool_choice)
376}
377
378/// Whether `model` accepts server-side refusal fallbacks. Unprofiled models
379/// never get fallbacks requested on their behalf.
380pub(crate) fn supports_server_side_fallback(model: &str, default_model: &str) -> bool {
381    claude_thinking_profile(model, default_model).is_some_and(|profile| profile.supports_server_side_fallback)
382}
383
384/// Whether a request to `model` with this `thinking` field runs with thinking
385/// on. An omitted field means the model's default, which is on for every
386/// profiled Claude 5.x model (Opus 5.5 omits `disabled` because it rejects it).
387pub(crate) fn thinking_is_on(thinking: Option<&ThinkingConfig>, model: &str, default_model: &str) -> bool {
388    match thinking {
389        // `between_tools` turns up-front thinking off; only the progress notes
390        // written between tool calls still come back as thinking blocks.
391        Some(ThinkingConfig::Disabled) | Some(ThinkingConfig::BetweenTools) => false,
392        // Unknown configs are treated as thinking so callers stay conservative.
393        Some(_) => true,
394        None => claude_thinking_profile(model, default_model).is_some_and(|profile| profile.default_thinking_enabled),
395    }
396}
397
398/// Whether the model enforces "preserved thinking": each replayed thinking
399/// block's signature binds the exact prefix that produced it (top-level
400/// `system`, the `tools` set, and every earlier message). Any reorder or edit
401/// of earlier turns invalidates every later thinking block (a 400 on
402/// enforced accounts, a silent drop otherwise), so request builders must keep
403/// history append-only for these models.
404pub(crate) fn preserves_thinking_across_turns(model: &str, default_model: &str) -> bool {
405    let requested = resolve_model_name(model, default_model);
406    matches_model(requested, models::anthropic::CLAUDE_SONNET_5_5)
407        || matches_model(requested, models::anthropic::CLAUDE_HAIKU_5_5)
408        || matches_model(requested, models::anthropic::CLAUDE_OPUS_5_5)
409        || matches_model(requested, models::anthropic::CLAUDE_FABLE_5_1)
410}
411
412/// Whether the model accepts `thinking.display: "updates"` (beta
413/// `thinking-display-updates-2026-08-18`): its between-tool progress notes
414/// come back as their own `thinking` blocks. Other models reject the value.
415/// `claude-fable-5-1` contains the Fable 5 id, and `claude-sonnet-5-5` and
416/// `claude-opus-5-5` contain their 5-generation ids, so all four releases match.
417pub(crate) fn supports_thinking_display_updates(model: &str, default_model: &str) -> bool {
418    let requested = resolve_model_name(model, default_model);
419    matches_model(requested, models::anthropic::CLAUDE_SONNET_5_5)
420        || matches_model(requested, models::anthropic::CLAUDE_OPUS_5_5)
421        || matches_model(requested, models::anthropic::CLAUDE_FABLE_5)
422}
423
424/// Display used when neither the request nor `[provider.anthropic]` sets one.
425/// On Claude Opus 5.5 and Claude Sonnet 5.5 the text written between tool calls
426/// arrives only as progress-update thinking blocks, which the API default
427/// (`omitted`) empties; requesting `updates` keeps that narration visible in
428/// the reasoning view while the reasoning itself stays hidden.
429pub(crate) fn default_thinking_display(model: &str, default_model: &str) -> Option<ThinkingDisplay> {
430    let requested = resolve_model_name(model, default_model);
431    (matches_model(requested, models::anthropic::CLAUDE_SONNET_5_5)
432        || matches_model(requested, models::anthropic::CLAUDE_OPUS_5_5))
433    .then_some(ThinkingDisplay::Updates)
434}
435
436pub(crate) fn default_effort_for_model(model: &str, default_model: &str) -> Option<&'static str> {
437    claude_thinking_profile(model, default_model)
438        .filter(|profile| profile.supports_effort)
439        .map(|profile| profile.default_effort)
440}
441
442pub(crate) fn allowed_efforts_for_model(model: &str, default_model: &str) -> Option<&'static [&'static str]> {
443    let profile = claude_thinking_profile(model, default_model)?;
444    if !profile.supports_effort {
445        return None;
446    }
447
448    if profile.supports_xhigh_effort {
449        Some(ANTHROPIC_EFFORTS_UP_TO_XHIGH_AND_MAX)
450    } else if profile.supports_max_effort {
451        Some(ANTHROPIC_EFFORTS_UP_TO_MAX)
452    } else {
453        Some(ANTHROPIC_EFFORTS_UP_TO_HIGH)
454    }
455}
456
457pub(crate) fn effort_allowed_for_model(model: &str, default_model: &str, effort: &str) -> bool {
458    let normalized = effort.trim().to_ascii_lowercase();
459    allowed_efforts_for_model(model, default_model).is_some_and(|allowed| allowed.contains(&normalized.as_str()))
460}
461
462pub(crate) fn supports_compaction(model: &str) -> bool {
463    is_claude_5_family(model)
464}
465
466pub(crate) fn supports_parallel_tool_config(_model: &str) -> bool {
467    true
468}
469
470pub fn effective_context_size(model: &str) -> usize {
471    if supports_native_1m_context(model) {
472        1_000_000
473    } else {
474        200_000
475    }
476}
477
478pub(crate) fn rejects_sampling(model: &str, default_model: &str) -> bool {
479    is_claude_5_family(resolve_model_name(model, default_model))
480}
481
482/// Pre-5.x Claude models without a thinking profile that still accept
483/// structured outputs.
484const LEGACY_STRUCTURED_OUTPUT_MODELS: &[&str] = &["claude-sonnet-4-5", "claude-opus-4-5"];
485
486pub(crate) fn supports_structured_output(model: &str, default_model: &str) -> bool {
487    let requested = resolve_model_name(model, default_model);
488
489    // All models with a thinking profile support structured outputs.
490    if claude_thinking_profile(requested, default_model).is_some() {
491        return true;
492    }
493
494    LEGACY_STRUCTURED_OUTPUT_MODELS
495        .iter()
496        .any(|candidate| matches_model(requested, candidate))
497}
498
499/// Model ids that accept structured outputs, derived from
500/// `supports_structured_output` so user-facing errors cannot drift from it:
501/// every supported model with a thinking profile, then the legacy ids.
502pub(crate) fn structured_output_models() -> Vec<&'static str> {
503    models::anthropic::SUPPORTED_MODELS
504        .iter()
505        .copied()
506        .filter(|model| supports_structured_output(model, ""))
507        .chain(LEGACY_STRUCTURED_OUTPUT_MODELS.iter().copied())
508        .collect()
509}
510
511pub(crate) fn supports_vision(model: &str, default_model: &str) -> bool {
512    let requested = resolve_model_name(model, default_model);
513
514    // All models with a thinking profile support vision.
515    if claude_thinking_profile(requested, default_model).is_some() {
516        return true;
517    }
518
519    // Legacy Claude 3 and Claude 4 Sonnet families support vision.
520    requested.starts_with("claude-3") || requested.starts_with("claude-4-sonnet")
521}
522
523pub fn is_claude_model(model: &str, default_model: &str) -> bool {
524    claude_thinking_profile(model, default_model).is_some()
525}
526
527pub(crate) fn supported_models() -> Vec<String> {
528    let mut supported: Vec<String> = models::anthropic::SUPPORTED_MODELS.iter().map(|s| s.to_string()).collect();
529
530    supported.extend(models::minimax::SUPPORTED_MODELS.iter().map(|s| s.to_string()));
531
532    supported.sort();
533    supported.dedup();
534    supported
535}
536
537/// Returns true if the effective effort for this request is "low", "medium", or "high"
538/// (i.e., at most high, not xhigh or max).
539///
540/// This is used by Opus 5 disabled-thinking validation: Opus 5 only allows
541/// `thinking: {type: "disabled"}` at effort ≤ high. When the override is `Omit`,
542/// the API uses the model default effort, so we check that instead of the config.
543pub(crate) fn effort_is_at_most_high(
544    request: &crate::provider::LLMRequest,
545    anthropic_config: &vtcode_config::core::AnthropicConfig,
546) -> bool {
547    use crate::provider::{AnthropicOptionalStringOverride, LLMRequest};
548    use vtcode_config::types::ReasoningEffortLevel;
549
550    if let Some(overrides) = request.anthropic_request_overrides.as_ref() {
551        match &overrides.effort {
552            AnthropicOptionalStringOverride::Explicit(effort) => {
553                return effort_str_is_at_most_high(effort);
554            }
555            AnthropicOptionalStringOverride::Omit => {
556                return default_effort_for_model(&request.model, "").is_some_and(effort_str_is_at_most_high);
557            }
558            AnthropicOptionalStringOverride::Inherit => {}
559        }
560    }
561
562    if let Some(effort) = request.effort.as_ref() {
563        return effort_str_is_at_most_high(effort);
564    }
565    if let Some(effort) = request.reasoning_effort {
566        return matches!(effort, ReasoningEffortLevel::Low | ReasoningEffortLevel::Medium | ReasoningEffortLevel::High);
567    }
568    match anthropic_config.effort {
569        Some(effort) => effort_str_is_at_most_high(effort.as_str()),
570        None => default_effort_for_model(&request.model, "").is_some_and(effort_str_is_at_most_high),
571    }
572}
573
574/// Returns true if `effort` names one of the `low`, `medium`, or `high`
575/// levels (case-insensitive).
576pub(crate) fn effort_str_is_at_most_high(effort: &str) -> bool {
577    let normalized = effort.trim().to_ascii_lowercase();
578    matches!(normalized.as_str(), reasoning::LOW | reasoning::MEDIUM | reasoning::HIGH)
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn opus_5_5_runs_adaptive_thinking_always_on_with_medium_default() {
587        let profile = claude_thinking_profile(models::anthropic::CLAUDE_OPUS_5_5, "").expect("opus 5.5 profile");
588        assert!(profile.adaptive_only);
589        assert_eq!(profile.default_effort, reasoning::MEDIUM);
590        assert!(profile.supports_xhigh_effort);
591        assert!(profile.supports_max_effort);
592        assert!(adaptive_thinking_always_on(models::anthropic::CLAUDE_OPUS_5_5, ""));
593        assert_eq!(default_effort_for_model(models::anthropic::CLAUDE_OPUS_5_5, ""), Some(reasoning::MEDIUM));
594    }
595
596    #[test]
597    fn sonnet_5_5_keeps_default_thinking_but_rejects_disabled() {
598        // `claude-sonnet-5-5` contains `claude-sonnet-5`; the 5.5 profile must
599        // win for the 5.5 id: `disabled` is a 400 there, `between_tools` is not.
600        let profile = claude_thinking_profile(models::anthropic::CLAUDE_SONNET_5_5, "").expect("sonnet 5.5 profile");
601        assert!(!profile.adaptive_only);
602        assert!(profile.default_thinking_enabled);
603        assert!(!profile.supports_manual_budget);
604        assert!(profile.rejects_disabled_thinking);
605        assert_eq!(profile.default_effort, reasoning::HIGH);
606        assert!(!profile.supports_task_budget);
607        assert!(profile.rejects_forced_tool_choice);
608        assert!(profile.supports_server_side_fallback);
609        assert!(!adaptive_thinking_always_on(models::anthropic::CLAUDE_SONNET_5_5, ""));
610        assert!(thinking_is_on(None, models::anthropic::CLAUDE_SONNET_5_5, ""));
611        assert!(!thinking_is_on(Some(&ThinkingConfig::Disabled), models::anthropic::CLAUDE_SONNET_5_5, ""));
612        assert!(!thinking_is_on(Some(&ThinkingConfig::BetweenTools), models::anthropic::CLAUDE_SONNET_5_5, ""));
613    }
614
615    #[test]
616    fn sonnet_5_profile_is_unchanged_by_sonnet_5_5() {
617        let profile = claude_thinking_profile(models::anthropic::CLAUDE_SONNET_5, "").expect("sonnet 5 profile");
618        assert!(!profile.adaptive_only);
619        assert_eq!(profile.default_effort, reasoning::HIGH);
620        assert!(!profile.rejects_disabled_thinking);
621        assert!(!profile.rejects_forced_tool_choice);
622    }
623
624    #[test]
625    fn opus_5_profile_is_unchanged_by_opus_5_5() {
626        // `claude-opus-5-5` contains `claude-opus-5`; the 5 profile must still
627        // resolve for the exact 5 id with its high default and opt-out support.
628        let profile = claude_thinking_profile(models::anthropic::CLAUDE_OPUS_5, "").expect("opus 5 profile");
629        assert!(!profile.adaptive_only);
630        assert_eq!(profile.default_effort, reasoning::HIGH);
631        assert!(!adaptive_thinking_always_on(models::anthropic::CLAUDE_OPUS_5, ""));
632    }
633
634    #[test]
635    fn claude_5_models_default_to_64k_max_tokens_regardless_of_thinking() {
636        for model in [
637            models::anthropic::CLAUDE_FABLE_5_1,
638            models::anthropic::CLAUDE_SONNET_5_5,
639            models::anthropic::CLAUDE_SONNET_5,
640            models::anthropic::CLAUDE_HAIKU_5_5,
641            models::anthropic::CLAUDE_FABLE_5,
642            models::anthropic::CLAUDE_OPUS_5_5,
643            models::anthropic::CLAUDE_OPUS_5,
644        ] {
645            let profile = claude_thinking_profile(model, "").expect("claude 5.x profile");
646            assert_eq!(profile.default_max_tokens, 64_000, "{model}");
647            assert_eq!(default_max_tokens_for_model(model, "", true), 64_000, "{model}");
648            assert_eq!(default_max_tokens_for_model(model, "", false), 64_000, "{model}");
649        }
650    }
651
652    #[test]
653    fn forced_tool_choice_is_rejected_by_sonnet_5_5_opus_5_5_and_fable_5_1() {
654        for model in [
655            models::anthropic::CLAUDE_SONNET_5_5,
656            models::anthropic::CLAUDE_OPUS_5_5,
657            models::anthropic::CLAUDE_FABLE_5_1,
658        ] {
659            assert!(rejects_forced_tool_choice(model, ""), "{model}");
660        }
661        for model in [
662            models::anthropic::CLAUDE_SONNET_5,
663            models::anthropic::CLAUDE_HAIKU_5_5,
664            models::anthropic::CLAUDE_FABLE_5,
665            models::anthropic::CLAUDE_OPUS_5,
666            "claude-unlisted-model",
667        ] {
668            assert!(!rejects_forced_tool_choice(model, ""), "{model}");
669        }
670    }
671
672    #[test]
673    fn haiku_5_5_runs_adaptive_thinking_with_medium_default_and_no_fallback() {
674        let profile = claude_thinking_profile(models::anthropic::CLAUDE_HAIKU_5_5, "").expect("haiku 5.5 profile");
675        assert_eq!(profile.mode, ClaudeThinkingMode::Adaptive);
676        assert!(!profile.adaptive_only);
677        assert!(profile.default_thinking_enabled);
678        assert!(!profile.supports_manual_budget);
679        assert!(profile.supports_effort);
680        assert_eq!(profile.default_effort, reasoning::MEDIUM);
681        assert_eq!(default_effort_for_model(models::anthropic::CLAUDE_HAIKU_5_5, ""), Some(reasoning::MEDIUM));
682        assert!(!profile.rejects_forced_tool_choice);
683        assert!(!profile.supports_server_side_fallback);
684        assert!(!profile.rejects_disabled_thinking);
685        assert!(!adaptive_thinking_always_on(models::anthropic::CLAUDE_HAIKU_5_5, ""));
686    }
687
688    #[test]
689    fn omitted_thinking_follows_the_model_default() {
690        assert!(thinking_is_on(None, models::anthropic::CLAUDE_OPUS_5_5, ""));
691        assert!(!thinking_is_on(None, "claude-unlisted-model", ""));
692        assert!(!thinking_is_on(Some(&ThinkingConfig::Disabled), models::anthropic::CLAUDE_SONNET_5, ""));
693        assert!(thinking_is_on(Some(&ThinkingConfig::Adaptive { display: None }), "claude-unlisted-model", ""));
694    }
695
696    #[test]
697    fn unprofiled_models_keep_legacy_max_tokens_defaults() {
698        assert_eq!(default_max_tokens_for_model("claude-unlisted-model", "", true), 16_000);
699        assert_eq!(default_max_tokens_for_model("claude-unlisted-model", "", false), 4_096);
700    }
701
702    #[test]
703    fn empty_model_uses_the_default_model_max_tokens() {
704        assert_eq!(default_max_tokens_for_model("", models::anthropic::CLAUDE_SONNET_5, false), 64_000);
705    }
706
707    #[test]
708    fn preserved_thinking_is_limited_to_prefix_bound_models() {
709        for model in [
710            models::anthropic::CLAUDE_SONNET_5_5,
711            models::anthropic::CLAUDE_HAIKU_5_5,
712            models::anthropic::CLAUDE_OPUS_5_5,
713            models::anthropic::CLAUDE_FABLE_5_1,
714        ] {
715            assert!(preserves_thinking_across_turns(model, ""), "{model}");
716        }
717        assert!(preserves_thinking_across_turns("", models::anthropic::CLAUDE_SONNET_5_5));
718        for model in [
719            models::anthropic::CLAUDE_SONNET_5,
720            models::anthropic::CLAUDE_OPUS_5,
721            models::anthropic::CLAUDE_FABLE_5,
722        ] {
723            assert!(!preserves_thinking_across_turns(model, ""), "{model}");
724        }
725    }
726
727    #[test]
728    fn thinking_display_updates_support_and_default() {
729        for model in [
730            models::anthropic::CLAUDE_SONNET_5_5,
731            models::anthropic::CLAUDE_OPUS_5_5,
732            models::anthropic::CLAUDE_FABLE_5_1,
733            models::anthropic::CLAUDE_FABLE_5,
734        ] {
735            assert!(supports_thinking_display_updates(model, ""), "{model}");
736        }
737        for model in [models::anthropic::CLAUDE_OPUS_5, models::anthropic::CLAUDE_SONNET_5] {
738            assert!(!supports_thinking_display_updates(model, ""), "{model}");
739        }
740
741        for model in [models::anthropic::CLAUDE_SONNET_5_5, models::anthropic::CLAUDE_OPUS_5_5] {
742            assert_eq!(default_thinking_display(model, ""), Some(ThinkingDisplay::Updates), "{model}");
743        }
744        for model in [models::anthropic::CLAUDE_OPUS_5, models::anthropic::CLAUDE_SONNET_5] {
745            assert_eq!(default_thinking_display(model, ""), None, "{model}");
746        }
747    }
748
749    #[test]
750    fn claude_5_family_capabilities_cover_every_profiled_model() {
751        for model in [
752            models::anthropic::CLAUDE_SONNET_5_5,
753            models::anthropic::CLAUDE_SONNET_5,
754            models::anthropic::CLAUDE_HAIKU_5_5,
755            models::anthropic::CLAUDE_FABLE_5,
756            models::anthropic::CLAUDE_FABLE_5_1,
757            models::anthropic::CLAUDE_OPUS_5,
758            models::anthropic::CLAUDE_OPUS_5_5,
759        ] {
760            assert!(claude_thinking_profile(model, "").is_some(), "{model}");
761            assert_eq!(effective_context_size(model), 1_000_000, "{model}");
762            assert!(supports_compaction(model), "{model}");
763            assert!(rejects_sampling(model, ""), "{model}");
764            assert!(supports_structured_output(model, ""), "{model}");
765        }
766        assert!(rejects_sampling("", models::anthropic::CLAUDE_OPUS_5_5));
767
768        for model in [CLAUDE_OPUS_4_8, "claude-sonnet-4-5", models::minimax::MINIMAX_M3, ""] {
769            assert_eq!(effective_context_size(model), 200_000, "{model}");
770            assert!(!supports_compaction(model), "{model}");
771            assert!(!rejects_sampling(model, ""), "{model}");
772        }
773        assert!(supports_structured_output("claude-sonnet-4-5", ""));
774        assert!(supports_structured_output("claude-opus-4-5", ""));
775        assert!(!supports_structured_output(models::minimax::MINIMAX_M3, ""));
776    }
777
778    #[test]
779    fn assistant_prefill_is_rejected_by_claude_4_6_and_later() {
780        for model in [
781            models::anthropic::CLAUDE_SONNET_5_5,
782            models::anthropic::CLAUDE_SONNET_5,
783            models::anthropic::CLAUDE_HAIKU_5_5,
784            models::anthropic::CLAUDE_FABLE_5,
785            models::anthropic::CLAUDE_FABLE_5_1,
786            models::anthropic::CLAUDE_OPUS_5,
787            models::anthropic::CLAUDE_OPUS_5_5,
788            CLAUDE_OPUS_4_8,
789            "claude-opus-4-7",
790            "claude-opus-4-6",
791            "claude-sonnet-4-6",
792            "claude-sonnet-4-6@20260101",
793            "anthropic.claude-opus-4-6-v1:0",
794            "claude-mythos-preview",
795            "claude-unlisted-model",
796        ] {
797            assert!(!supports_assistant_prefill(model, ""), "{model}");
798        }
799        assert!(!supports_assistant_prefill("", models::anthropic::CLAUDE_OPUS_5_5));
800    }
801
802    #[test]
803    fn assistant_prefill_is_kept_for_older_claude_and_non_claude_backends() {
804        for model in [
805            "claude-haiku-4-5",
806            "claude-sonnet-4-5-20250929",
807            "claude-opus-4-1",
808            "claude-sonnet-4-20250514",
809            "claude-3-7-sonnet-20250219",
810            "claude-2.1",
811            models::minimax::MINIMAX_M3,
812            "glm-4.6",
813        ] {
814            assert!(supports_assistant_prefill(model, ""), "{model}");
815        }
816    }
817}