Skip to main content

codex_helper_core/proxy/
codex_relay_probe.rs

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}