From 5ddb0fe691e80e047d661d7b88c796bd5809cfad Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 19:38:05 +0000 Subject: [PATCH] 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);