feat: Add standard customer ID headers for tracking spend

Add support for x-litellm-customer-id and x-litellm-end-user-id headers
that work out-of-the-box without any configuration. This enables tools
like Claude Code to pass customer IDs via ANTHROPIC_CUSTOM_HEADERS.

Changes:
- Add STANDARD_CUSTOMER_ID_HEADERS constant with supported header names
- Update get_end_user_id_from_request_body to check standard headers first
- Add comprehensive tests for the new header-based customer ID feature
- Update documentation with examples for header-based tracking and Claude Code setup

The standard headers take precedence over configured user_header_name/user_header_mappings
and request body fields (user, litellm_metadata.user, metadata.user_id).

Co-authored-by: ishaan <ishaan@berri.ai>
This commit is contained in:
Cursor Agent 2026-01-16 01:35:37 +00:00
parent e0a29cac99
commit 835e0c0e18
3 changed files with 150 additions and 10 deletions

View file

@ -22,19 +22,22 @@ Customer Usage enables you to track spend and usage for individual customers (en
## How to Track Spend
Track customer spend by including a `user` field in your API requests. The customer ID will be automatically tracked and associated with all spend from that request.
Track customer spend by including a `user` field in your API requests or by passing a customer ID header. The customer ID will be automatically tracked and associated with all spend from that request.
### Example using cURL
<Tabs>
<TabItem value="body" label="Request Body" default>
### Using Request Body
Make a `/chat/completions` call with the `user` field containing your customer ID:
```bash showLineNumbers title="Track spend with customer ID"
```bash showLineNumbers title="Track spend with customer ID in body"
curl -X POST 'http://0.0.0.0:4000/chat/completions' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer sk-1234' \ # 👈 YOUR PROXY KEY
--header 'Authorization: Bearer sk-1234' \
--data '{
"model": "gpt-3.5-turbo",
"user": "customer-123", # 👈 CUSTOMER ID
"user": "customer-123",
"messages": [
{
"role": "user",
@ -44,7 +47,49 @@ curl -X POST 'http://0.0.0.0:4000/chat/completions' \
}'
```
The customer ID (`customer-123`) will be automatically upserted into the database with the new spend. If the customer ID already exists, spend will be incremented.
</TabItem>
<TabItem value="header" label="Request Header">
### Using Request Headers
You can also pass the customer ID via HTTP headers. This is useful for tools that support custom headers but don't allow modifying the request body (like Claude Code with `ANTHROPIC_CUSTOM_HEADERS`).
LiteLLM automatically recognizes these standard headers (no configuration required):
- `x-litellm-customer-id`
- `x-litellm-end-user-id`
```bash showLineNumbers title="Track spend with customer ID in header"
curl -X POST 'http://0.0.0.0:4000/chat/completions' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer sk-1234' \
--header 'x-litellm-customer-id: customer-123' \
--data '{
"model": "gpt-3.5-turbo",
"messages": [
{
"role": "user",
"content": "What is the capital of France?"
}
]
}'
```
#### Using with Claude Code
Claude Code supports custom headers via the `ANTHROPIC_CUSTOM_HEADERS` environment variable. Set it to pass your customer ID:
```bash title="Configure Claude Code with customer tracking"
export ANTHROPIC_BASE_URL="http://0.0.0.0:4000/v1/messages"
export ANTHROPIC_API_KEY="sk-1234"
export ANTHROPIC_CUSTOM_HEADERS="x-litellm-customer-id: my-customer-id"
```
Now all requests from Claude Code will automatically track spend under `my-customer-id`.
</TabItem>
</Tabs>
The customer ID will be automatically upserted into the database with the new spend. If the customer ID already exists, spend will be incremented.
### Example using OpenWebUI

View file

@ -562,6 +562,14 @@ def get_customer_user_header_from_mapping(user_id_mapping) -> Optional[str]:
return None
# Standard headers that are always checked for customer/end-user ID (no configuration required)
# These headers work out-of-the-box for tools like Claude Code that support custom headers
STANDARD_CUSTOMER_ID_HEADERS = [
"x-litellm-customer-id",
"x-litellm-end-user-id",
]
def get_end_user_id_from_request_body(
request_body: dict, request_headers: Optional[dict] = None
) -> Optional[str]:
@ -569,7 +577,19 @@ def get_end_user_id_from_request_body(
# and to ensure it's fetched at runtime.
from litellm.proxy.proxy_server import general_settings
# Check 1 : Follow the user header mappings feature, if not found, then check for deprecated user_header_name (only if request_headers is provided)
# Check 1: Standard customer ID headers (always checked, no configuration required)
# This enables tools like Claude Code to pass customer IDs via ANTHROPIC_CUSTOM_HEADERS
if request_headers is not None:
for standard_header in STANDARD_CUSTOMER_ID_HEADERS:
for header_name, header_value in request_headers.items():
if header_name.lower() == standard_header.lower():
user_id_str = (
str(header_value) if header_value is not None else ""
)
if user_id_str.strip():
return user_id_str
# Check 2: Follow the user header mappings feature, if not found, then check for deprecated user_header_name (only if request_headers is provided)
# User query: "system not respecting user_header_name property"
# This implies the key in general_settings is 'user_header_name'.
if request_headers is not None:
@ -602,19 +622,19 @@ def get_end_user_id_from_request_body(
if user_id_str.strip():
return user_id_str
# Check 2: 'user' field in request_body (commonly OpenAI)
# Check 3: 'user' field in request_body (commonly OpenAI)
if "user" in request_body and request_body["user"] is not None:
user_from_body_user_field = request_body["user"]
return str(user_from_body_user_field)
# Check 3: 'litellm_metadata.user' in request_body (commonly Anthropic)
# Check 4: 'litellm_metadata.user' in request_body (commonly Anthropic)
litellm_metadata = request_body.get("litellm_metadata")
if isinstance(litellm_metadata, dict):
user_from_litellm_metadata = litellm_metadata.get("user")
if user_from_litellm_metadata is not None:
return str(user_from_litellm_metadata)
# Check 4: 'metadata.user_id' in request_body (another common pattern)
# Check 5: 'metadata.user_id' in request_body (another common pattern)
metadata_dict = request_body.get("metadata")
if isinstance(metadata_dict, dict):
user_id_from_metadata_field = metadata_dict.get("user_id")

View file

@ -260,6 +260,81 @@ def test_get_model_from_request(request_data, expected_model):
assert model == ["gpt-3.5-turbo", "gpt-4o-mini-general-deployment"]
@pytest.mark.parametrize(
"headers, request_body, expected_user_id",
[
# Test 1: x-litellm-customer-id header takes precedence over body
(
{"x-litellm-customer-id": "customer-from-header"},
{"user": "body-user-456"},
"customer-from-header"
),
# Test 2: x-litellm-end-user-id header works as alternative
(
{"x-litellm-end-user-id": "end-user-from-header"},
{"user": "body-user-456"},
"end-user-from-header"
),
# Test 3: x-litellm-customer-id takes precedence over x-litellm-end-user-id
(
{
"x-litellm-customer-id": "customer-id",
"x-litellm-end-user-id": "end-user-id"
},
{"user": "body-user-456"},
"customer-id"
),
# Test 4: Standard headers work with case-insensitive matching
(
{"X-LiteLLM-Customer-ID": "customer-uppercase"},
{"user": "body-user-456"},
"customer-uppercase"
),
# Test 5: Empty standard header falls back to body
(
{"x-litellm-customer-id": ""},
{"user": "body-user-456"},
"body-user-456"
),
# Test 6: Standard header works without any body user
(
{"x-litellm-customer-id": "header-only-customer"},
{"model": "gpt-4"},
"header-only-customer"
),
# Test 7: No standard header present, falls back to body
(
{"x-other-header": "some-value"},
{"user": "body-user-456"},
"body-user-456"
),
]
)
def test_get_end_user_id_from_standard_customer_headers(
headers, request_body, expected_user_id
):
"""
Test that standard customer ID headers (x-litellm-customer-id, x-litellm-end-user-id)
work without any configuration. This enables tools like Claude Code to pass customer
IDs via ANTHROPIC_CUSTOM_HEADERS.
"""
from litellm.proxy.auth.auth_utils import get_end_user_id_from_request_body
from unittest.mock import patch
# Mock general_settings with no user_header_name configured
with patch('litellm.proxy.proxy_server.general_settings', {}):
end_user_id = get_end_user_id_from_request_body(request_body, headers)
assert end_user_id == expected_user_id
def test_standard_customer_headers_constant():
"""Test that the standard customer ID headers constant is defined correctly."""
from litellm.proxy.auth.auth_utils import STANDARD_CUSTOMER_ID_HEADERS
assert "x-litellm-customer-id" in STANDARD_CUSTOMER_ID_HEADERS
assert "x-litellm-end-user-id" in STANDARD_CUSTOMER_ID_HEADERS
def test_get_customer_user_header_from_mapping_returns_customer_header():
from litellm.proxy.auth.auth_utils import get_customer_user_header_from_mapping