use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Membership {
Closed,
Open,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ModelStatus {
#[default]
Available,
Retiring,
Retired,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RegistryModel {
pub id: String,
#[serde(default)]
pub reasoning_efforts: Option<Vec<String>>,
pub last_verified: String,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub status: Option<ModelStatus>,
#[serde(default)]
pub retirement_date: Option<String>,
#[serde(default)]
pub successor: Option<String>,
}
impl RegistryModel {
pub fn status(&self) -> ModelStatus {
self.status.unwrap_or_default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RegistryNamespace {
pub id: String,
#[serde(default)]
pub description: Option<String>,
#[serde(rename = "match")]
pub match_pattern: String,
pub membership: Membership,
#[serde(default)]
pub model_provider: Option<String>,
#[serde(default)]
pub requires_provider_table: Option<bool>,
#[serde(default)]
pub reasoning_efforts: Option<Vec<String>>,
#[serde(default)]
pub reasoning_note: Option<String>,
#[serde(default)]
pub sources: Vec<String>,
#[serde(default)]
pub examples: Vec<String>,
#[serde(default)]
pub models: Vec<RegistryModel>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RegistryHarness {
pub reasoning_key: Option<String>,
pub reasoning_efforts: Vec<String>,
#[serde(default)]
pub reasoning_note: Option<String>,
pub namespaces: Vec<RegistryNamespace>,
}
impl RegistryHarness {
pub fn supports_reasoning(&self) -> bool {
self.reasoning_key.is_some() && !self.reasoning_efforts.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ModelRegistry {
pub manifest_version: u32,
#[serde(default)]
pub description: Option<String>,
pub last_refreshed: String,
pub harnesses: HashMap<String, RegistryHarness>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelChoice {
pub model: String,
pub namespace: String,
pub efforts: Vec<String>,
pub status: ModelStatus,
pub is_example: bool,
pub retirement_date: Option<String>,
pub successor: Option<String>,
pub note: Option<String>,
}
impl ModelChoice {
pub fn label_suffix(&self) -> String {
let mut parts: Vec<String> = Vec::new();
match self.status {
ModelStatus::Retiring => parts.push(match &self.retirement_date {
Some(d) => format!("retiring {d}"),
None => "retiring".to_string(),
}),
ModelStatus::Retired => parts.push(match &self.successor {
Some(s) => format!("retired -> {s}"),
None => "retired".to_string(),
}),
ModelStatus::Available => {}
}
if self.is_example {
parts.push("example".to_string());
}
if self.efforts.is_empty() {
parts.push("no effort".to_string());
}
if parts.is_empty() {
String::new()
} else {
format!(" ({})", parts.join(", "))
}
}
}
fn resolve_successor<'a>(ns: &'a RegistryNamespace, model: &'a RegistryModel) -> &'a RegistryModel {
let mut current = model;
for _ in 0..ns.models.len() {
if current.status() != ModelStatus::Retired {
break;
}
let Some(successor) = current.successor.as_deref() else {
break;
};
let Some(next) = ns.models.iter().find(|m| m.id == successor) else {
break;
};
if next.id == current.id {
break;
}
current = next;
}
current
}
impl ModelRegistry {
pub fn harness(&self, harness: &str) -> Option<&RegistryHarness> {
self.harnesses.get(harness)
}
pub fn harness_efforts(&self, harness: &str) -> Vec<String> {
self.harness(harness)
.map(|h| h.reasoning_efforts.clone())
.unwrap_or_default()
}
pub fn supports_reasoning(&self, harness: &str) -> bool {
self.harness(harness)
.is_some_and(|h| h.supports_reasoning())
}
pub fn choices_for_harness(&self, harness: &str) -> Vec<ModelChoice> {
let Some(hdef) = self.harness(harness) else {
return Vec::new();
};
let mut out: Vec<ModelChoice> = Vec::new();
let mut seen: Vec<String> = Vec::new();
let push = |choice: ModelChoice, out: &mut Vec<ModelChoice>, seen: &mut Vec<String>| {
if seen.iter().any(|m| m == &choice.model) {
return;
}
seen.push(choice.model.clone());
out.push(choice);
};
for ns in &hdef.namespaces {
let ns_efforts = ns
.reasoning_efforts
.clone()
.unwrap_or_else(|| hdef.reasoning_efforts.clone());
for m in &ns.models {
let effective = resolve_successor(ns, m);
let efforts = effective
.reasoning_efforts
.clone()
.unwrap_or_else(|| ns_efforts.clone());
push(
ModelChoice {
model: m.id.clone(),
namespace: ns.id.clone(),
efforts,
status: m.status(),
is_example: false,
retirement_date: m.retirement_date.clone(),
successor: m.successor.clone(),
note: m.note.clone(),
},
&mut out,
&mut seen,
);
}
if ns.membership == Membership::Open {
for ex in &ns.examples {
push(
ModelChoice {
model: ex.clone(),
namespace: ns.id.clone(),
efforts: ns_efforts.clone(),
status: ModelStatus::Available,
is_example: true,
retirement_date: None,
successor: None,
note: None,
},
&mut out,
&mut seen,
);
}
}
}
out
}
pub fn choice_for(&self, harness: &str, model: &str) -> Option<ModelChoice> {
self.choices_for_harness(harness)
.into_iter()
.find(|c| c.model == model)
}
pub fn efforts_for(&self, harness: &str, model: &str) -> Vec<String> {
match self.choice_for(harness, model) {
Some(choice) => choice.efforts,
None => self.harness_efforts(harness),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> ModelRegistry {
let json = r#"{
"manifest_version": 1,
"description": "test",
"last_refreshed": "2026-09-05",
"harnesses": {
"codex": {
"reasoning_key": "model_reasoning_effort",
"reasoning_efforts": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
"namespaces": [
{
"id": "openai",
"match": "^(gpt-|o[0-9])[a-z0-9.-]*$",
"membership": "closed",
"model_provider": null,
"models": [
{ "id": "gpt-6-astra", "reasoning_efforts": ["low", "high", "max"], "last_verified": "2026-09-05" },
{ "id": "gpt-4.1-mini", "reasoning_efforts": [], "last_verified": "2026-09-05" },
{ "id": "gpt-old", "last_verified": "2026-09-05", "status": "retiring", "retirement_date": "2026-12-11", "successor": "gpt-6-astra" }
]
},
{
"id": "ollama",
"match": "^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$",
"membership": "open",
"model_provider": "ollama",
"requires_provider_table": true,
"reasoning_efforts": ["none", "low", "medium", "high", "max"],
"examples": ["qwen3:32b", "glm-5.3:cloud"]
}
]
},
"cursor": {
"reasoning_key": null,
"reasoning_efforts": [],
"namespaces": [
{ "id": "named", "match": "^[a-z0-9][a-z0-9.-]*$", "membership": "closed",
"models": [{ "id": "composer-2.5", "last_verified": "2026-09-05" }] }
]
}
}
}"#;
serde_json::from_str(json).expect("fixture parses")
}
#[test]
fn parses_registry_shape() {
let reg = fixture();
assert_eq!(reg.manifest_version, 1);
assert_eq!(reg.last_refreshed, "2026-09-05");
assert_eq!(reg.harness("codex").unwrap().namespaces.len(), 2);
}
#[test]
fn choices_include_closed_models_and_open_examples() {
let ids: Vec<String> = fixture()
.choices_for_harness("codex")
.into_iter()
.map(|c| c.model)
.collect();
assert_eq!(
ids,
vec![
"gpt-6-astra",
"gpt-4.1-mini",
"gpt-old",
"qwen3:32b",
"glm-5.3:cloud"
]
);
}
#[test]
fn open_namespace_entries_are_marked_as_examples() {
let reg = fixture();
assert!(!reg.choice_for("codex", "gpt-6-astra").unwrap().is_example);
let tag = reg.choice_for("codex", "qwen3:32b").unwrap();
assert!(tag.is_example);
assert_eq!(tag.namespace, "ollama");
}
#[test]
fn effort_narrowing_prefers_model_then_namespace_then_harness() {
let reg = fixture();
assert_eq!(
reg.efforts_for("codex", "gpt-6-astra"),
vec!["low", "high", "max"]
);
assert_eq!(
reg.efforts_for("codex", "qwen3:32b"),
vec!["none", "low", "medium", "high", "max"]
);
assert_eq!(reg.efforts_for("codex", "gpt-old").len(), 7);
}
#[test]
fn non_reasoning_model_offers_no_efforts() {
assert!(fixture().efforts_for("codex", "gpt-4.1-mini").is_empty());
}
#[test]
fn free_typed_model_falls_back_to_harness_vocabulary() {
let reg = fixture();
assert!(reg.choice_for("codex", "deepseek-r1:70b").is_none());
assert_eq!(reg.efforts_for("codex", "deepseek-r1:70b").len(), 7);
}
#[test]
fn harness_without_reasoning_key_reports_unsupported() {
let reg = fixture();
assert!(reg.supports_reasoning("codex"));
assert!(!reg.supports_reasoning("cursor"));
assert!(!reg.supports_reasoning("nonexistent"));
assert!(reg.choices_for_harness("nonexistent").is_empty());
}
#[test]
fn lifecycle_surfaces_in_the_picker_label() {
let reg = fixture();
let retiring = reg.choice_for("codex", "gpt-old").unwrap();
assert_eq!(retiring.status, ModelStatus::Retiring);
assert!(retiring.label_suffix().contains("retiring 2026-12-11"));
assert!(reg
.choice_for("codex", "gpt-4.1-mini")
.unwrap()
.label_suffix()
.contains("no effort"));
assert!(reg
.choice_for("codex", "qwen3:32b")
.unwrap()
.label_suffix()
.contains("example"));
assert_eq!(
reg.choice_for("codex", "gpt-6-astra")
.unwrap()
.label_suffix(),
""
);
}
#[test]
fn retired_model_takes_its_successors_efforts() {
let json = r#"{
"manifest_version": 1,
"last_refreshed": "2026-09-05",
"harnesses": {
"codex": {
"reasoning_key": "model_reasoning_effort",
"reasoning_efforts": ["none", "low", "medium", "high", "xhigh", "max"],
"namespaces": [
{
"id": "openai",
"match": "^gpt-[a-z0-9.-]*$",
"membership": "closed",
"models": [
{ "id": "gpt-dead", "reasoning_efforts": ["low"], "last_verified": "2026-09-05", "status": "retired", "successor": "gpt-mid" },
{ "id": "gpt-mid", "reasoning_efforts": ["medium"], "last_verified": "2026-09-05", "status": "retired", "successor": "gpt-live" },
{ "id": "gpt-live", "reasoning_efforts": ["none", "high", "max"], "last_verified": "2026-09-05" },
{ "id": "gpt-loop", "reasoning_efforts": ["low"], "last_verified": "2026-09-05", "status": "retired", "successor": "gpt-loop" },
{ "id": "gpt-dangling", "reasoning_efforts": ["low"], "last_verified": "2026-09-05", "status": "retired", "successor": "gpt-missing" }
]
}
]
},
"claude-code": { "reasoning_key": "effort", "reasoning_efforts": ["high"], "namespaces": [
{ "id": "alias", "match": "^opus$", "membership": "closed", "models": [{ "id": "opus", "last_verified": "2026-09-05" }] } ] },
"cursor": { "reasoning_key": null, "reasoning_efforts": [], "namespaces": [
{ "id": "named", "match": "^[a-z0-9-]+$", "membership": "closed", "models": [{ "id": "composer-2", "last_verified": "2026-09-05" }] } ] }
}
}"#;
let reg: ModelRegistry = serde_json::from_str(json).expect("parse");
assert_eq!(
reg.efforts_for("codex", "gpt-dead"),
vec!["none", "high", "max"]
);
assert_eq!(
reg.efforts_for("codex", "gpt-mid"),
vec!["none", "high", "max"]
);
assert_eq!(
reg.efforts_for("codex", "gpt-live"),
vec!["none", "high", "max"]
);
assert_eq!(reg.efforts_for("codex", "gpt-loop"), vec!["low"]);
assert_eq!(reg.efforts_for("codex", "gpt-dangling"), vec!["low"]);
assert!(reg
.choice_for("codex", "gpt-dead")
.unwrap()
.label_suffix()
.contains("retired -> gpt-mid"));
}
#[test]
fn unknown_fields_are_rejected() {
let json = r#"{ "id": "x", "last_verified": "2026-09-05", "surprise": true }"#;
assert!(serde_json::from_str::<RegistryModel>(json).is_err());
}
#[test]
fn parses_the_committed_registry() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../catalog/model-registry.json"
);
let data = std::fs::read_to_string(path).expect("committed registry is readable");
let reg: ModelRegistry = serde_json::from_str(&data).expect("committed registry parses");
for harness in ["codex", "claude-code", "cursor"] {
assert!(
!reg.choices_for_harness(harness).is_empty(),
"{harness} should offer at least one model choice"
);
}
}
}