From 0add3eda4bdc299a03959a6e9d96a359ea0ebf7b Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 29 Jul 2026 17:14:09 -0700 Subject: [PATCH] feat(ui): drive the model access group filter from ?model_access_group= Keeps URL parity with the ?model_group= filter on the same page: the access group drawer filter now reads from and writes to the URL the same way, so both filters are shareable and clear together with their chips. The filter state moves from AllModelsTab local state up into AllModelsPanel where the URL wiring lives --- .../components/AllModelsTab.test.tsx | 32 +++++++++++++ .../components/AllModelsTab.tsx | 5 +- .../panels/AllModelsPanel.test.tsx | 46 +++++++++++++++++++ .../panels/AllModelsPanel.tsx | 33 ++++++++----- 4 files changed, 104 insertions(+), 12 deletions(-) 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 6891082b47e..1f933d42e06 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 @@ -113,12 +113,15 @@ const MOCK_AUTHORIZED = { }; const mockSetSelectedModelGroup = vi.fn(); +const mockSetSelectedModelAccessGroupFilter = vi.fn(); const mockSetSelectedModelId = vi.fn(); const mockSetSelectedTeamId = vi.fn(); const defaultProps = { selectedModelGroup: "all", setSelectedModelGroup: mockSetSelectedModelGroup, + selectedModelAccessGroupFilter: null, + setSelectedModelAccessGroupFilter: mockSetSelectedModelAccessGroupFilter, availableModelGroups: ["gpt-4", "gpt-3.5-turbo"], availableModelAccessGroups: ["sales-team"], setSelectedModelId: mockSetSelectedModelId, @@ -263,6 +266,35 @@ describe("AllModelsTab", () => { expect(within(table).queryByText("gpt-4")).not.toBeInTheDocument(); }); + it("filters the fetched page down to the selected access group", () => { + setModelsInfo( + [ + makeRow(), + { + ...makeRow({ model_info: { id: "model-2", access_groups: ["sales-team"] } }), + model_name: "claude-opus", + }, + ], + 2, + ); + render(); + + const table = screen.getByRole("table"); + expect(within(table).getByText("claude-opus")).toBeInTheDocument(); + expect(within(table).queryByText("gpt-4")).not.toBeInTheDocument(); + }); + + it("removing the access group filter chip clears it through the setter", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("filter-chip-remove-model_info_access_groups")); + + await waitFor(() => { + expect(mockSetSelectedModelAccessGroupFilter).toHaveBeenCalledWith(null); + }); + }); + it("keeps an unknown model group (for example a stale deep link) visible as a removable filter chip", async () => { const user = userEvent.setup(); setModelsInfo([makeRow()], 1); 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 1dc7736d5ac..b56e3fcb19d 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 @@ -32,6 +32,8 @@ const DEFAULT_PAGINATION: PaginationState = { pageIndex: 0, pageSize: DEFAULT_PA interface AllModelsTabProps { selectedModelGroup: string | null; setSelectedModelGroup: (selectedModelGroup: string) => void; + selectedModelAccessGroupFilter: string | null; + setSelectedModelAccessGroupFilter: (selectedModelAccessGroupFilter: string | null) => void; availableModelGroups: string[]; availableModelAccessGroups: string[]; setSelectedModelId: (id: string) => void; @@ -41,6 +43,8 @@ interface AllModelsTabProps { const AllModelsTab = ({ selectedModelGroup, setSelectedModelGroup, + selectedModelAccessGroupFilter, + setSelectedModelAccessGroupFilter, availableModelGroups, availableModelAccessGroups, setSelectedModelId, @@ -55,7 +59,6 @@ const AllModelsTab = ({ const [debouncedSearch, setDebouncedSearch] = useState(""); const [modelViewMode, setModelViewMode] = useState("current_team"); const [selectedTeamValue, setSelectedTeamValue] = useState(PERSONAL_TEAM_VALUE); - const [selectedModelAccessGroupFilter, setSelectedModelAccessGroupFilter] = useState(null); const [pagination, setPagination] = useState(DEFAULT_PAGINATION); const [sorting, setSorting] = useState([]); const [isModelSettingsModalVisible, setIsModelSettingsModalVisible] = useState(false); 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 index 3b2c0a84ea2..454b2abfecb 100644 --- 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 @@ -7,6 +7,8 @@ import AllModelsPanel from "./AllModelsPanel"; interface CapturedTabProps { selectedModelGroup: string | null; setSelectedModelGroup: (modelGroup: string) => void; + selectedModelAccessGroupFilter: string | null; + setSelectedModelAccessGroupFilter: (accessGroup: string | null) => void; } const captured: { props: CapturedTabProps | null } = { props: null }; @@ -59,3 +61,47 @@ describe("AllModelsPanel ?model_group= filter", () => { spy.mockRestore(); }); }); + +describe("AllModelsPanel ?model_access_group= filter", () => { + beforeEach(() => { + captured.props = null; + window.history.pushState(null, "", "/models-and-endpoints/"); + }); + + it("seeds the access group filter from the URL", () => { + window.history.pushState(null, "", "/models-and-endpoints/?model_access_group=sales-team"); + render(); + expect(captured.props?.selectedModelAccessGroupFilter).toBe("sales-team"); + }); + + it("writes the selected access group to the URL via replaceState", () => { + render(); + const spy = vi.spyOn(window.history, "replaceState"); + act(() => captured.props?.setSelectedModelAccessGroupFilter("sales-team")); + expect(spy.mock.calls.at(-1)?.[2]).toContain("model_access_group=sales-team"); + spy.mockRestore(); + }); + + it("removes the param when the access group filter clears", () => { + window.history.pushState(null, "", "/models-and-endpoints/?model_access_group=sales-team"); + render(); + const spy = vi.spyOn(window.history, "replaceState"); + act(() => captured.props?.setSelectedModelAccessGroupFilter(null)); + expect(spy.mock.calls.at(-1)?.[2] as string).not.toContain("model_access_group"); + spy.mockRestore(); + }); + + it("the two filter params stay independent", () => { + window.history.pushState(null, "", "/models-and-endpoints/?model_group=gpt-4o&model_access_group=sales-team"); + render(); + expect(captured.props?.selectedModelGroup).toBe("gpt-4o"); + expect(captured.props?.selectedModelAccessGroupFilter).toBe("sales-team"); + + const spy = vi.spyOn(window.history, "replaceState"); + act(() => captured.props?.setSelectedModelAccessGroupFilter(null)); + const url = spy.mock.calls.at(-1)?.[2] as string; + expect(url).toContain("model_group=gpt-4o"); + expect(url).not.toContain("model_access_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 f6021e92ede..fd6a4607124 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 @@ -8,18 +8,27 @@ import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/us import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation"; import { navigateWithParams } from "@/app/(dashboard)/navigateWithParams"; -export default function AllModelsPanel() { +const useUrlFilter = (paramName: string): [string | null, (value: string | null) => void] => { 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 value = searchParams?.get(paramName) ?? null; + const setValue = useCallback( + (nextValue: string | null) => { + navigateWithParams((params) => { + if (nextValue && nextValue !== ALL_MODEL_GROUPS_VALUE) { + params.set(paramName, nextValue); + } else { + params.delete(paramName); + } + }, "replace"); + }, + [paramName], + ); + return [value, setValue]; +}; + +export default function AllModelsPanel() { + const [selectedModelGroup, setSelectedModelGroup] = useUrlFilter("model_group"); + const [selectedModelAccessGroupFilter, setSelectedModelAccessGroupFilter] = useUrlFilter("model_access_group"); const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData(); const { openModel, openTeam } = useModelDetailRouting(); @@ -27,6 +36,8 @@ export default function AllModelsPanel() {