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