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;