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}