diff --git a/ui/litellm-dashboard/src/components/key_info_utils.test.tsx b/ui/litellm-dashboard/src/components/key_info_utils.test.tsx new file mode 100644 index 0000000000..490e188e21 --- /dev/null +++ b/ui/litellm-dashboard/src/components/key_info_utils.test.tsx @@ -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); + }); +}); diff --git a/ui/litellm-dashboard/src/components/key_info_utils.tsx b/ui/litellm-dashboard/src/components/key_info_utils.tsx index 4a255a7b1f..e346878c24 100644 --- a/ui/litellm-dashboard/src/components/key_info_utils.tsx +++ b/ui/litellm-dashboard/src/components/key_info_utils.tsx @@ -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; + return rest; +}; diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index 8257cd6288..f58e392a58 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -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: {}, diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index 9033bb373e..b245119cfe 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -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( + {}} + 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( + {}} + onSubmit={async () => {}} + accessToken={""} + userID={""} + userRole={""} + premiumUser={false} + />, + ); + + const metadataTextarea = getByLabelText("Metadata") as HTMLTextAreaElement; + await waitFor(() => { + expect(metadataTextarea).toHaveValue("{}"); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index 3b9cf3c30d..d90b9e32f7 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -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([]); const [promptsList, setPromptsList] = useState([]); + const [tagsList, setTagsList] = useState>({}); const team = teams?.find((team) => team.team_id === keyData.team_id); const [availableModels, setAvailableModels] = useState([]); const [mcpAccessGroups, setMcpAccessGroups] = useState([]); @@ -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({ )} + + { + 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( + {}} + 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( + {}} + 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("{}"); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 9e0c95900f..411d73a90c 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -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({ +
+ Tags +
+ {Array.isArray(currentKeyData.metadata?.tags) && currentKeyData.metadata.tags.length > 0 + ? currentKeyData.metadata.tags.map((tag, index) => ( + + {tag} + + )) + : "No tags specified"} +
+
+
Prompts @@ -692,7 +717,7 @@ export default function KeyInfoView({
Metadata
-                      {formatMetadataForDisplay(currentKeyData.metadata)}
+                      {formatMetadataForDisplay(stripTagsFromMetadata(currentKeyData.metadata))}