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) => ( - - -