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.
12pub const MODELS_PATH: &str = "/models";
13
14// ─── Static capability table ─────────────────────────────────────────────────
15//
16// Per spec §Assumptions #1: the live catalog publishes exact
17// `supported_reasoning_levels` per model. These static values are the
18// safe fallback for offline / not-yet-configured sessions, and the
19// authoritative capability table for the worktree implementation.
20//
21// Levels are ordered from least to most intensive (matching the spec table).
22
23/// Static per-model capability for the known Codex catalog, keyed by model id.
24/// Used when live catalog data is unavailable.
25pub fn codex_static_capability(model_id: &str) -> Option<ReasoningSupport> {
26    use ReasoningLevel::*;
27    let (supported, default_level, multi_agent_version): (
28        &[ReasoningLevel],
29        ReasoningLevel,
30        Option<CodexMultiAgentVersion>,
31    ) = match model_id {
32        "gpt-5.6-sol" => (
33            &[Low, Medium, High, XHigh, Max, Ultra],
34            Low,
35            Some(CodexMultiAgentVersion::V2),
36        ),
37        "gpt-5.6-terra" => (
38            &[Low, Medium, High, XHigh, Max, Ultra],
39            Medium,
40            Some(CodexMultiAgentVersion::V2),
41        ),
42        "gpt-5.6-luna" => (
43            &[Low, Medium, High, XHigh, Max],
44            Medium,
45            Some(CodexMultiAgentVersion::V1),
46        ),
47        "gpt-5.5" | "gpt-5.4" | "gpt-5.4-mini" => (&[Low, Medium, High, XHigh], Medium, None),
48        "gpt-5.3-codex-spark" => (&[Low, Medium, High, XHigh], High, None),
49        _ => return None,
50    };
51    Some(ReasoningSupport::CodexNamed {
52        supported: supported.to_vec(),
53        default_level: Some(default_level),
54        multi_agent_version,
55    })
56}
57
58/// Build the static catalog models with static capability data attached.
59pub fn codex_static_catalog_models() -> Vec<CatalogModel> {
60    [
61        ("gpt-5.6-sol", "GPT-5.6-Sol"),
62        ("gpt-5.6-terra", "GPT-5.6-Terra"),
63        ("gpt-5.6-luna", "GPT-5.6-Luna"),
64        ("gpt-5.5", "GPT-5.5"),
65        ("gpt-5.4", "GPT-5.4"),
66        ("gpt-5.4-mini", "GPT-5.4 Mini"),
67        ("gpt-5.3-codex-spark", "GPT-5.3-Codex-Spark"),
68    ]
69    .into_iter()
70    .filter_map(|(id, label)| {
71        let mut m = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, id)?;
72        m.provider_kind = CatalogProviderKind::OpenAiCodex;
73        m.label = Some(label.to_string());
74        m.reasoning = codex_static_capability(id).unwrap_or(ReasoningSupport::Unknown);
75        m.source = CatalogSource::StaticFallback;
76        Some(m)
77    })
78    .collect()
79}
80
81/// Build the broker-relative path for the ChatGPT backend models endpoint,
82/// including the required `client_version` query (matches official Codex).
83pub fn codex_models_path(client_version: &str) -> String {
84    let version = if client_version.trim().is_empty() {
85        env!("CARGO_PKG_VERSION")
86    } else {
87        client_version.trim()
88    };
89    format!("{MODELS_PATH}?client_version={version}")
90}
91
92/// Full pinned models URL (host + path + client_version query).
93pub fn codex_models_url(client_version: &str) -> String {
94    format!("{MODELS_BASE_URL}{}", codex_models_path(client_version))
95}
96
97// ─── Live catalog wire types ──────────────────────────────────────────────────
98
99#[derive(Debug, Deserialize)]
100struct CodexModelsResponse {
101    models: Vec<CodexModelItem>,
102}
103
104#[derive(Debug, Deserialize)]
105struct CodexModelItem {
106    slug: String,
107    #[serde(default)]
108    display_name: Option<String>,
109    #[serde(default)]
110    visibility: Option<String>,
111    #[serde(default)]
112    supported_in_api: Option<bool>,
113    #[serde(default)]
114    context_window: Option<u64>,
115    /// Live catalog reasoning metadata.
116    #[serde(default)]
117    supported_reasoning_levels: Option<Vec<CodexReasoningLevelItem>>,
118    #[serde(default)]
119    default_reasoning_level: Option<String>,
120    #[serde(default)]
121    multi_agent_version: Option<String>,
122}
123
124#[derive(Debug, Deserialize)]
125struct CodexReasoningLevelItem {
126    effort: String,
127}
128
129// ─── Picker eligibility ───────────────────────────────────────────────────────
130
131/// True when a Codex catalog row should appear as a selectable model.
132///
133/// Mirrors the official Codex picker rule: picker eligibility is determined by
134/// list visibility. `supported_in_api` describes API-key support and therefore
135/// must not filter the ChatGPT OAuth catalog.
136pub fn codex_model_is_selectable(
137    visibility: Option<&str>,
138    _supported_in_api: Option<bool>,
139) -> bool {
140    match visibility.map(str::trim).filter(|v| !v.is_empty()) {
141        None => true,
142        Some("list") => true,
143        Some(other) => {
144            // Anything other than the known list-visible token is hidden.
145            // Official Codex uses "list" for picker rows and "hide" for internal.
146            other.eq_ignore_ascii_case("list")
147        }
148    }
149}
150
151// ─── Reasoning level parsing ──────────────────────────────────────────────────
152
153/// Parse an ordered list of `CodexReasoningLevelItem` into `ReasoningLevel`
154/// values, silently discarding unknown strings.
155///
156/// Unknown effort strings are ignored, never silently coerced to a known level.
157fn parse_reasoning_levels(items: &[CodexReasoningLevelItem]) -> Vec<ReasoningLevel> {
158    items
159        .iter()
160        .filter_map(|item| ReasoningLevel::parse(&item.effort))
161        .collect()
162}
163
164/// Parse a `default_reasoning_level` string, returning `None` for unknown values.
165fn parse_default_level(s: Option<&str>) -> Option<ReasoningLevel> {
166    s.and_then(ReasoningLevel::parse)
167}
168
169fn parse_multi_agent_version(s: Option<&str>) -> Option<CodexMultiAgentVersion> {
170    s.map(|version| match version {
171        "v1" => CodexMultiAgentVersion::V1,
172        "v2" => CodexMultiAgentVersion::V2,
173        _ => CodexMultiAgentVersion::Unknown,
174    })
175}
176
177/// Build a `ReasoningSupport` for a parsed Codex model item.
178/// Falls back to static capability data when the live response omits the fields.
179fn codex_reasoning_support(item: &CodexModelItem) -> ReasoningSupport {
180    let default_level = parse_default_level(item.default_reasoning_level.as_deref());
181    let multi_agent_version = parse_multi_agent_version(item.multi_agent_version.as_deref());
182
183    if let Some(items) = item.supported_reasoning_levels.as_deref() {
184        return ReasoningSupport::CodexNamed {
185            supported: parse_reasoning_levels(items),
186            default_level,
187            multi_agent_version,
188        };
189    }
190    // A live row may omit reasoning levels, so retain the existing static
191    // level fallback. Never backfill the live row's collaboration version:
192    // missing live V2 evidence must not authorize Ultra.
193    match codex_static_capability(&item.slug) {
194        Some(ReasoningSupport::CodexNamed {
195            supported,
196            default_level: static_default,
197            ..
198        }) => ReasoningSupport::CodexNamed {
199            supported,
200            default_level: default_level.or(static_default),
201            multi_agent_version,
202        },
203        Some(other) => other,
204        None => ReasoningSupport::Unknown,
205    }
206}
207
208// ─── Parser ───────────────────────────────────────────────────────────────────
209
210/// Parse the ChatGPT backend-api models response into normalized catalog rows.
211///
212/// Response shape: `{ "models": [ { "slug", "display_name", "visibility",
213/// "supported_in_api", "context_window",
214/// "supported_reasoning_levels": [{"effort": "..."}],
215/// "default_reasoning_level": "...", ... }, ... ] }`.
216pub fn parse_codex_catalog_models(body: &str) -> Result<Vec<CatalogModel>, serde_json::Error> {
217    let resp: CodexModelsResponse = serde_json::from_str(body)?;
218    let models: Vec<CatalogModel> = resp
219        .models
220        .into_iter()
221        .filter(|item| codex_model_is_selectable(item.visibility.as_deref(), item.supported_in_api))
222        .filter_map(|item| {
223            let reasoning = codex_reasoning_support(&item);
224            let mut m = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, item.slug)?;
225            m.provider_kind = CatalogProviderKind::OpenAiCodex;
226            m.label = item.display_name.filter(|name| !name.trim().is_empty());
227            m.context_tokens = item.context_window;
228            m.reasoning = reasoning;
229            m.source = CatalogSource::Live;
230            Some(m)
231        })
232        .collect();
233    Ok(models)
234}
235
236// ─── Typed request planning ───────────────────────────────────────────────────
237
238/// Reasoning values accepted by the Codex Responses request wire.
239/// Logical Ultra is deliberately absent and must be lowered by the planner.
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub enum CodexWireEffort {
242    Low,
243    Medium,
244    High,
245    XHigh,
246    Max,
247}
248
249impl CodexWireEffort {
250    pub fn as_str(self) -> &'static str {
251        match self {
252            Self::Low => "low",
253            Self::Medium => "medium",
254            Self::High => "high",
255            Self::XHigh => "xhigh",
256            Self::Max => "max",
257        }
258    }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum CodexExecutionMode {
263    Standard,
264    Max,
265    Ultra,
266}
267
268impl CodexExecutionMode {
269    pub fn as_str(self) -> &'static str {
270        match self {
271            Self::Standard => "standard",
272            Self::Max => "max",
273            Self::Ultra => "ultra",
274        }
275    }
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub enum CodexMultiAgentMode {
280    ExplicitRequestOnly,
281    Proactive,
282}
283
284impl CodexMultiAgentMode {
285    pub fn as_str(self) -> &'static str {
286        match self {
287            Self::ExplicitRequestOnly => "explicit_request_only",
288            Self::Proactive => "proactive",
289        }
290    }
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
294pub enum ExecutionRole {
295    #[default]
296    Foreground,
297    Worker,
298    Internal,
299}
300
301impl ExecutionRole {
302    pub fn as_str(self) -> &'static str {
303        match self {
304            Self::Foreground => "foreground",
305            Self::Worker => "worker",
306            Self::Internal => "internal",
307        }
308    }
309}
310
311/// Backward-compatible name; request role semantics are provider-neutral.
312pub type CodexRequestRole = ExecutionRole;
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub enum CodexCapabilitySource {
316    Live,
317    Static,
318}
319
320impl CodexCapabilitySource {
321    pub fn as_str(self) -> &'static str {
322        match self {
323            Self::Live => "live",
324            Self::Static => "static",
325        }
326    }
327}
328
329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub enum CodexPlanErrorCode {
331    InvalidProviderIdentity,
332    CapabilityMetadataMissing,
333    UnsupportedReasoningLevel,
334    UltraRequiresMultiAgentV2,
335}
336
337impl CodexPlanErrorCode {
338    pub fn as_str(self) -> &'static str {
339        match self {
340            Self::InvalidProviderIdentity => "invalid_provider_identity",
341            Self::CapabilityMetadataMissing => "capability_metadata_missing",
342            Self::UnsupportedReasoningLevel => "unsupported_reasoning_level",
343            Self::UltraRequiresMultiAgentV2 => "ultra_requires_multi_agent_v2",
344        }
345    }
346}
347
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct CodexPlanError {
350    code: CodexPlanErrorCode,
351    message: String,
352}
353
354impl CodexPlanError {
355    fn new(code: CodexPlanErrorCode, message: impl Into<String>) -> Self {
356        Self {
357            code,
358            message: message.into(),
359        }
360    }
361
362    pub fn code(&self) -> CodexPlanErrorCode {
363        self.code
364    }
365}
366
367impl std::fmt::Display for CodexPlanError {
368    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369        formatter.write_str(&self.message)
370    }
371}
372
373impl std::error::Error for CodexPlanError {}
374
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct CodexExecutionPlan {
377    pub qualified_model: String,
378    pub selected_level: ReasoningLevel,
379    pub mode: CodexExecutionMode,
380    pub wire_effort: Option<CodexWireEffort>,
381    pub multi_agent_version: Option<CodexMultiAgentVersion>,
382    pub multi_agent_mode: Option<CodexMultiAgentMode>,
383    pub request_role: CodexRequestRole,
384    pub capability_source: Option<CodexCapabilitySource>,
385}
386
387impl CodexExecutionPlan {
388    pub fn automatic_delegation(&self) -> bool {
389        self.multi_agent_mode == Some(CodexMultiAgentMode::Proactive)
390    }
391
392    pub fn wire_effort_label(&self) -> &'static str {
393        self.wire_effort.map_or("omitted", CodexWireEffort::as_str)
394    }
395
396    pub fn multi_agent_version_label(&self) -> &'static str {
397        self.multi_agent_version
398            .map_or("missing", CodexMultiAgentVersion::as_str)
399    }
400
401    pub fn multi_agent_mode_label(&self) -> &'static str {
402        self.multi_agent_mode
403            .map_or("disabled", CodexMultiAgentMode::as_str)
404    }
405}
406
407#[derive(Debug, Clone)]
408struct CodexCapabilitySnapshot {
409    supported: Vec<ReasoningLevel>,
410    multi_agent_version: Option<CodexMultiAgentVersion>,
411    source: CodexCapabilitySource,
412}
413
414fn snapshot_from_model(
415    model: &CatalogModel,
416    source: CodexCapabilitySource,
417) -> Option<CodexCapabilitySnapshot> {
418    match &model.reasoning {
419        ReasoningSupport::CodexNamed {
420            supported,
421            multi_agent_version,
422            ..
423        } => Some(CodexCapabilitySnapshot {
424            supported: supported.clone(),
425            multi_agent_version: *multi_agent_version,
426            source,
427        }),
428        _ => None,
429    }
430}
431
432fn authoritative_source(
433    model: &CatalogModel,
434    qualified_model: &str,
435) -> Result<CodexCapabilitySource, CodexPlanError> {
436    match model.source {
437        CatalogSource::Live => Ok(CodexCapabilitySource::Live),
438        CatalogSource::StaticFallback => Ok(CodexCapabilitySource::Static),
439        CatalogSource::StaticWithLive | CatalogSource::Inferred => Err(CodexPlanError::new(
440            CodexPlanErrorCode::CapabilityMetadataMissing,
441            format!(
442                "authoritative exact-model capability metadata is unavailable for {qualified_model}"
443            ),
444        )),
445    }
446}
447
448fn authoritative_capability(
449    qualified_model: &str,
450    model_id: &str,
451    catalog_model: Option<&CatalogModel>,
452) -> Result<Option<CodexCapabilitySnapshot>, CodexPlanError> {
453    if let Some(model) = catalog_model {
454        if model.runtime_id() != qualified_model
455            || model.provider_kind != CatalogProviderKind::OpenAiCodex
456        {
457            return Err(CodexPlanError::new(
458                CodexPlanErrorCode::InvalidProviderIdentity,
459                format!("catalog identity does not match exact Codex model {qualified_model}"),
460            ));
461        }
462        let source = authoritative_source(model, qualified_model)?;
463        return Ok(snapshot_from_model(model, source));
464    }
465
466    // A present live row is authoritative even when it lacks usable Codex
467    // metadata. Never mix that absence with a static V2 fallback.
468    if let Some(model) = super::capability_cache::get(qualified_model) {
469        if model.runtime_id() != qualified_model
470            || model.provider_kind != CatalogProviderKind::OpenAiCodex
471        {
472            return Err(CodexPlanError::new(
473                CodexPlanErrorCode::InvalidProviderIdentity,
474                format!(
475                    "cached catalog identity does not match exact Codex model {qualified_model}"
476                ),
477            ));
478        }
479        let source = authoritative_source(&model, qualified_model)?;
480        return Ok(snapshot_from_model(&model, source));
481    }
482
483    Ok(match codex_static_capability(model_id) {
484        Some(ReasoningSupport::CodexNamed {
485            supported,
486            multi_agent_version,
487            ..
488        }) => Some(CodexCapabilitySnapshot {
489            supported,
490            multi_agent_version,
491            source: CodexCapabilitySource::Static,
492        }),
493        _ => None,
494    })
495}
496
497/// Derive the exact Codex request contract from provider-qualified identity,
498/// logical selection, authoritative capability metadata, and runtime role.
499pub fn plan_codex_execution(
500    qualified_model: &str,
501    selected_level: ReasoningLevel,
502    request_role: CodexRequestRole,
503    catalog_model: Option<&CatalogModel>,
504) -> Result<CodexExecutionPlan, CodexPlanError> {
505    let Some(model_id) = qualified_model.strip_prefix("openai-codex/") else {
506        return Err(CodexPlanError::new(
507            CodexPlanErrorCode::InvalidProviderIdentity,
508            format!("{qualified_model} is not an exact openai-codex identity"),
509        ));
510    };
511    if model_id.is_empty() || model_id.contains('/') {
512        return Err(CodexPlanError::new(
513            CodexPlanErrorCode::InvalidProviderIdentity,
514            format!("{qualified_model} is not an exact openai-codex identity"),
515        ));
516    }
517
518    let capability = authoritative_capability(qualified_model, model_id, catalog_model)?;
519    if !matches!(
520        selected_level,
521        ReasoningLevel::Off | ReasoningLevel::Adaptive
522    ) {
523        let Some(capability) = capability.as_ref() else {
524            return Err(CodexPlanError::new(
525                CodexPlanErrorCode::CapabilityMetadataMissing,
526                format!(
527                    "no capability metadata for {qualified_model}; cannot authorize level '{selected_level}'"
528                ),
529            ));
530        };
531        if !capability.supported.contains(&selected_level) {
532            return Err(CodexPlanError::new(
533                CodexPlanErrorCode::UnsupportedReasoningLevel,
534                format!(
535                    "reasoning level '{selected_level}' is not supported by {qualified_model}; supported: [{}]",
536                    capability
537                        .supported
538                        .iter()
539                        .map(|level| level.as_str())
540                        .collect::<Vec<_>>()
541                        .join(", ")
542                ),
543            ));
544        }
545    }
546
547    let multi_agent_version = capability
548        .as_ref()
549        .and_then(|capability| capability.multi_agent_version);
550    if selected_level == ReasoningLevel::Ultra
551        && multi_agent_version != Some(CodexMultiAgentVersion::V2)
552    {
553        return Err(CodexPlanError::new(
554            CodexPlanErrorCode::UltraRequiresMultiAgentV2,
555            format!(
556                "reasoning level 'ultra' requires multi-agent v2 capability for {qualified_model}"
557            ),
558        ));
559    }
560
561    let mode = match selected_level {
562        ReasoningLevel::Max => CodexExecutionMode::Max,
563        ReasoningLevel::Ultra => CodexExecutionMode::Ultra,
564        _ => CodexExecutionMode::Standard,
565    };
566    let wire_effort = match selected_level {
567        ReasoningLevel::Off | ReasoningLevel::Adaptive => None,
568        ReasoningLevel::Low => Some(CodexWireEffort::Low),
569        ReasoningLevel::Medium => Some(CodexWireEffort::Medium),
570        ReasoningLevel::High => Some(CodexWireEffort::High),
571        ReasoningLevel::XHigh => Some(CodexWireEffort::XHigh),
572        ReasoningLevel::Max | ReasoningLevel::Ultra => Some(CodexWireEffort::Max),
573        // Anthropic-only logical mode; Codex validation rejects it before lowering.
574        ReasoningLevel::UltraCode => None,
575    };
576    let multi_agent_mode = if request_role == CodexRequestRole::Foreground
577        && multi_agent_version == Some(CodexMultiAgentVersion::V2)
578    {
579        Some(if selected_level == ReasoningLevel::Ultra {
580            CodexMultiAgentMode::Proactive
581        } else {
582            CodexMultiAgentMode::ExplicitRequestOnly
583        })
584    } else {
585        None
586    };
587
588    Ok(CodexExecutionPlan {
589        qualified_model: qualified_model.to_string(),
590        selected_level,
591        mode,
592        wire_effort,
593        multi_agent_version,
594        multi_agent_mode,
595        request_role,
596        capability_source: capability.map(|capability| capability.source),
597    })
598}
599
600// ─── Capability validation helper ─────────────────────────────────────────────
601
602/// Validate that the given `level` is supported by the Codex model identified
603/// by `model_id`. Uses the provided catalog when available, else falls back to
604/// the static table.
605///
606/// Returns `Ok(())` if supported, `Err(message)` if not.
607/// Always fails for providers without authoritative Codex metadata.
608pub fn validate_codex_level(
609    model_id: &str,
610    level: ReasoningLevel,
611    catalog_model: Option<&CatalogModel>,
612) -> Result<(), String> {
613    let qualified = format!("openai-codex/{model_id}");
614    plan_codex_execution(
615        &qualified,
616        level,
617        CodexRequestRole::Foreground,
618        catalog_model,
619    )
620    .map(|_| ())
621    .map_err(|error| error.to_string())
622}
623
624// ─── Tests ───────────────────────────────────────────────────────────────────
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    const FIXTURE: &str = include_str!("fixtures/openai_codex_models.json");
631
632    #[test]
633    fn parse_fixture_matches_list_visible_chatgpt_picker_models() {
634        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
635        let ids: Vec<_> = models.iter().map(|m| m.id.as_str()).collect();
636        assert_eq!(
637            ids,
638            vec![
639                "gpt-5.6-sol",
640                "gpt-5.6-terra",
641                "gpt-5.6-luna",
642                "gpt-5.5",
643                "gpt-5.4",
644                "gpt-5.4-mini",
645                "gpt-5.3-codex-spark",
646            ]
647        );
648        // `supported_in_api` describes API-key availability, not ChatGPT OAuth
649        // picker eligibility. Spark is list-visible despite this being false.
650        assert!(ids.contains(&"gpt-5.3-codex-spark"));
651        // Non-list-visible backend rows are not user-selectable.
652        assert!(!ids.contains(&"codex-auto-review"));
653        assert!(!ids.contains(&"codex-internal-eval"));
654        assert!(models
655            .iter()
656            .all(|m| m.runtime_id().starts_with("openai-codex/")));
657        assert!(models.iter().all(|m| m.source == CatalogSource::Live));
658        assert!(models
659            .iter()
660            .all(|m| m.provider_kind == CatalogProviderKind::OpenAiCodex));
661    }
662
663    #[test]
664    fn parse_fixture_reads_display_name_and_context_window() {
665        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
666        let gpt55 = models.iter().find(|m| m.id == "gpt-5.5").unwrap();
667        assert_eq!(gpt55.label.as_deref(), Some("GPT-5.5"));
668        assert_eq!(gpt55.context_tokens, Some(272_000));
669    }
670
671    #[test]
672    fn execution_plan_fixture_preserves_multi_agent_versions() {
673        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
674        let version = |id: &str| {
675            models
676                .iter()
677                .find(|model| model.id == id)
678                .and_then(CatalogModel::codex_multi_agent_version)
679        };
680        assert_eq!(version("gpt-5.6-sol"), Some(CodexMultiAgentVersion::V2));
681        assert_eq!(version("gpt-5.6-terra"), Some(CodexMultiAgentVersion::V2));
682        assert_eq!(version("gpt-5.6-luna"), Some(CodexMultiAgentVersion::V1));
683        assert_eq!(version("gpt-5.5"), None);
684    }
685
686    #[test]
687    fn execution_plan_maps_max_ultra_and_xhigh_exactly() {
688        let max = plan_codex_execution(
689            "openai-codex/gpt-5.6-sol",
690            ReasoningLevel::Max,
691            CodexRequestRole::Foreground,
692            None,
693        )
694        .expect("Sol Max plan");
695        assert_eq!(max.mode, CodexExecutionMode::Max);
696        assert_eq!(max.wire_effort, Some(CodexWireEffort::Max));
697        assert_eq!(
698            max.multi_agent_mode,
699            Some(CodexMultiAgentMode::ExplicitRequestOnly)
700        );
701
702        let ultra = plan_codex_execution(
703            "openai-codex/gpt-5.6-sol",
704            ReasoningLevel::Ultra,
705            CodexRequestRole::Foreground,
706            None,
707        )
708        .expect("Sol Ultra plan");
709        assert_eq!(ultra.mode, CodexExecutionMode::Ultra);
710        assert_eq!(ultra.wire_effort, Some(CodexWireEffort::Max));
711        assert_eq!(ultra.multi_agent_mode, Some(CodexMultiAgentMode::Proactive));
712
713        let xhigh = plan_codex_execution(
714            "openai-codex/gpt-5.6-sol",
715            ReasoningLevel::XHigh,
716            CodexRequestRole::Foreground,
717            None,
718        )
719        .expect("Sol XHigh plan");
720        assert_eq!(xhigh.mode, CodexExecutionMode::Standard);
721        assert_eq!(xhigh.wire_effort, Some(CodexWireEffort::XHigh));
722        assert_eq!(
723            xhigh.multi_agent_mode,
724            Some(CodexMultiAgentMode::ExplicitRequestOnly)
725        );
726    }
727
728    #[test]
729    fn execution_plan_luna_max_uses_max_without_v2_context() {
730        let plan = plan_codex_execution(
731            "openai-codex/gpt-5.6-luna",
732            ReasoningLevel::Max,
733            CodexRequestRole::Foreground,
734            None,
735        )
736        .expect("Luna Max plan");
737        assert_eq!(plan.wire_effort, Some(CodexWireEffort::Max));
738        assert_eq!(plan.multi_agent_version, Some(CodexMultiAgentVersion::V1));
739        assert_eq!(plan.multi_agent_mode, None);
740    }
741
742    #[test]
743    fn execution_plan_worker_ultra_never_enables_proactive_mode() {
744        let plan = plan_codex_execution(
745            "openai-codex/gpt-5.6-sol",
746            ReasoningLevel::Ultra,
747            CodexRequestRole::Worker,
748            None,
749        )
750        .expect("worker Ultra plan");
751        assert_eq!(plan.wire_effort, Some(CodexWireEffort::Max));
752        assert_eq!(plan.multi_agent_mode, None);
753        assert!(!plan.automatic_delegation());
754    }
755
756    #[test]
757    fn execution_plan_ultra_requires_live_v2_without_static_backfill() {
758        for (slug, version_field) in [
759            ("gpt-ultra-live-missing", ""),
760            ("gpt-ultra-live-v1", r#", "multi_agent_version": "v1""#),
761            ("gpt-ultra-live-unknown", r#", "multi_agent_version": "v3""#),
762        ] {
763            let body = format!(
764                r#"{{"models":[{{
765                    "slug":"{slug}",
766                    "visibility":"list",
767                    "supported_reasoning_levels":[{{"effort":"max"}},{{"effort":"ultra"}}]
768                    {version_field}
769                }}]}}"#
770            );
771            let models = parse_codex_catalog_models(&body).expect("parse live row");
772            let error = plan_codex_execution(
773                &format!("openai-codex/{slug}"),
774                ReasoningLevel::Ultra,
775                CodexRequestRole::Foreground,
776                Some(&models[0]),
777            )
778            .expect_err("live missing/v1/unknown version must deny Ultra");
779            assert_eq!(error.code(), CodexPlanErrorCode::UltraRequiresMultiAgentV2);
780        }
781    }
782
783    #[test]
784    fn execution_plan_rejects_wrong_provider_identity() {
785        let error = plan_codex_execution(
786            "openrouter/gpt-5.6-sol",
787            ReasoningLevel::Ultra,
788            CodexRequestRole::Foreground,
789            None,
790        )
791        .expect_err("wrong provider must fail closed");
792        assert_eq!(error.code(), CodexPlanErrorCode::InvalidProviderIdentity);
793    }
794
795    #[test]
796    fn execution_plan_rejects_wrong_provider_kind_in_cache() {
797        let id = "gpt-cached-provider-kind-spoof";
798        let qualified = format!("openai-codex/{id}");
799        let mut spoofed = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, id).unwrap();
800        spoofed.reasoning = ReasoningSupport::CodexNamed {
801            supported: vec![ReasoningLevel::Max, ReasoningLevel::Ultra],
802            default_level: Some(ReasoningLevel::Max),
803            multi_agent_version: Some(CodexMultiAgentVersion::V2),
804        };
805        super::super::capability_cache::insert(spoofed);
806
807        let error = plan_codex_execution(
808            &qualified,
809            ReasoningLevel::Ultra,
810            CodexRequestRole::Foreground,
811            None,
812        )
813        .expect_err("cache rows must preserve the exact Codex provider kind");
814        assert_eq!(error.code(), CodexPlanErrorCode::InvalidProviderIdentity);
815    }
816
817    #[test]
818    fn execution_plan_rejects_inferred_capability_provenance() {
819        fn inferred_model(id: &str) -> CatalogModel {
820            let mut model = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, id).unwrap();
821            model.provider_kind = CatalogProviderKind::OpenAiCodex;
822            model.source = CatalogSource::Inferred;
823            model.reasoning = ReasoningSupport::CodexNamed {
824                supported: vec![ReasoningLevel::Max, ReasoningLevel::Ultra],
825                default_level: Some(ReasoningLevel::Max),
826                multi_agent_version: Some(CodexMultiAgentVersion::V2),
827            };
828            model
829        }
830
831        let explicit_id = "gpt-inferred-explicit-provenance";
832        let explicit = inferred_model(explicit_id);
833        let explicit_error = plan_codex_execution(
834            &format!("openai-codex/{explicit_id}"),
835            ReasoningLevel::Ultra,
836            CodexRequestRole::Foreground,
837            Some(&explicit),
838        )
839        .expect_err("inferred explicit metadata must never authorize Ultra");
840        assert_eq!(
841            explicit_error.code(),
842            CodexPlanErrorCode::CapabilityMetadataMissing
843        );
844
845        let cached_id = "gpt-inferred-cached-provenance";
846        super::super::capability_cache::insert(inferred_model(cached_id));
847        let cached_error = plan_codex_execution(
848            &format!("openai-codex/{cached_id}"),
849            ReasoningLevel::Ultra,
850            CodexRequestRole::Foreground,
851            None,
852        )
853        .expect_err("inferred cached metadata must never authorize Ultra");
854        assert_eq!(
855            cached_error.code(),
856            CodexPlanErrorCode::CapabilityMetadataMissing
857        );
858    }
859
860    // ── Reasoning level parsing from fixture ──────────────────────────────────
861
862    #[test]
863    fn parse_fixture_sol_has_ultra() {
864        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
865        let sol = models.iter().find(|m| m.id == "gpt-5.6-sol").unwrap();
866        assert!(
867            sol.codex_supports_level(ReasoningLevel::Ultra),
868            "sol must support ultra"
869        );
870        assert!(sol.codex_supports_level(ReasoningLevel::Max));
871        assert!(sol.codex_supports_level(ReasoningLevel::XHigh));
872    }
873
874    #[test]
875    fn parse_fixture_terra_has_ultra() {
876        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
877        let terra = models.iter().find(|m| m.id == "gpt-5.6-terra").unwrap();
878        assert!(terra.codex_supports_level(ReasoningLevel::Ultra));
879        assert!(terra.codex_supports_level(ReasoningLevel::Max));
880    }
881
882    #[test]
883    fn parse_fixture_luna_has_max_not_ultra() {
884        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
885        let luna = models.iter().find(|m| m.id == "gpt-5.6-luna").unwrap();
886        assert!(luna.codex_supports_level(ReasoningLevel::Max));
887        assert!(
888            !luna.codex_supports_level(ReasoningLevel::Ultra),
889            "luna must NOT support ultra"
890        );
891    }
892
893    #[test]
894    fn parse_fixture_gpt55_has_xhigh_not_max_or_ultra() {
895        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
896        let m = models.iter().find(|m| m.id == "gpt-5.5").unwrap();
897        assert!(m.codex_supports_level(ReasoningLevel::XHigh));
898        assert!(
899            !m.codex_supports_level(ReasoningLevel::Max),
900            "gpt-5.5 must NOT support max"
901        );
902        assert!(
903            !m.codex_supports_level(ReasoningLevel::Ultra),
904            "gpt-5.5 must NOT support ultra"
905        );
906    }
907
908    #[test]
909    fn parse_fixture_spark_has_xhigh_not_max_or_ultra() {
910        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
911        let spark = models
912            .iter()
913            .find(|m| m.id == "gpt-5.3-codex-spark")
914            .unwrap();
915        assert!(spark.codex_supports_level(ReasoningLevel::XHigh));
916        assert!(!spark.codex_supports_level(ReasoningLevel::Max));
917        assert!(!spark.codex_supports_level(ReasoningLevel::Ultra));
918    }
919
920    #[test]
921    fn parse_fixture_default_levels_match_observed_cache() {
922        let models = parse_codex_catalog_models(FIXTURE).expect("parse fixture");
923        let get = |id: &str| {
924            models
925                .iter()
926                .find(|m| m.id == id)
927                .unwrap_or_else(|| panic!("missing {id}"))
928        };
929        let default_of = |id: &str| match &get(id).reasoning {
930            ReasoningSupport::CodexNamed { default_level, .. } => *default_level,
931            other => panic!("{id}: expected CodexNamed, got {other:?}"),
932        };
933        assert_eq!(default_of("gpt-5.6-sol"), Some(ReasoningLevel::Low), "sol");
934        assert_eq!(
935            default_of("gpt-5.6-terra"),
936            Some(ReasoningLevel::Medium),
937            "terra"
938        );
939        assert_eq!(
940            default_of("gpt-5.6-luna"),
941            Some(ReasoningLevel::Medium),
942            "luna"
943        );
944        assert_eq!(default_of("gpt-5.5"), Some(ReasoningLevel::Medium), "5.5");
945        assert_eq!(default_of("gpt-5.4"), Some(ReasoningLevel::Medium), "5.4");
946        assert_eq!(
947            default_of("gpt-5.4-mini"),
948            Some(ReasoningLevel::Medium),
949            "5.4-mini"
950        );
951        assert_eq!(
952            default_of("gpt-5.3-codex-spark"),
953            Some(ReasoningLevel::High),
954            "spark"
955        );
956    }
957
958    #[test]
959    fn unknown_effort_strings_are_ignored_not_coerced() {
960        let body = r#"{"models":[{
961            "slug": "gpt-test",
962            "visibility": "list",
963            "supported_reasoning_levels": [
964                {"effort": "low"},
965                {"effort": "hyper-ultra-experimental"},
966                {"effort": "high"}
967            ]
968        }]}"#;
969        let models = parse_codex_catalog_models(body).unwrap();
970        let m = &models[0];
971        // "hyper-ultra-experimental" is silently dropped; only low and high survive.
972        assert!(m.codex_supports_level(ReasoningLevel::Low));
973        assert!(m.codex_supports_level(ReasoningLevel::High));
974        assert!(!m.codex_supports_level(ReasoningLevel::Ultra));
975        assert!(!m.codex_supports_level(ReasoningLevel::Max));
976    }
977
978    #[test]
979    fn live_known_model_with_only_unknown_efforts_does_not_borrow_static_ultra() {
980        let item: CodexModelItem = serde_json::from_str(
981            r#"{
982            "slug": "gpt-5.6-sol",
983            "visibility": "list",
984            "supported_reasoning_levels": [{"effort": "future-effort"}],
985            "multi_agent_version": "v2"
986        }"#,
987        )
988        .expect("parse live Sol row");
989        let mut model = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, &item.slug).unwrap();
990        model.provider_kind = CatalogProviderKind::OpenAiCodex;
991        model.source = CatalogSource::Live;
992        model.reasoning = codex_reasoning_support(&item);
993
994        let error = plan_codex_execution(
995            "openai-codex/gpt-5.6-sol",
996            ReasoningLevel::Ultra,
997            CodexRequestRole::Foreground,
998            Some(&model),
999        )
1000        .expect_err("an explicit unknown live effort list must fail closed");
1001        assert_eq!(error.code(), CodexPlanErrorCode::UnsupportedReasoningLevel);
1002    }
1003
1004    // ── Static capability table ───────────────────────────────────────────────
1005
1006    #[test]
1007    fn static_sol_has_ultra_and_default_low() {
1008        let cap = codex_static_capability("gpt-5.6-sol").expect("sol");
1009        match cap {
1010            ReasoningSupport::CodexNamed {
1011                supported,
1012                default_level,
1013                ..
1014            } => {
1015                assert!(
1016                    supported.contains(&ReasoningLevel::Ultra),
1017                    "sol needs ultra"
1018                );
1019                assert!(supported.contains(&ReasoningLevel::Max), "sol needs max");
1020                assert_eq!(
1021                    default_level,
1022                    Some(ReasoningLevel::Low),
1023                    "sol default is Low"
1024                );
1025            }
1026            _ => panic!("expected CodexNamed"),
1027        }
1028    }
1029
1030    #[test]
1031    fn static_terra_has_ultra_and_default_medium() {
1032        let cap = codex_static_capability("gpt-5.6-terra").expect("terra");
1033        match cap {
1034            ReasoningSupport::CodexNamed {
1035                supported,
1036                default_level,
1037                ..
1038            } => {
1039                assert!(
1040                    supported.contains(&ReasoningLevel::Ultra),
1041                    "terra needs ultra"
1042                );
1043                assert!(supported.contains(&ReasoningLevel::Max), "terra needs max");
1044                assert_eq!(
1045                    default_level,
1046                    Some(ReasoningLevel::Medium),
1047                    "terra default is Medium"
1048                );
1049            }
1050            _ => panic!("expected CodexNamed"),
1051        }
1052    }
1053
1054    #[test]
1055    fn static_luna_has_max_not_ultra_and_default_medium() {
1056        let cap = codex_static_capability("gpt-5.6-luna").unwrap();
1057        match cap {
1058            ReasoningSupport::CodexNamed {
1059                supported,
1060                default_level,
1061                ..
1062            } => {
1063                assert!(supported.contains(&ReasoningLevel::Max));
1064                assert!(!supported.contains(&ReasoningLevel::Ultra));
1065                assert_eq!(
1066                    default_level,
1067                    Some(ReasoningLevel::Medium),
1068                    "luna default is Medium"
1069                );
1070            }
1071            _ => panic!(),
1072        }
1073    }
1074
1075    #[test]
1076    fn static_gpt55_family_has_xhigh_not_max_ultra_default_medium() {
1077        for id in ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"] {
1078            let cap = codex_static_capability(id).expect(id);
1079            match cap {
1080                ReasoningSupport::CodexNamed {
1081                    supported,
1082                    default_level,
1083                    ..
1084                } => {
1085                    assert!(
1086                        supported.contains(&ReasoningLevel::XHigh),
1087                        "{id} needs xhigh"
1088                    );
1089                    assert!(
1090                        !supported.contains(&ReasoningLevel::Max),
1091                        "{id} must NOT have max"
1092                    );
1093                    assert!(
1094                        !supported.contains(&ReasoningLevel::Ultra),
1095                        "{id} must NOT have ultra"
1096                    );
1097                    assert_eq!(
1098                        default_level,
1099                        Some(ReasoningLevel::Medium),
1100                        "{id} default is Medium"
1101                    );
1102                }
1103                _ => panic!(),
1104            }
1105        }
1106    }
1107
1108    #[test]
1109    fn static_spark_default_high() {
1110        let cap = codex_static_capability("gpt-5.3-codex-spark").unwrap();
1111        match cap {
1112            ReasoningSupport::CodexNamed {
1113                supported,
1114                default_level,
1115                ..
1116            } => {
1117                assert!(supported.contains(&ReasoningLevel::XHigh));
1118                assert!(!supported.contains(&ReasoningLevel::Max));
1119                assert!(!supported.contains(&ReasoningLevel::Ultra));
1120                assert_eq!(
1121                    default_level,
1122                    Some(ReasoningLevel::High),
1123                    "spark default is High"
1124                );
1125            }
1126            _ => panic!(),
1127        }
1128    }
1129
1130    #[test]
1131    fn static_unknown_model_returns_none() {
1132        assert!(codex_static_capability("gpt-future-unknown").is_none());
1133        // Internal/hidden models are also not in the table.
1134        assert!(codex_static_capability("codex-auto-review").is_none());
1135    }
1136
1137    // ── validate_codex_level ──────────────────────────────────────────────────
1138
1139    #[test]
1140    fn validate_sol_ultra_ok() {
1141        assert!(validate_codex_level("gpt-5.6-sol", ReasoningLevel::Ultra, None).is_ok());
1142    }
1143
1144    #[test]
1145    fn validate_sol_client_omission_modes_ok() {
1146        assert!(validate_codex_level("gpt-5.6-sol", ReasoningLevel::Off, None).is_ok());
1147        assert!(validate_codex_level("gpt-5.6-sol", ReasoningLevel::Adaptive, None).is_ok());
1148    }
1149
1150    #[test]
1151    fn validate_luna_ultra_rejected() {
1152        let err = validate_codex_level("gpt-5.6-luna", ReasoningLevel::Ultra, None).unwrap_err();
1153        assert!(err.contains("ultra"), "error must name the rejected level");
1154        assert!(err.contains("gpt-5.6-luna"));
1155    }
1156
1157    #[test]
1158    fn validate_gpt55_max_rejected() {
1159        assert!(validate_codex_level("gpt-5.5", ReasoningLevel::Max, None).is_err());
1160        assert!(validate_codex_level("gpt-5.5", ReasoningLevel::Ultra, None).is_err());
1161    }
1162
1163    #[test]
1164    fn validate_unknown_model_rejected() {
1165        let err = validate_codex_level("gpt-future-x", ReasoningLevel::Max, None).unwrap_err();
1166        assert!(err.contains("no capability metadata"));
1167    }
1168
1169    #[test]
1170    fn validate_live_catalog_overrides_static() {
1171        // Build a live catalog model that supports ONLY low+medium (fewer than static).
1172        let mut live = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, "gpt-5.6-sol").unwrap();
1173        live.provider_kind = CatalogProviderKind::OpenAiCodex;
1174        live.reasoning = ReasoningSupport::CodexNamed {
1175            supported: vec![ReasoningLevel::Low, ReasoningLevel::Medium],
1176            default_level: Some(ReasoningLevel::Low),
1177            multi_agent_version: None,
1178        };
1179        // Ultra is in the static table but the live model says otherwise.
1180        let err =
1181            validate_codex_level("gpt-5.6-sol", ReasoningLevel::Ultra, Some(&live)).unwrap_err();
1182        assert!(err.contains("ultra"));
1183        // Low is accepted.
1184        assert!(validate_codex_level("gpt-5.6-sol", ReasoningLevel::Low, Some(&live)).is_ok());
1185    }
1186
1187    #[test]
1188    fn validate_live_ultra_requires_v2_even_when_listed() {
1189        let mut live = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, "gpt-ultra-no-v2").unwrap();
1190        live.provider_kind = CatalogProviderKind::OpenAiCodex;
1191        live.source = CatalogSource::Live;
1192        live.reasoning = ReasoningSupport::CodexNamed {
1193            supported: vec![ReasoningLevel::Max, ReasoningLevel::Ultra],
1194            default_level: Some(ReasoningLevel::Max),
1195            multi_agent_version: None,
1196        };
1197
1198        assert!(validate_codex_level("gpt-ultra-no-v2", ReasoningLevel::Max, Some(&live)).is_ok());
1199        let err = validate_codex_level("gpt-ultra-no-v2", ReasoningLevel::Ultra, Some(&live))
1200            .expect_err("Ultra must require exact v2 capability");
1201        assert!(err.contains("multi-agent v2"), "{err}");
1202    }
1203
1204    /// Verify that `validate_codex_level(..., None)` consults the process-local
1205    /// capability cache (gap 1): a narrower live entry must override the static
1206    /// table even when no explicit catalog_model argument is supplied.
1207    #[test]
1208    fn validate_cache_narrows_sol_rejects_ultra_without_catalog_arg() {
1209        // Use a unique model slug to avoid cross-test pollution from the shared cache.
1210        let unique_id = "gpt-5.6-sol-cache-test-ultra";
1211        let qualified = format!("openai-codex/{unique_id}");
1212
1213        // Insert a live cache entry that supports only Low+Medium (no Ultra).
1214        let mut live = CatalogModel::new(PROVIDER_KEY, PROVIDER_NAME, unique_id).unwrap();
1215        live.provider_kind = CatalogProviderKind::OpenAiCodex;
1216        live.source = CatalogSource::Live;
1217        live.reasoning = ReasoningSupport::CodexNamed {
1218            supported: vec![ReasoningLevel::Low, ReasoningLevel::Medium],
1219            default_level: Some(ReasoningLevel::Low),
1220            multi_agent_version: None,
1221        };
1222        super::super::capability_cache::insert(live);
1223
1224        // Confirm it's in the cache.
1225        let cached = super::super::capability_cache::get(&qualified)
1226            .expect("model must be in cache after insert");
1227        assert!(matches!(
1228            cached.reasoning,
1229            ReasoningSupport::CodexNamed { .. }
1230        ));
1231
1232        // validate_codex_level with catalog_model=None must use the cache and
1233        // reject Ultra (which the static sol table would have allowed).
1234        let err = validate_codex_level(unique_id, ReasoningLevel::Ultra, None)
1235            .expect_err("cache should narrow Ultra rejection");
1236        assert!(
1237            err.contains("ultra"),
1238            "error must name the rejected level; got: {err}"
1239        );
1240
1241        // Low is still accepted via the cache.
1242        assert!(
1243            validate_codex_level(unique_id, ReasoningLevel::Low, None).is_ok(),
1244            "Low must still pass via cache"
1245        );
1246    }
1247
1248    // ── Existing catalog tests ────────────────────────────────────────────────
1249
1250    #[test]
1251    fn models_path_includes_client_version_query() {
1252        assert_eq!(codex_models_path("0.6.0"), "/models?client_version=0.6.0");
1253        assert_eq!(
1254            codex_models_url("0.6.0"),
1255            "https://chatgpt.com/backend-api/models?client_version=0.6.0"
1256        );
1257    }
1258
1259    #[test]
1260    fn selectable_rules_match_codex_picker() {
1261        assert!(codex_model_is_selectable(Some("list"), Some(true)));
1262        assert!(codex_model_is_selectable(Some("list"), Some(false)));
1263        assert!(codex_model_is_selectable(None, Some(true)));
1264        assert!(codex_model_is_selectable(Some("list"), None));
1265        assert!(!codex_model_is_selectable(Some("hide"), Some(true)));
1266        assert!(!codex_model_is_selectable(Some("internal"), Some(false)));
1267        assert!(!codex_model_is_selectable(Some("hidden"), Some(true)));
1268    }
1269
1270    #[test]
1271    fn static_catalog_is_current_safe_chatgpt_oauth_set() {
1272        let models = codex_static_catalog_models();
1273        let ids: Vec<_> = models.iter().map(|model| model.id.as_str()).collect();
1274        assert_eq!(
1275            ids,
1276            vec![
1277                "gpt-5.6-sol",
1278                "gpt-5.6-terra",
1279                "gpt-5.6-luna",
1280                "gpt-5.5",
1281                "gpt-5.4",
1282                "gpt-5.4-mini",
1283                "gpt-5.3-codex-spark",
1284            ]
1285        );
1286        for api_only in ["gpt-5.6-sol-wm", "gpt-5.6-pro", "gpt-5.5-pro"] {
1287            assert!(!ids.contains(&api_only));
1288        }
1289        assert!(models
1290            .iter()
1291            .all(|m| m.source == CatalogSource::StaticFallback));
1292        assert!(models
1293            .iter()
1294            .all(|m| m.runtime_id().starts_with("openai-codex/")));
1295    }
1296
1297    #[test]
1298    fn static_catalog_has_codex_named_reasoning() {
1299        let models = codex_static_catalog_models();
1300        for m in &models {
1301            assert!(
1302                matches!(m.reasoning, ReasoningSupport::CodexNamed { .. }),
1303                "static model {} must have CodexNamed reasoning",
1304                m.id
1305            );
1306        }
1307    }
1308
1309    #[test]
1310    fn invalid_json_returns_error() {
1311        assert!(parse_codex_catalog_models("{not json}").is_err());
1312    }
1313
1314    #[test]
1315    fn missing_models_key_returns_error() {
1316        assert!(parse_codex_catalog_models(r#"{"data":[]}"#).is_err());
1317    }
1318}