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