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