use std::borrow::Cow;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::agent::Agent;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Kind {
Alias,
Pinned,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Model {
pub id: Cow<'static, str>,
pub name: Cow<'static, str>,
pub note: Cow<'static, str>,
pub kind: Kind,
pub efforts: Vec<Cow<'static, str>>,
pub is_default: bool,
}
impl Model {
fn new(
id: &'static str,
name: &'static str,
note: &'static str,
kind: Kind,
efforts: &[&'static str],
is_default: bool,
) -> Model {
Model {
id: Cow::Borrowed(id),
name: Cow::Borrowed(name),
note: Cow::Borrowed(note),
kind,
efforts: efforts.iter().map(|e| Cow::Borrowed(*e)).collect(),
is_default,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Verified {
pub source: Source,
pub checked: &'static str,
pub against: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Source {
Cli,
Picker,
Docs,
}
impl Agent {
#[must_use]
pub fn models(&self) -> Vec<Model> {
match self {
Agent::Claude => claude_models(),
Agent::Codex => codex_models(),
Agent::Copilot => copilot_models(),
}
}
#[must_use]
pub fn models_verified(&self) -> Verified {
match self {
Agent::Claude => Verified {
source: Source::Docs,
checked: "2026-07-30",
against: "claude 2.1.212",
},
Agent::Codex => Verified {
source: Source::Cli,
checked: "2026-08-07",
against: "codex-cli 0.146.0",
},
Agent::Copilot => Verified {
source: Source::Picker,
checked: "2026-07-29",
against: "Copilot CLI 1.0.75",
},
}
}
pub async fn discover_models(&self) -> Result<Vec<Model>> {
match self {
Agent::Codex => discover_codex(self.bin()).await,
Agent::Claude | Agent::Copilot => Err(Error::Unsupported {
agent: *self,
what: "listing models without a terminal",
}),
}
}
}
const CLAUDE_EFFORTS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
fn claude_models() -> Vec<Model> {
let mut models = claude_aliases();
models.extend(claude_pinned());
models
}
fn claude_aliases() -> Vec<Model> {
vec![
Model::new(
"default",
"Default",
"Whatever is recommended for this account, or the organization default",
Kind::Alias,
CLAUDE_EFFORTS,
true,
),
Model::new(
"opus",
"Opus",
"Latest Opus, for complex reasoning",
Kind::Alias,
CLAUDE_EFFORTS,
false,
),
Model::new(
"sonnet",
"Sonnet",
"Latest Sonnet, for daily coding",
Kind::Alias,
CLAUDE_EFFORTS,
false,
),
Model::new(
"haiku",
"Haiku",
"Fast and efficient, for simple tasks",
Kind::Alias,
CLAUDE_EFFORTS,
false,
),
Model::new(
"fable",
"Fable",
"For the hardest and longest-running tasks (1M context)",
Kind::Alias,
CLAUDE_EFFORTS,
false,
),
Model::new(
"best",
"Best available",
"Fable where the organization has it, otherwise the latest Opus",
Kind::Alias,
CLAUDE_EFFORTS,
false,
),
Model::new(
"opusplan",
"Opus, then Sonnet",
"Opus while planning, Sonnet to execute (a mode, not a model)",
Kind::Alias,
CLAUDE_EFFORTS,
false,
),
Model::new(
"opus[1m]",
"Opus (1M context)",
"Opus with a 1M token context window (a variant, not a model)",
Kind::Alias,
CLAUDE_EFFORTS,
false,
),
Model::new(
"sonnet[1m]",
"Sonnet (1M context)",
"Sonnet with a 1M token context window (a variant, not a model)",
Kind::Alias,
CLAUDE_EFFORTS,
false,
),
]
}
fn claude_pinned() -> Vec<Model> {
vec![
Model::new(
"claude-opus-5",
"Claude Opus 5",
"For complex agentic coding and enterprise work (200k context)",
Kind::Pinned,
CLAUDE_EFFORTS,
false,
),
Model::new(
"claude-opus-5[1m]",
"Claude Opus 5 (1M context)",
"Opus 5 with a 1M token context window",
Kind::Pinned,
CLAUDE_EFFORTS,
false,
),
Model::new(
"claude-sonnet-5",
"Claude Sonnet 5",
"The best combination of speed and intelligence (1M context)",
Kind::Pinned,
CLAUDE_EFFORTS,
false,
),
Model::new(
"claude-fable-5",
"Claude Fable 5",
"Next-generation intelligence for long-running agents (1M context)",
Kind::Pinned,
CLAUDE_EFFORTS,
false,
),
Model::new(
"claude-haiku-4-5",
"Claude Haiku 4.5",
"The fastest model with near-frontier intelligence",
Kind::Pinned,
CLAUDE_EFFORTS,
false,
),
]
}
fn codex_models() -> Vec<Model> {
const FULL: &[&str] = &["low", "medium", "high", "xhigh", "max", "ultra"];
const TO_MAX: &[&str] = &["low", "medium", "high", "xhigh", "max"];
const TO_XHIGH: &[&str] = &["low", "medium", "high", "xhigh"];
vec![
Model::new(
"gpt-5.6-sol",
"GPT-5.6-Sol",
"Latest frontier agentic coding model.",
Kind::Pinned,
FULL,
true,
),
Model::new(
"gpt-5.6-terra",
"GPT-5.6-Terra",
"Balanced agentic coding model for everyday work.",
Kind::Pinned,
FULL,
false,
),
Model::new(
"gpt-5.6-luna",
"GPT-5.6-Luna",
"Fast and affordable agentic coding model.",
Kind::Pinned,
TO_MAX,
false,
),
Model::new(
"gpt-5.5",
"GPT-5.5",
"Frontier model for complex coding, research, and real-world tasks.",
Kind::Pinned,
TO_XHIGH,
false,
),
Model::new(
"gpt-5.4",
"GPT-5.4",
"Strong model for everyday coding.",
Kind::Pinned,
TO_XHIGH,
false,
),
Model::new(
"gpt-5.4-mini",
"GPT-5.4-Mini",
"Small, fast, and cost-efficient model for simpler coding tasks.",
Kind::Pinned,
TO_XHIGH,
false,
),
]
}
const COPILOT_EFFORTS: &[&str] = &["none", "minimal", "low", "medium", "high", "xhigh", "max"];
fn copilot_models() -> Vec<Model> {
vec![
Model::new(
"auto",
"Auto",
"Copilot picks the best available model for each task",
Kind::Alias,
&[],
true,
),
pinned("claude-sonnet-5", "Claude Sonnet 5"),
pinned("claude-sonnet-4.6", "Claude Sonnet 4.6"),
pinned("claude-sonnet-4.5", "Claude Sonnet 4.5"),
pinned("claude-haiku-4.5", "Claude Haiku 4.5"),
pinned("claude-fable-5", "Claude Fable 5"),
pinned("claude-opus-5", "Claude Opus 5"),
pinned("claude-opus-4.8", "Claude Opus 4.8"),
pinned("claude-opus-4.8-fast", "Claude Opus 4.8 (fast)"),
pinned("claude-opus-4.7", "Claude Opus 4.7"),
pinned("claude-opus-4.6", "Claude Opus 4.6"),
pinned("claude-opus-4.5", "Claude Opus 4.5"),
pinned("gpt-5.6-sol", "GPT-5.6-Sol"),
pinned("gpt-5.6-terra", "GPT-5.6-Terra"),
pinned("gpt-5.6-luna", "GPT-5.6-Luna"),
pinned("gpt-5.5", "GPT-5.5"),
pinned("gpt-5.4", "GPT-5.4"),
pinned("gpt-5.3-codex", "GPT-5.3-Codex"),
pinned("gpt-5.4-mini", "GPT-5.4-Mini"),
pinned("gpt-5-mini", "GPT-5 mini"),
pinned("gemini-3.1-pro-preview", "Gemini 3.1 Pro (preview)"),
pinned("gemini-3.6-flash", "Gemini 3.6 Flash"),
pinned("gemini-3.5-flash", "Gemini 3.5 Flash"),
pinned("kimi-k2.7-code", "Kimi K2.7 Code"),
]
}
fn pinned(id: &'static str, name: &'static str) -> Model {
Model::new(id, name, "", Kind::Pinned, COPILOT_EFFORTS, false)
}
async fn discover_codex(bin: &str) -> Result<Vec<Model>> {
let output = tokio::process::Command::new(bin)
.args(["debug", "models"])
.output()
.await
.map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
Error::NotInstalled {
agent: Agent::Codex,
bin: bin.to_string(),
hint: Agent::Codex.install_hint(),
}
} else {
Error::Spawn {
bin: bin.to_string(),
source,
}
}
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
parse_codex_models(&stdout)
}
fn parse_codex_models(stdout: &str) -> Result<Vec<Model>> {
let value: Value = serde_json::from_str(stdout.trim()).map_err(|e| Error::Parse {
agent: Agent::Codex,
detail: format!("`codex debug models` did not return JSON: {e}"),
})?;
let listed = value
.get("models")
.and_then(Value::as_array)
.ok_or_else(|| Error::Parse {
agent: Agent::Codex,
detail: "`codex debug models` returned no `models` array".into(),
})?;
let mut ranked: Vec<(u64, Model)> = listed
.iter()
.filter(|m| m.get("visibility").and_then(Value::as_str) != Some("hide"))
.filter(|m| m.get("supported_in_api").and_then(Value::as_bool) != Some(false))
.filter_map(|m| {
let id = m.get("slug").and_then(Value::as_str)?;
let model = Model {
id: id.to_string().into(),
name: m
.get("display_name")
.and_then(Value::as_str)
.unwrap_or(id)
.to_string()
.into(),
note: m
.get("description")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
.into(),
kind: Kind::Pinned,
efforts: m
.get("supported_reasoning_levels")
.and_then(Value::as_array)
.map(|levels| {
levels
.iter()
.filter_map(|l| l.get("effort").and_then(Value::as_str))
.map(|e| Cow::Owned(e.to_string()))
.collect()
})
.unwrap_or_default(),
is_default: false,
};
let priority = m
.get("priority")
.and_then(Value::as_u64)
.unwrap_or(u64::MAX);
Some((priority, model))
})
.collect();
if ranked.is_empty() {
return Err(Error::Parse {
agent: Agent::Codex,
detail: "`codex debug models` listed no visible models".into(),
});
}
ranked.sort_by_key(|(priority, _)| *priority);
let mut models: Vec<Model> = ranked.into_iter().map(|(_, model)| model).collect();
if let Some(first) = models.first_mut() {
first.is_default = true;
}
Ok(models)
}
#[cfg(test)]
mod tests {
use super::*;
const CODEX_OUTPUT: &str = r#"{"models":[
{"slug":"gpt-5.5","display_name":"GPT-5.5","description":"Frontier model.",
"default_reasoning_level":"medium","visibility":"list","priority":7,
"supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"}]},
{"slug":"codex-auto-review","display_name":"Codex Auto Review","description":"Internal.",
"visibility":"hide","priority":43,"supported_reasoning_levels":[{"effort":"low"}]},
{"slug":"gpt-5.3-codex-spark","display_name":"GPT-5.3-Codex-Spark","description":"Inline only.",
"visibility":"list","supported_in_api":false,"priority":26,
"supported_reasoning_levels":[{"effort":"high"}]},
{"slug":"gpt-5.6-sol","display_name":"GPT-5.6-Sol","description":"Latest frontier model.",
"default_reasoning_level":"low","visibility":"list","priority":1,
"supported_reasoning_levels":[{"effort":"low"},{"effort":"ultra"}]}
]}"#;
#[test]
fn codex_discovery_reads_the_fields_a_picker_needs() {
let models = parse_codex_models(CODEX_OUTPUT).expect("should parse");
let sol = &models[0];
assert_eq!(sol.id, "gpt-5.6-sol");
assert_eq!(sol.name, "GPT-5.6-Sol");
assert_eq!(sol.note, "Latest frontier model.");
assert_eq!(sol.efforts, vec!["low", "ultra"]);
}
#[test]
fn codex_discovery_uses_the_vendors_ordering_not_the_array_order() {
let models = parse_codex_models(CODEX_OUTPUT).expect("should parse");
let ids: Vec<&str> = models.iter().map(|m| m.id.as_ref()).collect();
assert_eq!(ids, ["gpt-5.6-sol", "gpt-5.5"]);
assert!(
models[0].is_default,
"the top-priority model is the default"
);
}
#[test]
fn codex_discovery_drops_models_the_vendor_hides() {
let models = parse_codex_models(CODEX_OUTPUT).expect("should parse");
assert!(
!models.iter().any(|m| m.id == "codex-auto-review"),
"a hidden model must not reach a picker"
);
}
#[test]
fn codex_discovery_drops_visible_models_that_are_not_api_supported() {
let models = parse_codex_models(CODEX_OUTPUT).expect("should parse");
assert!(
!models.iter().any(|m| m.id == "gpt-5.3-codex-spark"),
"inline-only models cannot run through the crate's API paths"
);
}
#[test]
fn unparseable_output_is_an_error_not_an_empty_list() {
assert!(matches!(
parse_codex_models("Reading additional input from stdin..."),
Err(Error::Parse { .. })
));
assert!(
matches!(
parse_codex_models(r#"{"models":[]}"#),
Err(Error::Parse { .. })
),
"an empty list means the shape changed, not that Codex has no models"
);
}
#[tokio::test]
async fn agents_that_cannot_be_asked_say_so() {
for agent in [Agent::Claude, Agent::Copilot] {
assert!(
matches!(
agent.discover_models().await,
Err(Error::Unsupported { .. })
),
"{agent} should report that it cannot enumerate models"
);
}
}
#[test]
fn every_model_reports_the_levels_its_agent_accepts() {
for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
for model in agent.models() {
if agent == Agent::Copilot && model.id == "auto" {
continue;
}
assert!(
!model.efforts.is_empty(),
"{agent} model {} reports no effort levels",
model.id
);
}
}
}
#[test]
fn copilot_auto_offers_no_levels_because_it_refuses_them() {
let models = Agent::Copilot.models();
let auto = models.iter().find(|m| m.id == "auto").expect("auto");
assert!(
auto.efforts.is_empty(),
"auto rejects the effort flag outright"
);
let pinned = models.iter().find(|m| m.id == "gpt-5.5").expect("gpt-5.5");
assert!(
!pinned.efforts.is_empty(),
"pinned models do document levels"
);
}
#[test]
fn the_documented_level_sets_are_not_interchangeable() {
let claude = &Agent::Claude.models()[0].efforts;
let copilot_models = Agent::Copilot.models();
let copilot = &copilot_models
.iter()
.find(|m| m.id == "gpt-5.5")
.expect("gpt-5.5")
.efforts;
assert_eq!(claude, &["low", "medium", "high", "xhigh", "max"]);
assert_eq!(
copilot,
&["none", "minimal", "low", "medium", "high", "xhigh", "max"]
);
assert_ne!(claude, copilot, "a shared enum would have to cover both");
}
#[test]
fn codex_levels_differ_between_its_own_models() {
let models = Agent::Codex.models();
let by_id = |id: &str| -> Vec<String> {
models
.iter()
.find(|m| m.id == id)
.unwrap_or_else(|| panic!("{id} should be catalogued"))
.efforts
.iter()
.map(ToString::to_string)
.collect()
};
assert!(
by_id("gpt-5.6-sol").contains(&"ultra".to_string()),
"its frontier model offers ultra"
);
assert!(
!by_id("gpt-5.6-luna").contains(&"ultra".to_string()),
"its fast model does not"
);
}
#[test]
fn every_agent_offers_exactly_one_default() {
for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
let defaults = agent.models().iter().filter(|m| m.is_default).count();
assert_eq!(defaults, 1, "{agent} should mark exactly one default");
}
}
#[test]
fn no_catalogue_repeats_an_id() {
for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
let models = agent.models();
let mut ids: Vec<&str> = models.iter().map(|m| m.id.as_ref()).collect();
ids.sort_unstable();
let count = ids.len();
ids.dedup();
assert_eq!(ids.len(), count, "{agent} has a duplicate model id");
}
}
#[test]
fn an_unlisted_model_is_still_accepted() {
let request = crate::Request::new(Agent::Claude, "hi").model("some-model-from-next-year");
let argv = request
.argv()
.expect("an unlisted model must not be rejected");
assert!(
argv.windows(2)
.any(|w| w[0] == "--model" && w[1] == "some-model-from-next-year"),
"the model should reach the command line verbatim: {argv:?}"
);
}
}