From 56a80c812561782af6a3a9903e37a73b606bc671 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 27 Aug 2026 21:08:33 -0700 Subject: [PATCH] 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=, 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. --- .../components/AllModelsTab.test.tsx | 21 ++++++++++ .../components/AllModelsTab.tsx | 7 +++- .../detailNavigation.test.ts | 28 ++++++++++++- .../models-and-endpoints/detailNavigation.ts | 21 +++++++++- .../panels/AllModelsPanel.tsx | 13 ++++--- .../shared/table_cells/status_badge.test.tsx | 20 +++++++++- .../shared/table_cells/status_badge.tsx | 39 +++++++++++++++---- .../src/components/team/TeamInfo.test.tsx | 33 ++++++++++++++++ .../src/components/team/TeamInfo.tsx | 20 +++++++--- .../templates/key_info_view.test.tsx | 26 +++++++++++++ .../components/templates/key_info_view.tsx | 11 +++--- .../src/utils/entityLinks.test.ts | 19 +++++++++ ui/litellm-dashboard/src/utils/entityLinks.ts | 12 ++++++ 13 files changed, 244 insertions(+), 26 deletions(-) create mode 100644 ui/litellm-dashboard/src/utils/entityLinks.test.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx index 4d0b1c466a4..29163108442 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx @@ -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(); + + expect(lastModelsInfoCall().search).toBe("claude-opus"); + }); + + it.each(["all", "wildcard"])("does not seed the server search from the %s pseudo group", (group) => { + render(); + + expect(lastModelsInfoCall().search).toBeUndefined(); + }); + + it("lets a typed search override the selected model group in the server query", async () => { + const user = userEvent.setup(); + render(); + + 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(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index 1a9d33a50bc..a710940e659 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts index 292b27618bd..c4ea206022c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts @@ -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)); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts index 2cfad341d25..16d031c52c1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts @@ -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 }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AllModelsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AllModelsPanel.tsx index 9d40ea32185..552a4f57b24 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AllModelsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AllModelsPanel.tsx @@ -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(null); + const { modelGroup, setModelGroup } = useModelGroupFilterRouting(); const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData(); const { openModel, openTeam } = useModelDetailRouting(); return ( setModelGroup(group === ALL_MODEL_GROUPS_VALUE ? null : group)} availableModelGroups={availableModelGroups} availableModelAccessGroups={availableModelAccessGroups} setSelectedModelId={openModel} diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.test.tsx index ae8bd87749b..9eddbff1950 100644 --- a/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.test.tsx @@ -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 = { 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(); + 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(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.tsx index cc7f32d0bd6..7e041038cfa 100644 --- a/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.tsx +++ b/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.tsx @@ -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 = ( - +export function StatusBadge({ tone, label, tooltip, dataTestId, className, href }: StatusBadgeProps) { + const badgeClassName = cn("whitespace-nowrap font-normal", TONE_CLASS[tone], className); + const badge = href ? ( + + {label} + + ) : ( + {label} ); @@ -41,3 +44,25 @@ export function StatusBadge({ tone, label, tooltip, dataTestId, className }: Sta } return ; } + +interface LinkedStatusBadgeProps { + href: string; + dataTestId?: string; + className: string; + children: string; +} + +function LinkedStatusBadge({ href, dataTestId, className, children }: LinkedStatusBadgeProps) { + const handleClick = useEntityLinkClick(href); + + return ( + } + > + {children} + + ); +} diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index d1978ca751f..ca1e0413dcb 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -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(); + + 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(); + + 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(() => {})); diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 6be1054c7fe..44f0d420fd6 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -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 = { "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 = ({ (badge, index) => ( - + ), @@ -1727,9 +1737,9 @@ const TeamInfoView: React.FC = ({

Models

{info.models.map((model, index) => ( - + {model} - + ))}
@@ -1738,9 +1748,9 @@ 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/key_info_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx index 0d41d199b22..7817d4e7cea 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 @@ -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( + {}} 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( + {}} 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( {currentKeyData.models && currentKeyData.models.length > 0 ? ( currentKeyData.models.map((model, index) => ( - + {model} - + )) ) : (

No models specified

@@ -996,9 +997,9 @@ export default function KeyInfoView({
{currentKeyData.models && currentKeyData.models.length > 0 ? ( currentKeyData.models.map((model, index) => ( - + {model} - + )) ) : (

No models specified

diff --git a/ui/litellm-dashboard/src/utils/entityLinks.test.ts b/ui/litellm-dashboard/src/utils/entityLinks.test.ts new file mode 100644 index 00000000000..47161a903ed --- /dev/null +++ b/ui/litellm-dashboard/src/utils/entityLinks.test.ts @@ -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(); + }, + ); +}); diff --git a/ui/litellm-dashboard/src/utils/entityLinks.ts b/ui/litellm-dashboard/src/utils/entityLinks.ts index 675ac8d0554..ced9bfb5395 100644 --- a/ui/litellm-dashboard/src/utils/entityLinks.ts +++ b/ui/litellm-dashboard/src/utils/entityLinks.ts @@ -1,5 +1,11 @@ import { migratedHref } from "@/utils/migratedPages"; +const MODEL_GRANT_SENTINELS: ReadonlySet = 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)}`; +}