1use schemars::JsonSchema;
26use serde::{Deserialize, Serialize};
27
28pub const MODELS_FILE: &str = "models.yaml";
30
31#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
35#[serde(deny_unknown_fields)]
36pub struct Vendor {
37 pub id: String,
39 pub title: String,
41 #[serde(default)]
44 pub inference: Inference,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub credential_env: Option<String>,
50}
51
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
54#[serde(rename_all = "snake_case")]
55pub enum Inference {
56 #[default]
58 Remote,
59 Local,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "snake_case")]
68pub enum ModelStatus {
69 Available,
71 Preview,
73 Deprecated,
75 Retired,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
83#[serde(deny_unknown_fields)]
84pub struct ModelEntry {
85 pub id: String,
88 pub vendor: String,
90 pub native_id: String,
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub aliases: Vec<String>,
95 #[serde(default)]
99 pub capabilities: Vec<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub context_window: Option<u64>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub status: Option<ModelStatus>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub note: Option<String>,
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
116#[serde(deny_unknown_fields)]
117pub struct ModelCatalogue {
118 pub version: u32,
120 #[serde(default)]
122 pub vendors: Vec<Vendor>,
123 #[serde(default)]
127 pub models: Vec<ModelEntry>,
128}
129
130impl ModelCatalogue {
131 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 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 pub fn vendor_of(&self, entry: &ModelEntry) -> Option<&Vendor> {
153 self.vendors.iter().find(|v| v.id == entry.vendor)
154 }
155
156 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#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
180#[serde(deny_unknown_fields, default)]
181pub struct Requirements {
182 pub require: Vec<String>,
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub min_context: Option<u64>,
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub vendor: Option<String>,
190 pub local_only: bool,
192 #[serde(skip_serializing_if = "Option::is_none")]
196 pub model: Option<String>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
201pub struct ExcludedModel {
202 pub model: String,
204 pub reason: String,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
212pub struct RoutingDecision {
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub selected: Option<String>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub reason: Option<String>,
219 #[serde(default, skip_serializing_if = "Vec::is_empty")]
221 pub fallbacks: Vec<String>,
222 #[serde(default, skip_serializing_if = "Vec::is_empty")]
224 pub excluded: Vec<ExcludedModel>,
225}
226
227pub 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
254fn 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 #[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 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}