use std::collections::HashMap;
use bitrouter_sdk::language_model::Usage;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct ModelPricing {
pub input_micro_usd_per_token: Option<f64>,
pub output_micro_usd_per_token: Option<f64>,
}
impl ModelPricing {
pub fn new(input_micro_usd_per_token: f64, output_micro_usd_per_token: f64) -> Self {
Self {
input_micro_usd_per_token: Some(input_micro_usd_per_token),
output_micro_usd_per_token: Some(output_micro_usd_per_token),
}
}
pub fn partial(input: Option<f64>, output: Option<f64>) -> Self {
Self {
input_micro_usd_per_token: input,
output_micro_usd_per_token: output,
}
}
pub fn is_unconfigured(&self) -> bool {
self.input_micro_usd_per_token.is_none() && self.output_micro_usd_per_token.is_none()
}
pub fn is_complete(&self) -> bool {
self.input_micro_usd_per_token.is_some() && self.output_micro_usd_per_token.is_some()
}
}
#[derive(Debug, Clone, Default)]
pub struct PricingTable {
entries: HashMap<(String, String), ModelPricing>,
}
impl PricingTable {
pub fn new() -> Self {
Self::default()
}
pub fn insert(
&mut self,
provider: impl Into<String>,
service_id: impl Into<String>,
pricing: ModelPricing,
) {
self.entries
.insert((provider.into(), service_id.into()), pricing);
}
pub fn resolve(&self, provider: &str, service_id: &str) -> Option<ModelPricing> {
self.entries
.get(&BorrowedKey(provider, service_id) as &dyn KeyLike)
.copied()
}
}
struct BorrowedKey<'a>(&'a str, &'a str);
trait KeyLike {
fn parts(&self) -> (&str, &str);
}
impl KeyLike for (String, String) {
fn parts(&self) -> (&str, &str) {
(self.0.as_str(), self.1.as_str())
}
}
impl KeyLike for BorrowedKey<'_> {
fn parts(&self) -> (&str, &str) {
(self.0, self.1)
}
}
impl std::hash::Hash for dyn KeyLike + '_ {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
let (a, b) = self.parts();
a.hash(state);
b.hash(state);
}
}
impl PartialEq for dyn KeyLike + '_ {
fn eq(&self, other: &Self) -> bool {
self.parts() == other.parts()
}
}
impl Eq for dyn KeyLike + '_ {}
impl<'a> std::borrow::Borrow<dyn KeyLike + 'a> for (String, String) {
fn borrow(&self) -> &(dyn KeyLike + 'a) {
self
}
}
pub const MAX_TRUSTED_TOKENS: u64 = 10_000_000;
pub fn calculate_charge_micro_usd(usage: &Usage, pricing: &ModelPricing) -> Option<i64> {
let input = bucket_charge(usage.prompt_tokens, pricing.input_micro_usd_per_token)?;
let output = bucket_charge(usage.completion_tokens, pricing.output_micro_usd_per_token)?;
Some((input + output).round().max(0.0) as i64)
}
fn bucket_charge(tokens: u64, rate: Option<f64>) -> Option<f64> {
let tokens = tokens.min(MAX_TRUSTED_TOKENS);
match (tokens, rate) {
(0, _) => Some(0.0),
(n, Some(r)) => Some(n as f64 * r),
(_, None) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_pricing_reads_as_unconfigured() {
assert!(ModelPricing::default().is_unconfigured());
assert!(!ModelPricing::new(1.0, 2.0).is_unconfigured());
}
#[test]
fn charge_is_input_plus_output() {
let pricing = ModelPricing::new(2.0, 10.0);
let usage = Usage {
prompt_tokens: 100,
completion_tokens: 50,
reasoning_tokens: 0,
..Default::default()
};
assert_eq!(calculate_charge_micro_usd(&usage, &pricing), Some(700));
}
#[test]
fn partial_pricing_with_nonzero_output_returns_none() {
let pricing = ModelPricing::partial(Some(2.0), None);
let usage = Usage {
prompt_tokens: 100,
completion_tokens: 50, ..Default::default()
};
assert_eq!(
calculate_charge_micro_usd(&usage, &pricing),
None,
"missing rate × nonzero usage MUST refuse to bill"
);
}
#[test]
fn zero_usage_bucket_with_missing_rate_is_zero() {
let pricing = ModelPricing::partial(Some(2.0), None);
let usage = Usage {
prompt_tokens: 100,
completion_tokens: 0,
..Default::default()
};
assert_eq!(calculate_charge_micro_usd(&usage, &pricing), Some(200));
}
#[test]
fn adversarial_token_count_is_clamped() {
let pricing = ModelPricing::new(1.0, 1.0);
let usage = Usage {
prompt_tokens: u64::MAX,
completion_tokens: 0,
..Default::default()
};
let charge = calculate_charge_micro_usd(&usage, &pricing).unwrap();
assert_eq!(charge, MAX_TRUSTED_TOKENS as i64);
}
#[test]
fn is_complete_predicate() {
assert!(!ModelPricing::default().is_complete());
assert!(!ModelPricing::partial(Some(1.0), None).is_complete());
assert!(!ModelPricing::partial(None, Some(1.0)).is_complete());
assert!(ModelPricing::new(1.0, 1.0).is_complete());
}
#[test]
fn table_resolve_keys_by_provider_and_service_id() {
let mut table = PricingTable::new();
table.insert("openai", "gpt-5-2026-01", ModelPricing::new(1.0, 4.0));
assert!(table.resolve("openai", "gpt-5-2026-01").is_some());
assert!(table.resolve("openai", "gpt-5").is_none());
assert!(table.resolve("anthropic", "gpt-5-2026-01").is_none());
}
}