use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
pub const DEFAULT_CACHE_READ_RATE: f64 = 0.25;
pub const DEFAULT_CACHE_WRITE_RATE: f64 = 1.0;
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct ModelPricing {
pub input_per_million: f64,
pub output_per_million: f64,
#[serde(default = "default_cache_read_rate")]
pub cache_read_multiplier: f64,
#[serde(default = "default_cache_write_rate")]
pub cache_write_multiplier: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub long_prompt: Option<LongPromptRates>,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct LongPromptRates {
pub above_prompt_tokens: i64,
pub input_per_million: f64,
pub output_per_million: f64,
}
fn default_cache_read_rate() -> f64 {
DEFAULT_CACHE_READ_RATE
}
fn default_cache_write_rate() -> f64 {
DEFAULT_CACHE_WRITE_RATE
}
impl ModelPricing {
#[must_use]
pub const fn new(input_per_million: f64, output_per_million: f64) -> Self {
Self {
input_per_million,
output_per_million,
cache_read_multiplier: DEFAULT_CACHE_READ_RATE,
cache_write_multiplier: DEFAULT_CACHE_WRITE_RATE,
long_prompt: None,
}
}
#[must_use]
pub const fn with_long_prompt_rates(
mut self,
above_prompt_tokens: i64,
input_per_million: f64,
output_per_million: f64,
) -> Self {
self.long_prompt = Some(LongPromptRates {
above_prompt_tokens,
input_per_million,
output_per_million,
});
self
}
#[must_use]
pub fn rates_for_prompt(&self, prompt_tokens: i64) -> (f64, f64) {
match self.long_prompt {
Some(long) if prompt_tokens > long.above_prompt_tokens => {
(long.input_per_million, long.output_per_million)
}
_ => (self.input_per_million, self.output_per_million),
}
}
#[must_use]
pub const fn with_cache_rates(mut self, read: f64, write: f64) -> Self {
self.cache_read_multiplier = read;
self.cache_write_multiplier = write;
self
}
}
pub const NOT_PER_TOKEN_METERED_PROVIDERS: &[&str] = &[
"local",
"ollama",
"vllm",
"localai",
"cursor-agent",
"opencode",
"codex",
"goose",
"cline",
"continue",
"warp_cli",
"kiro",
"kilo",
"copilot",
"claude_web",
];
const OPENROUTER_PROVIDER: &str = "openrouter";
pub const PRICING_TABLE: &[(&str, &str, ModelPricing)] = &[
(
"gemini",
"gemini-flash-lite-latest",
ModelPricing::new(0.10, 0.40),
),
(
"gemini",
"gemini-2.5-flash-lite",
ModelPricing::new(0.10, 0.40),
),
("gemini", "gemini-2.5-pro", ModelPricing::new(1.25, 10.0)),
("gemini", "gemini-2.5-flash", ModelPricing::new(0.15, 0.60)),
("gemini", "gemini-2.0-flash", ModelPricing::new(0.075, 0.30)),
(
"anthropic",
"claude-opus-5-5",
ModelPricing::new(4.0, 20.0).with_cache_rates(0.05, 1.25),
),
(
"anthropic",
"claude-sonnet-5-5",
ModelPricing::new(2.0, 10.0).with_cache_rates(0.10, 1.25),
),
(
"anthropic",
"claude-haiku-5-5",
ModelPricing::new(0.10, 0.50)
.with_cache_rates(0.10, 1.25)
.with_long_prompt_rates(100_000, 0.50, 2.50),
),
("groq", "llama-3.3-70b", ModelPricing::new(0.59, 0.79)),
("groq", "mixtral", ModelPricing::new(0.24, 0.24)),
("groq", "llama-3.1-8b", ModelPricing::new(0.05, 0.08)),
(
"copilot_headless",
"claude-opus-4",
ModelPricing::new(15.0, 75.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-sonnet",
ModelPricing::new(3.0, 15.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-opus-5.5",
ModelPricing::new(4.0, 20.0).with_cache_rates(0.05, 1.25),
),
(
"copilot_headless",
"claude-opus-5-5",
ModelPricing::new(4.0, 20.0).with_cache_rates(0.05, 1.25),
),
(
"copilot_headless",
"claude-opus-5",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-opus-4.8",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-opus-4-8",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-opus-4.7",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-opus-4-7",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-opus-4.6",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-opus-4-6",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-opus-4.7-fast",
ModelPricing::new(15.0, 75.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-opus-4-7-fast",
ModelPricing::new(15.0, 75.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-opus-4.6-fast",
ModelPricing::new(15.0, 75.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-opus-4-6-fast",
ModelPricing::new(15.0, 75.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-sonnet-5",
ModelPricing::new(2.0, 10.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-sonnet-5.5",
ModelPricing::new(2.0, 10.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-sonnet-5-5",
ModelPricing::new(2.0, 10.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-haiku-5.5",
ModelPricing::new(0.10, 0.50).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-haiku-5-5",
ModelPricing::new(0.10, 0.50).with_cache_rates(0.10, 1.25),
),
(
"copilot_headless",
"claude-haiku-4",
ModelPricing::new(1.0, 5.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-opus-4",
ModelPricing::new(15.0, 75.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-sonnet",
ModelPricing::new(3.0, 15.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-opus-5.5",
ModelPricing::new(4.0, 20.0).with_cache_rates(0.05, 1.25),
),
(
"copilot_sdk",
"claude-opus-5-5",
ModelPricing::new(4.0, 20.0).with_cache_rates(0.05, 1.25),
),
(
"copilot_sdk",
"claude-opus-5",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-opus-4.8",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-opus-4-8",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-opus-4.7",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-opus-4-7",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-opus-4.6",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-opus-4-6",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-opus-4.7-fast",
ModelPricing::new(15.0, 75.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-opus-4-7-fast",
ModelPricing::new(15.0, 75.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-opus-4.6-fast",
ModelPricing::new(15.0, 75.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-opus-4-6-fast",
ModelPricing::new(15.0, 75.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-sonnet-5",
ModelPricing::new(2.0, 10.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-sonnet-5.5",
ModelPricing::new(2.0, 10.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-sonnet-5-5",
ModelPricing::new(2.0, 10.0).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-haiku-5.5",
ModelPricing::new(0.10, 0.50).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-haiku-5-5",
ModelPricing::new(0.10, 0.50).with_cache_rates(0.10, 1.25),
),
(
"copilot_sdk",
"claude-haiku-4",
ModelPricing::new(1.0, 5.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-opus-4",
ModelPricing::new(15.0, 75.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-sonnet",
ModelPricing::new(3.0, 15.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-opus-5.5",
ModelPricing::new(4.0, 20.0).with_cache_rates(0.05, 1.25),
),
(
"claude-code",
"claude-opus-5-5",
ModelPricing::new(4.0, 20.0).with_cache_rates(0.05, 1.25),
),
(
"claude-code",
"claude-opus-5",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-opus-4.8",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-opus-4-8",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-opus-4.7",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-opus-4-7",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-opus-4.6",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-opus-4-6",
ModelPricing::new(5.0, 25.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-opus-4.7-fast",
ModelPricing::new(15.0, 75.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-opus-4-7-fast",
ModelPricing::new(15.0, 75.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-opus-4.6-fast",
ModelPricing::new(15.0, 75.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-opus-4-6-fast",
ModelPricing::new(15.0, 75.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-sonnet-5",
ModelPricing::new(2.0, 10.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-sonnet-5.5",
ModelPricing::new(2.0, 10.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-sonnet-5-5",
ModelPricing::new(2.0, 10.0).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-haiku-5.5",
ModelPricing::new(0.10, 0.50).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-haiku-5-5",
ModelPricing::new(0.10, 0.50).with_cache_rates(0.10, 1.25),
),
(
"claude-code",
"claude-haiku-4",
ModelPricing::new(1.0, 5.0).with_cache_rates(0.10, 1.25),
),
(
"cohere",
"command-a-reasoning",
ModelPricing::new(2.50, 10.0),
),
("cohere", "command-a-vision", ModelPricing::new(2.50, 10.0)),
("cohere", "command-a", ModelPricing::new(2.50, 10.0)),
("cohere", "command-r-plus", ModelPricing::new(2.50, 10.0)),
("cohere", "command-r7b", ModelPricing::new(0.0375, 0.15)),
("cohere", "command-r", ModelPricing::new(0.15, 0.60)),
(
"openai_api",
"gpt-4o",
ModelPricing::new(2.50, 10.0).with_cache_rates(0.50, 1.00),
),
(
"openai_api",
"gpt-4o-mini",
ModelPricing::new(0.15, 0.60).with_cache_rates(0.50, 1.00),
),
(
"openrouter",
"meta-llama/llama-3.3-70b-instruct",
ModelPricing::new(0.12, 0.30),
),
(
"openrouter",
"meta-llama/llama-3.1-8b-instruct",
ModelPricing::new(0.02, 0.03),
),
(
"openrouter",
"anthropic/claude-3.5-sonnet",
ModelPricing::new(3.0, 15.0).with_cache_rates(0.10, 1.25),
),
(
"openrouter",
"anthropic/claude-3.5-haiku",
ModelPricing::new(0.80, 4.0).with_cache_rates(0.10, 1.25),
),
(
"openrouter",
"openai/gpt-4o-mini",
ModelPricing::new(0.15, 0.60).with_cache_rates(0.50, 1.00),
),
(
"openrouter",
"openai/gpt-4o",
ModelPricing::new(2.50, 10.0).with_cache_rates(0.50, 1.00),
),
(
"openrouter",
"google/gemini-2.0-flash-001",
ModelPricing::new(0.10, 0.40),
),
(
"openrouter",
"google/gemini-pro-1.5",
ModelPricing::new(1.25, 5.0),
),
(
"openrouter",
"mistralai/mistral-large",
ModelPricing::new(2.0, 6.0),
),
(
"openrouter",
"mistralai/mistral-nemo",
ModelPricing::new(0.03, 0.07),
),
(
"openrouter",
"qwen/qwen-2.5-72b-instruct",
ModelPricing::new(0.13, 0.40),
),
];
#[must_use]
pub fn lookup_pricing(provider: &str, model: &str) -> Option<ModelPricing> {
let provider = pool_family(provider);
PRICING_TABLE
.iter()
.filter(|(p, prefix, _)| *p == provider && model.starts_with(prefix))
.max_by_key(|(_, prefix, _)| prefix.len())
.map(|(_, _, pricing)| *pricing)
}
#[must_use]
pub fn pool_family(provider: &str) -> &str {
provider
.split_once('#')
.map_or(provider, |(family, _)| family)
}
#[must_use]
pub fn is_not_per_token_metered(provider: &str) -> bool {
NOT_PER_TOKEN_METERED_PROVIDERS.contains(&pool_family(provider))
}
#[must_use]
pub fn zero_cost_for_unpriced(provider: &str, model: &str, scope: Option<&str>) -> f64 {
if is_not_per_token_metered(provider) {
debug!(
provider = provider,
model = model,
"Provider is subscription/self-hosted; per-token cost is $0 by design (not an undercount)"
);
} else if provider == OPENROUTER_PROVIDER {
info!(
provider = provider,
model = model,
"OpenRouter model price is model-specific and unset for this slug; recording $0 (add a PRICING_TABLE or admin override to meter it)"
);
} else {
warn!(
provider = provider,
model = model,
scope = scope,
"No pricing data for model, cost will be recorded as 0.0"
);
}
0.0
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TokenCounts {
pub prompt: i64,
pub cached_read: i64,
pub cached_write: i64,
pub completion: i64,
pub reasoning: i64,
}
impl TokenCounts {
#[must_use]
pub const fn new(prompt: i64, completion: i64) -> Self {
Self {
prompt,
cached_read: 0,
cached_write: 0,
completion,
reasoning: 0,
}
}
#[must_use]
pub const fn with_cache(mut self, read: i64, write: i64) -> Self {
self.cached_read = read;
self.cached_write = write;
self
}
#[must_use]
pub const fn with_reasoning(mut self, reasoning: i64) -> Self {
self.reasoning = reasoning;
self
}
}
#[must_use]
pub fn calculate_cost(
provider: &str,
model: &str,
prompt_tokens: i64,
completion_tokens: i64,
) -> f64 {
calculate_cost_for(
provider,
model,
&TokenCounts::new(prompt_tokens, completion_tokens),
)
}
#[must_use]
pub fn calculate_cost_for(provider: &str, model: &str, counts: &TokenCounts) -> f64 {
let Some(pricing) = lookup_pricing(provider, model) else {
return zero_cost_for_unpriced(provider, model, None);
};
cost_from_pricing(&pricing, counts)
}
#[must_use]
pub fn calculate_cost_with_cache(
provider: &str,
model: &str,
prompt_tokens: i64,
cached_tokens: i64,
completion_tokens: i64,
) -> f64 {
calculate_cost_for(
provider,
model,
&TokenCounts::new(prompt_tokens, completion_tokens).with_cache(cached_tokens, 0),
)
}
#[must_use]
pub fn cost_from_pricing(pricing: &ModelPricing, counts: &TokenCounts) -> f64 {
let divisor = 1_000_000.0;
let prompt = counts.prompt.max(0);
let cached_read = counts.cached_read.clamp(0, prompt);
let cached_write = counts.cached_write.clamp(0, prompt - cached_read);
let fresh_prompt = prompt - cached_read - cached_write;
let (input, output) = pricing.rates_for_prompt(prompt);
let fresh_prompt_cost = fresh_prompt as f64 * input / divisor;
let cached_read_cost = cached_read as f64 * input * pricing.cache_read_multiplier / divisor;
let cached_write_cost = cached_write as f64 * input * pricing.cache_write_multiplier / divisor;
let output_tokens = counts.completion.max(0) + counts.reasoning.max(0);
let output_cost = output_tokens as f64 * output / divisor;
fresh_prompt_cost + cached_read_cost + cached_write_cost + output_cost
}