mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-01 02:02:20 +00:00
fix(ui): address router usage review and test failures
This commit is contained in:
parent
70a87c03aa
commit
a55a19190e
13 changed files with 297 additions and 124 deletions
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
125
tests/proxy_behavior/management/test_auto_router_usage.py
Normal file
125
tests/proxy_behavior/management/test_auto_router_usage.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<typeof useAutoRouterBenchmarks>;
|
||||
|
||||
const mockAutoRouters = (deployments: AutoRouterDeployment[] = []) => {
|
||||
|
|
|
|||
|
|
@ -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<BenchmarksBodyProps> = ({
|
|||
selectedKey,
|
||||
autoRouters,
|
||||
usage,
|
||||
usageUnavailable,
|
||||
usageError,
|
||||
}) => {
|
||||
if (isPending) return <Message>Loading auto-router usage...</Message>;
|
||||
if (error instanceof ApiError && error.status === 403) {
|
||||
|
|
@ -306,7 +306,7 @@ const BenchmarksBody: React.FC<BenchmarksBodyProps> = ({
|
|||
<>
|
||||
<HeroCard view={view} />
|
||||
|
||||
<TierTurnsChart view={view} autoRouters={autoRouters} usage={usage} usageUnavailable={usageUnavailable} />
|
||||
<TierTurnsChart view={view} autoRouters={autoRouters} usage={usage} usageError={usageError} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric
|
||||
|
|
@ -414,7 +414,7 @@ export const AutoRouterUsageView: React.FC<AutoRouterBenchmarksTabProps> = ({
|
|||
data={data}
|
||||
selectedKey={selectedKey}
|
||||
usage={usage.data}
|
||||
usageUnavailable={usage.isError}
|
||||
usageError={usage.error}
|
||||
autoRouters={autoRouters ?? []}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 }) => <div data-testid="donut">{label}</div>,
|
||||
|
|
@ -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(
|
||||
<TierTurnsChart
|
||||
view={groupView()}
|
||||
autoRouters={[deployment({ tiers: { SIMPLE: ["public-fast", "unused"], COMPLEX: "public-strong" } })]}
|
||||
usage={[
|
||||
{
|
||||
model: "provider/fast",
|
||||
router_name: "claude-auto",
|
||||
router_type: "complexity",
|
||||
tier: "SIMPLE",
|
||||
requests: 3,
|
||||
spend: 1,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
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(<TierTurnsChart view={groupView()} autoRouters={[]} usage={usage} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Spend" }));
|
||||
expect(screen.getByTestId("donut")).toHaveTextContent("$3.00");
|
||||
rerender(<TierTurnsChart view={groupView()} autoRouters={[]} usage={usage.map((row) => ({ ...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(
|
||||
<TierTurnsChart
|
||||
view={groupView()}
|
||||
autoRouters={[]}
|
||||
usageError={new ApiError("Select a range of 93 days or fewer", 400, {})}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> => {
|
||||
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<TierTurnsChartProps> = ({ view, autoRouters, usage, usageUnavailable }) => {
|
||||
const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ 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<TierTurnsChartProps> = ({ 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<TierTurnsChartProps> = ({ 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"}
|
||||
</button>
|
||||
|
|
@ -140,7 +147,11 @@ const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ 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."}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -171,11 +182,18 @@ const TierTurnsChart: React.FC<TierTurnsChartProps> = ({ view, autoRouters, usag
|
|||
{slice.tier} {trafficShare(slice[category], total)}
|
||||
{measured && ` · ${routingSpend(slice.spend)}`}
|
||||
</p>
|
||||
{measured && slice.models.length > 0 && (
|
||||
<p className="text-xs break-words text-muted-foreground">Configured: {slice.models.join(", ")}</p>
|
||||
)}
|
||||
{measured && slice.modelUsage.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">No retained requests for this tier</p>
|
||||
)}
|
||||
{measured
|
||||
? slice.modelUsage.map((model) => (
|
||||
<p key={model.model} className="text-xs break-words text-muted-foreground">
|
||||
{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)}
|
||||
</p>
|
||||
))
|
||||
: slice.models.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ beforeAll(() => {
|
|||
});
|
||||
|
||||
// Mock the networking module
|
||||
vi.mock("@/components/networking", () => ({
|
||||
vi.mock("@/components/networking", async (importOriginal) => ({
|
||||
formatDate: (await importOriginal<typeof import("@/components/networking")>()).formatDate,
|
||||
userDailyActivityCall: vi.fn(),
|
||||
userDailyActivityAggregatedCall: vi.fn(),
|
||||
gatewayDailyActivityCall: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ModelRoutingShare model="fast" scope={scope} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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), {
|
||||
|
|
|
|||
|
|
@ -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 <p className="text-xs text-muted-foreground">Loading traffic sources...</p>;
|
||||
if (isError) return <p className="text-xs text-muted-foreground">Traffic sources unavailable</p>;
|
||||
if (isError)
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error instanceof ApiError && error.status === 400 ? error.message : "Traffic sources unavailable"}
|
||||
</p>
|
||||
);
|
||||
const sources = routingSources(data ?? []);
|
||||
const total = sources.reduce((sum, source) => sum + source.requests, 0);
|
||||
if (total === 0) return <p className="text-xs text-muted-foreground">No retained requests for traffic sources</p>;
|
||||
|
|
|
|||
3
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
3
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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"];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue