Add Tags To Edit Key Flow (#16500)

This commit is contained in:
yuneng-jiang
2025-11-11 17:44:29 -08:00
committed by GitHub
parent 443bada425
commit c623ff916f
7 changed files with 322 additions and 36 deletions
@@ -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>