mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
577d74c1ce
commit
5133485009
11 changed files with 295 additions and 13 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>({
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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: [] }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue