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-08-07",
170 against: "codex-cli 0.146.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 (1M context)",
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 // Windows verified by running each id on claude 2.1.212 (2026-07-30).
337 // `claude-opus-5` is the odd one out: every other 5-series model is 1M
338 // natively, while it defaults to 200k and needs the suffix. Both forms
339 // are catalogued so a picker can offer the choice explicitly.
340 Model::new(
341 "claude-opus-5",
342 "Claude Opus 5",
343 "For complex agentic coding and enterprise work (200k context)",
344 Kind::Pinned,
345 CLAUDE_EFFORTS,
346 false,
347 ),
348 Model::new(
349 "claude-opus-5[1m]",
350 "Claude Opus 5 (1M context)",
351 "Opus 5 with a 1M token context window",
352 Kind::Pinned,
353 CLAUDE_EFFORTS,
354 false,
355 ),
356 Model::new(
357 "claude-sonnet-5",
358 "Claude Sonnet 5",
359 "The best combination of speed and intelligence (1M context)",
360 Kind::Pinned,
361 CLAUDE_EFFORTS,
362 false,
363 ),
364 Model::new(
365 "claude-fable-5",
366 "Claude Fable 5",
367 "Next-generation intelligence for long-running agents (1M context)",
368 Kind::Pinned,
369 CLAUDE_EFFORTS,
370 false,
371 ),
372 Model::new(
373 "claude-haiku-4-5",
374 "Claude Haiku 4.5",
375 "The fastest model with near-frontier intelligence",
376 Kind::Pinned,
377 CLAUDE_EFFORTS,
378 false,
379 ),
380 ]
381}
382
383/// Codex, in the priority order the CLI itself reports.
384///
385/// Verified by running `codex debug models` against codex-cli 0.146.0 on
386/// 2026-08-07. `codex-auto-review` is reported with `visibility: "hide"` and is
387/// left out for that reason; [`discover_codex`] applies the same filter.
388fn codex_models() -> Vec<Model> {
389 const FULL: &[&str] = &["low", "medium", "high", "xhigh", "max", "ultra"];
390 const TO_MAX: &[&str] = &["low", "medium", "high", "xhigh", "max"];
391 const TO_XHIGH: &[&str] = &["low", "medium", "high", "xhigh"];
392 vec![
393 Model::new(
394 "gpt-5.6-sol",
395 "GPT-5.6-Sol",
396 "Latest frontier agentic coding model.",
397 Kind::Pinned,
398 FULL,
399 true,
400 ),
401 Model::new(
402 "gpt-5.6-terra",
403 "GPT-5.6-Terra",
404 "Balanced agentic coding model for everyday work.",
405 Kind::Pinned,
406 FULL,
407 false,
408 ),
409 Model::new(
410 "gpt-5.6-luna",
411 "GPT-5.6-Luna",
412 "Fast and affordable agentic coding model.",
413 Kind::Pinned,
414 TO_MAX,
415 false,
416 ),
417 Model::new(
418 "gpt-5.5",
419 "GPT-5.5",
420 "Frontier model for complex coding, research, and real-world tasks.",
421 Kind::Pinned,
422 TO_XHIGH,
423 false,
424 ),
425 Model::new(
426 "gpt-5.4",
427 "GPT-5.4",
428 "Strong model for everyday coding.",
429 Kind::Pinned,
430 TO_XHIGH,
431 false,
432 ),
433 Model::new(
434 "gpt-5.4-mini",
435 "GPT-5.4-Mini",
436 "Small, fast, and cost-efficient model for simpler coding tasks.",
437 Kind::Pinned,
438 TO_XHIGH,
439 false,
440 ),
441 ]
442}
443
444/// Copilot, in the order its `/model` picker lists them.
445///
446/// Read from the interactive picker on Copilot CLI 1.0.75, 2026-07-29, because
447/// nothing else enumerates them. Note that the picker lists every model the
448/// product has, not every model the account may use: the same screen carried
449/// "Your Copilot Free plan currently includes only Auto", and on that plan every
450/// id below except `auto` is refused before a request is made.
451/// Copilot's effort levels, verified from `copilot --help` on 1.0.75:
452/// `--effort, --reasoning-effort <level>` (none, minimal, low, medium, high,
453/// xhigh, max).
454///
455/// Two levels wider than Claude's at the bottom, which is why levels are passed
456/// through rather than mapped onto a shared enum.
457///
458/// Applied to the pinned models only. `auto` rejects the flag outright, so
459/// support is not uniform across an agent even when `--help` lists one set, and
460/// these came from `--help` rather than from running each model: a Free plan
461/// permits only `auto`, so there was no way to confirm the rest.
462const COPILOT_EFFORTS: &[&str] = &["none", "minimal", "low", "medium", "high", "xhigh", "max"];
463
464fn copilot_models() -> Vec<Model> {
465 vec![
466 Model::new(
467 "auto",
468 "Auto",
469 "Copilot picks the best available model for each task",
470 Kind::Alias,
471 // Deliberately none. Verified on Copilot CLI 1.0.75 by running it:
472 // Error: Model "auto" does not support reasoning effort
473 // configuration (requested: "low").
474 // It exits 1 rather than ignoring the flag, so offering a level for
475 // `auto` in a picker produces a failed run, not a slower one.
476 &[],
477 true,
478 ),
479 pinned("claude-sonnet-5", "Claude Sonnet 5"),
480 pinned("claude-sonnet-4.6", "Claude Sonnet 4.6"),
481 pinned("claude-sonnet-4.5", "Claude Sonnet 4.5"),
482 pinned("claude-haiku-4.5", "Claude Haiku 4.5"),
483 pinned("claude-fable-5", "Claude Fable 5"),
484 pinned("claude-opus-5", "Claude Opus 5"),
485 pinned("claude-opus-4.8", "Claude Opus 4.8"),
486 pinned("claude-opus-4.8-fast", "Claude Opus 4.8 (fast)"),
487 pinned("claude-opus-4.7", "Claude Opus 4.7"),
488 pinned("claude-opus-4.6", "Claude Opus 4.6"),
489 pinned("claude-opus-4.5", "Claude Opus 4.5"),
490 pinned("gpt-5.6-sol", "GPT-5.6-Sol"),
491 pinned("gpt-5.6-terra", "GPT-5.6-Terra"),
492 pinned("gpt-5.6-luna", "GPT-5.6-Luna"),
493 pinned("gpt-5.5", "GPT-5.5"),
494 pinned("gpt-5.4", "GPT-5.4"),
495 pinned("gpt-5.3-codex", "GPT-5.3-Codex"),
496 pinned("gpt-5.4-mini", "GPT-5.4-Mini"),
497 pinned("gpt-5-mini", "GPT-5 mini"),
498 pinned("gemini-3.1-pro-preview", "Gemini 3.1 Pro (preview)"),
499 pinned("gemini-3.6-flash", "Gemini 3.6 Flash"),
500 pinned("gemini-3.5-flash", "Gemini 3.5 Flash"),
501 pinned("kimi-k2.7-code", "Kimi K2.7 Code"),
502 ]
503}
504
505/// A pinned entry with no vendor description, which is every Copilot model: its
506/// picker shows ids and nothing else.
507fn pinned(id: &'static str, name: &'static str) -> Model {
508 Model::new(id, name, "", Kind::Pinned, COPILOT_EFFORTS, false)
509}
510
511/// Read Codex's own model list.
512///
513/// `codex debug models` prints one JSON document carrying every model plus each
514/// one's full system prompt, so the reply runs to hundreds of kilobytes. Only
515/// the descriptive fields are kept.
516async fn discover_codex(bin: &str) -> Result<Vec<Model>> {
517 let output = tokio::process::Command::new(bin)
518 .args(["debug", "models"])
519 .output()
520 .await
521 .map_err(|source| {
522 if source.kind() == std::io::ErrorKind::NotFound {
523 Error::NotInstalled {
524 agent: Agent::Codex,
525 bin: bin.to_string(),
526 hint: Agent::Codex.install_hint(),
527 }
528 } else {
529 Error::Spawn {
530 bin: bin.to_string(),
531 source,
532 }
533 }
534 })?;
535
536 let stdout = String::from_utf8_lossy(&output.stdout);
537 parse_codex_models(&stdout)
538}
539
540/// Turn `codex debug models` output into catalogue entries.
541///
542/// Split from the spawn so the shape can be tested without a subprocess.
543fn parse_codex_models(stdout: &str) -> Result<Vec<Model>> {
544 let value: Value = serde_json::from_str(stdout.trim()).map_err(|e| Error::Parse {
545 agent: Agent::Codex,
546 detail: format!("`codex debug models` did not return JSON: {e}"),
547 })?;
548 let listed = value
549 .get("models")
550 .and_then(Value::as_array)
551 .ok_or_else(|| Error::Parse {
552 agent: Agent::Codex,
553 detail: "`codex debug models` returned no `models` array".into(),
554 })?;
555
556 // `priority` is the vendor's own display order and is not the array order,
557 // so it is read rather than assumed.
558 let mut ranked: Vec<(u64, Model)> = listed
559 .iter()
560 // `visibility` marks internal models such as `codex-auto-review`.
561 // Separately, Codex can list a visible model for inline coding while
562 // saying `supported_in_api: false`; it is not runnable through this
563 // crate's app-server/exec paths and must not reach their picker.
564 .filter(|m| m.get("visibility").and_then(Value::as_str) != Some("hide"))
565 .filter(|m| m.get("supported_in_api").and_then(Value::as_bool) != Some(false))
566 .filter_map(|m| {
567 let id = m.get("slug").and_then(Value::as_str)?;
568 let model = Model {
569 id: id.to_string().into(),
570 name: m
571 .get("display_name")
572 .and_then(Value::as_str)
573 .unwrap_or(id)
574 .to_string()
575 .into(),
576 note: m
577 .get("description")
578 .and_then(Value::as_str)
579 .unwrap_or_default()
580 .to_string()
581 .into(),
582 kind: Kind::Pinned,
583 efforts: m
584 .get("supported_reasoning_levels")
585 .and_then(Value::as_array)
586 .map(|levels| {
587 levels
588 .iter()
589 .filter_map(|l| l.get("effort").and_then(Value::as_str))
590 .map(|e| Cow::Owned(e.to_string()))
591 .collect()
592 })
593 .unwrap_or_default(),
594 // Codex names a default reasoning level per model but never a
595 // default model, so the top of its own ordering stands in.
596 is_default: false,
597 };
598 let priority = m
599 .get("priority")
600 .and_then(Value::as_u64)
601 .unwrap_or(u64::MAX);
602 Some((priority, model))
603 })
604 .collect();
605
606 if ranked.is_empty() {
607 return Err(Error::Parse {
608 agent: Agent::Codex,
609 detail: "`codex debug models` listed no visible models".into(),
610 });
611 }
612 ranked.sort_by_key(|(priority, _)| *priority);
613
614 let mut models: Vec<Model> = ranked.into_iter().map(|(_, model)| model).collect();
615 if let Some(first) = models.first_mut() {
616 first.is_default = true;
617 }
618 Ok(models)
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 /// Trimmed from real `codex debug models` output (codex-cli 0.145.0). The
626 /// hidden entry and the out-of-order priorities are both as reported.
627 const CODEX_OUTPUT: &str = r#"{"models":[
628 {"slug":"gpt-5.5","display_name":"GPT-5.5","description":"Frontier model.",
629 "default_reasoning_level":"medium","visibility":"list","priority":7,
630 "supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"}]},
631 {"slug":"codex-auto-review","display_name":"Codex Auto Review","description":"Internal.",
632 "visibility":"hide","priority":43,"supported_reasoning_levels":[{"effort":"low"}]},
633 {"slug":"gpt-5.3-codex-spark","display_name":"GPT-5.3-Codex-Spark","description":"Inline only.",
634 "visibility":"list","supported_in_api":false,"priority":26,
635 "supported_reasoning_levels":[{"effort":"high"}]},
636 {"slug":"gpt-5.6-sol","display_name":"GPT-5.6-Sol","description":"Latest frontier model.",
637 "default_reasoning_level":"low","visibility":"list","priority":1,
638 "supported_reasoning_levels":[{"effort":"low"},{"effort":"ultra"}]}
639 ]}"#;
640
641 #[test]
642 fn codex_discovery_reads_the_fields_a_picker_needs() {
643 let models = parse_codex_models(CODEX_OUTPUT).expect("should parse");
644 let sol = &models[0];
645 assert_eq!(sol.id, "gpt-5.6-sol");
646 assert_eq!(sol.name, "GPT-5.6-Sol");
647 assert_eq!(sol.note, "Latest frontier model.");
648 assert_eq!(sol.efforts, vec!["low", "ultra"]);
649 }
650
651 /// The array order is not the display order: `gpt-5.5` is listed first and
652 /// carries priority 7, while `gpt-5.6-sol` is listed last at priority 1.
653 #[test]
654 fn codex_discovery_uses_the_vendors_ordering_not_the_array_order() {
655 let models = parse_codex_models(CODEX_OUTPUT).expect("should parse");
656 let ids: Vec<&str> = models.iter().map(|m| m.id.as_ref()).collect();
657 assert_eq!(ids, ["gpt-5.6-sol", "gpt-5.5"]);
658 assert!(
659 models[0].is_default,
660 "the top-priority model is the default"
661 );
662 }
663
664 /// Codex marks its internal models `hide`. Offering one in a picker hands a
665 /// user a model the vendor deliberately withheld.
666 #[test]
667 fn codex_discovery_drops_models_the_vendor_hides() {
668 let models = parse_codex_models(CODEX_OUTPUT).expect("should parse");
669 assert!(
670 !models.iter().any(|m| m.id == "codex-auto-review"),
671 "a hidden model must not reach a picker"
672 );
673 }
674
675 #[test]
676 fn codex_discovery_drops_visible_models_that_are_not_api_supported() {
677 let models = parse_codex_models(CODEX_OUTPUT).expect("should parse");
678 assert!(
679 !models.iter().any(|m| m.id == "gpt-5.3-codex-spark"),
680 "inline-only models cannot run through the crate's API paths"
681 );
682 }
683
684 #[test]
685 fn unparseable_output_is_an_error_not_an_empty_list() {
686 assert!(matches!(
687 parse_codex_models("Reading additional input from stdin..."),
688 Err(Error::Parse { .. })
689 ));
690 assert!(
691 matches!(
692 parse_codex_models(r#"{"models":[]}"#),
693 Err(Error::Parse { .. })
694 ),
695 "an empty list means the shape changed, not that Codex has no models"
696 );
697 }
698
699 /// Discovery must not quietly answer with the compiled-in list: a caller
700 /// asking for it is asking for freshness, and a silent fallback answers a
701 /// different question.
702 #[tokio::test]
703 async fn agents_that_cannot_be_asked_say_so() {
704 for agent in [Agent::Claude, Agent::Copilot] {
705 assert!(
706 matches!(
707 agent.discover_models().await,
708 Err(Error::Unsupported { .. })
709 ),
710 "{agent} should report that it cannot enumerate models"
711 );
712 }
713 }
714
715 /// The gap this closes: every Claude and Copilot entry shipped with an
716 /// empty `efforts` while both CLIs document a `--effort` flag, so a picker
717 /// had nothing to offer.
718 #[test]
719 fn every_model_reports_the_levels_its_agent_accepts() {
720 for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
721 for model in agent.models() {
722 // `auto` is the documented exception, asserted below.
723 if agent == Agent::Copilot && model.id == "auto" {
724 continue;
725 }
726 assert!(
727 !model.efforts.is_empty(),
728 "{agent} model {} reports no effort levels",
729 model.id
730 );
731 }
732 }
733 }
734
735 /// Support is not uniform across an agent even where `--help` lists one set.
736 /// Copilot exits 1 for an effort on `auto` rather than ignoring it, so
737 /// offering a level there would produce a failed run.
738 #[test]
739 fn copilot_auto_offers_no_levels_because_it_refuses_them() {
740 let models = Agent::Copilot.models();
741 let auto = models.iter().find(|m| m.id == "auto").expect("auto");
742 assert!(
743 auto.efforts.is_empty(),
744 "auto rejects the effort flag outright"
745 );
746 let pinned = models.iter().find(|m| m.id == "gpt-5.5").expect("gpt-5.5");
747 assert!(
748 !pinned.efforts.is_empty(),
749 "pinned models do document levels"
750 );
751 }
752
753 /// Verified from `claude --help` (2.1.212) and `copilot --help` (1.0.75).
754 /// Copilot's set is two wider at the bottom, which is the whole reason
755 /// levels are strings rather than a shared enum.
756 #[test]
757 fn the_documented_level_sets_are_not_interchangeable() {
758 let claude = &Agent::Claude.models()[0].efforts;
759 let copilot_models = Agent::Copilot.models();
760 let copilot = &copilot_models
761 .iter()
762 .find(|m| m.id == "gpt-5.5")
763 .expect("gpt-5.5")
764 .efforts;
765 assert_eq!(claude, &["low", "medium", "high", "xhigh", "max"]);
766 assert_eq!(
767 copilot,
768 &["none", "minimal", "low", "medium", "high", "xhigh", "max"]
769 );
770 assert_ne!(claude, copilot, "a shared enum would have to cover both");
771 }
772
773 /// Codex is the one agent whose levels differ per model, which is why they
774 /// live on the model rather than on the agent.
775 #[test]
776 fn codex_levels_differ_between_its_own_models() {
777 let models = Agent::Codex.models();
778 let by_id = |id: &str| -> Vec<String> {
779 models
780 .iter()
781 .find(|m| m.id == id)
782 .unwrap_or_else(|| panic!("{id} should be catalogued"))
783 .efforts
784 .iter()
785 .map(ToString::to_string)
786 .collect()
787 };
788 assert!(
789 by_id("gpt-5.6-sol").contains(&"ultra".to_string()),
790 "its frontier model offers ultra"
791 );
792 assert!(
793 !by_id("gpt-5.6-luna").contains(&"ultra".to_string()),
794 "its fast model does not"
795 );
796 }
797
798 #[test]
799 fn every_agent_offers_exactly_one_default() {
800 for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
801 let defaults = agent.models().iter().filter(|m| m.is_default).count();
802 assert_eq!(defaults, 1, "{agent} should mark exactly one default");
803 }
804 }
805
806 #[test]
807 fn no_catalogue_repeats_an_id() {
808 for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
809 let models = agent.models();
810 let mut ids: Vec<&str> = models.iter().map(|m| m.id.as_ref()).collect();
811 ids.sort_unstable();
812 let count = ids.len();
813 ids.dedup();
814 assert_eq!(ids.len(), count, "{agent} has a duplicate model id");
815 }
816 }
817
818 /// The catalogue is a suggestion, not a gate. A model released after this
819 /// list was compiled has to reach the command line untouched.
820 #[test]
821 fn an_unlisted_model_is_still_accepted() {
822 let request = crate::Request::new(Agent::Claude, "hi").model("some-model-from-next-year");
823 let argv = request
824 .argv()
825 .expect("an unlisted model must not be rejected");
826 assert!(
827 argv.windows(2)
828 .any(|w| w[0] == "--model" && w[1] == "some-model-from-next-year"),
829 "the model should reach the command line verbatim: {argv:?}"
830 );
831 }
832}