1use super::*;
2use agent_core::reasoning::ReasoningLevel;
3use serde::Deserialize;
4
5pub const PROVIDER_KEY: &str = "openai-codex";
7pub const PROVIDER_NAME: &str = "OpenAI Codex";
9pub const MODELS_BASE_URL: &str = "https://chatgpt.com/backend-api";
11pub const MODELS_PATH: &str = "/models";
13
14pub 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
58pub 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
81pub 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
92pub fn codex_models_url(client_version: &str) -> String {
94 format!("{MODELS_BASE_URL}{}", codex_models_path(client_version))
95}
96
97#[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 #[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
129pub 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 other.eq_ignore_ascii_case("list")
147 }
148 }
149}
150
151fn parse_reasoning_levels(items: &[CodexReasoningLevelItem]) -> Vec<ReasoningLevel> {
158 items
159 .iter()
160 .filter_map(|item| ReasoningLevel::parse(&item.effort))
161 .collect()
162}
163
164fn 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
177fn 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 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
208pub 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#[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
311pub 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 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
497pub 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 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
600pub 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#[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 assert!(ids.contains(&"gpt-5.3-codex-spark"));
651 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 #[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 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 #[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 assert!(codex_static_capability("codex-auto-review").is_none());
1135 }
1136
1137 #[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 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 let err =
1181 validate_codex_level("gpt-5.6-sol", ReasoningLevel::Ultra, Some(&live)).unwrap_err();
1182 assert!(err.contains("ultra"));
1183 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 #[test]
1208 fn validate_cache_narrows_sol_rejects_ultra_without_catalog_arg() {
1209 let unique_id = "gpt-5.6-sol-cache-test-ultra";
1211 let qualified = format!("openai-codex/{unique_id}");
1212
1213 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 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 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 assert!(
1243 validate_codex_level(unique_id, ReasoningLevel::Low, None).is_ok(),
1244 "Low must still pass via cache"
1245 );
1246 }
1247
1248 #[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}