fix: sanitize API keys and strip /v1 suffix from base URLs

Add sanitize_api_key() to strip curly/smart quotes silently injected by
copy-paste from rich-text sources (Slack, docs). Add strip_v1_suffix()
to prevent doubled /v1/v1 paths when provider URLs already include /v1.
Applied across all config paths (env, simple YAML, LiteLLM YAML, TOML).
Also adds crate structure section to proxy-architecture.md and rebuilds
admin UI dist after vite upgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
whit3rabbit
2026-04-08 20:20:32 -05:00
co-authored by Claude Opus 4.6
parent 0dd3ef98f6
commit af9a87ec6e
5 changed files with 130 additions and 94 deletions
+14 -53
View File
File diff suppressed because one or more lines are too long
+15 -13
View File
@@ -258,16 +258,18 @@ pub fn parse_litellm_yaml(yaml: &str) -> LiteLLMParsed {
let (kind, actual_model, stub_provider) = parse_provider_model(&entry.litellm_params.model);
let params = &entry.litellm_params;
let api_key = params
.api_key
.as_deref()
.map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("model_list api_key: {e}")))
.unwrap_or_else(|| {
// Fall back to the provider's own env vars when no api_key in YAML.
stub_provider
.and_then(|p| p.env_vars.iter().find_map(|v| std::env::var(v).ok()))
.unwrap_or_default()
});
let api_key = super::sanitize_api_key(
&params
.api_key
.as_deref()
.map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("model_list api_key: {e}")))
.unwrap_or_else(|| {
// Fall back to the provider's own env vars when no api_key in YAML.
stub_provider
.and_then(|p| p.env_vars.iter().find_map(|v| std::env::var(v).ok()))
.unwrap_or_default()
}),
);
let base_url = resolve_base_url(&kind, params, stub_provider);
@@ -411,11 +413,11 @@ fn resolve_base_url(
BackendKind::OpenAI => {
// Use the stub provider's default URL when available (e.g. groq, xai, mistral).
// Falls back to OpenAI's URL only when the provider has no default or is unknown.
stub_provider
let url = stub_provider
.map(|p| p.default_base_url)
.filter(|u| !u.is_empty())
.unwrap_or("https://api.openai.com")
.to_string()
.unwrap_or("https://api.openai.com");
super::strip_v1_suffix(url).to_string()
}
BackendKind::Gemini => {
"https://generativelanguage.googleapis.com/v1beta/openai".to_string()
+63 -22
View File
@@ -47,6 +47,35 @@ pub enum BackendAuth {
AzureApiKey(String),
}
/// Strip curly/smart quotes and other non-ASCII punctuation that copy-paste
/// from rich-text sources (Slack, docs, web pages) can silently inject into
/// API keys. Logs a warning so the operator notices.
pub fn sanitize_api_key(key: &str) -> String {
// U+2018 ' U+2019 ' U+201C " U+201D "
let cleaned: String = key
.chars()
.filter(|c| !matches!(c, '\u{2018}' | '\u{2019}' | '\u{201C}' | '\u{201D}'))
.collect();
if cleaned.len() != key.len() {
tracing::warn!(
"stripped curly/smart quotes from API key \
(likely copy-pasted from a rich-text source)"
);
}
cleaned
}
/// Strip a trailing `/v1` or `/v1/` suffix from a base URL.
///
/// The OpenAI client always appends `/v1/chat/completions`, so provider URLs
/// that include `/v1` (e.g. `https://openrouter.ai/api/v1`) would produce a
/// doubled path without this.
pub fn strip_v1_suffix(url: &str) -> &str {
url.strip_suffix("/v1/")
.or_else(|| url.strip_suffix("/v1"))
.unwrap_or(url)
}
impl fmt::Debug for BackendAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -154,16 +183,19 @@ impl Config {
.unwrap_or("https://api.openai.com");
let base_url = std::env::var("OPENAI_BASE_URL")
.unwrap_or_else(|_| provider_default_url.to_string());
let base_url = strip_v1_suffix(&base_url).to_string();
if let Err(e) = validate_base_url(&base_url) {
panic!("OPENAI_BASE_URL rejected: {e}");
}
// For stub providers, fall back to their env var (e.g. GROQ_API_KEY) when
// OPENAI_API_KEY is not set.
let api_key = std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| {
stub_provider
.and_then(|p| p.env_vars.iter().find_map(|v| std::env::var(v).ok()))
.unwrap_or_default()
});
let api_key = sanitize_api_key(
&std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| {
stub_provider
.and_then(|p| p.env_vars.iter().find_map(|v| std::env::var(v).ok()))
.unwrap_or_default()
}),
);
let backend_auth = BackendAuth::BearerToken(api_key.clone());
let openai_api_format = match std::env::var("OPENAI_API_FORMAT")
.unwrap_or_else(|_| "chat".into())
@@ -196,9 +228,11 @@ impl Config {
let deployment = std::env::var("AZURE_OPENAI_DEPLOYMENT").unwrap_or_else(|_| {
panic!("AZURE_OPENAI_DEPLOYMENT is required when BACKEND=azure")
});
let api_key = std::env::var("AZURE_OPENAI_API_KEY").unwrap_or_else(|_| {
panic!("AZURE_OPENAI_API_KEY is required when BACKEND=azure")
});
let api_key = sanitize_api_key(
&std::env::var("AZURE_OPENAI_API_KEY").unwrap_or_else(|_| {
panic!("AZURE_OPENAI_API_KEY is required when BACKEND=azure")
}),
);
let api_version = std::env::var("AZURE_OPENAI_API_VERSION")
.unwrap_or_else(|_| "2024-10-21".to_string());
@@ -236,9 +270,9 @@ impl Config {
validate_gcp_identifier("VERTEX_REGION", &region);
let backend_auth = if let Ok(api_key) = std::env::var("VERTEX_API_KEY") {
BackendAuth::GoogleApiKey(api_key)
BackendAuth::GoogleApiKey(sanitize_api_key(&api_key))
} else if let Ok(token) = std::env::var("GOOGLE_ACCESS_TOKEN") {
BackendAuth::BearerToken(token)
BackendAuth::BearerToken(sanitize_api_key(&token))
} else {
panic!("VERTEX_API_KEY or GOOGLE_ACCESS_TOKEN is required when BACKEND=vertex");
};
@@ -267,8 +301,10 @@ impl Config {
}
}
BackendKind::Gemini => {
let api_key = std::env::var("GEMINI_API_KEY")
.unwrap_or_else(|_| panic!("GEMINI_API_KEY is required when BACKEND=gemini"));
let api_key = sanitize_api_key(
&std::env::var("GEMINI_API_KEY")
.unwrap_or_else(|_| panic!("GEMINI_API_KEY is required when BACKEND=gemini")),
);
let base_url = std::env::var("GEMINI_BASE_URL").unwrap_or_else(|_| {
"https://generativelanguage.googleapis.com/v1beta".to_string()
@@ -296,9 +332,11 @@ impl Config {
}
}
BackendKind::Anthropic => {
let api_key = std::env::var("ANTHROPIC_API_KEY").unwrap_or_else(|_| {
panic!("ANTHROPIC_API_KEY is required when BACKEND=anthropic")
});
let api_key = sanitize_api_key(
&std::env::var("ANTHROPIC_API_KEY").unwrap_or_else(|_| {
panic!("ANTHROPIC_API_KEY is required when BACKEND=anthropic")
}),
);
let base_url = std::env::var("ANTHROPIC_BASE_URL")
.unwrap_or_else(|_| "https://api.anthropic.com".to_string());
@@ -780,11 +818,12 @@ impl MultiConfig {
other => panic!("unknown backend kind '{other}' for backend '{name}'"),
};
let api_key = tb
.api_key
.as_deref()
.map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("backend '{name}': {e}")))
.unwrap_or_default();
let api_key = sanitize_api_key(
&tb.api_key
.as_deref()
.map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("backend '{name}': {e}")))
.unwrap_or_default(),
);
let (base_url, backend_auth, model_mapping, api_format) = match &kind {
BackendKind::OpenAI => {
@@ -871,8 +910,10 @@ impl MultiConfig {
let auth = if !api_key.is_empty() {
BackendAuth::GoogleApiKey(api_key.clone())
} else if let Some(token_ref) = &tb.access_token {
let token = resolve_env_value(token_ref)
.unwrap_or_else(|e| panic!("backend '{name}': {e}"));
let token = sanitize_api_key(
&resolve_env_value(token_ref)
.unwrap_or_else(|e| panic!("backend '{name}': {e}")),
);
BackendAuth::BearerToken(token)
} else {
panic!("backend '{name}': api_key or access_token is required for vertex");
+11 -6
View File
@@ -225,10 +225,12 @@ pub fn parse_simple_yaml(yaml: &str) -> SimpleParsed {
for entry in &config.models {
let norm = normalize_entry(entry);
let kind = parse_kind(&norm.provider);
let api_key = norm
.api_key
.clone()
.unwrap_or_else(|| default_api_key_for_provider(&norm.provider, &kind));
let api_key = super::sanitize_api_key(
&norm
.api_key
.clone()
.unwrap_or_else(|| default_api_key_for_provider(&norm.provider, &kind)),
);
let base_url = if kind == BackendKind::AzureOpenAI {
// Azure always builds a full deployment URL (api_base or env var + deployment + version).
default_base_url(&kind, &norm)
@@ -517,8 +519,11 @@ fn default_api_key_for_provider(provider: &str, kind: &BackendKind) -> String {
fn default_base_url(kind: &BackendKind, entry: &NormalizedEntry) -> String {
match kind {
BackendKind::OpenAI => std::env::var("OPENAI_BASE_URL")
.unwrap_or_else(|_| "https://api.openai.com".to_string()),
BackendKind::OpenAI => {
let url = std::env::var("OPENAI_BASE_URL")
.unwrap_or_else(|_| "https://api.openai.com".to_string());
super::strip_v1_suffix(&url).to_string()
}
BackendKind::Gemini => {
let base = std::env::var("GEMINI_BASE_URL")
.unwrap_or_else(|_| "https://generativelanguage.googleapis.com/v1beta".to_string());
+27
View File
@@ -1,5 +1,32 @@
# Proxy Architecture
## Crate Structure
Cargo workspace with five crates:
### `crates/providers` (lib: `anyllm_providers`)
Metadata-only catalog: no HTTP, no IO. `ProviderDef` (protocol, auth, env vars, LiteLLM prefix) and `ModelDef` (context window, capabilities). Registry functions in `registry.rs`. Add a new provider: create `providers/src/providers/<name>.rs`, register in `providers/mod.rs` and `registry.rs`. OpenAI-compatible providers route through the existing `OpenAIClient` automatically.
### `crates/client` (lib: `anyllm_client`)
Async HTTP client (Anthropic-in, Anthropic-out). `ClientBuilder`, `ToolBuilder`, `messages_stream()` returning `impl Stream`.
### `crates/translator` (lib: `anyllm_translate`)
Pure translation logic, no IO. Stateless `fn(A) -> B` mapping between Anthropic and OpenAI types.
- `anthropic/`: Anthropic Messages API types
- `openai/`: OpenAI types (Chat Completions + Responses API)
- `mapping/`: Conversion functions (message_map, tools_map, streaming_map, reverse_streaming_map, responses_*, warnings)
- `middleware/`: Request/response handler orchestrating translation
### `crates/batch_engine` (lib: `anyllm_batch_engine`)
HTTP-agnostic batch orchestration: job queue, file storage, webhook delivery.
### `crates/proxy` (bin: `anyllm_proxy`)
HTTP proxy on axum + reqwest:
- `server/`: Routes, middleware (auth, rate limit, request ID, size/concurrency limits), SSE streaming, passthrough handlers. `bedrock_native.rs`: Bedrock Converse/InvokeModel native passthrough (SigV4 handled by proxy). `generic_passthrough.rs`: catch-all `/v1/{*path}` for Translate mode (registered last).
- `backend/`: `BackendClient` enum dispatching to OpenAI/Azure/Vertex/Gemini/Anthropic/Bedrock with retry
- `admin/`: Admin server (localhost:3001), virtual key CRUD, managed backend CRUD (`routes/managed_backends.rs`), model management, audit log, WebSocket live updates
- `admin-ui/`: React 19 + TypeScript SPA (Vite). Build: `cd crates/proxy/admin-ui && npm run build`
## Data Flow
```