#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Family {
ClaudeOpus,
ClaudeSonnet,
ClaudeHaiku,
Gpt,
Gemini,
}
impl Family {
const fn priority(self) -> u8 {
match self {
Self::ClaudeOpus => 10,
Self::ClaudeSonnet => 8,
Self::ClaudeHaiku => 6,
Self::Gpt => 5,
Self::Gemini => 4,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tier {
Full,
Fast,
Mini,
}
impl Tier {
const fn priority(self) -> u8 {
match self {
Self::Full => 2,
Self::Fast => 1,
Self::Mini => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReasoningEffort {
Low,
Medium,
High,
XHigh,
}
impl ReasoningEffort {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ModelCandidate {
pub id: &'static str,
pub family: Family,
pub version: (u8, u8),
pub tier: Tier,
}
pub const CATALOG: &[ModelCandidate] = &[
ModelCandidate {
id: "claude-opus-4.7",
family: Family::ClaudeOpus,
version: (4, 7),
tier: Tier::Full,
},
ModelCandidate {
id: "claude-opus-4.7-fast",
family: Family::ClaudeOpus,
version: (4, 7),
tier: Tier::Fast,
},
ModelCandidate {
id: "claude-opus-4.6",
family: Family::ClaudeOpus,
version: (4, 6),
tier: Tier::Full,
},
ModelCandidate {
id: "claude-opus-4.6-fast",
family: Family::ClaudeOpus,
version: (4, 6),
tier: Tier::Fast,
},
ModelCandidate {
id: "claude-opus-4.5",
family: Family::ClaudeOpus,
version: (4, 5),
tier: Tier::Full,
},
ModelCandidate {
id: "claude-sonnet-4.6",
family: Family::ClaudeSonnet,
version: (4, 6),
tier: Tier::Full,
},
ModelCandidate {
id: "claude-sonnet-4.5",
family: Family::ClaudeSonnet,
version: (4, 5),
tier: Tier::Full,
},
ModelCandidate {
id: "claude-sonnet-4",
family: Family::ClaudeSonnet,
version: (4, 0),
tier: Tier::Full,
},
ModelCandidate {
id: "claude-haiku-4.5",
family: Family::ClaudeHaiku,
version: (4, 5),
tier: Tier::Full,
},
ModelCandidate {
id: "gpt-5.4",
family: Family::Gpt,
version: (5, 4),
tier: Tier::Full,
},
ModelCandidate {
id: "gpt-5.3-codex",
family: Family::Gpt,
version: (5, 3),
tier: Tier::Full,
},
ModelCandidate {
id: "gpt-5.2-codex",
family: Family::Gpt,
version: (5, 2),
tier: Tier::Full,
},
ModelCandidate {
id: "gpt-5.2",
family: Family::Gpt,
version: (5, 2),
tier: Tier::Full,
},
ModelCandidate {
id: "gpt-5.1-codex-max",
family: Family::Gpt,
version: (5, 1),
tier: Tier::Full,
},
ModelCandidate {
id: "gpt-5.1-codex",
family: Family::Gpt,
version: (5, 1),
tier: Tier::Full,
},
ModelCandidate {
id: "gpt-5.1",
family: Family::Gpt,
version: (5, 1),
tier: Tier::Full,
},
ModelCandidate {
id: "gpt-5.1-codex-mini",
family: Family::Gpt,
version: (5, 1),
tier: Tier::Mini,
},
ModelCandidate {
id: "gpt-5-mini",
family: Family::Gpt,
version: (5, 0),
tier: Tier::Mini,
},
ModelCandidate {
id: "gpt-4.1",
family: Family::Gpt,
version: (4, 1),
tier: Tier::Full,
},
ModelCandidate {
id: "gemini-3-pro-preview",
family: Family::Gemini,
version: (3, 0),
tier: Tier::Full,
},
];
pub fn rank(candidates: &mut [ModelCandidate]) {
candidates.sort_by(|a, b| {
b.family
.priority()
.cmp(&a.family.priority())
.then_with(|| b.version.cmp(&a.version))
.then_with(|| b.tier.priority().cmp(&a.tier.priority()))
});
}
#[must_use]
pub fn preferred_default() -> &'static str {
CATALOG[0].id
}
#[must_use]
pub fn catalog_ids() -> Vec<String> {
CATALOG.iter().map(|c| c.id.to_owned()).collect()
}
#[must_use]
pub fn next_preferred(current: &str) -> Option<&'static str> {
let idx = CATALOG.iter().position(|c| c.id == current)?;
CATALOG.get(idx + 1).map(|c| c.id)
}
#[must_use]
pub fn find(id: &str) -> Option<&'static ModelCandidate> {
CATALOG.iter().find(|c| c.id == id)
}
#[must_use]
pub fn default_effort_for(model_id: &str) -> Option<ReasoningEffort> {
let candidate = find(model_id)?;
match candidate.family {
Family::ClaudeOpus | Family::ClaudeSonnet => Some(ReasoningEffort::Medium),
Family::Gpt if candidate.version.0 >= 5 => Some(ReasoningEffort::Medium),
_ => None,
}
}
#[must_use]
pub fn classify_model_error(stderr: &str) -> Option<String> {
let needle = "from --model flag is not available";
let line = stderr.lines().find(|l| l.contains(needle))?;
let after_first_quote = line.split_once('"')?.1;
let (model, _) = after_first_quote.split_once('"')?;
Some(model.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_is_pre_ranked() {
let mut cloned: Vec<ModelCandidate> = CATALOG.to_vec();
rank(&mut cloned);
let ordered_ids: Vec<&str> = cloned.iter().map(|c| c.id).collect();
let catalog_ids: Vec<&str> = CATALOG.iter().map(|c| c.id).collect();
assert_eq!(
ordered_ids, catalog_ids,
"CATALOG entries should already be sorted by rank()"
);
}
#[test]
fn preferred_default_is_newest_opus() {
assert_eq!(preferred_default(), "claude-opus-4.7");
}
#[test]
fn next_preferred_walks_catalog() {
assert_eq!(
next_preferred("claude-opus-4.7"),
Some("claude-opus-4.7-fast")
);
assert_eq!(
next_preferred("claude-opus-4.6-fast"),
Some("claude-opus-4.5")
);
}
#[test]
fn next_preferred_unknown_id_returns_none() {
assert_eq!(next_preferred("model-that-does-not-exist"), None);
}
#[test]
fn next_preferred_last_entry_returns_none() {
let last = CATALOG[CATALOG.len() - 1].id;
assert_eq!(next_preferred(last), None);
}
#[test]
fn classify_model_error_parses_copilot_message() {
let stderr = "Error: Model \"claude-opus-4.6-fast\" from --model flag is not available.";
assert_eq!(
classify_model_error(stderr),
Some("claude-opus-4.6-fast".to_owned())
);
}
#[test]
fn classify_model_error_ignores_unrelated_stderr() {
assert_eq!(classify_model_error(""), None);
assert_eq!(classify_model_error("Error: network unreachable"), None);
assert_eq!(classify_model_error("Model \"x\" is invalid"), None);
}
#[test]
fn default_effort_picks_medium_for_opus_sonnet_gpt5() {
assert_eq!(
default_effort_for("claude-opus-4.7"),
Some(ReasoningEffort::Medium)
);
assert_eq!(
default_effort_for("claude-sonnet-4.6"),
Some(ReasoningEffort::Medium)
);
assert_eq!(default_effort_for("gpt-5.4"), Some(ReasoningEffort::Medium));
}
#[test]
fn default_effort_is_none_for_haiku_and_gemini() {
assert_eq!(default_effort_for("claude-haiku-4.5"), None);
assert_eq!(default_effort_for("gemini-3-pro-preview"), None);
assert_eq!(default_effort_for("gpt-4.1"), None);
}
#[test]
fn default_effort_is_none_for_unknown_id() {
assert_eq!(default_effort_for("unknown-model"), None);
}
#[test]
fn catalog_ids_matches_catalog_len() {
assert_eq!(catalog_ids().len(), CATALOG.len());
}
#[test]
fn reasoning_effort_as_str() {
assert_eq!(ReasoningEffort::Low.as_str(), "low");
assert_eq!(ReasoningEffort::Medium.as_str(), "medium");
assert_eq!(ReasoningEffort::High.as_str(), "high");
assert_eq!(ReasoningEffort::XHigh.as_str(), "xhigh");
}
}