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.
29pub fn model_supports_adaptive_thinking(model: &str) -> bool {
30    let m = model.to_ascii_lowercase();
31    // Only Opus 4.7+ REQUIRES adaptive. Opus 4.6 supports it optionally
32    // but works fine with enabled+budget_tokens (and doesn't support the
33    // xhigh effort level that adaptive users expect). Keep 4.6 on the
34    // legacy path to avoid effort-mapping headaches.
35    if m.contains("opus-4-7") || m.contains("opus-4-8") || m.contains("opus-4-9") {
36        return true;
37    }
38    if m.contains("sonnet-4-7") || m.contains("sonnet-4-8") || m.contains("sonnet-4-9") {
39        return true;
40    }
41    // 5.x and beyond — assume adaptive by default.
42    if m.contains("opus-5")
43        || m.contains("sonnet-5")
44        || m.contains("haiku-5")
45        || m.contains("fable-5")
46    {
47        return true;
48    }
49    false
50}
51
52/// Maps a SynapsCLI thinking level to an Anthropic `effort` value for models
53/// that use adaptive thinking (Opus 4.6+/Sonnet 4.6+). Effort controls
54/// thinking depth when `budget_tokens` is unavailable/deprecated.
55///
56/// "adaptive" as input means "let the model decide" — returns None so the
57/// caller omits `output_config.effort` entirely.
58pub fn effort_for_thinking_level(level: &str) -> Option<&'static str> {
59    match level {
60        "low" => Some("low"),
61        "medium" | "med" => Some("medium"),
62        "high" => Some("high"),
63        "xhigh" => Some("xhigh"),
64        "adaptive" => None, // model decides
65        _ => Some("high"),  // safe default
66    }
67}
68
69/// Maps a raw `thinking_budget` value to the user-facing level name.
70///
71/// `0` is the sentinel for "adaptive" (model decides). Positive values
72/// bucket into the four fixed tiers. Single source of truth — consumed by
73/// Runtime::thinking_level, the request builders in runtime/api.rs, and
74/// the status display.
75pub fn thinking_level_for_budget(budget: u32) -> &'static str {
76    match budget {
77        0 => "adaptive",
78        1..=2048 => "low",
79        2049..=4096 => "medium",
80        4097..=16384 => "high",
81        _ => "xhigh",
82    }
83}
84
85/// Inverse of `thinking_level_for_budget` — maps a level name back to its
86/// canonical budget. Returns None for unrecognized labels (callers should
87/// leave the current budget untouched). Used by /resume to restore the
88/// session's serialized thinking level.
89pub fn budget_for_thinking_level(level: &str) -> Option<u32> {
90    match level {
91        "adaptive" | "off" => Some(0),
92        "low" => Some(2048),
93        "medium" => Some(4096),
94        "high" => Some(16384),
95        "xhigh" => Some(32768),
96        _ => None,
97    }
98}
99
100/// Default legacy-model thinking budget used when the "adaptive" sentinel
101/// (0) leaks into the non-adaptive request path. Matches the "high" tier.
102pub const DEFAULT_LEGACY_ADAPTIVE_FALLBACK: u32 = 16384;
103
104/// Returns true for models that *support* opting into the 1M context window
105/// via the `context-1m-2025-08-07` beta header.
106///
107/// Without that header, all current Claude models operate at the default
108/// 200k window — including those documented as "1M tokens", because the
109/// extended window is gated behind the beta opt-in. This matches Anthropic's
110/// own claude-code logic (`modelSupports1M` in src/utils/context.ts).
111pub fn model_supports_1m(model: &str) -> bool {
112    let m = model.to_ascii_lowercase();
113    // Opus 4.6+, Sonnet 4 family. Newer models (5.x) assumed supported.
114    m.contains("opus-4-6")
115        || m.contains("opus-4-7")
116        || m.contains("opus-4-8")
117        || m.contains("opus-4-9")
118        || m.contains("sonnet-4")
119        || m.contains("opus-5")
120        || m.contains("sonnet-5")
121        || m.contains("fable-5")
122}
123
124/// Returns the input context window size for a given model, in tokens.
125/// Used as the denominator for the chatui context-usage bar and anywhere
126/// else the client needs to know how much prompt the model will accept.
127///
128/// **All models default to 200k** — matching Anthropic's claude-code
129/// behavior. Models that support a larger window (Opus 4.6+, Sonnet 4)
130/// require explicit opt-in via the `context-1m-2025-08-07` beta header.
131/// Without the header, the model operates in 200k mode (better inference
132/// quality — context rot at long contexts is real).
133///
134/// Use [`model_supports_1m`] to check whether a model *can* opt into 1M;
135/// the actual decision happens at request time based on the user's
136/// `context_window` setting.
137pub fn context_window_for_model(_model: &str) -> u64 {
138    200_000
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn default_model_is_first_entry() {
147        assert_eq!(default_model(), KNOWN_MODELS[0].0);
148    }
149
150    #[test]
151    fn known_models_has_expected_ids() {
152        let ids: Vec<&str> = KNOWN_MODELS.iter().map(|(id, _)| *id).collect();
153        assert!(ids.contains(&"claude-fable-5-1"));
154        assert!(ids.contains(&"claude-fable-5"));
155        assert!(ids.contains(&"claude-opus-5-5"));
156        assert!(ids.contains(&"claude-opus-4-7"));
157        assert!(ids.contains(&"claude-sonnet-4-6"));
158        assert!(ids.contains(&"claude-haiku-4-5-20251001"));
159    }
160
161    #[test]
162    fn descriptions_are_non_empty() {
163        for (_, desc) in KNOWN_MODELS {
164            assert!(!desc.is_empty(), "empty description");
165        }
166    }
167
168    #[test]
169    fn budget_for_thinking_level_round_trips() {
170        for budget in [0u32, 2048, 4096, 16384, 32768] {
171            let level = thinking_level_for_budget(budget);
172            assert_eq!(
173                budget_for_thinking_level(level),
174                Some(budget),
175                "level {}",
176                level
177            );
178        }
179        assert_eq!(budget_for_thinking_level("off"), Some(0));
180        assert_eq!(budget_for_thinking_level("custom(8192)"), None);
181    }
182
183    #[test]
184    fn context_window_default_is_200k_for_all_models() {
185        // Without explicit 1M opt-in (context-1m beta), every model is 200k.
186        assert_eq!(context_window_for_model("claude-opus-4-7"), 200_000);
187        assert_eq!(context_window_for_model("claude-opus-4-6"), 200_000);
188        assert_eq!(context_window_for_model("claude-sonnet-4-6"), 200_000);
189        assert_eq!(
190            context_window_for_model("claude-opus-4-5-20251101"),
191            200_000
192        );
193        assert_eq!(
194            context_window_for_model("claude-haiku-4-5-20251001"),
195            200_000
196        );
197    }
198
199    #[test]
200    fn model_supports_1m_for_opus_46_and_sonnet_4() {
201        assert!(model_supports_1m("claude-opus-4-6"));
202        assert!(model_supports_1m("claude-opus-4-7"));
203        assert!(model_supports_1m("claude-sonnet-4-6"));
204        assert!(model_supports_1m("claude-sonnet-4-5-20250929"));
205    }
206
207    #[test]
208    fn model_supports_1m_rejects_older_and_haiku() {
209        assert!(!model_supports_1m("claude-opus-4-5"));
210        assert!(!model_supports_1m("claude-haiku-4-5-20251001"));
211        assert!(!model_supports_1m("claude-opus-3-5"));
212    }
213
214    #[test]
215    fn model_supports_1m_assumed_for_5x() {
216        assert!(model_supports_1m("claude-opus-5-0"));
217        assert!(model_supports_1m("claude-sonnet-5-1"));
218        assert!(model_supports_1m("claude-fable-5"));
219    }
220
221    #[test]
222    fn model_supports_1m_case_insensitive() {
223        assert!(model_supports_1m("CLAUDE-OPUS-4-6"));
224    }
225
226    #[test]
227    fn context_window_haiku_is_200k() {
228        assert_eq!(
229            context_window_for_model("claude-haiku-4-5-20251001"),
230            200_000
231        );
232    }
233
234    #[test]
235    fn context_window_opus3_is_200k() {
236        assert_eq!(
237            context_window_for_model("claude-opus-3-5-20250101"),
238            200_000
239        );
240    }
241
242    #[test]
243    fn context_window_unknown_defaults_200k() {
244        assert_eq!(context_window_for_model("some-future-model"), 200_000);
245        assert_eq!(context_window_for_model(""), 200_000);
246    }
247
248    #[test]
249    fn adaptive_thinking_required_for_opus_4_7_plus() {
250        assert!(model_supports_adaptive_thinking("claude-opus-4-7"));
251        assert!(model_supports_adaptive_thinking("claude-opus-4-8"));
252    }
253
254    #[test]
255    fn adaptive_thinking_not_for_opus_4_6() {
256        // 4.6 uses enabled+budget_tokens (deprecated but functional)
257        assert!(!model_supports_adaptive_thinking("claude-opus-4-6"));
258    }
259
260    #[test]
261    fn adaptive_thinking_not_for_sonnet_4_6() {
262        assert!(!model_supports_adaptive_thinking("claude-sonnet-4-6"));
263    }
264
265    #[test]
266    fn adaptive_thinking_required_for_sonnet_4_7_plus() {
267        assert!(model_supports_adaptive_thinking("claude-sonnet-4-7"));
268    }
269
270    #[test]
271    fn adaptive_thinking_not_for_older_models() {
272        assert!(!model_supports_adaptive_thinking("claude-opus-4-5"));
273        assert!(!model_supports_adaptive_thinking("claude-sonnet-4-5"));
274        assert!(!model_supports_adaptive_thinking(
275            "claude-haiku-4-5-20251001"
276        ));
277        assert!(!model_supports_adaptive_thinking("claude-opus-3-5"));
278    }
279
280    #[test]
281    fn adaptive_thinking_assumed_for_5x() {
282        assert!(model_supports_adaptive_thinking("claude-opus-5-0"));
283        assert!(model_supports_adaptive_thinking("claude-sonnet-5-1"));
284    }
285
286    #[test]
287    fn opus_5_5_is_a_known_adaptive_1m_capable_model() {
288        // Exact wire id is dash-separated, like every other Anthropic id.
289        assert!(KNOWN_MODELS.iter().any(|(id, _)| *id == "claude-opus-5-5"));
290        assert!(!KNOWN_MODELS.iter().any(|(id, _)| *id == "claude-opus-5.5"));
291        assert!(model_supports_adaptive_thinking("claude-opus-5-5"));
292        assert!(model_supports_1m("claude-opus-5-5"));
293        // Not the default: adding a model must not silently change it.
294        assert_ne!(default_model(), "claude-opus-5-5");
295    }
296
297    #[test]
298    fn adaptive_thinking_case_insensitive() {
299        assert!(model_supports_adaptive_thinking("CLAUDE-OPUS-4-7"));
300    }
301}