mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
fix(proxy): drop a caller-supplied spend logs metadata header on skipped emission paths
Past the opt-in gate the proxy owns x-litellm-spend-logs-metadata, so strip any copy the caller put in the request body before deciding whether to emit. The emission can still be skipped when the resolved value is empty, oversized or unserializable, and extra_headers outranks headers in every provider handler, so leaving the caller copy there let a caller forge the attribution the upstream records by making the resolved value too big to forward.
This commit is contained in:
parent
9b6742b6d8
commit
74e3152f2e
2 changed files with 66 additions and 13 deletions
|
|
@ -1243,6 +1243,18 @@ class LiteLLMProxyRequestSetup:
|
|||
if not general_settings or general_settings.get("forward_spend_logs_metadata_to_llm_api") is not True:
|
||||
return
|
||||
|
||||
# Past the opt-in gate the proxy owns this header, so drop any copy the caller put
|
||||
# in the request body first. `extra_headers` beats `headers` in every provider
|
||||
# handler, and the emission below can still be skipped (no values, oversized,
|
||||
# unserializable) - leaving the caller's copy on those paths would let a caller
|
||||
# forge the attribution the upstream records by making the resolved value too big.
|
||||
caller_extra_headers: Final = data.get("extra_headers")
|
||||
if isinstance(caller_extra_headers, dict):
|
||||
for key in [
|
||||
k for k in caller_extra_headers if isinstance(k, str) and k.lower() == SPEND_LOGS_METADATA_HEADER_NAME
|
||||
]:
|
||||
del caller_extra_headers[key]
|
||||
|
||||
metadata: Final = data.get(_metadata_variable_name)
|
||||
if not isinstance(metadata, dict):
|
||||
return
|
||||
|
|
@ -1277,17 +1289,6 @@ class LiteLLMProxyRequestSetup:
|
|||
emitted[SPEND_LOGS_METADATA_HEADER_NAME] = encoded
|
||||
data["headers"] = emitted # rebind-ok: emitting this header is what this helper is for
|
||||
|
||||
# `extra_headers` beats `headers` in every provider handler, so a caller that put
|
||||
# this header in the request body would otherwise overwrite the proxy's resolved
|
||||
# value and forge the attribution the upstream records. The proxy owns this header;
|
||||
# a caller contributes through the `x-litellm-spend-logs-metadata` request header,
|
||||
# which is merged above and loses to nothing.
|
||||
caller_extra_headers: Final = data.get("extra_headers")
|
||||
if isinstance(caller_extra_headers, dict):
|
||||
for key in caller_extra_headers:
|
||||
if isinstance(key, str) and key.lower() == SPEND_LOGS_METADATA_HEADER_NAME:
|
||||
caller_extra_headers[key] = encoded
|
||||
|
||||
@staticmethod
|
||||
def add_headers_to_llm_call_by_model_group(data: dict, headers: dict, user_api_key_dict: UserAPIKeyAuth) -> dict:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -7971,8 +7971,60 @@ async def test_forward_spend_logs_metadata_overrides_caller_supplied_extra_heade
|
|||
|
||||
resolved = json.loads(updated["headers"][SPEND_LOGS_METADATA_HEADER_NAME])
|
||||
assert resolved["user_id"] == "U0099887"
|
||||
forged = json.loads(updated["extra_headers"][SPEND_LOGS_METADATA_HEADER_NAME])
|
||||
assert forged == resolved, "the proxy's resolved value must win over the request body"
|
||||
assert SPEND_LOGS_METADATA_HEADER_NAME not in updated["extra_headers"], (
|
||||
"the caller's copy must be dropped so the proxy's resolved value is what ships"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"key_metadata",
|
||||
[
|
||||
{"spend_logs_metadata": {"blob": "x" * (MAX_SPEND_LOGS_METADATA_HEADER_BYTES + 1)}},
|
||||
{},
|
||||
],
|
||||
ids=["oversized", "no-values"],
|
||||
)
|
||||
async def test_forward_spend_logs_metadata_drops_caller_header_even_when_nothing_is_emitted(
|
||||
key_metadata: dict[str, object],
|
||||
):
|
||||
"""
|
||||
The emission is skipped when the resolved dict is oversized or empty. The caller's own
|
||||
copy has to go anyway, otherwise a caller forges upstream attribution by making the
|
||||
resolved value too big to forward.
|
||||
"""
|
||||
updated = await add_litellm_data_to_request(
|
||||
data={
|
||||
"model": "gpt-4o",
|
||||
"messages": [],
|
||||
"extra_headers": {SPEND_LOGS_METADATA_HEADER_NAME: json.dumps({"user_id": "forged"})},
|
||||
},
|
||||
request=_spend_logs_metadata_request(),
|
||||
user_api_key_dict=_proxy_chain_auth(metadata=key_metadata, team_metadata={}),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={"forward_spend_logs_metadata_to_llm_api": True},
|
||||
)
|
||||
|
||||
assert SPEND_LOGS_METADATA_HEADER_NAME not in updated["extra_headers"]
|
||||
assert SPEND_LOGS_METADATA_HEADER_NAME not in (updated.get("headers") or {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_spend_logs_metadata_leaves_caller_header_alone_when_flag_is_off():
|
||||
"""Without the opt-in the proxy claims no ownership of the header, so nothing is touched."""
|
||||
updated = await add_litellm_data_to_request(
|
||||
data={
|
||||
"model": "gpt-4o",
|
||||
"messages": [],
|
||||
"extra_headers": {SPEND_LOGS_METADATA_HEADER_NAME: json.dumps({"user_id": "caller"})},
|
||||
},
|
||||
request=_spend_logs_metadata_request(),
|
||||
user_api_key_dict=_proxy_chain_auth(),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
)
|
||||
|
||||
assert json.loads(updated["extra_headers"][SPEND_LOGS_METADATA_HEADER_NAME]) == {"user_id": "caller"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue