1pub mod anthropic;
2#[cfg(test)]
3mod auth_tests;
4pub mod cache_aligner;
5pub mod cache_attribution;
6pub mod cache_breakpoint;
7pub mod cache_policy;
8pub mod cache_safety;
9pub mod ccr;
10#[cfg(test)]
11mod ccr_robustness_tests;
12pub mod chatgpt;
13pub mod chatgpt_cookies;
14pub mod chatgpt_ws;
15pub mod cold_prefix;
16pub mod compress;
17pub mod compress_api;
18pub mod cost;
19pub mod counterfactual;
20pub mod effort;
21pub mod forward;
22pub mod gateway_identity;
23pub mod google;
24pub mod history_prune;
25pub mod holdout;
26pub mod introspect;
27pub mod metrics;
28pub mod models_api;
29pub mod openai;
30pub mod openai_responses;
31pub mod openai_responses_ws;
32pub mod output_savings;
33pub mod pii;
34pub mod policy_gate;
35pub mod prose;
36pub mod prose_ranker;
37pub mod providers;
38pub mod routing;
39#[cfg(feature = "shape-xlat")]
40pub mod shape_xlat;
41#[cfg(test)]
42mod stats_tests;
43pub mod tool_kind;
44pub mod tool_output;
45#[cfg(test)]
46mod upstream_tests;
47pub mod usage;
48pub mod usage_accounting;
49pub mod usage_meter;
50pub mod usage_sink;
51pub mod verbosity;
52
53use std::net::SocketAddr;
54use std::sync::Arc;
55use std::sync::atomic::{AtomicU64, Ordering};
56
57use crate::core::config::Upstreams;
58
59use axum::{
60 Router,
61 body::Body,
62 extract::State,
63 http::{Request, StatusCode},
64 response::{IntoResponse, Response},
65 routing::{any, get, post},
66};
67
68#[derive(Clone)]
69pub struct ProxyState {
70 pub client: reqwest::Client,
71 pub port: u16,
72 pub stats: Arc<ProxyStats>,
73 pub introspect: Arc<introspect::IntrospectState>,
74 pub upstreams: tokio::sync::watch::Receiver<Arc<Upstreams>>,
77 pub(crate) chatgpt_cookies: Arc<chatgpt_cookies::ChatGptCloudflareCookieStore>,
81 pub mcp_servers: Arc<Vec<crate::core::config::ResolvedMcpServer>>,
86}
87
88impl ProxyState {
89 #[cfg(all(test, feature = "gateway-server"))]
95 pub(crate) fn for_tests(mcp_servers: Vec<crate::core::config::ResolvedMcpServer>) -> Self {
96 let (_tx, rx) = tokio::sync::watch::channel(Arc::new(Upstreams {
98 anthropic: "https://api.anthropic.com".into(),
99 openai: "https://api.openai.com".into(),
100 chatgpt: "https://chatgpt.com".into(),
101 gemini: "https://generativelanguage.googleapis.com".into(),
102 providers: Vec::new(),
103 }));
104 Self {
105 client: reqwest::Client::new(),
106 port: 0,
107 stats: Arc::new(ProxyStats::default()),
108 introspect: Arc::new(introspect::IntrospectState::default()),
109 upstreams: rx,
110 chatgpt_cookies: chatgpt_cookies::shared_chatgpt_cloudflare_cookie_store(),
111 mcp_servers: Arc::new(mcp_servers),
112 }
113 }
114
115 pub fn upstream_snapshot(&self) -> Arc<Upstreams> {
117 self.upstreams.borrow().clone()
118 }
119
120 pub fn anthropic_upstream(&self) -> String {
122 self.upstreams.borrow().anthropic.clone()
123 }
124
125 pub fn openai_upstream(&self) -> String {
127 self.upstreams.borrow().openai.clone()
128 }
129
130 pub fn chatgpt_upstream(&self) -> String {
132 self.upstreams.borrow().chatgpt.clone()
133 }
134
135 pub fn gemini_upstream(&self) -> String {
137 self.upstreams.borrow().gemini.clone()
138 }
139
140 pub fn chatgpt_cookie_header(&self) -> Option<String> {
144 let url = reqwest::Url::parse(&self.chatgpt_upstream()).ok()?;
145 self.chatgpt_cookies
146 .cookie_header(&url)
147 .and_then(|v| v.to_str().ok().map(str::to_owned))
148 }
149}
150
151pub struct ProxyStats {
152 pub requests_total: AtomicU64,
153 pub requests_compressed: AtomicU64,
154 pub tokens_saved: AtomicU64,
155 pub bytes_original: AtomicU64,
156 pub bytes_compressed: AtomicU64,
157 pub anthropic: ProviderStats,
158 pub openai: ProviderStats,
159 pub chatgpt: ProviderStats,
160 pub gemini: ProviderStats,
161}
162
163#[derive(Default)]
164pub struct ProviderStats {
165 pub requests_total: AtomicU64,
166 pub requests_compressed: AtomicU64,
167 pub tokens_saved: AtomicU64,
168 pub bytes_original: AtomicU64,
169 pub bytes_compressed: AtomicU64,
170}
171
172impl Default for ProxyStats {
173 fn default() -> Self {
174 Self {
175 requests_total: AtomicU64::new(0),
176 requests_compressed: AtomicU64::new(0),
177 tokens_saved: AtomicU64::new(0),
178 bytes_original: AtomicU64::new(0),
179 bytes_compressed: AtomicU64::new(0),
180 anthropic: ProviderStats::default(),
181 openai: ProviderStats::default(),
182 chatgpt: ProviderStats::default(),
183 gemini: ProviderStats::default(),
184 }
185 }
186}
187
188impl ProxyStats {
189 pub fn record_request(&self, original: usize, compressed: usize) {
190 self.record_totals(original, compressed);
191 }
192
193 pub fn record_provider_request(
194 &self,
195 provider_label: &str,
196 original: usize,
197 compressed: usize,
198 ) {
199 let (effective_compressed, saved_tokens, compressed_request) =
200 self.record_totals(original, compressed);
201
202 if let Some(provider) = self.provider(provider_label) {
203 provider.record(
204 original,
205 effective_compressed,
206 compressed_request,
207 saved_tokens,
208 );
209 }
210 }
211
212 fn record_totals(&self, original: usize, compressed: usize) -> (usize, u64, bool) {
213 self.requests_total.fetch_add(1, Ordering::Relaxed);
214 self.bytes_original
215 .fetch_add(original as u64, Ordering::Relaxed);
216 let effective_compressed = compressed.min(original);
217 self.bytes_compressed
218 .fetch_add(effective_compressed as u64, Ordering::Relaxed);
219 if compressed < original {
220 self.requests_compressed.fetch_add(1, Ordering::Relaxed);
221 }
222 let saved_tokens = (original.saturating_sub(effective_compressed) / 4) as u64;
223 self.tokens_saved.fetch_add(saved_tokens, Ordering::Relaxed);
224 (effective_compressed, saved_tokens, compressed < original)
225 }
226
227 pub fn compression_ratio(&self) -> f64 {
228 let original = self.bytes_original.load(Ordering::Relaxed);
229 if original == 0 {
230 return 0.0;
231 }
232 let compressed = self.bytes_compressed.load(Ordering::Relaxed);
233 (1.0 - compressed as f64 / original as f64) * 100.0
234 }
235
236 fn provider(&self, provider_label: &str) -> Option<&ProviderStats> {
240 match provider_label {
241 "Anthropic" => Some(&self.anthropic),
242 "OpenAI" => Some(&self.openai),
243 "ChatGPT" => Some(&self.chatgpt),
244 "Gemini" => Some(&self.gemini),
245 _ => None,
246 }
247 }
248
249 pub fn provider_summary(&self) -> serde_json::Value {
250 serde_json::json!({
251 "anthropic": self.anthropic.summary(),
252 "openai": self.openai.summary(),
253 "chatgpt": self.chatgpt.summary(),
254 "gemini": self.gemini.summary(),
255 })
256 }
257}
258
259impl ProviderStats {
260 fn record(
261 &self,
262 original: usize,
263 effective_compressed: usize,
264 compressed_request: bool,
265 saved_tokens: u64,
266 ) {
267 self.requests_total.fetch_add(1, Ordering::Relaxed);
268 if compressed_request {
269 self.requests_compressed.fetch_add(1, Ordering::Relaxed);
270 }
271 self.tokens_saved.fetch_add(saved_tokens, Ordering::Relaxed);
272 self.bytes_original
273 .fetch_add(original as u64, Ordering::Relaxed);
274 self.bytes_compressed
275 .fetch_add(effective_compressed as u64, Ordering::Relaxed);
276 }
277
278 fn compression_ratio(&self) -> f64 {
279 let original = self.bytes_original.load(Ordering::Relaxed);
280 if original == 0 {
281 return 0.0;
282 }
283 let compressed = self.bytes_compressed.load(Ordering::Relaxed);
284 (1.0 - compressed as f64 / original as f64) * 100.0
285 }
286
287 fn summary(&self) -> serde_json::Value {
288 serde_json::json!({
289 "requests_total": self.requests_total.load(Ordering::Relaxed),
290 "requests_compressed": self.requests_compressed.load(Ordering::Relaxed),
291 "tokens_saved": self.tokens_saved.load(Ordering::Relaxed),
292 "bytes_original": self.bytes_original.load(Ordering::Relaxed),
293 "bytes_compressed": self.bytes_compressed.load(Ordering::Relaxed),
294 "compression_ratio_pct": format!("{:.1}", self.compression_ratio()),
295 })
296 }
297}
298
299fn connect_timeout_secs() -> u64 {
301 std::env::var("LEAN_CTX_PROXY_CONNECT_TIMEOUT_SECS")
302 .ok()
303 .and_then(|v| v.trim().parse::<u64>().ok())
304 .filter(|s| *s > 0)
305 .unwrap_or(15)
306}
307
308fn read_idle_timeout_secs() -> u64 {
313 std::env::var("LEAN_CTX_PROXY_READ_TIMEOUT_SECS")
314 .ok()
315 .and_then(|v| v.trim().parse::<u64>().ok())
316 .filter(|s| *s > 0)
317 .unwrap_or(300)
318}
319
320fn upstream_reload_secs() -> u64 {
323 std::env::var("LEAN_CTX_PROXY_RELOAD_SECS")
324 .ok()
325 .and_then(|v| v.trim().parse::<u64>().ok())
326 .filter(|s| *s > 0)
327 .unwrap_or(5)
328}
329
330fn spawn_upstream_refresh(tx: tokio::sync::watch::Sender<Arc<Upstreams>>, initial: Upstreams) {
339 let interval = std::time::Duration::from_secs(upstream_reload_secs());
340 tokio::spawn(async move {
341 let mut last = initial;
342 loop {
343 tokio::time::sleep(interval).await;
344 let next = crate::core::config::Config::load()
345 .proxy
346 .refresh_upstreams(&last);
347 if next != last {
348 log_upstream_change(&last, &next);
349 last = next.clone();
350 if tx.send(Arc::new(next)).is_err() {
351 break;
352 }
353 }
354 }
355 });
356}
357
358fn log_upstream_change(old: &Upstreams, new: &Upstreams) {
361 if old.anthropic != new.anthropic {
362 println!(" ↻ Anthropic upstream → {}", new.anthropic);
363 }
364 if old.openai != new.openai {
365 println!(" ↻ OpenAI upstream → {}", new.openai);
366 }
367 if old.chatgpt != new.chatgpt {
368 println!(" ↻ ChatGPT upstream → {}", new.chatgpt);
369 }
370 if old.gemini != new.gemini {
371 println!(" ↻ Gemini upstream → {}", new.gemini);
372 }
373 if old.providers != new.providers {
374 let ids: Vec<&str> = new.providers.iter().map(|p| p.id.as_str()).collect();
375 println!(" ↻ provider registry → [{}]", ids.join(", "));
376 }
377}
378
379pub async fn start_proxy(port: u16) -> anyhow::Result<()> {
380 let token = crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN");
381 start_proxy_with_token(port, Some(token)).await
382}
383
384fn effective_auth_token(auth_token: Option<String>) -> String {
389 auth_token
390 .filter(|t| !t.trim().is_empty())
391 .unwrap_or_else(|| crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN"))
392}
393
394fn install_default_crypto_provider() {
405 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
406}
407
408pub async fn start_proxy_with_token(port: u16, auth_token: Option<String>) -> anyhow::Result<()> {
409 use crate::core::config::{Config, is_local_proxy_url};
410
411 install_default_crypto_provider();
413
414 let auth_token = effective_auth_token(auth_token);
415
416 let chatgpt_cookies = chatgpt_cookies::shared_chatgpt_cloudflare_cookie_store();
421 let client = chatgpt_cookies::with_chatgpt_cloudflare_cookie_store(
422 reqwest::Client::builder(),
423 chatgpt_cookies.clone(),
424 )
425 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs()))
426 .read_timeout(std::time::Duration::from_secs(read_idle_timeout_secs()))
427 .build()?;
428
429 usage_meter::resume_from_disk();
432 crate::core::gain::live_pricing::spawn_background_refresh();
437 cold_prefix::resume_from_disk();
440
441 let cfg = Config::load();
442 let bind_host = cfg.resolved_proxy_bind_host();
444 let loopback_bind = bind_host.is_loopback();
445 let require_token = cfg.proxy_require_token || !loopback_bind;
450 let allowed_hosts: Arc<Vec<String>> = Arc::new(
451 cfg.proxy_allowed_hosts
452 .iter()
453 .map(|h| h.trim().trim_end_matches('.').to_ascii_lowercase())
454 .filter(|h| !h.is_empty())
455 .collect(),
456 );
457 let rate_limiter = match (cfg.proxy_max_rps, loopback_bind) {
461 (Some(rps), _) if rps > 0 => Some(Arc::new(RateLimiter::new(rps, rps.saturating_mul(2)))),
462 (None, false) => Some(Arc::new(RateLimiter::new(50, 100))),
463 _ => None,
464 };
465 let initial = cfg.proxy.resolve_all();
466
467 let (upstream_tx, upstream_rx) = tokio::sync::watch::channel(Arc::new(initial.clone()));
472 spawn_upstream_refresh(upstream_tx, initial.clone());
473
474 let Upstreams {
475 anthropic: anthropic_upstream,
476 openai: openai_upstream,
477 chatgpt: chatgpt_upstream,
478 gemini: gemini_upstream,
479 providers: initial_providers,
480 } = initial;
481
482 let mcp_servers = Arc::new(
487 cfg.gateway_server
488 .resolve_mcp_servers(cfg.proxy.allows_insecure_http_upstream()),
489 );
490
491 let state = ProxyState {
492 client,
493 port,
494 stats: Arc::new(ProxyStats::default()),
495 introspect: Arc::new(introspect::IntrospectState::default()),
496 upstreams: upstream_rx,
497 chatgpt_cookies,
498 mcp_servers: mcp_servers.clone(),
499 };
500
501 #[cfg_attr(not(feature = "gateway-server"), allow(unused_mut))]
503 let mut app = Router::new()
504 .route("/health", get(health))
505 .route("/status", get(status_handler))
506 .route("/v1/messages", any(anthropic::handler))
507 .route("/v1/messages/{*rest}", any(anthropic::handler))
508 .route("/v1/chat/completions", any(openai::handler))
509 .route(
511 "/v1/responses",
512 post(openai_responses::handler).get(openai_responses::ws_handler),
513 )
514 .route("/v1/responses/{*rest}", any(openai_responses::handler))
515 .route("/messages", any(anthropic::handler))
521 .route("/messages/{*rest}", any(anthropic::handler))
522 .route("/chat/completions", any(openai::handler))
523 .route(
524 "/responses",
525 post(openai_responses::handler).get(openai_responses::ws_handler),
526 )
527 .route("/responses/{*rest}", any(openai_responses::handler))
528 .route(
529 "/backend-api/codex/responses",
530 post(chatgpt::codex_responses_handler).get(chatgpt::codex_responses_ws_handler),
531 )
532 .route(
533 "/backend-api/codex/responses/{*rest}",
534 any(chatgpt::codex_responses_handler),
535 )
536 .route("/backend-api", any(chatgpt::backend_api_handler))
539 .route("/backend-api/{*rest}", any(chatgpt::backend_api_handler))
540 .route("/v1/references/{id}", get(v1_resolve_reference))
541 .route("/v1/retrieve/{hash}", get(v1_retrieve_ccr))
544 .route("/v1/models", get(models_api::handler))
549 .route("/models", get(models_api::handler))
550 .route("/v1/compress", post(compress_api::handler))
553 .route("/providers/{id}/{*rest}", any(providers::handler))
557 .fallback(fallback_router);
558
559 #[cfg(feature = "gateway-server")]
563 {
564 app = app.merge(crate::gateway_server::user_api::router());
565 }
566
567 #[cfg(feature = "gateway-server")]
573 {
574 use crate::gateway_server::mcp::proxy::handler as mcp_handler;
575 app = app
576 .route("/mcp/{server}", any(mcp_handler))
577 .route("/mcp/{server}/", any(mcp_handler));
578 }
579
580 let mut app = app
581 .layer(axum::middleware::from_fn(move |req, next| {
582 let allowed = allowed_hosts.clone();
583 host_guard(req, next, allowed)
584 }))
585 .with_state(state);
586
587 let gateway_keys = match gateway_identity::GatewayKeys::load_default() {
591 Ok(keys) => {
592 if !keys.is_empty() {
593 println!(
594 " Identity: {} gateway key(s) loaded ({})",
595 keys.len(),
596 gateway_identity::GatewayKeys::default_path().display()
597 );
598 }
599 Arc::new(keys)
600 }
601 Err(e) => anyhow::bail!("gateway-keys.toml: {e}"),
602 };
603
604 {
605 let expected = auth_token.clone();
606 let keys = gateway_keys.clone();
607 app = app.layer(axum::middleware::from_fn(move |req, next| {
608 let expected = expected.clone();
609 let keys = keys.clone();
610 proxy_auth_guard(req, next, expected, require_token, keys)
611 }));
612 }
613
614 if let Some(limiter) = rate_limiter {
615 app = app.layer(axum::middleware::from_fn(move |req, next| {
616 let limiter = limiter.clone();
617 rate_limit_guard(req, next, limiter)
618 }));
619 }
620
621 app = app.layer(axum::middleware::from_fn(normalize_provider_path));
625
626 let addr = SocketAddr::from((bind_host, port));
627 println!("lean-ctx proxy listening on http://{addr} (token auth enabled)");
628 if !loopback_bind {
629 println!(
630 " ⚠ gateway mode: non-loopback bind — Bearer token REQUIRED (provider-key \
631 fallback disabled), Host allowlist + rate limit active"
632 );
633 }
634 println!(" Anthropic: POST /v1/messages → {anthropic_upstream}");
635 println!(" OpenAI: POST /v1/chat/completions → {openai_upstream}");
636 println!(
637 " OpenAI: POST /v1/responses → {openai_upstream} (bare /responses also accepted)"
638 );
639 println!(" ChatGPT: POST /backend-api/codex/responses → {chatgpt_upstream}");
640 println!(" ChatGPT: any /backend-api/* → {chatgpt_upstream}");
641 println!(" Gemini: POST /v1beta/models/... → {gemini_upstream}");
642 println!(" Compress: POST /v1/compress (deterministic messages-in/out, local)");
643 println!(
647 " Codex: WS ws://{addr}/responses → bridged to {openai_upstream} (HTTP/SSE, #440)"
648 );
649 for p in &initial_providers {
650 println!(
651 " Provider: any /providers/{}/... → {} ({} shape{})",
652 p.id,
653 p.base_url,
654 p.shape.as_str(),
655 if p.api_key_env.is_some() {
656 ", gateway-held key"
657 } else {
658 ""
659 }
660 );
661 }
662 for s in mcp_servers.iter() {
663 println!(
664 " MCP: any /mcp/{} → {} (observed{})",
665 s.id,
666 s.url,
667 if s.auth_env.is_some() {
668 ", gateway-held credential"
669 } else {
670 ""
671 }
672 );
673 }
674 if openai_upstream.starts_with("http://") && !is_local_proxy_url(&openai_upstream) {
675 println!(
676 " ⚠ OpenAI upstream is plaintext HTTP to a non-loopback host \
677 (allow_insecure_http_upstream) — use only on a trusted local network"
678 );
679 }
680
681 let listener = tokio::net::TcpListener::bind(addr).await?;
682 axum::serve(listener, app)
683 .with_graceful_shutdown(shutdown_signal())
684 .await?;
685
686 println!("lean-ctx proxy shut down cleanly.");
687 Ok(())
688}
689
690async fn shutdown_signal() {
691 let ctrl_c = tokio::signal::ctrl_c();
692
693 #[cfg(unix)]
694 {
695 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
698 Ok(mut sigterm) => {
699 tokio::select! {
700 _ = ctrl_c => {},
701 _ = sigterm.recv() => {},
702 }
703 }
704 Err(e) => {
705 tracing::warn!("lean-ctx proxy: SIGTERM handler unavailable ({e}); Ctrl-C only");
706 ctrl_c.await.ok();
707 }
708 }
709 }
710
711 #[cfg(not(unix))]
712 {
713 ctrl_c.await.ok();
714 }
715
716 println!("lean-ctx proxy: received shutdown signal, draining…");
717}
718
719async fn health() -> impl IntoResponse {
720 let body = serde_json::json!({
721 "status": "ok",
722 "pid": std::process::id(),
723 });
724 (StatusCode::OK, axum::Json(body))
725}
726
727async fn v1_resolve_reference(
728 axum::extract::Path(id): axum::extract::Path<String>,
729) -> impl IntoResponse {
730 match crate::server::reference_store::resolve(&id) {
731 Some(content) => (StatusCode::OK, content),
732 None => (
733 StatusCode::NOT_FOUND,
734 "Reference expired or not found".to_string(),
735 ),
736 }
737}
738
739async fn v1_retrieve_ccr(
749 axum::extract::Path(hash): axum::extract::Path<String>,
750) -> impl IntoResponse {
751 match ccr::retrieve_litellm(&hash) {
752 Some(content) => (
753 StatusCode::OK,
754 axum::Json(serde_json::json!({ "original_content": content })),
755 ),
756 None => (
757 StatusCode::NOT_FOUND,
758 axum::Json(serde_json::json!({
759 "error": "hash not found or expired",
760 "hash": hash,
761 })),
762 ),
763 }
764}
765
766async fn status_handler(State(state): State<ProxyState>) -> impl IntoResponse {
767 use std::sync::atomic::Ordering::Relaxed;
768 let s = &state.stats;
769 let i = &state.introspect;
770
771 let last_breakdown = i
772 .last_breakdown
773 .lock()
774 .ok()
775 .and_then(|guard| guard.as_ref().map(|b| serde_json::to_value(b).ok()))
776 .flatten();
777
778 let spend = usage_meter::snapshot();
779 let spend_total: f64 = spend.iter().map(|m| m.cost_usd).sum();
780
781 let up = state.upstream_snapshot();
785
786 let active_effort = crate::core::config::Config::load().proxy.resolved_effort();
789
790 let body = serde_json::json!({
791 "status": "running",
792 "port": state.port,
793 "upstreams": {
794 "anthropic": up.anthropic.clone(),
795 "openai": up.openai.clone(),
796 "chatgpt": up.chatgpt.clone(),
797 "gemini": up.gemini.clone(),
798 },
799 "providers": up.providers.iter().map(|p| serde_json::json!({
802 "id": p.id,
803 "shape": p.shape.as_str(),
804 "base_url": p.base_url,
805 "gateway_key": p.api_key_env.is_some(),
806 })).collect::<Vec<_>>(),
807 "requests_total": s.requests_total.load(Relaxed),
808 "requests_compressed": s.requests_compressed.load(Relaxed),
809 "tokens_saved": s.tokens_saved.load(Relaxed),
810 "tokens_saved_estimated": true,
811 "verified_savings": usage_meter::verified_savings(),
815 "bytes_original": s.bytes_original.load(Relaxed),
816 "bytes_compressed": s.bytes_compressed.load(Relaxed),
817 "compression_ratio_pct": format!("{:.1}", s.compression_ratio()),
818 "per_upstream": s.provider_summary(),
819 "cache_safety": cache_safety::snapshot(),
820 "cache_attribution": cache_attribution::snapshot(),
821 "effort": effort::snapshot(active_effort),
822 "per_model": cost::snapshot(),
823 "spend": {
824 "source": "measured",
825 "total_usd": spend_total,
826 "per_model": spend,
827 "note": "Actual provider bill: real model + billed tokens (incl. cache reads/writes & reasoning) read from upstream responses for proxy-routed clients."
828 },
829 "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.",
830 "introspect": {
831 "total_requests_analyzed": i.total_requests.load(Relaxed),
832 "total_system_prompt_tokens": i.total_system_prompt_tokens.load(Relaxed),
833 "last_breakdown": last_breakdown,
834 }
835 });
836 (StatusCode::OK, axum::Json(body))
837}
838
839#[allow(clippy::result_large_err)]
840async fn proxy_auth_guard(
841 mut req: axum::extract::Request,
842 next: axum::middleware::Next,
843 expected_token: String,
844 require_token: bool,
845 gateway_keys: Arc<gateway_identity::GatewayKeys>,
846) -> Result<Response, Response> {
847 let path = req.uri().path();
848 if path == "/health" || me_shell_path(path) {
849 return Ok(next.run(req).await);
850 }
851
852 let bearer = req
853 .headers()
854 .get("authorization")
855 .and_then(|v| v.to_str().ok())
856 .and_then(|auth| auth.strip_prefix("Bearer "))
857 .map(str::to_string);
858
859 if let Some(token) = bearer.as_deref()
860 && constant_time_eq(token.as_bytes(), expected_token.as_bytes())
861 {
862 attach_gateway_tags(&mut req, gateway_identity::GatewayTags::default());
863 return Ok(next.run(req).await);
864 }
865
866 if let Some(token) = bearer.as_deref()
870 && let Some(tags) = gateway_keys.lookup(token)
871 {
872 attach_gateway_tags(&mut req, tags);
873 return Ok(next.run(req).await);
874 }
875
876 if provider_key_fallback_allowed(
882 require_token,
883 has_provider_api_key(&req),
884 is_provider_route(path),
885 ) {
886 attach_gateway_tags(&mut req, gateway_identity::GatewayTags::default());
887 return Ok(next.run(req).await);
888 }
889
890 let cfg = crate::core::config::Config::load();
891 let hint = match cfg.proxy_enabled {
892 Some(true) => {
893 "lean-ctx proxy requires authentication. Use a Bearer token (LEAN_CTX_PROXY_TOKEN) or configure your AI tool's API key."
894 }
895 Some(false) => "lean-ctx proxy is disabled but still running. Run: lean-ctx proxy cleanup",
896 None => {
897 "lean-ctx proxy is not configured. Your AI tool's ANTHROPIC_BASE_URL may be pointing here by mistake. Fix: lean-ctx proxy cleanup OR lean-ctx proxy enable"
898 }
899 };
900
901 let body = serde_json::json!({
902 "type": "error",
903 "error": {
904 "type": "authentication_error",
905 "message": format!("401 Unauthorized — {hint}")
906 }
907 });
908
909 Err((StatusCode::UNAUTHORIZED, axum::Json(body)).into_response())
910}
911
912fn attach_gateway_tags(req: &mut axum::extract::Request, mut tags: gateway_identity::GatewayTags) {
921 if let Some(project) = req
922 .headers()
923 .get("x-leanctx-project")
924 .and_then(|v| v.to_str().ok())
925 .map(str::trim)
926 .filter(|p| !p.is_empty() && p.len() <= 128 && !p.chars().any(char::is_control))
927 {
928 tags.project = Some(project.to_string());
929 }
930 if let Some(person) = tags.person.as_deref()
934 && pii::enabled()
935 {
936 tags.person = Some(pii::pseudonymize(person));
937 }
938 if !tags.is_empty() {
939 req.extensions_mut().insert(tags);
940 }
941}
942
943fn me_shell_path(path: &str) -> bool {
947 #[cfg(feature = "gateway-server")]
948 {
949 crate::gateway_server::user_api::is_shell_path(path)
950 }
951 #[cfg(not(feature = "gateway-server"))]
952 {
953 let _ = path;
954 false
955 }
956}
957
958fn has_provider_api_key(req: &axum::extract::Request) -> bool {
959 let headers = req.headers();
960 for key in ["x-api-key", "x-goog-api-key", "api-key"] {
963 if headers
964 .get(key)
965 .and_then(|v| v.to_str().ok())
966 .is_some_and(|v| !v.trim().is_empty())
967 {
968 return true;
969 }
970 }
971 if let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
980 let auth = auth.trim();
981 let credential = auth
982 .strip_prefix("Bearer ")
983 .or_else(|| auth.strip_prefix("bearer "))
984 .unwrap_or(auth)
985 .trim();
986 return !credential.is_empty() && !credential.eq_ignore_ascii_case("bearer");
988 }
989 false
990}
991
992fn is_provider_route(path: &str) -> bool {
993 path.starts_with("/v1/")
994 || path.starts_with("/v1beta/")
995 || path.starts_with("/chat/completions")
996 || path.starts_with("/responses")
997 || path.starts_with("/messages")
998 || path.starts_with("/backend-api")
999 || path == "/models"
1002}
1003
1004fn provider_key_fallback_allowed(
1013 require_token: bool,
1014 has_provider_key: bool,
1015 is_provider_route: bool,
1016) -> bool {
1017 !require_token && has_provider_key && is_provider_route
1018}
1019
1020fn canonical_provider_path(path: &str) -> Option<String> {
1031 if let Some(rest) = path.strip_prefix("/v1/v1/") {
1035 return Some(format!("/v1/{rest}"));
1036 }
1037 const BARE_TO_CANONICAL: &[(&str, &str, &str)] = &[
1038 ("/responses", "/v1/responses", "/responses/"),
1039 (
1040 "/chat/completions",
1041 "/v1/chat/completions",
1042 "/chat/completions/",
1043 ),
1044 ("/messages", "/v1/messages", "/messages/"),
1045 ];
1046 for (bare, canonical, bare_with_slash) in BARE_TO_CANONICAL {
1047 if path == *bare {
1048 return Some((*canonical).to_string());
1049 }
1050 if let Some(rest) = path.strip_prefix(bare_with_slash) {
1051 return Some(format!("{canonical}/{rest}"));
1052 }
1053 }
1054 None
1055}
1056
1057fn normalized_provider_uri(uri: &axum::http::Uri) -> Option<axum::http::Uri> {
1061 let canonical = canonical_provider_path(uri.path())?;
1062 let new_path_and_query = match uri.query() {
1063 Some(q) => format!("{canonical}?{q}"),
1064 None => canonical,
1065 };
1066 new_path_and_query.parse::<axum::http::Uri>().ok()
1067}
1068
1069async fn normalize_provider_path(
1073 mut req: axum::extract::Request,
1074 next: axum::middleware::Next,
1075) -> Response {
1076 if let Some(uri) = normalized_provider_uri(req.uri()) {
1077 *req.uri_mut() = uri;
1078 }
1079 next.run(req).await
1080}
1081
1082fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
1083 use subtle::ConstantTimeEq;
1084 if a.len() != b.len() {
1085 return false;
1086 }
1087 bool::from(a.ct_eq(b))
1088}
1089
1090async fn host_guard(
1091 req: axum::extract::Request,
1092 next: axum::middleware::Next,
1093 allowed_hosts: Arc<Vec<String>>,
1094) -> Result<Response, StatusCode> {
1095 if let Some(host) = req.headers().get("host").and_then(|v| v.to_str().ok())
1096 && host_allowed(host, &allowed_hosts)
1097 {
1098 return Ok(next.run(req).await);
1099 }
1100 Err(StatusCode::FORBIDDEN)
1101}
1102
1103fn host_allowed(host_header: &str, allowed: &[String]) -> bool {
1108 let host = host_header.trim();
1110 let h = if let Some(bracketed) = host.strip_prefix('[') {
1111 bracketed
1112 .split(']')
1113 .next()
1114 .map(|inner| format!("[{inner}]"))
1115 } else {
1116 host.split(':').next().map(str::to_string)
1117 };
1118 let Some(h) = h else {
1119 return false;
1120 };
1121 let h = h.trim_end_matches('.').to_ascii_lowercase();
1122 matches!(h.as_str(), "127.0.0.1" | "localhost" | "[::1]") || allowed.contains(&h)
1123}
1124
1125async fn rate_limit_guard(
1128 req: axum::extract::Request,
1129 next: axum::middleware::Next,
1130 limiter: Arc<RateLimiter>,
1131) -> Result<Response, StatusCode> {
1132 if req.uri().path() != "/health" && !limiter.allow().await {
1133 return Err(StatusCode::TOO_MANY_REQUESTS);
1134 }
1135 Ok(next.run(req).await)
1136}
1137
1138pub(crate) struct RateLimiter {
1142 max_rps: f64,
1143 burst: f64,
1144 state: tokio::sync::Mutex<RateLimiterState>,
1145}
1146
1147struct RateLimiterState {
1148 tokens: f64,
1149 last: std::time::Instant,
1150}
1151
1152impl RateLimiter {
1153 pub(crate) fn new(max_rps: u32, burst: u32) -> Self {
1154 Self {
1155 max_rps: f64::from(max_rps.max(1)),
1156 burst: f64::from(burst.max(1)),
1157 state: tokio::sync::Mutex::new(RateLimiterState {
1158 tokens: f64::from(burst.max(1)),
1159 last: std::time::Instant::now(),
1160 }),
1161 }
1162 }
1163
1164 pub(crate) async fn allow(&self) -> bool {
1165 let mut s = self.state.lock().await;
1166 let now = std::time::Instant::now();
1167 let refill = now.saturating_duration_since(s.last).as_secs_f64() * self.max_rps;
1168 s.tokens = (s.tokens + refill).min(self.burst);
1169 s.last = now;
1170 if s.tokens >= 1.0 {
1171 s.tokens -= 1.0;
1172 true
1173 } else {
1174 false
1175 }
1176 }
1177}
1178
1179async fn fallback_router(State(state): State<ProxyState>, req: Request<Body>) -> Response {
1180 let path = req.uri().path().to_string();
1181
1182 if path.starts_with("/v1beta/models/") || path.starts_with("/v1/models/") {
1183 match google::handler(State(state), req).await {
1184 Ok(resp) => resp,
1185 Err(status) => Response::builder()
1186 .status(status)
1187 .body(Body::from("proxy error"))
1188 .expect("BUG: building error response with valid status should never fail"),
1189 }
1190 } else {
1191 let method = req.method().to_string();
1192 eprintln!("lean-ctx proxy: unmatched {method} {path}");
1193 Response::builder()
1194 .status(StatusCode::NOT_FOUND)
1195 .body(Body::from(format!(
1196 "lean-ctx proxy: no handler for {method} {path}"
1197 )))
1198 .expect("BUG: building 404 response should never fail")
1199 }
1200}