Skip to main content

switchyard_llm_client/
client.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! [`TranslatingLlmClient`]: encode a neutral
5//! request, call the configured backend over HTTP, decode the neutral response.
6
7use std::borrow::Cow;
8use std::collections::{BTreeMap, BTreeSet, HashMap};
9use std::future::ready;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::{Duration, SystemTime};
12
13use async_trait::async_trait;
14use futures_util::{StreamExt, stream};
15use http::StatusCode;
16use reqwest::RequestBuilder;
17use reqwest::header::{HeaderMap, RETRY_AFTER};
18use serde_json::{Map, Value, json};
19use switchyard_protocol::{
20    LlmRequest, LlmResponse, LlmResponseChunk, LlmResponseStream, LlmResponseStreamEvent, Metadata,
21    ModelId, Request, Response, RoutedLlmClient,
22};
23use switchyard_translation::{
24    TranslationError, WireFormat, decode_stream, encode_stream_with_extensions,
25};
26use tokio::time::Instant;
27use tracing::Instrument;
28
29use crate::backend::{Backend, openai_url};
30use crate::error::{LlmClientError, Result};
31use crate::metrics;
32use crate::observed_translation;
33use crate::raw::RawResponse;
34
35// Caller headers safe to send when caller auth forwarding is disabled.
36const ALLOWED_METADATA_HEADERS: &[&str] = &["x-request-id"];
37
38// Headers tied to the inbound connection, destination, or body. The HTTP client
39// must rebuild these for the upstream request, even when forwarding auth.
40const NON_FORWARDABLE_HEADERS: &[&str] = &[
41    "host",
42    "content-length",
43    "connection",
44    "proxy-connection",
45    "keep-alive",
46    "proxy-authenticate",
47    "proxy-authorization",
48    "te",
49    "trailer",
50    "transfer-encoding",
51    "upgrade",
52    "content-type",
53    "content-encoding",
54    "accept-encoding",
55    "expect",
56];
57
58const INITIAL_RETRY_DELAY: Duration = Duration::from_millis(250);
59const MAX_RETRY_BACKOFF: Duration = Duration::from_secs(2);
60const MAX_RETRY_AFTER: Duration = Duration::from_secs(60);
61
62/// How one model is served: the `default_backend` used when the request does not
63/// pin a wire format, plus any `other_backends` reachable over additional formats.
64#[derive(Clone, Debug)]
65pub struct ModelConfig {
66    model_name: ModelId,
67    provider_model: Option<ModelId>,
68    default_backend: Backend,
69    other_backends: Option<Vec<Backend>>,
70    responses_reasoning: crate::ResponsesReasoningPolicy,
71    responses_compatibility: crate::ResponsesCompatibility,
72}
73
74impl ModelConfig {
75    /// A model named `model_name` served by `default_backend`, optionally reachable
76    /// over additional wire formats via `other_backends`.
77    pub fn new(
78        model_name: impl Into<ModelId>,
79        default_backend: Backend,
80        other_backends: Option<Vec<Backend>>,
81    ) -> Self {
82        Self {
83            model_name: model_name.into(),
84            provider_model: None,
85            default_backend,
86            other_backends,
87            responses_reasoning: crate::ResponsesReasoningPolicy::default(),
88            responses_compatibility: crate::ResponsesCompatibility::default(),
89        }
90    }
91
92    /// This method selects the Responses request changes required by the model's provider.
93    #[must_use]
94    pub fn with_responses_compatibility(mut self, policy: crate::ResponsesCompatibility) -> Self {
95        self.responses_compatibility = policy;
96        self
97    }
98
99    /// Sets how Responses reasoning items are replayed to this model.
100    #[must_use]
101    pub fn with_responses_reasoning(mut self, policy: crate::ResponsesReasoningPolicy) -> Self {
102        self.responses_reasoning = policy;
103        self
104    }
105
106    /// Sends `provider_model` upstream in place of `model_name`. The client is still
107    /// called with `model_name`, so several entries can serve one provider model with
108    /// different backend settings.
109    pub fn with_provider_model(mut self, provider_model: impl Into<ModelId>) -> Self {
110        self.provider_model = Some(provider_model.into());
111        self
112    }
113}
114
115/// A model-bearing provider operation outside the normal completion endpoint.
116#[derive(Clone, Copy, Debug, Eq, PartialEq)]
117pub enum AuxiliaryOperation {
118    /// Anthropic Messages input-token counting.
119    AnthropicCountTokens,
120    /// OpenAI Responses input-token counting.
121    ResponsesInputTokens,
122    /// OpenAI Responses compaction.
123    ResponsesCompact,
124}
125
126impl AuxiliaryOperation {
127    const fn wire_format(self) -> WireFormat {
128        match self {
129            Self::AnthropicCountTokens => WireFormat::AnthropicMessages,
130            Self::ResponsesInputTokens | Self::ResponsesCompact => WireFormat::OpenAiResponses,
131        }
132    }
133
134    fn url(self, backend: &Backend) -> String {
135        let suffix = match self {
136            Self::AnthropicCountTokens => return backend.count_tokens_url(),
137            Self::ResponsesInputTokens => "/responses/input_tokens",
138            Self::ResponsesCompact => "/responses/compact",
139        };
140        let base_url = backend.url();
141        openai_url(&base_url, suffix).unwrap_or_else(|error| {
142            tracing::error!(%error, "Unable to build OpenAI auxiliary endpoint URL");
143            base_url
144        })
145    }
146}
147
148/// A client that dispatches neutral-IR requests to per-model HTTP backends.
149///
150/// Construct it with a list of [`ModelConfig`]s — one per model, each naming a
151/// default [`Backend`] and any additional per-format backends. Each call resolves
152/// the model and wire format, encodes the request to that backend's wire format,
153/// applies auth and forwarded headers, sends the HTTP request with a shared
154/// [`reqwest::Client`], and decodes the response back to the neutral IR (buffered
155/// or streamed).
156pub struct TranslatingLlmClient {
157    model_to_config: HashMap<ModelId, ModelConfig>,
158    client: reqwest::Client,
159    forward_auth_client: reqwest::Client,
160    is_rate_limit_cooldown_enabled: bool,
161    cooldown_epoch: Instant,
162    // Milliseconds since cooldown_epoch. Each atomic is independent, so relaxed ordering suffices.
163    unavailable_until: HashMap<ModelId, AtomicU64>,
164}
165
166impl TranslatingLlmClient {
167    /// Builds a client over the given [`ModelConfig`]s, with a fresh shared HTTP
168    /// client and the built-in translation codecs.
169    pub fn new(model_configs: &[ModelConfig]) -> Result<Self> {
170        // Pre-populate so concurrent calls update only atomics and keep map access lock-free.
171        let mut unavailable_until = HashMap::new();
172        for config in model_configs {
173            for backend in std::iter::once(&config.default_backend)
174                .chain(config.other_backends.iter().flatten())
175            {
176                backend.validate_configured_headers(&config.model_name)?;
177            }
178            unavailable_until.insert(config.model_name.clone(), AtomicU64::new(0));
179        }
180        let build_client = |builder: reqwest::ClientBuilder| {
181            builder.build().map_err(|error| LlmClientError::Transport {
182                source: Box::new(error),
183            })
184        };
185        let client = build_client(reqwest::Client::builder())?;
186        // A redirect could move provider-specific headers to another origin.
187        // Forwarded credentials are sent only to the configured URL.
188        let forward_auth_client =
189            build_client(reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()))?;
190        let model_to_config = model_configs
191            .iter()
192            .map(|config| (config.model_name.clone(), config.clone()))
193            .collect();
194
195        Ok(Self {
196            model_to_config,
197            client,
198            forward_auth_client,
199            is_rate_limit_cooldown_enabled: true,
200            cooldown_epoch: Instant::now(),
201            unavailable_until,
202        })
203    }
204
205    /// Disable HTTP 429 cooldown so subsequent calls can receive the upstream error.
206    pub fn without_rate_limit_cooldown(mut self) -> Self {
207        self.is_rate_limit_cooldown_enabled = false;
208        self
209    }
210
211    /// The backend serving `model` over `format` — the default backend when its
212    /// format matches, otherwise a matching entry in `other_backends`; `None` when
213    /// the model is unknown or has no backend for `format`.
214    pub fn backend_for(&self, model: &ModelId, format: WireFormat) -> Option<&Backend> {
215        self.model_to_config.get(model).and_then(|config| {
216            if config.default_backend.wire_format() == format {
217                Some(&config.default_backend)
218            } else {
219                config
220                    .other_backends
221                    .as_ref()
222                    .and_then(|backends| backends.iter().find(|b| b.wire_format() == format))
223            }
224        })
225    }
226
227    /// The model id sent upstream for `model`: its provider model when one is set,
228    /// otherwise `model` itself.
229    pub fn provider_model<'a>(&'a self, model: &'a ModelId) -> &'a ModelId {
230        self.model_to_config
231            .get(model)
232            .and_then(|config| config.provider_model.as_ref())
233            .unwrap_or(model)
234    }
235
236    /// Whether `model` has a backend for `operation`.
237    pub fn supports_auxiliary(&self, model: &ModelId, operation: AuxiliaryOperation) -> bool {
238        self.backend_for(model, operation.wire_format()).is_some()
239    }
240
241    /// Reads llama.cpp model properties through the backend's configured auth and headers.
242    ///
243    /// Redirects are disabled so credentials cannot move to another origin.
244    pub async fn get_model_properties(
245        &self,
246        model: &ModelId,
247        format: WireFormat,
248        timeout: Duration,
249    ) -> Result<Value> {
250        let backend =
251            self.backend_for(model, format)
252                .ok_or_else(|| LlmClientError::Configuration {
253                    message: format!("model {model} has no backend for {format:?}"),
254                })?;
255        let mut url = reqwest::Url::parse(backend.base_url()).map_err(|error| {
256            LlmClientError::Configuration {
257                message: format!("model {model} has an invalid backend URL: {error}"),
258            }
259        })?;
260        let root = url.path().trim_end_matches('/');
261        let root = root
262            .strip_suffix("/chat/completions")
263            .or_else(|| root.strip_suffix("/responses"))
264            .unwrap_or(root);
265        let path = format!(
266            "{}/props",
267            root.strip_suffix("/v1")
268                .unwrap_or(root)
269                .trim_end_matches('/')
270        );
271        url.set_path(&path);
272        url.set_query(None);
273
274        let builder = self.forward_auth_client.get(url).timeout(timeout);
275        let builder = apply_extra_headers(builder, backend);
276        let response = backend
277            .apply_auth(builder)
278            .send()
279            .await
280            .map_err(convert_reqwest_error)?
281            .error_for_status()
282            .map_err(convert_reqwest_error)?;
283        response
284            .json()
285            .await
286            .map_err(|source| LlmClientError::InvalidResponse {
287                source: Box::new(source),
288            })
289    }
290
291    /// Calls a model-bearing auxiliary provider operation.
292    ///
293    /// Returns an error when the model has no compatible backend or the upstream
294    /// request fails or returns invalid JSON.
295    pub async fn call_auxiliary(
296        &self,
297        model: &ModelId,
298        request: Request,
299        operation: AuxiliaryOperation,
300    ) -> Result<Value> {
301        let wire_format = operation.wire_format();
302        let backend =
303            self.backend_for(model, wire_format)
304                .ok_or_else(|| LlmClientError::Configuration {
305                    message: format!("model {model} has no backend for {operation:?}"),
306                })?;
307        let Request {
308            mut llm_request,
309            metadata,
310            ..
311        } = request;
312        llm_request.model = Some(self.provider_model(model).to_string());
313        let http_response = self
314            .send_encoded(
315                backend,
316                wire_format,
317                llm_request,
318                metadata.as_ref(),
319                model,
320                UpstreamEndpoint::Auxiliary(operation),
321            )
322            .await?;
323        let EncodedResponse::Buffered { body, .. } = http_response else {
324            return Err(LlmClientError::InvalidRequest {
325                message: "auxiliary endpoints do not support streaming".to_string(),
326            });
327        };
328        serde_json::from_slice(&body).map_err(|error| LlmClientError::InvalidResponse {
329            source: Box::new(error),
330        })
331    }
332
333    /// Encode `llm_request` for `wire_format`, POST it to `url` with the request's
334    /// forwarded headers plus the backend's static headers and auth, and return the
335    /// successful upstream response. A
336    /// buffered response is fully collected within the retry boundary; a streamed
337    /// response has its first decoded event checked within that boundary. The backend's
338    /// deadline, when set, spans every attempt, retry delay, and subsequent stream read. A
339    /// non-success status maps to a typed error — a 400 is classified as a context-window
340    /// overflow via the backend's provider rules. Shared by
341    /// [`call_rewrite_model`](Self::call_rewrite_model) (which POSTs to the
342    /// backend's completion URL and decodes a response) and
343    /// the model-bearing auxiliary operations, which return raw JSON.
344    async fn send_encoded(
345        &self,
346        backend: &Backend,
347        wire_format: WireFormat,
348        llm_request: LlmRequest,
349        metadata: Option<&Metadata>,
350        model: &ModelId,
351        endpoint: UpstreamEndpoint,
352    ) -> Result<EncodedResponse> {
353        let cooldown = backend.failure_cooldown();
354        let unavailable_until =
355            if matches!(endpoint, UpstreamEndpoint::Completion) && !cooldown.is_zero() {
356                self.unavailable_until.get(model)
357            } else {
358                None
359            };
360        if let Some(until) = unavailable_until
361            && until.load(Ordering::Relaxed) > duration_millis(self.cooldown_epoch.elapsed())
362        {
363            tracing::debug!(model = %model, wire_format = %wire_format, "skipping backend during failure cooldown");
364            return Err(LlmClientError::TemporarilyUnavailable);
365        }
366
367        let mut body = observed_translation::encode_request(&llm_request, wire_format)
368            .map_err(|error| LlmClientError::RequestEncoding(error.to_string()))?;
369        // `encode_request` round-trips a preserved same-format body verbatim,
370        // which keeps the caller's original `model`; force the resolved model so
371        // the upstream always sees the provider model.
372        let upstream_model = self.provider_model(model);
373        set_json_model(&mut body, upstream_model);
374        // Strip before `merge_extra_body` so a target can reinstate either field
375        // deliberately via `extra_body`.
376        if matches!(backend, Backend::Anthropic(_)) {
377            strip_anthropic_incompatible_fields(&mut body);
378            strip_unsigned_thinking_blocks(&mut body);
379        }
380        omit_configured_body_fields(&mut body, backend.omit_body_fields());
381        merge_extra_body(&mut body, backend.extra_body());
382        if matches!(backend, Backend::OpenAiResponses(_)) {
383            self.model_to_config
384                .get(model)
385                .map(|config| config.responses_compatibility)
386                .unwrap_or_default()
387                .normalize(&mut body);
388            self.model_to_config
389                .get(model)
390                .map(|config| config.responses_reasoning)
391                .unwrap_or_default()
392                .normalize(&mut body, backend.base_url(), model);
393        }
394        // After the merge on purpose: the effort override must win over both the caller's
395        // value and any `reasoning` default a target set through `extra_body`. Token counting
396        // does not depend on effort, and Anthropic's count endpoint may reject the field.
397        if matches!(endpoint, UpstreamEndpoint::Completion) {
398            apply_reasoning_effort(&mut body, backend);
399        }
400        if matches!(backend, Backend::OpenAiResponses(_)) {
401            normalize_codex_string_input(&backend.url(), &mut body);
402        }
403        if matches!(backend, Backend::Anthropic(_)) {
404            if nvidia_inference_api_backend(backend) {
405                strip_anthropic_tool_strict(&mut body);
406            }
407            enable_anthropic_prompt_caching(&mut body, llm_request.temporary_note_count);
408        }
409        if matches!(
410            backend,
411            Backend::OpenAiChat(_) | Backend::OpenAiResponses(_)
412        ) {
413            enable_openai_guidance_caching(
414                &mut body,
415                wire_format,
416                upstream_model,
417                llm_request.temporary_note_count,
418            );
419        }
420        if matches!(backend, Backend::OpenAiChat(_)) {
421            ensure_openai_stream_usage(&mut body);
422        }
423        let streaming = endpoint.allows_streaming()
424            && body.get("stream").and_then(Value::as_bool).unwrap_or(false);
425        let url = endpoint.url(backend);
426        record_gen_ai_request(&url, upstream_model, streaming);
427
428        let result = self
429            .send_with_retries(&url, backend, &body, metadata, model, streaming)
430            .await;
431        // Keep quota failures request-local when forwarding auth or returning 429 to callers.
432        if let Some(until) = unavailable_until
433            && let Err(error) = &result
434            && is_transient_failure(error)
435            && !((backend.is_forwarding_auth() || !self.is_rate_limit_cooldown_enabled)
436                && matches!(error, LlmClientError::UpstreamHttp { status, .. } if *status == StatusCode::TOO_MANY_REQUESTS))
437        {
438            let deadline = duration_millis(self.cooldown_epoch.elapsed().saturating_add(cooldown));
439            until.fetch_max(deadline, Ordering::Relaxed);
440        }
441        // Let cooldown expire naturally; a successful in-flight call may overlap a newer failure.
442        result
443    }
444
445    // Sends the encoded body, retrying retryable failures within the backend's retry budget.
446    async fn send_with_retries(
447        &self,
448        url: &str,
449        backend: &Backend,
450        body: &Value,
451        metadata: Option<&Metadata>,
452        model: &ModelId,
453        streaming: bool,
454    ) -> Result<EncodedResponse> {
455        let max_retries = u64::from(backend.max_retries());
456        let max_attempts = max_retries
457            + 1
458            + u64::from(
459                matches!(backend, Backend::OpenAiChat(_))
460                    && body.get("chat_template_kwargs").is_some(),
461            );
462        let mut body = Cow::Borrowed(body);
463        let mut template_retry = false;
464        let expires_at = backend
465            .timeout()
466            .map(|timeout| (tokio::time::sleep(timeout).deadline(), timeout));
467        let deadline = async {
468            match expires_at {
469                Some((at, timeout)) => {
470                    tokio::time::sleep_until(at).await;
471                    timeout
472                }
473                None => std::future::pending().await,
474            }
475        };
476        tokio::pin!(deadline);
477        let mut attempt = 0_u64;
478        loop {
479            if let Some((at, timeout)) = expires_at
480                && tokio::time::Instant::now() >= at
481            {
482                return Err(deadline_error(timeout));
483            }
484            let span = tracing::debug_span!(
485                target: "libsy",
486                "libsy.upstream_attempt",
487                model = %model,
488                wire_format = %backend.wire_format(),
489                attempt = attempt + 1 + u64::from(template_retry),
490                max_attempts,
491                retry = attempt > 0 || template_retry,
492                openinference.span.kind = "CHAIN",
493                outcome = tracing::field::Empty,
494                status_code = tracing::field::Empty,
495                will_retry = tracing::field::Empty,
496                retry_delay_ms = tracing::field::Empty,
497            );
498            let mut attempt_started = false;
499            let result = tokio::select! {
500                biased;
501                timeout = &mut deadline => {
502                    if attempt_started {
503                        metrics::record_upstream_attempt(None);
504                    }
505                    span.record("outcome", "error");
506                    span.record("will_retry", false);
507                    return Err(deadline_error(timeout));
508                }
509                result = async {
510                    attempt_started = true;
511                    self.send_once(url, backend, body.as_ref(), metadata, model, streaming).await
512                }.instrument(span.clone()) => result,
513            };
514            // The retained handle updates this same attempt span with its outcome.
515            match result {
516                Ok(response) => {
517                    span.record("outcome", "ok");
518                    span.record("status_code", response.status());
519                    span.record("will_retry", false);
520                    if attempt > 0 || template_retry {
521                        metrics::record_retry_recovered();
522                    }
523                    return Ok(match response {
524                        EncodedResponse::Streaming {
525                            status,
526                            chunks,
527                            upstream_headers,
528                        } => EncodedResponse::Streaming {
529                            status,
530                            chunks: stream_with_deadline(chunks, expires_at),
531                            upstream_headers,
532                        },
533                        buffered => buffered,
534                    });
535                }
536                Err(failure) => {
537                    let retry_without_template = matches!(backend, Backend::OpenAiChat(_))
538                        && body.get("chat_template_kwargs")
539                            == Some(&json!({"enable_thinking": false}))
540                        && rejects_template_options(&failure.error);
541                    let will_retry =
542                        retry_without_template || (attempt < max_retries && failure.is_retryable());
543                    span.record("outcome", "error");
544                    if let Some(status) = failure.status {
545                        span.record("status_code", status.as_u16());
546                    }
547                    span.record("will_retry", will_retry);
548                    if !will_retry {
549                        return Err(failure.error);
550                    }
551
552                    if retry_without_template {
553                        if let Some(object) = body.to_mut().as_object_mut() {
554                            object.remove("chat_template_kwargs");
555                        }
556                        template_retry = true;
557                        span.record("retry_delay_ms", 0_u64);
558                        continue;
559                    }
560                    let delay = retry_delay(attempt, failure.retry_after);
561                    span.record("retry_delay_ms", duration_millis(delay));
562                    // Close the attempt span before sleeping so backoff is not attempt latency.
563                    drop(span);
564                    tokio::select! {
565                        biased;
566                        timeout = &mut deadline => return Err(deadline_error(timeout)),
567                        _ = tokio::time::sleep(delay) => {}
568                    }
569                    attempt += 1;
570                }
571            }
572        }
573    }
574
575    // Performs one HTTP attempt and retains the retry metadata alongside any error.
576    async fn send_once(
577        &self,
578        url: &str,
579        backend: &Backend,
580        body: &Value,
581        metadata: Option<&Metadata>,
582        model: &ModelId,
583        streaming: bool,
584    ) -> std::result::Result<EncodedResponse, AttemptFailure> {
585        let client = if backend.is_forwarding_auth() {
586            &self.forward_auth_client
587        } else {
588            &self.client
589        };
590        let builder = client.post(url).json(body);
591        let builder = forward_metadata_headers(builder, metadata, backend);
592        let builder = backend.apply_forwarded_auth(builder, metadata);
593        let builder = apply_extra_headers(builder, backend);
594        let builder = backend.apply_auth(builder);
595
596        let response = match async {
597            let request = builder.build()?;
598            if let Some(body) = request.body().and_then(reqwest::Body::as_bytes) {
599                crate::payload_capture::record("request", || String::from_utf8_lossy(body));
600            }
601            client.execute(request).await
602        }
603        .await
604        {
605            Ok(response) => response,
606            Err(error) => {
607                metrics::record_upstream_attempt(None);
608                return Err(AttemptFailure {
609                    error: convert_reqwest_error(error),
610                    status: None,
611                    retry_after: None,
612                });
613            }
614        };
615        let status = response.status();
616        if status.is_success() {
617            if streaming {
618                let upstream_headers = response.headers().clone();
619                let chunks = match prepare_response_stream(response, backend, model).await {
620                    Ok(chunks) => chunks,
621                    Err(error) => {
622                        metrics::record_upstream_attempt(None);
623                        return Err(AttemptFailure {
624                            error,
625                            status: Some(status),
626                            retry_after: None,
627                        });
628                    }
629                };
630                metrics::record_upstream_attempt(Some(status.as_u16()));
631                return Ok(EncodedResponse::Streaming {
632                    status: status.as_u16(),
633                    chunks,
634                    upstream_headers,
635                });
636            }
637            let upstream_headers = response.headers().clone();
638            let body = match response.bytes().await {
639                Ok(body) => body,
640                Err(error) => {
641                    metrics::record_upstream_attempt(None);
642                    return Err(AttemptFailure {
643                        error: convert_reqwest_error(error),
644                        status: Some(status),
645                        retry_after: None,
646                    });
647                }
648            };
649            metrics::record_upstream_attempt(Some(status.as_u16()));
650            crate::payload_capture::record("response", || String::from_utf8_lossy(&body));
651            return Ok(EncodedResponse::Buffered {
652                status: status.as_u16(),
653                body: body.to_vec(),
654                upstream_headers,
655            });
656        }
657
658        let retry_after = retry_after_delay(response.headers());
659        let body = match response.text().await {
660            Ok(body) => body,
661            Err(error) => {
662                metrics::record_upstream_attempt(None);
663                return Err(AttemptFailure {
664                    error: convert_reqwest_error(error),
665                    status: Some(status),
666                    retry_after,
667                });
668            }
669        };
670        let body = redact_forwarded_headers(body, metadata, backend.is_forwarding_auth());
671        metrics::record_upstream_attempt(Some(status.as_u16()));
672        let error =
673            if status == reqwest::StatusCode::BAD_REQUEST && backend.is_context_overflow(&body) {
674                LlmClientError::ContextWindowExceeded {
675                    model: model.clone(),
676                    message: body,
677                }
678            } else {
679                LlmClientError::UpstreamHttp { status, body }
680            };
681        Err(AttemptFailure {
682            error,
683            status: Some(status),
684            retry_after,
685        })
686    }
687
688    /// Calls the backend for `model_name` (or the request's own model), over the
689    /// wire format the request pins in its metadata (else the model's default
690    /// backend), and returns the neutral response.
691    ///
692    /// Resolution: `model_name` wins over `request.llm_request.model`; the
693    /// resolved name is the outer map key, and its provider model is the model id
694    /// written into the request before translation. Missing models are invalid requests; unknown
695    /// models or wire formats are configuration errors.
696    pub async fn call_rewrite_model(
697        &self,
698        request: Request,
699        model_name: Option<&ModelId>,
700    ) -> Result<Response> {
701        let Request {
702            mut llm_request,
703            metadata,
704            ..
705        } = request;
706
707        let model_id = model_name
708            .cloned()
709            .or_else(|| llm_request.model.map(ModelId::from))
710            .ok_or_else(|| LlmClientError::InvalidRequest {
711                message: "no model given".to_string(),
712            })?;
713        llm_request.model = Some(self.provider_model(&model_id).to_string());
714
715        let orig_format = metadata.as_ref().and_then(|m| m.wire_format);
716        let wire_format = orig_format.unwrap_or(
717            self.model_to_config
718                .get(&model_id)
719                .map(|config| config.default_backend.wire_format())
720                .ok_or_else(|| LlmClientError::Configuration {
721                    message: format!("no backend configured for model {model_id:?}"),
722                })?,
723        );
724        let backend = self.backend_for(&model_id, wire_format).ok_or_else(|| {
725            LlmClientError::Configuration {
726                message: format!("model {model_id:?} has no backend for format {wire_format}"),
727            }
728        })?;
729
730        let http_response = self
731            .send_encoded(
732                backend,
733                wire_format,
734                llm_request,
735                metadata.as_ref(),
736                &model_id,
737                UpstreamEndpoint::Completion,
738            )
739            .await?;
740
741        let (llm_response, upstream_headers) = match http_response {
742            EncodedResponse::Streaming {
743                chunks,
744                upstream_headers,
745                ..
746            } => (LlmResponse::Stream(chunks), upstream_headers),
747            EncodedResponse::Buffered {
748                body,
749                upstream_headers,
750                ..
751            } => {
752                let body = serde_json::from_slice::<Value>(&body).map_err(|error| {
753                    LlmClientError::ResponseTranslation(format!("invalid upstream JSON: {error}"))
754                })?;
755                // Map a provider's failed generation to 502, even under HTTP 200.
756                // Redact forwarded credentials before returning the provider error.
757                let mut agg =
758                    observed_translation::decode_response(&body, wire_format).map_err(|error| {
759                        match error {
760                            TranslationError::UpstreamFailure { error } => {
761                                LlmClientError::UpstreamHttp {
762                                    status: StatusCode::BAD_GATEWAY,
763                                    body: redact_forwarded_headers(
764                                        json!({ "error": error }).to_string(),
765                                        metadata.as_ref(),
766                                        backend.is_forwarding_auth(),
767                                    ),
768                                }
769                            }
770                            error => LlmClientError::ResponseTranslation(error.to_string()),
771                        }
772                    })?;
773                // The gateway may report a buffered charge only in its response header.
774                if agg.usage.provider_cost.is_none() {
775                    agg.usage.provider_cost = upstream_headers
776                        .get("x-litellm-response-cost")
777                        .and_then(|value| value.to_str().ok())
778                        .and_then(|value| value.trim().parse::<f64>().ok())
779                        .filter(|usd| usd.is_finite() && *usd >= 0.0);
780                }
781                (LlmResponse::Agg(agg), upstream_headers)
782            }
783        };
784
785        Ok(Response {
786            llm_response,
787            metadata,
788            upstream_headers,
789        })
790    }
791
792    /// The whole decode → call → encode path a wire endpoint needs, in one call.
793    ///
794    /// Decodes `raw_http_request` from `wire_format` to the neutral IR, serves it via
795    /// [`call_rewrite_model`](Self::call_rewrite_model) — the *upstream* wire format is
796    /// resolved there from the model's backend, independently of `wire_format` — then
797    /// encodes the neutral response back into `wire_format`. The result is a buffered
798    /// [`RawResponse::Buffered`] JSON body or a streamed [`RawResponse::Stream`] of
799    /// wire events (the caller frames the stream as SSE). The response's `model` is
800    /// restamped with the model that actually served the call, so the body names the
801    /// model that answered rather than the route the caller addressed.
802    ///
803    /// `http_headers` are carried through as the request's
804    /// [`Metadata::http_headers`]. Backends with `forward_auth` disabled forward only
805    /// allowed metadata headers; `forward_auth` backends forward all application
806    /// headers. The forwarding backends in one route must use one credential family
807    /// unless they all use the same scheme, host, and port. Transport headers are always
808    /// rebuilt. Pass `None` to forward nothing.
809    pub async fn call_rewrite_model_raw(
810        &self,
811        raw_http_request: Value,
812        http_headers: Option<http::HeaderMap>,
813        model: Option<&ModelId>,
814        wire_format: WireFormat,
815    ) -> Result<RawResponse> {
816        let llm_request = observed_translation::decode_request(wire_format, &raw_http_request)
817            .map_err(|error| LlmClientError::RequestTranslation(error.to_string()))?;
818        let request_extensions = llm_request.extensions.clone();
819        // The model that serves the call — the rewrite target when the caller pinned
820        // one, else the request's own model. Mirrors `call_rewrite_model`'s own
821        // resolution so the response names whoever answered.
822        let served_model = model
823            .cloned()
824            .or_else(|| llm_request.model.clone().map(ModelId::from))
825            .map(|model| self.provider_model(&model).to_string());
826
827        let request = Request {
828            llm_request,
829            raw_request: None,
830            metadata: Some(Metadata {
831                session_id: None,
832                agent_id: None,
833                task_id: None,
834                correlation_id: None,
835                extra_metadata: None,
836                http_headers,
837                wire_format: None,
838                ..Default::default()
839            }),
840        };
841        let response = self.call_rewrite_model(request, model).await?;
842
843        match response.llm_response {
844            LlmResponse::Agg(agg) => {
845                let body = observed_translation::encode_response(
846                    &agg,
847                    wire_format,
848                    served_model.as_deref(),
849                    &request_extensions,
850                )
851                .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
852                Ok(RawResponse::Buffered(body))
853            }
854            LlmResponse::Stream(chunks) => {
855                let events = encode_stream_with_extensions(
856                    chunks,
857                    wire_format,
858                    served_model,
859                    &request_extensions,
860                )?;
861                Ok(RawResponse::Stream(events))
862            }
863        }
864    }
865}
866
867#[async_trait]
868impl RoutedLlmClient for TranslatingLlmClient {
869    fn provider_model<'a>(&'a self, target: &'a ModelId) -> &'a ModelId {
870        TranslatingLlmClient::provider_model(self, target)
871    }
872
873    async fn call(&self, request: Request) -> Result<Response> {
874        self.call_rewrite_model(request, None).await
875    }
876}
877
878#[derive(Clone, Copy)]
879enum UpstreamEndpoint {
880    Completion,
881    Auxiliary(AuxiliaryOperation),
882}
883
884impl UpstreamEndpoint {
885    fn url(self, backend: &Backend) -> String {
886        match self {
887            UpstreamEndpoint::Completion => backend.url(),
888            UpstreamEndpoint::Auxiliary(operation) => operation.url(backend),
889        }
890    }
891
892    fn allows_streaming(self) -> bool {
893        matches!(self, UpstreamEndpoint::Completion)
894    }
895}
896
897enum EncodedResponse {
898    Buffered {
899        status: u16,
900        body: Vec<u8>,
901        upstream_headers: HeaderMap,
902    },
903    Streaming {
904        status: u16,
905        chunks: LlmResponseStream,
906        upstream_headers: HeaderMap,
907    },
908}
909
910impl EncodedResponse {
911    fn status(&self) -> u16 {
912        match self {
913            EncodedResponse::Buffered { status, .. }
914            | EncodedResponse::Streaming { status, .. } => *status,
915        }
916    }
917}
918
919// The typed error decides retry eligibility; status and Retry-After feed
920// attempt telemetry and delay selection.
921struct AttemptFailure {
922    error: LlmClientError,
923    status: Option<StatusCode>,
924    retry_after: Option<Duration>,
925}
926
927fn deadline_error(timeout: Duration) -> LlmClientError {
928    LlmClientError::Timeout {
929        source: Box::new(std::io::Error::new(
930            std::io::ErrorKind::TimedOut,
931            format!(
932                "response did not finish within {} ms, retries included",
933                duration_millis(timeout)
934            ),
935        )),
936    }
937}
938
939impl AttemptFailure {
940    fn is_retryable(&self) -> bool {
941        is_transient_failure(&self.error)
942    }
943}
944
945fn is_transient_failure(error: &LlmClientError) -> bool {
946    match error {
947        LlmClientError::Transport { .. } | LlmClientError::Timeout { .. } => true,
948        LlmClientError::UpstreamHttp { status, .. } => {
949            metrics::is_retryable_http_status(status.as_u16())
950        }
951        _ => false,
952    }
953}
954
955fn stream_with_deadline(
956    chunks: LlmResponseStream,
957    deadline: Option<(tokio::time::Instant, Duration)>,
958) -> LlmResponseStream {
959    let Some((at, timeout)) = deadline else {
960        return chunks;
961    };
962    stream::unfold(Some(chunks), move |chunks| async move {
963        let mut chunks = chunks?;
964        let chunk = tokio::select! {
965            biased;
966            _ = tokio::time::sleep_until(at) => return Some((Err(deadline_error(timeout)), None)),
967            chunk = chunks.next() => chunk?,
968        };
969        let next = chunk.is_ok().then_some(chunks);
970        Some((chunk, next))
971    })
972    .boxed()
973}
974
975async fn prepare_response_stream(
976    response: reqwest::Response,
977    backend: &Backend,
978    model: &ModelId,
979) -> Result<LlmResponseStream> {
980    let bytes = response.bytes_stream().map(|chunk| {
981        chunk
982            .map(|bytes| bytes.to_vec())
983            .map_err(convert_reqwest_error)
984    });
985    let mut chunks = decode_stream(bytes, backend.wire_format())?;
986    match chunks.next().await {
987        None => Ok(stream::empty().boxed()),
988        // Nothing has reached the caller, so transport failures can still be retried.
989        Some(Err(error @ (LlmClientError::Transport { .. } | LlmClientError::Timeout { .. }))) => {
990            Err(error)
991        }
992        Some(first) => {
993            // An in-band context overflow skips retries and advances to another candidate.
994            if let Some(message) = first_event_overflow(&first, backend) {
995                return Err(LlmClientError::ContextWindowExceeded {
996                    model: model.clone(),
997                    message,
998                });
999            }
1000            Ok(stream::once(ready(first)).chain(chunks).boxed())
1001        }
1002    }
1003}
1004
1005// The overflow message when a stream's first event is an in-band provider rejection
1006// of the whole request, rather than the start of a response.
1007fn first_event_overflow(
1008    first: &Result<LlmResponseStreamEvent>,
1009    backend: &Backend,
1010) -> Option<String> {
1011    first
1012        .as_ref()
1013        .ok()?
1014        .normalized()
1015        .iter()
1016        .find_map(|chunk| match chunk {
1017            LlmResponseChunk::StreamError { message } if backend.is_context_overflow(message) => {
1018                Some(message.clone())
1019            }
1020            _ => None,
1021        })
1022}
1023
1024// Uses Retry-After when supplied, capped so an upstream cannot stall a request indefinitely.
1025fn retry_after_delay(headers: &HeaderMap) -> Option<Duration> {
1026    let value = headers.get(RETRY_AFTER)?.to_str().ok()?;
1027    let delay = if let Ok(seconds) = value.parse::<u64>() {
1028        Duration::from_secs(seconds)
1029    } else {
1030        let retry_at = httpdate::parse_http_date(value).ok()?;
1031        retry_at
1032            .duration_since(SystemTime::now())
1033            .unwrap_or(Duration::ZERO)
1034    };
1035    Some(delay.min(MAX_RETRY_AFTER))
1036}
1037
1038fn retry_delay(retry_number: u64, retry_after: Option<Duration>) -> Duration {
1039    // Retry-After wins; otherwise double 250 ms up to the two-second cap.
1040    retry_after.unwrap_or_else(|| {
1041        let multiplier = 1_u32 << retry_number.min(3);
1042        INITIAL_RETRY_DELAY
1043            .saturating_mul(multiplier)
1044            .min(MAX_RETRY_BACKOFF)
1045    })
1046}
1047
1048fn duration_millis(duration: Duration) -> u64 {
1049    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
1050}
1051
1052fn record_gen_ai_request(url: &str, model: &str, streaming: bool) {
1053    let span = tracing::Span::current();
1054    span.record("gen_ai.request.model", model);
1055    if streaming {
1056        span.record("gen_ai.request.stream", true);
1057    }
1058    if let Ok(url) = reqwest::Url::parse(url) {
1059        if let Some(host) = url.host_str() {
1060            span.record("server.address", host);
1061        }
1062        if let Some(port) = url.port_or_known_default() {
1063            span.record("server.port", i64::from(port));
1064        }
1065    }
1066}
1067
1068pub(crate) fn convert_reqwest_error(error: reqwest::Error) -> LlmClientError {
1069    // Reqwest labels truncated or otherwise unreadable response bodies as decode
1070    // errors, so distinguish them from serde JSON failures at the call site.
1071    let error = error.without_url();
1072    // reqwest reports a connect-phase timeout as both `is_connect()` and
1073    // `is_timeout()`. Check connect first: no request was sent on the failed
1074    // connection, so it is a transport failure like a refused connection.
1075    if error.is_connect() {
1076        LlmClientError::Transport {
1077            source: Box::new(error),
1078        }
1079    } else if error.is_timeout() {
1080        LlmClientError::Timeout {
1081            source: Box::new(error),
1082        }
1083    } else if error.is_builder() {
1084        LlmClientError::Configuration {
1085            message: format!("failed to build upstream request: {error}"),
1086        }
1087    } else {
1088        LlmClientError::Transport {
1089            source: Box::new(error),
1090        }
1091    }
1092}
1093
1094// Forwards safe metadata headers, or all application headers when forwarding auth.
1095fn forward_metadata_headers(
1096    builder: RequestBuilder,
1097    metadata: Option<&Metadata>,
1098    backend: &Backend,
1099) -> RequestBuilder {
1100    let Some(headers) = metadata.and_then(|metadata| metadata.http_headers.as_ref()) else {
1101        return builder;
1102    };
1103    let mut forwarded = HeaderMap::with_capacity(headers.len());
1104    for (name, value) in headers {
1105        let is_allowed = if backend.is_forwarding_auth() {
1106            !is_non_forwardable_header(name.as_str(), headers)
1107                && !backend.is_provider_owned_header(name.as_str())
1108        } else {
1109            is_allowed_metadata_header(name.as_str())
1110        };
1111        if !is_allowed {
1112            continue;
1113        }
1114        forwarded.append(name, value.clone());
1115    }
1116    builder.headers(forwarded)
1117}
1118
1119// Adds the backend's custom per-call headers.
1120fn apply_extra_headers(mut builder: RequestBuilder, backend: &Backend) -> RequestBuilder {
1121    for (name, value) in backend.extra_headers() {
1122        builder = builder.header(name, value);
1123    }
1124    builder
1125}
1126
1127// Overwrites the outbound body's `model` field with the resolved model id.
1128fn set_json_model(body: &mut Value, model: &str) {
1129    if let Value::Object(object) = body {
1130        object.insert("model".to_string(), Value::String(model.to_string()));
1131    }
1132}
1133
1134// The Codex Responses endpoint requires item-list input even on the preserved-body path.
1135fn normalize_codex_string_input(url: &str, body: &mut Value) {
1136    let codex = reqwest::Url::parse(url).ok().is_some_and(|url| {
1137        url.path()
1138            .trim_end_matches('/')
1139            .ends_with("/backend-api/codex/responses")
1140    });
1141    if codex && let Some(input @ Value::String(_)) = body.get_mut("input") {
1142        let text = input.take();
1143        *input = json!([{"type": "message", "role": "user", "content": text}]);
1144    }
1145}
1146
1147fn rejects_template_options(error: &LlmClientError) -> bool {
1148    let LlmClientError::UpstreamHttp { status, body } = error else {
1149        return false;
1150    };
1151    if !matches!(
1152        *status,
1153        StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY
1154    ) {
1155        return false;
1156    }
1157    let parsed = serde_json::from_str::<Value>(body).ok();
1158    let error = parsed.as_ref().and_then(|value| value.get("error"));
1159    let message = match &parsed {
1160        Some(_) => error
1161            .and_then(|error| error.get("message"))
1162            .and_then(Value::as_str)
1163            .unwrap_or_default(),
1164        None => body,
1165    }
1166    .to_ascii_lowercase();
1167    let field = error
1168        .and_then(|error| error.get("param"))
1169        .and_then(Value::as_str);
1170    let code = error
1171        .and_then(|error| error.get("code"))
1172        .and_then(Value::as_str);
1173    (field == Some("chat_template_kwargs") || message.contains("chat_template_kwargs"))
1174        && (matches!(code, Some("unknown_parameter" | "unsupported_parameter"))
1175            || [
1176                "unrecognized request argument",
1177                "unknown parameter",
1178                "unsupported parameter",
1179                "unexpected keyword argument",
1180                "unknown field",
1181                "unsupported field",
1182                "extra inputs are not permitted",
1183            ]
1184            .iter()
1185            .any(|phrase| message.contains(phrase)))
1186}
1187
1188// Drops fields accepted by OpenAI-like APIs but rejected by Anthropic Messages.
1189//
1190// A router can serve earlier turns of a session from an OpenAI-format target and
1191// later turns from an Anthropic one. Clients such as Claude Code send
1192// `context_management` on every turn, so the Anthropic leg must strip it or the
1193// upstream rejects the request (for example `clear_thinking_20251015` strategy
1194// requires `thinking` to be enabled or adaptive).
1195fn strip_anthropic_incompatible_fields(body: &mut Value) {
1196    if let Value::Object(object) = body {
1197        object.remove("reasoning_effort");
1198        object.remove("context_management");
1199        // `strict: false` is the default, and Bedrock rejects the field outright.
1200        if let Some(Value::Array(tools)) = object.get_mut("tools") {
1201            for tool in tools {
1202                if let Value::Object(tool) = tool
1203                    && tool.get("strict") == Some(&Value::Bool(false))
1204                {
1205                    tool.remove("strict");
1206                }
1207            }
1208        }
1209    }
1210}
1211
1212// NVIDIA Inference API's Anthropic-compatible schema rejects the strict tool-use
1213// extension that native Anthropic accepts.
1214fn nvidia_inference_api_backend(backend: &Backend) -> bool {
1215    reqwest::Url::parse(backend.base_url())
1216        .ok()
1217        .is_some_and(|url| {
1218            url.host_str()
1219                .is_some_and(|host| host.eq_ignore_ascii_case("inference-api.nvidia.com"))
1220        })
1221}
1222
1223fn strip_anthropic_tool_strict(body: &mut Value) {
1224    let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) else {
1225        return;
1226    };
1227    for tool in tools.iter_mut().filter_map(Value::as_object_mut) {
1228        tool.remove("strict");
1229    }
1230}
1231
1232// Removes replayed `thinking` blocks that carry no signature.
1233//
1234// Anthropic requires signed thinking blocks on replay. A router can serve earlier
1235// turns of a session from an OpenAI-format target whose thinking blocks are
1236// unsigned, so the Anthropic leg must drop them or the upstream rejects the
1237// request. Bedrock enforces this (surfacing as a SigV4 signature mismatch) where
1238// Azure-hosted Anthropic currently does not.
1239fn strip_unsigned_thinking_blocks(body: &mut Value) {
1240    let Value::Object(object) = body else {
1241        return;
1242    };
1243    let Some(Value::Array(messages)) = object.get_mut("messages") else {
1244        return;
1245    };
1246    for message in messages {
1247        strip_unsigned_thinking_from_message(message);
1248    }
1249}
1250
1251// Drops unsigned thinking blocks from one message, collapsing content that ends
1252// up empty to an empty string so the message stays valid.
1253fn strip_unsigned_thinking_from_message(message: &mut Value) {
1254    let Value::Object(message) = message else {
1255        return;
1256    };
1257    let Some(Value::Array(blocks)) = message.get("content") else {
1258        return;
1259    };
1260    if !blocks.iter().any(is_unsigned_thinking_block) {
1261        return;
1262    }
1263    let Some(Value::Array(blocks)) = message.get_mut("content") else {
1264        return;
1265    };
1266    blocks.retain(|block| !is_unsigned_thinking_block(block));
1267    if blocks.is_empty() {
1268        message.insert("content".to_string(), Value::String(String::new()));
1269    }
1270}
1271
1272// A thinking block is unsigned when `signature` is absent or empty.
1273fn is_unsigned_thinking_block(block: &Value) -> bool {
1274    if block.get("type").and_then(Value::as_str) != Some("thinking") {
1275        return false;
1276    }
1277    !matches!(
1278        block.get("signature").and_then(Value::as_str),
1279        Some(signature) if !signature.is_empty()
1280    )
1281}
1282
1283// Forces the target's configured reasoning effort onto the outbound body, replacing the
1284// caller's value. Unlike `extra_body`, this is an override: a route that sends one model at a
1285// higher effort than the client asked for is the point of the setting.
1286fn apply_reasoning_effort(body: &mut Value, backend: &Backend) {
1287    let Some(effort) = backend.reasoning_effort() else {
1288        return;
1289    };
1290    let Value::Object(object) = body else {
1291        return;
1292    };
1293    match backend {
1294        // Responses nests effort under `reasoning`, Anthropic under `output_config`.
1295        Backend::OpenAiResponses(_) => set_nested_effort(object, "reasoning", effort),
1296        Backend::Anthropic(_) => set_nested_effort(object, "output_config", effort),
1297        Backend::OpenAiChat(_) => {
1298            // Chat Completions takes effort as a top-level field.
1299            object.insert(
1300                "reasoning_effort".to_string(),
1301                Value::String(effort.to_string()),
1302            );
1303        }
1304    }
1305}
1306
1307// Replaces only `effort` inside `key`, keeping fields the caller set next to it (`summary` or
1308// `format`, for example). A non-object value there is malformed and is replaced whole.
1309fn set_nested_effort(object: &mut serde_json::Map<String, Value>, key: &str, effort: &str) {
1310    let container = object
1311        .entry(key.to_string())
1312        .or_insert_with(|| Value::Object(serde_json::Map::new()));
1313    if !container.is_object() {
1314        *container = Value::Object(serde_json::Map::new());
1315    }
1316    if let Value::Object(container) = container {
1317        container.insert("effort".to_string(), Value::String(effort.to_string()));
1318    }
1319}
1320
1321// Applies target defaults without overriding fields supplied by the caller.
1322fn merge_extra_body(body: &mut Value, extra_body: &BTreeMap<String, Value>) {
1323    let Value::Object(object) = body else {
1324        return;
1325    };
1326    for (key, value) in extra_body {
1327        object.entry(key.clone()).or_insert_with(|| value.clone());
1328    }
1329}
1330
1331fn omit_configured_body_fields(body: &mut Value, omit_body_fields: &BTreeSet<String>) {
1332    let Value::Object(object) = body else {
1333        return;
1334    };
1335    for key in omit_body_fields {
1336        object.remove(key);
1337    }
1338}
1339
1340// Anthropic and Bedrock both cap a request at four blocks carrying
1341// `cache_control`, counting tools, system blocks and message blocks together.
1342const MAX_CACHE_CONTROL_BLOCKS: usize = 4;
1343
1344// Counts the blocks upstream will see carrying a `cache_control` marker. The
1345// marker's own value holds no such key, so it is never counted twice.
1346fn count_cache_control_blocks(value: &Value) -> usize {
1347    match value {
1348        Value::Object(map) => {
1349            usize::from(map.contains_key("cache_control"))
1350                + map.values().map(count_cache_control_blocks).sum::<usize>()
1351        }
1352        Value::Array(items) => items.iter().map(count_cache_control_blocks).sum(),
1353        _ => 0,
1354    }
1355}
1356
1357// This helper marks the final caller content block before any temporary notes, if policy permits.
1358fn enable_anthropic_prompt_caching(body: &mut Value, temporary_note_count: usize) {
1359    // Anthropic rejects a fifth cache marker with HTTP 400.
1360    // This check preserves the caller's markers when the request has reached that limit.
1361    if count_cache_control_blocks(body) >= MAX_CACHE_CONTROL_BLOCKS {
1362        return;
1363    }
1364    // Requests with only a top-level `1h` policy can return HTTP 400.
1365    // New block markers match the caller's TTL on turns with or without notes.
1366    let marker = body
1367        .get("cache_control")
1368        .cloned()
1369        .unwrap_or_else(|| serde_json::json!({"type":"ephemeral"}));
1370    let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
1371        return;
1372    };
1373    let mut remaining = temporary_note_count;
1374    // Temporary guidance is absent next turn, so the breakpoint must precede it.
1375    for message in messages.iter_mut().rev() {
1376        match message.get_mut("content") {
1377            Some(content @ Value::String(_)) if remaining == 0 => {
1378                let text = std::mem::take(content);
1379                *content = serde_json::json!([{
1380                    "type": "text", "text": text, "cache_control": marker
1381                }]);
1382                return;
1383            }
1384            Some(Value::String(_)) => remaining = remaining.saturating_sub(1),
1385            Some(Value::Array(blocks)) => {
1386                if remaining >= blocks.len() {
1387                    remaining -= blocks.len();
1388                    continue;
1389                }
1390                let index = blocks.len() - remaining - 1;
1391                if let Some(block) = blocks[index].as_object_mut() {
1392                    block.entry("cache_control".to_string()).or_insert(marker);
1393                }
1394                return;
1395            }
1396            _ => {}
1397        }
1398    }
1399}
1400
1401// GPT-5.6 and GPT-6 cache writes end at selected message or content boundaries.
1402// A temporary suffix needs a separate cache entry before it so the next request can reuse the prefix.
1403fn enable_openai_guidance_caching(
1404    body: &mut Value,
1405    format: WireFormat,
1406    model: &ModelId,
1407    suffix: usize,
1408) {
1409    let name = model.as_str().rsplit('/').next().unwrap_or_default();
1410    let supported = name == "gpt-5.6"
1411        || name.starts_with("gpt-5.6-")
1412        || name == "gpt-6"
1413        || name.starts_with("gpt-6-")
1414        || name.starts_with("gpt-6.");
1415    // Explicit-only callers choose their write locations.
1416    // This helper adds markers only for names recognized by its model-name check.
1417    if suffix == 0
1418        || !supported
1419        || body
1420            .pointer("/prompt_cache_options/mode")
1421            .and_then(Value::as_str)
1422            == Some("explicit")
1423    {
1424        return;
1425    }
1426    let key = if format == WireFormat::OpenAiResponses {
1427        "input"
1428    } else {
1429        "messages"
1430    };
1431    let Some(items) = body.get_mut(key).and_then(Value::as_array_mut) else {
1432        return;
1433    };
1434    let marker_count = items
1435        .iter()
1436        .map(|item| {
1437            ["content", "output"]
1438                .iter()
1439                .filter_map(|key| item.get(key).and_then(Value::as_array))
1440                .flatten()
1441                .filter(|block| block.get("prompt_cache_breakpoint").is_some())
1442                .count()
1443        })
1444        .sum::<usize>();
1445    // Implicit mode reserves one of the four write slots for the provider's final breakpoint.
1446    if marker_count >= 3 {
1447        return;
1448    }
1449    let Some(prefix_len) = items.len().checked_sub(suffix) else {
1450        return;
1451    };
1452    for item in items[..prefix_len].iter_mut().rev() {
1453        let field = if item.get("type").and_then(Value::as_str) == Some("function_call_output") {
1454            "output"
1455        } else if matches!(
1456            item.get("role").and_then(Value::as_str),
1457            Some("user" | "tool" | "developer" | "system")
1458        ) {
1459            "content"
1460        } else {
1461            continue;
1462        };
1463        let Some(content) = item.get_mut(field) else {
1464            continue;
1465        };
1466        if let Value::String(text) = content {
1467            let text = std::mem::take(text);
1468            let kind = if format == WireFormat::OpenAiResponses {
1469                "input_text"
1470            } else {
1471                "text"
1472            };
1473            *content = serde_json::json!([{"type":kind,"text":text}]);
1474        }
1475        // Empty content arrays have no block to mark, so the search continues through earlier items.
1476        if let Some(block) = content
1477            .as_array_mut()
1478            .and_then(|blocks| blocks.last_mut())
1479            .and_then(Value::as_object_mut)
1480        {
1481            block
1482                .entry("prompt_cache_breakpoint".to_string())
1483                .or_insert_with(|| serde_json::json!({"mode":"explicit"}));
1484            return;
1485        }
1486    }
1487}
1488
1489// Requests streamed Chat usage by default while preserving an explicit caller choice.
1490fn ensure_openai_stream_usage(body: &mut Value) {
1491    let Value::Object(object) = body else {
1492        return;
1493    };
1494    if object.get("stream").and_then(Value::as_bool) != Some(true) {
1495        return;
1496    }
1497
1498    match object.get_mut("stream_options") {
1499        Some(Value::Object(options)) => {
1500            options
1501                .entry("include_usage".to_string())
1502                .or_insert(Value::Bool(true));
1503        }
1504        _ => {
1505            let mut options = Map::new();
1506            options.insert("include_usage".to_string(), Value::Bool(true));
1507            object.insert("stream_options".to_string(), Value::Object(options));
1508        }
1509    }
1510}
1511
1512fn is_allowed_metadata_header(name: &str) -> bool {
1513    ALLOWED_METADATA_HEADERS
1514        .iter()
1515        .any(|allowed| name.eq_ignore_ascii_case(allowed))
1516}
1517
1518fn is_non_forwardable_header(name: &str, headers: &HeaderMap) -> bool {
1519    NON_FORWARDABLE_HEADERS
1520        .iter()
1521        .any(|blocked| name.eq_ignore_ascii_case(blocked))
1522        || headers.get_all("connection").iter().any(|value| {
1523            value
1524                .as_bytes()
1525                .split(|byte| *byte == b',')
1526                .any(|option| option.trim_ascii().eq_ignore_ascii_case(name.as_bytes()))
1527        })
1528}
1529
1530// Unknown application headers can carry credentials when auth forwarding is enabled.
1531fn redact_forwarded_headers(
1532    mut body: String,
1533    metadata: Option<&Metadata>,
1534    is_forwarding_auth: bool,
1535) -> String {
1536    if !is_forwarding_auth {
1537        return body;
1538    }
1539    let Some(headers) = metadata.and_then(|metadata| metadata.http_headers.as_ref()) else {
1540        return body;
1541    };
1542    for (name, value) in headers {
1543        if is_non_forwardable_header(name.as_str(), headers) {
1544            continue;
1545        }
1546        let Ok(value) = value.to_str() else {
1547            continue;
1548        };
1549        if !value.is_empty() {
1550            body = body.replace(value, "[REDACTED]");
1551        }
1552    }
1553    body
1554}
1555
1556#[cfg(test)]
1557mod tests {
1558    use std::collections::BTreeMap;
1559    use std::error::Error;
1560    use std::io::{Read, Write};
1561    use std::sync::Arc;
1562    use std::sync::atomic::{AtomicUsize, Ordering};
1563    use std::thread::JoinHandle;
1564
1565    use serde_json::json;
1566    use switchyard_protocol::{completion_text, text_request};
1567    use wiremock::matchers::{method, path};
1568    use wiremock::{Mock, MockServer, ResponseTemplate};
1569
1570    use super::*;
1571    use crate::backend::HttpBackendConfig;
1572
1573    fn config(base_url: &str) -> HttpBackendConfig {
1574        HttpBackendConfig {
1575            base_url: base_url.to_string(),
1576            api_key: Some("secret".to_string()),
1577            forward_auth: false,
1578            extra_headers: BTreeMap::new(),
1579            extra_body: BTreeMap::new(),
1580            omit_body_fields: BTreeSet::new(),
1581            reasoning_effort: None,
1582            max_retries: 0,
1583            failure_cooldown: Duration::ZERO,
1584            timeout: None,
1585        }
1586    }
1587
1588    #[test]
1589    fn provider_endpoint_urls_preserve_query_and_fragment() {
1590        let invalid_url = "not a URL/";
1591        let backend = Backend::OpenAiResponses(config(invalid_url));
1592        assert!(openai_url(invalid_url, "/responses").is_err());
1593        assert_eq!(backend.url(), invalid_url);
1594        assert_eq!(
1595            AuxiliaryOperation::ResponsesCompact.url(&backend),
1596            invalid_url
1597        );
1598
1599        let anthropic = Backend::Anthropic(config(invalid_url));
1600        assert_eq!(anthropic.url(), invalid_url);
1601        assert_eq!(anthropic.count_tokens_url(), invalid_url);
1602
1603        for path in ["", "/v1/", "/v1/messages"] {
1604            for tail in [
1605                "",
1606                "?api-version=2026-09-01&value=%2F/",
1607                "#fragment?value=/",
1608            ] {
1609                let base_url = format!("https://example.com{path}{tail}");
1610                let backend = Backend::Anthropic(config(&base_url));
1611                assert_eq!(
1612                    backend.url(),
1613                    format!("https://example.com/v1/messages{tail}")
1614                );
1615                assert_eq!(
1616                    AuxiliaryOperation::AnthropicCountTokens.url(&backend),
1617                    format!("https://example.com/v1/messages/count_tokens{tail}")
1618                );
1619            }
1620        }
1621
1622        for path in ["/v1", "/v1/responses/", "/v1/chat/completions"] {
1623            for tail in [
1624                "",
1625                "?api-version=2026-09-01&value=%2F/",
1626                "#fragment?value=/",
1627            ] {
1628                let base_url = format!("https://example.com{path}{tail}");
1629                let responses = Backend::OpenAiResponses(config(&base_url));
1630                let chat = Backend::OpenAiChat(config(&base_url));
1631                for (actual, endpoint) in [
1632                    (responses.url(), "/responses"),
1633                    (chat.url(), "/chat/completions"),
1634                    (
1635                        AuxiliaryOperation::ResponsesCompact.url(&responses),
1636                        "/responses/compact",
1637                    ),
1638                    (
1639                        AuxiliaryOperation::ResponsesInputTokens.url(&responses),
1640                        "/responses/input_tokens",
1641                    ),
1642                ] {
1643                    assert_eq!(actual, format!("https://example.com/v1{endpoint}{tail}"));
1644                }
1645            }
1646        }
1647    }
1648
1649    fn config_with_retries(base_url: &str, max_retries: u32) -> HttpBackendConfig {
1650        HttpBackendConfig {
1651            max_retries,
1652            ..config(base_url)
1653        }
1654    }
1655
1656    fn forwarding_config(base_url: &str) -> HttpBackendConfig {
1657        HttpBackendConfig {
1658            api_key: None,
1659            forward_auth: true,
1660            ..config(base_url)
1661        }
1662    }
1663
1664    // A one-model config list: "gpt" served over OpenAI Chat at base_url.
1665    fn chat_map(base_url: &str) -> Vec<ModelConfig> {
1666        vec![ModelConfig::new(
1667            "gpt",
1668            Backend::OpenAiChat(config(base_url)),
1669            None,
1670        )]
1671    }
1672
1673    fn responses_map(base_url: &str) -> Vec<ModelConfig> {
1674        vec![ModelConfig::new(
1675            "gpt",
1676            Backend::OpenAiResponses(config(base_url)),
1677            None,
1678        )]
1679    }
1680
1681    fn chat_map_with_extra_body(
1682        base_url: &str,
1683        extra_body: BTreeMap<String, Value>,
1684    ) -> Vec<ModelConfig> {
1685        let mut backend = config(base_url);
1686        backend.extra_body = extra_body;
1687        vec![ModelConfig::new("gpt", Backend::OpenAiChat(backend), None)]
1688    }
1689
1690    fn chat_map_with_omit_body_fields(
1691        base_url: &str,
1692        omit_body_fields: BTreeSet<String>,
1693    ) -> Vec<ModelConfig> {
1694        let mut backend = config(base_url);
1695        backend.omit_body_fields = omit_body_fields;
1696        vec![ModelConfig::new("gpt", Backend::OpenAiChat(backend), None)]
1697    }
1698
1699    fn chat_map_with_effort(base_url: &str, effort: &str) -> Vec<ModelConfig> {
1700        let mut backend = config(base_url);
1701        backend.reasoning_effort = Some(effort.to_string());
1702        vec![ModelConfig::new("gpt", Backend::OpenAiChat(backend), None)]
1703    }
1704
1705    fn responses_map_with_effort(base_url: &str, effort: &str) -> Vec<ModelConfig> {
1706        let mut backend = config(base_url);
1707        backend.reasoning_effort = Some(effort.to_string());
1708        vec![ModelConfig::new(
1709            "gpt",
1710            Backend::OpenAiResponses(backend),
1711            None,
1712        )]
1713    }
1714
1715    fn anthropic_map(base_url: &str) -> Vec<ModelConfig> {
1716        vec![ModelConfig::new(
1717            "claude",
1718            Backend::Anthropic(config(base_url)),
1719            None,
1720        )]
1721    }
1722
1723    #[tokio::test]
1724    async fn model_properties_uses_configured_auth_and_backend_path()
1725    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1726        for (base_path, props_path) in [
1727            ("/v1", "/props"),
1728            ("/v1/", "/props"),
1729            ("/v1/chat/completions", "/props"),
1730            ("/v1/responses/", "/props"),
1731            ("/prefix/v1", "/prefix/props"),
1732            ("/prefix/v1/chat/completions", "/prefix/props"),
1733            ("/prefix/v1/responses", "/prefix/props"),
1734            ("/prefix/", "/prefix/props"),
1735        ] {
1736            let server = MockServer::start().await;
1737            Mock::given(method("GET"))
1738                .and(path(props_path))
1739                .and(wiremock::matchers::header("authorization", "Bearer secret"))
1740                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1741                    "default_generation_settings": {"n_ctx": 65_536}
1742                })))
1743                .expect(1)
1744                .mount(&server)
1745                .await;
1746            let client =
1747                TranslatingLlmClient::new(&chat_map(&format!("{}{base_path}", server.uri())))?;
1748            let properties = client
1749                .get_model_properties(
1750                    &ModelId::from("gpt"),
1751                    WireFormat::OpenAiChat,
1752                    Duration::from_secs(1),
1753                )
1754                .await?;
1755            assert_eq!(properties["default_generation_settings"]["n_ctx"], 65_536);
1756        }
1757        Ok(())
1758    }
1759
1760    fn chat_map_with_retries(base_url: &str, max_retries: u32) -> Vec<ModelConfig> {
1761        vec![ModelConfig::new(
1762            "gpt",
1763            Backend::OpenAiChat(config_with_retries(base_url, max_retries)),
1764            None,
1765        )]
1766    }
1767
1768    fn chat_success_response() -> ResponseTemplate {
1769        ResponseTemplate::new(200).set_body_json(json!({
1770            "id": "chatcmpl-1",
1771            "model": "gpt",
1772            "choices": [{
1773                "index": 0,
1774                "message": {"role": "assistant", "content": "recovered"},
1775                "finish_reason": "stop"
1776            }],
1777            "usage": {}
1778        }))
1779    }
1780
1781    fn truncated_response_server(
1782        content_type: &str,
1783        body: &str,
1784    ) -> std::io::Result<(String, JoinHandle<std::io::Result<()>>)> {
1785        response_sequence_server(vec![format!(
1786            "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\n\
1787             Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
1788            body.len() + 100
1789        )])
1790    }
1791
1792    fn response_sequence_server(
1793        responses: Vec<String>,
1794    ) -> std::io::Result<(String, JoinHandle<std::io::Result<()>>)> {
1795        let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
1796        let address = listener.local_addr()?;
1797        let handle = std::thread::spawn(move || {
1798            for response in responses {
1799                let (mut stream, _) = listener.accept()?;
1800                let mut request = [0_u8; 1024];
1801                if stream.read(&mut request)? == 0 {
1802                    return Err(std::io::Error::new(
1803                        std::io::ErrorKind::UnexpectedEof,
1804                        "client closed before sending a request",
1805                    ));
1806                }
1807                stream.write_all(response.as_bytes())?;
1808            }
1809            Ok(())
1810        });
1811        Ok((format!("http://{address}/v1"), handle))
1812    }
1813
1814    fn raw_chat_success_response() -> String {
1815        let body = r#"{"id":"chatcmpl-1","model":"gpt","choices":[{"index":0,"message":{"role":"assistant","content":"recovered"},"finish_reason":"stop"}],"usage":{}}"#;
1816        format!(
1817            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
1818             Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
1819            body.len()
1820        )
1821    }
1822
1823    fn request_for(model: Option<&str>, stream: bool) -> Request {
1824        let mut llm_request = text_request(model.map(str::to_string), "hi");
1825        llm_request.stream = stream;
1826        Request {
1827            llm_request,
1828            raw_request: None,
1829            metadata: None,
1830        }
1831    }
1832
1833    fn request_with_headers(model: &str, headers: HeaderMap) -> Request {
1834        let mut request = request_for(Some(model), false);
1835        request.metadata = Some(Metadata {
1836            http_headers: Some(headers),
1837            ..Default::default()
1838        });
1839        request
1840    }
1841
1842    #[tokio::test]
1843    async fn transport_errors_drop_the_upstream_url() {
1844        let error = reqwest::Client::new()
1845            .post("http://127.0.0.1:1/v1?key=CANARY")
1846            .send()
1847            .await
1848            .expect_err("closed port");
1849
1850        assert!(!convert_reqwest_error(error).to_string().contains("CANARY"));
1851    }
1852
1853    #[tokio::test]
1854    async fn captured_request_build_failure_remains_a_configuration_error() {
1855        let client = TranslatingLlmClient::new(&chat_map("://invalid")).unwrap();
1856        let result =
1857            crate::with_payload_capture(client.call(request_for(Some("gpt"), false))).await;
1858        assert!(matches!(result, Err(LlmClientError::Configuration { .. })));
1859    }
1860
1861    #[test]
1862    fn nvidia_anthropic_tools_drop_unsupported_strict_field() {
1863        let backend = Backend::Anthropic(config("https://inference-api.nvidia.com"));
1864        assert!(nvidia_inference_api_backend(&backend));
1865        assert!(!nvidia_inference_api_backend(&Backend::Anthropic(config(
1866            "https://inference-api.nvidia.com.example.test"
1867        ))));
1868        let mut body = json!({"tools": [{"name": "a", "input_schema": {}, "strict": true}]});
1869        strip_anthropic_tool_strict(&mut body);
1870        assert!(body["tools"][0].get("strict").is_none());
1871    }
1872
1873    #[test]
1874    fn anthropic_prompt_caching_marks_final_message() {
1875        let caller_managed = json!({
1876            "cache_control": {"type": "ephemeral", "ttl": "1h"},
1877            "messages": [{"role": "user", "content": "hello"}]
1878        });
1879        let mut body = caller_managed.clone();
1880        enable_anthropic_prompt_caching(&mut body, 0);
1881        assert_eq!(body["cache_control"], caller_managed["cache_control"]);
1882        assert_eq!(
1883            body["messages"][0]["content"],
1884            json!([{
1885                "type": "text", "text": "hello",
1886                "cache_control": {"type": "ephemeral", "ttl": "1h"}
1887            }])
1888        );
1889
1890        let mut body = json!({
1891            "messages": [{"role": "user", "content": "hello"}]
1892        });
1893
1894        enable_anthropic_prompt_caching(&mut body, 0);
1895
1896        assert_eq!(
1897            body["messages"][0]["content"][0]["cache_control"],
1898            json!({"type": "ephemeral"})
1899        );
1900    }
1901
1902    // Counts the blocks upstream will see carrying a `cache_control` marker.
1903    fn cache_control_blocks(value: &Value) -> usize {
1904        count_cache_control_blocks(value)
1905    }
1906
1907    // A body whose four breakpoints are all spent elsewhere: the caller manages
1908    // its own caching and left the final block unmarked on purpose.
1909    fn body_at_the_cache_control_limit() -> Value {
1910        json!({
1911            "system": [
1912                {"type": "text", "text": "a", "cache_control": {"type": "ephemeral"}},
1913                {"type": "text", "text": "b", "cache_control": {"type": "ephemeral"}}
1914            ],
1915            "tools": [
1916                {"name": "t", "input_schema": {"type": "object"},
1917                 "cache_control": {"type": "ephemeral"}}
1918            ],
1919            "messages": [
1920                {"role": "user", "content": [
1921                    {"type": "text", "text": "old", "cache_control": {"type": "ephemeral"}}
1922                ]},
1923                {"role": "assistant", "content": [{"type": "text", "text": "ok"}]},
1924                {"role": "user", "content": [{"type": "text", "text": "new"}]}
1925            ]
1926        })
1927    }
1928
1929    #[test]
1930    fn anthropic_prompt_caching_abstains_at_the_cache_control_limit() {
1931        let mut body = body_at_the_cache_control_limit();
1932        assert_eq!(cache_control_blocks(&body), 4);
1933
1934        enable_anthropic_prompt_caching(&mut body, 0);
1935
1936        // Anthropic and Bedrock both reject a fifth marker with HTTP 400, which
1937        // would fail a request that was valid before it reached us.
1938        assert_eq!(cache_control_blocks(&body), 4);
1939        assert!(
1940            body["messages"][2]["content"][0]
1941                .get("cache_control")
1942                .is_none()
1943        );
1944    }
1945
1946    #[test]
1947    fn anthropic_prompt_caching_still_marks_one_below_the_limit() {
1948        let mut body = body_at_the_cache_control_limit();
1949        // Free one breakpoint, so there is room for ours.
1950        body["system"].as_array_mut().unwrap().pop();
1951        assert_eq!(cache_control_blocks(&body), 3);
1952
1953        enable_anthropic_prompt_caching(&mut body, 0);
1954
1955        assert_eq!(cache_control_blocks(&body), 4);
1956        assert_eq!(
1957            body["messages"][2]["content"][0]["cache_control"],
1958            json!({"type": "ephemeral"})
1959        );
1960    }
1961
1962    // A request that pins `format` in its metadata, so the client resolves that
1963    // wire format instead of the model's default backend.
1964    fn request_with_wire_format(model: &str, format: WireFormat) -> Request {
1965        let mut request = request_for(Some(model), false);
1966        request.metadata = Some(Metadata {
1967            session_id: None,
1968            agent_id: None,
1969            task_id: None,
1970            correlation_id: None,
1971            extra_metadata: None,
1972            http_headers: None,
1973            wire_format: Some(format),
1974            ..Default::default()
1975        });
1976        request
1977    }
1978
1979    #[tokio::test]
1980    async fn missing_model_errors()
1981    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1982        let client = TranslatingLlmClient::new(&[])?;
1983        let Err(error) = client
1984            .call_rewrite_model(request_for(None, false), None)
1985            .await
1986        else {
1987            panic!("expected an error");
1988        };
1989        assert!(matches!(
1990            error,
1991            LlmClientError::InvalidRequest { message } if message == "no model given"
1992        ));
1993        Ok(())
1994    }
1995
1996    #[tokio::test]
1997    async fn unknown_model_errors()
1998    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1999        let client = TranslatingLlmClient::new(&[])?;
2000        let Err(error) = client
2001            .call_rewrite_model(request_for(Some("gpt"), false), None)
2002            .await
2003        else {
2004            panic!("expected an error");
2005        };
2006        assert!(matches!(
2007            error,
2008            LlmClientError::Configuration { message }
2009                if message.contains("gpt")
2010        ));
2011        Ok(())
2012    }
2013
2014    #[tokio::test]
2015    async fn unknown_model_format_errors()
2016    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2017        // "gpt" exists but only over OpenAI Chat; the request pins Anthropic.
2018        let client = TranslatingLlmClient::new(&chat_map("https://example.test/v1"))?;
2019        let Err(error) = client
2020            .call_rewrite_model(
2021                request_with_wire_format("gpt", WireFormat::AnthropicMessages),
2022                None,
2023            )
2024            .await
2025        else {
2026            panic!("expected an error");
2027        };
2028        assert!(matches!(
2029            error,
2030            LlmClientError::Configuration { message }
2031                if message.contains("gpt")
2032                    && message.contains(&WireFormat::AnthropicMessages.to_string())
2033        ));
2034        Ok(())
2035    }
2036
2037    #[test]
2038    fn backend_for_resolves_configured_format()
2039    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2040        let client = TranslatingLlmClient::new(&chat_map("https://example.test/v1"))?;
2041        // "gpt" is served over OpenAI Chat only; other formats and models miss.
2042        assert!(
2043            client
2044                .backend_for(&ModelId::from("gpt"), WireFormat::OpenAiChat)
2045                .is_some()
2046        );
2047        assert!(
2048            client
2049                .backend_for(&ModelId::from("gpt"), WireFormat::AnthropicMessages)
2050                .is_none()
2051        );
2052        assert!(
2053            client
2054                .backend_for(&ModelId::from("missing"), WireFormat::OpenAiChat)
2055                .is_none()
2056        );
2057        Ok(())
2058    }
2059
2060    #[tokio::test]
2061    async fn model_name_arg_wins_over_request_model()
2062    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2063        let client = TranslatingLlmClient::new(&[])?;
2064        // Arg "b" is looked up (and reported), not the request's "a".
2065        let Err(error) = client
2066            .call_rewrite_model(request_for(Some("a"), false), Some(&ModelId::from("b")))
2067            .await
2068        else {
2069            panic!("expected an error");
2070        };
2071        assert!(matches!(
2072            error,
2073            LlmClientError::Configuration { message }
2074                if message.contains("\"b\"")
2075        ));
2076        Ok(())
2077    }
2078
2079    #[tokio::test]
2080    async fn buffered_openai_chat_round_trips()
2081    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2082        let server = MockServer::start().await;
2083        Mock::given(method("POST"))
2084            .and(path("/v1/chat/completions"))
2085            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2086                "id": "chatcmpl-1",
2087                "model": "gpt",
2088                "choices": [{
2089                    "index": 0,
2090                    "message": {"role": "assistant", "content": "Hi there"},
2091                    "finish_reason": "stop"
2092                }],
2093                "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
2094            })))
2095            .mount(&server)
2096            .await;
2097
2098        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
2099
2100        let response = client
2101            .call_rewrite_model(request_for(Some("gpt"), false), None)
2102            .await?;
2103        let agg = response.llm_response.into_agg().await?;
2104        assert_eq!(completion_text(&agg), "Hi there");
2105
2106        Ok(())
2107    }
2108
2109    #[tokio::test]
2110    async fn invalid_json_is_a_response_translation_error()
2111    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2112        let server = MockServer::start().await;
2113        let calls = Arc::new(AtomicUsize::new(0));
2114        let observed_calls = Arc::clone(&calls);
2115        Mock::given(method("POST"))
2116            .respond_with(move |_: &wiremock::Request| {
2117                observed_calls.fetch_add(1, Ordering::SeqCst);
2118                ResponseTemplate::new(200).set_body_raw("not json", "application/json")
2119            })
2120            .mount(&server)
2121            .await;
2122
2123        let client =
2124            TranslatingLlmClient::new(&chat_map_with_retries(&format!("{}/v1", server.uri()), 2))?;
2125        let Err(error) = client
2126            .call_rewrite_model(request_for(Some("gpt"), false), None)
2127            .await
2128        else {
2129            panic!("expected invalid JSON to fail");
2130        };
2131
2132        assert!(matches!(
2133            error,
2134            LlmClientError::ResponseTranslation(message)
2135                if message.contains("invalid upstream JSON")
2136        ));
2137        assert_eq!(calls.load(Ordering::SeqCst), 1);
2138        Ok(())
2139    }
2140
2141    #[tokio::test]
2142    async fn response_body_io_failure_is_a_transport_error()
2143    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2144        let (base_url, server) = truncated_response_server("application/json", "{}")?;
2145        let client = TranslatingLlmClient::new(&chat_map(&base_url))?;
2146        let result = client
2147            .call_rewrite_model(request_for(Some("gpt"), false), None)
2148            .await;
2149        server
2150            .join()
2151            .map_err(|_| std::io::Error::other("response server thread panicked"))??;
2152
2153        let Err(error) = result else {
2154            panic!("expected the truncated response body to fail");
2155        };
2156        let LlmClientError::Transport { source } = error else {
2157            panic!("expected a transport error");
2158        };
2159        let Some(source) = source.downcast_ref::<reqwest::Error>() else {
2160            panic!("expected the reqwest transport source");
2161        };
2162        assert!(source.is_decode());
2163        assert!(
2164            !std::error::Error::source(&source)
2165                .is_some_and(|source| source.is::<serde_json::Error>())
2166        );
2167        Ok(())
2168    }
2169
2170    #[tokio::test]
2171    async fn response_body_transport_failures_are_retried()
2172    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2173        let truncated_responses = [
2174            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
2175             Content-Length: 102\r\nConnection: close\r\n\r\n{}",
2176            "HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\n\
2177             Content-Length: 100\r\nConnection: close\r\n\r\nbad",
2178        ];
2179
2180        for truncated in truncated_responses {
2181            let (base_url, server) =
2182                response_sequence_server(vec![truncated.to_string(), raw_chat_success_response()])?;
2183            let client = TranslatingLlmClient::new(&chat_map_with_retries(&base_url, 1))?;
2184            let response = client
2185                .call_rewrite_model(request_for(Some("gpt"), false), None)
2186                .await?;
2187            server
2188                .join()
2189                .map_err(|_| std::io::Error::other("response server thread panicked"))??;
2190
2191            assert_eq!(
2192                completion_text(&response.llm_response.into_agg().await?),
2193                "recovered"
2194            );
2195        }
2196        Ok(())
2197    }
2198
2199    #[tokio::test]
2200    async fn pre_first_event_transport_failure_uses_retry_budget()
2201    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2202        let truncated = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\
2203                         Content-Length: 100\r\nConnection: close\r\n\r\n";
2204        let body = "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"recovered\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n";
2205        let success = format!(
2206            "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\
2207             Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
2208            body.len()
2209        );
2210
2211        for second in [success, truncated.to_string()] {
2212            let is_exhausted = second == truncated;
2213            let (base_url, server) = response_sequence_server(vec![truncated.to_string(), second])?;
2214            let client = TranslatingLlmClient::new(&chat_map_with_retries(&base_url, 1))?;
2215            let result = client
2216                .call_rewrite_model(request_for(Some("gpt"), true), None)
2217                .await;
2218            if is_exhausted {
2219                assert!(matches!(result, Err(LlmClientError::Transport { .. })));
2220            } else {
2221                assert_eq!(
2222                    completion_text(&result?.llm_response.into_agg().await?),
2223                    "recovered"
2224                );
2225            }
2226            server
2227                .join()
2228                .map_err(|_| std::io::Error::other("response server thread panicked"))??;
2229        }
2230        Ok(())
2231    }
2232
2233    #[tokio::test]
2234    async fn streaming_body_io_failure_preserves_transport_error()
2235    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2236        let body = "data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n";
2237        let (base_url, server) = truncated_response_server("text/event-stream", body)?;
2238        let client = TranslatingLlmClient::new(&chat_map_with_retries(&base_url, 2))?;
2239        let response = client
2240            .call_rewrite_model(request_for(Some("gpt"), true), None)
2241            .await?;
2242        let result = response.llm_response.into_agg().await;
2243        server
2244            .join()
2245            .map_err(|_| std::io::Error::other("response server thread panicked"))??;
2246
2247        let Err(error) = result else {
2248            panic!("expected the truncated stream body to fail");
2249        };
2250
2251        assert!(matches!(error, LlmClientError::Transport { .. }));
2252        Ok(())
2253    }
2254
2255    #[tokio::test]
2256    async fn rewrites_model_to_resolved_upstream_id()
2257    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2258        // Inbound body says "switchyard"; the upstream must receive "gpt".
2259        let server = MockServer::start().await;
2260        Mock::given(method("POST"))
2261            .and(path("/v1/chat/completions"))
2262            .and(wiremock::matchers::body_partial_json(json!({"model": "gpt"})))
2263            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2264                "id": "1", "model": "gpt",
2265                "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
2266                "usage": {}
2267            })))
2268            .mount(&server)
2269            .await;
2270
2271        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
2272        // Inbound model differs from the map key / resolved model.
2273        client
2274            .call_rewrite_model(
2275                request_for(Some("switchyard"), false),
2276                Some(&ModelId::from("gpt")),
2277            )
2278            .await?;
2279        // The body_partial_json matcher asserts the upstream saw model "gpt".
2280        Ok(())
2281    }
2282
2283    /// A configured reasoning effort replaces the caller's value on both OpenAI wire formats,
2284    /// which `extra_body` (defaults only) cannot do.
2285    #[tokio::test]
2286    async fn reasoning_effort_override_replaces_the_callers_effort()
2287    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2288        let server = MockServer::start().await;
2289        Mock::given(method("POST"))
2290            .and(path("/v1/chat/completions"))
2291            .and(wiremock::matchers::body_partial_json(json!({
2292                "model": "gpt",
2293                "reasoning_effort": "max"
2294            })))
2295            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2296                "id": "1",
2297                "model": "gpt",
2298                "choices": [{
2299                    "index": 0,
2300                    "message": {"role": "assistant", "content": "ok"},
2301                    "finish_reason": "stop"
2302                }],
2303                "usage": {}
2304            })))
2305            .mount(&server)
2306            .await;
2307        let client = TranslatingLlmClient::new(&chat_map_with_effort(
2308            &format!("{}/v1", server.uri()),
2309            "max",
2310        ))?;
2311        client
2312            .call_rewrite_model_raw(
2313                json!({
2314                    "model": "client-facing",
2315                    "messages": [{"role": "user", "content": "hi"}],
2316                    "reasoning_effort": "high"
2317                }),
2318                None,
2319                Some(&ModelId::from("gpt")),
2320                WireFormat::OpenAiChat,
2321            )
2322            .await?;
2323
2324        let server = MockServer::start().await;
2325        Mock::given(method("POST"))
2326            .and(path("/v1/responses"))
2327            .and(wiremock::matchers::body_partial_json(json!({
2328                "model": "gpt",
2329                "reasoning": {"effort": "max", "summary": "auto"}
2330            })))
2331            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2332                "id": "resp_1",
2333                "object": "response",
2334                "model": "gpt",
2335                "status": "completed",
2336                "output": [{
2337                    "type": "message",
2338                    "id": "msg_1",
2339                    "role": "assistant",
2340                    "status": "completed",
2341                    "content": [{"type": "output_text", "text": "ok"}]
2342                }],
2343                "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}
2344            })))
2345            .mount(&server)
2346            .await;
2347        let client = TranslatingLlmClient::new(&responses_map_with_effort(
2348            &format!("{}/v1", server.uri()),
2349            "max",
2350        ))?;
2351        client
2352            .call_rewrite_model_raw(
2353                json!({
2354                    "model": "client-facing",
2355                    "input": [{"role": "user", "content": "hi"}],
2356                    "reasoning": {"effort": "high", "summary": "auto"}
2357                }),
2358                None,
2359                Some(&ModelId::from("gpt")),
2360                WireFormat::OpenAiResponses,
2361            )
2362            .await?;
2363        Ok(())
2364    }
2365
2366    // Covers both a translated request and an Anthropic caller's body sent as is.
2367    #[tokio::test]
2368    async fn anthropic_requests_drop_tool_strict_false()
2369    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2370        let server = MockServer::start().await;
2371        Mock::given(method("POST"))
2372            .and(path("/v1/messages"))
2373            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2374                "id": "msg_1",
2375                "type": "message",
2376                "role": "assistant",
2377                "model": "claude",
2378                "content": [{"type": "text", "text": "ok"}],
2379                "stop_reason": "end_turn",
2380                "usage": {"input_tokens": 1, "output_tokens": 1}
2381            })))
2382            .expect(2)
2383            .mount(&server)
2384            .await;
2385        let client = TranslatingLlmClient::new(&[ModelConfig::new(
2386            "claude",
2387            Backend::Anthropic(config(&server.uri())),
2388            None,
2389        )])?;
2390        let schema = json!({"type": "object"});
2391        let requests = [
2392            (
2393                WireFormat::OpenAiChat,
2394                json!({
2395                    "model": "client-facing",
2396                    "messages": [{"role": "user", "content": "hi"}],
2397                    "tools": [
2398                        {"type": "function", "function": {"name": "loose", "parameters": schema, "strict": false}},
2399                        {"type": "function", "function": {"name": "exact", "parameters": schema, "strict": true}}
2400                    ]
2401                }),
2402            ),
2403            (
2404                WireFormat::AnthropicMessages,
2405                json!({
2406                    "model": "client-facing",
2407                    "max_tokens": 8,
2408                    "messages": [{"role": "user", "content": "hi"}],
2409                    "tools": [
2410                        {"name": "loose", "input_schema": schema, "strict": false},
2411                        {"name": "exact", "input_schema": schema, "strict": true}
2412                    ]
2413                }),
2414            ),
2415        ];
2416        for (format, request) in requests {
2417            client
2418                .call_rewrite_model_raw(request, None, Some(&ModelId::from("claude")), format)
2419                .await?;
2420        }
2421        for received in server.received_requests().await.unwrap_or_default() {
2422            let body: Value = serde_json::from_slice(&received.body)?;
2423            assert!(body["tools"][0].get("strict").is_none(), "{body}");
2424            assert_eq!(body["tools"][1]["strict"], true, "{body}");
2425        }
2426        Ok(())
2427    }
2428
2429    #[tokio::test]
2430    async fn template_rejection_retries_once_without_changing_other_fields()
2431    -> std::result::Result<(), Box<dyn Error + Sync + Send>> {
2432        for (message, reject_retry, omit) in [
2433            (
2434                "Unrecognized request argument supplied: chat_template_kwargs",
2435                false,
2436                false,
2437            ),
2438            (
2439                "Unrecognized request argument supplied: chat_template_kwargs",
2440                true,
2441                false,
2442            ),
2443            (
2444                "Invalid temperature for chat_template_kwargs request",
2445                false,
2446                false,
2447            ),
2448            (
2449                "Unrecognized request argument supplied: chat_template_kwargs",
2450                false,
2451                true,
2452            ),
2453        ] {
2454            let server = MockServer::start().await;
2455            Mock::given(method("POST"))
2456                .respond_with(move |request: &wiremock::Request| {
2457                    let body: Value = serde_json::from_slice(&request.body).expect("request JSON");
2458                    if body.get("chat_template_kwargs").is_some() || reject_retry {
2459                        ResponseTemplate::new(400).set_body_json(json!({"error": {"message": message}}))
2460                    } else {
2461                        ResponseTemplate::new(200).set_body_json(json!({
2462                            "id": "1", "model": "gpt", "choices": [{"index": 0,
2463                                "message": {"role": "assistant", "content": "yes"}, "finish_reason": "stop"}],
2464                            "usage": {}
2465                        }))
2466                    }
2467                }).mount(&server).await;
2468            let fields = if omit {
2469                BTreeSet::from(["chat_template_kwargs".into()])
2470            } else {
2471                BTreeSet::new()
2472            };
2473            let client = TranslatingLlmClient::new(&chat_map_with_omit_body_fields(
2474                &format!("{}/v1", server.uri()),
2475                fields,
2476            ))?;
2477            let raw = json!({"model": "gpt", "messages": [{"role": "user", "content": "judge"}],
2478                "reasoning_effort": "none", "logprobs": true, "top_logprobs": 8,
2479                "chat_template_kwargs": {"enable_thinking": false}});
2480            let result = client
2481                .call_rewrite_model_raw(
2482                    raw.clone(),
2483                    None,
2484                    Some(&ModelId::from("gpt")),
2485                    WireFormat::OpenAiChat,
2486                )
2487                .await;
2488            let rejects_field = message.starts_with("Unrecognized");
2489            assert_eq!(result.is_ok(), omit || (rejects_field && !reject_retry));
2490            let calls = server.received_requests().await.ok_or("missing requests")?;
2491            assert_eq!(calls.len(), if !omit && rejects_field { 2 } else { 1 });
2492            for (index, call) in calls.iter().enumerate() {
2493                let mut expected = raw.clone();
2494                if omit || index > 0 {
2495                    expected
2496                        .as_object_mut()
2497                        .unwrap()
2498                        .remove("chat_template_kwargs");
2499                }
2500                assert_eq!(serde_json::from_slice::<Value>(&call.body)?, expected);
2501            }
2502        }
2503        Ok(())
2504    }
2505
2506    #[tokio::test]
2507    async fn template_compatibility_preserves_the_transient_retry_budget()
2508    -> std::result::Result<(), Box<dyn Error + Sync + Send>> {
2509        let server = MockServer::start().await;
2510        let count = Arc::new(AtomicUsize::new(0));
2511        let observed = Arc::clone(&count);
2512        Mock::given(method("POST"))
2513            .respond_with(move |request: &wiremock::Request| {
2514                let body: Value = serde_json::from_slice(&request.body).expect("request JSON");
2515                match observed.fetch_add(1, Ordering::SeqCst) {
2516                    0 => {
2517                        assert!(body.get("chat_template_kwargs").is_some());
2518                        ResponseTemplate::new(422).set_body_json(json!({"error": {
2519                            "code": "unsupported_parameter", "param": "chat_template_kwargs"
2520                        }}))
2521                    }
2522                    1 => {
2523                        assert!(body.get("chat_template_kwargs").is_none());
2524                        ResponseTemplate::new(503).insert_header("retry-after", "0")
2525                    }
2526                    _ => ResponseTemplate::new(200).set_body_json(json!({
2527                        "id": "1", "model": "gpt", "choices": [{"index": 0,
2528                            "message": {"role": "assistant", "content": "yes"}, "finish_reason": "stop"}],
2529                        "usage": {}
2530                    })),
2531                }
2532            }).mount(&server).await;
2533        let client =
2534            TranslatingLlmClient::new(&chat_map_with_retries(&format!("{}/v1", server.uri()), 1))?;
2535        client
2536            .call_rewrite_model_raw(
2537                json!({"model": "gpt", "messages": [{"role": "user", "content": "judge"}],
2538                "chat_template_kwargs": {"enable_thinking": false}}),
2539                None,
2540                Some(&ModelId::from("gpt")),
2541                WireFormat::OpenAiChat,
2542            )
2543            .await?;
2544        assert_eq!(count.load(Ordering::SeqCst), 3);
2545        Ok(())
2546    }
2547
2548    #[tokio::test]
2549    async fn preserved_codex_string_input_is_normalized_at_the_client_boundary()
2550    -> std::result::Result<(), Box<dyn Error + Sync + Send>> {
2551        for root in ["/backend-api/codex", "/v1"] {
2552            for input in [
2553                json!("hello"),
2554                json!([{"role": "user", "content": "hello"}]),
2555            ] {
2556                let server = MockServer::start().await;
2557                Mock::given(method("POST"))
2558                    .and(path(format!("{root}/responses")))
2559                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2560                        "id": "resp_1", "object": "response", "model": "gpt", "status": "completed", "output": [], "usage": {}
2561                    }))).expect(1).mount(&server).await;
2562                let client =
2563                    TranslatingLlmClient::new(&responses_map(&format!("{}{root}", server.uri())))?;
2564                let raw = json!({"model": "client-name", "input": input,
2565                    "instructions": "Be brief.", "store": false, "metadata": {"task": "judge"},
2566                    "custom_field": {"keep": true}});
2567                client
2568                    .call_rewrite_model_raw(
2569                        raw.clone(),
2570                        None,
2571                        Some(&ModelId::from("gpt")),
2572                        WireFormat::OpenAiResponses,
2573                    )
2574                    .await?;
2575                let mut expected = raw;
2576                expected["model"] = json!("gpt");
2577                if root == "/backend-api/codex" && input.is_string() {
2578                    expected["input"] =
2579                        json!([{"type": "message", "role": "user", "content": input}]);
2580                }
2581                let calls = server.received_requests().await.ok_or("missing requests")?;
2582                assert_eq!(serde_json::from_slice::<Value>(&calls[0].body)?, expected);
2583            }
2584        }
2585        Ok(())
2586    }
2587
2588    #[tokio::test]
2589    async fn token_counting_does_not_send_the_effort_override()
2590    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2591        let server = MockServer::start().await;
2592        Mock::given(method("POST"))
2593            .and(path("/v1/messages/count_tokens"))
2594            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"input_tokens": 3})))
2595            .expect(1)
2596            .mount(&server)
2597            .await;
2598        let mut backend = config(&server.uri());
2599        backend.reasoning_effort = Some("max".to_string());
2600        let client = TranslatingLlmClient::new(&[ModelConfig::new(
2601            "claude",
2602            Backend::Anthropic(backend),
2603            None,
2604        )])?;
2605        let llm_request = switchyard_translation::decode_request(
2606            WireFormat::AnthropicMessages,
2607            &json!({"model": "claude", "messages": [{"role": "user", "content": "hi"}]}),
2608        )?;
2609        let request = Request {
2610            llm_request,
2611            raw_request: None,
2612            metadata: None,
2613        };
2614        client
2615            .call_auxiliary(
2616                &ModelId::from("claude"),
2617                request,
2618                AuxiliaryOperation::AnthropicCountTokens,
2619            )
2620            .await?;
2621        let received = server.received_requests().await.unwrap_or_default();
2622        let body: Value = serde_json::from_slice(&received[0].body)?;
2623        assert!(body.get("output_config").is_none(), "{body}");
2624        Ok(())
2625    }
2626
2627    #[tokio::test]
2628    async fn reasoning_effort_override_sets_anthropic_output_config_effort()
2629    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2630        let server = MockServer::start().await;
2631        let format = json!({"type": "json_schema", "schema": {"type": "object"}});
2632        Mock::given(method("POST"))
2633            .and(path("/v1/messages"))
2634            .and(wiremock::matchers::body_partial_json(json!({
2635                "model": "claude",
2636                "output_config": {"effort": "max", "format": format}
2637            })))
2638            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2639                "id": "msg_1",
2640                "type": "message",
2641                "role": "assistant",
2642                "model": "claude",
2643                "content": [{"type": "text", "text": "ok"}],
2644                "stop_reason": "end_turn",
2645                "usage": {"input_tokens": 1, "output_tokens": 1}
2646            })))
2647            .expect(1)
2648            .mount(&server)
2649            .await;
2650        let mut backend = config(&server.uri());
2651        backend.reasoning_effort = Some("max".to_string());
2652        let client = TranslatingLlmClient::new(&[ModelConfig::new(
2653            "claude",
2654            Backend::Anthropic(backend),
2655            None,
2656        )])?;
2657        client
2658            .call_rewrite_model_raw(
2659                json!({
2660                    "model": "client-facing",
2661                    "max_tokens": 7,
2662                    "messages": [{"role": "user", "content": "hi"}],
2663                    "output_config": {"effort": "high", "format": format}
2664                }),
2665                None,
2666                Some(&ModelId::from("claude")),
2667                WireFormat::AnthropicMessages,
2668            )
2669            .await?;
2670        Ok(())
2671    }
2672
2673    #[tokio::test]
2674    async fn extra_body_adds_defaults_without_overriding_the_request()
2675    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2676        let server = MockServer::start().await;
2677        Mock::given(method("POST"))
2678            .and(path("/v1/chat/completions"))
2679            .and(wiremock::matchers::body_partial_json(json!({
2680                "model": "gpt",
2681                "max_tokens": 7,
2682                "service_tier": "priority"
2683            })))
2684            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2685                "id": "1",
2686                "model": "gpt",
2687                "choices": [{
2688                    "index": 0,
2689                    "message": {"role": "assistant", "content": "ok"},
2690                    "finish_reason": "stop"
2691                }],
2692                "usage": {}
2693            })))
2694            .mount(&server)
2695            .await;
2696
2697        let extra_body = BTreeMap::from([
2698            ("max_tokens".to_string(), json!(999)),
2699            ("service_tier".to_string(), json!("priority")),
2700        ]);
2701        let client = TranslatingLlmClient::new(&chat_map_with_extra_body(
2702            &format!("{}/v1", server.uri()),
2703            extra_body,
2704        ))?;
2705        let raw = json!({
2706            "model": "client-facing",
2707            "messages": [{"role": "user", "content": "hi"}],
2708            "max_tokens": 7
2709        });
2710
2711        client
2712            .call_rewrite_model_raw(
2713                raw,
2714                None,
2715                Some(&ModelId::from("gpt")),
2716                WireFormat::OpenAiChat,
2717            )
2718            .await?;
2719        Ok(())
2720    }
2721
2722    #[tokio::test]
2723    async fn omit_body_fields_strips_a_field_extra_body_cannot_override()
2724    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2725        let server = MockServer::start().await;
2726        Mock::given(method("POST"))
2727            .and(path("/v1/chat/completions"))
2728            .and(|request: &wiremock::Request| {
2729                let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null);
2730                body.get("max_tokens").is_none()
2731            })
2732            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2733                "id": "1",
2734                "model": "gpt",
2735                "choices": [{
2736                    "index": 0,
2737                    "message": {"role": "assistant", "content": "ok"},
2738                    "finish_reason": "stop"
2739                }],
2740                "usage": {}
2741            })))
2742            .mount(&server)
2743            .await;
2744
2745        let client = TranslatingLlmClient::new(&chat_map_with_omit_body_fields(
2746            &format!("{}/v1", server.uri()),
2747            BTreeSet::from(["max_tokens".to_string()]),
2748        ))?;
2749        let raw = json!({
2750            "model": "client-facing",
2751            "messages": [{"role": "user", "content": "hi"}],
2752            "max_tokens": 7
2753        });
2754
2755        client
2756            .call_rewrite_model_raw(
2757                raw,
2758                None,
2759                Some(&ModelId::from("gpt")),
2760                WireFormat::OpenAiChat,
2761            )
2762            .await?;
2763        Ok(())
2764    }
2765
2766    // A weak OpenAI-format tier emits thinking blocks with no signature. Replaying
2767    // them to Anthropic is rejected (Bedrock reports it as a SigV4 mismatch), so
2768    // the Anthropic leg must drop them while keeping signed ones.
2769    #[tokio::test]
2770    async fn anthropic_requests_drop_unsigned_thinking_blocks()
2771    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2772        let server = MockServer::start().await;
2773        Mock::given(method("POST"))
2774            .and(path("/v1/messages"))
2775            .and(|request: &wiremock::Request| {
2776                let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null);
2777                let messages = body.get("messages").and_then(Value::as_array).cloned();
2778                let Some(messages) = messages else {
2779                    return false;
2780                };
2781                // The unsigned block is gone, the signed one survives, and the
2782                // message whose only block was unsigned is not left with an empty
2783                // content array.
2784                let blocks: Vec<&Value> = messages
2785                    .iter()
2786                    .filter_map(|message| message.get("content"))
2787                    .filter_map(Value::as_array)
2788                    .flatten()
2789                    .collect();
2790                let thinking: Vec<&&Value> = blocks
2791                    .iter()
2792                    .filter(|block| block.get("type").and_then(Value::as_str) == Some("thinking"))
2793                    .collect();
2794                thinking.len() == 1
2795                    && thinking[0].get("signature").and_then(Value::as_str) == Some("sig-abc")
2796                    && messages
2797                        .iter()
2798                        .all(|message| message.get("content") != Some(&json!([])))
2799            })
2800            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2801                "id": "msg_1",
2802                "type": "message",
2803                "role": "assistant",
2804                "model": "claude",
2805                "content": [{"type": "text", "text": "ok"}],
2806                "stop_reason": "end_turn",
2807                "usage": {"input_tokens": 1, "output_tokens": 1}
2808            })))
2809            .mount(&server)
2810            .await;
2811
2812        let client = TranslatingLlmClient::new(&anthropic_map(&server.uri()))?;
2813        let raw = json!({
2814            "model": "client-facing",
2815            "max_tokens": 7,
2816            "messages": [
2817                {"role": "user", "content": "fix the build"},
2818                {"role": "assistant", "content": [
2819                    {"type": "thinking", "thinking": "weak tier reasoning", "signature": ""}
2820                ]},
2821                {"role": "assistant", "content": [
2822                    {"type": "thinking", "thinking": "signed reasoning", "signature": "sig-abc"},
2823                    {"type": "text", "text": "here goes"}
2824                ]},
2825                {"role": "user", "content": "continue"}
2826            ]
2827        });
2828
2829        client
2830            .call_rewrite_model_raw(
2831                raw,
2832                None,
2833                Some(&ModelId::from("claude")),
2834                WireFormat::AnthropicMessages,
2835            )
2836            .await?;
2837        Ok(())
2838    }
2839
2840    #[tokio::test]
2841    async fn responses_reasoning_policy_preserves_replay_history()
2842    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2843        use crate::ResponsesReasoningPolicy::{Auto, Drop, PreserveEncrypted, PreservePlaintext};
2844        let summary = json!([{"type": "summary_text", "text": "Check the parser."}]);
2845        let history = json!([
2846            {"type": "message", "role": "user", "content": "continue"},
2847            {"type": "reasoning", "encrypted_content": "opaque", "content": [{"type": "reasoning_text", "text": "private"}]},
2848            {"type": "reasoning", "id": "rs_stored", "status": "completed", "summary": []},
2849            // GLM's plaintext forms, as Codex replays them.
2850            {"type": "reasoning", "id": "rs_glm", "summary": summary, "encrypted_content": null},
2851            {"type": "reasoning", "content": [{"type": "reasoning_text", "text": "Run the tests."}], "encrypted_content": ""},
2852            {"type": "reasoning"},
2853            {"type": "function_call", "call_id": "call_1", "name": "shell", "arguments": "{}"},
2854            {"type": "function_call_output", "call_id": "call_1", "output": "ok"}
2855        ]);
2856        // An explicit policy wins over the target. `Auto` follows the target.
2857        let cases = [
2858            (Auto, "openai/gpt-5", PreserveEncrypted),
2859            (Auto, "nvidia/zai-org/glm-5.3-flash", PreservePlaintext),
2860            (
2861                PreserveEncrypted,
2862                "nvidia/zai-org/glm-5.3-flash",
2863                PreserveEncrypted,
2864            ),
2865            (Drop, "openai/gpt-5", Drop),
2866        ];
2867        for store in [Some(true), Some(false), None] {
2868            for (policy, model, behavior) in cases {
2869                for has_input_override in [false, true] {
2870                    let server = MockServer::start().await;
2871                    Mock::given(method("POST"))
2872                        .and(path("/v1/responses"))
2873                        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2874                            "id": "resp_1", "object": "response", "model": "gpt",
2875                            "status": "completed", "output": [],
2876                            "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}
2877                        })))
2878                        .expect(1)
2879                        .mount(&server)
2880                        .await;
2881                    let mut backend = config(&format!("{}/v1", server.uri()));
2882                    if has_input_override {
2883                        backend.omit_body_fields.insert("input".to_string());
2884                        backend
2885                            .extra_body
2886                            .insert("input".to_string(), history.clone());
2887                    }
2888                    let client = TranslatingLlmClient::new(&[ModelConfig::new(
2889                        model,
2890                        Backend::OpenAiResponses(backend),
2891                        None,
2892                    )
2893                    .with_responses_reasoning(policy)])?;
2894                    let mut request = json!({"model": "switchyard", "input": history});
2895                    if let Some(store) = store {
2896                        request["store"] = json!(store);
2897                    }
2898                    client
2899                        .call_rewrite_model_raw(
2900                            request,
2901                            None,
2902                            Some(&ModelId::from(model)),
2903                            WireFormat::OpenAiResponses,
2904                        )
2905                        .await?;
2906                    let requests = server.received_requests().await.ok_or("missing requests")?;
2907                    let body: Value = serde_json::from_slice(&requests[0].body)?;
2908                    let stored = store != Some(false);
2909                    let mut expected = vec![history[0].clone()];
2910                    match behavior {
2911                        // OpenAI reads its own payload and stored IDs, but rejects plaintext.
2912                        PreserveEncrypted => {
2913                            let indexes: &[usize] = if stored { &[1, 2, 3] } else { &[1] };
2914                            for &index in indexes {
2915                                let mut item = history[index].clone();
2916                                item["content"] = json!([]);
2917                                expected.push(item);
2918                            }
2919                        }
2920                        // Other targets keep plaintext and lose payloads of unknown origin.
2921                        PreservePlaintext => {
2922                            let mut item = history[1].clone();
2923                            item.as_object_mut()
2924                                .ok_or("object")?
2925                                .remove("encrypted_content");
2926                            expected.push(item);
2927                            if stored {
2928                                expected.push(history[2].clone());
2929                            }
2930                            expected.extend([history[3].clone(), history[4].clone()]);
2931                        }
2932                        _ => {}
2933                    }
2934                    expected.extend([history[6].clone(), history[7].clone()]);
2935                    assert_eq!(
2936                        body["input"],
2937                        json!(expected),
2938                        "store={store:?}, policy={policy:?}, model={model}, replace={has_input_override}"
2939                    );
2940                }
2941            }
2942        }
2943
2944        Ok(())
2945    }
2946
2947    #[tokio::test]
2948    async fn nvidia_anthropic_requests_strip_strict_from_extra_body()
2949    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
2950        let server = MockServer::start().await;
2951        Mock::given(method("POST"))
2952            .and(path("/v1/messages"))
2953            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2954                "id": "msg_1",
2955                "type": "message",
2956                "role": "assistant",
2957                "model": "claude",
2958                "content": [{"type": "text", "text": "ok"}],
2959                "stop_reason": "end_turn",
2960                "usage": {"input_tokens": 1, "output_tokens": 1}
2961            })))
2962            .expect(1)
2963            .mount(&server)
2964            .await;
2965
2966        let mut backend = config(&format!(
2967            "http://inference-api.nvidia.com:{}",
2968            server.address().port()
2969        ));
2970        backend.extra_body.insert(
2971            "tools".into(),
2972            json!([{
2973                "name": "lookup",
2974                "input_schema": {"type": "object", "properties": {}},
2975                "strict": true
2976            }]),
2977        );
2978        let mut client = TranslatingLlmClient::new(&[ModelConfig::new(
2979            "claude",
2980            Backend::Anthropic(backend),
2981            None,
2982        )])?;
2983        client.client = reqwest::Client::builder()
2984            .no_proxy()
2985            .resolve("inference-api.nvidia.com", *server.address())
2986            .build()?;
2987        client
2988            .call_rewrite_model_raw(
2989                json!({
2990                    "model": "claude",
2991                    "max_tokens": 7,
2992                    "messages": [{"role": "user", "content": "hi"}]
2993                }),
2994                None,
2995                Some(&ModelId::from("claude")),
2996                WireFormat::AnthropicMessages,
2997            )
2998            .await?;
2999
3000        let requests = server.received_requests().await.unwrap();
3001        let body: Value = serde_json::from_slice(&requests[0].body)?;
3002        assert_eq!(body["tools"][0]["name"], "lookup");
3003        assert!(body["tools"][0].get("strict").is_none());
3004        Ok(())
3005    }
3006
3007    // A router can serve earlier turns from an OpenAI target and later turns from
3008    // an Anthropic one, so the Anthropic leg must drop OpenAI-only fields the
3009    // caller keeps sending or the upstream rejects the whole request.
3010    #[tokio::test]
3011    async fn anthropic_requests_drop_openai_only_fields()
3012    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3013        let server = MockServer::start().await;
3014        Mock::given(method("POST"))
3015            .and(path("/v1/messages"))
3016            .and(|request: &wiremock::Request| {
3017                let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null);
3018                body.get("context_management").is_none() && body.get("reasoning_effort").is_none()
3019            })
3020            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3021                "id": "msg_1",
3022                "type": "message",
3023                "role": "assistant",
3024                "model": "claude",
3025                "content": [{"type": "text", "text": "ok"}],
3026                "stop_reason": "end_turn",
3027                "usage": {"input_tokens": 1, "output_tokens": 1}
3028            })))
3029            .mount(&server)
3030            .await;
3031
3032        let client = TranslatingLlmClient::new(&anthropic_map(&server.uri()))?;
3033        let raw = json!({
3034            "model": "client-facing",
3035            "messages": [{"role": "user", "content": "hi"}],
3036            "max_tokens": 7,
3037            "reasoning_effort": "high",
3038            "context_management": {
3039                "edits": [{"type": "clear_thinking_20251015"}]
3040            }
3041        });
3042
3043        client
3044            .call_rewrite_model_raw(
3045                raw,
3046                None,
3047                Some(&ModelId::from("claude")),
3048                WireFormat::AnthropicMessages,
3049            )
3050            .await?;
3051        Ok(())
3052    }
3053
3054    #[tokio::test]
3055    async fn streaming_openai_chat_aggregates()
3056    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3057        let server = MockServer::start().await;
3058        let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n\
3059             data: {\"choices\":[{\"delta\":{\"content\":\" world\"}}]}\n\n\
3060             data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n\
3061             data: [DONE]\n\n";
3062        Mock::given(method("POST"))
3063            .and(path("/v1/chat/completions"))
3064            .and(wiremock::matchers::body_partial_json(json!({
3065                "stream": true,
3066                "stream_options": {"include_usage": true}
3067            })))
3068            .respond_with(ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream"))
3069            .mount(&server)
3070            .await;
3071
3072        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
3073
3074        let response = client
3075            .call_rewrite_model(request_for(Some("gpt"), true), None)
3076            .await?;
3077        assert!(matches!(response.llm_response, LlmResponse::Stream(_)));
3078        let agg = response.llm_response.into_agg().await?;
3079        assert_eq!(completion_text(&agg), "Hello world");
3080        assert_eq!(agg.usage.input_tokens, Some(1));
3081        assert_eq!(agg.usage.output_tokens, Some(2));
3082        assert_eq!(agg.usage.total_tokens, Some(3));
3083        Ok(())
3084    }
3085
3086    #[tokio::test]
3087    async fn streaming_openai_chat_preserves_usage_opt_out()
3088    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3089        let server = MockServer::start().await;
3090        Mock::given(method("POST"))
3091            .and(path("/v1/chat/completions"))
3092            .and(wiremock::matchers::body_partial_json(json!({
3093                "stream": true,
3094                "stream_options": {"include_usage": false}
3095            })))
3096            .respond_with(
3097                ResponseTemplate::new(200).set_body_raw("data: [DONE]\n\n", "text/event-stream"),
3098            )
3099            .mount(&server)
3100            .await;
3101
3102        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
3103        let raw = json!({
3104            "model": "client-facing",
3105            "messages": [{"role": "user", "content": "hi"}],
3106            "stream": true,
3107            "stream_options": {"include_usage": false}
3108        });
3109
3110        let response = client
3111            .call_rewrite_model_raw(
3112                raw,
3113                None,
3114                Some(&ModelId::from("gpt")),
3115                WireFormat::OpenAiChat,
3116            )
3117            .await?;
3118        assert!(matches!(response, RawResponse::Stream(_)));
3119        Ok(())
3120    }
3121
3122    #[tokio::test]
3123    async fn upstream_500_is_upstream_http()
3124    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3125        let server = MockServer::start().await;
3126        Mock::given(method("POST"))
3127            .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
3128            .mount(&server)
3129            .await;
3130
3131        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
3132
3133        let Err(error) = client
3134            .call_rewrite_model(request_for(Some("gpt"), false), None)
3135            .await
3136        else {
3137            panic!("expected an error");
3138        };
3139        assert!(matches!(
3140            error,
3141            LlmClientError::UpstreamHttp {
3142                status: StatusCode::INTERNAL_SERVER_ERROR,
3143                ..
3144            }
3145        ));
3146        Ok(())
3147    }
3148
3149    #[tokio::test]
3150    async fn retryable_http_failure_recovers_within_budget()
3151    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3152        let server = MockServer::start().await;
3153        let calls = Arc::new(AtomicUsize::new(0));
3154        let observed_calls = Arc::clone(&calls);
3155        Mock::given(method("POST"))
3156            .respond_with(move |_: &wiremock::Request| {
3157                if observed_calls.fetch_add(1, Ordering::SeqCst) == 0 {
3158                    ResponseTemplate::new(503)
3159                        .insert_header("retry-after", "0")
3160                        .set_body_string("temporarily unavailable")
3161                } else {
3162                    chat_success_response()
3163                }
3164            })
3165            .mount(&server)
3166            .await;
3167
3168        let client =
3169            TranslatingLlmClient::new(&chat_map_with_retries(&format!("{}/v1", server.uri()), 1))?;
3170        let response = client
3171            .call_rewrite_model(request_for(Some("gpt"), false), None)
3172            .await?;
3173        let agg = response.llm_response.into_agg().await?;
3174
3175        assert_eq!(completion_text(&agg), "recovered");
3176        assert_eq!(calls.load(Ordering::SeqCst), 2);
3177        Ok(())
3178    }
3179
3180    #[tokio::test]
3181    async fn deterministic_http_failure_is_not_retried()
3182    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3183        let server = MockServer::start().await;
3184        let calls = Arc::new(AtomicUsize::new(0));
3185        let observed_calls = Arc::clone(&calls);
3186        Mock::given(method("POST"))
3187            .respond_with(move |_: &wiremock::Request| {
3188                observed_calls.fetch_add(1, Ordering::SeqCst);
3189                ResponseTemplate::new(401).set_body_string("invalid key")
3190            })
3191            .mount(&server)
3192            .await;
3193
3194        let client =
3195            TranslatingLlmClient::new(&chat_map_with_retries(&format!("{}/v1", server.uri()), 2))?;
3196        let Err(error) = client
3197            .call_rewrite_model(request_for(Some("gpt"), false), None)
3198            .await
3199        else {
3200            panic!("expected an upstream error");
3201        };
3202
3203        assert!(matches!(
3204            error,
3205            LlmClientError::UpstreamHttp {
3206                status: StatusCode::UNAUTHORIZED,
3207                body
3208            } if body == "invalid key"
3209        ));
3210        assert_eq!(calls.load(Ordering::SeqCst), 1);
3211        Ok(())
3212    }
3213
3214    #[tokio::test]
3215    async fn rate_limit_cooldown_depends_on_shared_credentials()
3216    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3217        for forward_auth in [true, false] {
3218            let server = MockServer::start().await;
3219            Mock::given(method("POST"))
3220                .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
3221                .expect(if forward_auth { 6 } else { 3 })
3222                .mount(&server)
3223                .await;
3224            let backend = HttpBackendConfig {
3225                forward_auth,
3226                failure_cooldown: Duration::from_secs(60),
3227                ..config_with_retries(&format!("{}/v1", server.uri()), 2)
3228            };
3229            let client = TranslatingLlmClient::new(&[ModelConfig::new(
3230                "gpt",
3231                Backend::OpenAiChat(backend),
3232                None,
3233            )])?;
3234            for credential in ["Bearer first-user", "Bearer second-user"] {
3235                let mut headers = http::HeaderMap::new();
3236                headers.insert("authorization", http::HeaderValue::from_static(credential));
3237                let result = client
3238                    .call_rewrite_model(request_with_headers("gpt", headers), None)
3239                    .await;
3240                if !forward_auth && credential == "Bearer second-user" {
3241                    assert!(matches!(
3242                        result,
3243                        Err(LlmClientError::TemporarilyUnavailable)
3244                    ));
3245                } else {
3246                    assert!(matches!(
3247                        result,
3248                        Err(LlmClientError::UpstreamHttp {
3249                            status: StatusCode::TOO_MANY_REQUESTS,
3250                            ..
3251                        })
3252                    ));
3253                }
3254            }
3255        }
3256        Ok(())
3257    }
3258
3259    #[tokio::test]
3260    async fn retry_exhaustion_returns_the_final_upstream_error()
3261    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3262        let server = MockServer::start().await;
3263        let calls = Arc::new(AtomicUsize::new(0));
3264        let observed_calls = Arc::clone(&calls);
3265        Mock::given(method("POST"))
3266            .respond_with(move |_: &wiremock::Request| {
3267                let attempt = observed_calls.fetch_add(1, Ordering::SeqCst) + 1;
3268                ResponseTemplate::new(500)
3269                    .insert_header("retry-after", "0")
3270                    .set_body_string(format!("attempt {attempt}"))
3271            })
3272            .mount(&server)
3273            .await;
3274
3275        let client =
3276            TranslatingLlmClient::new(&chat_map_with_retries(&format!("{}/v1", server.uri()), 2))?;
3277        let Err(error) = client
3278            .call_rewrite_model(request_for(Some("gpt"), false), None)
3279            .await
3280        else {
3281            panic!("expected retry exhaustion");
3282        };
3283
3284        assert!(matches!(
3285            error,
3286            LlmClientError::UpstreamHttp {
3287                status: StatusCode::INTERNAL_SERVER_ERROR,
3288                body
3289            } if body == "attempt 3"
3290        ));
3291        assert_eq!(calls.load(Ordering::SeqCst), 3);
3292        Ok(())
3293    }
3294
3295    #[tokio::test]
3296    async fn deadline_expires_before_send_or_stream_poll()
3297    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3298        let server = MockServer::start().await;
3299        Mock::given(method("POST"))
3300            .respond_with(ResponseTemplate::new(200).set_body_raw(
3301                "data: {\"id\":\"test\",\"model\":\"gpt\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n",
3302                "text/event-stream",
3303            ))
3304            .mount(&server)
3305            .await;
3306        for timeout in [Duration::ZERO, Duration::from_millis(200)] {
3307            let mut backend = config(&server.uri());
3308            backend.timeout = Some(timeout);
3309            let client = TranslatingLlmClient::new(&[ModelConfig::new(
3310                "gpt",
3311                Backend::OpenAiChat(backend),
3312                None,
3313            )])?;
3314            let result = client
3315                .call_rewrite_model(request_for(Some("gpt"), true), None)
3316                .await;
3317            if timeout.is_zero() {
3318                assert!(matches!(result, Err(LlmClientError::Timeout { .. })));
3319                assert!(
3320                    server
3321                        .received_requests()
3322                        .await
3323                        .expect("recorded requests")
3324                        .is_empty()
3325                );
3326            } else {
3327                let LlmResponse::Stream(mut chunks) = result?.llm_response else {
3328                    return Err("expected a streaming response".into());
3329                };
3330                tokio::time::sleep(Duration::from_millis(300)).await;
3331                assert!(matches!(
3332                    chunks.next().await,
3333                    Some(Err(LlmClientError::Timeout { .. }))
3334                ));
3335                assert!(chunks.next().await.is_none());
3336            }
3337        }
3338        Ok(())
3339    }
3340
3341    #[tokio::test]
3342    async fn timeout_is_retried_before_a_response_is_returned()
3343    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3344        let server = MockServer::start().await;
3345        let calls = Arc::new(AtomicUsize::new(0));
3346        let observed_calls = Arc::clone(&calls);
3347        Mock::given(method("POST"))
3348            .respond_with(move |_: &wiremock::Request| {
3349                if observed_calls.fetch_add(1, Ordering::SeqCst) == 0 {
3350                    ResponseTemplate::new(200).set_delay(Duration::from_millis(500))
3351                } else {
3352                    chat_success_response()
3353                }
3354            })
3355            .mount(&server)
3356            .await;
3357
3358        let mut client =
3359            TranslatingLlmClient::new(&chat_map_with_retries(&format!("{}/v1", server.uri()), 1))?;
3360        client.client = reqwest::Client::builder()
3361            .timeout(Duration::from_millis(100))
3362            .build()?;
3363        let response = client
3364            .call_rewrite_model(request_for(Some("gpt"), false), None)
3365            .await?;
3366
3367        assert_eq!(
3368            completion_text(&response.llm_response.into_agg().await?),
3369            "recovered"
3370        );
3371        assert_eq!(calls.load(Ordering::SeqCst), 2);
3372        Ok(())
3373    }
3374
3375    #[test]
3376    fn retryable_error_classes_are_explicit() {
3377        let transport = AttemptFailure {
3378            error: LlmClientError::Transport {
3379                source: std::io::Error::other("disconnected").into(),
3380            },
3381            status: None,
3382            retry_after: None,
3383        };
3384        assert!(transport.is_retryable());
3385
3386        for status in [
3387            StatusCode::REQUEST_TIMEOUT,
3388            StatusCode::TOO_MANY_REQUESTS,
3389            StatusCode::INTERNAL_SERVER_ERROR,
3390            StatusCode::SERVICE_UNAVAILABLE,
3391            StatusCode::from_u16(599)
3392                .expect("599 is a syntactically valid HTTP code in the http crate"),
3393        ] {
3394            let failure = AttemptFailure {
3395                error: LlmClientError::UpstreamHttp {
3396                    status,
3397                    body: String::new(),
3398                },
3399                status: Some(status),
3400                retry_after: None,
3401            };
3402            assert!(failure.is_retryable(), "HTTP {status} should retry");
3403        }
3404        for status in [
3405            StatusCode::BAD_REQUEST,
3406            StatusCode::UNAUTHORIZED,
3407            StatusCode::CONFLICT,
3408            StatusCode::from_u16(600)
3409                .expect("600 is a syntactically valid HTTP code in the http crate"),
3410        ] {
3411            let failure = AttemptFailure {
3412                error: LlmClientError::UpstreamHttp {
3413                    status,
3414                    body: String::new(),
3415                },
3416                status: Some(status),
3417                retry_after: None,
3418            };
3419            assert!(!failure.is_retryable(), "HTTP {status} should fail fast");
3420        }
3421
3422        let configuration = AttemptFailure {
3423            error: LlmClientError::Configuration {
3424                message: "invalid header".to_string(),
3425            },
3426            status: None,
3427            retry_after: None,
3428        };
3429        assert!(!configuration.is_retryable());
3430
3431        let context_window = AttemptFailure {
3432            error: LlmClientError::ContextWindowExceeded {
3433                model: ModelId::from("gpt"),
3434                message: "too long".to_string(),
3435            },
3436            status: Some(StatusCode::BAD_REQUEST),
3437            retry_after: None,
3438        };
3439        assert!(!context_window.is_retryable());
3440    }
3441
3442    #[test]
3443    fn retry_after_supports_seconds_and_http_dates() {
3444        let mut headers = HeaderMap::new();
3445        headers.insert(RETRY_AFTER, reqwest::header::HeaderValue::from_static("3"));
3446        assert_eq!(retry_after_delay(&headers), Some(Duration::from_secs(3)));
3447
3448        let retry_at = SystemTime::now() + Duration::from_secs(2);
3449        let value = httpdate::fmt_http_date(retry_at);
3450        let Ok(value) = reqwest::header::HeaderValue::from_str(&value) else {
3451            panic!("formatted HTTP date should be a valid header");
3452        };
3453        headers.insert(RETRY_AFTER, value);
3454        let Some(delay) = retry_after_delay(&headers) else {
3455            panic!("HTTP date should produce a retry delay");
3456        };
3457        assert!(delay <= Duration::from_secs(2));
3458    }
3459
3460    #[tokio::test]
3461    async fn routed_llm_client_exposes_timeout_variant()
3462    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3463        let server = MockServer::start().await;
3464        Mock::given(method("POST"))
3465            .respond_with(
3466                ResponseTemplate::new(200).set_delay(std::time::Duration::from_millis(100)),
3467            )
3468            .mount(&server)
3469            .await;
3470
3471        let mut client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
3472        client.client = reqwest::Client::builder()
3473            .timeout(std::time::Duration::from_millis(10))
3474            .build()?;
3475
3476        let Err(error) = client.call(request_for(Some("gpt"), false)).await else {
3477            panic!("expected a timeout");
3478        };
3479        let LlmClientError::Timeout { source } = error else {
3480            panic!("expected the protocol timeout variant");
3481        };
3482        let Some(source) = source.downcast_ref::<reqwest::Error>() else {
3483            panic!("expected the reqwest timeout source");
3484        };
3485        assert!(source.is_timeout());
3486        Ok(())
3487    }
3488
3489    // A connect that never completes sends nothing, like a refused connection.
3490    #[cfg(target_os = "linux")]
3491    #[tokio::test]
3492    async fn connect_timeout_is_a_transport_error()
3493    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3494        // Linux drops SYNs once a listener's accept queue is full, so later
3495        // connects hang like an upstream behind a dropping firewall.
3496        let socket = tokio::net::TcpSocket::new_v4()?;
3497        socket.bind("127.0.0.1:0".parse()?)?;
3498        let listener = socket.listen(0)?;
3499        let addr = listener.local_addr()?;
3500        let _queued: Vec<_> = (0..2)
3501            .map(|_| {
3502                std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(100))
3503            })
3504            .collect();
3505
3506        let mut client = TranslatingLlmClient::new(&chat_map(&format!("http://{addr}/v1")))?;
3507        // Stands in for the OS connect timeout (30 s on Linux via reqwest's default
3508        // TCP_USER_TIMEOUT); both surface as `is_connect() && is_timeout()`.
3509        client.client = reqwest::Client::builder()
3510            .connect_timeout(std::time::Duration::from_millis(100))
3511            .build()?;
3512
3513        let Err(error) = client.call(request_for(Some("gpt"), false)).await else {
3514            panic!("expected the connect to fail");
3515        };
3516        let LlmClientError::Transport { source } = error else {
3517            panic!("expected Transport, got {error:?}");
3518        };
3519        let Some(source) = source.downcast_ref::<reqwest::Error>() else {
3520            panic!("expected the reqwest connect-timeout source");
3521        };
3522        assert!(source.is_connect());
3523        assert!(source.is_timeout());
3524        Ok(())
3525    }
3526
3527    #[tokio::test]
3528    async fn llama_cpp_context_overflow_400_is_mapped()
3529    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3530        let server = MockServer::start().await;
3531        Mock::given(method("POST"))
3532            .respond_with(ResponseTemplate::new(400).set_body_json(json!({
3533                "error": {
3534                    "message": "request (6016 tokens) exceeds the available context size (4096 tokens), try increasing it"
3535                }
3536            })))
3537            .mount(&server)
3538            .await;
3539
3540        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
3541
3542        let Err(error) = client
3543            .call_rewrite_model(request_for(Some("gpt"), false), None)
3544            .await
3545        else {
3546            panic!("expected an error");
3547        };
3548        assert!(matches!(
3549            error,
3550            LlmClientError::ContextWindowExceeded { model, .. } if model == "gpt"
3551        ));
3552        Ok(())
3553    }
3554
3555    #[tokio::test]
3556    async fn content_policy_400_preserves_upstream_error()
3557    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3558        let server = MockServer::start().await;
3559        let body = json!({
3560            "error": {
3561                "code": "content_policy_violation",
3562                "message": "request blocked by content policy",
3563                "type": "invalid_request_error"
3564            },
3565            "metadata": {"documentation_section": "context window"}
3566        });
3567        Mock::given(method("POST"))
3568            .respond_with(ResponseTemplate::new(400).set_body_json(&body))
3569            .expect(1)
3570            .mount(&server)
3571            .await;
3572        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
3573        let Err(error) = client
3574            .call_rewrite_model(request_for(Some("gpt"), false), None)
3575            .await
3576        else {
3577            panic!("expected a policy denial");
3578        };
3579        let LlmClientError::UpstreamHttp {
3580            status,
3581            body: actual,
3582        } = error
3583        else {
3584            panic!("expected the upstream HTTP error, got {error:?}");
3585        };
3586        assert_eq!(status, StatusCode::BAD_REQUEST);
3587        assert_eq!(serde_json::from_str::<serde_json::Value>(&actual)?, body);
3588        Ok(())
3589    }
3590
3591    #[tokio::test]
3592    async fn forwards_only_allowlisted_metadata_headers()
3593    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3594        let server = MockServer::start().await;
3595        Mock::given(method("POST"))
3596            .and(wiremock::matchers::header("x-request-id", "abc"))
3597            // A forwarded Authorization must NOT override the backend's bearer key.
3598            .and(wiremock::matchers::header("authorization", "Bearer secret"))
3599            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3600                "id": "1", "model": "gpt",
3601                "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
3602                "usage": {}
3603            })))
3604            .mount(&server)
3605            .await;
3606
3607        let mut headers = http::HeaderMap::new();
3608        headers.insert("x-request-id", http::HeaderValue::from_static("abc"));
3609        headers.insert(
3610            "authorization",
3611            http::HeaderValue::from_static("Bearer client-key"),
3612        );
3613        headers.insert(
3614            "x-goog-api-key",
3615            http::HeaderValue::from_static("client-google-key"),
3616        );
3617        headers.insert(
3618            "x-custom-internal-secret",
3619            http::HeaderValue::from_static("client-custom-key"),
3620        );
3621        let request = request_with_headers("gpt", headers);
3622
3623        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
3624
3625        // The allowed request id survives and caller authorization cannot replace
3626        // the backend's credential.
3627        client.call_rewrite_model(request, None).await?;
3628        let received = server
3629            .received_requests()
3630            .await
3631            .ok_or("request recording should be enabled")?;
3632        let received = received.first().ok_or("expected one upstream request")?;
3633        assert!(!received.headers.contains_key("x-goog-api-key"));
3634        assert!(!received.headers.contains_key("x-custom-internal-secret"));
3635        Ok(())
3636    }
3637
3638    #[tokio::test]
3639    async fn forwards_application_headers_with_forward_auth()
3640    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3641        let server = MockServer::start().await;
3642        Mock::given(method("POST"))
3643            .and(wiremock::matchers::header(
3644                "authorization",
3645                "Bearer client-key",
3646            ))
3647            .and(wiremock::matchers::header(
3648                "x-goog-api-key",
3649                "client-google-key",
3650            ))
3651            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3652                "id": "1", "model": "gpt",
3653                "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
3654                "usage": {}
3655            })))
3656            .mount(&server)
3657            .await;
3658
3659        let mut headers = http::HeaderMap::new();
3660        headers.insert(
3661            "authorization",
3662            http::HeaderValue::from_static("Bearer client-key"),
3663        );
3664        headers.insert(
3665            "x-goog-api-key",
3666            http::HeaderValue::from_static("client-google-key"),
3667        );
3668        headers.insert("host", http::HeaderValue::from_static("client.example"));
3669        headers.insert("content-type", http::HeaderValue::from_static("text/plain"));
3670        headers.insert("connection", http::HeaderValue::from_static("x-hop-by-hop"));
3671        headers.insert(
3672            "x-hop-by-hop",
3673            http::HeaderValue::from_static("client-only"),
3674        );
3675        let request = request_with_headers("gpt", headers);
3676        let backend_config = forwarding_config(&format!("{}/v1", server.uri()));
3677        let client = TranslatingLlmClient::new(&[ModelConfig::new(
3678            "gpt",
3679            Backend::OpenAiChat(backend_config),
3680            None,
3681        )])?;
3682
3683        client.call_rewrite_model(request, None).await?;
3684        let received = server
3685            .received_requests()
3686            .await
3687            .ok_or("request recording should be enabled")?;
3688        let received = received.first().ok_or("expected one upstream request")?;
3689        assert_ne!(received.headers["host"], "client.example");
3690        assert_eq!(received.headers["content-type"], "application/json");
3691        assert!(!received.headers.contains_key("connection"));
3692        assert!(!received.headers.contains_key("x-hop-by-hop"));
3693        Ok(())
3694    }
3695
3696    #[tokio::test]
3697    async fn forward_auth_preserves_anthropic_headers_and_redacts_errors()
3698    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3699        let server = MockServer::start().await;
3700        Mock::given(method("POST"))
3701            .and(path("/v1/messages"))
3702            .and(|request: &wiremock::Request| {
3703                request
3704                    .headers
3705                    .get("anthropic-beta")
3706                    .and_then(|value| value.to_str().ok())
3707                    == Some("oauth-2025-04-20")
3708                    && request.headers.get_all("anthropic-version").iter().count() == 1
3709                    && request
3710                        .headers
3711                        .get("anthropic-version")
3712                        .and_then(|value| value.to_str().ok())
3713                        == Some("2023-06-01")
3714            })
3715            .respond_with(ResponseTemplate::new(401).set_body_json(json!({
3716                "error": {"message": "rejected client-google-key"}
3717            })))
3718            .mount(&server)
3719            .await;
3720
3721        let mut headers = http::HeaderMap::new();
3722        headers.insert(
3723            "authorization",
3724            http::HeaderValue::from_static("Bearer client-key"),
3725        );
3726        headers.insert(
3727            "x-goog-api-key",
3728            http::HeaderValue::from_static("client-google-key"),
3729        );
3730        headers.insert(
3731            "anthropic-beta",
3732            http::HeaderValue::from_static("oauth-2025-04-20,prompt-caching-2024-07-31"),
3733        );
3734        headers.insert(
3735            "anthropic-version",
3736            http::HeaderValue::from_static("caller-version"),
3737        );
3738        let backend_config = forwarding_config(&server.uri());
3739        let client = TranslatingLlmClient::new(&[ModelConfig::new(
3740            "claude",
3741            Backend::Anthropic(backend_config),
3742            None,
3743        )])?;
3744        let raw = json!({
3745            "model": "claude",
3746            "max_tokens": 8,
3747            "messages": [{"role": "user", "content": "hello"}]
3748        });
3749
3750        let Err(LlmClientError::UpstreamHttp { body, .. }) = client
3751            .call_rewrite_model_raw(
3752                raw,
3753                Some(headers),
3754                Some(&ModelId::from("claude")),
3755                WireFormat::AnthropicMessages,
3756            )
3757            .await
3758        else {
3759            panic!("expected an upstream HTTP error");
3760        };
3761        assert_eq!(body, r#"{"error":{"message":"rejected [REDACTED]"}}"#);
3762        Ok(())
3763    }
3764
3765    // Exercises the `RoutedLlmClient` impl: `call` uses the model already materialized in the
3766    // request and round-trips a buffered response.
3767    #[tokio::test]
3768    async fn routed_llm_client_serves_the_request_model()
3769    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3770        let server = MockServer::start().await;
3771        Mock::given(method("POST"))
3772            .and(path("/v1/chat/completions"))
3773            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3774                "id": "chatcmpl-1",
3775                "model": "gpt",
3776                "choices": [{
3777                    "index": 0,
3778                    "message": {"role": "assistant", "content": "routed hi"},
3779                    "finish_reason": "stop"
3780                }],
3781                "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
3782            })))
3783            .mount(&server)
3784            .await;
3785
3786        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
3787        let response = client.call(request_for(Some("gpt"), false)).await?;
3788        let agg = response.llm_response.into_agg().await?;
3789        assert_eq!(completion_text(&agg), "routed hi");
3790        Ok(())
3791    }
3792
3793    #[tokio::test]
3794    async fn invalid_raw_request_is_a_request_translation_error()
3795    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3796        let client = TranslatingLlmClient::new(&[])?;
3797        let Err(error) = client
3798            .call_rewrite_model_raw(
3799                json!("invalid"),
3800                None,
3801                Some(&ModelId::from("gpt")),
3802                WireFormat::OpenAiChat,
3803            )
3804            .await
3805        else {
3806            panic!("expected request translation to fail");
3807        };
3808
3809        assert!(matches!(
3810            error,
3811            LlmClientError::RequestTranslation(message) if !message.is_empty()
3812        ));
3813        Ok(())
3814    }
3815
3816    // Raw path, buffered: decode an OpenAI Chat body -> call -> encode back to OpenAI
3817    // Chat JSON, with the served `model` restamped over the id the caller addressed.
3818    #[tokio::test]
3819    async fn call_rewrite_model_raw_round_trips_buffered_json()
3820    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3821        let server = MockServer::start().await;
3822        Mock::given(method("POST"))
3823            .and(path("/v1/chat/completions"))
3824            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3825                "id": "chatcmpl-1",
3826                "model": "gpt",
3827                "choices": [{
3828                    "index": 0,
3829                    "message": {"role": "assistant", "content": "Hi there"},
3830                    "finish_reason": "stop"
3831                }],
3832                "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
3833            })))
3834            .mount(&server)
3835            .await;
3836
3837        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
3838        let raw = json!({
3839            "model": "client-facing",
3840            "messages": [{"role": "user", "content": "hi"}]
3841        });
3842        let RawResponse::Buffered(body) = client
3843            .call_rewrite_model_raw(
3844                raw,
3845                None,
3846                Some(&ModelId::from("gpt")),
3847                WireFormat::OpenAiChat,
3848            )
3849            .await?
3850        else {
3851            panic!("expected a buffered response");
3852        };
3853
3854        assert_eq!(body["choices"][0]["message"]["content"], "Hi there");
3855        // The client sees the model that answered, not the "client-facing" route id.
3856        assert_eq!(body["model"], "gpt");
3857        Ok(())
3858    }
3859
3860    // A Codex namespace is folded into the upstream tool name, then split back
3861    // into name and namespace on the Responses call that returns to Codex.
3862    #[tokio::test]
3863    async fn call_rewrite_model_raw_restores_codex_mcp_namespace()
3864    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3865        let server = MockServer::start().await;
3866        Mock::given(method("POST"))
3867            .and(path("/v1/chat/completions"))
3868            .and(wiremock::matchers::body_partial_json(json!({
3869                "tools": [{
3870                    "type": "function",
3871                    "function": {"name": "mcp__open_websearch__search"}
3872                }]
3873            })))
3874            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3875                "id": "chatcmpl-1",
3876                "model": "gpt",
3877                "choices": [{
3878                    "index": 0,
3879                    "message": {
3880                        "role": "assistant",
3881                        "content": null,
3882                        "tool_calls": [{
3883                            "id": "call_1",
3884                            "type": "function",
3885                            "function": {
3886                                "name": "mcp__open_websearch__search",
3887                                "arguments": "{\"q\":\"rust\"}"
3888                            }
3889                        }]
3890                    },
3891                    "finish_reason": "tool_calls"
3892                }],
3893                "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
3894            })))
3895            .mount(&server)
3896            .await;
3897
3898        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
3899        let raw = json!({
3900            "model": "client-facing",
3901            "input": "Search for Rust.",
3902            "tools": [{
3903                "type": "namespace",
3904                "name": "mcp__open_websearch",
3905                "tools": [{
3906                    "type": "function",
3907                    "name": "search",
3908                    "description": "Search the web",
3909                    "parameters": {"type": "object", "properties": {"q": {"type": "string"}}}
3910                }]
3911            }]
3912        });
3913
3914        let RawResponse::Buffered(body) = client
3915            .call_rewrite_model_raw(
3916                raw,
3917                None,
3918                Some(&ModelId::from("gpt")),
3919                WireFormat::OpenAiResponses,
3920            )
3921            .await?
3922        else {
3923            panic!("expected a buffered response");
3924        };
3925
3926        assert_eq!(body["output"][0]["type"], "function_call");
3927        assert_eq!(body["output"][0]["name"], "search");
3928        assert_eq!(body["output"][0]["namespace"], "mcp__open_websearch");
3929        // Arguments are parsed and re-serialized, so the spacing is normalized.
3930        assert_eq!(body["output"][0]["arguments"], "{\"q\": \"rust\"}");
3931        Ok(())
3932    }
3933
3934    #[tokio::test]
3935    async fn openai_responses_backend_flattens_codex_namespaces_before_upstream()
3936    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
3937        let server = MockServer::start().await;
3938        Mock::given(method("POST"))
3939            .and(path("/v1/responses"))
3940            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
3941                "id": "resp_1",
3942                "model": "gpt",
3943                "object": "response",
3944                "created_at": 0,
3945                "status": "completed",
3946                "output": [{
3947                    "type": "function_call",
3948                    "call_id": "call_1",
3949                    "name": "mcp__open_websearch__search",
3950                    "arguments": "{\"q\":\"rust\"}"
3951                }],
3952                "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}
3953            })))
3954            .mount(&server)
3955            .await;
3956
3957        let models = responses_map(&format!("{}/v1", server.uri()))
3958            .into_iter()
3959            .map(|model| {
3960                model.with_responses_compatibility(crate::ResponsesCompatibility::FlatTools)
3961            })
3962            .collect::<Vec<_>>();
3963        let client = TranslatingLlmClient::new(&models)?;
3964        let parameters = json!({"type": "object", "properties": {"q": {"type": "string"}}});
3965        let raw = json!({
3966            "model": "client-facing",
3967            "input": [{
3968                "type": "function_call",
3969                "call_id": "call_0",
3970                "name": "search",
3971                "namespace": "mcp__open_websearch",
3972                "arguments": "{}"
3973            }],
3974            "tool_choice": {
3975                "type": "function",
3976                "name": "search",
3977                "namespace": "mcp__open_websearch"
3978            },
3979            "tools": [{
3980                "type": "namespace",
3981                "name": "mcp__open_websearch",
3982                "tools": [{
3983                    "type": "function",
3984                    "name": "search",
3985                    "parameters": parameters.clone()
3986                }]
3987            }]
3988        });
3989
3990        let RawResponse::Buffered(body) = client
3991            .call_rewrite_model_raw(
3992                raw,
3993                None,
3994                Some(&ModelId::from("gpt")),
3995                WireFormat::OpenAiResponses,
3996            )
3997            .await?
3998        else {
3999            panic!("expected a buffered response");
4000        };
4001
4002        let received = server
4003            .received_requests()
4004            .await
4005            .ok_or("request recording should be enabled")?;
4006        let request_body: Value = serde_json::from_slice(&received[0].body)?;
4007        assert_eq!(request_body["model"], "gpt");
4008        assert_eq!(
4009            request_body["tools"],
4010            json!([{
4011                "type": "function",
4012                "name": "mcp__open_websearch__search",
4013                "description": "",
4014                "parameters": parameters
4015            }])
4016        );
4017        assert_eq!(
4018            request_body["tool_choice"],
4019            json!({"type": "function", "name": "mcp__open_websearch__search"})
4020        );
4021        assert_eq!(
4022            request_body["input"][0]["name"],
4023            "mcp__open_websearch__search"
4024        );
4025        assert!(request_body["input"][0].get("namespace").is_none());
4026
4027        assert_eq!(body["output"][0]["type"], "function_call");
4028        assert_eq!(body["output"][0]["name"], "search");
4029        assert_eq!(body["output"][0]["namespace"], "mcp__open_websearch");
4030        Ok(())
4031    }
4032
4033    // A stage route can answer early steps of a Codex turn from Claude and a later step from
4034    // GPT, and Codex replays Claude's reasoning items on every step. OpenAI returns 400 for two
4035    // kinds: items with an ID but no `encrypted_content` when the request sets `store: false`,
4036    // and items whose `encrypted_content` holds Anthropic thinking that Switchyard packed for
4037    // the client. The Responses leg must drop both and keep GPT's own encrypted reasoning.
4038    // A non-OpenAI target instead keeps the plaintext and drops both payloads.
4039    #[tokio::test]
4040    async fn openai_responses_backend_drops_reasoning_the_provider_cannot_read()
4041    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
4042        let server = MockServer::start().await;
4043        Mock::given(method("POST"))
4044            .and(path("/v1/responses"))
4045            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
4046                "id": "resp_1",
4047                "model": "gpt",
4048                "object": "response",
4049                "created_at": 0,
4050                "status": "completed",
4051                "output": [{
4052                    "type": "message",
4053                    "role": "assistant",
4054                    "content": [{"type": "output_text", "text": "ok"}]
4055                }],
4056                "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}
4057            })))
4058            .mount(&server)
4059            .await;
4060
4061        let mut claude_reasoning = json!({
4062            "type": "reasoning",
4063            "id": "rs_b99feb9a-763d-4f7f-b6b2-2c0199b47ded",
4064            "summary": [{"type": "summary_text", "text": "Read the files first."}],
4065            "encrypted_content": null
4066        });
4067        // Build the item the way Switchyard does when it returns a Claude reply to a Responses
4068        // client, so the test follows any change to that format.
4069        let claude_reply = switchyard_translation::TranslationEngine::default()
4070            .translate_response(
4071                WireFormat::AnthropicMessages,
4072                WireFormat::OpenAiResponses,
4073                &json!({
4074                    "id": "msg_1",
4075                    "type": "message",
4076                    "role": "assistant",
4077                    "model": "claude",
4078                    "content": [
4079                        {"type": "thinking", "thinking": "Plan the edit.", "signature": "sig-abc"},
4080                        {"type": "text", "text": "ok"}
4081                    ],
4082                    "stop_reason": "end_turn",
4083                    "usage": {"input_tokens": 1, "output_tokens": 1}
4084                }),
4085                &switchyard_translation::TranslationPolicy::default(),
4086            )?
4087            .body;
4088        let switchyard_reasoning = claude_reply["output"]
4089            .as_array()
4090            .and_then(|output| output.iter().find(|item| item["type"] == "reasoning"))
4091            .cloned()
4092            .ok_or("translated Claude reply should contain a reasoning item")?;
4093        assert!(switchyard_translation::is_anthropic_thinking_envelope(
4094            switchyard_reasoning["encrypted_content"]
4095                .as_str()
4096                .unwrap_or_default()
4097        ));
4098        let mut gpt_reasoning = json!({
4099            "type": "reasoning",
4100            "id": "rs_gpt",
4101            "summary": [],
4102            "encrypted_content": "opaque-encrypted-reasoning"
4103        });
4104        let mut empty_reasoning =
4105            json!({"type": "reasoning", "id": "rs_empty", "summary": [], "encrypted_content": ""});
4106        let user = json!({"type": "message", "role": "user", "content": "fix the build"});
4107        let call = json!({"type": "function_call", "call_id": "call_1", "name": "exec", "arguments": "{}"});
4108        let output = json!({"type": "function_call_output", "call_id": "call_1", "output": "ok"});
4109        let input = json!([
4110            user,
4111            claude_reasoning,
4112            switchyard_reasoning,
4113            call,
4114            output,
4115            gpt_reasoning,
4116            empty_reasoning
4117        ]);
4118
4119        let models = ["openai/gpt-5", "nvidia/zai-org/glm-5.3-flash"].map(|model| {
4120            ModelConfig::new(
4121                model,
4122                Backend::OpenAiResponses(config(&format!("{}/v1", server.uri()))),
4123                None,
4124            )
4125        });
4126        let client = TranslatingLlmClient::new(&models)?;
4127        for model in ["openai/gpt-5", "nvidia/zai-org/glm-5.3-flash"] {
4128            for store in [json!(false), json!(true)] {
4129                let raw = json!({"model": "client-facing", "store": store, "input": input.clone()});
4130                client
4131                    .call_rewrite_model_raw(
4132                        raw,
4133                        None,
4134                        Some(&ModelId::from(model)),
4135                        WireFormat::OpenAiResponses,
4136                    )
4137                    .await?;
4138            }
4139        }
4140
4141        let received = server
4142            .received_requests()
4143            .await
4144            .ok_or("request recording should be enabled")?;
4145        let sent: Vec<Value> = received
4146            .iter()
4147            .map(|request| serde_json::from_slice::<Value>(&request.body))
4148            .collect::<std::result::Result<_, _>>()?;
4149        assert_eq!(sent.len(), 4);
4150        for request in &sent {
4151            assert!(
4152                request["input"]
4153                    .as_array()
4154                    .ok_or("expected input array")?
4155                    .iter()
4156                    .all(|item| !item["encrypted_content"]
4157                        .as_str()
4158                        .is_some_and(switchyard_translation::is_anthropic_thinking_envelope)),
4159                "provider cannot read translated Anthropic reasoning"
4160            );
4161        }
4162
4163        // GLM keeps the plaintext beside the Anthropic envelope and drops GPT's payload.
4164        let mut switchyard_plaintext = switchyard_reasoning.clone();
4165        switchyard_plaintext
4166            .as_object_mut()
4167            .ok_or("object")?
4168            .remove("encrypted_content");
4169        assert_eq!(
4170            sent[2]["input"],
4171            json!([user, claude_reasoning, switchyard_plaintext, call, output])
4172        );
4173        assert_eq!(
4174            sent[3]["input"],
4175            json!([
4176                user,
4177                claude_reasoning,
4178                switchyard_plaintext,
4179                call,
4180                output,
4181                empty_reasoning
4182            ])
4183        );
4184
4185        for item in [
4186            &mut claude_reasoning,
4187            &mut gpt_reasoning,
4188            &mut empty_reasoning,
4189        ] {
4190            item["content"] = json!([]);
4191        }
4192        // With `store: false`, only GPT's own encrypted reasoning survives.
4193        assert_eq!(sent[0]["input"], json!([user, call, output, gpt_reasoning]));
4194        // With `store: true`, IDs can refer to stored reasoning, but OpenAI still cannot
4195        // decrypt the Switchyard item.
4196        assert_eq!(
4197            sent[1]["input"],
4198            json!([
4199                user,
4200                claude_reasoning,
4201                call,
4202                output,
4203                gpt_reasoning,
4204                empty_reasoning
4205            ])
4206        );
4207        Ok(())
4208    }
4209
4210    // Raw path, streaming: an inbound `stream: true` request yields an unframed stream
4211    // of OpenAI Chat chunk objects whose deltas reassemble the completion.
4212    #[tokio::test]
4213    async fn call_rewrite_model_raw_streams_wire_events()
4214    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
4215        use futures::TryStreamExt;
4216
4217        let server = MockServer::start().await;
4218        let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n\
4219             data: {\"choices\":[{\"delta\":{\"content\":\" world\"}}]}\n\n\
4220             data: [DONE]\n\n";
4221        Mock::given(method("POST"))
4222            .and(path("/v1/chat/completions"))
4223            .respond_with(ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream"))
4224            .mount(&server)
4225            .await;
4226
4227        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
4228        let raw = json!({
4229            "model": "client-facing",
4230            "messages": [{"role": "user", "content": "hi"}],
4231            "stream": true
4232        });
4233        let RawResponse::Stream(stream) = client
4234            .call_rewrite_model_raw(
4235                raw,
4236                None,
4237                Some(&ModelId::from("gpt")),
4238                WireFormat::OpenAiChat,
4239            )
4240            .await?
4241        else {
4242            panic!("expected a streamed response");
4243        };
4244
4245        let events: Vec<Value> = stream.try_collect().await?;
4246        assert!(!events.is_empty(), "expected at least one wire event");
4247        let content: String = events
4248            .iter()
4249            .filter_map(|event| event["choices"][0]["delta"]["content"].as_str())
4250            .collect();
4251        assert_eq!(content, "Hello world");
4252        // The mock frames carry no `model`, so every chunk's model comes from the
4253        // served id rather than the "unknown" fallback or the caller's route id.
4254        assert!(events.iter().all(|event| event["model"] == "gpt"));
4255        Ok(())
4256    }
4257
4258    // Raw path forwards allowed caller headers to the upstream.
4259    #[tokio::test]
4260    async fn call_rewrite_model_raw_forwards_headers()
4261    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
4262        let server = MockServer::start().await;
4263        Mock::given(method("POST"))
4264            .and(wiremock::matchers::header("x-request-id", "abc"))
4265            // A forwarded authorization must NOT override the backend's bearer key.
4266            .and(wiremock::matchers::header("authorization", "Bearer secret"))
4267            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
4268                "id": "1", "model": "gpt",
4269                "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
4270                "usage": {}
4271            })))
4272            .mount(&server)
4273            .await;
4274
4275        let mut headers = http::HeaderMap::new();
4276        headers.insert("x-request-id", http::HeaderValue::from_static("abc"));
4277        headers.insert(
4278            "authorization",
4279            http::HeaderValue::from_static("Bearer client-key"),
4280        );
4281
4282        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
4283        let raw = json!({"model": "gpt", "messages": [{"role": "user", "content": "hi"}]});
4284        // The allowed request id survives and caller authorization cannot replace
4285        // the backend's credential.
4286        client
4287            .call_rewrite_model_raw(
4288                raw,
4289                Some(headers),
4290                Some(&ModelId::from("gpt")),
4291                WireFormat::OpenAiChat,
4292            )
4293            .await?;
4294        Ok(())
4295    }
4296}