From a55a19190e338410831580d1821c0dbe0bf59868 Mon Sep 17 00:00:00 2001
From: moe-berri
Date: Thu, 24 Sep 2026 18:06:06 -0700
Subject: [PATCH] fix(ui): address router usage review and test failures
---
.github/workflows/test-unit.yml | 1 +
.../management_endpoints/auto_router_usage.py | 6 +-
.../management/test_auto_router_usage.py | 125 ++++++++++++++++++
.../spend/test_auto_router_usage.py | 90 -------------
.../test_auto_router_usage.py | 35 +++++
.../AutoRouterBenchmarksTab.test.tsx | 7 +-
.../_components/AutoRouterBenchmarksTab.tsx | 8 +-
.../_components/TierTurnsChart.test.tsx | 55 ++++++++
.../_components/TierTurnsChart.tsx | 62 ++++++---
.../components/UsagePageView.test.tsx | 3 +-
.../ModelRoutingShare.integration.test.tsx | 16 +++
.../components/ModelRoutingShare.tsx | 10 +-
ui/litellm-dashboard/src/lib/http/schema.d.ts | 3 +-
13 files changed, 297 insertions(+), 124 deletions(-)
create mode 100644 tests/proxy_behavior/management/test_auto_router_usage.py
delete mode 100644 tests/proxy_behavior/spend/test_auto_router_usage.py
diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml
index 126a6e26e6f..d5fc41dce4e 100644
--- a/.github/workflows/test-unit.yml
+++ b/.github/workflows/test-unit.yml
@@ -148,6 +148,7 @@ jobs:
test-path: >-
tests/test_litellm/proxy/analytics_endpoints
tests/test_litellm/proxy/management_endpoints
+ tests/unit/proxy/management_endpoints/test_auto_router_usage.py
tests/test_litellm/proxy/list_api
tests/test_litellm/proxy/memory
tests/test_litellm/proxy/guardrails
diff --git a/litellm/proxy/management_endpoints/auto_router_usage.py b/litellm/proxy/management_endpoints/auto_router_usage.py
index 1928ee2baff..c4511251e65 100644
--- a/litellm/proxy/management_endpoints/auto_router_usage.py
+++ b/litellm/proxy/management_endpoints/auto_router_usage.py
@@ -60,6 +60,7 @@ ORDER BY spend DESC, model, router_name, tier
"""
_USAGE_ROWS: Final = TypeAdapter(tuple[AutoRouterUsage, ...])
+MAX_ROUTING_USAGE_DAYS: Final = 93
@router.get(
@@ -80,13 +81,16 @@ async def get_auto_router_usage(
) -> tuple[AutoRouterUsage, ...]:
"""Requests and destination-model spend from retained logs in inclusive UTC days.
- Select one model or one router. Internal classifier and shadow-evaluation calls
+ Select one model or one router over at most 93 inclusive UTC days.
+ Internal classifier and shadow-evaluation calls
are excluded. Non-admins can only see requests attributed to their own user.
"""
if (destination_model is None) == (router_name is None):
raise HTTPException(status_code=400, detail="Select exactly one model or router")
if end_date < start_date or end_date == date.max:
raise HTTPException(status_code=400, detail="Invalid date range")
+ if (end_date - start_date).days >= MAX_ROUTING_USAGE_DAYS:
+ raise HTTPException(status_code=400, detail=f"Select a range of {MAX_ROUTING_USAGE_DAYS} days or fewer")
if router_type is not None and router_name is None:
raise HTTPException(status_code=400, detail="router_type requires router_name")
scoped_user: Final = (
diff --git a/tests/proxy_behavior/management/test_auto_router_usage.py b/tests/proxy_behavior/management/test_auto_router_usage.py
new file mode 100644
index 00000000000..ae121422a3c
--- /dev/null
+++ b/tests/proxy_behavior/management/test_auto_router_usage.py
@@ -0,0 +1,125 @@
+import json
+from collections.abc import Mapping
+from typing import Final
+from uuid import uuid4
+
+import pytest
+from httpx import AsyncClient
+
+from litellm.proxy.utils import PrismaClient
+
+from .actors import Actor, World
+from .conftest import MASTER_KEY
+
+pytestmark: Final = pytest.mark.asyncio(loop_scope="session")
+
+
+async def test_actual_model_spend_sources_tiers_and_scope(
+ proxy_client: AsyncClient, prisma: PrismaClient, world: World
+) -> None:
+ db: Final = prisma.db
+ caller: Final = world.keys[Actor.INTERNAL_USER]
+ owner: Final = caller.user_id
+ key: Final = f"routing-usage-{uuid4()}"
+ records: Final = (
+ ("fast", 1.0, "a", "SIMPLE", None, owner, "2026-01-01T00:00:00"),
+ ("strong", 5.0, "a", "SIMPLE", None, owner, "2026-01-01T12:00:00"),
+ ("fast", 0.5, "a", None, None, owner, "2026-01-01T12:00:00"),
+ ("fast", 2.0, "b", "COMPLEX", None, owner, "2026-01-01T12:00:00"),
+ ("fast", 3.0, None, None, None, owner, "2026-01-01T12:00:00"),
+ ("fast", 90.0, "a", "SIMPLE", "shadow_eval_router", owner, "2026-01-01T12:00:00"),
+ ("fast", 80.0, None, None, "autorouter_classifier", owner, "2026-01-01T12:00:00"),
+ ("fast", 70.0, "a", "SIMPLE", None, "other-user", "2026-01-01T12:00:00"),
+ ("fast", 60.0, "a", "SIMPLE", None, owner, "2026-01-02T00:00:00"),
+ )
+ try:
+ for index, (model, spend, router, tier, origin, user, at) in enumerate(records):
+ metadata: Final = json.dumps(
+ {
+ "routing_decision": {"router_model_name": router, "router_type": "complexity", "tier": tier}
+ if router
+ else None,
+ "internal_call_origin": origin,
+ }
+ )
+ await db.execute_raw(
+ 'INSERT INTO "LiteLLM_SpendLogs" (request_id, call_type, api_key, model, model_group, spend, '
+ '"user", "startTime", "endTime", metadata) '
+ "VALUES ($1, 'completion', $2, $3, 'renamed-alias', $4, $5, $6::timestamp, $6::timestamp, $7::jsonb)",
+ f"{key}-{index}",
+ key,
+ model,
+ spend,
+ user,
+ at,
+ metadata,
+ )
+ query: Final = {"start_date": "2026-01-01", "end_date": "2026-01-01", "api_key": key}
+ headers: Final = {"Authorization": f"Bearer {caller.cleartext}"}
+ model_usage: Final = await proxy_client.get(
+ "/auto_router/usage", params={**query, "destination_model": "fast"}, headers=headers
+ )
+ assert model_usage.status_code == 200, model_usage.text
+ assert model_usage.json() == [
+ {"model": "fast", "router_name": router, "router_type": router_type, "tier": tier, "requests": 1, "spend": spend}
+ for router, router_type, tier, spend in (
+ (None, None, None, 3.0),
+ ("b", "complexity", "COMPLEX", 2.0),
+ ("a", "complexity", "SIMPLE", 1.0),
+ ("a", "complexity", None, 0.5),
+ )
+ ]
+ router_usage: Final = await proxy_client.get(
+ "/auto_router/usage", params={**query, "router_name": "a", "router_type": "complexity"}, headers=headers
+ )
+ assert router_usage.status_code == 200, router_usage.text
+ assert router_usage.json() == [
+ {"model": model, "router_name": "a", "router_type": "complexity", "tier": tier, "requests": 1, "spend": spend}
+ for model, tier, spend in (("strong", "SIMPLE", 5.0), ("fast", "SIMPLE", 1.0), ("fast", None, 0.5))
+ ]
+ empty: Final = await proxy_client.get(
+ "/auto_router/usage",
+ params={**query, "destination_model": "fast", "api_key": f"{key}-other"},
+ headers=headers,
+ )
+ assert empty.status_code == 200, empty.text
+ assert empty.json() == []
+
+ finally:
+ await db.execute_raw('DELETE FROM "LiteLLM_SpendLogs" WHERE api_key = $1', key)
+
+
+@pytest.mark.parametrize(
+ "params,status",
+ (
+ ({}, 400),
+ ({"destination_model": "fast", "router_name": "a"}, 400),
+ ({"destination_model": "fast", "router_type": "complexity"}, 400),
+ ({"destination_model": "fast", "end_date": "2025-12-31"}, 400),
+ ({"destination_model": "fast", "end_date": "9999-12-31"}, 400),
+ ({"destination_model": "fast", "end_date": "2026-04-03"}, 200),
+ ({"destination_model": "fast", "end_date": "2026-04-04"}, 400),
+ ({"destination_model": "fast", "start_date": "0001-01-01"}, 400),
+ ({"destination_model": ""}, 422),
+ ({"destination_model": "fast", "start_date": "not-a-date"}, 422),
+ ),
+)
+async def test_routing_usage_rejects_invalid_filters_and_bounds_inclusive_days(
+ proxy_client: AsyncClient, params: Mapping[str, str], status: int
+) -> None:
+ response: Final = await proxy_client.get(
+ "/auto_router/usage",
+ params={"start_date": "2026-01-01", "end_date": "2026-01-01", **params},
+ headers={"Authorization": f"Bearer {MASTER_KEY}"},
+ )
+ assert response.status_code == status, response.text
+
+
+async def test_routing_usage_cannot_query_another_user(proxy_client: AsyncClient, world: World) -> None:
+ caller: Final = world.keys[Actor.INTERNAL_USER]
+ response: Final = await proxy_client.get(
+ "/auto_router/usage",
+ params={"start_date": "2026-01-01", "end_date": "2026-01-01", "destination_model": "fast", "user_id": "other"},
+ headers={"Authorization": f"Bearer {caller.cleartext}"},
+ )
+ assert response.status_code == 403, response.text
diff --git a/tests/proxy_behavior/spend/test_auto_router_usage.py b/tests/proxy_behavior/spend/test_auto_router_usage.py
deleted file mode 100644
index 3fd8f935eff..00000000000
--- a/tests/proxy_behavior/spend/test_auto_router_usage.py
+++ /dev/null
@@ -1,90 +0,0 @@
-import json
-from datetime import date
-from typing import Final
-from uuid import uuid4
-
-import pytest
-
-from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
-from litellm.proxy.management_endpoints.auto_router_usage import get_auto_router_usage
-
-pytestmark = pytest.mark.asyncio(loop_scope="session")
-
-
-async def test_actual_model_spend_sources_tiers_and_scope(db):
- key: Final = f"routing-usage-{uuid4()}"
- records: Final = (
- ("fast", 1.0, "a", "SIMPLE", None, "owner", "2026-01-01T00:00:00"),
- ("strong", 5.0, "a", "SIMPLE", None, "owner", "2026-01-01T12:00:00"),
- ("fast", 0.5, "a", None, None, "owner", "2026-01-01T12:00:00"),
- ("fast", 2.0, "b", "COMPLEX", None, "owner", "2026-01-01T12:00:00"),
- ("fast", 3.0, None, None, None, "owner", "2026-01-01T12:00:00"),
- ("fast", 90.0, "a", "SIMPLE", "shadow_eval_router", "owner", "2026-01-01T12:00:00"),
- ("fast", 80.0, None, None, "autorouter_classifier", "owner", "2026-01-01T12:00:00"),
- ("fast", 70.0, "a", "SIMPLE", None, "other-user", "2026-01-01T12:00:00"),
- ("fast", 60.0, "a", "SIMPLE", None, "owner", "2026-01-02T00:00:00"),
- )
- try:
- for index, (model, spend, router, tier, origin, user, at) in enumerate(records):
- metadata: Final = json.dumps(
- {
- "routing_decision": {"router_model_name": router, "router_type": "complexity", "tier": tier}
- if router
- else None,
- "internal_call_origin": origin,
- }
- )
- await db.execute_raw(
- 'INSERT INTO "LiteLLM_SpendLogs" (request_id, call_type, api_key, model, model_group, spend, '
- '"user", "startTime", "endTime", metadata) '
- "VALUES ($1, 'completion', $2, $3, 'renamed-alias', $4, $5, $6::timestamp, $6::timestamp, $7::jsonb)",
- f"{key}-{index}",
- key,
- model,
- spend,
- user,
- at,
- metadata,
- )
- caller: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="owner")
- model_usage: Final = await get_auto_router_usage(
- date(2026, 1, 1),
- date(2026, 1, 1),
- caller,
- db,
- destination_model="fast",
- api_key=key,
- )
- assert {(row.router_name, row.tier, row.requests, row.spend) for row in model_usage} == {
- (None, None, 1, 3.0),
- ("b", "COMPLEX", 1, 2.0),
- ("a", "SIMPLE", 1, 1.0),
- ("a", None, 1, 0.5),
- }
- router_usage: Final = await get_auto_router_usage(
- date(2026, 1, 1),
- date(2026, 1, 1),
- caller,
- db,
- router_name="a",
- router_type="complexity",
- api_key=key,
- )
- assert {(row.model, row.tier, row.requests, row.spend) for row in router_usage} == {
- ("fast", "SIMPLE", 1, 1.0),
- ("strong", "SIMPLE", 1, 5.0),
- ("fast", None, 1, 0.5),
- }
- assert (
- await get_auto_router_usage(
- date(2026, 1, 1),
- date(2026, 1, 1),
- caller,
- db,
- destination_model="fast",
- api_key=f"{key}-other",
- )
- == ()
- )
- finally:
- await db.execute_raw('DELETE FROM "LiteLLM_SpendLogs" WHERE api_key = $1', key)
diff --git a/tests/unit/proxy/management_endpoints/test_auto_router_usage.py b/tests/unit/proxy/management_endpoints/test_auto_router_usage.py
index 2ab5fe5093a..fabd2bef130 100644
--- a/tests/unit/proxy/management_endpoints/test_auto_router_usage.py
+++ b/tests/unit/proxy/management_endpoints/test_auto_router_usage.py
@@ -1,4 +1,5 @@
from datetime import date
+from typing import Final
from unittest.mock import AsyncMock
import pytest
@@ -91,3 +92,37 @@ async def test_query_requires_one_specific_model_or_router(model: str | None, ro
)
assert error.value.status_code == 400
db.query_raw.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("end", [date(2025, 12, 31), date.max, date(2026, 4, 4)])
+async def test_invalid_or_overlong_ranges_never_query_spend_logs(end: date) -> None:
+ db: Final = Database()
+ with pytest.raises(HTTPException) as error:
+ await get_auto_router_usage(
+ date(2026, 1, 1), end, UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), db, destination_model="fast"
+ )
+ assert error.value.status_code == 400
+ db.query_raw.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_maximum_range_includes_its_last_day() -> None:
+ db: Final = Database()
+ await get_auto_router_usage(
+ date(2026, 1, 1), date(2026, 4, 3), UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), db,
+ destination_model="fast",
+ )
+ assert db.query_raw.call_args.args[1:3] == ("2026-01-01T00:00:00", "2026-04-04T00:00:00")
+
+
+@pytest.mark.asyncio
+async def test_router_type_cannot_filter_a_destination_model() -> None:
+ db: Final = Database()
+ with pytest.raises(HTTPException) as error:
+ await get_auto_router_usage(
+ date(2026, 1, 1), date(2026, 1, 1), UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), db,
+ destination_model="fast", router_type="complexity",
+ )
+ assert error.value.status_code == 400
+ db.query_raw.assert_not_called()
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx
index 5f3bc394c1e..2a0313d59f7 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx
@@ -1,6 +1,3 @@
-vi.mock("@/components/UsagePage/useRoutingUsage", () => ({
- useRoutingUsage: () => ({ data: undefined, isError: false }),
-}));
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, within } from "@testing-library/react";
import React from "react";
@@ -36,6 +33,10 @@ import type {
} from "./autoRouterBenchmarks";
import { useAutoRouterBenchmarks } from "./useAutoRouterBenchmarks";
+vi.mock("@/components/UsagePage/useRoutingUsage", () => ({
+ useRoutingUsage: () => ({ data: undefined, error: null }),
+}));
+
type HookResult = ReturnType;
const mockAutoRouters = (deployments: AutoRouterDeployment[] = []) => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx
index 6bc74ffb8a2..7a0dd2157ba 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx
@@ -277,7 +277,7 @@ const CachingCard: React.FC<{ cache: AutoRouterCacheStats }> = ({ cache }) => {
interface BenchmarksBodyProps {
usage?: readonly RoutingUsage[];
- usageUnavailable?: boolean;
+ usageError?: unknown;
isPending: boolean;
error: unknown;
data: AutoRouterBenchmarksResponse | undefined;
@@ -292,7 +292,7 @@ const BenchmarksBody: React.FC = ({
selectedKey,
autoRouters,
usage,
- usageUnavailable,
+ usageError,
}) => {
if (isPending) return Loading auto-router usage...;
if (error instanceof ApiError && error.status === 403) {
@@ -306,7 +306,7 @@ const BenchmarksBody: React.FC = ({
<>
-
+
= ({
data={data}
selectedKey={selectedKey}
usage={usage.data}
- usageUnavailable={usage.isError}
+ usageError={usage.error}
autoRouters={autoRouters ?? []}
/>
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/TierTurnsChart.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/TierTurnsChart.test.tsx
index 9282ea11d2c..ae9a464f8db 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/TierTurnsChart.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/TierTurnsChart.test.tsx
@@ -3,6 +3,7 @@ import React from "react";
import { describe, expect, it, vi } from "vitest";
import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
+import { ApiError } from "@/lib/http/client";
vi.mock("@/components/shared/charts", () => ({
DonutChart: ({ label }: { label: string }) => {label}
,
@@ -205,3 +206,57 @@ it("switches the existing donut to actual spend and shows each destination model
expect(screen.getByText("Simple 60% · $6.00")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Spend" })).toHaveAttribute("aria-pressed", "true");
});
+
+it("keeps configured aliases and unused tiers visible separately from recorded destinations", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Configured: public-fast, unused")).toBeVisible();
+ expect(screen.getByText("provider/fast · 3 requests (100%) · $1.00")).toBeVisible();
+ expect(screen.getByText("Configured: public-strong")).toBeVisible();
+ expect(screen.getByText("No retained requests for this tier")).toBeVisible();
+ expect(screen.getByTestId("donut")).toHaveTextContent("3 total requests");
+});
+
+it("keeps traffic visible when requests have no spend, including after changing the range in Spend mode", () => {
+ const usage = [
+ { model: "fast", router_name: "claude-auto", router_type: "complexity", tier: "SIMPLE", requests: 3, spend: 1 },
+ { model: "strong", router_name: "claude-auto", router_type: "complexity", tier: "COMPLEX", requests: 1, spend: 2 },
+ ];
+ const { rerender } = render();
+ fireEvent.click(screen.getByRole("button", { name: "Spend" }));
+ expect(screen.getByTestId("donut")).toHaveTextContent("$3.00");
+ rerender( ({ ...row, spend: 0 }))} />);
+ expect(screen.getByRole("button", { name: "Spend" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Traffic" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByText(/No model spend recorded; showing traffic/)).toBeVisible();
+ expect(screen.getByText("Simple 75% · $0.00")).toBeVisible();
+ expect(screen.getByText("Complex 25% · $0.00")).toBeVisible();
+ expect(screen.getByTestId("donut")).toHaveTextContent("4 total requests");
+});
+
+it("explains the date limit while retaining the existing tier traffic chart", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/Select a range of 93 days or fewer/)).toBeVisible();
+ expect(screen.getByTestId("donut")).toHaveTextContent("4 total turns");
+ expect(screen.queryByRole("button", { name: "Spend" })).not.toBeInTheDocument();
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/TierTurnsChart.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/TierTurnsChart.tsx
index c8b6ce19cb1..a3a3e545512 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/TierTurnsChart.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/TierTurnsChart.tsx
@@ -14,6 +14,7 @@ import {
import { normalizeTierModels } from "@/components/add_model/complexity_router_tiers";
import { chartColorValue, DEFAULT_COLOR_CYCLE, DonutChart } from "@/components/shared/charts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { ApiError } from "@/lib/http/client";
import { viewGroup, type BenchmarkView } from "./autoRouterBenchmarks";
@@ -66,27 +67,25 @@ const tierLabelsFor = (
return hydrateTierLabels(config.tier_labels);
};
-const tierModelsFor = (
- tier: string,
+const configuredTiersFor = (
routerName: string,
routerType: string,
autoRouters: readonly AutoRouterDeployment[],
-): string[] => {
+): Record => {
const deployment = deploymentFor(routerName, routerType, autoRouters);
- if (!deployment) return [];
+ if (!deployment) return {};
const config = asRecord(deployment.litellm_params?.complexity_router_config);
- const tiers = asRecord(config.tiers);
- return normalizeTierModels(tiers[tier]);
+ return asRecord(config.tiers);
};
interface TierTurnsChartProps {
usage?: readonly RoutingUsage[];
- usageUnavailable?: boolean;
+ usageError?: unknown;
view: BenchmarkView;
autoRouters: readonly AutoRouterDeployment[];
}
-const TierTurnsChart: React.FC = ({ view, autoRouters, usage, usageUnavailable }) => {
+const TierTurnsChart: React.FC = ({ view, autoRouters, usage, usageError }) => {
const [mode, setMode] = useState<"turns" | "spend">("turns");
const group = viewGroup(view);
const entries = Object.entries(group?.tier_turns ?? {}).filter(([, turns]) => turns > 0);
@@ -94,23 +93,30 @@ const TierTurnsChart: React.FC = ({ view, autoRouters, usag
const tierLabels = tierLabelsFor(group.router_name, group.router_type, autoRouters);
const measured = usage !== undefined && usage.length > 0;
- const spendUnavailable = !measured && (usageUnavailable || usage !== undefined);
+ const spendUnavailable = !measured && (Boolean(usageError) || usage !== undefined);
+ const configuredTiers = configuredTiersFor(group.router_name, group.router_type, autoRouters);
+ const recordedTiers = routingTiers(usage ?? []);
+ const tiers = [...new Set([...recordedTiers.map((row) => row.tier), ...Object.keys(configuredTiers)])];
const slices = measured
- ? routingTiers(usage).map((row) => ({
- tier: tierDisplayLabel(row.tier ?? "Default / no tier", tierLabels),
- turns: row.requests,
- spend: row.spend,
- models: row.models.map((model) => model.model),
- modelUsage: row.models,
- }))
+ ? tiers.map((tier) => {
+ const recorded = recordedTiers.find((row) => row.tier === tier);
+ return {
+ tier: tierDisplayLabel(tier ?? "Default / no tier", tierLabels),
+ turns: recorded?.requests ?? 0,
+ spend: recorded?.spend ?? 0,
+ models: tier === null ? [] : normalizeTierModels(configuredTiers[tier]),
+ modelUsage: recorded?.models ?? [],
+ };
+ })
: entries.map(([tier, turns]) => ({
tier: tierDisplayLabel(tier, tierLabels),
turns,
spend: 0,
- models: tierModelsFor(tier, group.router_name, group.router_type, autoRouters),
+ models: normalizeTierModels(configuredTiers[tier]),
modelUsage: [],
}));
- const category = measured ? mode : "turns";
+ const hasSpend = measured && slices.some((slice) => slice.spend > 0);
+ const category = hasSpend ? mode : "turns";
const total = slices.reduce((sum, slice) => sum + slice[category], 0);
const requests = slices.reduce((sum, slice) => sum + slice.turns, 0);
const colors = slices.map((_, idx) => DEFAULT_COLOR_CYCLE[idx % DEFAULT_COLOR_CYCLE.length]);
@@ -127,8 +133,9 @@ const TierTurnsChart: React.FC = ({ view, autoRouters, usag
key={value}
type="button"
aria-pressed={category === value}
+ disabled={value === "spend" && !hasSpend}
onClick={() => setMode(value)}
- className={`rounded-md px-3 py-1 text-sm transition-colors ${category === value ? "bg-card text-foreground shadow-xs" : "text-muted-foreground hover:text-foreground"}`}
+ className={`rounded-md px-3 py-1 text-sm transition-colors disabled:opacity-50 ${category === value ? "bg-card text-foreground shadow-xs" : "text-muted-foreground hover:text-foreground"}`}
>
{value === "turns" ? "Traffic" : "Spend"}
@@ -140,7 +147,11 @@ const TierTurnsChart: React.FC = ({ view, autoRouters, usag
{measured
? "Requests and model spend from retained logs in this period. Excludes classifier and shadow-evaluation calls."
: "Turns each tier served. Turns the classifier sent to the default model belong to no tier and are not counted here, so this can total less than the router's turns."}
- {spendUnavailable && " Model spend is unavailable for this range."}
+ {spendUnavailable &&
+ (usageError instanceof ApiError && usageError.status === 400
+ ? ` ${usageError.message}.`
+ : " Model spend is unavailable for this range.")}
+ {measured && !hasSpend && " No model spend recorded; showing traffic."}
@@ -171,11 +182,18 @@ const TierTurnsChart: React.FC = ({ view, autoRouters, usag
{slice.tier} {trafficShare(slice[category], total)}
{measured && ` · ${routingSpend(slice.spend)}`}
+ {measured && slice.models.length > 0 && (
+ Configured: {slice.models.join(", ")}
+ )}
+ {measured && slice.modelUsage.length === 0 && (
+ No retained requests for this tier
+ )}
{measured
? slice.modelUsage.map((model) => (
- {model.model} · {model.requests.toLocaleString()} {model.requests === 1 ? "request" : "requests"} (
- {trafficShare(model.requests, requests)}) · {routingSpend(model.spend)}
+ {model.model} · {model.requests.toLocaleString()}{" "}
+ {model.requests === 1 ? "request" : "requests"} ({trafficShare(model.requests, requests)}) ·{" "}
+ {routingSpend(model.spend)}
))
: slice.models.length > 0 && (
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx
index 7702488f5bf..804931a691e 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx
@@ -24,7 +24,8 @@ beforeAll(() => {
});
// Mock the networking module
-vi.mock("@/components/networking", () => ({
+vi.mock("@/components/networking", async (importOriginal) => ({
+ formatDate: (await importOriginal()).formatDate,
userDailyActivityCall: vi.fn(),
userDailyActivityAggregatedCall: vi.fn(),
gatewayDailyActivityCall: vi.fn(),
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/ModelRoutingShare.integration.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/ModelRoutingShare.integration.test.tsx
index aa2c2a73364..4d31c327246 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/ModelRoutingShare.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/ModelRoutingShare.integration.test.tsx
@@ -12,6 +12,22 @@ const rows = [
afterEach(() => vi.restoreAllMocks());
describe("model traffic sources", () => {
+ it("explains how to recover from a date range rejected by the gateway", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ detail: "Select a range of 93 days or fewer" }), {
+ status: 400,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ render(
+
+
+ ,
+ );
+ expect(await screen.findByText("Select a range of 93 days or fewer")).toBeVisible();
+ expect(screen.queryByRole("button", { name: /via auto-router/ })).not.toBeInTheDocument();
+ });
+
it("loads the selected model and user, then opens direct and router request/spend figures", async () => {
const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify(rows), {
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/ModelRoutingShare.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/ModelRoutingShare.tsx
index ca864cbe1d9..5bbe1463c20 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/ModelRoutingShare.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/ModelRoutingShare.tsx
@@ -2,11 +2,17 @@ import { useRoutingUsage } from "../useRoutingUsage";
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { routingSources, routingSpend, trafficShare, type RoutingUsageScope } from "../routingUsage";
+import { ApiError } from "@/lib/http/client";
export default function ModelRoutingShare({ model, scope }: { model: string; scope: RoutingUsageScope }) {
- const { data, isPending, isError } = useRoutingUsage({ ...scope, destination_model: model });
+ const { data, isPending, isError, error } = useRoutingUsage({ ...scope, destination_model: model });
if (isPending) return Loading traffic sources...
;
- if (isError) return Traffic sources unavailable
;
+ if (isError)
+ return (
+
+ {error instanceof ApiError && error.status === 400 ? error.message : "Traffic sources unavailable"}
+
+ );
const sources = routingSources(data ?? []);
const total = sources.reduce((sum, source) => sum + source.requests, 0);
if (total === 0) return No retained requests for traffic sources
;
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 11774c91b5b..18c53495e69 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -1504,7 +1504,8 @@ export interface paths {
* Get Auto Router Usage
* @description Requests and destination-model spend from retained logs in inclusive UTC days.
*
- * Select one model or one router. Internal classifier and shadow-evaluation calls
+ * Select one model or one router over at most 93 inclusive UTC days.
+ * Internal classifier and shadow-evaluation calls
* are excluded. Non-admins can only see requests attributed to their own user.
*/
get: operations["get_auto_router_usage_auto_router_usage_get"];