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_meter;
49pub mod usage_sink;
50pub mod verbosity;
51
52use std::net::SocketAddr;
53use std::sync::Arc;
54use std::sync::atomic::{AtomicU64, Ordering};
55
56use crate::core::config::Upstreams;
57
58use axum::{
59 Router,
60 body::Body,
61 extract::State,
62 http::{Request, StatusCode},
63 response::{IntoResponse, Response},
64 routing::{any, get, post},
65};
66
67#[derive(Clone)]
68pub struct ProxyState {
69 pub client: reqwest::Client,
70 pub port: u16,
71 pub stats: Arc<ProxyStats>,
72 pub introspect: Arc<introspect::IntrospectState>,
73 pub upstreams: tokio::sync::watch::Receiver<Arc<Upstreams>>,
76 pub(crate) chatgpt_cookies: Arc<chatgpt_cookies::ChatGptCloudflareCookieStore>,
80}
81
82impl ProxyState {
83 pub fn upstream_snapshot(&self) -> Arc<Upstreams> {
85 self.upstreams.borrow().clone()
86 }
87
88 pub fn anthropic_upstream(&self) -> String {
90 self.upstreams.borrow().anthropic.clone()
91 }
92
93 pub fn openai_upstream(&self) -> String {
95 self.upstreams.borrow().openai.clone()
96 }
97
98 pub fn chatgpt_upstream(&self) -> String {
100 self.upstreams.borrow().chatgpt.clone()
101 }
102
103 pub fn gemini_upstream(&self) -> String {
105 self.upstreams.borrow().gemini.clone()
106 }
107
108 pub fn chatgpt_cookie_header(&self) -> Option<String> {
112 let url = reqwest::Url::parse(&self.chatgpt_upstream()).ok()?;
113 self.chatgpt_cookies
114 .cookie_header(&url)
115 .and_then(|v| v.to_str().ok().map(str::to_owned))
116 }
117}
118
119pub struct ProxyStats {
120 pub requests_total: AtomicU64,
121 pub requests_compressed: AtomicU64,
122 pub tokens_saved: AtomicU64,
123 pub bytes_original: AtomicU64,
124 pub bytes_compressed: AtomicU64,
125 pub anthropic: ProviderStats,
126 pub openai: ProviderStats,
127 pub chatgpt: ProviderStats,
128 pub gemini: ProviderStats,
129}
130
131#[derive(Default)]
132pub struct ProviderStats {
133 pub requests_total: AtomicU64,
134 pub requests_compressed: AtomicU64,
135 pub tokens_saved: AtomicU64,
136 pub bytes_original: AtomicU64,
137 pub bytes_compressed: AtomicU64,
138}
139
140impl Default for ProxyStats {
141 fn default() -> Self {
142 Self {
143 requests_total: AtomicU64::new(0),
144 requests_compressed: AtomicU64::new(0),
145 tokens_saved: AtomicU64::new(0),
146 bytes_original: AtomicU64::new(0),
147 bytes_compressed: AtomicU64::new(0),
148 anthropic: ProviderStats::default(),
149 openai: ProviderStats::default(),
150 chatgpt: ProviderStats::default(),
151 gemini: ProviderStats::default(),
152 }
153 }
154}
155
156impl ProxyStats {
157 pub fn record_request(&self, original: usize, compressed: usize) {
158 self.record_totals(original, compressed);
159 }
160
161 pub fn record_provider_request(
162 &self,
163 provider_label: &str,
164 original: usize,
165 compressed: usize,
166 ) {
167 let (effective_compressed, saved_tokens, compressed_request) =
168 self.record_totals(original, compressed);
169
170 if let Some(provider) = self.provider(provider_label) {
171 provider.record(
172 original,
173 effective_compressed,
174 compressed_request,
175 saved_tokens,
176 );
177 }
178 }
179
180 fn record_totals(&self, original: usize, compressed: usize) -> (usize, u64, bool) {
181 self.requests_total.fetch_add(1, Ordering::Relaxed);
182 self.bytes_original
183 .fetch_add(original as u64, Ordering::Relaxed);
184 let effective_compressed = compressed.min(original);
185 self.bytes_compressed
186 .fetch_add(effective_compressed as u64, Ordering::Relaxed);
187 if compressed < original {
188 self.requests_compressed.fetch_add(1, Ordering::Relaxed);
189 }
190 let saved_tokens = (original.saturating_sub(effective_compressed) / 4) as u64;
191 self.tokens_saved.fetch_add(saved_tokens, Ordering::Relaxed);
192 (effective_compressed, saved_tokens, compressed < original)
193 }
194
195 pub fn compression_ratio(&self) -> f64 {
196 let original = self.bytes_original.load(Ordering::Relaxed);
197 if original == 0 {
198 return 0.0;
199 }
200 let compressed = self.bytes_compressed.load(Ordering::Relaxed);
201 (1.0 - compressed as f64 / original as f64) * 100.0
202 }
203
204 fn provider(&self, provider_label: &str) -> Option<&ProviderStats> {
208 match provider_label {
209 "Anthropic" => Some(&self.anthropic),
210 "OpenAI" => Some(&self.openai),
211 "ChatGPT" => Some(&self.chatgpt),
212 "Gemini" => Some(&self.gemini),
213 _ => None,
214 }
215 }
216
217 pub fn provider_summary(&self) -> serde_json::Value {
218 serde_json::json!({
219 "anthropic": self.anthropic.summary(),
220 "openai": self.openai.summary(),
221 "chatgpt": self.chatgpt.summary(),
222 "gemini": self.gemini.summary(),
223 })
224 }
225}
226
227impl ProviderStats {
228 fn record(
229 &self,
230 original: usize,
231 effective_compressed: usize,
232 compressed_request: bool,
233 saved_tokens: u64,
234 ) {
235 self.requests_total.fetch_add(1, Ordering::Relaxed);
236 if compressed_request {
237 self.requests_compressed.fetch_add(1, Ordering::Relaxed);
238 }
239 self.tokens_saved.fetch_add(saved_tokens, Ordering::Relaxed);
240 self.bytes_original
241 .fetch_add(original as u64, Ordering::Relaxed);
242 self.bytes_compressed
243 .fetch_add(effective_compressed as u64, Ordering::Relaxed);
244 }
245
246 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 summary(&self) -> serde_json::Value {
256 serde_json::json!({
257 "requests_total": self.requests_total.load(Ordering::Relaxed),
258 "requests_compressed": self.requests_compressed.load(Ordering::Relaxed),
259 "tokens_saved": self.tokens_saved.load(Ordering::Relaxed),
260 "bytes_original": self.bytes_original.load(Ordering::Relaxed),
261 "bytes_compressed": self.bytes_compressed.load(Ordering::Relaxed),
262 "compression_ratio_pct": format!("{:.1}", self.compression_ratio()),
263 })
264 }
265}
266
267fn connect_timeout_secs() -> u64 {
269 std::env::var("LEAN_CTX_PROXY_CONNECT_TIMEOUT_SECS")
270 .ok()
271 .and_then(|v| v.trim().parse::<u64>().ok())
272 .filter(|s| *s > 0)
273 .unwrap_or(15)
274}
275
276fn read_idle_timeout_secs() -> u64 {
281 std::env::var("LEAN_CTX_PROXY_READ_TIMEOUT_SECS")
282 .ok()
283 .and_then(|v| v.trim().parse::<u64>().ok())
284 .filter(|s| *s > 0)
285 .unwrap_or(300)
286}
287
288fn upstream_reload_secs() -> u64 {
291 std::env::var("LEAN_CTX_PROXY_RELOAD_SECS")
292 .ok()
293 .and_then(|v| v.trim().parse::<u64>().ok())
294 .filter(|s| *s > 0)
295 .unwrap_or(5)
296}
297
298fn spawn_upstream_refresh(tx: tokio::sync::watch::Sender<Arc<Upstreams>>, initial: Upstreams) {
307 let interval = std::time::Duration::from_secs(upstream_reload_secs());
308 tokio::spawn(async move {
309 let mut last = initial;
310 loop {
311 tokio::time::sleep(interval).await;
312 let next = crate::core::config::Config::load()
313 .proxy
314 .refresh_upstreams(&last);
315 if next != last {
316 log_upstream_change(&last, &next);
317 last = next.clone();
318 if tx.send(Arc::new(next)).is_err() {
319 break;
320 }
321 }
322 }
323 });
324}
325
326fn log_upstream_change(old: &Upstreams, new: &Upstreams) {
329 if old.anthropic != new.anthropic {
330 println!(" ↻ Anthropic upstream → {}", new.anthropic);
331 }
332 if old.openai != new.openai {
333 println!(" ↻ OpenAI upstream → {}", new.openai);
334 }
335 if old.chatgpt != new.chatgpt {
336 println!(" ↻ ChatGPT upstream → {}", new.chatgpt);
337 }
338 if old.gemini != new.gemini {
339 println!(" ↻ Gemini upstream → {}", new.gemini);
340 }
341 if old.providers != new.providers {
342 let ids: Vec<&str> = new.providers.iter().map(|p| p.id.as_str()).collect();
343 println!(" ↻ provider registry → [{}]", ids.join(", "));
344 }
345}
346
347pub async fn start_proxy(port: u16) -> anyhow::Result<()> {
348 let token = crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN");
349 start_proxy_with_token(port, Some(token)).await
350}
351
352fn effective_auth_token(auth_token: Option<String>) -> String {
357 auth_token
358 .filter(|t| !t.trim().is_empty())
359 .unwrap_or_else(|| crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN"))
360}
361
362fn install_default_crypto_provider() {
373 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
374}
375
376pub async fn start_proxy_with_token(port: u16, auth_token: Option<String>) -> anyhow::Result<()> {
377 use crate::core::config::{Config, is_local_proxy_url};
378
379 install_default_crypto_provider();
381
382 let auth_token = effective_auth_token(auth_token);
383
384 let chatgpt_cookies = chatgpt_cookies::shared_chatgpt_cloudflare_cookie_store();
389 let client = chatgpt_cookies::with_chatgpt_cloudflare_cookie_store(
390 reqwest::Client::builder(),
391 chatgpt_cookies.clone(),
392 )
393 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs()))
394 .read_timeout(std::time::Duration::from_secs(read_idle_timeout_secs()))
395 .build()?;
396
397 usage_meter::resume_from_disk();
400 cold_prefix::resume_from_disk();
403
404 let cfg = Config::load();
405 let bind_host = cfg.resolved_proxy_bind_host();
407 let loopback_bind = bind_host.is_loopback();
408 let require_token = cfg.proxy_require_token || !loopback_bind;
413 let allowed_hosts: Arc<Vec<String>> = Arc::new(
414 cfg.proxy_allowed_hosts
415 .iter()
416 .map(|h| h.trim().trim_end_matches('.').to_ascii_lowercase())
417 .filter(|h| !h.is_empty())
418 .collect(),
419 );
420 let rate_limiter = match (cfg.proxy_max_rps, loopback_bind) {
424 (Some(rps), _) if rps > 0 => Some(Arc::new(RateLimiter::new(rps, rps.saturating_mul(2)))),
425 (None, false) => Some(Arc::new(RateLimiter::new(50, 100))),
426 _ => None,
427 };
428 let initial = cfg.proxy.resolve_all();
429
430 let (upstream_tx, upstream_rx) = tokio::sync::watch::channel(Arc::new(initial.clone()));
435 spawn_upstream_refresh(upstream_tx, initial.clone());
436
437 let Upstreams {
438 anthropic: anthropic_upstream,
439 openai: openai_upstream,
440 chatgpt: chatgpt_upstream,
441 gemini: gemini_upstream,
442 providers: initial_providers,
443 } = initial;
444
445 let state = ProxyState {
446 client,
447 port,
448 stats: Arc::new(ProxyStats::default()),
449 introspect: Arc::new(introspect::IntrospectState::default()),
450 upstreams: upstream_rx,
451 chatgpt_cookies,
452 };
453
454 #[cfg_attr(not(feature = "gateway-server"), allow(unused_mut))]
456 let mut app = Router::new()
457 .route("/health", get(health))
458 .route("/status", get(status_handler))
459 .route("/v1/messages", any(anthropic::handler))
460 .route("/v1/messages/{*rest}", any(anthropic::handler))
461 .route("/v1/chat/completions", any(openai::handler))
462 .route(
464 "/v1/responses",
465 post(openai_responses::handler).get(openai_responses::ws_handler),
466 )
467 .route("/v1/responses/{*rest}", any(openai_responses::handler))
468 .route("/messages", any(anthropic::handler))
474 .route("/messages/{*rest}", any(anthropic::handler))
475 .route("/chat/completions", any(openai::handler))
476 .route(
477 "/responses",
478 post(openai_responses::handler).get(openai_responses::ws_handler),
479 )
480 .route("/responses/{*rest}", any(openai_responses::handler))
481 .route(
482 "/backend-api/codex/responses",
483 post(chatgpt::codex_responses_handler).get(chatgpt::codex_responses_ws_handler),
484 )
485 .route(
486 "/backend-api/codex/responses/{*rest}",
487 any(chatgpt::codex_responses_handler),
488 )
489 .route("/backend-api", any(chatgpt::backend_api_handler))
492 .route("/backend-api/{*rest}", any(chatgpt::backend_api_handler))
493 .route("/v1/references/{id}", get(v1_resolve_reference))
494 .route("/v1/retrieve/{hash}", get(v1_retrieve_ccr))
497 .route("/v1/models", get(models_api::handler))
502 .route("/models", get(models_api::handler))
503 .route("/v1/compress", post(compress_api::handler))
506 .route("/providers/{id}/{*rest}", any(providers::handler))
510 .fallback(fallback_router);
511
512 #[cfg(feature = "gateway-server")]
516 {
517 app = app.merge(crate::gateway_server::user_api::router());
518 }
519
520 let mut app = app
521 .layer(axum::middleware::from_fn(move |req, next| {
522 let allowed = allowed_hosts.clone();
523 host_guard(req, next, allowed)
524 }))
525 .with_state(state);
526
527 let gateway_keys = match gateway_identity::GatewayKeys::load_default() {
531 Ok(keys) => {
532 if !keys.is_empty() {
533 println!(
534 " Identity: {} gateway key(s) loaded ({})",
535 keys.len(),
536 gateway_identity::GatewayKeys::default_path().display()
537 );
538 }
539 Arc::new(keys)
540 }
541 Err(e) => anyhow::bail!("gateway-keys.toml: {e}"),
542 };
543
544 {
545 let expected = auth_token.clone();
546 let keys = gateway_keys.clone();
547 app = app.layer(axum::middleware::from_fn(move |req, next| {
548 let expected = expected.clone();
549 let keys = keys.clone();
550 proxy_auth_guard(req, next, expected, require_token, keys)
551 }));
552 }
553
554 if let Some(limiter) = rate_limiter {
555 app = app.layer(axum::middleware::from_fn(move |req, next| {
556 let limiter = limiter.clone();
557 rate_limit_guard(req, next, limiter)
558 }));
559 }
560
561 app = app.layer(axum::middleware::from_fn(normalize_provider_path));
565
566 let addr = SocketAddr::from((bind_host, port));
567 println!("lean-ctx proxy listening on http://{addr} (token auth enabled)");
568 if !loopback_bind {
569 println!(
570 " ⚠ gateway mode: non-loopback bind — Bearer token REQUIRED (provider-key \
571 fallback disabled), Host allowlist + rate limit active"
572 );
573 }
574 println!(" Anthropic: POST /v1/messages → {anthropic_upstream}");
575 println!(" OpenAI: POST /v1/chat/completions → {openai_upstream}");
576 println!(
577 " OpenAI: POST /v1/responses → {openai_upstream} (bare /responses also accepted)"
578 );
579 println!(" ChatGPT: POST /backend-api/codex/responses → {chatgpt_upstream}");
580 println!(" ChatGPT: any /backend-api/* → {chatgpt_upstream}");
581 println!(" Gemini: POST /v1beta/models/... → {gemini_upstream}");
582 println!(" Compress: POST /v1/compress (deterministic messages-in/out, local)");
583 println!(
587 " Codex: WS ws://{addr}/responses → bridged to {openai_upstream} (HTTP/SSE, #440)"
588 );
589 for p in &initial_providers {
590 println!(
591 " Provider: any /providers/{}/... → {} ({} shape{})",
592 p.id,
593 p.base_url,
594 p.shape.as_str(),
595 if p.api_key_env.is_some() {
596 ", gateway-held key"
597 } else {
598 ""
599 }
600 );
601 }
602 if openai_upstream.starts_with("http://") && !is_local_proxy_url(&openai_upstream) {
603 println!(
604 " ⚠ OpenAI upstream is plaintext HTTP to a non-loopback host \
605 (allow_insecure_http_upstream) — use only on a trusted local network"
606 );
607 }
608
609 let listener = tokio::net::TcpListener::bind(addr).await?;
610 axum::serve(listener, app)
611 .with_graceful_shutdown(shutdown_signal())
612 .await?;
613
614 println!("lean-ctx proxy shut down cleanly.");
615 Ok(())
616}
617
618async fn shutdown_signal() {
619 let ctrl_c = tokio::signal::ctrl_c();
620
621 #[cfg(unix)]
622 {
623 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
626 Ok(mut sigterm) => {
627 tokio::select! {
628 _ = ctrl_c => {},
629 _ = sigterm.recv() => {},
630 }
631 }
632 Err(e) => {
633 tracing::warn!("lean-ctx proxy: SIGTERM handler unavailable ({e}); Ctrl-C only");
634 ctrl_c.await.ok();
635 }
636 }
637 }
638
639 #[cfg(not(unix))]
640 {
641 ctrl_c.await.ok();
642 }
643
644 println!("lean-ctx proxy: received shutdown signal, draining…");
645}
646
647async fn health() -> impl IntoResponse {
648 let body = serde_json::json!({
649 "status": "ok",
650 "pid": std::process::id(),
651 });
652 (StatusCode::OK, axum::Json(body))
653}
654
655async fn v1_resolve_reference(
656 axum::extract::Path(id): axum::extract::Path<String>,
657) -> impl IntoResponse {
658 match crate::server::reference_store::resolve(&id) {
659 Some(content) => (StatusCode::OK, content),
660 None => (
661 StatusCode::NOT_FOUND,
662 "Reference expired or not found".to_string(),
663 ),
664 }
665}
666
667async fn v1_retrieve_ccr(
677 axum::extract::Path(hash): axum::extract::Path<String>,
678) -> impl IntoResponse {
679 match ccr::retrieve_litellm(&hash) {
680 Some(content) => (
681 StatusCode::OK,
682 axum::Json(serde_json::json!({ "original_content": content })),
683 ),
684 None => (
685 StatusCode::NOT_FOUND,
686 axum::Json(serde_json::json!({
687 "error": "hash not found or expired",
688 "hash": hash,
689 })),
690 ),
691 }
692}
693
694async fn status_handler(State(state): State<ProxyState>) -> impl IntoResponse {
695 use std::sync::atomic::Ordering::Relaxed;
696 let s = &state.stats;
697 let i = &state.introspect;
698
699 let last_breakdown = i
700 .last_breakdown
701 .lock()
702 .ok()
703 .and_then(|guard| guard.as_ref().map(|b| serde_json::to_value(b).ok()))
704 .flatten();
705
706 let spend = usage_meter::snapshot();
707 let spend_total: f64 = spend.iter().map(|m| m.cost_usd).sum();
708
709 let up = state.upstream_snapshot();
713
714 let active_effort = crate::core::config::Config::load().proxy.resolved_effort();
717
718 let body = serde_json::json!({
719 "status": "running",
720 "port": state.port,
721 "upstreams": {
722 "anthropic": up.anthropic.clone(),
723 "openai": up.openai.clone(),
724 "chatgpt": up.chatgpt.clone(),
725 "gemini": up.gemini.clone(),
726 },
727 "providers": up.providers.iter().map(|p| serde_json::json!({
730 "id": p.id,
731 "shape": p.shape.as_str(),
732 "base_url": p.base_url,
733 "gateway_key": p.api_key_env.is_some(),
734 })).collect::<Vec<_>>(),
735 "requests_total": s.requests_total.load(Relaxed),
736 "requests_compressed": s.requests_compressed.load(Relaxed),
737 "tokens_saved": s.tokens_saved.load(Relaxed),
738 "tokens_saved_estimated": true,
739 "verified_savings": usage_meter::verified_savings(),
743 "bytes_original": s.bytes_original.load(Relaxed),
744 "bytes_compressed": s.bytes_compressed.load(Relaxed),
745 "compression_ratio_pct": format!("{:.1}", s.compression_ratio()),
746 "per_upstream": s.provider_summary(),
747 "cache_safety": cache_safety::snapshot(),
748 "cache_attribution": cache_attribution::snapshot(),
749 "effort": effort::snapshot(active_effort),
750 "per_model": cost::snapshot(),
751 "spend": {
752 "source": "measured",
753 "total_usd": spend_total,
754 "per_model": spend,
755 "note": "Actual provider bill: real model + billed tokens (incl. cache reads/writes & reasoning) read from upstream responses for proxy-routed clients."
756 },
757 "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.",
758 "introspect": {
759 "total_requests_analyzed": i.total_requests.load(Relaxed),
760 "total_system_prompt_tokens": i.total_system_prompt_tokens.load(Relaxed),
761 "last_breakdown": last_breakdown,
762 }
763 });
764 (StatusCode::OK, axum::Json(body))
765}
766
767#[allow(clippy::result_large_err)]
768async fn proxy_auth_guard(
769 mut req: axum::extract::Request,
770 next: axum::middleware::Next,
771 expected_token: String,
772 require_token: bool,
773 gateway_keys: Arc<gateway_identity::GatewayKeys>,
774) -> Result<Response, Response> {
775 let path = req.uri().path();
776 if path == "/health" || me_shell_path(path) {
777 return Ok(next.run(req).await);
778 }
779
780 let bearer = req
781 .headers()
782 .get("authorization")
783 .and_then(|v| v.to_str().ok())
784 .and_then(|auth| auth.strip_prefix("Bearer "))
785 .map(str::to_string);
786
787 if let Some(token) = bearer.as_deref()
788 && constant_time_eq(token.as_bytes(), expected_token.as_bytes())
789 {
790 attach_gateway_tags(&mut req, gateway_identity::GatewayTags::default());
791 return Ok(next.run(req).await);
792 }
793
794 if let Some(token) = bearer.as_deref()
798 && let Some(tags) = gateway_keys.lookup(token)
799 {
800 attach_gateway_tags(&mut req, tags);
801 return Ok(next.run(req).await);
802 }
803
804 if provider_key_fallback_allowed(
810 require_token,
811 has_provider_api_key(&req),
812 is_provider_route(path),
813 ) {
814 attach_gateway_tags(&mut req, gateway_identity::GatewayTags::default());
815 return Ok(next.run(req).await);
816 }
817
818 let cfg = crate::core::config::Config::load();
819 let hint = match cfg.proxy_enabled {
820 Some(true) => {
821 "lean-ctx proxy requires authentication. Use a Bearer token (LEAN_CTX_PROXY_TOKEN) or configure your AI tool's API key."
822 }
823 Some(false) => "lean-ctx proxy is disabled but still running. Run: lean-ctx proxy cleanup",
824 None => {
825 "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"
826 }
827 };
828
829 let body = serde_json::json!({
830 "type": "error",
831 "error": {
832 "type": "authentication_error",
833 "message": format!("401 Unauthorized — {hint}")
834 }
835 });
836
837 Err((StatusCode::UNAUTHORIZED, axum::Json(body)).into_response())
838}
839
840fn attach_gateway_tags(req: &mut axum::extract::Request, mut tags: gateway_identity::GatewayTags) {
849 if let Some(project) = req
850 .headers()
851 .get("x-leanctx-project")
852 .and_then(|v| v.to_str().ok())
853 .map(str::trim)
854 .filter(|p| !p.is_empty() && p.len() <= 128 && !p.chars().any(char::is_control))
855 {
856 tags.project = Some(project.to_string());
857 }
858 if let Some(person) = tags.person.as_deref()
862 && pii::enabled()
863 {
864 tags.person = Some(pii::pseudonymize(person));
865 }
866 if !tags.is_empty() {
867 req.extensions_mut().insert(tags);
868 }
869}
870
871fn me_shell_path(path: &str) -> bool {
875 #[cfg(feature = "gateway-server")]
876 {
877 crate::gateway_server::user_api::is_shell_path(path)
878 }
879 #[cfg(not(feature = "gateway-server"))]
880 {
881 let _ = path;
882 false
883 }
884}
885
886fn has_provider_api_key(req: &axum::extract::Request) -> bool {
887 let headers = req.headers();
888 for key in ["x-api-key", "x-goog-api-key", "api-key"] {
891 if headers
892 .get(key)
893 .and_then(|v| v.to_str().ok())
894 .is_some_and(|v| !v.trim().is_empty())
895 {
896 return true;
897 }
898 }
899 if let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
908 let auth = auth.trim();
909 let credential = auth
910 .strip_prefix("Bearer ")
911 .or_else(|| auth.strip_prefix("bearer "))
912 .unwrap_or(auth)
913 .trim();
914 return !credential.is_empty() && !credential.eq_ignore_ascii_case("bearer");
916 }
917 false
918}
919
920fn is_provider_route(path: &str) -> bool {
921 path.starts_with("/v1/")
922 || path.starts_with("/v1beta/")
923 || path.starts_with("/chat/completions")
924 || path.starts_with("/responses")
925 || path.starts_with("/messages")
926 || path.starts_with("/backend-api")
927 || path == "/models"
930}
931
932fn provider_key_fallback_allowed(
941 require_token: bool,
942 has_provider_key: bool,
943 is_provider_route: bool,
944) -> bool {
945 !require_token && has_provider_key && is_provider_route
946}
947
948fn canonical_provider_path(path: &str) -> Option<String> {
959 if let Some(rest) = path.strip_prefix("/v1/v1/") {
963 return Some(format!("/v1/{rest}"));
964 }
965 const BARE_TO_CANONICAL: &[(&str, &str, &str)] = &[
966 ("/responses", "/v1/responses", "/responses/"),
967 (
968 "/chat/completions",
969 "/v1/chat/completions",
970 "/chat/completions/",
971 ),
972 ("/messages", "/v1/messages", "/messages/"),
973 ];
974 for (bare, canonical, bare_with_slash) in BARE_TO_CANONICAL {
975 if path == *bare {
976 return Some((*canonical).to_string());
977 }
978 if let Some(rest) = path.strip_prefix(bare_with_slash) {
979 return Some(format!("{canonical}/{rest}"));
980 }
981 }
982 None
983}
984
985fn normalized_provider_uri(uri: &axum::http::Uri) -> Option<axum::http::Uri> {
989 let canonical = canonical_provider_path(uri.path())?;
990 let new_path_and_query = match uri.query() {
991 Some(q) => format!("{canonical}?{q}"),
992 None => canonical,
993 };
994 new_path_and_query.parse::<axum::http::Uri>().ok()
995}
996
997async fn normalize_provider_path(
1001 mut req: axum::extract::Request,
1002 next: axum::middleware::Next,
1003) -> Response {
1004 if let Some(uri) = normalized_provider_uri(req.uri()) {
1005 *req.uri_mut() = uri;
1006 }
1007 next.run(req).await
1008}
1009
1010fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
1011 use subtle::ConstantTimeEq;
1012 if a.len() != b.len() {
1013 return false;
1014 }
1015 bool::from(a.ct_eq(b))
1016}
1017
1018async fn host_guard(
1019 req: axum::extract::Request,
1020 next: axum::middleware::Next,
1021 allowed_hosts: Arc<Vec<String>>,
1022) -> Result<Response, StatusCode> {
1023 if let Some(host) = req.headers().get("host").and_then(|v| v.to_str().ok())
1024 && host_allowed(host, &allowed_hosts)
1025 {
1026 return Ok(next.run(req).await);
1027 }
1028 Err(StatusCode::FORBIDDEN)
1029}
1030
1031fn host_allowed(host_header: &str, allowed: &[String]) -> bool {
1036 let host = host_header.trim();
1038 let h = if let Some(bracketed) = host.strip_prefix('[') {
1039 bracketed
1040 .split(']')
1041 .next()
1042 .map(|inner| format!("[{inner}]"))
1043 } else {
1044 host.split(':').next().map(str::to_string)
1045 };
1046 let Some(h) = h else {
1047 return false;
1048 };
1049 let h = h.trim_end_matches('.').to_ascii_lowercase();
1050 matches!(h.as_str(), "127.0.0.1" | "localhost" | "[::1]") || allowed.contains(&h)
1051}
1052
1053async fn rate_limit_guard(
1056 req: axum::extract::Request,
1057 next: axum::middleware::Next,
1058 limiter: Arc<RateLimiter>,
1059) -> Result<Response, StatusCode> {
1060 if req.uri().path() != "/health" && !limiter.allow().await {
1061 return Err(StatusCode::TOO_MANY_REQUESTS);
1062 }
1063 Ok(next.run(req).await)
1064}
1065
1066pub(crate) struct RateLimiter {
1070 max_rps: f64,
1071 burst: f64,
1072 state: tokio::sync::Mutex<RateLimiterState>,
1073}
1074
1075struct RateLimiterState {
1076 tokens: f64,
1077 last: std::time::Instant,
1078}
1079
1080impl RateLimiter {
1081 pub(crate) fn new(max_rps: u32, burst: u32) -> Self {
1082 Self {
1083 max_rps: f64::from(max_rps.max(1)),
1084 burst: f64::from(burst.max(1)),
1085 state: tokio::sync::Mutex::new(RateLimiterState {
1086 tokens: f64::from(burst.max(1)),
1087 last: std::time::Instant::now(),
1088 }),
1089 }
1090 }
1091
1092 pub(crate) async fn allow(&self) -> bool {
1093 let mut s = self.state.lock().await;
1094 let now = std::time::Instant::now();
1095 let refill = now.saturating_duration_since(s.last).as_secs_f64() * self.max_rps;
1096 s.tokens = (s.tokens + refill).min(self.burst);
1097 s.last = now;
1098 if s.tokens >= 1.0 {
1099 s.tokens -= 1.0;
1100 true
1101 } else {
1102 false
1103 }
1104 }
1105}
1106
1107async fn fallback_router(State(state): State<ProxyState>, req: Request<Body>) -> Response {
1108 let path = req.uri().path().to_string();
1109
1110 if path.starts_with("/v1beta/models/") || path.starts_with("/v1/models/") {
1111 match google::handler(State(state), req).await {
1112 Ok(resp) => resp,
1113 Err(status) => Response::builder()
1114 .status(status)
1115 .body(Body::from("proxy error"))
1116 .expect("BUG: building error response with valid status should never fail"),
1117 }
1118 } else {
1119 let method = req.method().to_string();
1120 eprintln!("lean-ctx proxy: unmatched {method} {path}");
1121 Response::builder()
1122 .status(StatusCode::NOT_FOUND)
1123 .body(Body::from(format!(
1124 "lean-ctx proxy: no handler for {method} {path}"
1125 )))
1126 .expect("BUG: building 404 response should never fail")
1127 }
1128}