1use std::time::Duration;
7use std::{
8 collections::{BTreeMap, BTreeSet},
9 fmt,
10};
11
12use reqwest::RequestBuilder;
13use reqwest::header::{HeaderName, HeaderValue};
14use serde_json::Value;
15use switchyard_protocol::{Metadata, WireFormat};
16
17use crate::error::{LlmClientError, Result, is_overflow_body};
18
19const ANTHROPIC_VERSION: &str = "2023-06-01";
20
21pub const DEFAULT_MAX_RETRIES: u32 = 2;
23
24const OPENAI_OVERFLOW_PHRASES: &[&str] = &[
27 "maximum context length",
28 "context length exceeded",
29 "context window",
30 "context length is only",
31 "please reduce the length of the input",
32 "exceeds the maximum allowed input length",
33 "exceeds the maximum allowed length",
34 "exceeds the available context size",
35 "is longer than the model's context length",
36 "exceeds the available context size",
41 "larger than the max context size",
42];
43
44const ANTHROPIC_OVERFLOW_PHRASES: &[&str] = &[
46 "prompt is too long",
47 "maximum number of tokens",
48 "context window",
49 "context length",
50];
51
52#[derive(Clone)]
54pub struct HttpBackendConfig {
55 pub base_url: String,
57 pub api_key: Option<String>,
60 pub forward_auth: bool,
66 pub extra_headers: BTreeMap<String, String>,
73 pub extra_body: BTreeMap<String, Value>,
75 pub omit_body_fields: BTreeSet<String>,
79 pub reasoning_effort: Option<String>,
83 pub max_retries: u32,
86 pub failure_cooldown: Duration,
88 pub timeout: Option<Duration>,
91}
92
93impl fmt::Debug for HttpBackendConfig {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 f.debug_struct("HttpBackendConfig")
96 .field("base_url", &self.base_url)
97 .field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
98 .field("forward_auth", &self.forward_auth)
99 .field("extra_header_names", &self.extra_headers.keys())
100 .field("extra_body_keys", &self.extra_body.keys())
101 .field("omit_body_fields", &self.omit_body_fields)
102 .field("reasoning_effort", &self.reasoning_effort)
103 .field("max_retries", &self.max_retries)
104 .field("failure_cooldown", &self.failure_cooldown)
105 .field("timeout", &self.timeout)
106 .finish()
107 }
108}
109
110#[derive(Clone, Debug)]
115pub enum Backend {
116 OpenAiChat(HttpBackendConfig),
118 OpenAiResponses(HttpBackendConfig),
120 Anthropic(HttpBackendConfig),
122}
123
124impl Backend {
125 pub(crate) fn validate_configured_headers(&self, model_name: &str) -> Result<()> {
127 for (name, value) in &self.config().extra_headers {
128 if HeaderName::from_bytes(name.as_bytes()).is_err() {
129 return Err(LlmClientError::Configuration {
130 message: format!(
131 "model {model_name:?} extra_headers contains invalid HTTP header name {name:?}"
132 ),
133 });
134 }
135 if HeaderValue::from_bytes(value.as_bytes()).is_err() {
136 return Err(LlmClientError::Configuration {
137 message: format!(
138 "model {model_name:?} has invalid HTTP header value for extra_headers entry {name:?}"
139 ),
140 });
141 }
142 }
143
144 let invalid_name = self.config().extra_headers.keys().find(|name| match self {
145 Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => {
146 name.eq_ignore_ascii_case("authorization")
147 || (self.is_forwarding_auth()
148 && (name.eq_ignore_ascii_case("chatgpt-account-id")
149 || name.eq_ignore_ascii_case("x-openai-fedramp")))
150 }
151 Backend::Anthropic(_) => {
152 name.eq_ignore_ascii_case("x-api-key")
153 || name.eq_ignore_ascii_case("anthropic-version")
154 || (self.is_forwarding_auth()
155 && (name.eq_ignore_ascii_case("authorization")
156 || name.eq_ignore_ascii_case("anthropic-beta")))
157 }
158 });
159 if let Some(name) = invalid_name {
160 return Err(LlmClientError::Configuration {
161 message: format!(
162 "model {model_name:?} extra_headers cannot set {name:?}; extra_headers is only for additional headers"
163 ),
164 });
165 }
166
167 let Some(api_key) = self.configured_api_key() else {
168 return Ok(());
169 };
170 let valid_api_key = match self {
171 Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => {
172 HeaderValue::try_from(format!("Bearer {api_key}")).is_ok()
173 }
174 Backend::Anthropic(_) => HeaderValue::from_str(api_key).is_ok(),
175 };
176 if !valid_api_key {
177 return Err(LlmClientError::Configuration {
178 message: format!(
179 "model {model_name:?} api_key cannot be encoded as an HTTP header"
180 ),
181 });
182 }
183 Ok(())
184 }
185
186 pub fn wire_format(&self) -> WireFormat {
188 match self {
189 Backend::OpenAiChat(_) => WireFormat::OpenAiChat,
190 Backend::OpenAiResponses(_) => WireFormat::OpenAiResponses,
191 Backend::Anthropic(_) => WireFormat::AnthropicMessages,
192 }
193 }
194
195 fn config(&self) -> &HttpBackendConfig {
197 match self {
198 Backend::OpenAiChat(config)
199 | Backend::OpenAiResponses(config)
200 | Backend::Anthropic(config) => config,
201 }
202 }
203
204 fn configured_api_key(&self) -> Option<&str> {
206 if self.is_forwarding_auth() {
207 None
208 } else {
209 self.config().api_key.as_deref()
210 }
211 }
212
213 pub(crate) fn base_url(&self) -> &str {
214 &self.config().base_url
215 }
216
217 pub fn url(&self) -> String {
222 let base_url = &self.config().base_url;
223 let result = match self {
224 Backend::OpenAiChat(_) => openai_url(base_url, "/chat/completions"),
225 Backend::OpenAiResponses(_) => openai_url(base_url, "/responses"),
226 Backend::Anthropic(_) => anthropic_url(base_url, ""),
227 };
228 result.unwrap_or_else(|error| {
229 tracing::error!(%error, "Unable to build provider endpoint URL");
230 base_url.clone()
231 })
232 }
233
234 pub fn apply_auth(&self, mut builder: RequestBuilder) -> RequestBuilder {
240 let api_key = self.configured_api_key();
241 match self {
242 Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => {
243 if let Some(api_key) = api_key {
244 builder = builder.bearer_auth(api_key);
245 }
246 }
247 Backend::Anthropic(_) => {
248 builder = builder.header("anthropic-version", ANTHROPIC_VERSION);
249 if let Some(api_key) = api_key {
250 builder = builder.header("x-api-key", api_key);
251 }
252 }
253 }
254 builder
255 }
256
257 pub(crate) fn is_forwarding_auth(&self) -> bool {
258 self.config().forward_auth
259 }
260
261 pub(crate) fn is_provider_owned_header(&self, name: &str) -> bool {
262 match self {
263 Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => {
264 ["authorization", "chatgpt-account-id", "x-openai-fedramp"]
265 .iter()
266 .any(|owned| name.eq_ignore_ascii_case(owned))
267 }
268 Backend::Anthropic(_) => [
269 "authorization",
270 "x-api-key",
271 "anthropic-beta",
272 "anthropic-version",
273 ]
274 .iter()
275 .any(|owned| name.eq_ignore_ascii_case(owned)),
276 }
277 }
278
279 pub(crate) fn apply_forwarded_auth(
281 &self,
282 mut builder: RequestBuilder,
283 metadata: Option<&Metadata>,
284 ) -> RequestBuilder {
285 if !self.is_forwarding_auth() {
286 return builder;
287 }
288 let Some(headers) = metadata.and_then(|metadata| metadata.http_headers.as_ref()) else {
289 return builder;
290 };
291 match self {
292 Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => {
293 for name in ["authorization", "chatgpt-account-id", "x-openai-fedramp"] {
294 if let Some(value) = headers.get(name) {
295 builder = builder.header(name, sensitive_header(value));
296 }
297 }
298 }
299 Backend::Anthropic(_) => {
300 for name in ["authorization", "x-api-key"] {
301 if let Some(value) = headers.get(name) {
302 builder = builder.header(name, sensitive_header(value));
303 }
304 }
305 if let Some(value) = headers.get("anthropic-beta")
306 && let Some(value) = oauth_beta_header(value)
307 {
308 builder = builder.header("anthropic-beta", value);
309 }
310 }
311 }
312 builder
313 }
314
315 pub fn extra_headers(&self) -> &BTreeMap<String, String> {
317 &self.config().extra_headers
318 }
319
320 pub fn extra_body(&self) -> &BTreeMap<String, Value> {
322 &self.config().extra_body
323 }
324
325 pub fn omit_body_fields(&self) -> &BTreeSet<String> {
327 &self.config().omit_body_fields
328 }
329
330 pub fn reasoning_effort(&self) -> Option<&str> {
332 self.config().reasoning_effort.as_deref()
333 }
334
335 pub fn max_retries(&self) -> u32 {
337 self.config().max_retries
338 }
339
340 pub fn failure_cooldown(&self) -> Duration {
342 self.config().failure_cooldown
343 }
344
345 pub fn timeout(&self) -> Option<Duration> {
347 self.config().timeout
348 }
349
350 pub fn is_anthropic(&self) -> bool {
353 matches!(self, Backend::Anthropic(_))
354 }
355
356 pub fn count_tokens_url(&self) -> String {
359 let base_url = &self.config().base_url;
360 anthropic_url(base_url, "/count_tokens").unwrap_or_else(|error| {
361 tracing::error!(%error, "Unable to build Anthropic token-counting URL");
362 base_url.clone()
363 })
364 }
365
366 pub(crate) fn is_context_overflow(&self, body: &str) -> bool {
369 match self {
370 Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => is_overflow_body(
371 body,
372 |value| {
373 let error = value.get("error");
374 let field = |name| {
375 error
376 .and_then(|err| err.get(name))
377 .and_then(serde_json::Value::as_str)
378 };
379 field("code") == Some("context_length_exceeded")
385 || field("type") == Some("exceed_context_size_error")
386 },
387 OPENAI_OVERFLOW_PHRASES,
388 ),
389 Backend::Anthropic(_) => is_overflow_body(body, |_| false, ANTHROPIC_OVERFLOW_PHRASES),
390 }
391 }
392}
393
394fn sensitive_header(value: &HeaderValue) -> HeaderValue {
396 let mut value = value.clone();
397 value.set_sensitive(true);
398 value
399}
400
401fn oauth_beta_header(value: &HeaderValue) -> Option<HeaderValue> {
402 let oauth_betas = value
403 .to_str()
404 .ok()?
405 .split(',')
406 .map(str::trim)
407 .filter(|beta| {
408 beta.get(..6)
409 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("oauth-"))
410 });
411 let value = oauth_betas.collect::<Vec<_>>().join(",");
412 if value.is_empty() {
413 return None;
414 }
415 let mut value = HeaderValue::from_str(&value).ok()?;
416 value.set_sensitive(true);
417 Some(value)
418}
419
420pub(crate) fn openai_url(base_url: &str, suffix: &str) -> Result<String> {
422 let mut url = reqwest::Url::parse(base_url).map_err(|error| LlmClientError::Configuration {
423 message: format!("Invalid OpenAI base URL: {error}"),
424 })?;
425 let base_path = url.path().trim_end_matches('/');
426 let base_root = base_path
427 .strip_suffix("/chat/completions")
428 .or_else(|| base_path.strip_suffix("/responses"))
429 .unwrap_or(base_path);
430 url.set_path(&format!("{base_root}{suffix}"));
431 Ok(url.into())
432}
433
434fn anthropic_url(base_url: &str, suffix: &str) -> Result<String> {
436 let mut url = reqwest::Url::parse(base_url).map_err(|error| LlmClientError::Configuration {
437 message: format!("Invalid Anthropic base URL: {error}"),
438 })?;
439 let base_path = url.path().trim_end_matches('/');
440 let messages_path = if base_path.ends_with("/v1/messages") {
441 base_path.to_string()
442 } else if base_path.ends_with("/v1") {
443 format!("{base_path}/messages")
444 } else {
445 format!("{base_path}/v1/messages")
446 };
447 url.set_path(&format!("{messages_path}{suffix}"));
448 Ok(url.into())
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 fn config(base_url: &str) -> HttpBackendConfig {
456 HttpBackendConfig {
457 base_url: base_url.to_string(),
458 api_key: Some("secret".to_string()),
459 forward_auth: false,
460 extra_headers: BTreeMap::new(),
461 extra_body: BTreeMap::new(),
462 omit_body_fields: BTreeSet::new(),
463 reasoning_effort: None,
464 max_retries: 0,
465 failure_cooldown: Duration::ZERO,
466 timeout: None,
467 }
468 }
469
470 #[test]
471 fn openai_chat_url_joins_bare_v1() {
472 let backend = Backend::OpenAiChat(config("https://api.openai.com/v1"));
473 assert_eq!(backend.url(), "https://api.openai.com/v1/chat/completions");
474 }
475
476 #[test]
477 fn openai_chat_url_tolerates_trailing_slash_and_existing_suffix() {
478 assert_eq!(
479 Backend::OpenAiChat(config("https://api.openai.com/v1/")).url(),
480 "https://api.openai.com/v1/chat/completions"
481 );
482 assert_eq!(
483 Backend::OpenAiChat(config("https://api.openai.com/v1/chat/completions")).url(),
484 "https://api.openai.com/v1/chat/completions"
485 );
486 }
487
488 #[test]
489 fn openai_responses_url_uses_responses_path() {
490 assert_eq!(
491 Backend::OpenAiResponses(config("https://api.openai.com/v1")).url(),
492 "https://api.openai.com/v1/responses"
493 );
494 }
495
496 #[test]
497 fn anthropic_url_join_cases() {
498 assert_eq!(
499 Backend::Anthropic(config("https://api.anthropic.com")).url(),
500 "https://api.anthropic.com/v1/messages"
501 );
502 assert_eq!(
503 Backend::Anthropic(config("https://api.anthropic.com/v1")).url(),
504 "https://api.anthropic.com/v1/messages"
505 );
506 assert_eq!(
507 Backend::Anthropic(config("https://api.anthropic.com/v1/messages")).url(),
508 "https://api.anthropic.com/v1/messages"
509 );
510 }
511
512 #[test]
513 fn wire_format_matches_variant() {
514 assert_eq!(
515 Backend::OpenAiChat(config("x")).wire_format(),
516 WireFormat::OpenAiChat
517 );
518 assert_eq!(
519 Backend::OpenAiResponses(config("x")).wire_format(),
520 WireFormat::OpenAiResponses
521 );
522 assert_eq!(
523 Backend::Anthropic(config("x")).wire_format(),
524 WireFormat::AnthropicMessages
525 );
526 }
527
528 #[test]
530 fn validates_additional_header_bytes() {
531 let cases = [
532 ("x-display-name", "café", None),
533 (
534 "bad header",
535 "value",
536 Some("invalid HTTP header name \"bad header\""),
537 ),
538 (
539 "x-test-header",
540 "bad\nvalue",
541 Some("invalid HTTP header value for extra_headers entry \"x-test-header\""),
542 ),
543 ];
544
545 for (name, value, expected) in cases {
546 let mut config = config("x");
547 config
548 .extra_headers
549 .insert(name.to_string(), value.to_string());
550 let result = Backend::OpenAiChat(config).validate_configured_headers("model");
551 match expected {
552 Some(expected) => assert!(
553 result.is_err_and(|error| error.to_string().contains(expected)),
554 "expected {expected:?}"
555 ),
556 None => result.expect("encodable header must pass validation"),
557 }
558 }
559 }
560
561 #[test]
563 fn configured_api_key_validation_matches_auth_application() {
564 const INVALID_KEY: &str = "canary\nsecret";
565 let mut config = config("x");
566 config.api_key = Some(INVALID_KEY.to_string());
567 let builders: [fn(HttpBackendConfig) -> Backend; 2] =
568 [Backend::OpenAiChat, Backend::Anthropic];
569 let client = reqwest::Client::new();
570
571 for build_backend in builders {
572 let error = build_backend(config.clone())
573 .validate_configured_headers("model")
574 .expect_err("invalid API key must fail")
575 .to_string();
576 assert!(
577 error.contains("api_key cannot be encoded as an HTTP header"),
578 "{error}"
579 );
580 assert!(!error.contains(INVALID_KEY), "API key leaked in: {error}");
581
582 let mut forwarded = config.clone();
583 forwarded.forward_auth = true;
584 let backend = build_backend(forwarded);
585 backend
586 .validate_configured_headers("model")
587 .expect("unused API key must not fail validation");
588 let request = backend
589 .apply_auth(client.get("https://example.test"))
590 .build()
591 .expect("request");
592 assert!(!request.headers().contains_key("authorization"));
593 assert!(!request.headers().contains_key("x-api-key"));
594 }
595 }
596
597 #[test]
598 fn openai_detects_canonical_and_wrapped_overflow() {
599 let backend = Backend::OpenAiChat(config("x"));
600 assert!(
601 backend.is_context_overflow(
602 r#"{"error":{"code":"context_length_exceeded","message":"x"}}"#
603 )
604 );
605 assert!(backend.is_context_overflow(
607 r#"{"error":{"message":"the model's context length is only 131072 tokens"}}"#
608 ));
609 assert!(!backend.is_context_overflow(r#"{"error":{"code":"invalid_api_key"}}"#));
610 assert!(backend.is_context_overflow(
612 r#"{"error":{"message":"Input length 877338 exceeds the maximum allowed input length of 639968 tokens","code":"400"}}"#
613 ));
614 assert!(backend.is_context_overflow(
618 r#"{"object":"error","message":"Input length (700001 tokens) exceeds the maximum allowed length (536826 tokens). Use a shorter input or enable --allow-auto-truncate.","type":"BadRequestError","param":null,"code":400}"#
619 ));
620 assert!(backend.is_context_overflow(
621 r#"{"object":"error","message":"The input (12345 tokens) is longer than the model's context length (8192 tokens).","type":"BadRequestError","param":null,"code":400}"#
622 ));
623 }
624
625 #[test]
626 fn openai_detects_llama_server_overflow() {
627 let backend = Backend::OpenAiChat(config("x"));
628 assert!(backend.is_context_overflow(
631 r#"{"error":{"code":400,"message":"request (600000 tokens) exceeds the available context size (524288 tokens), try increasing it","type":"exceed_context_size_error"}}"#
632 ));
633 assert!(backend.is_context_overflow(
634 r#"{"error":{"code":400,"message":"input (600000 tokens) is larger than the max context size (524288 tokens). skipping","type":"exceed_context_size_error"}}"#
635 ));
636 assert!(backend.is_context_overflow(
638 r#"{"error":{"code":400,"message":"something new","type":"exceed_context_size_error"}}"#
639 ));
640 assert!(backend.is_context_overflow(
642 r#"{"error":{"code":400,"message":"request (600000 tokens) exceeds the available context size (524288 tokens), try increasing it"}}"#
643 ));
644 assert!(!backend.is_context_overflow(
646 r#"{"error":{"code":400,"message":"Invalid tool_choice","type":"invalid_request_error"}}"#
647 ));
648 }
649
650 #[test]
651 fn anthropic_detects_prompt_too_long() {
652 let backend = Backend::Anthropic(config("x"));
653 assert!(
654 backend.is_context_overflow(
655 r#"{"error":{"message":"prompt is too long: 200000 tokens"}}"#
656 )
657 );
658 assert!(!backend.is_context_overflow(r#"{"error":{"message":"overloaded"}}"#));
659 }
660}