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