1use axum::body::Bytes;
2use axum::http::{HeaderMap, HeaderValue, Method, StatusCode};
3use futures_util::StreamExt;
4use serde::Deserialize;
5use serde::Serialize;
6use serde_json::Value;
7
8use crate::config::UpstreamConfig;
9
10use super::classify::{ROUTING_MISMATCH_CAPABILITY_CLASS, classify_upstream_response};
11use super::models_compat::maybe_decode_models_response_body_without_translation;
12
13const MAX_PROBE_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum CodexRelayProbeKind {
18 Models,
19 Responses,
20 ResponsesCompact,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum CodexRelayProbeSupport {
26 Supported,
27 Unsupported,
28 Unknown,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum CodexRelayProbeConfidence {
34 SuccessStatus,
35 EndpointValidation,
36 ErrorClassification,
37 Transport,
38 Malformed,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum CodexRelayProbeSideEffect {
44 ReadOnly,
45 ValidationOnly,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub(in crate::proxy) struct CodexRelayProbeCase {
50 pub kind: CodexRelayProbeKind,
51 pub capability: &'static str,
52 pub method: &'static str,
53 pub path: &'static str,
54 pub side_effect: CodexRelayProbeSideEffect,
55 body: CodexRelayProbeBody,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59enum CodexRelayProbeBody {
60 None,
61 EmptyJsonObject,
62}
63
64const CODEX_RELAY_PROBE_CASES: &[CodexRelayProbeCase] = &[
65 CodexRelayProbeCase {
66 kind: CodexRelayProbeKind::Models,
67 capability: "model_catalog",
68 method: "GET",
69 path: "/models",
70 side_effect: CodexRelayProbeSideEffect::ReadOnly,
71 body: CodexRelayProbeBody::None,
72 },
73 CodexRelayProbeCase {
74 kind: CodexRelayProbeKind::Responses,
75 capability: "responses",
76 method: "POST",
77 path: "/responses",
78 side_effect: CodexRelayProbeSideEffect::ValidationOnly,
79 body: CodexRelayProbeBody::EmptyJsonObject,
80 },
81 CodexRelayProbeCase {
82 kind: CodexRelayProbeKind::ResponsesCompact,
83 capability: "remote_compaction_v1",
84 method: "POST",
85 path: "/responses/compact",
86 side_effect: CodexRelayProbeSideEffect::ValidationOnly,
87 body: CodexRelayProbeBody::EmptyJsonObject,
88 },
89];
90
91pub(in crate::proxy) fn codex_relay_probe_cases() -> &'static [CodexRelayProbeCase] {
92 CODEX_RELAY_PROBE_CASES
93}
94
95impl CodexRelayProbeCase {
96 pub(in crate::proxy) fn for_kind(kind: CodexRelayProbeKind) -> &'static Self {
97 codex_relay_probe_cases()
98 .iter()
99 .find(|case| case.kind == kind)
100 .expect("Codex relay probe kind must be registered")
101 }
102
103 pub(in crate::proxy) fn spec(&self) -> CodexRelayProbeSpec {
104 CodexRelayProbeSpec {
105 kind: self.kind,
106 method: self.method.to_string(),
107 path: self.path.to_string(),
108 side_effect: self.side_effect,
109 body: match self.body {
110 CodexRelayProbeBody::None => None,
111 CodexRelayProbeBody::EmptyJsonObject => Some(serde_json::json!({})),
112 },
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct CodexRelayProbeSpec {
119 pub kind: CodexRelayProbeKind,
120 pub method: String,
121 pub path: String,
122 pub side_effect: CodexRelayProbeSideEffect,
123 pub body: Option<Value>,
124}
125
126impl CodexRelayProbeSpec {
127 pub fn for_kind(kind: CodexRelayProbeKind) -> Self {
128 CodexRelayProbeCase::for_kind(kind).spec()
129 }
130
131 fn method(&self) -> Method {
132 Method::from_bytes(self.method.as_bytes()).unwrap_or(Method::GET)
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct CodexRelayProbeResult {
138 pub kind: CodexRelayProbeKind,
139 pub support: CodexRelayProbeSupport,
140 pub confidence: CodexRelayProbeConfidence,
141 pub status_code: Option<u16>,
142 pub response_shape: Option<String>,
143 pub translation_required: bool,
144 pub error_class: Option<String>,
145 pub reason: String,
146}
147
148#[derive(Debug, Clone)]
149pub(in crate::proxy) struct CodexRelayProbeObservation {
150 pub result: CodexRelayProbeResult,
151 pub status: Option<StatusCode>,
152 pub headers: HeaderMap,
153 pub body: Bytes,
154}
155
156impl CodexRelayProbeResult {
157 fn supported(
158 kind: CodexRelayProbeKind,
159 confidence: CodexRelayProbeConfidence,
160 status_code: Option<u16>,
161 reason: impl Into<String>,
162 ) -> Self {
163 Self {
164 kind,
165 support: CodexRelayProbeSupport::Supported,
166 confidence,
167 status_code,
168 response_shape: None,
169 translation_required: false,
170 error_class: None,
171 reason: reason.into(),
172 }
173 }
174
175 fn unsupported(
176 kind: CodexRelayProbeKind,
177 confidence: CodexRelayProbeConfidence,
178 status_code: Option<u16>,
179 reason: impl Into<String>,
180 ) -> Self {
181 Self {
182 kind,
183 support: CodexRelayProbeSupport::Unsupported,
184 confidence,
185 status_code,
186 response_shape: None,
187 translation_required: false,
188 error_class: None,
189 reason: reason.into(),
190 }
191 }
192
193 fn unknown(
194 kind: CodexRelayProbeKind,
195 confidence: CodexRelayProbeConfidence,
196 status_code: Option<u16>,
197 reason: impl Into<String>,
198 ) -> Self {
199 Self {
200 kind,
201 support: CodexRelayProbeSupport::Unknown,
202 confidence,
203 status_code,
204 response_shape: None,
205 translation_required: false,
206 error_class: None,
207 reason: reason.into(),
208 }
209 }
210}
211
212pub fn classify_codex_relay_probe_response(
213 spec: &CodexRelayProbeSpec,
214 status: StatusCode,
215 headers: &HeaderMap,
216 body: &[u8],
217) -> CodexRelayProbeResult {
218 let body = if spec.kind == CodexRelayProbeKind::Models && status.is_success() {
219 maybe_decode_models_response_body_without_translation(
220 "codex",
221 "/models",
222 headers,
223 Bytes::copy_from_slice(body),
224 )
225 } else {
226 Bytes::copy_from_slice(body)
227 };
228 let status_code = status.as_u16();
229 if spec.kind == CodexRelayProbeKind::Models {
230 return classify_models_probe_response(spec.kind, status, body.as_ref());
231 }
232
233 let (error_class, _, _) = classify_upstream_response(status_code, headers, body.as_ref());
234 let mut result = classify_endpoint_probe_response(spec.kind, status, body.as_ref());
235 result.error_class = error_class;
236 if result.support == CodexRelayProbeSupport::Unknown
237 && result.error_class.as_deref() == Some(ROUTING_MISMATCH_CAPABILITY_CLASS)
238 {
239 result.support = CodexRelayProbeSupport::Supported;
240 result.confidence = CodexRelayProbeConfidence::ErrorClassification;
241 result.reason =
242 "endpoint exists but rejected the probe due to a model or capability mismatch"
243 .to_string();
244 }
245 result
246}
247
248fn classify_models_probe_response(
249 kind: CodexRelayProbeKind,
250 status: StatusCode,
251 body: &[u8],
252) -> CodexRelayProbeResult {
253 if is_unsupported_endpoint_status(status) {
254 return CodexRelayProbeResult::unsupported(
255 kind,
256 CodexRelayProbeConfidence::ErrorClassification,
257 Some(status.as_u16()),
258 "/models endpoint is not available on this relay",
259 );
260 }
261 if !status.is_success() {
262 return CodexRelayProbeResult::unknown(
263 kind,
264 CodexRelayProbeConfidence::ErrorClassification,
265 Some(status.as_u16()),
266 "models probe did not return a successful response",
267 );
268 }
269
270 let Ok(value) = serde_json::from_slice::<Value>(body) else {
271 return CodexRelayProbeResult::unknown(
272 kind,
273 CodexRelayProbeConfidence::Malformed,
274 Some(status.as_u16()),
275 "models probe returned non-JSON or malformed JSON",
276 );
277 };
278 if value.get("models").and_then(Value::as_array).is_some() {
279 let mut result = CodexRelayProbeResult::supported(
280 kind,
281 CodexRelayProbeConfidence::SuccessStatus,
282 Some(status.as_u16()),
283 "relay returned a Codex models catalog",
284 );
285 result.response_shape = Some("codex_models".to_string());
286 return result;
287 }
288 if value.get("data").and_then(Value::as_array).is_some() {
289 let mut result = CodexRelayProbeResult::supported(
290 kind,
291 CodexRelayProbeConfidence::SuccessStatus,
292 Some(status.as_u16()),
293 "relay returned an OpenAI models list that helper can translate",
294 );
295 result.response_shape = Some("openai_data_list".to_string());
296 result.translation_required = true;
297 return result;
298 }
299 CodexRelayProbeResult::unknown(
300 kind,
301 CodexRelayProbeConfidence::Malformed,
302 Some(status.as_u16()),
303 "models probe JSON does not contain `models` or `data` arrays",
304 )
305}
306
307fn classify_endpoint_probe_response(
308 kind: CodexRelayProbeKind,
309 status: StatusCode,
310 body: &[u8],
311) -> CodexRelayProbeResult {
312 if status.is_success() {
313 return CodexRelayProbeResult::supported(
314 kind,
315 CodexRelayProbeConfidence::SuccessStatus,
316 Some(status.as_u16()),
317 "endpoint accepted the probe request",
318 );
319 }
320 if is_unsupported_endpoint_status(status)
321 || (kind == CodexRelayProbeKind::ResponsesCompact
322 && body_mentions_compact_unsupported(body))
323 {
324 return CodexRelayProbeResult::unsupported(
325 kind,
326 CodexRelayProbeConfidence::ErrorClassification,
327 Some(status.as_u16()),
328 "endpoint is missing or explicitly reports unsupported capability",
329 );
330 }
331 if matches!(
332 status,
333 StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY
334 ) && looks_like_validation_error(body)
335 {
336 return CodexRelayProbeResult::supported(
337 kind,
338 CodexRelayProbeConfidence::EndpointValidation,
339 Some(status.as_u16()),
340 "endpoint exists and returned validation feedback for the validation-only probe",
341 );
342 }
343 CodexRelayProbeResult::unknown(
344 kind,
345 CodexRelayProbeConfidence::ErrorClassification,
346 Some(status.as_u16()),
347 "endpoint returned an inconclusive response",
348 )
349}
350
351fn is_unsupported_endpoint_status(status: StatusCode) -> bool {
352 matches!(
353 status,
354 StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED | StatusCode::NOT_IMPLEMENTED
355 )
356}
357
358fn body_mentions_compact_unsupported(body: &[u8]) -> bool {
359 let text = String::from_utf8_lossy(body).to_ascii_lowercase();
360 (text.contains("compact") || text.contains("compaction"))
361 && (text.contains("unsupported")
362 || text.contains("not supported")
363 || text.contains("not implemented")
364 || text.contains("not found"))
365}
366
367fn looks_like_validation_error(body: &[u8]) -> bool {
368 let Ok(value) = serde_json::from_slice::<Value>(body) else {
369 return false;
370 };
371 let lower = value.to_string().to_ascii_lowercase();
372 lower.contains("missing")
373 || lower.contains("required")
374 || lower.contains("invalid")
375 || lower.contains("validation")
376 || lower.contains("model")
377 || lower.contains("input")
378}
379
380#[derive(Debug, Clone)]
381pub struct CodexRelayProbeClient {
382 client: reqwest::Client,
383}
384
385impl CodexRelayProbeClient {
386 pub fn new(client: reqwest::Client) -> Self {
387 Self { client }
388 }
389
390 pub async fn probe_upstream(
391 &self,
392 upstream: &UpstreamConfig,
393 spec: &CodexRelayProbeSpec,
394 ) -> CodexRelayProbeResult {
395 self.probe_upstream_observation(upstream, spec).await.result
396 }
397
398 pub(in crate::proxy) async fn probe_upstream_observation(
399 &self,
400 upstream: &UpstreamConfig,
401 spec: &CodexRelayProbeSpec,
402 ) -> CodexRelayProbeObservation {
403 let url = match build_probe_url(&upstream.base_url, spec.path.as_str()) {
404 Ok(url) => url,
405 Err(error) => {
406 return transport_observation(spec.kind, None, error);
407 }
408 };
409
410 let mut headers = HeaderMap::new();
411 headers.insert(
412 axum::http::header::ACCEPT_ENCODING,
413 HeaderValue::from_static("identity"),
414 );
415 if spec.body.is_some() {
416 headers.insert(
417 axum::http::header::CONTENT_TYPE,
418 HeaderValue::from_static("application/json"),
419 );
420 }
421 if let Some(token) = upstream.auth.resolve_auth_token()
422 && let Ok(value) = HeaderValue::from_str(&format!("Bearer {token}"))
423 {
424 headers.insert(axum::http::header::AUTHORIZATION, value);
425 }
426 if let Some(key) = upstream.auth.resolve_api_key()
427 && let Ok(value) = HeaderValue::from_str(&key)
428 {
429 headers.insert("x-api-key", value);
430 }
431
432 let mut request = self
433 .client
434 .request(spec.method(), url)
435 .headers(headers)
436 .timeout(std::time::Duration::from_secs(15));
437 if let Some(body) = spec.body.as_ref() {
438 request = request.json(body);
439 }
440 let response = match request.send().await {
441 Ok(response) => response,
442 Err(error) => {
443 return transport_observation(
444 spec.kind,
445 None,
446 format!("transport error during probe: {error}"),
447 );
448 }
449 };
450
451 let status = StatusCode::from_u16(response.status().as_u16())
452 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
453 let headers = response.headers().clone();
454 let body = match read_limited_body(response, MAX_PROBE_RESPONSE_BYTES).await {
455 Ok(body) => body,
456 Err(error) => {
457 return transport_observation(spec.kind, Some(status), error);
458 }
459 };
460 let result = classify_codex_relay_probe_response(spec, status, &headers, body.as_ref());
461 CodexRelayProbeObservation {
462 result,
463 status: Some(status),
464 headers,
465 body,
466 }
467 }
468}
469
470fn transport_observation(
471 kind: CodexRelayProbeKind,
472 status: Option<StatusCode>,
473 reason: impl Into<String>,
474) -> CodexRelayProbeObservation {
475 CodexRelayProbeObservation {
476 result: CodexRelayProbeResult::unknown(
477 kind,
478 CodexRelayProbeConfidence::Transport,
479 status.map(|status| status.as_u16()),
480 reason,
481 ),
482 status,
483 headers: HeaderMap::new(),
484 body: Bytes::new(),
485 }
486}
487
488fn build_probe_url(base_url: &str, path: &str) -> Result<reqwest::Url, String> {
489 let base = base_url.trim_end_matches('/');
490 let base_url =
491 reqwest::Url::parse(base).map_err(|error| format!("invalid upstream base_url: {error}"))?;
492 let base_path = base_url.path().trim_end_matches('/');
493 let mut path = path.to_string();
494 if !base_path.is_empty()
495 && base_path != "/"
496 && (path == base_path || path.starts_with(&format!("{base_path}/")))
497 {
498 let rest = &path[base_path.len()..];
499 path = if rest.is_empty() {
500 "/".to_string()
501 } else {
502 rest.to_string()
503 };
504 }
505 if !path.starts_with('/') {
506 path = format!("/{path}");
507 }
508 let full = format!("{base}{path}");
509 reqwest::Url::parse(&full).map_err(|error| format!("invalid probe url: {error}"))
510}
511
512async fn read_limited_body(response: reqwest::Response, max_bytes: usize) -> Result<Bytes, String> {
513 let mut stream = response.bytes_stream();
514 let mut out = Vec::new();
515 while let Some(chunk) = stream.next().await {
516 let chunk = chunk.map_err(|error| format!("read probe body: {error}"))?;
517 if out.len() + chunk.len() > max_bytes {
518 return Err(format!("probe response body exceeded {max_bytes} bytes"));
519 }
520 out.extend_from_slice(&chunk);
521 }
522 Ok(Bytes::from(out))
523}
524
525#[cfg(test)]
526mod tests {
527 use std::collections::HashMap;
528 use std::sync::{Arc, Mutex};
529
530 use axum::Json;
531 use axum::body::Body;
532 use axum::http::Request;
533 use axum::routing::{get, post};
534
535 use super::*;
536 use crate::config::{UpstreamAuth, UpstreamConfig};
537
538 fn spec(kind: CodexRelayProbeKind) -> CodexRelayProbeSpec {
539 CodexRelayProbeSpec::for_kind(kind)
540 }
541
542 fn json_headers() -> HeaderMap {
543 let mut headers = HeaderMap::new();
544 headers.insert(
545 axum::http::header::CONTENT_TYPE,
546 HeaderValue::from_static("application/json"),
547 );
548 headers
549 }
550
551 fn upstream(base_url: String) -> UpstreamConfig {
552 UpstreamConfig {
553 base_url,
554 auth: UpstreamAuth::default(),
555 tags: HashMap::new(),
556 supported_models: HashMap::new(),
557 model_mapping: HashMap::new(),
558 }
559 }
560
561 fn spawn_axum_server(app: axum::Router) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
562 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
563 let addr = listener.local_addr().expect("local_addr");
564 listener.set_nonblocking(true).expect("nonblocking");
565 let listener = tokio::net::TcpListener::from_std(listener).expect("to tokio listener");
566 let handle = tokio::spawn(async move {
567 axum::serve(listener, app).await.expect("serve probe test");
568 });
569 (addr, handle)
570 }
571
572 #[test]
573 fn codex_relay_probe_registry_defines_existing_wire_contracts() {
574 let cases = codex_relay_probe_cases();
575 assert_eq!(cases.len(), 3);
576 assert_eq!(
577 cases.iter().map(|case| case.kind).collect::<Vec<_>>(),
578 vec![
579 CodexRelayProbeKind::Models,
580 CodexRelayProbeKind::Responses,
581 CodexRelayProbeKind::ResponsesCompact,
582 ]
583 );
584 assert_eq!(
585 cases.iter().map(|case| case.capability).collect::<Vec<_>>(),
586 vec!["model_catalog", "responses", "remote_compaction_v1"]
587 );
588
589 let compact = CodexRelayProbeSpec::for_kind(CodexRelayProbeKind::ResponsesCompact);
590 assert_eq!(compact.method, "POST");
591 assert_eq!(compact.path, "/responses/compact");
592 assert_eq!(
593 compact.side_effect,
594 CodexRelayProbeSideEffect::ValidationOnly
595 );
596 assert_eq!(compact.body, Some(serde_json::json!({})));
597 }
598
599 #[test]
600 fn codex_relay_probe_models_classifies_codex_catalog() {
601 let body = br#"{"models":[{"slug":"gpt-5.5"}]}"#;
602
603 let result = classify_codex_relay_probe_response(
604 &spec(CodexRelayProbeKind::Models),
605 StatusCode::OK,
606 &json_headers(),
607 body,
608 );
609
610 assert_eq!(result.support, CodexRelayProbeSupport::Supported);
611 assert_eq!(result.response_shape.as_deref(), Some("codex_models"));
612 assert!(!result.translation_required);
613 }
614
615 #[test]
616 fn codex_relay_probe_models_classifies_openai_list_as_translatable() {
617 let body = br#"{"object":"list","data":[{"id":"gpt-5.5"}]}"#;
618
619 let result = classify_codex_relay_probe_response(
620 &spec(CodexRelayProbeKind::Models),
621 StatusCode::OK,
622 &json_headers(),
623 body,
624 );
625
626 assert_eq!(result.support, CodexRelayProbeSupport::Supported);
627 assert_eq!(result.response_shape.as_deref(), Some("openai_data_list"));
628 assert!(result.translation_required);
629 }
630
631 #[test]
632 fn codex_relay_probe_models_malformed_json_is_unknown() {
633 let result = classify_codex_relay_probe_response(
634 &spec(CodexRelayProbeKind::Models),
635 StatusCode::OK,
636 &json_headers(),
637 br#"{"object":"list","items":[]}"#,
638 );
639
640 assert_eq!(result.support, CodexRelayProbeSupport::Unknown);
641 assert_eq!(result.confidence, CodexRelayProbeConfidence::Malformed);
642 assert_eq!(result.response_shape, None);
643 assert!(!result.translation_required);
644 }
645
646 #[test]
647 fn codex_relay_probe_responses_validation_error_marks_endpoint_supported() {
648 let body = br#"{"error":{"type":"invalid_request_error","message":"Missing required parameter: model"}}"#;
649
650 let result = classify_codex_relay_probe_response(
651 &spec(CodexRelayProbeKind::Responses),
652 StatusCode::BAD_REQUEST,
653 &json_headers(),
654 body,
655 );
656
657 assert_eq!(result.support, CodexRelayProbeSupport::Supported);
658 assert_eq!(
659 result.confidence,
660 CodexRelayProbeConfidence::EndpointValidation
661 );
662 }
663
664 #[test]
665 fn codex_relay_probe_compact_unsupported_error_marks_endpoint_unsupported() {
666 let body =
667 br#"{"error":{"code":"compact_not_supported","message":"compact is not supported"}}"#;
668
669 let result = classify_codex_relay_probe_response(
670 &spec(CodexRelayProbeKind::ResponsesCompact),
671 StatusCode::BAD_REQUEST,
672 &json_headers(),
673 body,
674 );
675
676 assert_eq!(result.support, CodexRelayProbeSupport::Unsupported);
677 }
678
679 #[test]
680 fn codex_relay_probe_compact_not_found_marks_endpoint_unsupported() {
681 let result = classify_codex_relay_probe_response(
682 &spec(CodexRelayProbeKind::ResponsesCompact),
683 StatusCode::NOT_FOUND,
684 &json_headers(),
685 br#"{"error":{"message":"not found"}}"#,
686 );
687
688 assert_eq!(result.support, CodexRelayProbeSupport::Unsupported);
689 }
690
691 #[tokio::test]
692 async fn codex_relay_probe_executor_sends_single_validation_request_with_auth() {
693 let hits = Arc::new(Mutex::new(0usize));
694 let seen_authorization = Arc::new(Mutex::new(None::<String>));
695 let seen_body = Arc::new(Mutex::new(None::<String>));
696
697 let hits_for_route = hits.clone();
698 let seen_authorization_for_route = seen_authorization.clone();
699 let seen_body_for_route = seen_body.clone();
700 let app = axum::Router::new().route(
701 "/v1/responses/compact",
702 post(move |request: Request<Body>| {
703 let hits = hits_for_route.clone();
704 let seen_authorization = seen_authorization_for_route.clone();
705 let seen_body = seen_body_for_route.clone();
706 async move {
707 *hits.lock().expect("lock hits") += 1;
708 *seen_authorization.lock().expect("lock auth") = request
709 .headers()
710 .get(axum::http::header::AUTHORIZATION)
711 .and_then(|value| value.to_str().ok())
712 .map(ToOwned::to_owned);
713 let body = axum::body::to_bytes(request.into_body(), 1024)
714 .await
715 .expect("body");
716 *seen_body.lock().expect("lock body") =
717 Some(String::from_utf8_lossy(body.as_ref()).into_owned());
718 (
719 StatusCode::BAD_REQUEST,
720 Json(serde_json::json!({
721 "error": {
722 "type": "invalid_request_error",
723 "message": "Missing required parameter: model"
724 }
725 })),
726 )
727 }
728 }),
729 );
730 let (addr, handle) = spawn_axum_server(app);
731 let mut upstream = upstream(format!("http://{addr}/v1"));
732 upstream.auth.auth_token = Some("probe-token".to_string());
733
734 let client = CodexRelayProbeClient::new(reqwest::Client::new());
735 let result = client
736 .probe_upstream(
737 &upstream,
738 &CodexRelayProbeSpec::for_kind(CodexRelayProbeKind::ResponsesCompact),
739 )
740 .await;
741
742 assert_eq!(*hits.lock().expect("lock hits"), 1);
743 assert_eq!(
744 seen_authorization.lock().expect("lock auth").as_deref(),
745 Some("Bearer probe-token")
746 );
747 assert_eq!(seen_body.lock().expect("lock body").as_deref(), Some("{}"));
748 assert_eq!(result.support, CodexRelayProbeSupport::Supported);
749 assert_eq!(
750 result.confidence,
751 CodexRelayProbeConfidence::EndpointValidation
752 );
753
754 handle.abort();
755 }
756
757 #[tokio::test]
758 async fn codex_relay_probe_executor_targets_only_explicit_upstream() {
759 let unused_hits = Arc::new(Mutex::new(0usize));
760 let target_hits = Arc::new(Mutex::new(0usize));
761
762 let unused_hits_for_route = unused_hits.clone();
763 let unused_app = axum::Router::new().route(
764 "/v1/models",
765 get(move || {
766 let unused_hits = unused_hits_for_route.clone();
767 async move {
768 *unused_hits.lock().expect("lock unused hits") += 1;
769 Json(serde_json::json!({ "models": [{ "slug": "unused" }] }))
770 }
771 }),
772 );
773 let (unused_addr, unused_handle) = spawn_axum_server(unused_app);
774
775 let target_hits_for_route = target_hits.clone();
776 let target_app = axum::Router::new().route(
777 "/v1/models",
778 get(move || {
779 let target_hits = target_hits_for_route.clone();
780 async move {
781 *target_hits.lock().expect("lock target hits") += 1;
782 Json(serde_json::json!({ "models": [{ "slug": "gpt-5.5" }] }))
783 }
784 }),
785 );
786 let (target_addr, target_handle) = spawn_axum_server(target_app);
787
788 let client = CodexRelayProbeClient::new(reqwest::Client::new());
789 let result = client
790 .probe_upstream(
791 &upstream(format!("http://{target_addr}/v1")),
792 &CodexRelayProbeSpec::for_kind(CodexRelayProbeKind::Models),
793 )
794 .await;
795
796 assert_ne!(unused_addr, target_addr);
797 assert_eq!(*unused_hits.lock().expect("lock unused hits"), 0);
798 assert_eq!(*target_hits.lock().expect("lock target hits"), 1);
799 assert_eq!(result.support, CodexRelayProbeSupport::Supported);
800 assert_eq!(result.response_shape.as_deref(), Some("codex_models"));
801
802 unused_handle.abort();
803 target_handle.abort();
804 }
805
806 #[tokio::test]
807 async fn codex_relay_probe_executor_classifies_models_without_normal_proxy_side_effects() {
808 let hits = Arc::new(Mutex::new(0usize));
809 let hits_for_route = hits.clone();
810 let app = axum::Router::new().route(
811 "/v1/models",
812 get(move || {
813 let hits = hits_for_route.clone();
814 async move {
815 *hits.lock().expect("lock hits") += 1;
816 Json(serde_json::json!({
817 "object": "list",
818 "data": [
819 { "id": "gpt-5.5", "object": "model" }
820 ]
821 }))
822 }
823 }),
824 );
825 let (addr, handle) = spawn_axum_server(app);
826 let client = CodexRelayProbeClient::new(reqwest::Client::new());
827
828 let result = client
829 .probe_upstream(
830 &upstream(format!("http://{addr}/v1")),
831 &CodexRelayProbeSpec::for_kind(CodexRelayProbeKind::Models),
832 )
833 .await;
834
835 assert_eq!(*hits.lock().expect("lock hits"), 1);
836 assert_eq!(result.support, CodexRelayProbeSupport::Supported);
837 assert_eq!(result.response_shape.as_deref(), Some("openai_data_list"));
838 assert!(result.translation_required);
839
840 handle.abort();
841 }
842
843 #[test]
844 fn codex_relay_probe_url_builder_avoids_double_v1_prefix() {
845 let url = build_probe_url("https://relay.example/v1", "/v1/responses/compact")
846 .expect("probe url");
847
848 assert_eq!(url.as_str(), "https://relay.example/v1/responses/compact");
849 }
850}