diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 92cc2008c73..1ef0ad9fd47 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -699,17 +699,23 @@ _GROUP_DATE_ENDPOINT_API_KEY = 30 # 0b0011110 def _record_to_spend_metrics(record: Any) -> SpendMetrics: - """Build a SpendMetrics directly from one already-aggregated rollup row.""" + """Build a SpendMetrics directly from one already-aggregated rollup row. + + SUM() over zero rows is SQL NULL, so rollup rows (notably the grand-total + row, which Postgres emits even on an empty match) can carry None values. + """ + prompt_tokens = record.prompt_tokens or 0 + completion_tokens = record.completion_tokens or 0 return SpendMetrics( - spend=record.spend, - prompt_tokens=record.prompt_tokens, - completion_tokens=record.completion_tokens, - total_tokens=record.prompt_tokens + record.completion_tokens, - cache_read_input_tokens=record.cache_read_input_tokens, - cache_creation_input_tokens=record.cache_creation_input_tokens, - api_requests=record.api_requests, - successful_requests=record.successful_requests, - failed_requests=record.failed_requests, + spend=record.spend or 0.0, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=prompt_tokens + completion_tokens, + cache_read_input_tokens=record.cache_read_input_tokens or 0, + cache_creation_input_tokens=record.cache_creation_input_tokens or 0, + api_requests=record.api_requests or 0, + successful_requests=record.successful_requests or 0, + failed_requests=record.failed_requests or 0, ) 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..9ad57b55555 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 @@ -585,3 +585,61 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys(): assert key_data.metadata.key_alias == "toto-test-2" assert key_data.metadata.team_id == "69cd4b77-b095-4489-8c46-4f2f31d840a2" assert key_data.metrics.spend == 10.0 + + +@pytest.mark.asyncio +async def test_get_daily_activity_aggregated_empty_result_set(): + """Regression test for the empty-range 500. + + When the date filter matches zero rows, Postgres still emits the + grand-total () grouping-set row with every SUM column NULL. The + endpoint must return an empty result set with zeroed totals, not + crash on None + None. + """ + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + + mock_rows = [ + { + "date": None, + "api_key": None, + "model": None, + "model_group": None, + "custom_llm_provider": None, + "mcp_namespaced_tool_name": None, + "endpoint": None, + "group_level": 127, + "spend": None, + "prompt_tokens": None, + "completion_tokens": None, + "cache_read_input_tokens": None, + "cache_creation_input_tokens": None, + "api_requests": None, + "successful_requests": None, + "failed_requests": None, + } + ] + mock_prisma.db.query_raw = AsyncMock(return_value=mock_rows) + + result = await get_daily_activity_aggregated( + prisma_client=mock_prisma, + table_name="litellm_dailyuserspend", + entity_id_field="user_id", + entity_id=None, + entity_metadata_field=None, + start_date="2026-06-16", + end_date="2026-06-16", + model=None, + api_key=None, + ) + + assert result.results == [] + assert result.metadata.total_spend == 0.0 + assert result.metadata.total_prompt_tokens == 0 + assert result.metadata.total_completion_tokens == 0 + assert result.metadata.total_tokens == 0 + assert result.metadata.total_api_requests == 0 + assert result.metadata.total_successful_requests == 0 + assert result.metadata.total_failed_requests == 0 + assert result.metadata.total_cache_read_input_tokens == 0 + assert result.metadata.total_cache_creation_input_tokens == 0