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