From f0a0341e6ff3b7ce58bb49cb5046f59eceff7f03 Mon Sep 17 00:00:00 2001 From: Animesh Kumar Date: Wed, 9 Sep 2026 23:40:28 +0530 Subject: [PATCH] fix(ui): offer No Default Models when picking a virtual key's models The Models dropdown on key create and edit listed the proxy or team sentinel and the concrete models, but not no-default-models. An admin granting access through an access group had to leave Models empty, which serialises to an empty list and grants every proxy model instead. Picking any sentinel now clears the models already selected, so a key saved as no-default-models cannot keep an earlier one and stay authorised for it. Both forms already collapsed the other two sentinels that way, so that logic moves to a helper they share, and each form drops a sentinel the backend also lists as an available model rather than offering it twice. Fixes #40212 --- .../fetch_available_models_team_key.test.tsx | 34 ++++++++++++++- .../fetch_available_models_team_key.tsx | 11 ++++- .../create_key_button.integration.test.tsx | 38 +++++++++++++++++ .../organisms/create_key_button.tsx | 41 ++++++++++--------- .../templates/keyEditFieldNormalizers.test.ts | 23 +++++++++++ .../templates/keyEditFieldNormalizers.ts | 5 ++- .../key_edit_view.integration.test.tsx | 24 +++++++++++ .../components/templates/key_edit_view.tsx | 32 +++++++-------- 8 files changed, 167 insertions(+), 41 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.test.ts diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/fetch_available_models_team_key.test.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/fetch_available_models_team_key.test.tsx index 1541472cc84..8e2a934702f 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/fetch_available_models_team_key.test.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/fetch_available_models_team_key.test.tsx @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { getModelDisplayName } from "./fetch_available_models_team_key"; +import { + collapseModelSentinelSelection, + getModelDisplayName, + hasModelSentinel, +} from "./fetch_available_models_team_key"; describe("getModelDisplayName", () => { it("should return display label for all proxy models", () => { @@ -11,3 +15,31 @@ describe("getModelDisplayName", () => { expect(getModelDisplayName("openai/*")).toBe("All openai models"); }); }); + +describe("hasModelSentinel", () => { + it.each(["all-proxy-models", "all-team-models", "no-default-models"])( + "treats %s as exclusive, so the concrete models alongside it are unpickable", + (sentinel) => { + expect(hasModelSentinel([sentinel])).toBe(true); + }, + ); + + it("leaves a plain selection pickable", () => { + expect(hasModelSentinel(["gpt-4o", "claude-sonnet-4-5"])).toBe(false); + expect(hasModelSentinel([])).toBe(false); + }); +}); + +describe("collapseModelSentinelSelection", () => { + it.each(["all-team-models", "all-proxy-models", "no-default-models"])( + "drops the concrete models already picked when %s is chosen", + (sentinel) => { + expect(collapseModelSentinelSelection(["gpt-4o", sentinel])).toEqual([sentinel]); + }, + ); + + it("leaves a selection of real models alone", () => { + expect(collapseModelSentinelSelection(["gpt-4o", "claude-sonnet-4-5"])).toEqual(["gpt-4o", "claude-sonnet-4-5"]); + expect(collapseModelSentinelSelection([])).toEqual([]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/fetch_available_models_team_key.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/fetch_available_models_team_key.tsx index 3a3226336b2..a4fb0f7ea2c 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/fetch_available_models_team_key.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/fetch_available_models_team_key.tsx @@ -38,8 +38,15 @@ export const fetchAvailableModelsForTeamOrKey = async ( export const excludeProxyWideSentinel = (models: string[]): string[] => models.filter((model) => model !== "all-proxy-models"); -export const hasAllModelsSentinel = (models: string[]): boolean => - models.includes("all-proxy-models") || models.includes("all-team-models"); +const MODEL_SENTINELS = ["all-team-models", "all-proxy-models", "no-default-models"] as const; + +export const hasModelSentinel = (models: string[]): boolean => + MODEL_SENTINELS.some((sentinel) => models.includes(sentinel)); + +export const collapseModelSentinelSelection = (models: string[]): string[] => { + const sentinel = MODEL_SENTINELS.find((candidate) => models.includes(candidate)); + return sentinel === undefined ? models : [sentinel]; +}; export const getModelDisplayName = (model: string) => { if (model === "all-proxy-models") { diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx index 0d5d9f5ec8d..42be9b8e5d9 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx @@ -776,6 +776,44 @@ describe("CreateKey", () => { expect(await screen.findByRole("option", { name: "All Team Models" })).toBeInTheDocument(); expect(screen.queryByRole("option", { name: "All Proxy Models" })).not.toBeInTheDocument(); }); + + it("offers No Default Models whether or not a team is selected", async () => { + await openModal(); + await userEvent.click(await screen.findByLabelText("Models")); + + expect(await screen.findByRole("option", { name: "No Default Models" })).toBeInTheDocument(); + }); + + it("sends the no-default-models sentinel rather than an empty list", async () => { + await openModal(); + await nameTheKey(); + + await userEvent.click(await screen.findByLabelText("Models")); + await userEvent.click(await screen.findByRole("option", { name: "No Default Models" })); + await userEvent.keyboard("{Escape}"); + + await submit(); + + expect((await createdPayload()).models).toStrictEqual(["no-default-models"]); + }); + + it("drops a model already picked when No Default Models is chosen after it", async () => { + state.teams = [{ team_id: "team-1", team_alias: "Team One", models: ["team-model-1"] }]; + await openModal({ teams: state.teams as unknown as Team[] }); + await nameTheKey(); + + await userEvent.click(await screen.findByLabelText("Team")); + await userEvent.click(await screen.findByRole("option", { name: /Team One/ })); + + await userEvent.click(await screen.findByLabelText("Models")); + await userEvent.click(await screen.findByRole("option", { name: "team-model-1" })); + await userEvent.click(await screen.findByRole("option", { name: "No Default Models" })); + await userEvent.keyboard("{Escape}"); + + await submit(); + + expect((await createdPayload()).models).toStrictEqual(["no-default-models"]); + }); }); describe("organization dropdown", () => { diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 82580fd4667..462276bf045 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -58,7 +58,8 @@ import { TagRateLimitEditor, TagRateLimitEntry } from "../key_team_helpers/TagRa import { excludeProxyWideSentinel, getModelDisplayName, - hasAllModelsSentinel, + collapseModelSentinelSelection, + hasModelSentinel, } from "../key_team_helpers/fetch_available_models_team_key"; import { Team } from "../key_team_helpers/key_list"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; @@ -626,18 +627,25 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp setSelectedProjectId(projectId); }; + const sentinelOptions: MultiSelectOption[] = + selectedProjectId === null + ? [ + selectedCreateKeyTeam + ? { value: "all-team-models", label: "All Team Models" } + : { value: "all-proxy-models", label: "All Proxy Models" }, + { value: "no-default-models", label: "No Default Models" }, + ] + : []; + const sentinelValues = new Set(sentinelOptions.map((option) => option.value)); const modelOptions: MultiSelectOption[] = [ - ...(selectedProjectId === null && selectedCreateKeyTeam - ? [{ value: "all-team-models", label: "All Team Models" }] - : []), - ...(selectedProjectId === null && !selectedCreateKeyTeam - ? [{ value: "all-proxy-models", label: "All Proxy Models" }] - : []), - ...modelsToPick.map((model) => ({ - value: model, - label: getModelDisplayName(model), - disabled: hasAllModelsSentinel(selectedModels), - })), + ...sentinelOptions, + ...modelsToPick + .filter((model) => !sentinelValues.has(model)) + .map((model) => ({ + value: model, + label: getModelDisplayName(model), + disabled: hasModelSentinel(selectedModels), + })), ]; const changeKeyType = (write: FieldWrite) => (value: string) => { @@ -908,14 +916,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp value={(control.value as string[] | undefined) ?? []} placeholder="Select models" disabled={keyType === "management" || keyType === "read_only"} - onValueChange={(values) => { - control.onChange(values); - if (values.includes("all-team-models")) { - form.setValue("models", ["all-team-models"]); - } else if (values.includes("all-proxy-models")) { - form.setValue("models", ["all-proxy-models"]); - } - }} + onValueChange={(values) => control.onChange(collapseModelSentinelSelection(values))} /> )} diff --git a/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.test.ts b/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.test.ts new file mode 100644 index 00000000000..c1c55aa0b17 --- /dev/null +++ b/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { modelSentinelOptions } from "./keyEditFieldNormalizers"; + +describe("modelSentinelOptions", () => { + it("offers No Default Models on a key with no team, so an access-group-only key is selectable", () => { + expect(modelSentinelOptions(null, false)).toEqual([ + { value: "all-proxy-models", label: "All Proxy Models" }, + { value: "no-default-models", label: "No Default Models" }, + ]); + }); + + it("offers No Default Models on a team key once the team has loaded", () => { + expect(modelSentinelOptions("team-1", true)).toEqual([ + { value: "all-team-models", label: "All Team Models" }, + { value: "no-default-models", label: "No Default Models" }, + ]); + }); + + it("offers nothing while a team key is still loading its team", () => { + expect(modelSentinelOptions("team-1", false)).toEqual([]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.ts b/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.ts index c8b8a689dae..e1d21236685 100644 --- a/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.ts +++ b/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.ts @@ -30,8 +30,9 @@ export const modelSentinelOptions = ( keyTeamId: string | null | undefined, teamLoaded: boolean, ): { value: string; label: string }[] => { - if (keyTeamId == null) return [{ value: "all-proxy-models", label: "All Proxy Models" }]; - return teamLoaded ? [{ value: "all-team-models", label: "All Team Models" }] : []; + const noDefaultModels = { value: "no-default-models", label: "No Default Models" }; + if (keyTeamId == null) return [{ value: "all-proxy-models", label: "All Proxy Models" }, noDefaultModels]; + return teamLoaded ? [{ value: "all-team-models", label: "All Team Models" }, noDefaultModels] : []; }; export const currentValuePlaceholder = ( diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx index 7c2226f0369..e98bbe0d29b 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx @@ -326,6 +326,30 @@ describe("KeyEditView", () => { }); }); + it("lists No Default Models once when the available models already carry the sentinel", async () => { + vi.mocked(modelAvailableCall).mockResolvedValue({ + data: [{ id: "gpt-4" }, { id: "no-default-models" }], + }); + + renderWithProviders( + {}} + onSubmit={async () => {}} + accessToken={"test-token"} + userID={"user-1"} + userRole={"Admin"} + premiumUser={false} + />, + ); + + await userEvent.click(await screen.findByLabelText("Models")); + + expect(await screen.findByRole("option", { name: "gpt-4" })).toBeInTheDocument(); + expect(screen.getAllByRole("option", { name: "No Default Models" })).toHaveLength(1); + expect(screen.queryByRole("option", { name: "no-default-models" })).not.toBeInTheDocument(); + }); + it("should render tags", async () => { renderWithProviders( option.value)); const modelOptions = [ - ...modelSentinelOptions(keyData.team_id, team != null), - ...availableModels.map((model) => ({ - value: model, - label: model, - disabled: hasAllModelsSentinel(selectedModels), - })), + ...sentinelOptions, + ...availableModels + .filter((model) => !sentinelValues.has(model)) + .map((model) => ({ + value: model, + label: model, + disabled: hasModelSentinel(selectedModels), + })), ]; const visibleTeams = selectedOrganizationId @@ -361,15 +369,7 @@ export function KeyEditView({ id={id} options={modelOptions} value={isModelsDisabled ? [] : (value as string[] | undefined) ?? []} - onValueChange={(next) => { - if (next.includes("all-team-models")) { - onChange(["all-team-models"]); - } else if (next.includes("all-proxy-models")) { - onChange(["all-proxy-models"]); - } else { - onChange(next); - } - }} + onValueChange={(next) => onChange(collapseModelSentinelSelection(next))} disabled={isModelsDisabled} placeholder="Select models" />