lean_ctx/core/config/
proxy.rs1use serde::{Deserialize, Serialize};
4
5#[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 pub history_mode: Option<String>,
15 pub allow_insecure_http_upstream: Option<bool>,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum HistoryMode {
30 CacheAware,
34 Rolling,
37 Off,
40}
41
42impl ProxyConfig {
43 pub fn resolved_history_mode(&self) -> HistoryMode {
48 let raw = std::env::var("LEAN_CTX_PROXY_HISTORY_MODE")
49 .ok()
50 .or_else(|| self.history_mode.clone());
51 match raw.as_deref().map(str::trim) {
52 Some(s) if s.eq_ignore_ascii_case("rolling") => HistoryMode::Rolling,
53 Some(s) if s.eq_ignore_ascii_case("off") => HistoryMode::Off,
54 _ => HistoryMode::CacheAware,
55 }
56 }
57
58 pub fn allows_insecure_http_upstream(&self) -> bool {
64 std::env::var("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM").is_ok()
65 || self.allow_insecure_http_upstream.unwrap_or(false)
66 }
67
68 pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
69 let (env_var, config_val, default) = match provider {
70 ProxyProvider::Anthropic => (
71 "LEAN_CTX_ANTHROPIC_UPSTREAM",
72 self.anthropic_upstream.as_deref(),
73 "https://api.anthropic.com",
74 ),
75 ProxyProvider::OpenAi => (
76 "LEAN_CTX_OPENAI_UPSTREAM",
77 self.openai_upstream.as_deref(),
78 "https://api.openai.com",
79 ),
80 ProxyProvider::Gemini => (
81 "LEAN_CTX_GEMINI_UPSTREAM",
82 self.gemini_upstream.as_deref(),
83 "https://generativelanguage.googleapis.com",
84 ),
85 };
86 let resolved = std::env::var(env_var)
87 .ok()
88 .and_then(|v| normalize_url_opt(&v))
89 .or_else(|| config_val.and_then(normalize_url_opt))
90 .unwrap_or_else(|| normalize_url(default));
91 match validate_upstream_url(&resolved, self.allows_insecure_http_upstream()) {
92 Ok(url) => url,
93 Err(e) => {
94 tracing::warn!("upstream validation failed, using default: {e}");
95 normalize_url(default)
96 }
97 }
98 }
99}
100
101#[derive(Debug, Clone, Copy)]
102pub enum ProxyProvider {
103 Anthropic,
104 OpenAi,
105 Gemini,
106}
107
108pub fn normalize_url(value: &str) -> String {
109 value.trim().trim_end_matches('/').to_string()
110}
111
112pub fn normalize_url_opt(value: &str) -> Option<String> {
113 let trimmed = normalize_url(value);
114 if trimmed.is_empty() {
115 None
116 } else {
117 Some(trimmed)
118 }
119}
120
121const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
122 "api.anthropic.com",
123 "api.openai.com",
124 "generativelanguage.googleapis.com",
125];
126
127pub(super) fn validate_upstream_url(
128 url: &str,
129 allow_insecure_http: bool,
130) -> Result<String, String> {
131 let normalized = normalize_url(url);
132 if is_local_proxy_url(&normalized) {
134 return Ok(normalized);
135 }
136
137 if normalized.starts_with("http://") {
144 if allow_insecure_http {
145 return Ok(normalized);
146 }
147 return Err(format!(
148 "upstream URL must use HTTPS: {normalized} (for a trusted local-network HTTP \
149 upstream opt in with LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM=1 or \
150 `[proxy] allow_insecure_http_upstream = true`)"
151 ));
152 }
153 let Some(host_segment) = normalized.strip_prefix("https://") else {
154 return Err(format!(
155 "upstream URL must start with http:// or https://: {normalized}"
156 ));
157 };
158
159 let host = host_segment.split('/').next().unwrap_or("");
160 let host_no_port = host.split(':').next().unwrap_or(host);
161 if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
162 || std::env::var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM").is_ok()
163 {
164 Ok(normalized)
165 } else {
166 Err(format!(
167 "upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (set LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 to override)"
168 ))
169 }
170}
171
172pub fn is_local_proxy_url(value: &str) -> bool {
173 let n = normalize_url(value);
174 n.starts_with("http://127.0.0.1:")
175 || n.starts_with("http://localhost:")
176 || n.starts_with("http://[::1]:")
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn loopback_http_is_always_allowed() {
185 assert_eq!(
186 validate_upstream_url("http://127.0.0.1:4444", false).unwrap(),
187 "http://127.0.0.1:4444"
188 );
189 assert_eq!(
190 validate_upstream_url("http://localhost:2455/", false).unwrap(),
191 "http://localhost:2455"
192 );
193 }
194
195 #[test]
196 fn https_allowlisted_host_is_allowed() {
197 assert_eq!(
198 validate_upstream_url("https://api.openai.com", false).unwrap(),
199 "https://api.openai.com"
200 );
201 }
202
203 #[test]
204 fn non_loopback_http_is_rejected_without_optin() {
205 let err = validate_upstream_url("http://host.docker.internal:2455", false).unwrap_err();
206 assert!(
210 err.contains("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM"),
211 "hint must name the working opt-in, got: {err}"
212 );
213 }
214
215 #[test]
216 fn non_loopback_http_is_allowed_with_optin() {
217 assert_eq!(
218 validate_upstream_url("http://host.docker.internal:2455", true).unwrap(),
219 "http://host.docker.internal:2455"
220 );
221 }
222
223 #[test]
224 fn unknown_scheme_is_rejected() {
225 assert!(validate_upstream_url("ftp://example.com", true).is_err());
226 }
227
228 #[test]
229 fn config_flag_enables_insecure_http_optin() {
230 let cfg = ProxyConfig {
233 allow_insecure_http_upstream: Some(true),
234 ..Default::default()
235 };
236 assert!(cfg.allows_insecure_http_upstream());
237 }
238}