Skip to main content

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}