Skip to content

feat(proxy): Client-side provider API key precedence for Anthropic /v1/messages (BYOK)#22964

Merged
Sameerlite merged 2 commits intomainfrom
litellm_claude-code-byok
Mar 6, 2026
Merged

feat(proxy): Client-side provider API key precedence for Anthropic /v1/messages (BYOK)#22964
Sameerlite merged 2 commits intomainfrom
litellm_claude-code-byok

Conversation

@Sameerlite
Copy link
Collaborator

Summary

Enables Bring Your Own Key (BYOK) for the unified Anthropic /v1/messages endpoint. When forward_llm_provider_auth_headers: true, client-provided x-api-key (e.g., from Claude Code /login) takes precedence over proxy/deployment-configured keys.

Changes

  • litellm_pre_call_utils.py: Read forward_llm_provider_auth_headers from global litellm module (set by litellm_settings in config)
  • clean_headers: Forward x-api-key when it's a provider key (not used for LiteLLM auth) and setting is enabled
  • add_litellm_data_to_request: Set data["api_key"] from client x-api-key so it overrides deployment keys via clientside_credential_handler
  • Tests: Unit tests for clean_headers x-api-key forwarding; fix duplicate patch in test_byok_oauth_endpoints.py
  • Docs: New Claude Code BYOK guide — use /login + ANTHROPIC_CUSTOM_HEADERS="x-litellm-api-key: sk-12345"

Screenshot

Claude Code BYOK

Configuration

litellm_settings:
  forward_llm_provider_auth_headers: true

Testing

  • Unit tests: pytest tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py -v
  • Manual: Proxy with forward_llm_provider_auth_headers: true, curl with Authorization: Bearer <litellm-key> + x-api-key: <anthropic-key>

Made with Cursor

@vercel
Copy link

vercel bot commented Mar 6, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 6, 2026 0:52am

Request Review

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 6, 2026

Greptile Summary

This PR implements Bring Your Own Key (BYOK) for the Anthropic /v1/messages endpoint by teaching clean_headers to conditionally forward x-api-key to the downstream provider when forward_llm_provider_auth_headers: true is configured, and then setting data["api_key"] from that header so it overrides any deployment-level key via the existing clientside_credential_handler path. It also extends the setting to be readable from litellm_settings (in addition to the existing general_settings) and adds a new Claude Code BYOK tutorial.

Key observations:

  • Several logic issues have been flagged in prior review rounds (case-sensitive "x-api-key" in _headers lookup, heuristic auth-header detection, inverted None guard, misleading else fallback, and provider-specific code in the proxy layer).
  • New: data["api_key"] is assigned before proxy_server_request.body = copy.copy(data), which means the client's Anthropic API key is now captured inside request-tracking metadata and may be emitted by logging/callback handlers that serialise proxy_server_request — a new key-exposure vector not present before this PR.
  • New: The explicit x-litellm-api-key passthrough block (if header_lower == "x-litellm-api-key": continue) is a silent backwards-incompatible change — operators who happened to rely on this header passing through with forward_llm_provider_auth_headers: true will lose it without a migration path or config flag.

Confidence Score: 2/5

  • Not safe to merge: the feature has multiple correctness gaps (case-sensitive header lookup, inverted None guard, heuristic auth detection) that cause BYOK to silently fall back to the proxy key in common scenarios, plus a new key-exposure vector via proxy_server_request.body and a backwards-incompatible change to x-litellm-api-key forwarding behaviour.
  • Multiple logic issues flagged across two review rounds remain unresolved. The core happy path (x-litellm-api-key + x-api-key, HTTP/2 lowercase headers) works, but edge cases like mixed-case headers, custom auth setups, and unknown auth sources cause silent BYOK failure. The new api_key assignment position captures client credentials in request-tracking metadata, and the implicit x-litellm-api-key forwarding removal is a breaking change without a migration flag.
  • litellm/proxy/litellm_pre_call_utils.py requires the most attention — specifically the clean_headers x-api-key branch logic, the authenticated_with_header heuristic, and the placement of the data api_key assignment relative to proxy_server_request body capture.

Important Files Changed

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]
Loading

Last reviewed commit: e9d797b

Comment on lines +921 to +922
if forward_llm_auth and "x-api-key" in _headers:
data["api_key"] = _headers["x-api-key"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)"
    )

Comment on lines +896 to +905
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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-key with an invalid token and authorization with a valid LiteLLM token (the actual auth path), the code still sets authenticated_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.

Comment on lines +289 to +292
if (
forward_llm_provider_auth_headers
and (authenticated_with_header is None or authenticated_with_header.lower() != "x-api-key")
):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +289 to +295
# 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-keydata["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!

Comment on lines +898 to +907
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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Unauthenticated/custom-auth requests: If a proxy operator has disabled auth or uses a completely custom auth mechanism (no x-litellm-api-key, no authorization, no x-api-key), authenticated_with_header is set to "x-api-key". If the user also sends an x-api-key intending it as a provider key, clean_headers will drop it (since the guard treats it as the auth header), and BYOK silently falls back to the proxy-configured key.

  2. Misleading fallback: The branch should only confidently set "x-api-key" when x-api-key is actually present in the request. For all other cases (no known auth header present), authenticated_with_header should remain None so clean_headers can 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

Comment on lines +570 to +590
)

# 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"),
]
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@Sameerlite Sameerlite force-pushed the litellm_claude-code-byok branch from cce1c29 to e9d797b Compare March 6, 2026 12:51
@Sameerlite Sameerlite merged commit 447b760 into main Mar 6, 2026
31 of 42 checks passed
Comment on lines 302 to +305
continue
# Never forward x-litellm-api-key (it's for proxy auth only)
if header_lower == "x-litellm-api-key":
continue
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment on lines +923 to +924
if forward_llm_auth and "x-api-key" in _headers:
data["api_key"] = _headers["x-api-key"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)"
    )

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant