From eea4c860f76ab5eadbe082ff36d081c00ec7346b Mon Sep 17 00:00:00 2001 From: tin-berri Date: Mon, 7 Sep 2026 12:12:22 -0700 Subject: [PATCH] feat(ui): add key-scoped auto-router usage tab (#39999) * feat(ui): add key-scoped auto-router usage tab GET /auto_router/benchmarks takes an optional api_key filter, applied in the rollup aggregate on the primary key's leading column. Proxy admins get a separate Auto-router usage tab on key detail pages with spend, baseline, savings, tier routing, cache metrics and the existing router selector * fix(ui): share key analytics date range --- litellm/proxy/db/autorouter_session_rollup.py | 4 +- .../auto_router_endpoints.py | 2 + .../spend/test_autorouter_session_rollup.py | 32 ++++++ .../test_auto_router_endpoints.py | 5 +- .../AutoRouterBenchmarksTab.test.tsx | 28 +++++- .../_components/AutoRouterBenchmarksTab.tsx | 9 +- .../useAutoRouterBenchmarks.test.ts | 31 +++++- .../_components/useAutoRouterBenchmarks.ts | 4 +- ...useDailyActivityRange.integration.test.tsx | 8 +- .../useDailyActivityRange.test.tsx | 10 +- .../_components/useDailyActivityRange.ts | 24 +++-- ...KeyAutoRouterUsageTab.integration.test.tsx | 98 +++++++++++++++++++ .../templates/KeyAutoRouterUsageTab.tsx | 18 ++++ .../KeySavingsTab.integration.test.tsx | 12 ++- .../components/templates/KeySavingsTab.tsx | 19 ++-- .../templates/key_info_view.test.tsx | 56 +++++++++++ .../components/templates/key_info_view.tsx | 26 ++++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 + 18 files changed, 354 insertions(+), 34 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/templates/KeyAutoRouterUsageTab.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/templates/KeyAutoRouterUsageTab.tsx diff --git a/litellm/proxy/db/autorouter_session_rollup.py b/litellm/proxy/db/autorouter_session_rollup.py index 9c637a62dc1..b33bffbaaa1 100644 --- a/litellm/proxy/db/autorouter_session_rollup.py +++ b/litellm/proxy/db/autorouter_session_rollup.py @@ -37,7 +37,9 @@ CACHE_TTL_1H_SECONDS: Final = 3600 AUTOROUTER_BENCHMARKS_SQL: Final = """ WITH windowed AS ( SELECT * FROM "LiteLLM_AutoRouterSession" - WHERE last_turn_at >= $1::timestamp AND first_turn_at < $2::timestamp + WHERE last_turn_at >= $1::timestamp + AND first_turn_at < $2::timestamp + AND ($3::text IS NULL OR api_key = $3::text) ), tier_maps AS ( SELECT router_name, router_type, jsonb_object_agg(tier, tier_turns) AS tier_turns diff --git a/litellm/proxy/management_endpoints/auto_router_endpoints.py b/litellm/proxy/management_endpoints/auto_router_endpoints.py index 2a7813bc140..0f0323b45f8 100644 --- a/litellm/proxy/management_endpoints/auto_router_endpoints.py +++ b/litellm/proxy/management_endpoints/auto_router_endpoints.py @@ -645,6 +645,7 @@ async def get_auto_router_benchmarks( str | None, Query(description="YYYY-MM-DD UTC, inclusive (defaults to 30 days before end_date)") ] = None, end_date: Annotated[str | None, Query(description="YYYY-MM-DD UTC, inclusive (defaults to today)")] = None, + api_key: Annotated[str | None, Query(description="Filter to one virtual key token hash")] = None, ) -> AutoRouterBenchmarksResponse: """ Benchmarks for the auto-router dashboard: session shape, savings against the configured @@ -681,6 +682,7 @@ async def get_auto_router_benchmarks( AUTOROUTER_BENCHMARKS_SQL, start_day.isoformat(), (end_day + timedelta(days=1)).isoformat(), + api_key, ) rows: Final = _SESSION_AGG_ROWS.validate_python(raw_rows or ()) groups: Final = ( diff --git a/tests/proxy_behavior/spend/test_autorouter_session_rollup.py b/tests/proxy_behavior/spend/test_autorouter_session_rollup.py index 7bc61c40ea0..c2272f3d20d 100644 --- a/tests/proxy_behavior/spend/test_autorouter_session_rollup.py +++ b/tests/proxy_behavior/spend/test_autorouter_session_rollup.py @@ -169,6 +169,7 @@ async def test_the_benchmarks_aggregate_reads_only_overlapping_sessions(db): AUTOROUTER_BENCHMARKS_SQL, (T0 - timedelta(days=1)).isoformat(), (T0 + timedelta(days=1)).isoformat(), + None, ) matching = [row for row in rows if row["router_name"] == router] assert len(matching) == 1 @@ -181,6 +182,33 @@ async def test_the_benchmarks_aggregate_reads_only_overlapping_sessions(db): assert grouped["session_seconds"] == pytest.approx(60.0) +async def test_the_benchmarks_aggregate_can_filter_to_one_key(db): + router = f"r-{uuid.uuid4()}" + first_key = f"k-{uuid.uuid4()}" + second_key = f"k-{uuid.uuid4()}" + await _turn(db, first_key, "A", T0, session_id=f"s-{uuid.uuid4()}", router=router, saved=0.5) + await _turn(db, second_key, "A", T0, session_id=f"s-{uuid.uuid4()}", router=router, saved=9.0) + + rows = await db.query_raw( + AUTOROUTER_BENCHMARKS_SQL, + (T0 - timedelta(days=1)).isoformat(), + (T0 + timedelta(days=1)).isoformat(), + first_key, + ) + matching = [row for row in rows if row["router_name"] == router] + assert len(matching) == 1 + assert matching[0]["sessions"] == 1 + assert matching[0]["saved_spend"] == pytest.approx(0.5) + + unknown_key_rows = await db.query_raw( + AUTOROUTER_BENCHMARKS_SQL, + (T0 - timedelta(days=1)).isoformat(), + (T0 + timedelta(days=1)).isoformat(), + f"k-{uuid.uuid4()}", + ) + assert [row for row in unknown_key_rows if row["router_name"] == router] == [] + + async def test_a_reconfigured_alias_reports_each_router_type_as_its_own_group(db): key = f"k-{uuid.uuid4()}" router = f"r-{uuid.uuid4()}" @@ -191,6 +219,7 @@ async def test_a_reconfigured_alias_reports_each_router_type_as_its_own_group(db AUTOROUTER_BENCHMARKS_SQL, (T0 - timedelta(days=1)).isoformat(), (T0 + timedelta(days=1)).isoformat(), + None, ) matching = sorted( (row for row in rows if row["router_name"] == router), @@ -253,6 +282,7 @@ async def test_the_benchmarks_aggregate_sums_tier_turns_across_sessions(db): AUTOROUTER_BENCHMARKS_SQL, (T0 - timedelta(days=1)).isoformat(), (T0 + timedelta(days=1)).isoformat(), + None, ) grouped = next(row for row in rows if row["router_name"] == router) assert grouped["tier_turns"] == {"simple": 2, "complex": 1} @@ -280,6 +310,7 @@ async def test_tier_maps_stay_separate_per_router_type_on_a_reconfigured_alias(d AUTOROUTER_BENCHMARKS_SQL, (T0 - timedelta(days=1)).isoformat(), (T0 + timedelta(days=1)).isoformat(), + None, ) by_type = {row["router_type"]: row["tier_turns"] for row in rows if row["router_name"] == router} assert by_type == {"complexity": {"medium": 1}, "quality": {"2": 1}} @@ -294,6 +325,7 @@ async def test_a_window_with_no_tiered_turns_aggregates_to_an_empty_map(db): AUTOROUTER_BENCHMARKS_SQL, (T0 - timedelta(days=1)).isoformat(), (T0 + timedelta(days=1)).isoformat(), + None, ) grouped = next(row for row in rows if row["router_name"] == router) assert grouped["tier_turns"] == {} diff --git a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py index 21f8d985f22..35e76c96c14 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py @@ -489,6 +489,7 @@ class TestAutoRouterBenchmarks: monkeypatch: pytest.MonkeyPatch, rows: Sequence[Mapping[str, object]], model_list: Sequence[object], + api_key: str | None = None, ) -> AutoRouterBenchmarksResponse: from litellm.proxy import proxy_server from litellm.proxy.management_endpoints.auto_router_endpoints import get_auto_router_benchmarks @@ -503,6 +504,7 @@ class TestAutoRouterBenchmarks: user_api_key_dict=ADMIN, start_date="2026-07-01", end_date="2026-08-01", + api_key=api_key, ) ROW = _SessionAggRow( @@ -652,8 +654,9 @@ class TestAutoRouterBenchmarks: user_api_key_dict=ADMIN, start_date="2026-07-01", end_date="2026-08-01", + api_key="key-hash", ) - assert captured["params"] == ("2026-07-01T00:00:00", "2026-08-02T00:00:00") + assert captured["params"] == ("2026-07-01T00:00:00", "2026-08-02T00:00:00", "key-hash") assert response.routers_in_scope == 1 assert response.groups[0].router_name == "live-auto" assert response.groups[0].saved_pct == response.totals.saved_pct == 75.0 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 ce0cd75cd36..e7c6adc478c 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 @@ -22,7 +22,7 @@ vi.mock("@/components/shared/advanced_date_picker", () => ({ import { useAutoRouters } from "@/app/(dashboard)/hooks/models/useModels"; -import AutoRouterBenchmarksTab from "./AutoRouterBenchmarksTab"; +import AutoRouterBenchmarksTab, { AutoRouterUsageView } from "./AutoRouterBenchmarksTab"; import type { AutoRouterBenchmarkGroup, AutoRouterBenchmarksResponse, @@ -359,13 +359,37 @@ describe("AutoRouterBenchmarksTab", () => { mockHook({ data: response([group()]) }); const { dateValue, onDateChange } = renderTab(); - expect(vi.mocked(useAutoRouterBenchmarks)).toHaveBeenCalledWith("sk-test", dateValue); + expect(vi.mocked(useAutoRouterBenchmarks)).toHaveBeenCalledWith("sk-test", dateValue, undefined); expect(screen.getByText("Jul 6 – Aug 5 (UTC)")).toBeInTheDocument(); fireEvent.click(screen.getByTestId("date-picker")); expect(onDateChange).toHaveBeenCalledWith({ from: new Date(2026, 7, 1), to: new Date(2026, 7, 5) }); }); + it("scopes the query to one key when the usage view is mounted for a key", () => { + mockHook({ data: response([group()]) }); + const dateValue = { from: new Date(2026, 6, 6), to: new Date(2026, 7, 5) }; + const activity = { + dateValue, + onDateChange: vi.fn(), + results: [], + loading: false, + isFetchingMore: false, + progress: { currentPage: 1, totalPages: 1 }, + cancelled: false, + cancel: vi.fn(), + }; + render( + + + , + ); + + expect(vi.mocked(useAutoRouterBenchmarks)).toHaveBeenCalledWith("sk-test", dateValue, "key-hash-1"); + expect(screen.getByText("Total estimated savings")).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Shadow Evals" })).not.toBeInTheDocument(); + }); + it("shows usage by default and mounts shadow evals only when its sub-tab is selected", () => { mockHook({ data: response([group()]) }); renderTab(); 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 09a0cf0242b..39e6b0fd390 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 @@ -273,12 +273,13 @@ const BenchmarksBody: React.FC = ({ isPending, error, data, interface AutoRouterBenchmarksTabProps { accessToken: string | null; - activity: DailyActivityRange; + activity: Pick; + apiKey?: string; } -const UsageView: React.FC = ({ accessToken, activity }) => { +export const AutoRouterUsageView: React.FC = ({ accessToken, activity, apiKey }) => { const { dateValue, onDateChange } = activity; - const { data, isPending, error } = useAutoRouterBenchmarks(accessToken, dateValue); + const { data, isPending, error } = useAutoRouterBenchmarks(accessToken, dateValue, apiKey); const [selectedKey, setSelectedKey] = useState(ALL_ROUTERS); const { data: autoRouters } = useAutoRouters(); @@ -347,7 +348,7 @@ const AutoRouterBenchmarksTab: React.FC = ({ acces - + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useAutoRouterBenchmarks.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useAutoRouterBenchmarks.test.ts index e8a858ef3a5..1258c967ec9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useAutoRouterBenchmarks.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useAutoRouterBenchmarks.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("@/components/networking", () => ({ formatDate: vi.fn() })); vi.mock("@/lib/http/api", () => ({ $api: { useQuery: vi.fn() } })); -import { benchmarksWindow } from "./useAutoRouterBenchmarks"; +import { $api } from "@/lib/http/api"; + +import { benchmarksWindow, useAutoRouterBenchmarks } from "./useAutoRouterBenchmarks"; const localDay = (offsetHours: number) => @@ -44,3 +46,30 @@ describe("benchmarksWindow", () => { expect(benchmarksWindow({ to: now }, now, pacific)).toEqual({}); }); }); + +describe("useAutoRouterBenchmarks", () => { + const range = { from: new Date("2026-07-06T19:00:00Z"), to: new Date("2026-08-05T19:00:00Z") }; + + it("forwards the key hash as the endpoint's api_key filter", () => { + useAutoRouterBenchmarks("sk-test", range, "key-hash-1"); + + const [, path, init] = vi.mocked($api.useQuery).mock.calls.at(-1) as unknown as [ + string, + string, + { params: { query: Record } }, + ]; + expect(path).toBe("/auto_router/benchmarks"); + expect(init.params.query.api_key).toBe("key-hash-1"); + }); + + it("leaves the read deployment-wide when no key is given", () => { + useAutoRouterBenchmarks("sk-test", range); + + const [, , init] = vi.mocked($api.useQuery).mock.calls.at(-1) as unknown as [ + string, + string, + { params: { query: Record } }, + ]; + expect(init.params.query.api_key).toBeUndefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useAutoRouterBenchmarks.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useAutoRouterBenchmarks.ts index 155c9d0c696..284342f0aec 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useAutoRouterBenchmarks.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useAutoRouterBenchmarks.ts @@ -23,10 +23,10 @@ export const benchmarksWindow = ( }; }; -export const useAutoRouterBenchmarks = (accessToken: string | null, range: DateRange) => +export const useAutoRouterBenchmarks = (accessToken: string | null, range: DateRange, apiKey?: string) => $api.useQuery( "get", "/auto_router/benchmarks", - { params: { query: benchmarksWindow(range, new Date()) } }, + { params: { query: { ...benchmarksWindow(range, new Date()), api_key: apiKey } } }, { enabled: Boolean(accessToken && range.from && range.to), retry: false }, ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.integration.test.tsx index 78280e32eed..51dfe9fda90 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.integration.test.tsx @@ -24,12 +24,18 @@ describe("useScopedDailyActivityRange wiring", () => { ); global.fetch = mockFetch; - renderHook(() => useScopedDailyActivityRange("sk-token", { userId: "u1", apiKey: "hash-abc" })); + const activity = { + dateValue: { from: new Date(2026, 7, 1), to: new Date(2026, 7, 10) }, + onDateChange: vi.fn(), + }; + renderHook(() => useScopedDailyActivityRange("sk-token", { userId: "u1", apiKey: "hash-abc" }, activity)); await waitFor(() => expect(mockFetch).toHaveBeenCalled()); const url = String(mockFetch.mock.calls[0][0]); expect(url).toContain("user_id=u1"); expect(url).toContain("api_key=hash-abc"); + expect(url).toContain("start_date=2026-08-01"); + expect(url).toContain("end_date=2026-08-10"); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx index 6c9281060a4..00902aa9fdd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx @@ -25,11 +25,19 @@ vi.mock("@/components/networking", () => ({ })); import { userDailyActivityAggregatedCall } from "@/components/networking"; -import { useDailyActivityRange } from "./useDailyActivityRange"; +import { useActivityDateRange, useDailyActivityRange } from "./useDailyActivityRange"; const argsOfLastCall = () => mockUsePaginatedDailyActivity.mock.calls.at(-1)?.[0].args as unknown[]; describe("useDailyActivityRange", () => { + it("offers date-range state without starting a daily-activity query", () => { + const { result } = renderHook(() => useActivityDateRange()); + + expect(result.current.dateValue.from).toBeInstanceOf(Date); + expect(result.current.dateValue.to).toBeInstanceOf(Date); + expect(mockUsePaginatedDailyActivity).not.toHaveBeenCalled(); + }); + it("queries every user's activity for an admin", () => { renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin")); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts index 8ed57e36a94..3435b57dbc8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts @@ -37,14 +37,20 @@ export interface DailyActivityScope { apiKey?: string | null; } -export const useScopedDailyActivityRange = ( - accessToken: string | null, - scope: DailyActivityScope, -): DailyActivityRange => { +export type ActivityDateRange = Pick; + +export const useActivityDateRange = (): ActivityDateRange => { const initialFrom = useMemo(() => new Date(new Date().getTime() - THIRTY_DAYS_MS), []); const initialTo = useMemo(() => new Date(), []); const [dateValue, setDateValue] = useState({ from: initialFrom, to: initialTo }); + return { dateValue, onDateChange: setDateValue }; +}; +export const useScopedDailyActivityRange = ( + accessToken: string | null, + scope: DailyActivityScope, + { dateValue, onDateChange }: ActivityDateRange, +): DailyActivityRange => { const startTime = dateValue.from ?? null; const endTime = dateValue.to ?? null; const { userId, apiKey = null } = scope; @@ -63,7 +69,7 @@ export const useScopedDailyActivityRange = ( return { dateValue, - onDateChange: setDateValue, + onDateChange, results: data.results as DailyData[], loading, isFetchingMore, @@ -77,7 +83,7 @@ export const useDailyActivityRange = ( accessToken: string | null, userId: string | null, userRole: string, -): DailyActivityRange => - useScopedDailyActivityRange(accessToken, { - userId: spendScopeUserId(userRole, userId), - }); +): DailyActivityRange => { + const dateRange = useActivityDateRange(); + return useScopedDailyActivityRange(accessToken, { userId: spendScopeUserId(userRole, userId) }, dateRange); +}; diff --git a/ui/litellm-dashboard/src/components/templates/KeyAutoRouterUsageTab.integration.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyAutoRouterUsageTab.integration.test.tsx new file mode 100644 index 00000000000..8cfd9d1941e --- /dev/null +++ b/ui/litellm-dashboard/src/components/templates/KeyAutoRouterUsageTab.integration.test.tsx @@ -0,0 +1,98 @@ +import { screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; +import KeyAutoRouterUsageTab from "./KeyAutoRouterUsageTab"; + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "test-token", userId: "admin-123", userRole: "Admin" }), +})); + +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + +const cache = { + coverage_pct: 100, + hit_rate_pct: 50, + same_model: { turns: 2, hits: 1, hit_rate_pct: 50 }, + first_visit: { turns: 1, hits: 0, hit_rate_pct: 0 }, + return_to_tier: { turns: 1, hits: 1, hit_rate_pct: 100 }, + unordered_turns: 0, + return_misses_expired: 0, + return_misses_within_ttl: 0, + return_misses_unknown: 0, + ttl_5m_turns: 0, + ttl_1h_turns: 0, +}; + +const stats = { + sessions: 2, + turns: 4, + avg_turns_per_session: 2, + avg_session_seconds: 30, + avg_tokens_per_session: 100, + spend: 1.25, + saved_spend: 8.75, + baseline_spend: 10, + saved_pct: 87.5, + saved_per_session: 4.375, + cache, +}; + +const benchmarks = { + start_date: "2025-01-01", + end_date: "2025-01-31", + routers_in_scope: 2, + totals: stats, + groups: [ + { router_name: "router-one", router_type: "complexity", tier_turns: { SIMPLE: 4 }, ...stats }, + { + router_name: "router-two", + router_type: "complexity", + tier_turns: { SIMPLE: 1 }, + ...stats, + spend: 0.25, + saved_spend: 0.75, + baseline_spend: 1, + }, + ], +}; + +const noDeployments = { data: [], total_count: 0, current_page: 1, total_pages: 1, size: 1000 }; +const fetchMock = vi.fn(async (request: Request | string) => { + const url = typeof request === "string" ? request : request.url; + if (url.includes("/auto_router/benchmarks")) return jsonResponse(benchmarks); + return jsonResponse(noDeployments); +}); +const requestedUrls = () => + fetchMock.mock.calls.map(([request]) => (typeof request === "string" ? request : request.url)); + +describe("KeyAutoRouterUsageTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + testQueryClient.clear(); + vi.stubGlobal("fetch", fetchMock); + }); + + it("renders this key's spend, baseline, savings and per-router filter", async () => { + const activity = { + dateValue: { from: new Date(2025, 0, 1), to: new Date(2025, 0, 31) }, + onDateChange: vi.fn(), + }; + renderWithProviders(); + + expect(await screen.findByText("$8.75")).toBeInTheDocument(); + expect(screen.getByText("Actual auto-router spend")).toBeInTheDocument(); + expect(screen.getByText("$1.25")).toBeInTheDocument(); + expect(screen.getByText("Estimated spend at highest-tier model")).toBeInTheDocument(); + expect(screen.getByText("$10.00")).toBeInTheDocument(); + expect(screen.getByText("Auto-router prompt caching")).toBeInTheDocument(); + expect(screen.getAllByText("50.0%").length).toBeGreaterThan(0); + expect(screen.getByText("All auto-routers")).toBeInTheDocument(); + + const benchmarkUrl = new URL(requestedUrls().find((url) => url.includes("/auto_router/benchmarks")) ?? ""); + expect(benchmarkUrl.searchParams.get("api_key")).toBe("key-hash-1"); + expect(benchmarkUrl.searchParams.get("start_date")).toBe("2025-01-01"); + expect(benchmarkUrl.searchParams.get("end_date")).toBe("2025-01-31"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/templates/KeyAutoRouterUsageTab.tsx b/ui/litellm-dashboard/src/components/templates/KeyAutoRouterUsageTab.tsx new file mode 100644 index 00000000000..9e1cee1ec2e --- /dev/null +++ b/ui/litellm-dashboard/src/components/templates/KeyAutoRouterUsageTab.tsx @@ -0,0 +1,18 @@ +"use client"; + +import React from "react"; + +import { AutoRouterUsageView } from "@/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab"; +import type { ActivityDateRange } from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange"; + +interface KeyAutoRouterUsageTabProps { + accessToken: string | null; + keyToken: string; + activity: ActivityDateRange; +} + +const KeyAutoRouterUsageTab: React.FC = ({ accessToken, keyToken, activity }) => ( + +); + +export default KeyAutoRouterUsageTab; diff --git a/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx b/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx index 6c7d65ee566..385c3967d02 100644 --- a/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx @@ -46,6 +46,11 @@ const mockActivity = ( const scopedRange = () => vi.spyOn(useScopedDailyActivityRangeModule, "useScopedDailyActivityRange"); +const activity = { + dateValue: { from: new Date(2025, 0, 1), to: new Date(2025, 0, 31) }, + onDateChange: vi.fn(), +}; + const renderTab = (props: Partial> = {}) => render( > = keyToken="key-abc123" userId="user-123" userRole="Internal User" + activity={activity} {...props} />, ); @@ -114,7 +120,7 @@ describe("KeySavingsTab", () => { renderTab({ userId: "user-456", userRole: "Internal User" }); - expect(hook).toHaveBeenCalledWith("test-token", { userId: "user-456", apiKey: "key-abc123" }); + expect(hook).toHaveBeenCalledWith("test-token", { userId: "user-456", apiKey: "key-abc123" }, activity); expect(screen.getByTestId("key-savings-scope-note")).toHaveTextContent("Showing your own requests"); }); @@ -123,7 +129,7 @@ describe("KeySavingsTab", () => { renderTab({ userId: "admin-123", userRole: "Admin" }); - expect(hook).toHaveBeenCalledWith("test-token", { userId: null, apiKey: "key-abc123" }); + expect(hook).toHaveBeenCalledWith("test-token", { userId: null, apiKey: "key-abc123" }, activity); expect(screen.queryByTestId("key-savings-scope-note")).not.toBeInTheDocument(); }); @@ -132,7 +138,7 @@ describe("KeySavingsTab", () => { renderTab({ userId: "org-admin-1", userRole: "Org Admin" }); - expect(hook).toHaveBeenCalledWith("test-token", { userId: "org-admin-1", apiKey: "key-abc123" }); + expect(hook).toHaveBeenCalledWith("test-token", { userId: "org-admin-1", apiKey: "key-abc123" }, activity); expect(screen.getByTestId("key-savings-scope-note")).toHaveTextContent("Showing your own requests"); }); }); diff --git a/ui/litellm-dashboard/src/components/templates/KeySavingsTab.tsx b/ui/litellm-dashboard/src/components/templates/KeySavingsTab.tsx index 967c3c7eff0..c33529eb042 100644 --- a/ui/litellm-dashboard/src/components/templates/KeySavingsTab.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeySavingsTab.tsx @@ -22,7 +22,10 @@ import { usd, withStartAnchor, } from "@/app/(dashboard)/cost-optimization/_components/costOptimizationUtils"; -import { useScopedDailyActivityRange } from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange"; +import { + useScopedDailyActivityRange, + type ActivityDateRange, +} from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange"; interface KeySavingsTabProps { accessToken: string | null; @@ -30,19 +33,19 @@ interface KeySavingsTabProps { keyToken: string; userId: string | null; userRole: string; + activity: ActivityDateRange; } -const KeySavingsTab: React.FC = ({ accessToken, keyToken, userId, userRole }) => { +const KeySavingsTab: React.FC = ({ accessToken, keyToken, userId, userRole, activity }) => { // Proxy admins read the whole key. For anyone else the endpoint applies the caller's own user_id // alongside the key filter, so the figures cover only that viewer's requests on this key -- said // plainly in the scope note below rather than left to be misread as the key's total. const readsWholeKey = hasProxyWideSpendView(userRole); - const activity = useScopedDailyActivityRange(accessToken, { - userId: spendScopeUserId(userRole, userId), - apiKey: keyToken, - }); - - const { dateValue, onDateChange, results, loading, isFetchingMore } = activity; + const { dateValue, onDateChange, results, loading, isFetchingMore } = useScopedDailyActivityRange( + accessToken, + { userId: spendScopeUserId(userRole, userId), apiKey: keyToken }, + activity, + ); const startTime = dateValue.from ?? null; const endTime = dateValue.to ?? null; diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx index 7a27cc41e5e..b403255b329 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx @@ -26,6 +26,30 @@ vi.mock("./key_edit_view", () => ({ }, })); +import type { ActivityDateRange } from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange"; + +const AnalyticsDateControl = ({ activity, keyToken }: { activity: ActivityDateRange; keyToken: string }) => ( +
+ {keyToken} + {activity.dateValue.from?.toISOString()} + {[1, 10].map((day) => ( + + ))} +
+); + +vi.mock("./KeyAutoRouterUsageTab", () => ({ + default: (props: React.ComponentProps) => , +})); +vi.mock("./KeySavingsTab", () => ({ + default: (props: React.ComponentProps) => , +})); + vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ default: vi.fn(), })); @@ -176,6 +200,38 @@ describe("KeyInfoView", () => { await userEvent.click(await screen.findByRole("button", { name: /more key actions/i })); }; + it("shows key-scoped auto-router usage as its own admin tab", async () => { + vi.mocked(useAuthorized).mockReturnValue({ ...baseUseAuthorizedMock, userRole: "Admin" }); + renderWithProviders( {}} keyId="test-key-id" teams={[]} />); + + await userEvent.click(screen.getByRole("tab", { name: "Auto-router usage" })); + + expect(screen.getByTestId("key-auto-router-usage")).toHaveTextContent("test-token-123"); + }); + + it("preserves dates in both directions across unmounted analytics panels", async () => { + vi.mocked(useAuthorized).mockReturnValue({ ...baseUseAuthorizedMock, userRole: "Admin" }); + renderWithProviders( {}} keyId="test-key-id" teams={[]} />); + + expect(screen.queryByLabelText("Selected dates")).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("tab", { name: "Savings" })); + await userEvent.click(screen.getByRole("button", { name: "Select August 1" })); + await userEvent.click(screen.getByRole("tab", { name: "Auto-router usage" })); + expect(screen.getByLabelText("Selected dates")).toHaveTextContent("2026-08-01T00:00:00.000Z"); + await userEvent.click(screen.getByRole("button", { name: "Select August 10" })); + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + expect(screen.queryByLabelText("Selected dates")).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("tab", { name: "Savings" })); + expect(screen.getByLabelText("Selected dates")).toHaveTextContent("2026-08-10T00:00:00.000Z"); + }); + + it("does not offer the admin-only auto-router usage tab to an internal user", () => { + vi.mocked(useAuthorized).mockReturnValue({ ...baseUseAuthorizedMock, userRole: "Internal User" }); + renderWithProviders( {}} keyId="test-key-id" teams={[]} />); + + expect(screen.queryByRole("tab", { name: "Auto-router usage" })).not.toBeInTheDocument(); + }); + describe("last updated", () => { const renderWithTimestamps = (overrides: Partial) => { vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock); diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 0dd0dd6d6af..f5c682a2ee0 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -16,8 +16,15 @@ import { modelGroupHref, teamDetailHref } from "@/utils/entityLinks"; import { BadgeLink } from "@/components/shared/BadgeLink"; import { KeyInfoHeader } from "./KeyInfoHeader"; import KeySavingsTab from "./KeySavingsTab"; +import KeyAutoRouterUsageTab from "./KeyAutoRouterUsageTab"; +import { useActivityDateRange } from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange"; import { useEffect, useState } from "react"; -import { isProxyAdminRole, isUserTeamAdminForSingleTeam, rolesWithWriteAccess } from "../../utils/roles"; +import { + hasProxyWideSpendView, + isProxyAdminRole, + isUserTeamAdminForSingleTeam, + rolesWithWriteAccess, +} from "../../utils/roles"; import { mapDisplayToInternalNames, mapInternalToDisplayNames } from "../callback_info_helpers"; import AutoRotationView from "../common_components/AutoRotationView"; import DeleteResourceModal from "../common_components/DeleteResourceModal"; @@ -81,6 +88,7 @@ export default function KeyInfoView({ backButtonText = "Back to Keys", }: KeyInfoViewProps) { const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized(); + const activityDateRange = useActivityDateRange(); const queryClient = useQueryClient(); const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole)); const { teams: teamsData } = useTeams(); @@ -618,6 +626,11 @@ export default function KeyInfoView({ Savings + {hasProxyWideSpendView(userRole) && ( + + Auto-router usage + + )} Settings @@ -761,9 +774,20 @@ export default function KeyInfoView({ keyToken={currentKeyData.token} userId={userID} userRole={userRole} + activity={activityDateRange} />
+ {hasProxyWideSpendView(userRole) && ( + + + + )} + {/* Settings Panel */} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 36fd744efc0..842a3da4122 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -41393,6 +41393,8 @@ export interface operations { start_date?: string | null; /** @description YYYY-MM-DD UTC, inclusive (defaults to today) */ end_date?: string | null; + /** @description Filter to one virtual key token hash */ + api_key?: string | null; }; header?: never; path?: never;