From da439f7b70623b12e8338011b434d946b6365cf7 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 29 Jul 2026 11:49:25 -0700 Subject: [PATCH] feat(ui): drive the models page model group filter from ?model_group= The All Models model-group filter was in-memory React state, so a filtered view could not be linked to or bookmarked. Deriving it from a ?model_group= query param makes the filter shareable and gives other pages (for example an organization's model list) a URL to point at Selecting a group writes the param with history.replaceState so the filter does not spam browser history; choosing All Model Groups removes it --- .../panels/AllModelsPanel.test.tsx | 61 +++++++++++++++++++ .../panels/AllModelsPanel.tsx | 17 +++++- 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AllModelsPanel.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AllModelsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AllModelsPanel.test.tsx new file mode 100644 index 00000000000..3b2c0a84ea2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AllModelsPanel.test.tsx @@ -0,0 +1,61 @@ +/* @vitest-environment jsdom */ +import { act, render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import AllModelsPanel from "./AllModelsPanel"; + +interface CapturedTabProps { + selectedModelGroup: string | null; + setSelectedModelGroup: (modelGroup: string) => void; +} + +const captured: { props: CapturedTabProps | null } = { props: null }; + +vi.mock("@/app/(dashboard)/models-and-endpoints/components/AllModelsTab", () => ({ + default: (props: CapturedTabProps) => { + captured.props = props; + return
; + }, +})); +vi.mock("@/app/(dashboard)/models-and-endpoints/components/AllModelsTable", () => ({ + ALL_MODEL_GROUPS_VALUE: "all", +})); +vi.mock("@/app/(dashboard)/models-and-endpoints/useModelDashboardData", () => ({ + useModelDashboardData: () => ({ availableModelGroups: [], availableModelAccessGroups: [] }), +})); +vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) })); + +describe("AllModelsPanel ?model_group= filter", () => { + beforeEach(() => { + captured.props = null; + window.history.pushState(null, "", "/models-and-endpoints/"); + }); + + it("seeds the model group filter from the URL", () => { + window.history.pushState(null, "", "/models-and-endpoints/?model_group=gpt-4o"); + render(); + expect(captured.props?.selectedModelGroup).toBe("gpt-4o"); + }); + + it("defaults to no filter when the param is absent", () => { + render(); + expect(captured.props?.selectedModelGroup).toBeNull(); + }); + + it("writes the selected group to the URL via replaceState", () => { + render(); + const spy = vi.spyOn(window.history, "replaceState"); + act(() => captured.props?.setSelectedModelGroup("claude-opus-5")); + expect(spy.mock.calls.at(-1)?.[2]).toContain("model_group=claude-opus-5"); + spy.mockRestore(); + }); + + it("removes the param when the filter resets to all groups", () => { + window.history.pushState(null, "", "/models-and-endpoints/?model_group=gpt-4o"); + render(); + const spy = vi.spyOn(window.history, "replaceState"); + act(() => captured.props?.setSelectedModelGroup("all")); + expect(spy.mock.calls.at(-1)?.[2] as string).not.toContain("model_group"); + spy.mockRestore(); + }); +}); 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..f6021e92ede 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,12 +1,25 @@ "use client"; -import { useState } from "react"; +import { useCallback } from "react"; +import { useSearchParams } from "next/navigation"; 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 { navigateWithParams } from "@/app/(dashboard)/navigateWithParams"; export default function AllModelsPanel() { - const [selectedModelGroup, setSelectedModelGroup] = useState(null); + const searchParams = useSearchParams(); + const selectedModelGroup = searchParams?.get("model_group") ?? null; + const setSelectedModelGroup = useCallback((modelGroup: string) => { + navigateWithParams((params) => { + if (modelGroup && modelGroup !== ALL_MODEL_GROUPS_VALUE) { + params.set("model_group", modelGroup); + } else { + params.delete("model_group"); + } + }, "replace"); + }, []); const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData(); const { openModel, openTeam } = useModelDetailRouting();