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" />