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