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::{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
93/// Backend router for selecting backends based on request characteristics.
94#[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    /// Create a new backend router from configs.
118    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        // Use first backend as default if none specified
145        let default_index = default_index.or(Some(0));
146
147        Ok(Self {
148            backends,
149            default_index,
150        })
151    }
152
153    /// Select backend and compute rewritten path.
154    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        // Remove path prefix if present
162        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        // Add backend's base_path
169        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    /// Select backend with config.
179    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            // Check path prefix
186            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            // Check header match
192            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        // Fall back to default
204        self.default_index.map(|i| (&self.backends[i].0, &self.backends[i].1))
205    }
206
207    /// Get all backend names.
208    pub fn backend_names(&self) -> Vec<&str> {
209        self.backends.iter().map(|(c, _)| c.name.as_str()).collect()
210    }
211}
212
213/// Proxy configuration for the server.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct ProxyConfig {
216    /// Listen address.
217    #[serde(default = "default_listen")]
218    pub listen: String,
219    /// Log directory.
220    #[serde(default = "default_log_dir")]
221    pub log_dir: String,
222    /// Whether to log request/response bodies.
223    #[serde(default = "default_log_body")]
224    pub log_body: bool,
225    /// Backends configuration.
226    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    /// Convert config to BackendInfo.
254    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        // Test path matching
304        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        // Test default fallback
313        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}