1use serde::{Deserialize, Serialize};
7use tracing::{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
93#[derive(Debug, Clone)]
95pub struct BackendRouter {
96 backends: Vec<(BackendConfig, BackendInfo)>,
97 default_index: Option<usize>,
98}
99
100impl BackendRouter {
101 fn path_matches_prefix(path: &str, prefix: &str) -> bool {
102 let normalized = if prefix != "/" {
103 prefix.trim_end_matches('/')
104 } else {
105 prefix
106 };
107 if normalized.is_empty() {
108 return false;
109 }
110 if path == normalized {
111 return true;
112 }
113 let with_slash = format!("{}/", normalized);
114 path.starts_with(&with_slash)
115 }
116
117 pub fn new(configs: Vec<BackendConfig>) -> anyhow::Result<Self> {
119 if configs.is_empty() {
120 return Err(anyhow::anyhow!("At least one backend must be configured"));
121 }
122
123 let mut backends = Vec::new();
124 let mut default_index = None;
125
126 for (i, config) in configs.into_iter().enumerate() {
127 let default_marker = if config.match_rules.default { " [default]" } else { "" };
128 info!(
129 "Loading backend [{}]: {} -> {}{}",
130 config.name, config.url, config.protocol, default_marker
131 );
132
133 if config.match_rules.default {
134 if default_index.is_some() {
135 warn!("Multiple default backends configured, using last one");
136 }
137 default_index = Some(i);
138 }
139
140 let info = BackendInfo::from_config(&config)?;
141 backends.push((config, info));
142 }
143
144 let default_index = default_index.or(Some(0));
146
147 Ok(Self {
148 backends,
149 default_index,
150 })
151 }
152
153 pub fn select_and_rewrite(
155 &self,
156 path: &str,
157 headers: &[(String, String)],
158 ) -> Option<(&BackendInfo, String)> {
159 let (config, info) = self.select_with_config(path, headers)?;
160
161 let new_path = if let Some(ref prefix) = config.match_rules.path_prefix {
163 path.strip_prefix(prefix).unwrap_or(path).to_string()
164 } else {
165 path.to_string()
166 };
167
168 let new_path = if !info.base_path.is_empty() {
170 format!("{}{}", info.base_path, new_path)
171 } else {
172 new_path
173 };
174
175 Some((info, new_path))
176 }
177
178 pub fn select_with_config(
180 &self,
181 path: &str,
182 headers: &[(String, String)],
183 ) -> Option<(&BackendConfig, &BackendInfo)> {
184 for (config, info) in &self.backends {
185 if let Some(ref prefix) = config.match_rules.path_prefix
187 && Self::path_matches_prefix(path, prefix) {
188 return Some((config, info));
189 }
190
191 if let Some(ref header_match) = config.match_rules.header {
193 for (name, value) in headers {
194 if name.eq_ignore_ascii_case(&header_match.name)
195 && value == &header_match.value
196 {
197 return Some((config, info));
198 }
199 }
200 }
201 }
202
203 self.default_index.map(|i| (&self.backends[i].0, &self.backends[i].1))
205 }
206
207 pub fn backend_names(&self) -> Vec<&str> {
209 self.backends.iter().map(|(c, _)| c.name.as_str()).collect()
210 }
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct ProxyConfig {
216 #[serde(default = "default_listen")]
218 pub listen: String,
219 #[serde(default = "default_log_dir")]
221 pub log_dir: String,
222 #[serde(default = "default_log_body")]
224 pub log_body: bool,
225 pub backends: Vec<BackendConfig>,
227}
228
229fn default_listen() -> String {
230 "0.0.0.0:8080".to_string()
231}
232
233fn default_log_dir() -> String {
234 "./logs".to_string()
235}
236
237fn default_log_body() -> bool {
238 false
239}
240
241impl Default for ProxyConfig {
242 fn default() -> Self {
243 Self {
244 listen: default_listen(),
245 log_dir: default_log_dir(),
246 log_body: default_log_body(),
247 backends: Vec::new(),
248 }
249 }
250}
251
252impl BackendConfig {
253 pub fn to_backend_info(&self) -> anyhow::Result<BackendInfo> {
255 BackendInfo::from_config(self)
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
264 fn test_backend_router() {
265 let configs = vec![
266 BackendConfig {
267 name: "anthropic".to_string(),
268 url: "https://api.anthropic.com".to_string(),
269 api_key: "sk-ant-xxx".to_string(),
270 protocol: "anthropic".to_string(),
271 model: None,
272 match_rules: MatchRules {
273 path_prefix: Some("/anthropic".to_string()),
274 ..Default::default()
275 },
276 },
277 BackendConfig {
278 name: "openai".to_string(),
279 url: "https://api.openai.com/v1".to_string(),
280 api_key: "sk-xxx".to_string(),
281 protocol: "openai".to_string(),
282 model: None,
283 match_rules: MatchRules {
284 path_prefix: Some("/openai".to_string()),
285 ..Default::default()
286 },
287 },
288 BackendConfig {
289 name: "default".to_string(),
290 url: "https://api.example.com".to_string(),
291 api_key: "xxx".to_string(),
292 protocol: "openai".to_string(),
293 model: None,
294 match_rules: MatchRules {
295 default: true,
296 ..Default::default()
297 },
298 },
299 ];
300
301 let router = BackendRouter::new(configs).unwrap();
302
303 let (info, path) = router.select_and_rewrite("/anthropic/v1/messages", &[]).unwrap();
305 assert_eq!(info.name, "anthropic");
306 assert_eq!(path, "/v1/messages");
307
308 let (info, path) = router.select_and_rewrite("/openai/chat/completions", &[]).unwrap();
309 assert_eq!(info.name, "openai");
310 assert_eq!(path, "/v1/chat/completions");
311
312 let (info, path) = router.select_and_rewrite("/other/path", &[]).unwrap();
314 assert_eq!(info.name, "default");
315 assert_eq!(path, "/other/path");
316 }
317
318 #[test]
319 fn test_select_and_rewrite_with_responses_prefix() {
320 let configs = vec![
321 BackendConfig {
322 name: "kimi".to_string(),
323 url: "https://api.moonshot.cn/v1".to_string(),
324 api_key: "sk-kimi".to_string(),
325 protocol: "openai".to_string(),
326 model: None,
327 match_rules: MatchRules {
328 path_prefix: Some("/kimi".to_string()),
329 ..Default::default()
330 },
331 },
332 BackendConfig {
333 name: "default".to_string(),
334 url: "https://api.example.com".to_string(),
335 api_key: "sk-default".to_string(),
336 protocol: "openai".to_string(),
337 model: None,
338 match_rules: MatchRules {
339 default: true,
340 ..Default::default()
341 },
342 },
343 ];
344
345 let router = BackendRouter::new(configs).unwrap();
346 let (info, rewritten_path) = router.select_and_rewrite("/kimi/responses", &[]).unwrap();
347 assert_eq!(info.name, "kimi");
348 assert_eq!(rewritten_path, "/v1/responses");
349 }
350}