mirror of
https://github.com/whit3rabbit/anyllm-proxy.git
synced 2026-09-22 00:00:50 +00:00
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:
co-authored by
Claude Sonnet 5
parent
9456d1ad1b
commit
a87cae6ca4
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user