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