mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
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:
parent
1a9fa70951
commit
ca04c4909e
13 changed files with 345 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ describe("useModelsInfo", () => {
|
|||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(modelInfoCall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
@ -154,6 +155,7 @@ describe("useModelsInfo", () => {
|
|||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
@ -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()}`;
|
||||
}
|
||||
|
|
|
|||
3
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
3
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue