Skip to main content

codex_helper_core/
codex_capability_profile.rs

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}