1use 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 pub meter_openai_usage: Option<bool>,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum HistoryMode {
36 CacheAware,
43 Rolling,
46 Off,
49}
50
51impl ProxyConfig {
52 pub fn resolved_history_mode(&self) -> HistoryMode {
57 let raw = std::env::var("LEAN_CTX_PROXY_HISTORY_MODE")
58 .ok()
59 .or_else(|| self.history_mode.clone());
60 match raw.as_deref().map(str::trim) {
61 Some(s) if s.eq_ignore_ascii_case("rolling") => HistoryMode::Rolling,
62 Some(s) if s.eq_ignore_ascii_case("off") => HistoryMode::Off,
63 _ => HistoryMode::CacheAware,
64 }
65 }
66
67 pub fn meters_openai_usage(&self) -> bool {
71 self.meter_openai_usage.unwrap_or(true)
72 }
73
74 pub fn allows_insecure_http_upstream(&self) -> bool {
80 std::env::var("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM").is_ok()
81 || self.allow_insecure_http_upstream.unwrap_or(false)
82 }
83
84 fn provider_spec(&self, provider: ProxyProvider) -> (&'static str, Option<&str>, &'static str) {
86 match provider {
87 ProxyProvider::Anthropic => (
88 "LEAN_CTX_ANTHROPIC_UPSTREAM",
89 self.anthropic_upstream.as_deref(),
90 "https://api.anthropic.com",
91 ),
92 ProxyProvider::OpenAi => (
93 "LEAN_CTX_OPENAI_UPSTREAM",
94 self.openai_upstream.as_deref(),
95 "https://api.openai.com",
96 ),
97 ProxyProvider::Gemini => (
98 "LEAN_CTX_GEMINI_UPSTREAM",
99 self.gemini_upstream.as_deref(),
100 "https://generativelanguage.googleapis.com",
101 ),
102 }
103 }
104
105 fn resolve_upstream_checked(&self, provider: ProxyProvider) -> Result<String, String> {
112 self.resolve_upstream_inner(provider, true)
113 }
114
115 fn resolve_upstream_inner(
119 &self,
120 provider: ProxyProvider,
121 use_env: bool,
122 ) -> Result<String, String> {
123 let (env_var, config_val, default) = self.provider_spec(provider);
124 let env_val = if use_env {
125 std::env::var(env_var)
126 .ok()
127 .and_then(|v| normalize_url_opt(&v))
128 } else {
129 None
130 };
131 let candidate = env_val.or_else(|| config_val.and_then(normalize_url_opt));
132 match candidate {
133 None => Ok(normalize_url(default)),
134 Some(url) => validate_upstream_url(&url, self.allows_insecure_http_upstream()),
135 }
136 }
137
138 pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
142 match self.resolve_upstream_checked(provider) {
143 Ok(url) => url,
144 Err(e) => {
145 tracing::warn!("upstream validation failed, using default: {e}");
146 normalize_url(self.provider_spec(provider).2)
147 }
148 }
149 }
150
151 pub fn resolve_all(&self) -> Upstreams {
153 Upstreams {
154 anthropic: self.resolve_upstream(ProxyProvider::Anthropic),
155 openai: self.resolve_upstream(ProxyProvider::OpenAi),
156 gemini: self.resolve_upstream(ProxyProvider::Gemini),
157 }
158 }
159
160 pub fn resolve_all_disk(&self) -> Upstreams {
164 let pick = |provider: ProxyProvider| {
165 self.resolve_upstream_inner(provider, false)
166 .unwrap_or_else(|_| normalize_url(self.provider_spec(provider).2))
167 };
168 Upstreams {
169 anthropic: pick(ProxyProvider::Anthropic),
170 openai: pick(ProxyProvider::OpenAi),
171 gemini: pick(ProxyProvider::Gemini),
172 }
173 }
174
175 pub fn refresh_upstreams(&self, last: &Upstreams) -> Upstreams {
180 let keep = |provider: ProxyProvider, prev: &str| {
181 self.resolve_upstream_checked(provider).unwrap_or_else(|e| {
182 tracing::warn!("upstream invalid, keeping {prev}: {e}");
183 prev.to_string()
184 })
185 };
186 Upstreams {
187 anthropic: keep(ProxyProvider::Anthropic, &last.anthropic),
188 openai: keep(ProxyProvider::OpenAi, &last.openai),
189 gemini: keep(ProxyProvider::Gemini, &last.gemini),
190 }
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct Upstreams {
199 pub anthropic: String,
200 pub openai: String,
201 pub gemini: String,
202}
203
204#[derive(Debug, Clone, Copy)]
205pub enum ProxyProvider {
206 Anthropic,
207 OpenAi,
208 Gemini,
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub enum UpstreamDrift {
214 EnvNotApplied,
221 ConfigNotApplied,
225}
226
227pub fn env_upstream_override(provider: ProxyProvider) -> Option<String> {
231 let var = match provider {
232 ProxyProvider::Anthropic => "LEAN_CTX_ANTHROPIC_UPSTREAM",
233 ProxyProvider::OpenAi => "LEAN_CTX_OPENAI_UPSTREAM",
234 ProxyProvider::Gemini => "LEAN_CTX_GEMINI_UPSTREAM",
235 };
236 std::env::var(var).ok().and_then(|v| normalize_url_opt(&v))
237}
238
239pub fn diagnose_drift(env: Option<&str>, disk: &str, live: &str) -> Option<UpstreamDrift> {
243 if let Some(env) = env {
244 return (env != live).then_some(UpstreamDrift::EnvNotApplied);
248 }
249 (disk != live).then_some(UpstreamDrift::ConfigNotApplied)
251}
252
253pub fn normalize_url(value: &str) -> String {
254 value.trim().trim_end_matches('/').to_string()
255}
256
257pub fn normalize_url_opt(value: &str) -> Option<String> {
258 let trimmed = normalize_url(value);
259 if trimmed.is_empty() {
260 None
261 } else {
262 Some(trimmed)
263 }
264}
265
266const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
267 "api.anthropic.com",
268 "api.openai.com",
269 "generativelanguage.googleapis.com",
270];
271
272pub(super) fn validate_upstream_url(
273 url: &str,
274 allow_insecure_http: bool,
275) -> Result<String, String> {
276 let normalized = normalize_url(url);
277 if is_local_proxy_url(&normalized) {
279 return Ok(normalized);
280 }
281
282 if normalized.starts_with("http://") {
289 if allow_insecure_http {
290 return Ok(normalized);
291 }
292 return Err(format!(
293 "upstream URL must use HTTPS: {normalized} (for a trusted local-network HTTP \
294 upstream opt in with LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM=1 or \
295 `[proxy] allow_insecure_http_upstream = true`)"
296 ));
297 }
298 let Some(host_segment) = normalized.strip_prefix("https://") else {
299 return Err(format!(
300 "upstream URL must start with http:// or https://: {normalized}"
301 ));
302 };
303
304 let host = host_segment.split('/').next().unwrap_or("");
305 let host_no_port = host.split(':').next().unwrap_or(host);
306 if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
307 || std::env::var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM").is_ok()
308 {
309 Ok(normalized)
310 } else {
311 Err(format!(
312 "upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (set LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 to override)"
313 ))
314 }
315}
316
317pub fn is_local_proxy_url(value: &str) -> bool {
318 let n = normalize_url(value);
319 n.starts_with("http://127.0.0.1:")
320 || n.starts_with("http://localhost:")
321 || n.starts_with("http://[::1]:")
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn loopback_http_is_always_allowed() {
330 assert_eq!(
331 validate_upstream_url("http://127.0.0.1:4444", false).unwrap(),
332 "http://127.0.0.1:4444"
333 );
334 assert_eq!(
335 validate_upstream_url("http://localhost:2455/", false).unwrap(),
336 "http://localhost:2455"
337 );
338 }
339
340 #[test]
341 fn https_allowlisted_host_is_allowed() {
342 assert_eq!(
343 validate_upstream_url("https://api.openai.com", false).unwrap(),
344 "https://api.openai.com"
345 );
346 }
347
348 #[test]
349 fn non_loopback_http_is_rejected_without_optin() {
350 let err = validate_upstream_url("http://host.docker.internal:2455", false).unwrap_err();
351 assert!(
355 err.contains("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM"),
356 "hint must name the working opt-in, got: {err}"
357 );
358 }
359
360 #[test]
361 fn non_loopback_http_is_allowed_with_optin() {
362 assert_eq!(
363 validate_upstream_url("http://host.docker.internal:2455", true).unwrap(),
364 "http://host.docker.internal:2455"
365 );
366 }
367
368 #[test]
369 fn unknown_scheme_is_rejected() {
370 assert!(validate_upstream_url("ftp://example.com", true).is_err());
371 }
372
373 #[test]
374 fn config_flag_enables_insecure_http_optin() {
375 let cfg = ProxyConfig {
378 allow_insecure_http_upstream: Some(true),
379 ..Default::default()
380 };
381 assert!(cfg.allows_insecure_http_upstream());
382 }
383
384 #[test]
388 fn resolve_all_disk_uses_config_then_default() {
389 let cfg = ProxyConfig {
390 openai_upstream: Some("http://127.0.0.1:19101".into()),
391 ..Default::default()
392 };
393 let up = cfg.resolve_all_disk();
394 assert_eq!(up.openai, "http://127.0.0.1:19101");
395 assert_eq!(up.anthropic, "https://api.anthropic.com");
396 assert_eq!(up.gemini, "https://generativelanguage.googleapis.com");
397 }
398
399 #[test]
400 fn resolve_all_disk_normalizes_trailing_slash() {
401 let cfg = ProxyConfig {
402 openai_upstream: Some("http://127.0.0.1:19101/".into()),
403 ..Default::default()
404 };
405 assert_eq!(cfg.resolve_all_disk().openai, "http://127.0.0.1:19101");
406 }
407
408 #[test]
409 fn refresh_keeps_last_good_on_invalid_config() {
410 let _lock = crate::core::data_dir::test_env_lock();
413 crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
414
415 let last = Upstreams {
417 anthropic: "https://api.anthropic.com".into(),
418 openai: "http://127.0.0.1:19101".into(),
419 gemini: "https://generativelanguage.googleapis.com".into(),
420 };
421 let cfg = ProxyConfig {
422 openai_upstream: Some("not-a-valid-url".into()),
423 ..Default::default()
424 };
425 assert_eq!(
426 cfg.refresh_upstreams(&last).openai,
427 "http://127.0.0.1:19101",
428 "invalid upstream → keep last good, never silently fall to default"
429 );
430 }
431
432 #[test]
433 fn refresh_adopts_valid_config_change() {
434 let _lock = crate::core::data_dir::test_env_lock();
435 crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
436
437 let last = Upstreams {
438 anthropic: "https://api.anthropic.com".into(),
439 openai: "http://127.0.0.1:19101".into(),
440 gemini: "https://generativelanguage.googleapis.com".into(),
441 };
442 let cfg = ProxyConfig {
443 openai_upstream: Some("http://127.0.0.1:19102".into()),
444 ..Default::default()
445 };
446 assert_eq!(
447 cfg.refresh_upstreams(&last).openai,
448 "http://127.0.0.1:19102"
449 );
450 }
451
452 #[test]
453 fn diagnose_drift_env_set_but_proxy_serves_other() {
454 assert_eq!(
457 diagnose_drift(
458 Some("http://127.0.0.1:2455"),
459 "https://api.openai.com",
460 "https://api.openai.com"
461 ),
462 Some(UpstreamDrift::EnvNotApplied)
463 );
464 }
465
466 #[test]
467 fn diagnose_drift_env_consistent_is_in_sync() {
468 assert_eq!(
470 diagnose_drift(
471 Some("http://127.0.0.1:2455"),
472 "https://api.openai.com",
473 "http://127.0.0.1:2455"
474 ),
475 None
476 );
477 }
478
479 #[test]
480 fn diagnose_drift_config_changed_needs_restart() {
481 assert_eq!(
482 diagnose_drift(None, "http://127.0.0.1:2455", "https://api.openai.com"),
483 Some(UpstreamDrift::ConfigNotApplied)
484 );
485 }
486
487 #[test]
488 fn diagnose_drift_in_sync() {
489 assert_eq!(
490 diagnose_drift(None, "https://api.openai.com", "https://api.openai.com"),
491 None
492 );
493 }
494}