Skip to main content

majordomus_cli/models/
mod.rs

1//! The model catalogue: which AI models exist for this tool's world, what each can do,
2//! and which one a stated need selects — as declared data, never as a live client. ADR
3//! 0044 is the decision; ADR 0032's boundary stands untouched: this crate calls no
4//! model, holds no SDK, and reads no credential's value. The catalogue is
5//! `share/models.yaml` — one declaration, exactly like `share/providers.yaml` — and
6//! `models.list` / `models.route` project it to the CLI, HTTP, OpenAPI, MCP and the
7//! Cockpit, so no consumer keeps a model name of its own.
8//!
9//! Two words are deliberately absent. "Provider" is spent (ADR 0024, 0032): here the
10//! party that serves a model is a *vendor*. And "routing" here is a pure, explainable
11//! function over the declared data — a decision with the reasons attached, never an
12//! opaque dispatcher: ask `models.route` why, and every excluded model answers.
13//!
14//! ```
15//! use majordomus_cli::models::{route, ModelCatalogue, Requirements};
16//!
17//! let catalogue: ModelCatalogue = majordomus_cli::metadata::yaml::parse_into(
18//!     "version: 1\nvendors:\n  - id: acme\n    title: Acme\nmodels:\n  - id: acme-large\n    vendor: acme\n    native_id: acme-large-1\n    context_window: 200000\n    capabilities: [text, tools, vision]\n  - id: acme-mini\n    vendor: acme\n    native_id: acme-mini-1\n    context_window: 32000\n    capabilities: [text]\n",
19//! ).unwrap();
20//! let decision = route(&catalogue, &Requirements { require: vec!["vision".into()], ..Default::default() });
21//! assert_eq!(decision.selected.as_deref(), Some("acme-large"));
22//! assert_eq!(decision.excluded[0].reason, "missing capability: vision");
23//! ```
24
25use schemars::JsonSchema;
26use serde::{Deserialize, Serialize};
27
28/// The file name under the share directory.
29pub const MODELS_FILE: &str = "models.yaml";
30
31/// A party that serves models: a remote API vendor or a local runtime. Not a
32/// "provider" — that word names the AI client tools this repository already adapts to
33/// (ADR 0024), and one word for two registries is how truths fork.
34#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
35#[serde(deny_unknown_fields)]
36pub struct Vendor {
37    /// The vendor id, unique in the catalogue.
38    pub id: String,
39    /// The human name.
40    pub title: String,
41    /// Where inference runs: `remote` (an API over the network) or `local` (a runtime
42    /// on this machine). Typed here so routing's `local_only` is data, not a name test.
43    #[serde(default)]
44    pub inference: Inference,
45    /// The environment variable whose *presence* means a credential is configured.
46    /// Only the name lives here, and only presence is ever reported — no code in this
47    /// crate reads the value, and the environment cache refuses to hold one.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub credential_env: Option<String>,
50}
51
52/// Where a vendor's inference runs.
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
54#[serde(rename_all = "snake_case")]
55pub enum Inference {
56    /// An API over the network.
57    #[default]
58    Remote,
59    /// A runtime on this machine.
60    Local,
61}
62
63/// A model's lifecycle standing, as the catalogue declares it. There is no default: a
64/// catalogue entry that declares none has an undeclared standing, which is not the same
65/// fact as "available", and no reader may render it as one.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "snake_case")]
68pub enum ModelStatus {
69    /// Generally available.
70    Available,
71    /// Usable, expected to change.
72    Preview,
73    /// Still served, no longer chosen by routing unless named explicitly.
74    Deprecated,
75    /// Gone; kept so an old record's reference still resolves to a row with a story.
76    Retired,
77}
78
79/// One model: the canonical reference this repository uses, the vendor's own name for
80/// it, and its typed capabilities. Capability words are data the catalogue declares —
81/// consumers compare against a requirement list, never against a hardcoded name.
82#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
83#[serde(deny_unknown_fields)]
84pub struct ModelEntry {
85    /// The canonical id, unique in the catalogue: what sessions, configuration and
86    /// routing name. Stable even when a vendor renames.
87    pub id: String,
88    /// The vendor that serves it.
89    pub vendor: String,
90    /// The vendor's own identifier, verbatim.
91    pub native_id: String,
92    /// Other names that resolve to this model. Aliases live here and nowhere else.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub aliases: Vec<String>,
95    /// Typed capability words: `text`, `vision`, `tools`, `structured_output`,
96    /// `reasoning`, `streaming`, `embeddings` — the declared vocabulary, open for a
97    /// catalogue to extend without a code change.
98    #[serde(default)]
99    pub capabilities: Vec<String>,
100    /// The context window, tokens.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub context_window: Option<u64>,
103    /// The lifecycle standing, when the catalogue declares one. Absent is undeclared: the
104    /// catalogue admits only facts with provenance, and routing still considers the model,
105    /// but nothing may say it is available on the catalogue's behalf.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub status: Option<ModelStatus>,
108    /// One line a person needs when choosing; never pricing — a price the tool cannot
109    /// verify is a fact it must not state (nothing here fabricates cost data).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub note: Option<String>,
112}
113
114/// The whole catalogue, as `share/models.yaml` declares it.
115#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
116#[serde(deny_unknown_fields)]
117pub struct ModelCatalogue {
118    /// The format version.
119    pub version: u32,
120    /// The vendors, in declaration order.
121    #[serde(default)]
122    pub vendors: Vec<Vendor>,
123    /// The models, in declaration order — which is also routing's preference order:
124    /// the first satisfying model wins, and "why this one" is "it came first among
125    /// those that qualify", an answer a person can verify by reading the file.
126    #[serde(default)]
127    pub models: Vec<ModelEntry>,
128}
129
130impl ModelCatalogue {
131    /// Load the catalogue from a share directory. No file is an empty catalogue — a
132    /// distribution that declares no models has none, which is an answer; a file that
133    /// does not parse is an error naming it.
134    pub fn load(share: &std::path::Path) -> Result<ModelCatalogue, String> {
135        let path = share.join(MODELS_FILE);
136        match std::fs::read_to_string(&path) {
137            Ok(text) => crate::metadata::yaml::parse_into(&text)
138                .map_err(|reason| format!("{}: {reason}", path.display())),
139            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ModelCatalogue::default()),
140            Err(e) => Err(format!("{}: {e}", path.display())),
141        }
142    }
143
144    /// The entry a canonical id or an alias resolves to.
145    pub fn resolve(&self, name: &str) -> Option<&ModelEntry> {
146        self.models
147            .iter()
148            .find(|m| m.id == name || m.aliases.iter().any(|a| a == name))
149    }
150
151    /// The declared vendor of an entry.
152    pub fn vendor_of(&self, entry: &ModelEntry) -> Option<&Vendor> {
153        self.vendors.iter().find(|v| v.id == entry.vendor)
154    }
155
156    /// The catalogue's own consistency: duplicate ids or aliases, and a model naming a
157    /// vendor the catalogue does not declare. Reported, never repaired.
158    pub fn diagnostics(&self) -> Vec<String> {
159        let mut findings = Vec::new();
160        let mut seen = std::collections::BTreeSet::new();
161        for model in &self.models {
162            for name in std::iter::once(&model.id).chain(model.aliases.iter()) {
163                if !seen.insert(name.clone()) {
164                    findings.push(format!("'{name}' names two models; an alias is one truth"));
165                }
166            }
167            if self.vendor_of(model).is_none() {
168                findings.push(format!(
169                    "model '{}' names vendor '{}', which the catalogue does not declare",
170                    model.id, model.vendor
171                ));
172            }
173        }
174        findings
175    }
176}
177
178/// What a task needs from a model. Every field empty means "the first available model".
179#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
180#[serde(deny_unknown_fields, default)]
181pub struct Requirements {
182    /// Capability words the model must declare, all of them.
183    pub require: Vec<String>,
184    /// The least context window, tokens.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub min_context: Option<u64>,
187    /// Only this vendor.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub vendor: Option<String>,
190    /// Only local inference: nothing that would leave the machine.
191    pub local_only: bool,
192    /// A model named outright — by canonical id or alias. Still checked against the
193    /// other requirements: an override that cannot do the work is an exclusion with
194    /// the reason, not a silent selection.
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub model: Option<String>,
197}
198
199/// One model that did not qualify, and exactly why.
200#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
201pub struct ExcludedModel {
202    /// The canonical id.
203    pub model: String,
204    /// The first reason it fell out, in the order the checks run.
205    pub reason: String,
206}
207
208/// The routing answer: what was selected, what stands behind it, who fell out and why.
209/// The whole decision is derived from the catalogue and the requirements — run it
210/// twice, get it twice; there is no state to consult and no health to guess.
211#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
212pub struct RoutingDecision {
213    /// The selected model's canonical id; `None` when nothing qualifies.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub selected: Option<String>,
216    /// Why the selected model was selected.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub reason: Option<String>,
219    /// The models that also qualify, in preference order: the fallback chain.
220    #[serde(default, skip_serializing_if = "Vec::is_empty")]
221    pub fallbacks: Vec<String>,
222    /// Every model that did not qualify, with its reason.
223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
224    pub excluded: Vec<ExcludedModel>,
225}
226
227/// Decide. Pure over the catalogue and the requirements; the preference order is the
228/// catalogue's declaration order, and every exclusion names its first failing check.
229pub fn route(catalogue: &ModelCatalogue, needs: &Requirements) -> RoutingDecision {
230    let mut qualified: Vec<&ModelEntry> = Vec::new();
231    let mut excluded = Vec::new();
232    for model in &catalogue.models {
233        match disqualify(catalogue, model, needs) {
234            Some(reason) => excluded.push(ExcludedModel {
235                model: model.id.clone(),
236                reason,
237            }),
238            None => qualified.push(model),
239        }
240    }
241    let selected = qualified.first().map(|m| m.id.clone());
242    let reason = selected.as_ref().map(|id| match &needs.model {
243        Some(named) => format!("named outright as '{named}' and it satisfies every requirement"),
244        None => format!("'{id}' is the first declared model satisfying every requirement"),
245    });
246    RoutingDecision {
247        selected,
248        reason,
249        fallbacks: qualified.iter().skip(1).map(|m| m.id.clone()).collect(),
250        excluded,
251    }
252}
253
254/// The first reason a model does not qualify, in a fixed check order; `None` qualifies.
255fn disqualify(
256    catalogue: &ModelCatalogue,
257    model: &ModelEntry,
258    needs: &Requirements,
259) -> Option<String> {
260    if let Some(named) = &needs.model {
261        let resolves = model.id == *named || model.aliases.iter().any(|a| a == named);
262        if !resolves {
263            return Some(format!("not the model named outright ('{named}')"));
264        }
265    }
266    if let Some(status @ (ModelStatus::Deprecated | ModelStatus::Retired)) = model.status {
267        if needs.model.is_none() {
268            return Some(format!(
269                "status is {}; routing only selects it when named outright",
270                match status {
271                    ModelStatus::Deprecated => "deprecated",
272                    _ => "retired",
273                }
274            ));
275        }
276    }
277    if let Some(vendor) = &needs.vendor {
278        if model.vendor != *vendor {
279            return Some(format!("vendor is '{}', not '{vendor}'", model.vendor));
280        }
281    }
282    if needs.local_only {
283        let local = catalogue
284            .vendor_of(model)
285            .is_some_and(|v| v.inference == Inference::Local);
286        if !local {
287            return Some("inference is remote and the need is local_only".into());
288        }
289    }
290    for capability in &needs.require {
291        if !model.capabilities.iter().any(|c| c == capability) {
292            return Some(format!("missing capability: {capability}"));
293        }
294    }
295    if let Some(least) = needs.min_context {
296        match model.context_window {
297            Some(window) if window >= least => {}
298            Some(window) => {
299                return Some(format!(
300                    "context window {window} is under the required {least}"
301                ))
302            }
303            None => return Some(format!("no declared context window; {least} required")),
304        }
305    }
306    None
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn catalogue() -> ModelCatalogue {
314        crate::metadata::yaml::parse_into(
315            "version: 1
316vendors:
317  - id: acme
318    title: Acme
319    credential_env: ACME_API_KEY
320  - id: bench
321    title: Bench
322    inference: local
323models:
324  - id: acme-large
325    vendor: acme
326    native_id: acme-large-1
327    aliases: [large]
328    context_window: 200000
329    capabilities: [text, tools, vision, reasoning]
330  - id: acme-old
331    vendor: acme
332    native_id: acme-old-9
333    status: deprecated
334    context_window: 100000
335    capabilities: [text, tools]
336  - id: bench-local
337    vendor: bench
338    native_id: bench.gguf
339    context_window: 8000
340    capabilities: [text]
341",
342        )
343        .unwrap()
344    }
345
346    /// An entry that declares no status has none: it is not read as "available".
347    #[test]
348    fn an_undeclared_status_is_undeclared_and_not_available() {
349        let c = catalogue();
350        let large = c.models.iter().find(|m| m.id == "acme-large").unwrap();
351        assert_eq!(large.status, None);
352        let old = c.models.iter().find(|m| m.id == "acme-old").unwrap();
353        assert_eq!(old.status, Some(ModelStatus::Deprecated));
354        let json = serde_json::to_value(large).unwrap();
355        assert!(json.get("status").is_none(), "{json}");
356    }
357
358    #[test]
359    fn the_first_satisfying_model_wins_and_every_exclusion_says_why() {
360        let c = catalogue();
361        let d = route(&c, &Requirements::default());
362        assert_eq!(d.selected.as_deref(), Some("acme-large"));
363        assert_eq!(d.fallbacks, vec!["bench-local"]);
364        let old = d.excluded.iter().find(|e| e.model == "acme-old").unwrap();
365        assert!(old.reason.contains("deprecated"), "{}", old.reason);
366    }
367
368    #[test]
369    fn requirements_narrow_and_the_reasons_name_the_first_failing_check() {
370        let c = catalogue();
371        let d = route(
372            &c,
373            &Requirements {
374                require: vec!["vision".into()],
375                min_context: Some(150_000),
376                ..Default::default()
377            },
378        );
379        assert_eq!(d.selected.as_deref(), Some("acme-large"));
380        assert!(d.fallbacks.is_empty());
381        let local = d
382            .excluded
383            .iter()
384            .find(|e| e.model == "bench-local")
385            .unwrap();
386        assert_eq!(local.reason, "missing capability: vision");
387    }
388
389    #[test]
390    fn local_only_selects_local_inference_or_nothing() {
391        let c = catalogue();
392        let d = route(
393            &c,
394            &Requirements {
395                local_only: true,
396                ..Default::default()
397            },
398        );
399        assert_eq!(d.selected.as_deref(), Some("bench-local"));
400        let d = route(
401            &c,
402            &Requirements {
403                local_only: true,
404                require: vec!["vision".into()],
405                ..Default::default()
406            },
407        );
408        assert_eq!(
409            d.selected, None,
410            "nothing qualifies, and that is the answer"
411        );
412        assert!(!d.excluded.is_empty());
413    }
414
415    #[test]
416    fn naming_a_model_outright_still_checks_the_requirements() {
417        let c = catalogue();
418        let d = route(
419            &c,
420            &Requirements {
421                model: Some("large".into()),
422                ..Default::default()
423            },
424        );
425        assert_eq!(
426            d.selected.as_deref(),
427            Some("acme-large"),
428            "an alias resolves"
429        );
430        let d = route(
431            &c,
432            &Requirements {
433                model: Some("bench-local".into()),
434                require: vec!["vision".into()],
435                ..Default::default()
436            },
437        );
438        assert_eq!(
439            d.selected, None,
440            "an override that cannot do the work is refused"
441        );
442    }
443
444    #[test]
445    fn a_deprecated_model_named_outright_is_still_selectable() {
446        let c = catalogue();
447        let d = route(
448            &c,
449            &Requirements {
450                model: Some("acme-old".into()),
451                ..Default::default()
452            },
453        );
454        assert_eq!(d.selected.as_deref(), Some("acme-old"));
455    }
456
457    #[test]
458    fn the_catalogue_reports_duplicates_and_undeclared_vendors() {
459        let c: ModelCatalogue = crate::metadata::yaml::parse_into(
460            "version: 1
461models:
462  - id: a
463    vendor: ghost
464    native_id: a1
465  - id: a
466    vendor: ghost
467    native_id: a2
468",
469        )
470        .unwrap();
471        let findings = c.diagnostics();
472        assert!(findings.iter().any(|f| f.contains("names two models")));
473        assert!(findings.iter().any(|f| f.contains("ghost")));
474    }
475
476    #[test]
477    fn an_absent_file_is_an_empty_catalogue_not_an_error() {
478        let dir = tempfile::tempdir().unwrap();
479        let c = ModelCatalogue::load(dir.path()).unwrap();
480        assert!(c.models.is_empty() && c.vendors.is_empty());
481    }
482
483    #[test]
484    fn no_field_of_the_catalogue_can_hold_a_secret_value() {
485        // Proves `models-no-secret-fields`: the catalogue reports whether a vendor's
486        // named credential variable is set and has no field a value could hide in.
487        // The vendor declares the *name* of a credential's environment variable; the
488        // schema has no field for a value, so a leak would need a new field to exist.
489        // Property names are what is checked — prose may say "tokens" all it likes.
490        fn property_names(value: &serde_json::Value, out: &mut Vec<String>) {
491            if let Some(properties) = value.get("properties").and_then(|p| p.as_object()) {
492                for (name, nested) in properties {
493                    out.push(name.to_lowercase());
494                    property_names(nested, out);
495                }
496            }
497            for key in ["items", "$defs", "definitions"] {
498                if let Some(nested) = value.get(key) {
499                    if let Some(map) = nested.as_object() {
500                        for entry in map.values() {
501                            property_names(entry, out);
502                        }
503                    } else {
504                        property_names(nested, out);
505                    }
506                }
507            }
508        }
509        let schema = serde_json::to_value(schemars::schema_for!(ModelCatalogue)).unwrap();
510        let mut names = Vec::new();
511        property_names(&schema, &mut names);
512        assert!(
513            names.iter().any(|n| n == "credential_env"),
514            "the walk sees the fields"
515        );
516        for name in names {
517            for word in ["secret", "token", "password", "key_value", "bearer"] {
518                assert!(
519                    !name.contains(word),
520                    "the catalogue schema declares a field that smells like a credential: {name}"
521                );
522            }
523        }
524    }
525}