Skip to content

Commit 128d9a3

Browse files
authored
[Feature]: Add header support for spend_logs_metadata (#14186)
* fix: allow settings spend_logs_metadata * fix add_litellm_data_for_backend_llm_call * fix: add add_litellm_metadata_from_request_headers * fix add_litellm_metadata_from_request_headers * test_add_litellm_metadata_from_request_headers * add_litellm_metadata_from_request_headers * docs Tracking Spend with custom metadata * add_litellm_metadata_from_request_headers * add_litellm_metadata_from_request_headers
1 parent c821f1d commit 128d9a3

File tree

5 files changed

+266
-5
lines changed

5 files changed

+266
-5
lines changed

docs/my-website/docs/proxy/enterprise.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,33 @@ response = client.chat.completions.create(
439439

440440
print(response)
441441
```
442+
443+
**Using Headers:**
444+
445+
```python
446+
import openai
447+
client = openai.OpenAI(
448+
api_key="sk-1234",
449+
base_url="http://0.0.0.0:4000"
450+
)
451+
452+
# Pass spend logs metadata via headers
453+
response = client.chat.completions.create(
454+
model="gpt-3.5-turbo",
455+
messages = [
456+
{
457+
"role": "user",
458+
"content": "this is a test request, write a short poem"
459+
}
460+
],
461+
extra_headers={
462+
"x-litellm-spend-logs-metadata": '{"user_id": "12345", "project_id": "proj_abc", "request_type": "chat_completion"}'
463+
}
464+
)
465+
466+
print(response)
467+
```
468+
442469
</TabItem>
443470

444471

@@ -478,6 +505,43 @@ async function runOpenAI() {
478505
// Call the asynchronous function
479506
runOpenAI();
480507
```
508+
509+
**Using Headers:**
510+
511+
```js
512+
const openai = require('openai');
513+
514+
async function runOpenAI() {
515+
const client = new openai.OpenAI({
516+
apiKey: 'sk-1234',
517+
baseURL: 'http://0.0.0.0:4000'
518+
});
519+
520+
try {
521+
const response = await client.chat.completions.create({
522+
model: 'gpt-3.5-turbo',
523+
messages: [
524+
{
525+
role: 'user',
526+
content: "this is a test request, write a short poem"
527+
},
528+
]
529+
}, {
530+
headers: {
531+
'x-litellm-spend-logs-metadata': '{"user_id": "12345", "project_id": "proj_abc", "request_type": "chat_completion"}'
532+
}
533+
});
534+
console.log(response);
535+
} catch (error) {
536+
console.log("got this exception from server");
537+
console.error(error);
538+
}
539+
}
540+
541+
// Call the asynchronous function
542+
runOpenAI();
543+
```
544+
481545
</TabItem>
482546

483547
<TabItem value="Curl" label="Curl Request">
@@ -502,6 +566,29 @@ curl --location 'http://0.0.0.0:4000/chat/completions' \
502566
}
503567
}'
504568
```
569+
570+
</TabItem>
571+
572+
<TabItem value="headers" label="Using Headers">
573+
574+
Pass `x-litellm-spend-logs-metadata` as a request header with JSON string
575+
576+
```shell
577+
curl --location 'http://0.0.0.0:4000/chat/completions' \
578+
--header 'Content-Type: application/json' \
579+
--header 'Authorization: Bearer sk-1234' \
580+
--header 'x-litellm-spend-logs-metadata: {"user_id": "12345", "project_id": "proj_abc", "request_type": "chat_completion"}' \
581+
--data '{
582+
"model": "gpt-3.5-turbo",
583+
"messages": [
584+
{
585+
"role": "user",
586+
"content": "what llm are you"
587+
}
588+
]
589+
}'
590+
```
591+
505592
</TabItem>
506593
<TabItem value="langchain" label="Langchain">
507594

docs/my-website/docs/proxy/request_headers.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Special headers that are supported by LiteLLM.
1414

1515
`x-litellm-num-retries`: Optional[int]: The number of retries for the request.
1616

17+
`x-litellm-spend-logs-metadata`: Optional[str]: JSON string containing custom metadata to include in spend logs. Example: `{"user_id": "12345", "project_id": "proj_abc", "request_type": "chat_completion"}`. [Learn More](./logging#tracking-spend-with-custom-metadata)
18+
1719
## Anthropic Headers
1820

1921
`anthropic-version` Optional[str]: The version of the Anthropic API to use.

litellm/proxy/_types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2908,6 +2908,12 @@ class LitellmDataForBackendLLMCall(TypedDict, total=False):
29082908
user: Optional[str]
29092909
num_retries: Optional[int]
29102910

2911+
class LitellmMetadataFromRequestHeaders(TypedDict, total=False):
2912+
"""
2913+
Headers a user can pass that will get added to litellm metadata for the request
2914+
"""
2915+
spend_logs_metadata: Optional[dict]
2916+
29112917

29122918
class JWTKeyItem(TypedDict, total=False):
29132919
kid: str

litellm/proxy/litellm_pre_call_utils.py

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,17 @@ def _get_num_retries_from_request(headers: dict) -> Optional[int]:
291291
if num_retries_header is not None:
292292
return int(num_retries_header)
293293
return None
294+
295+
@staticmethod
296+
def _get_spend_logs_metadata_from_request_headers(headers: dict) -> Optional[dict]:
297+
"""
298+
Get the `spend_logs_metadata` from the request headers.
299+
"""
300+
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
301+
spend_logs_metadata_header = headers.get("x-litellm-spend-logs-metadata", None)
302+
if spend_logs_metadata_header is not None:
303+
return safe_json_loads(spend_logs_metadata_header)
304+
return None
294305

295306
@staticmethod
296307
def _get_forwardable_headers(
@@ -459,6 +470,30 @@ def add_litellm_data_for_backend_llm_call(
459470
data["num_retries"] = num_retries
460471

461472
return data
473+
474+
@staticmethod
475+
def add_litellm_metadata_from_request_headers(
476+
headers: dict,
477+
data: dict,
478+
_metadata_variable_name: str,
479+
) -> dict:
480+
"""
481+
Add litellm metadata from request headers
482+
483+
Relevant issue: https://github.com/BerriAI/litellm/issues/14008
484+
"""
485+
from litellm.proxy._types import LitellmMetadataFromRequestHeaders
486+
metadata_from_headers = LitellmMetadataFromRequestHeaders()
487+
spend_logs_metadata = LiteLLMProxyRequestSetup._get_spend_logs_metadata_from_request_headers(headers)
488+
if spend_logs_metadata is not None:
489+
metadata_from_headers["spend_logs_metadata"] = spend_logs_metadata
490+
491+
#########################################################################################
492+
# Finally update the requests metadata with the `metadata_from_headers`
493+
#########################################################################################
494+
if isinstance(data[_metadata_variable_name], dict):
495+
data[_metadata_variable_name].update(metadata_from_headers)
496+
return data
462497

463498
@staticmethod
464499
def get_sanitized_user_information_from_key(
@@ -643,6 +678,10 @@ async def add_litellm_data_to_request( # noqa: PLR0915
643678
from litellm.types.proxy.litellm_pre_call_utils import SecretFields
644679

645680
safe_add_api_version_from_query_params(data, request)
681+
_metadata_variable_name = _get_metadata_variable_name(request)
682+
if data.get(_metadata_variable_name, None) is None:
683+
data[_metadata_variable_name] = {}
684+
646685

647686
_headers = clean_headers(
648687
request.headers,
@@ -661,6 +700,14 @@ async def add_litellm_data_to_request( # noqa: PLR0915
661700
)
662701
)
663702

703+
data.update(
704+
LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers(
705+
headers=_headers,
706+
data=data,
707+
_metadata_variable_name=_metadata_variable_name,
708+
)
709+
)
710+
664711
# check for forwardable headers
665712
data = LiteLLMProxyRequestSetup.add_headers_to_llm_call_by_model_group(
666713
data=data, headers=_headers, user_api_key_dict=user_api_key_dict
@@ -711,11 +758,6 @@ async def add_litellm_data_to_request( # noqa: PLR0915
711758

712759
verbose_proxy_logger.debug("receiving data: %s", data)
713760

714-
_metadata_variable_name = _get_metadata_variable_name(request)
715-
716-
if data.get(_metadata_variable_name, None) is None:
717-
data[_metadata_variable_name] = {}
718-
719761
# Parse metadata if it's a string (e.g., from multipart/form-data)
720762
if "metadata" in data and data["metadata"] is not None:
721763
if isinstance(data["metadata"], str):

tests/test_litellm/proxy/test_litellm_pre_call_utils.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99
from fastapi import Request
1010

11+
import litellm
1112
from litellm.proxy._types import TeamCallbackMetadata, UserAPIKeyAuth
1213
from litellm.proxy.litellm_pre_call_utils import (
1314
KeyAndTeamLoggingSettings,
@@ -935,3 +936,126 @@ def test_add_headers_to_llm_call_by_model_group_existing_headers_in_data():
935936
finally:
936937
# Restore original model_group_settings
937938
litellm.model_group_settings = original_model_group_settings
939+
940+
import json
941+
import time
942+
from typing import Optional
943+
from unittest.mock import AsyncMock
944+
945+
from fastapi.responses import Response
946+
947+
from litellm.integrations.custom_logger import CustomLogger
948+
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
949+
from litellm.proxy.utils import ProxyLogging
950+
from litellm.types.utils import StandardLoggingPayload
951+
952+
953+
class TestCustomLogger(CustomLogger):
954+
def __init__(self):
955+
self.standard_logging_object: Optional[StandardLoggingPayload] = None
956+
super().__init__()
957+
958+
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
959+
print(f"SUCCESS CALLBACK CALLED! kwargs keys: {list(kwargs.keys())}")
960+
self.standard_logging_object = kwargs.get("standard_logging_object")
961+
print(f"Captured standard_logging_object: {self.standard_logging_object}")
962+
963+
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
964+
print(f"FAILURE CALLBACK CALLED! kwargs keys: {list(kwargs.keys())}")
965+
966+
@pytest.mark.asyncio
967+
async def test_add_litellm_metadata_from_request_headers():
968+
"""
969+
Test that add_litellm_metadata_from_request_headers properly adds litellm metadata from request headers,
970+
makes an LLM request using base_process_llm_request, sleeps for 3 seconds, and checks standard_logging_payload has spend_logs_metadata from headers
971+
972+
Relevant issue: https://github.com/BerriAI/litellm/issues/14008
973+
"""
974+
# Set up test logger
975+
litellm._turn_on_debug()
976+
test_logger = TestCustomLogger()
977+
litellm.callbacks = [test_logger]
978+
979+
# Prepare test data (ensure no streaming, add mock_response and api_key to route to litellm.acompletion)
980+
headers = {"x-litellm-spend-logs-metadata": '{"user_id": "12345", "project_id": "proj_abc", "request_type": "chat_completion", "timestamp": "2025-09-02T10:30:00Z"}'}
981+
data = {"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}], "stream": False, "mock_response": "Hi", "api_key": "fake-key"}
982+
983+
# Create mock request with headers
984+
mock_request = MagicMock(spec=Request)
985+
mock_request.headers = headers
986+
mock_request.url.path = "/chat/completions"
987+
988+
# Create mock response
989+
mock_fastapi_response = MagicMock(spec=Response)
990+
991+
# Create mock user API key dict
992+
mock_user_api_key_dict = UserAPIKeyAuth(
993+
api_key="test-key",
994+
user_id="test-user",
995+
org_id="test-org"
996+
)
997+
998+
# Create mock proxy logging object
999+
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
1000+
1001+
# Create async functions for the hooks
1002+
async def mock_during_call_hook(*args, **kwargs):
1003+
return None
1004+
1005+
async def mock_pre_call_hook(*args, **kwargs):
1006+
return data
1007+
1008+
async def mock_post_call_success_hook(*args, **kwargs):
1009+
# Return the response unchanged
1010+
return kwargs.get('response', args[2] if len(args) > 2 else None)
1011+
1012+
mock_proxy_logging_obj.during_call_hook = mock_during_call_hook
1013+
mock_proxy_logging_obj.pre_call_hook = mock_pre_call_hook
1014+
mock_proxy_logging_obj.post_call_success_hook = mock_post_call_success_hook
1015+
1016+
# Create mock proxy config
1017+
mock_proxy_config = MagicMock()
1018+
1019+
# Create mock general settings
1020+
general_settings = {}
1021+
1022+
# Create mock select_data_generator with correct signature
1023+
def mock_select_data_generator(response=None, user_api_key_dict=None, request_data=None):
1024+
async def mock_generator():
1025+
yield "data: " + json.dumps({"choices": [{"delta": {"content": "Hello"}}]}) + "\n\n"
1026+
yield "data: [DONE]\n\n"
1027+
return mock_generator()
1028+
1029+
# Create the processor
1030+
processor = ProxyBaseLLMRequestProcessing(data=data)
1031+
1032+
# Call base_process_llm_request (it will use the mock_response="Hi" parameter)
1033+
result = await processor.base_process_llm_request(
1034+
request=mock_request,
1035+
fastapi_response=mock_fastapi_response,
1036+
user_api_key_dict=mock_user_api_key_dict,
1037+
route_type="acompletion",
1038+
proxy_logging_obj=mock_proxy_logging_obj,
1039+
general_settings=general_settings,
1040+
proxy_config=mock_proxy_config,
1041+
select_data_generator=mock_select_data_generator,
1042+
llm_router=None,
1043+
model="gpt-4",
1044+
is_streaming_request=False
1045+
)
1046+
1047+
# Sleep for 3 seconds to allow logging to complete
1048+
await asyncio.sleep(3)
1049+
1050+
# Check if standard_logging_object was set
1051+
assert test_logger.standard_logging_object is not None, "standard_logging_object should be populated after LLM request"
1052+
1053+
# Verify the logging object contains expected metadata
1054+
standard_logging_obj = test_logger.standard_logging_object
1055+
1056+
print(f"Standard logging object captured: {json.dumps(standard_logging_obj, indent=4, default=str)}")
1057+
1058+
SPEND_LOGS_METADATA = standard_logging_obj["metadata"]["spend_logs_metadata"]
1059+
assert SPEND_LOGS_METADATA == dict(json.loads(headers["x-litellm-spend-logs-metadata"])), "spend_logs_metadata should be the same as the headers"
1060+
1061+

0 commit comments

Comments
 (0)