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