Skip to main content

agent_sdk_providers/model_catalog/
registry.rs

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/// Where a [`ResolvedModel`] came from, in the registry's resolution order.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ResolvedSource {
12    /// A user-registered override.
13    Override,
14    /// The refreshable third-party feed cache.
15    Feed,
16    /// The static [`model_capabilities`](crate::model_capabilities) table.
17    Static,
18    /// No source had this model; fields are empty defaults.
19    Unknown,
20}
21
22/// The resolved capability/pricing view of a provider/model.
23#[derive(Debug, Clone, PartialEq)]
24pub struct ResolvedModel {
25    /// Maximum total context window in tokens, if known.
26    pub context_window: Option<u32>,
27    /// Maximum output tokens per response, if known.
28    pub max_output_tokens: Option<u32>,
29    /// Base pricing for cost estimation, if known. Applies to a call whose
30    /// context stays inside the base band — see [`pricing_tiers`](Self::pricing_tiers).
31    pub pricing: Option<Pricing>,
32    /// Long-context price bands, when the source publishes them. Empty for a
33    /// model priced at one flat rate (every static-table row).
34    pub pricing_tiers: Vec<PricingTier>,
35    /// Whether the model is a reasoning/thinking model, if known.
36    pub supports_thinking: Option<bool>,
37    /// Which layer satisfied the lookup.
38    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/// A layered model resolver: user override → feed cache → static table.
48///
49/// Construct one with [`new`](Self::new), optionally seed user overrides, then
50/// [`refresh`](Self::refresh) it from a [`ModelCatalogSource`] to populate the
51/// feed cache. [`resolve`](Self::resolve) walks the layers in priority order
52/// and always returns a [`ResolvedModel`] (empty + [`ResolvedSource::Unknown`]
53/// when nothing matched).
54#[derive(Clone, Default)]
55pub struct ModelRegistry {
56    overrides: HashMap<ModelKey, CatalogEntry>,
57    feed_cache: Arc<RwLock<HashMap<ModelKey, CatalogEntry>>>,
58}
59
60impl ModelRegistry {
61    /// Create an empty registry (no overrides, no feed loaded).
62    #[must_use]
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Builder-style: add a user override that wins over feed and static data.
68    #[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    /// Register (or replace) a user override in place.
81    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    /// Fetch from `source` and replace the feed cache. Returns the entry count.
92    ///
93    /// # Errors
94    ///
95    /// Returns an error if the source fails to fetch or parse, or if the cache
96    /// lock is poisoned.
97    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    /// Resolve a provider/model through the layers: override → feed → static.
112    #[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                // The compiled-in table prices every model at one flat rate.
124                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    /// Resolve through the *dynamic* layers only — user override → feed cache
141    /// — reporting `None` when neither carries the pair.
142    ///
143    /// Unlike [`resolve`](Self::resolve) this never falls back to the static
144    /// table, which lets a caller that owns its own static lookup keep the two
145    /// sources apart. That distinction matters when the key a model is filed
146    /// under differs per source: a caller can then ask this catalog about
147    /// *every* key the feeds might use before letting the static table answer
148    /// under the (narrower) set of keys it is built from, instead of having
149    /// the first key that happens to hit the static layer short-circuit the
150    /// search.
151    #[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    /// Resolve through the *override* layer only — a user-registered override —
166    /// reporting `None` when the pair has none.
167    ///
168    /// An override is authoritative: the caller uses it in preference to any
169    /// feed row, so it is probed on its own. See
170    /// [`estimate_override_cost_usd`](Self::estimate_override_cost_usd).
171    #[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    /// Estimate cost from the *override* layer only (never feed or static),
179    /// with tier selection. `None` when the pair has no override.
180    #[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    /// Estimate cost from the *override* layer only, at the base band (a summed
195    /// usage). `None` when the pair has no override.
196    #[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    /// Resolve through the *feed* layer only — the refreshable cache — skipping
211    /// both the override and the static table. `None` when the feed has none.
212    ///
213    /// A caller that scopes override authority by candidate specificity needs
214    /// the feed price at a key *independently* of any override on the same key,
215    /// which the layered lookups (which let an override shadow the feed) cannot
216    /// give it.
217    #[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    /// Estimate cost from the *feed* layer only (never override or static), with
226    /// tier selection. `None` when the feed has no row for the pair.
227    #[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    /// Estimate cost from the *feed* layer only, at the base band (a summed
238    /// usage). `None` when the feed has no row for the pair.
239    #[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    /// Estimate request cost in USD using the layered pricing (override → feed
250    /// → static), if any.
251    ///
252    /// Reports `None` — "this catalog cannot price the call" — rather than a
253    /// partial figure when the resolved pricing is missing a rate for a usage
254    /// component with a nonzero token count (e.g. a feed row that lists an
255    /// input rate but no output rate). A caller can then fall back to another
256    /// pricing source instead of billing those tokens at zero.
257    ///
258    /// A call whose context exceeds a published tier threshold is billed at
259    /// that tier's rates, not the base rates.
260    #[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    /// Estimate request cost in USD from the *dynamic* layers only (override →
266    /// feed), never the static table. See [`resolve_dynamic`](Self::resolve_dynamic).
267    ///
268    /// Declines partial pricing, and selects the tier the call falls in,
269    /// exactly as [`estimate_cost_usd`](Self::estimate_cost_usd) does.
270    ///
271    /// `usage` must describe a SINGLE provider call: tier selection reads its
272    /// input-token count as the call's context size. To price a summed usage,
273    /// use [`estimate_dynamic_base_cost_usd`](Self::estimate_dynamic_base_cost_usd).
274    #[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    /// Estimate cost from the *dynamic* layers at the BASE band, ignoring any
289    /// long-context tiers.
290    ///
291    /// For a summed usage — a thread's cumulative tokens, not one call — the
292    /// input-token count is not a context size: ten 50K-token calls sum to 500K
293    /// without any single call ever reaching a 272K threshold. Selecting a tier
294    /// from that sum would reprice the whole history at long-context rates a
295    /// call never paid, so aggregate repricing stays on the base band.
296    #[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/// Whether a price lookup may select a long-context tier — only valid when the
312/// usage describes one provider call.
313#[derive(Clone, Copy, PartialEq, Eq)]
314enum TierMode {
315    Apply,
316    Base,
317}
318
319/// Price `usage` from a resolved model: pick the band that applies, then
320/// decline if that band cannot price every component the usage bills.
321fn 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
333/// Whether `pricing` carries a rate for every usage component with a nonzero
334/// token count.
335///
336/// Feed rows are not guaranteed to be complete: a row that lists an input
337/// rate but no output rate would otherwise price a call's output tokens —
338/// typically the majority of its cost — at zero. Under-reporting is worse
339/// than declining: a caller that falls back to another pricing source (e.g.
340/// the static capability table) recovers the true cost, whereas a partial
341/// estimate silently understates spend and delays or defeats a cost budget.
342///
343/// Cache-read and cache-write tokens count as priced by their own rate *or* by
344/// the plain input rate, matching how [`Pricing::estimate_cost_usd`] bills
345/// them: both are input tokens, so the input rate is the approximation used
346/// when a source publishes no cache rate of its own. What no source may do is
347/// bill a component at zero.
348fn 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        // Override wins even though the feed also has this model.
453        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        // Feed satisfies a model only the feed knows.
458        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        // Static table satisfies a model the feed does not carry. `gpt-4o` is
463        // in the bundled static table but not in our trimmed fixture.
464        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        // Nothing knows this model.
469        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        // gpt-5.2 from the feed: $1.75/M input, $14/M output, $0.175/M cache_read.
482        // 1000 uncached input + 1000 cached input + 1000 output.
483        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        // (1000/1e6*1.75) + (1000/1e6*0.175) + (1000/1e6*14)
493        // = 0.00175 + 0.000175 + 0.014 = 0.015925
494        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        // `gpt-4o` is in the static table and in neither dynamic layer.
512        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        // A model the feed carries is priced by both lookups.
522        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    /// Cache-creation tokens are a component of `input_tokens`, and providers
538    /// charge a premium to write the cache (Anthropic: 1.25× input). Billing
539    /// them at the ordinary input rate under-reports every cache-warming call.
540    #[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                // models.dev: input 1, output 5, cache_read 0.1, cache_write 1.25.
551                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        // 1M input tokens = 200K plain + 300K cache-read + 500K cache-write,
558        // plus 1M output.
559        // 0.2*1 + 0.3*0.1 + 0.5*1.25 + 1*5 = 0.2 + 0.03 + 0.625 + 5 = 5.855.
560        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    /// A source with no cache-write rate bills those tokens at the ordinary
574    /// input rate — the provider-agnostic approximation the compiled-in table
575    /// has always used — rather than declining or billing them at zero.
576    #[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        // Every input token at $1/M, cache-write included: $1.00.
598        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    /// Long-context calls are billed at the tier the call falls in. Frontier
606    /// models roughly double their rates above the threshold, so pricing an
607    /// over-threshold call at the base rates halves the estimate.
608    #[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                // models.dev: base 2.5/15, tier from 272K context 5/22.5.
619                pricing: Some(Pricing::flat(2.5, 15.0)),
620                pricing_tiers: vec![PricingTier {
621                    // Inclusive bound: the tier applies from 272K upward.
622                    min_input_tokens: 272_000,
623                    pricing: Pricing::flat(5.0, 22.5),
624                }],
625                supports_thinking: None,
626            },
627        );
628
629        // Inside the base band: 200K in + 100K out = 0.2*2.5 + 0.1*15 = 2.0.
630        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        // Past the threshold: 300K in + 100K out = 0.3*5 + 0.1*22.5 = 3.75,
645        // where the base rates would have said 0.3*2.5 + 0.1*15 = 2.25.
646        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    /// The threshold is inclusive: a call *at* the tier size already pays the
663    /// tier rate, and one token below it still pays base.
664    #[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        // 271_999 / 1e6 * 2.5 = 0.6799975.
678        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        // 272_000 / 1e6 * 5.0 = 1.36.
693        assert!(
694            (tier_cost - 1.36).abs() < 1e-9,
695            "unexpected cost: {tier_cost}"
696        );
697        Ok(())
698    }
699
700    /// A summed usage is not a context size. Repricing a thread's aggregate
701    /// must stay on base rates, or ten short calls that each paid base rates
702    /// would be re-billed at the long-context tier they never reached — and a
703    /// healthy thread would be killed by a phantom `BudgetExceeded`.
704    #[test]
705    fn aggregate_repricing_never_selects_a_tier() -> Result<()> {
706        let registry = tiered_registry();
707
708        // Three 100K-token calls: 300K summed, but no call ever crossed 272K.
709        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        // Base: 0.3 * 2.5 = 0.75, not the tier's 0.3 * 5 = 1.50.
720        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    /// models.dev's gpt-5.4: base 2.5/15, tier from 272K context 5/22.5.
736    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                    // Inclusive bound: the tier applies from 272K upward.
748                    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        // A feed row that lists an input rate but no output rate: pricing the
759        // call from it would bill the output tokens at zero.
760        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        // The same row still prices a call that bills no output tokens.
795        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}