mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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
This commit is contained in:
parent
9071ca503e
commit
f0a0341e6f
8 changed files with 167 additions and 41 deletions
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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<CreateKeyProps> = ({ 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<CreateKeyProps> = ({ 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))}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => {}}
|
||||
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(
|
||||
<KeyEditView
|
||||
|
|
|
|||
|
|
@ -49,7 +49,11 @@ import {
|
|||
tagLimitsToRows,
|
||||
tagRowsToLimits,
|
||||
} from "../key_team_helpers/TagRateLimitEditor";
|
||||
import { excludeProxyWideSentinel, hasAllModelsSentinel } from "../key_team_helpers/fetch_available_models_team_key";
|
||||
import {
|
||||
collapseModelSentinelSelection,
|
||||
excludeProxyWideSentinel,
|
||||
hasModelSentinel,
|
||||
} from "../key_team_helpers/fetch_available_models_team_key";
|
||||
import { KeyResponse } from "../key_team_helpers/key_list";
|
||||
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
|
||||
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
|
||||
|
|
@ -325,13 +329,17 @@ export function KeyEditView({
|
|||
form.setValue("disabled_callbacks", internalValues);
|
||||
};
|
||||
|
||||
const sentinelOptions = modelSentinelOptions(keyData.team_id, team != null);
|
||||
const sentinelValues = new Set(sentinelOptions.map((option) => 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"
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue