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