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