Skip to main content

lean_ctx/core/config/
proxy.rs

1//! API proxy upstream overrides (`config.toml`).
2
3use lean_ctx_protocol::ProtocolReference;
4use serde::{Deserialize, Serialize};
5
6#[path = "proxy_upstream.rs"]
7mod upstream;
8use upstream::is_custom_upstream_host;
9pub(super) use upstream::validate_upstream_url;
10pub use upstream::{
11    ProxyProvider, UpstreamDrift, Upstreams, diagnose_drift, env_upstream_override,
12    is_local_proxy_url, normalize_url, normalize_url_opt,
13};
14
15/// API proxy upstream overrides. `None` = use provider default.
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
17#[serde(default)]
18pub struct ProxyConfig {
19    pub anthropic_upstream: Option<String>,
20    pub openai_upstream: Option<String>,
21    pub chatgpt_upstream: Option<String>,
22    pub gemini_upstream: Option<String>,
23    /// Universal provider registry (`[[proxy.providers]]`): additional upstream
24    /// providers beyond the four built-ins, declared as data — id + wire shape +
25    /// base URL — so a new OpenAI/Anthropic/Gemini/Bedrock endpoint (Azure AI
26    /// Foundry, OpenRouter, Groq, vLLM/Ollama, a corporate gateway…) is a pure
27    /// config entry, never a code change. Reachable under
28    /// `/providers/{id}/...` on the proxy and addressable by the router.
29    /// The legacy `*_upstream` fields above stay authoritative for the four
30    /// built-in provider routes (backwards compatible).
31    pub providers: Vec<ProviderEntry>,
32    /// History-pruning strategy for proxied chat requests.
33    /// "cache-aware" (default) | "rolling" | "off". See [`HistoryMode`].
34    pub history_mode: Option<String>,
35    /// Allow a non-loopback plaintext `http://` upstream (trusted local network
36    /// only). Opt-in; see [`ProxyConfig::allows_insecure_http_upstream`]. (#440)
37    pub allow_insecure_http_upstream: Option<bool>,
38    /// Allow a custom (non-allowlisted) **HTTPS** upstream host — e.g. a corporate
39    /// gateway in front of the provider API. Opt-in; see
40    /// [`ProxyConfig::allows_custom_upstream`]. Mirrors `allow_insecure_http_upstream`
41    /// so the long-lived managed proxy (LaunchAgent / systemd), which only reads
42    /// `config.toml` and never the shell's `LEAN_CTX_ALLOW_CUSTOM_UPSTREAM`, can
43    /// honor a custom upstream too (#590).
44    pub allow_custom_upstream: Option<bool>,
45    /// mz14: allow a `[[proxy.providers]]` entry that points at a vendor's own
46    /// China-hosted API (a `[[direct]]` host of the bundled model catalog:
47    /// DeepSeek, Z.ai, MiniMax). Default `false`: such a request is refused
48    /// with 403 `cn_direct_opt_in_required`. An org policy with
49    /// `vendor_direct_cn = false` voids this opt-in. Config only, no env var.
50    pub allow_vendor_direct_cn: Option<bool>,
51    /// Absolute project root the proxy acts for; operator-set only, verified at
52    /// use. Env `LEAN_CTX_PROXY_PROJECT_ROOT`. See `proxy::request_project`.
53    pub project_root: Option<String>,
54    /// Inject `stream_options.include_usage = true` into streamed OpenAI Chat
55    /// Completions so the final chunk reports real token usage for the measured
56    /// spend meter. Default on; set `false` for a client that mishandles the
57    /// trailing usage chunk. Anthropic/Gemini/OpenAI-Responses report usage
58    /// without any request change, so this only affects Chat Completions.
59    pub meter_openai_usage: Option<bool>,
60    /// Additional response header carrying the upstream gateway's billed USD
61    /// for the turn (#1189). LiteLLM's `x-litellm-response-cost` is always
62    /// recognized; set this for a corporate gateway that reports the charge
63    /// under its own header name. Measured header costs beat table estimates
64    /// (body-reported costs, e.g. OpenRouter `usage.cost`, beat headers).
65    pub cost_response_header: Option<String>,
66    /// Opt-in "big-gap cold-prefix repack" (#480). When the proxy can confidently
67    /// predict (from idle time vs the provider cache TTL) that the client-cached
68    /// prefix has already expired, it overrides the normal "never rewrite the
69    /// cached prefix" rule for that one resume request and prunes the now-cold
70    /// prefix too, re-seeding a leaner cache. `None`/`false` (the default) keeps
71    /// the prefix always protected. See [`ProxyConfig::repacks_cold_prefix`].
72    pub cold_prefix_repack: Option<bool>,
73    /// Opt-in per-role prose compression for the proxy's frozen request region
74    /// (#710). `None` for a role (the default) leaves that role untouched —
75    /// today's behaviour. See [`RoleAggressiveness`].
76    pub role_aggressiveness: RoleAggressiveness,
77    /// Live tool-result compression on the wire (#481). `true` (the default)
78    /// keeps today's behaviour: the proxy compresses non-protected `tool_result`
79    /// content on every request. `false` turns it off so the proxy can run
80    /// **meter-only** — real billed/cache token metering with zero request
81    /// rewriting (combine with `history_mode = "off"`, `long_session_elide =
82    /// false` — env `LEAN_CTX_PROXY_LONG_SESSION_ELIDE=off`, the default — and
83    /// no `role_aggressiveness` for a fully byte-unchanged body).
84    /// Env `LEAN_CTX_PROXY_LIVE_COMPRESS`.
85    /// See [`ProxyConfig::live_compresses`].
86    pub live_compress: Option<bool>,
87    /// Per-tool exclusion list for live tool-result compression (#481). Tool
88    /// names are matched case-insensitively as substrings (the same style as
89    /// `proxy::tool_kind::classify_tool_name`); a match is treated as
90    /// protected, exactly like a file read. `None` (the default) protects
91    /// Serena's code-reading tools (`find_symbol`/`find_referencing_symbols`/
92    /// `search_for_pattern` return source bodies the model edits, but are
93    /// mis-bucketed as `Search` by name). Set an explicit list to narrow it, or
94    /// `[]` to disable the exclusion. See [`ProxyConfig::is_tool_live_compress_excluded`].
95    pub live_compress_exclude: Option<Vec<String>>,
96    /// File-path globs whose reads are never compressed (#1150). A read whose path
97    /// matches any of these is returned verbatim (`full`) by the read tools — for
98    /// files where exact bytes matter more than token savings: golden snapshots,
99    /// byte-asserted fixtures, security-sensitive configs. Globs (`*`/`**`/`?`,
100    /// the `glob` crate) are matched against the path and its file name, so
101    /// `*.snap`, `**/golden/**`, and `tests/fixtures/*` all work. `None`/empty (the
102    /// default) protects nothing — the lossless crushers and beneficial gate
103    /// already keep compression safe, so this is an explicit escape hatch, not a
104    /// default. See [`ProxyConfig::is_path_compress_protected`].
105    pub compress_protect: Option<Vec<String>>,
106    /// Opt-in in-band CCR retrieval for a remote proxy with no shared filesystem
107    /// (#493, follow-up to #482). When enabled, a lossy stub advertises a compact
108    /// `<lc_expand:HASH>` marker (instead of a local tee path the remote agent
109    /// can't read); when the model echoes that marker back, the proxy splices the
110    /// verbatim original — recovered from its **local** tee store — inline on the
111    /// next request, costing one turn of latency and needing no MCP/FS on the
112    /// agent host. `None`/`false` (the default) keeps the path-handle stub. The
113    /// splice is a strict no-op on marker-less turns, so it never perturbs the
114    /// provider cache prefix unless the model explicitly asked to expand. See
115    /// [`ProxyConfig::ccr_inband_enabled`].
116    pub ccr_inband: Option<bool>,
117    /// Opt-in active prompt-cache breakpoint injection for Anthropic (#939). When
118    /// enabled and the client set no `cache_control` of its own, the proxy adds a
119    /// single `cache_control: {type:"ephemeral"}` breakpoint to the `system`
120    /// field so an otherwise-uncached, stable system prompt bills later turns at
121    /// the cached rate. Anthropic-only: OpenAI/Gemini cache prefixes automatically
122    /// and ignore the marker, so those paths stay byte-unchanged. The injection is
123    /// deterministic, never adds a second breakpoint, and is skipped below
124    /// Anthropic's minimum cacheable size. `None`/`false` (the default) leaves the
125    /// request untouched. See [`ProxyConfig::cache_breakpoint_enabled`].
126    pub cache_breakpoint: Option<bool>,
127    /// Agent-loop cache lifetime for Anthropic. While the model works through
128    /// tool calls (the request ends in tool results), the conversation's cache
129    /// markers are written for 5 minutes instead of 1 hour; the person's last
130    /// prompt keeps a 1 h marker of its own, so a long pause loses at most this
131    /// turn's tool steps. A 1 h write costs 2x the input price, a 5 min write
132    /// 1.25x. See [`ProxyConfig::cache_ttl_loop_enabled`].
133    pub cache_ttl_loop: Option<bool>,
134    /// Long-session elision for Anthropic. Once a conversation's messages pass
135    /// 180 000 characters, tool output older than the live window and longer
136    /// than 2 000 characters leaves as a one-line stub naming a private archive
137    /// file with the full text. See [`ProxyConfig::long_session_elide_enabled`].
138    pub long_session_elide: Option<bool>,
139    /// Opt-in counterfactual savings metering (#701). When enabled, each
140    /// *rewritten* Anthropic `/v1/messages` request additionally fires a **free**
141    /// `count_tokens` probe with the original, uncompressed body, concurrently
142    /// with the real forward. The provider-counted answer ("this request would
143    /// have cost N input tokens without lean-ctx") is paired with the actually
144    /// billed usage from the same response — provider-authoritative receipts
145    /// instead of local tokenizer estimates. The probe never mutates or delays
146    /// the forwarded request; probe failures degrade to the estimate. Off by
147    /// default: it adds one extra HTTP call per compressed request (free at
148    /// Anthropic, but latency/rate-limit surface). See
149    /// [`ProxyConfig::counterfactual_metering_enabled`].
150    pub counterfactual_metering: Option<bool>,
151    /// Opt-in cache-aligner volatile-field telemetry (#940). When enabled, the
152    /// proxy scans each *unanchored* Anthropic system prompt for volatile,
153    /// cache-busting fields (ISO dates/datetimes, UUIDs, git SHAs) and records how
154    /// many it found on `/status` `cache_safety` — purely to quantify how much
155    /// prompt-cache the client is leaking. **Measurement only**: the request body
156    /// is never mutated, so it is strictly cache-safe. `None` (the default) enables
157    /// it — every proxy ships cache-leak visibility out of the box (#986 premium
158    /// defaults); set `false` to opt out of the per-request scan. See
159    /// [`ProxyConfig::cache_aligner_enabled`].
160    pub cache_aligner: Option<bool>,
161    /// Opt-in active cache-aligner relocate (#974). When enabled, the proxy
162    /// rewrites an *unanchored* Anthropic `system` prompt into a stable block
163    /// (volatile values — ISO dates/datetimes, UUIDs, git SHAs — replaced by
164    /// constant placeholders) carrying the `cache_control` breakpoint, plus an
165    /// *uncached* trailing block that re-states the relocated values. The cacheable
166    /// prefix then stays byte-stable turn-to-turn and finally caches; only the
167    /// small tail is reprocessed. Anthropic-only, Treatment-arm, gated on a client
168    /// that anchored nothing and on Anthropic's minimum cacheable size.
169    /// Deterministic (#498) and idempotent. `None`/`false` (the default) leaves the
170    /// request untouched. The `cache_aligner` telemetry above is the precursor that
171    /// quantifies how much this would save. See
172    /// [`ProxyConfig::cache_align_relocate_enabled`].
173    pub cache_align_relocate: Option<bool>,
174    /// Cache-economics (#986), **on by default**. Bundles two strictly-safe halves
175    /// behind one flag: (1) prompt-cache **miss attribution** telemetry — per turn,
176    /// classify why the cache hit or missed (cold start / warm reuse / TTL lapse /
177    /// prefix change) and expose cumulative gauges on `/status`
178    /// (`proxy::cache_attribution`); and (2) a **net-cost gate** on the
179    /// cold-prefix repack (`proxy::cache_policy::worth_repacking`) that
180    /// skips re-seeding prefixes too small to be cached. The telemetry never
181    /// touches the body and the gate only makes repacking *more* conservative, so
182    /// it can never bust a cache that would otherwise have been kept. `None` (the
183    /// default) enables both — every proxy gets the diagnosis and the safer repack
184    /// out of the box (#986 premium defaults); set `false` to opt out. See
185    /// [`ProxyConfig::cache_policy_enabled`].
186    pub cache_policy: Option<bool>,
187    /// Cache-safe, cross-provider reasoning-effort control (#834). One of
188    /// `minimal|low|medium|high` pins the model's reasoning depth across every
189    /// provider; `None`/`"off"` (the default) is a strict no-op. The value is a
190    /// constant — identical on every request — so the provider prompt-cache
191    /// prefix stays byte-stable (#448/#498) and only the model's reasoning depth
192    /// changes. lean-ctx translates it to each provider's native parameter and
193    /// only ever *fills* it (never overrides a client-set value), on models that
194    /// accept it. Per-turn effort switching is deliberately unsupported — it
195    /// would invalidate the prompt cache. Env `LEAN_CTX_PROXY_EFFORT`. See
196    /// [`ProxyConfig::resolved_effort`].
197    pub effort: Option<String>,
198    /// How the proxy squeezes prose it must shrink (#895): `"auto"` (default) and
199    /// `"extractive"` use embedding-based extractive ranking — keeping the most
200    /// central sentences instead of just the prefix — when the local embedding
201    /// engine is available, falling back to truncation otherwise; `"truncate"`
202    /// keeps the original deterministic FIFO squeeze (and no engine). Wire
203    /// rewrites are memoized per content so the engine's cold→warm transition
204    /// never changes an already-emitted frozen-region rewrite (#448/#498). Env
205    /// `LEAN_CTX_PROXY_PROSE_RANKER`. See [`ProxyConfig::resolved_prose_ranker`].
206    pub prose_ranker: Option<String>,
207    /// Fraction `0.0..=1.0` of conversations placed in the output-savings control
208    /// arm (#895 Track B). `0` (default) = no holdout (every conversation is
209    /// shaped). When `> 0`, a deterministic cohort = `blake3(system + first user
210    /// msg)` puts ~this fraction of conversations in a control arm that skips
211    /// output-shaping (effort control + verbosity steer) but is still metered —
212    /// giving an honest measured output-token reduction. The cohort is a pure
213    /// function of conversation identity, so a conversation stays in one arm
214    /// across turns (cache-safe). Env `LEAN_CTX_PROXY_OUTPUT_HOLDOUT`. See
215    /// [`ProxyConfig::output_holdout_fraction`].
216    pub output_holdout: Option<f64>,
217    /// Fraction `0.0..=1.0` of conversations placed in the input-compression
218    /// control arm (#1905). `0` (default) = no holdout. When `> 0`, a
219    /// deterministic cohort (salted separately from `output_holdout`) puts ~this
220    /// fraction of conversations in a control arm whose requests are forwarded
221    /// without any of the proxy's input compression (tool-output compression,
222    /// history pruning, prose rewriting, cold-prefix repack) but still metered,
223    /// so the input-token reduction is measured against a real uncompressed
224    /// baseline. Env `LEAN_CTX_PROXY_COMPRESSION_HOLDOUT`. See
225    /// [`ProxyConfig::compression_holdout_fraction`].
226    pub compression_holdout: Option<f64>,
227    /// Opt-in cache-safe wire verbosity steer (#895). When `true`, the proxy
228    /// appends a single constant "be concise" instruction to the last user turn
229    /// of each request (output-shaping for non-rules-aware API clients). The
230    /// suffix is constant and appended strictly after the last `cache_control`
231    /// breakpoint, so the provider prompt-cache prefix stays byte-stable. Default
232    /// `false`. Env `LEAN_CTX_PROXY_VERBOSITY_STEER`. See
233    /// [`ProxyConfig::verbosity_steer_enabled`].
234    pub verbosity_steer: Option<bool>,
235    /// Unified proxy operation mode (`[proxy] proxy_mode`). `"token"` (default)
236    /// or `"token"`. Sets sensible defaults for all cache-related knobs; explicit
237    /// per-knob overrides always win. Env `LEAN_CTX_PROXY_MODE`.
238    pub proxy_mode: Option<String>,
239    /// Headroom stacking compatibility (`[proxy] compat_stack`). When set to
240    /// `"headroom"`, the proxy auto-configures for running behind Headroom:
241    /// live compression off, breakpoint injection off, cache alignment on.
242    /// Also auto-detected via the `X-Headroom-Compressed` request header.
243    /// Env `LEAN_CTX_PROXY_COMPAT_STACK`.
244    pub compat_stack: Option<String>,
245    /// Opt-in: route a Codex *ChatGPT-subscription* login through the proxy for
246    /// model-turn compression. Default `None`/`false` keeps Codex native (history
247    /// visible, cloud/remote intact, no #597). When `true`, Codex setup pins the
248    /// generated `leanctx-chatgpt` provider + `chatgpt_base_url`; that scopes Codex
249    /// history to the provider (#597), so it stays opt-in. Toggle durably with
250    /// `lean-ctx proxy codex-chatgpt on|off`; resolved via
251    /// [`ProxyConfig::codex_chatgpt_proxy_enabled`].
252    pub codex_chatgpt_proxy: Option<bool>,
253    /// Active request routing (`[proxy.routing]`, enterprise#13): model aliases
254    /// and intent-tier downgrades applied in the forward path. Off by default —
255    /// an empty/absent table is a strict passthrough. See [`RoutingRules`].
256    pub routing: RoutingRules,
257    /// Counterfactual-baseline parameters (`[proxy.baseline]`, enterprise#15/#18)
258    /// for the avoided-cost evidence chain. See [`BaselineConfig`].
259    pub baseline: BaselineConfig,
260    pub pipeline: PipelineConfig,
261    /// Prompt-cache share guard (`[proxy.cache_share]`): rolling cache-read
262    /// share per provider+model, its alarm threshold and the provider
263    /// allowlist for open models. See [`CacheShareConfig`].
264    pub cache_share: CacheShareConfig,
265}
266
267/// `[proxy.cache_share]` — guards the provider prompt-cache discount.
268///
269/// Without prompt caching a long agent session bills every prompt token at
270/// the full input rate; with it most prompt tokens bill at the cache-read
271/// rate. The proxy keeps a rolling window per provider+model and raises an
272/// alarm when the cache-read share of the prompt tokens drops below
273/// `alarm_threshold`.
274///
275/// Open-weight models (Llama, Qwen, DeepSeek, …) are served by many hosts,
276/// and not all of them give a reliable cache discount. A provider not in
277/// `open_model_providers` is flagged (`open_model_policy = "flag"`, the
278/// default) or refused before the request leaves (`"refuse"`). Local
279/// endpoints are exempt: nothing is bought there.
280#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
281#[serde(default)]
282pub struct CacheShareConfig {
283    /// Minimum cache-read share (0–1) of the prompt tokens; default 0.90.
284    pub alarm_threshold: Option<f64>,
285    /// Rolling window in seconds; default 3600, capped at 7 days.
286    pub window_secs: Option<u64>,
287    /// Prompt tokens a window needs before the alarm may fire, so the cold
288    /// first turn of a session (all cache writes) does not alarm; default 200 000.
289    pub min_prompt_tokens: Option<u64>,
290    /// Provider ids (registry id or built-in label, case-insensitive) with a
291    /// reliable cache discount, allowed to serve open models.
292    pub open_model_providers: Vec<String>,
293    /// Extra model-name fragments (case-insensitive) counted as open models,
294    /// added to the built-in list.
295    pub open_model_patterns: Vec<String>,
296    /// `"flag"` (default) or `"refuse"` for an open model on a provider
297    /// outside `open_model_providers`.
298    pub open_model_policy: Option<String>,
299}
300
301/// Default cache-read share below which the cache-share alarm fires.
302const DEFAULT_CACHE_SHARE_THRESHOLD: f64 = 0.90;
303/// Default rolling window of the cache-share guard (one hour).
304const DEFAULT_CACHE_SHARE_WINDOW_SECS: u64 = 3_600;
305/// Longest rolling window the cache-share guard keeps (seven days).
306const MAX_CACHE_SHARE_WINDOW_SECS: u64 = 7 * 24 * 3_600;
307/// Default prompt tokens a window needs before the alarm may fire.
308const DEFAULT_CACHE_SHARE_MIN_PROMPT_TOKENS: u64 = 200_000;
309
310impl CacheShareConfig {
311    /// Configured threshold clamped to 0–1, or the 0.90 default.
312    #[must_use]
313    pub fn effective_threshold(&self) -> f64 {
314        match self.alarm_threshold {
315            Some(t) if t.is_finite() => t.clamp(0.0, 1.0),
316            _ => DEFAULT_CACHE_SHARE_THRESHOLD,
317        }
318    }
319
320    /// Configured window clamped to 60 s – 7 days, or one hour.
321    #[must_use]
322    pub fn effective_window_secs(&self) -> u64 {
323        self.window_secs
324            .unwrap_or(DEFAULT_CACHE_SHARE_WINDOW_SECS)
325            .clamp(60, MAX_CACHE_SHARE_WINDOW_SECS)
326    }
327
328    /// Configured minimum prompt tokens, or the default.
329    #[must_use]
330    pub fn effective_min_prompt_tokens(&self) -> u64 {
331        self.min_prompt_tokens
332            .unwrap_or(DEFAULT_CACHE_SHARE_MIN_PROMPT_TOKENS)
333    }
334
335    /// True when an unlisted provider must refuse an open model; any value
336    /// other than `"refuse"` flags only.
337    #[must_use]
338    pub fn refuses_unlisted_open_models(&self) -> bool {
339        self.open_model_policy
340            .as_deref()
341            .is_some_and(|p| p.trim().eq_ignore_ascii_case("refuse"))
342    }
343}
344
345/// `[proxy.baseline]` — the contract-frozen counterfactual parameters that make
346/// the success fee provable (enterprise#15, Doc 04 §6 / Doc 08 §2).
347///
348/// - `reference_model`: the model the customer *would have used* without
349///   lean-ctx. Frozen per deployment/contract (calibration: enterprise#41);
350///   every usage event stores `reference_cost_usd` = the request's
351///   **uncompressed** input tokens priced at this model's input rate — the
352///   counterfactual cost the avoided-cost ledger settles against.
353/// - `local_shadow_rate_per_mtok`: USD per 1M tokens booked as the actual cost
354///   of locally served (loopback) inference. Local compute is never free —
355///   hardware and power are real — so the shadow rate keeps local-model savings
356///   honest instead of infinite. Default: `0.25` USD/MTok, a conservative
357///   self-hosting cost estimate; calibrate per deployment.
358#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
359#[serde(default)]
360pub struct BaselineConfig {
361    /// Counterfactual reference model (`None` = baseline evidence off).
362    pub reference_model: Option<String>,
363    /// USD per 1M tokens for local/loopback inference (default 0.25, never 0).
364    pub local_shadow_rate_per_mtok: Option<f64>,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
368#[serde(default)]
369pub struct PipelineConfig {
370    pub enable_dedup: bool,
371    pub enable_prose: bool,
372    /// Use KnowledgeRouter references to protect relevant request context.
373    pub enable_knowledge_routing: bool,
374    pub min_savings_pct: f32,
375}
376
377impl Default for PipelineConfig {
378    fn default() -> Self {
379        Self {
380            enable_dedup: true,
381            enable_prose: true,
382            enable_knowledge_routing: true,
383            min_savings_pct: 5.0,
384        }
385    }
386}
387
388/// Default local shadow rate (USD per 1M tokens) when `[proxy.baseline]` sets
389/// none: a conservative self-hosted inference cost so `is_local` usage is
390/// never booked at $0 (Doc 04 §6 "local-free ≠ cost-free").
391pub const DEFAULT_LOCAL_SHADOW_RATE_PER_MTOK: f64 = 0.25;
392
393impl BaselineConfig {
394    /// Effective shadow rate: configured value (clamped positive) or default.
395    #[must_use]
396    pub fn effective_local_shadow_rate(&self) -> f64 {
397        match self.local_shadow_rate_per_mtok {
398            Some(r) if r > 0.0 => r,
399            _ => DEFAULT_LOCAL_SHADOW_RATE_PER_MTOK,
400        }
401    }
402}
403
404/// `[proxy.routing]` — explicit, operator-written model targets.
405///
406/// **Aliases** map an exact requested model to a fixed target, e.g. an org
407/// name (`acme/fast`) onto an approved endpoint or a local model. LeanCTX never
408/// chooses a model by itself: automatic, intent- or cost-based selection was
409/// removed in v4 (the gateway decides *what* may leave, not *which* model).
410///
411/// A target is `"model"` (swap the model, keep the upstream) or
412/// `"provider:model"` where `provider` is a `[[proxy.providers]]` registry id
413/// or a built-in (`anthropic|openai|gemini`) — then the request is also
414/// re-targeted to that provider's upstream.
415///
416/// **Fail-open by construction:** an unknown alias or unreachable target
417/// forwards the request unchanged.
418#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
419#[serde(default)]
420pub struct RoutingRules {
421    /// Master switch; `false`/absent = passthrough (no body rewrite at all).
422    pub enabled: Option<bool>,
423    /// Exact model-name aliases: requested model → `"provider:model"` | `"model"`.
424    /// BTreeMap for deterministic iteration/serialization (#498).
425    pub aliases: std::collections::BTreeMap<String, String>,
426    /// Removed intent-tier table. Still parsed so `doctor` can tell an operator
427    /// that it no longer routes anything; never read by the proxy.
428    #[serde(skip_serializing)]
429    pub tiers: std::collections::BTreeMap<String, String>,
430}
431
432impl RoutingRules {
433    /// True when the router should run at all.
434    #[must_use]
435    pub fn is_active(&self) -> bool {
436        self.enabled.unwrap_or(false) && !self.aliases.is_empty()
437    }
438}
439
440/// A parsed routing target: optional provider id + model name.
441/// `"foundry:gpt-4o-mini"` → provider `foundry`, model `gpt-4o-mini`;
442/// `"claude-haiku-4-5"` → model only (upstream unchanged).
443#[must_use]
444pub fn parse_route_target(target: &str) -> Option<(Option<&str>, &str)> {
445    let t = target.trim();
446    if t.is_empty() {
447        return None;
448    }
449    match t.split_once(':') {
450        Some((provider, model)) => {
451            let (provider, model) = (provider.trim(), model.trim());
452            if provider.is_empty() || model.is_empty() {
453                None
454            } else {
455                Some((Some(provider), model))
456            }
457        }
458        None => Some((None, t)),
459    }
460}
461
462/// The API dialect an upstream endpoint speaks — deliberately separate from the
463/// provider's *identity*. lean-ctx understands four wire shapes; any number of
464/// configured providers (Foundry, OpenRouter, Groq, a local vLLM…) map onto
465/// them. New shape = code; new provider = config (universal-provider-framework).
466#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
467#[serde(rename_all = "lowercase")]
468pub enum WireShape {
469    /// Anthropic Messages API (`/v1/messages`).
470    Anthropic,
471    /// OpenAI Chat Completions / Responses API (also spoken by Azure AI
472    /// Foundry, OpenRouter, Groq, vLLM, Ollama, LM Studio…).
473    OpenAi,
474    /// Google Gemini `generateContent` API.
475    Gemini,
476    /// Amazon Bedrock Runtime `InvokeModel` / event-stream API.
477    Bedrock,
478}
479
480impl WireShape {
481    /// Stable lowercase name (serde representation) for logs and `/status`.
482    #[must_use]
483    pub fn as_str(self) -> &'static str {
484        match self {
485            WireShape::Anthropic => "anthropic",
486            WireShape::OpenAi => "openai",
487            WireShape::Gemini => "gemini",
488            WireShape::Bedrock => "bedrock",
489        }
490    }
491}
492
493/// One `[[proxy.providers]]` registry entry (see [`ProxyConfig::providers`]).
494#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct ProviderEntry {
496    /// Registry id, used in the `/providers/{id}/...` route and in routing
497    /// rules. Lowercase alphanumeric plus `-`/`_`; must not shadow a built-in
498    /// provider name (`anthropic`, `openai`, `chatgpt`, `gemini`).
499    pub id: String,
500    /// Which API dialect the endpoint speaks (`anthropic|openai|gemini|bedrock`).
501    pub shape: WireShape,
502    /// Endpoint base URL. HTTPS for any non-loopback host; a declared registry
503    /// entry is itself the custom-host opt-in (no separate allowlist flag).
504    pub base_url: String,
505    /// Name of the environment variable holding the upstream API key the
506    /// gateway injects (replacing the caller's credential headers). `None` =
507    /// forward the caller's own credentials verbatim (default, loopback mode).
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub api_key_env: Option<String>,
510    /// AWS region for Bedrock SigV4. Bedrock uses standard AWS credential
511    /// environment variables and rejects `api_key_env`.
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub aws_region: Option<String>,
514    /// Non-secret opaque account label for destination policy. This is an
515    /// operator-configured route label, not a provider-verified account ID.
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub account_ref: Option<String>,
518    /// Declared provider region. Bedrock uses its validated SigV4 `aws_region`.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub region: Option<String>,
521    /// Set `false` to keep the entry in config but take it out of service.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub enabled: Option<bool>,
524    /// Marks this endpoint as local inference (Ollama/vLLM/…): usage is booked
525    /// at the transparent `local_shadow_rate` instead of provider list prices
526    /// (enterprise#15/#18). Unset = derived from the URL (loopback hosts are
527    /// local). Set it explicitly when the endpoint is local but not loopback —
528    /// the containerized gateway reaching the host's Ollama
529    /// (`host.docker.internal`) or an in-cluster server (`ollama.svc.cluster.local`).
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    pub local: Option<bool>,
532}
533
534/// A validated, ready-to-serve registry provider (runtime view of
535/// [`ProviderEntry`], published inside [`Upstreams`]).
536#[derive(Debug, Clone, PartialEq, Eq)]
537pub struct ResolvedProvider {
538    pub id: String,
539    pub shape: WireShape,
540    pub base_url: String,
541    pub api_key_env: Option<String>,
542    /// Validated SigV4 region for Bedrock; absent for other shapes.
543    pub aws_region: Option<String>,
544    /// Non-secret operator label, only usable as an account binding with
545    /// gateway-managed authentication and no caller scope override.
546    pub account_ref: Option<String>,
547    /// Validated destination region; Bedrock's value is its signing region.
548    pub region: Option<String>,
549    /// Billed as local inference (shadow rate). Explicit `local` flag when the
550    /// entry declares one, otherwise loopback-URL derivation.
551    pub local: bool,
552    /// Where the upstream credential comes from. Never read from TOML:
553    /// every `[[proxy.providers]]` entry is [`CredentialSource::Configured`].
554    pub credential: CredentialSource,
555}
556
557/// The credential a provider sends upstream.
558#[derive(Debug, Clone, Copy, PartialEq, Eq)]
559pub enum CredentialSource {
560    /// The caller's own headers, or the key in `api_key_env`.
561    Configured,
562    /// The Hive instance's short-lived access token (`hive_i_…`), only for
563    /// the built-in `leanctx` provider and only to the Hive models origin
564    /// (`core::hive_models`, mz6).
565    HiveInstance,
566}
567
568/// Built-in provider route names a registry entry must not shadow
569/// (`leanctx`: the Hive subscription models, `core::hive_models`).
570const BUILTIN_PROVIDER_IDS: &[&str] = &[
571    "anthropic",
572    "openai",
573    "chatgpt",
574    "gemini",
575    crate::core::hive_models::PROVIDER_ID,
576];
577
578/// True when `id` is usable as a registry id: non-empty, lowercase alnum plus
579/// `-`/`_` (it becomes a URL path segment), and not a built-in provider name.
580fn is_valid_provider_id(id: &str) -> bool {
581    !id.is_empty()
582        && !BUILTIN_PROVIDER_IDS.contains(&id)
583        && id
584            .chars()
585            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
586}
587
588fn valid_aws_region(region: &str) -> bool {
589    !region.is_empty()
590        && region.len() <= 32
591        && region
592            .bytes()
593            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
594}
595
596fn valid_bedrock_endpoint(base_url: &str, region: &str) -> bool {
597    let Ok(url) = reqwest::Url::parse(base_url) else {
598        return false;
599    };
600    if !matches!(url.path(), "" | "/") || url.query().is_some() || url.fragment().is_some() {
601        return false;
602    }
603    let Some(host) = url.host_str().map(str::to_ascii_lowercase) else {
604        return false;
605    };
606    let loopback = host == "localhost"
607        || host
608            .parse::<std::net::IpAddr>()
609            .is_ok_and(|address| address.is_loopback());
610    if loopback {
611        return true;
612    }
613    let accepted = [
614        format!("bedrock-runtime.{region}.amazonaws.com"),
615        format!("bedrock-runtime.{region}.amazonaws.com.cn"),
616        format!("bedrock-runtime.{region}.api.aws"),
617        format!("bedrock-runtime-fips.{region}.amazonaws.com"),
618        format!("bedrock-runtime-fips.{region}.api.aws"),
619    ];
620    accepted.iter().any(|candidate| candidate == &host)
621        || host.ends_with(&format!(".bedrock-runtime.{region}.vpce.amazonaws.com"))
622}
623
624impl ResolvedProvider {
625    #[must_use]
626    pub fn injects_gateway_credential(&self) -> bool {
627        self.api_key_env.is_some() || self.shape == WireShape::Bedrock
628    }
629
630    #[must_use]
631    pub fn gateway_credential_present(&self) -> bool {
632        if self.shape == WireShape::Bedrock {
633            return ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]
634                .into_iter()
635                .all(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()));
636        }
637        self.api_key_env
638            .as_deref()
639            .is_some_and(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()))
640    }
641}
642
643/// Per-role prose-compression intensity for the proxy's frozen request region.
644///
645/// Each value is a `0.0–1.0` aggressiveness level reusing the same mapping as
646/// the `ctx_read` knob (#708): `0.0` keeps everything, `1.0` is most aggressive.
647/// `None` (the default) means "do not compress this role's prose" so the proxy
648/// stays byte-for-byte unchanged until an operator opts in. The `assistant`
649/// role is never represented here — model turns are always passed through
650/// verbatim (the #710 passthrough guarantee).
651#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
652#[serde(default)]
653pub struct RoleAggressiveness {
654    /// Aggressiveness for system prompts (Anthropic `system` / OpenAI `system`
655    /// messages / Gemini `systemInstruction`). `None` = leave untouched.
656    pub system: Option<f64>,
657    /// Aggressiveness for user prose (free-text user turns, never tool results).
658    /// `None` = leave untouched.
659    pub user: Option<f64>,
660}
661
662/// The conversation roles whose prose the proxy may compress in the frozen
663/// region. Deliberately excludes `assistant` — model turns are never rewritten.
664#[derive(Debug, Clone, Copy, PartialEq, Eq)]
665pub enum ProseRole {
666    System,
667    User,
668}
669
670/// Unified proxy operation mode that sets cache-optimal defaults for all knobs.
671///
672/// Instead of configuring 8+ individual booleans, operators pick a single mode
673/// that resolves sensible defaults. Explicit per-knob overrides always win.
674///
675/// - `Cache`: maximise provider prompt-cache hit rate. History is
676///   frozen at staircase boundaries, breakpoints are injected, volatile fields
677///   are detected, and the live tail is compressed — but the prefix is never
678///   rewritten.
679/// - `Token` (default for the knob presets): maximise raw token reduction.
680///   Cold prefixes may be repacked and volatile fields relocated. History is
681///   only rewritten (rolling) when `token` is chosen explicitly; an unset mode
682///   keeps cache-aware history pruning (#1895). Best for short one-shot
683///   requests where cache reuse is unlikely.
684#[derive(Debug, Clone, Copy, PartialEq, Eq)]
685pub enum ProxyMode {
686    Cache,
687    Token,
688}
689
690impl ProxyMode {
691    fn parse(s: &str) -> Option<Self> {
692        match s.trim().to_ascii_lowercase().as_str() {
693            "cache" | "cache_mode" | "cost_savings" => Some(Self::Cache),
694            "token" | "token_mode" | "token_savings" => Some(Self::Token),
695            _ => None,
696        }
697    }
698
699    /// Default value for a cache-related knob under this mode.
700    pub fn preset_for(self, knob: &str) -> Option<bool> {
701        match (self, knob) {
702            (Self::Cache | Self::Token, "cache_aligner" | "cache_policy")
703            | (Self::Token, "cache_align_relocate" | "cold_prefix_repack" | "verbosity_steer") => {
704                Some(true)
705            }
706
707            (Self::Cache | Self::Token, "cache_breakpoint")
708            | (Self::Cache, "cache_align_relocate" | "cold_prefix_repack" | "verbosity_steer") => {
709                Some(false)
710            }
711
712            _ => None,
713        }
714    }
715}
716
717/// How the proxy squeezes prose it must shrink (#895).
718#[derive(Debug, Clone, Copy, PartialEq, Eq)]
719pub enum ProseRanker {
720    /// Extractive embedding ranking when the engine is available, else truncate.
721    /// The default — strictly better than truncation, and cache-safe via the
722    /// per-content memo in `proxy::prose_ranker`.
723    Auto,
724    /// Same engine path as `Auto` (kept distinct so an operator can express
725    /// intent / so a future "require engine" semantic has a name).
726    Extractive,
727    /// Original deterministic FIFO squeeze; never touches the embedding engine.
728    Truncate,
729}
730
731/// How the proxy prunes old tool results from conversation history.
732///
733/// Provider prompt caches (Anthropic `cache_control`, OpenAI automatic prompt
734/// caching) bill cached prefix tokens at a fraction of the base rate but only
735/// match *exact* prefixes. Any mutation whose position depends on the current
736/// conversation length (a rolling window) rewrites a previously-stable message
737/// every turn, invalidating the cache from that point — turning cheap cache
738/// reads into full-price writes.
739#[derive(Debug, Clone, Copy, PartialEq, Eq)]
740pub enum HistoryMode {
741    /// Prune only at frozen generation boundaries that advance in large,
742    /// deterministic steps. Between jumps the request prefix is byte-stable,
743    /// so provider prompt caches keep hitting. Content the client has marked
744    /// with a `cache_control` breakpoint is never rewritten, so an advancing
745    /// boundary can no longer invalidate the already-cached prefix (#448).
746    /// Default.
747    CacheAware,
748    /// Legacy behaviour: summarize everything older than the last N messages.
749    /// Maximum raw-token reduction, but defeats provider prompt caching.
750    Rolling,
751    /// Never prune history (tool-result compression still applies — it is
752    /// content-deterministic and therefore prefix-stable).
753    Off,
754}
755
756impl ProxyConfig {
757    /// Resolved proxy mode. `LEAN_CTX_PROXY_MODE` env wins, then config, then `Token`.
758    #[must_use]
759    pub fn resolved_proxy_mode(&self) -> ProxyMode {
760        let raw = std::env::var("LEAN_CTX_PROXY_MODE")
761            .ok()
762            .or_else(|| self.proxy_mode.clone());
763        raw.as_deref()
764            .and_then(ProxyMode::parse)
765            .unwrap_or(ProxyMode::Token)
766    }
767
768    /// Whether a Headroom-compatible stack is configured.
769    #[must_use]
770    pub fn is_headroom_compat(&self) -> bool {
771        let raw = std::env::var("LEAN_CTX_PROXY_COMPAT_STACK")
772            .ok()
773            .or_else(|| self.compat_stack.clone());
774        raw.as_deref()
775            .is_some_and(|s| s.trim().eq_ignore_ascii_case("headroom"))
776    }
777
778    /// Resolved history mode: `LEAN_CTX_PROXY_HISTORY_MODE` env var wins,
779    /// then `[proxy].history_mode` in config.toml, then the preset of an
780    /// *explicitly chosen* `proxy_mode` (`token` → rolling, `cache` →
781    /// cache-aware), then cache-aware.
782    /// Unknown values fall back to the default so a typo can never silently
783    /// re-enable the cache-hostile rolling mode — and neither can the implicit
784    /// `Token` default of [`Self::resolved_proxy_mode`] (#1895).
785    pub fn resolved_history_mode(&self) -> HistoryMode {
786        let raw = std::env::var("LEAN_CTX_PROXY_HISTORY_MODE")
787            .ok()
788            .or_else(|| self.history_mode.clone());
789        if let Some(mode) = raw.as_deref().map(str::trim) {
790            if mode.eq_ignore_ascii_case("rolling") {
791                return HistoryMode::Rolling;
792            }
793            if mode.eq_ignore_ascii_case("off") {
794                return HistoryMode::Off;
795            }
796        }
797        let explicit_mode = std::env::var("LEAN_CTX_PROXY_MODE")
798            .ok()
799            .or_else(|| self.proxy_mode.clone());
800        match explicit_mode.as_deref().and_then(ProxyMode::parse) {
801            Some(ProxyMode::Token) => HistoryMode::Rolling,
802            Some(ProxyMode::Cache) | None => HistoryMode::CacheAware,
803        }
804    }
805
806    /// Whether the proxy injects `stream_options.include_usage` into streamed
807    /// OpenAI Chat Completions to meter real spend. `[proxy] meter_openai_usage`
808    /// in config.toml, default `true`.
809    pub fn meters_openai_usage(&self) -> bool {
810        self.meter_openai_usage.unwrap_or(true)
811    }
812
813    /// Operator-configured extra cost header (#1189), normalized to lowercase.
814    /// `None` when unset/blank — LiteLLM's standard header is always checked.
815    pub fn cost_response_header(&self) -> Option<String> {
816        self.cost_response_header
817            .as_deref()
818            .map(str::trim)
819            .filter(|h| !h.is_empty())
820            .map(str::to_lowercase)
821    }
822
823    /// Resolved prose-ranker strategy (#895). Precedence: the
824    /// `LEAN_CTX_PROXY_PROSE_RANKER` env var, then `[proxy] prose_ranker` in
825    /// config.toml, then `Auto`. Unknown values resolve to `Auto` so a typo can
826    /// never silently disable the premium path; `"truncate"`/`"off"` selects the
827    /// legacy squeeze.
828    #[must_use]
829    pub fn resolved_prose_ranker(&self) -> ProseRanker {
830        let raw = std::env::var("LEAN_CTX_PROXY_PROSE_RANKER")
831            .ok()
832            .or_else(|| self.prose_ranker.clone());
833        match raw.as_deref().map(str::trim) {
834            Some(s) if s.eq_ignore_ascii_case("truncate") || s.eq_ignore_ascii_case("off") => {
835                ProseRanker::Truncate
836            }
837            Some(s) if s.eq_ignore_ascii_case("extractive") => ProseRanker::Extractive,
838            _ => ProseRanker::Auto,
839        }
840    }
841
842    /// Resolved output-savings holdout fraction (#895 Track B), clamped to
843    /// `[0,1]`. Precedence: `LEAN_CTX_PROXY_OUTPUT_HOLDOUT` env > `[proxy]
844    /// output_holdout` > `0.0` (no holdout). An unparseable/blank env value is
845    /// ignored so a typo can never silently change the experiment fraction.
846    #[must_use]
847    pub fn output_holdout_fraction(&self) -> f64 {
848        let from_env = std::env::var("LEAN_CTX_PROXY_OUTPUT_HOLDOUT")
849            .ok()
850            .and_then(|v| v.trim().parse::<f64>().ok());
851        from_env
852            .or(self.output_holdout)
853            .unwrap_or(0.0)
854            .clamp(0.0, 1.0)
855    }
856
857    /// Resolved input-compression holdout fraction (#1905), clamped to `[0,1]`.
858    /// Precedence: `LEAN_CTX_PROXY_COMPRESSION_HOLDOUT` env > `[proxy]
859    /// compression_holdout` > `0.0` (no holdout). An unparseable/blank env value
860    /// is ignored, as for [`ProxyConfig::output_holdout_fraction`].
861    #[must_use]
862    pub fn compression_holdout_fraction(&self) -> f64 {
863        let from_env = std::env::var("LEAN_CTX_PROXY_COMPRESSION_HOLDOUT")
864            .ok()
865            .and_then(|v| v.trim().parse::<f64>().ok());
866        from_env
867            .or(self.compression_holdout)
868            .unwrap_or(0.0)
869            .clamp(0.0, 1.0)
870    }
871
872    /// Whether the cache-safe wire verbosity steer (#895) is enabled. Precedence:
873    /// `LEAN_CTX_PROXY_VERBOSITY_STEER` env (`1`/`true`/`on`) > `[proxy]
874    /// verbosity_steer` > `false` (off).
875    #[must_use]
876    pub fn verbosity_steer_enabled(&self) -> bool {
877        if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_VERBOSITY_STEER") {
878            let v = raw.trim();
879            return v.eq_ignore_ascii_case("1")
880                || v.eq_ignore_ascii_case("true")
881                || v.eq_ignore_ascii_case("on")
882                || v.eq_ignore_ascii_case("yes");
883        }
884        if let Some(v) = self.verbosity_steer {
885            return v;
886        }
887        self.resolved_proxy_mode()
888            .preset_for("verbosity_steer")
889            .unwrap_or(false)
890    }
891
892    /// Resolved Codex ChatGPT-subscription proxy opt-in (default off).
893    /// `LEAN_CTX_CODEX_CHATGPT_PROXY` (any value) forces it on for the current
894    /// process, then `[proxy] codex_chatgpt_proxy` in config.toml, else `false`.
895    pub fn codex_chatgpt_proxy_enabled(&self) -> bool {
896        std::env::var("LEAN_CTX_CODEX_CHATGPT_PROXY").is_ok()
897            || self.codex_chatgpt_proxy.unwrap_or(false)
898    }
899
900    /// Whether the opt-in cold-prefix repack (#480) is enabled. A wrong "cold"
901    /// guess re-bills cache reads as writes (~12x), so this is off by default and
902    /// must be explicitly enabled. `LEAN_CTX_PROXY_COLD_PREFIX_REPACK` (any
903    /// value) wins, then `[proxy] cold_prefix_repack` in config.toml, else
904    /// `false`.
905    pub fn repacks_cold_prefix(&self) -> bool {
906        if std::env::var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK").is_ok() {
907            return true;
908        }
909        if let Some(v) = self.cold_prefix_repack {
910            return v;
911        }
912        self.resolved_proxy_mode()
913            .preset_for("cold_prefix_repack")
914            .unwrap_or(false)
915    }
916
917    /// Whether opt-in in-band CCR retrieval (#493) is enabled. Off by default:
918    /// the splice mutates provider-visible conversation content for the one turn
919    /// the model asks to expand, so it must be an explicit opt-in.
920    /// `LEAN_CTX_PROXY_CCR_INBAND` (any value) wins, then `[proxy] ccr_inband` in
921    /// config.toml, else `false`.
922    pub fn ccr_inband_enabled(&self) -> bool {
923        std::env::var("LEAN_CTX_PROXY_CCR_INBAND").is_ok() || self.ccr_inband.unwrap_or(false)
924    }
925
926    /// Whether opt-in Anthropic prompt-cache breakpoint injection (#939) is
927    /// enabled. Off by default: it mutates the provider-visible `system` shape
928    /// (string → cache-marked block array), so it must be an explicit opt-in.
929    /// `LEAN_CTX_PROXY_CACHE_BREAKPOINT` (any value) wins, then `[proxy]
930    /// cache_breakpoint` in config.toml, else `false`.
931    pub fn cache_breakpoint_enabled(&self) -> bool {
932        if std::env::var("LEAN_CTX_PROXY_CACHE_BREAKPOINT").is_ok() {
933            return true;
934        }
935        if let Some(v) = self.cache_breakpoint {
936            return v;
937        }
938        if self.is_headroom_compat() {
939            return false;
940        }
941        self.resolved_proxy_mode()
942            .preset_for("cache_breakpoint")
943            .unwrap_or(false)
944    }
945
946    /// Whether the agent-loop cache lifetime (5 min inside the loop, 1 h on the
947    /// person's last prompt) is applied to Anthropic requests.
948    /// `LEAN_CTX_PROXY_CACHE_TTL_LOOP=on|off` wins, then `[proxy]
949    /// cache_ttl_loop` in config.toml, else `true` (measured on Claude Code
950    /// `-p`, Haiku 5.5, Context Mode short tasks: -17.9 % cost, 79/80 vs 80/80
951    /// solved; only `cache_control` lifetimes change).
952    pub fn cache_ttl_loop_enabled(&self) -> bool {
953        env_bool_or("LEAN_CTX_PROXY_CACHE_TTL_LOOP", self.cache_ttl_loop, true)
954    }
955
956    /// Whether long-session elision applies to Anthropic requests
957    /// (every turn of a session past the threshold). `LEAN_CTX_PROXY_LONG_SESSION_ELIDE=on|off` wins,
958    /// then `[proxy] long_session_elide` in config.toml, else `false`: on
959    /// Claude Code with Haiku 5.5 (three runs, 44 long tasks per arm) it cut
960    /// cost to x0.67-0.92 but solved 37/44 vs 42/44 natively, because the
961    /// model did not always read back an elided early output.
962    pub fn long_session_elide_enabled(&self) -> bool {
963        env_bool_or(
964            "LEAN_CTX_PROXY_LONG_SESSION_ELIDE",
965            self.long_session_elide,
966            false,
967        )
968    }
969
970    /// Whether opt-in counterfactual savings metering (#701) is enabled. Off by
971    /// default: it fires one extra (free) Anthropic `count_tokens` call per
972    /// rewritten request — pure telemetry, but extra latency budget and
973    /// rate-limit surface, so it must be an explicit opt-in.
974    /// `LEAN_CTX_PROXY_COUNTERFACTUAL` (any value) wins, then `[proxy]
975    /// counterfactual_metering` in config.toml, else `false`.
976    pub fn counterfactual_metering_enabled(&self) -> bool {
977        std::env::var("LEAN_CTX_PROXY_COUNTERFACTUAL").is_ok()
978            || self.counterfactual_metering.unwrap_or(false)
979    }
980
981    /// Whether opt-in cache-aligner volatile-field telemetry (#940) is enabled.
982    /// On by default (#986 premium defaults): the scan is pure measurement and
983    /// never mutates the body, so every proxy ships cache-leak visibility out of
984    /// the box. Strictly cache-safe. `LEAN_CTX_PROXY_CACHE_ALIGNER=on|off` wins,
985    /// then `[proxy] cache_aligner` in config.toml, else `true`. Opt **out** only
986    /// to drop the per-request system-prompt scan.
987    pub fn cache_aligner_enabled(&self) -> bool {
988        env_bool_or("LEAN_CTX_PROXY_CACHE_ALIGNER", self.cache_aligner, true)
989    }
990
991    /// Whether opt-in active cache-aligner relocate (#974) is enabled. Off by
992    /// default: it reshapes the provider-visible `system` field (moving volatile
993    /// values to an uncached tail block), so it must be an explicit opt-in.
994    /// `LEAN_CTX_PROXY_CACHE_ALIGN_RELOCATE` (any value) wins, then `[proxy]
995    /// cache_align_relocate` in config.toml, else `false`.
996    pub fn cache_align_relocate_enabled(&self) -> bool {
997        if std::env::var("LEAN_CTX_PROXY_CACHE_ALIGN_RELOCATE").is_ok() {
998            return true;
999        }
1000        if let Some(v) = self.cache_align_relocate {
1001            return v;
1002        }
1003        if self.is_headroom_compat() {
1004            return false;
1005        }
1006        self.resolved_proxy_mode()
1007            .preset_for("cache_align_relocate")
1008            .unwrap_or(false)
1009    }
1010
1011    /// Whether cache-economics (#986) is enabled: prompt-cache miss attribution
1012    /// telemetry plus the net-cost repack gate. Both are strictly safe
1013    /// (measurement + a more-conservative repack that never busts a cache the
1014    /// default kept), so this is **on by default** — every proxy gets the
1015    /// diagnosis and the safer repack out of the box.
1016    /// `LEAN_CTX_PROXY_CACHE_POLICY=on|off` wins, then `[proxy] cache_policy` in
1017    /// config.toml, else `true`. Opt out to keep `/status` free of the attribution
1018    /// gauges and skip the per-request prefix hash.
1019    pub fn cache_policy_enabled(&self) -> bool {
1020        env_bool_or("LEAN_CTX_PROXY_CACHE_POLICY", self.cache_policy, true)
1021    }
1022
1023    /// Resolved cross-provider reasoning effort (#834), or `None` when the
1024    /// feature is off (the default — a strict no-op that preserves the
1025    /// byte-unchanged meter-only path). Precedence: `LEAN_CTX_PROXY_EFFORT` env
1026    /// (`off` disables, a valid level wins, an unparseable/blank value is
1027    /// ignored) > `[proxy] effort` in config.toml. Any unknown value resolves to
1028    /// `None` so a typo can never silently enable reasoning steering.
1029    #[must_use]
1030    pub fn resolved_effort(&self) -> Option<super::Effort> {
1031        if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_EFFORT") {
1032            let trimmed = raw.trim();
1033            if trimmed.eq_ignore_ascii_case("off") {
1034                return None;
1035            }
1036            if let Some(effort) = super::Effort::parse(trimmed) {
1037                return Some(effort);
1038            }
1039            // Blank/unknown env → ignore and fall through to config, mirroring
1040            // `live_compresses` so a typo never flips the configured behaviour.
1041        }
1042        self.effort.as_deref().and_then(super::Effort::parse)
1043    }
1044
1045    /// The proxy's project root as written, unverified: env
1046    /// `LEAN_CTX_PROXY_PROJECT_ROOT` wins over `[proxy] project_root`; blank = unset.
1047    pub fn configured_project_root(&self) -> Option<String> {
1048        let set = |root: &String| !root.trim().is_empty();
1049        let env = std::env::var("LEAN_CTX_PROXY_PROJECT_ROOT")
1050            .ok()
1051            .filter(set);
1052        env.or_else(|| self.project_root.clone()).filter(set)
1053    }
1054
1055    /// Whether the proxy live-compresses non-protected `tool_result` content
1056    /// (#481). `LEAN_CTX_PROXY_LIVE_COMPRESS` (`0`/`false`/`off`/`no` → off,
1057    /// `1`/`true`/`on`/`yes` → on) wins, then `[proxy] live_compress` in
1058    /// config.toml, else `true`. An unparseable/blank env value is ignored so a
1059    /// typo can never silently flip the mode.
1060    pub fn live_compresses(&self) -> bool {
1061        if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_LIVE_COMPRESS") {
1062            match raw.trim().to_ascii_lowercase().as_str() {
1063                "0" | "false" | "off" | "no" => return false,
1064                "1" | "true" | "on" | "yes" => return true,
1065                _ => {}
1066            }
1067        }
1068        if let Some(v) = self.live_compress {
1069            return v;
1070        }
1071        if self.is_headroom_compat() {
1072            return false;
1073        }
1074        match self.resolved_proxy_mode() {
1075            ProxyMode::Cache | ProxyMode::Token => true,
1076        }
1077    }
1078
1079    /// Resolved per-tool live-compress exclusion patterns (#481). `None` in
1080    /// config falls back to the built-in default (protect Serena); an explicit
1081    /// list — including the empty list — is used verbatim so operators can narrow
1082    /// or fully clear it.
1083    #[must_use]
1084    pub fn live_compress_exclude_patterns(&self) -> Vec<String> {
1085        self.live_compress_exclude
1086            .clone()
1087            .unwrap_or_else(default_live_compress_exclude)
1088    }
1089
1090    /// Whether `tool_name` is on the live-compress exclusion list (#481) and must
1091    /// therefore reach the model intact, like a protected file read. Matching is
1092    /// case-insensitive substring, mirroring `tool_kind::classify_tool_name`.
1093    #[must_use]
1094    pub fn is_tool_live_compress_excluded(&self, tool_name: &str) -> bool {
1095        let name = tool_name.to_ascii_lowercase();
1096        self.live_compress_exclude_patterns().iter().any(|p| {
1097            let p = p.trim().to_ascii_lowercase();
1098            !p.is_empty() && name.contains(p.as_str())
1099        })
1100    }
1101
1102    /// Compiled `compress_protect` globs (#1150), skipping any that fail to parse
1103    /// so one malformed entry never disables the rest. Empty when unset — the
1104    /// default — which makes [`Self::is_path_compress_protected`] a fast no-op.
1105    #[must_use]
1106    pub fn compress_protect_globs(&self) -> Vec<glob::Pattern> {
1107        self.compress_protect
1108            .as_deref()
1109            .unwrap_or_default()
1110            .iter()
1111            .filter_map(|p| glob::Pattern::new(p.trim()).ok())
1112            .collect()
1113    }
1114
1115    /// Whether `path` is on the never-compress list (#1150) and must be returned
1116    /// verbatim. Each glob is tried against both the full path (with backslashes
1117    /// normalised to `/`) and the bare file name, so `*.snap` matches anywhere
1118    /// while `**/golden/**` can still target a directory. Empty list → always
1119    /// `false` (today's behaviour), so a default proxy pays nothing.
1120    #[must_use]
1121    pub fn is_path_compress_protected(&self, path: &str) -> bool {
1122        let patterns = self.compress_protect_globs();
1123        if patterns.is_empty() {
1124            return false;
1125        }
1126        let norm = path.replace('\\', "/");
1127        let base = norm.rsplit('/').next().unwrap_or(norm.as_str());
1128        patterns.iter().any(|p| p.matches(&norm) || p.matches(base))
1129    }
1130
1131    /// Resolved prose-compression aggressiveness for `role`, clamped to `[0,1]`,
1132    /// or `None` when prose compression is off for that role (the default).
1133    ///
1134    /// Precedence: the role's env override (`LEAN_CTX_PROXY_SYSTEM_AGGR` /
1135    /// `LEAN_CTX_PROXY_USER_AGGR`) wins, then `[proxy.role_aggressiveness]` in
1136    /// config.toml. An unparseable or blank env value is ignored so a typo can
1137    /// never silently disable the configured behaviour.
1138    #[must_use]
1139    pub fn resolved_role_aggressiveness(&self, role: ProseRole) -> Option<f64> {
1140        let (env_var, configured) = match role {
1141            ProseRole::System => (
1142                "LEAN_CTX_PROXY_SYSTEM_AGGR",
1143                self.role_aggressiveness.system,
1144            ),
1145            ProseRole::User => ("LEAN_CTX_PROXY_USER_AGGR", self.role_aggressiveness.user),
1146        };
1147        let from_env = std::env::var(env_var)
1148            .ok()
1149            .and_then(|v| v.trim().parse::<f64>().ok());
1150        let resolved = from_env.or(configured);
1151        if resolved.is_some() {
1152            return resolved.map(|a| a.clamp(0.0, 1.0));
1153        }
1154        if self.resolved_proxy_mode() == ProxyMode::Token && role == ProseRole::System {
1155            return Some(0.5);
1156        }
1157        None
1158    }
1159
1160    /// Whether a non-loopback plaintext `http://` upstream is allowed. Opt-in
1161    /// only — a deliberate downgrade for a trusted local-network service such as
1162    /// `http://host.docker.internal:2455` in front of codex-lb (#440).
1163    /// `LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM` (any value) wins, then
1164    /// `[proxy] allow_insecure_http_upstream` in config.toml, default `false`.
1165    pub fn allows_insecure_http_upstream(&self) -> bool {
1166        std::env::var("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM").is_ok()
1167            || self.allow_insecure_http_upstream.unwrap_or(false)
1168    }
1169
1170    /// Whether a custom (non-allowlisted) HTTPS upstream host is allowed. Opt-in
1171    /// only — lifting the built-in host allowlist points the proxy at a host you
1172    /// control (e.g. a corporate gateway), so it must be deliberate.
1173    /// `LEAN_CTX_ALLOW_CUSTOM_UPSTREAM` (any value) wins, then
1174    /// `[proxy] allow_custom_upstream` in config.toml, default `false`.
1175    ///
1176    /// Unlike the env var, the **config flag reaches the managed (service-spawned)
1177    /// proxy**, which only reads `config.toml` — that is the whole point of #590:
1178    /// `proxy enable`/`restart` start the proxy via launchd/systemd, which never
1179    /// inherits the shell's `LEAN_CTX_ALLOW_CUSTOM_UPSTREAM`.
1180    pub fn allows_custom_upstream(&self) -> bool {
1181        std::env::var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM").is_ok()
1182            || self.allow_custom_upstream.unwrap_or(false)
1183    }
1184
1185    /// `[proxy] allow_vendor_direct_cn` (default `false`). The org policy can
1186    /// still void it (`routing.vendor_direct_cn = false`).
1187    #[must_use]
1188    pub fn allows_vendor_direct_cn(&self) -> bool {
1189        self.allow_vendor_direct_cn.unwrap_or(false)
1190    }
1191
1192    /// True when any `*_upstream` configured in `config.toml` (env-independent) is a
1193    /// custom HTTPS host outside the built-in allowlist — i.e. one that resolves
1194    /// only with the [`Self::allows_custom_upstream`] opt-in. Plaintext-HTTP custom
1195    /// hosts are governed by `allow_insecure_http_upstream` instead, so they are
1196    /// excluded here. Lets `proxy enable`/`restart` persist the opt-in (so the
1197    /// managed proxy honors it) and `proxy status` explain a blocked upstream,
1198    /// without touching the allowlisted-host case (#590).
1199    #[must_use]
1200    pub fn has_custom_host_upstream(&self) -> bool {
1201        [
1202            self.anthropic_upstream.as_deref(),
1203            self.openai_upstream.as_deref(),
1204            self.chatgpt_upstream.as_deref(),
1205            self.gemini_upstream.as_deref(),
1206        ]
1207        .into_iter()
1208        .flatten()
1209        .filter_map(normalize_url_opt)
1210        .any(|u| is_custom_upstream_host(&u))
1211    }
1212
1213    /// `(env var, configured value, provider default)` for one provider.
1214    fn provider_spec(&self, provider: ProxyProvider) -> (&'static str, Option<&str>, &'static str) {
1215        match provider {
1216            ProxyProvider::Anthropic => (
1217                "LEAN_CTX_ANTHROPIC_UPSTREAM",
1218                self.anthropic_upstream.as_deref(),
1219                "https://api.anthropic.com",
1220            ),
1221            ProxyProvider::OpenAi => (
1222                "LEAN_CTX_OPENAI_UPSTREAM",
1223                self.openai_upstream.as_deref(),
1224                "https://api.openai.com",
1225            ),
1226            ProxyProvider::ChatGpt => (
1227                "LEAN_CTX_CHATGPT_UPSTREAM",
1228                self.chatgpt_upstream.as_deref(),
1229                "https://chatgpt.com",
1230            ),
1231            ProxyProvider::Gemini => (
1232                "LEAN_CTX_GEMINI_UPSTREAM",
1233                self.gemini_upstream.as_deref(),
1234                "https://generativelanguage.googleapis.com",
1235            ),
1236        }
1237    }
1238
1239    /// Resolve one upstream with precedence `LEAN_CTX_*_UPSTREAM` env var >
1240    /// `[proxy].*_upstream` (config.toml) > provider default.
1241    ///
1242    /// Returns `Err` when a value is *present but invalid* so a live reload can
1243    /// keep the last good value instead of silently rerouting to the default; an
1244    /// *absent* value resolves to the provider default (`Ok`).
1245    fn resolve_upstream_checked(&self, provider: ProxyProvider) -> Result<String, String> {
1246        self.resolve_upstream_inner(provider, true)
1247    }
1248
1249    /// Shared resolver for [`resolve_upstream_checked`] and the disk-only view.
1250    /// `use_env = false` ignores the `LEAN_CTX_*_UPSTREAM` override and yields
1251    /// the config.toml truth a freshly (re)started managed proxy would serve.
1252    fn resolve_upstream_inner(
1253        &self,
1254        provider: ProxyProvider,
1255        use_env: bool,
1256    ) -> Result<String, String> {
1257        let (env_var, config_val, default) = self.provider_spec(provider);
1258        let env_val = if use_env {
1259            std::env::var(env_var)
1260                .ok()
1261                .and_then(|v| normalize_url_opt(&v))
1262        } else {
1263            None
1264        };
1265        let candidate = env_val.or_else(|| config_val.and_then(normalize_url_opt));
1266        match candidate {
1267            None => Ok(normalize_url(default)),
1268            Some(url) => validate_upstream_url(
1269                &url,
1270                self.allows_insecure_http_upstream(),
1271                self.allows_custom_upstream(),
1272            ),
1273        }
1274    }
1275
1276    /// Effective upstream for a provider (env > config > default). An invalid
1277    /// configured/env value falls back to the provider default (logged) — the
1278    /// safe choice at startup.
1279    pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
1280        match self.resolve_upstream_checked(provider) {
1281            Ok(url) => url,
1282            Err(e) => {
1283                tracing::warn!("upstream validation failed, using default: {e}");
1284                normalize_url(self.provider_spec(provider).2)
1285            }
1286        }
1287    }
1288
1289    /// Resolve all three upstreams at once (startup snapshot, env-aware).
1290    pub fn resolve_all(&self) -> Upstreams {
1291        Upstreams {
1292            anthropic: self.resolve_upstream(ProxyProvider::Anthropic),
1293            openai: self.resolve_upstream(ProxyProvider::OpenAi),
1294            chatgpt: self.resolve_upstream(ProxyProvider::ChatGpt),
1295            gemini: self.resolve_upstream(ProxyProvider::Gemini),
1296            providers: self.resolve_providers(),
1297        }
1298    }
1299
1300    /// Validate + resolve the `[[proxy.providers]]` registry. Invalid entries
1301    /// are logged and skipped (one typo must never take the proxy down or
1302    /// disable the remaining registry); duplicates keep the first occurrence.
1303    /// A declared registry entry is itself the deliberate custom-host opt-in,
1304    /// so any HTTPS host is accepted; plaintext HTTP still requires loopback or
1305    /// the explicit insecure-HTTP opt-in (same rule as the built-ins).
1306    #[must_use]
1307    pub fn resolve_providers(&self) -> Vec<ResolvedProvider> {
1308        let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1309        let mut out = Vec::new();
1310        for entry in &self.providers {
1311            if !entry.enabled.unwrap_or(true) {
1312                continue;
1313            }
1314            let id = entry.id.trim();
1315            if !is_valid_provider_id(id) {
1316                tracing::warn!(
1317                    "[proxy.providers] invalid id '{id}' (lowercase alnum/-/_ only, \
1318                     must not shadow a built-in provider) — entry skipped"
1319                );
1320                continue;
1321            }
1322            if !seen.insert(id) {
1323                tracing::warn!("[proxy.providers] duplicate id '{id}' — keeping first entry");
1324                continue;
1325            }
1326            match validate_upstream_url(&entry.base_url, self.allows_insecure_http_upstream(), true)
1327            {
1328                Ok(base_url) => {
1329                    let account_ref = entry
1330                        .account_ref
1331                        .as_deref()
1332                        .map(str::trim)
1333                        .filter(|value| !value.is_empty());
1334                    // Check raw controls before normalization can remove them.
1335                    if entry
1336                        .account_ref
1337                        .as_deref()
1338                        .is_some_and(|value| value.chars().any(char::is_control))
1339                        || account_ref
1340                            .is_some_and(|value| ProtocolReference::new(value.to_owned()).is_err())
1341                    {
1342                        tracing::warn!(
1343                            "[proxy.providers] provider '{id}' has invalid account_ref — skipped"
1344                        );
1345                        continue;
1346                    }
1347                    let configured_region = entry
1348                        .region
1349                        .as_deref()
1350                        .map(str::trim)
1351                        .filter(|value| !value.is_empty());
1352                    if entry
1353                        .region
1354                        .as_deref()
1355                        .is_some_and(|value| value.chars().any(char::is_control))
1356                        || configured_region
1357                            .is_some_and(|value| ProtocolReference::new(value.to_owned()).is_err())
1358                    {
1359                        tracing::warn!(
1360                            "[proxy.providers] provider '{id}' has invalid region — skipped"
1361                        );
1362                        continue;
1363                    }
1364                    let aws_region = entry
1365                        .aws_region
1366                        .as_deref()
1367                        .map(str::trim)
1368                        .filter(|value| !value.is_empty());
1369                    if entry.shape == WireShape::Bedrock {
1370                        let Some(region) = aws_region.filter(|value| valid_aws_region(value))
1371                        else {
1372                            tracing::warn!(
1373                                "[proxy.providers] Bedrock provider '{id}' requires valid aws_region — skipped"
1374                            );
1375                            continue;
1376                        };
1377                        if entry.api_key_env.is_some() || !valid_bedrock_endpoint(&base_url, region)
1378                        {
1379                            tracing::warn!(
1380                                "[proxy.providers] Bedrock provider '{id}' has invalid credential mode or endpoint — skipped"
1381                            );
1382                            continue;
1383                        }
1384                        if configured_region.is_some_and(|configured| configured != region) {
1385                            tracing::warn!(
1386                                "[proxy.providers] Bedrock provider '{id}' region must match aws_region — skipped"
1387                            );
1388                            continue;
1389                        }
1390                    } else if aws_region.is_some() {
1391                        tracing::warn!(
1392                            "[proxy.providers] non-Bedrock provider '{id}' cannot set aws_region — skipped"
1393                        );
1394                        continue;
1395                    }
1396                    // Explicit `local` flag wins; otherwise loopback URLs are
1397                    // local (host.docker.internal etc. need the explicit flag).
1398                    let local = entry.local.unwrap_or_else(|| is_local_proxy_url(&base_url));
1399                    out.push(ResolvedProvider {
1400                        id: id.to_string(),
1401                        shape: entry.shape,
1402                        base_url,
1403                        api_key_env: entry
1404                            .api_key_env
1405                            .as_deref()
1406                            .map(str::trim)
1407                            .filter(|v| !v.is_empty())
1408                            .map(str::to_string),
1409                        aws_region: aws_region.map(str::to_string),
1410                        account_ref: account_ref.map(str::to_string),
1411                        region: if entry.shape == WireShape::Bedrock {
1412                            aws_region.map(str::to_string)
1413                        } else {
1414                            configured_region.map(str::to_string)
1415                        },
1416                        local,
1417                        credential: CredentialSource::Configured,
1418                    });
1419                }
1420                Err(e) => {
1421                    tracing::warn!("[proxy.providers] '{id}' has invalid base_url — skipped: {e}");
1422                }
1423            }
1424        }
1425        out
1426    }
1427
1428    /// Resolve all upstreams from config.toml only (ignoring `LEAN_CTX_*` env) —
1429    /// the values a freshly (re)started managed proxy would serve. Used by
1430    /// status/doctor to detect drift from a running proxy's live upstream (#449).
1431    pub fn resolve_all_disk(&self) -> Upstreams {
1432        let pick = |provider: ProxyProvider| {
1433            self.resolve_upstream_inner(provider, false)
1434                .unwrap_or_else(|_| normalize_url(self.provider_spec(provider).2))
1435        };
1436        Upstreams {
1437            anthropic: pick(ProxyProvider::Anthropic),
1438            openai: pick(ProxyProvider::OpenAi),
1439            chatgpt: pick(ProxyProvider::ChatGpt),
1440            gemini: pick(ProxyProvider::Gemini),
1441            providers: self.resolve_providers(),
1442        }
1443    }
1444
1445    /// Re-resolve upstreams for a *running* proxy (#449). For any provider whose
1446    /// currently configured/env value fails validation, the last good value is
1447    /// kept instead of rerouting live traffic to the provider default — so a typo
1448    /// in config.toml can never silently redirect in-flight requests.
1449    pub fn refresh_upstreams(&self, last: &Upstreams) -> Upstreams {
1450        let keep = |provider: ProxyProvider, prev: &str| {
1451            self.resolve_upstream_checked(provider).unwrap_or_else(|e| {
1452                tracing::warn!("upstream invalid, keeping {prev}: {e}");
1453                prev.to_string()
1454            })
1455        };
1456        Upstreams {
1457            anthropic: keep(ProxyProvider::Anthropic, &last.anthropic),
1458            openai: keep(ProxyProvider::OpenAi, &last.openai),
1459            chatgpt: keep(ProxyProvider::ChatGpt, &last.chatgpt),
1460            gemini: keep(ProxyProvider::Gemini, &last.gemini),
1461            // Registry re-resolution is deterministic from config; an entry
1462            // that turned invalid is dropped with a warning (see
1463            // `resolve_providers`), the rest keep serving.
1464            providers: self.resolve_providers(),
1465        }
1466    }
1467}
1468
1469/// Resolve a tri-state boolean toggle for the default-**on** proxy features: an
1470/// explicit `on`/`off`-style environment variable wins, then the config
1471/// `Option<bool>`, else `default`. Lets an operator force a feature on **or** off
1472/// from the shell; an unparseable value is ignored so a typo can never silently
1473/// flip it (mirrors [`ProxyConfig::live_compresses`]).
1474fn env_bool_or(env_key: &str, configured: Option<bool>, default: bool) -> bool {
1475    if let Ok(raw) = std::env::var(env_key) {
1476        match raw.trim().to_ascii_lowercase().as_str() {
1477            "1" | "true" | "yes" | "on" => return true,
1478            "0" | "false" | "no" | "off" => return false,
1479            _ => {}
1480        }
1481    }
1482    configured.unwrap_or(default)
1483}
1484
1485/// Built-in default live-compress exclusion (#481). Serena's code-reading tools
1486/// (`find_symbol`/`find_referencing_symbols`/`search_for_pattern`) return source
1487/// bodies the model edits, yet are mis-bucketed as `Search` by name, so the proxy
1488/// would otherwise gut them. Protect anything namespaced `serena` by default.
1489fn default_live_compress_exclude() -> Vec<String> {
1490    vec!["serena".to_string()]
1491}
1492
1493#[cfg(test)]
1494#[path = "proxy_tests.rs"]
1495mod tests;