use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ProxyConfig {
pub anthropic_upstream: Option<String>,
pub openai_upstream: Option<String>,
pub chatgpt_upstream: Option<String>,
pub gemini_upstream: Option<String>,
pub providers: Vec<ProviderEntry>,
pub history_mode: Option<String>,
pub allow_insecure_http_upstream: Option<bool>,
pub allow_custom_upstream: Option<bool>,
pub meter_openai_usage: Option<bool>,
pub cost_response_header: Option<String>,
pub cold_prefix_repack: Option<bool>,
pub role_aggressiveness: RoleAggressiveness,
pub live_compress: Option<bool>,
pub live_compress_exclude: Option<Vec<String>>,
pub compress_protect: Option<Vec<String>>,
pub ccr_inband: Option<bool>,
pub cache_breakpoint: Option<bool>,
pub counterfactual_metering: Option<bool>,
pub cache_aligner: Option<bool>,
pub cache_align_relocate: Option<bool>,
pub cache_policy: Option<bool>,
pub effort: Option<String>,
pub prose_ranker: Option<String>,
pub output_holdout: Option<f64>,
pub verbosity_steer: Option<bool>,
pub proxy_mode: Option<String>,
pub compat_stack: Option<String>,
pub codex_chatgpt_proxy: Option<bool>,
pub routing: RoutingRules,
pub baseline: BaselineConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct BaselineConfig {
pub reference_model: Option<String>,
pub local_shadow_rate_per_mtok: Option<f64>,
}
pub const DEFAULT_LOCAL_SHADOW_RATE_PER_MTOK: f64 = 0.25;
impl BaselineConfig {
#[must_use]
pub fn effective_local_shadow_rate(&self) -> f64 {
match self.local_shadow_rate_per_mtok {
Some(r) if r > 0.0 => r,
_ => DEFAULT_LOCAL_SHADOW_RATE_PER_MTOK,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct RoutingRules {
pub enabled: Option<bool>,
pub aliases: std::collections::BTreeMap<String, String>,
pub tiers: std::collections::BTreeMap<String, String>,
}
impl RoutingRules {
#[must_use]
pub fn is_active(&self) -> bool {
self.enabled.unwrap_or(false) && !(self.aliases.is_empty() && self.tiers.is_empty())
}
}
#[must_use]
pub fn parse_route_target(target: &str) -> Option<(Option<&str>, &str)> {
let t = target.trim();
if t.is_empty() {
return None;
}
match t.split_once(':') {
Some((provider, model)) => {
let (provider, model) = (provider.trim(), model.trim());
if provider.is_empty() || model.is_empty() {
None
} else {
Some((Some(provider), model))
}
}
None => Some((None, t)),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WireShape {
Anthropic,
OpenAi,
Gemini,
Bedrock,
}
impl WireShape {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
WireShape::Anthropic => "anthropic",
WireShape::OpenAi => "openai",
WireShape::Gemini => "gemini",
WireShape::Bedrock => "bedrock",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderEntry {
pub id: String,
pub shape: WireShape,
pub base_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aws_region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedProvider {
pub id: String,
pub shape: WireShape,
pub base_url: String,
pub api_key_env: Option<String>,
pub aws_region: Option<String>,
pub local: bool,
}
const BUILTIN_PROVIDER_IDS: &[&str] = &["anthropic", "openai", "chatgpt", "gemini"];
fn is_valid_provider_id(id: &str) -> bool {
!id.is_empty()
&& !BUILTIN_PROVIDER_IDS.contains(&id)
&& id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
fn valid_aws_region(region: &str) -> bool {
!region.is_empty()
&& region.len() <= 32
&& region
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}
fn valid_bedrock_endpoint(base_url: &str, region: &str) -> bool {
let Ok(url) = reqwest::Url::parse(base_url) else {
return false;
};
if !matches!(url.path(), "" | "/") || url.query().is_some() || url.fragment().is_some() {
return false;
}
let Some(host) = url.host_str().map(str::to_ascii_lowercase) else {
return false;
};
let loopback = host == "localhost"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_loopback());
if loopback {
return true;
}
let accepted = [
format!("bedrock-runtime.{region}.amazonaws.com"),
format!("bedrock-runtime.{region}.amazonaws.com.cn"),
format!("bedrock-runtime.{region}.api.aws"),
format!("bedrock-runtime-fips.{region}.amazonaws.com"),
format!("bedrock-runtime-fips.{region}.api.aws"),
];
accepted.iter().any(|candidate| candidate == &host)
|| host.ends_with(&format!(".bedrock-runtime.{region}.vpce.amazonaws.com"))
}
impl ResolvedProvider {
#[must_use]
pub fn injects_gateway_credential(&self) -> bool {
self.api_key_env.is_some() || self.shape == WireShape::Bedrock
}
#[must_use]
pub fn gateway_credential_present(&self) -> bool {
if self.shape == WireShape::Bedrock {
return ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]
.into_iter()
.all(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()));
}
self.api_key_env
.as_deref()
.is_some_and(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()))
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct RoleAggressiveness {
pub system: Option<f64>,
pub user: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProseRole {
System,
User,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProxyMode {
Cache,
Token,
}
impl ProxyMode {
fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"cache" | "cache_mode" | "cost_savings" => Some(Self::Cache),
"token" | "token_mode" | "token_savings" => Some(Self::Token),
_ => None,
}
}
pub fn preset_for(self, knob: &str) -> Option<bool> {
match (self, knob) {
(Self::Cache | Self::Token, "cache_aligner" | "cache_policy")
| (Self::Token, "cache_align_relocate" | "cold_prefix_repack" | "verbosity_steer") => {
Some(true)
}
(Self::Cache | Self::Token, "cache_breakpoint")
| (Self::Cache, "cache_align_relocate" | "cold_prefix_repack" | "verbosity_steer") => {
Some(false)
}
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProseRanker {
Auto,
Extractive,
Truncate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HistoryMode {
CacheAware,
Rolling,
Off,
}
impl ProxyConfig {
#[must_use]
pub fn resolved_proxy_mode(&self) -> ProxyMode {
let raw = std::env::var("LEAN_CTX_PROXY_MODE")
.ok()
.or_else(|| self.proxy_mode.clone());
raw.as_deref()
.and_then(ProxyMode::parse)
.unwrap_or(ProxyMode::Cache)
}
#[must_use]
pub fn is_headroom_compat(&self) -> bool {
let raw = std::env::var("LEAN_CTX_PROXY_COMPAT_STACK")
.ok()
.or_else(|| self.compat_stack.clone());
raw.as_deref()
.is_some_and(|s| s.trim().eq_ignore_ascii_case("headroom"))
}
pub fn resolved_history_mode(&self) -> HistoryMode {
let raw = std::env::var("LEAN_CTX_PROXY_HISTORY_MODE")
.ok()
.or_else(|| self.history_mode.clone());
if let Some(mode) = raw.as_deref().map(str::trim) {
if mode.eq_ignore_ascii_case("rolling") {
return HistoryMode::Rolling;
}
if mode.eq_ignore_ascii_case("off") {
return HistoryMode::Off;
}
}
match self.resolved_proxy_mode() {
ProxyMode::Token => HistoryMode::Rolling,
ProxyMode::Cache => HistoryMode::CacheAware,
}
}
pub fn meters_openai_usage(&self) -> bool {
self.meter_openai_usage.unwrap_or(true)
}
pub fn cost_response_header(&self) -> Option<String> {
self.cost_response_header
.as_deref()
.map(str::trim)
.filter(|h| !h.is_empty())
.map(str::to_lowercase)
}
#[must_use]
pub fn resolved_prose_ranker(&self) -> ProseRanker {
let raw = std::env::var("LEAN_CTX_PROXY_PROSE_RANKER")
.ok()
.or_else(|| self.prose_ranker.clone());
match raw.as_deref().map(str::trim) {
Some(s) if s.eq_ignore_ascii_case("truncate") || s.eq_ignore_ascii_case("off") => {
ProseRanker::Truncate
}
Some(s) if s.eq_ignore_ascii_case("extractive") => ProseRanker::Extractive,
_ => ProseRanker::Auto,
}
}
#[must_use]
pub fn output_holdout_fraction(&self) -> f64 {
let from_env = std::env::var("LEAN_CTX_PROXY_OUTPUT_HOLDOUT")
.ok()
.and_then(|v| v.trim().parse::<f64>().ok());
from_env
.or(self.output_holdout)
.unwrap_or(0.0)
.clamp(0.0, 1.0)
}
#[must_use]
pub fn verbosity_steer_enabled(&self) -> bool {
if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_VERBOSITY_STEER") {
let v = raw.trim();
return v.eq_ignore_ascii_case("1")
|| v.eq_ignore_ascii_case("true")
|| v.eq_ignore_ascii_case("on")
|| v.eq_ignore_ascii_case("yes");
}
if let Some(v) = self.verbosity_steer {
return v;
}
self.resolved_proxy_mode()
.preset_for("verbosity_steer")
.unwrap_or(false)
}
pub fn codex_chatgpt_proxy_enabled(&self) -> bool {
std::env::var("LEAN_CTX_CODEX_CHATGPT_PROXY").is_ok()
|| self.codex_chatgpt_proxy.unwrap_or(false)
}
pub fn repacks_cold_prefix(&self) -> bool {
if std::env::var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK").is_ok() {
return true;
}
if let Some(v) = self.cold_prefix_repack {
return v;
}
self.resolved_proxy_mode()
.preset_for("cold_prefix_repack")
.unwrap_or(false)
}
pub fn ccr_inband_enabled(&self) -> bool {
std::env::var("LEAN_CTX_PROXY_CCR_INBAND").is_ok() || self.ccr_inband.unwrap_or(false)
}
pub fn cache_breakpoint_enabled(&self) -> bool {
if std::env::var("LEAN_CTX_PROXY_CACHE_BREAKPOINT").is_ok() {
return true;
}
if let Some(v) = self.cache_breakpoint {
return v;
}
if self.is_headroom_compat() {
return false;
}
self.resolved_proxy_mode()
.preset_for("cache_breakpoint")
.unwrap_or(false)
}
pub fn counterfactual_metering_enabled(&self) -> bool {
std::env::var("LEAN_CTX_PROXY_COUNTERFACTUAL").is_ok()
|| self.counterfactual_metering.unwrap_or(false)
}
pub fn cache_aligner_enabled(&self) -> bool {
env_bool_or("LEAN_CTX_PROXY_CACHE_ALIGNER", self.cache_aligner, true)
}
pub fn cache_align_relocate_enabled(&self) -> bool {
if std::env::var("LEAN_CTX_PROXY_CACHE_ALIGN_RELOCATE").is_ok() {
return true;
}
if let Some(v) = self.cache_align_relocate {
return v;
}
if self.is_headroom_compat() {
return false;
}
self.resolved_proxy_mode()
.preset_for("cache_align_relocate")
.unwrap_or(false)
}
pub fn cache_policy_enabled(&self) -> bool {
env_bool_or("LEAN_CTX_PROXY_CACHE_POLICY", self.cache_policy, true)
}
#[must_use]
pub fn resolved_effort(&self) -> Option<super::Effort> {
if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_EFFORT") {
let trimmed = raw.trim();
if trimmed.eq_ignore_ascii_case("off") {
return None;
}
if let Some(effort) = super::Effort::parse(trimmed) {
return Some(effort);
}
}
self.effort.as_deref().and_then(super::Effort::parse)
}
pub fn live_compresses(&self) -> bool {
if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_LIVE_COMPRESS") {
match raw.trim().to_ascii_lowercase().as_str() {
"0" | "false" | "off" | "no" => return false,
"1" | "true" | "on" | "yes" => return true,
_ => {}
}
}
if let Some(v) = self.live_compress {
return v;
}
if self.is_headroom_compat() {
return false;
}
match self.resolved_proxy_mode() {
ProxyMode::Cache | ProxyMode::Token => true,
}
}
#[must_use]
pub fn live_compress_exclude_patterns(&self) -> Vec<String> {
self.live_compress_exclude
.clone()
.unwrap_or_else(default_live_compress_exclude)
}
#[must_use]
pub fn is_tool_live_compress_excluded(&self, tool_name: &str) -> bool {
let name = tool_name.to_ascii_lowercase();
self.live_compress_exclude_patterns().iter().any(|p| {
let p = p.trim().to_ascii_lowercase();
!p.is_empty() && name.contains(p.as_str())
})
}
#[must_use]
pub fn compress_protect_globs(&self) -> Vec<glob::Pattern> {
self.compress_protect
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|p| glob::Pattern::new(p.trim()).ok())
.collect()
}
#[must_use]
pub fn is_path_compress_protected(&self, path: &str) -> bool {
let patterns = self.compress_protect_globs();
if patterns.is_empty() {
return false;
}
let norm = path.replace('\\', "/");
let base = norm.rsplit('/').next().unwrap_or(norm.as_str());
patterns.iter().any(|p| p.matches(&norm) || p.matches(base))
}
#[must_use]
pub fn resolved_role_aggressiveness(&self, role: ProseRole) -> Option<f64> {
let (env_var, configured) = match role {
ProseRole::System => (
"LEAN_CTX_PROXY_SYSTEM_AGGR",
self.role_aggressiveness.system,
),
ProseRole::User => ("LEAN_CTX_PROXY_USER_AGGR", self.role_aggressiveness.user),
};
let from_env = std::env::var(env_var)
.ok()
.and_then(|v| v.trim().parse::<f64>().ok());
let resolved = from_env.or(configured);
if resolved.is_some() {
return resolved.map(|a| a.clamp(0.0, 1.0));
}
if self.resolved_proxy_mode() == ProxyMode::Token && role == ProseRole::System {
return Some(0.5);
}
None
}
pub fn allows_insecure_http_upstream(&self) -> bool {
std::env::var("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM").is_ok()
|| self.allow_insecure_http_upstream.unwrap_or(false)
}
pub fn allows_custom_upstream(&self) -> bool {
std::env::var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM").is_ok()
|| self.allow_custom_upstream.unwrap_or(false)
}
#[must_use]
pub fn has_custom_host_upstream(&self) -> bool {
[
self.anthropic_upstream.as_deref(),
self.openai_upstream.as_deref(),
self.chatgpt_upstream.as_deref(),
self.gemini_upstream.as_deref(),
]
.into_iter()
.flatten()
.filter_map(normalize_url_opt)
.any(|u| is_custom_upstream_host(&u))
}
fn provider_spec(&self, provider: ProxyProvider) -> (&'static str, Option<&str>, &'static str) {
match provider {
ProxyProvider::Anthropic => (
"LEAN_CTX_ANTHROPIC_UPSTREAM",
self.anthropic_upstream.as_deref(),
"https://api.anthropic.com",
),
ProxyProvider::OpenAi => (
"LEAN_CTX_OPENAI_UPSTREAM",
self.openai_upstream.as_deref(),
"https://api.openai.com",
),
ProxyProvider::ChatGpt => (
"LEAN_CTX_CHATGPT_UPSTREAM",
self.chatgpt_upstream.as_deref(),
"https://chatgpt.com",
),
ProxyProvider::Gemini => (
"LEAN_CTX_GEMINI_UPSTREAM",
self.gemini_upstream.as_deref(),
"https://generativelanguage.googleapis.com",
),
}
}
fn resolve_upstream_checked(&self, provider: ProxyProvider) -> Result<String, String> {
self.resolve_upstream_inner(provider, true)
}
fn resolve_upstream_inner(
&self,
provider: ProxyProvider,
use_env: bool,
) -> Result<String, String> {
let (env_var, config_val, default) = self.provider_spec(provider);
let env_val = if use_env {
std::env::var(env_var)
.ok()
.and_then(|v| normalize_url_opt(&v))
} else {
None
};
let candidate = env_val.or_else(|| config_val.and_then(normalize_url_opt));
match candidate {
None => Ok(normalize_url(default)),
Some(url) => validate_upstream_url(
&url,
self.allows_insecure_http_upstream(),
self.allows_custom_upstream(),
),
}
}
pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
match self.resolve_upstream_checked(provider) {
Ok(url) => url,
Err(e) => {
tracing::warn!("upstream validation failed, using default: {e}");
normalize_url(self.provider_spec(provider).2)
}
}
}
pub fn resolve_all(&self) -> Upstreams {
Upstreams {
anthropic: self.resolve_upstream(ProxyProvider::Anthropic),
openai: self.resolve_upstream(ProxyProvider::OpenAi),
chatgpt: self.resolve_upstream(ProxyProvider::ChatGpt),
gemini: self.resolve_upstream(ProxyProvider::Gemini),
providers: self.resolve_providers(),
}
}
#[must_use]
pub fn resolve_providers(&self) -> Vec<ResolvedProvider> {
let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
let mut out = Vec::new();
for entry in &self.providers {
if !entry.enabled.unwrap_or(true) {
continue;
}
let id = entry.id.trim();
if !is_valid_provider_id(id) {
tracing::warn!(
"[proxy.providers] invalid id '{id}' (lowercase alnum/-/_ only, \
must not shadow a built-in provider) — entry skipped"
);
continue;
}
if !seen.insert(id) {
tracing::warn!("[proxy.providers] duplicate id '{id}' — keeping first entry");
continue;
}
match validate_upstream_url(&entry.base_url, self.allows_insecure_http_upstream(), true)
{
Ok(base_url) => {
let aws_region = entry
.aws_region
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if entry.shape == WireShape::Bedrock {
let Some(region) = aws_region.filter(|value| valid_aws_region(value))
else {
tracing::warn!(
"[proxy.providers] Bedrock provider '{id}' requires valid aws_region — skipped"
);
continue;
};
if entry.api_key_env.is_some() || !valid_bedrock_endpoint(&base_url, region)
{
tracing::warn!(
"[proxy.providers] Bedrock provider '{id}' has invalid credential mode or endpoint — skipped"
);
continue;
}
} else if aws_region.is_some() {
tracing::warn!(
"[proxy.providers] non-Bedrock provider '{id}' cannot set aws_region — skipped"
);
continue;
}
let local = entry.local.unwrap_or_else(|| is_local_proxy_url(&base_url));
out.push(ResolvedProvider {
id: id.to_string(),
shape: entry.shape,
base_url,
api_key_env: entry
.api_key_env
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string),
aws_region: aws_region.map(str::to_string),
local,
});
}
Err(e) => {
tracing::warn!("[proxy.providers] '{id}' has invalid base_url — skipped: {e}");
}
}
}
out
}
pub fn resolve_all_disk(&self) -> Upstreams {
let pick = |provider: ProxyProvider| {
self.resolve_upstream_inner(provider, false)
.unwrap_or_else(|_| normalize_url(self.provider_spec(provider).2))
};
Upstreams {
anthropic: pick(ProxyProvider::Anthropic),
openai: pick(ProxyProvider::OpenAi),
chatgpt: pick(ProxyProvider::ChatGpt),
gemini: pick(ProxyProvider::Gemini),
providers: self.resolve_providers(),
}
}
pub fn refresh_upstreams(&self, last: &Upstreams) -> Upstreams {
let keep = |provider: ProxyProvider, prev: &str| {
self.resolve_upstream_checked(provider).unwrap_or_else(|e| {
tracing::warn!("upstream invalid, keeping {prev}: {e}");
prev.to_string()
})
};
Upstreams {
anthropic: keep(ProxyProvider::Anthropic, &last.anthropic),
openai: keep(ProxyProvider::OpenAi, &last.openai),
chatgpt: keep(ProxyProvider::ChatGpt, &last.chatgpt),
gemini: keep(ProxyProvider::Gemini, &last.gemini),
providers: self.resolve_providers(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Upstreams {
pub anthropic: String,
pub openai: String,
pub chatgpt: String,
pub gemini: String,
pub providers: Vec<ResolvedProvider>,
}
impl Upstreams {
#[must_use]
pub fn provider_by_id(&self, id: &str) -> Option<&ResolvedProvider> {
self.providers.iter().find(|p| p.id == id)
}
}
#[derive(Debug, Clone, Copy)]
pub enum ProxyProvider {
Anthropic,
OpenAi,
ChatGpt,
Gemini,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpstreamDrift {
EnvNotApplied,
ConfigNotApplied,
}
pub fn env_upstream_override(provider: ProxyProvider) -> Option<String> {
let var = match provider {
ProxyProvider::Anthropic => "LEAN_CTX_ANTHROPIC_UPSTREAM",
ProxyProvider::OpenAi => "LEAN_CTX_OPENAI_UPSTREAM",
ProxyProvider::ChatGpt => "LEAN_CTX_CHATGPT_UPSTREAM",
ProxyProvider::Gemini => "LEAN_CTX_GEMINI_UPSTREAM",
};
std::env::var(var).ok().and_then(|v| normalize_url_opt(&v))
}
pub fn diagnose_drift(env: Option<&str>, disk: &str, live: &str) -> Option<UpstreamDrift> {
if let Some(env) = env {
return (env != live).then_some(UpstreamDrift::EnvNotApplied);
}
(disk != live).then_some(UpstreamDrift::ConfigNotApplied)
}
fn env_bool_or(env_key: &str, configured: Option<bool>, default: bool) -> bool {
if let Ok(raw) = std::env::var(env_key) {
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => return true,
"0" | "false" | "no" | "off" => return false,
_ => {}
}
}
configured.unwrap_or(default)
}
fn default_live_compress_exclude() -> Vec<String> {
vec!["serena".to_string()]
}
pub fn normalize_url(value: &str) -> String {
value.trim().trim_end_matches('/').to_string()
}
pub fn normalize_url_opt(value: &str) -> Option<String> {
let trimmed = normalize_url(value);
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
"api.anthropic.com",
"api.openai.com",
"chatgpt.com",
"generativelanguage.googleapis.com",
"api.x.ai",
"cli-chat-proxy.grok.com",
"api.commandcode.ai",
];
pub(super) fn validate_upstream_url(
url: &str,
allow_insecure_http: bool,
allow_custom_host: bool,
) -> Result<String, String> {
let normalized = normalize_url(url);
if is_local_proxy_url(&normalized) {
return Ok(normalized);
}
if normalized.starts_with("http://") {
if allow_insecure_http {
return Ok(normalized);
}
return Err(format!(
"upstream URL must use HTTPS: {normalized} (for a trusted local-network HTTP \
upstream opt in with LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM=1 or \
`[proxy] allow_insecure_http_upstream = true`)"
));
}
let Some(host_segment) = normalized.strip_prefix("https://") else {
return Err(format!(
"upstream URL must start with http:// or https://: {normalized}"
));
};
let host = host_segment.split('/').next().unwrap_or("");
let host_no_port = host.split(':').next().unwrap_or(host);
if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port) || allow_custom_host {
Ok(normalized)
} else {
Err(format!(
"upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (for a \
custom upstream host opt in with LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 or \
`[proxy] allow_custom_upstream = true`)"
))
}
}
fn is_custom_upstream_host(url: &str) -> bool {
let n = normalize_url(url);
if is_local_proxy_url(&n) {
return false;
}
let Some(host_segment) = n.strip_prefix("https://") else {
return false;
};
let host = host_segment.split('/').next().unwrap_or("");
let host_no_port = host.split(':').next().unwrap_or(host);
!host_no_port.is_empty() && !ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
}
pub fn is_local_proxy_url(value: &str) -> bool {
let n = normalize_url(value);
n.starts_with("http://127.0.0.1:")
|| n.starts_with("http://localhost:")
|| n.starts_with("http://[::1]:")
}
#[cfg(test)]
#[path = "proxy_tests.rs"]
mod tests;