Skip to main content

lean_ctx/proxy/
mod.rs

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    /// Live provider upstreams, refreshed from config.toml without a proxy
44    /// restart (#449). Read per request via [`ProxyState::openai_upstream`] etc.
45    pub upstreams: tokio::sync::watch::Receiver<Arc<Upstreams>>,
46}
47
48impl ProxyState {
49    /// Consistent snapshot of all upstreams for the current request/response.
50    pub fn upstream_snapshot(&self) -> Arc<Upstreams> {
51        self.upstreams.borrow().clone()
52    }
53
54    /// Current Anthropic upstream (live).
55    pub fn anthropic_upstream(&self) -> String {
56        self.upstreams.borrow().anthropic.clone()
57    }
58
59    /// Current OpenAI upstream (live).
60    pub fn openai_upstream(&self) -> String {
61        self.upstreams.borrow().openai.clone()
62    }
63
64    /// Current Gemini upstream (live).
65    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
115/// TCP connect timeout (seconds). Configurable via `LEAN_CTX_PROXY_CONNECT_TIMEOUT_SECS`.
116fn 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
124/// Idle read timeout (seconds) between bytes from upstream. Generous by default
125/// so long extended-thinking phases (which still emit SSE keepalives) are never
126/// cut, while a truly dead connection eventually fails. Configurable via
127/// `LEAN_CTX_PROXY_READ_TIMEOUT_SECS`.
128fn 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
136/// How often (seconds) a running proxy re-reads config.toml for upstream
137/// changes. `LEAN_CTX_PROXY_RELOAD_SECS` overrides; default 5s.
138fn 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
146/// Background task: re-resolves the provider upstreams from config.toml on an
147/// interval and publishes any change to the live request handlers (#449). Ends
148/// once every receiver (the proxy itself) has been dropped.
149///
150/// `Config::load()` already keeps an internal content-hash cache, so re-reading
151/// an unchanged `config.toml` skips the TOML parse + merge and costs only a small
152/// file read; combined with the relaxed default interval (#453) the idle steady
153/// state is negligible without needing a separate stat pre-check.
154fn 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
174/// One stdout line per changed provider, matching the startup banner style so a
175/// running proxy's log shows when (and to what) an upstream switched.
176fn 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
193/// Security invariant: the proxy NEVER runs unauthenticated. `None` does not
194/// mean "no auth" — it means "resolve the session token for me". Provider
195/// routes additionally accept provider API keys (see `proxy_auth_guard`), so
196/// IDE clients keep working without any setup.
197fn 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    // A single total timeout aborts long streaming generations (e.g. Opus doing
209    // a big refactor) mid-response. Use a connect timeout plus a read (idle)
210    // timeout instead: a genuinely hung upstream still fails, but a slow-but-
211    // alive stream is never cut off. Both are configurable for edge networks.
212    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    // Seed the measured-spend meter from disk so a proxy restart never zeroes
218    // the user's cumulative real provider bill.
219    usage_meter::resume_from_disk();
220    // Seed the cold-prefix baselines too so a long idle gap that straddles a
221    // proxy restart is still detected and the repack can fire (#499).
222    cold_prefix::resume_from_disk();
223
224    let cfg = Config::load();
225    // Read once at startup — avoids a Config::load() on every proxied request.
226    let require_token = cfg.proxy_require_token;
227    let initial = cfg.proxy.resolve_all();
228
229    // The proxy reads its upstreams live from a watch channel: a background task
230    // re-resolves them from config.toml on an interval and publishes any change,
231    // so `lean-ctx config set proxy.*_upstream` (or any config.toml edit) takes
232    // effect on the running proxy within seconds, without a restart (#449).
233    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        // POST → HTTP/SSE forwarder; GET → Codex/OpenAI WebSocket bridge (#440).
257        .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        // Bare provider endpoints (no `/v1` prefix). Clients whose base URL points
263        // at the proxy root — notably OpenCode via `@ai-sdk/openai`, whose
264        // Responses-API requests hit `/responses` — dispatch here. The
265        // `normalize_provider_path` layer rewrites the URI to its canonical
266        // `/v1/...` form before the handler forwards upstream (#353).
267        .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        // Drop-in `compress(messages, model)` contract (#739): deterministic
277        // messages-in / messages-out compression for SDK clients.
278        .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    // Outermost layer (runs first): normalize bare provider endpoints to their
292    // canonical `/v1/...` form so auth, routing and upstream forwarding all agree,
293    // regardless of whether the client's base URL includes `/v1` (#353).
294    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    // Codex defaults to a WebSocket Responses transport (ws://…/responses). The
306    // proxy now bridges it to the HTTP/SSE upstream (#440), so Codex works as a
307    // drop-in without a `supports_websockets = false` workaround.
308    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        // Fall back to Ctrl-C only if the SIGTERM handler cannot be installed,
333        // rather than panicking the proxy on startup.
334        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    // Live upstreams the proxy is forwarding to right now (#449). This is the
392    // single source of truth for "where is my traffic actually going" — it
393    // reflects config.toml hot-reloads and any start-time env override.
394    let up = state.upstream_snapshot();
395
396    // Resolve the effort level fresh so /status reflects config.toml hot-reloads
397    // and env overrides, matching the upstream snapshot above (#834).
398    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    // Accept provider API keys on provider routes (loopback-only, host_guard runs first).
457    // AI tools like Claude Code send x-api-key, not Bearer tokens. Since the proxy
458    // only binds to 127.0.0.1, the presence of a valid API key header is sufficient
459    // to authenticate the request as coming from a local AI tool. Disabled when
460    // `proxy_require_token` is set — strict hosts then require the Bearer token.
461    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    // Provider-specific key headers: Anthropic `x-api-key`, Google
494    // `x-goog-api-key`, Azure `api-key`. Any non-empty value authenticates.
495    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    // OpenAI-style `Authorization` auth. Accept ANY non-empty credential, not
505    // just `Bearer sk-`/`gsk_`: OpenAI-*compatible* providers driven through
506    // OpenCode/Codex (Azure, OpenRouter, Groq, vLLM/Ollama gateways, project &
507    // service-account keys) issue keys that don't carry those prefixes. The proxy
508    // binds to loopback only and never injects upstream credentials — it forwards
509    // this header verbatim, so an invalid key is rejected by the real upstream,
510    // never silently honoured. Gating provider routes on key *shape* only ever
511    // produced false 401s for those clients (#362).
512    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        // Reject an empty value or a bare scheme keyword carrying no token.
520        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
533/// Decides whether a request authenticates via a provider API key alone, without
534/// the lean-ctx Bearer token. True only in the default, loopback-friendly mode
535/// where a local AI tool's own provider key is accepted on a provider route. When
536/// `require_token` is set (strict, shared-host mode) the fallback is disabled and
537/// the Bearer token becomes mandatory. Pure, so the policy is unit-testable
538/// without axum middleware plumbing.
539fn 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
547/// Maps a bare provider endpoint to its canonical `/v1/...` form, preserving any
548/// sub-path. Returns `None` when the path is already canonical or not a known
549/// provider endpoint.
550///
551/// Some OpenAI-compatible clients treat the configured base URL as the API root
552/// and append the bare endpoint, so they send `POST /responses` or
553/// `/chat/completions` instead of `/v1/responses` — notably OpenCode via
554/// `@ai-sdk/openai`, whose Responses-API requests land on `/responses`. The proxy
555/// and every upstream only know the `/v1/...` paths, so an un-prefixed request
556/// would 401 (not a provider route) and then 404 (no handler). (#353)
557fn canonical_provider_path(path: &str) -> Option<String> {
558    // Inverse case of the bare-endpoint rewrite below: the advertised
559    // OPENAI_BASE_URL includes `/v1` (#366), so a client that treats the base URL
560    // as an origin and appends `/v1/...` itself produces `/v1/v1/...`.
561    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
584/// Returns the canonicalized URI for a bare provider endpoint (query preserved),
585/// or `None` when no rewrite is needed. Pure, so the rewrite is unit-testable
586/// without constructing axum middleware plumbing.
587fn 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
596/// Rewrites the request URI in place when it targets a bare provider endpoint, so
597/// downstream auth (`is_provider_route`), routing and upstream forwarding all see
598/// the canonical `/v1/...` path. (#353)
599async 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    // P0-4 (#416): the proxy must never run unauthenticated — `None` means
658    // "resolve the session token", not "no auth".
659    #[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        // #362: OpenAI-*compatible* providers (Azure, OpenRouter, Groq, vLLM/
755        // Ollama gateways, project/service keys) issue keys without the sk-/gsk_
756        // prefix. OpenCode (@ai-sdk/openai) forwards them as `Bearer <key>`; they
757        // must authenticate on a loopback provider route. The upstream validates
758        // the real key — the proxy never injects one.
759        for key in [
760            "Bearer or-v1-9f8e7d6c", // OpenRouter
761            "Bearer gsk_live_1234",  // (still works)
762            "Bearer abc.def.ghi",    // gateway/service token
763            "Bearer 0123456789",     // opaque
764        ] {
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        // A blank credential — or a bare scheme word with no token (some HTTP
776        // stacks trim trailing whitespace down to just "Bearer") — is not auth.
777        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    // --- #334: opt-in strict proxy auth (proxy_require_token) ---
787
788    #[test]
789    fn provider_key_fallback_allowed_in_default_mode() {
790        // Default (require_token = false): a provider key on a provider route is
791        // sufficient. This is what lets a local AI tool authenticate with its own
792        // key and no lean-ctx Bearer token (the loopback-friendly behavior).
793        assert!(provider_key_fallback_allowed(false, true, true));
794    }
795
796    #[test]
797    fn provider_key_fallback_denied_in_strict_mode() {
798        // Strict (require_token = true, e.g. shared/multi-user host): the
799        // provider-key fallback is disabled, so even a valid provider key on a
800        // provider route is not enough — the Bearer token becomes mandatory.
801        assert!(!provider_key_fallback_allowed(true, true, true));
802    }
803
804    #[test]
805    fn provider_key_fallback_requires_key_and_provider_route() {
806        // The fallback never fires without a provider key, nor off a provider
807        // route — regardless of mode.
808        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        // The strict mode must be opt-in: a fresh config keeps the loopback
816        // behavior so existing local setups (Claude Code, OpenCode, Codex) keep
817        // working without a token.
818        assert!(!crate::core::config::Config::default().proxy_require_token);
819    }
820
821    // --- #353: bare provider endpoints (OpenCode / @ai-sdk/openai) ---
822
823    #[test]
824    fn is_provider_route_bare_responses_and_messages() {
825        // Clients that point their base URL at the proxy root (no `/v1`) send the
826        // bare endpoint; auth must still recognise it as a provider route.
827        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        // Already canonical → no rewrite (avoids `/v1/v1/...`).
863        assert_eq!(canonical_provider_path("/v1/responses"), None);
864        assert_eq!(canonical_provider_path("/v1/chat/completions"), None);
865        // Unrelated paths are untouched.
866        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        // OPENAI_BASE_URL now advertises `/v1` (#366); a client treating it as an
874        // origin and appending `/v1/...` itself produces a double prefix.
875        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    /// The #449 core wiring: provider handlers read the upstream per request from
921    /// the watch channel, so a published change is served immediately, without
922    /// rebuilding the `ProxyState`.
923    #[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    /// End-to-end #449 repro (in-process, no network): a `config set`-style edit
947    /// to config.toml is picked up by a *running* proxy's refresh task within the
948    /// reload interval — without any restart. Before the fix this value stayed
949    /// frozen at the start-time upstream forever.
950    ///
951    /// The process-global env lock is intentionally held across the polling
952    /// `.await`s to keep `LEAN_CTX_*` isolated for the whole test; safe because
953    /// each `#[tokio::test]` owns its current-thread runtime, so this std guard
954    /// only makes *other* test threads wait — it can never deadlock this one.
955    #[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        // Isolate from a developer shell that exports the env override (#449),
964        // and make the reload fast + deterministic.
965        crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
966        crate::test_env::set_var("LEAN_CTX_PROXY_RELOAD_SECS", "1");
967
968        // Start state: config.toml points OpenAI at a loopback upstream.
969        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        // `lean-ctx config set proxy.openai_upstream …` (same safe write path).
980        Config::update_global(|c| {
981            c.proxy.openai_upstream = Some("http://127.0.0.1:19102".into());
982        })
983        .unwrap();
984
985        // Poll the live value the handlers would read — no restart in between.
986        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}