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`] — the crate's single public entry point: encode a neutral
5//! request, call the configured backend over HTTP, decode the neutral response.
6
7use std::collections::{BTreeMap, HashMap};
8use std::time::{Duration, SystemTime};
9
10use async_trait::async_trait;
11use futures_util::StreamExt;
12use reqwest::RequestBuilder;
13use reqwest::header::{HeaderMap, RETRY_AFTER};
14use serde_json::{Map, Value};
15use switchyard_protocol::{
16    Context, Decision, LlmRequest, LlmResponse, Metadata, Request, Response, RoutedLlmClient,
17};
18use switchyard_translation::{
19    WireFormat, decode_aggregated_response, decode_request, decode_stream,
20    encode_aggregated_response, encode_request, encode_stream,
21};
22use tracing::Instrument;
23
24use crate::backend::Backend;
25use crate::error::{LlmClientError, Result};
26use crate::metrics::{is_retryable_http_status, record_upstream_attempt};
27use crate::raw::RawResponse;
28
29// TODO: Why is this here? What does it do?
30// Headers this client owns or that are hop-by-hop; never forwarded from the
31// caller's metadata. Auth/version/content-type are set by the backend or the
32// JSON body, so a forwarded copy would either be ignored or conflict. Compared
33// case-insensitively. Aligns with `_SENSITIVE_HEADERS` in the Python
34// `switchyard/lib/request_metadata.py` forwarding logic.
35const RESERVED_HEADERS: &[&str] = &[
36    "host",
37    "content-length",
38    "connection",
39    "authorization",
40    "proxy-authorization",
41    "proxy-authenticate",
42    "cookie",
43    "set-cookie",
44    "x-api-key",
45    "anthropic-beta",
46    "anthropic-version",
47    "content-type",
48    "accept-encoding",
49];
50
51const INITIAL_RETRY_DELAY: Duration = Duration::from_millis(250);
52const MAX_RETRY_BACKOFF: Duration = Duration::from_secs(2);
53const MAX_RETRY_AFTER: Duration = Duration::from_secs(60);
54
55/// How one model is served: the `default_backend` used when the request does not
56/// pin a wire format, plus any `other_backends` reachable over additional formats.
57#[derive(Clone, Debug)]
58pub struct ModelConfig {
59    model_name: String,
60    default_backend: Backend,
61    other_backends: Option<Vec<Backend>>,
62}
63
64impl ModelConfig {
65    /// A model named `model_name` served by `default_backend`, optionally reachable
66    /// over additional wire formats via `other_backends`.
67    pub fn new(
68        model_name: impl Into<String>,
69        default_backend: Backend,
70        other_backends: Option<Vec<Backend>>,
71    ) -> Self {
72        Self {
73            model_name: model_name.into(),
74            default_backend,
75            other_backends,
76        }
77    }
78}
79
80/// A client that dispatches neutral-IR requests to per-model HTTP backends.
81///
82/// Construct it with a list of [`ModelConfig`]s — one per model, each naming a
83/// default [`Backend`] and any additional per-format backends. Each call resolves
84/// the model and wire format, encodes the request to that backend's wire format,
85/// applies auth and forwarded headers, sends the HTTP request with a shared
86/// [`reqwest::Client`], and decodes the response back to the neutral IR (buffered
87/// or streamed).
88pub struct TranslatingLlmClient {
89    model_to_config: HashMap<String, ModelConfig>,
90    client: reqwest::Client,
91}
92
93impl TranslatingLlmClient {
94    /// Builds a client over the given [`ModelConfig`]s, with a fresh shared HTTP
95    /// client and the built-in translation codecs.
96    pub fn new(model_configs: &[ModelConfig]) -> Result<Self> {
97        let client =
98            reqwest::Client::builder()
99                .build()
100                .map_err(|error| LlmClientError::Transport {
101                    source: Box::new(error),
102                })?;
103        let model_to_config = model_configs
104            .iter()
105            .map(|config| (config.model_name.clone(), config.clone()))
106            .collect();
107
108        Ok(Self {
109            model_to_config,
110            client,
111        })
112    }
113
114    /// The backend serving `model` over `format` — the default backend when its
115    /// format matches, otherwise a matching entry in `other_backends`; `None` when
116    /// the model is unknown or has no backend for `format`.
117    pub fn backend_for(&self, model: &str, format: WireFormat) -> Option<&Backend> {
118        self.model_to_config.get(model).and_then(|config| {
119            if config.default_backend.wire_format() == format {
120                Some(&config.default_backend)
121            } else {
122                config
123                    .other_backends
124                    .as_ref()
125                    .and_then(|backends| backends.iter().find(|b| b.wire_format() == format))
126            }
127        })
128    }
129
130    /// Whether `model` has an Anthropic backend that supports token counting.
131    pub fn supports_count_tokens(&self, model: &str) -> bool {
132        self.backend_for(model, WireFormat::AnthropicMessages)
133            .is_some()
134    }
135
136    /// Counts input tokens with `model`'s Anthropic backend.
137    ///
138    /// Returns an error when the model has no Anthropic backend or the upstream
139    /// request fails or returns invalid JSON.
140    pub async fn count_tokens(&self, model: &str, request: Request) -> Result<Value> {
141        let backend = self
142            .backend_for(model, WireFormat::AnthropicMessages)
143            .ok_or_else(|| LlmClientError::Configuration {
144                message: format!("model {model} has no Anthropic backend for count_tokens"),
145            })?;
146        let Request {
147            llm_request,
148            metadata,
149            ..
150        } = request;
151        let http_response = self
152            .send_encoded(
153                backend,
154                WireFormat::AnthropicMessages,
155                llm_request,
156                metadata.as_ref(),
157                model,
158                UpstreamEndpoint::CountTokens,
159            )
160            .await?;
161        let body = match http_response {
162            EncodedResponse::Buffered { body, .. } => body,
163            EncodedResponse::Streaming(_) => {
164                return Err(LlmClientError::InvalidRequest {
165                    message: "count_tokens does not support streaming requests".to_string(),
166                });
167            }
168        };
169        serde_json::from_slice(&body).map_err(|error| LlmClientError::InvalidResponse {
170            source: Box::new(error),
171        })
172    }
173
174    /// Encode `llm_request` (its model restamped to `model`) for `wire_format`,
175    /// POST it to `url` with the request's forwarded headers plus the backend's
176    /// static headers and auth, and return the successful upstream response. A
177    /// buffered response is fully collected within the retry boundary; a streamed
178    /// response is returned as soon as its successful headers arrive. A non-success
179    /// status maps to a typed error — a 400 is classified as a context-window
180    /// overflow via the backend's provider rules. Shared by
181    /// [`call_rewrite_model`](Self::call_rewrite_model) (which POSTs to the
182    /// backend's completion URL and decodes a response) and
183    /// [`count_tokens`](Self::count_tokens) (which POSTs to the `count_tokens`
184    /// URL and returns the raw JSON).
185    async fn send_encoded(
186        &self,
187        backend: &Backend,
188        wire_format: WireFormat,
189        mut llm_request: LlmRequest,
190        metadata: Option<&Metadata>,
191        model: &str,
192        endpoint: UpstreamEndpoint,
193    ) -> Result<EncodedResponse> {
194        // The resolved name is the upstream model id (per the crate contract).
195        llm_request.model = Some(model.to_string());
196        let mut body = encode_request(&llm_request, wire_format)
197            .map_err(|error| LlmClientError::RequestEncoding(error.to_string()))?;
198        // `encode_request` round-trips a preserved same-format body verbatim,
199        // which keeps the caller's original `model`; force the resolved model so
200        // the upstream always sees the target id.
201        set_json_model(&mut body, model);
202        // Strip before `merge_extra_body` so a target can reinstate either field
203        // deliberately via `extra_body`.
204        if matches!(backend, Backend::Anthropic(_)) {
205            strip_anthropic_incompatible_fields(&mut body);
206            strip_unsigned_thinking_blocks(&mut body);
207        }
208        merge_extra_body(&mut body, backend.extra_body());
209        if matches!(backend, Backend::Anthropic(_)) {
210            enable_anthropic_prompt_caching(&mut body);
211        }
212        if matches!(backend, Backend::OpenAiChat(_)) {
213            ensure_openai_stream_usage(&mut body);
214        }
215        let streaming = endpoint.allows_streaming()
216            && body.get("stream").and_then(Value::as_bool).unwrap_or(false);
217        let url = endpoint.url(backend);
218        record_gen_ai_request(&url, model, streaming);
219
220        let max_retries = u64::from(backend.max_retries());
221        let max_attempts = max_retries + 1;
222        let mut attempt = 0_u64;
223        loop {
224            let span = tracing::debug_span!(
225                target: "libsy",
226                "libsy.upstream_attempt",
227                model,
228                wire_format = %wire_format,
229                attempt = attempt + 1,
230                max_attempts,
231                retry = attempt > 0,
232                openinference.span.kind = "CHAIN",
233                outcome = tracing::field::Empty,
234                status_code = tracing::field::Empty,
235                will_retry = tracing::field::Empty,
236                retry_delay_ms = tracing::field::Empty,
237            );
238            let result = self
239                .send_once(&url, backend, &body, metadata, model, streaming)
240                .instrument(span.clone())
241                .await;
242            // The retained handle updates this same attempt span with its outcome.
243            match result {
244                Ok(response) => {
245                    span.record("outcome", "success");
246                    span.record("status_code", response.status());
247                    span.record("will_retry", false);
248                    return Ok(response);
249                }
250                Err(failure) => {
251                    let will_retry = attempt < max_retries && failure.is_retryable();
252                    span.record("outcome", "error");
253                    if let Some(status) = failure.status {
254                        span.record("status_code", status);
255                    }
256                    span.record("will_retry", will_retry);
257                    if !will_retry {
258                        return Err(failure.error);
259                    }
260
261                    let delay = retry_delay(attempt, failure.retry_after);
262                    span.record("retry_delay_ms", duration_millis(delay));
263                    // Close the attempt span before sleeping so backoff is not attempt latency.
264                    drop(span);
265                    tokio::time::sleep(delay).await;
266                    attempt += 1;
267                }
268            }
269        }
270    }
271
272    // Performs one HTTP attempt and retains the retry metadata alongside any error.
273    async fn send_once(
274        &self,
275        url: &str,
276        backend: &Backend,
277        body: &Value,
278        metadata: Option<&Metadata>,
279        model: &str,
280        streaming: bool,
281    ) -> std::result::Result<EncodedResponse, AttemptFailure> {
282        let builder = self.client.post(url).json(body);
283        let builder = forward_metadata_headers(builder, metadata);
284        let builder = apply_extra_headers(builder, backend);
285        let builder = backend.apply_auth(builder);
286
287        let response = match builder.send().await {
288            Ok(response) => response,
289            Err(error) => {
290                record_upstream_attempt(None);
291                return Err(AttemptFailure {
292                    error: convert_reqwest_error(error),
293                    status: None,
294                    retry_after: None,
295                });
296            }
297        };
298        let status = response.status();
299        if status.is_success() {
300            if streaming {
301                // Streaming body failures happen after the retry boundary.
302                record_upstream_attempt(Some(status.as_u16()));
303                return Ok(EncodedResponse::Streaming(response));
304            }
305            let body = match response.bytes().await {
306                Ok(body) => body,
307                Err(error) => {
308                    record_upstream_attempt(None);
309                    return Err(AttemptFailure {
310                        error: convert_reqwest_error(error),
311                        status: Some(status.as_u16()),
312                        retry_after: None,
313                    });
314                }
315            };
316            record_upstream_attempt(Some(status.as_u16()));
317            return Ok(EncodedResponse::Buffered {
318                status: status.as_u16(),
319                body: body.to_vec(),
320            });
321        }
322
323        let retry_after = retry_after_delay(response.headers());
324        let body = match response.text().await {
325            Ok(body) => body,
326            Err(error) => {
327                record_upstream_attempt(None);
328                return Err(AttemptFailure {
329                    error: convert_reqwest_error(error),
330                    status: Some(status.as_u16()),
331                    retry_after,
332                });
333            }
334        };
335        record_upstream_attempt(Some(status.as_u16()));
336        let error =
337            if status == reqwest::StatusCode::BAD_REQUEST && backend.is_context_overflow(&body) {
338                LlmClientError::ContextWindowExceeded {
339                    model: model.to_string(),
340                    message: body,
341                }
342            } else {
343                LlmClientError::UpstreamHttp {
344                    status: status.as_u16(),
345                    body,
346                }
347            };
348        Err(AttemptFailure {
349            error,
350            status: Some(status.as_u16()),
351            retry_after,
352        })
353    }
354
355    /// Calls the backend for `model_name` (or the request's own model), over the
356    /// wire format the request pins in its metadata (else the model's default
357    /// backend), and returns the neutral response.
358    ///
359    /// Resolution: `model_name` wins over `request.llm_request.model`; the
360    /// resolved name is both the outer map key and the model id written into the
361    /// request before translation. Missing models are invalid requests; unknown
362    /// models or wire formats are configuration errors.
363    pub async fn call_rewrite_model(
364        &self,
365        _ctx: Context,
366        request: Request,
367        model_name: Option<&str>,
368    ) -> Result<Response> {
369        // Own the request's parts so the model can be set without a `mut` param
370        // and without cloning the messages. `raw_request` is unused here.
371        let Request {
372            llm_request,
373            metadata,
374            ..
375        } = request;
376
377        let model = model_name
378            .map(str::to_string)
379            .or_else(|| llm_request.model.clone())
380            .ok_or_else(|| LlmClientError::InvalidRequest {
381                message: "no model given".to_string(),
382            })?;
383
384        let orig_format = metadata.as_ref().and_then(|m| m.wire_format);
385        let wire_format = orig_format.unwrap_or(
386            self.model_to_config
387                .get(&model)
388                .map(|config| config.default_backend.wire_format())
389                .ok_or_else(|| LlmClientError::Configuration {
390                    message: format!("no backend configured for model {model:?}"),
391                })?,
392        );
393        let backend =
394            self.backend_for(&model, wire_format)
395                .ok_or_else(|| LlmClientError::Configuration {
396                    message: format!("model {model:?} has no backend for format {wire_format}"),
397                })?;
398
399        let http_response = self
400            .send_encoded(
401                backend,
402                wire_format,
403                llm_request,
404                metadata.as_ref(),
405                &model,
406                UpstreamEndpoint::Completion,
407            )
408            .await?;
409
410        let llm_response = match http_response {
411            EncodedResponse::Streaming(http_response) => {
412                // Adapt the reqwest body stream to plain bytes; the SSE-decode itself is
413                // transport-agnostic and lives in `switchyard-translation`.
414                let bytes = http_response.bytes_stream().map(|chunk| {
415                    chunk.map(|bytes| bytes.to_vec()).map_err(|error| {
416                        if error.is_timeout() {
417                            LlmClientError::Timeout {
418                                source: Box::new(error),
419                            }
420                        } else {
421                            LlmClientError::Transport {
422                                source: Box::new(error),
423                            }
424                        }
425                    })
426                });
427                let chunks = decode_stream(bytes, wire_format)?;
428                LlmResponse::Stream(chunks)
429            }
430            EncodedResponse::Buffered { body, .. } => {
431                let body = serde_json::from_slice::<Value>(&body).map_err(|error| {
432                    LlmClientError::ResponseTranslation(format!("invalid upstream JSON: {error}"))
433                })?;
434                let agg = decode_aggregated_response(&body, wire_format)
435                    .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
436                LlmResponse::Agg(agg)
437            }
438        };
439
440        Ok(Response {
441            llm_response,
442            metadata,
443        })
444    }
445
446    /// The whole decode → call → encode path a wire endpoint needs, in one call.
447    ///
448    /// Decodes `raw_http_request` from `wire_format` to the neutral IR, serves it via
449    /// [`call_rewrite_model`](Self::call_rewrite_model) — the *upstream* wire format is
450    /// resolved there from the model's backend, independently of `wire_format` — then
451    /// encodes the neutral response back into `wire_format`. The result is a buffered
452    /// [`RawResponse::Buffered`] JSON body or a streamed [`RawResponse::Stream`] of
453    /// wire events (the caller frames the stream as SSE). The response's `model` is
454    /// restamped with the model that actually served the call, so the body names the
455    /// model that answered rather than the route the caller addressed.
456    ///
457    /// `http_headers` are carried through as the request's
458    /// [`Metadata::http_headers`] and forwarded to the upstream (minus the reserved
459    /// set); pass `None` to forward nothing.
460    pub async fn call_rewrite_model_raw(
461        &self,
462        ctx: Context,
463        raw_http_request: Value,
464        http_headers: Option<http::HeaderMap>,
465        model: Option<&str>,
466        wire_format: WireFormat,
467    ) -> Result<RawResponse> {
468        let llm_request = decode_request(wire_format, &raw_http_request)
469            .map_err(|error| LlmClientError::RequestTranslation(error.to_string()))?;
470        // The model that serves the call — the rewrite target when the caller pinned
471        // one, else the request's own model. Mirrors `call_rewrite_model`'s own
472        // resolution so the response names whoever answered.
473        let served_model = model
474            .map(str::to_string)
475            .or_else(|| llm_request.model.clone());
476
477        let request = Request {
478            llm_request,
479            raw_request: None,
480            metadata: Some(Metadata {
481                session_id: None,
482                agent_id: None,
483                task_id: None,
484                correlation_id: None,
485                extra_metadata: None,
486                http_headers,
487                wire_format: None,
488                ..Default::default()
489            }),
490        };
491        let response = self.call_rewrite_model(ctx, request, model).await?;
492
493        match response.llm_response {
494            LlmResponse::Agg(agg) => {
495                let body =
496                    encode_aggregated_response(&agg, wire_format, served_model.as_deref())
497                        .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
498                Ok(RawResponse::Buffered(body))
499            }
500            LlmResponse::Stream(chunks) => {
501                let events = encode_stream(chunks, wire_format, served_model)?;
502                Ok(RawResponse::Stream(events))
503            }
504        }
505    }
506}
507
508#[async_trait]
509impl RoutedLlmClient for TranslatingLlmClient {
510    async fn call(
511        &self,
512        ctx: Context,
513        request: Request,
514        decision: std::sync::Arc<dyn Decision>,
515    ) -> Result<Response> {
516        let model_name = Some(decision.selected_model());
517        self.call_rewrite_model(ctx, request, model_name).await
518    }
519}
520
521#[derive(Clone, Copy)]
522enum UpstreamEndpoint {
523    Completion,
524    CountTokens,
525}
526
527impl UpstreamEndpoint {
528    fn url(self, backend: &Backend) -> String {
529        match self {
530            UpstreamEndpoint::Completion => backend.url(),
531            UpstreamEndpoint::CountTokens => backend.count_tokens_url(),
532        }
533    }
534
535    fn allows_streaming(self) -> bool {
536        matches!(self, UpstreamEndpoint::Completion)
537    }
538}
539
540enum EncodedResponse {
541    Buffered { status: u16, body: Vec<u8> },
542    Streaming(reqwest::Response),
543}
544
545impl EncodedResponse {
546    fn status(&self) -> u16 {
547        match self {
548            EncodedResponse::Buffered { status, .. } => *status,
549            EncodedResponse::Streaming(response) => response.status().as_u16(),
550        }
551    }
552}
553
554// The typed error decides retry eligibility; status and Retry-After feed
555// attempt telemetry and delay selection.
556struct AttemptFailure {
557    error: LlmClientError,
558    status: Option<u16>,
559    retry_after: Option<Duration>,
560}
561
562impl AttemptFailure {
563    fn is_retryable(&self) -> bool {
564        match &self.error {
565            LlmClientError::Transport { .. } | LlmClientError::Timeout { .. } => true,
566            LlmClientError::UpstreamHttp { status, .. } => is_retryable_http_status(*status),
567            _ => false,
568        }
569    }
570}
571
572// Uses Retry-After when supplied, capped so an upstream cannot stall a request indefinitely.
573fn retry_after_delay(headers: &HeaderMap) -> Option<Duration> {
574    let value = headers.get(RETRY_AFTER)?.to_str().ok()?;
575    let delay = if let Ok(seconds) = value.parse::<u64>() {
576        Duration::from_secs(seconds)
577    } else {
578        let retry_at = httpdate::parse_http_date(value).ok()?;
579        retry_at
580            .duration_since(SystemTime::now())
581            .unwrap_or(Duration::ZERO)
582    };
583    Some(delay.min(MAX_RETRY_AFTER))
584}
585
586fn retry_delay(retry_number: u64, retry_after: Option<Duration>) -> Duration {
587    // Retry-After wins; otherwise double 250 ms up to the two-second cap.
588    retry_after.unwrap_or_else(|| {
589        let multiplier = 1_u32 << retry_number.min(3);
590        INITIAL_RETRY_DELAY
591            .saturating_mul(multiplier)
592            .min(MAX_RETRY_BACKOFF)
593    })
594}
595
596fn duration_millis(duration: Duration) -> u64 {
597    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
598}
599
600fn record_gen_ai_request(url: &str, model: &str, streaming: bool) {
601    let span = tracing::Span::current();
602    span.record("gen_ai.request.model", model);
603    if streaming {
604        span.record("gen_ai.request.stream", true);
605    }
606    if let Ok(url) = reqwest::Url::parse(url) {
607        if let Some(host) = url.host_str() {
608            span.record("server.address", host);
609        }
610        if let Some(port) = url.port_or_known_default() {
611            span.record("server.port", i64::from(port));
612        }
613    }
614}
615
616fn convert_reqwest_error(error: reqwest::Error) -> LlmClientError {
617    // Reqwest labels truncated or otherwise unreadable response bodies as decode
618    // errors, so distinguish them from serde JSON failures at the call site.
619    if error.is_timeout() {
620        LlmClientError::Timeout {
621            source: Box::new(error),
622        }
623    } else if error.is_builder() {
624        LlmClientError::Configuration {
625            message: format!("failed to build upstream request: {error}"),
626        }
627    } else {
628        LlmClientError::Transport {
629            source: Box::new(error),
630        }
631    }
632}
633
634// Forwards caller-supplied metadata headers, skipping the reserved set.
635fn forward_metadata_headers(
636    mut builder: RequestBuilder,
637    metadata: Option<&Metadata>,
638) -> RequestBuilder {
639    let Some(headers) = metadata.and_then(|metadata| metadata.http_headers.as_ref()) else {
640        return builder;
641    };
642    for (name, value) in headers {
643        if is_reserved_header(name.as_str()) {
644            continue;
645        }
646        builder = builder.header(name, value);
647    }
648    builder
649}
650
651// Adds the backend's static per-call headers.
652fn apply_extra_headers(mut builder: RequestBuilder, backend: &Backend) -> RequestBuilder {
653    for (name, value) in backend.extra_headers() {
654        builder = builder.header(name, value);
655    }
656    builder
657}
658
659// Overwrites the outbound body's `model` field with the resolved model id.
660fn set_json_model(body: &mut Value, model: &str) {
661    if let Value::Object(object) = body {
662        object.insert("model".to_string(), Value::String(model.to_string()));
663    }
664}
665
666// Drops fields accepted by OpenAI-like APIs but rejected by Anthropic Messages.
667//
668// A router can serve earlier turns of a session from an OpenAI-format target and
669// later turns from an Anthropic one. Clients such as Claude Code send
670// `context_management` on every turn, so the Anthropic leg must strip it or the
671// upstream rejects the request (for example `clear_thinking_20251015` strategy
672// requires `thinking` to be enabled or adaptive). Mirrors
673// `switchyard-components`' `strip_anthropic_incompatible_fields`.
674fn strip_anthropic_incompatible_fields(body: &mut Value) {
675    if let Value::Object(object) = body {
676        object.remove("reasoning_effort");
677        object.remove("context_management");
678    }
679}
680
681// Removes replayed `thinking` blocks that carry no signature.
682//
683// Anthropic requires signed thinking blocks on replay. A router can serve earlier
684// turns of a session from an OpenAI-format target whose thinking blocks are
685// unsigned, so the Anthropic leg must drop them or the upstream rejects the
686// request. Bedrock enforces this (surfacing as a SigV4 signature mismatch) where
687// Azure-hosted Anthropic currently does not. Mirrors `switchyard-components`'
688// `strip_unsigned_thinking_blocks`.
689fn strip_unsigned_thinking_blocks(body: &mut Value) {
690    let Value::Object(object) = body else {
691        return;
692    };
693    let Some(Value::Array(messages)) = object.get_mut("messages") else {
694        return;
695    };
696    for message in messages {
697        strip_unsigned_thinking_from_message(message);
698    }
699}
700
701// Drops unsigned thinking blocks from one message, collapsing content that ends
702// up empty to an empty string so the message stays valid.
703fn strip_unsigned_thinking_from_message(message: &mut Value) {
704    let Value::Object(message) = message else {
705        return;
706    };
707    let Some(Value::Array(blocks)) = message.get("content") else {
708        return;
709    };
710    if !blocks.iter().any(is_unsigned_thinking_block) {
711        return;
712    }
713    let Some(Value::Array(blocks)) = message.get_mut("content") else {
714        return;
715    };
716    blocks.retain(|block| !is_unsigned_thinking_block(block));
717    if blocks.is_empty() {
718        message.insert("content".to_string(), Value::String(String::new()));
719    }
720}
721
722// A thinking block is unsigned when `signature` is absent or empty.
723fn is_unsigned_thinking_block(block: &Value) -> bool {
724    if block.get("type").and_then(Value::as_str) != Some("thinking") {
725        return false;
726    }
727    !matches!(
728        block.get("signature").and_then(Value::as_str),
729        Some(signature) if !signature.is_empty()
730    )
731}
732
733// Applies target defaults without overriding fields supplied by the caller.
734fn merge_extra_body(body: &mut Value, extra_body: &BTreeMap<String, Value>) {
735    let Value::Object(object) = body else {
736        return;
737    };
738    for (key, value) in extra_body {
739        object.entry(key.clone()).or_insert_with(|| value.clone());
740    }
741}
742
743// Marks the final message content block as the Anthropic prompt-cache breakpoint.
744fn enable_anthropic_prompt_caching(body: &mut Value) {
745    let Some(content) = body
746        .get_mut("messages")
747        .and_then(Value::as_array_mut)
748        .and_then(|messages| messages.last_mut())
749        .and_then(|message| message.get_mut("content"))
750    else {
751        return;
752    };
753    match content {
754        Value::String(text) => {
755            *content = serde_json::json!([{
756                "type": "text",
757                "text": std::mem::take(text),
758                "cache_control": {"type": "ephemeral"}
759            }]);
760        }
761        Value::Array(blocks) => {
762            if let Some(block) = blocks.last_mut().and_then(Value::as_object_mut) {
763                block
764                    .entry("cache_control".to_string())
765                    .or_insert_with(|| serde_json::json!({"type": "ephemeral"}));
766            }
767        }
768        _ => {}
769    }
770}
771
772// Requests streamed Chat usage by default while preserving an explicit caller choice.
773fn ensure_openai_stream_usage(body: &mut Value) {
774    let Value::Object(object) = body else {
775        return;
776    };
777    if object.get("stream").and_then(Value::as_bool) != Some(true) {
778        return;
779    }
780
781    match object.get_mut("stream_options") {
782        Some(Value::Object(options)) => {
783            options
784                .entry("include_usage".to_string())
785                .or_insert(Value::Bool(true));
786        }
787        _ => {
788            let mut options = Map::new();
789            options.insert("include_usage".to_string(), Value::Bool(true));
790            object.insert("stream_options".to_string(), Value::Object(options));
791        }
792    }
793}
794
795// Case-insensitive membership test against RESERVED_HEADERS.
796fn is_reserved_header(name: &str) -> bool {
797    RESERVED_HEADERS
798        .iter()
799        .any(|reserved| name.eq_ignore_ascii_case(reserved))
800}
801
802#[cfg(test)]
803mod tests {
804    use std::collections::BTreeMap;
805    use std::error::Error;
806    use std::io::{Read, Write};
807    use std::sync::Arc;
808    use std::sync::atomic::{AtomicUsize, Ordering};
809    use std::thread::JoinHandle;
810
811    use serde_json::json;
812    use switchyard_protocol::{LlmRequest, completion_text, text_request};
813    use wiremock::matchers::{method, path};
814    use wiremock::{Mock, MockServer, ResponseTemplate};
815
816    use super::*;
817    use crate::backend::HttpBackendConfig;
818
819    fn config(base_url: &str) -> HttpBackendConfig {
820        HttpBackendConfig {
821            base_url: base_url.to_string(),
822            api_key: Some("secret".to_string()),
823            extra_headers: BTreeMap::new(),
824            extra_body: BTreeMap::new(),
825            max_retries: 0,
826        }
827    }
828
829    fn config_with_retries(base_url: &str, max_retries: u32) -> HttpBackendConfig {
830        HttpBackendConfig {
831            max_retries,
832            ..config(base_url)
833        }
834    }
835
836    // A one-model config list: "gpt" served over OpenAI Chat at base_url.
837    fn chat_map(base_url: &str) -> Vec<ModelConfig> {
838        vec![ModelConfig::new(
839            "gpt",
840            Backend::OpenAiChat(config(base_url)),
841            None,
842        )]
843    }
844
845    fn chat_map_with_extra_body(
846        base_url: &str,
847        extra_body: BTreeMap<String, Value>,
848    ) -> Vec<ModelConfig> {
849        let mut backend = config(base_url);
850        backend.extra_body = extra_body;
851        vec![ModelConfig::new("gpt", Backend::OpenAiChat(backend), None)]
852    }
853
854    fn anthropic_map(base_url: &str) -> Vec<ModelConfig> {
855        vec![ModelConfig::new(
856            "claude",
857            Backend::Anthropic(config(base_url)),
858            None,
859        )]
860    }
861
862    fn chat_map_with_retries(base_url: &str, max_retries: u32) -> Vec<ModelConfig> {
863        vec![ModelConfig::new(
864            "gpt",
865            Backend::OpenAiChat(config_with_retries(base_url, max_retries)),
866            None,
867        )]
868    }
869
870    fn chat_success_response() -> ResponseTemplate {
871        ResponseTemplate::new(200).set_body_json(json!({
872            "id": "chatcmpl-1",
873            "model": "gpt",
874            "choices": [{
875                "index": 0,
876                "message": {"role": "assistant", "content": "recovered"},
877                "finish_reason": "stop"
878            }],
879            "usage": {}
880        }))
881    }
882
883    fn truncated_response_server(
884        content_type: &str,
885        body: &str,
886    ) -> std::io::Result<(String, JoinHandle<std::io::Result<()>>)> {
887        response_sequence_server(vec![format!(
888            "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\n\
889             Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
890            body.len() + 100
891        )])
892    }
893
894    fn response_sequence_server(
895        responses: Vec<String>,
896    ) -> std::io::Result<(String, JoinHandle<std::io::Result<()>>)> {
897        let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
898        let address = listener.local_addr()?;
899        let handle = std::thread::spawn(move || {
900            for response in responses {
901                let (mut stream, _) = listener.accept()?;
902                let mut request = [0_u8; 1024];
903                if stream.read(&mut request)? == 0 {
904                    return Err(std::io::Error::new(
905                        std::io::ErrorKind::UnexpectedEof,
906                        "client closed before sending a request",
907                    ));
908                }
909                stream.write_all(response.as_bytes())?;
910            }
911            Ok(())
912        });
913        Ok((format!("http://{address}/v1"), handle))
914    }
915
916    fn raw_chat_success_response() -> String {
917        let body = r#"{"id":"chatcmpl-1","model":"gpt","choices":[{"index":0,"message":{"role":"assistant","content":"recovered"},"finish_reason":"stop"}],"usage":{}}"#;
918        format!(
919            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
920             Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
921            body.len()
922        )
923    }
924
925    fn request_for(model: Option<&str>, stream: bool) -> Request {
926        let mut llm_request = text_request(model.map(str::to_string), "hi");
927        llm_request.stream = stream;
928        Request {
929            llm_request,
930            raw_request: None,
931            metadata: None,
932        }
933    }
934
935    #[test]
936    fn anthropic_prompt_caching_marks_final_message() {
937        let mut body = json!({
938            "messages": [{"role": "user", "content": "hello"}]
939        });
940
941        enable_anthropic_prompt_caching(&mut body);
942
943        assert_eq!(
944            body["messages"][0]["content"][0]["cache_control"],
945            json!({"type": "ephemeral"})
946        );
947    }
948
949    // A request that pins `format` in its metadata, so the client resolves that
950    // wire format instead of the model's default backend.
951    fn request_with_wire_format(model: &str, format: WireFormat) -> Request {
952        let mut request = request_for(Some(model), false);
953        request.metadata = Some(Metadata {
954            session_id: None,
955            agent_id: None,
956            task_id: None,
957            correlation_id: None,
958            extra_metadata: None,
959            http_headers: None,
960            wire_format: Some(format),
961            ..Default::default()
962        });
963        request
964    }
965
966    #[tokio::test]
967    async fn missing_model_errors()
968    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
969        let client = TranslatingLlmClient::new(&[])?;
970        let Err(error) = client
971            .call_rewrite_model(Context::default(), request_for(None, false), None)
972            .await
973        else {
974            panic!("expected an error");
975        };
976        assert!(matches!(
977            error,
978            LlmClientError::InvalidRequest { message } if message == "no model given"
979        ));
980        Ok(())
981    }
982
983    #[tokio::test]
984    async fn unknown_model_errors()
985    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
986        let client = TranslatingLlmClient::new(&[])?;
987        let Err(error) = client
988            .call_rewrite_model(Context::default(), request_for(Some("gpt"), false), None)
989            .await
990        else {
991            panic!("expected an error");
992        };
993        assert!(matches!(
994            error,
995            LlmClientError::Configuration { message }
996                if message.contains("gpt")
997        ));
998        Ok(())
999    }
1000
1001    #[tokio::test]
1002    async fn unknown_model_format_errors()
1003    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1004        // "gpt" exists but only over OpenAI Chat; the request pins Anthropic.
1005        let client = TranslatingLlmClient::new(&chat_map("https://example.test/v1"))?;
1006        let Err(error) = client
1007            .call_rewrite_model(
1008                Context::default(),
1009                request_with_wire_format("gpt", WireFormat::AnthropicMessages),
1010                None,
1011            )
1012            .await
1013        else {
1014            panic!("expected an error");
1015        };
1016        assert!(matches!(
1017            error,
1018            LlmClientError::Configuration { message }
1019                if message.contains("gpt")
1020                    && message.contains(&WireFormat::AnthropicMessages.to_string())
1021        ));
1022        Ok(())
1023    }
1024
1025    #[test]
1026    fn backend_for_resolves_configured_format()
1027    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1028        let client = TranslatingLlmClient::new(&chat_map("https://example.test/v1"))?;
1029        // "gpt" is served over OpenAI Chat only; other formats and models miss.
1030        assert!(client.backend_for("gpt", WireFormat::OpenAiChat).is_some());
1031        assert!(
1032            client
1033                .backend_for("gpt", WireFormat::AnthropicMessages)
1034                .is_none()
1035        );
1036        assert!(
1037            client
1038                .backend_for("missing", WireFormat::OpenAiChat)
1039                .is_none()
1040        );
1041        Ok(())
1042    }
1043
1044    #[tokio::test]
1045    async fn model_name_arg_wins_over_request_model()
1046    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1047        let client = TranslatingLlmClient::new(&[])?;
1048        // Arg "b" is looked up (and reported), not the request's "a".
1049        let Err(error) = client
1050            .call_rewrite_model(Context::default(), request_for(Some("a"), false), Some("b"))
1051            .await
1052        else {
1053            panic!("expected an error");
1054        };
1055        assert!(matches!(
1056            error,
1057            LlmClientError::Configuration { message }
1058                if message.contains("\"b\"")
1059        ));
1060        Ok(())
1061    }
1062
1063    #[tokio::test]
1064    async fn buffered_openai_chat_round_trips()
1065    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1066        let server = MockServer::start().await;
1067        Mock::given(method("POST"))
1068            .and(path("/v1/chat/completions"))
1069            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1070                "id": "chatcmpl-1",
1071                "model": "gpt",
1072                "choices": [{
1073                    "index": 0,
1074                    "message": {"role": "assistant", "content": "Hi there"},
1075                    "finish_reason": "stop"
1076                }],
1077                "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
1078            })))
1079            .mount(&server)
1080            .await;
1081
1082        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
1083
1084        let response = client
1085            .call_rewrite_model(Context::default(), request_for(Some("gpt"), false), None)
1086            .await?;
1087        let agg = response.llm_response.into_agg().await?;
1088        assert_eq!(completion_text(&agg), "Hi there");
1089
1090        Ok(())
1091    }
1092
1093    #[tokio::test]
1094    async fn invalid_json_is_a_response_translation_error()
1095    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1096        let server = MockServer::start().await;
1097        let calls = Arc::new(AtomicUsize::new(0));
1098        let observed_calls = Arc::clone(&calls);
1099        Mock::given(method("POST"))
1100            .respond_with(move |_: &wiremock::Request| {
1101                observed_calls.fetch_add(1, Ordering::SeqCst);
1102                ResponseTemplate::new(200).set_body_raw("not json", "application/json")
1103            })
1104            .mount(&server)
1105            .await;
1106
1107        let client =
1108            TranslatingLlmClient::new(&chat_map_with_retries(&format!("{}/v1", server.uri()), 2))?;
1109        let Err(error) = client
1110            .call_rewrite_model(Context::default(), request_for(Some("gpt"), false), None)
1111            .await
1112        else {
1113            panic!("expected invalid JSON to fail");
1114        };
1115
1116        assert!(matches!(
1117            error,
1118            LlmClientError::ResponseTranslation(message)
1119                if message.contains("invalid upstream JSON")
1120        ));
1121        assert_eq!(calls.load(Ordering::SeqCst), 1);
1122        Ok(())
1123    }
1124
1125    #[tokio::test]
1126    async fn response_body_io_failure_is_a_transport_error()
1127    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1128        let (base_url, server) = truncated_response_server("application/json", "{}")?;
1129        let client = TranslatingLlmClient::new(&chat_map(&base_url))?;
1130        let result = client
1131            .call_rewrite_model(Context::default(), request_for(Some("gpt"), false), None)
1132            .await;
1133        server
1134            .join()
1135            .map_err(|_| std::io::Error::other("response server thread panicked"))??;
1136
1137        let Err(error) = result else {
1138            panic!("expected the truncated response body to fail");
1139        };
1140        let LlmClientError::Transport { source } = error else {
1141            panic!("expected a transport error");
1142        };
1143        let Some(source) = source.downcast_ref::<reqwest::Error>() else {
1144            panic!("expected the reqwest transport source");
1145        };
1146        assert!(source.is_decode());
1147        assert!(
1148            !std::error::Error::source(&source)
1149                .is_some_and(|source| source.is::<serde_json::Error>())
1150        );
1151        Ok(())
1152    }
1153
1154    #[tokio::test]
1155    async fn response_body_transport_failures_are_retried()
1156    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1157        let truncated_responses = [
1158            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
1159             Content-Length: 102\r\nConnection: close\r\n\r\n{}",
1160            "HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\n\
1161             Content-Length: 100\r\nConnection: close\r\n\r\nbad",
1162        ];
1163
1164        for truncated in truncated_responses {
1165            let (base_url, server) =
1166                response_sequence_server(vec![truncated.to_string(), raw_chat_success_response()])?;
1167            let client = TranslatingLlmClient::new(&chat_map_with_retries(&base_url, 1))?;
1168            let response = client
1169                .call_rewrite_model(Context::default(), request_for(Some("gpt"), false), None)
1170                .await?;
1171            server
1172                .join()
1173                .map_err(|_| std::io::Error::other("response server thread panicked"))??;
1174
1175            assert_eq!(
1176                completion_text(&response.llm_response.into_agg().await?),
1177                "recovered"
1178            );
1179        }
1180        Ok(())
1181    }
1182
1183    #[tokio::test]
1184    async fn streaming_body_io_failure_preserves_transport_error()
1185    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1186        let body = "data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n";
1187        let (base_url, server) = truncated_response_server("text/event-stream", body)?;
1188        let client = TranslatingLlmClient::new(&chat_map_with_retries(&base_url, 2))?;
1189        let response = client
1190            .call_rewrite_model(Context::default(), request_for(Some("gpt"), true), None)
1191            .await?;
1192        let result = response.llm_response.into_agg().await;
1193        server
1194            .join()
1195            .map_err(|_| std::io::Error::other("response server thread panicked"))??;
1196
1197        let Err(error) = result else {
1198            panic!("expected the truncated stream body to fail");
1199        };
1200
1201        assert!(matches!(error, LlmClientError::Transport { .. }));
1202        Ok(())
1203    }
1204
1205    #[tokio::test]
1206    async fn rewrites_model_to_resolved_upstream_id()
1207    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1208        // Inbound body says "switchyard"; the upstream must receive "gpt".
1209        let server = MockServer::start().await;
1210        Mock::given(method("POST"))
1211            .and(path("/v1/chat/completions"))
1212            .and(wiremock::matchers::body_partial_json(json!({"model": "gpt"})))
1213            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1214                "id": "1", "model": "gpt",
1215                "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
1216                "usage": {}
1217            })))
1218            .mount(&server)
1219            .await;
1220
1221        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
1222        // Inbound model differs from the map key / resolved model.
1223        client
1224            .call_rewrite_model(
1225                Context::default(),
1226                request_for(Some("switchyard"), false),
1227                Some("gpt"),
1228            )
1229            .await?;
1230        // The body_partial_json matcher asserts the upstream saw model "gpt".
1231        Ok(())
1232    }
1233
1234    #[tokio::test]
1235    async fn extra_body_adds_defaults_without_overriding_the_request()
1236    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1237        let server = MockServer::start().await;
1238        Mock::given(method("POST"))
1239            .and(path("/v1/chat/completions"))
1240            .and(wiremock::matchers::body_partial_json(json!({
1241                "model": "gpt",
1242                "max_tokens": 7,
1243                "service_tier": "priority"
1244            })))
1245            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1246                "id": "1",
1247                "model": "gpt",
1248                "choices": [{
1249                    "index": 0,
1250                    "message": {"role": "assistant", "content": "ok"},
1251                    "finish_reason": "stop"
1252                }],
1253                "usage": {}
1254            })))
1255            .mount(&server)
1256            .await;
1257
1258        let extra_body = BTreeMap::from([
1259            ("max_tokens".to_string(), json!(999)),
1260            ("service_tier".to_string(), json!("priority")),
1261        ]);
1262        let client = TranslatingLlmClient::new(&chat_map_with_extra_body(
1263            &format!("{}/v1", server.uri()),
1264            extra_body,
1265        ))?;
1266        let raw = json!({
1267            "model": "client-facing",
1268            "messages": [{"role": "user", "content": "hi"}],
1269            "max_tokens": 7
1270        });
1271
1272        client
1273            .call_rewrite_model_raw(
1274                Context::default(),
1275                raw,
1276                None,
1277                Some("gpt"),
1278                WireFormat::OpenAiChat,
1279            )
1280            .await?;
1281        Ok(())
1282    }
1283
1284    // A weak OpenAI-format tier emits thinking blocks with no signature. Replaying
1285    // them to Anthropic is rejected (Bedrock reports it as a SigV4 mismatch), so
1286    // the Anthropic leg must drop them while keeping signed ones.
1287    #[tokio::test]
1288    async fn anthropic_requests_drop_unsigned_thinking_blocks()
1289    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1290        let server = MockServer::start().await;
1291        Mock::given(method("POST"))
1292            .and(path("/v1/messages"))
1293            .and(|request: &wiremock::Request| {
1294                let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null);
1295                let messages = body.get("messages").and_then(Value::as_array).cloned();
1296                let Some(messages) = messages else {
1297                    return false;
1298                };
1299                // The unsigned block is gone, the signed one survives, and the
1300                // message whose only block was unsigned is not left with an empty
1301                // content array.
1302                let blocks: Vec<&Value> = messages
1303                    .iter()
1304                    .filter_map(|message| message.get("content"))
1305                    .filter_map(Value::as_array)
1306                    .flatten()
1307                    .collect();
1308                let thinking: Vec<&&Value> = blocks
1309                    .iter()
1310                    .filter(|block| block.get("type").and_then(Value::as_str) == Some("thinking"))
1311                    .collect();
1312                thinking.len() == 1
1313                    && thinking[0].get("signature").and_then(Value::as_str) == Some("sig-abc")
1314                    && messages
1315                        .iter()
1316                        .all(|message| message.get("content") != Some(&json!([])))
1317            })
1318            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1319                "id": "msg_1",
1320                "type": "message",
1321                "role": "assistant",
1322                "model": "claude",
1323                "content": [{"type": "text", "text": "ok"}],
1324                "stop_reason": "end_turn",
1325                "usage": {"input_tokens": 1, "output_tokens": 1}
1326            })))
1327            .mount(&server)
1328            .await;
1329
1330        let client = TranslatingLlmClient::new(&anthropic_map(&server.uri()))?;
1331        let raw = json!({
1332            "model": "client-facing",
1333            "max_tokens": 7,
1334            "messages": [
1335                {"role": "user", "content": "fix the build"},
1336                {"role": "assistant", "content": [
1337                    {"type": "thinking", "thinking": "weak tier reasoning", "signature": ""}
1338                ]},
1339                {"role": "assistant", "content": [
1340                    {"type": "thinking", "thinking": "signed reasoning", "signature": "sig-abc"},
1341                    {"type": "text", "text": "here goes"}
1342                ]},
1343                {"role": "user", "content": "continue"}
1344            ]
1345        });
1346
1347        client
1348            .call_rewrite_model_raw(
1349                Context::default(),
1350                raw,
1351                None,
1352                Some("claude"),
1353                WireFormat::AnthropicMessages,
1354            )
1355            .await?;
1356        Ok(())
1357    }
1358
1359    // A router can serve earlier turns from an OpenAI target and later turns from
1360    // an Anthropic one, so the Anthropic leg must drop OpenAI-only fields the
1361    // caller keeps sending or the upstream rejects the whole request.
1362    #[tokio::test]
1363    async fn anthropic_requests_drop_openai_only_fields()
1364    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1365        let server = MockServer::start().await;
1366        Mock::given(method("POST"))
1367            .and(path("/v1/messages"))
1368            .and(|request: &wiremock::Request| {
1369                let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null);
1370                body.get("context_management").is_none() && body.get("reasoning_effort").is_none()
1371            })
1372            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1373                "id": "msg_1",
1374                "type": "message",
1375                "role": "assistant",
1376                "model": "claude",
1377                "content": [{"type": "text", "text": "ok"}],
1378                "stop_reason": "end_turn",
1379                "usage": {"input_tokens": 1, "output_tokens": 1}
1380            })))
1381            .mount(&server)
1382            .await;
1383
1384        let client = TranslatingLlmClient::new(&anthropic_map(&server.uri()))?;
1385        let raw = json!({
1386            "model": "client-facing",
1387            "messages": [{"role": "user", "content": "hi"}],
1388            "max_tokens": 7,
1389            "reasoning_effort": "high",
1390            "context_management": {
1391                "edits": [{"type": "clear_thinking_20251015"}]
1392            }
1393        });
1394
1395        client
1396            .call_rewrite_model_raw(
1397                Context::default(),
1398                raw,
1399                None,
1400                Some("claude"),
1401                WireFormat::AnthropicMessages,
1402            )
1403            .await?;
1404        Ok(())
1405    }
1406
1407    #[tokio::test]
1408    async fn streaming_openai_chat_aggregates()
1409    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1410        let server = MockServer::start().await;
1411        let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n\
1412             data: {\"choices\":[{\"delta\":{\"content\":\" world\"}}]}\n\n\
1413             data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n\
1414             data: [DONE]\n\n";
1415        Mock::given(method("POST"))
1416            .and(path("/v1/chat/completions"))
1417            .and(wiremock::matchers::body_partial_json(json!({
1418                "stream": true,
1419                "stream_options": {"include_usage": true}
1420            })))
1421            .respond_with(ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream"))
1422            .mount(&server)
1423            .await;
1424
1425        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
1426
1427        let response = client
1428            .call_rewrite_model(Context::default(), request_for(Some("gpt"), true), None)
1429            .await?;
1430        assert!(matches!(response.llm_response, LlmResponse::Stream(_)));
1431        let agg = response.llm_response.into_agg().await?;
1432        assert_eq!(completion_text(&agg), "Hello world");
1433        assert_eq!(agg.usage.input_tokens, Some(1));
1434        assert_eq!(agg.usage.output_tokens, Some(2));
1435        assert_eq!(agg.usage.total_tokens, Some(3));
1436        Ok(())
1437    }
1438
1439    #[tokio::test]
1440    async fn streaming_openai_chat_preserves_usage_opt_out()
1441    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1442        let server = MockServer::start().await;
1443        Mock::given(method("POST"))
1444            .and(path("/v1/chat/completions"))
1445            .and(wiremock::matchers::body_partial_json(json!({
1446                "stream": true,
1447                "stream_options": {"include_usage": false}
1448            })))
1449            .respond_with(
1450                ResponseTemplate::new(200).set_body_raw("data: [DONE]\n\n", "text/event-stream"),
1451            )
1452            .mount(&server)
1453            .await;
1454
1455        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
1456        let raw = json!({
1457            "model": "client-facing",
1458            "messages": [{"role": "user", "content": "hi"}],
1459            "stream": true,
1460            "stream_options": {"include_usage": false}
1461        });
1462
1463        let response = client
1464            .call_rewrite_model_raw(
1465                Context::default(),
1466                raw,
1467                None,
1468                Some("gpt"),
1469                WireFormat::OpenAiChat,
1470            )
1471            .await?;
1472        assert!(matches!(response, RawResponse::Stream(_)));
1473        Ok(())
1474    }
1475
1476    #[tokio::test]
1477    async fn upstream_500_is_upstream_http()
1478    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1479        let server = MockServer::start().await;
1480        Mock::given(method("POST"))
1481            .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
1482            .mount(&server)
1483            .await;
1484
1485        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
1486
1487        let Err(error) = client
1488            .call_rewrite_model(Context::default(), request_for(Some("gpt"), false), None)
1489            .await
1490        else {
1491            panic!("expected an error");
1492        };
1493        assert!(matches!(
1494            error,
1495            LlmClientError::UpstreamHttp { status: 500, .. }
1496        ));
1497        Ok(())
1498    }
1499
1500    #[tokio::test]
1501    async fn retryable_http_failure_recovers_within_budget()
1502    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1503        let server = MockServer::start().await;
1504        let calls = Arc::new(AtomicUsize::new(0));
1505        let observed_calls = Arc::clone(&calls);
1506        Mock::given(method("POST"))
1507            .respond_with(move |_: &wiremock::Request| {
1508                if observed_calls.fetch_add(1, Ordering::SeqCst) == 0 {
1509                    ResponseTemplate::new(503)
1510                        .insert_header("retry-after", "0")
1511                        .set_body_string("temporarily unavailable")
1512                } else {
1513                    chat_success_response()
1514                }
1515            })
1516            .mount(&server)
1517            .await;
1518
1519        let client =
1520            TranslatingLlmClient::new(&chat_map_with_retries(&format!("{}/v1", server.uri()), 1))?;
1521        let response = client
1522            .call_rewrite_model(Context::default(), request_for(Some("gpt"), false), None)
1523            .await?;
1524        let agg = response.llm_response.into_agg().await?;
1525
1526        assert_eq!(completion_text(&agg), "recovered");
1527        assert_eq!(calls.load(Ordering::SeqCst), 2);
1528        Ok(())
1529    }
1530
1531    #[tokio::test]
1532    async fn deterministic_http_failure_is_not_retried()
1533    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1534        let server = MockServer::start().await;
1535        let calls = Arc::new(AtomicUsize::new(0));
1536        let observed_calls = Arc::clone(&calls);
1537        Mock::given(method("POST"))
1538            .respond_with(move |_: &wiremock::Request| {
1539                observed_calls.fetch_add(1, Ordering::SeqCst);
1540                ResponseTemplate::new(401).set_body_string("invalid key")
1541            })
1542            .mount(&server)
1543            .await;
1544
1545        let client =
1546            TranslatingLlmClient::new(&chat_map_with_retries(&format!("{}/v1", server.uri()), 2))?;
1547        let Err(error) = client
1548            .call_rewrite_model(Context::default(), request_for(Some("gpt"), false), None)
1549            .await
1550        else {
1551            panic!("expected an upstream error");
1552        };
1553
1554        assert!(matches!(
1555            error,
1556            LlmClientError::UpstreamHttp {
1557                status: 401,
1558                body
1559            } if body == "invalid key"
1560        ));
1561        assert_eq!(calls.load(Ordering::SeqCst), 1);
1562        Ok(())
1563    }
1564
1565    #[tokio::test]
1566    async fn retry_exhaustion_returns_the_final_upstream_error()
1567    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1568        let server = MockServer::start().await;
1569        let calls = Arc::new(AtomicUsize::new(0));
1570        let observed_calls = Arc::clone(&calls);
1571        Mock::given(method("POST"))
1572            .respond_with(move |_: &wiremock::Request| {
1573                let attempt = observed_calls.fetch_add(1, Ordering::SeqCst) + 1;
1574                ResponseTemplate::new(500)
1575                    .insert_header("retry-after", "0")
1576                    .set_body_string(format!("attempt {attempt}"))
1577            })
1578            .mount(&server)
1579            .await;
1580
1581        let client =
1582            TranslatingLlmClient::new(&chat_map_with_retries(&format!("{}/v1", server.uri()), 2))?;
1583        let Err(error) = client
1584            .call_rewrite_model(Context::default(), request_for(Some("gpt"), false), None)
1585            .await
1586        else {
1587            panic!("expected retry exhaustion");
1588        };
1589
1590        assert!(matches!(
1591            error,
1592            LlmClientError::UpstreamHttp {
1593                status: 500,
1594                body
1595            } if body == "attempt 3"
1596        ));
1597        assert_eq!(calls.load(Ordering::SeqCst), 3);
1598        Ok(())
1599    }
1600
1601    #[tokio::test]
1602    async fn timeout_is_retried_before_a_response_is_returned()
1603    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1604        let server = MockServer::start().await;
1605        let calls = Arc::new(AtomicUsize::new(0));
1606        let observed_calls = Arc::clone(&calls);
1607        Mock::given(method("POST"))
1608            .respond_with(move |_: &wiremock::Request| {
1609                if observed_calls.fetch_add(1, Ordering::SeqCst) == 0 {
1610                    ResponseTemplate::new(200).set_delay(Duration::from_millis(500))
1611                } else {
1612                    chat_success_response()
1613                }
1614            })
1615            .mount(&server)
1616            .await;
1617
1618        let mut client =
1619            TranslatingLlmClient::new(&chat_map_with_retries(&format!("{}/v1", server.uri()), 1))?;
1620        client.client = reqwest::Client::builder()
1621            .timeout(Duration::from_millis(100))
1622            .build()?;
1623        let response = client
1624            .call_rewrite_model(Context::default(), request_for(Some("gpt"), false), None)
1625            .await?;
1626
1627        assert_eq!(
1628            completion_text(&response.llm_response.into_agg().await?),
1629            "recovered"
1630        );
1631        assert_eq!(calls.load(Ordering::SeqCst), 2);
1632        Ok(())
1633    }
1634
1635    #[test]
1636    fn retryable_error_classes_are_explicit() {
1637        let transport = AttemptFailure {
1638            error: LlmClientError::Transport {
1639                source: std::io::Error::other("disconnected").into(),
1640            },
1641            status: None,
1642            retry_after: None,
1643        };
1644        assert!(transport.is_retryable());
1645
1646        for status in [408, 429, 500, 503, 599] {
1647            let failure = AttemptFailure {
1648                error: LlmClientError::UpstreamHttp {
1649                    status,
1650                    body: String::new(),
1651                },
1652                status: Some(status),
1653                retry_after: None,
1654            };
1655            assert!(failure.is_retryable(), "HTTP {status} should retry");
1656        }
1657        for status in [400, 401, 409, 600] {
1658            let failure = AttemptFailure {
1659                error: LlmClientError::UpstreamHttp {
1660                    status,
1661                    body: String::new(),
1662                },
1663                status: Some(status),
1664                retry_after: None,
1665            };
1666            assert!(!failure.is_retryable(), "HTTP {status} should fail fast");
1667        }
1668
1669        let configuration = AttemptFailure {
1670            error: LlmClientError::Configuration {
1671                message: "invalid header".to_string(),
1672            },
1673            status: None,
1674            retry_after: None,
1675        };
1676        assert!(!configuration.is_retryable());
1677
1678        let context_window = AttemptFailure {
1679            error: LlmClientError::ContextWindowExceeded {
1680                model: "gpt".to_string(),
1681                message: "too long".to_string(),
1682            },
1683            status: Some(400),
1684            retry_after: None,
1685        };
1686        assert!(!context_window.is_retryable());
1687    }
1688
1689    #[test]
1690    fn retry_after_supports_seconds_and_http_dates() {
1691        let mut headers = HeaderMap::new();
1692        headers.insert(RETRY_AFTER, reqwest::header::HeaderValue::from_static("3"));
1693        assert_eq!(retry_after_delay(&headers), Some(Duration::from_secs(3)));
1694
1695        let retry_at = SystemTime::now() + Duration::from_secs(2);
1696        let value = httpdate::fmt_http_date(retry_at);
1697        let Ok(value) = reqwest::header::HeaderValue::from_str(&value) else {
1698            panic!("formatted HTTP date should be a valid header");
1699        };
1700        headers.insert(RETRY_AFTER, value);
1701        let Some(delay) = retry_after_delay(&headers) else {
1702            panic!("HTTP date should produce a retry delay");
1703        };
1704        assert!(delay <= Duration::from_secs(2));
1705    }
1706
1707    #[tokio::test]
1708    async fn routed_llm_client_exposes_timeout_variant()
1709    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1710        let server = MockServer::start().await;
1711        Mock::given(method("POST"))
1712            .respond_with(
1713                ResponseTemplate::new(200).set_delay(std::time::Duration::from_millis(100)),
1714            )
1715            .mount(&server)
1716            .await;
1717
1718        let mut client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
1719        client.client = reqwest::Client::builder()
1720            .timeout(std::time::Duration::from_millis(10))
1721            .build()?;
1722        let decision: std::sync::Arc<dyn Decision> = std::sync::Arc::new(FixedDecision("gpt"));
1723
1724        let Err(error) = client
1725            .call(Context::default(), request_for(None, false), decision)
1726            .await
1727        else {
1728            panic!("expected a timeout");
1729        };
1730        let LlmClientError::Timeout { source } = error else {
1731            panic!("expected the protocol timeout variant");
1732        };
1733        let Some(source) = source.downcast_ref::<reqwest::Error>() else {
1734            panic!("expected the reqwest timeout source");
1735        };
1736        assert!(source.is_timeout());
1737        Ok(())
1738    }
1739
1740    #[tokio::test]
1741    async fn context_overflow_400_is_mapped()
1742    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1743        let server = MockServer::start().await;
1744        Mock::given(method("POST"))
1745            .respond_with(ResponseTemplate::new(400).set_body_json(json!({
1746                "error": {"code": "context_length_exceeded", "message": "too big"}
1747            })))
1748            .mount(&server)
1749            .await;
1750
1751        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
1752
1753        let Err(error) = client
1754            .call_rewrite_model(Context::default(), request_for(Some("gpt"), false), None)
1755            .await
1756        else {
1757            panic!("expected an error");
1758        };
1759        assert!(matches!(
1760            error,
1761            LlmClientError::ContextWindowExceeded { model, .. } if model == "gpt"
1762        ));
1763        Ok(())
1764    }
1765
1766    #[tokio::test]
1767    async fn forwards_metadata_headers_except_reserved()
1768    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1769        let server = MockServer::start().await;
1770        Mock::given(method("POST"))
1771            .and(wiremock::matchers::header("x-request-id", "abc"))
1772            // A forwarded Authorization must NOT override the backend's bearer key.
1773            .and(wiremock::matchers::header("authorization", "Bearer secret"))
1774            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1775                "id": "1", "model": "gpt",
1776                "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
1777                "usage": {}
1778            })))
1779            .mount(&server)
1780            .await;
1781
1782        let mut headers = http::HeaderMap::new();
1783        headers.insert("x-request-id", http::HeaderValue::from_static("abc"));
1784        headers.insert(
1785            "authorization",
1786            http::HeaderValue::from_static("Bearer client-key"),
1787        );
1788        headers.insert(
1789            "accept-encoding",
1790            http::HeaderValue::from_static("gzip, br"),
1791        );
1792        let request = Request {
1793            llm_request: LlmRequest {
1794                model: Some("gpt".to_string()),
1795                ..LlmRequest::default()
1796            },
1797            raw_request: None,
1798            metadata: Some(Metadata {
1799                session_id: None,
1800                agent_id: None,
1801                task_id: None,
1802                correlation_id: None,
1803                extra_metadata: None,
1804                http_headers: Some(headers),
1805                wire_format: None,
1806                ..Default::default()
1807            }),
1808        };
1809
1810        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
1811
1812        // Matchers assert forwarded x-request-id survives and reserved
1813        // authorization is the backend's, not the client's.
1814        client
1815            .call_rewrite_model(Context::default(), request, None)
1816            .await?;
1817        let received = server
1818            .received_requests()
1819            .await
1820            .ok_or("request recording should be enabled")?;
1821        let received = received.first().ok_or("expected one upstream request")?;
1822        assert!(!received.headers.contains_key("accept-encoding"));
1823        Ok(())
1824    }
1825
1826    // Minimal `Decision` for driving the client through the `RoutedLlmClient` trait.
1827    struct FixedDecision(&'static str);
1828
1829    impl Decision for FixedDecision {
1830        fn selected_model(&self) -> &str {
1831            self.0
1832        }
1833        fn reasoning(&self) -> Option<&str> {
1834            None
1835        }
1836        fn as_any(&self) -> &dyn std::any::Any {
1837            self
1838        }
1839    }
1840
1841    // Exercises the `RoutedLlmClient` impl: `call` resolves the upstream model from the
1842    // decision (the request carries none) and round-trips a buffered response.
1843    #[tokio::test]
1844    async fn routed_llm_client_serves_the_decision_model()
1845    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1846        let server = MockServer::start().await;
1847        Mock::given(method("POST"))
1848            .and(path("/v1/chat/completions"))
1849            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1850                "id": "chatcmpl-1",
1851                "model": "gpt",
1852                "choices": [{
1853                    "index": 0,
1854                    "message": {"role": "assistant", "content": "routed hi"},
1855                    "finish_reason": "stop"
1856                }],
1857                "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
1858            })))
1859            .mount(&server)
1860            .await;
1861
1862        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
1863        let decision: std::sync::Arc<dyn Decision> = std::sync::Arc::new(FixedDecision("gpt"));
1864        // Called through the trait; the request has no model, so "gpt" comes from the decision.
1865        let response = client
1866            .call(Context::default(), request_for(None, false), decision)
1867            .await?;
1868        let agg = response.llm_response.into_agg().await?;
1869        assert_eq!(completion_text(&agg), "routed hi");
1870        Ok(())
1871    }
1872
1873    #[tokio::test]
1874    async fn invalid_raw_request_is_a_request_translation_error()
1875    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1876        let client = TranslatingLlmClient::new(&[])?;
1877        let Err(error) = client
1878            .call_rewrite_model_raw(
1879                Context::default(),
1880                json!("invalid"),
1881                None,
1882                Some("gpt"),
1883                WireFormat::OpenAiChat,
1884            )
1885            .await
1886        else {
1887            panic!("expected request translation to fail");
1888        };
1889
1890        assert!(matches!(
1891            error,
1892            LlmClientError::RequestTranslation(message) if !message.is_empty()
1893        ));
1894        Ok(())
1895    }
1896
1897    // Raw path, buffered: decode an OpenAI Chat body -> call -> encode back to OpenAI
1898    // Chat JSON, with the served `model` restamped over the id the caller addressed.
1899    #[tokio::test]
1900    async fn call_rewrite_model_raw_round_trips_buffered_json()
1901    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1902        let server = MockServer::start().await;
1903        Mock::given(method("POST"))
1904            .and(path("/v1/chat/completions"))
1905            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1906                "id": "chatcmpl-1",
1907                "model": "gpt",
1908                "choices": [{
1909                    "index": 0,
1910                    "message": {"role": "assistant", "content": "Hi there"},
1911                    "finish_reason": "stop"
1912                }],
1913                "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
1914            })))
1915            .mount(&server)
1916            .await;
1917
1918        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
1919        let raw = json!({
1920            "model": "client-facing",
1921            "messages": [{"role": "user", "content": "hi"}]
1922        });
1923        let RawResponse::Buffered(body) = client
1924            .call_rewrite_model_raw(
1925                Context::default(),
1926                raw,
1927                None,
1928                Some("gpt"),
1929                WireFormat::OpenAiChat,
1930            )
1931            .await?
1932        else {
1933            panic!("expected a buffered response");
1934        };
1935
1936        assert_eq!(body["choices"][0]["message"]["content"], "Hi there");
1937        // The client sees the model that answered, not the "client-facing" route id.
1938        assert_eq!(body["model"], "gpt");
1939        Ok(())
1940    }
1941
1942    // Raw path, streaming: an inbound `stream: true` request yields an unframed stream
1943    // of OpenAI Chat chunk objects whose deltas reassemble the completion.
1944    #[tokio::test]
1945    async fn call_rewrite_model_raw_streams_wire_events()
1946    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1947        use futures::TryStreamExt;
1948
1949        let server = MockServer::start().await;
1950        let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n\
1951             data: {\"choices\":[{\"delta\":{\"content\":\" world\"}}]}\n\n\
1952             data: [DONE]\n\n";
1953        Mock::given(method("POST"))
1954            .and(path("/v1/chat/completions"))
1955            .respond_with(ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream"))
1956            .mount(&server)
1957            .await;
1958
1959        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
1960        let raw = json!({
1961            "model": "client-facing",
1962            "messages": [{"role": "user", "content": "hi"}],
1963            "stream": true
1964        });
1965        let RawResponse::Stream(stream) = client
1966            .call_rewrite_model_raw(
1967                Context::default(),
1968                raw,
1969                None,
1970                Some("gpt"),
1971                WireFormat::OpenAiChat,
1972            )
1973            .await?
1974        else {
1975            panic!("expected a streamed response");
1976        };
1977
1978        let events: Vec<Value> = stream.try_collect().await?;
1979        assert!(!events.is_empty(), "expected at least one wire event");
1980        let content: String = events
1981            .iter()
1982            .filter_map(|event| event["choices"][0]["delta"]["content"].as_str())
1983            .collect();
1984        assert_eq!(content, "Hello world");
1985        // The mock frames carry no `model`, so every chunk's model comes from the
1986        // served id rather than the "unknown" fallback or the caller's route id.
1987        assert!(events.iter().all(|event| event["model"] == "gpt"));
1988        Ok(())
1989    }
1990
1991    // Raw path forwards caller headers (minus the reserved set) to the upstream.
1992    #[tokio::test]
1993    async fn call_rewrite_model_raw_forwards_headers()
1994    -> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1995        let server = MockServer::start().await;
1996        Mock::given(method("POST"))
1997            .and(wiremock::matchers::header("x-request-id", "abc"))
1998            // A forwarded authorization must NOT override the backend's bearer key.
1999            .and(wiremock::matchers::header("authorization", "Bearer secret"))
2000            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2001                "id": "1", "model": "gpt",
2002                "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
2003                "usage": {}
2004            })))
2005            .mount(&server)
2006            .await;
2007
2008        let mut headers = http::HeaderMap::new();
2009        headers.insert("x-request-id", http::HeaderValue::from_static("abc"));
2010        headers.insert(
2011            "authorization",
2012            http::HeaderValue::from_static("Bearer client-key"),
2013        );
2014
2015        let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
2016        let raw = json!({"model": "gpt", "messages": [{"role": "user", "content": "hi"}]});
2017        // Matchers assert the forwarded x-request-id survives and reserved
2018        // authorization is the backend's, not the client's.
2019        client
2020            .call_rewrite_model_raw(
2021                Context::default(),
2022                raw,
2023                Some(headers),
2024                Some("gpt"),
2025                WireFormat::OpenAiChat,
2026            )
2027            .await?;
2028        Ok(())
2029    }
2030}