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