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, OptionName};
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; a decoder whose provider
209    /// splits writes by lifetime prices them itself.
210    /// These are standard-tier API list-price estimates, not subscription
211    /// charges or verified invoices, including for custom endpoints. Service
212    /// tiers, long-context tiers and hosted-tool fees are not included.
213    pub fn cost(&self, usage: &Usage) -> Option<Cost> {
214        let written = usage.cache_creation_input_tokens.unwrap_or(0);
215        let write_cost = match written {
216            0 => Some(0.0),
217            tokens => self
218                .cache_write
219                .map(|rate| tokens as f64 * rate / 1_000_000.0),
220        };
221        self.cost_with_cache_write(usage, write_cost)
222    }
223
224    /// Price the counters with a separately calculated cache-write cost in
225    /// USD. `None` leaves the write part unknown and the total incomplete.
226    pub(crate) fn cost_with_cache_write(
227        &self,
228        usage: &Usage,
229        write_cost: Option<f64>,
230    ) -> Option<Cost> {
231        let input = usage.input_tokens?;
232        let output = usage.output_tokens?;
233        let read = usage.cached_input_tokens.unwrap_or(0);
234        let written = usage.cache_creation_input_tokens.unwrap_or(0);
235        let uncached = input.saturating_sub(read).saturating_sub(written);
236        // Token counts stay far below 2^53, so the conversion is exact.
237        let price = |tokens: u64, per_million: f64| tokens as f64 * per_million / 1_000_000.0;
238        // No tokens cost nothing, whether or not the rate is listed.
239        let cache = |tokens: u64, rate: Option<f64>| match (tokens, rate) {
240            (0, _) => Some(0.0),
241            (tokens, rate) => rate.map(|rate| price(tokens, rate)),
242        };
243        Some(Cost::priced(
244            price(uncached, self.input),
245            price(output, self.output),
246            cache(read, self.cache_read),
247            write_cost,
248        ))
249    }
250}
251
252/// When a model takes sampling parameters.
253#[non_exhaustive]
254#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum Sampling {
257    /// Always.
258    Any,
259    /// Only while its reasoning is off.
260    ReasoningOff,
261    /// Never.
262    Never,
263}
264
265/// When OpenAI Chat Completions takes a model's tools.
266#[non_exhaustive]
267#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
268#[serde(rename_all = "snake_case")]
269pub enum ChatTools {
270    /// Always.
271    Any,
272    /// Only while its reasoning is off.
273    ReasoningOff,
274    /// Never: tool calls need the Responses API.
275    Never,
276}
277
278/// Wire facts the encoders read that no portable field holds. Each defaults
279/// to `false` or `None`, which is what a model the field does not concern
280/// has. An override file sets each one by its field name under a row's
281/// `rig` object, and a spec built in code sets it with the `with_*` setter
282/// of the same name.
283#[non_exhaustive]
284#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
285pub struct Compat {
286    /// The field an OpenAI Chat Completions assistant message must carry
287    /// its reasoning under, such as `reasoning_content`.
288    pub reasoning_field: Option<String>,
289    /// Anthropic: an effort level goes with `thinking: {"type": "adaptive"}`.
290    pub adaptive_thinking: bool,
291    /// Anthropic: the `thinking.type` that turns reasoning off, where it is
292    /// not `disabled`.
293    pub thinking_off: Option<String>,
294    /// Anthropic: the model takes `role: "system"` inside `messages`.
295    pub mid_conversation_system: bool,
296    /// Anthropic: the model answers a forced `tool_choice` with an error.
297    pub rejects_forced_tool_choice: bool,
298    /// Anthropic: the model binds its thinking to the request's tools and
299    /// system prompt.
300    pub binds_context: bool,
301    /// OpenAI: the prompt cache takes `prompt_cache_options`, not
302    /// `prompt_cache_retention`.
303    pub prompt_cache_options: bool,
304    /// OpenAI: when Chat Completions takes the model's tools. `None` is
305    /// [`ChatTools::Any`].
306    pub chat_tools: Option<ChatTools>,
307    /// The model continues a request that ends in an assistant message (a
308    /// prefill) from where that message stops, instead of refusing it, as
309    /// Anthropic's models before Claude 4.6 do while their reasoning is
310    /// off. Unknown is `false`.
311    pub prefill: bool,
312}
313
314impl Compat {
315    /// These facts with `field` as the Chat Completions assistant message's
316    /// reasoning field.
317    pub fn with_reasoning_field(mut self, field: impl Into<String>) -> Self {
318        self.reasoning_field = Some(field.into());
319        self
320    }
321
322    /// These facts with prefill continuation set to `on`.
323    pub fn with_prefill(mut self, on: bool) -> Self {
324        self.prefill = on;
325        self
326    }
327}
328
329impl CacheSupport {
330    /// Support for exactly the retentions in `retention`. An empty list means
331    /// the catalog does not know, and refuses nothing.
332    pub fn new(retention: impl IntoIterator<Item = CacheRetention>) -> Self {
333        Self {
334            retention: retention.into_iter().collect(),
335        }
336    }
337}
338
339impl ModelSpec {
340    /// A spec for `provider`'s model `id` with nothing else known: its id as
341    /// its name, no limits, text input only, no reasoning, unknown caching,
342    /// no tools or structured output, no prices (`None`, never zero), not
343    /// deprecated, unknown sampling, default [`Compat`] and no
344    /// [`ModelDetails`].
345    pub fn new(provider: ProviderId, id: impl Into<String>) -> Self {
346        let id = id.into();
347        Self {
348            display_name: id.clone(),
349            id,
350            provider,
351            context_window: None,
352            max_input_tokens: None,
353            max_output_tokens: None,
354            input: Modalities {
355                text: true,
356                ..Modalities::default()
357            },
358            reasoning: ReasoningSupport::default(),
359            caching: CacheSupport::default(),
360            tools: false,
361            structured_output: false,
362            pricing: None,
363            deprecated: false,
364            sampling: None,
365            compat: Compat::default(),
366            details: ModelDetails::default(),
367        }
368    }
369
370    /// The protocol family a connection to this model speaks unless told
371    /// otherwise, or `None` for a provider the registry cannot configure.
372    /// To reach the model in another family its vendor is registered for,
373    /// build the spec with that [`ProviderId`] or pass the family to
374    /// [`ConnectOptions::format`](crate::providers::registry::ConnectOptions::format).
375    pub fn format(&self) -> Option<Format> {
376        self.provider.format()
377    }
378
379    /// The `vendor/model` reference [`Catalog::resolve`](super::Catalog::resolve)
380    /// reads back to this model.
381    pub fn reference(&self) -> String {
382        format!("{}/{}", self.provider.vendor(), self.id)
383    }
384
385    /// The options of a request to this model with `reasoning` (`None`
386    /// for the provider's default): that reasoning, and the short prompt
387    /// cache when [`Self::caching`] lists it. A model whose caching the
388    /// catalog does not know gets no cache option, which it could refuse.
389    pub fn default_options(&self, reasoning: Option<Reasoning>) -> GenerationOptions {
390        let options = GenerationOptions {
391            reasoning,
392            ..GenerationOptions::default()
393        };
394        if self.caching.retention.contains(&CacheRetention::Short) {
395            options.cache(CacheRetention::Short)
396        } else {
397            options
398        }
399    }
400
401    /// This spec named `name`.
402    pub fn with_display_name(mut self, name: impl Into<String>) -> Self {
403        self.display_name = name.into();
404        self
405    }
406
407    /// This spec with a context window of `tokens`.
408    pub fn with_context_window(mut self, tokens: u32) -> Self {
409        self.context_window = Some(tokens);
410        self
411    }
412
413    /// This spec reading at most `tokens` in one request.
414    pub fn with_max_input_tokens(mut self, tokens: u32) -> Self {
415        self.max_input_tokens = Some(tokens);
416        self
417    }
418
419    /// The most tokens a request to this model may send: its input limit
420    /// when that is below its window, else its window; `None` when neither
421    /// is known.
422    pub fn input_limit(&self) -> Option<u32> {
423        match (self.max_input_tokens, self.context_window) {
424            (Some(input), Some(window)) => Some(input.min(window)),
425            (input, window) => input.or(window),
426        }
427    }
428
429    /// This spec writing at most `tokens` in one reply.
430    pub fn with_max_output_tokens(mut self, tokens: u32) -> Self {
431        self.max_output_tokens = Some(tokens);
432        self
433    }
434
435    /// This spec taking `reasoning`.
436    pub fn with_reasoning(mut self, reasoning: ReasoningSupport) -> Self {
437        self.reasoning = reasoning;
438        self
439    }
440
441    /// This spec honouring `caching`.
442    pub fn with_caching(mut self, caching: CacheSupport) -> Self {
443        self.caching = caching;
444        self
445    }
446
447    /// This spec with tool calling set to `tools`.
448    pub fn with_tools(mut self, tools: bool) -> Self {
449        self.tools = tools;
450        self
451    }
452
453    /// This spec priced at `pricing`.
454    pub fn with_pricing(mut self, pricing: Pricing) -> Self {
455        self.pricing = Some(pricing);
456        self
457    }
458
459    /// This spec taking sampling parameters as `sampling` says.
460    pub fn with_sampling(mut self, sampling: Sampling) -> Self {
461        self.sampling = Some(sampling);
462        self
463    }
464
465    /// This spec with the wire facts `compat`.
466    pub fn with_compat(mut self, compat: Compat) -> Self {
467        self.compat = compat;
468        self
469    }
470
471    /// This spec with the catalog's other records `details`.
472    pub fn with_details(mut self, details: ModelDetails) -> Self {
473        self.details = details;
474        self
475    }
476
477    /// This spec as one models.dev-shaped row of an override file, with
478    /// rig's facts under `rig`. Read back under its vendor key, the row
479    /// builds this spec again. A fact the spec does not know (a `None`
480    /// limit, price, sampling rule or reasoning default, or empty caching)
481    /// is left out, so laid over another row it keeps that row's value.
482    pub fn to_row_json(&self) -> serde_json::Value {
483        super::row::Row::from_spec(self).to_json()
484    }
485
486    /// Checks `options` against the rules [`Self::refusals`] applies,
487    /// and returns the first refusal. It checks only what `options` holds,
488    /// so it never refuses `temperature`, which a request carries outside
489    /// its options; [`Self::refusals`] checks the whole request.
490    ///
491    /// # Errors
492    ///
493    /// The first option the model cannot take. The error names the option,
494    /// the provider's vendor and this model.
495    pub fn validate(&self, options: &GenerationOptions) -> Result<(), UnsupportedOption> {
496        match self.rules(options, None).into_iter().next() {
497            Some(refusal) => Err(self.unsupported(refusal)),
498            None => Ok(()),
499        }
500    }
501
502    /// Every option of `request` the model cannot take, by the rules every
503    /// completion wire also applies before it encodes, so a request this
504    /// refuses is refused by the model's wire too:
505    ///
506    /// - `reasoning` against [`Self::reasoning`] ([`ReasoningSupport::refusal`]);
507    /// - `cache` against [`Self::caching`] ([`CacheSupport::refusal`]);
508    /// - `top_p` and `temperature` against [`Self::sampling`]:
509    ///   [`Sampling::Never`] refuses both, and [`Sampling::ReasoningOff`]
510    ///   refuses both while the model reasons. A model that cannot turn
511    ///   reasoning off always reasons; one that can reasons unless
512    ///   `reasoning` is `Off`, and with no `reasoning` set, when it names a
513    ///   default effort.
514    ///
515    /// A request whose [`GenerationOptions`] are default is not checked, as
516    /// [`GenerationOptions::is_default`] says, so this is empty for it. A
517    /// wire refuses more than this: what its route or API cannot carry
518    /// (`DynModel::check` reports both).
519    pub fn refusals(&self, request: &CompletionRequest) -> Vec<UnsupportedOption> {
520        self.request_rules(request)
521            .into_iter()
522            .map(|refusal| self.unsupported(refusal))
523            .collect()
524    }
525
526    /// Fits `request` to this model: takes out each option it would refuse
527    /// ([`Self::refusals`]) and leaves everything else as it is, so a request
528    /// built or edited for another model keeps what this one takes. A refused
529    /// reasoning setting becomes the provider default (`None`) first, since
530    /// whether the model reasons decides whether it takes `temperature` and
531    /// `top_p`. Returns what was taken out, in that order; afterwards
532    /// [`Self::refusals`] of the request is empty.
533    ///
534    /// ```
535    /// use rig_core::catalog::{ModelSpec, ReasoningSupport};
536    /// use rig_core::completion::{CompletionRequest, Effort, GenerationOptions, Reasoning};
537    /// use rig_core::providers::registry::ProviderId;
538    ///
539    /// let vendor = ProviderId::catalog("ollama").ok_or("a known vendor")?;
540    /// let plain = ModelSpec::new(vendor, "plain").with_reasoning(ReasoningSupport::None);
541    /// let options = GenerationOptions::default().reasoning(Reasoning::Effort(Effort::High));
542    /// let mut request = CompletionRequest::new("hi").options(options.seed(7));
543    /// let dropped = plain.fit(&mut request);
544    /// assert_eq!(dropped.len(), 1);
545    /// assert_eq!((request.options.reasoning, request.options.seed), (None, Some(7)));
546    /// # Ok::<(), &str>(())
547    /// ```
548    pub fn fit(&self, request: &mut CompletionRequest) -> Vec<UnsupportedOption> {
549        let mut dropped = Vec::new();
550        // Only a set option is refused, and each pass unsets one, so this ends.
551        while let Some(refusal) = self.request_rules(request).into_iter().next() {
552            refusal.option.clear(request);
553            dropped.push(self.unsupported(refusal));
554        }
555        dropped
556    }
557
558    /// [`Self::refusals`] as the options and reasons the wires report under
559    /// their own provider name.
560    pub(crate) fn request_rules(&self, request: &CompletionRequest) -> Vec<CatalogRefusal> {
561        if request.options.is_default() {
562            return Vec::new();
563        }
564        self.rules(&request.options, request.temperature)
565    }
566
567    /// The refusals of `options` and of a request's `temperature`.
568    fn rules(&self, options: &GenerationOptions, temperature: Option<f64>) -> Vec<CatalogRefusal> {
569        let mut refusals = Vec::new();
570        if let Some(reason) = options
571            .reasoning
572            .as_ref()
573            .and_then(|reasoning| self.reasoning.refusal(reasoning))
574        {
575            refusals.push(CatalogRefusal {
576                option: OptionName::Reasoning,
577                reason,
578            });
579        }
580        if let Some(reason) = options
581            .cache
582            .as_ref()
583            .and_then(|cache| self.caching.refusal(cache))
584        {
585            refusals.push(CatalogRefusal {
586                option: OptionName::Cache,
587                reason,
588            });
589        }
590        let reasons = self.reasons_with(options.reasoning.as_ref());
591        let sampled = [
592            (OptionName::Temperature, temperature.is_some()),
593            (OptionName::TopP, options.top_p.is_some()),
594        ];
595        for (option, set) in sampled {
596            let refusal = set.then(|| self.sampling_refusal(option.as_str(), reasons));
597            if let Some(reason) = refusal.flatten() {
598                refusals.push(CatalogRefusal { option, reason });
599            }
600        }
601        refusals
602    }
603
604    /// Why the model takes no `field`, a sampling parameter, when it reasons
605    /// as `reasons` says, by [`Self::sampling`].
606    pub(crate) fn sampling_refusal(&self, field: &str, reasons: bool) -> Option<String> {
607        match self.sampling? {
608            Sampling::Never => Some(format!("the model takes no `{field}`")),
609            Sampling::ReasoningOff if reasons => {
610                Some(match self.reasoning.can_disable() == Some(true) {
611                    true => format!(
612                        "the model rejects `{field}` while it reasons; remove `{field}` or set \
613                         `reasoning` to `Off`"
614                    ),
615                    false => format!("the model rejects `{field}` while it reasons"),
616                })
617            }
618            Sampling::ReasoningOff | Sampling::Any => None,
619        }
620    }
621
622    /// Whether the model reasons when a request asks for `reasoning`: at
623    /// `Off`, only when it cannot turn reasoning off; with nothing asked, as
624    /// [`Self::reasons_by_default`] says.
625    pub(crate) fn reasons_with(&self, reasoning: Option<&Reasoning>) -> bool {
626        match reasoning {
627            Some(Reasoning::Off) => {
628                self.reasoning.supported() && self.reasoning.can_disable() == Some(false)
629            }
630            Some(_) => self.reasoning.supported(),
631            None => self.reasons_by_default(),
632        }
633    }
634
635    /// Whether the model reasons when a request asks for nothing: when it
636    /// names a default effort or cannot turn reasoning off.
637    pub(crate) fn reasons_by_default(&self) -> bool {
638        self.reasoning.supported()
639            && (self.reasoning.default_effort().is_some()
640                || self.reasoning.can_disable() == Some(false))
641    }
642
643    /// `refusal` named for this model.
644    fn unsupported(&self, refusal: CatalogRefusal) -> UnsupportedOption {
645        UnsupportedOption::new(
646            refusal.option.as_str(),
647            self.provider.vendor(),
648            &self.id,
649            refusal.reason,
650        )
651    }
652}
653
654/// A reasoning setting a model takes, by the name a picker shows: the
655/// provider default (`default`, no setting), `off`, an effort level, or a
656/// named budget.
657#[derive(Clone, Copy, Debug, PartialEq, Eq)]
658pub struct ReasoningChoice {
659    /// `default`, `off`, the level's word, or the budget's name.
660    pub name: &'static str,
661    /// The setting a request carries; `None` for the provider default.
662    pub reasoning: Option<Reasoning>,
663}
664
665impl ReasoningChoice {
666    /// The name, with a budget's tokens.
667    pub fn label(&self) -> String {
668        match self.reasoning {
669            Some(Reasoning::Budget { tokens }) => format!("{} ({tokens} tokens)", self.name),
670            _ => self.name.to_owned(),
671        }
672    }
673}
674
675/// A reasoning setting a model does not take, and the ones it does.
676#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
677#[error("the reasoning settings are {}, not `{name}`", choices.join(", "))]
678pub struct UnknownReasoning {
679    /// The name asked for.
680    pub name: String,
681    /// The names of the model's [`ReasoningSupport::choices`].
682    pub choices: Vec<&'static str>,
683}
684
685/// Token budgets for the named levels on a model that takes a budget
686/// instead of levels, clamped into its range.
687const NAMED_BUDGETS: [(Effort, u32); 3] = [
688    (Effort::Low, 2048),
689    (Effort::Medium, 8192),
690    (Effort::High, 16384),
691];
692
693impl ReasoningSupport {
694    /// The reasoning settings the model takes: the provider default first,
695    /// then `off` when reasoning can be turned off, then each effort level,
696    /// or `low`, `medium` and `high` budgets on a model that takes a budget
697    /// and no levels. A model whose controls the catalog does not list
698    /// offers only the default.
699    pub fn choices(&self) -> Vec<ReasoningChoice> {
700        let mut choices = vec![ReasoningChoice {
701            name: "default",
702            reasoning: None,
703        }];
704        let Self::Listed {
705            levels,
706            budget,
707            can_disable,
708            ..
709        } = self
710        else {
711            return choices;
712        };
713        if *can_disable {
714            choices.push(ReasoningChoice {
715                name: "off",
716                reasoning: Some(Reasoning::Off),
717            });
718        }
719        choices.extend(levels.iter().map(|level| ReasoningChoice {
720            name: level.as_str(),
721            reasoning: Some(Reasoning::Effort(*level)),
722        }));
723        if levels.is_empty()
724            && let Some(range) = budget
725        {
726            choices.extend(
727                NAMED_BUDGETS
728                    .iter()
729                    .map(|&(effort, tokens)| ReasoningChoice {
730                        name: effort.as_str(),
731                        // Not `clamp`, which panics on an inverted range.
732                        reasoning: Some(Reasoning::Budget {
733                            tokens: tokens.max(*range.start()).min(*range.end()),
734                        }),
735                    }),
736            );
737        }
738        choices
739    }
740
741    /// The choice of [`Self::choices`] named `name`.
742    ///
743    /// # Errors
744    ///
745    /// [`UnknownReasoning`] with the names there are, when none is `name`.
746    pub fn named(&self, name: &str) -> Result<ReasoningChoice, UnknownReasoning> {
747        let choices = self.choices();
748        choices
749            .iter()
750            .find(|choice| choice.name == name)
751            .copied()
752            .ok_or_else(|| UnknownReasoning {
753                name: name.to_owned(),
754                choices: choices.iter().map(|choice| choice.name).collect(),
755            })
756    }
757
758    /// Whether the model reasons at all.
759    pub fn supported(&self) -> bool {
760        !matches!(self, Self::None)
761    }
762
763    /// The effort levels the model takes: empty when it takes none (or does
764    /// not reason), `None` when the catalog does not know, in which case a
765    /// picker shows every level.
766    pub fn levels(&self) -> Option<&[Effort]> {
767        match self {
768            Self::None => Some(&[]),
769            Self::Unknown { .. } => None,
770            Self::Listed { levels, .. } => Some(levels),
771        }
772    }
773
774    /// The reasoning-token budgets the model takes, if the catalog lists
775    /// one. `None` both when it takes no budget and when the catalog does
776    /// not know; [`Self::levels`] tells the two apart.
777    pub fn budget(&self) -> Option<&RangeInclusive<u32>> {
778        match self {
779            Self::Listed { budget, .. } => budget.as_ref(),
780            _ => None,
781        }
782    }
783
784    /// Whether a request can turn the model's reasoning off: `Some(false)`
785    /// for a model that does not reason, `None` when the catalog does not
786    /// know.
787    pub fn can_disable(&self) -> Option<bool> {
788        match self {
789            Self::None => Some(false),
790            Self::Unknown { .. } => None,
791            Self::Listed { can_disable, .. } => Some(*can_disable),
792        }
793    }
794
795    /// The effort the model uses when a request names none, if documented.
796    pub fn default_effort(&self) -> Option<Effort> {
797        match self {
798            Self::None => None,
799            Self::Unknown { default } | Self::Listed { default, .. } => *default,
800        }
801    }
802
803    /// Why the model cannot take `reasoning`, or `None` when it can or the
804    /// catalog does not know ([`Self::Unknown`]).
805    pub fn refusal(&self, reasoning: &Reasoning) -> Option<String> {
806        let (levels, budget, can_disable) = match self {
807            Self::None => {
808                return (!matches!(reasoning, Reasoning::Off))
809                    .then(|| "the model does not reason".to_owned());
810            }
811            Self::Unknown { .. } => return None,
812            Self::Listed {
813                levels,
814                budget,
815                can_disable,
816                ..
817            } => (levels, budget, *can_disable),
818        };
819        match reasoning {
820            Reasoning::Off if can_disable => None,
821            Reasoning::Off => Some("reasoning cannot be turned off on this model".to_owned()),
822            Reasoning::Effort(effort) if levels.contains(effort) => None,
823            Reasoning::Effort(_) if levels.is_empty() && budget.is_some() => {
824                Some("the model takes a reasoning budget, not an effort level".to_owned())
825            }
826            Reasoning::Effort(_) if levels.is_empty() => {
827                Some("the model takes no effort level".to_owned())
828            }
829            Reasoning::Effort(effort) => Some(format!(
830                "the model has no `{}` effort level",
831                effort.as_str()
832            )),
833            Reasoning::Budget { tokens } => match budget {
834                Some(range) if range.contains(tokens) => None,
835                Some(range) => Some(format!(
836                    "the model takes a reasoning budget from {} to {} tokens",
837                    range.start(),
838                    range.end()
839                )),
840                None if levels.is_empty() => Some("the model takes no reasoning budget".to_owned()),
841                None => Some("the model takes an effort level, not a reasoning budget".to_owned()),
842            },
843        }
844    }
845}
846
847impl CacheSupport {
848    /// Why the model cannot honour `cache`, or `None` when it can or the
849    /// catalog does not know.
850    pub fn refusal(&self, cache: &CacheRetention) -> Option<String> {
851        (!self.retention.is_empty() && !self.retention.contains(cache)).then(|| {
852            format!(
853                "the model does not honour `{}` cache retention",
854                retention_word(cache)
855            )
856        })
857    }
858}
859
860/// The lower-case word `cache` serializes as.
861pub(super) fn retention_word(cache: &CacheRetention) -> &'static str {
862    match cache {
863        CacheRetention::None => "none",
864        CacheRetention::Short => "short",
865        CacheRetention::Long => "long",
866    }
867}