From 56a80c812561782af6a3a9903e37a73b606bc671 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 27 Aug 2026 21:08:33 -0700 Subject: [PATCH 1/6] 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)}`; +} From fe63ebdb193e557bbd8ce9679633d93f3dea3eb8 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 29 Aug 2026 10:18:06 -0700 Subject: [PATCH 2/6] fix(ui): filter the models page by exact model group instead of substring search Pass the selected group as the exact model= param on /v2/model/info rather than as the substring search, so a group like gpt-4 no longer pulls gpt-4o rows into the page and count. Drop two comments that restated helper behavior. --- .../app/(dashboard)/hooks/models/useModels.ts | 3 ++ .../components/AllModelsTab.test.tsx | 30 +++++++------- .../components/AllModelsTab.tsx | 5 ++- .../models-and-endpoints/detailNavigation.ts | 1 - .../src/components/networking.test.ts | 39 +++++++++++++++++++ .../src/components/networking.tsx | 4 ++ ui/litellm-dashboard/src/utils/entityLinks.ts | 1 - 7 files changed, 66 insertions(+), 17 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts index a5fbc433ea3..a9f7c54698a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts @@ -38,6 +38,7 @@ export const useModelsInfo = ( sortBy?: string, sortOrder?: string, excludeAutoRouters: boolean = false, + modelName?: string, ) => { const { accessToken, userId, userRole } = useAuthorized(); return useQuery({ @@ -48,6 +49,7 @@ export const useModelsInfo = ( page, size, ...(search && { search }), + ...(modelName && { modelName }), ...(modelId && { modelId }), ...(teamId && { teamId }), ...(sortBy && { sortBy }), @@ -70,6 +72,7 @@ export const useModelsInfo = ( sortBy, sortOrder, excludeAutoRouters, + modelName, ), enabled: Boolean(accessToken && userId && userRole), }); 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 29163108442..65faa85e29e 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 @@ -33,6 +33,7 @@ interface ModelsInfoArgs { teamId?: string; sortBy?: string; sortOrder?: string; + modelName?: string; } const modelsInfoCalls: ModelsInfoArgs[] = []; @@ -47,12 +48,14 @@ type UseModelsInfoArgs = [ teamId?: string, sortBy?: string, sortOrder?: string, + excludeAutoRouters?: boolean, + modelName?: string, ]; vi.mock("../../hooks/models/useModels", () => ({ useModelsInfo: (...args: UseModelsInfoArgs) => { - const [page, size, search, , teamId, sortBy, sortOrder] = args; - const call: ModelsInfoArgs = { page, size, search, teamId, sortBy, sortOrder }; + const [page, size, search, , teamId, sortBy, sortOrder, , modelName] = args; + const call: ModelsInfoArgs = { page, size, search, teamId, sortBy, sortOrder, modelName }; modelsInfoCalls.push(call); return { ...modelsInfoResult, refetch: mockRefetch }; }, @@ -260,25 +263,26 @@ 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", () => { + it("asks the server for the exact 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().modelName).toBe("claude-opus"); expect(lastModelsInfoCall().search).toBeUndefined(); }); - it("lets a typed search override the selected model group in the server query", async () => { - const user = userEvent.setup(); + it.each(["all", "wildcard"])("sends no exact model name for the %s pseudo group", (group) => { + render(); + + expect(lastModelsInfoCall().modelName).toBeUndefined(); + }); + + it("keeps the exact model group alongside a typed search", async () => { render(); - await user.type(screen.getByPlaceholderText("Search model names…"), "gpt"); + fireEvent.change(screen.getByPlaceholderText("Search model names…"), { target: { value: "opus" } }); - await waitFor(() => expect(lastModelsInfoCall().search).toBe("gpt")); + await waitFor(() => expect(lastModelsInfoCall().search).toBe("opus")); + expect(lastModelsInfoCall().modelName).toBe("claude-opus"); }); it("resets search, filters, team and sorting from the drawer reset button", async () => { 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 a710940e659..be2cf22d71a 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 @@ -85,7 +85,7 @@ const AllModelsTab = ({ Boolean(selectedModelGroup) && selectedModelGroup !== ALL_MODEL_GROUPS_VALUE && selectedModelGroup !== WILDCARD_MODEL_GROUP_VALUE; - const searchForQuery = debouncedSearch || (isConcreteModelGroup ? selectedModelGroup ?? undefined : undefined); + const modelNameForQuery = isConcreteModelGroup ? selectedModelGroup ?? undefined : undefined; const sortBy = useMemo(() => { if (sorting.length === 0) return undefined; @@ -105,7 +105,7 @@ const AllModelsTab = ({ } = useModelsInfo( pagination.pageIndex + 1, pagination.pageSize, - searchForQuery, + debouncedSearch || undefined, undefined, teamIdForQuery, sortBy, @@ -113,6 +113,7 @@ const AllModelsTab = ({ // Auto-routers are routing constructs, not deployments; the sibling Auto-Routers tab // lists and manages them. Excluded server-side so total_count stays honest. true, + modelNameForQuery, ); const isLoading = isLoadingModelsInfo || isLoadingModelCostMap; 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 16d031c52c1..e83a81a53cf 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 @@ -47,7 +47,6 @@ export interface ModelGroupFilterRouting { 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); diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index cd22935a66f..71296bc1dfd 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -104,6 +104,45 @@ describe("loginCall - storeLoginToken integration", () => { }); }); +describe("modelInfoCall", () => { + let currentFetch: typeof global.fetch; + + beforeEach(() => { + currentFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = currentFetch; + }); + + it("sends the exact model name as the model query param and leaves search alone", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ data: [] }) } as any); + global.fetch = mockFetch as any; + + await Networking.modelInfoCall( + "token", + "user", + "Admin", + 2, + 25, + undefined, + undefined, + undefined, + undefined, + undefined, + true, + "gpt-4", + ); + + const parsed = new URL(mockFetch.mock.calls[0][0] as string, "http://example.com"); + expect(parsed.pathname).toBe("/v2/model/info"); + expect(parsed.searchParams.get("model")).toBe("gpt-4"); + expect(parsed.searchParams.has("search")).toBe(false); + expect(parsed.searchParams.get("page")).toBe("2"); + expect(parsed.searchParams.get("exclude_auto_routers")).toBe("true"); + }); +}); + describe("daily activity helpers", () => { const startTime = new Date("2025-02-12T00:00:00.000Z"); const endTime = new Date("2025-02-19T00:00:00.000Z"); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index f7368f3957d..c95d57dec3f 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1656,6 +1656,7 @@ export const modelInfoCall = async ( sortBy?: string, sortOrder?: string, excludeAutoRouters?: boolean, + modelName?: string, ) => { /** * Get all models on proxy @@ -1669,6 +1670,9 @@ export const modelInfoCall = async ( if (search && search.trim()) { params.append("search", search.trim()); } + if (modelName && modelName.trim()) { + params.append("model", modelName.trim()); + } if (modelId && modelId.trim()) { params.append("modelId", modelId.trim()); } diff --git a/ui/litellm-dashboard/src/utils/entityLinks.ts b/ui/litellm-dashboard/src/utils/entityLinks.ts index ced9bfb5395..ad257ec7969 100644 --- a/ui/litellm-dashboard/src/utils/entityLinks.ts +++ b/ui/litellm-dashboard/src/utils/entityLinks.ts @@ -22,7 +22,6 @@ 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)}`; From 4411562a0f98626bfc42c3e91804b511b25ad700 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 29 Aug 2026 10:32:29 -0700 Subject: [PATCH 3/6] fix(ui): satisfy lint and the modelInfoCall arity in the models hook tests --- .../src/app/(dashboard)/hooks/models/useModels.test.ts | 2 ++ .../src/components/shared/table_cells/status_badge.test.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts index 411e8402e11..7231c126a63 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts @@ -118,6 +118,7 @@ describe("useModelsInfo", () => { // exclude_auto_routers defaults off: only the Models + Endpoints table opts in, so // every other consumer of this hook keeps seeing auto-routers. false, + undefined, ); expect(modelInfoCall).toHaveBeenCalledTimes(1); }); @@ -145,6 +146,7 @@ describe("useModelsInfo", () => { // exclude_auto_routers defaults off: only the Models + Endpoints table opts in, so // every other consumer of this hook keeps seeing auto-routers. false, + undefined, ); }); 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 9eddbff1950..916a637f31b 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 @@ -48,7 +48,7 @@ describe("StatusBadge", () => { 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"); + expect(link).toHaveClass("text-info"); await user.click(link); expect(push).toHaveBeenCalledWith("/models-and-endpoints?model_group=gpt-4.1"); }); From 3e99ee8d0e226a363b36a0f5402d60f3ab5dfb5f Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 29 Aug 2026 10:32:29 -0700 Subject: [PATCH 4/6] fix(proxy): scope the DB-side model search by the exact model= filter With model=&search=, the router list was narrowed to the group but the DB query only matched the substring, so other groups' rows leaked into the page and total_count. --- litellm/proxy/proxy_server.py | 14 ++++++- tests/test_litellm/proxy/test_proxy_server.py | 37 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 7ea6fdee6a5..6e6f711f468 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12768,6 +12768,7 @@ async def _fetch_db_models_for_search( size: int, sort_by: str | None, is_byok_outside_caller_teams: Callable[[dict[str, JsonValue]], bool], + model_name: str | None = None, ) -> tuple[list[dict[str, Any]], int]: """ Run the bounded DB query that backs `/v2/model/info?search=`. Returns @@ -12784,7 +12785,11 @@ async def _fetch_db_models_for_search( filter for `team_public_model_name` instead and keep the DB cost bounded by `search`. """ - db_where_condition: Final[dict[str, Any]] = {"model_name": {"contains": search_lower, "mode": "insensitive"}} + exact_name_filter: Final[dict[str, Any]] = {} if model_name is None else {"AND": [{"model_name": model_name}]} + db_where_condition: Final[dict[str, Any]] = { + "model_name": {"contains": search_lower, "mode": "insensitive"}, + **exact_name_filter, + } if db_model_ids_in_router: db_where_condition["model_id"] = {"not": {"in": list(db_model_ids_in_router)}} @@ -12831,6 +12836,7 @@ async def _apply_search_filter_to_models( page: int = 1, size: int = 50, sort_by: str | None = None, + model_name: str | None = None, ) -> tuple[list[dict[str, Any]], int | None]: """ Apply search filter to models, querying database for additional matching models. @@ -12851,6 +12857,10 @@ async def _apply_search_filter_to_models( sort_by: Sort field. When set, results must be sorted across the full match set, so the DB fetch is capped at ``_SORTED_SEARCH_DB_FETCH_CAP`` instead of one page. + model_name: Exact ``model_name`` the caller already narrowed + ``all_models`` to (``?model=``). The DB query honours it too, + otherwise rows from other model groups leak into the result + and the count. Returns: Tuple of (filtered_models, total_count). total_count is None if not searching. @@ -12920,6 +12930,7 @@ async def _apply_search_filter_to_models( size=size, sort_by=sort_by, is_byok_outside_caller_teams=_is_byok_outside_caller_teams, + model_name=model_name, ) search_total_count = router_models_count + db_models_total_count except Exception as e: @@ -13485,6 +13496,7 @@ async def model_info_v2( page=page, size=size, sort_by=sortBy, + model_name=model, ) if user_models_only: diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 8e470cc663b..fa8d6d8df41 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -2126,6 +2126,43 @@ async def test_apply_search_filter_bounds_db_fetch_by_page_and_cap(): assert take < 10_000, "sorted search must cap below the full match set" +@pytest.mark.asyncio +async def test_apply_search_filter_honours_exact_model_name_in_db_query(): + """ + `/v2/model/info?model=&search=`: the router list is already + narrowed to the exact group, so the DB count and fetch must be too, or + other groups' rows leak into the page and inflate total_count. + """ + from litellm.proxy.proxy_server import _apply_search_filter_to_models + + prisma_client = MagicMock() + prisma_client.db.litellm_proxymodeltable.count = AsyncMock(return_value=0) + prisma_client.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[]) + proxy_config = MagicMock() + proxy_config.decrypt_model_list_from_db = lambda rows: [] + + await _apply_search_filter_to_models( + all_models=[], + search="sonnet", + prisma_client=prisma_client, + proxy_config=proxy_config, + model_name="anthropic-sonnet-5", + ) + where = prisma_client.db.litellm_proxymodeltable.count.call_args.kwargs["where"] + assert where["AND"] == [{"model_name": "anthropic-sonnet-5"}] + assert where["model_name"] == {"contains": "sonnet", "mode": "insensitive"} + assert prisma_client.db.litellm_proxymodeltable.find_many.call_args.kwargs["where"] == where + + prisma_client.db.litellm_proxymodeltable.count.reset_mock() + await _apply_search_filter_to_models( + all_models=[], + search="sonnet", + prisma_client=prisma_client, + proxy_config=proxy_config, + ) + assert "AND" not in prisma_client.db.litellm_proxymodeltable.count.call_args.kwargs["where"] + + @pytest.mark.asyncio async def test_filter_models_by_team_id_excludes_viewer_direct_access(): """ From 9beb5ead4d10854f298bd1b0b281b4c08cf739d1 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 29 Aug 2026 10:40:39 -0700 Subject: [PATCH 5/6] fix(proxy): keep the exact model= DB predicate within the type-discipline budget The where clause now uses the exact name string directly and skips the DB query when the typed search cannot occur in that name, so no new mutable literals are added (LIT002 gate). --- litellm/proxy/proxy_server.py | 14 +++++++------- tests/test_litellm/proxy/test_proxy_server.py | 16 +++++++++++++--- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 6e6f711f468..b398bd7e37e 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12785,10 +12785,8 @@ async def _fetch_db_models_for_search( filter for `team_public_model_name` instead and keep the DB cost bounded by `search`. """ - exact_name_filter: Final[dict[str, Any]] = {} if model_name is None else {"AND": [{"model_name": model_name}]} db_where_condition: Final[dict[str, Any]] = { - "model_name": {"contains": search_lower, "mode": "insensitive"}, - **exact_name_filter, + "model_name": {"contains": search_lower, "mode": "insensitive"} if model_name is None else model_name } if db_model_ids_in_router: db_where_condition["model_id"] = {"not": {"in": list(db_model_ids_in_router)}} @@ -12858,9 +12856,10 @@ async def _apply_search_filter_to_models( full match set, so the DB fetch is capped at ``_SORTED_SEARCH_DB_FETCH_CAP`` instead of one page. model_name: Exact ``model_name`` the caller already narrowed - ``all_models`` to (``?model=``). The DB query honours it too, - otherwise rows from other model groups leak into the result - and the count. + ``all_models`` to (``?model=``). The DB query matches it + exactly instead of the substring, and is skipped when the + substring cannot occur in it, otherwise rows from other model + groups leak into the result and the count. Returns: Tuple of (filtered_models, total_count). total_count is None if not searching. @@ -12918,7 +12917,8 @@ async def _apply_search_filter_to_models( # Query database for additional models with search term db_models: list[dict[str, Any]] = [] - if prisma_client is not None: + exact_name_can_match: Final = model_name is None or search_lower in model_name.lower() + if prisma_client is not None and exact_name_can_match: try: db_models, db_models_total_count = await _fetch_db_models_for_search( prisma_client=prisma_client, diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index fa8d6d8df41..949088ea3ba 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -2149,18 +2149,28 @@ async def test_apply_search_filter_honours_exact_model_name_in_db_query(): model_name="anthropic-sonnet-5", ) where = prisma_client.db.litellm_proxymodeltable.count.call_args.kwargs["where"] - assert where["AND"] == [{"model_name": "anthropic-sonnet-5"}] - assert where["model_name"] == {"contains": "sonnet", "mode": "insensitive"} + assert where["model_name"] == "anthropic-sonnet-5" assert prisma_client.db.litellm_proxymodeltable.find_many.call_args.kwargs["where"] == where prisma_client.db.litellm_proxymodeltable.count.reset_mock() + _, total_count = await _apply_search_filter_to_models( + all_models=[], + search="opus", + prisma_client=prisma_client, + proxy_config=proxy_config, + model_name="anthropic-sonnet-5", + ) + prisma_client.db.litellm_proxymodeltable.count.assert_not_called() + assert total_count == 0 + await _apply_search_filter_to_models( all_models=[], search="sonnet", prisma_client=prisma_client, proxy_config=proxy_config, ) - assert "AND" not in prisma_client.db.litellm_proxymodeltable.count.call_args.kwargs["where"] + where = prisma_client.db.litellm_proxymodeltable.count.call_args.kwargs["where"] + assert where["model_name"] == {"contains": "sonnet", "mode": "insensitive"} @pytest.mark.asyncio From 08118e6246b6d3ed1508ff89dc13a149dbcf1a69 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 29 Aug 2026 10:52:15 -0700 Subject: [PATCH 6/6] fix(proxy): let the exact model= filter match team BYOK public names Team-scoped deployments keep the internal model_name_{team_id}_{uuid} routing key and expose the public name in model_info.team_public_model_name. The dashboard links team model chips with the public name, so the exact filter now matches either name via the existing helper. --- litellm/proxy/proxy_server.py | 6 +-- .../test_team_model_name_translation.py | 53 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index b398bd7e37e..bad0cf3b575 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -16,7 +16,7 @@ import threading import time import traceback import warnings -from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping, MutableMapping, Sequence +from collections.abc import AsyncGenerator, AsyncIterator, Callable, Collection, Mapping, MutableMapping, Sequence from datetime import datetime, timedelta, timezone from types import MappingProxyType, UnionType from typing import ( @@ -13484,7 +13484,7 @@ async def model_info_v2( all_models += [user_model] if model is not None: - all_models = [m for m in all_models if m["model_name"] == model] + all_models = [m for m in all_models if _deployment_matches_allowed_model_names(m, frozenset((model,)))] # Apply search filter if provided all_models, search_total_count = await _apply_search_filter_to_models( @@ -14011,7 +14011,7 @@ async def model_metrics_exceptions( return {"data": response, "exception_types": list(exception_types)} -def _deployment_matches_allowed_model_names(model: dict[str, JsonValue], allowed_model_names: set[str]) -> bool: +def _deployment_matches_allowed_model_names(model: dict[str, JsonValue], allowed_model_names: Collection[str]) -> bool: """Match a router deployment against allowed public model names. Team-scoped rows store an internal routing key in ``model_name``; callers diff --git a/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py b/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py index 2f4018b55ab..038d061350f 100644 --- a/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py +++ b/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py @@ -154,6 +154,59 @@ async def test_model_info_v2_translates_team_model_name(monkeypatch): assert "model_name_team-abc-123_4a6b8" not in names +@pytest.mark.asyncio +async def test_model_info_v2_exact_model_filter_matches_team_public_name(monkeypatch): + """`/v2/model/info?model=` must keep the team-scoped row whose + `model_name` is the internal routing key: the dashboard links team model + chips with the public name, and the exact filter ran before translation.""" + global_row = { + "model_name": "gpt-4o", + "litellm_params": {"model": "gpt-4o"}, + "model_info": {"id": "normal-id-1", "db_model": False}, + } + router = MagicMock() + router.model_list = [_team_row(), global_row] + + monkeypatch.setattr(ps, "llm_router", router) + monkeypatch.setattr(ps, "user_model", None) + monkeypatch.setattr(ps, "prisma_client", MagicMock()) + monkeypatch.setattr(ps.proxy_config, "get_config", AsyncMock(return_value={})) + monkeypatch.setattr( + ps, + "_apply_search_filter_to_models", + AsyncMock(side_effect=lambda all_models, **kw: (all_models, len(all_models))), + ) + monkeypatch.setattr( + ps, "_enrich_model_info_with_litellm_data", lambda model, **kw: model + ) + import litellm.proxy.agent_endpoints.model_list_helpers as mlh + + monkeypatch.setattr( + mlh, + "append_agents_to_model_info", + AsyncMock(side_effect=lambda models, **kw: models), + ) + + admin = UserAPIKeyAuth(user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN) + resp = await ps.model_info_v2( + user_api_key_dict=admin, + model="team-claude-sonnet", + user_models_only=False, + include_team_models=False, + debug=False, + page=1, + size=50, + search=None, + modelId=None, + teamId=None, + sortBy=None, + sortOrder="asc", + ) + + assert [m["model_name"] for m in resp["data"]] == ["team-claude-sonnet"] + assert resp["total_count"] == 1 + + @pytest.mark.asyncio async def test_model_info_v1_list_path_translates_team_model_name(monkeypatch): """/v1/model/info list path (no litellm_model_id) must include team-scoped