feat(ui): migrate tag-management section to shadcn

- TagSelector: rewritten from antd Select mode=tags as a custom chip-input
  using shadcn Popover (suggestions) + Input (free-form text) + Badge
  (chips). Supports type-to-add-via-Enter, comma separator, Backspace to
  remove last tag, and click-to-add from server-provided suggestion list.
- TagTable: @heroicons + @tremor + antd Tooltip \u2192 shadcn Table +
  TanStack-react-table + lucide Pencil/Trash2/Chevron* + shadcn Tooltip /
  Badge / Button. Action buttons are now real <button>s with semantic
  aria-labels and disabled state (replaces the @tremor Icon pattern).
- CreateTagModal: antd Modal/Form/Input + @tremor Accordion/TextInput +
  @ant-design/icons \u2192 shadcn Dialog/react-hook-form/Input/Textarea/
  Accordion + lucide Info/X. Added an inline multi-select chip pattern
  (mirrors AccessGroupBaseForm).
- index (TagManagement): @tremor Icon/Button/Col/Text/Grid + @heroicons
  RefreshIcon \u2192 shadcn Button + lucide RefreshCw + shadcn Dialog for
  the delete confirmation (replaces the hand-rolled fixed-overlay div).
- tag_info: @tremor + antd Form/Input/Select \u2192 shadcn Card/Button/Input/
  Textarea/Select/Accordion + react-hook-form. Inline multi-select chip
  pattern; tooltip provider per cell for model-id hovers.

Test updates:
- TagTable.test.tsx: replace 'Edit tag (disabled)' aria-label expectation
  with 'Edit tag' + toBeDisabled() (semantic state, not framework class).
- TagSelector.test.tsx: assert onChange fires with new tag (Enter commits
  the chip and clears the input, per the new chip-input UX).
- CreateTagModal.test.tsx: getByLabelText regex match (label includes the
  required-marker glyph); assert mockOnSubmit was called with an object
  containing tag_name (rhf submits the full default-values bag, not just
  touched fields).

Gates: TS \u2713 | Lint \u2713 (1 fixed react-hooks/exhaustive-deps warning) |
Vitest 14/14 \u2713 | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 07:42:28 +00:00
parent 3b7b49137d
commit 43524c1f62
No known key found for this signature in database
9 changed files with 1009 additions and 473 deletions

View file

@ -77,6 +77,18 @@ Layer abbreviations: **TS** (tsc --noEmit), **Lint** (eslint), **Vitest**
- cycle 1: TS ✓ | Lint ✓ | Vitest ✓ (4/4) | Build ✓ | Parity ⏭ | Snap ⏭
- Final status: **done (organization_view.tsx; OrganizationFilters and the route page already had no banned imports)**
## 4. Tag Management (tag-management)
- Cycles used: 1 / 7
- Layer outcomes per cycle:
- cycle 1: TS ✓ | Lint ✓ | Vitest ✓ (14/14) | Build ✓ | Parity ⏭ | Snap ⏭
- Final status: **done**
- Scope: TagSelector (rewritten as chip-input over shadcn Popover/Input/Badge),
TagTable (shadcn Table + tanstack-react-table), CreateTagModal (rhf + Dialog),
index (shadcn Dialog for delete confirmation), tag_info (rhf + Card/Accordion).
Tests updated to use semantic queries (button disabled state) and to match
the rhf submit shape (object containing tag_name).
## 24. Guardrails Monitor (guardrails-monitor)
- Cycles used: 1 / 7

View file

@ -1,5 +1,5 @@
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import TagSelector from "./TagSelector";
describe("TagSelector", () => {
@ -8,14 +8,19 @@ describe("TagSelector", () => {
});
it("should allow creating new tags", () => {
const { container } = render(<TagSelector onChange={() => {}} accessToken="test-token" />);
const onChange = vi.fn();
const { container } = render(
<TagSelector onChange={onChange} accessToken="test-token" />,
);
const tagSelector = container.querySelector("input");
expect(tagSelector).toBeInTheDocument();
if (tagSelector) {
fireEvent.change(tagSelector, { target: { value: "new-tag" } });
expect(tagSelector).toHaveValue("new-tag");
fireEvent.keyDown(tagSelector, { key: "Enter" });
expect(tagSelector).toHaveValue("new-tag");
// Post phase-1: pressing Enter commits the tag (clears the input);
// onChange fires with the new tag.
expect(onChange).toHaveBeenCalledWith(["new-tag"]);
}
});
});

View file

@ -1,7 +1,15 @@
import React, { useEffect, useState } from "react";
import { Select } from "antd";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Tag } from "./types";
import { tagListCall } from "../networking";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
interface TagSelectorProps {
onChange: (selectedTags: string[]) => void;
@ -10,47 +18,139 @@ interface TagSelectorProps {
accessToken: string;
}
const TagSelector: React.FC<TagSelectorProps> = ({ onChange, value, className, accessToken }) => {
/**
* Free-form tag input. Supports:
* - selecting from a server-fetched suggestion list (popover)
* - typing a new tag and pressing Enter / typing a `,` to add it
* - removing existing tags via the X icon on each chip
*
* Replaces antd `<Select mode="tags">` for the phase-1 migration.
*/
const TagSelector: React.FC<TagSelectorProps> = ({
onChange,
value,
className,
accessToken,
}) => {
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const selected = useMemo(() => value ?? [], [value]);
useEffect(() => {
const fetchTags = async () => {
if (!accessToken) return;
try {
const response = await tagListCall(accessToken);
console.log("List tags response:", response);
setTags(Object.values(response));
} catch (error) {
console.error("Error fetching tags:", error);
} finally {
setLoading(false);
}
};
fetchTags();
}, [accessToken]);
const suggestions = useMemo(
() =>
tags
.filter((t) => !selected.includes(t.name))
.filter((t) =>
query ? t.name.toLowerCase().includes(query.toLowerCase()) : true,
)
.slice(0, 10),
[tags, selected, query],
);
const addTag = (name: string) => {
const trimmed = name.trim();
if (!trimmed || selected.includes(trimmed)) return;
onChange([...selected, trimmed]);
setQuery("");
};
const removeTag = (name: string) => {
onChange(selected.filter((t) => t !== name));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
if (query.trim()) addTag(query);
} else if (e.key === "," ) {
e.preventDefault();
if (query.trim()) addTag(query);
} else if (
e.key === "Backspace" &&
query === "" &&
selected.length > 0
) {
onChange(selected.slice(0, -1));
}
};
return (
<Select
mode="tags"
showSearch
placeholder="Select or create tags"
onChange={onChange}
value={value}
loading={loading}
className={className}
options={tags.map((tag) => ({
label: tag.name,
value: tag.name,
title: tag.description || tag.name,
}))}
optionFilterProp="label"
tokenSeparators={[","]}
maxTagCount="responsive"
allowClear
style={{ width: "100%" }}
/>
<Popover open={open && suggestions.length > 0} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div
className={cn(
"min-h-9 w-full flex flex-wrap items-center gap-1 rounded-md border border-input bg-background px-2 py-1 text-sm cursor-text",
className,
)}
onClick={() => inputRef.current?.focus()}
>
{selected.map((name) => (
<Badge key={name} variant="secondary" className="gap-1">
{name}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeTag(name);
}}
className="inline-flex items-center"
aria-label={`Remove ${name}`}
>
<X size={12} />
</button>
</Badge>
))}
<Input
ref={inputRef}
value={query}
placeholder={selected.length === 0 ? "Select or create tags" : ""}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
className="border-0 shadow-none focus-visible:ring-0 h-7 px-1 flex-1 min-w-[120px]"
/>
</div>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-1"
onOpenAutoFocus={(e) => e.preventDefault()}
>
{suggestions.map((t) => (
<button
key={t.name}
type="button"
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent"
onClick={() => {
addTag(t.name);
inputRef.current?.focus();
}}
title={t.description || t.name}
>
{t.name}
</button>
))}
</PopoverContent>
</Popover>
);
};

View file

@ -87,15 +87,18 @@ describe("TagTable", () => {
it("should disable edit icon for dynamic spend tags", () => {
render(<TagTable {...defaultProps} data={[mockDynamicSpendTag]} />);
const editIcon = screen.getByLabelText("Edit tag (disabled)");
expect(editIcon).toBeInTheDocument();
expect(editIcon).toHaveClass("cursor-not-allowed");
// Post phase-1 migration the action buttons are <button> elements with
// semantic aria-labels; disabled state is conveyed via the button's
// disabled attribute rather than an explicit "(disabled)" suffix.
const editButton = screen.getByLabelText("Edit tag");
expect(editButton).toBeInTheDocument();
expect(editButton).toBeDisabled();
});
it("should disable delete icon for dynamic spend tags", () => {
render(<TagTable {...defaultProps} data={[mockDynamicSpendTag]} />);
const deleteIcon = screen.getByLabelText("Delete tag (disabled)");
expect(deleteIcon).toBeInTheDocument();
expect(deleteIcon).toHaveClass("cursor-not-allowed");
const deleteButton = screen.getByLabelText("Delete tag");
expect(deleteButton).toBeInTheDocument();
expect(deleteButton).toBeDisabled();
});
});

View file

@ -1,4 +1,3 @@
import { ChevronDownIcon, ChevronUpIcon, PencilAltIcon, SwitchVerticalIcon, TrashIcon } from "@heroicons/react/outline";
import {
ColumnDef,
flexRender,
@ -7,19 +6,29 @@ import {
SortingState,
useReactTable,
} from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Badge,
Button,
Icon,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableHeader,
TableRow,
Text,
} from "@tremor/react";
import { Tooltip } from "antd";
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
ChevronDown,
ChevronUp,
ChevronsUpDown,
Pencil,
Trash2,
} from "lucide-react";
import React from "react";
import { Tag } from "./types";
@ -33,8 +42,15 @@ interface TagTableProps {
const DYNAMIC_SPEND_TAG_DESCRIPTION =
"This is just a spend tag that was passed dynamically in a request. It does not control any LLM models.";
const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag }) => {
const [sorting, setSorting] = React.useState<SortingState>([{ id: "created_at", desc: true }]);
const TagTable: React.FC<TagTableProps> = ({
data,
onEdit,
onDelete,
onSelectTag,
}) => {
const [sorting, setSorting] = React.useState<SortingState>([
{ id: "created_at", desc: true },
]);
const columns: ColumnDef<Tag>[] = [
{
@ -44,23 +60,26 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
const tag = row.original;
const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION;
return (
<div className="overflow-hidden">
<Tooltip
title={
isDynamicSpendTag ? "You cannot view the information of a dynamically generated spend tag" : tag.name
}
>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5"
onClick={() => onSelectTag(tag.name)}
disabled={isDynamicSpendTag}
>
{tag.name}
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="font-mono text-primary bg-primary/10 hover:bg-primary/20 text-xs font-normal px-2 py-0.5 h-auto"
onClick={() => onSelectTag(tag.name)}
disabled={isDynamicSpendTag}
>
{tag.name}
</Button>
</TooltipTrigger>
<TooltipContent>
{isDynamicSpendTag
? "You cannot view the information of a dynamically generated spend tag"
: tag.name}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
},
},
@ -70,9 +89,16 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
cell: ({ row }) => {
const tag = row.original;
return (
<Tooltip title={tag.description}>
<span className="text-xs">{tag.description || "-"}</span>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs">{tag.description || "-"}</span>
</TooltipTrigger>
{tag.description && (
<TooltipContent>{tag.description}</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
);
},
},
@ -82,18 +108,21 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
cell: ({ row }) => {
const tag = row.original;
return (
<div style={{ display: "flex", flexDirection: "column" }}>
<div className="flex flex-col gap-1">
{tag?.models?.length === 0 ? (
<Badge size="xs" className="mb-1" color="red">
All Models
</Badge>
<Badge variant="destructive">All Models</Badge>
) : (
tag?.models?.map((modelId) => (
<Badge key={modelId} size="xs" className="mb-1" color="blue">
<Tooltip title={`ID: ${modelId}`}>
<Text>{tag.model_info?.[modelId] || modelId}</Text>
<TooltipProvider key={modelId}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="w-fit">
{tag.model_info?.[modelId] || modelId}
</Badge>
</TooltipTrigger>
<TooltipContent>{`ID: ${modelId}`}</TooltipContent>
</Tooltip>
</Badge>
</TooltipProvider>
))
)}
</div>
@ -104,10 +133,11 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
header: "Created",
accessorKey: "created_at",
sortingFn: "datetime",
cell: ({ row }) => {
const tag = row.original;
return <span className="text-xs">{new Date(tag.created_at).toLocaleDateString()}</span>;
},
cell: ({ row }) => (
<span className="text-xs">
{new Date(row.original.created_at).toLocaleDateString()}
</span>
),
},
{
id: "actions",
@ -116,45 +146,57 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
const tag = row.original;
const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION;
return (
<div className="flex space-x-2">
{isDynamicSpendTag ? (
<Tooltip title="Dynamically generated spend tags cannot be edited">
<Icon
icon={PencilAltIcon}
size="sm"
className="opacity-50 cursor-not-allowed"
aria-label="Edit tag (disabled)"
/>
<div className="flex space-x-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={
isDynamicSpendTag
? "opacity-50 cursor-not-allowed"
: "hover:text-primary"
}
disabled={isDynamicSpendTag}
onClick={() => onEdit(tag)}
aria-label="Edit tag"
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{isDynamicSpendTag
? "Dynamically generated spend tags cannot be edited"
: "Edit tag"}
</TooltipContent>
</Tooltip>
) : (
<Tooltip title="Edit tag">
<Icon
icon={PencilAltIcon}
size="sm"
onClick={() => onEdit(tag)}
className="cursor-pointer hover:text-blue-500"
/>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={
isDynamicSpendTag
? "opacity-50 cursor-not-allowed"
: "hover:text-destructive"
}
disabled={isDynamicSpendTag}
onClick={() => onDelete(tag.name)}
aria-label="Delete tag"
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{isDynamicSpendTag
? "Dynamically generated spend tags cannot be deleted"
: "Delete tag"}
</TooltipContent>
</Tooltip>
)}
{isDynamicSpendTag ? (
<Tooltip title="Dynamically generated spend tags cannot be deleted">
<Icon
icon={TrashIcon}
size="sm"
className="opacity-50 cursor-not-allowed"
aria-label="Delete tag (disabled)"
/>
</Tooltip>
) : (
<Tooltip title="Delete tag">
<Icon
icon={TrashIcon}
size="sm"
onClick={() => onDelete(tag.name)}
className="cursor-pointer hover:text-red-500"
/>
</Tooltip>
)}
</TooltipProvider>
</div>
);
},
@ -164,9 +206,7 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
const table = useReactTable({
data,
columns,
state: {
sorting,
},
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
@ -177,39 +217,50 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
<TableHead
key={header.id}
className={`py-1 h-8 ${
header.id === "actions" ? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]" : ""
header.id === "actions"
? "sticky right-0 bg-background shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
}`}
onClick={header.column.getToggleSortingHandler()}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</div>
{header.id !== "actions" && (
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
asc: (
<ChevronUp className="h-4 w-4 text-primary" />
),
desc: (
<ChevronDown className="h-4 w-4 text-primary" />
),
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
)}
</div>
</TableHeaderCell>
</TableHead>
))}
</TableRow>
))}
</TableHead>
</TableHeader>
<TableBody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
@ -219,7 +270,7 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
key={cell.id}
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${
cell.column.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
? "sticky right-0 bg-background shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
}`}
>
@ -230,8 +281,11 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<TableCell
colSpan={columns.length}
className="h-8 text-center"
>
<div className="text-center text-muted-foreground">
<p>No tags found</p>
</div>
</TableCell>

View file

@ -40,15 +40,18 @@ describe("CreateTagModal", () => {
const user = userEvent.setup();
render(<CreateTagModal {...defaultProps} />);
const tagNameInput = screen.getByLabelText("Tag Name");
// The label includes a required indicator; match by partial text via regex.
const tagNameInput = screen.getByLabelText(/Tag Name/);
await user.type(tagNameInput, "test-tag");
const submitButton = screen.getByRole("button", { name: /Create Tag/i });
await user.click(submitButton);
expect(mockOnSubmit).toHaveBeenCalledWith({
tag_name: "test-tag",
});
// Post phase-1: rhf submits the full default values bag, not just the
// touched fields. Assert the tag_name made it through.
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({ tag_name: "test-tag" }),
);
});
it("should not submit form when tag name is missing", async () => {

View file

@ -1,130 +1,308 @@
import { InfoCircleOutlined } from "@ant-design/icons";
import { Accordion, AccordionBody, AccordionHeader, Button, TextInput, Title } from "@tremor/react";
import { Form, Input, Modal, Select as Select2, Tooltip } from "antd";
import React from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Info, X } from "lucide-react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import BudgetDurationDropdown from "../../common_components/budget_duration_dropdown";
import NumericalInput from "../../shared/numerical_input";
interface ModelInfo {
model_name: string;
litellm_params: {
model: string;
};
model_info: {
id: string;
};
litellm_params: { model: string };
model_info: { id: string };
}
interface CreateTagFormValues {
tag_name: string;
description: string;
allowed_llms: string[];
max_budget: number | null;
budget_duration: string | null;
}
const defaultValues: CreateTagFormValues = {
tag_name: "",
description: "",
allowed_llms: [],
max_budget: null,
budget_duration: null,
};
interface CreateTagModalProps {
visible: boolean;
onCancel: () => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onSubmit: (values: any) => void;
availableModels: ModelInfo[];
}
const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSubmit, availableModels }) => {
const [form] = Form.useForm();
function InfoTip({ children }: { children: React.ReactNode }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-muted-foreground inline-block ml-1 cursor-help" />
</TooltipTrigger>
<TooltipContent>{children}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
const handleFinish = (values: any) => {
const CreateTagModal: React.FC<CreateTagModalProps> = ({
visible,
onCancel,
onSubmit,
availableModels,
}) => {
const form = useForm<CreateTagFormValues>({
defaultValues,
mode: "onSubmit",
});
const handleFinish = form.handleSubmit((values) => {
onSubmit(values);
form.resetFields();
};
form.reset(defaultValues);
});
const handleCancel = () => {
form.resetFields();
form.reset(defaultValues);
onCancel();
};
// Multi-select scaffolding (chips + add via Select).
const allowedLlms = form.watch("allowed_llms");
const remainingModels = availableModels.filter(
(m) => !allowedLlms.includes(m.model_info.id),
);
return (
<Modal title="Create New Tag" open={visible} width={800} footer={null} onCancel={handleCancel}>
<Form form={form} onFinish={handleFinish} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
<Form.Item label="Tag Name" name="tag_name" rules={[{ required: true, message: "Please input a tag name" }]}>
<TextInput />
</Form.Item>
<Form.Item label="Description" name="description">
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item
label={
<span>
Allowed Models
<Tooltip title="Select which models are allowed to process requests from this tag">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="allowed_llms"
>
<Select2 mode="multiple" placeholder="Select Models">
{availableModels.map((model) => (
<Select2.Option key={model.model_info.id} value={model.model_info.id}>
<div>
<span>{model.model_name}</span>
<span className="text-gray-400 ml-2">({model.model_info.id})</span>
</div>
</Select2.Option>
))}
</Select2>
</Form.Item>
<Accordion className="mt-4 mb-4">
<AccordionHeader>
<Title className="m-0">Budget & Rate Limits (Optional)</Title>
</AccordionHeader>
<AccordionBody>
<Form.Item
className="mt-4"
label={
<span>
Max Budget (USD){" "}
<Tooltip title="Maximum amount in USD this tag can spend. When reached, requests with this tag will be blocked">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="max_budget"
>
<NumericalInput step={0.01} precision={2} width={200} />
</Form.Item>
<Form.Item
className="mt-4"
label={
<span>
Reset Budget{" "}
<Tooltip title="How often the budget should reset. For example, setting 'daily' will reset the budget every 24 hours">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="budget_duration"
>
<BudgetDurationDropdown onChange={(value) => form.setFieldValue("budget_duration", value)} />
</Form.Item>
<div className="mt-4 p-3 bg-gray-50 rounded-md border border-gray-200">
<p className="text-sm text-gray-600">
TPM/RPM limits for tags are not currently supported. If you need this feature, please{" "}
<a
href="https://github.com/BerriAI/litellm/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline"
>
create a GitHub issue
</a>
.
</p>
<Dialog
open={visible}
onOpenChange={(o) => (!o ? handleCancel() : undefined)}
>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Create New Tag</DialogTitle>
<DialogDescription className="sr-only">
Define a new tag with allowed models and optional budget / rate
limits.
</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form onSubmit={handleFinish} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tag_name">
Tag Name <span className="text-destructive">*</span>
</Label>
<Input
id="tag_name"
{...form.register("tag_name", {
required: "Please input a tag name",
})}
/>
{form.formState.errors.tag_name && (
<p className="text-sm text-destructive">
{form.formState.errors.tag_name.message as string}
</p>
)}
</div>
</AccordionBody>
</Accordion>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button type="submit">Create Tag</Button>
</div>
</Form>
</Modal>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
rows={4}
{...form.register("description")}
/>
</div>
<div className="space-y-2">
<Label>
Allowed Models
<InfoTip>
Select which models are allowed to process requests from
this tag
</InfoTip>
</Label>
<Controller
control={form.control}
name="allowed_llms"
render={({ field }) => (
<div className="space-y-2">
<Select
value=""
onValueChange={(v) => {
if (v) field.onChange([...field.value, v]);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select Models" />
</SelectTrigger>
<SelectContent>
{remainingModels.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No more models available
</div>
) : (
remainingModels.map((model) => (
<SelectItem
key={model.model_info.id}
value={model.model_info.id}
>
{model.model_name}{" "}
<span className="text-muted-foreground text-xs ml-2">
({model.model_info.id})
</span>
</SelectItem>
))
)}
</SelectContent>
</Select>
{field.value.length > 0 && (
<div className="flex flex-wrap gap-1">
{field.value.map((id) => {
const m = availableModels.find(
(mm) => mm.model_info.id === id,
);
return (
<span
key={id}
className="inline-flex items-center gap-1 rounded bg-secondary text-secondary-foreground px-2 py-0.5 text-xs"
>
{m?.model_name ?? id}
<button
type="button"
onClick={() =>
field.onChange(
field.value.filter((v) => v !== id),
)
}
aria-label={`Remove ${m?.model_name ?? id}`}
>
<X size={10} />
</button>
</span>
);
})}
</div>
)}
</div>
)}
/>
</div>
<Accordion type="single" collapsible className="mt-4 mb-4">
<AccordionItem value="budget">
<AccordionTrigger className="font-semibold">
Budget &amp; Rate Limits (Optional)
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="max_budget">
Max Budget (USD)
<InfoTip>
Maximum amount in USD this tag can spend. When
reached, requests with this tag will be blocked.
</InfoTip>
</Label>
<Controller
control={form.control}
name="max_budget"
render={({ field }) => (
<NumericalInput
step={0.01}
precision={2}
style={{ width: "100%" }}
value={field.value ?? undefined}
onChange={(v: number | null | undefined) =>
field.onChange(v ?? null)
}
/>
)}
/>
</div>
<div className="space-y-2">
<Label>
Reset Budget
<InfoTip>
How often the budget should reset. For example,
&quot;daily&quot; resets every 24 hours.
</InfoTip>
</Label>
<Controller
control={form.control}
name="budget_duration"
render={({ field }) => (
<BudgetDurationDropdown
onChange={(value) =>
field.onChange(value as string | null)
}
/>
)}
/>
</div>
<div className="mt-4 p-3 bg-muted rounded-md border border-border">
<p className="text-sm text-muted-foreground">
TPM/RPM limits for tags are not currently supported.
If you need this feature, please{" "}
<a
href="https://github.com/BerriAI/litellm/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
create a GitHub issue
</a>
.
</p>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button type="submit">Create Tag</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};

View file

@ -1,6 +1,14 @@
import React, { useState, useEffect } from "react";
import { Icon, Button, Col, Text, Grid } from "@tremor/react";
import { RefreshIcon } from "@heroicons/react/outline";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RefreshCw } from "lucide-react";
import TagInfoView from "./tag_info";
import { modelInfoCall } from "../networking";
import { tagCreateCall, tagListCall, tagDeleteCall } from "../networking";
@ -11,12 +19,8 @@ import CreateTagModal from "./components/CreateTagModal";
interface ModelInfo {
model_name: string;
litellm_params: {
model: string;
};
model_info: {
id: string;
};
litellm_params: { model: string };
model_info: { id: string };
}
interface TagProps {
@ -25,7 +29,11 @@ interface TagProps {
userRole: string | null;
}
const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) => {
const TagManagement: React.FC<TagProps> = ({
accessToken,
userID,
userRole,
}) => {
const [tags, setTags] = useState<Tag[]>([]);
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const [selectedTagId, setSelectedTagId] = useState<string | null>(null);
@ -39,7 +47,6 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
if (!accessToken) return;
try {
const response = await tagListCall(accessToken);
console.log("List tags response:", response);
setTags(Object.values(response));
} catch (error) {
console.error("Error fetching tags:", error);
@ -49,10 +56,10 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
const handleRefreshClick = () => {
fetchTags();
const currentDate = new Date();
setLastRefreshed(currentDate.toLocaleString());
setLastRefreshed(new Date().toLocaleString());
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleCreate = async (formValues: any) => {
if (!accessToken) return;
try {
@ -75,7 +82,7 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
}
};
const handleDelete = async (tagName: string) => {
const handleDelete = (tagName: string) => {
setTagToDelete(tagName);
setIsDeleteModalOpen(true);
};
@ -113,6 +120,7 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
useEffect(() => {
fetchTags();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accessToken]);
return (
@ -131,50 +139,59 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
) : (
<div className="gap-2 p-8 h-[75vh] w-full mt-2">
<div className="flex justify-between mt-2 w-full items-center mb-4">
<h1>Tag Management</h1>
<h1 className="text-xl font-semibold">Tag Management</h1>
<div className="flex items-center space-x-2">
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>}
<Icon
icon={RefreshIcon}
variant="shadow"
size="xs"
className="self-center cursor-pointer"
{lastRefreshed && (
<span className="text-sm text-muted-foreground">
Last Refreshed: {lastRefreshed}
</span>
)}
<Button
variant="outline"
size="icon"
onClick={handleRefreshClick}
/>
aria-label="Refresh"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
<Text className="mb-4">
<p className="text-sm text-muted-foreground mb-4">
Click on a tag name to view and edit its details.
<p>
You can use tags to restrict the usage of certain LLMs based on tags passed in the request. Read more
about tag routing{" "}
<a href="https://docs.litellm.ai/docs/proxy/tag_routing" target="_blank" rel="noopener noreferrer">
here
</a>
.
</p>
</Text>
<br />
You can use tags to restrict the usage of certain LLMs based on
tags passed in the request. Read more about tag routing{" "}
<a
href="https://docs.litellm.ai/docs/proxy/tag_routing"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
here
</a>
.
</p>
<Button className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
<Button
className="mb-4"
onClick={() => setIsCreateModalVisible(true)}
>
+ Create New Tag
</Button>
<Grid numItems={1} className="gap-2 pt-2 pb-2 h-[75vh] w-full mt-2">
<Col numColSpan={1}>
<TagTable
data={tags}
onEdit={(tag) => {
setSelectedTagId(tag.name);
setEditTag(true);
}}
onDelete={handleDelete}
onSelectTag={setSelectedTagId}
/>
</Col>
</Grid>
<div className="pt-2 pb-2 h-[75vh] w-full mt-2">
<TagTable
data={tags}
onEdit={(tag) => {
setSelectedTagId(tag.name);
setEditTag(true);
}}
onDelete={handleDelete}
onSelectTag={setSelectedTagId}
/>
</div>
{/* Create Tag Modal */}
<CreateTagModal
visible={isCreateModalVisible}
onCancel={() => setIsCreateModalVisible(false)}
@ -182,41 +199,38 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
availableModels={availableModels}
/>
{/* Delete Confirmation Modal */}
{isDeleteModalOpen && (
<div className="fixed z-10 inset-0 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 transition-opacity" aria-hidden="true">
<div className="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900">Delete Tag</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">Are you sure you want to delete this tag?</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<Button onClick={confirmDelete} color="red" className="ml-2">
Delete
</Button>
<Button
onClick={() => {
setIsDeleteModalOpen(false);
setTagToDelete(null);
}}
>
Cancel
</Button>
</div>
</div>
</div>
</div>
)}
<Dialog
open={isDeleteModalOpen}
onOpenChange={(o) => {
if (!o) {
setIsDeleteModalOpen(false);
setTagToDelete(null);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete Tag</DialogTitle>
<DialogDescription>
Are you sure you want to delete this tag?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsDeleteModalOpen(false);
setTagToDelete(null);
}}
>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)}
</div>

View file

@ -1,17 +1,29 @@
import React, { useState, useEffect } from "react";
import {
Card,
Text,
Title,
Button,
Badge,
Accordion,
AccordionHeader,
AccordionBody,
Title as TremorTitle,
} from "@tremor/react";
import { Form, Input, Select as Select2, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { fetchUserModels } from "../organisms/create_key_button";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
import { tagInfoCall, tagUpdateCall } from "../networking";
@ -20,8 +32,8 @@ import NotificationsManager from "../molecules/notifications_manager";
import NumericalInput from "../shared/numerical_input";
import BudgetDurationDropdown from "../common_components/budget_duration_dropdown";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { CheckIcon, CopyIcon } from "lucide-react";
import { Button as AntdButton } from "antd";
import { CheckIcon, CopyIcon, Info, X } from "lucide-react";
import { Controller, FormProvider, useForm } from "react-hook-form";
interface TagInfoViewProps {
tagId: string;
@ -31,14 +43,53 @@ interface TagInfoViewProps {
editTag: boolean;
}
const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken, is_admin, editTag }) => {
const [form] = Form.useForm();
interface TagEditValues {
name: string;
description: string;
models: string[];
max_budget: number | null;
budget_duration: string | null;
}
function InfoTip({ children }: { children: React.ReactNode }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="inline-block ml-1 h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>{children}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
const TagInfoView: React.FC<TagInfoViewProps> = ({
tagId,
onClose,
accessToken,
is_admin,
editTag,
}) => {
const form = useForm<TagEditValues>({
defaultValues: {
name: "",
description: "",
models: [],
max_budget: null,
budget_duration: null,
},
mode: "onSubmit",
});
const [tagDetails, setTagDetails] = useState<Tag | null>(null);
const [isEditing, setIsEditing] = useState<boolean>(editTag);
const [userModels, setUserModels] = useState<string[]>([]);
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
const copyToClipboard = async (text: string | null | undefined, key: string) => {
const copyToClipboard = async (
text: string | null | undefined,
key: string,
) => {
const success = await utilCopyToClipboard(text);
if (success) {
setCopiedStates((prev) => ({ ...prev, [key]: true }));
@ -56,12 +107,13 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
if (tagData) {
setTagDetails(tagData);
if (editTag) {
form.setFieldsValue({
form.reset({
name: tagData.name,
description: tagData.description,
models: tagData.models,
max_budget: tagData.litellm_budget_table?.max_budget,
budget_duration: tagData.litellm_budget_table?.budget_duration,
description: tagData.description ?? "",
models: tagData.models ?? [],
max_budget: tagData.litellm_budget_table?.max_budget ?? null,
budget_duration:
tagData.litellm_budget_table?.budget_duration ?? null,
});
}
}
@ -73,27 +125,24 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
useEffect(() => {
fetchTagDetails();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tagId, accessToken]);
useEffect(() => {
if (accessToken) {
// Using dummy values for userID and userRole since they're required by the function
// TODO: Pass these as props if needed for the actual API implementation
fetchUserModels("dummy-user", "Admin", accessToken, setUserModels);
}
}, [accessToken]);
const handleSave = async (values: any) => {
const handleSave = form.handleSubmit(async (values) => {
if (!accessToken) return;
try {
await tagUpdateCall(accessToken, {
name: values.name,
description: values.description,
models: values.models,
max_budget: values.max_budget,
tpm_limit: values.tpm_limit,
rpm_limit: values.rpm_limit,
budget_duration: values.budget_duration,
max_budget: values.max_budget ?? undefined,
budget_duration: values.budget_duration ?? undefined,
});
NotificationsManager.success("Tag updated successfully");
setIsEditing(false);
@ -102,195 +151,313 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
console.error("Error updating tag:", error);
NotificationsManager.fromBackend("Error updating tag: " + error);
}
};
});
if (!tagDetails) {
return <div>Loading...</div>;
}
if (!tagDetails) return <div>Loading...</div>;
return (
<div className="p-4">
<div className="flex justify-between items-center mb-6">
<div>
<Button onClick={onClose} className="mb-4">
<Button onClick={onClose} variant="outline" className="mb-4">
← Back to Tags
</Button>
<div className="flex items-center gap-2">
<Text className="font-medium">Tag Name:</Text>
<span className="font-mono px-2 py-1 bg-gray-100 rounded text-sm border border-gray-200">
<span className="font-medium">Tag Name:</span>
<span className="font-mono px-2 py-1 bg-muted rounded text-sm border border-border">
{tagDetails.name}
</span>
<AntdButton
type="text"
size="small"
icon={copiedStates["tag-name"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(tagDetails.name, "tag-name")}
className={`transition-all duration-200 ${
copiedStates["tag-name"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
/>
aria-label="Copy tag name"
className="h-7 w-7"
>
{copiedStates["tag-name"] ? (
<CheckIcon size={12} />
) : (
<CopyIcon size={12} />
)}
</Button>
</div>
<Text className="text-gray-500">{tagDetails.description || "No description"}</Text>
<p className="text-muted-foreground text-sm">
{tagDetails.description || "No description"}
</p>
</div>
{is_admin && !isEditing && <Button onClick={() => setIsEditing(true)}>Edit Tag</Button>}
{is_admin && !isEditing && (
<Button onClick={() => setIsEditing(true)}>Edit Tag</Button>
)}
</div>
{isEditing ? (
<Card>
<Form form={form} onFinish={handleSave} layout="vertical" initialValues={tagDetails}>
<Form.Item label="Tag Name" name="name" rules={[{ required: true, message: "Please input a tag name" }]}>
<Input className="rounded-md border-gray-300" />
</Form.Item>
<Form.Item label="Description" name="description">
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item
label={
<span>
Allowed Models
<Tooltip title="Select which models are allowed to process this type of data">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="models"
>
<Select2 mode="multiple" placeholder="Select Models">
{userModels.map((modelId) => (
<Select2.Option key={modelId} value={modelId}>
{getModelDisplayName(modelId)}
</Select2.Option>
))}
</Select2>
</Form.Item>
<Accordion className="mt-4 mb-4">
<AccordionHeader>
<TremorTitle className="m-0">Budget & Rate Limits</TremorTitle>
</AccordionHeader>
<AccordionBody>
<Form.Item
label={
<span>
Max Budget (USD){" "}
<Tooltip title="Maximum amount in USD this tag can spend">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="max_budget"
>
<NumericalInput step={0.01} precision={2} width={200} />
</Form.Item>
<Form.Item
label={
<span>
Reset Budget{" "}
<Tooltip title="How often the budget should reset">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="budget_duration"
>
<BudgetDurationDropdown onChange={(value) => form.setFieldValue("budget_duration", value)} />
</Form.Item>
<div className="mt-4 p-3 bg-gray-50 rounded-md border border-gray-200">
<p className="text-sm text-gray-600">
TPM/RPM limits for tags are not currently supported. If you need this feature, please{" "}
<a
href="https://github.com/BerriAI/litellm/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline"
>
create a GitHub issue
</a>
.
<Card className="p-6">
<FormProvider {...form}>
<form onSubmit={handleSave} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">
Tag Name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
{...form.register("name", {
required: "Please input a tag name",
})}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message as string}
</p>
</div>
</AccordionBody>
</Accordion>
)}
</div>
<div className="flex justify-end space-x-2">
<Button onClick={() => setIsEditing(false)}>Cancel</Button>
<Button type="submit">Save Changes</Button>
</div>
</Form>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
rows={4}
{...form.register("description")}
/>
</div>
<div className="space-y-2">
<Label>
Allowed Models
<InfoTip>
Select which models are allowed to process this type of data
</InfoTip>
</Label>
<Controller
control={form.control}
name="models"
render={({ field }) => {
const remaining = userModels.filter(
(m) => !field.value.includes(m),
);
return (
<div className="space-y-2">
<Select
value=""
onValueChange={(v) => {
if (v) field.onChange([...field.value, v]);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select Models" />
</SelectTrigger>
<SelectContent>
{remaining.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No more models available
</div>
) : (
remaining.map((modelId) => (
<SelectItem key={modelId} value={modelId}>
{getModelDisplayName(modelId)}
</SelectItem>
))
)}
</SelectContent>
</Select>
{field.value.length > 0 && (
<div className="flex flex-wrap gap-1">
{field.value.map((id) => (
<Badge
key={id}
variant="secondary"
className="gap-1"
>
{getModelDisplayName(id)}
<button
type="button"
onClick={() =>
field.onChange(
field.value.filter((v) => v !== id),
)
}
aria-label={`Remove ${id}`}
>
<X size={10} />
</button>
</Badge>
))}
</div>
)}
</div>
);
}}
/>
</div>
<Accordion type="single" collapsible className="mt-4 mb-4">
<AccordionItem value="budget">
<AccordionTrigger className="font-semibold">
Budget &amp; Rate Limits
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="space-y-2">
<Label>
Max Budget (USD)
<InfoTip>
Maximum amount in USD this tag can spend
</InfoTip>
</Label>
<Controller
control={form.control}
name="max_budget"
render={({ field }) => (
<NumericalInput
step={0.01}
precision={2}
style={{ width: "100%" }}
value={field.value ?? ""}
onChange={(v: number | null | undefined) =>
field.onChange(v ?? null)
}
/>
)}
/>
</div>
<div className="space-y-2">
<Label>
Reset Budget
<InfoTip>
How often the budget should reset
</InfoTip>
</Label>
<Controller
control={form.control}
name="budget_duration"
render={({ field }) => (
<BudgetDurationDropdown
value={field.value ?? undefined}
onChange={(value) =>
field.onChange((value as string) ?? null)
}
/>
)}
/>
</div>
<div className="mt-4 p-3 bg-muted rounded-md border border-border">
<p className="text-sm text-muted-foreground">
TPM/RPM limits for tags are not currently supported.
If you need this feature, please{" "}
<a
href="https://github.com/BerriAI/litellm/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
create a GitHub issue
</a>
.
</p>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
onClick={() => setIsEditing(false)}
>
Cancel
</Button>
<Button type="submit">Save Changes</Button>
</div>
</form>
</FormProvider>
</Card>
) : (
<div className="space-y-6">
<Card>
<Title>Tag Details</Title>
<div className="space-y-4 mt-4">
<Card className="p-6">
<h3 className="text-lg font-semibold">Tag Details</h3>
<div className="space-y-4 mt-4 text-sm">
<div>
<Text className="font-medium">Name</Text>
<Text>{tagDetails.name}</Text>
<p className="font-medium">Name</p>
<p>{tagDetails.name}</p>
</div>
<div>
<Text className="font-medium">Description</Text>
<Text>{tagDetails.description || "-"}</Text>
<p className="font-medium">Description</p>
<p>{tagDetails.description || "-"}</p>
</div>
<div>
<Text className="font-medium">Allowed Models</Text>
<p className="font-medium">Allowed Models</p>
<div className="flex flex-wrap gap-2 mt-2">
{!tagDetails.models || tagDetails.models.length === 0 ? (
<Badge color="red">All Models</Badge>
<Badge variant="destructive">All Models</Badge>
) : (
tagDetails.models.map((modelId) => (
<Badge key={modelId} color="blue">
<Tooltip title={`ID: ${modelId}`}>{tagDetails.model_info?.[modelId] || modelId}</Tooltip>
</Badge>
<TooltipProvider key={modelId}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">
{tagDetails.model_info?.[modelId] || modelId}
</Badge>
</TooltipTrigger>
<TooltipContent>{`ID: ${modelId}`}</TooltipContent>
</Tooltip>
</TooltipProvider>
))
)}
</div>
</div>
<div>
<Text className="font-medium">Created</Text>
<Text>{tagDetails.created_at ? new Date(tagDetails.created_at).toLocaleString() : "-"}</Text>
<p className="font-medium">Created</p>
<p>
{tagDetails.created_at
? new Date(tagDetails.created_at).toLocaleString()
: "-"}
</p>
</div>
<div>
<Text className="font-medium">Last Updated</Text>
<Text>{tagDetails.updated_at ? new Date(tagDetails.updated_at).toLocaleString() : "-"}</Text>
<p className="font-medium">Last Updated</p>
<p>
{tagDetails.updated_at
? new Date(tagDetails.updated_at).toLocaleString()
: "-"}
</p>
</div>
</div>
</Card>
{tagDetails.litellm_budget_table && (
<Card>
<Title>Budget & Rate Limits</Title>
<div className="space-y-4 mt-4">
<Card className="p-6">
<h3 className="text-lg font-semibold">Budget &amp; Rate Limits</h3>
<div className="space-y-4 mt-4 text-sm">
{tagDetails.litellm_budget_table.max_budget !== undefined &&
tagDetails.litellm_budget_table.max_budget !== null && (
<div>
<Text className="font-medium">Max Budget</Text>
<Text>${tagDetails.litellm_budget_table.max_budget}</Text>
<p className="font-medium">Max Budget</p>
<p>${tagDetails.litellm_budget_table.max_budget}</p>
</div>
)}
{tagDetails.litellm_budget_table.budget_duration && (
<div>
<Text className="font-medium">Budget Duration</Text>
<Text>{tagDetails.litellm_budget_table.budget_duration}</Text>
<p className="font-medium">Budget Duration</p>
<p>{tagDetails.litellm_budget_table.budget_duration}</p>
</div>
)}
{tagDetails.litellm_budget_table.tpm_limit !== undefined &&
tagDetails.litellm_budget_table.tpm_limit !== null && (
<div>
<Text className="font-medium">TPM Limit</Text>
<Text>{tagDetails.litellm_budget_table.tpm_limit.toLocaleString()}</Text>
<p className="font-medium">TPM Limit</p>
<p>
{tagDetails.litellm_budget_table.tpm_limit.toLocaleString()}
</p>
</div>
)}
{tagDetails.litellm_budget_table.rpm_limit !== undefined &&
tagDetails.litellm_budget_table.rpm_limit !== null && (
<div>
<Text className="font-medium">RPM Limit</Text>
<Text>{tagDetails.litellm_budget_table.rpm_limit.toLocaleString()}</Text>
<p className="font-medium">RPM Limit</p>
<p>
{tagDetails.litellm_budget_table.rpm_limit.toLocaleString()}
</p>
</div>
)}
</div>