Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/laughing-hugle-b31e9e

This commit is contained in:
Yuneng Jiang 2026-08-10 15:22:47 -07:00
commit 8f7cfd4034
No known key found for this signature in database
8 changed files with 235 additions and 53 deletions

View file

@ -3,8 +3,11 @@ Per-feature OpenAPI snapshot for lazy-loaded routers.
The committed JSON is generated by `python -m litellm.proxy._lazy_openapi_snapshot`
and consumed at runtime so /openapi.json can show full route info for unloaded
features without importing them. CI verifies the file is current and surfaces
any drift as a neutral check.
features without importing them. No CI job regenerates this file; drift surfaces
only indirectly through check-ui-api-types.yml, which rebuilds schema.d.ts from
app.openapi() with the committed snapshot injected. After changing any lazily
loaded route or this generator, rerun the module and commit the JSON, then run
`npm run gen:api` in ui/litellm-dashboard and commit schema.d.ts.
"""
import json
@ -89,8 +92,6 @@ def generate_snapshot() -> dict[str, dict]:
from litellm.proxy.proxy_server import app, ensure_unique_openapi_operation_ids
for feat in LAZY_FEATURES:
if feat.module_path in sys.modules:
continue
try:
module = importlib.import_module(feat.module_path)
feat.register_fn(app, module)
@ -100,7 +101,7 @@ def generate_snapshot() -> dict[str, dict]:
fragments: Final[dict[str, dict]] = {}
used_operation_ids: Final[set[str]] = set()
for feat in LAZY_FEATURES:
feat_routes = [r for r in app.routes if any(getattr(r, "path", "").startswith(p) for p in feat.path_prefixes)]
feat_routes = [r for r in app.routes if feat.matches(getattr(r, "path", ""))]
if not feat_routes:
continue
_stabilize_multi_method_route_ids(feat_routes)

View file

@ -1,4 +1,4 @@
from typing import Any, Final
from typing import Annotated, Any, Final
from fastapi import APIRouter, Depends, HTTPException, Request, Response
@ -18,7 +18,8 @@ from litellm.proxy.vector_store_endpoints.utils import (
get_litellm_managed_vector_store,
)
from litellm.repositories.table_repositories import ManagedVectorStoreIndexRepository
from litellm.types.vector_stores import IndexCreateRequest
from litellm.types.vector_stores import IndexCreateRequest, IndexListResponse
from litellm.vector_stores.vector_store_registry import VectorStoreIndexRegistry
router: Final = APIRouter()
########################################################
@ -549,14 +550,15 @@ async def index_create(
Create an index. Just writes the index to the database.
```bash
curl -L -X POST 'http://0.0.0.0:4000/indexes/create' \
curl -L -X POST 'http://0.0.0.0:4000/v1/indexes' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer sk-1234' \
-H 'LiteLLM-Beta: indexes_beta=v1' \
-d '{
-d '{
"index_name": "dall-e-3",
"vector_store_index": "real-index-name",
"vector_store_name": "azure-ai-search"
"litellm_params": {
"vector_store_index": "real-index-name",
"vector_store_name": "azure-ai-search"
}
}'
```
"""
@ -592,3 +594,36 @@ async def index_create(
new_index = await ManagedVectorStoreIndexRepository(prisma_client).table.create(data=jsonify_object(index_data))
return new_index.model_dump()
@router.get(
"/v1/indexes",
dependencies=[Depends(user_api_key_auth)],
response_model=IndexListResponse,
)
async def index_list(
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> IndexListResponse:
"""
List all vector store indexes. Proxy admin only.
```bash
curl -L -X GET 'http://0.0.0.0:4000/v1/indexes' \
-H 'Authorization: Bearer sk-1234'
```
"""
from litellm.proxy.proxy_server import prisma_client
assert_proxy_admin_for_vector_store_index_management(
user_api_key_dict,
operation="list",
)
if prisma_client is None:
raise HTTPException(
status_code=500,
detail=CommonProxyErrors.db_not_connected_error.value,
)
indexes: Final = await VectorStoreIndexRegistry._get_vector_store_indexes_from_db(prisma_client)
return IndexListResponse(data=indexes)

View file

@ -41,7 +41,7 @@ def _is_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> bool:
def assert_proxy_admin_for_vector_store_index_management(
user_api_key_dict: UserAPIKeyAuth,
*,
operation: Literal["create", "delete", "update"] = "create",
operation: Literal["create", "delete", "update", "list"] = "create",
) -> None:
"""Raise 403 unless the caller is a proxy admin."""
if _is_proxy_admin(user_api_key_dict):

View file

@ -277,6 +277,11 @@ class LiteLLM_ManagedVectorStoreIndex(BaseModel):
updated_by: str | None = None
class IndexListResponse(BaseModel):
object: Literal["list"] = "list"
data: tuple[LiteLLM_ManagedVectorStoreIndex, ...]
class VectorStoreIndexType(str, Enum):
"""Type of vector store index"""

View file

@ -252,26 +252,40 @@ def scan_comments(path: Path, source: str) -> tuple[Comments, tuple[Violation, .
# --------------------------------------------------------------------------- #
def mutable_names_in(annotation: ast.expr) -> Iterator[str]:
def _is_literal_subscript(node: ast.AST) -> bool:
if not isinstance(node, ast.Subscript):
return False
base: Final = node.value
return (isinstance(base, ast.Name) and base.id == "Literal") or (
isinstance(base, ast.Attribute) and base.attr == "Literal"
)
def mutable_names_in(annotation: ast.AST) -> Iterator[str]:
"""Yield mutable-collection names anywhere inside an annotation expression.
Matches bare names (`dict`, `MutableMapping`) and dotted access (`typing.Dict`,
`collections.deque`, `collections.abc.MutableMapping`), descends through nesting
(`Mapping[str, list[int]]`, `tuple[set[int], ...]`) and string forward references.
Skips `Literal[...]` subtrees: their string arguments are values, not forward
references, so `Literal["list"]` is not the `list` type.
"""
for node in ast.walk(annotation):
if isinstance(node, ast.Name) and node.id in MUTABLE_COLLECTIONS:
yield node.id
elif isinstance(node, ast.Attribute) and node.attr in MUTABLE_COLLECTIONS:
yield node.attr
elif isinstance(node, ast.Constant):
value: object = node.value # forward references arrive as string constants
if isinstance(value, str):
try:
inner = ast.parse(value, mode="eval").body
except SyntaxError:
continue
yield from mutable_names_in(inner)
if _is_literal_subscript(annotation):
return
if isinstance(annotation, ast.Name) and annotation.id in MUTABLE_COLLECTIONS:
yield annotation.id
elif isinstance(annotation, ast.Attribute) and annotation.attr in MUTABLE_COLLECTIONS:
yield annotation.attr
elif isinstance(annotation, ast.Constant):
value: object = annotation.value # forward references arrive as string constants
if isinstance(value, str):
try:
inner = ast.parse(value, mode="eval").body
except SyntaxError:
return
yield from mutable_names_in(inner)
for child in ast.iter_child_nodes(annotation):
yield from mutable_names_in(child)
def _mutable_ann(path: Path, line: int, name: str, where: str) -> Violation:

View file

@ -1,6 +1,7 @@
import sys
from types import ModuleType, SimpleNamespace
from litellm.proxy._lazy_features import LazyFeature
from litellm.proxy._lazy_openapi_snapshot import _normalize_operation_ids
@ -22,22 +23,20 @@ def test_generate_snapshot_uses_shared_operation_id_reservations(monkeypatch):
fake_lazy_features_module = ModuleType("litellm.proxy._lazy_features")
fake_lazy_features_module.LAZY_FEATURES = [
SimpleNamespace(
LazyFeature(
name="feature-a",
module_path="fake_feature_a",
path_prefixes=("/feature-a",),
register_fn=lambda app, module: None,
),
SimpleNamespace(
LazyFeature(
name="feature-b",
module_path="fake_feature_b",
path_prefixes=("/feature-b",),
register_fn=lambda app, module: None,
),
]
monkeypatch.setitem(
sys.modules, "litellm.proxy._lazy_features", fake_lazy_features_module
)
monkeypatch.setitem(sys.modules, "litellm.proxy._lazy_features", fake_lazy_features_module)
def fake_get_openapi(title, version, routes):
path = routes[0].path
@ -58,30 +57,59 @@ def test_generate_snapshot_uses_shared_operation_id_reservations(monkeypatch):
fake_proxy_server_module = ModuleType("litellm.proxy.proxy_server")
fake_proxy_server_module.app = fake_app
fake_proxy_server_module.ensure_unique_openapi_operation_ids = (
fake_ensure_unique_openapi_operation_ids
)
monkeypatch.setitem(
sys.modules, "litellm.proxy.proxy_server", fake_proxy_server_module
)
fake_proxy_server_module.ensure_unique_openapi_operation_ids = fake_ensure_unique_openapi_operation_ids
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_proxy_server_module)
monkeypatch.setattr("fastapi.openapi.utils.get_openapi", fake_get_openapi)
fragments = _lazy_openapi_snapshot.generate_snapshot()
assert (
fragments["feature-a"]["paths"]["/feature-a/items"]["get"]["operationId"]
== "shared_operation_id_get"
)
assert (
fragments["feature-b"]["paths"]["/feature-b/items"]["get"]["operationId"]
== "shared_operation_id_get_2"
)
assert fragments["feature-a"]["paths"]["/feature-a/items"]["get"]["tags"] == [
"feature-a"
]
assert fragments["feature-b"]["paths"]["/feature-b/items"]["get"]["tags"] == [
"feature-b"
assert fragments["feature-a"]["paths"]["/feature-a/items"]["get"]["operationId"] == "shared_operation_id_get"
assert fragments["feature-b"]["paths"]["/feature-b/items"]["get"]["operationId"] == "shared_operation_id_get_2"
assert fragments["feature-a"]["paths"]["/feature-a/items"]["get"]["tags"] == ["feature-a"]
assert fragments["feature-b"]["paths"]["/feature-b/items"]["get"]["tags"] == ["feature-b"]
def test_generate_snapshot_registers_transitively_imported_modules(monkeypatch):
"""A feature module already in sys.modules (pulled in transitively by an
earlier feature) must still get register_fn called, else its routes never
mount and its fragment silently vanishes from the snapshot. Fragment
collection must also honor path_suffixes, not just prefixes."""
from litellm.proxy import _lazy_openapi_snapshot
fake_app = SimpleNamespace(title="LiteLLM test", version="0.0.0", routes=[])
fake_module = ModuleType("fake_transitive_feature")
monkeypatch.setitem(sys.modules, "fake_transitive_feature", fake_module)
def register_fn(app, module):
app.routes.append(SimpleNamespace(path="/transitive/items"))
app.routes.append(SimpleNamespace(path="/v1/{param}/deep/leaf"))
fake_lazy_features_module = ModuleType("litellm.proxy._lazy_features")
fake_lazy_features_module.LAZY_FEATURES = [
LazyFeature(
name="transitive",
module_path="fake_transitive_feature",
path_prefixes=("/transitive",),
path_suffixes=("/deep/leaf",),
register_fn=register_fn,
)
]
monkeypatch.setitem(sys.modules, "litellm.proxy._lazy_features", fake_lazy_features_module)
def fake_get_openapi(title, version, routes):
return {"paths": {route.path: {"get": {"operationId": f"op{i}_get"}} for i, route in enumerate(routes)}}
fake_proxy_server_module = ModuleType("litellm.proxy.proxy_server")
fake_proxy_server_module.app = fake_app
fake_proxy_server_module.ensure_unique_openapi_operation_ids = lambda schema, reserved_operation_ids: schema
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_proxy_server_module)
monkeypatch.setattr("fastapi.openapi.utils.get_openapi", fake_get_openapi)
fragments = _lazy_openapi_snapshot.generate_snapshot()
assert fragments["transitive"]["paths"]["/transitive/items"]["get"]["tags"] == ["transitive"]
assert "/v1/{param}/deep/leaf" in fragments["transitive"]["paths"]
def test_normalize_operation_ids_uses_each_http_method():

View file

@ -16,10 +16,11 @@ import litellm
from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook import (
LiteLLM_ManagedVectorStore,
)
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.vector_store_endpoints.endpoints import (
_update_request_data_with_litellm_managed_vector_store_registry,
index_create,
index_list,
)
from litellm.proxy.vector_store_files_endpoints.endpoints import (
_update_request_data_with_model_routing_hint,
@ -37,7 +38,7 @@ from litellm.proxy.vector_store_endpoints.utils import (
is_allowed_to_call_vector_store_endpoint,
is_allowed_to_call_vector_store_files_endpoint,
)
from litellm.types.vector_stores import IndexCreateRequest
from litellm.types.vector_stores import IndexCreateRequest, IndexListResponse
from litellm.types.utils import LlmProviders
@ -1316,6 +1317,93 @@ class TestIndexCreate:
mock_prisma.db.litellm_managedvectorstoreindextable.create.assert_awaited_once()
class TestIndexList:
def _admin(self) -> UserAPIKeyAuth:
return UserAPIKeyAuth(
token="sk-test",
key_name="sk-...test",
user_role=LitellmUserRoles.PROXY_ADMIN,
user_id="admin-user",
)
def _index_row(self, index_id: str, index_name: str) -> dict:
return {
"id": index_id,
"index_name": index_name,
"litellm_params": {
"vector_store_index": f"real-{index_name}",
"vector_store_name": "azure-ai-search",
},
"index_info": None,
"created_at": datetime(2026, 1, 2, tzinfo=timezone.utc),
"created_by": "admin-user",
"updated_at": datetime(2026, 1, 2, tzinfo=timezone.utc),
"updated_by": "admin-user",
}
@pytest.mark.asyncio
async def test_index_list_requires_admin(self):
"""Index topology must never reach non-admins, not even via a DB read."""
mock_prisma = MagicMock()
mock_prisma.db.litellm_managedvectorstoreindextable.find_many = AsyncMock()
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma):
with pytest.raises(HTTPException) as exc_info:
await index_list(
user_api_key_dict=UserAPIKeyAuth(
token="sk-test",
key_name="sk-...test",
user_role=LitellmUserRoles.INTERNAL_USER,
)
)
assert exc_info.value.status_code == 403
assert "Only proxy admins can list" in exc_info.value.detail
mock_prisma.db.litellm_managedvectorstoreindextable.find_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_index_list_requires_db_connection(self):
with patch("litellm.proxy.proxy_server.prisma_client", None):
with pytest.raises(HTTPException) as exc_info:
await index_list(user_api_key_dict=self._admin())
assert exc_info.value.status_code == 500
assert CommonProxyErrors.db_not_connected_error.value in exc_info.value.detail
@pytest.mark.asyncio
async def test_index_list_returns_db_rows_newest_first(self):
"""Rows round-trip into typed models and DB ordering (created_at desc) is requested."""
rows = [
self._index_row("idx-2", "index-b"),
self._index_row("idx-1", "index-a"),
]
mock_prisma = MagicMock()
mock_prisma.db.litellm_managedvectorstoreindextable.find_many = AsyncMock(return_value=rows)
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma):
result = await index_list(user_api_key_dict=self._admin())
assert isinstance(result, IndexListResponse)
assert result.object == "list"
assert [index.index_name for index in result.data] == ["index-b", "index-a"]
assert result.data[0].litellm_params.vector_store_index == "real-index-b"
assert result.data[0].litellm_params.vector_store_name == "azure-ai-search"
assert result.data[1].litellm_params.vector_store_index == "real-index-a"
mock_prisma.db.litellm_managedvectorstoreindextable.find_many.assert_awaited_once_with(
order={"created_at": "desc"}
)
def test_get_v1_indexes_route_registered(self):
from litellm.proxy.vector_store_endpoints.endpoints import router
routes = [
(method, getattr(route, "path", None))
for route in router.routes
for method in (getattr(route, "methods", None) or ())
]
assert ("GET", "/v1/indexes") in routes
class TestIsAllowedToCallVectorStoreFilesEndpoint:
def _mock_provider_config(self):
provider_config = MagicMock()

View file

@ -117,6 +117,17 @@ def test_typing_alias_and_forward_ref_annotations_are_flagged(tmp_path):
assert "LIT001" in _codes(tmp_path, 'x: "dict[str, int]"\n')
def test_literal_string_args_are_values_not_forward_refs(tmp_path):
assert "LIT001" not in _codes(tmp_path, 'from typing import Literal\nx: Literal["list"] = "list"\n')
assert "LIT001" not in _codes(
tmp_path,
'from typing import Literal\ndef f(op: Literal["create", "list"] = "create") -> None:\n return None\n',
)
assert "LIT001" not in _codes(tmp_path, 'import typing\nx: typing.Literal["dict"] = "dict"\n')
assert "LIT001" in _codes(tmp_path, 'from typing import Literal\nx: dict[str, Literal["a"]]\n')
assert "LIT001" in _codes(tmp_path, "x: \"Literal['x'] | list[int]\"\n")
def test_readonly_annotations_are_clean(tmp_path):
for ann in ("Mapping[str, int]", "Sequence[int]", "tuple[int, ...]", "frozenset[int]"):
assert "LIT001" not in _codes(tmp_path, f"from typing import Mapping, Sequence\nx: {ann}\n")