mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-27 06:13:07 +00:00
feat(ui): sort virtual keys by expiry, activity, spend and limits (#48074)
This commit is contained in:
parent
7a75cf273e
commit
1aa531767c
7 changed files with 252 additions and 31 deletions
|
|
@ -7397,20 +7397,26 @@ async def key_aliases(
|
|||
)
|
||||
|
||||
|
||||
def _validate_sort_params(sort_by: str | None, sort_order: str) -> dict[str, str] | None:
|
||||
order_by: Final[dict[str, str]] = {}
|
||||
|
||||
def _validate_sort_params(
|
||||
sort_by: str | None, sort_order: str
|
||||
) -> Mapping[str, object] | Sequence[Mapping[str, object]] | None:
|
||||
if sort_by is None:
|
||||
return None
|
||||
# Validate sort_by is a valid column
|
||||
valid_columns: Final = [
|
||||
valid_columns: Final = (
|
||||
"spend",
|
||||
"max_budget",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"token",
|
||||
"key_alias",
|
||||
]
|
||||
"expires",
|
||||
"last_active",
|
||||
"budget_reset_at",
|
||||
"total_spend",
|
||||
"tpm_limit",
|
||||
"rpm_limit",
|
||||
)
|
||||
if sort_by not in valid_columns:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
@ -7424,9 +7430,14 @@ def _validate_sort_params(sort_by: str | None, sort_order: str) -> dict[str, str
|
|||
detail={"error": "Invalid sort order. Must be 'asc' or 'desc'"},
|
||||
)
|
||||
|
||||
order_by[sort_by] = sort_order.lower()
|
||||
|
||||
return order_by
|
||||
direction: Final = sort_order.lower()
|
||||
nullable_sort_columns: Final = ("expires", "last_active", "budget_reset_at", "tpm_limit", "rpm_limit")
|
||||
if sort_by not in (*nullable_sort_columns, "total_spend"):
|
||||
return {sort_by: direction}
|
||||
return [
|
||||
{sort_by: {"sort": direction, "nulls": "last"}} if sort_by in nullable_sort_columns else {sort_by: direction},
|
||||
{"token": "asc"},
|
||||
]
|
||||
|
||||
|
||||
def _build_expires_where_clause(expires_filter: str, now: datetime) -> dict[str, object]:
|
||||
|
|
@ -7668,7 +7679,7 @@ async def list_key_helper(
|
|||
|
||||
verbose_proxy_logger.debug("Pagination: skip=%s, take=%s", skip, size)
|
||||
|
||||
order_by: Final[dict[str, str] | None] = (
|
||||
order_by: Final = (
|
||||
_validate_sort_params(sort_by, sort_order) if sort_by is not None and isinstance(sort_by, str) else None
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
from typing import FrozenSet
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from itertools import chain
|
||||
from typing import Final, FrozenSet
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.utils import hash_token
|
||||
|
||||
from tests.integration._support.actors import TEAM_ALPHA, Actor
|
||||
from tests.integration._support.actors import TEAM_ALPHA, Actor, ManagementDatabase, ScratchResources
|
||||
from tests.integration._support.actors import create_scratch_key
|
||||
from tests.integration._support.client import Gateway, Scenario
|
||||
from litellm.repositories.table_repositories import DeletedVerificationTokenRepository
|
||||
from litellm.repositories.verification_token_repository import VerificationTokenRepository
|
||||
|
||||
pytestmark = pytest.mark.asyncio(loop_scope="session")
|
||||
|
||||
|
|
@ -202,3 +207,74 @@ async def test_key_list_non_admin_cannot_filter_other_team(gateway_client, actor
|
|||
},
|
||||
)
|
||||
assert resp.status_code == 403, resp.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field", ("expires", "last_active", "budget_reset_at", "total_spend", "tpm_limit", "rpm_limit")
|
||||
)
|
||||
@pytest.mark.parametrize("direction", ("asc", "desc"))
|
||||
@pytest.mark.parametrize("deleted", (False, True), ids=("live", "deleted"))
|
||||
async def test_key_list_sorts_across_pages_with_nulls_last(
|
||||
field: str,
|
||||
direction: str,
|
||||
deleted: bool,
|
||||
gateway: Gateway,
|
||||
scenario: Scenario,
|
||||
db: ManagementDatabase,
|
||||
resources: ScratchResources,
|
||||
) -> None:
|
||||
team_admin: Final = scenario.user(user_role="internal_user")
|
||||
owner: Final = scenario.user(user_role="internal_user")
|
||||
team_id: Final = scenario.team(
|
||||
members_with_roles=[{"user_id": team_admin, "role": "admin"}, {"user_id": owner, "role": "user"}]
|
||||
)
|
||||
team_admin_key: Final = scenario.key(user_id=team_admin)
|
||||
table: Final = (
|
||||
DeletedVerificationTokenRepository(db.prisma).table if deleted else VerificationTokenRepository(db.prisma).table
|
||||
)
|
||||
values: Final = (20, 3, 3, 100, 0 if field == "total_spend" else None)
|
||||
tokens: Final = tuple(hash_token(f"{resources.prefix}-{index}") for index in range(len(values)))
|
||||
rows: Final = [
|
||||
{
|
||||
"token": token,
|
||||
"key_alias": resources.prefix,
|
||||
"team_id": team_id,
|
||||
"user_id": owner,
|
||||
field: (
|
||||
datetime(2030, 1, 1, tzinfo=timezone.utc) + timedelta(days=value)
|
||||
if field in ("expires", "last_active", "budget_reset_at") and value is not None
|
||||
else value
|
||||
),
|
||||
}
|
||||
for token, value in zip(tokens, values, strict=True)
|
||||
]
|
||||
await table.create_many(rows)
|
||||
try:
|
||||
valued: Final = tuple((value, token) for value, token in zip(values, tokens, strict=True) if value is not None)
|
||||
expected: Final = [
|
||||
token
|
||||
for _, token in sorted(valued, key=lambda item: (item[0] if direction == "asc" else -item[0], item[1]))
|
||||
] + [token for value, token in zip(values, tokens, strict=True) if value is None]
|
||||
for caller in (gateway.key, team_admin_key):
|
||||
pages: Final = [
|
||||
gateway.client.get(
|
||||
"/key/list",
|
||||
headers={"Authorization": f"Bearer {caller}"},
|
||||
params={
|
||||
"key_alias": resources.prefix,
|
||||
"team_id": team_id,
|
||||
"include_team_keys": "true",
|
||||
"sort_by": field,
|
||||
"sort_order": direction,
|
||||
"size": "2",
|
||||
"page": str(page),
|
||||
**({"status": "deleted"} if deleted else {}),
|
||||
},
|
||||
)
|
||||
for page in (1, 2, 3)
|
||||
]
|
||||
assert all(page.status_code == 200 for page in pages), [page.text for page in pages]
|
||||
assert all(page.json()["total_count"] == len(tokens) for page in pages)
|
||||
assert list(chain.from_iterable(page.json()["keys"] for page in pages)) == expected
|
||||
finally:
|
||||
await table.delete_many(where={"token": {"in": tokens}})
|
||||
|
|
|
|||
|
|
@ -427,6 +427,57 @@ it("sorts by spend ascending when 'Spend ascending' is chosen from the Spend / B
|
|||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["expires", "Expires", "asc"],
|
||||
["last_active", "Last Active", "asc"],
|
||||
["budget_reset_at", "Budget Reset", "asc"],
|
||||
["total_spend", "Lifetime Spend", "desc"],
|
||||
])("sorts %s in both directions and resets pagination", async (field, label, direction) => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VirtualKeysTable />, { searchParams: { page: "2" } });
|
||||
if (field === "expires") {
|
||||
await user.click(screen.getByRole("button", { name: "Columns" }));
|
||||
await user.click(await screen.findByText("Expires"));
|
||||
await user.keyboard("{Escape}");
|
||||
}
|
||||
await user.click(screen.getByRole("button", { name: label, exact: true }));
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(
|
||||
1,
|
||||
50,
|
||||
expect.objectContaining({ sortBy: field, sortOrder: direction }),
|
||||
);
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: label, exact: true }));
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(
|
||||
1,
|
||||
50,
|
||||
expect.objectContaining({ sortBy: field, sortOrder: direction === "asc" ? "desc" : "asc" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["TPM", "tpm_limit"],
|
||||
["RPM", "rpm_limit"],
|
||||
])("sorts by %s through the rate-limit menu", async (label, field) => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
await user.click(screen.getByRole("button", { name: "Columns" }));
|
||||
await user.click(await screen.findByText("Rate Limits"));
|
||||
await user.keyboard("{Escape}");
|
||||
await chooseSelectOption(
|
||||
user,
|
||||
screen.getByRole("button", { name: "Sort options for TPM or RPM" }),
|
||||
`${label} ascending`,
|
||||
"menuitem",
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortBy: field, sortOrder: "asc" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking the key cell deep-links via ?key=", async () => {
|
||||
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
|
||||
renderWithProviders(<VirtualKeysTable />, { onUrlUpdate });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Info } from "lucide-react";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
|
|
@ -28,6 +29,7 @@ import { orgDetailHref, teamDetailHref } from "@/utils/entityLinks";
|
|||
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
import { Organization } from "../networking";
|
||||
import { KEY_RATE_LIMIT_SORT_FIELDS } from "../key_team_helpers/key_sorting";
|
||||
|
||||
interface KeyStatus {
|
||||
tone: StatusTone;
|
||||
|
|
@ -46,6 +48,11 @@ export const KEY_TABLE_SORT_FIELDS: readonly string[] = [
|
|||
"created_at",
|
||||
"updated_at",
|
||||
...SPEND_BUDGET_SORT_FIELDS.map((field) => field.id),
|
||||
"expires",
|
||||
"last_active",
|
||||
"budget_reset_at",
|
||||
"total_spend",
|
||||
...KEY_RATE_LIMIT_SORT_FIELDS.map((field) => field.id),
|
||||
];
|
||||
|
||||
const getKeyStatus = (key: KeyResponse): KeyStatus => {
|
||||
|
|
@ -77,7 +84,7 @@ const getKeyStatus = (key: KeyResponse): KeyStatus => {
|
|||
};
|
||||
};
|
||||
|
||||
const InfoHeader = ({ label, tooltip }: { label: string; tooltip: string }) => (
|
||||
const InfoHeader = ({ label, tooltip }: { label: ReactNode; tooltip: string }) => (
|
||||
<span className="flex items-center gap-1">
|
||||
{label}
|
||||
<HoverCard>
|
||||
|
|
@ -252,23 +259,25 @@ export const getKeyTableColumns = ({
|
|||
id: "last_active",
|
||||
accessorKey: "last_active",
|
||||
meta: { title: "Last Active" },
|
||||
header: () => (
|
||||
header: ({ column }) => (
|
||||
<InfoHeader
|
||||
label="Last Active"
|
||||
label={<DataTableSortHeader column={column} title="Last Active" />}
|
||||
tooltip="This is a new field and is not backfilled. Only new key usage will update this value."
|
||||
/>
|
||||
),
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
enableSorting: true,
|
||||
sortDescFirst: false,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Unknown" />,
|
||||
},
|
||||
{
|
||||
id: "expires",
|
||||
accessorKey: "expires",
|
||||
meta: { title: "Expires" },
|
||||
header: "Expires",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Expires" />,
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
enableSorting: true,
|
||||
sortDescFirst: false,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
|
||||
},
|
||||
{
|
||||
|
|
@ -313,23 +322,25 @@ export const getKeyTableColumns = ({
|
|||
id: "total_spend",
|
||||
accessorKey: "total_spend",
|
||||
meta: { title: "Lifetime Spend" },
|
||||
header: () => (
|
||||
header: ({ column }) => (
|
||||
<InfoHeader
|
||||
label="Lifetime Spend"
|
||||
label={<DataTableSortHeader column={column} title="Lifetime Spend" />}
|
||||
tooltip="Cumulative spend across every budget period. Budget resets do not touch this value. Lifetime tracking started with LiteLLM v1.103.0 on September 19, 2026, so keys created earlier only count spend since that upgrade."
|
||||
/>
|
||||
),
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
enableSorting: true,
|
||||
sortDescFirst: true,
|
||||
cell: (info) => <MoneyCell value={info.getValue() as number | null | undefined} showZero />,
|
||||
},
|
||||
{
|
||||
id: "budget_reset_at",
|
||||
accessorKey: "budget_reset_at",
|
||||
meta: { title: "Budget Reset" },
|
||||
header: "Budget Reset",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Budget Reset" />,
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
enableSorting: true,
|
||||
sortDescFirst: false,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} fallback="Never" />,
|
||||
},
|
||||
{
|
||||
|
|
@ -350,7 +361,7 @@ export const getKeyTableColumns = ({
|
|||
{
|
||||
id: "rate_limits",
|
||||
meta: { title: "Rate Limits" },
|
||||
header: "Rate Limits",
|
||||
header: ({ table }) => <DataTableMultiSortHeader table={table} fields={KEY_RATE_LIMIT_SORT_FIELDS} />,
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import type { DataTableSortField } from "@/components/shared/DataTable";
|
||||
|
||||
export const KEY_RATE_LIMIT_SORT_FIELDS: DataTableSortField[] = [
|
||||
{ id: "tpm_limit", label: "TPM" },
|
||||
{ id: "rpm_limit", label: "RPM" },
|
||||
];
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi, MockedFunction } from "vitest";
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
import { fireEvent, renderWithProviders, screen, waitFor, within } from "../../../tests/test-utils";
|
||||
import { chooseSelectOption, fireEvent, renderWithProviders, screen, waitFor, within } from "../../../tests/test-utils";
|
||||
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
|
||||
import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import { useTeamMemberBudgets } from "@/app/(dashboard)/hooks/teams/useTeamMemberBudgets";
|
||||
|
|
@ -283,6 +283,53 @@ describe("TeamVirtualKeysTable", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["expires", "Expires", "asc"],
|
||||
["last_active", "Last Active", "asc"],
|
||||
["budget_reset_at", "Budget Reset", "asc"],
|
||||
["total_spend", "Lifetime Spend", "desc"],
|
||||
])("sorts %s in both directions while preserving the team scope", async (field, label, direction) => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
|
||||
await user.click(screen.getByRole("button", { name: label, exact: true }));
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(
|
||||
1,
|
||||
50,
|
||||
expect.objectContaining({ teamID: "team-1", sortBy: field, sortOrder: direction }),
|
||||
);
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: label, exact: true }));
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(
|
||||
1,
|
||||
50,
|
||||
expect.objectContaining({ teamID: "team-1", sortBy: field, sortOrder: direction === "asc" ? "desc" : "asc" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["TPM", "tpm_limit"],
|
||||
["RPM", "rpm_limit"],
|
||||
])("sorts by %s through the rate-limit menu", async (label, field) => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
|
||||
await chooseSelectOption(
|
||||
user,
|
||||
screen.getByRole("button", { name: "Sort options for TPM or RPM" }),
|
||||
`${label} ascending`,
|
||||
"menuitem",
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(
|
||||
1,
|
||||
50,
|
||||
expect.objectContaining({ teamID: "team-1", sortBy: field, sortOrder: "asc" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("resets to the first page when the sort changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseKeys.mockImplementation(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
DataTableFilterDrawer,
|
||||
DataTableFilterField,
|
||||
DataTableSortHeader,
|
||||
DataTableMultiSortHeader,
|
||||
DataTableToolbar,
|
||||
} from "@/components/shared/DataTable";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -31,6 +32,7 @@ import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
|
|||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
|
||||
import { deriveKeyModelScope } from "../key_scope";
|
||||
import { KEY_RATE_LIMIT_SORT_FIELDS } from "../key_team_helpers/key_sorting";
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
import { Organization } from "../networking";
|
||||
import KeyInfoView from "../templates/key_info_view";
|
||||
|
|
@ -278,17 +280,21 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
{
|
||||
id: "last_active",
|
||||
accessorKey: "last_active",
|
||||
header: "Last Active",
|
||||
meta: { title: "Last Active" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Last Active" />,
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
enableSorting: true,
|
||||
sortDescFirst: false,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Unknown" />,
|
||||
},
|
||||
{
|
||||
id: "expires",
|
||||
accessorKey: "expires",
|
||||
header: "Expires",
|
||||
meta: { title: "Expires" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Expires" />,
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
enableSorting: true,
|
||||
sortDescFirst: false,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
|
||||
},
|
||||
{
|
||||
|
|
@ -300,6 +306,16 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
enableSorting: true,
|
||||
cell: (info) => <MoneyCell value={info.getValue() as number | null} decimals={4} />,
|
||||
},
|
||||
{
|
||||
id: "total_spend",
|
||||
accessorKey: "total_spend",
|
||||
meta: { title: "Lifetime Spend", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Lifetime Spend" />,
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
sortDescFirst: true,
|
||||
cell: (info) => <MoneyCell value={info.getValue() as number | null | undefined} showZero />,
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
accessorKey: "max_budget",
|
||||
|
|
@ -327,9 +343,11 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
{
|
||||
id: "budget_reset_at",
|
||||
accessorKey: "budget_reset_at",
|
||||
header: "Budget Reset",
|
||||
meta: { title: "Budget Reset" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Budget Reset" />,
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
enableSorting: true,
|
||||
sortDescFirst: false,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} fallback="Never" />,
|
||||
},
|
||||
{
|
||||
|
|
@ -428,7 +446,8 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
},
|
||||
{
|
||||
id: "rate_limits",
|
||||
header: "Rate Limits",
|
||||
meta: { title: "Rate Limits" },
|
||||
header: ({ table }) => <DataTableMultiSortHeader table={table} fields={KEY_RATE_LIMIT_SORT_FIELDS} />,
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue