feat(ui): filter active and paused model deployments (#43710)

* 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.

* 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.

* 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).

* refactor: readable db search where-condition and ruff-format clean

* 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

* 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.

* 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.

* chore(deps): bump pypdf to 6.19.0 to clear osv-scan

pypdf 6.16.2 has 8 High CVEs (GHSA-5jq2-8x83-x246 and aliases) fixed in
6.17.0-6.19.0; bump past all of them

* Revert "chore(deps): bump pypdf to 6.19.0 to clear osv-scan"

This reverts commit 0e2528123db7ab23304ae20f6a4c0961d0e2d073.

* fix(proxy): keep LIT002 budget flat in the model search where-builder

Extract the dynamic Prisma where-condition fragments into module-level
helpers so every dict starts on a short line, split the litellm_params
fallback lookup into a Final local, and annotate suppressions on the
construct start lines the checker attributes

* chore(tests): repoint unbounded-IN baseline at the extracted where-builder helper

The bounded-router-id filter moved from _fetch_db_models_for_search into
_not_in_router_ids, so the old baseline key is stale and the new helper
site is the finding to record

* fix(proxy): avoid searching encrypted deployment model names

* refactor(ui): keep model routing status as a filter only

* docs(api): regenerate model info sorting description

* fix(api): remove remaining paused status sort references

---------

Co-authored-by: thementalcoding <312162298+thementalcoding@users.noreply.github.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
TheMentalCoding 2026-10-15 08:19:59 +02:00 • committed by GitHub
parent 1a9fa70951
commit ca04c4909e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 345 additions and 15 deletions

View file

@ -26,6 +26,9 @@ from collections.abc import (
MutableMapping,
Sequence,
)
from collections.abc import (
Set as AbstractSet,
)
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from itertools import chain
@ -15096,6 +15099,20 @@ def _byok_row_outside_caller_teams(model_info_dict: dict[str, JsonValue], allowe
_SORTED_SEARCH_DB_FETCH_CAP: Final = 500
def _insensitive_contains(value: str) -> Mapping[str, object]:
"""Case-insensitive Prisma substring filter for the model search."""
return {"contains": value, "mode": "insensitive"}
def _blocked_equals(blocked: bool) -> Mapping[str, object]:
return {"blocked": blocked}
def _not_in_router_ids(ids: AbstractSet[str]) -> Mapping[str, object]:
"""Prisma filter excluding models already materialized in the router."""
return {"model_id": {"not": {"in": list(ids)}}}
async def _fetch_db_models_for_search(
prisma_client: PrismaClient,
proxy_config: ProxyConfig,
@ -15107,6 +15124,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
@ -15121,13 +15139,18 @@ async def _fetch_db_models_for_search(
Postgres, which let any authenticated caller force a full BYOK-table
read via `/v2/model/info?search=x`. We rely on the router-side
filter for `team_public_model_name` instead and keep the DB cost
bounded by `search`.
bounded by `search`. The upstream model name is encrypted in the DB,
so provider/upstream search is limited to router-loaded deployments.
"""
db_where_condition: Final[dict[str, object]] = {
"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)}}
model_name_condition: Final[Mapping[str, object]] = (
{"model_name": _insensitive_contains(search_lower)} if model_name is None else {"model_name": model_name}
)
status_conditions: Final = (_blocked_equals(blocked),) if blocked is not None else ()
router_conditions: Final = (_not_in_router_ids(db_model_ids_in_router),) if db_model_ids_in_router else ()
match_conditions: Final = (model_name_condition, *status_conditions, *router_conditions)
db_where_condition: Final[Mapping[str, object]] = (
match_conditions[0] if len(match_conditions) == 1 else {"AND": list(match_conditions)}
)
# Unsorted searches only need enough DB rows to fill the current
# page after counting router-side matches. Sorted searches need
@ -15173,6 +15196,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, object]], int | None]:
"""
Apply search filter to models, querying database for additional matching models.
@ -15193,6 +15217,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
@ -15223,7 +15250,14 @@ 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_params: Final[Mapping[str, object]] = m.get("litellm_params") or MappingProxyType({})
litellm_model: Final = litellm_params.get("model")
return isinstance(litellm_model, str) and 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
@ -15232,7 +15266,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
@ -15269,6 +15305,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:
@ -15435,16 +15472,29 @@ 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:
if blocked is not True and blocked is not False:
return True
match model.get("model_info"):
case {"blocked": True}:
return blocked is True
case _:
return blocked is False
def _matches_model_info_filters(
model: Mapping[str, object],
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
if not _matches_routing_status(model, blocked):
return False
return wildcard_only is not True or "*" in str(model.get("model_name") or "")
@ -15749,6 +15799,13 @@ async def model_info_v2(
"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=(
@ -15783,6 +15840,7 @@ async def model_info_v2(
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.
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 `*`.
@ -15866,6 +15924,7 @@ async def model_info_v2(
size=size,
sort_by=sortBy,
model_name=model,
blocked=blocked,
)
if user_models_only:
@ -15939,7 +15998,9 @@ 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

View file

@ -119,7 +119,7 @@ litellm/proxy/management_helpers/resource_display_names.py mcp_server_display_na
litellm/proxy/policy_engine/policy_resolve_endpoints.py _build_alias_where prisma [field].in `exact` 0
litellm/proxy/policy_engine/policy_resolve_endpoints.py _find_affected_by_team_patterns prisma team_id.in `matched_team_ids` 0
litellm/proxy/proxy_server.py _add_access_group_models_to_team_models prisma access_group_id.in `list(all_access_group_ids)` 0
litellm/proxy/proxy_server.py _fetch_db_models_for_search prisma not.in `list(db_model_ids_in_router)` 0
litellm/proxy/proxy_server.py _not_in_router_ids prisma not.in `list(ids)` 0
litellm/proxy/proxy_server.py _gather_team_accessible_model_ids prisma model_name.in `_resolved_names` 0
litellm/proxy/proxy_server.py get_all_team_models prisma team_id.in `user_teams` 0
litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py _prune_filter prisma model.in `chunk` 0

View file

@ -9,6 +9,7 @@ Pins (PR2):
from __future__ import annotations
import asyncio
import copy
from collections.abc import Callable
from contextlib import AbstractContextManager
@ -802,6 +803,171 @@ def test_v2_model_info_exclude_auto_routers_paginates_over_the_filtered_set(clie
assert len(payload["data"]) == 1
# ---------------------------------------------------------------------------
# GET /v2/model/info?blocked
# ---------------------------------------------------------------------------
@pytest.fixture
def routing_status_router(monkeypatch):
"""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",
"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": 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))
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_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 {"blocked": True} in where["AND"]
assert {"blocked": 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."""
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.

View file

@ -2710,7 +2710,7 @@ 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 == {"model_name": {"contains": "sonnet", "mode": "insensitive"}}
@pytest.mark.asyncio

View file

@ -124,6 +124,7 @@ describe("useModelsInfo", () => {
undefined,
undefined,
false,
undefined,
);
expect(modelInfoCall).toHaveBeenCalledTimes(1);
});
@ -154,6 +155,7 @@ describe("useModelsInfo", () => {
undefined,
undefined,
false,
undefined,
);
});

View file

@ -44,6 +44,7 @@ export const useModelsInfo = (
modelName?: string,
accessGroup?: string,
wildcardOnly: boolean = false,
blocked?: boolean,
) => {
const { accessToken, userId, userRole } = useAuthorized();
return useQuery<PaginatedModelInfoResponse>({
@ -64,6 +65,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 () =>
@ -82,6 +85,7 @@ export const useModelsInfo = (
modelName,
accessGroup,
wildcardOnly,
blocked,
),
enabled: Boolean(accessToken && userId && userRole),
});

View file

@ -20,6 +20,7 @@ import { createParser, parseAsInteger, parseAsString, parseAsStringLiteral, useQ
import { useCallback, useMemo, useState } from "react";
import { useModelsInfo } from "../../hooks/models/useModels";
import { ROUTING_STATUS_FILTER_ID, routingStatusToBlocked } from "../utils/routingStatus";
import { transformModelData } from "../utils/modelDataTransformer";
import {
ALL_MODEL_GROUPS_VALUE,
@ -57,6 +58,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),
@ -96,6 +98,9 @@ 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 = routingStatusToBlocked(routingStatusFilter);
const pagination = useMemo<PaginationState>(
() => ({ pageIndex: tableState.page - 1, pageSize: tableState.page_size }),
[tableState.page, tableState.page_size],
@ -150,6 +155,7 @@ const AllModelsTab = ({
modelNameForQuery,
accessGroupForQuery,
wildcardOnlyForQuery,
blockedForQuery,
);
const isLoading = isLoadingModelsInfo || isLoadingModelCostMap;
@ -177,8 +183,9 @@ const AllModelsTab = ({
? { id: MODEL_NAME_COLUMN_ID, value: selectedModelGroup }
: null,
selectedModelAccessGroupFilter ? { id: ACCESS_GROUPS_COLUMN_ID, value: selectedModelAccessGroupFilter } : null,
routingStatusFilter ? { id: ROUTING_STATUS_FILTER_ID, value: routingStatusFilter } : null,
].filter((entry) => entry !== null),
[selectedModelGroup, selectedModelAccessGroupFilter],
[selectedModelGroup, selectedModelAccessGroupFilter, routingStatusFilter],
);
const handleSearchChange = useCallback(
@ -192,8 +199,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_FILTER_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<SortingState> = (updater) => {

View file

@ -94,18 +94,34 @@ describe("AllModelsTable", () => {
expect(screen.getByRole("columnheader", { name: new RegExp(header, "i") })).toBeInTheDocument();
}
expect(screen.queryByRole("columnheader", { name: /^source$/i })).not.toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: /^status$/i })).not.toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: /^source$/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(<AllModelsTable {...baseProps} onColumnFiltersChange={onColumnFiltersChange} />);
await user.click(screen.getByTestId("datatable-filters-trigger"));
await user.click(screen.getByRole("combobox", { name: /filter by status/i }));
expect(await screen.findByRole("option", { name: "All", exact: true })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "All Statuses" })).not.toBeInTheDocument();
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();

View file

@ -1,5 +1,7 @@
"use client";
import { ROUTING_STATUS_FILTER_ID } from "../utils/routingStatus";
import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import { Search, Settings } from "lucide-react";
import { useMemo, useState } from "react";
@ -36,8 +38,15 @@ const ALL_PROXY_MODELS_LABEL = "All Proxy Models";
const FILTER_LABELS: Record<string, string> = {
[MODEL_NAME_COLUMN_ID]: "Public Model Name",
[ACCESS_GROUPS_COLUMN_ID]: "Model Access Group",
[ROUTING_STATUS_FILTER_ID]: "Status",
};
const ROUTING_STATUS_FILTER_VALUES = {
all: "All",
active: "Active",
paused: "Paused",
} as const;
const VIEW_MODE_LABELS: Record<ModelViewMode, string> = {
current_team: "Current Team Models",
all: ALL_PROXY_MODELS_LABEL,
@ -180,6 +189,9 @@ export function AllModelsTable({
if (columnId === MODEL_NAME_COLUMN_ID && raw === WILDCARD_MODEL_GROUP_VALUE) {
return "Wildcard Models (*)";
}
if (columnId === ROUTING_STATUS_FILTER_ID) {
return ROUTING_STATUS_FILTER_VALUES[raw as keyof typeof ROUTING_STATUS_FILTER_VALUES] ?? raw;
}
return raw;
};
@ -301,6 +313,29 @@ export function AllModelsTable({
emptyText="No models found"
/>
</DataTableFilterField>
<DataTableFilterField label="Status">
<Select
value={(get(ROUTING_STATUS_FILTER_ID) as string) ?? "all"}
onValueChange={(value) =>
set(ROUTING_STATUS_FILTER_ID, value === "all" ? undefined : (value as "active" | "paused"))
}
>
<SelectTrigger aria-label="Filter by status" className="w-full">
{ROUTING_STATUS_FILTER_VALUES[
(get(ROUTING_STATUS_FILTER_ID) as keyof typeof ROUTING_STATUS_FILTER_VALUES) ?? "all"
] ?? ROUTING_STATUS_FILTER_VALUES.all}
</SelectTrigger>
<SelectContent>
{(
Object.keys(ROUTING_STATUS_FILTER_VALUES) as Array<keyof typeof ROUTING_STATUS_FILTER_VALUES>
).map((value) => (
<SelectItem key={value} value={value}>
{ROUTING_STATUS_FILTER_VALUES[value]}
</SelectItem>
))}
</SelectContent>
</Select>
</DataTableFilterField>
<DataTableFilterField label="Model Access Group">
<SearchSelect
options={accessGroupOptions}

View file

@ -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();
});
});

View file

@ -0,0 +1,11 @@
export const ROUTING_STATUS_FILTER_ID = "model_info_blocked";
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";

View file

@ -1604,6 +1604,7 @@ export const modelInfoCall = async (
modelName?: string,
accessGroup?: string,
wildcardOnly?: boolean,
blocked?: boolean,
) => {
/**
* Get all models on proxy
@ -1641,6 +1642,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()}`;
}

View file

@ -23604,6 +23604,7 @@ export interface paths {
* 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.
* 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 `*`.
*
@ -83113,6 +83114,8 @@ export interface operations {
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 */