use std::process::Command;
use anyhow::{Context, Result, bail};
use serde_json::Value;
use super::Codex;
use crate::harness::{Adapter, ModelAndEffort, said};
#[derive(Debug, Clone, PartialEq, Eq)]
struct CatalogModel {
slug: String,
display_name: Option<String>,
efforts: Vec<String>,
listed: bool,
}
#[derive(Debug)]
pub struct Catalog {
models: Vec<CatalogModel>,
}
impl Catalog {
pub fn read() -> Result<Catalog> {
let output = crate::harness::process::output(
&Codex,
Command::new(Codex.name()).args(["debug", "models"]),
None,
)
.context("could not run codex debug models to read Codex's Models")?;
if !output.status.success() {
bail!(
"codex debug models failed, so Codex's Models can't be read: {}",
said(&output)
);
}
Catalog::parse(&String::from_utf8_lossy(&output.stdout))
}
pub fn parse(json: &str) -> Result<Catalog> {
let parsed: Value = serde_json::from_str(json)
.context("codex debug models printed no JSON catalog of Models")?;
let models = parsed["models"]
.as_array()
.context("codex debug models printed no list of Models")?
.iter()
.filter_map(|model| {
Some(CatalogModel {
slug: model["slug"].as_str()?.to_string(),
display_name: model["display_name"].as_str().map(String::from),
efforts: model["supported_reasoning_levels"]
.as_array()
.into_iter()
.flatten()
.filter_map(|level| level["effort"].as_str().map(String::from))
.collect(),
listed: model["visibility"] != "hide",
})
})
.collect();
Ok(Catalog { models })
}
pub fn listed(&self) -> Vec<String> {
self.models
.iter()
.filter(|model| model.listed)
.map(|model| match &model.display_name {
Some(display) => format!("{} ({display})", model.slug),
None => model.slug.clone(),
})
.collect()
}
pub fn efforts(&self, slug: Option<&str>) -> Vec<&str> {
let mut efforts = Vec::new();
let models = self
.models
.iter()
.filter(|model| slug.is_none_or(|slug| model.slug == slug));
for each in models.flat_map(|model| &model.efforts) {
if !efforts.contains(&each.as_str()) {
efforts.push(each.as_str());
}
}
efforts
}
pub fn settle(&self, model: Option<&str>, effort: Option<&str>) -> Result<ModelAndEffort> {
let found = match model {
Some(model) => Some(self.model(model)?),
None => None,
};
let Some(effort) = effort else {
return Ok(ModelAndEffort {
model: found.map(|found| found.slug.clone()),
effort: None,
});
};
let efforts = self.efforts(found.map(|found| found.slug.as_str()));
let Some(settled) = efforts
.iter()
.find(|supported| supported.eq_ignore_ascii_case(effort))
else {
let of = match found {
Some(found) => format!("the Codex Model {}", found.slug),
None => "any Codex Model".to_string(),
};
bail!(
"the Effort {effort} is not one {of} supports: choose one of {}",
efforts.join(", ")
);
};
Ok(ModelAndEffort {
model: found.map(|found| found.slug.clone()),
effort: Some(settled.to_string()),
})
}
fn model(&self, name: &str) -> Result<&CatalogModel> {
let named = |candidate: &&CatalogModel| {
candidate.slug.eq_ignore_ascii_case(name)
|| candidate
.display_name
.as_deref()
.is_some_and(|display| display.eq_ignore_ascii_case(name))
};
if let Some(found) = self.models.iter().find(named) {
return Ok(found);
}
let listed: Vec<&str> = self
.models
.iter()
.filter(|model| model.listed)
.map(|model| model.slug.as_str())
.collect();
bail!(
"the Model {name} is not in Codex's catalog: choose one of {}",
listed.join(", ")
)
}
}