From f4e1e8eb68cc34b971c7cc71e77db18d99804184 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Jul 2026 15:03:44 -0700 Subject: [PATCH 001/188] feat(ui): add shared composable DataTable component Phase 0 of the dashboard table-standardization effort: one composable DataTable built on TanStack react-table and the shadcn-style primitives in components/ui/table.tsx (Base UI, Tailwind v4), plus its behavioral test suite. No existing tables are migrated in this change. The component owns the TanStack instance and a shadcn shell, and exposes composable slots (toolbar, pagination, footer) plus DataTableToolbar, DataTablePagination, DataTableViewOptions, and DataTableSortHeader. Sorting and pagination each use a single mode enum (none/client/server) so server modes only surface state via callbacks and never reorder or slice locally. columnMeta.ts defines the canonical ColumnMeta augmentation. The rendering shell imports only components/ui/table primitives; no tremor or antd. --- .../shared/DataTable/DataTable.test.tsx | 431 +++++++++++++++ .../components/shared/DataTable/DataTable.tsx | 497 ++++++++++++++++++ .../DataTable/DataTablePagination.test.tsx | 68 +++ .../shared/DataTable/DataTablePagination.tsx | 113 ++++ .../DataTable/DataTableSortHeader.test.tsx | 112 ++++ .../shared/DataTable/DataTableSortHeader.tsx | 96 ++++ .../DataTable/DataTableToolbar.test.tsx | 35 ++ .../shared/DataTable/DataTableToolbar.tsx | 55 ++ .../shared/DataTable/DataTableViewOptions.tsx | 55 ++ .../components/shared/DataTable/columnMeta.ts | 13 + .../src/components/shared/DataTable/index.ts | 16 + .../src/components/shared/DataTable/types.ts | 60 +++ 12 files changed, 1551 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/index.ts create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/types.ts diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx new file mode 100644 index 00000000000..df42c156975 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx @@ -0,0 +1,431 @@ +import type { ColumnDef, ExpandedState } from "@tanstack/react-table"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { DataTable } from "./DataTable"; +import { DataTableSortHeader } from "./DataTableSortHeader"; +import { DataTableViewOptions } from "./DataTableViewOptions"; + +interface Person { + id: string; + name: string; + email: string; + flagged?: boolean; +} + +function person(id: string, name: string, flagged = false): Person { + return { id, name, email: `${name.toLowerCase()}@x.io`, flagged }; +} + +const names = (): (string | null)[] => screen.getAllByTestId("name-cell").map((el) => el.textContent); + +const nameCellColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + }, +]; + +const headerCycleColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => {row.original.name}, + }, +]; + +const dropdownSortColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => {row.original.name}, + }, +]; + +const nameEmailColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + }, + { + accessorKey: "email", + header: "Email", + cell: ({ row }) => {row.original.email}, + }, +]; + +const pinnedColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + meta: { pinned: "left" }, + }, + { + accessorKey: "email", + header: "Email", + cell: ({ row }) => {row.original.email}, + }, +]; + +const rowClickColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + }, + { + id: "actions", + header: "Actions", + cell: () => ( +
+ + +
+ ), + }, +]; + +const expansionColumns: ColumnDef[] = [ + { + id: "expander", + header: "", + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + }, +]; + +const CHARLIE_ALICE_BOB: Person[] = [person("c", "Charlie"), person("a", "Alice"), person("b", "Bob")]; + +describe("DataTable sorting", () => { + it("client mode reorders rows when the sort header is clicked", async () => { + const user = userEvent.setup(); + render(); + + expect(names()).toEqual(["Charlie", "Alice", "Bob"]); + await user.click(screen.getByTestId("sort-header-name")); + expect(names()).toEqual(["Alice", "Bob", "Charlie"]); + }); + + it("server mode fires the callback but never reorders locally", async () => { + const user = userEvent.setup(); + const onSortingChange = vi.fn(); + render( + , + ); + + // sorting state says ascending, but server mode must render data as given + expect(names()).toEqual(["Charlie", "Alice", "Bob"]); + await user.click(screen.getByTestId("sort-header-name")); + expect(onSortingChange).toHaveBeenCalledTimes(1); + expect(names()).toEqual(["Charlie", "Alice", "Bob"]); + }); + + it("dropdown-tristate variant sorts ascending, descending, then resets", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("sort-trigger-name")); + await user.click(await screen.findByText("Ascending")); + expect(names()).toEqual(["Alice", "Bob", "Charlie"]); + + await user.click(screen.getByTestId("sort-trigger-name")); + await user.click(await screen.findByText("Descending")); + expect(names()).toEqual(["Charlie", "Bob", "Alice"]); + + await user.click(screen.getByTestId("sort-trigger-name")); + await user.click(await screen.findByText("Reset")); + expect(names()).toEqual(["Charlie", "Alice", "Bob"]); + }); +}); + +describe("DataTable pagination", () => { + const fivePeople: Person[] = Array.from({ length: 5 }, (_, i) => person(String(i), `P${i}`)); + + it("client mode slices rows and advances pages", async () => { + const user = userEvent.setup(); + render(); + + expect(names()).toEqual(["P0", "P1"]); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-2 of 5"); + + await user.click(screen.getByTestId("pagination-next")); + expect(names()).toEqual(["P2", "P3"]); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 3-4 of 5"); + }); + + it("server mode shows X-Y of Z from rowCount and does NOT slice the given rows", async () => { + const user = userEvent.setup(); + const onPaginationChange = vi.fn(); + const pageSlice: Person[] = [person("10", "P10"), person("11", "P11"), person("12", "P12")]; + render( + , + ); + + expect(names()).toEqual(["P10", "P11", "P12"]); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 11-20 of 25"); + + await user.click(screen.getByTestId("pagination-next")); + expect(onPaginationChange).toHaveBeenCalledTimes(1); + }); +}); + +describe("DataTable column visibility", () => { + it("hides a column when toggled off in the view-options menu", async () => { + const user = userEvent.setup(); + render( + } + />, + ); + + expect(screen.getByText("Email")).toBeInTheDocument(); + await user.click(screen.getByTestId("view-options-trigger")); + await user.click(await screen.findByTestId("view-option-email")); + await waitFor(() => expect(screen.queryByText("Email")).not.toBeInTheDocument()); + + await user.click(screen.getByTestId("view-option-email")); + await waitFor(() => expect(screen.getByText("Email")).toBeInTheDocument()); + }); + + it("omits columns that opt out of hiding from the menu", async () => { + const user = userEvent.setup(); + const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + enableHiding: false, + cell: ({ row }) => {row.original.name}, + }, + { + accessorKey: "email", + header: "Email", + cell: ({ row }) => {row.original.email}, + }, + ]; + render( + } + />, + ); + + await user.click(screen.getByTestId("view-options-trigger")); + expect(await screen.findByTestId("view-option-email")).toBeInTheDocument(); + expect(screen.queryByTestId("view-option-name")).toBeNull(); + }); +}); + +describe("DataTable pinned columns", () => { + it("applies sticky positioning to a pinned column only", () => { + const { container } = render(); + + const pinnedHead = container.querySelector('th[data-header-id="name"]'); + const normalHead = container.querySelector('th[data-header-id="email"]'); + + expect(pinnedHead?.style.position).toBe("sticky"); + expect(pinnedHead?.style.left).toBe("0px"); + expect(normalHead?.style.position).toBe(""); + }); +}); + +describe("DataTable row click guard", () => { + it("fires onRowClick from a plain cell but not from interactive elements", async () => { + const user = userEvent.setup(); + const onRowClick = vi.fn(); + render(); + + await user.click(screen.getByTestId("name-cell")); + expect(onRowClick).toHaveBeenCalledTimes(1); + expect(onRowClick).toHaveBeenCalledWith(expect.objectContaining({ id: "a" })); + + await user.click(screen.getByTestId("row-button")); + expect(onRowClick).toHaveBeenCalledTimes(1); + + await user.click(screen.getByTestId("row-input")); + expect(onRowClick).toHaveBeenCalledTimes(1); + }); +}); + +describe("DataTable expansion", () => { + const subComponent = ({ row }: { row: { original: Person } }) => ( +
details for {row.original.name}
+ ); + + it("toggles the sub-row in uncontrolled mode", async () => { + const user = userEvent.setup(); + render( + row.id} + getRowCanExpand={() => true} + renderSubComponent={subComponent} + />, + ); + + expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument(); + await user.click(screen.getByTestId("expand-a")); + expect(screen.getByTestId("sub-row")).toBeInTheDocument(); + await user.click(screen.getByTestId("expand-a")); + expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument(); + }); + + it("toggles the sub-row in controlled mode driven by parent state", async () => { + const user = userEvent.setup(); + const Harness = () => { + const [expanded, setExpanded] = useState({}); + return ( + row.id} + expanded={expanded} + onExpandedChange={setExpanded} + getRowCanExpand={() => true} + renderSubComponent={subComponent} + /> + ); + }; + render(); + + expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument(); + await user.click(screen.getByTestId("expand-a")); + expect(screen.getByTestId("sub-row")).toBeInTheDocument(); + await user.click(screen.getByTestId("expand-a")); + expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument(); + }); + + it("stays collapsed in controlled mode when the parent ignores the change", async () => { + const user = userEvent.setup(); + const onExpandedChange = vi.fn(); + render( + row.id} + expanded={{}} + onExpandedChange={onExpandedChange} + getRowCanExpand={() => true} + renderSubComponent={subComponent} + />, + ); + + await user.click(screen.getByTestId("expand-a")); + expect(onExpandedChange).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument(); + }); +}); + +describe("DataTable row styling and footer", () => { + it("applies rowClassName to the matching row only", () => { + const data = [person("a", "Alice", true), person("b", "Bob", false)]; + const { container } = render( + row.id} + rowClassName={(row) => (row.original.flagged ? "flagged-row" : "")} + />, + ); + + expect(container.querySelector('tr[data-row-id="a"]')?.className).toContain("flagged-row"); + expect(container.querySelector('tr[data-row-id="b"]')?.className).not.toContain("flagged-row"); + }); + + it("renders the footer slot inside a tfoot element", () => { + render( + ( + + Total: 3 + + )} + />, + ); + + expect(screen.getByTestId("footer-row").closest("tfoot")).not.toBeNull(); + }); +}); + +describe("DataTable layout", () => { + it("exposes resize handles with stable selectors only when resizing is enabled", () => { + const { container, rerender } = render( + , + ); + expect(container.querySelectorAll("[data-resizer][data-header-id]").length).toBe(2); + + rerender(); + expect(container.querySelectorAll("[data-resizer]").length).toBe(0); + }); + + it("makes the header sticky and constrains body height when maxBodyHeight is set", () => { + const { container } = render(); + expect(container.querySelector("thead")?.className).toContain("sticky"); + const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement; + expect(scroller.style.maxHeight).toBe("240px"); + }); +}); + +describe("DataTable misconfiguration guards", () => { + it("throws when server sorting is missing required props", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow( + /sortingMode='server'/, + ); + spy.mockRestore(); + }); + + it("throws when server pagination is missing required props", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow( + /paginationMode='server'/, + ); + spy.mockRestore(); + }); + + it("throws when both defaultSorting and sorting are provided", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => + render( + , + ), + ).toThrow(/defaultSorting/); + spy.mockRestore(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx new file mode 100644 index 00000000000..7655454f381 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx @@ -0,0 +1,497 @@ +"use client"; + +import { + type Cell, + type Column, + type ColumnDef, + type ColumnPinningState, + type ColumnSizingState, + type ExpandedState, + flexRender, + getCoreRowModel, + getExpandedRowModel, + getPaginationRowModel, + getSortedRowModel, + type Header, + type OnChangeFn, + type Row, + type RowData, + type Table, + type TableOptions, + useReactTable, + type VisibilityState, +} from "@tanstack/react-table"; +import * as React from "react"; +import { Fragment, useState } from "react"; + +import { + Table as TableRoot, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/cva.config"; + +import "./columnMeta"; +import { DataTablePagination } from "./DataTablePagination"; +import type { ColumnPinnedSide, DataTableProps, DataTableSize, PaginationMode, SortingMode } from "./types"; + +const DEFAULT_PAGE_SIZE_OPTIONS = [25, 50, 100]; + +const INTERACTIVE_SELECTOR = "button, a, input, select, textarea, [role=checkbox], [data-row-click-exempt]"; + +const noop = () => {}; + +export class DataTableConfigError extends Error { + constructor(messages: readonly string[]) { + super(`DataTable misconfiguration:\n- ${messages.join("\n- ")}`); + this.name = "DataTableConfigError"; + } +} + +export function validateDataTableConfig( + props: DataTableProps, +): readonly string[] { + const serverSortingIncomplete = + props.sortingMode === "server" && (props.sorting === undefined || props.onSortingChange === undefined); + + const serverPaginationPropsMissing = + props.pagination === undefined || props.onPaginationChange === undefined || props.rowCount === undefined; + const serverPaginationIncomplete = props.paginationMode === "server" && serverPaginationPropsMissing; + + const bothSortingSources = props.defaultSorting !== undefined && props.sorting !== undefined; + + return [ + serverSortingIncomplete ? "sortingMode='server' requires both `sorting` and `onSortingChange`." : null, + serverPaginationIncomplete + ? "paginationMode='server' requires `pagination`, `onPaginationChange`, and `rowCount`." + : null, + bothSortingSources ? "Provide either `defaultSorting` (uncontrolled) or `sorting` (controlled), not both." : null, + ].filter((message): message is string => message !== null); +} + +function columnDefId(column: ColumnDef): string | undefined { + if ("id" in column && typeof column.id === "string") { + return column.id; + } + if ("accessorKey" in column && column.accessorKey != null) { + return String(column.accessorKey); + } + return undefined; +} + +function derivePinning(columns: ColumnDef[]): ColumnPinningState { + const collect = (side: ColumnPinnedSide): string[] => + columns + .filter((column) => column.meta?.pinned === side) + .map(columnDefId) + .filter((id): id is string => id !== undefined); + return { left: collect("left"), right: collect("right") }; +} + +function buildRowModels( + sortingMode: SortingMode, + paginationMode: PaginationMode, + getRowCanExpand: ((row: Row) => boolean) | undefined, +): Partial> { + return { + ...(sortingMode === "client" ? { getSortedRowModel: getSortedRowModel() } : {}), + ...(paginationMode === "client" ? { getPaginationRowModel: getPaginationRowModel() } : {}), + ...(getRowCanExpand !== undefined ? { getRowCanExpand, getExpandedRowModel: getExpandedRowModel() } : {}), + }; +} + +function stickyZIndex(isPinned: boolean, isHeader: boolean): number { + if (isPinned && isHeader) { + return 30; + } + if (isHeader) { + return 20; + } + return 10; +} + +function pinnedShadow(pinned: false | ColumnPinnedSide): string { + if (pinned === "left") { + return "shadow-[inset_-1px_0_0_var(--color-border)]"; + } + if (pinned === "right") { + return "shadow-[inset_1px_0_0_var(--color-border)]"; + } + return ""; +} + +function computeStickyStyle( + column: Column, + isHeader: boolean, + stickyHeader: boolean, +): { style: React.CSSProperties; className: string } { + const pinned = column.getIsPinned(); + const stickyTop = isHeader && stickyHeader; + if (!pinned && !stickyTop) { + return { style: {}, className: "" }; + } + + const left = pinned === "left" ? column.getStart("left") : undefined; + const right = pinned === "right" ? column.getAfter("right") : undefined; + + const style: React.CSSProperties = { + position: "sticky", + zIndex: stickyZIndex(pinned !== false, isHeader), + ...(stickyTop ? { top: 0 } : {}), + ...(left !== undefined ? { left } : {}), + ...(right !== undefined ? { right } : {}), + }; + + return { style, className: cn(pinned ? "bg-background" : "", pinnedShadow(pinned)) }; +} + +function widthStyle( + column: Column, + enableColumnResizing: boolean, +): React.CSSProperties | undefined { + if (enableColumnResizing || column.columnDef.size !== undefined) { + return { width: column.getSize() }; + } + return undefined; +} + +interface HeadCellProps { + header: Header; + size: DataTableSize; + stickyHeader: boolean; + enableColumnResizing: boolean; +} + +function DataTableHeadCell({ header, size, stickyHeader, enableColumnResizing }: HeadCellProps) { + const { column } = header; + const meta = column.columnDef.meta; + const sticky = computeStickyStyle(column, true, stickyHeader); + const canResize = enableColumnResizing && column.getCanResize(); + + return ( + + {header.isPlaceholder ? null : ( +
+ {flexRender(column.columnDef.header, header.getContext())} +
+ )} + {canResize && ( +
column.resetSize()} + className={cn( + "absolute top-0 right-0 h-full w-1 cursor-col-resize touch-none select-none hover:bg-border", + column.getIsResizing() ? "bg-primary" : "", + )} + /> + )} + + ); +} + +interface BodyCellProps { + cell: Cell; + size: DataTableSize; + stickyHeader: boolean; + enableColumnResizing: boolean; +} + +function DataTableBodyCell({ cell, size, stickyHeader, enableColumnResizing }: BodyCellProps) { + const { column } = cell; + const meta = column.columnDef.meta; + const sticky = computeStickyStyle(column, false, stickyHeader); + + return ( + + {flexRender(column.columnDef.cell, cell.getContext())} + + ); +} + +interface BodyRowProps { + row: Row; + size: DataTableSize; + stickyHeader: boolean; + enableColumnResizing: boolean; + onRowClick?: (row: TData) => void; + rowClassName?: (row: Row) => string; + renderSubComponent?: (props: { row: Row }) => React.ReactElement; +} + +function DataTableBodyRow({ + row, + size, + stickyHeader, + enableColumnResizing, + onRowClick, + rowClassName, + renderSubComponent, +}: BodyRowProps) { + const clickable = onRowClick !== undefined; + const cells = row.getVisibleCells(); + + const handleClick = (event: React.MouseEvent) => { + if (onRowClick === undefined) { + return; + } + const target = event.target as HTMLElement | null; + if (target === null || !event.currentTarget.contains(target)) { + return; + } + if (target.closest(INTERACTIVE_SELECTOR) !== null) { + return; + } + onRowClick(row.original); + }; + + return ( + + + {cells.map((cell) => ( + + ))} + + {renderSubComponent !== undefined && row.getIsExpanded() && ( + + + {renderSubComponent({ row })} + + + )} + + ); +} + +function MessageRow({ colSpan, children }: { colSpan: number; children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + +function useControllable( + controlled: T | undefined, + controlledOnChange: OnChangeFn | undefined, + initial: T, +): { value: T; onChange: OnChangeFn } { + const [internal, setInternal] = useState(initial); + if (controlled !== undefined) { + return { value: controlled, onChange: controlledOnChange ?? noop }; + } + return { value: internal, onChange: setInternal }; +} + +function useDataTableInstance(props: DataTableProps): Table { + const { + data, + columns, + getRowId, + sortingMode = "none", + sorting, + onSortingChange, + defaultSorting, + enableSortingRemoval = false, + paginationMode = "none", + pagination, + onPaginationChange, + rowCount, + pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, + enableColumnResizing = false, + columnResizeMode = "onEnd", + defaultColumnVisibility, + getRowCanExpand, + renderSubComponent, + expanded, + onExpandedChange, + } = props; + + const sortingState = useControllable(sorting, onSortingChange, defaultSorting ?? []); + const paginationState = useControllable(pagination, onPaginationChange, { + pageIndex: 0, + pageSize: pageSizeOptions[0] ?? 25, + }); + const expandedState = useControllable(expanded, onExpandedChange, {}); + const [columnVisibility, setColumnVisibility] = useState(defaultColumnVisibility ?? {}); + const [columnSizing, setColumnSizing] = useState({}); + const columnPinning = React.useMemo(() => derivePinning(columns), [columns]); + const expansionGuard = renderSubComponent !== undefined ? getRowCanExpand : undefined; + + const tableOptions: TableOptions = { + data, + columns, + state: { + sorting: sortingState.value, + pagination: paginationState.value, + expanded: expandedState.value, + columnVisibility, + columnSizing, + }, + initialState: { columnPinning }, + manualSorting: sortingMode === "server", + manualPagination: paginationMode === "server", + enableSortingRemoval, + enableColumnResizing, + columnResizeMode, + onSortingChange: sortingState.onChange, + onPaginationChange: paginationState.onChange, + onExpandedChange: expandedState.onChange, + onColumnVisibilityChange: setColumnVisibility, + onColumnSizingChange: setColumnSizing, + getCoreRowModel: getCoreRowModel(), + ...buildRowModels(sortingMode, paginationMode, expansionGuard), + ...(getRowId !== undefined ? { getRowId } : {}), + ...(paginationMode === "server" && rowCount !== undefined ? { rowCount } : {}), + }; + + return useReactTable(tableOptions); +} + +export function DataTable(props: DataTableProps) { + // Validate once at construction so a misconfig surfaces immediately instead of on every render. + useState(() => { + const errors = validateDataTableConfig(props); + if (errors.length > 0) { + throw new DataTableConfigError(errors); + } + return null; + }); + + const { + isLoading = false, + loadingMessage = "Loading…", + noDataMessage = "No results", + paginationMode = "none", + rowCount, + pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, + enableColumnResizing = false, + onRowClick, + rowClassName, + renderSubComponent, + maxBodyHeight, + size = "default", + toolbar, + paginationSlot, + footer, + } = props; + + const table = useDataTableInstance(props); + + const rows = table.getRowModel().rows; + const visibleColumnCount = table.getVisibleLeafColumns().length; + const stickyHeader = maxBodyHeight !== undefined; + const tableStyle = enableColumnResizing ? { width: table.getTotalSize() } : undefined; + + const renderPagination = (): React.ReactNode => { + if (paginationSlot !== undefined) { + return paginationSlot(table); + } + if (paginationMode === "none") { + return null; + } + const current = table.getState().pagination; + const total = paginationMode === "server" ? rowCount ?? 0 : table.getPrePaginationRowModel().rows.length; + return ( + table.setPageIndex(next)} + onPageSizeChange={(next) => table.setPageSize(next)} + pageSizeOptions={pageSizeOptions} + isLoading={isLoading} + /> + ); + }; + + const renderBody = (): React.ReactNode => { + if (isLoading) { + return {loadingMessage}; + } + if (rows.length === 0) { + return {noDataMessage}; + } + return rows.map((row) => ( + + )); + }; + + return ( +
+ {toolbar !== undefined &&
{toolbar(table)}
} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + {renderBody()} + {footer !== undefined && {footer(table)}} + +
+ {renderPagination()} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.test.tsx new file mode 100644 index 00000000000..e5bd4a55b53 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { DataTablePagination } from "./DataTablePagination"; + +const baseProps = { + page: 0, + pageSize: 25, + rowCount: 100, + onPageChange: () => {}, + onPageSizeChange: () => {}, +}; + +describe("DataTablePagination", () => { + it("renders the current range from plain props", () => { + render(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 100"); + }); + + it("computes the range for a middle page and clamps the end to rowCount", () => { + render(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 91-100 of 100"); + }); + + it("disables the previous controls on the first page", () => { + render(); + expect(screen.getByTestId("pagination-first")).toBeDisabled(); + expect(screen.getByTestId("pagination-prev")).toBeDisabled(); + expect(screen.getByTestId("pagination-next")).toBeEnabled(); + }); + + it("disables the next controls on the last page", () => { + render(); + expect(screen.getByTestId("pagination-next")).toBeDisabled(); + expect(screen.getByTestId("pagination-last")).toBeDisabled(); + expect(screen.getByTestId("pagination-prev")).toBeEnabled(); + }); + + it("advances by one page when next is clicked", async () => { + const user = userEvent.setup(); + const onPageChange = vi.fn(); + render(); + await user.click(screen.getByTestId("pagination-next")); + expect(onPageChange).toHaveBeenCalledWith(2); + }); + + it("jumps to the last page index when last is clicked", async () => { + const user = userEvent.setup(); + const onPageChange = vi.fn(); + render(); + await user.click(screen.getByTestId("pagination-last")); + expect(onPageChange).toHaveBeenCalledWith(3); + }); + + it("shows an empty state and disables all navigation when there are no rows", () => { + render(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("No results"); + expect(screen.getByTestId("pagination-next")).toBeDisabled(); + expect(screen.getByTestId("pagination-prev")).toBeDisabled(); + }); + + it("disables navigation while loading", () => { + render(); + expect(screen.getByTestId("pagination-next")).toBeDisabled(); + expect(screen.getByTestId("pagination-prev")).toBeDisabled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.tsx new file mode 100644 index 00000000000..5a30b12f27f --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { cn } from "@/lib/cva.config"; + +export const DEFAULT_PAGE_SIZE_OPTIONS = [25, 50, 100]; + +export interface DataTablePaginationProps { + page: number; + pageSize: number; + rowCount: number; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; + pageSizeOptions?: number[]; + isLoading?: boolean; + className?: string; +} + +export function DataTablePagination({ + page, + pageSize, + rowCount, + onPageChange, + onPageSizeChange, + pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, + isLoading = false, + className, +}: DataTablePaginationProps) { + const pageCount = pageSize > 0 ? Math.ceil(rowCount / pageSize) : 0; + const start = rowCount === 0 ? 0 : page * pageSize + 1; + const end = Math.min((page + 1) * pageSize, rowCount); + const canPrev = page > 0 && !isLoading; + const canNext = page < pageCount - 1 && !isLoading; + const lastPage = Math.max(pageCount - 1, 0); + + return ( +
+
+ Rows per page + +
+ +
+ + {rowCount === 0 ? "No results" : `Showing ${start}-${end} of ${rowCount}`} + +
+ + + + +
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.test.tsx new file mode 100644 index 00000000000..a6164307a78 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.test.tsx @@ -0,0 +1,112 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getSortedRowModel, + type OnChangeFn, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { DataTableSortHeader, type DataTableSortVariant } from "./DataTableSortHeader"; + +interface Item { + name: string; +} + +interface HarnessProps { + variant: DataTableSortVariant; + canSort?: boolean; + onSortingChange?: OnChangeFn; +} + +function SortHeaderHarness({ variant, canSort = true, onSortingChange }: HarnessProps) { + const [sorting, setSorting] = useState([]); + const columns: ColumnDef[] = [ + { + accessorKey: "name", + enableSorting: canSort, + header: ({ column }) => , + }, + ]; + const options = { + data: [{ name: "x" }], + columns, + state: { sorting }, + onSortingChange: (updater: SortingState | ((prev: SortingState) => SortingState)) => { + setSorting(updater); + onSortingChange?.(updater); + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }; + const table = useReactTable(options); + + return ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + +
{flexRender(header.column.columnDef.header, header.getContext())}
+ ); +} + +describe("DataTableSortHeader", () => { + it("renders a plain label and no button when the column cannot sort", () => { + render(); + expect(screen.queryByTestId("sort-header-name")).toBeNull(); + expect(screen.getByText("Name")).toBeInTheDocument(); + }); + + it("header-cycle indicator advances none -> asc -> desc on click", async () => { + const user = userEvent.setup(); + render(); + const indicator = () => screen.getByTestId("sort-header-name").querySelector("[data-sort-indicator]"); + + expect(indicator()).toHaveAttribute("data-sort-indicator", "none"); + await user.click(screen.getByTestId("sort-header-name")); + expect(indicator()).toHaveAttribute("data-sort-indicator", "asc"); + await user.click(screen.getByTestId("sort-header-name")); + expect(indicator()).toHaveAttribute("data-sort-indicator", "desc"); + }); + + it("dropdown-tristate sets ascending, descending, and reset from the menu", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("sort-trigger-name")); + await user.click(await screen.findByText("Descending")); + expect(screen.getByTestId("sort-trigger-name").querySelector('[data-sort-indicator="desc"]')).not.toBeNull(); + + await user.click(screen.getByTestId("sort-trigger-name")); + await user.click(await screen.findByText("Ascending")); + expect(screen.getByTestId("sort-trigger-name").querySelector('[data-sort-indicator="asc"]')).not.toBeNull(); + + await user.click(screen.getByTestId("sort-trigger-name")); + await user.click(await screen.findByText("Reset")); + expect(screen.getByTestId("sort-trigger-name").querySelector('[data-sort-indicator="none"]')).not.toBeNull(); + }); + + it("dropdown-tristate trigger stops the click from reaching an outer handler", async () => { + const user = userEvent.setup(); + const onOuterClick = vi.fn(); + render( +
+ +
, + ); + + await user.click(screen.getByTestId("sort-trigger-name")); + expect(onOuterClick).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.tsx new file mode 100644 index 00000000000..1cf09ce4f47 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { Menu } from "@base-ui/react/menu"; +import type { Column, SortDirection } from "@tanstack/react-table"; +import { ChevronDown, ChevronsUpDown, ChevronUp, X } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "@/lib/cva.config"; + +export type DataTableSortVariant = "header-cycle" | "dropdown-tristate"; + +interface DataTableSortHeaderProps { + column: Column; + title: React.ReactNode; + variant?: DataTableSortVariant; + className?: string; +} + +function SortIndicator({ sorted }: { sorted: false | SortDirection }) { + if (sorted === "asc") { + return ; + } + if (sorted === "desc") { + return ; + } + return ; +} + +const MENU_ITEM_CLASS = + "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"; + +export function DataTableSortHeader({ + column, + title, + variant = "header-cycle", + className, +}: DataTableSortHeaderProps) { + const sorted = column.getIsSorted(); + + if (!column.getCanSort()) { + return {title}; + } + + if (variant === "dropdown-tristate") { + return ( +
+ {title} + + event.stopPropagation()} + className={cn( + "inline-flex size-6 items-center justify-center rounded-md hover:bg-muted", + sorted ? "text-primary" : "text-muted-foreground", + )} + > + + + } + /> + + + + column.toggleSorting(false)}> + Ascending + + column.toggleSorting(true)}> + Descending + + column.clearSorting()}> + Reset + + + + + +
+ ); + } + + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.test.tsx new file mode 100644 index 00000000000..5d1f5340f5d --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { DataTableToolbar } from "./DataTableToolbar"; + +describe("DataTableToolbar", () => { + it("renders slotted action children", () => { + render( + + + , + ); + expect(screen.getByTestId("toolbar-action")).toBeInTheDocument(); + }); + + it("shows the reset button only when there are active filters", async () => { + const user = userEvent.setup(); + const onResetFilters = vi.fn(); + const { rerender } = render(); + expect(screen.queryByText("Reset Filters")).toBeNull(); + + rerender(); + await user.click(screen.getByText("Reset Filters")); + expect(onResetFilters).toHaveBeenCalledTimes(1); + }); + + it("wires the filters toggle button", async () => { + const user = userEvent.setup(); + const onToggleFilters = vi.fn(); + render(); + await user.click(screen.getByText("Filters")); + expect(onToggleFilters).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.tsx new file mode 100644 index 00000000000..80b4ecc7edd --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Search } from "lucide-react"; +import type * as React from "react"; + +import { FilterInput } from "@/components/common_components/Filters/FilterInput"; +import { FiltersButton } from "@/components/common_components/Filters/FiltersButton"; +import { ResetFiltersButton } from "@/components/common_components/Filters/ResetFiltersButton"; +import { cn } from "@/lib/cva.config"; + +interface DataTableToolbarProps { + searchValue?: string; + onSearchChange?: (value: string) => void; + searchPlaceholder?: string; + filtersActive?: boolean; + hasActiveFilters?: boolean; + onToggleFilters?: () => void; + onResetFilters?: () => void; + children?: React.ReactNode; + className?: string; +} + +export function DataTableToolbar({ + searchValue, + onSearchChange, + searchPlaceholder = "Search", + filtersActive = false, + hasActiveFilters = false, + onToggleFilters, + onResetFilters, + children, + className, +}: DataTableToolbarProps) { + const showReset = onResetFilters !== undefined && hasActiveFilters; + + return ( +
+
+ {onSearchChange !== undefined && ( + + )} + {onToggleFilters !== undefined && ( + + )} + {showReset && } +
+ {children !== undefined &&
{children}
} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx new file mode 100644 index 00000000000..ab56aafe7b5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Menu } from "@base-ui/react/menu"; +import type { Table } from "@tanstack/react-table"; +import { Check, SlidersHorizontal } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +interface DataTableViewOptionsProps { + table: Table; + label?: string; + className?: string; +} + +export function DataTableViewOptions({ table, label = "View", className }: DataTableViewOptionsProps) { + const hideableColumns = table.getAllLeafColumns().filter((column) => column.getCanHide()); + + if (hideableColumns.length === 0) { + return null; + } + + return ( + + + + {label} + + } + /> + + + + {hideableColumns.map((column) => ( + column.toggleVisibility(checked)} + closeOnClick={false} + data-testid={`view-option-${column.id}`} + className="relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-7 capitalize outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground" + > + + + + {column.columnDef.meta?.title ?? column.id} + + ))} + + + + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts b/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts new file mode 100644 index 00000000000..46e72226038 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts @@ -0,0 +1,13 @@ +import type { RowData } from "@tanstack/react-table"; + +import type { ColumnPinnedSide } from "./types"; + +declare module "@tanstack/react-table" { + interface ColumnMeta { + numeric?: boolean; + className?: string; + headerClassName?: string; + title?: string; + pinned?: ColumnPinnedSide; + } +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/index.ts b/ui/litellm-dashboard/src/components/shared/DataTable/index.ts new file mode 100644 index 00000000000..49a4430bbee --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/index.ts @@ -0,0 +1,16 @@ +import "./columnMeta"; + +export { DataTable, DataTableConfigError, validateDataTableConfig } from "./DataTable"; +export { DataTablePagination, DEFAULT_PAGE_SIZE_OPTIONS } from "./DataTablePagination"; +export { DataTableToolbar } from "./DataTableToolbar"; +export { DataTableViewOptions } from "./DataTableViewOptions"; +export { DataTableSortHeader, type DataTableSortVariant } from "./DataTableSortHeader"; +export type { DataTablePaginationProps } from "./DataTablePagination"; +export type { + ColumnPinnedSide, + ColumnResizeMode, + DataTableProps, + DataTableSize, + PaginationMode, + SortingMode, +} from "./types"; diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/types.ts b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts new file mode 100644 index 00000000000..8fa6f21c4d3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts @@ -0,0 +1,60 @@ +import type { + ColumnDef, + ExpandedState, + OnChangeFn, + PaginationState, + Row, + RowData, + SortingState, + Table, + VisibilityState, +} from "@tanstack/react-table"; +import type * as React from "react"; + +export type SortingMode = "none" | "client" | "server"; +export type PaginationMode = "none" | "client" | "server"; +export type ColumnResizeMode = "onEnd" | "onChange"; +export type DataTableSize = "compact" | "default"; +export type ColumnPinnedSide = "left" | "right"; + +export interface DataTableProps { + data: TData[]; + columns: ColumnDef[]; + getRowId?: (row: TData, index: number, parent?: Row) => string; + + isLoading?: boolean; + loadingMessage?: string; + noDataMessage?: React.ReactNode; + + sortingMode?: SortingMode; + sorting?: SortingState; + onSortingChange?: OnChangeFn; + defaultSorting?: SortingState; + enableSortingRemoval?: boolean; + + paginationMode?: PaginationMode; + pagination?: PaginationState; + onPaginationChange?: OnChangeFn; + rowCount?: number; + pageSizeOptions?: number[]; + + enableColumnResizing?: boolean; + columnResizeMode?: ColumnResizeMode; + defaultColumnVisibility?: VisibilityState; + + getRowCanExpand?: (row: Row) => boolean; + renderSubComponent?: (props: { row: Row }) => React.ReactElement; + expanded?: ExpandedState; + onExpandedChange?: OnChangeFn; + + onRowClick?: (row: TData) => void; + + rowClassName?: (row: Row) => string; + + maxBodyHeight?: number | string; + size?: DataTableSize; + + toolbar?: (table: Table) => React.ReactNode; + paginationSlot?: (table: Table) => React.ReactNode; + footer?: (table: Table) => React.ReactNode; +} From b0ff698addc9246a28f0f247669d487f43bb608f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Jul 2026 15:21:52 -0700 Subject: [PATCH 002/188] refactor(ui): migrate Workflow Runs table onto shared DataTable Proof-of-concept consumer for the shared DataTable added in the previous commit. Swaps the antd Table in the Workflow Runs page for DataTable in client-pagination mode, keeping the existing cell renderers, row-click drawer, and empty state. Adds a focused test that the rows render through DataTable, a row click routes the detail fetch to the correct run, and the empty state shows. --- .../workflows/WorkflowRuns.test.tsx | 81 +++++++++++ .../(dashboard)/workflows/WorkflowRuns.tsx | 136 +++++++++--------- 2 files changed, 149 insertions(+), 68 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx new file mode 100644 index 00000000000..e73abfe7cd6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx @@ -0,0 +1,81 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import WorkflowRuns from "./WorkflowRuns"; + +vi.mock("@/components/networking", () => ({ proxyBaseUrl: "" })); + +interface FakeRun { + run_id: string; + status: string; + workflow_type: string; + created_at: string; + metadata: { title?: string; state?: string } | null; +} + +const RUNS: FakeRun[] = [ + { + run_id: "run-aaaaaaaa-1111", + status: "completed", + workflow_type: "grill", + created_at: "2026-01-01T00:00:00Z", + metadata: { title: "First run", state: "done" }, + }, + { + run_id: "run-bbbbbbbb-2222", + status: "running", + workflow_type: "autofix", + created_at: "2026-01-02T00:00:00Z", + metadata: null, + }, +]; + +function mockFetch(runs: FakeRun[]) { + return vi.fn((url: string) => { + if (url.includes("/runs?limit")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ runs }) }); + } + if (url.includes("/events")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ events: [] }) }); + } + if (url.includes("/messages")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages: [] }) }); + } + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("WorkflowRuns (migrated onto shared DataTable)", () => { + it("renders one DataTable row per fetched run", async () => { + vi.stubGlobal("fetch", mockFetch(RUNS)); + const { container } = render(); + + expect(await screen.findByText("First run")).toBeInTheDocument(); + expect(container.querySelectorAll("tr[data-row-id]")).toHaveLength(2); + }); + + it("opens the detail drawer for the clicked run by firing its detail fetch", async () => { + const user = userEvent.setup(); + const fetchSpy = mockFetch(RUNS); + vi.stubGlobal("fetch", fetchSpy); + render(); + + await user.click(await screen.findByText("First run")); + + await waitFor(() => + expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining("run-aaaaaaaa-1111/events"), expect.anything()), + ); + }); + + it("shows the empty state when there are no runs", async () => { + vi.stubGlobal("fetch", mockFetch([])); + render(); + + expect(await screen.findByText("No workflow runs yet")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx index 2aecbece2e3..b668cdee0bd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx @@ -1,7 +1,9 @@ -import React, { useState, useEffect, useCallback } from "react"; -import { Button, Collapse, Drawer, Empty, Spin, Table, Tooltip, Typography } from "antd"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Button, Collapse, Drawer, Empty, Spin, Tooltip, Typography } from "antd"; import { ReloadOutlined } from "@ant-design/icons"; +import type { ColumnDef } from "@tanstack/react-table"; import { proxyBaseUrl } from "@/components/networking"; +import { DataTable } from "@/components/shared/DataTable"; const { Text } = Typography; @@ -541,48 +543,54 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { fetchRuns(); }, [fetchRuns]); - const columns = [ - { - title: "Run", - dataIndex: "run_id", - key: "run", - render: (_: string, run: WorkflowRun) => ( -
- -
-
{runTitle(run)}
-
{shortId(run.run_id)}
-
-
- ), - }, - { - title: "Type", - dataIndex: "workflow_type", - key: "workflow_type", - render: (v: string) => {v}, - }, - { - title: "Status", - dataIndex: "status", - key: "status", - render: (status: RunStatus, run: WorkflowRun) => { - const state = run.metadata?.state; - return ( -
- - {state ?? status} -
- ); + const columns = useMemo[]>( + () => [ + { + id: "run", + header: "Run", + cell: ({ row }) => { + const run = row.original; + return ( +
+ +
+
{runTitle(run)}
+
{shortId(run.run_id)}
+
+
+ ); + }, }, - }, - { - title: "Created", - dataIndex: "created_at", - key: "created_at", - render: (v: string) => {timeAgo(v)}, - }, - ]; + { + accessorKey: "workflow_type", + header: "Type", + cell: ({ row }) => ( + {row.original.workflow_type} + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => { + const run = row.original; + return ( +
+ + + {run.metadata?.state ?? run.status} + +
+ ); + }, + }, + { + accessorKey: "created_at", + header: "Created", + cell: ({ row }) => {timeAgo(row.original.created_at)}, + }, + ], + [], + ); return (
= ({ accessToken }) => {
- {/* runs table — matches logs page density */} -
- ({ - onClick: () => fetchRunDetail(run), - style: { cursor: "pointer" }, - })} - locale={{ - emptyText: ( - No workflow runs yet} - image={Empty.PRESENTED_IMAGE_SIMPLE} - /> - ), - }} - className="[&_.ant-table-cell]:py-0.5 [&_.ant-table-thead_.ant-table-cell]:py-1" - style={{ border: "none" }} - /> - + run.run_id} + isLoading={loadingRuns} + loadingMessage="Loading workflow runs…" + noDataMessage={ + No workflow runs yet} + image={Empty.PRESENTED_IMAGE_SIMPLE} + /> + } + paginationMode="client" + pageSizeOptions={[50, 100]} + onRowClick={fetchRunDetail} + size="compact" + /> {/* detail drawer */} Date: Thu, 9 Jul 2026 16:08:18 -0700 Subject: [PATCH 003/188] refactor(ui): migrate Team Info virtual keys table onto shared DataTable Second proof-of-concept consumer for the shared DataTable. Replaces the hand-rolled tremor table in the Team Info Virtual Keys tab with DataTable in server-sort and server-pagination mode plus column resizing; the file drops about 150 lines. Sortable headers now use DataTableSortHeader, pagination is a detached DataTablePagination driven by the page state, the id-cell still opens the key drawer, and the body scrolls under a sticky header via maxBodyHeight. Two behavior changes: the pagination control is the standardized bar (row range plus page-size select) rather than the old Previous/Next buttons, and a sort header cycles ascending/descending without a third unsorted state, which also removes a latent case where clearing the sort left the server sorted. Updates the TeamVirtualKeysTable and TeamInfo tests to the new pagination, adds a test that a sort-header click routes to useKeys as a server sort, and lowers the no-large-inline-object-arg metric by one and the file's no-nested-ternary suppression from two to one to match the leaner code. --- ui/litellm-dashboard/eslint-metrics.json | 2 +- ui/litellm-dashboard/eslint-suppressions.json | 2 +- .../src/components/team/TeamInfo.test.tsx | 8 +- .../team/TeamVirtualKeysTable.test.tsx | 32 ++- .../components/team/TeamVirtualKeysTable.tsx | 235 +++--------------- 5 files changed, 74 insertions(+), 205 deletions(-) diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index fcf60934f64..ad60a0d0c89 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,7 +1,7 @@ { "@typescript-eslint/no-explicit-any": 1980, "complexity": 128, - "local/no-large-inline-object-arg": 519, + "local/no-large-inline-object-arg": 518, "local/no-long-condition-chain": 233, "max-depth": 59, "no-console": 15 diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index fe8f182c106..6d310a97dbf 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2323,7 +2323,7 @@ }, "src/components/team/TeamVirtualKeysTable.tsx": { "no-nested-ternary": { - "count": 2 + "count": 1 }, "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index 46eac1c5772..4ccfb891417 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -536,7 +536,7 @@ describe("TeamInfoView", () => { await user.click(virtualKeysTab); await waitFor(() => { - expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-5 of 5"); }); }); @@ -584,9 +584,9 @@ describe("TeamInfoView", () => { expect(screen.getByRole("button", { name: "Filters" })).toBeInTheDocument(); }); expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument(); - expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Previous" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-1 of 1"); + expect(screen.getByTestId("pagination-prev")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-next")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx index 954f5ea98c5..9eb0282580b 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx @@ -163,7 +163,7 @@ describe("TeamVirtualKeysTable", () => { expect(screen.getByText("bob_key_team1")).toBeInTheDocument(); }); - it("should show Page X of Y when multiple pages exist", async () => { + it("should show the current range from total_count when multiple pages exist", async () => { mockUseKeys.mockReturnValue({ data: { keys: [createMockKey()], @@ -179,7 +179,7 @@ describe("TeamVirtualKeysTable", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Page 1 of 3")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 100"); }); }); @@ -203,17 +203,39 @@ describe("TeamVirtualKeysTable", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Page 1 of 3")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 100"); }); - const nextButton = screen.getByRole("button", { name: "Next" }); - await user.click(nextButton); + await user.click(screen.getByTestId("pagination-next")); await waitFor(() => { expect(mockUseKeys).toHaveBeenLastCalledWith(2, 50, expect.objectContaining({ teamID: "team-1" })); }); }); + it("routes a sort-header click to useKeys as a server-side sort", async () => { + const user = userEvent.setup(); + mockUseKeys.mockReturnValue({ + data: { keys: [createMockKey()], total_count: 1, current_page: 1, total_pages: 1 } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as unknown as ReturnType); + + renderWithProviders(); + + await waitFor(() => expect(screen.getByTestId("sort-header-created_at")).toBeInTheDocument()); + await user.click(screen.getByTestId("sort-header-created_at")); + + await waitFor(() => + expect(mockUseKeys).toHaveBeenLastCalledWith( + 1, + 50, + expect.objectContaining({ sortBy: "created_at", sortOrder: "asc" }), + ), + ); + }); + it("should show Loading keys when isPending", async () => { mockUseKeys.mockReturnValue({ data: undefined, diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index b7128e642a5..909608d630f 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -1,20 +1,12 @@ -// TO-DO: Standardize tables eventually - "use client"; import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells"; -import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - PaginationState, - SortingState, - useReactTable, -} from "@tanstack/react-table"; -import { Badge, Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text } from "@tremor/react"; +import { DataTable, DataTablePagination, DataTableSortHeader } from "@/components/shared/DataTable"; +import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; +import { ColumnDef, PaginationState, SortingState } from "@tanstack/react-table"; +import { Badge, Icon, Text } from "@tremor/react"; import { InfoCircleOutlined } from "@ant-design/icons"; -import { Popover, Skeleton, Tooltip, Typography } from "antd"; +import { Popover, Tooltip, Typography } from "antd"; 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"; @@ -83,7 +75,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi })); }, [keys?.keys, organization?.organization_id]); - const pageCount = keys?.total_pages ?? 0; + const rowCount = keys?.total_count ?? 0; const [expandedAccordions, setExpandedAccordions] = useState>({}); const currentTeam: Team = useMemo( @@ -200,7 +192,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "token", accessorKey: "token", - header: "Key ID", + header: ({ column }) => , size: 100, enableSorting: true, cell: (info) => ( @@ -210,7 +202,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "key_alias", accessorKey: "key_alias", - header: "Key Alias", + header: ({ column }) => , size: 150, enableSorting: true, cell: (info) => { @@ -282,7 +274,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "created_at", accessorKey: "created_at", - header: "Created At", + header: ({ column }) => , size: 120, enableSorting: true, cell: (info) => , @@ -349,7 +341,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "updated_at", accessorKey: "updated_at", - header: "Updated At", + header: ({ column }) => , size: 120, enableSorting: true, cell: (info) => , @@ -383,7 +375,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "spend", accessorKey: "spend", - header: "Spend (USD)", + header: ({ column }) => , size: 100, enableSorting: true, cell: (info) => , @@ -391,7 +383,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "max_budget", accessorKey: "max_budget", - header: "Budget (USD)", + header: ({ column }) => , size: 110, enableSorting: true, cell: (info) => ( @@ -529,22 +521,6 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi [sorting, handleFilterChange], ); - const table = useReactTable({ - data: displayKeys, - columns, - columnResizeMode: "onChange", - columnResizeDirection: "ltr", - state: { sorting, pagination: tablePagination }, - onSortingChange: handleSortingChange, - onPaginationChange: setTablePagination, - getCoreRowModel: getCoreRowModel(), - // getSortedRowModel not needed — manualSorting: true delegates sorting to the server - enableSorting: true, - manualSorting: true, // Server sorts via useKeys. Avoid redundant client-side sort - manualPagination: true, - pageCount: pageCount, - }); - return (
{selectedKey ? ( @@ -566,165 +542,36 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi />
-
-
- {isLoading || isFetching ? ( - - ) : ( - - Page {pageIndex + 1} of {table.getPageCount()} - - )} - - {isLoading || isFetching ? ( - - ) : ( - - )} - - {isLoading || isFetching ? ( - - ) : ( - - )} -
-
-
-
-
-
- - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - { - const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); - if (resizer) (resizer as HTMLElement).style.opacity = "0.5"; - }} - onMouseLeave={() => { - const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); - if (resizer && !header.column.getIsResizing()) - (resizer as HTMLElement).style.opacity = "0"; - }} - onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined} - > -
-
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} -
- {header.id !== "actions" && header.column.getCanSort() && ( -
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
- )} -
header.column.resetSize()} - onMouseDown={header.getResizeHandler()} - onTouchStart={header.getResizeHandler()} - className={`resizer ${table.options.columnResizeDirection} ${ - header.column.getIsResizing() ? "isResizing" : "" - }`} - style={{ - position: "absolute", - right: 0, - top: 0, - height: "100%", - width: "5px", - background: header.column.getIsResizing() ? "#3b82f6" : "transparent", - cursor: "col-resize", - userSelect: "none", - touchAction: "none", - opacity: header.column.getIsResizing() ? 1 : 0, - }} - /> -
- - ))} - - ))} - - - {isLoading || isFetching ? ( - - -
-

Loading keys...

-
-
-
- ) : displayKeys.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - 3 - ? "px-0" - : "" - }`} - > - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-

No keys found

-
-
-
- )} -
-
-
-
+
+ setTablePagination((prev) => ({ ...prev, pageIndex: nextPage }))} + onPageSizeChange={(nextSize) => setTablePagination({ pageIndex: 0, pageSize: nextSize })} + isLoading={isLoading || isFetching} + />
+ + null} + enableColumnResizing + columnResizeMode="onChange" + isLoading={isLoading || isFetching} + loadingMessage="Loading keys..." + noDataMessage="No keys found" + maxBodyHeight="75vh" + size="compact" + /> )} From 1b96bfacec0542ce123922f2b3450958a1ba18c8 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Jul 2026 16:58:43 -0700 Subject: [PATCH 004/188] refactor(ui): widen Key ID and Created By columns, drop Last Active info icon Follow-up polish on the migrated Team Info virtual keys table: widen the Key ID column by 20px (100 -> 120), nearly double Created By (70 -> 130) so the name and popover fit, and remove the Last Active header info icon (and its now unused InfoCircleOutlined import). --- .../components/team/TeamVirtualKeysTable.tsx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index 909608d630f..778b82a8fc5 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -5,7 +5,6 @@ import { DataTable, DataTablePagination, DataTableSortHeader } from "@/component import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; import { ColumnDef, PaginationState, SortingState } from "@tanstack/react-table"; import { Badge, Icon, Text } from "@tremor/react"; -import { InfoCircleOutlined } from "@ant-design/icons"; import { Popover, Tooltip, Typography } from "antd"; import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; import React, { useCallback, useEffect, useMemo, useState } from "react"; @@ -193,7 +192,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi id: "token", accessorKey: "token", header: ({ column }) => , - size: 100, + size: 120, enableSorting: true, cell: (info) => ( setSelectedKey(info.row.original)} /> @@ -283,7 +282,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi id: "created_by", accessorKey: "created_by", header: "Created By", - size: 70, + size: 130, enableSorting: false, cell: (info) => { const userId = info.getValue() as string | null; @@ -349,17 +348,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "last_active", accessorKey: "last_active", - header: () => ( - - Last Active - - - - - ), + header: "Last Active", size: 130, enableSorting: false, cell: (info) => , From 1612df18a6fdbc3a3f6cb2eabbdf105cd2c4b14e Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Jul 2026 17:19:21 -0700 Subject: [PATCH 005/188] refactor(ui): reuse exported DEFAULT_PAGE_SIZE_OPTIONS in DataTable Drop the duplicate local DEFAULT_PAGE_SIZE_OPTIONS in DataTable.tsx and import the one already exported from DataTablePagination.tsx, removing the divergence risk if the canonical list changes. --- .../src/components/shared/DataTable/DataTable.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx index 7655454f381..2e95ee170fa 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx @@ -36,11 +36,9 @@ import { import { cn } from "@/lib/cva.config"; import "./columnMeta"; -import { DataTablePagination } from "./DataTablePagination"; +import { DataTablePagination, DEFAULT_PAGE_SIZE_OPTIONS } from "./DataTablePagination"; import type { ColumnPinnedSide, DataTableProps, DataTableSize, PaginationMode, SortingMode } from "./types"; -const DEFAULT_PAGE_SIZE_OPTIONS = [25, 50, 100]; - const INTERACTIVE_SELECTOR = "button, a, input, select, textarea, [role=checkbox], [data-row-click-exempt]"; const noop = () => {}; From 3e1383f529b796fc557e7886f76c66c63f2427c7 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Jul 2026 17:45:05 -0700 Subject: [PATCH 006/188] fix(ui): reset page and sort correctly in Team virtual keys table Addresses three issues in the migrated Team Info virtual keys table, all pre-existing behavior carried over from the tremor version: - Changing the sort now resets to page 1. Previously handleSortingChange routed through handleFilterChange with skipDebounce=true, which skipped the pageIndex reset, so sorting while on a later page asked the server for that page of the newly sorted results (an arbitrary slice). - Reset Filters now restores the default sort. It previously reset the filter fields and page but never touched the sorting state that actually drives the query, so the sort indicator and server order persisted. - Removes the dead Sort By / Sort Order keys from the filters object; sort is derived solely from the sorting state, so those keys were written but never read. Sort now lives in one place. Adds regression tests for the page-reset-on-sort and sort-reset-on-filter-reset behaviors (both fail if either fix is reverted). --- ui/litellm-dashboard/eslint-metrics.json | 2 +- .../team/TeamVirtualKeysTable.test.tsx | 53 +++++++++++++++++++ .../components/team/TeamVirtualKeysTable.tsx | 38 ++++--------- 3 files changed, 64 insertions(+), 29 deletions(-) diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index 036af5bc789..0900584f5ce 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,7 +1,7 @@ { "@typescript-eslint/no-explicit-any": 1980, "complexity": 128, - "local/no-large-inline-object-arg": 511, + "local/no-large-inline-object-arg": 509, "local/no-long-condition-chain": 233, "max-depth": 59, "no-console": 15 diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx index 9eb0282580b..fd81aaaad99 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx @@ -236,6 +236,59 @@ describe("TeamVirtualKeysTable", () => { ); }); + it("resets to the first page when the sort changes", async () => { + const user = userEvent.setup(); + mockUseKeys.mockImplementation( + (page: number) => + ({ + data: { + keys: [createMockKey({ token: `sk-p${page}`, key_alias: `page${page}_key` })], + total_count: 100, + current_page: page, + total_pages: 2, + }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + }) as unknown as ReturnType, + ); + + renderWithProviders(); + + await user.click(await screen.findByTestId("pagination-next")); + await waitFor(() => expect(mockUseKeys).toHaveBeenLastCalledWith(2, 50, expect.anything())); + + await user.click(screen.getByTestId("sort-header-created_at")); + await waitFor(() => expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.anything())); + }); + + it("resets the sort order to the default when filters are reset", async () => { + const user = userEvent.setup(); + const result = { + data: { keys: [createMockKey()], total_count: 1, current_page: 1, total_pages: 1 }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as unknown as ReturnType; + mockUseKeys.mockReturnValue(result); + + renderWithProviders(); + + await user.click(await screen.findByTestId("sort-header-created_at")); + await waitFor(() => + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortOrder: "asc" })), + ); + + await user.click(screen.getByRole("button", { name: "Reset Filters" })); + await waitFor(() => + expect(mockUseKeys).toHaveBeenLastCalledWith( + 1, + 50, + expect.objectContaining({ sortBy: "created_at", sortOrder: "desc" }), + ), + ); + }); + it("should show Loading keys when isPending", async () => { mockUseKeys.mockReturnValue({ data: undefined, diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index 778b82a8fc5..73f524e13f8 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -27,10 +27,12 @@ interface TeamVirtualKeysTableProps { * TeamVirtualKeysTable – variant of VirtualKeysTable scoped to a single team. * Displays all virtual keys belonging to the team with same format and styling. */ +const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; + export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVirtualKeysTableProps) { const { accessToken } = useAuthorized(); const [selectedKey, setSelectedKey] = useState(null); - const [sorting, setSorting] = useState([{ id: "created_at", desc: true }]); + const [sorting, setSorting] = useState(DEFAULT_SORTING); const [tablePagination, setTablePagination] = useState({ pageIndex: 0, pageSize: 50, @@ -39,8 +41,6 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi "Organization ID": "", "Key Alias": "", "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", }); const sortBy = sorting.length > 0 ? sorting[0].id : "created_at"; @@ -116,18 +116,14 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi return () => window.removeEventListener("storage", handleStorageChange); }, [handleStorageChange]); - const handleFilterChange = useCallback((newFilters: Record, skipDebounce = false) => { + const handleFilterChange = useCallback((newFilters: Record) => { setFilters((prev) => ({ ...prev, "Organization ID": newFilters["Organization ID"] ?? prev["Organization ID"], "Key Alias": newFilters["Key Alias"] ?? prev["Key Alias"], "User ID": newFilters["User ID"] ?? prev["User ID"], - "Sort By": newFilters["Sort By"] ?? prev["Sort By"] ?? "created_at", - "Sort Order": newFilters["Sort Order"] ?? prev["Sort Order"] ?? "desc", })); - if (!skipDebounce) { - setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); - } + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); }, []); const handleFilterReset = useCallback(() => { @@ -135,9 +131,8 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi "Organization ID": "", "Key Alias": "", "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", }); + setSorting(DEFAULT_SORTING); setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); }, []); @@ -492,23 +487,10 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi [expandedAccordions], ); - const handleSortingChange = useCallback( - (updaterOrValue: React.SetStateAction) => { - const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; - setSorting(newSorting); - if (newSorting?.length > 0) { - const sortState = newSorting[0]; - handleFilterChange( - { - "Sort By": sortState.id, - "Sort Order": sortState.desc ? "desc" : "asc", - }, - true, - ); - } - }, - [sorting, handleFilterChange], - ); + const handleSortingChange = useCallback((updaterOrValue: React.SetStateAction) => { + setSorting(updaterOrValue); + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + }, []); return (
From 41a43d52832dd9f72aef85ee6080b8970a6eb7fa Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Fri, 10 Jul 2026 00:14:45 -0700 Subject: [PATCH 007/188] feat(mcp): add dcr_bridge column and plumbing for client-forwarded auth modes --- .../migration.sql | 2 + .../litellm_proxy_extras/schema.prisma | 1 + litellm/models/mcp_server.py | 1 + litellm/proxy/_experimental/mcp_server/db.py | 1 + .../mcp_server/mcp_server_manager.py | 22 +++ litellm/proxy/_types.py | 36 +++++ .../mcp_management_endpoints.py | 16 +++ litellm/proxy/schema.prisma | 1 + .../types/mcp_server/mcp_server_manager.py | 10 ++ schema.prisma | 1 + .../mcp_server/test_mcp_oauth_passthrough.py | 36 +++++ .../mcp_server/test_mcp_partial_update.py | 16 +++ .../mcp_server/test_mcp_server_manager.py | 60 ++++++++ .../test_mcp_management_endpoints.py | 132 ++++++++++++++++++ 14 files changed, 335 insertions(+) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260710000000_add_dcr_bridge_to_mcp_server_table/migration.sql diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260710000000_add_dcr_bridge_to_mcp_server_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260710000000_add_dcr_bridge_to_mcp_server_table/migration.sql new file mode 100644 index 00000000000..2cfabb9c02e --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260710000000_add_dcr_bridge_to_mcp_server_table/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "dcr_bridge" BOOLEAN; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 6d87102a3f3..fb4d8d0b5a3 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -339,6 +339,7 @@ model LiteLLM_MCPServerTable { available_on_public_internet Boolean @default(true) delegate_auth_to_upstream Boolean @default(false) oauth_passthrough Boolean @default(false) + dcr_bridge Boolean? is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? diff --git a/litellm/models/mcp_server.py b/litellm/models/mcp_server.py index d9757cf80f4..af2efa822b0 100644 --- a/litellm/models/mcp_server.py +++ b/litellm/models/mcp_server.py @@ -96,6 +96,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase): available_on_public_internet: bool = True delegate_auth_to_upstream: bool = False oauth_passthrough: bool = False + dcr_bridge: Optional[bool] = None is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index e4f8b0c331d..c7b32614c61 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -52,6 +52,7 @@ _AUTH_FLOW_SCOPED_FIELDS: frozenset = frozenset( "token_url", "registration_url", "oauth2_flow", + "dcr_bridge", "token_exchange_endpoint", "audience", "subject_token_type", diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 8dd9949e17b..b1a2b68fe34 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -1044,6 +1044,24 @@ class MCPServerManager: "browser sign-in, including delegate_auth_to_upstream)." ) + config_dcr_bridge = server_config.get("dcr_bridge", None) + if config_dcr_bridge is not None and not isinstance(config_dcr_bridge, bool): + raise ValueError( + f"Invalid config for MCP server '{server_name or server_id}': dcr_bridge " + f"must be a boolean (got {config_dcr_bridge!r})." + ) + if config_dcr_bridge and auth_type not in ( + MCPAuth.true_passthrough, + MCPAuth.oauth_delegate, + ): + raise ValueError( + f"Invalid config for MCP server '{server_name or server_id}': dcr_bridge is only " + f"supported for auth_type true_passthrough or oauth_delegate (got {auth_type!r}). " + "The DCR bridge serves gateway-hosted OAuth discovery for the client-forwarded " + "token modes; interactive oauth2 servers already run the gateway " + "authorization-code flow." + ) + new_server = MCPServer( server_id=server_id, name=name_for_prefix, @@ -1079,6 +1097,7 @@ class MCPServerManager: available_on_public_internet=bool(server_config.get("available_on_public_internet", True)), delegate_auth_to_upstream=bool(server_config.get("delegate_auth_to_upstream", False)), oauth_passthrough=bool(server_config.get("oauth_passthrough", False)), + dcr_bridge=config_dcr_bridge, # AWS SigV4 fields aws_access_key_id=server_config.get("aws_access_key_id", None), aws_secret_access_key=server_config.get("aws_secret_access_key", None), @@ -1454,6 +1473,7 @@ class MCPServerManager: available_on_public_internet=bool(getattr(mcp_server, "available_on_public_internet", True)), delegate_auth_to_upstream=bool(getattr(mcp_server, "delegate_auth_to_upstream", False)), oauth_passthrough=bool(getattr(mcp_server, "oauth_passthrough", False)), + dcr_bridge=getattr(mcp_server, "dcr_bridge", None), created_at=getattr(mcp_server, "created_at", None), updated_at=getattr(mcp_server, "updated_at", None), tool_name_to_display_name=_deserialize_json_dict(getattr(mcp_server, "tool_name_to_display_name", None)), @@ -4800,6 +4820,7 @@ class MCPServerManager: token_url=server.token_url, registration_url=server.registration_url, oauth2_flow=server.oauth2_flow, + dcr_bridge=server.dcr_bridge, token_exchange_endpoint=server.token_exchange_endpoint, audience=server.audience, subject_token_type=server.subject_token_type, @@ -4916,6 +4937,7 @@ class MCPServerManager: available_on_public_internet=server.available_on_public_internet, delegate_auth_to_upstream=server.delegate_auth_to_upstream, oauth_passthrough=getattr(server, "oauth_passthrough", False), + dcr_bridge=server.dcr_bridge, is_byok=server.is_byok, byok_description=server.byok_description, byok_api_key_help_url=server.byok_api_key_help_url, diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b6bef568637..43ddf302692 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -26,6 +26,7 @@ from litellm.types.llms.openai import ( ResponsesAPIResponse, ) from litellm.types.mcp import ( + MCPAuth, MCPAuthType, MCPCredentials, MCPTransport, @@ -1229,6 +1230,14 @@ from litellm.models.mcp_server import ( # noqa: E402 # MCP Proxy Request Types +def _dcr_bridge_auth_type_error(auth_type: object) -> ValueError: + return ValueError( + f"dcr_bridge is only supported for auth_type true_passthrough or oauth_delegate (got {auth_type!r}). " + "The DCR bridge serves gateway-hosted OAuth discovery for the client-forwarded token modes; " + "interactive oauth2 servers already run the gateway authorization-code flow." + ) + + class NewMCPServerRequest(LiteLLMPydanticObjectBase): server_id: Optional[str] = None server_name: Optional[str] = None @@ -1268,6 +1277,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase): available_on_public_internet: bool = True delegate_auth_to_upstream: bool = False oauth_passthrough: bool = False + dcr_bridge: Optional[bool] = None is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None @@ -1322,6 +1332,16 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase): """ return values + @model_validator(mode="before") + @classmethod + def validate_dcr_bridge_auth_type(cls, values): + if not isinstance(values, dict) or not values.get("dcr_bridge"): + return values + auth_type = values.get("auth_type") + if auth_type in (MCPAuth.true_passthrough, MCPAuth.oauth_delegate): + return values + raise _dcr_bridge_auth_type_error(auth_type) + class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): server_id: str @@ -1362,6 +1382,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): available_on_public_internet: bool = True delegate_auth_to_upstream: bool = False oauth_passthrough: bool = False + dcr_bridge: Optional[bool] = None is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None @@ -1391,6 +1412,21 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): raise ValueError("url or spec_path is required for HTTP/SSE transport") return values + @model_validator(mode="before") + @classmethod + def validate_dcr_bridge_auth_type(cls, values): + """Partial updates omit auth_type; that case is validated against the stored row by the + update endpoint, which can read the database. This validator covers payloads that carry + both fields.""" + if not isinstance(values, dict) or not values.get("dcr_bridge"): + return values + if "auth_type" not in values: + return values + auth_type = values.get("auth_type") + if auth_type in (MCPAuth.true_passthrough, MCPAuth.oauth_delegate): + return values + raise _dcr_bridge_auth_type_error(auth_type) + from litellm.models.mcp_server import ( # noqa: E402 LiteLLM_MCPServerTable as LiteLLM_MCPServerTable, diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 907a17d76d9..93c9422c035 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -2333,6 +2333,22 @@ if MCP_AVAILABLE: ) old_server_record = None + if payload.dcr_bridge and payload.auth_type is None: + stored_auth_type = old_server_record.auth_type if old_server_record else None + stored_auth_type_name = getattr(stored_auth_type, "value", stored_auth_type) + if stored_auth_type not in (MCPAuth.true_passthrough, MCPAuth.oauth_delegate): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": ( + "dcr_bridge is only supported for auth_type true_passthrough or " + f"oauth_delegate (stored auth_type: {stored_auth_type_name!r}). Include " + "the server's auth_type in the update payload or configure one of the " + "client-forwarded token modes first." + ) + }, + ) + # try to update the mcp server mcp_server_record_updated = await update_mcp_server( prisma_client, diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 6d87102a3f3..fb4d8d0b5a3 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -339,6 +339,7 @@ model LiteLLM_MCPServerTable { available_on_public_internet Boolean @default(true) delegate_auth_to_upstream Boolean @default(false) oauth_passthrough Boolean @default(false) + dcr_bridge Boolean? is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index f102ab5b7b9..bb0baba6cf4 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -103,6 +103,7 @@ class MCPServer(BaseModel): # ``Authorization`` for non-OAuth reasons (e.g. static bearer tokens). Must # be set explicitly to avoid regressing servers that did not opt in. oauth_passthrough: bool = False + dcr_bridge: Optional[bool] = None is_byok: bool = False byok_description: List[str] = [] byok_api_key_help_url: Optional[str] = None @@ -164,6 +165,15 @@ class MCPServer(BaseModel): JWT) but forwards the caller's separate upstream ``Authorization`` unchanged, minting nothing.""" return self.auth_type == MCPAuth.oauth_delegate + @property + def is_dcr_bridge(self) -> bool: + """True when this client-forwarded-token server serves the gateway-hosted DCR front door + (gateway-self protected-resource and authorization-server metadata plus the register, + authorize, and token relays) instead of relaying the upstream's own OAuth discovery + verbatim. ``dcr_bridge`` is rejected on every other auth type at create, update, and + config load, so the mode gate here only defends rows edited outside those paths.""" + return bool(self.dcr_bridge) and (self.is_true_passthrough or self.is_oauth_delegate) + @property def requires_per_user_auth(self) -> bool: """ diff --git a/schema.prisma b/schema.prisma index 6d87102a3f3..fb4d8d0b5a3 100644 --- a/schema.prisma +++ b/schema.prisma @@ -339,6 +339,7 @@ model LiteLLM_MCPServerTable { available_on_public_internet Boolean @default(true) delegate_auth_to_upstream Boolean @default(false) oauth_passthrough Boolean @default(false) + dcr_bridge Boolean? is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py index ef2a8318d7a..6de35ebc524 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py @@ -110,6 +110,42 @@ def test_is_oauth_passthrough_false_without_authorization_header(): assert server.is_oauth_passthrough is False +@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate]) +def test_is_dcr_bridge_true_for_flagged_client_forwarded_modes(auth_type): + server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=auth_type, + dcr_bridge=True, + ) + assert server.is_dcr_bridge is True + + +@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate]) +def test_is_dcr_bridge_false_when_flag_unset(auth_type): + server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=auth_type, + ) + assert server.dcr_bridge is None + assert server.is_dcr_bridge is False + + +@pytest.mark.parametrize("auth_type", [MCPAuth.oauth2, MCPAuth.none, MCPAuth.api_key, None]) +def test_is_dcr_bridge_false_for_non_client_forwarded_auth_types(auth_type): + server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=auth_type, + dcr_bridge=True, + ) + assert server.is_dcr_bridge is False + + def test_is_oauth_passthrough_false_without_extra_headers(): server = MCPServer( server_id="s1", diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_partial_update.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_partial_update.py index d341d8f7e3b..00d96f975bb 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_partial_update.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_partial_update.py @@ -201,6 +201,7 @@ async def test_auth_type_switch_clears_stale_flow_scoped_fields(): "token_url", "registration_url", "oauth2_flow", + "dcr_bridge", "token_exchange_endpoint", "audience", "subject_token_type", @@ -225,6 +226,20 @@ async def test_auth_type_switch_keeps_explicitly_provided_flow_fields(): assert data_dict["token_url"] is None +@pytest.mark.asyncio +async def test_auth_type_switch_to_client_forwarded_keeps_explicit_dcr_bridge(): + data = UpdateMCPServerRequest( + server_id="my-test-server", + auth_type="true_passthrough", + dcr_bridge=True, + ) + + data_dict = await _run_update_with_existing(data, existing_auth_type="oauth2") + + assert data_dict["dcr_bridge"] is True + assert data_dict["oauth2_flow"] is None + + @pytest.mark.asyncio async def test_auth_type_switch_back_to_oauth2_clears_token_exchange_fields(): """The reverse switch must not leave token-exchange settings behind to @@ -256,6 +271,7 @@ async def test_unchanged_auth_type_does_not_clear_flow_fields(): "token_url", "registration_url", "oauth2_flow", + "dcr_bridge", "token_exchange_endpoint", "audience", "subject_token_type", diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index a18e1ac2c44..d6ddb0131af 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -373,6 +373,66 @@ class TestMCPServerManager: server = next(iter(manager.config_mcp_servers.values())) assert server.oauth2_flow is None + def _client_forwarded_config(self, auth_type, **overrides): + base = { + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + "auth_type": auth_type, + } + base.update(overrides) + return {"bridgeserver": base} + + @pytest.mark.asyncio + async def test_load_servers_from_config_rejects_dcr_bridge_on_gateway_managed_auth_type(self): + manager = MCPServerManager() + + with ( + patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)), + pytest.raises(ValueError) as exc_info, + ): + await manager.load_servers_from_config( + self._oauth2_config(oauth2_flow="authorization_code", dcr_bridge=True) + ) + + assert "dcr_bridge is only supported" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_load_servers_from_config_rejects_non_boolean_dcr_bridge(self): + manager = MCPServerManager() + + with ( + patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)), + pytest.raises(ValueError) as exc_info, + ): + await manager.load_servers_from_config( + self._client_forwarded_config(MCPAuth.true_passthrough, dcr_bridge="yes") + ) + + assert "must be a boolean" in str(exc_info.value) + + @pytest.mark.asyncio + @pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate]) + async def test_load_servers_from_config_accepts_dcr_bridge_on_client_forwarded_modes(self, auth_type): + manager = MCPServerManager() + + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)): + await manager.load_servers_from_config(self._client_forwarded_config(auth_type, dcr_bridge=True)) + + server = next(iter(manager.config_mcp_servers.values())) + assert server.dcr_bridge is True + assert server.is_dcr_bridge is True + + @pytest.mark.asyncio + async def test_load_servers_from_config_dcr_bridge_defaults_off(self): + manager = MCPServerManager() + + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)): + await manager.load_servers_from_config(self._client_forwarded_config(MCPAuth.true_passthrough)) + + server = next(iter(manager.config_mcp_servers.values())) + assert server.dcr_bridge is None + assert server.is_dcr_bridge is False + @pytest.mark.asyncio async def test_load_servers_from_config_coerces_cost_string_to_float(self): """YAML 1.1 parses `7e-05` as a string; ingest must coerce it to float.""" diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index a02acc02502..a07abbed412 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -4900,6 +4900,138 @@ def test_oauth2_flow_defaults_to_none_when_omitted(): assert LiteLLM_MCPServerTable(server_id="srv-1", transport="http").oauth2_flow is None +def test_dcr_bridge_rejected_on_create_for_gateway_managed_auth_type(): + from pydantic import ValidationError + + from litellm.proxy._types import NewMCPServerRequest + + with pytest.raises(ValidationError) as exc: + NewMCPServerRequest( + server_name="bridge-server", + url="https://example.com/mcp", + transport="http", + auth_type="oauth2", + oauth2_flow="authorization_code", + dcr_bridge=True, + ) + assert "dcr_bridge is only supported" in str(exc.value) + + +def test_dcr_bridge_rejected_on_create_when_auth_type_omitted(): + from pydantic import ValidationError + + from litellm.proxy._types import NewMCPServerRequest + + with pytest.raises(ValidationError) as exc: + NewMCPServerRequest( + server_name="bridge-server", + url="https://example.com/mcp", + transport="http", + dcr_bridge=True, + ) + assert "dcr_bridge is only supported" in str(exc.value) + + +@pytest.mark.parametrize("auth_type", ["true_passthrough", "oauth_delegate"]) +def test_dcr_bridge_accepted_on_create_for_client_forwarded_modes(auth_type): + from litellm.proxy._experimental.mcp_server.db import _prepare_mcp_server_data + from litellm.proxy._types import NewMCPServerRequest + + payload = NewMCPServerRequest( + server_name="bridge-server", + url="https://example.com/mcp", + transport="http", + auth_type=auth_type, + dcr_bridge=True, + ) + data_dict = _prepare_mcp_server_data(payload) + assert data_dict["dcr_bridge"] is True + + +def test_dcr_bridge_update_rejected_when_payload_auth_type_not_client_forwarded(): + from pydantic import ValidationError + + from litellm.proxy._types import UpdateMCPServerRequest + + with pytest.raises(ValidationError) as exc: + UpdateMCPServerRequest(server_id="srv-1", auth_type="oauth2", dcr_bridge=True) + assert "dcr_bridge is only supported" in str(exc.value) + + +def test_dcr_bridge_update_without_auth_type_defers_to_endpoint(): + from litellm.proxy._types import UpdateMCPServerRequest + + assert UpdateMCPServerRequest(server_id="srv-1", dcr_bridge=True).dcr_bridge is True + + +def test_dcr_bridge_round_trips_on_response_model(): + from litellm.proxy._types import LiteLLM_MCPServerTable + + row = LiteLLM_MCPServerTable(server_id="srv-1", transport="http", dcr_bridge=True) + assert row.dcr_bridge is True + assert LiteLLM_MCPServerTable(server_id="srv-1", transport="http").dcr_bridge is None + + +def _edit_endpoint_patches(old_record, update_mock): + return ( + patch("litellm.proxy.management_endpoints.mcp_management_endpoints.MCP_AVAILABLE", True), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", + return_value=MagicMock(), + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server", + AsyncMock(side_effect=old_record) if isinstance(old_record, Exception) else AsyncMock(return_value=old_record), + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.update_mcp_server", + update_mock, + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.validate_and_normalize_mcp_server_payload", + autospec=True, + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stored_auth_type", ["oauth2", "api_key", "none"]) +async def test_edit_mcp_server_rejects_dcr_bridge_when_stored_auth_type_not_client_forwarded(stored_auth_type): + from litellm.proxy._types import UpdateMCPServerRequest + from litellm.proxy.management_endpoints.mcp_management_endpoints import edit_mcp_server + + old_record = MagicMock() + old_record.auth_type = stored_auth_type + update_mock = AsyncMock() + p1, p2, p3, p4, p5 = _edit_endpoint_patches(old_record, update_mock) + with p1, p2, p3, p4, p5: + payload = UpdateMCPServerRequest(server_id="srv-1", dcr_bridge=True) + user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + with pytest.raises(HTTPException) as exc: + await edit_mcp_server(payload=payload, user_api_key_dict=user_auth) + + assert exc.value.status_code == 400 + assert "dcr_bridge is only supported" in str(exc.value.detail) + update_mock.assert_not_called() + + +@pytest.mark.asyncio +async def test_edit_mcp_server_rejects_dcr_bridge_when_stored_record_unreadable(): + from litellm.proxy._types import UpdateMCPServerRequest + from litellm.proxy.management_endpoints.mcp_management_endpoints import edit_mcp_server + + update_mock = AsyncMock() + p1, p2, p3, p4, p5 = _edit_endpoint_patches(RuntimeError("db down"), update_mock) + with p1, p2, p3, p4, p5: + payload = UpdateMCPServerRequest(server_id="srv-1", dcr_bridge=True) + user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + with pytest.raises(HTTPException) as exc: + await edit_mcp_server(payload=payload, user_api_key_dict=user_auth) + + assert exc.value.status_code == 400 + update_mock.assert_not_called() + + class TestPerUserCredentialConfigServerResolution: """Per-user credential and env-var endpoints must resolve config-defined MCP servers, which live only in the in-memory registry and never get a DB row, so From 7e0af8fbbfb388f86c407789ceb0cdba584a53a7 Mon Sep 17 00:00:00 2001 From: Tin Date: Thu, 9 Jul 2026 23:28:23 -0700 Subject: [PATCH 008/188] fix(mcp): stop persisting the DCR client onto true_passthrough and oauth_delegate server rows --- .../mcp_server/discoverable_endpoints.py | 14 ++- .../mcp_server/test_discoverable_endpoints.py | 88 +++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 4fd47a97066..f746a42080a 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -471,7 +471,8 @@ def _raise_if_not_oauth2(mcp_server: MCPServer) -> None: through: the caller owns the upstream token, and this relayed flow is how a browser obtains one against the upstream IdP (the admin UI's browser-only Authorize uses it). The minted token is upstream-audienced and held by the caller; the gateway persists nothing for these - modes (DCR persistence is opt-in and never enabled on this path). + modes (``_persist_dcr_client_registration`` skips them unconditionally, so even the admin + Authorize path with ``persist_credentials`` enabled writes nothing to the server row). """ from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # circular import with mcp_server_manager at module load _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES, @@ -807,7 +808,7 @@ async def _reuse_persisted_dcr_client_if_available(mcp_server: MCPServer) -> boo return bool(mcp_server.client_id) -DcrRegistrationPersistenceResult = Literal["persisted", "reused", "failed"] +DcrRegistrationPersistenceResult = Literal["persisted", "reused", "skipped", "failed"] async def _persist_dcr_client_registration( @@ -821,7 +822,16 @@ async def _persist_dcr_client_registration( full re-authorization instead of a silent refresh. Mirrors the ``encrypt_credentials`` write that ``client_credentials`` and token exchange already use. Failures are logged, never raised: registration still returns to the caller even when persistence fails. + + The client-forwarded token modes (``true_passthrough`` / ``oauth_delegate``) are skipped + unconditionally: the caller holds the upstream token and the gateway must hold no OAuth + client identity for these servers. Persisting here would stamp ``oauth2_flow`` and a + ``client_id`` onto a server whose mode promises the gateway stores nothing, making a + fresh pass-through server read as gateway-authorized. """ + if mcp_server.is_true_passthrough or mcp_server.is_oauth_delegate: + return "skipped" + try: registration = _DcrClientRegistration.model_validate(registration_response) except ValidationError as exc: diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 9de342eabd7..c95e3bbc42a 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -665,6 +665,94 @@ async def test_register_client_persists_dcr_client_identity(): mock_update_server.assert_called_once() +async def _register_persistence_attempted_for_auth_type(auth_type: MCPAuth) -> bool: + """Run register_client_with_server with persist_credentials=True for a server of ``auth_type`` + and report whether the DCR result was persisted onto the server row. The client-forwarded token + modes must skip the persist even on the admin path: writing it stamps oauth2_flow and a + client_id onto a server whose contract is that the gateway stores nothing, which makes a fresh + pass-through server read as gateway-authorized. The upstream registration must still be relayed + to the browser either way, since the caller needs the minted client to run its own flow.""" + from fastapi import Request + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + register_client_with_server, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="pt_server", + name="pt_server", + server_name="pt_server", + alias="pt_server", + transport=MCPTransport.http, + auth_type=auth_type, + client_id=None, + client_secret=None, + authorization_url="https://provider.example/oauth/authorize", + token_url="https://provider.example/oauth/token", + registration_url="https://provider.example/oauth/register", + ) + + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://proxy.litellm.example/" + mock_request.headers = {} + + mock_response = MagicMock() + mock_response.json.return_value = { + "client_id": "generated-client", + "client_secret": "generated-secret", + "token_endpoint_auth_method": "none", + } + mock_response.raise_for_status = MagicMock() + mock_async_client = MagicMock() + mock_async_client.post = AsyncMock(return_value=mock_response) + + mock_update = AsyncMock(return_value=MagicMock()) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=mock_async_client, + ), + patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()), + patch("litellm.proxy._experimental.mcp_server.db.update_mcp_server", new=mock_update), + patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()), + ): + response = await register_client_with_server( + request=mock_request, + mcp_server=server, + client_name="Litellm Proxy", + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + token_endpoint_auth_method="none", + persist_credentials=True, + ) + + assert json.loads(response.body.decode("utf-8")) == mock_response.json.return_value + return mock_update.await_count > 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate]) +async def test_register_client_does_not_persist_for_client_forwarded_modes(auth_type): + """The admin Authorize path (persist_credentials=True) must not write the DCR client onto a + true_passthrough / oauth_delegate server row: the browser still receives the registration, but + the gateway keeps no OAuth client identity for these modes.""" + assert await _register_persistence_attempted_for_auth_type(auth_type) is False + + +@pytest.mark.asyncio +async def test_register_client_persist_discriminator_oauth2_persists(): + """Guard the no-persist assertion above against vacuity: the same helper run against a genuine + oauth2 server DOES persist, so a regression that silently disables persistence everywhere (or a + helper that never reaches the persist) fails here instead of passing both.""" + assert await _register_persistence_attempted_for_auth_type(MCPAuth.oauth2) is True + + @pytest.mark.asyncio async def test_register_client_does_not_clobber_token_url_when_absent(): """When the in-memory server has no token_url, the DCR persist must omit it from the From a786ba9005fb974e40f1b5c9075f7dc2429e4dc7 Mon Sep 17 00:00:00 2001 From: Tin Date: Thu, 9 Jul 2026 23:44:31 -0700 Subject: [PATCH 009/188] test(mcp): pin credential isolation across server entries sharing an upstream URL --- .../outbound_credentials/test_resolver.py | 23 +++++ .../mcp_server/test_discoverable_endpoints.py | 84 +++++++++++++++++++ .../mcp_server/test_oauth2_token_cache.py | 30 +++++++ 3 files changed, 137 insertions(+) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py index 64226eea821..c88027abcd4 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py @@ -149,6 +149,29 @@ async def test_authorization_code_isolates_by_subject(): assert isinstance(bob, Error) and bob.error.tag == "unauthorized" +@pytest.mark.asyncio +async def test_authorization_code_isolates_by_server_id_even_when_servers_share_a_url(): + """A token stored for one server must be invisible to a different server_id pointing at the + same upstream URL: credentials bind to the server entry they were authorized for, so a + recreated or duplicated server starts unauthorized instead of inheriting the old grant. Guards + against any future token lookup keyed on the resource URL instead of (user_id, server_id) -- + both the egress resolve and the has_user_token discovery check must agree.""" + shared_url = "https://upstream.example.com" + store = _FakeTokenStore({("alice", "server-a"): OAuthToken(access_token="at-alice")}) + provider = UpstreamCredentialProvider(oauth_token_store=store) + subject = Subject(tenant_id="", subject_id="alice") + spec_a = ServerSpec(server_id="server-a", resource=shared_url, config=AuthorizationCodeConfig()) + spec_b = ServerSpec(server_id="server-b", resource=shared_url, config=AuthorizationCodeConfig()) + + granted = await provider.resolve_credentials(subject, spec_a) + fresh = await provider.resolve_credentials(subject, spec_b) + + assert isinstance(granted, Ok) and _emitted(granted.ok)["Authorization"] == "Bearer at-alice" + assert isinstance(fresh, Error) and fresh.error.tag == "unauthorized" + assert await provider.has_user_token(subject, spec_a) is True + assert await provider.has_user_token(subject, spec_b) is False + + @pytest.mark.asyncio async def test_has_user_token_reflects_the_stored_token(): present = UpstreamCredentialProvider( diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index c95e3bbc42a..c8e871b3bc1 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -753,6 +753,90 @@ async def test_register_client_persist_discriminator_oauth2_persists(): assert await _register_persistence_attempted_for_auth_type(MCPAuth.oauth2) is True +@pytest.mark.asyncio +async def test_register_client_persists_only_to_its_own_row_when_another_server_shares_the_url(): + """A fresh server must mint and persist its OWN DCR client even when another server row with + the same upstream URL already holds one: both the reuse lookup and the persist are keyed by + server_id, never by URL, so OAuth client identity is not transferable between server entries. + If either side ever falls back to a URL match, this fails: the fresh server would skip the + upstream registration (adopting the sibling's client) or persist onto the wrong row.""" + from fastapi import Request + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + register_client_with_server, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + shared_url = "https://provider.example/mcp" + fresh_server = MCPServer( + server_id="server-b", + name="server-b", + server_name="server-b", + alias="server-b", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + url=shared_url, + client_id=None, + client_secret=None, + authorization_url="https://provider.example/oauth/authorize", + token_url="https://provider.example/oauth/token", + registration_url="https://provider.example/oauth/register", + ) + sibling_row_with_client = MagicMock(server_id="server-a", url=shared_url) + sibling_row_with_client.credentials = {"client_id": "client-a-do-not-adopt"} + own_row_without_client = MagicMock(server_id="server-b", url=shared_url) + own_row_without_client.credentials = {} + rows_by_server_id = {"server-a": sibling_row_with_client, "server-b": own_row_without_client} + + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://proxy.litellm.example/" + mock_request.headers = {} + + mock_response = MagicMock() + mock_response.json.return_value = {"client_id": "fresh-client-b", "token_endpoint_auth_method": "none"} + mock_response.raise_for_status = MagicMock() + mock_async_client = MagicMock() + mock_async_client.post = AsyncMock(return_value=mock_response) + + mock_update = AsyncMock(return_value=MagicMock()) + + async def _get_row(prisma_client, server_id): + return rows_by_server_id.get(server_id) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=mock_async_client, + ), + patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()), + patch("litellm.proxy._experimental.mcp_server.db.get_mcp_server", new=AsyncMock(side_effect=_get_row)), + patch("litellm.proxy._experimental.mcp_server.db.update_mcp_server", new=mock_update), + patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()), + ): + response = await register_client_with_server( + request=mock_request, + mcp_server=fresh_server, + client_name="Litellm Proxy", + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + token_endpoint_auth_method="none", + persist_credentials=True, + ) + + mock_async_client.post.assert_called_once() + body = json.loads(response.body.decode("utf-8")) + assert body["client_id"] == "fresh-client-b" + + mock_update.assert_called_once() + update_data = mock_update.call_args.kwargs["data"] + assert update_data.server_id == "server-b" + assert update_data.credentials["client_id"] == "fresh-client-b" + + @pytest.mark.asyncio async def test_register_client_does_not_clobber_token_url_when_absent(): """When the in-memory server has no token_url, the DCR persist must omit it from the diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py index a60dab9148d..7d2cf4442a5 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py @@ -92,6 +92,36 @@ async def test_token_cached_across_calls(): assert mock_client.post.call_count == 1 +@pytest.mark.asyncio +async def test_m2m_token_not_shared_across_server_ids_with_identical_config(): + """Two servers with byte-identical client_credentials config but different server_ids must not + share a cached M2M token: the cache is keyed by server_id, so a new server entry (even one + recreated with the same URL and credentials) mints its own token instead of inheriting the + sibling's. Guards against the cache key ever collapsing to the URL or the client config.""" + cache = MCPOAuth2TokenCache() + server_a = _server(server_id="srv-a") + server_b = _server(server_id="srv-b") + mock_client = AsyncMock() + mock_client.post.side_effect = [_token_response("tok-for-a"), _token_response("tok-for-b")] + + with ( + patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.get_async_httpx_client", + return_value=mock_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.mcp_oauth2_token_cache", + cache, + ), + ): + token_a = await resolve_mcp_auth(server_a) + token_b = await resolve_mcp_auth(server_b) + + assert token_a == "tok-for-a" + assert token_b == "tok-for-b" + assert mock_client.post.call_count == 2 + + @pytest.mark.asyncio async def test_per_request_header_beats_oauth2(): """An explicit mcp_auth_header takes priority over the OAuth2 token.""" From 5ec4c162eaf8ddda366ffd6d465ced91eea22b91 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Fri, 10 Jul 2026 00:29:17 -0700 Subject: [PATCH 010/188] fix(mcp): let a missing server 404 on a dcr_bridge enablement instead of a misleading 400 --- .../mcp_management_endpoints.py | 8 +++++++- .../test_mcp_management_endpoints.py | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 93c9422c035..cab2a51a8ca 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -2325,6 +2325,7 @@ if MCP_AVAILABLE: # warning instead of failing the edit, whose primary job is the update itself. try: old_server_record = await get_mcp_server(prisma_client, payload.server_id) + old_server_record_read_failed = False except Exception as exc: # noqa: BLE001 - advisory read; invalidation is best-effort end-to-end verbose_logger.warning( "MCP server %s: could not snapshot the pre-update record; skipping the stale-token check: %s", @@ -2332,8 +2333,13 @@ if MCP_AVAILABLE: exc, ) old_server_record = None + old_server_record_read_failed = True - if payload.dcr_bridge and payload.auth_type is None: + if ( + payload.dcr_bridge + and payload.auth_type is None + and (old_server_record is not None or old_server_record_read_failed) + ): stored_auth_type = old_server_record.auth_type if old_server_record else None stored_auth_type_name = getattr(stored_auth_type, "value", stored_auth_type) if stored_auth_type not in (MCPAuth.true_passthrough, MCPAuth.oauth_delegate): diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index a07abbed412..a669a277d2b 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -5032,6 +5032,26 @@ async def test_edit_mcp_server_rejects_dcr_bridge_when_stored_record_unreadable( update_mock.assert_not_called() +@pytest.mark.asyncio +async def test_edit_mcp_server_dcr_bridge_on_unknown_server_returns_404_not_400(): + """A dcr_bridge enablement targeting a server_id that does not exist must surface the accurate + 404 from the update path, not a misleading 400 about the stored auth_type: get_mcp_server + returns None for a missing row without raising, which is distinct from a failed read.""" + from litellm.proxy._types import UpdateMCPServerRequest + from litellm.proxy.management_endpoints.mcp_management_endpoints import edit_mcp_server + + update_mock = AsyncMock(return_value=None) + p1, p2, p3, p4, p5 = _edit_endpoint_patches(None, update_mock) + with p1, p2, p3, p4, p5: + payload = UpdateMCPServerRequest(server_id="does-not-exist", dcr_bridge=True) + user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + with pytest.raises(HTTPException) as exc: + await edit_mcp_server(payload=payload, user_api_key_dict=user_auth) + + assert exc.value.status_code == 404 + update_mock.assert_called_once() + + class TestPerUserCredentialConfigServerResolution: """Per-user credential and env-var endpoints must resolve config-defined MCP servers, which live only in the in-memory registry and never get a DB row, so From 1e8c2f724090079d5fc3ad12d6a2563710c985ea Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 9 Jul 2026 20:23:49 -0700 Subject: [PATCH 011/188] fix(mcp): fail closed and surface semantic filter context window errors Resolves LIT-4284 When the embedding model exceeded its context window, the MCP semantic tool filter silently passed all tools through and reported N->N success in the filter header; when the overflow happened while embedding tool descriptions at router build time, the hook was never registered at all and filtering was silently disabled Semantic filtering now fails closed on context window overflows: the request is rejected with HTTP 400 and a message that names the embedding model and advises switching to one with a larger context window or disabling the filter. Build time overflows are recorded on the filter so the hook still registers and blocks MCP tool requests with the same actionable error while leaving native-only requests untouched. The dashboard test panel renders the backend message in an error banner instead of a success state. OpenAI's embedding overflow message (maximum input length is N tokens) now maps to ContextWindowExceededError --- .../exception_mapping_utils.py | 3 + .../mcp_server/semantic_tool_filter.py | 47 ++++ .../proxy/hooks/mcp_semantic_filter/hook.py | 9 + .../test_exception_mapping_utils.py | 12 + .../mcp_server/test_semantic_tool_filter.py | 266 ++++++++++++++++++ .../MCPSemanticFilterSettings.tsx | 3 + .../MCPSemanticFilterTestPanel.test.tsx | 15 + .../MCPSemanticFilterTestPanel.tsx | 12 + .../semanticFilterTestUtils.test.ts | 32 +++ .../semanticFilterTestUtils.ts | 5 + 10 files changed, 404 insertions(+) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 9dc202c4717..fdab3d5b9d4 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -95,6 +95,9 @@ class ExceptionCheckers: if "current length is" in _error_str_lowercase and "while limit is" in _error_str_lowercase: return True + if "maximum input length is" in _error_str_lowercase and "tokens" in _error_str_lowercase: + return True + return False @staticmethod diff --git a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py index f24d5715e83..eb86206c566 100644 --- a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py +++ b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py @@ -7,6 +7,8 @@ Filters MCP tools semantically for /chat/completions and /responses endpoints. from typing import TYPE_CHECKING, Any, Dict, List, Optional from litellm._logging import verbose_logger +from litellm.exceptions import ContextWindowExceededError +from litellm.litellm_core_utils.exception_mapping_utils import ExceptionCheckers from litellm.proxy._experimental.mcp_server.utils import MCP_TOOL_PREFIX_SEPARATOR if TYPE_CHECKING: @@ -15,6 +17,33 @@ if TYPE_CHECKING: from litellm.router import Router +class SemanticToolFilterContextWindowError(Exception): + """Raised when the embedding model exceeds its context window, so semantic filtering cannot run.""" + + def __init__(self, embedding_model: str, stage: str, original_error: str): + self.embedding_model = embedding_model + self.stage = stage + self.original_error = original_error + super().__init__( + f"MCP semantic tool filtering could not run: embedding model '{embedding_model}' " + f"exceeded its context window while embedding {stage}. " + f"The request was blocked instead of silently passing all tools through. " + f"Switch to an embedding model with a larger context window, or disable " + f"semantic tool filtering. Original error: {original_error}" + ) + + +def _is_context_window_error(error: Optional[BaseException], depth: int = 5) -> bool: + """Detect a context-window overflow anywhere in an exception's cause chain.""" + if error is None or depth == 0: + return False + if isinstance(error, ContextWindowExceededError): + return True + if ExceptionCheckers.is_error_str_context_window_exceeded(str(error)): + return True + return _is_context_window_error(error.__cause__, depth - 1) + + class SemanticMCPToolFilter: """Filters MCP tools using semantic similarity to reduce context window size.""" @@ -42,6 +71,7 @@ class SemanticMCPToolFilter: self.embedding_model = embedding_model self.router_instance = litellm_router_instance self.tool_router: Optional["SemanticRouter"] = None + self.context_window_error: Optional[str] = None self._tool_map: Dict[str, Any] = {} # MCPTool objects or OpenAI function dicts async def build_router_from_mcp_registry(self) -> None: @@ -111,6 +141,7 @@ class SemanticMCPToolFilter: return try: + self.context_window_error = None # Convert tools to routes routes = [] self._tool_map = {} @@ -143,6 +174,9 @@ class SemanticMCPToolFilter: except Exception as e: verbose_logger.error(f"Failed to build semantic router: {e}") self.tool_router = None + if _is_context_window_error(e): + self.context_window_error = str(e) + return raise async def filter_tools( @@ -169,6 +203,13 @@ class SemanticMCPToolFilter: if not available_tools: return available_tools + if self.context_window_error is not None: + raise SemanticToolFilterContextWindowError( + embedding_model=self.embedding_model, + stage="the MCP tool descriptions during semantic router build", + original_error=self.context_window_error, + ) + if not query or not query.strip(): return available_tools @@ -189,6 +230,12 @@ class SemanticMCPToolFilter: return self._get_tools_by_names(matched_tool_names, available_tools) except Exception as e: + if _is_context_window_error(e): + raise SemanticToolFilterContextWindowError( + embedding_model=self.embedding_model, + stage="the user query", + original_error=str(e), + ) from e verbose_logger.error(f"Semantic tool filter failed: {e}", exc_info=True) return available_tools diff --git a/litellm/proxy/hooks/mcp_semantic_filter/hook.py b/litellm/proxy/hooks/mcp_semantic_filter/hook.py index 7379096bf9b..bad6ef44ccd 100644 --- a/litellm/proxy/hooks/mcp_semantic_filter/hook.py +++ b/litellm/proxy/hooks/mcp_semantic_filter/hook.py @@ -7,6 +7,8 @@ Reduces context window size and improves tool selection accuracy. from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from fastapi import HTTPException + from litellm._logging import verbose_proxy_logger from litellm.constants import ( DEFAULT_MCP_SEMANTIC_FILTER_EMBEDDING_MODEL, @@ -14,6 +16,9 @@ from litellm.constants import ( DEFAULT_MCP_SEMANTIC_FILTER_TOP_K, ) from litellm.integrations.custom_logger import CustomLogger +from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + SemanticToolFilterContextWindowError, +) if TYPE_CHECKING: from litellm.caching.caching import DualCache @@ -294,6 +299,8 @@ class SemanticToolFilterHook(CustomLogger): ) return data + except SemanticToolFilterContextWindowError as e: + raise HTTPException(status_code=400, detail={"error": str(e)}) from e except Exception as e: verbose_proxy_logger.error(f"Failed to expand MCP references: {e}", exc_info=True) return None @@ -366,6 +373,8 @@ class SemanticToolFilterHook(CustomLogger): return data + except SemanticToolFilterContextWindowError as e: + raise HTTPException(status_code=400, detail={"error": str(e)}) from e except Exception as e: verbose_proxy_logger.warning(f"Semantic tool filter hook failed: {e}. Proceeding with all tools.") return None diff --git a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py index f441270be7c..1fcee1b1c42 100644 --- a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py @@ -78,6 +78,18 @@ context_window_test_cases = [ "CerebrasException - Please reduce the length of the messages or completion. Current length is 50000 while limit is 40000", True, ), + ( + "Invalid 'input[0]': maximum input length is 8192 tokens.", + True, + ), + ( + "OpenAIException - Error code: 400 - {'error': {'message': \"Invalid 'input[0]': maximum input length is 8192 tokens.\", 'type': 'invalid_request_error'}}", + True, + ), + ( + "Invalid 'metadata': maximum input length is 512 characters.", + False, + ), # Negative cases (should return False) ("A generic API error occurred.", False), ("Invalid API Key provided.", False), diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py index 82c0aa3ccda..edf6921de1e 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py @@ -1356,3 +1356,269 @@ def test_truncate_csv_at_tool_name_boundary_edges(): assert _truncate_csv_at_tool_name_boundary(tool_names_csv="ab,cd,ef", max_length=5) == "ab,cd" assert _truncate_csv_at_tool_name_boundary(tool_names_csv="ab,cd,ef", max_length=4) == "ab" assert _truncate_csv_at_tool_name_boundary(tool_names_csv="single_name_longer_than_cap", max_length=10) == "" + + +def _make_context_window_raising_router(state): + """ + Mock litellm Router whose embedding call raises ContextWindowExceededError + once state["raise_context_error"] is flipped to True. + """ + import litellm + from litellm.types.utils import Embedding, EmbeddingResponse + + def mock_embedding_sync(*args, **kwargs): + if state["raise_context_error"]: + raise litellm.ContextWindowExceededError( + message="Invalid 'input[0]': maximum input length is 8192 tokens.", + model="text-embedding-3-small", + llm_provider="openai", + ) + return EmbeddingResponse( + data=[Embedding(embedding=[0.1] * 1536, index=0, object="embedding")], + model="text-embedding-3-small", + object="list", + usage={"prompt_tokens": 10, "total_tokens": 10}, + ) + + async def mock_embedding_async(*args, **kwargs): + return mock_embedding_sync(*args, **kwargs) + + mock_router = Mock() + mock_router.embedding = mock_embedding_sync + mock_router.aembedding = mock_embedding_async + return mock_router + + +def _make_context_window_filter(state, top_k: int = 3): + from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + SemanticMCPToolFilter, + ) + + return SemanticMCPToolFilter( + embedding_model="text-embedding-3-small", + litellm_router_instance=_make_context_window_raising_router(state), + top_k=top_k, + similarity_threshold=0.3, + enabled=True, + ) + + +@pytest.mark.asyncio +async def test_semantic_filter_fails_closed_on_query_time_context_window_error(): + """ + Regression test (LIT-4284): a context-window overflow while embedding the + user query must fail closed with a typed error instead of silently + returning all tools (previously reported as N->N "success"). + """ + from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + SemanticToolFilterContextWindowError, + ) + + state = {"raise_context_error": False} + filter_instance = _make_context_window_filter(state) + + tools = [ + MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) + for i in range(5) + ] + filter_instance._build_router(tools) + assert filter_instance.tool_router is not None + + state["raise_context_error"] = True + with pytest.raises(SemanticToolFilterContextWindowError) as exc_info: + await filter_instance.filter_tools(query="send an email", available_tools=tools) + + message = str(exc_info.value) + assert "context window" in message + assert "text-embedding-3-small" in message + print("✅ Query-time context window overflow fails closed") + + +@pytest.mark.asyncio +async def test_semantic_filter_records_build_time_context_window_error(): + """ + Regression test (LIT-4284): a context-window overflow while embedding the + tool descriptions at router-build time must be recorded (not raised out of + the build, which previously left the hook unregistered and filtering + silently disabled) and must fail subsequent filtering closed. + """ + from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + SemanticToolFilterContextWindowError, + ) + + state = {"raise_context_error": True} + filter_instance = _make_context_window_filter(state) + + tools = [ + MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) + for i in range(5) + ] + filter_instance._build_router(tools) + + assert filter_instance.tool_router is None + assert filter_instance.context_window_error is not None + + with pytest.raises(SemanticToolFilterContextWindowError) as exc_info: + await filter_instance.filter_tools(query="send an email", available_tools=tools) + + message = str(exc_info.value) + assert "context window" in message + assert "tool descriptions" in message + print("✅ Build-time context window overflow is recorded and fails closed") + + +@pytest.mark.asyncio +async def test_semantic_filter_hook_fails_closed_on_context_window_error(): + """ + Regression test (LIT-4284): the pre-call hook must reject the request with + an actionable HTTP 400 when the embedding model overflows its context + window, instead of forwarding all tools and emitting an N->N success + header. + """ + from fastapi import HTTPException + + from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook + + state = {"raise_context_error": False} + filter_instance = _make_context_window_filter(state) + + tools = [ + MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) + for i in range(5) + ] + filter_instance._build_router(tools) + hook = SemanticToolFilterHook(filter_instance) + + state["raise_context_error"] = True + data = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Send an email"}], + "tools": tools, + "metadata": {}, + } + + with pytest.raises(HTTPException) as exc_info: + await hook.async_pre_call_hook( + user_api_key_dict=Mock(), + cache=Mock(), + data=data, + call_type="completion", + ) + + assert exc_info.value.status_code == 400 + error_message = exc_info.value.detail["error"] + assert "context window" in error_message + assert "text-embedding-3-small" in error_message + assert "larger context window" in error_message + print("✅ Hook fails closed with actionable 400 on context window overflow") + + +@pytest.mark.asyncio +async def test_semantic_filter_hook_fails_closed_on_expanded_tools_context_window_error(): + """ + Regression test (LIT-4284): the litellm_proxy MCP expansion path (driven + by the dashboard test panel via /v1/responses) must also fail closed with + an actionable HTTP 400 instead of being swallowed by the expansion + catch-all. + """ + from fastapi import HTTPException + + from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook + + state = {"raise_context_error": False} + filter_instance = _make_context_window_filter(state) + + registry_tools = [ + MCPTool(name=f"srv-tool_{i}", description=f"Registry tool {i}", inputSchema={"type": "object"}) + for i in range(5) + ] + filter_instance._build_router(registry_tools) + + expanded_tools = [ + { + "type": "function", + "name": f"srv-tool_{i}", + "description": f"Registry tool {i}", + "parameters": {"type": "object", "properties": {}}, + } + for i in range(5) + ] + + hook = SemanticToolFilterHook(filter_instance) + hook._expand_mcp_tools = AsyncMock( # type: ignore[method-assign] + return_value=expanded_tools + ) + + state["raise_context_error"] = True + data = { + "model": "gpt-4", + "input": [{"role": "user", "content": "Send an email", "type": "message"}], + "tools": [ + { + "type": "mcp", + "server_url": "litellm_proxy", + "require_approval": "never", + } + ], + "metadata": {}, + } + + with pytest.raises(HTTPException) as exc_info: + await hook.async_pre_call_hook( + user_api_key_dict=Mock(), + cache=Mock(), + data=data, + call_type="aresponses", + ) + + assert exc_info.value.status_code == 400 + error_message = exc_info.value.detail["error"] + assert "context window" in error_message + assert "larger context window" in error_message + print("✅ Expansion path fails closed with actionable 400 on context window overflow") + + +@pytest.mark.asyncio +async def test_semantic_filter_hook_ignores_build_error_for_native_only_tools(): + """ + A recorded build-time context-window error must only block requests that + rely on MCP tool filtering; requests carrying only native tools pass + through untouched. + """ + from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook + + state = {"raise_context_error": True} + filter_instance = _make_context_window_filter(state) + + mcp_tools = [ + MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) + for i in range(3) + ] + filter_instance._build_router(mcp_tools) + assert filter_instance.context_window_error is not None + + hook = SemanticToolFilterHook(filter_instance) + + native_tools = [ + { + "type": "function", + "function": {"name": "local_fn", "description": "A local function", "parameters": {}}, + } + ] + data = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Send an email"}], + "tools": native_tools, + "metadata": {}, + } + + result = await hook.async_pre_call_hook( + user_api_key_dict=Mock(), + cache=Mock(), + data=data, + call_type="completion", + ) + + assert result is not None + assert result["tools"] == native_tools + print("✅ Native-only requests pass through despite recorded build error") diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx index 38b1420f97e..888ffa43261 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx @@ -46,6 +46,7 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi const [testQuery, setTestQuery] = useState(""); const [testModel, setTestModel] = useState("gpt-4o"); const [testResult, setTestResult] = useState(null); + const [testError, setTestError] = useState(null); const [isTesting, setIsTesting] = useState(false); const schema = data?.field_schema; @@ -113,6 +114,7 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi testQuery, setIsTesting, setTestResult, + setTestError, }); }; @@ -285,6 +287,7 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi onTest={handleTest} filterEnabled={!!values.enabled} testResult={testResult} + testError={testError} curlCommand={getCurlCommand(testModel, testQuery)} /> diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx index 280a28e4765..c35caffc4be 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx @@ -27,6 +27,7 @@ const buildProps = (overrides: Partial { expect(screen.queryByText("Results")).not.toBeInTheDocument(); }); + it("should render an error banner with the backend message when testError is set", () => { + const testError = + "MCP semantic tool filtering could not run: embedding model 'text-embedding-3-small' exceeded its context window while embedding the user query. Switch to an embedding model with a larger context window, or disable semantic tool filtering."; + render(); + + expect(screen.getByText("Semantic filtering did not run")).toBeInTheDocument(); + expect(screen.getByText(testError)).toBeInTheDocument(); + }); + + it("should not render the error banner when testError is null", () => { + render(); + expect(screen.queryByText("Semantic filtering did not run")).not.toBeInTheDocument(); + }); + it("should show the curl command in the API Usage tab", async () => { const user = userEvent.setup(); const curlCommand = "curl --location 'http://localhost:4000/v1/responses' --header 'Authorization: Bearer sk-1234'"; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx index 74850020aa8..21aded8733c 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx @@ -13,6 +13,7 @@ interface MCPSemanticFilterTestPanelProps { onTest: () => void; filterEnabled: boolean; testResult: TestResult | null; + testError: string | null; curlCommand: string; } @@ -26,6 +27,7 @@ export default function MCPSemanticFilterTestPanel({ onTest, filterEnabled, testResult, + testError, curlCommand, }: MCPSemanticFilterTestPanelProps) { return ( @@ -82,6 +84,16 @@ export default function MCPSemanticFilterTestPanel({ /> )} + {testError && ( + + )} + {testResult && (
Results diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.test.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.test.ts index abb18edb053..5880cca3f8f 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.test.ts +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.test.ts @@ -27,12 +27,14 @@ describe("getCurlCommand", () => { describe("runSemanticFilterTest", () => { const mockSetIsTesting = vi.fn(); const mockSetTestResult = vi.fn(); + const mockSetTestError = vi.fn(); const baseArgs = { accessToken: "test-token", testModel: "gpt-4o", testQuery: "find relevant files", setIsTesting: mockSetIsTesting, setTestResult: mockSetTestResult, + setTestError: mockSetTestError, }; beforeEach(() => { @@ -112,4 +114,34 @@ describe("runSemanticFilterTest", () => { expect(NotificationManager.error).toHaveBeenCalledWith("Failed to test semantic filter"); expect(mockSetIsTesting).toHaveBeenLastCalledWith(false); }); + + it("should surface the backend error message via setTestError when the API call fails", async () => { + const backendMessage = + "MCP semantic tool filtering could not run: embedding model 'text-embedding-3-small' exceeded its context window while embedding the user query."; + vi.mocked(testMCPSemanticFilter).mockRejectedValueOnce(new Error(backendMessage)); + + await runSemanticFilterTest(baseArgs); + + expect(mockSetTestError).toHaveBeenLastCalledWith(backendMessage); + }); + + it("should clear the previous test error before making a new request", async () => { + vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({ + data: {}, + headers: { filter: "5->2", tools: "tool-a,tool-b" }, + }); + + await runSemanticFilterTest(baseArgs); + + expect(mockSetTestError).toHaveBeenCalledTimes(1); + expect(mockSetTestError).toHaveBeenCalledWith(null); + }); + + it("should fall back to a generic message when the thrown error has no message", async () => { + vi.mocked(testMCPSemanticFilter).mockRejectedValueOnce(new Error("")); + + await runSemanticFilterTest(baseArgs); + + expect(mockSetTestError).toHaveBeenLastCalledWith("Failed to test semantic filter"); + }); }); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.ts index 97fc1465d56..065014e2869 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.ts +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.ts @@ -29,12 +29,14 @@ export const runSemanticFilterTest = async ({ testQuery, setIsTesting, setTestResult, + setTestError, }: { accessToken: string; testModel: string; testQuery: string; setIsTesting: (value: boolean) => void; setTestResult: (result: TestResult | null) => void; + setTestError: (error: string | null) => void; }) => { if (!testQuery || !testModel || !accessToken) { NotificationManager.error("Please enter a query and select a model"); @@ -43,6 +45,7 @@ export const runSemanticFilterTest = async ({ setIsTesting(true); setTestResult(null); + setTestError(null); try { const { headers } = await testMCPSemanticFilter(accessToken, testModel, testQuery); @@ -57,6 +60,8 @@ export const runSemanticFilterTest = async ({ NotificationManager.success("Semantic filter test completed successfully"); } catch (error) { console.error("Test failed:", error); + const message = error instanceof Error && error.message ? error.message : "Failed to test semantic filter"; + setTestError(message); NotificationManager.error("Failed to test semantic filter"); } finally { setIsTesting(false); From 898182b0e6e34111489f414cbdffb0343de25b38 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 9 Jul 2026 20:35:57 -0700 Subject: [PATCH 012/188] fix(mcp): redact provider error from client-facing semantic filter message Keep the full provider exception in server-side logs only; the client receives a fixed actionable message. Also follow implicit exception context when detecting context window overflows and pin the detection variants plus the redaction in tests --- .../mcp_server/semantic_tool_filter.py | 8 +++- .../mcp_server/test_semantic_tool_filter.py | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py index eb86206c566..0a73420e9d2 100644 --- a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py +++ b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py @@ -29,7 +29,7 @@ class SemanticToolFilterContextWindowError(Exception): f"exceeded its context window while embedding {stage}. " f"The request was blocked instead of silently passing all tools through. " f"Switch to an embedding model with a larger context window, or disable " - f"semantic tool filtering. Original error: {original_error}" + f"semantic tool filtering." ) @@ -41,7 +41,7 @@ def _is_context_window_error(error: Optional[BaseException], depth: int = 5) -> return True if ExceptionCheckers.is_error_str_context_window_exceeded(str(error)): return True - return _is_context_window_error(error.__cause__, depth - 1) + return _is_context_window_error(error.__cause__ or error.__context__, depth - 1) class SemanticMCPToolFilter: @@ -231,6 +231,10 @@ class SemanticMCPToolFilter: except Exception as e: if _is_context_window_error(e): + verbose_logger.error( + f"Semantic tool filter embedding exceeded its context window: {e}", + exc_info=True, + ) raise SemanticToolFilterContextWindowError( embedding_model=self.embedding_model, stage="the user query", diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py index edf6921de1e..9bc0a525326 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py @@ -1510,6 +1510,7 @@ async def test_semantic_filter_hook_fails_closed_on_context_window_error(): assert "context window" in error_message assert "text-embedding-3-small" in error_message assert "larger context window" in error_message + assert "maximum input length" not in error_message print("✅ Hook fails closed with actionable 400 on context window overflow") @@ -1575,6 +1576,7 @@ async def test_semantic_filter_hook_fails_closed_on_expanded_tools_context_windo error_message = exc_info.value.detail["error"] assert "context window" in error_message assert "larger context window" in error_message + assert "maximum input length" not in error_message print("✅ Expansion path fails closed with actionable 400 on context window overflow") @@ -1622,3 +1624,43 @@ async def test_semantic_filter_hook_ignores_build_error_for_native_only_tools(): assert result is not None assert result["tools"] == native_tools print("✅ Native-only requests pass through despite recorded build error") + + +def test_is_context_window_error_detection_variants(): + """ + _is_context_window_error must detect the overflow in every shape it + reaches filter_tools in: the raw typed exception, the encoder's + explicitly chained ValueError wrapper, an implicitly chained wrapper, + and a bare error whose message carries a known overflow phrase; a + generic error must not match. + """ + import litellm + from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + _is_context_window_error, + ) + + cwe = litellm.ContextWindowExceededError( + message="Invalid 'input[0]': maximum input length is 8192 tokens.", + model="text-embedding-3-small", + llm_provider="openai", + ) + assert _is_context_window_error(cwe) + + try: + raise ValueError("Internal_litellm_router API call failed") from cwe + except ValueError as explicitly_chained: + assert _is_context_window_error(explicitly_chained) + + try: + try: + raise litellm.ContextWindowExceededError( + message="overflow", model="m", llm_provider="openai" + ) + except litellm.ContextWindowExceededError: + raise ValueError("wrapper without explicit chaining") + except ValueError as implicitly_chained: + assert _is_context_window_error(implicitly_chained) + + assert _is_context_window_error(ValueError("Invalid 'input[0]': maximum input length is 8192 tokens.")) + assert not _is_context_window_error(ValueError("A generic API error occurred.")) + assert not _is_context_window_error(None) From 38efe9872010ab96add30d64fcca7891a5d89384 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 9 Jul 2026 23:06:47 -0700 Subject: [PATCH 013/188] refactor(mcp): make context window detection iterative for the recursion gate The code-quality recursive_detector CI step bans recursive functions under litellm/; walk the exception cause chain with a bounded loop instead --- .../mcp_server/semantic_tool_filter.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py index 0a73420e9d2..e12c6cdbd56 100644 --- a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py +++ b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py @@ -33,15 +33,18 @@ class SemanticToolFilterContextWindowError(Exception): ) -def _is_context_window_error(error: Optional[BaseException], depth: int = 5) -> bool: +def _is_context_window_error(error: Optional[BaseException], max_depth: int = 5) -> bool: """Detect a context-window overflow anywhere in an exception's cause chain.""" - if error is None or depth == 0: - return False - if isinstance(error, ContextWindowExceededError): - return True - if ExceptionCheckers.is_error_str_context_window_exceeded(str(error)): - return True - return _is_context_window_error(error.__cause__ or error.__context__, depth - 1) + current = error + for _ in range(max_depth): + if current is None: + return False + if isinstance(current, ContextWindowExceededError): + return True + if ExceptionCheckers.is_error_str_context_window_exceeded(str(current)): + return True + current = current.__cause__ or current.__context__ + return False class SemanticMCPToolFilter: From 931b617a51f775327cde2d2cbb75dc3ce0873e55 Mon Sep 17 00:00:00 2001 From: Tin Date: Fri, 10 Jul 2026 00:45:38 -0700 Subject: [PATCH 014/188] feat(mcp): persist admin-entered OAuth app credentials for the client-forwarded modes --- .../mcp_tools/PassthroughAuthorizeSection.tsx | 31 ++++---- .../mcp_tools/create_mcp_server.test.tsx | 71 ++++++++++++++++++- .../mcp_tools/create_mcp_server.tsx | 4 +- .../mcp_tools/mcp_server_edit.test.tsx | 46 ++++++++++++ .../components/mcp_tools/mcp_server_edit.tsx | 4 +- 5 files changed, 138 insertions(+), 18 deletions(-) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx index af81f2713ae..314b45fff6c 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx @@ -11,13 +11,14 @@ interface PassthroughOAuthFlow { /** * Browser-only Authorize & Fetch for the client-forwarded token modes - * (true_passthrough / oauth_delegate). LiteLLM never stores upstream - * credentials for these modes, so the token obtained here lives in this - * browser session only: it is forwarded per-server for the tools preview and - * allowlist configuration, and is never written to the server row or the - * per-user credential store. The optional client credentials cover IdPs - * without dynamic client registration (e.g. a pre-registered Slack app) and - * ride the temporary authorize session only. + * (true_passthrough / oauth_delegate). Tokens are never stored: the token + * obtained here lives in this browser session only, forwarded per-server for + * the tools preview and allowlist configuration, and is never written to the + * server row or the per-user credential store. The optional OAuth client + * credentials cover IdPs without dynamic client registration (e.g. a + * pre-registered Slack app); unlike the token they ARE saved onto the server + * as declared config, so internal users' Authorize relays through the org's + * app instead of dead-ending on upstreams that cannot mint clients. */ export default function PassthroughAuthorizeSection({ authType, @@ -35,14 +36,15 @@ export default function PassthroughAuthorizeSection({ return (

- Callers bring their own upstream token for this auth type, so LiteLLM stores no upstream credentials. To preview - tools and configure the tool allowlist, authorize against the upstream here: the token stays in this browser - session only and is never saved to LiteLLM. + Callers bring their own upstream token for this auth type, so LiteLLM never stores tokens. To preview tools and + configure the tool allowlist, authorize against the upstream here: the token stays in this browser session only + and is never saved to LiteLLM. An OAuth app configured below IS saved with the server, so internal users who + authorize from the Tools page go through it.

OAuth Client ID (optional, not saved)} + label={OAuth Client ID (optional, saved)} name={["credentials", "client_id"]} - extra="Only needed when the upstream does not support dynamic client registration (e.g. a pre-registered Slack app). Used for this browser authorization only." + extra="Set this to make everyone authorize through a specific app; required for upstreams without dynamic client registration (e.g. a pre-registered Slack app)." > OAuth Client Secret (optional, not saved)} + label={OAuth Client Secret (optional, saved)} name={["credentials", "client_secret"]} > {oauthFlow.error}

} {oauthFlow.status === "success" && oauthFlow.tokenResponse?.access_token && (

- Token held for this browser session. Tools can now be previewed and configured; nothing was saved to LiteLLM. + Token held for this browser session. Tools can now be previewed and configured; the token was not saved to + LiteLLM.

)}
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx index 02374a3ffa4..0a3128c6e7f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx @@ -202,8 +202,8 @@ describe("CreateMCPServer", () => { await waitFor(() => { expect(screen.getByRole("button", { name: "Authorize & Fetch Tools (browser-only)" })).toBeInTheDocument(); }); - expect(screen.getByText("OAuth Client ID (optional, not saved)")).toBeInTheDocument(); - expect(screen.getByText("OAuth Client Secret (optional, not saved)")).toBeInTheDocument(); + expect(screen.getByText("OAuth Client ID (optional, saved)")).toBeInTheDocument(); + expect(screen.getByText("OAuth Client Secret (optional, saved)")).toBeInTheDocument(); }, ); @@ -436,6 +436,73 @@ describe("CreateMCPServer", () => { ); }); + it.each([ + ["true_passthrough", "True Passthrough (no LiteLLM auth)"], + ["oauth_delegate", "OAuth Delegate (client-supplied upstream token)"], + ])( + "persists admin-entered OAuth app credentials on create for %s while the token stays browser-held", + async (_authType, optionLabel) => { + oauthHook.tokenResponse = { access_token: "upstream-tok", token_type: "Bearer" }; + await selectHttpTransport(); + + const user = userEvent.setup({ delay: null }); + await user.type(getServerNameInput(), "CF_App_Server"); + await user.type(screen.getByPlaceholderText("https://your-mcp-server.com"), "https://example.com/mcp"); + + await selectAntOption("Authentication", optionLabel); + + // Admin declares the org's pre-registered upstream app; unlike the browser-authorized + // token, this is config and must survive onto the server row so internal users' + // Tools-page Authorize relays through it (required for non-DCR upstreams like Slack). + await user.type( + screen.getByPlaceholderText("Leave blank to use dynamic client registration"), + "org-app-client-id", + ); + await user.type(screen.getByPlaceholderText("Leave blank for public clients / PKCE"), "org-app-secret"); + + await waitFor(() => expect(oauthHook.onTokenReceived).toBeTruthy()); + await act(async () => { + oauthHook.onTokenReceived!({ access_token: "upstream-tok", token_type: "Bearer" }, undefined); + }); + + const createdServer = { + server_id: "new-cf-app-server", + server_name: "CF_App_Server", + alias: "CF_App_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: _authType, + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }; + vi.mocked(networking.createMCPServer).mockResolvedValue(createdServer); + + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => expect(networking.createMCPServer).toHaveBeenCalledTimes(1)); + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + + // The declared app persists; the browser-authorized token still appears nowhere in the + // payload and no per-user DB credential is written. + expect(payload.credentials).toEqual({ + client_id: "org-app-client-id", + client_secret: "org-app-secret", + }); + expect(JSON.stringify(payload)).not.toContain("upstream-tok"); + expect(networking.storeMCPOAuthUserCredential).not.toHaveBeenCalled(); + expect(setToken).toHaveBeenCalledWith( + "new-cf-app-server", + expect.objectContaining({ access_token: "upstream-tok" }), + undefined, + ); + }, + ); + it("should not show auth value field when None auth type is selected", async () => { await selectHttpTransport(); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index eb48fd02474..79db031008e 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -60,6 +60,8 @@ const AUTH_TYPES_REQUIRING_CREDENTIALS = [ AUTH_TYPE.OAUTH2, AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE, AUTH_TYPE.AWS_SIGV4, + AUTH_TYPE.TRUE_PASSTHROUGH, + AUTH_TYPE.OAUTH_DELEGATE, ]; const CREATE_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-create-state"; @@ -209,7 +211,7 @@ const CreateMCPServer: React.FC = ({ // edit form's onTokenReceived early return. setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true))); NotificationsManager.success( - "Token held for this browser session. Tools can now be previewed and configured; nothing will be saved to LiteLLM.", + "Token held for this browser session. Tools can now be previewed and configured; the token is not saved to LiteLLM.", ); return; } diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx index adb3e161da5..a1bad0307b0 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx @@ -1,6 +1,7 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import MCPServerEdit from "./mcp_server_edit"; import * as networking from "../networking"; import NotificationsManager from "../molecules/notifications_manager"; @@ -1377,6 +1378,51 @@ describe("MCPServerEdit (OAuth token persistence on save)", () => { }, ); + it.each([["true_passthrough"], ["oauth_delegate"]])( + "persists admin-entered OAuth app credentials in the update payload for the %s mode", + async (authType) => { + mockOauth.tokenResponse = { access_token: "cf-tok", expires_in: 1800, token_type: "bearer" }; + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + auth_type: authType, + }); + + render( + , + ); + + const user = userEvent.setup({ delay: null }); + await user.type( + screen.getByPlaceholderText("Leave blank to use dynamic client registration"), + "org-app-client-id", + ); + await user.type(screen.getByPlaceholderText("Leave blank for public clients / PKCE"), "org-app-secret"); + + await act(async () => { + fireEvent.click(screen.getAllByRole("button", { name: "Save Changes" })[0]); + }); + + await waitFor(() => expect(networking.updateMCPServer).toHaveBeenCalledTimes(1)); + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + + // The declared app is config and persists onto the row; the browser-held token still never + // reaches the payload or the per-user credential store. + expect(payload.credentials).toMatchObject({ + client_id: "org-app-client-id", + client_secret: "org-app-secret", + }); + expect(JSON.stringify(payload)).not.toContain("cf-tok"); + expect(networking.storeMCPOAuthUserCredential).not.toHaveBeenCalled(); + }, + ); + it("forwards a newly authorized browser-held token for tool loading before the form is saved", async () => { // Regression: fetchTools keyed the browser-held decision off the saved mcpServer.auth_type, so // after switching the form to true_passthrough and authorizing, the fresh token was not sent as diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index 7446c96c40e..9b2c5af861f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -56,6 +56,8 @@ const AUTH_TYPES_REQUIRING_CREDENTIALS = [ AUTH_TYPE.OAUTH2, AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE, AUTH_TYPE.AWS_SIGV4, + AUTH_TYPE.TRUE_PASSTHROUGH, + AUTH_TYPE.OAUTH_DELEGATE, ]; export const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state"; @@ -202,7 +204,7 @@ const MCPServerEdit: React.FC = ({ }; setToken(mcpServer.server_id, browserHeldToken, userID); NotificationsManager.success( - "Token held for this browser session. Tools can now be loaded and configured; nothing was saved to LiteLLM.", + "Token held for this browser session. Tools can now be loaded and configured; the token is not saved to LiteLLM.", ); return; } From d2b7996170c16767b1f08d9327fd2f96c9422d78 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Fri, 10 Jul 2026 00:57:31 -0700 Subject: [PATCH 015/188] test(ui): pin deriveErrorMessage against the ProxyException wire shape Both automated reviewers assumed the semantic filter 400 reaches the browser as FastAPI's flat detail dict and would render as raw JSON in the test panel banner. The proxy converts a pre-call hook HTTPException into a ProxyException that serializes as {"error": {"message": ...}}, which deriveErrorMessage unpacks first; pin that contract with direct tests --- .../src/lib/http/client.test.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/lib/http/client.test.ts b/ui/litellm-dashboard/src/lib/http/client.test.ts index 6d50f99feca..e0b5a73d11a 100644 --- a/ui/litellm-dashboard/src/lib/http/client.test.ts +++ b/ui/litellm-dashboard/src/lib/http/client.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { createApiClient, ApiError } from "./client"; +import { createApiClient, ApiError, deriveErrorMessage } from "./client"; const okResponse = (data: unknown): Response => ({ ok: true, status: 200, text: async () => JSON.stringify(data) }) as unknown as Response; @@ -101,3 +101,22 @@ describe("createApiClient", () => { } }); }); + +describe("deriveErrorMessage", () => { + it("extracts error.message from a ProxyException body, the shape the proxy emits for a pre-call hook HTTPException", () => { + const actionable = + "MCP semantic tool filtering could not run: embedding model 'text-embedding-3-small' exceeded its context window while embedding the user query. The request was blocked instead of silently passing all tools through. Switch to an embedding model with a larger context window, or disable semantic tool filtering."; + const wireBody = { + error: { message: actionable, type: "None", param: "None", code: "400" }, + }; + expect(deriveErrorMessage(wireBody)).toBe(actionable); + }); + + it("returns error directly when it is a plain string", () => { + expect(deriveErrorMessage({ error: "flat error text" })).toBe("flat error text"); + }); + + it("falls back to a string detail field", () => { + expect(deriveErrorMessage({ detail: "detail text" })).toBe("detail text"); + }); +}); From 5a654c5c61a7c77bd176757f10461c0dbd9d841b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 10 Jul 2026 09:30:33 -0700 Subject: [PATCH 016/188] refactor(ui): full-height sidebar shell with content-scoped top bar Move the admin dashboard to a standard fixed-viewport shell. The sidebar is now full-height with its own scrolling nav (fixed logo header, pinned footer) and the top bar sits only over the content, so the page can no longer scroll past the end of the sidebar The brand, version, collapse toggle, and account menu move into the sidebar; the AI Gateway/Chat switch, docs/blog/community links, notifications, and worker switcher stay in the top bar. The sidebar is rebuilt on a new shadcn ui/sidebar primitive that uses the existing design-system tokens instead of the antd Menu This is a pure move-around of the sidebar, header, and content with no behavioral change intended. Chat keeps its own shell and navbar and is deliberately out of scope --- .../components/SidebarProvider.tsx | 9 +- .../src/app/(dashboard)/hooks/useLogout.ts | 19 + .../src/app/(dashboard)/layout.tsx | 44 +- .../src/components/DashboardHeader.tsx | 74 ++ .../Navbar/UserDropdown/UserDropdown.tsx | 74 +- .../src/components/SidebarUsageCard.tsx | 145 ++++ .../src/components/leftnav.test.tsx | 81 +- .../src/components/leftnav.tsx | 731 +++++++++--------- .../src/components/ui/sidebar.tsx | 203 +++++ 9 files changed, 926 insertions(+), 454 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts create mode 100644 ui/litellm-dashboard/src/components/DashboardHeader.tsx create mode 100644 ui/litellm-dashboard/src/components/SidebarUsageCard.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/sidebar.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx index b21759f136d..d14357b5026 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx @@ -9,9 +9,15 @@ interface SidebarProviderProps { setPage: (page: string) => void; defaultSelectedKey: string; sidebarCollapsed: boolean; + onToggleCollapsed?: () => void; } -const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: SidebarProviderProps) => { +const SidebarProvider = ({ + setPage, + defaultSelectedKey, + sidebarCollapsed, + onToggleCollapsed, +}: SidebarProviderProps) => { const { accessToken } = useAuthorized(); const [enabledPagesInternalUsers, setEnabledPagesInternalUsers] = useState(null); const [enableProjectsUI, setEnableProjectsUI] = useState(false); @@ -72,6 +78,7 @@ const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: Side setPage={setPage} defaultSelectedKey={defaultSelectedKey} collapsed={sidebarCollapsed} + onToggleCollapsed={onToggleCollapsed} enabledPagesInternalUsers={enabledPagesInternalUsers} enableProjectsUI={enableProjectsUI} enableChatUI={enableChatUI} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts new file mode 100644 index 00000000000..8da057ef9be --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts @@ -0,0 +1,19 @@ +import { clearTokenCookies } from "@/utils/cookieUtils"; +import { clearStoredReturnUrl } from "@/utils/returnUrlUtils"; +import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings"; + +/** + * Shared sign-out handler. Used by both the top navbar and the sidebar footer so + * the two entry points can never drift on which client state gets cleared. + */ +export function useLogout(accessToken: string | null): () => void { + const proxySettings = useProxySettings(accessToken); + + return () => { + clearTokenCookies(); + clearStoredReturnUrl(); + localStorage.removeItem("litellm_selected_worker_id"); + localStorage.removeItem("litellm_worker_url"); + window.location.href = proxySettings.PROXY_LOGOUT_URL || ""; + }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index b8eb4e66ed0..d2e8ea4e540 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -1,7 +1,7 @@ "use client"; import React, { Suspense, useState, useRef, useEffect } from "react"; -import Navbar from "@/components/navbar"; +import { DashboardHeader } from "@/components/DashboardHeader"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { ThemeProvider } from "@/contexts/ThemeContext"; import { useAuth } from "@/contexts/AuthContext"; @@ -102,34 +102,36 @@ function DashboardShell({ children }: { children: React.ReactNode }) { const { mode } = usePluginMode(); const page = legacyKeyForPathname(pathname) || searchParams.get("page") || "api-keys"; + const isGateway = mode === "ai-gateway"; const navigateToPage = (newPage: string) => { const migratedRoute = MIGRATED_PAGES[newPage]; router.push(migratedRoute ? migratedHref(migratedRoute) : legacyPageHref(newPage)); }; + // Standard app shell: the viewport is fixed height and never scrolls. The + // sidebar owns its own scroll and the content column scrolls independently, + // so the page can't be dragged past the end of the nav. return ( -
- setSidebarCollapsed((v) => !v)} - /> - - -
- {mode !== "ai-gateway" ? ( -
- -
+
+ {isGateway && ( + setSidebarCollapsed((v) => !v)} + /> + )} +
+ + + + {isGateway ? ( +
{children}
) : ( - <> -
- -
-
{children}
- +
+ +
)}
diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx new file mode 100644 index 00000000000..d765cdd45cb --- /dev/null +++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; +import { Separator } from "@/components/ui/separator"; +import { getBreadcrumb } from "@/components/leftnav"; +import { BlogDropdown } from "@/components/Navbar/BlogDropdown/BlogDropdown"; +import { CommunityEngagementButtons } from "@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons"; +import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell"; +import ViewSwitcher from "@/components/Navbar/ViewSwitcher"; +import WorkerDropdown from "@/components/Navbar/WorkerDropdown/WorkerDropdown"; +import { useWorker } from "@/hooks/useWorker"; +import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; +import { clearTokenCookies } from "@/utils/cookieUtils"; +import { clearStoredReturnUrl } from "@/utils/returnUrlUtils"; + +interface DashboardHeaderProps { + page: string; +} + +// Top bar for the dashboard shell. Sits only over the content column (the brand +// lives in the sidebar header); mirrors the design's breadcrumb-left / tools-right layout. +export function DashboardHeader({ page }: DashboardHeaderProps) { + const { section, title } = getBreadcrumb(page); + const { isControlPlane, selectedWorker } = useWorker(); + const showWorkerSwitch = isControlPlane && selectedWorker !== null; + const hideCommunityLinks = useDisableShowPrompts(); + + const handleWorkerSwitch = (workerId: string) => { + clearTokenCookies(); + clearStoredReturnUrl(); + localStorage.removeItem("litellm_selected_worker_id"); + localStorage.removeItem("litellm_worker_url"); + window.location.href = `/ui/login?worker=${encodeURIComponent(workerId)}`; + }; + + return ( +
+ + +
+ {showWorkerSwitch && ( + <> + + + + )} + + Docs + + + {!hideCommunityLinks && } + + + + +
+
+ ); +} + +export default DashboardHeader; diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index bd197f3ec8b..dba4c97dbe3 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -20,6 +20,8 @@ import { } from "@ant-design/icons"; import type { MenuProps } from "antd"; import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd"; +import { ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/cva.config"; import React, { useEffect, useState } from "react"; const { Text } = Typography; @@ -59,9 +61,14 @@ function initialsFromIdentity(email: string | null, userId: string | null): stri interface UserDropdownProps { onLogout: () => void; + // "navbar" (default): compact top-right trigger. "sidebar": full-width footer + // trigger whose menu opens upward, for the redesigned sidebar dock. + variant?: "navbar" | "sidebar"; + // Sidebar rail mode: render the avatar only (no name/role). + collapsed?: boolean; } -const UserDropdown: React.FC = ({ onLogout }) => { +const UserDropdown: React.FC = ({ onLogout, variant = "navbar", collapsed = false }) => { const { userId, userEmail, userRole, premiumUser } = useAuthorized(); const disableShowPrompts = useDisableShowPrompts(); const disableUsageIndicator = useDisableUsageIndicator(); @@ -219,6 +226,7 @@ const UserDropdown: React.FC = ({ onLogout }) => { return ( (
@@ -230,24 +238,54 @@ const UserDropdown: React.FC = ({ onLogout }) => {
)} > - + + {initials} + + {!collapsed && ( + <> + + {displayName} + {userRole && {userRole}} + + + + )} + + ) : ( + + )}
); }; diff --git a/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx b/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx new file mode 100644 index 00000000000..51bdf5de208 --- /dev/null +++ b/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx @@ -0,0 +1,145 @@ +import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator"; +import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo"; +import { getDaysUntilExpiration } from "@/utils/licenseUtils"; +import { cn } from "@/lib/cva.config"; +import { useQuery } from "@tanstack/react-query"; +import { Award, ChevronDown, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { getRemainingUsers } from "./networking"; + +interface SidebarUsageCardProps { + accessToken: string | null; + collapsed: boolean; + onExpandRail: () => void; +} + +interface Meter { + label: string; + used: number; + total: number; +} + +const formatExpiration = (daysRemaining: number | null): string => { + if (daysRemaining === null) return "No expiration"; + if (daysRemaining < 0) return "Expired"; + if (daysRemaining === 0) return "Expires today"; + if (daysRemaining === 1) return "1 day remaining"; + if (daysRemaining < 30) return `${daysRemaining} days remaining`; + if (daysRemaining < 60) return "1 month remaining"; + return `${Math.floor(daysRemaining / 30)} months remaining`; +}; + +const meterBarClass = (pct: number): string => { + if (pct > 100) return "bg-destructive"; + if (pct >= 90) return "bg-amber-500"; + return "bg-sidebar-primary"; +}; + +const Meter = ({ label, used, total }: Meter) => { + const pct = total > 0 ? (used / total) * 100 : 0; + return ( +
+
+ {label} + + {used.toLocaleString()} + / {total.toLocaleString()} + +
+
+
+
+
+ ); +}; + +type RemainingUsage = NonNullable>>; + +const remainingUsersQuery = (accessToken: string | null) => ({ + queryKey: ["sidebarRemainingUsers", accessToken] as const, + queryFn: () => getRemainingUsers(accessToken as string), + enabled: Boolean(accessToken), + retry: false as const, + staleTime: 5 * 60 * 1000, +}); + +const buildMeters = (data: RemainingUsage | null): Meter[] => { + if (!data) return []; + return [ + ...(data.total_users != null ? [{ label: "Seats", used: data.total_users_used, total: data.total_users }] : []), + ...(data.total_teams != null ? [{ label: "Teams", used: data.total_teams_used, total: data.total_teams }] : []), + ]; +}; + +/** + * Bottom-dock "Enterprise usage" card for the sidebar. Backed only by data + * LiteLLM actually exposes: seat (user) and team allocations from the license, + * plus the license expiry. There is no plan-level spend or request cap, so the + * design's Spend / API-request meters are intentionally omitted. + */ +export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail }: SidebarUsageCardProps) { + const disableUsageIndicator = useDisableUsageIndicator(); + const [open, setOpen] = useState(true); + const licenseInfo = useLicenseInfo(accessToken).data ?? null; + const { data: usageData, isLoading } = useQuery(remainingUsersQuery(accessToken)); + const data = usageData ?? null; + + const hasData = data !== null && (data.total_users !== null || data.total_teams !== null); + const noUsableData = !isLoading && !hasData; + if (disableUsageIndicator || !accessToken || noUsableData) { + return null; + } + + if (collapsed) { + return ( + + ); + } + + const daysUntilExpiration = licenseInfo?.expiration_date ? getDaysUntilExpiration(licenseInfo.expiration_date) : null; + const subtitle = licenseInfo?.expiration_date ? formatExpiration(daysUntilExpiration) : "Active plan"; + const meters = buildMeters(data); + + return ( +
+ + + {open && ( +
+ {isLoading && meters.length === 0 ? ( +
+ Loading… +
+ ) : ( + meters.map((m) => ) + )} +
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index dac4a35ebb6..96a895f15d7 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../tests/test-utils"; -import Sidebar from "./leftnav"; +import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav"; vi.mock("../utils/roles", () => { return { @@ -56,6 +56,23 @@ vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => { }; }); +// The redesigned sidebar reads the custom logo from ThemeContext; the test tree +// has no ThemeProvider, so stub the hook. +vi.mock("@/contexts/ThemeContext", () => ({ + useTheme: () => ({ logoUrl: null, faviconUrl: null, setLogoUrl: vi.fn(), setFaviconUrl: vi.fn() }), +})); + +// Version tag + logout target come from network hooks; keep them inert in unit tests. +vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({ + useHealthReadinessDetails: () => ({ data: undefined }), +})); +vi.mock("@/app/(dashboard)/hooks/useLogout", () => ({ + useLogout: () => vi.fn(), +})); + +const collectNavKeys = (): string[] => + menuGroups.flatMap((group) => group.items.flatMap((item) => [item.key, ...(item.children ?? []).map((c) => c.key)])); + describe("Sidebar (leftnav)", () => { const defaultProps = { setPage: vi.fn(), @@ -117,33 +134,11 @@ describe("Sidebar (leftnav)", () => { }); }); it("has no duplicate keys among all menu items and their children", () => { - // Helper to recursively extract all keys from Ant Design Menu items - function getAllKeysFromMenu(wrapper: HTMLElement): string[] { - const allKeys: string[] = []; - // Ant Design renders key as data-menu-id or inside attributes, but for this case, we look for text as fallback. - // For a generic check, here we fetch ids from rendered list items, and also descend into submenus - const items = wrapper.querySelectorAll("[data-menu-id]"); - items.forEach((item) => { - const dataMenuId = item.getAttribute("data-menu-id"); - if (dataMenuId) { - allKeys.push(dataMenuId); - } - }); - return allKeys; - } - - const { container } = renderWithProviders(); - const allRenderedKeys = getAllKeysFromMenu(container); - - const keySet = new Set(); - const duplicates: string[] = []; - for (const key of allRenderedKeys) { - if (keySet.has(key)) { - duplicates.push(key); - } - keySet.add(key); - } - expect(duplicates).toHaveLength(0); + // React keys must be unique across the whole nav config, otherwise the + // active-item highlight and group expansion collide. + const keys = collectNavKeys(); + const duplicates = keys.filter((key, i) => keys.indexOf(key) !== i); + expect(duplicates).toEqual([]); }); describe("Admin Viewer parity", () => { @@ -231,4 +226,34 @@ describe("Sidebar (leftnav)", () => { expect(screen.getByText("Organizations")).toBeInTheDocument(); }); + + it("marks the selected page's nav item active", () => { + renderWithProviders(); + const logs = screen.getByText("Logs").closest("a"); + expect(logs).toHaveAttribute("data-active", "true"); + // A different item must not be active. + expect(screen.getByText("Virtual Keys").closest("a")).not.toHaveAttribute("data-active"); + }); + + it("hides labels but keeps items when collapsed to the rail", () => { + const { container } = renderWithProviders(); + expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute("data-collapsed", "true"); + // Items still render (icons), so navigation is reachable in rail mode. + expect(screen.getByText("Virtual Keys")).toBeInTheDocument(); + }); +}); + +describe("getBreadcrumb", () => { + it("resolves a top-level page to its section + title", () => { + expect(getBreadcrumb("api-keys")).toEqual({ section: "AI Gateway", title: "Virtual Keys" }); + expect(getBreadcrumb("logs")).toEqual({ section: "Observability", title: "Logs" }); + }); + + it("resolves a nested child page to its parent section", () => { + expect(getBreadcrumb("search-tools")).toEqual({ section: "AI Gateway", title: "Search Tools" }); + }); + + it("falls back to a prettified title with no section for unknown pages", () => { + expect(getBreadcrumb("some-unknown-page")).toEqual({ section: null, title: "Some Unknown Page" }); + }); }); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 74b45942006..90bdca06595 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -1,38 +1,68 @@ import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; +import { useLogout } from "@/app/(dashboard)/hooks/useLogout"; +import { getProxyBaseUrl } from "@/components/networking"; +import { useTheme } from "@/contexts/ThemeContext"; +import { Button } from "@/components/ui/button"; import { - ApiOutlined, - ApartmentOutlined, - AppstoreOutlined, - AuditOutlined, - BankOutlined, - BarChartOutlined, - BgColorsOutlined, - BlockOutlined, - BookOutlined, - CommentOutlined, - CreditCardOutlined, - DatabaseOutlined, - ExperimentOutlined, - ExportOutlined, - FileTextOutlined, - FolderOutlined, - KeyOutlined, - LineChartOutlined, - PlayCircleOutlined, - RobotOutlined, - SafetyOutlined, - SearchOutlined, - SettingOutlined, - TagsOutlined, - TeamOutlined, - ToolOutlined, - UserOutlined, -} from "@ant-design/icons"; -import type { MenuProps } from "antd"; -import { ConfigProvider, Layout, Menu } from "antd"; -import { useMemo } from "react"; + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarSeparator, + sidebarMenuButtonVariants, +} from "@/components/ui/sidebar"; +import { + Activity, + BarChart3, + Bell, + Blocks, + Bot, + BookOpen, + Building2, + Boxes, + ChevronRight, + Code2, + Database, + ExternalLink, + FileText, + FlaskConical, + Folder, + HeartPulse, + KeyRound, + LayoutGrid, + MessageSquare, + Network, + Palette, + PanelLeftClose, + PanelLeftOpen, + PlayCircle, + Route, + ScrollText, + Search, + Server, + Settings as SettingsIcon, + Shield, + ShieldCheck, + Tags, + Terminal, + User, + Users, + Wallet, + Wrench, + Workflow, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { cn } from "@/lib/cva.config"; import { all_admin_roles, internalUserRoles, @@ -43,15 +73,17 @@ import { } from "../utils/roles"; import NewBadge from "./common_components/NewBadge"; import type { Organization } from "./networking"; -import UsageIndicator from "./UsageIndicator"; +import SidebarUsageCard from "./SidebarUsageCard"; +import UserDropdown from "./Navbar/UserDropdown/UserDropdown"; import { MIGRATED_PAGES, migratedHref, legacyPageHref } from "@/utils/migratedPages"; -const { Sider } = Layout; -// Define the props type +const ICON = { strokeWidth: 1.75 } as const; + interface SidebarProps { setPage: (page: string) => void; defaultSelectedKey: string; collapsed?: boolean; + onToggleCollapsed?: () => void; enabledPagesInternalUsers?: string[] | null; enableProjectsUI?: boolean; enableChatUI?: boolean; @@ -61,7 +93,6 @@ interface SidebarProps { allowVectorStoresForTeamAdmins?: boolean; } -// Menu item configuration interface MenuItem { key: string; page: string; @@ -72,29 +103,25 @@ interface MenuItem { external_url?: string; } -// Group configuration interface MenuGroup { groupLabel: string; items: MenuItem[]; roles?: string[]; } -// Menu groups organized by category - defined outside component for export +// Menu groups organized by category - defined outside component for export. +// Shape (key/page/label/roles/children) is consumed by page_utils.ts; only the +// icons changed to lucide as part of the sidebar redesign. const menuGroups: MenuGroup[] = [ { groupLabel: "AI GATEWAY", items: [ - { - key: "api-keys", - page: "api-keys", - label: "Virtual Keys", - icon: , - }, + { key: "api-keys", page: "api-keys", label: "Virtual Keys", icon: }, { key: "llm-playground", page: "llm-playground", label: "Playground", - icon: , + icon: , roles: rolesWithWriteAccess, }, { @@ -105,96 +132,51 @@ const menuGroups: MenuGroup[] = [ Chat ), - icon: , + icon: , }, { key: "models", page: "models", label: "Models + Endpoints", - icon: , - // Admin Viewer can view models read-only (write actions are - // hidden inside the page); Playground above stays write-only. + icon: , roles: rolesAllowedToViewWriteScopedPages, }, { key: "agentic", page: "agentic", label: "Agentic", - icon: , + icon: , children: [ { key: "agents", page: "agents", label: "Agents", - icon: , - // Admin Viewer can view agents read-only (write actions are - // hidden inside the page); Playground above stays write-only. + icon: , roles: rolesAllowedToViewWriteScopedPages, }, - { - key: "workflows", - page: "workflows", - label: "Workflow Runs", - icon: , - }, - { - key: "memory", - page: "memory", - label: "Memory", - icon: , - }, + { key: "workflows", page: "workflows", label: "Workflow Runs", icon: }, + { key: "memory", page: "memory", label: "Memory", icon: }, ], }, - { - key: "mcp-servers", - page: "mcp-servers", - label: "MCP Servers", - icon: , - }, - { - key: "skills", - page: "skills", - label: "Skills", - icon: , - roles: all_admin_roles, - }, - { - key: "guardrails", - page: "guardrails", - label: "Guardrails", - icon: , - }, + { key: "mcp-servers", page: "mcp-servers", label: "MCP Servers", icon: }, + { key: "skills", page: "skills", label: "Skills", icon: , roles: all_admin_roles }, + { key: "guardrails", page: "guardrails", label: "Guardrails", icon: }, { key: "policies", page: "policies", - label: Policies, - icon: , + label: "Policies", + icon: , roles: all_admin_roles, }, { key: "tools", page: "tools", label: "Tools", - icon: , + icon: , children: [ - { - key: "search-tools", - page: "search-tools", - label: "Search Tools", - icon: , - }, - { - key: "vector-stores", - page: "vector-stores", - label: "Vector Stores", - icon: , - }, - { - key: "tool-policies", - page: "tool-policies", - label: "Tool Policies", - icon: , - }, + { key: "search-tools", page: "search-tools", label: "Search Tools", icon: }, + { key: "vector-stores", page: "vector-stores", label: "Vector Stores", icon: }, + { key: "tool-policies", page: "tool-policies", label: "Tool Policies", icon: }, ], }, ], @@ -205,21 +187,16 @@ const menuGroups: MenuGroup[] = [ { key: "new_usage", page: "new_usage", - icon: , + icon: , roles: [...all_admin_roles, ...internalUserRoles], label: "Usage", }, - { - key: "logs", - page: "logs", - label: "Logs", - icon: , - }, + { key: "logs", page: "logs", label: "Logs", icon: }, { key: "guardrails-monitor", page: "guardrails-monitor", label: "Guardrails Monitor", - icon: , + icon: , roles: [...all_admin_roles, ...internalUserRoles], }, ], @@ -227,12 +204,7 @@ const menuGroups: MenuGroup[] = [ { groupLabel: "ACCESS CONTROL", items: [ - { - key: "teams", - page: "teams", - label: "Teams", - icon: , - }, + { key: "teams", page: "teams", label: "Teams", icon: }, { key: "projects", page: "projects", @@ -241,102 +213,62 @@ const menuGroups: MenuGroup[] = [ Projects ), - icon: , - roles: all_admin_roles, - }, - { - key: "users", - page: "users", - label: "Internal Users", - icon: , + icon: , roles: all_admin_roles, }, + { key: "users", page: "users", label: "Internal Users", icon: , roles: all_admin_roles }, { key: "organizations", page: "organizations", label: "Organizations", - icon: , + icon: , roles: all_admin_roles, }, { key: "access-groups", page: "access-groups", label: "Access Groups", - icon: , - roles: all_admin_roles, - }, - { - key: "budgets", - page: "budgets", - label: "Budgets", - icon: , + icon: , roles: all_admin_roles, }, + { key: "budgets", page: "budgets", label: "Budgets", icon: , roles: all_admin_roles }, ], }, { groupLabel: "DEVELOPER TOOLS", items: [ - { - key: "api_ref", - page: "api_ref", - label: "API Reference", - icon: , - }, - { - key: "model-hub-table", - page: "model-hub-table", - label: "AI Hub", - icon: , - }, - + { key: "api_ref", page: "api_ref", label: "API Reference", icon: }, + { key: "model-hub-table", page: "model-hub-table", label: "AI Hub", icon: }, { key: "learning-resources", page: "learning-resources", label: "Learning Resources", - icon: , + icon: , external_url: "https://models.litellm.ai/cookbook", }, { key: "experimental", page: "experimental", label: "Experimental", - icon: , + icon: , children: [ - { - key: "caching", - page: "caching", - label: "Caching", - icon: , - roles: all_admin_roles, - }, - { - key: "prompts", - page: "prompts", - label: "Prompts", - icon: , - roles: all_admin_roles, - }, + { key: "caching", page: "caching", label: "Caching", icon: , roles: all_admin_roles }, + { key: "prompts", page: "prompts", label: "Prompts", icon: , roles: all_admin_roles }, { key: "transform-request", page: "transform-request", label: "API Playground", - icon: , + icon: , roles: [...all_admin_roles, ...internalUserRoles], }, { key: "tag-management", page: "tag-management", label: "Tag Management", - icon: , + icon: , roles: all_admin_roles, }, - { - key: "4", - page: "usage", - label: "Old Usage", - icon: , - }, + { key: "4", page: "usage", label: "Old Usage", icon: }, ], }, ], @@ -353,21 +285,21 @@ const menuGroups: MenuGroup[] = [ Settings ), - icon: , + icon: , roles: all_admin_roles, children: [ { key: "router-settings", page: "router-settings", label: "Router Settings", - icon: , + icon: , roles: all_admin_roles, }, { key: "logging-and-alerts", page: "logging-and-alerts", label: "Logging & Alerts", - icon: , + icon: , roles: all_admin_roles, }, { @@ -381,33 +313,78 @@ const menuGroups: MenuGroup[] = [ ), - icon: , + icon: , roles: all_admin_roles, }, { key: "cost-tracking", page: "cost-tracking", label: "Cost Tracking", - icon: , - roles: all_admin_roles, - }, - { - key: "ui-theme", - page: "ui-theme", - label: "UI Theme", - icon: , + icon: , roles: all_admin_roles, }, + { key: "ui-theme", page: "ui-theme", label: "UI Theme", icon: , roles: all_admin_roles }, ], }, ], }, ]; -const Sidebar: React.FC = ({ +const findParentKey = (page: string): string | null => { + for (const group of menuGroups) { + for (const item of group.items) { + if (item.children?.some((c) => c.page === page || c.key === page)) return item.key; + } + } + return null; +}; + +const findMenuItemKey = (page: string): string => { + for (const group of menuGroups) { + for (const item of group.items) { + if (item.page === page) return item.key; + const child = item.children?.find((c) => c.page === page); + if (child) return child.key; + } + } + return "api-keys"; +}; + +const labelText = (item: MenuItem): string => (typeof item.label === "string" ? item.label : item.key); + +const SECTION_DISPLAY: Record = { + "AI GATEWAY": "AI Gateway", + OBSERVABILITY: "Observability", + "ACCESS CONTROL": "Access Control", + "DEVELOPER TOOLS": "Developer Tools", + SETTINGS: "Settings", +}; + +const prettify = (key: string): string => + key + .split(/[-_]/) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + +// Breadcrumb ("Section" / "Page") for the top bar, derived from the same nav config. +export const getBreadcrumb = (page: string): { section: string | null; title: string } => { + for (const group of menuGroups) { + for (const item of group.items) { + const section = SECTION_DISPLAY[group.groupLabel] ?? group.groupLabel; + if (item.page === page) + return { section, title: typeof item.label === "string" ? item.label : prettify(item.key) }; + const child = item.children?.find((c) => c.page === page); + if (child) return { section, title: typeof child.label === "string" ? child.label : prettify(child.key) }; + } + } + return { section: null, title: prettify(page) }; +}; + +const Sidebar_: React.FC = ({ setPage, defaultSelectedKey, collapsed = false, + onToggleCollapsed, enabledPagesInternalUsers, enableProjectsUI, enableChatUI, @@ -419,8 +396,31 @@ const Sidebar: React.FC = ({ const { userId, accessToken, userRole } = useAuthorized(); const { data: organizations } = useOrganizations(); const { data: teams } = useTeams(); + const { logoUrl } = useTheme(); + const { data: healthData } = useHealthReadinessDetails(accessToken); + const logout = useLogout(accessToken); + + const baseUrl = getProxyBaseUrl(); + const version = healthData?.litellm_version; + const selectedKey = findMenuItemKey(defaultSelectedKey); + + const [openGroups, setOpenGroups] = useState>(() => { + const parent = findParentKey(defaultSelectedKey); + return new Set(parent ? [parent] : []); + }); + + // Keep the active page's parent group expanded as the user navigates, using the + // "adjust state during render" pattern rather than an effect (avoids a + // setState-in-effect render cascade). + const [prevSelectedKey, setPrevSelectedKey] = useState(defaultSelectedKey); + if (defaultSelectedKey !== prevSelectedKey) { + setPrevSelectedKey(defaultSelectedKey); + const parent = findParentKey(defaultSelectedKey); + if (parent && !openGroups.has(parent)) { + setOpenGroups((prev) => new Set(prev).add(parent)); + } + } - // Check if user is an org_admin const isOrgAdmin = useMemo(() => { if (!userId || !organizations) return false; return organizations.some((org: Organization) => @@ -428,83 +428,21 @@ const Sidebar: React.FC = ({ ); }, [userId, organizations]); - // Check if user is a team admin for any team const isTeamAdmin = useMemo(() => isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""), [teams, userId]); - // The parent (legacy root page or dashboard layout) owns navigation for both - // migrated and legacy pages; the sidebar only reports the selected page. - const navigateToPage = (page: string) => setPage(page); - - // Wrap label in so every nav item supports right-click → "Open in new tab" - // and Ctrl/Cmd+click to open in a new tab, while preserving SPA navigation for normal clicks. - const renderNavLink = (label: React.ReactNode, page: string, externalUrl?: string): React.ReactNode => { - if (externalUrl) { - return ( - e.stopPropagation()} - style={{ color: "inherit", textDecoration: "none" }} - > - {label} - - ); - } - const migratedRoute = MIGRATED_PAGES[page]; - const href = migratedRoute ? migratedHref(migratedRoute) : legacyPageHref(page); - return ( - { - if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { - e.stopPropagation(); - return; - } - e.preventDefault(); - }} - style={{ color: "inherit", textDecoration: "none" }} - > - {label} - - ); - }; - - // Filter items based on user role and enabled pages for internal users const filterItemsByRole = (items: MenuItem[]): MenuItem[] => { const isAdmin = isAdminRole(userRole); - - // Debug logging - if (enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) { - } - return items - .map((item) => ({ - ...item, - children: item.children ? filterItemsByRole(item.children) : undefined, - })) + .map((item) => ({ ...item, children: item.children ? filterItemsByRole(item.children) : undefined })) .filter((item) => { - // Special handling for organizations and users menu items - allow org_admins if (item.key === "organizations" || item.key === "users") { const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin; if (!hasRoleAccess) return false; - - // Check enabled pages for internal users (non-admins) - if (!isAdmin && enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) { - const isIncluded = enabledPagesInternalUsers.includes(item.page); - return isIncluded; - } + if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page); return true; } - - // Hide Projects page if enableProjectsUI is not enabled if (item.key === "projects" && !enableProjectsUI) return false; - - // Hide Chat page if enableChatUI is not enabled if (item.key === "chat" && !enableChatUI) return false; - - // Hide agents and vector-stores pages for non-admin users when disabled, - // unless allow_*_for_team_admins is on and the user is a team admin. if ( !isAdmin && item.key === "agents" && @@ -519,160 +457,181 @@ const Sidebar: React.FC = ({ !(allowVectorStoresForTeamAdmins && isTeamAdmin) ) return false; - - // Existing role check if (item.roles && !item.roles.includes(userRole)) return false; - - // Check enabled pages for internal users (non-admins) - if (!isAdmin && enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) { - // If item has children, check if any children are visible + if (!isAdmin && enabledPagesInternalUsers != null) { if (item.children && item.children.length > 0) { const hasVisibleChildren = item.children.some((child) => enabledPagesInternalUsers.includes(child.page)); - if (hasVisibleChildren) { - return true; - } + if (hasVisibleChildren) return true; } - - const isIncluded = enabledPagesInternalUsers.includes(item.page); - return isIncluded; + return enabledPagesInternalUsers.includes(item.page); } - return true; }); }; - // Build menu items with groups - const buildMenuItems = (): MenuProps["items"] => { - const items: MenuProps["items"] = []; + const visibleGroups = menuGroups + .filter((group) => !group.roles || group.roles.includes(userRole)) + .map((group) => ({ groupLabel: group.groupLabel, items: filterItemsByRole(group.items) })) + .filter((group) => group.items.length > 0); - menuGroups.forEach((group) => { - // Check if group has role restriction - if (group.roles && !group.roles.includes(userRole)) { - return; - } - - const filteredItems = filterItemsByRole(group.items); - if (filteredItems.length === 0) return; - - // Add group with items - items.push({ - type: "group", - label: collapsed ? null : ( - - {group.groupLabel} - - ), - children: filteredItems.map((item) => ({ - key: item.key, - icon: item.icon, - label: renderNavLink(item.label, item.page, item.external_url), - children: item.children?.map((child) => ({ - key: child.key, - icon: child.icon, - label: renderNavLink(child.label, child.page, child.external_url), - onClick: () => { - if (child.external_url) { - window.open(child.external_url, "_blank"); - } else { - navigateToPage(child.page); - } - }, - })), - onClick: !item.children - ? () => { - if (item.external_url) { - window.open(item.external_url, "_blank"); - } else { - navigateToPage(item.page); - } - } - : undefined, - })), - }); - }); - - return items; - }; - - // Find selected menu key - const findMenuItemKey = (page: string): string => { - for (const group of menuGroups) { - for (const item of group.items) { - if (item.page === page) return item.key; - if (item.children) { - const child = item.children.find((c) => c.page === page); - if (child) return child.key; - } - } + const toggleGroup = (key: string) => { + if (collapsed) { + onToggleCollapsed?.(); + setOpenGroups((prev) => new Set(prev).add(key)); + return; } - return "api-keys"; + setOpenGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); }; - const selectedMenuKey = findMenuItemKey(defaultSelectedKey); + const handleLeafClick = (e: React.MouseEvent, item: MenuItem) => { + if (item.external_url) return; + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; + e.preventDefault(); + setPage(item.page); + }; + + const renderLeaf = (item: MenuItem, isChild: boolean) => { + const active = selectedKey === item.key; + const size = isChild ? "sub" : "default"; + const label = {item.label}; + + if (item.external_url) { + return ( + + {item.icon} + {label} + + + ); + } + + const href = MIGRATED_PAGES[item.page] ? migratedHref(MIGRATED_PAGES[item.page]) : legacyPageHref(item.page); + return ( + handleLeafClick(e, item)} + title={collapsed ? labelText(item) : undefined} + data-active={active || undefined} + className={cn(sidebarMenuButtonVariants({ isActive: active, size }))} + > + {item.icon} + {label} + + ); + }; + + const renderItem = (item: MenuItem) => { + const isGroup = !!item.children && item.children.length > 0; + if (!isGroup) { + return {renderLeaf(item, false)}; + } + + const active = selectedKey === item.key; + const open = openGroups.has(item.key); + return ( + + toggleGroup(item.key)} + title={collapsed ? labelText(item) : undefined} + > + {item.icon} + {item.label} + + + {open && ( + + {item.children!.map((child) => ( + {renderLeaf(child, true)} + ))} + + )} + + ); + }; + + const logoSrc = logoUrl || `${baseUrl}/get_image`; return ( - - - - + +
+
+ + LiteLLM + + {version && ( + + v{version} + + )} +
+ {onToggleCollapsed && ( + + )} +
+
+ + + {visibleGroups.map((group, gi) => ( + + {gi > 0 && } + {group.groupLabel} + {group.items.map((item) => renderItem(item))} + + ))} + + + + {isAdminRole(userRole) && ( + onToggleCollapsed?.()} /> - - {isAdminRole(userRole) && !collapsed && } - - + )} + + + ); }; -export default Sidebar; +export default Sidebar_; -// Also export menuGroups for advanced use cases export { menuGroups }; diff --git a/ui/litellm-dashboard/src/components/ui/sidebar.tsx b/ui/litellm-dashboard/src/components/ui/sidebar.tsx new file mode 100644 index 00000000000..5ac82788d87 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/sidebar.tsx @@ -0,0 +1,203 @@ +"use client"; + +import * as React from "react"; +import { type VariantProps } from "cva"; + +import { cn, cva } from "@/lib/cva.config"; + +type SidebarContextValue = { collapsed: boolean }; +const SidebarContext = React.createContext({ collapsed: false }); + +export function useSidebar(): SidebarContextValue { + return React.useContext(SidebarContext); +} + +const Sidebar = React.forwardRef & { collapsed?: boolean }>( + ({ className, collapsed = false, children, ...props }, ref) => ( + + + + ), +); +Sidebar.displayName = "Sidebar"; + +const SidebarHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +SidebarHeader.displayName = "SidebarHeader"; + +const SidebarContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +