fix(ui): link model access group chips to the access group filter (#45402)

* fix(ui): link model access group chips to the access group filter

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(ui): format access group chip link changes

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(ui): mock access group hook in affected suites

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): keep access group chips unlinked until the group lookup resolves

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): keep cached access group names when a refetch fails

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: nate <nate@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-08 15:32:33 -07:00 • committed by GitHub
parent 577d74c1ce
commit 5133485009
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 295 additions and 13 deletions

View file

@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import React, { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
@ -11,6 +11,7 @@ import {
useAutoRouters,
useInfiniteModelInfo,
useModelHub,
useModelAccessGroupNames,
useModelsInfo,
usePlainChatModelGroups,
useSelectedTeamModels,
@ -565,6 +566,127 @@ describe("useUserModels", () => {
});
});
describe("useModelAccessGroupNames", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
userId: "test-user-id",
userRole: "Admin",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("fetches and returns the caller's model access group names", async () => {
vi.mocked(modelAvailableCall).mockResolvedValue({
data: [
{ id: "repro-access-group", object: "model", created: 0, owned_by: "litellm" },
{ id: "another-access-group", object: "model", created: 0, owned_by: "litellm" },
],
});
const { result } = renderHook(() => useModelAccessGroupNames(), { wrapper });
await waitFor(() => {
expect(result.current).toEqual(new Set(["repro-access-group", "another-access-group"]));
});
expect(modelAvailableCall).toHaveBeenCalledWith(
"test-access-token",
"test-user-id",
"Admin",
false,
null,
true,
true,
);
});
it("returns undefined while the access-group lookup is pending", () => {
vi.mocked(modelAvailableCall).mockReturnValue(new Promise<AllProxyModelsResponse>(() => undefined));
const { result } = renderHook(() => useModelAccessGroupNames(), { wrapper });
expect(result.current).toBeUndefined();
});
it("returns undefined until authorization is ready", () => {
const unauthorizedContext = {
accessToken: null,
userId: null,
userRole: null,
token: null,
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
};
mockUseAuthorized.mockReturnValue(unauthorizedContext);
const { result } = renderHook(() => useModelAccessGroupNames(), { wrapper });
expect(result.current).toBeUndefined();
expect(modelAvailableCall).not.toHaveBeenCalled();
});
it("returns an empty set when the access-group lookup fails", async () => {
vi.mocked(modelAvailableCall).mockRejectedValue(new Error("lookup failed"));
const { result } = renderHook(() => useModelAccessGroupNames(), { wrapper });
await waitFor(() => expect(result.current).toBeDefined());
expect(result.current?.size).toBe(0);
});
it("keeps cached access-group names after a failed refetch", async () => {
vi.mocked(modelAvailableCall).mockResolvedValueOnce({
data: [{ id: "repro-access-group", object: "model", created: 0, owned_by: "litellm" }],
});
const { result } = renderHook(() => useModelAccessGroupNames(), { wrapper });
await waitFor(() => {
expect(result.current?.has("repro-access-group")).toBe(true);
});
const queryKey = queryClient
.getQueryCache()
.getAll()
.find((query) => {
return query.queryKey[0] === "modelAccessGroupNames";
})?.queryKey;
expect(queryKey).toBeDefined();
if (!queryKey) throw new Error("The access-group query was not created");
vi.mocked(modelAvailableCall).mockRejectedValueOnce(new Error("refetch failed"));
await act(async () => {
await queryClient.refetchQueries({ queryKey });
});
await waitFor(() => {
expect(queryClient.getQueryCache().find({ queryKey })?.state.status).toBe("error");
});
expect(result.current).toEqual(new Set(["repro-access-group"]));
});
});
describe("useSelectedTeamModels", () => {
let queryClient: QueryClient;

View file

@ -1,4 +1,5 @@
import { useQuery, useInfiniteQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query";
import { useMemo } from "react";
import { createQueryKeys } from "../common/queryKeysFactory";
import { modelInfoCall, modelHubCall, modelAvailableCall } from "@/components/networking";
import useAuthorized from "../useAuthorized";
@ -29,6 +30,7 @@ const allProxyModelsKeys = createQueryKeys("allProxyModels");
const selectedTeamModelsKeys = createQueryKeys("selectedTeamModels");
const infiniteModelKeys = createQueryKeys("infiniteModels");
const userModelsKeys = createQueryKeys("userModels");
const modelAccessGroupNameKeys = createQueryKeys("modelAccessGroupNames");
export const useModelsInfo = (
page: number = 1,
@ -265,6 +267,31 @@ export const useUserModels = (): UseQueryResult<string[]> => {
});
};
export const useModelAccessGroupNames = (): ReadonlySet<string> | undefined => {
const { accessToken, userId, userRole } = useAuthorized();
const { data, isError } = useQuery<string[]>({
queryKey: modelAccessGroupNameKeys.list({}),
queryFn: async () => {
const response: AllProxyModelsResponse = await modelAvailableCall(
accessToken!,
userId!,
userRole!,
false,
null,
true,
true,
);
return response.data.map((model) => model.id);
},
enabled: Boolean(accessToken && userId && userRole),
});
return useMemo(() => {
if (data !== undefined) return new Set(data);
if (isError) return new Set<string>();
return undefined;
}, [data, isError]);
};
export const useSelectedTeamModels = (teamID: string | null) => {
const { accessToken, userId, userRole } = useAuthorized();
return useQuery<AllProxyModelsResponse>({

View file

@ -84,6 +84,7 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useAllProxyModels: vi.fn(() => ({ data: { data: [] }, isLoading: false })),
useModelAccessGroupNames: vi.fn(() => new Set<string>()),
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({

View file

@ -75,6 +75,7 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useAllProxyModels: vi.fn(),
useModelAccessGroupNames: vi.fn(() => new Set<string>()),
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", async (importOriginal) => ({

View file

@ -81,6 +81,7 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useAllProxyModels: vi.fn(),
useModelAccessGroupNames: vi.fn(() => new Set<string>()),
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", async (importOriginal) => ({
@ -231,7 +232,7 @@ vi.mock("../key_team_helpers/filter_helpers", () => ({
fetchAllOrganizations: vi.fn().mockResolvedValue([]),
}));
import { useAllProxyModels } from "@/app/(dashboard)/hooks/models/useModels";
import { useAllProxyModels, useModelAccessGroupNames } from "@/app/(dashboard)/hooks/models/useModels";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { teamKeys, teamsTableKeys, useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
@ -242,6 +243,7 @@ import { useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessG
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
const mockUseAllProxyModels = vi.mocked(useAllProxyModels);
const mockUseModelAccessGroupNames = vi.mocked(useModelAccessGroupNames);
const mockUseKeys = vi.mocked(useKeys);
const mockUseTeam = vi.mocked(useTeam);
const mockUseOrganization = vi.mocked(useOrganization);
@ -292,6 +294,7 @@ const createMockTeamData = (overrides = {}) => ({
});
const seedDefaultMocks = () => {
mockUseModelAccessGroupNames.mockReturnValue(new Set());
mockUseAllProxyModels.mockReturnValue({
data: { data: [] },
isLoading: false,
@ -361,6 +364,35 @@ describe("TeamInfoView", () => {
});
describe("display and rendering", () => {
it("links direct model chips to their matching access-group or model filter", async () => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({ models: ["repro-access-group", "gpt-4.1"] }),
);
mockUseModelAccessGroupNames.mockReturnValue(new Set(["repro-access-group"]));
renderWithProviders(<TeamInfoView {...defaultProps} />);
expect(await screen.findByRole("link", { name: "repro-access-group" })).toHaveAttribute(
"href",
expect.stringMatching(/\?access_group=repro-access-group$/),
);
expect(screen.getByRole("link", { name: "gpt-4.1" })).toHaveAttribute(
"href",
expect.stringMatching(/\?model_group=gpt-4\.1$/),
);
await userEvent.setup({ delay: null }).click(screen.getByRole("tab", { name: "Settings" }));
const settings = await screen.findByRole("tabpanel", { name: "Settings" });
expect(within(settings).getByRole("link", { name: "repro-access-group" })).toHaveAttribute(
"href",
expect.stringMatching(/\?access_group=repro-access-group$/),
);
expect(within(settings).getByRole("link", { name: "gpt-4.1" })).toHaveAttribute(
"href",
expect.stringMatching(/\?model_group=gpt-4\.1$/),
);
});
it("should render", async () => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());

View file

@ -26,7 +26,7 @@ import { ArrowLeftIcon } from "@heroicons/react/outline";
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge";
import { BadgeLink } from "@/components/shared/BadgeLink";
import { Badge } from "@/components/ui/badge";
import { modelGroupHref } from "@/utils/entityLinks";
import { modelGroupHref, modelOrAccessGroupHref } from "@/utils/entityLinks";
import { Card } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input as UIInput } from "@/components/ui/input";
@ -119,6 +119,7 @@ import {
TEAM_INFO_TAB_LABELS,
} from "./tabVisibilityUtils";
import TeamMembersComponent from "./TeamMemberTab";
import { useModelAccessGroupNames } from "@/app/(dashboard)/hooks/models/useModels";
import {
isValidThreshold,
TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY,
@ -154,8 +155,14 @@ const TEAM_MODEL_BADGE_TONES: Record<TeamModelBadgeKind, StatusTone> = {
"access-group": "success",
};
const teamModelBadgeHref = (badge: TeamModelBadge): string | undefined =>
badge.kind === "direct" || badge.kind === "access-group" ? modelGroupHref(badge.label) : undefined;
const teamModelBadgeHref = (
badge: TeamModelBadge,
accessGroupNames: ReadonlySet<string> | undefined,
): string | undefined => {
if (badge.kind === "direct") return modelOrAccessGroupHref(badge.label, accessGroupNames);
if (badge.kind === "access-group") return modelGroupHref(badge.label);
return undefined;
};
export type McpGrantResolution =
| { readonly kind: "resolved"; readonly serverIds: ReadonlySet<string> }
@ -622,6 +629,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const routerSettingsRef = React.useRef<RouterSettingsAccordionRef>(null);
const [organization, setOrganization] = useState<Organization | null>(null);
const { userRole } = useAuthorized();
const accessGroupNames = useModelAccessGroupNames();
const { data: allMcpServers = [], isError: mcpServersFailed, isLoading: mcpServersLoading } = useMCPServers();
const { data: allMcpToolsets = [], isError: mcpToolsetsFailed, isLoading: mcpToolsetsLoading } = useMCPToolsets();
const { data: allAccessGroups = [], isError: accessGroupsFailed, isLoading: accessGroupsLoading } = useAccessGroups();
@ -1355,7 +1363,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<StatusBadge
tone={TEAM_MODEL_BADGE_TONES[badge.kind]}
label={badge.label}
href={teamModelBadgeHref(badge)}
href={teamModelBadgeHref(badge, accessGroupNames)}
/>
</span>
</SimpleTooltip>
@ -2191,7 +2199,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<p className="font-medium">Models</p>
<div className="flex flex-wrap gap-2 mt-1">
{info.models.map((model, index) => (
<BadgeLink key={index} href={modelGroupHref(model)}>
<BadgeLink key={index} href={modelOrAccessGroupHref(model, accessGroupNames)}>
{model}
</BadgeLink>
))}
@ -2202,7 +2210,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<p className="font-medium">Default Member Models</p>
<div className="flex flex-wrap gap-2 mt-1">
{info.default_team_member_models.map((model, index) => (
<BadgeLink key={index} href={modelGroupHref(model)}>
<BadgeLink key={index} href={modelOrAccessGroupHref(model, accessGroupNames)}>
{model}
</BadgeLink>
))}

View file

@ -23,6 +23,10 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: mockUseAuthorized,
}));
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useModelAccessGroupNames: vi.fn(() => new Set<string>()),
}));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: () => ({ data: [] }),
}));

View file

@ -1,7 +1,7 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { renderWithProviders } from "../../../tests/test-utils";
import { screen, waitFor } from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { KeyResponse, Team } from "../key_team_helpers/key_list";
@ -9,6 +9,12 @@ import { keyDeleteCall, keyUpdateCall } from "../networking";
import { QueryClient } from "@tanstack/react-query";
import KeyInfoView, { needsLifetimeSpendBackfill } from "./key_info_view";
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useModelAccessGroupNames: vi.fn(() => new Set<string>()),
}));
import { useModelAccessGroupNames } from "@/app/(dashboard)/hooks/models/useModels";
const editViewMocks = vi.hoisted(() => ({
onSubmit: undefined as ((v: Record<string, any>) => Promise<void>) | undefined,
}));
@ -639,6 +645,7 @@ describe("KeyInfoView", () => {
beforeEach(() => {
vi.mocked(useTeams).mockReturnValue({ teams: [mockTeam], setTeams: vi.fn() });
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
vi.mocked(useModelAccessGroupNames).mockReturnValue(new Set());
});
it("links the key's team by alias, resolved from the teams list, to the team page", async () => {
@ -703,6 +710,37 @@ describe("KeyInfoView", () => {
);
});
it("links access-group model chips to the access-group filter", async () => {
vi.mocked(useModelAccessGroupNames).mockReturnValue(new Set(["repro-access-group"]));
const keyData = { ...MOCK_KEY_DATA, models: ["repro-access-group"] };
renderWithProviders(
<KeyInfoView keyData={keyData} onClose={() => {}} keyId="test-key-id" onKeyDataUpdate={() => {}} teams={[]} />,
);
expect(await screen.findByRole("link", { name: "repro-access-group" })).toHaveAttribute(
"href",
expect.stringMatching(/\?access_group=repro-access-group$/),
);
await userEvent.setup({ delay: null }).click(screen.getByRole("tab", { name: "Settings" }));
const settings = await screen.findByRole("tabpanel", { name: "Settings" });
expect(within(settings).getByRole("link", { name: "repro-access-group" })).toHaveAttribute(
"href",
expect.stringMatching(/\?access_group=repro-access-group$/),
);
});
it("keeps access-group model chips unlinked while access-group names are loading", async () => {
vi.mocked(useModelAccessGroupNames).mockReturnValue(undefined);
const keyData = { ...MOCK_KEY_DATA, models: ["repro-access-group"] };
renderWithProviders(
<KeyInfoView keyData={keyData} onClose={() => {}} keyId="test-key-id" onKeyDataUpdate={() => {}} teams={[]} />,
);
expect(await screen.findAllByText("repro-access-group")).not.toHaveLength(0);
expect(screen.queryByRole("link", { name: "repro-access-group" })).not.toBeInTheDocument();
});
it("keeps the all-proxy-models grant chip non-clickable", async () => {
const keyData = { ...MOCK_KEY_DATA, models: ["all-proxy-models"] };
renderWithProviders(

View file

@ -1,4 +1,5 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useModelAccessGroupNames } from "@/app/(dashboard)/hooks/models/useModels";
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { useApplyUserBudgetToTeamKeys } from "@/app/(dashboard)/hooks/uiSettings/useApplyUserBudgetToTeamKeys";
@ -14,7 +15,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EntityLink } from "@/components/shared/EntityLink";
import { modelGroupHref, teamDetailHref } from "@/utils/entityLinks";
import { modelOrAccessGroupHref, teamDetailHref } from "@/utils/entityLinks";
import { BadgeLink } from "@/components/shared/BadgeLink";
import { KeyInfoHeader } from "./KeyInfoHeader";
import KeySavingsTab from "./KeySavingsTab";
@ -95,6 +96,7 @@ export default function KeyInfoView({
backButtonText = "Back to Keys",
}: KeyInfoViewProps) {
const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized();
const accessGroupNames = useModelAccessGroupNames();
const activityDateRange = useActivityDateRange();
const queryClient = useQueryClient();
const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole));
@ -752,7 +754,11 @@ export default function KeyInfoView({
<div className="mt-2 flex flex-wrap gap-2">
{currentKeyData.models && currentKeyData.models.length > 0 ? (
currentKeyData.models.map((model, index) => (
<BadgeLink key={index} href={modelGroupHref(model)} className="min-w-0 break-words">
<BadgeLink
key={index}
href={modelOrAccessGroupHref(model, accessGroupNames)}
className="min-w-0 break-words"
>
{model}
</BadgeLink>
))
@ -1104,7 +1110,11 @@ export default function KeyInfoView({
<div className="flex flex-wrap gap-2 mt-1">
{currentKeyData.models && currentKeyData.models.length > 0 ? (
currentKeyData.models.map((model, index) => (
<BadgeLink key={index} href={modelGroupHref(model)} className="min-w-0 break-words">
<BadgeLink
key={index}
href={modelOrAccessGroupHref(model, accessGroupNames)}
className="min-w-0 break-words"
>
{model}
</BadgeLink>
))

View file

@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
import { modelGroupHref, teamDetailHref, userDetailHref } from "./entityLinks";
import { accessGroupHref, modelGroupHref, modelOrAccessGroupHref, teamDetailHref, userDetailHref } from "./entityLinks";
describe("userDetailHref", () => {
it("targets the users page filtered to the encoded user id", () => {
@ -39,3 +39,30 @@ describe("modelGroupHref", () => {
},
);
});
describe("accessGroupHref", () => {
it("targets the models page filtered to the encoded access group", () => {
expect(accessGroupHref("a b/c")).toMatch(/\/models-and-endpoints\?access_group=a%20b%2Fc$/);
});
});
describe("modelOrAccessGroupHref", () => {
it("uses the access-group filter for a known access group", () => {
expect(modelOrAccessGroupHref("repro-access-group", new Set(["repro-access-group"]))).toMatch(
/\?access_group=repro-access-group$/,
);
});
it("uses the model-group filter for a name outside the access-group set", () => {
expect(modelOrAccessGroupHref("gpt-4.1", new Set(["repro-access-group"]))).toMatch(/\?model_group=gpt-4\.1$/);
});
it("keeps grant sentinels without a link unless they are access groups", () => {
expect(modelOrAccessGroupHref("all-team-models", new Set())).toBeUndefined();
});
it("keeps model names unlinked until access group names are available", () => {
expect(modelOrAccessGroupHref("repro-access-group", undefined)).toBeUndefined();
expect(modelOrAccessGroupHref("gpt-4.1", undefined)).toBeUndefined();
});
});

View file

@ -29,3 +29,15 @@ export function modelGroupHref(modelGroup: string): string | undefined {
if (MODEL_GRANT_SENTINELS.has(modelGroup)) return undefined;
return `${uiHref("models-and-endpoints")}?model_group=${encodeURIComponent(modelGroup)}`;
}
export function accessGroupHref(accessGroup: string): string {
return `${uiHref("models-and-endpoints")}?access_group=${encodeURIComponent(accessGroup)}`;
}
export function modelOrAccessGroupHref(
name: string,
accessGroupNames: ReadonlySet<string> | undefined,
): string | undefined {
if (accessGroupNames === undefined) return undefined;
return accessGroupNames.has(name) ? accessGroupHref(name) : modelGroupHref(name);
}