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