mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
feat(ui): link team and key model chips to the models page filtered to that group
Model chips on the team info and virtual key info pages (overview and settings tabs) now link to /models-and-endpoints?model_group=<name>, which the All Models tab reads through a new nuqs-backed model group filter. Grant sentinels such as all-proxy-models stay plain badges. The selected group is also sent as the server-side search so the matching deployments are fetched even when they are beyond the first page.
This commit is contained in:
parent
3300fc3a96
commit
56a80c8125
13 changed files with 244 additions and 26 deletions
|
|
@ -260,6 +260,27 @@ describe("AllModelsTab", () => {
|
||||||
expect(within(table).queryByText("gpt-4")).not.toBeInTheDocument();
|
expect(within(table).queryByText("gpt-4")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("queries the server for the selected model group so deployments beyond the first page are found", () => {
|
||||||
|
render(<AllModelsTab {...defaultProps} selectedModelGroup="claude-opus" />);
|
||||||
|
|
||||||
|
expect(lastModelsInfoCall().search).toBe("claude-opus");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["all", "wildcard"])("does not seed the server search from the %s pseudo group", (group) => {
|
||||||
|
render(<AllModelsTab {...defaultProps} selectedModelGroup={group} />);
|
||||||
|
|
||||||
|
expect(lastModelsInfoCall().search).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a typed search override the selected model group in the server query", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AllModelsTab {...defaultProps} selectedModelGroup="claude-opus" />);
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText("Search model names…"), "gpt");
|
||||||
|
|
||||||
|
await waitFor(() => expect(lastModelsInfoCall().search).toBe("gpt"));
|
||||||
|
});
|
||||||
|
|
||||||
it("resets search, filters, team and sorting from the drawer reset button", async () => {
|
it("resets search, filters, team and sorting from the drawer reset button", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AllModelsTab {...defaultProps} selectedModelGroup="gpt-4" />);
|
render(<AllModelsTab {...defaultProps} selectedModelGroup="gpt-4" />);
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,11 @@ const AllModelsTab = ({
|
||||||
}, [modelNameSearch, debouncedUpdateSearch]);
|
}, [modelNameSearch, debouncedUpdateSearch]);
|
||||||
|
|
||||||
const teamIdForQuery = selectedTeamValue === PERSONAL_TEAM_VALUE ? undefined : selectedTeamValue;
|
const teamIdForQuery = selectedTeamValue === PERSONAL_TEAM_VALUE ? undefined : selectedTeamValue;
|
||||||
|
const isConcreteModelGroup =
|
||||||
|
Boolean(selectedModelGroup) &&
|
||||||
|
selectedModelGroup !== ALL_MODEL_GROUPS_VALUE &&
|
||||||
|
selectedModelGroup !== WILDCARD_MODEL_GROUP_VALUE;
|
||||||
|
const searchForQuery = debouncedSearch || (isConcreteModelGroup ? selectedModelGroup ?? undefined : undefined);
|
||||||
|
|
||||||
const sortBy = useMemo(() => {
|
const sortBy = useMemo(() => {
|
||||||
if (sorting.length === 0) return undefined;
|
if (sorting.length === 0) return undefined;
|
||||||
|
|
@ -100,7 +105,7 @@ const AllModelsTab = ({
|
||||||
} = useModelsInfo(
|
} = useModelsInfo(
|
||||||
pagination.pageIndex + 1,
|
pagination.pageIndex + 1,
|
||||||
pagination.pageSize,
|
pagination.pageSize,
|
||||||
debouncedSearch || undefined,
|
searchForQuery,
|
||||||
undefined,
|
undefined,
|
||||||
teamIdForQuery,
|
teamIdForQuery,
|
||||||
sortBy,
|
sortBy,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { withNuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing";
|
import { withNuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { useModelDetailRouting } from "./detailNavigation";
|
import { useModelDetailRouting, useModelGroupFilterRouting } from "./detailNavigation";
|
||||||
|
|
||||||
describe("useModelDetailRouting", () => {
|
describe("useModelDetailRouting", () => {
|
||||||
it("openModel sets ?model= with a history push", async () => {
|
it("openModel sets ?model= with a history push", async () => {
|
||||||
|
|
@ -54,3 +54,29 @@ describe("useModelDetailRouting", () => {
|
||||||
expect(result.current.teamId).toBeNull();
|
expect(result.current.teamId).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("useModelGroupFilterRouting", () => {
|
||||||
|
it("reads the selected group from ?model_group=", () => {
|
||||||
|
const { result } = renderHook(() => useModelGroupFilterRouting(), {
|
||||||
|
wrapper: withNuqsTestingAdapter({ searchParams: "?model_group=gpt-4.1" }),
|
||||||
|
});
|
||||||
|
expect(result.current.modelGroup).toBe("gpt-4.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the selected group to ?model_group= and clears it on null", async () => {
|
||||||
|
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
|
||||||
|
const { result } = renderHook(() => useModelGroupFilterRouting(), {
|
||||||
|
wrapper: withNuqsTestingAdapter({ onUrlUpdate }),
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
result.current.setModelGroup("claude-sonnet-5");
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
|
||||||
|
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("model_group")).toBe("claude-sonnet-5");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.setModelGroup(null);
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has("model_group")).toBe(false));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { parseAsString, useQueryStates } from "nuqs";
|
import { parseAsString, useQueryState, useQueryStates } from "nuqs";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
export interface ModelDetailRouting {
|
export interface ModelDetailRouting {
|
||||||
|
|
@ -41,3 +41,22 @@ export function useModelDetailRouting(): ModelDetailRouting {
|
||||||
close,
|
close,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModelGroupFilterRouting {
|
||||||
|
modelGroup: string | null;
|
||||||
|
setModelGroup: (modelGroup: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `?model_group=` backs the All Models group filter so other pages can deep-link to one group. */
|
||||||
|
export function useModelGroupFilterRouting(): ModelGroupFilterRouting {
|
||||||
|
const [modelGroup, setParam] = useQueryState("model_group", parseAsString);
|
||||||
|
|
||||||
|
const setModelGroup = useCallback(
|
||||||
|
(next: string | null) => {
|
||||||
|
void setParam(next);
|
||||||
|
},
|
||||||
|
[setParam],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { modelGroup, setModelGroup };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import AllModelsTab from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTab";
|
import AllModelsTab from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTab";
|
||||||
|
import { ALL_MODEL_GROUPS_VALUE } from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTable";
|
||||||
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
|
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
|
||||||
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
import {
|
||||||
|
useModelDetailRouting,
|
||||||
|
useModelGroupFilterRouting,
|
||||||
|
} from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||||
|
|
||||||
export default function AllModelsPanel() {
|
export default function AllModelsPanel() {
|
||||||
const [selectedModelGroup, setSelectedModelGroup] = useState<string | null>(null);
|
const { modelGroup, setModelGroup } = useModelGroupFilterRouting();
|
||||||
const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData();
|
const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData();
|
||||||
const { openModel, openTeam } = useModelDetailRouting();
|
const { openModel, openTeam } = useModelDetailRouting();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AllModelsTab
|
<AllModelsTab
|
||||||
selectedModelGroup={selectedModelGroup}
|
selectedModelGroup={modelGroup}
|
||||||
setSelectedModelGroup={setSelectedModelGroup}
|
setSelectedModelGroup={(group) => setModelGroup(group === ALL_MODEL_GROUPS_VALUE ? null : group)}
|
||||||
availableModelGroups={availableModelGroups}
|
availableModelGroups={availableModelGroups}
|
||||||
availableModelAccessGroups={availableModelAccessGroups}
|
availableModelAccessGroups={availableModelAccessGroups}
|
||||||
setSelectedModelId={openModel}
|
setSelectedModelId={openModel}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { StatusBadge, type StatusTone } from "./status_badge";
|
import { StatusBadge, type StatusTone } from "./status_badge";
|
||||||
|
|
||||||
|
const push = vi.fn();
|
||||||
|
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
|
||||||
|
|
||||||
describe("StatusBadge", () => {
|
describe("StatusBadge", () => {
|
||||||
const toneClasses: Record<StatusTone, string[]> = {
|
const toneClasses: Record<StatusTone, string[]> = {
|
||||||
success: ["border-success/20", "bg-success/10", "text-success"],
|
success: ["border-success/20", "bg-success/10", "text-success"],
|
||||||
|
|
@ -39,4 +42,19 @@ describe("StatusBadge", () => {
|
||||||
await user.hover(screen.getByText("Blocked"));
|
await user.hover(screen.getByText("Blocked"));
|
||||||
expect(await screen.findByText("This key was blocked by SCIM")).toBeInTheDocument();
|
expect(await screen.findByText("This key was blocked by SCIM")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a tinted anchor that navigates client-side when href is given", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<StatusBadge tone="info" label="gpt-4.1" href="/models-and-endpoints?model_group=gpt-4.1" />);
|
||||||
|
const link = screen.getByRole("link", { name: "gpt-4.1" });
|
||||||
|
expect(link).toHaveAttribute("href", "/models-and-endpoints?model_group=gpt-4.1");
|
||||||
|
expect(link.className).toContain("text-info");
|
||||||
|
await user.click(link);
|
||||||
|
expect(push).toHaveBeenCalledWith("/models-and-endpoints?model_group=gpt-4.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders no anchor without an href", () => {
|
||||||
|
render(<StatusBadge tone="info" label="gpt-4.1" />);
|
||||||
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { useEntityLinkClick } from "@/components/shared/EntityLink";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/cva.config";
|
import { cn } from "@/lib/cva.config";
|
||||||
|
|
||||||
|
|
@ -23,15 +24,17 @@ interface StatusBadgeProps {
|
||||||
tooltip?: React.ReactNode;
|
tooltip?: React.ReactNode;
|
||||||
dataTestId?: string;
|
dataTestId?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
href?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBadge({ tone, label, tooltip, dataTestId, className }: StatusBadgeProps) {
|
export function StatusBadge({ tone, label, tooltip, dataTestId, className, href }: StatusBadgeProps) {
|
||||||
const badge = (
|
const badgeClassName = cn("whitespace-nowrap font-normal", TONE_CLASS[tone], className);
|
||||||
<Badge
|
const badge = href ? (
|
||||||
variant="outline"
|
<LinkedStatusBadge href={href} dataTestId={dataTestId} className={badgeClassName}>
|
||||||
data-testid={dataTestId}
|
{label}
|
||||||
className={cn("whitespace-nowrap font-normal", TONE_CLASS[tone], className)}
|
</LinkedStatusBadge>
|
||||||
>
|
) : (
|
||||||
|
<Badge variant="outline" data-testid={dataTestId} className={badgeClassName}>
|
||||||
{label}
|
{label}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|
@ -41,3 +44,25 @@ export function StatusBadge({ tone, label, tooltip, dataTestId, className }: Sta
|
||||||
}
|
}
|
||||||
return <CellTooltip content={tooltip} trigger={badge} />;
|
return <CellTooltip content={tooltip} trigger={badge} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LinkedStatusBadgeProps {
|
||||||
|
href: string;
|
||||||
|
dataTestId?: string;
|
||||||
|
className: string;
|
||||||
|
children: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkedStatusBadge({ href, dataTestId, className, children }: LinkedStatusBadgeProps) {
|
||||||
|
const handleClick = useEntityLinkClick(href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
data-testid={dataTestId}
|
||||||
|
className={cn("cursor-pointer hover:underline", className)}
|
||||||
|
render={<a href={href} onClick={handleClick} />}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||||
|
|
||||||
vi.mock("@/components/networking", () => ({
|
vi.mock("@/components/networking", () => ({
|
||||||
|
serverRootPath: "",
|
||||||
teamInfoCall: vi.fn(),
|
teamInfoCall: vi.fn(),
|
||||||
teamMemberDeleteCall: vi.fn(),
|
teamMemberDeleteCall: vi.fn(),
|
||||||
teamMemberAddCall: vi.fn(),
|
teamMemberAddCall: vi.fn(),
|
||||||
|
|
@ -278,6 +281,36 @@ describe("TeamInfoView", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("links direct and access-group model badges to the models page filtered to that group", async () => {
|
||||||
|
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||||
|
createMockTeamData({
|
||||||
|
models: ["gpt-4.1"],
|
||||||
|
access_group_models: ["claude-sonnet-5"],
|
||||||
|
access_group_details: [{ access_group_id: "ag-1", access_group_name: "prod", models: ["claude-sonnet-5"] }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProviders(<TeamInfoView {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("link", { name: "gpt-4.1" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
expect.stringContaining("/models-and-endpoints?model_group=gpt-4.1"),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("link", { name: "claude-sonnet-5" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
expect.stringContaining("/models-and-endpoints?model_group=claude-sonnet-5"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the all-proxy-models badge non-clickable", async () => {
|
||||||
|
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ models: ["all-proxy-models"] }));
|
||||||
|
|
||||||
|
renderWithProviders(<TeamInfoView {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("All proxy models")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("link", { name: "All proxy models" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("should display loading state while fetching team data", () => {
|
it("should display loading state while fetching team data", () => {
|
||||||
vi.mocked(networking.teamInfoCall).mockImplementation(() => new Promise(() => {}));
|
vi.mocked(networking.teamInfoCall).mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ import type { ObjectPermission } from "@/components/object_permission_types";
|
||||||
import { isProxyAdminRole } from "@/utils/roles";
|
import { isProxyAdminRole } from "@/utils/roles";
|
||||||
import { ArrowLeftIcon } from "@heroicons/react/outline";
|
import { ArrowLeftIcon } from "@heroicons/react/outline";
|
||||||
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge";
|
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge";
|
||||||
|
import { BadgeLink } from "@/components/shared/BadgeLink";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { modelGroupHref } from "@/utils/entityLinks";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { Input as UIInput } from "@/components/ui/input";
|
import { Input as UIInput } from "@/components/ui/input";
|
||||||
|
|
@ -53,6 +55,7 @@ import {
|
||||||
computeTeamModelBadges,
|
computeTeamModelBadges,
|
||||||
normalizeTeamModelSelection,
|
normalizeTeamModelSelection,
|
||||||
TeamAccessGroupModelGrant,
|
TeamAccessGroupModelGrant,
|
||||||
|
TeamModelBadge,
|
||||||
TeamModelBadgeKind,
|
TeamModelBadgeKind,
|
||||||
} from "./teamModelAccess";
|
} from "./teamModelAccess";
|
||||||
import MetadataKeyValueFields, {
|
import MetadataKeyValueFields, {
|
||||||
|
|
@ -111,6 +114,9 @@ const TEAM_MODEL_BADGE_TONES: Record<TeamModelBadgeKind, StatusTone> = {
|
||||||
"access-group": "success",
|
"access-group": "success",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const teamModelBadgeHref = (badge: TeamModelBadge): string | undefined =>
|
||||||
|
badge.kind === "direct" || badge.kind === "access-group" ? modelGroupHref(badge.label) : undefined;
|
||||||
|
|
||||||
export interface TeamMembership {
|
export interface TeamMembership {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
team_id: string;
|
team_id: string;
|
||||||
|
|
@ -1006,7 +1012,11 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||||
(badge, index) => (
|
(badge, index) => (
|
||||||
<SimpleTooltip key={`${badge.kind}-${badge.label}-${index}`} content={badge.tooltip}>
|
<SimpleTooltip key={`${badge.kind}-${badge.label}-${index}`} content={badge.tooltip}>
|
||||||
<span>
|
<span>
|
||||||
<StatusBadge tone={TEAM_MODEL_BADGE_TONES[badge.kind]} label={badge.label} />
|
<StatusBadge
|
||||||
|
tone={TEAM_MODEL_BADGE_TONES[badge.kind]}
|
||||||
|
label={badge.label}
|
||||||
|
href={teamModelBadgeHref(badge)}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</SimpleTooltip>
|
</SimpleTooltip>
|
||||||
),
|
),
|
||||||
|
|
@ -1727,9 +1737,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||||
<p className="font-medium">Models</p>
|
<p className="font-medium">Models</p>
|
||||||
<div className="flex flex-wrap gap-2 mt-1">
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
{info.models.map((model, index) => (
|
{info.models.map((model, index) => (
|
||||||
<Badge key={index} variant="secondary">
|
<BadgeLink key={index} href={modelGroupHref(model)}>
|
||||||
{model}
|
{model}
|
||||||
</Badge>
|
</BadgeLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1738,9 +1748,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||||
<p className="font-medium">Default Member Models</p>
|
<p className="font-medium">Default Member Models</p>
|
||||||
<div className="flex flex-wrap gap-2 mt-1">
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
{info.default_team_member_models.map((model, index) => (
|
{info.default_team_member_models.map((model, index) => (
|
||||||
<Badge key={index} variant="secondary">
|
<BadgeLink key={index} href={modelGroupHref(model)}>
|
||||||
{model}
|
{model}
|
||||||
</Badge>
|
</BadgeLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -561,6 +561,32 @@ describe("KeyInfoView", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("links each model chip to the models page filtered to that model group", async () => {
|
||||||
|
const keyData = { ...MOCK_KEY_DATA, models: ["gpt-4.1", "anthropic/*"] };
|
||||||
|
renderWithProviders(
|
||||||
|
<KeyInfoView keyData={keyData} onClose={() => {}} keyId="test-key-id" onKeyDataUpdate={() => {}} teams={[]} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("link", { name: "gpt-4.1" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
expect.stringContaining("/models-and-endpoints?model_group=gpt-4.1"),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("link", { name: "anthropic/*" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
expect.stringContaining("/models-and-endpoints?model_group=anthropic%2F*"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the all-proxy-models grant chip non-clickable", async () => {
|
||||||
|
const keyData = { ...MOCK_KEY_DATA, models: ["all-proxy-models"] };
|
||||||
|
renderWithProviders(
|
||||||
|
<KeyInfoView keyData={keyData} onClose={() => {}} keyId="test-key-id" onKeyDataUpdate={() => {}} teams={[]} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await screen.findAllByText("all-proxy-models")).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByRole("link", { name: "all-proxy-models" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders no team link when the key has no team", async () => {
|
it("renders no team link when the key has no team", async () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<KeyInfoView
|
<KeyInfoView
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ import { Card } from "@/components/ui/card";
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { EntityLink } from "@/components/shared/EntityLink";
|
import { EntityLink } from "@/components/shared/EntityLink";
|
||||||
import { teamDetailHref } from "@/utils/entityLinks";
|
import { modelGroupHref, teamDetailHref } from "@/utils/entityLinks";
|
||||||
|
import { BadgeLink } from "@/components/shared/BadgeLink";
|
||||||
import { KeyInfoHeader } from "./KeyInfoHeader";
|
import { KeyInfoHeader } from "./KeyInfoHeader";
|
||||||
import KeySavingsTab from "./KeySavingsTab";
|
import KeySavingsTab from "./KeySavingsTab";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
@ -660,9 +661,9 @@ export default function KeyInfoView({
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
{currentKeyData.models && currentKeyData.models.length > 0 ? (
|
{currentKeyData.models && currentKeyData.models.length > 0 ? (
|
||||||
currentKeyData.models.map((model, index) => (
|
currentKeyData.models.map((model, index) => (
|
||||||
<Badge key={index} variant="secondary" className="min-w-0 break-words">
|
<BadgeLink key={index} href={modelGroupHref(model)} className="min-w-0 break-words">
|
||||||
{model}
|
{model}
|
||||||
</Badge>
|
</BadgeLink>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm">No models specified</p>
|
<p className="text-sm">No models specified</p>
|
||||||
|
|
@ -996,9 +997,9 @@ export default function KeyInfoView({
|
||||||
<div className="flex flex-wrap gap-2 mt-1">
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
{currentKeyData.models && currentKeyData.models.length > 0 ? (
|
{currentKeyData.models && currentKeyData.models.length > 0 ? (
|
||||||
currentKeyData.models.map((model, index) => (
|
currentKeyData.models.map((model, index) => (
|
||||||
<span key={index} className="px-2 py-1 bg-info/15 rounded-sm text-xs">
|
<BadgeLink key={index} href={modelGroupHref(model)} className="min-w-0 break-words">
|
||||||
{model}
|
{model}
|
||||||
</span>
|
</BadgeLink>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm">No models specified</p>
|
<p className="text-sm">No models specified</p>
|
||||||
|
|
|
||||||
19
ui/litellm-dashboard/src/utils/entityLinks.test.ts
Normal file
19
ui/litellm-dashboard/src/utils/entityLinks.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
|
||||||
|
|
||||||
|
import { modelGroupHref } from "./entityLinks";
|
||||||
|
|
||||||
|
describe("modelGroupHref", () => {
|
||||||
|
it("targets the models page filtered to the encoded model group", () => {
|
||||||
|
expect(modelGroupHref("gpt-4.1")).toMatch(/\/models-and-endpoints\?model_group=gpt-4\.1$/);
|
||||||
|
expect(modelGroupHref("openai/*")).toMatch(/\?model_group=openai%2F\*$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["all-proxy-models", "all-team-models", "no-default-models"])(
|
||||||
|
"returns no href for the %s grant sentinel",
|
||||||
|
(sentinel) => {
|
||||||
|
expect(modelGroupHref(sentinel)).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { migratedHref } from "@/utils/migratedPages";
|
import { migratedHref } from "@/utils/migratedPages";
|
||||||
|
|
||||||
|
const MODEL_GRANT_SENTINELS: ReadonlySet<string> = new Set([
|
||||||
|
"all-proxy-models",
|
||||||
|
"all-team-models",
|
||||||
|
"no-default-models",
|
||||||
|
]);
|
||||||
|
|
||||||
export function teamDetailHref(teamId: string): string {
|
export function teamDetailHref(teamId: string): string {
|
||||||
return `${migratedHref("teams")}?team=${encodeURIComponent(teamId)}`;
|
return `${migratedHref("teams")}?team=${encodeURIComponent(teamId)}`;
|
||||||
}
|
}
|
||||||
|
|
@ -15,3 +21,9 @@ export function userDetailHref(userId: string): string {
|
||||||
export function orgDetailHref(orgId: string): string {
|
export function orgDetailHref(orgId: string): string {
|
||||||
return `${migratedHref("organizations")}?org=${encodeURIComponent(orgId)}`;
|
return `${migratedHref("organizations")}?org=${encodeURIComponent(orgId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Models page filtered to one model group; undefined for grant sentinels that name no deployment. */
|
||||||
|
export function modelGroupHref(modelGroup: string): string | undefined {
|
||||||
|
if (MODEL_GRANT_SENTINELS.has(modelGroup)) return undefined;
|
||||||
|
return `${migratedHref("models-and-endpoints")}?model_group=${encodeURIComponent(modelGroup)}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue