From 9e56630347d881b370a1e82a07a04eb17ec6ddcd Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 24 Jul 2026 16:08:29 -0700 Subject: [PATCH 1/2] refactor(ui): migrate routing groups table onto the shared DataTable Rebuilds the Router Settings > Routing Groups table on the shared DataTable and cell library, the last antd entity grid in the dashboard. The table splits into a thin RoutingGroupsTable container plus RoutingGroupsTableColumns, with the usage snippets moving to their own RoutingGroupUsagePanel on ui/tabs and the shared CodeBlock instead of antd Tabs and Paragraph copyable. Models render through the shared ModelsCell so long lists collapse behind "+N more" rather than wrapping the row, and the two inline icon buttons become a single overflow menu with Edit and Delete. antd gave the snippet panel its own chevron column; under the shared pattern a row has two click targets, the name cell and the overflow menu, so clicking the group name now opens the panel. Column set, order, actions, and the backend row order are otherwise unchanged. --- ui/litellm-dashboard/eslint-suppressions.json | 10 - .../routing_groups/RoutingGroupUsagePanel.tsx | 91 +++++++ .../RoutingGroupsTable.test.tsx | 145 ++++++++++ .../routing_groups/RoutingGroupsTable.tsx | 257 ++++-------------- .../RoutingGroupsTableColumns.tsx | 111 ++++++++ .../src/components/routing_groups/index.tsx | 2 +- .../src/components/routing_groups/strategy.ts | 8 + 7 files changed, 411 insertions(+), 213 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/routing_groups/RoutingGroupUsagePanel.tsx create mode 100644 ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.test.tsx create mode 100644 ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTableColumns.tsx create mode 100644 ui/litellm-dashboard/src/components/routing_groups/strategy.ts diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index ce743063309..90ba4dc7f59 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2964,11 +2964,6 @@ "count": 1 } }, - "src/components/common_components/TableHeaderSortDropdown/TableHeaderSortDropdown.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/budget_duration_dropdown.tsx": { "local/filename-pascal-case": { "count": 1 @@ -3554,11 +3549,6 @@ "count": 1 } }, - "src/components/routing_groups/RoutingGroupsTable.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "src/components/routing_groups/index.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupUsagePanel.tsx b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupUsagePanel.tsx new file mode 100644 index 00000000000..fafea569012 --- /dev/null +++ b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupUsagePanel.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { Code2 } from "lucide-react"; +import React from "react"; + +import CodeBlock from "@/components/CodeBlock"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +import { formatStrategyLabel } from "./strategy"; +import type { RoutingGroup } from "./types"; + +interface RoutingGroupUsagePanelProps { + group: RoutingGroup; + baseUrl: string; +} + +const exampleModel = (group: RoutingGroup): string => group.models[0] ?? ""; + +const buildCurlSnippet = (group: RoutingGroup, baseUrl: string): string => + `curl -X POST '${baseUrl}/v1/chat/completions' \\ + -H 'Content-Type: application/json' \\ + -H 'Authorization: Bearer $LITELLM_API_KEY' \\ + -d '{ + "model": "${exampleModel(group)}", + "messages": [{"role": "user", "content": "Hello!"}] + }'`; + +const buildPythonSnippet = (group: RoutingGroup, baseUrl: string): string => + `from openai import OpenAI + +client = OpenAI( + api_key="$LITELLM_API_KEY", + base_url="${baseUrl}", +) + +response = client.chat.completions.create( + model="${exampleModel(group)}", + messages=[{"role": "user", "content": "Hello!"}], +) + +print(response)`; + +const buildJsSnippet = (group: RoutingGroup, baseUrl: string): string => + `import OpenAI from "openai"; + +const client = new OpenAI({ + apiKey: process.env.LITELLM_API_KEY, + baseURL: "${baseUrl}", +}); + +const response = await client.chat.completions.create({ + model: "${exampleModel(group)}", + messages: [{ role: "user", content: "Hello!" }], +}); + +console.log(response);`; + +const SNIPPET_TABS = [ + { value: "curl", label: "cURL", language: "bash", build: buildCurlSnippet }, + { value: "python", label: "Python (OpenAI SDK)", language: "python", build: buildPythonSnippet }, + { value: "javascript", label: "JavaScript (OpenAI SDK)", language: "javascript", build: buildJsSnippet }, +] as const; + +export function RoutingGroupUsagePanel({ group, baseUrl }: RoutingGroupUsagePanelProps) { + return ( +
+
+ + How routing works for this group +
+

+ Callers request any model in the group by name; LiteLLM picks a deployment behind the scenes using the{" "} + {formatStrategyLabel(group.routing_strategy)} strategy. +

+ + + {SNIPPET_TABS.map((tab) => ( + + {tab.label} + + ))} + + {SNIPPET_TABS.map((tab) => ( + + + + ))} + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.test.tsx b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.test.tsx new file mode 100644 index 00000000000..6f14b76e2fd --- /dev/null +++ b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.test.tsx @@ -0,0 +1,145 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import RoutingGroupsTable from "./RoutingGroupsTable"; +import type { RoutingGroup } from "./types"; + +describe("RoutingGroupsTable", () => { + const onEdit = vi.fn(); + const onDelete = vi.fn(); + + const prodGroup: RoutingGroup = { + group_name: "prod-group", + models: ["gpt-4o", "claude-sonnet-4-5"], + routing_strategy: "usage-based-routing", + }; + + const devGroup: RoutingGroup = { + group_name: "dev-group", + models: ["gpt-4o-mini"], + routing_strategy: "simple-shuffle", + }; + + const defaultProps = { + groups: [] as RoutingGroup[], + onEdit, + onDelete, + proxyBaseUrl: "https://proxy.example.com", + }; + + const rowFor = (groupName: string): HTMLElement => { + const row = document.querySelector(`[data-row-id="${groupName}"]`); + if (!(row instanceof HTMLElement)) { + throw new Error(`No row rendered for ${groupName}`); + } + return row; + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render every column header", () => { + render(); + for (const header of ["Group Name", "Models", "Strategy"]) { + expect(screen.getByText(header)).toBeInTheDocument(); + } + }); + + it("should show the empty state when there are no groups", () => { + render(); + expect(screen.getByText("No routing groups yet")).toBeInTheDocument(); + }); + + it("should render the group name, its models, and a human-readable strategy label", () => { + render(); + const row = rowFor("prod-group"); + expect(within(row).getByText("prod-group")).toBeInTheDocument(); + expect(within(row).getByText("gpt-4o")).toBeInTheDocument(); + expect(within(row).getByText("claude-sonnet-4-5")).toBeInTheDocument(); + expect(within(row).getByText("Usage Based")).toBeInTheDocument(); + }); + + it("should fall back to the raw strategy value when it has no friendly label", () => { + render(); + expect(within(rowFor("prod-group")).getByText("custom-strategy")).toBeInTheDocument(); + }); + + it("should collapse models beyond the first three behind a +N more badge", () => { + const wideGroup: RoutingGroup = { ...prodGroup, models: ["a", "b", "c", "d", "e"] }; + render(); + const row = rowFor("prod-group"); + expect(within(row).getByText("+2 more")).toBeInTheDocument(); + expect(within(row).queryByText("d")).not.toBeInTheDocument(); + }); + + it("should keep the incoming order until a column is sorted", async () => { + const user = userEvent.setup(); + render(); + + const namesInOrder = () => + screen + .getAllByRole("row") + .slice(1) + .map((row) => row.getAttribute("data-row-id")); + + expect(namesInOrder()).toEqual(["prod-group", "dev-group"]); + + await user.click(screen.getByTestId("sort-header-group_name")); + expect(namesInOrder()).toEqual(["dev-group", "prod-group"]); + }); + + it("should toggle the usage panel when the group name is clicked", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByText("How routing works for this group")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "prod-group" })); + expect(await screen.findByText("How routing works for this group")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "prod-group" })); + expect(screen.queryByText("How routing works for this group")).not.toBeInTheDocument(); + }); + + it("should build the usage snippet from the proxy base url and the group's first model", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "prod-group" })); + + const panel = (await screen.findByText("How routing works for this group")).closest("div")?.parentElement; + expect(panel?.textContent).toContain("https://proxy.example.com"); + expect(panel?.textContent).toContain("gpt-4o"); + }); + + it("should expand only the clicked group", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "dev-group" })); + expect(await screen.findAllByText("How routing works for this group")).toHaveLength(1); + expect(within(rowFor("prod-group")).queryByText("How routing works for this group")).not.toBeInTheDocument(); + }); + + it("should edit a group through the actions menu", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("routing-group-actions-prod-group")); + await user.click(await screen.findByTestId("routing-group-action-edit")); + expect(onEdit).toHaveBeenCalledWith(prodGroup); + }); + + it("should delete a group through the actions menu", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("routing-group-actions-prod-group")); + await user.click(await screen.findByTestId("routing-group-action-delete")); + expect(onDelete).toHaveBeenCalledWith(prodGroup); + }); + + it("should show skeleton rows instead of the empty state while loading", () => { + render(); + expect(screen.queryByText("No routing groups yet")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.tsx b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.tsx index 96f6e578c83..fce887fc63b 100644 --- a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.tsx +++ b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.tsx @@ -1,229 +1,82 @@ "use client"; -import React, { useState } from "react"; -import { Flex, Table, Tabs, Tag, Tooltip, Typography, Button } from "antd"; -import type { ColumnsType } from "antd/es/table"; -import { BranchesOutlined, DeleteOutlined, EditOutlined, CodeOutlined } from "@ant-design/icons"; -import type { RoutingGroup } from "./types"; +import type { ExpandedState, SortingState } from "@tanstack/react-table"; +import { Inbox } from "lucide-react"; +import React, { useCallback, useMemo, useState } from "react"; -const { Text, Paragraph } = Typography; +import { DataTable } from "@/components/shared/DataTable"; + +import { RoutingGroupUsagePanel } from "./RoutingGroupUsagePanel"; +import { getRoutingGroupsTableColumns } from "./RoutingGroupsTableColumns"; +import type { RoutingGroup } from "./types"; interface RoutingGroupsTableProps { groups: RoutingGroup[]; - loading?: boolean; + isLoading?: boolean; onEdit: (group: RoutingGroup) => void; onDelete: (group: RoutingGroup) => void; proxyBaseUrl?: string; } -const formatStrategyLabel = (strategy: string): string => { - switch (strategy) { - case "simple-shuffle": - return "Simple Shuffle"; - case "least-busy": - return "Least Busy"; - case "usage-based-routing": - return "Usage Based"; - case "latency-based-routing": - return "Latency Based"; - default: - return strategy; - } -}; - const resolveBaseUrl = (proxyBaseUrl?: string): string => { if (proxyBaseUrl && proxyBaseUrl.trim()) return proxyBaseUrl; if (typeof window !== "undefined" && window.location?.origin) return window.location.origin; return ""; }; -const exampleModel = (group: RoutingGroup): string => group.models[0] ?? ""; - -const buildCurlSnippet = (group: RoutingGroup, baseUrl: string): string => - `curl -X POST '${baseUrl}/v1/chat/completions' \\ - -H 'Content-Type: application/json' \\ - -H 'Authorization: Bearer $LITELLM_API_KEY' \\ - -d '{ - "model": "${exampleModel(group)}", - "messages": [{"role": "user", "content": "Hello!"}] - }'`; - -const buildPythonSnippet = (group: RoutingGroup, baseUrl: string): string => - `from openai import OpenAI - -client = OpenAI( - api_key="$LITELLM_API_KEY", - base_url="${baseUrl}", -) - -response = client.chat.completions.create( - model="${exampleModel(group)}", - messages=[{"role": "user", "content": "Hello!"}], -) - -print(response)`; - -const buildJsSnippet = (group: RoutingGroup, baseUrl: string): string => - `import OpenAI from "openai"; - -const client = new OpenAI({ - apiKey: process.env.LITELLM_API_KEY, - baseURL: "${baseUrl}", -}); - -const response = await client.chat.completions.create({ - model: "${exampleModel(group)}", - messages: [{ role: "user", content: "Hello!" }], -}); - -console.log(response);`; - -interface RoutingGroupSnippetProps { - group: RoutingGroup; - baseUrl: string; +function EmptyState() { + return ( +
+
+ +
+
No routing groups yet
+
+ Create a group to load-balance a set of models behind one name. +
+
+ ); } -const SNIPPET_BLOCK_STYLE: React.CSSProperties = { - backgroundColor: "#111827", - color: "#f3f4f6", - borderRadius: 6, - padding: 16, - fontSize: 12, - whiteSpace: "pre", - overflowX: "auto", -}; - -const RoutingGroupSnippet: React.FC = ({ group, baseUrl }) => { - const snippets = { - curl: buildCurlSnippet(group, baseUrl), - python: buildPythonSnippet(group, baseUrl), - javascript: buildJsSnippet(group, baseUrl), - } as const; - type SnippetKey = keyof typeof snippets; - const [activeKey, setActiveKey] = useState("curl"); - - const items = [ - { key: "curl", label: "cURL" }, - { key: "python", label: "Python (OpenAI SDK)" }, - { key: "javascript", label: "JavaScript (OpenAI SDK)" }, - ].map(({ key, label }) => ({ - key, - label, - children: ( - - {snippets[key as SnippetKey]} - - ), - })); - - return ( - setActiveKey(k as SnippetKey)} - items={items} - tabBarExtraContent={ - - } - /> - ); -}; - -const RoutingGroupsTable: React.FC = ({ groups, loading, onEdit, onDelete, proxyBaseUrl }) => { - const [expandedRowKeys, setExpandedRowKeys] = useState([]); +const RoutingGroupsTable: React.FC = ({ + groups, + isLoading, + onEdit, + onDelete, + proxyBaseUrl, +}) => { + const [sorting, setSorting] = useState([]); + const [expanded, setExpanded] = useState({}); const baseUrl = resolveBaseUrl(proxyBaseUrl); - const columns: ColumnsType = [ - { - title: "GROUP NAME", - dataIndex: "group_name", - key: "group_name", - render: (name: string) => ( - - {name} - - ), - }, - { - title: "MODELS", - dataIndex: "models", - key: "models", - render: (models: string[]) => ( - - {models.map((m) => ( - {m} - ))} - - ), - }, - { - title: "STRATEGY", - dataIndex: "routing_strategy", - key: "routing_strategy", - render: (strategy: string) => ( - - - {formatStrategyLabel(strategy)} - - ), - }, - { - title: "ACTIONS", - key: "actions", - width: 120, - align: "right", - render: (_, group) => ( - - -