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.
This commit is contained in:
yuneng-jiang 2026-08-18 19:37:32 -07:00 committed by GitHub
parent 5f6a14dbb7
commit 7ac764970b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 61 additions and 5 deletions

View file

@ -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)

View file

@ -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.