mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-14 03:04:12 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user