[Feature] UI - Add Tags to update model (#16140)

* Add Tags to update model

* Test Key Page Tag Selector
This commit is contained in:
yuneng-jiang 2025-10-31 16:28:46 -07:00 committed by GitHub
parent dd053e1d12
commit cf0d84c789
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 207 additions and 48 deletions

View file

@ -127,53 +127,120 @@ describe("ModelInfoView", () => {
supported_openai_params: ["temperature", "max_tokens", "top_p", "frequency_penalty", "presence_penalty"],
};
it("should render the model info view", async () => {
const { getByText } = render(
<ModelInfoView
modelId="123"
onClose={() => {}}
modelData={modelData}
accessToken="123"
userID="123"
userRole="Admin"
editModel={false}
setEditModalVisible={() => {}}
setSelectedModel={() => {}}
onModelUpdate={() => {}}
modelAccessGroups={[]}
/>,
);
await waitFor(() => {
expect(getByText("Model Settings")).toBeInTheDocument();
describe("Edit Model", () => {
it("should render the model info view", async () => {
const { getByText } = render(
<ModelInfoView
modelId="123"
onClose={() => {}}
modelData={modelData}
accessToken="123"
userID="123"
userRole="Admin"
editModel={false}
setEditModalVisible={() => {}}
setSelectedModel={() => {}}
onModelUpdate={() => {}}
modelAccessGroups={[]}
/>,
);
await waitFor(() => {
expect(getByText("Model Settings")).toBeInTheDocument();
});
});
it("should not render an edit model button if the model is not a DB model", async () => {
const nonDbModelData = {
...modelData,
model_info: {
...modelData.model_info,
db_model: false,
},
};
const { queryByText } = render(
<ModelInfoView
modelId="123"
onClose={() => {}}
modelData={nonDbModelData}
accessToken="123"
userID="123"
userRole="Admin"
editModel={false}
setEditModalVisible={() => {}}
setSelectedModel={() => {}}
onModelUpdate={() => {}}
modelAccessGroups={[]}
/>,
);
await waitFor(() => {
expect(queryByText("Edit Model")).not.toBeInTheDocument();
});
});
it("should render tags in the edit model", async () => {
const { getByText } = render(
<ModelInfoView
modelId="123"
onClose={() => {}}
modelData={modelData}
accessToken="123"
userID="123"
userRole="Admin"
editModel={true}
setEditModalVisible={() => {}}
setSelectedModel={() => {}}
onModelUpdate={() => {}}
modelAccessGroups={[]}
/>,
);
await waitFor(() => {
expect(getByText("Tags")).toBeInTheDocument();
});
});
});
it("should not render an edit model button if the model is not a DB model", async () => {
const nonDbModelData = {
...modelData,
model_info: {
...modelData.model_info,
db_model: false,
},
};
describe("View Model", () => {
it("should render the model info view", async () => {
const { getByText } = render(
<ModelInfoView
modelId="123"
onClose={() => {}}
modelData={modelData}
accessToken="123"
userID="123"
userRole="Admin"
editModel={false}
setEditModalVisible={() => {}}
setSelectedModel={() => {}}
onModelUpdate={() => {}}
modelAccessGroups={[]}
/>,
);
await waitFor(() => {
expect(getByText("Model Settings")).toBeInTheDocument();
});
});
const { queryByText } = render(
<ModelInfoView
modelId="123"
onClose={() => {}}
modelData={nonDbModelData}
accessToken="123"
userID="123"
userRole="Admin"
editModel={false}
setEditModalVisible={() => {}}
setSelectedModel={() => {}}
onModelUpdate={() => {}}
modelAccessGroups={[]}
/>,
);
await waitFor(() => {
expect(queryByText("Edit Model")).not.toBeInTheDocument();
it("should render tags in the view model", async () => {
const { getByText } = render(
<ModelInfoView
modelId="123"
onClose={() => {}}
modelData={modelData}
accessToken="123"
userID="123"
userRole="Admin"
editModel={false}
setEditModalVisible={() => {}}
setSelectedModel={() => {}}
onModelUpdate={() => {}}
modelAccessGroups={[]}
/>,
);
await waitFor(() => {
expect(getByText("Tags")).toBeInTheDocument();
});
});
});
});

View file

@ -22,6 +22,7 @@ import {
modelInfoV1Call,
modelPatchUpdateCall,
getGuardrailsList,
tagListCall,
} from "./networking";
import { Button, Form, Input, Select, Modal, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
@ -33,6 +34,7 @@ import { CheckIcon, CopyIcon } from "lucide-react";
import { copyToClipboard as utilCopyToClipboard } from "../utils/dataUtils";
import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal";
import NotificationsManager from "./molecules/notifications_manager";
import { Tag } from "./tag_management/types";
interface ModelInfoViewProps {
modelId: string;
@ -73,6 +75,7 @@ export default function ModelInfoView({
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
const [isAutoRouterModalOpen, setIsAutoRouterModalOpen] = useState(false);
const [guardrailsList, setGuardrailsList] = useState<string[]>([]);
const [tagsList, setTagsList] = useState<Record<string, Tag>>({});
const canEditModel =
(userRole === "Admin" || modelData?.model_info?.created_by === userID) && modelData?.model_info?.db_model;
const isAdmin = userRole === "Admin";
@ -84,6 +87,8 @@ export default function ModelInfoView({
console.log("usingExistingCredential, ", usingExistingCredential);
console.log("modelData.litellm_params.litellm_credential_name, ", modelData?.litellm_params?.litellm_credential_name);
console.log("tagsList, ", modelData.litellm_params?.tags);
useEffect(() => {
const getExistingCredential = async () => {
console.log("accessToken, ", accessToken);
@ -132,9 +137,20 @@ export default function ModelInfoView({
}
};
const fetchTags = async () => {
if (!accessToken) return;
try {
const response = await tagListCall(accessToken);
setTagsList(response);
} catch (error) {
console.error("Failed to fetch tags:", error);
}
};
getExistingCredential();
getModelInfo();
fetchGuardrails();
fetchTags();
}, [accessToken, modelId]);
const handleReuseCredential = async (values: any) => {
@ -173,6 +189,7 @@ export default function ModelInfoView({
stream_timeout: values.stream_timeout,
input_cost_per_token: values.input_cost / 1_000_000,
output_cost_per_token: values.output_cost / 1_000_000,
tags: values.tags,
};
if (values.guardrails) {
updatedLitellmParams.guardrails = values.guardrails;
@ -476,6 +493,7 @@ export default function ModelInfoView({
guardrails: Array.isArray(localModelData.litellm_params?.guardrails)
? localModelData.litellm_params.guardrails
: [],
tags: Array.isArray(localModelData.litellm_params?.tags) ? localModelData.litellm_params.tags : [],
}}
layout="vertical"
onValuesChange={() => setIsDirty(true)}
@ -691,7 +709,7 @@ export default function ModelInfoView({
<div>
<Text className="font-medium">
Guardrails{" "}
Guardrails
<Tooltip title="Apply safety guardrails to this model to filter content or enforce policies">
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/quick_start"
@ -750,6 +768,54 @@ export default function ModelInfoView({
)}
</div>
<div>
<Text className="font-medium">Tags</Text>
{isEditing ? (
<Form.Item name="tags" className="mb-0">
<Select
mode="tags"
showSearch
placeholder="Select existing tags or type to create new ones"
optionFilterProp="children"
tokenSeparators={[","]}
maxTagCount="responsive"
allowClear
style={{ width: "100%" }}
options={Object.values(tagsList).map((tag: Tag) => ({
value: tag.name,
label: tag.name,
title: tag.description || tag.name,
}))}
/>
</Form.Item>
) : (
<div className="mt-1 p-2 bg-gray-50 rounded">
{localModelData.litellm_params?.tags ? (
Array.isArray(localModelData.litellm_params.tags) ? (
localModelData.litellm_params.tags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{localModelData.litellm_params.tags.map((tag: string, index: number) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800"
>
{tag}
</span>
))}
</div>
) : (
"No tags assigned"
)
) : (
localModelData.litellm_params.tags
)
) : (
"Not Set"
)}
</div>
)}
</div>
{/* Cache Control Section */}
{isEditing ? (
<CacheControlSettings

View file

@ -0,0 +1,21 @@
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import TagSelector from "./TagSelector";
describe("TagSelector", () => {
it("should render the tag selector", () => {
render(<TagSelector onChange={() => {}} accessToken="test-token" />);
});
it("should allow creating new tags", () => {
const { container } = render(<TagSelector 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");
}
});
});

View file

@ -23,16 +23,19 @@ const TagSelector: React.FC<TagSelectorProps> = ({ onChange, value, className, a
setTags(Object.values(response));
} catch (error) {
console.error("Error fetching tags:", error);
} finally {
setLoading(false);
}
};
fetchTags();
}, []);
}, [accessToken]);
return (
<Select
mode="multiple"
placeholder="Select tags"
mode="tags"
showSearch
placeholder="Select or create tags"
onChange={onChange}
value={value}
loading={loading}
@ -43,7 +46,9 @@ const TagSelector: React.FC<TagSelectorProps> = ({ onChange, value, className, a
title: tag.description || tag.name,
}))}
optionFilterProp="label"
showSearch
tokenSeparators={[","]}
maxTagCount="responsive"
allowClear
style={{ width: "100%" }}
/>
);