Merge pull request #41023 from BerriAI/litellm_ui_move_metadata_tags_to_tags_field

fix(ui): move tags typed into key metadata JSON into the Tags field
This commit is contained in:
ryan-crabbe-berri 2026-09-14 15:03:54 -07:00 committed by GitHub
commit 51926c2e79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 164 additions and 8 deletions

View file

@ -1,12 +1,15 @@
import React from "react";
import { Control } from "react-hook-form";
import { Control, UseFormReturn } from "react-hook-form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { CircleHelp } from "lucide-react";
import { FormField } from "@/components/shared/form/FormField";
import { toast } from "@/lib/toast";
import AgentSelector from "../agent_management/AgentSelector";
import NumericalInput from "../shared/numerical_input";
import SkillSelector from "../skills/SkillSelector";
import { moveTagsOutOfMetadataJson } from "./keyEditFieldNormalizers";
import { AgentsAndGroups, KeyEditFormValues } from "./keyEditFormValues";
export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => (
@ -85,6 +88,42 @@ export const KeyAgentAndSkillFields = ({
</>
);
type KeyEditForm = Pick<
UseFormReturn<KeyEditFormValues, unknown, KeyEditFormValues>,
"control" | "getValues" | "setValue"
>;
export const moveMetadataTagsToTagsField = (form: KeyEditForm): void => {
const moved = moveTagsOutOfMetadataJson(form.getValues("metadata"), form.getValues("tags"));
if (moved === null) return;
form.setValue("metadata", moved.metadata, { shouldDirty: true });
form.setValue("tags", moved.tags, { shouldDirty: true });
if (moved.movedTags.length > 0) {
toast.info(`Moved ${moved.movedTags.join(", ")} from metadata to the Tags field`);
}
};
export const KeyMetadataField = ({ form }: { form: KeyEditForm }) => (
<FormField
control={form.control}
name="metadata"
label="Metadata"
description="Tags are managed by the Tags field above. A tags array typed here is moved to that field."
>
{(field) => (
<Textarea
{...field}
value={(field.value as string | undefined) ?? ""}
rows={10}
onBlur={() => {
field.onBlur();
moveMetadataTagsToTagsField(form);
}}
/>
)}
</FormField>
);
export const KeyBudgetNumberField = ({
control,
name,

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { moveTagsOutOfMetadataJson } from "./keyEditFieldNormalizers";
describe("moveTagsOutOfMetadataJson", () => {
it("moves a tags array out of the JSON and appends it to the current tags", () => {
expect(moveTagsOutOfMetadataJson('{"tags": ["pilot-tag"], "env": "non-prod"}', ["ui-tag"])).toEqual({
metadata: '{\n "env": "non-prod"\n}',
tags: ["ui-tag", "pilot-tag"],
movedTags: ["pilot-tag"],
});
});
it("drops duplicates and non-string entries without reporting them as moved", () => {
expect(moveTagsOutOfMetadataJson('{"tags": ["a", "a", "ui-tag", 7, null]}', ["ui-tag"])).toEqual({
metadata: "{}",
tags: ["ui-tag", "a"],
movedTags: ["a"],
});
});
it("trims whitespace and drops blank entries the same way the Tags control does", () => {
expect(moveTagsOutOfMetadataJson('{"tags": [" a ", "a", " ", "", " ui-tag"]}', ["ui-tag"])).toEqual({
metadata: "{}",
tags: ["ui-tag", "a"],
movedTags: ["a"],
});
});
it("strips an empty tags array while moving nothing", () => {
expect(moveTagsOutOfMetadataJson('{"tags": []}', undefined)).toEqual({
metadata: "{}",
tags: [],
movedTags: [],
});
});
it("is a no-op when there is no tags array to move", () => {
expect(moveTagsOutOfMetadataJson('{"env": "prod"}', ["a"])).toBeNull();
expect(moveTagsOutOfMetadataJson('{"tags": "not-an-array"}', ["a"])).toBeNull();
expect(moveTagsOutOfMetadataJson("", ["a"])).toBeNull();
expect(moveTagsOutOfMetadataJson(undefined, ["a"])).toBeNull();
});
it("leaves invalid or non-object JSON alone so the save path can report it", () => {
expect(moveTagsOutOfMetadataJson('{"tags": [', ["a"])).toBeNull();
expect(moveTagsOutOfMetadataJson('["tags"]', ["a"])).toBeNull();
expect(moveTagsOutOfMetadataJson("null", ["a"])).toBeNull();
});
});

View file

@ -34,6 +34,39 @@ export const modelSentinelOptions = (
return teamLoaded ? [{ value: "all-team-models", label: "All Team Models" }] : [];
};
export type MovedMetadataTags = {
metadata: string;
tags: string[];
movedTags: string[];
};
const parseJsonObject = (text: string): Record<string, unknown> | null => {
try {
const parsed: unknown = JSON.parse(text);
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
};
export const moveTagsOutOfMetadataJson = (
metadataJson: string | undefined,
currentTags: readonly string[] | undefined,
): MovedMetadataTags | null => {
const parsed = metadataJson === undefined ? null : parseJsonObject(metadataJson);
if (parsed === null) return null;
const { tags: metadataTags, ...rest } = parsed;
if (!Array.isArray(metadataTags)) return null;
const existing = currentTags ?? [];
const movedTags = metadataTags
.filter((tag: unknown): tag is string => typeof tag === "string")
.map((tag) => tag.trim())
.filter((tag, index, all) => tag.length > 0 && !existing.includes(tag) && all.indexOf(tag) === index);
return { metadata: JSON.stringify(rest, null, 2), tags: [...existing, ...movedTags], movedTags };
};
export const currentValuePlaceholder = (
premiumUser: boolean,
current: unknown,

View file

@ -2180,6 +2180,32 @@ describe("KeyEditView", () => {
expect(onSubmitMock.mock.calls[0][0].tags).toEqual(["test-tag", "typed-tag"]);
});
it("moves a tags array typed into the metadata JSON into the Tags control on blur", async () => {
renderForPayload(vi.fn().mockResolvedValue(undefined));
await screen.findByRole("button", { name: /save changes/i });
const metadata = screen.getByLabelText("Metadata");
fireEvent.change(metadata, { target: { value: '{"tags": ["pilot-tag"], "env": "non-prod"}' } });
fireEvent.blur(metadata);
expect(await screen.findByText("pilot-tag")).toBeInTheDocument();
expect(metadata).toHaveValue('{\n "env": "non-prod"\n}');
});
it("carries a tags array typed into the metadata JSON into the payload even without a blur", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderForPayload(onSubmitMock);
await screen.findByRole("button", { name: /save changes/i });
fireEvent.change(screen.getByLabelText("Metadata"), { target: { value: '{"tags": ["pilot-tag"]}' } });
fireEvent.submit(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0]).toMatchObject({ tags: ["test-tag", "pilot-tag"], metadata: "{}" });
});
const pickFromCombobox = async (inputLabel: RegExp | string, optionName: RegExp | string) => {
await userEvent.click(screen.getByLabelText(inputLabel));
await userEvent.click(await screen.findByRole("option", { name: optionName }));

View file

@ -31,7 +31,14 @@ import {
modelSentinelOptions,
parseAllowedRoutes,
} from "./keyEditFieldNormalizers";
import { KeyAgentAndSkillFields, KeyBudgetNumberField, KeyTypeSelect, labelWithHint } from "./KeyEditViewControls";
import {
KeyAgentAndSkillFields,
KeyBudgetNumberField,
KeyMetadataField,
KeyTypeSelect,
labelWithHint,
moveMetadataTagsToTagsField,
} from "./KeyEditViewControls";
import {
KeyEditFormValues,
keyEditFormSchema,
@ -341,9 +348,12 @@ export function KeyEditView({
return (
<TooltipProvider>
<form
onSubmit={form.handleSubmit((values) =>
handleSubmit(toSubmittedValues(values, { canViewPolicies, canViewPrompts })),
)}
onSubmit={(event) => {
moveMetadataTagsToTagsField(form);
return form.handleSubmit((values) =>
handleSubmit(toSubmittedValues(values, { canViewPolicies, canViewPrompts })),
)(event);
}}
>
<FieldGroup>
<FormField control={form.control} name="key_alias" label="Key Alias">
@ -843,9 +853,7 @@ export function KeyEditView({
)}
</FormField>
<FormField control={form.control} name="metadata" label="Metadata">
{(field) => <Textarea {...field} value={(field.value as string | undefined) ?? ""} rows={10} />}
</FormField>
<KeyMetadataField form={form} />
<div className="mb-4">
<FormField control={form.control} name="duration">