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;
59#[cfg(test)]
60mod stats_tests;
61pub mod sticky_tools;
62pub mod tool_kind;
63pub mod tool_output;
64#[cfg(test)]
65mod upstream_tests;
66pub mod usage;
67pub mod usage_accounting;
68pub mod usage_meter;
69pub mod usage_sink;
70pub mod verbosity;
71
72use std::net::SocketAddr;
73use std::sync::Arc;
74use std::sync::atomic::{AtomicU64, Ordering};
75
76use crate::core::config::Upstreams;
77
78use axum::{
79 Router,
80 body::Body,
81 extract::State,
82 http::{Request, StatusCode},
83 response::{IntoResponse, Response},
84 routing::{any, get, post},
85};
86
87#[derive(Clone)]
88pub struct ProxyState {
89 pub client: reqwest::Client,
90 pub port: u16,
91 pub stats: Arc<ProxyStats>,
92 pub introspect: Arc<introspect::IntrospectState>,
93 pub upstreams: tokio::sync::watch::Receiver<Arc<Upstreams>>,
96 pub(crate) chatgpt_cookies: Arc<chatgpt_cookies::ChatGptCloudflareCookieStore>,
100 pub mcp_servers: Arc<Vec<crate::core::config::ResolvedMcpServer>>,
105}
106
107impl ProxyState {
108 #[cfg(all(test, feature = "gateway-server"))]
114 pub(crate) fn for_tests(mcp_servers: Vec<crate::core::config::ResolvedMcpServer>) -> Self {
115 let (_tx, rx) = tokio::sync::watch::channel(Arc::new(Upstreams {
117 anthropic: "https://api.anthropic.com".into(),
118 openai: "https://api.openai.com".into(),
119 chatgpt: "https://chatgpt.com".into(),
120 gemini: "https://generativelanguage.googleapis.com".into(),
121 providers: Vec::new(),
122 }));
123 Self {
124 client: reqwest::Client::new(),
125 port: 0,
126 stats: Arc::new(ProxyStats::default()),
127 introspect: Arc::new(introspect::IntrospectState::default()),
128 upstreams: rx,
129 chatgpt_cookies: chatgpt_cookies::shared_chatgpt_cloudflare_cookie_store(),
130 mcp_servers: Arc::new(mcp_servers),
131 }
132 }
133
134 pub fn upstream_snapshot(&self) -> Arc<Upstreams> {
136 self.upstreams.borrow().clone()
137 }
138
139 pub fn anthropic_upstream(&self) -> String {
141 self.upstreams.borrow().anthropic.clone()
142 }
143
144 pub fn openai_upstream(&self) -> String {
146 self.upstreams.borrow().openai.clone()
147 }
148
149 pub fn chatgpt_upstream(&self) -> String {
151 self.upstreams.borrow().chatgpt.clone()
152 }
153
154 pub fn gemini_upstream(&self) -> String {
156 self.upstreams.borrow().gemini.clone()
157 }
158
159 pub fn chatgpt_cookie_header(&self) -> Option<String> {
163 let url = reqwest::Url::parse(&self.chatgpt_upstream()).ok()?;
164 self.chatgpt_cookies
165 .cookie_header(&url)
166 .and_then(|v| v.to_str().ok().map(str::to_owned))
167 }
168}
169
170pub struct ProxyStats {
171 pub requests_total: AtomicU64,
172 pub requests_compressed: AtomicU64,
173 pub tokens_saved: AtomicU64,
174 pub bytes_original: AtomicU64,
175 pub bytes_compressed: AtomicU64,
176 pub anthropic: ProviderStats,
177 pub openai: ProviderStats,
178 pub chatgpt: ProviderStats,
179 pub gemini: ProviderStats,
180}
181
182#[derive(Default)]
183pub struct ProviderStats {
184 pub requests_total: AtomicU64,
185 pub requests_compressed: AtomicU64,
186 pub tokens_saved: AtomicU64,
187 pub bytes_original: AtomicU64,
188 pub bytes_compressed: AtomicU64,
189}
190
191impl Default for ProxyStats {
192 fn default() -> Self {
193 Self {
194 requests_total: AtomicU64::new(0),
195 requests_compressed: AtomicU64::new(0),
196 tokens_saved: AtomicU64::new(0),
197 bytes_original: AtomicU64::new(0),
198 bytes_compressed: AtomicU64::new(0),
199 anthropic: ProviderStats::default(),
200 openai: ProviderStats::default(),
201 chatgpt: ProviderStats::default(),
202 gemini: ProviderStats::default(),
203 }
204 }
205}
206
207impl ProxyStats {
208 pub fn record_request(&self, original: usize, compressed: usize) {
209 self.record_totals(original, compressed);
210 }
211
212 pub fn record_provider_request(
213 &self,
214 provider_label: &str,
215 original: usize,
216 compressed: usize,
217 ) {
218 let (effective_compressed, saved_tokens, compressed_request) =
219 self.record_totals(original, compressed);
220
221 if let Some(provider) = self.provider(provider_label) {
222 provider.record(
223 original,
224 effective_compressed,
225 compressed_request,
226 saved_tokens,
227 );
228 }
229 }
230
231 fn record_totals(&self, original: usize, compressed: usize) -> (usize, u64, bool) {
232 self.requests_total.fetch_add(1, Ordering::Relaxed);
233 self.bytes_original
234 .fetch_add(original as u64, Ordering::Relaxed);
235 let effective_compressed = compressed.min(original);
236 self.bytes_compressed
237 .fetch_add(effective_compressed as u64, Ordering::Relaxed);
238 if compressed < original {
239 self.requests_compressed.fetch_add(1, Ordering::Relaxed);
240 }
241 let saved_tokens = (original.saturating_sub(effective_compressed) / 4) as u64;
242 self.tokens_saved.fetch_add(saved_tokens, Ordering::Relaxed);
243 (effective_compressed, saved_tokens, compressed < original)
244 }
245
246 pub fn compression_ratio(&self) -> f64 {
247 let original = self.bytes_original.load(Ordering::Relaxed);
248 if original == 0 {
249 return 0.0;
250 }
251 let compressed = self.bytes_compressed.load(Ordering::Relaxed);
252 (1.0 - compressed as f64 / original as f64) * 100.0
253 }
254
255 fn provider(&self, provider_label: &str) -> Option<&ProviderStats> {
259 match provider_label {
260 "Anthropic" => Some(&self.anthropic),
261 "OpenAI" => Some(&self.openai),
262 "ChatGPT" => Some(&self.chatgpt),
263 "Gemini" => Some(&self.gemini),
264 _ => None,
265 }
266 }
267
268 pub fn provider_summary(&self) -> serde_json::Value {
269 serde_json::json!({
270 "anthropic": self.anthropic.summary(),
271 "openai": self.openai.summary(),
272 "chatgpt": self.chatgpt.summary(),
273 "gemini": self.gemini.summary(),
274 })
275 }
276}
277
278impl ProviderStats {
279 fn record(
280 &self,
281 original: usize,
282 effective_compressed: usize,
283 compressed_request: bool,
284 saved_tokens: u64,
285 ) {
286 self.requests_total.fetch_add(1, Ordering::Relaxed);
287 if compressed_request {
288 self.requests_compressed.fetch_add(1, Ordering::Relaxed);
289 }
290 self.tokens_saved.fetch_add(saved_tokens, Ordering::Relaxed);
291 self.bytes_original
292 .fetch_add(original as u64, Ordering::Relaxed);
293 self.bytes_compressed
294 .fetch_add(effective_compressed as u64, Ordering::Relaxed);
295 }
296
297 fn compression_ratio(&self) -> f64 {
298 let original = self.bytes_original.load(Ordering::Relaxed);
299 if original == 0 {
300 return 0.0;
301 }
302 let compressed = self.bytes_compressed.load(Ordering::Relaxed);
303 (1.0 - compressed as f64 / original as f64) * 100.0
304 }
305
306 fn summary(&self) -> serde_json::Value {
307 serde_json::json!({
308 "requests_total": self.requests_total.load(Ordering::Relaxed),
309 "requests_compressed": self.requests_compressed.load(Ordering::Relaxed),
310 "tokens_saved": self.tokens_saved.load(Ordering::Relaxed),
311 "bytes_original": self.bytes_original.load(Ordering::Relaxed),
312 "bytes_compressed": self.bytes_compressed.load(Ordering::Relaxed),
313 "compression_ratio_pct": format!("{:.1}", self.compression_ratio()),
314 })
315 }
316}
317
318fn connect_timeout_secs() -> u64 {
320 std::env::var("LEAN_CTX_PROXY_CONNECT_TIMEOUT_SECS")
321 .ok()
322 .and_then(|v| v.trim().parse::<u64>().ok())
323 .filter(|s| *s > 0)
324 .unwrap_or(15)
325}
326
327fn read_idle_timeout_secs() -> u64 {
332 std::env::var("LEAN_CTX_PROXY_READ_TIMEOUT_SECS")
333 .ok()
334 .and_then(|v| v.trim().parse::<u64>().ok())
335 .filter(|s| *s > 0)
336 .unwrap_or(300)
337}
338
339fn upstream_reload_secs() -> u64 {
342 std::env::var("LEAN_CTX_PROXY_RELOAD_SECS")
343 .ok()
344 .and_then(|v| v.trim().parse::<u64>().ok())
345 .filter(|s| *s > 0)
346 .unwrap_or(5)
347}
348
349fn spawn_upstream_refresh(tx: tokio::sync::watch::Sender<Arc<Upstreams>>, initial: Upstreams) {
358 let interval = std::time::Duration::from_secs(upstream_reload_secs());
359 tokio::spawn(async move {
360 let mut last = initial;
361 loop {
362 tokio::time::sleep(interval).await;
363 let next = crate::core::config::Config::load()
364 .proxy
365 .refresh_upstreams(&last);
366 if next != last {
367 log_upstream_change(&last, &next);
368 last = next.clone();
369 if tx.send(Arc::new(next)).is_err() {
370 break;
371 }
372 }
373 }
374 });
375}
376
377fn log_upstream_change(old: &Upstreams, new: &Upstreams) {
380 if old.anthropic != new.anthropic {
381 println!(" ↻ Anthropic upstream → {}", new.anthropic);
382 }
383 if old.openai != new.openai {
384 println!(" ↻ OpenAI upstream → {}", new.openai);
385 }
386 if old.chatgpt != new.chatgpt {
387 println!(" ↻ ChatGPT upstream → {}", new.chatgpt);
388 }
389 if old.gemini != new.gemini {
390 println!(" ↻ Gemini upstream → {}", new.gemini);
391 }
392 if old.providers != new.providers {
393 let ids: Vec<&str> = new.providers.iter().map(|p| p.id.as_str()).collect();
394 println!(" ↻ provider registry → [{}]", ids.join(", "));
395 }
396}
397
398pub async fn start_proxy(port: u16) -> anyhow::Result<()> {
399 let token = crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN");
400 start_proxy_with_token(port, Some(token)).await
401}
402
403fn effective_auth_token(auth_token: Option<String>) -> String {
408 auth_token
409 .filter(|t| !t.trim().is_empty())
410 .unwrap_or_else(|| crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN"))
411}
412
413fn install_default_crypto_provider() {
424 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
425}
426
427pub async fn start_proxy_with_token(port: u16, auth_token: Option<String>) -> anyhow::Result<()> {
428 use crate::core::config::{Config, is_local_proxy_url};
429
430 install_default_crypto_provider();
432
433 let auth_token = effective_auth_token(auth_token);
434
435 let chatgpt_cookies = chatgpt_cookies::shared_chatgpt_cloudflare_cookie_store();
440 let client = chatgpt_cookies::with_chatgpt_cloudflare_cookie_store(
441 reqwest::Client::builder(),
442 chatgpt_cookies.clone(),
443 )
444 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs()))
445 .read_timeout(std::time::Duration::from_secs(read_idle_timeout_secs()))
446 .build()?;
447
448 usage_meter::resume_from_disk();
451 crate::core::gain::live_pricing::spawn_background_refresh();
456 cold_prefix::resume_from_disk();
459
460 let cfg = Config::load();
461 let bind_host = cfg.resolved_proxy_bind_host();
463 let loopback_bind = bind_host.is_loopback();
464 let require_token = cfg.proxy_require_token || !loopback_bind;
469 let loopback_open = cfg.proxy_loopback_open && loopback_bind;
470 let allowed_hosts: Arc<Vec<String>> = Arc::new(
471 cfg.proxy_allowed_hosts
472 .iter()
473 .map(|h| h.trim().trim_end_matches('.').to_ascii_lowercase())
474 .filter(|h| !h.is_empty())
475 .collect(),
476 );
477 let rate_limiter = match (cfg.proxy_max_rps, loopback_bind) {
481 (Some(rps), _) if rps > 0 => Some(Arc::new(RateLimiter::new(rps, rps.saturating_mul(2)))),
482 (None, false) => Some(Arc::new(RateLimiter::new(50, 100))),
483 _ => None,
484 };
485 let initial = cfg.proxy.resolve_all();
486
487 let (upstream_tx, upstream_rx) = tokio::sync::watch::channel(Arc::new(initial.clone()));
492 spawn_upstream_refresh(upstream_tx, initial.clone());
493
494 let Upstreams {
495 anthropic: anthropic_upstream,
496 openai: openai_upstream,
497 chatgpt: chatgpt_upstream,
498 gemini: gemini_upstream,
499 providers: initial_providers,
500 } = initial;
501
502 let mcp_servers = Arc::new(
507 cfg.gateway_server
508 .resolve_mcp_servers(cfg.proxy.allows_insecure_http_upstream()),
509 );
510
511 let state = ProxyState {
512 client,
513 port,
514 stats: Arc::new(ProxyStats::default()),
515 introspect: Arc::new(introspect::IntrospectState::default()),
516 upstreams: upstream_rx,
517 chatgpt_cookies,
518 mcp_servers: mcp_servers.clone(),
519 };
520
521 #[cfg_attr(not(feature = "gateway-server"), allow(unused_mut))]
523 let mut app = Router::new()
524 .route("/health", get(health))
525 .route("/status", get(status_handler))
526 .route("/v1/messages", any(anthropic::handler))
527 .route("/v1/messages/{*rest}", any(anthropic::handler))
528 .route("/v1/chat/completions", any(openai::handler))
529 .route(
531 "/v1/responses",
532 post(openai_responses::handler).get(openai_responses::ws_handler),
533 )
534 .route("/v1/responses/{*rest}", any(openai_responses::handler))
535 .route("/messages", any(anthropic::handler))
541 .route("/messages/{*rest}", any(anthropic::handler))
542 .route("/chat/completions", any(openai::handler))
543 .route(
544 "/responses",
545 post(openai_responses::handler).get(openai_responses::ws_handler),
546 )
547 .route("/responses/{*rest}", any(openai_responses::handler))
548 .route(
549 "/backend-api/codex/responses",
550 post(chatgpt::codex_responses_handler).get(chatgpt::codex_responses_ws_handler),
551 )
552 .route(
553 "/backend-api/codex/responses/{*rest}",
554 any(chatgpt::codex_responses_handler),
555 )
556 .route("/backend-api", any(chatgpt::backend_api_handler))
559 .route("/backend-api/{*rest}", any(chatgpt::backend_api_handler))
560 .route("/v1/references/{id}", get(v1_resolve_reference))
561 .route("/v1/retrieve/{hash}", get(v1_retrieve_ccr))
564 .route("/v1/models", get(models_api::handler))
569 .route("/models", get(models_api::handler))
570 .route("/v1/compress", post(compress_api::handler))
573 .route("/providers/{id}/{*rest}", any(providers::handler))
577 .fallback(fallback_router);
578
579 #[cfg(feature = "gateway-server")]
583 {
584 app = app.merge(crate::gateway_server::user_api::router());
585 }
586
587 #[cfg(feature = "gateway-server")]
593 {
594 use crate::gateway_server::mcp::proxy::handler as mcp_handler;
595 app = app
596 .route("/mcp/{server}", any(mcp_handler))
597 .route("/mcp/{server}/", any(mcp_handler));
598 }
599
600 let mut app = app
601 .layer(axum::middleware::from_fn(move |req, next| {
602 let allowed = allowed_hosts.clone();
603 host_guard(req, next, allowed)
604 }))
605 .with_state(state);
606
607 let gateway_keys = match gateway_identity::GatewayKeys::load_default() {
611 Ok(keys) => {
612 if !keys.is_empty() {
613 println!(
614 " Identity: {} gateway key(s) loaded ({})",
615 keys.len(),
616 gateway_identity::GatewayKeys::default_path().display()
617 );
618 }
619 Arc::new(keys)
620 }
621 Err(e) => anyhow::bail!("gateway-keys.toml: {e}"),
622 };
623
624 {
625 let expected = auth_token.clone();
626 let keys = gateway_keys.clone();
627 app = app.layer(axum::middleware::from_fn(move |req, next| {
628 let expected = expected.clone();
629 let keys = keys.clone();
630 proxy_auth_guard(req, next, expected, require_token, loopback_open, keys)
631 }));
632 }
633
634 if let Some(limiter) = rate_limiter {
635 app = app.layer(axum::middleware::from_fn(move |req, next| {
636 let limiter = limiter.clone();
637 rate_limit_guard(req, next, limiter)
638 }));
639 }
640
641 app = app.layer(axum::middleware::from_fn(normalize_provider_path));
645
646 let addr = SocketAddr::from((bind_host, port));
647 if loopback_open {
648 println!("lean-ctx proxy listening on http://{addr} (loopback-open: auth disabled)");
649 } else {
650 println!("lean-ctx proxy listening on http://{addr} (token auth enabled)");
651 }
652 if !loopback_bind {
653 println!(
654 " ⚠ gateway mode: non-loopback bind — Bearer token REQUIRED (provider-key \
655 fallback disabled), Host allowlist + rate limit active"
656 );
657 }
658 println!(" Anthropic: POST /v1/messages → {anthropic_upstream}");
659 println!(" OpenAI: POST /v1/chat/completions → {openai_upstream}");
660 println!(
661 " OpenAI: POST /v1/responses → {openai_upstream} (bare /responses also accepted)"
662 );
663 println!(" ChatGPT: POST /backend-api/codex/responses → {chatgpt_upstream}");
664 println!(" ChatGPT: any /backend-api/* → {chatgpt_upstream}");
665 println!(" Gemini: POST /v1beta/models/... → {gemini_upstream}");
666 println!(" Compress: POST /v1/compress (deterministic messages-in/out, local)");
667 println!(
671 " Codex: WS ws://{addr}/responses → bridged to {openai_upstream} (HTTP/SSE, #440)"
672 );
673 for p in &initial_providers {
674 println!(
675 " Provider: any /providers/{}/... → {} ({} shape{})",
676 p.id,
677 p.base_url,
678 p.shape.as_str(),
679 if p.api_key_env.is_some() {
680 ", gateway-held key"
681 } else {
682 ""
683 }
684 );
685 }
686 for s in mcp_servers.iter() {
687 println!(
688 " MCP: any /mcp/{} → {} (observed{})",
689 s.id,
690 s.url,
691 if s.auth_env.is_some() {
692 ", gateway-held credential"
693 } else {
694 ""
695 }
696 );
697 }
698 if openai_upstream.starts_with("http://") && !is_local_proxy_url(&openai_upstream) {
699 println!(
700 " ⚠ OpenAI upstream is plaintext HTTP to a non-loopback host \
701 (allow_insecure_http_upstream) — use only on a trusted local network"
702 );
703 }
704
705 let listener = tokio::net::TcpListener::bind(addr).await?;
706 axum::serve(listener, app)
707 .with_graceful_shutdown(shutdown_signal())
708 .await?;
709
710 println!("lean-ctx proxy shut down cleanly.");
711 Ok(())
712}
713
714async fn shutdown_signal() {
715 let ctrl_c = tokio::signal::ctrl_c();
716
717 #[cfg(unix)]
718 {
719 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
722 Ok(mut sigterm) => {
723 tokio::select! {
724 _ = ctrl_c => {},
725 _ = sigterm.recv() => {},
726 }
727 }
728 Err(e) => {
729 tracing::warn!("lean-ctx proxy: SIGTERM handler unavailable ({e}); Ctrl-C only");
730 ctrl_c.await.ok();
731 }
732 }
733 }
734
735 #[cfg(not(unix))]
736 {
737 ctrl_c.await.ok();
738 }
739
740 println!("lean-ctx proxy: received shutdown signal, draining…");
741}
742
743async fn health() -> impl IntoResponse {
744 let body = serde_json::json!({
745 "status": "ok",
746 "pid": std::process::id(),
747 });
748 (StatusCode::OK, axum::Json(body))
749}
750
751async fn v1_resolve_reference(
752 axum::extract::Path(id): axum::extract::Path<String>,
753) -> impl IntoResponse {
754 match crate::server::reference_store::resolve(&id) {
755 Some(content) => (StatusCode::OK, content),
756 None => (
757 StatusCode::NOT_FOUND,
758 "Reference expired or not found".to_string(),
759 ),
760 }
761}
762
763async fn v1_retrieve_ccr(
773 axum::extract::Path(hash): axum::extract::Path<String>,
774) -> impl IntoResponse {
775 match ccr::retrieve_litellm(&hash) {
776 Some(content) => (
777 StatusCode::OK,
778 axum::Json(serde_json::json!({ "original_content": content })),
779 ),
780 None => (
781 StatusCode::NOT_FOUND,
782 axum::Json(serde_json::json!({
783 "error": "hash not found or expired",
784 "hash": hash,
785 })),
786 ),
787 }
788}
789
790async fn status_handler(State(state): State<ProxyState>) -> impl IntoResponse {
791 use std::sync::atomic::Ordering::Relaxed;
792 let s = &state.stats;
793 let i = &state.introspect;
794
795 let last_breakdown = i
796 .last_breakdown
797 .lock()
798 .ok()
799 .and_then(|guard| guard.as_ref().map(|b| serde_json::to_value(b).ok()))
800 .flatten();
801
802 let spend = usage_meter::snapshot();
803 let spend_total: f64 = spend.iter().map(|m| m.cost_usd).sum();
804
805 let up = state.upstream_snapshot();
809
810 let active_effort = crate::core::config::Config::load().proxy.resolved_effort();
813
814 let body = serde_json::json!({
815 "status": "running",
816 "proxy_mode": format!("{:?}", crate::core::config::Config::load().proxy.resolved_proxy_mode()),
817 "port": state.port,
818 "upstreams": {
819 "anthropic": up.anthropic.clone(),
820 "openai": up.openai.clone(),
821 "chatgpt": up.chatgpt.clone(),
822 "gemini": up.gemini.clone(),
823 },
824 "providers": up.providers.iter().map(|p| serde_json::json!({
827 "id": p.id,
828 "shape": p.shape.as_str(),
829 "base_url": p.base_url,
830 "gateway_key": p.api_key_env.is_some(),
831 })).collect::<Vec<_>>(),
832 "requests_total": s.requests_total.load(Relaxed),
833 "requests_compressed": s.requests_compressed.load(Relaxed),
834 "tokens_saved": s.tokens_saved.load(Relaxed),
835 "tokens_saved_estimated": true,
836 "verified_savings": usage_meter::verified_savings(),
840 "bytes_original": s.bytes_original.load(Relaxed),
841 "bytes_compressed": s.bytes_compressed.load(Relaxed),
842 "compression_ratio_pct": format!("{:.1}", s.compression_ratio()),
843 "per_upstream": s.provider_summary(),
844 "prefix_cache": prefix_cache_stats::snapshot(),
845 "cache_safety": cache_safety::snapshot(),
846 "cache_attribution": cache_attribution::snapshot(),
847 "effort": effort::snapshot(active_effort),
848 "per_model": cost::snapshot(),
849 "spend": {
850 "source": "measured",
851 "total_usd": spend_total,
852 "per_model": spend,
853 "note": "Actual provider bill: real model + billed tokens (incl. cache reads/writes & reasoning) read from upstream responses for proxy-routed clients."
854 },
855 "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.",
856 "introspect": {
857 "total_requests_analyzed": i.total_requests.load(Relaxed),
858 "total_system_prompt_tokens": i.total_system_prompt_tokens.load(Relaxed),
859 "last_breakdown": last_breakdown,
860 }
861 });
862 (StatusCode::OK, axum::Json(body))
863}
864
865#[allow(clippy::result_large_err)]
866async fn proxy_auth_guard(
867 mut req: axum::extract::Request,
868 next: axum::middleware::Next,
869 expected_token: String,
870 require_token: bool,
871 loopback_open: bool,
872 gateway_keys: Arc<gateway_identity::GatewayKeys>,
873) -> Result<Response, Response> {
874 let path = req.uri().path();
875 if path == "/health" || me_shell_path(path) {
876 return Ok(next.run(req).await);
877 }
878
879 if loopback_open {
881 attach_gateway_tags(&mut req, gateway_identity::GatewayTags::default());
882 return Ok(next.run(req).await);
883 }
884
885 let bearer = req
886 .headers()
887 .get("authorization")
888 .and_then(|v| v.to_str().ok())
889 .and_then(|auth| auth.strip_prefix("Bearer "))
890 .map(str::to_string);
891
892 if let Some(token) = bearer.as_deref()
893 && constant_time_eq(token.as_bytes(), expected_token.as_bytes())
894 {
895 attach_gateway_tags(&mut req, gateway_identity::GatewayTags::default());
896 return Ok(next.run(req).await);
897 }
898
899 if let Some(token) = bearer.as_deref()
903 && let Some(tags) = gateway_keys.lookup(token)
904 {
905 attach_gateway_tags(&mut req, tags);
906 return Ok(next.run(req).await);
907 }
908
909 if provider_key_fallback_allowed(
911 require_token,
912 has_provider_api_key(&req),
913 is_provider_route(path),
914 ) {
915 attach_gateway_tags(&mut req, gateway_identity::GatewayTags::default());
916 return Ok(next.run(req).await);
917 }
918
919 Err(auth_error_response(path))
920}
921
922fn auth_error_response(path: &str) -> Response {
923 let is_mcp = path.starts_with("/mcp");
924 let hint = if is_mcp {
925 "MCP Streamable HTTP requires a Bearer token. Get it with: lean-ctx proxy token"
926 } else if is_provider_route(path) {
927 "Provider route requires authentication. Set your API key header or use: lean-ctx proxy token"
928 } else {
929 "This endpoint requires a lean-ctx Bearer token. Get it with: lean-ctx proxy token \
930 — or set proxy_loopback_open = true to disable auth on localhost"
931 };
932
933 let body = serde_json::json!({
934 "type": "error",
935 "error": {
936 "type": "authentication_error",
937 "message": format!("401 Unauthorized — {hint}")
938 }
939 });
940
941 (StatusCode::UNAUTHORIZED, axum::Json(body)).into_response()
942}
943
944fn attach_gateway_tags(req: &mut axum::extract::Request, mut tags: gateway_identity::GatewayTags) {
953 if let Some(project) = req
954 .headers()
955 .get("x-leanctx-project")
956 .and_then(|v| v.to_str().ok())
957 .map(str::trim)
958 .filter(|p| !p.is_empty() && p.len() <= 128 && !p.chars().any(char::is_control))
959 {
960 tags.project = Some(project.to_string());
961 }
962 if let Some(person) = tags.person.as_deref()
966 && pii::enabled()
967 {
968 tags.person = Some(pii::pseudonymize(person));
969 }
970 if !tags.is_empty() {
971 req.extensions_mut().insert(tags);
972 }
973}
974
975fn me_shell_path(path: &str) -> bool {
979 #[cfg(feature = "gateway-server")]
980 {
981 crate::gateway_server::user_api::is_shell_path(path)
982 }
983 #[cfg(not(feature = "gateway-server"))]
984 {
985 let _ = path;
986 false
987 }
988}
989
990fn has_provider_api_key(req: &axum::extract::Request) -> bool {
991 let headers = req.headers();
992 for key in ["x-api-key", "x-goog-api-key", "api-key"] {
995 if headers
996 .get(key)
997 .and_then(|v| v.to_str().ok())
998 .is_some_and(|v| !v.trim().is_empty())
999 {
1000 return true;
1001 }
1002 }
1003 if let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
1012 let auth = auth.trim();
1013 let credential = auth
1014 .strip_prefix("Bearer ")
1015 .or_else(|| auth.strip_prefix("bearer "))
1016 .unwrap_or(auth)
1017 .trim();
1018 return !credential.is_empty() && !credential.eq_ignore_ascii_case("bearer");
1020 }
1021 false
1022}
1023
1024fn is_provider_route(path: &str) -> bool {
1025 path.starts_with("/v1/")
1026 || path.starts_with("/v1beta/")
1027 || path.starts_with("/chat/completions")
1028 || path.starts_with("/responses")
1029 || path.starts_with("/messages")
1030 || path.starts_with("/backend-api")
1031 || path == "/models"
1034}
1035
1036fn provider_key_fallback_allowed(
1045 require_token: bool,
1046 has_provider_key: bool,
1047 is_provider_route: bool,
1048) -> bool {
1049 !require_token && has_provider_key && is_provider_route
1050}
1051
1052fn canonical_provider_path(path: &str) -> Option<String> {
1063 if let Some(rest) = path.strip_prefix("/v1/v1/") {
1067 return Some(format!("/v1/{rest}"));
1068 }
1069 const BARE_TO_CANONICAL: &[(&str, &str, &str)] = &[
1070 ("/responses", "/v1/responses", "/responses/"),
1071 (
1072 "/chat/completions",
1073 "/v1/chat/completions",
1074 "/chat/completions/",
1075 ),
1076 ("/messages", "/v1/messages", "/messages/"),
1077 ];
1078 for (bare, canonical, bare_with_slash) in BARE_TO_CANONICAL {
1079 if path == *bare {
1080 return Some((*canonical).to_string());
1081 }
1082 if let Some(rest) = path.strip_prefix(bare_with_slash) {
1083 return Some(format!("{canonical}/{rest}"));
1084 }
1085 }
1086 None
1087}
1088
1089fn normalized_provider_uri(uri: &axum::http::Uri) -> Option<axum::http::Uri> {
1093 let canonical = canonical_provider_path(uri.path())?;
1094 let new_path_and_query = match uri.query() {
1095 Some(q) => format!("{canonical}?{q}"),
1096 None => canonical,
1097 };
1098 new_path_and_query.parse::<axum::http::Uri>().ok()
1099}
1100
1101async fn normalize_provider_path(
1105 mut req: axum::extract::Request,
1106 next: axum::middleware::Next,
1107) -> Response {
1108 if let Some(uri) = normalized_provider_uri(req.uri()) {
1109 *req.uri_mut() = uri;
1110 }
1111 next.run(req).await
1112}
1113
1114fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
1115 use subtle::ConstantTimeEq;
1116 if a.len() != b.len() {
1117 return false;
1118 }
1119 bool::from(a.ct_eq(b))
1120}
1121
1122async fn host_guard(
1123 req: axum::extract::Request,
1124 next: axum::middleware::Next,
1125 allowed_hosts: Arc<Vec<String>>,
1126) -> Result<Response, StatusCode> {
1127 if let Some(host) = req.headers().get("host").and_then(|v| v.to_str().ok())
1128 && host_allowed(host, &allowed_hosts)
1129 {
1130 return Ok(next.run(req).await);
1131 }
1132 Err(StatusCode::FORBIDDEN)
1133}
1134
1135fn host_allowed(host_header: &str, allowed: &[String]) -> bool {
1140 let host = host_header.trim();
1142 let h = if let Some(bracketed) = host.strip_prefix('[') {
1143 bracketed
1144 .split(']')
1145 .next()
1146 .map(|inner| format!("[{inner}]"))
1147 } else {
1148 host.split(':').next().map(str::to_string)
1149 };
1150 let Some(h) = h else {
1151 return false;
1152 };
1153 let h = h.trim_end_matches('.').to_ascii_lowercase();
1154 matches!(h.as_str(), "127.0.0.1" | "localhost" | "[::1]") || allowed.contains(&h)
1155}
1156
1157async fn rate_limit_guard(
1160 req: axum::extract::Request,
1161 next: axum::middleware::Next,
1162 limiter: Arc<RateLimiter>,
1163) -> Result<Response, StatusCode> {
1164 if req.uri().path() != "/health" && !limiter.allow().await {
1165 return Err(StatusCode::TOO_MANY_REQUESTS);
1166 }
1167 Ok(next.run(req).await)
1168}
1169
1170pub(crate) struct RateLimiter {
1174 max_rps: f64,
1175 burst: f64,
1176 state: tokio::sync::Mutex<RateLimiterState>,
1177}
1178
1179struct RateLimiterState {
1180 tokens: f64,
1181 last: std::time::Instant,
1182}
1183
1184impl RateLimiter {
1185 pub(crate) fn new(max_rps: u32, burst: u32) -> Self {
1186 Self {
1187 max_rps: f64::from(max_rps.max(1)),
1188 burst: f64::from(burst.max(1)),
1189 state: tokio::sync::Mutex::new(RateLimiterState {
1190 tokens: f64::from(burst.max(1)),
1191 last: std::time::Instant::now(),
1192 }),
1193 }
1194 }
1195
1196 pub(crate) async fn allow(&self) -> bool {
1197 let mut s = self.state.lock().await;
1198 let now = std::time::Instant::now();
1199 let refill = now.saturating_duration_since(s.last).as_secs_f64() * self.max_rps;
1200 s.tokens = (s.tokens + refill).min(self.burst);
1201 s.last = now;
1202 if s.tokens >= 1.0 {
1203 s.tokens -= 1.0;
1204 true
1205 } else {
1206 false
1207 }
1208 }
1209}
1210
1211async fn fallback_router(State(state): State<ProxyState>, req: Request<Body>) -> Response {
1212 let path = req.uri().path().to_string();
1213
1214 if path.starts_with("/v1beta/models/") || path.starts_with("/v1/models/") {
1215 match google::handler(State(state), req).await {
1216 Ok(resp) => resp,
1217 Err(status) => Response::builder()
1218 .status(status)
1219 .body(Body::from("proxy error"))
1220 .expect("BUG: building error response with valid status should never fail"),
1221 }
1222 } else {
1223 let method = req.method().to_string();
1224 eprintln!("lean-ctx proxy: unmatched {method} {path}");
1225 Response::builder()
1226 .status(StatusCode::NOT_FOUND)
1227 .body(Body::from(format!(
1228 "lean-ctx proxy: no handler for {method} {path}"
1229 )))
1230 .expect("BUG: building 404 response should never fail")
1231 }
1232}