Skip to main content

codex_helper_core/
pricing.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::PathBuf;
3use std::sync::OnceLock;
4
5use serde::{Deserialize, Serialize};
6
7use crate::file_replace::write_text_file;
8use crate::usage::{CacheInputAccounting, UsageMetrics};
9
10const BASELLM_ALL_JSON_URL: &str = "https://basellm.github.io/llm-metadata/api/all.json";
11const FEMTO_USD_PER_USD: i128 = 1_000_000_000_000_000;
12const TOKENS_PER_MILLION: i128 = 1_000_000;
13const MULTIPLIER_SCALE: i128 = 1_000_000;
14const MODEL_PRICE_OVERRIDES_DOC_HEADER: &str = r#"# codex-helper pricing_overrides.toml
15#
16# Managed by `codex-helper pricing`.
17# Use this file for provider-specific model aliases, custom relay prices, or local corrections.
18"#;
19
20fn u64_is_zero(value: &u64) -> bool {
21    *value == 0
22}
23
24#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
25#[serde(rename_all = "snake_case")]
26pub enum CostConfidence {
27    #[default]
28    Unknown,
29    Partial,
30    Estimated,
31    Exact,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
35pub struct UsdAmount {
36    femto_usd: i128,
37}
38
39impl UsdAmount {
40    pub const ZERO: Self = Self { femto_usd: 0 };
41
42    pub fn from_femto_usd(femto_usd: i128) -> Self {
43        Self {
44            femto_usd: femto_usd.max(0),
45        }
46    }
47
48    pub fn from_decimal_str(value: &str) -> Option<Self> {
49        parse_decimal_usd_to_femto(value).map(Self::from_femto_usd)
50    }
51
52    pub fn femto_usd(self) -> i128 {
53        self.femto_usd
54    }
55
56    pub fn is_zero(self) -> bool {
57        self.femto_usd == 0
58    }
59
60    pub fn checked_div_u64(self, divisor: u64) -> Option<Self> {
61        (divisor > 0).then(|| Self::from_femto_usd(self.femto_usd / divisor as i128))
62    }
63
64    pub fn saturating_add(self, other: Self) -> Self {
65        Self::from_femto_usd(self.femto_usd.saturating_add(other.femto_usd))
66    }
67
68    pub fn saturating_sub(self, other: Self) -> Self {
69        Self::from_femto_usd(self.femto_usd.saturating_sub(other.femto_usd))
70    }
71
72    pub fn cost_for_tokens_per_million(tokens: i64, price_per_million: Self) -> Self {
73        let tokens = tokens.max(0) as i128;
74        Self::from_femto_usd(
75            tokens
76                .saturating_mul(price_per_million.femto_usd)
77                .saturating_div(TOKENS_PER_MILLION),
78        )
79    }
80
81    pub fn format_usd(self) -> String {
82        format_femto_usd(self.femto_usd)
83    }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct PriceMultiplier {
88    scaled: i128,
89}
90
91impl Default for PriceMultiplier {
92    fn default() -> Self {
93        Self::one()
94    }
95}
96
97impl PriceMultiplier {
98    pub const fn one() -> Self {
99        Self {
100            scaled: MULTIPLIER_SCALE,
101        }
102    }
103
104    pub fn from_decimal_str(value: &str) -> Option<Self> {
105        let amount = parse_decimal_usd_to_femto(value)?;
106        let scaled = amount
107            .saturating_mul(MULTIPLIER_SCALE)
108            .saturating_div(FEMTO_USD_PER_USD);
109        (scaled > 0).then_some(Self { scaled })
110    }
111
112    pub fn apply(self, amount: UsdAmount) -> UsdAmount {
113        let numerator = amount.femto_usd.saturating_mul(self.scaled);
114        let q = numerator / MULTIPLIER_SCALE;
115        let r = (numerator % MULTIPLIER_SCALE).abs();
116        let rounded = if r.saturating_mul(2) >= MULTIPLIER_SCALE {
117            q.saturating_add(1)
118        } else {
119            q
120        };
121        UsdAmount::from_femto_usd(rounded)
122    }
123
124    pub fn format(self) -> String {
125        let whole = self.scaled / MULTIPLIER_SCALE;
126        let frac = self.scaled % MULTIPLIER_SCALE;
127        if frac == 0 {
128            return whole.to_string();
129        }
130        let mut frac_s = format!("{frac:06}");
131        while frac_s.ends_with('0') {
132            frac_s.pop();
133        }
134        format!("{whole}.{frac_s}")
135    }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
139pub struct CostAdjustments {
140    pub service_tier_multiplier: Option<PriceMultiplier>,
141    pub provider_multiplier: Option<PriceMultiplier>,
142}
143
144impl CostAdjustments {
145    fn apply(self, amount: UsdAmount) -> UsdAmount {
146        let mut out = amount;
147        if let Some(multiplier) = self.service_tier_multiplier {
148            out = multiplier.apply(out);
149        }
150        if let Some(multiplier) = self.provider_multiplier {
151            out = multiplier.apply(out);
152        }
153        out
154    }
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158pub struct CostBreakdown {
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub input_cost_usd: Option<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub output_cost_usd: Option<String>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub cache_read_cost_usd: Option<String>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub cache_creation_cost_usd: Option<String>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub service_tier_multiplier: Option<String>,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub provider_cost_multiplier: Option<String>,
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub total_cost_usd: Option<String>,
173    #[serde(default)]
174    pub confidence: CostConfidence,
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub pricing_source: Option<String>,
177    #[serde(skip)]
178    total_cost_femto_usd: Option<i128>,
179}
180
181impl Default for CostBreakdown {
182    fn default() -> Self {
183        Self::unknown()
184    }
185}
186
187impl CostBreakdown {
188    pub fn unknown() -> Self {
189        Self {
190            input_cost_usd: None,
191            output_cost_usd: None,
192            cache_read_cost_usd: None,
193            cache_creation_cost_usd: None,
194            service_tier_multiplier: None,
195            provider_cost_multiplier: None,
196            total_cost_usd: None,
197            confidence: CostConfidence::Unknown,
198            pricing_source: None,
199            total_cost_femto_usd: None,
200        }
201    }
202
203    pub fn is_unknown(&self) -> bool {
204        self.confidence == CostConfidence::Unknown && self.total_cost_usd.is_none()
205    }
206
207    pub fn total_cost_femto_usd(&self) -> Option<i128> {
208        self.total_cost_femto_usd
209    }
210
211    pub fn display_total(&self) -> String {
212        format_cost_display(self.total_cost_usd.as_deref())
213    }
214
215    pub fn display_total_with_confidence(&self) -> String {
216        format_cost_with_confidence(self.total_cost_usd.as_deref(), self.confidence)
217    }
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
221pub struct CostSummary {
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub total_cost_usd: Option<String>,
224    #[serde(default)]
225    pub confidence: CostConfidence,
226    #[serde(default, skip_serializing_if = "u64_is_zero")]
227    pub priced_requests: u64,
228    #[serde(default, skip_serializing_if = "u64_is_zero")]
229    pub unpriced_requests: u64,
230    #[serde(skip)]
231    total_cost_femto_usd: i128,
232}
233
234impl Default for CostSummary {
235    fn default() -> Self {
236        Self {
237            total_cost_usd: None,
238            confidence: CostConfidence::Unknown,
239            priced_requests: 0,
240            unpriced_requests: 0,
241            total_cost_femto_usd: 0,
242        }
243    }
244}
245
246impl CostSummary {
247    pub fn is_empty(&self) -> bool {
248        self.priced_requests == 0 && self.unpriced_requests == 0 && self.total_cost_usd.is_none()
249    }
250
251    pub fn add_assign(&mut self, other: &Self) {
252        self.priced_requests = self.priced_requests.saturating_add(other.priced_requests);
253        self.unpriced_requests = self
254            .unpriced_requests
255            .saturating_add(other.unpriced_requests);
256        self.total_cost_femto_usd = self
257            .total_cost_femto_usd
258            .saturating_add(other.total_cost_femto_usd);
259        self.refresh_display();
260    }
261
262    pub fn record_usage_cost(&mut self, cost: &CostBreakdown) {
263        if matches!(cost.confidence, CostConfidence::Unknown) {
264            self.unpriced_requests = self.unpriced_requests.saturating_add(1);
265            self.refresh_display();
266            return;
267        }
268
269        let total = cost.total_cost_femto_usd().or_else(|| {
270            cost.total_cost_usd
271                .as_deref()
272                .and_then(parse_decimal_usd_to_femto)
273        });
274
275        let Some(total) = total else {
276            self.unpriced_requests = self.unpriced_requests.saturating_add(1);
277            self.refresh_display();
278            return;
279        };
280
281        self.priced_requests = self.priced_requests.saturating_add(1);
282        self.total_cost_femto_usd = self.total_cost_femto_usd.saturating_add(total.max(0));
283        self.refresh_display();
284    }
285
286    pub fn display_total(&self) -> String {
287        format_cost_display(self.total_cost_usd.as_deref())
288    }
289
290    pub fn display_total_with_confidence(&self) -> String {
291        format_cost_with_confidence(self.total_cost_usd.as_deref(), self.confidence)
292    }
293
294    fn refresh_display(&mut self) {
295        if self.priced_requests == 0 {
296            self.total_cost_usd = None;
297            self.confidence = CostConfidence::Unknown;
298            return;
299        }
300
301        self.total_cost_usd = Some(format_femto_usd(self.total_cost_femto_usd));
302        self.confidence = if self.unpriced_requests > 0 {
303            CostConfidence::Partial
304        } else {
305            CostConfidence::Estimated
306        };
307    }
308}
309
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct BillableTokenUsage {
312    pub input_tokens: i64,
313    pub output_tokens: i64,
314    pub cache_read_input_tokens: i64,
315    pub cache_creation_input_tokens: i64,
316}
317
318impl BillableTokenUsage {
319    pub fn from_usage(usage: &UsageMetrics) -> Self {
320        Self::from_usage_with_accounting(usage, CacheInputAccounting::default())
321    }
322
323    pub fn from_usage_with_accounting(
324        usage: &UsageMetrics,
325        accounting: CacheInputAccounting,
326    ) -> Self {
327        let breakdown = usage.cache_usage_breakdown(accounting);
328
329        Self {
330            input_tokens: breakdown.effective_input_tokens,
331            output_tokens: usage.output_tokens.max(0),
332            cache_read_input_tokens: breakdown.cache_read_input_tokens,
333            cache_creation_input_tokens: breakdown.cache_creation_input_tokens,
334        }
335    }
336}
337
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub struct ModelPrice {
340    pub model_id: String,
341    pub display_name: Option<String>,
342    pub aliases: Vec<String>,
343    pub input_per_1m: UsdAmount,
344    pub output_per_1m: UsdAmount,
345    pub cache_read_input_per_1m: Option<UsdAmount>,
346    pub cache_creation_input_per_1m: Option<UsdAmount>,
347    pub source: String,
348    pub confidence: CostConfidence,
349}
350
351impl ModelPrice {
352    pub fn from_per_million_usd(
353        model_id: impl Into<String>,
354        display_name: Option<String>,
355        input: &str,
356        output: &str,
357        cache_read: Option<&str>,
358        cache_creation: Option<&str>,
359        source: impl Into<String>,
360    ) -> Option<Self> {
361        Some(Self {
362            model_id: model_id.into(),
363            display_name,
364            aliases: Vec::new(),
365            input_per_1m: UsdAmount::from_decimal_str(input)?,
366            output_per_1m: UsdAmount::from_decimal_str(output)?,
367            cache_read_input_per_1m: cache_read.and_then(UsdAmount::from_decimal_str),
368            cache_creation_input_per_1m: cache_creation.and_then(UsdAmount::from_decimal_str),
369            source: source.into(),
370            confidence: CostConfidence::Estimated,
371        })
372    }
373
374    pub fn with_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
375        self.aliases = aliases.into_iter().map(Into::into).collect();
376        self
377    }
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
381pub struct LocalModelPriceOverridesDocument {
382    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
383    pub models: BTreeMap<String, LocalModelPriceOverride>,
384}
385
386impl LocalModelPriceOverridesDocument {
387    pub fn is_empty(&self) -> bool {
388        self.models.is_empty()
389    }
390
391    pub fn normalized(&self) -> Result<Self, String> {
392        let mut models = BTreeMap::new();
393        for (raw_model_id, row) in &self.models {
394            let model_id = raw_model_id.trim();
395            if model_id.is_empty() {
396                return Err("pricing override model id cannot be empty".to_string());
397            }
398            let model_id = model_id.to_string();
399            let sanitized = row.clone().sanitized(&model_id)?;
400            if models.insert(model_id.clone(), sanitized).is_some() {
401                return Err(format!(
402                    "pricing override model id '{model_id}' appears more than once after normalization"
403                ));
404            }
405        }
406        Ok(Self { models })
407    }
408
409    fn into_prices(self, source: &str) -> Result<Vec<ModelPrice>, String> {
410        validate_model_price_overrides_document(&self)?;
411
412        let mut prices = Vec::new();
413        for (model_id, override_row) in self.models {
414            let model_id = model_id.trim().to_string();
415            let price = override_row
416                .into_model_price(model_id.clone(), source)
417                .map_err(|err| format!("invalid pricing override for model '{model_id}': {err}"))?;
418            prices.push(price);
419        }
420        Ok(prices)
421    }
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
425pub struct LocalModelPriceOverride {
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub display_name: Option<String>,
428    #[serde(default, skip_serializing_if = "Vec::is_empty")]
429    pub aliases: Vec<String>,
430    pub input_per_1m_usd: String,
431    pub output_per_1m_usd: String,
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub cache_read_input_per_1m_usd: Option<String>,
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub cache_creation_input_per_1m_usd: Option<String>,
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub confidence: Option<CostConfidence>,
438}
439
440impl LocalModelPriceOverride {
441    pub fn sanitized(mut self, model_id: &str) -> Result<Self, String> {
442        self.validate_prices()?;
443
444        self.display_name = self
445            .display_name
446            .map(|value| value.trim().to_string())
447            .filter(|value| !value.is_empty());
448
449        let model_key = normalize_model_key(model_id);
450        let mut seen_aliases = BTreeSet::new();
451        let mut aliases = Vec::new();
452        for alias in self.aliases {
453            let alias = alias.trim().to_string();
454            if alias.is_empty() {
455                return Err(format!("model '{model_id}' contains an empty alias"));
456            }
457            let alias_key = normalize_model_key(&alias);
458            if alias_key == model_key {
459                continue;
460            }
461            if seen_aliases.insert(alias_key) {
462                aliases.push(alias);
463            }
464        }
465        self.aliases = aliases;
466
467        Ok(self)
468    }
469
470    fn validate_prices(&self) -> Result<(), String> {
471        validate_usd_decimal("input_per_1m_usd", &self.input_per_1m_usd)?;
472        validate_usd_decimal("output_per_1m_usd", &self.output_per_1m_usd)?;
473        if let Some(value) = self.cache_read_input_per_1m_usd.as_deref() {
474            validate_usd_decimal("cache_read_input_per_1m_usd", value)?;
475        }
476        if let Some(value) = self.cache_creation_input_per_1m_usd.as_deref() {
477            validate_usd_decimal("cache_creation_input_per_1m_usd", value)?;
478        }
479        Ok(())
480    }
481
482    fn into_model_price(self, model_id: String, source: &str) -> Result<ModelPrice, String> {
483        let row = self.sanitized(&model_id)?;
484        let mut price = ModelPrice::from_per_million_usd(
485            model_id,
486            row.display_name,
487            &row.input_per_1m_usd,
488            &row.output_per_1m_usd,
489            row.cache_read_input_per_1m_usd.as_deref(),
490            row.cache_creation_input_per_1m_usd.as_deref(),
491            source.to_string(),
492        )
493        .ok_or_else(|| "invalid USD decimal price".to_string())?
494        .with_aliases(row.aliases);
495        if let Some(confidence) = row.confidence {
496            price.confidence = confidence;
497        }
498        Ok(price)
499    }
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
503pub struct ModelPriceView {
504    pub model_id: String,
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub display_name: Option<String>,
507    #[serde(default, skip_serializing_if = "Vec::is_empty")]
508    pub aliases: Vec<String>,
509    pub input_per_1m_usd: String,
510    pub output_per_1m_usd: String,
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub cache_read_input_per_1m_usd: Option<String>,
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub cache_creation_input_per_1m_usd: Option<String>,
515    pub source: String,
516    pub confidence: CostConfidence,
517}
518
519impl ModelPriceView {
520    pub fn matches_model(&self, model: &str) -> bool {
521        let lookup_keys = model_lookup_keys(model);
522        std::iter::once(self.model_id.as_str())
523            .chain(self.aliases.iter().map(String::as_str))
524            .map(normalize_model_key)
525            .any(|price_key| {
526                lookup_keys
527                    .iter()
528                    .any(|lookup_key| lookup_key == &price_key)
529            })
530    }
531}
532
533impl From<&ModelPrice> for ModelPriceView {
534    fn from(price: &ModelPrice) -> Self {
535        Self {
536            model_id: price.model_id.clone(),
537            display_name: price.display_name.clone(),
538            aliases: price.aliases.clone(),
539            input_per_1m_usd: price.input_per_1m.format_usd(),
540            output_per_1m_usd: price.output_per_1m.format_usd(),
541            cache_read_input_per_1m_usd: price.cache_read_input_per_1m.map(UsdAmount::format_usd),
542            cache_creation_input_per_1m_usd: price
543                .cache_creation_input_per_1m
544                .map(UsdAmount::format_usd),
545            source: price.source.clone(),
546            confidence: price.confidence,
547        }
548    }
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
552pub struct ModelPriceCatalogSnapshot {
553    pub source: String,
554    pub model_count: usize,
555    #[serde(default)]
556    pub models: Vec<ModelPriceView>,
557}
558
559impl ModelPriceCatalogSnapshot {
560    pub fn prioritized_models<I, S>(&self, observed_models: I, limit: usize) -> Vec<&ModelPriceView>
561    where
562        I: IntoIterator<Item = S>,
563        S: AsRef<str>,
564    {
565        let mut used = BTreeSet::new();
566        let mut rows = Vec::new();
567
568        for model in observed_models {
569            let model = model.as_ref().trim();
570            if model.is_empty() {
571                continue;
572            }
573            if let Some((idx, row)) = self
574                .models
575                .iter()
576                .enumerate()
577                .find(|(idx, row)| !used.contains(idx) && row.matches_model(model))
578            {
579                used.insert(idx);
580                rows.push(row);
581                if rows.len() >= limit {
582                    return rows;
583                }
584            }
585        }
586
587        for (idx, row) in self.models.iter().enumerate() {
588            if used.insert(idx) {
589                rows.push(row);
590                if rows.len() >= limit {
591                    break;
592                }
593            }
594        }
595
596        rows
597    }
598}
599
600#[derive(Debug, Clone, Default)]
601pub struct ModelPriceCatalog {
602    entries: BTreeMap<String, ModelPrice>,
603    aliases: BTreeMap<String, String>,
604}
605
606impl ModelPriceCatalog {
607    pub fn new() -> Self {
608        Self::default()
609    }
610
611    pub fn with_prices(prices: impl IntoIterator<Item = ModelPrice>) -> Self {
612        let mut catalog = Self::new();
613        for price in prices {
614            catalog.insert(price);
615        }
616        catalog
617    }
618
619    pub fn insert(&mut self, price: ModelPrice) {
620        let key = normalize_model_key(&price.model_id);
621        for alias in &price.aliases {
622            self.aliases.insert(normalize_model_key(alias), key.clone());
623        }
624        self.entries.insert(key, price);
625    }
626
627    pub fn price_for_model(&self, model: &str) -> Option<&ModelPrice> {
628        for key in model_lookup_keys(model) {
629            if let Some(price) = self.entries.get(&key) {
630                return Some(price);
631            }
632            if let Some(target) = self.aliases.get(&key)
633                && let Some(price) = self.entries.get(target)
634            {
635                return Some(price);
636            }
637        }
638        None
639    }
640
641    pub fn estimate_usage_cost(
642        &self,
643        model: &str,
644        usage: &UsageMetrics,
645        adjustments: CostAdjustments,
646    ) -> CostBreakdown {
647        self.estimate_usage_cost_with_accounting(
648            model,
649            usage,
650            adjustments,
651            CacheInputAccounting::default(),
652        )
653    }
654
655    pub fn estimate_usage_cost_with_accounting(
656        &self,
657        model: &str,
658        usage: &UsageMetrics,
659        adjustments: CostAdjustments,
660        accounting: CacheInputAccounting,
661    ) -> CostBreakdown {
662        let Some(price) = self.price_for_model(model) else {
663            return CostBreakdown::unknown();
664        };
665        estimate_usage_cost_with_accounting(usage, price, adjustments, accounting)
666    }
667
668    pub fn len(&self) -> usize {
669        self.entries.len()
670    }
671
672    pub fn is_empty(&self) -> bool {
673        self.entries.is_empty()
674    }
675
676    pub fn snapshot(&self, source: impl Into<String>) -> ModelPriceCatalogSnapshot {
677        let models = self
678            .entries
679            .values()
680            .map(ModelPriceView::from)
681            .collect::<Vec<_>>();
682        ModelPriceCatalogSnapshot {
683            source: source.into(),
684            model_count: models.len(),
685            models,
686        }
687    }
688}
689
690pub fn bundled_model_price_catalog() -> &'static ModelPriceCatalog {
691    static CATALOG: OnceLock<ModelPriceCatalog> = OnceLock::new();
692    CATALOG.get_or_init(build_bundled_model_price_catalog)
693}
694
695pub fn bundled_model_price_catalog_snapshot() -> ModelPriceCatalogSnapshot {
696    bundled_model_price_catalog().snapshot("bundled")
697}
698
699pub fn basellm_all_json_url() -> &'static str {
700    BASELLM_ALL_JSON_URL
701}
702
703pub fn basellm_model_price_catalog_snapshot_from_json(
704    source: impl Into<String>,
705    text: &str,
706) -> Result<ModelPriceCatalogSnapshot, String> {
707    let root: serde_json::Value =
708        serde_json::from_str(text).map_err(|err| format!("invalid basellm JSON: {err}"))?;
709    let provider_map = root
710        .as_object()
711        .ok_or_else(|| "basellm all.json root must be an object".to_string())?;
712
713    let mut models = Vec::new();
714    for (provider_name, provider_value) in provider_map {
715        let Some(models_map) = provider_value
716            .get("models")
717            .and_then(|value| value.as_object())
718        else {
719            continue;
720        };
721
722        for (model_id, model_value) in models_map {
723            let Some(cost) = model_value.get("cost").and_then(|value| value.as_object()) else {
724                continue;
725            };
726            let Some(input) = basellm_cost_field(cost, "input") else {
727                continue;
728            };
729            let Some(output) = basellm_cost_field(cost, "output") else {
730                continue;
731            };
732
733            let cache_read = basellm_cost_field(cost, "cache_read");
734            let cache_creation = basellm_cost_field(cost, "cache_write");
735            let display_name = model_value
736                .get("name")
737                .and_then(json_scalar_to_string)
738                .or_else(|| {
739                    model_value
740                        .get("display_name")
741                        .and_then(json_scalar_to_string)
742                })
743                .filter(|value| value != model_id);
744
745            models.push(ModelPriceView {
746                model_id: model_id.to_string(),
747                display_name,
748                aliases: basellm_aliases(model_value),
749                input_per_1m_usd: input,
750                output_per_1m_usd: output,
751                cache_read_input_per_1m_usd: cache_read,
752                cache_creation_input_per_1m_usd: cache_creation,
753                source: format!("basellm:{provider_name}"),
754                confidence: CostConfidence::Estimated,
755            });
756        }
757    }
758
759    models.sort_by(|left, right| left.model_id.cmp(&right.model_id));
760    models.dedup_by(|left, right| {
761        normalize_model_key(&left.model_id) == normalize_model_key(&right.model_id)
762    });
763    Ok(ModelPriceCatalogSnapshot {
764        source: source.into(),
765        model_count: models.len(),
766        models,
767    })
768}
769
770fn basellm_cost_field(
771    cost: &serde_json::Map<String, serde_json::Value>,
772    key: &str,
773) -> Option<String> {
774    cost.get(key).and_then(json_scalar_to_string)
775}
776
777fn basellm_aliases(model_value: &serde_json::Value) -> Vec<String> {
778    let mut aliases = Vec::new();
779    if let Some(value) = model_value.get("aliases") {
780        match value {
781            serde_json::Value::Array(items) => {
782                for item in items {
783                    if let Some(alias) = json_scalar_to_string(item) {
784                        let alias = alias.trim();
785                        if !alias.is_empty() {
786                            aliases.push(alias.to_string());
787                        }
788                    }
789                }
790            }
791            serde_json::Value::String(alias) => {
792                let alias = alias.trim();
793                if !alias.is_empty() {
794                    aliases.push(alias.to_string());
795                }
796            }
797            _ => {}
798        }
799    }
800    aliases
801}
802
803fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
804    match value {
805        serde_json::Value::Number(number) => Some(number.to_string()),
806        serde_json::Value::String(text) => {
807            let text = text.trim();
808            (!text.is_empty()).then(|| text.to_string())
809        }
810        _ => None,
811    }
812}
813
814pub fn model_price_overrides_path() -> PathBuf {
815    crate::config::proxy_home_dir().join("pricing_overrides.toml")
816}
817
818fn parse_model_price_overrides_document(
819    text: &str,
820) -> Result<LocalModelPriceOverridesDocument, String> {
821    let parsed: LocalModelPriceOverridesDocument =
822        toml::from_str(text).map_err(|err| format!("invalid pricing override TOML: {err}"))?;
823    validate_model_price_overrides_document(&parsed)?;
824    Ok(parsed)
825}
826
827pub fn load_model_price_overrides_document() -> Result<LocalModelPriceOverridesDocument, String> {
828    let path = model_price_overrides_path();
829    if !path.exists() {
830        return Ok(LocalModelPriceOverridesDocument::default());
831    }
832    let text = std::fs::read_to_string(&path)
833        .map_err(|err| format!("failed to read {}: {err}", path.display()))?;
834    parse_model_price_overrides_document(&text)
835}
836
837pub fn save_model_price_overrides_document(
838    document: &LocalModelPriceOverridesDocument,
839) -> Result<PathBuf, String> {
840    validate_model_price_overrides_document(document)?;
841    let normalized = document.normalized()?;
842    validate_model_price_overrides_document(&normalized)?;
843    let path = model_price_overrides_path();
844    let body = toml::to_string_pretty(&normalized)
845        .map_err(|err| format!("failed to serialize pricing overrides: {err}"))?;
846    let text = if body.trim().is_empty() {
847        MODEL_PRICE_OVERRIDES_DOC_HEADER.to_string()
848    } else {
849        format!("{MODEL_PRICE_OVERRIDES_DOC_HEADER}\n{body}")
850    };
851    write_text_file(&path, &text)
852        .map_err(|err| format!("failed to write {}: {err}", path.display()))?;
853    Ok(path)
854}
855
856pub fn local_model_price_catalog_snapshot() -> Result<ModelPriceCatalogSnapshot, String> {
857    let path = model_price_overrides_path();
858    let document = load_model_price_overrides_document()?;
859    let source = format!("local:{}", path.display());
860    let prices = document.into_prices(&source)?;
861    Ok(ModelPriceCatalog::with_prices(prices).snapshot(source))
862}
863
864fn load_model_price_overrides_from_disk() -> Result<Vec<ModelPrice>, String> {
865    let path = model_price_overrides_path();
866    if !path.exists() {
867        return Ok(Vec::new());
868    }
869    let document = load_model_price_overrides_document()?;
870    document.into_prices(&format!("local:{}", path.display()))
871}
872
873fn build_operator_model_price_catalog_with_overrides(
874    overrides: Vec<ModelPrice>,
875) -> (ModelPriceCatalog, String) {
876    let mut catalog = bundled_model_price_catalog().clone();
877    if overrides.is_empty() {
878        return (catalog, "bundled".to_string());
879    }
880
881    let override_count = overrides.len();
882    for price in overrides {
883        catalog.insert(price);
884    }
885    (
886        catalog,
887        format!("bundled+local-overrides({override_count})"),
888    )
889}
890
891pub fn validate_model_price_overrides_document(
892    document: &LocalModelPriceOverridesDocument,
893) -> Result<(), String> {
894    let mut seen_model_ids = BTreeMap::<String, String>::new();
895    let mut seen_aliases = BTreeMap::<String, String>::new();
896
897    for (raw_model_id, row) in &document.models {
898        let model_id = raw_model_id.trim();
899        if model_id.is_empty() {
900            return Err("pricing override model id cannot be empty".to_string());
901        }
902        let model_key = normalize_model_key(model_id);
903        if model_key.is_empty() {
904            return Err("pricing override model id cannot be empty".to_string());
905        }
906
907        if let Some(existing) = seen_aliases.get(&model_key)
908            && existing != model_id
909        {
910            return Err(format!(
911                "pricing override model id '{model_id}' conflicts with alias from '{existing}'"
912            ));
913        }
914
915        if let Some(existing) = seen_model_ids.insert(model_key.clone(), model_id.to_string())
916            && existing != model_id
917        {
918            return Err(format!(
919                "pricing override model id '{model_id}' conflicts with '{existing}' after case-insensitive normalization"
920            ));
921        }
922
923        row.validate_prices()?;
924
925        let mut row_aliases = BTreeSet::new();
926        for alias in &row.aliases {
927            let alias = alias.trim();
928            if alias.is_empty() {
929                return Err(format!(
930                    "pricing override model '{model_id}' contains an empty alias"
931                ));
932            }
933
934            let alias_key = normalize_model_key(alias);
935            if alias_key == model_key {
936                continue;
937            }
938            if !row_aliases.insert(alias_key.clone()) {
939                continue;
940            }
941
942            if let Some(existing) = seen_model_ids.get(&alias_key) {
943                return Err(format!(
944                    "pricing override alias '{alias}' for model '{model_id}' conflicts with model id '{existing}'"
945                ));
946            }
947
948            if let Some(existing) = seen_aliases.insert(alias_key.clone(), model_id.to_string())
949                && existing != model_id
950            {
951                return Err(format!(
952                    "pricing override alias '{alias}' is used by both '{existing}' and '{model_id}'"
953                ));
954            }
955        }
956    }
957
958    Ok(())
959}
960
961fn build_operator_model_price_catalog() -> (ModelPriceCatalog, String) {
962    match load_model_price_overrides_from_disk() {
963        Ok(overrides) => build_operator_model_price_catalog_with_overrides(overrides),
964        Err(err) => {
965            static WARNED: OnceLock<()> = OnceLock::new();
966            WARNED.get_or_init(|| {
967                tracing::warn!("failed to load model price overrides: {err}");
968            });
969            (bundled_model_price_catalog().clone(), "bundled".to_string())
970        }
971    }
972}
973
974pub fn operator_model_price_catalog_snapshot() -> ModelPriceCatalogSnapshot {
975    let (catalog, source) = build_operator_model_price_catalog();
976    catalog.snapshot(source)
977}
978
979pub fn estimate_request_cost_from_operator_catalog(
980    model: Option<&str>,
981    usage: Option<&UsageMetrics>,
982    adjustments: CostAdjustments,
983) -> CostBreakdown {
984    estimate_request_cost_from_operator_catalog_with_accounting(
985        model,
986        usage,
987        adjustments,
988        CacheInputAccounting::default(),
989    )
990}
991
992pub fn estimate_request_cost_from_operator_catalog_for_service(
993    model: Option<&str>,
994    usage: Option<&UsageMetrics>,
995    adjustments: CostAdjustments,
996    service: &str,
997) -> CostBreakdown {
998    estimate_request_cost_from_operator_catalog_with_accounting(
999        model,
1000        usage,
1001        adjustments,
1002        CacheInputAccounting::for_service(service),
1003    )
1004}
1005
1006pub fn estimate_request_cost_from_operator_catalog_with_accounting(
1007    model: Option<&str>,
1008    usage: Option<&UsageMetrics>,
1009    adjustments: CostAdjustments,
1010    accounting: CacheInputAccounting,
1011) -> CostBreakdown {
1012    let (Some(model), Some(usage)) = (model, usage) else {
1013        return CostBreakdown::unknown();
1014    };
1015    let (catalog, _) = build_operator_model_price_catalog();
1016    catalog.estimate_usage_cost_with_accounting(model, usage, adjustments, accounting)
1017}
1018
1019pub fn estimate_request_cost_from_bundled_catalog(
1020    model: Option<&str>,
1021    usage: Option<&UsageMetrics>,
1022    adjustments: CostAdjustments,
1023) -> CostBreakdown {
1024    estimate_request_cost_from_bundled_catalog_with_accounting(
1025        model,
1026        usage,
1027        adjustments,
1028        CacheInputAccounting::default(),
1029    )
1030}
1031
1032pub fn estimate_request_cost_from_bundled_catalog_with_accounting(
1033    model: Option<&str>,
1034    usage: Option<&UsageMetrics>,
1035    adjustments: CostAdjustments,
1036    accounting: CacheInputAccounting,
1037) -> CostBreakdown {
1038    let (Some(model), Some(usage)) = (model, usage) else {
1039        return CostBreakdown::unknown();
1040    };
1041    bundled_model_price_catalog().estimate_usage_cost_with_accounting(
1042        model,
1043        usage,
1044        adjustments,
1045        accounting,
1046    )
1047}
1048
1049pub fn estimate_usage_cost(
1050    usage: &UsageMetrics,
1051    price: &ModelPrice,
1052    adjustments: CostAdjustments,
1053) -> CostBreakdown {
1054    estimate_usage_cost_with_accounting(usage, price, adjustments, CacheInputAccounting::default())
1055}
1056
1057pub fn estimate_usage_cost_with_accounting(
1058    usage: &UsageMetrics,
1059    price: &ModelPrice,
1060    adjustments: CostAdjustments,
1061    accounting: CacheInputAccounting,
1062) -> CostBreakdown {
1063    let billable = BillableTokenUsage::from_usage_with_accounting(usage, accounting);
1064
1065    let Some(cache_read_price) = required_price(
1066        billable.cache_read_input_tokens,
1067        price.cache_read_input_per_1m,
1068    ) else {
1069        return unknown_with_source(&price.source);
1070    };
1071    let Some(cache_creation_price) = required_price(
1072        billable.cache_creation_input_tokens,
1073        price.cache_creation_input_per_1m,
1074    ) else {
1075        return unknown_with_source(&price.source);
1076    };
1077
1078    let input_cost =
1079        UsdAmount::cost_for_tokens_per_million(billable.input_tokens, price.input_per_1m);
1080    let output_cost =
1081        UsdAmount::cost_for_tokens_per_million(billable.output_tokens, price.output_per_1m);
1082    let cache_read_cost =
1083        UsdAmount::cost_for_tokens_per_million(billable.cache_read_input_tokens, cache_read_price);
1084    let cache_creation_cost = UsdAmount::cost_for_tokens_per_million(
1085        billable.cache_creation_input_tokens,
1086        cache_creation_price,
1087    );
1088    let base_total = input_cost
1089        .saturating_add(output_cost)
1090        .saturating_add(cache_read_cost)
1091        .saturating_add(cache_creation_cost);
1092    let adjusted_total = adjustments.apply(base_total);
1093
1094    CostBreakdown {
1095        input_cost_usd: (billable.input_tokens > 0).then(|| input_cost.format_usd()),
1096        output_cost_usd: (billable.output_tokens > 0).then(|| output_cost.format_usd()),
1097        cache_read_cost_usd: (billable.cache_read_input_tokens > 0)
1098            .then(|| cache_read_cost.format_usd()),
1099        cache_creation_cost_usd: (billable.cache_creation_input_tokens > 0)
1100            .then(|| cache_creation_cost.format_usd()),
1101        service_tier_multiplier: adjustments
1102            .service_tier_multiplier
1103            .map(PriceMultiplier::format),
1104        provider_cost_multiplier: adjustments.provider_multiplier.map(PriceMultiplier::format),
1105        total_cost_usd: Some(adjusted_total.format_usd()),
1106        confidence: price.confidence,
1107        pricing_source: Some(price.source.clone()),
1108        total_cost_femto_usd: Some(adjusted_total.femto_usd()),
1109    }
1110}
1111
1112pub fn format_cost_display(total_cost_usd: Option<&str>) -> String {
1113    total_cost_usd
1114        .map(|value| format!("${value}"))
1115        .unwrap_or_else(|| "-".to_string())
1116}
1117
1118pub fn format_cost_with_confidence(
1119    total_cost_usd: Option<&str>,
1120    confidence: CostConfidence,
1121) -> String {
1122    let total = format_cost_display(total_cost_usd);
1123    if total == "-" {
1124        return "- (unknown)".to_string();
1125    }
1126    match confidence {
1127        CostConfidence::Unknown => format!("{total} (unknown)"),
1128        CostConfidence::Partial => format!("{total} (partial)"),
1129        CostConfidence::Estimated => format!("{total} (estimated)"),
1130        CostConfidence::Exact => format!("{total} (exact)"),
1131    }
1132}
1133
1134fn required_price(tokens: i64, price: Option<UsdAmount>) -> Option<UsdAmount> {
1135    if tokens <= 0 {
1136        Some(UsdAmount::ZERO)
1137    } else {
1138        price
1139    }
1140}
1141
1142fn unknown_with_source(source: &str) -> CostBreakdown {
1143    CostBreakdown {
1144        pricing_source: Some(source.to_string()),
1145        ..CostBreakdown::unknown()
1146    }
1147}
1148
1149fn validate_usd_decimal(field: &str, value: &str) -> Result<(), String> {
1150    if UsdAmount::from_decimal_str(value).is_some() {
1151        return Ok(());
1152    }
1153    Err(format!("{field} must be a non-negative USD decimal string"))
1154}
1155
1156fn normalize_model_key(model: &str) -> String {
1157    model.trim().to_ascii_lowercase()
1158}
1159
1160fn model_lookup_keys(model: &str) -> Vec<String> {
1161    let normalized = normalize_model_key(model);
1162    let mut keys = vec![normalized.clone()];
1163    for suffix in ["-minimal", "-low", "-medium", "-high", "-xhigh"] {
1164        if let Some(stripped) = normalized.strip_suffix(suffix)
1165            && !stripped.is_empty()
1166        {
1167            keys.push(stripped.to_string());
1168        }
1169    }
1170    keys
1171}
1172
1173fn build_bundled_model_price_catalog() -> ModelPriceCatalog {
1174    const SOURCE: &str = "bundled-openai-codex-seed";
1175    const ROWS: &[(&str, &str, &str, &str, &str, &str)] = &[
1176        ("gpt-5.5", "GPT-5.5", "5", "30", "0.50", "0"),
1177        ("gpt-5.4", "GPT-5.4", "2.50", "15", "0.25", "0"),
1178        ("gpt-5.4-mini", "GPT-5.4 Mini", "0.75", "4.50", "0.075", "0"),
1179        ("gpt-5.4-nano", "GPT-5.4 Nano", "0.20", "1.25", "0.02", "0"),
1180        ("gpt-5.3-codex", "GPT-5.3 Codex", "1.75", "14", "0.175", "0"),
1181        ("gpt-5.2", "GPT-5.2", "1.75", "14", "0.175", "0"),
1182        ("gpt-5.2-codex", "GPT-5.2 Codex", "1.75", "14", "0.175", "0"),
1183        ("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
1184        ("gpt-5.1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
1185        (
1186            "gpt-5.1-codex-max",
1187            "GPT-5.1 Codex Max",
1188            "1.25",
1189            "10",
1190            "0.125",
1191            "0",
1192        ),
1193        ("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"),
1194        ("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
1195        (
1196            "gpt-5-codex-mini",
1197            "GPT-5 Codex Mini",
1198            "1.25",
1199            "10",
1200            "0.125",
1201            "0",
1202        ),
1203        ("gpt-5-mini", "GPT-5 Mini", "0.25", "2", "0.025", "0"),
1204        ("gpt-5-nano", "GPT-5 Nano", "0.05", "0.40", "0.005", "0"),
1205        ("codex-mini", "Codex Mini", "0.75", "3", "0.025", "0"),
1206        ("gpt-4.1", "GPT-4.1", "2", "8", "0.50", "0"),
1207        ("gpt-4.1-mini", "GPT-4.1 Mini", "0.40", "1.60", "0.10", "0"),
1208        ("gpt-4.1-nano", "GPT-4.1 Nano", "0.10", "0.40", "0.025", "0"),
1209        ("o3", "OpenAI o3", "2", "8", "0.50", "0"),
1210        ("o3-mini", "OpenAI o3-mini", "0.55", "2.20", "0.55", "0"),
1211        ("o3-pro", "OpenAI o3-pro", "20", "80", "0", "0"),
1212        ("o4-mini", "OpenAI o4-mini", "1.10", "4.40", "0.275", "0"),
1213        ("o1", "OpenAI o1", "15", "60", "7.50", "0"),
1214        ("o1-mini", "OpenAI o1-mini", "0.55", "2.20", "0.55", "0"),
1215    ];
1216
1217    let prices = ROWS.iter().filter_map(
1218        |(model, display, input, output, cache_read, cache_creation)| {
1219            ModelPrice::from_per_million_usd(
1220                *model,
1221                Some((*display).to_string()),
1222                input,
1223                output,
1224                Some(cache_read),
1225                Some(cache_creation),
1226                SOURCE,
1227            )
1228        },
1229    );
1230    ModelPriceCatalog::with_prices(prices)
1231}
1232
1233fn pow10_i128(exp: u32) -> i128 {
1234    let mut value = 1_i128;
1235    for _ in 0..exp {
1236        value = value.saturating_mul(10);
1237    }
1238    value
1239}
1240
1241fn parse_decimal_usd_to_femto(value: &str) -> Option<i128> {
1242    let value = value.trim();
1243    if value.is_empty() || value.starts_with('-') {
1244        return None;
1245    }
1246    let value = value.strip_prefix('+').unwrap_or(value);
1247    let (mantissa, exp10) = match value.split_once(['e', 'E']) {
1248        Some((mantissa, exp)) => (mantissa.trim(), exp.trim().parse::<i64>().ok()?),
1249        None => (value, 0),
1250    };
1251
1252    let (whole, frac) = mantissa.split_once('.').unwrap_or((mantissa, ""));
1253    if whole.is_empty() && frac.is_empty() {
1254        return None;
1255    }
1256    if !whole.chars().all(|ch| ch.is_ascii_digit()) {
1257        return None;
1258    }
1259    if !frac.chars().all(|ch| ch.is_ascii_digit()) {
1260        return None;
1261    }
1262
1263    let mut digits = String::with_capacity(whole.len() + frac.len());
1264    digits.push_str(whole);
1265    digits.push_str(frac);
1266    let digits = digits.trim_start_matches('0');
1267    let mantissa_int = if digits.is_empty() {
1268        0
1269    } else {
1270        digits.parse::<i128>().ok()?
1271    };
1272
1273    let exp_femto = exp10.saturating_sub(frac.len() as i64).saturating_add(15);
1274    if exp_femto >= 0 {
1275        return Some(mantissa_int.saturating_mul(pow10_i128(exp_femto as u32)));
1276    }
1277
1278    let divisor = pow10_i128((-exp_femto) as u32);
1279    if divisor == 0 {
1280        return None;
1281    }
1282    let q = mantissa_int / divisor;
1283    let r = mantissa_int % divisor;
1284    if r.saturating_mul(2) >= divisor {
1285        Some(q.saturating_add(1))
1286    } else {
1287        Some(q)
1288    }
1289}
1290
1291fn format_femto_usd(value: i128) -> String {
1292    let value = value.max(0);
1293    let whole = value / FEMTO_USD_PER_USD;
1294    let frac = value % FEMTO_USD_PER_USD;
1295    if frac == 0 {
1296        return whole.to_string();
1297    }
1298    let mut frac_s = format!("{frac:015}");
1299    while frac_s.ends_with('0') {
1300        frac_s.pop();
1301    }
1302    format!("{whole}.{frac_s}")
1303}
1304
1305#[cfg(test)]
1306mod tests {
1307    use super::*;
1308
1309    #[test]
1310    fn parses_and_formats_precise_usd_amounts() {
1311        assert_eq!(
1312            UsdAmount::from_decimal_str("0.000001")
1313                .expect("amount")
1314                .femto_usd(),
1315            1_000_000_000
1316        );
1317        assert_eq!(
1318            UsdAmount::from_decimal_str("1e-9")
1319                .expect("amount")
1320                .format_usd(),
1321            "0.000000001"
1322        );
1323        assert_eq!(UsdAmount::from_decimal_str("-1"), None);
1324        assert_eq!(UsdAmount::from_decimal_str("abc"), None);
1325    }
1326
1327    #[test]
1328    fn estimates_cache_aware_usage_cost_without_double_charging_cached_input() {
1329        let price = ModelPrice::from_per_million_usd(
1330            "test-model",
1331            None,
1332            "1",
1333            "2",
1334            Some("0.1"),
1335            Some("3"),
1336            "test",
1337        )
1338        .expect("price");
1339        let usage = UsageMetrics {
1340            input_tokens: 1_000,
1341            output_tokens: 500,
1342            cached_input_tokens: 100,
1343            cache_creation_input_tokens: 50,
1344            total_tokens: 1_500,
1345            ..UsageMetrics::default()
1346        };
1347
1348        let cost = estimate_usage_cost(&usage, &price, CostAdjustments::default());
1349
1350        assert_eq!(cost.input_cost_usd.as_deref(), Some("0.0009"));
1351        assert_eq!(cost.cache_read_cost_usd.as_deref(), Some("0.00001"));
1352        assert_eq!(cost.cache_creation_cost_usd.as_deref(), Some("0.00015"));
1353        assert_eq!(cost.output_cost_usd.as_deref(), Some("0.001"));
1354        assert_eq!(cost.total_cost_usd.as_deref(), Some("0.00206"));
1355        assert_eq!(cost.confidence, CostConfidence::Estimated);
1356    }
1357
1358    #[test]
1359    fn keeps_anthropic_style_cache_tokens_outside_regular_input() {
1360        let usage = UsageMetrics {
1361            input_tokens: 10,
1362            output_tokens: 5,
1363            cache_read_input_tokens: 30,
1364            cache_creation_5m_input_tokens: 20,
1365            cache_creation_1h_input_tokens: 40,
1366            ..UsageMetrics::default()
1367        };
1368
1369        let billable = BillableTokenUsage::from_usage(&usage);
1370
1371        assert_eq!(billable.input_tokens, 10);
1372        assert_eq!(billable.cache_read_input_tokens, 30);
1373        assert_eq!(billable.cache_creation_input_tokens, 60);
1374    }
1375
1376    #[test]
1377    fn subtracts_direct_cache_read_for_codex_style_accounting() {
1378        let usage = UsageMetrics {
1379            input_tokens: 100,
1380            output_tokens: 5,
1381            cache_read_input_tokens: 30,
1382            cache_creation_input_tokens: 10,
1383            ..UsageMetrics::default()
1384        };
1385
1386        let billable = BillableTokenUsage::from_usage_with_accounting(
1387            &usage,
1388            CacheInputAccounting::DirectReadIncludedInInput,
1389        );
1390
1391        assert_eq!(billable.input_tokens, 70);
1392        assert_eq!(billable.cache_read_input_tokens, 30);
1393        assert_eq!(billable.cache_creation_input_tokens, 10);
1394    }
1395
1396    #[test]
1397    fn unknown_cost_is_not_zero() {
1398        let cost = CostBreakdown::default();
1399
1400        assert_eq!(cost.confidence, CostConfidence::Unknown);
1401        assert_eq!(cost.display_total(), "-");
1402    }
1403
1404    #[test]
1405    fn missing_required_cache_price_makes_cost_unknown() {
1406        let price =
1407            ModelPrice::from_per_million_usd("test-model", None, "1", "2", None, Some("3"), "test")
1408                .expect("price");
1409        let usage = UsageMetrics {
1410            input_tokens: 100,
1411            cached_input_tokens: 10,
1412            output_tokens: 20,
1413            ..UsageMetrics::default()
1414        };
1415
1416        let cost = estimate_usage_cost(&usage, &price, CostAdjustments::default());
1417
1418        assert_eq!(cost.confidence, CostConfidence::Unknown);
1419        assert_eq!(cost.total_cost_usd, None);
1420        assert_eq!(cost.pricing_source.as_deref(), Some("test"));
1421    }
1422
1423    #[test]
1424    fn model_lookup_accepts_reasoning_suffixes() {
1425        let catalog = bundled_model_price_catalog();
1426
1427        assert!(catalog.price_for_model("gpt-5.3-codex-high").is_some());
1428        assert!(catalog.price_for_model("GPT-5.1-CODEX-MAX-XHIGH").is_some());
1429    }
1430
1431    #[test]
1432    fn bundled_catalog_snapshot_exposes_operator_price_rows() {
1433        let snapshot = bundled_model_price_catalog_snapshot();
1434
1435        assert_eq!(snapshot.source, "bundled");
1436        assert_eq!(snapshot.model_count, snapshot.models.len());
1437        let gpt5 = snapshot
1438            .models
1439            .iter()
1440            .find(|model| model.model_id == "gpt-5")
1441            .expect("gpt-5 price row");
1442        assert_eq!(gpt5.input_per_1m_usd, "1.25");
1443        assert_eq!(gpt5.output_per_1m_usd, "10");
1444        assert_eq!(gpt5.cache_read_input_per_1m_usd.as_deref(), Some("0.125"));
1445        assert_eq!(gpt5.confidence, CostConfidence::Estimated);
1446    }
1447
1448    #[test]
1449    fn model_price_view_matches_reasoning_suffixed_model() {
1450        let snapshot = bundled_model_price_catalog_snapshot();
1451        let row = snapshot
1452            .models
1453            .iter()
1454            .find(|model| model.model_id == "gpt-5.3-codex")
1455            .expect("gpt-5.3-codex price row");
1456
1457        assert!(row.matches_model("GPT-5.3-CODEX-HIGH"));
1458    }
1459
1460    #[test]
1461    fn catalog_snapshot_prioritizes_observed_models_then_fills_catalog_order() {
1462        let snapshot = bundled_model_price_catalog_snapshot();
1463        let rows = snapshot.prioritized_models(["gpt-5.4-mini", "unknown-model"], 3);
1464
1465        assert_eq!(rows[0].model_id, "gpt-5.4-mini");
1466        assert_eq!(rows.len(), 3);
1467        assert!(rows[1..].iter().all(|row| row.model_id != "gpt-5.4-mini"));
1468    }
1469
1470    #[test]
1471    fn parses_local_price_overrides_and_replaces_bundled_rows() {
1472        let text = r#"
1473[models.gpt-5]
1474display_name = "Custom GPT-5"
1475aliases = ["custom-gpt5"]
1476input_per_1m_usd = "9"
1477output_per_1m_usd = "18"
1478cache_read_input_per_1m_usd = "0.9"
1479cache_creation_input_per_1m_usd = "0.1"
1480confidence = "exact"
1481
1482[models.custom-relay]
1483input_per_1m_usd = "0.5"
1484output_per_1m_usd = "1.5"
1485"#;
1486        let document = parse_model_price_overrides_document(text).expect("overrides");
1487        let overrides = document.into_prices("local-test").expect("overrides");
1488        let mut catalog = bundled_model_price_catalog().clone();
1489        for price in overrides {
1490            catalog.insert(price);
1491        }
1492
1493        let gpt5 = catalog
1494            .price_for_model("custom-gpt5")
1495            .expect("override alias");
1496        assert_eq!(gpt5.display_name.as_deref(), Some("Custom GPT-5"));
1497        assert_eq!(gpt5.input_per_1m.format_usd(), "9");
1498        assert_eq!(gpt5.output_per_1m.format_usd(), "18");
1499        assert_eq!(gpt5.confidence, CostConfidence::Exact);
1500
1501        let custom = catalog
1502            .price_for_model("custom-relay")
1503            .expect("new override model");
1504        assert_eq!(custom.input_per_1m.format_usd(), "0.5");
1505        assert_eq!(custom.source, "local-test");
1506    }
1507
1508    #[test]
1509    fn local_price_override_document_rejects_conflicting_aliases() {
1510        let text = r#"
1511[models.gpt-5]
1512input_per_1m_usd = "1"
1513output_per_1m_usd = "2"
1514aliases = ["custom"]
1515
1516[models.gpt-4]
1517input_per_1m_usd = "3"
1518output_per_1m_usd = "4"
1519aliases = ["CUSTOM"]
1520"#;
1521        let err = parse_model_price_overrides_document(text).expect_err("should fail");
1522        assert!(err.contains("used by both"));
1523    }
1524
1525    #[test]
1526    fn summary_tracks_partial_confidence() {
1527        let mut summary = CostSummary::default();
1528        let known = CostBreakdown {
1529            total_cost_usd: Some("0.001".to_string()),
1530            confidence: CostConfidence::Estimated,
1531            total_cost_femto_usd: Some(1_000_000_000_000),
1532            ..CostBreakdown::unknown()
1533        };
1534
1535        summary.record_usage_cost(&known);
1536        summary.record_usage_cost(&CostBreakdown::unknown());
1537
1538        assert_eq!(summary.total_cost_usd.as_deref(), Some("0.001"));
1539        assert_eq!(summary.confidence, CostConfidence::Partial);
1540        assert_eq!(summary.priced_requests, 1);
1541        assert_eq!(summary.unpriced_requests, 1);
1542    }
1543
1544    #[test]
1545    fn basellm_snapshot_imports_per_million_cost_rows() {
1546        let text = r#"
1547{
1548  "openai": {
1549    "models": {
1550      "gpt-test": {
1551        "name": "GPT Test",
1552        "aliases": ["relay-gpt-test"],
1553        "cost": {
1554          "input": "1.5",
1555          "output": 6,
1556          "cache_read": "0.15",
1557          "cache_write": "0"
1558        }
1559      }
1560    }
1561  },
1562  "unknown-provider": {
1563    "models": {
1564      "ignored": {
1565        "cost": { "input": 1, "output": 2 }
1566      }
1567    }
1568  }
1569}
1570"#;
1571
1572        let snapshot =
1573            basellm_model_price_catalog_snapshot_from_json("basellm-test", text).expect("snapshot");
1574
1575        assert_eq!(snapshot.source, "basellm-test");
1576        assert_eq!(snapshot.model_count, 2);
1577        let row = snapshot
1578            .models
1579            .iter()
1580            .find(|row| row.model_id == "gpt-test")
1581            .expect("gpt-test row");
1582        assert_eq!(row.display_name.as_deref(), Some("GPT Test"));
1583        assert_eq!(row.aliases, vec!["relay-gpt-test"]);
1584        assert_eq!(row.input_per_1m_usd, "1.5");
1585        assert_eq!(row.output_per_1m_usd, "6");
1586        assert_eq!(row.cache_read_input_per_1m_usd.as_deref(), Some("0.15"));
1587        assert_eq!(row.cache_creation_input_per_1m_usd.as_deref(), Some("0"));
1588        assert_eq!(row.source, "basellm:openai");
1589    }
1590}