diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 4dbae6394f6..44d9b430955 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -4313,6 +4313,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 2ec68a10f65..fac74a5775f 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -4325,6 +4325,20 @@ async def _hydrate_member_emails( ) +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: @@ -4396,7 +4410,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 @@ -4405,6 +4423,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()), @@ -4471,7 +4490,8 @@ async def team_info( update={ # mutable-ok: pydantic update payload # list(), not the tuple: model_copy skips validation, so the field has # to be handed the list[Member] the response model declares. - "members_with_roles": list(hydrated_members) # mutable-ok: declared list[Member] + "members_with_roles": list(hydrated_members), # mutable-ok: declared list[Member] + "organization_models": organization_models, } ) 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 051e6bed4fd..8fbf3e0ca67 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -14067,3 +14067,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 {