mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-06 00:23:19 +00:00
Add Tags To Edit Key Flow (#16500)
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
filterSensitiveMetadata,
|
||||
extractLoggingSettings,
|
||||
formatMetadataForDisplay,
|
||||
stripTagsFromMetadata,
|
||||
} from "./key_info_utils";
|
||||
|
||||
describe("filterSensitiveMetadata", () => {
|
||||
it("removes sensitive top-level fields like 'logging' while preserving others", () => {
|
||||
const input = {
|
||||
a: 1,
|
||||
logging: [{ level: "info" }],
|
||||
nested: { c: 2 },
|
||||
tags: ["x"],
|
||||
};
|
||||
const result = filterSensitiveMetadata(input);
|
||||
expect(result).toEqual({
|
||||
a: 1,
|
||||
nested: { c: 2 },
|
||||
tags: ["x"],
|
||||
});
|
||||
expect((result as any).logging).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractLoggingSettings", () => {
|
||||
it("returns the logging array when present; returns the same reference", () => {
|
||||
const loggingRef = [{ enabled: true, destination: "s3" }];
|
||||
const input = { logging: loggingRef, other: 42 };
|
||||
const extracted = extractLoggingSettings(input);
|
||||
expect(extracted).toBe(loggingRef);
|
||||
expect(extracted).toEqual([{ enabled: true, destination: "s3" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMetadataForDisplay", () => {
|
||||
it("stringifies metadata without sensitive fields like 'logging'", () => {
|
||||
const input = {
|
||||
logging: [{ level: "error" }],
|
||||
visible: "ok",
|
||||
};
|
||||
const output = formatMetadataForDisplay(input); // default indent = 2
|
||||
const expected = JSON.stringify({ visible: "ok" }, null, 2);
|
||||
expect(output).toBe(expected);
|
||||
expect(output).not.toContain("logging");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripTagsFromMetadata", () => {
|
||||
it("removes top-level 'tags' but leaves other properties intact and does not mutate input", () => {
|
||||
const input = { tags: ["a", "b"], keep: { x: 1 } };
|
||||
const originalCopy = JSON.parse(JSON.stringify(input));
|
||||
const result = stripTagsFromMetadata(input);
|
||||
expect(result).toEqual({ keep: { x: 1 } });
|
||||
// Ensure original input is not mutated
|
||||
expect(input).toEqual(originalCopy);
|
||||
});
|
||||
});
|
||||
@@ -46,3 +46,18 @@ export const formatMetadataForDisplay = (
|
||||
const filtered = filterSensitiveMetadata(metadata);
|
||||
return JSON.stringify(filtered, null, indent);
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes the top-level "tags" property from a metadata object.
|
||||
* This prevents duplicated tag information in UIs where tags are managed separately.
|
||||
* @param metadata - The metadata value to process; returned as-is if not an object
|
||||
* @returns A shallow copy of the object without the "tags" key, or the original value for non-objects
|
||||
*/
|
||||
export const stripTagsFromMetadata = (metadata: any) => {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return metadata;
|
||||
}
|
||||
// Remove tags key from metadata shown in textarea to avoid duplication
|
||||
const { tags, ...rest } = metadata as Record<string, any>;
|
||||
return rest;
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ vi.mock("@/utils/dataUtils", () => ({
|
||||
vi.mock("../key_info_utils", () => ({
|
||||
extractLoggingSettings: () => ({}),
|
||||
formatMetadataForDisplay: (m: any) => JSON.stringify(m, null, 2),
|
||||
stripTagsFromMetadata: (m: any) => m,
|
||||
}));
|
||||
vi.mock("../callback_info_helpers", () => ({
|
||||
callback_map: {},
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("KeyEditView", () => {
|
||||
max_parallel_requests: 10,
|
||||
metadata: {
|
||||
logging: [],
|
||||
tags: ["test-tag"],
|
||||
},
|
||||
tpm_limit: 10,
|
||||
rpm_limit: 10,
|
||||
@@ -104,4 +105,41 @@ describe("KeyEditView", () => {
|
||||
expect(getByText("Save Changes")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render tags", async () => {
|
||||
const { getByText } = render(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => {}}
|
||||
onSubmit={async () => {}}
|
||||
accessToken={""}
|
||||
userID={""}
|
||||
userRole={""}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("test-tag")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render tags in metadata textarea", async () => {
|
||||
const { getByLabelText } = render(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => {}}
|
||||
onSubmit={async () => {}}
|
||||
accessToken={""}
|
||||
userID={""}
|
||||
userRole={""}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const metadataTextarea = getByLabelText("Metadata") as HTMLTextAreaElement;
|
||||
await waitFor(() => {
|
||||
expect(metadataTextarea).toHaveValue("{}");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Form, Input, Select, Button as AntdButton, Tooltip } from "antd";
|
||||
import { Button as TremorButton, TextInput } from "@tremor/react";
|
||||
import GuardrailSelector from "@/components/guardrails/GuardrailSelector";
|
||||
import { TextInput, Button as TremorButton } from "@tremor/react";
|
||||
import { Button as AntdButton, Form, Input, Select, Tooltip } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { mapInternalToDisplayNames } from "../callback_info_helpers";
|
||||
import KeyLifecycleSettings from "../common_components/KeyLifecycleSettings";
|
||||
import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector";
|
||||
import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem";
|
||||
import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils";
|
||||
import { KeyResponse } from "../key_team_helpers/key_list";
|
||||
import { fetchTeamModels } from "../organisms/create_key_button";
|
||||
import { modelAvailableCall, getPromptsList } from "../networking";
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
|
||||
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { fetchMCPAccessGroups, getPromptsList, modelAvailableCall, tagListCall } from "../networking";
|
||||
import { fetchTeamModels } from "../organisms/create_key_button";
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
import { Tag } from "../tag_management/types";
|
||||
import EditLoggingSettings from "../team/EditLoggingSettings";
|
||||
import { extractLoggingSettings, formatMetadataForDisplay } from "../key_info_utils";
|
||||
import { fetchMCPAccessGroups } from "../networking";
|
||||
import { mapInternalToDisplayNames } from "../callback_info_helpers";
|
||||
import GuardrailSelector from "@/components/guardrails/GuardrailSelector";
|
||||
import KeyLifecycleSettings from "../common_components/KeyLifecycleSettings";
|
||||
import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem";
|
||||
import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector";
|
||||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
|
||||
interface KeyEditViewProps {
|
||||
keyData: KeyResponse;
|
||||
@@ -81,6 +82,7 @@ export function KeyEditView({
|
||||
const [form] = Form.useForm();
|
||||
const [userModels, setUserModels] = useState<string[]>([]);
|
||||
const [promptsList, setPromptsList] = useState<string[]>([]);
|
||||
const [tagsList, setTagsList] = useState<Record<string, Tag>>({});
|
||||
const team = teams?.find((team) => team.team_id === keyData.team_id);
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const [mcpAccessGroups, setMcpAccessGroups] = useState<string[]>([]);
|
||||
@@ -160,9 +162,10 @@ export function KeyEditView({
|
||||
...keyData,
|
||||
token: keyData.token || keyData.token_id,
|
||||
budget_duration: getBudgetDuration(keyData.budget_duration),
|
||||
metadata: formatMetadataForDisplay(keyData.metadata),
|
||||
metadata: formatMetadataForDisplay(stripTagsFromMetadata(keyData.metadata)),
|
||||
guardrails: keyData.metadata?.guardrails,
|
||||
prompts: keyData.metadata?.prompts,
|
||||
tags: keyData.metadata?.tags,
|
||||
vector_stores: keyData.object_permission?.vector_stores || [],
|
||||
mcp_servers_and_groups: {
|
||||
servers: keyData.object_permission?.mcp_servers || [],
|
||||
@@ -183,9 +186,10 @@ export function KeyEditView({
|
||||
...keyData,
|
||||
token: keyData.token || keyData.token_id,
|
||||
budget_duration: getBudgetDuration(keyData.budget_duration),
|
||||
metadata: formatMetadataForDisplay(keyData.metadata),
|
||||
metadata: formatMetadataForDisplay(stripTagsFromMetadata(keyData.metadata)),
|
||||
guardrails: keyData.metadata?.guardrails,
|
||||
prompts: keyData.metadata?.prompts,
|
||||
tags: keyData.metadata?.tags,
|
||||
vector_stores: keyData.object_permission?.vector_stores || [],
|
||||
mcp_servers_and_groups: {
|
||||
servers: keyData.object_permission?.mcp_servers || [],
|
||||
@@ -213,6 +217,20 @@ export function KeyEditView({
|
||||
}
|
||||
}, [rotationInterval, form]);
|
||||
|
||||
// Fetch tags for selector
|
||||
useEffect(() => {
|
||||
const fetchTags = async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const response = await tagListCall(accessToken);
|
||||
setTagsList(response);
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend("Error fetching tags: " + error);
|
||||
}
|
||||
};
|
||||
fetchTags();
|
||||
}, [accessToken]);
|
||||
|
||||
console.log("premiumUser:", premiumUser);
|
||||
|
||||
return (
|
||||
@@ -370,6 +388,19 @@ export function KeyEditView({
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Tags" name="tags">
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Select or enter tags"
|
||||
options={Object.values(tagsList).map((tag) => ({
|
||||
value: tag.name,
|
||||
label: tag.name,
|
||||
title: tag.description || tag.name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Prompts" name="prompts">
|
||||
<Tooltip title={!premiumUser ? "Setting prompts by key is a premium feature" : ""} placement="top">
|
||||
<Select
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { KeyResponse } from "../key_team_helpers/key_list";
|
||||
import KeyInfoView from "./key_info_view";
|
||||
|
||||
describe("KeyInfoView", () => {
|
||||
const MOCK_KEY_DATA: KeyResponse = {
|
||||
token: "40b7608ea43423400d5b82bb5ee11042bfb2ed4655f05b5992b5abbc2f294931",
|
||||
token_id: "40b7608ea43423400d5b82bb5ee11042bfb2ed4655f05b5992b5abbc2f294931",
|
||||
key_name: "sk-...TUuw",
|
||||
key_alias: "asdasdas",
|
||||
spend: 0,
|
||||
max_budget: 0,
|
||||
expires: "null",
|
||||
models: [],
|
||||
aliases: {},
|
||||
config: {},
|
||||
user_id: "default_user_id",
|
||||
team_id: null,
|
||||
max_parallel_requests: 10,
|
||||
metadata: {
|
||||
logging: [],
|
||||
tags: ["test-tag"],
|
||||
},
|
||||
tpm_limit: 10,
|
||||
rpm_limit: 10,
|
||||
duration: "30d",
|
||||
budget_duration: "30d",
|
||||
budget_reset_at: "never",
|
||||
allowed_cache_controls: [],
|
||||
allowed_routes: [],
|
||||
permissions: {},
|
||||
model_spend: {},
|
||||
model_max_budget: {},
|
||||
soft_budget_cooldown: false,
|
||||
blocked: false,
|
||||
litellm_budget_table: {},
|
||||
organization_id: null,
|
||||
created_at: "2025-10-29T01:26:41.613000Z",
|
||||
updated_at: "2025-10-29T01:47:33.980000Z",
|
||||
team_spend: 100,
|
||||
team_alias: "",
|
||||
team_tpm_limit: 100,
|
||||
team_rpm_limit: 100,
|
||||
team_max_budget: 100,
|
||||
team_models: [],
|
||||
team_blocked: false,
|
||||
soft_budget: 200,
|
||||
team_model_aliases: {},
|
||||
team_member_spend: 0,
|
||||
team_metadata: {},
|
||||
end_user_id: "default_user_id",
|
||||
end_user_tpm_limit: 10,
|
||||
end_user_rpm_limit: 10,
|
||||
end_user_max_budget: 0,
|
||||
last_refreshed_at: Date.now(),
|
||||
api_key: "sk-...TUuw",
|
||||
user_role: "user",
|
||||
rpm_limit_per_model: {},
|
||||
tpm_limit_per_model: {},
|
||||
user_tpm_limit: 10,
|
||||
user_rpm_limit: 10,
|
||||
user_email: "test@example.com",
|
||||
object_permission: {
|
||||
object_permission_id: "067002ed-3b01-4bb3-b942-cefa400f0049",
|
||||
mcp_servers: [],
|
||||
mcp_access_groups: [],
|
||||
mcp_tool_permissions: {},
|
||||
vector_stores: [],
|
||||
},
|
||||
auto_rotate: false,
|
||||
rotation_interval: undefined,
|
||||
last_rotation_at: undefined,
|
||||
key_rotation_at: undefined,
|
||||
};
|
||||
|
||||
it("should render tags", async () => {
|
||||
const { getByText } = render(
|
||||
<KeyInfoView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onClose={() => {}}
|
||||
keyId={"test-key-id"}
|
||||
onKeyDataUpdate={() => {}}
|
||||
accessToken={"test-token"}
|
||||
userID={"test-user"}
|
||||
userRole={"admin"}
|
||||
premiumUser={true}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByText("test-tag")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render tags in metadata textarea", async () => {
|
||||
const { container, getByText } = render(
|
||||
<KeyInfoView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onClose={() => {}}
|
||||
keyId={"test-key-id"}
|
||||
onKeyDataUpdate={() => {}}
|
||||
accessToken={"test-token"}
|
||||
userID={"test-user"}
|
||||
userRole={"admin"}
|
||||
premiumUser={true}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByText("Metadata")).toBeInTheDocument();
|
||||
const metadataBlock = container.querySelector("pre");
|
||||
expect(metadataBlock).toBeInTheDocument();
|
||||
expect(metadataBlock?.textContent?.trim()).toBe("{}");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,22 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Card, Text, Button, Grid, Tab, TabList, TabGroup, TabPanel, TabPanels, Title, Badge } from "@tremor/react";
|
||||
import { ArrowLeftIcon, TrashIcon, RefreshIcon } from "@heroicons/react/outline";
|
||||
import { keyDeleteCall, keyUpdateCall } from "../networking";
|
||||
import { KeyResponse } from "../key_team_helpers/key_list";
|
||||
import { Form, Tooltip, Button as AntdButton } from "antd";
|
||||
import NotificationManager from "../molecules/notifications_manager";
|
||||
import { KeyEditView } from "./key_edit_view";
|
||||
import { RegenerateKeyModal } from "../organisms/regenerate_key_modal";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
import ObjectPermissionsView from "../object_permissions_view";
|
||||
import LoggingSettingsView from "../logging_settings_view";
|
||||
import { copyToClipboard as utilCopyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { extractLoggingSettings, formatMetadataForDisplay } from "../key_info_utils";
|
||||
import { CopyIcon, CheckIcon } from "lucide-react";
|
||||
import { mapInternalToDisplayNames, mapDisplayToInternalNames } from "../callback_info_helpers";
|
||||
import { parseErrorMessage } from "../shared/errorUtils";
|
||||
import AutoRotationView from "../common_components/AutoRotationView";
|
||||
import { formatNumberWithCommas, copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
|
||||
import { ArrowLeftIcon, RefreshIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { Badge, Button, Card, Grid, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react";
|
||||
import { Button as AntdButton, Form, Tooltip } from "antd";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
import { mapDisplayToInternalNames, mapInternalToDisplayNames } from "../callback_info_helpers";
|
||||
import AutoRotationView from "../common_components/AutoRotationView";
|
||||
import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils";
|
||||
import { KeyResponse } from "../key_team_helpers/key_list";
|
||||
import LoggingSettingsView from "../logging_settings_view";
|
||||
import NotificationManager from "../molecules/notifications_manager";
|
||||
import { keyDeleteCall, keyUpdateCall } from "../networking";
|
||||
import ObjectPermissionsView from "../object_permissions_view";
|
||||
import { RegenerateKeyModal } from "../organisms/regenerate_key_modal";
|
||||
import { parseErrorMessage } from "../shared/errorUtils";
|
||||
import { KeyEditView } from "./key_edit_view";
|
||||
|
||||
interface KeyInfoViewProps {
|
||||
keyId: string;
|
||||
@@ -153,8 +153,13 @@ export default function KeyInfoView({
|
||||
if (formValues.metadata && typeof formValues.metadata === "string") {
|
||||
try {
|
||||
const parsedMetadata = JSON.parse(formValues.metadata);
|
||||
// Ensure tags are controlled via dedicated field, not in metadata textarea
|
||||
if ("tags" in parsedMetadata) {
|
||||
delete parsedMetadata["tags"];
|
||||
}
|
||||
formValues.metadata = {
|
||||
...parsedMetadata,
|
||||
...(Array.isArray(formValues.tags) && formValues.tags.length > 0 ? { tags: formValues.tags } : {}),
|
||||
...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}),
|
||||
...(formValues.logging_settings ? { logging: formValues.logging_settings } : {}),
|
||||
...(formValues.disabled_callbacks?.length > 0
|
||||
@@ -169,8 +174,11 @@ export default function KeyInfoView({
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const baseMetadata = formValues.metadata || {};
|
||||
const { tags: _omitTags, ...rest } = baseMetadata;
|
||||
formValues.metadata = {
|
||||
...(formValues.metadata || {}),
|
||||
...rest,
|
||||
...(Array.isArray(formValues.tags) && formValues.tags.length > 0 ? { tags: formValues.tags } : {}),
|
||||
...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}),
|
||||
...(formValues.logging_settings ? { logging: formValues.logging_settings } : {}),
|
||||
...(formValues.disabled_callbacks?.length > 0
|
||||
@@ -181,6 +189,10 @@ export default function KeyInfoView({
|
||||
};
|
||||
}
|
||||
|
||||
// tags are merged into metadata; do not send as top-level field
|
||||
if ("tags" in formValues) {
|
||||
delete formValues.tags;
|
||||
}
|
||||
delete formValues.logging_settings;
|
||||
|
||||
// Convert budget_duration to API format
|
||||
@@ -623,6 +635,19 @@ export default function KeyInfoView({
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Tags</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{Array.isArray(currentKeyData.metadata?.tags) && currentKeyData.metadata.tags.length > 0
|
||||
? currentKeyData.metadata.tags.map((tag, index) => (
|
||||
<span key={index} className="px-2 mr-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
: "No tags specified"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Prompts</Text>
|
||||
<Text>
|
||||
@@ -692,7 +717,7 @@ export default function KeyInfoView({
|
||||
<div>
|
||||
<Text className="font-medium">Metadata</Text>
|
||||
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto mt-1">
|
||||
{formatMetadataForDisplay(currentKeyData.metadata)}
|
||||
{formatMetadataForDisplay(stripTagsFromMetadata(currentKeyData.metadata))}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user