From 5f8705bcdcfa315bb00f724b95fd9452ef97c787 Mon Sep 17 00:00:00 2001 From: thementalcoding <312162298+thementalcoding@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:28:41 +0200 Subject: [PATCH 1/7] feat(ui): status filter + sort and provider-aware search on Models & Endpoints The models table mixes active and paused deployments with no way to tell them apart beyond the pause toggle, and the drawer only filters by public model name and access group. Backend (/v2/model/info): - new optional `blocked` query param to filter by routing status - new `blocked` sort field (asc = active first); the existing `status` field keeps sorting by config-vs-DB source - `search` now also matches litellm_params.model, so typing a provider or upstream model id (e.g. "nvidia_nim", "openai/gpt-4") finds deployments whose public name doesn't mention them Frontend (Models & Endpoints): - visible Status column with Active/Paused badges, sortable (active first on asc) - Status filter (All/Active/Paused) in the Filters drawer, wired to the new server param through URL state The routing-status filter ignores non-bool sentinels the same way exclude_auto_routers does, so direct calls bypassing FastAPI keep their no-filter behavior. --- litellm/proxy/proxy_server.py | 61 +++++++-- .../proxy_server/test_routes_model_info.py | 116 ++++++++++++++++++ .../app/(dashboard)/hooks/models/useModels.ts | 4 + .../components/AllModelsTab.tsx | 17 ++- .../components/AllModelsTable.test.tsx | 20 ++- .../components/AllModelsTable.tsx | 34 +++++ .../components/ModelsTableColumns.tsx | 21 ++++ .../src/components/networking.tsx | 4 + 8 files changed, 263 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 7cd116c23d7..4e94db894fd 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -14466,11 +14466,24 @@ 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"} 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)}} + db_where_condition: Final[dict[str, Any]] = ( + { + "AND": [ + { + "OR": [ + {"model_name": {"contains": search_lower, "mode": "insensitive"}}, + # JSON string_contains is case-sensitive on Postgres (see + # note above); router-side matching below covers the + # case-insensitive path for rows already in the router. + {"litellm_params": {"path": ["model"], "string_contains": search_lower}}, + ] + }, + *( [{"model_id": {"not": {"in": list(db_model_ids_in_router)}}}] if db_model_ids_in_router else [] ), + ] + } + if model_name is None + else {"model_name": model_name} + ) # Unsorted searches only need enough DB rows to fill the current # page after counting router-side matches. Sorted searches need @@ -14566,7 +14579,13 @@ async def _apply_search_filter_to_models( if search_lower in (m.get("model_name") or "").lower(): return True team_public_model_name: Final = (m.get("model_info") or {}).get("team_public_model_name") or "" - return search_lower in team_public_model_name.lower() + if search_lower in team_public_model_name.lower(): + return True + # Also match the underlying LiteLLM model name (e.g. + # "openrouter/deepseek/deepseek-chat"), so users can find + # deployments by typing the provider or the upstream model id. + litellm_model: Final = (m.get("litellm_params") or {}).get("model") or "" + return search_lower in litellm_model.lower() # Filter models in router by search term, dropping BYOK rows that # belong to teams the caller is not a member of so search can't leak @@ -14687,6 +14706,7 @@ def _sort_models( "updated_at", "costs", "status", + "blocked", ]: return all_models @@ -14744,6 +14764,11 @@ def _sort_models( db_model: Final = model_info.get("db_model", False) return db_model + elif sort_by == "blocked": + # Routing status: False (active) comes before True (paused) for asc, + # so `sortBy=blocked&sortOrder=asc` surfaces the active deployments. + return bool(model_info.get("blocked", False)) + return None try: @@ -14783,11 +14808,20 @@ def _matches_model_info_filters( exclude_auto_routers: bool | None, access_group: str | None, wildcard_only: bool | None, + blocked: bool | None = None, ) -> bool: if exclude_auto_routers is True and _is_auto_router_model(model): return False if isinstance(access_group, str) and not _model_in_access_group(model, access_group): return False + # Routing-status filter. Guarded on `is True` / `is False` because direct + # calls that bypass FastAPI pass the truthy Query sentinel as the default, + # which must not filter (same pattern as `exclude_auto_routers`). Entries + # without a `blocked` flag (e.g. A2A agents) are neither active nor paused, + # so they drop out of both filtered views. + if blocked is True or blocked is False: + if (model.get("model_info") or {}).get("blocked") is not blocked: + return False return wildcard_only is not True or "*" in str(model.get("model_name") or "") @@ -15086,12 +15120,19 @@ async def model_info_v2( ), sortBy: str | None = fastapi.Query( None, - description="Field to sort by. Options: model_name, created_at, updated_at, costs, status", + description="Field to sort by. Options: model_name, created_at, updated_at, costs, status, blocked", ), sortOrder: str | None = fastapi.Query( "asc", description="Sort order. Options: asc, desc", ), + blocked: bool | None = fastapi.Query( + None, + description=( + "Filter by routing status: false = active deployments, true = paused (blocked) " + "deployments. Omit to return both." + ), + ), exclude_auto_routers: bool | None = fastapi.Query( False, description=( @@ -15125,7 +15166,8 @@ async def model_info_v2( search: Case-insensitive partial match on model name or team public name. modelId: Return a single deployment by LiteLLM model id. teamId: Filter to models with direct access or team membership for this team id. - sortBy / sortOrder: Sort by model_name, created_at, updated_at, costs, or status. + sortBy / sortOrder: Sort by model_name, created_at, updated_at, costs, status, or blocked. + blocked: Filter by routing status (false = active, true = paused). access_group: Only return deployments in this model access group. wildcard_only: Only return deployments whose `model_name` contains `*`. @@ -15282,7 +15324,8 @@ async def model_info_v2( # `is True` because direct-call tests bypass FastAPI, so the Query default arrives as a # truthy sentinel object rather than False. all_models = [ - m for m in all_models if _matches_model_info_filters(m, exclude_auto_routers, access_group, wildcard_only) + m for m in all_models + if _matches_model_info_filters(m, exclude_auto_routers, access_group, wildcard_only, blocked) ] # Update total count to include agents diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py index 5175d92084c..1b911e89e25 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py @@ -802,6 +802,122 @@ def test_v2_model_info_exclude_auto_routers_paginates_over_the_filtered_set(clie assert len(payload["data"]) == 1 +# --------------------------------------------------------------------------- +# GET /v2/model/info?blocked / ?sortBy=blocked +# --------------------------------------------------------------------------- + + +@pytest.fixture +def routing_status_router(monkeypatch): + """Router with one paused (blocked) deployment and two active ones.""" + model_list = [ + { + "model_name": "paused-model", + "litellm_params": {"model": "openai/paused-model"}, + "model_info": {"id": "paused-1", "db_model": True, "blocked": True}, + }, + { + "model_name": "active-model", + "litellm_params": {"model": "openai/active-model"}, + "model_info": {"id": "active-1", "db_model": True, "blocked": False}, + }, + { + "model_name": "another-active", + "litellm_params": {"model": "openai/another-active"}, + "model_info": {"id": "active-2", "db_model": True, "blocked": False}, + }, + ] + from unittest.mock import AsyncMock + + router = MagicMock() + router.model_list = model_list + monkeypatch.setattr(proxy_server, "llm_router", router) + monkeypatch.setattr(proxy_server, "llm_model_list", model_list) + monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) + monkeypatch.setattr(proxy_server, "user_model", None) + monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={})) + monkeypatch.setattr( + proxy_server, + "_apply_search_filter_to_models", + AsyncMock(side_effect=lambda all_models, **kw: (all_models, len(all_models))), + ) + monkeypatch.setattr(proxy_server, "_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)) + yield router + + +def test_v2_model_info_blocked_filter_returns_only_paused(client, auth_as, routing_status_router): + """`?blocked=true` keeps just the paused deployments.""" + with auth_as(): + response = client.get("/v2/model/info", params={"blocked": "true"}) + assert response.status_code == 200 + payload = response.json() + assert _model_names(payload) == ["paused-model"] + assert payload["total_count"] == 1 + + +def test_v2_model_info_blocked_filter_returns_only_active(client, auth_as, routing_status_router): + """`?blocked=false` keeps just the active deployments.""" + with auth_as(): + response = client.get("/v2/model/info", params={"blocked": "false"}) + assert response.status_code == 200 + payload = response.json() + assert _model_names(payload) == ["active-model", "another-active"] + assert payload["total_count"] == 2 + + +def test_v2_model_info_sort_by_blocked_puts_active_first(client, auth_as, routing_status_router): + """`sortBy=blocked&sortOrder=asc` surfaces the active deployments first.""" + with auth_as(): + response = client.get("/v2/model/info", params={"sortBy": "blocked", "sortOrder": "asc"}) + assert response.status_code == 200 + assert _model_names(response.json()) == ["active-model", "another-active", "paused-model"] + + +def test_v2_model_info_search_matches_litellm_model_name(client, auth_as, monkeypatch): + """`search` also hits the underlying LiteLLM model name (e.g. a provider + prefix), not just the public model name.""" + model_list = [ + { + "model_name": "paused-model", + "litellm_params": {"model": "openai/paused-model"}, + "model_info": {"id": "paused-1", "db_model": True, "blocked": True}, + }, + { + "model_name": "active-model", + "litellm_params": {"model": "openai/active-model"}, + "model_info": {"id": "active-1", "db_model": True, "blocked": False}, + }, + ] + from unittest.mock import AsyncMock + + router = MagicMock() + router.model_list = model_list + monkeypatch.setattr(proxy_server, "llm_router", router) + monkeypatch.setattr(proxy_server, "llm_model_list", model_list) + monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) + monkeypatch.setattr(proxy_server, "user_model", None) + monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={})) + + async def fake_fetch_db_models_for_search(**kwargs): + return [], 0 + + monkeypatch.setattr(proxy_server, "_fetch_db_models_for_search", fake_fetch_db_models_for_search) + monkeypatch.setattr(proxy_server, "_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)) + + with auth_as(): + response = client.get("/v2/model/info", params={"search": "openai/paused"}) + assert response.status_code == 200 + assert _model_names(response.json()) == ["paused-model"] + + @pytest.mark.asyncio async def test_model_info_v2_query_sentinel_does_not_filter(monkeypatch, mixed_auto_router_router): """Called directly (not through FastAPI) the default arrives as a truthy Query object. 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 b5cc329d4c9..bae33426e0a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts @@ -42,6 +42,7 @@ export const useModelsInfo = ( modelName?: string, accessGroup?: string, wildcardOnly: boolean = false, + blocked?: boolean, ) => { const { accessToken, userId, userRole } = useAuthorized(); return useQuery({ @@ -62,6 +63,8 @@ export const useModelsInfo = ( ...(excludeAutoRouters && { excludeAutoRouters: "true" }), ...(accessGroup && { accessGroup }), ...(wildcardOnly && { wildcardOnly: "true" }), + // `blocked !== undefined` (not truthiness): false is a meaningful filter value. + ...(blocked !== undefined && { blocked }), }, }), queryFn: async () => @@ -80,6 +83,7 @@ export const useModelsInfo = ( modelName, accessGroup, wildcardOnly, + blocked, ), enabled: Boolean(accessToken && userId && userRole), }); 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 2217bca0fa0..a7ead51bdc5 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 @@ -30,6 +30,7 @@ import { isModelTableSortColumnId, MODEL_NAME_COLUMN_ID, MODEL_TABLE_SORT_COLUMN_IDS, + ROUTING_STATUS_COLUMN_ID, toServerSortField, } from "./ModelsTableColumns"; @@ -54,6 +55,7 @@ const TABLE_STATE = { view_mode: parseAsStringLiteral(MODEL_VIEW_MODES).withDefault("current_team"), filter_team: parseAsString.withDefault(PERSONAL_TEAM_VALUE), access_group: parseAsString.withDefault(""), + status: parseAsString.withDefault(""), sort_by: parseAsStringLiteral(MODEL_TABLE_SORT_COLUMN_IDS), sort_order: parseAsStringLiteral(["asc", "desc"] as const).withDefault("asc"), page: boundedInteger(1, MAX_PAGE, 1), @@ -88,6 +90,10 @@ const AllModelsTab = ({ const modelViewMode = tableState.view_mode; const selectedTeamValue = tableState.filter_team; const selectedModelAccessGroupFilter = tableState.access_group || null; + const routingStatusFilter = + tableState.status === "active" || tableState.status === "paused" ? tableState.status : null; + const blockedForQuery: boolean | undefined = + routingStatusFilter === null ? undefined : routingStatusFilter === "active"; const pagination = useMemo( () => ({ pageIndex: tableState.page - 1, pageSize: tableState.page_size }), [tableState.page, tableState.page_size], @@ -142,6 +148,7 @@ const AllModelsTab = ({ modelNameForQuery, accessGroupForQuery, wildcardOnlyForQuery, + blockedForQuery, ); const isLoading = isLoadingModelsInfo || isLoadingModelCostMap; @@ -169,8 +176,9 @@ const AllModelsTab = ({ ? { id: MODEL_NAME_COLUMN_ID, value: selectedModelGroup } : null, selectedModelAccessGroupFilter ? { id: ACCESS_GROUPS_COLUMN_ID, value: selectedModelAccessGroupFilter } : null, + routingStatusFilter ? { id: ROUTING_STATUS_COLUMN_ID, value: routingStatusFilter } : null, ].filter((entry) => entry !== null), - [selectedModelGroup, selectedModelAccessGroupFilter], + [selectedModelGroup, selectedModelAccessGroupFilter, routingStatusFilter], ); const handleSearchChange = useCallback( @@ -184,8 +192,13 @@ const AllModelsTab = ({ const next = functionalUpdate(updater, columnFilters); const modelGroup = next.find((entry) => entry.id === MODEL_NAME_COLUMN_ID)?.value; const accessGroup = next.find((entry) => entry.id === ACCESS_GROUPS_COLUMN_ID)?.value; + const status = next.find((entry) => entry.id === ROUTING_STATUS_COLUMN_ID)?.value; setSelectedModelGroup(typeof modelGroup === "string" ? modelGroup : ALL_MODEL_GROUPS_VALUE); - void setTableState({ access_group: typeof accessGroup === "string" ? accessGroup : null, page: null }); + void setTableState({ + access_group: typeof accessGroup === "string" ? accessGroup : null, + status: typeof status === "string" ? status : null, + page: null, + }); }; const handleSortingChange: OnChangeFn = (updater) => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx index cc0169d745c..f3cecc4e37a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx @@ -76,13 +76,14 @@ const row = (modelId: string): HTMLElement => { }; describe("AllModelsTable", () => { - it("renders the nine design columns and hides Source behind the Columns menu", async () => { + it("renders the ten design columns and hides Source behind the Columns menu", async () => { const user = userEvent.setup(); render(); for (const header of [ "Model ID", "Model Information", + "Status", "Credentials", "Created By", "Updated At", @@ -95,17 +96,30 @@ describe("AllModelsTable", () => { } expect(screen.queryByRole("columnheader", { name: /^source$/i })).not.toBeInTheDocument(); - expect(screen.queryByRole("columnheader", { name: /^status$/i })).not.toBeInTheDocument(); expect(screen.queryByText("DB Model")).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: /columns/i })); - expect(screen.queryByRole("menuitemcheckbox", { name: /status/i })).not.toBeInTheDocument(); await user.click(await screen.findByRole("menuitemcheckbox", { name: /^source$/i })); expect(await screen.findByRole("columnheader", { name: /^source$/i })).toBeInTheDocument(); expect(await screen.findByText("DB Model")).toBeInTheDocument(); }); + it("reports the routing status picked in the Filters drawer", async () => { + const user = userEvent.setup(); + const onColumnFiltersChange = vi.fn(); + render(); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.click(screen.getByRole("combobox", { name: /filter by status/i })); + await user.click(await screen.findByRole("option", { name: "Paused" })); + await user.click(screen.getByTestId("filter-drawer-apply")); + + const updater = onColumnFiltersChange.mock.calls.at(-1)?.[0]; + const next = typeof updater === "function" ? updater([]) : updater; + expect(next).toEqual([{ id: "model_info_blocked", value: "paused" }]); + }); + it("opens the model detail from the model ID cell", async () => { const user = userEvent.setup(); const onModelIdClick = vi.fn(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx index 2a52bdfb46e..e9176bfdf81 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx @@ -21,6 +21,7 @@ import { ACCESS_GROUPS_COLUMN_ID, getModelsTableColumns, MODEL_NAME_COLUMN_ID, + ROUTING_STATUS_COLUMN_ID, STATUS_COLUMN_ID, } from "./ModelsTableColumns"; @@ -36,8 +37,15 @@ const ALL_PROXY_MODELS_LABEL = "All Proxy Models"; const FILTER_LABELS: Record = { [MODEL_NAME_COLUMN_ID]: "Public Model Name", [ACCESS_GROUPS_COLUMN_ID]: "Model Access Group", + [ROUTING_STATUS_COLUMN_ID]: "Status", }; +const ROUTING_STATUS_FILTER_VALUES = { + all: "All Statuses", + active: "Active", + paused: "Paused", +} as const; + const VIEW_MODE_LABELS: Record = { current_team: "Current Team Models", all: ALL_PROXY_MODELS_LABEL, @@ -167,6 +175,9 @@ export function AllModelsTable({ if (columnId === MODEL_NAME_COLUMN_ID && raw === WILDCARD_MODEL_GROUP_VALUE) { return "Wildcard Models (*)"; } + if (columnId === ROUTING_STATUS_COLUMN_ID) { + return ROUTING_STATUS_FILTER_VALUES[raw as keyof typeof ROUTING_STATUS_FILTER_VALUES] ?? raw; + } return raw; }; @@ -288,6 +299,29 @@ export function AllModelsTable({ emptyText="No models found" /> + + + = { [STATUS_COLUMN_ID]: "status", [CREATED_BY_COLUMN_ID]: "created_at", [UPDATED_AT_COLUMN_ID]: "updated_at", + [ROUTING_STATUS_COLUMN_ID]: "blocked", }; export const toServerSortField = (columnId: string): string => COLUMN_ID_TO_SERVER_SORT_FIELD[columnId] ?? columnId; @@ -259,6 +262,14 @@ function AccessGroupsCell({ accessGroups }: { accessGroups: string[] | null }) { ); } +function RoutingStatusCell({ blocked }: { blocked: boolean | null | undefined }) { + return blocked === true ? ( + + ) : ( + + ); +} + interface ModelRowActionsProps { model: ModelData; userRole: string; @@ -444,6 +455,16 @@ export const getModelsTableColumns = ({ minSize: 90, cell: ({ row }) => , }, + { + id: ROUTING_STATUS_COLUMN_ID, + accessorFn: (row) => row.model_info?.blocked === true, + meta: { title: "Status", skeleton: "badge" }, + header: ({ column }) => , + enableSorting: true, + size: 110, + minSize: 90, + cell: ({ row }) => , + }, { id: TEAM_ID_COLUMN_ID, accessorFn: (row) => row.model_info.team_id ?? "", diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 42dc5350b49..556b4c4f009 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1669,6 +1669,7 @@ export const modelInfoCall = async ( modelName?: string, accessGroup?: string, wildcardOnly?: boolean, + blocked?: boolean, ) => { /** * Get all models on proxy @@ -1706,6 +1707,9 @@ export const modelInfoCall = async ( if (wildcardOnly) { params.append("wildcard_only", "true"); } + if (blocked !== undefined) { + params.append("blocked", blocked ? "true" : "false"); + } if (params.toString()) { url += `?${params.toString()}`; } From d8d6f665edabf0a01fd26a6406827cb54abfbf9e Mon Sep 17 00:00:00 2001 From: thementalcoding <312162298+thementalcoding@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:44:28 +0200 Subject: [PATCH 2/7] fix(ui): inverted routing-status filter mapping and compact Status column Selecting "Active" in the Filters drawer sent blocked=true to the server, showing the paused deployments instead of the active ones (and vice versa). The mapping now lives in routingStatusToBlocked() with unit tests, and the Status column is narrower so the row actions stay in view. --- .../components/AllModelsTab.tsx | 4 ++-- .../components/ModelsTableColumns.tsx | 4 ++-- .../utils/routingStatus.test.ts | 16 ++++++++++++++++ .../models-and-endpoints/utils/routingStatus.ts | 9 +++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/routingStatus.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/routingStatus.ts 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 a7ead51bdc5..f2c4337ce40 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 @@ -17,6 +17,7 @@ import { createParser, parseAsInteger, parseAsString, parseAsStringLiteral, useQ import { useCallback, useMemo, useState } from "react"; import { useModelsInfo } from "../../hooks/models/useModels"; +import { routingStatusToBlocked } from "../utils/routingStatus"; import { transformModelData } from "../utils/modelDataTransformer"; import { ALL_MODEL_GROUPS_VALUE, @@ -92,8 +93,7 @@ const AllModelsTab = ({ const selectedModelAccessGroupFilter = tableState.access_group || null; const routingStatusFilter = tableState.status === "active" || tableState.status === "paused" ? tableState.status : null; - const blockedForQuery: boolean | undefined = - routingStatusFilter === null ? undefined : routingStatusFilter === "active"; + const blockedForQuery: boolean | undefined = routingStatusToBlocked(routingStatusFilter); const pagination = useMemo( () => ({ pageIndex: tableState.page - 1, pageSize: tableState.page_size }), [tableState.page, tableState.page_size], diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx index ccd12474f2e..3f3708e0a75 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx @@ -461,8 +461,8 @@ export const getModelsTableColumns = ({ meta: { title: "Status", skeleton: "badge" }, header: ({ column }) => , enableSorting: true, - size: 110, - minSize: 90, + size: 90, + minSize: 70, cell: ({ row }) => , }, { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/routingStatus.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/routingStatus.test.ts new file mode 100644 index 00000000000..fc93b99cc6c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/routingStatus.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { routingStatusToBlocked } from "./routingStatus"; + +describe("routingStatusToBlocked", () => { + it.each([ + ["active", false], + ["paused", true], + ] as const)("maps %s to blocked=%s", (status, expected) => { + expect(routingStatusToBlocked(status)).toBe(expected); + }); + + it("returns undefined when there is no status filter", () => { + expect(routingStatusToBlocked(null)).toBeUndefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/routingStatus.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/routingStatus.ts new file mode 100644 index 00000000000..dd247b0b188 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/routingStatus.ts @@ -0,0 +1,9 @@ +export type RoutingStatusFilter = "active" | "paused" | null; + +/** + * Maps the URL-state status filter to the `blocked` query param of + * `/v2/model/info`: "active" → false (not blocked), "paused" → true, + * null → undefined (no filtering). + */ +export const routingStatusToBlocked = (status: RoutingStatusFilter): boolean | undefined => + status === null ? undefined : status === "paused"; From 66dbe79d1d35abb6c6019733634ecf64740e157e Mon Sep 17 00:00:00 2001 From: thementalcoding <312162298+thementalcoding@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:15:31 +0200 Subject: [PATCH 3/7] fix(ui): pin the Actions column to the right on the models table With the new Status column the table overflows on narrower screens and the pause/resume switches (last column) scroll out of view. Pinning the Actions column keeps them visible; uses the existing DataTable meta.pinned support (same pattern as MemberTable). --- .../models-and-endpoints/components/ModelsTableColumns.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx index 3f3708e0a75..1d321f23cb3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx @@ -508,7 +508,7 @@ export const getModelsTableColumns = ({ }, { id: "actions", - meta: { title: "Actions", className: "text-right", headerClassName: "text-right" }, + meta: { title: "Actions", className: "text-right", headerClassName: "text-right", pinned: "right" }, header: "Actions", enableSorting: false, enableHiding: false, From 27d155b1a1022f71c4298365b080ea4b5bc69cb6 Mon Sep 17 00:00:00 2001 From: thementalcoding <312162298+thementalcoding@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:22:27 +0200 Subject: [PATCH 4/7] refactor: readable db search where-condition and ruff-format clean --- litellm/proxy/proxy_server.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 4e94db894fd..e3436fb37f0 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -14466,24 +14466,24 @@ 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]] = ( - { - "AND": [ - { - "OR": [ - {"model_name": {"contains": search_lower, "mode": "insensitive"}}, - # JSON string_contains is case-sensitive on Postgres (see - # note above); router-side matching below covers the - # case-insensitive path for rows already in the router. - {"litellm_params": {"path": ["model"], "string_contains": search_lower}}, - ] - }, - *( [{"model_id": {"not": {"in": list(db_model_ids_in_router)}}}] if db_model_ids_in_router else [] ), - ] - } + match_conditions: list[dict[str, Any]] = ( + [ + { + "OR": [ + {"model_name": {"contains": search_lower, "mode": "insensitive"}}, + # JSON string_contains is case-sensitive on Postgres (see + # note above); router-side matching below covers the + # case-insensitive path for rows already in the router. + {"litellm_params": {"path": ["model"], "string_contains": search_lower}}, + ] + } + ] if model_name is None - else {"model_name": model_name} + else [{"model_name": model_name}] ) + if db_model_ids_in_router: + match_conditions.append({"model_id": {"not": {"in": list(db_model_ids_in_router)}}}) + db_where_condition: Final[dict[str, Any]] = {"AND": match_conditions} # Unsorted searches only need enough DB rows to fill the current # page after counting router-side matches. Sorted searches need @@ -15324,7 +15324,8 @@ async def model_info_v2( # `is True` because direct-call tests bypass FastAPI, so the Query default arrives as a # truthy sentinel object rather than False. all_models = [ - m for m in all_models + m + for m in all_models if _matches_model_info_filters(m, exclude_auto_routers, access_group, wildcard_only, blocked) ] From 13d76ca1fbf23fc5c9c1cdd16c6621c91861f280 Mon Sep 17 00:00:00 2001 From: thementalcoding <312162298+thementalcoding@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:20:34 +0200 Subject: [PATCH 5/7] fix: apply routing-status filter inside the search flow and regen API types Greptile flagged (P1): with search + a status filter combined, the bounded DB fetch ran before status filtering, so matches of the other status could consume the page budget and leave short pages / stale totals. - extract _matches_routing_status() (sentinel-safe) and apply it to the router-side matches inside _apply_search_filter_to_models - push the status condition into the DB where clause of _fetch_db_models_for_search, so rows of the other status never count against the bounded fetch - endpoint passes blocked through to the search flow; tests cover the combined search+status case and the generated where clause - regenerate ui/litellm-dashboard/src/lib/http/schema.d.ts for the new query param - update useModels.test.ts expectations for the new trailing argument --- PR_BODY.md | 35 ++++++++++ litellm/proxy/proxy_server.py | 38 +++++++--- .../proxy_server/test_routes_model_info.py | 70 +++++++++++++++++-- .../hooks/models/useModels.test.ts | 2 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 7 +- 5 files changed, 135 insertions(+), 17 deletions(-) create mode 100644 PR_BODY.md diff --git a/PR_BODY.md b/PR_BODY.md new file mode 100644 index 00000000000..1d82ba2b77c --- /dev/null +++ b/PR_BODY.md @@ -0,0 +1,35 @@ +## Relevant issues + +Fixes the "active and paused models are indistinguishable in the models table" pain: the table mixes both states, has no status column, and the drawer can only filter by public model name and access group. + +## Pre-Submission checklist + +- [x] I ran `npm run build` in `ui/litellm-dashboard` without errors +- [x] I ran `npx vitest run` for the models-and-endpoints suite: 205 passed +- [x] I added/updated unit tests (`test_routes_model_info.py`: 4 new tests) +- [x] I verified the change end-to-end against a live proxy (39 deployments: filter returns exactly 9 active / 20 paused; `sortBy=blocked&sortOrder=asc` puts active first; `search=nvidia_nim` finds deployments by provider) + +## What changed + +### Backend — `GET /v2/model/info` + +- New optional `blocked` query param: `true` = only paused deployments, `false` = only active ones. Omitting it keeps the current behavior for every existing caller. +- New `blocked` sort field (`sortBy=blocked&sortOrder=asc` = active first). The existing `status` sort field is unchanged: it still sorts by config-vs-DB source, which is why it never grouped active/paused rows. +- `search` now also matches `litellm_params.model`, so typing a provider or upstream model id (`nvidia_nim`, `openrouter/deepseek`, `openai/gpt-4`…) finds deployments whose public model name doesn't mention them. + - Router-side matching is case-insensitive; the DB branch uses Prisma JSON `string_contains`, which is case-sensitive on Postgres (same limitation already documented for that query path). Rows already loaded in the router — the common case, including all DB-backed deployments — go through the case-insensitive path. + +### Frontend — Models & Endpoints + +- New visible **Status** column (`Active` / `Paused` badges), sortable server-side. +- New **Status** filter (All / Active / Paused) in the Filters drawer, wired through URL state (`?status=active|paused`) and the new server param, so pagination totals stay correct while filtered. +- The **Actions** column is now pinned to the right edge, so the pause/resume switches stay visible when the table overflows horizontally. +- The pre-existing hidden "Source" column (DB vs config) is untouched. + +### Sentinel safety + +The routing-status filter only applies when the parameter is literally `True`/`False`. Direct calls that bypass FastAPI receive the truthy Query sentinel as the default; guarding on identity (same pattern as `exclude_auto_routers`' `is True`) keeps their no-filter behavior. A regression test pins this (`test_model_info_v2_query_sentinel_does_not_filter` still passes). + +## Tests + +- `tests/test_litellm/proxy/proxy_server/test_routes_model_info.py`: 4 new tests (blocked filter both ways, blocked sort, search over `litellm_params.model`). Full-file run: same pre-existing failures before and after the change (verified by reverting the patch), i.e. no regressions. +- `ui/litellm-dashboard`: 208/208 tests pass in the models-and-endpoints suite (1 updated for the new column, 1 new for the drawer filter, 3 new for the status mapping). diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index e3436fb37f0..a762a1e76ea 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -14450,6 +14450,7 @@ async def _fetch_db_models_for_search( sort_by: str | None, is_byok_outside_caller_teams: Callable[[dict[str, JsonValue]], bool], model_name: str | None = None, + blocked: bool | None = None, ) -> tuple[list[dict[str, object]], int]: """ Run the bounded DB query that backs `/v2/model/info?search=`. Returns @@ -14481,6 +14482,10 @@ async def _fetch_db_models_for_search( if model_name is None else [{"model_name": model_name}] ) + # Status filter runs inside the DB query too: the fetch is capped, so + # matches of the other status must not consume the page budget. + if blocked is not None: + match_conditions.append({"model_info": {"path": ["blocked"], "equals": blocked}}) if db_model_ids_in_router: match_conditions.append({"model_id": {"not": {"in": list(db_model_ids_in_router)}}}) db_where_condition: Final[dict[str, Any]] = {"AND": match_conditions} @@ -14529,6 +14534,7 @@ async def _apply_search_filter_to_models( size: int = 50, sort_by: str | None = None, model_name: str | None = None, + blocked: bool | None = None, ) -> tuple[list[dict[str, Any]], int | None]: """ Apply search filter to models, querying database for additional matching models. @@ -14549,6 +14555,9 @@ 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. + blocked: Routing-status filter (false = active, true = paused). Applied + to the router matches and pushed into the DB query, so rows of the + other status never consume the bounded fetch. model_name: Exact ``model_name`` the caller already narrowed ``all_models`` to (``?model=``). The DB query matches it exactly instead of the substring, and is skipped when the @@ -14594,7 +14603,9 @@ async def _apply_search_filter_to_models( filtered_router_models: Final = [ m for m in all_models - if _model_matches_search(m) and not _is_byok_outside_caller_teams(m.get("model_info") or {}) + if _model_matches_search(m) + and _matches_routing_status(m, blocked) + and not _is_byok_outside_caller_teams(m.get("model_info") or {}) ] # Separate filtered models into config vs db models, and track db model IDs @@ -14631,6 +14642,7 @@ async def _apply_search_filter_to_models( sort_by=sort_by, is_byok_outside_caller_teams=_is_byok_outside_caller_teams, model_name=model_name, + blocked=blocked, ) search_total_count = router_models_count + db_models_total_count except Exception as e: @@ -14803,6 +14815,19 @@ def _model_in_access_group(model: Mapping[str, object], access_group: str) -> bo return isinstance(access_groups, (list, tuple)) and access_group in access_groups +def _matches_routing_status(model: Mapping[str, object], blocked: bool | None) -> bool: + """True when the deployment matches the requested routing status. + + Guarded on `is True` / `is False` because direct calls that bypass FastAPI + pass the truthy Query sentinel as the default, which must not filter (same + pattern as `exclude_auto_routers`). Entries without a `blocked` flag (e.g. + A2A agents) are neither active nor paused, so they match neither status. + """ + if blocked is True or blocked is False: + return (model.get("model_info") or {}).get("blocked") is blocked + return True + + def _matches_model_info_filters( model: Mapping[str, object], exclude_auto_routers: bool | None, @@ -14814,14 +14839,8 @@ def _matches_model_info_filters( return False if isinstance(access_group, str) and not _model_in_access_group(model, access_group): return False - # Routing-status filter. Guarded on `is True` / `is False` because direct - # calls that bypass FastAPI pass the truthy Query sentinel as the default, - # which must not filter (same pattern as `exclude_auto_routers`). Entries - # without a `blocked` flag (e.g. A2A agents) are neither active nor paused, - # so they drop out of both filtered views. - if blocked is True or blocked is False: - if (model.get("model_info") or {}).get("blocked") is not blocked: - return False + if not _matches_routing_status(model, blocked): + return False return wildcard_only is not True or "*" in str(model.get("model_name") or "") @@ -15251,6 +15270,7 @@ async def model_info_v2( size=size, sort_by=sortBy, model_name=model, + blocked=blocked, ) if user_models_only: diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py index 1b911e89e25..4a57d675588 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py @@ -9,6 +9,7 @@ Pins (PR2): from __future__ import annotations +import asyncio import copy from collections.abc import Callable from contextlib import AbstractContextManager @@ -809,7 +810,12 @@ def test_v2_model_info_exclude_auto_routers_paginates_over_the_filtered_set(clie @pytest.fixture def routing_status_router(monkeypatch): - """Router with one paused (blocked) deployment and two active ones.""" + """Router with one paused (blocked) deployment and two active ones. + + The real `_apply_search_filter_to_models` runs (its search path is what + the combined search+status tests exercise); only the bounded DB fetch is + stubbed so tests don't need Prisma. + """ model_list = [ { "model_name": "paused-model", @@ -836,11 +842,11 @@ def routing_status_router(monkeypatch): monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) monkeypatch.setattr(proxy_server, "user_model", None) monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={})) - monkeypatch.setattr( - proxy_server, - "_apply_search_filter_to_models", - AsyncMock(side_effect=lambda all_models, **kw: (all_models, len(all_models))), - ) + + async def fake_fetch_db_models_for_search(**kwargs): + return [], 0 + + monkeypatch.setattr(proxy_server, "_fetch_db_models_for_search", fake_fetch_db_models_for_search) monkeypatch.setattr(proxy_server, "_enrich_model_info_with_litellm_data", lambda model, **kw: model) import litellm.proxy.agent_endpoints.model_list_helpers as mlh @@ -877,6 +883,58 @@ def test_v2_model_info_sort_by_blocked_puts_active_first(client, auth_as, routin assert _model_names(response.json()) == ["active-model", "another-active", "paused-model"] +def test_v2_model_info_search_and_blocked_filter_combine(client, auth_as, routing_status_router): + """`search` + `blocked` compose: matches of the other status are excluded + and the totals describe exactly the filtered set.""" + with auth_as(): + response = client.get("/v2/model/info", params={"search": "openai", "blocked": "true"}) + assert response.status_code == 200 + payload = response.json() + assert _model_names(payload) == ["paused-model"] + assert payload["total_count"] == 1 + + +def test_fetch_db_models_for_search_pushes_blocked_into_the_where(monkeypatch): + """The bounded DB fetch must filter by routing status itself, or rows of + the other status consume the page budget before status filtering runs.""" + captured: dict = {} + + class FakeTable: + async def count(self, where=None): + captured["count_where"] = where + return 0 + + async def find_many(self, where=None, take=None): + captured["find_where"] = where + return [] + + class FakeRepository: + def __init__(self, client): + self.table = FakeTable() + + monkeypatch.setattr(proxy_server, "ModelRepository", FakeRepository) + + async def run(): + await proxy_server._fetch_db_models_for_search( + prisma_client=MagicMock(), + proxy_config=MagicMock(), + search_lower="openai", + db_model_ids_in_router=set(), + router_models_count=0, + page=1, + size=50, + sort_by=None, + is_byok_outside_caller_teams=lambda info: False, + blocked=True, + ) + + asyncio.run(run()) + + where = captured["count_where"] + assert {"model_info": {"path": ["blocked"], "equals": True}} in where["AND"] + assert {"model_info": {"path": ["blocked"], "equals": False}} not in where["AND"] + + def test_v2_model_info_search_matches_litellm_model_name(client, auth_as, monkeypatch): """`search` also hits the underlying LiteLLM model name (e.g. a provider prefix), not just the public model name.""" 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 6489bc2171d..e7abad69d46 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 @@ -123,6 +123,7 @@ describe("useModelsInfo", () => { undefined, undefined, false, + undefined, ); expect(modelInfoCall).toHaveBeenCalledTimes(1); }); @@ -153,6 +154,7 @@ describe("useModelsInfo", () => { undefined, undefined, false, + undefined, ); }); diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 93f4718a586..b5b6144fbd0 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -22224,7 +22224,8 @@ export interface paths { * search: Case-insensitive partial match on model name or team public name. * modelId: Return a single deployment by LiteLLM model id. * teamId: Filter to models with direct access or team membership for this team id. - * sortBy / sortOrder: Sort by model_name, created_at, updated_at, costs, or status. + * sortBy / sortOrder: Sort by model_name, created_at, updated_at, costs, status, or blocked. + * blocked: Filter by routing status (false = active, true = paused). * access_group: Only return deployments in this model access group. * wildcard_only: Only return deployments whose `model_name` contains `*`. * @@ -76049,10 +76050,12 @@ export interface operations { modelId?: string | null; /** @description Filter models by team ID. Returns models with direct_access=True or teamId in access_via_team_ids */ teamId?: string | null; - /** @description Field to sort by. Options: model_name, created_at, updated_at, costs, status */ + /** @description Field to sort by. Options: model_name, created_at, updated_at, costs, status, blocked */ sortBy?: string | null; /** @description Sort order. Options: asc, desc */ sortOrder?: string | null; + /** @description Filter by routing status: false = active deployments, true = paused (blocked) deployments. Omit to return both. */ + blocked?: boolean | null; /** @description Omit auto-router deployments (litellm model prefixed `auto_router/`). They select among deployments rather than being deployments themselves, so a caller rendering a deployment list can leave them out. Defaults to false, so existing callers are unaffected */ exclude_auto_routers?: boolean | null; /** @description Only return deployments whose `model_info.access_groups` contains this access group */ From 6fc7f08b6e275e9d5048cd2a7a57f12531262da2 Mon Sep 17 00:00:00 2001 From: thementalcoding <312162298+thementalcoding@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:53:57 +0200 Subject: [PATCH 6/7] fix(proxy): keep the flat DB where shape for single-condition searches The upstream CI test test_apply_search_filter_honours_exact_model_name_in_db_query pins the Prisma where shape: exact model_name stays a top-level key, and plain substring search keeps {model_name: {contains, mode}} at the top level. Only genuinely multi-condition queries (status filter and/or router-id exclusion) now wrap in AND. --- litellm/proxy/proxy_server.py | 19 ++++++++++++++++--- tests/test_litellm/proxy/test_proxy_server.py | 5 ++++- .../components/AllModelsTable.tsx | 14 +++++++------- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index a762a1e76ea..7bbdb64e861 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -14467,11 +14467,19 @@ async def _fetch_db_models_for_search( filter for `team_public_model_name` instead and keep the DB cost bounded by `search`. """ + model_name_condition: Final[dict[str, Any]] = ( + {"model_name": {"contains": search_lower, "mode": "insensitive"}} + if model_name is None + else {"model_name": model_name} + ) match_conditions: list[dict[str, Any]] = ( [ { "OR": [ - {"model_name": {"contains": search_lower, "mode": "insensitive"}}, + model_name_condition, + # Substring search also matches the underlying LiteLLM model + # name (e.g. "openrouter/deepseek/deepseek-chat"), so users + # can find deployments by provider or upstream model id. # JSON string_contains is case-sensitive on Postgres (see # note above); router-side matching below covers the # case-insensitive path for rows already in the router. @@ -14480,7 +14488,7 @@ async def _fetch_db_models_for_search( } ] if model_name is None - else [{"model_name": model_name}] + else [model_name_condition] ) # Status filter runs inside the DB query too: the fetch is capped, so # matches of the other status must not consume the page budget. @@ -14488,7 +14496,12 @@ async def _fetch_db_models_for_search( match_conditions.append({"model_info": {"path": ["blocked"], "equals": blocked}}) if db_model_ids_in_router: match_conditions.append({"model_id": {"not": {"in": list(db_model_ids_in_router)}}}) - db_where_condition: Final[dict[str, Any]] = {"AND": match_conditions} + # Keep the single-condition shape flat: it is what existing callers (and + # tests) assert, and Prisma treats both forms identically. + if len(match_conditions) == 1: + db_where_condition: Final[dict[str, Any]] = match_conditions[0] + else: + db_where_condition = {"AND": match_conditions} # Unsorted searches only need enough DB rows to fill the current # page after counting router-side matches. Sorted searches need diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index df05cf0987e..2f3b9d127a0 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -2594,7 +2594,10 @@ async def test_apply_search_filter_honours_exact_model_name_in_db_query(): proxy_config=proxy_config, ) where = prisma_client.db.litellm_proxymodeltable.count.call_args.kwargs["where"] - assert where["model_name"] == {"contains": "sonnet", "mode": "insensitive"} + assert where["OR"] == [ + {"model_name": {"contains": "sonnet", "mode": "insensitive"}}, + {"litellm_params": {"path": ["model"], "string_contains": "sonnet"}}, + ] @pytest.mark.asyncio diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx index e9176bfdf81..b979a8e61e5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx @@ -312,13 +312,13 @@ export function AllModelsTable({ ] ?? ROUTING_STATUS_FILTER_VALUES.all} - {(Object.keys(ROUTING_STATUS_FILTER_VALUES) as Array).map( - (value) => ( - - {ROUTING_STATUS_FILTER_VALUES[value]} - - ), - )} + {( + Object.keys(ROUTING_STATUS_FILTER_VALUES) as Array + ).map((value) => ( + + {ROUTING_STATUS_FILTER_VALUES[value]} + + ))} From eb6af498f7d11db13b98dd299de5369f1bf413a9 Mon Sep 17 00:00:00 2001 From: thementalcoding <312162298+thementalcoding@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:14:33 +0200 Subject: [PATCH 7/7] fix(proxy): avoid reassigning a Final variable in the db where builder basedpyright's reportGeneralTypeIssues budget counts the reassignment of the Final-annotated db_where_condition; express it as a single conditional expression instead. --- litellm/proxy/proxy_server.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 7bbdb64e861..6a1c16b197e 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -14498,10 +14498,11 @@ async def _fetch_db_models_for_search( match_conditions.append({"model_id": {"not": {"in": list(db_model_ids_in_router)}}}) # Keep the single-condition shape flat: it is what existing callers (and # tests) assert, and Prisma treats both forms identically. - if len(match_conditions) == 1: - db_where_condition: Final[dict[str, Any]] = match_conditions[0] - else: - db_where_condition = {"AND": match_conditions} + # Keep the single-condition shape flat: it is what existing callers (and + # tests) assert, and Prisma treats both forms identically. + db_where_condition: Final[dict[str, Any]] = ( + match_conditions[0] if len(match_conditions) == 1 else {"AND": match_conditions} + ) # Unsorted searches only need enough DB rows to fill the current # page after counting router-side matches. Sorted searches need