diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index c52d3748ce2..32559b98aea 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -41284,6 +41284,39 @@ "title": "ModelInsightDailyMetric", "type": "object" }, + "ModelInsightDailyTotal": { + "properties": { + "completion_tokens": { + "title": "Completion Tokens", + "type": "integer" + }, + "date": { + "title": "Date", + "type": "string" + }, + "prompt_tokens": { + "title": "Prompt Tokens", + "type": "integer" + }, + "requests": { + "title": "Requests", + "type": "integer" + }, + "spend": { + "title": "Spend", + "type": "number" + } + }, + "required": [ + "date", + "spend", + "prompt_tokens", + "completion_tokens", + "requests" + ], + "title": "ModelInsightDailyTotal", + "type": "object" + }, "ModelInsightMetric": { "properties": { "completion_tokens": { @@ -41415,6 +41448,13 @@ "title": "Daily", "type": "array" }, + "daily_totals": { + "items": { + "$ref": "#/components/schemas/ModelInsightDailyTotal" + }, + "title": "Daily Totals", + "type": "array" + }, "end_date": { "title": "End Date", "type": "string" @@ -41435,6 +41475,7 @@ "start_date", "end_date", "daily", + "daily_totals", "top_models" ], "title": "ModelInsightsResponse", diff --git a/litellm/proxy/management_endpoints/model_insights_endpoints.py b/litellm/proxy/management_endpoints/model_insights_endpoints.py index 0c6c7d1227d..b787aac2d9f 100644 --- a/litellm/proxy/management_endpoints/model_insights_endpoints.py +++ b/litellm/proxy/management_endpoints/model_insights_endpoints.py @@ -14,6 +14,7 @@ from litellm.proxy.db.model_insights_tasks import load_model_insight_tasks from litellm.repositories.table_repositories import DailyModelUsageRepository from litellm.types.model_insights import ( ModelInsightDailyMetric, + ModelInsightDailyTotal, ModelInsightMetric, ModelInsightsMetric, ModelInsightsResponse, @@ -45,12 +46,18 @@ class _GroupedDaily(_GroupedModel): date: str +class _GroupedDate(BaseModel): + date: str + sums: _Sums = Field(alias="_sum") + + class _GroupedTask(_GroupedModel): task_type: str _MODEL_ROWS: Final = TypeAdapter(list[_GroupedModel]) _DAILY_ROWS: Final = TypeAdapter(list[_GroupedDaily]) +_DATE_ROWS: Final = TypeAdapter(list[_GroupedDate]) _TASK_ROWS: Final = TypeAdapter(list[_GroupedTask]) _UNCATEGORIZED_TASK: Final = ModelInsightTask( task_type=MODEL_INSIGHTS_DEFAULT_TASK, label="Uncategorized", category="General" @@ -111,6 +118,16 @@ def _daily_metric(row: _GroupedDaily) -> ModelInsightDailyMetric: return ModelInsightDailyMetric(date=row.date, **_metric(row).model_dump()) +def _daily_total(row: _GroupedDate) -> ModelInsightDailyTotal: + return ModelInsightDailyTotal( + date=row.date, + spend=row.sums.spend, + prompt_tokens=row.sums.prompt_tokens, + completion_tokens=row.sums.completion_tokens, + requests=row.sums.request_count, + ) + + def _summarize_tasks(rows: list[_GroupedTask], metric: ModelInsightsMetric) -> list[ModelInsightTaskSummary]: catalog: Final = load_model_insight_tasks() first_seen: Final = {task: index for index, task in enumerate(dict.fromkeys(row.task_type for row in rows))} @@ -193,11 +210,20 @@ async def get_model_insights( if model_rows else [] ) + date_rows: Final = _DATE_ROWS.validate_python( + await table.group_by( + by=["date"], # mutable-ok: prisma group_by requires a list of fields + sum=_SUM_FIELDS, + where=date_window, + order={"date": "asc"}, # mutable-ok: prisma order clause must be a dict + ) + ) return ModelInsightsResponse( start_date=start_day.isoformat(), end_date=end_day.isoformat(), top_models=[_metric(row) for row in model_rows], daily=[_daily_metric(row) for row in daily_rows], + daily_totals=tuple(_daily_total(row) for row in date_rows), ) diff --git a/litellm/types/model_insights.py b/litellm/types/model_insights.py index 6b7939386a7..8d4fbbff9d3 100644 --- a/litellm/types/model_insights.py +++ b/litellm/types/model_insights.py @@ -21,6 +21,14 @@ class ModelInsightDailyMetric(ModelInsightMetric): date: str +class ModelInsightDailyTotal(BaseModel): + date: str + spend: float + prompt_tokens: int + completion_tokens: int + requests: int + + class ModelInsightTask(BaseModel): task_type: str label: str @@ -38,6 +46,7 @@ class ModelInsightsResponse(BaseModel): start_date: str end_date: str daily: list[ModelInsightDailyMetric] + daily_totals: tuple[ModelInsightDailyTotal, ...] top_models: list[ModelInsightMetric] diff --git a/tests/unit/proxy/management_endpoints/test_model_insights_endpoints.py b/tests/unit/proxy/management_endpoints/test_model_insights_endpoints.py index 2cb66771e72..535f32a7f10 100644 --- a/tests/unit/proxy/management_endpoints/test_model_insights_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_model_insights_endpoints.py @@ -45,7 +45,7 @@ def test_model_insights_reads_only_bounded_rollup() -> None: custom_llm_provider="openai", ) table = MagicMock() - table.group_by = AsyncMock(side_effect=[[model, prompt_heavy_model], [daily]]) + table.group_by = AsyncMock(side_effect=[[model, prompt_heavy_model], [daily], []]) prisma = MagicMock() prisma.db.litellm_dailymodelusage = table prisma.db.query_raw = AsyncMock() @@ -60,7 +60,7 @@ def test_model_insights_reads_only_bounded_rollup() -> None: assert response.status_code == 200 assert response.json()["top_models"][0]["model_group"] == "long-context" assert "by_task" not in response.json() - assert table.group_by.await_count == 2 + assert table.group_by.await_count == 3 prisma.db.query_raw.assert_not_awaited() prisma.db.litellm_spendlogs.find_many.assert_not_awaited() @@ -96,11 +96,11 @@ def test_model_insights_ranks_top_models_by_selected_metric() -> None: ) request_heavy["_sum"]["request_count"] = "500" table = MagicMock() - table.group_by = AsyncMock(side_effect=[[token_heavy, request_heavy], []]) + table.group_by = AsyncMock(side_effect=[[token_heavy, request_heavy], [], []]) by_requests = _call(table, "metric=requests").json() by_tokens = _call( - MagicMock(group_by=AsyncMock(side_effect=[[token_heavy, request_heavy], []])), "metric=tokens" + MagicMock(group_by=AsyncMock(side_effect=[[token_heavy, request_heavy], [], []])), "metric=tokens" ).json() assert by_requests["top_models"][0]["model_group"] == "busy" @@ -110,7 +110,7 @@ def test_model_insights_ranks_top_models_by_selected_metric() -> None: def test_model_insights_scopes_daily_to_ranked_deployments() -> None: ranked = _grouped_row(model_group="shared", model="m1", custom_llm_provider="openai") table = MagicMock() - table.group_by = AsyncMock(side_effect=[[ranked], []]) + table.group_by = AsyncMock(side_effect=[[ranked], [], []]) _call(table, "metric=tokens") @@ -119,6 +119,24 @@ def test_model_insights_scopes_daily_to_ranked_deployments() -> None: assert "model_group" not in daily_where +def test_model_insights_daily_totals_cover_every_model_not_just_the_ranked_ones() -> None: + ranked = _grouped_row(model_group="ranked", model="m1", custom_llm_provider="openai") + ranked_day = _grouped_row(date="2026-09-28", model_group="ranked", model="m1", custom_llm_provider="openai") + whole_gateway_day = _grouped_row(prompt_tokens="7000", completion_tokens="3000", date="2026-09-28") + table = MagicMock() + table.group_by = AsyncMock(side_effect=[[ranked], [ranked_day], [whole_gateway_day]]) + + body = _call(table, "metric=tokens").json() + + totals_call = table.group_by.await_args_list[2].kwargs + assert totals_call["by"] == ["date"] + assert "OR" not in totals_call["where"] + assert body["daily_totals"] == [ + {"date": "2026-09-28", "spend": 1.25, "prompt_tokens": 7000, "completion_tokens": 3000, "requests": 3} + ] + assert body["daily"][0]["prompt_tokens"] + body["daily"][0]["completion_tokens"] < 10000 + + def _task_rows() -> list[dict[str, object]]: def row(task: str, group: str, requests: str, spend: float) -> dict[str, object]: base = _grouped_row(task_type=task, model_group=group, model=group, custom_llm_provider="openai") diff --git a/ui/litellm-dashboard/src/app/(dashboard)/model-insights/_components/ModelInsightsView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/model-insights/_components/ModelInsightsView.test.tsx index 67e57a794a1..36443e90b63 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/model-insights/_components/ModelInsightsView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/model-insights/_components/ModelInsightsView.test.tsx @@ -14,7 +14,11 @@ vi.mock("@/components/ui/chart", () => ({ })); vi.mock("recharts", () => ({ Bar: () => null, - BarChart: ({ children }: { children: React.ReactNode }) =>