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