mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/laughing-hugle-b31e9e
This commit is contained in:
commit
8f7cfd4034
8 changed files with 235 additions and 53 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue