1use serde::{Deserialize, Serialize};
7use tracing::{debug, info, warn};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct BackendConfig {
12 pub name: String,
14 pub url: String,
16 pub api_key: String,
18 #[serde(default = "default_protocol")]
20 pub protocol: String,
21 #[serde(default)]
23 pub model: Option<String>,
24 #[serde(default)]
26 pub match_rules: MatchRules,
27}
28
29fn default_protocol() -> String {
30 "openai".to_string()
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct MatchRules {
36 pub path_prefix: Option<String>,
38 pub header: Option<HeaderMatch>,
40 #[serde(default)]
42 pub default: bool,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct HeaderMatch {
48 pub name: String,
49 pub value: String,
50}
51
52#[derive(Clone, Debug)]
54pub struct BackendInfo {
55 pub name: String,
56 pub host: String,
57 pub port: u16,
58 pub use_tls: bool,
59 pub base_path: String,
60 pub api_key: String,
61 pub protocol: String,
62 pub model: Option<String>,
63}
64
65impl BackendInfo {
66 pub fn from_config(config: &BackendConfig) -> anyhow::Result<Self> {
68 let parsed = url::Url::parse(&config.url)
69 .map_err(|e| anyhow::anyhow!("Invalid backend URL '{}': {}", config.name, e))?;
70
71 let host = parsed
72 .host_str()
73 .ok_or_else(|| anyhow::anyhow!("Backend '{}' missing host", config.name))?
74 .to_string();
75
76 let use_tls = parsed.scheme() == "https";
77 let port = parsed.port().unwrap_or(if use_tls { 443 } else { 80 });
78 let base_path = parsed.path().trim_end_matches('/').to_string();
79
80 Ok(Self {
81 name: config.name.clone(),
82 host,
83 port,
84 use_tls,
85 base_path,
86 api_key: config.api_key.clone(),
87 protocol: config.protocol.clone(),
88 model: config.model.clone(),
89 })
90 }
91
92 pub fn use_anthropic_auth(&self) -> bool {
94 self.protocol.to_lowercase() != "openai"
95 }
96}
97
98#[derive(Debug, Clone)]
100pub struct BackendRouter {
101 backends: Vec<(BackendConfig, BackendInfo)>,
102 default_index: Option<usize>,
103}
104
105impl BackendRouter {
106 fn path_matches_prefix(path: &str, prefix: &str) -> bool {
107 let normalized = if prefix != "/" {
108 prefix.trim_end_matches('/')
109 } else {
110 prefix
111 };
112 if normalized.is_empty() {
113 return false;
114 }
115 if path == normalized {
116 return true;
117 }
118 let with_slash = format!("{}/", normalized);
119 path.starts_with(&with_slash)
120 }
121
122 pub fn new(configs: Vec<BackendConfig>) -> anyhow::Result<Self> {
124 if configs.is_empty() {
125 return Err(anyhow::anyhow!("At least one backend must be configured"));
126 }
127
128 let mut backends = Vec::new();
129 let mut default_index = None;
130
131 for (i, config) in configs.into_iter().enumerate() {
132 let default_marker = if config.match_rules.default { " [default]" } else { "" };
133 info!(
134 "Loading backend [{}]: {} -> {}{}",
135 config.name, config.url, config.protocol, default_marker
136 );
137
138 if config.match_rules.default {
139 if default_index.is_some() {
140 warn!("Multiple default backends configured, using last one");
141 }
142 default_index = Some(i);
143 }
144
145 let info = BackendInfo::from_config(&config)?;
146 backends.push((config, info));
147 }
148
149 let default_index = default_index.or(Some(0));
151
152 Ok(Self {
153 backends,
154 default_index,
155 })
156 }
157
158 pub fn select(&self, path: &str, headers: &[(String, String)]) -> Option<&BackendInfo> {
160 for (config, info) in &self.backends {
161 if let Some(ref prefix) = config.match_rules.path_prefix
163 && Self::path_matches_prefix(path, prefix) {
164 debug!(
165 "Path '{}' matched backend '{}' (prefix: {})",
166 path, config.name, prefix
167 );
168 return Some(info);
169 }
170
171 if let Some(ref header_match) = config.match_rules.header {
173 for (name, value) in headers {
174 if name.eq_ignore_ascii_case(&header_match.name)
175 && value == &header_match.value
176 {
177 debug!(
178 "Header '{}: {}' matched backend '{}'",
179 header_match.name, header_match.value, config.name
180 );
181 return Some(info);
182 }
183 }
184 }
185 }
186
187 if let Some(index) = self.default_index {
189 debug!("Using default backend '{}'", self.backends[index].1.name);
190 return Some(&self.backends[index].1);
191 }
192
193 None
194 }
195
196 pub fn select_and_rewrite(
198 &self,
199 path: &str,
200 headers: &[(String, String)],
201 ) -> Option<(&BackendInfo, String)> {
202 let (config, info) = self.select_with_config(path, headers)?;
203
204 let new_path = if let Some(ref prefix) = config.match_rules.path_prefix {
206 path.strip_prefix(prefix).unwrap_or(path).to_string()
207 } else {
208 path.to_string()
209 };
210
211 let new_path = if !info.base_path.is_empty() {
213 format!("{}{}", info.base_path, new_path)
214 } else {
215 new_path
216 };
217
218 Some((info, new_path))
219 }
220
221 pub fn select_with_config(
223 &self,
224 path: &str,
225 headers: &[(String, String)],
226 ) -> Option<(&BackendConfig, &BackendInfo)> {
227 for (config, info) in &self.backends {
228 if let Some(ref prefix) = config.match_rules.path_prefix
230 && Self::path_matches_prefix(path, prefix) {
231 return Some((config, info));
232 }
233
234 if let Some(ref header_match) = config.match_rules.header {
236 for (name, value) in headers {
237 if name.eq_ignore_ascii_case(&header_match.name)
238 && value == &header_match.value
239 {
240 return Some((config, info));
241 }
242 }
243 }
244 }
245
246 self.default_index.map(|i| (&self.backends[i].0, &self.backends[i].1))
248 }
249
250 pub fn backend_names(&self) -> Vec<&str> {
252 self.backends.iter().map(|(c, _)| c.name.as_str()).collect()
253 }
254
255 pub fn default_backend(&self) -> Option<&BackendInfo> {
257 self.default_index.map(|i| &self.backends[i].1)
258 }
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ProxyConfig {
264 #[serde(default = "default_listen")]
266 pub listen: String,
267 #[serde(default = "default_log_dir")]
269 pub log_dir: String,
270 #[serde(default = "default_log_body")]
272 pub log_body: bool,
273 pub backends: Vec<BackendConfig>,
275}
276
277fn default_listen() -> String {
278 "0.0.0.0:8080".to_string()
279}
280
281fn default_log_dir() -> String {
282 "./logs".to_string()
283}
284
285fn default_log_body() -> bool {
286 false
287}
288
289impl Default for ProxyConfig {
290 fn default() -> Self {
291 Self {
292 listen: default_listen(),
293 log_dir: default_log_dir(),
294 log_body: default_log_body(),
295 backends: Vec::new(),
296 }
297 }
298}
299
300impl BackendConfig {
301 pub fn to_backend_info(&self) -> anyhow::Result<BackendInfo> {
303 BackendInfo::from_config(self)
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn test_backend_router() {
313 let configs = vec![
314 BackendConfig {
315 name: "anthropic".to_string(),
316 url: "https://api.anthropic.com".to_string(),
317 api_key: "sk-ant-xxx".to_string(),
318 protocol: "anthropic".to_string(),
319 model: None,
320 match_rules: MatchRules {
321 path_prefix: Some("/anthropic".to_string()),
322 ..Default::default()
323 },
324 },
325 BackendConfig {
326 name: "openai".to_string(),
327 url: "https://api.openai.com/v1".to_string(),
328 api_key: "sk-xxx".to_string(),
329 protocol: "openai".to_string(),
330 model: None,
331 match_rules: MatchRules {
332 path_prefix: Some("/openai".to_string()),
333 ..Default::default()
334 },
335 },
336 BackendConfig {
337 name: "default".to_string(),
338 url: "https://api.example.com".to_string(),
339 api_key: "xxx".to_string(),
340 protocol: "openai".to_string(),
341 model: None,
342 match_rules: MatchRules {
343 default: true,
344 ..Default::default()
345 },
346 },
347 ];
348
349 let router = BackendRouter::new(configs).unwrap();
350
351 let (info, path) = router.select_and_rewrite("/anthropic/v1/messages", &[]).unwrap();
353 assert_eq!(info.name, "anthropic");
354 assert_eq!(path, "/v1/messages");
355
356 let (info, path) = router.select_and_rewrite("/openai/chat/completions", &[]).unwrap();
357 assert_eq!(info.name, "openai");
358 assert_eq!(path, "/v1/chat/completions");
359
360 let (info, path) = router.select_and_rewrite("/other/path", &[]).unwrap();
362 assert_eq!(info.name, "default");
363 assert_eq!(path, "/other/path");
364 }
365
366 #[test]
367 fn test_anthropic_auth() {
368 let info = BackendInfo {
369 name: "test".to_string(),
370 host: "localhost".to_string(),
371 port: 443,
372 use_tls: true,
373 base_path: String::new(),
374 api_key: "test".to_string(),
375 protocol: "anthropic".to_string(),
376 model: None,
377 };
378 assert!(info.use_anthropic_auth());
379
380 let info = BackendInfo {
381 protocol: "openai".to_string(),
382 ..info
383 };
384 assert!(!info.use_anthropic_auth());
385 }
386
387 #[test]
388 fn test_select_and_rewrite_with_responses_prefix() {
389 let configs = vec![
390 BackendConfig {
391 name: "kimi".to_string(),
392 url: "https://api.moonshot.cn/v1".to_string(),
393 api_key: "sk-kimi".to_string(),
394 protocol: "openai".to_string(),
395 model: None,
396 match_rules: MatchRules {
397 path_prefix: Some("/kimi".to_string()),
398 ..Default::default()
399 },
400 },
401 BackendConfig {
402 name: "default".to_string(),
403 url: "https://api.example.com".to_string(),
404 api_key: "sk-default".to_string(),
405 protocol: "openai".to_string(),
406 model: None,
407 match_rules: MatchRules {
408 default: true,
409 ..Default::default()
410 },
411 },
412 ];
413
414 let router = BackendRouter::new(configs).unwrap();
415 let (info, rewritten_path) = router.select_and_rewrite("/kimi/responses", &[]).unwrap();
416 assert_eq!(info.name, "kimi");
417 assert_eq!(rewritten_path, "/v1/responses");
418 }
419}