Skip to main content

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;