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;