diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 0ddb68e225c..887ccbf180e 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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 diff --git a/tests/code_coverage_tests/unbounded_in_baseline.txt b/tests/code_coverage_tests/unbounded_in_baseline.txt index da56a25542d..0ab157498c1 100644 --- a/tests/code_coverage_tests/unbounded_in_baseline.txt +++ b/tests/code_coverage_tests/unbounded_in_baseline.txt @@ -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 diff --git a/tests/unit/proxy/proxy_server/test_routes_model_info.py b/tests/unit/proxy/proxy_server/test_routes_model_info.py index fae4a6470cf..37e02059179 100644 --- a/tests/unit/proxy/proxy_server/test_routes_model_info.py +++ b/tests/unit/proxy/proxy_server/test_routes_model_info.py @@ -9,6 +9,7 @@ Pins (PR2): from __future__ import annotations +import asyncio import copy from collections.abc import Callable from contextlib import AbstractContextManager @@ -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. diff --git a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py index 09646baa07f..80b87d0a03f 100644 --- a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py +++ b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py @@ -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 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts index 09a0f214da3..e9df939f220 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts @@ -124,6 +124,7 @@ describe("useModelsInfo", () => { undefined, undefined, false, + undefined, ); expect(modelInfoCall).toHaveBeenCalledTimes(1); }); @@ -154,6 +155,7 @@ describe("useModelsInfo", () => { undefined, undefined, false, + undefined, ); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts index 8bc2a50b4d8..24f76065f34 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts @@ -44,6 +44,7 @@ export const useModelsInfo = ( modelName?: string, accessGroup?: string, wildcardOnly: boolean = false, + blocked?: boolean, ) => { const { accessToken, userId, userRole } = useAuthorized(); return useQuery({ @@ -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), }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index ea502396072..478ddcd2458 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -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( () => ({ 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 = (updater) => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx index 768fedf2201..b00affc50f5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx @@ -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(); + + 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(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx index e9eca183a7a..f0e075f5b42 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx @@ -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 = { [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 = { 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" /> + + + { + it.each([ + ["active", false], + ["paused", true], + ] as const)("maps %s to blocked=%s", (status, expected) => { + expect(routingStatusToBlocked(status)).toBe(expected); + }); + + it("returns undefined when there is no status filter", () => { + expect(routingStatusToBlocked(null)).toBeUndefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/routingStatus.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/routingStatus.ts new file mode 100644 index 00000000000..c05f3e4aeba --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/routingStatus.ts @@ -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"; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index d5e81af4652..2a2ce213216 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -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()}`; } diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index df2d7dc64f5..94100646a7d 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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 */