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