diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 94cfd090dcc..680c63393e8 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -4470,6 +4470,29 @@ class TeamInfoMember(Member):
user_alias: str | None = None
+class TeamEditUnrestricted(BaseModel):
+ kind: Literal["unrestricted"] = "unrestricted"
+
+
+class TeamEditAsTeamAdmin(BaseModel):
+ kind: Literal["team_admin"] = "team_admin"
+ editable_fields: tuple[str, ...]
+
+
+class TeamEditAsTeamAdminDisabled(BaseModel):
+ kind: Literal["team_admin_disabled"] = "team_admin_disabled"
+
+
+class TeamEditNone(BaseModel):
+ kind: Literal["none"] = "none"
+
+
+TeamEditAccess = Annotated[
+ TeamEditUnrestricted | TeamEditAsTeamAdmin | TeamEditAsTeamAdminDisabled | TeamEditNone,
+ Field(discriminator="kind"),
+]
+
+
class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):
members_with_roles: tuple[TeamInfoMember, ...] = ()
team_member_budget_table: LiteLLM_BudgetTableFull | None = None
@@ -4482,6 +4505,7 @@ class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):
# None = no org or not a manager; [] or ["all-proxy-models"] = no ceiling.
organization_models: list[str] | None = None
model_max_budget_usage: Mapping[str, Mapping[str, object]] | None = None
+ caller_edit_access: TeamEditAccess = Field(default_factory=TeamEditNone)
class TeamInfoResponseObject(TypedDict):
diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py
index d88d6d13720..6fccdf02123 100644
--- a/litellm/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_endpoints.py
@@ -33,8 +33,8 @@ from typing import (
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from pydantic import BaseModel, JsonValue, ValidationError
-from typing_extensions import ReadOnly, TypedDict
+from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
+from typing_extensions import ReadOnly, TypedDict, assert_never
import litellm
from litellm._logging import verbose_proxy_logger
@@ -73,6 +73,11 @@ from litellm.proxy._types import (
SpecialProxyStrings,
TeamAccessGroupModelGrant,
TeamAddMemberResponse,
+ TeamEditAccess,
+ TeamEditAsTeamAdmin,
+ TeamEditAsTeamAdminDisabled,
+ TeamEditNone,
+ TeamEditUnrestricted,
TeamInfoMember,
TeamInfoResponseObject,
TeamInfoResponseObjectTeamTable,
@@ -495,6 +500,33 @@ async def _verify_team_access(
_raise_team_access_denied()
+_GENERAL_SETTINGS: Final = TypeAdapter(dict[str, object])
+
+
+def _general_settings() -> Mapping[str, object]:
+ from litellm.proxy import proxy_server
+
+ return _GENERAL_SETTINGS.validate_python(cast(object, proxy_server.general_settings))
+
+
+def _caller_edit_access(role: TeamAccessRole | None, general_settings: Mapping[str, object]) -> TeamEditAccess:
+ """What the caller may change on /team/update, reported on /team/info so the dashboard never re-derives it."""
+ match role:
+ case "proxy_admin" | "org_admin":
+ return TeamEditUnrestricted()
+ case "team_admin":
+ permitted: Final = resolve_team_admin_editable_fields(
+ general_settings, SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
+ )
+ if not permitted:
+ return TeamEditAsTeamAdminDisabled()
+ return TeamEditAsTeamAdmin(editable_fields=tuple(sorted(permitted)))
+ case None:
+ return TeamEditNone()
+ case _:
+ assert_never(role)
+
+
class TeamMemberBudgetHandler:
"""Helper class to handle team member budget, RPM, and TPM limit operations"""
@@ -2124,7 +2156,6 @@ async def update_team(
try:
from litellm.proxy.management_helpers.audit_logs import is_audit_logging_enabled
from litellm.proxy.proxy_server import (
- general_settings,
litellm_proxy_admin_name,
llm_router,
premium_user,
@@ -2192,7 +2223,7 @@ async def update_team(
data=data,
existing=existing_team,
permitted=resolve_team_admin_editable_fields(
- general_settings, SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
+ _general_settings(), SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
),
)
)
@@ -4625,10 +4656,9 @@ async def team_info(
)
team_table: Final = LiteLLM_TeamTable.model_validate(team_info.model_dump())
await validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_table)
+ access_role: Final = await _resolve_team_access(team_obj=team_table, user_api_key_dict=user_api_key_dict)
organization_models: Final[list[str] | None] = (
- _parent_organization_models(team_info)
- if await _resolve_team_access(team_obj=team_table, user_api_key_dict=user_api_key_dict) is not None
- else None
+ _parent_organization_models(team_info) if access_role is not None else None
)
## GET ALL KEYS ##
@@ -4697,6 +4727,7 @@ async def team_info(
model_max_budget=resolved_team_info.model_max_budget,
cache=model_max_budget_limiter.dual_cache,
),
+ "caller_edit_access": _caller_edit_access(access_role, _general_settings()),
}
)
diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
index 733f6ddc36f..db4b4bb7cf3 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
@@ -15122,3 +15122,65 @@ async def test_resolve_team_access_ranks_proxy_admin_then_org_admin_then_team_ad
assert await _resolve_team_access(team_obj=team, user_api_key_dict=outsider) is None
org_lookup.return_value = True
assert await _resolve_team_access(team_obj=team, user_api_key_dict=roster_admin) == "org_admin"
+
+
+_ROSTER_ADMIN_CALLER = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="admin-1")
+_MEMBER_CALLER = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="member-1")
+
+
+@pytest.mark.parametrize(
+ "caller, org_admin, enabled_fields, expected",
+ [
+ pytest.param(_PROXY_ADMIN_CALLER, False, (), {"kind": "unrestricted"}, id="proxy-admin"),
+ pytest.param(
+ UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, user_id="viewer"),
+ False,
+ ("tpm_limit",),
+ {"kind": "none"},
+ id="proxy-admin-viewer",
+ ),
+ pytest.param(_ROSTER_ADMIN_CALLER, True, (), {"kind": "unrestricted"}, id="org-admin-who-is-also-team-admin"),
+ pytest.param(_ROSTER_ADMIN_CALLER, False, (), {"kind": "team_admin_disabled"}, id="team-admin-nothing-enabled"),
+ pytest.param(
+ _ROSTER_ADMIN_CALLER,
+ False,
+ ("tpm_limit",),
+ {"kind": "team_admin", "editable_fields": ["tpm_limit"]},
+ id="team-admin-field-enabled",
+ ),
+ pytest.param(_MEMBER_CALLER, False, ("tpm_limit",), {"kind": "none"}, id="plain-member"),
+ ],
+)
+@pytest.mark.asyncio
+async def test_team_info_reports_what_the_caller_may_edit(caller, org_admin, enabled_fields, expected):
+ """The dashboard gates its edit form on this field instead of guessing the caller's role from the org list,
+ which is premium-gated and can be empty for a dual-role org admin."""
+ from fastapi import Request
+
+ from litellm.proxy.management_endpoints import team_endpoints
+
+ team_row = LiteLLM_TeamTable(
+ team_id="team-1",
+ organization_id="org-1",
+ members_with_roles=[Member(user_id="admin-1", role="admin"), Member(user_id="member-1", role="user")],
+ )
+ mock_prisma = MagicMock()
+ mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
+ mock_prisma.db.litellm_usertable.find_many = AsyncMock(return_value=[])
+ mock_prisma.get_data = AsyncMock(return_value=[])
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: no seam on team_info
+ patch.object(team_endpoints, "get_all_team_memberships", AsyncMock(return_value=[])), # test-quality-ok: no seam on team_info
+ patch.object( # test-quality-ok: the org-admin lookup needs a real prisma client this file's MagicMock cannot provide
+ team_endpoints, "_is_user_org_admin_for_team", AsyncMock(return_value=org_admin)
+ ),
+ _team_admin_may_edit(*enabled_fields),
+ ):
+ response = await team_endpoints.team_info(
+ http_request=MagicMock(spec=Request),
+ team_id="team-1",
+ user_api_key_dict=caller,
+ )
+
+ assert response["team_info"].caller_edit_access.model_dump(mode="json") == expected
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
index 00bf264c636..dea53eb42a2 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
@@ -312,7 +312,7 @@ const seedDefaultMocks = () => {
isError: false,
} as any);
mockUseUISettings.mockReturnValue({
- data: { values: { team_admin_editable_team_fields: [] } },
+ data: { values: {} },
isLoading: false,
} as any);
mockUseKeys.mockReturnValue({
@@ -666,19 +666,9 @@ describe("TeamInfoView", () => {
});
});
- it("shows edit tabs when the fetched team data marks the session user as team admin, even without the is_team_admin prop", async () => {
+ it("shows edit tabs when the proxy reports the session user may edit, even without the is_team_admin prop", async () => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(
- createMockTeamData({
- members_with_roles: [
- {
- user_id: "user-1",
- user_email: "admin@test.com",
- role: "admin",
- spend: 0,
- budget_id: "budget1",
- },
- ],
- }),
+ createMockTeamData({ caller_edit_access: { kind: "team_admin_disabled" } }),
);
renderWithProviders();
@@ -1881,9 +1871,11 @@ describe("TeamInfoView", () => {
authState.userRole = "Internal User";
});
- it("tells a team admin to ask a proxy admin when no team field is enabled for them", async () => {
+ it("tells a team admin to ask a proxy admin when the proxy reports no team field is enabled for them", async () => {
const user = userEvent.setup({ delay: null });
- vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(
+ createMockTeamData({ caller_edit_access: { kind: "team_admin_disabled" } }),
+ );
renderWithProviders();
@@ -1897,13 +1889,26 @@ describe("TeamInfoView", () => {
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
});
- it("opens the form for a team admin once a proxy admin has enabled a field", async () => {
- mockUseUISettings.mockReturnValue({
- data: { values: { team_admin_editable_team_fields: ["tpm_limit"] } },
- isLoading: false,
- } as any);
+ it("opens the form for a team admin once the proxy reports an enabled field", async () => {
const user = userEvent.setup({ delay: null });
- vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(
+ createMockTeamData({ caller_edit_access: { kind: "team_admin", editable_fields: ["tpm_limit"] } }),
+ );
+
+ renderWithProviders();
+
+ await user.click(await screen.findByRole("tab", { name: "Settings" }));
+ await user.click(await screen.findByRole("button", { name: /edit settings/i }));
+
+ expect(await screen.findByLabelText("Team Name")).toBeInTheDocument();
+ expect(toast.error).not.toHaveBeenCalled();
+ });
+
+ it("opens the form when the proxy reports unrestricted access although the props only mark a team admin", async () => {
+ const user = userEvent.setup({ delay: null });
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(
+ createMockTeamData({ caller_edit_access: { kind: "unrestricted" } }),
+ );
renderWithProviders();
@@ -1917,7 +1922,9 @@ describe("TeamInfoView", () => {
it("never gates a proxy admin on the team admin field list", async () => {
authState.userRole = "Admin";
const user = userEvent.setup({ delay: null });
- vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(
+ createMockTeamData({ caller_edit_access: { kind: "unrestricted" } }),
+ );
renderWithProviders();
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
index e84e1db5ca4..48de90f74b5 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
@@ -1,7 +1,6 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
-import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { useQueryClient } from "@tanstack/react-query";
import UserSearchModal from "@/components/common_components/user_search_modal";
import {
@@ -50,7 +49,8 @@ import { useFieldArray } from "react-hook-form";
import { z } from "zod/v4";
import GuardrailsSelect from "./GuardrailsSelect";
import {
- resolveTeamEditAccess,
+ type CallerEditAccess,
+ parseTeamEditAccess,
TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION,
TEAM_ADMIN_EDITING_DISABLED_TITLE,
} from "./teamAdminEditAccess";
@@ -303,6 +303,7 @@ export interface TeamData {
guardrails?: string[];
policies?: string[];
object_permission?: ObjectPermission | null;
+ caller_edit_access?: CallerEditAccess;
team_member_budget_table: {
max_budget: number;
budget_duration: string | null;
@@ -321,7 +322,6 @@ export interface TeamInfoProps {
accessToken: string | null;
is_team_admin: boolean;
is_proxy_admin: boolean;
- is_org_admin?: boolean;
userModels: string[];
editTeam: boolean;
premiumUser?: boolean;
@@ -537,7 +537,6 @@ const TeamInfoView: React.FC = ({
accessToken,
is_team_admin,
is_proxy_admin,
- is_org_admin = false,
userModels,
editTeam,
premiumUser = false,
@@ -581,25 +580,16 @@ const TeamInfoView: React.FC = ({
const [teamModelMaxBudget, setTeamModelMaxBudget] = useState({});
const routerSettingsRef = React.useRef(null);
const [organization, setOrganization] = useState(null);
- const { userRole, userId } = useAuthorized();
+ const { userRole } = useAuthorized();
const { data: allMcpServers = [], isError: mcpServersFailed, isLoading: mcpServersLoading } = useMCPServers();
const { data: allMcpToolsets = [], isError: mcpToolsetsFailed, isLoading: mcpToolsetsLoading } = useMCPToolsets();
const { data: allAccessGroups = [], isError: accessGroupsFailed, isLoading: accessGroupsLoading } = useAccessGroups();
const canEditTeamEstimates = isProxyAdminRole(userRole);
const teamEstimateTooltip = estimateTooltips(canEditTeamEstimates, "team");
const { data: userOrganizations = [] } = useOrganizations();
- const { data: uiSettingsData } = useUISettings();
const { data: teamMetadataSchemaFields = [], isLoading: isTeamMetadataSchemaLoading } = useTeamMetadataSchema();
const queryClient = useQueryClient();
- // Check if user is org admin for this team's organization
- const isOrgAdminForTeam = useMemo(() => {
- const teamOrgId = teamData?.team_info?.organization_id;
- if (!teamOrgId || !userId) return false;
- const org = userOrganizations.find((o) => o.organization_id === teamOrgId);
- return org?.members?.some((m: any) => m.user_id === userId && m.user_role === "org_admin") ?? false;
- }, [teamData, userOrganizations, userId]);
-
// Models currently selected in the team edit form, used to scope the per-model
// rate limit dropdown to models this team actually has access to.
const watchedModels = form.watch("models");
@@ -623,22 +613,8 @@ const TeamInfoView: React.FC = ({
return unfurlWildcardModelsInList(selected, userModels);
}, [watchedModels, teamData, userModels]);
- const isTeamAdminFromTeamData = useMemo(
- () =>
- teamData?.team_info?.members_with_roles?.some(
- (member) => member.user_id != null && member.user_id === userId && member.role === "admin",
- ) ?? false,
- [teamData, userId],
- );
-
- const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam || isTeamAdminFromTeamData;
- const viewerIsProxyAdmin = is_proxy_admin || isProxyAdminRole(userRole);
- const viewerIsOrgAdmin = is_org_admin || isOrgAdminForTeam;
- const editsAsTeamAdmin = canEditTeam && !viewerIsProxyAdmin && !viewerIsOrgAdmin;
- const teamEditAccess = useMemo(
- () => resolveTeamEditAccess(editsAsTeamAdmin, uiSettingsData?.values),
- [editsAsTeamAdmin, uiSettingsData],
- );
+ const teamEditAccess = useMemo(() => parseTeamEditAccess(teamData?.team_info?.caller_edit_access), [teamData]);
+ const canEditTeam = is_team_admin || is_proxy_admin || teamEditAccess.kind !== "none";
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]);
const { onTabChange, hasVisited } = useVisitedTabs(defaultTabKey);
diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts
index 8ebc9584a49..122cc749e6b 100644
--- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts
+++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import {
parseSupportedTeamAdminEditableFields,
parseTeamAdminEditableFields,
- resolveTeamEditAccess,
+ parseTeamEditAccess,
} from "./teamAdminEditAccess";
describe("parseTeamAdminEditableFields", () => {
@@ -48,22 +48,28 @@ describe("parseSupportedTeamAdminEditableFields", () => {
});
});
-describe("resolveTeamEditAccess", () => {
- it("does not restrict callers who edit as proxy or org admin", () => {
- expect(resolveTeamEditAccess(false, { team_admin_editable_team_fields: [] })).toEqual({ kind: "unrestricted" });
+describe("parseTeamEditAccess", () => {
+ it.each([
+ ["unrestricted", { kind: "unrestricted" }],
+ ["team_admin_disabled", { kind: "team_admin_disabled" }],
+ ["none", { kind: "none" }],
+ ])("passes the proxy's %s verdict through", (_kind, verdict) => {
+ expect(parseTeamEditAccess(verdict)).toEqual(verdict);
});
- it("disables editing for a team admin when no field is enabled", () => {
- expect(resolveTeamEditAccess(true, { team_admin_editable_team_fields: [] })).toEqual({
- kind: "team_admin_disabled",
- });
- expect(resolveTeamEditAccess(true, undefined)).toEqual({ kind: "team_admin_disabled" });
- });
-
- it("hands a team admin the enabled fields", () => {
- expect(resolveTeamEditAccess(true, { team_admin_editable_team_fields: ["tpm_limit"] })).toEqual({
+ it("hands a team admin the fields the proxy enabled", () => {
+ expect(parseTeamEditAccess({ kind: "team_admin", editable_fields: ["tpm_limit"] })).toEqual({
kind: "team_admin",
editableFields: new Set(["tpm_limit"]),
});
});
+
+ it.each([
+ ["the proxy sent nothing", undefined],
+ ["the kind is unknown", { kind: "owner" }],
+ ["a team admin verdict lacks its field list", { kind: "team_admin" }],
+ ["the field list holds a non-string", { kind: "team_admin", editable_fields: [7] }],
+ ])("fails closed to no access when %s", (_label, value) => {
+ expect(parseTeamEditAccess(value)).toEqual({ kind: "none" });
+ });
});
diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts
index 81fa158be1c..79cc81b1416 100644
--- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts
+++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts
@@ -6,10 +6,20 @@ export const TEAM_ADMIN_EDITING_DISABLED_TITLE = "Team admins cannot edit team s
export const TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION =
"Ask a proxy admin to enable fields under Settings > UI > Team admin editable fields.";
+const callerEditAccessSchema = z.discriminatedUnion("kind", [
+ z.object({ kind: z.literal("unrestricted") }),
+ z.object({ kind: z.literal("team_admin"), editable_fields: z.array(z.string()) }),
+ z.object({ kind: z.literal("team_admin_disabled") }),
+ z.object({ kind: z.literal("none") }),
+]);
+
+export type CallerEditAccess = z.infer;
+
export type TeamEditAccess =
| { readonly kind: "unrestricted" }
| { readonly kind: "team_admin"; readonly editableFields: ReadonlySet }
- | { readonly kind: "team_admin_disabled" };
+ | { readonly kind: "team_admin_disabled" }
+ | { readonly kind: "none" };
const fieldListSchema = z.array(z.string()).catch([]);
@@ -29,10 +39,11 @@ export const parseSupportedTeamAdminEditableFields = (uiSettingsFieldSchema: unk
return items.success ? fieldListSchema.parse(items.data.enum) : [];
};
-export const resolveTeamEditAccess = (editsAsTeamAdmin: boolean, uiSettingsValues: unknown): TeamEditAccess => {
- if (!editsAsTeamAdmin) return { kind: "unrestricted" };
- const editableFields = parseTeamAdminEditableFields(uiSettingsValues);
- return editableFields.length === 0
- ? { kind: "team_admin_disabled" }
- : { kind: "team_admin", editableFields: new Set(editableFields) };
+export const parseTeamEditAccess = (callerEditAccess: unknown): TeamEditAccess => {
+ const parsed = callerEditAccessSchema.safeParse(callerEditAccess);
+ if (!parsed.success) return { kind: "none" };
+ if (parsed.data.kind === "team_admin") {
+ return { kind: "team_admin", editableFields: new Set(parsed.data.editable_fields) };
+ }
+ return parsed.data;
};