mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
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:
commit
51926c2e79
5 changed files with 164 additions and 8 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue