Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/my-website/docs/proxy/forward_client_headers.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ general_settings:
forward_llm_provider_auth_headers: true # Enable BYOK
```

For **Claude Code** with `/login` and your own Anthropic key, see [Claude Code BYOK](../tutorials/claude_code_byok.md). Use `ANTHROPIC_CUSTOM_HEADERS="x-litellm-api-key: sk-12345"` to pass your LiteLLM key while your Anthropic key (from `/login`) is forwarded as `x-api-key`.

Client request:
```bash
curl -X POST "http://localhost:4000/v1/messages" \
Expand Down
123 changes: 123 additions & 0 deletions docs/my-website/docs/tutorials/claude_code_byok.md
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/my-website/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ const sidebars = {
items: [
"tutorials/claude_responses_api",
"tutorials/claude_code_max_subscription",
"tutorials/claude_code_byok",
"tutorials/claude_code_customer_tracking",
"tutorials/claude_code_prompt_cache_routing",
"tutorials/claude_code_websearch",
Expand Down
47 changes: 46 additions & 1 deletion litellm/proxy/litellm_pre_call_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")
):
Comment on lines +291 to +294
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.

clean_headers[header] = value
Comment on lines +289 to +295
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!

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

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 (
Expand Down Expand Up @@ -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
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 +898 to +907
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


_headers: Dict[str, str] = clean_headers(
request.headers,
Expand All @@ -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
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 +923 to +924
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)"
    )

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
Expand Down
Loading
Loading