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