From 36caeb013b7953f813fe097db30b09624973b35a Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 9 May 2026 18:08:36 -0700 Subject: [PATCH 1/8] fix: enforce tag budgets on x-litellm-tags header requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The x-litellm-tags header was merged into request metadata only after the auth chain completed, so _tag_max_budget_check (which reads tags from the request body) silently failed open for header-tagged requests — spend accumulated past max_budget without any 400 budget_exceeded response. Move the client-tag policy (strip-or-merge gated on allow_client_tags) to run before common_checks so header tags are visible to budget enforcement. The post-auth strip+merge in add_litellm_data_to_request stays as defense-in-depth; the new pre-auth helper is idempotent with it. Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/proxy/auth/user_api_key_auth.py | 13 + litellm/proxy/litellm_pre_call_utils.py | 83 +++++++ .../proxy/test_litellm_pre_call_utils.py | 232 ++++++++++++++++++ 3 files changed, 328 insertions(+) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 4778549bef..995116e483 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1939,6 +1939,19 @@ 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 + + LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth( + request=request, + request_data=request_data, + user_api_key_dict=user_api_key_auth_obj, + ) + _ = await common_checks( request=request, request_body=request_data, diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index a63613c583..43418428f8 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -24,6 +24,9 @@ from litellm.proxy._types import ( TeamCallbackMetadata, UserAPIKeyAuth, ) +from litellm.proxy.common_utils.callback_utils import ( + get_metadata_variable_name_from_kwargs, +) from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers # Cache special headers as a frozenset for O(1) lookup performance @@ -1177,6 +1180,86 @@ class LiteLLMProxyRequestSetup: return tags + @staticmethod + def apply_client_tag_policy_pre_auth( + request: Request, + request_data: dict, + user_api_key_dict: UserAPIKeyAuth, + ) -> None: + """ + Apply the client-tag policy BEFORE auth budget gates run, so + ``_tag_max_budget_check`` (which only inspects ``request_data``) + sees ``x-litellm-tags`` header tags. Without this, header-tagged + requests silently bypass per-tag budget enforcement. + + Mirrors the strip + merge that ``add_litellm_data_to_request`` + performs post-auth, gated on the same ``allow_client_tags`` flag. + + Why: ``add_litellm_data_to_request`` runs after the auth chain has + completed, so any header-supplied tags it merges in are invisible + to ``_tag_max_budget_check``. Running the merge here closes that + gap. The post-auth strip + merge remains as defense-in-depth. + + How to apply: invoked from the auth chain just before + ``common_checks``. Mutates ``request_data`` in place; idempotent + when followed by ``add_litellm_data_to_request``. + """ + _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: + # 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) + return + + headers = _safe_get_request_headers(request=request) + raw_header_tags = headers.get("x-litellm-tags") + if not raw_header_tags: + return + + if isinstance(raw_header_tags, str): + header_tags: List[str] = [ + t.strip() for t in raw_header_tags.split(",") if t.strip() + ] + elif isinstance(raw_header_tags, list): + header_tags = [t for t in raw_header_tags if isinstance(t, str) and t] + else: + return + + if not header_tags: + return + + # Match the metadata key that get_tags_from_request_body will read + # from (litellm_metadata vs metadata) so the merged tags are visible + # to _tag_max_budget_check. + _metadata_variable_name = get_metadata_variable_name_from_kwargs(request_data) + metadata = request_data.get(_metadata_variable_name) + if not isinstance(metadata, dict): + metadata = {} + request_data[_metadata_variable_name] = metadata + + existing_tags = metadata.get("tags") + metadata["tags"] = LiteLLMProxyRequestSetup._merge_tags( + request_tags=existing_tags if isinstance(existing_tags, list) else None, + tags_to_add=header_tags, + ) + async def add_litellm_data_to_request( # noqa: PLR0915 data: 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 92611431a1..75dedb4b89 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -4043,3 +4043,235 @@ def test_get_guardrail_from_metadata_reads_litellm_metadata_when_no_metadata(): assert result == [ "my-guardrail" ], f"Expected guardrails from litellm_metadata fallback, got: {result}" + + +def _build_request_mock_with_headers(headers: dict) -> Request: + request_mock = MagicMock(spec=Request) + request_mock.url = MagicMock() + request_mock.url.path = "/v1/chat/completions" + request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions" + request_mock.method = "POST" + request_mock.query_params = {} + request_mock.headers = headers + request_mock.client = MagicMock() + request_mock.client.host = "127.0.0.1" + request_mock.state = MagicMock() + request_mock.state._cached_headers = None + return request_mock + + +class TestApplyClientTagPolicyPreAuth: + """Tests for ``LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth``. + + Regression coverage for the bug where ``x-litellm-tags`` header was + invisible to ``_tag_max_budget_check`` because the merge happened + post-auth in ``add_litellm_data_to_request``. + """ + + def test_merges_header_tags_into_metadata_when_opted_in(self): + request_mock = _build_request_mock_with_headers( + {"x-litellm-tags": "tenant:acme,env:prod"} + ) + data = {"model": "gpt-3.5-turbo"} + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-key", + metadata={"allow_client_tags": True}, + team_metadata={}, + ) + + LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth( + request=request_mock, + request_data=data, + user_api_key_dict=user_api_key_dict, + ) + + assert data["metadata"]["tags"] == ["tenant:acme", "env:prod"] + + def test_unions_header_tags_with_existing_metadata_tags(self): + request_mock = _build_request_mock_with_headers( + {"x-litellm-tags": "tenant:acme,env:prod"} + ) + data = { + "model": "gpt-3.5-turbo", + "metadata": {"tags": ["env:prod", "team:platform"]}, + } + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-key", + metadata={"allow_client_tags": True}, + team_metadata={}, + ) + + LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth( + request=request_mock, + request_data=data, + user_api_key_dict=user_api_key_dict, + ) + + # 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): + request_mock = _build_request_mock_with_headers( + {"x-litellm-tags": "tenant:acme"} + ) + data = { + "model": "gpt-3.5-turbo", + "tags": ["root-tag"], + "metadata": {"tags": ["meta-tag"]}, + "litellm_metadata": {"tags": ["litellm-meta-tag"]}, + } + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-key", + metadata={}, + team_metadata={}, + ) + + LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth( + request=request_mock, + request_data=data, + 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"] + + 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 + # and metadata.tags is not created from it. + request_mock = _build_request_mock_with_headers( + {"x-litellm-tags": "tenant:acme"} + ) + data = {"model": "gpt-3.5-turbo"} + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-key", + metadata={}, + team_metadata={}, + ) + + LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth( + request=request_mock, + request_data=data, + user_api_key_dict=user_api_key_dict, + ) + + assert "tags" not in data.get("metadata", {}) + + def test_team_metadata_opt_in_is_honored(self): + request_mock = _build_request_mock_with_headers( + {"x-litellm-tags": "tenant:acme"} + ) + data = {"model": "gpt-3.5-turbo"} + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-key", + metadata={}, + team_metadata={"allow_client_tags": True}, + ) + + LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth( + request=request_mock, + request_data=data, + user_api_key_dict=user_api_key_dict, + ) + + assert data["metadata"]["tags"] == ["tenant:acme"] + + def test_uses_litellm_metadata_when_present(self): + request_mock = _build_request_mock_with_headers( + {"x-litellm-tags": "tenant:acme"} + ) + data = { + "model": "gpt-3.5-turbo", + "litellm_metadata": {"foo": "bar"}, + } + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-key", + metadata={"allow_client_tags": True}, + team_metadata={}, + ) + + LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth( + request=request_mock, + request_data=data, + user_api_key_dict=user_api_key_dict, + ) + + # get_metadata_variable_name_from_kwargs returns "litellm_metadata" + # when present, so header tags should land there to be visible to + # _tag_max_budget_check. + assert data["litellm_metadata"]["tags"] == ["tenant:acme"] + assert "tags" not in data.get("metadata", {}) + + def test_no_header_no_mutation_when_opted_in(self): + request_mock = _build_request_mock_with_headers({}) + data = {"model": "gpt-3.5-turbo"} + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-key", + metadata={"allow_client_tags": True}, + team_metadata={}, + ) + + LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth( + request=request_mock, + request_data=data, + user_api_key_dict=user_api_key_dict, + ) + + assert "metadata" not in data or "tags" not in data["metadata"] + + @pytest.mark.asyncio + async def test_header_tags_visible_to_tag_max_budget_check(self): + """End-to-end: helper + ``_tag_max_budget_check`` enforces budget on + header-supplied tags. Without the helper, this would silently pass.""" + from litellm.proxy._types import LiteLLM_BudgetTable, LiteLLM_TagTable + from litellm.proxy.auth.auth_checks import _tag_max_budget_check + from litellm.proxy.utils import ProxyLogging + + request_mock = _build_request_mock_with_headers( + {"x-litellm-tags": "tenant:acme"} + ) + data = {"model": "gpt-3.5-turbo"} + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-key", + metadata={"allow_client_tags": True}, + team_metadata={}, + ) + + LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth( + request=request_mock, + request_data=data, + user_api_key_dict=user_api_key_dict, + ) + + tag_object = LiteLLM_TagTable( + tag_name="tenant:acme", + spend=0.0, + litellm_budget_table=LiteLLM_BudgetTable(max_budget=0.10), + ) + + async def mock_get_current_spend(counter_key, fallback_spend): + if counter_key == "spend:tag:tenant:acme": + return 0.50 + return fallback_spend + + with ( + patch( + "litellm.proxy.proxy_server.get_current_spend", + mock_get_current_spend, + ), + patch( + "litellm.proxy.auth.auth_checks.get_tag_objects_batch", + new_callable=AsyncMock, + return_value={"tenant:acme": tag_object}, + ), + ): + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await _tag_max_budget_check( + request_body=data, + prisma_client=MagicMock(), + user_api_key_cache=MagicMock(), + proxy_logging_obj=ProxyLogging(user_api_key_cache=None), + valid_token=UserAPIKeyAuth(token="test-token"), + ) + assert exc_info.value.current_cost == 0.50 + assert exc_info.value.max_budget == 0.10 From 4fc0f1d8f6cc20a0dc379ed5cbec7e52debff78a Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 9 May 2026 18:29:23 -0700 Subject: [PATCH 2/8] fix: keep body tags visible to _tag_max_budget_check pre-auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stripping body-supplied tags in apply_client_tag_policy_pre_auth silently disabled per-tag budget enforcement for non-opted-in keys — pre-PR behavior was that those tags reached _tag_max_budget_check inside common_checks. The post-auth strip in add_litellm_data_to_request continues to remove unauthorized tags before they leave the proxy. Also moves the LiteLLMProxyRequestSetup import to module-level (no circular dep with auth/user_api_key_auth.py). Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/proxy/auth/user_api_key_auth.py | 12 +++++------- litellm/proxy/litellm_pre_call_utils.py | 15 ++++++--------- .../proxy/test_litellm_pre_call_utils.py | 15 +++++++++++---- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 995116e483..a5b9c732e7 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 43418428f8..ad5c49e577 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 75dedb4b89..b23ac182a1 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 From 7066587f5c7511b509fe20171188b43c9182f140 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 12 May 2026 16:07:59 -0700 Subject: [PATCH 3/8] fix(tests): swap dall-e to gpt-image-1 after openai deprecation DALL-E 2 and DALL-E 3 were removed from the OpenAI API on 2026-05-12, causing e2e image-generation tests to fail with "model does not exist". Swap all live-API DALL-E references in proxy-backed tests to gpt-image-1 and update the dall-e-2 alias in proxy_server_config.yaml to point at openai/gpt-image-1 (preserves any historical dall-e-2 callers). --- proxy_server_config.yaml | 4 ++-- tests/otel_tests/test_otel.py | 2 +- tests/test_health.py | 2 +- tests/test_keys.py | 4 ++-- tests/test_openai_endpoints.py | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/proxy_server_config.yaml b/proxy_server_config.yaml index 5d3d810926..d9838c852a 100644 --- a/proxy_server_config.yaml +++ b/proxy_server_config.yaml @@ -38,9 +38,9 @@ model_list: model_info: mode: embedding base_model: text-embedding-ada-002 - - model_name: dall-e-2 # some tests use dall-e-2 which is now deprecated, alias to dall-e-3 + - model_name: dall-e-2 # dall-e-2 and dall-e-3 were deprecated 2026-05-12; alias to gpt-image-1 litellm_params: - model: openai/dall-e-3 + model: openai/gpt-image-1 - model_name: openai-dall-e-3 litellm_params: model: dall-e-3 diff --git a/tests/otel_tests/test_otel.py b/tests/otel_tests/test_otel.py index a0f58dd5b8..9ded859eb9 100644 --- a/tests/otel_tests/test_otel.py +++ b/tests/otel_tests/test_otel.py @@ -13,7 +13,7 @@ async def generate_key( models=[ "gpt-4", "text-embedding-ada-002", - "dall-e-2", + "gpt-image-1", "fake-openai-endpoint", "mistral-embed", ], diff --git a/tests/test_health.py b/tests/test_health.py index 15dc2330ff..cc551fd938 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -31,7 +31,7 @@ async def generate_key(session): url = "http://0.0.0.0:4000/key/generate" headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"} data = { - "models": ["gpt-4", "text-embedding-ada-002", "dall-e-2"], + "models": ["gpt-4", "text-embedding-ada-002", "gpt-image-1"], "duration": None, } diff --git a/tests/test_keys.py b/tests/test_keys.py index 6d4c24aa80..e6bda59c2c 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -62,7 +62,7 @@ async def generate_key( i, budget=None, budget_duration=None, - models=["azure-models", "gpt-4", "dall-e-3"], + models=["azure-models", "gpt-4", "gpt-image-1"], max_parallel_requests: Optional[int] = None, user_id: Optional[str] = None, team_id: Optional[str] = None, @@ -235,7 +235,7 @@ async def chat_completion(session, key, model="gpt-4"): pass -async def image_generation(session, key, model="dall-e-3"): +async def image_generation(session, key, model="gpt-image-1"): url = "http://0.0.0.0:4000/v1/images/generations" headers = { "Authorization": f"Bearer {key}", diff --git a/tests/test_openai_endpoints.py b/tests/test_openai_endpoints.py index 8a3f9361ba..e898b88a55 100644 --- a/tests/test_openai_endpoints.py +++ b/tests/test_openai_endpoints.py @@ -23,7 +23,7 @@ async def generate_key( models=[ "gpt-4", "text-embedding-ada-002", - "dall-e-2", + "gpt-image-1", "fake-openai-endpoint-2", "mistral-embed", ], @@ -56,7 +56,7 @@ async def new_user(session): url = "http://0.0.0.0:4000/user/new" headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"} data = { - "models": ["gpt-4", "text-embedding-ada-002", "dall-e-2"], + "models": ["gpt-4", "text-embedding-ada-002", "gpt-image-1"], "duration": None, } @@ -264,7 +264,7 @@ async def image_generation(session, key): "Content-Type": "application/json", } data = { - "model": "dall-e-2", + "model": "gpt-image-1", "prompt": "A cute baby sea otter", } From 10a106a1e56ae08be24f6957b68810b339a41843 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 12 May 2026 16:16:59 -0700 Subject: [PATCH 4/8] fix(tests): drop dall-e-only test classes; route live image tests via gpt-image-1 Second wave of failures from the 2026-05-12 DALL-E shutdown: - tests/image_gen_tests/test_image_edits.py::TestOpenAIImageEditDallE2 and tests/image_gen_tests/test_image_generation.py::TestOpenAIDalle3 are explicitly named for the deprecated models and can't pass; remove. gpt-image-1 coverage already exists in sibling classes. - tests/local_testing/test_router.py image gen tests use dall-e-3 only as a routing example; swap to gpt-image-1. - tests/local_testing/test_custom_callback_input.py image_generation success/failure paths swapped to gpt-image-1. --- tests/image_gen_tests/test_image_edits.py | 14 -------------- tests/image_gen_tests/test_image_generation.py | 5 ----- tests/local_testing/test_custom_callback_input.py | 4 ++-- tests/local_testing/test_router.py | 12 ++++++------ 4 files changed, 8 insertions(+), 27 deletions(-) diff --git a/tests/image_gen_tests/test_image_edits.py b/tests/image_gen_tests/test_image_edits.py index dcb04c597e..6900bacdb9 100644 --- a/tests/image_gen_tests/test_image_edits.py +++ b/tests/image_gen_tests/test_image_edits.py @@ -133,20 +133,6 @@ class TestOpenAIImageEditGPTImage1(BaseLLMImageEditTest): } -class TestOpenAIImageEditDallE2(BaseLLMImageEditTest): - """ - Concrete implementation of BaseLLMImageEditTest for OpenAI DALL-E-2 image edits. - DALL-E-2 only supports a single image (not an array). - """ - - def get_base_image_edit_call_args(self) -> dict: - """Return base call args for OpenAI DALL-E-2 image edit (single image only)""" - return { - "model": "dall-e-2", - "image": SINGLE_TEST_IMAGE, - } - - class TestAzureAIFlux2ImageEdit(BaseLLMImageEditTest): """ Concrete implementation of BaseLLMImageEditTest for Azure AI FLUX 2 image edits. diff --git a/tests/image_gen_tests/test_image_generation.py b/tests/image_gen_tests/test_image_generation.py index 5152e3e012..873777189c 100644 --- a/tests/image_gen_tests/test_image_generation.py +++ b/tests/image_gen_tests/test_image_generation.py @@ -163,11 +163,6 @@ class TestBedrockNovaCanvasColorGuidedGeneration(BaseImageGenTest): } -class TestOpenAIDalle3(BaseImageGenTest): - def get_base_image_generation_call_args(self) -> dict: - return {"model": "dall-e-3"} - - class TestOpenAIGPTImage1(BaseImageGenTest): def get_base_image_generation_call_args(self) -> dict: return {"model": "gpt-image-1"} diff --git a/tests/local_testing/test_custom_callback_input.py b/tests/local_testing/test_custom_callback_input.py index 15a2975bec..545039e60b 100644 --- a/tests/local_testing/test_custom_callback_input.py +++ b/tests/local_testing/test_custom_callback_input.py @@ -930,7 +930,7 @@ def test_image_generation_openai(): response = litellm.image_generation( prompt="A cute baby sea otter", - model="openai/dall-e-3", + model="openai/gpt-image-1", api_key=os.getenv("OPENAI_API_KEY"), ) @@ -948,7 +948,7 @@ def test_image_generation_openai(): try: response = litellm.image_generation( prompt="A cute baby sea otter", - model="dall-e-2", + model="gpt-image-1", api_key="my-bad-api-key", ) except Exception: diff --git a/tests/local_testing/test_router.py b/tests/local_testing/test_router.py index f7885fb8a0..d6b239c79c 100644 --- a/tests/local_testing/test_router.py +++ b/tests/local_testing/test_router.py @@ -995,15 +995,15 @@ async def test_aimg_gen_on_router(): try: model_list = [ { - "model_name": "dall-e-3", + "model_name": "gpt-image-1", "litellm_params": { - "model": "dall-e-3", + "model": "gpt-image-1", }, } ] router = Router(model_list=model_list, num_retries=3) response = await router.aimage_generation( - model="dall-e-3", prompt="A cute baby sea otter" + model="gpt-image-1", prompt="A cute baby sea otter" ) print(response) assert len(response.data) > 0 @@ -1030,15 +1030,15 @@ def test_img_gen_on_router(): try: model_list = [ { - "model_name": "dall-e-3", + "model_name": "gpt-image-1", "litellm_params": { - "model": "dall-e-3", + "model": "gpt-image-1", }, } ] router = Router(model_list=model_list) response = router.image_generation( - model="dall-e-3", prompt="A cute baby sea otter" + model="gpt-image-1", prompt="A cute baby sea otter" ) print(response) assert len(response.data) > 0 From 75da2f384072aeacf737f06b57c39fc82c5351c6 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 12 May 2026 13:51:32 -0700 Subject: [PATCH 5/8] 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 8ea9dd4dfc..0a711b99f7 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -1517,46 +1517,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. # @@ -1745,27 +1707,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 611929abef..edc5aa1bec 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, From 5859fcc917774c3f154e9bc5b7af868272ab50eb Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 12 May 2026 16:00:57 -0700 Subject: [PATCH 6/8] docs(proxy): refresh stale comments referencing removed tag strip The tag-strip block was removed in the parent commit but two surrounding comments still referenced "tags without opt-in" and "runs AFTER the strip". Update them to describe the remaining user_api_key_* and _pipeline_managed_guardrails strip that the snapshot/merge ordering actually protects against. --- litellm/proxy/litellm_pre_call_utils.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 0a711b99f7..78e63f4094 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -1528,19 +1528,21 @@ async def add_litellm_data_to_request( # noqa: PLR0915 _body_snapshot = {k: v for k, v in data.items() if k != "secret_fields"} data["proxy_server_request"]["body"] = _body_snapshot - # Snapshot the (now-cleaned) requester-supplied metadata for downstream - # consumers. Taking the deepcopy AFTER the strip prevents attacker- - # injected admin slots (user_api_key_*, tags without opt-in, - # _pipeline_managed_guardrails) from surviving in requester_metadata - # where guardrails and audit paths may read from it. + # Snapshot the requester-supplied metadata for downstream consumers. + # Taking the deepcopy after the user_api_key_* / _pipeline_managed_guardrails + # strip above prevents those proxy-internal slots — if a caller forged + # them — from leaking into requester_metadata where guardrails and audit + # paths may read from it. if "metadata" in data and isinstance(data["metadata"], dict): data[_metadata_variable_name]["requester_metadata"] = copy.deepcopy( data["metadata"] ) - # Now merge litellm_metadata into the metadata variable (preserving existing - # values) — runs AFTER the strip so attacker injections in litellm_metadata - # cannot cross-contaminate the admin-authoritative metadata dict. + # Merge litellm_metadata into the metadata variable (preserving existing + # values). Runs after the user_api_key_* / _pipeline_managed_guardrails + # strip above so those proxy-internal slots — if a caller forged them + # into litellm_metadata — cannot cross-contaminate the admin-authoritative + # metadata dict. if "litellm_metadata" in data and isinstance(data["litellm_metadata"], dict): for key, value in data["litellm_metadata"].items(): if key not in data[_metadata_variable_name]: From eb142b900e1e2900b9f8ebf059c6ca0e95014f28 Mon Sep 17 00:00:00 2001 From: shivam Date: Tue, 12 May 2026 17:44:05 -0700 Subject: [PATCH 7/8] test(proxy): drop allow_client_tags opt-in gate and add credential rename cascade tests Removes the allow_client_tags metadata check from apply_client_tag_policy_pre_auth so x-litellm-tags headers are always merged into request metadata, matching the post-auth behavior in add_litellm_data_to_request. Updates pre-call tests accordingly and adds a new test suite covering cascading credential renames into model rows. Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/proxy/litellm_pre_call_utils.py | 42 ++--- .../proxy/credential_endpoints/__init__.py | 0 .../test_credential_rename_cascade.py | 165 ++++++++++++++++++ .../proxy/test_litellm_pre_call_utils.py | 76 ++------ 4 files changed, 194 insertions(+), 89 deletions(-) create mode 100644 tests/test_litellm/proxy/credential_endpoints/__init__.py create mode 100644 tests/test_litellm/proxy/credential_endpoints/test_credential_rename_cascade.py diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 78e63f4094..9f6e21521c 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -1197,44 +1197,24 @@ class LiteLLMProxyRequestSetup: user_api_key_dict: UserAPIKeyAuth, ) -> None: """ - Apply the client-tag policy BEFORE auth budget gates run, so - ``_tag_max_budget_check`` (which only inspects ``request_data``) - sees ``x-litellm-tags`` header tags. Without this, header-tagged + Merge ``x-litellm-tags`` header tags into ``request_data`` BEFORE + auth budget gates run, so ``_tag_max_budget_check`` (which only + inspects ``request_data``) sees them. Without this, header-tagged requests silently bypass per-tag budget enforcement. - Mirrors the strip + merge that ``add_litellm_data_to_request`` - performs post-auth, gated on the same ``allow_client_tags`` flag. - - Why: ``add_litellm_data_to_request`` runs after the auth chain has - completed, so any header-supplied tags it merges in are invisible - to ``_tag_max_budget_check``. Running the merge here closes that - gap. The post-auth strip + merge remains as defense-in-depth. + Why: ``add_litellm_data_to_request`` runs the equivalent merge + post-auth, after ``_tag_max_budget_check`` has already executed. + Header-supplied tags merged there are invisible to that check. + Running the merge here closes that gap; the post-auth merge in + ``add_litellm_data_to_request`` remains as defense-in-depth. How to apply: invoked from the auth chain just before ``common_checks``. Mutates ``request_data`` in place; idempotent when followed by ``add_litellm_data_to_request``. """ - _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: - # 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 - + # No allow_client_tags opt-in: caller-supplied tags always flow + # into metadata.tags (see add_litellm_data_to_request). The pre-auth + # merge mirrors that so _tag_max_budget_check sees the same tags. headers = _safe_get_request_headers(request=request) raw_header_tags = headers.get("x-litellm-tags") if not raw_header_tags: diff --git a/tests/test_litellm/proxy/credential_endpoints/__init__.py b/tests/test_litellm/proxy/credential_endpoints/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_litellm/proxy/credential_endpoints/test_credential_rename_cascade.py b/tests/test_litellm/proxy/credential_endpoints/test_credential_rename_cascade.py new file mode 100644 index 0000000000..e8d0e04876 --- /dev/null +++ b/tests/test_litellm/proxy/credential_endpoints/test_credential_rename_cascade.py @@ -0,0 +1,165 @@ +""" +Tests for cascading credential renames into model rows. + +When a credential is renamed via PATCH /credentials/{old_name}, every model +row whose `litellm_params.litellm_credential_name` references the old name +must be updated in lockstep — otherwise those models will fail at request +time when the router tries to resolve a credential that no longer exists. +""" + +import json +import types +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import litellm.proxy.proxy_server as ps +from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper +from litellm.proxy.credential_endpoints.endpoints import ( + _cascade_rename_credential_in_models, +) + + +@pytest.fixture +def salt_key(monkeypatch): + """Encrypt/decrypt helpers require a signing key — set one for the test.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-key-cascade-rename") + yield + + +def _model_row(model_id: str, credential_name_plain: str | None): + """ + Build a fake LiteLLM_ProxyModelTable row whose `litellm_params` mirrors the + on-disk shape (encrypted values, dict-typed JSON column). + """ + params: dict = {"model": "gpt-4o"} + if credential_name_plain is not None: + params["litellm_credential_name"] = encrypt_value_helper( + value=credential_name_plain + ) + row = MagicMock() + row.model_id = model_id + row.litellm_params = params + return row + + +@pytest.mark.asyncio +async def test_cascade_renames_only_matching_models(salt_key, monkeypatch): + """Only models referencing the old name are updated; others are left alone.""" + monkeypatch.setattr(ps, "llm_router", None) + + matching = _model_row("model-1", "old-cred") + other = _model_row("model-2", "different-cred") + no_credential = _model_row("model-3", None) + + tx = types.SimpleNamespace( + litellm_proxymodeltable=types.SimpleNamespace( + find_many=AsyncMock(return_value=[matching, other, no_credential]), + update=AsyncMock(), + ) + ) + + updated = await _cascade_rename_credential_in_models( + tx=tx, + old_credential_name="old-cred", + new_credential_name="new-cred", + ) + + assert updated == 1 + tx.litellm_proxymodeltable.update.assert_awaited_once() + call = tx.litellm_proxymodeltable.update.await_args + assert call.kwargs["where"] == {"model_id": "model-1"} + + # Prisma's JSON column expects a serialized string, not a dict. + raw_params = call.kwargs["data"]["litellm_params"] + assert isinstance(raw_params, str) + written_params = json.loads(raw_params) + assert written_params["model"] == "gpt-4o" + # The new credential name is stored encrypted, not plain text. + assert written_params["litellm_credential_name"] != "new-cred" + # And it must round-trip back to the new name. + from litellm.proxy.common_utils.encrypt_decrypt_utils import ( + decrypt_value_helper, + ) + + assert ( + decrypt_value_helper( + value=written_params["litellm_credential_name"], + key="litellm_credential_name", + return_original_value=True, + ) + == "new-cred" + ) + + +@pytest.mark.asyncio +async def test_cascade_updates_in_memory_router(salt_key, monkeypatch): + """ + The router's in-memory model_list holds decrypted credential names. The + cascade must rewrite them so live traffic doesn't try to resolve a name + that no longer exists in litellm.credential_list. + """ + fake_router = MagicMock() + fake_router.model_list = [ + {"litellm_params": {"litellm_credential_name": "old-cred", "model": "gpt-4o"}}, + { + "litellm_params": { + "litellm_credential_name": "other-cred", + "model": "claude", + } + }, + {"litellm_params": {"model": "no-creds"}}, + ] + monkeypatch.setattr(ps, "llm_router", fake_router) + + matching = _model_row("model-1", "old-cred") + tx = types.SimpleNamespace( + litellm_proxymodeltable=types.SimpleNamespace( + find_many=AsyncMock(return_value=[matching]), + update=AsyncMock(), + ) + ) + + await _cascade_rename_credential_in_models( + tx=tx, + old_credential_name="old-cred", + new_credential_name="new-cred", + ) + + assert ( + fake_router.model_list[0]["litellm_params"]["litellm_credential_name"] + == "new-cred" + ) + assert ( + fake_router.model_list[1]["litellm_params"]["litellm_credential_name"] + == "other-cred" + ) + assert "litellm_credential_name" not in fake_router.model_list[2]["litellm_params"] + + +@pytest.mark.asyncio +async def test_cascade_noop_when_no_models_match(salt_key, monkeypatch): + """No matching rows → no updates issued, no in-memory mutation.""" + fake_router = MagicMock() + untouched = { + "litellm_params": {"litellm_credential_name": "other-cred", "model": "gpt-4o"} + } + fake_router.model_list = [untouched] + monkeypatch.setattr(ps, "llm_router", fake_router) + + tx = types.SimpleNamespace( + litellm_proxymodeltable=types.SimpleNamespace( + find_many=AsyncMock(return_value=[_model_row("model-2", "other-cred")]), + update=AsyncMock(), + ) + ) + + updated = await _cascade_rename_credential_in_models( + tx=tx, + old_credential_name="old-cred", + new_credential_name="new-cred", + ) + + assert updated == 0 + tx.litellm_proxymodeltable.update.assert_not_awaited() + assert untouched["litellm_params"]["litellm_credential_name"] == "other-cred" 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 edc5aa1bec..18c11dcc20 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -3873,14 +3873,14 @@ class TestApplyClientTagPolicyPreAuth: post-auth in ``add_litellm_data_to_request``. """ - def test_merges_header_tags_into_metadata_when_opted_in(self): + def test_merges_header_tags_into_metadata(self): request_mock = _build_request_mock_with_headers( {"x-litellm-tags": "tenant:acme,env:prod"} ) data = {"model": "gpt-3.5-turbo"} user_api_key_dict = UserAPIKeyAuth( api_key="hashed-key", - metadata={"allow_client_tags": True}, + metadata={}, team_metadata={}, ) @@ -3902,7 +3902,7 @@ class TestApplyClientTagPolicyPreAuth: } user_api_key_dict = UserAPIKeyAuth( api_key="hashed-key", - metadata={"allow_client_tags": True}, + metadata={}, team_metadata={}, ) @@ -3915,21 +3915,17 @@ class TestApplyClientTagPolicyPreAuth: # Existing tags first, dedupe header tags assert data["metadata"]["tags"] == ["env:prod", "team:platform", "tenant:acme"] - 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. + def test_preserves_body_tags(self): + # Pre-auth must NOT touch body-supplied tags. _tag_max_budget_check + # (inside common_checks) enforces per-tag budgets on whatever tags + # it sees in request_data, including body tags. The helper only + # adds header tags to metadata.tags. request_mock = _build_request_mock_with_headers( {"x-litellm-tags": "tenant:acme"} ) data = { "model": "gpt-3.5-turbo", "tags": ["root-tag"], - "metadata": {"tags": ["meta-tag"]}, "litellm_metadata": {"tags": ["litellm-meta-tag"]}, } user_api_key_dict = UserAPIKeyAuth( @@ -3945,48 +3941,12 @@ class TestApplyClientTagPolicyPreAuth: ) 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 - # and metadata.tags is not created from it. - request_mock = _build_request_mock_with_headers( - {"x-litellm-tags": "tenant:acme"} - ) - data = {"model": "gpt-3.5-turbo"} - user_api_key_dict = UserAPIKeyAuth( - api_key="hashed-key", - metadata={}, - team_metadata={}, - ) - - LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth( - request=request_mock, - request_data=data, - user_api_key_dict=user_api_key_dict, - ) - - assert "tags" not in data.get("metadata", {}) - - def test_team_metadata_opt_in_is_honored(self): - request_mock = _build_request_mock_with_headers( - {"x-litellm-tags": "tenant:acme"} - ) - data = {"model": "gpt-3.5-turbo"} - user_api_key_dict = UserAPIKeyAuth( - api_key="hashed-key", - metadata={}, - team_metadata={"allow_client_tags": True}, - ) - - LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth( - request=request_mock, - request_data=data, - user_api_key_dict=user_api_key_dict, - ) - - assert data["metadata"]["tags"] == ["tenant:acme"] + # litellm_metadata is the active metadata key (it's present), so + # header tags merge into it and union with existing tags there. + assert data["litellm_metadata"]["tags"] == [ + "litellm-meta-tag", + "tenant:acme", + ] def test_uses_litellm_metadata_when_present(self): request_mock = _build_request_mock_with_headers( @@ -3998,7 +3958,7 @@ class TestApplyClientTagPolicyPreAuth: } user_api_key_dict = UserAPIKeyAuth( api_key="hashed-key", - metadata={"allow_client_tags": True}, + metadata={}, team_metadata={}, ) @@ -4014,12 +3974,12 @@ class TestApplyClientTagPolicyPreAuth: assert data["litellm_metadata"]["tags"] == ["tenant:acme"] assert "tags" not in data.get("metadata", {}) - def test_no_header_no_mutation_when_opted_in(self): + def test_no_header_no_mutation(self): request_mock = _build_request_mock_with_headers({}) data = {"model": "gpt-3.5-turbo"} user_api_key_dict = UserAPIKeyAuth( api_key="hashed-key", - metadata={"allow_client_tags": True}, + metadata={}, team_metadata={}, ) @@ -4045,7 +4005,7 @@ class TestApplyClientTagPolicyPreAuth: data = {"model": "gpt-3.5-turbo"} user_api_key_dict = UserAPIKeyAuth( api_key="hashed-key", - metadata={"allow_client_tags": True}, + metadata={}, team_metadata={}, ) From a5944140e982a877fd44dcbf5f9d398b9782738a Mon Sep 17 00:00:00 2001 From: shivam Date: Tue, 12 May 2026 18:06:12 -0700 Subject: [PATCH 8/8] fix(proxy): parse string metadata before pre-auth tag merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apply_client_tag_policy_pre_auth` overwrote string-typed metadata with `{}` before merging header tags, dropping any tags inside. A caller could send `metadata='{"tags":["over-budget"]}'` plus `x-litellm-tags: within-budget` and bypass `_tag_max_budget_check` on the body tag. Parse the string via `safe_json_loads` first so existing tags survive the merge. Also drop the empty `tests/test_litellm/proxy/credential_endpoints/` directory — the cascade-rename tests it held imported a function that was never implemented (out of scope for this PR). Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/proxy/litellm_pre_call_utils.py | 10 +- .../proxy/credential_endpoints/__init__.py | 0 .../test_credential_rename_cascade.py | 165 ------------------ .../proxy/test_litellm_pre_call_utils.py | 92 +++++++++- 4 files changed, 95 insertions(+), 172 deletions(-) delete mode 100644 tests/test_litellm/proxy/credential_endpoints/__init__.py delete mode 100644 tests/test_litellm/proxy/credential_endpoints/test_credential_rename_cascade.py diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 9f6e21521c..7cd099a729 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -1237,7 +1237,15 @@ class LiteLLMProxyRequestSetup: # to _tag_max_budget_check. _metadata_variable_name = get_metadata_variable_name_from_kwargs(request_data) metadata = request_data.get(_metadata_variable_name) - if not isinstance(metadata, dict): + # metadata can arrive as a JSON string (multipart/form-data, extra_body). + # Parse it so existing tags survive the merge — overwriting the string + # with {} would let a caller bypass _tag_max_budget_check on an + # over-budget body tag by also sending a within-budget header tag. + if isinstance(metadata, str): + parsed = safe_json_loads(metadata) + metadata = parsed if isinstance(parsed, dict) else {} + request_data[_metadata_variable_name] = metadata + elif not isinstance(metadata, dict): metadata = {} request_data[_metadata_variable_name] = metadata diff --git a/tests/test_litellm/proxy/credential_endpoints/__init__.py b/tests/test_litellm/proxy/credential_endpoints/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/test_litellm/proxy/credential_endpoints/test_credential_rename_cascade.py b/tests/test_litellm/proxy/credential_endpoints/test_credential_rename_cascade.py deleted file mode 100644 index e8d0e04876..0000000000 --- a/tests/test_litellm/proxy/credential_endpoints/test_credential_rename_cascade.py +++ /dev/null @@ -1,165 +0,0 @@ -""" -Tests for cascading credential renames into model rows. - -When a credential is renamed via PATCH /credentials/{old_name}, every model -row whose `litellm_params.litellm_credential_name` references the old name -must be updated in lockstep — otherwise those models will fail at request -time when the router tries to resolve a credential that no longer exists. -""" - -import json -import types -from unittest.mock import AsyncMock, MagicMock - -import pytest - -import litellm.proxy.proxy_server as ps -from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper -from litellm.proxy.credential_endpoints.endpoints import ( - _cascade_rename_credential_in_models, -) - - -@pytest.fixture -def salt_key(monkeypatch): - """Encrypt/decrypt helpers require a signing key — set one for the test.""" - monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-key-cascade-rename") - yield - - -def _model_row(model_id: str, credential_name_plain: str | None): - """ - Build a fake LiteLLM_ProxyModelTable row whose `litellm_params` mirrors the - on-disk shape (encrypted values, dict-typed JSON column). - """ - params: dict = {"model": "gpt-4o"} - if credential_name_plain is not None: - params["litellm_credential_name"] = encrypt_value_helper( - value=credential_name_plain - ) - row = MagicMock() - row.model_id = model_id - row.litellm_params = params - return row - - -@pytest.mark.asyncio -async def test_cascade_renames_only_matching_models(salt_key, monkeypatch): - """Only models referencing the old name are updated; others are left alone.""" - monkeypatch.setattr(ps, "llm_router", None) - - matching = _model_row("model-1", "old-cred") - other = _model_row("model-2", "different-cred") - no_credential = _model_row("model-3", None) - - tx = types.SimpleNamespace( - litellm_proxymodeltable=types.SimpleNamespace( - find_many=AsyncMock(return_value=[matching, other, no_credential]), - update=AsyncMock(), - ) - ) - - updated = await _cascade_rename_credential_in_models( - tx=tx, - old_credential_name="old-cred", - new_credential_name="new-cred", - ) - - assert updated == 1 - tx.litellm_proxymodeltable.update.assert_awaited_once() - call = tx.litellm_proxymodeltable.update.await_args - assert call.kwargs["where"] == {"model_id": "model-1"} - - # Prisma's JSON column expects a serialized string, not a dict. - raw_params = call.kwargs["data"]["litellm_params"] - assert isinstance(raw_params, str) - written_params = json.loads(raw_params) - assert written_params["model"] == "gpt-4o" - # The new credential name is stored encrypted, not plain text. - assert written_params["litellm_credential_name"] != "new-cred" - # And it must round-trip back to the new name. - from litellm.proxy.common_utils.encrypt_decrypt_utils import ( - decrypt_value_helper, - ) - - assert ( - decrypt_value_helper( - value=written_params["litellm_credential_name"], - key="litellm_credential_name", - return_original_value=True, - ) - == "new-cred" - ) - - -@pytest.mark.asyncio -async def test_cascade_updates_in_memory_router(salt_key, monkeypatch): - """ - The router's in-memory model_list holds decrypted credential names. The - cascade must rewrite them so live traffic doesn't try to resolve a name - that no longer exists in litellm.credential_list. - """ - fake_router = MagicMock() - fake_router.model_list = [ - {"litellm_params": {"litellm_credential_name": "old-cred", "model": "gpt-4o"}}, - { - "litellm_params": { - "litellm_credential_name": "other-cred", - "model": "claude", - } - }, - {"litellm_params": {"model": "no-creds"}}, - ] - monkeypatch.setattr(ps, "llm_router", fake_router) - - matching = _model_row("model-1", "old-cred") - tx = types.SimpleNamespace( - litellm_proxymodeltable=types.SimpleNamespace( - find_many=AsyncMock(return_value=[matching]), - update=AsyncMock(), - ) - ) - - await _cascade_rename_credential_in_models( - tx=tx, - old_credential_name="old-cred", - new_credential_name="new-cred", - ) - - assert ( - fake_router.model_list[0]["litellm_params"]["litellm_credential_name"] - == "new-cred" - ) - assert ( - fake_router.model_list[1]["litellm_params"]["litellm_credential_name"] - == "other-cred" - ) - assert "litellm_credential_name" not in fake_router.model_list[2]["litellm_params"] - - -@pytest.mark.asyncio -async def test_cascade_noop_when_no_models_match(salt_key, monkeypatch): - """No matching rows → no updates issued, no in-memory mutation.""" - fake_router = MagicMock() - untouched = { - "litellm_params": {"litellm_credential_name": "other-cred", "model": "gpt-4o"} - } - fake_router.model_list = [untouched] - monkeypatch.setattr(ps, "llm_router", fake_router) - - tx = types.SimpleNamespace( - litellm_proxymodeltable=types.SimpleNamespace( - find_many=AsyncMock(return_value=[_model_row("model-2", "other-cred")]), - update=AsyncMock(), - ) - ) - - updated = await _cascade_rename_credential_in_models( - tx=tx, - old_credential_name="old-cred", - new_credential_name="new-cred", - ) - - assert updated == 0 - tx.litellm_proxymodeltable.update.assert_not_awaited() - assert untouched["litellm_params"]["litellm_credential_name"] == "other-cred" 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 18c11dcc20..19204e8b8a 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -3991,6 +3991,88 @@ class TestApplyClientTagPolicyPreAuth: assert "metadata" not in data or "tags" not in data["metadata"] + def test_string_metadata_tags_survive_header_merge(self): + # metadata can arrive as a JSON string (multipart/form-data, extra_body). + # The pre-auth merge must parse it so an over-budget body tag isn't + # silently dropped when a within-budget header tag is also present. + request_mock = _build_request_mock_with_headers({"x-litellm-tags": "free"}) + data = { + "model": "gpt-3.5-turbo", + "metadata": '{"tags": ["paid"]}', + } + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-key", + metadata={}, + team_metadata={}, + ) + + LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth( + request=request_mock, + request_data=data, + user_api_key_dict=user_api_key_dict, + ) + + assert isinstance(data["metadata"], dict) + assert data["metadata"]["tags"] == ["paid", "free"] + + @pytest.mark.asyncio + async def test_string_metadata_does_not_bypass_tag_max_budget_check(self): + """Regression: string metadata containing an over-budget tag must not + be silently overwritten when an x-litellm-tags header is present.""" + from litellm.proxy._types import LiteLLM_BudgetTable, LiteLLM_TagTable + from litellm.proxy.auth.auth_checks import _tag_max_budget_check + from litellm.proxy.utils import ProxyLogging + + request_mock = _build_request_mock_with_headers({"x-litellm-tags": "free"}) + data = { + "model": "gpt-3.5-turbo", + "metadata": '{"tags": ["paid"]}', + } + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-key", + metadata={}, + team_metadata={}, + ) + + LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth( + request=request_mock, + request_data=data, + user_api_key_dict=user_api_key_dict, + ) + + paid_tag = LiteLLM_TagTable( + tag_name="paid", + spend=0.0, + litellm_budget_table=LiteLLM_BudgetTable(max_budget=0.10), + ) + + async def mock_get_current_spend(counter_key, fallback_spend): + if counter_key == "spend:tag:paid": + return 0.50 + return fallback_spend + + with ( + patch( + "litellm.proxy.proxy_server.get_current_spend", + mock_get_current_spend, + ), + patch( + "litellm.proxy.auth.auth_checks.get_tag_objects_batch", + new_callable=AsyncMock, + return_value={"paid": paid_tag}, + ), + ): + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await _tag_max_budget_check( + request_body=data, + prisma_client=MagicMock(), + user_api_key_cache=MagicMock(), + proxy_logging_obj=ProxyLogging(user_api_key_cache=None), + valid_token=UserAPIKeyAuth(token="test-token"), + ) + assert exc_info.value.current_cost == 0.50 + assert exc_info.value.max_budget == 0.10 + @pytest.mark.asyncio async def test_header_tags_visible_to_tag_max_budget_check(self): """End-to-end: helper + ``_tag_max_budget_check`` enforces budget on @@ -4047,6 +4129,8 @@ class TestApplyClientTagPolicyPreAuth: ) assert exc_info.value.current_cost == 0.50 assert exc_info.value.max_budget == 0.10 + + # ============================================================================ # Tests for #27516: provider hint resolution from deployment when the # user-facing model name has no provider prefix. @@ -4061,9 +4145,7 @@ def test_resolve_provider_from_deployment_uses_litellm_params_model(): deployment.litellm_params.custom_llm_provider = None router.get_deployment_by_model_group_name.return_value = deployment - assert ( - _resolve_provider_from_deployment(router, "claude-sonnet-4.6") == "bedrock" - ) + assert _resolve_provider_from_deployment(router, "claude-sonnet-4.6") == "bedrock" def test_resolve_provider_from_deployment_prefers_custom_llm_provider(): @@ -4074,9 +4156,7 @@ def test_resolve_provider_from_deployment_prefers_custom_llm_provider(): deployment.litellm_params.custom_llm_provider = "bedrock" router.get_deployment_by_model_group_name.return_value = deployment - assert ( - _resolve_provider_from_deployment(router, "claude-sonnet-4.6") == "bedrock" - ) + assert _resolve_provider_from_deployment(router, "claude-sonnet-4.6") == "bedrock" def test_resolve_provider_from_deployment_no_match():