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