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>,
pub meter_openai_usage: 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 meters_openai_usage(&self) -> bool {
self.meter_openai_usage.unwrap_or(true)
}
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)
}
fn provider_spec(&self, provider: ProxyProvider) -> (&'static str, Option<&str>, &'static str) {
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",
),
}
}
fn resolve_upstream_checked(&self, provider: ProxyProvider) -> Result<String, String> {
self.resolve_upstream_inner(provider, true)
}
fn resolve_upstream_inner(
&self,
provider: ProxyProvider,
use_env: bool,
) -> Result<String, String> {
let (env_var, config_val, default) = self.provider_spec(provider);
let env_val = if use_env {
std::env::var(env_var)
.ok()
.and_then(|v| normalize_url_opt(&v))
} else {
None
};
let candidate = env_val.or_else(|| config_val.and_then(normalize_url_opt));
match candidate {
None => Ok(normalize_url(default)),
Some(url) => validate_upstream_url(&url, self.allows_insecure_http_upstream()),
}
}
pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
match self.resolve_upstream_checked(provider) {
Ok(url) => url,
Err(e) => {
tracing::warn!("upstream validation failed, using default: {e}");
normalize_url(self.provider_spec(provider).2)
}
}
}
pub fn resolve_all(&self) -> Upstreams {
Upstreams {
anthropic: self.resolve_upstream(ProxyProvider::Anthropic),
openai: self.resolve_upstream(ProxyProvider::OpenAi),
gemini: self.resolve_upstream(ProxyProvider::Gemini),
}
}
pub fn resolve_all_disk(&self) -> Upstreams {
let pick = |provider: ProxyProvider| {
self.resolve_upstream_inner(provider, false)
.unwrap_or_else(|_| normalize_url(self.provider_spec(provider).2))
};
Upstreams {
anthropic: pick(ProxyProvider::Anthropic),
openai: pick(ProxyProvider::OpenAi),
gemini: pick(ProxyProvider::Gemini),
}
}
pub fn refresh_upstreams(&self, last: &Upstreams) -> Upstreams {
let keep = |provider: ProxyProvider, prev: &str| {
self.resolve_upstream_checked(provider).unwrap_or_else(|e| {
tracing::warn!("upstream invalid, keeping {prev}: {e}");
prev.to_string()
})
};
Upstreams {
anthropic: keep(ProxyProvider::Anthropic, &last.anthropic),
openai: keep(ProxyProvider::OpenAi, &last.openai),
gemini: keep(ProxyProvider::Gemini, &last.gemini),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Upstreams {
pub anthropic: String,
pub openai: String,
pub gemini: String,
}
#[derive(Debug, Clone, Copy)]
pub enum ProxyProvider {
Anthropic,
OpenAi,
Gemini,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpstreamDrift {
EnvNotApplied,
ConfigNotApplied,
}
pub fn env_upstream_override(provider: ProxyProvider) -> Option<String> {
let var = match provider {
ProxyProvider::Anthropic => "LEAN_CTX_ANTHROPIC_UPSTREAM",
ProxyProvider::OpenAi => "LEAN_CTX_OPENAI_UPSTREAM",
ProxyProvider::Gemini => "LEAN_CTX_GEMINI_UPSTREAM",
};
std::env::var(var).ok().and_then(|v| normalize_url_opt(&v))
}
pub fn diagnose_drift(env: Option<&str>, disk: &str, live: &str) -> Option<UpstreamDrift> {
if let Some(env) = env {
return (env != live).then_some(UpstreamDrift::EnvNotApplied);
}
(disk != live).then_some(UpstreamDrift::ConfigNotApplied)
}
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());
}
#[test]
fn resolve_all_disk_uses_config_then_default() {
let cfg = ProxyConfig {
openai_upstream: Some("http://127.0.0.1:19101".into()),
..Default::default()
};
let up = cfg.resolve_all_disk();
assert_eq!(up.openai, "http://127.0.0.1:19101");
assert_eq!(up.anthropic, "https://api.anthropic.com");
assert_eq!(up.gemini, "https://generativelanguage.googleapis.com");
}
#[test]
fn resolve_all_disk_normalizes_trailing_slash() {
let cfg = ProxyConfig {
openai_upstream: Some("http://127.0.0.1:19101/".into()),
..Default::default()
};
assert_eq!(cfg.resolve_all_disk().openai, "http://127.0.0.1:19101");
}
#[test]
fn refresh_keeps_last_good_on_invalid_config() {
let _lock = crate::core::data_dir::test_env_lock();
crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
let last = Upstreams {
anthropic: "https://api.anthropic.com".into(),
openai: "http://127.0.0.1:19101".into(),
gemini: "https://generativelanguage.googleapis.com".into(),
};
let cfg = ProxyConfig {
openai_upstream: Some("not-a-valid-url".into()),
..Default::default()
};
assert_eq!(
cfg.refresh_upstreams(&last).openai,
"http://127.0.0.1:19101",
"invalid upstream → keep last good, never silently fall to default"
);
}
#[test]
fn refresh_adopts_valid_config_change() {
let _lock = crate::core::data_dir::test_env_lock();
crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
let last = Upstreams {
anthropic: "https://api.anthropic.com".into(),
openai: "http://127.0.0.1:19101".into(),
gemini: "https://generativelanguage.googleapis.com".into(),
};
let cfg = ProxyConfig {
openai_upstream: Some("http://127.0.0.1:19102".into()),
..Default::default()
};
assert_eq!(
cfg.refresh_upstreams(&last).openai,
"http://127.0.0.1:19102"
);
}
#[test]
fn diagnose_drift_env_set_but_proxy_serves_other() {
assert_eq!(
diagnose_drift(
Some("http://127.0.0.1:2455"),
"https://api.openai.com",
"https://api.openai.com"
),
Some(UpstreamDrift::EnvNotApplied)
);
}
#[test]
fn diagnose_drift_env_consistent_is_in_sync() {
assert_eq!(
diagnose_drift(
Some("http://127.0.0.1:2455"),
"https://api.openai.com",
"http://127.0.0.1:2455"
),
None
);
}
#[test]
fn diagnose_drift_config_changed_needs_restart() {
assert_eq!(
diagnose_drift(None, "http://127.0.0.1:2455", "https://api.openai.com"),
Some(UpstreamDrift::ConfigNotApplied)
);
}
#[test]
fn diagnose_drift_in_sync() {
assert_eq!(
diagnose_drift(None, "https://api.openai.com", "https://api.openai.com"),
None
);
}
}