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,
}
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,
}
}
#[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)),
("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-haiku-4",
ModelPricing::new(0.80, 4.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-haiku-4",
ModelPricing::new(0.80, 4.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-haiku-4",
ModelPricing::new(0.80, 4.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> {
PRICING_TABLE
.iter()
.find(|(p, prefix, _)| *p == provider && model.starts_with(prefix))
.map(|(_, _, pricing)| *pricing)
}
#[must_use]
pub fn is_not_per_token_metered(provider: &str) -> bool {
NOT_PER_TOKEN_METERED_PROVIDERS.contains(&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 = pricing.input_per_million;
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 * pricing.output_per_million / divisor;
fresh_prompt_cost + cached_read_cost + cached_write_cost + output_cost
}