From 74e3152f2ed6fc3880fb0b2c1be512a123da75ba Mon Sep 17 00:00:00 2001 From: Yucheng He Date: Fri, 4 Sep 2026 16:38:22 -0700 Subject: [PATCH] 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. --- litellm/proxy/litellm_pre_call_utils.py | 23 ++++---- .../proxy/test_litellm_pre_call_utils.py | 56 ++++++++++++++++++- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index c5243685622..2b0630e4602 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -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: """ diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 85fbde911e2..e7c35ab27ba 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -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