diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 92cc2008c73..634cce6c117 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -918,11 +918,21 @@ async def get_daily_activity( where=where_conditions ) - # Fetch paginated results + # Fetch paginated results. + # ``date`` alone is not a unique sort key -- a busy tenant has many + # rows per date (one per api_key, model, model_group, provider, + # endpoint, ...), so offset pagination over ``date desc`` lands on + # arbitrary boundaries and the same row can be skipped on one page + # and returned on another. A client that pages through and sums the + # per-page metrics (the Usage dashboard) then gets a non-deterministic + # total. Adding ``id`` (the row's UUID primary key, present on both + # LiteLLM_DailyUserSpend and LiteLLM_DailyTeamSpend) as a tiebreaker + # gives every page a stable cursor (#30164). daily_spend_data = await getattr(prisma_client.db, table_name).find_many( where=where_conditions, order=[ {"date": "desc"}, + {"id": "asc"}, ], skip=(page - 1) * page_size, take=page_size, 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 dc983aa26fd..850e5aac865 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 @@ -57,6 +57,51 @@ async def test_get_daily_activity_empty_entity_id_list(): assert where_conditions["team_id"] == {"in": []} +@pytest.mark.asyncio +async def test_get_daily_activity_order_has_id_tiebreaker(): + """Regression for #30164. + + ``date`` alone is not a unique sort key for either + ``LiteLLM_DailyUserSpend`` or ``LiteLLM_DailyTeamSpend`` -- a busy + tenant has many rows per date (one per api_key, model, model_group, + provider, endpoint, ...). Offset pagination over a non-unique sort + landed on arbitrary page boundaries between queries, so summing + per-page totals across pages produced non-deterministic results + (sometimes inflated, sometimes deflated). The tiebreaker on the + UUID primary key pins the row order so a client paging through all + results gets the correct total. + """ + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_table = MagicMock() + mock_table.count = AsyncMock(return_value=0) + mock_table.find_many = AsyncMock(return_value=[]) + mock_prisma.db.litellm_verificationtoken = MagicMock() + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + mock_prisma.db.litellm_dailyspend = mock_table + + await get_daily_activity( + prisma_client=mock_prisma, + table_name="litellm_dailyspend", + entity_id_field="team_id", + entity_id="team-1", + entity_metadata_field=None, + start_date="2024-01-01", + end_date="2024-01-02", + model=None, + api_key=None, + page=1, + page_size=10, + ) + + mock_table.find_many.assert_called_once() + order = mock_table.find_many.call_args[1]["order"] + assert order == [{"date": "desc"}, {"id": "asc"}], ( + f"order must include the id tiebreaker after date for stable offset " + f"pagination (see #30164); got {order!r}" + ) + + def test_is_user_agent_tag(): """Test _is_user_agent_tag function.""" # Test None and empty string