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, for
69    /// [`crate::Request::effort`].
70    ///
71    /// Kept as strings for the same reason ids are, and the three agents make
72    /// the case on their own: Claude documents five levels, Copilot seven, and
73    /// Codex varies them per model, offering `ultra` on its two frontier models
74    /// and not on the rest. A shared enum would have to be edited before a new
75    /// level could even be named.
76    ///
77    /// Empty means a picker has nothing to offer for this model. That covers
78    /// two cases, and the catalogue comments say which applies: the model
79    /// genuinely accepts no level, as Copilot's `auto` does, or the levels are
80    /// simply not established here. Neither is a promise that a level would be
81    /// refused, since nothing in this crate validates against it.
82    pub efforts: Vec<Cow<'static, str>>,
83    /// Whether the agent uses this when the caller names no model.
84    pub is_default: bool,
85}
86
87impl Model {
88    /// Build a catalogue entry from static parts.
89    fn new(
90        id: &'static str,
91        name: &'static str,
92        note: &'static str,
93        kind: Kind,
94        efforts: &[&'static str],
95        is_default: bool,
96    ) -> Model {
97        Model {
98            id: Cow::Borrowed(id),
99            name: Cow::Borrowed(name),
100            note: Cow::Borrowed(note),
101            kind,
102            efforts: efforts.iter().map(|e| Cow::Borrowed(*e)).collect(),
103            is_default,
104        }
105    }
106}
107
108/// How a catalogue was established, so a stale one can be recognised as stale.
109///
110/// Recorded rather than described in prose because the entries below were
111/// gathered three different ways, and the weakest of them is the one a reader
112/// most needs to know about.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[non_exhaustive]
115pub struct Verified {
116    /// Where the list came from.
117    pub source: Source,
118    /// ISO date it was last checked.
119    pub checked: &'static str,
120    /// The CLI release it was checked against.
121    pub against: &'static str,
122}
123
124/// The kind of evidence behind a catalogue.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127#[non_exhaustive]
128pub enum Source {
129    /// The CLI itself reported it, and can be asked again at runtime. The
130    /// strongest of the three: it cannot drift without the CLI changing.
131    Cli,
132    /// Read out of the CLI's interactive picker. Accurate when taken, but there
133    /// is no way to re-read it without a terminal, so it ages silently.
134    Picker,
135    /// Taken from vendor documentation. Weakest: it describes the product
136    /// rather than the installed binary, and says nothing about entitlement.
137    Docs,
138}
139
140impl Agent {
141    /// The models this agent offers, best first.
142    ///
143    /// Advisory: this is not enforced, and it does not tell you what an account
144    /// may actually use. See [`Model`] and [`Agent::models_verified`].
145    #[must_use]
146    pub fn models(&self) -> Vec<Model> {
147        match self {
148            Agent::Claude => claude_models(),
149            Agent::Codex => codex_models(),
150            Agent::Copilot => copilot_models(),
151        }
152    }
153
154    /// How this agent's compiled-in catalogue was established, and when.
155    #[must_use]
156    pub fn models_verified(&self) -> Verified {
157        match self {
158            // Mixed: the five aliases were read from the `/model` picker, but
159            // the pinned ids and the `best` / `opusplan` / `[1m]` entries come
160            // from documentation. `source` records the weakest evidence behind
161            // any entry, since that is the one a reader needs to distrust.
162            Agent::Claude => Verified {
163                source: Source::Docs,
164                checked: "2026-07-30",
165                against: "claude 2.1.212",
166            },
167            Agent::Codex => Verified {
168                source: Source::Cli,
169                checked: "2026-07-29",
170                against: "codex-cli 0.145.0",
171            },
172            // Read from the `/model` picker. Copilot has no headless list; see
173            // `discover_models`.
174            Agent::Copilot => Verified {
175                source: Source::Picker,
176                checked: "2026-07-29",
177                against: "Copilot CLI 1.0.75",
178            },
179        }
180    }
181
182    /// Ask the installed CLI what models it has, rather than trusting the
183    /// compiled-in list.
184    ///
185    /// Worth preferring wherever it works: it reflects the binary actually
186    /// present instead of the one this crate was written against.
187    ///
188    /// # Errors
189    /// [`Error::Unsupported`] on an agent with no headless way to answer, which
190    /// today is Claude and Copilot. That is deliberately an error rather than a
191    /// silent fall back to [`Agent::models`]: a caller asking for discovery is
192    /// asking for freshness, and handing back a compiled list without saying so
193    /// answers a question they did not ask. [`Error::NotInstalled`] if the
194    /// binary is missing, [`Error::Spawn`] if it cannot be run, and
195    /// [`Error::Parse`] if its output is not the expected shape.
196    pub async fn discover_models(&self) -> Result<Vec<Model>> {
197        match self {
198            Agent::Codex => discover_codex(self.bin()).await,
199            // Neither can be asked without a terminal, verified against
200            // Copilot CLI 1.0.75 and claude 2.1.212. Copilot has no `models`
201            // subcommand, rejects an unknown `--model` without listing the valid
202            // ones, and its ACP `session/new` reply carries session modes and
203            // permissions but no models. Claude documents its aliases in
204            // `--help` but has no subcommand that enumerates them. In both the
205            // interactive `/model` picker is the only listing.
206            Agent::Claude | Agent::Copilot => Err(Error::Unsupported {
207                agent: *self,
208                what: "listing models without a terminal",
209            }),
210        }
211    }
212}
213
214/// Claude, aliases first.
215///
216/// The aliases are the better picker entries and are listed first for that
217/// reason: they resolve to whatever is current, so they survive a model release
218/// and respect what the account is entitled to, which a pinned id does neither
219/// of. Pinned ids follow for a caller who needs one exact model.
220///
221/// Verified against claude 2.1.212 (`--help`) and the published model list,
222/// 2026-07-29.
223/// Claude's effort levels, verified from `claude --help` on 2.1.212:
224/// `--effort <level>` (low, medium, high, xhigh, max).
225///
226/// Session-level rather than per-model, so every entry carries the same set:
227/// `--help` does not vary the choices by model, and a picker reading
228/// [`Model::efforts`] for the selected model gets the right answer either way.
229const CLAUDE_EFFORTS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
230
231fn claude_models() -> Vec<Model> {
232    let mut models = claude_aliases();
233    models.extend(claude_pinned());
234    models
235}
236
237/// The aliases, which are what the `/model` picker offers.
238fn claude_aliases() -> Vec<Model> {
239    vec![
240        Model::new(
241            "default",
242            "Default",
243            "Whatever is recommended for this account, or the organization default",
244            Kind::Alias,
245            CLAUDE_EFFORTS,
246            true,
247        ),
248        Model::new(
249            "opus",
250            "Opus",
251            "Latest Opus, for complex reasoning",
252            Kind::Alias,
253            CLAUDE_EFFORTS,
254            false,
255        ),
256        Model::new(
257            "sonnet",
258            "Sonnet",
259            "Latest Sonnet, for daily coding",
260            Kind::Alias,
261            CLAUDE_EFFORTS,
262            false,
263        ),
264        Model::new(
265            "haiku",
266            "Haiku",
267            "Fast and efficient, for simple tasks",
268            Kind::Alias,
269            CLAUDE_EFFORTS,
270            false,
271        ),
272        Model::new(
273            "fable",
274            "Fable",
275            "For the hardest and longest-running tasks",
276            Kind::Alias,
277            CLAUDE_EFFORTS,
278            false,
279        ),
280        Model::new(
281            "best",
282            "Best available",
283            "Fable where the organization has it, otherwise the latest Opus",
284            Kind::Alias,
285            CLAUDE_EFFORTS,
286            false,
287        ),
288        // Not models. Accepted by `--model` and offered here for that reason,
289        // but a picker that shows them beside the rest will mislead: one swaps
290        // model mid-session and the others only widen the context window.
291        Model::new(
292            "opusplan",
293            "Opus, then Sonnet",
294            "Opus while planning, Sonnet to execute (a mode, not a model)",
295            Kind::Alias,
296            CLAUDE_EFFORTS,
297            false,
298        ),
299        // The `[1m]` suffix widens the context window without changing the
300        // model. Verified by running each on claude 2.1.212 (2026-07-30): the
301        // terminal record reports `contextWindow: 1000000`, keyed by the
302        // suffixed id (`claude-sonnet-5[1m]`). The suffix also composes with a
303        // pinned id: `claude-opus-5[1m]` ran and reported 1M. `fable[1m]` is
304        // accepted too, resolving to plain `claude-fable-5` at 1M, since Fable
305        // is 1M natively and needs no suffix.
306        Model::new(
307            "opus[1m]",
308            "Opus (1M context)",
309            "Opus with a 1M token context window (a variant, not a model)",
310            Kind::Alias,
311            CLAUDE_EFFORTS,
312            false,
313        ),
314        Model::new(
315            "sonnet[1m]",
316            "Sonnet (1M context)",
317            "Sonnet with a 1M token context window (a variant, not a model)",
318            Kind::Alias,
319            CLAUDE_EFFORTS,
320            false,
321        ),
322    ]
323}
324
325/// Pinned ids, which the picker does not list at all.
326///
327/// Its own subtitle says so: "For other/previous model names, specify with
328/// `--model`". They are still worth carrying, because an alias and a pinned id
329/// do not always agree. Verified on 2026-07-29 against claude 2.1.212 by running
330/// both: `--model opus` reported `claude-opus-4-8` in its usage while
331/// `--model claude-opus-5` reported `claude-opus-5`, even though that release's
332/// own notes call Opus 5 "now the default Opus model". An alias is whatever the
333/// account resolves it to, which is not always the newest model.
334fn claude_pinned() -> Vec<Model> {
335    vec![
336        Model::new(
337            "claude-opus-5",
338            "Claude Opus 5",
339            "For complex agentic coding and enterprise work",
340            Kind::Pinned,
341            CLAUDE_EFFORTS,
342            false,
343        ),
344        Model::new(
345            "claude-sonnet-5",
346            "Claude Sonnet 5",
347            "The best combination of speed and intelligence",
348            Kind::Pinned,
349            CLAUDE_EFFORTS,
350            false,
351        ),
352        Model::new(
353            "claude-fable-5",
354            "Claude Fable 5",
355            "Next-generation intelligence for long-running agents",
356            Kind::Pinned,
357            CLAUDE_EFFORTS,
358            false,
359        ),
360        Model::new(
361            "claude-haiku-4-5",
362            "Claude Haiku 4.5",
363            "The fastest model with near-frontier intelligence",
364            Kind::Pinned,
365            CLAUDE_EFFORTS,
366            false,
367        ),
368    ]
369}
370
371/// Codex, in the priority order the CLI itself reports.
372///
373/// Verified by running `codex debug models` against codex-cli 0.145.0 on
374/// 2026-07-29. `codex-auto-review` is reported with `visibility: "hide"` and is
375/// left out for that reason; [`discover_codex`] applies the same filter.
376fn codex_models() -> Vec<Model> {
377    const FULL: &[&str] = &["low", "medium", "high", "xhigh", "max", "ultra"];
378    const TO_MAX: &[&str] = &["low", "medium", "high", "xhigh", "max"];
379    const TO_XHIGH: &[&str] = &["low", "medium", "high", "xhigh"];
380    vec![
381        Model::new(
382            "gpt-5.6-sol",
383            "GPT-5.6-Sol",
384            "Latest frontier agentic coding model.",
385            Kind::Pinned,
386            FULL,
387            true,
388        ),
389        Model::new(
390            "gpt-5.6-terra",
391            "GPT-5.6-Terra",
392            "Balanced agentic coding model for everyday work.",
393            Kind::Pinned,
394            FULL,
395            false,
396        ),
397        Model::new(
398            "gpt-5.6-luna",
399            "GPT-5.6-Luna",
400            "Fast and affordable agentic coding model.",
401            Kind::Pinned,
402            TO_MAX,
403            false,
404        ),
405        Model::new(
406            "gpt-5.5",
407            "GPT-5.5",
408            "Frontier model for complex coding, research, and real-world tasks.",
409            Kind::Pinned,
410            TO_XHIGH,
411            false,
412        ),
413        Model::new(
414            "gpt-5.4",
415            "GPT-5.4",
416            "Strong model for everyday coding.",
417            Kind::Pinned,
418            TO_XHIGH,
419            false,
420        ),
421        Model::new(
422            "gpt-5.4-mini",
423            "GPT-5.4-Mini",
424            "Small, fast, and cost-efficient model for simpler coding tasks.",
425            Kind::Pinned,
426            TO_XHIGH,
427            false,
428        ),
429    ]
430}
431
432/// Copilot, in the order its `/model` picker lists them.
433///
434/// Read from the interactive picker on Copilot CLI 1.0.75, 2026-07-29, because
435/// nothing else enumerates them. Note that the picker lists every model the
436/// product has, not every model the account may use: the same screen carried
437/// "Your Copilot Free plan currently includes only Auto", and on that plan every
438/// id below except `auto` is refused before a request is made.
439/// Copilot's effort levels, verified from `copilot --help` on 1.0.75:
440/// `--effort, --reasoning-effort <level>` (none, minimal, low, medium, high,
441/// xhigh, max).
442///
443/// Two levels wider than Claude's at the bottom, which is why levels are passed
444/// through rather than mapped onto a shared enum.
445///
446/// Applied to the pinned models only. `auto` rejects the flag outright, so
447/// support is not uniform across an agent even when `--help` lists one set, and
448/// these came from `--help` rather than from running each model: a Free plan
449/// permits only `auto`, so there was no way to confirm the rest.
450const COPILOT_EFFORTS: &[&str] = &["none", "minimal", "low", "medium", "high", "xhigh", "max"];
451
452fn copilot_models() -> Vec<Model> {
453    vec![
454        Model::new(
455            "auto",
456            "Auto",
457            "Copilot picks the best available model for each task",
458            Kind::Alias,
459            // Deliberately none. Verified on Copilot CLI 1.0.75 by running it:
460            //   Error: Model "auto" does not support reasoning effort
461            //   configuration (requested: "low").
462            // It exits 1 rather than ignoring the flag, so offering a level for
463            // `auto` in a picker produces a failed run, not a slower one.
464            &[],
465            true,
466        ),
467        pinned("claude-sonnet-5", "Claude Sonnet 5"),
468        pinned("claude-sonnet-4.6", "Claude Sonnet 4.6"),
469        pinned("claude-sonnet-4.5", "Claude Sonnet 4.5"),
470        pinned("claude-haiku-4.5", "Claude Haiku 4.5"),
471        pinned("claude-fable-5", "Claude Fable 5"),
472        pinned("claude-opus-5", "Claude Opus 5"),
473        pinned("claude-opus-4.8", "Claude Opus 4.8"),
474        pinned("claude-opus-4.8-fast", "Claude Opus 4.8 (fast)"),
475        pinned("claude-opus-4.7", "Claude Opus 4.7"),
476        pinned("claude-opus-4.6", "Claude Opus 4.6"),
477        pinned("claude-opus-4.5", "Claude Opus 4.5"),
478        pinned("gpt-5.6-sol", "GPT-5.6-Sol"),
479        pinned("gpt-5.6-terra", "GPT-5.6-Terra"),
480        pinned("gpt-5.6-luna", "GPT-5.6-Luna"),
481        pinned("gpt-5.5", "GPT-5.5"),
482        pinned("gpt-5.4", "GPT-5.4"),
483        pinned("gpt-5.3-codex", "GPT-5.3-Codex"),
484        pinned("gpt-5.4-mini", "GPT-5.4-Mini"),
485        pinned("gpt-5-mini", "GPT-5 mini"),
486        pinned("gemini-3.1-pro-preview", "Gemini 3.1 Pro (preview)"),
487        pinned("gemini-3.6-flash", "Gemini 3.6 Flash"),
488        pinned("gemini-3.5-flash", "Gemini 3.5 Flash"),
489        pinned("kimi-k2.7-code", "Kimi K2.7 Code"),
490    ]
491}
492
493/// A pinned entry with no vendor description, which is every Copilot model: its
494/// picker shows ids and nothing else.
495fn pinned(id: &'static str, name: &'static str) -> Model {
496    Model::new(id, name, "", Kind::Pinned, COPILOT_EFFORTS, false)
497}
498
499/// Read Codex's own model list.
500///
501/// `codex debug models` prints one JSON document carrying every model plus each
502/// one's full system prompt, so the reply runs to hundreds of kilobytes. Only
503/// the descriptive fields are kept.
504async fn discover_codex(bin: &str) -> Result<Vec<Model>> {
505    let output = tokio::process::Command::new(bin)
506        .args(["debug", "models"])
507        .output()
508        .await
509        .map_err(|source| {
510            if source.kind() == std::io::ErrorKind::NotFound {
511                Error::NotInstalled {
512                    agent: Agent::Codex,
513                    bin: bin.to_string(),
514                    hint: Agent::Codex.install_hint(),
515                }
516            } else {
517                Error::Spawn {
518                    bin: bin.to_string(),
519                    source,
520                }
521            }
522        })?;
523
524    let stdout = String::from_utf8_lossy(&output.stdout);
525    parse_codex_models(&stdout)
526}
527
528/// Turn `codex debug models` output into catalogue entries.
529///
530/// Split from the spawn so the shape can be tested without a subprocess.
531fn parse_codex_models(stdout: &str) -> Result<Vec<Model>> {
532    let value: Value = serde_json::from_str(stdout.trim()).map_err(|e| Error::Parse {
533        agent: Agent::Codex,
534        detail: format!("`codex debug models` did not return JSON: {e}"),
535    })?;
536    let listed = value
537        .get("models")
538        .and_then(Value::as_array)
539        .ok_or_else(|| Error::Parse {
540            agent: Agent::Codex,
541            detail: "`codex debug models` returned no `models` array".into(),
542        })?;
543
544    // `priority` is the vendor's own display order and is not the array order,
545    // so it is read rather than assumed.
546    let mut ranked: Vec<(u64, Model)> = listed
547        .iter()
548        // `visibility` is how Codex marks its internal models, and
549        // `codex-auto-review` is one. Offering it in a picker hands a user a
550        // model the vendor deliberately withheld.
551        .filter(|m| m.get("visibility").and_then(Value::as_str) != Some("hide"))
552        .filter_map(|m| {
553            let id = m.get("slug").and_then(Value::as_str)?;
554            let model = Model {
555                id: id.to_string().into(),
556                name: m
557                    .get("display_name")
558                    .and_then(Value::as_str)
559                    .unwrap_or(id)
560                    .to_string()
561                    .into(),
562                note: m
563                    .get("description")
564                    .and_then(Value::as_str)
565                    .unwrap_or_default()
566                    .to_string()
567                    .into(),
568                kind: Kind::Pinned,
569                efforts: m
570                    .get("supported_reasoning_levels")
571                    .and_then(Value::as_array)
572                    .map(|levels| {
573                        levels
574                            .iter()
575                            .filter_map(|l| l.get("effort").and_then(Value::as_str))
576                            .map(|e| Cow::Owned(e.to_string()))
577                            .collect()
578                    })
579                    .unwrap_or_default(),
580                // Codex names a default reasoning level per model but never a
581                // default model, so the top of its own ordering stands in.
582                is_default: false,
583            };
584            let priority = m
585                .get("priority")
586                .and_then(Value::as_u64)
587                .unwrap_or(u64::MAX);
588            Some((priority, model))
589        })
590        .collect();
591
592    if ranked.is_empty() {
593        return Err(Error::Parse {
594            agent: Agent::Codex,
595            detail: "`codex debug models` listed no visible models".into(),
596        });
597    }
598    ranked.sort_by_key(|(priority, _)| *priority);
599
600    let mut models: Vec<Model> = ranked.into_iter().map(|(_, model)| model).collect();
601    if let Some(first) = models.first_mut() {
602        first.is_default = true;
603    }
604    Ok(models)
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    /// Trimmed from real `codex debug models` output (codex-cli 0.145.0). The
612    /// hidden entry and the out-of-order priorities are both as reported.
613    const CODEX_OUTPUT: &str = r#"{"models":[
614      {"slug":"gpt-5.5","display_name":"GPT-5.5","description":"Frontier model.",
615       "default_reasoning_level":"medium","visibility":"list","priority":7,
616       "supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"}]},
617      {"slug":"codex-auto-review","display_name":"Codex Auto Review","description":"Internal.",
618       "visibility":"hide","priority":43,"supported_reasoning_levels":[{"effort":"low"}]},
619      {"slug":"gpt-5.6-sol","display_name":"GPT-5.6-Sol","description":"Latest frontier model.",
620       "default_reasoning_level":"low","visibility":"list","priority":1,
621       "supported_reasoning_levels":[{"effort":"low"},{"effort":"ultra"}]}
622    ]}"#;
623
624    #[test]
625    fn codex_discovery_reads_the_fields_a_picker_needs() {
626        let models = parse_codex_models(CODEX_OUTPUT).expect("should parse");
627        let sol = &models[0];
628        assert_eq!(sol.id, "gpt-5.6-sol");
629        assert_eq!(sol.name, "GPT-5.6-Sol");
630        assert_eq!(sol.note, "Latest frontier model.");
631        assert_eq!(sol.efforts, vec!["low", "ultra"]);
632    }
633
634    /// The array order is not the display order: `gpt-5.5` is listed first and
635    /// carries priority 7, while `gpt-5.6-sol` is listed last at priority 1.
636    #[test]
637    fn codex_discovery_uses_the_vendors_ordering_not_the_array_order() {
638        let models = parse_codex_models(CODEX_OUTPUT).expect("should parse");
639        let ids: Vec<&str> = models.iter().map(|m| m.id.as_ref()).collect();
640        assert_eq!(ids, ["gpt-5.6-sol", "gpt-5.5"]);
641        assert!(
642            models[0].is_default,
643            "the top-priority model is the default"
644        );
645    }
646
647    /// Codex marks its internal models `hide`. Offering one in a picker hands a
648    /// user a model the vendor deliberately withheld.
649    #[test]
650    fn codex_discovery_drops_models_the_vendor_hides() {
651        let models = parse_codex_models(CODEX_OUTPUT).expect("should parse");
652        assert!(
653            !models.iter().any(|m| m.id == "codex-auto-review"),
654            "a hidden model must not reach a picker"
655        );
656    }
657
658    #[test]
659    fn unparseable_output_is_an_error_not_an_empty_list() {
660        assert!(matches!(
661            parse_codex_models("Reading additional input from stdin..."),
662            Err(Error::Parse { .. })
663        ));
664        assert!(
665            matches!(
666                parse_codex_models(r#"{"models":[]}"#),
667                Err(Error::Parse { .. })
668            ),
669            "an empty list means the shape changed, not that Codex has no models"
670        );
671    }
672
673    /// Discovery must not quietly answer with the compiled-in list: a caller
674    /// asking for it is asking for freshness, and a silent fallback answers a
675    /// different question.
676    #[tokio::test]
677    async fn agents_that_cannot_be_asked_say_so() {
678        for agent in [Agent::Claude, Agent::Copilot] {
679            assert!(
680                matches!(
681                    agent.discover_models().await,
682                    Err(Error::Unsupported { .. })
683                ),
684                "{agent} should report that it cannot enumerate models"
685            );
686        }
687    }
688
689    /// The gap this closes: every Claude and Copilot entry shipped with an
690    /// empty `efforts` while both CLIs document a `--effort` flag, so a picker
691    /// had nothing to offer.
692    #[test]
693    fn every_model_reports_the_levels_its_agent_accepts() {
694        for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
695            for model in agent.models() {
696                // `auto` is the documented exception, asserted below.
697                if agent == Agent::Copilot && model.id == "auto" {
698                    continue;
699                }
700                assert!(
701                    !model.efforts.is_empty(),
702                    "{agent} model {} reports no effort levels",
703                    model.id
704                );
705            }
706        }
707    }
708
709    /// Support is not uniform across an agent even where `--help` lists one set.
710    /// Copilot exits 1 for an effort on `auto` rather than ignoring it, so
711    /// offering a level there would produce a failed run.
712    #[test]
713    fn copilot_auto_offers_no_levels_because_it_refuses_them() {
714        let models = Agent::Copilot.models();
715        let auto = models.iter().find(|m| m.id == "auto").expect("auto");
716        assert!(
717            auto.efforts.is_empty(),
718            "auto rejects the effort flag outright"
719        );
720        let pinned = models.iter().find(|m| m.id == "gpt-5.5").expect("gpt-5.5");
721        assert!(
722            !pinned.efforts.is_empty(),
723            "pinned models do document levels"
724        );
725    }
726
727    /// Verified from `claude --help` (2.1.212) and `copilot --help` (1.0.75).
728    /// Copilot's set is two wider at the bottom, which is the whole reason
729    /// levels are strings rather than a shared enum.
730    #[test]
731    fn the_documented_level_sets_are_not_interchangeable() {
732        let claude = &Agent::Claude.models()[0].efforts;
733        let copilot_models = Agent::Copilot.models();
734        let copilot = &copilot_models
735            .iter()
736            .find(|m| m.id == "gpt-5.5")
737            .expect("gpt-5.5")
738            .efforts;
739        assert_eq!(claude, &["low", "medium", "high", "xhigh", "max"]);
740        assert_eq!(
741            copilot,
742            &["none", "minimal", "low", "medium", "high", "xhigh", "max"]
743        );
744        assert_ne!(claude, copilot, "a shared enum would have to cover both");
745    }
746
747    /// Codex is the one agent whose levels differ per model, which is why they
748    /// live on the model rather than on the agent.
749    #[test]
750    fn codex_levels_differ_between_its_own_models() {
751        let models = Agent::Codex.models();
752        let by_id = |id: &str| -> Vec<String> {
753            models
754                .iter()
755                .find(|m| m.id == id)
756                .unwrap_or_else(|| panic!("{id} should be catalogued"))
757                .efforts
758                .iter()
759                .map(ToString::to_string)
760                .collect()
761        };
762        assert!(
763            by_id("gpt-5.6-sol").contains(&"ultra".to_string()),
764            "its frontier model offers ultra"
765        );
766        assert!(
767            !by_id("gpt-5.6-luna").contains(&"ultra".to_string()),
768            "its fast model does not"
769        );
770    }
771
772    #[test]
773    fn every_agent_offers_exactly_one_default() {
774        for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
775            let defaults = agent.models().iter().filter(|m| m.is_default).count();
776            assert_eq!(defaults, 1, "{agent} should mark exactly one default");
777        }
778    }
779
780    #[test]
781    fn no_catalogue_repeats_an_id() {
782        for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
783            let models = agent.models();
784            let mut ids: Vec<&str> = models.iter().map(|m| m.id.as_ref()).collect();
785            ids.sort_unstable();
786            let count = ids.len();
787            ids.dedup();
788            assert_eq!(ids.len(), count, "{agent} has a duplicate model id");
789        }
790    }
791
792    /// The catalogue is a suggestion, not a gate. A model released after this
793    /// list was compiled has to reach the command line untouched.
794    #[test]
795    fn an_unlisted_model_is_still_accepted() {
796        let request = crate::Request::new(Agent::Claude, "hi").model("some-model-from-next-year");
797        let argv = request
798            .argv()
799            .expect("an unlisted model must not be rejected");
800        assert!(
801            argv.windows(2)
802                .any(|w| w[0] == "--model" && w[1] == "some-model-from-next-year"),
803            "the model should reach the command line verbatim: {argv:?}"
804        );
805    }
806}