From 7a75cf273e7dede87d915cdbac827c788887ceb4 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 23 Oct 2026 15:38:32 -0700 Subject: [PATCH 01/27] fix(ui): align pass-through headers and authentication defaults (#48081) --- .../panels/PassThroughPanel.tsx | 6 +-- .../PassThroughSettings.tsx | 5 +- .../add_pass_through.integration.test.tsx | 47 +++++++++---------- .../src/components/add_pass_through.tsx | 26 +++------- .../PassThroughSecuritySection.tsx | 27 +---------- .../pass_through_info.integration.test.tsx | 36 ++++++++------ .../src/components/pass_through_info.tsx | 12 ++--- 7 files changed, 59 insertions(+), 100 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/PassThroughPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/PassThroughPanel.tsx index 7ae7e976651..ad08ff48a5b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/PassThroughPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/PassThroughPanel.tsx @@ -4,8 +4,6 @@ import PassThroughSettings from "@/components/PassThroughSettings/PassThroughSet import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function PassThroughPanel() { - const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); - return ( - - ); + const { accessToken, userRole, userId: userID } = useAuthorized(); + return ; } diff --git a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.tsx b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.tsx index 8ef2766d412..e45954d7c11 100644 --- a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.tsx +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.tsx @@ -19,7 +19,6 @@ interface PassThroughSettingsProps { accessToken: string | null; userRole: string | null; userID: string | null; - premiumUser?: boolean; } export interface passThroughItem { @@ -37,7 +36,7 @@ export interface passThroughItem { is_from_config?: boolean; } -const PassThroughSettings: React.FC = ({ accessToken, userRole, userID, premiumUser }) => { +const PassThroughSettings: React.FC = ({ accessToken, userRole, userID }) => { const [generalSettings, setGeneralSettings] = useState([]); const [isLoading, setIsLoading] = useState(true); const [selectedEndpointId, setSelectedEndpointId] = useState(null); @@ -116,7 +115,6 @@ const PassThroughSettings: React.FC = ({ accessToken, onClose={() => setSelectedEndpointId(null)} accessToken={accessToken} isAdmin={userRole === "Admin" || userRole === "admin"} - premiumUser={premiumUser} onEndpointUpdated={handleEndpointUpdated} /> ); @@ -133,7 +131,6 @@ const PassThroughSettings: React.FC = ({ accessToken, accessToken={accessToken} setPassThroughItems={setGeneralSettings} passThroughItems={generalSettings} - premiumUser={premiumUser} /> userEvent.setup({ pointerEventsCheck: PointerEventsCheckLeve type User = ReturnType; -const renderForm = (premiumUser = true) => +const renderForm = () => renderWithProviders( - , + , ); const openModal = async (user: User) => { @@ -82,7 +77,7 @@ const reopenedPayload = { include_subpath: true, methods: undefined, default_query_params: undefined, - auth: undefined, + auth: true, timeout: undefined, cost_per_request: undefined, }; @@ -93,7 +88,7 @@ describe("add_pass_through submit payload", () => { createPassThroughEndpoint.mockResolvedValue({ endpoints: [{ id: "generated-id", path: "/bria" }] }); }); - it("sends exactly the antd payload, once, for a minimal endpoint", async () => { + it("sends the configured headers and numeric fields once", async () => { const user = setup(); renderForm(); await openModal(user); @@ -114,7 +109,7 @@ describe("add_pass_through submit payload", () => { include_subpath: true, methods: undefined, default_query_params: undefined, - auth: undefined, + auth: true, timeout: "900", cost_per_request: "1.5", }); @@ -161,32 +156,37 @@ describe("add_pass_through submit payload", () => { expect(lastPayload().methods).toStrictEqual(["POST"]); }); - it("sends auth true once the premium security toggle is switched on", async () => { + it("creates an authenticated endpoint without adding headers", async () => { const user = setup(); - renderForm(true); + renderForm(); await openModal(user); - await fillRequiredFields(user); - - const switches = screen.getAllByRole("switch"); - expect(switches).toHaveLength(2); - await user.click(switches[1]); + fireEvent.change(screen.getByPlaceholderText("bria"), { target: { value: "example" } }); + fireEvent.change(screen.getByPlaceholderText("https://engine.prod.bria-api.com"), { + target: { value: "https://example.com" }, + }); + expect(screen.getByRole("switch", { name: "Require LiteLLM authentication" })).toBeChecked(); await submit(user); - await waitFor(() => expect(createPassThroughEndpoint).toHaveBeenCalled()); - expect(lastPayload().auth).toBe(true); + await waitFor(() => expect(createPassThroughEndpoint).toHaveBeenCalledOnce()); + expect(lastPayload()).toMatchObject({ path: "/example", headers: {}, auth: true }); }); - it("omits the auth key entirely for a non-premium user", async () => { + it("saves authentication off with all headers removed and resets it on reopening", async () => { const user = setup(); - renderForm(false); + renderForm(); await openModal(user); await fillRequiredFields(user); + await user.click(screen.getByRole("button", { name: "Remove header 1" })); + await user.click(screen.getByRole("switch", { name: "Require LiteLLM authentication" })); await submit(user); - await waitFor(() => expect(createPassThroughEndpoint).toHaveBeenCalled()); - expect(lastPayload()).not.toHaveProperty("auth"); + await waitFor(() => expect(createPassThroughEndpoint).toHaveBeenCalledOnce()); + expect(lastPayload()).toMatchObject({ headers: {}, auth: false }); + await openModal(user); + expect(screen.getByRole("switch", { name: "Require LiteLLM authentication" })).toBeChecked(); + expect(screen.queryByPlaceholderText("Header Name")).not.toBeInTheDocument(); }); it("drops include_subpath to false when the toggle is switched off", async () => { @@ -212,7 +212,6 @@ describe("add_pass_through submit payload", () => { expect(await screen.findByText("Path is required")).toBeInTheDocument(); expect(screen.getByText("Target URL is required")).toBeInTheDocument(); - expect(screen.getByText("Please configure the headers")).toBeInTheDocument(); expect(createPassThroughEndpoint).not.toHaveBeenCalled(); }); diff --git a/ui/litellm-dashboard/src/components/add_pass_through.tsx b/ui/litellm-dashboard/src/components/add_pass_through.tsx index a240f1f6bce..10a1e1a90ac 100644 --- a/ui/litellm-dashboard/src/components/add_pass_through.tsx +++ b/ui/litellm-dashboard/src/components/add_pass_through.tsx @@ -41,11 +41,9 @@ const passThroughFormSchema = z.object({ .pipe(z.url({ error: "Please enter a valid URL" })), methods: z.array(z.string()).optional(), include_subpath: z.boolean(), - headers: keyValuePairsSchema.refine((pairs) => pairs.some(([name]) => name !== ""), { - error: "Please configure the headers", - }), + headers: keyValuePairsSchema, default_query_params: keyValuePairsSchema.optional(), - auth: z.boolean().optional(), + auth: z.boolean(), timeout: z.string().optional(), cost_per_request: z.string().optional(), }); @@ -59,7 +57,7 @@ const emptyFormValues = { include_subpath: true, headers: [], default_query_params: undefined, - auth: undefined, + auth: true, timeout: undefined, cost_per_request: undefined, } as unknown as PassThroughFormValues; @@ -88,14 +86,12 @@ interface AddFallbacksProps { accessToken: string; passThroughItems: passThroughItem[]; setPassThroughItems: React.Dispatch>; - premiumUser?: boolean; } const AddPassThroughEndpoint: React.FC = ({ accessToken, setPassThroughItems, passThroughItems, - premiumUser = false, }) => { const [isModalVisible, setIsModalVisible] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -123,7 +119,7 @@ const AddPassThroughEndpoint: React.FC = ({ include_subpath: values.include_subpath, headers: toRecord(values.headers), default_query_params: optionalRecord(values.default_query_params), - ...(premiumUser ? { auth: values.auth } : {}), + auth: values.auth, timeout: values.timeout, cost_per_request: values.cost_per_request, ...(Object.keys(guardrails).length > 0 ? { guardrails } : {}), @@ -268,14 +264,12 @@ const AddPassThroughEndpoint: React.FC = ({ control={form.control} name="headers" label={labelWithHint( - "Authentication Headers", + "Headers (Optional)", "Authentication and other headers to forward with requests", )} description={ <> - - Add authentication tokens and other required headers - + Add headers only if required by the target API Common examples: auth_token, Authorization, x-api-key } @@ -313,13 +307,7 @@ const AddPassThroughEndpoint: React.FC = ({ - {({ value, onChange }) => ( - - )} + {({ value, onChange }) => } diff --git a/ui/litellm-dashboard/src/components/common_components/PassThroughSecuritySection.tsx b/ui/litellm-dashboard/src/components/common_components/PassThroughSecuritySection.tsx index c7aac09fd04..c6c167ce9f0 100644 --- a/ui/litellm-dashboard/src/components/common_components/PassThroughSecuritySection.tsx +++ b/ui/litellm-dashboard/src/components/common_components/PassThroughSecuritySection.tsx @@ -4,41 +4,18 @@ import { Card } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; export interface PassThroughSecuritySectionProps { - premiumUser: boolean; authEnabled: boolean; onAuthChange: (checked: boolean) => void; } -const PassThroughSecuritySection: React.FC = ({ - premiumUser, - authEnabled, - onAuthChange, -}) => { +const PassThroughSecuritySection: React.FC = ({ authEnabled, onAuthChange }) => { return (

Security

When enabled, requests to this endpoint will require a valid LiteLLM Virtual Key

- {premiumUser ? ( - - ) : ( -
-
- - Authentication (Premium) -
-
-

- Setting authentication for pass-through endpoints is a LiteLLM Enterprise feature. Get a trial key{" "} - - here - - . -

-
-
- )} +
); }; diff --git a/ui/litellm-dashboard/src/components/pass_through_info.integration.test.tsx b/ui/litellm-dashboard/src/components/pass_through_info.integration.test.tsx index 90994a8a2cd..051195c72b4 100644 --- a/ui/litellm-dashboard/src/components/pass_through_info.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/pass_through_info.integration.test.tsx @@ -31,16 +31,8 @@ const endpoint = { methods: ["GET"], }; -const renderView = (premiumUser = true, data = endpoint) => - renderWithProviders( - , - ); +const renderView = (data = endpoint) => + renderWithProviders(); const openEditForm = async (user: User) => { await user.click(screen.getByRole("tab", { name: "Settings" })); @@ -122,20 +114,34 @@ describe("pass_through_info update payload", () => { expect(lastPayload().headers).toStrictEqual({ "x-api-key": "k" }); }); - it("sends auth undefined for a non-premium user", async () => { + it.each([true, false])("loads auth %s and persists the opposite choice", async (auth) => { const user = setup(); - renderView(false); + renderView({ ...endpoint, headers: {}, auth }); await openEditForm(user); + const toggle = screen.getByRole("switch", { name: "Require LiteLLM authentication" }); + if (auth) { + expect(toggle).toBeChecked(); + } else { + expect(toggle).not.toBeChecked(); + } + await user.click(toggle); await save(user); - await waitFor(() => expect(updatePassThroughEndpoint).toHaveBeenCalled()); - expect(lastPayload().auth).toBeUndefined(); + await waitFor(() => expect(updatePassThroughEndpoint).toHaveBeenCalledOnce()); + expect(lastPayload()).toMatchObject({ headers: {}, auth: !auth }); + await openEditForm(user); + const savedToggle = screen.getByRole("switch", { name: "Require LiteLLM authentication" }); + if (auth) { + expect(savedToggle).not.toBeChecked(); + } else { + expect(savedToggle).toBeChecked(); + } }); it("sends methods undefined when the endpoint has none", async () => { const user = setup(); - renderView(true, { ...endpoint, methods: [] }); + renderView({ ...endpoint, methods: [] }); await openEditForm(user); await save(user); diff --git a/ui/litellm-dashboard/src/components/pass_through_info.tsx b/ui/litellm-dashboard/src/components/pass_through_info.tsx index f44afdd9bf8..3925443fa86 100644 --- a/ui/litellm-dashboard/src/components/pass_through_info.tsx +++ b/ui/litellm-dashboard/src/components/pass_through_info.tsx @@ -91,7 +91,6 @@ export interface PassThroughInfoProps { onClose: () => void; accessToken: string | null; isAdmin: boolean; - premiumUser?: boolean; onEndpointUpdated?: () => void; } @@ -139,7 +138,6 @@ const PassThroughInfoView: React.FC = ({ onClose, accessToken, isAdmin, - premiumUser = false, onEndpointUpdated, }) => { const [endpointData, setEndpointData] = useState(initialEndpointData); @@ -157,7 +155,7 @@ const PassThroughInfoView: React.FC = ({ include_subpath: initialEndpointData.include_subpath || false, cost_per_request: initialEndpointData.cost_per_request, timeout: initialEndpointData.timeout, - auth: initialEndpointData.auth || false, + auth: initialEndpointData.auth ?? true, }, }); @@ -189,7 +187,7 @@ const PassThroughInfoView: React.FC = ({ include_subpath: values.include_subpath, cost_per_request: values.cost_per_request, timeout: values.timeout, - auth: premiumUser ? values.auth : undefined, + auth: values.auth, methods: values.methods.length > 0 ? values.methods : undefined, guardrails: guardrails && Object.keys(guardrails).length > 0 ? guardrails : undefined, }; @@ -472,11 +470,7 @@ const PassThroughInfoView: React.FC = ({ {({ value, onChange }) => ( - + )} From 1aa531767ca67b2b791d9640bd23e11da8cd783a Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 23 Oct 2026 15:45:17 -0700 Subject: [PATCH 02/27] feat(ui): sort virtual keys by expiry, activity, spend and limits (#48074) --- .../key_management_endpoints.py | 29 ++++--- tests/integration/management/test_key_list.py | 80 ++++++++++++++++++- .../VirtualKeysPage/VirtualKeysTable.test.tsx | 51 ++++++++++++ .../VirtualKeysPage/keyTableColumns.tsx | 35 +++++--- .../key_team_helpers/key_sorting.ts | 6 ++ .../team/TeamVirtualKeysTable.test.tsx | 49 +++++++++++- .../components/team/TeamVirtualKeysTable.tsx | 33 ++++++-- 7 files changed, 252 insertions(+), 31 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/key_team_helpers/key_sorting.ts diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 824b310a1dd..eebc537509b 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -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 ) diff --git a/tests/integration/management/test_key_list.py b/tests/integration/management/test_key_list.py index b70a3782eda..4634314817b 100644 --- a/tests/integration/management/test_key_list.py +++ b/tests/integration/management/test_key_list.py @@ -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}}) diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index ccf7f453132..47fd554d196 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -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(, { 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(); + 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(); renderWithProviders(, { onUrlUpdate }); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx index 9751b9ebf2c..547298fa8cc 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx @@ -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 }) => ( {label} @@ -252,23 +259,25 @@ export const getKeyTableColumns = ({ id: "last_active", accessorKey: "last_active", meta: { title: "Last Active" }, - header: () => ( + header: ({ column }) => ( } 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) => , }, { id: "expires", accessorKey: "expires", meta: { title: "Expires" }, - header: "Expires", + header: ({ column }) => , size: 120, - enableSorting: false, + enableSorting: true, + sortDescFirst: false, cell: (info) => , }, { @@ -313,23 +322,25 @@ export const getKeyTableColumns = ({ id: "total_spend", accessorKey: "total_spend", meta: { title: "Lifetime Spend" }, - header: () => ( + header: ({ column }) => ( } 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) => , }, { id: "budget_reset_at", accessorKey: "budget_reset_at", meta: { title: "Budget Reset" }, - header: "Budget Reset", + header: ({ column }) => , size: 130, - enableSorting: false, + enableSorting: true, + sortDescFirst: false, cell: (info) => , }, { @@ -350,7 +361,7 @@ export const getKeyTableColumns = ({ { id: "rate_limits", meta: { title: "Rate Limits" }, - header: "Rate Limits", + header: ({ table }) => , size: 140, enableSorting: false, cell: ({ row }) => { diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_sorting.ts b/ui/litellm-dashboard/src/components/key_team_helpers/key_sorting.ts new file mode 100644 index 00000000000..b8a780efb21 --- /dev/null +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_sorting.ts @@ -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" }, +]; diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx index 5c3af3f6c3f..bb6204b5219 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx @@ -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(); + 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(); + 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( diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index 8ea559990c0..29a1cc232b6 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -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 }) => , size: 130, - enableSorting: false, + enableSorting: true, + sortDescFirst: false, cell: (info) => , }, { id: "expires", accessorKey: "expires", - header: "Expires", + meta: { title: "Expires" }, + header: ({ column }) => , size: 120, - enableSorting: false, + enableSorting: true, + sortDescFirst: false, cell: (info) => , }, { @@ -300,6 +306,16 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi enableSorting: true, cell: (info) => , }, + { + id: "total_spend", + accessorKey: "total_spend", + meta: { title: "Lifetime Spend", numeric: true }, + header: ({ column }) => , + size: 130, + enableSorting: true, + sortDescFirst: true, + cell: (info) => , + }, { 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 }) => , size: 130, - enableSorting: false, + enableSorting: true, + sortDescFirst: false, cell: (info) => , }, { @@ -428,7 +446,8 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi }, { id: "rate_limits", - header: "Rate Limits", + meta: { title: "Rate Limits" }, + header: ({ table }) => , size: 140, enableSorting: false, cell: ({ row }) => { From ffe3b5e07e96f0611db25c34b9f612b1ef04782c Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 23 Oct 2026 15:45:32 -0700 Subject: [PATCH 03/27] feat(ui): sort every organizations table data column (#48106) * feat(ui): sort every organizations table data column * fix(ui): normalize organization sorting and unlimited limits --- .../_components/OrganizationsTable.test.tsx | 84 ++++++++++++++++++- .../_components/OrganizationsTable.tsx | 1 + .../_components/OrganizationsTableColumns.tsx | 45 ++++++++-- .../_components/useOrganizationsTableState.ts | 12 ++- 4 files changed, 130 insertions(+), 12 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx index 839eb406200..724a72a77f3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx @@ -66,6 +66,85 @@ const baseProps = { }; describe("OrganizationsTable", () => { + it.each(["max_budget", "tpm_limit", "rpm_limit"])( + "sorts %s numerically with Unlimited as infinity", + async (field) => { + const user = userEvent.setup(); + const organizations = [20, 3, null, 100, 0].map((value, index) => + makeOrganization({ + organization_id: `org-${index}`, + organization_alias: `Value ${value}`, + litellm_budget_table: { [field]: value }, + }), + ); + renderWithProviders(, { + searchParams: { sort_by: field, sort_order: "asc", page_size: "25" }, + }); + const names = () => + screen + .getAllByRole("row") + .slice(1) + .map((row) => within(row).getByText(/^Value /).textContent); + expect(names()).toEqual(["Value 0", "Value 3", "Value 20", "Value 100", "Value null"]); + if (field === "max_budget") { + await user.click(screen.getByTestId("sort-header-max_budget")); + } else { + await user.click(screen.getByRole("button", { name: "Sort options for TPM or RPM" })); + await user.click( + await screen.findByRole("menuitem", { name: `${field === "tpm_limit" ? "TPM" : "RPM"} descending` }), + ); + } + await waitFor(() => expect(names()).toEqual(["Value null", "Value 100", "Value 20", "Value 3", "Value 0"])); + }, + ); + + it.each(["asc", "desc"])("groups both All Proxy Models representations when sorting %s", (direction) => { + const organizations = [ + makeOrganization({ organization_id: "org-empty", organization_alias: "Name Empty", models: [] }), + makeOrganization({ organization_id: "org-z", organization_alias: "Name Z", models: ["z"] }), + makeOrganization({ + organization_id: "org-wildcard", + organization_alias: "Name Wildcard", + models: ["all-proxy-models"], + }), + makeOrganization({ organization_id: "org-a", organization_alias: "Name A", models: ["a"] }), + ]; + renderWithProviders(, { + searchParams: { sort_by: "models", sort_order: direction }, + }); + const names = screen + .getAllByRole("row") + .slice(1) + .map((row) => within(row).getByText(/^Name /).textContent); + const models = direction === "asc" ? ["Name A", "Name Z"] : ["Name Z", "Name A"]; + expect(names).toEqual([...models, "Name Empty", "Name Wildcard"]); + expect(screen.getAllByText("All Proxy Models")).toHaveLength(2); + }); + + it.each(["models", "members"])("sorts %s by its displayed data", async (field) => { + const user = userEvent.setup(); + const organizations = [ + { organization_id: "org-zed", organization_alias: "Zed", models: ["z"], members: [] }, + { + organization_id: "org-ace", + organization_alias: "Ace", + models: ["a"], + members: [{ user_id: "1" }, { user_id: "2" }], + }, + { + organization_id: "org-mid", + organization_alias: "Mid", + models: ["m"], + members: [{ user_id: "3" }], + }, + ].map(makeOrganization); + renderWithProviders(); + await user.click(screen.getByTestId(`sort-header-${field}`)); + await waitFor(() => expect(bodyRowAliases()).toEqual(["Ace", "Mid", "Zed"])); + await user.click(screen.getByTestId(`sort-header-${field}`)); + await waitFor(() => expect(bodyRowAliases()).toEqual(["Zed", "Mid", "Ace"])); + }); + it("renders every column header", () => { renderWithProviders(); for (const header of [ @@ -75,7 +154,6 @@ describe("OrganizationsTable", () => { "Spend (USD)", "Budget (USD)", "Models", - "TPM / RPM Limits", "Members", ]) { expect(screen.getByText(header)).toBeInTheDocument(); @@ -87,7 +165,7 @@ describe("OrganizationsTable", () => { for (const header of ["Spend (USD)", "Budget (USD)", "Members"]) { expect(screen.getByRole("columnheader", { name: header })).toHaveClass("text-right"); } - for (const header of ["Organization Name", "TPM / RPM Limits"]) { + for (const header of ["Organization Name"]) { expect(screen.getByRole("columnheader", { name: header })).not.toHaveClass("text-right"); } }); @@ -270,7 +348,7 @@ describe("OrganizationsTable URL state", () => { it("falls back to sorting by creation date for a ?sort_by= column that cannot be sorted", () => { renderWithProviders(, { - searchParams: "?sort_by=members&sort_order=asc", + searchParams: "?sort_by=actions&sort_order=asc", }); expect(bodyRowAliases()).toEqual(["Zed", "Mid", "Ace"]); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx index a9ac0b7e699..df1c475ebb1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx @@ -62,6 +62,7 @@ const OrganizationsTable: React.FC = ({ onPaginationChange={onPaginationChange} columns={columns} getRowId={(organization, index) => organization.organization_id || String(index)} + defaultColumnVisibility={{ tpm_limit: false, rpm_limit: false }} sortingMode="client" sorting={sorting} onSortingChange={onSortingChange} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTableColumns.tsx index 5f170a32941..dcbf141314b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTableColumns.tsx @@ -3,7 +3,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"; -import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { DataTableMultiSortHeader, DataTableSortHeader } from "@/components/shared/DataTable"; import { DateCell, IdentityCell, ModelsCell, MoneyCell } from "@/components/shared/table_cells"; import { Organization } from "@/components/networking"; import { buttonVariants } from "@/components/ui/button"; @@ -137,38 +137,67 @@ export const getOrganizationsTableColumns = ({ }, { id: "max_budget", + accessorFn: (organization) => getOrganizationBudget(organization).max_budget ?? Number.POSITIVE_INFINITY, + sortUndefined: "last", + sortDescFirst: true, meta: { title: "Budget (USD)", numeric: true }, - header: "Budget (USD)", + header: ({ column }) => , size: 120, - enableSorting: false, + enableSorting: true, cell: ({ row }) => ( ), }, { id: "models", + accessorFn: (organization) => + organization.models?.length && !organization.models.includes("all-proxy-models") + ? [...organization.models].sort().join(", ") + : undefined, + sortUndefined: "last", + sortDescFirst: false, meta: { title: "Models", skeleton: "chips" }, - header: "Models", + header: ({ column }) => , size: 260, - enableSorting: false, + enableSorting: true, cell: ({ row }) => , }, { id: "limits", meta: { title: "TPM / RPM Limits" }, - header: "TPM / RPM Limits", + header: ({ table }) => ( + + ), size: 150, enableSorting: false, cell: ({ row }) => , }, { id: "members", + accessorFn: (organization) => organization.members?.length ?? 0, + sortUndefined: "last", + sortDescFirst: true, meta: { title: "Members", numeric: true }, - header: "Members", + header: ({ column }) => , size: 100, - enableSorting: false, + enableSorting: true, cell: ({ row }) => {row.original.members?.length ?? 0} Members, }, + ...(["tpm_limit", "rpm_limit"] as const).map( + (field): ColumnDef => ({ + id: field, + accessorFn: (organization) => getOrganizationBudget(organization)[field] ?? Number.POSITIVE_INFINITY, + sortUndefined: "last", + sortDescFirst: true, + enableHiding: false, + }), + ), { id: "actions", meta: { className: "text-right", headerClassName: "text-right" }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/useOrganizationsTableState.ts b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/useOrganizationsTableState.ts index 20a54a25ba1..ffe67c0aa14 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/useOrganizationsTableState.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/useOrganizationsTableState.ts @@ -4,7 +4,17 @@ const FILTER_COLUMNS = ["org_id"] as const; type FilterColumn = (typeof FILTER_COLUMNS)[number]; const TABLE_STATE_OPTIONS: UrlTableStateOptions = { - sortFields: ["organization_id", "organization_alias", "created_at", "spend"], + sortFields: [ + "organization_id", + "organization_alias", + "created_at", + "spend", + "max_budget", + "models", + "members", + "tpm_limit", + "rpm_limit", + ], defaultSort: { id: "created_at", desc: true }, defaultPageSize: 25, filterColumns: FILTER_COLUMNS, From e4766973a0466e2cdb99ad243aac0081d2bce83b Mon Sep 17 00:00:00 2001 From: Ishaan Date: Fri, 23 Oct 2026 15:54:26 -0700 Subject: [PATCH 04/27] feat(ui): give Aria usage charts and PDF answer exports (#48094) * fix(ui): preserve Aria usage token breakdown and viewer model access * fix(ui): make Aria conversations easier to read and reply to * fix(ui): wrap long headings in assistant tables * feat(ui): simplify Aria and add usage charts with PDF answer export * fix(ui): retain full chart values in Aria PDF exports --- .../test_user_api_key_auth_request_flow.py | 135 +++- .../_components/HomePage.integration.test.tsx | 3 + .../components/chat_ui/ChatComposer.test.tsx | 20 + .../components/chat_ui/ChatComposer.tsx | 15 +- .../src/components/chat/ChatMessages.tsx | 13 +- .../liteadmin/AriaAnswer.integration.test.tsx | 146 ++++ .../src/components/liteadmin/AriaAnswer.tsx | 47 ++ .../liteadmin/LiteAdmin.integration.test.tsx | 648 ++++++++++-------- .../src/components/liteadmin/LiteAdmin.tsx | 216 ++---- .../liteadmin/LiteAdminConversation.tsx | 25 +- .../components/liteadmin/UsageAnswerChart.tsx | 78 +++ .../src/components/liteadmin/agent.test.ts | 164 ++++- .../src/components/liteadmin/agent.ts | 1 + .../components/liteadmin/answerExport.test.ts | 134 ++++ .../src/components/liteadmin/answerExport.ts | 238 +++++++ .../src/components/liteadmin/operations.ts | 48 +- .../components/liteadmin/toolResults.test.ts | 185 ++++- .../src/components/liteadmin/toolResults.ts | 37 +- .../components/liteadmin/usageChart.test.ts | 198 ++++++ .../src/components/liteadmin/usageChart.ts | 148 ++++ .../src/components/liteadmin/useLiteAdmin.ts | 15 +- .../shared/charts/bar_chart.test.tsx | 22 + .../components/shared/charts/bar_chart.tsx | 13 +- 23 files changed, 2022 insertions(+), 527 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/liteadmin/AriaAnswer.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/liteadmin/AriaAnswer.tsx create mode 100644 ui/litellm-dashboard/src/components/liteadmin/UsageAnswerChart.tsx create mode 100644 ui/litellm-dashboard/src/components/liteadmin/answerExport.test.ts create mode 100644 ui/litellm-dashboard/src/components/liteadmin/answerExport.ts create mode 100644 ui/litellm-dashboard/src/components/liteadmin/usageChart.test.ts create mode 100644 ui/litellm-dashboard/src/components/liteadmin/usageChart.ts diff --git a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py index 4cdd7fefd28..108f7ce2983 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py @@ -11688,6 +11688,124 @@ def _alias_request(route: str, data: dict, content_type: str = "application/json return request +def _viewer_inference_key(monkeypatch: pytest.MonkeyPatch, team_id: str | None, *, spend: float = 0.25) -> str: + from litellm.proxy import proxy_server + from litellm.proxy.utils import ProxyLogging, hash_token + + key: Final = "sk-viewer-inference" + hashed_key: Final = hash_token(key) + cache: Final = UserApiKeyCache() + cache.set_cache( + key=hashed_key, + value=UserAPIKeyAuth( + token=hashed_key, + user_id="viewer", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + team_id=team_id, + models=["gpt-6.1-sol"], + max_budget=1.0, + spend=spend, + ), + ) + cache.set_cache( + key="viewer", + value=LiteLLM_UserTable( + user_id="viewer", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + user_email="viewer@example.com", + models=["gpt-6.1-sol"], + ), + ) + if team_id is not None: + cache.set_cache( + key=team_membership_reservation_cache_key(user_id="viewer", team_id=team_id), + value=NO_TEAM_MEMBERSHIP_SENTINEL, + ) + for name, value in { + **_proxy_attrs_for_centralized_checks(), + "prisma_client": MagicMock(), + "user_api_key_cache": cache, + "spend_counter_cache": DualCache(), + "proxy_logging_obj": ProxyLogging(user_api_key_cache=cache), + "llm_model_list": [], + }.items(): + monkeypatch.setattr(proxy_server, name, value) + return key + + +@pytest.mark.asyncio +@pytest.mark.parametrize("team_id", ["litellm-dashboard", None], ids=["dashboard-session", "virtual-key"]) +@pytest.mark.parametrize("route", ["/chat/completions", "/v1/chat/completions"]) +async def test_viewer_inference_uses_the_normal_key_authorization_path( + monkeypatch: pytest.MonkeyPatch, team_id: str | None, route: str +) -> None: + key: Final = _viewer_inference_key(monkeypatch, team_id) + request: Final = _alias_request(route, {"model": "gpt-6.1-sol", "messages": [{"role": "user", "content": "usage"}]}) + + result: Final = await user_api_key_auth(request=request, api_key=f"Bearer {key}") + + assert (result.user_id, result.user_role, result.team_id, result.models, result.max_budget) == ( + "viewer", + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + team_id, + ["gpt-6.1-sol"], + 1.0, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("team_id", ["litellm-dashboard", None], ids=["dashboard-session", "virtual-key"]) +async def test_viewer_inference_rejects_a_model_outside_the_key_allowlist( + monkeypatch: pytest.MonkeyPatch, team_id: str | None +) -> None: + key: Final = _viewer_inference_key(monkeypatch, team_id) + request: Final = _alias_request("/chat/completions", {"model": "blocked-model"}) + + with pytest.raises(ProxyException) as failure: + await user_api_key_auth(request=request, api_key=f"Bearer {key}") + + assert (failure.value.code, failure.value.type, failure.value.param) == ( + "403", + ProxyErrorTypes.key_model_access_denied, + "model", + ) + assert failure.value.message == ( + "The requested model 'blocked-model' is not available for this API key, or the model name is invalid. " + "Check the models available to you and try again." + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("team_id", ["litellm-dashboard", None], ids=["dashboard-session", "virtual-key"]) +@pytest.mark.parametrize("spend", [1.0, 1.25], ids=["at-limit", "over-limit"]) +async def test_viewer_inference_rejects_an_exhausted_key_budget( + monkeypatch: pytest.MonkeyPatch, team_id: str | None, spend: float +) -> None: + key: Final = _viewer_inference_key(monkeypatch, team_id, spend=spend) + request: Final = _alias_request("/chat/completions", {"model": "gpt-6.1-sol"}) + + with pytest.raises(ProxyException) as failure: + await user_api_key_auth(request=request, api_key=f"Bearer {key}") + + assert failure.value.type == ProxyErrorTypes.budget_exceeded + assert f"Current cost: {spend}, Max budget: 1.0" in failure.value.message + + +@pytest.mark.asyncio +@pytest.mark.parametrize("team_id", ["litellm-dashboard", None], ids=["dashboard-session", "virtual-key"]) +async def test_viewer_inference_access_does_not_allow_management_writes( + monkeypatch: pytest.MonkeyPatch, team_id: str | None +) -> None: + key: Final = _viewer_inference_key(monkeypatch, team_id) + request: Final = _alias_request("/team/new", {"team_alias": "forbidden"}) + + with pytest.raises(ProxyException) as failure: + await user_api_key_auth(request=request, api_key=f"Bearer {key}") + + assert failure.value.code == "403" + assert "proxy_admin_viewer" in failure.value.message + + async def _enforce_alias_access(token: UserAPIKeyAuth, data: dict, route: str, request, router: litellm.Router): from litellm.proxy.auth.user_api_key_auth import enforce_key_and_fallback_model_access @@ -12480,11 +12598,14 @@ async def test_lens_oauth2_validates_identity_without_inference_user_field( patch("litellm.proxy.proxy_server.master_key", "sk-fixture-master"), patch("litellm.proxy.proxy_server.prisma_client", None), patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()), - patch.dict(os.environ, { - "OAUTH_TOKEN_INFO_ENDPOINT": "https://oauth.fixture.test/introspect", - "OAUTH_CLIENT_ID": "fixture-client", - "OAUTH_CLIENT_SECRET": "fixture-client-secret", - }), + patch.dict( + os.environ, + { + "OAUTH_TOKEN_INFO_ENDPOINT": "https://oauth.fixture.test/introspect", + "OAUTH_CLIENT_ID": "fixture-client", + "OAUTH_CLIENT_SECRET": "fixture-client-secret", + }, + ), patch("httpx.AsyncClient.send", side_effect=token_info) as network, ): if active: @@ -12611,6 +12732,4 @@ def test_should_skip_budget_checks_spares_only_zero_spend_mcp_discovery( ) -> None: from litellm.proxy.auth.user_api_key_auth import _should_skip_budget_checks - assert ( - _should_skip_budget_checks(request_data=request_data, route=route, request=None, llm_router=None) is expected - ) + assert _should_skip_budget_checks(request_data=request_data, route=route, request=None, llm_router=None) is expected diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx index edde511d1cd..1891a580088 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx @@ -49,6 +49,9 @@ const requestUrl = (input: RequestInfo | URL): string => { }; const fakeFetch = (input: RequestInfo | URL) => { + if (requestUrl(input).endsWith("/models")) { + return Promise.resolve(Response.json({ data: [{ id: "claude-sonnet-5-5" }] })); + } if (requestUrl(input).includes("/model_group/info")) { return Promise.resolve(Response.json({ data: [{ model_group: "claude-sonnet-5-5", mode: "chat" }] })); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.test.tsx index da2e4debb3c..4903a431ff7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { ChatComposer } from "./ChatComposer"; @@ -111,4 +112,23 @@ describe("ChatComposer", () => { expect(screen.getByTestId("chat-composer-input")).toHaveFocus(); }); + + it.each([ + { label: "Find a model", control: }, + { + label: "Choose a model", + control: ( + + ), + }, + ])("should preserve focus when clicking the $label toolbar control", async ({ label, control }) => { + renderComposer({ tools: control }); + + await userEvent.setup().click(screen.getByLabelText(label)); + + expect(screen.getByLabelText(label)).toHaveFocus(); + expect(screen.getByPlaceholderText("Send a message")).not.toHaveFocus(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx index 5d2edbdfc94..c33b5e992fc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx @@ -51,6 +51,14 @@ export function ChatComposer({ } }; + const handleToolbarClick = (event: React.MouseEvent) => { + const control = (event.target as HTMLElement).closest( + "button, input, select, textarea, a[href], [contenteditable='true'], [tabindex]", + ); + if (event.defaultPrevented || (control && event.currentTarget.contains(control))) return; + event.currentTarget.parentElement?.querySelector("[data-slot=input-group-control]")?.focus(); + }; + return (
{showSuggestions && suggestions.length > 0 && ( @@ -99,12 +107,7 @@ export function ChatComposer({ { - if ((event.target as HTMLElement).closest("button")) { - return; - } - event.currentTarget.parentElement?.querySelector("[data-slot=input-group-control]")?.focus(); - }} + onClick={handleToolbarClick} >
{tools}
diff --git a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx index 5b331cc81bc..48ba5ddd0ae 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx @@ -70,14 +70,18 @@ function MarkdownCodeRenderer({ const markdownComponents: Components = { code: MarkdownCodeRenderer, pre: ({ node, ...props }) =>
,
+  h1: ({ node, ...props }) => 

, + h2: ({ node, ...props }) =>

, + h3: ({ node, ...props }) =>

, p: ({ node, ...props }) =>

, + blockquote: ({ node, ...props }) =>

, ul: ({ node, ...props }) =>
    , ol: ({ node, ...props }) =>
      , table: ({ node, ...props }) => , thead: ({ node, ...props }) => , tbody: ({ node, ...props }) => , tr: ({ node, ...props }) => , - th: ({ node, ...props }) => , + th: ({ node, ...props }) => , td: ({ node, ...props }) => , }; @@ -212,6 +216,7 @@ interface AssistantBubbleProps { isStreaming: boolean; isTypingIndicator: boolean; mcpEvents?: ChatMessage["mcpEvents"]; + actions?: React.ReactNode; } function AssistantBubble({ @@ -221,6 +226,7 @@ function AssistantBubble({ isStreaming, isTypingIndicator, mcpEvents, + actions, }: AssistantBubbleProps) { const [reasoningKey, setReasoningKey] = useState(0); const prevStreamingRef = useRef(isStreaming); @@ -271,7 +277,7 @@ function AssistantBubble({ {stoppedSuffix && [stopped]} - + {actions ?? } {mcpEvents && mcpEvents.length > 0 && (
      @@ -421,6 +427,7 @@ interface Props { interface ChatMessageContentProps { message: ChatMessage; + actions?: React.ReactNode; allowImages?: boolean; isStreaming?: boolean; isLastMessage?: boolean; @@ -433,6 +440,7 @@ export function ChatMessageContent({ isStreaming = false, isLastMessage = true, onEditMessage, + actions, }: ChatMessageContentProps) { if (message.role === "user") { return ; @@ -450,6 +458,7 @@ export function ChatMessageContent({ isStreaming={isStreaming} isTypingIndicator={isLastMessage && isStreaming && message.content === ""} mcpEvents={message.mcpEvents} + actions={actions} /> ); } diff --git a/ui/litellm-dashboard/src/components/liteadmin/AriaAnswer.integration.test.tsx b/ui/litellm-dashboard/src/components/liteadmin/AriaAnswer.integration.test.tsx new file mode 100644 index 00000000000..6040ae1f115 --- /dev/null +++ b/ui/litellm-dashboard/src/components/liteadmin/AriaAnswer.integration.test.tsx @@ -0,0 +1,146 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatMessage } from "@/components/chat/types"; +import { toast } from "@/lib/toast"; +import { AriaAnswer } from "./AriaAnswer"; +import { createAriaAnswerDocument, exportAriaAnswer } from "./answerExport"; +import type { UsageChart } from "./usageChart"; + +vi.mock("./answerExport", async (importOriginal) => ({ + ...(await importOriginal()), + exportAriaAnswer: vi.fn(() => true), +})); + +const message: ChatMessage = { + id: "answer-1", + role: "assistant", + content: "## Spend overview\n\nThe **primary model** accounts for most of your spend.", + timestamp: 1, +}; +const chart: UsageChart = { + kind: "bar", + title: "Spend by model", + period: "2026-10-19 to 2026-10-23 (UTC)", + scope: "Your usage", + unit: "usd", + total: 4, + data: [ + { label: "Primary", value: 3.25 }, + { label: "Secondary", value: 0.75 }, + ], +}; + +beforeEach(() => { + vi.mocked(exportAriaAnswer).mockReset().mockReturnValue(true); + vi.mocked(toast.error).mockClear(); +}); + +describe("AriaAnswer", () => { + it("should render a text-only Markdown answer with one export action", () => { + render(); + + expect(screen.getByRole("article", { name: "Aria Agent answer" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Spend overview", level: 2 })).toBeInTheDocument(); + expect(screen.getByText("primary model")).toBeInTheDocument(); + expect(screen.queryByRole("figure")).not.toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Export answer" })).toHaveLength(1); + expect(screen.queryByRole("button", { name: /copy/i })).not.toBeInTheDocument(); + }); + + it("should present the chart's exact accessible values alongside the formatted explanation", () => { + render(); + + const figure = screen.getByRole("figure", { name: "Spend by model" }); + const values = within(figure).getByRole("table", { name: "Spend by model values" }); + expect(within(values).getByRole("row", { name: "Primary $3.25" })).toBeInTheDocument(); + expect(within(values).getByRole("row", { name: "Secondary $0.75" })).toBeInTheDocument(); + expect(within(figure).getByText("$4.00")).toBeInTheDocument(); + expect(within(figure).getByText("Your usage · 2026-10-19 to 2026-10-23 (UTC)")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Spend overview" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /copy/i })).not.toBeInTheDocument(); + }); + + it("should export the current question, explanation and chart without including adjacent receipts", () => { + const question = "Which model costs the most?"; + render( + <> + + + , + ); + act(() => fireEvent.click(screen.getByRole("button", { name: "Export answer" }))); + + expect(exportAriaAnswer).toHaveBeenCalledOnce(); + const [exportedQuestion, exportedAnswer] = vi.mocked(exportAriaAnswer).mock.calls[0]; + expect(exportedQuestion).toBe(question); + expect(within(exportedAnswer).getByRole("figure", { name: "Spend by model" })).toBeInTheDocument(); + expect(within(exportedAnswer).getByRole("heading", { name: "Spend overview" })).toBeInTheDocument(); + expect(within(exportedAnswer).queryByText("Private action receipt")).not.toBeInTheDocument(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("should explain when the PDF print dialog cannot open", () => { + vi.mocked(exportAriaAnswer).mockReturnValue(false); + render(); + act(() => fireEvent.click(screen.getByRole("button", { name: "Export answer" }))); + + expect(exportAriaAnswer).toHaveBeenCalledWith("", expect.any(HTMLElement)); + expect(toast.error).toHaveBeenCalledWith("Could not open the PDF print dialog. Please try again."); + }); + + it("should preserve full chart labels and exact values in the exported answer", () => { + const question = "Which deployment costs the most?"; + const detailedChart: UsageChart = { + ...chart, + total: null, + data: [ + { label: "provider/long-deployment-primary", value: 0.000123 }, + { label: "provider/long-deployment-secondary", value: null }, + { label: "Unused deployment", value: 0 }, + ], + }; + render(); + act(() => fireEvent.click(screen.getByRole("button", { name: "Export answer" }))); + const [exportedQuestion, exportedAnswer] = vi.mocked(exportAriaAnswer).mock.calls[0]; + + const report = createAriaAnswerDocument(exportedQuestion, exportedAnswer); + render( +
      Hidden tooltip
      +
      Injected HTML
      + +
      `); + const question = ""; + const report = previewReport(createAriaAnswerDocument(question, source)); + + expect(report.getElementsByTagName("header")[0]).toHaveTextContent(question); + expect(report.body).toHaveTextContent("Safe answer linked text"); + expect(report.body).not.toHaveTextContent("Secret action receipt"); + expect(report.body).not.toHaveTextContent("Copy answer"); + expect(report.body).not.toHaveTextContent("Hidden tooltip"); + expect(report.body).not.toHaveTextContent("Decoration"); + expect(report.body).not.toHaveTextContent("Injected HTML"); + for (const tag of ["script", "iframe", "img", "image", "foreignObject", "button"]) { + expect(report.getElementsByTagName(tag)).toHaveLength(0); + } + expect(report.getElementsByTagName("a")[0]).not.toHaveAttribute("href"); + expect(report.getElementsByTagName("path")[0]).not.toHaveAttribute("onclick"); + expect(report.getElementsByTagName("rect")[0]).not.toHaveAttribute("fill"); + expect(report.head).not.toHaveTextContent("evil.example"); + expect(report.getElementsByTagName("meta")[0].content).toContain("default-src 'none'"); + }); + + it("should retain local SVG clipping and gradients while omitting hover overlays", () => { + const source = + answer(` + + + `); + const report = previewReport(createAriaAnswerDocument("Show spend", source)); + const path = report.getElementsByTagName("path")[0]; + expect(path).toHaveAttribute("clip-path", "url(#plot)"); + expect(path).toHaveAttribute("fill", "url(#series)"); + expect(report.getElementsByTagName("rect")).toHaveLength(1); + expect(report.getElementsByTagName("stop")[0]).toHaveAttribute("stop-color", "#123456"); + }); +}); + +function printFrame() { + const frame = document.createElement("iframe"); + const printWindow = window; + vi.spyOn(frame, "contentWindow", "get").mockReturnValue(printWindow); + const print = vi.spyOn(printWindow, "print").mockImplementation(() => undefined); + vi.spyOn(printWindow, "focus").mockImplementation(() => undefined); + const createElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tagName, options) => + tagName === "iframe" ? frame : createElement(tagName, options), + ); + return { frame, printWindow, print }; +} + +describe("exportAriaAnswer", () => { + it("should print only the supplied answer in an isolated document and clean up after printing", () => { + vi.useFakeTimers(); + const source = answer("

      The answer is $3.25.

      "); + const { frame, printWindow, print } = printFrame(); + expect(exportAriaAnswer("How much did I spend?", source)).toBe(true); + expect(print).toHaveBeenCalledOnce(); + expect(frame).toHaveAttribute("sandbox", "allow-modals allow-same-origin"); + expect(frame.contentDocument?.body).toHaveTextContent("How much did I spend?"); + expect(frame.contentDocument?.body).toHaveTextContent("The answer is $3.25."); + expect(frame.contentDocument?.body).not.toHaveTextContent("Export answer"); + printWindow.dispatchEvent(new Event("afterprint")); + expect(frame.isConnected).toBe(false); + expect(source.isConnected).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it("should report unavailable printing and remove the temporary document", () => { + vi.useFakeTimers(); + const source = answer("

      Answer

      "); + const { frame, print } = printFrame(); + print.mockImplementation(() => { + throw new Error("Printing unavailable"); + }); + expect(exportAriaAnswer("Question", source)).toBe(false); + expect(frame.isConnected).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/ui/litellm-dashboard/src/components/liteadmin/answerExport.ts b/ui/litellm-dashboard/src/components/liteadmin/answerExport.ts new file mode 100644 index 00000000000..bb3529299df --- /dev/null +++ b/ui/litellm-dashboard/src/components/liteadmin/answerExport.ts @@ -0,0 +1,238 @@ +import { ARIA_AGENT_LABEL } from "./aria-brand"; + +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; +const HTML_ELEMENTS = new Set([ + "article", + "section", + "div", + "span", + "p", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "strong", + "em", + "b", + "i", + "ul", + "ol", + "li", + "blockquote", + "pre", + "code", + "table", + "thead", + "tbody", + "tfoot", + "tr", + "th", + "td", + "br", + "hr", + "figure", + "figcaption", + "a", + "del", + "s", + "sup", + "sub", +]); +const SVG_ELEMENTS = new Set([ + "svg", + "g", + "path", + "rect", + "circle", + "ellipse", + "line", + "polyline", + "polygon", + "text", + "tspan", + "defs", + "clipPath", + "linearGradient", + "radialGradient", + "stop", + "title", + "desc", +]); +const SVG_ATTRIBUTES = new Set([ + "id", + "viewBox", + "width", + "height", + "x", + "y", + "x1", + "x2", + "y1", + "y2", + "cx", + "cy", + "r", + "rx", + "ry", + "d", + "points", + "transform", + "dx", + "dy", + "textLength", + "lengthAdjust", + "offset", + "gradientUnits", + "gradientTransform", + "spreadMethod", + "fx", + "fy", + "fr", + "clipPathUnits", + "preserveAspectRatio", +]); +const SVG_STYLES = [ + "fill", + "fill-opacity", + "fill-rule", + "stroke", + "stroke-width", + "stroke-opacity", + "stroke-dasharray", + "stroke-linecap", + "stroke-linejoin", + "opacity", + "font-size", + "font-family", + "font-weight", + "text-anchor", + "dominant-baseline", + "stop-color", + "stop-opacity", + "clip-path", +] as const; +const EXCLUDED = + "[data-export-exclude],button,input,textarea,select,script,style,link,meta,iframe,object,embed,img,video,audio,source,canvas,form,[role='tooltip'],.recharts-tooltip-wrapper,.recharts-tooltip-cursor"; + +const PRINT_CSS = ` + @page { margin: 18mm; } + * { box-sizing: border-box; } + body { margin: 0; color: #111827; background: #fff; font: 11pt/1.55 system-ui, sans-serif; } + header { margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #d1d5db; } + h1 { margin: 0 0 12px; font-size: 22pt; } + h2, h3, h4, h5, h6 { margin: 20px 0 8px; break-after: avoid; line-height: 1.3; } + h2 { font-size: 17pt; } h3 { font-size: 14pt; } + p { margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; } + ul, ol { padding-left: 24px; } li { margin: 5px 0; } + table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 10pt; } + thead { display: table-header-group; } + th, td { padding: 8px; border-bottom: 1px solid #d1d5db; text-align: left; overflow-wrap: anywhere; } + th { background: #f3f4f6; } tr, svg { break-inside: avoid; } + figcaption { break-after: avoid; } + blockquote { margin-left: 0; padding-left: 16px; border-left: 3px solid #d1d5db; } + pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 12px; background: #f3f4f6; } + code { font-family: ui-monospace, monospace; font-size: 0.9em; } + svg { display: block; max-width: 100%; height: auto; overflow: visible; } + svg text { fill: #334155; } + [data-report-chart] { margin: 18px 0; } + body { print-color-adjust: exact; -webkit-print-color-adjust: exact; } +`; + +function safeSvgStyle(value: string): boolean { + return !/url\s*\(/i.test(value) || /^url\(["']?#[\w:.-]+["']?\)$/.test(value); +} + +function copySvgAttributes(source: Element, copy: Element): void { + for (const attribute of source.attributes) { + if (SVG_ATTRIBUTES.has(attribute.name)) copy.setAttribute(attribute.name, attribute.value); + } + const computed = source.ownerDocument.defaultView?.getComputedStyle(source); + for (const property of SVG_STYLES) { + const value = computed?.getPropertyValue(property) || source.getAttribute(property); + if (value && safeSvgStyle(value)) copy.setAttribute(property, value); + } +} + +function copyAnswerNode(source: Node, target: Document): Node | null { + if (source.nodeType === Node.TEXT_NODE) return target.createTextNode(source.textContent ?? ""); + if (source.nodeType !== Node.ELEMENT_NODE) return null; + const element = source as Element; + const svg = element.namespaceURI === SVG_NAMESPACE; + const hidden = element.hasAttribute("hidden") || (!svg && element.getAttribute("aria-hidden") === "true"); + if (element.matches(EXCLUDED) || hidden) return null; + if (svg && !SVG_ELEMENTS.has(element.localName)) return null; + + const copy = svg + ? target.createElementNS(SVG_NAMESPACE, element.localName) + : target.createElement(HTML_ELEMENTS.has(element.localName) ? element.localName : "div"); + if (svg) { + copySvgAttributes(element, copy); + } else { + for (const attribute of ["colspan", "rowspan", "start"]) { + const value = element.getAttribute(attribute); + if (value && /^\d+$/.test(value)) copy.setAttribute(attribute, value); + } + if (element.getAttribute("data-slot") === "chart") copy.setAttribute("data-report-chart", ""); + } + for (const child of element.childNodes) { + const safeChild = copyAnswerNode(child, target); + if (safeChild) copy.appendChild(safeChild); + } + return copy; +} + +export function createAriaAnswerDocument(question: string, answerElement: HTMLElement): Document { + const report = answerElement.ownerDocument.implementation.createHTMLDocument(`${ARIA_AGENT_LABEL} answer`); + report.documentElement.lang = "en"; + const policy = report.createElement("meta"); + policy.httpEquiv = "Content-Security-Policy"; + policy.content = "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"; + report.head.prepend(policy); + const style = report.createElement("style"); + style.textContent = PRINT_CSS; + report.head.appendChild(style); + const header = report.createElement("header"); + const title = report.createElement("h1"); + title.textContent = ARIA_AGENT_LABEL; + const prompt = report.createElement("p"); + prompt.textContent = question; + header.append(title, prompt); + report.body.appendChild(header); + const answer = copyAnswerNode(answerElement, report); + if (answer) report.body.appendChild(answer); + return report; +} + +export function exportAriaAnswer(question: string, answerElement: HTMLElement): boolean { + const owner = answerElement.ownerDocument; + const report = createAriaAnswerDocument(question, answerElement); + const frame = owner.createElement("iframe"); + frame.title = `${ARIA_AGENT_LABEL} print preview`; + frame.setAttribute("sandbox", "allow-modals allow-same-origin"); + frame.style.cssText = "position:fixed;inset:0;width:0;height:0;border:0;"; + owner.body.appendChild(frame); + const printWindow = frame.contentWindow; + const printDocument = frame.contentDocument; + if (!printWindow || !printDocument || !owner.defaultView) { + frame.remove(); + return false; + } + printDocument.replaceChild(printDocument.importNode(report.documentElement, true), printDocument.documentElement); + const cleanup = () => { + owner.defaultView?.clearTimeout(cleanupTimer); + printWindow.removeEventListener("afterprint", cleanup); + frame.remove(); + }; + printWindow.addEventListener("afterprint", cleanup, { once: true }); + const cleanupTimer = owner.defaultView.setTimeout(cleanup, 300_000); + try { + printWindow.focus(); + printWindow.print(); + return true; + } catch { + cleanup(); + return false; + } +} diff --git a/ui/litellm-dashboard/src/components/liteadmin/operations.ts b/ui/litellm-dashboard/src/components/liteadmin/operations.ts index 513624f9ffc..11b287da07c 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/operations.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/operations.ts @@ -4,6 +4,13 @@ import { apiClient } from "@/components/networking"; import { ApiError } from "@/lib/http/client"; import type { components } from "@/lib/http/schema"; import { generatedKey, projectToolResult, type ResultKind } from "./toolResults"; +import { + buildUsageChart, + usageChartRequestSchema, + usageChartSource, + type UsageChart, + type UsageChartSource, +} from "./usageChart"; export interface LiteAdminAction { id: string; @@ -27,6 +34,7 @@ export interface OperationContext { beforeTool: () => void; confirm: (action: LiteAdminAction) => Promise; onResult: (action: LiteAdminAction, result: ActionResult) => void; + onChart: (chart: UsageChart) => void; } type Schemas = components["schemas"]; @@ -111,11 +119,31 @@ const requestLogsFields = { }; export function createLiteAdminOperations(context: OperationContext) { + const usageSources = new Map(); const active = () => { context.signal.throwIfAborted(); context.assertCurrent(); }; const auth = { accessToken: context.accessToken, signal: context.signal }; + const chartDefinition = { + name: "render_usage_chart", + description: + "Show one chart with the answer using the source_id of a successful usage lookup in this turn. Choose daily_spend for spend over time, model_spend or provider_spend for cost comparisons, daily_requests or daily_tokens for trends, or token_breakdown for input/output/cache analysis. Values come directly from that lookup. Follow with a short explanation, not a duplicate table. A later chart replaces the earlier chart for this answer. Read-only.", + parameters: usageChartRequestSchema, + function: (rawArgs: unknown) => { + active(); + context.beforeTool(); + const args = usageChartRequestSchema.parse(rawArgs); + const source = usageSources.get(args.source_id); + if (!source) return { success: false, message: "Fetch usage first and use the source_id from that result." }; + const chart = buildUsageChart(source, args.view); + if (!chart) + return { success: false, message: "No recorded data is available for this chart. Explain that in text." }; + context.onChart(chart); + return { success: true, chart }; + }, + }; + const renderChart = zodFunction(chartDefinition); const operation = (mode: "read" | "write" | "delete", kind: ResultKind, extraArguments?: Record) => >>( @@ -178,10 +206,18 @@ export function createLiteAdminOperations(context: OperationContext) { } const key = name === "key_create" || name === "user_create" ? generatedKey(outcome.value) : undefined; if (action) context.onResult(action, { status: "completed", ...(key ? { key } : {}) }); + const result = projectToolResult(kind, outcome.value, [context.accessToken, ...(key ? [key] : [])]); + let reportScope: "Gateway usage" | "All teams" | "All tags" = "Gateway usage"; + if (name === "get_team_usage_data") reportScope = "All teams"; + if (name === "get_tag_usage_data") reportScope = "All tags"; + const source = kind === "usage" ? usageChartSource(result, args, context.access, reportScope) : undefined; + const sourceId = source ? crypto.randomUUID() : undefined; + if (source && sourceId) usageSources.set(sourceId, source); return { operation: name, success: true, - result: projectToolResult(kind, outcome.value, [context.accessToken, ...(key ? [key] : [])]), + result, + ...(sourceId ? { source_id: sourceId } : {}), }; }, }; @@ -191,9 +227,10 @@ export function createLiteAdminOperations(context: OperationContext) { if (context.access.mode === "usage" && context.access.scope.type === "user") { const userId = context.access.scope.userId; return [ + renderChart, operation("read", "usage")( "get_usage_data", - "View your dated usage totals, daily trends, and top models, providers and keys", + "View your dated usage totals, daily trends, and top models, providers and keys with prompt, completion, cache-read and cache-creation token counts when available. Do not add cache counts to total_tokens", dated({}), (a) => { if (!userId.trim()) throw new Error("A signed-in user is required to query usage."); @@ -207,21 +244,22 @@ export function createLiteAdminOperations(context: OperationContext) { } const usageOperations = [ + renderChart, operation("read", "usage")( "get_usage_data", - "View dated global or user usage totals, daily trends, and top models, providers and keys", + "View dated global or user usage totals, daily trends, and top models, providers and keys with prompt, completion, cache-read and cache-creation token counts when available. Do not add cache counts to total_tokens", dated({ user_id: optionalText }), (a) => apiClient.get("/user/daily/activity/aggregated", { ...auth, query: { ...a, api_key_limit: 20 } }), ), operation("read", "usage")( "get_team_usage_data", - "View dated usage by team, including model and provider breakdowns. Omit team_ids for all teams", + "View dated usage by team, including model and provider breakdowns with prompt, completion, cache-read and cache-creation token counts when available. Do not add cache counts to total_tokens. Omit team_ids for all teams", dated({ team_ids: optionalText }), (a) => apiClient.get("/team/daily/activity/aggregated", { ...auth, query: { ...a, api_key_limit: 20 } }), ), operation("read", "usage")( "get_tag_usage_data", - "View dated usage by tag, including model and provider breakdowns. Omit tags for all tags", + "View dated usage by tag, including model and provider breakdowns with prompt, completion, cache-read and cache-creation token counts when available. Do not add cache counts to total_tokens. Omit tags for all tags", dated({ tags: optionalText }), (a) => apiClient.get("/tag/daily/activity/aggregated", { ...auth, query: { ...a, api_key_limit: 20 } }), ), diff --git a/ui/litellm-dashboard/src/components/liteadmin/toolResults.test.ts b/ui/litellm-dashboard/src/components/liteadmin/toolResults.test.ts index 31d3aa01810..b23b2c6d531 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/toolResults.test.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/toolResults.test.ts @@ -143,38 +143,157 @@ describe("model result privacy", () => { }); }); - it("should aggregate usage across the full date range before ranking and sanitize names and metadata", () => { - const metrics = { spend: 10, api_requests: 3, total_tokens: 50 }; - const raw = { - metadata: { total_spend: 20, total_api_requests: 6, total_tokens: 100, secret: "private" }, - results: ["2031-05-01", "2031-05-02"].map((date) => ({ - date, - metrics, - breakdown: { - providers: { "provider captured-token": { metrics, messages: ["private prompt"] } }, - entities: { - "tag sk-secret": { metrics, metadata: { team_alias: "Team captured-token", secret: "private" } }, - }, - api_keys: { "sk-private-key": { metrics, metadata: { key_alias: "worker", raw_key: "private" } } }, + it.each(["team", "tag"])( + "should aggregate all token counts across dates and %s usage without exposing private fields", + (entity) => { + const days = [ + { + date: "2031-05-01", + spend: 10, + api_requests: 3, + total_tokens: 50, + prompt_tokens: 40, + completion_tokens: 10, + cache_read_input_tokens: 15, + cache_creation_input_tokens: 5, }, - })), + { + date: "2031-05-02", + spend: 20, + api_requests: 4, + total_tokens: 75, + prompt_tokens: 60, + completion_tokens: 15, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 10, + }, + ]; + const totals = { + total_spend: 30, + total_api_requests: 7, + total_tokens: 125, + total_prompt_tokens: 100, + total_completion_tokens: 25, + total_cache_read_input_tokens: 35, + total_cache_creation_input_tokens: 15, + }; + const raw = { + metadata: { ...totals, secret: "private" }, + results: days.map(({ date, ...metrics }) => ({ + date, + metrics, + breakdown: { + models: { "model captured-token": { metrics, messages: ["private prompt"] } }, + providers: { "provider captured-token": { metrics, messages: ["private prompt"] } }, + entities: { + [`${entity} sk-secret`]: { + metrics, + metadata: { team_alias: "Team captured-token", secret: "private" }, + }, + }, + api_keys: { "sk-private-key": { metrics, metadata: { key_alias: "worker", raw_key: "private" } } }, + }, + })), + }; + const combined = { + spend: 30, + api_requests: 7, + total_tokens: 125, + prompt_tokens: 100, + completion_tokens: 25, + cache_read_input_tokens: 35, + cache_creation_input_tokens: 15, + }; + const expected = { + totals, + daily: days, + breakdown_limit: 20, + top_models: [{ name: "model [redacted]", ...combined }], + top_providers: [{ name: "provider [redacted]", ...combined }], + top_entities: [{ name: `${entity} [redacted]`, alias: "Team [redacted]", ...combined }], + top_api_keys: [{ name: "[redacted]", alias: "worker", ...combined }], + }; + expect(projectToolResult("usage", raw, ["captured-token"])).toEqual(expected); + }, + ); + + it.each([undefined, null, 0])("should preserve zero or unavailable token counts reported as %s", (count) => { + const tokens = { + prompt_tokens: count, + completion_tokens: count, + cache_read_input_tokens: count, + cache_creation_input_tokens: count, + }; + const metrics = { spend: 0, api_requests: 0, total_tokens: 0, ...tokens }; + const metadata = { + total_prompt_tokens: count, + total_completion_tokens: count, + total_cache_read_input_tokens: count, + total_cache_creation_input_tokens: count, + }; + const daily = ["2031-05-01", "2031-05-02"].map((date) => ({ date, ...metrics })); + const raw = { + metadata, + results: daily.map(({ date }) => ({ date, metrics, breakdown: { models: { model: { metrics } } } })), }; const expected = { - totals: { total_spend: 20, total_api_requests: 6, total_tokens: 100 }, - daily: ["2031-05-01", "2031-05-02"].map((date) => ({ date, ...metrics })), + totals: metadata, + daily, breakdown_limit: 20, - top_models: [], - top_providers: [{ name: "provider [redacted]", spend: 20, api_requests: 6, total_tokens: 100 }], - top_entities: [ - { name: "tag [redacted]", alias: "Team [redacted]", spend: 20, api_requests: 6, total_tokens: 100 }, + top_models: [ + { + name: "model", + ...metrics, + prompt_tokens: count ?? undefined, + completion_tokens: count ?? undefined, + cache_read_input_tokens: count ?? undefined, + cache_creation_input_tokens: count ?? undefined, + }, ], - top_api_keys: [{ name: "[redacted]", alias: "worker", spend: 20, api_requests: 6, total_tokens: 100 }], + top_providers: [], + top_entities: [], + top_api_keys: [], }; - expect(projectToolResult("usage", raw, ["captured-token"])).toEqual(expected); + expect(projectToolResult("usage", raw, [])).toEqual(expected); }); + it.each([0, 1, 2])( + "should leave partially reported token counts unavailable when day %s omits them", + (missingDay) => { + const metrics = { spend: 1, api_requests: 1, total_tokens: 20 }; + const tokens = { + prompt_tokens: 15, + completion_tokens: 5, + cache_read_input_tokens: 6, + cache_creation_input_tokens: 2, + }; + const raw = { + results: Array.from({ length: 3 }, (_, index) => { + const dayMetrics = index === missingDay ? metrics : { ...metrics, ...tokens }; + return { + date: `2031-05-0${index + 1}`, + metrics: dayMetrics, + breakdown: { models: { model: { metrics: dayMetrics } }, entities: { entity: { metrics: dayMetrics } } }, + }; + }), + }; + const result = projectToolResult("usage", raw, []); + expect(result).toHaveProperty("top_models", [{ name: "model", spend: 3, api_requests: 3, total_tokens: 60 }]); + expect(result).toHaveProperty("top_entities", [{ name: "entity", spend: 3, api_requests: 3, total_tokens: 60 }]); + expect(result).toHaveProperty("totals", undefined); + }, + ); + it("should bound usage breakdowns after ranking while keeping complete totals", () => { - const metrics = { spend: 300, api_requests: 30, total_tokens: 900 }; + const metrics = { + spend: 300, + api_requests: 30, + total_tokens: 900, + prompt_tokens: 700, + completion_tokens: 200, + cache_read_input_tokens: 250, + cache_creation_input_tokens: 100, + }; const result = projectToolResult( "usage", { @@ -207,6 +326,26 @@ describe("model result privacy", () => { ); }); + it("should keep the usage output size limit when token breakdowns enlarge the response", () => { + const raw = { + results: Array.from({ length: 200 }, () => ({ + date: "2031-05-01", + metrics: { + spend: 1, + api_requests: 1, + total_tokens: 20, + prompt_tokens: 15, + completion_tokens: 5, + cache_read_input_tokens: 6, + cache_creation_input_tokens: 2, + }, + })), + }; + expect(projectToolResult("usage", raw, [])).toEqual({ + notice: "The result is too large to summarize safely. Use a smaller page or narrower filters.", + }); + }); + it("bounds list and string sizes and rejects results that exceed the overall output budget", () => { const raw = { users: Array.from({ length: 80 }, (_, index) => ({ user_id: String(index), user_alias: "x".repeat(800) })), diff --git a/ui/litellm-dashboard/src/components/liteadmin/toolResults.ts b/ui/litellm-dashboard/src/components/liteadmin/toolResults.ts index 64198a51847..7cfb809f668 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/toolResults.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/toolResults.ts @@ -5,6 +5,10 @@ export type ResultKind = "key" | "team" | "user" | "budget" | "spend" | "usage" const limitedList = >(schema: Schema, limit = 50) => z.preprocess((value) => (Array.isArray(value) ? value.slice(0, limit) : value), z.array(schema)); +function sumKnownTokenCounts(left: number | null | undefined, right: number | null | undefined): number | undefined { + return left == null || right == null ? undefined : left + right; +} + export function generatedKey(value: unknown): string | undefined { const result = z.object({ key: z.string().min(1) }).safeParse(value); return result.success ? result.data.key : undefined; @@ -133,11 +137,16 @@ export function projectToolResult(kind: ResultKind, value: unknown, secrets: rea cache_hit: z.union([text, z.boolean()]), }; const log = z.object(logFields); - const usageMetrics = z.object({ + const usageMetricFields = { spend: z.number().finite(), api_requests: z.number().finite(), total_tokens: z.number().finite(), - }); + prompt_tokens: number, + completion_tokens: number, + cache_read_input_tokens: number, + cache_creation_input_tokens: number, + }; + const usageMetrics = z.object(usageMetricFields); const usageEntry = z.object({ metrics: usageMetrics, metadata: z.object({ key_alias: text, team_alias: text }).optional(), @@ -155,6 +164,8 @@ export function projectToolResult(kind: ResultKind, value: unknown, secrets: rea total_tokens: number, total_prompt_tokens: number, total_completion_tokens: number, + total_cache_read_input_tokens: number, + total_cache_creation_input_tokens: number, total_successful_requests: number, total_failed_requests: number, total_api_keys: number, @@ -181,11 +192,23 @@ export function projectToolResult(kind: ResultKind, value: unknown, secrets: rea const previous = totals.get(name)?.metrics; totals.set(name, { metadata: entry.metadata, - metrics: { - spend: (previous?.spend ?? 0) + entry.metrics.spend, - api_requests: (previous?.api_requests ?? 0) + entry.metrics.api_requests, - total_tokens: (previous?.total_tokens ?? 0) + entry.metrics.total_tokens, - }, + metrics: previous + ? { + spend: previous.spend + entry.metrics.spend, + api_requests: previous.api_requests + entry.metrics.api_requests, + total_tokens: previous.total_tokens + entry.metrics.total_tokens, + prompt_tokens: sumKnownTokenCounts(previous.prompt_tokens, entry.metrics.prompt_tokens), + completion_tokens: sumKnownTokenCounts(previous.completion_tokens, entry.metrics.completion_tokens), + cache_read_input_tokens: sumKnownTokenCounts( + previous.cache_read_input_tokens, + entry.metrics.cache_read_input_tokens, + ), + cache_creation_input_tokens: sumKnownTokenCounts( + previous.cache_creation_input_tokens, + entry.metrics.cache_creation_input_tokens, + ), + } + : entry.metrics, }); } } diff --git a/ui/litellm-dashboard/src/components/liteadmin/usageChart.test.ts b/ui/litellm-dashboard/src/components/liteadmin/usageChart.test.ts new file mode 100644 index 00000000000..0966ab3a88f --- /dev/null +++ b/ui/litellm-dashboard/src/components/liteadmin/usageChart.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import { + buildUsageChart, + formatUsageChartValue, + usageChartRequestSchema, + usageChartSource, + type UsageChartSource, +} from "./usageChart"; + +const query = { start_date: "2031-05-01", end_date: "2031-05-02" }; +const admin = { mode: "admin" } as const; +const report = { daily: [{ date: query.start_date, spend: 0 }], top_models: [], top_providers: [] }; +const source = (data: UsageChartSource["report"]): UsageChartSource => ({ + report: data, + period: `${query.start_date} to ${query.end_date} (UTC)`, + scope: "Gateway usage", +}); + +describe("usage chart sources", () => { + it("should retain validated usage data with its date range and account scope", () => { + expect(usageChartSource(report, query, admin)).toEqual(source(report)); + expect( + usageChartSource( + report, + { ...query, user_id: "viewer" }, + { mode: "usage", scope: { type: "user", userId: "viewer" } }, + ), + ).toMatchObject({ scope: "Your usage" }); + }); + + it.each(["All teams", "All tags"] as const)( + "should label an unfiltered %s report and retain combined filters", + (scope) => { + expect(usageChartSource(report, query, admin, scope)?.scope).toBe(scope); + const filters = { ...query, user_id: "member", team_ids: "team-a", tags: "production" }; + expect(usageChartSource(report, filters, admin, scope)?.scope).toBe( + "User member · Teams team-a · Tags production", + ); + }, + ); + + it.each([-1, Number.NaN, Number.POSITIVE_INFINITY, "12"])("should reject invalid metric %s", (spend) => { + expect(usageChartSource({ ...report, daily: [{ date: query.start_date, spend }] }, query, admin)).toBeUndefined(); + }); + + it("should reject malformed reports or date ranges before creating a chart source", () => { + expect(usageChartSource({ daily: report.daily }, query, admin)).toBeUndefined(); + expect(usageChartSource({ ...report, daily: [{ date: "yesterday", spend: 2 }] }, query, admin)).toBeUndefined(); + expect(usageChartSource(report, { ...query, start_date: "2031-02-30" }, admin)).toBeUndefined(); + }); + + it("should accept only a source reference and a supported view in chart requests", () => { + const request = { source_id: "recorded-lookup", view: "daily_spend" }; + expect(usageChartRequestSchema.safeParse(request).success).toBe(true); + expect(usageChartRequestSchema.safeParse({ ...request, data: [{ value: 999 }] }).success).toBe(false); + expect(usageChartRequestSchema.safeParse({ ...request, source_id: "" }).success).toBe(false); + expect(usageChartRequestSchema.safeParse({ ...request, view: "arbitrary_chart" }).success).toBe(false); + }); +}); + +describe("usage chart values", () => { + it.each([ + ["daily_spend", "spend", "usd"], + ["daily_requests", "api_requests", "requests"], + ["daily_tokens", "total_tokens", "tokens"], + ] as const)( + "should sort %s chronologically and preserve missing values separately from zero", + (view, metric, unit) => { + const data = { + ...report, + daily: [ + { date: "2031-05-03", [metric]: 7 }, + { date: "2031-05-01", [metric]: 0 }, + { date: "2031-05-02", [metric]: null }, + ], + }; + const chart = buildUsageChart(source(data), view); + const expectedChart = { + kind: "line", + unit, + total: null, + data: [ + { label: "2031-05-01", value: 0 }, + { label: "2031-05-02", value: null }, + { label: "2031-05-03", value: 7 }, + ], + }; + expect(chart).toMatchObject(expectedChart); + expect(data.daily[0].date).toBe("2031-05-03"); + }, + ); + + it("should preserve independent prompt, completion and cache totals without adding cache to total tokens", () => { + const chart = buildUsageChart( + source({ + ...report, + daily: [ + { + date: query.start_date, + total_tokens: 50, + prompt_tokens: 40, + completion_tokens: 10, + cache_read_input_tokens: 15, + cache_creation_input_tokens: 5, + }, + { + date: query.end_date, + total_tokens: 75, + prompt_tokens: 60, + completion_tokens: 15, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 10, + }, + ], + }), + "token_breakdown", + ); + const expectedChart = { + kind: "bar", + unit: "tokens", + total: 125, + data: [ + { label: "Prompt", value: 100 }, + { label: "Completion", value: 25 }, + { label: "Cache read", value: 35 }, + { label: "Cache creation", value: 15 }, + ], + }; + expect(chart).toMatchObject(expectedChart); + }); + + it("should leave an incomplete token counter unavailable while retaining known zeros", () => { + const chart = buildUsageChart( + source({ + ...report, + daily: [ + { date: query.start_date, total_tokens: 8, prompt_tokens: 8, cache_read_input_tokens: 0 }, + { date: query.end_date, total_tokens: 12, prompt_tokens: null, cache_read_input_tokens: 0 }, + ], + }), + "token_breakdown", + ); + expect(chart?.total).toBe(20); + expect(chart?.data).toEqual([ + { label: "Prompt", value: null }, + { label: "Completion", value: null }, + { label: "Cache read", value: 0 }, + { label: "Cache creation", value: null }, + ]); + }); + + it.each([ + ["model_spend", "top_models"], + ["provider_spend", "top_providers"], + ] as const)("should show the six leading entries and all remaining recorded spend for %s", (view, dimension) => { + const ranked = [5, 20, 1, 10, 15, 25, 2, 4].map((spend) => ({ name: `entry-${spend}`, spend })); + const chart = buildUsageChart( + source({ ...report, daily: [{ date: query.start_date, spend: 100 }], [dimension]: ranked }), + view, + ); + expect(chart?.data).toEqual([ + { label: "entry-25", value: 25 }, + { label: "entry-20", value: 20 }, + { label: "entry-15", value: 15 }, + { label: "entry-10", value: 10 }, + { label: "entry-5", value: 5 }, + { label: "entry-4", value: 4 }, + { label: "Other", value: 21 }, + ]); + expect(chart?.data.reduce((sum, row) => sum + (row.value ?? 0), 0)).toBe(chart?.total); + expect(ranked[0].spend).toBe(5); + }); + + it("should not invent remaining spend when the daily total is incomplete", () => { + const chart = buildUsageChart( + source({ + ...report, + daily: [{ date: query.start_date, spend: null }], + top_models: [{ name: "Known model", spend: 5 }], + }), + "model_spend", + ); + expect(chart?.total).toBeNull(); + expect(chart?.data).toEqual([{ label: "Known model", value: 5 }]); + }); + + it.each(["daily_spend", "model_spend", "provider_spend", "token_breakdown"] as const)( + "should omit %s when its source has no recorded rows", + (view) => expect(buildUsageChart(source({ daily: [], top_models: [], top_providers: [] }), view)).toBeUndefined(), + ); + + it("should display tiny dollar amounts without rounding them to zero and label unavailable values", () => { + expect(formatUsageChartValue(0.000123, "usd")).toBe("$0.000123"); + expect(formatUsageChartValue(0, "usd")).toBe("$0.00"); + expect(formatUsageChartValue(null, "tokens")).toBe("Unavailable"); + expect(formatUsageChartValue(12345, "requests")).toBe("12,345"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/liteadmin/usageChart.ts b/ui/litellm-dashboard/src/components/liteadmin/usageChart.ts new file mode 100644 index 00000000000..b6a6d1a008f --- /dev/null +++ b/ui/litellm-dashboard/src/components/liteadmin/usageChart.ts @@ -0,0 +1,148 @@ +import { z } from "zod"; +import type { AriaAccess } from "./operations"; + +export const usageChartRequestSchema = z.strictObject({ + source_id: z.string().min(1).max(100), + view: z.enum(["daily_spend", "model_spend", "provider_spend", "daily_requests", "daily_tokens", "token_breakdown"]), +}); + +type UsageChartView = z.infer["view"]; +const metric = z.number().finite().nonnegative().nullish(); +const metricFields = { + spend: metric, + api_requests: metric, + total_tokens: metric, + prompt_tokens: metric, + completion_tokens: metric, + cache_read_input_tokens: metric, + cache_creation_input_tokens: metric, +}; +const metrics = z.object(metricFields); +const usageReportSchema = z.object({ + daily: z.array(metrics.extend({ date: z.iso.date() })).max(367), + top_models: z.array(metrics.extend({ name: z.string().nullish() })).max(20), + top_providers: z.array(metrics.extend({ name: z.string().nullish() })).max(20), +}); +const queryFields = { + start_date: z.iso.date(), + end_date: z.iso.date(), + user_id: z.string().nullish(), + team_ids: z.string().nullish(), + tags: z.string().nullish(), +}; +const usageQuerySchema = z.object(queryFields); + +export interface UsageChartSource { + report: z.infer; + period: string; + scope: string; +} + +export interface UsageChart { + kind: "bar" | "line"; + title: string; + period: string; + scope: string; + unit: "usd" | "requests" | "tokens"; + total: number | null; + data: { label: string; value: number | null }[]; + note?: string; +} + +export function usageChartSource( + value: unknown, + query: unknown, + access: AriaAccess, + defaultScope: "Gateway usage" | "All teams" | "All tags" = "Gateway usage", +): UsageChartSource | undefined { + const report = usageReportSchema.safeParse(value); + const args = usageQuerySchema.safeParse(query); + if (!report.success || !args.success) return undefined; + const { start_date, end_date, user_id, team_ids, tags } = args.data; + const filters = [user_id && `User ${user_id}`, team_ids && `Teams ${team_ids}`, tags && `Tags ${tags}`].filter( + Boolean, + ); + const scope = + access.mode === "usage" && access.scope.type === "user" ? "Your usage" : filters.join(" · ") || defaultScope; + return { report: report.data, period: `${start_date} to ${end_date} (UTC)`, scope }; +} + +function knownTotal(values: readonly (number | null | undefined)[]): number | null { + if (values.length === 0 || values.some((value) => value == null)) return null; + return values.reduce((total, value) => total + (value ?? 0), 0); +} + +function rankedData(source: UsageChartSource, dimension: "top_models" | "top_providers"): UsageChart["data"] { + const ranked = [...source.report[dimension]].sort((a, b) => (b.spend ?? 0) - (a.spend ?? 0)); + const shown = ranked.slice(0, 6).map((row) => ({ label: row.name || "Unknown", value: row.spend ?? null })); + const total = knownTotal(source.report.daily.map((day) => day.spend)); + const visibleTotal = knownTotal(shown.map((row) => row.value)); + const remainder = total == null || visibleTotal == null ? null : total - visibleTotal; + return remainder != null && remainder > 1e-10 ? [...shown, { label: "Other", value: remainder }] : shown; +} + +const dailyViews = { + daily_spend: { title: "Daily spend", metric: "spend", unit: "usd" }, + daily_requests: { title: "Daily requests", metric: "api_requests", unit: "requests" }, + daily_tokens: { title: "Daily tokens", metric: "total_tokens", unit: "tokens" }, +} as const; + +export function buildUsageChart(source: UsageChartSource, view: UsageChartView): UsageChart | undefined { + const base = { period: source.period, scope: source.scope }; + if (view === "model_spend" || view === "provider_spend") { + const data = rankedData(source, view === "model_spend" ? "top_models" : "top_providers"); + if (data.length === 0) return undefined; + return { + ...base, + kind: "bar", + title: view === "model_spend" ? "Spend by model" : "Spend by provider", + unit: "usd", + total: knownTotal(source.report.daily.map((day) => day.spend)), + data, + note: data.some((row) => row.label === "Other") + ? "Up to six leading entries. Other includes remaining recorded spend." + : undefined, + }; + } + if (view === "token_breakdown") { + if (source.report.daily.length === 0) return undefined; + const tokenFields = [ + ["Prompt", "prompt_tokens"], + ["Completion", "completion_tokens"], + ["Cache read", "cache_read_input_tokens"], + ["Cache creation", "cache_creation_input_tokens"], + ] as const; + return { + ...base, + kind: "bar", + title: "Token breakdown", + unit: "tokens", + total: knownTotal(source.report.daily.map((day) => day.total_tokens)), + data: tokenFields.map(([label, field]) => ({ + label, + value: knownTotal(source.report.daily.map((day) => day[field])), + })), + note: "Cache counts are reported separately. Do not add them to total tokens.", + }; + } + const config = dailyViews[view]; + const data = [...source.report.daily] + .sort((a, b) => a.date.localeCompare(b.date)) + .map((day) => ({ label: day.date, value: day[config.metric] ?? null })); + if (data.length === 0) return undefined; + return { + ...base, + kind: "line", + title: config.title, + unit: config.unit, + total: knownTotal(data.map((point) => point.value)), + data, + }; +} + +export function formatUsageChartValue(value: number | null, unit: UsageChart["unit"]): string { + if (value == null) return "Unavailable"; + return unit === "usd" + ? value.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 6 }) + : value.toLocaleString("en-US"); +} diff --git a/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts b/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts index 5b4196b36fc..6c6b385a164 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts @@ -8,11 +8,12 @@ import { getCookie } from "@/utils/cookieUtils"; import { checkTokenValidity } from "@/utils/jwtUtils"; import { runLiteAdmin } from "./agent"; import type { ActionResult, AriaAccess, LiteAdminAction } from "./operations"; +import type { UsageChart } from "./usageChart"; type ActionState = { status: "review" | "applying" | "cancelled" } | ActionResult; export type ActionEntry = { kind: "action"; id: string; action: LiteAdminAction } & ActionState; export type ConversationEntry = - | { kind: "message"; id: string; message: ChatMessage } + | { kind: "message"; id: string; message: ChatMessage; chart?: UsageChart; question?: string } | ActionEntry | { kind: "error"; id: string; text: string }; export interface LiteAdminSession { @@ -28,6 +29,7 @@ type ActiveTurn = { controller: AbortController; outcome: "none" | "submitted" | "completed" | "unknown"; approval?: { id: string; resolve: (approved: boolean) => void }; + chart?: UsageChart; }; const INTERRUPTED_WRITE = "A submitted change may have completed. Check the relevant page before trying again."; const RESOURCE_QUERIES = new Set([ @@ -131,7 +133,16 @@ export function useLiteAdmin(session: LiteAdminSession) { onMessage: (content) => { assertCurrent(); const reply: ChatMessage = { id: crypto.randomUUID(), role: "assistant", content, timestamp: Date.now() }; - setEntries((current) => [...current, { kind: "message", id: reply.id, message: reply }]); + const chart = turn.chart; + turn.chart = undefined; + setEntries((current) => [ + ...current, + { kind: "message", id: reply.id, message: reply, chart, question: message.content }, + ]); + }, + onChart: (chart) => { + assertCurrent(); + turn.chart = chart; }, confirm: (action) => new Promise((resolve) => { diff --git a/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx index 3cd7bd2fd08..4de55550daa 100644 --- a/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx @@ -81,6 +81,28 @@ describe("BarChart", () => { expect(screen.getAllByText("beta").length).toBeGreaterThan(0); }); + it.each(["horizontal", "vertical"] as const)( + "should shorten category labels independently of numeric ticks in %s layout", + (layout) => { + const fullLabel = "primary-model-with-a-long-deployment-name"; + render( + `${label.slice(0, 13)}…`} + valueFormatter={(value) => `${value} USD`} + showLegend={false} + />, + ); + + expect(screen.getByText("primary-model…")).toBeInTheDocument(); + expect(screen.queryByText(fullLabel)).not.toBeInTheDocument(); + expect(screen.getAllByText(/ USD$/).length).toBeGreaterThan(0); + }, + ); + it("applies valueFormatter to the value axis ticks", () => { render( > = { data: readonly TDatum[]; index: string; + indexFormatter?: (value: string) => string; categories: readonly string[]; colors?: readonly ChartColor[]; colorByDatum?: boolean; @@ -32,6 +33,7 @@ export type BarChartProps> = { export function BarChart>({ data, index, + indexFormatter, categories, colors, colorByDatum = false, @@ -87,10 +89,19 @@ export function BarChart>({ axisLine={false} minTickGap={tickGap} interval="equidistantPreserveStart" + tickFormatter={indexFormatter} /> )} {vertical ? ( - + ) : ( )} From eeedf98d4004b44f11ec0069fe32661165f79611 Mon Sep 17 00:00:00 2001 From: shrey-berri Date: Fri, 23 Oct 2026 16:04:09 -0700 Subject: [PATCH 05/27] fix(azure_ai): drop tool_reference parts from tool messages on chat (#48032) --- litellm/llms/azure_ai/chat/transformation.py | 3 ++- .../chat/test_azure_ai_transformation.py | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/litellm/llms/azure_ai/chat/transformation.py b/litellm/llms/azure_ai/chat/transformation.py index 0fa54cccbc9..83d00a621b4 100644 --- a/litellm/llms/azure_ai/chat/transformation.py +++ b/litellm/llms/azure_ai/chat/transformation.py @@ -12,6 +12,7 @@ from litellm._logging import verbose_logger from litellm.litellm_core_utils.prompt_templates.common_utils import ( audio_or_image_in_message_content, convert_content_list_to_str, + drop_tool_reference_parts_from_tool_messages, filter_value_from_dict, ) from litellm.llms.azure.common_utils import BaseAzureLLM @@ -250,7 +251,7 @@ class AzureAIStudioConfig(OpenAIConfig): Operates on a deep copy so the caller's messages keep their thinking blocks and provider metadata, which a fallback to another provider still needs. """ - stripped_messages: Final = copy.deepcopy(messages) + stripped_messages: Final = copy.deepcopy(drop_tool_reference_parts_from_tool_messages(messages)) for message in stripped_messages: message_dict = cast(dict, message) # cast-ok: TypedDict is a runtime dict stripped on our copy for field in NON_OPENAI_SPEC_MESSAGE_FIELDS: diff --git a/tests/unit/llms/azure_ai/chat/test_azure_ai_transformation.py b/tests/unit/llms/azure_ai/chat/test_azure_ai_transformation.py index 5186541a62b..ccac6ffc309 100644 --- a/tests/unit/llms/azure_ai/chat/test_azure_ai_transformation.py +++ b/tests/unit/llms/azure_ai/chat/test_azure_ai_transformation.py @@ -879,6 +879,32 @@ def test_azure_ai_stripping_does_not_mutate_caller_messages(): } +def test_azure_ai_reference_only_tool_result_is_sent_as_empty_text(): + messages: Final = [ + {"role": "user", "content": "load the WebFetch tool"}, + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "ToolSearch", "arguments": "{}"}}], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": [{"type": "tool_reference", "tool_name": "WebFetch"}], + }, + ] + + request: Final = AzureAIStudioConfig().transform_request( + model="fw-glm-5.2", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + + assert request["messages"][2] == {"role": "tool", "tool_call_id": "call_1", "content": ""} + + @pytest.mark.parametrize( "api_base, expected_url", [ From 91287b49f403094a754c2a3b979001d1d0fad9d1 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 23 Oct 2026 16:12:51 -0700 Subject: [PATCH 06/27] feat(ui): sort every users table data column (#48105) * feat(ui): sort every users table data column * fix(ui): order unlimited user budgets as infinity and verify org scope * fix(ui): cover user sort queries and document pagination bound --- .../internal_user_endpoints.py | 50 ++++- .../management_helpers/user_list_sorting.py | 77 ++++++++ .../management/test_user_list_sorting.py | 179 ++++++++++++++++++ .../test_user_list_sorting.py | 71 +++++++ ...st.tsx => view_users.integration.test.tsx} | 15 +- ...st.tsx => UsersTable.integration.test.tsx} | 19 +- .../view_users/UsersTableColumns.tsx | 33 ++-- 7 files changed, 415 insertions(+), 29 deletions(-) create mode 100644 litellm/proxy/management_helpers/user_list_sorting.py create mode 100644 tests/integration/management/test_user_list_sorting.py create mode 100644 tests/unit/proxy/management_helpers/test_user_list_sorting.py rename ui/litellm-dashboard/src/app/(dashboard)/users/_components/{view_users.test.tsx => view_users.integration.test.tsx} (96%) rename ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/{UsersTable.test.tsx => UsersTable.integration.test.tsx} (96%) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index b563dbc5c4c..d6bf9220551 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -2093,7 +2093,9 @@ async def get_user_key_counts( return result -def _validate_sort_params(sort_by: str | None, sort_order: str) -> dict[str, str] | None: +def _validate_sort_params( + sort_by: str | None, sort_order: str +) -> Mapping[str, object] | Sequence[Mapping[str, object]] | None: order_by: Final[dict[str, str]] = {} if sort_by is None: @@ -2106,6 +2108,11 @@ def _validate_sort_params(sort_by: str | None, sort_order: str) -> dict[str, str "spend", "user_alias", "user_role", + "max_budget", + "updated_at", + "sso_user_id", + "status", + "key_count", ] if sort_by not in valid_columns: raise HTTPException( @@ -2120,6 +2127,11 @@ def _validate_sort_params(sort_by: str | None, sort_order: str) -> dict[str, str detail={"error": "Invalid sort order. Must be 'asc' or 'desc'"}, ) + if sort_by in {"status", "key_count"}: + return None + if sort_by in {"max_budget", "updated_at", "sso_user_id"}: + nulls: Final = "first" if sort_by == "max_budget" and sort_order.lower() == "desc" else "last" + return ({sort_by: {"sort": sort_order.lower(), "nulls": nulls}}, {"user_id": "asc"}) order_by[sort_by] = sort_order.lower() return order_by @@ -2338,16 +2350,40 @@ async def get_users( # Build order_by conditions - 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 ) - users: Final[Sequence[prisma_models.LiteLLM_UserTable]] = await UserRepository(prisma_client).table.find_many( - where=where, - skip=skip, - take=page_size, - order=(order_by if order_by else {"created_at": "desc"}), # Default to created_at desc if no sort specified + from litellm.proxy.management_helpers.user_list_sorting import sorted_user_ids + + computed_sort: Final = sort_by in ("status", "key_count") + sorted_ids: Final = ( + await sorted_user_ids( + prisma_client, + sort_by="status" if sort_by == "status" else "key_count", + descending=sort_order.lower() == "desc", + skip=skip, + take=page_size, + role=role, + user_ids=user_ids, + user_email=user_email, + team=team, + sso_user_ids=sso_user_ids, + organization_ids=organization_ids, + search=search, + ) + if computed_sort + else () ) + page_users: Final = await UserRepository(prisma_client).table.find_many( + # bounded-ok: sorted_ids comes from LIMIT page_size, capped at 100 by the route + where={"AND": (where, {"user_id": {"in": sorted_ids}})} if computed_sort else where, + skip=0 if computed_sort else skip, + take=page_size, + order=order_by if order_by else {"created_at": "desc"}, + ) + positions: Final = {user_id: index for index, user_id in enumerate(sorted_ids)} + users: Final = sorted(page_users, key=lambda user: positions[user.user_id]) if computed_sort else page_users # Get total count of user rows total_count: Final[int] = await UserRepository(prisma_client).table.count(where=where) diff --git a/litellm/proxy/management_helpers/user_list_sorting.py b/litellm/proxy/management_helpers/user_list_sorting.py new file mode 100644 index 00000000000..d3d2e323cb0 --- /dev/null +++ b/litellm/proxy/management_helpers/user_list_sorting.py @@ -0,0 +1,77 @@ +from collections.abc import Sequence +from typing import Final, Literal + +from pydantic import BaseModel, ConfigDict, TypeAdapter + +from litellm.constants import UI_SESSION_TOKEN_TEAM_ID +from litellm.proxy.utils import PrismaClient + + +class _UserId(BaseModel): + model_config = ConfigDict(frozen=True) + user_id: str + + +_USER_IDS: Final = TypeAdapter(tuple[_UserId, ...]) + + +async def sorted_user_ids( + prisma_client: PrismaClient, + *, + sort_by: Literal["status", "key_count"], + descending: bool, + skip: int, + take: int, + role: str | None, + user_ids: str | None, + user_email: str | None, + team: str | None, + sso_user_ids: str | None, + organization_ids: str | None, + search: str | None, +) -> Sequence[str]: + ids: Final = tuple(value.strip() for value in (user_ids or "").split(",") if value.strip()) + sso_ids: Final = tuple(value.strip() for value in (sso_user_ids or "").split(",") if value.strip()) + org_ids: Final = tuple(value.strip() for value in (organization_ids or "").split(",") if value.strip()) + expression: Final = ( + "CASE WHEN u.metadata->'scim_active' = 'false'::jsonb THEN 1 ELSE 0 END" + if sort_by == "status" + else '(SELECT COUNT(*) FROM "LiteLLM_VerificationToken" k ' + "WHERE k.user_id = u.user_id AND k.team_id IS DISTINCT FROM $11::text)" + ) + direction: Final = "DESC" if descending else "ASC" + sql: Final = f""" + SELECT u.user_id FROM "LiteLLM_UserTable" u + WHERE ($1::text IS NULL OR u.user_role = $1) + AND ($2::text[] IS NULL + OR CASE WHEN cardinality($2::text[]) = 1 + THEN u.user_id ILIKE '%' || ($2::text[])[1] || '%' + ELSE u.user_id = ANY($2::text[]) END) + AND ($3::text IS NULL OR u.user_email ILIKE '%' || $3 || '%') + AND ($4::text IS NULL OR $4 = ANY(u.teams)) + AND ($5::boolean OR u.sso_user_id = ANY($6::text[])) + AND (cardinality($7::text[]) = 0 OR EXISTS ( + SELECT 1 FROM "LiteLLM_OrganizationMembership" m + WHERE m.user_id = u.user_id AND m.organization_id = ANY($7::text[]))) + AND ($8::text IS NULL OR u.user_id ILIKE '%' || $8 || '%' + OR u.user_email ILIKE '%' || $8 || '%') + ORDER BY {expression} {direction}, u.user_id ASC + LIMIT $9 OFFSET $10 + """ + rows: Final = _USER_IDS.validate_python( + await prisma_client.db.query_raw( + sql, + role or None, + ids if user_ids else None, + user_email, + team, + sso_user_ids is None, + sso_ids, + org_ids, + search or None, + take, + skip, + *((UI_SESSION_TOKEN_TEAM_ID,) if sort_by == "key_count" else ()), + ) + ) + return tuple(row.user_id for row in rows) diff --git a/tests/integration/management/test_user_list_sorting.py b/tests/integration/management/test_user_list_sorting.py new file mode 100644 index 00000000000..f5578140e1c --- /dev/null +++ b/tests/integration/management/test_user_list_sorting.py @@ -0,0 +1,179 @@ +from itertools import chain +from typing import Final +from uuid import uuid4 + +import pytest + +from litellm.constants import UI_SESSION_TOKEN_TEAM_ID +from tests.integration._support.client import Gateway, Scenario +from tests.integration._support.database import write_rows + + +@pytest.mark.parametrize("field", ("user_alias", "max_budget", "updated_at", "sso_user_id", "status", "key_count")) +@pytest.mark.parametrize("direction", ("asc", "desc")) +def test_user_list_sorts_all_pages(field: str, direction: str, gateway: Gateway, scenario: Scenario) -> None: + prefix: Final = f"sort-{uuid4().hex}" + ids: Final = tuple( + scenario.user( + user_id=f"{prefix}-{index}", + user_role="internal_user", + user_alias=f"Alias {value if index != 2 else 4:03}", + user_email=f"{prefix}-{index}@example.com", + max_budget=value, + metadata={"scim_active": value != 3}, + ) + for index, value in enumerate((20, 3, 3, 100, 0)) + ) + for user_id, value in zip(ids, (20, 3, 3, 100, 0), strict=True): + write_rows( + 'UPDATE "LiteLLM_UserTable" SET sso_user_id = %s, ' + "updated_at = timestamp '2030-01-01' + %s::int * interval '1 day' WHERE user_id = %s", + (f"{prefix}-{value:03}", str(value), user_id), + ) if value != 3 or user_id == ids[1] else write_rows( + "UPDATE \"LiteLLM_UserTable\" SET sso_user_id = %s, updated_at = timestamp '2030-01-04' WHERE user_id = %s", + (f"{prefix}-004", user_id), + ) + for index in range(value if value < 4 else 1): + scenario.key(user_id=user_id, key_alias=f"{user_id}-key-{index}") + write_rows( + 'UPDATE "LiteLLM_UserTable" SET max_budget = NULL, updated_at = NULL, sso_user_id = NULL WHERE user_id = %s', + (ids[-1],), + ) + scenario.key(user_id=ids[-1]) + write_rows( + 'UPDATE "LiteLLM_VerificationToken" SET team_id = %s WHERE user_id = %s', (UI_SESSION_TOKEN_TEAM_ID, ids[-1]) + ) + values: Final = ( + (0, 1, 1, 0, 0) + if field == "status" + else (1, 3, 3, 1, 0) + if field == "key_count" + else (20, 3, 4, 100, None) + if field == "sso_user_id" + else (20, 3, 4, 100, 0) + if field == "user_alias" + else (20, 3, 3, 100, None) + ) + expected: Final = tuple( + user_id + for _, user_id in sorted( + ((value, user_id) for value, user_id in zip(values, ids, strict=True)), + key=lambda item: ( + (item[0] is not None if field == "max_budget" and direction == "desc" else item[0] is None), + (item[0] or 0) * (1 if direction == "asc" else -1), + item[1], + ), + ) + ) + pages: Final = tuple( + gateway.request( + "GET", + "/user/list", + params={ + "search": prefix, + "sort_by": field, + "sort_order": direction, + "page_size": "2", + "page": str(page), + }, + ) + 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"] == len(ids) for page in pages) + assert tuple(user["user_id"] for user in chain.from_iterable(page.json()["users"] for page in pages)) == expected + + +@pytest.mark.parametrize("field", ("status", "key_count")) +def test_computed_user_sorts_preserve_filters(field: str, gateway: Gateway, scenario: Scenario) -> None: + team: Final = scenario.team() + member: Final = scenario.member(team) + other: Final = scenario.user(user_role="internal_user") + for filters in ({"team": team}, {"user_ids": member}, {"user_ids": f"{member},{other}"}, {"role": "internal_user"}): + baseline: Final = gateway.get("/user/list", {"user_ids": f"{member},{other}", **filters, "sort_by": "user_id"}) + response: Final = gateway.get("/user/list", {"user_ids": f"{member},{other}", **filters, "sort_by": field}) + assert response["total"] == baseline["total"] + assert sorted(user["user_id"] for user in response["users"]) == sorted( + user["user_id"] for user in baseline["users"] + ) + ordinary_key: Final = scenario.key(user_id=other) + denied: Final = gateway.request("GET", "/user/list", key=ordinary_key, params={"sort_by": field}) + assert denied.status_code == 403, denied.text + + +@pytest.mark.parametrize("field", ("status", "key_count")) +@pytest.mark.parametrize("direction", ("asc", "desc")) +def test_computed_user_sorts_preserve_org_admin_scope( + field: str, direction: str, gateway: Gateway, scenario: Scenario +) -> None: + prefix: Final = f"sort-org-users-{uuid4().hex}" + owner: Final = scenario.user(user_role="internal_user") + members: Final = tuple( + scenario.user(user_id=f"{prefix}-{index}", user_role="internal_user", metadata={"scim_active": index != 1}) + for index in range(4) + ) + budget: Final = scenario.budget(max_budget=10) + orgs: Final = (f"{prefix}-allowed", f"{prefix}-outside") + try: + for org in orgs: + write_rows( + 'INSERT INTO "LiteLLM_OrganizationTable" ' + "(organization_id, organization_alias, budget_id, created_by, updated_by, updated_at) " + "VALUES (%s, %s, %s, 'sorting-test', 'sorting-test', NOW())", + (org, org, budget), + ) + write_rows( + 'INSERT INTO "LiteLLM_OrganizationMembership" (user_id, organization_id, user_role) ' + "VALUES (%s, %s, 'org_admin')", + (owner, orgs[0]), + ) + for index, member in enumerate(members): + write_rows( + 'INSERT INTO "LiteLLM_OrganizationMembership" (user_id, organization_id, user_role) ' + "VALUES (%s, %s, 'internal_user')", + (member, orgs[0] if index < 3 else orgs[1]), + ) + for _ in range(index): + scenario.key(user_id=member) + caller: Final = scenario.key(user_id=owner) + values: Final = (0, 1, 0) if field == "status" else (0, 1, 2) + expected: Final = tuple( + member + for value, member in sorted( + zip(values, members[:3], strict=True), + key=lambda item: (item[0] * (-1 if direction == "desc" else 1), item[1]), + ) + ) + pages: Final = tuple( + gateway.request( + "GET", + "/user/list", + key=caller, + params={ + "search": prefix, + "sort_by": field, + "sort_order": direction, + "page_size": "2", + "page": str(page), + }, + ) + for page in (1, 2) + ) + assert all(page.status_code == 200 for page in pages), [page.text for page in pages] + assert all(page.json()["total"] == 3 for page in pages) + returned: Final = tuple(row["user_id"] for row in chain.from_iterable(page.json()["users"] for page in pages)) + assert returned == expected + denied: Final = gateway.request( + "GET", + "/user/list", + key=caller, + params={ + "organization_ids": orgs[1], + "sort_by": field, + }, + ) + assert denied.status_code == 403, denied.text + finally: + for org in orgs: + write_rows('DELETE FROM "LiteLLM_OrganizationMembership" WHERE organization_id = %s', (org,)) + write_rows('DELETE FROM "LiteLLM_OrganizationTable" WHERE organization_id = %s', (org,)) diff --git a/tests/unit/proxy/management_helpers/test_user_list_sorting.py b/tests/unit/proxy/management_helpers/test_user_list_sorting.py new file mode 100644 index 00000000000..263c1691b23 --- /dev/null +++ b/tests/unit/proxy/management_helpers/test_user_list_sorting.py @@ -0,0 +1,71 @@ +from typing import Final, Literal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from litellm.constants import UI_SESSION_TOKEN_TEAM_ID +from litellm.proxy.management_helpers.user_list_sorting import sorted_user_ids + + +@pytest.mark.parametrize("sort_by", ("status", "key_count")) +@pytest.mark.parametrize("descending", (False, True)) +@pytest.mark.asyncio +async def test_user_sort_binds_scope_search_and_page_and_excludes_ui_sessions( + sort_by: Literal["status", "key_count"], descending: bool +) -> None: + prisma: Final = MagicMock() + prisma.db.query_raw = AsyncMock(return_value=[]) + search: Final = "x' OR TRUE --" + result: Final = await sorted_user_ids( + prisma, + sort_by=sort_by, + descending=descending, + skip=25, + take=25, + role="internal_user", + user_ids=" a, b ", + user_email=search, + team="team-1", + sso_user_ids="sso-1, sso-2", + organization_ids="org-1, org-2", + search=search, + ) + assert result == () + sql, *bindings = prisma.db.query_raw.await_args.args + assert search not in sql + assert bindings == [ + "internal_user", + ("a", "b"), + search, + "team-1", + False, + ("sso-1", "sso-2"), + ("org-1", "org-2"), + search, + 25, + 25, + ] + ([UI_SESSION_TOKEN_TEAM_ID] if sort_by == "key_count" else []) + assert ("DESC" if descending else "ASC") + ", u.user_id ASC" in sql + assert ("k.team_id IS DISTINCT FROM $11::text" in sql) == (sort_by == "key_count") + + +@pytest.mark.asyncio +async def test_missing_user_filters_preserve_unfiltered_query() -> None: + prisma: Final = MagicMock() + prisma.db.query_raw = AsyncMock(return_value=[]) + result: Final = await sorted_user_ids( + prisma, + sort_by="status", + descending=False, + skip=0, + take=25, + role=None, + user_ids=None, + user_email=None, + team=None, + sso_user_ids=None, + organization_ids=None, + search=None, + ) + assert result == () + assert prisma.db.query_raw.await_args.args[1:] == (None, None, None, None, True, (), (), None, 25, 0) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.integration.test.tsx similarity index 96% rename from ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.integration.test.tsx index 13958a26adf..d728f6473f5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.integration.test.tsx @@ -307,7 +307,14 @@ describe("ViewUserDashboard", () => { expect(sortOrder).toBe("desc"); }); - it("sends the clicked column as sort_by and resets to the first page", async () => { + it.each([ + ["user_alias", "asc"], + ["max_budget", "desc"], + ["updated_at", "asc"], + ["status", "asc"], + ["key_count", "desc"], + ["sso_user_id", "asc"], + ])("sorts %s through the API and resets pagination", async (field, direction) => { const user = userEvent.setup(); renderDashboard(); @@ -315,12 +322,12 @@ describe("ViewUserDashboard", () => { expect(screen.getByText("test@example.com")).toBeInTheDocument(); }); - await user.click(screen.getByTestId("sort-header-user_email")); + await user.click(screen.getByTestId(`sort-header-${field}`)); await waitFor(() => { const latest = userListCall.mock.calls[userListCall.mock.calls.length - 1]; - expect(latest[8]).toBe("user_email"); - expect(latest[9]).toBe("asc"); + expect(latest[8]).toBe(field); + expect(latest[9]).toBe(direction); expect(latest[2]).toBe(1); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.integration.test.tsx similarity index 96% rename from ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.integration.test.tsx index b9ed37a2152..85d9ef386a3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.integration.test.tsx @@ -132,10 +132,7 @@ describe("UsersTable", () => { expect(screen.getByText("$98.85")).toBeInTheDocument(); }); - // Sorting is server-side and the backend only accepts these five keys, so a sort - // control on any other column would send an invalid sort_by. Assert the exact set: - // a missing control and an extra one both have to fail. - it("exposes a sort control for exactly the five server-sortable columns", () => { + it("exposes sorting for every data column", () => { render(); const sortableIds = screen @@ -143,7 +140,19 @@ describe("UsersTable", () => { .map((node) => (node.getAttribute("data-testid") ?? "").replace("sort-header-", "")) .sort(); - expect(sortableIds).toEqual(["created_at", "spend", "user_email", "user_id", "user_role"]); + expect(sortableIds).toEqual([ + "created_at", + "key_count", + "max_budget", + "spend", + "sso_user_id", + "status", + "updated_at", + "user_alias", + "user_email", + "user_id", + "user_role", + ]); }); it("reports the clicked column to the server sorting handler", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx index ad0cdbbd9c1..f2593f6dda8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx @@ -130,10 +130,12 @@ export const getUsersTableColumns = ({ }, { id: "status", + accessorFn: (user) => (isScimInactive(user) ? "Inactive" : "Active"), meta: { title: "Status", skeleton: "badge" }, - header: "Status", + header: ({ column }) => , + sortDescFirst: false, size: 110, - enableSorting: false, + enableSorting: true, cell: ({ row }) => { if (isScimInactive(row.original)) { return ( @@ -165,9 +167,10 @@ export const getUsersTableColumns = ({ id: "user_alias", accessorKey: "user_alias", meta: { title: "User Alias" }, - header: "User Alias", + header: ({ column }) => , + sortDescFirst: false, size: 150, - enableSorting: false, + enableSorting: true, cell: ({ row }) => ( {row.original.user_alias || "-"} @@ -187,26 +190,28 @@ export const getUsersTableColumns = ({ id: "max_budget", accessorKey: "max_budget", meta: { title: "Budget (USD)", numeric: true }, - header: "Budget (USD)", + header: ({ column }) => , + sortDescFirst: true, size: 130, - enableSorting: false, + enableSorting: true, cell: ({ row }) => , }, { id: "sso_user_id", accessorKey: "sso_user_id", meta: { title: "SSO ID" }, - header: () => ( + header: ({ column }) => ( - SSO ID + } /> ), + sortDescFirst: false, size: 160, - enableSorting: false, + enableSorting: true, cell: ({ row }) => ( {row.original.sso_user_id ?? "-"} @@ -217,9 +222,10 @@ export const getUsersTableColumns = ({ id: "key_count", accessorKey: "key_count", meta: { title: "Virtual Keys", skeleton: "badge" }, - header: "Virtual Keys", + header: ({ column }) => , + sortDescFirst: true, size: 120, - enableSorting: false, + enableSorting: true, cell: ({ row }) => { const keyCount = row.original.key_count; if (keyCount > 0) { @@ -255,9 +261,10 @@ export const getUsersTableColumns = ({ id: "updated_at", accessorKey: "updated_at", meta: { title: "Updated At" }, - header: "Updated At", + header: ({ column }) => , + sortDescFirst: false, size: 130, - enableSorting: false, + enableSorting: true, cell: ({ row }) => , }, { From 683442eb61aa9d470cb01f5f7fb2c75fff9971be Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 23 Oct 2026 16:21:36 -0700 Subject: [PATCH 07/27] feat(ui): sort every teams table data column (#48109) * feat(ui): sort every teams table data column * fix(ui): order unlimited team limits as infinity and verify org scope * fix(ui): validate team sort filters without recursion --- .../management_endpoints/team_endpoints.py | 73 +++++- .../management_helpers/team_list_sorting.py | 109 ++++++++ .../management/test_team_list_sorting.py | 244 ++++++++++++++++++ .../test_team_list_sorting.py | 85 ++++++ ...st.tsx => TeamsTable.integration.test.tsx} | 22 +- .../components/TeamsPage/teamTableColumns.tsx | 55 +++- 6 files changed, 556 insertions(+), 32 deletions(-) create mode 100644 litellm/proxy/management_helpers/team_list_sorting.py create mode 100644 tests/integration/management/test_team_list_sorting.py create mode 100644 tests/unit/proxy/management_helpers/test_team_list_sorting.py rename ui/litellm-dashboard/src/components/TeamsPage/{TeamsTable.test.tsx => TeamsTable.integration.test.tsx} (92%) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index d9763f72741..c7adae2ce8d 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -6109,13 +6109,52 @@ async def list_team_v2( "total_pages": 0, } - # Build order_by conditions - valid_sort_columns: Final = ["team_id", "team_alias", "created_at"] - order_by = None - if sort_by and sort_by in valid_sort_columns: - if sort_order.lower() not in ["asc", "desc"]: - sort_order = "asc" - order_by = {sort_by: sort_order.lower()} + from litellm.proxy.management_helpers.team_list_sorting import ( + COMPUTED_TEAM_SORTS, + UnsupportedTeamFilter, + sorted_team_ids, + ) + + valid_sort_columns: Final = ( + "team_id", + "team_alias", + "created_at", + "spend", + "max_budget", + "tpm_limit", + "rpm_limit", + "updated_at", + ) + sort_direction: Final = "desc" if str(sort_order).lower() == "desc" else "asc" + nullable_sort: Final = sort_by in ("max_budget", "tpm_limit", "rpm_limit") + order_by: Final = ( + ( + {sort_by: {"sort": sort_direction, "nulls": "first" if sort_direction == "desc" else "last"}}, + {"team_id": "asc"}, + ) + if nullable_sort + else ({sort_by: sort_direction}, {"team_id": "asc"}) + if sort_by in ("spend", "updated_at") + else {sort_by: sort_direction} + if sort_by in valid_sort_columns + else None + ) + computed_sort: Final = not use_deleted_table and sort_by in COMPUTED_TEAM_SORTS + sorted_ids: Final = ( + await sorted_team_ids( + prisma_client, + where=where_conditions, + sort_by=sort_by or "", + descending=sort_direction == "desc", + skip=skip, + take=page_size, + ) + if computed_sort + else () + ) + + if isinstance(sorted_ids, UnsupportedTeamFilter): + raise HTTPException(status_code=500, detail="Unsupported team sorting filter") # Get teams with pagination if use_deleted_table: @@ -6130,26 +6169,32 @@ async def list_team_v2( total_count = await _deleted_team_db(prisma_client).count(where=where_conditions) else: teams = await _team_db(prisma_client).find_many( - where=where_conditions, - skip=skip, + # bounded-ok: sorted_ids comes from LIMIT page_size, capped at 100 by the route + where={"AND": (where_conditions, {"team_id": {"in": sorted_ids}})} if computed_sort else where_conditions, + skip=0 if computed_sort else skip, take=page_size, - order=order_by if order_by else {"created_at": "desc"}, # Default sort + order=order_by if order_by else {"created_at": "desc"}, include=_INCLUDE_MODEL_TABLE, ) # Get total count for pagination total_count = await _team_db(prisma_client).count(where=where_conditions) + positions: Final = {team_id: index for index, team_id in enumerate(sorted_ids)} + sorted_teams: Final = sorted(teams, key=lambda team: positions[team.team_id]) if computed_sort else teams + # Calculate total pages total_pages: Final = -(-total_count // page_size) # Ceiling division # Aggregate virtual-key counts per team for the current page. The deleted # table does not carry keys_count, so it is skipped. - keys_count_by_team: dict[str, int] = {} - if not use_deleted_table: - keys_count_by_team = await _get_keys_count_by_team(prisma_client, teams) + keys_count_by_team: Final = ( + await _get_keys_count_by_team(prisma_client, sorted_teams) if not use_deleted_table else {} + ) # Convert Prisma models to response models with members_count and keys_count - team_list = _convert_teams_to_response_models(teams, use_deleted_table, keys_count_by_team=keys_count_by_team) + team_list: Final = _convert_teams_to_response_models( + sorted_teams, use_deleted_table, keys_count_by_team=keys_count_by_team + ) # Resolve resources inherited from access groups (single batch query) if not use_deleted_table: diff --git a/litellm/proxy/management_helpers/team_list_sorting.py b/litellm/proxy/management_helpers/team_list_sorting.py new file mode 100644 index 00000000000..caddd8afc08 --- /dev/null +++ b/litellm/proxy/management_helpers/team_list_sorting.py @@ -0,0 +1,109 @@ +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from itertools import chain +from types import MappingProxyType +from typing import Final + +from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter + +from litellm.proxy.utils import PrismaClient + +COMPUTED_TEAM_SORTS: Final = MappingProxyType( + { + "organization_alias": "COALESCE((SELECT NULLIF(o.organization_alias, '') " + 'FROM "LiteLLM_OrganizationTable" o WHERE o.organization_id = t.organization_id), ' + "NULLIF(t.organization_id, ''))", + "members": "CASE WHEN jsonb_typeof(t.members_with_roles) = 'array' " + "THEN jsonb_array_length(t.members_with_roles) ELSE 0 END", + "models": "cardinality(t.models)", + "keys_count": '(SELECT COUNT(*) FROM "LiteLLM_VerificationToken" k WHERE k.team_id = t.team_id)', + } +) +_WHERE: Final = TypeAdapter(dict[str, JsonValue]) + + +@dataclass(frozen=True, slots=True) +class UnsupportedTeamFilter: + field: str + + +def _field_sql(field: str, value: JsonValue, index: int) -> tuple[str, tuple[object, ...]] | UnsupportedTeamFilter: + if field not in ("team_id", "team_alias", "organization_id"): + return UnsupportedTeamFilter(field) + column: Final = f't."{field}"' + match value: + case str(text): + return f"{column} = ${index}", (text,) + case {"in": list(values)}: + return f"{column} = ANY(${index}::text[])", (values,) + case {"contains": str(text), "mode": "insensitive"}: + return f"{column} ILIKE ${index}", (f"%{text}%",) + case {"startsWith": str(text)}: + return f"{column} LIKE ${index}", (f"{text}%",) + case _: + return UnsupportedTeamFilter(field) + + +def _group_tokens( + field: str, value: Sequence[JsonValue] | Mapping[str, JsonValue] +) -> tuple[str | tuple[str, JsonValue], ...]: + children: Final = tuple(value.items()) if isinstance(value, Mapping) else tuple(("AND", item) for item in value) + if not children: + return ("FALSE" if field == "OR" else "TRUE",) + return ( + "(", + *chain.from_iterable(((f" {field} " if index else ""), child) for index, child in enumerate(children)), + ")", + ) + + +def _where_sql(where: Mapping[str, JsonValue]) -> tuple[str, tuple[object, ...]] | UnsupportedTeamFilter: + pending: Final[list[str | tuple[str, JsonValue]]] = [("AND", dict(where))] # mutable-ok: iterative tree traversal + fragments: Final[list[str]] = [] # mutable-ok: append-only SQL output avoids quadratic tuple copying + params: Final[list[object]] = [] # mutable-ok: append-only bindings follow traversal order + while pending: + match pending.pop(): + case str(fragment): + fragments.append(fragment) + case (field, value) if field in ("AND", "OR") and isinstance(value, (list, dict)): + pending.extend(reversed(_group_tokens(field, value))) + case (field, value): + match _field_sql(field, value, len(params) + 1): + case UnsupportedTeamFilter() as failure: + return failure + case (fragment, values): + fragments.append(fragment) + params.extend(values) + return "".join(fragments), tuple(params) + + +class _TeamId(BaseModel): + model_config = ConfigDict(frozen=True) + team_id: str + + +_TEAM_IDS: Final = TypeAdapter(tuple[_TeamId, ...]) + + +async def sorted_team_ids( + prisma_client: PrismaClient, + *, + where: Mapping[str, object], + sort_by: str, + descending: bool, + skip: int, + take: int, +) -> Sequence[str] | UnsupportedTeamFilter: + result: Final = _where_sql(_WHERE.validate_python(where)) + if isinstance(result, UnsupportedTeamFilter): + return result + predicate, params = result + expression: Final = COMPUTED_TEAM_SORTS[sort_by] + direction: Final = "DESC" if descending else "ASC" + sql: Final = ( + f'SELECT t.team_id FROM "LiteLLM_TeamTable" t WHERE {predicate} ' + f"ORDER BY {expression} {direction} NULLS LAST, t.team_id ASC " + f"LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}" + ) + rows: Final = _TEAM_IDS.validate_python(await prisma_client.db.query_raw(sql, *params, take, skip)) + return tuple(row.team_id for row in rows) diff --git a/tests/integration/management/test_team_list_sorting.py b/tests/integration/management/test_team_list_sorting.py new file mode 100644 index 00000000000..c09e51f0dfe --- /dev/null +++ b/tests/integration/management/test_team_list_sorting.py @@ -0,0 +1,244 @@ +import json +from itertools import chain +from typing import Final +from uuid import uuid4 + +import pytest + +from tests.integration._support.client import Gateway, Scenario +from tests.integration._support.database import write_rows + + +@pytest.mark.parametrize( + "field", ("spend", "max_budget", "tpm_limit", "rpm_limit", "updated_at", "members", "models", "keys_count") +) +@pytest.mark.parametrize("direction", ("asc", "desc")) +def test_team_list_sorts_across_pages_and_respects_membership( + field: str, + direction: str, + gateway: Gateway, + scenario: Scenario, +) -> None: + prefix: Final = f"sort-{uuid4().hex}" + owner: Final = scenario.user(user_role="internal_user") + caller: Final = scenario.key(user_id=owner) + values: Final = (20, 3, 3, 100, 0) + teams: Final = tuple( + scenario.team( + team_id=f"{prefix}-{index}", + team_alias=f"{prefix}-{index}", + members_with_roles=[{"user_id": owner, "role": "admin"}], + ) + for index in range(len(values)) + ) + outsider: Final = scenario.team(team_alias=f"{prefix}-outside") + for team_id, value in zip(teams, values, strict=True): + write_rows( + 'UPDATE "LiteLLM_TeamTable" SET spend = %s::float, max_budget = %s::float, ' + "tpm_limit = %s::bigint, rpm_limit = %s::bigint, " + "updated_at = timestamp '2030-01-01' + %s::int * interval '1 day', " + "members_with_roles = %s::jsonb, models = ARRAY(SELECT generate_series(1, %s::int)::text) " + "WHERE team_id = %s", + ( + str(value), + str(value), + str(value), + str(value), + str(value), + json.dumps( + [{"user_id": owner if i == 0 else f"{prefix}-member-{i}", "role": "admin"} for i in range(value)] + ), + str(value), + team_id, + ), + ) + write_rows( + 'INSERT INTO "LiteLLM_VerificationToken" (token, team_id) ' + "SELECT %s || '-key-' || n::text, %s FROM generate_series(1, %s::int) n", + (team_id, team_id, str(value)), + ) + write_rows( + 'UPDATE "LiteLLM_TeamTable" SET max_budget = NULL, tpm_limit = NULL, rpm_limit = NULL WHERE team_id = %s', + (teams[-1],), + ) + try: + expected: Final = tuple( + team_id + for value, team_id in sorted( + zip(values, teams, strict=True), + key=lambda item: ( + (item[0] != 0 if direction == "desc" else item[0] == 0) + if field in ("max_budget", "tpm_limit", "rpm_limit") + else False, + item[0] * (1 if direction == "asc" else -1), + item[1], + ), + ) + ) + pages: Final = tuple( + gateway.request( + "GET", + "/v2/team/list", + key=caller, + params={ + "search": prefix, + "user_id": owner, + "sort_by": field, + "sort_order": direction, + "page_size": "2", + "page": str(page), + }, + ) + for page in (1, 2, 3) + ) + assert all(page.status_code == 200 for page in pages), [page.text for page in pages] + returned: Final = tuple(row["team_id"] for row in chain.from_iterable(page.json()["teams"] for page in pages)) + assert returned == expected + assert outsider not in returned + assert all(page.json()["total"] == len(teams) for page in pages) + finally: + for team_id in teams: + write_rows('DELETE FROM "LiteLLM_VerificationToken" WHERE team_id = %s', (team_id,)) + + +@pytest.mark.parametrize("direction", ("asc", "desc")) +def test_team_list_sorts_organization_names_and_missing_values( + direction: str, gateway: Gateway, scenario: Scenario +) -> None: + prefix: Final = f"sort-org-{uuid4().hex}" + budget: Final = scenario.budget(max_budget=10) + organizations: Final = ((f"{prefix}-z", "alpha"), (f"{prefix}-a", "zulu"), (f"{prefix}-m", "")) + teams: Final = tuple(scenario.team(team_id=f"{prefix}-{i}", team_alias=f"{prefix}-{i}") for i in range(4)) + try: + for (organization_id, alias), team_id in zip(organizations, teams): + write_rows( + 'INSERT INTO "LiteLLM_OrganizationTable" ' + "(organization_id, organization_alias, budget_id, created_by, updated_by, updated_at) " + "VALUES (%s, %s, %s, 'sorting-test', 'sorting-test', NOW())", + (organization_id, alias, budget), + ) + write_rows( + 'UPDATE "LiteLLM_TeamTable" SET organization_id = %s WHERE team_id = %s', + (organization_id, team_id), + ) + ascending: Final = (teams[0], teams[2], teams[1]) + ordered: Final = ascending if direction == "asc" else tuple(reversed(ascending)) + pages: Final = tuple( + gateway.request( + "GET", + "/v2/team/list", + params={ + "search": prefix, + "sort_by": "organization_alias", + "sort_order": direction, + "page_size": "2", + "page": str(page), + }, + ) + for page in (1, 2) + ) + assert all(page.status_code == 200 for page in pages), [page.text for page in pages] + assert tuple(row["team_id"] for row in chain.from_iterable(page.json()["teams"] for page in pages)) == (*ordered, teams[-1]) + finally: + for team_id in teams: + write_rows('UPDATE "LiteLLM_TeamTable" SET organization_id = NULL WHERE team_id = %s', (team_id,)) + for organization_id, _ in organizations: + write_rows('DELETE FROM "LiteLLM_OrganizationTable" WHERE organization_id = %s', (organization_id,)) + + +@pytest.mark.parametrize("field", ("organization_alias", "members", "models", "keys_count")) +@pytest.mark.parametrize("direction", ("asc", "desc")) +def test_computed_team_sorts_preserve_org_and_membership_union( + field: str, direction: str, gateway: Gateway, scenario: Scenario +) -> None: + prefix: Final = f"sort-org-scope-{uuid4().hex}" + owner: Final = scenario.user(user_role="internal_user") + budget: Final = scenario.budget(max_budget=10) + orgs: Final = (f"{prefix}-allowed", f"{prefix}-outside") + teams: Final = tuple( + scenario.team( + team_id=f"{prefix}-{i}", + team_alias=f"{prefix}-{i}", + members_with_roles=[{"user_id": owner, "role": "admin"}] if i == 2 else [], + ) + for i in range(4) + ) + try: + for org, alias in zip(orgs, ("alpha", "zulu"), strict=True): + write_rows( + 'INSERT INTO "LiteLLM_OrganizationTable" ' + "(organization_id, organization_alias, budget_id, created_by, updated_by, updated_at) " + "VALUES (%s, %s, %s, 'sorting-test', 'sorting-test', NOW())", + (org, alias, budget), + ) + write_rows( + 'INSERT INTO "LiteLLM_OrganizationMembership" (user_id, organization_id, user_role) ' + "VALUES (%s, %s, 'org_admin')", + (owner, orgs[0]), + ) + for index, team in enumerate(teams): + write_rows( + 'UPDATE "LiteLLM_TeamTable" SET organization_id = %s, members_with_roles = %s::jsonb, ' + "models = ARRAY(SELECT generate_series(1, %s::int)::text) WHERE team_id = %s", + ( + orgs[0] if index < 2 else orgs[1], + json.dumps( + [ + {"user_id": owner if i == 0 else f"{prefix}-member-{i}", "role": "admin"} + for i in range(index + 1) + ] + ), + str(index + 1), + team, + ), + ) + write_rows( + 'INSERT INTO "LiteLLM_VerificationToken" (token, team_id) ' + "SELECT %s || '-key-' || n::text, %s FROM generate_series(1, %s::int) n", + (team, team, str(index + 1)), + ) + caller: Final = scenario.key(user_id=owner) + values: Final = (0, 0, 1) if field == "organization_alias" else (1, 2, 3) + expected: Final = tuple( + team + for value, team in sorted( + zip(values, teams[:3], strict=True), + key=lambda item: (item[0] * (-1 if direction == "desc" else 1), item[1]), + ) + ) + pages: Final = tuple( + gateway.request( + "GET", + "/v2/team/list", + key=caller, + params={ + "user_id": owner, + "search": prefix, + "sort_by": field, + "sort_order": direction, + "page_size": "2", + "page": str(page), + }, + ) + for page in (1, 2) + ) + assert all(page.status_code == 200 for page in pages), [page.text for page in pages] + assert all(page.json()["total"] == 3 for page in pages) + assert tuple(row["team_id"] for row in chain.from_iterable(page.json()["teams"] for page in pages)) == expected + denied: Final = gateway.request( + "GET", + "/v2/team/list", + key=caller, + params={ + "organization_id": orgs[1], + "sort_by": field, + }, + ) + assert denied.status_code == 403, denied.text + finally: + for team in teams: + write_rows('DELETE FROM "LiteLLM_VerificationToken" WHERE team_id = %s', (team,)) + write_rows('UPDATE "LiteLLM_TeamTable" SET organization_id = NULL WHERE team_id = %s', (team,)) + for org in orgs: + write_rows('DELETE FROM "LiteLLM_OrganizationMembership" WHERE organization_id = %s', (org,)) + write_rows('DELETE FROM "LiteLLM_OrganizationTable" WHERE organization_id = %s', (org,)) diff --git a/tests/unit/proxy/management_helpers/test_team_list_sorting.py b/tests/unit/proxy/management_helpers/test_team_list_sorting.py new file mode 100644 index 00000000000..bc4c77cc6a4 --- /dev/null +++ b/tests/unit/proxy/management_helpers/test_team_list_sorting.py @@ -0,0 +1,85 @@ +import json +import re +import sqlite3 +from typing import Final +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pydantic import JsonValue + +from litellm.proxy.management_helpers.team_list_sorting import UnsupportedTeamFilter, _where_sql, sorted_team_ids + + +@pytest.mark.parametrize( + ("where", "expected"), + ( + ({}, ("a", "b", "c")), + ({"OR": []}, ()), + ({"AND": []}, ("a", "b", "c")), + ({"team_id": "a"}, ("a",)), + ({"team_alias": {"contains": "ALPHA", "mode": "insensitive"}}, ("a", "c")), + ({"team_id": {"startsWith": "b"}}, ("b",)), + ({"organization_id": {"in": ["org-1"]}}, ("a", "b")), + ( + { + "OR": [{"team_id": "b"}, {"team_alias": {"contains": "alpha", "mode": "insensitive"}}], + "AND": [{"OR": [{"organization_id": "org-1"}, {"team_id": "c"}]}], + "team_id": {"in": ["a", "c"]}, + }, + ("a", "c"), + ), + ({"team_id": "a' OR 1=1 --"}, ()), + ), +) +def test_team_filters_preserve_search_and_authorization_intersection( + where: dict[str, JsonValue], expected: tuple[str, ...] +) -> None: + result: Final = _where_sql(where) + assert not isinstance(result, UnsupportedTeamFilter) + predicate, params = result + sqlite_predicate: Final = re.sub( + r"= ANY\((\$\d+)::text\[\]\)", r"IN (SELECT value FROM json_each(\1))", predicate + ).replace(" ILIKE ", " LIKE ") + with sqlite3.connect(":memory:") as db: + db.execute("CREATE TABLE teams (team_id TEXT, team_alias TEXT, organization_id TEXT)") + db.executemany( + "INSERT INTO teams VALUES (?, ?, ?)", + (("a", "Alpha", "org-1"), ("b", "Beta", "org-1"), ("c", "Alpha", "org-2")), + ) + rows: Final = db.execute( + f"SELECT t.team_id FROM teams t WHERE {sqlite_predicate} ORDER BY t.team_id", + { + str(index): json.dumps(value) if isinstance(value, list) else value + for index, value in enumerate(params, 1) + }, + ).fetchall() + assert tuple(row[0] for row in rows) == expected + + +@pytest.mark.parametrize("where", ({"unknown": "x"}, {"team_id": {"not": "x"}}, {"AND": [False]})) +@pytest.mark.asyncio +async def test_unsupported_filter_fails_without_reading_unscoped_teams(where: dict[str, JsonValue]) -> None: + prisma: Final = MagicMock() + prisma.db.query_raw = AsyncMock() + result: Final = await sorted_team_ids(prisma, where=where, sort_by="keys_count", descending=False, skip=0, take=25) + assert isinstance(result, UnsupportedTeamFilter) + prisma.db.query_raw.assert_not_awaited() + + +@pytest.mark.parametrize("sort_by", ("keys_count", "members", "models", "organization_alias")) +@pytest.mark.parametrize("descending", (False, True)) +@pytest.mark.asyncio +async def test_sorted_ids_bind_search_and_pagination_without_interpolating_input( + sort_by: str, descending: bool +) -> None: + prisma: Final = MagicMock() + prisma.db.query_raw = AsyncMock(return_value=[]) + search: Final = "x' OR TRUE --" + result: Final = await sorted_team_ids( + prisma, where={"team_id": search}, sort_by=sort_by, descending=descending, skip=25, take=25 + ) + assert result == () + sql, *bindings = prisma.db.query_raw.await_args.args + assert search not in sql + assert bindings == [search, 25, 25] + assert ("DESC" if descending else "ASC") + " NULLS LAST, t.team_id ASC" in sql diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx similarity index 92% rename from ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx rename to ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx index ad89b62d611..def6750071f 100644 --- a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx @@ -163,16 +163,26 @@ describe("sort contract – only backend-sortable columns are sortable", () => { it("right-aligns Spend / Budget but not Team", () => { renderTable(); - expect(screen.getByRole("columnheader", { name: "Spend / Budget" })).toHaveClass("text-right"); + expect(screen.getByRole("columnheader", { name: /Spend\s*\/\s*Budget/ })).toHaveClass("text-right"); expect(screen.getByRole("columnheader", { name: "Team" })).not.toHaveClass("text-right"); }); - it("does not make Spend / Budget sortable (the backend rejects sort_by=spend)", () => { + it.each([ + ["Spend or Budget", "Spend", "spend"], + ["Spend or Budget", "Budget", "max_budget"], + ["Members or Models or Keys", "Members", "members"], + ["Members or Models or Keys", "Models", "models"], + ["Members or Models or Keys", "Keys", "keys_count"], + ])("sorts %s by %s through the API", async (menu, label, field) => { + const user = userEvent.setup(); renderTable(); - expect(screen.queryByText("Spend / Budget").closest("button")).toBeNull(); - // Team and Created are the only sortable headers. - expect(screen.getByText("Team").closest("button")).not.toBeNull(); - expect(screen.getByText("Created").closest("button")).not.toBeNull(); + await user.click(screen.getByRole("button", { name: `Sort options for ${menu}` })); + await user.click(await screen.findByRole("menuitem", { name: `${label} descending` })); + await waitFor(() => expect(lastOptions()).toMatchObject({ sortBy: field, sortOrder: "desc" })); + expect(mockUseTeamsTable.mock.calls.at(-1)?.[0]).toBe(1); + await user.click(screen.getByRole("button", { name: `Sort options for ${menu}` })); + await user.click(await screen.findByRole("menuitem", { name: `${label} ascending` })); + await waitFor(() => expect(lastOptions()).toMatchObject({ sortBy: field, sortOrder: "asc" })); }); }); diff --git a/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx b/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx index 05bfccf6417..e56e0d16522 100644 --- a/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx @@ -3,7 +3,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { Copy, KeyRound, Layers, MoreHorizontal, Pencil, Trash2, Users } from "lucide-react"; -import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { DataTableMultiSortHeader, DataTableSortHeader } from "@/components/shared/DataTable"; import { DateCell, IdentityCell, SpendBudgetCell } from "@/components/shared/table_cells"; import { buttonVariants } from "@/components/ui/button"; import { @@ -174,9 +174,10 @@ export const getTeamTableColumns = ({ id: "organization_alias", accessorKey: "organization_id", meta: { title: "Organization" }, - header: "Organization", + header: ({ column }) => , + sortDescFirst: false, size: 160, - enableSorting: false, + enableSorting: true, cell: (info) => { const orgId = info.getValue() as string | null; if (!orgId) return —; @@ -202,7 +203,16 @@ export const getTeamTableColumns = ({
      ), }, - header: "Resources", + header: ({ table }) => ( + + ), size: 210, enableSorting: false, cell: ({ row }) => , @@ -211,7 +221,15 @@ export const getTeamTableColumns = ({ id: "spend", accessorKey: "spend", meta: { title: "Spend / Budget", skeleton: "meter", numeric: true }, - header: "Spend / Budget", + header: ({ table }) => ( + + ), size: 200, enableSorting: false, cell: ({ row }) => ( @@ -234,24 +252,36 @@ export const getTeamTableColumns = ({ }, { id: "members", + accessorFn: teamMemberCount, meta: { title: "Members", numeric: true }, - header: "Members", + header: ({ column }) => , + sortDescFirst: true, size: 110, - enableSorting: false, + enableSorting: true, cell: ({ row }) => {teamMemberCount(row.original)}, }, { id: "models", + accessorFn: teamModelCount, meta: { title: "Models", numeric: true }, - header: "Models", + header: ({ column }) => , + sortDescFirst: true, size: 100, - enableSorting: false, + enableSorting: true, cell: ({ row }) => {teamModelCount(row.original)}, }, { id: "rate_limits", meta: { title: "Rate Limits", skeleton: "twoLine" }, - header: "Rate Limits", + header: ({ table }) => ( + + ), size: 140, enableSorting: false, cell: ({ row }) => ( @@ -265,9 +295,10 @@ export const getTeamTableColumns = ({ id: "updated_at", accessorKey: "updated_at", meta: { title: "Updated" }, - header: "Updated", + header: ({ column }) => , + sortDescFirst: false, size: 130, - enableSorting: false, + enableSorting: true, cell: (info) => , }, { From bffc166cd66b1661d0fc25e8f9783c370f1dda21 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 23 Oct 2026 16:21:53 -0700 Subject: [PATCH 08/27] feat(ui): sort budgets by reset duration (#48107) * feat(ui): sort budgets by reset duration * fix(ui): order unlimited budget limits as infinity * fix(ui): sort localized budget reset durations --- .../management_v1/budgets.py | 46 +++++++-- .../management/test_budget_list_sorting.py | 94 +++++++++++++++++++ .../management_v1/test_budgets.py | 20 +--- .../budgets/_components/BudgetTable.test.tsx | 9 +- .../_components/BudgetTableColumns.tsx | 3 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 5 +- 6 files changed, 149 insertions(+), 28 deletions(-) create mode 100644 tests/integration/management/test_budget_list_sorting.py diff --git a/litellm/proxy/management_endpoints/management_v1/budgets.py b/litellm/proxy/management_endpoints/management_v1/budgets.py index 1970a0797ef..19a5408496a 100644 --- a/litellm/proxy/management_endpoints/management_v1/budgets.py +++ b/litellm/proxy/management_endpoints/management_v1/budgets.py @@ -3,6 +3,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime +from itertools import chain from types import MappingProxyType from typing import Annotated, Final @@ -76,6 +77,32 @@ _ROW_COUNTS: Final = TypeAdapter(tuple[_RowCount, ...]) SELECTED_COLUMNS: Final = ", ".join(f'"{name}"' for name in BudgetListItem.model_fields) +# Unicode 17.0 Nd zeroes, each followed by its other nine digits in UnicodeData.txt. +_DECIMAL_ZEROES: Final = "0٠۰߀०০੦૦୦௦౦೦൦෦๐໐༠၀႐០᠐᥆᧐᪀᪐᭐᮰᱀᱐꘠꣐꤀꧐꧰꩐꯰0𐒠𐴰𐵀𑁦𑃰𑄶𑇐𑋰𑑐𑓐𑙐𑛀𑛐𑛚𑜰𑣠𑥐𑯰𑱐𑵐𑶠\U00011de0𑽐𖄰𖩠𖫀𖭐𖵰𜳰𝟎𝟘𝟢𝟬𝟶𞅀𞋰𞓰𞗱𞥐🯰" +_DECIMAL_DIGITS: Final = "".join( + chain.from_iterable(tuple(chr(ord(zero) + digit) for digit in range(10)) for zero in _DECIMAL_ZEROES) +) +_ASCII_DIGITS: Final = "0123456789" * len(_DECIMAL_ZEROES) + + +_BUDGET_DURATION_SECONDS: Final = f"""( + SELECT EXTRACT(EPOCH FROM ( + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + + (parts[1] || ' ' || CASE parts[2] + WHEN 's' THEN 'seconds' WHEN 'm' THEN 'minutes' WHEN 'h' THEN 'hours' + WHEN 'd' THEN 'days' WHEN 'w' THEN 'weeks' WHEN 'mo' THEN 'months' END)::interval + - (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + )) FROM regexp_match( + CASE lower(trim(budget_duration)) + WHEN 'hourly' THEN '1h' WHEN 'daily' THEN '24h' + WHEN 'weekly' THEN '7d' WHEN 'monthly' THEN '30d' + ELSE CASE WHEN octet_length(budget_duration) = length(budget_duration) THEN budget_duration + ELSE translate(budget_duration, '{_DECIMAL_DIGITS}', '{_ASCII_DIGITS}') END END, + '^([0-9]+)(mo|[smhdw])' + ) AS duration(parts) +)""" + + @dataclass(frozen=True, slots=True) class PrismaBudgetListExecutor: """The database half of the budgets list. Every caller-supplied value is bound to a @@ -91,10 +118,16 @@ class PrismaBudgetListExecutor: async def find_many(self, plan: QueryPlan) -> Sequence[BudgetListItem]: clauses, params = where_sql(plan.where) + ordering: Final = ", ".join( + order_by_sql((key,)).replace("NULLS LAST", "NULLS FIRST") + if key.descending and key.field in ("max_budget", "tpm_limit", "rpm_limit", "tpd_limit") + else order_by_sql((key,)) + for key in plan.order + ).replace('"budget_duration"', _BUDGET_DURATION_SECONDS) sql: Final = ( f"SELECT {SELECTED_COLUMNS} FROM {BUDGET_TABLE}" + (f" WHERE {clauses}" if clauses else "") - + f" ORDER BY {order_by_sql(plan.order)}" + + f" ORDER BY {ordering}" + f" LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}" ) return _BUDGET_ROWS.validate_python(await self.prisma_client.db.query_raw(sql, *params, plan.take, plan.skip)) @@ -111,8 +144,6 @@ def _scope(caller: UserAPIKeyAuth) -> Scope: return ScopeDenied(reason=f"Only proxy admins can list budgets, your role={caller.user_role}") -# budget_duration is deliberately absent from `sortable`: the column holds strings -# like "7d" and "30d", so a lexicographic ORDER BY puts "30d" ahead of "7d". BUDGET_FILTERS: Final[Mapping[str, FilterSpec]] = MappingProxyType( { "budget_duration": FilterSpec(type=str, ops=frozenset(("in", "is_null"))), @@ -123,7 +154,9 @@ BUDGET_FILTERS: Final[Mapping[str, FilterSpec]] = MappingProxyType( BUDGETS_LIST_SPEC: Final[ListSpec[BudgetListItem, BudgetListItem]] = ListSpec( resource="budgets", - sortable=frozenset(("budget_id", "max_budget", "tpm_limit", "rpm_limit", "tpd_limit", "created_at")), + sortable=frozenset( + ("budget_id", "max_budget", "tpm_limit", "rpm_limit", "tpd_limit", "created_at", "budget_duration") + ), searchable=frozenset(("budget_id",)), filters=BUDGET_FILTERS, default_sort=(SortKey(field="created_at", descending=True),), @@ -154,8 +187,9 @@ async def list_budgets( way to page, sort or filter it. `sort` takes a comma-separated list of `budget_id`, `max_budget`, `tpm_limit`, - `rpm_limit`, `tpd_limit` or `created_at`, each optionally prefixed with `-` for descending, - and defaults to `-created_at`. `budget_id` is appended to every sort as the + `rpm_limit`, `tpd_limit`, `created_at` or `budget_duration`, each optionally prefixed with `-` for descending, + and defaults to `-created_at`. Reset periods sort by elapsed time, with calendar months + measured from the current UTC date and unset periods last. `budget_id` is appended to every sort as the tiebreaker. `q` is a case-insensitive substring match on `budget_id`. `page_size` defaults to 50 and is capped at 100. Filters are `filter[budget_duration][in|is_null]`, `filter[max_budget][gte|lte|is_null]` diff --git a/tests/integration/management/test_budget_list_sorting.py b/tests/integration/management/test_budget_list_sorting.py new file mode 100644 index 00000000000..aae0bd6e274 --- /dev/null +++ b/tests/integration/management/test_budget_list_sorting.py @@ -0,0 +1,94 @@ +from datetime import datetime, timezone +from itertools import chain +from typing import Final +from uuid import uuid4 + +import pytest + +from litellm.litellm_core_utils.duration_parser import duration_in_seconds_from +from tests.integration._support.client import Gateway, Scenario + + +@pytest.mark.parametrize("descending", (False, True)) +def test_budget_reset_sort_orders_durations_before_pagination( + descending: bool, + gateway: Gateway, + scenario: Scenario, +) -> None: + prefix: Final = f"sort-{uuid4().hex}" + durations: Final = ("30d", "7d", "1h", "60m", "1mo", "weekly", "monthly", "٥d", "۵d", "५d", "5d", "𝟝d", "5d", None) + ids: Final = tuple( + scenario.budget(budget_id=f"{prefix}-{index}", budget_duration=duration, max_budget=10) + for index, duration in enumerate(durations) + ) + now: Final = datetime.now(timezone.utc) + expected: Final = tuple( + budget_id + for _, budget_id in sorted( + zip(durations, ids, strict=True), + key=lambda item: ( + item[0] is None, + duration_in_seconds_from(now, item[0]) * (-1 if descending else 1) if item[0] else 0, + item[1], + ), + ) + ) + pages: Final = tuple( + gateway.request( + "GET", + "/management/v1/budgets", + params={ + "q": prefix, + "sort": "-budget_duration" if descending else "budget_duration", + "page_size": "3", + "page": str(page), + }, + ) + for page in range(1, -(-len(ids) // 3) + 1) + ) + assert all(page.status_code == 200 for page in pages), [page.text for page in pages] + assert tuple(row["budget_id"] for row in chain.from_iterable(page.json()["data"] for page in pages)) == expected + filtered: Final = gateway.request( + "GET", + "/management/v1/budgets", + params={ + "q": prefix, + "sort": "budget_duration", + "filter[budget_duration][in]": "7d,30d", + }, + ) + assert filtered.status_code == 200, filtered.text + assert [row["budget_duration"] for row in filtered.json()["data"]] == ["7d", "30d"] + + +@pytest.mark.parametrize("field", ("max_budget", "tpm_limit", "rpm_limit", "tpd_limit")) +@pytest.mark.parametrize("descending", (False, True)) +def test_budget_limits_sort_unlimited_as_infinity( + field: str, descending: bool, gateway: Gateway, scenario: Scenario +) -> None: + prefix: Final = f"sort-unlimited-{uuid4().hex}" + values: Final = (20, None, 0, 3) + ids: Final = tuple(scenario.budget(budget_id=f"{prefix}-{i}", **{field: value}) for i, value in enumerate(values)) + expected: Final = tuple( + identity + for value, identity in sorted( + zip(values, ids, strict=True), + key=lambda item: float("inf") if item[0] is None else item[0], + reverse=descending, + ) + ) + pages: Final = tuple( + gateway.request( + "GET", + "/management/v1/budgets", + params={ + "q": prefix, + "sort": ("-" if descending else "") + field, + "page_size": "2", + "page": str(page), + }, + ) + for page in (1, 2) + ) + assert all(page.status_code == 200 for page in pages), [page.text for page in pages] + assert tuple(row["budget_id"] for row in chain.from_iterable(page.json()["data"] for page in pages)) == expected diff --git a/tests/unit/proxy/management_endpoints/management_v1/test_budgets.py b/tests/unit/proxy/management_endpoints/management_v1/test_budgets.py index 2b438a9d370..4b42e6404a3 100644 --- a/tests/unit/proxy/management_endpoints/management_v1/test_budgets.py +++ b/tests/unit/proxy/management_endpoints/management_v1/test_budgets.py @@ -52,7 +52,7 @@ app.include_router(router) client = TestClient(app) BUDGETS_PATH = f"{MANAGEMENT_V1_PREFIX}/budgets" -SORTABLE = ["budget_id", "created_at", "max_budget", "rpm_limit", "tpd_limit", "tpm_limit"] +SORTABLE = ["budget_duration", "budget_id", "created_at", "max_budget", "rpm_limit", "tpd_limit", "tpm_limit"] def _row(budget_id: str, **overrides: Any) -> dict[str, Any]: @@ -172,22 +172,12 @@ def test_appends_the_tiebreaker_to_an_explicit_sort(query_raw, as_proxy_admin): _get("sort=-max_budget") - assert 'ORDER BY "max_budget" DESC NULLS LAST, "budget_id" ASC NULLS LAST' in _select_call(query_raw)[0] + assert 'ORDER BY "max_budget" DESC NULLS FIRST, "budget_id" ASC NULLS LAST' in _select_call(query_raw)[0] -def test_refuses_to_sort_on_budget_duration(query_raw, as_proxy_admin): - """The column holds "7d"/"30d", so a lexicographic ORDER BY would put "30d" - before "7d" and silently mis-order the page.""" - _serve(query_raw, []) - +def test_accepts_duration_sorting(query_raw, as_proxy_admin): response = _get("sort=budget_duration") - - assert response.status_code == 400 - assert response.headers["content-type"].startswith("application/problem+json") - body = response.json() - assert "budget_duration" in body["detail"] - assert body["allowed"] == SORTABLE - query_raw.assert_not_called() + assert response.status_code == 200 def test_the_advertised_sort_fields_are_the_ones_that_work(query_raw, as_proxy_admin): @@ -346,7 +336,7 @@ def test_q_does_not_search_any_other_column(query_raw, as_proxy_admin): def test_is_null_selects_the_unlimited_budgets(query_raw, as_proxy_admin): - """"Unlimited" is max_budget IS NULL; `max_budget = 0` would be a hard zero cap.""" + """ "Unlimited" is max_budget IS NULL; `max_budget = 0` would be a hard zero cap.""" _serve(query_raw, [_row("b-unlimited", max_budget=None)]) body = _get("filter[max_budget][is_null]=true").json() diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx index ae898645de4..b4fb7586e6d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx @@ -134,13 +134,16 @@ describe("BudgetTable", () => { } }); - it("should not make the reset column sortable", async () => { + it("should request duration sorting from the server", async () => { const user = userEvent.setup(); - renderWithProviders(); + const onSortingChange = vi.fn(); + renderWithProviders(); await showColumn(user, "budget_duration"); const headers = screen.getAllByRole("columnheader").map((header) => header.textContent); expect(headers).toContain("Reset"); - expect(screen.queryByTestId("sort-header-budget_duration")).not.toBeInTheDocument(); + await user.click(screen.getByTestId("sort-header-budget_duration")); + expect(onSortingChange).toHaveBeenCalled(); + expect(onSortingChange.mock.calls[0][0]([])).toEqual([{ id: "budget_duration", desc: false }]); }); it("should ask the list for a new sort when a sortable header is clicked", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx index fb894322208..3ec291c1158 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx @@ -139,8 +139,7 @@ export const getBudgetTableColumns = ({ accessorKey: "budget_duration", filterFn: serverFilter, meta: { title: "Reset" }, - // "7d"/"30d" sort lexicographically, not chronologically, so the route does not offer it. - enableSorting: false, + sortDescFirst: false, header: ({ column }) => , size: 110, cell: ({ row }) => , diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index a71933626fc..1d36f5fd9a2 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -9280,8 +9280,9 @@ export interface paths { * way to page, sort or filter it. * * `sort` takes a comma-separated list of `budget_id`, `max_budget`, `tpm_limit`, - * `rpm_limit`, `tpd_limit` or `created_at`, each optionally prefixed with `-` for descending, - * and defaults to `-created_at`. `budget_id` is appended to every sort as the + * `rpm_limit`, `tpd_limit`, `created_at` or `budget_duration`, each optionally prefixed with `-` for descending, + * and defaults to `-created_at`. Reset periods sort by elapsed time, with calendar months + * measured from the current UTC date and unset periods last. `budget_id` is appended to every sort as the * tiebreaker. `q` is a case-insensitive substring match on `budget_id`. * `page_size` defaults to 50 and is capped at 100. Filters are * `filter[budget_duration][in|is_null]`, `filter[max_budget][gte|lte|is_null]` From acfa7a29addc4cc44f7f26d9e6b4309872737aac Mon Sep 17 00:00:00 2001 From: Igor Tavares Date: Fri, 23 Oct 2026 20:27:33 -0300 Subject: [PATCH 09/27] fix(proxy): record spend_logs_metadata on /interactions (#47848) The router plants its deployment fields in litellm_metadata for Interactions calls, while the proxy put the request's metadata (spend_logs_metadata, tags, key fields) under metadata. The spend log resolves litellm_metadata and only carries user_api_key* fields over, so spend_logs_metadata never reached LiteLLM_SpendLogs. Route /interactions like /responses so both land in litellm_metadata. Fixes #47841 Co-authored-by: Igor Tavares <2163472+igorborgest@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- litellm/proxy/litellm_pre_call_utils.py | 1 + .../unit/proxy/test_litellm_pre_call_utils.py | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 8a45f658f36..da849ee4fad 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -334,6 +334,7 @@ LITELLM_METADATA_ROUTES: Final = ( "/v1/messages", "responses", "files", + "interactions", ) LITELLM_TRACE_CONTROL_METADATA_FIELDS: Final = frozenset( diff --git a/tests/unit/proxy/test_litellm_pre_call_utils.py b/tests/unit/proxy/test_litellm_pre_call_utils.py index d9def23bde8..04f6381f32f 100644 --- a/tests/unit/proxy/test_litellm_pre_call_utils.py +++ b/tests/unit/proxy/test_litellm_pre_call_utils.py @@ -106,6 +106,13 @@ class TestGetMetadataVariableName: request = self._make_request("/v1/files") assert get_metadata_variable_name(request) == "litellm_metadata" + @pytest.mark.parametrize( + "path", ["/v1beta/interactions", "/interactions", "/v1beta/interactions/v1_abc123", "/interactions/v1_abc123/cancel"] + ) + def test_returns_litellm_metadata_for_interactions_routes(self, path): + request = self._make_request(path) + assert get_metadata_variable_name(request) == "litellm_metadata" + def test_returns_metadata_for_chat_completions(self): request = self._make_request("/chat/completions") assert get_metadata_variable_name(request) == "metadata" @@ -708,6 +715,46 @@ async def test_add_litellm_data_to_request_parses_json_object_string_litellm_met assert updated["litellm_metadata"]["cost_centre"] == "research" +@pytest.mark.asyncio +async def test_interactions_spend_logs_metadata_survives_the_router_metadata_bucket(): + """GH#47841: on /v1beta/interactions the router plants its deployment fields in + `litellm_metadata`. The proxy must put the request's metadata there too: otherwise the + spend log reads only the router's bucket and `spend_logs_metadata` from the + `x-litellm-spend-logs-metadata` header never reaches LiteLLM_SpendLogs.""" + request_mock = MagicMock(spec=Request) + request_mock.url = MagicMock() + request_mock.url.__str__.return_value = "http://localhost/v1beta/interactions" + request_mock.url.path = "/v1beta/interactions" + request_mock.method = "POST" + request_mock.query_params = {} + request_mock.headers = { + "Content-Type": "application/json", + "x-litellm-spend-logs-metadata": '{"cost_centre": "research"}', + } + request_mock.client = MagicMock() + request_mock.client.host = "127.0.0.1" + request_mock.state.parent_otel_span = None + + data = await add_litellm_data_to_request( + data={"model": "gemini-3.1-flash-lite", "input": "Reply OK.", "store": True}, + request=request_mock, + user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"), + proxy_config=MagicMock(), + general_settings={}, + version="test-version", + ) + assert data["litellm_metadata"]["spend_logs_metadata"] == {"cost_centre": "research"} + + # The router then adds its deployment fields to the same bucket, and the spend log + # resolves that bucket. + data["litellm_metadata"].update({"model_group": "gemini-3.1-flash-lite", "deployment": "gemini/gemini-3.1-flash-lite"}) + resolved = get_litellm_metadata_from_kwargs( + {"litellm_params": {"metadata": data.get("metadata") or {}, "litellm_metadata": data["litellm_metadata"]}} + ) + assert resolved["spend_logs_metadata"] == {"cost_centre": "research"} + assert resolved["model_group"] == "gemini-3.1-flash-lite" + + @pytest.mark.asyncio async def test_add_litellm_data_to_request_proxy_server_request_body_is_post_strip(): """Regression: proxy_server_request['body'] used to be snapshotted before From 23ccff69a105bf70499c6003393e3390d6985aeb Mon Sep 17 00:00:00 2001 From: shrey-berri Date: Fri, 23 Oct 2026 16:34:55 -0700 Subject: [PATCH 10/27] fix(azure_ai): forward the thinking-binding-controls beta header to Foundry (#48033) --- .../src/providers/anthropic/beta.rs | 6 +- litellm/anthropic_beta_headers_config.json | 2 +- litellm/proxy/litellm_pre_call_utils.py | 5 +- .../unit/proxy/test_litellm_pre_call_utils.py | 61 +++++++++++++++++++ .../test_anthropic_beta_headers_filtering.py | 4 +- 5 files changed, 72 insertions(+), 6 deletions(-) diff --git a/litellm-rust/crates/llms-types/src/providers/anthropic/beta.rs b/litellm-rust/crates/llms-types/src/providers/anthropic/beta.rs index b1509313b88..8429c98a88a 100644 --- a/litellm-rust/crates/llms-types/src/providers/anthropic/beta.rs +++ b/litellm-rust/crates/llms-types/src/providers/anthropic/beta.rs @@ -235,7 +235,10 @@ impl AnthropicBeta { BetaProvider::Databricks, ], Self::Compact20260112 => &[BetaProvider::AzureAi, BetaProvider::BedrockConverse], - Self::ComputerUse20250124 | Self::ComputerUse20251124 | Self::Context1m20250807 => &[], + Self::ComputerUse20250124 + | Self::ComputerUse20251124 + | Self::Context1m20250807 + | Self::ThinkingBindingControls20260801 => &[], Self::DangerousToolUse20260903 => &[BetaProvider::BedrockConverse], Self::Effort20251124 => &[BetaProvider::VertexAi], Self::FastMode20260201 | Self::Oauth20250420 => &[ @@ -272,7 +275,6 @@ impl AnthropicBeta { BetaProvider::Databricks, ], Self::StructuredOutputs20251113 => &[BetaProvider::Bedrock, BetaProvider::VertexAi], - Self::ThinkingBindingControls20260801 => &[BetaProvider::AzureAi], Self::ThinkingDisplayUpdates20260818 => &[BetaProvider::Databricks], Self::ToolSearchTool20251019 => &[ BetaProvider::Anthropic, diff --git a/litellm/anthropic_beta_headers_config.json b/litellm/anthropic_beta_headers_config.json index 236b18fe428..d68de6c9d4d 100644 --- a/litellm/anthropic_beta_headers_config.json +++ b/litellm/anthropic_beta_headers_config.json @@ -67,7 +67,7 @@ "structured-outputs-2025-11-13": "structured-outputs-2025-11-13", "text_editor_20241022": null, "text_editor_20250124": null, - "thinking-binding-controls-2026-08-01": null, + "thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01", "thinking-display-updates-2026-08-18": "thinking-display-updates-2026-08-18", "token-efficient-tools-2025-02-19": null, "web-fetch-2025-09-10": "web-fetch-2025-09-10", diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index da849ee4fad..8dc7501bc3b 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -4278,6 +4278,9 @@ _ANTHROPIC_API_HEADER_PROVIDERS: Final = ",".join( LlmProviders.VERTEX_AI.value, ) ) +_NATIVE_MESSAGES_ANTHROPIC_API_HEADER_PROVIDERS: Final = ",".join( + (LlmProviders.AZURE_AI.value, LlmProviders.DATABRICKS.value) +) _ANTHROPIC_OAUTH_CREDENTIAL_PROVIDERS: Final = ",".join(sorted(ANTHROPIC_OAUTH_FORWARD_PROVIDERS)) @@ -4306,7 +4309,7 @@ def add_provider_specific_headers_to_request( custom_llm_provider=_ANTHROPIC_API_HEADER_PROVIDERS, extra_headers=anthropic_api_headers ), ProviderSpecificHeader( - custom_llm_provider=LlmProviders.DATABRICKS.value, + custom_llm_provider=_NATIVE_MESSAGES_ANTHROPIC_API_HEADER_PROVIDERS, extra_headers=anthropic_api_headers, native_anthropic_messages_only=True, ), diff --git a/tests/unit/proxy/test_litellm_pre_call_utils.py b/tests/unit/proxy/test_litellm_pre_call_utils.py index 04f6381f32f..64ff8392c02 100644 --- a/tests/unit/proxy/test_litellm_pre_call_utils.py +++ b/tests/unit/proxy/test_litellm_pre_call_utils.py @@ -8111,6 +8111,67 @@ def test_client_anthropic_api_headers_stay_off_openai_compatible_providers(): assert forwarded == {} +def test_client_anthropic_api_headers_stay_off_azure_ai_outside_native_messages(): + forwarded = _headers_forwarded_to({"anthropic-beta": "thinking-binding-controls-2026-08-01"}, "azure_ai") + + assert forwarded == {} + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("local_beta_headers_config") +async def test_client_thinking_binding_beta_reaches_azure_ai_foundry_claude(): + import httpx + + from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler + + data: Final[dict] = {"model": "claude-opus-4-8"} # mutable-ok: the function under test writes into it + add_provider_specific_headers_to_request( + data=data, + headers={ + "content-type": "application/json", + "anthropic-beta": "thinking-binding-controls-2026-08-01", + "anthropic-version": "2026-08-01", + }, + ) + received: Final[list[httpx.Request]] = [] # mutable-ok: the fake Foundry endpoint records what the proxy sent + + def foundry_answers(request: httpx.Request) -> httpx.Response: + received.append(request) + return httpx.Response( + status_code=200, + json={ + "id": "msg_01", + "type": "message", + "role": "assistant", + "model": "claude-opus-4-8", + "content": [{"type": "text", "text": "hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 12, "output_tokens": 6}, + }, + request=request, + ) + + upstream: Final = AsyncHTTPHandler(transport=httpx.MockTransport(foundry_answers)) + try: + await litellm.anthropic.messages.acreate( + max_tokens=16, + messages=[{"role": "user", "content": "hi"}], + thinking={"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "drop_block"}}, + model="azure_ai/claude-opus-4-8", + api_key="foundry-key", + api_base="https://my-resource.services.ai.azure.com/anthropic", + client=upstream, + provider_specific_header=data["provider_specific_header"], + ) + finally: + await upstream.close() + + assert received[0].headers["anthropic-beta"] == "thinking-binding-controls-2026-08-01" + assert received[0].headers["anthropic-version"] == "2026-08-01" + assert json.loads(received[0].content)["thinking"]["block_binding"] == {"prefix_mismatch_behavior": "drop_block"} + + TYPESAFE_DANGER_ZONE_HEADERS = { "TypeSafe-Danger-Zone-Images": "true", "typesafe-danger-zone-long-context": "true", diff --git a/tests/unit/test_anthropic_beta_headers_filtering.py b/tests/unit/test_anthropic_beta_headers_filtering.py index 2a85122d91c..4d5ba1c5897 100644 --- a/tests/unit/test_anthropic_beta_headers_filtering.py +++ b/tests/unit/test_anthropic_beta_headers_filtering.py @@ -429,13 +429,13 @@ class TestAnthropicBetaHeadersFiltering: assert filtered == ["fine-grained-tool-streaming-2025-05-14"] @pytest.mark.parametrize( - "provider", ["anthropic", "bedrock", "bedrock_converse", "vertex_ai", "databricks"] + "provider", ["anthropic", "azure_ai", "bedrock", "bedrock_converse", "vertex_ai", "databricks"] ) def test_thinking_binding_controls_forwarded(self, provider): """`thinking.block_binding` (preserved thinking, Claude Fable 5.1) is only accepted alongside thinking-binding-controls-2026-08-01. The body field is forwarded untouched, so stripping the header (previously unknown, hence - dropped) makes Bedrock and Vertex reject the request with + dropped) makes Bedrock, Vertex and Azure AI Foundry reject the request with "thinking.adaptive.block_binding: Extra inputs are not permitted".""" filtered = filter_and_transform_beta_headers( beta_headers=["thinking-binding-controls-2026-08-01"], From ca64a3bef2ee0c6430f338c6967288d998ed7f7e Mon Sep 17 00:00:00 2001 From: Ishaan Date: Fri, 23 Oct 2026 16:39:23 -0700 Subject: [PATCH 11/27] feat(chat): add Moyai UI, model hub, images, and usage metrics (#40557) (#48108) * feat(chat): port Moyai UI with Model Hub and images * feat(chat): add Models sidebar with embedded Model Hub * feat(chat): polish Model Hub with provider cards and logos * refactor(chat): use a provider dropdown in Model Hub * feat(chat): keep workspace switching visible with a Chat identity * Add minimal opt-in Chat usage metrics and prioritize Chat in workspace selector * fix(chat): enable usage metrics by default only when chat is used * fix(chat): count requesting users with frontend-only metrics * feat(chat): send default-on request metrics to litellm-tel --- tests/unit/proxy/telemetry/test_endpoints.py | 5 +- tests/unit/proxy/telemetry/test_settings.py | 1 - .../src/app/chat/layout.integration.test.tsx | 142 ++++ .../src/app/chat/layout.test.tsx | 79 +- ui/litellm-dashboard/src/app/chat/layout.tsx | 16 +- .../app/chat/models/page.integration.test.tsx | 172 +++++ .../src/app/chat/models/page.tsx | 37 + .../app/chat/page.images.integration.test.tsx | 355 +++++++++ .../src/app/chat/page.integration.test.tsx | 4 +- ui/litellm-dashboard/src/app/chat/page.tsx | 719 +++++++++-------- .../chat/page.url-state.integration.test.tsx | 30 +- ui/litellm-dashboard/src/app/globals.css | 375 +++++++++ .../ModelHubProviders.integration.test.tsx | 85 ++ .../components/AIHub/ModelHubProviders.tsx | 103 +++ .../components/AIHub/ModelHubTable.test.tsx | 101 +++ .../src/components/AIHub/ModelHubTable.tsx | 167 ++-- .../AIHub/ModelHubTableColumns.test.tsx | 21 +- .../components/AIHub/ModelHubTableColumns.tsx | 354 +++++---- .../DashboardHeader.integration.test.tsx | 4 +- .../src/components/DashboardHeader.test.tsx | 8 +- .../Navbar/ViewSwitcher.integration.test.tsx | 179 +++++ .../components/Navbar/ViewSwitcher.test.tsx | 234 ------ .../src/components/Navbar/ViewSwitcher.tsx | 142 ++-- .../components/PublicModelHubTableColumns.tsx | 83 +- .../src/components/chat/ChatMessages.test.tsx | 104 +++ .../src/components/chat/ChatMessages.tsx | 104 ++- .../src/components/chat/ChatModelPicker.tsx | 122 +++ .../chat/ChatShell.integration.test.tsx | 173 +++++ .../chat/ChatShell.serverRootPath.test.ts | 6 +- .../src/components/chat/ChatShell.test.tsx | 91 --- .../src/components/chat/ChatShell.tsx | 316 +++++--- .../ConversationList.integration.test.tsx | 89 +++ .../src/components/chat/ConversationList.tsx | 119 ++- .../src/components/chat/chatImageStorage.ts | 125 +++ .../src/components/chat/design.md | 63 +- .../src/components/chat/imageHelpers.test.tsx | 113 +++ .../src/components/chat/imageHelpers.ts | 83 ++ .../components/chat/moyai/FONT-LICENSE.txt | 93 +++ .../src/components/chat/moyai/LICENSE.txt | 21 + .../chat/moyai/MoyaiChatComposer.test.tsx | 116 +++ .../chat/moyai/MoyaiChatComposer.tsx | 81 ++ .../chat/moyai/MoyaiChatHome.test.tsx | 86 +++ .../components/chat/moyai/MoyaiChatHome.tsx | 108 +++ .../chat/moyai/geist-mono-variable.woff2 | Bin 0 -> 23108 bytes .../chat/moyai/geist-variable.woff2 | Bin 0 -> 29288 bytes .../src/components/chat/moyai/moyai-chat.css | 729 ++++++++++++++++++ .../components/chat/runChatRequest.test.ts | 132 ++++ .../src/components/chat/runChatRequest.ts | 86 +++ .../src/components/chat/types.ts | 27 +- .../components/chat/useChatHistory.test.ts | 218 +++++- .../src/components/chat/useChatHistory.ts | 86 ++- .../src/components/model_filters.tsx | 135 +++- .../src/components/public_model_hub.test.tsx | 85 +- .../src/components/public_model_hub.tsx | 354 +++++---- .../shared/DataTable/DataTablePagination.tsx | 7 +- .../src/components/shared/MultiSelect.tsx | 4 +- .../src/contexts/PluginModeContext.test.tsx | 43 +- .../src/contexts/PluginModeContext.tsx | 16 +- .../src/lib/http/chatTelemetry.test.ts | 43 ++ .../src/lib/http/chatTelemetry.ts | 16 + .../src/lib/telemetry/chat.test.ts | 117 +++ .../src/lib/telemetry/chat.ts | 20 + 62 files changed, 6165 insertions(+), 1382 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/chat/layout.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/chat/models/page.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/chat/models/page.tsx create mode 100644 ui/litellm-dashboard/src/app/chat/page.images.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/AIHub/ModelHubProviders.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/AIHub/ModelHubProviders.tsx create mode 100644 ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ChatMessages.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ChatModelPicker.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ChatShell.integration.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ConversationList.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/chatImageStorage.ts create mode 100644 ui/litellm-dashboard/src/components/chat/imageHelpers.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/imageHelpers.ts create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/FONT-LICENSE.txt create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/LICENSE.txt create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/MoyaiChatComposer.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/MoyaiChatComposer.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/MoyaiChatHome.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/MoyaiChatHome.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/geist-mono-variable.woff2 create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/geist-variable.woff2 create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/moyai-chat.css create mode 100644 ui/litellm-dashboard/src/components/chat/runChatRequest.test.ts create mode 100644 ui/litellm-dashboard/src/components/chat/runChatRequest.ts create mode 100644 ui/litellm-dashboard/src/lib/http/chatTelemetry.test.ts create mode 100644 ui/litellm-dashboard/src/lib/http/chatTelemetry.ts create mode 100644 ui/litellm-dashboard/src/lib/telemetry/chat.test.ts create mode 100644 ui/litellm-dashboard/src/lib/telemetry/chat.ts diff --git a/tests/unit/proxy/telemetry/test_endpoints.py b/tests/unit/proxy/telemetry/test_endpoints.py index 3b5f66da0ed..c962c8df6a5 100644 --- a/tests/unit/proxy/telemetry/test_endpoints.py +++ b/tests/unit/proxy/telemetry/test_endpoints.py @@ -1,13 +1,12 @@ -from dataclasses import dataclass, field from collections.abc import Mapping +from dataclasses import dataclass, field from typing import Final -from typing_extensions import LiteralString - import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from httpx import ASGITransport, AsyncClient, Response +from typing_extensions import LiteralString from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth diff --git a/tests/unit/proxy/telemetry/test_settings.py b/tests/unit/proxy/telemetry/test_settings.py index 0e9c180da4f..b88e6811113 100644 --- a/tests/unit/proxy/telemetry/test_settings.py +++ b/tests/unit/proxy/telemetry/test_settings.py @@ -1,7 +1,6 @@ from typing import Final import pytest - from pydantic import ValidationError from litellm.proxy.telemetry.settings import EnvPolicy, TelemetrySettings, describe_errors, env_policy, load_settings diff --git a/ui/litellm-dashboard/src/app/chat/layout.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/layout.integration.test.tsx new file mode 100644 index 00000000000..12acf9192aa --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/layout.integration.test.tsx @@ -0,0 +1,142 @@ +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen, testQueryClient } from "@/../tests/test-utils"; +import { setServerRootPath } from "@/lib/serverRootPath"; +import { recordChatQuery } from "@/lib/telemetry/chat"; +import ChatLayout from "./layout"; + +vi.mock("@/lib/telemetry/chat", () => ({ recordChatQuery: vi.fn(async () => {}) })); + +const { router, route } = vi.hoisted(() => ({ + router: { push: vi.fn(), replace: vi.fn() }, + route: { pathname: "/ui/chat" }, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => router, + usePathname: () => route.pathname, +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ + accessToken: "chat-layout-test-token", + userId: "chat-layout-user", + userEmail: "developer@example.com", + userRole: "internal_user", + premiumUser: false, + isLoading: false, + isAuthorized: true, + }), +})); + +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: () => ({ data: { values: { enable_chat_ui: true } }, isLoading: false }), +})); + +const plugin = { name: "observability", display_name: "Observability", url: "https://plugin.example.com" }; +const originalLocation = Object.getOwnPropertyDescriptor(window, "location")!; +const assign = vi.fn(); + +describe("Chat layout product navigation", () => { + beforeEach(() => { + route.pathname = "/ui/chat"; + localStorage.clear(); + localStorage.setItem("litellm_plugin_mode", plugin.name); + testQueryClient.clear(); + assign.mockReset(); + Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, assign }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + const path = new URL(url, "http://localhost").pathname; + switch (path) { + case "/api/plugins": + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer chat-layout-test-token"); + return Response.json([plugin]); + case "/get/ui_theme_settings": + return Response.json({ values: {} }); + case "/sso/get/ui_settings": + return Response.json({}); + default: + throw new Error(`Unexpected request: ${path}`); + } + }), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + testQueryClient.clear(); + localStorage.clear(); + setServerRootPath("/"); + Object.defineProperty(window, "location", originalLocation); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it.each([ + { pathname: "/ui/chat", root: "/", gateway: "/ui/" }, + { pathname: "/gateway/ui/chat/models/", root: "/gateway", gateway: "/gateway/ui/" }, + ])( + "should render Chat at $pathname and clear a saved plugin when returning to AI Gateway", + async ({ pathname, root, gateway }) => { + route.pathname = pathname; + setServerRootPath(root); + const user = userEvent.setup(); + renderWithProviders( + +

      Chat content

      +
      , + ); + + expect(screen.getByRole("heading", { name: "Chat content" })).toBeVisible(); + expect(recordChatQuery).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Switch workspace, current: Chat" })); + expect(await screen.findByRole("menuitem", { name: "Observability" })).toBeInTheDocument(); + expect(localStorage.getItem("litellm_plugin_mode")).toBe(plugin.name); + + await user.click(screen.getByRole("menuitem", { name: "AI Gateway", exact: true })); + + expect(localStorage.getItem("litellm_plugin_mode")).toBe("ai-gateway"); + expect(assign).toHaveBeenCalledWith(gateway); + expect(recordChatQuery).not.toHaveBeenCalled(); + }, + ); + + it.each(["SecurityError", "QuotaExceededError"])( + "should still navigate to AI Gateway when browser storage throws %s", + async (errorName) => { + if (errorName === "SecurityError") { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new DOMException("Storage is unavailable", errorName); + }); + vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => { + throw new DOMException("Storage is unavailable", errorName); + }); + } + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("Storage is unavailable", errorName); + }); + const user = userEvent.setup(); + renderWithProviders( + +

      Chat content

      +
      , + ); + + expect(screen.getByRole("heading", { name: "Chat content" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Switch workspace, current: Chat" })); + expect(await screen.findByRole("menuitem", { name: "Observability" })).toBeInTheDocument(); + await user.click(screen.getByRole("menuitem", { name: "AI Gateway", exact: true })); + + expect(assign).toHaveBeenCalledWith("/ui/"); + if (errorName === "QuotaExceededError") { + expect(localStorage.getItem("litellm_plugin_mode")).toBeNull(); + } + }, + ); +}); diff --git a/ui/litellm-dashboard/src/app/chat/layout.test.tsx b/ui/litellm-dashboard/src/app/chat/layout.test.tsx index d455b84afa3..0b0cb96ddc9 100644 --- a/ui/litellm-dashboard/src/app/chat/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/chat/layout.test.tsx @@ -1,23 +1,25 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, screen } from "@testing-library/react"; import { renderWithProviders as render } from "@/../tests/test-utils"; import ChatLayout from "./layout"; -const { mockUseAuthorized, mockUseUISettings, mockReplace, mockUiHref, state } = vi.hoisted(() => { +const { mockUseAuthorized, mockUseUISettings, mockReplace, mockUiHref, recordChatQuery, state } = vi.hoisted(() => { const state = { enableChatUI: false, isUISettingsLoading: false, isIdentityLoading: false, isAuthorized: true, + userId: "user-1" as string | null, }; return { state, mockReplace: vi.fn(), + recordChatQuery: vi.fn(async () => {}), mockUiHref: vi.fn((segment: string) => `/mocked-ui/${segment}`), mockUseAuthorized: vi.fn(() => ({ accessToken: "token-123", userRole: "Internal User", - userId: "user-1", + userId: state.userId, userEmail: "user@example.com", premiumUser: false, isLoading: state.isIdentityLoading, @@ -36,6 +38,7 @@ vi.mock("next/navigation", () => ({ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: mockUseAuthorized })); vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings })); vi.mock("@/utils/uiHref", () => ({ uiHref: mockUiHref })); +vi.mock("@/lib/telemetry/chat", () => ({ recordChatQuery })); vi.mock("@/components/navbar", () => ({ default: () =>
      })); vi.mock("@/contexts/ThemeContext", () => ({ ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, @@ -43,21 +46,32 @@ vi.mock("@/contexts/ThemeContext", () => ({ vi.mock("@/contexts/ChatShellContext", () => ({ ChatShellProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); +vi.mock("@/contexts/PluginModeContext", () => ({ + PluginModeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); vi.mock("@/components/chat/ChatShell", () => ({ default: ({ children }: { children: React.ReactNode }) =>
      {children}
      , })); describe("ChatLayout", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + afterEach(() => { state.enableChatUI = false; state.isUISettingsLoading = false; state.isIdentityLoading = false; state.isAuthorized = true; + state.userId = "user-1"; mockReplace.mockClear(); mockUiHref.mockClear(); + recordChatQuery.mockClear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); - it("renders the chat shell when enable_chat_ui is on", () => { + it("should render the chat shell without recording a query when enable_chat_ui is on", () => { state.enableChatUI = true; render( @@ -67,9 +81,10 @@ describe("ChatLayout", () => { expect(screen.getByTestId("chat-shell")).toBeInTheDocument(); expect(screen.getByTestId("page-content")).toBeInTheDocument(); expect(mockReplace).not.toHaveBeenCalled(); + expect(recordChatQuery).not.toHaveBeenCalled(); }); - it("redirects to the dashboard when enable_chat_ui is off", () => { + it("should redirect without recording a query when enable_chat_ui is off", () => { state.enableChatUI = false; render( @@ -78,9 +93,10 @@ describe("ChatLayout", () => { ); expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument(); expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/"); + expect(recordChatQuery).not.toHaveBeenCalled(); }); - it("renders nothing while UI settings are still loading", () => { + it("should wait for UI settings without recording a query", () => { state.isUISettingsLoading = true; render( @@ -89,9 +105,10 @@ describe("ChatLayout", () => { ); expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument(); expect(mockReplace).not.toHaveBeenCalled(); + expect(recordChatQuery).not.toHaveBeenCalled(); }); - it("renders nothing until the signed-in user's identity has loaded", () => { + it("should wait for the signed-in user's identity without recording a query", () => { state.enableChatUI = true; state.isIdentityLoading = true; render( @@ -101,9 +118,10 @@ describe("ChatLayout", () => { ); expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument(); expect(screen.queryByTestId("page-content")).not.toBeInTheDocument(); + expect(recordChatQuery).not.toHaveBeenCalled(); }); - it("renders nothing when the signed-in user's identity could not be confirmed", () => { + it("should hide Chat without recording a query when the signed-in user's identity cannot be confirmed", () => { state.enableChatUI = true; state.isAuthorized = false; render( @@ -113,5 +131,48 @@ describe("ChatLayout", () => { ); expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument(); expect(screen.queryByTestId("page-content")).not.toBeInTheDocument(); + expect(recordChatQuery).not.toHaveBeenCalled(); + }); + + it("should not record a query or fetch telemetry when opening, focusing, or revisiting Chat", () => { + state.enableChatUI = true; + const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden"); + const view = render(Chat content); + + act(() => fireEvent.focus(window)); + expect(recordChatQuery).not.toHaveBeenCalled(); + + visibility.mockReturnValue("visible"); + act(() => fireEvent(document, new Event("visibilitychange"))); + expect(recordChatQuery).not.toHaveBeenCalled(); + + view.rerender(Another Chat page); + act(() => fireEvent.focus(window)); + + view.unmount(); + render(Chat revisited); + act(() => { + fireEvent.focus(window); + fireEvent(document, new Event("visibilitychange")); + }); + expect(recordChatQuery).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("should not record a query or fetch telemetry when the signed-in account changes", () => { + state.enableChatUI = true; + const view = render(Chat content); + expect(recordChatQuery).not.toHaveBeenCalled(); + + state.userId = "user-2"; + view.rerender(Chat content); + expect(screen.getByTestId("chat-shell")).toBeInTheDocument(); + + state.userId = null; + state.isAuthorized = false; + view.rerender(Chat content); + act(() => fireEvent.focus(window)); + expect(recordChatQuery).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); }); }); diff --git a/ui/litellm-dashboard/src/app/chat/layout.tsx b/ui/litellm-dashboard/src/app/chat/layout.tsx index bfe494d2234..1da33d80ec5 100644 --- a/ui/litellm-dashboard/src/app/chat/layout.tsx +++ b/ui/litellm-dashboard/src/app/chat/layout.tsx @@ -4,11 +4,12 @@ import { Suspense, useEffect } from "react"; import { useRouter } from "next/navigation"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; -import Navbar from "@/components/navbar"; import { ThemeProvider } from "@/contexts/ThemeContext"; import { ChatShellProvider } from "@/contexts/ChatShellContext"; +import { PluginModeProvider } from "@/contexts/PluginModeContext"; import ChatShell from "@/components/chat/ChatShell"; import { uiHref } from "@/utils/uiHref"; +import "@/components/chat/moyai/moyai-chat.css"; // The nuqs Next adapter uses useSearchParams, so keep the chat tree behind Suspense. function ChatLayoutContent({ children }: { children: React.ReactNode }) { @@ -32,13 +33,16 @@ function ChatLayoutContent({ children }: { children: React.ReactNode }) { }, [blocked, router]); const identityReady = !isIdentityLoading && isAuthorized; + const chatReady = identityReady && !isUISettingsLoading && chatEnabled; - if (!identityReady || isUISettingsLoading || blocked) return null; + if (!chatReady) return null; return ( -
      - + + + +
      - {children} + + {children} +
      diff --git a/ui/litellm-dashboard/src/app/chat/models/page.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/models/page.integration.test.tsx new file mode 100644 index 00000000000..d1868558626 --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/models/page.integration.test.tsx @@ -0,0 +1,172 @@ +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders, screen, testQueryClient, waitFor, within } from "@/../tests/test-utils"; +import type { ModelHubData } from "@/components/AIHub/ModelHubTableColumns"; + +import ChatModelsPage from "./page"; + +const { apiGetMock, modelHubCallMock, shellState } = vi.hoisted(() => ({ + apiGetMock: vi.fn(), + modelHubCallMock: vi.fn(), + shellState: { accessToken: "chat-session-token", userRole: "proxy_admin", premiumUser: false }, +})); + +vi.mock("@/contexts/ChatShellContext", () => ({ + useChatShell: () => shellState, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), +})); + +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: () => ({ data: { values: {} }, isLoading: false }), +})); + +vi.mock("@/components/networking", async (importOriginal) => { + const actual = await importOriginal(); + const hubInfo = { + docs_title: "Gateway models", + custom_docs_description: null, + litellm_version: "1.0.0", + useful_links: {}, + }; + return { + ...actual, + apiClient: { ...actual.apiClient, get: apiGetMock }, + modelHubCall: modelHubCallMock, + getConfigFieldSetting: vi.fn().mockResolvedValue({ field_value: true }), + getUiConfig: vi.fn().mockResolvedValue({}), + getProxyBaseUrl: () => "https://gateway.example.com", + getGatewayBaseUrl: () => "https://gateway.example.com", + getPublicModelHubInfo: vi.fn().mockResolvedValue(hubInfo), + getAgentsList: vi.fn().mockResolvedValue({ agents: [] }), + fetchMCPServers: vi.fn().mockResolvedValue([]), + getClaudeCodePluginsList: vi.fn().mockResolvedValue({ plugins: [] }), + agentHubPublicModelsCall: vi.fn().mockResolvedValue([]), + mcpHubPublicServersCall: vi.fn().mockResolvedValue([]), + skillHubPublicCall: vi.fn().mockResolvedValue({ plugins: [] }), + }; +}); + +const PRIVATE_MODEL = { + model_group: "research-private", + description: "An internal model for reviewing research reports.", + providers: ["openai"], + mode: "chat", + max_input_tokens: 128_000, + max_output_tokens: 4_096, + input_cost_per_token: 0.000002, + output_cost_per_token: 0.000008, + supports_function_calling: true, + supports_parallel_function_calling: false, + supports_vision: true, + is_public_model_group: false, +} satisfies ModelHubData; + +const IMAGE_MODEL: ModelHubData = { + ...PRIVATE_MODEL, + model_group: "image-studio", + description: "Create illustrations for presentations.", + mode: "image_generation", +}; + +const PUBLISHED_MODEL = { + ...PRIVATE_MODEL, + model_group: "published-chat", + description: "The shared model published for gateway users.", + is_public_model_group: true, +} satisfies ModelHubData; + +const PUBLIC_MODELS_PATH = "/public/v1/model_hub"; +const FACETS: Record = { + [`${PUBLIC_MODELS_PATH}/providers`]: ["openai"], + [`${PUBLIC_MODELS_PATH}/modes`]: ["chat"], + [`${PUBLIC_MODELS_PATH}/features`]: ["function_calling", "vision"], +}; + +describe("Chat Models page", () => { + beforeEach(() => { + shellState.userRole = "proxy_admin"; + modelHubCallMock.mockResolvedValue({ data: [PRIVATE_MODEL, IMAGE_MODEL] }); + apiGetMock.mockImplementation(async (path: string) => { + if (path in FACETS) { + return { + data: FACETS[path], + meta: { page: 1, page_size: 100, has_more: false }, + links: { self: path, prev: null, next: null }, + }; + } + if (path === PUBLIC_MODELS_PATH) { + return { + data: [PUBLISHED_MODEL], + meta: { total_count: 1, page: 1, page_size: 50, total_pages: 1 }, + links: { + self: path, + first: path, + prev: null, + next: null, + last: path, + }, + }; + } + throw new Error(`Unexpected catalog request: ${path}`); + }); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (String(input) !== "https://gateway.example.com/get/ui_theme_settings") { + throw new Error(`Unexpected fetch: ${String(input)}`); + } + return new Response(JSON.stringify({ values: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }), + ); + }); + + afterEach(() => { + testQueryClient.clear(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should render gateway models for admins and let them search and inspect model details", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + expect(screen.getByRole("heading", { name: "Models", level: 1 })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /^research-private/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^image-studio/ })).toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText("Search model names..."), "research"); + await waitFor(() => expect(screen.queryByRole("button", { name: /^image-studio/ })).not.toBeInTheDocument()); + await user.click(screen.getByRole("button", { name: /^research-private/ })); + + const details = await screen.findByRole("dialog", { name: PRIVATE_MODEL.model_group }); + expect(within(details).getByText(PRIVATE_MODEL.description)).toBeInTheDocument(); + expect(within(details).getByText("128,000")).toBeInTheDocument(); + expect(within(details).getByText("$2.00")).toBeInTheDocument(); + expect(within(details).getByText("Vision")).toBeInTheDocument(); + expect(within(details).getByText("Function Calling")).toBeInTheDocument(); + }); + + it("should show regular users the published catalog and its model capabilities", async () => { + shellState.userRole = "internal_user"; + const user = userEvent.setup(); + renderWithProviders(); + + const publishedModel = await screen.findByRole("button", { name: /^published-chat/ }); + expect(screen.queryByRole("button", { name: /^research-private/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^image-studio/ })).not.toBeInTheDocument(); + await user.click(publishedModel); + + const details = await screen.findByRole("dialog", { name: PUBLISHED_MODEL.model_group }); + expect(within(details).getByText(PUBLISHED_MODEL.description)).toBeInTheDocument(); + expect(within(details).getByText("128,000")).toBeInTheDocument(); + expect(within(details).getByText("$2.0000")).toBeInTheDocument(); + expect(within(details).getByText("Vision")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/chat/models/page.tsx b/ui/litellm-dashboard/src/app/chat/models/page.tsx new file mode 100644 index 00000000000..8011235821e --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/models/page.tsx @@ -0,0 +1,37 @@ +"use client"; + +import ModelHubTable from "@/components/AIHub/ModelHubTable"; +import PublicModelHub from "@/components/public_model_hub"; +import { useChatShell } from "@/contexts/ChatShellContext"; +import { isAdminRole } from "@/utils/roles"; + +export default function ChatModelsPage() { + const { accessToken, userRole, premiumUser } = useChatShell(); + + return ( +
      +
      +
      +

      Model Hub

      +

      Models

      +

      + Find the right model. Explore providers, compare pricing, and see what each model can do. +

      +
      + {isAdminRole(userRole) ? ( + + ) : ( + + )} +
      +
      + ); +} diff --git a/ui/litellm-dashboard/src/app/chat/page.images.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/page.images.integration.test.tsx new file mode 100644 index 00000000000..4f34fe22e58 --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/page.images.integration.test.tsx @@ -0,0 +1,355 @@ +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, testQueryClient } from "@/../tests/test-utils"; +import { ChatShellProvider } from "@/contexts/ChatShellContext"; +import { recordChatQuery } from "@/lib/telemetry/chat"; +import ChatConversationPage from "./page"; + +vi.mock("@/lib/telemetry/chat", () => ({ recordChatQuery: vi.fn(async () => {}) })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +type RecordedRequest = { pathname: string; body: Record }; +const requests: RecordedRequest[] = []; +const pixel = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aRZQAAAAASUVORK5CYII="; +let models: { model_group: string; mode: string; providers: string[] }[]; +let imageResponse: () => Response; +let pendingGeneration: boolean; +let generationSignal: AbortSignal | null; +let includeMCPEvents: boolean; + +function responseStream() { + return new Response( + [ + { type: "response.output_text.delta", delta: "I can see " }, + ...(includeMCPEvents + ? ["search_notes", "read_note"].map((name) => ({ + type: "response.output_item.done", + item: { id: name, type: "mcp_call", name, server_label: "research-tools", output: "Found a note" }, + })) + : []), + { type: "response.output_text.delta", delta: "the uploaded image." }, + { type: "response.completed", response: { id: `response-${requests.length}`, output: [] } }, + ] + .map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`) + .join(""), + { headers: { "Content-Type": "text/event-stream" } }, + ); +} + +function renderChat() { + return renderWithProviders( + + + , + ); +} + +async function uploadImage(user: ReturnType, name = "diagram.png") { + const bytes = Uint8Array.from(atob(pixel), (character) => character.charCodeAt(0)); + await user.upload(screen.getByLabelText("Upload images"), new File([bytes], name, { type: "image/png" })); + await screen.findByRole("img", { name }); +} + +async function chooseModel(user: ReturnType, current: string, next: string) { + await user.click(screen.getByRole("button", { name: current, exact: true })); + await user.click(await screen.findByRole("button", { name: new RegExp(`^${next}`) })); + await waitFor(() => expect(screen.queryByRole("button", { name: "All", exact: true })).not.toBeInTheDocument()); +} + +beforeEach(() => { + localStorage.clear(); + testQueryClient.clear(); + requests.length = 0; + vi.mocked(recordChatQuery).mockClear(); + models = [ + { model_group: "research-chat", mode: "chat", providers: ["openai"] }, + { model_group: "studio-image", mode: "image_generation", providers: ["openai"] }, + ]; + imageResponse = () => Response.json({ created: 1, data: [{ b64_json: pixel }] }); + pendingGeneration = false; + generationSignal = null; + includeMCPEvents = false; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = + input instanceof Request ? input : new Request(new URL(String(input), window.location.origin), init); + const pathname = new URL(request.url).pathname; + if (pathname.endsWith("/model_group/info")) return Response.json({ data: models }); + if (pathname.endsWith("/v1/mcp/server")) { + return Response.json([{ server_id: "research-tools", server_name: "research-tools" }]); + } + if (pathname.endsWith("/mcp-rest/tools/list")) return Response.json({ tools: [] }); + if (pathname.endsWith("/responses") || pathname.endsWith("/images/generations")) { + requests.push({ pathname, body: JSON.parse(await request.text()) }); + if (pathname.endsWith("/responses")) return responseStream(); + if (pendingGeneration) { + generationSignal = request.signal; + return new Promise((_resolve, reject) => { + request.signal.addEventListener("abort", () => reject(new DOMException("Stopped", "AbortError")), { + once: true, + }); + }); + } + return imageResponse(); + } + return Response.json({}, { status: 404 }); + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); +}); + +describe("chat image workflows", () => { + it("should render the permitted models and image controls for a research team member", async () => { + renderChat(); + expect(await screen.findByRole("button", { name: "research-chat" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Attach images" })).toBeInTheDocument(); + await userEvent.setup().click(screen.getByRole("button", { name: "research-chat", exact: true })); + expect(await screen.findByRole("button", { name: /^studio-image/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send" })).toBeDisabled(); + expect(recordChatQuery).not.toHaveBeenCalled(); + }); + + it("should restore the selected chat model after switching modes and reopening the page", async () => { + const user = userEvent.setup(); + const view = renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await chooseModel(user, "research-chat", "studio-image"); + expect(screen.getByRole("button", { name: "studio-image" })).toBeInTheDocument(); + await chooseModel(user, "studio-image", "research-chat"); + view.unmount(); + renderChat(); + expect(await screen.findByRole("button", { name: "research-chat" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Message", exact: true })).toBeInTheDocument(); + }); + + it("should preview and remove an uploaded image before sending", async () => { + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await uploadImage(user); + expect(screen.getByRole("button", { name: "Send" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Remove diagram.png" })); + expect(screen.queryByRole("img", { name: "diagram.png" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send" })).toBeDisabled(); + expect(requests).toHaveLength(0); + expect(recordChatQuery).not.toHaveBeenCalled(); + }); + + it("should send the image and preserve it when editing and resending the message", async () => { + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await uploadImage(user); + await user.type(screen.getByRole("textbox", { name: "Message", exact: true }), "Explain this diagram"); + await user.click(screen.getByRole("button", { name: "Send" })); + await screen.findByText("I can see the uploaded image."); + expect(recordChatQuery).toHaveBeenCalledExactlyOnceWith("media-test-user"); + expect(requests[0].body).toMatchObject({ + model: "research-chat", + input: [ + { + role: "user", + content: [ + { type: "input_text", text: "Explain this diagram" }, + { type: "input_image", image_url: `data:image/png;base64,${pixel}` }, + ], + }, + ], + }); + expect(screen.getByRole("img", { name: "diagram.png" })).toBeInTheDocument(); + await user.hover(screen.getAllByText("Explain this diagram").at(-1)!); + const editButton = await screen.findByRole("button", { name: "Edit message" }); + act(() => fireEvent.click(editButton)); + await user.clear(screen.getByRole("textbox", { name: "Edit message" })); + await user.type(screen.getByRole("textbox", { name: "Edit message" }), "Explain the arrows"); + await user.click(screen.getByRole("button", { name: "Save & Send" })); + await waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1].body).toMatchObject({ + input: [ + { + role: "user", + content: [ + { type: "input_text", text: "Explain the arrows" }, + { type: "input_image", image_url: `data:image/png;base64,${pixel}` }, + ], + }, + ], + }); + expect(requests[1].body).not.toHaveProperty("previous_response_id"); + expect(screen.queryByText("Explain this diagram", { selector: "p" })).not.toBeInTheDocument(); + expect(recordChatQuery).toHaveBeenCalledTimes(2); + }); + + it("should preserve a chat turn when editing it while an image model is selected", async () => { + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await user.type(screen.getByRole("textbox", { name: "Message", exact: true }), "Explain the experiment"); + await user.click(screen.getByRole("button", { name: "Send" })); + await screen.findByText("I can see the uploaded image."); + await chooseModel(user, "research-chat", "studio-image"); + await user.hover(screen.getAllByText("Explain the experiment").at(-1)!); + const editButton = await screen.findByRole("button", { name: "Edit message" }); + act(() => fireEvent.click(editButton)); + await user.clear(screen.getByRole("textbox", { name: "Edit message" })); + await user.type(screen.getByRole("textbox", { name: "Edit message" }), "Explain the findings"); + await user.click(screen.getByRole("button", { name: "Save & Send" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Choose a chat model before resending this message."); + expect(screen.getByRole("heading", { name: "Explain the experiment" })).toBeInTheDocument(); + expect(screen.getByText("I can see the uploaded image.")).toBeInTheDocument(); + expect(requests).toHaveLength(1); + expect(recordChatQuery).toHaveBeenCalledExactlyOnceWith("media-test-user"); + }); + + it("should continue an image conversation using the completed response context", async () => { + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await uploadImage(user); + await user.click(screen.getByRole("button", { name: "Send" })); + await screen.findByText("I can see the uploaded image."); + await user.type(screen.getByRole("textbox", { name: "Message", exact: true }), "What is the main color?"); + await user.click(screen.getByRole("button", { name: "Send" })); + await waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1].body).toMatchObject({ + previous_response_id: "response-1", + input: [{ role: "user", content: "What is the main color?" }], + }); + expect(screen.getByRole("img", { name: "diagram.png" })).toBeInTheDocument(); + }); + + it("should generate and render an image through the selected Model Hub model", async () => { + vi.mocked(recordChatQuery).mockReturnValueOnce(new Promise(() => {})); + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await chooseModel(user, "research-chat", "studio-image"); + expect(screen.getByRole("button", { name: "studio-image" })).toBeInTheDocument(); + await user.type(screen.getByRole("textbox", { name: "Message", exact: true }), "A blue research lab"); + await user.click(screen.getByRole("button", { name: "Generate image" })); + const result = await screen.findByRole("img", { name: "Generated image 1" }); + expect(result).toHaveAttribute("src", `data:image/png;base64,${pixel}`); + expect(requests).toEqual([ + { pathname: "/images/generations", body: { model: "studio-image", prompt: "A blue research lab" } }, + ]); + expect(recordChatQuery).toHaveBeenCalledExactlyOnceWith("media-test-user"); + }); + + it("should list no image models when the account has none permitted", async () => { + models = models.filter((model) => model.mode === "chat"); + const user = userEvent.setup(); + renderChat(); + await user.click(await screen.findByRole("button", { name: "research-chat" })); + await user.click(screen.getByRole("button", { name: "Images", exact: true })); + expect(screen.getByText("No models found.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^studio-image/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Generate image" })).not.toBeInTheDocument(); + expect(requests).toHaveLength(0); + }); + + it("should show the provider error when image generation fails", async () => { + imageResponse = () => + Response.json({ error: { message: "Image access is disabled for this key" } }, { status: 403 }); + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await chooseModel(user, "research-chat", "studio-image"); + await user.type(screen.getByRole("textbox", { name: "Message", exact: true }), "A blue lab"); + await user.click(screen.getByRole("button", { name: "Generate image" })); + expect(await screen.findByText(/Image access is disabled for this key/)).toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "Generated image 1" })).not.toBeInTheDocument(); + expect(recordChatQuery).toHaveBeenCalledExactlyOnceWith("media-test-user"); + }); + + it("should abort image generation when the user stops the request", async () => { + pendingGeneration = true; + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await chooseModel(user, "research-chat", "studio-image"); + await user.type(screen.getByRole("textbox", { name: "Message", exact: true }), "A blue lab"); + await user.click(screen.getByRole("button", { name: "Generate image" })); + await waitFor(() => expect(generationSignal).not.toBeNull()); + act(() => { + fireEvent.change(screen.getByRole("textbox", { name: "Message", exact: true }), { + target: { value: "Another image" }, + }); + fireEvent.keyDown(screen.getByRole("textbox", { name: "Message", exact: true }), { key: "Enter" }); + }); + expect(requests).toHaveLength(1); + expect(recordChatQuery).toHaveBeenCalledExactlyOnceWith("media-test-user"); + await user.click(screen.getByRole("button", { name: "Stop request" })); + expect(generationSignal?.aborted).toBe(true); + expect(await screen.findByText("[stopped]")).toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "Generated image 1" })).not.toBeInTheDocument(); + expect(recordChatQuery).toHaveBeenCalledTimes(1); + }); + + it("should reject unsupported pasted files without issuing a request", async () => { + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + act(() => { + fireEvent.paste(screen.getByRole("textbox", { name: "Message", exact: true }), { + clipboardData: { files: [new File(["svg"], "diagram.svg", { type: "image/svg+xml" })] }, + }); + }); + expect(await screen.findByRole("alert")).toHaveTextContent("Choose a PNG, JPEG, WebP, or GIF image."); + expect(requests).toHaveLength(0); + expect(recordChatQuery).not.toHaveBeenCalled(); + }); + + it("should not count a blank submission as a query", async () => { + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + act(() => { + fireEvent.change(screen.getByRole("textbox", { name: "Message", exact: true }), { + target: { value: " " }, + }); + fireEvent.keyDown(screen.getByRole("textbox", { name: "Message", exact: true }), { key: "Enter" }); + }); + expect(requests).toHaveLength(0); + expect(recordChatQuery).not.toHaveBeenCalled(); + }); + + it("should count one query for a message that streams text and executes multiple MCP tools", async () => { + includeMCPEvents = true; + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await user.click(screen.getByRole("button", { name: "Connect tools" })); + const serverSwitch = await screen.findByRole("switch"); + await user.click(serverSwitch); + await waitFor(() => expect(screen.getByRole("switch")).toBeChecked()); + await user.click(screen.getByRole("button", { name: "Connect tools" })); + expect(recordChatQuery).not.toHaveBeenCalled(); + + act(() => { + fireEvent.change(screen.getByRole("textbox", { name: "Message", exact: true }), { + target: { value: "Search the research notes" }, + }); + }); + await user.click(screen.getByRole("button", { name: "Send" })); + await screen.findByText("I can see the uploaded image."); + + expect(requests).toHaveLength(1); + expect(requests[0].body).toMatchObject({ + tools: [{ type: "mcp", server_label: "research-tools", require_approval: "never" }], + }); + expect(recordChatQuery).toHaveBeenCalledExactlyOnceWith("media-test-user"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx index 2020a66bd1c..e49f3a17094 100644 --- a/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx @@ -72,7 +72,7 @@ const ON_TOTAL_LATENCY_INDEX = 24; async function sendOneMessage(onUrlUpdate?: OnUrlUpdateFunction): Promise { renderWithProviders(, { onUrlUpdate }); expect(await screen.findByRole("button", { name: /gpt-5\.4-mini/ })).toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText("How can I help you today?"), { + fireEvent.change(screen.getByPlaceholderText("Describe a change, paste an error, or ask a question…"), { target: { value: "How much did this cost?" }, }); fireEvent.click(screen.getByRole("button", { name: "Send" })); @@ -157,7 +157,7 @@ describe("/ui/chat storage banner", () => { it("keeps the dismiss control amber on hover instead of the ghost variant's foreground", async () => { renderWithProviders(); - const banner = await screen.findByText("Chat history won't be saved in this browser session"); + const banner = await screen.findByText("Some chat history or images could not be saved or restored."); const dismiss = within(banner.parentElement!).getByRole("button"); expect(dismiss).toHaveClass("hover:text-warning/80"); expect(dismiss).not.toHaveClass("hover:text-foreground"); diff --git a/ui/litellm-dashboard/src/app/chat/page.tsx b/ui/litellm-dashboard/src/app/chat/page.tsx index fdf6a1e02a3..dbb578a0e3d 100644 --- a/ui/litellm-dashboard/src/app/chat/page.tsx +++ b/ui/litellm-dashboard/src/app/chat/page.tsx @@ -1,11 +1,8 @@ "use client"; import React, { useCallback, useEffect, useRef, useState, useLayoutEffect } from "react"; -import { Plus, ChevronDown, Check, X } from "lucide-react"; +import { Plus, ChevronDown, Paperclip, ImageIcon, X } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Skeleton } from "@/components/ui/skeleton"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { toast } from "@/lib/toast"; import { useRouter } from "next/navigation"; @@ -13,41 +10,22 @@ import { useChatShell } from "@/contexts/ChatShellContext"; import { getChatRoutes } from "@/components/chat/ChatShell"; import ChatMessages from "@/components/chat/ChatMessages"; import MCPConnectPicker from "@/components/chat/MCPConnectPicker"; -import { fetchAvailableModels } from "@/components/llm_calls/fetch_models"; -import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api"; -import type { TokenUsage } from "@/components/chat_ui/ResponseMetrics"; -import type { MCPEvent } from "@/components/chat/types"; -import { getProviderLogoAndName } from "@/components/provider_info_helpers"; +import { fetchAvailableModels, type ModelGroup } from "@/components/llm_calls/fetch_models"; +import type { AssistantMessageUpdate, ChatImageAttachment, ChatMessage } from "@/components/chat/types"; +import ChatModelPicker, { isChatModel, modelChatMode, type ChatMode } from "@/components/chat/ChatModelPicker"; +import MoyaiChatComposer from "@/components/chat/moyai/MoyaiChatComposer"; +import MoyaiChatHome from "@/components/chat/moyai/MoyaiChatHome"; +import { runChatRequest } from "@/components/chat/runChatRequest"; +import { readChatImage, MAX_CHAT_IMAGES } from "@/components/chat/imageHelpers"; import { useQueryState } from "nuqs"; +import { recordChatQuery } from "@/lib/telemetry/chat"; -const SUGGESTIONS = ["Write", "Learn", "Code", "Brainstorm"]; const LOCALSTORAGE_MODEL_KEY = "litellm_chat_selected_model"; -function getGreeting(): string { - const h = new Date().getHours(); - if (h >= 5 && h < 12) return "Good morning"; - if (h >= 12 && h < 17) return "Good afternoon"; - return "Good evening"; -} +type ReplayMessage = Pick; -// Extract provider from model name for logo lookup. -// Handles prefixed models ("groq/llama-3"), and detects well-known providers by keyword. -function getProviderFromModelName(modelName: string): string { - if (!modelName) return ""; - const lower = modelName.toLowerCase(); - const slash = lower.indexOf("/"); - if (slash > 0) return lower.slice(0, slash); - // Keyword matching — order matters (more specific first) - if (lower.includes("claude")) return "anthropic"; - if (lower.includes("gemini")) return "gemini"; - if (lower.includes("gpt") || lower.includes("chatgpt") || /^o[0-9]/.test(lower)) return "openai"; - if (lower.includes("mistral") || lower.includes("codestral")) return "mistral"; - if (lower.includes("llama")) return "meta_llama"; - if (lower.includes("deepseek")) return "deepseek"; - if (lower.includes("grok")) return "xai"; - if (lower.includes("command")) return "cohere"; - if (lower.includes("nova") || lower.includes("titan")) return "bedrock"; - return ""; +function hasMessageContent(text: string, images: ChatImageAttachment[], mode: ChatMode): boolean { + return Boolean(text.trim()) || (mode === "chat" && images.length > 0); } export default function ChatConversationPage() { @@ -56,7 +34,7 @@ export default function ChatConversationPage() { const { accessToken, userId, - userEmail, + conversations, selectedMCPServers, setSelectedMCPServers, activeConversationId, @@ -70,10 +48,15 @@ export default function ChatConversationPage() { } = useChatShell(); const [selectedModel, setSelectedModel] = useState(null); - const [models, setModels] = useState([]); + const [models, setModels] = useState([]); + const [mode, setMode] = useState("chat"); + const [attachments, setAttachments] = useState([]); + const [attachmentError, setAttachmentError] = useState(null); + const [isReadingImages, setIsReadingImages] = useState(false); + const imageInputRef = useRef(null); + const readImagesRef = useRef(false); + const draftVersionRef = useRef(0); const [isLoadingModels, setIsLoadingModels] = useState(true); - const [modelSelectorOpen, setModelSelectorOpen] = useState(false); - const [modelSearchText, setModelSearchText] = useState(""); const [responsesSessionId, setResponsesSessionId] = useState(null); const [prevConversationIdForSessionReset, setPrevConversationIdForSessionReset] = useState(activeConversationId); @@ -83,7 +66,7 @@ export default function ChatConversationPage() { const [storageBannerDismissed, setStorageBannerDismissed] = useState(false); const abortControllerRef = useRef(null); - const textareaRef = useRef(null); + const requestConversationRef = useRef(null); const messagesScrollRef = useRef(null); const [showScrollButton, setShowScrollButton] = useState(false); const streamScrollLock = useRef(null); @@ -97,12 +80,14 @@ export default function ChatConversationPage() { if (!accessToken) return; fetchAvailableModels(accessToken) .then((data) => { - const names = (data || []).map((m: { model_group?: string }) => m.model_group ?? "").filter(Boolean); - setModels(names); + const available = (data || []).filter(isChatModel); + const names = available.map((model) => model.model_group); + setModels(available); try { const saved = localStorage.getItem(LOCALSTORAGE_MODEL_KEY); if (saved && names.includes(saved)) { setSelectedModel(saved); + setMode(modelChatMode(available.find((model) => model.model_group === saved)!)); return; } } catch { @@ -110,7 +95,7 @@ export default function ChatConversationPage() { } if (names.length > 0) { setSelectedModel(names[0]); - localStorage.setItem(LOCALSTORAGE_MODEL_KEY, names[0]); + setMode(modelChatMode(available[0])); } }) .catch(() => toast.error("Could not load models")) @@ -122,20 +107,87 @@ export default function ChatConversationPage() { if (activeConversationId !== prevConversationIdForSessionReset) { setPrevConversationIdForSessionReset(activeConversationId); setResponsesSessionId(null); + setAttachments([]); + setAttachmentError(null); + setInputText(""); } - const selectModel = useCallback((model: string) => { - setSelectedModel(model); - localStorage.setItem(LOCALSTORAGE_MODEL_KEY, model); - setModelSelectorOpen(false); - setModelSearchText(""); - }, []); + useEffect(() => { + draftVersionRef.current += 1; + if (abortControllerRef.current && requestConversationRef.current !== activeConversationId) { + abortControllerRef.current.abort(); + } + }, [activeConversationId]); + + const selectModel = useCallback( + (model: ModelGroup) => { + if (modelChatMode(model) === "image" && attachments.length > 0) { + setAttachmentError("Remove the attached images before switching to image generation."); + return; + } + setSelectedModel(model.model_group); + setMode(modelChatMode(model)); + setResponsesSessionId(null); + setAttachmentError(null); + try { + localStorage.setItem(LOCALSTORAGE_MODEL_KEY, model.model_group); + } catch { + return; + } + }, + [attachments.length], + ); + + const addImages = async (files: File[]) => { + if (mode !== "chat" || files.length === 0) return; + if (isStreaming || readImagesRef.current) return; + if (attachments.length + files.length > MAX_CHAT_IMAGES) { + setAttachmentError(`Attach up to ${MAX_CHAT_IMAGES} images per message.`); + return; + } + const draftVersion = draftVersionRef.current; + readImagesRef.current = true; + setIsReadingImages(true); + setAttachmentError(null); + try { + const images = await Promise.all(files.map(readChatImage)); + if (draftVersion === draftVersionRef.current) setAttachments((current) => [...current, ...images]); + } catch (error) { + if (draftVersion === draftVersionRef.current) + setAttachmentError(error instanceof Error ? error.message : "Could not read these images."); + } finally { + readImagesRef.current = false; + setIsReadingImages(false); + } + }; + + const handleImageInput = (event: React.ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + void addImages(files); + }; + + useEffect( + () => () => { + abortControllerRef.current?.abort(); + draftVersionRef.current += 1; + }, + [], + ); const handleSend = useCallback( - async (text: string, historyOverride?: Array<{ role: "user" | "assistant"; content: string }>) => { + async ( + text: string, + historyOverride?: ReplayMessage[], + attachmentOverride?: ChatImageAttachment[], + generationModel?: string, + ) => { const trimmed = text.trim(); - if (!trimmed || !selectedModel || isStreaming) return; - const model = selectedModel; + const images = attachmentOverride ?? attachments; + const requestMode = generationModel ? "image" : mode; + const model = generationModel ?? selectedModel; + if (!hasMessageContent(trimmed, images, requestMode) || !model) return; + if (isStreaming || abortControllerRef.current || readImagesRef.current) return; setInputText(""); let convId = activeConversationId; @@ -145,11 +197,21 @@ export default function ChatConversationPage() { setConversationIdInUrl(convId); } - appendMessage(convId, { role: "user", content: trimmed }); - appendMessage(convId, { role: "assistant", content: "" }); + const imageGeneration = requestMode === "image" ? { model, prompt: trimmed } : undefined; + const userMessage = { + role: "user" as const, + content: trimmed, + attachments: images.length > 0 ? images : undefined, + imageGeneration, + }; + appendMessage(convId, userMessage); + appendMessage(convId, { role: "assistant", content: "", imageGeneration }); + setAttachments([]); + setAttachmentError(null); setIsStreaming(true); abortControllerRef.current = new AbortController(); + requestConversationRef.current = convId; // When historyOverride is set (edit / retry), the existing server-side // session chain covers messages that were just truncated and is no longer @@ -167,86 +229,33 @@ export default function ChatConversationPage() { // On the very first turn (no session yet), we send the full history. const previousResponseId = historyOverride ? null : responsesSessionId; - const history: Array<{ role: "user" | "assistant"; content: string }> = historyOverride - ? [...historyOverride, { role: "user" as const, content: trimmed }] - : previousResponseId - ? [{ role: "user" as const, content: trimmed }] - : [ - // Explicitly filter to only user/assistant roles — tool messages - // lack a required tool_call_id and would cause API errors. - ...(activeConversation?.messages ?? []) - .filter( - (m): m is typeof m & { role: "user" | "assistant" } => m.role === "user" || m.role === "assistant", - ) - .map((m) => ({ role: m.role, content: m.content })), - { role: "user" as const, content: trimmed }, - ]; - - let accumulatedContent = ""; - let accumulatedReasoning = ""; - // MCP events accumulated locally so we can persist them to the message - // without relying on component state (which would cause stale closures). - const accumulatedMCPEvents: MCPEvent[] = []; - // Track clean completion so partial events are not shown on error/abort. - let streamCompletedCleanly = false; + const history: ReplayMessage[] = [ + ...(historyOverride ?? (previousResponseId ? [] : activeConversation?.messages ?? [])), + userMessage, + ]; + const requestController = abortControllerRef.current; + const request = { + model, + mode: requestMode, + prompt: trimmed, + accessToken, + signal: requestController.signal, + messages: history, + previousResponseId, + mcpServers: selectedMCPServers, + onResponseId: setResponsesSessionId, + onUpdate: (updates: AssistantMessageUpdate) => updateLastAssistantMessage(convId, updates), + }; try { - await makeOpenAIResponsesRequest( - history, - (_role: string, chunk: string) => { - accumulatedContent += chunk; - updateLastAssistantMessage(convId!, { content: accumulatedContent }); - }, - model, - accessToken, - undefined, // tags - abortControllerRef.current.signal, - (rc: string) => { - accumulatedReasoning += rc; - updateLastAssistantMessage(convId!, { reasoningContent: accumulatedReasoning }); - }, - (timeToFirstToken: number) => updateLastAssistantMessage(convId!, { timeToFirstToken }), - (usage: TokenUsage) => updateLastAssistantMessage(convId!, { usage }), - undefined, - undefined, - undefined, - undefined, - selectedMCPServers.length > 0 ? selectedMCPServers : undefined, - previousResponseId, - (id: string) => setResponsesSessionId(id), - (event: MCPEvent) => { - // Accumulate locally only — persisted once in finally to avoid - // one full localStorage write per MCP event during streaming. - accumulatedMCPEvents.push(event); - }, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - true, - (totalLatency: number) => updateLastAssistantMessage(convId!, { totalLatency }), - ); - streamCompletedCleanly = true; - } catch (err: unknown) { - if (err instanceof Error && err.name === "AbortError") { - updateLastAssistantMessage(convId!, { - content: accumulatedContent + " [stopped]", - }); - } else { - updateLastAssistantMessage(convId!, { - content: "[Something went wrong. The partial response has been saved.]", - }); - } + void recordChatQuery(userId); + await runChatRequest(request); } finally { - // Only persist MCP events on clean completion — partial events from an - // aborted or errored turn would show incomplete tool calls to the user. - if (accumulatedMCPEvents.length > 0 && streamCompletedCleanly) { - updateLastAssistantMessage(convId!, { mcpEvents: accumulatedMCPEvents }); + if (abortControllerRef.current === requestController) { + setIsStreaming(false); + abortControllerRef.current = null; + requestConversationRef.current = null; } - setIsStreaming(false); - abortControllerRef.current = null; } }, [ @@ -254,7 +263,10 @@ export default function ChatConversationPage() { activeConversation, selectedModel, selectedMCPServers, + mode, + attachments, accessToken, + userId, createConversation, appendMessage, updateLastAssistantMessage, @@ -271,34 +283,25 @@ export default function ChatConversationPage() { const handleEditAndResend = useCallback( (messageId: string, newContent: string) => { if (!activeConversationId || isStreaming) return; + if (abortControllerRef.current || readImagesRef.current) return; // Compute the truncated history synchronously before the async state update lands, // so handleSend receives the correct pre-edit context rather than the stale closure value. const msgs = activeConversation?.messages ?? []; const idx = msgs.findIndex((m) => m.id === messageId); - const priorMessages = (idx === -1 ? msgs : msgs.slice(0, idx)) - .filter((m) => m.role === "user" || m.role === "assistant") - .map((m) => ({ role: m.role as "user" | "assistant", content: m.content })); + if (idx < 0) return; + const original = msgs[idx]; + if (!original.imageGeneration && (mode !== "chat" || !selectedModel)) { + setAttachmentError("Choose a chat model before resending this message."); + return; + } + if (!newContent.trim() && !original.attachments?.length) return; + const priorMessages = msgs.slice(0, idx); truncateFromMessage(activeConversationId, messageId); - handleSend(newContent, priorMessages); + handleSend(newContent, priorMessages, original.attachments ?? [], original.imageGeneration?.model); }, - [activeConversationId, isStreaming, activeConversation, truncateFromMessage, handleSend], + [activeConversationId, isStreaming, activeConversation, truncateFromMessage, handleSend, mode, selectedModel], ); - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSend(inputText); - } - }; - - // Auto-resize textarea - useEffect(() => { - const ta = textareaRef.current; - if (!ta) return; - ta.style.height = "auto"; - ta.style.height = `${Math.min(ta.scrollHeight, 180)}px`; - }, [inputText]); - // Track scroll position to show/hide scroll-to-bottom button useEffect(() => { const el = messagesScrollRef.current; @@ -345,178 +348,165 @@ export default function ChatConversationPage() { }, [activeConversation?.messages]); const showBlankState = !activeConversation || activeConversation.messages.length === 0; - const displayName = userEmail?.split("@")[0] ?? userId ?? ""; - const greeting = displayName ? `${getGreeting()}, ${displayName}` : getGreeting(); - // Filtered models: selected one floats to the top, then alphabetical - const filteredModels = ( - modelSearchText ? models.filter((m) => m.toLowerCase().includes(modelSearchText.toLowerCase())) : models - ).sort((a, b) => { - if (a === selectedModel) return -1; - if (b === selectedModel) return 1; - return 0; - }); - - const modelSelectorContent = ( -
      -
      - setModelSearchText(e.target.value)} - placeholder="Search models..." - className="h-8 text-[13px]" - /> -
      - - {filteredModels.map((m) => { - const checked = m === selectedModel; - const provider = getProviderFromModelName(m); - const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" }; - return ( - - ); - })} - -
      - ); - - const modelSelectorTrigger = isLoadingModels ? ( - - ) : ( - { - setModelSelectorOpen(open); - if (!open) setModelSearchText(""); - }} - > - - {selectedModel ? ( + const inputBar = (inConversation: boolean) => { + const preparing = isLoadingModels || isReadingImages; + const submitDisabled = !hasMessageContent(inputText, attachments, mode) || !selectedModel || preparing; + return ( +
      { + if (mode === "chat" && event.dataTransfer.types.includes("Files")) event.preventDefault(); + }} + onDrop={(event) => { + if (mode === "chat" && event.dataTransfer.files.length) { + event.preventDefault(); + void addImages(Array.from(event.dataTransfer.files)); + } + }} + > + void handleSend(inputText)} + onCancel={handleStop} + placeholder={ + mode === "image" + ? "Describe the image you want to create…" + : inConversation + ? "Send a message..." + : "Describe a change, paste an error, or ask a question…" + } + isLoading={isStreaming} + submitLabel={mode === "image" ? "Generate image" : "Send"} + submitDisabled={submitDisabled} + onPaste={(event) => { + const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/")); + if (mode === "chat" && images.length > 0) { + event.preventDefault(); + void addImages(images); + } + }} + attachmentPreview={ + attachments.length > 0 ? ( +
      + {attachments.map((image) => ( +
      + {image.name} + +

      {image.name}

      +
      + ))} +
      + ) : undefined + } + modelPicker={ + + } + tools={ + mode === "chat" ? ( <> - {(() => { - const provider = getProviderFromModelName(selectedModel); - const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" }; - return logo ? ( - { - (e.currentTarget as HTMLImageElement).style.display = "none"; - }} + + + + + + Tools + {selectedMCPServers.length > 0 && ( + {selectedMCPServers.length} + )} + + } + /> + + - ) : null; - })()} - {selectedModel} + + ) : ( - Select model - )} - - - } - /> - - {modelSelectorContent} - - - ); - - const inputBar = (inConversation: boolean) => ( -
      -