fix(ui): lock mode chips for tag-based guardrails in CustomCodeModal

- chips still display the flattened modes but disable editing when the stored mode is tag-based, with a hint, so a chip edit can't silently discard tag routing
- drop two redundant helper comments in guardrail_info_helpers
This commit is contained in:
ryan-crabbe-berri 2026-08-26 13:09:35 -07:00
parent 5d4f14651f
commit b2d831745d
3 changed files with 43 additions and 3 deletions

View file

@ -14,6 +14,8 @@ const mockCreate = vi.mocked(createGuardrailCall);
const mockUpdate = vi.mocked(updateGuardrailCall);
const mockTest = vi.mocked(testCustomCodeGuardrail);
const TAG_BASED_MODE_HINT = "Tag-based mode is managed in config and can't be edited here";
describe("CustomCodeModal", () => {
const onClose = vi.fn();
const onSuccess = vi.fn();
@ -94,6 +96,39 @@ describe("CustomCodeModal", () => {
expect(payload.litellm_params).not.toHaveProperty("mode");
});
it("should lock the mode chips when editing a tag-based mode", async () => {
renderModal({
editData: {
guardrail_id: "g-1",
guardrail_name: "tagged-guardrail",
litellm_params: {
mode: { tags: { "team:internal": "post_call" }, default: ["pre_call"] },
default_on: false,
custom_code: "def apply_guardrail(): pass",
},
},
});
expect(await screen.findByLabelText("pre_call (Request)")).toBeInTheDocument();
expect(screen.getByLabelText("post_call (Response)")).toBeInTheDocument();
expect(screen.getAllByRole("combobox")[0]).toBeDisabled();
expect(screen.getByText(TAG_BASED_MODE_HINT)).toBeInTheDocument();
});
it("should leave the mode chips editable when editing a plain mode", async () => {
renderModal({
editData: {
guardrail_id: "g-1",
guardrail_name: "plain-guardrail",
litellm_params: { mode: ["pre_call"], default_on: false, custom_code: "def apply_guardrail(): pass" },
},
});
expect(await screen.findByLabelText("pre_call (Request)")).toBeInTheDocument();
expect(screen.getAllByRole("combobox")[0]).toBeEnabled();
expect(screen.queryByText(TAG_BASED_MODE_HINT)).not.toBeInTheDocument();
});
it("should keep save disabled until a guardrail name is entered", async () => {
const user = userEvent.setup();
renderModal();

View file

@ -31,7 +31,7 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import type { GuardrailLitellmParams, GuardrailMode } from "@/components/guardrails/types";
import { guardrailModeList } from "../guardrail_info_helpers";
import { guardrailModeList, isTagBasedMode } from "../guardrail_info_helpers";
// Code templates
const CODE_TEMPLATES = {
@ -190,6 +190,7 @@ interface CustomCodeModalProps {
const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onSuccess, accessToken, editData }) => {
const anchor = useComboboxAnchor();
const isEditMode = !!editData;
const tagBasedMode = isEditMode && isTagBasedMode(editData?.litellm_params?.mode);
const [guardrailName, setGuardrailName] = useState("");
const [mode, setMode] = useState<string[]>(["pre_call"]);
const [defaultOn, setDefaultOn] = useState(false);
@ -519,6 +520,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
items={MODE_OPTIONS}
value={selectedModeOptions}
onValueChange={(options: ModeOption[]) => setMode(options.map((option) => option.value))}
disabled={tagBasedMode}
multiple
>
<ComboboxChips render={<div ref={anchor} />} className="w-full">
@ -540,6 +542,11 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
</ComboboxList>
</ComboboxContent>
</Combobox>
{tagBasedMode && (
<p className="mt-1 text-xs text-muted-foreground">
Tag-based mode is managed in config and can&apos;t be edited here
</p>
)}
</div>
<div className="w-[180px]">
<label className="mb-1 block text-xs font-medium text-muted-foreground">Template</label>

View file

@ -103,7 +103,6 @@ export const populateGuardrailProviderMap = (providerParamsResponse: Record<stri
});
};
// Normalizes a form "mode" value (string, string[], or empty) into a string array
export const toModeArray = (raw: unknown): string[] => {
if (Array.isArray(raw)) return raw.filter((m): m is string => typeof m === "string");
if (typeof raw === "string") return [raw];
@ -113,7 +112,6 @@ export const toModeArray = (raw: unknown): string[] => {
export const isTagBasedMode = (raw: unknown): raw is { tags?: Record<string, unknown>; default?: unknown } =>
raw !== null && typeof raw === "object" && !Array.isArray(raw);
// Every mode a guardrail can run in, with a tag-based mode's per-tag and default modes flattened and deduped
export const guardrailModeList = (raw: unknown): string[] => {
if (!isTagBasedMode(raw)) return toModeArray(raw);
const tagged: string[] = raw.tags && typeof raw.tags === "object" ? Object.values(raw.tags).flatMap(toModeArray) : [];