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