From 4a78bfcd289da4f0506fe6807a8f11ef033e278b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 12 May 2026 13:51:32 -0700 Subject: [PATCH] fix(proxy): always merge caller-supplied tags into request metadata Caller-supplied tags (`x-litellm-tags` header, body `tags`, `metadata.tags`) were silently dropped unless the key/team had `metadata.allow_client_tags: true` set. Restore the documented behavior: tags from the request always flow into `metadata.tags` and union with any admin-configured static tags from key/team/project metadata. Removes the `allow_client_tags` opt-in flag from the pre-call pipeline. The flag was only ever read here; it has no schema or endpoint footprint, so leftover values in existing key metadata are inert. Test cleanup mirrors the simplification: drop the three tests that verified the strip-when-not-opted-in path, drop the `allow_client_tags` fixture lines from the merge/union tests. --- litellm/proxy/litellm_pre_call_utils.py | 54 +---- tests/proxy_unit_tests/test_proxy_utils.py | 9 +- .../proxy/test_litellm_pre_call_utils.py | 220 +----------------- 3 files changed, 18 insertions(+), 265 deletions(-) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index b97e7c5e69..929ef04063 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -1437,46 +1437,8 @@ async def add_litellm_data_to_request( # noqa: PLR0915 if not _key_or_team_allows_client_pricing_override(user_api_key_dict): _strip_client_pricing_overrides(data) - # Strip caller-supplied routing/budget tags unless the admin has opted - # this key or team in via metadata.allow_client_tags=True. Tags drive - # tag-based routing and tag budget attribution — accepting them from - # untrusted callers lets an attacker reach restricted deployments or - # misattribute spend to a victim team's tag. - _admin_allow_client_tags = False - for _admin_meta in ( - user_api_key_dict.metadata, - user_api_key_dict.team_metadata, - ): - if ( - isinstance(_admin_meta, dict) - and _admin_meta.get("allow_client_tags") is True - ): - _admin_allow_client_tags = True - break - if not _admin_allow_client_tags: - _stripped_from: List[str] = [] - for _meta_key in ("metadata", "litellm_metadata"): - _user_meta = data.get(_meta_key) - if isinstance(_user_meta, dict) and "tags" in _user_meta: - _user_meta.pop("tags", None) - _stripped_from.append(_meta_key) - # Also strip the root-level `tags` field. get_tags_from_request_body - # reads request_body["tags"] directly and feeds it to the policy - # engine, so leaving it in place here would let the strip-in-metadata - # above be trivially bypassed by moving the tags to the body root. - if "tags" in data: - data.pop("tags", None) - _stripped_from.append("tags (root)") - if _stripped_from: - verbose_proxy_logger.warning( - "Stripped caller-supplied tags from %s: this key/team does " - "not have `allow_client_tags: true` in its metadata. Set it " - "to opt into client-supplied routing/budget tags.", - ", ".join(_stripped_from), - ) - # Fill in the proxy_server_request body snapshot now that metadata has - # been parsed and stripped. Consumers (standard_logging_payload, lago, + # been parsed. Consumers (standard_logging_payload, lago, # spend_tracking_utils, streaming_iterator) read `body` to audit the # request; taking the snapshot here ensures they see cleaned metadata. # @@ -1665,27 +1627,19 @@ async def add_litellm_data_to_request( # noqa: PLR0915 user_agent = request.headers["user-agent"] data[_metadata_variable_name]["user_agent"] = user_agent - # Check if using tag based routing. The helper reads caller-controlled - # sources (x-litellm-tags header, data["tags"] root-level), so its result - # is still gated by the same allow_client_tags flag that gated the - # body-metadata tag strip above. Otherwise the strip is trivially - # bypassed by sending tags via header or at the root of the body. + # Merge caller-supplied tags (x-litellm-tags header, data["tags"] root-level) + # into request metadata for tag-based routing and spend attribution. tags = LiteLLMProxyRequestSetup.add_request_tag_to_metadata( llm_router=llm_router, headers=_headers, data=data, ) - if tags is not None and _admin_allow_client_tags: + if tags is not None: data[_metadata_variable_name]["tags"] = LiteLLMProxyRequestSetup._merge_tags( request_tags=data[_metadata_variable_name].get("tags"), tags_to_add=tags, ) - elif tags is not None: - verbose_proxy_logger.warning( - "Ignored caller-supplied tags from header/root body: this " - "key/team does not have `allow_client_tags: true` in its metadata." - ) # Team Callbacks controls callback_settings_obj = _get_dynamic_logging_metadata( diff --git a/tests/proxy_unit_tests/test_proxy_utils.py b/tests/proxy_unit_tests/test_proxy_utils.py index 68aff36038..ff695d4a57 100644 --- a/tests/proxy_unit_tests/test_proxy_utils.py +++ b/tests/proxy_unit_tests/test_proxy_utils.py @@ -167,13 +167,9 @@ async def test_add_key_or_team_level_spend_logs_metadata_to_request( print(f"team_sl_metadata: {team_sl_metadata}") mock_request.url.path = "/chat/completions" - # Opt the key into client-supplied tags so request_tags are preserved - # and merged with admin-configured key/team tags. Without this flag, - # request_tags would be stripped by add_litellm_data_to_request. key_metadata = { "tags": key_tags, "spend_logs_metadata": key_sl_metadata, - "allow_client_tags": True, } team_metadata = { "tags": team_tags, @@ -909,13 +905,12 @@ async def test_add_litellm_data_to_request_duplicate_tags( mock_request.headers = {} mock_request.state = State() - # Setup key with tags in metadata. Opt into client-supplied tags so the - # request_tags are preserved for the merge under test. + # Setup key with tags in metadata. user_api_key_dict = UserAPIKeyAuth( api_key="test_api_key", user_id="test_user_id", org_id="test_org_id", - metadata={"tags": key_tags, "allow_client_tags": True}, + metadata={"tags": key_tags}, ) # Setup request data with tags 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 b803dfb709..5412bf3a56 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -874,101 +874,8 @@ async def test_add_litellm_data_to_request_allows_redaction_opt_out_with_admin_o @pytest.mark.asyncio -async def test_add_litellm_data_to_request_ignores_x_litellm_tags_header_without_permission(): - """Regression: the `x-litellm-tags` header bypassed the body-metadata - tag strip. Header tags must also be gated by `allow_client_tags`.""" - from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request - - request_mock = MagicMock(spec=Request) - request_mock.url.path = "/v1/chat/completions" - request_mock.url = MagicMock() - request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions" - request_mock.method = "POST" - request_mock.query_params = {} - request_mock.headers = { - "Content-Type": "application/json", - "x-litellm-tags": "restricted-tier,victim-team", - } - request_mock.client = MagicMock() - request_mock.client.host = "127.0.0.1" - - data = {"model": "gpt-3.5-turbo"} - - user_api_key_dict = UserAPIKeyAuth( - api_key="hashed-key", - metadata={}, - team_metadata={}, - spend=0.0, - max_budget=100.0, - model_max_budget={}, - team_spend=0.0, - team_max_budget=200.0, - ) - - updated = await add_litellm_data_to_request( - data=data, - request=request_mock, - user_api_key_dict=user_api_key_dict, - proxy_config=MagicMock(), - general_settings={}, - version="test-version", - ) - - assert "tags" not in (updated.get("metadata") or {}) - - -@pytest.mark.asyncio -async def test_add_litellm_data_to_request_ignores_root_level_tags_without_permission(): - """Regression: root-level `data["tags"]` bypassed the body-metadata - tag strip. Root-level tags must also be gated by `allow_client_tags`.""" - from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request - - request_mock = MagicMock(spec=Request) - request_mock.url.path = "/v1/chat/completions" - request_mock.url = MagicMock() - request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions" - request_mock.method = "POST" - request_mock.query_params = {} - request_mock.headers = {"Content-Type": "application/json"} - request_mock.client = MagicMock() - request_mock.client.host = "127.0.0.1" - - data = { - "model": "gpt-3.5-turbo", - "tags": ["restricted-tier", "victim-team"], - } - - user_api_key_dict = UserAPIKeyAuth( - api_key="hashed-key", - metadata={}, - team_metadata={}, - spend=0.0, - max_budget=100.0, - model_max_budget={}, - team_spend=0.0, - team_max_budget=200.0, - ) - - updated = await add_litellm_data_to_request( - data=data, - request=request_mock, - user_api_key_dict=user_api_key_dict, - proxy_config=MagicMock(), - general_settings={}, - version="test-version", - ) - - assert "tags" not in (updated.get("metadata") or {}) - # Also ensure the root-level tags are removed. get_tags_from_request_body - # reads request_body["tags"] directly, so leaving it in place would let - # the policy engine see caller-supplied tags even after the metadata - # strip. - assert "tags" not in updated - - -@pytest.mark.asyncio -async def test_add_litellm_data_to_request_honors_header_tags_when_opted_in(): - """When allow_client_tags=True, header-supplied tags flow through.""" +async def test_add_litellm_data_to_request_honors_header_tags(): + """Header-supplied tags flow through to request metadata.""" from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request request_mock = MagicMock(spec=Request) @@ -988,7 +895,7 @@ async def test_add_litellm_data_to_request_honors_header_tags_when_opted_in(): user_api_key_dict = UserAPIKeyAuth( api_key="hashed-key", - metadata={"allow_client_tags": True}, + metadata={}, team_metadata={}, spend=0.0, max_budget=100.0, @@ -1010,11 +917,8 @@ async def test_add_litellm_data_to_request_honors_header_tags_when_opted_in(): @pytest.mark.asyncio -async def test_add_litellm_data_to_request_strips_user_tags_without_permission(): - """Caller-supplied metadata.tags must be stripped when the key/team - metadata does not opt in via allow_client_tags=True. Otherwise an - attacker can reach restricted tag-routed deployments or attribute - spend to a victim team's tag.""" +async def test_add_litellm_data_to_request_preserves_caller_metadata_tags(): + """Caller-supplied metadata.tags are preserved and reach the router.""" from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request request_mock = MagicMock(spec=Request) @@ -1029,8 +933,7 @@ async def test_add_litellm_data_to_request_strips_user_tags_without_permission() data = { "model": "gpt-3.5-turbo", - "metadata": {"tags": ["restricted-tier", "victim-team"]}, - "litellm_metadata": {"tags": ["also-stripped"]}, + "metadata": {"tags": ["caller-tag"]}, } user_api_key_dict = UserAPIKeyAuth( @@ -1053,101 +956,13 @@ async def test_add_litellm_data_to_request_strips_user_tags_without_permission() version="test-version", ) - assert "tags" not in (updated.get("metadata") or {}) - assert "tags" not in (updated.get("litellm_metadata") or {}) - - -@pytest.mark.asyncio -async def test_add_litellm_data_to_request_preserves_user_tags_when_key_opts_in(): - """When key.metadata.allow_client_tags=True, caller-supplied tags are - preserved and reach the router.""" - from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request - - request_mock = MagicMock(spec=Request) - request_mock.url.path = "/v1/chat/completions" - request_mock.url = MagicMock() - request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions" - request_mock.method = "POST" - request_mock.query_params = {} - request_mock.headers = {"Content-Type": "application/json"} - request_mock.client = MagicMock() - request_mock.client.host = "127.0.0.1" - - data = { - "model": "gpt-3.5-turbo", - "metadata": {"tags": ["opted-in-tag"]}, - } - - user_api_key_dict = UserAPIKeyAuth( - api_key="hashed-key", - metadata={"allow_client_tags": True}, - team_metadata={}, - spend=0.0, - max_budget=100.0, - model_max_budget={}, - team_spend=0.0, - team_max_budget=200.0, - ) - - updated = await add_litellm_data_to_request( - data=data, - request=request_mock, - user_api_key_dict=user_api_key_dict, - proxy_config=MagicMock(), - general_settings={}, - version="test-version", - ) - - assert updated["metadata"].get("tags") == ["opted-in-tag"] - - -@pytest.mark.asyncio -async def test_add_litellm_data_to_request_preserves_user_tags_when_team_opts_in(): - """Team-level allow_client_tags is also honored (not just key-level).""" - from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request - - request_mock = MagicMock(spec=Request) - request_mock.url.path = "/v1/chat/completions" - request_mock.url = MagicMock() - request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions" - request_mock.method = "POST" - request_mock.query_params = {} - request_mock.headers = {"Content-Type": "application/json"} - request_mock.client = MagicMock() - request_mock.client.host = "127.0.0.1" - - data = { - "model": "gpt-3.5-turbo", - "metadata": {"tags": ["team-allowed"]}, - } - - user_api_key_dict = UserAPIKeyAuth( - api_key="hashed-key", - metadata={}, - team_metadata={"allow_client_tags": True}, - spend=0.0, - max_budget=100.0, - model_max_budget={}, - team_spend=0.0, - team_max_budget=200.0, - ) - - updated = await add_litellm_data_to_request( - data=data, - request=request_mock, - user_api_key_dict=user_api_key_dict, - proxy_config=MagicMock(), - general_settings={}, - version="test-version", - ) - - assert updated["metadata"].get("tags") == ["team-allowed"] + assert updated["metadata"].get("tags") == ["caller-tag"] @pytest.mark.asyncio async def test_add_litellm_data_to_request_unions_caller_header_tags_with_static_key_tags(): """Caller-supplied `x-litellm-tags` must union with static key-level - tags, not overwrite them, when `allow_client_tags=True`.""" + tags, not overwrite them.""" from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request request_mock = MagicMock(spec=Request) @@ -1167,10 +982,7 @@ async def test_add_litellm_data_to_request_unions_caller_header_tags_with_static user_api_key_dict = UserAPIKeyAuth( api_key="hashed-key", - metadata={ - "allow_client_tags": True, - "tags": ["team:platform", "env:prod"], - }, + metadata={"tags": ["team:platform", "env:prod"]}, team_metadata={}, spend=0.0, max_budget=100.0, @@ -1217,10 +1029,7 @@ async def test_add_litellm_data_to_request_unions_caller_header_tags_with_static user_api_key_dict = UserAPIKeyAuth( api_key="hashed-key", metadata={}, - team_metadata={ - "allow_client_tags": True, - "tags": ["team:eng", "owner:platform"], - }, + team_metadata={"tags": ["team:eng", "owner:platform"]}, spend=0.0, max_budget=100.0, model_max_budget={}, @@ -1266,10 +1075,7 @@ async def test_add_litellm_data_to_request_unions_dedups_overlapping_caller_and_ user_api_key_dict = UserAPIKeyAuth( api_key="hashed-key", - metadata={ - "allow_client_tags": True, - "tags": ["env:prod", "team:platform"], - }, + metadata={"tags": ["env:prod", "team:platform"]}, team_metadata={}, spend=0.0, max_budget=100.0, @@ -1364,11 +1170,9 @@ async def test_add_litellm_data_to_request_audio_transcription_multipart(): "file": b"Fake audio bytes", } - # Opt the key in to client-supplied tags so the parsed tags from the - # JSON-string multipart body aren't stripped by the admin-injection strip. user_api_key_dict = UserAPIKeyAuth( api_key="hashed-key", - metadata={"allow_client_tags": True}, + metadata={}, team_metadata={}, spend=0.0, max_budget=100.0,