1pub mod anthropic;
2pub mod cache_safety;
3pub mod ccr;
4pub mod cold_prefix;
5pub mod compress;
6pub mod compress_api;
7pub mod cost;
8pub mod effort;
9pub mod forward;
10pub mod google;
11pub mod history_prune;
12pub mod introspect;
13pub mod metrics;
14pub mod openai;
15pub mod openai_responses;
16pub mod openai_responses_ws;
17pub mod prose;
18pub mod tool_kind;
19pub mod usage;
20pub mod usage_meter;
21
22use std::net::SocketAddr;
23use std::sync::Arc;
24use std::sync::atomic::{AtomicU64, Ordering};
25
26use crate::core::config::Upstreams;
27
28use axum::{
29 Router,
30 body::Body,
31 extract::State,
32 http::{Request, StatusCode},
33 response::{IntoResponse, Response},
34 routing::{any, get, post},
35};
36
37#[derive(Clone)]
38pub struct ProxyState {
39 pub client: reqwest::Client,
40 pub port: u16,
41 pub stats: Arc<ProxyStats>,
42 pub introspect: Arc<introspect::IntrospectState>,
43 pub upstreams: tokio::sync::watch::Receiver<Arc<Upstreams>>,
46}
47
48impl ProxyState {
49 pub fn upstream_snapshot(&self) -> Arc<Upstreams> {
51 self.upstreams.borrow().clone()
52 }
53
54 pub fn anthropic_upstream(&self) -> String {
56 self.upstreams.borrow().anthropic.clone()
57 }
58
59 pub fn openai_upstream(&self) -> String {
61 self.upstreams.borrow().openai.clone()
62 }
63
64 pub fn gemini_upstream(&self) -> String {
66 self.upstreams.borrow().gemini.clone()
67 }
68}
69
70pub struct ProxyStats {
71 pub requests_total: AtomicU64,
72 pub requests_compressed: AtomicU64,
73 pub tokens_saved: AtomicU64,
74 pub bytes_original: AtomicU64,
75 pub bytes_compressed: AtomicU64,
76}
77
78impl Default for ProxyStats {
79 fn default() -> Self {
80 Self {
81 requests_total: AtomicU64::new(0),
82 requests_compressed: AtomicU64::new(0),
83 tokens_saved: AtomicU64::new(0),
84 bytes_original: AtomicU64::new(0),
85 bytes_compressed: AtomicU64::new(0),
86 }
87 }
88}
89
90impl ProxyStats {
91 pub fn record_request(&self) {
92 self.requests_total.fetch_add(1, Ordering::Relaxed);
93 }
94
95 pub fn record_compression(&self, original: usize, compressed: usize) {
96 self.requests_compressed.fetch_add(1, Ordering::Relaxed);
97 self.bytes_original
98 .fetch_add(original as u64, Ordering::Relaxed);
99 self.bytes_compressed
100 .fetch_add(compressed as u64, Ordering::Relaxed);
101 let saved_tokens = (original.saturating_sub(compressed) / 4) as u64;
102 self.tokens_saved.fetch_add(saved_tokens, Ordering::Relaxed);
103 }
104
105 pub fn compression_ratio(&self) -> f64 {
106 let original = self.bytes_original.load(Ordering::Relaxed);
107 if original == 0 {
108 return 0.0;
109 }
110 let compressed = self.bytes_compressed.load(Ordering::Relaxed);
111 (1.0 - compressed as f64 / original as f64) * 100.0
112 }
113}
114
115fn connect_timeout_secs() -> u64 {
117 std::env::var("LEAN_CTX_PROXY_CONNECT_TIMEOUT_SECS")
118 .ok()
119 .and_then(|v| v.trim().parse::<u64>().ok())
120 .filter(|s| *s > 0)
121 .unwrap_or(15)
122}
123
124fn read_idle_timeout_secs() -> u64 {
129 std::env::var("LEAN_CTX_PROXY_READ_TIMEOUT_SECS")
130 .ok()
131 .and_then(|v| v.trim().parse::<u64>().ok())
132 .filter(|s| *s > 0)
133 .unwrap_or(300)
134}
135
136fn upstream_reload_secs() -> u64 {
139 std::env::var("LEAN_CTX_PROXY_RELOAD_SECS")
140 .ok()
141 .and_then(|v| v.trim().parse::<u64>().ok())
142 .filter(|s| *s > 0)
143 .unwrap_or(5)
144}
145
146fn spawn_upstream_refresh(tx: tokio::sync::watch::Sender<Arc<Upstreams>>, initial: Upstreams) {
155 let interval = std::time::Duration::from_secs(upstream_reload_secs());
156 tokio::spawn(async move {
157 let mut last = initial;
158 loop {
159 tokio::time::sleep(interval).await;
160 let next = crate::core::config::Config::load()
161 .proxy
162 .refresh_upstreams(&last);
163 if next != last {
164 log_upstream_change(&last, &next);
165 last = next.clone();
166 if tx.send(Arc::new(next)).is_err() {
167 break;
168 }
169 }
170 }
171 });
172}
173
174fn log_upstream_change(old: &Upstreams, new: &Upstreams) {
177 if old.anthropic != new.anthropic {
178 println!(" ↻ Anthropic upstream → {}", new.anthropic);
179 }
180 if old.openai != new.openai {
181 println!(" ↻ OpenAI upstream → {}", new.openai);
182 }
183 if old.gemini != new.gemini {
184 println!(" ↻ Gemini upstream → {}", new.gemini);
185 }
186}
187
188pub async fn start_proxy(port: u16) -> anyhow::Result<()> {
189 let token = crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN");
190 start_proxy_with_token(port, Some(token)).await
191}
192
193fn effective_auth_token(auth_token: Option<String>) -> String {
198 auth_token
199 .filter(|t| !t.trim().is_empty())
200 .unwrap_or_else(|| crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN"))
201}
202
203pub async fn start_proxy_with_token(port: u16, auth_token: Option<String>) -> anyhow::Result<()> {
204 use crate::core::config::{Config, is_local_proxy_url};
205
206 let auth_token = effective_auth_token(auth_token);
207
208 let client = reqwest::Client::builder()
213 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs()))
214 .read_timeout(std::time::Duration::from_secs(read_idle_timeout_secs()))
215 .build()?;
216
217 usage_meter::resume_from_disk();
220 cold_prefix::resume_from_disk();
223
224 let cfg = Config::load();
225 let require_token = cfg.proxy_require_token;
227 let initial = cfg.proxy.resolve_all();
228
229 let (upstream_tx, upstream_rx) = tokio::sync::watch::channel(Arc::new(initial.clone()));
234 spawn_upstream_refresh(upstream_tx, initial.clone());
235
236 let Upstreams {
237 anthropic: anthropic_upstream,
238 openai: openai_upstream,
239 gemini: gemini_upstream,
240 } = initial;
241
242 let state = ProxyState {
243 client,
244 port,
245 stats: Arc::new(ProxyStats::default()),
246 introspect: Arc::new(introspect::IntrospectState::default()),
247 upstreams: upstream_rx,
248 };
249
250 let mut app = Router::new()
251 .route("/health", get(health))
252 .route("/status", get(status_handler))
253 .route("/v1/messages", any(anthropic::handler))
254 .route("/v1/messages/{*rest}", any(anthropic::handler))
255 .route("/v1/chat/completions", any(openai::handler))
256 .route(
258 "/v1/responses",
259 post(openai_responses::handler).get(openai_responses::ws_handler),
260 )
261 .route("/v1/responses/{*rest}", any(openai_responses::handler))
262 .route("/messages", any(anthropic::handler))
268 .route("/messages/{*rest}", any(anthropic::handler))
269 .route("/chat/completions", any(openai::handler))
270 .route(
271 "/responses",
272 post(openai_responses::handler).get(openai_responses::ws_handler),
273 )
274 .route("/responses/{*rest}", any(openai_responses::handler))
275 .route("/v1/references/{id}", get(v1_resolve_reference))
276 .route("/v1/compress", post(compress_api::handler))
279 .fallback(fallback_router)
280 .layer(axum::middleware::from_fn(host_guard))
281 .with_state(state);
282
283 {
284 let expected = auth_token.clone();
285 app = app.layer(axum::middleware::from_fn(move |req, next| {
286 let expected = expected.clone();
287 proxy_auth_guard(req, next, expected, require_token)
288 }));
289 }
290
291 app = app.layer(axum::middleware::from_fn(normalize_provider_path));
295
296 let addr = SocketAddr::from(([127, 0, 0, 1], port));
297 println!("lean-ctx proxy listening on http://{addr} (token auth enabled)");
298 println!(" Anthropic: POST /v1/messages → {anthropic_upstream}");
299 println!(" OpenAI: POST /v1/chat/completions → {openai_upstream}");
300 println!(
301 " OpenAI: POST /v1/responses → {openai_upstream} (bare /responses also accepted)"
302 );
303 println!(" Gemini: POST /v1beta/models/... → {gemini_upstream}");
304 println!(" Compress: POST /v1/compress (deterministic messages-in/out, local)");
305 println!(
309 " Codex: WS ws://{addr}/responses → bridged to {openai_upstream} (HTTP/SSE, #440)"
310 );
311 if openai_upstream.starts_with("http://") && !is_local_proxy_url(&openai_upstream) {
312 println!(
313 " ⚠ OpenAI upstream is plaintext HTTP to a non-loopback host \
314 (allow_insecure_http_upstream) — use only on a trusted local network"
315 );
316 }
317
318 let listener = tokio::net::TcpListener::bind(addr).await?;
319 axum::serve(listener, app)
320 .with_graceful_shutdown(shutdown_signal())
321 .await?;
322
323 println!("lean-ctx proxy shut down cleanly.");
324 Ok(())
325}
326
327async fn shutdown_signal() {
328 let ctrl_c = tokio::signal::ctrl_c();
329
330 #[cfg(unix)]
331 {
332 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
335 Ok(mut sigterm) => {
336 tokio::select! {
337 _ = ctrl_c => {},
338 _ = sigterm.recv() => {},
339 }
340 }
341 Err(e) => {
342 tracing::warn!("lean-ctx proxy: SIGTERM handler unavailable ({e}); Ctrl-C only");
343 ctrl_c.await.ok();
344 }
345 }
346 }
347
348 #[cfg(not(unix))]
349 {
350 ctrl_c.await.ok();
351 }
352
353 println!("lean-ctx proxy: received shutdown signal, draining…");
354}
355
356async fn health() -> impl IntoResponse {
357 let body = serde_json::json!({
358 "status": "ok",
359 "pid": std::process::id(),
360 });
361 (StatusCode::OK, axum::Json(body))
362}
363
364async fn v1_resolve_reference(
365 axum::extract::Path(id): axum::extract::Path<String>,
366) -> impl IntoResponse {
367 match crate::server::reference_store::resolve(&id) {
368 Some(content) => (StatusCode::OK, content),
369 None => (
370 StatusCode::NOT_FOUND,
371 "Reference expired or not found".to_string(),
372 ),
373 }
374}
375
376async fn status_handler(State(state): State<ProxyState>) -> impl IntoResponse {
377 use std::sync::atomic::Ordering::Relaxed;
378 let s = &state.stats;
379 let i = &state.introspect;
380
381 let last_breakdown = i
382 .last_breakdown
383 .lock()
384 .ok()
385 .and_then(|guard| guard.as_ref().map(|b| serde_json::to_value(b).ok()))
386 .flatten();
387
388 let spend = usage_meter::snapshot();
389 let spend_total: f64 = spend.iter().map(|m| m.cost_usd).sum();
390
391 let up = state.upstream_snapshot();
395
396 let active_effort = crate::core::config::Config::load().proxy.resolved_effort();
399
400 let body = serde_json::json!({
401 "status": "running",
402 "port": state.port,
403 "upstreams": {
404 "anthropic": up.anthropic.clone(),
405 "openai": up.openai.clone(),
406 "gemini": up.gemini.clone(),
407 },
408 "requests_total": s.requests_total.load(Relaxed),
409 "requests_compressed": s.requests_compressed.load(Relaxed),
410 "tokens_saved": s.tokens_saved.load(Relaxed),
411 "tokens_saved_estimated": true,
412 "bytes_original": s.bytes_original.load(Relaxed),
413 "bytes_compressed": s.bytes_compressed.load(Relaxed),
414 "compression_ratio_pct": format!("{:.1}", s.compression_ratio()),
415 "cache_safety": cache_safety::snapshot(),
416 "effort": effort::snapshot(active_effort),
417 "per_model": cost::snapshot(),
418 "spend": {
419 "source": "measured",
420 "total_usd": spend_total,
421 "per_model": spend,
422 "note": "Actual provider bill: real model + billed tokens (incl. cache reads/writes & reasoning) read from upstream responses for proxy-routed clients."
423 },
424 "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.",
425 "introspect": {
426 "total_requests_analyzed": i.total_requests.load(Relaxed),
427 "total_system_prompt_tokens": i.total_system_prompt_tokens.load(Relaxed),
428 "last_breakdown": last_breakdown,
429 }
430 });
431 (StatusCode::OK, axum::Json(body))
432}
433
434#[allow(clippy::result_large_err)]
435async fn proxy_auth_guard(
436 req: axum::extract::Request,
437 next: axum::middleware::Next,
438 expected_token: String,
439 require_token: bool,
440) -> Result<Response, Response> {
441 let path = req.uri().path();
442 if path == "/health" {
443 return Ok(next.run(req).await);
444 }
445
446 if let Some(auth) = req
447 .headers()
448 .get("authorization")
449 .and_then(|v| v.to_str().ok())
450 && let Some(token) = auth.strip_prefix("Bearer ")
451 && constant_time_eq(token.as_bytes(), expected_token.as_bytes())
452 {
453 return Ok(next.run(req).await);
454 }
455
456 if provider_key_fallback_allowed(
462 require_token,
463 has_provider_api_key(&req),
464 is_provider_route(path),
465 ) {
466 return Ok(next.run(req).await);
467 }
468
469 let cfg = crate::core::config::Config::load();
470 let hint = match cfg.proxy_enabled {
471 Some(true) => {
472 "lean-ctx proxy requires authentication. Use a Bearer token (LEAN_CTX_PROXY_TOKEN) or configure your AI tool's API key."
473 }
474 Some(false) => "lean-ctx proxy is disabled but still running. Run: lean-ctx proxy cleanup",
475 None => {
476 "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"
477 }
478 };
479
480 let body = serde_json::json!({
481 "type": "error",
482 "error": {
483 "type": "authentication_error",
484 "message": format!("401 Unauthorized — {hint}")
485 }
486 });
487
488 Err((StatusCode::UNAUTHORIZED, axum::Json(body)).into_response())
489}
490
491fn has_provider_api_key(req: &axum::extract::Request) -> bool {
492 let headers = req.headers();
493 for key in ["x-api-key", "x-goog-api-key", "api-key"] {
496 if headers
497 .get(key)
498 .and_then(|v| v.to_str().ok())
499 .is_some_and(|v| !v.trim().is_empty())
500 {
501 return true;
502 }
503 }
504 if let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
513 let auth = auth.trim();
514 let credential = auth
515 .strip_prefix("Bearer ")
516 .or_else(|| auth.strip_prefix("bearer "))
517 .unwrap_or(auth)
518 .trim();
519 return !credential.is_empty() && !credential.eq_ignore_ascii_case("bearer");
521 }
522 false
523}
524
525fn is_provider_route(path: &str) -> bool {
526 path.starts_with("/v1/")
527 || path.starts_with("/v1beta/")
528 || path.starts_with("/chat/completions")
529 || path.starts_with("/responses")
530 || path.starts_with("/messages")
531}
532
533fn provider_key_fallback_allowed(
540 require_token: bool,
541 has_provider_key: bool,
542 is_provider_route: bool,
543) -> bool {
544 !require_token && has_provider_key && is_provider_route
545}
546
547fn canonical_provider_path(path: &str) -> Option<String> {
558 if let Some(rest) = path.strip_prefix("/v1/v1/") {
562 return Some(format!("/v1/{rest}"));
563 }
564 const BARE_TO_CANONICAL: &[(&str, &str, &str)] = &[
565 ("/responses", "/v1/responses", "/responses/"),
566 (
567 "/chat/completions",
568 "/v1/chat/completions",
569 "/chat/completions/",
570 ),
571 ("/messages", "/v1/messages", "/messages/"),
572 ];
573 for (bare, canonical, bare_with_slash) in BARE_TO_CANONICAL {
574 if path == *bare {
575 return Some((*canonical).to_string());
576 }
577 if let Some(rest) = path.strip_prefix(bare_with_slash) {
578 return Some(format!("{canonical}/{rest}"));
579 }
580 }
581 None
582}
583
584fn normalized_provider_uri(uri: &axum::http::Uri) -> Option<axum::http::Uri> {
588 let canonical = canonical_provider_path(uri.path())?;
589 let new_path_and_query = match uri.query() {
590 Some(q) => format!("{canonical}?{q}"),
591 None => canonical,
592 };
593 new_path_and_query.parse::<axum::http::Uri>().ok()
594}
595
596async fn normalize_provider_path(
600 mut req: axum::extract::Request,
601 next: axum::middleware::Next,
602) -> Response {
603 if let Some(uri) = normalized_provider_uri(req.uri()) {
604 *req.uri_mut() = uri;
605 }
606 next.run(req).await
607}
608
609fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
610 use subtle::ConstantTimeEq;
611 if a.len() != b.len() {
612 return false;
613 }
614 bool::from(a.ct_eq(b))
615}
616
617async fn host_guard(
618 req: axum::extract::Request,
619 next: axum::middleware::Next,
620) -> Result<Response, StatusCode> {
621 if let Some(host) = req.headers().get("host").and_then(|v| v.to_str().ok()) {
622 let h = host.split(':').next().unwrap_or(host);
623 if matches!(h, "127.0.0.1" | "localhost" | "[::1]") {
624 return Ok(next.run(req).await);
625 }
626 }
627 Err(StatusCode::FORBIDDEN)
628}
629
630async fn fallback_router(State(state): State<ProxyState>, req: Request<Body>) -> Response {
631 let path = req.uri().path().to_string();
632
633 if path.starts_with("/v1beta/models/") || path.starts_with("/v1/models/") {
634 match google::handler(State(state), req).await {
635 Ok(resp) => resp,
636 Err(status) => Response::builder()
637 .status(status)
638 .body(Body::from("proxy error"))
639 .expect("BUG: building error response with valid status should never fail"),
640 }
641 } else {
642 let method = req.method().to_string();
643 eprintln!("lean-ctx proxy: unmatched {method} {path}");
644 Response::builder()
645 .status(StatusCode::NOT_FOUND)
646 .body(Body::from(format!(
647 "lean-ctx proxy: no handler for {method} {path}"
648 )))
649 .expect("BUG: building 404 response should never fail")
650 }
651}
652
653#[cfg(test)]
654mod auth_tests {
655 use super::*;
656
657 #[test]
660 fn effective_auth_token_never_yields_empty() {
661 let _env = crate::core::data_dir::test_env_lock();
662 let tmp = tempfile::tempdir().unwrap();
663 crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
664
665 assert_eq!(effective_auth_token(Some("tok".into())), "tok");
666 let auto = effective_auth_token(None);
667 assert!(!auto.trim().is_empty(), "None must auto-resolve a token");
668 let blank = effective_auth_token(Some(" ".into()));
669 assert!(!blank.trim().is_empty(), "blank tokens must be replaced");
670
671 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
672 }
673
674 #[test]
675 fn is_provider_route_v1() {
676 assert!(is_provider_route("/v1/chat/completions"));
677 assert!(is_provider_route("/v1/messages"));
678 assert!(is_provider_route("/v1/completions"));
679 }
680
681 #[test]
682 fn is_provider_route_anthropic_subpaths() {
683 assert!(is_provider_route("/v1/messages/count_tokens"));
684 assert!(is_provider_route("/v1/messages/batches"));
685 assert!(is_provider_route("/v1/messages/batches/batch_123"));
686 }
687
688 #[test]
689 fn is_provider_route_v1beta() {
690 assert!(is_provider_route("/v1beta/models"));
691 }
692
693 #[test]
694 fn is_provider_route_chat() {
695 assert!(is_provider_route("/chat/completions"));
696 }
697
698 #[test]
699 fn is_provider_route_rejects_non_provider() {
700 assert!(!is_provider_route("/health"));
701 assert!(!is_provider_route("/api/v2/test"));
702 assert!(!is_provider_route("/"));
703 }
704
705 fn build_request(headers: &[(&str, &str)], path: &str) -> axum::extract::Request {
706 let mut builder = axum::http::Request::builder().uri(path);
707 for (k, v) in headers {
708 builder = builder.header(*k, *v);
709 }
710 builder.body(axum::body::Body::empty()).unwrap()
711 }
712
713 #[test]
714 fn has_provider_api_key_x_api_key() {
715 let req = build_request(&[("x-api-key", "sk-ant-abc123")], "/v1/messages");
716 assert!(has_provider_api_key(&req));
717 }
718
719 #[test]
720 fn has_provider_api_key_x_goog() {
721 let req = build_request(&[("x-goog-api-key", "AIzaSyAbc")], "/v1beta/models");
722 assert!(has_provider_api_key(&req));
723 }
724
725 #[test]
726 fn has_provider_api_key_azure() {
727 let req = build_request(&[("api-key", "deadbeef")], "/v1/completions");
728 assert!(has_provider_api_key(&req));
729 }
730
731 #[test]
732 fn has_provider_api_key_bearer_sk() {
733 let req = build_request(
734 &[("authorization", "Bearer sk-proj-abc123")],
735 "/v1/chat/completions",
736 );
737 assert!(has_provider_api_key(&req));
738 }
739
740 #[test]
741 fn has_provider_api_key_empty_rejected() {
742 let req = build_request(&[("x-api-key", " ")], "/v1/messages");
743 assert!(!has_provider_api_key(&req));
744 }
745
746 #[test]
747 fn has_provider_api_key_no_headers() {
748 let req = build_request(&[], "/v1/messages");
749 assert!(!has_provider_api_key(&req));
750 }
751
752 #[test]
753 fn has_provider_api_key_accepts_non_sk_bearer() {
754 for key in [
760 "Bearer or-v1-9f8e7d6c", "Bearer gsk_live_1234", "Bearer abc.def.ghi", "Bearer 0123456789", ] {
765 let req = build_request(&[("authorization", key)], "/v1/responses");
766 assert!(
767 has_provider_api_key(&req),
768 "non-sk Bearer must count as a provider credential: {key}"
769 );
770 }
771 }
772
773 #[test]
774 fn has_provider_api_key_empty_bearer_rejected() {
775 for bad in ["Bearer ", "", "Bearer", "bearer", " "] {
778 let req = build_request(&[("authorization", bad)], "/responses");
779 assert!(
780 !has_provider_api_key(&req),
781 "blank/scheme-only Authorization must not authenticate: {bad:?}"
782 );
783 }
784 }
785
786 #[test]
789 fn provider_key_fallback_allowed_in_default_mode() {
790 assert!(provider_key_fallback_allowed(false, true, true));
794 }
795
796 #[test]
797 fn provider_key_fallback_denied_in_strict_mode() {
798 assert!(!provider_key_fallback_allowed(true, true, true));
802 }
803
804 #[test]
805 fn provider_key_fallback_requires_key_and_provider_route() {
806 assert!(!provider_key_fallback_allowed(false, false, true));
809 assert!(!provider_key_fallback_allowed(false, true, false));
810 assert!(!provider_key_fallback_allowed(true, false, true));
811 }
812
813 #[test]
814 fn proxy_require_token_defaults_off() {
815 assert!(!crate::core::config::Config::default().proxy_require_token);
819 }
820
821 #[test]
824 fn is_provider_route_bare_responses_and_messages() {
825 assert!(is_provider_route("/responses"));
828 assert!(is_provider_route("/responses/resp_123/input_items"));
829 assert!(is_provider_route("/messages"));
830 }
831
832 #[test]
833 fn canonical_provider_path_rewrites_bare_endpoints() {
834 assert_eq!(
835 canonical_provider_path("/responses").as_deref(),
836 Some("/v1/responses")
837 );
838 assert_eq!(
839 canonical_provider_path("/chat/completions").as_deref(),
840 Some("/v1/chat/completions")
841 );
842 assert_eq!(
843 canonical_provider_path("/messages").as_deref(),
844 Some("/v1/messages")
845 );
846 }
847
848 #[test]
849 fn canonical_provider_path_preserves_subpaths() {
850 assert_eq!(
851 canonical_provider_path("/responses/resp_abc/cancel").as_deref(),
852 Some("/v1/responses/resp_abc/cancel")
853 );
854 assert_eq!(
855 canonical_provider_path("/messages/batches/batch_1").as_deref(),
856 Some("/v1/messages/batches/batch_1")
857 );
858 }
859
860 #[test]
861 fn canonical_provider_path_ignores_already_canonical_and_unknown() {
862 assert_eq!(canonical_provider_path("/v1/responses"), None);
864 assert_eq!(canonical_provider_path("/v1/chat/completions"), None);
865 assert_eq!(canonical_provider_path("/health"), None);
867 assert_eq!(canonical_provider_path("/responsesx"), None);
868 assert_eq!(canonical_provider_path("/"), None);
869 }
870
871 #[test]
872 fn canonical_provider_path_collapses_double_v1_prefix() {
873 assert_eq!(
876 canonical_provider_path("/v1/v1/responses").as_deref(),
877 Some("/v1/responses")
878 );
879 assert_eq!(
880 canonical_provider_path("/v1/v1/chat/completions").as_deref(),
881 Some("/v1/chat/completions")
882 );
883 }
884
885 #[test]
886 fn normalized_provider_uri_rewrites_path_and_preserves_query() {
887 use axum::http::Uri;
888 let uri: Uri = "/responses?stream=true".parse().unwrap();
889 let rewritten = normalized_provider_uri(&uri).expect("bare /responses must rewrite");
890 assert_eq!(rewritten.path(), "/v1/responses");
891 assert_eq!(rewritten.query(), Some("stream=true"));
892 assert_eq!(
893 rewritten
894 .path_and_query()
895 .map(axum::http::uri::PathAndQuery::as_str),
896 Some("/v1/responses?stream=true")
897 );
898 }
899
900 #[test]
901 fn normalized_provider_uri_noop_for_canonical() {
902 use axum::http::Uri;
903 let uri: Uri = "/v1/responses".parse().unwrap();
904 assert!(normalized_provider_uri(&uri).is_none());
905 }
906}
907
908#[cfg(test)]
909mod upstream_tests {
910 use super::*;
911
912 fn upstreams_with_openai(openai: &str) -> Upstreams {
913 Upstreams {
914 anthropic: "https://api.anthropic.com".into(),
915 openai: openai.into(),
916 gemini: "https://generativelanguage.googleapis.com".into(),
917 }
918 }
919
920 #[tokio::test]
924 async fn proxy_state_reads_upstream_live_from_watch() {
925 let (tx, rx) =
926 tokio::sync::watch::channel(Arc::new(upstreams_with_openai("https://old.example")));
927 let state = ProxyState {
928 client: reqwest::Client::new(),
929 port: 0,
930 stats: Arc::new(ProxyStats::default()),
931 introspect: Arc::new(introspect::IntrospectState::default()),
932 upstreams: rx,
933 };
934 assert_eq!(state.openai_upstream(), "https://old.example");
935
936 tx.send(Arc::new(upstreams_with_openai("https://new.example")))
937 .unwrap();
938 assert_eq!(
939 state.openai_upstream(),
940 "https://new.example",
941 "a live handler read must reflect the published change"
942 );
943 assert_eq!(state.upstream_snapshot().openai, "https://new.example");
944 }
945
946 #[tokio::test]
956 #[allow(clippy::await_holding_lock)]
957 async fn config_change_is_picked_up_live_without_restart() {
958 use crate::core::config::Config;
959
960 let _lock = crate::core::data_dir::test_env_lock();
961 let tmp = tempfile::tempdir().unwrap();
962 crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
963 crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
966 crate::test_env::set_var("LEAN_CTX_PROXY_RELOAD_SECS", "1");
967
968 Config::update_global(|c| {
970 c.proxy.openai_upstream = Some("http://127.0.0.1:19101".into());
971 })
972 .unwrap();
973 let initial = Config::load().proxy.resolve_all();
974 assert_eq!(initial.openai, "http://127.0.0.1:19101");
975
976 let (tx, rx) = tokio::sync::watch::channel(Arc::new(initial.clone()));
977 spawn_upstream_refresh(tx, initial);
978
979 Config::update_global(|c| {
981 c.proxy.openai_upstream = Some("http://127.0.0.1:19102".into());
982 })
983 .unwrap();
984
985 let mut live = rx.borrow().openai.clone();
987 for _ in 0..80 {
988 if live == "http://127.0.0.1:19102" {
989 break;
990 }
991 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
992 live = rx.borrow().openai.clone();
993 }
994 assert_eq!(
995 live, "http://127.0.0.1:19102",
996 "running proxy must serve the new config.toml upstream without a restart"
997 );
998
999 crate::test_env::remove_var("LEAN_CTX_PROXY_RELOAD_SECS");
1000 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1001 }
1002}