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::CodexPatchMode;
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(default, alias = "patch_preset")]
40    pub patch_mode: Option<CodexPatchMode>,
41    #[serde(default)]
42    pub responses_websocket: Option<bool>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct CodexRelayCapabilitiesResponse {
47    pub api_version: u32,
48    pub service_name: String,
49    pub station_name: String,
50    pub upstream_index: usize,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub provider_id: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub endpoint_id: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub provider_endpoint_key: Option<String>,
57    pub upstream_base_url: String,
58    pub patch_mode: CodexPatchMode,
59    pub responses_websocket: bool,
60    pub model: Option<String>,
61    pub expected: CodexCapabilityProfile,
62    pub observed: CodexRelayCapabilitiesObserved,
63    pub recommendation: CodexPatchModeRecommendation,
64    #[serde(default)]
65    pub continuity: CodexRelayContinuityDiagnostics,
66    pub mismatches: Vec<CodexRelayCapabilityMismatch>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CodexRelayCapabilitiesObserved {
71    pub models: CodexRelayProbeResult,
72    pub responses: CodexRelayProbeResult,
73    pub responses_compact: CodexRelayProbeResult,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct CodexRelayCapabilityMismatch {
78    pub capability: String,
79    pub expected: String,
80    pub observed: String,
81    pub reason: String,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct CodexRelayContinuityDiagnostics {
86    pub selected_domain: CodexRelayContinuityDomainSummary,
87    pub same_domain_endpoint_count: usize,
88    pub configured_endpoint_count: usize,
89    pub affinity_policy: Option<String>,
90    pub can_state_bound_failover_within_domain: bool,
91    pub warnings: Vec<String>,
92    pub recommendations: Vec<String>,
93}
94
95impl Default for CodexRelayContinuityDiagnostics {
96    fn default() -> Self {
97        Self {
98            selected_domain: CodexRelayContinuityDomainSummary::default(),
99            same_domain_endpoint_count: 1,
100            configured_endpoint_count: 1,
101            affinity_policy: None,
102            can_state_bound_failover_within_domain: false,
103            warnings: Vec::new(),
104            recommendations: Vec::new(),
105        }
106    }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct CodexRelayContinuityDomainSummary {
111    pub key: String,
112    pub explicit: bool,
113}
114
115impl Default for CodexRelayContinuityDomainSummary {
116    fn default() -> Self {
117        Self {
118            key: "unknown".to_string(),
119            explicit: false,
120        }
121    }
122}
123
124pub(super) async fn codex_relay_capabilities_for_proxy(
125    proxy: &ProxyService,
126    payload: CodexRelayCapabilitiesRequest,
127) -> Result<CodexRelayCapabilitiesResponse, ProxyControlError> {
128    if proxy.service_name != "codex" {
129        return Err(ProxyControlError::new(
130            StatusCode::BAD_REQUEST,
131            "Codex relay capabilities are only available for the codex service",
132        ));
133    }
134
135    let cfg = proxy.config.snapshot().await;
136    let mgr = proxy.service_manager(cfg.as_ref());
137    let target = select_codex_relay_target(
138        mgr,
139        CodexRelayTargetSelection {
140            station_name: payload.station_name.as_deref(),
141            upstream_index: payload.upstream_index,
142            provider_id: payload.provider_id.as_deref(),
143            endpoint_id: payload.endpoint_id.as_deref(),
144        },
145    )?;
146    let patch_mode = payload
147        .patch_mode
148        .or_else(current_codex_switch_patch_mode)
149        .or_else(|| {
150            crate::config::codex_client_patch_config_from_config_file()
151                .ok()
152                .map(|cfg| cfg.preset)
153        })
154        .unwrap_or_default();
155    let responses_websocket = payload
156        .responses_websocket
157        .or_else(current_codex_switch_responses_websocket)
158        .or_else(|| {
159            crate::config::codex_client_patch_config_from_config_file()
160                .ok()
161                .map(|cfg| cfg.options.responses_websocket)
162        })
163        .unwrap_or(false);
164    let model = payload
165        .model
166        .as_deref()
167        .map(str::trim)
168        .filter(|value| !value.is_empty())
169        .map(ToOwned::to_owned);
170
171    let probe_client = CodexRelayProbeClient::new(proxy.client.clone());
172    let observations = run_capability_probe_cases(&probe_client, &target.upstream).await;
173    let models_observation = observation_for_kind(&observations, CodexRelayProbeKind::Models);
174
175    let expected = build_expected_profile(
176        patch_mode,
177        responses_websocket,
178        model.as_deref(),
179        models_observation,
180    );
181    let observed = build_observed_from_probe_observations(&observations);
182    let recommendation = build_recommendation(patch_mode, &expected, &observed);
183    let mismatches = build_mismatches(&expected, &observed);
184    let v4_source = proxy.config.v4_snapshot().await;
185    let continuity = build_continuity_diagnostics(
186        proxy.service_name,
187        v4_source.as_deref().map(|cfg| &cfg.codex),
188        &target,
189        patch_mode,
190        responses_websocket,
191    );
192
193    let response = CodexRelayCapabilitiesResponse {
194        api_version: 1,
195        service_name: proxy.service_name.to_string(),
196        station_name: target.station_name,
197        upstream_index: target.upstream_index,
198        provider_id: target.provider_id,
199        endpoint_id: target.endpoint_id,
200        provider_endpoint_key: target.provider_endpoint_key,
201        upstream_base_url: target.upstream.base_url,
202        patch_mode,
203        responses_websocket,
204        model,
205        expected,
206        observed,
207        recommendation,
208        continuity,
209        mismatches,
210    };
211    if let Err(error) = super::codex_relay_evidence::append_codex_relay_capabilities_evidence(
212        &response,
213        "proxy_service",
214    ) {
215        tracing::warn!("failed to write Codex relay capability evidence: {}", error);
216    }
217    Ok(response)
218}
219
220async fn run_capability_probe_cases(
221    probe_client: &CodexRelayProbeClient,
222    upstream: &crate::config::UpstreamConfig,
223) -> Vec<CodexRelayProbeObservation> {
224    let mut observations = Vec::with_capacity(codex_relay_probe_cases().len());
225    for case in codex_relay_probe_cases() {
226        observations.push(
227            probe_client
228                .probe_upstream_observation(upstream, &case.spec())
229                .await,
230        );
231    }
232    observations
233}
234
235fn build_observed_from_probe_observations(
236    observations: &[CodexRelayProbeObservation],
237) -> CodexRelayCapabilitiesObserved {
238    CodexRelayCapabilitiesObserved {
239        models: observation_for_kind(observations, CodexRelayProbeKind::Models)
240            .result
241            .clone(),
242        responses: observation_for_kind(observations, CodexRelayProbeKind::Responses)
243            .result
244            .clone(),
245        responses_compact: observation_for_kind(
246            observations,
247            CodexRelayProbeKind::ResponsesCompact,
248        )
249        .result
250        .clone(),
251    }
252}
253
254fn observation_for_kind(
255    observations: &[CodexRelayProbeObservation],
256    kind: CodexRelayProbeKind,
257) -> &CodexRelayProbeObservation {
258    observations
259        .iter()
260        .find(|observation| observation.result.kind == kind)
261        .expect("Codex relay probe registry must include all observed response fields")
262}
263
264fn current_codex_switch_patch_mode() -> Option<CodexPatchMode> {
265    crate::codex_integration::codex_switch_status()
266        .ok()
267        .and_then(|status| status.patch_mode)
268}
269
270fn current_codex_switch_responses_websocket() -> Option<bool> {
271    crate::codex_integration::codex_switch_status()
272        .ok()
273        .and_then(|status| status.supports_websockets)
274}
275
276fn build_expected_profile(
277    patch_mode: CodexPatchMode,
278    responses_websocket: bool,
279    model: Option<&str>,
280    models_observation: &CodexRelayProbeObservation,
281) -> CodexCapabilityProfile {
282    let model_catalog = translated_models_catalog(models_observation, model).unwrap_or_else(|| {
283        CodexModelCatalogProfile::unknown(models_observation.result.reason.clone())
284    });
285    CodexCapabilityProfile::for_input(CodexCapabilityProfileInput::from_patch_config(
286        patch_mode,
287        crate::codex_integration::CodexSwitchOptions {
288            responses_websocket,
289        },
290        model_catalog,
291    ))
292}
293
294fn translated_models_catalog(
295    models_observation: &CodexRelayProbeObservation,
296    model: Option<&str>,
297) -> Option<CodexModelCatalogProfile> {
298    let status = models_observation.status?;
299    if !status.is_success() {
300        return None;
301    }
302    let body = maybe_decode_models_response_body(
303        "codex",
304        "/models",
305        &models_observation.headers,
306        models_observation.body.clone(),
307    );
308    let value = serde_json::from_slice::<Value>(body.as_ref()).ok()?;
309    Some(CodexModelCatalogProfile::from_models_response_json(
310        &value, model,
311    ))
312}
313
314fn build_mismatches(
315    expected: &CodexCapabilityProfile,
316    observed: &CodexRelayCapabilitiesObserved,
317) -> Vec<CodexRelayCapabilityMismatch> {
318    let mut out = Vec::new();
319    push_endpoint_mismatch(
320        &mut out,
321        "responses",
322        &CodexCapabilityDecision::supported("Codex model requests require a /responses endpoint"),
323        &observed.responses,
324    );
325    push_endpoint_mismatch(
326        &mut out,
327        "remote_compaction_v1",
328        &expected.remote_compaction_v1,
329        &observed.responses_compact,
330    );
331    if observed.models.translation_required {
332        out.push(CodexRelayCapabilityMismatch {
333            capability: "model_catalog".to_string(),
334            expected: "codex_models".to_string(),
335            observed: "openai_data_list".to_string(),
336            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(),
337        });
338    }
339    out
340}
341
342fn build_continuity_diagnostics(
343    service_name: &str,
344    view: Option<&ServiceViewV4>,
345    target: &SelectedCodexRelayTarget,
346    patch_mode: CodexPatchMode,
347    responses_websocket: bool,
348) -> CodexRelayContinuityDiagnostics {
349    let fallback_domain = target
350        .provider_endpoint_key
351        .as_deref()
352        .map(|key| format!("provider_endpoint:{key}"))
353        .unwrap_or_else(|| {
354            format!(
355                "legacy:{}/{}/{}",
356                service_name, target.station_name, target.upstream_index
357            )
358        });
359    let mut selected_domain = CodexRelayContinuityDomainSummary {
360        key: fallback_domain,
361        explicit: false,
362    };
363    let mut same_domain_endpoint_count = 1usize;
364    let mut configured_endpoint_count = 1usize;
365    let mut affinity_policy = None;
366
367    if let Some(view) = view {
368        let routing = effective_v4_routing(view);
369        affinity_policy = Some(routing_affinity_policy_label(routing.affinity_policy).to_string());
370        if let Ok(template) = compile_v4_route_plan_template_for_compat_runtime(service_name, view)
371        {
372            let topology = template.continuity_topology();
373            configured_endpoint_count = topology.configured_provider_endpoint_count().max(1);
374            if let Some(provider_endpoint_key) = target.provider_endpoint_key.as_deref()
375                && let Some(summary) = topology.selected_domain_summary(provider_endpoint_key)
376            {
377                selected_domain = domain_summary(&summary.domain);
378                same_domain_endpoint_count = summary.same_domain_endpoint_count;
379            }
380        }
381    }
382
383    let official_relay = patch_mode.enables_official_relay_features();
384    let can_state_bound_failover_within_domain =
385        selected_domain.explicit && same_domain_endpoint_count > 1;
386    let mut warnings = Vec::new();
387    let mut recommendations = Vec::new();
388
389    if official_relay && !selected_domain.explicit && configured_endpoint_count > 1 {
390        warnings.push(
391            "official relay preset is active with multiple configured provider endpoints, but the selected endpoint has no explicit continuity_domain".to_string(),
392        );
393        recommendations.push(
394            "Set the same continuity_domain only on provider endpoints that intentionally share encrypted response state; otherwise keep provider-endpoint isolation.".to_string(),
395        );
396    }
397
398    if can_state_bound_failover_within_domain {
399        recommendations.push(format!(
400            "State-bound compact may fail over across {same_domain_endpoint_count} endpoints in explicit continuity domain {}.",
401            selected_domain.key
402        ));
403    } else {
404        recommendations.push(
405            "State-bound compact stays isolated to the selected provider endpoint unless a shared continuity_domain is configured.".to_string(),
406        );
407    }
408
409    if matches!(
410        affinity_policy.as_deref(),
411        Some("preferred-group") | Some("off")
412    ) && official_relay
413        && configured_endpoint_count > 1
414    {
415        warnings.push(
416            "official relay presets with multiple provider endpoints should use fallback-sticky or hard affinity when encrypted compact state matters".to_string(),
417        );
418    }
419
420    if responses_websocket && !selected_domain.explicit && configured_endpoint_count > 1 {
421        warnings.push(
422            "Responses WebSocket compact uses the same state-bound continuity rules; do not enable cross-provider continuity without explicit continuity_domain".to_string(),
423        );
424    }
425
426    CodexRelayContinuityDiagnostics {
427        selected_domain,
428        same_domain_endpoint_count,
429        configured_endpoint_count,
430        affinity_policy,
431        can_state_bound_failover_within_domain,
432        warnings,
433        recommendations,
434    }
435}
436
437fn domain_summary(domain: &ContinuityDomainKey) -> CodexRelayContinuityDomainSummary {
438    CodexRelayContinuityDomainSummary {
439        key: domain.stable_key(),
440        explicit: domain.is_explicit(),
441    }
442}
443
444fn routing_affinity_policy_label(policy: crate::config::RoutingAffinityPolicyV5) -> &'static str {
445    match policy {
446        crate::config::RoutingAffinityPolicyV5::Off => "off",
447        crate::config::RoutingAffinityPolicyV5::PreferredGroup => "preferred-group",
448        crate::config::RoutingAffinityPolicyV5::FallbackSticky => "fallback-sticky",
449        crate::config::RoutingAffinityPolicyV5::Hard => "hard",
450    }
451}
452
453fn build_recommendation(
454    current_patch_mode: CodexPatchMode,
455    expected: &CodexCapabilityProfile,
456    observed: &CodexRelayCapabilitiesObserved,
457) -> CodexPatchModeRecommendation {
458    CodexPatchModeRecommendation::for_input(CodexPatchModeRecommendationInput {
459        current_patch_mode,
460        model_catalog: expected.model_catalog.clone(),
461        responses: observed_support_to_capability_support(observed.responses.support),
462        responses_compact: observed_support_to_capability_support(
463            observed.responses_compact.support,
464        ),
465    })
466}
467
468fn observed_support_to_capability_support(
469    support: super::CodexRelayProbeSupport,
470) -> CodexCapabilitySupport {
471    match support {
472        super::CodexRelayProbeSupport::Supported => CodexCapabilitySupport::Supported,
473        super::CodexRelayProbeSupport::Unsupported => CodexCapabilitySupport::Unsupported,
474        super::CodexRelayProbeSupport::Unknown => CodexCapabilitySupport::Unknown,
475    }
476}
477
478fn push_endpoint_mismatch(
479    out: &mut Vec<CodexRelayCapabilityMismatch>,
480    capability: &str,
481    expected: &CodexCapabilityDecision,
482    observed: &CodexRelayProbeResult,
483) {
484    let expected_label = support_label(expected.support);
485    let observed_label = format!(
486        "{} via {}",
487        probe_support_label(observed.support),
488        probe_confidence_label(observed.confidence)
489    );
490    if expected.support == crate::codex_capability_profile::CodexCapabilitySupport::Supported
491        && observed.support != super::CodexRelayProbeSupport::Supported
492    {
493        out.push(CodexRelayCapabilityMismatch {
494            capability: capability.to_string(),
495            expected: expected_label.to_string(),
496            observed: observed_label,
497            reason: observed.reason.clone(),
498        });
499    }
500}
501
502fn support_label(support: crate::codex_capability_profile::CodexCapabilitySupport) -> &'static str {
503    match support {
504        crate::codex_capability_profile::CodexCapabilitySupport::Unknown => "unknown",
505        crate::codex_capability_profile::CodexCapabilitySupport::Supported => "supported",
506        crate::codex_capability_profile::CodexCapabilitySupport::Unsupported => "unsupported",
507    }
508}
509
510fn probe_support_label(support: super::CodexRelayProbeSupport) -> &'static str {
511    match support {
512        super::CodexRelayProbeSupport::Supported => "supported",
513        super::CodexRelayProbeSupport::Unsupported => "unsupported",
514        super::CodexRelayProbeSupport::Unknown => "unknown",
515    }
516}
517
518fn probe_confidence_label(confidence: super::CodexRelayProbeConfidence) -> &'static str {
519    match confidence {
520        super::CodexRelayProbeConfidence::SuccessStatus => "success_status",
521        super::CodexRelayProbeConfidence::EndpointValidation => "endpoint_validation",
522        super::CodexRelayProbeConfidence::ErrorClassification => "error_classification",
523        super::CodexRelayProbeConfidence::Transport => "transport",
524        super::CodexRelayProbeConfidence::Malformed => "malformed",
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use std::collections::{BTreeMap, HashMap};
531    use std::sync::{Arc, Mutex};
532
533    use reqwest::Client;
534
535    use super::*;
536    use crate::config::{
537        ProviderConfigV4, ProxyConfigV4, RoutingConfigV4, ServiceViewV4, UpstreamAuth,
538    };
539    use crate::lb::LbState;
540
541    fn probe_result(
542        kind: CodexRelayProbeKind,
543        support: super::super::CodexRelayProbeSupport,
544    ) -> CodexRelayProbeResult {
545        CodexRelayProbeResult {
546            kind,
547            support,
548            confidence: super::super::CodexRelayProbeConfidence::SuccessStatus,
549            status_code: Some(200),
550            response_shape: Some("ok".to_string()),
551            translation_required: false,
552            error_class: None,
553            reason: "ok".to_string(),
554        }
555    }
556
557    fn observation(kind: CodexRelayProbeKind) -> CodexRelayProbeObservation {
558        CodexRelayProbeObservation {
559            result: probe_result(kind, super::super::CodexRelayProbeSupport::Supported),
560            status: Some(StatusCode::OK),
561            headers: axum::http::HeaderMap::new(),
562            body: axum::body::Bytes::new(),
563        }
564    }
565
566    #[test]
567    fn codex_relay_capabilities_observed_shape_is_built_from_probe_registry() {
568        let observations = codex_relay_probe_cases()
569            .iter()
570            .map(|case| observation(case.kind))
571            .collect::<Vec<_>>();
572
573        let observed = build_observed_from_probe_observations(&observations);
574
575        assert_eq!(observed.models.kind, CodexRelayProbeKind::Models);
576        assert_eq!(observed.responses.kind, CodexRelayProbeKind::Responses);
577        assert_eq!(
578            observed.responses_compact.kind,
579            CodexRelayProbeKind::ResponsesCompact
580        );
581    }
582
583    #[tokio::test]
584    async fn codex_relay_capabilities_targets_route_graph_provider_id() {
585        let v4 = ProxyConfigV4 {
586            codex: ServiceViewV4 {
587                providers: BTreeMap::from([
588                    (
589                        "input8".to_string(),
590                        ProviderConfigV4 {
591                            base_url: Some("http://127.0.0.1:9/v1".to_string()),
592                            inline_auth: UpstreamAuth::default(),
593                            ..ProviderConfigV4::default()
594                        },
595                    ),
596                    (
597                        "ciii".to_string(),
598                        ProviderConfigV4 {
599                            base_url: Some("http://127.0.0.1:10/v1".to_string()),
600                            inline_auth: UpstreamAuth::default(),
601                            ..ProviderConfigV4::default()
602                        },
603                    ),
604                ]),
605                routing: Some(RoutingConfigV4::ordered_failover(vec![
606                    "input8".to_string(),
607                    "ciii".to_string(),
608                ])),
609                ..ServiceViewV4::default()
610            },
611            ..ProxyConfigV4::default()
612        };
613        let runtime = crate::config::compile_v4_to_runtime(&v4).expect("compile v4 runtime");
614        let proxy = ProxyService::new_with_v4_source(
615            Client::new(),
616            Arc::new(runtime),
617            Some(Arc::new(v4)),
618            "codex",
619            Arc::new(Mutex::new(HashMap::<String, LbState>::new())),
620        );
621
622        let response = codex_relay_capabilities_for_proxy(
623            &proxy,
624            CodexRelayCapabilitiesRequest {
625                provider_id: Some("ciii".to_string()),
626                model: Some("gpt-5.5".to_string()),
627                ..Default::default()
628            },
629        )
630        .await
631        .expect("capabilities response");
632
633        assert_eq!(response.station_name, "routing");
634        assert_eq!(response.upstream_index, 1);
635        assert_eq!(response.provider_id.as_deref(), Some("ciii"));
636        assert_eq!(response.endpoint_id.as_deref(), Some("default"));
637        assert_eq!(
638            response.provider_endpoint_key.as_deref(),
639            Some("codex/ciii/default")
640        );
641        assert_eq!(response.upstream_base_url, "http://127.0.0.1:10/v1");
642    }
643
644    #[tokio::test]
645    async fn codex_relay_capabilities_recommends_explicit_continuity_domain_for_multi_relay() {
646        let v4 = ProxyConfigV4 {
647            codex: ServiceViewV4 {
648                providers: BTreeMap::from([
649                    (
650                        "relay-a".to_string(),
651                        ProviderConfigV4 {
652                            base_url: Some("http://127.0.0.1:9/v1".to_string()),
653                            inline_auth: UpstreamAuth::default(),
654                            ..ProviderConfigV4::default()
655                        },
656                    ),
657                    (
658                        "relay-b".to_string(),
659                        ProviderConfigV4 {
660                            base_url: Some("http://127.0.0.1:10/v1".to_string()),
661                            inline_auth: UpstreamAuth::default(),
662                            ..ProviderConfigV4::default()
663                        },
664                    ),
665                ]),
666                routing: Some(RoutingConfigV4::ordered_failover(vec![
667                    "relay-a".to_string(),
668                    "relay-b".to_string(),
669                ])),
670                ..ServiceViewV4::default()
671            },
672            ..ProxyConfigV4::default()
673        };
674        let runtime = crate::config::compile_v4_to_runtime(&v4).expect("compile v4 runtime");
675        let proxy = ProxyService::new_with_v4_source(
676            Client::new(),
677            Arc::new(runtime),
678            Some(Arc::new(v4)),
679            "codex",
680            Arc::new(Mutex::new(HashMap::<String, LbState>::new())),
681        );
682
683        let response = codex_relay_capabilities_for_proxy(
684            &proxy,
685            CodexRelayCapabilitiesRequest {
686                provider_id: Some("relay-a".to_string()),
687                patch_mode: Some(CodexPatchMode::OfficialRelayBridge),
688                ..Default::default()
689            },
690        )
691        .await
692        .expect("capabilities response");
693
694        assert_eq!(
695            response.continuity.selected_domain.key,
696            "provider_endpoint:codex/relay-a/default"
697        );
698        assert!(!response.continuity.selected_domain.explicit);
699        assert_eq!(response.continuity.configured_endpoint_count, 2);
700        assert_eq!(response.continuity.same_domain_endpoint_count, 1);
701        assert!(!response.continuity.can_state_bound_failover_within_domain);
702        assert!(
703            response
704                .continuity
705                .warnings
706                .iter()
707                .any(|warning| warning.contains("no explicit continuity_domain"))
708        );
709    }
710
711    #[tokio::test]
712    async fn codex_relay_capabilities_does_not_infer_official_openai_domain_from_same_host() {
713        let v4 = ProxyConfigV4 {
714            codex: ServiceViewV4 {
715                providers: BTreeMap::from([
716                    (
717                        "openai-a".to_string(),
718                        ProviderConfigV4 {
719                            base_url: Some("https://api.openai.com/v1".to_string()),
720                            inline_auth: UpstreamAuth::default(),
721                            ..ProviderConfigV4::default()
722                        },
723                    ),
724                    (
725                        "openai-b".to_string(),
726                        ProviderConfigV4 {
727                            base_url: Some("https://api.openai.com/v1".to_string()),
728                            inline_auth: UpstreamAuth::default(),
729                            ..ProviderConfigV4::default()
730                        },
731                    ),
732                ]),
733                routing: Some(RoutingConfigV4::ordered_failover(vec![
734                    "openai-a".to_string(),
735                    "openai-b".to_string(),
736                ])),
737                ..ServiceViewV4::default()
738            },
739            ..ProxyConfigV4::default()
740        };
741        let runtime = crate::config::compile_v4_to_runtime(&v4).expect("compile v4 runtime");
742        let proxy = ProxyService::new_with_v4_source(
743            Client::new(),
744            Arc::new(runtime),
745            Some(Arc::new(v4)),
746            "codex",
747            Arc::new(Mutex::new(HashMap::<String, LbState>::new())),
748        );
749
750        let response = codex_relay_capabilities_for_proxy(
751            &proxy,
752            CodexRelayCapabilitiesRequest {
753                provider_id: Some("openai-a".to_string()),
754                patch_mode: Some(CodexPatchMode::OfficialRelayBridge),
755                ..Default::default()
756            },
757        )
758        .await
759        .expect("capabilities response");
760
761        assert_eq!(
762            response.continuity.selected_domain.key,
763            "provider_endpoint:codex/openai-a/default"
764        );
765        assert!(!response.continuity.selected_domain.explicit);
766        assert_eq!(response.continuity.same_domain_endpoint_count, 1);
767        assert!(!response.continuity.can_state_bound_failover_within_domain);
768        assert!(
769            response
770                .continuity
771                .warnings
772                .iter()
773                .any(|warning| warning.contains("no explicit continuity_domain"))
774        );
775    }
776
777    #[tokio::test]
778    async fn codex_relay_capabilities_reports_shared_explicit_continuity_domain() {
779        let v4 = ProxyConfigV4 {
780            codex: ServiceViewV4 {
781                providers: BTreeMap::from([
782                    (
783                        "relay-a".to_string(),
784                        ProviderConfigV4 {
785                            base_url: Some("http://127.0.0.1:9/v1".to_string()),
786                            continuity_domain: Some("relay-cluster".to_string()),
787                            inline_auth: UpstreamAuth::default(),
788                            ..ProviderConfigV4::default()
789                        },
790                    ),
791                    (
792                        "relay-b".to_string(),
793                        ProviderConfigV4 {
794                            base_url: Some("http://127.0.0.1:10/v1".to_string()),
795                            continuity_domain: Some("relay-cluster".to_string()),
796                            inline_auth: UpstreamAuth::default(),
797                            ..ProviderConfigV4::default()
798                        },
799                    ),
800                ]),
801                routing: Some(RoutingConfigV4::ordered_failover(vec![
802                    "relay-a".to_string(),
803                    "relay-b".to_string(),
804                ])),
805                ..ServiceViewV4::default()
806            },
807            ..ProxyConfigV4::default()
808        };
809        let runtime = crate::config::compile_v4_to_runtime(&v4).expect("compile v4 runtime");
810        let proxy = ProxyService::new_with_v4_source(
811            Client::new(),
812            Arc::new(runtime),
813            Some(Arc::new(v4)),
814            "codex",
815            Arc::new(Mutex::new(HashMap::<String, LbState>::new())),
816        );
817
818        let response = codex_relay_capabilities_for_proxy(
819            &proxy,
820            CodexRelayCapabilitiesRequest {
821                provider_id: Some("relay-a".to_string()),
822                patch_mode: Some(CodexPatchMode::OfficialRelayBridge),
823                ..Default::default()
824            },
825        )
826        .await
827        .expect("capabilities response");
828
829        assert_eq!(
830            response.continuity.selected_domain.key,
831            "explicit:codex/relay-cluster"
832        );
833        assert!(response.continuity.selected_domain.explicit);
834        assert_eq!(response.continuity.same_domain_endpoint_count, 2);
835        assert!(response.continuity.can_state_bound_failover_within_domain);
836        assert!(
837            response
838                .continuity
839                .recommendations
840                .iter()
841                .any(|recommendation| recommendation.contains("may fail over across 2 endpoints"))
842        );
843    }
844}