Skip to main content

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