Skip to main content

codex_helper_core/proxy/
codex_relay_capabilities.rs

1use axum::http::StatusCode;
2use serde::Deserialize;
3use serde::Serialize;
4use serde_json::Value;
5
6use crate::codex_capability_profile::{
7    CodexCapabilityDecision, CodexCapabilityProfile, CodexCapabilityProfileInput,
8    CodexCapabilitySupport, CodexModelCatalogProfile, CodexPatchModeRecommendation,
9    CodexPatchModeRecommendationInput,
10};
11use crate::codex_integration::{CodexCompactionStrategy, CodexPatchMode, CodexSwitchOptions};
12use crate::config::{ServiceViewV4, effective_v4_routing};
13use crate::routing_ir::compile_v4_route_plan_template_for_compat_runtime;
14use crate::runtime_identity::ContinuityDomainKey;
15
16use super::codex_relay_probe::CodexRelayProbeObservation;
17use super::codex_relay_probe::codex_relay_probe_cases;
18use super::codex_relay_target::{
19    CodexRelayTargetSelection, SelectedCodexRelayTarget, select_codex_relay_target,
20};
21use super::models_compat::maybe_decode_models_response_body;
22use super::{
23    CodexRelayProbeClient, CodexRelayProbeKind, CodexRelayProbeResult, ProxyControlError,
24    ProxyService,
25};
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct CodexRelayCapabilitiesRequest {
29    #[serde(default)]
30    pub station_name: Option<String>,
31    #[serde(default)]
32    pub provider_id: Option<String>,
33    #[serde(default)]
34    pub endpoint_id: Option<String>,
35    #[serde(default)]
36    pub upstream_index: Option<usize>,
37    #[serde(default)]
38    pub model: Option<String>,
39    #[serde(
40        default,
41        alias = "patch_preset",
42        deserialize_with = "deserialize_patch_preset_input"
43    )]
44    pub patch_mode: Option<CodexPatchMode>,
45    #[serde(default)]
46    pub compaction: Option<CodexCompactionStrategy>,
47    #[serde(default)]
48    pub responses_websocket: Option<bool>,
49}
50
51fn deserialize_patch_preset_input<'de, D>(
52    deserializer: D,
53) -> Result<Option<CodexPatchMode>, D::Error>
54where
55    D: serde::Deserializer<'de>,
56{
57    let value = Option::<String>::deserialize(deserializer)?;
58    value
59        .as_deref()
60        .map(parse_patch_preset_input)
61        .transpose()
62        .map_err(serde::de::Error::custom)
63}
64
65fn parse_patch_preset_input(value: &str) -> Result<CodexPatchMode, String> {
66    match value.trim() {
67        "default" => Ok(CodexPatchMode::Default),
68        "chatgpt-bridge" => Ok(CodexPatchMode::ChatGptBridge),
69        "imagegen-bridge" => Ok(CodexPatchMode::ImagegenBridge),
70        "official-relay" => Ok(CodexPatchMode::OfficialRelayBridge),
71        "official-imagegen" => Ok(CodexPatchMode::OfficialImagegenBridge),
72        "official-relay-bridge" | "official_relay_bridge" => Err(
73            "legacy patch preset 'official-relay-bridge' has been removed; use 'official-relay'"
74                .to_string(),
75        ),
76        "official-imagegen-bridge" | "official_imagegen_bridge" => Err(
77            "legacy patch preset 'official-imagegen-bridge' has been removed; use 'official-imagegen'"
78                .to_string(),
79        ),
80        other => Err(format!(
81            "unsupported patch_preset '{other}'; expected 'default', 'chatgpt-bridge', 'imagegen-bridge', 'official-relay', or 'official-imagegen'"
82        )),
83    }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct CodexRelayCapabilitiesResponse {
88    pub api_version: u32,
89    pub service_name: String,
90    pub station_name: String,
91    pub upstream_index: usize,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub provider_id: Option<String>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub endpoint_id: Option<String>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub provider_endpoint_key: Option<String>,
98    pub upstream_base_url: String,
99    pub patch_mode: CodexPatchMode,
100    pub compaction: CodexCompactionStrategy,
101    pub responses_websocket: bool,
102    pub model: Option<String>,
103    pub expected: CodexCapabilityProfile,
104    pub observed: CodexRelayCapabilitiesObserved,
105    pub recommendation: CodexPatchModeRecommendation,
106    #[serde(default)]
107    pub continuity: CodexRelayContinuityDiagnostics,
108    pub mismatches: Vec<CodexRelayCapabilityMismatch>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct CodexRelayCapabilitiesObserved {
113    pub models: CodexRelayProbeResult,
114    pub responses: CodexRelayProbeResult,
115    pub responses_compact: CodexRelayProbeResult,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct CodexRelayCapabilityMismatch {
120    pub capability: String,
121    pub expected: String,
122    pub observed: String,
123    pub reason: String,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct CodexRelayContinuityDiagnostics {
128    pub selected_domain: CodexRelayContinuityDomainSummary,
129    pub same_domain_endpoint_count: usize,
130    pub configured_endpoint_count: usize,
131    pub affinity_policy: Option<String>,
132    pub can_state_bound_failover_within_domain: bool,
133    pub warnings: Vec<String>,
134    pub recommendations: Vec<String>,
135}
136
137impl Default for CodexRelayContinuityDiagnostics {
138    fn default() -> Self {
139        Self {
140            selected_domain: CodexRelayContinuityDomainSummary::default(),
141            same_domain_endpoint_count: 1,
142            configured_endpoint_count: 1,
143            affinity_policy: None,
144            can_state_bound_failover_within_domain: false,
145            warnings: Vec::new(),
146            recommendations: Vec::new(),
147        }
148    }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct CodexRelayContinuityDomainSummary {
153    pub key: String,
154    pub explicit: bool,
155}
156
157impl Default for CodexRelayContinuityDomainSummary {
158    fn default() -> Self {
159        Self {
160            key: "unknown".to_string(),
161            explicit: false,
162        }
163    }
164}
165
166pub(super) async fn codex_relay_capabilities_for_proxy(
167    proxy: &ProxyService,
168    payload: CodexRelayCapabilitiesRequest,
169) -> Result<CodexRelayCapabilitiesResponse, ProxyControlError> {
170    if proxy.service_name != "codex" {
171        return Err(ProxyControlError::new(
172            StatusCode::BAD_REQUEST,
173            "Codex relay capabilities are only available for the codex service",
174        ));
175    }
176
177    let cfg = proxy.config.snapshot().await;
178    let mgr = proxy.service_manager(cfg.as_ref());
179    let target = select_codex_relay_target(
180        mgr,
181        CodexRelayTargetSelection {
182            station_name: payload.station_name.as_deref(),
183            upstream_index: payload.upstream_index,
184            provider_id: payload.provider_id.as_deref(),
185            endpoint_id: payload.endpoint_id.as_deref(),
186        },
187    )?;
188    let patch_mode = payload
189        .patch_mode
190        .or_else(current_codex_switch_patch_mode)
191        .or_else(|| {
192            crate::config::codex_client_patch_config_from_config_file()
193                .ok()
194                .map(|cfg| cfg.preset)
195        })
196        .unwrap_or_default();
197    let responses_websocket = payload
198        .responses_websocket
199        .or_else(current_codex_switch_responses_websocket)
200        .or_else(|| {
201            crate::config::codex_client_patch_config_from_config_file()
202                .ok()
203                .map(|cfg| cfg.options.responses_websocket)
204        })
205        .unwrap_or(false);
206    let compaction = payload
207        .compaction
208        .or_else(current_codex_switch_compaction_strategy)
209        .or_else(|| {
210            crate::config::codex_client_patch_config_from_config_file()
211                .ok()
212                .map(|cfg| cfg.options.compaction)
213        })
214        .unwrap_or_default();
215    if let Err(err) = (CodexSwitchOptions {
216        responses_websocket,
217        compaction,
218    })
219    .validate_for_mode(patch_mode)
220    {
221        return Err(ProxyControlError::new(
222            StatusCode::BAD_REQUEST,
223            err.to_string(),
224        ));
225    }
226    let model = payload
227        .model
228        .as_deref()
229        .map(str::trim)
230        .filter(|value| !value.is_empty())
231        .map(ToOwned::to_owned);
232
233    let probe_client = CodexRelayProbeClient::new(proxy.client.clone());
234    let observations = run_capability_probe_cases(&probe_client, &target.upstream).await;
235    let models_observation = observation_for_kind(&observations, CodexRelayProbeKind::Models);
236
237    let expected = build_expected_profile(
238        patch_mode,
239        compaction,
240        responses_websocket,
241        model.as_deref(),
242        models_observation,
243    );
244    let observed = build_observed_from_probe_observations(&observations);
245    let recommendation = build_recommendation(patch_mode, &expected, &observed);
246    let mismatches = build_mismatches(&expected, &observed);
247    let v4_source = proxy.config.v4_snapshot().await;
248    let continuity = build_continuity_diagnostics(
249        proxy.service_name,
250        v4_source.as_deref().map(|cfg| &cfg.codex),
251        &target,
252        patch_mode,
253        compaction,
254        responses_websocket,
255    );
256
257    let response = CodexRelayCapabilitiesResponse {
258        api_version: 1,
259        service_name: proxy.service_name.to_string(),
260        station_name: target.station_name,
261        upstream_index: target.upstream_index,
262        provider_id: target.provider_id,
263        endpoint_id: target.endpoint_id,
264        provider_endpoint_key: target.provider_endpoint_key,
265        upstream_base_url: target.upstream.base_url,
266        patch_mode,
267        compaction,
268        responses_websocket,
269        model,
270        expected,
271        observed,
272        recommendation,
273        continuity,
274        mismatches,
275    };
276    if let Err(error) = super::codex_relay_evidence::append_codex_relay_capabilities_evidence(
277        &response,
278        "proxy_service",
279    ) {
280        tracing::warn!("failed to write Codex relay capability evidence: {}", error);
281    }
282    Ok(response)
283}
284
285async fn run_capability_probe_cases(
286    probe_client: &CodexRelayProbeClient,
287    upstream: &crate::config::UpstreamConfig,
288) -> Vec<CodexRelayProbeObservation> {
289    let mut observations = Vec::with_capacity(codex_relay_probe_cases().len());
290    for case in codex_relay_probe_cases() {
291        observations.push(
292            probe_client
293                .probe_upstream_observation(upstream, &case.spec())
294                .await,
295        );
296    }
297    observations
298}
299
300fn build_observed_from_probe_observations(
301    observations: &[CodexRelayProbeObservation],
302) -> CodexRelayCapabilitiesObserved {
303    CodexRelayCapabilitiesObserved {
304        models: observation_for_kind(observations, CodexRelayProbeKind::Models)
305            .result
306            .clone(),
307        responses: observation_for_kind(observations, CodexRelayProbeKind::Responses)
308            .result
309            .clone(),
310        responses_compact: observation_for_kind(
311            observations,
312            CodexRelayProbeKind::ResponsesCompact,
313        )
314        .result
315        .clone(),
316    }
317}
318
319fn observation_for_kind(
320    observations: &[CodexRelayProbeObservation],
321    kind: CodexRelayProbeKind,
322) -> &CodexRelayProbeObservation {
323    observations
324        .iter()
325        .find(|observation| observation.result.kind == kind)
326        .expect("Codex relay probe registry must include all observed response fields")
327}
328
329fn current_codex_switch_patch_mode() -> Option<CodexPatchMode> {
330    crate::codex_integration::codex_switch_status()
331        .ok()
332        .and_then(|status| status.patch_mode)
333}
334
335fn current_codex_switch_responses_websocket() -> Option<bool> {
336    crate::codex_integration::codex_switch_status()
337        .ok()
338        .and_then(|status| status.supports_websockets)
339}
340
341fn current_codex_switch_compaction_strategy() -> Option<CodexCompactionStrategy> {
342    crate::codex_integration::codex_switch_status()
343        .ok()
344        .filter(|status| status.enabled)
345        .map(|status| status.compaction_strategy)
346}
347
348fn build_expected_profile(
349    patch_mode: CodexPatchMode,
350    compaction: CodexCompactionStrategy,
351    responses_websocket: bool,
352    model: Option<&str>,
353    models_observation: &CodexRelayProbeObservation,
354) -> CodexCapabilityProfile {
355    let model_catalog = translated_models_catalog(models_observation, model).unwrap_or_else(|| {
356        CodexModelCatalogProfile::unknown(models_observation.result.reason.clone())
357    });
358    CodexCapabilityProfile::for_input(CodexCapabilityProfileInput::from_patch_config(
359        patch_mode,
360        CodexSwitchOptions {
361            responses_websocket,
362            compaction,
363        },
364        model_catalog,
365    ))
366}
367
368fn translated_models_catalog(
369    models_observation: &CodexRelayProbeObservation,
370    model: Option<&str>,
371) -> Option<CodexModelCatalogProfile> {
372    let status = models_observation.status?;
373    if !status.is_success() {
374        return None;
375    }
376    let body = maybe_decode_models_response_body(
377        "codex",
378        "/models",
379        &models_observation.headers,
380        models_observation.body.clone(),
381    );
382    let value = serde_json::from_slice::<Value>(body.as_ref()).ok()?;
383    Some(CodexModelCatalogProfile::from_models_response_json(
384        &value, model,
385    ))
386}
387
388fn build_mismatches(
389    expected: &CodexCapabilityProfile,
390    observed: &CodexRelayCapabilitiesObserved,
391) -> Vec<CodexRelayCapabilityMismatch> {
392    let mut out = Vec::new();
393    push_endpoint_mismatch(
394        &mut out,
395        "responses",
396        &CodexCapabilityDecision::supported("Codex model requests require a /responses endpoint"),
397        &observed.responses,
398    );
399    push_endpoint_mismatch(
400        &mut out,
401        "remote_compaction_v1",
402        &expected.remote_compaction_v1,
403        &observed.responses_compact,
404    );
405    if observed.models.translation_required {
406        out.push(CodexRelayCapabilityMismatch {
407            capability: "model_catalog".to_string(),
408            expected: "codex_models".to_string(),
409            observed: "openai_data_list".to_string(),
410            reason: "relay returned an OpenAI models list; helper model translation is disabled by default so Codex can keep using its bundled model metadata. Enable codex.client_patch.translate_models only if you intentionally want helper-synthesized model metadata.".to_string(),
411        });
412    }
413    out
414}
415
416fn build_continuity_diagnostics(
417    service_name: &str,
418    view: Option<&ServiceViewV4>,
419    target: &SelectedCodexRelayTarget,
420    patch_mode: CodexPatchMode,
421    compaction: CodexCompactionStrategy,
422    responses_websocket: bool,
423) -> CodexRelayContinuityDiagnostics {
424    let fallback_domain = target
425        .provider_endpoint_key
426        .as_deref()
427        .map(|key| format!("provider_endpoint:{key}"))
428        .unwrap_or_else(|| {
429            format!(
430                "legacy:{}/{}/{}",
431                service_name, target.station_name, target.upstream_index
432            )
433        });
434    let mut selected_domain = CodexRelayContinuityDomainSummary {
435        key: fallback_domain,
436        explicit: false,
437    };
438    let mut same_domain_endpoint_count = 1usize;
439    let mut configured_endpoint_count = 1usize;
440    let mut affinity_policy = None;
441
442    if let Some(view) = view {
443        let routing = effective_v4_routing(view);
444        affinity_policy = Some(routing_affinity_policy_label(routing.affinity_policy).to_string());
445        if let Ok(template) = compile_v4_route_plan_template_for_compat_runtime(service_name, view)
446        {
447            let topology = template.continuity_topology();
448            configured_endpoint_count = topology.configured_provider_endpoint_count().max(1);
449            if let Some(provider_endpoint_key) = target.provider_endpoint_key.as_deref()
450                && let Some(summary) = topology.selected_domain_summary(provider_endpoint_key)
451            {
452                selected_domain = domain_summary(&summary.domain);
453                same_domain_endpoint_count = summary.same_domain_endpoint_count;
454            }
455        }
456    }
457
458    let remote_compaction_identity = compaction
459        .provider_identity_for_mode(patch_mode)
460        .provider_name()
461        == "OpenAI";
462    let can_state_bound_failover_within_domain =
463        selected_domain.explicit && same_domain_endpoint_count > 1;
464    let mut warnings = Vec::new();
465    let mut recommendations = Vec::new();
466
467    if remote_compaction_identity && !selected_domain.explicit && configured_endpoint_count > 1 {
468        warnings.push(
469            "remote compaction identity is active with multiple configured provider endpoints, but the selected endpoint has no explicit continuity_domain".to_string(),
470        );
471        recommendations.push(
472            "Set the same continuity_domain only on provider endpoints that intentionally share encrypted response state; otherwise keep provider-endpoint isolation.".to_string(),
473        );
474    }
475
476    if can_state_bound_failover_within_domain {
477        recommendations.push(format!(
478            "State-bound compact may fail over across {same_domain_endpoint_count} endpoints in explicit continuity domain {}.",
479            selected_domain.key
480        ));
481    } else {
482        recommendations.push(
483            "State-bound compact stays isolated to the selected provider endpoint unless a shared continuity_domain is configured.".to_string(),
484        );
485    }
486
487    if matches!(
488        affinity_policy.as_deref(),
489        Some("preferred-group") | Some("off")
490    ) && remote_compaction_identity
491        && configured_endpoint_count > 1
492    {
493        warnings.push(
494            "remote compaction with multiple provider endpoints should use fallback-sticky or hard affinity when encrypted compact state matters".to_string(),
495        );
496    }
497
498    if responses_websocket && !selected_domain.explicit && configured_endpoint_count > 1 {
499        warnings.push(
500            "Responses WebSocket compact uses the same state-bound continuity rules; do not enable cross-provider continuity without explicit continuity_domain".to_string(),
501        );
502    }
503
504    CodexRelayContinuityDiagnostics {
505        selected_domain,
506        same_domain_endpoint_count,
507        configured_endpoint_count,
508        affinity_policy,
509        can_state_bound_failover_within_domain,
510        warnings,
511        recommendations,
512    }
513}
514
515fn domain_summary(domain: &ContinuityDomainKey) -> CodexRelayContinuityDomainSummary {
516    CodexRelayContinuityDomainSummary {
517        key: domain.stable_key(),
518        explicit: domain.is_explicit(),
519    }
520}
521
522fn routing_affinity_policy_label(policy: crate::config::RoutingAffinityPolicyV5) -> &'static str {
523    match policy {
524        crate::config::RoutingAffinityPolicyV5::Off => "off",
525        crate::config::RoutingAffinityPolicyV5::PreferredGroup => "preferred-group",
526        crate::config::RoutingAffinityPolicyV5::FallbackSticky => "fallback-sticky",
527        crate::config::RoutingAffinityPolicyV5::Hard => "hard",
528    }
529}
530
531fn build_recommendation(
532    current_patch_mode: CodexPatchMode,
533    expected: &CodexCapabilityProfile,
534    observed: &CodexRelayCapabilitiesObserved,
535) -> CodexPatchModeRecommendation {
536    CodexPatchModeRecommendation::for_input(CodexPatchModeRecommendationInput {
537        current_patch_mode,
538        model_catalog: expected.model_catalog.clone(),
539        responses: observed_support_to_capability_support(observed.responses.support),
540        responses_compact: observed_support_to_capability_support(
541            observed.responses_compact.support,
542        ),
543    })
544}
545
546fn observed_support_to_capability_support(
547    support: super::CodexRelayProbeSupport,
548) -> CodexCapabilitySupport {
549    match support {
550        super::CodexRelayProbeSupport::Supported => CodexCapabilitySupport::Supported,
551        super::CodexRelayProbeSupport::Unsupported => CodexCapabilitySupport::Unsupported,
552        super::CodexRelayProbeSupport::Unknown => CodexCapabilitySupport::Unknown,
553    }
554}
555
556fn push_endpoint_mismatch(
557    out: &mut Vec<CodexRelayCapabilityMismatch>,
558    capability: &str,
559    expected: &CodexCapabilityDecision,
560    observed: &CodexRelayProbeResult,
561) {
562    let expected_label = support_label(expected.support);
563    let observed_label = format!(
564        "{} via {}",
565        probe_support_label(observed.support),
566        probe_confidence_label(observed.confidence)
567    );
568    if expected.support == crate::codex_capability_profile::CodexCapabilitySupport::Supported
569        && observed.support != super::CodexRelayProbeSupport::Supported
570    {
571        out.push(CodexRelayCapabilityMismatch {
572            capability: capability.to_string(),
573            expected: expected_label.to_string(),
574            observed: observed_label,
575            reason: observed.reason.clone(),
576        });
577    }
578}
579
580fn support_label(support: crate::codex_capability_profile::CodexCapabilitySupport) -> &'static str {
581    match support {
582        crate::codex_capability_profile::CodexCapabilitySupport::Unknown => "unknown",
583        crate::codex_capability_profile::CodexCapabilitySupport::Supported => "supported",
584        crate::codex_capability_profile::CodexCapabilitySupport::Unsupported => "unsupported",
585    }
586}
587
588fn probe_support_label(support: super::CodexRelayProbeSupport) -> &'static str {
589    match support {
590        super::CodexRelayProbeSupport::Supported => "supported",
591        super::CodexRelayProbeSupport::Unsupported => "unsupported",
592        super::CodexRelayProbeSupport::Unknown => "unknown",
593    }
594}
595
596fn probe_confidence_label(confidence: super::CodexRelayProbeConfidence) -> &'static str {
597    match confidence {
598        super::CodexRelayProbeConfidence::SuccessStatus => "success_status",
599        super::CodexRelayProbeConfidence::EndpointValidation => "endpoint_validation",
600        super::CodexRelayProbeConfidence::ErrorClassification => "error_classification",
601        super::CodexRelayProbeConfidence::Transport => "transport",
602        super::CodexRelayProbeConfidence::Malformed => "malformed",
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use std::collections::{BTreeMap, HashMap};
609    use std::sync::{Arc, Mutex};
610
611    use reqwest::Client;
612
613    use super::*;
614    use crate::codex_capability_profile::{CodexCapabilitySupport, CodexProviderIdentity};
615    use crate::config::{
616        ProviderConfigV4, ProxyConfigV4, RoutingConfigV4, ServiceViewV4, UpstreamAuth,
617    };
618    use crate::lb::LbState;
619
620    fn probe_result(
621        kind: CodexRelayProbeKind,
622        support: super::super::CodexRelayProbeSupport,
623    ) -> CodexRelayProbeResult {
624        CodexRelayProbeResult {
625            kind,
626            support,
627            confidence: super::super::CodexRelayProbeConfidence::SuccessStatus,
628            status_code: Some(200),
629            response_shape: Some("ok".to_string()),
630            translation_required: false,
631            error_class: None,
632            reason: "ok".to_string(),
633        }
634    }
635
636    fn observation(kind: CodexRelayProbeKind) -> CodexRelayProbeObservation {
637        CodexRelayProbeObservation {
638            result: probe_result(kind, super::super::CodexRelayProbeSupport::Supported),
639            status: Some(StatusCode::OK),
640            headers: axum::http::HeaderMap::new(),
641            body: axum::body::Bytes::new(),
642        }
643    }
644
645    #[test]
646    fn codex_relay_capabilities_request_accepts_patch_preset_names() {
647        let request = serde_json::from_value::<CodexRelayCapabilitiesRequest>(
648            serde_json::json!({ "patch_preset": "official-imagegen" }),
649        )
650        .expect("current patch preset should deserialize");
651
652        assert_eq!(
653            request.patch_mode,
654            Some(CodexPatchMode::OfficialImagegenBridge)
655        );
656    }
657
658    #[test]
659    fn codex_relay_capabilities_request_rejects_legacy_bridge_preset_names() {
660        let error = serde_json::from_value::<CodexRelayCapabilitiesRequest>(
661            serde_json::json!({ "patch_preset": "official-imagegen-bridge" }),
662        )
663        .expect_err("legacy bridge preset should be rejected for new API requests");
664
665        assert!(error.to_string().contains("use 'official-imagegen'"));
666    }
667
668    #[test]
669    fn codex_relay_capabilities_observed_shape_is_built_from_probe_registry() {
670        let observations = codex_relay_probe_cases()
671            .iter()
672            .map(|case| observation(case.kind))
673            .collect::<Vec<_>>();
674
675        let observed = build_observed_from_probe_observations(&observations);
676
677        assert_eq!(observed.models.kind, CodexRelayProbeKind::Models);
678        assert_eq!(observed.responses.kind, CodexRelayProbeKind::Responses);
679        assert_eq!(
680            observed.responses_compact.kind,
681            CodexRelayProbeKind::ResponsesCompact
682        );
683    }
684
685    #[tokio::test]
686    async fn codex_relay_capabilities_targets_route_graph_provider_id() {
687        let v4 = ProxyConfigV4 {
688            codex: ServiceViewV4 {
689                providers: BTreeMap::from([
690                    (
691                        "input8".to_string(),
692                        ProviderConfigV4 {
693                            base_url: Some("http://127.0.0.1:9/v1".to_string()),
694                            inline_auth: UpstreamAuth::default(),
695                            ..ProviderConfigV4::default()
696                        },
697                    ),
698                    (
699                        "ciii".to_string(),
700                        ProviderConfigV4 {
701                            base_url: Some("http://127.0.0.1:10/v1".to_string()),
702                            inline_auth: UpstreamAuth::default(),
703                            ..ProviderConfigV4::default()
704                        },
705                    ),
706                ]),
707                routing: Some(RoutingConfigV4::ordered_failover(vec![
708                    "input8".to_string(),
709                    "ciii".to_string(),
710                ])),
711                ..ServiceViewV4::default()
712            },
713            ..ProxyConfigV4::default()
714        };
715        let runtime = crate::config::compile_v4_to_runtime(&v4).expect("compile v4 runtime");
716        let proxy = ProxyService::new_with_v4_source(
717            Client::new(),
718            Arc::new(runtime),
719            Some(Arc::new(v4)),
720            "codex",
721            Arc::new(Mutex::new(HashMap::<String, LbState>::new())),
722        );
723
724        let response = codex_relay_capabilities_for_proxy(
725            &proxy,
726            CodexRelayCapabilitiesRequest {
727                provider_id: Some("ciii".to_string()),
728                model: Some("gpt-5.5".to_string()),
729                ..Default::default()
730            },
731        )
732        .await
733        .expect("capabilities response");
734
735        assert_eq!(response.station_name, "routing");
736        assert_eq!(response.upstream_index, 1);
737        assert_eq!(response.provider_id.as_deref(), Some("ciii"));
738        assert_eq!(response.endpoint_id.as_deref(), Some("default"));
739        assert_eq!(
740            response.provider_endpoint_key.as_deref(),
741            Some("codex/ciii/default")
742        );
743        assert_eq!(response.upstream_base_url, "http://127.0.0.1:10/v1");
744    }
745
746    #[tokio::test]
747    async fn codex_relay_capabilities_recommends_explicit_continuity_domain_for_multi_relay() {
748        let v4 = ProxyConfigV4 {
749            codex: ServiceViewV4 {
750                providers: BTreeMap::from([
751                    (
752                        "relay-a".to_string(),
753                        ProviderConfigV4 {
754                            base_url: Some("http://127.0.0.1:9/v1".to_string()),
755                            inline_auth: UpstreamAuth::default(),
756                            ..ProviderConfigV4::default()
757                        },
758                    ),
759                    (
760                        "relay-b".to_string(),
761                        ProviderConfigV4 {
762                            base_url: Some("http://127.0.0.1:10/v1".to_string()),
763                            inline_auth: UpstreamAuth::default(),
764                            ..ProviderConfigV4::default()
765                        },
766                    ),
767                ]),
768                routing: Some(RoutingConfigV4::ordered_failover(vec![
769                    "relay-a".to_string(),
770                    "relay-b".to_string(),
771                ])),
772                ..ServiceViewV4::default()
773            },
774            ..ProxyConfigV4::default()
775        };
776        let runtime = crate::config::compile_v4_to_runtime(&v4).expect("compile v4 runtime");
777        let proxy = ProxyService::new_with_v4_source(
778            Client::new(),
779            Arc::new(runtime),
780            Some(Arc::new(v4)),
781            "codex",
782            Arc::new(Mutex::new(HashMap::<String, LbState>::new())),
783        );
784
785        let response = codex_relay_capabilities_for_proxy(
786            &proxy,
787            CodexRelayCapabilitiesRequest {
788                provider_id: Some("relay-a".to_string()),
789                patch_mode: Some(CodexPatchMode::OfficialRelayBridge),
790                ..Default::default()
791            },
792        )
793        .await
794        .expect("capabilities response");
795
796        assert_eq!(
797            response.continuity.selected_domain.key,
798            "provider_endpoint:codex/relay-a/default"
799        );
800        assert!(!response.continuity.selected_domain.explicit);
801        assert_eq!(response.continuity.configured_endpoint_count, 2);
802        assert_eq!(response.continuity.same_domain_endpoint_count, 1);
803        assert!(!response.continuity.can_state_bound_failover_within_domain);
804        assert!(
805            response
806                .continuity
807                .warnings
808                .iter()
809                .any(|warning| warning.contains("no explicit continuity_domain"))
810        );
811    }
812
813    #[tokio::test]
814    async fn codex_relay_capabilities_uses_compaction_strategy_for_expected_profile() {
815        let v4 = ProxyConfigV4 {
816            codex: ServiceViewV4 {
817                providers: BTreeMap::from([
818                    (
819                        "relay-a".to_string(),
820                        ProviderConfigV4 {
821                            base_url: Some("http://127.0.0.1:9/v1".to_string()),
822                            inline_auth: UpstreamAuth::default(),
823                            ..ProviderConfigV4::default()
824                        },
825                    ),
826                    (
827                        "relay-b".to_string(),
828                        ProviderConfigV4 {
829                            base_url: Some("http://127.0.0.1:10/v1".to_string()),
830                            inline_auth: UpstreamAuth::default(),
831                            ..ProviderConfigV4::default()
832                        },
833                    ),
834                ]),
835                routing: Some(RoutingConfigV4::ordered_failover(vec![
836                    "relay-a".to_string(),
837                    "relay-b".to_string(),
838                ])),
839                ..ServiceViewV4::default()
840            },
841            ..ProxyConfigV4::default()
842        };
843        let runtime = crate::config::compile_v4_to_runtime(&v4).expect("compile v4 runtime");
844        let proxy = ProxyService::new_with_v4_source(
845            Client::new(),
846            Arc::new(runtime),
847            Some(Arc::new(v4)),
848            "codex",
849            Arc::new(Mutex::new(HashMap::<String, LbState>::new())),
850        );
851
852        let response = codex_relay_capabilities_for_proxy(
853            &proxy,
854            CodexRelayCapabilitiesRequest {
855                provider_id: Some("relay-a".to_string()),
856                patch_mode: Some(CodexPatchMode::OfficialImagegenBridge),
857                compaction: Some(CodexCompactionStrategy::Local),
858                ..Default::default()
859            },
860        )
861        .await
862        .expect("capabilities response");
863
864        assert_eq!(response.compaction, CodexCompactionStrategy::Local);
865        assert_eq!(
866            response.expected.provider_identity,
867            CodexProviderIdentity::HelperRelay
868        );
869        assert_eq!(
870            response.expected.remote_compaction_v1.support,
871            CodexCapabilitySupport::Unsupported
872        );
873        assert!(
874            response.continuity.warnings.is_empty(),
875            "local compaction should not warn about remote compact continuity domains"
876        );
877        assert!(
878            !response
879                .mismatches
880                .iter()
881                .any(|mismatch| mismatch.capability == "remote_compaction_v1"),
882            "local compaction should not require /responses/compact support"
883        );
884    }
885
886    #[tokio::test]
887    async fn codex_relay_capabilities_rejects_invalid_compaction_combination() {
888        let v4 = ProxyConfigV4 {
889            codex: ServiceViewV4 {
890                providers: BTreeMap::from([(
891                    "relay-a".to_string(),
892                    ProviderConfigV4 {
893                        base_url: Some("http://127.0.0.1:9/v1".to_string()),
894                        inline_auth: UpstreamAuth::default(),
895                        ..ProviderConfigV4::default()
896                    },
897                )]),
898                routing: Some(RoutingConfigV4::ordered_failover(vec![
899                    "relay-a".to_string(),
900                ])),
901                ..ServiceViewV4::default()
902            },
903            ..ProxyConfigV4::default()
904        };
905        let runtime = crate::config::compile_v4_to_runtime(&v4).expect("compile v4 runtime");
906        let proxy = ProxyService::new_with_v4_source(
907            Client::new(),
908            Arc::new(runtime),
909            Some(Arc::new(v4)),
910            "codex",
911            Arc::new(Mutex::new(HashMap::<String, LbState>::new())),
912        );
913
914        let err = codex_relay_capabilities_for_proxy(
915            &proxy,
916            CodexRelayCapabilitiesRequest {
917                provider_id: Some("relay-a".to_string()),
918                patch_mode: Some(CodexPatchMode::Default),
919                compaction: Some(CodexCompactionStrategy::RemoteV1),
920                ..Default::default()
921            },
922        )
923        .await
924        .expect_err("remote compaction should require official preset");
925
926        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
927        assert!(
928            err.message()
929                .contains("remote compaction strategies require --preset official-relay")
930        );
931    }
932
933    #[tokio::test]
934    async fn codex_relay_capabilities_does_not_infer_official_openai_domain_from_same_host() {
935        let v4 = ProxyConfigV4 {
936            codex: ServiceViewV4 {
937                providers: BTreeMap::from([
938                    (
939                        "openai-a".to_string(),
940                        ProviderConfigV4 {
941                            base_url: Some("https://api.openai.com/v1".to_string()),
942                            inline_auth: UpstreamAuth::default(),
943                            ..ProviderConfigV4::default()
944                        },
945                    ),
946                    (
947                        "openai-b".to_string(),
948                        ProviderConfigV4 {
949                            base_url: Some("https://api.openai.com/v1".to_string()),
950                            inline_auth: UpstreamAuth::default(),
951                            ..ProviderConfigV4::default()
952                        },
953                    ),
954                ]),
955                routing: Some(RoutingConfigV4::ordered_failover(vec![
956                    "openai-a".to_string(),
957                    "openai-b".to_string(),
958                ])),
959                ..ServiceViewV4::default()
960            },
961            ..ProxyConfigV4::default()
962        };
963        let runtime = crate::config::compile_v4_to_runtime(&v4).expect("compile v4 runtime");
964        let proxy = ProxyService::new_with_v4_source(
965            Client::new(),
966            Arc::new(runtime),
967            Some(Arc::new(v4)),
968            "codex",
969            Arc::new(Mutex::new(HashMap::<String, LbState>::new())),
970        );
971
972        let response = codex_relay_capabilities_for_proxy(
973            &proxy,
974            CodexRelayCapabilitiesRequest {
975                provider_id: Some("openai-a".to_string()),
976                patch_mode: Some(CodexPatchMode::OfficialRelayBridge),
977                ..Default::default()
978            },
979        )
980        .await
981        .expect("capabilities response");
982
983        assert_eq!(
984            response.continuity.selected_domain.key,
985            "provider_endpoint:codex/openai-a/default"
986        );
987        assert!(!response.continuity.selected_domain.explicit);
988        assert_eq!(response.continuity.same_domain_endpoint_count, 1);
989        assert!(!response.continuity.can_state_bound_failover_within_domain);
990        assert!(
991            response
992                .continuity
993                .warnings
994                .iter()
995                .any(|warning| warning.contains("no explicit continuity_domain"))
996        );
997    }
998
999    #[tokio::test]
1000    async fn codex_relay_capabilities_reports_shared_explicit_continuity_domain() {
1001        let v4 = ProxyConfigV4 {
1002            codex: ServiceViewV4 {
1003                providers: BTreeMap::from([
1004                    (
1005                        "relay-a".to_string(),
1006                        ProviderConfigV4 {
1007                            base_url: Some("http://127.0.0.1:9/v1".to_string()),
1008                            continuity_domain: Some("relay-cluster".to_string()),
1009                            inline_auth: UpstreamAuth::default(),
1010                            ..ProviderConfigV4::default()
1011                        },
1012                    ),
1013                    (
1014                        "relay-b".to_string(),
1015                        ProviderConfigV4 {
1016                            base_url: Some("http://127.0.0.1:10/v1".to_string()),
1017                            continuity_domain: Some("relay-cluster".to_string()),
1018                            inline_auth: UpstreamAuth::default(),
1019                            ..ProviderConfigV4::default()
1020                        },
1021                    ),
1022                ]),
1023                routing: Some(RoutingConfigV4::ordered_failover(vec![
1024                    "relay-a".to_string(),
1025                    "relay-b".to_string(),
1026                ])),
1027                ..ServiceViewV4::default()
1028            },
1029            ..ProxyConfigV4::default()
1030        };
1031        let runtime = crate::config::compile_v4_to_runtime(&v4).expect("compile v4 runtime");
1032        let proxy = ProxyService::new_with_v4_source(
1033            Client::new(),
1034            Arc::new(runtime),
1035            Some(Arc::new(v4)),
1036            "codex",
1037            Arc::new(Mutex::new(HashMap::<String, LbState>::new())),
1038        );
1039
1040        let response = codex_relay_capabilities_for_proxy(
1041            &proxy,
1042            CodexRelayCapabilitiesRequest {
1043                provider_id: Some("relay-a".to_string()),
1044                patch_mode: Some(CodexPatchMode::OfficialRelayBridge),
1045                ..Default::default()
1046            },
1047        )
1048        .await
1049        .expect("capabilities response");
1050
1051        assert_eq!(
1052            response.continuity.selected_domain.key,
1053            "explicit:codex/relay-cluster"
1054        );
1055        assert!(response.continuity.selected_domain.explicit);
1056        assert_eq!(response.continuity.same_domain_endpoint_count, 2);
1057        assert!(response.continuity.can_state_bound_failover_within_domain);
1058        assert!(
1059            response
1060                .continuity
1061                .recommendations
1062                .iter()
1063                .any(|recommendation| recommendation.contains("may fail over across 2 endpoints"))
1064        );
1065    }
1066}