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"];