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