1pub mod anthropic;
19#[cfg(test)]
20mod auth_tests;
21pub mod bedrock;
22pub mod break_even;
23pub mod cache_aligner;
24pub mod cache_attribution;
25pub mod cache_breakpoint;
26pub mod cache_policy;
27pub mod cache_safety;
28pub mod ccr;
29#[cfg(test)]
30mod ccr_robustness_tests;
31pub mod chatgpt;
32pub mod chatgpt_cookies;
33pub mod chatgpt_ws;
34mod codec;
35pub mod cold_prefix;
36pub mod compress;
37pub mod compress_api;
38pub mod compress_shared;
39mod connector;
40pub(crate) mod conversation;
41pub mod cost;
42pub mod counterfactual;
43#[allow(dead_code)]
44pub mod dedup;
45pub mod effort;
46pub mod effort_routing;
47pub mod forward;
48#[cfg(feature = "enterprise")]
49pub mod gateway_identity;
50pub mod google;
51pub mod history_prune;
52pub mod holdout;
53pub mod image_compression;
54mod intent;
55pub mod introspect;
56pub mod latency_guard;
57mod lineage;
58pub mod metrics;
59pub mod model_router;
60pub mod models_api;
61pub mod ocla_cache_bridge;
62pub mod openai;
63pub mod openai_responses;
64pub mod openai_responses_ws;
65pub mod output_savings;
66pub mod pii;
67#[cfg(feature = "enterprise")]
68pub mod policy_gate;
69pub mod prefix_cache_stats;
70pub mod prefix_replay;
71pub mod prose;
72pub mod prose_ranker;
73pub mod providers;
74pub mod quality_lab_api;
75pub mod response_optimizer;
76#[allow(dead_code)]
77pub(crate) mod response_shaper;
78pub mod routing;
79pub mod routing_feedback;
80#[cfg(feature = "shape-xlat")]
81pub mod shape_xlat;
82#[allow(dead_code)]
83pub(crate) mod shaping_hook;
84pub mod sse_keepalive;
85#[cfg(test)]
86mod stats_tests;
87pub mod sticky_tools;
88pub mod tool_kind;
89#[allow(dead_code)]
90pub mod tool_output;
91#[cfg(test)]
92mod upstream_tests;
93pub mod usage;
94pub mod usage_accounting;
95pub mod usage_azure;
96pub mod usage_meter;
97pub mod usage_parity;
98pub mod usage_sink;
99pub mod verbosity;
100pub mod web_app;
101#[allow(dead_code)]
102pub(crate) mod web_app_middleware;
103
104use std::net::SocketAddr;
105use std::sync::Arc;
106use std::sync::atomic::{AtomicU64, Ordering};
107
108use crate::core::config::Upstreams;
109
110use axum::{
111 Router,
112 body::Body,
113 extract::State,
114 http::{Request, StatusCode},
115 response::{IntoResponse, Response},
116 routing::{any, get, post},
117};
118
119#[derive(Clone)]
120pub struct ProxyState {
121 pub client: reqwest::Client,
122 pub port: u16,
123 pub stats: Arc<ProxyStats>,
124 pub(crate) break_even: Arc<break_even::BreakEvenCalculator>,
125 pub introspect: Arc<introspect::IntrospectState>,
126 pub ocla_cache: Option<Arc<ocla_cache_bridge::OclaCacheBridge>>,
127 pub upstreams: tokio::sync::watch::Receiver<Arc<Upstreams>>,
130 pub(crate) chatgpt_cookies: Arc<chatgpt_cookies::ChatGptCloudflareCookieStore>,
134 pub mcp_servers: Arc<Vec<crate::core::config::ResolvedMcpServer>>,
139 pub(crate) web_app_tracker:
140 Arc<std::sync::Mutex<web_app::conversation_tracker::ConversationTracker>>,
141}
142
143impl ProxyState {
144 pub fn upstream_snapshot(&self) -> Arc<Upstreams> {
146 self.upstreams.borrow().clone()
147 }
148
149 pub(crate) fn break_even_summary(&self) -> break_even::BreakEvenSummary {
151 self.break_even.summary()
152 }
153
154 pub fn anthropic_upstream(&self) -> String {
156 self.upstreams.borrow().anthropic.clone()
157 }
158
159 pub fn openai_upstream(&self) -> String {
161 self.upstreams.borrow().openai.clone()
162 }
163
164 pub fn chatgpt_upstream(&self) -> String {
166 self.upstreams.borrow().chatgpt.clone()
167 }
168
169 pub fn gemini_upstream(&self) -> String {
171 self.upstreams.borrow().gemini.clone()
172 }
173
174 pub fn chatgpt_cookie_header(&self) -> Option<String> {
178 let url = reqwest::Url::parse(&self.chatgpt_upstream()).ok()?;
179 self.chatgpt_cookies
180 .cookie_header(&url)
181 .and_then(|v| v.to_str().ok().map(str::to_owned))
182 }
183}
184
185pub struct ProxyStats {
186 pub requests_total: AtomicU64,
187 pub requests_compressed: AtomicU64,
188 pub tokens_saved: AtomicU64,
189 pub bytes_original: AtomicU64,
190 pub bytes_compressed: AtomicU64,
191 pub anthropic: ProviderStats,
192 pub openai: ProviderStats,
193 pub chatgpt: ProviderStats,
194 pub gemini: ProviderStats,
195 pub grok: ProviderStats,
199 pub commandcode: ProviderStats,
203}
204
205#[derive(Default)]
206pub struct ProviderStats {
207 pub requests_total: AtomicU64,
208 pub requests_compressed: AtomicU64,
209 pub tokens_saved: AtomicU64,
210 pub bytes_original: AtomicU64,
211 pub bytes_compressed: AtomicU64,
212}
213
214impl Default for ProxyStats {
215 fn default() -> Self {
216 Self {
217 requests_total: AtomicU64::new(0),
218 requests_compressed: AtomicU64::new(0),
219 tokens_saved: AtomicU64::new(0),
220 bytes_original: AtomicU64::new(0),
221 bytes_compressed: AtomicU64::new(0),
222 anthropic: ProviderStats::default(),
223 openai: ProviderStats::default(),
224 chatgpt: ProviderStats::default(),
225 gemini: ProviderStats::default(),
226 grok: ProviderStats::default(),
227 commandcode: ProviderStats::default(),
228 }
229 }
230}
231
232impl ProxyStats {
233 pub fn record_request(&self, original: usize, compressed: usize) {
234 self.record_totals(original, compressed);
235 }
236
237 pub fn record_provider_request(
238 &self,
239 provider_label: &str,
240 original: usize,
241 compressed: usize,
242 ) {
243 let (effective_compressed, saved_tokens, compressed_request) =
244 self.record_totals(original, compressed);
245
246 if let Some(provider) = self.provider(provider_label) {
247 provider.record(
248 original,
249 effective_compressed,
250 compressed_request,
251 saved_tokens,
252 );
253 }
254 }
255
256 fn record_totals(&self, original: usize, compressed: usize) -> (usize, u64, bool) {
257 self.requests_total.fetch_add(1, Ordering::Relaxed);
258 self.bytes_original
259 .fetch_add(original as u64, Ordering::Relaxed);
260 let effective_compressed = compressed.min(original);
261 self.bytes_compressed
262 .fetch_add(effective_compressed as u64, Ordering::Relaxed);
263 if compressed < original {
264 self.requests_compressed.fetch_add(1, Ordering::Relaxed);
265 }
266 let saved_tokens = (original.saturating_sub(effective_compressed) / 4) as u64;
267 self.tokens_saved.fetch_add(saved_tokens, Ordering::Relaxed);
268 (effective_compressed, saved_tokens, compressed < original)
269 }
270
271 pub fn compression_ratio(&self) -> f64 {
272 let original = self.bytes_original.load(Ordering::Relaxed);
273 if original == 0 {
274 return 0.0;
275 }
276 let compressed = self.bytes_compressed.load(Ordering::Relaxed);
277 (1.0 - compressed as f64 / original as f64) * 100.0
278 }
279
280 fn provider(&self, provider_label: &str) -> Option<&ProviderStats> {
287 match provider_label {
288 "Anthropic" => Some(&self.anthropic),
289 "OpenAI" => Some(&self.openai),
290 "ChatGPT" => Some(&self.chatgpt),
291 "Gemini" => Some(&self.gemini),
292 "Grok" => Some(&self.grok),
293 "CommandCode" => Some(&self.commandcode),
294 _ => None,
295 }
296 }
297
298 pub fn provider_summary(&self) -> serde_json::Value {
299 serde_json::json!({
300 "anthropic": self.anthropic.summary(),
301 "openai": self.openai.summary(),
302 "chatgpt": self.chatgpt.summary(),
303 "gemini": self.gemini.summary(),
304 "grok": self.grok.summary(),
305 "commandcode": self.commandcode.summary(),
306 })
307 }
308}
309
310impl ProviderStats {
311 fn record(
312 &self,
313 original: usize,
314 effective_compressed: usize,
315 compressed_request: bool,
316 saved_tokens: u64,
317 ) {
318 self.requests_total.fetch_add(1, Ordering::Relaxed);
319 if compressed_request {
320 self.requests_compressed.fetch_add(1, Ordering::Relaxed);
321 }
322 self.tokens_saved.fetch_add(saved_tokens, Ordering::Relaxed);
323 self.bytes_original
324 .fetch_add(original as u64, Ordering::Relaxed);
325 self.bytes_compressed
326 .fetch_add(effective_compressed as u64, Ordering::Relaxed);
327 }
328
329 fn compression_ratio(&self) -> f64 {
330 let original = self.bytes_original.load(Ordering::Relaxed);
331 if original == 0 {
332 return 0.0;
333 }
334 let compressed = self.bytes_compressed.load(Ordering::Relaxed);
335 (1.0 - compressed as f64 / original as f64) * 100.0
336 }
337
338 fn summary(&self) -> serde_json::Value {
339 serde_json::json!({
340 "requests_total": self.requests_total.load(Ordering::Relaxed),
341 "requests_compressed": self.requests_compressed.load(Ordering::Relaxed),
342 "tokens_saved": self.tokens_saved.load(Ordering::Relaxed),
343 "bytes_original": self.bytes_original.load(Ordering::Relaxed),
344 "bytes_compressed": self.bytes_compressed.load(Ordering::Relaxed),
345 "compression_ratio_pct": format!("{:.1}", self.compression_ratio()),
346 })
347 }
348}
349
350fn connect_timeout_secs() -> u64 {
352 std::env::var("LEAN_CTX_PROXY_CONNECT_TIMEOUT_SECS")
353 .ok()
354 .and_then(|v| v.trim().parse::<u64>().ok())
355 .filter(|s| *s > 0)
356 .unwrap_or(15)
357}
358
359fn read_idle_timeout_secs() -> u64 {
364 std::env::var("LEAN_CTX_PROXY_READ_TIMEOUT_SECS")
365 .ok()
366 .and_then(|v| v.trim().parse::<u64>().ok())
367 .filter(|s| *s > 0)
368 .unwrap_or(300)
369}
370
371fn upstream_reload_secs() -> u64 {
374 std::env::var("LEAN_CTX_PROXY_RELOAD_SECS")
375 .ok()
376 .and_then(|v| v.trim().parse::<u64>().ok())
377 .filter(|s| *s > 0)
378 .unwrap_or(5)
379}
380
381fn spawn_upstream_refresh(tx: tokio::sync::watch::Sender<Arc<Upstreams>>, initial: Upstreams) {
390 let interval = std::time::Duration::from_secs(upstream_reload_secs());
391 tokio::spawn(async move {
392 let mut last = initial;
393 loop {
394 tokio::time::sleep(interval).await;
395 let next = crate::core::config::Config::load()
396 .proxy
397 .refresh_upstreams(&last);
398 if next != last {
399 log_upstream_change(&last, &next);
400 last = next.clone();
401 if tx.send(Arc::new(next)).is_err() {
402 break;
403 }
404 }
405 }
406 });
407}
408
409fn log_upstream_change(old: &Upstreams, new: &Upstreams) {
412 if old.anthropic != new.anthropic {
413 println!(" ↻ Anthropic upstream → {}", new.anthropic);
414 }
415 if old.openai != new.openai {
416 println!(" ↻ OpenAI upstream → {}", new.openai);
417 }
418 if old.chatgpt != new.chatgpt {
419 println!(" ↻ ChatGPT upstream → {}", new.chatgpt);
420 }
421 if old.gemini != new.gemini {
422 println!(" ↻ Gemini upstream → {}", new.gemini);
423 }
424 if old.providers != new.providers {
425 let ids: Vec<&str> = new.providers.iter().map(|p| p.id.as_str()).collect();
426 println!(" ↻ provider registry → [{}]", ids.join(", "));
427 }
428}
429
430pub async fn start_proxy(port: u16) -> anyhow::Result<()> {
431 let token = crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN");
432 start_proxy_with_token(port, Some(token)).await
433}
434
435fn effective_auth_token(auth_token: Option<String>) -> String {
440 auth_token
441 .filter(|t| !t.trim().is_empty())
442 .unwrap_or_else(|| crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN"))
443}
444
445fn install_default_crypto_provider() {
456 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
457}
458
459pub async fn start_proxy_with_token(port: u16, auth_token: Option<String>) -> anyhow::Result<()> {
460 use crate::core::config::{Config, is_local_proxy_url};
461
462 install_default_crypto_provider();
464
465 let auth_token = effective_auth_token(auth_token);
466
467 let chatgpt_cookies = chatgpt_cookies::shared_chatgpt_cloudflare_cookie_store();
472 let client = chatgpt_cookies::with_chatgpt_cloudflare_cookie_store(
473 reqwest::Client::builder(),
474 chatgpt_cookies.clone(),
475 )
476 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs()))
477 .read_timeout(std::time::Duration::from_secs(read_idle_timeout_secs()))
478 .build()?;
479
480 usage_meter::resume_from_disk();
483 crate::core::gain::live_pricing::spawn_background_refresh();
488 cold_prefix::resume_from_disk();
491
492 let cfg = Config::load();
493 let break_even_schema_overhead = match cfg.tool_profile_effective() {
494 crate::core::tool_profiles::ToolProfile::Minimal
495 | crate::core::tool_profiles::ToolProfile::Auto => 1500,
496 crate::core::tool_profiles::ToolProfile::Standard => 3000,
497 crate::core::tool_profiles::ToolProfile::Power
498 | crate::core::tool_profiles::ToolProfile::Custom(_) => 5000,
499 };
500 let bind_host = cfg.resolved_proxy_bind_host();
502 let loopback_bind = bind_host.is_loopback();
503 let require_token = cfg.proxy_require_token || !loopback_bind;
508 let loopback_open = cfg.proxy_loopback_open && loopback_bind;
509 let allowed_hosts: Arc<Vec<String>> = Arc::new(
510 cfg.proxy_allowed_hosts
511 .iter()
512 .map(|h| h.trim().trim_end_matches('.').to_ascii_lowercase())
513 .filter(|h| !h.is_empty())
514 .collect(),
515 );
516 let rate_limiter = match (cfg.proxy_max_rps, loopback_bind) {
520 (Some(rps), _) if rps > 0 => Some(Arc::new(RateLimiter::new(rps, rps.saturating_mul(2)))),
521 (None, false) => Some(Arc::new(RateLimiter::new(50, 100))),
522 _ => None,
523 };
524 let initial = cfg.proxy.resolve_all();
525
526 let (upstream_tx, upstream_rx) = tokio::sync::watch::channel(Arc::new(initial.clone()));
531 let auth_upstreams = upstream_rx.clone();
534 spawn_upstream_refresh(upstream_tx, initial.clone());
535
536 let Upstreams {
537 anthropic: anthropic_upstream,
538 openai: openai_upstream,
539 chatgpt: chatgpt_upstream,
540 gemini: gemini_upstream,
541 providers: initial_providers,
542 } = initial;
543
544 let mcp_servers = Arc::new(
549 cfg.gateway_server
550 .resolve_mcp_servers(cfg.proxy.allows_insecure_http_upstream()),
551 );
552
553 let state = ProxyState {
554 client,
555 port,
556 stats: Arc::new(ProxyStats::default()),
557 break_even: Arc::new(break_even::BreakEvenCalculator::new(
558 break_even_schema_overhead,
559 )),
560 introspect: Arc::new(introspect::IntrospectState::default()),
561 ocla_cache: None,
562 upstreams: upstream_rx,
563 chatgpt_cookies,
564 mcp_servers: mcp_servers.clone(),
565 web_app_tracker: Arc::new(std::sync::Mutex::new(
566 web_app::conversation_tracker::ConversationTracker::default(),
567 )),
568 };
569
570 let app = Router::new()
571 .route("/health", get(health))
572 .route("/status", get(status_handler))
573 .route("/v1/messages", any(anthropic::handler))
574 .route("/v1/messages/{*rest}", any(anthropic::handler))
575 .route("/v1/chat/completions", any(openai::handler))
576 .route(
578 "/v1/responses",
579 post(openai_responses::handler).get(openai_responses::ws_handler),
580 )
581 .route("/v1/responses/{*rest}", any(openai_responses::handler))
582 .route("/messages", any(anthropic::handler))
588 .route("/messages/{*rest}", any(anthropic::handler))
589 .route("/chat/completions", any(openai::handler))
590 .route(
591 "/responses",
592 post(openai_responses::handler).get(openai_responses::ws_handler),
593 )
594 .route("/responses/{*rest}", any(openai_responses::handler))
595 .route(
596 "/backend-api/codex/responses",
597 post(chatgpt::codex_responses_handler).get(chatgpt::codex_responses_ws_handler),
598 )
599 .route(
600 "/backend-api/codex/responses/{*rest}",
601 any(chatgpt::codex_responses_handler),
602 )
603 .route("/backend-api", any(chatgpt::backend_api_handler))
606 .route("/backend-api/{*rest}", any(chatgpt::backend_api_handler))
607 .route("/v1/references/{id}", get(v1_resolve_reference))
608 .route("/v1/retrieve/{hash}", get(v1_retrieve_ccr))
611 .route("/v1/models", get(models_api::handler))
616 .route("/models", get(models_api::handler))
617 .route("/v1/compress", post(compress_api::handler))
620 .route(
621 "/v1/quality-lab",
622 post(quality_lab_api::handler).get(quality_lab_api::handler_get),
623 )
624 .route("/providers/{id}/{*rest}", any(providers::handler))
628 .fallback(fallback_router);
629
630 let mut app = app
631 .layer(axum::middleware::from_fn(
632 web_app_middleware::detect_web_app,
633 ))
634 .layer(axum::middleware::from_fn(move |req, next| {
635 let allowed = allowed_hosts.clone();
636 host_guard(req, next, allowed)
637 }))
638 .with_state(state);
639
640 #[cfg(feature = "enterprise")]
644 let gateway_keys = match gateway_identity::GatewayKeys::load_default() {
645 Ok(keys) => {
646 if !keys.is_empty() {
647 println!(
648 " Identity: {} gateway key(s) loaded ({})",
649 keys.len(),
650 gateway_identity::GatewayKeys::default_path().display()
651 );
652 }
653 Arc::new(keys)
654 }
655 Err(e) => anyhow::bail!("gateway-keys.toml: {e}"),
656 };
657
658 #[cfg(feature = "enterprise")]
659 {
660 let expected = auth_token.clone();
661 let keys = gateway_keys.clone();
662 let upstreams = auth_upstreams;
663 app = app.layer(axum::middleware::from_fn(move |req, next| {
664 let expected = expected.clone();
665 let keys = keys.clone();
666 let upstreams = upstreams.clone();
667 proxy_auth_guard(
668 req,
669 next,
670 expected,
671 require_token,
672 loopback_open,
673 keys,
674 upstreams,
675 )
676 }));
677 }
678
679 #[cfg(not(feature = "enterprise"))]
680 {
681 let expected = auth_token.clone();
682 let upstreams = auth_upstreams;
683 app = app.layer(axum::middleware::from_fn(move |req, next| {
684 let expected = expected.clone();
685 let upstreams = upstreams.clone();
686 proxy_auth_guard(req, next, expected, require_token, loopback_open, upstreams)
687 }));
688 }
689
690 if let Some(limiter) = rate_limiter {
691 app = app.layer(axum::middleware::from_fn(move |req, next| {
692 let limiter = limiter.clone();
693 rate_limit_guard(req, next, limiter)
694 }));
695 }
696
697 app = app.layer(axum::middleware::from_fn(normalize_provider_path));
701
702 let addr = SocketAddr::from((bind_host, port));
703 if loopback_open {
704 println!("lean-ctx proxy listening on http://{addr} (loopback-open: auth disabled)");
705 } else {
706 println!("lean-ctx proxy listening on http://{addr} (token auth enabled)");
707 }
708 if !loopback_bind {
709 println!(
710 " ⚠ gateway mode: non-loopback bind — Bearer token REQUIRED for gateway-held \
711 keys and non-registry routes; client-auth /providers/* (api_key_env unset) \
712 still accept provider credentials. Host allowlist + rate limit active"
713 );
714 }
715 println!(" Anthropic: POST /v1/messages → {anthropic_upstream}");
716 println!(" OpenAI: POST /v1/chat/completions → {openai_upstream}");
717 println!(
718 " OpenAI: POST /v1/responses → {openai_upstream} (bare /responses also accepted)"
719 );
720 println!(" ChatGPT: POST /backend-api/codex/responses → {chatgpt_upstream}");
721 println!(" ChatGPT: any /backend-api/* → {chatgpt_upstream}");
722 println!(" Gemini: POST /v1beta/models/... → {gemini_upstream}");
723 println!(" Compress: POST /v1/compress (deterministic messages-in/out, local)");
724 println!(
728 " Codex: WS ws://{addr}/responses → bridged to {openai_upstream} (HTTP/SSE, #440)"
729 );
730 for p in &initial_providers {
731 println!(
732 " Provider: any /providers/{}/... → {} ({} shape{})",
733 p.id,
734 p.base_url,
735 p.shape.as_str(),
736 if p.api_key_env.is_some() {
737 ", gateway-held key"
738 } else {
739 ""
740 }
741 );
742 }
743 for s in mcp_servers.iter() {
744 println!(
745 " MCP: any /mcp/{} → {} (observed{})",
746 s.id,
747 s.url,
748 if s.auth_env.is_some() {
749 ", gateway-held credential"
750 } else {
751 ""
752 }
753 );
754 }
755 if openai_upstream.starts_with("http://") && !is_local_proxy_url(&openai_upstream) {
756 println!(
757 " ⚠ OpenAI upstream is plaintext HTTP to a non-loopback host \
758 (allow_insecure_http_upstream) — use only on a trusted local network"
759 );
760 }
761
762 let listener = tokio::net::TcpListener::bind(addr).await?;
763 axum::serve(listener, app)
764 .with_graceful_shutdown(shutdown_signal())
765 .await?;
766
767 println!("lean-ctx proxy shut down cleanly.");
768 Ok(())
769}
770
771async fn shutdown_signal() {
772 let ctrl_c = tokio::signal::ctrl_c();
773
774 #[cfg(unix)]
775 {
776 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
779 Ok(mut sigterm) => {
780 tokio::select! {
781 _ = ctrl_c => {},
782 _ = sigterm.recv() => {},
783 }
784 }
785 Err(e) => {
786 tracing::warn!("lean-ctx proxy: SIGTERM handler unavailable ({e}); Ctrl-C only");
787 ctrl_c.await.ok();
788 }
789 }
790 }
791
792 #[cfg(not(unix))]
793 {
794 ctrl_c.await.ok();
795 }
796
797 println!("lean-ctx proxy: received shutdown signal, draining…");
798}
799
800async fn health() -> impl IntoResponse {
801 let body = serde_json::json!({
802 "status": "ok",
803 "service": "lean-ctx-proxy",
804 "pid": std::process::id(),
805 });
806 (StatusCode::OK, axum::Json(body))
807}
808
809async fn v1_resolve_reference(
810 axum::extract::Path(id): axum::extract::Path<String>,
811) -> impl IntoResponse {
812 match crate::server::reference_store::resolve(&id) {
813 Some(content) => (StatusCode::OK, content),
814 None => (
815 StatusCode::NOT_FOUND,
816 "Reference expired or not found".to_string(),
817 ),
818 }
819}
820
821async fn v1_retrieve_ccr(
831 axum::extract::Path(hash): axum::extract::Path<String>,
832) -> impl IntoResponse {
833 match ccr::retrieve_litellm(&hash) {
834 Some(content) => (
835 StatusCode::OK,
836 axum::Json(serde_json::json!({ "original_content": content })),
837 ),
838 None => (
839 StatusCode::NOT_FOUND,
840 axum::Json(serde_json::json!({
841 "error": "hash not found or expired",
842 "hash": hash,
843 })),
844 ),
845 }
846}
847
848async fn status_handler(State(state): State<ProxyState>) -> impl IntoResponse {
849 use std::sync::atomic::Ordering::Relaxed;
850 let s = &state.stats;
851 let i = &state.introspect;
852
853 let last_breakdown = i
854 .last_breakdown
855 .lock()
856 .ok()
857 .and_then(|guard| guard.as_ref().map(|b| serde_json::to_value(b).ok()))
858 .flatten();
859
860 let spend = usage_meter::snapshot();
861 let spend_total: f64 = spend.iter().map(|m| m.cost_usd).sum();
862
863 let up = state.upstream_snapshot();
867
868 let active_effort = crate::core::config::Config::load().proxy.resolved_effort();
871
872 let introspect_total_input = i.total_input_tokens.load(Relaxed);
873 let introspect_bulk_candidates = i.total_bulk_candidate_tokens.load(Relaxed);
874 let introspect_bulk_share =
875 introspect::token_share_basis_points(introspect_bulk_candidates, introspect_total_input);
876 let break_even = state.break_even_summary();
877
878 let body = serde_json::json!({
879 "status": "running",
880 "proxy_mode": format!("{:?}", crate::core::config::Config::load().proxy.resolved_proxy_mode()),
881 "port": state.port,
882 "upstreams": {
883 "anthropic": up.anthropic.clone(),
884 "openai": up.openai.clone(),
885 "chatgpt": up.chatgpt.clone(),
886 "gemini": up.gemini.clone(),
887 },
888 "providers": up.providers.iter().map(|p| serde_json::json!({
891 "id": p.id,
892 "shape": p.shape.as_str(),
893 "base_url": p.base_url,
894 "gateway_key": p.api_key_env.is_some(),
895 })).collect::<Vec<_>>(),
896 "requests_total": s.requests_total.load(Relaxed),
897 "requests_compressed": s.requests_compressed.load(Relaxed),
898 "tokens_saved": s.tokens_saved.load(Relaxed),
899 "tokens_saved_estimated": true,
900 "verified_savings": usage_meter::verified_savings(),
904 "bytes_original": s.bytes_original.load(Relaxed),
905 "bytes_compressed": s.bytes_compressed.load(Relaxed),
906 "compression_ratio_pct": format!("{:.1}", s.compression_ratio()),
907 "break_even": {
908 "proxy_savings": break_even.proxy_savings,
909 "estimated_mcp_overhead": break_even.estimated_mcp_overhead,
910 "net": break_even.net,
911 "mcp_recommended": break_even.mcp_recommended,
912 "reason": break_even.reason,
913 },
914 "per_upstream": s.provider_summary(),
915 "prefix_cache": prefix_cache_stats::snapshot(),
916 "cache_safety": cache_safety::snapshot(),
917 "cache_attribution": cache_attribution::snapshot(),
918 "effort": effort::snapshot(active_effort),
919 "per_model": cost::snapshot(),
920 "spend": {
921 "source": "measured",
922 "total_usd": spend_total,
923 "per_model": spend,
924 "note": "Actual provider bill: real model + billed tokens (incl. cache reads/writes & reasoning) read from upstream responses for proxy-routed clients."
925 },
926 "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.",
927 "introspect": {
928 "total_requests_analyzed": i.total_requests.load(Relaxed),
929 "total_input_tokens": introspect_total_input,
930 "total_system_prompt_tokens": i.total_system_prompt_tokens.load(Relaxed),
931 "total_bulk_candidate_tokens": introspect_bulk_candidates,
932 "bulk_candidate_share_basis_points": introspect_bulk_share,
933 "vision_encoding_decision_gate_met": introspect_bulk_share.is_some_and(|share| share >= 2_000),
934 "last_breakdown": last_breakdown,
935 }
936 });
937 (StatusCode::OK, axum::Json(body))
938}
939
940#[allow(clippy::result_large_err)]
941#[cfg_attr(not(feature = "enterprise"), allow(unused_mut))]
942async fn proxy_auth_guard(
943 mut req: axum::extract::Request,
944 next: axum::middleware::Next,
945 expected_token: String,
946 require_token: bool,
947 loopback_open: bool,
948 #[cfg(feature = "enterprise")] gateway_keys: Arc<gateway_identity::GatewayKeys>,
949 upstreams: tokio::sync::watch::Receiver<Arc<Upstreams>>,
950) -> Result<Response, Response> {
951 let path = req.uri().path();
952 if path == "/health" {
953 return Ok(next.run(req).await);
954 }
955
956 if loopback_open {
958 #[cfg(feature = "enterprise")]
959 {
960 req.extensions_mut()
961 .insert(gateway_identity::TrustedGatewayRequest);
962 attach_gateway_tags(&mut req, gateway_identity::GatewayTags::default());
963 }
964 return Ok(next.run(req).await);
965 }
966
967 let bearer = req
968 .headers()
969 .get("authorization")
970 .and_then(|v| v.to_str().ok())
971 .and_then(|auth| auth.strip_prefix("Bearer "))
972 .map(str::to_string);
973
974 if let Some(token) = bearer.as_deref()
975 && !proxy_token_expired(token, std::time::SystemTime::now())
976 && constant_time_eq(token.as_bytes(), expected_token.as_bytes())
977 {
978 #[cfg(feature = "enterprise")]
979 {
980 req.extensions_mut()
981 .insert(gateway_identity::TrustedGatewayRequest);
982 attach_gateway_tags(&mut req, gateway_identity::GatewayTags::default());
983 }
984 return Ok(next.run(req).await);
985 }
986
987 #[cfg(feature = "enterprise")]
991 if let Some(token) = bearer.as_deref()
992 && let Some(tags) = gateway_keys.lookup(token)
993 {
994 req.extensions_mut()
995 .insert(gateway_identity::TrustedGatewayRequest);
996 attach_gateway_tags(&mut req, tags);
997 return Ok(next.run(req).await);
998 }
999
1000 let client_auth_registry = is_client_auth_registry_route(path, &upstreams.borrow());
1006 if provider_key_fallback_allowed(
1007 require_token,
1008 has_provider_api_key(&req),
1009 is_provider_route(path),
1010 client_auth_registry,
1011 ) {
1012 #[cfg(feature = "enterprise")]
1013 attach_gateway_tags(&mut req, gateway_identity::GatewayTags::default());
1014 return Ok(next.run(req).await);
1015 }
1016
1017 Err(auth_error_response(path))
1018}
1019
1020fn proxy_token_expired(token: &str, now: std::time::SystemTime) -> bool {
1024 const TOKEN_MAX_AGE: std::time::Duration = std::time::Duration::from_hours(24);
1025
1026 let Some((value, timestamp)) = token.rsplit_once('.') else {
1027 return false;
1028 };
1029 if value.is_empty() {
1030 return false;
1031 }
1032 let Ok(timestamp) = timestamp.parse::<u64>() else {
1033 return false;
1034 };
1035
1036 let issued_at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(timestamp);
1037 now.duration_since(issued_at)
1038 .is_ok_and(|age| age > TOKEN_MAX_AGE)
1039}
1040
1041fn auth_error_response(path: &str) -> Response {
1042 let is_mcp = path.starts_with("/mcp");
1043 let hint = if is_mcp {
1044 "MCP Streamable HTTP requires a Bearer token. Get it with: lean-ctx proxy token"
1045 } else if is_provider_route(path) {
1046 "Provider route requires authentication. Set your API key header or use: lean-ctx proxy token"
1047 } else {
1048 "This endpoint requires a lean-ctx Bearer token. Get it with: lean-ctx proxy token \
1049 — or set proxy_loopback_open = true to disable auth on localhost"
1050 };
1051
1052 let body = serde_json::json!({
1053 "type": "error",
1054 "error": {
1055 "type": "authentication_error",
1056 "message": format!("401 Unauthorized — {hint}")
1057 }
1058 });
1059
1060 (StatusCode::UNAUTHORIZED, axum::Json(body)).into_response()
1061}
1062
1063#[cfg(feature = "enterprise")]
1072fn attach_gateway_tags(req: &mut axum::extract::Request, mut tags: gateway_identity::GatewayTags) {
1073 if let Some(project) = req
1074 .headers()
1075 .get("x-leanctx-project")
1076 .and_then(|v| v.to_str().ok())
1077 .map(str::trim)
1078 .filter(|p| !p.is_empty() && p.len() <= 128 && !p.chars().any(char::is_control))
1079 {
1080 tags.project = Some(project.to_string());
1081 }
1082 if let Some(person) = tags.person.as_deref()
1086 && pii::enabled()
1087 {
1088 tags.person = Some(pii::pseudonymize(person));
1089 }
1090 if !tags.is_empty() {
1091 req.extensions_mut().insert(tags);
1092 }
1093}
1094
1095fn has_provider_api_key(req: &axum::extract::Request) -> bool {
1096 let headers = req.headers();
1097 for key in ["x-api-key", "x-goog-api-key", "api-key", "x-xai-token-auth"] {
1102 if headers
1103 .get(key)
1104 .and_then(|v| v.to_str().ok())
1105 .is_some_and(|v| !v.trim().is_empty())
1106 {
1107 return true;
1108 }
1109 }
1110 if let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
1119 let auth = auth.trim();
1120 let credential = auth
1121 .strip_prefix("Bearer ")
1122 .or_else(|| auth.strip_prefix("bearer "))
1123 .unwrap_or(auth)
1124 .trim();
1125 return !credential.is_empty() && !credential.eq_ignore_ascii_case("bearer");
1127 }
1128 false
1129}
1130
1131fn is_client_auth_registry_route(path: &str, upstreams: &Upstreams) -> bool {
1135 let Some(rest) = path.strip_prefix("/providers/") else {
1136 return false;
1137 };
1138 let id = rest.split('/').next().unwrap_or("");
1139 if id.is_empty() {
1140 return false;
1141 }
1142 upstreams
1143 .providers
1144 .iter()
1145 .any(|p| p.id == id && !p.injects_gateway_credential())
1146}
1147
1148fn is_provider_route(path: &str) -> bool {
1149 path.starts_with("/v1/")
1150 || path.starts_with("/v1beta/")
1151 || path.starts_with("/chat/completions")
1152 || path.starts_with("/responses")
1153 || path.starts_with("/messages")
1154 || path.starts_with("/backend-api")
1155 || path == "/models"
1158 || path.starts_with("/providers/")
1164}
1165
1166#[allow(clippy::fn_params_excessive_bools)] fn provider_key_fallback_allowed(
1184 require_token: bool,
1185 has_provider_key: bool,
1186 is_provider_route: bool,
1187 client_auth_registry: bool,
1188) -> bool {
1189 if !has_provider_key || !is_provider_route {
1190 return false;
1191 }
1192 if client_auth_registry {
1193 return true;
1194 }
1195 !require_token
1196}
1197
1198fn canonical_provider_path(path: &str) -> Option<String> {
1209 if let Some(rest) = path.strip_prefix("/v1/v1/") {
1213 return Some(format!("/v1/{rest}"));
1214 }
1215 const BARE_TO_CANONICAL: &[(&str, &str, &str)] = &[
1216 ("/responses", "/v1/responses", "/responses/"),
1217 (
1218 "/chat/completions",
1219 "/v1/chat/completions",
1220 "/chat/completions/",
1221 ),
1222 ("/messages", "/v1/messages", "/messages/"),
1223 ];
1224 for (bare, canonical, bare_with_slash) in BARE_TO_CANONICAL {
1225 if path == *bare {
1226 return Some((*canonical).to_string());
1227 }
1228 if let Some(rest) = path.strip_prefix(bare_with_slash) {
1229 return Some(format!("{canonical}/{rest}"));
1230 }
1231 }
1232 None
1233}
1234
1235fn normalized_provider_uri(uri: &axum::http::Uri) -> Option<axum::http::Uri> {
1239 let canonical = canonical_provider_path(uri.path())?;
1240 let new_path_and_query = match uri.query() {
1241 Some(q) => format!("{canonical}?{q}"),
1242 None => canonical,
1243 };
1244 new_path_and_query.parse::<axum::http::Uri>().ok()
1245}
1246
1247async fn normalize_provider_path(
1251 mut req: axum::extract::Request,
1252 next: axum::middleware::Next,
1253) -> Response {
1254 if let Some(uri) = normalized_provider_uri(req.uri()) {
1255 *req.uri_mut() = uri;
1256 }
1257 next.run(req).await
1258}
1259
1260fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
1261 use subtle::ConstantTimeEq;
1262 if a.len() != b.len() {
1263 return false;
1264 }
1265 bool::from(a.ct_eq(b))
1266}
1267
1268async fn host_guard(
1269 req: axum::extract::Request,
1270 next: axum::middleware::Next,
1271 allowed_hosts: Arc<Vec<String>>,
1272) -> Result<Response, StatusCode> {
1273 if let Some(host) = req.headers().get("host").and_then(|v| v.to_str().ok())
1274 && host_allowed(host, &allowed_hosts)
1275 {
1276 return Ok(next.run(req).await);
1277 }
1278 Err(StatusCode::FORBIDDEN)
1279}
1280
1281fn host_allowed(host_header: &str, allowed: &[String]) -> bool {
1286 let host = host_header.trim();
1288 let h = if let Some(bracketed) = host.strip_prefix('[') {
1289 bracketed
1290 .split(']')
1291 .next()
1292 .map(|inner| format!("[{inner}]"))
1293 } else {
1294 host.split(':').next().map(str::to_string)
1295 };
1296 let Some(h) = h else {
1297 return false;
1298 };
1299 let h = h.trim_end_matches('.').to_ascii_lowercase();
1300 matches!(h.as_str(), "127.0.0.1" | "localhost" | "[::1]") || allowed.contains(&h)
1301}
1302
1303async fn rate_limit_guard(
1306 req: axum::extract::Request,
1307 next: axum::middleware::Next,
1308 limiter: Arc<RateLimiter>,
1309) -> Result<Response, StatusCode> {
1310 if req.uri().path() != "/health" && !limiter.allow().await {
1311 return Err(StatusCode::TOO_MANY_REQUESTS);
1312 }
1313 Ok(next.run(req).await)
1314}
1315
1316pub(crate) struct RateLimiter {
1320 max_rps: f64,
1321 burst: f64,
1322 state: tokio::sync::Mutex<RateLimiterState>,
1323}
1324
1325struct RateLimiterState {
1326 tokens: f64,
1327 last: std::time::Instant,
1328}
1329
1330impl RateLimiter {
1331 pub(crate) fn new(max_rps: u32, burst: u32) -> Self {
1332 Self {
1333 max_rps: f64::from(max_rps.max(1)),
1334 burst: f64::from(burst.max(1)),
1335 state: tokio::sync::Mutex::new(RateLimiterState {
1336 tokens: f64::from(burst.max(1)),
1337 last: std::time::Instant::now(),
1338 }),
1339 }
1340 }
1341
1342 pub(crate) async fn allow(&self) -> bool {
1343 let mut s = self.state.lock().await;
1344 let now = std::time::Instant::now();
1345 let refill = now.saturating_duration_since(s.last).as_secs_f64() * self.max_rps;
1346 s.tokens = (s.tokens + refill).min(self.burst);
1347 s.last = now;
1348 if s.tokens >= 1.0 {
1349 s.tokens -= 1.0;
1350 true
1351 } else {
1352 false
1353 }
1354 }
1355}
1356
1357async fn fallback_router(State(state): State<ProxyState>, req: Request<Body>) -> Response {
1358 let path = req.uri().path().to_string();
1359
1360 if path.starts_with("/v1beta/models/") || path.starts_with("/v1/models/") {
1361 match google::handler(State(state), req).await {
1362 Ok(resp) => resp,
1363 Err(status) => Response::builder()
1364 .status(status)
1365 .body(Body::from("proxy error"))
1366 .expect("BUG: building error response with valid status should never fail"),
1367 }
1368 } else {
1369 let method = req.method().to_string();
1370 eprintln!("lean-ctx proxy: unmatched {method} {path}");
1371 Response::builder()
1372 .status(StatusCode::NOT_FOUND)
1373 .body(Body::from(format!(
1374 "lean-ctx proxy: no handler for {method} {path}"
1375 )))
1376 .expect("BUG: building 404 response should never fail")
1377 }
1378}