switchyard_llm_client/
backend.rs1use std::{collections::BTreeMap, fmt};
7
8use reqwest::RequestBuilder;
9use serde_json::Value;
10use switchyard_protocol::WireFormat;
11
12use crate::error::is_overflow_body;
13
14const ANTHROPIC_VERSION: &str = "2023-06-01";
15
16pub const DEFAULT_MAX_RETRIES: u32 = 2;
18
19const OPENAI_OVERFLOW_PHRASES: &[&str] = &[
22 "maximum context length",
23 "context length exceeded",
24 "context window",
25 "context length is only",
26 "please reduce the length of the input",
27 "exceeds the maximum allowed input length",
28];
29
30const ANTHROPIC_OVERFLOW_PHRASES: &[&str] = &[
32 "prompt is too long",
33 "maximum number of tokens",
34 "context window",
35 "context length",
36];
37
38#[derive(Clone)]
40pub struct HttpBackendConfig {
41 pub base_url: String,
43 pub api_key: Option<String>,
45 pub extra_headers: BTreeMap<String, String>,
47 pub extra_body: BTreeMap<String, Value>,
49 pub max_retries: u32,
51}
52
53impl fmt::Debug for HttpBackendConfig {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 f.debug_struct("HttpBackendConfig")
56 .field("base_url", &self.base_url)
57 .field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
58 .field("extra_headers", &self.extra_headers)
59 .field("extra_body_keys", &self.extra_body.keys())
60 .field("max_retries", &self.max_retries)
61 .finish()
62 }
63}
64
65#[derive(Clone, Debug)]
70pub enum Backend {
71 OpenAiChat(HttpBackendConfig),
73 OpenAiResponses(HttpBackendConfig),
75 Anthropic(HttpBackendConfig),
77}
78
79impl Backend {
80 pub fn wire_format(&self) -> WireFormat {
82 match self {
83 Backend::OpenAiChat(_) => WireFormat::OpenAiChat,
84 Backend::OpenAiResponses(_) => WireFormat::OpenAiResponses,
85 Backend::Anthropic(_) => WireFormat::AnthropicMessages,
86 }
87 }
88
89 fn config(&self) -> &HttpBackendConfig {
91 match self {
92 Backend::OpenAiChat(config)
93 | Backend::OpenAiResponses(config)
94 | Backend::Anthropic(config) => config,
95 }
96 }
97
98 pub fn url(&self) -> String {
103 let base_url = self.config().base_url.trim_end_matches('/');
104 match self {
105 Backend::OpenAiChat(_) => openai_url(base_url, "/chat/completions"),
106 Backend::OpenAiResponses(_) => openai_url(base_url, "/responses"),
107 Backend::Anthropic(_) => anthropic_url(base_url),
108 }
109 }
110
111 pub fn apply_auth(&self, mut builder: RequestBuilder) -> RequestBuilder {
116 let api_key = self.config().api_key.as_deref();
117 match self {
118 Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => {
119 if let Some(api_key) = api_key {
120 builder = builder.bearer_auth(api_key);
121 }
122 }
123 Backend::Anthropic(_) => {
124 builder = builder.header("anthropic-version", ANTHROPIC_VERSION);
125 if let Some(api_key) = api_key {
126 builder = builder.header("x-api-key", api_key);
127 }
128 }
129 }
130 builder
131 }
132
133 pub fn extra_headers(&self) -> &BTreeMap<String, String> {
135 &self.config().extra_headers
136 }
137
138 pub fn extra_body(&self) -> &BTreeMap<String, Value> {
140 &self.config().extra_body
141 }
142
143 pub fn max_retries(&self) -> u32 {
145 self.config().max_retries
146 }
147
148 pub fn is_anthropic(&self) -> bool {
151 matches!(self, Backend::Anthropic(_))
152 }
153
154 pub fn count_tokens_url(&self) -> String {
157 let base_url = self.config().base_url.trim_end_matches('/');
158 format!("{}/count_tokens", anthropic_url(base_url))
159 }
160
161 pub(crate) fn is_context_overflow(&self, body: &str) -> bool {
164 match self {
165 Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => is_overflow_body(
166 body,
167 |value| {
168 value
169 .get("error")
170 .and_then(|err| err.get("code"))
171 .and_then(serde_json::Value::as_str)
172 == Some("context_length_exceeded")
173 },
174 OPENAI_OVERFLOW_PHRASES,
175 ),
176 Backend::Anthropic(_) => is_overflow_body(body, |_| false, ANTHROPIC_OVERFLOW_PHRASES),
177 }
178 }
179}
180
181fn openai_url(base_url: &str, suffix: &str) -> String {
183 let base_root = base_url
184 .strip_suffix("/chat/completions")
185 .or_else(|| base_url.strip_suffix("/responses"))
186 .unwrap_or(base_url);
187 format!("{base_root}{suffix}")
188}
189
190fn anthropic_url(base_url: &str) -> String {
192 if base_url.ends_with("/v1/messages") {
193 base_url.to_string()
194 } else if base_url.ends_with("/v1") {
195 format!("{base_url}/messages")
196 } else {
197 format!("{base_url}/v1/messages")
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 fn config(base_url: &str) -> HttpBackendConfig {
206 HttpBackendConfig {
207 base_url: base_url.to_string(),
208 api_key: Some("secret".to_string()),
209 extra_headers: BTreeMap::new(),
210 extra_body: BTreeMap::new(),
211 max_retries: 0,
212 }
213 }
214
215 #[test]
216 fn openai_chat_url_joins_bare_v1() {
217 let backend = Backend::OpenAiChat(config("https://api.openai.com/v1"));
218 assert_eq!(backend.url(), "https://api.openai.com/v1/chat/completions");
219 }
220
221 #[test]
222 fn openai_chat_url_tolerates_trailing_slash_and_existing_suffix() {
223 assert_eq!(
224 Backend::OpenAiChat(config("https://api.openai.com/v1/")).url(),
225 "https://api.openai.com/v1/chat/completions"
226 );
227 assert_eq!(
228 Backend::OpenAiChat(config("https://api.openai.com/v1/chat/completions")).url(),
229 "https://api.openai.com/v1/chat/completions"
230 );
231 }
232
233 #[test]
234 fn openai_responses_url_uses_responses_path() {
235 assert_eq!(
236 Backend::OpenAiResponses(config("https://api.openai.com/v1")).url(),
237 "https://api.openai.com/v1/responses"
238 );
239 }
240
241 #[test]
242 fn anthropic_url_join_cases() {
243 assert_eq!(
244 Backend::Anthropic(config("https://api.anthropic.com")).url(),
245 "https://api.anthropic.com/v1/messages"
246 );
247 assert_eq!(
248 Backend::Anthropic(config("https://api.anthropic.com/v1")).url(),
249 "https://api.anthropic.com/v1/messages"
250 );
251 assert_eq!(
252 Backend::Anthropic(config("https://api.anthropic.com/v1/messages")).url(),
253 "https://api.anthropic.com/v1/messages"
254 );
255 }
256
257 #[test]
258 fn count_tokens_url_joins_every_base_url_shape() {
259 assert_eq!(
260 Backend::Anthropic(config("https://host")).count_tokens_url(),
261 "https://host/v1/messages/count_tokens"
262 );
263 assert_eq!(
264 Backend::Anthropic(config("https://host/v1")).count_tokens_url(),
265 "https://host/v1/messages/count_tokens"
266 );
267 assert_eq!(
268 Backend::Anthropic(config("https://host/v1/messages")).count_tokens_url(),
269 "https://host/v1/messages/count_tokens"
270 );
271 assert_eq!(
273 Backend::Anthropic(config("https://host/v1/")).count_tokens_url(),
274 "https://host/v1/messages/count_tokens"
275 );
276 }
277
278 #[test]
279 fn only_anthropic_backend_is_anthropic() {
280 assert!(Backend::Anthropic(config("x")).is_anthropic());
281 assert!(!Backend::OpenAiChat(config("x")).is_anthropic());
282 assert!(!Backend::OpenAiResponses(config("x")).is_anthropic());
283 }
284
285 #[test]
286 fn wire_format_matches_variant() {
287 assert_eq!(
288 Backend::OpenAiChat(config("x")).wire_format(),
289 WireFormat::OpenAiChat
290 );
291 assert_eq!(
292 Backend::OpenAiResponses(config("x")).wire_format(),
293 WireFormat::OpenAiResponses
294 );
295 assert_eq!(
296 Backend::Anthropic(config("x")).wire_format(),
297 WireFormat::AnthropicMessages
298 );
299 }
300
301 #[test]
302 fn openai_detects_canonical_and_wrapped_overflow() {
303 let backend = Backend::OpenAiChat(config("x"));
304 assert!(
305 backend.is_context_overflow(
306 r#"{"error":{"code":"context_length_exceeded","message":"x"}}"#
307 )
308 );
309 assert!(backend.is_context_overflow(
311 r#"{"error":{"message":"the model's context length is only 131072 tokens"}}"#
312 ));
313 assert!(!backend.is_context_overflow(r#"{"error":{"code":"invalid_api_key"}}"#));
314 assert!(backend.is_context_overflow(
316 r#"{"error":{"message":"Input length 877338 exceeds the maximum allowed input length of 639968 tokens","code":"400"}}"#
317 ));
318 }
319
320 #[test]
321 fn anthropic_detects_prompt_too_long() {
322 let backend = Backend::Anthropic(config("x"));
323 assert!(
324 backend.is_context_overflow(
325 r#"{"error":{"message":"prompt is too long: 200000 tokens"}}"#
326 )
327 );
328 assert!(!backend.is_context_overflow(r#"{"error":{"message":"overloaded"}}"#));
329 }
330}