fix(proxy): match deployment ids in the models page search

/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
This commit is contained in:
ryan-crabbe-berri 2026-09-03 11:30:45 -07:00
parent fa533f709b
commit a06204728d
5 changed files with 207 additions and 45 deletions

View file

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

View file

@ -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=<group>&search=<term>`: 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

View file

@ -279,7 +279,7 @@ describe("AllModelsTab", () => {
it("keeps the exact model group alongside a typed search", async () => {
render(<AllModelsTab {...defaultProps} selectedModelGroup="claude-opus" />);
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");

View file

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

View file

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