feat: ANTHROPIC_FORWARD_CLIENT_AUTH with live admin-UI toggle

Adds opt-in client-credential forwarding for Anthropic passthrough
(single-key/BYOK deployments), plus fixes found in review:

- Startup safeguard now shares one check (server/middleware/auth.rs)
  with the live admin PUT /admin/api/config path, closing a bypass
  where PROXY_OPEN_RELAY=true alongside 2+ PROXY_API_KEYS entries
  slipped past the old startup-only check.
- x-goog-api-key is now recognized as a forwardable credential
  (renamed to x-api-key upstream, since Anthropic doesn't understand
  that header name), matching validate_auth's precedence.
- Managed (admin-API) backends no longer carry a dead
  forward_client_auth field that could never take effect.
- ClientAuthPath forwarding decisions are now double-checked against
  live VirtualKeyContext/JwtClaims presence, not just the enum, to
  fail closed if the two ever desync.
- Moved from a per-backend BackendConfig field to a global
  RuntimeConfig field (like anthropic_thinking_repair), making it
  live-toggleable from the admin UI with no restart, and uniform
  across every Anthropic-kind backend in a multi-backend deployment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
whit3rabbit
2026-07-04 15:40:53 -05:00
co-authored by Claude Sonnet 5
parent 9456d1ad1b
commit a87cae6ca4
33 changed files with 1145 additions and 20 deletions
+14
View File
@@ -11,6 +11,20 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follo
## [Unreleased]
### Added
- Opt-in `ANTHROPIC_FORWARD_CLIENT_AUTH` for Anthropic passthrough: forwards the client's own
incoming `x-api-key`/`Authorization`/`x-goog-api-key` credential upstream (renamed to `x-api-key`
when it came in as `x-goog-api-key`, since Anthropic doesn't recognize that header name) instead
of the operator's configured credential, for single-key/BYOK deployments (e.g. using Claude
Code's own Pro/Max subscription OAuth token directly, no separate `claude setup-token` step).
Only applies when the request authenticated via a static `PROXY_API_KEYS` entry or
`PROXY_OPEN_RELAY`; virtual-key and OIDC-authenticated requests always use the operator's own
credential regardless of the toggle. Applies uniformly to every Anthropic-kind backend in a
multi-backend deployment (one shared runtime setting, like `ANTHROPIC_THINKING_REPAIR`) and is
live-toggleable from the admin UI (**Settings**) / `PUT /admin/api/config` with no restart.
Enabling it (at startup or live) is rejected when `PROXY_API_KEYS` has 2+ distinct entries and no
open relay, since that combination would let different callers each redirect the upstream
Anthropic credential.
- Opt-in Forge-style tool-call guardrails: advisory nudges for `lsp_first`, `quiet_command`, and
`write_payload_cap` policies, plus fingerprint-based dedup of repeated tool calls. Configure via
the simple-YAML `tool_execution.guardrails` key or the `FORGE_TOOL_CALL_POLICY` env var (the env
+1 -1
View File
@@ -111,7 +111,7 @@ Five-crate Cargo workspace: `providers` (metadata catalog), `client` (Anthropic
- **`reqwest::Error::is_timeout()` fires on read timeouts too.** A read timeout means the server already processed the POST. Only `is_connect()` is safe to retry on POST endpoints. The `retry_transport_errors` flag in `RetryPolicy` gates on `is_connect()` only by design — do not add `is_timeout()` back.
- **Two retry loops exist; keep them in sync.** The shared `anyllm_client::retry::send_with_retry_policy` (client crate) is canonical; the proxy's `backend/mod.rs::send_with_retry` and `client/anthropic_client.rs` both delegate to it (OpenAI/Gemini/Azure/Vertex/Anthropic). But `bedrock_client.rs` still has its own hand-rolled `for attempt in 0..=MAX_RETRIES` loop. A retry-policy change (backoff, quota fast-fail, status classification) must be applied to both or they diverge silently.
- **`anthropic::ErrorType::as_wire_str()` is the canonical snake_case stringifier.** Use it for error-event wire strings; do NOT round-trip through `serde_json::to_value(&et).as_str()...unwrap_or("api_error")` (silent fallback masks bugs). Adding a variant fails to compile until handled in the `match`.
- **`extra_headers` Vec + `HeaderMap::insert` last writer wins.** Push builder-level headers to the END of `extra_headers` Vec so they overwrite any caller-supplied duplicate when `build_http_client` iterates. Inserting at index 0 loses priority — the caller's later entry wins.
- **Two unrelated `extra_headers` mechanisms exist; don't conflate them.** `HttpClientConfig.extra_headers` (`crates/client/src/http.rs`, applied once at client-construction time via `HeaderMap::insert` in `build_http_client`) really is last-writer-wins — push builder-level headers to the END of that Vec so they overwrite any caller-supplied duplicate; inserting at index 0 loses priority. But the PER-REQUEST `extra_headers: &[(&str, &str)]` param used in `anthropic_client.rs`'s `forward`/`forward_stream`/`forward_generic` applies via `reqwest::RequestBuilder::header()`, which APPENDS rather than replaces — calling it twice for the same header name sends two conflicting header lines upstream, not an override. Overriding a credential set earlier on that path (e.g. `ANTHROPIC_FORWARD_CLIENT_AUTH`) needs a real `Option<(&str, &str)>` parameter that skips the default `.header()` call entirely, not a Vec entry.
- **`Ipv4Addr::is_broadcast()` matches only `255.255.255.255`**, not directed subnet broadcasts like `10.0.0.255`. Directed broadcasts slip through SSRF filters when `allow_private=true`.
- **`2u64.pow(attempt)` overflows at attempt ≥ 64.** `backoff_delay` caps with `attempt.min(62)`. Any new backoff formula needs the same guard — `RetryPolicy::max_retries` has no upper bound.
- **SSE streaming: use `run_sse_task` in `crates/client/src/streaming.rs`.** New stream types must use this shared helper (BytesMut + `find_double_newline` loop + channel send). Implement as `FnMut(SseEvent<'_>) -> Vec<...>`. Do not duplicate the frame-reading loop.
+48
View File
@@ -166,6 +166,54 @@ anyllm_proxy
BACKEND=anthropic ANTHROPIC_API_KEY=sk-ant-... anyllm_proxy
```
**With tool-call guardrails, thinking-block repair, and body logging enabled** (one command):
```bash
BACKEND=anthropic ANTHROPIC_API_KEY=sk-ant-... \
FORGE_TOOL_CALL_POLICY=standard ANTHROPIC_THINKING_REPAIR=true \
LOG_BODIES=true RUST_LOG=info \
anyllm_proxy
```
- `FORGE_TOOL_CALL_POLICY=standard` — opt-in tool-call guardrails (nudges Claude toward LSP tools over grep, quieter shell commands, and caps oversized write/edit payloads). Also toggleable live from the admin UI (`Settings` tab) with no restart.
- `ANTHROPIC_THINKING_REPAIR=true` — records each response's thinking blocks as ground truth and repairs them if a client-side replay corrupts them, instead of erroring out. Also live-toggleable from the admin UI.
- `LOG_BODIES=true RUST_LOG=info` — logs request/response bodies (admin UI **Request Log** tab); use `RUST_LOG=anyllm_proxy=debug` for more detail.
### Claude Code with your Pro/Max subscription (not an API key)
Passthrough mode forwards bytes to the real Anthropic API using **the proxy's own** credential — it does not forward whatever `Authorization`/`x-api-key` header Claude Code sends it. So pointing Claude Code at the proxy with nothing else configured (which normally falls back to your logged-in subscription session) won't authenticate upstream; the subscription credential has to live on the proxy process instead:
```bash
# 1. On a machine where you're logged into Claude Code (Pro/Max), mint a
# portable, ~1-year bearer token from your subscription:
claude setup-token
# copy the printed token
# 2. Start the proxy with it as the server-side upstream credential:
BACKEND=anthropic ANTHROPIC_AUTH_TOKEN=<token-from-setup-token> \
FORGE_TOOL_CALL_POLICY=standard ANTHROPIC_THINKING_REPAIR=true \
LOG_BODIES=true RUST_LOG=info \
PROXY_OPEN_RELAY=true \
anyllm_proxy
# 3. Point Claude Code at the proxy (this credential is only checked by the
# proxy's own inbound gate above -- PROXY_OPEN_RELAY=true accepts any
# value here; use PROXY_API_KEYS=... instead for anything beyond local use):
ANTHROPIC_BASE_URL=http://localhost:3000 ANTHROPIC_API_KEY=proxy-user claude
```
This keeps billing on your subscription (nothing pay-per-token) while still getting guardrails, thinking-block repair, and full request logging from the proxy.
**Alternative: skip the token-minting step.** Set `ANTHROPIC_FORWARD_CLIENT_AUTH=true` and the proxy forwards whatever `Authorization`/`x-api-key` header Claude Code sends it straight upstream, verbatim, instead of substituting its own credential — no `claude setup-token` step needed:
```bash
BACKEND=anthropic ANTHROPIC_FORWARD_CLIENT_AUTH=true \
FORGE_TOOL_CALL_POLICY=standard ANTHROPIC_THINKING_REPAIR=true \
LOG_BODIES=true RUST_LOG=info \
PROXY_OPEN_RELAY=true \
anyllm_proxy
```
Since the credential that gets the request past the proxy's own gate becomes the literal credential sent to Anthropic, this is single-key/BYOK only: it's automatically skipped (falls back to the operator's own credential) for virtual-key or OIDC-authenticated requests, and the proxy refuses to start if it's on alongside 2+ `PROXY_API_KEYS` entries with no `PROXY_OPEN_RELAY`. See [docs/ENV.md](docs/ENV.md#forwarding-the-clients-own-credential-anthropic_forward_client_authtrue) for the safeguard details.
See [docs/ENV.md](docs/ENV.md) for the full variable reference.
---
+1
View File
@@ -96,6 +96,7 @@ export interface ConfigResponse {
log_bodies: boolean
redact_secrets: boolean
anthropic_thinking_repair: boolean
forward_client_auth: boolean
tool_guardrail_mode: string
backends: Record<string, { big_model: string; small_model: string }>
overridden_keys: string[]
@@ -282,6 +282,32 @@ PROXY_API_KEYS=my-key`}
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-forward-client-auth" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
id="cfg-forward-client-auth"
type="checkbox"
checked={cfg.forward_client_auth}
disabled={save.isPending}
onChange={(e) => handleBooleanSave('forward_client_auth', e.target.checked)}
/>
Forward client credential (Anthropic passthrough)
</label>
<div className="dim" style={{ fontSize: 12 }}>
Forwards the client's own x-api-key/Authorization header upstream instead of the
operator's configured credential (BACKEND=anthropic passthrough only, single-key/BYOK
deployments). The proxy refuses to enable this with 2+ PROXY_API_KEYS entries and no
PROXY_OPEN_RELAY. Off by default.
</div>
{cfg.overridden_keys.includes('forward_client_auth') && (
<div className="form-row">
<AdminButton size="sm" onClick={() => setPendingReset('forward_client_auth')}>
Reset
</AdminButton>
</div>
)}
</div>
<div className="form-group">
<label className="form-label" htmlFor="cfg-tool-guardrail-mode">Tool guardrail mode</label>
<div className="form-row">
@@ -305,7 +331,7 @@ PROXY_API_KEYS=my-key`}
</div>
</div>
{cfg.entries.filter((entry) => !['redact_secrets', 'log_bodies', 'anthropic_thinking_repair', 'tool_guardrail_mode'].includes(entry.key)).map((entry) => {
{cfg.entries.filter((entry) => !['redact_secrets', 'log_bodies', 'anthropic_thinking_repair', 'forward_client_auth', 'tool_guardrail_mode'].includes(entry.key)).map((entry) => {
const inputId = `cfg-${entry.key}`
return (
<div className="form-group" key={entry.key}>
+45
View File
@@ -85,6 +85,7 @@ pub(super) async fn get_config(State(shared): State<SharedState>) -> Json<serde_
log_bodies,
redact_secrets,
anthropic_thinking_repair,
forward_client_auth,
tool_guardrail_mode,
backends,
) = {
@@ -107,6 +108,7 @@ pub(super) async fn get_config(State(shared): State<SharedState>) -> Json<serde_
config.log_bodies,
config.redact_secrets,
config.anthropic_thinking_repair,
config.forward_client_auth,
config.tool_guardrail_mode.clone(),
backends,
)
@@ -125,6 +127,7 @@ pub(super) async fn get_config(State(shared): State<SharedState>) -> Json<serde_
"log_bodies": log_bodies,
"redact_secrets": redact_secrets,
"anthropic_thinking_repair": anthropic_thinking_repair,
"forward_client_auth": forward_client_auth,
"tool_guardrail_mode": tool_guardrail_mode,
"backends": backends,
"overridden_keys": override_keys,
@@ -194,6 +197,37 @@ pub(super) async fn put_config(
{
db_writes.push(("anthropic_thinking_repair".to_string(), val.to_string()));
}
if let Some(val) = body.get("forward_client_auth").and_then(|v| v.as_bool()) {
// Same rule enforced at startup (main_helpers::async_main) for
// statically-configured backends: 2+ distinct PROXY_API_KEYS entries
// with no PROXY_OPEN_RELAY would let different callers each redirect
// the upstream Anthropic credential. This is the only path that can
// enable the toggle *after* boot (live, no restart), so it must be
// re-checked here -- otherwise this admin route would silently
// reopen exactly the misconfiguration the startup panic exists to
// block. Reads the same ALLOWED_KEY_HASHES/OPEN_RELAY statics
// validate_auth uses, not a re-parse of the env vars, so it can't
// diverge from what a request actually experiences.
if val
&& crate::server::middleware::forward_client_auth_misconfigured(
crate::server::middleware::distinct_static_key_count(),
crate::server::middleware::open_relay_active(),
)
{
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "ANTHROPIC_FORWARD_CLIENT_AUTH cannot be enabled with 2+ \
PROXY_API_KEYS entries and no PROXY_OPEN_RELAY: this would let \
different callers each redirect the upstream Anthropic \
credential. Use exactly one PROXY_API_KEYS entry or \
PROXY_OPEN_RELAY=true for a single-operator/BYOK deployment."
})),
)
.into_response();
}
db_writes.push(("forward_client_auth".to_string(), val.to_string()));
}
if let Some(val) = body.get("tool_guardrail_mode").and_then(|v| v.as_str()) {
match val.parse::<crate::tools::ToolGuardrailMode>() {
Ok(mode) => {
@@ -276,6 +310,7 @@ pub(super) async fn put_config(
"log_bodies" => config.log_bodies.to_string(),
"redact_secrets" => config.redact_secrets.to_string(),
"anthropic_thinking_repair" => config.anthropic_thinking_repair.to_string(),
"forward_client_auth" => config.forward_client_auth.to_string(),
"tool_guardrail_mode" => config.tool_guardrail_mode.clone(),
other => {
if let Some((backend, field)) = other.split_once('.') {
@@ -320,6 +355,9 @@ pub(super) async fn put_config(
"anthropic_thinking_repair" => {
config.anthropic_thinking_repair = value == "true";
}
"forward_client_auth" => {
config.forward_client_auth = value == "true";
}
"tool_guardrail_mode" => {
config.tool_guardrail_mode = value.clone();
}
@@ -433,6 +471,13 @@ pub(super) async fn delete_config_override(
}
Some(env_default.to_string())
}
"forward_client_auth" => {
let env_default = shared.runtime_defaults.forward_client_auth;
if let Ok(mut config) = shared.runtime_config.write() {
config.forward_client_auth = env_default;
}
Some(env_default.to_string())
}
"tool_guardrail_mode" => {
let env_default = shared.runtime_defaults.tool_guardrail_mode.clone();
if let Ok(mut config) = shared.runtime_config.write() {
+81
View File
@@ -289,6 +289,43 @@ async fn put_config_updates_anthropic_thinking_repair_override() {
.any(|(key, value, _)| key == "anthropic_thinking_repair" && value == "true"));
}
#[tokio::test]
async fn put_config_updates_forward_client_auth_override() {
set_admin_rpm(10_000);
let shared = crate::admin::state::SharedState::new_for_test();
let token_str = "i".repeat(64);
shared.issued_csrf_tokens.insert(token_str.clone(), ());
let app = admin_router(
shared.clone(),
Arc::new(zeroize::Zeroizing::new("test-token".to_string())),
);
let req = Request::put("/admin/api/config")
.header("host", "localhost:9090")
.header("authorization", "Bearer test-token")
.header("content-type", "application/json")
.header("x-csrf-token", &token_str)
.header("cookie", format!("csrf_token={token_str}"))
.extension(ConnectInfo("127.0.0.1:9090".parse::<SocketAddr>().unwrap()))
.body(Body::from(r#"{"forward_client_auth":true}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body_bytes = axum::body::to_bytes(resp.into_body(), 1 << 16)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(body["keys"], serde_json::json!(["forward_client_auth"]));
assert!(shared.runtime_config.read().unwrap().forward_client_auth);
let conn = shared.db.lock().unwrap();
let overrides = crate::admin::db::get_config_overrides(&conn).unwrap();
assert!(overrides
.iter()
.any(|(key, value, _)| key == "forward_client_auth" && value == "true"));
}
#[tokio::test]
async fn put_config_tool_guardrail_mode_then_get_returns_new_value() {
set_admin_rpm(10_000);
@@ -417,6 +454,50 @@ async fn delete_config_anthropic_thinking_repair_override_restores_loaded_defaul
.any(|(key, _, _)| key == "anthropic_thinking_repair"));
}
#[tokio::test]
async fn delete_config_forward_client_auth_override_restores_loaded_default() {
set_admin_rpm(10_000);
let mut shared = crate::admin::state::SharedState::new_for_test();
shared.runtime_defaults.forward_client_auth = true;
{
let mut config = shared.runtime_config.write().unwrap();
config.forward_client_auth = false;
}
{
let conn = shared.db.lock().unwrap();
crate::admin::db::set_config_override(&conn, "forward_client_auth", "false").unwrap();
}
let token_str = "j".repeat(64);
shared.issued_csrf_tokens.insert(token_str.clone(), ());
let app = admin_router(
shared.clone(),
Arc::new(zeroize::Zeroizing::new("test-token".to_string())),
);
let req = Request::delete("/admin/api/config/overrides/forward_client_auth")
.header("host", "localhost:9090")
.header("authorization", "Bearer test-token")
.header("x-csrf-token", &token_str)
.header("cookie", format!("csrf_token={token_str}"))
.extension(ConnectInfo("127.0.0.1:9090".parse::<SocketAddr>().unwrap()))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(
shared.runtime_config.read().unwrap().forward_client_auth,
"runtime config should return to the loaded forward_client_auth default"
);
let conn = shared.db.lock().unwrap();
let overrides = crate::admin::db::get_config_overrides(&conn).unwrap();
assert!(!overrides
.iter()
.any(|(key, _, _)| key == "forward_client_auth"));
}
/// POST with a CSRF token that was not server-issued is rejected even if header==cookie.
#[tokio::test]
async fn post_with_unissued_csrf_returns_403() {
+10
View File
@@ -108,6 +108,13 @@ pub struct RuntimeConfig {
/// Whether Anthropic thinking-block record-and-restore repair is active
/// (BACKEND=anthropic passthrough only; see `crate::thinking_repair`).
pub anthropic_thinking_repair: bool,
/// Whether Anthropic passthrough forwards the client's own incoming
/// credential upstream instead of the operator's (BACKEND=anthropic
/// only; see `ANTHROPIC_FORWARD_CLIENT_AUTH` / `server::passthrough`).
/// Enabling this via `PUT /admin/api/config` is gated by
/// `server::middleware::forward_client_auth_misconfigured` -- see
/// `admin::routes::config::put_config`.
pub forward_client_auth: bool,
/// Opt-in tool-call guardrail preset, stored as the stable string form of
/// `crate::tools::ToolGuardrailMode` (see `ToolGuardrailMode::as_str`),
/// e.g. "disabled" or "standard". Runtime-tunable like the other fields
@@ -123,6 +130,7 @@ pub struct RuntimeConfigDefaults {
pub log_bodies: bool,
pub redact_secrets: bool,
pub anthropic_thinking_repair: bool,
pub forward_client_auth: bool,
pub tool_guardrail_mode: String,
}
@@ -198,6 +206,7 @@ impl SharedState {
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
forward_client_auth: false,
tool_guardrail_mode: crate::tools::ToolGuardrailMode::Disabled
.as_str()
.to_string(),
@@ -206,6 +215,7 @@ impl SharedState {
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
forward_client_auth: false,
tool_guardrail_mode: crate::tools::ToolGuardrailMode::Disabled
.as_str()
.to_string(),
+115 -10
View File
@@ -98,10 +98,27 @@ impl AnthropicClient {
/// Apply required Anthropic authentication headers.
/// x-api-key and anthropic-version are mandatory per the Anthropic API spec;
/// without the version header, the API rejects requests.
fn auth_request(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
let (name, value) = self.auth.header();
rb.header(name, value)
.header("anthropic-version", "2023-06-01")
///
/// `override_auth`, when `Some`, is forwarded verbatim (exact name+value the
/// client sent) INSTEAD OF the operator's configured credential -- used by
/// `ANTHROPIC_FORWARD_CLIENT_AUTH`. Exactly one of the two branches ever calls
/// `.header()` for the credential, so no duplicate/conflicting credential
/// header can reach upstream (reqwest's `RequestBuilder::header` APPENDS
/// rather than replaces, so calling it twice for the same header name would
/// send two header lines, not an override).
fn auth_request(
&self,
rb: reqwest::RequestBuilder,
override_auth: Option<(&str, &str)>,
) -> reqwest::RequestBuilder {
let rb = match override_auth {
Some((name, value)) => rb.header(name, value),
None => {
let (name, value) = self.auth.header();
rb.header(name, value)
}
};
rb.header("anthropic-version", "2023-06-01")
}
/// Forward a non-streaming request. Returns raw response body and rate limit headers.
@@ -110,8 +127,11 @@ impl AnthropicClient {
&self,
body: bytes::Bytes,
extra_headers: &[(&str, &str)],
override_auth: Option<(&str, &str)>,
) -> Result<(bytes::Bytes, RateLimitHeaders), AnthropicClientError> {
let response = self.send_with_retry(body, false, extra_headers).await?;
let response = self
.send_with_retry(body, false, extra_headers, override_auth)
.await?;
let rate_limits = RateLimitHeaders::from_anthropic_headers(response.headers());
let resp_body = response
.bytes()
@@ -126,8 +146,11 @@ impl AnthropicClient {
&self,
body: bytes::Bytes,
extra_headers: &[(&str, &str)],
override_auth: Option<(&str, &str)>,
) -> Result<(reqwest::Response, RateLimitHeaders), AnthropicClientError> {
let response = self.send_with_retry(body, true, extra_headers).await?;
let response = self
.send_with_retry(body, true, extra_headers, override_auth)
.await?;
let rate_limits = RateLimitHeaders::from_anthropic_headers(response.headers());
Ok((response, rate_limits))
}
@@ -142,6 +165,7 @@ impl AnthropicClient {
path: &str,
body: bytes::Bytes,
extra_headers: &[(&str, &str)],
override_auth: Option<(&str, &str)>,
) -> Result<reqwest::Response, AnthropicClientError> {
let url = format!("{}{}", self.base_url, path);
let rb = self
@@ -149,7 +173,7 @@ impl AnthropicClient {
.request(method, &url)
.header("content-type", "application/json")
.body(body);
let rb = self.auth_request(rb);
let rb = self.auth_request(rb, override_auth);
let rb = extra_headers.iter().fold(rb, |rb, &(k, v)| rb.header(k, v));
rb.send()
.await
@@ -162,6 +186,7 @@ impl AnthropicClient {
body: bytes::Bytes,
stream: bool,
extra_headers: &[(&str, &str)],
override_auth: Option<(&str, &str)>,
) -> Result<reqwest::Response, AnthropicClientError> {
let content_type = "application/json";
for attempt in 0..=super::MAX_RETRIES {
@@ -170,7 +195,7 @@ impl AnthropicClient {
.post(&self.messages_url)
.header("content-type", content_type)
.body(body.clone());
let rb = self.auth_request(rb);
let rb = self.auth_request(rb, override_auth);
// Tell upstream we expect SSE format; the Anthropic routing layer
// may use this hint to optimize response handling.
let rb = if stream {
@@ -256,10 +281,90 @@ fn anthropic_urls(base_url: &str) -> (String, String) {
#[cfg(test)]
mod tests {
use super::{anthropic_urls, AnthropicAuth};
use crate::config::BackendAuth;
use super::{anthropic_urls, AnthropicAuth, AnthropicClient};
use crate::config::{BackendAuth, TlsConfig};
use std::sync::Mutex;
fn credential_headers(rb: reqwest::RequestBuilder) -> Vec<(String, String)> {
let req = rb.build().expect("request builds");
req.headers()
.iter()
.filter(|(name, _)| {
let n = name.as_str();
n == "x-api-key" || n == "authorization"
})
.map(|(name, value)| {
(
name.as_str().to_string(),
value.to_str().unwrap_or_default().to_string(),
)
})
.collect()
}
fn test_client() -> AnthropicClient {
AnthropicClient::new(
"https://api.anthropic.com",
&BackendAuth::AnthropicApiKey("operator-key".to_string()),
&TlsConfig::default(),
)
}
#[test]
fn auth_request_without_override_uses_operator_credential() {
let client = test_client();
let rb = client.client.post(&client.messages_url);
let rb = client.auth_request(rb, None);
let headers = credential_headers(rb);
assert_eq!(
headers,
vec![("x-api-key".to_string(), "operator-key".to_string())]
);
}
#[test]
fn auth_request_with_x_api_key_override_replaces_operator_credential() {
let client = test_client();
let rb = client.client.post(&client.messages_url);
let rb = client.auth_request(rb, Some(("x-api-key", "client-key")));
let headers = credential_headers(rb);
assert_eq!(
headers,
vec![("x-api-key".to_string(), "client-key".to_string())],
"operator credential must not appear anywhere in the built request"
);
}
#[test]
fn auth_request_with_bearer_override_is_forwarded_unmodified() {
let client = test_client();
let rb = client.client.post(&client.messages_url);
let rb = client.auth_request(rb, Some(("authorization", "Bearer sk-ant-oat-abc123")));
let headers = credential_headers(rb);
assert_eq!(
headers,
vec![(
"authorization".to_string(),
"Bearer sk-ant-oat-abc123".to_string()
)],
"must forward the Bearer token as-is, not convert it to x-api-key"
);
}
#[test]
fn auth_request_never_sends_duplicate_credential_headers() {
// Regression guard: reqwest's RequestBuilder::header() appends rather
// than replaces, so auth_request must never call it twice for the
// credential (once for the operator's, once for an override) or two
// conflicting header lines would reach upstream.
let client = test_client();
for override_auth in [None, Some(("x-api-key", "client-key"))] {
let rb = client.client.post(&client.messages_url);
let rb = client.auth_request(rb, override_auth);
assert_eq!(credential_headers(rb).len(), 1);
}
}
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
+9
View File
@@ -27,6 +27,15 @@ pub fn strip_v1_suffix(url: &str) -> &str {
.unwrap_or(url)
}
/// Parse a boolean env var as `"true"` or `"1"`, defaulting to `false` if
/// unset. Shared by every `BackendConfig` loader that reads
/// `ANTHROPIC_FORWARD_CLIENT_AUTH` so the parsing rule lives in one place.
pub fn env_bool_flag(name: &str) -> bool {
std::env::var(name)
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
}
/// Resolve a config value that may reference an env var via `env:VAR_NAME` prefix.
/// This allows TOML config files to reference secrets from the environment
/// without hardcoding them, keeping credentials out of version control.
+1
View File
@@ -376,6 +376,7 @@ pub fn parse_litellm_yaml(yaml: &str) -> LiteLLMParsed {
log_bodies,
redact_secrets,
anthropic_thinking_repair,
forward_client_auth: crate::config::env_bool_flag("ANTHROPIC_FORWARD_CLIENT_AUTH"),
default_backend,
backends,
expose_degradation_warnings: false, // overridden in MultiConfig::load()
+1 -1
View File
@@ -22,7 +22,7 @@ pub use url_validation::{is_private_ip, validate_base_url, warn_if_cloud_metadat
pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub use helpers::{
extract_litellm_master_key, resolve_env_value, sanitize_api_key, strip_v1_suffix,
env_bool_flag, extract_litellm_master_key, resolve_env_value, sanitize_api_key, strip_v1_suffix,
};
pub use multi::{BackendConfig, LoadResult, MultiConfig};
pub use single::Config;
+1
View File
@@ -171,6 +171,7 @@ impl MultiConfig {
log_bodies: config.log_bodies,
redact_secrets: config.redact_secrets,
anthropic_thinking_repair: config.anthropic_thinking_repair,
forward_client_auth: crate::config::env_bool_flag("ANTHROPIC_FORWARD_CLIENT_AUTH"),
default_backend: name.to_string(),
backends,
expose_degradation_warnings: config.expose_degradation_warnings,
+13
View File
@@ -45,6 +45,19 @@ pub struct MultiConfig {
pub redact_secrets: bool,
/// Enable Anthropic thinking-block record-and-restore repair (BACKEND=anthropic passthrough only).
pub anthropic_thinking_repair: bool,
/// Anthropic passthrough only: forward the CLIENT's own incoming
/// `Authorization`/`x-api-key` header upstream verbatim instead of the
/// operator's configured credential, for a single-key/BYOK deployment
/// (e.g. a Claude Pro/Max subscription's own OAuth token). Global (like
/// `anthropic_thinking_repair` above), not per-backend: every backend of
/// `BackendKind::Anthropic` shares one `RuntimeConfig`
/// (`AppState::forward_client_auth_enabled()`), live-toggleable from the
/// admin UI. See `server/passthrough.rs::client_auth_forwardable` for the
/// per-request safeguard that also gates this on how the request itself
/// authenticated, and `server/middleware/auth.rs::forward_client_auth_misconfigured`
/// for the multi-static-key safeguard enforced both at startup and on
/// every admin-API attempt to enable it live.
pub forward_client_auth: bool,
/// Backend name used when no route prefix matches.
pub default_backend: String,
/// Ordered map: key = route prefix (e.g. "openai"), value = backend config.
@@ -107,6 +107,7 @@ impl MultiConfig {
log_bodies,
redact_secrets,
anthropic_thinking_repair,
forward_client_auth: crate::config::env_bool_flag("ANTHROPIC_FORWARD_CLIENT_AUTH"),
default_backend,
backends,
expose_degradation_warnings,
+1
View File
@@ -144,6 +144,7 @@ pub fn parse_simple_yaml(yaml: &str) -> SimpleParsed {
log_bodies,
redact_secrets,
anthropic_thinking_repair,
forward_client_auth: crate::config::env_bool_flag("ANTHROPIC_FORWARD_CLIENT_AUTH"),
default_backend,
backends,
expose_degradation_warnings: false,
+2
View File
@@ -182,6 +182,7 @@ fn record_cost_with_shared_state_persists_spend() {
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
forward_client_auth: false,
tool_guardrail_mode: crate::tools::ToolGuardrailMode::Disabled
.as_str()
.to_string(),
@@ -190,6 +191,7 @@ fn record_cost_with_shared_state_persists_spend() {
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
forward_client_auth: false,
tool_guardrail_mode: crate::tools::ToolGuardrailMode::Disabled
.as_str()
.to_string(),
@@ -106,12 +106,14 @@ pub(crate) async fn init_admin(
log_bodies: multi_config.log_bodies,
redact_secrets: multi_config.redact_secrets,
anthropic_thinking_repair: multi_config.anthropic_thinking_repair,
forward_client_auth: multi_config.forward_client_auth,
tool_guardrail_mode: tool_guardrail_default.clone(),
};
let runtime_defaults = admin::state::RuntimeConfigDefaults {
log_bodies: multi_config.log_bodies,
redact_secrets: multi_config.redact_secrets,
anthropic_thinking_repair: multi_config.anthropic_thinking_repair,
forward_client_auth: multi_config.forward_client_auth,
tool_guardrail_mode: tool_guardrail_default,
};
let mut log_bodies_enabled_by_override = false;
@@ -149,6 +151,30 @@ pub(crate) async fn init_admin(
"anthropic_thinking_repair" => {
runtime_config.anthropic_thinking_repair = value == "true";
}
"forward_client_auth" => {
// Defensively re-validate against the same rule
// enforced by put_config (a tampered/hand-edited SQLite
// row could otherwise re-enable a misconfigured toggle
// that would let multiple distinct PROXY_API_KEYS
// entries each redirect the upstream Anthropic
// credential -- see
// server/middleware/auth.rs::forward_client_auth_misconfigured).
let wants_enabled = value == "true";
if wants_enabled
&& anyllm_proxy::server::middleware::forward_client_auth_misconfigured(
anyllm_proxy::server::middleware::distinct_static_key_count(),
anyllm_proxy::server::middleware::open_relay_active(),
)
{
tracing::warn!(
"ignoring persisted forward_client_auth=true override: 2+ \
PROXY_API_KEYS entries with no PROXY_OPEN_RELAY would let \
different callers each redirect the upstream Anthropic credential"
);
} else {
runtime_config.forward_client_auth = wants_enabled;
}
}
"tool_guardrail_mode" => {
if value
.parse::<anyllm_proxy::tools::ToolGuardrailMode>()
@@ -102,6 +102,42 @@ pub async fn async_main(args: Vec<String>, data_dir: PathBuf) {
);
}
// ANTHROPIC_FORWARD_CLIENT_AUTH safeguard: forwarding the client's own
// credential upstream only makes sense when that same credential IS the
// operator's Anthropic secret (single-key/BYOK). Reject at startup if it's
// enabled (now a global RuntimeConfig-backed toggle, live-editable via the
// admin UI -- see main_helpers/async_main/admin.rs) alongside multiple
// distinct static keys with no open relay. `distinct_static_key_count`/
// `open_relay_active` read the SAME `ALLOWED_KEY_HASHES`/`OPEN_RELAY`
// statics `validate_auth` uses for every request, so this can never
// diverge from the real runtime gate the way independently re-parsing
// PROXY_API_KEYS/PROXY_OPEN_RELAY here once did. The identical check also
// runs in admin::routes::config::put_config, so enabling this live via
// the admin API is gated the same way -- this startup check only covers
// the "already on at boot" case.
let any_anthropic_forwarding = multi_config.forward_client_auth
&& multi_config
.backends
.values()
.any(|bc| bc.kind == config::BackendKind::Anthropic);
if any_anthropic_forwarding {
use anyllm_proxy::server::middleware::{
distinct_static_key_count, forward_client_auth_misconfigured, open_relay_active,
};
let key_count = distinct_static_key_count();
if forward_client_auth_misconfigured(key_count, open_relay_active()) {
panic!(
"ANTHROPIC_FORWARD_CLIENT_AUTH=true with {key_count} PROXY_API_KEYS entries and \
PROXY_OPEN_RELAY not set: this forwards whichever credential gated a request \
straight to Anthropic, so multiple distinct proxy keys would let different \
callers each redirect the upstream Anthropic credential. Use exactly one \
PROXY_API_KEYS entry (set it to your own Anthropic secret) or \
PROXY_OPEN_RELAY=true for a single-operator/BYOK deployment, or disable \
ANTHROPIC_FORWARD_CLIENT_AUTH."
);
}
}
tracing::info!(
backends = ?multi_config.backends.keys().collect::<Vec<_>>(),
default = %multi_config.default_backend,
@@ -559,7 +559,7 @@ pub(crate) async fn chat_completions(
};
let refs = header_refs(&safe_headers);
match client.forward(body, &refs).await {
match client.forward(body, &refs, None).await {
Ok((resp_body, rate_limits)) => {
if let Some(ref d) = deployment {
d.record_finish(backend_start.elapsed().as_millis() as u64);
@@ -72,7 +72,7 @@ pub(super) async fn anthropic_chat_completions_stream(
};
let refs = header_refs(&safe_headers);
let (response, rate_limits) = match client.forward_stream(body, &refs).await {
let (response, rate_limits) = match client.forward_stream(body, &refs, None).await {
Ok(result) => result,
Err(e) => {
state.metrics.record_error();
@@ -96,7 +96,7 @@ pub(super) async fn call_backend_non_streaming(
let body = serde_json::to_vec(req).map_err(|e| {
BackendError::Anthropic(AnthropicClientError::Transport(e.to_string()))
})?;
let (resp_bytes, _rate_limits) = client.forward(body.into(), &[]).await?;
let (resp_bytes, _rate_limits) = client.forward(body.into(), &[], None).await?;
let resp: anthropic::MessageResponse =
serde_json::from_slice(&resp_bytes).map_err(|e| {
BackendError::Anthropic(AnthropicClientError::Transport(e.to_string()))
@@ -74,6 +74,27 @@ pub struct VirtualKeyContext {
pub(crate) period_reset: Option<String>,
}
/// Which of `validate_auth`'s four success paths authenticated this request.
/// Inserted into request extensions at every success branch so a handler can
/// tell what kind of credential got it in -- used by `ANTHROPIC_FORWARD_CLIENT_AUTH`
/// to decide whether it's safe to forward that same credential upstream as the
/// real Anthropic auth: only `StaticKey`/`OpenRelay` mean "the credential that
/// gated this request IS the operator's own secret" for a single-key/BYOK
/// deployment. A virtual key is deliberately not a real Anthropic credential,
/// and a JWT is a proxy-auth artifact, so those two paths must never be
/// forwarded upstream regardless of the toggle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientAuthPath {
/// OIDC/JWT bearer token.
OidcJwt,
/// A single static key from `PROXY_API_KEYS`.
StaticKey,
/// A per-tenant virtual key (never a real upstream credential).
VirtualKey,
/// `PROXY_OPEN_RELAY=true`: any non-empty credential accepted.
OpenRelay,
}
/// Controls which authentication paths are active.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthMode {
@@ -164,6 +185,40 @@ static OPEN_RELAY: LazyLock<bool> = LazyLock::new(|| {
.unwrap_or(false)
});
/// Whether open-relay mode is actually active for this process. This is the
/// canonical answer -- PROXY_OPEN_RELAY=true alone is not enough once any
/// PROXY_API_KEYS entry exists, see the `OPEN_RELAY` static above. Exposed so
/// callers outside this module (the startup safeguard in
/// `main_helpers::async_main`, the admin API's `PUT /admin/api/config`) can
/// check the real gate instead of re-deriving it from env vars a second time.
pub fn open_relay_active() -> bool {
*OPEN_RELAY
}
/// Number of distinct entries in `PROXY_API_KEYS`, deduplicated. Derived from
/// the same hashed list `validate_auth`'s Check 1 uses, not a separate
/// re-parse of the raw env var, so it can never diverge from what actually
/// authenticates a request. An identical key string repeated in
/// `PROXY_API_KEYS` (e.g. "keyA,keyA") hashes to the same digest and counts
/// once.
pub fn distinct_static_key_count() -> usize {
ALLOWED_KEY_HASHES
.iter()
.collect::<std::collections::HashSet<_>>()
.len()
}
/// True when forwarding the client's own credential upstream
/// (`ANTHROPIC_FORWARD_CLIENT_AUTH`) could let different callers each
/// redirect the real Anthropic credential: 2+ distinct static keys with no
/// open relay. Pure/parameterized (not reading env or the `LazyLock` statics
/// directly) so it stays deterministically testable; callers pass
/// `distinct_static_key_count()`/`open_relay_active()` (this module) or,
/// for the pre-request startup check, freshly-computed equivalents.
pub fn forward_client_auth_misconfigured(key_count: usize, open_relay: bool) -> bool {
key_count > 1 && !open_relay
}
/// Validate that the request carries a valid API key.
/// If `PROXY_API_KEYS` is set, the caller's key must be in the allowlist.
/// Otherwise, any non-empty key is accepted (backward-compatible open mode).
@@ -215,6 +270,7 @@ pub async fn validate_auth(
Ok(claims) => {
tracing::debug!(sub = ?claims.sub, auth_path = "jwt", "authentication successful");
request.extensions_mut().insert(claims);
request.extensions_mut().insert(ClientAuthPath::OidcJwt);
return Ok(next.run(request).await);
}
Err(e) => {
@@ -261,6 +317,7 @@ pub async fn validate_auth(
if env_key_match {
tracing::debug!(auth_path = "static_key", "authentication successful");
request.extensions_mut().insert(ClientAuthPath::StaticKey);
return Ok(next.run(request).await);
}
@@ -420,6 +477,7 @@ pub async fn validate_auth(
allowed_routes: meta.allowed_routes.clone(),
period_reset,
});
request.extensions_mut().insert(ClientAuthPath::VirtualKey);
tracing::debug!(
key_id = meta.id,
@@ -432,6 +490,7 @@ pub async fn validate_auth(
// Check 3: open-relay mode (any non-empty key accepted)
if *OPEN_RELAY {
request.extensions_mut().insert(ClientAuthPath::OpenRelay);
return Ok(next.run(request).await);
}
@@ -500,4 +559,28 @@ mod auth_mode_tests {
let mode = AuthMode::from_env_str("unrecognized_value");
assert_eq!(mode, AuthMode::Both);
}
#[test]
fn forward_client_auth_rejects_multiple_static_keys_without_open_relay() {
assert!(forward_client_auth_misconfigured(2, false));
}
#[test]
fn forward_client_auth_allows_open_relay_even_with_multiple_keys() {
// Reflects that `open_relay_active()` can only be true when
// `distinct_static_key_count()` is 0 (see the OPEN_RELAY static) --
// this combination is unreachable via those two real accessors, but
// the pure decision function itself must still handle it sanely.
assert!(!forward_client_auth_misconfigured(2, true));
}
#[test]
fn forward_client_auth_allows_exactly_one_key() {
assert!(!forward_client_auth_misconfigured(1, false));
}
#[test]
fn forward_client_auth_allows_zero_keys() {
assert!(!forward_client_auth_misconfigured(0, false));
}
}
+3 -1
View File
@@ -5,7 +5,9 @@ pub mod request_id;
pub use anthropic_headers::log_anthropic_headers;
pub use auth::{
set_hmac_secret, set_oidc_config, set_virtual_keys, validate_auth, AuthMode, VirtualKeyContext,
distinct_static_key_count, forward_client_auth_misconfigured, open_relay_active,
set_hmac_secret, set_oidc_config, set_virtual_keys, validate_auth, AuthMode, ClientAuthPath,
VirtualKeyContext,
};
pub use ip_allowlist::{check_ip_allowlist, ip_allowlist_active, is_ip_allowed};
pub use request_id::add_request_id;
+251 -3
View File
@@ -20,21 +20,114 @@ use futures::StreamExt;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use super::middleware::ClientAuthPath;
use super::state::AppState;
/// Selects the exact incoming credential to forward upstream when
/// `ANTHROPIC_FORWARD_CLIENT_AUTH` is enabled. Same precedence as
/// `validate_auth` (`x-api-key` / `x-goog-api-key` win over `authorization`,
/// see `server/middleware/auth.rs`'s `api_key = headers.get("x-api-key")
/// .or_else(|| headers.get("x-goog-api-key"))`) so this always forwards the
/// credential that actually gated the request into the proxy, never a
/// second, unrelated header the client also happened to send.
///
/// `x-goog-api-key` (Gemini-CLI compatibility) is folded into the
/// `x-api-key` slot rather than forwarded under its own name: Anthropic's API
/// only recognizes `x-api-key`/`authorization`, so a client authenticated via
/// `x-goog-api-key` must still have its value sent upstream as `x-api-key`,
/// not as a header name Anthropic would silently ignore. Beyond that one
/// rename, no shape detection/conversion happens -- forwarded byte-for-byte,
/// unlike LiteLLM's `optionally_handle_anthropic_oauth()`, which mis-converts
/// a Bearer token into `x-api-key`.
fn select_client_auth_override(headers: &axum::http::HeaderMap) -> Option<(&'static str, &str)> {
if let Some(v) = headers
.get("x-api-key")
.or_else(|| headers.get("x-goog-api-key"))
.and_then(|v| v.to_str().ok())
{
if !v.is_empty() {
return Some(("x-api-key", v));
}
}
if let Some(v) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
if !v.is_empty() {
return Some(("authorization", v));
}
}
None
}
/// Only `StaticKey`/`OpenRelay` mean "the credential that gated this request
/// IS the operator's own secret" (a single-key/BYOK deployment). A virtual
/// key is deliberately not a real Anthropic credential and a JWT is a
/// proxy-auth artifact, so those must never be forwarded upstream regardless
/// of the `ANTHROPIC_FORWARD_CLIENT_AUTH` toggle.
fn client_auth_forwardable(auth_path: Option<ClientAuthPath>) -> bool {
matches!(
auth_path,
Some(ClientAuthPath::StaticKey) | Some(ClientAuthPath::OpenRelay)
)
}
/// Resolves the client-credential override shared by both passthrough
/// handlers. `vk_ctx`/`claims` are checked directly, not just via
/// `client_auth_forwardable(auth_path)`: `ClientAuthPath` and
/// `VirtualKeyContext`/`JwtClaims` are inserted as two independent
/// `request.extensions_mut().insert()` calls in `validate_auth`, with
/// nothing structurally coupling them, so a future edit to one of those
/// branches could desync them without a compile error. Re-checking presence
/// of the extension that actually gates virtual-key/OIDC requests fails
/// closed instead of silently forwarding a non-operator credential if that
/// ever happens.
fn resolve_client_auth_override<'h>(
forward_client_auth: bool,
auth_path: Option<ClientAuthPath>,
vk_ctx: &Option<super::middleware::VirtualKeyContext>,
claims: &Option<crate::server::oidc::JwtClaims>,
headers: &'h axum::http::HeaderMap,
) -> Option<(&'static str, &'h str)> {
if forward_client_auth
&& client_auth_forwardable(auth_path)
&& vk_ctx.is_none()
&& claims.is_none()
{
select_client_auth_override(headers)
} else {
None
}
}
/// Forward an Anthropic-format request byte-for-byte to the upstream Anthropic API.
/// No translation is performed. Only active when `BACKEND=anthropic`.
pub(crate) async fn anthropic_passthrough(
State(state): State<AppState>,
permit: Option<axum::Extension<ConcurrencyPermit>>,
vk_ctx: Option<axum::Extension<super::middleware::VirtualKeyContext>>,
auth_path: Option<axum::Extension<ClientAuthPath>>,
claims: Option<axum::Extension<crate::server::oidc::JwtClaims>>,
headers: axum::http::HeaderMap,
mut body: Bytes,
) -> Response {
let permit = permit.map(|axum::Extension(p)| p);
let vk_ctx = vk_ctx.map(|axum::Extension(c)| c);
let auth_path = auth_path.map(|axum::Extension(p)| p);
let claims = claims.map(|axum::Extension(c)| c);
state.metrics.record_request();
// Verbatim client-credential override for ANTHROPIC_FORWARD_CLIENT_AUTH:
// computed once per request, reused across the streaming/non-streaming
// branches below. Borrowed straight from `headers` (which outlives both
// branches and is never mutated), not owned -- `forward`/`forward_stream`
// consume it synchronously before the branch that does
// `tokio::spawn`, so there's no need to outlive the spawned task.
let auth_override_ref = resolve_client_auth_override(
state.forward_client_auth_enabled(),
auth_path,
&vk_ctx,
&claims,
&headers,
);
// Scopes every thinking-repair store lookup/commit to this backend and
// virtual key: `state.thinking_repair` is one store shared across every
// Anthropic-mode backend (see server/routes.rs), so without this a
@@ -195,7 +288,10 @@ pub(crate) async fn anthropic_passthrough(
};
if is_stream {
match client.forward_stream(body, &extra_headers).await {
match client
.forward_stream(body, &extra_headers, auth_override_ref)
.await
{
Ok((response, rate_limits)) => {
let (tx, rx) = mpsc::channel::<Result<Bytes, std::convert::Infallible>>(32);
let metrics = state.metrics.clone();
@@ -319,7 +415,10 @@ pub(crate) async fn anthropic_passthrough(
}
}
} else {
match client.forward(body, &extra_headers).await {
match client
.forward(body, &extra_headers, auth_override_ref)
.await
{
Ok((resp_body, rate_limits)) => {
// Parsed once and shared between thinking-repair recording
// and virtual-key accounting below (previously each parsed
@@ -427,9 +526,12 @@ pub(crate) async fn anthropic_passthrough(
/// Forwards batch, file CRUD, count_tokens, and other Anthropic-native endpoints
/// directly to the upstream Anthropic API. Registered after /v1/messages so that
/// route retains its dedicated streaming/model-peek logic.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn anthropic_generic_passthrough(
State(state): State<AppState>,
vk_ctx: Option<axum::Extension<super::middleware::VirtualKeyContext>>,
auth_path: Option<axum::Extension<ClientAuthPath>>,
claims: Option<axum::Extension<crate::server::oidc::JwtClaims>>,
OriginalUri(uri): OriginalUri,
method: axum::http::Method,
headers: axum::http::HeaderMap,
@@ -448,6 +550,21 @@ pub(crate) async fn anthropic_generic_passthrough(
);
return (StatusCode::FORBIDDEN, Json(err)).into_response();
}
let vk_ctx = vk_ctx.map(|axum::Extension(c)| c);
let auth_path = auth_path.map(|axum::Extension(p)| p);
let claims = claims.map(|axum::Extension(c)| c);
// vk_ctx is already rejected above (so resolve_client_auth_override's
// vk_ctx.is_none() check is always true here), but an OIDC-authenticated
// non-virtual-key request can still reach here, so this must also be
// gated on claims/auth_path (never forward a JWT upstream as if it were
// an Anthropic credential).
let auth_override_ref = resolve_client_auth_override(
state.forward_client_auth_enabled(),
auth_path,
&vk_ctx,
&claims,
&headers,
);
let client = match &state.backend {
BackendClient::Anthropic(c) => c,
@@ -492,7 +609,7 @@ pub(crate) async fn anthropic_generic_passthrough(
};
match client
.forward_generic(method, &full_path, body, &extra)
.forward_generic(method, &full_path, body, &extra, auth_override_ref)
.await
{
Ok(response) => {
@@ -566,3 +683,134 @@ fn virtual_key_accounting_parse_error() -> Response {
);
(StatusCode::BAD_GATEWAY, Json(err)).into_response()
}
#[cfg(test)]
mod tests {
use super::{
client_auth_forwardable, resolve_client_auth_override, select_client_auth_override,
ClientAuthPath,
};
use axum::http::HeaderMap;
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut h = HeaderMap::new();
for (k, v) in pairs {
h.insert(
axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
v.parse().unwrap(),
);
}
h
}
#[test]
fn selects_x_api_key_when_only_that_is_sent() {
let h = headers(&[("x-api-key", "client-key")]);
assert_eq!(
select_client_auth_override(&h),
Some(("x-api-key", "client-key"))
);
}
#[test]
fn selects_authorization_when_only_that_is_sent() {
let h = headers(&[("authorization", "Bearer sk-ant-oat-abc")]);
assert_eq!(
select_client_auth_override(&h),
Some(("authorization", "Bearer sk-ant-oat-abc"))
);
}
#[test]
fn prefers_x_api_key_when_both_sent_matching_validate_auth_precedence() {
let h = headers(&[
("x-api-key", "client-key"),
("authorization", "Bearer sk-ant-oat-abc"),
]);
assert_eq!(
select_client_auth_override(&h),
Some(("x-api-key", "client-key"))
);
}
#[test]
fn returns_none_when_neither_header_sent() {
let h = headers(&[]);
assert_eq!(select_client_auth_override(&h), None);
}
#[test]
fn selects_x_goog_api_key_forwarded_as_x_api_key() {
// validate_auth (server/middleware/auth.rs) treats x-goog-api-key as
// fully equivalent to x-api-key for authentication, but Anthropic's
// API only understands x-api-key -- the value must be forwarded
// under the x-api-key name, not the literal x-goog-api-key name.
let h = headers(&[("x-goog-api-key", "gemini-cli-key")]);
assert_eq!(
select_client_auth_override(&h),
Some(("x-api-key", "gemini-cli-key"))
);
}
#[test]
fn prefers_x_api_key_over_x_goog_api_key_matching_validate_auth_precedence() {
let h = headers(&[
("x-api-key", "primary-key"),
("x-goog-api-key", "secondary-key"),
]);
assert_eq!(
select_client_auth_override(&h),
Some(("x-api-key", "primary-key"))
);
}
#[test]
fn client_auth_forwardable_only_for_static_key_and_open_relay() {
assert!(client_auth_forwardable(Some(ClientAuthPath::StaticKey)));
assert!(client_auth_forwardable(Some(ClientAuthPath::OpenRelay)));
assert!(!client_auth_forwardable(Some(ClientAuthPath::VirtualKey)));
assert!(!client_auth_forwardable(Some(ClientAuthPath::OidcJwt)));
assert!(!client_auth_forwardable(None));
}
#[test]
fn resolve_client_auth_override_forwards_on_static_key() {
let h = headers(&[("x-api-key", "client-key")]);
assert_eq!(
resolve_client_auth_override(true, Some(ClientAuthPath::StaticKey), &None, &None, &h),
Some(("x-api-key", "client-key"))
);
}
#[test]
fn resolve_client_auth_override_refuses_when_vk_ctx_present_even_if_auth_path_says_static_key()
{
// Regression guard for the ClientAuthPath/VirtualKeyContext desync
// risk: even if a future bug leaves auth_path reporting StaticKey
// while a VirtualKeyContext extension is also present, forwarding
// must still be refused.
let h = headers(&[("x-api-key", "client-key")]);
let vk_ctx = Some(crate::server::middleware::VirtualKeyContext {
key_id: 1,
#[cfg(feature = "redis")]
key_hash_hex: String::new(),
rate_state: std::sync::Arc::new(crate::admin::keys::RateLimitState::new()),
allowed_models: None,
allowed_routes: None,
period_reset: None,
});
assert_eq!(
resolve_client_auth_override(true, Some(ClientAuthPath::StaticKey), &vk_ctx, &None, &h),
None
);
}
#[test]
fn resolve_client_auth_override_refuses_when_feature_disabled() {
let h = headers(&[("x-api-key", "client-key")]);
assert_eq!(
resolve_client_auth_override(false, Some(ClientAuthPath::StaticKey), &None, &None, &h),
None
);
}
}
+1
View File
@@ -83,6 +83,7 @@ pub fn app_multi_with_shared(
log_bodies: config.log_bodies,
redact_secrets: config.redact_secrets,
anthropic_thinking_repair: config.anthropic_thinking_repair,
forward_client_auth: config.forward_client_auth,
// Derive from the static tool-engine preset (built from YAML/env
// at startup) rather than hardcoding Disabled -- otherwise a
// standalone deployment (no --webui/--admin, so `shared` is None)
+14
View File
@@ -267,6 +267,20 @@ impl AppState {
.anthropic_thinking_repair
}
/// Whether Anthropic passthrough forwards the client's own incoming
/// credential upstream instead of the operator's (`ANTHROPIC_FORWARD_CLIENT_AUTH`,
/// live-toggleable via `RuntimeConfig.forward_client_auth`). Read fresh on
/// every request -- unlike the old frozen `AppState` field this replaced,
/// this reflects an admin-UI change immediately without a restart, and
/// applies uniformly to every `BackendKind::Anthropic` backend since they
/// all share one `RuntimeConfig`.
pub(crate) fn forward_client_auth_enabled(&self) -> bool {
self.runtime_config
.read()
.unwrap_or_else(|e| e.into_inner())
.forward_client_auth
}
/// The thinking-repair store, but only when the live admin-toggleable
/// flag is actually on. `None` both when repair is entirely absent (non-
/// Anthropic backend) and when it's present-but-disabled -- single
+241
View File
@@ -0,0 +1,241 @@
// Integration tests for ANTHROPIC_FORWARD_CLIENT_AUTH (BACKEND=anthropic
// passthrough only). Drives the real /v1/messages route
// (`anthropic_passthrough`) against a mock upstream that records the
// credential header it actually received, proving:
// - toggle off -> the operator's own configured credential always reaches
// upstream, regardless of what the client sent (regression baseline).
// - toggle on + PROXY_OPEN_RELAY (auth_path = OpenRelay) -> the client's
// own header is forwarded verbatim instead, byte-for-byte (no
// x-api-key<->Bearer re-shaping).
use anyllm_proxy::config::{self, BackendAuth, BackendKind, Config, ModelMapping, OpenAIApiFormat};
use anyllm_proxy::server::routes;
use axum::{extract::Request, response::IntoResponse, routing::post, Router};
use reqwest::Client;
use serde_json::json;
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
// Serializes tests in this file that mutate process env vars
// (ANTHROPIC_FORWARD_CLIENT_AUTH, PROXY_OPEN_RELAY): each test needs a
// different value, unlike thinking_repair.rs's tests which all want the same
// ones. This file compiles to its own test binary, so it can't race with
// other integration test files -- only with itself. `tokio::sync::Mutex`,
// not `std::sync::Mutex`, because the guard is held across `.await` points
// (clippy::await_holding_lock).
static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
fn anthropic_config_with_base(base_url: &str) -> Config {
Config {
backend: BackendKind::Anthropic,
openai_api_key: "operator-secret-key".to_string(),
openai_base_url: base_url.to_string(),
listen_port: 0,
model_mapping: ModelMapping {
big_model: String::new(),
small_model: String::new(),
},
tls: config::TlsConfig::default(),
backend_auth: BackendAuth::AnthropicApiKey("operator-secret-key".to_string()),
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
expose_degradation_warnings: false,
openai_api_format: OpenAIApiFormat::Chat,
provider_id: None,
}
}
async fn spawn_proxy(config: Config) -> String {
let app = routes::app(config);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("http://{addr}")
}
/// Mock upstream Anthropic API: records the exact `x-api-key`/`authorization`
/// header it received (name + value) and acks with a minimal valid response.
async fn spawn_mock_anthropic_backend(received: Arc<Mutex<Vec<(String, String)>>>) -> String {
let app = Router::new().route(
"/v1/messages",
post(move |req: Request| {
let received = received.clone();
async move {
let headers = req.headers().clone();
if let Some(v) = headers.get("x-api-key") {
received
.lock()
.unwrap()
.push(("x-api-key".to_string(), v.to_str().unwrap().to_string()));
}
if let Some(v) = headers.get("authorization") {
received
.lock()
.unwrap()
.push(("authorization".to_string(), v.to_str().unwrap().to_string()));
}
axum::Json(json!({
"id": "msg_ack",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "ack"}],
"model": "claude-opus-4-5",
"usage": {"input_tokens": 5, "output_tokens": 1}
}))
.into_response()
}
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("http://{addr}")
}
#[tokio::test]
async fn toggle_off_always_forwards_operator_credential() {
let _lock = ENV_LOCK.lock().await;
std::env::remove_var("ANTHROPIC_FORWARD_CLIENT_AUTH");
std::env::set_var("PROXY_OPEN_RELAY", "true");
let received = Arc::new(Mutex::new(Vec::new()));
let mock = spawn_mock_anthropic_backend(received.clone()).await;
let proxy = spawn_proxy(anthropic_config_with_base(&mock)).await;
let resp = Client::new()
.post(format!("{proxy}/v1/messages"))
.header("x-api-key", "client-sent-key-must-be-ignored")
.json(&json!({
"model": "claude-opus-4-5",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "hi"}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let calls = received.lock().unwrap();
assert_eq!(
calls.as_slice(),
&[("x-api-key".to_string(), "operator-secret-key".to_string())],
"toggle off: only the operator's own configured credential must reach upstream"
);
std::env::remove_var("PROXY_OPEN_RELAY");
}
#[tokio::test]
async fn toggle_on_with_open_relay_forwards_client_x_api_key_verbatim() {
let _lock = ENV_LOCK.lock().await;
std::env::set_var("ANTHROPIC_FORWARD_CLIENT_AUTH", "true");
std::env::set_var("PROXY_OPEN_RELAY", "true");
let received = Arc::new(Mutex::new(Vec::new()));
let mock = spawn_mock_anthropic_backend(received.clone()).await;
let proxy = spawn_proxy(anthropic_config_with_base(&mock)).await;
let resp = Client::new()
.post(format!("{proxy}/v1/messages"))
.header("x-api-key", "clients-own-subscription-derived-key")
.json(&json!({
"model": "claude-opus-4-5",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "hi"}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let calls = received.lock().unwrap();
assert_eq!(
calls.as_slice(),
&[(
"x-api-key".to_string(),
"clients-own-subscription-derived-key".to_string()
)],
"toggle on + open relay: the client's own header must be forwarded, operator credential absent"
);
std::env::remove_var("ANTHROPIC_FORWARD_CLIENT_AUTH");
std::env::remove_var("PROXY_OPEN_RELAY");
}
#[tokio::test]
async fn toggle_on_with_open_relay_forwards_bearer_token_unmodified() {
let _lock = ENV_LOCK.lock().await;
std::env::set_var("ANTHROPIC_FORWARD_CLIENT_AUTH", "true");
std::env::set_var("PROXY_OPEN_RELAY", "true");
let received = Arc::new(Mutex::new(Vec::new()));
let mock = spawn_mock_anthropic_backend(received.clone()).await;
let proxy = spawn_proxy(anthropic_config_with_base(&mock)).await;
let resp = Client::new()
.post(format!("{proxy}/v1/messages"))
.header("authorization", "Bearer sk-ant-oat-subscription-token")
.json(&json!({
"model": "claude-opus-4-5",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "hi"}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let calls = received.lock().unwrap();
assert_eq!(
calls.as_slice(),
&[(
"authorization".to_string(),
"Bearer sk-ant-oat-subscription-token".to_string()
)],
"must forward the Bearer token as-is -- never converted to x-api-key"
);
std::env::remove_var("ANTHROPIC_FORWARD_CLIENT_AUTH");
std::env::remove_var("PROXY_OPEN_RELAY");
}
#[tokio::test]
async fn toggle_on_with_open_relay_forwards_x_goog_api_key_as_x_api_key() {
// Regression test: validate_auth (server/middleware/auth.rs) treats
// x-goog-api-key as equally valid to x-api-key for authentication, but
// Anthropic's API only understands x-api-key -- the client's
// x-goog-api-key value must reach upstream renamed to x-api-key, not
// silently dropped in favor of the operator's own credential and not
// forwarded under a header name Anthropic would ignore.
let _lock = ENV_LOCK.lock().await;
std::env::set_var("ANTHROPIC_FORWARD_CLIENT_AUTH", "true");
std::env::set_var("PROXY_OPEN_RELAY", "true");
let received = Arc::new(Mutex::new(Vec::new()));
let mock = spawn_mock_anthropic_backend(received.clone()).await;
let proxy = spawn_proxy(anthropic_config_with_base(&mock)).await;
let resp = Client::new()
.post(format!("{proxy}/v1/messages"))
.header("x-goog-api-key", "gemini-cli-compat-key")
.json(&json!({
"model": "claude-opus-4-5",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "hi"}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let calls = received.lock().unwrap();
assert_eq!(
calls.as_slice(),
&[("x-api-key".to_string(), "gemini-cli-compat-key".to_string())],
"x-goog-api-key must be forwarded upstream renamed to x-api-key, not dropped"
);
std::env::remove_var("ANTHROPIC_FORWARD_CLIENT_AUTH");
std::env::remove_var("PROXY_OPEN_RELAY");
}
@@ -0,0 +1,67 @@
// Regression test for the ANTHROPIC_FORWARD_CLIENT_AUTH misconfiguration
// guard when the toggle is flipped live via the admin API (not just at
// startup). See server/middleware/auth.rs::forward_client_auth_misconfigured
// and admin/routes/config.rs::put_config.
//
// This lives in its own integration test binary (its own process) rather
// than crates/proxy/src/admin/routes/tests.rs deliberately: the check reads
// server/middleware/auth.rs's ALLOWED_KEY_HASHES/OPEN_RELAY `LazyLock`
// statics, which evaluate ONCE per process on first access and are cached
// forever after. That file's shared `--lib` test binary already has other
// tests (e.g. config::env_aliases's) that mutate PROXY_API_KEYS, and its own
// existing forward_client_auth-adjacent tests never need PROXY_API_KEYS set
// with 2+ entries -- adding that scenario there would make the outcome
// depend on which test happens to touch those statics first. A dedicated
// binary guarantees this test is the first (and only) thing to do so.
use anyllm_proxy::admin::state::SharedState;
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{Request, StatusCode};
use std::net::SocketAddr;
use std::sync::Arc;
use tower::ServiceExt;
#[tokio::test]
async fn put_config_rejects_forward_client_auth_when_misconfigured() {
// No PROXY_OPEN_RELAY set, and 2 distinct PROXY_API_KEYS entries: exactly
// the combination forward_client_auth_misconfigured rejects, matching the
// startup safeguard's rule.
std::env::remove_var("PROXY_OPEN_RELAY");
std::env::set_var("PROXY_API_KEYS", "key-one,key-two");
let shared = SharedState::new_for_test();
let token_str = "k".repeat(64);
shared.issued_csrf_tokens.insert(token_str.clone(), ());
let app = anyllm_proxy::admin::routes::admin_router(
shared.clone(),
Arc::new(zeroize::Zeroizing::new("test-token".to_string())),
);
let req = Request::put("/admin/api/config")
.header("host", "localhost:9090")
.header("authorization", "Bearer test-token")
.header("content-type", "application/json")
.header("x-csrf-token", &token_str)
.header("cookie", format!("csrf_token={token_str}"))
.extension(ConnectInfo("127.0.0.1:9090".parse::<SocketAddr>().unwrap()))
.body(Body::from(r#"{"forward_client_auth":true}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert!(
!shared.runtime_config.read().unwrap().forward_client_auth,
"runtime config must not have been flipped on"
);
let conn = shared.db.lock().unwrap();
let overrides = anyllm_proxy::admin::db::get_config_overrides(&conn).unwrap();
assert!(
!overrides
.iter()
.any(|(key, _, _)| key == "forward_client_auth"),
"rejected toggle must not be persisted as an override"
);
}
@@ -89,6 +89,7 @@ async fn spawn_proxy(backend_base_url: String, deployment: Arc<Deployment>) -> S
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
forward_client_auth: false,
default_backend: "openai".to_string(),
backends,
expose_degradation_warnings: false,
+1
View File
@@ -133,6 +133,7 @@ fn multi_config(backends: IndexMap<String, BackendConfig>, default_backend: &str
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
forward_client_auth: false,
default_backend: default_backend.to_string(),
backends,
expose_degradation_warnings: false,
+1
View File
@@ -735,6 +735,7 @@ fn multi_config_with_backend_bases(allowed_base: &str, denied_base: &str) -> Mul
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
forward_client_auth: false,
default_backend: "allowed".to_string(),
backends,
expose_degradation_warnings: false,
+46
View File
@@ -204,6 +204,7 @@ Set `BACKEND=anthropic` to forward Anthropic Messages API requests directly to t
| `ANTHROPIC_API_KEY` | (required) | Anthropic API key. |
| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | Base URL for the Anthropic API. |
| `ANTHROPIC_THINKING_REPAIR` | `false` | Repair corrupted `thinking`/`redacted_thinking` blocks in the last assistant message of `/v1/messages` requests before forwarding upstream. See below. |
| `ANTHROPIC_FORWARD_CLIENT_AUTH` | `false` | Forward the client's own `x-api-key`/`Authorization` header upstream verbatim instead of `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`. See below. |
### Example
@@ -242,6 +243,51 @@ restart required. The env var only sets the value at startup; an admin-UI
change takes effect immediately and persists across restarts via SQLite until
reset.
### Forwarding the client's own credential (`ANTHROPIC_FORWARD_CLIENT_AUTH=true`)
By default the proxy always sends **its own** `ANTHROPIC_API_KEY`/
`ANTHROPIC_AUTH_TOKEN` to the real Anthropic API — the client's incoming
`x-api-key`/`Authorization` header is only ever checked against the proxy's
own inbound auth (`PROXY_API_KEYS`/`PROXY_OPEN_RELAY`) and then discarded.
Setting this flag instead forwards that exact header — same name, same
value, byte-for-byte, no re-shaping — upstream in place of the operator's
configured credential. This lets Claude Code use its own Pro/Max
subscription OAuth session directly through the proxy, without a separate
`claude setup-token` step.
Since the credential that authenticates a request into the proxy becomes the
literal credential sent to Anthropic, this only makes sense for a
single-key/BYOK deployment where those two are meant to be the same thing.
It is automatically skipped (the operator's own credential is used instead,
regardless of the flag) for any request authenticated via a virtual key or
OIDC/JWT — a virtual key is deliberately not a real Anthropic credential, and
forwarding a JWT upstream would never work. A client that authenticated via
the Gemini-CLI-compatible `x-goog-api-key` header has its value forwarded
renamed to `x-api-key` (the only credential header name Anthropic itself
understands), not literally as `x-goog-api-key`.
At startup, the proxy refuses to start with this flag on if `PROXY_API_KEYS`
has 2+ distinct entries and `PROXY_OPEN_RELAY` is not set, since that
combination would let different callers each redirect the upstream Anthropic
credential. The same rule is enforced live: this flag is toggleable from the
admin UI (**Settings**) or `PUT /admin/api/config` with no restart, and that
route rejects the same misconfigured combination with a 400 rather than
silently accepting it.
```bash
BACKEND=anthropic \
ANTHROPIC_AUTH_TOKEN=$(claude setup-token) \
ANTHROPIC_FORWARD_CLIENT_AUTH=true \
PROXY_OPEN_RELAY=true \
anyllm_proxy
```
Only active for `BACKEND=anthropic` passthrough (`/v1/messages` and the
generic Anthropic-native catch-all route). Off by default. Applies uniformly
to every `BackendKind::Anthropic` backend in a multi-backend deployment (one
shared runtime setting, like `ANTHROPIC_THINKING_REPAIR`) rather than being
configurable per backend.
---
## Third-party OpenAI-compatible providers