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}