This commit is contained in:
TheMentalCoding 2026-09-30 16:55:54 -04:00 • committed by GitHub
commit 88d4e7b833
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 428 additions and 18 deletions

35
PR_BODY.md Normal file
View file

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

View file

@ -14593,6 +14593,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
@ -14609,11 +14610,42 @@ 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
}
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_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.
{"litellm_params": {"path": ["model"], "string_contains": search_lower}},
]
}
]
if model_name is None
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.
if blocked is not None:
match_conditions.append({"model_info": {"path": ["blocked"], "equals": blocked}})
if db_model_ids_in_router:
db_where_condition["model_id"] = {"not": {"in": list(db_model_ids_in_router)}}
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.
# 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
@ -14659,6 +14691,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.
@ -14679,6 +14712,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
@ -14709,7 +14745,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
@ -14718,7 +14760,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
@ -14755,6 +14799,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:
@ -14830,6 +14875,7 @@ def _sort_models(
"updated_at",
"costs",
"status",
"blocked",
]:
return all_models
@ -14887,6 +14933,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:
@ -14921,16 +14972,32 @@ 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,
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 "")
@ -15229,12 +15296,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=(
@ -15268,7 +15342,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 `*`.
@ -15352,6 +15427,7 @@ async def model_info_v2(
size=size,
sort_by=sortBy,
model_name=model,
blocked=blocked,
)
if user_models_only:
@ -15425,7 +15501,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

@ -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,179 @@ 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.
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": 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))
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_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."""
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

@ -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

View file

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

View file

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

View file

@ -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,
@ -30,6 +31,7 @@ import {
isModelTableSortColumnId,
MODEL_NAME_COLUMN_ID,
MODEL_TABLE_SORT_COLUMN_IDS,
ROUTING_STATUS_COLUMN_ID,
toServerSortField,
} from "./ModelsTableColumns";
@ -54,6 +56,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 +91,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],
@ -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<SortingState> = (updater) => {

View file

@ -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(<AllModelsTable {...baseProps} />);
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(<AllModelsTable {...baseProps} onColumnFiltersChange={onColumnFiltersChange} />);
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();

View file

@ -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<string, string> = {
[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<ModelViewMode, string> = {
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"
/>
</DataTableFilterField>
<DataTableFilterField label="Status">
<Select
value={(get(ROUTING_STATUS_COLUMN_ID) as string) ?? "all"}
onValueChange={(value) =>
set(ROUTING_STATUS_COLUMN_ID, value === "all" ? undefined : (value as "active" | "paused"))
}
>
<SelectTrigger aria-label="Filter by status" className="w-full">
{ROUTING_STATUS_FILTER_VALUES[
(get(ROUTING_STATUS_COLUMN_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

@ -23,6 +23,7 @@ export const COSTS_COLUMN_ID = "input_cost";
export const TEAM_ID_COLUMN_ID = "model_info_team_id";
export const ACCESS_GROUPS_COLUMN_ID = "model_info_access_groups";
export const STATUS_COLUMN_ID = "model_info_db_model";
export const ROUTING_STATUS_COLUMN_ID = "model_info_blocked";
export const MODEL_TABLE_SORT_COLUMN_IDS = [
MODEL_NAME_COLUMN_ID,
@ -30,6 +31,7 @@ export const MODEL_TABLE_SORT_COLUMN_IDS = [
UPDATED_AT_COLUMN_ID,
COSTS_COLUMN_ID,
STATUS_COLUMN_ID,
ROUTING_STATUS_COLUMN_ID,
] as const;
export type ModelTableSortColumnId = (typeof MODEL_TABLE_SORT_COLUMN_IDS)[number];
@ -42,6 +44,7 @@ const COLUMN_ID_TO_SERVER_SORT_FIELD: Record<string, string> = {
[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 ? (
<StatusBadge tone="neutral" label="Paused" />
) : (
<StatusBadge tone="success" label="Active" />
);
}
interface ModelRowActionsProps {
model: ModelData;
userRole: string;
@ -444,6 +455,16 @@ export const getModelsTableColumns = ({
minSize: 90,
cell: ({ row }) => <CostsCell model={row.original} />,
},
{
id: ROUTING_STATUS_COLUMN_ID,
accessorFn: (row) => row.model_info?.blocked === true,
meta: { title: "Status", skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title="Status" />,
enableSorting: true,
size: 90,
minSize: 70,
cell: ({ row }) => <RoutingStatusCell blocked={row.original.model_info?.blocked} />,
},
{
id: TEAM_ID_COLUMN_ID,
accessorFn: (row) => row.model_info.team_id ?? "",
@ -487,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,

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,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";

View file

@ -1670,6 +1670,7 @@ export const modelInfoCall = async (
modelName?: string,
accessGroup?: string,
wildcardOnly?: boolean,
blocked?: boolean,
) => {
/**
* Get all models on proxy
@ -1707,6 +1708,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

@ -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 `*`.
*
@ -76145,10 +76146,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 */