Skip to main content

codex_convert_proxy/
config.rs

1//! Configuration module for proxy settings and backend routing.
2//!
3//! This module provides configuration structures for the proxy server,
4//! including backend definitions and routing rules.
5
6use serde::{Deserialize, Serialize};
7use tracing::{debug, info, warn};
8
9/// Single backend configuration.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct BackendConfig {
12    /// Backend name (for logging and stats).
13    pub name: String,
14    /// Backend URL (e.g., https://api.anthropic.com).
15    pub url: String,
16    /// API Key for authentication.
17    pub api_key: String,
18    /// API protocol: "openai" or "anthropic".
19    #[serde(default = "default_protocol")]
20    pub protocol: String,
21    /// Model to use for this backend (overrides request model).
22    #[serde(default)]
23    pub model: Option<String>,
24    /// Match rules for routing.
25    #[serde(default)]
26    pub match_rules: MatchRules,
27}
28
29fn default_protocol() -> String {
30    "openai".to_string()
31}
32
33/// Backend matching rules.
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct MatchRules {
36    /// Path prefix for matching.
37    pub path_prefix: Option<String>,
38    /// Header match rule.
39    pub header: Option<HeaderMatch>,
40    /// Whether this is the default backend.
41    #[serde(default)]
42    pub default: bool,
43}
44
45/// Header matching rule.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct HeaderMatch {
48    pub name: String,
49    pub value: String,
50}
51
52/// Parsed backend connection information.
53#[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    /// Parse connection info from backend config.
67    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    /// Check if using Anthropic-style auth (x-api-key header).
93    pub fn use_anthropic_auth(&self) -> bool {
94        self.protocol.to_lowercase() != "openai"
95    }
96}
97
98/// Backend router for selecting backends based on request characteristics.
99#[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    /// Create a new backend router from configs.
123    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        // Use first backend as default if none specified
150        let default_index = default_index.or(Some(0));
151
152        Ok(Self {
153            backends,
154            default_index,
155        })
156    }
157
158    /// Select a backend based on request path and headers.
159    pub fn select(&self, path: &str, headers: &[(String, String)]) -> Option<&BackendInfo> {
160        for (config, info) in &self.backends {
161            // Check path prefix match
162            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            // Check header match
172            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        // Fall back to default backend
188        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    /// Select backend and compute rewritten path.
197    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        // Remove path prefix if present
205        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        // Add backend's base_path
212        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    /// Select backend with config.
222    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            // Check path prefix
229            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            // Check header match
235            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        // Fall back to default
247        self.default_index.map(|i| (&self.backends[i].0, &self.backends[i].1))
248    }
249
250    /// Get all backend names.
251    pub fn backend_names(&self) -> Vec<&str> {
252        self.backends.iter().map(|(c, _)| c.name.as_str()).collect()
253    }
254
255    /// Get the default backend.
256    pub fn default_backend(&self) -> Option<&BackendInfo> {
257        self.default_index.map(|i| &self.backends[i].1)
258    }
259}
260
261/// Proxy configuration for the server.
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ProxyConfig {
264    /// Listen address.
265    #[serde(default = "default_listen")]
266    pub listen: String,
267    /// Log directory.
268    #[serde(default = "default_log_dir")]
269    pub log_dir: String,
270    /// Whether to log request/response bodies.
271    #[serde(default = "default_log_body")]
272    pub log_body: bool,
273    /// Backends configuration.
274    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    /// Convert config to BackendInfo.
302    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        // Test path matching
352        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        // Test default fallback
361        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}