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