1use std::collections::HashSet;
11use std::path::PathBuf;
12#[cfg(test)]
13use std::sync::Mutex;
14use std::sync::{Arc, LazyLock, RwLock};
15
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19use super::response::{AnnotatedLlmResponse, CostEstimate, CostSource, Usage};
20
21const PRICING_CATALOG_VERSION: u32 = 1;
22
23static ACTIVE_PRICING_RESOLVER: LazyLock<RwLock<Arc<PricingResolver>>> =
24 LazyLock::new(|| RwLock::new(Arc::new(PricingResolver::default())));
25
26#[cfg(test)]
27pub(crate) fn pricing_test_mutex() -> &'static Mutex<()> {
28 static PRICING_TEST_MUTEX: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
29 &PRICING_TEST_MUTEX
30}
31
32#[derive(Debug, Error)]
34pub enum PricingCatalogError {
35 #[error("invalid model pricing catalog JSON: {0}")]
37 Json(#[from] serde_json::Error),
38 #[error("duplicate model pricing alias '{model}'")]
40 DuplicateModelAlias {
41 model: String,
43 },
44 #[error("unsupported model pricing catalog version {version}")]
46 UnsupportedVersion {
47 version: u32,
49 },
50 #[error("model pricing entry {entry_index} has empty {field}")]
52 EmptyField {
53 entry_index: usize,
55 field: String,
57 },
58 #[error("model pricing entry {entry_index} has invalid {field}: {value}")]
60 InvalidRate {
61 entry_index: usize,
63 field: String,
65 value: f64,
67 },
68 #[error("could not read model pricing catalog file '{}': {source}", path.display())]
70 FileRead {
71 path: PathBuf,
73 source: std::io::Error,
75 },
76 #[error("model pricing resolver lock poisoned: {0}")]
78 LockPoisoned(String),
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct PricingCatalog {
84 pub version: u32,
86 pub entries: Vec<ModelPricing>,
88}
89
90impl PricingCatalog {
91 pub fn from_json_str(catalog_json: &str) -> Result<Self, PricingCatalogError> {
93 let catalog: Self = serde_json::from_str(catalog_json)?;
94 catalog.validate()?;
95 Ok(catalog)
96 }
97
98 #[must_use]
100 pub fn pricing_for_model(&self, model: &str) -> Option<ModelPricing> {
101 self.pricing_for(None, model)
102 }
103
104 #[must_use]
106 pub fn pricing_for(&self, provider: Option<&str>, model: &str) -> Option<ModelPricing> {
107 let model_keys = normalized_model_lookup_keys(provider, model);
108 if model_keys.is_empty() {
109 return None;
110 }
111
112 model_keys.iter().find_map(|model_key| {
113 self.entries
114 .iter()
115 .find(|entry| entry.matches_model(model_key))
116 .cloned()
117 })
118 }
119
120 fn validate(&self) -> Result<(), PricingCatalogError> {
121 if self.version != PRICING_CATALOG_VERSION {
122 return Err(PricingCatalogError::UnsupportedVersion {
123 version: self.version,
124 });
125 }
126
127 let mut seen = HashSet::new();
128
129 for (entry_index, entry) in self.entries.iter().enumerate() {
130 entry.validate(entry_index)?;
131
132 for model_key in entry.provider_model_keys() {
133 if !seen.insert(model_key.clone()) {
134 return Err(PricingCatalogError::DuplicateModelAlias { model: model_key });
135 }
136 }
137 }
138
139 Ok(())
140 }
141}
142
143#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct PricingConfig {
147 #[serde(default)]
149 pub sources: Vec<PricingSourceConfig>,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(tag = "type", rename_all = "snake_case")]
155pub enum PricingSourceConfig {
156 Inline {
158 catalog: PricingCatalog,
160 },
161 File {
163 path: PathBuf,
165 },
166}
167
168pub trait PricingSource: Send + Sync {
175 fn source_name(&self) -> &str;
177
178 fn load_catalog(&self) -> Result<Option<PricingCatalog>, PricingCatalogError>;
180}
181
182#[derive(Debug, Clone, Default, PartialEq)]
184pub struct PricingResolver {
185 catalogs: Vec<PricingCatalog>,
186}
187
188impl PricingResolver {
189 #[must_use]
191 pub fn from_catalogs(catalogs: Vec<PricingCatalog>) -> Self {
192 Self { catalogs }
193 }
194
195 pub fn from_config(config: &PricingConfig) -> Result<Self, PricingCatalogError> {
197 let mut catalogs = Vec::new();
198 for source in &config.sources {
199 match source {
200 PricingSourceConfig::Inline { catalog } => {
201 catalog.validate()?;
202 catalogs.push(catalog.clone());
203 }
204 PricingSourceConfig::File { path } => {
205 let raw = std::fs::read_to_string(path).map_err(|source| {
206 PricingCatalogError::FileRead {
207 path: path.clone(),
208 source,
209 }
210 })?;
211 catalogs.push(PricingCatalog::from_json_str(&raw)?);
212 }
213 }
214 }
215 Ok(Self { catalogs })
216 }
217
218 pub fn from_sources(sources: Vec<Box<dyn PricingSource>>) -> Result<Self, PricingCatalogError> {
220 let mut catalogs = Vec::new();
221 for source in sources {
222 if let Some(catalog) = source.load_catalog()? {
223 catalog.validate()?;
224 catalogs.push(catalog);
225 }
226 }
227 Ok(Self { catalogs })
228 }
229
230 #[must_use]
232 pub fn pricing_for_model(&self, model: &str) -> Option<ModelPricing> {
233 self.pricing_for(None, model)
234 }
235
236 #[must_use]
238 pub fn pricing_for(&self, provider: Option<&str>, model: &str) -> Option<ModelPricing> {
239 self.catalogs
240 .iter()
241 .find_map(|catalog| catalog.pricing_for(provider, model))
242 }
243
244 #[must_use]
246 pub fn estimate_cost(&self, model: &str, usage: &Usage) -> Option<CostEstimate> {
247 self.estimate_cost_for_provider(None, model, usage)
248 }
249
250 #[must_use]
252 pub fn estimate_cost_for_provider(
253 &self,
254 provider: Option<&str>,
255 model: &str,
256 usage: &Usage,
257 ) -> Option<CostEstimate> {
258 self.pricing_for(provider, model)
259 .and_then(|pricing| pricing.estimate_cost(usage))
260 }
261}
262
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
265pub struct ModelPricing {
266 pub provider: String,
268 pub model_id: String,
270 #[serde(default)]
272 pub aliases: Vec<String>,
273 #[serde(default = "default_pricing_currency")]
275 pub currency: String,
276 #[serde(default)]
278 pub unit: PricingUnit,
279 #[serde(skip_serializing_if = "Option::is_none")]
281 pub rates: Option<TokenPricingRates>,
282 #[serde(skip_serializing_if = "Option::is_none")]
284 pub rate_schedule: Option<TokenRateSchedule>,
285 pub prompt_cache: PromptCachePricing,
287 pub pricing_as_of: String,
289 pub pricing_source: String,
291}
292
293impl ModelPricing {
294 #[must_use]
296 pub fn estimate_cost(&self, usage: &Usage) -> Option<CostEstimate> {
297 if self.unit != PricingUnit::PerToken {
298 return None;
299 }
300 let prompt_tokens = usage.prompt_tokens.unwrap_or(0);
301 let completion_tokens = usage.completion_tokens.unwrap_or(0);
302 let cache_read_tokens = usage.cache_read_tokens.unwrap_or(0);
303 let cache_write_tokens = usage.cache_write_tokens.unwrap_or(0);
304 let rates = self.rates_for_usage(usage)?;
305
306 if prompt_tokens == 0
307 && completion_tokens == 0
308 && cache_read_tokens == 0
309 && cache_write_tokens == 0
310 {
311 return None;
312 }
313
314 let billable_prompt_tokens =
315 if self.prompt_cache.read_accounting == CacheReadAccounting::IncludedInPromptTokens {
316 prompt_tokens.saturating_sub(cache_read_tokens)
317 } else {
318 prompt_tokens
319 };
320
321 let input_cost = cost_component_if_nonzero(billable_prompt_tokens, rates.input_per_million);
322 let output_cost = cost_component_if_nonzero(completion_tokens, rates.output_per_million);
323 let cache_read_cost = rates
324 .cache_read_per_million
325 .and_then(|price| cost_component_if_nonzero(cache_read_tokens, price));
326 let cache_write_cost = rates
327 .cache_write_per_million
328 .and_then(|price| cost_component_if_nonzero(cache_write_tokens, price));
329
330 let total: f64 = [input_cost, output_cost, cache_read_cost, cache_write_cost]
331 .into_iter()
332 .flatten()
333 .sum();
334
335 Some(CostEstimate {
336 total: Some(round_cost_amount(total)),
337 currency: self.currency.clone(),
338 input: input_cost,
339 output: output_cost,
340 cache_read: cache_read_cost,
341 cache_write: cache_write_cost,
342 source: CostSource::ModelPricing,
343 pricing_provider: Some(self.provider.clone()),
344 pricing_model: Some(self.model_id.clone()),
345 pricing_as_of: Some(self.pricing_as_of.clone()),
346 pricing_source: Some(self.pricing_source.clone()),
347 })
348 }
349
350 fn rates_for_usage(&self, usage: &Usage) -> Option<TokenPricingRates> {
351 if let Some(schedule) = &self.rate_schedule {
352 return schedule.rates_for_usage(usage);
353 }
354 self.rates
355 }
356
357 fn matches_model(&self, lookup: &ModelLookupKey) -> bool {
358 if let Some(provider) = lookup.provider.as_deref()
359 && normalized_provider_name(&self.provider) != provider
360 {
361 return false;
362 }
363
364 self.model_keys().any(|key| key == lookup.model)
365 }
366
367 fn model_keys(&self) -> impl Iterator<Item = String> + '_ {
368 std::iter::once(&self.model_id)
369 .chain(self.aliases.iter())
370 .map(|model| normalized_model_name(model))
371 .filter(|model| !model.is_empty())
372 }
373
374 fn provider_model_keys(&self) -> impl Iterator<Item = String> + '_ {
375 let provider = normalized_provider_name(&self.provider);
376 self.model_keys()
377 .map(move |model| format!("{provider}/{model}"))
378 }
379
380 fn validate(&self, entry_index: usize) -> Result<(), PricingCatalogError> {
381 validate_nonempty(entry_index, "provider", &self.provider)?;
382 validate_nonempty(entry_index, "model_id", &self.model_id)?;
383 validate_nonempty(entry_index, "currency", &self.currency)?;
384 validate_nonempty(entry_index, "pricing_as_of", &self.pricing_as_of)?;
385 validate_nonempty(entry_index, "pricing_source", &self.pricing_source)?;
386
387 if self.unit == PricingUnit::PerToken
388 && self.rates.is_none()
389 && self.rate_schedule.is_none()
390 {
391 return Err(PricingCatalogError::EmptyField {
392 entry_index,
393 field: "rates or rate_schedule".to_string(),
394 });
395 }
396 if let Some(rates) = &self.rates {
397 rates.validate(entry_index, "rates")?;
398 }
399 if let Some(schedule) = &self.rate_schedule {
400 schedule.validate(entry_index)?;
401 }
402
403 Ok(())
404 }
405}
406
407#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
409#[serde(rename_all = "snake_case")]
410pub enum PricingUnit {
411 #[default]
413 PerToken,
414 PerRequest,
416 PerSecond,
418 GpuHour,
420}
421
422#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
424pub struct TokenPricingRates {
425 pub input_per_million: f64,
427 pub output_per_million: f64,
429 #[serde(skip_serializing_if = "Option::is_none")]
431 pub cache_read_per_million: Option<f64>,
432 #[serde(skip_serializing_if = "Option::is_none")]
434 pub cache_write_per_million: Option<f64>,
435}
436
437impl TokenPricingRates {
438 fn validate(&self, entry_index: usize, field_prefix: &str) -> Result<(), PricingCatalogError> {
439 validate_rate(
440 entry_index,
441 format!("{field_prefix}.input_per_million"),
442 self.input_per_million,
443 )?;
444 validate_rate(
445 entry_index,
446 format!("{field_prefix}.output_per_million"),
447 self.output_per_million,
448 )?;
449 if let Some(value) = self.cache_read_per_million {
450 validate_rate(
451 entry_index,
452 format!("{field_prefix}.cache_read_per_million"),
453 value,
454 )?;
455 }
456 if let Some(value) = self.cache_write_per_million {
457 validate_rate(
458 entry_index,
459 format!("{field_prefix}.cache_write_per_million"),
460 value,
461 )?;
462 }
463 Ok(())
464 }
465}
466
467#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
469#[serde(tag = "type", rename_all = "snake_case")]
470pub enum TokenRateSchedule {
471 PromptTokenThreshold {
473 #[serde(default)]
475 applies_to: RateScheduleApplication,
476 tiers: Vec<TokenRateTier>,
478 },
479}
480
481impl TokenRateSchedule {
482 fn rates_for_usage(&self, usage: &Usage) -> Option<TokenPricingRates> {
483 match self {
484 Self::PromptTokenThreshold { applies_to, tiers } => {
485 if *applies_to != RateScheduleApplication::FullRequest {
486 return None;
487 }
488 let prompt_tokens = usage.prompt_tokens?;
489 tiers
490 .iter()
491 .find(|tier| tier.matches_prompt_tokens(prompt_tokens))
492 .map(|tier| tier.rates)
493 }
494 }
495 }
496
497 fn validate(&self, entry_index: usize) -> Result<(), PricingCatalogError> {
498 match self {
499 Self::PromptTokenThreshold { tiers, .. } if tiers.is_empty() => {
500 Err(PricingCatalogError::EmptyField {
501 entry_index,
502 field: "rate_schedule.tiers".to_string(),
503 })
504 }
505 Self::PromptTokenThreshold { tiers, .. } => {
506 for (tier_index, tier) in tiers.iter().enumerate() {
507 tier.validate(entry_index, tier_index)?;
508 }
509 Ok(())
510 }
511 }
512 }
513}
514
515#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
517#[serde(rename_all = "snake_case")]
518pub enum RateScheduleApplication {
519 #[default]
521 FullRequest,
522}
523
524#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
526pub struct TokenRateTier {
527 #[serde(skip_serializing_if = "Option::is_none")]
529 pub min_prompt_tokens: Option<u64>,
530 #[serde(skip_serializing_if = "Option::is_none")]
532 pub max_prompt_tokens: Option<u64>,
533 pub rates: TokenPricingRates,
535}
536
537impl TokenRateTier {
538 fn matches_prompt_tokens(&self, prompt_tokens: u64) -> bool {
539 self.min_prompt_tokens
540 .is_none_or(|min| prompt_tokens >= min)
541 && self
542 .max_prompt_tokens
543 .is_none_or(|max| prompt_tokens <= max)
544 }
545
546 fn validate(&self, entry_index: usize, tier_index: usize) -> Result<(), PricingCatalogError> {
547 if let (Some(min), Some(max)) = (self.min_prompt_tokens, self.max_prompt_tokens)
548 && min > max
549 {
550 return Err(PricingCatalogError::InvalidRate {
551 entry_index,
552 field: "rate_schedule.tiers.prompt_tokens".to_string(),
553 value: min as f64,
554 });
555 }
556 self.rates.validate(
557 entry_index,
558 &format!("rate_schedule.tiers[{tier_index}].rates"),
559 )
560 }
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
565pub struct PromptCachePricing {
566 pub read_accounting: CacheReadAccounting,
568}
569
570#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
572#[serde(rename_all = "snake_case")]
573pub enum CacheReadAccounting {
574 IncludedInPromptTokens,
576 Separate,
578}
579
580#[must_use]
585pub fn pricing_for_model(model: &str) -> Option<ModelPricing> {
586 active_pricing_resolver().pricing_for_model(model)
587}
588
589#[must_use]
591pub fn pricing_for_provider(provider: Option<&str>, model: &str) -> Option<ModelPricing> {
592 active_pricing_resolver().pricing_for(provider, model)
593}
594
595#[must_use]
597pub fn estimate_cost(model: &str, usage: &Usage) -> Option<CostEstimate> {
598 active_pricing_resolver().estimate_cost(model, usage)
599}
600
601#[must_use]
603pub fn estimate_cost_for_provider(
604 provider: Option<&str>,
605 model: &str,
606 usage: &Usage,
607) -> Option<CostEstimate> {
608 active_pricing_resolver().estimate_cost_for_provider(provider, model, usage)
609}
610
611#[must_use]
613pub fn estimate_cost_with_catalog(
614 catalog: &PricingCatalog,
615 model: &str,
616 usage: &Usage,
617) -> Option<CostEstimate> {
618 catalog
619 .pricing_for_model(model)
620 .and_then(|pricing| pricing.estimate_cost(usage))
621}
622
623#[must_use]
625pub fn estimate_cost_with_provider(
626 catalog: &PricingCatalog,
627 provider: Option<&str>,
628 model: &str,
629 usage: &Usage,
630) -> Option<CostEstimate> {
631 catalog
632 .pricing_for(provider, model)
633 .and_then(|pricing| pricing.estimate_cost(usage))
634}
635
636#[must_use]
638pub fn active_pricing_resolver() -> Arc<PricingResolver> {
639 ACTIVE_PRICING_RESOLVER
640 .read()
641 .map(|resolver| Arc::clone(&resolver))
642 .unwrap_or_else(|_| Arc::new(PricingResolver::default()))
643}
644
645pub fn set_active_pricing_resolver(resolver: PricingResolver) -> Result<(), PricingCatalogError> {
647 let mut guard = ACTIVE_PRICING_RESOLVER
648 .write()
649 .map_err(|err| PricingCatalogError::LockPoisoned(err.to_string()))?;
650 *guard = Arc::new(resolver);
651 Ok(())
652}
653
654pub fn reset_active_pricing_resolver() -> Result<(), PricingCatalogError> {
656 set_active_pricing_resolver(PricingResolver::default())
657}
658
659pub fn attach_estimated_cost(response: &mut AnnotatedLlmResponse) {
663 attach_estimated_cost_for_provider(response, None);
664}
665
666pub fn attach_estimated_cost_for_provider(
670 response: &mut AnnotatedLlmResponse,
671 provider: Option<&str>,
672) {
673 if response
674 .usage
675 .as_ref()
676 .and_then(|usage| usage.cost.as_ref())
677 .is_some()
678 {
679 return;
680 }
681
682 let Some(model) = response.model.clone() else {
683 return;
684 };
685 let Some(usage) = response.usage.as_mut() else {
686 return;
687 };
688
689 usage.cost = estimate_cost_for_provider(provider, &model, usage);
690}
691
692fn validate_nonempty(
693 entry_index: usize,
694 field: &'static str,
695 value: &str,
696) -> Result<(), PricingCatalogError> {
697 if value.trim().is_empty() {
698 return Err(PricingCatalogError::EmptyField {
699 entry_index,
700 field: field.to_string(),
701 });
702 }
703
704 Ok(())
705}
706
707fn validate_rate(
708 entry_index: usize,
709 field: impl Into<String>,
710 value: f64,
711) -> Result<(), PricingCatalogError> {
712 if !value.is_finite() || value < 0.0 {
713 return Err(PricingCatalogError::InvalidRate {
714 entry_index,
715 field: field.into(),
716 value,
717 });
718 }
719
720 Ok(())
721}
722
723fn default_pricing_currency() -> String {
724 "USD".into()
725}
726
727fn normalized_model_name(model: &str) -> String {
728 model.trim().to_ascii_lowercase()
729}
730
731fn normalized_provider_name(provider: &str) -> String {
732 provider.trim().trim_matches('/').to_ascii_lowercase()
733}
734
735#[derive(Debug, Clone, PartialEq, Eq, Hash)]
736struct ModelLookupKey {
737 provider: Option<String>,
738 model: String,
739}
740
741fn normalized_model_lookup_keys(provider: Option<&str>, model: &str) -> Vec<ModelLookupKey> {
742 let normalized = normalized_model_name(model);
743 if normalized.is_empty() {
744 return vec![];
745 }
746
747 let parts: Vec<&str> = normalized
748 .split('/')
749 .map(str::trim)
750 .filter(|part| !part.is_empty())
751 .collect();
752 let mut keys = Vec::with_capacity(parts.len() + 3);
753 let explicit_provider = provider
754 .map(normalized_provider_name)
755 .filter(|provider| !provider.is_empty());
756 let terminal_model = parts
757 .last()
758 .copied()
759 .unwrap_or(normalized.as_str())
760 .to_string();
761
762 if let Some(provider) = explicit_provider {
763 push_lookup_key(&mut keys, Some(provider.clone()), normalized.clone());
764 push_lookup_key(&mut keys, Some(provider), terminal_model.clone());
765 } else if parts.len() > 1 {
766 push_lookup_key(
767 &mut keys,
768 Some(parts[..parts.len() - 1].join("/")),
769 terminal_model,
770 );
771 }
772
773 for start in 0..parts.len() {
774 let key = parts[start..].join("/");
775 push_lookup_key(&mut keys, None, key);
776 }
777 keys
778}
779
780fn push_lookup_key(keys: &mut Vec<ModelLookupKey>, provider: Option<String>, model: String) {
781 let key = ModelLookupKey { provider, model };
782 if !key.model.is_empty() && !keys.contains(&key) {
783 keys.push(key);
784 }
785}
786
787#[must_use]
789pub fn infer_model_provider(default_provider: &str, model: Option<&str>) -> Option<String> {
790 let normalized_default = normalized_provider_name(default_provider);
791 if let Some(model) = model {
792 let normalized = normalized_model_name(model);
793 let parts: Vec<&str> = normalized
794 .split('/')
795 .map(str::trim)
796 .filter(|part| !part.is_empty())
797 .collect();
798 if parts.len() > 1 {
799 return Some(parts[..parts.len() - 1].join("/"));
800 }
801 }
802
803 (!normalized_default.is_empty()).then_some(normalized_default)
804}
805
806fn cost_component(tokens: u64, price_per_million: f64) -> f64 {
807 tokens as f64 * price_per_million / 1_000_000.0
808}
809
810fn cost_component_if_nonzero(tokens: u64, price_per_million: f64) -> Option<f64> {
811 (tokens > 0).then(|| round_cost_amount(cost_component(tokens, price_per_million)))
812}
813
814fn round_cost_amount(cost: f64) -> f64 {
815 const SCALE: f64 = 1_000_000_000_000.0;
816 (cost * SCALE).round() / SCALE
817}