use std::time::Duration;
use anyhow::{Context, Result};
use llmtrim_core::ir::ProviderKind;
pub fn redact_proxy_url(url: &str) -> String {
if let Some(scheme_end) = url.find("://") {
let after_scheme = &url[scheme_end + 3..];
let path_start = after_scheme.find('/').unwrap_or(after_scheme.len());
let host_part = &after_scheme[..path_start];
if let Some(at_pos) = host_part.rfind('@') {
let scheme = &url[..scheme_end + 3];
let rest = &after_scheme[at_pos + 1..];
return format!("{scheme}{rest}");
}
}
url.to_string()
}
pub fn upstream_proxy_url(bind_addr: Option<std::net::SocketAddr>) -> Result<Option<String>> {
let Some(url) = llmtrim_core::config::RuntimeConfig::get()
.upstream_proxy
.clone()
else {
return Ok(None);
};
validate_proxy_url(&url, bind_addr)?;
Ok(Some(url))
}
fn is_loopback_host(host: &str) -> bool {
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
}
fn validate_proxy_url(url: &str, bind_addr: Option<std::net::SocketAddr>) -> Result<()> {
let host = proxy_host(url).with_context(|| {
format!(
"LLMTRIM_UPSTREAM_PROXY: cannot parse host from `{}`",
redact_proxy_url(url)
)
})?;
let raw_host_segment = {
let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or("");
let without_userinfo = after_scheme
.rfind('@')
.map(|i| &after_scheme[i + 1..])
.unwrap_or(after_scheme);
without_userinfo
.split(['/', '?'])
.next()
.unwrap_or("")
.to_ascii_lowercase()
};
let upstream_is_loopback = is_loopback_host(host)
|| raw_host_segment == "[::1]"
|| raw_host_segment.starts_with("[::1]:");
if upstream_is_loopback {
let upstream_port: Option<u16> = raw_host_segment
.rsplit(':')
.next()
.and_then(|p| p.parse().ok());
if let (Some(bind), Some(up_port)) = (bind_addr, upstream_port) {
if up_port == bind.port() {
anyhow::bail!(
"LLMTRIM_UPSTREAM_PROXY points at llmtrim's own listen address \
(`{host}:{up_port}`), which would cause infinite recursion. \
Use a different port or a non-loopback proxy address."
);
}
} else if bind_addr.is_none() {
} else {
anyhow::bail!(
"LLMTRIM_UPSTREAM_PROXY points at a loopback address (`{host}`) without an \
explicit port — cannot verify it does not point at llmtrim itself. \
Use a non-loopback proxy address or include the port explicitly."
);
}
}
ureq::Proxy::new(url).with_context(|| {
format!(
"LLMTRIM_UPSTREAM_PROXY: invalid proxy URL `{}`",
redact_proxy_url(url)
)
})?;
Ok(())
}
fn proxy_host(url: &str) -> Option<&str> {
let after_scheme = url.split_once("://")?.1;
let without_userinfo = after_scheme
.rfind('@')
.map(|i| &after_scheme[i + 1..])
.unwrap_or(after_scheme);
let host = without_userinfo.split([':', '/', '?']).next()?;
(!host.is_empty()).then_some(host)
}
fn make_proxy(url: &str) -> Result<ureq::Proxy> {
ureq::Proxy::new(url).with_context(|| {
format!(
"failed to construct upstream proxy `{}`",
redact_proxy_url(url)
)
})
}
pub(crate) const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(600);
pub struct Endpoint {
pub provider: ProviderKind,
pub base_url: String,
pub api_key: String,
}
impl Endpoint {
pub fn from_env(provider: ProviderKind) -> Result<Self> {
let (key_var, base_var, default_base) = match provider {
ProviderKind::OpenAi => (
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"https://api.openai.com",
),
ProviderKind::Anthropic => (
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"https://api.anthropic.com",
),
ProviderKind::Google => (
"GEMINI_API_KEY",
"GEMINI_BASE_URL",
"https://generativelanguage.googleapis.com",
),
};
let api_key = std::env::var(key_var).with_context(|| format!("{key_var} is not set"))?;
let base_url = std::env::var(base_var).unwrap_or_else(|_| default_base.to_string());
Ok(Self {
provider,
base_url,
api_key,
})
}
pub fn url(&self) -> String {
let base = self.base_url.trim_end_matches('/');
match self.provider {
ProviderKind::OpenAi => format!("{base}/v1/chat/completions"),
ProviderKind::Anthropic => format!("{base}/v1/messages"),
ProviderKind::Google => {
format!("{base}/v1beta/models/gemini-2.0-flash:generateContent")
}
}
}
fn headers(&self) -> Vec<(&'static str, String)> {
let mut headers = vec![("content-type", "application/json".to_string())];
match self.provider {
ProviderKind::OpenAi => {
headers.push(("authorization", format!("Bearer {}", self.api_key)));
}
ProviderKind::Anthropic => {
headers.push(("x-api-key", self.api_key.clone()));
headers.push(("anthropic-version", "2023-06-01".to_string()));
}
ProviderKind::Google => {
headers.push(("x-goog-api-key", self.api_key.clone()));
}
}
headers
}
pub fn send(&self, request_json: &str, proxy_url: Option<&str>) -> Result<String> {
let url = self.url();
let proxy = match proxy_url {
Some(u) => Some(make_proxy(u)?),
None => None,
};
let mut request = ureq::post(&url)
.config()
.proxy(proxy)
.timeout_global(Some(UPSTREAM_TIMEOUT))
.build();
for (name, value) in self.headers() {
request = request.header(name, &value);
}
let mut response = request
.send(request_json)
.map_err(|e| anyhow::anyhow!("request to {url} failed: {e}"))?;
response
.body_mut()
.read_to_string()
.context("failed to read response body")
}
}
pub struct Upstream {
pub status: u16,
pub content_type: Option<String>,
pub retry_after: Option<String>,
pub reader: Box<dyn std::io::Read>,
}
pub fn forward_post(
url: &str,
headers: &[(String, String)],
body: &str,
proxy_url: Option<&str>,
) -> Result<Upstream> {
let proxy = match proxy_url {
Some(u) => Some(make_proxy(u)?),
None => None,
};
let mut req = ureq::post(url)
.config()
.http_status_as_error(false)
.proxy(proxy)
.timeout_global(Some(UPSTREAM_TIMEOUT))
.build();
for (k, v) in headers {
req = req.header(k.as_str(), v.as_str());
}
let response = req
.send(body)
.map_err(|e| anyhow::anyhow!("upstream POST {url} failed: {e}"))?;
let status = response.status().as_u16();
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let reader = response.into_body().into_reader();
Ok(Upstream {
status,
content_type,
retry_after,
reader: Box::new(reader),
})
}
pub fn forward_get(
url: &str,
headers: &[(String, String)],
proxy_url: Option<&str>,
) -> Result<Upstream> {
let proxy = match proxy_url {
Some(u) => Some(make_proxy(u)?),
None => None,
};
let mut req = ureq::get(url)
.config()
.http_status_as_error(false)
.proxy(proxy)
.timeout_global(Some(UPSTREAM_TIMEOUT))
.build();
for (k, v) in headers {
req = req.header(k.as_str(), v.as_str());
}
let response = req
.call()
.map_err(|e| anyhow::anyhow!("upstream GET {url} failed: {e}"))?;
let status = response.status().as_u16();
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let reader = response.into_body().into_reader();
Ok(Upstream {
status,
content_type,
retry_after,
reader: Box::new(reader),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn endpoint(provider: ProviderKind) -> Endpoint {
Endpoint {
provider,
base_url: "https://example.test/".to_string(), api_key: "secret".to_string(),
}
}
#[test]
fn openai_url_and_headers() {
let e = endpoint(ProviderKind::OpenAi);
assert_eq!(e.url(), "https://example.test/v1/chat/completions");
let h = e.headers();
assert!(
h.iter()
.any(|(k, v)| *k == "authorization" && v == "Bearer secret")
);
assert!(
h.iter()
.any(|(k, v)| *k == "content-type" && v == "application/json")
);
}
#[test]
fn anthropic_url_and_headers() {
let e = endpoint(ProviderKind::Anthropic);
assert_eq!(e.url(), "https://example.test/v1/messages");
let h = e.headers();
assert!(h.iter().any(|(k, v)| *k == "x-api-key" && v == "secret"));
assert!(h.iter().any(|(k, _)| *k == "anthropic-version"));
assert!(
!h.iter().any(|(k, _)| *k == "authorization"),
"no bearer for anthropic"
);
}
fn bind(addr: &str) -> Option<std::net::SocketAddr> {
Some(addr.parse().expect("test bind addr"))
}
#[test]
fn redact_strips_userinfo() {
assert_eq!(
redact_proxy_url("http://alice:s3cr3t@proxy.corp:3128"),
"http://proxy.corp:3128"
);
}
#[test]
fn redact_no_userinfo_unchanged() {
assert_eq!(
redact_proxy_url("http://proxy.corp:3128"),
"http://proxy.corp:3128"
);
}
#[test]
fn redact_preserves_path() {
assert_eq!(
redact_proxy_url("http://u:p@proxy.corp:3128/path"),
"http://proxy.corp:3128/path"
);
}
#[test]
fn loopback_127_rejected() {
let err = validate_proxy_url("http://127.0.0.1:8788", bind("127.0.0.1:8788")).unwrap_err();
assert!(
err.to_string().contains("own listen address")
|| err.to_string().contains("infinite recursion"),
"got: {err}"
);
}
#[test]
fn loopback_ipv6_rejected() {
let err = validate_proxy_url("http://[::1]:8788", bind("127.0.0.1:8788")).unwrap_err();
assert!(
err.to_string().contains("own listen address")
|| err.to_string().contains("infinite recursion")
|| err.to_string().contains("::1"),
"got: {err}"
);
}
#[test]
fn loopback_localhost_rejected() {
let err = validate_proxy_url("http://localhost:8788", bind("127.0.0.1:8788")).unwrap_err();
assert!(
err.to_string().contains("own listen address")
|| err.to_string().contains("infinite recursion"),
"got: {err}"
);
}
#[test]
fn loopback_different_port_allowed() {
assert!(
validate_proxy_url("http://127.0.0.1:9999", bind("127.0.0.1:8788")).is_ok(),
"companion proxy on a different loopback port must be allowed"
);
}
#[test]
fn non_loopback_allowed() {
assert!(validate_proxy_url("http://proxy.corp:8788", bind("127.0.0.1:8788")).is_ok());
}
#[test]
fn valid_proxy_without_auth() {
assert!(validate_proxy_url("http://proxy.corp:3128", None).is_ok());
}
#[test]
fn valid_proxy_with_auth() {
assert!(validate_proxy_url("http://user:pass@proxy.corp:3128", None).is_ok());
}
#[test]
fn proxy_host_extraction() {
assert_eq!(
proxy_host("http://user:pass@proxy.corp:3128/"),
Some("proxy.corp")
);
assert_eq!(proxy_host("http://proxy.corp:3128"), Some("proxy.corp"));
assert_eq!(proxy_host("http://proxy.corp/"), Some("proxy.corp"));
assert_eq!(proxy_host("not-a-url"), None);
}
}