mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
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:
parent
3b7b49137d
commit
43524c1f62
9 changed files with 1009 additions and 473 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 & 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,
|
||||
"daily" 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue