1use 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 pub default_max_tokens: u32,
38 pub rejects_forced_tool_choice: bool,
41 pub supports_server_side_fallback: bool,
44 pub rejects_disabled_thinking: bool,
50}
51
52const CLAUDE_5_DEFAULT_MAX_TOKENS: u32 = 64_000;
55const LEGACY_THINKING_DEFAULT_MAX_TOKENS: u32 = 16_000;
57const 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 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 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 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 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
239const 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
299pub(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
318fn 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
344pub(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
360pub(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
372pub(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
378pub(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
384pub(crate) fn thinking_is_on(thinking: Option<&ThinkingConfig>, model: &str, default_model: &str) -> bool {
388 match thinking {
389 Some(ThinkingConfig::Disabled) | Some(ThinkingConfig::BetweenTools) => false,
392 Some(_) => true,
394 None => claude_thinking_profile(model, default_model).is_some_and(|profile| profile.default_thinking_enabled),
395 }
396}
397
398pub(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
412pub(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
424pub(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
482const 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 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
499pub(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 if claude_thinking_profile(requested, default_model).is_some() {
516 return true;
517 }
518
519 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
537pub(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
574pub(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 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 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}