use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::metrics::TokenUsage;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelRate {
pub input_usd_per_1k: f64,
pub output_usd_per_1k: f64,
}
impl ModelRate {
pub fn cost(&self, usage: &TokenUsage) -> f64 {
let input_cost = (usage.input_tokens as f64 / 1000.0) * self.input_usd_per_1k;
let output_cost = (usage.output_tokens as f64 / 1000.0) * self.output_usd_per_1k;
input_cost + output_cost
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct PricingConfig {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub overrides: HashMap<String, ModelRate>,
}
impl PricingConfig {
pub fn rate_for(&self, model_id: &str) -> Option<ModelRate> {
if let Some(r) = self.overrides.get(model_id) {
return Some(r.clone());
}
let prefix_match = self
.overrides
.iter()
.filter(|(k, _)| model_id.starts_with(k.as_str()))
.max_by_key(|(k, _)| k.len());
if let Some((_, r)) = prefix_match {
return Some(r.clone());
}
builtin_rate(model_id)
}
pub fn estimate_cost(&self, model_id: &str, usage: Option<&TokenUsage>) -> Option<f64> {
let usage = usage?;
let rate = self.rate_for(model_id)?;
Some(rate.cost(usage))
}
}
fn builtin_rate(model_id: &str) -> Option<ModelRate> {
let m = model_id.to_lowercase();
for prefix in &[
"gemma",
"llama",
"qwen",
"mistral",
"phi",
"orca",
"codellama",
"deepseek",
"starcoder",
"falcon",
"vicuna",
"neural-chat",
"openhermes",
"nous-hermes",
"dolphin",
"bakllava",
"llava",
"yi:",
"yi-",
"zephyr",
"tinyllama",
] {
if m.starts_with(prefix) {
return Some(ModelRate {
input_usd_per_1k: 0.0,
output_usd_per_1k: 0.0,
});
}
}
if m.starts_with("gpt-4o-mini") {
return Some(ModelRate {
input_usd_per_1k: 0.00015,
output_usd_per_1k: 0.0006,
});
}
if m.starts_with("gpt-4o") {
return Some(ModelRate {
input_usd_per_1k: 0.005,
output_usd_per_1k: 0.015,
});
}
if m.starts_with("gpt-4-turbo") || m.starts_with("gpt-4") {
return Some(ModelRate {
input_usd_per_1k: 0.01,
output_usd_per_1k: 0.03,
});
}
if m.starts_with("gpt-3.5-turbo") {
return Some(ModelRate {
input_usd_per_1k: 0.0005,
output_usd_per_1k: 0.0015,
});
}
if m.starts_with("o1-mini") {
return Some(ModelRate {
input_usd_per_1k: 0.003,
output_usd_per_1k: 0.012,
});
}
if m.starts_with("o1") {
return Some(ModelRate {
input_usd_per_1k: 0.015,
output_usd_per_1k: 0.06,
});
}
if m.contains("claude-3-5-sonnet") || m.contains("claude-3.5-sonnet") {
return Some(ModelRate {
input_usd_per_1k: 0.003,
output_usd_per_1k: 0.015,
});
}
if m.contains("claude-3-5-haiku") || m.contains("claude-3.5-haiku") {
return Some(ModelRate {
input_usd_per_1k: 0.0008,
output_usd_per_1k: 0.004,
});
}
if m.contains("claude-3-opus") {
return Some(ModelRate {
input_usd_per_1k: 0.015,
output_usd_per_1k: 0.075,
});
}
if m.contains("claude-3-sonnet") {
return Some(ModelRate {
input_usd_per_1k: 0.003,
output_usd_per_1k: 0.015,
});
}
if m.contains("claude-3-haiku") {
return Some(ModelRate {
input_usd_per_1k: 0.00025,
output_usd_per_1k: 0.00125,
});
}
if m.contains("claude-2") {
return Some(ModelRate {
input_usd_per_1k: 0.008,
output_usd_per_1k: 0.024,
});
}
if m.contains("gemini-1.5-pro") {
return Some(ModelRate {
input_usd_per_1k: 0.0035,
output_usd_per_1k: 0.0105,
});
}
if m.contains("gemini-1.5-flash") {
return Some(ModelRate {
input_usd_per_1k: 0.000075,
output_usd_per_1k: 0.0003,
});
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn usage(inp: u32, out: u32) -> TokenUsage {
TokenUsage {
input_tokens: inp,
output_tokens: out,
}
}
#[test]
fn local_models_are_free() {
let cfg = PricingConfig::default();
for model in &[
"gemma4:e2b",
"llama3.1:8b",
"qwen2.5-coder:32b",
"mistral:7b",
] {
let cost = cfg.estimate_cost(model, Some(&usage(1000, 500)));
assert_eq!(cost, Some(0.0), "expected free for {model}");
}
}
#[test]
fn gpt4o_has_cost() {
let cfg = PricingConfig::default();
let cost = cfg.estimate_cost("gpt-4o", Some(&usage(1000, 500)));
assert!(cost.is_some(), "gpt-4o should have a rate");
assert!(cost.unwrap() > 0.0);
}
#[test]
fn unknown_model_returns_none() {
let cfg = PricingConfig::default();
let cost = cfg.estimate_cost("some-private-model-v99", Some(&usage(100, 50)));
assert!(cost.is_none(), "unknown model should return None");
}
#[test]
fn no_usage_returns_none() {
let cfg = PricingConfig::default();
assert!(cfg.estimate_cost("gpt-4o", None).is_none());
}
#[test]
fn override_takes_precedence() {
let mut cfg = PricingConfig::default();
cfg.overrides.insert(
"gemma4:e2b".into(),
ModelRate {
input_usd_per_1k: 1.0,
output_usd_per_1k: 2.0,
},
);
let cost = cfg.estimate_cost("gemma4:e2b", Some(&usage(1000, 1000)));
assert!((cost.unwrap() - 3.0).abs() < 0.0001);
}
#[test]
fn prefix_override() {
let mut cfg = PricingConfig::default();
cfg.overrides.insert(
"my-model".into(),
ModelRate {
input_usd_per_1k: 0.5,
output_usd_per_1k: 1.0,
},
);
let cost = cfg.estimate_cost("my-model-v2", Some(&usage(2000, 1000)));
assert!((cost.unwrap() - 2.0).abs() < 0.0001);
}
#[test]
fn claude_models_have_rates() {
let cfg = PricingConfig::default();
for m in &[
"claude-3-5-sonnet-20241022",
"claude-3-haiku-20240307",
"claude-3-opus-20240229",
] {
assert!(
cfg.estimate_cost(m, Some(&usage(100, 50))).unwrap() > 0.0,
"{m} should have a positive rate"
);
}
}
#[test]
fn pricing_config_toml_roundtrip() {
let mut cfg = PricingConfig::default();
cfg.overrides.insert(
"test-model".into(),
ModelRate {
input_usd_per_1k: 0.01,
output_usd_per_1k: 0.02,
},
);
let s = toml::to_string(&cfg).unwrap();
let back: PricingConfig = toml::from_str(&s).unwrap();
assert!(back.overrides.contains_key("test-model"));
}
}