Skip to main content

mermaid_cli/models/
catalog.rs

1//! The data-driven model-capability catalog: one FIRST-MATCH-WINS const table
2//! replacing the model-name string gates that were scattered across the
3//! adapters (anthropic thinking/temperature/effort tiers, gemini thinking
4//! dispatch, ollama's gpt-oss think-string, openai-compat reasoning-model and
5//! vision markers) and the domain's static context windows.
6//!
7//! Each COLUMN is consulted only by the consumers named on its field doc, so
8//! a cross-provider match can never change behavior an adapter didn't already
9//! have. Ollama's `/api/show` probes remain authoritative for local models'
10//! vision/thinking/context — the catalog never overrides a probe.
11//!
12//! Deliberate behavior deltas from the old scattered gates (both accuracy
13//! fixes, pinned by tests): matching is uniformly case-insensitive (the old
14//! openai-compat reasoning gate was case-sensitive), and the gpt-5/gpt-4.1
15//! static 400k context window now applies through any provider (previously
16//! gated on `provider == "openai"`).
17
18/// How a catalog row matches a model id.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum MatchRule {
21    /// Anchors at the start of the BARE name (any `provider/` prefix stripped
22    /// via the last `/`) — the historical `starts_with` gate semantics.
23    Prefix(&'static str),
24    /// Searches the FULL id including provider prefixes — the historical
25    /// vision-marker semantics (the same model appears under many ids).
26    Substring(&'static str),
27}
28
29impl MatchRule {
30    /// Whether this rule matches the (lowercased) full id / bare name pair.
31    fn matches(&self, full: &str, bare: &str) -> bool {
32        match self {
33            MatchRule::Prefix(p) => bare.starts_with(p),
34            MatchRule::Substring(s) => full.contains(s),
35        }
36    }
37}
38
39/// Which thinking/reasoning wire shape the model's requests use.
40/// Consumed by the anthropic, gemini, and ollama adapters — each reacts only
41/// to its own variants and treats everything else as `ProviderDefault`.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum ThinkingShape {
44    /// No catalog opinion: the adapter falls back to its provider default
45    /// (anthropic → legacy `budget_tokens`; gemini → omit `thinkingConfig`;
46    /// ollama → `think: bool` gated by the live probe; openai-compat →
47    /// the `ProviderProfile` strategy).
48    ProviderDefault,
49    /// Anthropic 4.6+ adaptive thinking: `thinking: {type: "adaptive"}` +
50    /// `output_config.effort` (legacy `budget_tokens` is rejected/deprecated).
51    AnthropicAdaptive,
52    /// Anthropic legacy thinking: `thinking: {type: "enabled", budget_tokens}`.
53    AnthropicBudget,
54    /// Gemini 3.x `thinkingLevel` enum (cannot truly disable).
55    GeminiLevel,
56    /// Gemini 2.5 integer `thinkingBudget`, clamped to the model's range.
57    GeminiBudget {
58        /// Lowest non-zero budget the model accepts.
59        min: i32,
60        /// Whether `thinkingBudget: 0` is valid for this model.
61        can_disable: bool,
62    },
63    /// Ollama gpt-oss: `think: "low"|"medium"|"high"` string enum instead of
64    /// the usual `think: bool`.
65    OllamaEffortString,
66}
67
68/// Highest `output_config.effort` tier the model accepts (ordered). Consumed
69/// by the anthropic adapter only. Every xhigh-capable model also accepts
70/// `max` (verified against the effort doc), so one ordered ceiling encodes
71/// the old supports_effort / supports_max_effort / supports_xhigh_effort
72/// trio: `> None` ⇒ effort accepted, `>= Max` ⇒ "max" accepted,
73/// `>= XHigh` ⇒ "xhigh" accepted.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
75pub enum EffortCeiling {
76    /// `output_config.effort` is rejected outright (4.5-family Sonnet/Haiku
77    /// and older) — the request must carry no effort field.
78    None,
79    /// Accepts up to `"high"`.
80    High,
81    /// Accepts up to `"max"` (Opus 4.6 / Sonnet 4.6).
82    Max,
83    /// Accepts `"xhigh"` (Opus 4.7/4.8, Fable 5, Mythos).
84    XHigh,
85}
86
87/// One catalog row: a match rule plus the capability columns.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub struct ModelCapEntry {
90    /// How this row matches (see [`MatchRule`]); first matching row wins.
91    pub rule: MatchRule,
92    /// Thinking wire shape — consumed by anthropic/gemini/ollama builders.
93    pub thinking: ThinkingShape,
94    /// Whether the model accepts a top-level `temperature`. Consumed by the
95    /// anthropic and openai-compat request builders (the 4.6+ adaptive line
96    /// and the o-series/gpt-5 reasoning models 400 on it).
97    pub supports_temperature: bool,
98    /// Highest accepted effort tier — consumed by the anthropic adapter only.
99    pub effort_ceiling: EffortCeiling,
100    /// Advertised vision capability — consumed by openai-compat
101    /// `derive_capabilities` only (anthropic/gemini hardcode true; ollama
102    /// probes `/api/show`). Never gates the send.
103    pub vision: bool,
104    /// Documented static context window — ONLY for models whose provider
105    /// API exposes no limits (OpenAI's gpt rows). Everything else resolves
106    /// live via `resolve_context_window`; `None` = unknown until discovery.
107    pub context_window: Option<usize>,
108}
109
110/// Shorthand for a row that only marks vision support (the generic
111/// multimodal-family markers).
112const fn vision_marker(marker: &'static str) -> ModelCapEntry {
113    ModelCapEntry {
114        rule: MatchRule::Substring(marker),
115        vision: true,
116        ..UNKNOWN_MODEL
117    }
118}
119
120/// Shorthand for an anthropic-family row (vision; window/output resolved
121/// live from the Models API — no static pins, they rot).
122const fn claude(
123    rule: MatchRule,
124    thinking: ThinkingShape,
125    supports_temperature: bool,
126    effort_ceiling: EffortCeiling,
127) -> ModelCapEntry {
128    ModelCapEntry {
129        rule,
130        thinking,
131        supports_temperature,
132        effort_ceiling,
133        vision: true,
134        context_window: None,
135    }
136}
137
138/// The default row for models no rule matches: provider-default thinking,
139/// temperature accepted, no effort, no vision, no static window.
140pub const UNKNOWN_MODEL: ModelCapEntry = ModelCapEntry {
141    rule: MatchRule::Substring(""),
142    thinking: ThinkingShape::ProviderDefault,
143    supports_temperature: true,
144    effort_ceiling: EffortCeiling::None,
145    vision: false,
146    context_window: None,
147};
148
149use EffortCeiling as E;
150use MatchRule::{Prefix, Substring};
151use ThinkingShape as T;
152
153/// The catalog. ORDER MATTERS — first match wins. Keep more-specific rules
154/// above the family catch-alls they share a prefix/substring with (pinned by
155/// the ordering tests): `gemini-2.5-flash-lite` before `gemini-2.5-flash`,
156/// the specific `claude-*` prefixes before the bare `claude-` catch-all,
157/// `gpt-5` Prefix (temperature rejected) before `gpt-5` Substring (accepted).
158pub const CATALOG: &[ModelCapEntry] = &[
159    // --- Anthropic, specific families (bare-name prefixes) ---
160    claude(
161        Prefix("claude-opus-4-7"),
162        T::AnthropicAdaptive,
163        false,
164        E::XHigh,
165    ),
166    claude(
167        Prefix("claude-opus-4-8"),
168        T::AnthropicAdaptive,
169        false,
170        E::XHigh,
171    ),
172    claude(
173        Prefix("claude-fable-5"),
174        T::AnthropicAdaptive,
175        false,
176        E::XHigh,
177    ),
178    claude(
179        Prefix("claude-mythos"),
180        T::AnthropicAdaptive,
181        false,
182        E::XHigh,
183    ),
184    claude(
185        Prefix("claude-opus-4-6"),
186        T::AnthropicAdaptive,
187        true,
188        E::Max,
189    ),
190    claude(
191        Prefix("claude-sonnet-4-6"),
192        T::AnthropicAdaptive,
193        true,
194        E::Max,
195    ),
196    claude(Prefix("claude-opus-4-5"), T::AnthropicBudget, true, E::High),
197    claude(
198        Prefix("claude-sonnet-4-5"),
199        T::AnthropicBudget,
200        true,
201        E::None,
202    ),
203    claude(
204        Prefix("claude-haiku-4-5"),
205        T::AnthropicBudget,
206        true,
207        E::None,
208    ),
209    // Opus 4.1, and (via the "-2" prefix) date-suffixed Opus 4 ids like
210    // claude-opus-4-20250514 — both document a 32k output ceiling.
211    claude(Prefix("claude-opus-4-1"), T::AnthropicBudget, true, E::None),
212    claude(Prefix("claude-opus-4-2"), T::AnthropicBudget, true, E::None),
213    // Claude 3.5 family: 8k output ceiling.
214    claude(Prefix("claude-3-5"), T::AnthropicBudget, true, E::None),
215    // --- Claude via gateways (full-id substrings; the vision-marker set) ---
216    claude(
217        Substring("claude-opus-4"),
218        T::AnthropicBudget,
219        true,
220        E::None,
221    ),
222    claude(
223        Substring("claude-sonnet-4"),
224        T::AnthropicBudget,
225        true,
226        E::None,
227    ),
228    claude(
229        Substring("claude-haiku-4"),
230        T::AnthropicBudget,
231        true,
232        E::None,
233    ),
234    claude(Substring("claude-3"), T::AnthropicBudget, true, E::None),
235    claude(Substring("claude-4"), T::AnthropicBudget, true, E::None),
236    // Bare catch-all for any other anthropic-direct id (claude-2, future
237    // names) — NOT in the gateway vision-marker set (claude-2 was never
238    // advertised as vision-capable).
239    ModelCapEntry {
240        rule: Prefix("claude-"),
241        thinking: T::AnthropicBudget,
242        supports_temperature: true,
243        effort_ceiling: E::None,
244        vision: false,
245        context_window: None,
246    },
247    // --- OpenAI reasoning models (temperature rejected) ---
248    ModelCapEntry {
249        rule: Prefix("o1"),
250        ..OPENAI_REASONING
251    },
252    ModelCapEntry {
253        rule: Prefix("o3"),
254        ..OPENAI_REASONING
255    },
256    ModelCapEntry {
257        rule: Prefix("o4"),
258        ..OPENAI_REASONING
259    },
260    // Meta Model API's /v1/models exposes no limit metadata (Model schema is
261    // id/object/created/owned_by/metadata — verified against the API
262    // reference 2026-07-09), so like the gpt rows below the documented window
263    // is static. Prefix so future muse-spark revisions inherit the family
264    // window instead of regressing to unknown.
265    ModelCapEntry {
266        rule: Prefix("muse-spark"),
267        vision: true,
268        context_window: Some(crate::constants::META_MUSE_SPARK_CONTEXT_WINDOW),
269        ..UNKNOWN_MODEL
270    },
271    // gpt-5.6 rows must stay ABOVE the gpt-5 rows — they share the prefix
272    // and first match wins. OpenAI's /v1/models exposes no limits, so these
273    // windows are static-but-documented (1.5M).
274    ModelCapEntry {
275        rule: Prefix("gpt-5.6"),
276        supports_temperature: false,
277        vision: true,
278        context_window: Some(1_500_000),
279        ..UNKNOWN_MODEL
280    },
281    ModelCapEntry {
282        rule: Substring("gpt-5.6"),
283        vision: true,
284        context_window: Some(1_500_000),
285        ..UNKNOWN_MODEL
286    },
287    ModelCapEntry {
288        rule: Prefix("gpt-5"),
289        supports_temperature: false,
290        vision: true,
291        context_window: Some(400_000),
292        ..UNKNOWN_MODEL
293    },
294    // A gpt-5 id NOT at the start of the bare name (historically not a
295    // "reasoning model", so temperature stays) — still vision + 400k.
296    ModelCapEntry {
297        rule: Substring("gpt-5"),
298        vision: true,
299        context_window: Some(400_000),
300        ..UNKNOWN_MODEL
301    },
302    ModelCapEntry {
303        rule: Substring("gpt-4.1"),
304        vision: true,
305        context_window: Some(400_000),
306        ..UNKNOWN_MODEL
307    },
308    vision_marker("gpt-4o"),
309    vision_marker("chatgpt-4o"),
310    vision_marker("gpt-4-turbo"),
311    vision_marker("gpt-4-vision"),
312    // --- Gemini thinking dispatch (bare-name prefixes) ---
313    ModelCapEntry {
314        rule: Prefix("gemini-3"),
315        thinking: T::GeminiLevel,
316        vision: true,
317        ..UNKNOWN_MODEL
318    },
319    ModelCapEntry {
320        rule: Prefix("gemini-2.5-pro"),
321        thinking: T::GeminiBudget {
322            min: 128,
323            can_disable: false,
324        },
325        vision: true,
326        ..UNKNOWN_MODEL
327    },
328    // Flash-Lite must stay ABOVE Flash — they share the prefix.
329    ModelCapEntry {
330        rule: Prefix("gemini-2.5-flash-lite"),
331        thinking: T::GeminiBudget {
332            min: 512,
333            can_disable: true,
334        },
335        vision: true,
336        ..UNKNOWN_MODEL
337    },
338    ModelCapEntry {
339        rule: Prefix("gemini-2.5-flash"),
340        thinking: T::GeminiBudget {
341            min: 0,
342            can_disable: true,
343        },
344        vision: true,
345        ..UNKNOWN_MODEL
346    },
347    // Older/other gemini ids: no thinkingConfig (2.0 and earlier 400 on it),
348    // but the whole family is vision-capable.
349    vision_marker("gemini"),
350    // --- Ollama gpt-oss (bare-name prefix; matches tags like gpt-oss:20b) ---
351    ModelCapEntry {
352        rule: Prefix("gpt-oss"),
353        thinking: T::OllamaEffortString,
354        ..UNKNOWN_MODEL
355    },
356    // --- Generic multimodal families / vision markers ---
357    vision_marker("-vision"),
358    vision_marker("-vl"),
359    vision_marker("vl-"),
360    vision_marker("llava"),
361    vision_marker("pixtral"),
362    vision_marker("internvl"),
363    vision_marker("molmo"),
364    vision_marker("llama-3.2-11b"),
365    vision_marker("llama-3.2-90b"),
366    vision_marker("llama-4"),
367    vision_marker("phi-3.5-vision"),
368    vision_marker("phi-4-multimodal"),
369    vision_marker("mistral-small-3"),
370    vision_marker("gemma-3"),
371];
372
373/// Shared columns for the o-series reasoning rows.
374const OPENAI_REASONING: ModelCapEntry = ModelCapEntry {
375    rule: Substring(""),
376    thinking: T::ProviderDefault,
377    supports_temperature: false,
378    effort_ceiling: E::None,
379    vision: false,
380    context_window: None,
381};
382
383/// Look up the capability row for a model id (bare or `provider/`-prefixed,
384/// case-insensitive). Unmatched ids get [`UNKNOWN_MODEL`].
385pub fn lookup(model_id: &str) -> &'static ModelCapEntry {
386    let full = model_id.to_ascii_lowercase();
387    let bare = full.rsplit('/').next().unwrap_or(full.as_str());
388    CATALOG
389        .iter()
390        .find(|entry| entry.rule.matches(&full, bare))
391        .unwrap_or(&UNKNOWN_MODEL)
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn unknown_model_gets_default_row() {
400        let entry = lookup("totally-unknown-model");
401        assert_eq!(entry.thinking, T::ProviderDefault);
402        assert!(entry.supports_temperature);
403        assert_eq!(entry.effort_ceiling, E::None);
404        assert!(!entry.vision);
405        assert_eq!(entry.context_window, None);
406    }
407
408    #[test]
409    fn ordering_flash_lite_before_flash() {
410        assert_eq!(
411            lookup("gemini-2.5-flash-lite").thinking,
412            T::GeminiBudget {
413                min: 512,
414                can_disable: true
415            }
416        );
417        assert_eq!(
418            lookup("gemini-2.5-flash-lite-preview").thinking,
419            T::GeminiBudget {
420                min: 512,
421                can_disable: true
422            }
423        );
424        assert_eq!(
425            lookup("gemini-2.5-flash").thinking,
426            T::GeminiBudget {
427                min: 0,
428                can_disable: true
429            }
430        );
431    }
432
433    #[test]
434    fn ordering_gpt5_prefix_before_substring() {
435        // Bare-name gpt-5 = reasoning model (temperature rejected)…
436        assert!(!lookup("openai/gpt-5").supports_temperature);
437        assert!(!lookup("gpt-5-mini").supports_temperature);
438        // …while a mid-name gpt-5 keeps temperature but still gets vision+400k.
439        let mid = lookup("some-gpt-5-variant");
440        assert!(mid.supports_temperature);
441        assert!(mid.vision);
442        assert_eq!(mid.context_window, Some(400_000));
443    }
444
445    #[test]
446    fn ordering_specific_claude_before_catch_all() {
447        // Specific family rows win over the bare catch-all…
448        assert_eq!(lookup("claude-opus-4-7").effort_ceiling, E::XHigh);
449        assert_eq!(
450            lookup("claude-3-5-sonnet-20241022").thinking,
451            T::AnthropicBudget
452        );
453        // …the date-suffix trick still lands Opus 4 on its own row (vision)…
454        assert!(lookup("claude-opus-4-20250514").vision);
455        // …and the catch-all covers the rest with NO vision marker and no
456        // static window (limits resolve live).
457        let catch_all = lookup("claude-2.1");
458        assert!(!catch_all.vision);
459        assert_eq!(catch_all.context_window, None);
460    }
461
462    #[test]
463    fn ordering_gpt56_before_gpt5() {
464        // gpt-5.6 rows sit above the gpt-5 rows (shared prefix, first match
465        // wins): 1.5M window, temperature rejected on the bare-name prefix.
466        let bare = lookup("gpt-5.6");
467        assert!(!bare.supports_temperature);
468        assert!(bare.vision);
469        assert_eq!(bare.context_window, Some(1_500_000));
470        // A gateway id hits the Substring row: temperature kept, same window.
471        let gateway = lookup("openai/some-gpt-5.6-variant");
472        assert!(gateway.supports_temperature);
473        assert_eq!(gateway.context_window, Some(1_500_000));
474        // Plain gpt-5 still lands on the 400k rows.
475        assert_eq!(lookup("gpt-5-mini").context_window, Some(400_000));
476    }
477
478    #[test]
479    fn prefix_matches_bare_id_after_provider_strip() {
480        assert_eq!(
481            lookup("anthropic/claude-opus-4-7").thinking,
482            T::AnthropicAdaptive
483        );
484        assert!(!lookup("openai/gpt-5").supports_temperature);
485        assert_eq!(lookup("ollama/gpt-oss:20b").thinking, T::OllamaEffortString);
486        // A provider prefix does NOT satisfy a Prefix rule mid-string.
487        assert_eq!(lookup("myprov/some-model").thinking, T::ProviderDefault);
488    }
489
490    #[test]
491    fn substring_matches_full_id() {
492        // The marker can sit anywhere in the full id, including the
493        // gateway-nested provider segment.
494        assert!(lookup("qwen/qwen2.5-vl-7b-instruct").vision);
495        assert!(lookup("openrouter/anthropic/claude-sonnet-4.5").vision);
496    }
497
498    #[test]
499    fn matching_is_case_insensitive() {
500        assert_eq!(
501            lookup("Claude-Opus-4-7-Special").thinking,
502            T::AnthropicAdaptive
503        );
504        assert_eq!(lookup("GPT-OSS:20b").thinking, T::OllamaEffortString);
505        // Deliberate widening vs the old case-sensitive openai gate.
506        assert!(!lookup("GPT-5").supports_temperature);
507    }
508
509    #[test]
510    fn effort_ceiling_ordering_encodes_the_old_trio() {
511        assert!(E::XHigh > E::Max && E::Max > E::High && E::High > E::None);
512        // xhigh-capable ⊂ max-capable: every XHigh row would also accept max.
513        for entry in CATALOG {
514            if entry.effort_ceiling == E::XHigh {
515                assert!(entry.effort_ceiling >= E::Max);
516            }
517        }
518    }
519}