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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AllModelsTab {...defaultProps} selectedModelGroup="gpt-4" />);
|
||||
|
|
|
|||
|
|
@ -81,6 +81,11 @@ const AllModelsTab = ({
|
|||
}, [modelNameSearch, debouncedUpdateSearch]);
|
||||
|
||||
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(() => {
|
||||
if (sorting.length === 0) return undefined;
|
||||
|
|
@ -100,7 +105,7 @@ const AllModelsTab = ({
|
|||
} = useModelsInfo(
|
||||
pagination.pageIndex + 1,
|
||||
pagination.pageSize,
|
||||
debouncedSearch || undefined,
|
||||
searchForQuery,
|
||||
undefined,
|
||||
teamIdForQuery,
|
||||
sortBy,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { withNuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useModelDetailRouting } from "./detailNavigation";
|
||||
import { useModelDetailRouting, useModelGroupFilterRouting } from "./detailNavigation";
|
||||
|
||||
describe("useModelDetailRouting", () => {
|
||||
it("openModel sets ?model= with a history push", async () => {
|
||||
|
|
@ -54,3 +54,29 @@ describe("useModelDetailRouting", () => {
|
|||
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";
|
||||
|
||||
export interface ModelDetailRouting {
|
||||
|
|
@ -41,3 +41,22 @@ export function useModelDetailRouting(): ModelDetailRouting {
|
|||
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";
|
||||
|
||||
import { useState } from "react";
|
||||
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 { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
import {
|
||||
useModelDetailRouting,
|
||||
useModelGroupFilterRouting,
|
||||
} from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
|
||||
export default function AllModelsPanel() {
|
||||
const [selectedModelGroup, setSelectedModelGroup] = useState<string | null>(null);
|
||||
const { modelGroup, setModelGroup } = useModelGroupFilterRouting();
|
||||
const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData();
|
||||
const { openModel, openTeam } = useModelDetailRouting();
|
||||
|
||||
return (
|
||||
<AllModelsTab
|
||||
selectedModelGroup={selectedModelGroup}
|
||||
setSelectedModelGroup={setSelectedModelGroup}
|
||||
selectedModelGroup={modelGroup}
|
||||
setSelectedModelGroup={(group) => setModelGroup(group === ALL_MODEL_GROUPS_VALUE ? null : group)}
|
||||
availableModelGroups={availableModelGroups}
|
||||
availableModelAccessGroups={availableModelAccessGroups}
|
||||
setSelectedModelId={openModel}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
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";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
const toneClasses: Record<StatusTone, string[]> = {
|
||||
success: ["border-success/20", "bg-success/10", "text-success"],
|
||||
|
|
@ -39,4 +42,19 @@ describe("StatusBadge", () => {
|
|||
await user.hover(screen.getByText("Blocked"));
|
||||
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 { useEntityLinkClick } from "@/components/shared/EntityLink";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
|
|
@ -23,15 +24,17 @@ interface StatusBadgeProps {
|
|||
tooltip?: React.ReactNode;
|
||||
dataTestId?: string;
|
||||
className?: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ tone, label, tooltip, dataTestId, className }: StatusBadgeProps) {
|
||||
const badge = (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid={dataTestId}
|
||||
className={cn("whitespace-nowrap font-normal", TONE_CLASS[tone], className)}
|
||||
>
|
||||
export function StatusBadge({ tone, label, tooltip, dataTestId, className, href }: StatusBadgeProps) {
|
||||
const badgeClassName = cn("whitespace-nowrap font-normal", TONE_CLASS[tone], className);
|
||||
const badge = href ? (
|
||||
<LinkedStatusBadge href={href} dataTestId={dataTestId} className={badgeClassName}>
|
||||
{label}
|
||||
</LinkedStatusBadge>
|
||||
) : (
|
||||
<Badge variant="outline" data-testid={dataTestId} className={badgeClassName}>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
|
|
@ -41,3 +44,25 @@ export function StatusBadge({ tone, label, tooltip, dataTestId, className }: Sta
|
|||
}
|
||||
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", () => ({
|
||||
serverRootPath: "",
|
||||
teamInfoCall: vi.fn(),
|
||||
teamMemberDeleteCall: 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", () => {
|
||||
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 { 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 { Card } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Input as UIInput } from "@/components/ui/input";
|
||||
|
|
@ -53,6 +55,7 @@ import {
|
|||
computeTeamModelBadges,
|
||||
normalizeTeamModelSelection,
|
||||
TeamAccessGroupModelGrant,
|
||||
TeamModelBadge,
|
||||
TeamModelBadgeKind,
|
||||
} from "./teamModelAccess";
|
||||
import MetadataKeyValueFields, {
|
||||
|
|
@ -111,6 +114,9 @@ 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;
|
||||
|
||||
export interface TeamMembership {
|
||||
user_id: string;
|
||||
team_id: string;
|
||||
|
|
@ -1006,7 +1012,11 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
(badge, index) => (
|
||||
<SimpleTooltip key={`${badge.kind}-${badge.label}-${index}`} content={badge.tooltip}>
|
||||
<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>
|
||||
</SimpleTooltip>
|
||||
),
|
||||
|
|
@ -1727,9 +1737,9 @@ 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) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
<BadgeLink key={index} href={modelGroupHref(model)}>
|
||||
{model}
|
||||
</Badge>
|
||||
</BadgeLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1738,9 +1748,9 @@ 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) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
<BadgeLink key={index} href={modelGroupHref(model)}>
|
||||
{model}
|
||||
</Badge>
|
||||
</BadgeLink>
|
||||
))}
|
||||
</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 () => {
|
||||
renderWithProviders(
|
||||
<KeyInfoView
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import { Card } from "@/components/ui/card";
|
|||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
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 KeySavingsTab from "./KeySavingsTab";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
@ -660,9 +661,9 @@ export default function KeyInfoView({
|
|||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{currentKeyData.models && currentKeyData.models.length > 0 ? (
|
||||
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}
|
||||
</Badge>
|
||||
</BadgeLink>
|
||||
))
|
||||
) : (
|
||||
<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">
|
||||
{currentKeyData.models && currentKeyData.models.length > 0 ? (
|
||||
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}
|
||||
</span>
|
||||
</BadgeLink>
|
||||
))
|
||||
) : (
|
||||
<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";
|
||||
|
||||
const MODEL_GRANT_SENTINELS: ReadonlySet<string> = new Set([
|
||||
"all-proxy-models",
|
||||
"all-team-models",
|
||||
"no-default-models",
|
||||
]);
|
||||
|
||||
export function teamDetailHref(teamId: string): string {
|
||||
return `${migratedHref("teams")}?team=${encodeURIComponent(teamId)}`;
|
||||
}
|
||||
|
|
@ -15,3 +21,9 @@ export function userDetailHref(userId: string): string {
|
|||
export function orgDetailHref(orgId: string): string {
|
||||
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