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