agent_sdk_providers/model_catalog.rs
1//! Dynamic model capability/pricing discovery from third-party feeds.
2//!
3//! The static [`model_capabilities`](crate::model_capabilities) table is the
4//! offline fallback: it ships in the binary and never makes a network call.
5//! This module layers a *refreshable* feed on top so newly shipped models gain
6//! pricing and limit metadata without an SDK code change.
7//!
8//! The pieces:
9//!
10//! - [`CatalogEntry`] — a single provider/model row distilled from a feed.
11//! - [`ModelCatalogSource`] — the trait a feed implements; each source pairs an
12//! async `fetch` with a pure `parse` so the parsing is testable offline.
13//! - [`ModelsDevSource`] (default) and [`OpenRouterSource`] — two concrete
14//! feeds.
15//! - [`ModelRegistry`] — a layered resolver: user override → feed cache →
16//! static table → graceful empty defaults.
17//!
18//! This whole module is gated behind the `model-discovery` cargo feature
19//! because it performs outbound HTTP to a third-party catalog. The default
20//! build is unchanged.
21
22use anyhow::{Context, Result};
23use async_trait::async_trait;
24use std::time::Duration;
25
26use crate::model_capabilities::Pricing;
27
28pub mod modelsdev;
29pub mod openrouter;
30pub mod registry;
31
32pub use modelsdev::{ModelsDevSource, parse_modelsdev};
33pub use openrouter::{OpenRouterSource, parse_openrouter};
34pub use registry::{ModelRegistry, ResolvedModel, ResolvedSource};
35
36/// Select the rates that apply to a call of `input_tokens`: the highest tier
37/// the call reaches, or the base rates when it reaches none.
38///
39/// The bound is inclusive — see [`PricingTier::min_input_tokens`]. `tiers` need
40/// not be sorted.
41#[must_use]
42pub fn applicable_pricing(base: Pricing, tiers: &[PricingTier], input_tokens: u32) -> Pricing {
43 tiers
44 .iter()
45 .filter(|tier| input_tokens >= tier.min_input_tokens)
46 .max_by_key(|tier| tier.min_input_tokens)
47 .map_or(base, |tier| tier.pricing)
48}
49
50pub(crate) const MODELS_DEV_URL: &str = "https://models.dev/api.json";
51pub(crate) const OPENROUTER_URL: &str = "https://openrouter.ai/api/v1/models";
52const FEED_TIMEOUT_SECS: u64 = 30;
53
54/// A single provider/model capability + pricing row distilled from a feed.
55///
56/// Field shapes mirror [`ModelCapabilities`](crate::model_capabilities::ModelCapabilities)
57/// so the registry can resolve feed rows and static rows into the same shape.
58#[derive(Debug, Clone, PartialEq)]
59pub struct CatalogEntry {
60 /// Our provider name (e.g. `anthropic`, `openai`, `gemini`).
61 pub provider: String,
62 /// The model identifier as the provider's chat endpoint expects it.
63 pub model_id: String,
64 /// Maximum total context window in tokens, when the feed reports it.
65 pub context_window: Option<u32>,
66 /// Maximum output tokens per response, when the feed reports it.
67 pub max_output_tokens: Option<u32>,
68 /// Base pricing distilled from the feed, when present. Applies to a call
69 /// whose context stays inside the base band — see [`pricing_tiers`](Self::pricing_tiers).
70 pub pricing: Option<Pricing>,
71 /// Context-tiered pricing, ascending by threshold, when the feed publishes
72 /// it. Empty for a model priced at one flat rate.
73 pub pricing_tiers: Vec<PricingTier>,
74 /// Whether the model is a reasoning/thinking model, when the feed reports it.
75 pub supports_thinking: Option<bool>,
76}
77
78/// A long-context price band: from `min_input_tokens` upwards, the provider
79/// bills the whole call at `pricing` instead of the base rates.
80///
81/// Frontier models price long context at a premium — GPT-5.x doubles its input
82/// rate above 272K tokens, Gemini and Claude above 200K — so a source that
83/// publishes tiers must be billed through them, or a long-context run is priced
84/// at a fraction of what it costs and sails past its budget.
85///
86/// The bound is **inclusive** on both feeds, confirmed against each one's
87/// documentation rather than inferred from a field name. `OpenRouter`'s
88/// provider docs state a tier "applies when input tokens meet or exceed the
89/// `min_context` value", so `min_prompt_tokens` maps straight across. The
90/// models.dev SDK schema documents `tier.size` as "the context size at which
91/// this tier starts to apply" / "pricing that applies from a given context size
92/// upward" — also inclusive, so its `size` maps straight across too. (The
93/// models.dev `context_over_<n>` field reads exclusive, but the schema marks it
94/// a legacy projection to be superseded by `tiers`, so it does not define the
95/// bound.)
96#[derive(Debug, Clone, Copy, PartialEq)]
97pub struct PricingTier {
98 /// The tier applies to a call whose input tokens reach this count.
99 pub min_input_tokens: u32,
100 /// The rates that replace the base rates for such a call. A source that
101 /// restates only some rates for the band leaves the rest inheriting the
102 /// base row, so this is the band's *effective* pricing, already merged.
103 pub pricing: Pricing,
104}
105
106/// Merge a band's stated rates over the base row: a rate the band restates
107/// wins, one it omits keeps its base value.
108///
109/// Both feeds publish partial bands — `OpenRouter`'s Gemini 2.5 Pro override
110/// raises `prompt`, `completion` and `input_cache_read` above 200K but says
111/// nothing about `input_cache_write`, and models.dev's tier for the same model
112/// omits `cache_write` too. Two independent feeds mirroring the vendor's table
113/// with the same omission is the vendor not restating that rate for the band,
114/// not the rate vanishing: the base value still applies. Inheriting it is
115/// therefore what the data means, and it is also the only reading that cannot
116/// silently under-bill a component.
117#[must_use]
118pub fn merge_band_over_base(base: Option<Pricing>, band: Pricing) -> Pricing {
119 let Some(base) = base else {
120 return band;
121 };
122 Pricing {
123 input: band.input.or(base.input),
124 output: band.output.or(base.output),
125 cached_input: band.cached_input.or(base.cached_input),
126 cache_write: band.cache_write.or(base.cache_write),
127 reasoning: band.reasoning.or(base.reasoning),
128 notes: band.notes.or(base.notes),
129 }
130}
131
132/// A third-party feed of [`CatalogEntry`] rows.
133#[async_trait]
134pub trait ModelCatalogSource: Send + Sync {
135 /// Fetch and parse the feed into catalog entries.
136 ///
137 /// # Errors
138 ///
139 /// Returns an error if the HTTP request fails or the body cannot be parsed.
140 async fn fetch(&self) -> Result<Vec<CatalogEntry>>;
141}
142
143pub(crate) fn build_feed_client() -> Result<reqwest::Client> {
144 reqwest::Client::builder()
145 .connect_timeout(Duration::from_secs(FEED_TIMEOUT_SECS))
146 .timeout(Duration::from_secs(FEED_TIMEOUT_SECS))
147 .build()
148 .context("failed to build model-catalog feed HTTP client")
149}