Skip to main content

lean_ctx/proxy/
mod.rs

1//! LLM reverse proxy — the core of the **Gateway pillar**.
2//!
3//! Intercepts Anthropic, OpenAI, Gemini and ChatGPT traffic, compresses
4//! prompts, meters usage, and optionally translates request shapes.
5//!
6//! # Cross-pillar coupling
7//!
8//! When the `gateway-server` feature is active, `start_proxy` mounts
9//! `gateway_server::user_api` and `gateway_server::mcp::proxy` routes into
10//! the Axum router. This is intentional: the self-hosted org gateway is a
11//! single process that combines the proxy with the admin/usage store.
12//! The dependency is feature-gated and uni-directional at the route level
13//! (proxy owns the router, gateway_server provides route handlers).
14
15pub mod anthropic;
16#[cfg(test)]
17mod auth_tests;
18pub mod bedrock;
19pub mod break_even;
20pub mod cache_aligner;
21pub mod cache_attribution;
22pub mod cache_breakpoint;
23pub mod cache_policy;
24pub mod cache_safety;
25pub mod ccr;
26#[cfg(test)]
27mod ccr_robustness_tests;
28pub mod chatgpt;
29pub mod chatgpt_cookies;
30pub mod chatgpt_ws;
31mod codec;
32pub mod cold_prefix;
33pub mod compress;
34pub mod compress_api;
35mod connector;
36pub(crate) mod conversation;
37pub mod cost;
38pub mod counterfactual;
39pub mod dedup;
40pub mod effort;
41pub mod effort_routing;
42pub mod forward;
43pub mod gateway_identity;
44pub mod google;
45pub mod history_prune;
46pub mod holdout;
47pub mod image_compression;
48mod intent;
49pub mod introspect;
50pub mod latency_guard;
51mod lineage;
52pub mod metrics;
53pub mod model_router;
54pub mod models_api;
55pub mod ocla_cache_bridge;
56pub mod openai;
57pub mod openai_responses;
58pub mod openai_responses_ws;
59pub mod output_savings;
60pub mod pii;
61pub mod policy_gate;
62pub mod prefix_cache_stats;
63pub mod prefix_replay;
64pub mod prose;
65pub mod prose_ranker;
66pub mod providers;
67pub mod quality_lab_api;
68pub mod response_optimizer;
69pub(crate) mod response_shaper;
70pub mod routing;
71pub mod routing_feedback;
72#[cfg(feature = "shape-xlat")]
73pub mod shape_xlat;
74pub(crate) mod shaping_hook;
75pub mod sse_keepalive;
76#[cfg(test)]
77mod stats_tests;
78pub mod sticky_tools;
79pub mod tool_kind;
80pub mod tool_output;
81#[cfg(test)]
82mod upstream_tests;
83pub mod usage;
84pub mod usage_accounting;
85pub mod usage_azure;
86pub mod usage_meter;
87pub mod usage_parity;
88pub mod usage_sink;
89pub mod verbosity;
90pub mod web_app;
91pub(crate) mod web_app_middleware;
92
93use std::net::SocketAddr;
94use std::sync::Arc;
95use std::sync::atomic::{AtomicU64, Ordering};
96
97use crate::core::config::Upstreams;
98
99use axum::{
100    Router,
101    body::Body,
102    extract::State,
103    http::{Request, StatusCode},
104    response::{IntoResponse, Response},
105    routing::{any, get, post},
106};
107
108#[derive(Clone)]
109pub struct ProxyState {
110    pub client: reqwest::Client,
111    pub port: u16,
112    pub stats: Arc<ProxyStats>,
113    pub(crate) break_even: Arc<break_even::BreakEvenCalculator>,
114    pub introspect: Arc<introspect::IntrospectState>,
115    pub ocla_cache: Option<Arc<ocla_cache_bridge::OclaCacheBridge>>,
116    /// Live provider upstreams, refreshed from config.toml without a proxy
117    /// restart (#449). Read per request via [`ProxyState::openai_upstream`] etc.
118    pub upstreams: tokio::sync::watch::Receiver<Arc<Upstreams>>,
119    /// Shared Cloudflare cookie jar (also wired into `client`), so the Codex
120    /// ChatGPT WebSocket passthrough replays the same clearance to chatgpt.com
121    /// that the reqwest rail accumulated (#597).
122    pub(crate) chatgpt_cookies: Arc<chatgpt_cookies::ChatGptCloudflareCookieStore>,
123    /// Resolved `[[gateway_server.mcp_servers]]` registry snapshot (GL#100).
124    /// Empty when none are registered; the `/mcp/{server}` routes are only
125    /// mounted with the `gateway-server` feature. Startup snapshot, restart
126    /// to reload — the same lifecycle as `gateway-keys.toml`.
127    pub mcp_servers: Arc<Vec<crate::core::config::ResolvedMcpServer>>,
128    #[allow(dead_code)] // Used by web_app_middleware handlers in E13
129    pub(crate) web_app_tracker:
130        Arc<std::sync::Mutex<web_app::conversation_tracker::ConversationTracker>>,
131}
132
133impl ProxyState {
134    /// Test-only construction for modules outside `proxy` (the cookie store
135    /// field is deliberately `pub(crate)`-narrow): default upstreams, fresh
136    /// stats, the given MCP registry. Gated with the sole consumer
137    /// (`gateway_server::mcp::e2e_tests`) so feature-reduced builds don't
138    /// carry dead test scaffolding.
139    #[cfg(all(test, feature = "gateway-server"))]
140    pub(crate) fn for_tests(mcp_servers: Vec<crate::core::config::ResolvedMcpServer>) -> Self {
141        // Dropping the sender is fine: handlers only `borrow()` the last value.
142        let (_tx, rx) = tokio::sync::watch::channel(Arc::new(Upstreams {
143            anthropic: "https://api.anthropic.com".into(),
144            openai: "https://api.openai.com".into(),
145            chatgpt: "https://chatgpt.com".into(),
146            gemini: "https://generativelanguage.googleapis.com".into(),
147            providers: Vec::new(),
148        }));
149        Self {
150            client: reqwest::Client::new(),
151            port: 0,
152            stats: Arc::new(ProxyStats::default()),
153            break_even: Arc::new(break_even::BreakEvenCalculator::new(1500)),
154            introspect: Arc::new(introspect::IntrospectState::default()),
155            ocla_cache: None,
156            upstreams: rx,
157            chatgpt_cookies: chatgpt_cookies::shared_chatgpt_cloudflare_cookie_store(),
158            mcp_servers: Arc::new(mcp_servers),
159            web_app_tracker: Arc::new(std::sync::Mutex::new(
160                web_app::conversation_tracker::ConversationTracker::default(),
161            )),
162        }
163    }
164
165    /// Consistent snapshot of all upstreams for the current request/response.
166    pub fn upstream_snapshot(&self) -> Arc<Upstreams> {
167        self.upstreams.borrow().clone()
168    }
169
170    /// Snapshot MCP schema-cost break-even status for `/status`.
171    pub(crate) fn break_even_summary(&self) -> break_even::BreakEvenSummary {
172        self.break_even.summary()
173    }
174
175    /// Current Anthropic upstream (live).
176    pub fn anthropic_upstream(&self) -> String {
177        self.upstreams.borrow().anthropic.clone()
178    }
179
180    /// Current OpenAI upstream (live).
181    pub fn openai_upstream(&self) -> String {
182        self.upstreams.borrow().openai.clone()
183    }
184
185    /// Current ChatGPT upstream (live).
186    pub fn chatgpt_upstream(&self) -> String {
187        self.upstreams.borrow().chatgpt.clone()
188    }
189
190    /// Current Gemini upstream (live).
191    pub fn gemini_upstream(&self) -> String {
192        self.upstreams.borrow().gemini.clone()
193    }
194
195    /// Cloudflare `Cookie` header for the current ChatGPT upstream, used by the
196    /// WebSocket passthrough handshake (#597). `None` until a request on the
197    /// reqwest rail has seen Cloudflare clearance.
198    pub fn chatgpt_cookie_header(&self) -> Option<String> {
199        let url = reqwest::Url::parse(&self.chatgpt_upstream()).ok()?;
200        self.chatgpt_cookies
201            .cookie_header(&url)
202            .and_then(|v| v.to_str().ok().map(str::to_owned))
203    }
204}
205
206pub struct ProxyStats {
207    pub requests_total: AtomicU64,
208    pub requests_compressed: AtomicU64,
209    pub tokens_saved: AtomicU64,
210    pub bytes_original: AtomicU64,
211    pub bytes_compressed: AtomicU64,
212    pub anthropic: ProviderStats,
213    pub openai: ProviderStats,
214    pub chatgpt: ProviderStats,
215    pub gemini: ProviderStats,
216    /// Grok dual-rail (`/providers/grok-chat`, `/providers/xai`) — OpenAI wire
217    /// shape, separate identity so `proxy status` does not fold xAI traffic
218    /// into the OpenAI bucket.
219    pub grok: ProviderStats,
220    /// Command Code gateway rail (`/providers/commandcode`) — separate identity
221    /// so `proxy status` does not fold Command Code traffic into the wire-shape
222    /// bucket.
223    pub commandcode: ProviderStats,
224}
225
226#[derive(Default)]
227pub struct ProviderStats {
228    pub requests_total: AtomicU64,
229    pub requests_compressed: AtomicU64,
230    pub tokens_saved: AtomicU64,
231    pub bytes_original: AtomicU64,
232    pub bytes_compressed: AtomicU64,
233}
234
235impl Default for ProxyStats {
236    fn default() -> Self {
237        Self {
238            requests_total: AtomicU64::new(0),
239            requests_compressed: AtomicU64::new(0),
240            tokens_saved: AtomicU64::new(0),
241            bytes_original: AtomicU64::new(0),
242            bytes_compressed: AtomicU64::new(0),
243            anthropic: ProviderStats::default(),
244            openai: ProviderStats::default(),
245            chatgpt: ProviderStats::default(),
246            gemini: ProviderStats::default(),
247            grok: ProviderStats::default(),
248            commandcode: ProviderStats::default(),
249        }
250    }
251}
252
253impl ProxyStats {
254    pub fn record_request(&self, original: usize, compressed: usize) {
255        self.record_totals(original, compressed);
256    }
257
258    pub fn record_provider_request(
259        &self,
260        provider_label: &str,
261        original: usize,
262        compressed: usize,
263    ) {
264        let (effective_compressed, saved_tokens, compressed_request) =
265            self.record_totals(original, compressed);
266
267        if let Some(provider) = self.provider(provider_label) {
268            provider.record(
269                original,
270                effective_compressed,
271                compressed_request,
272                saved_tokens,
273            );
274        }
275    }
276
277    fn record_totals(&self, original: usize, compressed: usize) -> (usize, u64, bool) {
278        self.requests_total.fetch_add(1, Ordering::Relaxed);
279        self.bytes_original
280            .fetch_add(original as u64, Ordering::Relaxed);
281        let effective_compressed = compressed.min(original);
282        self.bytes_compressed
283            .fetch_add(effective_compressed as u64, Ordering::Relaxed);
284        if compressed < original {
285            self.requests_compressed.fetch_add(1, Ordering::Relaxed);
286        }
287        let saved_tokens = (original.saturating_sub(effective_compressed) / 4) as u64;
288        self.tokens_saved.fetch_add(saved_tokens, Ordering::Relaxed);
289        (effective_compressed, saved_tokens, compressed < original)
290    }
291
292    pub fn compression_ratio(&self) -> f64 {
293        let original = self.bytes_original.load(Ordering::Relaxed);
294        if original == 0 {
295            return 0.0;
296        }
297        let compressed = self.bytes_compressed.load(Ordering::Relaxed);
298        (1.0 - compressed as f64 / original as f64) * 100.0
299    }
300
301    /// Maps a proxy `provider_label` to its per-upstream bucket. Unknown labels
302    /// return `None` (still counted in the totals, never misattributed to a bucket);
303    /// every real upstream — Gemini included — passes an explicit label.
304    /// `"Grok"` is the identity label for registry routes `grok-chat` / `xai`
305    /// (OpenAI wire shape; see `providers::stats_label`). `"CommandCode"` is the
306    /// identity label for the `commandcode` registry route.
307    fn provider(&self, provider_label: &str) -> Option<&ProviderStats> {
308        match provider_label {
309            "Anthropic" => Some(&self.anthropic),
310            "OpenAI" => Some(&self.openai),
311            "ChatGPT" => Some(&self.chatgpt),
312            "Gemini" => Some(&self.gemini),
313            "Grok" => Some(&self.grok),
314            "CommandCode" => Some(&self.commandcode),
315            _ => None,
316        }
317    }
318
319    pub fn provider_summary(&self) -> serde_json::Value {
320        serde_json::json!({
321            "anthropic": self.anthropic.summary(),
322            "openai": self.openai.summary(),
323            "chatgpt": self.chatgpt.summary(),
324            "gemini": self.gemini.summary(),
325            "grok": self.grok.summary(),
326            "commandcode": self.commandcode.summary(),
327        })
328    }
329}
330
331impl ProviderStats {
332    fn record(
333        &self,
334        original: usize,
335        effective_compressed: usize,
336        compressed_request: bool,
337        saved_tokens: u64,
338    ) {
339        self.requests_total.fetch_add(1, Ordering::Relaxed);
340        if compressed_request {
341            self.requests_compressed.fetch_add(1, Ordering::Relaxed);
342        }
343        self.tokens_saved.fetch_add(saved_tokens, Ordering::Relaxed);
344        self.bytes_original
345            .fetch_add(original as u64, Ordering::Relaxed);
346        self.bytes_compressed
347            .fetch_add(effective_compressed as u64, Ordering::Relaxed);
348    }
349
350    fn compression_ratio(&self) -> f64 {
351        let original = self.bytes_original.load(Ordering::Relaxed);
352        if original == 0 {
353            return 0.0;
354        }
355        let compressed = self.bytes_compressed.load(Ordering::Relaxed);
356        (1.0 - compressed as f64 / original as f64) * 100.0
357    }
358
359    fn summary(&self) -> serde_json::Value {
360        serde_json::json!({
361            "requests_total": self.requests_total.load(Ordering::Relaxed),
362            "requests_compressed": self.requests_compressed.load(Ordering::Relaxed),
363            "tokens_saved": self.tokens_saved.load(Ordering::Relaxed),
364            "bytes_original": self.bytes_original.load(Ordering::Relaxed),
365            "bytes_compressed": self.bytes_compressed.load(Ordering::Relaxed),
366            "compression_ratio_pct": format!("{:.1}", self.compression_ratio()),
367        })
368    }
369}
370
371/// TCP connect timeout (seconds). Configurable via `LEAN_CTX_PROXY_CONNECT_TIMEOUT_SECS`.
372fn connect_timeout_secs() -> u64 {
373    std::env::var("LEAN_CTX_PROXY_CONNECT_TIMEOUT_SECS")
374        .ok()
375        .and_then(|v| v.trim().parse::<u64>().ok())
376        .filter(|s| *s > 0)
377        .unwrap_or(15)
378}
379
380/// Idle read timeout (seconds) between bytes from upstream. Generous by default
381/// so long extended-thinking phases (which still emit SSE keepalives) are never
382/// cut, while a truly dead connection eventually fails. Configurable via
383/// `LEAN_CTX_PROXY_READ_TIMEOUT_SECS`.
384fn read_idle_timeout_secs() -> u64 {
385    std::env::var("LEAN_CTX_PROXY_READ_TIMEOUT_SECS")
386        .ok()
387        .and_then(|v| v.trim().parse::<u64>().ok())
388        .filter(|s| *s > 0)
389        .unwrap_or(300)
390}
391
392/// How often (seconds) a running proxy re-reads config.toml for upstream
393/// changes. `LEAN_CTX_PROXY_RELOAD_SECS` overrides; default 5s.
394fn upstream_reload_secs() -> u64 {
395    std::env::var("LEAN_CTX_PROXY_RELOAD_SECS")
396        .ok()
397        .and_then(|v| v.trim().parse::<u64>().ok())
398        .filter(|s| *s > 0)
399        .unwrap_or(5)
400}
401
402/// Background task: re-resolves the provider upstreams from config.toml on an
403/// interval and publishes any change to the live request handlers (#449). Ends
404/// once every receiver (the proxy itself) has been dropped.
405///
406/// `Config::load()` already keeps an internal content-hash cache, so re-reading
407/// an unchanged `config.toml` skips the TOML parse + merge and costs only a small
408/// file read; combined with the relaxed default interval (#453) the idle steady
409/// state is negligible without needing a separate stat pre-check.
410fn spawn_upstream_refresh(tx: tokio::sync::watch::Sender<Arc<Upstreams>>, initial: Upstreams) {
411    let interval = std::time::Duration::from_secs(upstream_reload_secs());
412    tokio::spawn(async move {
413        let mut last = initial;
414        loop {
415            tokio::time::sleep(interval).await;
416            let next = crate::core::config::Config::load()
417                .proxy
418                .refresh_upstreams(&last);
419            if next != last {
420                log_upstream_change(&last, &next);
421                last = next.clone();
422                if tx.send(Arc::new(next)).is_err() {
423                    break;
424                }
425            }
426        }
427    });
428}
429
430/// One stdout line per changed provider, matching the startup banner style so a
431/// running proxy's log shows when (and to what) an upstream switched.
432fn log_upstream_change(old: &Upstreams, new: &Upstreams) {
433    if old.anthropic != new.anthropic {
434        println!("  ↻ Anthropic upstream → {}", new.anthropic);
435    }
436    if old.openai != new.openai {
437        println!("  ↻ OpenAI upstream → {}", new.openai);
438    }
439    if old.chatgpt != new.chatgpt {
440        println!("  ↻ ChatGPT upstream → {}", new.chatgpt);
441    }
442    if old.gemini != new.gemini {
443        println!("  ↻ Gemini upstream → {}", new.gemini);
444    }
445    if old.providers != new.providers {
446        let ids: Vec<&str> = new.providers.iter().map(|p| p.id.as_str()).collect();
447        println!("  ↻ provider registry → [{}]", ids.join(", "));
448    }
449}
450
451pub async fn start_proxy(port: u16) -> anyhow::Result<()> {
452    let token = crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN");
453    start_proxy_with_token(port, Some(token)).await
454}
455
456/// Security invariant: the proxy NEVER runs unauthenticated. `None` does not
457/// mean "no auth" — it means "resolve the session token for me". Provider
458/// routes additionally accept provider API keys (see `proxy_auth_guard`), so
459/// IDE clients keep working without any setup.
460fn effective_auth_token(auth_token: Option<String>) -> String {
461    auth_token
462        .filter(|t| !t.trim().is_empty())
463        .unwrap_or_else(|| crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN"))
464}
465
466/// Install the process-default rustls `CryptoProvider` for the Codex ChatGPT
467/// WebSocket passthrough (#597).
468///
469/// `tokio-tungstenite`'s rustls connector builds its `ClientConfig` from the
470/// process-default provider. Our tree pulls *both* aws-lc-rs (reqwest) and ring
471/// (lettre/ureq), so rustls cannot auto-pick one and the `wss://chatgpt.com`
472/// handshake aborts with *"Could not automatically determine the process-level
473/// CryptoProvider"*. reqwest is unaffected (it configures aws-lc-rs explicitly),
474/// so we match it here. Idempotent: a prior install just returns the provider
475/// back as `Err`, which we ignore.
476fn install_default_crypto_provider() {
477    let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
478}
479
480pub async fn start_proxy_with_token(port: u16, auth_token: Option<String>) -> anyhow::Result<()> {
481    use crate::core::config::{Config, is_local_proxy_url};
482
483    // Must run before any WebSocket passthrough opens a wss:// upstream (#597).
484    install_default_crypto_provider();
485
486    let auth_token = effective_auth_token(auth_token);
487
488    // A single total timeout aborts long streaming generations (e.g. Opus doing
489    // a big refactor) mid-response. Use a connect timeout plus a read (idle)
490    // timeout instead: a genuinely hung upstream still fails, but a slow-but-
491    // alive stream is never cut off. Both are configurable for edge networks.
492    let chatgpt_cookies = chatgpt_cookies::shared_chatgpt_cloudflare_cookie_store();
493    let client = chatgpt_cookies::with_chatgpt_cloudflare_cookie_store(
494        reqwest::Client::builder(),
495        chatgpt_cookies.clone(),
496    )
497    .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs()))
498    .read_timeout(std::time::Duration::from_secs(read_idle_timeout_secs()))
499    .build()?;
500
501    // Seed the measured-spend meter from disk so a proxy restart never zeroes
502    // the user's cumulative real provider bill.
503    usage_meter::resume_from_disk();
504    // Live model prices (#1179): load the cached provider price list and keep
505    // it fresh in the background, so new models are billed at their real
506    // market rates instead of family heuristics. Fail-open, kill switch:
507    // LEAN_CTX_LIVE_PRICING=off.
508    crate::core::gain::live_pricing::spawn_background_refresh();
509    // Seed the cold-prefix baselines too so a long idle gap that straddles a
510    // proxy restart is still detected and the repack can fire (#499).
511    cold_prefix::resume_from_disk();
512
513    let cfg = Config::load();
514    let break_even_schema_overhead = match cfg.tool_profile_effective() {
515        crate::core::tool_profiles::ToolProfile::Minimal
516        | crate::core::tool_profiles::ToolProfile::Auto => 1500,
517        crate::core::tool_profiles::ToolProfile::Standard => 3000,
518        crate::core::tool_profiles::ToolProfile::Power
519        | crate::core::tool_profiles::ToolProfile::Custom(_) => 5000,
520    };
521    // Read once at startup — avoids a Config::load() on every proxied request.
522    let bind_host = cfg.resolved_proxy_bind_host();
523    let loopback_bind = bind_host.is_loopback();
524    // Gateway mode (non-loopback bind, enterprise#8) hard-requires the Bearer
525    // token: the provider-key fallback's whole justification is "loopback only",
526    // so it is disabled by construction once the listener is reachable from the
527    // network — regardless of the config flag.
528    let require_token = cfg.proxy_require_token || !loopback_bind;
529    let loopback_open = cfg.proxy_loopback_open && loopback_bind;
530    let allowed_hosts: Arc<Vec<String>> = Arc::new(
531        cfg.proxy_allowed_hosts
532            .iter()
533            .map(|h| h.trim().trim_end_matches('.').to_ascii_lowercase())
534            .filter(|h| !h.is_empty())
535            .collect(),
536    );
537    // Rate limit (enterprise#37): explicit config wins; gateway mode ships a
538    // sane default floor; loopback stays unlimited unless configured. `0`
539    // disables the limiter explicitly.
540    let rate_limiter = match (cfg.proxy_max_rps, loopback_bind) {
541        (Some(rps), _) if rps > 0 => Some(Arc::new(RateLimiter::new(rps, rps.saturating_mul(2)))),
542        (None, false) => Some(Arc::new(RateLimiter::new(50, 100))),
543        _ => None,
544    };
545    let initial = cfg.proxy.resolve_all();
546
547    // The proxy reads its upstreams live from a watch channel: a background task
548    // re-resolves them from config.toml on an interval and publishes any change,
549    // so `lean-ctx config set proxy.*_upstream` (or any config.toml edit) takes
550    // effect on the running proxy within seconds, without a restart (#449).
551    let (upstream_tx, upstream_rx) = tokio::sync::watch::channel(Arc::new(initial.clone()));
552    // Clone before moving into ProxyState — auth guard needs live registry
553    // snapshots to decide client-auth provider fallback under require_token.
554    let auth_upstreams = upstream_rx.clone();
555    spawn_upstream_refresh(upstream_tx, initial.clone());
556
557    let Upstreams {
558        anthropic: anthropic_upstream,
559        openai: openai_upstream,
560        chatgpt: chatgpt_upstream,
561        gemini: gemini_upstream,
562        providers: initial_providers,
563    } = initial;
564
565    // Governed MCP reverse proxy registry (GL#91/#100): resolved once at
566    // startup (restart to reload — the gateway-keys.toml lifecycle). The
567    // `/mcp/{server}` routes are mounted below with the gateway-server
568    // feature; the snapshot lives in ProxyState either way.
569    let mcp_servers = Arc::new(
570        cfg.gateway_server
571            .resolve_mcp_servers(cfg.proxy.allows_insecure_http_upstream()),
572    );
573
574    let state = ProxyState {
575        client,
576        port,
577        stats: Arc::new(ProxyStats::default()),
578        break_even: Arc::new(break_even::BreakEvenCalculator::new(
579            break_even_schema_overhead,
580        )),
581        introspect: Arc::new(introspect::IntrospectState::default()),
582        ocla_cache: None,
583        upstreams: upstream_rx,
584        chatgpt_cookies,
585        mcp_servers: mcp_servers.clone(),
586        web_app_tracker: Arc::new(std::sync::Mutex::new(
587            web_app::conversation_tracker::ConversationTracker::default(),
588        )),
589    };
590
591    // `mut` is only exercised by the gateway-server merge below.
592    #[cfg_attr(not(feature = "gateway-server"), allow(unused_mut))]
593    let mut app = Router::new()
594        .route("/health", get(health))
595        .route("/status", get(status_handler))
596        .route("/v1/messages", any(anthropic::handler))
597        .route("/v1/messages/{*rest}", any(anthropic::handler))
598        .route("/v1/chat/completions", any(openai::handler))
599        // POST → HTTP/SSE forwarder; GET → Codex/OpenAI WebSocket bridge (#440).
600        .route(
601            "/v1/responses",
602            post(openai_responses::handler).get(openai_responses::ws_handler),
603        )
604        .route("/v1/responses/{*rest}", any(openai_responses::handler))
605        // Bare provider endpoints (no `/v1` prefix). Clients whose base URL points
606        // at the proxy root — notably OpenCode via `@ai-sdk/openai`, whose
607        // Responses-API requests hit `/responses` — dispatch here. The
608        // `normalize_provider_path` layer rewrites the URI to its canonical
609        // `/v1/...` form before the handler forwards upstream (#353).
610        .route("/messages", any(anthropic::handler))
611        .route("/messages/{*rest}", any(anthropic::handler))
612        .route("/chat/completions", any(openai::handler))
613        .route(
614            "/responses",
615            post(openai_responses::handler).get(openai_responses::ws_handler),
616        )
617        .route("/responses/{*rest}", any(openai_responses::handler))
618        .route(
619            "/backend-api/codex/responses",
620            post(chatgpt::codex_responses_handler).get(chatgpt::codex_responses_ws_handler),
621        )
622        .route(
623            "/backend-api/codex/responses/{*rest}",
624            any(chatgpt::codex_responses_handler),
625        )
626        // Non-model ChatGPT backend calls (including codex_apps MCP) are not
627        // prompt JSON. Keep them as credential-preserving passthrough traffic.
628        .route("/backend-api", any(chatgpt::backend_api_handler))
629        .route("/backend-api/{*rest}", any(chatgpt::backend_api_handler))
630        .route("/v1/references/{id}", get(v1_resolve_reference))
631        // LiteLLM headroom-guardrail CCR retrieval (#702): resolves the 24-hex
632        // `hash=` marker `/v1/compress` emits back to the verbatim original.
633        .route("/v1/retrieve/{hash}", get(v1_retrieve_ccr))
634        // Org model catalog (enterprise#63): IDE clients discover the curated
635        // alias namespace (`zuehlke/fast` → provider:model) and verify their
636        // key. Exact-match only — `/v1/models/{...}` subpaths stay Gemini
637        // passthrough in the fallback router.
638        .route("/v1/models", get(models_api::handler))
639        .route("/models", get(models_api::handler))
640        // Drop-in `compress(messages, model)` contract (#739): deterministic
641        // messages-in / messages-out compression for SDK clients.
642        .route("/v1/compress", post(compress_api::handler))
643        .route(
644            "/v1/quality-lab",
645            post(quality_lab_api::handler).get(quality_lab_api::handler_get),
646        )
647        // Universal provider registry (`[[proxy.providers]]`, enterprise#7):
648        // `/providers/{id}/...` forwards to the registry entry with that id,
649        // speaking its declared wire shape. New provider = config, not code.
650        .route("/providers/{id}/{*rest}", any(providers::handler))
651        .fallback(fallback_router);
652
653    // Personal usage view (enterprise#64): `/me` shell + guarded `/api/me/*`.
654    // Merged before the guard layers so host_guard and auth wrap it too; the
655    // shell paths themselves are exempted inside `proxy_auth_guard`.
656    #[cfg(feature = "gateway-server")]
657    {
658        app = app.merge(crate::gateway_server::user_api::router());
659    }
660
661    // Governed MCP reverse proxy (GL#91/#100): `/mcp/{server}` fronts the
662    // `[[gateway_server.mcp_servers]]` registry. Registered before the guard
663    // layers, so the same Bearer auth + host allowlist + rate limit wrap the
664    // tool channel — and `/mcp/*` is not a provider route, so the loopback
665    // provider-key fallback never authenticates it.
666    #[cfg(feature = "gateway-server")]
667    {
668        use crate::gateway_server::mcp::proxy::handler as mcp_handler;
669        app = app
670            .route("/mcp/{server}", any(mcp_handler))
671            .route("/mcp/{server}/", any(mcp_handler));
672    }
673
674    let mut app = app
675        .layer(axum::middleware::from_fn(
676            web_app_middleware::detect_web_app,
677        ))
678        .layer(axum::middleware::from_fn(move |req, next| {
679            let allowed = allowed_hosts.clone();
680            host_guard(req, next, allowed)
681        }))
682        .with_state(state);
683
684    // Per-person gateway keys (enterprise#11): sha256(bearer) → person/team/
685    // default_project. Loaded once at startup; rotation = restart (the standard
686    // secret-mount flow). A malformed file fails the start loudly.
687    let gateway_keys = match gateway_identity::GatewayKeys::load_default() {
688        Ok(keys) => {
689            if !keys.is_empty() {
690                println!(
691                    "  Identity:  {} gateway key(s) loaded ({})",
692                    keys.len(),
693                    gateway_identity::GatewayKeys::default_path().display()
694                );
695            }
696            Arc::new(keys)
697        }
698        Err(e) => anyhow::bail!("gateway-keys.toml: {e}"),
699    };
700
701    {
702        let expected = auth_token.clone();
703        let keys = gateway_keys.clone();
704        let upstreams = auth_upstreams;
705        app = app.layer(axum::middleware::from_fn(move |req, next| {
706            let expected = expected.clone();
707            let keys = keys.clone();
708            let upstreams = upstreams.clone();
709            proxy_auth_guard(
710                req,
711                next,
712                expected,
713                require_token,
714                loopback_open,
715                keys,
716                upstreams,
717            )
718        }));
719    }
720
721    if let Some(limiter) = rate_limiter {
722        app = app.layer(axum::middleware::from_fn(move |req, next| {
723            let limiter = limiter.clone();
724            rate_limit_guard(req, next, limiter)
725        }));
726    }
727
728    // Outermost layer (runs first): normalize bare provider endpoints to their
729    // canonical `/v1/...` form so auth, routing and upstream forwarding all agree,
730    // regardless of whether the client's base URL includes `/v1` (#353).
731    app = app.layer(axum::middleware::from_fn(normalize_provider_path));
732
733    let addr = SocketAddr::from((bind_host, port));
734    if loopback_open {
735        println!("lean-ctx proxy listening on http://{addr} (loopback-open: auth disabled)");
736    } else {
737        println!("lean-ctx proxy listening on http://{addr} (token auth enabled)");
738    }
739    if !loopback_bind {
740        println!(
741            "  ⚠ gateway mode: non-loopback bind — Bearer token REQUIRED for gateway-held \
742             keys and non-registry routes; client-auth /providers/* (api_key_env unset) \
743             still accept provider credentials. Host allowlist + rate limit active"
744        );
745    }
746    println!("  Anthropic: POST /v1/messages → {anthropic_upstream}");
747    println!("  OpenAI:    POST /v1/chat/completions → {openai_upstream}");
748    println!(
749        "  OpenAI:    POST /v1/responses → {openai_upstream}  (bare /responses also accepted)"
750    );
751    println!("  ChatGPT:   POST /backend-api/codex/responses → {chatgpt_upstream}");
752    println!("  ChatGPT:   any  /backend-api/* → {chatgpt_upstream}");
753    println!("  Gemini:    POST /v1beta/models/... → {gemini_upstream}");
754    println!("  Compress:  POST /v1/compress (deterministic messages-in/out, local)");
755    // Codex defaults to a WebSocket Responses transport (ws://…/responses). The
756    // proxy now bridges it to the HTTP/SSE upstream (#440), so Codex works as a
757    // drop-in without a `supports_websockets = false` workaround.
758    println!(
759        "  Codex:     WS  ws://{addr}/responses → bridged to {openai_upstream} (HTTP/SSE, #440)"
760    );
761    for p in &initial_providers {
762        println!(
763            "  Provider:  any  /providers/{}/... → {} ({} shape{})",
764            p.id,
765            p.base_url,
766            p.shape.as_str(),
767            if p.api_key_env.is_some() {
768                ", gateway-held key"
769            } else {
770                ""
771            }
772        );
773    }
774    for s in mcp_servers.iter() {
775        println!(
776            "  MCP:       any  /mcp/{} → {} (observed{})",
777            s.id,
778            s.url,
779            if s.auth_env.is_some() {
780                ", gateway-held credential"
781            } else {
782                ""
783            }
784        );
785    }
786    if openai_upstream.starts_with("http://") && !is_local_proxy_url(&openai_upstream) {
787        println!(
788            "  ⚠ OpenAI upstream is plaintext HTTP to a non-loopback host \
789             (allow_insecure_http_upstream) — use only on a trusted local network"
790        );
791    }
792
793    let listener = tokio::net::TcpListener::bind(addr).await?;
794    axum::serve(listener, app)
795        .with_graceful_shutdown(shutdown_signal())
796        .await?;
797
798    println!("lean-ctx proxy shut down cleanly.");
799    Ok(())
800}
801
802async fn shutdown_signal() {
803    let ctrl_c = tokio::signal::ctrl_c();
804
805    #[cfg(unix)]
806    {
807        // Fall back to Ctrl-C only if the SIGTERM handler cannot be installed,
808        // rather than panicking the proxy on startup.
809        match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
810            Ok(mut sigterm) => {
811                tokio::select! {
812                    _ = ctrl_c => {},
813                    _ = sigterm.recv() => {},
814                }
815            }
816            Err(e) => {
817                tracing::warn!("lean-ctx proxy: SIGTERM handler unavailable ({e}); Ctrl-C only");
818                ctrl_c.await.ok();
819            }
820        }
821    }
822
823    #[cfg(not(unix))]
824    {
825        ctrl_c.await.ok();
826    }
827
828    println!("lean-ctx proxy: received shutdown signal, draining…");
829}
830
831async fn health() -> impl IntoResponse {
832    let body = serde_json::json!({
833        "status": "ok",
834        "service": "lean-ctx-proxy",
835        "pid": std::process::id(),
836    });
837    (StatusCode::OK, axum::Json(body))
838}
839
840async fn v1_resolve_reference(
841    axum::extract::Path(id): axum::extract::Path<String>,
842) -> impl IntoResponse {
843    match crate::server::reference_store::resolve(&id) {
844        Some(content) => (StatusCode::OK, content),
845        None => (
846            StatusCode::NOT_FOUND,
847            "Reference expired or not found".to_string(),
848        ),
849    }
850}
851
852/// `GET /v1/retrieve/{hash}` (#702) — LiteLLM headroom-guardrail CCR contract
853/// (BerriAI/litellm#31681): resolve a 24-hex `hash=` marker emitted by
854/// `/v1/compress` back to the verbatim original from the tee store. The reply
855/// carries `original_content` (the field LiteLLM's `_call_retrieve` reads
856/// first). The optional `?query=` the guardrail forwards is accepted but the
857/// full original is always returned — a superset of any ranked slice, and the
858/// stored blobs are single tool outputs, not corpora worth ranking. Auth: the
859/// standard proxy bearer guard wraps this route (LiteLLM sends the configured
860/// `api_key` as a Bearer token); the tee store is loopback-scoped local state.
861async fn v1_retrieve_ccr(
862    axum::extract::Path(hash): axum::extract::Path<String>,
863) -> impl IntoResponse {
864    match ccr::retrieve_litellm(&hash) {
865        Some(content) => (
866            StatusCode::OK,
867            axum::Json(serde_json::json!({ "original_content": content })),
868        ),
869        None => (
870            StatusCode::NOT_FOUND,
871            axum::Json(serde_json::json!({
872                "error": "hash not found or expired",
873                "hash": hash,
874            })),
875        ),
876    }
877}
878
879async fn status_handler(State(state): State<ProxyState>) -> impl IntoResponse {
880    use std::sync::atomic::Ordering::Relaxed;
881    let s = &state.stats;
882    let i = &state.introspect;
883
884    let last_breakdown = i
885        .last_breakdown
886        .lock()
887        .ok()
888        .and_then(|guard| guard.as_ref().map(|b| serde_json::to_value(b).ok()))
889        .flatten();
890
891    let spend = usage_meter::snapshot();
892    let spend_total: f64 = spend.iter().map(|m| m.cost_usd).sum();
893
894    // Live upstreams the proxy is forwarding to right now (#449). This is the
895    // single source of truth for "where is my traffic actually going" — it
896    // reflects config.toml hot-reloads and any start-time env override.
897    let up = state.upstream_snapshot();
898
899    // Resolve the effort level fresh so /status reflects config.toml hot-reloads
900    // and env overrides, matching the upstream snapshot above (#834).
901    let active_effort = crate::core::config::Config::load().proxy.resolved_effort();
902
903    let introspect_total_input = i.total_input_tokens.load(Relaxed);
904    let introspect_bulk_candidates = i.total_bulk_candidate_tokens.load(Relaxed);
905    let introspect_bulk_share =
906        introspect::token_share_basis_points(introspect_bulk_candidates, introspect_total_input);
907    let break_even = state.break_even_summary();
908
909    let body = serde_json::json!({
910        "status": "running",
911        "proxy_mode": format!("{:?}", crate::core::config::Config::load().proxy.resolved_proxy_mode()),
912        "port": state.port,
913        "upstreams": {
914            "anthropic": up.anthropic.clone(),
915            "openai": up.openai.clone(),
916            "chatgpt": up.chatgpt.clone(),
917            "gemini": up.gemini.clone(),
918        },
919        // Universal registry (`[[proxy.providers]]`, enterprise#7). Key names
920        // only — never the key material.
921        "providers": up.providers.iter().map(|p| serde_json::json!({
922            "id": p.id,
923            "shape": p.shape.as_str(),
924            "base_url": p.base_url,
925            "gateway_key": p.api_key_env.is_some(),
926        })).collect::<Vec<_>>(),
927        "requests_total": s.requests_total.load(Relaxed),
928        "requests_compressed": s.requests_compressed.load(Relaxed),
929        "tokens_saved": s.tokens_saved.load(Relaxed),
930        "tokens_saved_estimated": true,
931        // Provider-verified savings (#701, opt-in counterfactual metering):
932        // both sides counted by Anthropic on the same request. `null` until
933        // the first probe-covered request lands.
934        "verified_savings": usage_meter::verified_savings(),
935        "bytes_original": s.bytes_original.load(Relaxed),
936        "bytes_compressed": s.bytes_compressed.load(Relaxed),
937        "compression_ratio_pct": format!("{:.1}", s.compression_ratio()),
938        "break_even": {
939            "proxy_savings": break_even.proxy_savings,
940            "estimated_mcp_overhead": break_even.estimated_mcp_overhead,
941            "net": break_even.net,
942            "mcp_recommended": break_even.mcp_recommended,
943            "reason": break_even.reason,
944        },
945        "per_upstream": s.provider_summary(),
946        "prefix_cache": prefix_cache_stats::snapshot(),
947        "cache_safety": cache_safety::snapshot(),
948        "cache_attribution": cache_attribution::snapshot(),
949        "effort": effort::snapshot(active_effort),
950        "per_model": cost::snapshot(),
951        "spend": {
952            "source": "measured",
953            "total_usd": spend_total,
954            "per_model": spend,
955            "note": "Actual provider bill: real model + billed tokens (incl. cache reads/writes & reasoning) read from upstream responses for proxy-routed clients."
956        },
957        "note": "Savings are request-side (tokens removed before forwarding); they do not subtract any re-reads the agent performs. Token figures are estimates; USD uses the shared model price table.",
958        "introspect": {
959            "total_requests_analyzed": i.total_requests.load(Relaxed),
960            "total_input_tokens": introspect_total_input,
961            "total_system_prompt_tokens": i.total_system_prompt_tokens.load(Relaxed),
962            "total_bulk_candidate_tokens": introspect_bulk_candidates,
963            "bulk_candidate_share_basis_points": introspect_bulk_share,
964            "vision_encoding_decision_gate_met": introspect_bulk_share.is_some_and(|share| share >= 2_000),
965            "last_breakdown": last_breakdown,
966        }
967    });
968    (StatusCode::OK, axum::Json(body))
969}
970
971#[allow(clippy::result_large_err)]
972async fn proxy_auth_guard(
973    mut req: axum::extract::Request,
974    next: axum::middleware::Next,
975    expected_token: String,
976    require_token: bool,
977    loopback_open: bool,
978    gateway_keys: Arc<gateway_identity::GatewayKeys>,
979    upstreams: tokio::sync::watch::Receiver<Arc<Upstreams>>,
980) -> Result<Response, Response> {
981    let path = req.uri().path();
982    if path == "/health" || me_shell_path(path) {
983        return Ok(next.run(req).await);
984    }
985
986    // #755: loopback-open mode skips all auth — every local process is trusted.
987    if loopback_open {
988        req.extensions_mut()
989            .insert(gateway_identity::TrustedGatewayRequest);
990        attach_gateway_tags(&mut req, gateway_identity::GatewayTags::default());
991        return Ok(next.run(req).await);
992    }
993
994    let bearer = req
995        .headers()
996        .get("authorization")
997        .and_then(|v| v.to_str().ok())
998        .and_then(|auth| auth.strip_prefix("Bearer "))
999        .map(str::to_string);
1000
1001    if let Some(token) = bearer.as_deref()
1002        && constant_time_eq(token.as_bytes(), expected_token.as_bytes())
1003    {
1004        req.extensions_mut()
1005            .insert(gateway_identity::TrustedGatewayRequest);
1006        attach_gateway_tags(&mut req, gateway_identity::GatewayTags::default());
1007        return Ok(next.run(req).await);
1008    }
1009
1010    // Per-person gateway keys (enterprise#11): a bearer key whose SHA-256 is in
1011    // gateway-keys.toml authenticates AND identifies — its person/team/project
1012    // tags travel with the request and end up on the usage record.
1013    if let Some(token) = bearer.as_deref()
1014        && let Some(tags) = gateway_keys.lookup(token)
1015    {
1016        req.extensions_mut()
1017            .insert(gateway_identity::TrustedGatewayRequest);
1018        attach_gateway_tags(&mut req, tags);
1019        return Ok(next.run(req).await);
1020    }
1021
1022    // Accept provider API keys on provider routes (host_guard runs first).
1023    // Client-auth registry rails (`/providers/{id}` with api_key_env=None,
1024    // e.g. Grok dual-rail) stay open even when require_token is set — the
1025    // Authorization / x-xai-token-auth header must carry the upstream session
1026    // and cannot also hold the lean-ctx Bearer.
1027    let client_auth_registry = is_client_auth_registry_route(path, &upstreams.borrow());
1028    if provider_key_fallback_allowed(
1029        require_token,
1030        has_provider_api_key(&req),
1031        is_provider_route(path),
1032        client_auth_registry,
1033    ) {
1034        attach_gateway_tags(&mut req, gateway_identity::GatewayTags::default());
1035        return Ok(next.run(req).await);
1036    }
1037
1038    Err(auth_error_response(path))
1039}
1040
1041fn auth_error_response(path: &str) -> Response {
1042    let is_mcp = path.starts_with("/mcp");
1043    let hint = if is_mcp {
1044        "MCP Streamable HTTP requires a Bearer token. Get it with: lean-ctx proxy token"
1045    } else if is_provider_route(path) {
1046        "Provider route requires authentication. Set your API key header or use: lean-ctx proxy token"
1047    } else {
1048        "This endpoint requires a lean-ctx Bearer token. Get it with: lean-ctx proxy token  \
1049         — or set proxy_loopback_open = true to disable auth on localhost"
1050    };
1051
1052    let body = serde_json::json!({
1053        "type": "error",
1054        "error": {
1055            "type": "authentication_error",
1056            "message": format!("401 Unauthorized — {hint}")
1057        }
1058    });
1059
1060    (StatusCode::UNAUTHORIZED, axum::Json(body)).into_response()
1061}
1062
1063/// Resolves the final identity tags for an authenticated request and inserts
1064/// them as a request extension (read by `forward.rs::wire_context`).
1065///
1066/// Project resolution (enterprise#11): the `x-leanctx-project` header wins over
1067/// the key's `default_project` — one person books work onto different projects
1068/// per request. The header also works without a gateway key (solo/local mode:
1069/// project tagging without identity). It is an internal gateway header, not on
1070/// `ALLOWED_REQUEST_HEADERS`, so it never reaches the upstream.
1071fn attach_gateway_tags(req: &mut axum::extract::Request, mut tags: gateway_identity::GatewayTags) {
1072    if let Some(project) = req
1073        .headers()
1074        .get("x-leanctx-project")
1075        .and_then(|v| v.to_str().ok())
1076        .map(str::trim)
1077        .filter(|p| !p.is_empty() && p.len() <= 128 && !p.chars().any(char::is_control))
1078    {
1079        tags.project = Some(project.to_string());
1080    }
1081    // GDPR pseudonymization (enterprise#39): applied at this single
1082    // choke-point, so budgets, usage rows, dashboards and logs only ever see
1083    // the pseudonym. No-op unless [gateway_server].pseudonymize_persons.
1084    if let Some(person) = tags.person.as_deref()
1085        && pii::enabled()
1086    {
1087        tags.person = Some(pii::pseudonymize(person));
1088    }
1089    if !tags.is_empty() {
1090        req.extensions_mut().insert(tags);
1091    }
1092}
1093
1094/// The personal view's static shell (`/me` + assets) renders without a key —
1095/// like the admin console's login screen, every number behind it comes from
1096/// the guarded `/api/me/usage`. Compiled out with the `gateway-server` feature.
1097fn me_shell_path(path: &str) -> bool {
1098    #[cfg(feature = "gateway-server")]
1099    {
1100        crate::gateway_server::user_api::is_shell_path(path)
1101    }
1102    #[cfg(not(feature = "gateway-server"))]
1103    {
1104        let _ = path;
1105        false
1106    }
1107}
1108
1109fn has_provider_api_key(req: &axum::extract::Request) -> bool {
1110    let headers = req.headers();
1111    // Provider-specific key headers: Anthropic `x-api-key`, Google
1112    // `x-goog-api-key`, Azure `api-key`, Grok subscription `x-xai-token-auth`.
1113    // Any non-empty value authenticates. Grok dual-rail may send only the
1114    // session header (no Authorization) against `/providers/grok-chat`.
1115    for key in ["x-api-key", "x-goog-api-key", "api-key", "x-xai-token-auth"] {
1116        if headers
1117            .get(key)
1118            .and_then(|v| v.to_str().ok())
1119            .is_some_and(|v| !v.trim().is_empty())
1120        {
1121            return true;
1122        }
1123    }
1124    // OpenAI-style `Authorization` auth. Accept ANY non-empty credential, not
1125    // just `Bearer sk-`/`gsk_`: OpenAI-*compatible* providers driven through
1126    // OpenCode/Codex (Azure, OpenRouter, Groq, vLLM/Ollama gateways, project &
1127    // service-account keys) issue keys that don't carry those prefixes. The proxy
1128    // binds to loopback only and never injects upstream credentials — it forwards
1129    // this header verbatim, so an invalid key is rejected by the real upstream,
1130    // never silently honoured. Gating provider routes on key *shape* only ever
1131    // produced false 401s for those clients (#362).
1132    if let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
1133        let auth = auth.trim();
1134        let credential = auth
1135            .strip_prefix("Bearer ")
1136            .or_else(|| auth.strip_prefix("bearer "))
1137            .unwrap_or(auth)
1138            .trim();
1139        // Reject an empty value or a bare scheme keyword carrying no token.
1140        return !credential.is_empty() && !credential.eq_ignore_ascii_case("bearer");
1141    }
1142    false
1143}
1144
1145/// `/providers/{id}/...` where the registered provider has no gateway-held
1146/// key (`api_key_env = None`). These dual-rail / client-auth rails put the
1147/// upstream session in Authorization or provider-specific headers.
1148fn is_client_auth_registry_route(path: &str, upstreams: &Upstreams) -> bool {
1149    let Some(rest) = path.strip_prefix("/providers/") else {
1150        return false;
1151    };
1152    let id = rest.split('/').next().unwrap_or("");
1153    if id.is_empty() {
1154        return false;
1155    }
1156    upstreams
1157        .providers
1158        .iter()
1159        .any(|p| p.id == id && !p.injects_gateway_credential())
1160}
1161
1162fn is_provider_route(path: &str) -> bool {
1163    path.starts_with("/v1/")
1164        || path.starts_with("/v1beta/")
1165        || path.starts_with("/chat/completions")
1166        || path.starts_with("/responses")
1167        || path.starts_with("/messages")
1168        || path.starts_with("/backend-api")
1169        // Bare model-catalog discovery (enterprise#63): clients whose base URL
1170        // omits `/v1` send `GET /models` with their provider key.
1171        || path == "/models"
1172        // Universal provider registry (enterprise#7): clients point their base
1173        // URL at `/providers/{id}/v1` (Grok subscription:
1174        // `GROK_CLI_CHAT_PROXY_BASE_URL`, Azure Foundry, OpenRouter, …) and
1175        // authenticate with their own provider/session Bearer. Without this,
1176        // model-list + chat land as lean-ctx 401s — Grok cannot select a mode.
1177        || path.starts_with("/providers/")
1178}
1179
1180/// Decides whether a request authenticates via a provider API key alone, without
1181/// the lean-ctx Bearer token.
1182///
1183/// * Built-in provider routes (`/v1/*`, bare endpoints, …): only when
1184///   `require_token` is false (loopback-friendly default). Gateway mode
1185///   (non-loopback bind) forces `require_token` so these require the lean-ctx
1186///   Bearer (enterprise#8).
1187/// * Client-auth registry routes (`/providers/{id}` with `api_key_env = None`,
1188///   e.g. Grok dual-rail): always allowed when a provider credential is present.
1189///   The Authorization / `x-xai-token-auth` header must carry the upstream
1190///   session and cannot also hold the lean-ctx Bearer. Gateway-held keys
1191///   (`api_key_env` set) never take this path — those still need the lean-ctx
1192///   token so inject cannot be abused with a forged provider header.
1193///
1194/// Pure (except the precomputed `client_auth_registry` flag), so the policy is
1195/// unit-testable without axum middleware plumbing.
1196#[allow(clippy::fn_params_excessive_bools)] // deliberate pure predicate for unit tests
1197fn provider_key_fallback_allowed(
1198    require_token: bool,
1199    has_provider_key: bool,
1200    is_provider_route: bool,
1201    client_auth_registry: bool,
1202) -> bool {
1203    if !has_provider_key || !is_provider_route {
1204        return false;
1205    }
1206    if client_auth_registry {
1207        return true;
1208    }
1209    !require_token
1210}
1211
1212/// Maps a bare provider endpoint to its canonical `/v1/...` form, preserving any
1213/// sub-path. Returns `None` when the path is already canonical or not a known
1214/// provider endpoint.
1215///
1216/// Some OpenAI-compatible clients treat the configured base URL as the API root
1217/// and append the bare endpoint, so they send `POST /responses` or
1218/// `/chat/completions` instead of `/v1/responses` — notably OpenCode via
1219/// `@ai-sdk/openai`, whose Responses-API requests land on `/responses`. The proxy
1220/// and every upstream only know the `/v1/...` paths, so an un-prefixed request
1221/// would 401 (not a provider route) and then 404 (no handler). (#353)
1222fn canonical_provider_path(path: &str) -> Option<String> {
1223    // Inverse case of the bare-endpoint rewrite below: the advertised
1224    // OPENAI_BASE_URL includes `/v1` (#366), so a client that treats the base URL
1225    // as an origin and appends `/v1/...` itself produces `/v1/v1/...`.
1226    if let Some(rest) = path.strip_prefix("/v1/v1/") {
1227        return Some(format!("/v1/{rest}"));
1228    }
1229    const BARE_TO_CANONICAL: &[(&str, &str, &str)] = &[
1230        ("/responses", "/v1/responses", "/responses/"),
1231        (
1232            "/chat/completions",
1233            "/v1/chat/completions",
1234            "/chat/completions/",
1235        ),
1236        ("/messages", "/v1/messages", "/messages/"),
1237    ];
1238    for (bare, canonical, bare_with_slash) in BARE_TO_CANONICAL {
1239        if path == *bare {
1240            return Some((*canonical).to_string());
1241        }
1242        if let Some(rest) = path.strip_prefix(bare_with_slash) {
1243            return Some(format!("{canonical}/{rest}"));
1244        }
1245    }
1246    None
1247}
1248
1249/// Returns the canonicalized URI for a bare provider endpoint (query preserved),
1250/// or `None` when no rewrite is needed. Pure, so the rewrite is unit-testable
1251/// without constructing axum middleware plumbing.
1252fn normalized_provider_uri(uri: &axum::http::Uri) -> Option<axum::http::Uri> {
1253    let canonical = canonical_provider_path(uri.path())?;
1254    let new_path_and_query = match uri.query() {
1255        Some(q) => format!("{canonical}?{q}"),
1256        None => canonical,
1257    };
1258    new_path_and_query.parse::<axum::http::Uri>().ok()
1259}
1260
1261/// Rewrites the request URI in place when it targets a bare provider endpoint, so
1262/// downstream auth (`is_provider_route`), routing and upstream forwarding all see
1263/// the canonical `/v1/...` path. (#353)
1264async fn normalize_provider_path(
1265    mut req: axum::extract::Request,
1266    next: axum::middleware::Next,
1267) -> Response {
1268    if let Some(uri) = normalized_provider_uri(req.uri()) {
1269        *req.uri_mut() = uri;
1270    }
1271    next.run(req).await
1272}
1273
1274fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
1275    use subtle::ConstantTimeEq;
1276    if a.len() != b.len() {
1277        return false;
1278    }
1279    bool::from(a.ct_eq(b))
1280}
1281
1282async fn host_guard(
1283    req: axum::extract::Request,
1284    next: axum::middleware::Next,
1285    allowed_hosts: Arc<Vec<String>>,
1286) -> Result<Response, StatusCode> {
1287    if let Some(host) = req.headers().get("host").and_then(|v| v.to_str().ok())
1288        && host_allowed(host, &allowed_hosts)
1289    {
1290        return Ok(next.run(req).await);
1291    }
1292    Err(StatusCode::FORBIDDEN)
1293}
1294
1295/// DNS-rebinding guard: loopback Host headers always pass (today's local
1296/// behavior); in gateway mode the operator additionally allowlists the names
1297/// the gateway is reachable under (`proxy_allowed_hosts`, enterprise#8).
1298/// Matching is case-insensitive on the host with the port stripped.
1299fn host_allowed(host_header: &str, allowed: &[String]) -> bool {
1300    // `[::1]:8080` carries the port after the bracket; plain hosts after `:`.
1301    let host = host_header.trim();
1302    let h = if let Some(bracketed) = host.strip_prefix('[') {
1303        bracketed
1304            .split(']')
1305            .next()
1306            .map(|inner| format!("[{inner}]"))
1307    } else {
1308        host.split(':').next().map(str::to_string)
1309    };
1310    let Some(h) = h else {
1311        return false;
1312    };
1313    let h = h.trim_end_matches('.').to_ascii_lowercase();
1314    matches!(h.as_str(), "127.0.0.1" | "localhost" | "[::1]") || allowed.contains(&h)
1315}
1316
1317/// Proxy-wide token-bucket rate limit (enterprise#37). `/health` is exempt so
1318/// orchestrator liveness probes never get throttled into a false restart.
1319async fn rate_limit_guard(
1320    req: axum::extract::Request,
1321    next: axum::middleware::Next,
1322    limiter: Arc<RateLimiter>,
1323) -> Result<Response, StatusCode> {
1324    if req.uri().path() != "/health" && !limiter.allow().await {
1325        return Err(StatusCode::TOO_MANY_REQUESTS);
1326    }
1327    Ok(next.run(req).await)
1328}
1329
1330/// Token bucket: `max_rps` sustained, `burst` peak. Mirrors the team server's
1331/// limiter; lives here so the proxy stays independent of `http_server`
1332/// internals.
1333pub(crate) struct RateLimiter {
1334    max_rps: f64,
1335    burst: f64,
1336    state: tokio::sync::Mutex<RateLimiterState>,
1337}
1338
1339struct RateLimiterState {
1340    tokens: f64,
1341    last: std::time::Instant,
1342}
1343
1344impl RateLimiter {
1345    pub(crate) fn new(max_rps: u32, burst: u32) -> Self {
1346        Self {
1347            max_rps: f64::from(max_rps.max(1)),
1348            burst: f64::from(burst.max(1)),
1349            state: tokio::sync::Mutex::new(RateLimiterState {
1350                tokens: f64::from(burst.max(1)),
1351                last: std::time::Instant::now(),
1352            }),
1353        }
1354    }
1355
1356    pub(crate) async fn allow(&self) -> bool {
1357        let mut s = self.state.lock().await;
1358        let now = std::time::Instant::now();
1359        let refill = now.saturating_duration_since(s.last).as_secs_f64() * self.max_rps;
1360        s.tokens = (s.tokens + refill).min(self.burst);
1361        s.last = now;
1362        if s.tokens >= 1.0 {
1363            s.tokens -= 1.0;
1364            true
1365        } else {
1366            false
1367        }
1368    }
1369}
1370
1371async fn fallback_router(State(state): State<ProxyState>, req: Request<Body>) -> Response {
1372    let path = req.uri().path().to_string();
1373
1374    if path.starts_with("/v1beta/models/") || path.starts_with("/v1/models/") {
1375        match google::handler(State(state), req).await {
1376            Ok(resp) => resp,
1377            Err(status) => Response::builder()
1378                .status(status)
1379                .body(Body::from("proxy error"))
1380                .expect("BUG: building error response with valid status should never fail"),
1381        }
1382    } else {
1383        let method = req.method().to_string();
1384        eprintln!("lean-ctx proxy: unmatched {method} {path}");
1385        Response::builder()
1386            .status(StatusCode::NOT_FOUND)
1387            .body(Body::from(format!(
1388                "lean-ctx proxy: no handler for {method} {path}"
1389            )))
1390            .expect("BUG: building 404 response should never fail")
1391    }
1392}