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    /// Inject `stream_options.include_usage = true` into streamed OpenAI Chat
19    /// Completions so the final chunk reports real token usage for the measured
20    /// spend meter. Default on; set `false` for a client that mishandles the
21    /// trailing usage chunk. Anthropic/Gemini/OpenAI-Responses report usage
22    /// without any request change, so this only affects Chat Completions.
23    pub meter_openai_usage: Option<bool>,
24}
25
26/// How the proxy prunes old tool results from conversation history.
27///
28/// Provider prompt caches (Anthropic `cache_control`, OpenAI automatic prompt
29/// caching) bill cached prefix tokens at a fraction of the base rate but only
30/// match *exact* prefixes. Any mutation whose position depends on the current
31/// conversation length (a rolling window) rewrites a previously-stable message
32/// every turn, invalidating the cache from that point — turning cheap cache
33/// reads into full-price writes.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum HistoryMode {
36    /// Prune only at frozen generation boundaries that advance in large,
37    /// deterministic steps. Between jumps the request prefix is byte-stable,
38    /// so provider prompt caches keep hitting. Content the client has marked
39    /// with a `cache_control` breakpoint is never rewritten, so an advancing
40    /// boundary can no longer invalidate the already-cached prefix (#448).
41    /// Default.
42    CacheAware,
43    /// Legacy behaviour: summarize everything older than the last N messages.
44    /// Maximum raw-token reduction, but defeats provider prompt caching.
45    Rolling,
46    /// Never prune history (tool-result compression still applies — it is
47    /// content-deterministic and therefore prefix-stable).
48    Off,
49}
50
51impl ProxyConfig {
52    /// Resolved history mode: `LEAN_CTX_PROXY_HISTORY_MODE` env var wins,
53    /// then `[proxy].history_mode` in config.toml, then cache-aware.
54    /// Unknown values fall back to the default so a typo can never silently
55    /// re-enable the cache-hostile rolling mode.
56    pub fn resolved_history_mode(&self) -> HistoryMode {
57        let raw = std::env::var("LEAN_CTX_PROXY_HISTORY_MODE")
58            .ok()
59            .or_else(|| self.history_mode.clone());
60        match raw.as_deref().map(str::trim) {
61            Some(s) if s.eq_ignore_ascii_case("rolling") => HistoryMode::Rolling,
62            Some(s) if s.eq_ignore_ascii_case("off") => HistoryMode::Off,
63            _ => HistoryMode::CacheAware,
64        }
65    }
66
67    /// Whether the proxy injects `stream_options.include_usage` into streamed
68    /// OpenAI Chat Completions to meter real spend. `[proxy] meter_openai_usage`
69    /// in config.toml, default `true`.
70    pub fn meters_openai_usage(&self) -> bool {
71        self.meter_openai_usage.unwrap_or(true)
72    }
73
74    /// Whether a non-loopback plaintext `http://` upstream is allowed. Opt-in
75    /// only — a deliberate downgrade for a trusted local-network service such as
76    /// `http://host.docker.internal:2455` in front of codex-lb (#440).
77    /// `LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM` (any value) wins, then
78    /// `[proxy] allow_insecure_http_upstream` in config.toml, default `false`.
79    pub fn allows_insecure_http_upstream(&self) -> bool {
80        std::env::var("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM").is_ok()
81            || self.allow_insecure_http_upstream.unwrap_or(false)
82    }
83
84    /// `(env var, configured value, provider default)` for one provider.
85    fn provider_spec(&self, provider: ProxyProvider) -> (&'static str, Option<&str>, &'static str) {
86        match provider {
87            ProxyProvider::Anthropic => (
88                "LEAN_CTX_ANTHROPIC_UPSTREAM",
89                self.anthropic_upstream.as_deref(),
90                "https://api.anthropic.com",
91            ),
92            ProxyProvider::OpenAi => (
93                "LEAN_CTX_OPENAI_UPSTREAM",
94                self.openai_upstream.as_deref(),
95                "https://api.openai.com",
96            ),
97            ProxyProvider::Gemini => (
98                "LEAN_CTX_GEMINI_UPSTREAM",
99                self.gemini_upstream.as_deref(),
100                "https://generativelanguage.googleapis.com",
101            ),
102        }
103    }
104
105    /// Resolve one upstream with precedence `LEAN_CTX_*_UPSTREAM` env var >
106    /// `[proxy].*_upstream` (config.toml) > provider default.
107    ///
108    /// Returns `Err` when a value is *present but invalid* so a live reload can
109    /// keep the last good value instead of silently rerouting to the default; an
110    /// *absent* value resolves to the provider default (`Ok`).
111    fn resolve_upstream_checked(&self, provider: ProxyProvider) -> Result<String, String> {
112        self.resolve_upstream_inner(provider, true)
113    }
114
115    /// Shared resolver for [`resolve_upstream_checked`] and the disk-only view.
116    /// `use_env = false` ignores the `LEAN_CTX_*_UPSTREAM` override and yields
117    /// the config.toml truth a freshly (re)started managed proxy would serve.
118    fn resolve_upstream_inner(
119        &self,
120        provider: ProxyProvider,
121        use_env: bool,
122    ) -> Result<String, String> {
123        let (env_var, config_val, default) = self.provider_spec(provider);
124        let env_val = if use_env {
125            std::env::var(env_var)
126                .ok()
127                .and_then(|v| normalize_url_opt(&v))
128        } else {
129            None
130        };
131        let candidate = env_val.or_else(|| config_val.and_then(normalize_url_opt));
132        match candidate {
133            None => Ok(normalize_url(default)),
134            Some(url) => validate_upstream_url(&url, self.allows_insecure_http_upstream()),
135        }
136    }
137
138    /// Effective upstream for a provider (env > config > default). An invalid
139    /// configured/env value falls back to the provider default (logged) — the
140    /// safe choice at startup.
141    pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
142        match self.resolve_upstream_checked(provider) {
143            Ok(url) => url,
144            Err(e) => {
145                tracing::warn!("upstream validation failed, using default: {e}");
146                normalize_url(self.provider_spec(provider).2)
147            }
148        }
149    }
150
151    /// Resolve all three upstreams at once (startup snapshot, env-aware).
152    pub fn resolve_all(&self) -> Upstreams {
153        Upstreams {
154            anthropic: self.resolve_upstream(ProxyProvider::Anthropic),
155            openai: self.resolve_upstream(ProxyProvider::OpenAi),
156            gemini: self.resolve_upstream(ProxyProvider::Gemini),
157        }
158    }
159
160    /// Resolve all upstreams from config.toml only (ignoring `LEAN_CTX_*` env) —
161    /// the values a freshly (re)started managed proxy would serve. Used by
162    /// status/doctor to detect drift from a running proxy's live upstream (#449).
163    pub fn resolve_all_disk(&self) -> Upstreams {
164        let pick = |provider: ProxyProvider| {
165            self.resolve_upstream_inner(provider, false)
166                .unwrap_or_else(|_| normalize_url(self.provider_spec(provider).2))
167        };
168        Upstreams {
169            anthropic: pick(ProxyProvider::Anthropic),
170            openai: pick(ProxyProvider::OpenAi),
171            gemini: pick(ProxyProvider::Gemini),
172        }
173    }
174
175    /// Re-resolve upstreams for a *running* proxy (#449). For any provider whose
176    /// currently configured/env value fails validation, the last good value is
177    /// kept instead of rerouting live traffic to the provider default — so a typo
178    /// in config.toml can never silently redirect in-flight requests.
179    pub fn refresh_upstreams(&self, last: &Upstreams) -> Upstreams {
180        let keep = |provider: ProxyProvider, prev: &str| {
181            self.resolve_upstream_checked(provider).unwrap_or_else(|e| {
182                tracing::warn!("upstream invalid, keeping {prev}: {e}");
183                prev.to_string()
184            })
185        };
186        Upstreams {
187            anthropic: keep(ProxyProvider::Anthropic, &last.anthropic),
188            openai: keep(ProxyProvider::OpenAi, &last.openai),
189            gemini: keep(ProxyProvider::Gemini, &last.gemini),
190        }
191    }
192}
193
194/// The three resolved provider upstreams a running proxy forwards to. Published
195/// to request handlers via a `tokio::sync::watch` channel so a config change is
196/// picked up live, without a proxy restart (#449).
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct Upstreams {
199    pub anthropic: String,
200    pub openai: String,
201    pub gemini: String,
202}
203
204#[derive(Debug, Clone, Copy)]
205pub enum ProxyProvider {
206    Anthropic,
207    OpenAi,
208    Gemini,
209}
210
211/// Why a running proxy's live upstream differs from what the operator expects.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub enum UpstreamDrift {
214    /// A `LEAN_CTX_*_UPSTREAM` env var is set in *this* process but the proxy
215    /// serves a different value — the env never reached the MCP/service-spawned
216    /// proxy. This is the #449 trap: Codex (and other MCP hosts) launch the
217    /// server with a stripped, allowlisted env that omits `LEAN_CTX_*_UPSTREAM`,
218    /// so the proxy it spawns never sees it. Fix: persist it to config.toml,
219    /// which the proxy reads live.
220    EnvNotApplied,
221    /// The proxy serves a value other than config.toml resolves to: it was
222    /// started with an env override that now masks a later config edit. Fix:
223    /// `lean-ctx proxy restart`.
224    ConfigNotApplied,
225}
226
227/// The `LEAN_CTX_*_UPSTREAM` override visible to *this* process for a provider,
228/// normalized (`None` if unset/blank). Lets status/doctor explain why an env var
229/// a user exported in their shell never reaches an MCP/service-spawned proxy.
230pub fn env_upstream_override(provider: ProxyProvider) -> Option<String> {
231    let var = match provider {
232        ProxyProvider::Anthropic => "LEAN_CTX_ANTHROPIC_UPSTREAM",
233        ProxyProvider::OpenAi => "LEAN_CTX_OPENAI_UPSTREAM",
234        ProxyProvider::Gemini => "LEAN_CTX_GEMINI_UPSTREAM",
235    };
236    std::env::var(var).ok().and_then(|v| normalize_url_opt(&v))
237}
238
239/// Diagnose upstream drift for one provider from the CLI-visible env override
240/// (`env`), the config.toml value (`disk`) and the proxy's live value (`live`).
241/// `None` means in sync.
242pub fn diagnose_drift(env: Option<&str>, disk: &str, live: &str) -> Option<UpstreamDrift> {
243    if let Some(env) = env {
244        // An env override is present in this process: the proxy honours it only
245        // if it was started with it. If the proxy serves something else, the env
246        // never reached it (#449). If it matches, that is consistent (no drift).
247        return (env != live).then_some(UpstreamDrift::EnvNotApplied);
248    }
249    // No env override here: the proxy should mirror config.toml.
250    (disk != live).then_some(UpstreamDrift::ConfigNotApplied)
251}
252
253pub fn normalize_url(value: &str) -> String {
254    value.trim().trim_end_matches('/').to_string()
255}
256
257pub fn normalize_url_opt(value: &str) -> Option<String> {
258    let trimmed = normalize_url(value);
259    if trimmed.is_empty() {
260        None
261    } else {
262        Some(trimmed)
263    }
264}
265
266const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
267    "api.anthropic.com",
268    "api.openai.com",
269    "generativelanguage.googleapis.com",
270];
271
272pub(super) fn validate_upstream_url(
273    url: &str,
274    allow_insecure_http: bool,
275) -> Result<String, String> {
276    let normalized = normalize_url(url);
277    // Loopback HTTP never leaves the machine — always allowed.
278    if is_local_proxy_url(&normalized) {
279        return Ok(normalized);
280    }
281
282    // A non-loopback plaintext `http://` upstream is reachable only through the
283    // explicit opt-in (#440). The old code rejected it on the HTTPS check *before*
284    // any override could apply, and pointed at `LEAN_CTX_ALLOW_CUSTOM_UPSTREAM`,
285    // which never lifted the scheme restriction. Handle it up front: the opt-in
286    // implies a deliberate custom host on a trusted local network, so it needs no
287    // separate allowlist check; otherwise give a hint that actually works.
288    if normalized.starts_with("http://") {
289        if allow_insecure_http {
290            return Ok(normalized);
291        }
292        return Err(format!(
293            "upstream URL must use HTTPS: {normalized} (for a trusted local-network HTTP \
294             upstream opt in with LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM=1 or \
295             `[proxy] allow_insecure_http_upstream = true`)"
296        ));
297    }
298    let Some(host_segment) = normalized.strip_prefix("https://") else {
299        return Err(format!(
300            "upstream URL must start with http:// or https://: {normalized}"
301        ));
302    };
303
304    let host = host_segment.split('/').next().unwrap_or("");
305    let host_no_port = host.split(':').next().unwrap_or(host);
306    if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
307        || std::env::var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM").is_ok()
308    {
309        Ok(normalized)
310    } else {
311        Err(format!(
312            "upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (set LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 to override)"
313        ))
314    }
315}
316
317pub fn is_local_proxy_url(value: &str) -> bool {
318    let n = normalize_url(value);
319    n.starts_with("http://127.0.0.1:")
320        || n.starts_with("http://localhost:")
321        || n.starts_with("http://[::1]:")
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn loopback_http_is_always_allowed() {
330        assert_eq!(
331            validate_upstream_url("http://127.0.0.1:4444", false).unwrap(),
332            "http://127.0.0.1:4444"
333        );
334        assert_eq!(
335            validate_upstream_url("http://localhost:2455/", false).unwrap(),
336            "http://localhost:2455"
337        );
338    }
339
340    #[test]
341    fn https_allowlisted_host_is_allowed() {
342        assert_eq!(
343            validate_upstream_url("https://api.openai.com", false).unwrap(),
344            "https://api.openai.com"
345        );
346    }
347
348    #[test]
349    fn non_loopback_http_is_rejected_without_optin() {
350        let err = validate_upstream_url("http://host.docker.internal:2455", false).unwrap_err();
351        // The hint must point at the flag that actually lifts the scheme check
352        // (#440). The old message pointed at LEAN_CTX_ALLOW_CUSTOM_UPSTREAM,
353        // which never bypassed the HTTPS requirement.
354        assert!(
355            err.contains("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM"),
356            "hint must name the working opt-in, got: {err}"
357        );
358    }
359
360    #[test]
361    fn non_loopback_http_is_allowed_with_optin() {
362        assert_eq!(
363            validate_upstream_url("http://host.docker.internal:2455", true).unwrap(),
364            "http://host.docker.internal:2455"
365        );
366    }
367
368    #[test]
369    fn unknown_scheme_is_rejected() {
370        assert!(validate_upstream_url("ftp://example.com", true).is_err());
371    }
372
373    #[test]
374    fn config_flag_enables_insecure_http_optin() {
375        // `Some(true)` resolves to `true` regardless of the environment, so this
376        // assertion is robust without mutating process-global env vars.
377        let cfg = ProxyConfig {
378            allow_insecure_http_upstream: Some(true),
379            ..Default::default()
380        };
381        assert!(cfg.allows_insecure_http_upstream());
382    }
383
384    /// `resolve_all_disk` ignores `LEAN_CTX_*_UPSTREAM` env by construction, so
385    /// these assertions are env-independent (no lock needed). Loopback HTTP is an
386    /// always-valid custom upstream (no allowlist / opt-in required).
387    #[test]
388    fn resolve_all_disk_uses_config_then_default() {
389        let cfg = ProxyConfig {
390            openai_upstream: Some("http://127.0.0.1:19101".into()),
391            ..Default::default()
392        };
393        let up = cfg.resolve_all_disk();
394        assert_eq!(up.openai, "http://127.0.0.1:19101");
395        assert_eq!(up.anthropic, "https://api.anthropic.com");
396        assert_eq!(up.gemini, "https://generativelanguage.googleapis.com");
397    }
398
399    #[test]
400    fn resolve_all_disk_normalizes_trailing_slash() {
401        let cfg = ProxyConfig {
402            openai_upstream: Some("http://127.0.0.1:19101/".into()),
403            ..Default::default()
404        };
405        assert_eq!(cfg.resolve_all_disk().openai, "http://127.0.0.1:19101");
406    }
407
408    #[test]
409    fn refresh_keeps_last_good_on_invalid_config() {
410        // `refresh_upstreams` is env-aware; isolate from a developer's shell that
411        // may export LEAN_CTX_OPENAI_UPSTREAM (e.g. while reproducing #449).
412        let _lock = crate::core::data_dir::test_env_lock();
413        crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
414
415        // A typo in config.toml must never reroute a live proxy to the default.
416        let last = Upstreams {
417            anthropic: "https://api.anthropic.com".into(),
418            openai: "http://127.0.0.1:19101".into(),
419            gemini: "https://generativelanguage.googleapis.com".into(),
420        };
421        let cfg = ProxyConfig {
422            openai_upstream: Some("not-a-valid-url".into()),
423            ..Default::default()
424        };
425        assert_eq!(
426            cfg.refresh_upstreams(&last).openai,
427            "http://127.0.0.1:19101",
428            "invalid upstream → keep last good, never silently fall to default"
429        );
430    }
431
432    #[test]
433    fn refresh_adopts_valid_config_change() {
434        let _lock = crate::core::data_dir::test_env_lock();
435        crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
436
437        let last = Upstreams {
438            anthropic: "https://api.anthropic.com".into(),
439            openai: "http://127.0.0.1:19101".into(),
440            gemini: "https://generativelanguage.googleapis.com".into(),
441        };
442        let cfg = ProxyConfig {
443            openai_upstream: Some("http://127.0.0.1:19102".into()),
444            ..Default::default()
445        };
446        assert_eq!(
447            cfg.refresh_upstreams(&last).openai,
448            "http://127.0.0.1:19102"
449        );
450    }
451
452    #[test]
453    fn diagnose_drift_env_set_but_proxy_serves_other() {
454        // The exact #449 / Codex case: env exported in the shell, but the
455        // MCP-spawned proxy serves config.toml → the env never reached it.
456        assert_eq!(
457            diagnose_drift(
458                Some("http://127.0.0.1:2455"),
459                "https://api.openai.com",
460                "https://api.openai.com"
461            ),
462            Some(UpstreamDrift::EnvNotApplied)
463        );
464    }
465
466    #[test]
467    fn diagnose_drift_env_consistent_is_in_sync() {
468        // Proxy was started with the env value and serves it → not drift.
469        assert_eq!(
470            diagnose_drift(
471                Some("http://127.0.0.1:2455"),
472                "https://api.openai.com",
473                "http://127.0.0.1:2455"
474            ),
475            None
476        );
477    }
478
479    #[test]
480    fn diagnose_drift_config_changed_needs_restart() {
481        assert_eq!(
482            diagnose_drift(None, "http://127.0.0.1:2455", "https://api.openai.com"),
483            Some(UpstreamDrift::ConfigNotApplied)
484        );
485    }
486
487    #[test]
488    fn diagnose_drift_in_sync() {
489        assert_eq!(
490            diagnose_drift(None, "https://api.openai.com", "https://api.openai.com"),
491            None
492        );
493    }
494}