mod model_alias;
mod tables;
use crate::types::Cost;
use model_alias::{family_alias, strip_trailing_date};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
const TOKENS_PER_RATE_UNIT: f64 = 1_000.0;
#[derive(Debug, Clone, Error, PartialEq)]
#[non_exhaustive]
pub enum RatesError {
#[error("rate must be finite: prompt={prompt_usd_per_1k}, completion={completion_usd_per_1k}")]
NotFinite {
prompt_usd_per_1k: f64,
completion_usd_per_1k: f64,
},
#[error(
"rate must be non-negative: prompt={prompt_usd_per_1k}, completion={completion_usd_per_1k}"
)]
Negative {
prompt_usd_per_1k: f64,
completion_usd_per_1k: f64,
},
#[error(
"cache rate must be finite and non-negative: \
read={cache_read_usd_per_1k}, creation={cache_creation_usd_per_1k}"
)]
CacheRateInvalid {
cache_read_usd_per_1k: f64,
cache_creation_usd_per_1k: f64,
},
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub(crate) struct RatesUnchecked {
pub(crate) prompt_usd_per_1k: f64,
pub(crate) completion_usd_per_1k: f64,
#[serde(default)]
pub(crate) cache_read_usd_per_1k: f64,
#[serde(default)]
pub(crate) cache_creation_usd_per_1k: f64,
}
impl TryFrom<RatesUnchecked> for Rates {
type Error = RatesError;
fn try_from(value: RatesUnchecked) -> Result<Self, Self::Error> {
let base = Rates::try_new(value.prompt_usd_per_1k, value.completion_usd_per_1k)?;
let (read, creation) = (value.cache_read_usd_per_1k, value.cache_creation_usd_per_1k);
if !read.is_finite() || !creation.is_finite() || read < 0.0 || creation < 0.0 {
return Err(RatesError::CacheRateInvalid {
cache_read_usd_per_1k: read,
cache_creation_usd_per_1k: creation,
});
}
Ok(base.with_cache_rates(read, creation))
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(try_from = "RatesUnchecked")]
pub struct Rates {
pub(crate) prompt_usd_per_1k: f64,
pub(crate) completion_usd_per_1k: f64,
pub(crate) cache_read_usd_per_1k: f64,
pub(crate) cache_creation_usd_per_1k: f64,
}
impl Rates {
pub const fn new(prompt_usd_per_1k: f64, completion_usd_per_1k: f64) -> Self {
debug_assert!(
prompt_usd_per_1k.is_finite() && completion_usd_per_1k.is_finite(),
"Rates::new: both rates must be finite"
);
debug_assert!(
prompt_usd_per_1k >= 0.0 && completion_usd_per_1k >= 0.0,
"Rates::new: both rates must be non-negative"
);
Self {
prompt_usd_per_1k,
completion_usd_per_1k,
cache_read_usd_per_1k: 0.0,
cache_creation_usd_per_1k: 0.0,
}
}
#[must_use]
pub const fn with_cache_rates(
mut self,
cache_read_usd_per_1k: f64,
cache_creation_usd_per_1k: f64,
) -> Self {
debug_assert!(
cache_read_usd_per_1k.is_finite() && cache_creation_usd_per_1k.is_finite(),
"Rates::with_cache_rates: both rates must be finite"
);
debug_assert!(
cache_read_usd_per_1k >= 0.0 && cache_creation_usd_per_1k >= 0.0,
"Rates::with_cache_rates: both rates must be non-negative"
);
self.cache_read_usd_per_1k = cache_read_usd_per_1k;
self.cache_creation_usd_per_1k = cache_creation_usd_per_1k;
self
}
pub fn try_new(prompt_usd_per_1k: f64, completion_usd_per_1k: f64) -> Result<Self, RatesError> {
if !prompt_usd_per_1k.is_finite() || !completion_usd_per_1k.is_finite() {
return Err(RatesError::NotFinite {
prompt_usd_per_1k,
completion_usd_per_1k,
});
}
if prompt_usd_per_1k < 0.0 || completion_usd_per_1k < 0.0 {
return Err(RatesError::Negative {
prompt_usd_per_1k,
completion_usd_per_1k,
});
}
Ok(Self {
prompt_usd_per_1k,
completion_usd_per_1k,
cache_read_usd_per_1k: 0.0,
cache_creation_usd_per_1k: 0.0,
})
}
pub fn cost_for(&self, prompt_tokens: u32, completion_tokens: u32) -> Cost {
self.cost_for_cached(prompt_tokens, completion_tokens, 0, 0)
}
pub fn cost_for_cached(
&self,
prompt_tokens: u32,
completion_tokens: u32,
cache_read_tokens: u32,
cache_creation_tokens: u32,
) -> Cost {
debug_assert!(
self.prompt_usd_per_1k.is_finite() && self.prompt_usd_per_1k >= 0.0,
"Rates::cost_for: prompt rate must be finite and non-negative"
);
debug_assert!(
self.completion_usd_per_1k.is_finite() && self.completion_usd_per_1k >= 0.0,
"Rates::cost_for: completion rate must be finite and non-negative"
);
let per_1k = |tokens: u32, rate: f64| (f64::from(tokens) / TOKENS_PER_RATE_UNIT) * rate;
let prompt_usd = per_1k(prompt_tokens, self.prompt_usd_per_1k);
let completion_usd = per_1k(completion_tokens, self.completion_usd_per_1k);
let cache_usd = per_1k(cache_read_tokens, self.cache_read_usd_per_1k)
+ per_1k(cache_creation_tokens, self.cache_creation_usd_per_1k);
Cost {
cache_usd,
prompt_usd,
completion_usd,
total_usd: prompt_usd + completion_usd + cache_usd,
}
}
pub fn prompt_usd_per_1k(&self) -> f64 {
self.prompt_usd_per_1k
}
pub fn completion_usd_per_1k(&self) -> f64 {
self.completion_usd_per_1k
}
pub fn cache_read_usd_per_1k(&self) -> f64 {
self.cache_read_usd_per_1k
}
pub fn cache_creation_usd_per_1k(&self) -> f64 {
self.cache_creation_usd_per_1k
}
}
#[derive(Debug, Clone, Default)]
pub struct PriceTable {
rates: HashMap<(String, String), Rates>,
}
impl PriceTable {
pub fn new() -> Self {
Self {
rates: HashMap::new(),
}
}
pub fn with_default_rates() -> Self {
tables::with_default_rates()
}
pub fn set(
&mut self,
provider: impl Into<String>,
model: impl Into<String>,
rates: Rates,
) -> Result<(), RatesError> {
let _ = Rates::try_new(rates.prompt_usd_per_1k, rates.completion_usd_per_1k)?;
self.rates.insert((provider.into(), model.into()), rates);
Ok(())
}
pub fn lookup(&self, provider: &str, model: &str) -> Option<Rates> {
let provider = normalize_provider(provider);
if let Some(r) = self.lookup_exact(provider, model) {
return Some(r);
}
if let Some(stripped) = strip_trailing_date(model) {
if let Some(r) = self.lookup_exact(provider, &stripped) {
return Some(r);
}
let family = format!("{stripped}-x");
if let Some(r) = self.lookup_exact(provider, &family) {
return Some(r);
}
if let Some(r) = self.lookup(provider, &stripped) {
return Some(r);
}
}
if let Some((head, _tag)) = model.split_once(':') {
if let Some(r) = self.lookup_exact(provider, head) {
return Some(r);
}
if let Some(family) = family_alias(head) {
if let Some(r) = self.lookup_exact(provider, &family) {
return Some(r);
}
}
}
if let Some(family) = family_alias(model) {
if let Some(r) = self.lookup_exact(provider, &family) {
return Some(r);
}
}
self.lookup_exact(provider, "*")
}
fn lookup_exact(&self, provider: &str, model: &str) -> Option<Rates> {
self.rates
.get(&(provider.to_string(), model.to_string()))
.copied()
}
}
fn normalize_provider(provider: &str) -> &str {
match provider {
"gemini" => "google",
other => other,
}
}
#[cfg(test)]
mod tests;