1use serde::Deserialize;
2use serde::Serialize;
3use serde_json::Value;
4
5use crate::codex_integration::CodexPatchMode;
6use crate::codex_patch_plan::{CodexPatchPlan, CodexSwitchOptions};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum CodexCapabilitySupport {
11 Unknown,
12 Supported,
13 Unsupported,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct CodexCapabilityDecision {
18 pub support: CodexCapabilitySupport,
19 pub reason: String,
20}
21
22impl CodexCapabilityDecision {
23 pub fn supported(reason: impl Into<String>) -> Self {
24 Self {
25 support: CodexCapabilitySupport::Supported,
26 reason: reason.into(),
27 }
28 }
29
30 pub fn unsupported(reason: impl Into<String>) -> Self {
31 Self {
32 support: CodexCapabilitySupport::Unsupported,
33 reason: reason.into(),
34 }
35 }
36
37 pub fn unknown(reason: impl Into<String>) -> Self {
38 Self {
39 support: CodexCapabilitySupport::Unknown,
40 reason: reason.into(),
41 }
42 }
43
44 pub fn is_supported(&self) -> bool {
45 self.support == CodexCapabilitySupport::Supported
46 }
47}
48
49impl Default for CodexCapabilityDecision {
50 fn default() -> Self {
51 Self::unknown("capability was not reported by this response")
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum CodexProviderIdentity {
58 HelperRelay,
59 OfficialOpenAi,
60}
61
62impl CodexProviderIdentity {
63 pub fn from_patch_mode(patch_mode: CodexPatchMode) -> Self {
64 let plan = CodexPatchPlan::for_switch_on(patch_mode, Default::default())
65 .expect("default Codex switch options must be valid for every patch mode");
66 Self::from_patch_provider_identity(plan.provider().identity())
67 }
68
69 fn from_patch_provider_identity(
70 identity: crate::codex_patch_plan::CodexPatchProviderIdentity,
71 ) -> Self {
72 match identity {
73 crate::codex_patch_plan::CodexPatchProviderIdentity::HelperRelay => Self::HelperRelay,
74 crate::codex_patch_plan::CodexPatchProviderIdentity::OfficialOpenAi => {
75 Self::OfficialOpenAi
76 }
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum CodexAuthShape {
84 None,
85 EmptyChatGptFacade,
86 CompleteChatGptLogin,
87}
88
89impl CodexAuthShape {
90 pub fn from_patch_mode(patch_mode: CodexPatchMode) -> Self {
91 let plan = CodexPatchPlan::for_switch_on(patch_mode, Default::default())
92 .expect("default Codex switch options must be valid for every patch mode");
93 Self::from_auth_patch_plan(plan.auth())
94 }
95
96 fn from_auth_patch_plan(plan: crate::codex_patch_plan::CodexAuthPatchPlan) -> Self {
97 match plan {
98 crate::codex_patch_plan::CodexAuthPatchPlan::RestoreOriginalIfHelperPatched => {
99 Self::None
100 }
101 crate::codex_patch_plan::CodexAuthPatchPlan::PatchImagegenFacade => {
102 Self::EmptyChatGptFacade
103 }
104 crate::codex_patch_plan::CodexAuthPatchPlan::PatchChatGptBridge => {
105 Self::CompleteChatGptLogin
106 }
107 }
108 }
109
110 pub fn allows_codex_backend_tools(self) -> bool {
111 matches!(self, Self::EmptyChatGptFacade | Self::CompleteChatGptLogin)
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum CodexModelCatalogShape {
118 Unknown,
119 CodexModels,
120 OpenAiDataList,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum CodexModelSelection {
126 NotRequested,
127 Selected,
128 Missing,
129 NotApplicable,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct CodexModelCapabilityProfile {
134 pub slug: String,
135 pub accepts_image_input: CodexCapabilityDecision,
136 pub supports_web_search: CodexCapabilityDecision,
137 pub supports_apply_patch: CodexCapabilityDecision,
138 pub supports_reasoning_summaries: CodexCapabilityDecision,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub struct CodexModelCatalogProfile {
143 pub shape: CodexModelCatalogShape,
144 pub selection: CodexModelSelection,
145 pub translation_required: bool,
146 pub selected_model: Option<CodexModelCapabilityProfile>,
147 pub reason: String,
148}
149
150impl CodexModelCatalogProfile {
151 pub fn unknown(reason: impl Into<String>) -> Self {
152 Self {
153 shape: CodexModelCatalogShape::Unknown,
154 selection: CodexModelSelection::NotApplicable,
155 translation_required: false,
156 selected_model: None,
157 reason: reason.into(),
158 }
159 }
160
161 pub fn from_models_response_json(value: &Value, selected_slug: Option<&str>) -> Self {
162 if value.get("models").is_some() {
163 return Self::from_codex_models_response(value, selected_slug);
164 }
165 if value.get("data").and_then(Value::as_array).is_some() {
166 return Self {
167 shape: CodexModelCatalogShape::OpenAiDataList,
168 selection: CodexModelSelection::NotApplicable,
169 translation_required: true,
170 selected_model: None,
171 reason: "OpenAI-style data list requires helper translation before Codex can use model metadata".to_string(),
172 };
173 }
174 Self::unknown("response does not look like a Codex or OpenAI models list")
175 }
176
177 fn from_codex_models_response(value: &Value, selected_slug: Option<&str>) -> Self {
178 let Some(models) = value.get("models").and_then(Value::as_array) else {
179 return Self::unknown("models field is present but is not an array");
180 };
181 let Some(selected_slug) = selected_slug else {
182 return Self {
183 shape: CodexModelCatalogShape::CodexModels,
184 selection: CodexModelSelection::NotRequested,
185 translation_required: false,
186 selected_model: None,
187 reason: "Codex model catalog was provided but no selected model was requested"
188 .to_string(),
189 };
190 };
191 let selected = models.iter().find(|model| {
192 model
193 .get("slug")
194 .and_then(Value::as_str)
195 .is_some_and(|slug| slug == selected_slug)
196 });
197 let Some(selected) = selected else {
198 return Self {
199 shape: CodexModelCatalogShape::CodexModels,
200 selection: CodexModelSelection::Missing,
201 translation_required: false,
202 selected_model: None,
203 reason: format!(
204 "selected model `{selected_slug}` is missing from the Codex catalog"
205 ),
206 };
207 };
208 let selected_model = CodexModelCapabilityProfile::from_codex_model_json(selected);
209 Self {
210 shape: CodexModelCatalogShape::CodexModels,
211 selection: CodexModelSelection::Selected,
212 translation_required: false,
213 selected_model: Some(selected_model),
214 reason: "selected model metadata came from a Codex models catalog".to_string(),
215 }
216 }
217
218 pub fn selected_image_input_support(&self) -> CodexCapabilityDecision {
219 self.selected_model
220 .as_ref()
221 .map(|model| model.accepts_image_input.clone())
222 .unwrap_or_else(|| CodexCapabilityDecision::unknown(self.reason.clone()))
223 }
224
225 pub fn selected_web_search_support(&self) -> CodexCapabilityDecision {
226 self.selected_model
227 .as_ref()
228 .map(|model| model.supports_web_search.clone())
229 .unwrap_or_else(|| CodexCapabilityDecision::unknown(self.reason.clone()))
230 }
231
232 pub fn selected_apply_patch_support(&self) -> CodexCapabilityDecision {
233 self.selected_model
234 .as_ref()
235 .map(|model| model.supports_apply_patch.clone())
236 .unwrap_or_else(|| CodexCapabilityDecision::unknown(self.reason.clone()))
237 }
238
239 pub fn selected_reasoning_summary_support(&self) -> CodexCapabilityDecision {
240 self.selected_model
241 .as_ref()
242 .map(|model| model.supports_reasoning_summaries.clone())
243 .unwrap_or_else(|| CodexCapabilityDecision::unknown(self.reason.clone()))
244 }
245}
246
247impl CodexModelCapabilityProfile {
248 pub fn from_codex_model_json(model: &Value) -> Self {
249 let slug = model
250 .get("slug")
251 .and_then(Value::as_str)
252 .unwrap_or("<unknown>")
253 .to_string();
254 Self {
255 slug: slug.clone(),
256 accepts_image_input: image_input_support(model),
257 supports_web_search: web_search_support(model),
258 supports_apply_patch: apply_patch_support(model),
259 supports_reasoning_summaries: reasoning_summary_support(model),
260 }
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265pub struct CodexCapabilityProfile {
266 pub patch_mode: CodexPatchMode,
267 pub provider_identity: CodexProviderIdentity,
268 pub auth_shape: CodexAuthShape,
269 pub provider_supports_websockets: bool,
270 #[serde(default)]
271 pub continuity: CodexContinuityCapabilityProfile,
272 pub model_catalog: CodexModelCatalogProfile,
273 pub remote_compaction_v1: CodexCapabilityDecision,
274 pub hosted_image_generation: CodexCapabilityDecision,
275 pub responses_websocket: CodexCapabilityDecision,
276 pub web_search: CodexCapabilityDecision,
277 pub apply_patch: CodexCapabilityDecision,
278 pub reasoning_summaries: CodexCapabilityDecision,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282pub struct CodexContinuityCapabilityProfile {
283 pub identity_sets_compact_path: CodexCapabilityDecision,
284 pub state_sharing: CodexCapabilityDecision,
285 pub operator_guidance: String,
286}
287
288impl Default for CodexContinuityCapabilityProfile {
289 fn default() -> Self {
290 Self {
291 identity_sets_compact_path: CodexCapabilityDecision::default(),
292 state_sharing: CodexCapabilityDecision::unknown(
293 "continuity profile was not reported by this response",
294 ),
295 operator_guidance: String::new(),
296 }
297 }
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(rename_all = "snake_case")]
302pub enum CodexPatchModeRecommendationConfidence {
303 High,
304 Medium,
305 Low,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub struct CodexPatchModeRecommendationInput {
310 pub current_patch_mode: CodexPatchMode,
311 pub model_catalog: CodexModelCatalogProfile,
312 pub responses: CodexCapabilitySupport,
313 pub responses_compact: CodexCapabilitySupport,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317pub struct CodexPatchModeRecommendation {
318 pub current_patch_mode: CodexPatchMode,
319 pub recommended_patch_mode: CodexPatchMode,
320 pub changes_current_mode: bool,
321 pub confidence: CodexPatchModeRecommendationConfidence,
322 pub reasons: Vec<String>,
323 pub warnings: Vec<String>,
324}
325
326impl CodexPatchModeRecommendation {
327 pub fn for_input(input: CodexPatchModeRecommendationInput) -> Self {
328 let mut reasons = Vec::new();
329 let mut warnings = Vec::new();
330
331 match input.responses {
332 CodexCapabilitySupport::Supported => {
333 reasons.push("/responses endpoint is available".to_string());
334 }
335 CodexCapabilitySupport::Unsupported => {
336 reasons.push("/responses endpoint is not available".to_string());
337 warnings.push(
338 "no Codex preset can compensate for a relay that does not expose /responses"
339 .to_string(),
340 );
341 return Self::new(
342 input.current_patch_mode,
343 CodexPatchMode::Default,
344 CodexPatchModeRecommendationConfidence::High,
345 reasons,
346 warnings,
347 );
348 }
349 CodexCapabilitySupport::Unknown => {
350 reasons.push("/responses endpoint support is unknown".to_string());
351 warnings.push(
352 "do not upgrade preset until ordinary Codex model requests are proven"
353 .to_string(),
354 );
355 return Self::new(
356 input.current_patch_mode,
357 CodexPatchMode::Default,
358 CodexPatchModeRecommendationConfidence::Low,
359 reasons,
360 warnings,
361 );
362 }
363 }
364
365 let image_support = input.model_catalog.selected_image_input_support();
366 let image_capable = image_support.support == CodexCapabilitySupport::Supported;
367 match image_support.support {
368 CodexCapabilitySupport::Supported => {
369 reasons.push(
370 "selected model metadata allows hosted image generation gates".to_string(),
371 );
372 warnings.push(
373 "hosted image generation is not actively probed because that can create artifacts or spend quota"
374 .to_string(),
375 );
376 }
377 CodexCapabilitySupport::Unsupported => {
378 reasons.push(
379 "selected model metadata does not allow hosted image generation".to_string(),
380 );
381 }
382 CodexCapabilitySupport::Unknown => {
383 warnings.push(format!(
384 "hosted image generation remains uncertain: {}",
385 image_support.reason
386 ));
387 }
388 }
389
390 let (recommended_patch_mode, confidence) = match input.responses_compact {
391 CodexCapabilitySupport::Supported if image_capable => {
392 reasons.push("/responses/compact is available".to_string());
393 reasons.push(
394 "combine official relay identity with the image-generation auth facade"
395 .to_string(),
396 );
397 (
398 CodexPatchMode::OfficialImagegenBridge,
399 CodexPatchModeRecommendationConfidence::Medium,
400 )
401 }
402 CodexCapabilitySupport::Supported => {
403 reasons.push("/responses/compact is available".to_string());
404 reasons.push(
405 "use official relay identity but avoid exposing hosted image generation"
406 .to_string(),
407 );
408 (
409 CodexPatchMode::OfficialRelayBridge,
410 confidence_without_image_uncertainty(image_support.support),
411 )
412 }
413 CodexCapabilitySupport::Unsupported if image_capable => {
414 reasons.push("/responses/compact is unavailable".to_string());
415 reasons.push(
416 "keep the image-generation auth facade and local compaction fallback"
417 .to_string(),
418 );
419 (
420 CodexPatchMode::ImagegenBridge,
421 CodexPatchModeRecommendationConfidence::Medium,
422 )
423 }
424 CodexCapabilitySupport::Unsupported => {
425 reasons.push("/responses/compact is unavailable".to_string());
426 reasons.push(
427 "avoid official relay identity so Codex keeps local compaction fallback"
428 .to_string(),
429 );
430 (
431 CodexPatchMode::Default,
432 confidence_without_image_uncertainty(image_support.support),
433 )
434 }
435 CodexCapabilitySupport::Unknown if image_capable => {
436 reasons.push("/responses/compact support is unknown".to_string());
437 warnings.push(
438 "avoid official relay identity until remote compaction is actively proven"
439 .to_string(),
440 );
441 (
442 CodexPatchMode::ImagegenBridge,
443 CodexPatchModeRecommendationConfidence::Low,
444 )
445 }
446 CodexCapabilitySupport::Unknown => {
447 reasons.push("/responses/compact support is unknown".to_string());
448 warnings.push(
449 "avoid official relay identity until remote compaction is actively proven"
450 .to_string(),
451 );
452 (
453 CodexPatchMode::Default,
454 CodexPatchModeRecommendationConfidence::Low,
455 )
456 }
457 };
458
459 Self::new(
460 input.current_patch_mode,
461 recommended_patch_mode,
462 confidence,
463 reasons,
464 warnings,
465 )
466 }
467
468 fn new(
469 current_patch_mode: CodexPatchMode,
470 recommended_patch_mode: CodexPatchMode,
471 confidence: CodexPatchModeRecommendationConfidence,
472 reasons: Vec<String>,
473 warnings: Vec<String>,
474 ) -> Self {
475 Self {
476 current_patch_mode,
477 recommended_patch_mode,
478 changes_current_mode: current_patch_mode != recommended_patch_mode,
479 confidence,
480 reasons,
481 warnings,
482 }
483 }
484}
485
486fn confidence_without_image_uncertainty(
487 image_support: CodexCapabilitySupport,
488) -> CodexPatchModeRecommendationConfidence {
489 match image_support {
490 CodexCapabilitySupport::Unknown => CodexPatchModeRecommendationConfidence::Medium,
491 CodexCapabilitySupport::Supported | CodexCapabilitySupport::Unsupported => {
492 CodexPatchModeRecommendationConfidence::High
493 }
494 }
495}
496
497#[derive(Debug, Clone, PartialEq, Eq)]
498pub struct CodexCapabilityProfileInput {
499 pub patch_mode: CodexPatchMode,
500 pub provider_identity: CodexProviderIdentity,
501 pub auth_shape: CodexAuthShape,
502 pub provider_supports_websockets: bool,
503 pub model_catalog: CodexModelCatalogProfile,
504}
505
506impl CodexCapabilityProfileInput {
507 pub fn from_patch_mode(
508 patch_mode: CodexPatchMode,
509 model_catalog: CodexModelCatalogProfile,
510 ) -> Self {
511 Self::from_patch_mode_with_transport(patch_mode, false, model_catalog)
512 }
513
514 pub fn from_patch_mode_with_transport(
515 patch_mode: CodexPatchMode,
516 provider_supports_websockets: bool,
517 model_catalog: CodexModelCatalogProfile,
518 ) -> Self {
519 Self {
520 patch_mode,
521 provider_identity: CodexProviderIdentity::from_patch_mode(patch_mode),
522 auth_shape: CodexAuthShape::from_patch_mode(patch_mode),
523 provider_supports_websockets,
524 model_catalog,
525 }
526 }
527
528 pub fn from_patch_plan(
529 patch_plan: CodexPatchPlan,
530 model_catalog: CodexModelCatalogProfile,
531 ) -> Self {
532 Self {
533 patch_mode: patch_plan.mode(),
534 provider_identity: CodexProviderIdentity::from_patch_provider_identity(
535 patch_plan.provider().identity(),
536 ),
537 auth_shape: CodexAuthShape::from_auth_patch_plan(patch_plan.auth()),
538 provider_supports_websockets: patch_plan.options().responses_websocket,
539 model_catalog,
540 }
541 }
542
543 pub fn from_patch_config(
544 patch_mode: CodexPatchMode,
545 options: CodexSwitchOptions,
546 model_catalog: CodexModelCatalogProfile,
547 ) -> Self {
548 let plan = CodexPatchPlan::for_switch_on(patch_mode, options)
549 .expect("validated codex client patch config should produce a capability profile");
550 Self::from_patch_plan(plan, model_catalog)
551 }
552}
553
554impl CodexCapabilityProfile {
555 pub fn for_patch_mode(
556 patch_mode: CodexPatchMode,
557 model_catalog: CodexModelCatalogProfile,
558 ) -> Self {
559 Self::for_input(CodexCapabilityProfileInput::from_patch_mode(
560 patch_mode,
561 model_catalog,
562 ))
563 }
564
565 pub fn for_input(input: CodexCapabilityProfileInput) -> Self {
566 let CodexCapabilityProfileInput {
567 patch_mode,
568 provider_identity,
569 auth_shape,
570 provider_supports_websockets,
571 model_catalog,
572 } = input;
573 let remote_compaction_v1 = remote_compaction_v1_support(provider_identity);
574 let hosted_image_generation = hosted_image_generation_support(auth_shape, &model_catalog);
575 let responses_websocket = responses_websocket_support(provider_supports_websockets);
576 let continuity = continuity_capability_profile(provider_identity);
577 let web_search = model_catalog.selected_web_search_support();
578 let apply_patch = model_catalog.selected_apply_patch_support();
579 let reasoning_summaries = model_catalog.selected_reasoning_summary_support();
580
581 Self {
582 patch_mode,
583 provider_identity,
584 auth_shape,
585 provider_supports_websockets,
586 continuity,
587 model_catalog,
588 remote_compaction_v1,
589 hosted_image_generation,
590 responses_websocket,
591 web_search,
592 apply_patch,
593 reasoning_summaries,
594 }
595 }
596
597 pub fn for_models_response_json(
598 patch_mode: CodexPatchMode,
599 models_response: &Value,
600 selected_slug: Option<&str>,
601 ) -> Self {
602 Self::for_patch_mode(
603 patch_mode,
604 CodexModelCatalogProfile::from_models_response_json(models_response, selected_slug),
605 )
606 }
607}
608
609fn remote_compaction_v1_support(
610 provider_identity: CodexProviderIdentity,
611) -> CodexCapabilityDecision {
612 match provider_identity {
613 CodexProviderIdentity::OfficialOpenAi => CodexCapabilityDecision::supported(
614 "provider identity is OpenAI, which makes Codex choose the /responses/compact path; this does not prove that relay endpoints share encrypted provider state",
615 ),
616 CodexProviderIdentity::HelperRelay => CodexCapabilityDecision::unsupported(
617 "provider identity is codex-helper, so Codex uses local compaction fallback",
618 ),
619 }
620}
621
622fn continuity_capability_profile(
623 provider_identity: CodexProviderIdentity,
624) -> CodexContinuityCapabilityProfile {
625 match provider_identity {
626 CodexProviderIdentity::OfficialOpenAi => CodexContinuityCapabilityProfile {
627 identity_sets_compact_path: CodexCapabilityDecision::supported(
628 "Codex sees the provider as OpenAI and can choose remote compact transport",
629 ),
630 state_sharing: CodexCapabilityDecision::unknown(
631 "OpenAI identity only selects the client protocol; relay continuity depends on the selected upstream account and must not be inferred from host or base_url",
632 ),
633 operator_guidance:
634 "For direct api.openai.com with one authenticated account, provider-endpoint affinity is usually sufficient. For relay chains such as sub2api or New API, configure the same continuity_domain only for endpoints that intentionally share encrypted response state.".to_string(),
635 },
636 CodexProviderIdentity::HelperRelay => CodexContinuityCapabilityProfile {
637 identity_sets_compact_path: CodexCapabilityDecision::unsupported(
638 "Codex sees codex-helper identity and keeps local compaction fallback",
639 ),
640 state_sharing: CodexCapabilityDecision::unknown(
641 "helper relay identity does not prove upstream state sharing",
642 ),
643 operator_guidance:
644 "State-bound remote compact is not expected in the default helper identity path; if official relay presets are enabled later, review continuity_domain before allowing cross-provider failover.".to_string(),
645 },
646 }
647}
648
649fn hosted_image_generation_support(
650 auth_shape: CodexAuthShape,
651 model_catalog: &CodexModelCatalogProfile,
652) -> CodexCapabilityDecision {
653 if !auth_shape.allows_codex_backend_tools() {
654 return CodexCapabilityDecision::unsupported(
655 "current preset does not make Codex auth look like Codex backend auth",
656 );
657 }
658
659 match model_catalog.selected_image_input_support().support {
660 CodexCapabilitySupport::Supported => CodexCapabilityDecision::supported(
661 "auth shape allows Codex backend tools and selected model accepts image input",
662 ),
663 CodexCapabilitySupport::Unsupported => CodexCapabilityDecision::unsupported(
664 "selected model metadata does not include image input modality",
665 ),
666 CodexCapabilitySupport::Unknown => CodexCapabilityDecision::unknown(format!(
667 "auth shape allows Codex backend tools, but selected model image support is unknown: {}",
668 model_catalog.reason
669 )),
670 }
671}
672
673fn responses_websocket_support(provider_supports_websockets: bool) -> CodexCapabilityDecision {
674 if provider_supports_websockets {
675 CodexCapabilityDecision::supported(
676 "provider advertises supports_websockets, so Codex may choose Responses WebSocket transport",
677 )
678 } else {
679 CodexCapabilityDecision::unsupported(
680 "provider does not advertise Responses WebSocket transport",
681 )
682 }
683}
684
685fn image_input_support(model: &Value) -> CodexCapabilityDecision {
686 let Some(modalities) = model.get("input_modalities") else {
687 return CodexCapabilityDecision::supported(
688 "Codex defaults missing input_modalities to text and image",
689 );
690 };
691 let Some(modalities) = modalities.as_array() else {
692 return CodexCapabilityDecision::unknown("input_modalities is not an array");
693 };
694 if modalities
695 .iter()
696 .any(|modality| modality.as_str() == Some("image"))
697 {
698 CodexCapabilityDecision::supported("input_modalities includes image")
699 } else {
700 CodexCapabilityDecision::unsupported("input_modalities does not include image")
701 }
702}
703
704fn web_search_support(model: &Value) -> CodexCapabilityDecision {
705 match model.get("supports_search_tool").and_then(Value::as_bool) {
706 Some(true) => CodexCapabilityDecision::supported("supports_search_tool is true"),
707 Some(false) => CodexCapabilityDecision::unsupported("supports_search_tool is false"),
708 None => CodexCapabilityDecision::unsupported(
709 "Codex defaults missing supports_search_tool to false",
710 ),
711 }
712}
713
714fn apply_patch_support(model: &Value) -> CodexCapabilityDecision {
715 match model.get("apply_patch_tool_type").and_then(Value::as_str) {
716 Some("freeform") => CodexCapabilityDecision::supported("apply_patch_tool_type is freeform"),
717 Some(_) => CodexCapabilityDecision::unsupported("apply_patch_tool_type is not freeform"),
718 None => CodexCapabilityDecision::unsupported("apply_patch_tool_type is missing"),
719 }
720}
721
722fn reasoning_summary_support(model: &Value) -> CodexCapabilityDecision {
723 match model
724 .get("supports_reasoning_summaries")
725 .and_then(Value::as_bool)
726 {
727 Some(true) => CodexCapabilityDecision::supported("supports_reasoning_summaries is true"),
728 Some(false) => {
729 CodexCapabilityDecision::unsupported("supports_reasoning_summaries is false")
730 }
731 None => CodexCapabilityDecision::unknown("supports_reasoning_summaries is missing"),
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738 use serde_json::json;
739
740 fn codex_models_response(model: Value) -> Value {
741 json!({ "models": [model] })
742 }
743
744 fn image_capable_model(slug: &str) -> Value {
745 json!({
746 "slug": slug,
747 "input_modalities": ["text", "image"],
748 "supports_search_tool": true,
749 "apply_patch_tool_type": "freeform",
750 "supports_reasoning_summaries": true
751 })
752 }
753
754 fn text_only_model(slug: &str) -> Value {
755 json!({
756 "slug": slug,
757 "input_modalities": ["text"],
758 "supports_search_tool": false,
759 "apply_patch_tool_type": null,
760 "supports_reasoning_summaries": false
761 })
762 }
763
764 #[test]
765 fn codex_capability_profile_official_imagegen_bridge_exposes_compact_and_imagegen() {
766 let catalog = codex_models_response(image_capable_model("gpt-5.5"));
767
768 let profile = CodexCapabilityProfile::for_models_response_json(
769 CodexPatchMode::OfficialImagegenBridge,
770 &catalog,
771 Some("gpt-5.5"),
772 );
773
774 assert_eq!(
775 profile.remote_compaction_v1.support,
776 CodexCapabilitySupport::Supported
777 );
778 assert_eq!(
779 profile.continuity.state_sharing.support,
780 CodexCapabilitySupport::Unknown
781 );
782 assert!(
783 profile
784 .continuity
785 .state_sharing
786 .reason
787 .contains("must not be inferred")
788 );
789 assert_eq!(
790 profile.hosted_image_generation.support,
791 CodexCapabilitySupport::Supported
792 );
793 assert_eq!(
794 profile.responses_websocket.support,
795 CodexCapabilitySupport::Unsupported
796 );
797 assert_eq!(
798 profile.web_search.support,
799 CodexCapabilitySupport::Supported
800 );
801 assert_eq!(
802 profile.apply_patch.support,
803 CodexCapabilitySupport::Supported
804 );
805 }
806
807 #[test]
808 fn codex_capability_profile_official_relay_does_not_expose_imagegen_without_auth_facade() {
809 let catalog = codex_models_response(image_capable_model("gpt-5.5"));
810
811 let profile = CodexCapabilityProfile::for_models_response_json(
812 CodexPatchMode::OfficialRelayBridge,
813 &catalog,
814 Some("gpt-5.5"),
815 );
816
817 assert_eq!(
818 profile.remote_compaction_v1.support,
819 CodexCapabilitySupport::Supported
820 );
821 assert_eq!(
822 profile.hosted_image_generation.support,
823 CodexCapabilitySupport::Unsupported
824 );
825 assert_eq!(
826 profile.continuity.state_sharing.support,
827 CodexCapabilitySupport::Unknown
828 );
829 assert!(
830 profile
831 .continuity
832 .operator_guidance
833 .contains("sub2api or New API")
834 );
835 assert!(
836 profile
837 .continuity
838 .operator_guidance
839 .contains("continuity_domain only for endpoints")
840 );
841 }
842
843 #[test]
844 fn codex_capability_profile_imagegen_bridge_requires_image_capable_model() {
845 let catalog = codex_models_response(text_only_model("gpt-5.3-codex-spark"));
846
847 let profile = CodexCapabilityProfile::for_models_response_json(
848 CodexPatchMode::ImagegenBridge,
849 &catalog,
850 Some("gpt-5.3-codex-spark"),
851 );
852
853 assert_eq!(
854 profile.remote_compaction_v1.support,
855 CodexCapabilitySupport::Unsupported
856 );
857 assert_eq!(
858 profile.continuity.identity_sets_compact_path.support,
859 CodexCapabilitySupport::Unsupported
860 );
861 assert_eq!(
862 profile.hosted_image_generation.support,
863 CodexCapabilitySupport::Unsupported
864 );
865 assert_eq!(
866 profile.web_search.support,
867 CodexCapabilitySupport::Unsupported
868 );
869 assert_eq!(
870 profile.apply_patch.support,
871 CodexCapabilitySupport::Unsupported
872 );
873 }
874
875 #[test]
876 fn codex_capability_profile_chatgpt_bridge_exposes_imagegen_but_not_compact() {
877 let catalog = codex_models_response(image_capable_model("gpt-5.5"));
878
879 let profile = CodexCapabilityProfile::for_models_response_json(
880 CodexPatchMode::ChatGptBridge,
881 &catalog,
882 Some("gpt-5.5"),
883 );
884
885 assert_eq!(
886 profile.remote_compaction_v1.support,
887 CodexCapabilitySupport::Unsupported
888 );
889 assert_eq!(
890 profile.hosted_image_generation.support,
891 CodexCapabilitySupport::Supported
892 );
893 }
894
895 #[test]
896 fn codex_capability_profile_allows_auth_shape_to_be_measured_separately_from_patch_mode() {
897 let catalog = CodexModelCatalogProfile::from_models_response_json(
898 &codex_models_response(image_capable_model("gpt-5.5")),
899 Some("gpt-5.5"),
900 );
901
902 let profile = CodexCapabilityProfile::for_input(CodexCapabilityProfileInput {
903 patch_mode: CodexPatchMode::OfficialRelayBridge,
904 provider_identity: CodexProviderIdentity::OfficialOpenAi,
905 auth_shape: CodexAuthShape::CompleteChatGptLogin,
906 provider_supports_websockets: false,
907 model_catalog: catalog,
908 });
909
910 assert_eq!(
911 profile.remote_compaction_v1.support,
912 CodexCapabilitySupport::Supported
913 );
914 assert_eq!(
915 profile.hosted_image_generation.support,
916 CodexCapabilitySupport::Supported
917 );
918 }
919
920 #[test]
921 fn codex_capability_profile_reports_websocket_if_provider_advertises_it() {
922 let catalog = CodexModelCatalogProfile::from_models_response_json(
923 &codex_models_response(image_capable_model("gpt-5.5")),
924 Some("gpt-5.5"),
925 );
926
927 let profile = CodexCapabilityProfile::for_input(CodexCapabilityProfileInput {
928 patch_mode: CodexPatchMode::Default,
929 provider_identity: CodexProviderIdentity::HelperRelay,
930 auth_shape: CodexAuthShape::None,
931 provider_supports_websockets: true,
932 model_catalog: catalog,
933 });
934
935 assert_eq!(
936 profile.responses_websocket.support,
937 CodexCapabilitySupport::Supported
938 );
939 }
940
941 #[test]
942 fn codex_capability_profile_official_imagegen_bridge_with_transport_exposes_compact_imagegen_and_websocket()
943 {
944 let catalog = CodexModelCatalogProfile::from_models_response_json(
945 &codex_models_response(image_capable_model("gpt-5.5")),
946 Some("gpt-5.5"),
947 );
948
949 let profile = CodexCapabilityProfile::for_input(
950 CodexCapabilityProfileInput::from_patch_mode_with_transport(
951 CodexPatchMode::OfficialImagegenBridge,
952 true,
953 catalog,
954 ),
955 );
956
957 assert_eq!(
958 profile.remote_compaction_v1.support,
959 CodexCapabilitySupport::Supported
960 );
961 assert_eq!(
962 profile.hosted_image_generation.support,
963 CodexCapabilitySupport::Supported
964 );
965 assert_eq!(
966 profile.responses_websocket.support,
967 CodexCapabilitySupport::Supported
968 );
969 }
970
971 #[test]
972 fn codex_capability_profile_openai_data_catalog_requires_translation() {
973 let models_response = json!({
974 "object": "list",
975 "data": [
976 { "id": "gpt-5.5", "object": "model" }
977 ]
978 });
979
980 let profile = CodexCapabilityProfile::for_models_response_json(
981 CodexPatchMode::OfficialImagegenBridge,
982 &models_response,
983 Some("gpt-5.5"),
984 );
985
986 assert_eq!(
987 profile.model_catalog.shape,
988 CodexModelCatalogShape::OpenAiDataList
989 );
990 assert!(profile.model_catalog.translation_required);
991 assert_eq!(
992 profile.hosted_image_generation.support,
993 CodexCapabilitySupport::Unknown
994 );
995 }
996
997 #[test]
998 fn codex_capability_profile_missing_input_modalities_uses_codex_default() {
999 let catalog = codex_models_response(json!({
1000 "slug": "gpt-5.5",
1001 "supports_search_tool": true,
1002 "apply_patch_tool_type": "freeform",
1003 "supports_reasoning_summaries": true
1004 }));
1005
1006 let profile = CodexCapabilityProfile::for_models_response_json(
1007 CodexPatchMode::ImagegenBridge,
1008 &catalog,
1009 Some("gpt-5.5"),
1010 );
1011
1012 assert_eq!(
1013 profile.hosted_image_generation.support,
1014 CodexCapabilitySupport::Supported
1015 );
1016 }
1017
1018 fn recommendation(
1019 current_patch_mode: CodexPatchMode,
1020 model_catalog: CodexModelCatalogProfile,
1021 responses_compact: CodexCapabilitySupport,
1022 ) -> CodexPatchModeRecommendation {
1023 CodexPatchModeRecommendation::for_input(CodexPatchModeRecommendationInput {
1024 current_patch_mode,
1025 model_catalog,
1026 responses: CodexCapabilitySupport::Supported,
1027 responses_compact,
1028 })
1029 }
1030
1031 #[test]
1032 fn codex_patch_mode_recommendation_uses_official_imagegen_when_compact_and_image_are_supported()
1033 {
1034 let catalog = CodexModelCatalogProfile::from_models_response_json(
1035 &codex_models_response(image_capable_model("gpt-5.5")),
1036 Some("gpt-5.5"),
1037 );
1038
1039 let recommendation = recommendation(
1040 CodexPatchMode::Default,
1041 catalog,
1042 CodexCapabilitySupport::Supported,
1043 );
1044
1045 assert_eq!(
1046 recommendation.recommended_patch_mode,
1047 CodexPatchMode::OfficialImagegenBridge
1048 );
1049 assert!(recommendation.changes_current_mode);
1050 assert_eq!(
1051 recommendation.confidence,
1052 CodexPatchModeRecommendationConfidence::Medium
1053 );
1054 assert!(
1055 recommendation
1056 .warnings
1057 .iter()
1058 .any(|warning| warning.contains("not actively probed"))
1059 );
1060 }
1061
1062 #[test]
1063 fn codex_patch_mode_recommendation_uses_official_relay_without_image_capable_model() {
1064 let catalog = CodexModelCatalogProfile::from_models_response_json(
1065 &codex_models_response(text_only_model("gpt-5.3-codex-spark")),
1066 Some("gpt-5.3-codex-spark"),
1067 );
1068
1069 let recommendation = recommendation(
1070 CodexPatchMode::Default,
1071 catalog,
1072 CodexCapabilitySupport::Supported,
1073 );
1074
1075 assert_eq!(
1076 recommendation.recommended_patch_mode,
1077 CodexPatchMode::OfficialRelayBridge
1078 );
1079 assert_eq!(
1080 recommendation.confidence,
1081 CodexPatchModeRecommendationConfidence::High
1082 );
1083 }
1084
1085 #[test]
1086 fn codex_patch_mode_recommendation_keeps_imagegen_bridge_when_compact_is_unsupported() {
1087 let catalog = CodexModelCatalogProfile::from_models_response_json(
1088 &codex_models_response(image_capable_model("gpt-5.5")),
1089 Some("gpt-5.5"),
1090 );
1091
1092 let recommendation = recommendation(
1093 CodexPatchMode::OfficialImagegenBridge,
1094 catalog,
1095 CodexCapabilitySupport::Unsupported,
1096 );
1097
1098 assert_eq!(
1099 recommendation.recommended_patch_mode,
1100 CodexPatchMode::ImagegenBridge
1101 );
1102 assert!(recommendation.changes_current_mode);
1103 assert!(
1104 recommendation
1105 .reasons
1106 .iter()
1107 .any(|reason| reason.contains("local compaction"))
1108 );
1109 }
1110
1111 #[test]
1112 fn codex_patch_mode_recommendation_uses_default_when_no_official_gate_is_proven() {
1113 let catalog = CodexModelCatalogProfile::from_models_response_json(
1114 &codex_models_response(text_only_model("gpt-5.3-codex-spark")),
1115 Some("gpt-5.3-codex-spark"),
1116 );
1117
1118 let recommendation = recommendation(
1119 CodexPatchMode::OfficialRelayBridge,
1120 catalog,
1121 CodexCapabilitySupport::Unsupported,
1122 );
1123
1124 assert_eq!(
1125 recommendation.recommended_patch_mode,
1126 CodexPatchMode::Default
1127 );
1128 assert_eq!(
1129 recommendation.confidence,
1130 CodexPatchModeRecommendationConfidence::High
1131 );
1132 }
1133
1134 #[test]
1135 fn codex_patch_mode_recommendation_does_not_upgrade_to_official_when_compact_is_unknown() {
1136 let catalog = CodexModelCatalogProfile::from_models_response_json(
1137 &codex_models_response(image_capable_model("gpt-5.5")),
1138 Some("gpt-5.5"),
1139 );
1140
1141 let recommendation = recommendation(
1142 CodexPatchMode::Default,
1143 catalog,
1144 CodexCapabilitySupport::Unknown,
1145 );
1146
1147 assert_eq!(
1148 recommendation.recommended_patch_mode,
1149 CodexPatchMode::ImagegenBridge
1150 );
1151 assert_eq!(
1152 recommendation.confidence,
1153 CodexPatchModeRecommendationConfidence::Low
1154 );
1155 assert!(
1156 recommendation
1157 .warnings
1158 .iter()
1159 .any(|warning| warning.contains("avoid official relay identity"))
1160 );
1161 }
1162
1163 #[test]
1164 fn codex_patch_mode_recommendation_warns_when_responses_endpoint_is_not_available() {
1165 let catalog = CodexModelCatalogProfile::from_models_response_json(
1166 &codex_models_response(image_capable_model("gpt-5.5")),
1167 Some("gpt-5.5"),
1168 );
1169
1170 let recommendation =
1171 CodexPatchModeRecommendation::for_input(CodexPatchModeRecommendationInput {
1172 current_patch_mode: CodexPatchMode::OfficialImagegenBridge,
1173 model_catalog: catalog,
1174 responses: CodexCapabilitySupport::Unsupported,
1175 responses_compact: CodexCapabilitySupport::Supported,
1176 });
1177
1178 assert_eq!(
1179 recommendation.recommended_patch_mode,
1180 CodexPatchMode::Default
1181 );
1182 assert!(
1183 recommendation
1184 .warnings
1185 .iter()
1186 .any(|warning| warning.contains("no Codex preset can compensate"))
1187 );
1188 }
1189}