From 53103a71ba8e708d2cd77a2bc6ceb3a8f3b55e67 Mon Sep 17 00:00:00 2001 From: jesus Date: Wed, 9 Sep 2026 16:12:36 +0000 Subject: [PATCH 1/3] fix(ui): keep tags typed into the key metadata JSON on save Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/components/key_info_utils.test.tsx | 28 +++++++ .../src/components/key_info_utils.tsx | 8 ++ .../KeyInfoView.handleKeyUpdate.test.tsx | 73 +++++++++++++++++-- .../components/templates/key_info_view.tsx | 22 +++--- 4 files changed, 116 insertions(+), 15 deletions(-) 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..92ddeac7cbf 100644 --- a/ui/litellm-dashboard/src/components/key_info_utils.tsx +++ b/ui/litellm-dashboard/src/components/key_info_utils.tsx @@ -61,3 +61,11 @@ 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 8a42ecff50c..d7bf72cf0dd 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 969decb6613..20ca28629bf 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -30,7 +30,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"; @@ -286,13 +291,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 } @@ -310,10 +312,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 } From 15af9658f8bc999e98fd7d3942c923fec951047d Mon Sep 17 00:00:00 2001 From: jesus Date: Wed, 9 Sep 2026 16:18:20 +0000 Subject: [PATCH 2/3] fix(ui): format metadata tag helper Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui/litellm-dashboard/src/components/key_info_utils.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/key_info_utils.tsx b/ui/litellm-dashboard/src/components/key_info_utils.tsx index 92ddeac7cbf..5a1c7f0cf67 100644 --- a/ui/litellm-dashboard/src/components/key_info_utils.tsx +++ b/ui/litellm-dashboard/src/components/key_info_utils.tsx @@ -65,7 +65,8 @@ export const stripTagsFromMetadata = (metadata: any) => { 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 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])); }; From 0fb5aeb5e50ddf0bdc296412e411d64a75389ee4 Mon Sep 17 00:00:00 2001 From: jesus Date: Wed, 9 Sep 2026 17:51:51 +0000 Subject: [PATCH 3/3] refactor(ui): drop redundant tags comment in key edit save Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui/litellm-dashboard/src/components/templates/key_info_view.tsx | 1 - 1 file changed, 1 deletion(-) 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 20ca28629bf..08fdce328b0 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -328,7 +328,6 @@ export default function KeyInfoView({ }; } - // tags are merged into metadata; do not send as top-level field if ("tags" in formValues) { delete formValues.tags; }