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}