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}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum HistoryMode {
27 CacheAware,
31 Rolling,
34 Off,
37}
38
39impl ProxyConfig {
40 pub fn resolved_history_mode(&self) -> HistoryMode {
45 let raw = std::env::var("LEAN_CTX_PROXY_HISTORY_MODE")
46 .ok()
47 .or_else(|| self.history_mode.clone());
48 match raw.as_deref().map(str::trim) {
49 Some(s) if s.eq_ignore_ascii_case("rolling") => HistoryMode::Rolling,
50 Some(s) if s.eq_ignore_ascii_case("off") => HistoryMode::Off,
51 _ => HistoryMode::CacheAware,
52 }
53 }
54
55 pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
56 let (env_var, config_val, default) = match provider {
57 ProxyProvider::Anthropic => (
58 "LEAN_CTX_ANTHROPIC_UPSTREAM",
59 self.anthropic_upstream.as_deref(),
60 "https://api.anthropic.com",
61 ),
62 ProxyProvider::OpenAi => (
63 "LEAN_CTX_OPENAI_UPSTREAM",
64 self.openai_upstream.as_deref(),
65 "https://api.openai.com",
66 ),
67 ProxyProvider::Gemini => (
68 "LEAN_CTX_GEMINI_UPSTREAM",
69 self.gemini_upstream.as_deref(),
70 "https://generativelanguage.googleapis.com",
71 ),
72 };
73 let resolved = std::env::var(env_var)
74 .ok()
75 .and_then(|v| normalize_url_opt(&v))
76 .or_else(|| config_val.and_then(normalize_url_opt))
77 .unwrap_or_else(|| normalize_url(default));
78 match validate_upstream_url(&resolved) {
79 Ok(url) => url,
80 Err(e) => {
81 tracing::warn!("upstream validation failed, using default: {e}");
82 normalize_url(default)
83 }
84 }
85 }
86}
87
88#[derive(Debug, Clone, Copy)]
89pub enum ProxyProvider {
90 Anthropic,
91 OpenAi,
92 Gemini,
93}
94
95pub fn normalize_url(value: &str) -> String {
96 value.trim().trim_end_matches('/').to_string()
97}
98
99pub fn normalize_url_opt(value: &str) -> Option<String> {
100 let trimmed = normalize_url(value);
101 if trimmed.is_empty() {
102 None
103 } else {
104 Some(trimmed)
105 }
106}
107
108const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
109 "api.anthropic.com",
110 "api.openai.com",
111 "generativelanguage.googleapis.com",
112];
113
114pub(super) fn validate_upstream_url(url: &str) -> Result<String, String> {
115 let normalized = normalize_url(url);
116 if is_local_proxy_url(&normalized) {
117 return Ok(normalized);
118 }
119 if !normalized.starts_with("https://") {
120 return Err(format!(
121 "upstream URL must use HTTPS: {normalized} (set LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 to override)"
122 ));
123 }
124 let host = normalized
125 .strip_prefix("https://")
126 .unwrap_or(&normalized)
127 .split('/')
128 .next()
129 .unwrap_or("");
130 let host_no_port = host.split(':').next().unwrap_or(host);
131 if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
132 || std::env::var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM").is_ok()
133 {
134 Ok(normalized)
135 } else {
136 Err(format!(
137 "upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (set LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 to override)"
138 ))
139 }
140}
141
142pub fn is_local_proxy_url(value: &str) -> bool {
143 let n = normalize_url(value);
144 n.starts_with("http://127.0.0.1:")
145 || n.starts_with("http://localhost:")
146 || n.starts_with("http://[::1]:")
147}