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}