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    /// Opt-in "big-gap cold-prefix repack" (#480). When the proxy can confidently
25    /// predict (from idle time vs the provider cache TTL) that the client-cached
26    /// prefix has already expired, it overrides the normal "never rewrite the
27    /// cached prefix" rule for that one resume request and prunes the now-cold
28    /// prefix too, re-seeding a leaner cache. `None`/`false` (the default) keeps
29    /// the prefix always protected. See [`ProxyConfig::repacks_cold_prefix`].
30    pub cold_prefix_repack: Option<bool>,
31    /// Opt-in per-role prose compression for the proxy's frozen request region
32    /// (#710). `None` for a role (the default) leaves that role untouched —
33    /// today's behaviour. See [`RoleAggressiveness`].
34    pub role_aggressiveness: RoleAggressiveness,
35    /// Live tool-result compression on the wire (#481). `true` (the default)
36    /// keeps today's behaviour: the proxy compresses non-protected `tool_result`
37    /// content on every request. `false` turns it off so the proxy can run
38    /// **meter-only** — real billed/cache token metering with zero request
39    /// rewriting (combine with `history_mode = "off"` and no `role_aggressiveness`
40    /// for a fully byte-unchanged body). Env `LEAN_CTX_PROXY_LIVE_COMPRESS`.
41    /// See [`ProxyConfig::live_compresses`].
42    pub live_compress: Option<bool>,
43    /// Per-tool exclusion list for live tool-result compression (#481). Tool
44    /// names are matched case-insensitively as substrings (the same style as
45    /// [`crate::proxy::tool_kind::classify_tool_name`]); a match is treated as
46    /// protected, exactly like a file read. `None` (the default) protects
47    /// Serena's code-reading tools (`find_symbol`/`find_referencing_symbols`/
48    /// `search_for_pattern` return source bodies the model edits, but are
49    /// mis-bucketed as `Search` by name). Set an explicit list to narrow it, or
50    /// `[]` to disable the exclusion. See [`ProxyConfig::is_tool_live_compress_excluded`].
51    pub live_compress_exclude: Option<Vec<String>>,
52    /// Opt-in in-band CCR retrieval for a remote proxy with no shared filesystem
53    /// (#493, follow-up to #482). When enabled, a lossy stub advertises a compact
54    /// `<lc_expand:HASH>` marker (instead of a local tee path the remote agent
55    /// can't read); when the model echoes that marker back, the proxy splices the
56    /// verbatim original — recovered from its **local** tee store — inline on the
57    /// next request, costing one turn of latency and needing no MCP/FS on the
58    /// agent host. `None`/`false` (the default) keeps the path-handle stub. The
59    /// splice is a strict no-op on marker-less turns, so it never perturbs the
60    /// provider cache prefix unless the model explicitly asked to expand. See
61    /// [`ProxyConfig::ccr_inband_enabled`].
62    pub ccr_inband: Option<bool>,
63    /// Cache-safe, cross-provider reasoning-effort control (#834). One of
64    /// `minimal|low|medium|high` pins the model's reasoning depth across every
65    /// provider; `None`/`"off"` (the default) is a strict no-op. The value is a
66    /// constant — identical on every request — so the provider prompt-cache
67    /// prefix stays byte-stable (#448/#498) and only the model's reasoning depth
68    /// changes. lean-ctx translates it to each provider's native parameter and
69    /// only ever *fills* it (never overrides a client-set value), on models that
70    /// accept it. Per-turn effort switching is deliberately unsupported — it
71    /// would invalidate the prompt cache. Env `LEAN_CTX_PROXY_EFFORT`. See
72    /// [`ProxyConfig::resolved_effort`].
73    pub effort: Option<String>,
74}
75
76/// Per-role prose-compression intensity for the proxy's frozen request region.
77///
78/// Each value is a `0.0–1.0` aggressiveness level reusing the same mapping as
79/// the `ctx_read` knob (#708): `0.0` keeps everything, `1.0` is most aggressive.
80/// `None` (the default) means "do not compress this role's prose" so the proxy
81/// stays byte-for-byte unchanged until an operator opts in. The `assistant`
82/// role is never represented here — model turns are always passed through
83/// verbatim (the #710 passthrough guarantee).
84#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
85#[serde(default)]
86pub struct RoleAggressiveness {
87    /// Aggressiveness for system prompts (Anthropic `system` / OpenAI `system`
88    /// messages / Gemini `systemInstruction`). `None` = leave untouched.
89    pub system: Option<f64>,
90    /// Aggressiveness for user prose (free-text user turns, never tool results).
91    /// `None` = leave untouched.
92    pub user: Option<f64>,
93}
94
95/// The conversation roles whose prose the proxy may compress in the frozen
96/// region. Deliberately excludes `assistant` — model turns are never rewritten.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum ProseRole {
99    System,
100    User,
101}
102
103/// How the proxy prunes old tool results from conversation history.
104///
105/// Provider prompt caches (Anthropic `cache_control`, OpenAI automatic prompt
106/// caching) bill cached prefix tokens at a fraction of the base rate but only
107/// match *exact* prefixes. Any mutation whose position depends on the current
108/// conversation length (a rolling window) rewrites a previously-stable message
109/// every turn, invalidating the cache from that point — turning cheap cache
110/// reads into full-price writes.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum HistoryMode {
113    /// Prune only at frozen generation boundaries that advance in large,
114    /// deterministic steps. Between jumps the request prefix is byte-stable,
115    /// so provider prompt caches keep hitting. Content the client has marked
116    /// with a `cache_control` breakpoint is never rewritten, so an advancing
117    /// boundary can no longer invalidate the already-cached prefix (#448).
118    /// Default.
119    CacheAware,
120    /// Legacy behaviour: summarize everything older than the last N messages.
121    /// Maximum raw-token reduction, but defeats provider prompt caching.
122    Rolling,
123    /// Never prune history (tool-result compression still applies — it is
124    /// content-deterministic and therefore prefix-stable).
125    Off,
126}
127
128impl ProxyConfig {
129    /// Resolved history mode: `LEAN_CTX_PROXY_HISTORY_MODE` env var wins,
130    /// then `[proxy].history_mode` in config.toml, then cache-aware.
131    /// Unknown values fall back to the default so a typo can never silently
132    /// re-enable the cache-hostile rolling mode.
133    pub fn resolved_history_mode(&self) -> HistoryMode {
134        let raw = std::env::var("LEAN_CTX_PROXY_HISTORY_MODE")
135            .ok()
136            .or_else(|| self.history_mode.clone());
137        match raw.as_deref().map(str::trim) {
138            Some(s) if s.eq_ignore_ascii_case("rolling") => HistoryMode::Rolling,
139            Some(s) if s.eq_ignore_ascii_case("off") => HistoryMode::Off,
140            _ => HistoryMode::CacheAware,
141        }
142    }
143
144    /// Whether the proxy injects `stream_options.include_usage` into streamed
145    /// OpenAI Chat Completions to meter real spend. `[proxy] meter_openai_usage`
146    /// in config.toml, default `true`.
147    pub fn meters_openai_usage(&self) -> bool {
148        self.meter_openai_usage.unwrap_or(true)
149    }
150
151    /// Whether the opt-in cold-prefix repack (#480) is enabled. A wrong "cold"
152    /// guess re-bills cache reads as writes (~12x), so this is off by default and
153    /// must be explicitly enabled. `LEAN_CTX_PROXY_COLD_PREFIX_REPACK` (any
154    /// value) wins, then `[proxy] cold_prefix_repack` in config.toml, else
155    /// `false`.
156    pub fn repacks_cold_prefix(&self) -> bool {
157        std::env::var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK").is_ok()
158            || self.cold_prefix_repack.unwrap_or(false)
159    }
160
161    /// Whether opt-in in-band CCR retrieval (#493) is enabled. Off by default:
162    /// the splice mutates provider-visible conversation content for the one turn
163    /// the model asks to expand, so it must be an explicit opt-in.
164    /// `LEAN_CTX_PROXY_CCR_INBAND` (any value) wins, then `[proxy] ccr_inband` in
165    /// config.toml, else `false`.
166    pub fn ccr_inband_enabled(&self) -> bool {
167        std::env::var("LEAN_CTX_PROXY_CCR_INBAND").is_ok() || self.ccr_inband.unwrap_or(false)
168    }
169
170    /// Resolved cross-provider reasoning effort (#834), or `None` when the
171    /// feature is off (the default — a strict no-op that preserves the
172    /// byte-unchanged meter-only path). Precedence: `LEAN_CTX_PROXY_EFFORT` env
173    /// (`off` disables, a valid level wins, an unparseable/blank value is
174    /// ignored) > `[proxy] effort` in config.toml. Any unknown value resolves to
175    /// `None` so a typo can never silently enable reasoning steering.
176    #[must_use]
177    pub fn resolved_effort(&self) -> Option<super::Effort> {
178        if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_EFFORT") {
179            let trimmed = raw.trim();
180            if trimmed.eq_ignore_ascii_case("off") {
181                return None;
182            }
183            if let Some(effort) = super::Effort::parse(trimmed) {
184                return Some(effort);
185            }
186            // Blank/unknown env → ignore and fall through to config, mirroring
187            // `live_compresses` so a typo never flips the configured behaviour.
188        }
189        self.effort.as_deref().and_then(super::Effort::parse)
190    }
191
192    /// Whether the proxy live-compresses non-protected `tool_result` content
193    /// (#481). `LEAN_CTX_PROXY_LIVE_COMPRESS` (`0`/`false`/`off`/`no` → off,
194    /// `1`/`true`/`on`/`yes` → on) wins, then `[proxy] live_compress` in
195    /// config.toml, else `true`. An unparseable/blank env value is ignored so a
196    /// typo can never silently flip the mode.
197    pub fn live_compresses(&self) -> bool {
198        if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_LIVE_COMPRESS") {
199            match raw.trim().to_ascii_lowercase().as_str() {
200                "0" | "false" | "off" | "no" => return false,
201                "1" | "true" | "on" | "yes" => return true,
202                _ => {}
203            }
204        }
205        self.live_compress.unwrap_or(true)
206    }
207
208    /// Resolved per-tool live-compress exclusion patterns (#481). `None` in
209    /// config falls back to the built-in default (protect Serena); an explicit
210    /// list — including the empty list — is used verbatim so operators can narrow
211    /// or fully clear it.
212    #[must_use]
213    pub fn live_compress_exclude_patterns(&self) -> Vec<String> {
214        self.live_compress_exclude
215            .clone()
216            .unwrap_or_else(default_live_compress_exclude)
217    }
218
219    /// Whether `tool_name` is on the live-compress exclusion list (#481) and must
220    /// therefore reach the model intact, like a protected file read. Matching is
221    /// case-insensitive substring, mirroring `tool_kind::classify_tool_name`.
222    #[must_use]
223    pub fn is_tool_live_compress_excluded(&self, tool_name: &str) -> bool {
224        let name = tool_name.to_ascii_lowercase();
225        self.live_compress_exclude_patterns().iter().any(|p| {
226            let p = p.trim().to_ascii_lowercase();
227            !p.is_empty() && name.contains(p.as_str())
228        })
229    }
230
231    /// Resolved prose-compression aggressiveness for `role`, clamped to `[0,1]`,
232    /// or `None` when prose compression is off for that role (the default).
233    ///
234    /// Precedence: the role's env override (`LEAN_CTX_PROXY_SYSTEM_AGGR` /
235    /// `LEAN_CTX_PROXY_USER_AGGR`) wins, then `[proxy.role_aggressiveness]` in
236    /// config.toml. An unparseable or blank env value is ignored so a typo can
237    /// never silently disable the configured behaviour.
238    #[must_use]
239    pub fn resolved_role_aggressiveness(&self, role: ProseRole) -> Option<f64> {
240        let (env_var, configured) = match role {
241            ProseRole::System => (
242                "LEAN_CTX_PROXY_SYSTEM_AGGR",
243                self.role_aggressiveness.system,
244            ),
245            ProseRole::User => ("LEAN_CTX_PROXY_USER_AGGR", self.role_aggressiveness.user),
246        };
247        let from_env = std::env::var(env_var)
248            .ok()
249            .and_then(|v| v.trim().parse::<f64>().ok());
250        from_env.or(configured).map(|a| a.clamp(0.0, 1.0))
251    }
252
253    /// Whether a non-loopback plaintext `http://` upstream is allowed. Opt-in
254    /// only — a deliberate downgrade for a trusted local-network service such as
255    /// `http://host.docker.internal:2455` in front of codex-lb (#440).
256    /// `LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM` (any value) wins, then
257    /// `[proxy] allow_insecure_http_upstream` in config.toml, default `false`.
258    pub fn allows_insecure_http_upstream(&self) -> bool {
259        std::env::var("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM").is_ok()
260            || self.allow_insecure_http_upstream.unwrap_or(false)
261    }
262
263    /// `(env var, configured value, provider default)` for one provider.
264    fn provider_spec(&self, provider: ProxyProvider) -> (&'static str, Option<&str>, &'static str) {
265        match provider {
266            ProxyProvider::Anthropic => (
267                "LEAN_CTX_ANTHROPIC_UPSTREAM",
268                self.anthropic_upstream.as_deref(),
269                "https://api.anthropic.com",
270            ),
271            ProxyProvider::OpenAi => (
272                "LEAN_CTX_OPENAI_UPSTREAM",
273                self.openai_upstream.as_deref(),
274                "https://api.openai.com",
275            ),
276            ProxyProvider::Gemini => (
277                "LEAN_CTX_GEMINI_UPSTREAM",
278                self.gemini_upstream.as_deref(),
279                "https://generativelanguage.googleapis.com",
280            ),
281        }
282    }
283
284    /// Resolve one upstream with precedence `LEAN_CTX_*_UPSTREAM` env var >
285    /// `[proxy].*_upstream` (config.toml) > provider default.
286    ///
287    /// Returns `Err` when a value is *present but invalid* so a live reload can
288    /// keep the last good value instead of silently rerouting to the default; an
289    /// *absent* value resolves to the provider default (`Ok`).
290    fn resolve_upstream_checked(&self, provider: ProxyProvider) -> Result<String, String> {
291        self.resolve_upstream_inner(provider, true)
292    }
293
294    /// Shared resolver for [`resolve_upstream_checked`] and the disk-only view.
295    /// `use_env = false` ignores the `LEAN_CTX_*_UPSTREAM` override and yields
296    /// the config.toml truth a freshly (re)started managed proxy would serve.
297    fn resolve_upstream_inner(
298        &self,
299        provider: ProxyProvider,
300        use_env: bool,
301    ) -> Result<String, String> {
302        let (env_var, config_val, default) = self.provider_spec(provider);
303        let env_val = if use_env {
304            std::env::var(env_var)
305                .ok()
306                .and_then(|v| normalize_url_opt(&v))
307        } else {
308            None
309        };
310        let candidate = env_val.or_else(|| config_val.and_then(normalize_url_opt));
311        match candidate {
312            None => Ok(normalize_url(default)),
313            Some(url) => validate_upstream_url(&url, self.allows_insecure_http_upstream()),
314        }
315    }
316
317    /// Effective upstream for a provider (env > config > default). An invalid
318    /// configured/env value falls back to the provider default (logged) — the
319    /// safe choice at startup.
320    pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
321        match self.resolve_upstream_checked(provider) {
322            Ok(url) => url,
323            Err(e) => {
324                tracing::warn!("upstream validation failed, using default: {e}");
325                normalize_url(self.provider_spec(provider).2)
326            }
327        }
328    }
329
330    /// Resolve all three upstreams at once (startup snapshot, env-aware).
331    pub fn resolve_all(&self) -> Upstreams {
332        Upstreams {
333            anthropic: self.resolve_upstream(ProxyProvider::Anthropic),
334            openai: self.resolve_upstream(ProxyProvider::OpenAi),
335            gemini: self.resolve_upstream(ProxyProvider::Gemini),
336        }
337    }
338
339    /// Resolve all upstreams from config.toml only (ignoring `LEAN_CTX_*` env) —
340    /// the values a freshly (re)started managed proxy would serve. Used by
341    /// status/doctor to detect drift from a running proxy's live upstream (#449).
342    pub fn resolve_all_disk(&self) -> Upstreams {
343        let pick = |provider: ProxyProvider| {
344            self.resolve_upstream_inner(provider, false)
345                .unwrap_or_else(|_| normalize_url(self.provider_spec(provider).2))
346        };
347        Upstreams {
348            anthropic: pick(ProxyProvider::Anthropic),
349            openai: pick(ProxyProvider::OpenAi),
350            gemini: pick(ProxyProvider::Gemini),
351        }
352    }
353
354    /// Re-resolve upstreams for a *running* proxy (#449). For any provider whose
355    /// currently configured/env value fails validation, the last good value is
356    /// kept instead of rerouting live traffic to the provider default — so a typo
357    /// in config.toml can never silently redirect in-flight requests.
358    pub fn refresh_upstreams(&self, last: &Upstreams) -> Upstreams {
359        let keep = |provider: ProxyProvider, prev: &str| {
360            self.resolve_upstream_checked(provider).unwrap_or_else(|e| {
361                tracing::warn!("upstream invalid, keeping {prev}: {e}");
362                prev.to_string()
363            })
364        };
365        Upstreams {
366            anthropic: keep(ProxyProvider::Anthropic, &last.anthropic),
367            openai: keep(ProxyProvider::OpenAi, &last.openai),
368            gemini: keep(ProxyProvider::Gemini, &last.gemini),
369        }
370    }
371}
372
373/// The three resolved provider upstreams a running proxy forwards to. Published
374/// to request handlers via a `tokio::sync::watch` channel so a config change is
375/// picked up live, without a proxy restart (#449).
376#[derive(Debug, Clone, PartialEq, Eq)]
377pub struct Upstreams {
378    pub anthropic: String,
379    pub openai: String,
380    pub gemini: String,
381}
382
383#[derive(Debug, Clone, Copy)]
384pub enum ProxyProvider {
385    Anthropic,
386    OpenAi,
387    Gemini,
388}
389
390/// Why a running proxy's live upstream differs from what the operator expects.
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392pub enum UpstreamDrift {
393    /// A `LEAN_CTX_*_UPSTREAM` env var is set in *this* process but the proxy
394    /// serves a different value — the env never reached the MCP/service-spawned
395    /// proxy. This is the #449 trap: Codex (and other MCP hosts) launch the
396    /// server with a stripped, allowlisted env that omits `LEAN_CTX_*_UPSTREAM`,
397    /// so the proxy it spawns never sees it. Fix: persist it to config.toml,
398    /// which the proxy reads live.
399    EnvNotApplied,
400    /// The proxy serves a value other than config.toml resolves to: it was
401    /// started with an env override that now masks a later config edit. Fix:
402    /// `lean-ctx proxy restart`.
403    ConfigNotApplied,
404}
405
406/// The `LEAN_CTX_*_UPSTREAM` override visible to *this* process for a provider,
407/// normalized (`None` if unset/blank). Lets status/doctor explain why an env var
408/// a user exported in their shell never reaches an MCP/service-spawned proxy.
409pub fn env_upstream_override(provider: ProxyProvider) -> Option<String> {
410    let var = match provider {
411        ProxyProvider::Anthropic => "LEAN_CTX_ANTHROPIC_UPSTREAM",
412        ProxyProvider::OpenAi => "LEAN_CTX_OPENAI_UPSTREAM",
413        ProxyProvider::Gemini => "LEAN_CTX_GEMINI_UPSTREAM",
414    };
415    std::env::var(var).ok().and_then(|v| normalize_url_opt(&v))
416}
417
418/// Diagnose upstream drift for one provider from the CLI-visible env override
419/// (`env`), the config.toml value (`disk`) and the proxy's live value (`live`).
420/// `None` means in sync.
421pub fn diagnose_drift(env: Option<&str>, disk: &str, live: &str) -> Option<UpstreamDrift> {
422    if let Some(env) = env {
423        // An env override is present in this process: the proxy honours it only
424        // if it was started with it. If the proxy serves something else, the env
425        // never reached it (#449). If it matches, that is consistent (no drift).
426        return (env != live).then_some(UpstreamDrift::EnvNotApplied);
427    }
428    // No env override here: the proxy should mirror config.toml.
429    (disk != live).then_some(UpstreamDrift::ConfigNotApplied)
430}
431
432/// Built-in default live-compress exclusion (#481). Serena's code-reading tools
433/// (`find_symbol`/`find_referencing_symbols`/`search_for_pattern`) return source
434/// bodies the model edits, yet are mis-bucketed as `Search` by name, so the proxy
435/// would otherwise gut them. Protect anything namespaced `serena` by default.
436fn default_live_compress_exclude() -> Vec<String> {
437    vec!["serena".to_string()]
438}
439
440pub fn normalize_url(value: &str) -> String {
441    value.trim().trim_end_matches('/').to_string()
442}
443
444pub fn normalize_url_opt(value: &str) -> Option<String> {
445    let trimmed = normalize_url(value);
446    if trimmed.is_empty() {
447        None
448    } else {
449        Some(trimmed)
450    }
451}
452
453const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
454    "api.anthropic.com",
455    "api.openai.com",
456    "generativelanguage.googleapis.com",
457];
458
459pub(super) fn validate_upstream_url(
460    url: &str,
461    allow_insecure_http: bool,
462) -> Result<String, String> {
463    let normalized = normalize_url(url);
464    // Loopback HTTP never leaves the machine — always allowed.
465    if is_local_proxy_url(&normalized) {
466        return Ok(normalized);
467    }
468
469    // A non-loopback plaintext `http://` upstream is reachable only through the
470    // explicit opt-in (#440). The old code rejected it on the HTTPS check *before*
471    // any override could apply, and pointed at `LEAN_CTX_ALLOW_CUSTOM_UPSTREAM`,
472    // which never lifted the scheme restriction. Handle it up front: the opt-in
473    // implies a deliberate custom host on a trusted local network, so it needs no
474    // separate allowlist check; otherwise give a hint that actually works.
475    if normalized.starts_with("http://") {
476        if allow_insecure_http {
477            return Ok(normalized);
478        }
479        return Err(format!(
480            "upstream URL must use HTTPS: {normalized} (for a trusted local-network HTTP \
481             upstream opt in with LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM=1 or \
482             `[proxy] allow_insecure_http_upstream = true`)"
483        ));
484    }
485    let Some(host_segment) = normalized.strip_prefix("https://") else {
486        return Err(format!(
487            "upstream URL must start with http:// or https://: {normalized}"
488        ));
489    };
490
491    let host = host_segment.split('/').next().unwrap_or("");
492    let host_no_port = host.split(':').next().unwrap_or(host);
493    if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
494        || std::env::var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM").is_ok()
495    {
496        Ok(normalized)
497    } else {
498        Err(format!(
499            "upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (set LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 to override)"
500        ))
501    }
502}
503
504pub fn is_local_proxy_url(value: &str) -> bool {
505    let n = normalize_url(value);
506    n.starts_with("http://127.0.0.1:")
507        || n.starts_with("http://localhost:")
508        || n.starts_with("http://[::1]:")
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn loopback_http_is_always_allowed() {
517        assert_eq!(
518            validate_upstream_url("http://127.0.0.1:4444", false).unwrap(),
519            "http://127.0.0.1:4444"
520        );
521        assert_eq!(
522            validate_upstream_url("http://localhost:2455/", false).unwrap(),
523            "http://localhost:2455"
524        );
525    }
526
527    #[test]
528    fn https_allowlisted_host_is_allowed() {
529        assert_eq!(
530            validate_upstream_url("https://api.openai.com", false).unwrap(),
531            "https://api.openai.com"
532        );
533    }
534
535    #[test]
536    fn non_loopback_http_is_rejected_without_optin() {
537        let err = validate_upstream_url("http://host.docker.internal:2455", false).unwrap_err();
538        // The hint must point at the flag that actually lifts the scheme check
539        // (#440). The old message pointed at LEAN_CTX_ALLOW_CUSTOM_UPSTREAM,
540        // which never bypassed the HTTPS requirement.
541        assert!(
542            err.contains("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM"),
543            "hint must name the working opt-in, got: {err}"
544        );
545    }
546
547    #[test]
548    fn non_loopback_http_is_allowed_with_optin() {
549        assert_eq!(
550            validate_upstream_url("http://host.docker.internal:2455", true).unwrap(),
551            "http://host.docker.internal:2455"
552        );
553    }
554
555    #[test]
556    fn unknown_scheme_is_rejected() {
557        assert!(validate_upstream_url("ftp://example.com", true).is_err());
558    }
559
560    #[test]
561    fn cold_prefix_repack_is_opt_in_and_config_enables() {
562        // #480: off by default (a wrong cold guess re-bills reads as writes ~12x),
563        // enabled via config. Isolate from a developer shell that may export the
564        // env override.
565        let _lock = crate::core::data_dir::test_env_lock();
566        crate::test_env::remove_var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK");
567        assert!(
568            !ProxyConfig::default().repacks_cold_prefix(),
569            "cold-prefix repack must be opt-in (off by default)"
570        );
571        let cfg = ProxyConfig {
572            cold_prefix_repack: Some(true),
573            ..Default::default()
574        };
575        assert!(cfg.repacks_cold_prefix());
576    }
577
578    #[test]
579    fn ccr_inband_is_opt_in_and_config_enables() {
580        // #493: off by default (the splice mutates provider-visible content for
581        // the expand turn), enabled via config. Isolate from a developer shell
582        // that may export the env override.
583        let _lock = crate::core::data_dir::test_env_lock();
584        crate::test_env::remove_var("LEAN_CTX_PROXY_CCR_INBAND");
585        assert!(
586            !ProxyConfig::default().ccr_inband_enabled(),
587            "in-band CCR must be opt-in (off by default)"
588        );
589        let cfg = ProxyConfig {
590            ccr_inband: Some(true),
591            ..Default::default()
592        };
593        assert!(cfg.ccr_inband_enabled());
594    }
595
596    #[test]
597    fn effort_defaults_off_and_config_sets_it() {
598        // #834: cache-safe effort control is opt-in. Isolate from a developer
599        // shell that may export the env override.
600        let _lock = crate::core::data_dir::test_env_lock();
601        crate::test_env::remove_var("LEAN_CTX_PROXY_EFFORT");
602        assert_eq!(
603            ProxyConfig::default().resolved_effort(),
604            None,
605            "effort control must be opt-in (off by default)"
606        );
607        let cfg = ProxyConfig {
608            effort: Some("low".into()),
609            ..Default::default()
610        };
611        assert_eq!(
612            cfg.resolved_effort(),
613            Some(crate::core::config::Effort::Low)
614        );
615        // An unknown configured value resolves to off — never a silent default.
616        let typo = ProxyConfig {
617            effort: Some("lowish".into()),
618            ..Default::default()
619        };
620        assert_eq!(typo.resolved_effort(), None);
621    }
622
623    #[test]
624    fn effort_env_overrides_and_off_disables() {
625        use crate::core::config::Effort;
626        let _lock = crate::core::data_dir::test_env_lock();
627        let cfg = ProxyConfig {
628            effort: Some("high".into()),
629            ..Default::default()
630        };
631        // A valid env level wins over config.
632        crate::test_env::set_var("LEAN_CTX_PROXY_EFFORT", "minimal");
633        assert_eq!(cfg.resolved_effort(), Some(Effort::Minimal));
634        // `off` explicitly disables even a configured level.
635        crate::test_env::set_var("LEAN_CTX_PROXY_EFFORT", "off");
636        assert_eq!(cfg.resolved_effort(), None);
637        // A blank/garbage env value is ignored → falls back to config.
638        crate::test_env::set_var("LEAN_CTX_PROXY_EFFORT", "   ");
639        assert_eq!(cfg.resolved_effort(), Some(Effort::High));
640        crate::test_env::remove_var("LEAN_CTX_PROXY_EFFORT");
641    }
642
643    #[test]
644    fn config_flag_enables_insecure_http_optin() {
645        // `Some(true)` resolves to `true` regardless of the environment, so this
646        // assertion is robust without mutating process-global env vars.
647        let cfg = ProxyConfig {
648            allow_insecure_http_upstream: Some(true),
649            ..Default::default()
650        };
651        assert!(cfg.allows_insecure_http_upstream());
652    }
653
654    /// `resolve_all_disk` ignores `LEAN_CTX_*_UPSTREAM` env by construction, so
655    /// these assertions are env-independent (no lock needed). Loopback HTTP is an
656    /// always-valid custom upstream (no allowlist / opt-in required).
657    #[test]
658    fn resolve_all_disk_uses_config_then_default() {
659        let cfg = ProxyConfig {
660            openai_upstream: Some("http://127.0.0.1:19101".into()),
661            ..Default::default()
662        };
663        let up = cfg.resolve_all_disk();
664        assert_eq!(up.openai, "http://127.0.0.1:19101");
665        assert_eq!(up.anthropic, "https://api.anthropic.com");
666        assert_eq!(up.gemini, "https://generativelanguage.googleapis.com");
667    }
668
669    #[test]
670    fn resolve_all_disk_normalizes_trailing_slash() {
671        let cfg = ProxyConfig {
672            openai_upstream: Some("http://127.0.0.1:19101/".into()),
673            ..Default::default()
674        };
675        assert_eq!(cfg.resolve_all_disk().openai, "http://127.0.0.1:19101");
676    }
677
678    #[test]
679    fn refresh_keeps_last_good_on_invalid_config() {
680        // `refresh_upstreams` is env-aware; isolate from a developer's shell that
681        // may export LEAN_CTX_OPENAI_UPSTREAM (e.g. while reproducing #449).
682        let _lock = crate::core::data_dir::test_env_lock();
683        crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
684
685        // A typo in config.toml must never reroute a live proxy to the default.
686        let last = Upstreams {
687            anthropic: "https://api.anthropic.com".into(),
688            openai: "http://127.0.0.1:19101".into(),
689            gemini: "https://generativelanguage.googleapis.com".into(),
690        };
691        let cfg = ProxyConfig {
692            openai_upstream: Some("not-a-valid-url".into()),
693            ..Default::default()
694        };
695        assert_eq!(
696            cfg.refresh_upstreams(&last).openai,
697            "http://127.0.0.1:19101",
698            "invalid upstream → keep last good, never silently fall to default"
699        );
700    }
701
702    #[test]
703    fn refresh_adopts_valid_config_change() {
704        let _lock = crate::core::data_dir::test_env_lock();
705        crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
706
707        let last = Upstreams {
708            anthropic: "https://api.anthropic.com".into(),
709            openai: "http://127.0.0.1:19101".into(),
710            gemini: "https://generativelanguage.googleapis.com".into(),
711        };
712        let cfg = ProxyConfig {
713            openai_upstream: Some("http://127.0.0.1:19102".into()),
714            ..Default::default()
715        };
716        assert_eq!(
717            cfg.refresh_upstreams(&last).openai,
718            "http://127.0.0.1:19102"
719        );
720    }
721
722    #[test]
723    fn diagnose_drift_env_set_but_proxy_serves_other() {
724        // The exact #449 / Codex case: env exported in the shell, but the
725        // MCP-spawned proxy serves config.toml → the env never reached it.
726        assert_eq!(
727            diagnose_drift(
728                Some("http://127.0.0.1:2455"),
729                "https://api.openai.com",
730                "https://api.openai.com"
731            ),
732            Some(UpstreamDrift::EnvNotApplied)
733        );
734    }
735
736    #[test]
737    fn diagnose_drift_env_consistent_is_in_sync() {
738        // Proxy was started with the env value and serves it → not drift.
739        assert_eq!(
740            diagnose_drift(
741                Some("http://127.0.0.1:2455"),
742                "https://api.openai.com",
743                "http://127.0.0.1:2455"
744            ),
745            None
746        );
747    }
748
749    #[test]
750    fn diagnose_drift_config_changed_needs_restart() {
751        assert_eq!(
752            diagnose_drift(None, "http://127.0.0.1:2455", "https://api.openai.com"),
753            Some(UpstreamDrift::ConfigNotApplied)
754        );
755    }
756
757    #[test]
758    fn diagnose_drift_in_sync() {
759        assert_eq!(
760            diagnose_drift(None, "https://api.openai.com", "https://api.openai.com"),
761            None
762        );
763    }
764
765    #[test]
766    fn role_aggressiveness_defaults_to_off() {
767        // Opt-in: a fresh config compresses no prose, so the proxy stays
768        // byte-for-byte unchanged until an operator sets a value (#710).
769        let cfg = ProxyConfig::default();
770        // Isolate from a developer shell that may export the override.
771        let _lock = crate::core::data_dir::test_env_lock();
772        crate::test_env::remove_var("LEAN_CTX_PROXY_SYSTEM_AGGR");
773        crate::test_env::remove_var("LEAN_CTX_PROXY_USER_AGGR");
774        assert_eq!(cfg.resolved_role_aggressiveness(ProseRole::System), None);
775        assert_eq!(cfg.resolved_role_aggressiveness(ProseRole::User), None);
776    }
777
778    #[test]
779    fn role_aggressiveness_reads_config_and_clamps() {
780        let _lock = crate::core::data_dir::test_env_lock();
781        crate::test_env::remove_var("LEAN_CTX_PROXY_SYSTEM_AGGR");
782        crate::test_env::remove_var("LEAN_CTX_PROXY_USER_AGGR");
783        let cfg = ProxyConfig {
784            role_aggressiveness: RoleAggressiveness {
785                system: Some(0.7),
786                user: Some(1.5),
787            },
788            ..Default::default()
789        };
790        assert_eq!(
791            cfg.resolved_role_aggressiveness(ProseRole::System),
792            Some(0.7)
793        );
794        // Out-of-range config values are clamped into [0,1].
795        assert_eq!(cfg.resolved_role_aggressiveness(ProseRole::User), Some(1.0));
796    }
797
798    #[test]
799    fn role_aggressiveness_env_overrides_config() {
800        let _lock = crate::core::data_dir::test_env_lock();
801        crate::test_env::set_var("LEAN_CTX_PROXY_SYSTEM_AGGR", "0.25");
802        let cfg = ProxyConfig {
803            role_aggressiveness: RoleAggressiveness {
804                system: Some(0.9),
805                user: None,
806            },
807            ..Default::default()
808        };
809        assert_eq!(
810            cfg.resolved_role_aggressiveness(ProseRole::System),
811            Some(0.25),
812            "env override must win over the configured value"
813        );
814        crate::test_env::remove_var("LEAN_CTX_PROXY_SYSTEM_AGGR");
815    }
816
817    #[test]
818    fn role_aggressiveness_ignores_blank_env() {
819        let _lock = crate::core::data_dir::test_env_lock();
820        crate::test_env::set_var("LEAN_CTX_PROXY_USER_AGGR", "  ");
821        let cfg = ProxyConfig {
822            role_aggressiveness: RoleAggressiveness {
823                system: None,
824                user: Some(0.4),
825            },
826            ..Default::default()
827        };
828        assert_eq!(
829            cfg.resolved_role_aggressiveness(ProseRole::User),
830            Some(0.4),
831            "a blank/garbage env value must fall back to config, not disable it"
832        );
833        crate::test_env::remove_var("LEAN_CTX_PROXY_USER_AGGR");
834    }
835
836    #[test]
837    fn live_compress_defaults_on_and_config_disables() {
838        // #481: default ON (today's behaviour); a config `false` opts into the
839        // meter-only mode. Isolate from a developer shell exporting the override.
840        let _lock = crate::core::data_dir::test_env_lock();
841        crate::test_env::remove_var("LEAN_CTX_PROXY_LIVE_COMPRESS");
842        assert!(
843            ProxyConfig::default().live_compresses(),
844            "live_compress must default to true"
845        );
846        let cfg = ProxyConfig {
847            live_compress: Some(false),
848            ..Default::default()
849        };
850        assert!(!cfg.live_compresses());
851    }
852
853    #[test]
854    fn live_compress_env_overrides_config() {
855        let _lock = crate::core::data_dir::test_env_lock();
856        // env `off` wins over a config `true`.
857        crate::test_env::set_var("LEAN_CTX_PROXY_LIVE_COMPRESS", "off");
858        let cfg = ProxyConfig {
859            live_compress: Some(true),
860            ..Default::default()
861        };
862        assert!(!cfg.live_compresses(), "env off must win over config true");
863        // A garbage env value is ignored → falls back to config.
864        crate::test_env::set_var("LEAN_CTX_PROXY_LIVE_COMPRESS", "maybe");
865        assert!(
866            cfg.live_compresses(),
867            "unparseable env must fall back to config, not flip the mode"
868        );
869        crate::test_env::remove_var("LEAN_CTX_PROXY_LIVE_COMPRESS");
870    }
871
872    #[test]
873    fn live_compress_exclude_defaults_to_serena() {
874        // #481: an unset list protects Serena's code-reading tools, which return
875        // source bodies but are mis-bucketed as `Search` by name.
876        let cfg = ProxyConfig::default();
877        assert!(cfg.is_tool_live_compress_excluded("mcp__serena__find_symbol"));
878        assert!(cfg.is_tool_live_compress_excluded("Serena.search_for_pattern"));
879        assert!(!cfg.is_tool_live_compress_excluded("ctx_shell"));
880    }
881
882    #[test]
883    fn live_compress_exclude_explicit_list_replaces_default() {
884        // An explicit list narrows the exclusion (Serena no longer protected).
885        let cfg = ProxyConfig {
886            live_compress_exclude: Some(vec!["my_reader".into()]),
887            ..Default::default()
888        };
889        assert!(cfg.is_tool_live_compress_excluded("acme_my_reader_v2"));
890        assert!(!cfg.is_tool_live_compress_excluded("mcp__serena__find_symbol"));
891    }
892
893    #[test]
894    fn live_compress_exclude_empty_list_disables_protection() {
895        // `[]` fully clears the exclusion (operator opts every tool back in).
896        let cfg = ProxyConfig {
897            live_compress_exclude: Some(vec![]),
898            ..Default::default()
899        };
900        assert!(!cfg.is_tool_live_compress_excluded("mcp__serena__find_symbol"));
901    }
902}