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:
devin-ai-integration[bot] 2026-09-23 04:02:16 +00:00 committed by GitHub
parent 5c0b374f0a
commit f44052d87b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 917 additions and 24 deletions

View file

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

View file

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

View file

@ -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": [
{

View file

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

View file

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

View file

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

View file

@ -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",
],

View file

@ -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),

View file

@ -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"
],

View file

@ -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() == []

View file

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

View file

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

View file

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

View file

@ -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();
});
});

View file

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

View file

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

View file

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

View file

@ -23,6 +23,7 @@ export interface VectorStore {
updated_at: string;
created_by?: string;
updated_by?: string;
is_config?: boolean;
}
export interface VectorStoreInfoRequest {

View file

@ -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 */