-
-
Notifications
You must be signed in to change notification settings - Fork 6.5k
feat(proxy): Client-side provider API key precedence for Anthropic /v1/messages (BYOK) #22964
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| # Claude Code with Bring Your Own Key (BYOK) | ||
|
|
||
| Use Claude Code with your own Anthropic API key through the LiteLLM proxy. When you use Claude's `/login` with your Anthropic account, your API key is sent as `x-api-key`. With BYOK enabled, LiteLLM forwards your key to Anthropic instead of using proxy-configured keys — so you pay Anthropic directly while still benefiting from LiteLLM's routing, logging, and guardrails. | ||
|
|
||
| ## How It Works | ||
|
|
||
| 1. **Claude Code `/login`** — You sign in with your Anthropic account; Claude Code sends your Anthropic API key as `x-api-key`. | ||
| 2. **LiteLLM authentication** — You pass your LiteLLM proxy key via `ANTHROPIC_CUSTOM_HEADERS` so the proxy can authenticate and track your usage. | ||
| 3. **Key forwarding** — With `forward_llm_provider_auth_headers: true`, LiteLLM forwards your `x-api-key` to Anthropic, giving it precedence over any proxy-configured keys. | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| - [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) installed | ||
| - Anthropic API key (from [console.anthropic.com](https://console.anthropic.com)) | ||
| - LiteLLM proxy with a virtual key for authentication | ||
|
|
||
| ## Step 1: Configure LiteLLM Proxy | ||
|
|
||
| Enable forwarding of LLM provider auth headers so your Anthropic key takes precedence: | ||
|
|
||
| ```yaml title="config.yaml" | ||
| model_list: | ||
| - model_name: claude-sonnet-4-5 | ||
| litellm_params: | ||
| model: anthropic/claude-sonnet-4-5 | ||
| # No api_key needed — client's key will be used | ||
|
|
||
| litellm_settings: | ||
| forward_llm_provider_auth_headers: true # Required for BYOK | ||
| ``` | ||
|
|
||
| :::info Why `forward_llm_provider_auth_headers`? | ||
|
|
||
| By default, LiteLLM strips `x-api-key` from client requests for security. Setting this to `true` allows client-provided provider keys (like your Anthropic key from `/login`) to be forwarded to Anthropic, overriding any proxy-configured keys. | ||
|
|
||
| ::: | ||
|
|
||
| ## Step 2: Create a LiteLLM Virtual Key | ||
|
|
||
| Create a virtual key in the LiteLLM UI or via API. | ||
| ```bash | ||
| # Example: Create key via API | ||
| curl -X POST "http://localhost:4000/key/generate" \ | ||
| -H "Authorization: Bearer sk-your-master-key" \ | ||
| -H "Content-Type: application/json" \ | ||
| -d '{"key_alias": "claude-code-byok", "models": ["claude-sonnet-4-5"]}' | ||
| ``` | ||
|
|
||
| ## Step 3: Configure Claude Code | ||
|
|
||
| Set environment variables so Claude Code uses LiteLLM and sends your LiteLLM key for proxy auth: | ||
|
|
||
| ```bash | ||
| # Point Claude Code to your LiteLLM proxy | ||
| export ANTHROPIC_BASE_URL="http://localhost:4000" | ||
|
|
||
| # Model name from your config | ||
| export ANTHROPIC_MODEL="claude-sonnet-4-5" | ||
|
|
||
| # LiteLLM proxy auth — this is added to every request | ||
| # Use x-litellm-api-key so the proxy authenticates you; your Anthropic key goes via x-api-key from /login | ||
| export ANTHROPIC_CUSTOM_HEADERS="x-litellm-api-key: sk-12345" | ||
| ``` | ||
|
|
||
| Replace `sk-12345` with your actual LiteLLM virtual key. | ||
|
|
||
| :::tip Multiple headers | ||
|
|
||
| For multiple headers, use newline-separated values: | ||
|
|
||
| ```bash | ||
| export ANTHROPIC_CUSTOM_HEADERS="x-litellm-api-key: sk-12345 | ||
| x-litellm-user-id: my-user-id" | ||
| ``` | ||
|
|
||
| ::: | ||
|
|
||
| ## Step 4: Sign In with Claude Code | ||
|
|
||
| 1. Launch Claude Code: | ||
|
|
||
| ```bash | ||
| claude | ||
| ``` | ||
|
|
||
| 2. Use **`/login`** and sign in with your Anthropic account (or use your API key directly). | ||
|
|
||
| 3. Claude Code will send: | ||
| - `x-api-key`: Your Anthropic API key (from `/login`) | ||
| - `x-litellm-api-key`: Your LiteLLM key (from `ANTHROPIC_CUSTOM_HEADERS`) | ||
|
|
||
| 4. LiteLLM authenticates you via `x-litellm-api-key`, then forwards `x-api-key` to Anthropic. Your Anthropic key takes precedence over any proxy-configured key. | ||
|
|
||
| ## Summary | ||
|
|
||
| | Header | Source | Purpose | | ||
| |--------|--------|---------| | ||
| | `x-api-key` | Claude Code `/login` (Anthropic key) | Sent to Anthropic for API calls | | ||
| | `x-litellm-api-key` | `ANTHROPIC_CUSTOM_HEADERS` | Proxy authentication, tracking, rate limits | | ||
|
|
||
| ## Troubleshooting | ||
|
|
||
| ### Requests fail with "invalid x-api-key" | ||
|
|
||
| - Ensure `forward_llm_provider_auth_headers: true` is set in `litellm_settings` (or `general_settings`). | ||
| - Restart the LiteLLM proxy after changing the config. | ||
| - Verify you completed `/login` in Claude Code so your Anthropic key is being sent. | ||
|
|
||
| ### Proxy returns 401 | ||
|
|
||
| - Check that `ANTHROPIC_CUSTOM_HEADERS` includes `x-litellm-api-key: <your-key>`. | ||
| - Ensure the LiteLLM key is valid and has access to the model. | ||
|
|
||
| ### Proxy key is used instead of my Anthropic key | ||
|
|
||
| - Confirm `forward_llm_provider_auth_headers: true` is in your config. | ||
| - The setting can be in `litellm_settings` or `general_settings` depending on your config structure. | ||
| - Enable debug logging: `LITELLM_LOG=DEBUG` to see which key is being forwarded. | ||
|
|
||
| ## Related | ||
|
|
||
| - [Forward Client Headers](./../proxy/forward_client_headers.md) — Full BYOK and header forwarding docs | ||
| - [Claude Code Max Subscription](./claude_code_max_subscription.md) — Using Claude Code with OAuth/Max subscription through LiteLLM |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -258,9 +258,20 @@ def clean_headers( | |
| headers: Headers, | ||
| litellm_key_header_name: Optional[str] = None, | ||
| forward_llm_provider_auth_headers: bool = False, | ||
| authenticated_with_header: Optional[str] = None, | ||
| ) -> dict: | ||
| """ | ||
| Removes litellm api key from headers | ||
|
|
||
| Args: | ||
| headers: Request headers | ||
| litellm_key_header_name: Custom header name for LiteLLM API key | ||
| forward_llm_provider_auth_headers: Whether to forward provider auth headers | ||
| authenticated_with_header: Which header was used for LiteLLM authentication | ||
| (e.g., "x-litellm-api-key", "authorization", "x-api-key") | ||
|
|
||
| Returns: | ||
| Cleaned headers dict | ||
| """ | ||
| from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key | ||
|
|
||
|
|
@@ -272,14 +283,26 @@ def clean_headers( | |
| header_lower = header.lower() | ||
|
|
||
| if header_lower == "authorization" and is_anthropic_oauth_key(value): | ||
| clean_headers[header] = value | ||
| if authenticated_with_header is None or authenticated_with_header.lower() != "authorization": | ||
| clean_headers[header] = value | ||
| continue | ||
| # 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 | ||
|
Comment on lines
+289
to
+295
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Provider-specific Lines 289–295 add special-case logic for The custom rule asks to keep provider-specific code inside the Consider extracting the "which header carries the provider key and how does it map to Context Used: Rule from 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! |
||
| elif ( | ||
| forward_llm_provider_auth_headers and header_lower in _SPECIAL_HEADERS_CACHE | ||
| ): | ||
| if litellm_key_lower and header_lower == litellm_key_lower: | ||
| continue | ||
| if header_lower == "authorization": | ||
| continue | ||
| # Never forward x-litellm-api-key (it's for proxy auth only) | ||
| if header_lower == "x-litellm-api-key": | ||
| continue | ||
|
Comment on lines
302
to
+305
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Silently breaks existing BYOK setups relying on The new explicit Any operator who was forwarding 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 Why: This breaks current ... (source) |
||
| clean_headers[header] = value | ||
| # Check if header should be excluded: either in special headers cache or matches custom litellm key | ||
| elif header_lower not in _SPECIAL_HEADERS_CACHE and ( | ||
|
|
@@ -868,6 +891,20 @@ async def add_litellm_data_to_request( # noqa: PLR0915 | |
| forward_llm_auth = general_settings.get( | ||
| "forward_llm_provider_auth_headers", False | ||
| ) | ||
| if not forward_llm_auth: | ||
| forward_llm_auth = getattr(litellm, "forward_llm_provider_auth_headers", False) | ||
| # Determine which header was used for authentication | ||
| # This enables forwarding provider keys (e.g., x-api-key) when they weren't used for LiteLLM auth | ||
| 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" | ||
|
Comment on lines
+898
to
+907
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
In this specific case the downstream
Comment on lines
+898
to
+907
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The
# 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 |
||
|
|
||
| _headers: Dict[str, str] = clean_headers( | ||
| request.headers, | ||
|
|
@@ -877,10 +914,18 @@ async def add_litellm_data_to_request( # noqa: PLR0915 | |
| else None | ||
| ), | ||
| forward_llm_provider_auth_headers=forward_llm_auth, | ||
| authenticated_with_header=authenticated_with_header, | ||
| ) | ||
| verbose_proxy_logger.debug(f"Request Headers: {_headers}") | ||
| verbose_proxy_logger.debug(f"Raw Headers: {_raw_headers}") | ||
|
|
||
|
|
||
| if forward_llm_auth and "x-api-key" in _headers: | ||
| data["api_key"] = _headers["x-api-key"] | ||
|
Comment on lines
+923
to
+924
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Case-sensitive header lookup will silently skip BYOK
If a client (or Claude Code in an HTTP/1.1 context) sends the header as 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
+923
to
+924
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Client API key captured in
Before this PR, If you move the # 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)"
) |
||
| verbose_proxy_logger.debug( | ||
| "Setting client-provided x-api-key as api_key parameter (will override deployment key)" | ||
| ) | ||
|
|
||
| ########################################################## | ||
| # Init - Proxy Server Request | ||
| # we do this as soon as entering so we track the original request | ||
|
|
||
There was a problem hiding this comment.
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
NonecaseThe
is None orshort-circuit means the provider key header gets forwarded even when the auth source is unknown. The testtest_clean_headers_x_api_key_without_authenticated_header_paramexplicitly states the key should be excluded whenauthenticated_with_headerisNone, but it only passes because the forwarding flag defaults toFalsein that test.Changing
is None ortois 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.