use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ProxyConfig {
pub anthropic_upstream: Option<String>,
pub openai_upstream: Option<String>,
pub gemini_upstream: Option<String>,
pub history_mode: Option<String>,
pub allow_insecure_http_upstream: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HistoryMode {
CacheAware,
Rolling,
Off,
}
impl ProxyConfig {
pub fn resolved_history_mode(&self) -> HistoryMode {
let raw = std::env::var("LEAN_CTX_PROXY_HISTORY_MODE")
.ok()
.or_else(|| self.history_mode.clone());
match raw.as_deref().map(str::trim) {
Some(s) if s.eq_ignore_ascii_case("rolling") => HistoryMode::Rolling,
Some(s) if s.eq_ignore_ascii_case("off") => HistoryMode::Off,
_ => HistoryMode::CacheAware,
}
}
pub fn allows_insecure_http_upstream(&self) -> bool {
std::env::var("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM").is_ok()
|| self.allow_insecure_http_upstream.unwrap_or(false)
}
pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
let (env_var, config_val, default) = match provider {
ProxyProvider::Anthropic => (
"LEAN_CTX_ANTHROPIC_UPSTREAM",
self.anthropic_upstream.as_deref(),
"https://api.anthropic.com",
),
ProxyProvider::OpenAi => (
"LEAN_CTX_OPENAI_UPSTREAM",
self.openai_upstream.as_deref(),
"https://api.openai.com",
),
ProxyProvider::Gemini => (
"LEAN_CTX_GEMINI_UPSTREAM",
self.gemini_upstream.as_deref(),
"https://generativelanguage.googleapis.com",
),
};
let resolved = std::env::var(env_var)
.ok()
.and_then(|v| normalize_url_opt(&v))
.or_else(|| config_val.and_then(normalize_url_opt))
.unwrap_or_else(|| normalize_url(default));
match validate_upstream_url(&resolved, self.allows_insecure_http_upstream()) {
Ok(url) => url,
Err(e) => {
tracing::warn!("upstream validation failed, using default: {e}");
normalize_url(default)
}
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum ProxyProvider {
Anthropic,
OpenAi,
Gemini,
}
pub fn normalize_url(value: &str) -> String {
value.trim().trim_end_matches('/').to_string()
}
pub fn normalize_url_opt(value: &str) -> Option<String> {
let trimmed = normalize_url(value);
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
"api.anthropic.com",
"api.openai.com",
"generativelanguage.googleapis.com",
];
pub(super) fn validate_upstream_url(
url: &str,
allow_insecure_http: bool,
) -> Result<String, String> {
let normalized = normalize_url(url);
if is_local_proxy_url(&normalized) {
return Ok(normalized);
}
if normalized.starts_with("http://") {
if allow_insecure_http {
return Ok(normalized);
}
return Err(format!(
"upstream URL must use HTTPS: {normalized} (for a trusted local-network HTTP \
upstream opt in with LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM=1 or \
`[proxy] allow_insecure_http_upstream = true`)"
));
}
let Some(host_segment) = normalized.strip_prefix("https://") else {
return Err(format!(
"upstream URL must start with http:// or https://: {normalized}"
));
};
let host = host_segment.split('/').next().unwrap_or("");
let host_no_port = host.split(':').next().unwrap_or(host);
if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
|| std::env::var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM").is_ok()
{
Ok(normalized)
} else {
Err(format!(
"upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (set LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 to override)"
))
}
}
pub fn is_local_proxy_url(value: &str) -> bool {
let n = normalize_url(value);
n.starts_with("http://127.0.0.1:")
|| n.starts_with("http://localhost:")
|| n.starts_with("http://[::1]:")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loopback_http_is_always_allowed() {
assert_eq!(
validate_upstream_url("http://127.0.0.1:4444", false).unwrap(),
"http://127.0.0.1:4444"
);
assert_eq!(
validate_upstream_url("http://localhost:2455/", false).unwrap(),
"http://localhost:2455"
);
}
#[test]
fn https_allowlisted_host_is_allowed() {
assert_eq!(
validate_upstream_url("https://api.openai.com", false).unwrap(),
"https://api.openai.com"
);
}
#[test]
fn non_loopback_http_is_rejected_without_optin() {
let err = validate_upstream_url("http://host.docker.internal:2455", false).unwrap_err();
assert!(
err.contains("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM"),
"hint must name the working opt-in, got: {err}"
);
}
#[test]
fn non_loopback_http_is_allowed_with_optin() {
assert_eq!(
validate_upstream_url("http://host.docker.internal:2455", true).unwrap(),
"http://host.docker.internal:2455"
);
}
#[test]
fn unknown_scheme_is_rejected() {
assert!(validate_upstream_url("ftp://example.com", true).is_err());
}
#[test]
fn config_flag_enables_insecure_http_optin() {
let cfg = ProxyConfig {
allow_insecure_http_upstream: Some(true),
..Default::default()
};
assert!(cfg.allows_insecure_http_upstream());
}
}