mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge pull request #34571 from BerriAI/litellm_/migrate-simple-table-status-74b530
refactor(ui): migrate routing groups table onto the shared DataTable
This commit is contained in:
commit
78348fd1c7
9 changed files with 411 additions and 443 deletions
|
|
@ -2959,11 +2959,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
|
||||
|
|
@ -3549,11 +3544,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
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TableHeaderSortDropdown } from "./TableHeaderSortDropdown";
|
||||
|
||||
describe("TableHeaderSortDropdown", () => {
|
||||
it("should render", () => {
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />);
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open dropdown menu when button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Ascending")).toBeInTheDocument();
|
||||
expect(screen.getByText("Descending")).toBeInTheDocument();
|
||||
expect(screen.getByText("Reset")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onSortChange with asc when ascending option is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Ascending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const ascendingOption = screen.getByText("Ascending");
|
||||
await user.click(ascendingOption);
|
||||
|
||||
expect(onSortChange).toHaveBeenCalledTimes(1);
|
||||
expect(onSortChange).toHaveBeenCalledWith("asc");
|
||||
});
|
||||
|
||||
it("should call onSortChange with desc when descending option is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Descending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const descendingOption = screen.getByText("Descending");
|
||||
await user.click(descendingOption);
|
||||
|
||||
expect(onSortChange).toHaveBeenCalledTimes(1);
|
||||
expect(onSortChange).toHaveBeenCalledWith("desc");
|
||||
});
|
||||
|
||||
it("should call onSortChange with false when reset option is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState="asc" onSortChange={onSortChange} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Reset")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const resetOption = screen.getByText("Reset");
|
||||
await user.click(resetOption);
|
||||
|
||||
expect(onSortChange).toHaveBeenCalledTimes(1);
|
||||
expect(onSortChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should highlight ascending option when sort state is asc", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState="asc" onSortChange={onSortChange} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const ascendingOption = screen.getByText("Ascending");
|
||||
const menuItem = ascendingOption.closest(".ant-dropdown-menu-item");
|
||||
expect(menuItem).toHaveClass("ant-dropdown-menu-item-selected");
|
||||
});
|
||||
});
|
||||
|
||||
it("should highlight descending option when sort state is desc", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState="desc" onSortChange={onSortChange} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const descendingOption = screen.getByText("Descending");
|
||||
const menuItem = descendingOption.closest(".ant-dropdown-menu-item");
|
||||
expect(menuItem).toHaveClass("ant-dropdown-menu-item-selected");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not highlight any option when sort state is false", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
render(<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Ascending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const ascendingOption = screen.getByText("Ascending");
|
||||
const menuItem = ascendingOption.closest(".ant-dropdown-menu-item");
|
||||
expect(menuItem).not.toHaveClass("ant-dropdown-menu-item-selected");
|
||||
});
|
||||
|
||||
it("should stop event propagation when button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
const onParentClick = vi.fn();
|
||||
|
||||
render(
|
||||
<div onClick={onParentClick}>
|
||||
<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
expect(onParentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import React from "react";
|
||||
import { Button, Dropdown, MenuProps } from "antd";
|
||||
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, XIcon } from "@heroicons/react/outline";
|
||||
|
||||
export type SortState = "asc" | "desc" | false;
|
||||
|
||||
interface TableHeaderSortDropdownProps {
|
||||
/**
|
||||
* Current sort state: "asc", "desc", or false for neutral
|
||||
*/
|
||||
sortState: SortState;
|
||||
/**
|
||||
* Callback when sort state changes
|
||||
* @param newState - The new sort state: "asc", "desc", or false
|
||||
*/
|
||||
onSortChange: (newState: SortState) => void;
|
||||
/**
|
||||
* Optional column ID for identification
|
||||
*/
|
||||
columnId?: string;
|
||||
}
|
||||
|
||||
export const TableHeaderSortDropdown: React.FC<TableHeaderSortDropdownProps> = ({ sortState, onSortChange }) => {
|
||||
const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
|
||||
if (key === "asc") {
|
||||
onSortChange("asc");
|
||||
} else if (key === "desc") {
|
||||
onSortChange("desc");
|
||||
} else if (key === "reset") {
|
||||
onSortChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems: MenuProps["items"] = [
|
||||
{
|
||||
key: "asc",
|
||||
label: "Ascending",
|
||||
icon: <ChevronUpIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
key: "desc",
|
||||
label: "Descending",
|
||||
icon: <ChevronDownIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
key: "reset",
|
||||
label: "Reset",
|
||||
icon: <XIcon className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
// Determine which icon to display based on current sort state
|
||||
const renderIcon = () => {
|
||||
if (sortState === "asc") {
|
||||
return <ChevronUpIcon className="h-4 w-4" />;
|
||||
} else if (sortState === "desc") {
|
||||
return <ChevronDownIcon className="h-4 w-4" />;
|
||||
} else {
|
||||
return <SwitchVerticalIcon className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: menuItems,
|
||||
onClick: handleMenuClick,
|
||||
selectable: true,
|
||||
selectedKeys: sortState ? [sortState] : [],
|
||||
}}
|
||||
trigger={["click"]}
|
||||
autoAdjustOverflow
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
icon={renderIcon()}
|
||||
className={sortState ? "text-blue-500 hover:text-blue-600" : "text-gray-400 hover:text-blue-500"}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
|
@ -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] ?? "<your-model>";
|
||||
|
||||
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 (
|
||||
<div className="border-y bg-muted/40 px-4 py-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Code2 className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium text-foreground">How routing works for this group</span>
|
||||
</div>
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
Callers request any model in the group by name; LiteLLM picks a deployment behind the scenes using the{" "}
|
||||
<span className="font-medium text-foreground">{formatStrategyLabel(group.routing_strategy)}</span> strategy.
|
||||
</p>
|
||||
<Tabs defaultValue="curl">
|
||||
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
|
||||
{SNIPPET_TABS.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value} className="flex-none rounded-none px-4 py-2">
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{SNIPPET_TABS.map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value} className="pt-3">
|
||||
<CodeBlock language={tab.language} code={tab.build(group, baseUrl)} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(<RoutingGroupsTable {...defaultProps} />);
|
||||
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(<RoutingGroupsTable {...defaultProps} />);
|
||||
expect(screen.getByText("No routing groups yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the group name, its models, and a human-readable strategy label", () => {
|
||||
render(<RoutingGroupsTable {...defaultProps} groups={[prodGroup]} />);
|
||||
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(<RoutingGroupsTable {...defaultProps} groups={[{ ...prodGroup, routing_strategy: "custom-strategy" }]} />);
|
||||
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(<RoutingGroupsTable {...defaultProps} groups={[wideGroup]} />);
|
||||
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(<RoutingGroupsTable {...defaultProps} groups={[prodGroup, devGroup]} />);
|
||||
|
||||
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(<RoutingGroupsTable {...defaultProps} groups={[prodGroup]} />);
|
||||
|
||||
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(<RoutingGroupsTable {...defaultProps} groups={[prodGroup]} />);
|
||||
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(<RoutingGroupsTable {...defaultProps} groups={[prodGroup, devGroup]} />);
|
||||
|
||||
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(<RoutingGroupsTable {...defaultProps} groups={[prodGroup]} />);
|
||||
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(<RoutingGroupsTable {...defaultProps} groups={[prodGroup]} />);
|
||||
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(<RoutingGroupsTable {...defaultProps} isLoading />);
|
||||
expect(screen.queryByText("No routing groups yet")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 "<your_proxy_base_url>";
|
||||
};
|
||||
|
||||
const exampleModel = (group: RoutingGroup): string => group.models[0] ?? "<your-model>";
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No routing groups yet</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Create a group to load-balance a set of models behind one name.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SNIPPET_BLOCK_STYLE: React.CSSProperties = {
|
||||
backgroundColor: "#111827",
|
||||
color: "#f3f4f6",
|
||||
borderRadius: 6,
|
||||
padding: 16,
|
||||
fontSize: 12,
|
||||
whiteSpace: "pre",
|
||||
overflowX: "auto",
|
||||
};
|
||||
|
||||
const RoutingGroupSnippet: React.FC<RoutingGroupSnippetProps> = ({ 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<SnippetKey>("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: (
|
||||
<Paragraph code className="mb-0!" style={SNIPPET_BLOCK_STYLE}>
|
||||
{snippets[key as SnippetKey]}
|
||||
</Paragraph>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
size="small"
|
||||
activeKey={activeKey}
|
||||
onChange={(k) => setActiveKey(k as SnippetKey)}
|
||||
items={items}
|
||||
tabBarExtraContent={
|
||||
<Paragraph copyable={{ text: snippets[activeKey], tooltips: ["Copy", "Copied"] }} className="mb-0!" />
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({ groups, loading, onEdit, onDelete, proxyBaseUrl }) => {
|
||||
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([]);
|
||||
const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({
|
||||
groups,
|
||||
isLoading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
proxyBaseUrl,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||||
const baseUrl = resolveBaseUrl(proxyBaseUrl);
|
||||
|
||||
const columns: ColumnsType<RoutingGroup> = [
|
||||
{
|
||||
title: "GROUP NAME",
|
||||
dataIndex: "group_name",
|
||||
key: "group_name",
|
||||
render: (name: string) => (
|
||||
<Text strong className="text-blue-600">
|
||||
{name}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "MODELS",
|
||||
dataIndex: "models",
|
||||
key: "models",
|
||||
render: (models: string[]) => (
|
||||
<Flex wrap="wrap" gap={4}>
|
||||
{models.map((m) => (
|
||||
<Tag key={m}>{m}</Tag>
|
||||
))}
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "STRATEGY",
|
||||
dataIndex: "routing_strategy",
|
||||
key: "routing_strategy",
|
||||
render: (strategy: string) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<BranchesOutlined className="text-gray-400" />
|
||||
<Text>{formatStrategyLabel(strategy)}</Text>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "ACTIONS",
|
||||
key: "actions",
|
||||
width: 120,
|
||||
align: "right",
|
||||
render: (_, group) => (
|
||||
<Flex justify="flex-end" align="center" gap={8}>
|
||||
<Tooltip title="Edit">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(group);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(group);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
const toggleUsage = useCallback((group: RoutingGroup) => {
|
||||
setExpanded((previous) => {
|
||||
const current = previous === true ? {} : previous;
|
||||
return { ...current, [group.group_name]: current[group.group_name] !== true };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const deps = { onEdit, onDelete, onToggleUsage: toggleUsage };
|
||||
return getRoutingGroupsTableColumns(deps);
|
||||
}, [onEdit, onDelete, toggleUsage]);
|
||||
|
||||
return (
|
||||
<Table<RoutingGroup>
|
||||
rowKey="group_name"
|
||||
<DataTable
|
||||
data={groups}
|
||||
columns={columns}
|
||||
dataSource={groups}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
expandable={{
|
||||
expandedRowKeys,
|
||||
onExpandedRowsChange: (keys) => setExpandedRowKeys([...keys]),
|
||||
expandedRowRender: (group) => (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4 my-2">
|
||||
<Flex align="center" gap={8} className="mb-2">
|
||||
<CodeOutlined className="text-blue-500" />
|
||||
<Text strong>How routing works for this group</Text>
|
||||
</Flex>
|
||||
<Paragraph className="text-sm text-gray-600 mb-3">
|
||||
Callers request any model in the group by name — LiteLLM picks a deployment behind the scenes using the{" "}
|
||||
<Text strong>{formatStrategyLabel(group.routing_strategy)}</Text> strategy.
|
||||
</Paragraph>
|
||||
<RoutingGroupSnippet group={group} baseUrl={baseUrl} />
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
getRowId={(group) => group.group_name}
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
expanded={expanded}
|
||||
onExpandedChange={setExpanded}
|
||||
getRowCanExpand={() => true}
|
||||
renderSubComponent={({ row }) => <RoutingGroupUsagePanel group={row.original} baseUrl={baseUrl} />}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading routing groups…"
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { GitBranch, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { IdentityCell, ModelsCell } from "@/components/shared/table_cells";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { formatStrategyLabel } from "./strategy";
|
||||
import type { RoutingGroup } from "./types";
|
||||
|
||||
interface RoutingGroupRowActionsProps {
|
||||
group: RoutingGroup;
|
||||
onEdit: (group: RoutingGroup) => void;
|
||||
onDelete: (group: RoutingGroup) => void;
|
||||
}
|
||||
|
||||
function RoutingGroupRowActions({ group, onEdit, onDelete }: RoutingGroupRowActionsProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={`Open actions for ${group.group_name}`}
|
||||
data-testid={`routing-group-actions-${group.group_name}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem data-testid="routing-group-action-edit" onClick={() => onEdit(group)}>
|
||||
<Pencil />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
data-testid="routing-group-action-delete"
|
||||
onClick={() => onDelete(group)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface RoutingGroupsTableColumnsDeps {
|
||||
onEdit: (group: RoutingGroup) => void;
|
||||
onDelete: (group: RoutingGroup) => void;
|
||||
onToggleUsage: (group: RoutingGroup) => void;
|
||||
}
|
||||
|
||||
export const getRoutingGroupsTableColumns = ({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleUsage,
|
||||
}: RoutingGroupsTableColumnsDeps): ColumnDef<RoutingGroup>[] => [
|
||||
{
|
||||
id: "group_name",
|
||||
accessorKey: "group_name",
|
||||
meta: { title: "Group Name", skeleton: "text" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Group Name" />,
|
||||
size: 240,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<IdentityCell title={row.original.group_name} className="max-w-60" onClick={() => onToggleUsage(row.original)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
meta: { title: "Models", skeleton: "chips" },
|
||||
header: "Models",
|
||||
size: 320,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ModelsCell models={row.original.models} />,
|
||||
},
|
||||
{
|
||||
id: "routing_strategy",
|
||||
accessorKey: "routing_strategy",
|
||||
meta: { title: "Strategy", skeleton: "text" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Strategy" />,
|
||||
size: 180,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<GitBranch className="size-4 shrink-0 text-muted-foreground" />
|
||||
{formatStrategyLabel(row.original.routing_strategy)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<RoutingGroupRowActions group={row.original} onEdit={onEdit} onDelete={onDelete} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -126,7 +126,7 @@ const RoutingGroups: React.FC = () => {
|
|||
|
||||
<RoutingGroupsTable
|
||||
groups={filteredGroups}
|
||||
loading={isLoading}
|
||||
isLoading={isLoading}
|
||||
onEdit={openEdit}
|
||||
onDelete={(g) => setDeletingGroup(g)}
|
||||
proxyBaseUrl={proxySettings.LITELLM_UI_API_DOC_BASE_URL?.trim() || proxySettings.PROXY_BASE_URL || ""}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
const STRATEGY_LABELS: Readonly<Record<string, string>> = {
|
||||
"simple-shuffle": "Simple Shuffle",
|
||||
"least-busy": "Least Busy",
|
||||
"usage-based-routing": "Usage Based",
|
||||
"latency-based-routing": "Latency Based",
|
||||
};
|
||||
|
||||
export const formatStrategyLabel = (strategy: string): string => STRATEGY_LABELS[strategy] ?? strategy;
|
||||
Loading…
Add table
Reference in a new issue