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}