1use std::collections::HashMap;
18
19use serde::{Deserialize, Serialize};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum Membership {
26 Closed,
27 Open,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum ModelStatus {
34 #[default]
35 Available,
36 Retiring,
37 Retired,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct RegistryModel {
44 pub id: String,
45 #[serde(default)]
48 pub reasoning_efforts: Option<Vec<String>>,
49 pub last_verified: String,
50 #[serde(default)]
51 pub source: Option<String>,
52 #[serde(default)]
53 pub note: Option<String>,
54 #[serde(default)]
55 pub status: Option<ModelStatus>,
56 #[serde(default)]
57 pub retirement_date: Option<String>,
58 #[serde(default)]
59 pub successor: Option<String>,
60}
61
62impl RegistryModel {
63 pub fn status(&self) -> ModelStatus {
64 self.status.unwrap_or_default()
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct RegistryNamespace {
72 pub id: String,
73 #[serde(default)]
74 pub description: Option<String>,
75 #[serde(rename = "match")]
78 pub match_pattern: String,
79 pub membership: Membership,
80 #[serde(default)]
81 pub model_provider: Option<String>,
82 #[serde(default)]
83 pub requires_provider_table: Option<bool>,
84 #[serde(default)]
85 pub reasoning_efforts: Option<Vec<String>>,
86 #[serde(default)]
87 pub reasoning_note: Option<String>,
88 #[serde(default)]
89 pub sources: Vec<String>,
90 #[serde(default)]
91 pub examples: Vec<String>,
92 #[serde(default)]
93 pub models: Vec<RegistryModel>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct RegistryHarness {
100 pub reasoning_key: Option<String>,
103 pub reasoning_efforts: Vec<String>,
104 #[serde(default)]
105 pub reasoning_note: Option<String>,
106 pub namespaces: Vec<RegistryNamespace>,
107}
108
109impl RegistryHarness {
110 pub fn supports_reasoning(&self) -> bool {
112 self.reasoning_key.is_some() && !self.reasoning_efforts.is_empty()
113 }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct ModelRegistry {
120 pub manifest_version: u32,
121 #[serde(default)]
122 pub description: Option<String>,
123 pub last_refreshed: String,
124 pub harnesses: HashMap<String, RegistryHarness>,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct ModelChoice {
131 pub model: String,
132 pub namespace: String,
133 pub efforts: Vec<String>,
136 pub status: ModelStatus,
137 pub is_example: bool,
140 pub retirement_date: Option<String>,
141 pub successor: Option<String>,
142 pub note: Option<String>,
143}
144
145impl ModelChoice {
146 pub fn label_suffix(&self) -> String {
148 let mut parts: Vec<String> = Vec::new();
149 match self.status {
150 ModelStatus::Retiring => parts.push(match &self.retirement_date {
151 Some(d) => format!("retiring {d}"),
152 None => "retiring".to_string(),
153 }),
154 ModelStatus::Retired => parts.push(match &self.successor {
155 Some(s) => format!("retired -> {s}"),
156 None => "retired".to_string(),
157 }),
158 ModelStatus::Available => {}
159 }
160 if self.is_example {
161 parts.push("example".to_string());
162 }
163 if self.efforts.is_empty() {
164 parts.push("no effort".to_string());
165 }
166 if parts.is_empty() {
167 String::new()
168 } else {
169 format!(" ({})", parts.join(", "))
170 }
171 }
172}
173
174fn resolve_successor<'a>(ns: &'a RegistryNamespace, model: &'a RegistryModel) -> &'a RegistryModel {
182 let mut current = model;
183 for _ in 0..ns.models.len() {
184 if current.status() != ModelStatus::Retired {
185 break;
186 }
187 let Some(successor) = current.successor.as_deref() else {
188 break;
189 };
190 let Some(next) = ns.models.iter().find(|m| m.id == successor) else {
191 break;
192 };
193 if next.id == current.id {
194 break;
195 }
196 current = next;
197 }
198 current
199}
200
201impl ModelRegistry {
202 pub fn harness(&self, harness: &str) -> Option<&RegistryHarness> {
203 self.harnesses.get(harness)
204 }
205
206 pub fn harness_efforts(&self, harness: &str) -> Vec<String> {
209 self.harness(harness)
210 .map(|h| h.reasoning_efforts.clone())
211 .unwrap_or_default()
212 }
213
214 pub fn supports_reasoning(&self, harness: &str) -> bool {
215 self.harness(harness)
216 .is_some_and(|h| h.supports_reasoning())
217 }
218
219 pub fn choices_for_harness(&self, harness: &str) -> Vec<ModelChoice> {
224 let Some(hdef) = self.harness(harness) else {
225 return Vec::new();
226 };
227 let mut out: Vec<ModelChoice> = Vec::new();
228 let mut seen: Vec<String> = Vec::new();
229
230 let push = |choice: ModelChoice, out: &mut Vec<ModelChoice>, seen: &mut Vec<String>| {
231 if seen.iter().any(|m| m == &choice.model) {
232 return;
233 }
234 seen.push(choice.model.clone());
235 out.push(choice);
236 };
237
238 for ns in &hdef.namespaces {
239 let ns_efforts = ns
240 .reasoning_efforts
241 .clone()
242 .unwrap_or_else(|| hdef.reasoning_efforts.clone());
243
244 for m in &ns.models {
245 let effective = resolve_successor(ns, m);
253 let efforts = effective
254 .reasoning_efforts
255 .clone()
256 .unwrap_or_else(|| ns_efforts.clone());
257 push(
258 ModelChoice {
259 model: m.id.clone(),
260 namespace: ns.id.clone(),
261 efforts,
262 status: m.status(),
263 is_example: false,
264 retirement_date: m.retirement_date.clone(),
265 successor: m.successor.clone(),
266 note: m.note.clone(),
267 },
268 &mut out,
269 &mut seen,
270 );
271 }
272
273 if ns.membership == Membership::Open {
277 for ex in &ns.examples {
278 push(
279 ModelChoice {
280 model: ex.clone(),
281 namespace: ns.id.clone(),
282 efforts: ns_efforts.clone(),
283 status: ModelStatus::Available,
284 is_example: true,
285 retirement_date: None,
286 successor: None,
287 note: None,
288 },
289 &mut out,
290 &mut seen,
291 );
292 }
293 }
294 }
295 out
296 }
297
298 pub fn choice_for(&self, harness: &str, model: &str) -> Option<ModelChoice> {
303 self.choices_for_harness(harness)
304 .into_iter()
305 .find(|c| c.model == model)
306 }
307
308 pub fn efforts_for(&self, harness: &str, model: &str) -> Vec<String> {
312 match self.choice_for(harness, model) {
313 Some(choice) => choice.efforts,
314 None => self.harness_efforts(harness),
315 }
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 fn fixture() -> ModelRegistry {
324 let json = r#"{
325 "manifest_version": 1,
326 "description": "test",
327 "last_refreshed": "2026-09-05",
328 "harnesses": {
329 "codex": {
330 "reasoning_key": "model_reasoning_effort",
331 "reasoning_efforts": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
332 "namespaces": [
333 {
334 "id": "openai",
335 "match": "^(gpt-|o[0-9])[a-z0-9.-]*$",
336 "membership": "closed",
337 "model_provider": null,
338 "models": [
339 { "id": "gpt-6-astra", "reasoning_efforts": ["low", "high", "max"], "last_verified": "2026-09-05" },
340 { "id": "gpt-4.1-mini", "reasoning_efforts": [], "last_verified": "2026-09-05" },
341 { "id": "gpt-old", "last_verified": "2026-09-05", "status": "retiring", "retirement_date": "2026-12-11", "successor": "gpt-6-astra" }
342 ]
343 },
344 {
345 "id": "ollama",
346 "match": "^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$",
347 "membership": "open",
348 "model_provider": "ollama",
349 "requires_provider_table": true,
350 "reasoning_efforts": ["none", "low", "medium", "high", "max"],
351 "examples": ["qwen3:32b", "glm-5.3:cloud"]
352 }
353 ]
354 },
355 "cursor": {
356 "reasoning_key": null,
357 "reasoning_efforts": [],
358 "namespaces": [
359 { "id": "named", "match": "^[a-z0-9][a-z0-9.-]*$", "membership": "closed",
360 "models": [{ "id": "composer-2.5", "last_verified": "2026-09-05" }] }
361 ]
362 }
363 }
364 }"#;
365 serde_json::from_str(json).expect("fixture parses")
366 }
367
368 #[test]
369 fn parses_registry_shape() {
370 let reg = fixture();
371 assert_eq!(reg.manifest_version, 1);
372 assert_eq!(reg.last_refreshed, "2026-09-05");
373 assert_eq!(reg.harness("codex").unwrap().namespaces.len(), 2);
374 }
375
376 #[test]
377 fn choices_include_closed_models_and_open_examples() {
378 let ids: Vec<String> = fixture()
379 .choices_for_harness("codex")
380 .into_iter()
381 .map(|c| c.model)
382 .collect();
383 assert_eq!(
384 ids,
385 vec![
386 "gpt-6-astra",
387 "gpt-4.1-mini",
388 "gpt-old",
389 "qwen3:32b",
390 "glm-5.3:cloud"
391 ]
392 );
393 }
394
395 #[test]
396 fn open_namespace_entries_are_marked_as_examples() {
397 let reg = fixture();
398 assert!(!reg.choice_for("codex", "gpt-6-astra").unwrap().is_example);
399 let tag = reg.choice_for("codex", "qwen3:32b").unwrap();
400 assert!(tag.is_example);
401 assert_eq!(tag.namespace, "ollama");
402 }
403
404 #[test]
405 fn effort_narrowing_prefers_model_then_namespace_then_harness() {
406 let reg = fixture();
407 assert_eq!(
409 reg.efforts_for("codex", "gpt-6-astra"),
410 vec!["low", "high", "max"]
411 );
412 assert_eq!(
414 reg.efforts_for("codex", "qwen3:32b"),
415 vec!["none", "low", "medium", "high", "max"]
416 );
417 assert_eq!(reg.efforts_for("codex", "gpt-old").len(), 7);
419 }
420
421 #[test]
422 fn non_reasoning_model_offers_no_efforts() {
423 assert!(fixture().efforts_for("codex", "gpt-4.1-mini").is_empty());
424 }
425
426 #[test]
427 fn free_typed_model_falls_back_to_harness_vocabulary() {
428 let reg = fixture();
429 assert!(reg.choice_for("codex", "deepseek-r1:70b").is_none());
431 assert_eq!(reg.efforts_for("codex", "deepseek-r1:70b").len(), 7);
432 }
433
434 #[test]
435 fn harness_without_reasoning_key_reports_unsupported() {
436 let reg = fixture();
437 assert!(reg.supports_reasoning("codex"));
438 assert!(!reg.supports_reasoning("cursor"));
439 assert!(!reg.supports_reasoning("nonexistent"));
440 assert!(reg.choices_for_harness("nonexistent").is_empty());
441 }
442
443 #[test]
444 fn lifecycle_surfaces_in_the_picker_label() {
445 let reg = fixture();
446 let retiring = reg.choice_for("codex", "gpt-old").unwrap();
447 assert_eq!(retiring.status, ModelStatus::Retiring);
448 assert!(retiring.label_suffix().contains("retiring 2026-12-11"));
449 assert!(reg
450 .choice_for("codex", "gpt-4.1-mini")
451 .unwrap()
452 .label_suffix()
453 .contains("no effort"));
454 assert!(reg
455 .choice_for("codex", "qwen3:32b")
456 .unwrap()
457 .label_suffix()
458 .contains("example"));
459 assert_eq!(
460 reg.choice_for("codex", "gpt-6-astra")
461 .unwrap()
462 .label_suffix(),
463 ""
464 );
465 }
466
467 #[test]
468 fn retired_model_takes_its_successors_efforts() {
469 let json = r#"{
472 "manifest_version": 1,
473 "last_refreshed": "2026-09-05",
474 "harnesses": {
475 "codex": {
476 "reasoning_key": "model_reasoning_effort",
477 "reasoning_efforts": ["none", "low", "medium", "high", "xhigh", "max"],
478 "namespaces": [
479 {
480 "id": "openai",
481 "match": "^gpt-[a-z0-9.-]*$",
482 "membership": "closed",
483 "models": [
484 { "id": "gpt-dead", "reasoning_efforts": ["low"], "last_verified": "2026-09-05", "status": "retired", "successor": "gpt-mid" },
485 { "id": "gpt-mid", "reasoning_efforts": ["medium"], "last_verified": "2026-09-05", "status": "retired", "successor": "gpt-live" },
486 { "id": "gpt-live", "reasoning_efforts": ["none", "high", "max"], "last_verified": "2026-09-05" },
487 { "id": "gpt-loop", "reasoning_efforts": ["low"], "last_verified": "2026-09-05", "status": "retired", "successor": "gpt-loop" },
488 { "id": "gpt-dangling", "reasoning_efforts": ["low"], "last_verified": "2026-09-05", "status": "retired", "successor": "gpt-missing" }
489 ]
490 }
491 ]
492 },
493 "claude-code": { "reasoning_key": "effort", "reasoning_efforts": ["high"], "namespaces": [
494 { "id": "alias", "match": "^opus$", "membership": "closed", "models": [{ "id": "opus", "last_verified": "2026-09-05" }] } ] },
495 "cursor": { "reasoning_key": null, "reasoning_efforts": [], "namespaces": [
496 { "id": "named", "match": "^[a-z0-9-]+$", "membership": "closed", "models": [{ "id": "composer-2", "last_verified": "2026-09-05" }] } ] }
497 }
498 }"#;
499 let reg: ModelRegistry = serde_json::from_str(json).expect("parse");
500 assert_eq!(
502 reg.efforts_for("codex", "gpt-dead"),
503 vec!["none", "high", "max"]
504 );
505 assert_eq!(
506 reg.efforts_for("codex", "gpt-mid"),
507 vec!["none", "high", "max"]
508 );
509 assert_eq!(
511 reg.efforts_for("codex", "gpt-live"),
512 vec!["none", "high", "max"]
513 );
514 assert_eq!(reg.efforts_for("codex", "gpt-loop"), vec!["low"]);
516 assert_eq!(reg.efforts_for("codex", "gpt-dangling"), vec!["low"]);
517 assert!(reg
519 .choice_for("codex", "gpt-dead")
520 .unwrap()
521 .label_suffix()
522 .contains("retired -> gpt-mid"));
523 }
524
525 #[test]
526 fn unknown_fields_are_rejected() {
527 let json = r#"{ "id": "x", "last_verified": "2026-09-05", "surprise": true }"#;
529 assert!(serde_json::from_str::<RegistryModel>(json).is_err());
530 }
531
532 #[test]
533 fn parses_the_committed_registry() {
534 let path = concat!(
537 env!("CARGO_MANIFEST_DIR"),
538 "/../../catalog/model-registry.json"
539 );
540 let data = std::fs::read_to_string(path).expect("committed registry is readable");
541 let reg: ModelRegistry = serde_json::from_str(&data).expect("committed registry parses");
542 for harness in ["codex", "claude-code", "cursor"] {
543 assert!(
544 !reg.choices_for_harness(harness).is_empty(),
545 "{harness} should offer at least one model choice"
546 );
547 }
548 }
549}