rig-core 0.45.0

An opinionated library for building LLM powered applications.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
//! What the catalog knows about one model, and the checks it makes against
//! [`GenerationOptions`].

use std::ops::RangeInclusive;

use serde::{Deserialize, Serialize};

use crate::completion::options::CatalogRefusal;
use crate::completion::{
    CacheRetention, CompletionRequest, Cost, Effort, GenerationOptions, Reasoning,
    UnsupportedOption, Usage,
};
use crate::providers::registry::{Format, ProviderId};

/// One model's facts: limits, input modalities, the reasoning and caching it
/// takes, and its prices. [`Catalog`](super::Catalog) builds one per row of
/// its data; [`ModelSpec::new`] and the `with_*` setters build one in code,
/// for a model the catalog does not list, and
/// [`Catalog::insert`](super::Catalog::insert) adds it.
///
/// It serializes field by field and reads back the same. For the override
/// file's row shape, use [`Self::to_row_json`].
///
/// ```
/// use rig_core::catalog::{Catalog, ModelSpec, Pricing};
/// use rig_core::providers::registry::ProviderId;
///
/// let ollama = ProviderId::catalog("ollama").ok_or("a known vendor")?;
/// let mut catalog = Catalog::builtin().clone();
/// catalog.insert(
///     ModelSpec::new(ollama, "qwen3:4b")
///         .with_context_window(32_768)
///         .with_tools(true)
///         .with_pricing(Pricing::new(0.0, 0.0)),
/// );
/// let qwen = catalog.get(ollama, "qwen3:4b").ok_or("inserted")?.spec;
/// assert_eq!(qwen.context_window, Some(32_768));
/// # Ok::<(), &str>(())
/// ```
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ModelSpec {
    /// The provider's own model id.
    pub id: String,
    /// The provider serving the model under [`Self::id`], in the protocol
    /// family the model is reached by: its vendor's own
    /// ([`ProviderId::catalog`]) unless the catalog row names another
    /// under `rig.format`. [`Self::format`] reads it.
    pub provider: ProviderId,
    /// A human-readable name.
    pub display_name: String,
    /// The most tokens the model reads and writes in one request, if known.
    pub context_window: Option<u32>,
    /// The most tokens the model writes in one reply, if known.
    pub max_output_tokens: Option<u32>,
    /// What the model reads.
    pub input: Modalities,
    /// The reasoning the model takes.
    pub reasoning: ReasoningSupport,
    /// The cache retentions the model honours.
    pub caching: CacheSupport,
    /// Whether the model calls tools.
    pub tools: bool,
    /// Whether the model constrains its output to a JSON schema.
    pub structured_output: bool,
    /// Prices in USD per million tokens, if known.
    pub pricing: Option<Pricing>,
    /// Whether the provider has deprecated the model.
    pub deprecated: bool,
    /// When the model takes sampling parameters (`temperature`, `top_p`,
    /// `top_logprobs`, `logprobs`), or `None` when unknown.
    pub sampling: Option<Sampling>,
    /// Wire facts the encoders read that no portable field holds.
    pub compat: Compat,
}

/// What a model reads.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Modalities {
    /// Text.
    pub text: bool,
    /// Images.
    pub image: bool,
    /// Audio.
    pub audio: bool,
    /// Video.
    pub video: bool,
    /// PDF documents.
    pub pdf: bool,
}

/// The reasoning a model takes, in one of three states: it does not reason,
/// it reasons but the catalog does not say which controls it takes, or it
/// reasons and takes exactly the listed controls.
///
/// A catalog row that marks a model as reasoning but lists no reasoning
/// options (most of them gateway rows on HuggingFace, OpenRouter, Venice and
/// Bedrock) is [`Self::Unknown`], which refuses nothing, as an empty
/// [`CacheSupport`] does. Only a row whose hand-reviewed `rig` facts say
/// `"reasoning_control": "none"` (a model whose API rejects every reasoning
/// control) is [`Self::Listed`] with nothing listed.
///
/// ```
/// use rig_core::catalog::ReasoningSupport;
/// use rig_core::completion::{Effort, Reasoning};
///
/// let unknown = ReasoningSupport::Unknown { default: None };
/// assert!(unknown.supported() && unknown.levels().is_none());
/// assert_eq!(unknown.refusal(&Reasoning::Effort(Effort::High)), None);
///
/// let listed = ReasoningSupport::Listed {
///     levels: vec![Effort::Low, Effort::High],
///     budget: None,
///     can_disable: false,
///     default: Some(Effort::High),
/// };
/// assert!(listed.refusal(&Reasoning::Off).is_some());
/// assert_eq!(listed.refusal(&Reasoning::Effort(Effort::Low)), None);
/// ```
#[non_exhaustive]
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningSupport {
    /// The model does not reason.
    #[default]
    None,
    /// The model reasons; the catalog does not say which controls it takes.
    /// Nothing is refused.
    Unknown {
        /// The effort it uses when a request names none, if documented.
        default: Option<Effort>,
    },
    /// The model reasons and takes exactly these controls.
    Listed {
        /// The effort levels it takes, empty when it takes none.
        levels: Vec<Effort>,
        /// The reasoning-token budgets it takes, if it takes one.
        budget: Option<RangeInclusive<u32>>,
        /// Whether a request can turn its reasoning off.
        can_disable: bool,
        /// The effort it uses when a request names none, if documented.
        default: Option<Effort>,
    },
}

/// The cache retentions a model honours.
#[non_exhaustive]
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheSupport {
    /// The [`CacheRetention`] values the model honours. Empty when the
    /// catalog does not know, in which case nothing is refused.
    pub retention: Vec<CacheRetention>,
}

/// Prices in USD per million tokens.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Pricing {
    /// Uncached input.
    pub input: f64,
    /// Output, reasoning included.
    pub output: f64,
    /// Input read from the cache, or `None` when unknown.
    pub cache_read: Option<f64>,
    /// Input written to the cache, or `None` when unknown.
    pub cache_write: Option<f64>,
}

impl Pricing {
    /// Prices for uncached input and for output, with no cache prices.
    pub fn new(input: f64, output: f64) -> Self {
        Self {
            input,
            output,
            cache_read: None,
            cache_write: None,
        }
    }

    /// These prices with `price` for input read from the cache.
    pub fn with_cache_read(mut self, price: f64) -> Self {
        self.cache_read = Some(price);
        self
    }

    /// These prices with `price` for input written to the cache.
    pub fn with_cache_write(mut self, price: f64) -> Self {
        self.cache_write = Some(price);
        self
    }

    /// What `usage` costs at these prices, or `None` unless it reports both
    /// its input and output tokens. Uncached input is the input tokens less
    /// those read from and written to the cache. A cache part with tokens
    /// but no listed price is `None`, and the cost is then not
    /// [complete](Cost::is_complete): its `total` leaves that part out. Every
    /// cache write is charged at one price, so a provider that bills longer
    /// retention higher costs more than this says. It is the standard-tier
    /// list price of the tokens: the service tier, long-context price tiers
    /// and hosted-tool fees (web search, code execution) are not in it.
    pub fn cost(&self, usage: &Usage) -> Option<Cost> {
        let input = usage.input_tokens?;
        let output = usage.output_tokens?;
        let read = usage.cached_input_tokens.unwrap_or(0);
        let written = usage.cache_creation_input_tokens.unwrap_or(0);
        let uncached = input.saturating_sub(read).saturating_sub(written);
        // Token counts stay far below 2^53, so the conversion is exact.
        let price = |tokens: u64, per_million: f64| tokens as f64 * per_million / 1_000_000.0;
        // No tokens cost nothing, whether or not the rate is listed.
        let cache = |tokens: u64, rate: Option<f64>| match (tokens, rate) {
            (0, _) => Some(0.0),
            (tokens, rate) => rate.map(|rate| price(tokens, rate)),
        };
        Some(Cost::priced(
            price(uncached, self.input),
            price(output, self.output),
            cache(read, self.cache_read),
            cache(written, self.cache_write),
        ))
    }
}

/// When a model takes sampling parameters.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Sampling {
    /// Always.
    Any,
    /// Only while its reasoning is off.
    ReasoningOff,
    /// Never.
    Never,
}

/// Wire facts the encoders read that no portable field holds. Each defaults
/// to `false` or `None`, which is what a model the field does not concern
/// has. An override file sets each one by its field name under a row's
/// `rig` object, and a spec built in code sets it with the `with_*` setter
/// of the same name.
#[non_exhaustive]
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Compat {
    /// The field an OpenAI Chat Completions assistant message must carry
    /// its reasoning under, such as `reasoning_content`.
    pub reasoning_field: Option<String>,
    /// Anthropic: an effort level goes with `thinking: {"type": "adaptive"}`.
    pub adaptive_thinking: bool,
    /// Anthropic: the `thinking.type` that turns reasoning off, where it is
    /// not `disabled`.
    pub thinking_off: Option<String>,
    /// Anthropic: the model takes `role: "system"` inside `messages`.
    pub mid_conversation_system: bool,
    /// Anthropic: the model answers a forced `tool_choice` with an error.
    pub rejects_forced_tool_choice: bool,
    /// Anthropic: the model binds its thinking to the request's tools and
    /// system prompt.
    pub binds_context: bool,
    /// OpenAI: the prompt cache takes `prompt_cache_options`, not
    /// `prompt_cache_retention`.
    pub prompt_cache_options: bool,
    /// OpenAI: Chat Completions takes the model's tools only while its
    /// reasoning is off.
    pub chat_tools_need_reasoning_off: bool,
    /// The model continues a request that ends in an assistant message (a
    /// prefill) from where that message stops, instead of refusing it, as
    /// Anthropic's models before Claude 4.6 do while their reasoning is
    /// off. Unknown is `false`.
    pub prefill: bool,
}

impl Compat {
    /// These facts with `field` as the Chat Completions assistant message's
    /// reasoning field.
    pub fn with_reasoning_field(mut self, field: impl Into<String>) -> Self {
        self.reasoning_field = Some(field.into());
        self
    }

    /// These facts with prefill continuation set to `on`.
    pub fn with_prefill(mut self, on: bool) -> Self {
        self.prefill = on;
        self
    }
}

impl CacheSupport {
    /// Support for exactly the retentions in `retention`. An empty list means
    /// the catalog does not know, and refuses nothing.
    pub fn new(retention: impl IntoIterator<Item = CacheRetention>) -> Self {
        Self {
            retention: retention.into_iter().collect(),
        }
    }
}

impl ModelSpec {
    /// A spec for `provider`'s model `id` with nothing else known: its id as
    /// its name, no limits, text input only, no reasoning, unknown caching,
    /// no tools or structured output, no prices (`None`, never zero), not
    /// deprecated, unknown sampling and default [`Compat`].
    pub fn new(provider: ProviderId, id: impl Into<String>) -> Self {
        let id = id.into();
        Self {
            display_name: id.clone(),
            id,
            provider,
            context_window: None,
            max_output_tokens: None,
            input: Modalities {
                text: true,
                ..Modalities::default()
            },
            reasoning: ReasoningSupport::default(),
            caching: CacheSupport::default(),
            tools: false,
            structured_output: false,
            pricing: None,
            deprecated: false,
            sampling: None,
            compat: Compat::default(),
        }
    }

    /// The protocol family a connection to this model speaks unless told
    /// otherwise, or `None` for a provider the registry cannot configure.
    /// To reach the model in another family its vendor is registered for,
    /// build the spec with that [`ProviderId`] or pass the family to
    /// [`ConnectOptions::format`](crate::providers::registry::ConnectOptions::format).
    pub fn format(&self) -> Option<Format> {
        self.provider.format()
    }

    /// The `vendor/model` reference [`Catalog::resolve`](super::Catalog::resolve)
    /// reads back to this model.
    pub fn reference(&self) -> String {
        format!("{}/{}", self.provider.vendor(), self.id)
    }

    /// The options of a request to this model with `reasoning` (`None`
    /// for the provider's default): that reasoning, and the short prompt
    /// cache when [`Self::caching`] lists it. A model whose caching the
    /// catalog does not know gets no cache option, which it could refuse.
    pub fn default_options(&self, reasoning: Option<Reasoning>) -> GenerationOptions {
        let options = GenerationOptions {
            reasoning,
            ..GenerationOptions::default()
        };
        if self.caching.retention.contains(&CacheRetention::Short) {
            options.cache(CacheRetention::Short)
        } else {
            options
        }
    }

    /// This spec named `name`.
    pub fn with_display_name(mut self, name: impl Into<String>) -> Self {
        self.display_name = name.into();
        self
    }

    /// This spec with a context window of `tokens`.
    pub fn with_context_window(mut self, tokens: u32) -> Self {
        self.context_window = Some(tokens);
        self
    }

    /// This spec writing at most `tokens` in one reply.
    pub fn with_max_output_tokens(mut self, tokens: u32) -> Self {
        self.max_output_tokens = Some(tokens);
        self
    }

    /// This spec taking `reasoning`.
    pub fn with_reasoning(mut self, reasoning: ReasoningSupport) -> Self {
        self.reasoning = reasoning;
        self
    }

    /// This spec honouring `caching`.
    pub fn with_caching(mut self, caching: CacheSupport) -> Self {
        self.caching = caching;
        self
    }

    /// This spec with tool calling set to `tools`.
    pub fn with_tools(mut self, tools: bool) -> Self {
        self.tools = tools;
        self
    }

    /// This spec priced at `pricing`.
    pub fn with_pricing(mut self, pricing: Pricing) -> Self {
        self.pricing = Some(pricing);
        self
    }

    /// This spec taking sampling parameters as `sampling` says.
    pub fn with_sampling(mut self, sampling: Sampling) -> Self {
        self.sampling = Some(sampling);
        self
    }

    /// This spec with the wire facts `compat`.
    pub fn with_compat(mut self, compat: Compat) -> Self {
        self.compat = compat;
        self
    }

    /// This spec as one models.dev-shaped row of an override file, with
    /// rig's facts under `rig`. Read back under its vendor key, the row
    /// builds this spec again. A fact the spec does not know (a `None`
    /// limit, price, sampling rule or reasoning default, or empty caching)
    /// is left out, so laid over another row it keeps that row's value.
    pub fn to_row_json(&self) -> serde_json::Value {
        super::row::Row::from_spec(self).to_json()
    }

    /// Checks `options` against the rules [`Self::refusals`] applies,
    /// and returns the first refusal. It checks only what `options` holds,
    /// so it never refuses `temperature`, which a request carries outside
    /// its options; [`Self::refusals`] checks the whole request.
    ///
    /// # Errors
    ///
    /// The first option the model cannot take. The error names the option,
    /// the provider's vendor and this model.
    pub fn validate(&self, options: &GenerationOptions) -> Result<(), UnsupportedOption> {
        match self.rules(options, None).into_iter().next() {
            Some(refusal) => Err(self.unsupported(refusal)),
            None => Ok(()),
        }
    }

    /// Every option of `request` the model cannot take, by the rules every
    /// completion wire also applies before it encodes, so a request this
    /// refuses is refused by the model's wire too:
    ///
    /// - `reasoning` against [`Self::reasoning`] ([`ReasoningSupport::refusal`]);
    /// - `cache` against [`Self::caching`] ([`CacheSupport::refusal`]);
    /// - `top_p` and `temperature` against [`Self::sampling`]:
    ///   [`Sampling::Never`] refuses both, and [`Sampling::ReasoningOff`]
    ///   refuses both while the model reasons. A model that cannot turn
    ///   reasoning off always reasons; one that can reasons unless
    ///   `reasoning` is `Off`, and with no `reasoning` set, when it names a
    ///   default effort.
    ///
    /// A request whose [`GenerationOptions`] are default is not checked, as
    /// [`GenerationOptions::is_default`] says, so this is empty for it. A
    /// wire refuses more than this: what its route or API cannot carry
    /// (`DynModel::check` reports both).
    pub fn refusals(&self, request: &CompletionRequest) -> Vec<UnsupportedOption> {
        self.request_rules(request)
            .into_iter()
            .map(|refusal| self.unsupported(refusal))
            .collect()
    }

    /// Fits `request` to this model: takes out each option it would refuse
    /// ([`Self::refusals`]) and leaves everything else as it is, so a request
    /// built or edited for another model keeps what this one takes. A refused
    /// reasoning setting becomes the provider default (`None`) first, since
    /// whether the model reasons decides whether it takes `temperature` and
    /// `top_p`. Returns what was taken out, in that order; afterwards
    /// [`Self::refusals`] of the request is empty.
    ///
    /// ```
    /// use rig_core::catalog::{ModelSpec, ReasoningSupport};
    /// use rig_core::completion::{CompletionRequest, Effort, GenerationOptions, Reasoning};
    /// use rig_core::providers::registry::ProviderId;
    ///
    /// let vendor = ProviderId::catalog("ollama").ok_or("a known vendor")?;
    /// let plain = ModelSpec::new(vendor, "plain").with_reasoning(ReasoningSupport::None);
    /// let options = GenerationOptions::default().reasoning(Reasoning::Effort(Effort::High));
    /// let mut request = CompletionRequest::new("hi").options(options.seed(7));
    /// let dropped = plain.fit(&mut request);
    /// assert_eq!(dropped.len(), 1);
    /// assert_eq!((request.options.reasoning, request.options.seed), (None, Some(7)));
    /// # Ok::<(), &str>(())
    /// ```
    pub fn fit(&self, request: &mut CompletionRequest) -> Vec<UnsupportedOption> {
        let mut dropped = Vec::new();
        // Only a set option is refused, and each pass unsets one, so this ends.
        while let Some(refusal) = self.request_rules(request).into_iter().next() {
            let options = &mut request.options;
            match refusal.field {
                "reasoning" => options.reasoning = None,
                "cache" => options.cache = None,
                "top_p" => options.top_p = None,
                "temperature" => request.temperature = None,
                _ => break,
            }
            dropped.push(self.unsupported(refusal));
        }
        dropped
    }

    /// [`Self::refusals`] as the options and reasons the wires report under
    /// their own provider name.
    pub(crate) fn request_rules(&self, request: &CompletionRequest) -> Vec<CatalogRefusal> {
        if request.options.is_default() {
            return Vec::new();
        }
        self.rules(&request.options, request.temperature)
    }

    /// The refusals of `options` and of a request's `temperature`.
    fn rules(&self, options: &GenerationOptions, temperature: Option<f64>) -> Vec<CatalogRefusal> {
        let mut refusals = Vec::new();
        if let Some(reason) = options
            .reasoning
            .as_ref()
            .and_then(|reasoning| self.reasoning.refusal(reasoning))
        {
            refusals.push(CatalogRefusal {
                field: "reasoning",
                reason,
            });
        }
        if let Some(reason) = options
            .cache
            .as_ref()
            .and_then(|cache| self.caching.refusal(cache))
        {
            refusals.push(CatalogRefusal {
                field: "cache",
                reason,
            });
        }
        let reasons = self.reasons_with(options.reasoning.as_ref());
        let sampled = [
            ("temperature", temperature.is_some()),
            ("top_p", options.top_p.is_some()),
        ];
        for (field, set) in sampled {
            if let Some(reason) = set.then(|| self.sampling_refusal(field, reasons)).flatten() {
                refusals.push(CatalogRefusal { field, reason });
            }
        }
        refusals
    }

    /// Why the model takes no `field`, a sampling parameter, when it reasons
    /// as `reasons` says, by [`Self::sampling`].
    pub(crate) fn sampling_refusal(&self, field: &str, reasons: bool) -> Option<String> {
        match self.sampling? {
            Sampling::Never => Some(format!("the model takes no `{field}`")),
            Sampling::ReasoningOff if reasons => {
                Some(match self.reasoning.can_disable() == Some(true) {
                    true => format!(
                        "the model rejects `{field}` while it reasons; remove `{field}` or set \
                         `reasoning` to `Off`"
                    ),
                    false => format!("the model rejects `{field}` while it reasons"),
                })
            }
            Sampling::ReasoningOff | Sampling::Any => None,
        }
    }

    /// Whether the model reasons when a request asks for `reasoning`: at
    /// `Off`, only when it cannot turn reasoning off; with nothing asked, as
    /// [`Self::reasons_by_default`] says.
    pub(crate) fn reasons_with(&self, reasoning: Option<&Reasoning>) -> bool {
        match reasoning {
            Some(Reasoning::Off) => {
                self.reasoning.supported() && self.reasoning.can_disable() == Some(false)
            }
            Some(_) => self.reasoning.supported(),
            None => self.reasons_by_default(),
        }
    }

    /// Whether the model reasons when a request asks for nothing: when it
    /// names a default effort or cannot turn reasoning off.
    pub(crate) fn reasons_by_default(&self) -> bool {
        self.reasoning.supported()
            && (self.reasoning.default_effort().is_some()
                || self.reasoning.can_disable() == Some(false))
    }

    /// `refusal` named for this model.
    fn unsupported(&self, refusal: CatalogRefusal) -> UnsupportedOption {
        UnsupportedOption::new(
            refusal.field,
            self.provider.vendor(),
            &self.id,
            refusal.reason,
        )
    }
}

/// A reasoning setting a model takes, by the name a picker shows: the
/// provider default (`default`, no setting), `off`, an effort level, or a
/// named budget.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ReasoningChoice {
    /// `default`, `off`, the level's word, or the budget's name.
    pub name: &'static str,
    /// The setting a request carries; `None` for the provider default.
    pub reasoning: Option<Reasoning>,
}

impl ReasoningChoice {
    /// The name, with a budget's tokens.
    pub fn label(&self) -> String {
        match self.reasoning {
            Some(Reasoning::Budget { tokens }) => format!("{} ({tokens} tokens)", self.name),
            _ => self.name.to_owned(),
        }
    }
}

/// A reasoning setting a model does not take, and the ones it does.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("the reasoning settings are {}, not `{name}`", choices.join(", "))]
pub struct UnknownReasoning {
    /// The name asked for.
    pub name: String,
    /// The names of the model's [`ReasoningSupport::choices`].
    pub choices: Vec<&'static str>,
}

/// Token budgets for the named levels on a model that takes a budget
/// instead of levels, clamped into its range.
const NAMED_BUDGETS: [(&str, u32); 3] = [("low", 2048), ("medium", 8192), ("high", 16384)];

impl ReasoningSupport {
    /// The reasoning settings the model takes: the provider default first,
    /// then `off` when reasoning can be turned off, then each effort level,
    /// or `low`, `medium` and `high` budgets on a model that takes a budget
    /// and no levels. A model whose controls the catalog does not list
    /// offers only the default.
    pub fn choices(&self) -> Vec<ReasoningChoice> {
        let mut choices = vec![ReasoningChoice {
            name: "default",
            reasoning: None,
        }];
        let Self::Listed {
            levels,
            budget,
            can_disable,
            ..
        } = self
        else {
            return choices;
        };
        if *can_disable {
            choices.push(ReasoningChoice {
                name: "off",
                reasoning: Some(Reasoning::Off),
            });
        }
        choices.extend(levels.iter().map(|level| ReasoningChoice {
            name: level.as_str(),
            reasoning: Some(Reasoning::Effort(*level)),
        }));
        if levels.is_empty()
            && let Some(range) = budget
        {
            choices.extend(NAMED_BUDGETS.iter().map(|&(name, tokens)| ReasoningChoice {
                name,
                // Not `clamp`, which panics on an inverted range.
                reasoning: Some(Reasoning::Budget {
                    tokens: tokens.max(*range.start()).min(*range.end()),
                }),
            }));
        }
        choices
    }

    /// The choice of [`Self::choices`] named `name`.
    ///
    /// # Errors
    ///
    /// [`UnknownReasoning`] with the names there are, when none is `name`.
    pub fn named(&self, name: &str) -> Result<ReasoningChoice, UnknownReasoning> {
        let choices = self.choices();
        choices
            .iter()
            .find(|choice| choice.name == name)
            .copied()
            .ok_or_else(|| UnknownReasoning {
                name: name.to_owned(),
                choices: choices.iter().map(|choice| choice.name).collect(),
            })
    }

    /// Whether the model reasons at all.
    pub fn supported(&self) -> bool {
        !matches!(self, Self::None)
    }

    /// The effort levels the model takes: empty when it takes none (or does
    /// not reason), `None` when the catalog does not know, in which case a
    /// picker shows every level.
    pub fn levels(&self) -> Option<&[Effort]> {
        match self {
            Self::None => Some(&[]),
            Self::Unknown { .. } => None,
            Self::Listed { levels, .. } => Some(levels),
        }
    }

    /// The reasoning-token budgets the model takes, if the catalog lists
    /// one. `None` both when it takes no budget and when the catalog does
    /// not know; [`Self::levels`] tells the two apart.
    pub fn budget(&self) -> Option<&RangeInclusive<u32>> {
        match self {
            Self::Listed { budget, .. } => budget.as_ref(),
            _ => None,
        }
    }

    /// Whether a request can turn the model's reasoning off: `Some(false)`
    /// for a model that does not reason, `None` when the catalog does not
    /// know.
    pub fn can_disable(&self) -> Option<bool> {
        match self {
            Self::None => Some(false),
            Self::Unknown { .. } => None,
            Self::Listed { can_disable, .. } => Some(*can_disable),
        }
    }

    /// The effort the model uses when a request names none, if documented.
    pub fn default_effort(&self) -> Option<Effort> {
        match self {
            Self::None => None,
            Self::Unknown { default } | Self::Listed { default, .. } => *default,
        }
    }

    /// Why the model cannot take `reasoning`, or `None` when it can or the
    /// catalog does not know ([`Self::Unknown`]).
    pub fn refusal(&self, reasoning: &Reasoning) -> Option<String> {
        let (levels, budget, can_disable) = match self {
            Self::None => {
                return (!matches!(reasoning, Reasoning::Off))
                    .then(|| "the model does not reason".to_owned());
            }
            Self::Unknown { .. } => return None,
            Self::Listed {
                levels,
                budget,
                can_disable,
                ..
            } => (levels, budget, *can_disable),
        };
        match reasoning {
            Reasoning::Off if can_disable => None,
            Reasoning::Off => Some("reasoning cannot be turned off on this model".to_owned()),
            Reasoning::Effort(effort) if levels.contains(effort) => None,
            Reasoning::Effort(_) if levels.is_empty() && budget.is_some() => {
                Some("the model takes a reasoning budget, not an effort level".to_owned())
            }
            Reasoning::Effort(_) if levels.is_empty() => {
                Some("the model takes no effort level".to_owned())
            }
            Reasoning::Effort(effort) => Some(format!(
                "the model has no `{}` effort level",
                effort.as_str()
            )),
            Reasoning::Budget { tokens } => match budget {
                Some(range) if range.contains(tokens) => None,
                Some(range) => Some(format!(
                    "the model takes a reasoning budget from {} to {} tokens",
                    range.start(),
                    range.end()
                )),
                None if levels.is_empty() => Some("the model takes no reasoning budget".to_owned()),
                None => Some("the model takes an effort level, not a reasoning budget".to_owned()),
            },
        }
    }
}

impl CacheSupport {
    /// Why the model cannot honour `cache`, or `None` when it can or the
    /// catalog does not know.
    pub fn refusal(&self, cache: &CacheRetention) -> Option<String> {
        (!self.retention.is_empty() && !self.retention.contains(cache)).then(|| {
            format!(
                "the model does not honour `{}` cache retention",
                retention_word(cache)
            )
        })
    }
}

/// The lower-case word `cache` serializes as.
pub(super) fn retention_word(cache: &CacheRetention) -> &'static str {
    match cache {
        CacheRetention::None => "none",
        CacheRetention::Short => "short",
        CacheRetention::Long => "long",
    }
}