mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
refactor(ui): migrate tag and memory forms to react-hook-form and shadcn (#37266)
Moves CreateTagModal, the tag detail editor and MemoryEditModal off antd Form onto react-hook-form with the shadcn field kit, and onto semantic colour tokens so the pages follow the dashboard theme. Submitted payloads are unchanged and pinned by tests. The collapsible budget sections keep values typed into them when collapsed and reopened, matching what antd's store did, rather than reverting to the seeded default.
This commit is contained in:
parent
b08032c5f7
commit
e09bbe9a14
5 changed files with 632 additions and 261 deletions
|
|
@ -0,0 +1,119 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MemoryRow } from "@/components/networking";
|
||||
|
||||
import { MemoryEditModal } from "./MemoryEditModal";
|
||||
|
||||
const onSave = vi.fn<(key: string, value: string, metadataText: string, isCreate: boolean) => Promise<boolean>>();
|
||||
const onClose = vi.fn();
|
||||
|
||||
const existingRow: MemoryRow = {
|
||||
memory_id: "mem-1",
|
||||
key: "user:profile",
|
||||
value: "The user prefers concise answers.",
|
||||
metadata: { tags: ["example"] },
|
||||
};
|
||||
|
||||
const renderModal = (props: Partial<React.ComponentProps<typeof MemoryEditModal>> = {}) =>
|
||||
render(<MemoryEditModal open mode="create" onClose={onClose} onSave={onSave} {...props} />);
|
||||
|
||||
const fill = async (user: ReturnType<typeof userEvent.setup>, label: RegExp, text: string) => {
|
||||
await user.click(screen.getByLabelText(label));
|
||||
await user.paste(text);
|
||||
};
|
||||
|
||||
describe("MemoryEditModal payload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
onSave.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("sends the trimmed key, the value and the raw metadata text on create", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await fill(user, /^Key/, " user_role ");
|
||||
await fill(user, /^Value/, "Remembers the user is an admin");
|
||||
await fill(user, /^Metadata/, '{"tags": ["example"]}');
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(onSave).toHaveBeenCalledWith("user_role", "Remembers the user is an admin", '{"tags": ["example"]}', true);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends an empty string for metadata the user never typed into", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await fill(user, /^Key/, "user_role");
|
||||
await fill(user, /^Value/, "Remembers the user is an admin");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith("user_role", "Remembers the user is an admin", "", true);
|
||||
});
|
||||
|
||||
it("prefills from the row and sends the edited value with isCreate false", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal({ mode: "edit", initialRow: existingRow });
|
||||
|
||||
expect(await screen.findByLabelText(/^Key/)).toHaveValue("user:profile");
|
||||
expect(screen.getByLabelText(/^Key/)).toBeDisabled();
|
||||
expect(screen.getByLabelText(/^Value/)).toHaveValue("The user prefers concise answers.");
|
||||
expect(screen.getByLabelText(/^Metadata/)).toHaveValue('{\n "tags": [\n "example"\n ]\n}');
|
||||
|
||||
await user.clear(screen.getByLabelText(/^Value/));
|
||||
await fill(user, /^Value/, "The user prefers long answers.");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
"user:profile",
|
||||
"The user prefers long answers.",
|
||||
'{\n "tags": [\n "example"\n ]\n}',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends an empty metadata string for a row that has none", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal({ mode: "edit", initialRow: { ...existingRow, metadata: null } });
|
||||
|
||||
expect(await screen.findByLabelText(/^Metadata/)).toHaveValue("");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith("user:profile", "The user prefers concise answers.", "", false);
|
||||
});
|
||||
|
||||
it("keeps the modal open when the save is rejected by the caller", async () => {
|
||||
const user = userEvent.setup();
|
||||
onSave.mockResolvedValue(false);
|
||||
renderModal();
|
||||
|
||||
await fill(user, /^Key/, "user_role");
|
||||
await fill(user, /^Value/, "Remembers the user is an admin");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports each required field as soon as it is emptied", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await fill(user, /^Key/, "user_role");
|
||||
await fill(user, /^Value/, "Remembers the user is an admin");
|
||||
expect(screen.queryByText("Key is required")).not.toBeInTheDocument();
|
||||
|
||||
await user.clear(screen.getByLabelText(/^Key/));
|
||||
expect(await screen.findByText("Key is required")).toBeInTheDocument();
|
||||
|
||||
await user.clear(screen.getByLabelText(/^Value/));
|
||||
expect(await screen.findByText("Value is required")).toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import { Modal } from "antd";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Form, Input, Modal, Typography } from "antd";
|
||||
import type { MemoryRow } from "@/components/networking";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const { Text } = Typography;
|
||||
import type { MemoryRow } from "@/components/networking";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
|
||||
const memorySchema = z.object({
|
||||
key: z.string().min(1, "Key is required"),
|
||||
value: z.string().min(1, "Value is required"),
|
||||
metadata: z.string(),
|
||||
});
|
||||
|
||||
type MemoryFormValues = z.output<typeof memorySchema>;
|
||||
|
||||
const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
const EMPTY_MEMORY: MemoryFormValues = { key: "", value: "", metadata: "" };
|
||||
|
||||
interface MemoryEditModalProps {
|
||||
open: boolean;
|
||||
|
|
@ -15,39 +42,37 @@ interface MemoryEditModalProps {
|
|||
}
|
||||
|
||||
export const MemoryEditModal: React.FC<MemoryEditModalProps> = ({ open, mode, initialRow, onClose, onSave }) => {
|
||||
const [form] = Form.useForm();
|
||||
const form = useZodForm(memorySchema, { defaultValues: EMPTY_MEMORY, mode: "onChange" });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (mode === "edit" && initialRow) {
|
||||
form.setFieldsValue({
|
||||
form.reset({
|
||||
key: initialRow.key,
|
||||
value: initialRow.value,
|
||||
metadata: initialRow.metadata != null ? JSON.stringify(initialRow.metadata, null, 2) : "",
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
return;
|
||||
}
|
||||
form.reset(EMPTY_MEMORY);
|
||||
}, [open, mode, initialRow, form]);
|
||||
|
||||
const handleOk = async () => {
|
||||
const values = await form.validateFields();
|
||||
const handleOk = form.handleSubmit(async (values) => {
|
||||
setSubmitting(true);
|
||||
const ok = await onSave(values.key.trim(), values.value ?? "", values.metadata ?? "", mode === "create");
|
||||
const ok = await onSave(values.key.trim(), values.value, values.metadata, mode === "create");
|
||||
setSubmitting(false);
|
||||
if (ok) {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
if (!ok) return;
|
||||
form.reset(EMPTY_MEMORY);
|
||||
onClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={mode === "create" ? "Create memory" : `Edit ${initialRow?.key ?? ""}`}
|
||||
onCancel={() => {
|
||||
form.resetFields();
|
||||
form.reset(EMPTY_MEMORY);
|
||||
onClose();
|
||||
}}
|
||||
onOk={handleOk}
|
||||
|
|
@ -56,39 +81,49 @@ export const MemoryEditModal: React.FC<MemoryEditModalProps> = ({ open, mode, in
|
|||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
label="Key"
|
||||
name="key"
|
||||
rules={[{ required: true, message: "Key is required" }]}
|
||||
tooltip="Globally unique — two memories cannot share a key. Namespace your own keys if you need per-user isolation (e.g. user:123:notes)."
|
||||
>
|
||||
<Input placeholder="e.g. user_role" disabled={mode === "edit"} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Value"
|
||||
name="value"
|
||||
rules={[{ required: true, message: "Value is required" }]}
|
||||
tooltip="Markdown/text injected into LLM context. Plain strings are fine."
|
||||
>
|
||||
<Input.TextArea rows={8} placeholder="What the agent should remember…" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Metadata <Text type="secondary">(optional JSON)</Text>
|
||||
</span>
|
||||
}
|
||||
name="metadata"
|
||||
tooltip="Optional structured metadata — must be valid JSON if provided."
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder='{"tags": ["example"]}'
|
||||
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
<TooltipProvider>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="key"
|
||||
label={labelWithHint(
|
||||
"Key",
|
||||
"Globally unique — two memories cannot share a key. Namespace your own keys if you need per-user isolation (e.g. user:123:notes).",
|
||||
)}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="e.g. user_role" disabled={mode === "edit"} />
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value"
|
||||
label={labelWithHint("Value", "Markdown/text injected into LLM context. Plain strings are fine.")}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Textarea {...field} ref={ref} rows={8} placeholder="What the agent should remember…" />
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metadata"
|
||||
label={labelWithHint(
|
||||
<span>
|
||||
Metadata <span className="text-muted-foreground">(optional JSON)</span>
|
||||
</span>,
|
||||
"Optional structured metadata — must be valid JSON if provided.",
|
||||
)}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Textarea {...field} ref={ref} rows={4} placeholder='{"tags": ["example"]}' className="font-mono" />
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</TooltipProvider>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,30 @@
|
|||
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";
|
||||
"use client";
|
||||
|
||||
import { Modal } from "antd";
|
||||
import { ChevronRight, CircleHelp } from "lucide-react";
|
||||
import React from "react";
|
||||
import { z } from "zod/v4";
|
||||
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import NumericalInput from "@/components/shared/numerical_input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
|
||||
const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
interface ModelInfo {
|
||||
model_name: string;
|
||||
|
|
@ -15,115 +36,136 @@ interface ModelInfo {
|
|||
};
|
||||
}
|
||||
|
||||
const createTagShape = {
|
||||
tag_name: z.string().min(1, "Please input a tag name"),
|
||||
description: z.string().optional(),
|
||||
allowed_llms: z.array(z.string()).optional(),
|
||||
max_budget: z.string().optional(),
|
||||
budget_duration: z.string().optional(),
|
||||
};
|
||||
|
||||
const createTagSchema = z.object(createTagShape);
|
||||
|
||||
export type CreateTagFormValues = z.output<typeof createTagSchema>;
|
||||
|
||||
interface CreateTagModalProps {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: any) => void;
|
||||
onSubmit: (values: CreateTagFormValues) => void;
|
||||
availableModels: ModelInfo[];
|
||||
}
|
||||
|
||||
const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSubmit, availableModels }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [budgetSectionOpen, setBudgetSectionOpen] = React.useState(false);
|
||||
const form = useZodForm(createTagSchema, { defaultValues: { tag_name: "" } });
|
||||
|
||||
const handleFinish = (values: any) => {
|
||||
onSubmit(values);
|
||||
form.resetFields();
|
||||
const modelOptions = availableModels.map((model) => ({
|
||||
label: model.model_name,
|
||||
value: model.model_info.id,
|
||||
description: model.model_info.id,
|
||||
}));
|
||||
|
||||
const handleFinish = (values: CreateTagFormValues) => {
|
||||
onSubmit(budgetSectionOpen ? values : { ...values, max_budget: undefined, budget_duration: undefined });
|
||||
form.reset();
|
||||
setBudgetSectionOpen(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
form.reset();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
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 onSubmit={form.handleSubmit(handleFinish)} noValidate>
|
||||
<TooltipProvider>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="tag_name" label="Tag Name">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} />}
|
||||
</FormField>
|
||||
|
||||
<Form.Item label="Description" name="description">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<FormField control={form.control} name="description" label="Description">
|
||||
{({ ref, value, ...field }) => <Textarea {...field} ref={ref} value={value ?? ""} rows={4} />}
|
||||
</FormField>
|
||||
|
||||
<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"
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowed_llms"
|
||||
label={labelWithHint(
|
||||
"Allowed Models",
|
||||
"Select which models are allowed to process requests from this tag",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{({ value, onChange }) => (
|
||||
<MultiSelect
|
||||
options={modelOptions}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
placeholder="Select Models"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
||||
<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"
|
||||
<Collapsible
|
||||
open={budgetSectionOpen}
|
||||
onOpenChange={setBudgetSectionOpen}
|
||||
className="mt-4 mb-4 rounded-md border border-border"
|
||||
>
|
||||
<CollapsibleTrigger className="group flex w-full items-center justify-between px-4 py-3 text-base font-medium text-foreground">
|
||||
Budget & Rate Limits (Optional)
|
||||
<ChevronRight className="size-4 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-4 pb-4">
|
||||
<FieldGroup className="mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_budget"
|
||||
label={labelWithHint(
|
||||
"Max Budget (USD)",
|
||||
"Maximum amount in USD this tag can spend. When reached, requests with this tag will be blocked",
|
||||
)}
|
||||
>
|
||||
create a GitHub issue
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
{({ ref, value, ...field }) => <NumericalInput {...field} value={value ?? ""} step={0.01} />}
|
||||
</FormField>
|
||||
|
||||
<div style={{ textAlign: "right", marginTop: "10px" }}>
|
||||
<Button type="submit">Create Tag</Button>
|
||||
</div>
|
||||
</Form>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="budget_duration"
|
||||
label={labelWithHint(
|
||||
"Reset Budget",
|
||||
"How often the budget should reset. For example, setting 'daily' will reset the budget every 24 hours",
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<BudgetDurationDropdown id={id} value={value ?? null} onChange={onChange} />
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-4 rounded-md border border-border bg-muted p-3">
|
||||
<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-blue-600 underline hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
create a GitHub issue
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="mt-2.5 text-right">
|
||||
<Button type="submit">Create Tag</Button>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { tagInfoCall, tagUpdateCall } from "@/components/networking";
|
||||
import type { Tag } from "@/components/tag_management/types";
|
||||
|
||||
import TagInfoView from "./tag_info";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
tagInfoCall: vi.fn(),
|
||||
tagUpdateCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/organisms/create_key_button", () => ({
|
||||
fetchUserModels: vi.fn(
|
||||
(_userID: string, _userRole: string, _accessToken: string, setUserModels: (models: string[]) => void) => {
|
||||
setUserModels(["model-1", "model-2"]);
|
||||
return Promise.resolve();
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
__esModule: true,
|
||||
default: { success: vi.fn(), fromBackend: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockTagInfoCall = vi.mocked(tagInfoCall);
|
||||
const mockTagUpdateCall = vi.mocked(tagUpdateCall);
|
||||
|
||||
const tag: Tag = {
|
||||
name: "prod-tag",
|
||||
description: "original description",
|
||||
models: ["model-1", "model-2"],
|
||||
model_info: { "model-1": "GPT-4", "model-2": "Claude-3" },
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-02T00:00:00Z",
|
||||
litellm_budget_table: { max_budget: 10, budget_duration: "7d", tpm_limit: 1000, rpm_limit: 60 },
|
||||
};
|
||||
|
||||
const renderEditor = async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagInfoView tagId="prod-tag" onClose={vi.fn()} accessToken="sk-test" is_admin editTag />);
|
||||
const nameInput = await screen.findByLabelText("Tag Name");
|
||||
return { user, nameInput };
|
||||
};
|
||||
|
||||
describe("TagInfoView save payload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTagInfoCall.mockResolvedValue({ "prod-tag": tag });
|
||||
mockTagUpdateCall.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("should send the edited fields and omit the budget fields while the budget section is collapsed", async () => {
|
||||
const { user, nameInput } = await renderEditor();
|
||||
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, "renamed-tag");
|
||||
|
||||
const descriptionInput = screen.getByLabelText("Description");
|
||||
await user.clear(descriptionInput);
|
||||
await user.type(descriptionInput, "updated description");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
const expected = {
|
||||
name: "renamed-tag",
|
||||
description: "updated description",
|
||||
models: ["model-1", "model-2"],
|
||||
max_budget: undefined,
|
||||
tpm_limit: undefined,
|
||||
rpm_limit: undefined,
|
||||
budget_duration: undefined,
|
||||
};
|
||||
|
||||
expect(mockTagUpdateCall).toHaveBeenCalledWith("sk-test", expected);
|
||||
});
|
||||
|
||||
it("should send the budget fields once the budget section is expanded", async () => {
|
||||
const { user, nameInput } = await renderEditor();
|
||||
expect(nameInput).toHaveValue("prod-tag");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Budget & Rate Limits/ }));
|
||||
|
||||
const maxBudgetInput = await screen.findByLabelText("Max Budget (USD)");
|
||||
await user.clear(maxBudgetInput);
|
||||
await user.type(maxBudgetInput, "150.75");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
const expected = {
|
||||
name: "prod-tag",
|
||||
description: "original description",
|
||||
models: ["model-1", "model-2"],
|
||||
max_budget: "150.75",
|
||||
tpm_limit: undefined,
|
||||
rpm_limit: undefined,
|
||||
budget_duration: "7d",
|
||||
};
|
||||
|
||||
expect(mockTagUpdateCall).toHaveBeenCalledWith("sk-test", expected);
|
||||
});
|
||||
|
||||
it("should block the save when the tag name is cleared", async () => {
|
||||
const { user, nameInput } = await renderEditor();
|
||||
|
||||
await user.clear(nameInput);
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
expect(await screen.findByText("Please input a tag name")).toBeInTheDocument();
|
||||
expect(mockTagUpdateCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a typed budget when the section is collapsed and reopened, as antd's store did", async () => {
|
||||
const { user } = await renderEditor();
|
||||
const toggle = () => screen.getByRole("button", { name: /Budget & Rate Limits/ });
|
||||
|
||||
await user.click(toggle());
|
||||
const maxBudgetInput = await screen.findByLabelText("Max Budget (USD)");
|
||||
await user.clear(maxBudgetInput);
|
||||
await user.type(maxBudgetInput, "150.75");
|
||||
|
||||
await user.click(toggle());
|
||||
await user.click(toggle());
|
||||
|
||||
expect(await screen.findByLabelText("Max Budget (USD)")).toHaveValue(150.75);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
const expected = {
|
||||
name: "prod-tag",
|
||||
description: "original description",
|
||||
models: ["model-1", "model-2"],
|
||||
max_budget: "150.75",
|
||||
tpm_limit: undefined,
|
||||
rpm_limit: undefined,
|
||||
budget_duration: "7d",
|
||||
};
|
||||
|
||||
expect(mockTagUpdateCall).toHaveBeenCalledWith("sk-test", expected);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,27 +1,145 @@
|
|||
"use client";
|
||||
|
||||
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";
|
||||
import { Card, Text, Title, Button as TremorButton, Badge } from "@tremor/react";
|
||||
import { Tooltip, Button as AntdButton } from "antd";
|
||||
import { z } from "zod/v4";
|
||||
import { fetchUserModels } from "@/components/organisms/create_key_button";
|
||||
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
|
||||
import { tagInfoCall, tagUpdateCall } from "@/components/networking";
|
||||
import { Tag } from "@/components/tag_management/types";
|
||||
import { Tag, TagUpdateRequest } from "@/components/tag_management/types";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import NumericalInput from "@/components/shared/numerical_input";
|
||||
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { Button as AntdButton } from "antd";
|
||||
import { CheckIcon, ChevronRight, CopyIcon } from "lucide-react";
|
||||
|
||||
const tagEditShape = {
|
||||
name: z.string().min(1, "Please input a tag name"),
|
||||
description: z.string().optional(),
|
||||
models: z.array(z.string()).optional(),
|
||||
max_budget: z.union([z.string(), z.number()]).optional(),
|
||||
budget_duration: z.string().optional(),
|
||||
};
|
||||
|
||||
const tagEditSchema = z.object(tagEditShape);
|
||||
|
||||
type TagEditFormValues = z.output<typeof tagEditSchema>;
|
||||
|
||||
interface TagEditFormProps {
|
||||
tag: Tag;
|
||||
seedBudgetFields: boolean;
|
||||
userModels: string[];
|
||||
onCancel: () => void;
|
||||
onSave: (values: TagEditFormValues) => Promise<void>;
|
||||
}
|
||||
|
||||
const TagEditForm: React.FC<TagEditFormProps> = ({ tag, seedBudgetFields, userModels, onCancel, onSave }) => {
|
||||
const [budgetSectionOpen, setBudgetSectionOpen] = useState(false);
|
||||
const form = useZodForm(tagEditSchema, {
|
||||
defaultValues: {
|
||||
name: tag.name,
|
||||
description: tag.description,
|
||||
models: tag.models,
|
||||
max_budget: seedBudgetFields ? tag.litellm_budget_table?.max_budget : undefined,
|
||||
budget_duration: seedBudgetFields ? tag.litellm_budget_table?.budget_duration : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const submitVisibleValues = (values: TagEditFormValues): Promise<void> =>
|
||||
onSave(budgetSectionOpen ? values : { ...values, max_budget: undefined, budget_duration: undefined });
|
||||
|
||||
const modelOptions = userModels.map((modelId) => ({ label: getModelDisplayName(modelId), value: modelId }));
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(submitVisibleValues)} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="name" label="Tag Name">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="description" label="Description">
|
||||
{({ ref, value, ...field }) => <Textarea {...field} ref={ref} value={value ?? ""} rows={4} />}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="models"
|
||||
label="Allowed Models"
|
||||
description="Select which models are allowed to process this type of data"
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
<MultiSelect options={modelOptions} value={value} onValueChange={onChange} placeholder="Select Models" />
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
||||
<Collapsible
|
||||
open={budgetSectionOpen}
|
||||
onOpenChange={setBudgetSectionOpen}
|
||||
className="mt-4 mb-4 rounded-md border border-border"
|
||||
>
|
||||
<CollapsibleTrigger className="group flex w-full items-center justify-between px-4 py-3 text-base font-medium text-foreground">
|
||||
Budget & Rate Limits
|
||||
<ChevronRight className="size-4 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-4 pb-4">
|
||||
<FieldGroup className="mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_budget"
|
||||
label="Max Budget (USD)"
|
||||
description="Maximum amount in USD this tag can spend"
|
||||
>
|
||||
{({ ref, value, ...field }) => <NumericalInput {...field} value={value ?? ""} step={0.01} />}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="budget_duration"
|
||||
label="Reset Budget"
|
||||
description="How often the budget should reset"
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<BudgetDurationDropdown id={id} value={value ?? null} onChange={onChange} />
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-4 rounded-md border border-border bg-muted p-3">
|
||||
<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-blue-600 underline hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
create a GitHub issue
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface TagInfoViewProps {
|
||||
tagId: string;
|
||||
|
|
@ -32,7 +150,6 @@ interface TagInfoViewProps {
|
|||
}
|
||||
|
||||
const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken, is_admin, editTag }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [tagDetails, setTagDetails] = useState<Tag | null>(null);
|
||||
const [isEditing, setIsEditing] = useState<boolean>(editTag);
|
||||
const [userModels, setUserModels] = useState<string[]>([]);
|
||||
|
|
@ -55,15 +172,6 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
|||
const tagData = response[tagId];
|
||||
if (tagData) {
|
||||
setTagDetails(tagData);
|
||||
if (editTag) {
|
||||
form.setFieldsValue({
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching tag details:", error);
|
||||
|
|
@ -83,16 +191,16 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
|||
}
|
||||
}, [accessToken]);
|
||||
|
||||
const handleSave = async (values: any) => {
|
||||
const handleSave = async (values: TagEditFormValues) => {
|
||||
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,
|
||||
models: values.models as TagUpdateRequest["models"],
|
||||
max_budget: values.max_budget as TagUpdateRequest["max_budget"],
|
||||
tpm_limit: undefined,
|
||||
rpm_limit: undefined,
|
||||
budget_duration: values.budget_duration,
|
||||
});
|
||||
NotificationsManager.success("Tag updated successfully");
|
||||
|
|
@ -112,12 +220,12 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
|||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<Button onClick={onClose} className="mb-4">
|
||||
<TremorButton onClick={onClose} className="mb-4">
|
||||
← Back to Tags
|
||||
</Button>
|
||||
</TremorButton>
|
||||
<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-sm text-sm border border-gray-200">
|
||||
<span className="font-mono px-2 py-1 bg-muted rounded-sm text-sm border border-border">
|
||||
{tagDetails.name}
|
||||
</span>
|
||||
<AntdButton
|
||||
|
|
@ -127,102 +235,25 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
|||
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"
|
||||
? "text-green-600 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950 dark:border-green-800"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<Text className="text-gray-500">{tagDetails.description || "No description"}</Text>
|
||||
<Text className="text-muted-foreground">{tagDetails.description || "No description"}</Text>
|
||||
</div>
|
||||
{is_admin && !isEditing && <Button onClick={() => setIsEditing(true)}>Edit Tag</Button>}
|
||||
{is_admin && !isEditing && <TremorButton onClick={() => setIsEditing(true)}>Edit Tag</TremorButton>}
|
||||
</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>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button onClick={() => setIsEditing(false)}>Cancel</Button>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</div>
|
||||
</Form>
|
||||
<TagEditForm
|
||||
tag={tagDetails}
|
||||
seedBudgetFields={editTag}
|
||||
userModels={userModels}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue