From 7ac764970b4ec4ab82233171caa3c0c3545cd235 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 18 Aug 2026 19:37:32 -0700 Subject: [PATCH] fix(proxy): return no rows when the aggregated activity entity filter is empty (#37414) The aggregated daily-activity queries build their WHERE clause as raw SQL, and an empty entity list rendered as `"team_id" IN ()`, which Postgres rejects with `syntax error at or near ")"`. Callers reach that state normally: a caller without admin view and no explicit team_ids has its scope resolved to the teams it belongs to, so anyone belonging to no teams, an org admin for instance, sent an empty list and got a 500 back from /team/daily/activity/aggregated. The paginated endpoint hands the same empty list to Prisma, which renders `in: []` and matches nothing, so it kept returning 200 with an empty result set. Emit FALSE for the empty case so the raw-SQL path lands on the same answer, mirroring what the api_key filter a few lines below already does. The fix covers both aggregated queries at once because they share one WHERE clause builder. --- .../common_daily_activity.py | 13 +++-- .../test_common_daily_activity.py | 53 +++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 8edb3b42dce..781fe264eb8 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -594,13 +594,16 @@ def _build_aggregated_where_clause( sql_params.append(adjusted_end) p += 1 - # Optional entity filter + # Optional entity filter; an empty list must match nothing, not everything if entity_id is not None: if isinstance(entity_id, list): - placeholders = ", ".join(f"${p + i}" for i in range(len(entity_id))) - sql_conditions.append(f'"{entity_id_field}" IN ({placeholders})') - sql_params.extend(entity_id) - p += len(entity_id) + if entity_id: + placeholders = ", ".join(f"${p + i}" for i in range(len(entity_id))) + sql_conditions.append(f'"{entity_id_field}" IN ({placeholders})') + sql_params.extend(entity_id) + p += len(entity_id) + else: + sql_conditions.append("FALSE") else: sql_conditions.append(f'"{entity_id_field}" = ${p}') sql_params.append(entity_id) diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py index 412e6f0f83c..62c05841197 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py @@ -14,6 +14,7 @@ sys.path.insert(0, os.path.abspath("../../../..")) # Adds the parent directory from litellm.proxy.management_endpoints.common_daily_activity import ( _adjust_dates_for_timezone, _build_aggregated_sql_query, + _build_entity_rollup_sql_query, _is_user_agent_tag, _record_to_spend_metrics, get_api_key_metadata, @@ -982,6 +983,58 @@ class TestBuildAggregatedSqlQuery: assert "COALESCE(model_group, model)" not in normalized +class TestAggregatedEmptyEntityFilter: + _BUILDERS: Final = (_build_aggregated_sql_query, _build_entity_rollup_sql_query) + + @pytest.mark.parametrize("build", _BUILDERS) + def test_empty_entity_list_emits_no_degenerate_in_clause(self, build): + sql, params = build( + table_name="litellm_dailyteamspend", + entity_id_field="team_id", + entity_id=[], + start_date="2026-08-01", + end_date="2026-08-19", + model=None, + api_key=None, + ) + + normalized = " ".join(sql.split()) + assert "IN ()" not in normalized + assert '"team_id" IN' not in normalized + assert params == ["2026-08-01", "2026-08-19"] + + @pytest.mark.parametrize("build", _BUILDERS) + def test_empty_entity_list_matches_nothing_rather_than_everything(self, build): + sql, _ = build( + table_name="litellm_dailyteamspend", + entity_id_field="team_id", + entity_id=[], + start_date="2026-08-01", + end_date="2026-08-19", + model=None, + api_key=None, + ) + + assert "FALSE" in " ".join(sql.split()) + + @pytest.mark.parametrize("build", _BUILDERS) + def test_populated_entity_list_still_filters_on_its_ids(self, build): + sql, params = build( + table_name="litellm_dailyteamspend", + entity_id_field="team_id", + entity_id=["team-alpha", "team-beta"], + start_date="2026-08-01", + end_date="2026-08-19", + model=None, + api_key=None, + ) + + normalized = " ".join(sql.split()) + assert '"team_id" IN ($3, $4)' in normalized + assert "FALSE" not in normalized + assert params == ["2026-08-01", "2026-08-19", "team-alpha", "team-beta"] + + @pytest.mark.asyncio async def test_get_daily_activity_aggregated_empty_result_set(): """Regression test for the empty-range 500.