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}