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>
This commit is contained in:
ryan 2026-09-14 19:38:05 +00:00
parent 3f42a4fc98
commit 5ddb0fe691
3 changed files with 52 additions and 6 deletions

View file

@ -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()

View file

@ -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(
<ModelSelect
onChange={mockOnChange}
context="team"
teamID="team-1"
organizationID="org-1"
options={{ includeSpecialOptions: true }}
/>,
);
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(
<ModelSelect
onChange={mockOnChange}
context="team"
teamID="team-1"
organizationID="org-1"
options={{ includeSpecialOptions: true }}
/>,
);
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);

View file

@ -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<ModelSelectProps["context"], (args: FilterContextArgs) => 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);