Skip to main content

lean_ctx/core/config/
proxy.rs

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