1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::sync::{Arc, RwLock};
4
5use super::{CatalogEntry, ModelCatalogSource, PricingTier, applicable_pricing};
6use crate::model_capabilities::{Pricing, get_model_capabilities};
7use agent_sdk_foundation::llm::Usage;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ResolvedSource {
12 Override,
14 Feed,
16 Static,
18 Unknown,
20}
21
22#[derive(Debug, Clone, PartialEq)]
24pub struct ResolvedModel {
25 pub context_window: Option<u32>,
27 pub max_output_tokens: Option<u32>,
29 pub pricing: Option<Pricing>,
32 pub pricing_tiers: Vec<PricingTier>,
35 pub supports_thinking: Option<bool>,
37 pub source: ResolvedSource,
39}
40
41type ModelKey = (String, String);
42
43fn model_key(provider: &str, model: &str) -> ModelKey {
44 (provider.to_ascii_lowercase(), model.to_ascii_lowercase())
45}
46
47#[derive(Clone, Default)]
55pub struct ModelRegistry {
56 overrides: HashMap<ModelKey, CatalogEntry>,
57 feed_cache: Arc<RwLock<HashMap<ModelKey, CatalogEntry>>>,
58}
59
60impl ModelRegistry {
61 #[must_use]
63 pub fn new() -> Self {
64 Self::default()
65 }
66
67 #[must_use]
69 pub fn with_override(
70 mut self,
71 provider: impl AsRef<str>,
72 model: impl AsRef<str>,
73 entry: CatalogEntry,
74 ) -> Self {
75 self.overrides
76 .insert(model_key(provider.as_ref(), model.as_ref()), entry);
77 self
78 }
79
80 pub fn register(
82 &mut self,
83 provider: impl AsRef<str>,
84 model: impl AsRef<str>,
85 entry: CatalogEntry,
86 ) {
87 self.overrides
88 .insert(model_key(provider.as_ref(), model.as_ref()), entry);
89 }
90
91 pub async fn refresh(&self, source: &dyn ModelCatalogSource) -> Result<usize> {
98 let entries = source.fetch().await?;
99 let mut cache = self
100 .feed_cache
101 .write()
102 .ok()
103 .context("feed cache lock poisoned")?;
104 cache.clear();
105 for entry in entries {
106 cache.insert(model_key(&entry.provider, &entry.model_id), entry);
107 }
108 Ok(cache.len())
109 }
110
111 #[must_use]
113 pub fn resolve(&self, provider: &str, model: &str) -> ResolvedModel {
114 if let Some(resolved) = self.resolve_dynamic(provider, model) {
115 return resolved;
116 }
117
118 if let Some(caps) = get_model_capabilities(provider, model) {
119 return ResolvedModel {
120 context_window: caps.context_window,
121 max_output_tokens: caps.max_output_tokens,
122 pricing: caps.pricing,
123 pricing_tiers: Vec::new(),
125 supports_thinking: Some(caps.supports_thinking),
126 source: ResolvedSource::Static,
127 };
128 }
129
130 ResolvedModel {
131 context_window: None,
132 max_output_tokens: None,
133 pricing: None,
134 pricing_tiers: Vec::new(),
135 supports_thinking: None,
136 source: ResolvedSource::Unknown,
137 }
138 }
139
140 #[must_use]
152 pub fn resolve_dynamic(&self, provider: &str, model: &str) -> Option<ResolvedModel> {
153 let key = model_key(provider, model);
154
155 if let Some(entry) = self.overrides.get(&key) {
156 return Some(resolved_from_entry(entry, ResolvedSource::Override));
157 }
158
159 let cache = self.feed_cache.read().ok()?;
160 cache
161 .get(&key)
162 .map(|entry| resolved_from_entry(entry, ResolvedSource::Feed))
163 }
164
165 #[must_use]
172 pub fn resolve_override(&self, provider: &str, model: &str) -> Option<ResolvedModel> {
173 self.overrides
174 .get(&model_key(provider, model))
175 .map(|entry| resolved_from_entry(entry, ResolvedSource::Override))
176 }
177
178 #[must_use]
181 pub fn estimate_override_cost_usd(
182 &self,
183 provider: &str,
184 model: &str,
185 usage: &Usage,
186 ) -> Option<f64> {
187 estimate_resolved(
188 &self.resolve_override(provider, model)?,
189 usage,
190 TierMode::Apply,
191 )
192 }
193
194 #[must_use]
197 pub fn estimate_override_base_cost_usd(
198 &self,
199 provider: &str,
200 model: &str,
201 usage: &Usage,
202 ) -> Option<f64> {
203 estimate_resolved(
204 &self.resolve_override(provider, model)?,
205 usage,
206 TierMode::Base,
207 )
208 }
209
210 #[must_use]
218 pub fn resolve_feed(&self, provider: &str, model: &str) -> Option<ResolvedModel> {
219 let cache = self.feed_cache.read().ok()?;
220 cache
221 .get(&model_key(provider, model))
222 .map(|entry| resolved_from_entry(entry, ResolvedSource::Feed))
223 }
224
225 #[must_use]
228 pub fn estimate_feed_cost_usd(
229 &self,
230 provider: &str,
231 model: &str,
232 usage: &Usage,
233 ) -> Option<f64> {
234 estimate_resolved(&self.resolve_feed(provider, model)?, usage, TierMode::Apply)
235 }
236
237 #[must_use]
240 pub fn estimate_feed_base_cost_usd(
241 &self,
242 provider: &str,
243 model: &str,
244 usage: &Usage,
245 ) -> Option<f64> {
246 estimate_resolved(&self.resolve_feed(provider, model)?, usage, TierMode::Base)
247 }
248
249 #[must_use]
261 pub fn estimate_cost_usd(&self, provider: &str, model: &str, usage: &Usage) -> Option<f64> {
262 estimate_resolved(&self.resolve(provider, model), usage, TierMode::Apply)
263 }
264
265 #[must_use]
275 pub fn estimate_dynamic_cost_usd(
276 &self,
277 provider: &str,
278 model: &str,
279 usage: &Usage,
280 ) -> Option<f64> {
281 estimate_resolved(
282 &self.resolve_dynamic(provider, model)?,
283 usage,
284 TierMode::Apply,
285 )
286 }
287
288 #[must_use]
297 pub fn estimate_dynamic_base_cost_usd(
298 &self,
299 provider: &str,
300 model: &str,
301 usage: &Usage,
302 ) -> Option<f64> {
303 estimate_resolved(
304 &self.resolve_dynamic(provider, model)?,
305 usage,
306 TierMode::Base,
307 )
308 }
309}
310
311#[derive(Clone, Copy, PartialEq, Eq)]
314enum TierMode {
315 Apply,
316 Base,
317}
318
319fn estimate_resolved(resolved: &ResolvedModel, usage: &Usage, tiers: TierMode) -> Option<f64> {
322 let base = resolved.pricing?;
323 let pricing = match tiers {
324 TierMode::Apply => applicable_pricing(base, &resolved.pricing_tiers, usage.input_tokens),
325 TierMode::Base => base,
326 };
327 if !prices_every_billed_component(&pricing, usage) {
328 return None;
329 }
330 pricing.estimate_cost_usd(usage)
331}
332
333fn prices_every_billed_component(pricing: &Pricing, usage: &Usage) -> bool {
349 let cache_read_tokens = usage.cached_input_tokens.min(usage.input_tokens);
350 let after_cache_read = usage.input_tokens.saturating_sub(cache_read_tokens);
351 let cache_write_tokens = usage.cache_creation_input_tokens.min(after_cache_read);
352 let plain_input_tokens = after_cache_read.saturating_sub(cache_write_tokens);
353
354 let input_priced = plain_input_tokens == 0 || pricing.input.is_some();
355 let cache_read_priced =
356 cache_read_tokens == 0 || pricing.cached_input.is_some() || pricing.input.is_some();
357 let cache_write_priced =
358 cache_write_tokens == 0 || pricing.cache_write.is_some() || pricing.input.is_some();
359 let output_priced = usage.output_tokens == 0 || pricing.output.is_some();
360
361 input_priced && cache_read_priced && cache_write_priced && output_priced
362}
363
364fn resolved_from_entry(entry: &CatalogEntry, source: ResolvedSource) -> ResolvedModel {
365 ResolvedModel {
366 context_window: entry.context_window,
367 max_output_tokens: entry.max_output_tokens,
368 pricing: entry.pricing,
369 pricing_tiers: entry.pricing_tiers.clone(),
370 supports_thinking: entry.supports_thinking,
371 source,
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use crate::model_catalog::modelsdev::parse_modelsdev;
379 use async_trait::async_trait;
380
381 const MODELSDEV_FIXTURE: &str = r#"{
382 "anthropic": {
383 "id": "anthropic",
384 "name": "Anthropic",
385 "models": {
386 "claude-sonnet-4-5": {
387 "id": "claude-sonnet-4-5",
388 "name": "Claude Sonnet 4.5",
389 "reasoning": true,
390 "limit": { "context": 1000000, "output": 64000 },
391 "cost": { "input": 3, "output": 15, "cache_read": 0.3, "cache_write": 3.75 }
392 }
393 }
394 },
395 "openai": {
396 "id": "openai",
397 "name": "OpenAI",
398 "models": {
399 "gpt-5.2": {
400 "id": "gpt-5.2",
401 "name": "GPT-5.2",
402 "reasoning": true,
403 "limit": { "context": 400000, "output": 128000 },
404 "cost": { "input": 1.75, "output": 14, "cache_read": 0.175 }
405 }
406 }
407 },
408 "google": {
409 "id": "google",
410 "name": "Google",
411 "models": {
412 "gemini-2.5-pro": {
413 "id": "gemini-2.5-pro",
414 "name": "Gemini 2.5 Pro",
415 "reasoning": true,
416 "limit": { "context": 1048576, "output": 65536 },
417 "cost": { "input": 1.25, "output": 10, "cache_read": 0.31, "cache_write": 2.375 }
418 }
419 }
420 }
421 }"#;
422
423 struct StaticSource(Vec<CatalogEntry>);
424
425 #[async_trait]
426 impl ModelCatalogSource for StaticSource {
427 async fn fetch(&self) -> Result<Vec<CatalogEntry>> {
428 Ok(self.0.clone())
429 }
430 }
431
432 #[tokio::test]
433 async fn registry_layered_resolution_prefers_override_then_feed_then_static() -> Result<()> {
434 let source = StaticSource(parse_modelsdev(MODELSDEV_FIXTURE)?);
435 let registry = ModelRegistry::new().with_override(
436 "anthropic",
437 "claude-sonnet-4-5",
438 CatalogEntry {
439 provider: "anthropic".to_owned(),
440 model_id: "claude-sonnet-4-5".to_owned(),
441 context_window: Some(123),
442 max_output_tokens: Some(7),
443 pricing: Some(Pricing::flat(1.0, 2.0)),
444 pricing_tiers: Vec::new(),
445 supports_thinking: Some(false),
446 },
447 );
448
449 let count = registry.refresh(&source).await?;
450 assert_eq!(count, 3);
451
452 let overridden = registry.resolve("anthropic", "claude-sonnet-4-5");
454 assert_eq!(overridden.source, ResolvedSource::Override);
455 assert_eq!(overridden.context_window, Some(123));
456
457 let feed = registry.resolve("openai", "gpt-5.2");
459 assert_eq!(feed.source, ResolvedSource::Feed);
460 assert_eq!(feed.max_output_tokens, Some(128_000));
461
462 let static_hit = registry.resolve("openai", "gpt-4o");
465 assert_eq!(static_hit.source, ResolvedSource::Static);
466 assert!(static_hit.pricing.is_some());
467
468 let unknown = registry.resolve("openai", "totally-made-up-model");
470 assert_eq!(unknown.source, ResolvedSource::Unknown);
471 assert!(unknown.pricing.is_none());
472 Ok(())
473 }
474
475 #[tokio::test]
476 async fn estimate_cost_usd_uses_feed_loaded_pricing() -> Result<()> {
477 let source = StaticSource(parse_modelsdev(MODELSDEV_FIXTURE)?);
478 let registry = ModelRegistry::new();
479 registry.refresh(&source).await?;
480
481 let usage = Usage {
484 input_tokens: 2_000,
485 output_tokens: 1_000,
486 cached_input_tokens: 1_000,
487 cache_creation_input_tokens: 0,
488 };
489 let cost = registry
490 .estimate_cost_usd("openai", "gpt-5.2", &usage)
491 .context("cost estimate missing")?;
492 assert!((cost - 0.015_925).abs() < 1e-9);
495 Ok(())
496 }
497
498 #[tokio::test]
499 async fn dynamic_lookups_never_answer_from_the_static_table() -> Result<()> {
500 let source = StaticSource(parse_modelsdev(MODELSDEV_FIXTURE)?);
501 let registry = ModelRegistry::new();
502 registry.refresh(&source).await?;
503
504 let usage = Usage {
505 input_tokens: 1_000_000,
506 output_tokens: 1_000_000,
507 cached_input_tokens: 0,
508 cache_creation_input_tokens: 0,
509 };
510
511 assert!(registry.resolve("openai", "gpt-4o").pricing.is_some());
513 assert!(registry.resolve_dynamic("openai", "gpt-4o").is_none());
514 assert!(
515 registry
516 .estimate_dynamic_cost_usd("openai", "gpt-4o", &usage)
517 .is_none(),
518 "the dynamic estimate must not fall back to the static table",
519 );
520
521 assert_eq!(
523 registry
524 .resolve_dynamic("openai", "gpt-5.2")
525 .context("the feed carries gpt-5.2")?
526 .source,
527 ResolvedSource::Feed
528 );
529 assert!(
530 registry
531 .estimate_dynamic_cost_usd("openai", "gpt-5.2", &usage)
532 .is_some()
533 );
534 Ok(())
535 }
536
537 #[test]
541 fn cache_creation_tokens_bill_at_the_cache_write_rate() -> Result<()> {
542 let registry = ModelRegistry::new().with_override(
543 "anthropic",
544 "claude-haiku-4-5",
545 CatalogEntry {
546 provider: "anthropic".to_owned(),
547 model_id: "claude-haiku-4-5".to_owned(),
548 context_window: None,
549 max_output_tokens: None,
550 pricing: Some(Pricing::flat_with_cached(1.0, 5.0, 0.1).with_cache_write(1.25)),
552 pricing_tiers: Vec::new(),
553 supports_thinking: None,
554 },
555 );
556
557 let usage = Usage {
561 input_tokens: 1_000_000,
562 output_tokens: 1_000_000,
563 cached_input_tokens: 300_000,
564 cache_creation_input_tokens: 500_000,
565 };
566 let cost = registry
567 .estimate_cost_usd("anthropic", "claude-haiku-4-5", &usage)
568 .context("cost estimate missing")?;
569 assert!((cost - 5.855).abs() < 1e-9, "unexpected cost: {cost}");
570 Ok(())
571 }
572
573 #[test]
577 fn cache_creation_falls_back_to_the_input_rate() -> Result<()> {
578 let registry = ModelRegistry::new().with_override(
579 "anthropic",
580 "no-write-rate",
581 CatalogEntry {
582 provider: "anthropic".to_owned(),
583 model_id: "no-write-rate".to_owned(),
584 context_window: None,
585 max_output_tokens: None,
586 pricing: Some(Pricing::flat(1.0, 5.0)),
587 pricing_tiers: Vec::new(),
588 supports_thinking: None,
589 },
590 );
591 let usage = Usage {
592 input_tokens: 1_000_000,
593 output_tokens: 0,
594 cached_input_tokens: 0,
595 cache_creation_input_tokens: 500_000,
596 };
597 let cost = registry
599 .estimate_cost_usd("anthropic", "no-write-rate", &usage)
600 .context("cost estimate missing")?;
601 assert!((cost - 1.0).abs() < 1e-9, "unexpected cost: {cost}");
602 Ok(())
603 }
604
605 #[test]
609 fn long_context_calls_bill_at_the_tier_rate() -> Result<()> {
610 let registry = ModelRegistry::new().with_override(
611 "openai",
612 "gpt-5.4",
613 CatalogEntry {
614 provider: "openai".to_owned(),
615 model_id: "gpt-5.4".to_owned(),
616 context_window: None,
617 max_output_tokens: None,
618 pricing: Some(Pricing::flat(2.5, 15.0)),
620 pricing_tiers: vec![PricingTier {
621 min_input_tokens: 272_000,
623 pricing: Pricing::flat(5.0, 22.5),
624 }],
625 supports_thinking: None,
626 },
627 );
628
629 let short = Usage {
631 input_tokens: 200_000,
632 output_tokens: 100_000,
633 cached_input_tokens: 0,
634 cache_creation_input_tokens: 0,
635 };
636 let short_cost = registry
637 .estimate_cost_usd("openai", "gpt-5.4", &short)
638 .context("cost estimate missing")?;
639 assert!(
640 (short_cost - 2.0).abs() < 1e-9,
641 "unexpected cost: {short_cost}"
642 );
643
644 let long = Usage {
647 input_tokens: 300_000,
648 output_tokens: 100_000,
649 cached_input_tokens: 0,
650 cache_creation_input_tokens: 0,
651 };
652 let long_cost = registry
653 .estimate_cost_usd("openai", "gpt-5.4", &long)
654 .context("cost estimate missing")?;
655 assert!(
656 (long_cost - 3.75).abs() < 1e-9,
657 "unexpected cost: {long_cost}"
658 );
659 Ok(())
660 }
661
662 #[test]
665 fn tier_selection_is_inclusive_at_the_threshold() -> Result<()> {
666 let registry = tiered_registry();
667
668 let just_below = Usage {
669 input_tokens: 271_999,
670 output_tokens: 0,
671 cached_input_tokens: 0,
672 cache_creation_input_tokens: 0,
673 };
674 let base_cost = registry
675 .estimate_cost_usd("openai", "gpt-5.4", &just_below)
676 .context("cost estimate missing")?;
677 assert!(
679 (base_cost - 0.679_997_5).abs() < 1e-9,
680 "unexpected cost: {base_cost}"
681 );
682
683 let at_threshold = Usage {
684 input_tokens: 272_000,
685 output_tokens: 0,
686 cached_input_tokens: 0,
687 cache_creation_input_tokens: 0,
688 };
689 let tier_cost = registry
690 .estimate_cost_usd("openai", "gpt-5.4", &at_threshold)
691 .context("cost estimate missing")?;
692 assert!(
694 (tier_cost - 1.36).abs() < 1e-9,
695 "unexpected cost: {tier_cost}"
696 );
697 Ok(())
698 }
699
700 #[test]
705 fn aggregate_repricing_never_selects_a_tier() -> Result<()> {
706 let registry = tiered_registry();
707
708 let aggregate = Usage {
710 input_tokens: 300_000,
711 output_tokens: 0,
712 cached_input_tokens: 0,
713 cache_creation_input_tokens: 0,
714 };
715
716 let base = registry
717 .estimate_dynamic_base_cost_usd("openai", "gpt-5.4", &aggregate)
718 .context("cost estimate missing")?;
719 assert!(
721 (base - 0.75).abs() < 1e-9,
722 "unexpected aggregate cost: {base}"
723 );
724
725 let per_call = registry
726 .estimate_dynamic_cost_usd("openai", "gpt-5.4", &aggregate)
727 .context("cost estimate missing")?;
728 assert!(
729 (per_call - 1.5).abs() < 1e-9,
730 "a single 300K call does pay the tier rate: {per_call}"
731 );
732 Ok(())
733 }
734
735 fn tiered_registry() -> ModelRegistry {
737 ModelRegistry::new().with_override(
738 "openai",
739 "gpt-5.4",
740 CatalogEntry {
741 provider: "openai".to_owned(),
742 model_id: "gpt-5.4".to_owned(),
743 context_window: None,
744 max_output_tokens: None,
745 pricing: Some(Pricing::flat(2.5, 15.0)),
746 pricing_tiers: vec![PricingTier {
747 min_input_tokens: 272_000,
749 pricing: Pricing::flat(5.0, 22.5),
750 }],
751 supports_thinking: None,
752 },
753 )
754 }
755
756 #[test]
757 fn estimate_cost_usd_declines_a_row_missing_a_billed_rate() -> Result<()> {
758 let registry = ModelRegistry::new().with_override(
761 "openai",
762 "gpt-4o",
763 CatalogEntry {
764 provider: "openai".to_owned(),
765 model_id: "gpt-4o".to_owned(),
766 context_window: None,
767 max_output_tokens: None,
768 pricing: Some(Pricing {
769 input: Some(crate::model_capabilities::PricePoint::new(1.0)),
770 output: None,
771 cached_input: None,
772 cache_write: None,
773 reasoning: None,
774 notes: None,
775 }),
776 pricing_tiers: Vec::new(),
777 supports_thinking: None,
778 },
779 );
780
781 let with_output = Usage {
782 input_tokens: 2_000,
783 output_tokens: 1_000,
784 cached_input_tokens: 0,
785 cache_creation_input_tokens: 0,
786 };
787 assert!(
788 registry
789 .estimate_cost_usd("openai", "gpt-4o", &with_output)
790 .is_none(),
791 "a row with no output rate must decline a call that bills output tokens",
792 );
793
794 let input_only = Usage {
796 input_tokens: 2_000,
797 output_tokens: 0,
798 cached_input_tokens: 0,
799 cache_creation_input_tokens: 0,
800 };
801 let cost = registry
802 .estimate_cost_usd("openai", "gpt-4o", &input_only)
803 .context("input-only usage is fully priced by an input rate")?;
804 assert!((cost - 0.002).abs() < 1e-9, "unexpected cost: {cost}");
805 Ok(())
806 }
807}