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;