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
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}