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