{
+ team: ({ allProxyModels, selectedTeam, selectedOrganization, userModels, options }) => {
+ const currentTeamModels = keepOnlyCurrentTeamModels(selectedTeam, options, allProxyModels);
+ if (currentTeamModels) return currentTeamModels;
+
if (selectedOrganization) {
if (
selectedOrganization.models.includes(MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value) ||
@@ -107,7 +133,8 @@ export const ModelSelect = (props: ModelSelectProps) => {
organization?.models.includes(MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value) ||
organization?.models.length === 0;
const shouldShowAllProxyModels =
- showAllProxyModelsOverride || (organizationHasAllProxyModels && includeSpecialOptions) || context === "global";
+ keepOnlyCurrentTeamModels(team, options, []) === null &&
+ (showAllProxyModelsOverride || (organizationHasAllProxyModels && includeSpecialOptions) || context === "global");
if (isLoading) {
return ;
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
index 712cff80649..27a9d8c0a7a 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
@@ -3,7 +3,7 @@ import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
-import TeamInfoView from "./TeamInfo";
+import TeamInfoView, { validateTeamMaxBudget } from "./TeamInfo";
vi.mock("@/components/networking", () => ({
teamInfoCall: vi.fn(),
@@ -43,6 +43,21 @@ vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
useCurrentUser: vi.fn(),
}));
+let mockUserRole = "Admin";
+
+vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
+ default: () => ({
+ token: "123",
+ accessToken: "test-token",
+ userId: "user-1",
+ userEmail: "user@example.com",
+ userRole: mockUserRole,
+ premiumUser: false,
+ disabledPersonalKeyCreation: null,
+ showSSOBanner: false,
+ }),
+}));
+
vi.mock("@/components/team/TeamMemberTab", () => ({
default: vi.fn(({ setIsAddMemberModalVisible }) => (
@@ -1193,4 +1208,119 @@ describe("TeamInfoView", () => {
});
});
});
+ describe("team-admin budget authority", () => {
+ const teamAdminProps = { ...defaultProps, is_proxy_admin: false, is_team_admin: true };
+
+ const openSettingsForm = async (user: ReturnType) => {
+ await waitFor(() => {
+ expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0);
+ });
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
+ await user.click(await screen.findByRole("button", { name: /edit settings/i }));
+ await screen.findByLabelText("Team Name");
+ };
+
+ beforeEach(() => {
+ testQueryClient.clear();
+ mockUserRole = "Internal User";
+ vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(
+ createMockTeamData({ models: ["gpt-4"], max_budget: 30 }),
+ );
+ });
+
+ afterEach(() => {
+ mockUserRole = "Admin";
+ });
+
+ it("blocks a raise before the request leaves the browser", async () => {
+ const user = userEvent.setup({ delay: null });
+ renderWithProviders();
+ await openSettingsForm(user);
+
+ const budgetInput = screen.getByLabelText("Max Budget (USD)");
+ await user.clear(budgetInput);
+ await user.type(budgetInput, "100");
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ expect(await screen.findByText(/Only a proxy admin can raise this team's budget above \$30/)).toBeInTheDocument();
+ expect(networking.teamUpdateCall).not.toHaveBeenCalled();
+ });
+
+ it("blocks clearing the cap", async () => {
+ const user = userEvent.setup({ delay: null });
+ renderWithProviders();
+ await openSettingsForm(user);
+
+ await user.clear(screen.getByLabelText("Max Budget (USD)"));
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ expect(await screen.findByText(/Only a proxy admin can remove this team's budget/)).toBeInTheDocument();
+ expect(networking.teamUpdateCall).not.toHaveBeenCalled();
+ });
+
+ it("lets a team admin lower the cap", async () => {
+ const user = userEvent.setup({ delay: null });
+ renderWithProviders();
+ await openSettingsForm(user);
+
+ const budgetInput = screen.getByLabelText("Max Budget (USD)");
+ await user.clear(budgetInput);
+ await user.type(budgetInput, "10");
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => {
+ expect(networking.teamUpdateCall).toHaveBeenCalled();
+ });
+ const [accessToken, payload] = vi.mocked(networking.teamUpdateCall).mock.calls[0];
+ expect(accessToken).toBe("test-token");
+ expect(payload.team_id).toBe("123");
+ expect(Number(payload.max_budget)).toBe(10);
+ });
+
+ it("leaves a proxy admin free to raise the cap", async () => {
+ const user = userEvent.setup({ delay: null });
+ mockUserRole = "Admin";
+ renderWithProviders();
+ await openSettingsForm(user);
+
+ const budgetInput = screen.getByLabelText("Max Budget (USD)");
+ await user.clear(budgetInput);
+ await user.type(budgetInput, "100");
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => {
+ expect(networking.teamUpdateCall).toHaveBeenCalled();
+ });
+ const [accessToken, payload] = vi.mocked(networking.teamUpdateCall).mock.calls[0];
+ expect(accessToken).toBe("test-token");
+ expect(payload.team_id).toBe("123");
+ expect(Number(payload.max_budget)).toBe(100);
+ });
+ });
+
+ describe("validateTeamMaxBudget", () => {
+ it("is inert for callers who hold the grant, or when the team has no cap", async () => {
+ await expect(validateTeamMaxBudget(true, 30)(null, 1000)).resolves.toBeUndefined();
+ await expect(validateTeamMaxBudget(false, null)(null, 1000)).resolves.toBeUndefined();
+ await expect(validateTeamMaxBudget(false, undefined)(null, "")).resolves.toBeUndefined();
+ });
+
+ it("allows keeping or lowering, rejects raising or clearing", async () => {
+ const validate = validateTeamMaxBudget(false, 30);
+
+ await expect(validate(null, 30)).resolves.toBeUndefined();
+ await expect(validate(null, 29.99)).resolves.toBeUndefined();
+ await expect(validate(null, 30.01)).rejects.toThrow(/raise/);
+ await expect(validate(null, "")).rejects.toThrow(/remove/);
+ await expect(validate(null, null)).rejects.toThrow(/remove/);
+ });
+
+ it("treats a zero cap as a real ceiling", async () => {
+ const validate = validateTeamMaxBudget(false, 0);
+
+ await expect(validate(null, 0)).resolves.toBeUndefined();
+ await expect(validate(null, 5)).rejects.toThrow(/raise/);
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
index 34570043f52..84544f74432 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
@@ -144,6 +144,25 @@ export interface TeamInfoProps {
premiumUser?: boolean;
}
+/**
+ * Mirrors the /team/update budget-authority gate so a team admin sees why the
+ * save is refused before the round-trip: they may keep or lower the team's
+ * ceiling, never raise or remove it.
+ */
+export const validateTeamMaxBudget =
+ (canWidenTeamGrants: boolean, currentMaxBudget: number | null | undefined) =>
+ async (_rule: unknown, value: unknown): Promise => {
+ if (canWidenTeamGrants || currentMaxBudget == null) return;
+ if (value === null || value === undefined || value === "") {
+ throw new Error(`Only a proxy admin can remove this team's budget (currently $${currentMaxBudget})`);
+ }
+ const requested = Number(value);
+ if (Number.isNaN(requested)) return;
+ if (requested > currentMaxBudget) {
+ throw new Error(`Only a proxy admin can raise this team's budget above $${currentMaxBudget}`);
+ }
+ };
+
const getOrganizationModels = (organization: Organization | null, userModels: string[]) => {
let tempModelsToPick = [];
@@ -234,6 +253,10 @@ const TeamInfoView: React.FC = ({
);
const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam || isTeamAdminFromTeamData;
+ const isTeamAdminForThisTeam = is_team_admin || isTeamAdminFromTeamData;
+ const holdsAuthorityOverTeam =
+ isProxyAdminRole(userRole) || is_proxy_admin || is_org_admin || isOrgAdminForTeam;
+ const canWidenTeamGrants = holdsAuthorityOverTeam || !isTeamAdminForThisTeam;
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]);
@@ -1042,6 +1065,7 @@ const TeamInfoView: React.FC = ({
includeUserModels: !teamData?.team_info?.organization_id,
showAllProxyModelsOverride:
isProxyAdminRole(userRole) && !teamData?.team_info?.organization_id,
+ restrictToCurrentTeamModels: !canWidenTeamGrants,
}}
context="team"
dataTestId="models-select"
@@ -1066,7 +1090,16 @@ const TeamInfoView: React.FC = ({
/>
-
+