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
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 importOriginalLoading traffic sources...
; - if (isError) returnTraffic 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) returnNo 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"];