Skip to main content

lean_ctx/core/config/
proxy.rs

1//! API proxy upstream overrides (`config.toml`).
2
3use serde::{Deserialize, Serialize};
4
5/// API proxy upstream overrides. `None` = use provider default.
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7#[serde(default)]
8pub struct ProxyConfig {
9    pub anthropic_upstream: Option<String>,
10    pub openai_upstream: Option<String>,
11    pub gemini_upstream: Option<String>,
12    /// History-pruning strategy for proxied chat requests.
13    /// "cache-aware" (default) | "rolling" | "off". See [`HistoryMode`].
14    pub history_mode: Option<String>,
15}
16
17/// How the proxy prunes old tool results from conversation history.
18///
19/// Provider prompt caches (Anthropic `cache_control`, OpenAI automatic prompt
20/// caching) bill cached prefix tokens at a fraction of the base rate but only
21/// match *exact* prefixes. Any mutation whose position depends on the current
22/// conversation length (a rolling window) rewrites a previously-stable message
23/// every turn, invalidating the cache from that point — turning cheap cache
24/// reads into full-price writes.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum HistoryMode {
27    /// Prune only at frozen generation boundaries that advance in large,
28    /// deterministic steps. Between jumps the request prefix is byte-stable,
29    /// so provider prompt caches keep hitting. Default.
30    CacheAware,
31    /// Legacy behaviour: summarize everything older than the last N messages.
32    /// Maximum raw-token reduction, but defeats provider prompt caching.
33    Rolling,
34    /// Never prune history (tool-result compression still applies — it is
35    /// content-deterministic and therefore prefix-stable).
36    Off,
37}
38
39impl ProxyConfig {
40    /// Resolved history mode: `LEAN_CTX_PROXY_HISTORY_MODE` env var wins,
41    /// then `[proxy].history_mode` in config.toml, then cache-aware.
42    /// Unknown values fall back to the default so a typo can never silently
43    /// re-enable the cache-hostile rolling mode.
44    pub fn resolved_history_mode(&self) -> HistoryMode {
45        let raw = std::env::var("LEAN_CTX_PROXY_HISTORY_MODE")
46            .ok()
47            .or_else(|| self.history_mode.clone());
48        match raw.as_deref().map(str::trim) {
49            Some(s) if s.eq_ignore_ascii_case("rolling") => HistoryMode::Rolling,
50            Some(s) if s.eq_ignore_ascii_case("off") => HistoryMode::Off,
51            _ => HistoryMode::CacheAware,
52        }
53    }
54
55    pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
56        let (env_var, config_val, default) = match provider {
57            ProxyProvider::Anthropic => (
58                "LEAN_CTX_ANTHROPIC_UPSTREAM",
59                self.anthropic_upstream.as_deref(),
60                "https://api.anthropic.com",
61            ),
62            ProxyProvider::OpenAi => (
63                "LEAN_CTX_OPENAI_UPSTREAM",
64                self.openai_upstream.as_deref(),
65                "https://api.openai.com",
66            ),
67            ProxyProvider::Gemini => (
68                "LEAN_CTX_GEMINI_UPSTREAM",
69                self.gemini_upstream.as_deref(),
70                "https://generativelanguage.googleapis.com",
71            ),
72        };
73        let resolved = std::env::var(env_var)
74            .ok()
75            .and_then(|v| normalize_url_opt(&v))
76            .or_else(|| config_val.and_then(normalize_url_opt))
77            .unwrap_or_else(|| normalize_url(default));
78        match validate_upstream_url(&resolved) {
79            Ok(url) => url,
80            Err(e) => {
81                tracing::warn!("upstream validation failed, using default: {e}");
82                normalize_url(default)
83            }
84        }
85    }
86}
87
88#[derive(Debug, Clone, Copy)]
89pub enum ProxyProvider {
90    Anthropic,
91    OpenAi,
92    Gemini,
93}
94
95pub fn normalize_url(value: &str) -> String {
96    value.trim().trim_end_matches('/').to_string()
97}
98
99pub fn normalize_url_opt(value: &str) -> Option<String> {
100    let trimmed = normalize_url(value);
101    if trimmed.is_empty() {
102        None
103    } else {
104        Some(trimmed)
105    }
106}
107
108const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
109    "api.anthropic.com",
110    "api.openai.com",
111    "generativelanguage.googleapis.com",
112];
113
114pub(super) fn validate_upstream_url(url: &str) -> Result<String, String> {
115    let normalized = normalize_url(url);
116    if is_local_proxy_url(&normalized) {
117        return Ok(normalized);
118    }
119    if !normalized.starts_with("https://") {
120        return Err(format!(
121            "upstream URL must use HTTPS: {normalized} (set LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 to override)"
122        ));
123    }
124    let host = normalized
125        .strip_prefix("https://")
126        .unwrap_or(&normalized)
127        .split('/')
128        .next()
129        .unwrap_or("");
130    let host_no_port = host.split(':').next().unwrap_or(host);
131    if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
132        || std::env::var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM").is_ok()
133    {
134        Ok(normalized)
135    } else {
136        Err(format!(
137            "upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (set LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 to override)"
138        ))
139    }
140}
141
142pub fn is_local_proxy_url(value: &str) -> bool {
143    let n = normalize_url(value);
144    n.starts_with("http://127.0.0.1:")
145        || n.starts_with("http://localhost:")
146        || n.starts_with("http://[::1]:")
147}