1pub 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 pub upstreams: tokio::sync::watch::Receiver<Arc<Upstreams>>,
97 pub(crate) chatgpt_cookies: Arc<chatgpt_cookies::ChatGptCloudflareCookieStore>,
101 pub mcp_servers: Arc<Vec<crate::core::config::ResolvedMcpServer>>,
106}
107
108impl ProxyState {
109 #[cfg(all(test, feature = "gateway-server"))]
115 pub(crate) fn for_tests(mcp_servers: Vec<crate::core::config::ResolvedMcpServer>) -> Self {
116 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 pub fn upstream_snapshot(&self) -> Arc<Upstreams> {
137 self.upstreams.borrow().clone()
138 }
139
140 pub fn anthropic_upstream(&self) -> String {
142 self.upstreams.borrow().anthropic.clone()
143 }
144
145 pub fn openai_upstream(&self) -> String {
147 self.upstreams.borrow().openai.clone()
148 }
149
150 pub fn chatgpt_upstream(&self) -> String {
152 self.upstreams.borrow().chatgpt.clone()
153 }
154
155 pub fn gemini_upstream(&self) -> String {
157 self.upstreams.borrow().gemini.clone()
158 }
159
160 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 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
319fn 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
328fn 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
340fn 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
350fn 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
378fn 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
404fn 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
414fn 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 install_default_crypto_provider();
433
434 let auth_token = effective_auth_token(auth_token);
435
436 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 usage_meter::resume_from_disk();
452 crate::core::gain::live_pricing::spawn_background_refresh();
457 cold_prefix::resume_from_disk();
460
461 let cfg = Config::load();
462 let bind_host = cfg.resolved_proxy_bind_host();
464 let loopback_bind = bind_host.is_loopback();
465 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 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 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 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 #[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 .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 .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 .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 .route("/v1/retrieve/{hash}", get(v1_retrieve_ccr))
565 .route("/v1/models", get(models_api::handler))
570 .route("/models", get(models_api::handler))
571 .route("/v1/compress", post(compress_api::handler))
574 .route("/providers/{id}/{*rest}", any(providers::handler))
578 .fallback(fallback_router);
579
580 #[cfg(feature = "gateway-server")]
584 {
585 app = app.merge(crate::gateway_server::user_api::router());
586 }
587
588 #[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 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 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 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 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
764async 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 let up = state.upstream_snapshot();
810
811 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 "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 "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 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 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 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
954fn 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 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
985fn 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 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 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 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 || path == "/models"
1044}
1045
1046fn 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
1062fn canonical_provider_path(path: &str) -> Option<String> {
1073 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
1099fn 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
1111async 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
1145fn host_allowed(host_header: &str, allowed: &[String]) -> bool {
1150 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
1167async 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
1180pub(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}