From 0669d1b3cb7152c3ebc58618dd766a41705503c0 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 25 Jun 2026 17:31:13 -0700 Subject: [PATCH 001/110] =?UTF-8?q?bump:=20version=200.1.43=20=E2=86=92=20?= =?UTF-8?q?0.1.44?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- enterprise/pyproject.toml | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/enterprise/pyproject.toml b/enterprise/pyproject.toml index b032942427c..66f6aeb7abc 100644 --- a/enterprise/pyproject.toml +++ b/enterprise/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-enterprise" -version = "0.1.43" +version = "0.1.44" description = "Package for LiteLLM Enterprise features" readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.1.43" +version = "0.1.44" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-enterprise==", diff --git a/pyproject.toml b/pyproject.toml index 6a6a47f540e..6e99d81f8f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ proxy = [ "azure-storage-blob>=12.28.0,<13.0", "mcp>=1.26.0,<2.0", "litellm-proxy-extras==0.4.74", - "litellm-enterprise==0.1.43", + "litellm-enterprise==0.1.44", "RestrictedPython>=8.1,<9.0", "rich>=13.9.4,<14.0", "polars>=1.38.1,<2.0", From 2c8cd6ad4dc65371ac3b7f3dc58206c6120773ac Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 25 Jun 2026 17:35:56 -0700 Subject: [PATCH 002/110] uv lock --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 917dff39e38..da44ad25715 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-22T19:05:55.080417Z" +exclude-newer = "2026-06-23T00:31:52.495979Z" exclude-newer-span = "P3D" [manifest] @@ -3597,7 +3597,7 @@ proxy-dev = [ [[package]] name = "litellm-enterprise" -version = "0.1.43" +version = "0.1.44" source = { editable = "enterprise" } [[package]] From 4a6f0dbd8c4af8eb56d0ee6a60eaf7139d051931 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 26 Jun 2026 00:10:14 -0700 Subject: [PATCH 003/110] fix(ui): size Request Logs table columns so it scrolls instead of overflowing Tremor's Table forwards className to a wrapper div rather than the inner table element, so the table-fixed class never reached the table and it stayed table-layout: auto. Across 16 whitespace-nowrap columns that expanded the table far past the viewport Give each spend-logs column an explicit pixel size and drive the table width from getCenterTotalSize(), matching the Virtual Keys table. The shared DataTable applies this only when columns declare sizes, so the other consumers keep their existing fluid layout --- .../src/components/view_logs/columns.tsx | 16 +++++++ .../src/components/view_logs/table.test.tsx | 44 +++++++++++++++++++ .../src/components/view_logs/table.tsx | 15 ++++++- 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/view_logs/table.test.tsx diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index 1265b8449de..aeeb31f78f3 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -119,11 +119,13 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "Time", accessorKey: "startTime", + size: 200, cell: (info: any) => , }, { header: "Type", id: "type", + size: 90, cell: (info: any) => { const row = info.row.original; const sessionCount = row.session_total_count || 1; @@ -168,6 +170,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Status", accessorKey: "metadata.status", + size: 100, cell: (info: any) => { const status = info.getValue() || "Success"; const isSuccess = status.toLowerCase() !== "failure"; @@ -186,6 +189,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Session ID", accessorKey: "session_id", + size: 160, cell: (info: any) => { const value = String(info.getValue() || ""); const onSessionClick = info.row.original.onSessionClick; @@ -207,6 +211,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Request ID", accessorKey: "request_id", + size: 160, cell: (info: any) => ( {String(info.getValue() || "")} @@ -226,6 +231,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "Cost", accessorKey: "spend", + size: 110, cell: (info: any) => { const row = info.row.original; const mcpCount = row.mcp_tool_call_count || 0; @@ -258,6 +264,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "Duration (s)", accessorKey: "request_duration_ms", + size: 120, cell: (info: any) => { const ms = info.getValue(); if (ms == null) return -; @@ -282,6 +289,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "TTFT (s)", accessorKey: "completionStartTime", + size: 110, cell: (info: any) => { const row = info.row.original; const completionStartTime = info.getValue(); @@ -301,6 +309,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Team Name", accessorKey: "metadata.user_api_key_team_alias", + size: 150, cell: (info: any) => ( {String(info.getValue() || "-")} @@ -310,6 +319,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Key Hash", accessorKey: "metadata.user_api_key", + size: 160, cell: (info: any) => { const value = String(info.getValue() || "-"); const onKeyHashClick = info.row.original.onKeyHashClick; @@ -329,6 +339,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Key Alias", accessorKey: "metadata.user_api_key_alias", + size: 150, cell: (info: any) => ( {String(info.getValue() || "-")} @@ -348,6 +359,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "Model", accessorKey: "model", + size: 200, cell: (info: any) => { const row = info.row.original; const provider = row.custom_llm_provider; @@ -385,6 +397,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "Tokens", accessorKey: "total_tokens", + size: 140, cell: (info: any) => { const row = info.row.original; return ( @@ -400,6 +413,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Internal User", accessorKey: "user", + size: 150, cell: (info: any) => ( {String(info.getValue() || "-")} @@ -409,6 +423,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "End User", accessorKey: "end_user", + size: 140, cell: (info: any) => ( {String(info.getValue() || "-")} @@ -419,6 +434,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Tags", accessorKey: "request_tags", + size: 150, cell: (info: any) => { const tags = info.getValue(); if (!tags || Object.keys(tags).length === 0) return "-"; diff --git a/ui/litellm-dashboard/src/components/view_logs/table.test.tsx b/ui/litellm-dashboard/src/components/view_logs/table.test.tsx new file mode 100644 index 00000000000..f88e9bd75c8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/table.test.tsx @@ -0,0 +1,44 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { DataTable } from "./table"; + +type Row = { request_id: string; a: string; b: string }; + +const data: Row[] = [{ request_id: "r1", a: "alpha", b: "beta" }]; + +const sizedColumns: ColumnDef[] = [ + { header: "A", accessorKey: "a", size: 120 }, + { header: "B", accessorKey: "b", size: 80 }, +]; + +const unsizedColumns: ColumnDef[] = [ + { header: "A", accessorKey: "a" }, + { header: "B", accessorKey: "b" }, +]; + +describe("DataTable column sizing", () => { + it("widths the table and every cell from column sizes when columns declare them", () => { + render(); + + expect(screen.getByRole("table").style.width).toBe("200px"); + + const headers = screen.getAllByRole("columnheader"); + expect(headers.map((h) => h.style.width)).toEqual(["120px", "80px"]); + + const cells = screen.getAllByRole("cell"); + expect(cells.map((c) => c.style.width)).toEqual(["120px", "80px"]); + }); + + it("leaves cells unsized and keeps the fluid table when no column declares a size", () => { + render(); + + const table = screen.getByRole("table"); + expect(table.style.width).toBe(""); + expect(table.style.minWidth).toBe("400px"); + + for (const cell of [...screen.getAllByRole("columnheader"), ...screen.getAllByRole("cell")]) { + expect(cell.style.width).toBe(""); + } + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/table.tsx b/ui/litellm-dashboard/src/components/view_logs/table.tsx index 6aa349513d5..a47bf5a8e5c 100644 --- a/ui/litellm-dashboard/src/components/view_logs/table.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/table.tsx @@ -41,6 +41,7 @@ export function DataTable({ enableSorting = false, }: DataTableProps) { const supportsExpansion = !!(renderSubComponent || renderChildRows) && !!getRowCanExpand; + const hasExplicitColumnSizes = columns.some((column) => column.size !== undefined); const [sorting, setSorting] = useState([]); const table = useReactTable({ @@ -63,9 +64,14 @@ export function DataTable({ ...(supportsExpansion && { getExpandedRowModel: getExpandedRowModel() }), }); + const tableClassName = hasExplicitColumnSizes + ? "[&_td]:py-0.5 [&_th]:py-1 [&_table]:table-fixed" + : "[&_td]:py-0.5 [&_th]:py-1 table-fixed w-full box-border"; + const tableStyle = hasExplicitColumnSizes ? { width: table.getCenterTotalSize() } : { minWidth: "400px" }; + return (
- +
{table.getHeaderGroups().map((headerGroup) => ( @@ -77,6 +83,7 @@ export function DataTable({ {header.isPlaceholder ? null : ( @@ -112,7 +119,11 @@ export function DataTable({ onClick={() => onRowClick?.(row.original)} > {row.getVisibleCells().map((cell) => ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} From 014754be947e60cbefa1a3ecaae0a68a05a7443f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 26 Jun 2026 18:34:07 -0700 Subject: [PATCH 004/110] fix(ui): let dashboard main pane shrink so wide tables scroll instead of overflowing The Request Logs page pushed the whole page past the viewport horizontally. The cause was the app shell flex layout:
is a flex item, and flex items default to min-width: auto, so they refuse to shrink below their content's intrinsic width. The logs table is intrinsically ~2300px across its 16 nowrap columns, so main grew to that width and dragged the page with it; the table's own overflow-x-auto wrapper never got the chance to scroll Add min-w-0 to main so it can shrink to the available width, at which point the existing overflow-x-auto wrapper engages and the table scrolls inside its card. This applies to every dashboard page, not just logs Also drop the dead max-w-screen class on the logs container (not a real Tailwind utility, so it was a no-op), and revert the earlier column-sizing attempt which targeted table-layout rather than the actual containment problem --- .../src/app/(dashboard)/layout.tsx | 2 +- .../src/components/view_logs/columns.tsx | 16 ------- .../src/components/view_logs/index.tsx | 2 +- .../src/components/view_logs/table.test.tsx | 44 ------------------- .../src/components/view_logs/table.tsx | 15 +------ 5 files changed, 4 insertions(+), 75 deletions(-) delete mode 100644 ui/litellm-dashboard/src/components/view_logs/table.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index a5e83436888..09951dc1923 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -126,7 +126,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
-
{children}
+
{children}
)} diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index aeeb31f78f3..1265b8449de 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -119,13 +119,11 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "Time", accessorKey: "startTime", - size: 200, cell: (info: any) => , }, { header: "Type", id: "type", - size: 90, cell: (info: any) => { const row = info.row.original; const sessionCount = row.session_total_count || 1; @@ -170,7 +168,6 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Status", accessorKey: "metadata.status", - size: 100, cell: (info: any) => { const status = info.getValue() || "Success"; const isSuccess = status.toLowerCase() !== "failure"; @@ -189,7 +186,6 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Session ID", accessorKey: "session_id", - size: 160, cell: (info: any) => { const value = String(info.getValue() || ""); const onSessionClick = info.row.original.onSessionClick; @@ -211,7 +207,6 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Request ID", accessorKey: "request_id", - size: 160, cell: (info: any) => ( {String(info.getValue() || "")} @@ -231,7 +226,6 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "Cost", accessorKey: "spend", - size: 110, cell: (info: any) => { const row = info.row.original; const mcpCount = row.mcp_tool_call_count || 0; @@ -264,7 +258,6 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "Duration (s)", accessorKey: "request_duration_ms", - size: 120, cell: (info: any) => { const ms = info.getValue(); if (ms == null) return -; @@ -289,7 +282,6 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "TTFT (s)", accessorKey: "completionStartTime", - size: 110, cell: (info: any) => { const row = info.row.original; const completionStartTime = info.getValue(); @@ -309,7 +301,6 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Team Name", accessorKey: "metadata.user_api_key_team_alias", - size: 150, cell: (info: any) => ( {String(info.getValue() || "-")} @@ -319,7 +310,6 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Key Hash", accessorKey: "metadata.user_api_key", - size: 160, cell: (info: any) => { const value = String(info.getValue() || "-"); const onKeyHashClick = info.row.original.onKeyHashClick; @@ -339,7 +329,6 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Key Alias", accessorKey: "metadata.user_api_key_alias", - size: 150, cell: (info: any) => ( {String(info.getValue() || "-")} @@ -359,7 +348,6 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "Model", accessorKey: "model", - size: 200, cell: (info: any) => { const row = info.row.original; const provider = row.custom_llm_provider; @@ -397,7 +385,6 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "Tokens", accessorKey: "total_tokens", - size: 140, cell: (info: any) => { const row = info.row.original; return ( @@ -413,7 +400,6 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Internal User", accessorKey: "user", - size: 150, cell: (info: any) => ( {String(info.getValue() || "-")} @@ -423,7 +409,6 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "End User", accessorKey: "end_user", - size: 140, cell: (info: any) => ( {String(info.getValue() || "-")} @@ -434,7 +419,6 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Tags", accessorKey: "request_tags", - size: 150, cell: (info: any) => { const tags = info.getValue(); if (!tags || Object.keys(tags).length === 0) return "-"; diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 6c5fd03f0a0..cfe1bd6025a 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -234,7 +234,7 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p }; return ( -
+
setActiveTab(index === 0 ? "request logs" : "audit logs")}> Request Logs diff --git a/ui/litellm-dashboard/src/components/view_logs/table.test.tsx b/ui/litellm-dashboard/src/components/view_logs/table.test.tsx deleted file mode 100644 index f88e9bd75c8..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/table.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { ColumnDef } from "@tanstack/react-table"; -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { DataTable } from "./table"; - -type Row = { request_id: string; a: string; b: string }; - -const data: Row[] = [{ request_id: "r1", a: "alpha", b: "beta" }]; - -const sizedColumns: ColumnDef[] = [ - { header: "A", accessorKey: "a", size: 120 }, - { header: "B", accessorKey: "b", size: 80 }, -]; - -const unsizedColumns: ColumnDef[] = [ - { header: "A", accessorKey: "a" }, - { header: "B", accessorKey: "b" }, -]; - -describe("DataTable column sizing", () => { - it("widths the table and every cell from column sizes when columns declare them", () => { - render(); - - expect(screen.getByRole("table").style.width).toBe("200px"); - - const headers = screen.getAllByRole("columnheader"); - expect(headers.map((h) => h.style.width)).toEqual(["120px", "80px"]); - - const cells = screen.getAllByRole("cell"); - expect(cells.map((c) => c.style.width)).toEqual(["120px", "80px"]); - }); - - it("leaves cells unsized and keeps the fluid table when no column declares a size", () => { - render(); - - const table = screen.getByRole("table"); - expect(table.style.width).toBe(""); - expect(table.style.minWidth).toBe("400px"); - - for (const cell of [...screen.getAllByRole("columnheader"), ...screen.getAllByRole("cell")]) { - expect(cell.style.width).toBe(""); - } - }); -}); diff --git a/ui/litellm-dashboard/src/components/view_logs/table.tsx b/ui/litellm-dashboard/src/components/view_logs/table.tsx index a47bf5a8e5c..6aa349513d5 100644 --- a/ui/litellm-dashboard/src/components/view_logs/table.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/table.tsx @@ -41,7 +41,6 @@ export function DataTable({ enableSorting = false, }: DataTableProps) { const supportsExpansion = !!(renderSubComponent || renderChildRows) && !!getRowCanExpand; - const hasExplicitColumnSizes = columns.some((column) => column.size !== undefined); const [sorting, setSorting] = useState([]); const table = useReactTable({ @@ -64,14 +63,9 @@ export function DataTable({ ...(supportsExpansion && { getExpandedRowModel: getExpandedRowModel() }), }); - const tableClassName = hasExplicitColumnSizes - ? "[&_td]:py-0.5 [&_th]:py-1 [&_table]:table-fixed" - : "[&_td]:py-0.5 [&_th]:py-1 table-fixed w-full box-border"; - const tableStyle = hasExplicitColumnSizes ? { width: table.getCenterTotalSize() } : { minWidth: "400px" }; - return (
-
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -83,7 +77,6 @@ export function DataTable({ {header.isPlaceholder ? null : ( @@ -119,11 +112,7 @@ export function DataTable({ onClick={() => onRowClick?.(row.original)} > {row.getVisibleCells().map((cell) => ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} From 76be4461cad2dfe89fb2930d42eb99a65546d004 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 26 Jun 2026 19:05:33 -0700 Subject: [PATCH 005/110] feat(ui): give Request Logs columns explicit widths and tighten the dense ones Now that the page-overflow bug is fixed by letting the main pane shrink, bring back per-column sizing purely to control widths. Columns declare explicit pixel sizes and the table derives its min-width from getCenterTotalSize(), so it stretches to fill a wide card but scrolls once the columns no longer fit. The shared DataTable applies this only when columns declare sizes, leaving the other consumers on their existing fluid layout Trim the columns that were eating horizontal space without earning it: Request ID and Key Hash drop ~30% (Key Hash now narrower than Key Alias, which is the more useful of the two), and Duration and TTFT shrink to fit their short numeric values --- .../src/components/view_logs/columns.tsx | 16 +++++++ .../src/components/view_logs/table.test.tsx | 46 +++++++++++++++++++ .../src/components/view_logs/table.tsx | 15 +++++- 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/view_logs/table.test.tsx diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index 1265b8449de..310316d205e 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -119,11 +119,13 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "Time", accessorKey: "startTime", + size: 200, cell: (info: any) => , }, { header: "Type", id: "type", + size: 90, cell: (info: any) => { const row = info.row.original; const sessionCount = row.session_total_count || 1; @@ -168,6 +170,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Status", accessorKey: "metadata.status", + size: 100, cell: (info: any) => { const status = info.getValue() || "Success"; const isSuccess = status.toLowerCase() !== "failure"; @@ -186,6 +189,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Session ID", accessorKey: "session_id", + size: 160, cell: (info: any) => { const value = String(info.getValue() || ""); const onSessionClick = info.row.original.onSessionClick; @@ -207,6 +211,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Request ID", accessorKey: "request_id", + size: 110, cell: (info: any) => ( {String(info.getValue() || "")} @@ -226,6 +231,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "Cost", accessorKey: "spend", + size: 110, cell: (info: any) => { const row = info.row.original; const mcpCount = row.mcp_tool_call_count || 0; @@ -258,6 +264,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "Duration (s)", accessorKey: "request_duration_ms", + size: 90, cell: (info: any) => { const ms = info.getValue(); if (ms == null) return -; @@ -282,6 +289,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "TTFT (s)", accessorKey: "completionStartTime", + size: 80, cell: (info: any) => { const row = info.row.original; const completionStartTime = info.getValue(); @@ -301,6 +309,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Team Name", accessorKey: "metadata.user_api_key_team_alias", + size: 150, cell: (info: any) => ( {String(info.getValue() || "-")} @@ -310,6 +319,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Key Hash", accessorKey: "metadata.user_api_key", + size: 110, cell: (info: any) => { const value = String(info.getValue() || "-"); const onKeyHashClick = info.row.original.onKeyHashClick; @@ -329,6 +339,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Key Alias", accessorKey: "metadata.user_api_key_alias", + size: 150, cell: (info: any) => ( {String(info.getValue() || "-")} @@ -348,6 +359,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "Model", accessorKey: "model", + size: 200, cell: (info: any) => { const row = info.row.original; const provider = row.custom_llm_provider; @@ -385,6 +397,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] ) : "Tokens", accessorKey: "total_tokens", + size: 140, cell: (info: any) => { const row = info.row.original; return ( @@ -400,6 +413,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Internal User", accessorKey: "user", + size: 150, cell: (info: any) => ( {String(info.getValue() || "-")} @@ -409,6 +423,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "End User", accessorKey: "end_user", + size: 140, cell: (info: any) => ( {String(info.getValue() || "-")} @@ -419,6 +434,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] { header: "Tags", accessorKey: "request_tags", + size: 150, cell: (info: any) => { const tags = info.getValue(); if (!tags || Object.keys(tags).length === 0) return "-"; diff --git a/ui/litellm-dashboard/src/components/view_logs/table.test.tsx b/ui/litellm-dashboard/src/components/view_logs/table.test.tsx new file mode 100644 index 00000000000..da9bcef1455 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/table.test.tsx @@ -0,0 +1,46 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { DataTable } from "./table"; + +type Row = { request_id: string; a: string; b: string }; + +const data: Row[] = [{ request_id: "r1", a: "alpha", b: "beta" }]; + +const sizedColumns: ColumnDef[] = [ + { header: "A", accessorKey: "a", size: 120 }, + { header: "B", accessorKey: "b", size: 80 }, +]; + +const unsizedColumns: ColumnDef[] = [ + { header: "A", accessorKey: "a" }, + { header: "B", accessorKey: "b" }, +]; + +describe("DataTable column sizing", () => { + it("min-widths the table to the column total and sizes every cell when columns declare sizes", () => { + render(); + + const table = screen.getByRole("table"); + expect(table.style.minWidth).toBe("200px"); + expect(table.style.width).toBe(""); + + const headers = screen.getAllByRole("columnheader"); + expect(headers.map((h) => h.style.width)).toEqual(["120px", "80px"]); + + const cells = screen.getAllByRole("cell"); + expect(cells.map((c) => c.style.width)).toEqual(["120px", "80px"]); + }); + + it("leaves cells unsized and keeps the fluid table when no column declares a size", () => { + render(); + + const table = screen.getByRole("table"); + expect(table.style.width).toBe(""); + expect(table.style.minWidth).toBe("400px"); + + for (const cell of [...screen.getAllByRole("columnheader"), ...screen.getAllByRole("cell")]) { + expect(cell.style.width).toBe(""); + } + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/table.tsx b/ui/litellm-dashboard/src/components/view_logs/table.tsx index 6aa349513d5..4510cc9a1f0 100644 --- a/ui/litellm-dashboard/src/components/view_logs/table.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/table.tsx @@ -41,6 +41,7 @@ export function DataTable({ enableSorting = false, }: DataTableProps) { const supportsExpansion = !!(renderSubComponent || renderChildRows) && !!getRowCanExpand; + const hasExplicitColumnSizes = columns.some((column) => column.size !== undefined); const [sorting, setSorting] = useState([]); const table = useReactTable({ @@ -63,9 +64,14 @@ export function DataTable({ ...(supportsExpansion && { getExpandedRowModel: getExpandedRowModel() }), }); + const tableClassName = hasExplicitColumnSizes + ? "[&_td]:py-0.5 [&_th]:py-1 [&_table]:table-fixed" + : "[&_td]:py-0.5 [&_th]:py-1 table-fixed w-full box-border"; + const tableStyle = hasExplicitColumnSizes ? { minWidth: table.getCenterTotalSize() } : { minWidth: "400px" }; + return (
-
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -77,6 +83,7 @@ export function DataTable({ {header.isPlaceholder ? null : ( @@ -112,7 +119,11 @@ export function DataTable({ onClick={() => onRowClick?.(row.original)} > {row.getVisibleCells().map((cell) => ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} From 2a5790fe55d1f73846b43ec4bce4c0fd84261f17 Mon Sep 17 00:00:00 2001 From: yucheng-berriai Date: Fri, 26 Jun 2026 12:53:07 -0700 Subject: [PATCH 006/110] fix(proxy): reject team-scoped object_permission on personal keys for non-admins Non-admin callers could create or update a personal key (no team_id) with arbitrary access_group_ids, mcp_toolsets, vector_stores, or search_tools in object_permission. The server persisted the values without ownership validation; runtime authorization then trusted the IDs because they were stored on the key, allowing cross-tenant access to other teams' restricted models, MCP toolsets, and vector stores. The personal-key gate now mirrors the team-key path. enforce_member_can_assign_access_groups raises 403 for non-admin teamless callers. validate_key_mcp_servers_against_team rejects non-empty mcp_toolsets on personal non-admin keys. A new validate_key_vector_stores_against_team enforces the same rule for vector_stores. validate_key_search_tools_against_team gains the same gate for search_tools. The four validators are wired into /key/generate, /key/update, and /key/regenerate. Proxy admins keep their existing carve-out across all fields; team keys are unaffected. Endpoint-level regression coverage lives in tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py (six new parametrised cases through generate_key_fn and _validate_update_key_data) and helper-level coverage in tests/test_litellm/proxy/management_helpers/. Deleting any of the validator calls in _common_key_generation_helper or unmoving the enforce gate in _validate_update_key_data breaks the suite. --- .../key_management_endpoints.py | 74 +++++-- .../object_permission_utils.py | 123 +++++++++-- .../team_member_permission_checks.py | 10 +- .../test_key_management_endpoints.py | 191 ++++++++++++++++++ .../test_object_permission_utils.py | 120 +++++++++++ .../test_team_member_permission_checks.py | 39 +++- 6 files changed, 512 insertions(+), 45 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index eed7d869a5d..f19ea6da529 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -80,6 +80,7 @@ from litellm.proxy.management_helpers.object_permission_utils import ( handle_update_object_permission_common, validate_key_mcp_servers_against_team, validate_key_search_tools_against_team, + validate_key_vector_stores_against_team, ) from litellm.proxy.management_helpers.team_member_permission_checks import ( TeamMemberPermissionChecks, @@ -349,6 +350,12 @@ def _personal_key_membership_check( def _personal_key_generation_check(user_api_key_dict: UserAPIKeyAuth, data: GenerateKeyRequest): + TeamMemberPermissionChecks.enforce_member_can_assign_access_groups( + user_api_key_dict=user_api_key_dict, + team_table=None, + access_group_ids=data.access_group_ids, + ) + if ( litellm.key_generation_settings is None or litellm.key_generation_settings.get("personal_key_generation") is None @@ -845,17 +852,26 @@ async def _common_key_generation_helper( data_json.pop("tags") # Validate MCP servers in object_permission are within team scope + _is_proxy_admin_caller = ( + user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value + ) normalized_object_permission = await validate_key_mcp_servers_against_team( object_permission=data_json.get("object_permission"), team_obj=team_table, prisma_client=prisma_client, - is_proxy_admin=user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value, + is_proxy_admin=_is_proxy_admin_caller, ) if normalized_object_permission is not None: data_json["object_permission"] = normalized_object_permission await validate_key_search_tools_against_team( object_permission=data_json.get("object_permission"), team_obj=team_table, + is_proxy_admin=_is_proxy_admin_caller, + ) + await validate_key_vector_stores_against_team( + object_permission=data_json.get("object_permission"), + team_obj=team_table, + is_proxy_admin=_is_proxy_admin_caller, ) data_json = await _set_object_permission( @@ -2069,13 +2085,7 @@ async def _validate_mcp_servers_for_key_update( user_api_key_cache=user_api_key_cache, check_db_only=True, ) - object_permission_dict: Optional[dict] = None - if data.object_permission is not None: - object_permission_dict = ( - data.object_permission.model_dump(exclude_unset=True) - if hasattr(data.object_permission, "model_dump") - else dict(data.object_permission) # type: ignore[arg-type] - ) + object_permission_dict = _object_permission_to_dict(data.object_permission) normalized_object_permission = await validate_key_mcp_servers_against_team( object_permission=object_permission_dict, team_obj=effective_team_obj, @@ -2085,6 +2095,12 @@ async def _validate_mcp_servers_for_key_update( await validate_key_search_tools_against_team( object_permission=object_permission_dict, team_obj=effective_team_obj, + is_proxy_admin=is_proxy_admin, + ) + await validate_key_vector_stores_against_team( + object_permission=object_permission_dict, + team_obj=effective_team_obj, + is_proxy_admin=is_proxy_admin, ) return normalized_object_permission @@ -2216,14 +2232,6 @@ async def _validate_update_key_data( detail=f"Team not found for team_id={data.team_id}. Non-admin users cannot set keys to non-existent teams.", ) - # Field-level opt-in: non-admin members may only assign access groups when - # the team has enabled KEY_ACCESS_GROUP_ASSIGNMENT. - TeamMemberPermissionChecks.enforce_member_can_assign_access_groups( - user_api_key_dict=user_api_key_dict, - team_table=team_obj, - access_group_ids=data.access_group_ids, - ) - if team_obj is not None: await _check_team_key_limits( team_table=team_obj, @@ -2231,6 +2239,12 @@ async def _validate_update_key_data( prisma_client=prisma_client, ) + TeamMemberPermissionChecks.enforce_member_can_assign_access_groups( + user_api_key_dict=user_api_key_dict, + team_table=team_obj, + access_group_ids=data.access_group_ids, + ) + # Validate key against project limits if project_id is being set _project_id_to_check = getattr(data, "project_id", None) or getattr(existing_key_row, "project_id", None) if _project_id_to_check is not None and (data.models is not None or data.max_budget is not None): @@ -4458,9 +4472,9 @@ async def regenerate_key_fn( detail={"error": "You are not authorized to regenerate this key"}, ) - # Gate access_group_ids on regenerate, same as /key/generate and - # /key/update. Use the existing key's team since the body may omit it. - if data is not None and data.access_group_ids: + if data is not None and ( + data.access_group_ids or data.object_permission is not None + ): regenerate_team_table: Optional[LiteLLM_TeamTableCachedObj] = None if _key_in_db.team_id is not None: regenerate_team_table = await get_team_object( @@ -4469,11 +4483,33 @@ async def regenerate_key_fn( user_api_key_cache=user_api_key_cache, check_db_only=True, ) + _regen_is_proxy_admin = ( + user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value + ) TeamMemberPermissionChecks.enforce_member_can_assign_access_groups( user_api_key_dict=user_api_key_dict, team_table=regenerate_team_table, access_group_ids=data.access_group_ids, ) + _regen_object_permission_dict = _object_permission_to_dict( + data.object_permission + ) + await validate_key_mcp_servers_against_team( + object_permission=_regen_object_permission_dict, + team_obj=regenerate_team_table, + prisma_client=prisma_client, + is_proxy_admin=_regen_is_proxy_admin, + ) + await validate_key_search_tools_against_team( + object_permission=_regen_object_permission_dict, + team_obj=regenerate_team_table, + is_proxy_admin=_regen_is_proxy_admin, + ) + await validate_key_vector_stores_against_team( + object_permission=_regen_object_permission_dict, + team_obj=regenerate_team_table, + is_proxy_admin=_regen_is_proxy_admin, + ) verbose_proxy_logger.info( "Key regeneration requested: key_alias=%s", diff --git a/litellm/proxy/management_helpers/object_permission_utils.py b/litellm/proxy/management_helpers/object_permission_utils.py index 9d5f716033f..d980a6f8cfd 100644 --- a/litellm/proxy/management_helpers/object_permission_utils.py +++ b/litellm/proxy/management_helpers/object_permission_utils.py @@ -555,31 +555,98 @@ async def validate_key_mcp_servers_against_team( detail={"error": detail}, ) - # Validate requested toolsets against team's allowed toolsets. - # Only enforce the team-based restriction when a team is present — standalone - # keys (no team) can freely be granted any toolset by an admin. - if requested_toolsets and team_obj is not None: - team_op = team_obj.object_permission - team_mcp_toolsets = team_op.mcp_toolsets if team_op is not None else None - # None or [] means the team has no toolset restriction — allow any toolsets. - if team_mcp_toolsets: - disallowed_toolsets = requested_toolsets - set(team_mcp_toolsets) - if disallowed_toolsets: - team_id = team_obj.team_id - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "error": ( - f"Key requests MCP toolsets not allowed by team '{team_id}': " - f"{sorted(disallowed_toolsets)}. " - f"Team allows: {sorted(team_mcp_toolsets)}." - ) - }, - ) + _validate_requested_toolsets( + requested_toolsets=requested_toolsets, + team_obj=team_obj, + is_proxy_admin=is_proxy_admin, + ) return object_permission +def _validate_requested_toolsets( + requested_toolsets: set[str], + team_obj: Optional["LiteLLM_TeamTableCachedObj"], + is_proxy_admin: bool, +) -> None: + """ + Validate mcp_toolsets requested on a key. + + Non-admin callers cannot assign toolsets to a personal (no team) key. Team + keys must request a subset of the team's own toolset allowlist. + """ + if not requested_toolsets: + return + if team_obj is None: + if is_proxy_admin: + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": ( + "Key is not in a team. MCP toolsets cannot be assigned to " + "personal keys by non-admin callers. Disallowed toolsets: " + f"{sorted(requested_toolsets)}." + ) + }, + ) + team_op = team_obj.object_permission + team_mcp_toolsets = team_op.mcp_toolsets if team_op is not None else None + if not team_mcp_toolsets: + return + disallowed_toolsets = requested_toolsets - set(team_mcp_toolsets) + if not disallowed_toolsets: + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": ( + f"Key requests MCP toolsets not allowed by team '{team_obj.team_id}': " + f"{sorted(disallowed_toolsets)}. " + f"Team allows: {sorted(team_mcp_toolsets)}." + ) + }, + ) + + +def _extract_requested_vector_stores(object_permission: Optional[dict]) -> set[str]: + """Return vector_store IDs from a key's object_permission dict.""" + if not object_permission or not isinstance(object_permission, dict): + return set() + raw = object_permission.get("vector_stores") + if isinstance(raw, list): + return {str(x) for x in raw if x} + return set() + + +async def validate_key_vector_stores_against_team( + object_permission: Optional[dict], + team_obj: Optional["LiteLLM_TeamTableCachedObj"], + is_proxy_admin: bool = False, +) -> None: + """ + Reject vector_stores requested on a personal (no team) key by a non-admin + caller. Vector store access is granted at use-time from the key's + object_permission.vector_stores list, so the assignment is the authorization + boundary. Team keys and proxy admins are unaffected. + """ + requested = _extract_requested_vector_stores(object_permission) + if not requested: + return + if team_obj is not None or is_proxy_admin: + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": ( + "Key is not in a team. Vector stores cannot be assigned to " + "personal keys by non-admin callers. Disallowed vector stores: " + f"{sorted(requested)}." + ) + }, + ) + + def _extract_requested_search_tools(object_permission: Optional[dict]) -> List[str]: """Return search_tool_name values from a key's object_permission dict.""" if not object_permission or not isinstance(object_permission, dict): @@ -593,16 +660,30 @@ def _extract_requested_search_tools(object_permission: Optional[dict]) -> List[s async def validate_key_search_tools_against_team( object_permission: Optional[dict], team_obj: Optional["LiteLLM_TeamTableCachedObj"], + is_proxy_admin: bool = False, ) -> None: """ Validate key object_permission.search_tools is a subset of the team's allowlist. Empty team allowlist means no restriction at team layer (skip). + Non-admin callers cannot assign search_tools to a personal (no team) key. """ requested = _extract_requested_search_tools(object_permission) if not requested: return + if team_obj is None and not is_proxy_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": ( + "Key is not in a team. search_tools cannot be assigned to " + "personal keys by non-admin callers. Disallowed search tools: " + f"{sorted(requested)}." + ) + }, + ) + team_tools: List[str] = [] if team_obj is not None and team_obj.object_permission is not None: st = team_obj.object_permission.search_tools diff --git a/litellm/proxy/management_helpers/team_member_permission_checks.py b/litellm/proxy/management_helpers/team_member_permission_checks.py index 1353b9ed651..1532668ed19 100644 --- a/litellm/proxy/management_helpers/team_member_permission_checks.py +++ b/litellm/proxy/management_helpers/team_member_permission_checks.py @@ -167,9 +167,15 @@ class TeamMemberPermissionChecks: if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value: return - # Personal (non-team) keys are out of scope for team-member gating. if team_table is None: - return + raise HTTPException( + status_code=403, + detail=( + "Key is not in a team. Access groups cannot be assigned to " + "personal keys by non-admin callers. Disallowed access groups: " + f"{sorted(access_group_ids)}." + ), + ) team_member_object = _get_user_in_team(team_table=team_table, user_id=user_api_key_dict.user_id) diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index a92eaa3a5f6..7f78e0de9a6 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -526,6 +526,197 @@ async def test_key_generation_with_object_permission(monkeypatch): assert key_insert_calls[0]["data"].get("object_permission_id") == "objperm123" +@pytest.mark.asyncio +@pytest.mark.parametrize( + "field,request_kwargs,expected_in_error", + [ + ( + "access_group_ids", + {"access_group_ids": ["acme_private"]}, + "Access groups", + ), + ( + "mcp_toolsets", + {"object_permission": {"mcp_toolsets": ["acme_toolset"]}}, + "MCP toolsets", + ), + ( + "vector_stores", + {"object_permission": {"vector_stores": ["acme_vs"]}}, + "Vector stores", + ), + ( + "search_tools", + {"object_permission": {"search_tools": ["acme_search"]}}, + "search_tools", + ), + ], +) +async def test_generate_key_personal_non_admin_denied_for_team_scoped_fields( + monkeypatch, field, request_kwargs, expected_in_error +): + """generate_key_fn must reject access_group_ids and + object_permission.{mcp_toolsets, vector_stores, search_tools} when the + caller is a non-admin and the request has no team_id. Mutating any of the + three validator calls in _common_key_generation_helper or unmoving the + enforce_member_can_assign_access_groups call in _personal_key_generation_check + must break this test.""" + mock_prisma_client = AsyncMock() + mock_prisma_client.jsonify_object = lambda data: data # type: ignore + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.litellm_objectpermissiontable = MagicMock() + mock_prisma_client.db.litellm_objectpermissiontable.create = AsyncMock( + return_value=MagicMock(object_permission_id="should-not-create") + ) + mock_prisma_client.insert_data = AsyncMock(return_value=MagicMock()) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + from litellm.proxy._types import ( + GenerateKeyRequest, + LiteLLM_ObjectPermissionBase, + LitellmUserRoles, + ) + from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth + from litellm.proxy.management_endpoints.key_management_endpoints import ( + generate_key_fn, + ) + + if "object_permission" in request_kwargs: + request_kwargs = { + **request_kwargs, + "object_permission": LiteLLM_ObjectPermissionBase( + **request_kwargs["object_permission"] + ), + } + request_data = GenerateKeyRequest(**request_kwargs) + + from litellm.proxy._types import ProxyException + + with pytest.raises((HTTPException, ProxyException)) as exc: + await generate_key_fn( + data=request_data, + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-alice", + user_id="alice", + ), + ) + code = getattr(exc.value, "status_code", None) or getattr(exc.value, "code", None) + assert int(code) == 403 + body = str( + getattr(exc.value, "detail", None) or getattr(exc.value, "message", exc.value) + ) + assert expected_in_error in body + mock_prisma_client.db.litellm_objectpermissiontable.create.assert_not_called() + + +@pytest.mark.asyncio +async def test_update_key_personal_non_admin_denied_vector_stores(monkeypatch): + """/key/update must reject vector_stores on a personal key by a non-admin. + Reverting the enforce_member_can_assign_access_groups move (i.e. putting + it back inside `if _team_id_to_check is not None`) does NOT cover + object_permission fields; this test exercises _validate_update_key_data + which calls _validate_mcp_servers_for_key_update.""" + mock_prisma_client = AsyncMock() + mock_prisma_client.jsonify_object = lambda data: data # type: ignore + mock_prisma_client.db = MagicMock() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr( + "litellm.proxy.proxy_server.user_api_key_cache", + MagicMock(), + ) + + from litellm.proxy._types import ( + LiteLLM_ObjectPermissionBase, + LitellmUserRoles, + UpdateKeyRequest, + ) + from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _validate_update_key_data, + ) + + existing_key_row = MagicMock( + token="hashed_alice_personal_key", + user_id="alice", + team_id=None, + created_by="alice", + max_budget=None, + organization_id=None, + project_id=None, + ) + data = UpdateKeyRequest( + key="sk-alice-personal", + object_permission=LiteLLM_ObjectPermissionBase(vector_stores=["acme_vs"]), + ) + + with pytest.raises(HTTPException) as exc: + await _validate_update_key_data( + data=data, + existing_key_row=existing_key_row, + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-alice", + user_id="alice", + ), + llm_router=None, + premium_user=True, + prisma_client=mock_prisma_client, + user_api_key_cache=MagicMock(), + ) + assert exc.value.status_code == 403 + assert "Vector stores" in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_update_key_personal_non_admin_denied_access_groups( + monkeypatch, +): + """/key/update on a personal key must also gate access_group_ids for + non-admins. Reverting the enforce move (putting it back inside + `if _team_id_to_check is not None`) breaks this test.""" + mock_prisma_client = AsyncMock() + mock_prisma_client.jsonify_object = lambda data: data # type: ignore + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + from litellm.proxy._types import LitellmUserRoles, UpdateKeyRequest + from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _validate_update_key_data, + ) + + existing_key_row = MagicMock( + token="hashed_alice_personal_key", + user_id="alice", + team_id=None, + created_by="alice", + max_budget=None, + organization_id=None, + project_id=None, + ) + data = UpdateKeyRequest( + key="sk-alice-personal", + access_group_ids=["ag-private"], + ) + + with pytest.raises(HTTPException) as exc: + await _validate_update_key_data( + data=data, + existing_key_row=existing_key_row, + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-alice", + user_id="alice", + ), + llm_router=None, + premium_user=True, + prisma_client=mock_prisma_client, + user_api_key_cache=MagicMock(), + ) + assert exc.value.status_code == 403 + assert "Access groups" in str(exc.value.detail) + + @pytest.mark.asyncio async def test_generate_key_helper_fn_with_access_group_ids(monkeypatch): """Ensure generate_key_helper_fn passes access_group_ids into the key insert payload.""" diff --git a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py index 2b38d732e9d..d81511d2322 100644 --- a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py +++ b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py @@ -18,6 +18,7 @@ from litellm.proxy.management_helpers.object_permission_utils import ( _set_object_permission, validate_key_mcp_servers_against_team, validate_key_search_tools_against_team, + validate_key_vector_stores_against_team, ) @@ -890,3 +891,122 @@ async def test_validate_search_tools_raises_when_not_subset(): team_obj=_make_team_obj_search(search_tools=["t1"]), ) assert exc.value.status_code == 403 + + +# ---- Personal-key non-admin gates on toolsets / vector_stores / search_tools ---- + + +@pytest.mark.asyncio +@patch( + "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids", + return_value=set(), +) +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=[], +) +async def test_personal_non_admin_cannot_assign_mcp_toolsets( + mock_access_groups, mock_allow_all +): + with pytest.raises(HTTPException) as exc: + await validate_key_mcp_servers_against_team( + object_permission={"mcp_toolsets": ["ts-private"]}, + team_obj=None, + is_proxy_admin=False, + ) + assert exc.value.status_code == 403 + assert "ts-private" in str(exc.value.detail) + + +@pytest.mark.asyncio +@patch( + "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids", + return_value=set(), +) +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=[], +) +async def test_personal_admin_can_assign_mcp_toolsets( + mock_access_groups, mock_allow_all +): + await validate_key_mcp_servers_against_team( + object_permission={"mcp_toolsets": ["ts-private"]}, + team_obj=None, + is_proxy_admin=True, + ) + + +@pytest.mark.asyncio +async def test_personal_non_admin_cannot_assign_vector_stores(): + with pytest.raises(HTTPException) as exc: + await validate_key_vector_stores_against_team( + object_permission={"vector_stores": ["vs-private"]}, + team_obj=None, + is_proxy_admin=False, + ) + assert exc.value.status_code == 403 + assert "vs-private" in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_personal_admin_can_assign_vector_stores(): + await validate_key_vector_stores_against_team( + object_permission={"vector_stores": ["vs-private"]}, + team_obj=None, + is_proxy_admin=True, + ) + + +@pytest.mark.asyncio +async def test_team_key_vector_stores_unrestricted_at_create(): + """Team-scoped keys retain their existing trust model at create time.""" + team_obj = _make_team_obj_search() + await validate_key_vector_stores_against_team( + object_permission={"vector_stores": ["vs-anything"]}, + team_obj=team_obj, + is_proxy_admin=False, + ) + + +@pytest.mark.asyncio +async def test_personal_non_admin_cannot_assign_search_tools(): + with pytest.raises(HTTPException) as exc: + await validate_key_search_tools_against_team( + object_permission={"search_tools": ["st-private"]}, + team_obj=None, + is_proxy_admin=False, + ) + assert exc.value.status_code == 403 + assert "st-private" in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_personal_admin_can_assign_search_tools(): + await validate_key_search_tools_against_team( + object_permission={"search_tools": ["st-private"]}, + team_obj=None, + is_proxy_admin=True, + ) + + +@pytest.mark.asyncio +async def test_empty_object_permission_passes_for_personal_non_admin(): + """An empty / absent object_permission must not be blocked.""" + await validate_key_vector_stores_against_team( + object_permission=None, + team_obj=None, + is_proxy_admin=False, + ) + await validate_key_vector_stores_against_team( + object_permission={"vector_stores": []}, + team_obj=None, + is_proxy_admin=False, + ) + await validate_key_search_tools_against_team( + object_permission=None, + team_obj=None, + is_proxy_admin=False, + ) diff --git a/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py b/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py index 29aa75a0f0a..71999e29f96 100644 --- a/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py +++ b/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py @@ -314,12 +314,45 @@ class TestEnforceMemberCanAssignAccessGroups: access_group_ids=["ag-1"], ) - def test_personal_key_out_of_scope(self): - """Personal (non-team) keys are not gated by team-member permissions.""" + def test_personal_key_non_admin_denied(self): + """A non-admin cannot self-grant access_group_ids on a personal (no + team) key. The access_group_id grants model access at use-time + without any team-membership cross-check, so the assignment is the + authorization boundary.""" + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc: + TeamMemberPermissionChecks.enforce_member_can_assign_access_groups( + user_api_key_dict=self._user(), + team_table=None, + access_group_ids=["ag-private"], + ) + assert exc.value.status_code == 403 + assert "ag-private" in str(exc.value.detail) + + def test_personal_key_proxy_admin_can_assign(self): + """Proxy admins bypass the personal-key gate and may assign access + groups on personal keys.""" + from litellm.proxy._types import LitellmUserRoles + + TeamMemberPermissionChecks.enforce_member_can_assign_access_groups( + user_api_key_dict=self._user(role=LitellmUserRoles.PROXY_ADMIN.value), + team_table=None, + access_group_ids=["ag-private"], + ) + + def test_personal_key_empty_access_groups_passes(self): + """An empty / absent access_group_ids list must not be rejected even + on a personal key — the gate only fires when the field is non-empty.""" TeamMemberPermissionChecks.enforce_member_can_assign_access_groups( user_api_key_dict=self._user(), team_table=None, - access_group_ids=["ag-1"], + access_group_ids=None, + ) + TeamMemberPermissionChecks.enforce_member_can_assign_access_groups( + user_api_key_dict=self._user(), + team_table=None, + access_group_ids=[], ) def test_team_admin_bypasses(self, monkeypatch): From f2d7cb152adba50a561fb72b6dde4f7ce9c96913 Mon Sep 17 00:00:00 2001 From: yucheng-berriai Date: Fri, 26 Jun 2026 16:33:52 -0700 Subject: [PATCH 007/110] refactor(proxy): type object_permission dict with ObjectPermissionDict Replace bare Optional[dict] on the object_permission validator surfaces with a typed TypedDict mirror of LiteLLM_ObjectPermissionBase. The TypedDict shape matches the Pydantic model field-for-field and supports .get() and item assignment, so the mutation in _rewrite_object_permission_mcp_identifiers continues to work at runtime (TypedDict is a plain dict). Propagated through the surfaces this PR touches: _object_permission_to_dict, _validate_mcp_servers_for_key_update, validate_key_mcp_servers_against_team, validate_key_search_tools_against_team, validate_key_vector_stores_against _team, the five _extract_requested_* helpers, and the two _rewrite_object_permission_mcp_* mutators. attach_object_permission_to_dict, handle_update_object_permission_common, and _set_object_permission keep their wider dict typing because they handle the full key/team data_json, which is a superset of ObjectPermissionDict and pre-dates this PR. No behavior change. 373 tests pass; ruff strict + type discipline gates green. --- litellm/proxy/_types.py | 17 +++++++++++ .../key_management_endpoints.py | 26 ++++++++-------- .../object_permission_utils.py | 30 +++++++++++-------- .../test_object_permission_utils.py | 17 ++++++++++- 4 files changed, 63 insertions(+), 27 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index d84588a4c24..7d470fba08b 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1005,6 +1005,23 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase): search_tools: Optional[List[str]] = None +class ObjectPermissionDict(TypedDict, total=False): + """Plain-dict mirror of LiteLLM_ObjectPermissionBase used by validators + that need to mutate the payload before persistence (e.g. MCP server + identifier normalization in object_permission_utils).""" + + mcp_servers: Optional[list[str]] + mcp_access_groups: Optional[list[str]] + mcp_tool_permissions: Optional[dict[str, list[str]]] + mcp_toolsets: Optional[list[str]] + blocked_tools: Optional[list[str]] + vector_stores: Optional[list[str]] + agents: Optional[list[str]] + agent_access_groups: Optional[list[str]] + models: Optional[list[str]] + search_tools: Optional[list[str]] + + from litellm.models.team import BudgetLimitEntry as BudgetLimitEntry # noqa: E402 diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index f19ea6da529..aeb664a5d1f 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -349,6 +349,14 @@ def _personal_key_membership_check( return True +def _object_permission_to_dict( + object_permission: Optional[LiteLLM_ObjectPermissionBase], +) -> Optional[ObjectPermissionDict]: + if object_permission is None: + return None + return cast(ObjectPermissionDict, object_permission.model_dump(exclude_unset=True)) + + def _personal_key_generation_check(user_api_key_dict: UserAPIKeyAuth, data: GenerateKeyRequest): TeamMemberPermissionChecks.enforce_member_can_assign_access_groups( user_api_key_dict=user_api_key_dict, @@ -852,9 +860,7 @@ async def _common_key_generation_helper( data_json.pop("tags") # Validate MCP servers in object_permission are within team scope - _is_proxy_admin_caller = ( - user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value - ) + _is_proxy_admin_caller = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value normalized_object_permission = await validate_key_mcp_servers_against_team( object_permission=data_json.get("object_permission"), team_obj=team_table, @@ -2074,7 +2080,7 @@ async def _validate_mcp_servers_for_key_update( prisma_client: Any, user_api_key_cache: Any, is_proxy_admin: bool, -) -> Optional[dict]: +) -> Optional[ObjectPermissionDict]: """Validate MCP servers in object_permission against the effective team.""" effective_team_obj = team_obj # If team_id isn't being changed, resolve the existing key's team @@ -4472,9 +4478,7 @@ async def regenerate_key_fn( detail={"error": "You are not authorized to regenerate this key"}, ) - if data is not None and ( - data.access_group_ids or data.object_permission is not None - ): + if data is not None and (data.access_group_ids or data.object_permission is not None): regenerate_team_table: Optional[LiteLLM_TeamTableCachedObj] = None if _key_in_db.team_id is not None: regenerate_team_table = await get_team_object( @@ -4483,17 +4487,13 @@ async def regenerate_key_fn( user_api_key_cache=user_api_key_cache, check_db_only=True, ) - _regen_is_proxy_admin = ( - user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value - ) + _regen_is_proxy_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value TeamMemberPermissionChecks.enforce_member_can_assign_access_groups( user_api_key_dict=user_api_key_dict, team_table=regenerate_team_table, access_group_ids=data.access_group_ids, ) - _regen_object_permission_dict = _object_permission_to_dict( - data.object_permission - ) + _regen_object_permission_dict = _object_permission_to_dict(data.object_permission) await validate_key_mcp_servers_against_team( object_permission=_regen_object_permission_dict, team_obj=regenerate_team_table, diff --git a/litellm/proxy/management_helpers/object_permission_utils.py b/litellm/proxy/management_helpers/object_permission_utils.py index d980a6f8cfd..fe96d9c260a 100644 --- a/litellm/proxy/management_helpers/object_permission_utils.py +++ b/litellm/proxy/management_helpers/object_permission_utils.py @@ -11,7 +11,7 @@ from fastapi import HTTPException, status from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid from litellm.litellm_core_utils.safe_json_dumps import safe_dumps -from litellm.proxy._types import SpecialMCPServerNames +from litellm.proxy._types import ObjectPermissionDict, SpecialMCPServerNames from litellm.proxy.utils import PrismaClient from litellm.repositories.object_permission_repository import ObjectPermissionRepository from litellm.repositories.table_repositories import MCPServerRepository @@ -257,7 +257,7 @@ async def _resolve_mcp_server_identifiers_to_ids( def _rewrite_object_permission_mcp_servers( - object_permission: dict, + object_permission: ObjectPermissionDict, identifier_to_server_ids: Dict[str, Set[str]], ) -> None: mcp_servers = object_permission.get("mcp_servers") @@ -274,7 +274,7 @@ def _rewrite_object_permission_mcp_servers( def _rewrite_object_permission_mcp_tool_permissions( - object_permission: dict, + object_permission: ObjectPermissionDict, identifier_to_server_ids: Dict[str, Set[str]], ) -> None: mcp_tool_permissions = object_permission.get("mcp_tool_permissions") @@ -295,7 +295,7 @@ def _rewrite_object_permission_mcp_tool_permissions( def _rewrite_object_permission_mcp_identifiers( - object_permission: Optional[dict], + object_permission: Optional[ObjectPermissionDict], identifier_to_server_ids: Dict[str, Set[str]], ) -> None: if not object_permission or not isinstance(object_permission, dict): @@ -383,7 +383,7 @@ async def _get_team_allowed_mcp_servers( def _extract_requested_mcp_server_ids( - object_permission: Optional[dict], + object_permission: Optional[ObjectPermissionDict], ) -> Set[str]: """ Extract all MCP server IDs referenced in a key's object_permission dict. @@ -409,7 +409,7 @@ def _extract_requested_mcp_server_ids( def _extract_requested_mcp_access_groups( - object_permission: Optional[dict], + object_permission: Optional[ObjectPermissionDict], ) -> Set[str]: """Extract MCP access groups from a key's object_permission dict.""" if not object_permission or not isinstance(object_permission, dict): @@ -422,7 +422,7 @@ def _extract_requested_mcp_access_groups( def _extract_requested_mcp_toolsets( - object_permission: Optional[dict], + object_permission: Optional[ObjectPermissionDict], ) -> Set[str]: """Extract MCP toolset IDs from a key's object_permission dict.""" if not object_permission or not isinstance(object_permission, dict): @@ -435,11 +435,11 @@ def _extract_requested_mcp_toolsets( async def validate_key_mcp_servers_against_team( - object_permission: Optional[dict], + object_permission: Optional[ObjectPermissionDict], team_obj: Optional["LiteLLM_TeamTableCachedObj"], prisma_client: Optional[PrismaClient] = None, is_proxy_admin: bool = False, -) -> Optional[dict]: +) -> Optional[ObjectPermissionDict]: """ Validate that MCP servers requested on a key are within the allowed scope. @@ -609,7 +609,9 @@ def _validate_requested_toolsets( ) -def _extract_requested_vector_stores(object_permission: Optional[dict]) -> set[str]: +def _extract_requested_vector_stores( + object_permission: Optional[ObjectPermissionDict], +) -> set[str]: """Return vector_store IDs from a key's object_permission dict.""" if not object_permission or not isinstance(object_permission, dict): return set() @@ -620,7 +622,7 @@ def _extract_requested_vector_stores(object_permission: Optional[dict]) -> set[s async def validate_key_vector_stores_against_team( - object_permission: Optional[dict], + object_permission: Optional[ObjectPermissionDict], team_obj: Optional["LiteLLM_TeamTableCachedObj"], is_proxy_admin: bool = False, ) -> None: @@ -647,7 +649,9 @@ async def validate_key_vector_stores_against_team( ) -def _extract_requested_search_tools(object_permission: Optional[dict]) -> List[str]: +def _extract_requested_search_tools( + object_permission: Optional[ObjectPermissionDict], +) -> list[str]: """Return search_tool_name values from a key's object_permission dict.""" if not object_permission or not isinstance(object_permission, dict): return [] @@ -658,7 +662,7 @@ def _extract_requested_search_tools(object_permission: Optional[dict]) -> List[s async def validate_key_search_tools_against_team( - object_permission: Optional[dict], + object_permission: Optional[ObjectPermissionDict], team_obj: Optional["LiteLLM_TeamTableCachedObj"], is_proxy_admin: bool = False, ) -> None: diff --git a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py index d81511d2322..26c8c774812 100644 --- a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py +++ b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py @@ -9,7 +9,7 @@ sys.path.insert(0, os.path.abspath("../../../..")) from unittest.mock import AsyncMock, MagicMock, patch -from litellm.proxy._types import LiteLLM_ObjectPermissionTable +from litellm.proxy._types import LiteLLM_ObjectPermissionBase, LiteLLM_ObjectPermissionTable, ObjectPermissionDict from litellm.proxy.management_helpers.object_permission_utils import ( _extract_requested_mcp_access_groups, _extract_requested_mcp_server_ids, @@ -1010,3 +1010,18 @@ async def test_empty_object_permission_passes_for_personal_non_admin(): team_obj=None, is_proxy_admin=False, ) + + +def test_object_permission_dict_mirrors_pydantic_model(): + """ObjectPermissionDict must stay field-for-field aligned with + LiteLLM_ObjectPermissionBase. If a new field is added to the Pydantic + model, this test fails until the TypedDict is updated to match.""" + from typing import get_type_hints + + pydantic_fields = set(LiteLLM_ObjectPermissionBase.model_fields.keys()) + typeddict_fields = set(get_type_hints(ObjectPermissionDict).keys()) + assert pydantic_fields == typeddict_fields, ( + f"ObjectPermissionDict drifted from LiteLLM_ObjectPermissionBase.\n" + f"Only in Pydantic model: {sorted(pydantic_fields - typeddict_fields)}\n" + f"Only in TypedDict: {sorted(typeddict_fields - pydantic_fields)}" + ) From 453aedef95e6d5cf3ad24379588428182dbd1b26 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:49:28 +0000 Subject: [PATCH 008/110] chore(router): simplify unknown-model error message construction The error string is already produced by the f-string interpolation; the trailing .format() call on it was redundant. Add a regression test that the message renders the model name verbatim. --- litellm/router.py | 6 ++---- tests/test_litellm/test_router.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index cdb45de66db..8abdd60ccad 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -10032,11 +10032,9 @@ class Router: # If still no deployments after checking for fallbacks, raise an error if len(healthy_deployments) == 0: if self.get_model_list(model_name=model) is None: - message = f"You passed in model={model}. There is no 'model_name' with this string".format(model) + message = f"You passed in model={model}. There is no 'model_name' with this string" else: - message = f"You passed in model={model}. There are no healthy deployments for this model".format( - model - ) + message = f"You passed in model={model}. There are no healthy deployments for this model" raise litellm.BadRequestError( message=message, diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 5f214506197..9c4d83ff7ea 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -3268,6 +3268,34 @@ async def test_router_acompletion_with_unknown_model_and_no_fallback(): assert "no healthy deployments for this model" in str(excinfo.value) +@pytest.mark.asyncio +async def test_router_unknown_model_error_message_renders_model_name_literally(): + """ + The unknown-model error message renders the caller-supplied model name + verbatim. A name containing Python format-field syntax must be treated as + literal text, not re-interpreted as a format template, which would distort + the message and balloon its length. + """ + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-4o", + "litellm_params": {"model": "azure/gpt-4o-real", "api_key": "fake-key"}, + } + ] + ) + + weird_model = "ghost{:>200}model" + messages = [{"role": "user", "content": "hi"}] + + with pytest.raises(litellm.BadRequestError) as excinfo: + await router.acompletion(model=weird_model, messages=messages) + + message = str(excinfo.value) + assert weird_model in message + assert " " not in message # no padding run from an expanded format field + + def test_get_deployment_credentials_with_provider_aws_bedrock_runtime_endpoint(): """ Test that get_deployment_credentials_with_provider correctly copies From 9b4442c6df65ad048097a1c0a87d23978deb2c73 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:37:11 +0000 Subject: [PATCH 009/110] chore(router): drop unreachable unknown-model error branch get_model_list always returns a list, never None, so the is-None branch could not execute. Collapse to the single reachable message. --- litellm/router.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 8abdd60ccad..2d66bc3158d 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -10031,10 +10031,7 @@ class Router: # If still no deployments after checking for fallbacks, raise an error if len(healthy_deployments) == 0: - if self.get_model_list(model_name=model) is None: - message = f"You passed in model={model}. There is no 'model_name' with this string" - else: - message = f"You passed in model={model}. There are no healthy deployments for this model" + message = f"You passed in model={model}. There are no healthy deployments for this model" raise litellm.BadRequestError( message=message, From d7654d07ab949e6fc05fe2da046ab1c67735cf40 Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Mon, 29 Jun 2026 20:14:22 +0200 Subject: [PATCH 010/110] feat(proxy): add AES-256-GCM at-rest credential encryption with versioned format and re-encryption migration (#31215) * feat(proxy): add AES-256-GCM at-rest credential encryption with versioned format and re-encryption migration * test(proxy): add behavior scenarios for credential migration endpoints * fix(proxy): scan covered tables in encryption check, fix CI lint and route types * fix(proxy): migrate callback_settings credentials, clear CI lint/recursion gates, add encryption endpoint+CLI tests * fix(proxy): correct dry-run/real-run migrated vs residual-legacy counters in config and SSO walkers * fix(proxy): make callback-vars residual detection gate-independent in encryption check --- .../proxy/client/cli/commands/encryption.py | 60 ++ litellm/proxy/client/cli/main.py | 3 + .../common_utils/encrypt_decrypt_utils.py | 87 ++- .../credential_migration.py | 702 ++++++++++++++++++ .../key_management_endpoints.py | 80 ++ .../test_credential_migration_endpoint.py | 71 ++ .../client/cli/test_encryption_commands.py | 71 ++ .../test_encrypt_decrypt_utils.py | 148 ++++ .../test_credential_migration.py | 516 +++++++++++++ .../test_encryption_endpoints.py | 100 +++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 98 +++ 11 files changed, 1935 insertions(+), 1 deletion(-) create mode 100644 litellm/proxy/client/cli/commands/encryption.py create mode 100644 litellm/proxy/management_endpoints/credential_migration.py create mode 100644 tests/proxy_behavior/management/test_credential_migration_endpoint.py create mode 100644 tests/test_litellm/proxy/client/cli/test_encryption_commands.py create mode 100644 tests/test_litellm/proxy/common_utils/test_encrypt_decrypt_utils.py create mode 100644 tests/test_litellm/proxy/management_endpoints/test_credential_migration.py create mode 100644 tests/test_litellm/proxy/management_endpoints/test_encryption_endpoints.py diff --git a/litellm/proxy/client/cli/commands/encryption.py b/litellm/proxy/client/cli/commands/encryption.py new file mode 100644 index 00000000000..f67c9746fa9 --- /dev/null +++ b/litellm/proxy/client/cli/commands/encryption.py @@ -0,0 +1,60 @@ +"""CLI commands for the at-rest credential encryption migration.""" + +import click +import rich + +from ...http_client import HTTPClient + + +@click.group() +def encryption(): + """Migrate at-rest credentials to AES-256-GCM and attest residual state.""" + pass + + +@encryption.command(name="migrate") +@click.option( + "--check", + "check_only", + is_flag=True, + default=False, + help="Read-only residual scan (no writes). Reports legacy values remaining.", +) +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Run the full migration walkers without writing any changes.", +) +@click.pass_context +def migrate(ctx: click.Context, check_only: bool, dry_run: bool): + """Re-encrypt at-rest credentials into the AES-256-GCM (v2:gcm:) format. + + Requires the proxy to be started with + ``general_settings.encryption_algorithm: aes-256-gcm``. Idempotent and + resumable — safe to re-run after an interruption. + + Examples: + litellm-proxy encryption migrate --check # attestation scan, no writes + litellm-proxy encryption migrate # perform the migration + """ + client = HTTPClient(ctx.obj["base_url"], ctx.obj["api_key"]) + + if check_only: + response = client.request("GET", "/credentials/migrate-encryption/check") + else: + response = client.request( + "POST", + "/credentials/migrate-encryption", + json={}, + params={"dry_run": "true"} if dry_run else None, + ) + + rich.print_json(data=response) + + report = response.get("report", {}) if isinstance(response, dict) else {} + residual = report.get("residual_legacy") + if residual is not None and residual > 0: + rich.print(f"[yellow]Residual legacy values remaining: {residual}[/yellow]") + elif residual == 0: + rich.print("[green]No legacy values remaining (residual_legacy == 0).[/green]") diff --git a/litellm/proxy/client/cli/main.py b/litellm/proxy/client/cli/main.py index b8c483f4b08..43b64aebd3b 100644 --- a/litellm/proxy/client/cli/main.py +++ b/litellm/proxy/client/cli/main.py @@ -11,6 +11,7 @@ from .commands.agents import agent_commands from .commands.auth import get_stored_api_key, login, logout, whoami from .commands.chat import chat from .commands.credentials import credentials +from .commands.encryption import encryption from .commands.http import http from .commands.keys import keys @@ -103,6 +104,8 @@ cli.add_command(whoami) cli.add_command(models) # Add the credentials command group cli.add_command(credentials) +# Add the encryption migration command group +cli.add_command(encryption) # Add the chat command group cli.add_command(chat) # Add the http command group diff --git a/litellm/proxy/common_utils/encrypt_decrypt_utils.py b/litellm/proxy/common_utils/encrypt_decrypt_utils.py index 6be56de1260..8599b3ace7f 100644 --- a/litellm/proxy/common_utils/encrypt_decrypt_utils.py +++ b/litellm/proxy/common_utils/encrypt_decrypt_utils.py @@ -1,9 +1,24 @@ import base64 import os -from typing import Literal, Optional +from typing import Literal, Optional, cast from litellm._logging import verbose_proxy_logger +# Versioned ciphertext marker for AES-256-GCM values. +# Format: "v2:gcm:" + base64url(nonce(12) || ciphertext || tag(16)). +# Legacy XSalsa20-Poly1305 (nacl) values carry no marker; the colon in the +# prefix can never appear in base64url(nacl output), so the prefix check is an +# unambiguous discriminator between the two formats on read. +_V2_GCM_PREFIX = "v2:gcm:" + +# general_settings key selecting the at-rest encryption algorithm for new writes. +# Default preserves the legacy algorithm so existing deployments are byte-for-byte +# unchanged until they explicitly opt in. Decrypt is always format-detecting, so +# flipping this flag forward (or back) never strands previously-written data. +_ENCRYPTION_ALGORITHM_SETTING = "encryption_algorithm" +_ALGO_AES_GCM = "aes-256-gcm" +_ALGO_XSALSA20 = "xsalsa20-poly1305" + def _get_salt_key(): from litellm.proxy.proxy_server import master_key @@ -16,11 +31,76 @@ def _get_salt_key(): return salt_key +def _get_encryption_algorithm() -> str: + """ + Resolve the configured at-rest encryption algorithm for *new writes*. + + Read from ``general_settings.encryption_algorithm`` at write time. Defaults to + the legacy XSalsa20-Poly1305 algorithm so deployments that have not opted in + keep producing byte-for-byte identical ciphertext. + """ + try: + from litellm.proxy.proxy_server import general_settings + + algo = general_settings.get(_ENCRYPTION_ALGORITHM_SETTING, _ALGO_XSALSA20) + except Exception: + # general_settings may not be importable in some contexts (e.g. SDK-only + # use of these helpers). Fall back to the legacy algorithm. + return _ALGO_XSALSA20 + + if isinstance(algo, str) and algo.lower() == _ALGO_AES_GCM: + return _ALGO_AES_GCM + return _ALGO_XSALSA20 + + +def _derive_key(signing_key: str) -> bytes: + """Derive a 32-byte key from the salt/master key (shared by both algorithms). + + Known limitation: this is a single-pass, unsalted ``SHA-256`` of the key, not + a dedicated KDF (HKDF/PBKDF2). It is the *same* derivation the legacy nacl + path already uses, so the AES path introduces no new weakness and stays + interoperable with existing key sourcing; AES-256-GCM's per-value 12-byte + random nonce gives the unique (key, nonce) pairs GCM requires. Moving both + algorithms to HKDF-SHA256 would be more defensible in an audit but is a + separate, coordinated change (it must re-derive or re-encrypt existing data). + """ + import hashlib + + return hashlib.sha256(signing_key.encode()).digest() + + +def _encrypt_aes_gcm(value: str, signing_key: str) -> str: + """Encrypt under AES-256-GCM and return the versioned ``v2:gcm:`` string.""" + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + nonce = os.urandom(12) + # AESGCM.encrypt returns ciphertext || tag(16); wire format is nonce || that. + blob = AESGCM(_derive_key(signing_key)).encrypt(nonce, value.encode("utf-8"), None) + return _V2_GCM_PREFIX + base64.urlsafe_b64encode(nonce + blob).decode("utf-8") + + +def _decrypt_aes_gcm(value: str, signing_key: str) -> str: + """Decrypt a versioned ``v2:gcm:`` string produced by :func:`_encrypt_aes_gcm`.""" + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + raw = base64.urlsafe_b64decode(value[len(_V2_GCM_PREFIX) :]) + # An empty plaintext still serializes to nonce(12) || tag(16) = 28 bytes, so a + # short/empty buffer here is a corrupt value: let AESGCM.decrypt raise and be + # swallowed by decrypt_value_helper (returns None/original), same as legacy. + nonce, blob = raw[:12], raw[12:] + return AESGCM(_derive_key(signing_key)).decrypt(nonce, blob, None).decode("utf-8") + + def encrypt_value_helper(value: str, new_encryption_key: Optional[str] = None): signing_key = new_encryption_key or _get_salt_key() try: if isinstance(value, str): + if _get_encryption_algorithm() == _ALGO_AES_GCM: + # AES path: the v2:gcm: output is already a base64url string, so it + # is returned directly with no extra base64 wrapper. + return _encrypt_aes_gcm(value=value, signing_key=cast(str, signing_key)) + encrypted_value = encrypt_value(value=value, signing_key=signing_key) # type: ignore # Use urlsafe_b64encode for URL-safe base64 encoding (replaces + with - and / with _) encrypted_value = base64.urlsafe_b64encode(encrypted_value).decode("utf-8") @@ -46,6 +126,11 @@ def decrypt_value_helper( try: if isinstance(value, str): + # Versioned AES-256-GCM values are detected before any base64 decode. + # The prefix is the algorithm tag the legacy nacl format never carried. + if value.startswith(_V2_GCM_PREFIX): + return _decrypt_aes_gcm(value=value, signing_key=cast(str, signing_key)) + # Try URL-safe base64 decoding first (new format) # Fall back to standard base64 decoding for backwards compatibility (old format) try: diff --git a/litellm/proxy/management_endpoints/credential_migration.py b/litellm/proxy/management_endpoints/credential_migration.py new file mode 100644 index 00000000000..4d51295f8dc --- /dev/null +++ b/litellm/proxy/management_endpoints/credential_migration.py @@ -0,0 +1,702 @@ +""" +At-rest credential re-encryption migration. + +Switches every encrypted-at-rest value from the legacy XSalsa20-Poly1305 (nacl) +format to the versioned AES-256-GCM (``v2:gcm:``) format produced by +``encrypt_decrypt_utils`` when ``general_settings.encryption_algorithm`` is set to +``aes-256-gcm``. + +Design properties (see case 2026-06-24 fix plan): + +* **Same key, new algorithm.** The migration does not change the encryption key; + it re-encrypts existing ciphertext under the same derived key but in the new + AES format. This is achieved by decrypting with the format-detecting reader and + re-encrypting through ``encrypt_value_helper`` with the AES gate enabled. +* **Idempotent.** A value already carrying the ``v2:gcm:`` prefix is recognised + and left untouched, so re-running the migration is a no-op on migrated rows. +* **Resumable.** Walkers commit per row (or per small table), so an interrupted + run leaves a clean mixed state that a re-run completes. +* **Skip-on-undecryptable.** A value that cannot be decrypted is never + overwritten — corrupt rows are preserved and reported, never destroyed. +* **Attestable.** :func:`check_encryption` is a read-only scan that classifies + every value as ``migrated`` / ``legacy`` / ``plaintext`` / ``undecryptable``. + A residual ``legacy == 0`` is the compliance attestation. + +Coverage. The covered tables (model table, credentials table, MCP credential/env +tables, config ``environment_variables``) already have a re-encryption path in +``_rotate_master_key``; this module delegates to it in *same-key* mode and adds +walkers for the locations that had no rotation path: team / verification-token +``callback_vars`` metadata, the ``vantage_settings`` / ``cloudzero_settings`` +config rows, and the SSO config table. +""" + +import json +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal, cast + +from litellm._logging import verbose_proxy_logger + +if TYPE_CHECKING: + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.utils import PrismaClient +from litellm.proxy.common_utils.encrypt_decrypt_utils import ( + _ALGO_AES_GCM, + _ENCRYPTION_ALGORITHM_SETTING, + _V2_GCM_PREFIX, + _get_salt_key, + decrypt_value_helper, + encrypt_value_helper, +) + +ValueClass = Literal["migrated", "legacy", "plaintext", "undecryptable", "not-a-string"] + + +@dataclass +class LocationReport: + """Per-location counters for one migration / check pass.""" + + location: str + scanned: int = 0 + migrated: int = 0 # values rewritten to v2 this run + already_v2: int = 0 # values already migrated (skipped) + plaintext: int = 0 # legacy-plaintext values (no ciphertext to migrate) + undecryptable: int = 0 # could not decrypt — preserved, not overwritten + + # Used by --check (read-only classification): + legacy: int = 0 # nacl ciphertext still awaiting migration + + def as_dict(self) -> dict[str, int]: + return { + "scanned": self.scanned, + "migrated": self.migrated, + "already_v2": self.already_v2, + "plaintext": self.plaintext, + "undecryptable": self.undecryptable, + "legacy": self.legacy, + } + + +@dataclass +class MigrationReport: + """Aggregate report across all locations.""" + + locations: list[LocationReport] = field(default_factory=list) + + def add(self, report: LocationReport) -> None: + self.locations.append(report) + + @property + def residual_legacy(self) -> int: + """Total legacy ciphertext still un-migrated (the TRO attestation number).""" + return sum(loc.legacy for loc in self.locations) + + @property + def total_undecryptable(self) -> int: + return sum(loc.undecryptable for loc in self.locations) + + def as_dict(self) -> dict[str, object]: + return { + "residual_legacy": self.residual_legacy, + "total_undecryptable": self.total_undecryptable, + "locations": {loc.location: loc.as_dict() for loc in self.locations}, + } + + +# --------------------------------------------------------------------------- +# Pure engine — no DB I/O, fully unit-testable. +# --------------------------------------------------------------------------- + + +def is_migrated(value: object) -> bool: + """True if ``value`` is already an AES-256-GCM (``v2:gcm:``) ciphertext.""" + return isinstance(value, str) and value.startswith(_V2_GCM_PREFIX) + + +def classify_value(value: object, key: str = "scan") -> ValueClass: + """Classify a stored value for the residual scanner. + + * ``not-a-string`` — not a string (numbers/bools/None left as-is on disk). + * ``migrated`` — carries the ``v2:gcm:`` prefix. + * ``legacy`` — decrypts under the legacy nacl reader (still needs migrating). + * ``plaintext`` — a non-empty string that does not decrypt and is not v2; + treated as legacy plaintext (nothing to migrate). + * ``undecryptable`` — reserved for callers that already know a value is + ciphertext but cannot decrypt it; ``classify_value`` itself cannot tell a + corrupt ciphertext from plaintext, so it returns ``plaintext`` for both. + """ + if not isinstance(value, str): + return "not-a-string" + if value == "": + return "plaintext" + if value.startswith(_V2_GCM_PREFIX): + return "migrated" + decrypted = decrypt_value_helper( + value=value, key=key, exception_type="debug", return_original_value=False + ) + if decrypted is None: + # Did not decrypt under nacl and has no v2 marker: legacy plaintext. + return "plaintext" + return "legacy" + + +def reencrypt_value(value: object, key: str = "migrate") -> object: + """Re-encrypt a single stored string into the configured (AES) format. + + Returns the value unchanged if it is not a string, is already ``v2:``, or + cannot be decrypted (skip-on-undecryptable). Otherwise decrypts under the + format-detecting reader and re-encrypts through ``encrypt_value_helper`` + (which writes AES when the gate is on). + """ + if not isinstance(value, str) or value == "": + return value + if value.startswith(_V2_GCM_PREFIX): + return value # idempotent: already migrated + decrypted = decrypt_value_helper( + value=value, key=key, exception_type="debug", return_original_value=False + ) + if decrypted is None: + # Either legacy plaintext (no ciphertext to migrate) or corrupt. Either + # way, do not overwrite — preserve the value as stored. + return value + return encrypt_value_helper(decrypted) + + +def reencrypt_selective_dict( + data: dict[str, object], sensitive_keys: list[str] +) -> dict[str, object]: + """Return a copy of ``data`` with only ``sensitive_keys`` re-encrypted. + + Non-sensitive fields (e.g. ``base_url``, ``connection_id``) are left as-is. + Null/missing fields are skipped. + """ + out = dict(data) + for k in sensitive_keys: + v = out.get(k) + if v is None: + continue + out[k] = reencrypt_value(v, key=k) + return out + + +def _assert_aes_gate_enabled() -> None: + """Fail fast if the AES algorithm gate is not enabled. + + Running the migration with the gate off would decrypt then re-encrypt right + back into the legacy format — a no-op that silently fails the migration. + """ + from litellm.proxy.proxy_server import general_settings + + algo = general_settings.get(_ENCRYPTION_ALGORITHM_SETTING) + if not (isinstance(algo, str) and algo.lower() == _ALGO_AES_GCM): + raise RuntimeError( + "Encryption migration requires general_settings.encryption_algorithm: " + f"'{_ALGO_AES_GCM}'. Current value: {algo!r}. Set it before migrating " + "so re-encrypted values are written in the AES-256-GCM format." + ) + + +# --------------------------------------------------------------------------- +# Walkers for the locations with no pre-existing rotation path. +# Each walker delegates the structural transform to the existing, tested helper +# for that table and only adds the per-row re-encrypt + commit + counters. +# --------------------------------------------------------------------------- + + +async def _migrate_config_settings_row( + prisma_client: object, + param_name: str, + sensitive_fields: list[str], + dry_run: bool, +) -> LocationReport: + """Migrate a single ``LiteLLM_Config`` row whose ``param_value`` is a JSON + dict with selected sensitive fields (vantage_settings / cloudzero_settings). + """ + report = LocationReport(location=param_name) + record = await prisma_client.db.litellm_config.find_unique( + where={"param_name": param_name} + ) + if record is None or record.param_value is None: + return report + + settings = record.param_value + if isinstance(settings, str): + settings = json.loads(settings) + if not isinstance(settings, dict): + return report + + changed = False + for fld in sensitive_fields: + v = settings.get(fld) + if v is None: + continue + report.scanned += 1 + cls = classify_value(v, key=fld) + if cls == "migrated": + report.already_v2 += 1 + continue + if cls == "legacy": + if dry_run: + # Residual: would migrate, but a dry run writes nothing, so it + # stays legacy for the attestation (never counted as migrated). + report.legacy += 1 + continue + new_v = reencrypt_value(v, key=fld) + if new_v != v: + settings[fld] = new_v + report.migrated += 1 + changed = True + else: + # Defensive: a legacy value that did not re-encrypt is still + # residual, not migrated. + report.legacy += 1 + else: # plaintext / not-a-string — nothing to migrate + report.plaintext += 1 + + if changed and not dry_run: + await prisma_client.db.litellm_config.update( + where={"param_name": param_name}, + data={"param_value": json.dumps(settings)}, + ) + return report + + +async def _migrate_sso_config(prisma_client: object, dry_run: bool) -> LocationReport: + """Migrate the ``LiteLLM_SSOConfig`` row. All non-null fields are encrypted + (via the same ``_encrypt_env_variables`` path used on save), so we re-encrypt + every present string field. + """ + report = LocationReport(location="sso_config") + record = await prisma_client.db.litellm_ssoconfig.find_unique( + where={"id": "sso_config"} + ) + if record is None or record.sso_settings is None: + return report + + settings = record.sso_settings + if isinstance(settings, str): + settings = json.loads(settings) + if not isinstance(settings, dict): + return report + + new_settings = dict(settings) + changed = False + for fld, v in settings.items(): + if not isinstance(v, str) or v == "": + continue + report.scanned += 1 + cls = classify_value(v, key=fld) + if cls == "migrated": + report.already_v2 += 1 + continue + if cls == "legacy": + if dry_run: + # Residual: would migrate, but a dry run writes nothing, so it + # stays legacy for the attestation (never counted as migrated). + report.legacy += 1 + continue + new_v = reencrypt_value(v, key=fld) + if new_v != v: + new_settings[fld] = new_v + report.migrated += 1 + changed = True + else: + # Defensive: a legacy value that did not re-encrypt is still + # residual, not migrated. + report.legacy += 1 + else: + report.plaintext += 1 + + if changed and not dry_run: + await prisma_client.db.litellm_ssoconfig.update( + where={"id": "sso_config"}, + data={"sso_settings": json.dumps(new_settings)}, + ) + return report + + +async def _migrate_callback_vars_table( + prisma_client: object, + table_name: Literal["team", "verification_token"], + dry_run: bool, +) -> LocationReport: + """Migrate callback-var credentials on the team or verification-token table. + + Covers both shapes the ``decrypt_callback_vars`` / ``encrypt_callback_vars`` + transforms understand: ``metadata.logging[*].callback_vars.`` and + the top-level ``metadata.callback_settings.callback_vars.``. Reuses + those proven transforms (selective, prefix-marked; legacy plaintext is left + alone until re-encrypted). + """ + from litellm.proxy.common_utils.callback_utils import ( + decrypt_callback_vars, + encrypt_callback_vars, + ) + + report = LocationReport(location=f"{table_name}.callback_vars") + + if table_name == "team": + table = prisma_client.db.litellm_teamtable + pk = "team_id" + else: + table = prisma_client.db.litellm_verificationtoken + pk = "token" + + rows = await table.find_many() + for row in rows or []: + metadata = getattr(row, "metadata", None) + if not isinstance(metadata, dict) or ( + "logging" not in metadata and "callback_settings" not in metadata + ): + continue + + # Classify every callback-var value directly (strip the litellm_enc:: + # marker, then prefix/decrypt-classify), exactly like the covered-table + # scanner. Detecting legacy this way is independent of the AES gate, so + # the check_encryption (dry-run) attestation is correct even when run + # before the gate is enabled -- a re-encrypt-delta heuristic would read + # zero residual here with the gate off. + row_legacy = 0 + for cvs in _iter_callback_var_dicts(metadata): + for v in cvs.values(): + report.scanned += 1 + cls = _classify_callback_value(v) + if cls == "migrated": + report.already_v2 += 1 + elif cls == "legacy": + row_legacy += 1 + else: # plaintext / not-a-string + report.plaintext += 1 + + if row_legacy == 0: + continue # no legacy ciphertext in this row + + if dry_run: + # Residual for the attestation; a dry run writes nothing. + report.legacy += row_legacy + continue + + # Real run: re-encrypt the legacy ciphertext to AES via the proven + # selective transforms and persist. Never drop a row on failure. + try: + re_encrypted = encrypt_callback_vars(decrypt_callback_vars(metadata)) + except Exception as e: # pragma: no cover - defensive; never drop a row + verbose_proxy_logger.warning( + "Skipping %s row %s callback_vars (transform failed): %s", + table_name, + getattr(row, pk, "?"), + str(e), + ) + report.undecryptable += row_legacy + continue + report.migrated += row_legacy + await table.update( + where={pk: getattr(row, pk)}, + data={"metadata": json.dumps(re_encrypted)}, + ) + + return report + + +def _iter_callback_var_dicts(metadata: dict[str, object]): + """Yield each ``callback_vars`` dict in a metadata structure. + + Mirrors ``_transform_callback_vars``: credentials live both under + ``logging[*].callback_vars`` and under the top-level + ``callback_settings.callback_vars``. Counting only the former would let the + walker report success while leaving ``callback_settings`` secrets in legacy + format at rest. + """ + for entry in metadata.get("logging", []) or []: + if isinstance(entry, dict): + cvs = entry.get("callback_vars") + if isinstance(cvs, dict): + yield cvs + callback_settings = metadata.get("callback_settings") + if isinstance(callback_settings, dict): + cvs = callback_settings.get("callback_vars") + if isinstance(cvs, dict): + yield cvs + + +def _classify_callback_value(value: object) -> ValueClass: + """Classify one stored callback-var value, independent of the AES gate. + + Encrypted callback vars carry the ``litellm_enc::`` marker in front of the + ciphertext; strip it, then classify the inner value the same way the + covered-table scanner does (``v2:gcm:`` prefix -> migrated, nacl-decryptable + -> legacy, otherwise plaintext). Detecting legacy by decrypt rather than by a + re-encrypt delta is what makes the ``check_encryption`` attestation correct + even when run with the AES write gate off. + """ + from litellm.proxy.common_utils.callback_utils import ( + _CALLBACK_VAR_ENCRYPTED_PREFIX, + ) + + if not isinstance(value, str): + return "not-a-string" + inner = value + if inner.startswith(_CALLBACK_VAR_ENCRYPTED_PREFIX): + inner = inner[len(_CALLBACK_VAR_ENCRYPTED_PREFIX) :] + return classify_value(inner, key="callback") + + +# --------------------------------------------------------------------------- +# Read-only scanner for the rotation-covered tables. +# +# ``_rotate_master_key`` re-encrypts these tables but returns no counts, so on +# its own it can neither attest residual legacy nor report how many rows it +# migrated. This scanner reads (never writes) the same encrypted columns the +# rotation path touches and classifies every value, giving both the attestation +# coverage and the pre/post counts the rotation path can't supply itself. +# --------------------------------------------------------------------------- + +# (location, prisma db attribute, JSON columns to walk, scalar string columns). +_COVERED_TABLE_SPECS = [ + ("model_table", "litellm_proxymodeltable", ("litellm_params",), ()), + ("credentials", "litellm_credentialstable", ("credential_values",), ()), + ("mcp_server", "litellm_mcpservertable", ("credentials", "env_vars"), ()), + ("mcp_user_credentials", "litellm_mcpusercredentials", (), ("credential_b64",)), + ("mcp_user_env_vars", "litellm_mcpuserenvvars", (), ("values_b64",)), +] + + +def _iter_encrypted_strings(obj: object): + """Yield every string leaf in a nested dict/list/scalar structure. + + Iterative (explicit stack) on purpose: recursion here is banned by the + code-quality recursive-function detector (unbounded nesting has caused CPU + spikes in the past), and an explicit stack walks arbitrary depth safely. + """ + stack: list[object] = [obj] + while stack: + cur = stack.pop() + if isinstance(cur, str): + yield cur + elif isinstance(cur, dict): + stack.extend(cur.values()) + elif isinstance(cur, list): + stack.extend(cur) + + +def _classify_into_report(report: LocationReport, value: str) -> None: + """Classify one stored string and bump the matching read-only counter. + + Only genuine nacl ciphertext lands in ``legacy``; non-secret strings (model + names, base URLs, …) do not decrypt and fall through to ``plaintext``, so + over-scanning a column is harmless to the residual count. + """ + report.scanned += 1 + cls = classify_value(value, key="scan") + if cls == "migrated": + report.already_v2 += 1 + elif cls == "legacy": + report.legacy += 1 + else: # plaintext / not-a-string + report.plaintext += 1 + + +async def _scan_one_table( + prisma_client: object, + location: str, + db_attr: str, + json_columns: tuple, + scalar_columns: tuple, +) -> LocationReport: + report = LocationReport(location=location) + table = getattr(prisma_client.db, db_attr, None) + if table is None: + return report + try: + rows = await table.find_many() + except Exception as e: # pragma: no cover - table absent / not migrated + verbose_proxy_logger.debug("scan: %s unavailable: %s", location, str(e)) + return report + for row in rows or []: + for col in json_columns: + raw = getattr(row, col, None) + if raw is None: + continue + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (ValueError, TypeError): + pass + for s in _iter_encrypted_strings(raw): + _classify_into_report(report, s) + for col in scalar_columns: + v = getattr(row, col, None) + if isinstance(v, str): + _classify_into_report(report, v) + return report + + +async def _scan_config_env_vars(prisma_client: object) -> LocationReport: + """Scan the ``environment_variables`` config row (``param_value`` dict).""" + report = LocationReport(location="config_environment_variables") + try: + record = await prisma_client.db.litellm_config.find_unique( + where={"param_name": "environment_variables"} + ) + except Exception as e: # pragma: no cover - defensive + verbose_proxy_logger.debug("scan: config env vars unavailable: %s", str(e)) + return report + if record is None or record.param_value is None: + return report + value = record.param_value + if isinstance(value, str): + try: + value = json.loads(value) + except (ValueError, TypeError): + value = {} + for s in _iter_encrypted_strings(value): + _classify_into_report(report, s) + return report + + +async def _scan_covered_tables(prisma_client: object) -> list[LocationReport]: + """Read-only classification of every rotation-covered table. No writes.""" + reports: list[LocationReport] = [] + for location, db_attr, json_cols, scalar_cols in _COVERED_TABLE_SPECS: + reports.append( + await _scan_one_table( + prisma_client, location, db_attr, json_cols, scalar_cols + ) + ) + reports.append(await _scan_config_env_vars(prisma_client)) + return reports + + +# --------------------------------------------------------------------------- +# Orchestrator +# --------------------------------------------------------------------------- + +# vantage_settings / cloudzero_settings sensitive fields (see *_endpoints.py). +_VANTAGE_SENSITIVE = ["api_key", "integration_token"] +_CLOUDZERO_SENSITIVE = ["api_key"] + + +async def _migrate_covered_tables( + prisma_client: object, user_api_key_dict: object +) -> list[LocationReport]: + """Re-encrypt the tables already covered by ``_rotate_master_key`` (model + table, credentials, MCP credential/env tables, config environment_variables) + by running that orchestrator in *same-key* mode. With the AES gate on, the + re-encrypt writes land in ``v2:`` format. + + ``_rotate_master_key`` returns no counts, so we bracket it with read-only + scans: the pre-scan's legacy total minus the post-scan's gives the number + actually migrated per location, and the post-scan supplies the residual / + already-v2 / scanned figures. Returns one report per covered location. + """ + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _rotate_master_key, + ) + + pre = {r.location: r for r in await _scan_covered_tables(prisma_client)} + + current_key = _get_salt_key() + if current_key is None: + raise RuntimeError( + "Cannot migrate covered tables: no salt key / master key is set. " + "Set LITELLM_SALT_KEY before migrating." + ) + await _rotate_master_key( + prisma_client=cast("PrismaClient", prisma_client), + user_api_key_dict=cast("UserAPIKeyAuth", user_api_key_dict), + current_master_key=current_key, + new_master_key=current_key, # same key, algorithm-only switch + ) + + post = await _scan_covered_tables(prisma_client) + for post_report in post: + pre_report = pre.get(post_report.location) + pre_legacy = pre_report.legacy if pre_report else 0 + # Everything that was legacy before and is no longer legacy now was + # converted this run. + post_report.migrated = max(0, pre_legacy - post_report.legacy) + return post + + +async def migrate_encryption( + prisma_client: object, + user_api_key_dict: object, + dry_run: bool = False, +) -> MigrationReport: + """Run the full at-rest re-encryption migration. + + Requires ``general_settings.encryption_algorithm == 'aes-256-gcm'`` so writes + are produced in the AES format. Idempotent and resumable: re-running skips + already-migrated values and finishes any partial run. + + A ``dry_run`` performs no writes: the covered tables are scanned read-only + (so their residual legacy still counts toward the attestation) and the + net-new walkers run in dry-run mode. + """ + _assert_aes_gate_enabled() + + report = MigrationReport() + + # Tables that already have a rotation path (items 1, 2, 5-10). On a real run + # delegate to the rotation path (with bracketing scans for counts); on a dry + # run only classify them read-only. + if dry_run: + for covered in await _scan_covered_tables(prisma_client): + report.add(covered) + else: + for covered in await _migrate_covered_tables(prisma_client, user_api_key_dict): + report.add(covered) + + # Net-new walkers (items 3, 4, 11, 12, 13). + report.add(await _migrate_callback_vars_table(prisma_client, "team", dry_run)) + report.add( + await _migrate_callback_vars_table(prisma_client, "verification_token", dry_run) + ) + report.add( + await _migrate_config_settings_row( + prisma_client, "vantage_settings", _VANTAGE_SENSITIVE, dry_run + ) + ) + report.add( + await _migrate_config_settings_row( + prisma_client, "cloudzero_settings", _CLOUDZERO_SENSITIVE, dry_run + ) + ) + report.add(await _migrate_sso_config(prisma_client, dry_run)) + + return report + + +async def check_encryption(prisma_client: object) -> MigrationReport: + """Read-only residual scan across **every** at-rest location. No writes. + + Covers both the rotation-managed tables (model / credentials / MCP credential + and env-var tables / config ``environment_variables``) and the net-new walker + locations (team and verification-token ``callback_vars``, vantage / cloudzero + config rows, SSO config). Reports how many values are still ``legacy``; + ``residual_legacy == 0`` across this full scan is the compliance attestation. + """ + report = MigrationReport() + + # Rotation-covered tables (read-only classification). + for covered in await _scan_covered_tables(prisma_client): + report.add(covered) + + # Net-new walker locations, in dry-run (read-only) mode. + report.add(await _migrate_callback_vars_table(prisma_client, "team", dry_run=True)) + report.add( + await _migrate_callback_vars_table( + prisma_client, "verification_token", dry_run=True + ) + ) + report.add( + await _migrate_config_settings_row( + prisma_client, "vantage_settings", _VANTAGE_SENSITIVE, dry_run=True + ) + ) + report.add( + await _migrate_config_settings_row( + prisma_client, "cloudzero_settings", _CLOUDZERO_SENSITIVE, dry_run=True + ) + ) + report.add(await _migrate_sso_config(prisma_client, dry_run=True)) + return report diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 2f5c38b0131..15e228a1a9f 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -4091,6 +4091,86 @@ async def _rotate_master_key( verbose_proxy_logger.debug(f"Successfully re-encrypted {len(credentials)} credentials with new master key") +def _require_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None: + from litellm.proxy._types import CommonProxyErrors + + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value: + raise HTTPException( + status_code=403, + detail={"error": CommonProxyErrors.not_allowed_access.value}, + ) + + +@router.post( + "/credentials/migrate-encryption", + tags=["credential management"], + dependencies=[Depends(user_api_key_auth)], +) +async def migrate_encryption_endpoint( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + dry_run: bool = Query( + False, + description="If true, scan and report without writing any changes.", + ), +): + """ + Re-encrypt all at-rest credentials into the AES-256-GCM (``v2:gcm:``) format. + + Admin only. Requires ``general_settings.encryption_algorithm: aes-256-gcm``. + Idempotent and resumable — re-running skips already-migrated values. Pass + ``dry_run=true`` for a non-mutating scan (equivalent to ``--check``). + """ + from litellm.proxy._types import CommonProxyErrors + from litellm.proxy.management_endpoints.credential_migration import ( + migrate_encryption, + ) + from litellm.proxy.proxy_server import prisma_client + + _require_proxy_admin(user_api_key_dict) + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + report = await migrate_encryption( + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + dry_run=dry_run, + ) + return {"status": "success", "dry_run": dry_run, "report": report.as_dict()} + + +@router.get( + "/credentials/migrate-encryption/check", + tags=["credential management"], + dependencies=[Depends(user_api_key_auth)], +) +async def check_encryption_endpoint( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Read-only residual scan for compliance attestation. Reports how many at-rest + values are still in the legacy format. ``residual_legacy == 0`` attests no + legacy ciphertext remains. Admin only; performs no writes. + """ + from litellm.proxy._types import CommonProxyErrors + from litellm.proxy.management_endpoints.credential_migration import ( + check_encryption, + ) + from litellm.proxy.proxy_server import prisma_client + + _require_proxy_admin(user_api_key_dict) + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + report = await check_encryption(prisma_client=prisma_client) + return {"status": "success", "report": report.as_dict()} + + async def get_new_token(data: Optional[RegenerateKeyRequest]) -> str: if data and data.new_key is not None: # Reject custom key values if disabled by admin diff --git a/tests/proxy_behavior/management/test_credential_migration_endpoint.py b/tests/proxy_behavior/management/test_credential_migration_endpoint.py new file mode 100644 index 00000000000..b0428195674 --- /dev/null +++ b/tests/proxy_behavior/management/test_credential_migration_endpoint.py @@ -0,0 +1,71 @@ +"""Behavior scenarios for the credential re-encryption migration endpoints. + +These run against the live ASGI app + DB. The migration POST is *not* exercised +end-to-end here because it mutates shared at-rest data (it delegates to the +master-key rotation path); that full flow is covered by the unit suite and a +live proxy run. Here we pin the HTTP-boundary contract: the read-only check is +admin-reachable, and both routes are admin-gated. + +Both routes are admin-only management routes, so a non-admin key is rejected by +the ``user_api_key_auth`` layer (401) before the endpoint's own admin guard runs +-- the negative scenarios assert that framework-level rejection. +""" + +import pytest + +from .conftest import MASTER_KEY + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +async def test_migrate_encryption_check_as_admin_is_read_only(proxy_client): + """GET /credentials/migrate-encryption/check returns a residual report (no writes).""" + resp = await proxy_client.get( + "/credentials/migrate-encryption/check", + headers={"Authorization": f"Bearer {MASTER_KEY}"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["status"] == "success" + assert "residual_legacy" in body["report"] + + +async def test_migrate_encryption_check_requires_admin(proxy_client, scratch): + """A non-admin key cannot reach the residual scan (auth layer rejects, 401).""" + gen = await proxy_client.post( + "/key/generate", + headers={"Authorization": f"Bearer {MASTER_KEY}"}, + json={"key_alias": scratch.tag("check"), "user_id": scratch.tag("check-user")}, + ) + assert gen.status_code == 200, gen.text + nonadmin_key = gen.json()["key"] + + resp = await proxy_client.get( + "/credentials/migrate-encryption/check", + headers={"Authorization": f"Bearer {nonadmin_key}"}, + ) + assert resp.status_code == 401, resp.text + + +async def test_migrate_encryption_requires_admin(proxy_client, scratch): + """A non-admin key cannot trigger the migration (auth layer rejects, 401). + + Rejection happens before any write: the admin-only route check fires in + ``user_api_key_auth``, ahead of the endpoint body. + """ + gen = await proxy_client.post( + "/key/generate", + headers={"Authorization": f"Bearer {MASTER_KEY}"}, + json={ + "key_alias": scratch.tag("migrate"), + "user_id": scratch.tag("migrate-user"), + }, + ) + assert gen.status_code == 200, gen.text + nonadmin_key = gen.json()["key"] + + resp = await proxy_client.post( + "/credentials/migrate-encryption", + headers={"Authorization": f"Bearer {nonadmin_key}"}, + ) + assert resp.status_code == 401, resp.text diff --git a/tests/test_litellm/proxy/client/cli/test_encryption_commands.py b/tests/test_litellm/proxy/client/cli/test_encryption_commands.py new file mode 100644 index 00000000000..43e53cf5be2 --- /dev/null +++ b/tests/test_litellm/proxy/client/cli/test_encryption_commands.py @@ -0,0 +1,71 @@ +"""CLI tests for the ``litellm-proxy encryption migrate`` command. + +The HTTP client is mocked, so these assert the command's request routing (GET +check vs POST migrate, dry-run param) and its residual-state messaging without a +live proxy. +""" + +import pytest +from click.testing import CliRunner + +from litellm.proxy.client.cli import main as cli_main +from litellm.proxy.client.cli.commands import encryption as enc_cli + + +class _FakeHTTPClient: + """Stand-in for HTTPClient: records the last request and returns a canned body.""" + + last = None + response = {"status": "success", "report": {"residual_legacy": 0, "locations": {}}} + + def __init__(self, base_url, api_key): + self.base_url = base_url + self.api_key = api_key + + def request(self, method, path, **kwargs): + _FakeHTTPClient.last = {"method": method, "path": path, **kwargs} + return _FakeHTTPClient.response + + +@pytest.fixture +def runner(monkeypatch): + _FakeHTTPClient.last = None + _FakeHTTPClient.response = { + "status": "success", + "report": {"residual_legacy": 0, "locations": {}}, + } + monkeypatch.setattr(enc_cli, "HTTPClient", _FakeHTTPClient) + return CliRunner() + + +def test_migrate_check_hits_check_route(runner): + result = runner.invoke(cli_main.cli, ["encryption", "migrate", "--check"]) + assert result.exit_code == 0, result.output + assert _FakeHTTPClient.last["method"] == "GET" + assert _FakeHTTPClient.last["path"] == "/credentials/migrate-encryption/check" + assert "No legacy values remaining" in result.output + + +def test_migrate_default_posts_without_dry_run(runner): + result = runner.invoke(cli_main.cli, ["encryption", "migrate"]) + assert result.exit_code == 0, result.output + assert _FakeHTTPClient.last["method"] == "POST" + assert _FakeHTTPClient.last["path"] == "/credentials/migrate-encryption" + assert _FakeHTTPClient.last["params"] is None + + +def test_migrate_dry_run_sets_param(runner): + result = runner.invoke(cli_main.cli, ["encryption", "migrate", "--dry-run"]) + assert result.exit_code == 0, result.output + assert _FakeHTTPClient.last["method"] == "POST" + assert _FakeHTTPClient.last["params"] == {"dry_run": "true"} + + +def test_migrate_reports_residual_legacy(runner): + _FakeHTTPClient.response = { + "status": "success", + "report": {"residual_legacy": 3, "locations": {}}, + } + result = runner.invoke(cli_main.cli, ["encryption", "migrate", "--check"]) + assert result.exit_code == 0, result.output + assert "Residual legacy values remaining: 3" in result.output diff --git a/tests/test_litellm/proxy/common_utils/test_encrypt_decrypt_utils.py b/tests/test_litellm/proxy/common_utils/test_encrypt_decrypt_utils.py new file mode 100644 index 00000000000..bee39e01dd6 --- /dev/null +++ b/tests/test_litellm/proxy/common_utils/test_encrypt_decrypt_utils.py @@ -0,0 +1,148 @@ +""" +Tests for the at-rest credential encryption chokepoint. + +Covers the AES-256-GCM (``v2:gcm:``) path, the ``encryption_algorithm`` config +gate, and the backward-compatibility guarantees that let legacy XSalsa20-Poly1305 +(nacl) ciphertext and new AES values coexist and decrypt correctly. +""" + +import pytest + +from litellm.proxy import proxy_server +from litellm.proxy.common_utils.encrypt_decrypt_utils import ( + _V2_GCM_PREFIX, + decrypt_value_helper, + encrypt_value_helper, +) + + +def _use_aes(monkeypatch): + """Flip the write-time algorithm to AES-256-GCM for the duration of a test.""" + monkeypatch.setattr( + proxy_server, "general_settings", {"encryption_algorithm": "aes-256-gcm"} + ) + + +@pytest.fixture(autouse=True) +def _salt_key(monkeypatch): + # Dominant convention in the test_litellm/ tree: set the key via env. + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-aes-1234") + # Ensure the legacy default is in force unless a test opts into AES. + monkeypatch.setattr(proxy_server, "general_settings", {}) + yield + + +def test_aes_gcm_round_trip(monkeypatch): + """A value written under AES-256-GCM is tagged v2:gcm: and decrypts back.""" + _use_aes(monkeypatch) + + ct = encrypt_value_helper("super-secret") + + assert ct.startswith(_V2_GCM_PREFIX) + assert decrypt_value_helper(ct, key="t") == "super-secret" + + +def test_default_is_legacy_algorithm(monkeypatch): + """With no config, writes stay on the legacy algorithm (no v2: marker).""" + ct = encrypt_value_helper("legacy-secret") + + assert not ct.startswith(_V2_GCM_PREFIX) + assert decrypt_value_helper(ct, key="t") == "legacy-secret" + + +def test_legacy_nacl_value_still_decrypts_after_flag_flip(monkeypatch): + """A value written under the old algorithm decrypts unchanged once AES is on. + + This is the mixed-format readback guarantee: decrypt is format-detecting, so + flipping the flag forward never strands previously-written data. + """ + legacy = encrypt_value_helper("legacy-secret") # default = xsalsa20 + assert not legacy.startswith(_V2_GCM_PREFIX) + + _use_aes(monkeypatch) + # New writes are now AES, but the old value must still come back. + assert decrypt_value_helper(legacy, key="t") == "legacy-secret" + assert encrypt_value_helper("fresh").startswith(_V2_GCM_PREFIX) + + +def test_v2_prefix_is_idempotent_marker(monkeypatch): + """The migration's skip-check: an already-v2 value is recognized by its prefix. + + Re-encrypting an AES value yields a fresh (different nonce) AES value, but the + prefix is what lets a migration skip already-migrated rows without decrypting. + """ + _use_aes(monkeypatch) + + ct = encrypt_value_helper("secret") + assert ct.startswith(_V2_GCM_PREFIX) + + # Round-tripping does not change the plaintext, and the marker is stable. + again = encrypt_value_helper(decrypt_value_helper(ct, key="t")) + assert again.startswith(_V2_GCM_PREFIX) + assert decrypt_value_helper(again, key="t") == "secret" + + +def test_aes_decrypt_failure_returns_none_not_raise(monkeypatch): + """Decrypt contract preserved: a garbled v2 value returns None, never raises.""" + _use_aes(monkeypatch) + + garbled = _V2_GCM_PREFIX + "not-valid-base64-or-ciphertext!!!" + # exception_type="debug" exercises the swallow path; must not raise. + assert decrypt_value_helper(garbled, key="t", exception_type="debug") is None + + +def test_aes_decrypt_failure_returns_original_when_requested(monkeypatch): + """With return_original_value=True a bad v2 value comes back as-is, not None.""" + _use_aes(monkeypatch) + + garbled = _V2_GCM_PREFIX + "###" + assert ( + decrypt_value_helper( + garbled, key="t", exception_type="debug", return_original_value=True + ) + == garbled + ) + + +def test_empty_string_round_trips_under_aes(monkeypatch): + """Empty string is preserved through the AES path (parity with legacy).""" + _use_aes(monkeypatch) + + ct = encrypt_value_helper("") + assert ct.startswith(_V2_GCM_PREFIX) + assert decrypt_value_helper(ct, key="t") == "" + + +def test_callback_prefix_composes_with_v2(monkeypatch): + """litellm_enc:: + v2:gcm:... round-trips through the callback read path. + + Callback vars are stored as ``litellm_enc::``; the read path + strips ``litellm_enc::`` then calls the helper, so the value handed to the + helper is ``v2:gcm:...``. Ordering must work end to end. + """ + from litellm.proxy.common_utils.callback_utils import ( + _CALLBACK_VAR_ENCRYPTED_PREFIX, + _decrypt_or_passthrough, + _encrypt_if_plaintext, + ) + + _use_aes(monkeypatch) + + # "gcs_path_service_account" is a known-sensitive callback key. + stored = _encrypt_if_plaintext("gcs_path_service_account", "my-sa-secret") + + assert stored.startswith(_CALLBACK_VAR_ENCRYPTED_PREFIX) + inner = stored[len(_CALLBACK_VAR_ENCRYPTED_PREFIX) :] + assert inner.startswith(_V2_GCM_PREFIX) + assert _decrypt_or_passthrough("gcs_path_service_account", stored) == "my-sa-secret" + + +def test_unknown_algorithm_falls_back_to_legacy(monkeypatch): + """An unrecognized encryption_algorithm value does not produce v2 writes.""" + monkeypatch.setattr( + proxy_server, "general_settings", {"encryption_algorithm": "rot13"} + ) + + ct = encrypt_value_helper("secret") + assert not ct.startswith(_V2_GCM_PREFIX) + assert decrypt_value_helper(ct, key="t") == "secret" diff --git a/tests/test_litellm/proxy/management_endpoints/test_credential_migration.py b/tests/test_litellm/proxy/management_endpoints/test_credential_migration.py new file mode 100644 index 00000000000..81226981089 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_credential_migration.py @@ -0,0 +1,516 @@ +""" +Tests for the at-rest credential re-encryption migration engine. + +The pure engine (classify / reencrypt / selective-dict) is tested directly; the +DB walkers are tested against an AsyncMock Prisma client. Live end-to-end +proof-of-fix (real proxy + DB) is performed separately on the repro server. +""" + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from litellm.proxy import proxy_server +from litellm.proxy.common_utils.encrypt_decrypt_utils import ( + _V2_GCM_PREFIX, + encrypt_value_helper, +) +from litellm.proxy.management_endpoints import credential_migration as cm + + +@pytest.fixture +def salt_key(monkeypatch): + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-migration-salt-1234") + monkeypatch.setattr(proxy_server, "general_settings", {}) + return "sk-migration-salt-1234" + + +def _legacy_ct(value: str, monkeypatch) -> str: + """Produce a legacy (nacl) ciphertext with the AES gate off.""" + monkeypatch.setattr(proxy_server, "general_settings", {}) + return encrypt_value_helper(value) + + +def _enable_aes(monkeypatch): + monkeypatch.setattr( + proxy_server, "general_settings", {"encryption_algorithm": "aes-256-gcm"} + ) + + +def _empty_covered_tables(client): + """Wire every rotation-covered table on `client` to return no rows. + + Lets a `check_encryption` / scanner test isolate the location under test + without the other covered tables raising on an unconfigured mock. + """ + for _, db_attr, _, _ in cm._COVERED_TABLE_SPECS: + getattr(client.db, db_attr).find_many = AsyncMock(return_value=[]) + + +# --------------------------- pure engine --------------------------- + + +def test_classify_value(salt_key, monkeypatch): + legacy = _legacy_ct("secret", monkeypatch) + _enable_aes(monkeypatch) + migrated = encrypt_value_helper("secret") + + assert cm.classify_value(legacy) == "legacy" + assert cm.classify_value(migrated) == "migrated" + assert cm.classify_value("just-plaintext") == "plaintext" + assert cm.classify_value("") == "plaintext" + assert cm.classify_value(123) == "not-a-string" + assert cm.classify_value(None) == "not-a-string" + + +def test_is_migrated(salt_key, monkeypatch): + _enable_aes(monkeypatch) + assert cm.is_migrated(encrypt_value_helper("x")) is True + assert cm.is_migrated("plaintext") is False + assert cm.is_migrated(5) is False + + +def test_reencrypt_value_legacy_to_v2(salt_key, monkeypatch): + legacy = _legacy_ct("secret", monkeypatch) + _enable_aes(monkeypatch) + + out = cm.reencrypt_value(legacy) + assert out != legacy + assert out.startswith(_V2_GCM_PREFIX) + + +def test_reencrypt_value_is_idempotent(salt_key, monkeypatch): + _enable_aes(monkeypatch) + v2 = encrypt_value_helper("secret") + # Already v2 -> returned byte-for-byte unchanged (no re-wrap). + assert cm.reencrypt_value(v2) == v2 + + +def test_reencrypt_value_preserves_non_string_and_empty(salt_key, monkeypatch): + _enable_aes(monkeypatch) + assert cm.reencrypt_value(42) == 42 + assert cm.reencrypt_value("") == "" + assert cm.reencrypt_value(None) is None + + +def test_reencrypt_value_skips_undecryptable(salt_key, monkeypatch): + """A value that does not decrypt (legacy plaintext or corrupt) is preserved.""" + _enable_aes(monkeypatch) + plaintext = "not-actually-encrypted" + assert cm.reencrypt_value(plaintext) == plaintext + + +def test_reencrypt_selective_dict(salt_key, monkeypatch): + legacy_key = _legacy_ct("the-api-key", monkeypatch) + _enable_aes(monkeypatch) + + data = {"api_key": legacy_key, "base_url": "https://x", "integration_token": None} + out = cm.reencrypt_selective_dict(data, ["api_key", "integration_token"]) + + assert out["api_key"].startswith(_V2_GCM_PREFIX) + assert out["base_url"] == "https://x" # untouched non-sensitive + assert out["integration_token"] is None # null skipped + + +# --------------------------- gate enforcement --------------------------- + + +@pytest.mark.asyncio +async def test_migrate_requires_aes_gate(salt_key, monkeypatch): + monkeypatch.setattr(proxy_server, "general_settings", {}) # gate off + with pytest.raises(RuntimeError, match="encryption_algorithm"): + await cm.migrate_encryption( + prisma_client=MagicMock(), user_api_key_dict=MagicMock() + ) + + +# --------------------------- config-row walker --------------------------- + + +def _config_prisma(record): + """Build an AsyncMock prisma client whose litellm_config returns `record`.""" + client = MagicMock() + client.db.litellm_config.find_unique = AsyncMock(return_value=record) + client.db.litellm_config.update = AsyncMock() + return client + + +@pytest.mark.asyncio +async def test_vantage_walker_migrates_legacy_field(salt_key, monkeypatch): + legacy_api_key = _legacy_ct("vantage-secret", monkeypatch) + _enable_aes(monkeypatch) + record = SimpleNamespace( + param_value={ + "api_key": legacy_api_key, + "integration_token": None, + "base_url": "https://api.vantage.sh", + } + ) + client = _config_prisma(record) + + report = await cm._migrate_config_settings_row( + client, "vantage_settings", cm._VANTAGE_SENSITIVE, dry_run=False + ) + + assert report.migrated == 1 + assert report.legacy == 0 # migrated -> no longer residual legacy + client.db.litellm_config.update.assert_awaited_once() + written = json.loads( + client.db.litellm_config.update.call_args.kwargs["data"]["param_value"] + ) + assert written["api_key"].startswith(_V2_GCM_PREFIX) + assert written["base_url"] == "https://api.vantage.sh" # non-sensitive untouched + + +@pytest.mark.asyncio +async def test_vantage_walker_idempotent_no_write(salt_key, monkeypatch): + _enable_aes(monkeypatch) + record = SimpleNamespace( + param_value={"api_key": encrypt_value_helper("already-v2"), "base_url": "x"} + ) + client = _config_prisma(record) + + report = await cm._migrate_config_settings_row( + client, "vantage_settings", cm._VANTAGE_SENSITIVE, dry_run=False + ) + + assert report.already_v2 == 1 + assert report.migrated == 0 + client.db.litellm_config.update.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_config_walker_dry_run_does_not_write(salt_key, monkeypatch): + legacy_api_key = _legacy_ct("vantage-secret", monkeypatch) + _enable_aes(monkeypatch) + record = SimpleNamespace(param_value={"api_key": legacy_api_key}) + client = _config_prisma(record) + + report = await cm._migrate_config_settings_row( + client, "vantage_settings", cm._VANTAGE_SENSITIVE, dry_run=True + ) + + # A dry run reports residual legacy only; nothing is migrated (no write), so + # `migrated` and `residual_legacy` are never contradictory in --check output. + assert report.legacy == 1 + assert report.migrated == 0 + client.db.litellm_config.update.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_config_walker_handles_missing_row(salt_key, monkeypatch): + _enable_aes(monkeypatch) + client = _config_prisma(None) + report = await cm._migrate_config_settings_row( + client, "cloudzero_settings", cm._CLOUDZERO_SENSITIVE, dry_run=False + ) + assert report.scanned == 0 + client.db.litellm_config.update.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_sso_walker_real_run_migrates_and_clears_residual(salt_key, monkeypatch): + """SSO real run: a migrated field is counted as migrated, not residual legacy.""" + legacy = _legacy_ct("client-secret", monkeypatch) + _enable_aes(monkeypatch) + record = SimpleNamespace(sso_settings={"client_secret": legacy, "client_id": "id"}) + client = MagicMock() + client.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=record) + client.db.litellm_ssoconfig.update = AsyncMock() + + report = await cm._migrate_sso_config(client, dry_run=False) + + assert report.migrated == 1 + assert report.legacy == 0 # migrated -> no longer residual + client.db.litellm_ssoconfig.update.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_sso_walker_dry_run_reports_residual_not_migrated(salt_key, monkeypatch): + """SSO dry run: residual legacy only; migrated stays 0 (never contradictory).""" + legacy = _legacy_ct("client-secret", monkeypatch) + _enable_aes(monkeypatch) + record = SimpleNamespace(sso_settings={"client_secret": legacy}) + client = MagicMock() + client.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=record) + client.db.litellm_ssoconfig.update = AsyncMock() + + report = await cm._migrate_sso_config(client, dry_run=True) + + assert report.legacy == 1 + assert report.migrated == 0 + client.db.litellm_ssoconfig.update.assert_not_awaited() + + +# --------------------------- --check scanner --------------------------- + + +@pytest.mark.asyncio +async def test_check_reports_residual_legacy(salt_key, monkeypatch): + legacy_api_key = _legacy_ct("vantage-secret", monkeypatch) + _enable_aes(monkeypatch) + + client = MagicMock() + # Net-new walker tables: empty team / token / sso, one legacy vantage field. + client.db.litellm_teamtable.find_many = AsyncMock(return_value=[]) + client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + client.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None) + client.db.litellm_config.update = AsyncMock() + _empty_covered_tables(client) + + def _find_unique(where): + if where.get("param_name") == "vantage_settings": + return SimpleNamespace(param_value={"api_key": legacy_api_key}) + return None + + client.db.litellm_config.find_unique = AsyncMock(side_effect=_find_unique) + + report = await cm.check_encryption(client) + + assert report.residual_legacy == 1 + client.db.litellm_config.update.assert_not_awaited() # read-only + + +@pytest.mark.asyncio +async def test_check_reports_zero_after_migration(salt_key, monkeypatch): + _enable_aes(monkeypatch) + client = MagicMock() + client.db.litellm_teamtable.find_many = AsyncMock(return_value=[]) + client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + client.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None) + client.db.litellm_config.update = AsyncMock() + _empty_covered_tables(client) + + def _find_unique(where): + if where.get("param_name") == "vantage_settings": + return SimpleNamespace( + param_value={"api_key": encrypt_value_helper("already-v2")} + ) + return None + + client.db.litellm_config.find_unique = AsyncMock(side_effect=_find_unique) + + report = await cm.check_encryption(client) + assert report.residual_legacy == 0 + + +# --------------------------- callback_vars walker --------------------------- + + +@pytest.mark.asyncio +async def test_callback_vars_walker_migrates_team_metadata(salt_key, monkeypatch): + """A team row with a legacy-encrypted callback var is rewritten to v2.""" + from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars + + # Legacy-encrypt a callback var via the real callback path (gate off). + monkeypatch.setattr(proxy_server, "general_settings", {}) + legacy_meta = encrypt_callback_vars( + {"logging": [{"callback_vars": {"gcs_path_service_account": "sa-secret"}}]} + ) + _enable_aes(monkeypatch) + + team_row = SimpleNamespace(team_id="team-1", metadata=legacy_meta) + client = MagicMock() + client.db.litellm_teamtable.find_many = AsyncMock(return_value=[team_row]) + client.db.litellm_teamtable.update = AsyncMock() + + report = await cm._migrate_callback_vars_table(client, "team", dry_run=False) + + assert report.migrated == 1 + assert report.scanned == 1 # one field examined, not "post-v2" count + client.db.litellm_teamtable.update.assert_awaited_once() + written = json.loads( + client.db.litellm_teamtable.update.call_args.kwargs["data"]["metadata"] + ) + inner = written["logging"][0]["callback_vars"]["gcs_path_service_account"] + assert "v2:gcm:" in inner + + +@pytest.mark.asyncio +async def test_callback_vars_walker_dry_run_reports_legacy(salt_key, monkeypatch): + """In --check (dry-run) mode, a legacy callback var counts as residual legacy.""" + from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars + + monkeypatch.setattr(proxy_server, "general_settings", {}) + legacy_meta = encrypt_callback_vars( + {"logging": [{"callback_vars": {"gcs_path_service_account": "sa-secret"}}]} + ) + _enable_aes(monkeypatch) + + team_row = SimpleNamespace(team_id="team-1", metadata=legacy_meta) + client = MagicMock() + client.db.litellm_teamtable.find_many = AsyncMock(return_value=[team_row]) + client.db.litellm_teamtable.update = AsyncMock() + + report = await cm._migrate_callback_vars_table(client, "team", dry_run=True) + + assert report.scanned == 1 + assert report.legacy == 1 # would-migrate -> residual legacy in attestation + assert report.migrated == 0 + client.db.litellm_teamtable.update.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_callback_vars_walker_migrates_callback_settings_shape( + salt_key, monkeypatch +): + """Regression: credentials under ``metadata.callback_settings.callback_vars`` + with no top-level ``logging`` key must be migrated, not skipped. + + The walker previously early-continued on ``"logging" not in metadata``, so + this credential shape (which ``encrypt_callback_vars`` does encrypt) was left + in legacy format at rest while the migration still reported success. + """ + from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars + + monkeypatch.setattr(proxy_server, "general_settings", {}) + legacy_meta = encrypt_callback_vars( + { + "callback_settings": { + "callback_vars": {"gcs_path_service_account": "sa-secret"} + } + } + ) + _enable_aes(monkeypatch) + assert "logging" not in legacy_meta # the shape that used to be skipped + + team_row = SimpleNamespace(team_id="team-1", metadata=legacy_meta) + client = MagicMock() + client.db.litellm_teamtable.find_many = AsyncMock(return_value=[team_row]) + client.db.litellm_teamtable.update = AsyncMock() + + report = await cm._migrate_callback_vars_table(client, "team", dry_run=False) + + assert report.migrated == 1 + assert report.scanned == 1 + client.db.litellm_teamtable.update.assert_awaited_once() + written = json.loads( + client.db.litellm_teamtable.update.call_args.kwargs["data"]["metadata"] + ) + inner = written["callback_settings"]["callback_vars"]["gcs_path_service_account"] + assert "v2:gcm:" in inner + + +@pytest.mark.asyncio +async def test_check_reports_callback_var_legacy_with_gate_off(salt_key, monkeypatch): + """check_encryption must report residual legacy callback vars even when the + AES gate is OFF. + + Detection is decrypt-based, not a re-encrypt delta, so it does not depend on + the write gate. A heuristic that re-encrypts and counts new v2 values would + read zero here (gate off -> no v2 produced) and emit a false-clean + attestation -- exactly the compliance trap this guards against. + """ + from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars + + # Legacy-encrypt a callback var, and leave the gate OFF for the check itself. + monkeypatch.setattr(proxy_server, "general_settings", {}) + legacy_meta = encrypt_callback_vars( + {"logging": [{"callback_vars": {"gcs_path_service_account": "sa-secret"}}]} + ) + team_row = SimpleNamespace(team_id="team-1", metadata=legacy_meta) + + client = MagicMock() + _empty_covered_tables(client) + client.db.litellm_teamtable.find_many = AsyncMock(return_value=[team_row]) + client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + client.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None) + client.db.litellm_config.find_unique = AsyncMock(return_value=None) + client.db.litellm_teamtable.update = AsyncMock() + + report = await cm.check_encryption(client) + + assert report.residual_legacy == 1 + assert report.as_dict()["locations"]["team.callback_vars"]["legacy"] == 1 + client.db.litellm_teamtable.update.assert_not_awaited() # read-only + + +# --------------------------- covered-tables scanner --------------------------- + + +@pytest.mark.asyncio +async def test_scan_covered_tables_classifies_legacy_and_v2(salt_key, monkeypatch): + """The read-only scanner classifies the model and credentials tables.""" + legacy = _legacy_ct("model-secret", monkeypatch) + _enable_aes(monkeypatch) + v2 = encrypt_value_helper("cred-secret") + + client = MagicMock() + _empty_covered_tables(client) + client.db.litellm_proxymodeltable.find_many = AsyncMock( + return_value=[ + SimpleNamespace(litellm_params={"api_key": legacy, "model": "gpt-4"}) + ] + ) + client.db.litellm_credentialstable.find_many = AsyncMock( + return_value=[SimpleNamespace(credential_values={"api_key": v2})] + ) + client.db.litellm_config.find_unique = AsyncMock(return_value=None) + + by_loc = {r.location: r for r in await cm._scan_covered_tables(client)} + + assert by_loc["model_table"].legacy == 1 + assert by_loc["model_table"].plaintext == 1 # "gpt-4" model name, not ciphertext + assert by_loc["credentials"].already_v2 == 1 + assert by_loc["credentials"].legacy == 0 + + +@pytest.mark.asyncio +async def test_check_counts_covered_table_residual(salt_key, monkeypatch): + """check_encryption now scans the rotation-covered tables (model table here), + so a legacy value there counts toward residual_legacy (the P1 attestation gap). + """ + legacy = _legacy_ct("model-secret", monkeypatch) + _enable_aes(monkeypatch) + + client = MagicMock() + _empty_covered_tables(client) + client.db.litellm_teamtable.find_many = AsyncMock(return_value=[]) + client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + client.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None) + client.db.litellm_config.find_unique = AsyncMock(return_value=None) + client.db.litellm_config.update = AsyncMock() + client.db.litellm_proxymodeltable.find_many = AsyncMock( + return_value=[SimpleNamespace(litellm_params={"api_key": legacy})] + ) + + report = await cm.check_encryption(client) + + assert report.residual_legacy == 1 + assert report.as_dict()["locations"]["model_table"]["legacy"] == 1 + client.db.litellm_config.update.assert_not_awaited() # read-only + + +@pytest.mark.asyncio +async def test_migrate_covered_tables_reports_real_counts(salt_key, monkeypatch): + """_migrate_covered_tables derives real per-table counts from pre/post scans, + instead of the always-zero report Greptile flagged (P1). + """ + legacy = _legacy_ct("model-secret", monkeypatch) + _enable_aes(monkeypatch) + v2 = encrypt_value_helper("model-secret") + + row = SimpleNamespace(litellm_params={"api_key": legacy}) + client = MagicMock() + _empty_covered_tables(client) + client.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[row]) + client.db.litellm_config.find_unique = AsyncMock(return_value=None) + + async def fake_rotate(**kwargs): + # Stand in for _rotate_master_key: re-encrypt the model api_key in place. + row.litellm_params["api_key"] = v2 + + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints._rotate_master_key", + fake_rotate, + ) + + by_loc = { + r.location: r for r in await cm._migrate_covered_tables(client, MagicMock()) + } + + assert by_loc["model_table"].migrated == 1 # was legacy pre, v2 post + assert by_loc["model_table"].legacy == 0 # residual zero after rotation + assert by_loc["model_table"].already_v2 == 1 diff --git a/tests/test_litellm/proxy/management_endpoints/test_encryption_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_encryption_endpoints.py new file mode 100644 index 00000000000..e92cb3b13a7 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_encryption_endpoints.py @@ -0,0 +1,100 @@ +"""Unit tests for the at-rest encryption-migration HTTP endpoints. + +The endpoint bodies are exercised directly with the migration engine mocked, so +the admin guard, db-not-connected guard, and success path are all covered +without touching a live DB. The live ASGI/auth contract is covered separately in +``tests/proxy_behavior/management/test_credential_migration_endpoint.py``. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from litellm.proxy import proxy_server +from litellm.proxy._types import LitellmUserRoles +from litellm.proxy.management_endpoints import credential_migration as cm +from litellm.proxy.management_endpoints.key_management_endpoints import ( + check_encryption_endpoint, + migrate_encryption_endpoint, +) + +ADMIN = SimpleNamespace(user_role=LitellmUserRoles.PROXY_ADMIN.value) +NONADMIN = SimpleNamespace(user_role=LitellmUserRoles.INTERNAL_USER.value) + + +def _sample_report() -> cm.MigrationReport: + report = cm.MigrationReport() + report.add( + cm.LocationReport(location="model_table", scanned=2, migrated=1, legacy=0) + ) + return report + + +# ------------------------------- check endpoint ------------------------------- + + +@pytest.mark.asyncio +async def test_check_endpoint_success(monkeypatch): + monkeypatch.setattr(proxy_server, "prisma_client", object()) + monkeypatch.setattr( + cm, "check_encryption", AsyncMock(return_value=_sample_report()) + ) + + out = await check_encryption_endpoint(user_api_key_dict=ADMIN) + + assert out["status"] == "success" + assert out["report"]["residual_legacy"] == 0 + assert out["report"]["locations"]["model_table"]["scanned"] == 2 + + +@pytest.mark.asyncio +async def test_check_endpoint_requires_admin(monkeypatch): + monkeypatch.setattr(proxy_server, "prisma_client", object()) + with pytest.raises(HTTPException) as exc: + await check_encryption_endpoint(user_api_key_dict=NONADMIN) + assert exc.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_check_endpoint_db_not_connected(monkeypatch): + monkeypatch.setattr(proxy_server, "prisma_client", None) + with pytest.raises(HTTPException) as exc: + await check_encryption_endpoint(user_api_key_dict=ADMIN) + assert exc.value.status_code == 500 + + +# ------------------------------ migrate endpoint ------------------------------ + + +@pytest.mark.asyncio +@pytest.mark.parametrize("dry_run", [False, True]) +async def test_migrate_endpoint_success(monkeypatch, dry_run): + monkeypatch.setattr(proxy_server, "prisma_client", object()) + fake = AsyncMock(return_value=_sample_report()) + monkeypatch.setattr(cm, "migrate_encryption", fake) + + out = await migrate_encryption_endpoint(user_api_key_dict=ADMIN, dry_run=dry_run) + + assert out["status"] == "success" + assert out["dry_run"] is dry_run + assert out["report"]["locations"]["model_table"]["migrated"] == 1 + # dry_run is threaded through to the engine unchanged. + assert fake.await_args.kwargs["dry_run"] is dry_run + + +@pytest.mark.asyncio +async def test_migrate_endpoint_requires_admin(monkeypatch): + monkeypatch.setattr(proxy_server, "prisma_client", object()) + with pytest.raises(HTTPException) as exc: + await migrate_encryption_endpoint(user_api_key_dict=NONADMIN) + assert exc.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_migrate_endpoint_db_not_connected(monkeypatch): + monkeypatch.setattr(proxy_server, "prisma_client", None) + with pytest.raises(HTTPException) as exc: + await migrate_encryption_endpoint(user_api_key_dict=ADMIN) + assert exc.value.status_code == 500 diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index dced1dd4eaa..b3c2b44ee4e 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2479,6 +2479,52 @@ export interface paths { patch?: never; trace?: never; }; + "/credentials/migrate-encryption": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Migrate Encryption Endpoint + * @description Re-encrypt all at-rest credentials into the AES-256-GCM (``v2:gcm:``) format. + * + * Admin only. Requires ``general_settings.encryption_algorithm: aes-256-gcm``. + * Idempotent and resumable — re-running skips already-migrated values. Pass + * ``dry_run=true`` for a non-mutating scan (equivalent to ``--check``). + */ + post: operations["migrate_encryption_endpoint_credentials_migrate_encryption_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/credentials/migrate-encryption/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Check Encryption Endpoint + * @description Read-only residual scan for compliance attestation. Reports how many at-rest + * values are still in the legacy format. ``residual_legacy == 0`` attests no + * legacy ciphertext remains. Admin only; performs no writes. + */ + get: operations["check_encryption_endpoint_credentials_migrate_encryption_check_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/credentials/{credential_name}": { parameters: { query?: never; @@ -37122,6 +37168,58 @@ export interface operations { }; }; }; + migrate_encryption_endpoint_credentials_migrate_encryption_post: { + parameters: { + query?: { + /** @description If true, scan and report without writing any changes. */ + dry_run?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + check_encryption_endpoint_credentials_migrate_encryption_check_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; delete_credential_credentials__credential_name__delete: { parameters: { query?: never; From 0e5aee18383709f425c63e5222c22ee002096e3f Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 29 Jun 2026 12:44:31 -0700 Subject: [PATCH 011/110] fix(ui): keep virtual-keys filters across delete and refresh (LIT-4080) (#31533) * fix(ui): keep virtual-keys filters across delete and refresh (LIT-4080) Filtering virtual keys by User ID and then deleting a key reset the filter to show all keys, and re-clicking Fetch did not re-apply it. The page ran two competing fetch paths: useKeys (React Query) fetched the page unfiltered while a separate useFilterLogic hook held its own filteredKeys list and, on any refresh, only re-applied Team and Organization client-side, silently dropping the User ID and Key Alias filters. Delete refreshed through the unfiltered useKeys path, so the filtered view collapsed back to everything VirtualKeysTable now owns its filter state and feeds every filter (team, organization, key alias, user id, key hash) straight into the useKeys options, so the filters are part of the React Query key. Any refetch or invalidation re-runs the same filtered query, which makes the reset-on-delete bug structurally impossible. Free-text inputs are debounced with @tanstack/react-pacer, sorting and pagination are server-side, and changing a filter or sort resets to page 1 Delete now invalidates keyKeys.lists() from key_info_view, matching the create path, instead of prop-drilling a refetch; the window "storage" refetch effect is removed. The dual-path useFilterLogic hook (and its test) are deleted Regression coverage: VirtualKeysTable threads an active User ID filter into the useKeys query and clears it on reset, useKeys encodes filter options in its query key so a filter change refetches, and key_info_view invalidates the keys list on delete * refactor(ui): simplify virtual-keys table data flow VirtualKeysTable now fetches its own teams and organizations via useOrganizations and the existing all-teams query instead of taking them as props, so the prop-drill through UserDashboard and the two page callers (page.tsx, ApiKeysDashboard) is gone along with their redundant organization state and fetch Filter state collapses from a useState plus a useDebouncedState mirror into a single source whose debounced copy is derived with useDebouncedValue, and one typed toKeyListFilters adapter maps it to the key/list query options. Behavior is unchanged; same 300ms debounce and the same reset timing The unused onSortChange/currentSort props and their sync effect are removed since no caller passed them, leaving sorting fully internal Adds a created_by_user alias-over-email regression test that fails if the display precedence is swapped * test(ui): add required last_active to useKeys mock fixtures The KeyResponse type requires last_active, so the typed mockKeys fixtures were missing it. Add it so the file type-checks cleanly. * chore(ui): ratchet lint budgets after virtual-keys refactor Deleting filter_logic.tsx and simplifying VirtualKeysTable lowered the no-explicit-any (2026 to 2016) and complexity (128 to 127) counts, so the eslint-metrics.json baseline was stale and failed the frontend-lint budget gate. Regenerate it, and drop the now-dead filter_logic.tsx suppression entry for the file this PR removed. * fix(ui): show a loading state for data-backed filter dropdowns The Team ID and Organization ID filters source their options from async hooks (teams / organizations). While that data was still loading the dropdowns rendered 'No results found', so they looked empty rather than loading. Add an opt-in loading flag to FilterOption that the searchable select surfaces as a spinner and a 'Loading...' empty state, and wire it from the teams and organizations query loading states. While loading, the filter no longer caches an empty initial-options list, so the real options appear once the data arrives. --- ui/litellm-dashboard/eslint-metrics.json | 4 +- ui/litellm-dashboard/eslint-suppressions.json | 14 - .../api-keys/ApiKeysDashboard.test.tsx | 4 - .../(dashboard)/api-keys/ApiKeysDashboard.tsx | 7 - .../(dashboard)/hooks/keys/useKeys.test.ts | 27 + .../src/app/(dashboard)/page.tsx | 8 +- .../VirtualKeysPage/VirtualKeysTable.test.tsx | 893 +++--------------- .../VirtualKeysPage/VirtualKeysTable.tsx | 180 ++-- .../key_team_helpers/filter_logic.test.tsx | 181 ---- .../key_team_helpers/filter_logic.tsx | 188 ---- .../src/components/molecules/filter.test.tsx | 61 ++ .../src/components/molecules/filter.tsx | 8 +- .../KeyInfoView.handleKeyUpdate.test.tsx | 9 + .../templates/key_info_view.test.tsx | 37 +- .../components/templates/key_info_view.tsx | 4 + .../src/components/user_dashboard.test.tsx | 1 - .../src/components/user_dashboard.tsx | 4 +- 17 files changed, 367 insertions(+), 1263 deletions(-) delete mode 100644 ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index 01c5a241562..deef1136b43 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,5 +1,5 @@ { - "@typescript-eslint/no-explicit-any": 2026, - "complexity": 128, + "@typescript-eslint/no-explicit-any": 2016, + "complexity": 127, "max-depth": 61 } diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 770c953d3f3..5d3a54f9e81 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1133,20 +1133,6 @@ "count": 1 } }, - "src/components/key_team_helpers/filter_logic.tsx": { - "react-hooks/purity": { - "count": 1 - }, - "react-hooks/refs": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 3 - }, - "react-hooks/use-memo": { - "count": 1 - } - }, "src/components/key_team_helpers/key_list.tsx": { "react-hooks/set-state-in-effect": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.test.tsx index 3a3251ea76a..13689afcb52 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.test.tsx @@ -42,10 +42,6 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ teamListCall: vi.fn(() => new Promise(() => {})), })); -vi.mock("@/components/organizations", () => ({ - fetchOrganizations: vi.fn(), -})); - vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(""), })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx index 54f4bf41a21..ae0c443910a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx @@ -3,9 +3,7 @@ import { teamListCall as v2TeamListCall } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { KeyResponse, Team } from "@/components/key_team_helpers/key_list"; -import { Organization } from "@/components/networking"; import { CreateKeyPrefillData } from "@/components/organisms/create_key_button"; -import { fetchOrganizations } from "@/components/organizations"; import UserDashboard from "@/components/user_dashboard"; import { useAuth } from "@/contexts/AuthContext"; import { useSearchParams } from "next/navigation"; @@ -20,7 +18,6 @@ export default function ApiKeysDashboard() { const [teams, setTeams] = useState(null); const [keys, setKeys] = useState([]); - const [organizations, setOrganizations] = useState([]); const [createClicked, setCreateClicked] = useState(false); const autoOpenCreate = searchParams.get("create") === "true"; @@ -77,9 +74,6 @@ export default function ApiKeysDashboard() { .then((response) => setTeams(response.teams ?? [])) .catch(console.error); } - if (accessToken) { - fetchOrganizations(accessToken, setOrganizations); - } }, [accessToken, userID, userRole]); return ( @@ -94,7 +88,6 @@ export default function ApiKeysDashboard() { setUserEmail={setUserEmail} setTeams={setTeams} setKeys={setKeys} - organizations={organizations} addKey={addKey} createClicked={createClicked} autoOpenCreate={autoOpenCreate} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts index 164e393eb9b..8c9b33f2c3e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts @@ -69,6 +69,7 @@ const mockKeys: KeyResponse[] = [ organization_id: null, created_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z", + last_active: null, team_spend: 0, team_alias: "", team_tpm_limit: 0, @@ -125,6 +126,7 @@ const mockKeys: KeyResponse[] = [ organization_id: null, created_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z", + last_active: null, team_spend: 0, team_alias: "test-team", team_tpm_limit: 1000, @@ -460,6 +462,31 @@ describe("useKeys", () => { expect(callUrl).not.toContain("project_id"); }); + // LIT-4080 guard: filter options must be part of the query key, not just the + // queryFn closure. If they were dropped from the key, changing a filter would + // reuse the cached (unfiltered) result and never refetch — exactly the bug + // where deleting a key wiped the active User ID filter. + it("refetches with the new filter when a filter option changes (options are in the query key)", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockKeysResponse, + }); + + const { result, rerender } = renderHook(({ userID }) => useKeys(1, 10, { userID }), { + wrapper, + initialProps: { userID: "user-1" }, + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0][0]).toContain("user_id=user-1"); + + rerender({ userID: "user-2" }); + + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); + expect(mockFetch.mock.calls[1][0]).toContain("user_id=user-2"); + }); + it("should pass agentID filter to the API", async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx index c5d28fab8a0..cb4a4a0de03 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx @@ -4,8 +4,7 @@ import ApiKeysDashboard from "@/app/(dashboard)/api-keys/ApiKeysDashboard"; import { teamListCall as v2TeamListCall } from "@/app/(dashboard)/hooks/teams/useTeams"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { Team } from "@/components/key_team_helpers/key_list"; -import { Organization, proxyBaseUrl } from "@/components/networking"; -import { fetchOrganizations } from "@/components/organizations"; +import { proxyBaseUrl } from "@/components/networking"; import UserDashboard from "@/components/user_dashboard"; import { useAuth } from "@/contexts/AuthContext"; import { @@ -25,7 +24,6 @@ function CreateKeyPageContent() { const [teams, setTeams] = useState(null); const [keys, setKeys] = useState([]); - const [organizations, setOrganizations] = useState([]); const router = useRouter(); const searchParams = useSearchParams()!; @@ -112,9 +110,6 @@ function CreateKeyPageContent() { .then((response) => setTeams(response.teams ?? [])) .catch(console.error); } - if (accessToken) { - fetchOrganizations(accessToken, setOrganizations); - } }, [accessToken, userID, userRole]); if (authLoading || redirectToLogin || isLegacyRedirect) { @@ -135,7 +130,6 @@ function CreateKeyPageContent() { setUserEmail={setUserEmail} setTeams={setTeams} setKeys={setKeys} - organizations={organizations} addKey={addKey} createClicked={createClicked} /> diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index 4002bbd245f..6389b82c9fd 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -1,63 +1,46 @@ import { act, screen, waitFor, fireEvent } from "@testing-library/react"; -import { vi, it, expect, beforeEach, MockedFunction } from "vitest"; +import { vi, it, expect, beforeEach, describe, MockedFunction } from "vitest"; import { renderWithProviders } from "../../../tests/test-utils"; import { VirtualKeysTable } from "./VirtualKeysTable"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; -import { Organization } from "../networking"; import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; -import { useFilterLogic } from "../key_team_helpers/filter_logic"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; -// Mock network calls -vi.mock("./networking", async (importOriginal) => { - const actual = await importOriginal(); +// Resolve debounced values synchronously so an applied filter lands in the useKeys query within the test tick. +vi.mock("@tanstack/react-pacer/debouncer", async () => { + const React = await vi.importActual("react"); return { - ...actual, - userListCall: vi.fn().mockResolvedValue({ - users: [ - { - user_id: "user-1", - user_email: "user@example.com", - user_role: "user", - }, - ], - }), - teamListCall: vi.fn().mockResolvedValue([]), + useDebouncedValue: (value: unknown) => [value, { cancel: vi.fn(), flush: vi.fn() }], + useDebouncedState: (initial: unknown) => { + const [value, setValue] = React.useState(initial); + return [value, setValue, { cancel: vi.fn(), flush: vi.fn() }]; + }, }; }); -// Mock filter helpers -vi.mock("./key_team_helpers/filter_helpers", () => ({ - fetchAllTeams: vi.fn().mockResolvedValue([ - { - team_id: "team-1", - team_alias: "Test Team", - }, - ]), - fetchAllOrganizations: vi.fn().mockResolvedValue([ - { - organization_id: "org-1", - organization_alias: "Test Organization", - }, - ]), +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: vi.fn(() => ({ + accessToken: "test-token", + userId: "test-user", + userRole: "Admin", + premiumUser: true, + token: "test-token", + })), +})); + +vi.mock("../key_team_helpers/filter_helpers", () => ({ + fetchAllTeams: vi.fn().mockResolvedValue([{ team_id: "team-1", team_alias: "Test Team" }]), })); -// Mock useKeys hook vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ useKeys: vi.fn(), + keyKeys: { lists: () => ["keys", "list"] }, })); -// Mock useFilterLogic hook -vi.mock("../key_team_helpers/filter_logic", () => ({ - useFilterLogic: vi.fn(), -})); - -// Mock useTeams hook (used by KeyInfoView) vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ default: vi.fn(), })); -// Mock useOrganizations hook vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ useOrganizations: vi.fn().mockReturnValue({ data: [ @@ -69,15 +52,6 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ }), })); -// Mock fetchTeams to prevent network calls -vi.mock("@/app/(dashboard)/networking", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - fetchTeams: vi.fn().mockResolvedValue([]), - }; -}); - const mockKey: KeyResponse = { token: "sk-1234567890abcdef", token_id: "key-1", @@ -91,6 +65,7 @@ const mockKey: KeyResponse = { config: {}, user_id: "user-1", team_id: "team-1", + project_id: null, max_parallel_requests: 10, metadata: {}, tpm_limit: 1000, @@ -153,68 +128,33 @@ const mockTeam: Team = { created_at: "2024-10-01T10:00:00Z", keys: [], members_with_roles: [], + spend: 0, }; -const mockOrganization: Organization = { - organization_id: "org-1", - organization_alias: "Test Organization", - budget_id: "budget-1", - metadata: {}, - models: ["gpt-3.5-turbo", "gpt-4"], - spend: 100, - model_spend: { "gpt-3.5-turbo": 50, "gpt-4": 50 }, - created_at: "2024-10-01T10:00:00Z", - created_by: "user-1", - updated_at: "2024-11-01T10:00:00Z", - updated_by: "user-1", - litellm_budget_table: {}, - teams: [], - users: [], - members: [], -}; - -// Mock hook implementations const mockUseKeys = useKeys as MockedFunction; -const mockUseFilterLogic = useFilterLogic as MockedFunction; const mockUseTeams = useTeams as MockedFunction; -beforeEach(() => { - // Reset mocks before each test - vi.clearAllMocks(); - - // Setup default mock implementations - mockUseKeys.mockReturnValue({ +const keysResult = (keys: KeyResponse[], data: Partial = {}, extra: Record = {}) => + ({ data: { - keys: [mockKey], - total_count: 1, + keys, + total_count: keys.length, current_page: 1, total_pages: 1, + ...data, } as KeysResponse, isPending: false, isFetching: false, + isError: false, refetch: vi.fn(), - } as any); + ...extra, + }) as any; - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "team-1", - "Organization ID": "org-1", - "Key Alias": "Test Key Alias", - "User ID": "user-1", - "User Email": "user@example.com", - "User Role": "user", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [mockKey], - filteredTotalCount: null, - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), - }); +beforeEach(() => { + vi.clearAllMocks(); + + mockUseKeys.mockReturnValue(keysResult([mockKey])); - // Mock useTeams hook (used by KeyInfoView) mockUseTeams.mockReturnValue({ teams: [mockTeam], setTeams: vi.fn(), @@ -222,33 +162,12 @@ beforeEach(() => { }); it("should render VirtualKeysTable component", () => { - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; - - renderWithProviders(); - + renderWithProviders(); expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); }); it("should display key information correctly", async () => { - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; - - renderWithProviders(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); @@ -258,17 +177,7 @@ it("should display key information correctly", async () => { }); it("should display user email correctly", async () => { - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; - - renderWithProviders(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText("user@example.com")).toBeInTheDocument(); @@ -276,121 +185,36 @@ it("should display user email correctly", async () => { }); it("should show loading message only on initial load (isPending)", () => { - // Mock initial loading state - mockUseKeys.mockReturnValue({ - data: null, - isPending: true, - isFetching: true, - refetch: vi.fn(), - } as any); + mockUseKeys.mockReturnValue(keysResult([], {}, { data: null, isPending: true, isFetching: true })); - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; + renderWithProviders(); - renderWithProviders(); - - // Check that loading message is shown on initial load expect(screen.getByText("🚅 Loading keys...")).toBeInTheDocument(); - - // Check that actual key data is not shown expect(screen.queryByText("Test Key Alias")).not.toBeInTheDocument(); expect(screen.queryByText("Test Team")).not.toBeInTheDocument(); }); -it("should show 'No keys found' message when filteredKeys is empty", () => { - // Mock empty filteredKeys - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [], - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), - }); +it("should show 'No keys found' message when the key list is empty", () => { + mockUseKeys.mockReturnValue(keysResult([])); - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; - - renderWithProviders(); + renderWithProviders(); expect(screen.getByText("No keys found")).toBeInTheDocument(); }); it("should handle models with more than 3 entries to trigger expansion UI", () => { - const keyWithManyModels = { - ...mockKey, - models: ["gpt-3.5-turbo", "gpt-4", "gpt-4-turbo", "claude-3", "claude-3-5-sonnet"], - }; + mockUseKeys.mockReturnValue( + keysResult([{ ...mockKey, models: ["gpt-3.5-turbo", "gpt-4", "gpt-4-turbo", "claude-3", "claude-3-5-sonnet"] }]), + ); - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [keyWithManyModels], - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), - }); + renderWithProviders(); - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; - - renderWithProviders(); - - // This test ensures the ChevronDownIcon import (line 6) is used - // by having a key with > 3 models which triggers the expansion logic - // that uses ChevronDownIcon and ChevronRightIcon expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); }); it("should render table headers correctly", () => { - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; + renderWithProviders(); - renderWithProviders(); - - // Check that main headers are rendered (testing the header.isPlaceholder condition path) expect(screen.getByText("Key ID")).toBeInTheDocument(); expect(screen.getByText("Key Alias")).toBeInTheDocument(); expect(screen.getByText("Team")).toBeInTheDocument(); @@ -399,442 +223,164 @@ it("should render table headers correctly", () => { }); it("should handle column resizing hover events", () => { - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; + renderWithProviders(); - renderWithProviders(); - - // Find a header cell with data-header-id attribute const headerCell = document.querySelector("[data-header-id]") as HTMLElement; - expect(headerCell).toBeInTheDocument(); - // Check that the resizer element exists within the header const resizer = headerCell?.querySelector(".resizer") as HTMLElement; expect(resizer).toBeInTheDocument(); - - // Initially, resizer should have opacity 0 expect(resizer.style.opacity).toBe("0"); - // Simulate mouse enter using fireEvent - should set opacity to 0.5 (lines 612-616) fireEvent.mouseEnter(headerCell); expect(resizer.style.opacity).toBe("0.5"); - // Simulate mouse leave using fireEvent - should set opacity back to 0 (lines 618-622) fireEvent.mouseLeave(headerCell); expect(resizer.style.opacity).toBe("0"); }); it("should open KeyInfoView when clicking on a key ID button", async () => { - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; + renderWithProviders(); - renderWithProviders(); - - // Wait for the table to render await waitFor(() => { expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); }); - // Verify table is visible before clicking - check for table-specific text expect(screen.getByText(/Showing.*results/)).toBeInTheDocument(); - // Find the key ID button (it shows the full token value, truncation is CSS-only) const keyIdButton = screen.getByText("sk-1234567890abcdef"); - expect(keyIdButton).toBeInTheDocument(); - - // Click on the key ID button fireEvent.click(keyIdButton); - // Wait for KeyInfoView to appear - check for unique elements that only exist in KeyInfoView await waitFor(() => { expect(screen.getByText("Back to Keys")).toBeInTheDocument(); - // KeyInfoHeader shows "Created At" metadata label expect(screen.getByText("Created At")).toBeInTheDocument(); }); - // Verify that table-specific elements are no longer visible - // The "Showing X of Y results" text should not be visible when KeyInfoView is open expect(screen.queryByText(/Showing.*results/)).not.toBeInTheDocument(); }); it("should display 'Default Proxy Admin' for user_id when value is 'default_user_id'", async () => { - const keyWithDefaultUserId = { - ...mockKey, - user_id: "default_user_id", - user_email: "", - user: { user_id: "default_user_id", user_email: "", user_alias: null }, - }; + mockUseKeys.mockReturnValue( + keysResult([ + { + ...mockKey, + user_id: "default_user_id", + user_email: "", + user: { user_id: "default_user_id", user_email: "", user_alias: null }, + }, + ]), + ); - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [keyWithDefaultUserId], - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), - }); - - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; - - renderWithProviders(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument(); }); }); -it("should display 'Default Proxy Admin' for created_by when value is 'default_user_id'", async () => { - const keyWithDefaultCreatedBy = { - ...mockKey, - created_by: "default_user_id", - }; - - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [keyWithDefaultCreatedBy], - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), - }); - - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; - - renderWithProviders(); - - await waitFor(() => { - // The created_by column should display "Default Proxy Admin" - const defaultProxyAdminElements = screen.getAllByText("Default Proxy Admin"); - expect(defaultProxyAdminElements.length).toBeGreaterThan(0); - }); -}); - it("should display created_by_user email in 'Created By' column when available", async () => { - const keyWithCreatedByUser = { - ...mockKey, - created_by: "some-uuid-1234", - created_by_user: { - user_id: "some-uuid-1234", - user_email: "creator@example.com", - user_alias: null, - }, - }; + mockUseKeys.mockReturnValue( + keysResult([ + { + ...mockKey, + created_by: "some-uuid-1234", + created_by_user: { user_id: "some-uuid-1234", user_email: "creator@example.com", user_alias: null }, + }, + ]), + ); - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [keyWithCreatedByUser], - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), - }); - - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; - - renderWithProviders(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText("creator@example.com")).toBeInTheDocument(); }); }); -it("should display created_by_user alias over email when both available", async () => { - const keyWithCreatedByUser = { - ...mockKey, - created_by: "some-uuid-1234", - created_by_user: { - user_id: "some-uuid-1234", - user_email: "creator@example.com", - user_alias: "The Creator", - }, - }; +it("should display created_by_user alias over email when both are available", async () => { + mockUseKeys.mockReturnValue( + keysResult([ + { + ...mockKey, + created_by: "some-uuid-1234", + created_by_user: { user_id: "some-uuid-1234", user_email: "creator@example.com", user_alias: "The Creator" }, + }, + ]), + ); - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [keyWithCreatedByUser], - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), - }); - - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; - - renderWithProviders(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText("The Creator")).toBeInTheDocument(); }); + expect(screen.queryByText("creator@example.com")).not.toBeInTheDocument(); }); it("should render table without crashing when models is null", async () => { - const keyWithNullModels = { - ...mockKey, - models: null as unknown as string[], - }; + mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, models: null as unknown as string[] }])); - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [keyWithNullModels], - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), - }); - - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; - - // This should not throw an error - renderWithProviders(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); }); }); -it("should render table without crashing when models is undefined", async () => { - const keyWithUndefinedModels = { - ...mockKey, - models: undefined as unknown as string[], - }; - - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [keyWithUndefinedModels], - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), - }); - - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; - - // This should not throw an error - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); - }); -}); - -it("should render Last Active column header with info icon", () => { - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; - - renderWithProviders(); - - expect(screen.getByText("Last Active")).toBeInTheDocument(); -}); - -it("should display formatted date for last_active when value exists", async () => { - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; - - renderWithProviders(); - - await waitFor(() => { - const expectedDate = new Date("2024-11-20T14:30:00Z").toLocaleDateString(); - expect(screen.getByText(expectedDate)).toBeInTheDocument(); - }); -}); - it("should display 'Unknown' for last_active when value is null", async () => { - const keyWithNullLastActive = { - ...mockKey, - last_active: null, - }; + mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, last_active: null }])); - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [keyWithNullLastActive], - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), - }); - - const mockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { - sortBy: "created_at", - sortOrder: "desc" as const, - }, - }; - - renderWithProviders(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText("Unknown")).toBeInTheDocument(); }); }); -const defaultMockProps = { - teams: [mockTeam], - organizations: [mockOrganization], - onSortChange: vi.fn(), - currentSort: { sortBy: "created_at", sortOrder: "desc" as const }, -}; +describe("server-side filtering – the LIT-4080 regression guard", () => { + it("threads an active User ID filter into the useKeys query so any refetch keeps it", async () => { + renderWithProviders(); -describe("pagination display – total count and page count", () => { - it("should show total_count from useKeys when no filter is active (filteredTotalCount is null)", async () => { - mockUseKeys.mockReturnValue({ - data: { - keys: [mockKey], - total_count: 509, - current_page: 1, - total_pages: 11, - } as KeysResponse, - isPending: false, - isFetching: false, - refetch: vi.fn(), - } as any); + fireEvent.click(screen.getByRole("button", { name: "Filters" })); - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [mockKey], - filteredTotalCount: null, - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), + const userIdInput = await screen.findByPlaceholderText("Enter User ID..."); + fireEvent.change(userIdInput, { target: { value: "user-42" } }); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: "user-42" })); + }); + }); + + it("does not send filter params to useKeys when no filter is active", () => { + renderWithProviders(); + + const lastCall = mockUseKeys.mock.calls[mockUseKeys.mock.calls.length - 1]; + expect(lastCall[2] ?? {}).toMatchObject({ userID: undefined, teamID: undefined, keyHash: undefined }); + }); + + it("drops the filter from the useKeys query when Reset Filters is clicked", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByRole("button", { name: "Filters" })); + const userIdInput = await screen.findByPlaceholderText("Enter User ID..."); + fireEvent.change(userIdInput, { target: { value: "user-42" } }); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: "user-42" })); }); - renderWithProviders(); + fireEvent.click(screen.getByRole("button", { name: "Reset Filters" })); + + await waitFor(() => { + const lastCall = mockUseKeys.mock.calls[mockUseKeys.mock.calls.length - 1]; + expect((lastCall[2] ?? {}).userID).toBeUndefined(); + }); + }); +}); + +describe("pagination display – total count comes from useKeys", () => { + it("shows total_count and page count from the useKeys response", async () => { + mockUseKeys.mockReturnValue(keysResult([mockKey], { total_count: 509, total_pages: 11 })); + + renderWithProviders(); await waitFor(() => { expect(screen.getByText("Showing 1 - 50 of 509 results")).toBeInTheDocument(); @@ -842,86 +388,21 @@ describe("pagination display – total count and page count", () => { }); }); - it("should show filteredTotalCount in pagination text when a filter search returns results", async () => { - mockUseKeys.mockReturnValue({ - data: { - keys: [mockKey], - total_count: 509, - current_page: 1, - total_pages: 11, - } as KeysResponse, - isPending: false, - isFetching: false, - refetch: vi.fn(), - } as any); + it("reflects a narrowed total when a filtered fetch returns fewer results", async () => { + mockUseKeys.mockReturnValue(keysResult([mockKey], { total_count: 1, total_pages: 1 })); - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "", - "Organization ID": "", - "Key Alias": "aaaaa", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [mockKey], - filteredTotalCount: 1, - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), - }); - - renderWithProviders(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText("Showing 1 - 1 of 1 results")).toBeInTheDocument(); expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); }); }); - - it("should not show stale unfiltered totals when filteredTotalCount is set", async () => { - mockUseKeys.mockReturnValue({ - data: { - keys: [mockKey], - total_count: 509, - current_page: 1, - total_pages: 11, - } as KeysResponse, - isPending: false, - isFetching: false, - refetch: vi.fn(), - } as any); - - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "", - "Organization ID": "", - "Key Alias": "aaaaa", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [mockKey], - filteredTotalCount: 1, - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), - }); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.queryByText(/509 results/)).not.toBeInTheDocument(); - expect(screen.queryByText(/of 11/)).not.toBeInTheDocument(); - }); - }); }); describe("refetch button", () => { it("should show Fetch button in normal state", () => { - renderWithProviders(); + renderWithProviders(); const fetchButton = screen.getByTitle("Fetch data"); expect(fetchButton).toBeInTheDocument(); @@ -930,64 +411,31 @@ describe("refetch button", () => { }); it("should show Fetching state and keep table data visible during refetch", () => { - mockUseKeys.mockReturnValue({ - data: { - keys: [mockKey], - total_count: 1, - current_page: 1, - total_pages: 1, - } as KeysResponse, - isPending: false, - isFetching: true, - refetch: vi.fn(), - } as any); + mockUseKeys.mockReturnValue(keysResult([mockKey], {}, { isFetching: true })); - renderWithProviders(); + renderWithProviders(); - // Button should show "Fetching" and be disabled expect(screen.getByText("Fetching")).toBeInTheDocument(); - const fetchButton = screen.getByTitle("Fetch data"); - expect(fetchButton).toBeDisabled(); - - // Table data should still be visible (stale data) + expect(screen.getByTitle("Fetch data")).toBeDisabled(); expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); - - // "Loading keys..." should NOT appear during refetch expect(screen.queryByText("🚅 Loading keys...")).not.toBeInTheDocument(); }); it("should call refetch when Fetch button is clicked", () => { const mockRefetch = vi.fn(); - mockUseKeys.mockReturnValue({ - data: { - keys: [mockKey], - total_count: 1, - current_page: 1, - total_pages: 1, - } as KeysResponse, - isPending: false, - isFetching: false, - refetch: mockRefetch, - } as any); + mockUseKeys.mockReturnValue(keysResult([mockKey], {}, { refetch: mockRefetch })); - renderWithProviders(); + renderWithProviders(); - const fetchButton = screen.getByTitle("Fetch data"); - fireEvent.click(fetchButton); + fireEvent.click(screen.getByTitle("Fetch data")); expect(mockRefetch).toHaveBeenCalledTimes(1); }); it("should show Fetch button enabled on error so user can retry", () => { - mockUseKeys.mockReturnValue({ - data: null, - isPending: false, - isFetching: false, - isError: true, - refetch: vi.fn(), - } as any); + mockUseKeys.mockReturnValue(keysResult([], {}, { data: null, isError: true })); - renderWithProviders(); + renderWithProviders(); const fetchButton = screen.getByTitle("Fetch data"); expect(fetchButton).not.toBeDisabled(); @@ -997,24 +445,9 @@ describe("refetch button", () => { describe("Status column reflects key.blocked / scim_blocked metadata", () => { it("should render Active for a non-blocked key", async () => { - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [{ ...mockKey, blocked: false, metadata: {} }], - filteredTotalCount: null, - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), - }); + mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, blocked: false, metadata: {} }])); - renderWithProviders(); + renderWithProviders(); await waitFor(() => { expect(screen.getByTestId(`key-status-${mockKey.token_id}`)).toHaveTextContent("Active"); @@ -1022,24 +455,9 @@ describe("Status column reflects key.blocked / scim_blocked metadata", () => { }); it("should render Blocked when key.blocked is true", async () => { - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [{ ...mockKey, blocked: true, metadata: {} }], - filteredTotalCount: null, - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), - }); + mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, blocked: true, metadata: {} }])); - renderWithProviders(); + renderWithProviders(); await waitFor(() => { expect(screen.getByTestId(`key-status-${mockKey.token_id}`)).toHaveTextContent("Blocked"); @@ -1048,24 +466,9 @@ describe("Status column reflects key.blocked / scim_blocked metadata", () => { }); it("should mark a SCIM-blocked key with the SCIM tooltip reason", async () => { - mockUseFilterLogic.mockReturnValue({ - filters: { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }, - filteredKeys: [{ ...mockKey, blocked: true, metadata: { scim_blocked: true } }], - filteredTotalCount: null, - allTeams: [mockTeam], - allOrganizations: [mockOrganization], - handleFilterChange: vi.fn(), - handleFilterReset: vi.fn(), - }); + mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, blocked: true, metadata: { scim_blocked: true } }])); - renderWithProviders(); + renderWithProviders(); const tag = await screen.findByTestId(`key-status-${mockKey.token_id}`); expect(tag).toHaveTextContent("Blocked"); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index 0c95d5dcfb7..0c71c4526ad 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -1,14 +1,15 @@ "use client"; -import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { useKeys, KeyListCallOptions } from "@/app/(dashboard)/hooks/keys/useKeys"; import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useQuery } from "@tanstack/react-query"; +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; import { ColumnDef, flexRender, getCoreRowModel, - getPaginationRowModel, - getSortedRowModel, PaginationState, SortingState, useReactTable, @@ -27,57 +28,57 @@ import { } from "@tremor/react"; import { InfoCircleOutlined, SyncOutlined } from "@ant-design/icons"; import { Button as AntButton, Popover, Skeleton, Tag, Tooltip, Typography } from "antd"; -import React, { useEffect, useDeferredValue, useMemo, useState } from "react"; +import React, { useDeferredValue, useMemo, useState } from "react"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; -import { useFilterLogic } from "../key_team_helpers/filter_logic"; +import { fetchAllTeams } from "../key_team_helpers/filter_helpers"; import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; import FilterComponent, { FilterOption } from "../molecules/filter"; import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; -import { Organization } from "../networking"; import KeyInfoView from "../templates/key_info_view"; -interface VirtualKeysTableProps { - teams: Team[] | null; - organizations: Organization[] | null; - onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void; - currentSort?: { - sortBy: string; - sortOrder: "asc" | "desc"; - }; -} +type KeyFilterState = { + "Team ID": string; + "Organization ID": string; + "Key Alias": string; + "User ID": string; + "Key Hash": string; +}; -/** - * VirtualKeysTable – a new table for keys that mimics the table styling used in view_logs. - * The team selector and filtering have been removed so that all keys are shown. - */ +const DEFAULT_KEY_FILTERS: KeyFilterState = { + "Team ID": "", + "Organization ID": "", + "Key Alias": "", + "User ID": "", + "Key Hash": "", +}; -export function VirtualKeysTable({ teams, organizations, onSortChange, currentSort }: VirtualKeysTableProps) { - const { data: fetchedOrganizations } = useOrganizations(); - const resolvedOrganizations = fetchedOrganizations ?? organizations ?? []; +type KeyListFilterOptions = Pick< + KeyListCallOptions, + "teamID" | "organizationID" | "selectedKeyAlias" | "userID" | "keyHash" +>; + +const toKeyListFilters = (filters: KeyFilterState): KeyListFilterOptions => ({ + teamID: filters["Team ID"].trim() || undefined, + organizationID: filters["Organization ID"].trim() || undefined, + selectedKeyAlias: filters["Key Alias"].trim() || undefined, + userID: filters["User ID"].trim() || undefined, + keyHash: filters["Key Hash"].trim() || undefined, +}); + +export function VirtualKeysTable() { + const { accessToken } = useAuthorized(); + const { data: fetchedOrganizations, isLoading: isOrgsLoading } = useOrganizations(); + const resolvedOrganizations = useMemo(() => fetchedOrganizations ?? [], [fetchedOrganizations]); const [selectedKey, setSelectedKey] = useState(null); - const [sorting, setSorting] = React.useState(() => { - if (currentSort) { - return [ - { - id: currentSort.sortBy, - desc: currentSort.sortOrder === "desc", - }, - ]; - } - return [ - { - id: "created_at", - desc: true, - }, - ]; - }); + const [sorting, setSorting] = React.useState([{ id: "created_at", desc: true }]); const [tablePagination, setTablePagination] = React.useState({ pageIndex: 0, pageSize: 50, }); + const [filters, setFilters] = useState(DEFAULT_KEY_FILTERS); + const [debouncedFilters] = useDebouncedValue(filters, { wait: 300 }); - // Extract sort parameters from sorting state const sortBy = sorting.length > 0 ? sorting[0].id : null; const sortOrder = sorting.length > 0 ? (sorting[0].desc ? "desc" : "asc") : null; @@ -88,29 +89,22 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo isError, refetch, } = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize, { + ...toKeyListFilters(debouncedFilters), sortBy: sortBy || undefined, sortOrder: sortOrder || undefined, expand: "user", }); const [expandedAccordions, setExpandedAccordions] = useState>({}); - // Use the filter logic hook - const keyList = useMemo(() => keys?.keys ?? [], [keys]); - const { - filters, - filteredKeys, - filteredTotalCount, - allTeams, - allOrganizations, - handleFilterChange, - handleFilterReset, - } = useFilterLogic({ - keys: keyList, - teams, - organizations, + const { data: fetchedTeams, isLoading: isTeamsLoading } = useQuery({ + queryKey: ["allTeamsForKeyFilters", accessToken], + queryFn: async () => (accessToken ? await fetchAllTeams(accessToken) : []), + enabled: !!accessToken, + staleTime: 30000, }); + const allTeams = useMemo(() => fetchedTeams ?? [], [fetchedTeams]); // Defer the transition so the button stays in loading state until the table // has rendered with the new data (mirrors the spend-logs pattern) @@ -121,23 +115,23 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo refetch(); }; - const totalCount = filteredTotalCount ?? keys?.total_count ?? 0; + const handleFilterChange = (newFilters: Record) => { + setFilters({ + "Team ID": newFilters["Team ID"] || "", + "Organization ID": newFilters["Organization ID"] || "", + "Key Alias": newFilters["Key Alias"] || "", + "User ID": newFilters["User ID"] || "", + "Key Hash": newFilters["Key Hash"] || "", + }); + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + }; - // Add a useEffect to call refresh when a key is created - useEffect(() => { - if (refetch) { - const handleStorageChange = () => { - refetch(); - }; + const handleFilterReset = () => { + setFilters(DEFAULT_KEY_FILTERS); + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + }; - // Listen for storage events that might indicate a key was created - window.addEventListener("storage", handleStorageChange); - - return () => { - window.removeEventListener("storage", handleStorageChange); - }; - } - }, [refetch]); + const totalCount = keys?.total_count ?? 0; const columns: ColumnDef[] = useMemo( () => [ @@ -237,7 +231,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo cell: (info) => { const teamId = info.getValue() as string | null; if (!teamId) return "-"; - const team = teams?.find((t) => t.team_id === teamId); + const team = allTeams.find((t) => t.team_id === teamId); const displayValue = team?.team_alias || teamId; const width = info.cell.column.getSize(); return ( @@ -471,7 +465,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo return `$${formatNumberWithCommas(maxBudget)}`; } const teamId = info.row.original.team_id; - const team = teams?.find((t) => t.team_id === teamId); + const team = allTeams.find((t) => t.team_id === teamId); if (team?.max_budget != null) { return `$${formatNumberWithCommas(team.max_budget)} (Team)`; } @@ -591,7 +585,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo }, }, ], - [teams, resolvedOrganizations], + [allTeams, resolvedOrganizations], ); const filterOptions: FilterOption[] = [ @@ -599,6 +593,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo name: "Team ID", label: "Team ID", isSearchable: true, + loading: isTeamsLoading, searchFn: async (searchText: string) => { if (!allTeams || allTeams.length === 0) return []; @@ -618,10 +613,11 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo name: "Organization ID", label: "Organization ID", isSearchable: true, + loading: isOrgsLoading, searchFn: async (searchText: string) => { - if (!allOrganizations || allOrganizations.length === 0) return []; + if (!resolvedOrganizations || resolvedOrganizations.length === 0) return []; - const filteredOrgs = allOrganizations.filter( + const filteredOrgs = resolvedOrganizations.filter( (org) => org.organization_id?.toLowerCase().includes(searchText.toLowerCase()) ?? false, ); @@ -651,7 +647,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo ]; const table = useReactTable({ - data: filteredKeys, + data: keyList, columns: columns.filter((col) => col.id !== "expander"), columnResizeMode: "onChange", columnResizeDirection: "ltr", @@ -662,45 +658,16 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo onSortingChange: (updaterOrValue) => { const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; setSorting(newSorting); - if (newSorting && newSorting.length > 0) { - const sortState = newSorting[0]; - const sortBy = sortState.id; - const sortOrder = sortState.desc ? "desc" : "asc"; - // Update filters state without triggering debouncedSearch - // The useKeys hook will automatically refetch with the new sort parameters - handleFilterChange( - { - ...filters, - "Sort By": sortBy, - "Sort Order": sortOrder, - }, - true, // skipDebounce - let useKeys handle the API call with correct page size - ); - onSortChange?.(sortBy, sortOrder); - } + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); }, onPaginationChange: setTablePagination, getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getPaginationRowModel: getPaginationRowModel(), enableSorting: true, - manualSorting: false, + manualSorting: true, manualPagination: true, pageCount: Math.ceil(totalCount / tablePagination.pageSize), }); - // Update local sorting state when currentSort prop changes - React.useEffect(() => { - if (currentSort) { - setSorting([ - { - id: currentSort.sortBy, - desc: currentSort.sortOrder === "desc", - }, - ]); - } - }, [currentSort]); - const { pageIndex, pageSize } = table.getState().pagination; const start = pageIndex * pageSize + 1; const end = Math.min((pageIndex + 1) * pageSize, totalCount); @@ -713,7 +680,6 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo onClose={() => setSelectedKey(null)} keyData={selectedKey} teams={allTeams} - onDelete={refetch} /> ) : (
@@ -867,7 +833,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
- ) : filteredKeys.length > 0 ? ( + ) : keyList.length > 0 ? ( table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.test.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.test.tsx deleted file mode 100644 index 23259528687..00000000000 --- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.test.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { act, renderHook, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useFilterLogic } from "./filter_logic"; -import { keyListCall } from "../networking"; - -vi.mock("../networking", () => ({ - keyListCall: vi.fn(), -})); - -vi.mock("./filter_helpers", () => ({ - fetchAllTeams: vi.fn().mockResolvedValue([]), - fetchAllOrganizations: vi.fn().mockResolvedValue([]), -})); - -const mockKey = { - token: "abc123", - key_alias: "aaaaa", - team_id: null, - organization_id: null, -}; - -const defaultProps = { - keys: [mockKey] as any[], - teams: [], - organizations: [], -}; - -const makeApiResponse = (overrides: { keys?: any[]; total_count?: number; total_pages?: number } = {}) => ({ - keys: overrides.keys ?? [mockKey], - total_count: overrides.total_count ?? 1, - current_page: 1, - total_pages: overrides.total_pages ?? 1, -}); - -describe("useFilterLogic – filteredTotalCount", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(keyListCall).mockResolvedValue(makeApiResponse({ total_count: 509, total_pages: 11 })); - }); - - it("should expose filteredTotalCount as null before any filter search runs", () => { - const { result } = renderHook(() => useFilterLogic(defaultProps)); - - expect(result.current.filteredTotalCount).toBeNull(); - }); - - it("should set filteredTotalCount to the API total_count after a Key Alias filter is applied", async () => { - vi.mocked(keyListCall).mockResolvedValue(makeApiResponse({ keys: [mockKey], total_count: 1, total_pages: 1 })); - - const { result } = renderHook(() => useFilterLogic(defaultProps)); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "aaaaa" }); - }); - - await waitFor( - () => { - expect(result.current.filteredTotalCount).toBe(1); - }, - { timeout: 500 }, - ); - }); - - it("should reflect the filtered total_count even when it differs from the full key count", async () => { - vi.mocked(keyListCall).mockResolvedValue(makeApiResponse({ total_count: 7, total_pages: 1 })); - - const { result } = renderHook(() => useFilterLogic(defaultProps)); - - act(() => { - result.current.handleFilterChange({ "Team ID": "team-x" }); - }); - - await waitFor( - () => { - expect(result.current.filteredTotalCount).toBe(7); - }, - { timeout: 500 }, - ); - }); - - it("should reset filteredTotalCount to null when handleFilterReset is called", async () => { - vi.mocked(keyListCall).mockResolvedValue(makeApiResponse({ total_count: 1 })); - - const { result } = renderHook(() => useFilterLogic(defaultProps)); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "aaaaa" }); - }); - - await waitFor( - () => { - expect(result.current.filteredTotalCount).toBe(1); - }, - { timeout: 500 }, - ); - - act(() => { - result.current.handleFilterReset(); - }); - - // filteredTotalCount resets synchronously before the debounced reset search completes - expect(result.current.filteredTotalCount).toBeNull(); - }); - - it("should pass the Key Alias value to keyListCall", async () => { - vi.mocked(keyListCall).mockResolvedValue(makeApiResponse({ total_count: 2 })); - - const { result } = renderHook(() => useFilterLogic(defaultProps)); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "my-alias" }); - }); - - await waitFor( - () => { - expect(keyListCall).toHaveBeenCalledWith( - expect.any(String), // accessToken - null, // organizationID (empty → null) - null, // teamID (empty → null) - "my-alias", // selectedKeyAlias ← the filter value - null, // userID - null, // keyHash - 1, // page (resets to 1 on filter change) - expect.any(Number), // pageSize (defaultPageSize) - expect.anything(), // sortBy - expect.anything(), // sortOrder - ); - }, - { timeout: 500 }, - ); - }); - - it("should not update filteredTotalCount when keyListCall throws", async () => { - vi.mocked(keyListCall).mockRejectedValue(new Error("Network error")); - - const { result } = renderHook(() => useFilterLogic(defaultProps)); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "bad-alias" }); - }); - - await waitFor( - () => { - expect(keyListCall).toHaveBeenCalled(); - }, - { timeout: 500 }, - ); - - expect(result.current.filteredTotalCount).toBeNull(); - }); - - it("should not enter an infinite update loop when keys is a fresh array reference on every render", () => { - const sourceKeys = [mockKey]; - let renderCount = 0; - - const { result } = renderHook(() => { - renderCount += 1; - const value = useFilterLogic({ keys: [...sourceKeys], teams: [], organizations: [] }); - if (renderCount > 25) { - throw new Error(`useFilterLogic re-rendered ${renderCount} times; setFilteredKeys is looping`); - } - return value; - }); - - expect(result.current.filteredKeys).toEqual([mockKey]); - expect(renderCount).toBeLessThanOrEqual(25); - }); - - it("should not trigger a debounced search when skipDebounce is true", async () => { - const { result } = renderHook(() => useFilterLogic(defaultProps)); - - act(() => { - result.current.handleFilterChange({ "Sort By": "spend", "Sort Order": "asc" }, true); - }); - - await new Promise((resolve) => setTimeout(resolve, 350)); - - expect(keyListCall).not.toHaveBeenCalled(); - expect(result.current.filteredTotalCount).toBeNull(); - }); -}); diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx deleted file mode 100644 index e31a6fbee38..00000000000 --- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { useCallback, useEffect, useState, useRef } from "react"; -import { KeyResponse } from "../key_team_helpers/key_list"; -import { keyListCall, Organization } from "../networking"; -import { Team } from "../key_team_helpers/key_list"; -import { fetchAllOrganizations, fetchAllTeams } from "./filter_helpers"; -import { debounce } from "lodash"; -import { defaultPageSize } from "../constants"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; - -export interface FilterState { - "Team ID": string; - "Organization ID": string; - "Key Alias": string; - [key: string]: string; - "User ID": string; - "Sort By": string; - "Sort Order": string; -} - -export function useFilterLogic({ - keys, - teams, - organizations, -}: { - keys: KeyResponse[]; - teams: Team[] | null; - organizations: Organization[] | null; -}) { - const defaultFilters: FilterState = { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }; - const { accessToken } = useAuthorized(); - const [filters, setFilters] = useState(defaultFilters); - const [allTeams, setAllTeams] = useState(teams || []); - const [allOrganizations, setAllOrganizations] = useState(organizations || []); - const [filteredKeys, setFilteredKeys] = useState(keys); - const [filteredTotalCount, setFilteredTotalCount] = useState(null); - const lastSearchTimestamp = useRef(0); - const debouncedSearch = useCallback( - debounce(async (filters: FilterState) => { - if (!accessToken) { - return; - } - - const currentTimestamp = Date.now(); - lastSearchTimestamp.current = currentTimestamp; - - try { - // Make the API call using userListCall with all filter parameters - const data = await keyListCall( - accessToken, - filters["Organization ID"] || null, - filters["Team ID"] || null, - filters["Key Alias"] || null, - filters["User ID"] || null, - filters["Key Hash"] || null, - 1, // Reset to first page when searching - defaultPageSize, - filters["Sort By"] || null, - filters["Sort Order"] || null, - ); - - // Only update state if this is the most recent search - if (currentTimestamp === lastSearchTimestamp.current) { - if (data) { - setFilteredKeys(data.keys); - setFilteredTotalCount(data.total_count ?? null); - console.log("called from debouncedSearch filters:", JSON.stringify(filters)); - console.log("called from debouncedSearch data:", JSON.stringify(data)); - } - } - } catch (error) { - console.error("Error searching users:", error); - } - }, 300), - [accessToken], - ); - // Apply filters to keys whenever keys or filters change - useEffect(() => { - if (!keys) { - setFilteredKeys([]); - return; - } - - let result = [...keys]; - - // Apply Team ID filter - if (filters["Team ID"]) { - result = result.filter((key) => key.team_id === filters["Team ID"]); - } - - // Apply Organization ID filter - if (filters["Organization ID"]) { - result = result.filter((key) => (key.organization_id ?? key.org_id) === filters["Organization ID"]); - } - - setFilteredKeys((prev) => - prev.length === result.length && prev.every((key, index) => key === result[index]) ? prev : result, - ); - }, [keys, filters]); - - // Fetch all data for filters when component mounts - useEffect(() => { - const loadAllFilterData = async () => { - // Load all teams - no organization filter needed here - const teamsData = await fetchAllTeams(accessToken); - if (teamsData.length > 0) { - setAllTeams(teamsData); - } - - // Load all organizations - const orgsData = await fetchAllOrganizations(accessToken); - if (orgsData.length > 0) { - setAllOrganizations(orgsData); - } - }; - - if (accessToken) { - loadAllFilterData(); - } - }, [accessToken]); - - // Update teams and organizations when props change - useEffect(() => { - if (teams && teams.length > 0) { - setAllTeams((prevTeams) => { - // Only update if we don't already have a larger set of teams - return prevTeams.length < teams.length ? teams : prevTeams; - }); - } - }, [teams]); - - useEffect(() => { - if (organizations && organizations.length > 0) { - setAllOrganizations((prevOrgs) => { - // Only update if we don't already have a larger set of organizations - return prevOrgs.length < organizations.length ? organizations : prevOrgs; - }); - } - }, [organizations]); - - const handleFilterChange = (newFilters: Record, skipDebounce: boolean = false) => { - // Update filters state - setFilters({ - "Team ID": newFilters["Team ID"] || "", - "Organization ID": newFilters["Organization ID"] || "", - "Key Alias": newFilters["Key Alias"] || "", - "User ID": newFilters["User ID"] || "", - "Sort By": newFilters["Sort By"] || "created_at", - "Sort Order": newFilters["Sort Order"] || "desc", - }); - - // Only trigger debouncedSearch if skipDebounce is false - // This allows sorting to be handled by the parent component's useKeys hook - if (!skipDebounce) { - // Fetch keys based on new filters - const updatedFilters = { - ...filters, - ...newFilters, - }; - debouncedSearch(updatedFilters); - } - }; - - const handleFilterReset = () => { - // Reset filters state - setFilters(defaultFilters); - setFilteredTotalCount(null); - - // Reset selections - debouncedSearch(defaultFilters); - }; - - return { - filters, - filteredKeys, - filteredTotalCount, - allTeams, - allOrganizations, - handleFilterChange, - handleFilterReset, - }; -} diff --git a/ui/litellm-dashboard/src/components/molecules/filter.test.tsx b/ui/litellm-dashboard/src/components/molecules/filter.test.tsx index 3a15c5c84f1..d956cd93168 100644 --- a/ui/litellm-dashboard/src/components/molecules/filter.test.tsx +++ b/ui/litellm-dashboard/src/components/molecules/filter.test.tsx @@ -326,6 +326,67 @@ describe("FilterComponent", () => { }); }); + it("shows a loading state (not an empty list) while a searchable filter's data is still loading", async () => { + const user = userEvent.setup({ delay: null }); + const mockSearchFn = vi.fn().mockResolvedValue([]); + + const options: FilterOption[] = [ + { + name: "model", + label: "Model", + isSearchable: true, + loading: true, + searchFn: mockSearchFn, + }, + ]; + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("button", { name: "Filters" })); + + const modelLabel = screen.getByText("Model"); + const modelSelect = within(modelLabel.closest("div")!).getByRole("combobox"); + await user.click(modelSelect); + + await waitFor(() => { + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + expect(screen.queryByText("No results found")).not.toBeInTheDocument(); + // It must not cache an empty initial-options list while the source is still loading. + expect(mockSearchFn).not.toHaveBeenCalled(); + }); + + it("loads initial options once a searchable filter's data finishes loading", async () => { + const user = userEvent.setup({ delay: null }); + const mockSearchFn = vi.fn().mockResolvedValue([{ label: "Team A", value: "team-a" }]); + const baseOption: FilterOption = { name: "model", label: "Model", isSearchable: true, searchFn: mockSearchFn }; + + const { rerender } = renderWithProviders( + , + ); + + await user.click(screen.getByRole("button", { name: "Filters" })); + expect(mockSearchFn).not.toHaveBeenCalled(); + + rerender( + , + ); + + await waitFor(() => { + expect(mockSearchFn).toHaveBeenCalledWith(""); + }); + }); + it("should handle search errors gracefully", async () => { const user = userEvent.setup({ delay: null }); const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/ui/litellm-dashboard/src/components/molecules/filter.tsx b/ui/litellm-dashboard/src/components/molecules/filter.tsx index 7086892c32d..45ad3a6b9ca 100644 --- a/ui/litellm-dashboard/src/components/molecules/filter.tsx +++ b/ui/litellm-dashboard/src/components/molecules/filter.tsx @@ -17,6 +17,7 @@ export interface FilterOption { searchFn?: (searchText: string) => Promise>; options?: Array<{ label: string; value: string }>; customComponent?: React.ComponentType; + loading?: boolean; } interface FilterValues { @@ -74,7 +75,7 @@ const FilterComponent: React.FC = ({ // Load initial options for searchable filters const loadInitialOptions = useCallback( async (option: FilterOption) => { - if (!option.isSearchable || !option.searchFn || initialOptionsLoaded[option.name]) return; + if (!option.isSearchable || !option.searchFn || option.loading || initialOptionsLoaded[option.name]) return; setSearchLoadingMap((prev) => ({ ...prev, [option.name]: true })); setInitialOptionsLoaded((prev) => ({ ...prev, [option.name]: true })); @@ -145,6 +146,7 @@ const FilterComponent: React.FC = ({ {showFilters && (
{options.map((option) => { + const isOptionLoading = searchLoadingMap[option.name] || option.loading; return (
@@ -166,10 +168,10 @@ const FilterComponent: React.FC = ({ } }} filterOption={false} - loading={searchLoadingMap[option.name]} + loading={isOptionLoading} options={searchOptionsMap[option.name] || []} allowClear - notFoundContent={searchLoadingMap[option.name] ? "Loading..." : "No results found"} + notFoundContent={isOptionLoading ? "Loading..." : "No results found"} /> ) : option.options ? (
- - - - ${null!==t.daily_cost?"":""} - ${null!==t.monthly_cost?"":""} - - - - - ${null!==t.daily_cost?``:""} - ${null!==t.monthly_cost?``:""} - - - - - ${null!==t.daily_cost?``:""} - ${null!==t.monthly_cost?``:""} - - - - - ${null!==t.daily_cost?``:""} - ${null!==t.monthly_cost?``:""} - - - - - ${null!==t.daily_cost?``:""} - ${null!==t.monthly_cost?``:""} - -
Cost TypePer RequestDailyMonthly
Input Cost${eT(t.input_cost_per_request)}${eT(t.daily_input_cost)}${eT(t.monthly_input_cost)}
Output Cost${eT(t.output_cost_per_request)}${eT(t.daily_output_cost)}${eT(t.monthly_output_cost)}
Margin/Fee${eT(t.margin_cost_per_request)}${eT(t.daily_margin_cost)}${eT(t.monthly_margin_cost)}
Total${eT(t.cost_per_request)}${eT(t.daily_cost)}${eT(t.monthly_cost)}
-
- `}).join("")} - - - - - `;t.document.write(a),t.document.close(),t.onload=()=>{t.print()}})(e),a(!1)},children:[(0,t.jsx)(e_.FilePdfOutlined,{className:"mr-3 text-red-500"}),"Export as PDF"]}),(0,t.jsxs)("button",{className:"flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors",onClick:()=>{(e=>{let t=e.entries.filter(e=>null!==e.result),s=[["LLM Multi-Model Cost Estimate Report"],["Generated",new Date().toLocaleString()],[""]];for(let r of(s.push(["COMBINED TOTALS"],["Total Per Request",e.totals.cost_per_request.toString()],["Total Daily",e.totals.daily_cost?.toString()||"-"],["Total Monthly",e.totals.monthly_cost?.toString()||"-"],["Margin Per Request",e.totals.margin_per_request.toString()],["Daily Margin",e.totals.daily_margin?.toString()||"-"],["Monthly Margin",e.totals.monthly_margin?.toString()||"-"],[""]),s.push(["Model","Provider","Input Tokens","Output Tokens","Requests/Day","Requests/Month","Cost/Request","Daily Cost","Monthly Cost","Input Cost/Req","Output Cost/Req","Margin/Req"]),t)){let e=r.result;s.push([e.model,e.provider||"-",e.input_tokens.toString(),e.output_tokens.toString(),e.num_requests_per_day?.toString()||"-",e.num_requests_per_month?.toString()||"-",e.cost_per_request.toString(),e.daily_cost?.toString()||"-",e.monthly_cost?.toString()||"-",e.input_cost_per_request.toString(),e.output_cost_per_request.toString(),e.margin_cost_per_request.toString()])}let r=new Blob([s.map(e=>e.map(e=>`"${e}"`).join(",")).join("\n")],{type:"text/csv;charset=utf-8;"}),a=window.URL.createObjectURL(r),l=document.createElement("a");l.href=a,l.download=`cost_estimate_multi_model_${new Date().toISOString().split("T")[0]}.csv`,document.body.appendChild(l),l.click(),document.body.removeChild(l),window.URL.revokeObjectURL(a)})(e),a(!1)},children:[(0,t.jsx)(eC,{className:"mr-3 text-green-600"}),"Export as CSV"]})]})]}):null},eP=e=>null==e?"-":0===e?"$0":e<1e-4?`$${e.toExponential(2)}`:e<1?`$${e.toFixed(4)}`:`$${(0,ej.formatNumberWithCommas)(e,2,!0)}`,eM=({result:e,loading:s,timePeriod:r})=>{let l="day"===r?"Daily":"Monthly",n="day"===r?e.daily_cost:e.monthly_cost,o="day"===r?e.daily_input_cost:e.monthly_input_cost,i="day"===r?e.daily_output_cost:e.monthly_output_cost,d="day"===r?e.daily_margin_cost:e.monthly_margin_cost,c="day"===r?e.num_requests_per_day:e.num_requests_per_month;return(0,t.jsxs)("div",{className:"space-y-3 bg-gray-50 p-4 rounded-lg",children:[s&&(0,t.jsxs)("div",{className:"flex items-center gap-2 text-gray-500 text-sm",children:[(0,t.jsx)(eg.Spin,{indicator:(0,t.jsx)(ef.LoadingOutlined,{spin:!0}),size:"small"}),(0,t.jsx)("span",{children:"Updating..."})]}),(0,t.jsxs)("div",{className:"grid grid-cols-4 gap-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsx)(a.Text,{className:"text-xs text-gray-500 block",children:"Total/Request"}),(0,t.jsx)(a.Text,{className:"text-base font-semibold text-blue-600",children:eP(e.cost_per_request)})]}),(0,t.jsxs)("div",{children:[(0,t.jsx)(a.Text,{className:"text-xs text-gray-500 block",children:"Input Cost"}),(0,t.jsx)(a.Text,{className:"text-sm",children:eP(e.input_cost_per_request)})]}),(0,t.jsxs)("div",{children:[(0,t.jsx)(a.Text,{className:"text-xs text-gray-500 block",children:"Output Cost"}),(0,t.jsx)(a.Text,{className:"text-sm",children:eP(e.output_cost_per_request)})]}),(0,t.jsxs)("div",{children:[(0,t.jsx)(a.Text,{className:"text-xs text-gray-500 block",children:"Margin Fee"}),(0,t.jsx)(a.Text,{className:`text-sm ${e.margin_cost_per_request>0?"text-amber-600":""}`,children:eP(e.margin_cost_per_request)})]})]}),null!==n&&(0,t.jsxs)("div",{className:"grid grid-cols-4 gap-4 pt-2 border-t border-gray-200",children:[(0,t.jsxs)("div",{children:[(0,t.jsxs)(a.Text,{className:"text-xs text-gray-500 block",children:[l," Total (",null==c?"-":(0,ej.formatNumberWithCommas)(c,0,!0)," req)"]}),(0,t.jsx)(a.Text,{className:`text-base font-semibold ${"day"===r?"text-green-600":"text-purple-600"}`,children:eP(n)})]}),(0,t.jsxs)("div",{children:[(0,t.jsxs)(a.Text,{className:"text-xs text-gray-500 block",children:[l," Input"]}),(0,t.jsx)(a.Text,{className:"text-sm",children:eP(o)})]}),(0,t.jsxs)("div",{children:[(0,t.jsxs)(a.Text,{className:"text-xs text-gray-500 block",children:[l," Output"]}),(0,t.jsx)(a.Text,{className:"text-sm",children:eP(i)})]}),(0,t.jsxs)("div",{children:[(0,t.jsxs)(a.Text,{className:"text-xs text-gray-500 block",children:[l," Margin Fee"]}),(0,t.jsx)(a.Text,{className:`text-sm ${(d??0)>0?"text-amber-600":""}`,children:eP(d)})]})]}),(e.input_cost_per_token||e.output_cost_per_token)&&(0,t.jsxs)("div",{className:"text-xs text-gray-400 pt-2 border-t border-gray-200",children:["Token Pricing:"," ",e.input_cost_per_token&&(0,t.jsxs)("span",{children:["Input $",(0,ej.formatNumberWithCommas)(1e6*e.input_cost_per_token,2),"/1M"]}),e.input_cost_per_token&&e.output_cost_per_token&&" | ",e.output_cost_per_token&&(0,t.jsxs)("span",{children:["Output $",(0,ej.formatNumberWithCommas)(1e6*e.output_cost_per_token,2),"/1M"]})]})]})},eq=({multiResult:e,timePeriod:r})=>{let[n,o]=(0,s.useState)(new Set),i=e.entries.filter(e=>null!==e.result),d=e.entries.filter(e=>e.loading),c=e.entries.filter(e=>null!==e.error),m=i.length>0,u=d.length>0,x=c.length>0;if(!m&&!u&&!x)return(0,t.jsx)("div",{className:"py-6 text-center border border-dashed border-gray-300 rounded-lg bg-gray-50",children:(0,t.jsx)(a.Text,{className:"text-gray-500",children:"Select models above to see cost estimates"})});if(!m&&u&&!x)return(0,t.jsxs)("div",{className:"py-6 text-center",children:[(0,t.jsx)(eg.Spin,{indicator:(0,t.jsx)(ef.LoadingOutlined,{spin:!0})}),(0,t.jsx)(a.Text,{className:"text-gray-500 block mt-2",children:"Calculating costs..."})]});if(!m&&x)return(0,t.jsxs)("div",{className:"space-y-4",children:[(0,t.jsx)(ep.Divider,{className:"my-4"}),(0,t.jsxs)("div",{className:"flex items-center justify-between",children:[(0,t.jsx)(a.Text,{className:"text-base font-semibold text-gray-900",children:"Cost Estimates"}),u&&(0,t.jsx)(eg.Spin,{indicator:(0,t.jsx)(ef.LoadingOutlined,{spin:!0}),size:"small"})]}),c.map(e=>(0,t.jsxs)("div",{className:"text-sm text-red-600 bg-red-50 p-3 rounded-lg border border-red-200",children:[(0,t.jsxs)("span",{className:"font-medium",children:[e.entry.model||"Unknown model",": "]}),e.error]},e.entry.id))]});let p=e.totals.margin_per_request>0,g="day"===r?"Daily":"Monthly",h=[{title:"Model",dataIndex:"model",key:"model",render:(e,s)=>(0,t.jsxs)("div",{className:"flex flex-col gap-1",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)("span",{className:"font-medium text-sm",children:e}),s.provider&&(0,t.jsx)(eh.Tag,{color:"blue",className:"text-xs",children:s.provider}),s.loading&&(0,t.jsx)(eg.Spin,{indicator:(0,t.jsx)(ef.LoadingOutlined,{spin:!0}),size:"small"})]}),s.error&&(0,t.jsxs)("div",{className:"text-xs text-red-600 bg-red-50 px-2 py-1 rounded",children:["⚠️ ",s.error]}),s.hasZeroCost&&!s.error&&(0,t.jsx)("div",{className:"text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded",children:"⚠️ No pricing data found for this model. Set base_model in config."})]})},{title:"Per Request",dataIndex:"cost_per_request",key:"cost_per_request",align:"right",render:(e,s)=>s.error?(0,t.jsx)("span",{className:"text-gray-400",children:"-"}):(0,t.jsx)("span",{className:"font-mono text-sm",children:eP(e)})},{title:"Margin Fee",dataIndex:"margin_cost_per_request",key:"margin_cost_per_request",align:"right",render:(e,s)=>s.error?(0,t.jsx)("span",{className:"text-gray-400",children:"-"}):(0,t.jsx)("span",{className:`font-mono text-sm ${(e??0)>0?"text-amber-600":"text-gray-400"}`,children:eP(e)})},{title:g,dataIndex:"day"===r?"daily_cost":"monthly_cost",key:"period_cost",align:"right",render:(e,s)=>s.error?(0,t.jsx)("span",{className:"text-gray-400",children:"-"}):(0,t.jsx)("span",{className:"font-mono text-sm",children:eP(e)})},{title:"",key:"expand",width:40,render:(e,s)=>s.error?null:(0,t.jsx)(l.Button,{size:"xs",variant:"light",onClick:()=>{var e;return e=s.id,void o(t=>{let s=new Set(t);return s.has(e)?s.delete(e):s.add(e),s})},className:"text-gray-400 hover:text-gray-600",children:n.has(s.id)?(0,t.jsx)(ey.DownOutlined,{}):(0,t.jsx)(ev.RightOutlined,{})})}],f=e.entries.filter(e=>e.entry.model).map(e=>({key:e.entry.id,id:e.entry.id,model:e.result?.model||e.entry.model,provider:e.result?.provider,cost_per_request:e.result?.cost_per_request??null,margin_cost_per_request:e.result?.margin_cost_per_request??null,daily_cost:e.result?.daily_cost??null,monthly_cost:e.result?.monthly_cost??null,error:e.error,loading:e.loading,hasZeroCost:e.result&&0===e.result.cost_per_request}));return(0,t.jsxs)("div",{className:"space-y-4",children:[(0,t.jsx)(ep.Divider,{className:"my-4"}),(0,t.jsxs)("div",{className:"flex items-center justify-between",children:[(0,t.jsx)(a.Text,{className:"text-base font-semibold text-gray-900",children:"Cost Estimates"}),(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[u&&(0,t.jsx)(eg.Spin,{indicator:(0,t.jsx)(ef.LoadingOutlined,{spin:!0}),size:"small"}),(0,t.jsx)(e$,{multiResult:e})]})]}),(0,t.jsxs)(W.Card,{size:"small",className:"bg-gradient-to-r from-slate-50 to-blue-50 border-slate-200",children:[(0,t.jsxs)(eu.Row,{gutter:[16,8],children:[(0,t.jsx)(ex.Col,{xs:24,sm:12,children:(0,t.jsx)(eo,{title:(0,t.jsx)("span",{className:"text-xs",children:"Total Per Request"}),value:eP(e.totals.cost_per_request),valueStyle:{color:"#1890ff",fontSize:"18px",fontFamily:"monospace"}})}),(0,t.jsx)(ex.Col,{xs:24,sm:12,children:(0,t.jsx)(eo,{title:(0,t.jsxs)("span",{className:"text-xs",children:["Total ",g]}),value:eP("day"===r?e.totals.daily_cost:e.totals.monthly_cost),valueStyle:{color:"day"===r?"#52c41a":"#722ed1",fontSize:"18px",fontFamily:"monospace"}})})]}),p&&(0,t.jsxs)(eu.Row,{gutter:[16,8],className:"mt-3 pt-3 border-t border-slate-200",children:[(0,t.jsxs)(ex.Col,{xs:24,sm:12,children:[(0,t.jsx)("div",{className:"text-xs text-gray-500",children:"Margin Fee/Request"}),(0,t.jsx)("div",{className:"text-sm font-mono text-amber-600",children:eP(e.totals.margin_per_request)})]}),(0,t.jsxs)(ex.Col,{xs:24,sm:12,children:[(0,t.jsxs)("div",{className:"text-xs text-gray-500",children:[g," Margin Fee"]}),(0,t.jsx)("div",{className:"text-sm font-mono text-amber-600",children:eP("day"===r?e.totals.daily_margin:e.totals.monthly_margin)})]})]})]}),f.length>0&&(0,t.jsx)(z.Table,{columns:h,dataSource:f,pagination:!1,size:"small",className:"border border-gray-200 rounded-lg",expandable:{expandedRowKeys:Array.from(n),expandedRowRender:e=>{let s=i.find(t=>t.entry.id===e.id);return s?.result?(0,t.jsx)("div",{className:"py-2",children:(0,t.jsx)(eM,{result:s.result,loading:s.loading,timePeriod:r})}):null},showExpandColumn:!1}})]})};var eO=e.i(602869);let eE=()=>({id:`entry-${Date.now()}-${Math.random().toString(36).substr(2,9)}`,model:"",input_tokens:1e3,output_tokens:500,num_requests_per_day:void 0,num_requests_per_month:void 0}),eF=({accessToken:e,models:r})=>{let[a,l]=(0,s.useState)([eE()]),[n,o]=(0,s.useState)("month"),{debouncedFetchForEntry:i,removeEntry:d,getMultiModelResult:c}=function(e){let[t,r]=(0,s.useState)(new Map),a=(0,s.useRef)(new Map),l=(0,s.useCallback)(async t=>{if(!e||!t.model)return void r(e=>{let s=new Map(e);return s.set(t.id,{entry:t,result:null,loading:!1,error:null}),s});r(e=>{let s=new Map(e),r=s.get(t.id);return s.set(t.id,{entry:t,result:r?.result??null,loading:!0,error:null}),s});try{let s=(0,eO.getProxyBaseUrl)(),a=s?`${s}/cost/estimate`:"/cost/estimate",l={model:t.model,input_tokens:t.input_tokens||0,output_tokens:t.output_tokens||0,num_requests_per_day:t.num_requests_per_day||null,num_requests_per_month:t.num_requests_per_month||null},n=await fetch(a,{method:"POST",headers:{[(0,eO.getGlobalLitellmHeaderName)()]:`Bearer ${e}`,"Content-Type":"application/json"},body:JSON.stringify(l)});if(n.ok){let e=await n.json();r(s=>{let r=new Map(s);return r.set(t.id,{entry:t,result:e,loading:!1,error:null}),r})}else{let e=await n.json(),s=e.detail?.error||e.detail||"Failed to estimate cost";r(e=>{let r=new Map(e);return r.set(t.id,{entry:t,result:null,loading:!1,error:s}),r})}}catch(e){console.error("Error estimating cost:",e),r(e=>{let s=new Map(e);return s.set(t.id,{entry:t,result:null,loading:!1,error:"Network error"}),s})}},[e]),n=(0,s.useCallback)(e=>{let t=a.current.get(e.id);t&&clearTimeout(t);let s=setTimeout(()=>{l(e)},500);a.current.set(e.id,s)},[l]),o=(0,s.useCallback)(e=>{let t=a.current.get(e);t&&(clearTimeout(t),a.current.delete(e)),r(t=>{let s=new Map(t);return s.delete(e),s})},[]);return(0,s.useEffect)(()=>{let e=a.current;return()=>{e.forEach(e=>clearTimeout(e)),e.clear()}},[]),{debouncedFetchForEntry:n,removeEntry:o,getMultiModelResult:(0,s.useCallback)(e=>{let s=e.map(e=>{let s=t.get(e.id);return{entry:e,result:s?.result??null,loading:s?.loading??!1,error:s?.error??null}}),r=0,a=null,l=null,n=0,o=null,i=null;for(let e of s)e.result&&(r+=e.result.cost_per_request,n+=e.result.margin_cost_per_request,null!==e.result.daily_cost&&(a=(a??0)+e.result.daily_cost),null!==e.result.daily_margin_cost&&(o=(o??0)+e.result.daily_margin_cost),null!==e.result.monthly_cost&&(l=(l??0)+e.result.monthly_cost),null!==e.result.monthly_margin_cost&&(i=(i??0)+e.result.monthly_margin_cost));return{entries:s,totals:{cost_per_request:r,daily_cost:a,monthly_cost:l,margin_per_request:n,daily_margin:o,monthly_margin:i}}},[t])}}(e),m=(0,s.useCallback)((e,t,s)=>{l(r=>{let a=r.map(r=>r.id===e?{...r,[t]:s}:r),l=a.find(t=>t.id===e);return l&&l.model&&i(l),a})},[i]),u=(0,s.useCallback)(e=>{o(e),l(t=>t.map(t=>({...t,num_requests_per_day:"day"===e?t.num_requests_per_day:void 0,num_requests_per_month:"month"===e?t.num_requests_per_month:void 0})))},[]),x=(0,s.useCallback)(()=>{l(e=>[...e,eE()])},[]),p=(0,s.useCallback)(e=>{l(t=>t.filter(t=>t.id!==e)),d(e)},[d]),g=c(a),h=[{title:"Model",dataIndex:"model",key:"model",width:"35%",render:(e,s)=>(0,t.jsx)(F.Select,{showSearch:!0,placeholder:"Select a model",value:s.model||void 0,onChange:e=>m(s.id,"model",e),optionFilterProp:"label",filterOption:(e,t)=>String(t?.label??"").toLowerCase().includes(e.toLowerCase()),options:r.map(e=>({value:e,label:e})),style:{width:"100%"},size:"small"})},{title:"Input Tokens",dataIndex:"input_tokens",key:"input_tokens",width:"18%",render:(e,s)=>(0,t.jsx)(H.InputNumber,{min:0,value:s.input_tokens,onChange:e=>m(s.id,"input_tokens",e??0),style:{width:"100%"},size:"small",formatter:e=>`${e}`.replace(/\B(?=(\d{3})+(?!\d))/g,",")})},{title:"Output Tokens",dataIndex:"output_tokens",key:"output_tokens",width:"18%",render:(e,s)=>(0,t.jsx)(H.InputNumber,{min:0,value:s.output_tokens,onChange:e=>m(s.id,"output_tokens",e??0),style:{width:"100%"},size:"small",formatter:e=>`${e}`.replace(/\B(?=(\d{3})+(?!\d))/g,",")})},{title:`Requests/${"day"===n?"Day":"Month"}`,dataIndex:"day"===n?"num_requests_per_day":"num_requests_per_month",key:"num_requests",width:"20%",render:(e,s)=>(0,t.jsx)(H.InputNumber,{min:0,value:"day"===n?s.num_requests_per_day:s.num_requests_per_month,onChange:e=>m(s.id,"day"===n?"num_requests_per_day":"num_requests_per_month",e??void 0),style:{width:"100%"},size:"small",placeholder:"-",formatter:e=>e?`${e}`.replace(/\B(?=(\d{3})+(?!\d))/g,","):""})},{title:"",key:"actions",width:50,render:(e,s)=>(0,t.jsx)(G.Button,{type:"text",icon:(0,t.jsx)(V.DeleteOutlined,{}),onClick:()=>p(s.id),disabled:1===a.length,danger:!0,size:"small"})}];return(0,t.jsxs)("div",{className:"space-y-4",children:[(0,t.jsx)("div",{className:"flex items-center justify-end mb-2",children:(0,t.jsxs)(A.Radio.Group,{value:n,onChange:e=>u(e.target.value),size:"small",optionType:"button",buttonStyle:"solid",children:[(0,t.jsx)(A.Radio.Button,{value:"day",children:"Per Day"}),(0,t.jsx)(A.Radio.Button,{value:"month",children:"Per Month"})]})}),(0,t.jsx)(z.Table,{columns:h,dataSource:a,rowKey:"id",pagination:!1,size:"small",footer:()=>(0,t.jsx)(G.Button,{type:"dashed",onClick:x,icon:(0,t.jsx)(U.PlusOutlined,{}),className:"w-full",children:"Add Another Model"})}),(0,t.jsx)(eq,{multiResult:g,timePeriod:n})]})};var eR=e.i(270377),eL=e.i(778917),eI=e.i(664659);let eD=({items:e,children:r="Docs",className:a=""})=>{let[l,n]=(0,s.useState)(!1),o=(0,s.useRef)(null);return(0,s.useEffect)(()=>{let e=e=>{o.current&&!o.current.contains(e.target)&&n(!1)};return l&&document.addEventListener("mousedown",e),()=>{document.removeEventListener("mousedown",e)}},[l]),(0,t.jsxs)("div",{className:`relative inline-block ${a}`,ref:o,children:[(0,t.jsxs)("button",{type:"button",onClick:()=>n(!l),className:"inline-flex items-center gap-1 text-gray-500 hover:text-gray-700 text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 rounded px-2 py-1","aria-expanded":l,"aria-haspopup":"true",children:[(0,t.jsx)("span",{children:r}),(0,t.jsx)(eI.ChevronDown,{className:`h-3 w-3 transition-transform ${l?"rotate-180":""}`,"aria-hidden":"true"})]}),l&&(0,t.jsx)("div",{className:"absolute right-0 mt-1 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50",children:e.map((e,s)=>(0,t.jsxs)("a",{href:e.href,target:"_blank",rel:"noopener noreferrer",className:"flex items-center justify-between px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors",onClick:()=>n(!1),children:[(0,t.jsx)("span",{children:e.label}),(0,t.jsx)(eL.ExternalLink,{className:"h-3.5 w-3.5 text-gray-400 flex-shrink-0 ml-2","aria-hidden":"true"})]},s))})]})};var eA=e.i(673709);let eB=()=>{let[e,r]=(0,s.useState)(""),[l,n]=(0,s.useState)(""),o=(0,s.useMemo)(()=>{let t=parseFloat(e),s=parseFloat(l);if(isNaN(t)||isNaN(s)||0===t||0===s)return null;let r=t+s,a=s/r*100;return{originalCost:r.toFixed(10),finalCost:t.toFixed(10),discountAmount:s.toFixed(10),discountPercentage:a.toFixed(2)}},[e,l]);return(0,t.jsxs)("div",{className:"space-y-4 pt-2",children:[(0,t.jsxs)("div",{children:[(0,t.jsx)(a.Text,{className:"font-medium text-gray-900 text-sm mb-1",children:"Cost Calculation"}),(0,t.jsxs)(a.Text,{className:"text-xs text-gray-600",children:["Discounts are applied to provider costs:"," ",(0,t.jsx)("code",{className:"bg-gray-100 px-1.5 py-0.5 rounded text-xs",children:"final_cost = base_cost × (1 - discount%/100)"})]})]}),(0,t.jsxs)("div",{children:[(0,t.jsx)(a.Text,{className:"font-medium text-gray-900 text-sm mb-1",children:"Example"}),(0,t.jsx)(a.Text,{className:"text-xs text-gray-600",children:"A 5% discount on a $10.00 request results in: $10.00 × (1 - 0.05) = $9.50"})]}),(0,t.jsxs)("div",{children:[(0,t.jsx)(a.Text,{className:"font-medium text-gray-900 text-sm mb-1",children:"Valid Range"}),(0,t.jsx)(a.Text,{className:"text-xs text-gray-600",children:"Discount percentages must be between 0% and 100%"})]}),(0,t.jsxs)("div",{className:"pt-4 border-t border-gray-200",children:[(0,t.jsx)(a.Text,{className:"font-medium text-gray-900 text-sm mb-2",children:"Validating Discounts"}),(0,t.jsx)(a.Text,{className:"text-xs text-gray-600 mb-3",children:"Make a test request and check the response headers to verify discounts are applied:"}),(0,t.jsx)(eA.default,{language:"bash",code:`curl -X POST -i http://your-proxy:4000/chat/completions \\ - -H "Content-Type: application/json" \\ - -H "Authorization: Bearer sk-1234" \\ - -d '{ - "model": "gemini/gemini-2.5-pro", - "messages": [{"role": "user", "content": "Hello"}] - }'`}),(0,t.jsx)(a.Text,{className:"text-xs text-gray-600 mt-3 mb-2",children:"Look for these headers in the response:"}),(0,t.jsxs)("div",{className:"space-y-1.5",children:[(0,t.jsxs)("div",{className:"flex items-start gap-3",children:[(0,t.jsx)("code",{className:"bg-gray-100 px-2 py-1 rounded text-xs font-mono text-gray-800 whitespace-nowrap",children:"x-litellm-response-cost"}),(0,t.jsx)(a.Text,{className:"text-xs text-gray-600",children:"Final cost after discount"})]}),(0,t.jsxs)("div",{className:"flex items-start gap-3",children:[(0,t.jsx)("code",{className:"bg-gray-100 px-2 py-1 rounded text-xs font-mono text-gray-800 whitespace-nowrap",children:"x-litellm-response-cost-original"}),(0,t.jsx)(a.Text,{className:"text-xs text-gray-600",children:"Original cost before discount"})]}),(0,t.jsxs)("div",{className:"flex items-start gap-3",children:[(0,t.jsx)("code",{className:"bg-gray-100 px-2 py-1 rounded text-xs font-mono text-gray-800 whitespace-nowrap",children:"x-litellm-response-cost-discount-amount"}),(0,t.jsx)(a.Text,{className:"text-xs text-gray-600",children:"Amount discounted"})]})]})]}),(0,t.jsxs)("div",{className:"pt-4 border-t border-gray-200",children:[(0,t.jsx)(a.Text,{className:"font-medium text-gray-900 text-sm mb-3",children:"Discount Calculator"}),(0,t.jsx)(a.Text,{className:"text-xs text-gray-600 mb-3",children:"Enter values from your response headers to verify the discount:"}),(0,t.jsxs)("div",{className:"grid grid-cols-1 md:grid-cols-2 gap-4 mb-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsx)("label",{className:"block text-xs font-medium text-gray-700 mb-1",children:"Response Cost (x-litellm-response-cost)"}),(0,t.jsx)(h.TextInput,{placeholder:"0.0171938125",value:e,onValueChange:r,className:"text-sm"})]}),(0,t.jsxs)("div",{children:[(0,t.jsx)("label",{className:"block text-xs font-medium text-gray-700 mb-1",children:"Discount Amount (x-litellm-response-cost-discount-amount)"}),(0,t.jsx)(h.TextInput,{placeholder:"0.0009049375",value:l,onValueChange:n,className:"text-sm"})]})]}),o&&(0,t.jsxs)("div",{className:"bg-blue-50 border border-blue-200 rounded-lg p-4",children:[(0,t.jsx)(a.Text,{className:"text-sm font-medium text-blue-900 mb-2",children:"Calculated Results"}),(0,t.jsxs)("div",{className:"space-y-2",children:[(0,t.jsxs)("div",{className:"flex items-center justify-between",children:[(0,t.jsx)(a.Text,{className:"text-xs text-blue-800",children:"Original Cost:"}),(0,t.jsxs)("code",{className:"text-xs font-mono text-blue-900",children:["$",o.originalCost]})]}),(0,t.jsxs)("div",{className:"flex items-center justify-between",children:[(0,t.jsx)(a.Text,{className:"text-xs text-blue-800",children:"Final Cost:"}),(0,t.jsxs)("code",{className:"text-xs font-mono text-blue-900",children:["$",o.finalCost]})]}),(0,t.jsxs)("div",{className:"flex items-center justify-between",children:[(0,t.jsx)(a.Text,{className:"text-xs text-blue-800",children:"Discount Amount:"}),(0,t.jsxs)("code",{className:"text-xs font-mono text-blue-900",children:["$",o.discountAmount]})]}),(0,t.jsxs)("div",{className:"flex items-center justify-between pt-2 border-t border-blue-300",children:[(0,t.jsx)(a.Text,{className:"text-xs font-semibold text-blue-900",children:"Discount Applied:"}),(0,t.jsxs)(a.Text,{className:"text-sm font-bold text-blue-900",children:[o.discountPercentage,"%"]})]})]})]})]})]})};var ez=e.i(727749),eH=e.i(695411);let eG=[{label:"Custom pricing for models",href:"https://docs.litellm.ai/docs/proxy/custom_pricing"},{label:"Spend tracking",href:"https://docs.litellm.ai/docs/proxy/cost_tracking"}],eV=({userID:e,userRole:h,accessToken:f})=>{let[y,v]=(0,s.useState)(void 0),[j,b]=(0,s.useState)(""),[_,N]=(0,s.useState)(!0),[w,k]=(0,s.useState)(!1),[C,T]=(0,s.useState)(!1),[S,P]=(0,s.useState)(void 0),[M,O]=(0,s.useState)("percentage"),[F,R]=(0,s.useState)(""),[L,A]=(0,s.useState)(""),[z,H]=(0,s.useState)([]),[G]=g.Form.useForm(),[V]=g.Form.useForm(),[U,W]=p.Modal.useModal(),K="proxy_admin"===h||"Admin"===h,{discountConfig:J,fetchDiscountConfig:X,handleAddProvider:Y,handleRemoveProvider:Z,handleDiscountChange:Q}=function({accessToken:e}){let[t,r]=(0,s.useState)({}),a=(0,s.useCallback)(async()=>{try{let t=(0,eO.getProxyBaseUrl)(),s=t?`${t}/config/cost_discount_config`:"/config/cost_discount_config",a=await fetch(s,{method:"GET",headers:{[(0,eO.getGlobalLitellmHeaderName)()]:`Bearer ${e}`,"Content-Type":"application/json"}});if(a.ok){let e=await a.json();r(e.values||{})}else console.error("Failed to fetch discount config")}catch(e){console.error("Error fetching discount config:",e),ez.default.fromBackend("Failed to fetch discount configuration")}},[e]),l=(0,s.useCallback)(async t=>{try{let s=(0,eO.getProxyBaseUrl)(),r=s?`${s}/config/cost_discount_config`:"/config/cost_discount_config",l=await fetch(r,{method:"PATCH",headers:{[(0,eO.getGlobalLitellmHeaderName)()]:`Bearer ${e}`,"Content-Type":"application/json"},body:JSON.stringify(t)});if(l.ok)ez.default.success("Discount configuration updated successfully"),await a();else{let e=await l.json(),t=e.detail?.error||e.detail||"Failed to update settings";ez.default.fromBackend(t)}}catch(e){console.error("Error updating discount config:",e),ez.default.fromBackend("Failed to update discount configuration")}},[e,a]),n=(0,s.useCallback)(async(e,s)=>{if(!e||!s)return ez.default.fromBackend("Please select a provider and enter discount percentage"),!1;let a=parseFloat(s);if(isNaN(a)||a<0||a>100)return ez.default.fromBackend("Discount must be between 0% and 100%"),!1;let n=q(e);if(!n)return ez.default.fromBackend("Invalid provider selected"),!1;if(t[n])return ez.default.fromBackend(`Discount for ${$.Providers[e]} already exists. Edit it in the table above.`),!1;let o={...t,[n]:a/100};return r(o),await l(o),!0},[t,l]),o=(0,s.useCallback)(async e=>{let s={...t};delete s[e],r(s),await l(s)},[t,l]),i=(0,s.useCallback)(async(e,s)=>{let a=parseFloat(s);if(!isNaN(a)&&a>=0&&a<=1){let s={...t,[e]:a};r(s),await l(s)}},[t,l]);return{discountConfig:t,setDiscountConfig:r,fetchDiscountConfig:a,saveDiscountConfig:l,handleAddProvider:n,handleRemoveProvider:o,handleDiscountChange:i}}({accessToken:f}),{marginConfig:ee,fetchMarginConfig:et,handleAddMargin:es,handleRemoveMargin:er,handleMarginChange:ea}=function({accessToken:e}){let[t,r]=(0,s.useState)({}),a=(0,s.useCallback)(async()=>{try{let t=(0,eO.getProxyBaseUrl)(),s=t?`${t}/config/cost_margin_config`:"/config/cost_margin_config",a=await fetch(s,{method:"GET",headers:{[(0,eO.getGlobalLitellmHeaderName)()]:`Bearer ${e}`,"Content-Type":"application/json"}});if(a.ok){let e=await a.json();r(e.values||{})}else console.error("Failed to fetch margin config")}catch(e){console.error("Error fetching margin config:",e),ez.default.fromBackend("Failed to fetch margin configuration")}},[e]),l=(0,s.useCallback)(async t=>{try{let s=(0,eO.getProxyBaseUrl)(),r=s?`${s}/config/cost_margin_config`:"/config/cost_margin_config",l=await fetch(r,{method:"PATCH",headers:{[(0,eO.getGlobalLitellmHeaderName)()]:`Bearer ${e}`,"Content-Type":"application/json"},body:JSON.stringify(t)});if(l.ok)ez.default.success("Margin configuration updated successfully"),await a();else{let e=await l.json(),t=e.detail?.error||e.detail||"Failed to update settings";ez.default.fromBackend(t)}}catch(e){console.error("Error updating margin config:",e),ez.default.fromBackend("Failed to update margin configuration")}},[e,a]),n=(0,s.useCallback)(async e=>{let s,a,{selectedProvider:n,marginType:o,percentageValue:i,fixedAmountValue:d}=e;if(!n)return ez.default.fromBackend("Please select a provider"),!1;if("global"===n)s="global";else{let e=q(n);if(!e)return ez.default.fromBackend("Invalid provider selected"),!1;s=e}if(t[s]){let e="global"===s?"Global":$.Providers[n];return ez.default.fromBackend(`Margin for ${e} already exists. Edit it in the table above.`),!1}if("percentage"===o){let e=parseFloat(i);if(isNaN(e)||e<0||e>1e3)return ez.default.fromBackend("Percentage must be between 0% and 1000%"),!1;a=e/100}else{let e=parseFloat(d);if(isNaN(e)||e<0)return ez.default.fromBackend("Fixed amount must be non-negative"),!1;a={fixed_amount:e}}let c={...t,[s]:a};return r(c),await l(c),!0},[t,l]),o=(0,s.useCallback)(async e=>{let s={...t};delete s[e],r(s),await l(s)},[t,l]),i=(0,s.useCallback)(async(e,s)=>{let a={...t,[e]:s};r(a),await l(a)},[t,l]);return{marginConfig:t,setMarginConfig:r,fetchMarginConfig:a,saveMarginConfig:l,handleAddMargin:n,handleRemoveMargin:o,handleMarginChange:i}}({accessToken:f});(0,s.useEffect)(()=>{f&&(Promise.all([X(),et()]).finally(()=>{N(!1)}),(async()=>{try{let e=await (0,eH.fetchAvailableModels)(f);H(e.map(e=>e.model_group))}catch(e){console.error("Error fetching models:",e)}})())},[f,X,et]);let el=async()=>{await Y(y,j)&&(v(void 0),b(""),k(!1))},en=async(e,s)=>{U.confirm({title:"Remove Provider Discount",icon:(0,t.jsx)(eR.ExclamationCircleOutlined,{}),content:`Are you sure you want to remove the discount for ${s}?`,okText:"Remove",okType:"danger",cancelText:"Cancel",onOk:()=>Z(e)})},eo=async()=>{await es({selectedProvider:S,marginType:M,percentageValue:F,fixedAmountValue:L})&&(P(void 0),R(""),A(""),O("percentage"),T(!1))},ei=async(e,s)=>{U.confirm({title:"Remove Provider Margin",icon:(0,t.jsx)(eR.ExclamationCircleOutlined,{}),content:`Are you sure you want to remove the margin for ${s}?`,okText:"Remove",okType:"danger",cancelText:"Cancel",onOk:()=>er(e)})};return f?(0,t.jsxs)("div",{className:"w-full p-8",children:[W,(0,t.jsx)("div",{className:"flex flex-col md:flex-row items-start md:items-center justify-between mb-6",children:(0,t.jsxs)("div",{children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)(r.Title,{children:"Cost Tracking Settings"}),(0,t.jsx)(eD,{items:eG})]}),(0,t.jsx)(a.Text,{className:"text-gray-500 mt-1",children:"Configure cost discounts and margins for different LLM providers. Changes are saved automatically."})]})}),(0,t.jsxs)("div",{className:"bg-white rounded-lg shadow w-full max-w-full space-y-4",children:[K&&(0,t.jsxs)(n.Accordion,{children:[(0,t.jsx)(o.AccordionHeader,{className:"px-6 py-4",children:(0,t.jsxs)("div",{className:"flex flex-col items-start w-full",children:[(0,t.jsx)(a.Text,{className:"text-lg font-semibold text-gray-900",children:"Provider Discounts"}),(0,t.jsx)(a.Text,{className:"text-sm text-gray-500 mt-1",children:"Apply percentage-based discounts to reduce costs for specific providers"})]})}),(0,t.jsx)(i.AccordionBody,{className:"px-0",children:(0,t.jsxs)(d.TabGroup,{children:[(0,t.jsxs)(c.TabList,{className:"px-6 pt-4",children:[(0,t.jsx)(m.Tab,{children:"Discounts"}),(0,t.jsx)(m.Tab,{children:"Test It"})]}),(0,t.jsxs)(u.TabPanels,{children:[(0,t.jsx)(x.TabPanel,{children:(0,t.jsxs)("div",{className:"p-6",children:[(0,t.jsx)("div",{className:"flex justify-end mb-4",children:(0,t.jsx)(l.Button,{onClick:()=>k(!0),children:"+ Add Provider Discount"})}),_?(0,t.jsx)("div",{className:"py-12 text-center",children:(0,t.jsx)(a.Text,{className:"text-gray-500",children:"Loading configuration..."})}):Object.keys(J).length>0?(0,t.jsx)(E,{discountConfig:J,onDiscountChange:Q,onRemoveProvider:en}):(0,t.jsxs)("div",{className:"py-16 px-6 text-center",children:[(0,t.jsx)("svg",{className:"mx-auto h-12 w-12 text-gray-400 mb-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:1.5,d:"M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})}),(0,t.jsx)(a.Text,{className:"text-gray-700 font-medium mb-2",children:"No provider discounts configured"}),(0,t.jsx)(a.Text,{className:"text-gray-500 text-sm",children:'Click "Add Provider Discount" to get started'})]})]})}),(0,t.jsx)(x.TabPanel,{children:(0,t.jsx)("div",{className:"px-6 pb-4",children:(0,t.jsx)(eB,{})})})]})]})})]}),K&&(0,t.jsxs)(n.Accordion,{children:[(0,t.jsx)(o.AccordionHeader,{className:"px-6 py-4",children:(0,t.jsxs)("div",{className:"flex flex-col items-start w-full",children:[(0,t.jsx)(a.Text,{className:"text-lg font-semibold text-gray-900",children:"Fee/Price Margin"}),(0,t.jsx)(a.Text,{className:"text-sm text-gray-500 mt-1",children:"Add fees or margins to LLM costs for internal billing and cost recovery"})]})}),(0,t.jsx)(i.AccordionBody,{className:"px-0",children:(0,t.jsxs)("div",{className:"p-6",children:[(0,t.jsx)("div",{className:"flex justify-end mb-4",children:(0,t.jsx)(l.Button,{onClick:()=>T(!0),children:"+ Add Provider Margin"})}),_?(0,t.jsx)("div",{className:"py-12 text-center",children:(0,t.jsx)(a.Text,{className:"text-gray-500",children:"Loading configuration..."})}):Object.keys(ee).length>0?(0,t.jsx)(D,{marginConfig:ee,onMarginChange:ea,onRemoveProvider:ei}):(0,t.jsxs)("div",{className:"py-16 px-6 text-center",children:[(0,t.jsx)("svg",{className:"mx-auto h-12 w-12 text-gray-400 mb-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:1.5,d:"M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})}),(0,t.jsx)(a.Text,{className:"text-gray-700 font-medium mb-2",children:"No provider margins configured"}),(0,t.jsx)(a.Text,{className:"text-gray-500 text-sm",children:'Click "Add Provider Margin" to get started'})]})]})})]}),(0,t.jsxs)(n.Accordion,{defaultOpen:!0,children:[(0,t.jsx)(o.AccordionHeader,{className:"px-6 py-4",children:(0,t.jsxs)("div",{className:"flex flex-col items-start w-full",children:[(0,t.jsx)(a.Text,{className:"text-lg font-semibold text-gray-900",children:"Pricing Calculator"}),(0,t.jsx)(a.Text,{className:"text-sm text-gray-500 mt-1",children:"Estimate LLM costs based on expected token usage and request volume"})]})}),(0,t.jsx)(i.AccordionBody,{className:"px-0",children:(0,t.jsx)("div",{className:"p-6",children:(0,t.jsx)(eF,{accessToken:f,models:z})})})]})]}),(0,t.jsx)(p.Modal,{title:(0,t.jsx)("div",{className:"flex items-center space-x-3 pb-4 border-b border-gray-100",children:(0,t.jsx)("h2",{className:"text-xl font-semibold text-gray-900",children:"Add Provider Discount"})}),open:w,width:1e3,onCancel:()=>{k(!1),G.resetFields(),v(void 0),b("")},footer:null,className:"top-8",styles:{body:{padding:"24px"},header:{padding:"24px 24px 0 24px",border:"none"}},children:(0,t.jsxs)("div",{className:"mt-6",children:[(0,t.jsx)(a.Text,{className:"text-sm text-gray-600 mb-6",children:"Select a provider and set its discount percentage. Enter a value between 0% and 100% (e.g., 5 for a 5% discount)."}),(0,t.jsx)(g.Form,{form:G,onFinish:()=>{el()},layout:"vertical",className:"space-y-6",children:(0,t.jsx)(I,{discountConfig:J,selectedProvider:y,newDiscount:j,onProviderChange:v,onDiscountChange:b,onAddProvider:el})})]})}),(0,t.jsx)(p.Modal,{title:(0,t.jsx)("div",{className:"flex items-center space-x-3 pb-4 border-b border-gray-100",children:(0,t.jsx)("h2",{className:"text-xl font-semibold text-gray-900",children:"Add Provider Margin"})}),open:C,width:1e3,onCancel:()=>{T(!1),V.resetFields(),P(void 0),R(""),A(""),O("percentage")},footer:null,className:"top-8",styles:{body:{padding:"24px"},header:{padding:"24px 24px 0 24px",border:"none"}},children:(0,t.jsxs)("div",{className:"mt-6",children:[(0,t.jsx)(a.Text,{className:"text-sm text-gray-600 mb-6",children:'Select a provider (or "Global" for all providers) and configure the margin. You can use percentage-based or fixed amount.'}),(0,t.jsx)(g.Form,{form:V,layout:"vertical",className:"space-y-6",children:(0,t.jsx)(B,{marginConfig:ee,selectedProvider:S,marginType:M,percentageValue:F,fixedAmountValue:L,onProviderChange:P,onMarginTypeChange:O,onPercentageChange:R,onFixedAmountChange:A,onAddProvider:eo})})]})})]}):null};var eU=e.i(135214);e.s(["default",0,function(){let{accessToken:e,userRole:s,userId:r}=(0,eU.default)();return(0,t.jsx)(eV,{userID:r,userRole:s,accessToken:e})}],193317)}]); \ No newline at end of file diff --git a/litellm/proxy/_experimental/out/_next/static/chunks/0hzdsr8t0ksq..js b/litellm/proxy/_experimental/out/_next/static/chunks/0hzdsr8t0ksq..js deleted file mode 100644 index 7827eed004d..00000000000 --- a/litellm/proxy/_experimental/out/_next/static/chunks/0hzdsr8t0ksq..js +++ /dev/null @@ -1,2 +0,0 @@ -(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,184163,e=>{"use strict";e.i(247167);var t=e.i(931067),r=e.i(271645);let n={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M505.7 661a8 8 0 0012.6 0l112-141.7c4.1-5.2.4-12.9-6.3-12.9h-74.1V168c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v338.3H400c-6.7 0-10.4 7.7-6.3 12.9l112 141.8zM878 626h-60c-4.4 0-8 3.6-8 8v154H214V634c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v198c0 17.7 14.3 32 32 32h684c17.7 0 32-14.3 32-32V634c0-4.4-3.6-8-8-8z"}}]},name:"download",theme:"outlined"};var i=e.i(9583),s=r.forwardRef(function(e,s){return r.createElement(i.default,(0,t.default)({},e,{ref:s,icon:n}))});e.s(["default",0,s],184163)},309821,e=>{"use strict";e.i(247167);var t=e.i(271645),r=e.i(135551),n=e.i(201072),i=e.i(121229),s=e.i(726289),a=e.i(864517),l=e.i(343794),o=e.i(529681),u=e.i(242064),c=e.i(931067),d=e.i(209428),m=e.i(703923),f={percent:0,prefixCls:"rc-progress",strokeColor:"#2db7f5",strokeLinecap:"round",strokeWidth:1,trailColor:"#D9D9D9",trailWidth:1,gapPosition:"bottom"},p=function(){var e=(0,t.useRef)([]),r=(0,t.useRef)(null);return(0,t.useEffect)(function(){var t=Date.now(),n=!1;e.current.forEach(function(e){if(e){n=!0;var i=e.style;i.transitionDuration=".3s, .3s, .3s, .06s",r.current&&t-r.current<100&&(i.transitionDuration="0s, 0s")}}),n&&(r.current=Date.now())}),e.current},h=e.i(410160),g=e.i(392221),x=e.i(654310),v=0,y=(0,x.default)();let b=function(e){var r=t.useState(),n=(0,g.default)(r,2),i=n[0],s=n[1];return t.useEffect(function(){var e;s("rc_progress_".concat((y?(e=v,v+=1):e="TEST_OR_SSR",e)))},[]),e||i};var _=function(e){var r=e.bg,n=e.children;return t.createElement("div",{style:{width:"100%",height:"100%",background:r}},n)};function w(e,t){return Object.keys(e).map(function(r){var n=parseFloat(r),i="".concat(Math.floor(n*t),"%");return"".concat(e[r]," ").concat(i)})}var j=t.forwardRef(function(e,r){var n=e.prefixCls,i=e.color,s=e.gradientId,a=e.radius,l=e.style,o=e.ptg,u=e.strokeLinecap,c=e.strokeWidth,d=e.size,m=e.gapDegree,f=i&&"object"===(0,h.default)(i),p=d/2,g=t.createElement("circle",{className:"".concat(n,"-circle-path"),r:a,cx:p,cy:p,stroke:f?"#FFF":void 0,strokeLinecap:u,strokeWidth:c,opacity:+(0!==o),style:l,ref:r});if(!f)return g;var x="".concat(s,"-conic"),v=w(i,(360-m)/360),y=w(i,1),b="conic-gradient(from ".concat(m?"".concat(180+m/2,"deg"):"0deg",", ").concat(v.join(", "),")"),j="linear-gradient(to ".concat(m?"bottom":"top",", ").concat(y.join(", "),")");return t.createElement(t.Fragment,null,t.createElement("mask",{id:x},g),t.createElement("foreignObject",{x:0,y:0,width:d,height:d,mask:"url(#".concat(x,")")},t.createElement(_,{bg:j},t.createElement(_,{bg:b}))))}),k=function(e,t,r,n,i,s,a,l,o,u){var c=arguments.length>10&&void 0!==arguments[10]?arguments[10]:0,d=(100-n)/100*t;return"round"===o&&100!==n&&(d+=u/2)>=t&&(d=t-.01),{stroke:"string"==typeof l?l:void 0,strokeDasharray:"".concat(t,"px ").concat(e),strokeDashoffset:d+c,transform:"rotate(".concat(i+r/100*360*((360-s)/360)+(0===s?0:({bottom:0,top:180,left:90,right:-90})[a]),"deg)"),transformOrigin:"".concat(50,"px ").concat(50,"px"),transition:"stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s",fillOpacity:0}},C=["id","prefixCls","steps","strokeWidth","trailWidth","gapDegree","gapPosition","trailColor","strokeLinecap","style","className","strokeColor","percent"];function S(e){var t=null!=e?e:[];return Array.isArray(t)?t:[t]}let E=function(e){var r,n,i,s,a=(0,d.default)((0,d.default)({},f),e),o=a.id,u=a.prefixCls,g=a.steps,x=a.strokeWidth,v=a.trailWidth,y=a.gapDegree,_=void 0===y?0:y,w=a.gapPosition,E=a.trailColor,O=a.strokeLinecap,N=a.style,I=a.className,T=a.strokeColor,R=a.percent,P=(0,m.default)(a,C),$=b(o),D="".concat($,"-gradient"),F=50-x/2,A=2*Math.PI*F,L=_>0?90+_/2:-90,M=(360-_)/360*A,B="object"===(0,h.default)(g)?g:{count:g,gap:2},U=B.count,z=B.gap,V=S(R),H=S(T),W=H.find(function(e){return e&&"object"===(0,h.default)(e)}),K=W&&"object"===(0,h.default)(W)?"butt":O,q=k(A,M,0,100,L,_,w,E,K,x),X=p();return t.createElement("svg",(0,c.default)({className:(0,l.default)("".concat(u,"-circle"),I),viewBox:"0 0 ".concat(100," ").concat(100),style:N,id:o,role:"presentation"},P),!U&&t.createElement("circle",{className:"".concat(u,"-circle-trail"),r:F,cx:50,cy:50,stroke:E,strokeLinecap:K,strokeWidth:v||x,style:q}),U?(r=Math.round(U*(V[0]/100)),n=100/U,i=0,Array(U).fill(null).map(function(e,s){var a=s<=r-1?H[0]:E,l=a&&"object"===(0,h.default)(a)?"url(#".concat(D,")"):void 0,o=k(A,M,i,n,L,_,w,a,"butt",x,z);return i+=(M-o.strokeDashoffset+z)*100/M,t.createElement("circle",{key:s,className:"".concat(u,"-circle-path"),r:F,cx:50,cy:50,stroke:l,strokeWidth:x,opacity:1,style:o,ref:function(e){X[s]=e}})})):(s=0,V.map(function(e,r){var n=H[r]||H[H.length-1],i=k(A,M,s,e,L,_,w,n,K,x);return s+=e,t.createElement(j,{key:r,color:n,ptg:e,radius:F,prefixCls:u,gradientId:D,style:i,strokeLinecap:K,strokeWidth:x,gapDegree:_,ref:function(e){X[r]=e},size:100})}).reverse()))};var O=e.i(491816);e.i(765846);var N=e.i(896091);function I(e){return!e||e<0?0:e>100?100:e}function T({success:e,successPercent:t}){let r=t;return e&&"progress"in e&&(r=e.progress),e&&"percent"in e&&(r=e.percent),r}let R=(e,t,r)=>{var n,i,s,a;let l=-1,o=-1;if("step"===t){let t=r.steps,n=r.strokeWidth;"string"==typeof e||void 0===e?(l="small"===e?2:14,o=null!=n?n:8):"number"==typeof e?[l,o]=[e,e]:[l=14,o=8]=Array.isArray(e)?e:[e.width,e.height],l*=t}else if("line"===t){let t=null==r?void 0:r.strokeWidth;"string"==typeof e||void 0===e?o=t||("small"===e?6:8):"number"==typeof e?[l,o]=[e,e]:[l=-1,o=8]=Array.isArray(e)?e:[e.width,e.height]}else("circle"===t||"dashboard"===t)&&("string"==typeof e||void 0===e?[l,o]="small"===e?[60,60]:[120,120]:"number"==typeof e?[l,o]=[e,e]:Array.isArray(e)&&(l=null!=(i=null!=(n=e[0])?n:e[1])?i:120,o=null!=(a=null!=(s=e[0])?s:e[1])?a:120));return[l,o]},P=e=>{let{prefixCls:r,trailColor:n=null,strokeLinecap:i="round",gapPosition:s,gapDegree:a,width:o=120,type:u,children:c,success:d,size:m=o,steps:f}=e,[p,h]=R(m,"circle"),{strokeWidth:g}=e;void 0===g&&(g=Math.max(3/p*100,6));let x=t.useMemo(()=>a||0===a?a:"dashboard"===u?75:void 0,[a,u]),v=(({percent:e,success:t,successPercent:r})=>{let n=I(T({success:t,successPercent:r}));return[n,I(I(e)-n)]})(e),y="[object Object]"===Object.prototype.toString.call(e.strokeColor),b=(({success:e={},strokeColor:t})=>{let{strokeColor:r}=e;return[r||N.presetPrimaryColors.green,t||null]})({success:d,strokeColor:e.strokeColor}),_=(0,l.default)(`${r}-inner`,{[`${r}-circle-gradient`]:y}),w=t.createElement(E,{steps:f,percent:f?v[1]:v,strokeWidth:g,trailWidth:g,strokeColor:f?b[1]:b,strokeLinecap:i,trailColor:n,prefixCls:r,gapDegree:x,gapPosition:s||"dashboard"===u&&"bottom"||void 0}),j=p<=20,k=t.createElement("div",{className:_,style:{width:p,height:h,fontSize:.15*p+6}},w,!j&&c);return j?t.createElement(O.default,{title:c},k):k};e.i(296059);var $=e.i(694758),D=e.i(915654),F=e.i(183293),A=e.i(246422),L=e.i(838378);let M="--progress-line-stroke-color",B="--progress-percent",U=e=>{let t=e?"100%":"-100%";return new $.Keyframes(`antProgress${e?"RTL":"LTR"}Active`,{"0%":{transform:`translateX(${t}) scaleX(0)`,opacity:.1},"20%":{transform:`translateX(${t}) scaleX(0)`,opacity:.5},to:{transform:"translateX(0) scaleX(1)",opacity:0}})},z=(0,A.genStyleHooks)("Progress",e=>{let t=e.calc(e.marginXXS).div(2).equal(),r=(0,L.mergeToken)(e,{progressStepMarginInlineEnd:t,progressStepMinWidth:t,progressActiveMotionDuration:"2.4s"});return[(e=>{let{componentCls:t,iconCls:r}=e;return{[t]:Object.assign(Object.assign({},(0,F.resetComponent)(e)),{display:"inline-block","&-rtl":{direction:"rtl"},"&-line":{position:"relative",width:"100%",fontSize:e.fontSize},[`${t}-outer`]:{display:"inline-flex",alignItems:"center",width:"100%"},[`${t}-inner`]:{position:"relative",display:"inline-block",width:"100%",flex:1,overflow:"hidden",verticalAlign:"middle",backgroundColor:e.remainingColor,borderRadius:e.lineBorderRadius},[`${t}-inner:not(${t}-circle-gradient)`]:{[`${t}-circle-path`]:{stroke:e.defaultColor}},[`${t}-success-bg, ${t}-bg`]:{position:"relative",background:e.defaultColor,borderRadius:e.lineBorderRadius,transition:`all ${e.motionDurationSlow} ${e.motionEaseInOutCirc}`},[`${t}-layout-bottom`]:{display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",[`${t}-text`]:{width:"max-content",marginInlineStart:0,marginTop:e.marginXXS}},[`${t}-bg`]:{overflow:"hidden","&::after":{content:'""',background:{_multi_value_:!0,value:["inherit",`var(${M})`]},height:"100%",width:`calc(1 / var(${B}) * 100%)`,display:"block"},[`&${t}-bg-inner`]:{minWidth:"max-content","&::after":{content:"none"},[`${t}-text-inner`]:{color:e.colorWhite,[`&${t}-text-bright`]:{color:"rgba(0, 0, 0, 0.45)"}}}},[`${t}-success-bg`]:{position:"absolute",insetBlockStart:0,insetInlineStart:0,backgroundColor:e.colorSuccess},[`${t}-text`]:{display:"inline-block",marginInlineStart:e.marginXS,color:e.colorText,lineHeight:1,width:"2em",whiteSpace:"nowrap",textAlign:"start",verticalAlign:"middle",wordBreak:"normal",[r]:{fontSize:e.fontSize},[`&${t}-text-outer`]:{width:"max-content"},[`&${t}-text-outer${t}-text-start`]:{width:"max-content",marginInlineStart:0,marginInlineEnd:e.marginXS}},[`${t}-text-inner`]:{display:"flex",justifyContent:"center",alignItems:"center",width:"100%",height:"100%",marginInlineStart:0,padding:`0 ${(0,D.unit)(e.paddingXXS)}`,[`&${t}-text-start`]:{justifyContent:"start"},[`&${t}-text-end`]:{justifyContent:"end"}},[`&${t}-status-active`]:{[`${t}-bg::before`]:{position:"absolute",inset:0,backgroundColor:e.colorBgContainer,borderRadius:e.lineBorderRadius,opacity:0,animationName:U(),animationDuration:e.progressActiveMotionDuration,animationTimingFunction:e.motionEaseOutQuint,animationIterationCount:"infinite",content:'""'}},[`&${t}-rtl${t}-status-active`]:{[`${t}-bg::before`]:{animationName:U(!0)}},[`&${t}-status-exception`]:{[`${t}-bg`]:{backgroundColor:e.colorError},[`${t}-text`]:{color:e.colorError}},[`&${t}-status-exception ${t}-inner:not(${t}-circle-gradient)`]:{[`${t}-circle-path`]:{stroke:e.colorError}},[`&${t}-status-success`]:{[`${t}-bg`]:{backgroundColor:e.colorSuccess},[`${t}-text`]:{color:e.colorSuccess}},[`&${t}-status-success ${t}-inner:not(${t}-circle-gradient)`]:{[`${t}-circle-path`]:{stroke:e.colorSuccess}}})}})(r),(e=>{let{componentCls:t,iconCls:r}=e;return{[t]:{[`${t}-circle-trail`]:{stroke:e.remainingColor},[`&${t}-circle ${t}-inner`]:{position:"relative",lineHeight:1,backgroundColor:"transparent"},[`&${t}-circle ${t}-text`]:{position:"absolute",insetBlockStart:"50%",insetInlineStart:0,width:"100%",margin:0,padding:0,color:e.circleTextColor,fontSize:e.circleTextFontSize,lineHeight:1,whiteSpace:"normal",textAlign:"center",transform:"translateY(-50%)",[r]:{fontSize:e.circleIconFontSize}},[`${t}-circle&-status-exception`]:{[`${t}-text`]:{color:e.colorError}},[`${t}-circle&-status-success`]:{[`${t}-text`]:{color:e.colorSuccess}}},[`${t}-inline-circle`]:{lineHeight:1,[`${t}-inner`]:{verticalAlign:"bottom"}}}})(r),(e=>{let{componentCls:t}=e;return{[t]:{[`${t}-steps`]:{display:"inline-block","&-outer":{display:"flex",flexDirection:"row",alignItems:"center"},"&-item":{flexShrink:0,minWidth:e.progressStepMinWidth,marginInlineEnd:e.progressStepMarginInlineEnd,backgroundColor:e.remainingColor,transition:`all ${e.motionDurationSlow}`,"&-active":{backgroundColor:e.defaultColor}}}}}})(r),(e=>{let{componentCls:t,iconCls:r}=e;return{[t]:{[`${t}-small&-line, ${t}-small&-line ${t}-text ${r}`]:{fontSize:e.fontSizeSM}}}})(r)]},e=>({circleTextColor:e.colorText,defaultColor:e.colorInfo,remainingColor:e.colorFillSecondary,lineBorderRadius:100,circleTextFontSize:"1em",circleIconFontSize:`${e.fontSize/e.fontSizeSM}em`}));var V=function(e,t){var r={};for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&0>t.indexOf(n)&&(r[n]=e[n]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols)for(var i=0,n=Object.getOwnPropertySymbols(e);it.indexOf(n[i])&&Object.prototype.propertyIsEnumerable.call(e,n[i])&&(r[n[i]]=e[n[i]]);return r};let H=e=>{let{prefixCls:r,direction:n,percent:i,size:s,strokeWidth:a,strokeColor:o,strokeLinecap:u="round",children:c,trailColor:d=null,percentPosition:m,success:f}=e,{align:p,type:h}=m,g=o&&"string"!=typeof o?((e,t)=>{let{from:r=N.presetPrimaryColors.blue,to:n=N.presetPrimaryColors.blue,direction:i="rtl"===t?"to left":"to right"}=e,s=V(e,["from","to","direction"]);if(0!==Object.keys(s).length){let e,t=(e=[],Object.keys(s).forEach(t=>{let r=Number.parseFloat(t.replace(/%/g,""));Number.isNaN(r)||e.push({key:r,value:s[t]})}),(e=e.sort((e,t)=>e.key-t.key)).map(({key:e,value:t})=>`${t} ${e}%`).join(", ")),r=`linear-gradient(${i}, ${t})`;return{background:r,[M]:r}}let a=`linear-gradient(${i}, ${r}, ${n})`;return{background:a,[M]:a}})(o,n):{[M]:o,background:o},x="square"===u||"butt"===u?0:void 0,[v,y]=R(null!=s?s:[-1,a||("small"===s?6:8)],"line",{strokeWidth:a}),b=Object.assign(Object.assign({width:`${I(i)}%`,height:y,borderRadius:x},g),{[B]:I(i)/100}),_=T(e),w={width:`${I(_)}%`,height:y,borderRadius:x,backgroundColor:null==f?void 0:f.strokeColor},j=t.createElement("div",{className:`${r}-inner`,style:{backgroundColor:d||void 0,borderRadius:x}},t.createElement("div",{className:(0,l.default)(`${r}-bg`,`${r}-bg-${h}`),style:b},"inner"===h&&c),void 0!==_&&t.createElement("div",{className:`${r}-success-bg`,style:w})),k="outer"===h&&"start"===p,C="outer"===h&&"end"===p;return"outer"===h&&"center"===p?t.createElement("div",{className:`${r}-layout-bottom`},j,c):t.createElement("div",{className:`${r}-outer`,style:{width:v<0?"100%":v}},k&&c,j,C&&c)},W=e=>{let{size:r,steps:n,rounding:i=Math.round,percent:s=0,strokeWidth:a=8,strokeColor:o,trailColor:u=null,prefixCls:c,children:d}=e,m=i(s/100*n),[f,p]=R(null!=r?r:["small"===r?2:14,a],"step",{steps:n,strokeWidth:a}),h=f/n,g=Array.from({length:n});for(let e=0;et.indexOf(n)&&(r[n]=e[n]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols)for(var i=0,n=Object.getOwnPropertySymbols(e);it.indexOf(n[i])&&Object.prototype.propertyIsEnumerable.call(e,n[i])&&(r[n[i]]=e[n[i]]);return r};let q=["normal","exception","active","success"],X=t.forwardRef((e,c)=>{let d,{prefixCls:m,className:f,rootClassName:p,steps:h,strokeColor:g,percent:x=0,size:v="default",showInfo:y=!0,type:b="line",status:_,format:w,style:j,percentPosition:k={}}=e,C=K(e,["prefixCls","className","rootClassName","steps","strokeColor","percent","size","showInfo","type","status","format","style","percentPosition"]),{align:S="end",type:E="outer"}=k,O=Array.isArray(g)?g[0]:g,N="string"==typeof g||Array.isArray(g)?g:void 0,$=t.useMemo(()=>{if(O){let e="string"==typeof O?O:Object.values(O)[0];return new r.FastColor(e).isLight()}return!1},[g]),D=t.useMemo(()=>{var t,r;let n=T(e);return Number.parseInt(void 0!==n?null==(t=null!=n?n:0)?void 0:t.toString():null==(r=null!=x?x:0)?void 0:r.toString(),10)},[x,e.success,e.successPercent]),F=t.useMemo(()=>!q.includes(_)&&D>=100?"success":_||"normal",[_,D]),{getPrefixCls:A,direction:L,progress:M}=t.useContext(u.ConfigContext),B=A("progress",m),[U,V,X]=z(B),Q="line"===b,J=Q&&!h,Y=t.useMemo(()=>{let r;if(!y)return null;let o=T(e),u=w||(e=>`${e}%`),c=Q&&$&&"inner"===E;return"inner"===E||w||"exception"!==F&&"success"!==F?r=u(I(x),I(o)):"exception"===F?r=Q?t.createElement(s.default,null):t.createElement(a.default,null):"success"===F&&(r=Q?t.createElement(n.default,null):t.createElement(i.default,null)),t.createElement("span",{className:(0,l.default)(`${B}-text`,{[`${B}-text-bright`]:c,[`${B}-text-${S}`]:J,[`${B}-text-${E}`]:J}),title:"string"==typeof r?r:void 0},r)},[y,x,D,F,b,B,w]);"line"===b?d=h?t.createElement(W,Object.assign({},e,{strokeColor:N,prefixCls:B,steps:"object"==typeof h?h.count:h}),Y):t.createElement(H,Object.assign({},e,{strokeColor:O,prefixCls:B,direction:L,percentPosition:{align:S,type:E}}),Y):("circle"===b||"dashboard"===b)&&(d=t.createElement(P,Object.assign({},e,{strokeColor:O,prefixCls:B,progressStatus:F}),Y));let G=(0,l.default)(B,`${B}-status-${F}`,{[`${B}-${"dashboard"===b&&"circle"||b}`]:"line"!==b,[`${B}-inline-circle`]:"circle"===b&&R(v,"circle")[0]<=20,[`${B}-line`]:J,[`${B}-line-align-${S}`]:J,[`${B}-line-position-${E}`]:J,[`${B}-steps`]:h,[`${B}-show-info`]:y,[`${B}-${v}`]:"string"==typeof v,[`${B}-rtl`]:"rtl"===L},null==M?void 0:M.className,f,p,V,X);return U(t.createElement("div",Object.assign({ref:c,style:Object.assign(Object.assign({},null==M?void 0:M.style),j),className:G,role:"progressbar","aria-valuenow":D,"aria-valuemin":0,"aria-valuemax":100},(0,o.default)(C,["trailColor","strokeWidth","width","gapDegree","gapPosition","strokeLinecap","success","successPercent"])),d))});e.s(["default",0,X],309821)},519756,e=>{"use strict";e.i(247167);var t=e.i(931067),r=e.i(271645);let n={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M400 317.7h73.9V656c0 4.4 3.6 8 8 8h60c4.4 0 8-3.6 8-8V317.7H624c6.7 0 10.4-7.7 6.3-12.9L518.3 163a8 8 0 00-12.6 0l-112 141.7c-4.1 5.3-.4 13 6.3 13zM878 626h-60c-4.4 0-8 3.6-8 8v154H214V634c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v198c0 17.7 14.3 32 32 32h684c17.7 0 32-14.3 32-32V634c0-4.4-3.6-8-8-8z"}}]},name:"upload",theme:"outlined"};var i=e.i(9583),s=r.forwardRef(function(e,s){return r.createElement(i.default,(0,t.default)({},e,{ref:s,icon:n}))});e.s(["UploadOutlined",0,s],519756)},233538,e=>{"use strict";e.s(["isDisabledReactIssue7711",0,function(e){let t=e.parentElement,r=null;for(;t&&!(t instanceof HTMLFieldSetElement);)t instanceof HTMLLegendElement&&(r=t),t=t.parentElement;let n=(null==t?void 0:t.getAttribute("disabled"))==="";return!(n&&function(e){if(!e)return!1;let t=e.previousElementSibling;for(;null!==t;){if(t instanceof HTMLLegendElement)return!1;t=t.previousElementSibling}return!0}(r))&&n}])},83733,233137,e=>{"use strict";let t,r;var n,i,s=e.i(247167),a=e.i(271645),l=e.i(544508),o=e.i(746725),u=e.i(835696);void 0!==s.default&&"u">typeof globalThis&&"u">typeof Element&&(null==(n=null==s.default?void 0:s.default.env)?void 0:n.NODE_ENV)==="test"&&void 0===(null==(i=null==Element?void 0:Element.prototype)?void 0:i.getAnimations)&&(Element.prototype.getAnimations=function(){return console.warn(["Headless UI has polyfilled `Element.prototype.getAnimations` for your tests.","Please install a proper polyfill e.g. `jsdom-testing-mocks`, to silence these warnings.","","Example usage:","```js","import { mockAnimationsApi } from 'jsdom-testing-mocks'","mockAnimationsApi()","```"].join(` -`)),[]});var c=((t=c||{})[t.None=0]="None",t[t.Closed=1]="Closed",t[t.Enter=2]="Enter",t[t.Leave=4]="Leave",t);e.s(["transitionDataAttributes",0,function(e){let t={};for(let r in e)!0===e[r]&&(t[`data-${r}`]="");return t},"useTransition",0,function(e,t,r,n){let[i,s]=(0,a.useState)(r),{hasFlag:c,addFlag:d,removeFlag:m}=function(e=0){let[t,r]=(0,a.useState)(e),n=(0,a.useCallback)(e=>r(e),[t]),i=(0,a.useCallback)(e=>r(t=>t|e),[t]),s=(0,a.useCallback)(e=>(t&e)===e,[t]);return{flags:t,setFlag:n,addFlag:i,hasFlag:s,removeFlag:(0,a.useCallback)(e=>r(t=>t&~e),[r]),toggleFlag:(0,a.useCallback)(e=>r(t=>t^e),[r])}}(e&&i?3:0),f=(0,a.useRef)(!1),p=(0,a.useRef)(!1),h=(0,o.useDisposables)();return(0,u.useIsoMorphicEffect)(()=>{var i;if(e){if(r&&s(!0),!t){r&&d(3);return}return null==(i=null==n?void 0:n.start)||i.call(n,r),function(e,{prepare:t,run:r,done:n,inFlight:i}){let s=(0,l.disposables)();return function(e,{inFlight:t,prepare:r}){if(null!=t&&t.current)return r();let n=e.style.transition;e.style.transition="none",r(),e.offsetHeight,e.style.transition=n}(e,{prepare:t,inFlight:i}),s.nextFrame(()=>{r(),s.requestAnimationFrame(()=>{s.add(function(e,t){var r,n;let i=(0,l.disposables)();if(!e)return i.dispose;let s=!1;i.add(()=>{s=!0});let a=null!=(n=null==(r=e.getAnimations)?void 0:r.call(e).filter(e=>e instanceof CSSTransition))?n:[];return 0===a.length?t():Promise.allSettled(a.map(e=>e.finished)).then(()=>{s||t()}),i.dispose}(e,n))})}),s.dispose}(t,{inFlight:f,prepare(){p.current?p.current=!1:p.current=f.current,f.current=!0,p.current||(r?(d(3),m(4)):(d(4),m(2)))},run(){p.current?r?(m(3),d(4)):(m(4),d(3)):r?m(1):d(1)},done(){var e;p.current&&"function"==typeof t.getAnimations&&t.getAnimations().length>0||(f.current=!1,m(7),r||s(!1),null==(e=null==n?void 0:n.end)||e.call(n,r))}})}},[e,r,t,h]),e?[i,{closed:c(1),enter:c(2),leave:c(4),transition:c(2)||c(4)}]:[r,{closed:void 0,enter:void 0,leave:void 0,transition:void 0}]}],83733);let d=(0,a.createContext)(null);d.displayName="OpenClosedContext";var m=((r=m||{})[r.Open=1]="Open",r[r.Closed=2]="Closed",r[r.Closing=4]="Closing",r[r.Opening=8]="Opening",r);e.s(["OpenClosedProvider",0,function({value:e,children:t}){return a.default.createElement(d.Provider,{value:e},t)},"ResetOpenClosedProvider",0,function({children:e}){return a.default.createElement(d.Provider,{value:null},e)},"State",0,m,"useOpenClosed",0,function(){return(0,a.useContext)(d)}],233137)},677667,674175,886148,543086,e=>{"use strict";let t,r;var n,i=e.i(290571),s=e.i(783222),a=e.i(433336),l=e.i(271645),o=e.i(394487),u=e.i(914189),c=e.i(144279),d=e.i(294316),m=e.i(83733);let f=(0,l.createContext)(()=>{});function p({value:e,children:t}){return l.default.createElement(f.Provider,{value:e},t)}e.s(["CloseProvider",0,p],674175);var h=e.i(233137),g=e.i(233538),x=e.i(397701),v=e.i(402155),y=e.i(700020);let b=null!=(n=l.default.startTransition)?n:function(e){e()};var _=e.i(998348),w=((t=w||{})[t.Open=0]="Open",t[t.Closed=1]="Closed",t),j=((r=j||{})[r.ToggleDisclosure=0]="ToggleDisclosure",r[r.CloseDisclosure=1]="CloseDisclosure",r[r.SetButtonId=2]="SetButtonId",r[r.SetPanelId=3]="SetPanelId",r[r.SetButtonElement=4]="SetButtonElement",r[r.SetPanelElement=5]="SetPanelElement",r);let k={0:e=>({...e,disclosureState:(0,x.match)(e.disclosureState,{0:1,1:0})}),1:e=>1===e.disclosureState?e:{...e,disclosureState:1},2:(e,t)=>e.buttonId===t.buttonId?e:{...e,buttonId:t.buttonId},3:(e,t)=>e.panelId===t.panelId?e:{...e,panelId:t.panelId},4:(e,t)=>e.buttonElement===t.element?e:{...e,buttonElement:t.element},5:(e,t)=>e.panelElement===t.element?e:{...e,panelElement:t.element}},C=(0,l.createContext)(null);function S(e){let t=(0,l.useContext)(C);if(null===t){let t=Error(`<${e} /> is missing a parent component.`);throw Error.captureStackTrace&&Error.captureStackTrace(t,S),t}return t}C.displayName="DisclosureContext";let E=(0,l.createContext)(null);E.displayName="DisclosureAPIContext";let O=(0,l.createContext)(null);function N(e,t){return(0,x.match)(t.type,k,e,t)}O.displayName="DisclosurePanelContext";let I=l.Fragment,T=y.RenderFeatures.RenderStrategy|y.RenderFeatures.Static,R=Object.assign((0,y.forwardRefWithAs)(function(e,t){let{defaultOpen:r=!1,...n}=e,i=(0,l.useRef)(null),s=(0,d.useSyncRefs)(t,(0,d.optionalRef)(e=>{i.current=e},void 0===e.as||e.as===l.Fragment)),a=(0,l.useReducer)(N,{disclosureState:+!r,buttonElement:null,panelElement:null,buttonId:null,panelId:null}),[{disclosureState:o,buttonId:c},m]=a,f=(0,u.useEvent)(e=>{m({type:1});let t=(0,v.getOwnerDocument)(i);if(!t||!c)return;let r=e?e instanceof HTMLElement?e:e.current instanceof HTMLElement?e.current:t.getElementById(c):t.getElementById(c);null==r||r.focus()}),g=(0,l.useMemo)(()=>({close:f}),[f]),b=(0,l.useMemo)(()=>({open:0===o,close:f}),[o,f]),_=(0,y.useRender)();return l.default.createElement(C.Provider,{value:a},l.default.createElement(E.Provider,{value:g},l.default.createElement(p,{value:f},l.default.createElement(h.OpenClosedProvider,{value:(0,x.match)(o,{0:h.State.Open,1:h.State.Closed})},_({ourProps:{ref:s},theirProps:n,slot:b,defaultTag:I,name:"Disclosure"})))))}),{Button:(0,y.forwardRefWithAs)(function(e,t){let r=(0,l.useId)(),{id:n=`headlessui-disclosure-button-${r}`,disabled:i=!1,autoFocus:m=!1,...f}=e,[p,h]=S("Disclosure.Button"),x=(0,l.useContext)(O),v=null!==x&&x===p.panelId,b=(0,l.useRef)(null),w=(0,d.useSyncRefs)(b,t,(0,u.useEvent)(e=>{if(!v)return h({type:4,element:e})}));(0,l.useEffect)(()=>{if(!v)return h({type:2,buttonId:n}),()=>{h({type:2,buttonId:null})}},[n,h,v]);let j=(0,u.useEvent)(e=>{var t;if(v){if(1===p.disclosureState)return;switch(e.key){case _.Keys.Space:case _.Keys.Enter:e.preventDefault(),e.stopPropagation(),h({type:0}),null==(t=p.buttonElement)||t.focus()}}else switch(e.key){case _.Keys.Space:case _.Keys.Enter:e.preventDefault(),e.stopPropagation(),h({type:0})}}),k=(0,u.useEvent)(e=>{e.key===_.Keys.Space&&e.preventDefault()}),C=(0,u.useEvent)(e=>{var t;(0,g.isDisabledReactIssue7711)(e.currentTarget)||i||(v?(h({type:0}),null==(t=p.buttonElement)||t.focus()):h({type:0}))}),{isFocusVisible:E,focusProps:N}=(0,s.useFocusRing)({autoFocus:m}),{isHovered:I,hoverProps:T}=(0,a.useHover)({isDisabled:i}),{pressed:R,pressProps:P}=(0,o.useActivePress)({disabled:i}),$=(0,l.useMemo)(()=>({open:0===p.disclosureState,hover:I,active:R,disabled:i,focus:E,autofocus:m}),[p,I,R,E,i,m]),D=(0,c.useResolveButtonType)(e,p.buttonElement),F=v?(0,y.mergeProps)({ref:w,type:D,disabled:i||void 0,autoFocus:m,onKeyDown:j,onClick:C},N,T,P):(0,y.mergeProps)({ref:w,id:n,type:D,"aria-expanded":0===p.disclosureState,"aria-controls":p.panelElement?p.panelId:void 0,disabled:i||void 0,autoFocus:m,onKeyDown:j,onKeyUp:k,onClick:C},N,T,P);return(0,y.useRender)()({ourProps:F,theirProps:f,slot:$,defaultTag:"button",name:"Disclosure.Button"})}),Panel:(0,y.forwardRefWithAs)(function(e,t){let r=(0,l.useId)(),{id:n=`headlessui-disclosure-panel-${r}`,transition:i=!1,...s}=e,[a,o]=S("Disclosure.Panel"),{close:c}=function e(t){let r=(0,l.useContext)(E);if(null===r){let r=Error(`<${t} /> is missing a parent component.`);throw Error.captureStackTrace&&Error.captureStackTrace(r,e),r}return r}("Disclosure.Panel"),[f,p]=(0,l.useState)(null),g=(0,d.useSyncRefs)(t,(0,u.useEvent)(e=>{b(()=>o({type:5,element:e}))}),p);(0,l.useEffect)(()=>(o({type:3,panelId:n}),()=>{o({type:3,panelId:null})}),[n,o]);let x=(0,h.useOpenClosed)(),[v,_]=(0,m.useTransition)(i,f,null!==x?(x&h.State.Open)===h.State.Open:0===a.disclosureState),w=(0,l.useMemo)(()=>({open:0===a.disclosureState,close:c}),[a.disclosureState,c]),j={ref:g,id:n,...(0,m.transitionDataAttributes)(_)},k=(0,y.useRender)();return l.default.createElement(h.ResetOpenClosedProvider,null,l.default.createElement(O.Provider,{value:a.panelId},k({ourProps:j,theirProps:s,slot:w,defaultTag:"div",features:T,visible:v,name:"Disclosure.Panel"})))})});e.s(["Disclosure",0,R],886148);let P=(0,l.createContext)(void 0);var $=e.i(444755);let D=(0,e.i(673706).makeClassName)("Accordion"),F=(0,l.createContext)({isOpen:!1}),A=l.default.forwardRef((e,t)=>{var r;let{defaultOpen:n=!1,children:s,className:a}=e,o=(0,i.__rest)(e,["defaultOpen","children","className"]),u=null!=(r=(0,l.useContext)(P))?r:(0,$.tremorTwMerge)("rounded-tremor-default border");return l.default.createElement(R,Object.assign({as:"div",ref:t,className:(0,$.tremorTwMerge)(D("root"),"overflow-hidden","bg-tremor-background border-tremor-border","dark:bg-dark-tremor-background dark:border-dark-tremor-border",u,a),defaultOpen:n},o),({open:e})=>l.default.createElement(F.Provider,{value:{isOpen:e}},s))});A.displayName="Accordion",e.s(["OpenContext",0,F,"default",0,A],543086),e.s(["Accordion",0,A],677667)},130643,e=>{"use strict";var t=e.i(290571),r=e.i(271645),n=e.i(886148),i=e.i(444755);let s=(0,e.i(673706).makeClassName)("AccordionBody"),a=r.default.forwardRef((e,a)=>{let{children:l,className:o}=e,u=(0,t.__rest)(e,["children","className"]);return r.default.createElement(n.Disclosure.Panel,Object.assign({ref:a,className:(0,i.tremorTwMerge)(s("root"),"w-full text-tremor-default px-4 pb-3","text-tremor-content","dark:text-dark-tremor-content",o)},u),l)});a.displayName="AccordionBody",e.s(["AccordionBody",0,a],130643)},898667,e=>{"use strict";var t=e.i(290571),r=e.i(271645),n=e.i(886148);let i=e=>{var n=(0,t.__rest)(e,[]);return r.default.createElement("svg",Object.assign({xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor"},n),r.default.createElement("path",{d:"M11.9999 10.8284L7.0502 15.7782L5.63599 14.364L11.9999 8L18.3639 14.364L16.9497 15.7782L11.9999 10.8284Z"}))};var s=e.i(543086),a=e.i(444755);let l=(0,e.i(673706).makeClassName)("AccordionHeader"),o=r.default.forwardRef((e,o)=>{let{children:u,className:c}=e,d=(0,t.__rest)(e,["children","className"]),{isOpen:m}=(0,r.useContext)(s.OpenContext);return r.default.createElement(n.Disclosure.Button,Object.assign({ref:o,className:(0,a.tremorTwMerge)(l("root"),"w-full flex items-center justify-between px-4 py-3","text-tremor-content-emphasis","dark:text-dark-tremor-content-emphasis",c)},d),r.default.createElement("div",{className:(0,a.tremorTwMerge)(l("children"),"flex flex-1 text-inherit mr-4")},u),r.default.createElement("div",null,r.default.createElement(i,{className:(0,a.tremorTwMerge)(l("arrowIcon"),"h-5 w-5 -mr-1","text-tremor-content-subtle","dark:text-dark-tremor-content-subtle",m?"transition-all":"transition-all -rotate-180")})))});o.displayName="AccordionHeader",e.s(["AccordionHeader",0,o],898667)},220508,e=>{"use strict";var t=e.i(271645);let r=t.forwardRef(function(e,r){return t.createElement("svg",Object.assign({xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24",strokeWidth:2,stroke:"currentColor","aria-hidden":"true",ref:r},e),t.createElement("path",{strokeLinecap:"round",strokeLinejoin:"round",d:"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"}))});e.s(["CheckCircleIcon",0,r],220508)},503269,214520,601893,694421,140721,942803,35889,722678,e=>{"use strict";var t=e.i(271645),r=e.i(914189);e.s(["useControllable",0,function(e,n,i){let[s,a]=(0,t.useState)(i),l=void 0!==e,o=(0,t.useRef)(l),u=(0,t.useRef)(!1),c=(0,t.useRef)(!1);return!l||o.current||u.current?l||!o.current||c.current||(c.current=!0,o.current=l,console.error("A component is changing from controlled to uncontrolled. This may be caused by the value changing from a defined value to undefined, which should not happen.")):(u.current=!0,o.current=l,console.error("A component is changing from uncontrolled to controlled. This may be caused by the value changing from undefined to a defined value, which should not happen.")),[l?e:s,(0,r.useEvent)(e=>(l||a(e),null==n?void 0:n(e)))]}],503269),e.s(["useDefaultValue",0,function(e){let[r]=(0,t.useState)(e);return r}],214520);let n=(0,t.createContext)(void 0);function i(){return(0,t.useContext)(n)}e.s(["useDisabled",0,i],601893);var s=e.i(174080),a=e.i(746725);function l(e={},t=null,r=[]){for(let[n,i]of Object.entries(e))!function e(t,r,n){if(Array.isArray(n))for(let[i,s]of n.entries())e(t,o(r,i.toString()),s);else n instanceof Date?t.push([r,n.toISOString()]):"boolean"==typeof n?t.push([r,n?"1":"0"]):"string"==typeof n?t.push([r,n]):"number"==typeof n?t.push([r,`${n}`]):null==n?t.push([r,""]):l(n,r,t)}(r,o(t,n),i);return r}function o(e,t){return e?e+"["+t+"]":t}e.s(["attemptSubmit",0,function(e){var t,r;let n=null!=(t=null==e?void 0:e.form)?t:e.closest("form");if(n){for(let t of n.elements)if(t!==e&&("INPUT"===t.tagName&&"submit"===t.type||"BUTTON"===t.tagName&&"submit"===t.type||"INPUT"===t.nodeName&&"image"===t.type))return void t.click();null==(r=n.requestSubmit)||r.call(n)}},"objectToFormEntries",0,l],694421);var u=e.i(700020),c=e.i(2788);let d=(0,t.createContext)(null);function m({children:e}){let r=(0,t.useContext)(d);if(!r)return t.default.createElement(t.default.Fragment,null,e);let{target:n}=r;return n?(0,s.createPortal)(t.default.createElement(t.default.Fragment,null,e),n):null}function f({setForm:e,formId:r}){return(0,t.useEffect)(()=>{if(r){let t=document.getElementById(r);t&&e(t)}},[e,r]),r?null:t.default.createElement(c.Hidden,{features:c.HiddenFeatures.Hidden,as:"input",type:"hidden",hidden:!0,readOnly:!0,ref:t=>{if(!t)return;let r=t.closest("form");r&&e(r)}})}e.s(["FormFields",0,function({data:e,form:r,disabled:n,onReset:i,overrides:s}){let[o,d]=(0,t.useState)(null),p=(0,a.useDisposables)();return(0,t.useEffect)(()=>{if(i&&o)return p.addEventListener(o,"reset",i)},[o,r,i]),t.default.createElement(m,null,t.default.createElement(f,{setForm:d,formId:r}),l(e).map(([e,i])=>t.default.createElement(c.Hidden,{features:c.HiddenFeatures.Hidden,...(0,u.compact)({key:e,as:"input",type:"hidden",hidden:!0,readOnly:!0,form:r,disabled:n,name:e,value:i,...s})})))}],140721);let p=(0,t.createContext)(void 0);function h(){return(0,t.useContext)(p)}e.s(["useProvidedId",0,h],942803);var g=e.i(835696),x=e.i(294316);let v=(0,t.createContext)(null);v.displayName="DescriptionContext";let y=Object.assign((0,u.forwardRefWithAs)(function(e,r){let n=(0,t.useId)(),s=i(),{id:a=`headlessui-description-${n}`,...l}=e,o=function e(){let r=(0,t.useContext)(v);if(null===r){let t=Error("You used a component, but it is not inside a relevant parent.");throw Error.captureStackTrace&&Error.captureStackTrace(t,e),t}return r}(),c=(0,x.useSyncRefs)(r);(0,g.useIsoMorphicEffect)(()=>o.register(a),[a,o.register]);let d=s||!1,m=(0,t.useMemo)(()=>({...o.slot,disabled:d}),[o.slot,d]),f={ref:c,...o.props,id:a};return(0,u.useRender)()({ourProps:f,theirProps:l,slot:m,defaultTag:"p",name:o.name||"Description"})}),{});e.s(["Description",0,y,"useDescribedBy",0,function(){var e,r;return null!=(r=null==(e=(0,t.useContext)(v))?void 0:e.value)?r:void 0},"useDescriptions",0,function(){let[e,n]=(0,t.useState)([]);return[e.length>0?e.join(" "):void 0,(0,t.useMemo)(()=>function(e){let i=(0,r.useEvent)(e=>(n(t=>[...t,e]),()=>n(t=>{let r=t.slice(),n=r.indexOf(e);return -1!==n&&r.splice(n,1),r}))),s=(0,t.useMemo)(()=>({register:i,slot:e.slot,name:e.name,props:e.props,value:e.value}),[i,e.slot,e.name,e.props,e.value]);return t.default.createElement(v.Provider,{value:s},e.children)},[n])]}],35889);let b=(0,t.createContext)(null);function _(e){var r,n,i;let s=null!=(n=null==(r=(0,t.useContext)(b))?void 0:r.value)?n:void 0;return(null!=(i=null==e?void 0:e.length)?i:0)>0?[s,...e].filter(Boolean).join(" "):s}b.displayName="LabelContext";let w=Object.assign((0,u.forwardRefWithAs)(function(e,n){var s;let a=(0,t.useId)(),l=function e(){let r=(0,t.useContext)(b);if(null===r){let t=Error("You used a