Skip to main content

lean_ctx/core/config/
proxy.rs

1//! API proxy upstream overrides (`config.toml`).
2
3use serde::{Deserialize, Serialize};
4
5/// API proxy upstream overrides. `None` = use provider default.
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7#[serde(default)]
8pub struct ProxyConfig {
9    pub anthropic_upstream: Option<String>,
10    pub openai_upstream: Option<String>,
11    pub gemini_upstream: Option<String>,
12}
13
14impl ProxyConfig {
15    pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
16        let (env_var, config_val, default) = match provider {
17            ProxyProvider::Anthropic => (
18                "LEAN_CTX_ANTHROPIC_UPSTREAM",
19                self.anthropic_upstream.as_deref(),
20                "https://api.anthropic.com",
21            ),
22            ProxyProvider::OpenAi => (
23                "LEAN_CTX_OPENAI_UPSTREAM",
24                self.openai_upstream.as_deref(),
25                "https://api.openai.com",
26            ),
27            ProxyProvider::Gemini => (
28                "LEAN_CTX_GEMINI_UPSTREAM",
29                self.gemini_upstream.as_deref(),
30                "https://generativelanguage.googleapis.com",
31            ),
32        };
33        let resolved = std::env::var(env_var)
34            .ok()
35            .and_then(|v| normalize_url_opt(&v))
36            .or_else(|| config_val.and_then(normalize_url_opt))
37            .unwrap_or_else(|| normalize_url(default));
38        match validate_upstream_url(&resolved) {
39            Ok(url) => url,
40            Err(e) => {
41                tracing::warn!("upstream validation failed, using default: {e}");
42                normalize_url(default)
43            }
44        }
45    }
46}
47
48#[derive(Debug, Clone, Copy)]
49pub enum ProxyProvider {
50    Anthropic,
51    OpenAi,
52    Gemini,
53}
54
55pub fn normalize_url(value: &str) -> String {
56    value.trim().trim_end_matches('/').to_string()
57}
58
59pub fn normalize_url_opt(value: &str) -> Option<String> {
60    let trimmed = normalize_url(value);
61    if trimmed.is_empty() {
62        None
63    } else {
64        Some(trimmed)
65    }
66}
67
68const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
69    "api.anthropic.com",
70    "api.openai.com",
71    "generativelanguage.googleapis.com",
72];
73
74pub fn validate_upstream_url(url: &str) -> Result<String, String> {
75    let normalized = normalize_url(url);
76    if is_local_proxy_url(&normalized) {
77        return Ok(normalized);
78    }
79    if !normalized.starts_with("https://") {
80        return Err(format!(
81            "upstream URL must use HTTPS: {normalized} (set LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 to override)"
82        ));
83    }
84    let host = normalized
85        .strip_prefix("https://")
86        .unwrap_or(&normalized)
87        .split('/')
88        .next()
89        .unwrap_or("");
90    let host_no_port = host.split(':').next().unwrap_or(host);
91    if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
92        || std::env::var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM").is_ok()
93    {
94        Ok(normalized)
95    } else {
96        Err(format!(
97            "upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (set LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 to override)"
98        ))
99    }
100}
101
102pub fn is_local_proxy_url(value: &str) -> bool {
103    let n = normalize_url(value);
104    n.starts_with("http://127.0.0.1:")
105        || n.starts_with("http://localhost:")
106        || n.starts_with("http://[::1]:")
107}