feat(proxy): Client-side provider API key precedence for Anthropic /v1/messages (BYOK)#22964
feat(proxy): Client-side provider API key precedence for Anthropic /v1/messages (BYOK)#22964Sameerlite merged 2 commits intomainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR implements Bring Your Own Key (BYOK) for the Anthropic Key observations:
Confidence Score: 2/5
|
| Filename | Overview |
|---|---|
| litellm/proxy/litellm_pre_call_utils.py | Core BYOK implementation: adds authenticated_with_header heuristic to clean_headers, special-cases x-api-key forwarding, and sets data["api_key"] before proxy_server_request.body is captured (key exposure risk). Several logic issues remain (case-sensitive lookup, inverted None guard, misleading else branch, provider-specific code in proxy layer), most already flagged in prior review rounds. |
| tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py | Removes a duplicate patch("litellm.proxy.proxy_server.prisma_client", ...) context manager in two test functions — straightforward fix that eliminates a Python syntax ambiguity. |
| docs/my-website/docs/tutorials/claude_code_byok.md | New tutorial doc for Claude Code BYOK setup; instructions are accurate for the happy path. Does not distinguish between the /login API-key flow (sends x-api-key) and OAuth flow (sends Authorization: Bearer), which could cause confusion if a user uses OAuth tokens via this guide. |
| docs/my-website/docs/proxy/forward_client_headers.md | Minor docs update adding a cross-link to the new BYOK tutorial — no functional changes. |
| docs/my-website/sidebars.js | Adds the new tutorials/claude_code_byok entry to the sidebar — straightforward docs navigation change. |
| tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py | Adds new unit tests covering x-api-key forwarding in clean_headers for BYOK scenarios. Tests are correctly isolated with mocking. One test verifies exclusion when authenticated_with_header is None but passes for the wrong reason (the forwarding flag is False by default, not the None guard), leaving the actual None-guard behaviour untested. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Client Request arrives at Proxy<br/>Headers: x-litellm-api-key + x-api-key] --> B{forward_llm_provider_auth_headers?}
B -- false --> C[clean_headers: strip all special headers<br/>x-api-key dropped]
B -- true --> D{Which header used for LiteLLM auth?<br/>Heuristic: check header presence}
D -- x-litellm-api-key present --> E[authenticated_with_header = x-litellm-api-key]
D -- authorization present --> F[authenticated_with_header = authorization]
D -- neither present --> G[authenticated_with_header = x-api-key<br/>⚠ hardcoded fallback]
E --> H[clean_headers: forward x-api-key<br/>auth != x-api-key so condition passes]
F --> H
G --> I[clean_headers: drop x-api-key<br/>auth == x-api-key so condition fails]
H --> J[data api_key = client provider token<br/>⚠ also captured in proxy_server_request body]
J --> K[LLM call uses client token<br/>overrides deployment token]
I --> L[LLM call uses deployment token<br/>BYOK silently bypassed]
Last reviewed commit: e9d797b
| if forward_llm_auth and "x-api-key" in _headers: | ||
| data["api_key"] = _headers["x-api-key"] |
There was a problem hiding this comment.
Case-sensitive header lookup will silently skip BYOK
_headers is a plain dict built from clean_headers, which stores each key with its original casing (e.g. clean_headers[header] = value where header is the raw value from headers.items()). The check "x-api-key" in _headers is therefore case-sensitive.
If a client (or Claude Code in an HTTP/1.1 context) sends the header as X-Api-Key or X-API-Key, the dict will contain that key under its original case, so "x-api-key" in _headers will evaluate to False, data["api_key"] will never be set, and BYOK will silently fall back to the proxy-configured key — which is the exact bug this feature is trying to prevent.
Fix: normalise the key when looking up and reading:
_x_api_key_value = next(
(v for k, v in _headers.items() if k.lower() == "x-api-key"), None
)
if forward_llm_auth and _x_api_key_value:
data["api_key"] = _x_api_key_value
verbose_proxy_logger.debug(
"Setting client-provided x-api-key as api_key parameter (will override deployment key)"
)| authenticated_with_header = None | ||
| if "x-litellm-api-key" in request.headers: | ||
| # If x-litellm-api-key is present, it was used for auth | ||
| authenticated_with_header = "x-litellm-api-key" | ||
| elif "authorization" in request.headers: | ||
| # Authorization header was used for auth | ||
| authenticated_with_header = "authorization" | ||
| else: | ||
| # x-api-key or another header was used for auth | ||
| authenticated_with_header = "x-api-key" |
There was a problem hiding this comment.
Auth header detection is heuristic — presence does not equal usage
The code infers which header was used for LiteLLM authentication by checking which headers are present in the request, not which one was actually accepted by user_api_key_auth. For example:
- If a client sends
x-litellm-api-keywith an invalid token andauthorizationwith a valid LiteLLM token (the actual auth path), the code still setsauthenticated_with_header = "x-litellm-api-key"because the first branch is taken on presence alone.
In this specific case the downstream x-api-key forwarding still works correctly (neither header name equals "x-api-key"), but the assumption is fragile. A more robust approach would be to propagate the actual authentication source from user_api_key_dict (e.g., capture which header was used during user_api_key_auth and pass it here) rather than re-deriving it from header presence in the critical request path.
| if ( | ||
| forward_llm_provider_auth_headers | ||
| and (authenticated_with_header is None or authenticated_with_header.lower() != "x-api-key") | ||
| ): |
There was a problem hiding this comment.
Forwarding guard inverted for the None case
The is None or short-circuit means the provider key header gets forwarded even when the auth source is unknown. The test test_clean_headers_x_api_key_without_authenticated_header_param explicitly states the key should be excluded when authenticated_with_header is None, but it only passes because the forwarding flag defaults to False in that test.
Changing is None or to is not None and (positive identification) would ensure the header is only forwarded when we positively know a different header was used for LiteLLM auth, matching the documented intent.
| # Special handling for x-api-key: forward it based on authenticated_with_header | ||
| elif header_lower == "x-api-key": | ||
| if ( | ||
| forward_llm_provider_auth_headers | ||
| and (authenticated_with_header is None or authenticated_with_header.lower() != "x-api-key") | ||
| ): | ||
| clean_headers[header] = value |
There was a problem hiding this comment.
Provider-specific x-api-key handling outside llms/ directory
Lines 289–295 add special-case logic for x-api-key (which is SpecialHeaders.anthropic_authorization) directly in the proxy-level utility. Similarly, lines 922–927 hardcode the x-api-key → data["api_key"] mapping specifically for the Anthropic BYOK use case.
The custom rule asks to keep provider-specific code inside the llms/ directory so changes are isolated and the general proxy path doesn't accumulate per-provider conditionals over time. For example, the existing is_anthropic_oauth_key check is imported from litellm.llms.anthropic.common_utils; the BYOK key resolution logic would be a natural fit for a similar helper there.
Consider extracting the "which header carries the provider key and how does it map to api_key?" logic into litellm/llms/anthropic/common_utils.py (or a new helper) and calling it from here, instead of embedding the x-api-key name string directly in the proxy utils.
Context Used: Rule from dashboard - What: Avoid writing provider-specific code outside of the llms/ directory.
Why: This practice ensur... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| authenticated_with_header = None | ||
| if "x-litellm-api-key" in request.headers: | ||
| # If x-litellm-api-key is present, it was used for auth | ||
| authenticated_with_header = "x-litellm-api-key" | ||
| elif "authorization" in request.headers: | ||
| # Authorization header was used for auth | ||
| authenticated_with_header = "authorization" | ||
| else: | ||
| # x-api-key or another header was used for auth | ||
| authenticated_with_header = "x-api-key" |
There was a problem hiding this comment.
else branch silently disables BYOK when no standard auth header is present
The else at line 905 fires whenever neither x-litellm-api-key nor authorization is in the request. The comment says "x-api-key or another header was used for auth", but the value is hardcoded to "x-api-key". This means:
-
Unauthenticated/custom-auth requests: If a proxy operator has disabled auth or uses a completely custom auth mechanism (no
x-litellm-api-key, noauthorization, nox-api-key),authenticated_with_headeris set to"x-api-key". If the user also sends anx-api-keyintending it as a provider key,clean_headerswill drop it (since the guard treats it as the auth header), and BYOK silently falls back to the proxy-configured key. -
Misleading fallback: The branch should only confidently set
"x-api-key"whenx-api-keyis actually present in the request. For all other cases (no known auth header present),authenticated_with_headershould remainNonesoclean_headerscan apply its default safe behaviour.
# Suggested fix:
if "x-litellm-api-key" in request.headers:
authenticated_with_header = "x-litellm-api-key"
elif "authorization" in request.headers:
authenticated_with_header = "authorization"
elif "x-api-key" in request.headers:
# x-api-key itself was used for LiteLLM auth
authenticated_with_header = "x-api-key"
# else: leave as None — unknown/no-auth path| ) | ||
|
|
||
| # x-api-key should be forwarded (it's a provider key, not used for auth) | ||
| assert "x-api-key" in cleaned | ||
| assert cleaned["x-api-key"] == "sk-ant-api03-client-key" | ||
| # authorization should be excluded (was used for auth, not OAuth) | ||
| assert "authorization" not in cleaned | ||
| assert cleaned["content-type"] == "application/json" | ||
|
|
||
| def test_clean_headers_x_api_key_without_authenticated_header_param(self): | ||
| """clean_headers should exclude x-api-key when authenticated_with_header is None.""" | ||
| from starlette.datastructures import Headers | ||
|
|
||
| from litellm.proxy.litellm_pre_call_utils import clean_headers | ||
|
|
||
| raw_headers = Headers( | ||
| raw=[ | ||
| (b"x-api-key", b"sk-ant-api03-key"), | ||
| (b"content-type", b"application/json"), | ||
| ] | ||
| ) |
There was a problem hiding this comment.
Misleading test name and docstring
The test test_clean_headers_x_api_key_without_authenticated_header_param claims that x-api-key is excluded because authenticated_with_header is None. However, the assertion passes because forward_llm_provider_auth_headers is not set and defaults to False — with that flag unset, x-api-key is never forwarded regardless of what authenticated_with_header is.
The docstring ("when authenticated_with_header is None means we can't determine") is not verified by this test. When the forwarding flag is enabled and authenticated_with_header is None, x-api-key would actually be forwarded (the authenticated_with_header is None or ... branch evaluates to True), contradicting the stated intent.
Consider adding an explicit test with the forwarding flag enabled and authenticated_with_header as None to verify/document the expected behaviour for that combination, and update the docstring to accurately describe what the current test exercises.
…1/messages - Add forward_llm_provider_auth_headers support from litellm_settings - When enabled, client x-api-key takes precedence over deployment keys - Forward x-api-key when x-litellm-api-key or Authorization used for auth - Fix duplicate patch lines in test_byok_oauth_endpoints.py - Add Claude Code BYOK documentation with /login and ANTHROPIC_CUSTOM_HEADERS - Add unit tests for clean_headers x-api-key forwarding logic - Sync model_prices backup (pre-commit hook) Made-with: Cursor
…used for LiteLLM proxy auth When forward_llm_provider_auth_headers=true, Authorization: Bearer <litellm-key> was being forwarded to Anthropic if it looked like an OAuth key, causing auth failures. Now checked against authenticated_with_header: if Authorization was used to authenticate with the proxy, it is always stripped before forwarding to the LLM provider. Made-with: Cursor
cce1c29 to
e9d797b
Compare
| continue | ||
| # Never forward x-litellm-api-key (it's for proxy auth only) | ||
| if header_lower == "x-litellm-api-key": | ||
| continue |
There was a problem hiding this comment.
Silently breaks existing BYOK setups relying on x-litellm-api-key passthrough
The new explicit continue here prevents x-litellm-api-key from ever being forwarded to LLM providers, even when forward_llm_provider_auth_headers: true is set. Before this PR, when no litellm_key_header_name was configured (the default), x-litellm-api-key could pass through the _SPECIAL_HEADERS_CACHE branch because the only guard was if litellm_key_lower and header_lower == litellm_key_lower: continue, which short-circuits when litellm_key_lower is None.
Any operator who was forwarding x-litellm-api-key to a downstream proxy (an unusual but valid use case) will silently stop receiving it after upgrading, with no config flag to restore the old behavior — violating the policy against backwards-incompatible changes without user-controlled flags.
If the intent is specifically to protect against proxy-key leakage, consider gating this behind a flag or only applying it when the header was confirmed to be the LiteLLM auth header for the current request.
Context Used: Rule from dashboard - What: avoid backwards-incompatible changes without user-controlled flags
Why: This breaks current ... (source)
| if forward_llm_auth and "x-api-key" in _headers: | ||
| data["api_key"] = _headers["x-api-key"] |
There was a problem hiding this comment.
Client API key captured in proxy_server_request.body before redaction
data["api_key"] is assigned here (line 924), but data["proxy_server_request"]["body"] is built via copy.copy(data) at line 939, after this assignment. This means the client-provided Anthropic API key is now embedded in proxy_server_request.body and can be emitted by any logging callback or success/failure handler that serialises proxy_server_request.
Before this PR, api_key was never set in data at this stage (it was applied later by the router from litellm_params), so it was never captured inside proxy_server_request.body.
If you move the data["api_key"] assignment to after the proxy_server_request block (after line 941), the key will not be serialised into request-tracking metadata, reducing accidental exposure in logs.
# After data["proxy_server_request"] = {...} block
if forward_llm_auth and "x-api-key" in _headers:
data["api_key"] = _headers["x-api-key"]
verbose_proxy_logger.debug(
"Setting client-provided x-api-key as api_key parameter (will override deployment key)"
)
Summary
Enables Bring Your Own Key (BYOK) for the unified Anthropic
/v1/messagesendpoint. Whenforward_llm_provider_auth_headers: true, client-providedx-api-key(e.g., from Claude Code/login) takes precedence over proxy/deployment-configured keys.Changes
litellm_pre_call_utils.py: Readforward_llm_provider_auth_headersfrom globallitellmmodule (set bylitellm_settingsin config)clean_headers: Forwardx-api-keywhen it's a provider key (not used for LiteLLM auth) and setting is enabledadd_litellm_data_to_request: Setdata["api_key"]from clientx-api-keyso it overrides deployment keys viaclientside_credential_handlerclean_headersx-api-key forwarding; fix duplicate patch intest_byok_oauth_endpoints.py/login+ANTHROPIC_CUSTOM_HEADERS="x-litellm-api-key: sk-12345"Screenshot
Configuration
Testing
pytest tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py -vforward_llm_provider_auth_headers: true,curlwithAuthorization: Bearer <litellm-key>+x-api-key: <anthropic-key>Made with Cursor