Skip to main content

rig_core/catalog/
spec.rs

1//! What the catalog knows about one model, and the checks it makes against
2//! [`GenerationOptions`].
3
4use std::ops::RangeInclusive;
5
6use serde::{Deserialize, Serialize};
7
8use crate::completion::options::CatalogRefusal;
9use crate::completion::{
10    CacheRetention, CompletionRequest, Cost, Effort, GenerationOptions, Reasoning,
11    UnsupportedOption, Usage,
12};
13use crate::providers::registry::{Format, ProviderId};
14
15use super::details::ModelDetails;
16
17/// One model's facts: limits, input modalities, the reasoning and caching it
18/// takes, and its prices. [`Catalog`](super::Catalog) builds one per row of
19/// its data; [`ModelSpec::new`] and the `with_*` setters build one in code,
20/// for a model the catalog does not list, and
21/// [`Catalog::insert`](super::Catalog::insert) adds it.
22///
23/// It serializes field by field and reads back the same. For the override
24/// file's row shape, use [`Self::to_row_json`].
25///
26/// ```
27/// use rig_core::catalog::{Catalog, ModelSpec, Pricing};
28/// use rig_core::providers::registry::ProviderId;
29///
30/// let ollama = ProviderId::catalog("ollama").ok_or("a known vendor")?;
31/// let mut catalog = Catalog::builtin().clone();
32/// catalog.insert(
33///     ModelSpec::new(ollama, "qwen3:4b")
34///         .with_context_window(32_768)
35///         .with_tools(true)
36///         .with_pricing(Pricing::new(0.0, 0.0)),
37/// );
38/// let qwen = catalog.get(ollama, "qwen3:4b").ok_or("inserted")?.spec;
39/// assert_eq!(qwen.context_window, Some(32_768));
40/// # Ok::<(), &str>(())
41/// ```
42#[non_exhaustive]
43#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
44pub struct ModelSpec {
45    /// The provider's own model id.
46    pub id: String,
47    /// The provider serving the model under [`Self::id`], in the protocol
48    /// family the model is reached by: its vendor's own
49    /// ([`ProviderId::catalog`]) unless the catalog row names another
50    /// under `rig.format`. [`Self::format`] reads it.
51    pub provider: ProviderId,
52    /// A human-readable name.
53    pub display_name: String,
54    /// The most tokens the model reads and writes in one request, if known.
55    pub context_window: Option<u32>,
56    /// The most tokens the model reads in one request, when the provider
57    /// caps input below [`Self::context_window`]. [`Self::input_limit`]
58    /// reads both.
59    #[serde(default)]
60    pub max_input_tokens: Option<u32>,
61    /// The most tokens the model writes in one reply, if known.
62    pub max_output_tokens: Option<u32>,
63    /// What the model reads.
64    pub input: Modalities,
65    /// The reasoning the model takes.
66    pub reasoning: ReasoningSupport,
67    /// The cache retentions the model honours.
68    pub caching: CacheSupport,
69    /// Whether the model calls tools.
70    pub tools: bool,
71    /// Whether the model constrains its output to a JSON schema.
72    pub structured_output: bool,
73    /// Prices in USD per million tokens, if known.
74    pub pricing: Option<Pricing>,
75    /// Whether the provider has deprecated the model.
76    pub deprecated: bool,
77    /// When the model takes sampling parameters (`temperature`, `top_p`,
78    /// `top_logprobs`, `logprobs`), or `None` when unknown.
79    pub sampling: Option<Sampling>,
80    /// Wire facts the encoders read that no portable field holds.
81    pub compat: Compat,
82    /// What the catalog records about the model that rig does not act on.
83    #[serde(default)]
84    pub details: ModelDetails,
85}
86
87/// What a model reads.
88#[non_exhaustive]
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
90pub struct Modalities {
91    /// Text.
92    pub text: bool,
93    /// Images.
94    pub image: bool,
95    /// Audio.
96    pub audio: bool,
97    /// Video.
98    pub video: bool,
99    /// PDF documents.
100    pub pdf: bool,
101}
102
103/// The reasoning a model takes, in one of three states: it does not reason,
104/// it reasons but the catalog does not say which controls it takes, or it
105/// reasons and takes exactly the listed controls.
106///
107/// A catalog row that marks a model as reasoning but lists no reasoning
108/// options (most of them gateway rows on HuggingFace, OpenRouter, Venice and
109/// Bedrock) is [`Self::Unknown`], which refuses nothing, as an empty
110/// [`CacheSupport`] does. Only a row whose hand-reviewed `rig` facts say
111/// `"reasoning_control": "none"` (a model whose API rejects every reasoning
112/// control) is [`Self::Listed`] with nothing listed.
113///
114/// ```
115/// use rig_core::catalog::ReasoningSupport;
116/// use rig_core::completion::{Effort, Reasoning};
117///
118/// let unknown = ReasoningSupport::Unknown { default: None };
119/// assert!(unknown.supported() && unknown.levels().is_none());
120/// assert_eq!(unknown.refusal(&Reasoning::Effort(Effort::High)), None);
121///
122/// let listed = ReasoningSupport::Listed {
123///     levels: vec![Effort::Low, Effort::High],
124///     budget: None,
125///     can_disable: false,
126///     default: Some(Effort::High),
127/// };
128/// assert!(listed.refusal(&Reasoning::Off).is_some());
129/// assert_eq!(listed.refusal(&Reasoning::Effort(Effort::Low)), None);
130/// ```
131#[non_exhaustive]
132#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134pub enum ReasoningSupport {
135    /// The model does not reason.
136    #[default]
137    None,
138    /// The model reasons; the catalog does not say which controls it takes.
139    /// Nothing is refused.
140    Unknown {
141        /// The effort it uses when a request names none, if documented.
142        default: Option<Effort>,
143    },
144    /// The model reasons and takes exactly these controls.
145    Listed {
146        /// The effort levels it takes, empty when it takes none.
147        levels: Vec<Effort>,
148        /// The reasoning-token budgets it takes, if it takes one.
149        budget: Option<RangeInclusive<u32>>,
150        /// Whether a request can turn its reasoning off.
151        can_disable: bool,
152        /// The effort it uses when a request names none, if documented.
153        default: Option<Effort>,
154    },
155}
156
157/// The cache retentions a model honours.
158#[non_exhaustive]
159#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
160pub struct CacheSupport {
161    /// The [`CacheRetention`] values the model honours. Empty when the
162    /// catalog does not know, in which case nothing is refused.
163    pub retention: Vec<CacheRetention>,
164}
165
166/// Prices in USD per million tokens.
167#[non_exhaustive]
168#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
169pub struct Pricing {
170    /// Uncached input.
171    pub input: f64,
172    /// Output, reasoning included.
173    pub output: f64,
174    /// Input read from the cache, or `None` when unknown.
175    pub cache_read: Option<f64>,
176    /// Input written to the cache, or `None` when unknown.
177    pub cache_write: Option<f64>,
178}
179
180impl Pricing {
181    /// Prices for uncached input and for output, with no cache prices.
182    pub fn new(input: f64, output: f64) -> Self {
183        Self {
184            input,
185            output,
186            cache_read: None,
187            cache_write: None,
188        }
189    }
190
191    /// These prices with `price` for input read from the cache.
192    pub fn with_cache_read(mut self, price: f64) -> Self {
193        self.cache_read = Some(price);
194        self
195    }
196
197    /// These prices with `price` for input written to the cache.
198    pub fn with_cache_write(mut self, price: f64) -> Self {
199        self.cache_write = Some(price);
200        self
201    }
202
203    /// What `usage` costs at these prices, or `None` unless it reports both
204    /// its input and output tokens. Uncached input is the input tokens less
205    /// those read from and written to the cache. A cache part with tokens
206    /// but no listed price is `None`, and the cost is then not
207    /// [complete](Cost::is_complete): its `total` leaves that part out. Every
208    /// cache write here is charged at one price. Native Anthropic response
209    /// pricing additionally uses the raw reply's TTL breakdown: five-minute
210    /// writes use `cache_write`, one-hour writes use twice `input`, and an
211    /// absent or inconsistent breakdown leaves nonzero writes unpriced.
212    /// These are standard-tier API list-price estimates, not subscription
213    /// charges or verified invoices, including for custom endpoints. Service
214    /// tiers, long-context tiers and hosted-tool fees are not included.
215    pub fn cost(&self, usage: &Usage) -> Option<Cost> {
216        let written = usage.cache_creation_input_tokens.unwrap_or(0);
217        let write_cost = match written {
218            0 => Some(0.0),
219            tokens => self
220                .cache_write
221                .map(|rate| tokens as f64 * rate / 1_000_000.0),
222        };
223        self.cost_with_cache_write(usage, write_cost)
224    }
225
226    /// Price the counters with a separately calculated cache-write cost in
227    /// USD. `None` leaves the write part unknown and the total incomplete.
228    pub(crate) fn cost_with_cache_write(
229        &self,
230        usage: &Usage,
231        write_cost: Option<f64>,
232    ) -> Option<Cost> {
233        let input = usage.input_tokens?;
234        let output = usage.output_tokens?;
235        let read = usage.cached_input_tokens.unwrap_or(0);
236        let written = usage.cache_creation_input_tokens.unwrap_or(0);
237        let uncached = input.saturating_sub(read).saturating_sub(written);
238        // Token counts stay far below 2^53, so the conversion is exact.
239        let price = |tokens: u64, per_million: f64| tokens as f64 * per_million / 1_000_000.0;
240        // No tokens cost nothing, whether or not the rate is listed.
241        let cache = |tokens: u64, rate: Option<f64>| match (tokens, rate) {
242            (0, _) => Some(0.0),
243            (tokens, rate) => rate.map(|rate| price(tokens, rate)),
244        };
245        Some(Cost::priced(
246            price(uncached, self.input),
247            price(output, self.output),
248            cache(read, self.cache_read),
249            write_cost,
250        ))
251    }
252}
253
254/// When a model takes sampling parameters.
255#[non_exhaustive]
256#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
257#[serde(rename_all = "snake_case")]
258pub enum Sampling {
259    /// Always.
260    Any,
261    /// Only while its reasoning is off.
262    ReasoningOff,
263    /// Never.
264    Never,
265}
266
267/// Wire facts the encoders read that no portable field holds. Each defaults
268/// to `false` or `None`, which is what a model the field does not concern
269/// has. An override file sets each one by its field name under a row's
270/// `rig` object, and a spec built in code sets it with the `with_*` setter
271/// of the same name.
272#[non_exhaustive]
273#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
274pub struct Compat {
275    /// The field an OpenAI Chat Completions assistant message must carry
276    /// its reasoning under, such as `reasoning_content`.
277    pub reasoning_field: Option<String>,
278    /// Anthropic: an effort level goes with `thinking: {"type": "adaptive"}`.
279    pub adaptive_thinking: bool,
280    /// Anthropic: the `thinking.type` that turns reasoning off, where it is
281    /// not `disabled`.
282    pub thinking_off: Option<String>,
283    /// Anthropic: the model takes `role: "system"` inside `messages`.
284    pub mid_conversation_system: bool,
285    /// Anthropic: the model answers a forced `tool_choice` with an error.
286    pub rejects_forced_tool_choice: bool,
287    /// Anthropic: the model binds its thinking to the request's tools and
288    /// system prompt.
289    pub binds_context: bool,
290    /// OpenAI: the prompt cache takes `prompt_cache_options`, not
291    /// `prompt_cache_retention`.
292    pub prompt_cache_options: bool,
293    /// OpenAI: Chat Completions takes the model's tools only while its
294    /// reasoning is off.
295    pub chat_tools_need_reasoning_off: bool,
296    /// The model continues a request that ends in an assistant message (a
297    /// prefill) from where that message stops, instead of refusing it, as
298    /// Anthropic's models before Claude 4.6 do while their reasoning is
299    /// off. Unknown is `false`.
300    pub prefill: bool,
301}
302
303impl Compat {
304    /// These facts with `field` as the Chat Completions assistant message's
305    /// reasoning field.
306    pub fn with_reasoning_field(mut self, field: impl Into<String>) -> Self {
307        self.reasoning_field = Some(field.into());
308        self
309    }
310
311    /// These facts with prefill continuation set to `on`.
312    pub fn with_prefill(mut self, on: bool) -> Self {
313        self.prefill = on;
314        self
315    }
316}
317
318impl CacheSupport {
319    /// Support for exactly the retentions in `retention`. An empty list means
320    /// the catalog does not know, and refuses nothing.
321    pub fn new(retention: impl IntoIterator<Item = CacheRetention>) -> Self {
322        Self {
323            retention: retention.into_iter().collect(),
324        }
325    }
326}
327
328impl ModelSpec {
329    /// A spec for `provider`'s model `id` with nothing else known: its id as
330    /// its name, no limits, text input only, no reasoning, unknown caching,
331    /// no tools or structured output, no prices (`None`, never zero), not
332    /// deprecated, unknown sampling, default [`Compat`] and no
333    /// [`ModelDetails`].
334    pub fn new(provider: ProviderId, id: impl Into<String>) -> Self {
335        let id = id.into();
336        Self {
337            display_name: id.clone(),
338            id,
339            provider,
340            context_window: None,
341            max_input_tokens: None,
342            max_output_tokens: None,
343            input: Modalities {
344                text: true,
345                ..Modalities::default()
346            },
347            reasoning: ReasoningSupport::default(),
348            caching: CacheSupport::default(),
349            tools: false,
350            structured_output: false,
351            pricing: None,
352            deprecated: false,
353            sampling: None,
354            compat: Compat::default(),
355            details: ModelDetails::default(),
356        }
357    }
358
359    /// The protocol family a connection to this model speaks unless told
360    /// otherwise, or `None` for a provider the registry cannot configure.
361    /// To reach the model in another family its vendor is registered for,
362    /// build the spec with that [`ProviderId`] or pass the family to
363    /// [`ConnectOptions::format`](crate::providers::registry::ConnectOptions::format).
364    pub fn format(&self) -> Option<Format> {
365        self.provider.format()
366    }
367
368    /// The `vendor/model` reference [`Catalog::resolve`](super::Catalog::resolve)
369    /// reads back to this model.
370    pub fn reference(&self) -> String {
371        format!("{}/{}", self.provider.vendor(), self.id)
372    }
373
374    /// The options of a request to this model with `reasoning` (`None`
375    /// for the provider's default): that reasoning, and the short prompt
376    /// cache when [`Self::caching`] lists it. A model whose caching the
377    /// catalog does not know gets no cache option, which it could refuse.
378    pub fn default_options(&self, reasoning: Option<Reasoning>) -> GenerationOptions {
379        let options = GenerationOptions {
380            reasoning,
381            ..GenerationOptions::default()
382        };
383        if self.caching.retention.contains(&CacheRetention::Short) {
384            options.cache(CacheRetention::Short)
385        } else {
386            options
387        }
388    }
389
390    /// This spec named `name`.
391    pub fn with_display_name(mut self, name: impl Into<String>) -> Self {
392        self.display_name = name.into();
393        self
394    }
395
396    /// This spec with a context window of `tokens`.
397    pub fn with_context_window(mut self, tokens: u32) -> Self {
398        self.context_window = Some(tokens);
399        self
400    }
401
402    /// This spec reading at most `tokens` in one request.
403    pub fn with_max_input_tokens(mut self, tokens: u32) -> Self {
404        self.max_input_tokens = Some(tokens);
405        self
406    }
407
408    /// The most tokens a request to this model may send: its input limit
409    /// when that is below its window, else its window; `None` when neither
410    /// is known.
411    pub fn input_limit(&self) -> Option<u32> {
412        match (self.max_input_tokens, self.context_window) {
413            (Some(input), Some(window)) => Some(input.min(window)),
414            (input, window) => input.or(window),
415        }
416    }
417
418    /// This spec writing at most `tokens` in one reply.
419    pub fn with_max_output_tokens(mut self, tokens: u32) -> Self {
420        self.max_output_tokens = Some(tokens);
421        self
422    }
423
424    /// This spec taking `reasoning`.
425    pub fn with_reasoning(mut self, reasoning: ReasoningSupport) -> Self {
426        self.reasoning = reasoning;
427        self
428    }
429
430    /// This spec honouring `caching`.
431    pub fn with_caching(mut self, caching: CacheSupport) -> Self {
432        self.caching = caching;
433        self
434    }
435
436    /// This spec with tool calling set to `tools`.
437    pub fn with_tools(mut self, tools: bool) -> Self {
438        self.tools = tools;
439        self
440    }
441
442    /// This spec priced at `pricing`.
443    pub fn with_pricing(mut self, pricing: Pricing) -> Self {
444        self.pricing = Some(pricing);
445        self
446    }
447
448    /// This spec taking sampling parameters as `sampling` says.
449    pub fn with_sampling(mut self, sampling: Sampling) -> Self {
450        self.sampling = Some(sampling);
451        self
452    }
453
454    /// This spec with the wire facts `compat`.
455    pub fn with_compat(mut self, compat: Compat) -> Self {
456        self.compat = compat;
457        self
458    }
459
460    /// This spec with the catalog's other records `details`.
461    pub fn with_details(mut self, details: ModelDetails) -> Self {
462        self.details = details;
463        self
464    }
465
466    /// This spec as one models.dev-shaped row of an override file, with
467    /// rig's facts under `rig`. Read back under its vendor key, the row
468    /// builds this spec again. A fact the spec does not know (a `None`
469    /// limit, price, sampling rule or reasoning default, or empty caching)
470    /// is left out, so laid over another row it keeps that row's value.
471    pub fn to_row_json(&self) -> serde_json::Value {
472        super::row::Row::from_spec(self).to_json()
473    }
474
475    /// Checks `options` against the rules [`Self::refusals`] applies,
476    /// and returns the first refusal. It checks only what `options` holds,
477    /// so it never refuses `temperature`, which a request carries outside
478    /// its options; [`Self::refusals`] checks the whole request.
479    ///
480    /// # Errors
481    ///
482    /// The first option the model cannot take. The error names the option,
483    /// the provider's vendor and this model.
484    pub fn validate(&self, options: &GenerationOptions) -> Result<(), UnsupportedOption> {
485        match self.rules(options, None).into_iter().next() {
486            Some(refusal) => Err(self.unsupported(refusal)),
487            None => Ok(()),
488        }
489    }
490
491    /// Every option of `request` the model cannot take, by the rules every
492    /// completion wire also applies before it encodes, so a request this
493    /// refuses is refused by the model's wire too:
494    ///
495    /// - `reasoning` against [`Self::reasoning`] ([`ReasoningSupport::refusal`]);
496    /// - `cache` against [`Self::caching`] ([`CacheSupport::refusal`]);
497    /// - `top_p` and `temperature` against [`Self::sampling`]:
498    ///   [`Sampling::Never`] refuses both, and [`Sampling::ReasoningOff`]
499    ///   refuses both while the model reasons. A model that cannot turn
500    ///   reasoning off always reasons; one that can reasons unless
501    ///   `reasoning` is `Off`, and with no `reasoning` set, when it names a
502    ///   default effort.
503    ///
504    /// A request whose [`GenerationOptions`] are default is not checked, as
505    /// [`GenerationOptions::is_default`] says, so this is empty for it. A
506    /// wire refuses more than this: what its route or API cannot carry
507    /// (`DynModel::check` reports both).
508    pub fn refusals(&self, request: &CompletionRequest) -> Vec<UnsupportedOption> {
509        self.request_rules(request)
510            .into_iter()
511            .map(|refusal| self.unsupported(refusal))
512            .collect()
513    }
514
515    /// Fits `request` to this model: takes out each option it would refuse
516    /// ([`Self::refusals`]) and leaves everything else as it is, so a request
517    /// built or edited for another model keeps what this one takes. A refused
518    /// reasoning setting becomes the provider default (`None`) first, since
519    /// whether the model reasons decides whether it takes `temperature` and
520    /// `top_p`. Returns what was taken out, in that order; afterwards
521    /// [`Self::refusals`] of the request is empty.
522    ///
523    /// ```
524    /// use rig_core::catalog::{ModelSpec, ReasoningSupport};
525    /// use rig_core::completion::{CompletionRequest, Effort, GenerationOptions, Reasoning};
526    /// use rig_core::providers::registry::ProviderId;
527    ///
528    /// let vendor = ProviderId::catalog("ollama").ok_or("a known vendor")?;
529    /// let plain = ModelSpec::new(vendor, "plain").with_reasoning(ReasoningSupport::None);
530    /// let options = GenerationOptions::default().reasoning(Reasoning::Effort(Effort::High));
531    /// let mut request = CompletionRequest::new("hi").options(options.seed(7));
532    /// let dropped = plain.fit(&mut request);
533    /// assert_eq!(dropped.len(), 1);
534    /// assert_eq!((request.options.reasoning, request.options.seed), (None, Some(7)));
535    /// # Ok::<(), &str>(())
536    /// ```
537    pub fn fit(&self, request: &mut CompletionRequest) -> Vec<UnsupportedOption> {
538        let mut dropped = Vec::new();
539        // Only a set option is refused, and each pass unsets one, so this ends.
540        while let Some(refusal) = self.request_rules(request).into_iter().next() {
541            let options = &mut request.options;
542            match refusal.field {
543                "reasoning" => options.reasoning = None,
544                "cache" => options.cache = None,
545                "top_p" => options.top_p = None,
546                "temperature" => request.temperature = None,
547                _ => break,
548            }
549            dropped.push(self.unsupported(refusal));
550        }
551        dropped
552    }
553
554    /// [`Self::refusals`] as the options and reasons the wires report under
555    /// their own provider name.
556    pub(crate) fn request_rules(&self, request: &CompletionRequest) -> Vec<CatalogRefusal> {
557        if request.options.is_default() {
558            return Vec::new();
559        }
560        self.rules(&request.options, request.temperature)
561    }
562
563    /// The refusals of `options` and of a request's `temperature`.
564    fn rules(&self, options: &GenerationOptions, temperature: Option<f64>) -> Vec<CatalogRefusal> {
565        let mut refusals = Vec::new();
566        if let Some(reason) = options
567            .reasoning
568            .as_ref()
569            .and_then(|reasoning| self.reasoning.refusal(reasoning))
570        {
571            refusals.push(CatalogRefusal {
572                field: "reasoning",
573                reason,
574            });
575        }
576        if let Some(reason) = options
577            .cache
578            .as_ref()
579            .and_then(|cache| self.caching.refusal(cache))
580        {
581            refusals.push(CatalogRefusal {
582                field: "cache",
583                reason,
584            });
585        }
586        let reasons = self.reasons_with(options.reasoning.as_ref());
587        let sampled = [
588            ("temperature", temperature.is_some()),
589            ("top_p", options.top_p.is_some()),
590        ];
591        for (field, set) in sampled {
592            if let Some(reason) = set.then(|| self.sampling_refusal(field, reasons)).flatten() {
593                refusals.push(CatalogRefusal { field, reason });
594            }
595        }
596        refusals
597    }
598
599    /// Why the model takes no `field`, a sampling parameter, when it reasons
600    /// as `reasons` says, by [`Self::sampling`].
601    pub(crate) fn sampling_refusal(&self, field: &str, reasons: bool) -> Option<String> {
602        match self.sampling? {
603            Sampling::Never => Some(format!("the model takes no `{field}`")),
604            Sampling::ReasoningOff if reasons => {
605                Some(match self.reasoning.can_disable() == Some(true) {
606                    true => format!(
607                        "the model rejects `{field}` while it reasons; remove `{field}` or set \
608                         `reasoning` to `Off`"
609                    ),
610                    false => format!("the model rejects `{field}` while it reasons"),
611                })
612            }
613            Sampling::ReasoningOff | Sampling::Any => None,
614        }
615    }
616
617    /// Whether the model reasons when a request asks for `reasoning`: at
618    /// `Off`, only when it cannot turn reasoning off; with nothing asked, as
619    /// [`Self::reasons_by_default`] says.
620    pub(crate) fn reasons_with(&self, reasoning: Option<&Reasoning>) -> bool {
621        match reasoning {
622            Some(Reasoning::Off) => {
623                self.reasoning.supported() && self.reasoning.can_disable() == Some(false)
624            }
625            Some(_) => self.reasoning.supported(),
626            None => self.reasons_by_default(),
627        }
628    }
629
630    /// Whether the model reasons when a request asks for nothing: when it
631    /// names a default effort or cannot turn reasoning off.
632    pub(crate) fn reasons_by_default(&self) -> bool {
633        self.reasoning.supported()
634            && (self.reasoning.default_effort().is_some()
635                || self.reasoning.can_disable() == Some(false))
636    }
637
638    /// `refusal` named for this model.
639    fn unsupported(&self, refusal: CatalogRefusal) -> UnsupportedOption {
640        UnsupportedOption::new(
641            refusal.field,
642            self.provider.vendor(),
643            &self.id,
644            refusal.reason,
645        )
646    }
647}
648
649/// A reasoning setting a model takes, by the name a picker shows: the
650/// provider default (`default`, no setting), `off`, an effort level, or a
651/// named budget.
652#[derive(Clone, Copy, Debug, PartialEq, Eq)]
653pub struct ReasoningChoice {
654    /// `default`, `off`, the level's word, or the budget's name.
655    pub name: &'static str,
656    /// The setting a request carries; `None` for the provider default.
657    pub reasoning: Option<Reasoning>,
658}
659
660impl ReasoningChoice {
661    /// The name, with a budget's tokens.
662    pub fn label(&self) -> String {
663        match self.reasoning {
664            Some(Reasoning::Budget { tokens }) => format!("{} ({tokens} tokens)", self.name),
665            _ => self.name.to_owned(),
666        }
667    }
668}
669
670/// A reasoning setting a model does not take, and the ones it does.
671#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
672#[error("the reasoning settings are {}, not `{name}`", choices.join(", "))]
673pub struct UnknownReasoning {
674    /// The name asked for.
675    pub name: String,
676    /// The names of the model's [`ReasoningSupport::choices`].
677    pub choices: Vec<&'static str>,
678}
679
680/// Token budgets for the named levels on a model that takes a budget
681/// instead of levels, clamped into its range.
682const NAMED_BUDGETS: [(&str, u32); 3] = [("low", 2048), ("medium", 8192), ("high", 16384)];
683
684impl ReasoningSupport {
685    /// The reasoning settings the model takes: the provider default first,
686    /// then `off` when reasoning can be turned off, then each effort level,
687    /// or `low`, `medium` and `high` budgets on a model that takes a budget
688    /// and no levels. A model whose controls the catalog does not list
689    /// offers only the default.
690    pub fn choices(&self) -> Vec<ReasoningChoice> {
691        let mut choices = vec![ReasoningChoice {
692            name: "default",
693            reasoning: None,
694        }];
695        let Self::Listed {
696            levels,
697            budget,
698            can_disable,
699            ..
700        } = self
701        else {
702            return choices;
703        };
704        if *can_disable {
705            choices.push(ReasoningChoice {
706                name: "off",
707                reasoning: Some(Reasoning::Off),
708            });
709        }
710        choices.extend(levels.iter().map(|level| ReasoningChoice {
711            name: level.as_str(),
712            reasoning: Some(Reasoning::Effort(*level)),
713        }));
714        if levels.is_empty()
715            && let Some(range) = budget
716        {
717            choices.extend(NAMED_BUDGETS.iter().map(|&(name, tokens)| ReasoningChoice {
718                name,
719                // Not `clamp`, which panics on an inverted range.
720                reasoning: Some(Reasoning::Budget {
721                    tokens: tokens.max(*range.start()).min(*range.end()),
722                }),
723            }));
724        }
725        choices
726    }
727
728    /// The choice of [`Self::choices`] named `name`.
729    ///
730    /// # Errors
731    ///
732    /// [`UnknownReasoning`] with the names there are, when none is `name`.
733    pub fn named(&self, name: &str) -> Result<ReasoningChoice, UnknownReasoning> {
734        let choices = self.choices();
735        choices
736            .iter()
737            .find(|choice| choice.name == name)
738            .copied()
739            .ok_or_else(|| UnknownReasoning {
740                name: name.to_owned(),
741                choices: choices.iter().map(|choice| choice.name).collect(),
742            })
743    }
744
745    /// Whether the model reasons at all.
746    pub fn supported(&self) -> bool {
747        !matches!(self, Self::None)
748    }
749
750    /// The effort levels the model takes: empty when it takes none (or does
751    /// not reason), `None` when the catalog does not know, in which case a
752    /// picker shows every level.
753    pub fn levels(&self) -> Option<&[Effort]> {
754        match self {
755            Self::None => Some(&[]),
756            Self::Unknown { .. } => None,
757            Self::Listed { levels, .. } => Some(levels),
758        }
759    }
760
761    /// The reasoning-token budgets the model takes, if the catalog lists
762    /// one. `None` both when it takes no budget and when the catalog does
763    /// not know; [`Self::levels`] tells the two apart.
764    pub fn budget(&self) -> Option<&RangeInclusive<u32>> {
765        match self {
766            Self::Listed { budget, .. } => budget.as_ref(),
767            _ => None,
768        }
769    }
770
771    /// Whether a request can turn the model's reasoning off: `Some(false)`
772    /// for a model that does not reason, `None` when the catalog does not
773    /// know.
774    pub fn can_disable(&self) -> Option<bool> {
775        match self {
776            Self::None => Some(false),
777            Self::Unknown { .. } => None,
778            Self::Listed { can_disable, .. } => Some(*can_disable),
779        }
780    }
781
782    /// The effort the model uses when a request names none, if documented.
783    pub fn default_effort(&self) -> Option<Effort> {
784        match self {
785            Self::None => None,
786            Self::Unknown { default } | Self::Listed { default, .. } => *default,
787        }
788    }
789
790    /// Why the model cannot take `reasoning`, or `None` when it can or the
791    /// catalog does not know ([`Self::Unknown`]).
792    pub fn refusal(&self, reasoning: &Reasoning) -> Option<String> {
793        let (levels, budget, can_disable) = match self {
794            Self::None => {
795                return (!matches!(reasoning, Reasoning::Off))
796                    .then(|| "the model does not reason".to_owned());
797            }
798            Self::Unknown { .. } => return None,
799            Self::Listed {
800                levels,
801                budget,
802                can_disable,
803                ..
804            } => (levels, budget, *can_disable),
805        };
806        match reasoning {
807            Reasoning::Off if can_disable => None,
808            Reasoning::Off => Some("reasoning cannot be turned off on this model".to_owned()),
809            Reasoning::Effort(effort) if levels.contains(effort) => None,
810            Reasoning::Effort(_) if levels.is_empty() && budget.is_some() => {
811                Some("the model takes a reasoning budget, not an effort level".to_owned())
812            }
813            Reasoning::Effort(_) if levels.is_empty() => {
814                Some("the model takes no effort level".to_owned())
815            }
816            Reasoning::Effort(effort) => Some(format!(
817                "the model has no `{}` effort level",
818                effort.as_str()
819            )),
820            Reasoning::Budget { tokens } => match budget {
821                Some(range) if range.contains(tokens) => None,
822                Some(range) => Some(format!(
823                    "the model takes a reasoning budget from {} to {} tokens",
824                    range.start(),
825                    range.end()
826                )),
827                None if levels.is_empty() => Some("the model takes no reasoning budget".to_owned()),
828                None => Some("the model takes an effort level, not a reasoning budget".to_owned()),
829            },
830        }
831    }
832}
833
834impl CacheSupport {
835    /// Why the model cannot honour `cache`, or `None` when it can or the
836    /// catalog does not know.
837    pub fn refusal(&self, cache: &CacheRetention) -> Option<String> {
838        (!self.retention.is_empty() && !self.retention.contains(cache)).then(|| {
839            format!(
840                "the model does not honour `{}` cache retention",
841                retention_word(cache)
842            )
843        })
844    }
845}
846
847/// The lower-case word `cache` serializes as.
848pub(super) fn retention_word(cache: &CacheRetention) -> &'static str {
849    match cache {
850        CacheRetention::None => "none",
851        CacheRetention::Short => "short",
852        CacheRetention::Long => "long",
853    }
854}