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.
This commit is contained in:
Yuneng Jiang
2026-05-12 13:51:32 -07:00
parent 63a2d1ddc9
commit 4a78bfcd28
3 changed files with 18 additions and 265 deletions
+4 -50
View File
@@ -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(
+2 -7
View File
@@ -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
@@ -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,