use crate::api::{ReasoningEffort, ReasoningMode};
const CACHE_READ_RATE: f64 = 0.10;
const CACHE_CREATION_RATE: f64 = 1.25;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Provider {
Anthropic,
OpenAI,
}
impl Provider {
pub fn label(self) -> &'static str {
match self {
Provider::Anthropic => "Anthropic",
Provider::OpenAI => "OpenAI",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Model {
pub name: &'static str,
pub description: &'static str,
pub provider: Provider,
pub context_window: u32,
pub max_output_tokens: u32,
pub auto_compact_token_limit: Option<u32>,
pub requires_adaptive_thinking: bool,
pub supports_server_compaction: bool,
pub supported_efforts: &'static [ReasoningEffort],
pub supports_refusal_fallback: bool,
pub supports_pro_mode: bool,
pub price_input_per_m: f64,
pub price_output_per_m: f64,
}
impl Model {
pub fn auto_compact_at(&self) -> u32 {
let api_ceiling = ((self.context_window as u64).saturating_mul(9) / 10) as u32;
match self.auto_compact_token_limit {
Some(limit) => limit.min(api_ceiling),
None => api_ceiling,
}
}
pub fn effective_window(&self) -> u32 {
((self.context_window as u64).saturating_mul(95) / 100) as u32
}
pub fn turn_cost(
&self,
input_tokens: u32,
output_tokens: u32,
cache_read_tokens: u32,
cache_creation_tokens: u32,
) -> f64 {
let uncached = match self.provider {
Provider::OpenAI => input_tokens.saturating_sub(cache_read_tokens),
Provider::Anthropic => input_tokens,
};
let per_million = |tokens: u32, price: f64| (tokens as f64 / 1_000_000.0) * price;
per_million(uncached, self.price_input_per_m)
+ per_million(cache_read_tokens, self.price_input_per_m) * CACHE_READ_RATE
+ per_million(cache_creation_tokens, self.price_input_per_m) * CACHE_CREATION_RATE
+ per_million(output_tokens, self.price_output_per_m)
}
pub fn supported_efforts_label(&self) -> String {
self.supported_efforts
.iter()
.map(|e| e.as_label())
.collect::<Vec<_>>()
.join(", ")
}
}
impl Default for Model {
fn default() -> Self {
SUPPORTED_MODELS[DEFAULT_MODEL_INDEX]
}
}
const DEFAULT_MODEL_INDEX: usize = 2;
pub const DEFAULT_MODEL_NAME: &str = SUPPORTED_MODELS[DEFAULT_MODEL_INDEX].name;
const _: () = assert!(DEFAULT_MODEL_INDEX < SUPPORTED_MODELS.len());
pub const CLAUDE_FABLE: &str = "claude-fable-5";
pub const CLAUDE_OPUS: &str = "claude-opus-5";
pub const CLAUDE_SONNET: &str = "claude-sonnet-5";
pub const CLAUDE_HAIKU: &str = "claude-haiku-4-5";
pub const GPT_SOL: &str = "gpt-5.6-sol";
pub const GPT_TERRA: &str = "gpt-5.6-terra";
pub const GPT_LUNA: &str = "gpt-5.6-luna";
pub const SUPPORTED_MODELS: &[Model] = &[
Model {
name: CLAUDE_FABLE,
description: "Anthropic's most capable model - demanding reasoning, 1M context",
provider: Provider::Anthropic,
context_window: 1_000_000,
max_output_tokens: 128_000,
auto_compact_token_limit: Some(250_000),
requires_adaptive_thinking: true,
supports_server_compaction: true,
supports_refusal_fallback: true,
supported_efforts: &[
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::XHigh,
ReasoningEffort::Max,
],
supports_pro_mode: false,
price_input_per_m: 10.0,
price_output_per_m: 50.0,
},
Model {
name: CLAUDE_OPUS,
description: "Powerful Anthropic reasoning model, 1M context",
provider: Provider::Anthropic,
context_window: 1_000_000,
max_output_tokens: 128_000,
auto_compact_token_limit: Some(250_000),
requires_adaptive_thinking: true,
supports_server_compaction: true,
supports_refusal_fallback: true,
supported_efforts: &[
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::XHigh,
ReasoningEffort::Max,
],
supports_pro_mode: false,
price_input_per_m: 5.0,
price_output_per_m: 25.0,
},
Model {
name: CLAUDE_SONNET,
description: "Balanced Anthropic model - default for day-to-day coding",
provider: Provider::Anthropic,
context_window: 1_000_000,
max_output_tokens: 128_000,
auto_compact_token_limit: Some(250_000),
requires_adaptive_thinking: true,
supports_server_compaction: true,
supports_refusal_fallback: false,
supported_efforts: &[
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::XHigh,
ReasoningEffort::Max,
],
supports_pro_mode: false,
price_input_per_m: 3.0,
price_output_per_m: 15.0,
},
Model {
name: CLAUDE_HAIKU,
description: "Fastest, cheapest Anthropic model - 200k context",
provider: Provider::Anthropic,
context_window: 200_000,
max_output_tokens: 64_000,
auto_compact_token_limit: Some(170_000),
requires_adaptive_thinking: false,
supports_server_compaction: false,
supports_refusal_fallback: false,
supported_efforts: &[
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
],
supports_pro_mode: false,
price_input_per_m: 1.0,
price_output_per_m: 5.0,
},
Model {
name: GPT_SOL,
description: "OpenAI frontier model for complex professional work",
provider: Provider::OpenAI,
context_window: 1_050_000,
max_output_tokens: 128_000,
auto_compact_token_limit: Some(250_000),
requires_adaptive_thinking: false,
supports_server_compaction: false,
supports_refusal_fallback: false,
supported_efforts: &[
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::XHigh,
ReasoningEffort::Max,
],
supports_pro_mode: true,
price_input_per_m: 5.0,
price_output_per_m: 30.0,
},
Model {
name: GPT_TERRA,
description: "Balanced OpenAI model - intelligence at mid-tier cost",
provider: Provider::OpenAI,
context_window: 1_050_000,
max_output_tokens: 128_000,
auto_compact_token_limit: Some(250_000),
requires_adaptive_thinking: false,
supports_server_compaction: false,
supports_refusal_fallback: false,
supported_efforts: &[
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::XHigh,
ReasoningEffort::Max,
],
supports_pro_mode: true,
price_input_per_m: 2.5,
price_output_per_m: 15.0,
},
Model {
name: GPT_LUNA,
description: "OpenAI model optimised for cost-sensitive workloads",
provider: Provider::OpenAI,
context_window: 1_050_000,
max_output_tokens: 128_000,
auto_compact_token_limit: Some(250_000),
requires_adaptive_thinking: false,
supports_server_compaction: false,
supports_refusal_fallback: false,
supported_efforts: &[
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::XHigh,
ReasoningEffort::Max,
],
supports_pro_mode: true,
price_input_per_m: 1.0,
price_output_per_m: 6.0,
},
];
pub fn supported_models_label() -> String {
SUPPORTED_MODELS
.iter()
.map(|m| m.name)
.collect::<Vec<_>>()
.join(", ")
}
pub fn canonical_model(name: &str) -> Option<&'static Model> {
SUPPORTED_MODELS
.iter()
.find(|m| m.name.eq_ignore_ascii_case(name))
}
pub fn model_support_error(name: &str) -> Option<String> {
if canonical_model(name).is_some() {
return None;
}
Some(format!(
"Model `{}` is not supported. Available models: {}.",
name,
supported_models_label()
))
}
pub fn provider_for(name: &str) -> Provider {
lookup(name).provider
}
pub fn lookup(name: &str) -> &'static Model {
canonical_model(name).unwrap_or(&SUPPORTED_MODELS[DEFAULT_MODEL_INDEX])
}
pub fn effort_support_error(name: &str, effort: ReasoningEffort) -> Option<String> {
let info = lookup(name);
if info.supported_efforts.contains(&effort) {
return None;
}
Some(format!(
"Model `{}` does not accept reasoning effort `{}`. Supported levels: {}.",
name,
effort.as_label(),
info.supported_efforts_label(),
))
}
pub fn pricing_model<'a>(served_by: &'a str, configured: &'a str) -> &'a str {
if canonical_model(served_by).is_some() {
served_by
} else {
configured
}
}
pub fn max_tokens_support_error(name: &str, max_tokens: u32) -> Option<String> {
let info = lookup(name);
if max_tokens <= info.max_output_tokens {
return None;
}
Some(format!(
"Model `{}` produces at most {} output tokens, but `--max-tokens` is {}.",
name, info.max_output_tokens, max_tokens
))
}
pub fn pro_capable_models_label() -> String {
SUPPORTED_MODELS
.iter()
.filter(|m| m.supports_pro_mode)
.map(|m| m.name)
.collect::<Vec<_>>()
.join(", ")
}
pub fn mode_support_error(name: &str, mode: ReasoningMode) -> Option<String> {
if !mode.is_pro() || lookup(name).supports_pro_mode {
return None;
}
Some(format!(
"Model `{}` does not support pro mode. Pro mode is available on: {}.",
name,
pro_capable_models_label(),
))
}
pub fn supports_pro_mode(name: &str) -> bool {
lookup(name).supports_pro_mode
}
#[cfg(test)]
mod tests {
use super::*;
fn cost_of(model: &str, input: u32, output: u32, cache_read: u32, cache_creation: u32) -> f64 {
lookup(model).turn_cost(input, output, cache_read, cache_creation)
}
fn approx(a: f64, b: f64) {
assert!(
(a - b).abs() < 1e-9,
"expected ≈{}, got {} (delta {})",
b,
a,
(a - b).abs()
);
}
#[test]
fn openai_cost_uses_full_rate_when_no_cache() {
let cost = cost_of(crate::api::model_info::GPT_SOL, 100_000, 5_000, 0, 0);
approx(cost, 100_000.0 / 1e6 * 5.0 + 5_000.0 / 1e6 * 30.0);
}
#[test]
fn openai_cost_discounts_cache_reads_at_10pct() {
let cost = cost_of(crate::api::model_info::GPT_SOL, 100_000, 5_000, 75_000, 0);
approx(cost, 0.1625 + 0.15);
}
#[test]
fn openai_cost_3x_lower_than_pre_fix_at_75pct_hit_input_only() {
let pre_fix_input = 100_000.0 / 1e6 * 5.0;
let post_fix_input = cost_of(crate::api::model_info::GPT_SOL, 100_000, 0, 75_000, 0);
let ratio = pre_fix_input / post_fix_input;
assert!(
(2.9..=3.2).contains(&ratio),
"expected pre/post ratio ≈3x at 75% hit, got {:.2}x",
ratio
);
}
#[test]
fn anthropic_cost_input_tokens_already_excludes_cache() {
let cost = cost_of(
crate::api::model_info::CLAUDE_OPUS,
25_000,
5_000,
75_000,
0,
);
approx(cost, 0.1625 + 0.125);
}
#[test]
fn anthropic_cost_charges_creation_at_125pct() {
let cost = cost_of(crate::api::model_info::CLAUDE_OPUS, 0, 0, 0, 50_000);
approx(cost, 50_000.0 / 1e6 * 5.0 * 1.25);
}
#[test]
fn cache_hit_does_not_underflow_when_read_exceeds_input() {
let cost = cost_of(crate::api::model_info::GPT_SOL, 50_000, 0, 100_000, 0);
approx(cost, 100_000.0 / 1e6 * 5.0 * 0.10);
}
#[test]
fn unknown_model_falls_back_without_panic() {
let cost = cost_of("some-future-model", 1_000, 1_000, 0, 0);
approx(cost, 1_000.0 / 1e6 * 3.0 + 1_000.0 / 1e6 * 15.0);
}
#[test]
fn provider_routes_supported_models_correctly() {
assert_eq!(provider_for(CLAUDE_FABLE), Provider::Anthropic);
assert_eq!(provider_for(CLAUDE_OPUS), Provider::Anthropic);
assert_eq!(provider_for(CLAUDE_SONNET), Provider::Anthropic);
assert_eq!(provider_for(CLAUDE_HAIKU), Provider::Anthropic);
assert_eq!(provider_for(GPT_SOL), Provider::OpenAI);
assert_eq!(provider_for(GPT_TERRA), Provider::OpenAI);
assert_eq!(provider_for(GPT_LUNA), Provider::OpenAI);
assert_eq!(
provider_for(&CLAUDE_OPUS.to_uppercase()),
Provider::Anthropic
);
assert_eq!(provider_for("unknown-model"), Provider::Anthropic);
}
#[test]
fn provider_label_is_human_readable() {
assert_eq!(Provider::OpenAI.label(), "OpenAI");
assert_eq!(Provider::Anthropic.label(), "Anthropic");
}
#[test]
fn supported_models_contains_every_whitelisted_id_in_order() {
let names: Vec<&str> = SUPPORTED_MODELS.iter().map(|m| m.name).collect();
assert_eq!(
names,
vec![
CLAUDE_FABLE,
CLAUDE_OPUS,
CLAUDE_SONNET,
CLAUDE_HAIKU,
GPT_SOL,
GPT_TERRA,
GPT_LUNA,
]
);
}
#[test]
fn default_model_is_the_cli_default() {
assert_eq!(Model::default().name, CLAUDE_SONNET);
assert_eq!(SUPPORTED_MODELS[DEFAULT_MODEL_INDEX].name, CLAUDE_SONNET);
}
#[test]
fn canonical_model_normalises_case() {
let m = canonical_model(&CLAUDE_SONNET.to_uppercase()).expect("matches whitelist");
assert_eq!(m.name, CLAUDE_SONNET);
}
#[test]
fn model_support_error_accepts_whitelist_and_rejects_others() {
for m in SUPPORTED_MODELS {
assert!(
model_support_error(m.name).is_none(),
"{} should be accepted",
m.name
);
}
let err = model_support_error("gpt-9.9-imaginary").expect("imaginary model is rejected");
assert!(err.contains("gpt-9.9-imaginary"));
for m in SUPPORTED_MODELS {
assert!(
err.contains(m.name),
"supported list must mention {}",
m.name
);
}
}
#[test]
fn flagship_has_1m_context_and_server_compaction() {
let info = lookup(CLAUDE_FABLE);
assert_eq!(info.context_window, 1_000_000);
assert!(info.requires_adaptive_thinking);
assert!(info.supports_server_compaction);
}
#[test]
fn anthropic_adaptive_models_match_their_lookup_flag() {
for slug in [CLAUDE_FABLE, CLAUDE_OPUS, CLAUDE_SONNET] {
assert!(
lookup(slug).requires_adaptive_thinking,
"{slug} should use adaptive thinking"
);
}
assert!(!lookup(CLAUDE_HAIKU).requires_adaptive_thinking);
}
#[test]
fn unknown_model_resolves_to_the_default_model() {
let info = lookup("some-future-model-2099");
assert_eq!(info.name, Model::default().name);
assert_eq!(info.price_input_per_m, Model::default().price_input_per_m);
}
#[test]
fn max_tokens_support_error_accepts_up_to_the_model_ceiling() {
for m in SUPPORTED_MODELS {
assert!(
max_tokens_support_error(m.name, m.max_output_tokens).is_none(),
"{} should accept its own ceiling",
m.name
);
let err = max_tokens_support_error(m.name, m.max_output_tokens + 1)
.expect("one token past the ceiling is rejected");
assert!(err.contains(m.name));
}
}
#[test]
fn default_max_tokens_fits_every_supported_model() {
let smallest = SUPPORTED_MODELS
.iter()
.map(|m| m.max_output_tokens)
.min()
.expect("the table is never empty");
assert!(
crate::cli::DEFAULT_MAX_TOKENS <= smallest,
"default --max-tokens ({}) exceeds the smallest model ceiling ({smallest})",
crate::cli::DEFAULT_MAX_TOKENS
);
}
#[test]
fn openai_tiers_are_priced_cheapest_last() {
let sol = lookup(GPT_SOL);
let terra = lookup(GPT_TERRA);
let luna = lookup(GPT_LUNA);
assert!(luna.price_input_per_m < terra.price_input_per_m);
assert!(terra.price_input_per_m < sol.price_input_per_m);
}
#[test]
fn auto_compact_at_clamps_override_against_api_ceiling() {
let info = Model {
context_window: 100_000,
auto_compact_token_limit: Some(200_000),
..Model::default()
};
assert_eq!(info.auto_compact_at(), 90_000);
}
#[test]
fn auto_compact_at_falls_back_to_90pct_when_unset() {
let info = Model {
context_window: 200_000,
auto_compact_token_limit: None,
..Model::default()
};
assert_eq!(info.auto_compact_at(), 180_000);
}
#[test]
fn effective_window_reserves_5pct_headroom() {
let info = Model {
context_window: 1_000_000,
..Model::default()
};
assert_eq!(info.effective_window(), 950_000);
}
#[test]
fn anthropic_adaptive_models_advertise_server_compaction() {
for slug in [CLAUDE_FABLE, CLAUDE_OPUS, CLAUDE_SONNET] {
assert!(
lookup(slug).supports_server_compaction,
"{slug} should opt into server-side compaction"
);
}
}
#[test]
fn fastest_model_does_not_advertise_server_compaction() {
assert!(!lookup(CLAUDE_HAIKU).supports_server_compaction);
}
#[test]
fn effort_support_matches_provider_matrix() {
use ReasoningEffort::*;
let supports = |slug: &str, e: ReasoningEffort| effort_support_error(slug, e).is_none();
for m in SUPPORTED_MODELS {
for e in [Low, Medium, High] {
assert!(supports(m.name, e), "{} should accept {e:?}", m.name);
}
}
for slug in [
CLAUDE_FABLE,
CLAUDE_OPUS,
CLAUDE_SONNET,
GPT_SOL,
GPT_TERRA,
GPT_LUNA,
] {
assert!(supports(slug, XHigh), "{slug} should accept xhigh");
assert!(supports(slug, Max), "{slug} should accept max");
}
assert!(!supports(CLAUDE_HAIKU, XHigh));
assert!(!supports(CLAUDE_HAIKU, Max));
}
#[test]
fn effort_support_error_lists_supported_levels_for_the_model() {
for rejected in [ReasoningEffort::XHigh, ReasoningEffort::Max] {
let err = effort_support_error(CLAUDE_HAIKU, rejected)
.expect("effort above the model's ceiling should be rejected");
assert!(err.contains(CLAUDE_HAIKU));
assert!(err.contains(&format!("`{}`", rejected.as_label())));
let listed = err
.split("Supported levels: ")
.nth(1)
.expect("error message lists supported levels");
for label in ["low", "medium", "high"] {
assert!(listed.contains(label), "expected {label} in {listed}");
}
assert!(!listed.contains("xhigh"));
assert!(!listed.contains("max"));
}
assert!(effort_support_error(CLAUDE_OPUS, ReasoningEffort::Max).is_none());
assert!(effort_support_error(CLAUDE_SONNET, ReasoningEffort::XHigh).is_none());
assert!(effort_support_error(GPT_SOL, ReasoningEffort::XHigh).is_none());
assert!(effort_support_error(CLAUDE_HAIKU, ReasoningEffort::High).is_none());
}
#[test]
fn pro_mode_only_supported_on_gpt_5_6_family() {
use ReasoningMode::{Pro, Standard};
for m in SUPPORTED_MODELS {
assert!(mode_support_error(m.name, Standard).is_none());
}
assert!(mode_support_error(GPT_SOL, Pro).is_none());
assert!(mode_support_error(GPT_TERRA, Pro).is_none());
assert!(mode_support_error(GPT_LUNA, Pro).is_none());
let err = mode_support_error(CLAUDE_OPUS, Pro)
.expect("pro on a non-5.6 model should be rejected");
assert!(err.contains(CLAUDE_OPUS));
assert!(err.contains(GPT_SOL));
assert!(supports_pro_mode(GPT_SOL));
assert!(!supports_pro_mode(CLAUDE_OPUS));
}
}