fix: passthrough_auth accepts Anthropic x-api-key and normalizes to upstream format (#892)
Some checks are pending
CI / pre-commit (push) Waiting to run
CI / plano-tools-tests (push) Waiting to run
CI / native-smoke-test (push) Waiting to run
CI / docker-build (push) Waiting to run
CI / validate-config (push) Waiting to run
CI / security-scan (push) Blocked by required conditions
CI / test-prompt-gateway (push) Blocked by required conditions
CI / test-model-alias-routing (push) Blocked by required conditions
CI / test-responses-api-with-state (push) Blocked by required conditions
CI / e2e-plano-tests (3.10) (push) Blocked by required conditions
CI / e2e-plano-tests (3.11) (push) Blocked by required conditions
CI / e2e-plano-tests (3.12) (push) Blocked by required conditions
CI / e2e-plano-tests (3.13) (push) Blocked by required conditions
CI / e2e-plano-tests (3.14) (push) Blocked by required conditions
CI / e2e-demo-preference (push) Blocked by required conditions
CI / e2e-demo-currency (push) Blocked by required conditions
Publish docker image (latest) / build-arm64 (push) Waiting to run
Publish docker image (latest) / build-amd64 (push) Waiting to run
Publish docker image (latest) / create-manifest (push) Blocked by required conditions
Build and Deploy Documentation / build (push) Waiting to run

This commit is contained in:
Adil Hafeez 2026-04-17 17:23:05 -07:00 committed by GitHub
parent 0f67b2c806
commit 37600fd07a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -177,24 +177,33 @@ impl StreamContext {
} }
fn modify_auth_headers(&mut self) -> Result<(), ServerError> { fn modify_auth_headers(&mut self) -> Result<(), ServerError> {
if self.llm_provider().passthrough_auth == Some(true) { // Determine the credential to forward upstream. Either the client
// Check if client provided an Authorization header // supplied one (passthrough_auth) or it's configured on the provider.
if self.get_http_request_header("Authorization").is_none() { let credential: String = if self.llm_provider().passthrough_auth == Some(true) {
warn!( // Client auth may arrive in either Anthropic-style (`x-api-key`)
"request_id={}: passthrough_auth enabled but no authorization header present in client request", // or OpenAI-style (`Authorization: Bearer ...`). Accept both so
self.request_identifier() // clients using Anthropic SDKs (which default to `x-api-key`)
); // work when the upstream is OpenAI-compatible, and vice versa.
} else { let authorization = self.get_http_request_header("Authorization");
let x_api_key = self.get_http_request_header("x-api-key");
match extract_client_credential(authorization.as_deref(), x_api_key.as_deref()) {
Some(key) => {
debug!( debug!(
"request_id={}: preserving client authorization header for provider '{}'", "request_id={}: forwarding client credential to provider '{}'",
self.request_identifier(), self.request_identifier(),
self.llm_provider().name self.llm_provider().name
); );
key
} }
None => {
warn!(
"request_id={}: passthrough_auth enabled but no Authorization / x-api-key header present in client request",
self.request_identifier()
);
return Ok(()); return Ok(());
} }
}
let llm_provider_api_key_value = } else {
self.llm_provider() self.llm_provider()
.access_key .access_key
.as_ref() .as_ref()
@ -203,15 +212,19 @@ impl StreamContext {
"No access key configured for selected LLM Provider \"{}\"", "No access key configured for selected LLM Provider \"{}\"",
self.llm_provider() self.llm_provider()
), ),
})?; })?
.clone()
};
// Set API-specific headers based on the resolved upstream API // Normalize the credential into whichever header the upstream expects.
// This lets an Anthropic-SDK client reach an OpenAI-compatible upstream
// (and vice versa) without the caller needing to know what format the
// upstream uses.
match self.resolved_api.as_ref() { match self.resolved_api.as_ref() {
Some(SupportedUpstreamAPIs::AnthropicMessagesAPI(_)) => { Some(SupportedUpstreamAPIs::AnthropicMessagesAPI(_)) => {
// Anthropic API requires x-api-key and anthropic-version headers // Anthropic expects `x-api-key` + `anthropic-version`.
// Remove any existing Authorization header since Anthropic doesn't use it
self.remove_http_request_header("Authorization"); self.remove_http_request_header("Authorization");
self.set_http_request_header("x-api-key", Some(llm_provider_api_key_value)); self.set_http_request_header("x-api-key", Some(&credential));
self.set_http_request_header("anthropic-version", Some("2023-06-01")); self.set_http_request_header("anthropic-version", Some("2023-06-01"));
} }
Some( Some(
@ -221,10 +234,9 @@ impl StreamContext {
| SupportedUpstreamAPIs::OpenAIResponsesAPI(_), | SupportedUpstreamAPIs::OpenAIResponsesAPI(_),
) )
| None => { | None => {
// OpenAI and default: use Authorization Bearer token // OpenAI (and default): `Authorization: Bearer ...`.
// Remove any existing x-api-key header since OpenAI doesn't use it
self.remove_http_request_header("x-api-key"); self.remove_http_request_header("x-api-key");
let authorization_header_value = format!("Bearer {}", llm_provider_api_key_value); let authorization_header_value = format!("Bearer {}", credential);
self.set_http_request_header("Authorization", Some(&authorization_header_value)); self.set_http_request_header("Authorization", Some(&authorization_header_value));
} }
} }
@ -1235,3 +1247,86 @@ fn current_time_ns() -> u128 {
} }
impl Context for StreamContext {} impl Context for StreamContext {}
/// Extract the credential a client sent in either an OpenAI-style
/// `Authorization` header or an Anthropic-style `x-api-key` header.
///
/// Returns `None` when neither header is present or both are empty/whitespace.
/// The `Bearer ` prefix on the `Authorization` value is stripped if present;
/// otherwise the value is taken verbatim (some clients send a raw token).
fn extract_client_credential(
authorization: Option<&str>,
x_api_key: Option<&str>,
) -> Option<String> {
// Strip the optional "Bearer " / "Bearer" prefix (case-sensitive, matches
// OpenAI SDK behavior) and trim surrounding whitespace before validating
// non-empty.
let from_authorization = authorization
.map(|v| {
v.strip_prefix("Bearer ")
.or_else(|| v.strip_prefix("Bearer"))
.unwrap_or(v)
.trim()
.to_string()
})
.filter(|s| !s.is_empty());
if from_authorization.is_some() {
return from_authorization;
}
x_api_key
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::extract_client_credential;
#[test]
fn authorization_bearer_strips_prefix() {
assert_eq!(
extract_client_credential(Some("Bearer sk-abc"), None),
Some("sk-abc".to_string())
);
}
#[test]
fn authorization_raw_token_preserved() {
// Some clients send the raw token without "Bearer " — accept it.
assert_eq!(
extract_client_credential(Some("sk-abc"), None),
Some("sk-abc".to_string())
);
}
#[test]
fn x_api_key_used_when_authorization_absent() {
assert_eq!(
extract_client_credential(None, Some("sk-ant-api-key")),
Some("sk-ant-api-key".to_string())
);
}
#[test]
fn authorization_wins_when_both_present() {
// If a client is particularly exotic and sends both, prefer the
// OpenAI-style Authorization header.
assert_eq!(
extract_client_credential(Some("Bearer openai-key"), Some("anthropic-key")),
Some("openai-key".to_string())
);
}
#[test]
fn returns_none_when_neither_present() {
assert!(extract_client_credential(None, None).is_none());
}
#[test]
fn empty_and_whitespace_headers_are_ignored() {
assert!(extract_client_credential(Some(""), None).is_none());
assert!(extract_client_credential(Some("Bearer "), None).is_none());
assert!(extract_client_credential(Some(" "), Some(" ")).is_none());
}
}