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;