diff --git a/ui/litellm-dashboard/src/components/key_info_utils.test.tsx b/ui/litellm-dashboard/src/components/key_info_utils.test.tsx index 490e188e21a..8231ab5b915 100644 --- a/ui/litellm-dashboard/src/components/key_info_utils.test.tsx +++ b/ui/litellm-dashboard/src/components/key_info_utils.test.tsx @@ -3,6 +3,7 @@ import { filterSensitiveMetadata, extractLoggingSettings, formatMetadataForDisplay, + mergeKeyTags, stripTagsFromMetadata, } from "./key_info_utils"; @@ -57,3 +58,30 @@ describe("stripTagsFromMetadata", () => { expect(input).toEqual(originalCopy); }); }); + +describe("mergeKeyTags", () => { + it("returns tags from the dedicated field", () => { + expect(mergeKeyTags(["a", "b"], {})).toEqual(["a", "b"]); + }); + + it("returns tags from metadata when the dedicated field is undefined", () => { + expect(mergeKeyTags(undefined, { tags: ["a", "b"] })).toEqual(["a", "b"]); + }); + + it("deduplicates overlapping tags with dedicated field order first", () => { + expect(mergeKeyTags(["a", "b"], { tags: ["b", "c"] })).toEqual(["a", "b", "c"]); + }); + + it("drops non-string metadata tags", () => { + expect(mergeKeyTags(undefined, { tags: ["a", 1, null, false, "b"] })).toEqual(["a", "b"]); + }); + + it("returns field tags when metadata is null or a string", () => { + expect(mergeKeyTags(["a"], null)).toEqual(["a"]); + expect(mergeKeyTags(["b"], "metadata")).toEqual(["b"]); + }); + + it("returns field tags when metadata has no tags key", () => { + expect(mergeKeyTags(["a"], { keep: "x" })).toEqual(["a"]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/key_info_utils.tsx b/ui/litellm-dashboard/src/components/key_info_utils.tsx index e346878c24e..5a1c7f0cf67 100644 --- a/ui/litellm-dashboard/src/components/key_info_utils.tsx +++ b/ui/litellm-dashboard/src/components/key_info_utils.tsx @@ -61,3 +61,12 @@ export const stripTagsFromMetadata = (metadata: any) => { const { tags, ...rest } = metadata as Record; return rest; }; + +export const mergeKeyTags = (fieldTags: unknown, metadata: unknown): string[] => { + const isString = (value: unknown): value is string => typeof value === "string"; + const fromField = Array.isArray(fieldTags) ? fieldTags.filter(isString) : []; + const metadataTags = + metadata && typeof metadata === "object" ? (metadata as Record).tags : undefined; + const fromMetadata = Array.isArray(metadataTags) ? metadataTags.filter(isString) : []; + return Array.from(new Set([...fromField, ...fromMetadata])); +}; 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 522af5a85ad..b3fb509cf97 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -49,11 +49,14 @@ vi.mock("@/utils/dataUtils", () => ({ copyToClipboard: async () => true, formatNumberWithCommas: (n: any) => String(n), })); -vi.mock("../key_info_utils", () => ({ - extractLoggingSettings: () => ({}), - formatMetadataForDisplay: (m: any) => JSON.stringify(m, null, 2), - stripTagsFromMetadata: (m: any) => m, -})); +vi.mock("../key_info_utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + extractLoggingSettings: () => ({}), + formatMetadataForDisplay: (m: any) => JSON.stringify(m, null, 2), + }; +}); vi.mock("../callback_info_helpers", () => ({ callback_map: {}, mapInternalToDisplayNames: (x: any) => x, @@ -344,6 +347,66 @@ describe("KeyInfoView handleKeyUpdate guardrails guard", () => { }); }); +describe("KeyInfoView handleKeyUpdate metadata tags", () => { + it("merges tags from the field and metadata JSON while preserving metadata", async () => { + renderView(true); + + fireEvent.click(screen.getByText("Settings")); + fireEvent.click(screen.getByText("Edit Settings")); + (globalThis as any).__TEST_FORM_VALUES = { + token: "tok_123", + tags: ["ui-tag"], + metadata: JSON.stringify({ tags: ["headroom-pilot"], keep: "x" }), + }; + + fireEvent.click(screen.getByText("Mock Submit")); + + await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled()); + + const [, sentPayload] = keyUpdateCallMock.mock.calls[0]; + expect(sentPayload.metadata.tags).toEqual(["ui-tag", "headroom-pilot"]); + expect(sentPayload.metadata.keep).toBe("x"); + expect("tags" in sentPayload).toBe(false); + }); + + it("preserves metadata tags when the dedicated field is absent", async () => { + renderView(true); + + fireEvent.click(screen.getByText("Settings")); + fireEvent.click(screen.getByText("Edit Settings")); + (globalThis as any).__TEST_FORM_VALUES = { + token: "tok_123", + metadata: JSON.stringify({ tags: ["headroom-pilot"] }), + }; + + fireEvent.click(screen.getByText("Mock Submit")); + + await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled()); + + const [, sentPayload] = keyUpdateCallMock.mock.calls[0]; + expect(sentPayload.metadata.tags).toEqual(["headroom-pilot"]); + }); + + it("merges and deduplicates tags from object metadata", async () => { + renderView(true); + + fireEvent.click(screen.getByText("Settings")); + fireEvent.click(screen.getByText("Edit Settings")); + (globalThis as any).__TEST_FORM_VALUES = { + token: "tok_123", + tags: ["ui-tag", "headroom-pilot"], + metadata: { tags: ["headroom-pilot"] }, + }; + + fireEvent.click(screen.getByText("Mock Submit")); + + await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled()); + + const [, sentPayload] = keyUpdateCallMock.mock.calls[0]; + expect(sentPayload.metadata.tags).toEqual(["ui-tag", "headroom-pilot"]); + }); +}); + describe("KeyInfoView handleKeyUpdate mcp_toolsets", () => { it("should forward the toolsets the edit form supplies into object_permission", async () => { renderView(true); 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 3c2c9bb352d..d2f087cb919 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -31,7 +31,12 @@ import AutoRotationView from "../common_components/AutoRotationView"; import DeleteResourceModal from "../common_components/DeleteResourceModal"; import RouterSettingsSummary from "../common_components/RouterSettingsSummary"; import { hasRouterSettings } from "../common_components/routerSettingsPayload"; -import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils"; +import { + extractLoggingSettings, + formatMetadataForDisplay, + mergeKeyTags, + stripTagsFromMetadata, +} from "../key_info_utils"; import { KeyResponse } from "../key_team_helpers/key_list"; import LoggingSettingsView from "../logging_settings_view"; import { toast } from "@/lib/toast"; @@ -297,13 +302,10 @@ 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"]; - } + const tags = mergeKeyTags(formValues.tags, parsedMetadata); formValues.metadata = { - ...parsedMetadata, - ...(Array.isArray(formValues.tags) && formValues.tags.length > 0 ? { tags: formValues.tags } : {}), + ...stripTagsFromMetadata(parsedMetadata), + ...(tags.length > 0 ? { tags } : {}), ...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}), ...(Array.isArray(formValues.logging_settings) && formValues.logging_settings.length > 0 ? { logging: formValues.logging_settings } @@ -321,10 +323,10 @@ export default function KeyInfoView({ } } else { const baseMetadata = formValues.metadata || {}; - const { tags: _omitTags, ...rest } = baseMetadata; + const tags = mergeKeyTags(formValues.tags, baseMetadata); formValues.metadata = { - ...rest, - ...(Array.isArray(formValues.tags) && formValues.tags.length > 0 ? { tags: formValues.tags } : {}), + ...stripTagsFromMetadata(baseMetadata), + ...(tags.length > 0 ? { tags } : {}), ...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}), ...(Array.isArray(formValues.logging_settings) && formValues.logging_settings.length > 0 ? { logging: formValues.logging_settings } @@ -337,7 +339,6 @@ export default function KeyInfoView({ }; } - // tags are merged into metadata; do not send as top-level field if ("tags" in formValues) { delete formValues.tags; }