Skip to main content

majordomus_cli/capability/builtin/
models.rs

1//! The `models` module: the model catalogue and its routing, projected. Both
2//! capabilities read `share/models.yaml` through [`crate::models::ModelCatalogue`] —
3//! declared data, never a live client (ADR 0049, standing on ADR 0032's no-network
4//! boundary) — so the CLI, HTTP, OpenAPI, MCP and the Cockpit name models from one
5//! file and nothing else.
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
11use crate::capability::handler::{CapabilityError, Context};
12use crate::capability::model::{CliExposure, Exposure, Stability};
13use crate::capability::module::ModuleDescriptor;
14use crate::models::{route, ModelCatalogue, ModelEntry, Requirements, RoutingDecision, Vendor};
15use crate::{capability, module};
16
17use super::{get, mcp};
18
19/// The MCP resource `models.list` is projected as.
20pub const MODELS_URI: &str = "majordomus://models";
21
22// ---------------------------------------------------------------- models.list
23
24#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
25#[serde(deny_unknown_fields, default)]
26/// The input of `models.list`: optional narrowing. Everything empty lists everything.
27pub struct ModelsFilter {
28    /// Only this vendor.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub vendor: Option<String>,
31    /// Only models declaring this capability word.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub capability: Option<String>,
34    /// One model, by canonical id or alias.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub id: Option<String>,
37}
38
39impl BenchmarkCases for ModelsFilter {
40    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
41        vec![
42            NamedCase::new("default", ModelsFilter::default()),
43            // Every parameter carries a value here so the OpenAPI document has an
44            // example for each; the filter names entries the shipped catalogue holds.
45            NamedCase::new(
46                "narrowed",
47                ModelsFilter {
48                    vendor: Some("anthropic".into()),
49                    capability: Some("text".into()),
50                    id: Some("claude-opus-5".into()),
51                },
52            ),
53        ]
54    }
55}
56
57/// One vendor with whether its credential is configured — presence of the named
58/// environment variable and nothing more. No value is read.
59#[derive(Debug, Serialize, Deserialize, JsonSchema)]
60pub struct VendorView {
61    /// The declaration.
62    #[serde(flatten)]
63    pub vendor: Vendor,
64    /// Whether the named credential variable is set in this process's environment.
65    /// `None` when the vendor names no variable.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub credential_configured: Option<bool>,
68}
69
70#[derive(Debug, Serialize, Deserialize, JsonSchema)]
71/// The answer of `models.list`.
72pub struct ModelsReport {
73    /// How many models matched.
74    pub count: usize,
75    /// The vendors, declaration order.
76    pub vendors: Vec<VendorView>,
77    /// The matching models, declaration order — which is routing's preference order.
78    pub models: Vec<ModelEntry>,
79    /// The catalogue's own findings: duplicate names, undeclared vendors. Empty is
80    /// the ordinary case.
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub diagnostics: Vec<String>,
83}
84
85fn catalogue_of(ctx: &Context) -> Result<ModelCatalogue, CapabilityError> {
86    let Some(share) = &ctx.index.share else {
87        return Ok(ModelCatalogue::default());
88    };
89    ModelCatalogue::load(share).map_err(CapabilityError::Internal)
90}
91
92fn models_list(ctx: &Context, filter: ModelsFilter) -> Result<ModelsReport, CapabilityError> {
93    let catalogue = catalogue_of(ctx)?;
94    let models: Vec<ModelEntry> = catalogue
95        .models
96        .iter()
97        .filter(|m| filter.vendor.as_ref().is_none_or(|v| &m.vendor == v))
98        .filter(|m| {
99            filter
100                .capability
101                .as_ref()
102                .is_none_or(|c| m.capabilities.iter().any(|has| has == c))
103        })
104        .filter(|m| {
105            filter
106                .id
107                .as_ref()
108                .is_none_or(|id| &m.id == id || m.aliases.iter().any(|a| a == id))
109        })
110        .cloned()
111        .collect();
112    Ok(ModelsReport {
113        count: models.len(),
114        vendors: catalogue
115            .vendors
116            .iter()
117            .map(|v| VendorView {
118                credential_configured: v
119                    .credential_env
120                    .as_deref()
121                    .map(|name| std::env::var_os(name).is_some()),
122                vendor: v.clone(),
123            })
124            .collect(),
125        models,
126        diagnostics: catalogue.diagnostics(),
127    })
128}
129
130// ---------------------------------------------------------------- models.route
131
132#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
133#[serde(deny_unknown_fields, default)]
134/// The input of `models.route`: what the task needs, in transport-friendly scalars
135/// (`require` is comma-separated so a GET query string can carry it).
136pub struct RouteInput {
137    /// Capability words the model must declare, comma-separated: `vision,tools`.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub require: Option<String>,
140    /// The least context window, tokens.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub min_context: Option<u64>,
143    /// Only this vendor.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub vendor: Option<String>,
146    /// Only local inference.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub local_only: Option<bool>,
149    /// A model named outright, by canonical id or alias — still checked against the
150    /// other requirements.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub model: Option<String>,
153}
154
155impl BenchmarkCases for RouteInput {
156    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
157        vec![NamedCase::new(
158            "default",
159            RouteInput {
160                require: Some("text".into()),
161                ..RouteInput::default()
162            },
163        )]
164    }
165}
166
167fn models_route(ctx: &Context, input: RouteInput) -> Result<RoutingDecision, CapabilityError> {
168    let catalogue = catalogue_of(ctx)?;
169    let needs = Requirements {
170        require: input
171            .require
172            .as_deref()
173            .unwrap_or_default()
174            .split(',')
175            .map(str::trim)
176            .filter(|w| !w.is_empty())
177            .map(str::to_string)
178            .collect(),
179        min_context: input.min_context,
180        vendor: input.vendor,
181        local_only: input.local_only.unwrap_or(false),
182        model: input.model,
183    };
184    Ok(route(&catalogue, &needs))
185}
186
187/// The module.
188pub fn module() -> ModuleDescriptor {
189    module! {
190        id: "models",
191        title: "Models",
192        description: "The model catalogue the distribution declares — vendors, canonical model references, typed capabilities, context windows, lifecycle — and the explainable routing over it: what a stated need selects, what stands behind it, and why every excluded model fell out. Declared data; nothing here calls a model or reads a credential's value.",
193        stability: Stability::Experimental,
194        capabilities: [
195            capability! {
196                id: "models.list",
197                title: "The model catalogue",
198                description: "Every declared vendor and model, optionally narrowed by vendor, capability word, or one id or alias. Vendors carry whether their named credential variable is set — presence only, never a value. The order is the declaration's, which is also routing's preference order; the catalogue's own findings (a duplicate alias, an undeclared vendor) ride along.",
199                input: ModelsFilter,
200                output: ModelsReport,
201                stability: Stability::Experimental,
202                exposure: Exposure {
203                    mcp: Some(crate::capability::model::McpExposure {
204                        tool: Some("majordomus_models".into()),
205                        resource: Some(crate::capability::model::McpResource {
206                            uri: MODELS_URI.into(),
207                            name: "models".into(),
208                        }),
209                    }),
210                    http: get("/api/v1/models"),
211                    cli: Some(CliExposure { path: vec!["models".into(), "list".into()] }),
212                },
213                tags: ["models", "catalogue"],
214                cache: crate::capability::model::CachePolicy::Process { max_entries: 4, ttl_seconds: Some(5) },
215                handler: models_list,
216            },
217            capability! {
218                id: "models.route",
219                title: "Route a need to a model",
220                description: "Decide which declared model a stated need selects: the first in declaration order satisfying every requirement, the qualifying rest as the fallback chain, and every excluded model with the first check it failed. Pure over the catalogue and the input — the same question gets the same answer, and 'why' is in the answer itself.",
221                input: RouteInput,
222                output: RoutingDecision,
223                stability: Stability::Experimental,
224                exposure: Exposure {
225                    mcp: mcp("majordomus_models_route"),
226                    http: get("/api/v1/models/route"),
227                    cli: Some(CliExposure { path: vec!["models".into(), "route".into()] }),
228                },
229                tags: ["models", "routing"],
230                handler: models_route,
231            },
232        ],
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    /// The declaration is the only place these names exist; this is the assertion a
241    /// refactor that dropped a projection would fail.
242    #[test]
243    fn the_declaration_yields_the_projections_it_claims() {
244        let m = module();
245        assert_eq!(m.id.as_str(), "models");
246        let expected: &[(&str, &str, &str)] = &[
247            ("models.list", "majordomus_models", "/api/v1/models"),
248            (
249                "models.route",
250                "majordomus_models_route",
251                "/api/v1/models/route",
252            ),
253        ];
254        let ids: Vec<&str> = m
255            .capabilities
256            .iter()
257            .map(|e| e.capability.id.as_str())
258            .collect();
259        let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
260        assert_eq!(
261            ids, want,
262            "the module declares a different set of capabilities"
263        );
264        for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
265            let exposure = &executable.capability.exposure;
266            assert_eq!(
267                exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
268                Some(*tool),
269                "{id} lost or renamed its MCP tool"
270            );
271            assert_eq!(
272                exposure.http.as_ref().map(|h| h.path.as_str()),
273                Some(*path),
274                "{id} lost or renamed its HTTP route"
275            );
276        }
277    }
278
279    #[test]
280    fn every_capability_is_a_query() {
281        for e in module().capabilities {
282            assert_eq!(
283                e.capability.kind,
284                crate::capability::model::CapabilityKind::Query,
285                "{}: the catalogue is declared data; nothing here mutates",
286                e.capability.id
287            );
288        }
289    }
290}