1use 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
35const ALLOWED_METADATA_HEADERS: &[&str] = &["x-request-id"];
37
38const 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#[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 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 #[must_use]
94 pub fn with_responses_compatibility(mut self, policy: crate::ResponsesCompatibility) -> Self {
95 self.responses_compatibility = policy;
96 self
97 }
98
99 #[must_use]
101 pub fn with_responses_reasoning(mut self, policy: crate::ResponsesReasoningPolicy) -> Self {
102 self.responses_reasoning = policy;
103 self
104 }
105
106 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
117pub enum AuxiliaryOperation {
118 AnthropicCountTokens,
120 ResponsesInputTokens,
122 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
148pub 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 unavailable_until: HashMap<ModelId, AtomicU64>,
164}
165
166impl TranslatingLlmClient {
167 pub fn new(model_configs: &[ModelConfig]) -> Result<Self> {
170 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 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 pub fn without_rate_limit_cooldown(mut self) -> Self {
207 self.is_rate_limit_cooldown_enabled = false;
208 self
209 }
210
211 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 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 pub fn supports_auxiliary(&self, model: &ModelId, operation: AuxiliaryOperation) -> bool {
238 self.backend_for(model, operation.wire_format()).is_some()
239 }
240
241 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 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 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 let upstream_model = self.provider_model(model);
373 set_json_model(&mut body, upstream_model);
374 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 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 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 result
443 }
444
445 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 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 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 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 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 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 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 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 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
919struct 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 Some(Err(error @ (LlmClientError::Transport { .. } | LlmClientError::Timeout { .. }))) => {
990 Err(error)
991 }
992 Some(first) => {
993 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
1005fn 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
1024fn 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.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 let error = error.without_url();
1072 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
1094fn 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
1119fn 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
1127fn 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
1134fn 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
1188fn 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 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
1212fn 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
1232fn 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
1251fn 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
1272fn 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
1283fn 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 Backend::OpenAiResponses(_) => set_nested_effort(object, "reasoning", effort),
1296 Backend::Anthropic(_) => set_nested_effort(object, "output_config", effort),
1297 Backend::OpenAiChat(_) => {
1298 object.insert(
1300 "reasoning_effort".to_string(),
1301 Value::String(effort.to_string()),
1302 );
1303 }
1304 }
1305}
1306
1307fn 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
1321fn 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
1340const MAX_CACHE_CONTROL_BLOCKS: usize = 4;
1343
1344fn 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
1357fn enable_anthropic_prompt_caching(body: &mut Value, temporary_note_count: usize) {
1359 if count_cache_control_blocks(body) >= MAX_CACHE_CONTROL_BLOCKS {
1362 return;
1363 }
1364 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 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
1401fn 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 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 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 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
1489fn 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
1530fn 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 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 fn cache_control_blocks(value: &Value) -> usize {
1904 count_cache_control_blocks(value)
1905 }
1906
1907 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 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 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 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 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 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 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 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 client
2274 .call_rewrite_model(
2275 request_for(Some("switchyard"), false),
2276 Some(&ModelId::from("gpt")),
2277 )
2278 .await?;
2279 Ok(())
2281 }
2282
2283 #[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 #[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 #[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 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 {"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 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 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 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 #[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 #[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 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 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 .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 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 #[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 #[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 assert_eq!(body["model"], "gpt");
3857 Ok(())
3858 }
3859
3860 #[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 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 #[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 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 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 assert_eq!(sent[0]["input"], json!([user, call, output, gpt_reasoning]));
4194 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 #[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 assert!(events.iter().all(|event| event["model"] == "gpt"));
4255 Ok(())
4256 }
4257
4258 #[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 .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 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}