diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 995116e483c..a5b9c732e76 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -67,6 +67,7 @@ from litellm.proxy.common_utils.http_parsing_utils import ( populate_request_with_path_params, ) from litellm.proxy.common_utils.realtime_utils import _realtime_request_body +from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup from litellm.proxy.utils import ( PrismaClient, ProxyLogging, @@ -1939,13 +1940,10 @@ async def _run_centralized_common_checks( llm_router=llm_router, ) - # Merge x-litellm-tags (or strip body tags when the key/team has not - # opted in via allow_client_tags) into request_data BEFORE common_checks - # runs. _tag_max_budget_check inside common_checks only inspects - # request_data; without this pre-merge, header-supplied tags bypass - # tag-budget enforcement. - from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup - + # Merge x-litellm-tags into request_data BEFORE common_checks runs. + # _tag_max_budget_check inside common_checks only inspects request_data; + # without this pre-merge, header-supplied tags bypass tag-budget + # enforcement. LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth( request=request, request_data=request_data, diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 43418428f83..ad5c49e5771 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -1217,15 +1217,12 @@ class LiteLLMProxyRequestSetup: break if not _admin_allow_client_tags: - # Strip any caller-supplied tags so the budget gate doesn't act - # on tags this key/team isn't authorized to set. Matches the - # post-auth strip in add_litellm_data_to_request. - for _meta_key in ("metadata", "litellm_metadata"): - _user_meta = request_data.get(_meta_key) - if isinstance(_user_meta, dict) and "tags" in _user_meta: - _user_meta.pop("tags", None) - if "tags" in request_data: - request_data.pop("tags", None) + # Don't strip body-supplied tags here — pre-PR behavior was that + # _tag_max_budget_check (inside common_checks) saw and enforced + # per-tag budgets on body tags regardless of allow_client_tags. + # Stripping pre-auth would silently disable that enforcement. + # The post-auth strip in add_litellm_data_to_request still + # removes unauthorized tags before they leave the proxy. return headers = _safe_get_request_headers(request=request) 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 75dedb4b89b..b23ac182a12 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -4110,7 +4110,14 @@ class TestApplyClientTagPolicyPreAuth: # Existing tags first, dedupe header tags assert data["metadata"]["tags"] == ["env:prod", "team:platform", "tenant:acme"] - def test_strips_body_tags_when_not_opted_in(self): + def test_preserves_body_tags_when_not_opted_in(self): + # Pre-auth must NOT strip body-supplied tags for non-opted-in keys. + # _tag_max_budget_check (inside common_checks) enforces per-tag + # budgets on whatever tags it sees in request_data, and pre-PR + # behavior was that body tags hit that check regardless of + # allow_client_tags. The post-auth strip in add_litellm_data_to_request + # cleans them up before they leave the proxy — that's covered by a + # separate regression test. request_mock = _build_request_mock_with_headers( {"x-litellm-tags": "tenant:acme"} ) @@ -4132,9 +4139,9 @@ class TestApplyClientTagPolicyPreAuth: user_api_key_dict=user_api_key_dict, ) - assert "tags" not in data - assert "tags" not in data["metadata"] - assert "tags" not in data["litellm_metadata"] + assert data["tags"] == ["root-tag"] + assert data["metadata"]["tags"] == ["meta-tag"] + assert data["litellm_metadata"]["tags"] == ["litellm-meta-tag"] def test_does_not_merge_header_tags_when_not_opted_in(self): # Even with the header set, no opt-in means the header is ignored