Skip to main content

agent_core/core/
models.rs

1//! Curated list of Claude models known to work with this CLI.
2//! Centralized so the settings dropdown, defaults, and subagent hints agree.
3
4pub const KNOWN_MODELS: &[(&str, &str)] = &[
5    ("claude-sonnet-4-6", "Sonnet 4.6 — balanced (default)"),
6    ("claude-fable-5-1", "Fable 5.1"),
7    ("claude-fable-5", "Fable 5"),
8    ("claude-opus-5-5", "Opus 5.5 — most capable Opus"),
9    ("claude-opus-4-7", "Opus 4.7"),
10    ("claude-opus-4-6", "Opus 4.6 — previous flagship"),
11    ("claude-haiku-4-5-20251001", "Haiku 4.5 — fast"),
12];
13
14pub fn default_model() -> &'static str {
15    KNOWN_MODELS[0].0
16}
17
18/// Returns true for models that support (and require) adaptive thinking:
19/// `thinking: {type: "adaptive"}` with NO `budget_tokens` field.
20///
21/// Per Anthropic's docs as of 2026-04: Opus 4.6+/Sonnet 4.6+ deprecated
22/// the fixed-budget `{type: "enabled", budget_tokens: N}` shape. On those
23/// models the deprecated shape is silently accepted but returns no
24/// thinking content (observed S172 on Opus 4.7). Older models (Opus 4.5,
25/// Sonnet 4.5, Haiku, Opus 3.x) still use the enabled+budget shape.
26///
27/// Adaptive thinking also auto-enables interleaved thinking — no beta
28/// header required.
29/// Whether an Anthropic model supports tool search, i.e. `defer_loading` tool
30/// definitions expanded by `tool_reference` blocks. Anthropic's compatibility
31/// table lists Haiku 4.5, Sonnet 4.5+, Opus 4.5+ and every 5.x family; Opus 4.1
32/// and earlier (and Sonnet 4 / the 3.x line) do not support it. Unknown future
33/// `claude-*` ids are assumed to support it.
34pub fn model_supports_tool_search(model: &str) -> bool {
35    let m = model.to_ascii_lowercase();
36    let m = m.strip_prefix("anthropic/").unwrap_or(&m);
37    if !m.starts_with("claude-") {
38        return false;
39    }
40    const UNSUPPORTED: &[&str] = &[
41        "claude-3",
42        "claude-opus-4-0",
43        "claude-opus-4-1",
44        "claude-opus-4-2025",
45        "claude-sonnet-4-0",
46        "claude-sonnet-4-2025",
47        "claude-instant",
48    ];
49    !UNSUPPORTED.iter().any(|p| m.starts_with(p))
50}
51
52pub fn model_supports_adaptive_thinking(model: &str) -> bool {
53    let m = model.to_ascii_lowercase();
54    // Only Opus 4.7+ REQUIRES adaptive. Opus 4.6 supports it optionally
55    // but works fine with enabled+budget_tokens (and doesn't support the
56    // xhigh effort level that adaptive users expect). Keep 4.6 on the
57    // legacy path to avoid effort-mapping headaches.
58    if m.contains("opus-4-7") || m.contains("opus-4-8") || m.contains("opus-4-9") {
59        return true;
60    }
61    if m.contains("sonnet-4-7") || m.contains("sonnet-4-8") || m.contains("sonnet-4-9") {
62        return true;
63    }
64    // 5.x and beyond — assume adaptive by default.
65    if m.contains("opus-5")
66        || m.contains("sonnet-5")
67        || m.contains("haiku-5")
68        || m.contains("fable-5")
69    {
70        return true;
71    }
72    false
73}
74
75/// Maps a SynapsCLI thinking level to an Anthropic `effort` value for models
76/// that use adaptive thinking (Opus 4.6+/Sonnet 4.6+). Effort controls
77/// thinking depth when `budget_tokens` is unavailable/deprecated.
78///
79/// "adaptive" as input means "let the model decide" — returns None so the
80/// caller omits `output_config.effort` entirely.
81pub fn effort_for_thinking_level(level: &str) -> Option<&'static str> {
82    match level {
83        "low" => Some("low"),
84        "medium" | "med" => Some("medium"),
85        "high" => Some("high"),
86        "xhigh" => Some("xhigh"),
87        "adaptive" => None, // model decides
88        _ => Some("high"),  // safe default
89    }
90}
91
92/// Maps a raw `thinking_budget` value to the user-facing level name.
93///
94/// `0` is the sentinel for "adaptive" (model decides). Positive values
95/// bucket into the four fixed tiers. Single source of truth — consumed by
96/// Runtime::thinking_level, the request builders in runtime/api.rs, and
97/// the status display.
98pub fn thinking_level_for_budget(budget: u32) -> &'static str {
99    match budget {
100        0 => "adaptive",
101        1..=2048 => "low",
102        2049..=4096 => "medium",
103        4097..=16384 => "high",
104        _ => "xhigh",
105    }
106}
107
108/// Inverse of `thinking_level_for_budget` — maps a level name back to its
109/// canonical budget. Returns None for unrecognized labels (callers should
110/// leave the current budget untouched). Used by /resume to restore the
111/// session's serialized thinking level.
112pub fn budget_for_thinking_level(level: &str) -> Option<u32> {
113    match level {
114        "adaptive" | "off" => Some(0),
115        "low" => Some(2048),
116        "medium" => Some(4096),
117        "high" => Some(16384),
118        "xhigh" => Some(32768),
119        _ => None,
120    }
121}
122
123/// Default legacy-model thinking budget used when the "adaptive" sentinel
124/// (0) leaks into the non-adaptive request path. Matches the "high" tier.
125pub const DEFAULT_LEGACY_ADAPTIVE_FALLBACK: u32 = 16384;
126
127/// Returns true for models that *support* opting into the 1M context window
128/// via the `context-1m-2025-08-07` beta header.
129///
130/// Without that header, all current Claude models operate at the default
131/// 200k window — including those documented as "1M tokens", because the
132/// extended window is gated behind the beta opt-in. This matches Anthropic's
133/// own claude-code logic (`modelSupports1M` in src/utils/context.ts).
134pub fn model_supports_1m(model: &str) -> bool {
135    let m = model.to_ascii_lowercase();
136    // Opus 4.6+, Sonnet 4 family. Newer models (5.x) assumed supported.
137    m.contains("opus-4-6")
138        || m.contains("opus-4-7")
139        || m.contains("opus-4-8")
140        || m.contains("opus-4-9")
141        || m.contains("sonnet-4")
142        || m.contains("opus-5")
143        || m.contains("sonnet-5")
144        || m.contains("fable-5")
145}
146
147/// The extended context window a 1M-capable model opens ([`model_supports_1m`]).
148pub const EXTENDED_CONTEXT_WINDOW: u64 = 1_000_000;
149
150/// Whether the Messages API rejects a request whose input plus `max_tokens`
151/// exceeds the model's context window.
152///
153/// Anthropic's context-window docs ("Context window overflow behavior",
154/// <https://platform.claude.com/docs/en/build-with-claude/context-windows#context-window-overflow-behavior>):
155/// on Claude 4.5 models and newer such a request is accepted, and generation
156/// that reaches the window stops with `stop_reason:
157/// "model_context_window_exceeded"`; earlier models return a validation
158/// error instead (unless the `model-context-window-exceeded-2025-08-26` beta
159/// is sent, which Synaps does not). Input alone over the window is a 400 on
160/// every model. So `true` only for the pre-4.5 line: Claude 2/Instant, the
161/// 3.x line, Opus 4 / 4.1 and Sonnet 4. Haiku 4.5, Sonnet/Opus 4.5 and every
162/// later or unknown future `claude-*` id accept overflow, and so does any
163/// non-Claude id: no route but the native Anthropic one sends an output cap.
164pub fn model_rejects_output_overflow(model: &str) -> bool {
165    let m = model.to_ascii_lowercase();
166    let m = m.strip_prefix("anthropic/").unwrap_or(&m);
167    const BEFORE_4_5: &[&str] = &[
168        "claude-2",
169        "claude-instant",
170        "claude-3",
171        "claude-opus-4-0",
172        "claude-opus-4-1",
173        "claude-opus-4-2025",
174        "claude-sonnet-4-0",
175        "claude-sonnet-4-2025",
176    ];
177    matches!(m, "claude-opus-4" | "claude-sonnet-4") || BEFORE_4_5.iter().any(|p| m.starts_with(p))
178}
179
180/// Returns the input context window size for a given model, in tokens.
181/// Used as the denominator for the chatui context-usage bar and anywhere
182/// else the client needs to know how much prompt the model will accept.
183///
184/// **All models default to 200k** — matching Anthropic's claude-code
185/// behavior. Models that support a larger window (Opus 4.6+, Sonnet 4)
186/// require explicit opt-in via the `context-1m-2025-08-07` beta header.
187/// Without the header, the model operates in 200k mode (better inference
188/// quality — context rot at long contexts is real).
189///
190/// Use [`model_supports_1m`] to check whether a model *can* opt into 1M;
191/// the actual decision happens at request time based on the user's
192/// `context_window` setting.
193pub fn context_window_for_model(_model: &str) -> u64 {
194    200_000
195}
196
197#[cfg(test)]
198mod tests {
199    #[test]
200    fn tool_search_support_follows_anthropic_compatibility_table() {
201        for m in [
202            "claude-opus-5-5",
203            "anthropic/claude-sonnet-5-5",
204            "claude-fable-5-1",
205            "claude-opus-4-5-20251101",
206            "claude-sonnet-4-5-20250929",
207            "claude-haiku-4-5-20251001",
208            "claude-opus-4-8",
209        ] {
210            assert!(super::model_supports_tool_search(m), "{m}");
211        }
212        for m in [
213            "claude-opus-4-1-20250805",
214            "claude-opus-4-20250514",
215            "claude-sonnet-4-20250514",
216            "claude-3-7-sonnet-latest",
217            "gpt-5.6-sol",
218            "groq/llama-3.3-70b-versatile",
219        ] {
220            assert!(!super::model_supports_tool_search(m), "{m}");
221        }
222    }
223
224    use super::*;
225
226    #[test]
227    fn default_model_is_first_entry() {
228        assert_eq!(default_model(), KNOWN_MODELS[0].0);
229    }
230
231    #[test]
232    fn known_models_has_expected_ids() {
233        let ids: Vec<&str> = KNOWN_MODELS.iter().map(|(id, _)| *id).collect();
234        assert!(ids.contains(&"claude-fable-5-1"));
235        assert!(ids.contains(&"claude-fable-5"));
236        assert!(ids.contains(&"claude-opus-5-5"));
237        assert!(ids.contains(&"claude-opus-4-7"));
238        assert!(ids.contains(&"claude-sonnet-4-6"));
239        assert!(ids.contains(&"claude-haiku-4-5-20251001"));
240    }
241
242    #[test]
243    fn descriptions_are_non_empty() {
244        for (_, desc) in KNOWN_MODELS {
245            assert!(!desc.is_empty(), "empty description");
246        }
247    }
248
249    #[test]
250    fn budget_for_thinking_level_round_trips() {
251        for budget in [0u32, 2048, 4096, 16384, 32768] {
252            let level = thinking_level_for_budget(budget);
253            assert_eq!(
254                budget_for_thinking_level(level),
255                Some(budget),
256                "level {}",
257                level
258            );
259        }
260        assert_eq!(budget_for_thinking_level("off"), Some(0));
261        assert_eq!(budget_for_thinking_level("custom(8192)"), None);
262    }
263
264    #[test]
265    fn only_models_before_claude_4_5_reject_output_overflow() {
266        for m in [
267            "claude-opus-4-1-20250805",
268            "claude-opus-4-20250514",
269            "claude-opus-4-0",
270            "claude-sonnet-4-20250514",
271            "anthropic/claude-sonnet-4-0",
272            "claude-sonnet-4",
273            "claude-3-7-sonnet-latest",
274            "claude-3-5-haiku-20241022",
275            "CLAUDE-3-OPUS-20240229",
276            "claude-2.1",
277            "claude-instant-1.2",
278        ] {
279            assert!(model_rejects_output_overflow(m), "{m}");
280        }
281        for m in [
282            "claude-haiku-4-5-20251001",
283            "claude-sonnet-4-5-20250929",
284            "claude-opus-4-5-20251101",
285            "claude-sonnet-4-6",
286            "claude-opus-4-7",
287            "anthropic/claude-opus-5-5",
288            "claude-fable-5-1",
289            "claude-sonnet-9-0",
290            "gpt-5.5",
291            "google-gemini/gemini-2.5-pro",
292        ] {
293            assert!(!model_rejects_output_overflow(m), "{m}");
294        }
295    }
296
297    #[test]
298    fn context_window_default_is_200k_for_all_models() {
299        // Without explicit 1M opt-in (context-1m beta), every model is 200k.
300        assert_eq!(context_window_for_model("claude-opus-4-7"), 200_000);
301        assert_eq!(context_window_for_model("claude-opus-4-6"), 200_000);
302        assert_eq!(context_window_for_model("claude-sonnet-4-6"), 200_000);
303        assert_eq!(
304            context_window_for_model("claude-opus-4-5-20251101"),
305            200_000
306        );
307        assert_eq!(
308            context_window_for_model("claude-haiku-4-5-20251001"),
309            200_000
310        );
311    }
312
313    #[test]
314    fn model_supports_1m_for_opus_46_and_sonnet_4() {
315        assert!(model_supports_1m("claude-opus-4-6"));
316        assert!(model_supports_1m("claude-opus-4-7"));
317        assert!(model_supports_1m("claude-sonnet-4-6"));
318        assert!(model_supports_1m("claude-sonnet-4-5-20250929"));
319    }
320
321    #[test]
322    fn model_supports_1m_rejects_older_and_haiku() {
323        assert!(!model_supports_1m("claude-opus-4-5"));
324        assert!(!model_supports_1m("claude-haiku-4-5-20251001"));
325        assert!(!model_supports_1m("claude-opus-3-5"));
326    }
327
328    #[test]
329    fn model_supports_1m_assumed_for_5x() {
330        assert!(model_supports_1m("claude-opus-5-0"));
331        assert!(model_supports_1m("claude-sonnet-5-1"));
332        assert!(model_supports_1m("claude-fable-5"));
333    }
334
335    #[test]
336    fn model_supports_1m_case_insensitive() {
337        assert!(model_supports_1m("CLAUDE-OPUS-4-6"));
338    }
339
340    #[test]
341    fn context_window_haiku_is_200k() {
342        assert_eq!(
343            context_window_for_model("claude-haiku-4-5-20251001"),
344            200_000
345        );
346    }
347
348    #[test]
349    fn context_window_opus3_is_200k() {
350        assert_eq!(
351            context_window_for_model("claude-opus-3-5-20250101"),
352            200_000
353        );
354    }
355
356    #[test]
357    fn context_window_unknown_defaults_200k() {
358        assert_eq!(context_window_for_model("some-future-model"), 200_000);
359        assert_eq!(context_window_for_model(""), 200_000);
360    }
361
362    #[test]
363    fn adaptive_thinking_required_for_opus_4_7_plus() {
364        assert!(model_supports_adaptive_thinking("claude-opus-4-7"));
365        assert!(model_supports_adaptive_thinking("claude-opus-4-8"));
366    }
367
368    #[test]
369    fn adaptive_thinking_not_for_opus_4_6() {
370        // 4.6 uses enabled+budget_tokens (deprecated but functional)
371        assert!(!model_supports_adaptive_thinking("claude-opus-4-6"));
372    }
373
374    #[test]
375    fn adaptive_thinking_not_for_sonnet_4_6() {
376        assert!(!model_supports_adaptive_thinking("claude-sonnet-4-6"));
377    }
378
379    #[test]
380    fn adaptive_thinking_required_for_sonnet_4_7_plus() {
381        assert!(model_supports_adaptive_thinking("claude-sonnet-4-7"));
382    }
383
384    #[test]
385    fn adaptive_thinking_not_for_older_models() {
386        assert!(!model_supports_adaptive_thinking("claude-opus-4-5"));
387        assert!(!model_supports_adaptive_thinking("claude-sonnet-4-5"));
388        assert!(!model_supports_adaptive_thinking(
389            "claude-haiku-4-5-20251001"
390        ));
391        assert!(!model_supports_adaptive_thinking("claude-opus-3-5"));
392    }
393
394    #[test]
395    fn adaptive_thinking_assumed_for_5x() {
396        assert!(model_supports_adaptive_thinking("claude-opus-5-0"));
397        assert!(model_supports_adaptive_thinking("claude-sonnet-5-1"));
398    }
399
400    #[test]
401    fn opus_5_5_is_a_known_adaptive_1m_capable_model() {
402        // Exact wire id is dash-separated, like every other Anthropic id.
403        assert!(KNOWN_MODELS.iter().any(|(id, _)| *id == "claude-opus-5-5"));
404        assert!(!KNOWN_MODELS.iter().any(|(id, _)| *id == "claude-opus-5.5"));
405        assert!(model_supports_adaptive_thinking("claude-opus-5-5"));
406        assert!(model_supports_1m("claude-opus-5-5"));
407        // Not the default: adding a model must not silently change it.
408        assert_ne!(default_model(), "claude-opus-5-5");
409    }
410
411    #[test]
412    fn adaptive_thinking_case_insensitive() {
413        assert!(model_supports_adaptive_thinking("CLAUDE-OPUS-4-7"));
414    }
415}