From 5133485009c952c871405cf64954f0814dff745a Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:32:33 -0700 Subject: [PATCH] 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 Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../hooks/models/useModels.test.ts | 124 +++++++++++++++++- .../app/(dashboard)/hooks/models/useModels.ts | 27 ++++ .../page.integration.test.tsx | 1 + .../team/TeamInfo.integration.test.tsx | 1 + .../src/components/team/TeamInfo.test.tsx | 34 ++++- .../src/components/team/TeamInfo.tsx | 20 ++- .../KeyInfoView.handleKeyUpdate.test.tsx | 4 + .../templates/key_info_view.test.tsx | 40 +++++- .../components/templates/key_info_view.tsx | 16 ++- .../src/utils/entityLinks.test.ts | 29 +++- ui/litellm-dashboard/src/utils/entityLinks.ts | 12 ++ 11 files changed, 295 insertions(+), 13 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts index 6489bc2171d..09a0f214da3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts @@ -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(() => 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; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts index b5cc329d4c9..8bc2a50b4d8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts @@ -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 => { }); }; +export const useModelAccessGroupNames = (): ReadonlySet | undefined => { + const { accessToken, userId, userRole } = useAuthorized(); + const { data, isError } = useQuery({ + 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(); + return undefined; + }, [data, isError]); +}; + export const useSelectedTeamModels = (teamID: string | null) => { const { accessToken, userId, userRole } = useAuthorized(); return useQuery({ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx index 865cc808627..86b5659d570 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx @@ -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()), })); vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.integration.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.integration.test.tsx index 456dc91b13f..3335ebf965a 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.integration.test.tsx @@ -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()), })); vi.mock("@/app/(dashboard)/hooks/teams/useTeams", async (importOriginal) => ({ diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index d721bdab2a5..b9df537884a 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -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()), })); 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(); + + 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()); diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 4c62423a474..badfbee0cf1 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -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 = { "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 | 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 } @@ -622,6 +629,7 @@ const TeamInfoView: React.FC = ({ const routerSettingsRef = React.useRef(null); const [organization, setOrganization] = useState(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 = ({ @@ -2191,7 +2199,7 @@ const TeamInfoView: React.FC = ({

Models

{info.models.map((model, index) => ( - + {model} ))} @@ -2202,7 +2210,7 @@ const TeamInfoView: React.FC = ({

Default Member Models

{info.default_team_member_models.map((model, index) => ( - + {model} ))} diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index 09042dea930..88f7fbb9f8c 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -23,6 +23,10 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: mockUseAuthorized, })); +vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ + useModelAccessGroupNames: vi.fn(() => new Set()), +})); + vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ useOrganizations: () => ({ data: [] }), })); diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx index 48f2f47ceda..f4e1fe288ec 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx @@ -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()), +})); + +import { useModelAccessGroupNames } from "@/app/(dashboard)/hooks/models/useModels"; + const editViewMocks = vi.hoisted(() => ({ onSubmit: undefined as ((v: Record) => Promise) | 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( + {}} 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( + {}} 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( diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index cfb5e9fa1f8..04a792bb5ca 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -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({
{currentKeyData.models && currentKeyData.models.length > 0 ? ( currentKeyData.models.map((model, index) => ( - + {model} )) @@ -1104,7 +1110,11 @@ export default function KeyInfoView({
{currentKeyData.models && currentKeyData.models.length > 0 ? ( currentKeyData.models.map((model, index) => ( - + {model} )) diff --git a/ui/litellm-dashboard/src/utils/entityLinks.test.ts b/ui/litellm-dashboard/src/utils/entityLinks.test.ts index 231413b213a..af5460635d8 100644 --- a/ui/litellm-dashboard/src/utils/entityLinks.test.ts +++ b/ui/litellm-dashboard/src/utils/entityLinks.test.ts @@ -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(); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/entityLinks.ts b/ui/litellm-dashboard/src/utils/entityLinks.ts index 33f2aa34976..69d47a8f497 100644 --- a/ui/litellm-dashboard/src/utils/entityLinks.ts +++ b/ui/litellm-dashboard/src/utils/entityLinks.ts @@ -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 | undefined, +): string | undefined { + if (accessGroupNames === undefined) return undefined; + return accessGroupNames.has(name) ? accessGroupHref(name) : modelGroupHref(name); +}