majordomus_cli/capability/builtin/
models.rs1use 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
19pub const MODELS_URI: &str = "majordomus://models";
21
22#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
25#[serde(deny_unknown_fields, default)]
26pub struct ModelsFilter {
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub vendor: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub capability: Option<String>,
34 #[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 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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
60pub struct VendorView {
61 #[serde(flatten)]
63 pub vendor: Vendor,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub credential_configured: Option<bool>,
68}
69
70#[derive(Debug, Serialize, Deserialize, JsonSchema)]
71pub struct ModelsReport {
73 pub count: usize,
75 pub vendors: Vec<VendorView>,
77 pub models: Vec<ModelEntry>,
79 #[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#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
133#[serde(deny_unknown_fields, default)]
134pub struct RouteInput {
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub require: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub min_context: Option<u64>,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub vendor: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub local_only: Option<bool>,
149 #[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
187pub 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 #[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}