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