mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
fix(vector_stores): keep config-defined vector stores listed and read-only (#42574)
* fix(vector_stores): keep config-defined vector stores listed and read-only Vector stores declared in config.yaml were purged from the in-memory registry by /vector_store/list because the database was treated as the only source of truth. Config-defined stores now carry is_config=True, stay in the list beside database rows, are never overwritten or evicted by database state, and reject /vector_store/new, /vector_store/update and /vector_store/delete with 400. The Admin UI renders them read-only Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): show vector store source and read-only state for config-defined stores Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): audit config-owned vector stores across list, writes, search, authz, peers and redis outage Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): show a visible read-only hint in the config vector store actions menu Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: ryan <ryan@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
5c0b374f0a
commit
f44052d87b
19 changed files with 917 additions and 24 deletions
|
|
@ -131,6 +131,7 @@ start_proxy() {
|
|||
fi
|
||||
setsid env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" INTEGRATION_RUN_ID="$integration_identity" \
|
||||
DATABASE_URL="$DATABASE_URL" REDIS_HOST="$REDIS_HOST" REDIS_PORT="$REDIS_PORT" \
|
||||
INTEGRATION_UPSTREAM_URL="$INTEGRATION_UPSTREAM_URL" \
|
||||
LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" LITELLM_SALT_KEY="$LITELLM_SALT_KEY" LITELLM_UI_PATH="$LITELLM_UI_PATH" PROXY_BASE_URL="http://127.0.0.1:$port" \
|
||||
LITELLM_MODE=PRODUCTION STORE_MODEL_IN_DB=True "${cost_map_env[@]}" \
|
||||
AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 \
|
||||
|
|
|
|||
|
|
@ -61,3 +61,4 @@ class LiteLLM_ManagedVectorStoresTable(LiteLLMPydanticObjectBase):
|
|||
litellm_params: dict[str, Any] | None = None
|
||||
team_id: str | None = None
|
||||
user_id: str | None = None
|
||||
is_config: bool = False
|
||||
|
|
|
|||
|
|
@ -45790,6 +45790,10 @@
|
|||
"title": "Custom Llm Provider",
|
||||
"type": "string"
|
||||
},
|
||||
"is_config": {
|
||||
"title": "Is Config",
|
||||
"type": "boolean"
|
||||
},
|
||||
"litellm_credential_name": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -45962,6 +45966,11 @@
|
|||
"title": "Custom Llm Provider",
|
||||
"type": "string"
|
||||
},
|
||||
"is_config": {
|
||||
"default": false,
|
||||
"title": "Is Config",
|
||||
"type": "boolean"
|
||||
},
|
||||
"litellm_credential_name": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -46203,7 +46212,7 @@
|
|||
"paths": {
|
||||
"/v1/vector_store/list": {
|
||||
"get": {
|
||||
"description": "List all available vector stores with optional filtering and pagination.\nCombines both in-memory vector stores and those stored in the database.\nDatabase is the source of truth - deleted stores are removed from memory, updated stores sync to memory.\n\nParameters:\n- page: int - Page number for pagination (default: 1)\n- page_size: int - Number of items per page (default: 100)",
|
||||
"description": "List all available vector stores with optional filtering and pagination.\nCombines both in-memory vector stores and those stored in the database.\nDatabase is the source of truth for stores it owns: deleted stores are removed from memory, updated stores\nsync to memory. Stores declared in the config file are owned by the config file, are always listed, and are\nnever overwritten by database rows.\n\nParameters:\n- page: int - Page number for pagination (default: 1)\n- page_size: int - Number of items per page (default: 100)",
|
||||
"operationId": "list_vector_stores_v1_vector_store_list_get",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -46354,7 +46363,7 @@
|
|||
},
|
||||
"/vector_store/list": {
|
||||
"get": {
|
||||
"description": "List all available vector stores with optional filtering and pagination.\nCombines both in-memory vector stores and those stored in the database.\nDatabase is the source of truth - deleted stores are removed from memory, updated stores sync to memory.\n\nParameters:\n- page: int - Page number for pagination (default: 1)\n- page_size: int - Number of items per page (default: 100)",
|
||||
"description": "List all available vector stores with optional filtering and pagination.\nCombines both in-memory vector stores and those stored in the database.\nDatabase is the source of truth for stores it owns: deleted stores are removed from memory, updated stores\nsync to memory. Stores declared in the config file are owned by the config file, are always listed, and are\nnever overwritten by database rows.\n\nParameters:\n- page: int - Page number for pagination (default: 1)\n- page_size: int - Number of items per page (default: 100)",
|
||||
"operationId": "list_vector_stores_vector_store_list_get",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import json
|
|||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma.models import LiteLLM_ManagedVectorStoresTable as _VectorStoreRow
|
||||
|
|
@ -56,6 +57,32 @@ def _row_to_vector_store(row: "_VectorStoreRow") -> LiteLLM_ManagedVectorStore:
|
|||
return LiteLLM_ManagedVectorStore(**row.model_dump())
|
||||
|
||||
|
||||
class _ConfigOwnedDetail(TypedDict):
|
||||
error: ReadOnly[str]
|
||||
vector_store_id: ReadOnly[str]
|
||||
|
||||
|
||||
def _raise_if_config_owned(vector_store_id: str) -> None:
|
||||
if litellm.vector_store_registry is None or not litellm.vector_store_registry.is_config_vector_store(
|
||||
vector_store_id
|
||||
):
|
||||
return
|
||||
detail: Final[_ConfigOwnedDetail] = {
|
||||
"error": (
|
||||
f"Vector store {vector_store_id} is defined in the config file, so the config file owns it and it "
|
||||
"cannot be changed here. Edit the config file to change it, or remove it from the file to let the "
|
||||
"database own it."
|
||||
),
|
||||
"vector_store_id": vector_store_id,
|
||||
}
|
||||
raise HTTPException(status_code=400, detail=detail)
|
||||
|
||||
|
||||
def _with_ownership(vector_store: LiteLLM_ManagedVectorStore) -> LiteLLM_ManagedVectorStore:
|
||||
ownership: Final = LiteLLM_ManagedVectorStore(is_config=vector_store.get("is_config", False))
|
||||
return vector_store | ownership
|
||||
|
||||
|
||||
_LITELLM_PARAMS_MASKER: Final = SensitiveDataMasker(extra_sensitive_patterns=frozenset(("connection",)))
|
||||
|
||||
|
||||
|
|
@ -274,6 +301,7 @@ async def new_vector_store(
|
|||
status_code=400,
|
||||
detail="vector_store_id and custom_llm_provider are required",
|
||||
)
|
||||
_raise_if_config_owned(vector_store_id)
|
||||
|
||||
# Extract and validate metadata
|
||||
metadata: Final = vector_store.get("vector_store_metadata")
|
||||
|
|
@ -306,6 +334,8 @@ async def new_vector_store(
|
|||
"message": f"Vector store {vector_store.get('vector_store_id')} created successfully",
|
||||
"vector_store": response_vs,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Error creating vector store: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
@ -331,7 +361,9 @@ async def list_vector_stores(
|
|||
"""
|
||||
List all available vector stores with optional filtering and pagination.
|
||||
Combines both in-memory vector stores and those stored in the database.
|
||||
Database is the source of truth - deleted stores are removed from memory, updated stores sync to memory.
|
||||
Database is the source of truth for stores it owns: deleted stores are removed from memory, updated stores
|
||||
sync to memory. Stores declared in the config file are owned by the config file, are always listed, and are
|
||||
never overwritten by database rows.
|
||||
|
||||
Parameters:
|
||||
- page: int - Page number for pagination (default: 1)
|
||||
|
|
@ -366,8 +398,10 @@ async def list_vector_stores(
|
|||
if not vector_store_id:
|
||||
continue
|
||||
|
||||
if vector_store.get("is_config", False):
|
||||
vector_store_map[vector_store_id] = vector_store
|
||||
# If vector store is in memory but NOT in database, it was deleted
|
||||
if vector_store_id not in db_vector_store_ids:
|
||||
elif vector_store_id not in db_vector_store_ids:
|
||||
verbose_proxy_logger.info(
|
||||
"Vector store %s exists in memory but not in database - marking for deletion from cache",
|
||||
vector_store_id,
|
||||
|
|
@ -394,7 +428,7 @@ async def list_vector_stores(
|
|||
# Filter vector stores based on access control
|
||||
accessible_vector_stores: Final = []
|
||||
for vs in await filter_listable_vector_stores(vector_store_map.values(), user_api_key_dict):
|
||||
redacted = LiteLLM_ManagedVectorStore(**vs)
|
||||
redacted = _with_ownership(vs)
|
||||
redacted["litellm_params"] = _redact_sensitive_litellm_params(vs.get("litellm_params"))
|
||||
accessible_vector_stores.append(redacted)
|
||||
|
||||
|
|
@ -467,6 +501,7 @@ async def delete_vector_store(
|
|||
status_code=404,
|
||||
detail=f"Vector store with ID {data.vector_store_id} not found",
|
||||
)
|
||||
_raise_if_config_owned(data.vector_store_id)
|
||||
|
||||
# Check access control
|
||||
if vector_store_to_check and not await _check_vector_store_access(vector_store_to_check, user_api_key_dict):
|
||||
|
|
@ -545,6 +580,7 @@ async def get_vector_store_info(
|
|||
litellm_params=_redact_sensitive_litellm_params(vector_store.get("litellm_params")),
|
||||
team_id=vector_store.get("team_id") or None,
|
||||
user_id=vector_store.get("user_id") or None,
|
||||
is_config=vector_store.get("is_config", False),
|
||||
)
|
||||
return {"vector_store": vector_store_pydantic_obj}
|
||||
|
||||
|
|
@ -591,6 +627,7 @@ async def update_vector_store(
|
|||
update_data: Final = data.model_dump(exclude_unset=True)
|
||||
vector_store_id: Final[str] = data.vector_store_id
|
||||
update_data.pop("vector_store_id")
|
||||
_raise_if_config_owned(vector_store_id)
|
||||
|
||||
# Per-store access control: anyone authenticated who passes the
|
||||
# premium-feature gate could otherwise update *any* vector store —
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ class LiteLLM_ManagedVectorStore(TypedDict, total=False):
|
|||
team_id: str | None
|
||||
user_id: str | None
|
||||
|
||||
is_config: ReadOnly[bool]
|
||||
|
||||
|
||||
class LiteLLM_ManagedVectorStoreListResponse(TypedDict, total=False):
|
||||
"""Response format for listing vector stores"""
|
||||
|
|
|
|||
|
|
@ -340,7 +340,7 @@ class VectorStoreRegistry:
|
|||
|
||||
# Verify vector store still exists in database (if we have DB access)
|
||||
# This ensures deleted vector stores are removed from cache
|
||||
if vector_store is not None and prisma_client is not None:
|
||||
if vector_store is not None and prisma_client is not None and not vector_store.get("is_config", False):
|
||||
try:
|
||||
# Check if it still exists in database
|
||||
db_vector_store = await ManagedVectorStoresRepository(prisma_client).table.find_unique(
|
||||
|
|
@ -426,6 +426,7 @@ class VectorStoreRegistry:
|
|||
vector_store_metadata=vector_store_litellm_params.get("vector_store_metadata"),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
is_config=True,
|
||||
)
|
||||
self.vector_stores.append(litellm_managed_vector_store)
|
||||
|
||||
|
|
@ -452,6 +453,10 @@ class VectorStoreRegistry:
|
|||
|
||||
return response
|
||||
|
||||
def is_config_vector_store(self, vector_store_id: str) -> bool:
|
||||
vector_store: Final = self.get_litellm_managed_vector_store_from_registry(vector_store_id=vector_store_id)
|
||||
return vector_store is not None and vector_store.get("is_config", False)
|
||||
|
||||
def add_vector_store_to_registry(self, vector_store: LiteLLM_ManagedVectorStore):
|
||||
"""
|
||||
Add a vector store to the registry
|
||||
|
|
@ -475,10 +480,11 @@ class VectorStoreRegistry:
|
|||
]
|
||||
|
||||
def update_vector_store_in_registry(self, vector_store_id: str, updated_data: LiteLLM_ManagedVectorStore):
|
||||
"""Update or add a vector store in the registry"""
|
||||
"""Update or add a vector store in the registry. Config-defined stores are left untouched"""
|
||||
for i, vector_store in enumerate(self.vector_stores):
|
||||
if vector_store.get("vector_store_id") == vector_store_id:
|
||||
self.vector_stores[i] = updated_data
|
||||
if not vector_store.get("is_config", False):
|
||||
self.vector_stores[i] = updated_data
|
||||
return
|
||||
self.vector_stores.append(updated_data)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import os
|
||||
import socket
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
|
@ -13,7 +13,6 @@ from typing import Final
|
|||
|
||||
import httpx
|
||||
import psutil
|
||||
|
||||
from integration._support.client import Gateway
|
||||
|
||||
|
||||
|
|
@ -61,9 +60,10 @@ def owned_proxy(
|
|||
*,
|
||||
config: Path | None = None,
|
||||
remove_environment: tuple[str, ...] = (),
|
||||
workers: int = 1,
|
||||
) -> Iterator[Gateway]:
|
||||
with owned_proxy_process(
|
||||
gateway, directory, overrides, config=config, remove_environment=remove_environment
|
||||
gateway, directory, overrides, config=config, remove_environment=remove_environment, workers=workers
|
||||
) as owned:
|
||||
yield owned.gateway
|
||||
|
||||
|
|
@ -76,11 +76,12 @@ def owned_proxy_process(
|
|||
*,
|
||||
config: Path | None = None,
|
||||
remove_environment: tuple[str, ...] = (),
|
||||
workers: int = 1,
|
||||
) -> Iterator[OwnedProxy]:
|
||||
with socket.socket() as reserve:
|
||||
reserve.bind(("127.0.0.1", 0))
|
||||
port: Final = reserve.getsockname()[1]
|
||||
root: Final = Path(__file__).resolve().parents[3]
|
||||
root: Final = Path(os.environ.get("INTEGRATION_PROXY_ROOT") or Path(__file__).resolve().parents[3])
|
||||
environment: Final = {
|
||||
**{name: value for name, value in os.environ.items() if name not in remove_environment},
|
||||
"LITELLM_MASTER_KEY": gateway.key,
|
||||
|
|
@ -104,7 +105,7 @@ def owned_proxy_process(
|
|||
"--port",
|
||||
str(port),
|
||||
"--num_workers",
|
||||
"1",
|
||||
str(workers),
|
||||
"--use_prisma_db_push",
|
||||
"--enforce_prisma_migration_check",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -152,6 +152,31 @@ class Provider:
|
|||
)
|
||||
return await chat_completions(request)
|
||||
|
||||
async def vector_store_search(self, request: Request) -> Response:
|
||||
body: Final = JSON_OBJECT.validate_json(await request.body())
|
||||
self.observations.put(Observation(request.url.path, request.headers.get("authorization", ""), body))
|
||||
query: Final = body.get("query")
|
||||
if not isinstance(query, str) or not query:
|
||||
return JSONResponse({"error": {"message": "query is required"}}, status_code=400)
|
||||
vector_store_id: Final = cast(str, request.path_params["vector_store_id"])
|
||||
return JSONResponse(
|
||||
{
|
||||
"object": "vector_store.search_results.page",
|
||||
"search_query": query,
|
||||
"data": [
|
||||
{
|
||||
"file_id": f"file_{vector_store_id}",
|
||||
"filename": "scripted.txt",
|
||||
"score": 0.9,
|
||||
"attributes": {},
|
||||
"content": [{"type": "text", "text": f"scripted context for {query}"}],
|
||||
}
|
||||
],
|
||||
"has_more": False,
|
||||
"next_page": None,
|
||||
}
|
||||
)
|
||||
|
||||
async def script(self, request: Request) -> Response:
|
||||
name: Final = cast(str, request.path_params["model"])
|
||||
if request.method in {"DELETE", "GET"} and name not in self.scripts:
|
||||
|
|
@ -338,6 +363,7 @@ class Provider:
|
|||
Route("/v1/completions", completions, methods=["POST"]),
|
||||
Route("/v1/embeddings", embeddings, methods=["POST"]),
|
||||
Route("/v1/moderations", moderations, methods=["POST"]),
|
||||
Route("/vector_stores/{vector_store_id}/search", self.vector_store_search, methods=["POST"]),
|
||||
Route("/{path:path}", self.scripted, methods=["POST"]),
|
||||
Route("/{path:path}", self.scripted, methods=["GET"]),
|
||||
WebSocketRoute("/v1/realtime", self.realtime),
|
||||
|
|
|
|||
|
|
@ -1789,6 +1789,33 @@
|
|||
"tests/integration/mcp/test_mcp_lifecycle.py::test_same_url_server_grants_scope_discovery_and_direct_or_virtual_execution[bearer]": [
|
||||
"other.mcp.permissions.same_url_servers_enforce_discovery_and_execution"
|
||||
],
|
||||
"tests/integration/management/test_vector_store_config_ownership.py::test_config_store_is_listed_beside_db_store_and_survives_listing": [
|
||||
"mgmt.vector_store.list.keeps_config_store_beside_db_stores"
|
||||
],
|
||||
"tests/integration/management/test_vector_store_config_ownership.py::test_config_store_refuses_new_update_and_delete": [
|
||||
"mgmt.vector_store.write.config_store_is_read_only"
|
||||
],
|
||||
"tests/integration/management/test_vector_store_config_ownership.py::test_db_store_lifecycle_is_unchanged_beside_config_store": [
|
||||
"mgmt.vector_store.write.db_store_lifecycle_unchanged_beside_config_store"
|
||||
],
|
||||
"tests/integration/management/test_vector_store_config_ownership.py::test_chat_with_config_store_searches_upstream_and_injects_context_after_listing": [
|
||||
"other.vector_store.chat.config_store_search_reaches_upstream_after_listing"
|
||||
],
|
||||
"tests/integration/management/test_vector_store_config_ownership.py::test_passthrough_search_on_config_store_uses_yaml_credentials_after_listing": [
|
||||
"other.vector_store.search.config_store_passthrough_uses_yaml_credentials_after_listing"
|
||||
],
|
||||
"tests/integration/management/test_vector_store_config_ownership.py::test_non_admin_key_access_to_config_store_follows_grants_after_admin_listing": [
|
||||
"authz.vector_store.list.non_admin_key_access_to_config_store_follows_grants"
|
||||
],
|
||||
"tests/integration/management/test_vector_store_config_ownership.py::test_peer_process_keeps_config_store_and_sees_db_store_created_elsewhere": [
|
||||
"mgmt.vector_store.list.peer_process_keeps_config_store_and_sees_db_store"
|
||||
],
|
||||
"tests/integration/management/test_vector_store_config_ownership.py::test_concurrent_burst_keeps_config_store_and_refuses_every_config_write": [
|
||||
"mgmt.vector_store.chaos.concurrent_burst_keeps_config_store_across_workers"
|
||||
],
|
||||
"tests/integration/management/test_vector_store_config_ownership.py::test_redis_outage_keeps_config_store_served_and_recovers": [
|
||||
"mgmt.vector_store.chaos.redis_outage_keeps_config_store_and_recovers"
|
||||
],
|
||||
"tests/integration/providers/test_anthropic_advisor_wire.py::test_advisor_sub_call_reaches_the_router_deployment_with_its_key_instead_of_anthropic_unauthenticated": [
|
||||
"providers.anthropic_messages_advisor.sub_call_uses_the_configured_advisor_deployment"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,351 @@
|
|||
import os
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
import psycopg
|
||||
import pytest
|
||||
from psycopg import sql
|
||||
from pydantic import JsonValue
|
||||
|
||||
from tests.integration._support.client import Gateway, eventually, object_value
|
||||
from tests.integration._support.database import read_rows
|
||||
from tests.integration._support.process import owned_proxy
|
||||
from tests.integration._support.redis_process import owned_redis
|
||||
|
||||
CONFIG_STORE_ID: Final = "vs_integration_config_store"
|
||||
CONFIG_STORE_NAME: Final = "integration-config-store"
|
||||
SEARCH_PATH: Final = f"/vector_stores/{CONFIG_STORE_ID}/search"
|
||||
PROXY_CONFIG: Final = Path(__file__).resolve().parents[1] / "proxy_config.yaml"
|
||||
|
||||
|
||||
def listed_rows(response: httpx.Response) -> tuple[dict[str, JsonValue], ...]:
|
||||
rows: Final = object_value(response.json()).get("data")
|
||||
assert isinstance(rows, list), response.text
|
||||
return tuple(object_value(row) for row in rows)
|
||||
|
||||
|
||||
def listed_store(gateway: Gateway, vector_store_id: str, *, key: str | None = None) -> dict[str, JsonValue]:
|
||||
listed: Final = gateway.request("GET", "/vector_store/list", key=key)
|
||||
assert listed.status_code == 200, listed.text
|
||||
matches: Final = tuple(row for row in listed_rows(listed) if row["vector_store_id"] == vector_store_id)
|
||||
assert len(matches) == 1, f"{vector_store_id} appears {len(matches)} times in {listed.text}"
|
||||
return matches[0]
|
||||
|
||||
|
||||
def listed_ids(gateway: Gateway) -> tuple[str, ...]:
|
||||
rows: Final = gateway.get("/vector_store/list")["data"]
|
||||
assert isinstance(rows, list)
|
||||
return tuple(str(object_value(row)["vector_store_id"]) for row in rows)
|
||||
|
||||
|
||||
def config_store_info(gateway: Gateway) -> dict[str, JsonValue]:
|
||||
return object_value(gateway.post("/vector_store/info", {"vector_store_id": CONFIG_STORE_ID})["vector_store"])
|
||||
|
||||
|
||||
def store_rows(vector_store_id: str) -> list[dict[str, JsonValue]]:
|
||||
return read_rows(
|
||||
'SELECT vector_store_id, vector_store_name FROM "LiteLLM_ManagedVectorStoresTable" WHERE vector_store_id = %s',
|
||||
(vector_store_id,),
|
||||
)
|
||||
|
||||
|
||||
def assert_config_write_refused(gateway: Gateway) -> None:
|
||||
for path, body in (
|
||||
("/vector_store/update", {"vector_store_id": CONFIG_STORE_ID, "vector_store_name": "renamed"}),
|
||||
("/vector_store/delete", {"vector_store_id": CONFIG_STORE_ID}),
|
||||
("/vector_store/new", {"vector_store_id": CONFIG_STORE_ID, "custom_llm_provider": "openai"}),
|
||||
):
|
||||
refused = gateway.request("POST", path, body)
|
||||
assert refused.status_code == 400, f"{path}: {refused.status_code} {refused.text}"
|
||||
error = object_value(object_value(refused.json())["detail"])
|
||||
assert error["vector_store_id"] == CONFIG_STORE_ID, refused.text
|
||||
assert "config file" in str(error["error"]), refused.text
|
||||
|
||||
|
||||
def burst_list(gateway: Gateway) -> tuple[int, str]:
|
||||
response: Final = gateway.request("GET", "/vector_store/list")
|
||||
if response.status_code != 200:
|
||||
return response.status_code, response.text
|
||||
ids: Final = tuple(str(row["vector_store_id"]) for row in listed_rows(response))
|
||||
return response.status_code, "config" if CONFIG_STORE_ID in ids else response.text
|
||||
|
||||
|
||||
def burst_post(gateway: Gateway, path: str, body: Mapping[str, JsonValue]) -> tuple[int, str]:
|
||||
response: Final = gateway.request("POST", path, body)
|
||||
return response.status_code, response.text
|
||||
|
||||
|
||||
def upstream_requests(upstream: httpx.Client, marker: str) -> list[dict[str, JsonValue]]:
|
||||
observed: Final = upstream.get("/__observations")
|
||||
observed.raise_for_status()
|
||||
requests: Final = object_value(observed.json())["requests"]
|
||||
assert isinstance(requests, list), observed.text
|
||||
return [object_value(value) for value in requests if marker in str(object_value(value)["body"])]
|
||||
|
||||
|
||||
@pytest.mark.covers("mgmt.vector_store.list.keeps_config_store_beside_db_stores")
|
||||
def test_config_store_is_listed_beside_db_store_and_survives_listing(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
db_store_id: Final = f"vs_db_{uuid.uuid4().hex}"
|
||||
gateway.post("/vector_store/new", {"vector_store_id": db_store_id, "custom_llm_provider": "openai"})
|
||||
scenario.cleanups.callback(gateway.post, "/vector_store/delete", {"vector_store_id": db_store_id})
|
||||
before: Final = config_store_info(gateway)
|
||||
assert before["vector_store_id"] == CONFIG_STORE_ID, before
|
||||
|
||||
config_row: Final = listed_store(gateway, CONFIG_STORE_ID)
|
||||
assert config_row["is_config"] is True, config_row
|
||||
assert config_row["vector_store_name"] == CONFIG_STORE_NAME, config_row
|
||||
assert object_value(config_row["litellm_params"])["api_key"] != "integration-provider-key", config_row
|
||||
db_row: Final = listed_store(gateway, db_store_id)
|
||||
assert db_row["is_config"] is False, db_row
|
||||
|
||||
after: Final = config_store_info(gateway)
|
||||
assert after["vector_store_id"] == CONFIG_STORE_ID, after
|
||||
assert after["is_config"] is True, after
|
||||
assert after["vector_store_description"] == "declared in tests/integration/proxy_config.yaml", after
|
||||
assert store_rows(CONFIG_STORE_ID) == [], "config store must not need a database row"
|
||||
assert listed_store(gateway, CONFIG_STORE_ID)["is_config"] is True
|
||||
|
||||
|
||||
@pytest.mark.covers("mgmt.vector_store.write.config_store_is_read_only")
|
||||
def test_config_store_refuses_new_update_and_delete(gateway: Gateway) -> None:
|
||||
assert_config_write_refused(gateway)
|
||||
row: Final = listed_store(gateway, CONFIG_STORE_ID)
|
||||
assert row["vector_store_name"] == CONFIG_STORE_NAME, row
|
||||
assert row["is_config"] is True, row
|
||||
assert config_store_info(gateway)["vector_store_name"] == CONFIG_STORE_NAME
|
||||
|
||||
|
||||
@pytest.mark.covers("mgmt.vector_store.write.db_store_lifecycle_unchanged_beside_config_store")
|
||||
def test_db_store_lifecycle_is_unchanged_beside_config_store(gateway: Gateway) -> None:
|
||||
incomplete: Final = gateway.request("POST", "/vector_store/new", {"custom_llm_provider": "openai"})
|
||||
assert incomplete.status_code == 400, incomplete.text
|
||||
db_store_id: Final = f"vs_db_{uuid.uuid4().hex}"
|
||||
created: Final = gateway.request(
|
||||
"POST",
|
||||
"/vector_store/new",
|
||||
{"vector_store_id": db_store_id, "custom_llm_provider": "openai", "vector_store_name": "first"},
|
||||
)
|
||||
assert created.status_code == 200, created.text
|
||||
assert store_rows(db_store_id) == [{"vector_store_id": db_store_id, "vector_store_name": "first"}]
|
||||
updated: Final = gateway.post(
|
||||
"/vector_store/update", {"vector_store_id": db_store_id, "vector_store_name": "second"}
|
||||
)
|
||||
assert object_value(updated["vector_store"])["vector_store_name"] == "second", updated
|
||||
assert store_rows(db_store_id) == [{"vector_store_id": db_store_id, "vector_store_name": "second"}]
|
||||
row: Final = listed_store(gateway, db_store_id)
|
||||
assert row["vector_store_name"] == "second" and row["is_config"] is False, row
|
||||
info: Final = object_value(gateway.post("/vector_store/info", {"vector_store_id": db_store_id})["vector_store"])
|
||||
assert info["vector_store_name"] == "second" and info["is_config"] is False, info
|
||||
gateway.post("/vector_store/delete", {"vector_store_id": db_store_id})
|
||||
assert store_rows(db_store_id) == []
|
||||
assert db_store_id not in listed_ids(gateway)
|
||||
assert CONFIG_STORE_ID in listed_ids(gateway)
|
||||
missing: Final = gateway.request("POST", "/vector_store/info", {"vector_store_id": db_store_id})
|
||||
assert missing.status_code == 404, missing.text
|
||||
|
||||
|
||||
@pytest.mark.covers("other.vector_store.chat.config_store_search_reaches_upstream_after_listing")
|
||||
def test_chat_with_config_store_searches_upstream_and_injects_context_after_listing(gateway: Gateway) -> None:
|
||||
with (
|
||||
gateway.scenario() as scenario,
|
||||
httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as upstream,
|
||||
):
|
||||
model: Final = scenario.model()
|
||||
marker: Final = f"lit6337 {uuid.uuid4().hex}"
|
||||
assert CONFIG_STORE_ID in listed_ids(gateway)
|
||||
upstream.get("/__observations").raise_for_status()
|
||||
completion: Final = gateway.post(
|
||||
"/v1/chat/completions",
|
||||
{"model": model, "messages": [{"role": "user", "content": marker}], "vector_store_ids": [CONFIG_STORE_ID]},
|
||||
)
|
||||
assert object_value(completion["usage"])["total_tokens"] == 40, completion
|
||||
requests: Final = upstream_requests(upstream, marker)
|
||||
searches: Final = [value for value in requests if value["path"] == SEARCH_PATH]
|
||||
assert len(searches) == 1, requests
|
||||
assert object_value(searches[0]["body"])["query"] == marker, searches
|
||||
assert searches[0]["authorization"] == "Bearer integration-provider-key", searches
|
||||
chats: Final = [value for value in requests if value["path"] == "/v1/chat/completions"]
|
||||
assert len(chats) == 1, requests
|
||||
messages: Final = object_value(chats[0]["body"])["messages"]
|
||||
assert isinstance(messages, list), chats
|
||||
contents: Final = tuple(str(object_value(message)["content"]) for message in messages)
|
||||
assert contents == (f"Context:\n\nscripted context for {marker}\n\n", marker), contents
|
||||
|
||||
|
||||
@pytest.mark.covers("other.vector_store.search.config_store_passthrough_uses_yaml_credentials_after_listing")
|
||||
def test_passthrough_search_on_config_store_uses_yaml_credentials_after_listing(gateway: Gateway) -> None:
|
||||
with httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as upstream:
|
||||
marker: Final = f"lit6337 passthrough {uuid.uuid4().hex}"
|
||||
assert CONFIG_STORE_ID in listed_ids(gateway)
|
||||
upstream.get("/__observations").raise_for_status()
|
||||
searched: Final = gateway.request("POST", f"/v1/vector_stores/{CONFIG_STORE_ID}/search", {"query": marker})
|
||||
assert searched.status_code == 200, searched.text
|
||||
data: Final = listed_rows(searched)
|
||||
assert len(data) == 1, searched.text
|
||||
content: Final = data[0]["content"]
|
||||
assert isinstance(content, list), searched.text
|
||||
assert object_value(content[0])["text"] == f"scripted context for {marker}", searched.text
|
||||
requests: Final = upstream_requests(upstream, marker)
|
||||
assert [value["path"] for value in requests] == [SEARCH_PATH], requests
|
||||
assert requests[0]["authorization"] == "Bearer integration-provider-key", requests
|
||||
|
||||
|
||||
@pytest.mark.covers("authz.vector_store.list.non_admin_key_access_to_config_store_follows_grants")
|
||||
def test_non_admin_key_access_to_config_store_follows_grants_after_admin_listing(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
granted: Final = scenario.key(object_permission={"vector_stores": [CONFIG_STORE_ID]})
|
||||
plain: Final = scenario.key()
|
||||
assert CONFIG_STORE_ID in listed_ids(gateway)
|
||||
row: Final = listed_store(gateway, CONFIG_STORE_ID, key=granted)
|
||||
assert row["is_config"] is True and row["vector_store_name"] == CONFIG_STORE_NAME, row
|
||||
unlisted: Final = gateway.request("GET", "/vector_store/list", key=plain)
|
||||
assert unlisted.status_code == 200, unlisted.text
|
||||
assert CONFIG_STORE_ID not in {value["vector_store_id"] for value in listed_rows(unlisted)}, unlisted.text
|
||||
for key in (granted, plain):
|
||||
info = gateway.request("POST", "/vector_store/info", {"vector_store_id": CONFIG_STORE_ID}, key=key)
|
||||
assert info.status_code == 200, info.text
|
||||
assert object_value(object_value(info.json())["vector_store"])["is_config"] is True, info.text
|
||||
forbidden: Final = gateway.request(
|
||||
"POST", "/vector_store/delete", {"vector_store_id": CONFIG_STORE_ID}, key=granted
|
||||
)
|
||||
assert forbidden.status_code in {400, 401, 403}, forbidden.text
|
||||
assert CONFIG_STORE_ID in listed_ids(gateway)
|
||||
|
||||
|
||||
@pytest.mark.covers("mgmt.vector_store.list.peer_process_keeps_config_store_and_sees_db_store")
|
||||
def test_peer_process_keeps_config_store_and_sees_db_store_created_elsewhere(gateway: Gateway, peer: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
db_store_id: Final = f"vs_db_{uuid.uuid4().hex}"
|
||||
gateway.post("/vector_store/new", {"vector_store_id": db_store_id, "custom_llm_provider": "openai"})
|
||||
scenario.cleanups.callback(gateway.request, "POST", "/vector_store/delete", {"vector_store_id": db_store_id})
|
||||
for side in (gateway, peer, gateway, peer):
|
||||
assert listed_store(side, CONFIG_STORE_ID)["is_config"] is True
|
||||
assert listed_store(side, db_store_id)["is_config"] is False
|
||||
assert config_store_info(side)["is_config"] is True
|
||||
assert_config_write_refused(side)
|
||||
gateway.post("/vector_store/delete", {"vector_store_id": db_store_id})
|
||||
assert db_store_id not in listed_ids(peer)
|
||||
assert CONFIG_STORE_ID in listed_ids(peer)
|
||||
|
||||
|
||||
@pytest.mark.covers("mgmt.vector_store.chaos.concurrent_burst_keeps_config_store_across_workers")
|
||||
def test_concurrent_burst_keeps_config_store_and_refuses_every_config_write(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
model: Final = scenario.model()
|
||||
db_store_ids: Final = tuple(f"vs_db_{uuid.uuid4().hex}" for _ in range(6))
|
||||
for db_store_id in db_store_ids:
|
||||
scenario.cleanups.callback(
|
||||
gateway.request, "POST", "/vector_store/delete", {"vector_store_id": db_store_id}
|
||||
)
|
||||
|
||||
def act(index: int) -> tuple[str, int, str]:
|
||||
match index % 5:
|
||||
case 0:
|
||||
return ("list", *burst_list(gateway))
|
||||
case 1:
|
||||
return ("info", *burst_post(gateway, "/vector_store/info", {"vector_store_id": CONFIG_STORE_ID}))
|
||||
case 2:
|
||||
return (
|
||||
"config-update",
|
||||
*burst_post(
|
||||
gateway,
|
||||
"/vector_store/update",
|
||||
{"vector_store_id": CONFIG_STORE_ID, "vector_store_name": str(index)},
|
||||
),
|
||||
)
|
||||
case 3:
|
||||
return (
|
||||
"db-new",
|
||||
*burst_post(
|
||||
gateway,
|
||||
"/vector_store/new",
|
||||
{
|
||||
"vector_store_id": db_store_ids[index % len(db_store_ids)],
|
||||
"custom_llm_provider": "openai",
|
||||
},
|
||||
),
|
||||
)
|
||||
case _:
|
||||
return (
|
||||
"chat",
|
||||
*burst_post(
|
||||
gateway,
|
||||
"/v1/chat/completions",
|
||||
{
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": f"burst {index}"}],
|
||||
"vector_store_ids": [CONFIG_STORE_ID],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as pool:
|
||||
outcomes: Final = tuple(pool.map(act, range(30)))
|
||||
expected: Final = {"list": 200, "info": 200, "config-update": 400, "db-new": 200, "chat": 200}
|
||||
assert [(kind, status) for kind, status, _ in outcomes] == [
|
||||
(kind, expected[kind]) for kind, _, _ in outcomes
|
||||
], outcomes
|
||||
assert all(detail == "config" for kind, _, detail in outcomes if kind == "list"), outcomes
|
||||
assert listed_store(gateway, CONFIG_STORE_ID)["vector_store_name"] == CONFIG_STORE_NAME
|
||||
assert config_store_info(gateway)["vector_store_name"] == CONFIG_STORE_NAME
|
||||
assert store_rows(CONFIG_STORE_ID) == []
|
||||
assert all(len(store_rows(db_store_id)) == 1 for db_store_id in db_store_ids), "each DB store exactly once"
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
@pytest.mark.covers("mgmt.vector_store.chaos.redis_outage_keeps_config_store_and_recovers")
|
||||
def test_redis_outage_keeps_config_store_served_and_recovers(
|
||||
gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
original: Final = os.environ["DATABASE_URL"]
|
||||
identity: Final = "integration_vs_outage_" + uuid.uuid4().hex
|
||||
parsed: Final = urlsplit(original)
|
||||
database_url: Final = urlunsplit((parsed.scheme, parsed.netloc, "/" + identity, "", ""))
|
||||
with psycopg.connect(original, autocommit=True) as admin:
|
||||
admin.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(identity)))
|
||||
try:
|
||||
with owned_redis(tmp_path) as cache, monkeypatch.context() as environment:
|
||||
environment.setenv("DATABASE_URL", database_url)
|
||||
overrides: Final = {
|
||||
"DATABASE_URL": database_url,
|
||||
"REDIS_HOST": cache.host,
|
||||
"REDIS_PORT": str(cache.port),
|
||||
"REDIS_CIRCUIT_BREAKER_RECOVERY_TIMEOUT": "1",
|
||||
}
|
||||
with owned_proxy(gateway, tmp_path, overrides, config=PROXY_CONFIG, workers=2) as candidate:
|
||||
db_store_id: Final = f"vs_db_{uuid.uuid4().hex}"
|
||||
for phase in ("before", "during", "after"):
|
||||
if phase == "during":
|
||||
cache.stop()
|
||||
if phase == "after":
|
||||
cache.start()
|
||||
for _ in range(4):
|
||||
assert listed_store(candidate, CONFIG_STORE_ID)["is_config"] is True, phase
|
||||
assert config_store_info(candidate)["vector_store_name"] == CONFIG_STORE_NAME, phase
|
||||
assert_config_write_refused(candidate)
|
||||
created = candidate.request(
|
||||
"POST",
|
||||
"/vector_store/new",
|
||||
{"vector_store_id": f"{db_store_id}_{phase}", "custom_llm_provider": "openai"},
|
||||
)
|
||||
assert created.status_code == 200, (phase, created.text)
|
||||
assert eventually(
|
||||
lambda phase=phase: store_rows(f"{db_store_id}_{phase}"), lambda rows: len(rows) == 1
|
||||
), phase
|
||||
assert f"{db_store_id}_{phase}" in listed_ids(candidate), phase
|
||||
assert store_rows(CONFIG_STORE_ID) == []
|
||||
with psycopg.connect(database_url) as fresh:
|
||||
counted: Final = fresh.execute(
|
||||
'SELECT count(*) FROM "LiteLLM_ManagedVectorStoresTable" WHERE vector_store_id LIKE %s',
|
||||
(f"{db_store_id}%",),
|
||||
).fetchone()
|
||||
assert counted is not None and counted[0] == 3, counted
|
||||
finally:
|
||||
admin.execute(sql.SQL("DROP DATABASE {} WITH (FORCE)").format(sql.Identifier(identity)))
|
||||
assert admin.execute("SELECT datname FROM pg_database WHERE datname=%s", (identity,)).fetchall() == []
|
||||
|
|
@ -14,3 +14,11 @@ litellm_settings:
|
|||
port: os.environ/REDIS_PORT
|
||||
router_settings:
|
||||
disable_cooldowns: true
|
||||
vector_store_registry:
|
||||
- vector_store_name: integration-config-store
|
||||
litellm_params:
|
||||
vector_store_id: vs_integration_config_store
|
||||
custom_llm_provider: openai
|
||||
api_base: os.environ/INTEGRATION_UPSTREAM_URL
|
||||
api_key: integration-provider-key
|
||||
vector_store_description: declared in tests/integration/proxy_config.yaml
|
||||
|
|
|
|||
|
|
@ -2189,6 +2189,7 @@ async def test_new_vector_store_persists_embedding_reference_without_credentials
|
|||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.add_vector_store_to_registry = MagicMock()
|
||||
mock_registry.is_config_vector_store.return_value = False
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client),
|
||||
|
|
@ -2267,6 +2268,7 @@ async def test_new_vector_store_auto_resolves_from_router():
|
|||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.add_vector_store_to_registry = MagicMock()
|
||||
mock_registry.is_config_vector_store.return_value = False
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client),
|
||||
|
|
@ -3061,3 +3063,196 @@ def test_vector_store_search_rejects_caller_embedding_selection_params(blocked_k
|
|||
|
||||
assert response.status_code == 400, response.json()
|
||||
assert blocked_key in str(response.json())
|
||||
|
||||
|
||||
class TestConfigOwnedVectorStores:
|
||||
"""Stores declared under ``vector_store_registry`` in config.yaml are owned by the config file"""
|
||||
|
||||
CONFIG_ID = "vs_from_config"
|
||||
DB_ID = "vs_from_db"
|
||||
|
||||
def _registry(self):
|
||||
from litellm.vector_stores.vector_store_registry import VectorStoreRegistry
|
||||
|
||||
registry = VectorStoreRegistry(vector_stores=[])
|
||||
registry.load_vector_stores_from_config(
|
||||
[
|
||||
{
|
||||
"vector_store_name": "config-store",
|
||||
"litellm_params": {"vector_store_id": self.CONFIG_ID, "custom_llm_provider": "openai"},
|
||||
}
|
||||
]
|
||||
)
|
||||
registry.add_vector_store_to_registry(self._db_row(self.DB_ID, "db-store"))
|
||||
registry.add_vector_store_to_registry(self._db_row("vs_stale", "deleted-elsewhere"))
|
||||
return registry
|
||||
|
||||
@staticmethod
|
||||
def _db_row(vector_store_id: str, vector_store_name: str) -> dict:
|
||||
return {
|
||||
"vector_store_id": vector_store_id,
|
||||
"custom_llm_provider": "openai",
|
||||
"vector_store_name": vector_store_name,
|
||||
"litellm_params": {},
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _admin() -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_keeps_config_store_that_has_no_db_row(self):
|
||||
from litellm.proxy.vector_store_endpoints.management_endpoints import list_vector_stores
|
||||
|
||||
registry = self._registry()
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_managedvectorstorestable.find_many = AsyncMock(return_value=[self._db_row(self.DB_ID, "db-store")])
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: proxy_server global, no seam
|
||||
patch.object(litellm, "vector_store_registry", registry),
|
||||
):
|
||||
first = await list_vector_stores(user_api_key_dict=self._admin())
|
||||
second = await list_vector_stores(user_api_key_dict=self._admin())
|
||||
|
||||
assert [(vs["vector_store_id"], vs["is_config"]) for vs in first["data"]] == [(self.DB_ID, False), (self.CONFIG_ID, True)]
|
||||
assert second["data"] == first["data"]
|
||||
assert [vs["vector_store_id"] for vs in registry.vector_stores] == [self.CONFIG_ID, self.DB_ID]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_keeps_config_store_and_db_row_with_same_id_does_not_overwrite_it(self):
|
||||
from litellm.proxy.vector_store_endpoints.management_endpoints import list_vector_stores
|
||||
|
||||
registry = self._registry()
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_managedvectorstorestable.find_many = AsyncMock(
|
||||
return_value=[self._db_row(self.DB_ID, "db-store"), self._db_row(self.CONFIG_ID, "renamed-in-db")]
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: proxy_server global, no seam
|
||||
patch.object(litellm, "vector_store_registry", registry),
|
||||
):
|
||||
response = await list_vector_stores(user_api_key_dict=self._admin())
|
||||
|
||||
by_id = {vs["vector_store_id"]: vs for vs in response["data"]}
|
||||
assert set(by_id) == {self.CONFIG_ID, self.DB_ID}, response
|
||||
assert (by_id[self.CONFIG_ID]["vector_store_name"], by_id[self.CONFIG_ID]["is_config"]) == ("config-store", True)
|
||||
assert (by_id[self.DB_ID]["vector_store_name"], by_id[self.DB_ID]["is_config"]) == ("db-store", False)
|
||||
assert [vs["vector_store_id"] for vs in registry.vector_stores] == [self.CONFIG_ID, self.DB_ID]
|
||||
assert registry.get_litellm_managed_vector_store_from_registry(self.CONFIG_ID)["vector_store_name"] == "config-store"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_info_reports_config_ownership(self):
|
||||
from litellm.proxy.vector_store_endpoints.management_endpoints import get_vector_store_info
|
||||
from litellm.types.vector_stores import VectorStoreInfoRequest
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), # test-quality-ok: proxy_server global, no seam
|
||||
patch.object(litellm, "vector_store_registry", self._registry()),
|
||||
):
|
||||
config_info = await get_vector_store_info(
|
||||
data=VectorStoreInfoRequest(vector_store_id=self.CONFIG_ID), user_api_key_dict=self._admin()
|
||||
)
|
||||
db_info = await get_vector_store_info(
|
||||
data=VectorStoreInfoRequest(vector_store_id=self.DB_ID), user_api_key_dict=self._admin()
|
||||
)
|
||||
|
||||
assert config_info["vector_store"].is_config is True
|
||||
assert db_info["vector_store"].is_config is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_with_config_store_id_is_rejected_before_db_write(self):
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_managedvectorstorestable.find_unique = AsyncMock(return_value=None)
|
||||
prisma.db.litellm_managedvectorstorestable.create = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: proxy_server global, no seam
|
||||
patch.object(litellm, "vector_store_registry", self._registry()),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await new_vector_store(
|
||||
vector_store={"vector_store_id": self.CONFIG_ID, "custom_llm_provider": "openai"},
|
||||
user_api_key_dict=self._admin(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400, exc_info.value.detail
|
||||
assert exc_info.value.detail["vector_store_id"] == self.CONFIG_ID
|
||||
assert "config file" in exc_info.value.detail["error"]
|
||||
prisma.db.litellm_managedvectorstorestable.create.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_of_config_store_is_rejected_before_db_write(self):
|
||||
from litellm.proxy.vector_store_endpoints.management_endpoints import update_vector_store
|
||||
from litellm.types.vector_stores import VectorStoreUpdateRequest
|
||||
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_managedvectorstorestable.find_unique = AsyncMock(return_value=None)
|
||||
prisma.db.litellm_managedvectorstorestable.update = AsyncMock()
|
||||
registry = self._registry()
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: proxy_server global, no seam
|
||||
patch.object(litellm, "vector_store_registry", registry),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await update_vector_store(
|
||||
data=VectorStoreUpdateRequest(vector_store_id=self.CONFIG_ID, vector_store_name="renamed"),
|
||||
user_api_key_dict=self._admin(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400, exc_info.value.detail
|
||||
assert exc_info.value.detail["vector_store_id"] == self.CONFIG_ID
|
||||
prisma.db.litellm_managedvectorstorestable.update.assert_not_called()
|
||||
assert registry.get_litellm_managed_vector_store_from_registry(self.CONFIG_ID)["vector_store_name"] == "config-store"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_of_config_store_is_rejected_and_store_stays_registered(self):
|
||||
from litellm.proxy.vector_store_endpoints.management_endpoints import delete_vector_store
|
||||
from litellm.types.vector_stores import VectorStoreDeleteRequest
|
||||
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_managedvectorstorestable.find_unique = AsyncMock(return_value=None)
|
||||
prisma.db.litellm_managedvectorstorestable.delete = AsyncMock()
|
||||
registry = self._registry()
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: proxy_server global, no seam
|
||||
patch.object(litellm, "vector_store_registry", registry),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await delete_vector_store(
|
||||
data=VectorStoreDeleteRequest(vector_store_id=self.CONFIG_ID), user_api_key_dict=self._admin()
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400, exc_info.value.detail
|
||||
assert exc_info.value.detail["vector_store_id"] == self.CONFIG_ID
|
||||
prisma.db.litellm_managedvectorstorestable.delete.assert_not_called()
|
||||
assert registry.is_config_vector_store(self.CONFIG_ID) is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_of_db_store_still_works(self):
|
||||
from litellm.proxy.vector_store_endpoints.management_endpoints import delete_vector_store
|
||||
from litellm.types.vector_stores import VectorStoreDeleteRequest
|
||||
|
||||
row = MagicMock()
|
||||
row.model_dump = MagicMock(return_value=self._db_row(self.DB_ID, "db-store"))
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_managedvectorstorestable.find_unique = AsyncMock(return_value=row)
|
||||
prisma.db.litellm_managedvectorstorestable.delete = AsyncMock()
|
||||
registry = self._registry()
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: proxy_server global, no seam
|
||||
patch.object(litellm, "vector_store_registry", registry),
|
||||
):
|
||||
response = await delete_vector_store(
|
||||
data=VectorStoreDeleteRequest(vector_store_id=self.DB_ID), user_api_key_dict=self._admin()
|
||||
)
|
||||
|
||||
assert response["status"] == "success", response
|
||||
prisma.db.litellm_managedvectorstorestable.delete.assert_awaited_once_with(where={"vector_store_id": self.DB_ID})
|
||||
assert registry.get_litellm_managed_vector_store_from_registry(self.DB_ID) is None
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from fastapi.testclient import TestClient
|
|||
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import litellm
|
||||
from litellm.types.vector_stores import LiteLLM_ManagedVectorStore
|
||||
|
|
@ -182,3 +182,70 @@ def test_search_uses_registry_credentials():
|
|||
assert getattr(called_params, "aws_region_name") == "us-east-1"
|
||||
finally:
|
||||
litellm.vector_store_registry = original_registry
|
||||
|
||||
|
||||
def _config_registry(vector_store_id: str = "vs_from_config") -> VectorStoreRegistry:
|
||||
registry = VectorStoreRegistry(vector_stores=[])
|
||||
registry.load_vector_stores_from_config(
|
||||
[
|
||||
{
|
||||
"vector_store_name": "config-store",
|
||||
"litellm_params": {"vector_store_id": vector_store_id, "custom_llm_provider": "openai"},
|
||||
}
|
||||
]
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
def _db_store(vector_store_id: str, vector_store_name: str) -> LiteLLM_ManagedVectorStore:
|
||||
return LiteLLM_ManagedVectorStore(
|
||||
vector_store_id=vector_store_id,
|
||||
custom_llm_provider="openai",
|
||||
vector_store_name=vector_store_name,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def test_config_loaded_store_is_marked_config_owned_and_db_store_is_not():
|
||||
registry = _config_registry()
|
||||
registry.add_vector_store_to_registry(_db_store("vs_from_db", "db-store"))
|
||||
|
||||
assert registry.get_litellm_managed_vector_store_from_registry("vs_from_config")["is_config"] is True
|
||||
assert registry.is_config_vector_store("vs_from_config") is True
|
||||
assert registry.is_config_vector_store("vs_from_db") is False
|
||||
assert registry.is_config_vector_store("vs_unknown") is False
|
||||
|
||||
|
||||
def test_db_row_does_not_overwrite_config_owned_store_in_registry():
|
||||
registry = _config_registry()
|
||||
registry.add_vector_store_to_registry(_db_store("vs_from_db", "db-store"))
|
||||
|
||||
registry.update_vector_store_in_registry("vs_from_config", _db_store("vs_from_config", "renamed-in-db"))
|
||||
registry.update_vector_store_in_registry("vs_from_db", _db_store("vs_from_db", "renamed-in-db"))
|
||||
|
||||
assert registry.get_litellm_managed_vector_store_from_registry("vs_from_config") == {
|
||||
**registry.get_litellm_managed_vector_store_from_registry("vs_from_config"),
|
||||
"vector_store_name": "config-store",
|
||||
"is_config": True,
|
||||
}
|
||||
assert registry.get_litellm_managed_vector_store_from_registry("vs_from_db")["vector_store_name"] == "renamed-in-db"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_owned_store_survives_db_liveness_check_while_missing_db_store_is_evicted():
|
||||
registry = _config_registry()
|
||||
registry.add_vector_store_to_registry(_db_store("vs_from_db", "db-store"))
|
||||
prisma_client = MagicMock()
|
||||
prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(return_value=None)
|
||||
|
||||
to_run = await registry.pop_vector_stores_to_run_with_db_fallback(
|
||||
non_default_params={"vector_store_ids": ["vs_from_config", "vs_from_db"]},
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
assert [vs["vector_store_id"] for vs in to_run] == ["vs_from_config"]
|
||||
assert [vs["vector_store_id"] for vs in registry.vector_stores] == ["vs_from_config"]
|
||||
prisma_client.db.litellm_managedvectorstorestable.find_unique.assert_awaited_once_with(
|
||||
where={"vector_store_id": "vs_from_db"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -59,7 +59,16 @@ describe("VectorStoreTable", () => {
|
|||
|
||||
it("should render every column header", () => {
|
||||
render(<VectorStoreTable {...defaultProps} />);
|
||||
for (const header of ["Vector Store ID", "Name", "Description", "Files", "Provider", "Created At", "Updated At"]) {
|
||||
for (const header of [
|
||||
"Vector Store ID",
|
||||
"Name",
|
||||
"Description",
|
||||
"Source",
|
||||
"Files",
|
||||
"Provider",
|
||||
"Created At",
|
||||
"Updated At",
|
||||
]) {
|
||||
expect(screen.getByText(header)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
|
@ -112,6 +121,36 @@ describe("VectorStoreTable", () => {
|
|||
expect(mockOnDelete).toHaveBeenCalledWith("vs-newer");
|
||||
});
|
||||
|
||||
it("should label each row's source as Config or DB", () => {
|
||||
const configStore: VectorStore = { ...mockVectorStores[1], vector_store_id: "vs-config", is_config: true };
|
||||
render(<VectorStoreTable {...defaultProps} data={[mockVectorStores[0], configStore]} />);
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
const dbRow = rows.find((row) => within(row).queryByText("vs-newer"));
|
||||
const configRow = rows.find((row) => within(row).queryByText("vs-config"));
|
||||
expect(within(dbRow!).getByText("DB")).toBeInTheDocument();
|
||||
expect(within(dbRow!).queryByText("Config")).not.toBeInTheDocument();
|
||||
expect(within(configRow!).getByText("Config")).toBeInTheDocument();
|
||||
expect(within(configRow!).queryByText("DB")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep edit and delete disabled for a config-defined store while copy still works", async () => {
|
||||
const user = userEvent.setup();
|
||||
const configStore: VectorStore = { ...mockVectorStores[1], vector_store_id: "vs-config", is_config: true };
|
||||
render(<VectorStoreTable {...defaultProps} data={[mockVectorStores[0], configStore]} />);
|
||||
await user.click(screen.getByTestId("vector-store-actions-vs-config"));
|
||||
const editItem = await screen.findByTestId("vector-store-action-edit");
|
||||
const deleteItem = screen.getByTestId("vector-store-action-delete");
|
||||
expect(editItem).toHaveAttribute("aria-disabled", "true");
|
||||
expect(deleteItem).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText(/Read only: this vector store is defined in the config file/)).toBeVisible();
|
||||
await user.click(editItem);
|
||||
await user.click(deleteItem);
|
||||
expect(mockOnEdit).not.toHaveBeenCalled();
|
||||
expect(mockOnDelete).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByTestId("vector-store-action-copy"));
|
||||
expect(await window.navigator.clipboard.readText()).toBe("vs-config");
|
||||
});
|
||||
|
||||
it("should copy the vector store ID through the actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VectorStoreTable {...defaultProps} />);
|
||||
|
|
@ -119,4 +158,12 @@ describe("VectorStoreTable", () => {
|
|||
await user.click(await screen.findByTestId("vector-store-action-copy"));
|
||||
expect(await window.navigator.clipboard.readText()).toBe("vs-newer");
|
||||
});
|
||||
|
||||
it("should not show the read-only hint for a database-backed store", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VectorStoreTable {...defaultProps} />);
|
||||
await user.click(screen.getByTestId("vector-store-actions-vs-newer"));
|
||||
await screen.findByTestId("vector-store-action-edit");
|
||||
expect(screen.queryByText(/Read only: this vector store is defined in the config file/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { ColumnDef } from "@tanstack/react-table";
|
|||
import { Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { CellTooltip, DateCell, IdentityCell } from "@/components/shared/table_cells";
|
||||
import { CellTooltip, DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { getVectorStoreProviderLogoAndName } from "@/components/vector_store_providers";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -18,6 +18,9 @@ import { VectorStore } from "@/components/vector_store_management/types";
|
|||
import { cn } from "@/lib/cva.config";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
|
||||
const CONFIG_STORE_HINT =
|
||||
"Read only: this vector store is defined in the config file and cannot be edited or deleted on the dashboard.";
|
||||
|
||||
function VectorStoreProviderCell({ provider }: { provider: string }) {
|
||||
const { displayName, logo } = getVectorStoreProviderLogoAndName(provider);
|
||||
return (
|
||||
|
|
@ -64,6 +67,7 @@ interface VectorStoreRowActionsProps {
|
|||
}
|
||||
|
||||
function VectorStoreRowActions({ vectorStore, onEdit, onDelete }: VectorStoreRowActionsProps) {
|
||||
const isFromConfig = vectorStore.is_config ?? false;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
|
|
@ -74,7 +78,11 @@ function VectorStoreRowActions({ vectorStore, onEdit, onDelete }: VectorStoreRow
|
|||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem data-testid="vector-store-action-edit" onClick={() => onEdit(vectorStore.vector_store_id)}>
|
||||
<DropdownMenuItem
|
||||
data-testid="vector-store-action-edit"
|
||||
disabled={isFromConfig}
|
||||
onClick={() => onEdit(vectorStore.vector_store_id)}
|
||||
>
|
||||
<Pencil />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -89,11 +97,17 @@ function VectorStoreRowActions({ vectorStore, onEdit, onDelete }: VectorStoreRow
|
|||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
data-testid="vector-store-action-delete"
|
||||
disabled={isFromConfig}
|
||||
onClick={() => onDelete(vectorStore.vector_store_id)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
{isFromConfig && (
|
||||
<div data-testid="vector-store-config-hint" className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{CONFIG_STORE_HINT}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
|
@ -158,6 +172,18 @@ export const getVectorStoreTableColumns = ({
|
|||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
accessorFn: (row) => row.is_config ?? false,
|
||||
meta: { title: "Source", skeleton: "badge" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Source" />,
|
||||
size: 110,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const isFromConfig = row.original.is_config ?? false;
|
||||
return <StatusBadge tone={isFromConfig ? "neutral" : "info"} label={isFromConfig ? "Config" : "DB"} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "files",
|
||||
meta: { title: "Files" },
|
||||
|
|
|
|||
|
|
@ -59,6 +59,61 @@ describe("VectorStoreInfoView", () => {
|
|||
expect(await screen.findByText("Vector Store ID: vs-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a config-defined store read-only for an admin, even when opened in edit mode", async () => {
|
||||
mockVectorStoreInfoCall.mockResolvedValue({
|
||||
vector_store: {
|
||||
vector_store_id: "vs-config",
|
||||
vector_store_name: "config-store",
|
||||
custom_llm_provider: "openai",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
is_config: true,
|
||||
},
|
||||
});
|
||||
render(
|
||||
<VectorStoreInfoView
|
||||
vectorStoreId="vs-config"
|
||||
onClose={vi.fn()}
|
||||
accessToken="sk-test"
|
||||
is_admin={true}
|
||||
editVectorStore={true}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText("Vector Store ID: vs-config")).toBeInTheDocument();
|
||||
expect(screen.getByText("Read only: defined in the config file")).toBeInTheDocument();
|
||||
expect(screen.getByText("Config")).toBeInTheDocument();
|
||||
expect(screen.queryByText("DB")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Vector Store Details")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit Vector Store" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Save/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should still offer editing for a database-backed store", async () => {
|
||||
mockVectorStoreInfoCall.mockResolvedValue({
|
||||
vector_store: {
|
||||
vector_store_id: "vs-db",
|
||||
vector_store_name: "db-store",
|
||||
custom_llm_provider: "openai",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
is_config: false,
|
||||
},
|
||||
});
|
||||
render(
|
||||
<VectorStoreInfoView
|
||||
vectorStoreId="vs-db"
|
||||
onClose={vi.fn()}
|
||||
accessToken="sk-test"
|
||||
is_admin={true}
|
||||
editVectorStore={false}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText("Vector Store ID: vs-db")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Read only: defined in the config file")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("DB")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: "Edit Vector Store" }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should show a not-found state with a working back button when the fetch fails instead of loading forever", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { ArrowLeft, CircleHelp } from "lucide-react";
|
||||
import { ArrowLeft, CircleHelp, Lock } from "lucide-react";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
vectorStoreInfoCall,
|
||||
|
|
@ -15,6 +15,8 @@ import VectorStoreTester from "./VectorStoreTester";
|
|||
import { toast } from "@/lib/toast";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { StatusBadge } from "@/components/shared/table_cells";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
|
@ -200,6 +202,9 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const canEdit = is_admin && !vectorStoreDetails.is_config;
|
||||
const showEditForm = isEditing && canEdit;
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-full">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
|
|
@ -208,14 +213,31 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
<ArrowLeft />
|
||||
Back to Vector Stores
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">Vector Store ID: {vectorStoreDetails.vector_store_id}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-semibold">Vector Store ID: {vectorStoreDetails.vector_store_id}</h1>
|
||||
<StatusBadge
|
||||
tone={vectorStoreDetails.is_config ? "neutral" : "info"}
|
||||
label={vectorStoreDetails.is_config ? "Config" : "DB"}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{vectorStoreDetails.vector_store_description || "No description"}
|
||||
</p>
|
||||
</div>
|
||||
{is_admin && !isEditing && <Button onClick={startEditing}>Edit Vector Store</Button>}
|
||||
{canEdit && !isEditing && <Button onClick={startEditing}>Edit Vector Store</Button>}
|
||||
</div>
|
||||
|
||||
{vectorStoreDetails.is_config && (
|
||||
<Alert variant="info" className="mb-4">
|
||||
<Lock className="size-4" aria-hidden />
|
||||
<AlertTitle>Read only: defined in the config file</AlertTitle>
|
||||
<AlertDescription>
|
||||
This vector store comes from the proxy config YAML, so it cannot be edited or deleted on the dashboard.
|
||||
Change or remove it in the config file and restart the proxy.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="details">
|
||||
<TabsList variant="line" className="mb-6 h-auto w-full justify-start rounded-none p-0">
|
||||
<TabsTrigger value="details" className="flex-none rounded-none px-4 py-2">
|
||||
|
|
@ -227,7 +249,7 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
</TabsList>
|
||||
|
||||
<TabsContent value="details" keepMounted>
|
||||
{isEditing ? (
|
||||
{showEditForm ? (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">Edit Vector Store</h3>
|
||||
|
|
@ -373,7 +395,7 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">Vector Store Details</h3>
|
||||
{is_admin && <Button onClick={startEditing}>Edit Vector Store</Button>}
|
||||
{canEdit && <Button onClick={startEditing}>Edit Vector Store</Button>}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface VectorStore {
|
|||
updated_at: string;
|
||||
created_by?: string;
|
||||
updated_by?: string;
|
||||
is_config?: boolean;
|
||||
}
|
||||
|
||||
export interface VectorStoreInfoRequest {
|
||||
|
|
|
|||
15
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
15
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -21168,7 +21168,9 @@ export interface paths {
|
|||
* List Vector Stores
|
||||
* @description List all available vector stores with optional filtering and pagination.
|
||||
* Combines both in-memory vector stores and those stored in the database.
|
||||
* Database is the source of truth - deleted stores are removed from memory, updated stores sync to memory.
|
||||
* Database is the source of truth for stores it owns: deleted stores are removed from memory, updated stores
|
||||
* sync to memory. Stores declared in the config file are owned by the config file, are always listed, and are
|
||||
* never overwritten by database rows.
|
||||
*
|
||||
* Parameters:
|
||||
* - page: int - Page number for pagination (default: 1)
|
||||
|
|
@ -22471,7 +22473,9 @@ export interface paths {
|
|||
* List Vector Stores
|
||||
* @description List all available vector stores with optional filtering and pagination.
|
||||
* Combines both in-memory vector stores and those stored in the database.
|
||||
* Database is the source of truth - deleted stores are removed from memory, updated stores sync to memory.
|
||||
* Database is the source of truth for stores it owns: deleted stores are removed from memory, updated stores
|
||||
* sync to memory. Stores declared in the config file are owned by the config file, are always listed, and are
|
||||
* never overwritten by database rows.
|
||||
*
|
||||
* Parameters:
|
||||
* - page: int - Page number for pagination (default: 1)
|
||||
|
|
@ -30876,6 +30880,8 @@ export interface components {
|
|||
created_at?: string | null;
|
||||
/** Custom Llm Provider */
|
||||
custom_llm_provider?: string;
|
||||
/** Is Config */
|
||||
is_config?: boolean;
|
||||
/** Litellm Credential Name */
|
||||
litellm_credential_name?: string | null;
|
||||
/** Litellm Params */
|
||||
|
|
@ -30947,6 +30953,11 @@ export interface components {
|
|||
created_at?: string | null;
|
||||
/** Custom Llm Provider */
|
||||
custom_llm_provider: string;
|
||||
/**
|
||||
* Is Config
|
||||
* @default false
|
||||
*/
|
||||
is_config: boolean;
|
||||
/** Litellm Credential Name */
|
||||
litellm_credential_name?: string | null;
|
||||
/** Litellm Params */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue