1use serde::{Deserialize, Serialize};
4
5#[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 pub history_mode: Option<String>,
15 pub allow_insecure_http_upstream: Option<bool>,
18 pub meter_openai_usage: Option<bool>,
24 pub role_aggressiveness: RoleAggressiveness,
28}
29
30#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
39#[serde(default)]
40pub struct RoleAggressiveness {
41 pub system: Option<f64>,
44 pub user: Option<f64>,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ProseRole {
53 System,
54 User,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum HistoryMode {
67 CacheAware,
74 Rolling,
77 Off,
80}
81
82impl ProxyConfig {
83 pub fn resolved_history_mode(&self) -> HistoryMode {
88 let raw = std::env::var("LEAN_CTX_PROXY_HISTORY_MODE")
89 .ok()
90 .or_else(|| self.history_mode.clone());
91 match raw.as_deref().map(str::trim) {
92 Some(s) if s.eq_ignore_ascii_case("rolling") => HistoryMode::Rolling,
93 Some(s) if s.eq_ignore_ascii_case("off") => HistoryMode::Off,
94 _ => HistoryMode::CacheAware,
95 }
96 }
97
98 pub fn meters_openai_usage(&self) -> bool {
102 self.meter_openai_usage.unwrap_or(true)
103 }
104
105 #[must_use]
113 pub fn resolved_role_aggressiveness(&self, role: ProseRole) -> Option<f64> {
114 let (env_var, configured) = match role {
115 ProseRole::System => (
116 "LEAN_CTX_PROXY_SYSTEM_AGGR",
117 self.role_aggressiveness.system,
118 ),
119 ProseRole::User => ("LEAN_CTX_PROXY_USER_AGGR", self.role_aggressiveness.user),
120 };
121 let from_env = std::env::var(env_var)
122 .ok()
123 .and_then(|v| v.trim().parse::<f64>().ok());
124 from_env.or(configured).map(|a| a.clamp(0.0, 1.0))
125 }
126
127 pub fn allows_insecure_http_upstream(&self) -> bool {
133 std::env::var("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM").is_ok()
134 || self.allow_insecure_http_upstream.unwrap_or(false)
135 }
136
137 fn provider_spec(&self, provider: ProxyProvider) -> (&'static str, Option<&str>, &'static str) {
139 match provider {
140 ProxyProvider::Anthropic => (
141 "LEAN_CTX_ANTHROPIC_UPSTREAM",
142 self.anthropic_upstream.as_deref(),
143 "https://api.anthropic.com",
144 ),
145 ProxyProvider::OpenAi => (
146 "LEAN_CTX_OPENAI_UPSTREAM",
147 self.openai_upstream.as_deref(),
148 "https://api.openai.com",
149 ),
150 ProxyProvider::Gemini => (
151 "LEAN_CTX_GEMINI_UPSTREAM",
152 self.gemini_upstream.as_deref(),
153 "https://generativelanguage.googleapis.com",
154 ),
155 }
156 }
157
158 fn resolve_upstream_checked(&self, provider: ProxyProvider) -> Result<String, String> {
165 self.resolve_upstream_inner(provider, true)
166 }
167
168 fn resolve_upstream_inner(
172 &self,
173 provider: ProxyProvider,
174 use_env: bool,
175 ) -> Result<String, String> {
176 let (env_var, config_val, default) = self.provider_spec(provider);
177 let env_val = if use_env {
178 std::env::var(env_var)
179 .ok()
180 .and_then(|v| normalize_url_opt(&v))
181 } else {
182 None
183 };
184 let candidate = env_val.or_else(|| config_val.and_then(normalize_url_opt));
185 match candidate {
186 None => Ok(normalize_url(default)),
187 Some(url) => validate_upstream_url(&url, self.allows_insecure_http_upstream()),
188 }
189 }
190
191 pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
195 match self.resolve_upstream_checked(provider) {
196 Ok(url) => url,
197 Err(e) => {
198 tracing::warn!("upstream validation failed, using default: {e}");
199 normalize_url(self.provider_spec(provider).2)
200 }
201 }
202 }
203
204 pub fn resolve_all(&self) -> Upstreams {
206 Upstreams {
207 anthropic: self.resolve_upstream(ProxyProvider::Anthropic),
208 openai: self.resolve_upstream(ProxyProvider::OpenAi),
209 gemini: self.resolve_upstream(ProxyProvider::Gemini),
210 }
211 }
212
213 pub fn resolve_all_disk(&self) -> Upstreams {
217 let pick = |provider: ProxyProvider| {
218 self.resolve_upstream_inner(provider, false)
219 .unwrap_or_else(|_| normalize_url(self.provider_spec(provider).2))
220 };
221 Upstreams {
222 anthropic: pick(ProxyProvider::Anthropic),
223 openai: pick(ProxyProvider::OpenAi),
224 gemini: pick(ProxyProvider::Gemini),
225 }
226 }
227
228 pub fn refresh_upstreams(&self, last: &Upstreams) -> Upstreams {
233 let keep = |provider: ProxyProvider, prev: &str| {
234 self.resolve_upstream_checked(provider).unwrap_or_else(|e| {
235 tracing::warn!("upstream invalid, keeping {prev}: {e}");
236 prev.to_string()
237 })
238 };
239 Upstreams {
240 anthropic: keep(ProxyProvider::Anthropic, &last.anthropic),
241 openai: keep(ProxyProvider::OpenAi, &last.openai),
242 gemini: keep(ProxyProvider::Gemini, &last.gemini),
243 }
244 }
245}
246
247#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct Upstreams {
252 pub anthropic: String,
253 pub openai: String,
254 pub gemini: String,
255}
256
257#[derive(Debug, Clone, Copy)]
258pub enum ProxyProvider {
259 Anthropic,
260 OpenAi,
261 Gemini,
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub enum UpstreamDrift {
267 EnvNotApplied,
274 ConfigNotApplied,
278}
279
280pub fn env_upstream_override(provider: ProxyProvider) -> Option<String> {
284 let var = match provider {
285 ProxyProvider::Anthropic => "LEAN_CTX_ANTHROPIC_UPSTREAM",
286 ProxyProvider::OpenAi => "LEAN_CTX_OPENAI_UPSTREAM",
287 ProxyProvider::Gemini => "LEAN_CTX_GEMINI_UPSTREAM",
288 };
289 std::env::var(var).ok().and_then(|v| normalize_url_opt(&v))
290}
291
292pub fn diagnose_drift(env: Option<&str>, disk: &str, live: &str) -> Option<UpstreamDrift> {
296 if let Some(env) = env {
297 return (env != live).then_some(UpstreamDrift::EnvNotApplied);
301 }
302 (disk != live).then_some(UpstreamDrift::ConfigNotApplied)
304}
305
306pub fn normalize_url(value: &str) -> String {
307 value.trim().trim_end_matches('/').to_string()
308}
309
310pub fn normalize_url_opt(value: &str) -> Option<String> {
311 let trimmed = normalize_url(value);
312 if trimmed.is_empty() {
313 None
314 } else {
315 Some(trimmed)
316 }
317}
318
319const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
320 "api.anthropic.com",
321 "api.openai.com",
322 "generativelanguage.googleapis.com",
323];
324
325pub(super) fn validate_upstream_url(
326 url: &str,
327 allow_insecure_http: bool,
328) -> Result<String, String> {
329 let normalized = normalize_url(url);
330 if is_local_proxy_url(&normalized) {
332 return Ok(normalized);
333 }
334
335 if normalized.starts_with("http://") {
342 if allow_insecure_http {
343 return Ok(normalized);
344 }
345 return Err(format!(
346 "upstream URL must use HTTPS: {normalized} (for a trusted local-network HTTP \
347 upstream opt in with LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM=1 or \
348 `[proxy] allow_insecure_http_upstream = true`)"
349 ));
350 }
351 let Some(host_segment) = normalized.strip_prefix("https://") else {
352 return Err(format!(
353 "upstream URL must start with http:// or https://: {normalized}"
354 ));
355 };
356
357 let host = host_segment.split('/').next().unwrap_or("");
358 let host_no_port = host.split(':').next().unwrap_or(host);
359 if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
360 || std::env::var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM").is_ok()
361 {
362 Ok(normalized)
363 } else {
364 Err(format!(
365 "upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (set LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 to override)"
366 ))
367 }
368}
369
370pub fn is_local_proxy_url(value: &str) -> bool {
371 let n = normalize_url(value);
372 n.starts_with("http://127.0.0.1:")
373 || n.starts_with("http://localhost:")
374 || n.starts_with("http://[::1]:")
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn loopback_http_is_always_allowed() {
383 assert_eq!(
384 validate_upstream_url("http://127.0.0.1:4444", false).unwrap(),
385 "http://127.0.0.1:4444"
386 );
387 assert_eq!(
388 validate_upstream_url("http://localhost:2455/", false).unwrap(),
389 "http://localhost:2455"
390 );
391 }
392
393 #[test]
394 fn https_allowlisted_host_is_allowed() {
395 assert_eq!(
396 validate_upstream_url("https://api.openai.com", false).unwrap(),
397 "https://api.openai.com"
398 );
399 }
400
401 #[test]
402 fn non_loopback_http_is_rejected_without_optin() {
403 let err = validate_upstream_url("http://host.docker.internal:2455", false).unwrap_err();
404 assert!(
408 err.contains("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM"),
409 "hint must name the working opt-in, got: {err}"
410 );
411 }
412
413 #[test]
414 fn non_loopback_http_is_allowed_with_optin() {
415 assert_eq!(
416 validate_upstream_url("http://host.docker.internal:2455", true).unwrap(),
417 "http://host.docker.internal:2455"
418 );
419 }
420
421 #[test]
422 fn unknown_scheme_is_rejected() {
423 assert!(validate_upstream_url("ftp://example.com", true).is_err());
424 }
425
426 #[test]
427 fn config_flag_enables_insecure_http_optin() {
428 let cfg = ProxyConfig {
431 allow_insecure_http_upstream: Some(true),
432 ..Default::default()
433 };
434 assert!(cfg.allows_insecure_http_upstream());
435 }
436
437 #[test]
441 fn resolve_all_disk_uses_config_then_default() {
442 let cfg = ProxyConfig {
443 openai_upstream: Some("http://127.0.0.1:19101".into()),
444 ..Default::default()
445 };
446 let up = cfg.resolve_all_disk();
447 assert_eq!(up.openai, "http://127.0.0.1:19101");
448 assert_eq!(up.anthropic, "https://api.anthropic.com");
449 assert_eq!(up.gemini, "https://generativelanguage.googleapis.com");
450 }
451
452 #[test]
453 fn resolve_all_disk_normalizes_trailing_slash() {
454 let cfg = ProxyConfig {
455 openai_upstream: Some("http://127.0.0.1:19101/".into()),
456 ..Default::default()
457 };
458 assert_eq!(cfg.resolve_all_disk().openai, "http://127.0.0.1:19101");
459 }
460
461 #[test]
462 fn refresh_keeps_last_good_on_invalid_config() {
463 let _lock = crate::core::data_dir::test_env_lock();
466 crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
467
468 let last = Upstreams {
470 anthropic: "https://api.anthropic.com".into(),
471 openai: "http://127.0.0.1:19101".into(),
472 gemini: "https://generativelanguage.googleapis.com".into(),
473 };
474 let cfg = ProxyConfig {
475 openai_upstream: Some("not-a-valid-url".into()),
476 ..Default::default()
477 };
478 assert_eq!(
479 cfg.refresh_upstreams(&last).openai,
480 "http://127.0.0.1:19101",
481 "invalid upstream → keep last good, never silently fall to default"
482 );
483 }
484
485 #[test]
486 fn refresh_adopts_valid_config_change() {
487 let _lock = crate::core::data_dir::test_env_lock();
488 crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
489
490 let last = Upstreams {
491 anthropic: "https://api.anthropic.com".into(),
492 openai: "http://127.0.0.1:19101".into(),
493 gemini: "https://generativelanguage.googleapis.com".into(),
494 };
495 let cfg = ProxyConfig {
496 openai_upstream: Some("http://127.0.0.1:19102".into()),
497 ..Default::default()
498 };
499 assert_eq!(
500 cfg.refresh_upstreams(&last).openai,
501 "http://127.0.0.1:19102"
502 );
503 }
504
505 #[test]
506 fn diagnose_drift_env_set_but_proxy_serves_other() {
507 assert_eq!(
510 diagnose_drift(
511 Some("http://127.0.0.1:2455"),
512 "https://api.openai.com",
513 "https://api.openai.com"
514 ),
515 Some(UpstreamDrift::EnvNotApplied)
516 );
517 }
518
519 #[test]
520 fn diagnose_drift_env_consistent_is_in_sync() {
521 assert_eq!(
523 diagnose_drift(
524 Some("http://127.0.0.1:2455"),
525 "https://api.openai.com",
526 "http://127.0.0.1:2455"
527 ),
528 None
529 );
530 }
531
532 #[test]
533 fn diagnose_drift_config_changed_needs_restart() {
534 assert_eq!(
535 diagnose_drift(None, "http://127.0.0.1:2455", "https://api.openai.com"),
536 Some(UpstreamDrift::ConfigNotApplied)
537 );
538 }
539
540 #[test]
541 fn diagnose_drift_in_sync() {
542 assert_eq!(
543 diagnose_drift(None, "https://api.openai.com", "https://api.openai.com"),
544 None
545 );
546 }
547
548 #[test]
549 fn role_aggressiveness_defaults_to_off() {
550 let cfg = ProxyConfig::default();
553 let _lock = crate::core::data_dir::test_env_lock();
555 crate::test_env::remove_var("LEAN_CTX_PROXY_SYSTEM_AGGR");
556 crate::test_env::remove_var("LEAN_CTX_PROXY_USER_AGGR");
557 assert_eq!(cfg.resolved_role_aggressiveness(ProseRole::System), None);
558 assert_eq!(cfg.resolved_role_aggressiveness(ProseRole::User), None);
559 }
560
561 #[test]
562 fn role_aggressiveness_reads_config_and_clamps() {
563 let _lock = crate::core::data_dir::test_env_lock();
564 crate::test_env::remove_var("LEAN_CTX_PROXY_SYSTEM_AGGR");
565 crate::test_env::remove_var("LEAN_CTX_PROXY_USER_AGGR");
566 let cfg = ProxyConfig {
567 role_aggressiveness: RoleAggressiveness {
568 system: Some(0.7),
569 user: Some(1.5),
570 },
571 ..Default::default()
572 };
573 assert_eq!(
574 cfg.resolved_role_aggressiveness(ProseRole::System),
575 Some(0.7)
576 );
577 assert_eq!(cfg.resolved_role_aggressiveness(ProseRole::User), Some(1.0));
579 }
580
581 #[test]
582 fn role_aggressiveness_env_overrides_config() {
583 let _lock = crate::core::data_dir::test_env_lock();
584 crate::test_env::set_var("LEAN_CTX_PROXY_SYSTEM_AGGR", "0.25");
585 let cfg = ProxyConfig {
586 role_aggressiveness: RoleAggressiveness {
587 system: Some(0.9),
588 user: None,
589 },
590 ..Default::default()
591 };
592 assert_eq!(
593 cfg.resolved_role_aggressiveness(ProseRole::System),
594 Some(0.25),
595 "env override must win over the configured value"
596 );
597 crate::test_env::remove_var("LEAN_CTX_PROXY_SYSTEM_AGGR");
598 }
599
600 #[test]
601 fn role_aggressiveness_ignores_blank_env() {
602 let _lock = crate::core::data_dir::test_env_lock();
603 crate::test_env::set_var("LEAN_CTX_PROXY_USER_AGGR", " ");
604 let cfg = ProxyConfig {
605 role_aggressiveness: RoleAggressiveness {
606 system: None,
607 user: Some(0.4),
608 },
609 ..Default::default()
610 };
611 assert_eq!(
612 cfg.resolved_role_aggressiveness(ProseRole::User),
613 Some(0.4),
614 "a blank/garbage env value must fall back to config, not disable it"
615 );
616 crate::test_env::remove_var("LEAN_CTX_PROXY_USER_AGGR");
617 }
618}