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}