From a06204728d6ef01fc60a084c0ef27ee0c5262e43 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 3 Sep 2026 11:30:45 -0700 Subject: [PATCH] fix(proxy): match deployment ids in the models page search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /v2/model/info?search= now also matches model_info.id, both in the loaded router list and in the DB query, so pasting a deployment id into the models page finds that row on any page. The search box placeholder reads "Search model names or IDs…" and schema.d.ts picks up the new param description Claude-Session: https://claude.ai/code/session_018yW93iDaEMhoQUXcYjus7D --- litellm/proxy/proxy_server.py | 76 +++++--- tests/test_litellm/proxy/test_proxy_server.py | 168 ++++++++++++++++-- .../components/AllModelsTab.test.tsx | 2 +- .../components/AllModelsTable.tsx | 2 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 +- 5 files changed, 207 insertions(+), 45 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 27132c90e05..a010879ceba 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -13133,6 +13133,44 @@ def _byok_row_outside_caller_teams(model_info_dict: dict[str, JsonValue], allowe _SORTED_SEARCH_DB_FETCH_CAP: Final = 500 +def _deployment_matches_search(m: dict[str, Any], search_lower: str) -> bool: + """`/v2/model/info?search=`: case-insensitive substring of the public name, the deployment id or the team BYOK public name.""" + model_info: Final[Mapping[str, object]] = m.get("model_info") or _EMPTY_MAPPING + return any( + search_lower in str(candidate).lower() + for candidate in (m.get("model_name"), model_info.get("id"), model_info.get("team_public_model_name")) + if candidate + ) + + +class _ContainsInsensitive(TypedDict): + contains: ReadOnly[str] + mode: ReadOnly[Literal["insensitive"]] + + +class _ModelSearchWhere(TypedDict): + OR: ReadOnly[tuple[Mapping[str, _ContainsInsensitive], ...]] + model_id: ReadOnly[Mapping[str, Mapping[str, tuple[str, ...]]]] + model_name: NotRequired[ReadOnly[str]] + + +def _model_search_where( + search_lower: str, + model_name: str | None, + db_model_ids_in_router: Collection[str], +) -> _ModelSearchWhere: + """Prisma `where` for `/v2/model/info?search=`: the substring against `model_name` or `model_id`, narrowed to the exact `model=` group when given, minus rows already served from the router.""" + contains_search: Final[_ContainsInsensitive] = {"contains": search_lower, "mode": "insensitive"} + matches_search: Final[_ModelSearchWhere] = { + "OR": ({"model_name": contains_search}, {"model_id": contains_search}), + "model_id": {"not": {"in": tuple(db_model_ids_in_router)}}, + } + if model_name is None: + return matches_search + within_model_group: Final[_ModelSearchWhere] = {**matches_search, "model_name": model_name} + return within_model_group + + async def _fetch_db_models_for_search( prisma_client: PrismaClient, proxy_config: ProxyConfig, @@ -13160,11 +13198,11 @@ async def _fetch_db_models_for_search( filter for `team_public_model_name` instead and keep the DB cost bounded by `search`. """ - db_where_condition: Final[dict[str, Any]] = { - "model_name": {"contains": search_lower, "mode": "insensitive"} if model_name is None else model_name - } - if db_model_ids_in_router: - db_where_condition["model_id"] = {"not": {"in": list(db_model_ids_in_router)}} + db_where_condition: Final = _model_search_where( + search_lower=search_lower, + model_name=model_name, + db_model_ids_in_router=db_model_ids_in_router, + ) # Unsorted searches only need enough DB rows to fill the current # page after counting router-side matches. Sorted searches need @@ -13231,10 +13269,9 @@ async def _apply_search_filter_to_models( full match set, so the DB fetch is capped at ``_SORTED_SEARCH_DB_FETCH_CAP`` instead of one page. 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 - substring cannot occur in it, otherwise rows from other model - groups leak into the result and the count. + ``all_models`` to (``?model=``). The DB query is narrowed to it + too, otherwise rows from other model groups leak into the + result and the count. Returns: Tuple of (filtered_models, total_count). total_count is None if not searching. @@ -13252,16 +13289,6 @@ async def _apply_search_filter_to_models( def _is_byok_outside_caller_teams(model_info_dict: dict[str, JsonValue]) -> bool: return _byok_row_outside_caller_teams(model_info_dict, allowed_team_ids) - def _model_matches_search(m: dict[str, Any]) -> bool: - # Team BYOK models persist an internal `model_name` - # (e.g. `model_name_{team_id}_{uuid}`) and expose the user-facing - # name via `model_info.team_public_model_name`. Match both so the - # name shown in the UI is searchable. - 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() - # 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 # other teams' models when the request omits `include_team_models` / @@ -13269,7 +13296,7 @@ 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 _deployment_matches_search(m, search_lower) 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 @@ -13292,8 +13319,7 @@ async def _apply_search_filter_to_models( # Query database for additional models with search term db_models: list[dict[str, Any]] = [] - exact_name_can_match: Final = model_name is None or search_lower in model_name.lower() - if prisma_client is not None and exact_name_can_match: + if prisma_client is not None: try: db_models, db_models_total_count = await _fetch_db_models_for_search( prisma_client=prisma_client, @@ -13751,7 +13777,9 @@ async def model_info_v2( debug: bool | None = False, page: int = Query(1, description="Page number", ge=1), size: int = Query(50, description="Page size", ge=1), - search: str | None = fastapi.Query(None, description="Search model names (case-insensitive partial match)"), + search: str | None = fastapi.Query( + None, description="Search model names or model IDs (case-insensitive partial match)" + ), modelId: str | None = fastapi.Query(None, description="Search for a specific model by its unique ID"), teamId: str | None = fastapi.Query( None, @@ -13787,7 +13815,7 @@ async def model_info_v2( include_team_models: When true, populate `access_via_team_ids` and `direct_access` on each model and filter to deployments the caller can use. page / size: Pagination controls (defaults: page=1, size=50). - search: Case-insensitive partial match on model name or team public name. + search: Case-insensitive partial match on model name, model id 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. diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 4ed6a468371..4f3bc1c6648 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -2239,51 +2239,185 @@ async def test_apply_search_filter_bounds_db_fetch_by_page_and_cap(): assert take < 10_000, "sorted search must cap below the full match set" +def _db_model_row(model_id: str, model_name: str) -> MagicMock: + row = MagicMock() + row.model_id = model_id + row.model_name = model_name + row.model_info = {"id": model_id, "db_model": True} + return row + + +def _prisma_client_with_model_rows(rows: list[MagicMock]) -> MagicMock: + """ + Fake `LiteLLM_ProxyModelTable` that evaluates the Prisma `where` the + search sends: top-level fields AND together, `OR` takes any clause, + `contains` is a case-insensitive substring and `not.in` excludes ids. + """ + + def _column_matches(row: MagicMock, column: str, predicate: object) -> bool: + value = getattr(row, column) + if isinstance(predicate, str): + return value == predicate + assert isinstance(predicate, dict) + if "contains" in predicate: + return predicate["contains"].lower() in value.lower() + return value not in predicate["not"]["in"] + + def _row_matches(row: MagicMock, where: dict) -> bool: + columns_match = all( + _column_matches(row, column, predicate) for column, predicate in where.items() if column != "OR" + ) + any_clause_matches = any( + all(_column_matches(row, column, predicate) for column, predicate in clause.items()) + for clause in where.get("OR", ()) + ) + return columns_match and ("OR" not in where or any_clause_matches) + + async def _count(where: dict) -> int: + return sum(_row_matches(row, where) for row in rows) + + async def _find_many(where: dict, take: int) -> list[MagicMock]: + return [row for row in rows if _row_matches(row, where)][:take] + + prisma_client = MagicMock() + prisma_client.db.litellm_proxymodeltable.count = AsyncMock(side_effect=_count) + prisma_client.db.litellm_proxymodeltable.find_many = AsyncMock(side_effect=_find_many) + return prisma_client + + +def _proxy_config_decrypting_rows() -> MagicMock: + proxy_config = MagicMock() + proxy_config.decrypt_model_list_from_db = lambda rows: [ + {"model_name": r.model_name, "model_info": r.model_info, "litellm_params": {"model": r.model_name}} + for r in rows + ] + return proxy_config + + @pytest.mark.asyncio async def test_apply_search_filter_honours_exact_model_name_in_db_query(): """ `/v2/model/info?model=&search=`: the router list is already narrowed to the exact group, so the DB count and fetch must be too, or - other groups' rows leak into the page and inflate total_count. + other groups' rows leak into the page and inflate total_count. Within + the group the term still matches either the name or the deployment id. """ from litellm.proxy.proxy_server import _apply_search_filter_to_models - prisma_client = MagicMock() - prisma_client.db.litellm_proxymodeltable.count = AsyncMock(return_value=0) - prisma_client.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[]) - proxy_config = MagicMock() - proxy_config.decrypt_model_list_from_db = lambda rows: [] + prisma_client = _prisma_client_with_model_rows( + [ + _db_model_row("aaa-in-sonnet-group", "anthropic-sonnet-5"), + _db_model_row("bbb-sonnet-in-id-only", "anthropic-opus-5"), + _db_model_row("ccc-opus-in-id-only", "anthropic-sonnet-5"), + ] + ) + proxy_config = _proxy_config_decrypting_rows() - await _apply_search_filter_to_models( + sonnet_group, sonnet_group_count = await _apply_search_filter_to_models( all_models=[], search="sonnet", prisma_client=prisma_client, proxy_config=proxy_config, model_name="anthropic-sonnet-5", ) - where = prisma_client.db.litellm_proxymodeltable.count.call_args.kwargs["where"] - assert where["model_name"] == "anthropic-sonnet-5" - assert prisma_client.db.litellm_proxymodeltable.find_many.call_args.kwargs["where"] == where + assert [m["model_info"]["id"] for m in sonnet_group] == ["aaa-in-sonnet-group", "ccc-opus-in-id-only"] + assert sonnet_group_count == 2 - prisma_client.db.litellm_proxymodeltable.count.reset_mock() - _, total_count = await _apply_search_filter_to_models( + opus_id_in_sonnet_group, opus_id_in_sonnet_group_count = await _apply_search_filter_to_models( all_models=[], search="opus", prisma_client=prisma_client, proxy_config=proxy_config, model_name="anthropic-sonnet-5", ) - prisma_client.db.litellm_proxymodeltable.count.assert_not_called() - assert total_count == 0 + assert [m["model_info"]["id"] for m in opus_id_in_sonnet_group] == ["ccc-opus-in-id-only"] + assert opus_id_in_sonnet_group_count == 1 - await _apply_search_filter_to_models( + every_sonnet, every_sonnet_count = await _apply_search_filter_to_models( all_models=[], search="sonnet", prisma_client=prisma_client, proxy_config=proxy_config, ) - where = prisma_client.db.litellm_proxymodeltable.count.call_args.kwargs["where"] - assert where["model_name"] == {"contains": "sonnet", "mode": "insensitive"} + assert len(every_sonnet) == 3 + assert every_sonnet_count == 3 + + +@pytest.mark.asyncio +async def test_apply_search_filter_matches_deployment_id(): + """ + LIT-4738: pasting a deployment id (`model_info.id`) into the models page + search must find that row, whether it is a config model that only lives + in the router or a DB model that is not loaded on this page, and + total_count must count it exactly once. + """ + from litellm.proxy.proxy_server import _apply_search_filter_to_models + + config_model_id = "39a9705e6b3a94a207bfbd3200b39a8ed886d2c6daa0deab123d15b721c8c898" + config_model = { + "model_name": "gpt-4.1", + "litellm_params": {"model": "openai/gpt-4.1"}, + "model_info": {"id": config_model_id, "db_model": False}, + } + sibling_config_model = { + "model_name": "gpt-4.1", + "litellm_params": {"model": "azure/gpt-4.1"}, + "model_info": {"id": "0f0f0f0f-sibling", "db_model": False}, + } + router_db_row = _db_model_row("db-row-loaded-in-router", "claude-sonnet-4-5") + unloaded_db_row = _db_model_row("lit4738-db-row-not-in-router", "gemini-2.5-pro") + router_db_model = { + "model_name": router_db_row.model_name, + "litellm_params": {"model": "anthropic/claude-sonnet-4-5"}, + "model_info": router_db_row.model_info, + } + all_models = [config_model, sibling_config_model, router_db_model] + prisma_client = _prisma_client_with_model_rows([router_db_row, unloaded_db_row]) + proxy_config = _proxy_config_decrypting_rows() + + by_config_id, by_config_id_count = await _apply_search_filter_to_models( + all_models=all_models, + search=config_model_id, + prisma_client=prisma_client, + proxy_config=proxy_config, + ) + assert by_config_id == [config_model] + assert by_config_id_count == 1 + + by_unloaded_db_id, by_unloaded_db_id_count = await _apply_search_filter_to_models( + all_models=all_models, + search=unloaded_db_row.model_id, + prisma_client=prisma_client, + proxy_config=proxy_config, + ) + assert [m["model_info"]["id"] for m in by_unloaded_db_id] == [unloaded_db_row.model_id] + assert by_unloaded_db_id_count == 1 + + by_router_db_id, by_router_db_id_count = await _apply_search_filter_to_models( + all_models=all_models, + search=router_db_row.model_id, + prisma_client=prisma_client, + proxy_config=proxy_config, + ) + assert by_router_db_id == [router_db_model], "a DB row already in the router must not be returned twice" + assert by_router_db_id_count == 1 + + by_id_prefix_upper, _ = await _apply_search_filter_to_models( + all_models=all_models, + search=config_model_id[:12].upper(), + prisma_client=prisma_client, + proxy_config=proxy_config, + ) + assert by_id_prefix_upper == [config_model] + + by_name, by_name_count = await _apply_search_filter_to_models( + all_models=all_models, + search="gpt-4", + prisma_client=prisma_client, + proxy_config=proxy_config, + ) + assert by_name == [config_model, sibling_config_model] + assert by_name_count == 2 @pytest.mark.asyncio diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx index 65faa85e29e..72116de0bd0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx @@ -279,7 +279,7 @@ describe("AllModelsTab", () => { it("keeps the exact model group alongside a typed search", async () => { render(); - fireEvent.change(screen.getByPlaceholderText("Search model names…"), { target: { value: "opus" } }); + fireEvent.change(screen.getByPlaceholderText("Search model names or IDs…"), { target: { value: "opus" } }); await waitFor(() => expect(lastModelsInfoCall().search).toBe("opus")); expect(lastModelsInfoCall().modelName).toBe("claude-opus"); 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 5c7dbb18428..802e5ab70bb 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 @@ -199,7 +199,7 @@ export function AllModelsTable({ table={table} searchValue={searchValue} onSearchChange={onSearchChange} - searchPlaceholder="Search model names…" + searchPlaceholder="Search model names or IDs…" onOpenFilters={() => setFiltersOpen(true)} onRefresh={onRefresh} isRefreshing={isRefreshing} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 098b43f6433..31421d330e2 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -21135,7 +21135,7 @@ export interface paths { * include_team_models: When true, populate `access_via_team_ids` and `direct_access` * on each model and filter to deployments the caller can use. * page / size: Pagination controls (defaults: page=1, size=50). - * search: Case-insensitive partial match on model name or team public name. + * search: Case-insensitive partial match on model name, model id 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. @@ -66130,7 +66130,7 @@ export interface operations { page?: number; /** @description Page size */ size?: number; - /** @description Search model names (case-insensitive partial match) */ + /** @description Search model names or model IDs (case-insensitive partial match) */ search?: string | null; /** @description Search for a specific model by its unique ID */ modelId?: string | null;