From b6a5563d058f61ca1cabfced4355db86de9a3b2a Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 7 Sep 2026 19:21:31 -0700 Subject: [PATCH 1/6] fix(ui): let team admins grant a team all proxy models The team edit form only offered "All Proxy Models" when the dashboard could read the parent organization, and /organization/info 403s for anyone who is not a proxy admin or an admin of that org. A team admin with the internal_user proxy role therefore saw only "No Default Models", which is the opposite of what they wanted, and had no way to grant their team everything on the proxy. /team/info now reports the parent org's model ceiling as organization_models, which the same authorization already admits, and ModelSelect reads the ceiling from the team it is editing before falling back to the organization. That also makes the individual model list respect the org's allow-list instead of listing every proxy model to a caller whose save would be rejected. useTeam was typed as Team while returning the /team/info envelope, so its one other caller unwrapped it behind a cast. It now returns the team itself. Claude-Session: https://claude.ai/code/session_011Tn3657NkV6ojLqewL64Kb --- litellm/proxy/_types.py | 2 + .../management_endpoints/team_endpoints.py | 26 +++++- .../test_team_endpoints.py | 72 +++++++++++++++++ .../(dashboard)/hooks/teams/useTeams.test.ts | 3 +- .../app/(dashboard)/hooks/teams/useTeams.ts | 3 +- .../_components/ProjectDetailsPage.test.tsx | 12 ++- .../_components/ProjectDetailsPage.tsx | 15 +--- .../ModelSelect/ModelSelect.test.tsx | 81 +++++++++++++++++++ .../components/ModelSelect/ModelSelect.tsx | 33 +++----- .../components/key_team_helpers/key_list.tsx | 2 + 10 files changed, 203 insertions(+), 46 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 49e0247aad9..a342f875d38 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -4414,6 +4414,8 @@ class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable): access_group_mcp_server_ids: list[str] | None = None access_group_agent_ids: list[str] | None = None access_group_details: tuple[TeamAccessGroupModelGrant, ...] | None = None + # Parent org's model ceiling. None = no org; [] or ["all-proxy-models"] = no ceiling. + organization_models: list[str] | None = None class TeamInfoResponseObject(TypedDict): diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 0b7f69bbb7f..f9c36728613 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -4368,6 +4368,20 @@ async def _hydrate_member_user_details( return tuple(hydrate(m) for m in members) +class _OrganizationModelsRow(BaseModel): + models: list[str] = [] # mutable-ok: pydantic field default + + +class _TeamRowWithOrganization(BaseModel): + litellm_organization_table: _OrganizationModelsRow | None = None + + +def _parent_organization_models(team_row: BaseModel) -> list[str] | None: + """Return the parent org's model allow-list, or None when the team has no org.""" + organization: Final = _TeamRowWithOrganization.model_validate(team_row.model_dump()).litellm_organization_table + return organization.models if organization is not None else None + + async def _resolve_team_access_group_resources( _team_info: TeamInfoResponseObjectTeamTable, ) -> TeamInfoResponseObjectTeamTable: @@ -4439,7 +4453,11 @@ async def team_info( try: team_info: BaseModel | None = await _team_db(prisma_client).find_unique( where={"team_id": team_id}, - include={"litellm_model_table": True, "object_permission": True}, + include={ + "litellm_model_table": True, + "object_permission": True, + "litellm_organization_table": True, + }, ) if team_info is None: raise Exception @@ -4448,6 +4466,7 @@ async def team_info( status_code=status.HTTP_404_NOT_FOUND, detail={"message": f"Team not found, passed team id: {team_id}."}, ) + organization_models: Final[list[str] | None] = _parent_organization_models(team_info) await validate_membership( user_api_key_dict=user_api_key_dict, team_table=LiteLLM_TeamTable.model_validate(team_info.model_dump()), @@ -4510,7 +4529,10 @@ async def team_info( members=resolved_team_info.members_with_roles, ) hydrated_team_info: Final = resolved_team_info.model_copy( - update={"members_with_roles": hydrated_members} # mutable-ok: pydantic update payload + update={ # mutable-ok: pydantic update payload + "members_with_roles": hydrated_members, + "organization_models": organization_models, + } ) response_object: Final = TeamInfoResponseObject( 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 0e1831614ac..d41be8b14b6 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -14157,3 +14157,75 @@ async def test_get_team_spend_by_user_rejects_bad_input(mock_db_client, team_ids assert exc_info.value.status_code == 400 assert expected_error in str(exc_info.value.detail) mock_db_client.db.query_raw.assert_not_called() + + +class _TeamRowWithOrganization(LiteLLM_TeamTable): + litellm_organization_table: LiteLLM_OrganizationTable | None = None + + +@pytest.mark.parametrize( + "organization, expected_models", + [ + ( + LiteLLM_OrganizationTable( + organization_id="org-1", + budget_id="budget-1", + models=["all-proxy-models"], + created_by="admin", + updated_by="admin", + ), + ["all-proxy-models"], + ), + ( + LiteLLM_OrganizationTable( + organization_id="org-1", + budget_id="budget-1", + models=["gpt-4o"], + created_by="admin", + updated_by="admin", + ), + ["gpt-4o"], + ), + (None, None), + ], +) +@pytest.mark.asyncio +async def test_team_info_returns_parent_organization_models(organization, expected_models): + """/team/info must report the parent org's model ceiling. + + A team admin who is not an org admin gets a 403 from /organization/info, so this + is the only read that can tell the Admin UI whether the org allows all proxy + models. Without it the team edit form hides the "All Proxy Models" option and a + team admin cannot grant their team everything on the proxy. + """ + from fastapi import Request + + from litellm.proxy.management_endpoints import team_endpoints + + team_row = _TeamRowWithOrganization( + team_id="team-1", + organization_id="org-1" if organization is not None else None, + litellm_organization_table=organization, + ) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row) + mock_prisma.get_data = AsyncMock(return_value=[]) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), + patch.object(team_endpoints, "get_all_team_memberships", AsyncMock(return_value=[])), + ): + response = await team_endpoints.team_info( + http_request=MagicMock(spec=Request), + team_id="team-1", + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + include = mock_prisma.db.litellm_teamtable.find_unique.await_args.kwargs["include"] + assert include["litellm_organization_table"] is True + + team_info = response["team_info"] + assert team_info.organization_models == expected_models + # the org row itself carries budgets and spend; only its model list may ride along + assert "litellm_organization_table" not in team_info.model_dump() diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts index eccd8a80748..56e45216ec5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts @@ -328,7 +328,8 @@ describe("useTeam", () => { }); it("should return team data when query is successful", async () => { - (teamInfoCall as any).mockResolvedValue(mockTeams[0]); + // /team/info answers with an envelope; the hook is typed as the team itself. + (teamInfoCall as any).mockResolvedValue({ team_id: "team-1", team_info: mockTeams[0], keys: [] }); const { result } = renderHook(() => useTeam("team-1"), { wrapper }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts index 14e95bcd543..05025adc5e6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts @@ -163,7 +163,8 @@ export const useTeam = (teamId?: string) => { throw new Error("Missing auth or teamId"); } - return teamInfoCall(accessToken, teamId); + const { team_info } = (await teamInfoCall(accessToken, teamId)) as { team_info: Team }; + return team_info; }, initialData: () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx index cf6576ce6e9..d22ac0d8742 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx @@ -242,13 +242,11 @@ describe("ProjectDetail", () => { it("should show team information when team data is available", () => { mockUseTeam.mockReturnValue({ data: { - team_info: { - team_id: "team-1", - team_alias: "Engineering", - models: ["gpt-4"], - spend: 50, - members_with_roles: [], - }, + team_id: "team-1", + team_alias: "Engineering", + models: ["gpt-4"], + spend: 50, + members_with_roles: [], }, isLoading: false, }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx index f94240f3c9e..2585b67c81b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx @@ -14,16 +14,6 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { EditProjectModal } from "./ProjectModals/EditProjectModal"; import { ProjectKeysSection } from "./ProjectKeysSection"; -interface TeamInfoShape { - team_id: string; - team_alias?: string; - models?: string[]; - max_budget?: number | null; - budget_duration?: string | null; - spend?: number; - members_with_roles?: { user_id: string; role: string }[]; -} - interface ProjectDetailProps { projectId: string; onBack: () => void; @@ -33,10 +23,7 @@ const utilisationTone = (percent: number) => (percent >= 90 ? "over" : percent > export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { const { data: project, isLoading } = useProjectDetails(projectId); - const { data: teamData } = useTeam(project?.team_id ?? undefined); - // teamInfoCall returns { team_id, team_info: {...}, keys, team_memberships } - const teamInfo: TeamInfoShape | undefined = ((teamData as unknown as { team_info?: TeamInfoShape })?.team_info ?? - teamData) as TeamInfoShape | undefined; + const { data: teamInfo } = useTeam(project?.team_id ?? undefined); const [isEditModalVisible, setIsEditModalVisible] = useState(false); const spend = project?.spend ?? 0; diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx index 34a21122027..690d41586d0 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx @@ -415,6 +415,87 @@ describe("ModelSelect", () => { } }); + it("should take the org model ceiling from the team when /organization/info is not readable", async () => { + const testCases = [ + { + name: "org allows all proxy models", + organizationModels: ["all-proxy-models"], + shouldShowSentinel: true, + offered: ["gpt-4", "claude-3"], + notOffered: [] as string[], + }, + { + name: "org places no ceiling at all", + organizationModels: [], + shouldShowSentinel: true, + offered: ["gpt-4", "claude-3"], + notOffered: [] as string[], + }, + { + name: "org restricts the team to one model", + organizationModels: ["gpt-4"], + shouldShowSentinel: false, + offered: ["gpt-4"], + notOffered: ["claude-3"], + }, + ]; + + for (const testCase of testCases) { + const user = userEvent.setup(); + // A team admin gets a 403 from /organization/info, so the org query never resolves. + mockUseOrganization.mockReturnValue({ data: undefined, isLoading: false } as any); + mockUseTeam.mockReturnValue({ + data: { team_id: "team-1", organization_models: testCase.organizationModels }, + isLoading: false, + } as any); + + const { unmount } = renderWithProviders( + , + ); + + await openModelList(user); + if (testCase.shouldShowSentinel) { + expectOffered("All Proxy Models"); + } else { + expectNotOffered("All Proxy Models"); + } + expectOffered("No Default Models"); + testCase.offered.forEach(expectOffered); + testCase.notOffered.forEach(expectNotOffered); + + unmount(); + } + }); + + it("should keep hiding All Proxy Models when neither the team nor the org reports a ceiling", async () => { + const user = userEvent.setup(); + mockUseOrganization.mockReturnValue({ data: undefined, isLoading: false } as any); + mockUseTeam.mockReturnValue({ + data: { team_id: "team-1", organization_models: null }, + isLoading: false, + } as any); + + renderWithProviders( + , + ); + + await openModelList(user); + expectNotOffered("All Proxy Models"); + expectOffered("No Default Models"); + }); + it("should use custom dataTestId when provided", async () => { renderWithProviders( + organizationModels.length === 0 || organizationModels.includes(MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value); + const contextFilters: Record string[]> = { user: ({ allProxyModels, userModels, options }) => { if (!userModels) return []; @@ -82,18 +83,9 @@ const contextFilters: Record { - if (selectedOrganization) { - if ( - selectedOrganization.models.includes(MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value) || - selectedOrganization.models.length === 0 - ) { - return allProxyModels; - } - return allProxyModels.filter((model) => selectedOrganization.models.includes(model)); - } - - return allProxyModels ?? []; + team: ({ allProxyModels, organizationModels }) => { + if (!organizationModels || isUncappedModelCeiling(organizationModels)) return allProxyModels; + return allProxyModels.filter((model) => organizationModels.includes(model)); }, organization: ({ allProxyModels }) => { @@ -108,7 +100,7 @@ const contextFilters: Record { const deduplicatedProxyModels = Array.from(new Map(allProxyModels.map((m) => [m.id, m])).values()).map( (model) => model.id, @@ -133,9 +125,9 @@ export const ModelSelect = (props: ModelSelectProps) => { const isSpecialOption = (value: string) => MODEL_SENTINEL_OPTIONS.some((sv) => sv.value === value); const hasSpecialOptionSelected = value.some(isSpecialOption); const isLoading = isLoadingAllProxyModels || isLoadingTeam || isLoadingOrganization || isCurrentUserLoading; - const organizationHasAllProxyModels = - organization?.models.includes(MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value) || - organization?.models.length === 0; + // The org's ceiling rides on /team/info, which a team admin may read; /organization/info 403s for them. + const organizationModels = team?.organization_models ?? organization?.models; + const organizationHasAllProxyModels = organizationModels !== undefined && isUncappedModelCeiling(organizationModels); const shouldShowAllProxyModels = showAllProxyModelsOverride || (organizationHasAllProxyModels && includeSpecialOptions) || context === "global"; @@ -159,8 +151,7 @@ export const ModelSelect = (props: ModelSelectProps) => { }; const filteredModels = filterModels(allProxyModels?.data ?? [], props, { - selectedTeam: team, - selectedOrganization: organization, + organizationModels, userModels: currentUser?.models, }); diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index a6cc940c7fd..d92e23af078 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -27,6 +27,8 @@ export interface Team { access_group_models?: string[]; access_group_mcp_server_ids?: string[]; access_group_agent_ids?: string[]; + // Parent org's model ceiling. undefined = no org / not loaded; [] or ["all-proxy-models"] = no ceiling. + organization_models?: string[] | null; } export interface KeyResponse { From 3f42a4fc981045fbdfd8407f35bc08284aacf94d Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 7 Sep 2026 19:25:52 -0700 Subject: [PATCH 2/6] test: suppress TQ008 on the two module-state patches team_info resolves prisma_client and get_all_team_memberships from module state, so there is no collaborator to inject; the two sibling team_info tests patch the same way. Claude-Session: https://claude.ai/code/session_011Tn3657NkV6ojLqewL64Kb --- .../proxy/management_endpoints/test_team_endpoints.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 d41be8b14b6..c28e398e451 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -14212,9 +14212,11 @@ async def test_team_info_returns_parent_organization_models(organization, expect mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row) mock_prisma.get_data = AsyncMock(return_value=[]) + memberships = AsyncMock(return_value=[]) + with ( - patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), - patch.object(team_endpoints, "get_all_team_memberships", AsyncMock(return_value=[])), + 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", memberships), # test-quality-ok: no seam on team_info ): response = await team_endpoints.team_info( http_request=MagicMock(spec=Request), From 5ddb0fe691e80e047d661d7b88c796bd5809cfad Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 19:38:05 +0000 Subject: [PATCH 3/6] fix(ui): keep the team model select loading until /team/info reports the org ceiling useTeam seeds its cache from the team list, which has no organization_models, so the select briefly rendered unfiltered. Also drop the Prisma include assertion from the backend test. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../test_team_endpoints.py | 4 -- .../ModelSelect/ModelSelect.test.tsx | 44 +++++++++++++++++++ .../components/ModelSelect/ModelSelect.tsx | 10 ++++- 3 files changed, 52 insertions(+), 6 deletions(-) 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 c28e398e451..4c99f154d7b 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -14224,10 +14224,6 @@ async def test_team_info_returns_parent_organization_models(organization, expect user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), ) - include = mock_prisma.db.litellm_teamtable.find_unique.await_args.kwargs["include"] - assert include["litellm_organization_table"] is True - team_info = response["team_info"] assert team_info.organization_models == expected_models - # the org row itself carries budgets and spend; only its model list may ride along assert "litellm_organization_table" not in team_info.model_dump() diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx index 690d41586d0..3ea42f5adc0 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx @@ -473,6 +473,50 @@ describe("ModelSelect", () => { } }); + it("should stay in the loading state while a list-seeded team is still fetching its org ceiling", () => { + mockUseOrganization.mockReturnValue({ data: undefined, isLoading: false } as any); + mockUseTeam.mockReturnValue({ + data: { team_id: "team-1", models: [] }, + isLoading: false, + isFetching: true, + } as any); + + renderWithProviders( + , + ); + + expect(screen.queryAllByRole("combobox")).toHaveLength(0); + }); + + it("should not hold the loading state on a background refetch once the org ceiling is known", async () => { + const user = userEvent.setup(); + mockUseOrganization.mockReturnValue({ data: undefined, isLoading: false } as any); + mockUseTeam.mockReturnValue({ + data: { team_id: "team-1", organization_models: ["all-proxy-models"] }, + isLoading: false, + isFetching: true, + } as any); + + renderWithProviders( + , + ); + + await openModelList(user); + expectOffered("All Proxy Models"); + }); + it("should keep hiding All Proxy Models when neither the team nor the org reports a ceiling", async () => { const user = userEvent.setup(); mockUseOrganization.mockReturnValue({ data: undefined, isLoading: false } as any); diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx index 76d95df6962..ca449be81e3 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx @@ -19,6 +19,7 @@ import { } from "@/components/ui/combobox"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import type { Team } from "@/components/key_team_helpers/key_list"; import { splitWildcardModels } from "./modelUtils"; const MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE = { @@ -76,6 +77,10 @@ type FilterContextArgs = { const isUncappedModelCeiling = (organizationModels: string[]) => organizationModels.length === 0 || organizationModels.includes(MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value); +// useTeam seeds from the team list, which omits organization_models; /team/info is the only source of the org ceiling. +const isAwaitingOrganizationModels = (team: Team | undefined, isFetchingTeam: boolean) => + isFetchingTeam && team !== undefined && team.organization_models === undefined; + const contextFilters: Record string[]> = { user: ({ allProxyModels, userModels, options }) => { if (!userModels) return []; @@ -118,13 +123,14 @@ export const ModelSelect = (props: ModelSelectProps) => { const { id, teamID, organizationID, options, context, dataTestId, value = [], onChange, style } = props; const { showAllProxyModelsOverride, includeSpecialOptions } = options || {}; const { data: allProxyModels, isLoading: isLoadingAllProxyModels } = useAllProxyModels(); - const { data: team, isLoading: isLoadingTeam } = useTeam(teamID); + const { data: team, isLoading: isLoadingTeam, isFetching: isFetchingTeam } = useTeam(teamID); const { data: organization, isLoading: isLoadingOrganization } = useOrganization(organizationID); const { data: currentUser, isLoading: isCurrentUserLoading } = useCurrentUser(); const isSpecialOption = (value: string) => MODEL_SENTINEL_OPTIONS.some((sv) => sv.value === value); const hasSpecialOptionSelected = value.some(isSpecialOption); - const isLoading = isLoadingAllProxyModels || isLoadingTeam || isLoadingOrganization || isCurrentUserLoading; + const isTeamPending = isLoadingTeam || isAwaitingOrganizationModels(team, isFetchingTeam); + const isLoading = isLoadingAllProxyModels || isTeamPending || isLoadingOrganization || isCurrentUserLoading; // The org's ceiling rides on /team/info, which a team admin may read; /organization/info 403s for them. const organizationModels = team?.organization_models ?? organization?.models; const organizationHasAllProxyModels = organizationModels !== undefined && isUncappedModelCeiling(organizationModels); From 9cdfe311e92c277d0fd238bb96e1142150df7557 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 19:39:18 +0000 Subject: [PATCH 4/6] test: assert only the org ceiling /team/info returns Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/management_endpoints/test_team_endpoints.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 4c99f154d7b..d198b047d91 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -14224,6 +14224,4 @@ async def test_team_info_returns_parent_organization_models(organization, expect user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), ) - team_info = response["team_info"] - assert team_info.organization_models == expected_models - assert "litellm_organization_table" not in team_info.model_dump() + assert response["team_info"].organization_models == expected_models From 2d3b63fb5abfcadba41a3b376f297901ca81fb9b Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 20:01:47 +0000 Subject: [PATCH 5/6] fix(proxy): report parent org models on /team/info only to team managers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 3 +- .../management_endpoints/team_endpoints.py | 43 +++++++-------- .../test_team_endpoints.py | 52 +++++++++++++++++++ 3 files changed, 76 insertions(+), 22 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index a342f875d38..49612d056bf 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -4414,7 +4414,8 @@ class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable): access_group_mcp_server_ids: list[str] | None = None access_group_agent_ids: list[str] | None = None access_group_details: tuple[TeamAccessGroupModelGrant, ...] | None = None - # Parent org's model ceiling. None = no org; [] or ["all-proxy-models"] = no ceiling. + # Parent org's model ceiling, reported only to callers who can manage the team. + # None = no org or not a manager; [] or ["all-proxy-models"] = no ceiling. organization_models: list[str] | None = None diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index f9c36728613..745c05ffec8 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -429,27 +429,26 @@ async def _refresh_cached_team( ) +async def _can_manage_team( + team_obj: LiteLLM_TeamTable, + user_api_key_dict: UserAPIKeyAuth, +) -> bool: + """True for a proxy admin, an admin of this team, or an org admin for the team's organization.""" + if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: + return True + + if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj): + return True + + return await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_obj) + + async def _verify_team_access( team_obj: LiteLLM_TeamTable, user_api_key_dict: UserAPIKeyAuth, ) -> None: - """ - Verify the caller is authorized to manage the given team. - - Access is granted if: - - Caller is a proxy admin, OR - - Caller is an org admin for the team's organization, OR - - Caller is a team admin of this team - - Raises HTTPException(403) otherwise. - """ - if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: - return - - if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj): - return - - if await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_obj): + """Raise HTTPException(403) unless the caller can manage the given team.""" + if await _can_manage_team(team_obj=team_obj, user_api_key_dict=user_api_key_dict): return raise HTTPException( @@ -4466,10 +4465,12 @@ async def team_info( status_code=status.HTTP_404_NOT_FOUND, detail={"message": f"Team not found, passed team id: {team_id}."}, ) - organization_models: Final[list[str] | None] = _parent_organization_models(team_info) - await validate_membership( - user_api_key_dict=user_api_key_dict, - team_table=LiteLLM_TeamTable.model_validate(team_info.model_dump()), + 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) + organization_models: Final[list[str] | None] = ( + _parent_organization_models(team_info) + if await _can_manage_team(team_obj=team_table, user_api_key_dict=user_api_key_dict) + else None ) ## GET ALL KEYS ## 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 d198b047d91..f2904ed8c45 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -14225,3 +14225,55 @@ async def test_team_info_returns_parent_organization_models(organization, expect ) assert response["team_info"].organization_models == expected_models + + +@pytest.mark.parametrize( + "caller, expected_models", + [ + (UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.INTERNAL_USER), ["gpt-4o"]), + (UserAPIKeyAuth(user_id="member-1", user_role=LitellmUserRoles.INTERNAL_USER), None), + (UserAPIKeyAuth(team_id="team-1"), None), + ], +) +@pytest.mark.asyncio +async def test_team_info_reports_parent_organization_models_only_to_team_managers(caller, expected_models): + """Plain members and team keys can read their team, but not the org's wider allow-list.""" + from fastapi import Request + + from litellm.proxy.management_endpoints import team_endpoints + + team_row = _TeamRowWithOrganization( + 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"), + ], + litellm_organization_table=LiteLLM_OrganizationTable( + organization_id="org-1", + budget_id="budget-1", + models=["gpt-4o"], + created_by="admin", + updated_by="admin", + ), + ) + + 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: no seam on team_info + team_endpoints, "_is_user_org_admin_for_team", AsyncMock(return_value=False) + ), + ): + response = await team_endpoints.team_info( + http_request=MagicMock(spec=Request), + team_id="team-1", + user_api_key_dict=caller, + ) + + assert response["team_info"].organization_models == expected_models From df87a49f8b9de9607bff25e95b029b15f1517534 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 21:35:55 +0000 Subject: [PATCH 6/6] fix(ui): offer no models when an org team's ceiling never arrives Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ModelSelect/ModelSelect.test.tsx | 46 +++++++++++-------- .../components/ModelSelect/ModelSelect.tsx | 14 ++++-- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx index 3ea42f5adc0..93a43d5e533 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx @@ -517,27 +517,35 @@ describe("ModelSelect", () => { expectOffered("All Proxy Models"); }); - it("should keep hiding All Proxy Models when neither the team nor the org reports a ceiling", async () => { - const user = userEvent.setup(); - mockUseOrganization.mockReturnValue({ data: undefined, isLoading: false } as any); - mockUseTeam.mockReturnValue({ - data: { team_id: "team-1", organization_models: null }, - isLoading: false, - } as any); + it("should offer no models for an org team when neither the team nor the org reports a ceiling", async () => { + const testCases = [ + { name: "/team/info withheld the ceiling", team: { team_id: "team-1", organization_models: null } }, + { name: "/team/info failed after the list seeded the team", team: { team_id: "team-1", models: [] } }, + ]; - renderWithProviders( - , - ); + for (const testCase of testCases) { + const user = userEvent.setup(); + mockUseOrganization.mockReturnValue({ data: undefined, isLoading: false } as any); + mockUseTeam.mockReturnValue({ data: testCase.team, isLoading: false, isFetching: false } as any); - await openModelList(user); - expectNotOffered("All Proxy Models"); - expectOffered("No Default Models"); + const { unmount } = renderWithProviders( + , + ); + + await openModelList(user); + expectNotOffered("All Proxy Models"); + expectOffered("No Default Models"); + expectNotOffered("gpt-4"); + expectNotOffered("claude-3"); + + unmount(); + } }); it("should use custom dataTestId when provided", async () => { diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx index ca449be81e3..c29eba9d997 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx @@ -69,6 +69,7 @@ type ModelOptionGroup = { type FilterContextArgs = { allProxyModels: string[]; + organizationID?: string; organizationModels?: string[]; userModels?: string[]; options?: ModelSelectProps["options"]; @@ -88,8 +89,9 @@ const contextFilters: Record { - if (!organizationModels || isUncappedModelCeiling(organizationModels)) return allProxyModels; + team: ({ allProxyModels, organizationID, organizationModels }) => { + if (organizationModels === undefined) return organizationID ? [] : allProxyModels; + if (isUncappedModelCeiling(organizationModels)) return allProxyModels; return allProxyModels.filter((model) => organizationModels.includes(model)); }, @@ -115,7 +117,13 @@ const filterModels = ( const filterFn = contextFilters[ctx.context]; if (!filterFn) return []; - return filterFn({ allProxyModels: deduplicatedProxyModels, ...extra, options: ctx.options }); + const filterArgs: FilterContextArgs = { + allProxyModels: deduplicatedProxyModels, + organizationID: ctx.organizationID, + ...extra, + options: ctx.options, + }; + return filterFn(filterArgs); }; export const ModelSelect = (props: ModelSelectProps) => {