Skip to main content

agent_engine/runtime/openai/catalog/
codex.rs

1use super::*;
2use agent_core::reasoning::ReasoningLevel;
3use serde::Deserialize;
4
5/// Canonical provider key (matches OAuth storage / broker id).
6pub const PROVIDER_KEY: &str = "openai-codex";
7/// User-facing provider name.
8pub const PROVIDER_NAME: &str = "OpenAI Codex";
9/// Pinned ChatGPT backend host for Codex model catalog discovery.
10pub const MODELS_BASE_URL: &str = "https://chatgpt.com/backend-api";
11/// Relative path allowlisted on the broker for catalog discovery.
12///
13/// This is the Codex-specific catalog (`/codex/models`), which publishes
14/// `display_name` / `visibility` / `supported_reasoning_levels` per model.
15/// The sibling `/models` path serves the ChatGPT web picker (different
16/// schema, `-wm` slugs, no `visibility`) and must not be used here.
17pub const MODELS_PATH: &str = "/codex/models";
18/// Codex client protocol version advertised on catalog discovery.
19///
20/// The ChatGPT backend filters `/codex/models` by `client_version` against
21/// each model's `minimal_client_version`: an unknown or too-old version
22/// (e.g. SynapsCLI's own crate version) yields an empty catalog. This value
23/// is therefore decoupled from `CARGO_PKG_VERSION` and pinned to the lowest
24/// official Codex CLI release that exposes the newest generation. Observed
25/// live (2026-10-04): `gpt-6-astra` is listed from 0.153.0, `gpt-6-sol` /
26/// `gpt-6-luna` from 0.155.0 and `gpt-6.1-sol` only from 0.159.0 (its row
27/// still reports minimal_client_version 0.153.0 — the backend gates it
28/// separately, so trust the observed floor, not that field). Bump
29/// deliberately when a new model raises the floor; never derive it from
30/// SynapsCLI's version.
31pub const CODEX_CLIENT_VERSION: &str = "0.159.0";
32
33// ─── Static capability table ─────────────────────────────────────────────────
34//
35// Per spec §Assumptions #1: the live catalog publishes exact
36// `supported_reasoning_levels` per model. These static values are the
37// safe fallback for offline / not-yet-configured sessions, and the
38// authoritative capability table for the worktree implementation.
39//
40// Levels are ordered from least to most intensive (matching the spec table).
41
42/// Static per-model capability for the known Codex catalog, keyed by model id.
43/// Used when live catalog data is unavailable.
44pub fn codex_static_capability(model_id: &str) -> Option<ReasoningSupport> {
45    use ReasoningLevel::*;
46    let (supported, default_level, multi_agent_version): (
47        &[ReasoningLevel],
48        ReasoningLevel,
49        Option<CodexMultiAgentVersion>,
50    ) = match model_id {
51        // GPT-6.1 generation. Live row (2026-10-04, client_version >= 0.159.0):
52        // default low, full ladder through Ultra, multi-agent v2 with
53        // multi_agent_reasoning_effort xhigh (Ultra requests use xhigh).
54        "gpt-6.1-sol" => (
55            &[Low, Medium, High, XHigh, Max, Ultra],
56            Low,
57            Some(CodexMultiAgentVersion::V2),
58        ),
59        // GPT-6 generation. Live row (2026-09): default medium, full ladder
60        // through Ultra, multi-agent v2 (Ultra requests use xhigh).
61        "gpt-6-astra" => (
62            &[Low, Medium, High, XHigh, Max, Ultra],
63            Medium,
64            Some(CodexMultiAgentVersion::V2),
65        ),
66        "gpt-5.6-sol" => (
67            &[Low, Medium, High, XHigh, Max, Ultra],
68            Low,
69            Some(CodexMultiAgentVersion::V2),
70        ),
71        "gpt-5.6-terra" => (
72            &[Low, Medium, High, XHigh, Max, Ultra],
73            Medium,
74            Some(CodexMultiAgentVersion::V2),
75        ),
76        "gpt-5.6-luna" => (
77            &[Low, Medium, High, XHigh, Max],
78            Medium,
79            Some(CodexMultiAgentVersion::V1),
80        ),
81        "gpt-5.5" | "gpt-5.4" | "gpt-5.4-mini" => (&[Low, Medium, High, XHigh], Medium, None),
82        "gpt-5.3-codex-spark" => (&[Low, Medium, High, XHigh], High, None),
83        _ => return None,
84    };
85    Some(ReasoningSupport::CodexNamed {
86        supported: supported.to_vec(),
87        multi_agent_reasoning_effort: matches!(model_id, "gpt-6-astra" | "gpt-6.1-sol")
88            .then_some(XHigh),
89        default_level: Some(default_level),
90        multi_agent_version,
91    })
92}
93
94/// Build the static catalog models with static capability data attached.
95pub fn codex_static_catalog_models() -> Vec<CatalogModel> {
96    [
97        ("gpt-6.1-sol", "GPT-6.1-Sol"),
98        ("gpt-6-astra", "GPT-6-Astra"),
99        ("gpt-5.6-sol", "GPT-5.6-Sol"),
100        ("gpt-5.6-terra", "GPT-5.6-Terra"),
101        ("gpt-5.6-luna", "GPT-5.6-Luna"),
102        ("gpt-5.5", "GPT-5.5"),
103        ("gpt-5.4", "GPT-5.4"),
104        ("gpt-5.4-mini", "GPT-5.4 Mini"),
105        ("gpt-5.3-codex-spark", "GPT-5.3-Codex-Spark"),
106    ]
107    .into_iter()
108    .filter_map(|(id, label)| {
109        let mut m = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, id)?;
110        m.provider_kind = CatalogProviderKind::OpenAiCodex;
111        m.label = Some(label.to_string());
112        m.reasoning = codex_static_capability(id).unwrap_or(ReasoningSupport::Unknown);
113        // Exact input-modality evidence from the official local Codex cache,
114        // verified 2026-09-05. Do not infer from generation/family names:
115        // gpt-5.4 was not observed, and spark explicitly advertises text only.
116        if matches!(
117            id,
118            "gpt-6.1-sol"
119                | "gpt-6-astra"
120                | "gpt-5.6-sol"
121                | "gpt-5.6-terra"
122                | "gpt-5.6-luna"
123                | "gpt-5.5"
124                | "gpt-5.4-mini"
125        ) {
126            m.input_modalities.push(Modality::Image);
127        }
128        // No static row has observed File support.
129        m.source = CatalogSource::StaticFallback;
130        Some(m)
131    })
132    .collect()
133}
134
135/// Build the broker-relative path for the ChatGPT backend Codex catalog
136/// endpoint, including the required `client_version` query (matches
137/// official Codex). An empty `client_version` falls back to the pinned
138/// [`CODEX_CLIENT_VERSION`] — never SynapsCLI's own crate version, which
139/// the backend would reject with an empty catalog.
140pub fn codex_models_path(client_version: &str) -> String {
141    let version = if client_version.trim().is_empty() {
142        CODEX_CLIENT_VERSION
143    } else {
144        client_version.trim()
145    };
146    format!("{MODELS_PATH}?client_version={version}")
147}
148
149/// Full pinned models URL (host + path + client_version query).
150pub fn codex_models_url(client_version: &str) -> String {
151    format!("{MODELS_BASE_URL}{}", codex_models_path(client_version))
152}
153
154// ─── Live catalog wire types ──────────────────────────────────────────────────
155
156#[derive(Debug, Deserialize)]
157struct CodexModelsResponse {
158    models: Vec<CodexModelItem>,
159}
160
161#[derive(Debug, Deserialize)]
162struct CodexModelItem {
163    slug: String,
164    #[serde(default)]
165    display_name: Option<String>,
166    #[serde(default)]
167    visibility: Option<String>,
168    #[serde(default)]
169    supported_in_api: Option<bool>,
170    #[serde(default)]
171    context_window: Option<u64>,
172    /// Exact advertised inputs. Absence must not inherit static image support.
173    #[serde(default)]
174    input_modalities: Option<Vec<String>>,
175    /// Live catalog reasoning metadata.
176    #[serde(default)]
177    supported_reasoning_levels: Option<Vec<CodexReasoningLevelItem>>,
178    #[serde(default)]
179    default_reasoning_level: Option<String>,
180    #[serde(default)]
181    multi_agent_version: Option<String>,
182    #[serde(default)]
183    multi_agent_reasoning_effort: Option<String>,
184}
185
186#[derive(Debug, Deserialize)]
187struct CodexReasoningLevelItem {
188    effort: String,
189}
190
191// ─── Picker eligibility ───────────────────────────────────────────────────────
192
193/// True when a Codex catalog row should appear as a selectable model.
194///
195/// Mirrors the official Codex picker rule: picker eligibility is determined by
196/// list visibility. `supported_in_api` describes API-key support and therefore
197/// must not filter the ChatGPT OAuth catalog.
198pub fn codex_model_is_selectable(
199    visibility: Option<&str>,
200    _supported_in_api: Option<bool>,
201) -> bool {
202    match visibility.map(str::trim).filter(|v| !v.is_empty()) {
203        None => true,
204        Some("list") => true,
205        Some(other) => {
206            // Anything other than the known list-visible token is hidden.
207            // Official Codex uses "list" for picker rows and "hide" for internal.
208            other.eq_ignore_ascii_case("list")
209        }
210    }
211}
212
213// ─── Reasoning level parsing ──────────────────────────────────────────────────
214
215/// Parse an ordered list of `CodexReasoningLevelItem` into `ReasoningLevel`
216/// values, silently discarding unknown strings.
217///
218/// Unknown effort strings are ignored, never silently coerced to a known level.
219fn parse_reasoning_levels(items: &[CodexReasoningLevelItem]) -> Vec<ReasoningLevel> {
220    items
221        .iter()
222        .filter_map(|item| ReasoningLevel::parse(&item.effort))
223        .collect()
224}
225
226/// Parse a `default_reasoning_level` string, returning `None` for unknown values.
227fn parse_default_level(s: Option<&str>) -> Option<ReasoningLevel> {
228    s.and_then(ReasoningLevel::parse)
229}
230
231fn parse_multi_agent_version(s: Option<&str>) -> Option<CodexMultiAgentVersion> {
232    s.map(|version| match version {
233        "v1" => CodexMultiAgentVersion::V1,
234        "v2" => CodexMultiAgentVersion::V2,
235        _ => CodexMultiAgentVersion::Unknown,
236    })
237}
238
239/// Build a `ReasoningSupport` for a parsed Codex model item.
240/// Falls back to static capability data when the live response omits the fields.
241fn codex_reasoning_support(item: &CodexModelItem) -> ReasoningSupport {
242    let default_level = parse_default_level(item.default_reasoning_level.as_deref());
243    let multi_agent_version = parse_multi_agent_version(item.multi_agent_version.as_deref());
244    let multi_agent_reasoning_effort =
245        parse_default_level(item.multi_agent_reasoning_effort.as_deref());
246
247    if let Some(items) = item.supported_reasoning_levels.as_deref() {
248        return ReasoningSupport::CodexNamed {
249            supported: parse_reasoning_levels(items),
250            multi_agent_reasoning_effort,
251            default_level,
252            multi_agent_version,
253        };
254    }
255    // A live row may omit reasoning levels, so retain the existing static
256    // level fallback. Never backfill the live row's collaboration version:
257    // missing live V2 evidence must not authorize Ultra.
258    match codex_static_capability(&item.slug) {
259        Some(ReasoningSupport::CodexNamed {
260            supported,
261            default_level: static_default,
262            ..
263        }) => ReasoningSupport::CodexNamed {
264            supported,
265            default_level: default_level.or(static_default),
266            // Live absence must not borrow Astra's offline override.
267            multi_agent_reasoning_effort,
268            multi_agent_version,
269        },
270        Some(other) => other,
271        None => ReasoningSupport::Unknown,
272    }
273}
274
275// ─── Parser ───────────────────────────────────────────────────────────────────
276
277/// Parse the ChatGPT backend-api models response into normalized catalog rows.
278///
279/// Response shape: `{ "models": [ { "slug", "display_name", "visibility",
280/// "supported_in_api", "context_window",
281/// "supported_reasoning_levels": [{"effort": "..."}],
282/// "default_reasoning_level": "...", ... }, ... ] }`.
283pub fn parse_codex_catalog_models(body: &str) -> Result<Vec<CatalogModel>, serde_json::Error> {
284    let resp: CodexModelsResponse = serde_json::from_str(body)?;
285    let models: Vec<CatalogModel> = resp
286        .models
287        .into_iter()
288        .filter(|item| codex_model_is_selectable(item.visibility.as_deref(), item.supported_in_api))
289        .filter_map(|item| {
290            let reasoning = codex_reasoning_support(&item);
291            let mut m = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, item.slug)?;
292            m.provider_kind = CatalogProviderKind::OpenAiCodex;
293            m.label = item.display_name.filter(|name| !name.trim().is_empty());
294            m.context_tokens = item.context_window;
295            if let Some(modalities) = item.input_modalities {
296                // Even an explicit empty/text-only list is authoritative. Unknown
297                // tokens remain Other and never authorize a supported modality.
298                m.input_modalities = modalities.iter().map(|m| Modality::from_str(m)).collect();
299            }
300            m.reasoning = reasoning;
301            m.source = CatalogSource::Live;
302            Some(m)
303        })
304        .collect();
305    Ok(models)
306}
307
308// ─── Typed request planning ───────────────────────────────────────────────────
309
310/// Reasoning values accepted by the Codex Responses request wire.
311/// Logical Ultra is deliberately absent and must be lowered by the planner.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum CodexWireEffort {
314    Low,
315    Medium,
316    High,
317    XHigh,
318    Max,
319}
320
321impl CodexWireEffort {
322    fn from_level(level: ReasoningLevel) -> Option<Self> {
323        match level {
324            ReasoningLevel::Low => Some(Self::Low),
325            ReasoningLevel::Medium => Some(Self::Medium),
326            ReasoningLevel::High => Some(Self::High),
327            ReasoningLevel::XHigh => Some(Self::XHigh),
328            ReasoningLevel::Max => Some(Self::Max),
329            // Logical/omission modes must never be emitted as wire effort.
330            _ => None,
331        }
332    }
333
334    pub fn as_str(self) -> &'static str {
335        match self {
336            Self::Low => "low",
337            Self::Medium => "medium",
338            Self::High => "high",
339            Self::XHigh => "xhigh",
340            Self::Max => "max",
341        }
342    }
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346pub enum CodexExecutionMode {
347    Standard,
348    Max,
349    Ultra,
350}
351
352impl CodexExecutionMode {
353    pub fn as_str(self) -> &'static str {
354        match self {
355            Self::Standard => "standard",
356            Self::Max => "max",
357            Self::Ultra => "ultra",
358        }
359    }
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub enum CodexMultiAgentMode {
364    ExplicitRequestOnly,
365    Proactive,
366}
367
368impl CodexMultiAgentMode {
369    pub fn as_str(self) -> &'static str {
370        match self {
371            Self::ExplicitRequestOnly => "explicit_request_only",
372            Self::Proactive => "proactive",
373        }
374    }
375}
376
377#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
378pub enum ExecutionRole {
379    #[default]
380    Foreground,
381    Worker,
382    Internal,
383}
384
385impl ExecutionRole {
386    pub fn as_str(self) -> &'static str {
387        match self {
388            Self::Foreground => "foreground",
389            Self::Worker => "worker",
390            Self::Internal => "internal",
391        }
392    }
393}
394
395/// Backward-compatible name; request role semantics are provider-neutral.
396pub type CodexRequestRole = ExecutionRole;
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399pub enum CodexCapabilitySource {
400    Live,
401    Static,
402}
403
404impl CodexCapabilitySource {
405    pub fn as_str(self) -> &'static str {
406        match self {
407            Self::Live => "live",
408            Self::Static => "static",
409        }
410    }
411}
412
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414pub enum CodexPlanErrorCode {
415    InvalidProviderIdentity,
416    CapabilityMetadataMissing,
417    UnsupportedReasoningLevel,
418    UltraRequiresMultiAgentV2,
419}
420
421impl CodexPlanErrorCode {
422    pub fn as_str(self) -> &'static str {
423        match self {
424            Self::InvalidProviderIdentity => "invalid_provider_identity",
425            Self::CapabilityMetadataMissing => "capability_metadata_missing",
426            Self::UnsupportedReasoningLevel => "unsupported_reasoning_level",
427            Self::UltraRequiresMultiAgentV2 => "ultra_requires_multi_agent_v2",
428        }
429    }
430}
431
432#[derive(Debug, Clone, PartialEq, Eq)]
433pub struct CodexPlanError {
434    code: CodexPlanErrorCode,
435    message: String,
436}
437
438impl CodexPlanError {
439    fn new(code: CodexPlanErrorCode, message: impl Into<String>) -> Self {
440        Self {
441            code,
442            message: message.into(),
443        }
444    }
445
446    pub fn code(&self) -> CodexPlanErrorCode {
447        self.code
448    }
449}
450
451impl std::fmt::Display for CodexPlanError {
452    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
453        formatter.write_str(&self.message)
454    }
455}
456
457impl std::error::Error for CodexPlanError {}
458
459#[derive(Debug, Clone, PartialEq, Eq)]
460pub struct CodexExecutionPlan {
461    pub qualified_model: String,
462    pub selected_level: ReasoningLevel,
463    pub mode: CodexExecutionMode,
464    pub wire_effort: Option<CodexWireEffort>,
465    pub multi_agent_version: Option<CodexMultiAgentVersion>,
466    pub multi_agent_mode: Option<CodexMultiAgentMode>,
467    pub request_role: CodexRequestRole,
468    pub capability_source: Option<CodexCapabilitySource>,
469}
470
471impl CodexExecutionPlan {
472    pub fn automatic_delegation(&self) -> bool {
473        self.multi_agent_mode == Some(CodexMultiAgentMode::Proactive)
474    }
475
476    pub fn wire_effort_label(&self) -> &'static str {
477        self.wire_effort.map_or("omitted", CodexWireEffort::as_str)
478    }
479
480    pub fn multi_agent_version_label(&self) -> &'static str {
481        self.multi_agent_version
482            .map_or("missing", CodexMultiAgentVersion::as_str)
483    }
484
485    pub fn multi_agent_mode_label(&self) -> &'static str {
486        self.multi_agent_mode
487            .map_or("disabled", CodexMultiAgentMode::as_str)
488    }
489}
490
491#[derive(Debug, Clone)]
492struct CodexCapabilitySnapshot {
493    supported: Vec<ReasoningLevel>,
494    multi_agent_reasoning_effort: Option<ReasoningLevel>,
495    multi_agent_version: Option<CodexMultiAgentVersion>,
496    source: CodexCapabilitySource,
497}
498
499fn snapshot_from_model(
500    model: &CatalogModel,
501    source: CodexCapabilitySource,
502) -> Option<CodexCapabilitySnapshot> {
503    match &model.reasoning {
504        ReasoningSupport::CodexNamed {
505            supported,
506            multi_agent_reasoning_effort,
507            multi_agent_version,
508            ..
509        } => Some(CodexCapabilitySnapshot {
510            supported: supported.clone(),
511            multi_agent_reasoning_effort: *multi_agent_reasoning_effort,
512            multi_agent_version: *multi_agent_version,
513            source,
514        }),
515        _ => None,
516    }
517}
518
519fn authoritative_source(
520    model: &CatalogModel,
521    qualified_model: &str,
522) -> Result<CodexCapabilitySource, CodexPlanError> {
523    match model.source {
524        CatalogSource::Live => Ok(CodexCapabilitySource::Live),
525        CatalogSource::StaticFallback => Ok(CodexCapabilitySource::Static),
526        CatalogSource::StaticWithLive | CatalogSource::Inferred => Err(CodexPlanError::new(
527            CodexPlanErrorCode::CapabilityMetadataMissing,
528            format!(
529                "authoritative exact-model capability metadata is unavailable for {qualified_model}"
530            ),
531        )),
532    }
533}
534
535fn authoritative_capability(
536    qualified_model: &str,
537    model_id: &str,
538    catalog_model: Option<&CatalogModel>,
539) -> Result<Option<CodexCapabilitySnapshot>, CodexPlanError> {
540    if let Some(model) = catalog_model {
541        if model.runtime_id() != qualified_model
542            || model.provider_kind != CatalogProviderKind::OpenAiCodex
543        {
544            return Err(CodexPlanError::new(
545                CodexPlanErrorCode::InvalidProviderIdentity,
546                format!("catalog identity does not match exact Codex model {qualified_model}"),
547            ));
548        }
549        let source = authoritative_source(model, qualified_model)?;
550        return Ok(snapshot_from_model(model, source));
551    }
552
553    // A present live row is authoritative even when it lacks usable Codex
554    // metadata. Never mix that absence with a static V2 fallback.
555    if let Some(model) = super::capability_cache::get(qualified_model) {
556        if model.runtime_id() != qualified_model
557            || model.provider_kind != CatalogProviderKind::OpenAiCodex
558        {
559            return Err(CodexPlanError::new(
560                CodexPlanErrorCode::InvalidProviderIdentity,
561                format!(
562                    "cached catalog identity does not match exact Codex model {qualified_model}"
563                ),
564            ));
565        }
566        let source = authoritative_source(&model, qualified_model)?;
567        return Ok(snapshot_from_model(&model, source));
568    }
569
570    Ok(match codex_static_capability(model_id) {
571        Some(ReasoningSupport::CodexNamed {
572            supported,
573            multi_agent_reasoning_effort,
574            multi_agent_version,
575            ..
576        }) => Some(CodexCapabilitySnapshot {
577            supported,
578            multi_agent_reasoning_effort,
579            multi_agent_version,
580            source: CodexCapabilitySource::Static,
581        }),
582        _ => None,
583    })
584}
585
586/// Derive the exact Codex request contract from provider-qualified identity,
587/// logical selection, authoritative capability metadata, and runtime role.
588pub fn plan_codex_execution(
589    qualified_model: &str,
590    selected_level: ReasoningLevel,
591    request_role: CodexRequestRole,
592    catalog_model: Option<&CatalogModel>,
593) -> Result<CodexExecutionPlan, CodexPlanError> {
594    let Some(model_id) = qualified_model.strip_prefix("openai-codex/") else {
595        return Err(CodexPlanError::new(
596            CodexPlanErrorCode::InvalidProviderIdentity,
597            format!("{qualified_model} is not an exact openai-codex identity"),
598        ));
599    };
600    if model_id.is_empty() || model_id.contains('/') {
601        return Err(CodexPlanError::new(
602            CodexPlanErrorCode::InvalidProviderIdentity,
603            format!("{qualified_model} is not an exact openai-codex identity"),
604        ));
605    }
606
607    let capability = authoritative_capability(qualified_model, model_id, catalog_model)?;
608    if !matches!(
609        selected_level,
610        ReasoningLevel::Off | ReasoningLevel::Adaptive
611    ) {
612        let Some(capability) = capability.as_ref() else {
613            return Err(CodexPlanError::new(
614                CodexPlanErrorCode::CapabilityMetadataMissing,
615                format!(
616                    "no capability metadata for {qualified_model}; cannot authorize level '{selected_level}'"
617                ),
618            ));
619        };
620        if !capability.supported.contains(&selected_level) {
621            return Err(CodexPlanError::new(
622                CodexPlanErrorCode::UnsupportedReasoningLevel,
623                format!(
624                    "reasoning level '{selected_level}' is not supported by {qualified_model}; supported: [{}]",
625                    capability
626                        .supported
627                        .iter()
628                        .map(|level| level.as_str())
629                        .collect::<Vec<_>>()
630                        .join(", ")
631                ),
632            ));
633        }
634    }
635
636    let multi_agent_version = capability
637        .as_ref()
638        .and_then(|capability| capability.multi_agent_version);
639    if selected_level == ReasoningLevel::Ultra
640        && multi_agent_version != Some(CodexMultiAgentVersion::V2)
641    {
642        return Err(CodexPlanError::new(
643            CodexPlanErrorCode::UltraRequiresMultiAgentV2,
644            format!(
645                "reasoning level 'ultra' requires multi-agent v2 capability for {qualified_model}"
646            ),
647        ));
648    }
649
650    let mode = match selected_level {
651        ReasoningLevel::Max => CodexExecutionMode::Max,
652        ReasoningLevel::Ultra => CodexExecutionMode::Ultra,
653        _ => CodexExecutionMode::Standard,
654    };
655    let wire_effort = if selected_level == ReasoningLevel::Ultra {
656        // Official Codex client::reasoning_effort_for_request applies this to
657        // BOTH foreground and worker requests. Honor only a supported wire
658        // effort; otherwise prefer Max, then the last supported wire effort.
659        // A malformed ladder with no wire effort fails closed before network.
660        let cap = capability
661            .as_ref()
662            .expect("Ultra capability validated above");
663        Some(
664            cap.multi_agent_reasoning_effort
665                .filter(|level| cap.supported.contains(level))
666                .and_then(CodexWireEffort::from_level)
667                .or_else(|| {
668                    cap.supported
669                        .contains(&ReasoningLevel::Max)
670                        .then_some(CodexWireEffort::Max)
671                })
672                .or_else(|| {
673                    cap.supported
674                        .iter()
675                        .rev()
676                        .find_map(|level| CodexWireEffort::from_level(*level))
677                })
678                .ok_or_else(|| {
679                    CodexPlanError::new(
680                        CodexPlanErrorCode::CapabilityMetadataMissing,
681                        format!("no supported wire effort for Ultra on {qualified_model}"),
682                    )
683                })?,
684        )
685    } else {
686        CodexWireEffort::from_level(selected_level)
687    };
688    let multi_agent_mode = if request_role == CodexRequestRole::Foreground
689        && multi_agent_version == Some(CodexMultiAgentVersion::V2)
690    {
691        Some(if selected_level == ReasoningLevel::Ultra {
692            CodexMultiAgentMode::Proactive
693        } else {
694            CodexMultiAgentMode::ExplicitRequestOnly
695        })
696    } else {
697        None
698    };
699
700    Ok(CodexExecutionPlan {
701        qualified_model: qualified_model.to_string(),
702        selected_level,
703        mode,
704        wire_effort,
705        multi_agent_version,
706        multi_agent_mode,
707        request_role,
708        capability_source: capability.map(|capability| capability.source),
709    })
710}
711
712// ─── Capability validation helper ─────────────────────────────────────────────
713
714/// Validate that the given `level` is supported by the Codex model identified
715/// by `model_id`. Uses the provided catalog when available, else falls back to
716/// the static table.
717///
718/// Returns `Ok(())` if supported, `Err(message)` if not.
719/// Always fails for providers without authoritative Codex metadata.
720pub fn validate_codex_level(
721    model_id: &str,
722    level: ReasoningLevel,
723    catalog_model: Option<&CatalogModel>,
724) -> Result<(), String> {
725    let qualified = format!("openai-codex/{model_id}");
726    plan_codex_execution(
727        &qualified,
728        level,
729        CodexRequestRole::Foreground,
730        catalog_model,
731    )
732    .map(|_| ())
733    .map_err(|error| error.to_string())
734}
735
736// ─── Tests ───────────────────────────────────────────────────────────────────
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741
742    const FIXTURE: &str = include_str!("fixtures/openai_codex_models.json");
743
744    #[test]
745    fn catalog_rows_never_gate_hosted_web_search() {
746        use crate::runtime::openai::codex_wire::codex_web_search_unsupported_reason as reason;
747        // Real row shape (2026-10 live cache). `use_responses_lite` is
748        // upstream's request-format switch and `supports_search_tool` is
749        // tool_search; Synaps sends full Responses requests, so neither keeps
750        // hosted web_search off (only spark and a learned rejection do).
751        let body = r#"{"models":[
752            {"slug":"gpt-zz-live-lite","visibility":"list","use_responses_lite":true,
753             "supports_search_tool":true,"web_search_tool_type":"text_and_image"},
754            {"slug":"gpt-zz-live-full","visibility":"list","use_responses_lite":false,
755             "supports_search_tool":false,"web_search_tool_type":"text"},
756            {"slug":"gpt-zz-live-silent","visibility":"list"}
757        ]}"#;
758        assert_eq!(parse_codex_catalog_models(body).unwrap().len(), 3);
759        assert_eq!(reason("gpt-zz-live-lite"), None);
760        assert_eq!(reason("gpt-zz-live-full"), None);
761        assert_eq!(reason("gpt-zz-live-silent"), None);
762    }
763
764    #[test]
765    fn input_modalities_use_only_exact_observed_static_and_fixture_rows() {
766        for models in [
767            codex_static_catalog_models(),
768            parse_codex_catalog_models(FIXTURE).unwrap(),
769        ] {
770            for model in models {
771                let expected = match model.id.as_str() {
772                    "gpt-6.1-sol" | "gpt-6-astra" | "gpt-5.6-sol" | "gpt-5.6-terra"
773                    | "gpt-5.6-luna" | "gpt-5.5" | "gpt-5.4-mini" => {
774                        vec![Modality::Text, Modality::Image]
775                    }
776                    // 5.4 unobserved; spark explicitly text-only.
777                    "gpt-5.4" | "gpt-5.3-codex-spark" => vec![Modality::Text],
778                    _ => panic!("unexpected selectable fixture row"),
779                };
780                assert_eq!(model.input_modalities, expected, "{}", model.id);
781                assert!(!model.input_modalities.contains(&Modality::File));
782            }
783        }
784    }
785
786    #[test]
787    fn live_input_modalities_never_backfill_static_images() {
788        for (field, expected) in [
789            ("", vec![Modality::Text]),
790            (r#", "input_modalities": null"#, vec![Modality::Text]),
791            (r#", "input_modalities": ["text"]"#, vec![Modality::Text]),
792            (r#", "input_modalities": []"#, vec![]),
793            (r#", "input_modalities": ["image"]"#, vec![Modality::Image]),
794            (
795                r#", "input_modalities": ["text", "file"]"#,
796                vec![Modality::Text, Modality::File],
797            ),
798            (
799                r#", "input_modalities": ["Image", "image/png", "future"]"#,
800                vec![
801                    Modality::Other("Image".into()),
802                    Modality::Other("image/png".into()),
803                    Modality::Other("future".into()),
804                ],
805            ),
806        ] {
807            let body = format!(r#"{{"models":[{{"slug":"gpt-6-astra"{field}}}]}}"#);
808            let models = parse_codex_catalog_models(&body).unwrap();
809            assert_eq!(models[0].input_modalities, expected);
810            assert_eq!(models[0].source, CatalogSource::Live);
811        }
812    }
813
814    #[test]
815    fn malformed_input_modalities_reject_catalog_instead_of_authorizing_static() {
816        for value in [
817            r#""image""#,
818            "true",
819            "{}",
820            "[null]",
821            "[1]",
822            r#"["text", {}]"#,
823        ] {
824            let body =
825                format!(r#"{{"models":[{{"slug":"gpt-6-astra","input_modalities":{value}}}]}}"#);
826            assert!(parse_codex_catalog_models(&body).is_err());
827        }
828    }
829
830    fn ultra_test_row(
831        id: &str,
832        levels: &[&str],
833        override_value: serde_json::Value,
834    ) -> CatalogModel {
835        let body = serde_json::json!({"models": [{
836            "slug": id, "visibility": "list", "multi_agent_version": "v2",
837            "supported_reasoning_levels": levels.iter().map(|level| serde_json::json!({"effort": level})).collect::<Vec<_>>(),
838            "multi_agent_reasoning_effort": override_value,
839        }]}).to_string();
840        parse_codex_catalog_models(&body).unwrap().remove(0)
841    }
842
843    #[test]
844    fn ultra_uses_supported_override_for_foreground_worker_and_internal() {
845        for (value, wire) in [
846            ("low", CodexWireEffort::Low),
847            ("medium", CodexWireEffort::Medium),
848            ("high", CodexWireEffort::High),
849            ("xhigh", CodexWireEffort::XHigh),
850            ("max", CodexWireEffort::Max),
851        ] {
852            let row = ultra_test_row(
853                "gpt-override-matrix",
854                &["low", "medium", "high", "xhigh", "max", "ultra"],
855                value.into(),
856            );
857            for role in [
858                CodexRequestRole::Foreground,
859                CodexRequestRole::Worker,
860                CodexRequestRole::Internal,
861            ] {
862                let plan = plan_codex_execution(
863                    &row.runtime_id(),
864                    ReasoningLevel::Ultra,
865                    role,
866                    Some(&row),
867                )
868                .unwrap();
869                assert_eq!(plan.selected_level, ReasoningLevel::Ultra);
870                assert_eq!(plan.wire_effort, Some(wire));
871                assert_eq!(
872                    plan.automatic_delegation(),
873                    role == CodexRequestRole::Foreground
874                );
875            }
876        }
877    }
878
879    #[test]
880    fn ultra_invalid_or_absent_override_uses_supported_fallback_only() {
881        for value in [
882            serde_json::Value::Null,
883            "future".into(),
884            "ultra".into(),
885            "adaptive".into(),
886            "off".into(),
887            "ultracode".into(),
888            "high".into(),
889        ] {
890            for (levels, expected) in [
891                (vec!["low", "max", "xhigh", "ultra"], CodexWireEffort::Max),
892                (vec!["low", "xhigh", "ultra"], CodexWireEffort::XHigh),
893                (vec!["low", "ultra"], CodexWireEffort::Low),
894            ] {
895                let row = ultra_test_row("gpt-fallback-matrix", &levels, value.clone());
896                let plan = plan_codex_execution(
897                    &row.runtime_id(),
898                    ReasoningLevel::Ultra,
899                    CodexRequestRole::Foreground,
900                    Some(&row),
901                )
902                .unwrap();
903                assert_eq!(
904                    plan.wire_effort,
905                    Some(expected),
906                    "override={value} levels={levels:?}"
907                );
908            }
909        }
910        let row = ultra_test_row("gpt-no-wire-effort", &["ultra"], "xhigh".into());
911        assert_eq!(
912            plan_codex_execution(
913                &row.runtime_id(),
914                ReasoningLevel::Ultra,
915                CodexRequestRole::Foreground,
916                Some(&row)
917            )
918            .unwrap_err()
919            .code(),
920            CodexPlanErrorCode::CapabilityMetadataMissing
921        );
922    }
923
924    #[test]
925    fn astra_live_absence_never_borrows_static_xhigh_override() {
926        for levels in [
927            Some(serde_json::json!([{"effort":"max"},{"effort":"ultra"}])),
928            None,
929        ] {
930            for value in [None, Some(serde_json::Value::Null), Some("future".into())] {
931                let mut body = serde_json::json!({"models":[{"slug":"gpt-6-astra", "multi_agent_version":"v2"}]});
932                if let Some(levels) = &levels {
933                    body["models"][0]["supported_reasoning_levels"] = levels.clone();
934                }
935                if let Some(value) = value {
936                    body["models"][0]["multi_agent_reasoning_effort"] = value;
937                }
938                let row = parse_codex_catalog_models(&body.to_string())
939                    .unwrap()
940                    .remove(0);
941                let plan = plan_codex_execution(
942                    &row.runtime_id(),
943                    ReasoningLevel::Ultra,
944                    CodexRequestRole::Foreground,
945                    Some(&row),
946                )
947                .unwrap();
948                assert_eq!(plan.wire_effort, Some(CodexWireEffort::Max));
949                assert_eq!(plan.capability_source, Some(CodexCapabilitySource::Live));
950            }
951        }
952    }
953
954    #[test]
955    fn ultra_override_survives_cache_and_refresh_removal() {
956        // Synthetic exact identity avoids races with any live/ambient catalog.
957        let id = "gpt-ultra-cache-override-removal";
958        let row = ultra_test_row(id, &["high", "max", "ultra"], "high".into());
959        let qualified = row.runtime_id();
960        super::super::capability_cache::insert(row);
961        assert_eq!(
962            plan_codex_execution(
963                &qualified,
964                ReasoningLevel::Ultra,
965                CodexRequestRole::Worker,
966                None
967            )
968            .unwrap()
969            .wire_effort,
970            Some(CodexWireEffort::High)
971        );
972        let row = ultra_test_row(id, &["high", "max", "ultra"], serde_json::Value::Null);
973        super::super::capability_cache::insert(row);
974        assert_eq!(
975            plan_codex_execution(
976                &qualified,
977                ReasoningLevel::Ultra,
978                CodexRequestRole::Worker,
979                None
980            )
981            .unwrap()
982            .wire_effort,
983            Some(CodexWireEffort::Max)
984        );
985    }
986
987    #[test]
988    fn astra_override_does_not_change_explicit_levels_or_omission_modes() {
989        let row = parse_codex_catalog_models(FIXTURE).unwrap().remove(0);
990        for (level, wire) in [
991            (ReasoningLevel::Max, Some(CodexWireEffort::Max)),
992            (ReasoningLevel::XHigh, Some(CodexWireEffort::XHigh)),
993            (ReasoningLevel::Medium, Some(CodexWireEffort::Medium)),
994            (ReasoningLevel::Off, None),
995            (ReasoningLevel::Adaptive, None),
996        ] {
997            let plan = plan_codex_execution(
998                &row.runtime_id(),
999                level,
1000                CodexRequestRole::Foreground,
1001                Some(&row),
1002            )
1003            .unwrap();
1004            assert_eq!(plan.wire_effort, wire);
1005            assert!(!plan.automatic_delegation());
1006        }
1007        assert!(validate_codex_level("gpt-6-astra-wm", ReasoningLevel::Ultra, None).is_err());
1008    }
1009
1010    #[test]
1011    fn ultra_override_does_not_grant_missing_v2() {
1012        for version in [None, Some("v1"), Some("future")] {
1013            let mut body = serde_json::json!({"models":[{"slug":"gpt-6-astra", "multi_agent_reasoning_effort":"xhigh"}]});
1014            if let Some(version) = version {
1015                body["models"][0]["multi_agent_version"] = version.into();
1016            }
1017            let row = parse_codex_catalog_models(&body.to_string())
1018                .unwrap()
1019                .remove(0);
1020            assert_eq!(
1021                plan_codex_execution(
1022                    &row.runtime_id(),
1023                    ReasoningLevel::Ultra,
1024                    CodexRequestRole::Foreground,
1025                    Some(&row)
1026                )
1027                .unwrap_err()
1028                .code(),
1029                CodexPlanErrorCode::UltraRequiresMultiAgentV2
1030            );
1031        }
1032    }
1033
1034    #[test]
1035    fn parse_fixture_matches_list_visible_chatgpt_picker_models() {
1036        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
1037        let ids: Vec<_> = models.iter().map(|m| m.id.as_str()).collect();
1038        assert_eq!(
1039            ids,
1040            vec![
1041                "gpt-6-astra",
1042                "gpt-5.6-sol",
1043                "gpt-5.6-terra",
1044                "gpt-5.6-luna",
1045                "gpt-5.5",
1046                "gpt-5.4",
1047                "gpt-5.4-mini",
1048                "gpt-5.3-codex-spark",
1049            ]
1050        );
1051        // `supported_in_api` describes API-key availability, not ChatGPT OAuth
1052        // picker eligibility. Spark is list-visible despite this being false.
1053        assert!(ids.contains(&"gpt-5.3-codex-spark"));
1054        // Non-list-visible backend rows are not user-selectable.
1055        assert!(!ids.contains(&"codex-auto-review"));
1056        assert!(!ids.contains(&"codex-internal-eval"));
1057        assert!(models
1058            .iter()
1059            .all(|m| m.runtime_id().starts_with("openai-codex/")));
1060        assert!(models.iter().all(|m| m.source == CatalogSource::Live));
1061        assert!(models
1062            .iter()
1063            .all(|m| m.provider_kind == CatalogProviderKind::OpenAiCodex));
1064    }
1065
1066    #[test]
1067    fn parse_fixture_reads_display_name_and_context_window() {
1068        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
1069        let gpt55 = models.iter().find(|m| m.id == "gpt-5.5").unwrap();
1070        assert_eq!(gpt55.label.as_deref(), Some("GPT-5.5"));
1071        assert_eq!(gpt55.context_tokens, Some(272_000));
1072    }
1073
1074    #[test]
1075    fn execution_plan_fixture_preserves_multi_agent_versions() {
1076        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
1077        let version = |id: &str| {
1078            models
1079                .iter()
1080                .find(|model| model.id == id)
1081                .and_then(CatalogModel::codex_multi_agent_version)
1082        };
1083        assert_eq!(version("gpt-6-astra"), Some(CodexMultiAgentVersion::V2));
1084        assert_eq!(version("gpt-5.6-sol"), Some(CodexMultiAgentVersion::V2));
1085        assert_eq!(version("gpt-5.6-terra"), Some(CodexMultiAgentVersion::V2));
1086        assert_eq!(version("gpt-5.6-luna"), Some(CodexMultiAgentVersion::V1));
1087        assert_eq!(version("gpt-5.5"), None);
1088    }
1089
1090    #[test]
1091    fn execution_plan_maps_max_ultra_and_xhigh_exactly() {
1092        let max = plan_codex_execution(
1093            "openai-codex/gpt-5.6-sol",
1094            ReasoningLevel::Max,
1095            CodexRequestRole::Foreground,
1096            None,
1097        )
1098        .expect("Sol Max plan");
1099        assert_eq!(max.mode, CodexExecutionMode::Max);
1100        assert_eq!(max.wire_effort, Some(CodexWireEffort::Max));
1101        assert_eq!(
1102            max.multi_agent_mode,
1103            Some(CodexMultiAgentMode::ExplicitRequestOnly)
1104        );
1105
1106        let ultra = plan_codex_execution(
1107            "openai-codex/gpt-5.6-sol",
1108            ReasoningLevel::Ultra,
1109            CodexRequestRole::Foreground,
1110            None,
1111        )
1112        .expect("Sol Ultra plan");
1113        assert_eq!(ultra.mode, CodexExecutionMode::Ultra);
1114        assert_eq!(ultra.wire_effort, Some(CodexWireEffort::Max));
1115        assert_eq!(ultra.multi_agent_mode, Some(CodexMultiAgentMode::Proactive));
1116
1117        let xhigh = plan_codex_execution(
1118            "openai-codex/gpt-5.6-sol",
1119            ReasoningLevel::XHigh,
1120            CodexRequestRole::Foreground,
1121            None,
1122        )
1123        .expect("Sol XHigh plan");
1124        assert_eq!(xhigh.mode, CodexExecutionMode::Standard);
1125        assert_eq!(xhigh.wire_effort, Some(CodexWireEffort::XHigh));
1126        assert_eq!(
1127            xhigh.multi_agent_mode,
1128            Some(CodexMultiAgentMode::ExplicitRequestOnly)
1129        );
1130    }
1131
1132    #[test]
1133    fn execution_plan_luna_max_uses_max_without_v2_context() {
1134        let plan = plan_codex_execution(
1135            "openai-codex/gpt-5.6-luna",
1136            ReasoningLevel::Max,
1137            CodexRequestRole::Foreground,
1138            None,
1139        )
1140        .expect("Luna Max plan");
1141        assert_eq!(plan.wire_effort, Some(CodexWireEffort::Max));
1142        assert_eq!(plan.multi_agent_version, Some(CodexMultiAgentVersion::V1));
1143        assert_eq!(plan.multi_agent_mode, None);
1144    }
1145
1146    #[test]
1147    fn execution_plan_worker_ultra_never_enables_proactive_mode() {
1148        let plan = plan_codex_execution(
1149            "openai-codex/gpt-5.6-sol",
1150            ReasoningLevel::Ultra,
1151            CodexRequestRole::Worker,
1152            None,
1153        )
1154        .expect("worker Ultra plan");
1155        assert_eq!(plan.wire_effort, Some(CodexWireEffort::Max));
1156        assert_eq!(plan.multi_agent_mode, None);
1157        assert!(!plan.automatic_delegation());
1158    }
1159
1160    #[test]
1161    fn execution_plan_ultra_requires_live_v2_without_static_backfill() {
1162        for (slug, version_field) in [
1163            ("gpt-ultra-live-missing", ""),
1164            ("gpt-ultra-live-v1", r#", "multi_agent_version": "v1""#),
1165            ("gpt-ultra-live-unknown", r#", "multi_agent_version": "v3""#),
1166        ] {
1167            let body = format!(
1168                r#"{{"models":[{{
1169                    "slug":"{slug}",
1170                    "visibility":"list",
1171                    "supported_reasoning_levels":[{{"effort":"max"}},{{"effort":"ultra"}}]
1172                    {version_field}
1173                }}]}}"#
1174            );
1175            let models = parse_codex_catalog_models(&body).expect("parse live row");
1176            let error = plan_codex_execution(
1177                &format!("openai-codex/{slug}"),
1178                ReasoningLevel::Ultra,
1179                CodexRequestRole::Foreground,
1180                Some(&models[0]),
1181            )
1182            .expect_err("live missing/v1/unknown version must deny Ultra");
1183            assert_eq!(error.code(), CodexPlanErrorCode::UltraRequiresMultiAgentV2);
1184        }
1185    }
1186
1187    #[test]
1188    fn execution_plan_rejects_wrong_provider_identity() {
1189        let error = plan_codex_execution(
1190            "openrouter/gpt-5.6-sol",
1191            ReasoningLevel::Ultra,
1192            CodexRequestRole::Foreground,
1193            None,
1194        )
1195        .expect_err("wrong provider must fail closed");
1196        assert_eq!(error.code(), CodexPlanErrorCode::InvalidProviderIdentity);
1197    }
1198
1199    #[test]
1200    fn execution_plan_rejects_wrong_provider_kind_in_cache() {
1201        let id = "gpt-cached-provider-kind-spoof";
1202        let qualified = format!("openai-codex/{id}");
1203        let mut spoofed = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, id).unwrap();
1204        spoofed.reasoning = ReasoningSupport::CodexNamed {
1205            multi_agent_reasoning_effort: None,
1206            supported: vec![ReasoningLevel::Max, ReasoningLevel::Ultra],
1207            default_level: Some(ReasoningLevel::Max),
1208            multi_agent_version: Some(CodexMultiAgentVersion::V2),
1209        };
1210        super::super::capability_cache::insert(spoofed);
1211
1212        let error = plan_codex_execution(
1213            &qualified,
1214            ReasoningLevel::Ultra,
1215            CodexRequestRole::Foreground,
1216            None,
1217        )
1218        .expect_err("cache rows must preserve the exact Codex provider kind");
1219        assert_eq!(error.code(), CodexPlanErrorCode::InvalidProviderIdentity);
1220    }
1221
1222    #[test]
1223    fn execution_plan_rejects_inferred_capability_provenance() {
1224        fn inferred_model(id: &str) -> CatalogModel {
1225            let mut model = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, id).unwrap();
1226            model.provider_kind = CatalogProviderKind::OpenAiCodex;
1227            model.source = CatalogSource::Inferred;
1228            model.reasoning = ReasoningSupport::CodexNamed {
1229                multi_agent_reasoning_effort: None,
1230                supported: vec![ReasoningLevel::Max, ReasoningLevel::Ultra],
1231                default_level: Some(ReasoningLevel::Max),
1232                multi_agent_version: Some(CodexMultiAgentVersion::V2),
1233            };
1234            model
1235        }
1236
1237        let explicit_id = "gpt-inferred-explicit-provenance";
1238        let explicit = inferred_model(explicit_id);
1239        let explicit_error = plan_codex_execution(
1240            &format!("openai-codex/{explicit_id}"),
1241            ReasoningLevel::Ultra,
1242            CodexRequestRole::Foreground,
1243            Some(&explicit),
1244        )
1245        .expect_err("inferred explicit metadata must never authorize Ultra");
1246        assert_eq!(
1247            explicit_error.code(),
1248            CodexPlanErrorCode::CapabilityMetadataMissing
1249        );
1250
1251        let cached_id = "gpt-inferred-cached-provenance";
1252        super::super::capability_cache::insert(inferred_model(cached_id));
1253        let cached_error = plan_codex_execution(
1254            &format!("openai-codex/{cached_id}"),
1255            ReasoningLevel::Ultra,
1256            CodexRequestRole::Foreground,
1257            None,
1258        )
1259        .expect_err("inferred cached metadata must never authorize Ultra");
1260        assert_eq!(
1261            cached_error.code(),
1262            CodexPlanErrorCode::CapabilityMetadataMissing
1263        );
1264    }
1265
1266    // ── Reasoning level parsing from fixture ──────────────────────────────────
1267
1268    #[test]
1269    fn parse_fixture_astra_is_first_with_full_ladder_and_v2() {
1270        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
1271        let astra = &models[0];
1272        assert_eq!(
1273            astra.id, "gpt-6-astra",
1274            "astra is priority 1 in the live catalog"
1275        );
1276        assert_eq!(astra.label.as_deref(), Some("GPT-6-Astra"));
1277        assert_eq!(astra.context_tokens, Some(272_000));
1278        for level in [
1279            ReasoningLevel::Low,
1280            ReasoningLevel::Medium,
1281            ReasoningLevel::High,
1282            ReasoningLevel::XHigh,
1283            ReasoningLevel::Max,
1284            ReasoningLevel::Ultra,
1285        ] {
1286            assert!(
1287                astra.codex_supports_level(level),
1288                "astra must support {level}"
1289            );
1290        }
1291        assert_eq!(
1292            astra.codex_multi_agent_version(),
1293            Some(CodexMultiAgentVersion::V2)
1294        );
1295        assert_eq!(astra.runtime_id(), "openai-codex/gpt-6-astra");
1296    }
1297
1298    #[test]
1299    fn parse_fixture_sol_has_ultra() {
1300        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
1301        let sol = models.iter().find(|m| m.id == "gpt-5.6-sol").unwrap();
1302        assert!(
1303            sol.codex_supports_level(ReasoningLevel::Ultra),
1304            "sol must support ultra"
1305        );
1306        assert!(sol.codex_supports_level(ReasoningLevel::Max));
1307        assert!(sol.codex_supports_level(ReasoningLevel::XHigh));
1308    }
1309
1310    #[test]
1311    fn parse_fixture_terra_has_ultra() {
1312        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
1313        let terra = models.iter().find(|m| m.id == "gpt-5.6-terra").unwrap();
1314        assert!(terra.codex_supports_level(ReasoningLevel::Ultra));
1315        assert!(terra.codex_supports_level(ReasoningLevel::Max));
1316    }
1317
1318    #[test]
1319    fn parse_fixture_luna_has_max_not_ultra() {
1320        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
1321        let luna = models.iter().find(|m| m.id == "gpt-5.6-luna").unwrap();
1322        assert!(luna.codex_supports_level(ReasoningLevel::Max));
1323        assert!(
1324            !luna.codex_supports_level(ReasoningLevel::Ultra),
1325            "luna must NOT support ultra"
1326        );
1327    }
1328
1329    #[test]
1330    fn parse_fixture_gpt55_has_xhigh_not_max_or_ultra() {
1331        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
1332        let m = models.iter().find(|m| m.id == "gpt-5.5").unwrap();
1333        assert!(m.codex_supports_level(ReasoningLevel::XHigh));
1334        assert!(
1335            !m.codex_supports_level(ReasoningLevel::Max),
1336            "gpt-5.5 must NOT support max"
1337        );
1338        assert!(
1339            !m.codex_supports_level(ReasoningLevel::Ultra),
1340            "gpt-5.5 must NOT support ultra"
1341        );
1342    }
1343
1344    #[test]
1345    fn parse_fixture_spark_has_xhigh_not_max_or_ultra() {
1346        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
1347        let spark = models
1348            .iter()
1349            .find(|m| m.id == "gpt-5.3-codex-spark")
1350            .unwrap();
1351        assert!(spark.codex_supports_level(ReasoningLevel::XHigh));
1352        assert!(!spark.codex_supports_level(ReasoningLevel::Max));
1353        assert!(!spark.codex_supports_level(ReasoningLevel::Ultra));
1354    }
1355
1356    #[test]
1357    fn parse_fixture_default_levels_match_observed_cache() {
1358        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
1359        let get = |id: &str| {
1360            models
1361                .iter()
1362                .find(|m| m.id == id)
1363                .unwrap_or_else(|| panic!("missing {id}"))
1364        };
1365        let default_of = |id: &str| match &get(id).reasoning {
1366            ReasoningSupport::CodexNamed { default_level, .. } => *default_level,
1367            other => panic!("{id}: expected CodexNamed, got {other:?}"),
1368        };
1369        assert_eq!(
1370            default_of("gpt-6-astra"),
1371            Some(ReasoningLevel::Medium),
1372            "astra"
1373        );
1374        assert_eq!(default_of("gpt-5.6-sol"), Some(ReasoningLevel::Low), "sol");
1375        assert_eq!(
1376            default_of("gpt-5.6-terra"),
1377            Some(ReasoningLevel::Medium),
1378            "terra"
1379        );
1380        assert_eq!(
1381            default_of("gpt-5.6-luna"),
1382            Some(ReasoningLevel::Medium),
1383            "luna"
1384        );
1385        assert_eq!(default_of("gpt-5.5"), Some(ReasoningLevel::Medium), "5.5");
1386        assert_eq!(default_of("gpt-5.4"), Some(ReasoningLevel::Medium), "5.4");
1387        assert_eq!(
1388            default_of("gpt-5.4-mini"),
1389            Some(ReasoningLevel::Medium),
1390            "5.4-mini"
1391        );
1392        assert_eq!(
1393            default_of("gpt-5.3-codex-spark"),
1394            Some(ReasoningLevel::High),
1395            "spark"
1396        );
1397    }
1398
1399    #[test]
1400    fn unknown_effort_strings_are_ignored_not_coerced() {
1401        let body = r#"{"models":[{
1402            "slug": "gpt-test",
1403            "visibility": "list",
1404            "supported_reasoning_levels": [
1405                {"effort": "low"},
1406                {"effort": "hyper-ultra-experimental"},
1407                {"effort": "high"}
1408            ]
1409        }]}"#;
1410        let models = parse_codex_catalog_models(body).unwrap();
1411        let m = &models[0];
1412        // "hyper-ultra-experimental" is silently dropped; only low and high survive.
1413        assert!(m.codex_supports_level(ReasoningLevel::Low));
1414        assert!(m.codex_supports_level(ReasoningLevel::High));
1415        assert!(!m.codex_supports_level(ReasoningLevel::Ultra));
1416        assert!(!m.codex_supports_level(ReasoningLevel::Max));
1417    }
1418
1419    #[test]
1420    fn live_known_model_with_only_unknown_efforts_does_not_borrow_static_ultra() {
1421        let item: CodexModelItem = serde_json::from_str(
1422            r#"{
1423            "slug": "gpt-5.6-sol",
1424            "visibility": "list",
1425            "supported_reasoning_levels": [{"effort": "future-effort"}],
1426            "multi_agent_version": "v2"
1427        }"#,
1428        )
1429        .expect("parse live Sol row");
1430        let mut model = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, &item.slug).unwrap();
1431        model.provider_kind = CatalogProviderKind::OpenAiCodex;
1432        model.source = CatalogSource::Live;
1433        model.reasoning = codex_reasoning_support(&item);
1434
1435        let error = plan_codex_execution(
1436            "openai-codex/gpt-5.6-sol",
1437            ReasoningLevel::Ultra,
1438            CodexRequestRole::Foreground,
1439            Some(&model),
1440        )
1441        .expect_err("an explicit unknown live effort list must fail closed");
1442        assert_eq!(error.code(), CodexPlanErrorCode::UnsupportedReasoningLevel);
1443    }
1444
1445    // ── Static capability table ───────────────────────────────────────────────
1446
1447    #[test]
1448    fn static_astra_has_ultra_v2_and_default_medium() {
1449        let cap = codex_static_capability("gpt-6-astra").expect("astra");
1450        match cap {
1451            ReasoningSupport::CodexNamed {
1452                supported,
1453                default_level,
1454                multi_agent_version,
1455                ..
1456            } => {
1457                assert_eq!(
1458                    supported,
1459                    vec![
1460                        ReasoningLevel::Low,
1461                        ReasoningLevel::Medium,
1462                        ReasoningLevel::High,
1463                        ReasoningLevel::XHigh,
1464                        ReasoningLevel::Max,
1465                        ReasoningLevel::Ultra,
1466                    ],
1467                    "astra ladder matches the live catalog exactly"
1468                );
1469                assert_eq!(
1470                    default_level,
1471                    Some(ReasoningLevel::Medium),
1472                    "astra default is Medium (unlike Sol's Low)"
1473                );
1474                assert_eq!(
1475                    multi_agent_version,
1476                    Some(CodexMultiAgentVersion::V2),
1477                    "astra advertises multi-agent v2 so Ultra is authorizable offline"
1478                );
1479            }
1480            _ => panic!("expected CodexNamed"),
1481        }
1482    }
1483
1484    #[test]
1485    fn static_astra_matches_fixture_row_exactly() {
1486        // Guard against the static seed drifting from the live/fixture row.
1487        let live = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
1488        let live_astra = live.iter().find(|m| m.id == "gpt-6-astra").unwrap();
1489        let static_astra = codex_static_catalog_models()
1490            .into_iter()
1491            .find(|m| m.id == "gpt-6-astra")
1492            .unwrap();
1493        assert_eq!(static_astra.label, live_astra.label);
1494        assert_eq!(static_astra.reasoning, live_astra.reasoning);
1495        assert_eq!(
1496            static_astra.codex_supported_levels(),
1497            live_astra.codex_supported_levels()
1498        );
1499        assert_eq!(
1500            static_astra.codex_multi_agent_version(),
1501            live_astra.codex_multi_agent_version()
1502        );
1503        assert_eq!(static_astra.source, CatalogSource::StaticFallback);
1504        assert_eq!(live_astra.source, CatalogSource::Live);
1505    }
1506
1507    #[test]
1508    fn static_sol_has_ultra_and_default_low() {
1509        let cap = codex_static_capability("gpt-5.6-sol").expect("sol");
1510        match cap {
1511            ReasoningSupport::CodexNamed {
1512                supported,
1513                default_level,
1514                ..
1515            } => {
1516                assert!(
1517                    supported.contains(&ReasoningLevel::Ultra),
1518                    "sol needs ultra"
1519                );
1520                assert!(supported.contains(&ReasoningLevel::Max), "sol needs max");
1521                assert_eq!(
1522                    default_level,
1523                    Some(ReasoningLevel::Low),
1524                    "sol default is Low"
1525                );
1526            }
1527            _ => panic!("expected CodexNamed"),
1528        }
1529    }
1530
1531    #[test]
1532    fn static_terra_has_ultra_and_default_medium() {
1533        let cap = codex_static_capability("gpt-5.6-terra").expect("terra");
1534        match cap {
1535            ReasoningSupport::CodexNamed {
1536                supported,
1537                default_level,
1538                ..
1539            } => {
1540                assert!(
1541                    supported.contains(&ReasoningLevel::Ultra),
1542                    "terra needs ultra"
1543                );
1544                assert!(supported.contains(&ReasoningLevel::Max), "terra needs max");
1545                assert_eq!(
1546                    default_level,
1547                    Some(ReasoningLevel::Medium),
1548                    "terra default is Medium"
1549                );
1550            }
1551            _ => panic!("expected CodexNamed"),
1552        }
1553    }
1554
1555    #[test]
1556    fn static_luna_has_max_not_ultra_and_default_medium() {
1557        let cap = codex_static_capability("gpt-5.6-luna").unwrap();
1558        match cap {
1559            ReasoningSupport::CodexNamed {
1560                supported,
1561                default_level,
1562                ..
1563            } => {
1564                assert!(supported.contains(&ReasoningLevel::Max));
1565                assert!(!supported.contains(&ReasoningLevel::Ultra));
1566                assert_eq!(
1567                    default_level,
1568                    Some(ReasoningLevel::Medium),
1569                    "luna default is Medium"
1570                );
1571            }
1572            _ => panic!(),
1573        }
1574    }
1575
1576    #[test]
1577    fn static_gpt55_family_has_xhigh_not_max_ultra_default_medium() {
1578        for id in ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"] {
1579            let cap = codex_static_capability(id).expect(id);
1580            match cap {
1581                ReasoningSupport::CodexNamed {
1582                    supported,
1583                    default_level,
1584                    ..
1585                } => {
1586                    assert!(
1587                        supported.contains(&ReasoningLevel::XHigh),
1588                        "{id} needs xhigh"
1589                    );
1590                    assert!(
1591                        !supported.contains(&ReasoningLevel::Max),
1592                        "{id} must NOT have max"
1593                    );
1594                    assert!(
1595                        !supported.contains(&ReasoningLevel::Ultra),
1596                        "{id} must NOT have ultra"
1597                    );
1598                    assert_eq!(
1599                        default_level,
1600                        Some(ReasoningLevel::Medium),
1601                        "{id} default is Medium"
1602                    );
1603                }
1604                _ => panic!(),
1605            }
1606        }
1607    }
1608
1609    #[test]
1610    fn static_spark_default_high() {
1611        let cap = codex_static_capability("gpt-5.3-codex-spark").unwrap();
1612        match cap {
1613            ReasoningSupport::CodexNamed {
1614                supported,
1615                default_level,
1616                ..
1617            } => {
1618                assert!(supported.contains(&ReasoningLevel::XHigh));
1619                assert!(!supported.contains(&ReasoningLevel::Max));
1620                assert!(!supported.contains(&ReasoningLevel::Ultra));
1621                assert_eq!(
1622                    default_level,
1623                    Some(ReasoningLevel::High),
1624                    "spark default is High"
1625                );
1626            }
1627            _ => panic!(),
1628        }
1629    }
1630
1631    #[test]
1632    fn static_unknown_model_returns_none() {
1633        assert!(codex_static_capability("gpt-future-unknown").is_none());
1634        // Internal/hidden models are also not in the table.
1635        assert!(codex_static_capability("codex-auto-review").is_none());
1636    }
1637
1638    // ── validate_codex_level ──────────────────────────────────────────────────
1639
1640    #[test]
1641    fn validate_astra_full_ladder_ok() {
1642        for level in [
1643            ReasoningLevel::Low,
1644            ReasoningLevel::Medium,
1645            ReasoningLevel::High,
1646            ReasoningLevel::XHigh,
1647            ReasoningLevel::Max,
1648            ReasoningLevel::Ultra,
1649        ] {
1650            assert!(
1651                validate_codex_level("gpt-6-astra", level, None).is_ok(),
1652                "astra must accept {level}"
1653            );
1654        }
1655    }
1656
1657    #[test]
1658    fn execution_plan_astra_ultra_is_proactive_foreground_xhigh_wire() {
1659        let plan = plan_codex_execution(
1660            "openai-codex/gpt-6-astra",
1661            ReasoningLevel::Ultra,
1662            CodexRequestRole::Foreground,
1663            None,
1664        )
1665        .expect("astra ultra plan");
1666        assert_eq!(plan.mode, CodexExecutionMode::Ultra);
1667        assert_eq!(plan.wire_effort, Some(CodexWireEffort::XHigh));
1668        assert_eq!(plan.multi_agent_version, Some(CodexMultiAgentVersion::V2));
1669        assert_eq!(plan.multi_agent_mode, Some(CodexMultiAgentMode::Proactive));
1670        assert!(plan.automatic_delegation());
1671    }
1672
1673    #[test]
1674    fn static_gpt_6_1_sol_plans_max_offline_and_ultra_as_xhigh() {
1675        // Offline / pre-catalog sessions must still authorize the bridge's
1676        // `thinking = max` for gpt-6.1-sol (live row: default low, full ladder,
1677        // multi-agent v2, multi_agent_reasoning_effort xhigh).
1678        let max = plan_codex_execution(
1679            "openai-codex/gpt-6.1-sol",
1680            ReasoningLevel::Max,
1681            CodexRequestRole::Foreground,
1682            None,
1683        )
1684        .expect("gpt-6.1-sol max plan");
1685        assert_eq!(max.mode, CodexExecutionMode::Max);
1686        assert_eq!(max.wire_effort, Some(CodexWireEffort::Max));
1687        assert!(!max.automatic_delegation());
1688
1689        let ultra = plan_codex_execution(
1690            "openai-codex/gpt-6.1-sol",
1691            ReasoningLevel::Ultra,
1692            CodexRequestRole::Foreground,
1693            None,
1694        )
1695        .expect("gpt-6.1-sol ultra plan");
1696        assert_eq!(ultra.wire_effort, Some(CodexWireEffort::XHigh));
1697        assert_eq!(ultra.multi_agent_version, Some(CodexMultiAgentVersion::V2));
1698
1699        let models = codex_static_catalog_models();
1700        let row = models
1701            .iter()
1702            .find(|m| m.id == "gpt-6.1-sol")
1703            .expect("static gpt-6.1-sol row");
1704        assert!(row.input_modalities.contains(&Modality::Image));
1705    }
1706
1707    #[test]
1708    fn validate_sol_ultra_ok() {
1709        assert!(validate_codex_level("gpt-5.6-sol", ReasoningLevel::Ultra, None).is_ok());
1710    }
1711
1712    #[test]
1713    fn validate_sol_client_omission_modes_ok() {
1714        assert!(validate_codex_level("gpt-5.6-sol", ReasoningLevel::Off, None).is_ok());
1715        assert!(validate_codex_level("gpt-5.6-sol", ReasoningLevel::Adaptive, None).is_ok());
1716    }
1717
1718    #[test]
1719    fn validate_luna_ultra_rejected() {
1720        let err = validate_codex_level("gpt-5.6-luna", ReasoningLevel::Ultra, None).unwrap_err();
1721        assert!(err.contains("ultra"), "error must name the rejected level");
1722        assert!(err.contains("gpt-5.6-luna"));
1723    }
1724
1725    #[test]
1726    fn validate_gpt55_max_rejected() {
1727        assert!(validate_codex_level("gpt-5.5", ReasoningLevel::Max, None).is_err());
1728        assert!(validate_codex_level("gpt-5.5", ReasoningLevel::Ultra, None).is_err());
1729    }
1730
1731    #[test]
1732    fn validate_unknown_model_rejected() {
1733        let err = validate_codex_level("gpt-future-x", ReasoningLevel::Max, None).unwrap_err();
1734        assert!(err.contains("no capability metadata"));
1735    }
1736
1737    #[test]
1738    fn validate_live_catalog_overrides_static() {
1739        // Build a live catalog model that supports ONLY low+medium (fewer than static).
1740        let mut live = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, "gpt-5.6-sol").unwrap();
1741        live.provider_kind = CatalogProviderKind::OpenAiCodex;
1742        live.reasoning = ReasoningSupport::CodexNamed {
1743            multi_agent_reasoning_effort: None,
1744            supported: vec![ReasoningLevel::Low, ReasoningLevel::Medium],
1745            default_level: Some(ReasoningLevel::Low),
1746            multi_agent_version: None,
1747        };
1748        // Ultra is in the static table but the live model says otherwise.
1749        let err =
1750            validate_codex_level("gpt-5.6-sol", ReasoningLevel::Ultra, Some(&live)).unwrap_err();
1751        assert!(err.contains("ultra"));
1752        // Low is accepted.
1753        assert!(validate_codex_level("gpt-5.6-sol", ReasoningLevel::Low, Some(&live)).is_ok());
1754    }
1755
1756    #[test]
1757    fn validate_live_ultra_requires_v2_even_when_listed() {
1758        let mut live = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, "gpt-ultra-no-v2").unwrap();
1759        live.provider_kind = CatalogProviderKind::OpenAiCodex;
1760        live.source = CatalogSource::Live;
1761        live.reasoning = ReasoningSupport::CodexNamed {
1762            multi_agent_reasoning_effort: None,
1763            supported: vec![ReasoningLevel::Max, ReasoningLevel::Ultra],
1764            default_level: Some(ReasoningLevel::Max),
1765            multi_agent_version: None,
1766        };
1767
1768        assert!(validate_codex_level("gpt-ultra-no-v2", ReasoningLevel::Max, Some(&live)).is_ok());
1769        let err = validate_codex_level("gpt-ultra-no-v2", ReasoningLevel::Ultra, Some(&live))
1770            .expect_err("Ultra must require exact v2 capability");
1771        assert!(err.contains("multi-agent v2"), "{err}");
1772    }
1773
1774    /// Verify that `validate_codex_level(..., None)` consults the process-local
1775    /// capability cache (gap 1): a narrower live entry must override the static
1776    /// table even when no explicit catalog_model argument is supplied.
1777    #[test]
1778    fn validate_cache_narrows_sol_rejects_ultra_without_catalog_arg() {
1779        // Use a unique model slug to avoid cross-test pollution from the shared cache.
1780        let unique_id = "gpt-5.6-sol-cache-test-ultra";
1781        let qualified = format!("openai-codex/{unique_id}");
1782
1783        // Insert a live cache entry that supports only Low+Medium (no Ultra).
1784        let mut live = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, unique_id).unwrap();
1785        live.provider_kind = CatalogProviderKind::OpenAiCodex;
1786        live.source = CatalogSource::Live;
1787        live.reasoning = ReasoningSupport::CodexNamed {
1788            multi_agent_reasoning_effort: None,
1789            supported: vec![ReasoningLevel::Low, ReasoningLevel::Medium],
1790            default_level: Some(ReasoningLevel::Low),
1791            multi_agent_version: None,
1792        };
1793        super::super::capability_cache::insert(live);
1794
1795        // Confirm it's in the cache.
1796        let cached = super::super::capability_cache::get(&qualified)
1797            .expect("model must be in cache after insert");
1798        assert!(matches!(
1799            cached.reasoning,
1800            ReasoningSupport::CodexNamed { .. }
1801        ));
1802
1803        // validate_codex_level with catalog_model=None must use the cache and
1804        // reject Ultra (which the static sol table would have allowed).
1805        let err = validate_codex_level(unique_id, ReasoningLevel::Ultra, None)
1806            .expect_err("cache should narrow Ultra rejection");
1807        assert!(
1808            err.contains("ultra"),
1809            "error must name the rejected level; got: {err}"
1810        );
1811
1812        // Low is still accepted via the cache.
1813        assert!(
1814            validate_codex_level(unique_id, ReasoningLevel::Low, None).is_ok(),
1815            "Low must still pass via cache"
1816        );
1817    }
1818
1819    // ── Existing catalog tests ────────────────────────────────────────────────
1820
1821    #[test]
1822    fn models_path_includes_client_version_query() {
1823        assert_eq!(
1824            codex_models_path("0.153.3"),
1825            "/codex/models?client_version=0.153.3"
1826        );
1827        assert_eq!(
1828            codex_models_url("0.153.3"),
1829            "https://chatgpt.com/backend-api/codex/models?client_version=0.153.3"
1830        );
1831    }
1832
1833    #[test]
1834    fn models_path_empty_version_falls_back_to_pinned_protocol_version() {
1835        // Never the crate version: the backend returns an empty catalog for
1836        // unknown client versions, which would silently hide every model.
1837        assert_eq!(
1838            codex_models_path(""),
1839            format!("/codex/models?client_version={CODEX_CLIENT_VERSION}")
1840        );
1841        assert_eq!(codex_models_path("   "), codex_models_path(""));
1842    }
1843
1844    #[test]
1845    fn models_path_targets_codex_catalog_not_chatgpt_web_picker() {
1846        // `/models` is the ChatGPT web picker (different schema, `-wm` slugs,
1847        // no `visibility`); the Codex catalog lives under `/codex/models`.
1848        assert_eq!(MODELS_PATH, "/codex/models");
1849        assert!(codex_models_path("0.153.3").starts_with("/codex/models?"));
1850    }
1851
1852    #[test]
1853    fn selectable_rules_match_codex_picker() {
1854        assert!(codex_model_is_selectable(Some("list"), Some(true)));
1855        assert!(codex_model_is_selectable(Some("list"), Some(false)));
1856        assert!(codex_model_is_selectable(None, Some(true)));
1857        assert!(codex_model_is_selectable(Some("list"), None));
1858        assert!(!codex_model_is_selectable(Some("hide"), Some(true)));
1859        assert!(!codex_model_is_selectable(Some("internal"), Some(false)));
1860        assert!(!codex_model_is_selectable(Some("hidden"), Some(true)));
1861    }
1862
1863    #[test]
1864    fn static_catalog_is_current_safe_chatgpt_oauth_set() {
1865        let models = codex_static_catalog_models();
1866        let ids: Vec<_> = models.iter().map(|model| model.id.as_str()).collect();
1867        assert_eq!(
1868            ids,
1869            vec![
1870                "gpt-6.1-sol",
1871                "gpt-6-astra",
1872                "gpt-5.6-sol",
1873                "gpt-5.6-terra",
1874                "gpt-5.6-luna",
1875                "gpt-5.5",
1876                "gpt-5.4",
1877                "gpt-5.4-mini",
1878                "gpt-5.3-codex-spark",
1879            ]
1880        );
1881        // ChatGPT-web-only / API-only slugs never appear in the Codex picker.
1882        for api_only in [
1883            "gpt-6-astra-wm",
1884            "gpt-6-pro",
1885            "gpt-5.6-sol-wm",
1886            "gpt-5.6-pro",
1887            "gpt-5.5-pro",
1888        ] {
1889            assert!(!ids.contains(&api_only));
1890        }
1891        assert!(models
1892            .iter()
1893            .all(|m| m.source == CatalogSource::StaticFallback));
1894        assert!(models
1895            .iter()
1896            .all(|m| m.runtime_id().starts_with("openai-codex/")));
1897    }
1898
1899    #[test]
1900    fn static_catalog_has_codex_named_reasoning() {
1901        let models = codex_static_catalog_models();
1902        for m in &models {
1903            assert!(
1904                matches!(m.reasoning, ReasoningSupport::CodexNamed { .. }),
1905                "static model {} must have CodexNamed reasoning",
1906                m.id
1907            );
1908        }
1909    }
1910
1911    #[test]
1912    fn invalid_json_returns_error() {
1913        assert!(parse_codex_catalog_models("{not json}").is_err());
1914    }
1915
1916    #[test]
1917    fn missing_models_key_returns_error() {
1918        assert!(parse_codex_catalog_models(r#"{"data":[]}"#).is_err());
1919    }
1920}