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