Skip to main content

lean_ctx/core/config/
proxy.rs

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