Skip to main content

lean_ctx/core/config/
proxy.rs

1//! API proxy upstream overrides (`config.toml`).
2
3use serde::{Deserialize, Serialize};
4
5/// API proxy upstream overrides. `None` = use provider default.
6#[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    /// History-pruning strategy for proxied chat requests.
13    /// "cache-aware" (default) | "rolling" | "off". See [`HistoryMode`].
14    pub history_mode: Option<String>,
15    /// Allow a non-loopback plaintext `http://` upstream (trusted local network
16    /// only). Opt-in; see [`ProxyConfig::allows_insecure_http_upstream`]. (#440)
17    pub allow_insecure_http_upstream: Option<bool>,
18}
19
20/// How the proxy prunes old tool results from conversation history.
21///
22/// Provider prompt caches (Anthropic `cache_control`, OpenAI automatic prompt
23/// caching) bill cached prefix tokens at a fraction of the base rate but only
24/// match *exact* prefixes. Any mutation whose position depends on the current
25/// conversation length (a rolling window) rewrites a previously-stable message
26/// every turn, invalidating the cache from that point — turning cheap cache
27/// reads into full-price writes.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum HistoryMode {
30    /// Prune only at frozen generation boundaries that advance in large,
31    /// deterministic steps. Between jumps the request prefix is byte-stable,
32    /// so provider prompt caches keep hitting. Default.
33    CacheAware,
34    /// Legacy behaviour: summarize everything older than the last N messages.
35    /// Maximum raw-token reduction, but defeats provider prompt caching.
36    Rolling,
37    /// Never prune history (tool-result compression still applies — it is
38    /// content-deterministic and therefore prefix-stable).
39    Off,
40}
41
42impl ProxyConfig {
43    /// Resolved history mode: `LEAN_CTX_PROXY_HISTORY_MODE` env var wins,
44    /// then `[proxy].history_mode` in config.toml, then cache-aware.
45    /// Unknown values fall back to the default so a typo can never silently
46    /// re-enable the cache-hostile rolling mode.
47    pub fn resolved_history_mode(&self) -> HistoryMode {
48        let raw = std::env::var("LEAN_CTX_PROXY_HISTORY_MODE")
49            .ok()
50            .or_else(|| self.history_mode.clone());
51        match raw.as_deref().map(str::trim) {
52            Some(s) if s.eq_ignore_ascii_case("rolling") => HistoryMode::Rolling,
53            Some(s) if s.eq_ignore_ascii_case("off") => HistoryMode::Off,
54            _ => HistoryMode::CacheAware,
55        }
56    }
57
58    /// Whether a non-loopback plaintext `http://` upstream is allowed. Opt-in
59    /// only — a deliberate downgrade for a trusted local-network service such as
60    /// `http://host.docker.internal:2455` in front of codex-lb (#440).
61    /// `LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM` (any value) wins, then
62    /// `[proxy] allow_insecure_http_upstream` in config.toml, default `false`.
63    pub fn allows_insecure_http_upstream(&self) -> bool {
64        std::env::var("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM").is_ok()
65            || self.allow_insecure_http_upstream.unwrap_or(false)
66    }
67
68    pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
69        let (env_var, config_val, default) = match provider {
70            ProxyProvider::Anthropic => (
71                "LEAN_CTX_ANTHROPIC_UPSTREAM",
72                self.anthropic_upstream.as_deref(),
73                "https://api.anthropic.com",
74            ),
75            ProxyProvider::OpenAi => (
76                "LEAN_CTX_OPENAI_UPSTREAM",
77                self.openai_upstream.as_deref(),
78                "https://api.openai.com",
79            ),
80            ProxyProvider::Gemini => (
81                "LEAN_CTX_GEMINI_UPSTREAM",
82                self.gemini_upstream.as_deref(),
83                "https://generativelanguage.googleapis.com",
84            ),
85        };
86        let resolved = std::env::var(env_var)
87            .ok()
88            .and_then(|v| normalize_url_opt(&v))
89            .or_else(|| config_val.and_then(normalize_url_opt))
90            .unwrap_or_else(|| normalize_url(default));
91        match validate_upstream_url(&resolved, self.allows_insecure_http_upstream()) {
92            Ok(url) => url,
93            Err(e) => {
94                tracing::warn!("upstream validation failed, using default: {e}");
95                normalize_url(default)
96            }
97        }
98    }
99}
100
101#[derive(Debug, Clone, Copy)]
102pub enum ProxyProvider {
103    Anthropic,
104    OpenAi,
105    Gemini,
106}
107
108pub fn normalize_url(value: &str) -> String {
109    value.trim().trim_end_matches('/').to_string()
110}
111
112pub fn normalize_url_opt(value: &str) -> Option<String> {
113    let trimmed = normalize_url(value);
114    if trimmed.is_empty() {
115        None
116    } else {
117        Some(trimmed)
118    }
119}
120
121const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
122    "api.anthropic.com",
123    "api.openai.com",
124    "generativelanguage.googleapis.com",
125];
126
127pub(super) fn validate_upstream_url(
128    url: &str,
129    allow_insecure_http: bool,
130) -> Result<String, String> {
131    let normalized = normalize_url(url);
132    // Loopback HTTP never leaves the machine — always allowed.
133    if is_local_proxy_url(&normalized) {
134        return Ok(normalized);
135    }
136
137    // A non-loopback plaintext `http://` upstream is reachable only through the
138    // explicit opt-in (#440). The old code rejected it on the HTTPS check *before*
139    // any override could apply, and pointed at `LEAN_CTX_ALLOW_CUSTOM_UPSTREAM`,
140    // which never lifted the scheme restriction. Handle it up front: the opt-in
141    // implies a deliberate custom host on a trusted local network, so it needs no
142    // separate allowlist check; otherwise give a hint that actually works.
143    if normalized.starts_with("http://") {
144        if allow_insecure_http {
145            return Ok(normalized);
146        }
147        return Err(format!(
148            "upstream URL must use HTTPS: {normalized} (for a trusted local-network HTTP \
149             upstream opt in with LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM=1 or \
150             `[proxy] allow_insecure_http_upstream = true`)"
151        ));
152    }
153    let Some(host_segment) = normalized.strip_prefix("https://") else {
154        return Err(format!(
155            "upstream URL must start with http:// or https://: {normalized}"
156        ));
157    };
158
159    let host = host_segment.split('/').next().unwrap_or("");
160    let host_no_port = host.split(':').next().unwrap_or(host);
161    if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
162        || std::env::var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM").is_ok()
163    {
164        Ok(normalized)
165    } else {
166        Err(format!(
167            "upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (set LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 to override)"
168        ))
169    }
170}
171
172pub fn is_local_proxy_url(value: &str) -> bool {
173    let n = normalize_url(value);
174    n.starts_with("http://127.0.0.1:")
175        || n.starts_with("http://localhost:")
176        || n.starts_with("http://[::1]:")
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn loopback_http_is_always_allowed() {
185        assert_eq!(
186            validate_upstream_url("http://127.0.0.1:4444", false).unwrap(),
187            "http://127.0.0.1:4444"
188        );
189        assert_eq!(
190            validate_upstream_url("http://localhost:2455/", false).unwrap(),
191            "http://localhost:2455"
192        );
193    }
194
195    #[test]
196    fn https_allowlisted_host_is_allowed() {
197        assert_eq!(
198            validate_upstream_url("https://api.openai.com", false).unwrap(),
199            "https://api.openai.com"
200        );
201    }
202
203    #[test]
204    fn non_loopback_http_is_rejected_without_optin() {
205        let err = validate_upstream_url("http://host.docker.internal:2455", false).unwrap_err();
206        // The hint must point at the flag that actually lifts the scheme check
207        // (#440). The old message pointed at LEAN_CTX_ALLOW_CUSTOM_UPSTREAM,
208        // which never bypassed the HTTPS requirement.
209        assert!(
210            err.contains("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM"),
211            "hint must name the working opt-in, got: {err}"
212        );
213    }
214
215    #[test]
216    fn non_loopback_http_is_allowed_with_optin() {
217        assert_eq!(
218            validate_upstream_url("http://host.docker.internal:2455", true).unwrap(),
219            "http://host.docker.internal:2455"
220        );
221    }
222
223    #[test]
224    fn unknown_scheme_is_rejected() {
225        assert!(validate_upstream_url("ftp://example.com", true).is_err());
226    }
227
228    #[test]
229    fn config_flag_enables_insecure_http_optin() {
230        // `Some(true)` resolves to `true` regardless of the environment, so this
231        // assertion is robust without mutating process-global env vars.
232        let cfg = ProxyConfig {
233            allow_insecure_http_upstream: Some(true),
234            ..Default::default()
235        };
236        assert!(cfg.allows_insecure_http_upstream());
237    }
238}