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 chatgpt_upstream: Option<String>,
12    pub gemini_upstream: Option<String>,
13    /// History-pruning strategy for proxied chat requests.
14    /// "cache-aware" (default) | "rolling" | "off". See [`HistoryMode`].
15    pub history_mode: Option<String>,
16    /// Allow a non-loopback plaintext `http://` upstream (trusted local network
17    /// only). Opt-in; see [`ProxyConfig::allows_insecure_http_upstream`]. (#440)
18    pub allow_insecure_http_upstream: Option<bool>,
19    /// Inject `stream_options.include_usage = true` into streamed OpenAI Chat
20    /// Completions so the final chunk reports real token usage for the measured
21    /// spend meter. Default on; set `false` for a client that mishandles the
22    /// trailing usage chunk. Anthropic/Gemini/OpenAI-Responses report usage
23    /// without any request change, so this only affects Chat Completions.
24    pub meter_openai_usage: Option<bool>,
25    /// Opt-in "big-gap cold-prefix repack" (#480). When the proxy can confidently
26    /// predict (from idle time vs the provider cache TTL) that the client-cached
27    /// prefix has already expired, it overrides the normal "never rewrite the
28    /// cached prefix" rule for that one resume request and prunes the now-cold
29    /// prefix too, re-seeding a leaner cache. `None`/`false` (the default) keeps
30    /// the prefix always protected. See [`ProxyConfig::repacks_cold_prefix`].
31    pub cold_prefix_repack: Option<bool>,
32    /// Opt-in per-role prose compression for the proxy's frozen request region
33    /// (#710). `None` for a role (the default) leaves that role untouched —
34    /// today's behaviour. See [`RoleAggressiveness`].
35    pub role_aggressiveness: RoleAggressiveness,
36    /// Live tool-result compression on the wire (#481). `true` (the default)
37    /// keeps today's behaviour: the proxy compresses non-protected `tool_result`
38    /// content on every request. `false` turns it off so the proxy can run
39    /// **meter-only** — real billed/cache token metering with zero request
40    /// rewriting (combine with `history_mode = "off"` and no `role_aggressiveness`
41    /// for a fully byte-unchanged body). Env `LEAN_CTX_PROXY_LIVE_COMPRESS`.
42    /// See [`ProxyConfig::live_compresses`].
43    pub live_compress: Option<bool>,
44    /// Per-tool exclusion list for live tool-result compression (#481). Tool
45    /// names are matched case-insensitively as substrings (the same style as
46    /// [`crate::proxy::tool_kind::classify_tool_name`]); a match is treated as
47    /// protected, exactly like a file read. `None` (the default) protects
48    /// Serena's code-reading tools (`find_symbol`/`find_referencing_symbols`/
49    /// `search_for_pattern` return source bodies the model edits, but are
50    /// mis-bucketed as `Search` by name). Set an explicit list to narrow it, or
51    /// `[]` to disable the exclusion. See [`ProxyConfig::is_tool_live_compress_excluded`].
52    pub live_compress_exclude: Option<Vec<String>>,
53    /// Opt-in in-band CCR retrieval for a remote proxy with no shared filesystem
54    /// (#493, follow-up to #482). When enabled, a lossy stub advertises a compact
55    /// `<lc_expand:HASH>` marker (instead of a local tee path the remote agent
56    /// can't read); when the model echoes that marker back, the proxy splices the
57    /// verbatim original — recovered from its **local** tee store — inline on the
58    /// next request, costing one turn of latency and needing no MCP/FS on the
59    /// agent host. `None`/`false` (the default) keeps the path-handle stub. The
60    /// splice is a strict no-op on marker-less turns, so it never perturbs the
61    /// provider cache prefix unless the model explicitly asked to expand. See
62    /// [`ProxyConfig::ccr_inband_enabled`].
63    pub ccr_inband: Option<bool>,
64    /// Opt-in active prompt-cache breakpoint injection for Anthropic (#939). When
65    /// enabled and the client set no `cache_control` of its own, the proxy adds a
66    /// single `cache_control: {type:"ephemeral"}` breakpoint to the `system`
67    /// field so an otherwise-uncached, stable system prompt bills later turns at
68    /// the cached rate. Anthropic-only: OpenAI/Gemini cache prefixes automatically
69    /// and ignore the marker, so those paths stay byte-unchanged. The injection is
70    /// deterministic, never adds a second breakpoint, and is skipped below
71    /// Anthropic's minimum cacheable size. `None`/`false` (the default) leaves the
72    /// request untouched. See [`ProxyConfig::cache_breakpoint_enabled`].
73    pub cache_breakpoint: Option<bool>,
74    /// Opt-in cache-aligner volatile-field telemetry (#940). When enabled, the
75    /// proxy scans each *unanchored* Anthropic system prompt for volatile,
76    /// cache-busting fields (ISO dates/datetimes, UUIDs, git SHAs) and records how
77    /// many it found on `/status` `cache_safety` — purely to quantify how much
78    /// prompt-cache the client is leaking. **Measurement only**: the request body
79    /// is never mutated, so it is strictly cache-safe. `None`/`false` (the default)
80    /// skips the scan entirely. See [`ProxyConfig::cache_aligner_enabled`].
81    pub cache_aligner: Option<bool>,
82    /// Cache-safe, cross-provider reasoning-effort control (#834). One of
83    /// `minimal|low|medium|high` pins the model's reasoning depth across every
84    /// provider; `None`/`"off"` (the default) is a strict no-op. The value is a
85    /// constant — identical on every request — so the provider prompt-cache
86    /// prefix stays byte-stable (#448/#498) and only the model's reasoning depth
87    /// changes. lean-ctx translates it to each provider's native parameter and
88    /// only ever *fills* it (never overrides a client-set value), on models that
89    /// accept it. Per-turn effort switching is deliberately unsupported — it
90    /// would invalidate the prompt cache. Env `LEAN_CTX_PROXY_EFFORT`. See
91    /// [`ProxyConfig::resolved_effort`].
92    pub effort: Option<String>,
93    /// How the proxy squeezes prose it must shrink (#895): `"auto"` (default) and
94    /// `"extractive"` use embedding-based extractive ranking — keeping the most
95    /// central sentences instead of just the prefix — when the local embedding
96    /// engine is available, falling back to truncation otherwise; `"truncate"`
97    /// keeps the original deterministic FIFO squeeze (and no engine). Wire
98    /// rewrites are memoized per content so the engine's cold→warm transition
99    /// never changes an already-emitted frozen-region rewrite (#448/#498). Env
100    /// `LEAN_CTX_PROXY_PROSE_RANKER`. See [`ProxyConfig::resolved_prose_ranker`].
101    pub prose_ranker: Option<String>,
102    /// Fraction `0.0..=1.0` of conversations placed in the output-savings control
103    /// arm (#895 Track B). `0` (default) = no holdout (every conversation is
104    /// shaped). When `> 0`, a deterministic cohort = `blake3(system + first user
105    /// msg)` puts ~this fraction of conversations in a control arm that skips
106    /// output-shaping (effort control + verbosity steer) but is still metered —
107    /// giving an honest measured output-token reduction. The cohort is a pure
108    /// function of conversation identity, so a conversation stays in one arm
109    /// across turns (cache-safe). Env `LEAN_CTX_PROXY_OUTPUT_HOLDOUT`. See
110    /// [`ProxyConfig::output_holdout_fraction`].
111    pub output_holdout: Option<f64>,
112    /// Opt-in cache-safe wire verbosity steer (#895). When `true`, the proxy
113    /// appends a single constant "be concise" instruction to the last user turn
114    /// of each request (output-shaping for non-rules-aware API clients). The
115    /// suffix is constant and appended strictly after the last `cache_control`
116    /// breakpoint, so the provider prompt-cache prefix stays byte-stable. Default
117    /// `false`. Env `LEAN_CTX_PROXY_VERBOSITY_STEER`. See
118    /// [`ProxyConfig::verbosity_steer_enabled`].
119    pub verbosity_steer: Option<bool>,
120}
121
122/// Per-role prose-compression intensity for the proxy's frozen request region.
123///
124/// Each value is a `0.0–1.0` aggressiveness level reusing the same mapping as
125/// the `ctx_read` knob (#708): `0.0` keeps everything, `1.0` is most aggressive.
126/// `None` (the default) means "do not compress this role's prose" so the proxy
127/// stays byte-for-byte unchanged until an operator opts in. The `assistant`
128/// role is never represented here — model turns are always passed through
129/// verbatim (the #710 passthrough guarantee).
130#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
131#[serde(default)]
132pub struct RoleAggressiveness {
133    /// Aggressiveness for system prompts (Anthropic `system` / OpenAI `system`
134    /// messages / Gemini `systemInstruction`). `None` = leave untouched.
135    pub system: Option<f64>,
136    /// Aggressiveness for user prose (free-text user turns, never tool results).
137    /// `None` = leave untouched.
138    pub user: Option<f64>,
139}
140
141/// The conversation roles whose prose the proxy may compress in the frozen
142/// region. Deliberately excludes `assistant` — model turns are never rewritten.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum ProseRole {
145    System,
146    User,
147}
148
149/// How the proxy squeezes prose it must shrink (#895).
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum ProseRanker {
152    /// Extractive embedding ranking when the engine is available, else truncate.
153    /// The default — strictly better than truncation, and cache-safe via the
154    /// per-content memo in [`crate::proxy::prose_ranker`].
155    Auto,
156    /// Same engine path as `Auto` (kept distinct so an operator can express
157    /// intent / so a future "require engine" semantic has a name).
158    Extractive,
159    /// Original deterministic FIFO squeeze; never touches the embedding engine.
160    Truncate,
161}
162
163/// How the proxy prunes old tool results from conversation history.
164///
165/// Provider prompt caches (Anthropic `cache_control`, OpenAI automatic prompt
166/// caching) bill cached prefix tokens at a fraction of the base rate but only
167/// match *exact* prefixes. Any mutation whose position depends on the current
168/// conversation length (a rolling window) rewrites a previously-stable message
169/// every turn, invalidating the cache from that point — turning cheap cache
170/// reads into full-price writes.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum HistoryMode {
173    /// Prune only at frozen generation boundaries that advance in large,
174    /// deterministic steps. Between jumps the request prefix is byte-stable,
175    /// so provider prompt caches keep hitting. Content the client has marked
176    /// with a `cache_control` breakpoint is never rewritten, so an advancing
177    /// boundary can no longer invalidate the already-cached prefix (#448).
178    /// Default.
179    CacheAware,
180    /// Legacy behaviour: summarize everything older than the last N messages.
181    /// Maximum raw-token reduction, but defeats provider prompt caching.
182    Rolling,
183    /// Never prune history (tool-result compression still applies — it is
184    /// content-deterministic and therefore prefix-stable).
185    Off,
186}
187
188impl ProxyConfig {
189    /// Resolved history mode: `LEAN_CTX_PROXY_HISTORY_MODE` env var wins,
190    /// then `[proxy].history_mode` in config.toml, then cache-aware.
191    /// Unknown values fall back to the default so a typo can never silently
192    /// re-enable the cache-hostile rolling mode.
193    pub fn resolved_history_mode(&self) -> HistoryMode {
194        let raw = std::env::var("LEAN_CTX_PROXY_HISTORY_MODE")
195            .ok()
196            .or_else(|| self.history_mode.clone());
197        match raw.as_deref().map(str::trim) {
198            Some(s) if s.eq_ignore_ascii_case("rolling") => HistoryMode::Rolling,
199            Some(s) if s.eq_ignore_ascii_case("off") => HistoryMode::Off,
200            _ => HistoryMode::CacheAware,
201        }
202    }
203
204    /// Whether the proxy injects `stream_options.include_usage` into streamed
205    /// OpenAI Chat Completions to meter real spend. `[proxy] meter_openai_usage`
206    /// in config.toml, default `true`.
207    pub fn meters_openai_usage(&self) -> bool {
208        self.meter_openai_usage.unwrap_or(true)
209    }
210
211    /// Resolved prose-ranker strategy (#895). Precedence: the
212    /// `LEAN_CTX_PROXY_PROSE_RANKER` env var, then `[proxy] prose_ranker` in
213    /// config.toml, then `Auto`. Unknown values resolve to `Auto` so a typo can
214    /// never silently disable the premium path; `"truncate"`/`"off"` selects the
215    /// legacy squeeze.
216    #[must_use]
217    pub fn resolved_prose_ranker(&self) -> ProseRanker {
218        let raw = std::env::var("LEAN_CTX_PROXY_PROSE_RANKER")
219            .ok()
220            .or_else(|| self.prose_ranker.clone());
221        match raw.as_deref().map(str::trim) {
222            Some(s) if s.eq_ignore_ascii_case("truncate") || s.eq_ignore_ascii_case("off") => {
223                ProseRanker::Truncate
224            }
225            Some(s) if s.eq_ignore_ascii_case("extractive") => ProseRanker::Extractive,
226            _ => ProseRanker::Auto,
227        }
228    }
229
230    /// Resolved output-savings holdout fraction (#895 Track B), clamped to
231    /// `[0,1]`. Precedence: `LEAN_CTX_PROXY_OUTPUT_HOLDOUT` env > `[proxy]
232    /// output_holdout` > `0.0` (no holdout). An unparseable/blank env value is
233    /// ignored so a typo can never silently change the experiment fraction.
234    #[must_use]
235    pub fn output_holdout_fraction(&self) -> f64 {
236        let from_env = std::env::var("LEAN_CTX_PROXY_OUTPUT_HOLDOUT")
237            .ok()
238            .and_then(|v| v.trim().parse::<f64>().ok());
239        from_env
240            .or(self.output_holdout)
241            .unwrap_or(0.0)
242            .clamp(0.0, 1.0)
243    }
244
245    /// Whether the cache-safe wire verbosity steer (#895) is enabled. Precedence:
246    /// `LEAN_CTX_PROXY_VERBOSITY_STEER` env (`1`/`true`/`on`) > `[proxy]
247    /// verbosity_steer` > `false` (off).
248    #[must_use]
249    pub fn verbosity_steer_enabled(&self) -> bool {
250        if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_VERBOSITY_STEER") {
251            let v = raw.trim();
252            return v.eq_ignore_ascii_case("1")
253                || v.eq_ignore_ascii_case("true")
254                || v.eq_ignore_ascii_case("on")
255                || v.eq_ignore_ascii_case("yes");
256        }
257        self.verbosity_steer.unwrap_or(false)
258    }
259
260    /// Whether the opt-in cold-prefix repack (#480) is enabled. A wrong "cold"
261    /// guess re-bills cache reads as writes (~12x), so this is off by default and
262    /// must be explicitly enabled. `LEAN_CTX_PROXY_COLD_PREFIX_REPACK` (any
263    /// value) wins, then `[proxy] cold_prefix_repack` in config.toml, else
264    /// `false`.
265    pub fn repacks_cold_prefix(&self) -> bool {
266        std::env::var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK").is_ok()
267            || self.cold_prefix_repack.unwrap_or(false)
268    }
269
270    /// Whether opt-in in-band CCR retrieval (#493) is enabled. Off by default:
271    /// the splice mutates provider-visible conversation content for the one turn
272    /// the model asks to expand, so it must be an explicit opt-in.
273    /// `LEAN_CTX_PROXY_CCR_INBAND` (any value) wins, then `[proxy] ccr_inband` in
274    /// config.toml, else `false`.
275    pub fn ccr_inband_enabled(&self) -> bool {
276        std::env::var("LEAN_CTX_PROXY_CCR_INBAND").is_ok() || self.ccr_inband.unwrap_or(false)
277    }
278
279    /// Whether opt-in Anthropic prompt-cache breakpoint injection (#939) is
280    /// enabled. Off by default: it mutates the provider-visible `system` shape
281    /// (string → cache-marked block array), so it must be an explicit opt-in.
282    /// `LEAN_CTX_PROXY_CACHE_BREAKPOINT` (any value) wins, then `[proxy]
283    /// cache_breakpoint` in config.toml, else `false`.
284    pub fn cache_breakpoint_enabled(&self) -> bool {
285        std::env::var("LEAN_CTX_PROXY_CACHE_BREAKPOINT").is_ok()
286            || self.cache_breakpoint.unwrap_or(false)
287    }
288
289    /// Whether opt-in cache-aligner volatile-field telemetry (#940) is enabled.
290    /// Off by default: it adds a per-request scan of the system prompt (pure
291    /// measurement, no body mutation). `LEAN_CTX_PROXY_CACHE_ALIGNER` (any value)
292    /// wins, then `[proxy] cache_aligner` in config.toml, else `false`.
293    pub fn cache_aligner_enabled(&self) -> bool {
294        std::env::var("LEAN_CTX_PROXY_CACHE_ALIGNER").is_ok() || self.cache_aligner.unwrap_or(false)
295    }
296
297    /// Resolved cross-provider reasoning effort (#834), or `None` when the
298    /// feature is off (the default — a strict no-op that preserves the
299    /// byte-unchanged meter-only path). Precedence: `LEAN_CTX_PROXY_EFFORT` env
300    /// (`off` disables, a valid level wins, an unparseable/blank value is
301    /// ignored) > `[proxy] effort` in config.toml. Any unknown value resolves to
302    /// `None` so a typo can never silently enable reasoning steering.
303    #[must_use]
304    pub fn resolved_effort(&self) -> Option<super::Effort> {
305        if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_EFFORT") {
306            let trimmed = raw.trim();
307            if trimmed.eq_ignore_ascii_case("off") {
308                return None;
309            }
310            if let Some(effort) = super::Effort::parse(trimmed) {
311                return Some(effort);
312            }
313            // Blank/unknown env → ignore and fall through to config, mirroring
314            // `live_compresses` so a typo never flips the configured behaviour.
315        }
316        self.effort.as_deref().and_then(super::Effort::parse)
317    }
318
319    /// Whether the proxy live-compresses non-protected `tool_result` content
320    /// (#481). `LEAN_CTX_PROXY_LIVE_COMPRESS` (`0`/`false`/`off`/`no` → off,
321    /// `1`/`true`/`on`/`yes` → on) wins, then `[proxy] live_compress` in
322    /// config.toml, else `true`. An unparseable/blank env value is ignored so a
323    /// typo can never silently flip the mode.
324    pub fn live_compresses(&self) -> bool {
325        if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_LIVE_COMPRESS") {
326            match raw.trim().to_ascii_lowercase().as_str() {
327                "0" | "false" | "off" | "no" => return false,
328                "1" | "true" | "on" | "yes" => return true,
329                _ => {}
330            }
331        }
332        self.live_compress.unwrap_or(true)
333    }
334
335    /// Resolved per-tool live-compress exclusion patterns (#481). `None` in
336    /// config falls back to the built-in default (protect Serena); an explicit
337    /// list — including the empty list — is used verbatim so operators can narrow
338    /// or fully clear it.
339    #[must_use]
340    pub fn live_compress_exclude_patterns(&self) -> Vec<String> {
341        self.live_compress_exclude
342            .clone()
343            .unwrap_or_else(default_live_compress_exclude)
344    }
345
346    /// Whether `tool_name` is on the live-compress exclusion list (#481) and must
347    /// therefore reach the model intact, like a protected file read. Matching is
348    /// case-insensitive substring, mirroring `tool_kind::classify_tool_name`.
349    #[must_use]
350    pub fn is_tool_live_compress_excluded(&self, tool_name: &str) -> bool {
351        let name = tool_name.to_ascii_lowercase();
352        self.live_compress_exclude_patterns().iter().any(|p| {
353            let p = p.trim().to_ascii_lowercase();
354            !p.is_empty() && name.contains(p.as_str())
355        })
356    }
357
358    /// Resolved prose-compression aggressiveness for `role`, clamped to `[0,1]`,
359    /// or `None` when prose compression is off for that role (the default).
360    ///
361    /// Precedence: the role's env override (`LEAN_CTX_PROXY_SYSTEM_AGGR` /
362    /// `LEAN_CTX_PROXY_USER_AGGR`) wins, then `[proxy.role_aggressiveness]` in
363    /// config.toml. An unparseable or blank env value is ignored so a typo can
364    /// never silently disable the configured behaviour.
365    #[must_use]
366    pub fn resolved_role_aggressiveness(&self, role: ProseRole) -> Option<f64> {
367        let (env_var, configured) = match role {
368            ProseRole::System => (
369                "LEAN_CTX_PROXY_SYSTEM_AGGR",
370                self.role_aggressiveness.system,
371            ),
372            ProseRole::User => ("LEAN_CTX_PROXY_USER_AGGR", self.role_aggressiveness.user),
373        };
374        let from_env = std::env::var(env_var)
375            .ok()
376            .and_then(|v| v.trim().parse::<f64>().ok());
377        from_env.or(configured).map(|a| a.clamp(0.0, 1.0))
378    }
379
380    /// Whether a non-loopback plaintext `http://` upstream is allowed. Opt-in
381    /// only — a deliberate downgrade for a trusted local-network service such as
382    /// `http://host.docker.internal:2455` in front of codex-lb (#440).
383    /// `LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM` (any value) wins, then
384    /// `[proxy] allow_insecure_http_upstream` in config.toml, default `false`.
385    pub fn allows_insecure_http_upstream(&self) -> bool {
386        std::env::var("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM").is_ok()
387            || self.allow_insecure_http_upstream.unwrap_or(false)
388    }
389
390    /// `(env var, configured value, provider default)` for one provider.
391    fn provider_spec(&self, provider: ProxyProvider) -> (&'static str, Option<&str>, &'static str) {
392        match provider {
393            ProxyProvider::Anthropic => (
394                "LEAN_CTX_ANTHROPIC_UPSTREAM",
395                self.anthropic_upstream.as_deref(),
396                "https://api.anthropic.com",
397            ),
398            ProxyProvider::OpenAi => (
399                "LEAN_CTX_OPENAI_UPSTREAM",
400                self.openai_upstream.as_deref(),
401                "https://api.openai.com",
402            ),
403            ProxyProvider::ChatGpt => (
404                "LEAN_CTX_CHATGPT_UPSTREAM",
405                self.chatgpt_upstream.as_deref(),
406                "https://chatgpt.com",
407            ),
408            ProxyProvider::Gemini => (
409                "LEAN_CTX_GEMINI_UPSTREAM",
410                self.gemini_upstream.as_deref(),
411                "https://generativelanguage.googleapis.com",
412            ),
413        }
414    }
415
416    /// Resolve one upstream with precedence `LEAN_CTX_*_UPSTREAM` env var >
417    /// `[proxy].*_upstream` (config.toml) > provider default.
418    ///
419    /// Returns `Err` when a value is *present but invalid* so a live reload can
420    /// keep the last good value instead of silently rerouting to the default; an
421    /// *absent* value resolves to the provider default (`Ok`).
422    fn resolve_upstream_checked(&self, provider: ProxyProvider) -> Result<String, String> {
423        self.resolve_upstream_inner(provider, true)
424    }
425
426    /// Shared resolver for [`resolve_upstream_checked`] and the disk-only view.
427    /// `use_env = false` ignores the `LEAN_CTX_*_UPSTREAM` override and yields
428    /// the config.toml truth a freshly (re)started managed proxy would serve.
429    fn resolve_upstream_inner(
430        &self,
431        provider: ProxyProvider,
432        use_env: bool,
433    ) -> Result<String, String> {
434        let (env_var, config_val, default) = self.provider_spec(provider);
435        let env_val = if use_env {
436            std::env::var(env_var)
437                .ok()
438                .and_then(|v| normalize_url_opt(&v))
439        } else {
440            None
441        };
442        let candidate = env_val.or_else(|| config_val.and_then(normalize_url_opt));
443        match candidate {
444            None => Ok(normalize_url(default)),
445            Some(url) => validate_upstream_url(&url, self.allows_insecure_http_upstream()),
446        }
447    }
448
449    /// Effective upstream for a provider (env > config > default). An invalid
450    /// configured/env value falls back to the provider default (logged) — the
451    /// safe choice at startup.
452    pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
453        match self.resolve_upstream_checked(provider) {
454            Ok(url) => url,
455            Err(e) => {
456                tracing::warn!("upstream validation failed, using default: {e}");
457                normalize_url(self.provider_spec(provider).2)
458            }
459        }
460    }
461
462    /// Resolve all three upstreams at once (startup snapshot, env-aware).
463    pub fn resolve_all(&self) -> Upstreams {
464        Upstreams {
465            anthropic: self.resolve_upstream(ProxyProvider::Anthropic),
466            openai: self.resolve_upstream(ProxyProvider::OpenAi),
467            chatgpt: self.resolve_upstream(ProxyProvider::ChatGpt),
468            gemini: self.resolve_upstream(ProxyProvider::Gemini),
469        }
470    }
471
472    /// Resolve all upstreams from config.toml only (ignoring `LEAN_CTX_*` env) —
473    /// the values a freshly (re)started managed proxy would serve. Used by
474    /// status/doctor to detect drift from a running proxy's live upstream (#449).
475    pub fn resolve_all_disk(&self) -> Upstreams {
476        let pick = |provider: ProxyProvider| {
477            self.resolve_upstream_inner(provider, false)
478                .unwrap_or_else(|_| normalize_url(self.provider_spec(provider).2))
479        };
480        Upstreams {
481            anthropic: pick(ProxyProvider::Anthropic),
482            openai: pick(ProxyProvider::OpenAi),
483            chatgpt: pick(ProxyProvider::ChatGpt),
484            gemini: pick(ProxyProvider::Gemini),
485        }
486    }
487
488    /// Re-resolve upstreams for a *running* proxy (#449). For any provider whose
489    /// currently configured/env value fails validation, the last good value is
490    /// kept instead of rerouting live traffic to the provider default — so a typo
491    /// in config.toml can never silently redirect in-flight requests.
492    pub fn refresh_upstreams(&self, last: &Upstreams) -> Upstreams {
493        let keep = |provider: ProxyProvider, prev: &str| {
494            self.resolve_upstream_checked(provider).unwrap_or_else(|e| {
495                tracing::warn!("upstream invalid, keeping {prev}: {e}");
496                prev.to_string()
497            })
498        };
499        Upstreams {
500            anthropic: keep(ProxyProvider::Anthropic, &last.anthropic),
501            openai: keep(ProxyProvider::OpenAi, &last.openai),
502            chatgpt: keep(ProxyProvider::ChatGpt, &last.chatgpt),
503            gemini: keep(ProxyProvider::Gemini, &last.gemini),
504        }
505    }
506}
507
508/// The three resolved provider upstreams a running proxy forwards to. Published
509/// to request handlers via a `tokio::sync::watch` channel so a config change is
510/// picked up live, without a proxy restart (#449).
511#[derive(Debug, Clone, PartialEq, Eq)]
512pub struct Upstreams {
513    pub anthropic: String,
514    pub openai: String,
515    pub chatgpt: String,
516    pub gemini: String,
517}
518
519#[derive(Debug, Clone, Copy)]
520pub enum ProxyProvider {
521    Anthropic,
522    OpenAi,
523    ChatGpt,
524    Gemini,
525}
526
527/// Why a running proxy's live upstream differs from what the operator expects.
528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
529pub enum UpstreamDrift {
530    /// A `LEAN_CTX_*_UPSTREAM` env var is set in *this* process but the proxy
531    /// serves a different value — the env never reached the MCP/service-spawned
532    /// proxy. This is the #449 trap: Codex (and other MCP hosts) launch the
533    /// server with a stripped, allowlisted env that omits `LEAN_CTX_*_UPSTREAM`,
534    /// so the proxy it spawns never sees it. Fix: persist it to config.toml,
535    /// which the proxy reads live.
536    EnvNotApplied,
537    /// The proxy serves a value other than config.toml resolves to: it was
538    /// started with an env override that now masks a later config edit. Fix:
539    /// `lean-ctx proxy restart`.
540    ConfigNotApplied,
541}
542
543/// The `LEAN_CTX_*_UPSTREAM` override visible to *this* process for a provider,
544/// normalized (`None` if unset/blank). Lets status/doctor explain why an env var
545/// a user exported in their shell never reaches an MCP/service-spawned proxy.
546pub fn env_upstream_override(provider: ProxyProvider) -> Option<String> {
547    let var = match provider {
548        ProxyProvider::Anthropic => "LEAN_CTX_ANTHROPIC_UPSTREAM",
549        ProxyProvider::OpenAi => "LEAN_CTX_OPENAI_UPSTREAM",
550        ProxyProvider::ChatGpt => "LEAN_CTX_CHATGPT_UPSTREAM",
551        ProxyProvider::Gemini => "LEAN_CTX_GEMINI_UPSTREAM",
552    };
553    std::env::var(var).ok().and_then(|v| normalize_url_opt(&v))
554}
555
556/// Diagnose upstream drift for one provider from the CLI-visible env override
557/// (`env`), the config.toml value (`disk`) and the proxy's live value (`live`).
558/// `None` means in sync.
559pub fn diagnose_drift(env: Option<&str>, disk: &str, live: &str) -> Option<UpstreamDrift> {
560    if let Some(env) = env {
561        // An env override is present in this process: the proxy honours it only
562        // if it was started with it. If the proxy serves something else, the env
563        // never reached it (#449). If it matches, that is consistent (no drift).
564        return (env != live).then_some(UpstreamDrift::EnvNotApplied);
565    }
566    // No env override here: the proxy should mirror config.toml.
567    (disk != live).then_some(UpstreamDrift::ConfigNotApplied)
568}
569
570/// Built-in default live-compress exclusion (#481). Serena's code-reading tools
571/// (`find_symbol`/`find_referencing_symbols`/`search_for_pattern`) return source
572/// bodies the model edits, yet are mis-bucketed as `Search` by name, so the proxy
573/// would otherwise gut them. Protect anything namespaced `serena` by default.
574fn default_live_compress_exclude() -> Vec<String> {
575    vec!["serena".to_string()]
576}
577
578pub fn normalize_url(value: &str) -> String {
579    value.trim().trim_end_matches('/').to_string()
580}
581
582pub fn normalize_url_opt(value: &str) -> Option<String> {
583    let trimmed = normalize_url(value);
584    if trimmed.is_empty() {
585        None
586    } else {
587        Some(trimmed)
588    }
589}
590
591const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
592    "api.anthropic.com",
593    "api.openai.com",
594    "chatgpt.com",
595    "generativelanguage.googleapis.com",
596];
597
598pub(super) fn validate_upstream_url(
599    url: &str,
600    allow_insecure_http: bool,
601) -> Result<String, String> {
602    let normalized = normalize_url(url);
603    // Loopback HTTP never leaves the machine — always allowed.
604    if is_local_proxy_url(&normalized) {
605        return Ok(normalized);
606    }
607
608    // A non-loopback plaintext `http://` upstream is reachable only through the
609    // explicit opt-in (#440). The old code rejected it on the HTTPS check *before*
610    // any override could apply, and pointed at `LEAN_CTX_ALLOW_CUSTOM_UPSTREAM`,
611    // which never lifted the scheme restriction. Handle it up front: the opt-in
612    // implies a deliberate custom host on a trusted local network, so it needs no
613    // separate allowlist check; otherwise give a hint that actually works.
614    if normalized.starts_with("http://") {
615        if allow_insecure_http {
616            return Ok(normalized);
617        }
618        return Err(format!(
619            "upstream URL must use HTTPS: {normalized} (for a trusted local-network HTTP \
620             upstream opt in with LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM=1 or \
621             `[proxy] allow_insecure_http_upstream = true`)"
622        ));
623    }
624    let Some(host_segment) = normalized.strip_prefix("https://") else {
625        return Err(format!(
626            "upstream URL must start with http:// or https://: {normalized}"
627        ));
628    };
629
630    let host = host_segment.split('/').next().unwrap_or("");
631    let host_no_port = host.split(':').next().unwrap_or(host);
632    if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
633        || std::env::var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM").is_ok()
634    {
635        Ok(normalized)
636    } else {
637        Err(format!(
638            "upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (set LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 to override)"
639        ))
640    }
641}
642
643pub fn is_local_proxy_url(value: &str) -> bool {
644    let n = normalize_url(value);
645    n.starts_with("http://127.0.0.1:")
646        || n.starts_with("http://localhost:")
647        || n.starts_with("http://[::1]:")
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    #[test]
655    fn loopback_http_is_always_allowed() {
656        assert_eq!(
657            validate_upstream_url("http://127.0.0.1:4444", false).unwrap(),
658            "http://127.0.0.1:4444"
659        );
660        assert_eq!(
661            validate_upstream_url("http://localhost:2455/", false).unwrap(),
662            "http://localhost:2455"
663        );
664    }
665
666    #[test]
667    fn https_allowlisted_host_is_allowed() {
668        assert_eq!(
669            validate_upstream_url("https://api.openai.com", false).unwrap(),
670            "https://api.openai.com"
671        );
672    }
673
674    #[test]
675    fn non_loopback_http_is_rejected_without_optin() {
676        let err = validate_upstream_url("http://host.docker.internal:2455", false).unwrap_err();
677        // The hint must point at the flag that actually lifts the scheme check
678        // (#440). The old message pointed at LEAN_CTX_ALLOW_CUSTOM_UPSTREAM,
679        // which never bypassed the HTTPS requirement.
680        assert!(
681            err.contains("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM"),
682            "hint must name the working opt-in, got: {err}"
683        );
684    }
685
686    #[test]
687    fn non_loopback_http_is_allowed_with_optin() {
688        assert_eq!(
689            validate_upstream_url("http://host.docker.internal:2455", true).unwrap(),
690            "http://host.docker.internal:2455"
691        );
692    }
693
694    #[test]
695    fn unknown_scheme_is_rejected() {
696        assert!(validate_upstream_url("ftp://example.com", true).is_err());
697    }
698
699    #[test]
700    fn cold_prefix_repack_is_opt_in_and_config_enables() {
701        // #480: off by default (a wrong cold guess re-bills reads as writes ~12x),
702        // enabled via config. Isolate from a developer shell that may export the
703        // env override.
704        let _lock = crate::core::data_dir::test_env_lock();
705        crate::test_env::remove_var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK");
706        assert!(
707            !ProxyConfig::default().repacks_cold_prefix(),
708            "cold-prefix repack must be opt-in (off by default)"
709        );
710        let cfg = ProxyConfig {
711            cold_prefix_repack: Some(true),
712            ..Default::default()
713        };
714        assert!(cfg.repacks_cold_prefix());
715    }
716
717    #[test]
718    fn ccr_inband_is_opt_in_and_config_enables() {
719        // #493: off by default (the splice mutates provider-visible content for
720        // the expand turn), enabled via config. Isolate from a developer shell
721        // that may export the env override.
722        let _lock = crate::core::data_dir::test_env_lock();
723        crate::test_env::remove_var("LEAN_CTX_PROXY_CCR_INBAND");
724        assert!(
725            !ProxyConfig::default().ccr_inband_enabled(),
726            "in-band CCR must be opt-in (off by default)"
727        );
728        let cfg = ProxyConfig {
729            ccr_inband: Some(true),
730            ..Default::default()
731        };
732        assert!(cfg.ccr_inband_enabled());
733    }
734
735    #[test]
736    fn cache_breakpoint_is_opt_in_and_config_enables() {
737        // #939: off by default (it reshapes the provider-visible system field),
738        // enabled via config. Isolate from a developer shell that may export the
739        // env override.
740        let _lock = crate::core::data_dir::test_env_lock();
741        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_BREAKPOINT");
742        assert!(
743            !ProxyConfig::default().cache_breakpoint_enabled(),
744            "cache-breakpoint injection must be opt-in (off by default)"
745        );
746        let cfg = ProxyConfig {
747            cache_breakpoint: Some(true),
748            ..Default::default()
749        };
750        assert!(cfg.cache_breakpoint_enabled());
751    }
752
753    #[test]
754    fn cache_aligner_is_opt_in_and_config_enables() {
755        // #940: off by default (it adds a per-request system-prompt scan, even
756        // though it never mutates the body). Isolate from a developer shell that
757        // may export the env override.
758        let _lock = crate::core::data_dir::test_env_lock();
759        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_ALIGNER");
760        assert!(
761            !ProxyConfig::default().cache_aligner_enabled(),
762            "cache-aligner telemetry must be opt-in (off by default)"
763        );
764        let cfg = ProxyConfig {
765            cache_aligner: Some(true),
766            ..Default::default()
767        };
768        assert!(cfg.cache_aligner_enabled());
769    }
770
771    #[test]
772    fn effort_defaults_off_and_config_sets_it() {
773        // #834: cache-safe effort control is opt-in. Isolate from a developer
774        // shell that may export the env override.
775        let _lock = crate::core::data_dir::test_env_lock();
776        crate::test_env::remove_var("LEAN_CTX_PROXY_EFFORT");
777        assert_eq!(
778            ProxyConfig::default().resolved_effort(),
779            None,
780            "effort control must be opt-in (off by default)"
781        );
782        let cfg = ProxyConfig {
783            effort: Some("low".into()),
784            ..Default::default()
785        };
786        assert_eq!(
787            cfg.resolved_effort(),
788            Some(crate::core::config::Effort::Low)
789        );
790        // An unknown configured value resolves to off — never a silent default.
791        let typo = ProxyConfig {
792            effort: Some("lowish".into()),
793            ..Default::default()
794        };
795        assert_eq!(typo.resolved_effort(), None);
796    }
797
798    #[test]
799    fn effort_env_overrides_and_off_disables() {
800        use crate::core::config::Effort;
801        let _lock = crate::core::data_dir::test_env_lock();
802        let cfg = ProxyConfig {
803            effort: Some("high".into()),
804            ..Default::default()
805        };
806        // A valid env level wins over config.
807        crate::test_env::set_var("LEAN_CTX_PROXY_EFFORT", "minimal");
808        assert_eq!(cfg.resolved_effort(), Some(Effort::Minimal));
809        // `off` explicitly disables even a configured level.
810        crate::test_env::set_var("LEAN_CTX_PROXY_EFFORT", "off");
811        assert_eq!(cfg.resolved_effort(), None);
812        // A blank/garbage env value is ignored → falls back to config.
813        crate::test_env::set_var("LEAN_CTX_PROXY_EFFORT", "   ");
814        assert_eq!(cfg.resolved_effort(), Some(Effort::High));
815        crate::test_env::remove_var("LEAN_CTX_PROXY_EFFORT");
816    }
817
818    #[test]
819    fn prose_ranker_defaults_to_auto_and_config_sets_it() {
820        // #895: premium extractive path is the default; `truncate`/`off` selects
821        // the legacy squeeze; a typo can never silently disable the premium path.
822        let _lock = crate::core::data_dir::test_env_lock();
823        crate::test_env::remove_var("LEAN_CTX_PROXY_PROSE_RANKER");
824        assert_eq!(
825            ProxyConfig::default().resolved_prose_ranker(),
826            ProseRanker::Auto
827        );
828        let truncate = ProxyConfig {
829            prose_ranker: Some("truncate".into()),
830            ..Default::default()
831        };
832        assert_eq!(truncate.resolved_prose_ranker(), ProseRanker::Truncate);
833        let off = ProxyConfig {
834            prose_ranker: Some("off".into()),
835            ..Default::default()
836        };
837        assert_eq!(off.resolved_prose_ranker(), ProseRanker::Truncate);
838        let extractive = ProxyConfig {
839            prose_ranker: Some("extractive".into()),
840            ..Default::default()
841        };
842        assert_eq!(extractive.resolved_prose_ranker(), ProseRanker::Extractive);
843        let typo = ProxyConfig {
844            prose_ranker: Some("extractiveish".into()),
845            ..Default::default()
846        };
847        assert_eq!(
848            typo.resolved_prose_ranker(),
849            ProseRanker::Auto,
850            "unknown value must resolve to Auto, never silently off"
851        );
852    }
853
854    #[test]
855    fn output_holdout_defaults_off_and_clamps() {
856        let _lock = crate::core::data_dir::test_env_lock();
857        crate::test_env::remove_var("LEAN_CTX_PROXY_OUTPUT_HOLDOUT");
858        assert_eq!(ProxyConfig::default().output_holdout_fraction(), 0.0);
859        let cfg = ProxyConfig {
860            output_holdout: Some(0.2),
861            ..Default::default()
862        };
863        assert!((cfg.output_holdout_fraction() - 0.2).abs() < f64::EPSILON);
864        let over = ProxyConfig {
865            output_holdout: Some(5.0),
866            ..Default::default()
867        };
868        assert_eq!(over.output_holdout_fraction(), 1.0, "clamped into [0,1]");
869    }
870
871    #[test]
872    fn verbosity_steer_defaults_off_and_env_overrides() {
873        let _lock = crate::core::data_dir::test_env_lock();
874        crate::test_env::remove_var("LEAN_CTX_PROXY_VERBOSITY_STEER");
875        assert!(!ProxyConfig::default().verbosity_steer_enabled());
876        let cfg = ProxyConfig {
877            verbosity_steer: Some(true),
878            ..Default::default()
879        };
880        assert!(cfg.verbosity_steer_enabled());
881        crate::test_env::set_var("LEAN_CTX_PROXY_VERBOSITY_STEER", "on");
882        assert!(ProxyConfig::default().verbosity_steer_enabled());
883        crate::test_env::remove_var("LEAN_CTX_PROXY_VERBOSITY_STEER");
884    }
885
886    #[test]
887    fn prose_ranker_env_overrides_config() {
888        let _lock = crate::core::data_dir::test_env_lock();
889        let cfg = ProxyConfig {
890            prose_ranker: Some("auto".into()),
891            ..Default::default()
892        };
893        crate::test_env::set_var("LEAN_CTX_PROXY_PROSE_RANKER", "truncate");
894        assert_eq!(cfg.resolved_prose_ranker(), ProseRanker::Truncate);
895        crate::test_env::remove_var("LEAN_CTX_PROXY_PROSE_RANKER");
896    }
897
898    #[test]
899    fn config_flag_enables_insecure_http_optin() {
900        // `Some(true)` resolves to `true` regardless of the environment, so this
901        // assertion is robust without mutating process-global env vars.
902        let cfg = ProxyConfig {
903            allow_insecure_http_upstream: Some(true),
904            ..Default::default()
905        };
906        assert!(cfg.allows_insecure_http_upstream());
907    }
908
909    /// `resolve_all_disk` ignores `LEAN_CTX_*_UPSTREAM` env by construction, so
910    /// these assertions are env-independent (no lock needed). Loopback HTTP is an
911    /// always-valid custom upstream (no allowlist / opt-in required).
912    #[test]
913    fn resolve_all_disk_uses_config_then_default() {
914        let cfg = ProxyConfig {
915            openai_upstream: Some("http://127.0.0.1:19101".into()),
916            ..Default::default()
917        };
918        let up = cfg.resolve_all_disk();
919        assert_eq!(up.openai, "http://127.0.0.1:19101");
920        assert_eq!(up.anthropic, "https://api.anthropic.com");
921        assert_eq!(up.chatgpt, "https://chatgpt.com");
922        assert_eq!(up.gemini, "https://generativelanguage.googleapis.com");
923    }
924
925    #[test]
926    fn resolve_all_disk_normalizes_trailing_slash() {
927        let cfg = ProxyConfig {
928            openai_upstream: Some("http://127.0.0.1:19101/".into()),
929            ..Default::default()
930        };
931        assert_eq!(cfg.resolve_all_disk().openai, "http://127.0.0.1:19101");
932    }
933
934    #[test]
935    fn refresh_keeps_last_good_on_invalid_config() {
936        // `refresh_upstreams` is env-aware; isolate from a developer's shell that
937        // may export LEAN_CTX_OPENAI_UPSTREAM (e.g. while reproducing #449).
938        let _lock = crate::core::data_dir::test_env_lock();
939        crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
940
941        // A typo in config.toml must never reroute a live proxy to the default.
942        let last = Upstreams {
943            anthropic: "https://api.anthropic.com".into(),
944            openai: "http://127.0.0.1:19101".into(),
945            chatgpt: "https://chatgpt.com".into(),
946            gemini: "https://generativelanguage.googleapis.com".into(),
947        };
948        let cfg = ProxyConfig {
949            openai_upstream: Some("not-a-valid-url".into()),
950            ..Default::default()
951        };
952        assert_eq!(
953            cfg.refresh_upstreams(&last).openai,
954            "http://127.0.0.1:19101",
955            "invalid upstream → keep last good, never silently fall to default"
956        );
957    }
958
959    #[test]
960    fn refresh_adopts_valid_config_change() {
961        let _lock = crate::core::data_dir::test_env_lock();
962        crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
963
964        let last = Upstreams {
965            anthropic: "https://api.anthropic.com".into(),
966            openai: "http://127.0.0.1:19101".into(),
967            chatgpt: "https://chatgpt.com".into(),
968            gemini: "https://generativelanguage.googleapis.com".into(),
969        };
970        let cfg = ProxyConfig {
971            openai_upstream: Some("http://127.0.0.1:19102".into()),
972            ..Default::default()
973        };
974        assert_eq!(
975            cfg.refresh_upstreams(&last).openai,
976            "http://127.0.0.1:19102"
977        );
978    }
979
980    #[test]
981    fn diagnose_drift_env_set_but_proxy_serves_other() {
982        // The exact #449 / Codex case: env exported in the shell, but the
983        // MCP-spawned proxy serves config.toml → the env never reached it.
984        assert_eq!(
985            diagnose_drift(
986                Some("http://127.0.0.1:2455"),
987                "https://api.openai.com",
988                "https://api.openai.com"
989            ),
990            Some(UpstreamDrift::EnvNotApplied)
991        );
992    }
993
994    #[test]
995    fn diagnose_drift_env_consistent_is_in_sync() {
996        // Proxy was started with the env value and serves it → not drift.
997        assert_eq!(
998            diagnose_drift(
999                Some("http://127.0.0.1:2455"),
1000                "https://api.openai.com",
1001                "http://127.0.0.1:2455"
1002            ),
1003            None
1004        );
1005    }
1006
1007    #[test]
1008    fn diagnose_drift_config_changed_needs_restart() {
1009        assert_eq!(
1010            diagnose_drift(None, "http://127.0.0.1:2455", "https://api.openai.com"),
1011            Some(UpstreamDrift::ConfigNotApplied)
1012        );
1013    }
1014
1015    #[test]
1016    fn diagnose_drift_in_sync() {
1017        assert_eq!(
1018            diagnose_drift(None, "https://api.openai.com", "https://api.openai.com"),
1019            None
1020        );
1021    }
1022
1023    #[test]
1024    fn role_aggressiveness_defaults_to_off() {
1025        // Opt-in: a fresh config compresses no prose, so the proxy stays
1026        // byte-for-byte unchanged until an operator sets a value (#710).
1027        let cfg = ProxyConfig::default();
1028        // Isolate from a developer shell that may export the override.
1029        let _lock = crate::core::data_dir::test_env_lock();
1030        crate::test_env::remove_var("LEAN_CTX_PROXY_SYSTEM_AGGR");
1031        crate::test_env::remove_var("LEAN_CTX_PROXY_USER_AGGR");
1032        assert_eq!(cfg.resolved_role_aggressiveness(ProseRole::System), None);
1033        assert_eq!(cfg.resolved_role_aggressiveness(ProseRole::User), None);
1034    }
1035
1036    #[test]
1037    fn role_aggressiveness_reads_config_and_clamps() {
1038        let _lock = crate::core::data_dir::test_env_lock();
1039        crate::test_env::remove_var("LEAN_CTX_PROXY_SYSTEM_AGGR");
1040        crate::test_env::remove_var("LEAN_CTX_PROXY_USER_AGGR");
1041        let cfg = ProxyConfig {
1042            role_aggressiveness: RoleAggressiveness {
1043                system: Some(0.7),
1044                user: Some(1.5),
1045            },
1046            ..Default::default()
1047        };
1048        assert_eq!(
1049            cfg.resolved_role_aggressiveness(ProseRole::System),
1050            Some(0.7)
1051        );
1052        // Out-of-range config values are clamped into [0,1].
1053        assert_eq!(cfg.resolved_role_aggressiveness(ProseRole::User), Some(1.0));
1054    }
1055
1056    #[test]
1057    fn role_aggressiveness_env_overrides_config() {
1058        let _lock = crate::core::data_dir::test_env_lock();
1059        crate::test_env::set_var("LEAN_CTX_PROXY_SYSTEM_AGGR", "0.25");
1060        let cfg = ProxyConfig {
1061            role_aggressiveness: RoleAggressiveness {
1062                system: Some(0.9),
1063                user: None,
1064            },
1065            ..Default::default()
1066        };
1067        assert_eq!(
1068            cfg.resolved_role_aggressiveness(ProseRole::System),
1069            Some(0.25),
1070            "env override must win over the configured value"
1071        );
1072        crate::test_env::remove_var("LEAN_CTX_PROXY_SYSTEM_AGGR");
1073    }
1074
1075    #[test]
1076    fn role_aggressiveness_ignores_blank_env() {
1077        let _lock = crate::core::data_dir::test_env_lock();
1078        crate::test_env::set_var("LEAN_CTX_PROXY_USER_AGGR", "  ");
1079        let cfg = ProxyConfig {
1080            role_aggressiveness: RoleAggressiveness {
1081                system: None,
1082                user: Some(0.4),
1083            },
1084            ..Default::default()
1085        };
1086        assert_eq!(
1087            cfg.resolved_role_aggressiveness(ProseRole::User),
1088            Some(0.4),
1089            "a blank/garbage env value must fall back to config, not disable it"
1090        );
1091        crate::test_env::remove_var("LEAN_CTX_PROXY_USER_AGGR");
1092    }
1093
1094    #[test]
1095    fn live_compress_defaults_on_and_config_disables() {
1096        // #481: default ON (today's behaviour); a config `false` opts into the
1097        // meter-only mode. Isolate from a developer shell exporting the override.
1098        let _lock = crate::core::data_dir::test_env_lock();
1099        crate::test_env::remove_var("LEAN_CTX_PROXY_LIVE_COMPRESS");
1100        assert!(
1101            ProxyConfig::default().live_compresses(),
1102            "live_compress must default to true"
1103        );
1104        let cfg = ProxyConfig {
1105            live_compress: Some(false),
1106            ..Default::default()
1107        };
1108        assert!(!cfg.live_compresses());
1109    }
1110
1111    #[test]
1112    fn live_compress_env_overrides_config() {
1113        let _lock = crate::core::data_dir::test_env_lock();
1114        // env `off` wins over a config `true`.
1115        crate::test_env::set_var("LEAN_CTX_PROXY_LIVE_COMPRESS", "off");
1116        let cfg = ProxyConfig {
1117            live_compress: Some(true),
1118            ..Default::default()
1119        };
1120        assert!(!cfg.live_compresses(), "env off must win over config true");
1121        // A garbage env value is ignored → falls back to config.
1122        crate::test_env::set_var("LEAN_CTX_PROXY_LIVE_COMPRESS", "maybe");
1123        assert!(
1124            cfg.live_compresses(),
1125            "unparseable env must fall back to config, not flip the mode"
1126        );
1127        crate::test_env::remove_var("LEAN_CTX_PROXY_LIVE_COMPRESS");
1128    }
1129
1130    #[test]
1131    fn live_compress_exclude_defaults_to_serena() {
1132        // #481: an unset list protects Serena's code-reading tools, which return
1133        // source bodies but are mis-bucketed as `Search` by name.
1134        let cfg = ProxyConfig::default();
1135        assert!(cfg.is_tool_live_compress_excluded("mcp__serena__find_symbol"));
1136        assert!(cfg.is_tool_live_compress_excluded("Serena.search_for_pattern"));
1137        assert!(!cfg.is_tool_live_compress_excluded("ctx_shell"));
1138    }
1139
1140    #[test]
1141    fn live_compress_exclude_explicit_list_replaces_default() {
1142        // An explicit list narrows the exclusion (Serena no longer protected).
1143        let cfg = ProxyConfig {
1144            live_compress_exclude: Some(vec!["my_reader".into()]),
1145            ..Default::default()
1146        };
1147        assert!(cfg.is_tool_live_compress_excluded("acme_my_reader_v2"));
1148        assert!(!cfg.is_tool_live_compress_excluded("mcp__serena__find_symbol"));
1149    }
1150
1151    #[test]
1152    fn live_compress_exclude_empty_list_disables_protection() {
1153        // `[]` fully clears the exclusion (operator opts every tool back in).
1154        let cfg = ProxyConfig {
1155            live_compress_exclude: Some(vec![]),
1156            ..Default::default()
1157        };
1158        assert!(!cfg.is_tool_live_compress_excluded("mcp__serena__find_symbol"));
1159    }
1160}