lean_ctx/core/config/
proxy_upstream.rs1use super::ResolvedProvider;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct Upstreams {
9 pub anthropic: String,
10 pub openai: String,
11 pub chatgpt: String,
12 pub gemini: String,
13 pub providers: Vec<ResolvedProvider>,
16}
17
18impl Upstreams {
19 #[must_use]
22 pub fn provider_by_id(&self, id: &str) -> Option<&ResolvedProvider> {
23 self.providers.iter().find(|p| p.id == id)
24 }
25}
26
27#[derive(Debug, Clone, Copy)]
28pub enum ProxyProvider {
29 Anthropic,
30 OpenAi,
31 ChatGpt,
32 Gemini,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum UpstreamDrift {
38 EnvNotApplied,
45 ConfigNotApplied,
49}
50
51pub fn env_upstream_override(provider: ProxyProvider) -> Option<String> {
55 let var = match provider {
56 ProxyProvider::Anthropic => "LEAN_CTX_ANTHROPIC_UPSTREAM",
57 ProxyProvider::OpenAi => "LEAN_CTX_OPENAI_UPSTREAM",
58 ProxyProvider::ChatGpt => "LEAN_CTX_CHATGPT_UPSTREAM",
59 ProxyProvider::Gemini => "LEAN_CTX_GEMINI_UPSTREAM",
60 };
61 std::env::var(var).ok().and_then(|v| normalize_url_opt(&v))
62}
63
64pub fn diagnose_drift(env: Option<&str>, disk: &str, live: &str) -> Option<UpstreamDrift> {
68 if let Some(env) = env {
69 return (env != live).then_some(UpstreamDrift::EnvNotApplied);
73 }
74 (disk != live).then_some(UpstreamDrift::ConfigNotApplied)
76}
77
78pub fn normalize_url(value: &str) -> String {
79 value.trim().trim_end_matches('/').to_string()
80}
81
82pub fn normalize_url_opt(value: &str) -> Option<String> {
83 let trimmed = normalize_url(value);
84 if trimmed.is_empty() {
85 None
86 } else {
87 Some(trimmed)
88 }
89}
90
91const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
92 "api.anthropic.com",
93 "api.openai.com",
94 "chatgpt.com",
95 "generativelanguage.googleapis.com",
96 "api.x.ai",
99 "cli-chat-proxy.grok.com",
100 "api.commandcode.ai",
103];
104
105pub(in crate::core::config) fn validate_upstream_url(
106 url: &str,
107 allow_insecure_http: bool,
108 allow_custom_host: bool,
109) -> Result<String, String> {
110 let normalized = normalize_url(url);
111 if is_local_proxy_url(&normalized) {
113 return Ok(normalized);
114 }
115
116 if normalized.starts_with("http://") {
123 if allow_insecure_http {
124 return Ok(normalized);
125 }
126 return Err(format!(
127 "upstream URL must use HTTPS: {normalized} (for a trusted local-network HTTP \
128 upstream opt in with LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM=1 or \
129 `[proxy] allow_insecure_http_upstream = true`)"
130 ));
131 }
132 let Some(host_segment) = normalized.strip_prefix("https://") else {
133 return Err(format!(
134 "upstream URL must start with http:// or https://: {normalized}"
135 ));
136 };
137
138 let host = host_segment.split('/').next().unwrap_or("");
139 let host_no_port = host.split(':').next().unwrap_or(host);
140 if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port) || allow_custom_host {
141 Ok(normalized)
142 } else {
143 Err(format!(
144 "upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (for a \
145 custom upstream host opt in with LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 or \
146 `[proxy] allow_custom_upstream = true`)"
147 ))
148 }
149}
150
151pub(super) fn is_custom_upstream_host(url: &str) -> bool {
156 let n = normalize_url(url);
157 if is_local_proxy_url(&n) {
158 return false;
159 }
160 let Some(host_segment) = n.strip_prefix("https://") else {
161 return false;
162 };
163 let host = host_segment.split('/').next().unwrap_or("");
164 let host_no_port = host.split(':').next().unwrap_or(host);
165 !host_no_port.is_empty() && !ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
166}
167
168pub fn is_local_proxy_url(value: &str) -> bool {
169 let n = normalize_url(value);
170 n.starts_with("http://127.0.0.1:")
171 || n.starts_with("http://localhost:")
172 || n.starts_with("http://[::1]:")
173}