From bb887f572e737e723db105117b2a8bb183ce0abf Mon Sep 17 00:00:00 2001 From: piyushrajyadav Date: Sat, 22 Aug 2026 20:34:55 +0530 Subject: [PATCH 1/2] fix(proxy): preserve null team_id keys when exclude_team_id is set (#37292) --- .../key_management_endpoints.py | 5 +- .../test_key_management_endpoints.py | 75 ++++++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index bf42aeeec05..af0c28a74e3 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -5875,7 +5875,10 @@ def _build_key_filter_conditions( else: user_condition["user_id"] = user_id if exclude_team_id and isinstance(exclude_team_id, str): - user_condition["team_id"] = {"not": exclude_team_id} + user_condition["OR"] = [ + {"team_id": None}, + {"team_id": {"not": exclude_team_id}}, + ] if organization_id and isinstance(organization_id, str): user_condition["organization_id"] = organization_id diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 0c615cbaa32..0a8058bc6e9 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -30,6 +30,7 @@ from litellm.proxy.auth.auth_checks import _project_cache_key from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.proxy.management_endpoints.key_management_endpoints import ( + _build_key_filter_conditions, _check_org_key_limits, _check_project_key_limits, _check_team_key_limits, @@ -239,11 +240,14 @@ async def test_list_keys_include_created_by_keys(): elif "created_by" in condition: created_by_condition_with_exclude = condition - # Verify exclude_team_id is applied to user condition + # Verify exclude_team_id produces an OR clause that preserves NULL team_id rows assert ( user_condition_with_exclude is not None ), "User condition with exclude should be present" - assert user_condition_with_exclude["team_id"] == {"not": "excluded-team-123"} + assert user_condition_with_exclude["OR"] == [ + {"team_id": None}, + {"team_id": {"not": "excluded-team-123"}}, + ] # Verify created_by condition still only has created_by filter assert ( @@ -253,6 +257,73 @@ async def test_list_keys_include_created_by_keys(): assert len(created_by_condition_with_exclude) == 1 +def test_build_key_filter_conditions_exclude_team_id_preserves_null(): + """ + Regression test for #37292: _build_key_filter_conditions must emit an OR + clause that retains keys where team_id IS NULL when exclude_team_id is set. + + In SQL, `team_id != 'x'` silently drops rows where team_id is NULL because + NULL comparisons evaluate to UNKNOWN (neither TRUE nor FALSE). The fix + wraps the condition as OR([{team_id: None}, {team_id: {not: exclude_team_id}}]) + so that unassigned-team keys are always included. + """ + # Case 1: no user_id (matches the Prometheus metrics caller path) + cond = _build_key_filter_conditions( + user_id=None, + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + exclude_team_id="litellm-dashboard", + admin_team_ids=None, + ) + assert "OR" in cond, "Expected OR key when exclude_team_id is set" + assert {"team_id": None} in cond["OR"], ( + "NULL team_id branch must be present so unassigned keys are not silently dropped" + ) + assert {"team_id": {"not": "litellm-dashboard"}} in cond["OR"] + + # Case 2: user_id provided alongside exclude_team_id + cond2 = _build_key_filter_conditions( + user_id="user-abc", + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + exclude_team_id="excluded-team", + admin_team_ids=None, + ) + assert cond2.get("user_id") == "user-abc" + assert cond2.get("OR") == [ + {"team_id": None}, + {"team_id": {"not": "excluded-team"}}, + ] + + # Case 3: exclude_team_id absent — user_id is set directly, no exclude_team_id OR injected. + # The top-level OR from _get_condition_to_filter_out_ui_session_tokens is always present; + # we assert that user_id appears as a direct key and that no exclude_team_id NOT pattern + # exists inside any OR list. + cond3 = _build_key_filter_conditions( + user_id="user-abc", + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + exclude_team_id=None, + admin_team_ids=None, + ) + assert cond3.get("user_id") == "user-abc", ( + "user_id should be a direct top-level key when no exclude_team_id" + ) + # Verify no exclude_team_id NOT pattern was injected (the only OR should be the + # session token filter, which doesn't have {team_id: {not: ...}} entries) + top_or = cond3.get("OR", []) + assert not any( + isinstance(c, dict) and c.get("team_id", {}) == {"not": "any-value"} + for c in top_or + ), "No {team_id: {not: ...}} pattern should appear when exclude_team_id is absent" + + @pytest.mark.asyncio async def test_key_token_handling(monkeypatch): """ From aad97cb83058500e5d5b57f4ac39ff691a1df862 Mon Sep 17 00:00:00 2001 From: piyushrajyadav Date: Thu, 27 Aug 2026 18:55:32 +0530 Subject: [PATCH 2/2] test(proxy): simplify redundant commentary in exclude_team_id regression test --- .../test_key_management_endpoints.py | 33 +++++-------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 0a8058bc6e9..db6ce0b5a78 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -258,16 +258,7 @@ async def test_list_keys_include_created_by_keys(): def test_build_key_filter_conditions_exclude_team_id_preserves_null(): - """ - Regression test for #37292: _build_key_filter_conditions must emit an OR - clause that retains keys where team_id IS NULL when exclude_team_id is set. - - In SQL, `team_id != 'x'` silently drops rows where team_id is NULL because - NULL comparisons evaluate to UNKNOWN (neither TRUE nor FALSE). The fix - wraps the condition as OR([{team_id: None}, {team_id: {not: exclude_team_id}}]) - so that unassigned-team keys are always included. - """ - # Case 1: no user_id (matches the Prometheus metrics caller path) + """Regression test for #37292: exclude_team_id must not drop keys where team_id IS NULL.""" cond = _build_key_filter_conditions( user_id=None, team_id=None, @@ -277,13 +268,11 @@ def test_build_key_filter_conditions_exclude_team_id_preserves_null(): exclude_team_id="litellm-dashboard", admin_team_ids=None, ) - assert "OR" in cond, "Expected OR key when exclude_team_id is set" - assert {"team_id": None} in cond["OR"], ( - "NULL team_id branch must be present so unassigned keys are not silently dropped" - ) + assert "OR" in cond + assert {"team_id": None} in cond["OR"] assert {"team_id": {"not": "litellm-dashboard"}} in cond["OR"] - # Case 2: user_id provided alongside exclude_team_id + # user_id alongside exclude_team_id cond2 = _build_key_filter_conditions( user_id="user-abc", team_id=None, @@ -299,10 +288,7 @@ def test_build_key_filter_conditions_exclude_team_id_preserves_null(): {"team_id": {"not": "excluded-team"}}, ] - # Case 3: exclude_team_id absent — user_id is set directly, no exclude_team_id OR injected. - # The top-level OR from _get_condition_to_filter_out_ui_session_tokens is always present; - # we assert that user_id appears as a direct key and that no exclude_team_id NOT pattern - # exists inside any OR list. + # no exclude_team_id — user_id is a direct key, no extra team OR injected cond3 = _build_key_filter_conditions( user_id="user-abc", team_id=None, @@ -312,16 +298,13 @@ def test_build_key_filter_conditions_exclude_team_id_preserves_null(): exclude_team_id=None, admin_team_ids=None, ) - assert cond3.get("user_id") == "user-abc", ( - "user_id should be a direct top-level key when no exclude_team_id" - ) - # Verify no exclude_team_id NOT pattern was injected (the only OR should be the - # session token filter, which doesn't have {team_id: {not: ...}} entries) + assert cond3.get("user_id") == "user-abc" top_or = cond3.get("OR", []) assert not any( isinstance(c, dict) and c.get("team_id", {}) == {"not": "any-value"} for c in top_or - ), "No {team_id: {not: ...}} pattern should appear when exclude_team_id is absent" + ) + @pytest.mark.asyncio