diff --git a/docs/my-website/docs/proxy/customer_usage.md b/docs/my-website/docs/proxy/customer_usage.md index 8e366586b15..5a6c06fdc81 100644 --- a/docs/my-website/docs/proxy/customer_usage.md +++ b/docs/my-website/docs/proxy/customer_usage.md @@ -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 + + + +### 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. + + + +### 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`. + + + + +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 diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 797540deaa4..982099d94c1 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -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") diff --git a/tests/local_testing/test_auth_utils.py b/tests/local_testing/test_auth_utils.py index 72f799a6cf0..c067d35cefe 100644 --- a/tests/local_testing/test_auth_utils.py +++ b/tests/local_testing/test_auth_utils.py @@ -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