Skip to main content

rig_core/catalog/
mod.rs

1//! Model facts by provider and model id: limits, input modalities, reasoning, caching, prices.
2//!
3//! A catalog is built in layers, each sharing the rows it does not change:
4//! 1. [`Catalog::builtin`]: generated from models.dev by `cargo xtask catalog sync`, its main
5//!    providers reviewed by hand;
6//! 2. a newer models.dev copy ([`Catalog::from_models_dev`], lenient; laid on with
7//!    [`Catalog::with_models_dev`], which keeps the hand-reviewed fields);
8//! 3. the user's overrides ([`Catalog::from_overrides`], strict, with a close alternative for
9//!    every mistake; laid on with [`Catalog::with_overrides`]).
10//!
11//! A model connected earlier keeps the facts it was connected with.
12//!
13//! ```no_run
14//! use std::path::Path;
15//! use rig_core::catalog::Catalog;
16//!
17//! fn catalog(cache: &Path, config: &Path) -> Result<Catalog, Box<dyn std::error::Error>> {
18//!     let mut catalog = Catalog::builtin().clone();
19//!     let fetched = cache.join("models-dev.json");
20//!     if std::fs::metadata(&fetched)
21//!         .and_then(|file| file.modified())
22//!         .is_ok_and(|fetched_at| fetched_at > Catalog::generated_at())
23//!     {
24//!         let (models_dev, _skipped) = Catalog::from_models_dev(&std::fs::read_to_string(&fetched)?)?;
25//!         catalog = catalog.with_models_dev(&models_dev);
26//!     }
27//!     if let Ok(json) = std::fs::read_to_string(config.join("models.json")) {
28//!         catalog = catalog.with_overrides(&Catalog::from_overrides(&json, &catalog)?);
29//!     }
30//!     Ok(catalog)
31//! }
32//! ```
33//!
34//! [`Catalog::connect`] builds a model from `vendor/model` with the key from the environment; it
35//! encodes and prices with the [`ModelFacts`] it was connected with. [`ModelSpec::validate`]
36//! checks a request's options before anything is sent:
37//!
38//! ```
39//! use rig_core::catalog::Catalog;
40//! use rig_core::completion::{Effort, GenerationOptions};
41//!
42//! let haiku = Catalog::builtin().resolve("anthropic/claude-haiku-4-5")?.spec;
43//! let options = GenerationOptions::default().reasoning(Effort::High);
44//! assert!(haiku.validate(&options).is_err(), "Haiku 4.5 takes a budget");
45//! # Ok::<(), rig_core::catalog::NotFound>(())
46//! ```
47
48mod connector;
49mod details;
50mod facts;
51mod lookup;
52mod read;
53mod row;
54mod spec;
55
56use std::sync::{Arc, OnceLock};
57use std::time::{Duration, SystemTime, UNIX_EPOCH};
58
59use crate::providers::registry::{Format, ProviderId};
60use row::Row;
61
62pub use connector::{Connector, ModelStatus, Provide, SignIn, Transport};
63pub use details::{
64    CostDetails, CostTier, Interleaved, Mode, ModelDetails, ProviderOverride, Rates, Tier,
65};
66pub use facts::ModelFacts;
67pub use lookup::{Matched, NotFound, Resolved, edit_distance};
68pub use read::{OverrideError, OverrideErrorKind, OverrideErrors, SkipReason, Skipped};
69pub use spec::{
70    CacheSupport, ChatTools, Compat, Modalities, ModelSpec, Pricing, ReasoningChoice,
71    ReasoningSupport, Sampling, UnknownReasoning,
72};
73
74/// The checked-in catalog data, generated by `cargo xtask catalog sync`.
75const BUILTIN: &str = include_str!("models.json");
76
77/// When `cargo xtask catalog sync` read the models.dev data [`BUILTIN`] was
78/// generated from, in seconds since the Unix epoch. The sync writes it as
79/// an RFC 3339 UTC time; a file in any other form fails the build.
80const GENERATED_AT: u64 = match unix_seconds(include_str!("generated_at").trim_ascii_end()) {
81    Some(seconds) => seconds,
82    #[allow(clippy::panic)]
83    None => panic!("catalog/generated_at is not a `YYYY-MM-DDTHH:MM:SSZ` time"),
84};
85
86/// Model facts by provider and model id. Cloning is cheap: the rows are
87/// shared, and a change copies only the list of handles to them.
88#[derive(Clone, Debug, Default)]
89pub struct Catalog {
90    /// Sorted by vendor, then model id.
91    entries: Arc<Vec<Entry>>,
92}
93
94/// One model: its spec, and the row it was built from, which
95/// [`Catalog::with_overrides`] lays an override on.
96#[derive(Clone, Debug)]
97struct Entry {
98    row: Arc<Row>,
99    spec: Arc<ModelSpec>,
100}
101
102/// Why catalog data did not load.
103#[non_exhaustive]
104#[derive(Debug, thiserror::Error)]
105pub enum CatalogError {
106    /// The text is not a JSON object.
107    #[error("not a models.dev-style catalog: {0}")]
108    Json(#[from] serde_json::Error),
109}
110
111/// The models.dev provider keys whose rig vendor name differs. Any other key
112/// is read as a rig vendor name.
113const KEYS: [(&str, &str); 9] = [
114    ("azure", "azure.openai"),
115    ("google", "gcp.gemini"),
116    ("google-vertex", "vertexai"),
117    ("amazon-bedrock", "aws_bedrock"),
118    ("togetherai", "together"),
119    ("moonshotai", "moonshot"),
120    ("xiaomi", "xiaomimimo"),
121    ("ollama-cloud", "ollama"),
122    ("github-copilot", "copilot"),
123];
124
125impl Catalog {
126    /// The catalog this build ships.
127    ///
128    /// # Panics
129    ///
130    /// Panics on first use if the data compiled into this build does not
131    /// parse whole. The data is checked in and a unit test parses it, so a build
132    /// that passed its tests never panics here.
133    pub fn builtin() -> &'static Catalog {
134        static BUILTIN_CATALOG: OnceLock<Catalog> = OnceLock::new();
135        BUILTIN_CATALOG.get_or_init(|| match Catalog::from_models_dev(BUILTIN) {
136            Ok((catalog, skipped)) if skipped.is_empty() => catalog,
137            // A missing row would silently drop that model's facts, and an
138            // empty catalog every model's, so a bad build fails loudly
139            // instead. `the_checked_in_catalog_loads_whole` keeps these
140            // branches unreachable in a tested build.
141            #[allow(clippy::panic)]
142            Ok((_, skipped)) => panic!(
143                "rig-core's built-in catalog data skips {}: {:?}",
144                skipped.len(),
145                skipped.first()
146            ),
147            #[allow(clippy::panic)]
148            Err(error) => panic!("rig-core's built-in catalog data does not parse: {error}"),
149        })
150    }
151
152    /// When the models.dev data behind [`Self::builtin`] was read. Lay a
153    /// fetched copy on only when it is newer, so an old cache never hides
154    /// what this rig shipped.
155    pub fn generated_at() -> SystemTime {
156        UNIX_EPOCH + Duration::from_secs(GENERATED_AT)
157    }
158
159    /// The model `provider` serves as `model`, by the one rule every
160    /// lookup in rig follows: the id as listed, or else the longest listed
161    /// id that `model` extends with `-20` and a year, a dated snapshot of it
162    /// (`claude-sonnet-4-5-20250929`, `gpt-5-2025-08-07`,
163    /// `claude-opus-5-5-20260601-v1:0`). Any selection of a vendor finds
164    /// that vendor's models.
165    pub fn get(&self, provider: ProviderId, model: &str) -> Option<Resolved<'_>> {
166        self.get_vendor(provider.vendor(), model)
167    }
168
169    /// The model `provider` lists as exactly `model`, for a caller that
170    /// must not read a snapshot as its base model.
171    pub fn get_exact(&self, provider: ProviderId, model: &str) -> Option<&ModelSpec> {
172        self.exact(provider.vendor(), model)
173    }
174
175    /// The model a reference names, by [`Self::get`]'s rule. A reference is
176    /// `vendor/model`, split at the first `/`, so the model id keeps any
177    /// `/` and `:` of its own (`openrouter/anthropic/claude-sonnet-4.5`,
178    /// `ollama/qwen3:4b`). The older `vendor/format:model` spelling is still
179    /// read; its format plays no part in the lookup.
180    ///
181    /// # Errors
182    ///
183    /// [`NotFound`] when the reference names no listed model, with up to
184    /// five close references to models that are not deprecated, or else
185    /// the closest ones of its vendor.
186    pub fn resolve(&self, reference: &str) -> Result<Resolved<'_>, NotFound> {
187        Reference::parse(reference)
188            .and_then(|parsed| self.get_vendor(parsed.vendor, parsed.model))
189            .ok_or_else(|| self.not_found(reference))
190    }
191
192    /// The miss [`Self::resolve`] reports for `reference`.
193    pub(crate) fn not_found(&self, reference: &str) -> NotFound {
194        let mut suggestions = lookup::suggestions(reference, self.iter());
195        if suggestions.is_empty() {
196            suggestions = lookup::vendor_models(reference, self.iter());
197        }
198        NotFound {
199            reference: reference.to_owned(),
200            suggestions,
201        }
202    }
203
204    /// Every model, by vendor then id.
205    pub fn iter(&self) -> impl Iterator<Item = &ModelSpec> {
206        self.entries.iter().map(|entry| &*entry.spec)
207    }
208
209    /// This catalog with `overrides` laid over it, field by field. A model in
210    /// both keeps every field the override leaves out; a model only in
211    /// `overrides` is added. `self` is unchanged, and the result shares every
212    /// row the override does not touch.
213    ///
214    /// Every field the override sets wins, including the facts rig reviewed
215    /// by hand, so lay a fetched copy of models.dev on with
216    /// [`Self::with_models_dev`] instead.
217    pub fn with_overrides(&self, overrides: &Catalog) -> Catalog {
218        let mut catalog = self.clone();
219        for entry in overrides.entries.iter() {
220            catalog.put(
221                entry.spec.provider,
222                &entry.spec.id,
223                Row::clone(&entry.row),
224                Row::overlay,
225            );
226        }
227        catalog
228    }
229
230    /// This catalog with `fetched`, a newer copy of models.dev read with
231    /// [`Self::from_models_dev`], laid over it as [`Self::with_overrides`]
232    /// lays an override, except for the fields of a row that rig's own data
233    /// pins. The built-in data pins the fields rig reviewed by hand and the
234    /// ones its generator joins from another row (a gateway's Claude row
235    /// takes Anthropic's reasoning options), so a refresh updates prices and
236    /// limits models.dev changes but keeps those. A model only in `fetched`
237    /// is added as models.dev lists it.
238    pub fn with_models_dev(&self, fetched: &Catalog) -> Catalog {
239        let mut catalog = self.clone();
240        for entry in fetched.entries.iter() {
241            let (provider, id) = (entry.spec.provider, entry.spec.id.as_str());
242            let row = Row::clone(&entry.row);
243            let row = match catalog.position(provider.vendor(), id) {
244                Ok(index) => match catalog.entries.get(index) {
245                    Some(base) => row.without(base.row.pinned()),
246                    None => row,
247                },
248                Err(_) => row,
249            };
250            catalog.put(provider, id, row, Row::refresh);
251        }
252        catalog
253    }
254
255    /// Add `spec`, replacing any model of the same vendor and id whole. A
256    /// later [`Self::with_overrides`] lays an override on it field by field,
257    /// as on any other row.
258    pub fn insert(&mut self, spec: ModelSpec) {
259        let position = self.position(spec.provider.vendor(), &spec.id);
260        let entry = Entry {
261            row: Arc::new(Row::from_spec(&spec)),
262            spec: Arc::new(spec),
263        };
264        let entries = Arc::make_mut(&mut self.entries);
265        match position {
266            Ok(index) => {
267                if let Some(slot) = entries.get_mut(index) {
268                    *slot = entry;
269                }
270            }
271            Err(index) => entries.insert(index, entry),
272        }
273    }
274
275    /// [`Self::get`] by vendor name.
276    pub(crate) fn get_vendor(&self, vendor: &str, model: &str) -> Option<Resolved<'_>> {
277        if let Some(shared) = self.entry(vendor, model) {
278            return Some(Resolved {
279                spec: shared,
280                matched: Matched::Exact,
281                shared,
282            });
283        }
284        lookup::snapshot_bases(model).find_map(|base| {
285            self.entry(vendor, base).map(|shared| Resolved {
286                spec: shared,
287                matched: Matched::SnapshotOf(shared.id.clone()),
288                shared,
289            })
290        })
291    }
292
293    fn exact(&self, vendor: &str, model: &str) -> Option<&ModelSpec> {
294        self.entry(vendor, model).map(|spec| &**spec)
295    }
296
297    fn entry(&self, vendor: &str, model: &str) -> Option<&Arc<ModelSpec>> {
298        self.position(vendor, model)
299            .ok()
300            .and_then(|index| self.entries.get(index))
301            .map(|entry| &entry.spec)
302    }
303
304    fn position(&self, vendor: &str, model: &str) -> Result<usize, usize> {
305        self.entries.binary_search_by(|entry| {
306            (entry.spec.provider.vendor(), entry.spec.id.as_str()).cmp(&(vendor, model))
307        })
308    }
309
310    /// Add `row` for `provider`'s `id`, laid over any row already there.
311    fn put(&mut self, provider: ProviderId, id: &str, row: Row, merge: fn(Row, Row) -> Row) {
312        let position = self.position(provider.vendor(), id);
313        let entries = Arc::make_mut(&mut self.entries);
314        match position {
315            Ok(index) => {
316                if let Some(entry) = entries.get_mut(index) {
317                    let row = merge(Row::clone(&entry.row), row);
318                    entry.spec = Arc::new(row.spec(entry.spec.provider, id));
319                    entry.row = Arc::new(row);
320                }
321            }
322            Err(index) => {
323                let spec = Arc::new(row.spec(provider, id));
324                entries.insert(
325                    index,
326                    Entry {
327                        row: Arc::new(row),
328                        spec,
329                    },
330                );
331            }
332        }
333    }
334}
335
336/// The seconds since the Unix epoch at `time`, written
337/// `YYYY-MM-DDTHH:MM:SSZ`, or `None` when it is not in that form.
338const fn unix_seconds(time: &str) -> Option<u64> {
339    const fn number(mut digits: &[u8]) -> Option<u64> {
340        let mut value = 0;
341        while let [digit, rest @ ..] = digits {
342            if !digit.is_ascii_digit() {
343                return None;
344            }
345            value = value * 10 + (*digit - b'0') as u64;
346            digits = rest;
347        }
348        Some(value)
349    }
350    let &[
351        y0,
352        y1,
353        y2,
354        y3,
355        b'-',
356        m0,
357        m1,
358        b'-',
359        d0,
360        d1,
361        b'T',
362        h0,
363        h1,
364        b':',
365        n0,
366        n1,
367        b':',
368        s0,
369        s1,
370        b'Z',
371    ] = time.as_bytes()
372    else {
373        return None;
374    };
375    let (Some(year), Some(month), Some(day), Some(hour), Some(minute), Some(second)) = (
376        number(&[y0, y1, y2, y3]),
377        number(&[m0, m1]),
378        number(&[d0, d1]),
379        number(&[h0, h1]),
380        number(&[n0, n1]),
381        number(&[s0, s1]),
382    ) else {
383        return None;
384    };
385    if year < 1970 || month < 1 || month > 12 || day < 1 || day > 31 {
386        return None;
387    }
388    if hour > 23 || minute > 59 || second > 59 {
389        return None;
390    }
391    // Days from the civil date, by Howard Hinnant's algorithm, with the
392    // year starting in March so the leap day falls last.
393    let year = if month <= 2 { year - 1 } else { year };
394    let era = year / 400;
395    let of_era = year - era * 400;
396    let shifted_month = if month > 2 { month - 3 } else { month + 9 };
397    let of_year = (153 * shifted_month + 2) / 5 + day - 1;
398    let of_era_days = of_era * 365 + of_era / 4 - of_era / 100 + of_year;
399    let days = era * 146_097 + of_era_days - 719_468;
400    Some(days * 86_400 + hour * 3600 + minute * 60 + second)
401}
402
403/// The rig vendor a models.dev provider key names.
404fn vendor_of(key: &str) -> &str {
405    KEYS.iter()
406        .find_map(|(models_dev, vendor)| (*models_dev == key).then_some(*vendor))
407        .unwrap_or(key)
408}
409
410/// A model reference as text spells it: `vendor/model`, or the older
411/// `vendor/format:model`, which also names a protocol family. Every reader
412/// of a reference string in rig parses it here.
413#[derive(Clone, Copy, Debug, PartialEq, Eq)]
414pub(crate) struct Reference<'a> {
415    /// The text before the first `/`.
416    pub(crate) vendor: &'a str,
417    /// The provider's model id.
418    pub(crate) model: &'a str,
419    /// The family the older spelling names, if it was used.
420    pub(crate) format: Option<Format>,
421}
422
423impl<'a> Reference<'a> {
424    /// `text` split at its first `/`, or `None` when either side is empty.
425    /// The rest is read as `format:model` only when the text before its
426    /// first `:` is a [`Format`]'s name.
427    pub(crate) fn parse(text: &'a str) -> Option<Self> {
428        let (vendor, rest) = text.split_once('/')?;
429        if vendor.is_empty() || rest.is_empty() {
430            return None;
431        }
432        if let Some((format, model)) = rest.split_once(':')
433            && let Some(format) = Format::named(format)
434        {
435            return (!model.is_empty()).then_some(Self {
436                vendor,
437                model,
438                format: Some(format),
439            });
440        }
441        Some(Self {
442            vendor,
443            model: rest,
444            format: None,
445        })
446    }
447}
448
449#[cfg(test)]
450mod tests;