Skip to main content

nemo_relay/codec/
model_pricing.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Data-driven LLM model pricing used to layer cost estimates onto usage.
5//!
6//! Model pricing is deliberately separate from response normalization so adding
7//! providers, aliases, or cache-accounting rules does not require editing
8//! [`AnnotatedLlmResponse`](super::response::AnnotatedLlmResponse).
9
10use 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/// Errors produced while parsing or validating a model pricing catalog.
33#[derive(Debug, Error)]
34pub enum PricingCatalogError {
35    /// The catalog was not valid JSON for the catalog schema.
36    #[error("invalid model pricing catalog JSON: {0}")]
37    Json(#[from] serde_json::Error),
38    /// Two entries or aliases normalize to the same model key.
39    #[error("duplicate model pricing alias '{model}'")]
40    DuplicateModelAlias {
41        /// Normalized model key that appeared more than once.
42        model: String,
43    },
44    /// The catalog schema version is not supported by this Relay build.
45    #[error("unsupported model pricing catalog version {version}")]
46    UnsupportedVersion {
47        /// Version number from the catalog payload.
48        version: u32,
49    },
50    /// A required text field was empty.
51    #[error("model pricing entry {entry_index} has empty {field}")]
52    EmptyField {
53        /// Zero-based index of the invalid catalog entry.
54        entry_index: usize,
55        /// Name of the invalid field.
56        field: String,
57    },
58    /// A price was negative or non-finite.
59    #[error("model pricing entry {entry_index} has invalid {field}: {value}")]
60    InvalidRate {
61        /// Zero-based index of the invalid catalog entry.
62        entry_index: usize,
63        /// Name of the invalid rate field.
64        field: String,
65        /// Invalid field value.
66        value: f64,
67    },
68    /// A model pricing catalog file could not be read.
69    #[error("could not read model pricing catalog file '{}': {source}", path.display())]
70    FileRead {
71        /// Catalog path.
72        path: PathBuf,
73        /// Underlying I/O error.
74        source: std::io::Error,
75    },
76    /// The active model pricing resolver lock was poisoned.
77    #[error("model pricing resolver lock poisoned: {0}")]
78    LockPoisoned(String),
79}
80
81/// Collection of model pricing entries.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct PricingCatalog {
84    /// Catalog schema version.
85    pub version: u32,
86    /// Pricing entries keyed by canonical model ID plus aliases.
87    pub entries: Vec<ModelPricing>,
88}
89
90impl PricingCatalog {
91    /// Parses and validates a model pricing catalog from JSON.
92    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    /// Finds model pricing for a canonical model ID or alias.
99    #[must_use]
100    pub fn pricing_for_model(&self, model: &str) -> Option<ModelPricing> {
101        self.pricing_for(None, model)
102    }
103
104    /// Finds model pricing for a provider/model pair, with model-only fallback.
105    #[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/// Runtime model pricing resolver configuration.
144#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct PricingConfig {
147    /// Pricing sources in precedence order.
148    #[serde(default)]
149    pub sources: Vec<PricingSourceConfig>,
150}
151
152/// Declarative model pricing source supported by Relay configuration.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(tag = "type", rename_all = "snake_case")]
155pub enum PricingSourceConfig {
156    /// Inline catalog entries from project, user, system, or plugin config.
157    Inline {
158        /// Inline catalog payload.
159        catalog: PricingCatalog,
160    },
161    /// Catalog loaded from a JSON file.
162    File {
163        /// JSON model pricing catalog path.
164        path: PathBuf,
165    },
166}
167
168/// Pluggable model pricing source interface.
169///
170/// Database, service-backed, or enterprise-managed model pricing integrations should
171/// implement this trait and return a validated catalog snapshot. The LLM hot
172/// path uses [`PricingResolver`], so sources can refresh out-of-band without
173/// making each response decode perform network or database I/O.
174pub trait PricingSource: Send + Sync {
175    /// Stable source name for diagnostics.
176    fn source_name(&self) -> &str;
177
178    /// Loads a catalog snapshot from this source.
179    fn load_catalog(&self) -> Result<Option<PricingCatalog>, PricingCatalogError>;
180}
181
182/// Ordered model pricing lookup chain.
183#[derive(Debug, Clone, Default, PartialEq)]
184pub struct PricingResolver {
185    catalogs: Vec<PricingCatalog>,
186}
187
188impl PricingResolver {
189    /// Builds a resolver from already-loaded catalogs in precedence order.
190    #[must_use]
191    pub fn from_catalogs(catalogs: Vec<PricingCatalog>) -> Self {
192        Self { catalogs }
193    }
194
195    /// Builds a resolver from declarative config.
196    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    /// Builds a resolver from imperative source implementations.
219    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    /// Finds model pricing for a canonical model ID or alias.
231    #[must_use]
232    pub fn pricing_for_model(&self, model: &str) -> Option<ModelPricing> {
233        self.pricing_for(None, model)
234    }
235
236    /// Finds model pricing for a provider/model pair, with model-only fallback.
237    #[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    /// Estimates cost for a model/usage pair when model pricing is known.
245    #[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    /// Estimates cost for a provider/model pair when model pricing is known.
251    #[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/// Per-token model pricing expressed in USD per one million tokens.
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
265pub struct ModelPricing {
266    /// Provider that owns this model pricing entry.
267    pub provider: String,
268    /// Canonical model ID for this model pricing entry.
269    pub model_id: String,
270    /// Additional model IDs that should use this model pricing.
271    #[serde(default)]
272    pub aliases: Vec<String>,
273    /// ISO 4217 currency for this model pricing entry.
274    #[serde(default = "default_pricing_currency")]
275    pub currency: String,
276    /// Billing unit represented by this model pricing entry.
277    #[serde(default)]
278    pub unit: PricingUnit,
279    /// Token rates expressed as USD per one million tokens.
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub rates: Option<TokenPricingRates>,
282    /// Data-driven token rate schedule for threshold-based model pricing.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub rate_schedule: Option<TokenRateSchedule>,
285    /// Prompt-cache accounting model for this provider/model.
286    pub prompt_cache: PromptCachePricing,
287    /// Date this model pricing entry was last verified.
288    pub pricing_as_of: String,
289    /// Source URL for this model pricing entry.
290    pub pricing_source: String,
291}
292
293impl ModelPricing {
294    /// Estimates cost for the provided token usage.
295    #[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/// Billing unit represented by a model pricing entry.
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
409#[serde(rename_all = "snake_case")]
410pub enum PricingUnit {
411    /// Token-based model pricing.
412    #[default]
413    PerToken,
414    /// Request-based model pricing, reserved for future estimation.
415    PerRequest,
416    /// Time-based model pricing, reserved for future estimation.
417    PerSecond,
418    /// GPU-hour amortized model pricing for self-hosted models, reserved for future estimation.
419    GpuHour,
420}
421
422/// Token rates expressed as USD per one million tokens.
423#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
424pub struct TokenPricingRates {
425    /// Uncached prompt/input token price.
426    pub input_per_million: f64,
427    /// Completion/output token price.
428    pub output_per_million: f64,
429    /// Cached prompt/input token read price.
430    #[serde(skip_serializing_if = "Option::is_none")]
431    pub cache_read_per_million: Option<f64>,
432    /// Prompt cache write price.
433    #[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/// Data-driven token rate schedule for model pricing with request thresholds.
468#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
469#[serde(tag = "type", rename_all = "snake_case")]
470pub enum TokenRateSchedule {
471    /// Selects one full-request rate tier based on prompt/input tokens.
472    PromptTokenThreshold {
473        /// How selected tier rates apply to tokens.
474        #[serde(default)]
475        applies_to: RateScheduleApplication,
476        /// Ordered threshold tiers.
477        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/// How a selected rate-schedule tier applies to billable usage.
516#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
517#[serde(rename_all = "snake_case")]
518pub enum RateScheduleApplication {
519    /// Apply the selected tier rates to the entire request.
520    #[default]
521    FullRequest,
522}
523
524/// A model pricing tier selected by prompt/input token count.
525#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
526pub struct TokenRateTier {
527    /// Inclusive lower bound for prompt tokens.
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub min_prompt_tokens: Option<u64>,
530    /// Inclusive upper bound for prompt tokens.
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub max_prompt_tokens: Option<u64>,
533    /// Rates to apply when this tier is selected.
534    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/// Prompt-cache accounting rules for a model pricing entry.
564#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
565pub struct PromptCachePricing {
566    /// Whether cache-read tokens are included in `prompt_tokens`.
567    pub read_accounting: CacheReadAccounting,
568}
569
570/// How cache-read tokens relate to prompt token counts in provider usage.
571#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
572#[serde(rename_all = "snake_case")]
573pub enum CacheReadAccounting {
574    /// `cache_read_tokens` are already included in `prompt_tokens`.
575    IncludedInPromptTokens,
576    /// `cache_read_tokens` are separate from `prompt_tokens`.
577    Separate,
578}
579
580/// Returns known model pricing for a model ID.
581///
582/// Unknown models return `None` so response handling and observability export
583/// can continue without inventing a cost.
584#[must_use]
585pub fn pricing_for_model(model: &str) -> Option<ModelPricing> {
586    active_pricing_resolver().pricing_for_model(model)
587}
588
589/// Returns known model pricing for a provider/model pair.
590#[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/// Estimates USD cost for a model/usage pair when model pricing is known.
596#[must_use]
597pub fn estimate_cost(model: &str, usage: &Usage) -> Option<CostEstimate> {
598    active_pricing_resolver().estimate_cost(model, usage)
599}
600
601/// Estimates USD cost for a provider/model pair when model pricing is known.
602#[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/// Estimates USD cost using the provided catalog.
612#[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/// Estimates USD cost using the provided catalog and provider/model pair.
624#[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/// Returns the active process-wide model pricing resolver.
637#[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
645/// Replaces the active process-wide model pricing resolver.
646pub 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
654/// Restores the active process-wide model pricing resolver to an empty resolver.
655pub fn reset_active_pricing_resolver() -> Result<(), PricingCatalogError> {
656    set_active_pricing_resolver(PricingResolver::default())
657}
658
659/// Adds a model pricing estimate to a normalized response when cost is missing.
660///
661/// Existing provider-reported or caller-supplied costs are preserved.
662pub fn attach_estimated_cost(response: &mut AnnotatedLlmResponse) {
663    attach_estimated_cost_for_provider(response, None);
664}
665
666/// Adds a provider-aware model pricing estimate to a normalized response when cost is missing.
667///
668/// Existing provider-reported or caller-supplied costs are preserved.
669pub 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/// Infers a provider/route value for a decoded model.
788#[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}