struct ModelPrice {
input_per_m: f64,
output_per_m: f64,
}
fn lookup(model: &str) -> Option<ModelPrice> {
let name = model
.rsplit_once('/')
.map_or(model, |(_, n)| n)
.to_lowercase();
let p = if name.starts_with("claude-opus-4") {
ModelPrice {
input_per_m: 15.0,
output_per_m: 75.0,
}
} else if name.starts_with("claude-sonnet-4") {
ModelPrice {
input_per_m: 3.0,
output_per_m: 15.0,
}
} else if name.starts_with("claude-haiku-4") {
ModelPrice {
input_per_m: 0.8,
output_per_m: 4.0,
}
} else if name.starts_with("claude-opus-3") {
ModelPrice {
input_per_m: 15.0,
output_per_m: 75.0,
}
} else if name.starts_with("claude-sonnet-3") {
ModelPrice {
input_per_m: 3.0,
output_per_m: 15.0,
}
} else if name.starts_with("claude-haiku-3") {
ModelPrice {
input_per_m: 0.25,
output_per_m: 1.25,
}
} else if name.starts_with("gpt-4o-mini") {
ModelPrice {
input_per_m: 0.15,
output_per_m: 0.60,
}
} else if name.starts_with("gpt-4o") {
ModelPrice {
input_per_m: 2.50,
output_per_m: 10.0,
}
} else if name.starts_with("gpt-4-turbo") || name.starts_with("gpt-4-1106") {
ModelPrice {
input_per_m: 10.0,
output_per_m: 30.0,
}
} else if name.starts_with("gpt-3.5") {
ModelPrice {
input_per_m: 0.50,
output_per_m: 1.50,
}
} else if name.starts_with("gemini-2.5-pro") {
ModelPrice {
input_per_m: 1.25,
output_per_m: 10.0,
}
} else if name.starts_with("gemini-2.5-flash") {
ModelPrice {
input_per_m: 0.15,
output_per_m: 0.60,
}
} else if name.starts_with("gemini-2.0-flash") {
ModelPrice {
input_per_m: 0.10,
output_per_m: 0.40,
}
} else if name.starts_with("gemini-1.5-pro") {
ModelPrice {
input_per_m: 3.50,
output_per_m: 10.50,
}
} else if name.starts_with("gemini-1.5-flash") {
ModelPrice {
input_per_m: 0.075,
output_per_m: 0.30,
}
} else {
return None;
};
Some(p)
}
pub fn estimate_cost_usd(model: &str, input_tokens: u32, output_tokens: u32) -> Option<f64> {
let p = lookup(model)?;
let cost = (f64::from(input_tokens) / 1_000_000.0) * p.input_per_m
+ (f64::from(output_tokens) / 1_000_000.0) * p.output_per_m;
Some(cost)
}
pub fn usage_footer(model: &str, iters: u32, input_tokens: u32, output_tokens: u32) -> String {
let total = input_tokens + output_tokens;
let cost_part = estimate_cost_usd(model, input_tokens, output_tokens)
.map(|c| format!(" | ~${c:.4}"))
.unwrap_or_default();
let short_model = model.rsplit_once('/').map_or(model, |(_, n)| n);
format!(
"[{iters} iter | {input_tokens}in {output_tokens}out {total}tok{cost_part} @ {short_model}]"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_model_has_cost() {
let c = estimate_cost_usd("anthropic/claude-sonnet-4-6", 10_000, 2_000);
assert!(c.is_some());
let c = c.unwrap();
assert!((c - 0.060).abs() < 0.001, "expected ~$0.06, got {c}");
}
#[test]
fn unknown_model_returns_none() {
assert!(estimate_cost_usd("local/mistral-7b", 1000, 500).is_none());
}
#[test]
fn footer_includes_iter_and_tokens() {
let footer = usage_footer("claude-sonnet-4-6", 3, 1000, 500);
assert!(footer.contains("3 iter"));
assert!(footer.contains("1000in"));
assert!(footer.contains("500out"));
assert!(footer.contains("1500tok"));
}
#[test]
fn footer_strips_provider_prefix() {
let footer = usage_footer("anthropic/claude-haiku-4-5", 1, 100, 50);
assert!(footer.contains("@ claude-haiku-4-5"));
assert!(!footer.contains("anthropic/"));
}
}