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