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-compatible 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 /// Opt-in: route a Codex *ChatGPT-subscription* login through the proxy for
193 /// model-turn compression. Default `None`/`false` keeps Codex native (history
194 /// visible, cloud/remote intact, no #597). When `true`, Codex setup pins the
195 /// generated `leanctx-chatgpt` provider + `chatgpt_base_url`; that scopes Codex
196 /// history to the provider (#597), so it stays opt-in. Toggle durably with
197 /// `lean-ctx proxy codex-chatgpt on|off`; resolved via
198 /// [`ProxyConfig::codex_chatgpt_proxy_enabled`].
199 pub codex_chatgpt_proxy: Option<bool>,
200 /// Active request routing (`[proxy.routing]`, enterprise#13): model aliases
201 /// and intent-tier downgrades applied in the forward path. Off by default —
202 /// an empty/absent table is a strict passthrough. See [`RoutingRules`].
203 pub routing: RoutingRules,
204 /// Counterfactual-baseline parameters (`[proxy.baseline]`, enterprise#15/#18)
205 /// for the avoided-cost evidence chain. See [`BaselineConfig`].
206 pub baseline: BaselineConfig,
207}
208
209/// `[proxy.baseline]` — the contract-frozen counterfactual parameters that make
210/// the success fee provable (enterprise#15, Doc 04 §6 / Doc 08 §2).
211///
212/// - `reference_model`: the model the customer *would have used* without
213/// lean-ctx. Frozen per deployment/contract (calibration: enterprise#41);
214/// every usage event stores `reference_cost_usd` = the request's
215/// **uncompressed** input tokens priced at this model's input rate — the
216/// counterfactual cost the avoided-cost ledger settles against.
217/// - `local_shadow_rate_per_mtok`: USD per 1M tokens booked as the actual cost
218/// of locally served (loopback) inference. Local compute is never free —
219/// hardware and power are real — so the shadow rate keeps local-model savings
220/// honest instead of infinite. Default: `0.25` USD/MTok, a conservative
221/// self-hosting cost estimate; calibrate per deployment.
222#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
223#[serde(default)]
224pub struct BaselineConfig {
225 /// Counterfactual reference model (`None` = baseline evidence off).
226 pub reference_model: Option<String>,
227 /// USD per 1M tokens for local/loopback inference (default 0.25, never 0).
228 pub local_shadow_rate_per_mtok: Option<f64>,
229}
230
231/// Default local shadow rate (USD per 1M tokens) when `[proxy.baseline]` sets
232/// none: a conservative self-hosted inference cost so `is_local` usage is
233/// never booked at $0 (Doc 04 §6 "local-free ≠ cost-free").
234pub const DEFAULT_LOCAL_SHADOW_RATE_PER_MTOK: f64 = 0.25;
235
236impl BaselineConfig {
237 /// Effective shadow rate: configured value (clamped positive) or default.
238 #[must_use]
239 pub fn effective_local_shadow_rate(&self) -> f64 {
240 match self.local_shadow_rate_per_mtok {
241 Some(r) if r > 0.0 => r,
242 _ => DEFAULT_LOCAL_SHADOW_RATE_PER_MTOK,
243 }
244 }
245}
246
247/// `[proxy.routing]` — the active router's rule set (enterprise#13).
248///
249/// Two mechanisms, both **within-shape** in M1 (the target must speak the same
250/// wire dialect as the request; N×M shape translation is M2):
251///
252/// - **Aliases**: exact requested-model → target. Lets an org expose stable
253/// names (`acme/fast`) or transparently swap one concrete model for another.
254/// - **Tiers**: intent-based downgrade. The request's last user message is
255/// classified (`intent_router`); the resulting tier (`fast|standard|premium`)
256/// picks a target from this table. An absent tier key (or `""`) keeps the
257/// requested model — premium work is never silently downgraded unless the
258/// operator says so.
259///
260/// A target is `"model"` (swap the model, keep the upstream) or
261/// `"provider:model"` where `provider` is a `[[proxy.providers]]` registry id
262/// or a built-in (`anthropic|openai|gemini`) — then the request is also
263/// re-targeted to that provider's upstream.
264///
265/// **Fail-open by construction:** any lookup/classification/validation miss
266/// routes nothing and forwards the request unchanged.
267#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
268#[serde(default)]
269pub struct RoutingRules {
270 /// Master switch; `false`/absent = passthrough (no body rewrite at all).
271 pub enabled: Option<bool>,
272 /// Exact model-name aliases: requested model → `"provider:model"` | `"model"`.
273 /// BTreeMap for deterministic iteration/serialization (#498).
274 pub aliases: std::collections::BTreeMap<String, String>,
275 /// Intent-tier targets: `fast|standard|premium` → `"provider:model"` |
276 /// `"model"` | `""` (= keep requested model).
277 pub tiers: std::collections::BTreeMap<String, String>,
278}
279
280impl RoutingRules {
281 /// True when the router should run at all.
282 #[must_use]
283 pub fn is_active(&self) -> bool {
284 self.enabled.unwrap_or(false) && !(self.aliases.is_empty() && self.tiers.is_empty())
285 }
286}
287
288/// A parsed routing target: optional provider id + model name.
289/// `"foundry:gpt-4o-mini"` → provider `foundry`, model `gpt-4o-mini`;
290/// `"claude-haiku-4-5"` → model only (upstream unchanged).
291#[must_use]
292pub fn parse_route_target(target: &str) -> Option<(Option<&str>, &str)> {
293 let t = target.trim();
294 if t.is_empty() {
295 return None;
296 }
297 match t.split_once(':') {
298 Some((provider, model)) => {
299 let (provider, model) = (provider.trim(), model.trim());
300 if provider.is_empty() || model.is_empty() {
301 None
302 } else {
303 Some((Some(provider), model))
304 }
305 }
306 None => Some((None, t)),
307 }
308}
309
310/// The API dialect an upstream endpoint speaks — deliberately separate from the
311/// provider's *identity*. lean-ctx understands three wire shapes; any number of
312/// configured providers (Foundry, OpenRouter, Groq, a local vLLM…) map onto
313/// them. New shape = code; new provider = config (universal-provider-framework).
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(rename_all = "lowercase")]
316pub enum WireShape {
317 /// Anthropic Messages API (`/v1/messages`).
318 Anthropic,
319 /// OpenAI Chat Completions / Responses API (also spoken by Azure AI
320 /// Foundry, OpenRouter, Groq, vLLM, Ollama, LM Studio…).
321 OpenAi,
322 /// Google Gemini `generateContent` API.
323 Gemini,
324}
325
326impl WireShape {
327 /// Stable lowercase name (serde representation) for logs and `/status`.
328 #[must_use]
329 pub fn as_str(self) -> &'static str {
330 match self {
331 WireShape::Anthropic => "anthropic",
332 WireShape::OpenAi => "openai",
333 WireShape::Gemini => "gemini",
334 }
335 }
336}
337
338/// One `[[proxy.providers]]` registry entry (see [`ProxyConfig::providers`]).
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct ProviderEntry {
341 /// Registry id, used in the `/providers/{id}/...` route and in routing
342 /// rules. Lowercase alphanumeric plus `-`/`_`; must not shadow a built-in
343 /// provider name (`anthropic`, `openai`, `chatgpt`, `gemini`).
344 pub id: String,
345 /// Which API dialect the endpoint speaks (`anthropic|openai|gemini`).
346 pub shape: WireShape,
347 /// Endpoint base URL. HTTPS for any non-loopback host; a declared registry
348 /// entry is itself the custom-host opt-in (no separate allowlist flag).
349 pub base_url: String,
350 /// Name of the environment variable holding the upstream API key the
351 /// gateway injects (replacing the caller's credential headers). `None` =
352 /// forward the caller's own credentials verbatim (default, loopback mode).
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub api_key_env: Option<String>,
355 /// Set `false` to keep the entry in config but take it out of service.
356 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub enabled: Option<bool>,
358 /// Marks this endpoint as local inference (Ollama/vLLM/…): usage is booked
359 /// at the transparent `local_shadow_rate` instead of provider list prices
360 /// (enterprise#15/#18). Unset = derived from the URL (loopback hosts are
361 /// local). Set it explicitly when the endpoint is local but not loopback —
362 /// the containerized gateway reaching the host's Ollama
363 /// (`host.docker.internal`) or an in-cluster server (`ollama.svc.cluster.local`).
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub local: Option<bool>,
366}
367
368/// A validated, ready-to-serve registry provider (runtime view of
369/// [`ProviderEntry`], published inside [`Upstreams`]).
370#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct ResolvedProvider {
372 pub id: String,
373 pub shape: WireShape,
374 pub base_url: String,
375 pub api_key_env: Option<String>,
376 /// Billed as local inference (shadow rate). Explicit `local` flag when the
377 /// entry declares one, otherwise loopback-URL derivation.
378 pub local: bool,
379}
380
381/// Built-in provider route names a registry entry must not shadow.
382const BUILTIN_PROVIDER_IDS: &[&str] = &["anthropic", "openai", "chatgpt", "gemini"];
383
384/// True when `id` is usable as a registry id: non-empty, lowercase alnum plus
385/// `-`/`_` (it becomes a URL path segment), and not a built-in provider name.
386fn is_valid_provider_id(id: &str) -> bool {
387 !id.is_empty()
388 && !BUILTIN_PROVIDER_IDS.contains(&id)
389 && id
390 .chars()
391 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
392}
393
394/// Per-role prose-compression intensity for the proxy's frozen request region.
395///
396/// Each value is a `0.0–1.0` aggressiveness level reusing the same mapping as
397/// the `ctx_read` knob (#708): `0.0` keeps everything, `1.0` is most aggressive.
398/// `None` (the default) means "do not compress this role's prose" so the proxy
399/// stays byte-for-byte unchanged until an operator opts in. The `assistant`
400/// role is never represented here — model turns are always passed through
401/// verbatim (the #710 passthrough guarantee).
402#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
403#[serde(default)]
404pub struct RoleAggressiveness {
405 /// Aggressiveness for system prompts (Anthropic `system` / OpenAI `system`
406 /// messages / Gemini `systemInstruction`). `None` = leave untouched.
407 pub system: Option<f64>,
408 /// Aggressiveness for user prose (free-text user turns, never tool results).
409 /// `None` = leave untouched.
410 pub user: Option<f64>,
411}
412
413/// The conversation roles whose prose the proxy may compress in the frozen
414/// region. Deliberately excludes `assistant` — model turns are never rewritten.
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
416pub enum ProseRole {
417 System,
418 User,
419}
420
421/// How the proxy squeezes prose it must shrink (#895).
422#[derive(Debug, Clone, Copy, PartialEq, Eq)]
423pub enum ProseRanker {
424 /// Extractive embedding ranking when the engine is available, else truncate.
425 /// The default — strictly better than truncation, and cache-safe via the
426 /// per-content memo in [`crate::proxy::prose_ranker`].
427 Auto,
428 /// Same engine path as `Auto` (kept distinct so an operator can express
429 /// intent / so a future "require engine" semantic has a name).
430 Extractive,
431 /// Original deterministic FIFO squeeze; never touches the embedding engine.
432 Truncate,
433}
434
435/// How the proxy prunes old tool results from conversation history.
436///
437/// Provider prompt caches (Anthropic `cache_control`, OpenAI automatic prompt
438/// caching) bill cached prefix tokens at a fraction of the base rate but only
439/// match *exact* prefixes. Any mutation whose position depends on the current
440/// conversation length (a rolling window) rewrites a previously-stable message
441/// every turn, invalidating the cache from that point — turning cheap cache
442/// reads into full-price writes.
443#[derive(Debug, Clone, Copy, PartialEq, Eq)]
444pub enum HistoryMode {
445 /// Prune only at frozen generation boundaries that advance in large,
446 /// deterministic steps. Between jumps the request prefix is byte-stable,
447 /// so provider prompt caches keep hitting. Content the client has marked
448 /// with a `cache_control` breakpoint is never rewritten, so an advancing
449 /// boundary can no longer invalidate the already-cached prefix (#448).
450 /// Default.
451 CacheAware,
452 /// Legacy behaviour: summarize everything older than the last N messages.
453 /// Maximum raw-token reduction, but defeats provider prompt caching.
454 Rolling,
455 /// Never prune history (tool-result compression still applies — it is
456 /// content-deterministic and therefore prefix-stable).
457 Off,
458}
459
460impl ProxyConfig {
461 /// Resolved history mode: `LEAN_CTX_PROXY_HISTORY_MODE` env var wins,
462 /// then `[proxy].history_mode` in config.toml, then cache-aware.
463 /// Unknown values fall back to the default so a typo can never silently
464 /// re-enable the cache-hostile rolling mode.
465 pub fn resolved_history_mode(&self) -> HistoryMode {
466 let raw = std::env::var("LEAN_CTX_PROXY_HISTORY_MODE")
467 .ok()
468 .or_else(|| self.history_mode.clone());
469 match raw.as_deref().map(str::trim) {
470 Some(s) if s.eq_ignore_ascii_case("rolling") => HistoryMode::Rolling,
471 Some(s) if s.eq_ignore_ascii_case("off") => HistoryMode::Off,
472 _ => HistoryMode::CacheAware,
473 }
474 }
475
476 /// Whether the proxy injects `stream_options.include_usage` into streamed
477 /// OpenAI Chat Completions to meter real spend. `[proxy] meter_openai_usage`
478 /// in config.toml, default `true`.
479 pub fn meters_openai_usage(&self) -> bool {
480 self.meter_openai_usage.unwrap_or(true)
481 }
482
483 /// Operator-configured extra cost header (#1189), normalized to lowercase.
484 /// `None` when unset/blank — LiteLLM's standard header is always checked.
485 pub fn cost_response_header(&self) -> Option<String> {
486 self.cost_response_header
487 .as_deref()
488 .map(str::trim)
489 .filter(|h| !h.is_empty())
490 .map(str::to_lowercase)
491 }
492
493 /// Resolved prose-ranker strategy (#895). Precedence: the
494 /// `LEAN_CTX_PROXY_PROSE_RANKER` env var, then `[proxy] prose_ranker` in
495 /// config.toml, then `Auto`. Unknown values resolve to `Auto` so a typo can
496 /// never silently disable the premium path; `"truncate"`/`"off"` selects the
497 /// legacy squeeze.
498 #[must_use]
499 pub fn resolved_prose_ranker(&self) -> ProseRanker {
500 let raw = std::env::var("LEAN_CTX_PROXY_PROSE_RANKER")
501 .ok()
502 .or_else(|| self.prose_ranker.clone());
503 match raw.as_deref().map(str::trim) {
504 Some(s) if s.eq_ignore_ascii_case("truncate") || s.eq_ignore_ascii_case("off") => {
505 ProseRanker::Truncate
506 }
507 Some(s) if s.eq_ignore_ascii_case("extractive") => ProseRanker::Extractive,
508 _ => ProseRanker::Auto,
509 }
510 }
511
512 /// Resolved output-savings holdout fraction (#895 Track B), clamped to
513 /// `[0,1]`. Precedence: `LEAN_CTX_PROXY_OUTPUT_HOLDOUT` env > `[proxy]
514 /// output_holdout` > `0.0` (no holdout). An unparseable/blank env value is
515 /// ignored so a typo can never silently change the experiment fraction.
516 #[must_use]
517 pub fn output_holdout_fraction(&self) -> f64 {
518 let from_env = std::env::var("LEAN_CTX_PROXY_OUTPUT_HOLDOUT")
519 .ok()
520 .and_then(|v| v.trim().parse::<f64>().ok());
521 from_env
522 .or(self.output_holdout)
523 .unwrap_or(0.0)
524 .clamp(0.0, 1.0)
525 }
526
527 /// Whether the cache-safe wire verbosity steer (#895) is enabled. Precedence:
528 /// `LEAN_CTX_PROXY_VERBOSITY_STEER` env (`1`/`true`/`on`) > `[proxy]
529 /// verbosity_steer` > `false` (off).
530 #[must_use]
531 pub fn verbosity_steer_enabled(&self) -> bool {
532 if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_VERBOSITY_STEER") {
533 let v = raw.trim();
534 return v.eq_ignore_ascii_case("1")
535 || v.eq_ignore_ascii_case("true")
536 || v.eq_ignore_ascii_case("on")
537 || v.eq_ignore_ascii_case("yes");
538 }
539 self.verbosity_steer.unwrap_or(false)
540 }
541
542 /// Resolved Codex ChatGPT-subscription proxy opt-in (default off).
543 /// `LEAN_CTX_CODEX_CHATGPT_PROXY` (any value) forces it on for the current
544 /// process, then `[proxy] codex_chatgpt_proxy` in config.toml, else `false`.
545 pub fn codex_chatgpt_proxy_enabled(&self) -> bool {
546 std::env::var("LEAN_CTX_CODEX_CHATGPT_PROXY").is_ok()
547 || self.codex_chatgpt_proxy.unwrap_or(false)
548 }
549
550 /// Whether the opt-in cold-prefix repack (#480) is enabled. A wrong "cold"
551 /// guess re-bills cache reads as writes (~12x), so this is off by default and
552 /// must be explicitly enabled. `LEAN_CTX_PROXY_COLD_PREFIX_REPACK` (any
553 /// value) wins, then `[proxy] cold_prefix_repack` in config.toml, else
554 /// `false`.
555 pub fn repacks_cold_prefix(&self) -> bool {
556 std::env::var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK").is_ok()
557 || self.cold_prefix_repack.unwrap_or(false)
558 }
559
560 /// Whether opt-in in-band CCR retrieval (#493) is enabled. Off by default:
561 /// the splice mutates provider-visible conversation content for the one turn
562 /// the model asks to expand, so it must be an explicit opt-in.
563 /// `LEAN_CTX_PROXY_CCR_INBAND` (any value) wins, then `[proxy] ccr_inband` in
564 /// config.toml, else `false`.
565 pub fn ccr_inband_enabled(&self) -> bool {
566 std::env::var("LEAN_CTX_PROXY_CCR_INBAND").is_ok() || self.ccr_inband.unwrap_or(false)
567 }
568
569 /// Whether opt-in Anthropic prompt-cache breakpoint injection (#939) is
570 /// enabled. Off by default: it mutates the provider-visible `system` shape
571 /// (string → cache-marked block array), so it must be an explicit opt-in.
572 /// `LEAN_CTX_PROXY_CACHE_BREAKPOINT` (any value) wins, then `[proxy]
573 /// cache_breakpoint` in config.toml, else `false`.
574 pub fn cache_breakpoint_enabled(&self) -> bool {
575 std::env::var("LEAN_CTX_PROXY_CACHE_BREAKPOINT").is_ok()
576 || self.cache_breakpoint.unwrap_or(false)
577 }
578
579 /// Whether opt-in counterfactual savings metering (#701) is enabled. Off by
580 /// default: it fires one extra (free) Anthropic `count_tokens` call per
581 /// rewritten request — pure telemetry, but extra latency budget and
582 /// rate-limit surface, so it must be an explicit opt-in.
583 /// `LEAN_CTX_PROXY_COUNTERFACTUAL` (any value) wins, then `[proxy]
584 /// counterfactual_metering` in config.toml, else `false`.
585 pub fn counterfactual_metering_enabled(&self) -> bool {
586 std::env::var("LEAN_CTX_PROXY_COUNTERFACTUAL").is_ok()
587 || self.counterfactual_metering.unwrap_or(false)
588 }
589
590 /// Whether opt-in cache-aligner volatile-field telemetry (#940) is enabled.
591 /// On by default (#986 premium defaults): the scan is pure measurement and
592 /// never mutates the body, so every proxy ships cache-leak visibility out of
593 /// the box. Strictly cache-safe. `LEAN_CTX_PROXY_CACHE_ALIGNER=on|off` wins,
594 /// then `[proxy] cache_aligner` in config.toml, else `true`. Opt **out** only
595 /// to drop the per-request system-prompt scan.
596 pub fn cache_aligner_enabled(&self) -> bool {
597 env_bool_or("LEAN_CTX_PROXY_CACHE_ALIGNER", self.cache_aligner, true)
598 }
599
600 /// Whether opt-in active cache-aligner relocate (#974) is enabled. Off by
601 /// default: it reshapes the provider-visible `system` field (moving volatile
602 /// values to an uncached tail block), so it must be an explicit opt-in.
603 /// `LEAN_CTX_PROXY_CACHE_ALIGN_RELOCATE` (any value) wins, then `[proxy]
604 /// cache_align_relocate` in config.toml, else `false`.
605 pub fn cache_align_relocate_enabled(&self) -> bool {
606 std::env::var("LEAN_CTX_PROXY_CACHE_ALIGN_RELOCATE").is_ok()
607 || self.cache_align_relocate.unwrap_or(false)
608 }
609
610 /// Whether cache-economics (#986) is enabled: prompt-cache miss attribution
611 /// telemetry plus the net-cost repack gate. Both are strictly safe
612 /// (measurement + a more-conservative repack that never busts a cache the
613 /// default kept), so this is **on by default** — every proxy gets the
614 /// diagnosis and the safer repack out of the box.
615 /// `LEAN_CTX_PROXY_CACHE_POLICY=on|off` wins, then `[proxy] cache_policy` in
616 /// config.toml, else `true`. Opt out to keep `/status` free of the attribution
617 /// gauges and skip the per-request prefix hash.
618 pub fn cache_policy_enabled(&self) -> bool {
619 env_bool_or("LEAN_CTX_PROXY_CACHE_POLICY", self.cache_policy, true)
620 }
621
622 /// Resolved cross-provider reasoning effort (#834), or `None` when the
623 /// feature is off (the default — a strict no-op that preserves the
624 /// byte-unchanged meter-only path). Precedence: `LEAN_CTX_PROXY_EFFORT` env
625 /// (`off` disables, a valid level wins, an unparseable/blank value is
626 /// ignored) > `[proxy] effort` in config.toml. Any unknown value resolves to
627 /// `None` so a typo can never silently enable reasoning steering.
628 #[must_use]
629 pub fn resolved_effort(&self) -> Option<super::Effort> {
630 if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_EFFORT") {
631 let trimmed = raw.trim();
632 if trimmed.eq_ignore_ascii_case("off") {
633 return None;
634 }
635 if let Some(effort) = super::Effort::parse(trimmed) {
636 return Some(effort);
637 }
638 // Blank/unknown env → ignore and fall through to config, mirroring
639 // `live_compresses` so a typo never flips the configured behaviour.
640 }
641 self.effort.as_deref().and_then(super::Effort::parse)
642 }
643
644 /// Whether the proxy live-compresses non-protected `tool_result` content
645 /// (#481). `LEAN_CTX_PROXY_LIVE_COMPRESS` (`0`/`false`/`off`/`no` → off,
646 /// `1`/`true`/`on`/`yes` → on) wins, then `[proxy] live_compress` in
647 /// config.toml, else `true`. An unparseable/blank env value is ignored so a
648 /// typo can never silently flip the mode.
649 pub fn live_compresses(&self) -> bool {
650 if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_LIVE_COMPRESS") {
651 match raw.trim().to_ascii_lowercase().as_str() {
652 "0" | "false" | "off" | "no" => return false,
653 "1" | "true" | "on" | "yes" => return true,
654 _ => {}
655 }
656 }
657 self.live_compress.unwrap_or(true)
658 }
659
660 /// Resolved per-tool live-compress exclusion patterns (#481). `None` in
661 /// config falls back to the built-in default (protect Serena); an explicit
662 /// list — including the empty list — is used verbatim so operators can narrow
663 /// or fully clear it.
664 #[must_use]
665 pub fn live_compress_exclude_patterns(&self) -> Vec<String> {
666 self.live_compress_exclude
667 .clone()
668 .unwrap_or_else(default_live_compress_exclude)
669 }
670
671 /// Whether `tool_name` is on the live-compress exclusion list (#481) and must
672 /// therefore reach the model intact, like a protected file read. Matching is
673 /// case-insensitive substring, mirroring `tool_kind::classify_tool_name`.
674 #[must_use]
675 pub fn is_tool_live_compress_excluded(&self, tool_name: &str) -> bool {
676 let name = tool_name.to_ascii_lowercase();
677 self.live_compress_exclude_patterns().iter().any(|p| {
678 let p = p.trim().to_ascii_lowercase();
679 !p.is_empty() && name.contains(p.as_str())
680 })
681 }
682
683 /// Compiled `compress_protect` globs (#1150), skipping any that fail to parse
684 /// so one malformed entry never disables the rest. Empty when unset — the
685 /// default — which makes [`Self::is_path_compress_protected`] a fast no-op.
686 #[must_use]
687 pub fn compress_protect_globs(&self) -> Vec<glob::Pattern> {
688 self.compress_protect
689 .as_deref()
690 .unwrap_or_default()
691 .iter()
692 .filter_map(|p| glob::Pattern::new(p.trim()).ok())
693 .collect()
694 }
695
696 /// Whether `path` is on the never-compress list (#1150) and must be returned
697 /// verbatim. Each glob is tried against both the full path (with backslashes
698 /// normalised to `/`) and the bare file name, so `*.snap` matches anywhere
699 /// while `**/golden/**` can still target a directory. Empty list → always
700 /// `false` (today's behaviour), so a default proxy pays nothing.
701 #[must_use]
702 pub fn is_path_compress_protected(&self, path: &str) -> bool {
703 let patterns = self.compress_protect_globs();
704 if patterns.is_empty() {
705 return false;
706 }
707 let norm = path.replace('\\', "/");
708 let base = norm.rsplit('/').next().unwrap_or(norm.as_str());
709 patterns.iter().any(|p| p.matches(&norm) || p.matches(base))
710 }
711
712 /// Resolved prose-compression aggressiveness for `role`, clamped to `[0,1]`,
713 /// or `None` when prose compression is off for that role (the default).
714 ///
715 /// Precedence: the role's env override (`LEAN_CTX_PROXY_SYSTEM_AGGR` /
716 /// `LEAN_CTX_PROXY_USER_AGGR`) wins, then `[proxy.role_aggressiveness]` in
717 /// config.toml. An unparseable or blank env value is ignored so a typo can
718 /// never silently disable the configured behaviour.
719 #[must_use]
720 pub fn resolved_role_aggressiveness(&self, role: ProseRole) -> Option<f64> {
721 let (env_var, configured) = match role {
722 ProseRole::System => (
723 "LEAN_CTX_PROXY_SYSTEM_AGGR",
724 self.role_aggressiveness.system,
725 ),
726 ProseRole::User => ("LEAN_CTX_PROXY_USER_AGGR", self.role_aggressiveness.user),
727 };
728 let from_env = std::env::var(env_var)
729 .ok()
730 .and_then(|v| v.trim().parse::<f64>().ok());
731 from_env.or(configured).map(|a| a.clamp(0.0, 1.0))
732 }
733
734 /// Whether a non-loopback plaintext `http://` upstream is allowed. Opt-in
735 /// only — a deliberate downgrade for a trusted local-network service such as
736 /// `http://host.docker.internal:2455` in front of codex-lb (#440).
737 /// `LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM` (any value) wins, then
738 /// `[proxy] allow_insecure_http_upstream` in config.toml, default `false`.
739 pub fn allows_insecure_http_upstream(&self) -> bool {
740 std::env::var("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM").is_ok()
741 || self.allow_insecure_http_upstream.unwrap_or(false)
742 }
743
744 /// Whether a custom (non-allowlisted) HTTPS upstream host is allowed. Opt-in
745 /// only — lifting the built-in host allowlist points the proxy at a host you
746 /// control (e.g. a corporate gateway), so it must be deliberate.
747 /// `LEAN_CTX_ALLOW_CUSTOM_UPSTREAM` (any value) wins, then
748 /// `[proxy] allow_custom_upstream` in config.toml, default `false`.
749 ///
750 /// Unlike the env var, the **config flag reaches the managed (service-spawned)
751 /// proxy**, which only reads `config.toml` — that is the whole point of #590:
752 /// `proxy enable`/`restart` start the proxy via launchd/systemd, which never
753 /// inherits the shell's `LEAN_CTX_ALLOW_CUSTOM_UPSTREAM`.
754 pub fn allows_custom_upstream(&self) -> bool {
755 std::env::var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM").is_ok()
756 || self.allow_custom_upstream.unwrap_or(false)
757 }
758
759 /// True when any `*_upstream` configured in `config.toml` (env-independent) is a
760 /// custom HTTPS host outside the built-in allowlist — i.e. one that resolves
761 /// only with the [`Self::allows_custom_upstream`] opt-in. Plaintext-HTTP custom
762 /// hosts are governed by `allow_insecure_http_upstream` instead, so they are
763 /// excluded here. Lets `proxy enable`/`restart` persist the opt-in (so the
764 /// managed proxy honors it) and `proxy status` explain a blocked upstream,
765 /// without touching the allowlisted-host case (#590).
766 #[must_use]
767 pub fn has_custom_host_upstream(&self) -> bool {
768 [
769 self.anthropic_upstream.as_deref(),
770 self.openai_upstream.as_deref(),
771 self.chatgpt_upstream.as_deref(),
772 self.gemini_upstream.as_deref(),
773 ]
774 .into_iter()
775 .flatten()
776 .filter_map(normalize_url_opt)
777 .any(|u| is_custom_upstream_host(&u))
778 }
779
780 /// `(env var, configured value, provider default)` for one provider.
781 fn provider_spec(&self, provider: ProxyProvider) -> (&'static str, Option<&str>, &'static str) {
782 match provider {
783 ProxyProvider::Anthropic => (
784 "LEAN_CTX_ANTHROPIC_UPSTREAM",
785 self.anthropic_upstream.as_deref(),
786 "https://api.anthropic.com",
787 ),
788 ProxyProvider::OpenAi => (
789 "LEAN_CTX_OPENAI_UPSTREAM",
790 self.openai_upstream.as_deref(),
791 "https://api.openai.com",
792 ),
793 ProxyProvider::ChatGpt => (
794 "LEAN_CTX_CHATGPT_UPSTREAM",
795 self.chatgpt_upstream.as_deref(),
796 "https://chatgpt.com",
797 ),
798 ProxyProvider::Gemini => (
799 "LEAN_CTX_GEMINI_UPSTREAM",
800 self.gemini_upstream.as_deref(),
801 "https://generativelanguage.googleapis.com",
802 ),
803 }
804 }
805
806 /// Resolve one upstream with precedence `LEAN_CTX_*_UPSTREAM` env var >
807 /// `[proxy].*_upstream` (config.toml) > provider default.
808 ///
809 /// Returns `Err` when a value is *present but invalid* so a live reload can
810 /// keep the last good value instead of silently rerouting to the default; an
811 /// *absent* value resolves to the provider default (`Ok`).
812 fn resolve_upstream_checked(&self, provider: ProxyProvider) -> Result<String, String> {
813 self.resolve_upstream_inner(provider, true)
814 }
815
816 /// Shared resolver for [`resolve_upstream_checked`] and the disk-only view.
817 /// `use_env = false` ignores the `LEAN_CTX_*_UPSTREAM` override and yields
818 /// the config.toml truth a freshly (re)started managed proxy would serve.
819 fn resolve_upstream_inner(
820 &self,
821 provider: ProxyProvider,
822 use_env: bool,
823 ) -> Result<String, String> {
824 let (env_var, config_val, default) = self.provider_spec(provider);
825 let env_val = if use_env {
826 std::env::var(env_var)
827 .ok()
828 .and_then(|v| normalize_url_opt(&v))
829 } else {
830 None
831 };
832 let candidate = env_val.or_else(|| config_val.and_then(normalize_url_opt));
833 match candidate {
834 None => Ok(normalize_url(default)),
835 Some(url) => validate_upstream_url(
836 &url,
837 self.allows_insecure_http_upstream(),
838 self.allows_custom_upstream(),
839 ),
840 }
841 }
842
843 /// Effective upstream for a provider (env > config > default). An invalid
844 /// configured/env value falls back to the provider default (logged) — the
845 /// safe choice at startup.
846 pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
847 match self.resolve_upstream_checked(provider) {
848 Ok(url) => url,
849 Err(e) => {
850 tracing::warn!("upstream validation failed, using default: {e}");
851 normalize_url(self.provider_spec(provider).2)
852 }
853 }
854 }
855
856 /// Resolve all three upstreams at once (startup snapshot, env-aware).
857 pub fn resolve_all(&self) -> Upstreams {
858 Upstreams {
859 anthropic: self.resolve_upstream(ProxyProvider::Anthropic),
860 openai: self.resolve_upstream(ProxyProvider::OpenAi),
861 chatgpt: self.resolve_upstream(ProxyProvider::ChatGpt),
862 gemini: self.resolve_upstream(ProxyProvider::Gemini),
863 providers: self.resolve_providers(),
864 }
865 }
866
867 /// Validate + resolve the `[[proxy.providers]]` registry. Invalid entries
868 /// are logged and skipped (one typo must never take the proxy down or
869 /// disable the remaining registry); duplicates keep the first occurrence.
870 /// A declared registry entry is itself the deliberate custom-host opt-in,
871 /// so any HTTPS host is accepted; plaintext HTTP still requires loopback or
872 /// the explicit insecure-HTTP opt-in (same rule as the built-ins).
873 #[must_use]
874 pub fn resolve_providers(&self) -> Vec<ResolvedProvider> {
875 let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
876 let mut out = Vec::new();
877 for entry in &self.providers {
878 if !entry.enabled.unwrap_or(true) {
879 continue;
880 }
881 let id = entry.id.trim();
882 if !is_valid_provider_id(id) {
883 tracing::warn!(
884 "[proxy.providers] invalid id '{id}' (lowercase alnum/-/_ only, \
885 must not shadow a built-in provider) — entry skipped"
886 );
887 continue;
888 }
889 if !seen.insert(id) {
890 tracing::warn!("[proxy.providers] duplicate id '{id}' — keeping first entry");
891 continue;
892 }
893 match validate_upstream_url(&entry.base_url, self.allows_insecure_http_upstream(), true)
894 {
895 Ok(base_url) => {
896 // Explicit `local` flag wins; otherwise loopback URLs are
897 // local (host.docker.internal etc. need the explicit flag).
898 let local = entry.local.unwrap_or_else(|| is_local_proxy_url(&base_url));
899 out.push(ResolvedProvider {
900 id: id.to_string(),
901 shape: entry.shape,
902 base_url,
903 api_key_env: entry
904 .api_key_env
905 .as_deref()
906 .map(str::trim)
907 .filter(|v| !v.is_empty())
908 .map(str::to_string),
909 local,
910 });
911 }
912 Err(e) => {
913 tracing::warn!("[proxy.providers] '{id}' has invalid base_url — skipped: {e}");
914 }
915 }
916 }
917 out
918 }
919
920 /// Resolve all upstreams from config.toml only (ignoring `LEAN_CTX_*` env) —
921 /// the values a freshly (re)started managed proxy would serve. Used by
922 /// status/doctor to detect drift from a running proxy's live upstream (#449).
923 pub fn resolve_all_disk(&self) -> Upstreams {
924 let pick = |provider: ProxyProvider| {
925 self.resolve_upstream_inner(provider, false)
926 .unwrap_or_else(|_| normalize_url(self.provider_spec(provider).2))
927 };
928 Upstreams {
929 anthropic: pick(ProxyProvider::Anthropic),
930 openai: pick(ProxyProvider::OpenAi),
931 chatgpt: pick(ProxyProvider::ChatGpt),
932 gemini: pick(ProxyProvider::Gemini),
933 providers: self.resolve_providers(),
934 }
935 }
936
937 /// Re-resolve upstreams for a *running* proxy (#449). For any provider whose
938 /// currently configured/env value fails validation, the last good value is
939 /// kept instead of rerouting live traffic to the provider default — so a typo
940 /// in config.toml can never silently redirect in-flight requests.
941 pub fn refresh_upstreams(&self, last: &Upstreams) -> Upstreams {
942 let keep = |provider: ProxyProvider, prev: &str| {
943 self.resolve_upstream_checked(provider).unwrap_or_else(|e| {
944 tracing::warn!("upstream invalid, keeping {prev}: {e}");
945 prev.to_string()
946 })
947 };
948 Upstreams {
949 anthropic: keep(ProxyProvider::Anthropic, &last.anthropic),
950 openai: keep(ProxyProvider::OpenAi, &last.openai),
951 chatgpt: keep(ProxyProvider::ChatGpt, &last.chatgpt),
952 gemini: keep(ProxyProvider::Gemini, &last.gemini),
953 // Registry re-resolution is deterministic from config; an entry
954 // that turned invalid is dropped with a warning (see
955 // `resolve_providers`), the rest keep serving.
956 providers: self.resolve_providers(),
957 }
958 }
959}
960
961/// The resolved provider upstreams a running proxy forwards to. Published
962/// to request handlers via a `tokio::sync::watch` channel so a config change is
963/// picked up live, without a proxy restart (#449).
964#[derive(Debug, Clone, PartialEq, Eq)]
965pub struct Upstreams {
966 pub anthropic: String,
967 pub openai: String,
968 pub chatgpt: String,
969 pub gemini: String,
970 /// Registry providers from `[[proxy.providers]]` (universal framework),
971 /// validated and live-reloadable exactly like the built-ins.
972 pub providers: Vec<ResolvedProvider>,
973}
974
975impl Upstreams {
976 /// Look up a registry provider by id (`/providers/{id}/...` route, router
977 /// upstream overrides). Built-ins are not addressed here.
978 #[must_use]
979 pub fn provider_by_id(&self, id: &str) -> Option<&ResolvedProvider> {
980 self.providers.iter().find(|p| p.id == id)
981 }
982}
983
984#[derive(Debug, Clone, Copy)]
985pub enum ProxyProvider {
986 Anthropic,
987 OpenAi,
988 ChatGpt,
989 Gemini,
990}
991
992/// Why a running proxy's live upstream differs from what the operator expects.
993#[derive(Debug, Clone, Copy, PartialEq, Eq)]
994pub enum UpstreamDrift {
995 /// A `LEAN_CTX_*_UPSTREAM` env var is set in *this* process but the proxy
996 /// serves a different value — the env never reached the MCP/service-spawned
997 /// proxy. This is the #449 trap: Codex (and other MCP hosts) launch the
998 /// server with a stripped, allowlisted env that omits `LEAN_CTX_*_UPSTREAM`,
999 /// so the proxy it spawns never sees it. Fix: persist it to config.toml,
1000 /// which the proxy reads live.
1001 EnvNotApplied,
1002 /// The proxy serves a value other than config.toml resolves to: it was
1003 /// started with an env override that now masks a later config edit. Fix:
1004 /// `lean-ctx proxy restart`.
1005 ConfigNotApplied,
1006}
1007
1008/// The `LEAN_CTX_*_UPSTREAM` override visible to *this* process for a provider,
1009/// normalized (`None` if unset/blank). Lets status/doctor explain why an env var
1010/// a user exported in their shell never reaches an MCP/service-spawned proxy.
1011pub fn env_upstream_override(provider: ProxyProvider) -> Option<String> {
1012 let var = match provider {
1013 ProxyProvider::Anthropic => "LEAN_CTX_ANTHROPIC_UPSTREAM",
1014 ProxyProvider::OpenAi => "LEAN_CTX_OPENAI_UPSTREAM",
1015 ProxyProvider::ChatGpt => "LEAN_CTX_CHATGPT_UPSTREAM",
1016 ProxyProvider::Gemini => "LEAN_CTX_GEMINI_UPSTREAM",
1017 };
1018 std::env::var(var).ok().and_then(|v| normalize_url_opt(&v))
1019}
1020
1021/// Diagnose upstream drift for one provider from the CLI-visible env override
1022/// (`env`), the config.toml value (`disk`) and the proxy's live value (`live`).
1023/// `None` means in sync.
1024pub fn diagnose_drift(env: Option<&str>, disk: &str, live: &str) -> Option<UpstreamDrift> {
1025 if let Some(env) = env {
1026 // An env override is present in this process: the proxy honours it only
1027 // if it was started with it. If the proxy serves something else, the env
1028 // never reached it (#449). If it matches, that is consistent (no drift).
1029 return (env != live).then_some(UpstreamDrift::EnvNotApplied);
1030 }
1031 // No env override here: the proxy should mirror config.toml.
1032 (disk != live).then_some(UpstreamDrift::ConfigNotApplied)
1033}
1034
1035/// Resolve a tri-state boolean toggle for the default-**on** proxy features: an
1036/// explicit `on`/`off`-style environment variable wins, then the config
1037/// `Option<bool>`, else `default`. Lets an operator force a feature on **or** off
1038/// from the shell; an unparseable value is ignored so a typo can never silently
1039/// flip it (mirrors [`ProxyConfig::live_compresses`]).
1040fn env_bool_or(env_key: &str, configured: Option<bool>, default: bool) -> bool {
1041 if let Ok(raw) = std::env::var(env_key) {
1042 match raw.trim().to_ascii_lowercase().as_str() {
1043 "1" | "true" | "yes" | "on" => return true,
1044 "0" | "false" | "no" | "off" => return false,
1045 _ => {}
1046 }
1047 }
1048 configured.unwrap_or(default)
1049}
1050
1051/// Built-in default live-compress exclusion (#481). Serena's code-reading tools
1052/// (`find_symbol`/`find_referencing_symbols`/`search_for_pattern`) return source
1053/// bodies the model edits, yet are mis-bucketed as `Search` by name, so the proxy
1054/// would otherwise gut them. Protect anything namespaced `serena` by default.
1055fn default_live_compress_exclude() -> Vec<String> {
1056 vec!["serena".to_string()]
1057}
1058
1059pub fn normalize_url(value: &str) -> String {
1060 value.trim().trim_end_matches('/').to_string()
1061}
1062
1063pub fn normalize_url_opt(value: &str) -> Option<String> {
1064 let trimmed = normalize_url(value);
1065 if trimmed.is_empty() {
1066 None
1067 } else {
1068 Some(trimmed)
1069 }
1070}
1071
1072const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
1073 "api.anthropic.com",
1074 "api.openai.com",
1075 "chatgpt.com",
1076 "generativelanguage.googleapis.com",
1077];
1078
1079pub(super) fn validate_upstream_url(
1080 url: &str,
1081 allow_insecure_http: bool,
1082 allow_custom_host: bool,
1083) -> Result<String, String> {
1084 let normalized = normalize_url(url);
1085 // Loopback HTTP never leaves the machine — always allowed.
1086 if is_local_proxy_url(&normalized) {
1087 return Ok(normalized);
1088 }
1089
1090 // A non-loopback plaintext `http://` upstream is reachable only through the
1091 // explicit opt-in (#440). The old code rejected it on the HTTPS check *before*
1092 // any override could apply, and pointed at `LEAN_CTX_ALLOW_CUSTOM_UPSTREAM`,
1093 // which never lifted the scheme restriction. Handle it up front: the opt-in
1094 // implies a deliberate custom host on a trusted local network, so it needs no
1095 // separate allowlist check; otherwise give a hint that actually works.
1096 if normalized.starts_with("http://") {
1097 if allow_insecure_http {
1098 return Ok(normalized);
1099 }
1100 return Err(format!(
1101 "upstream URL must use HTTPS: {normalized} (for a trusted local-network HTTP \
1102 upstream opt in with LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM=1 or \
1103 `[proxy] allow_insecure_http_upstream = true`)"
1104 ));
1105 }
1106 let Some(host_segment) = normalized.strip_prefix("https://") else {
1107 return Err(format!(
1108 "upstream URL must start with http:// or https://: {normalized}"
1109 ));
1110 };
1111
1112 let host = host_segment.split('/').next().unwrap_or("");
1113 let host_no_port = host.split(':').next().unwrap_or(host);
1114 if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port) || allow_custom_host {
1115 Ok(normalized)
1116 } else {
1117 Err(format!(
1118 "upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (for a \
1119 custom upstream host opt in with LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 or \
1120 `[proxy] allow_custom_upstream = true`)"
1121 ))
1122 }
1123}
1124
1125/// True when `url` is an HTTPS upstream whose host is not in the built-in
1126/// allowlist (and not loopback) — the case the `allow_custom_upstream` opt-in
1127/// governs. Plaintext-HTTP custom hosts are governed by
1128/// `allow_insecure_http_upstream` instead, so they are excluded here.
1129fn is_custom_upstream_host(url: &str) -> bool {
1130 let n = normalize_url(url);
1131 if is_local_proxy_url(&n) {
1132 return false;
1133 }
1134 let Some(host_segment) = n.strip_prefix("https://") else {
1135 return false;
1136 };
1137 let host = host_segment.split('/').next().unwrap_or("");
1138 let host_no_port = host.split(':').next().unwrap_or(host);
1139 !host_no_port.is_empty() && !ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
1140}
1141
1142pub fn is_local_proxy_url(value: &str) -> bool {
1143 let n = normalize_url(value);
1144 n.starts_with("http://127.0.0.1:")
1145 || n.starts_with("http://localhost:")
1146 || n.starts_with("http://[::1]:")
1147}
1148
1149#[cfg(test)]
1150#[path = "proxy_tests.rs"]
1151mod tests;