use std::{collections::BTreeMap, fmt};
use reqwest::RequestBuilder;
use serde_json::Value;
use switchyard_protocol::WireFormat;
use crate::error::is_overflow_body;
const ANTHROPIC_VERSION: &str = "2023-06-01";
pub const DEFAULT_MAX_RETRIES: u32 = 2;
const OPENAI_OVERFLOW_PHRASES: &[&str] = &[
"maximum context length",
"context length exceeded",
"context window",
"context length is only",
"please reduce the length of the input",
"exceeds the maximum allowed input length",
];
const ANTHROPIC_OVERFLOW_PHRASES: &[&str] = &[
"prompt is too long",
"maximum number of tokens",
"context window",
"context length",
];
#[derive(Clone)]
pub struct HttpBackendConfig {
pub base_url: String,
pub api_key: Option<String>,
pub extra_headers: BTreeMap<String, String>,
pub extra_body: BTreeMap<String, Value>,
pub max_retries: u32,
}
impl fmt::Debug for HttpBackendConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HttpBackendConfig")
.field("base_url", &self.base_url)
.field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
.field("extra_headers", &self.extra_headers)
.field("extra_body_keys", &self.extra_body.keys())
.field("max_retries", &self.max_retries)
.finish()
}
}
#[derive(Clone, Debug)]
pub enum Backend {
OpenAiChat(HttpBackendConfig),
OpenAiResponses(HttpBackendConfig),
Anthropic(HttpBackendConfig),
}
impl Backend {
pub fn wire_format(&self) -> WireFormat {
match self {
Backend::OpenAiChat(_) => WireFormat::OpenAiChat,
Backend::OpenAiResponses(_) => WireFormat::OpenAiResponses,
Backend::Anthropic(_) => WireFormat::AnthropicMessages,
}
}
fn config(&self) -> &HttpBackendConfig {
match self {
Backend::OpenAiChat(config)
| Backend::OpenAiResponses(config)
| Backend::Anthropic(config) => config,
}
}
pub fn url(&self) -> String {
let base_url = self.config().base_url.trim_end_matches('/');
match self {
Backend::OpenAiChat(_) => openai_url(base_url, "/chat/completions"),
Backend::OpenAiResponses(_) => openai_url(base_url, "/responses"),
Backend::Anthropic(_) => anthropic_url(base_url),
}
}
pub fn apply_auth(&self, mut builder: RequestBuilder) -> RequestBuilder {
let api_key = self.config().api_key.as_deref();
match self {
Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => {
if let Some(api_key) = api_key {
builder = builder.bearer_auth(api_key);
}
}
Backend::Anthropic(_) => {
builder = builder.header("anthropic-version", ANTHROPIC_VERSION);
if let Some(api_key) = api_key {
builder = builder.header("x-api-key", api_key);
}
}
}
builder
}
pub fn extra_headers(&self) -> &BTreeMap<String, String> {
&self.config().extra_headers
}
pub fn extra_body(&self) -> &BTreeMap<String, Value> {
&self.config().extra_body
}
pub fn max_retries(&self) -> u32 {
self.config().max_retries
}
pub fn is_anthropic(&self) -> bool {
matches!(self, Backend::Anthropic(_))
}
pub fn count_tokens_url(&self) -> String {
let base_url = self.config().base_url.trim_end_matches('/');
format!("{}/count_tokens", anthropic_url(base_url))
}
pub(crate) fn is_context_overflow(&self, body: &str) -> bool {
match self {
Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => is_overflow_body(
body,
|value| {
value
.get("error")
.and_then(|err| err.get("code"))
.and_then(serde_json::Value::as_str)
== Some("context_length_exceeded")
},
OPENAI_OVERFLOW_PHRASES,
),
Backend::Anthropic(_) => is_overflow_body(body, |_| false, ANTHROPIC_OVERFLOW_PHRASES),
}
}
}
fn openai_url(base_url: &str, suffix: &str) -> String {
let base_root = base_url
.strip_suffix("/chat/completions")
.or_else(|| base_url.strip_suffix("/responses"))
.unwrap_or(base_url);
format!("{base_root}{suffix}")
}
fn anthropic_url(base_url: &str) -> String {
if base_url.ends_with("/v1/messages") {
base_url.to_string()
} else if base_url.ends_with("/v1") {
format!("{base_url}/messages")
} else {
format!("{base_url}/v1/messages")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config(base_url: &str) -> HttpBackendConfig {
HttpBackendConfig {
base_url: base_url.to_string(),
api_key: Some("secret".to_string()),
extra_headers: BTreeMap::new(),
extra_body: BTreeMap::new(),
max_retries: 0,
}
}
#[test]
fn openai_chat_url_joins_bare_v1() {
let backend = Backend::OpenAiChat(config("https://api.openai.com/v1"));
assert_eq!(backend.url(), "https://api.openai.com/v1/chat/completions");
}
#[test]
fn openai_chat_url_tolerates_trailing_slash_and_existing_suffix() {
assert_eq!(
Backend::OpenAiChat(config("https://api.openai.com/v1/")).url(),
"https://api.openai.com/v1/chat/completions"
);
assert_eq!(
Backend::OpenAiChat(config("https://api.openai.com/v1/chat/completions")).url(),
"https://api.openai.com/v1/chat/completions"
);
}
#[test]
fn openai_responses_url_uses_responses_path() {
assert_eq!(
Backend::OpenAiResponses(config("https://api.openai.com/v1")).url(),
"https://api.openai.com/v1/responses"
);
}
#[test]
fn anthropic_url_join_cases() {
assert_eq!(
Backend::Anthropic(config("https://api.anthropic.com")).url(),
"https://api.anthropic.com/v1/messages"
);
assert_eq!(
Backend::Anthropic(config("https://api.anthropic.com/v1")).url(),
"https://api.anthropic.com/v1/messages"
);
assert_eq!(
Backend::Anthropic(config("https://api.anthropic.com/v1/messages")).url(),
"https://api.anthropic.com/v1/messages"
);
}
#[test]
fn count_tokens_url_joins_every_base_url_shape() {
assert_eq!(
Backend::Anthropic(config("https://host")).count_tokens_url(),
"https://host/v1/messages/count_tokens"
);
assert_eq!(
Backend::Anthropic(config("https://host/v1")).count_tokens_url(),
"https://host/v1/messages/count_tokens"
);
assert_eq!(
Backend::Anthropic(config("https://host/v1/messages")).count_tokens_url(),
"https://host/v1/messages/count_tokens"
);
assert_eq!(
Backend::Anthropic(config("https://host/v1/")).count_tokens_url(),
"https://host/v1/messages/count_tokens"
);
}
#[test]
fn only_anthropic_backend_is_anthropic() {
assert!(Backend::Anthropic(config("x")).is_anthropic());
assert!(!Backend::OpenAiChat(config("x")).is_anthropic());
assert!(!Backend::OpenAiResponses(config("x")).is_anthropic());
}
#[test]
fn wire_format_matches_variant() {
assert_eq!(
Backend::OpenAiChat(config("x")).wire_format(),
WireFormat::OpenAiChat
);
assert_eq!(
Backend::OpenAiResponses(config("x")).wire_format(),
WireFormat::OpenAiResponses
);
assert_eq!(
Backend::Anthropic(config("x")).wire_format(),
WireFormat::AnthropicMessages
);
}
#[test]
fn openai_detects_canonical_and_wrapped_overflow() {
let backend = Backend::OpenAiChat(config("x"));
assert!(
backend.is_context_overflow(
r#"{"error":{"code":"context_length_exceeded","message":"x"}}"#
)
);
assert!(backend.is_context_overflow(
r#"{"error":{"message":"the model's context length is only 131072 tokens"}}"#
));
assert!(!backend.is_context_overflow(r#"{"error":{"code":"invalid_api_key"}}"#));
assert!(backend.is_context_overflow(
r#"{"error":{"message":"Input length 877338 exceeds the maximum allowed input length of 639968 tokens","code":"400"}}"#
));
}
#[test]
fn anthropic_detects_prompt_too_long() {
let backend = Backend::Anthropic(config("x"));
assert!(
backend.is_context_overflow(
r#"{"error":{"message":"prompt is too long: 200000 tokens"}}"#
)
);
assert!(!backend.is_context_overflow(r#"{"error":{"message":"overloaded"}}"#));
}
}