Skip to main content

agent_abstraction/
model.rs

1//! Which models each agent offers, so a host can render a picker.
2//!
3//! The catalogue is **advisory and never enforced**. [`crate::Request::model`]
4//! takes any string and this crate does not check it against anything here. A
5//! model that shipped this morning must not be blocked by a list compiled last
6//! month, and a picked model the account cannot reach fails as
7//! [`crate::Error::AgentError`] carrying the provider's own status and wording.
8//! Enforcing the list would trade a clear runtime error for a wrong compile-time
9//! one.
10//!
11//! # A catalogue is not an entitlement
12//!
13//! What an agent *offers* and what an account may *use* are different sets, and
14//! only the account knows the second one. On a Copilot Free plan the picker
15//! lists twenty-three models and permits exactly one:
16//!
17//! ```text
18//! Your Copilot Free plan currently includes only Auto, which automatically
19//! selects the best available model for each task.
20//! ```
21//!
22//! Every other id there is rejected before a request is made, including
23//! `gpt-5.4`, the example in Copilot's own `--help`. So a host should present
24//! this list as choices to try, not as promises, and let the run report what the
25//! account actually allows. [`Model::is_default`] marks the one an agent falls
26//! back to, which is the safe pre-selection.
27//!
28//! # Where the entries come from
29//!
30//! [`Agent::models`] is a compiled-in list with its provenance recorded in
31//! [`Agent::models_verified`], because two of the three agents cannot be asked.
32//! [`Agent::discover_models`] asks the CLI itself where that is possible, and
33//! returns [`crate::Error::Unsupported`] where it is not, rather than quietly
34//! handing back the compiled list under a name that promises freshness.
35
36use std::borrow::Cow;
37
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40
41use crate::agent::Agent;
42use crate::error::{Error, Result};
43
44/// Whether an id names a specific model or points at whichever is current.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47#[non_exhaustive]
48pub enum Kind {
49    /// Resolves to whatever is newest in a family, so it survives a release.
50    /// Claude's `opus` and Copilot's `auto` are both this.
51    Alias,
52    /// Names one model. Reproducible, and goes stale on its own schedule.
53    Pinned,
54}
55
56/// One model a caller can choose.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[non_exhaustive]
59pub struct Model {
60    /// Exactly what goes to `--model`. Passed through verbatim.
61    pub id: Cow<'static, str>,
62    /// The vendor's own display name, for a picker.
63    pub name: Cow<'static, str>,
64    /// One line on what it is for. Empty when the vendor offers none.
65    pub note: Cow<'static, str>,
66    /// Whether the id tracks a family or names one model.
67    pub kind: Kind,
68    /// Reasoning levels this model accepts, in the vendor's order.
69    ///
70    /// Empty means "not established here", not "none exist": Claude Code has an
71    /// effort setting of its own, shown in its `/model` picker, but it is not a
72    /// `--model` value and its levels have not been verified. Kept as strings
73    /// for the same reason ids are:
74    /// Codex added `ultra` to some models and not others, and an enum here would
75    /// have to be edited before a new level could even be named.
76    pub efforts: Vec<Cow<'static, str>>,
77    /// Whether the agent uses this when the caller names no model.
78    pub is_default: bool,
79}
80
81impl Model {
82    /// Build a catalogue entry from static parts.
83    fn new(
84        id: &'static str,
85        name: &'static str,
86        note: &'static str,
87        kind: Kind,
88        efforts: &[&'static str],
89        is_default: bool,
90    ) -> Model {
91        Model {
92            id: Cow::Borrowed(id),
93            name: Cow::Borrowed(name),
94            note: Cow::Borrowed(note),
95            kind,
96            efforts: efforts.iter().map(|e| Cow::Borrowed(*e)).collect(),
97            is_default,
98        }
99    }
100}
101
102/// How a catalogue was established, so a stale one can be recognised as stale.
103///
104/// Recorded rather than described in prose because the entries below were
105/// gathered three different ways, and the weakest of them is the one a reader
106/// most needs to know about.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[non_exhaustive]
109pub struct Verified {
110    /// Where the list came from.
111    pub source: Source,
112    /// ISO date it was last checked.
113    pub checked: &'static str,
114    /// The CLI release it was checked against.
115    pub against: &'static str,
116}
117
118/// The kind of evidence behind a catalogue.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121#[non_exhaustive]
122pub enum Source {
123    /// The CLI itself reported it, and can be asked again at runtime. The
124    /// strongest of the three: it cannot drift without the CLI changing.
125    Cli,
126    /// Read out of the CLI's interactive picker. Accurate when taken, but there
127    /// is no way to re-read it without a terminal, so it ages silently.
128    Picker,
129    /// Taken from vendor documentation. Weakest: it describes the product
130    /// rather than the installed binary, and says nothing about entitlement.
131    Docs,
132}
133
134impl Agent {
135    /// The models this agent offers, best first.
136    ///
137    /// Advisory: this is not enforced, and it does not tell you what an account
138    /// may actually use. See [`Model`] and [`Agent::models_verified`].
139    #[must_use]
140    pub fn models(&self) -> Vec<Model> {
141        match self {
142            Agent::Claude => claude_models(),
143            Agent::Codex => codex_models(),
144            Agent::Copilot => copilot_models(),
145        }
146    }
147
148    /// How this agent's compiled-in catalogue was established, and when.
149    #[must_use]
150    pub fn models_verified(&self) -> Verified {
151        match self {
152            // Mixed: the five aliases were read from the `/model` picker, but
153            // the pinned ids and the `best` / `opusplan` / `[1m]` entries come
154            // from documentation. `source` records the weakest evidence behind
155            // any entry, since that is the one a reader needs to distrust.
156            Agent::Claude => Verified {
157                source: Source::Docs,
158                checked: "2026-07-29",
159                against: "claude 2.1.212",
160            },
161            Agent::Codex => Verified {
162                source: Source::Cli,
163                checked: "2026-07-29",
164                against: "codex-cli 0.145.0",
165            },
166            // Read from the `/model` picker. Copilot has no headless list; see
167            // `discover_models`.
168            Agent::Copilot => Verified {
169                source: Source::Picker,
170                checked: "2026-07-29",
171                against: "Copilot CLI 1.0.75",
172            },
173        }
174    }
175
176    /// Ask the installed CLI what models it has, rather than trusting the
177    /// compiled-in list.
178    ///
179    /// Worth preferring wherever it works: it reflects the binary actually
180    /// present instead of the one this crate was written against.
181    ///
182    /// # Errors
183    /// [`Error::Unsupported`] on an agent with no headless way to answer, which
184    /// today is Claude and Copilot. That is deliberately an error rather than a
185    /// silent fall back to [`Agent::models`]: a caller asking for discovery is
186    /// asking for freshness, and handing back a compiled list without saying so
187    /// answers a question they did not ask. [`Error::NotInstalled`] if the
188    /// binary is missing, [`Error::Spawn`] if it cannot be run, and
189    /// [`Error::Parse`] if its output is not the expected shape.
190    pub async fn discover_models(&self) -> Result<Vec<Model>> {
191        match self {
192            Agent::Codex => discover_codex(self.bin()).await,
193            // Neither can be asked without a terminal, verified against
194            // Copilot CLI 1.0.75 and claude 2.1.212. Copilot has no `models`
195            // subcommand, rejects an unknown `--model` without listing the valid
196            // ones, and its ACP `session/new` reply carries session modes and
197            // permissions but no models. Claude documents its aliases in
198            // `--help` but has no subcommand that enumerates them. In both the
199            // interactive `/model` picker is the only listing.
200            Agent::Claude | Agent::Copilot => Err(Error::Unsupported {
201                agent: *self,
202                what: "listing models without a terminal",
203            }),
204        }
205    }
206}
207
208/// Claude, aliases first.
209///
210/// The aliases are the better picker entries and are listed first for that
211/// reason: they resolve to whatever is current, so they survive a model release
212/// and respect what the account is entitled to, which a pinned id does neither
213/// of. Pinned ids follow for a caller who needs one exact model.
214///
215/// Verified against claude 2.1.212 (`--help`) and the published model list,
216/// 2026-07-29.
217fn claude_models() -> Vec<Model> {
218    let mut models = claude_aliases();
219    models.extend(claude_pinned());
220    models
221}
222
223/// The aliases, which are what the `/model` picker offers.
224fn claude_aliases() -> Vec<Model> {
225    vec![
226        Model::new(
227            "default",
228            "Default",
229            "Whatever is recommended for this account, or the organization default",
230            Kind::Alias,
231            &[],
232            true,
233        ),
234        Model::new(
235            "opus",
236            "Opus",
237            "Latest Opus, for complex reasoning",
238            Kind::Alias,
239            &[],
240            false,
241        ),
242        Model::new(
243            "sonnet",
244            "Sonnet",
245            "Latest Sonnet, for daily coding",
246            Kind::Alias,
247            &[],
248            false,
249        ),
250        Model::new(
251            "haiku",
252            "Haiku",
253            "Fast and efficient, for simple tasks",
254            Kind::Alias,
255            &[],
256            false,
257        ),
258        Model::new(
259            "fable",
260            "Fable",
261            "For the hardest and longest-running tasks",
262            Kind::Alias,
263            &[],
264            false,
265        ),
266        Model::new(
267            "best",
268            "Best available",
269            "Fable where the organization has it, otherwise the latest Opus",
270            Kind::Alias,
271            &[],
272            false,
273        ),
274        // Not models. Accepted by `--model` and offered here for that reason,
275        // but a picker that shows them beside the rest will mislead: one swaps
276        // model mid-session and the others only widen the context window.
277        Model::new(
278            "opusplan",
279            "Opus, then Sonnet",
280            "Opus while planning, Sonnet to execute (a mode, not a model)",
281            Kind::Alias,
282            &[],
283            false,
284        ),
285        Model::new(
286            "opus[1m]",
287            "Opus (1M context)",
288            "Opus with a 1M token context window (a variant, not a model)",
289            Kind::Alias,
290            &[],
291            false,
292        ),
293        Model::new(
294            "sonnet[1m]",
295            "Sonnet (1M context)",
296            "Sonnet with a 1M token context window (a variant, not a model)",
297            Kind::Alias,
298            &[],
299            false,
300        ),
301    ]
302}
303
304/// Pinned ids, which the picker does not list at all.
305///
306/// Its own subtitle says so: "For other/previous model names, specify with
307/// `--model`". They are still worth carrying, because an alias and a pinned id
308/// do not always agree. Verified on 2026-07-29 against claude 2.1.212 by running
309/// both: `--model opus` reported `claude-opus-4-8` in its usage while
310/// `--model claude-opus-5` reported `claude-opus-5`, even though that release's
311/// own notes call Opus 5 "now the default Opus model". An alias is whatever the
312/// account resolves it to, which is not always the newest model.
313fn claude_pinned() -> Vec<Model> {
314    vec![
315        Model::new(
316            "claude-opus-5",
317            "Claude Opus 5",
318            "For complex agentic coding and enterprise work",
319            Kind::Pinned,
320            &[],
321            false,
322        ),
323        Model::new(
324            "claude-sonnet-5",
325            "Claude Sonnet 5",
326            "The best combination of speed and intelligence",
327            Kind::Pinned,
328            &[],
329            false,
330        ),
331        Model::new(
332            "claude-fable-5",
333            "Claude Fable 5",
334            "Next-generation intelligence for long-running agents",
335            Kind::Pinned,
336            &[],
337            false,
338        ),
339        Model::new(
340            "claude-haiku-4-5",
341            "Claude Haiku 4.5",
342            "The fastest model with near-frontier intelligence",
343            Kind::Pinned,
344            &[],
345            false,
346        ),
347    ]
348}
349
350/// Codex, in the priority order the CLI itself reports.
351///
352/// Verified by running `codex debug models` against codex-cli 0.145.0 on
353/// 2026-07-29. `codex-auto-review` is reported with `visibility: "hide"` and is
354/// left out for that reason; [`discover_codex`] applies the same filter.
355fn codex_models() -> Vec<Model> {
356    const FULL: &[&str] = &["low", "medium", "high", "xhigh", "max", "ultra"];
357    const TO_MAX: &[&str] = &["low", "medium", "high", "xhigh", "max"];
358    const TO_XHIGH: &[&str] = &["low", "medium", "high", "xhigh"];
359    vec![
360        Model::new(
361            "gpt-5.6-sol",
362            "GPT-5.6-Sol",
363            "Latest frontier agentic coding model.",
364            Kind::Pinned,
365            FULL,
366            true,
367        ),
368        Model::new(
369            "gpt-5.6-terra",
370            "GPT-5.6-Terra",
371            "Balanced agentic coding model for everyday work.",
372            Kind::Pinned,
373            FULL,
374            false,
375        ),
376        Model::new(
377            "gpt-5.6-luna",
378            "GPT-5.6-Luna",
379            "Fast and affordable agentic coding model.",
380            Kind::Pinned,
381            TO_MAX,
382            false,
383        ),
384        Model::new(
385            "gpt-5.5",
386            "GPT-5.5",
387            "Frontier model for complex coding, research, and real-world tasks.",
388            Kind::Pinned,
389            TO_XHIGH,
390            false,
391        ),
392        Model::new(
393            "gpt-5.4",
394            "GPT-5.4",
395            "Strong model for everyday coding.",
396            Kind::Pinned,
397            TO_XHIGH,
398            false,
399        ),
400        Model::new(
401            "gpt-5.4-mini",
402            "GPT-5.4-Mini",
403            "Small, fast, and cost-efficient model for simpler coding tasks.",
404            Kind::Pinned,
405            TO_XHIGH,
406            false,
407        ),
408    ]
409}
410
411/// Copilot, in the order its `/model` picker lists them.
412///
413/// Read from the interactive picker on Copilot CLI 1.0.75, 2026-07-29, because
414/// nothing else enumerates them. Note that the picker lists every model the
415/// product has, not every model the account may use: the same screen carried
416/// "Your Copilot Free plan currently includes only Auto", and on that plan every
417/// id below except `auto` is refused before a request is made.
418fn copilot_models() -> Vec<Model> {
419    vec![
420        Model::new(
421            "auto",
422            "Auto",
423            "Copilot picks the best available model for each task",
424            Kind::Alias,
425            &[],
426            true,
427        ),
428        pinned("claude-sonnet-5", "Claude Sonnet 5"),
429        pinned("claude-sonnet-4.6", "Claude Sonnet 4.6"),
430        pinned("claude-sonnet-4.5", "Claude Sonnet 4.5"),
431        pinned("claude-haiku-4.5", "Claude Haiku 4.5"),
432        pinned("claude-fable-5", "Claude Fable 5"),
433        pinned("claude-opus-5", "Claude Opus 5"),
434        pinned("claude-opus-4.8", "Claude Opus 4.8"),
435        pinned("claude-opus-4.8-fast", "Claude Opus 4.8 (fast)"),
436        pinned("claude-opus-4.7", "Claude Opus 4.7"),
437        pinned("claude-opus-4.6", "Claude Opus 4.6"),
438        pinned("claude-opus-4.5", "Claude Opus 4.5"),
439        pinned("gpt-5.6-sol", "GPT-5.6-Sol"),
440        pinned("gpt-5.6-terra", "GPT-5.6-Terra"),
441        pinned("gpt-5.6-luna", "GPT-5.6-Luna"),
442        pinned("gpt-5.5", "GPT-5.5"),
443        pinned("gpt-5.4", "GPT-5.4"),
444        pinned("gpt-5.3-codex", "GPT-5.3-Codex"),
445        pinned("gpt-5.4-mini", "GPT-5.4-Mini"),
446        pinned("gpt-5-mini", "GPT-5 mini"),
447        pinned("gemini-3.1-pro-preview", "Gemini 3.1 Pro (preview)"),
448        pinned("gemini-3.6-flash", "Gemini 3.6 Flash"),
449        pinned("gemini-3.5-flash", "Gemini 3.5 Flash"),
450        pinned("kimi-k2.7-code", "Kimi K2.7 Code"),
451    ]
452}
453
454/// A pinned entry with no vendor description, which is every Copilot model: its
455/// picker shows ids and nothing else.
456fn pinned(id: &'static str, name: &'static str) -> Model {
457    Model::new(id, name, "", Kind::Pinned, &[], false)
458}
459
460/// Read Codex's own model list.
461///
462/// `codex debug models` prints one JSON document carrying every model plus each
463/// one's full system prompt, so the reply runs to hundreds of kilobytes. Only
464/// the descriptive fields are kept.
465async fn discover_codex(bin: &str) -> Result<Vec<Model>> {
466    let output = tokio::process::Command::new(bin)
467        .args(["debug", "models"])
468        .output()
469        .await
470        .map_err(|source| {
471            if source.kind() == std::io::ErrorKind::NotFound {
472                Error::NotInstalled {
473                    agent: Agent::Codex,
474                    bin: bin.to_string(),
475                    hint: Agent::Codex.install_hint(),
476                }
477            } else {
478                Error::Spawn {
479                    bin: bin.to_string(),
480                    source,
481                }
482            }
483        })?;
484
485    let stdout = String::from_utf8_lossy(&output.stdout);
486    parse_codex_models(&stdout)
487}
488
489/// Turn `codex debug models` output into catalogue entries.
490///
491/// Split from the spawn so the shape can be tested without a subprocess.
492fn parse_codex_models(stdout: &str) -> Result<Vec<Model>> {
493    let value: Value = serde_json::from_str(stdout.trim()).map_err(|e| Error::Parse {
494        agent: Agent::Codex,
495        detail: format!("`codex debug models` did not return JSON: {e}"),
496    })?;
497    let listed = value
498        .get("models")
499        .and_then(Value::as_array)
500        .ok_or_else(|| Error::Parse {
501            agent: Agent::Codex,
502            detail: "`codex debug models` returned no `models` array".into(),
503        })?;
504
505    // `priority` is the vendor's own display order and is not the array order,
506    // so it is read rather than assumed.
507    let mut ranked: Vec<(u64, Model)> = listed
508        .iter()
509        // `visibility` is how Codex marks its internal models, and
510        // `codex-auto-review` is one. Offering it in a picker hands a user a
511        // model the vendor deliberately withheld.
512        .filter(|m| m.get("visibility").and_then(Value::as_str) != Some("hide"))
513        .filter_map(|m| {
514            let id = m.get("slug").and_then(Value::as_str)?;
515            let model = Model {
516                id: id.to_string().into(),
517                name: m
518                    .get("display_name")
519                    .and_then(Value::as_str)
520                    .unwrap_or(id)
521                    .to_string()
522                    .into(),
523                note: m
524                    .get("description")
525                    .and_then(Value::as_str)
526                    .unwrap_or_default()
527                    .to_string()
528                    .into(),
529                kind: Kind::Pinned,
530                efforts: m
531                    .get("supported_reasoning_levels")
532                    .and_then(Value::as_array)
533                    .map(|levels| {
534                        levels
535                            .iter()
536                            .filter_map(|l| l.get("effort").and_then(Value::as_str))
537                            .map(|e| Cow::Owned(e.to_string()))
538                            .collect()
539                    })
540                    .unwrap_or_default(),
541                // Codex names a default reasoning level per model but never a
542                // default model, so the top of its own ordering stands in.
543                is_default: false,
544            };
545            let priority = m
546                .get("priority")
547                .and_then(Value::as_u64)
548                .unwrap_or(u64::MAX);
549            Some((priority, model))
550        })
551        .collect();
552
553    if ranked.is_empty() {
554        return Err(Error::Parse {
555            agent: Agent::Codex,
556            detail: "`codex debug models` listed no visible models".into(),
557        });
558    }
559    ranked.sort_by_key(|(priority, _)| *priority);
560
561    let mut models: Vec<Model> = ranked.into_iter().map(|(_, model)| model).collect();
562    if let Some(first) = models.first_mut() {
563        first.is_default = true;
564    }
565    Ok(models)
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    /// Trimmed from real `codex debug models` output (codex-cli 0.145.0). The
573    /// hidden entry and the out-of-order priorities are both as reported.
574    const CODEX_OUTPUT: &str = r#"{"models":[
575      {"slug":"gpt-5.5","display_name":"GPT-5.5","description":"Frontier model.",
576       "default_reasoning_level":"medium","visibility":"list","priority":7,
577       "supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"}]},
578      {"slug":"codex-auto-review","display_name":"Codex Auto Review","description":"Internal.",
579       "visibility":"hide","priority":43,"supported_reasoning_levels":[{"effort":"low"}]},
580      {"slug":"gpt-5.6-sol","display_name":"GPT-5.6-Sol","description":"Latest frontier model.",
581       "default_reasoning_level":"low","visibility":"list","priority":1,
582       "supported_reasoning_levels":[{"effort":"low"},{"effort":"ultra"}]}
583    ]}"#;
584
585    #[test]
586    fn codex_discovery_reads_the_fields_a_picker_needs() {
587        let models = parse_codex_models(CODEX_OUTPUT).expect("should parse");
588        let sol = &models[0];
589        assert_eq!(sol.id, "gpt-5.6-sol");
590        assert_eq!(sol.name, "GPT-5.6-Sol");
591        assert_eq!(sol.note, "Latest frontier model.");
592        assert_eq!(sol.efforts, vec!["low", "ultra"]);
593    }
594
595    /// The array order is not the display order: `gpt-5.5` is listed first and
596    /// carries priority 7, while `gpt-5.6-sol` is listed last at priority 1.
597    #[test]
598    fn codex_discovery_uses_the_vendors_ordering_not_the_array_order() {
599        let models = parse_codex_models(CODEX_OUTPUT).expect("should parse");
600        let ids: Vec<&str> = models.iter().map(|m| m.id.as_ref()).collect();
601        assert_eq!(ids, ["gpt-5.6-sol", "gpt-5.5"]);
602        assert!(
603            models[0].is_default,
604            "the top-priority model is the default"
605        );
606    }
607
608    /// Codex marks its internal models `hide`. Offering one in a picker hands a
609    /// user a model the vendor deliberately withheld.
610    #[test]
611    fn codex_discovery_drops_models_the_vendor_hides() {
612        let models = parse_codex_models(CODEX_OUTPUT).expect("should parse");
613        assert!(
614            !models.iter().any(|m| m.id == "codex-auto-review"),
615            "a hidden model must not reach a picker"
616        );
617    }
618
619    #[test]
620    fn unparseable_output_is_an_error_not_an_empty_list() {
621        assert!(matches!(
622            parse_codex_models("Reading additional input from stdin..."),
623            Err(Error::Parse { .. })
624        ));
625        assert!(
626            matches!(
627                parse_codex_models(r#"{"models":[]}"#),
628                Err(Error::Parse { .. })
629            ),
630            "an empty list means the shape changed, not that Codex has no models"
631        );
632    }
633
634    /// Discovery must not quietly answer with the compiled-in list: a caller
635    /// asking for it is asking for freshness, and a silent fallback answers a
636    /// different question.
637    #[tokio::test]
638    async fn agents_that_cannot_be_asked_say_so() {
639        for agent in [Agent::Claude, Agent::Copilot] {
640            assert!(
641                matches!(
642                    agent.discover_models().await,
643                    Err(Error::Unsupported { .. })
644                ),
645                "{agent} should report that it cannot enumerate models"
646            );
647        }
648    }
649
650    #[test]
651    fn every_agent_offers_exactly_one_default() {
652        for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
653            let defaults = agent.models().iter().filter(|m| m.is_default).count();
654            assert_eq!(defaults, 1, "{agent} should mark exactly one default");
655        }
656    }
657
658    #[test]
659    fn no_catalogue_repeats_an_id() {
660        for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
661            let models = agent.models();
662            let mut ids: Vec<&str> = models.iter().map(|m| m.id.as_ref()).collect();
663            ids.sort_unstable();
664            let count = ids.len();
665            ids.dedup();
666            assert_eq!(ids.len(), count, "{agent} has a duplicate model id");
667        }
668    }
669
670    /// The catalogue is a suggestion, not a gate. A model released after this
671    /// list was compiled has to reach the command line untouched.
672    #[test]
673    fn an_unlisted_model_is_still_accepted() {
674        let request = crate::Request::new(Agent::Claude, "hi").model("some-model-from-next-year");
675        let argv = request
676            .argv()
677            .expect("an unlisted model must not be rejected");
678        assert!(
679            argv.windows(2)
680                .any(|w| w[0] == "--model" && w[1] == "some-model-from-next-year"),
681            "the model should reach the command line verbatim: {argv:?}"
682        );
683    }
684}