mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-10 06:22:04 +00:00
Disable edit, delete, info, for dynamically generated spend tags
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import TagTable from "./TagTable";
|
||||
import { Tag } from "./types";
|
||||
|
||||
describe("TagTable", () => {
|
||||
const mockOnEdit = vi.fn();
|
||||
const mockOnDelete = vi.fn();
|
||||
const mockOnSelectTag = vi.fn();
|
||||
|
||||
const mockTag: Tag = {
|
||||
name: "test-tag",
|
||||
description: "Test 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-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const mockDynamicSpendTag: Tag = {
|
||||
name: "dynamic-spend-tag",
|
||||
description:
|
||||
"This is just a spend tag that was passed dynamically in a request. It does not control any LLM models.",
|
||||
models: [],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
data: [],
|
||||
onEdit: mockOnEdit,
|
||||
onDelete: mockOnDelete,
|
||||
onSelectTag: mockOnSelectTag,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
render(<TagTable {...defaultProps} />);
|
||||
expect(screen.getByText("Tag Name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Description")).toBeInTheDocument();
|
||||
expect(screen.getByText("Allowed Models")).toBeInTheDocument();
|
||||
expect(screen.getByText("Created")).toBeInTheDocument();
|
||||
expect(screen.getByText("Actions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display no tags found message when data is empty", () => {
|
||||
render(<TagTable {...defaultProps} />);
|
||||
expect(screen.getByText("No tags found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display tag name", () => {
|
||||
render(<TagTable {...defaultProps} data={[mockTag]} />);
|
||||
expect(screen.getByText("test-tag")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display tag description", () => {
|
||||
render(<TagTable {...defaultProps} data={[mockTag]} />);
|
||||
expect(screen.getByText("Test description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display All Models badge when models array is empty", () => {
|
||||
const tagWithNoModels: Tag = {
|
||||
...mockTag,
|
||||
models: [],
|
||||
};
|
||||
render(<TagTable {...defaultProps} data={[tagWithNoModels]} />);
|
||||
expect(screen.getByText("All Models")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display formatted created date", () => {
|
||||
render(<TagTable {...defaultProps} data={[mockTag]} />);
|
||||
const formattedDate = new Date(mockTag.created_at).toLocaleDateString();
|
||||
expect(screen.getByText(formattedDate)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable tag name button for dynamic spend tags", () => {
|
||||
render(<TagTable {...defaultProps} data={[mockDynamicSpendTag]} />);
|
||||
const tagButton = screen.getByRole("button", { name: "dynamic-spend-tag" });
|
||||
expect(tagButton).toBeDisabled();
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,4 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Icon,
|
||||
Button,
|
||||
Badge,
|
||||
Text,
|
||||
} from "@tremor/react";
|
||||
import { PencilAltIcon, TrashIcon, SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
|
||||
import { Tooltip } from "antd";
|
||||
import { ChevronDownIcon, ChevronUpIcon, PencilAltIcon, SwitchVerticalIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
@@ -21,6 +7,20 @@ import {
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Icon,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Text,
|
||||
} from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import React from "react";
|
||||
import { Tag } from "./types";
|
||||
|
||||
interface TagTableProps {
|
||||
@@ -30,6 +30,9 @@ interface TagTableProps {
|
||||
onSelectTag: (tagName: string) => void;
|
||||
}
|
||||
|
||||
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 }]);
|
||||
|
||||
@@ -39,14 +42,20 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
|
||||
accessorKey: "name",
|
||||
cell: ({ row }) => {
|
||||
const tag = row.original;
|
||||
const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION;
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<Tooltip title={tag.name}>
|
||||
<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>
|
||||
@@ -68,7 +77,7 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Allowed LLMs",
|
||||
header: "Allowed Models",
|
||||
accessorKey: "models",
|
||||
cell: ({ row }) => {
|
||||
const tag = row.original;
|
||||
@@ -102,13 +111,50 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
const tag = row.original;
|
||||
const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION;
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<Icon icon={PencilAltIcon} size="sm" onClick={() => onEdit(tag)} className="cursor-pointer" />
|
||||
<Icon icon={TrashIcon} size="sm" onClick={() => onDelete(tag.name)} className="cursor-pointer" />
|
||||
{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)"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Edit tag">
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => onEdit(tag)}
|
||||
className="cursor-pointer hover:text-blue-500"
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import CreateTagModal from "./CreateTagModal";
|
||||
|
||||
describe("CreateTagModal", () => {
|
||||
const mockOnCancel = vi.fn();
|
||||
const mockOnSubmit = vi.fn();
|
||||
const mockAvailableModels = [
|
||||
{
|
||||
model_name: "GPT-4",
|
||||
litellm_params: { model: "gpt-4" },
|
||||
model_info: { id: "model-1" },
|
||||
},
|
||||
{
|
||||
model_name: "Claude-3",
|
||||
litellm_params: { model: "claude-3" },
|
||||
model_info: { id: "model-2" },
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
visible: true,
|
||||
onCancel: mockOnCancel,
|
||||
onSubmit: mockOnSubmit,
|
||||
availableModels: mockAvailableModels,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the modal", () => {
|
||||
render(<CreateTagModal {...defaultProps} />);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText("Create New Tag")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should submit form with required tag name", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateTagModal {...defaultProps} />);
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not submit form when tag name is missing", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateTagModal {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /Create Tag/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
// Form validation should prevent submission
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from "react";
|
||||
import { Button, TextInput, Accordion, AccordionHeader, AccordionBody, Title } from "@tremor/react";
|
||||
import { Modal, Form, Select as Select2, Tooltip, Input } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import NumericalInput from "../../shared/numerical_input";
|
||||
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 BudgetDurationDropdown from "../../common_components/budget_duration_dropdown";
|
||||
import NumericalInput from "../../shared/numerical_input";
|
||||
|
||||
interface ModelInfo {
|
||||
model_name: string;
|
||||
@@ -22,12 +22,7 @@ interface CreateTagModalProps {
|
||||
availableModels: ModelInfo[];
|
||||
}
|
||||
|
||||
const CreateTagModal: React.FC<CreateTagModalProps> = ({
|
||||
visible,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
availableModels,
|
||||
}) => {
|
||||
const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSubmit, availableModels }) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleFinish = (values: any) => {
|
||||
@@ -41,25 +36,9 @@ const CreateTagModal: React.FC<CreateTagModalProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Create New Tag"
|
||||
visible={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" }]}
|
||||
>
|
||||
<Modal title="Create New Tag" visible={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>
|
||||
|
||||
@@ -70,15 +49,15 @@ const CreateTagModal: React.FC<CreateTagModalProps> = ({
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed Models{" "}
|
||||
<Tooltip title="Select which LLMs are allowed to process requests from this tag">
|
||||
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 LLMs">
|
||||
<Select2 mode="multiple" placeholder="Select Models">
|
||||
{availableModels.map((model) => (
|
||||
<Select2.Option key={model.model_info.id} value={model.model_info.id}>
|
||||
<div>
|
||||
@@ -150,4 +129,3 @@ const CreateTagModal: React.FC<CreateTagModalProps> = ({
|
||||
};
|
||||
|
||||
export default CreateTagModal;
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, Text, Title, Button, Badge, Accordion, AccordionHeader, AccordionBody, Title as TremorTitle } from "@tremor/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 { fetchUserModels } from "../organisms/create_key_button";
|
||||
@@ -131,7 +141,7 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
||||
<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 />
|
||||
<Input className="rounded-md border-gray-300" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Description" name="description">
|
||||
@@ -141,15 +151,15 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed LLMs{" "}
|
||||
<Tooltip title="Select which LLMs are allowed to process this type of data">
|
||||
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 LLMs">
|
||||
<Select2 mode="multiple" placeholder="Select Models">
|
||||
{userModels.map((modelId) => (
|
||||
<Select2.Option key={modelId} value={modelId}>
|
||||
{getModelDisplayName(modelId)}
|
||||
@@ -228,7 +238,7 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
||||
<Text>{tagDetails.description || "-"}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Allowed LLMs</Text>
|
||||
<Text className="font-medium">Allowed Models</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{!tagDetails.models || tagDetails.models.length === 0 ? (
|
||||
<Badge color="red">All Models</Badge>
|
||||
@@ -256,30 +266,33 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
||||
<Card>
|
||||
<Title>Budget & Rate Limits</Title>
|
||||
<div className="space-y-4 mt-4">
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
{tagDetails.litellm_budget_table.budget_duration && (
|
||||
<div>
|
||||
<Text className="font-medium">Budget Duration</Text>
|
||||
<Text>{tagDetails.litellm_budget_table.budget_duration}</Text>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user