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:
Yucheng He 2026-09-04 16:38:22 -07:00
parent 9b6742b6d8
commit 74e3152f2e
2 changed files with 66 additions and 13 deletions

View file

@ -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:
"""

View file

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