1pub mod anthropic;
2pub mod cache_aligner;
3pub mod cache_attribution;
4pub mod cache_breakpoint;
5pub mod cache_policy;
6pub mod cache_safety;
7pub mod ccr;
8#[cfg(test)]
9mod ccr_robustness_tests;
10pub mod chatgpt;
11pub mod chatgpt_cookies;
12pub mod cold_prefix;
13pub mod compress;
14pub mod compress_api;
15pub mod cost;
16pub mod effort;
17pub mod forward;
18pub mod google;
19pub mod history_prune;
20pub mod holdout;
21pub mod introspect;
22pub mod metrics;
23pub mod openai;
24pub mod openai_responses;
25pub mod openai_responses_ws;
26pub mod output_savings;
27pub mod prose;
28pub mod prose_ranker;
29pub mod tool_kind;
30pub mod tool_output;
31pub mod usage;
32pub mod usage_meter;
33pub mod verbosity;
34
35use std::net::SocketAddr;
36use std::sync::Arc;
37use std::sync::atomic::{AtomicU64, Ordering};
38
39use crate::core::config::Upstreams;
40
41use axum::{
42 Router,
43 body::Body,
44 extract::State,
45 http::{Request, StatusCode},
46 response::{IntoResponse, Response},
47 routing::{any, get, post},
48};
49
50#[derive(Clone)]
51pub struct ProxyState {
52 pub client: reqwest::Client,
53 pub port: u16,
54 pub stats: Arc<ProxyStats>,
55 pub introspect: Arc<introspect::IntrospectState>,
56 pub upstreams: tokio::sync::watch::Receiver<Arc<Upstreams>>,
59}
60
61impl ProxyState {
62 pub fn upstream_snapshot(&self) -> Arc<Upstreams> {
64 self.upstreams.borrow().clone()
65 }
66
67 pub fn anthropic_upstream(&self) -> String {
69 self.upstreams.borrow().anthropic.clone()
70 }
71
72 pub fn openai_upstream(&self) -> String {
74 self.upstreams.borrow().openai.clone()
75 }
76
77 pub fn chatgpt_upstream(&self) -> String {
79 self.upstreams.borrow().chatgpt.clone()
80 }
81
82 pub fn gemini_upstream(&self) -> String {
84 self.upstreams.borrow().gemini.clone()
85 }
86}
87
88pub struct ProxyStats {
89 pub requests_total: AtomicU64,
90 pub requests_compressed: AtomicU64,
91 pub tokens_saved: AtomicU64,
92 pub bytes_original: AtomicU64,
93 pub bytes_compressed: AtomicU64,
94 pub anthropic: ProviderStats,
95 pub openai: ProviderStats,
96 pub chatgpt: ProviderStats,
97 pub gemini: ProviderStats,
98}
99
100#[derive(Default)]
101pub struct ProviderStats {
102 pub requests_total: AtomicU64,
103 pub requests_compressed: AtomicU64,
104 pub tokens_saved: AtomicU64,
105 pub bytes_original: AtomicU64,
106 pub bytes_compressed: AtomicU64,
107}
108
109impl Default for ProxyStats {
110 fn default() -> Self {
111 Self {
112 requests_total: AtomicU64::new(0),
113 requests_compressed: AtomicU64::new(0),
114 tokens_saved: AtomicU64::new(0),
115 bytes_original: AtomicU64::new(0),
116 bytes_compressed: AtomicU64::new(0),
117 anthropic: ProviderStats::default(),
118 openai: ProviderStats::default(),
119 chatgpt: ProviderStats::default(),
120 gemini: ProviderStats::default(),
121 }
122 }
123}
124
125impl ProxyStats {
126 pub fn record_request(&self, original: usize, compressed: usize) {
127 self.record_totals(original, compressed);
128 }
129
130 pub fn record_provider_request(
131 &self,
132 provider_label: &str,
133 original: usize,
134 compressed: usize,
135 ) {
136 let (effective_compressed, saved_tokens, compressed_request) =
137 self.record_totals(original, compressed);
138
139 if let Some(provider) = self.provider(provider_label) {
140 provider.record(
141 original,
142 effective_compressed,
143 compressed_request,
144 saved_tokens,
145 );
146 }
147 }
148
149 fn record_totals(&self, original: usize, compressed: usize) -> (usize, u64, bool) {
150 self.requests_total.fetch_add(1, Ordering::Relaxed);
151 self.bytes_original
152 .fetch_add(original as u64, Ordering::Relaxed);
153 let effective_compressed = compressed.min(original);
154 self.bytes_compressed
155 .fetch_add(effective_compressed as u64, Ordering::Relaxed);
156 if compressed < original {
157 self.requests_compressed.fetch_add(1, Ordering::Relaxed);
158 }
159 let saved_tokens = (original.saturating_sub(effective_compressed) / 4) as u64;
160 self.tokens_saved.fetch_add(saved_tokens, Ordering::Relaxed);
161 (effective_compressed, saved_tokens, compressed < original)
162 }
163
164 pub fn compression_ratio(&self) -> f64 {
165 let original = self.bytes_original.load(Ordering::Relaxed);
166 if original == 0 {
167 return 0.0;
168 }
169 let compressed = self.bytes_compressed.load(Ordering::Relaxed);
170 (1.0 - compressed as f64 / original as f64) * 100.0
171 }
172
173 fn provider(&self, provider_label: &str) -> Option<&ProviderStats> {
177 match provider_label {
178 "Anthropic" => Some(&self.anthropic),
179 "OpenAI" => Some(&self.openai),
180 "ChatGPT" => Some(&self.chatgpt),
181 "Gemini" => Some(&self.gemini),
182 _ => None,
183 }
184 }
185
186 pub fn provider_summary(&self) -> serde_json::Value {
187 serde_json::json!({
188 "anthropic": self.anthropic.summary(),
189 "openai": self.openai.summary(),
190 "chatgpt": self.chatgpt.summary(),
191 "gemini": self.gemini.summary(),
192 })
193 }
194}
195
196impl ProviderStats {
197 fn record(
198 &self,
199 original: usize,
200 effective_compressed: usize,
201 compressed_request: bool,
202 saved_tokens: u64,
203 ) {
204 self.requests_total.fetch_add(1, Ordering::Relaxed);
205 if compressed_request {
206 self.requests_compressed.fetch_add(1, Ordering::Relaxed);
207 }
208 self.tokens_saved.fetch_add(saved_tokens, Ordering::Relaxed);
209 self.bytes_original
210 .fetch_add(original as u64, Ordering::Relaxed);
211 self.bytes_compressed
212 .fetch_add(effective_compressed as u64, Ordering::Relaxed);
213 }
214
215 fn compression_ratio(&self) -> f64 {
216 let original = self.bytes_original.load(Ordering::Relaxed);
217 if original == 0 {
218 return 0.0;
219 }
220 let compressed = self.bytes_compressed.load(Ordering::Relaxed);
221 (1.0 - compressed as f64 / original as f64) * 100.0
222 }
223
224 fn summary(&self) -> serde_json::Value {
225 serde_json::json!({
226 "requests_total": self.requests_total.load(Ordering::Relaxed),
227 "requests_compressed": self.requests_compressed.load(Ordering::Relaxed),
228 "tokens_saved": self.tokens_saved.load(Ordering::Relaxed),
229 "bytes_original": self.bytes_original.load(Ordering::Relaxed),
230 "bytes_compressed": self.bytes_compressed.load(Ordering::Relaxed),
231 "compression_ratio_pct": format!("{:.1}", self.compression_ratio()),
232 })
233 }
234}
235
236#[cfg(test)]
237mod stats_tests {
238 use super::*;
239 use std::sync::atomic::Ordering;
240
241 #[test]
242 fn compression_ratio_includes_uncompressed_requests() {
243 let stats = ProxyStats::default();
244
245 stats.record_request(1_000, 500);
246 stats.record_request(1_000, 1_000);
247
248 assert_eq!(stats.requests_total.load(Ordering::Relaxed), 2);
249 assert_eq!(stats.requests_compressed.load(Ordering::Relaxed), 1);
250 assert_eq!(stats.tokens_saved.load(Ordering::Relaxed), 125);
251 assert_eq!(stats.compression_ratio(), 25.0);
252 }
253
254 #[test]
255 fn expanded_requests_count_as_zero_savings() {
256 let stats = ProxyStats::default();
257
258 stats.record_request(1_000, 1_500);
259
260 assert_eq!(stats.requests_total.load(Ordering::Relaxed), 1);
261 assert_eq!(stats.requests_compressed.load(Ordering::Relaxed), 0);
262 assert_eq!(stats.tokens_saved.load(Ordering::Relaxed), 0);
263 assert_eq!(stats.compression_ratio(), 0.0);
264 }
265
266 #[test]
267 fn provider_stats_are_separate() {
268 let stats = ProxyStats::default();
269
270 stats.record_provider_request("OpenAI", 1_000, 500);
271 stats.record_provider_request("ChatGPT", 2_000, 1_000);
272
273 assert_eq!(stats.requests_total.load(Ordering::Relaxed), 2);
274 assert_eq!(stats.openai.requests_total.load(Ordering::Relaxed), 1);
275 assert_eq!(stats.chatgpt.requests_total.load(Ordering::Relaxed), 1);
276 assert_eq!(stats.openai.tokens_saved.load(Ordering::Relaxed), 125);
277 assert_eq!(stats.chatgpt.tokens_saved.load(Ordering::Relaxed), 250);
278 assert_eq!(stats.openai.compression_ratio(), 50.0);
279 assert_eq!(stats.chatgpt.compression_ratio(), 50.0);
280 }
281
282 #[test]
283 fn unlabelled_requests_do_not_count_as_gemini() {
284 let stats = ProxyStats::default();
285
286 stats.record_request(1_000, 500);
287
288 assert_eq!(stats.requests_total.load(Ordering::Relaxed), 1);
289 assert_eq!(stats.gemini.requests_total.load(Ordering::Relaxed), 0);
290 }
291
292 #[test]
293 fn unknown_label_is_not_recorded_to_any_bucket() {
294 let stats = ProxyStats::default();
295
296 stats.record_provider_request("Mystery", 1_000, 500);
297
298 assert_eq!(stats.requests_total.load(Ordering::Relaxed), 1);
300 assert_eq!(stats.anthropic.requests_total.load(Ordering::Relaxed), 0);
301 assert_eq!(stats.openai.requests_total.load(Ordering::Relaxed), 0);
302 assert_eq!(stats.chatgpt.requests_total.load(Ordering::Relaxed), 0);
303 assert_eq!(stats.gemini.requests_total.load(Ordering::Relaxed), 0);
304 }
305}
306
307fn connect_timeout_secs() -> u64 {
309 std::env::var("LEAN_CTX_PROXY_CONNECT_TIMEOUT_SECS")
310 .ok()
311 .and_then(|v| v.trim().parse::<u64>().ok())
312 .filter(|s| *s > 0)
313 .unwrap_or(15)
314}
315
316fn read_idle_timeout_secs() -> u64 {
321 std::env::var("LEAN_CTX_PROXY_READ_TIMEOUT_SECS")
322 .ok()
323 .and_then(|v| v.trim().parse::<u64>().ok())
324 .filter(|s| *s > 0)
325 .unwrap_or(300)
326}
327
328fn upstream_reload_secs() -> u64 {
331 std::env::var("LEAN_CTX_PROXY_RELOAD_SECS")
332 .ok()
333 .and_then(|v| v.trim().parse::<u64>().ok())
334 .filter(|s| *s > 0)
335 .unwrap_or(5)
336}
337
338fn spawn_upstream_refresh(tx: tokio::sync::watch::Sender<Arc<Upstreams>>, initial: Upstreams) {
347 let interval = std::time::Duration::from_secs(upstream_reload_secs());
348 tokio::spawn(async move {
349 let mut last = initial;
350 loop {
351 tokio::time::sleep(interval).await;
352 let next = crate::core::config::Config::load()
353 .proxy
354 .refresh_upstreams(&last);
355 if next != last {
356 log_upstream_change(&last, &next);
357 last = next.clone();
358 if tx.send(Arc::new(next)).is_err() {
359 break;
360 }
361 }
362 }
363 });
364}
365
366fn log_upstream_change(old: &Upstreams, new: &Upstreams) {
369 if old.anthropic != new.anthropic {
370 println!(" ↻ Anthropic upstream → {}", new.anthropic);
371 }
372 if old.openai != new.openai {
373 println!(" ↻ OpenAI upstream → {}", new.openai);
374 }
375 if old.chatgpt != new.chatgpt {
376 println!(" ↻ ChatGPT upstream → {}", new.chatgpt);
377 }
378 if old.gemini != new.gemini {
379 println!(" ↻ Gemini upstream → {}", new.gemini);
380 }
381}
382
383pub async fn start_proxy(port: u16) -> anyhow::Result<()> {
384 let token = crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN");
385 start_proxy_with_token(port, Some(token)).await
386}
387
388fn effective_auth_token(auth_token: Option<String>) -> String {
393 auth_token
394 .filter(|t| !t.trim().is_empty())
395 .unwrap_or_else(|| crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN"))
396}
397
398pub async fn start_proxy_with_token(port: u16, auth_token: Option<String>) -> anyhow::Result<()> {
399 use crate::core::config::{Config, is_local_proxy_url};
400
401 let auth_token = effective_auth_token(auth_token);
402
403 let client = chatgpt_cookies::with_chatgpt_cloudflare_cookie_store(reqwest::Client::builder())
408 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs()))
409 .read_timeout(std::time::Duration::from_secs(read_idle_timeout_secs()))
410 .build()?;
411
412 usage_meter::resume_from_disk();
415 cold_prefix::resume_from_disk();
418
419 let cfg = Config::load();
420 let require_token = cfg.proxy_require_token;
422 let initial = cfg.proxy.resolve_all();
423
424 let (upstream_tx, upstream_rx) = tokio::sync::watch::channel(Arc::new(initial.clone()));
429 spawn_upstream_refresh(upstream_tx, initial.clone());
430
431 let Upstreams {
432 anthropic: anthropic_upstream,
433 openai: openai_upstream,
434 chatgpt: chatgpt_upstream,
435 gemini: gemini_upstream,
436 } = initial;
437
438 let state = ProxyState {
439 client,
440 port,
441 stats: Arc::new(ProxyStats::default()),
442 introspect: Arc::new(introspect::IntrospectState::default()),
443 upstreams: upstream_rx,
444 };
445
446 let mut app = Router::new()
447 .route("/health", get(health))
448 .route("/status", get(status_handler))
449 .route("/v1/messages", any(anthropic::handler))
450 .route("/v1/messages/{*rest}", any(anthropic::handler))
451 .route("/v1/chat/completions", any(openai::handler))
452 .route(
454 "/v1/responses",
455 post(openai_responses::handler).get(openai_responses::ws_handler),
456 )
457 .route("/v1/responses/{*rest}", any(openai_responses::handler))
458 .route("/messages", any(anthropic::handler))
464 .route("/messages/{*rest}", any(anthropic::handler))
465 .route("/chat/completions", any(openai::handler))
466 .route(
467 "/responses",
468 post(openai_responses::handler).get(openai_responses::ws_handler),
469 )
470 .route("/responses/{*rest}", any(openai_responses::handler))
471 .route(
472 "/backend-api/codex/responses",
473 post(chatgpt::codex_responses_handler).get(chatgpt::codex_responses_ws_handler),
474 )
475 .route(
476 "/backend-api/codex/responses/{*rest}",
477 any(chatgpt::codex_responses_handler),
478 )
479 .route("/backend-api", any(chatgpt::backend_api_handler))
482 .route("/backend-api/{*rest}", any(chatgpt::backend_api_handler))
483 .route("/v1/references/{id}", get(v1_resolve_reference))
484 .route("/v1/compress", post(compress_api::handler))
487 .fallback(fallback_router)
488 .layer(axum::middleware::from_fn(host_guard))
489 .with_state(state);
490
491 {
492 let expected = auth_token.clone();
493 app = app.layer(axum::middleware::from_fn(move |req, next| {
494 let expected = expected.clone();
495 proxy_auth_guard(req, next, expected, require_token)
496 }));
497 }
498
499 app = app.layer(axum::middleware::from_fn(normalize_provider_path));
503
504 let addr = SocketAddr::from(([127, 0, 0, 1], port));
505 println!("lean-ctx proxy listening on http://{addr} (token auth enabled)");
506 println!(" Anthropic: POST /v1/messages → {anthropic_upstream}");
507 println!(" OpenAI: POST /v1/chat/completions → {openai_upstream}");
508 println!(
509 " OpenAI: POST /v1/responses → {openai_upstream} (bare /responses also accepted)"
510 );
511 println!(" ChatGPT: POST /backend-api/codex/responses → {chatgpt_upstream}");
512 println!(" ChatGPT: any /backend-api/* → {chatgpt_upstream}");
513 println!(" Gemini: POST /v1beta/models/... → {gemini_upstream}");
514 println!(" Compress: POST /v1/compress (deterministic messages-in/out, local)");
515 println!(
519 " Codex: WS ws://{addr}/responses → bridged to {openai_upstream} (HTTP/SSE, #440)"
520 );
521 if openai_upstream.starts_with("http://") && !is_local_proxy_url(&openai_upstream) {
522 println!(
523 " ⚠ OpenAI upstream is plaintext HTTP to a non-loopback host \
524 (allow_insecure_http_upstream) — use only on a trusted local network"
525 );
526 }
527
528 let listener = tokio::net::TcpListener::bind(addr).await?;
529 axum::serve(listener, app)
530 .with_graceful_shutdown(shutdown_signal())
531 .await?;
532
533 println!("lean-ctx proxy shut down cleanly.");
534 Ok(())
535}
536
537async fn shutdown_signal() {
538 let ctrl_c = tokio::signal::ctrl_c();
539
540 #[cfg(unix)]
541 {
542 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
545 Ok(mut sigterm) => {
546 tokio::select! {
547 _ = ctrl_c => {},
548 _ = sigterm.recv() => {},
549 }
550 }
551 Err(e) => {
552 tracing::warn!("lean-ctx proxy: SIGTERM handler unavailable ({e}); Ctrl-C only");
553 ctrl_c.await.ok();
554 }
555 }
556 }
557
558 #[cfg(not(unix))]
559 {
560 ctrl_c.await.ok();
561 }
562
563 println!("lean-ctx proxy: received shutdown signal, draining…");
564}
565
566async fn health() -> impl IntoResponse {
567 let body = serde_json::json!({
568 "status": "ok",
569 "pid": std::process::id(),
570 });
571 (StatusCode::OK, axum::Json(body))
572}
573
574async fn v1_resolve_reference(
575 axum::extract::Path(id): axum::extract::Path<String>,
576) -> impl IntoResponse {
577 match crate::server::reference_store::resolve(&id) {
578 Some(content) => (StatusCode::OK, content),
579 None => (
580 StatusCode::NOT_FOUND,
581 "Reference expired or not found".to_string(),
582 ),
583 }
584}
585
586async fn status_handler(State(state): State<ProxyState>) -> impl IntoResponse {
587 use std::sync::atomic::Ordering::Relaxed;
588 let s = &state.stats;
589 let i = &state.introspect;
590
591 let last_breakdown = i
592 .last_breakdown
593 .lock()
594 .ok()
595 .and_then(|guard| guard.as_ref().map(|b| serde_json::to_value(b).ok()))
596 .flatten();
597
598 let spend = usage_meter::snapshot();
599 let spend_total: f64 = spend.iter().map(|m| m.cost_usd).sum();
600
601 let up = state.upstream_snapshot();
605
606 let active_effort = crate::core::config::Config::load().proxy.resolved_effort();
609
610 let body = serde_json::json!({
611 "status": "running",
612 "port": state.port,
613 "upstreams": {
614 "anthropic": up.anthropic.clone(),
615 "openai": up.openai.clone(),
616 "chatgpt": up.chatgpt.clone(),
617 "gemini": up.gemini.clone(),
618 },
619 "requests_total": s.requests_total.load(Relaxed),
620 "requests_compressed": s.requests_compressed.load(Relaxed),
621 "tokens_saved": s.tokens_saved.load(Relaxed),
622 "tokens_saved_estimated": true,
623 "bytes_original": s.bytes_original.load(Relaxed),
624 "bytes_compressed": s.bytes_compressed.load(Relaxed),
625 "compression_ratio_pct": format!("{:.1}", s.compression_ratio()),
626 "per_upstream": s.provider_summary(),
627 "cache_safety": cache_safety::snapshot(),
628 "cache_attribution": cache_attribution::snapshot(),
629 "effort": effort::snapshot(active_effort),
630 "per_model": cost::snapshot(),
631 "spend": {
632 "source": "measured",
633 "total_usd": spend_total,
634 "per_model": spend,
635 "note": "Actual provider bill: real model + billed tokens (incl. cache reads/writes & reasoning) read from upstream responses for proxy-routed clients."
636 },
637 "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.",
638 "introspect": {
639 "total_requests_analyzed": i.total_requests.load(Relaxed),
640 "total_system_prompt_tokens": i.total_system_prompt_tokens.load(Relaxed),
641 "last_breakdown": last_breakdown,
642 }
643 });
644 (StatusCode::OK, axum::Json(body))
645}
646
647#[allow(clippy::result_large_err)]
648async fn proxy_auth_guard(
649 req: axum::extract::Request,
650 next: axum::middleware::Next,
651 expected_token: String,
652 require_token: bool,
653) -> Result<Response, Response> {
654 let path = req.uri().path();
655 if path == "/health" {
656 return Ok(next.run(req).await);
657 }
658
659 if let Some(auth) = req
660 .headers()
661 .get("authorization")
662 .and_then(|v| v.to_str().ok())
663 && let Some(token) = auth.strip_prefix("Bearer ")
664 && constant_time_eq(token.as_bytes(), expected_token.as_bytes())
665 {
666 return Ok(next.run(req).await);
667 }
668
669 if provider_key_fallback_allowed(
675 require_token,
676 has_provider_api_key(&req),
677 is_provider_route(path),
678 ) {
679 return Ok(next.run(req).await);
680 }
681
682 let cfg = crate::core::config::Config::load();
683 let hint = match cfg.proxy_enabled {
684 Some(true) => {
685 "lean-ctx proxy requires authentication. Use a Bearer token (LEAN_CTX_PROXY_TOKEN) or configure your AI tool's API key."
686 }
687 Some(false) => "lean-ctx proxy is disabled but still running. Run: lean-ctx proxy cleanup",
688 None => {
689 "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"
690 }
691 };
692
693 let body = serde_json::json!({
694 "type": "error",
695 "error": {
696 "type": "authentication_error",
697 "message": format!("401 Unauthorized — {hint}")
698 }
699 });
700
701 Err((StatusCode::UNAUTHORIZED, axum::Json(body)).into_response())
702}
703
704fn has_provider_api_key(req: &axum::extract::Request) -> bool {
705 let headers = req.headers();
706 for key in ["x-api-key", "x-goog-api-key", "api-key"] {
709 if headers
710 .get(key)
711 .and_then(|v| v.to_str().ok())
712 .is_some_and(|v| !v.trim().is_empty())
713 {
714 return true;
715 }
716 }
717 if let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
726 let auth = auth.trim();
727 let credential = auth
728 .strip_prefix("Bearer ")
729 .or_else(|| auth.strip_prefix("bearer "))
730 .unwrap_or(auth)
731 .trim();
732 return !credential.is_empty() && !credential.eq_ignore_ascii_case("bearer");
734 }
735 false
736}
737
738fn is_provider_route(path: &str) -> bool {
739 path.starts_with("/v1/")
740 || path.starts_with("/v1beta/")
741 || path.starts_with("/chat/completions")
742 || path.starts_with("/responses")
743 || path.starts_with("/messages")
744 || path.starts_with("/backend-api")
745}
746
747fn provider_key_fallback_allowed(
754 require_token: bool,
755 has_provider_key: bool,
756 is_provider_route: bool,
757) -> bool {
758 !require_token && has_provider_key && is_provider_route
759}
760
761fn canonical_provider_path(path: &str) -> Option<String> {
772 if let Some(rest) = path.strip_prefix("/v1/v1/") {
776 return Some(format!("/v1/{rest}"));
777 }
778 const BARE_TO_CANONICAL: &[(&str, &str, &str)] = &[
779 ("/responses", "/v1/responses", "/responses/"),
780 (
781 "/chat/completions",
782 "/v1/chat/completions",
783 "/chat/completions/",
784 ),
785 ("/messages", "/v1/messages", "/messages/"),
786 ];
787 for (bare, canonical, bare_with_slash) in BARE_TO_CANONICAL {
788 if path == *bare {
789 return Some((*canonical).to_string());
790 }
791 if let Some(rest) = path.strip_prefix(bare_with_slash) {
792 return Some(format!("{canonical}/{rest}"));
793 }
794 }
795 None
796}
797
798fn normalized_provider_uri(uri: &axum::http::Uri) -> Option<axum::http::Uri> {
802 let canonical = canonical_provider_path(uri.path())?;
803 let new_path_and_query = match uri.query() {
804 Some(q) => format!("{canonical}?{q}"),
805 None => canonical,
806 };
807 new_path_and_query.parse::<axum::http::Uri>().ok()
808}
809
810async fn normalize_provider_path(
814 mut req: axum::extract::Request,
815 next: axum::middleware::Next,
816) -> Response {
817 if let Some(uri) = normalized_provider_uri(req.uri()) {
818 *req.uri_mut() = uri;
819 }
820 next.run(req).await
821}
822
823fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
824 use subtle::ConstantTimeEq;
825 if a.len() != b.len() {
826 return false;
827 }
828 bool::from(a.ct_eq(b))
829}
830
831async fn host_guard(
832 req: axum::extract::Request,
833 next: axum::middleware::Next,
834) -> Result<Response, StatusCode> {
835 if let Some(host) = req.headers().get("host").and_then(|v| v.to_str().ok()) {
836 let h = host.split(':').next().unwrap_or(host);
837 if matches!(h, "127.0.0.1" | "localhost" | "[::1]") {
838 return Ok(next.run(req).await);
839 }
840 }
841 Err(StatusCode::FORBIDDEN)
842}
843
844async fn fallback_router(State(state): State<ProxyState>, req: Request<Body>) -> Response {
845 let path = req.uri().path().to_string();
846
847 if path.starts_with("/v1beta/models/") || path.starts_with("/v1/models/") {
848 match google::handler(State(state), req).await {
849 Ok(resp) => resp,
850 Err(status) => Response::builder()
851 .status(status)
852 .body(Body::from("proxy error"))
853 .expect("BUG: building error response with valid status should never fail"),
854 }
855 } else {
856 let method = req.method().to_string();
857 eprintln!("lean-ctx proxy: unmatched {method} {path}");
858 Response::builder()
859 .status(StatusCode::NOT_FOUND)
860 .body(Body::from(format!(
861 "lean-ctx proxy: no handler for {method} {path}"
862 )))
863 .expect("BUG: building 404 response should never fail")
864 }
865}
866
867#[cfg(test)]
868mod auth_tests {
869 use super::*;
870
871 #[test]
874 fn effective_auth_token_never_yields_empty() {
875 let _env = crate::core::data_dir::test_env_lock();
876 let tmp = tempfile::tempdir().unwrap();
877 crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
878
879 assert_eq!(effective_auth_token(Some("tok".into())), "tok");
880 let auto = effective_auth_token(None);
881 assert!(!auto.trim().is_empty(), "None must auto-resolve a token");
882 let blank = effective_auth_token(Some(" ".into()));
883 assert!(!blank.trim().is_empty(), "blank tokens must be replaced");
884
885 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
886 }
887
888 #[test]
889 fn is_provider_route_v1() {
890 assert!(is_provider_route("/v1/chat/completions"));
891 assert!(is_provider_route("/v1/messages"));
892 assert!(is_provider_route("/v1/completions"));
893 }
894
895 #[test]
896 fn is_provider_route_anthropic_subpaths() {
897 assert!(is_provider_route("/v1/messages/count_tokens"));
898 assert!(is_provider_route("/v1/messages/batches"));
899 assert!(is_provider_route("/v1/messages/batches/batch_123"));
900 }
901
902 #[test]
903 fn is_provider_route_v1beta() {
904 assert!(is_provider_route("/v1beta/models"));
905 }
906
907 #[test]
908 fn is_provider_route_chat() {
909 assert!(is_provider_route("/chat/completions"));
910 }
911
912 #[test]
913 fn is_provider_route_chatgpt_backend_api() {
914 assert!(is_provider_route("/backend-api/codex/responses"));
915 assert!(is_provider_route("/backend-api/codex/responses/resp_123"));
916 assert!(is_provider_route("/backend-api/wham/session"));
917 assert!(is_provider_route("/backend-api/ps/mcp"));
918 assert!(is_provider_route("/backend-api/codex_apps"));
919 assert!(is_provider_route("/backend-api/codex_apps/mcp"));
920 assert!(is_provider_route("/backend-api/mcp/codex_apps"));
921 assert!(is_provider_route("/backend-api/apps/codex_apps/mcp"));
922 }
923
924 #[test]
925 fn is_provider_route_rejects_non_provider() {
926 assert!(!is_provider_route("/health"));
927 assert!(!is_provider_route("/api/v2/test"));
928 assert!(!is_provider_route("/"));
929 }
930
931 fn build_request(headers: &[(&str, &str)], path: &str) -> axum::extract::Request {
932 let mut builder = axum::http::Request::builder().uri(path);
933 for (k, v) in headers {
934 builder = builder.header(*k, *v);
935 }
936 builder.body(axum::body::Body::empty()).unwrap()
937 }
938
939 #[test]
940 fn has_provider_api_key_x_api_key() {
941 let req = build_request(&[("x-api-key", "sk-ant-abc123")], "/v1/messages");
942 assert!(has_provider_api_key(&req));
943 }
944
945 #[test]
946 fn has_provider_api_key_x_goog() {
947 let req = build_request(&[("x-goog-api-key", "AIzaSyAbc")], "/v1beta/models");
948 assert!(has_provider_api_key(&req));
949 }
950
951 #[test]
952 fn has_provider_api_key_azure() {
953 let req = build_request(&[("api-key", "deadbeef")], "/v1/completions");
954 assert!(has_provider_api_key(&req));
955 }
956
957 #[test]
958 fn has_provider_api_key_bearer_sk() {
959 let req = build_request(
960 &[("authorization", "Bearer sk-proj-abc123")],
961 "/v1/chat/completions",
962 );
963 assert!(has_provider_api_key(&req));
964 }
965
966 #[test]
967 fn has_provider_api_key_empty_rejected() {
968 let req = build_request(&[("x-api-key", " ")], "/v1/messages");
969 assert!(!has_provider_api_key(&req));
970 }
971
972 #[test]
973 fn has_provider_api_key_no_headers() {
974 let req = build_request(&[], "/v1/messages");
975 assert!(!has_provider_api_key(&req));
976 }
977
978 #[test]
979 fn has_provider_api_key_accepts_non_sk_bearer() {
980 for key in [
986 "Bearer or-v1-9f8e7d6c", "Bearer gsk_live_1234", "Bearer abc.def.ghi", "Bearer 0123456789", ] {
991 let req = build_request(&[("authorization", key)], "/v1/responses");
992 assert!(
993 has_provider_api_key(&req),
994 "non-sk Bearer must count as a provider credential: {key}"
995 );
996 }
997 }
998
999 #[test]
1000 fn has_provider_api_key_empty_bearer_rejected() {
1001 for bad in ["Bearer ", "", "Bearer", "bearer", " "] {
1004 let req = build_request(&[("authorization", bad)], "/responses");
1005 assert!(
1006 !has_provider_api_key(&req),
1007 "blank/scheme-only Authorization must not authenticate: {bad:?}"
1008 );
1009 }
1010 }
1011
1012 #[test]
1015 fn provider_key_fallback_allowed_in_default_mode() {
1016 assert!(provider_key_fallback_allowed(false, true, true));
1020 }
1021
1022 #[test]
1023 fn provider_key_fallback_denied_in_strict_mode() {
1024 assert!(!provider_key_fallback_allowed(true, true, true));
1028 }
1029
1030 #[test]
1031 fn provider_key_fallback_requires_key_and_provider_route() {
1032 assert!(!provider_key_fallback_allowed(false, false, true));
1035 assert!(!provider_key_fallback_allowed(false, true, false));
1036 assert!(!provider_key_fallback_allowed(true, false, true));
1037 }
1038
1039 #[test]
1040 fn proxy_require_token_defaults_off() {
1041 assert!(!crate::core::config::Config::default().proxy_require_token);
1045 }
1046
1047 #[test]
1050 fn is_provider_route_bare_responses_and_messages() {
1051 assert!(is_provider_route("/responses"));
1054 assert!(is_provider_route("/responses/resp_123/input_items"));
1055 assert!(is_provider_route("/messages"));
1056 }
1057
1058 #[test]
1059 fn canonical_provider_path_rewrites_bare_endpoints() {
1060 assert_eq!(
1061 canonical_provider_path("/responses").as_deref(),
1062 Some("/v1/responses")
1063 );
1064 assert_eq!(
1065 canonical_provider_path("/chat/completions").as_deref(),
1066 Some("/v1/chat/completions")
1067 );
1068 assert_eq!(
1069 canonical_provider_path("/messages").as_deref(),
1070 Some("/v1/messages")
1071 );
1072 }
1073
1074 #[test]
1075 fn canonical_provider_path_preserves_subpaths() {
1076 assert_eq!(
1077 canonical_provider_path("/responses/resp_abc/cancel").as_deref(),
1078 Some("/v1/responses/resp_abc/cancel")
1079 );
1080 assert_eq!(
1081 canonical_provider_path("/messages/batches/batch_1").as_deref(),
1082 Some("/v1/messages/batches/batch_1")
1083 );
1084 }
1085
1086 #[test]
1087 fn canonical_provider_path_ignores_already_canonical_and_unknown() {
1088 assert_eq!(canonical_provider_path("/v1/responses"), None);
1090 assert_eq!(canonical_provider_path("/v1/chat/completions"), None);
1091 assert_eq!(canonical_provider_path("/health"), None);
1093 assert_eq!(canonical_provider_path("/responsesx"), None);
1094 assert_eq!(canonical_provider_path("/"), None);
1095 }
1096
1097 #[test]
1098 fn canonical_provider_path_collapses_double_v1_prefix() {
1099 assert_eq!(
1102 canonical_provider_path("/v1/v1/responses").as_deref(),
1103 Some("/v1/responses")
1104 );
1105 assert_eq!(
1106 canonical_provider_path("/v1/v1/chat/completions").as_deref(),
1107 Some("/v1/chat/completions")
1108 );
1109 }
1110
1111 #[test]
1112 fn normalized_provider_uri_rewrites_path_and_preserves_query() {
1113 use axum::http::Uri;
1114 let uri: Uri = "/responses?stream=true".parse().unwrap();
1115 let rewritten = normalized_provider_uri(&uri).expect("bare /responses must rewrite");
1116 assert_eq!(rewritten.path(), "/v1/responses");
1117 assert_eq!(rewritten.query(), Some("stream=true"));
1118 assert_eq!(
1119 rewritten
1120 .path_and_query()
1121 .map(axum::http::uri::PathAndQuery::as_str),
1122 Some("/v1/responses?stream=true")
1123 );
1124 }
1125
1126 #[test]
1127 fn normalized_provider_uri_noop_for_canonical() {
1128 use axum::http::Uri;
1129 let uri: Uri = "/v1/responses".parse().unwrap();
1130 assert!(normalized_provider_uri(&uri).is_none());
1131 }
1132}
1133
1134#[cfg(test)]
1135mod upstream_tests {
1136 use super::*;
1137
1138 fn upstreams_with_openai(openai: &str) -> Upstreams {
1139 Upstreams {
1140 anthropic: "https://api.anthropic.com".into(),
1141 openai: openai.into(),
1142 chatgpt: "https://chatgpt.com".into(),
1143 gemini: "https://generativelanguage.googleapis.com".into(),
1144 }
1145 }
1146
1147 #[tokio::test]
1151 async fn proxy_state_reads_upstream_live_from_watch() {
1152 let (tx, rx) =
1153 tokio::sync::watch::channel(Arc::new(upstreams_with_openai("https://old.example")));
1154 let state = ProxyState {
1155 client: reqwest::Client::new(),
1156 port: 0,
1157 stats: Arc::new(ProxyStats::default()),
1158 introspect: Arc::new(introspect::IntrospectState::default()),
1159 upstreams: rx,
1160 };
1161 assert_eq!(state.openai_upstream(), "https://old.example");
1162
1163 tx.send(Arc::new(upstreams_with_openai("https://new.example")))
1164 .unwrap();
1165 assert_eq!(
1166 state.openai_upstream(),
1167 "https://new.example",
1168 "a live handler read must reflect the published change"
1169 );
1170 assert_eq!(state.upstream_snapshot().openai, "https://new.example");
1171 }
1172
1173 #[tokio::test]
1183 #[allow(clippy::await_holding_lock)]
1184 async fn config_change_is_picked_up_live_without_restart() {
1185 use crate::core::config::Config;
1186
1187 let _lock = crate::core::data_dir::test_env_lock();
1188 let tmp = tempfile::tempdir().unwrap();
1189 crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
1190 crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
1193 crate::test_env::set_var("LEAN_CTX_PROXY_RELOAD_SECS", "1");
1194
1195 Config::update_global(|c| {
1197 c.proxy.openai_upstream = Some("http://127.0.0.1:19101".into());
1198 })
1199 .unwrap();
1200 let initial = Config::load().proxy.resolve_all();
1201 assert_eq!(initial.openai, "http://127.0.0.1:19101");
1202
1203 let (tx, rx) = tokio::sync::watch::channel(Arc::new(initial.clone()));
1204 spawn_upstream_refresh(tx, initial);
1205
1206 Config::update_global(|c| {
1208 c.proxy.openai_upstream = Some("http://127.0.0.1:19102".into());
1209 })
1210 .unwrap();
1211
1212 let mut live = rx.borrow().openai.clone();
1214 for _ in 0..80 {
1215 if live == "http://127.0.0.1:19102" {
1216 break;
1217 }
1218 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1219 live = rx.borrow().openai.clone();
1220 }
1221 assert_eq!(
1222 live, "http://127.0.0.1:19102",
1223 "running proxy must serve the new config.toml upstream without a restart"
1224 );
1225
1226 crate::test_env::remove_var("LEAN_CTX_PROXY_RELOAD_SECS");
1227 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1228 }
1229}