Merge pull request #34788 from BerriAI/litellm_fix_s3_vectors_search

fix(vector_stores): s3 vectors search router bypass + rag query config drop + ui error swallow
This commit is contained in:
Mateo Wang 2026-09-02 11:35:56 -07:00 committed by GitHub
commit b600f02fc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 918 additions and 90 deletions

View file

@ -19,6 +19,7 @@ from litellm.types.vector_stores import (
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
from litellm.router import Router
LiteLLMLoggingObj = _LiteLLMLoggingObj
else:
@ -115,6 +116,7 @@ class AzureAIVectorStoreConfig(BaseVectorStoreConfig, BaseAzureLLM):
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
extra_body: dict[str, Any] | None = None,
router: "Router | None" = None,
) -> tuple[str, dict[str, Any]]:
"""
Transform search request for Azure AI Search API

View file

@ -17,6 +17,7 @@ from litellm.types.vector_stores import (
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
from litellm.router import Router
from ..chat.transformation import BaseLLMException as _BaseLLMException
@ -57,6 +58,7 @@ class BaseVectorStoreConfig:
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
extra_body: dict[str, Any] | None = None,
router: "Router | None" = None,
) -> tuple[str, dict]:
pass
@ -69,6 +71,7 @@ class BaseVectorStoreConfig:
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
extra_body: dict[str, Any] | None = None,
router: "Router | None" = None,
) -> tuple[str, dict]:
"""
Optional async version of transform_search_vector_store_request.
@ -84,6 +87,7 @@ class BaseVectorStoreConfig:
litellm_logging_obj=litellm_logging_obj,
litellm_params=litellm_params,
extra_body=extra_body,
router=router,
)
@abstractmethod
@ -197,6 +201,7 @@ class BaseDirectVectorStoreConfig(BaseVectorStoreConfig):
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: Mapping[str, object],
extra_body: Mapping[str, object] | None = None,
router: "Router | None" = None,
) -> NoReturn:
raise NotImplementedError("Direct vector store providers execute the search themselves; no HTTP request shape")

View file

@ -27,6 +27,7 @@ from litellm.types.vector_stores import (
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.router import Router
else:
LiteLLMLoggingObj = Any
@ -196,6 +197,7 @@ class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
extra_body: dict[str, Any] | None = None,
router: "Router | None" = None,
) -> tuple[str, dict]:
if isinstance(query, list):
query = " ".join(query)

View file

@ -178,6 +178,7 @@ if TYPE_CHECKING:
AnthropicMessagesStreamingResponse,
)
from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig
from litellm.router import Router
from litellm.types.llms.openai_evals import (
CancelEvalResponse,
CancelRunResponse,
@ -2923,7 +2924,7 @@ class BaseLLMHTTPHandler:
final_response: Final = await self._call_agentic_completion_hooks(
response=initial_response,
model=model,
messages=(input if isinstance(input, list) else [{"role": "user", "content": input}]),
messages=(input if isinstance(input, list) else [{"role": "user", "content": input}]), # pyright: ignore[reportArgumentType] # pre-existing mismatch surfaced by the Router import; the hook accepts response input items at runtime
anthropic_messages_provider_config=responses_api_provider_config,
anthropic_messages_optional_request_params=response_api_optional_request_params,
logging_obj=logging_obj,
@ -5415,7 +5416,7 @@ class BaseLLMHTTPHandler:
try:
response: ResponsesAPIResponse | BaseResponsesAPIStreamingIterator = await litellm.aresponses(
model=patch.model or model,
input=patch.messages,
input=patch.messages, # pyright: ignore[reportArgumentType] # pre-existing mismatch surfaced by the Router import; patch messages are valid response input at runtime
**optional_params,
**kwargs_for_followup,
)
@ -9688,6 +9689,7 @@ class BaseLLMHTTPHandler:
timeout: float | httpx.Timeout | None = None,
client: HTTPHandler | AsyncHTTPHandler | None = None,
_is_async: bool = False,
router: "Router | None" = None,
) -> VectorStoreSearchResponse:
if isinstance(vector_store_provider_config, BaseDirectVectorStoreConfig):
self._pre_call_direct_vector_store_search(
@ -9738,6 +9740,7 @@ class BaseLLMHTTPHandler:
litellm_logging_obj=logging_obj,
litellm_params=dict(litellm_params),
extra_body=extra_body,
router=router,
)
else:
(
@ -9751,6 +9754,7 @@ class BaseLLMHTTPHandler:
litellm_logging_obj=logging_obj,
litellm_params=dict(litellm_params),
extra_body=extra_body,
router=router,
)
all_optional_params: Final[dict[str, object]] = dict(litellm_params)
all_optional_params.update(vector_store_search_optional_params or {})
@ -9802,6 +9806,7 @@ class BaseLLMHTTPHandler:
timeout: float | httpx.Timeout | None = None,
client: HTTPHandler | AsyncHTTPHandler | None = None,
_is_async: bool = False,
router: "Router | None" = None,
) -> VectorStoreSearchResponse | Coroutine[object, object, VectorStoreSearchResponse]:
if _is_async:
return self.async_vector_store_search_handler(
@ -9816,6 +9821,7 @@ class BaseLLMHTTPHandler:
extra_body=extra_body,
timeout=timeout,
client=client,
router=router,
)
if isinstance(vector_store_provider_config, BaseDirectVectorStoreConfig):
@ -9862,6 +9868,7 @@ class BaseLLMHTTPHandler:
litellm_logging_obj=logging_obj,
litellm_params=dict(litellm_params),
extra_body=extra_body,
router=router,
)
all_optional_params: Final[dict[str, object]] = dict(litellm_params)

View file

@ -33,6 +33,7 @@ from litellm.types.vector_stores import (
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.router import Router
else:
LiteLLMLoggingObj = Any
@ -168,6 +169,7 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
extra_body: Mapping[str, object] | None = None,
router: "Router | None" = None,
) -> tuple[str, dict]:
"""
Transform search request to Gemini's generateContent format.

View file

@ -19,6 +19,7 @@ from litellm.types.vector_stores import (
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
from litellm.router import Router
LiteLLMLoggingObj = _LiteLLMLoggingObj
else:
@ -123,6 +124,7 @@ class MilvusVectorStoreConfig(BaseVectorStoreConfig):
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
extra_body: dict[str, Any] | None = None,
router: "Router | None" = None,
) -> tuple[str, dict[str, Any]]:
"""
Transform search request for Azure AI Search API

View file

@ -21,6 +21,7 @@ from litellm.utils import add_openai_metadata
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
from litellm.router import Router
LiteLLMLoggingObj = _LiteLLMLoggingObj
else:
@ -99,6 +100,7 @@ class OpenAIVectorStoreConfig(BaseVectorStoreConfig):
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
extra_body: dict[str, Any] | None = None,
router: "Router | None" = None,
) -> tuple[str, dict]:
encoded_vector_store_id: Final = encode_url_path_segment(vector_store_id, field_name="vector_store_id")
url: Final = f"{api_base}/{encoded_vector_store_id}/search"

View file

@ -8,6 +8,7 @@ from litellm.types.vector_stores import VectorStoreSearchOptionalRequestParams
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.router import Router
else:
LiteLLMLoggingObj = Any
@ -80,6 +81,7 @@ class PGVectorStoreConfig(OpenAIVectorStoreConfig):
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
extra_body: dict[str, Any] | None = None,
router: "Router | None" = None,
) -> tuple[str, dict]:
encoded_vector_store_id: Final = encode_url_path_segment(vector_store_id, field_name="vector_store_id")
url: Final = f"{api_base}/{encoded_vector_store_id}/search"

View file

@ -17,6 +17,7 @@ from litellm.types.vector_stores import (
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.router import Router
else:
LiteLLMLoggingObj = Any
@ -92,6 +93,7 @@ class RAGFlowVectorStoreConfig(BaseVectorStoreConfig):
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
extra_body: dict[str, Any] | None = None,
router: "Router | None" = None,
) -> tuple[str, dict]:
"""RAGFlow vector stores are management-only, search is not supported."""
raise NotImplementedError("RAGFlow vector stores support dataset management only, not search/retrieval")

View file

@ -1,8 +1,8 @@
import re
from typing import TYPE_CHECKING, Any, Final
import httpx
from litellm.caching._embedding_router import resolve_embedding_router
from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.types.router import GenericLiteLLMParams
@ -18,6 +18,7 @@ from litellm.types.vector_stores import (
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.router import Router
else:
LiteLLMLoggingObj = Any
@ -58,13 +59,20 @@ class S3VectorsVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
return headers
def get_complete_url(self, api_base: str | None, litellm_params: dict) -> str:
aws_region_name: Final = litellm_params.get("aws_region_name")
if not aws_region_name:
raise ValueError("aws_region_name is required for S3 Vectors")
if not re.match(r"^[a-z][a-z0-9-]*$", aws_region_name):
raise ValueError("Invalid aws_region_name format")
# Resolve region the same way the ingestion path does:
# dynamic param -> AWS_REGION_NAME -> AWS_REGION -> default (us-west-2)
aws_region_name: Final = self.get_aws_region_name_for_non_llm_api_calls(litellm_params.get("aws_region_name"))
return f"https://s3vectors.{aws_region_name}.api.aws"
def _resolve_query_embedding_router(self, embedding_model: str, router: "Router | None") -> "Router | None":
"""Return the router iff it serves ``embedding_model`` as a deployment."""
if router is None:
return None
model_list: Final = [
dict(m) for m in (router.get_model_list() or ())
] # mutable-ok: resolve_embedding_router requires list[dict]
return resolve_embedding_router(embedding_model=embedding_model, llm_router=router, llm_model_list=model_list)
def transform_search_vector_store_request(
self,
vector_store_id: str,
@ -74,6 +82,7 @@ class S3VectorsVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
extra_body: dict[str, Any] | None = None,
router: "Router | None" = None,
) -> tuple[str, dict]:
"""Sync version - generates embedding synchronously."""
# For S3 Vectors, vector_store_id should be in format: bucket_name:index_name
@ -99,10 +108,16 @@ class S3VectorsVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
# Generate embedding for the query
embedding_model: Final = litellm_params.get("embedding_model", "text-embedding-3-small")
embedding_router: Final = self._resolve_query_embedding_router(embedding_model=embedding_model, router=router)
import litellm as litellm_module
embedding_response: Final = litellm_module.embedding(model=embedding_model, input=[query])
embedding_input: Final = [query] # mutable-ok: the embedding API takes list input
embedding_response: Final = (
embedding_router.embedding(model=embedding_model, input=embedding_input)
if embedding_router is not None
else litellm_module.embedding(model=embedding_model, input=embedding_input)
)
query_embedding: Final = embedding_response.data[0]["embedding"]
url: Final = f"{api_base}/QueryVectors"
@ -128,6 +143,7 @@ class S3VectorsVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
extra_body: dict[str, Any] | None = None,
router: "Router | None" = None,
) -> tuple[str, dict]:
"""Async version - generates embedding asynchronously."""
# For S3 Vectors, vector_store_id should be in format: bucket_name:index_name
@ -153,10 +169,16 @@ class S3VectorsVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
# Generate embedding for the query asynchronously
embedding_model: Final = litellm_params.get("embedding_model", "text-embedding-3-small")
embedding_router: Final = self._resolve_query_embedding_router(embedding_model=embedding_model, router=router)
import litellm as litellm_module
embedding_response: Final = await litellm_module.aembedding(model=embedding_model, input=[query])
embedding_input: Final = [query] # mutable-ok: the embedding API takes list input
embedding_response: Final = (
await embedding_router.aembedding(model=embedding_model, input=embedding_input)
if embedding_router is not None
else await litellm_module.aembedding(model=embedding_model, input=embedding_input)
)
query_embedding: Final = embedding_response.data[0]["embedding"]
url: Final = f"{api_base}/QueryVectors"

View file

@ -21,6 +21,7 @@ from litellm.types.vector_stores import (
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
from litellm.router import Router
LiteLLMLoggingObj = _LiteLLMLoggingObj
else:
@ -161,6 +162,7 @@ class VertexVectorStoreConfig(BaseVectorStoreConfig, VertexBase):
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
extra_body: Mapping[str, object] | None = None,
router: "Router | None" = None,
) -> tuple[str, dict[str, object]]:
"""
Transform search request for Vertex AI RAG API

View file

@ -25,6 +25,7 @@ from litellm.types.vector_stores import (
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
from litellm.router import Router
LiteLLMLoggingObj = _LiteLLMLoggingObj
else:
@ -245,6 +246,7 @@ class VertexSearchAPIVectorStoreConfig(BaseVectorStoreConfig, VertexBase):
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
extra_body: Mapping[str, object] | None = None,
router: "Router | None" = None,
) -> tuple[str, dict[str, object]]:
"""
Transform a search request for the Vertex AI Search (Discovery Engine) API.

View file

@ -9,6 +9,7 @@ Provides:
import base64
import json
from collections.abc import Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final
import orjson
@ -19,6 +20,9 @@ from starlette.datastructures import UploadFile
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook import (
LiteLLM_ManagedVectorStore,
)
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
from litellm.proxy._types import *
from litellm.proxy.auth.auth_utils import is_request_body_safe
@ -36,6 +40,10 @@ from litellm.proxy.rag_endpoints.upload_security import (
RejectedUpload,
validate_upload,
)
from litellm.proxy.vector_store_endpoints.endpoints import (
build_request_data_from_managed_vector_store,
reject_caller_embedding_selection_params,
)
from litellm.proxy.vector_store_endpoints.utils import (
assert_user_can_access_vector_store_id,
)
@ -120,12 +128,21 @@ def _collect_vector_store_ids_from_payload(payload: object) -> set[str]:
async def _authorize_nested_vector_store_ids(
payload: object,
user_api_key_dict: UserAPIKeyAuth,
) -> None:
for vector_store_id in sorted(_collect_vector_store_ids_from_payload(payload)):
await assert_user_can_access_vector_store_id(
vector_store_id=vector_store_id,
user_api_key_dict=user_api_key_dict,
)
) -> Mapping[str, LiteLLM_ManagedVectorStore]:
"""Authorize every nested vector store id and return the managed stores it resolved."""
return MappingProxyType(
{
vector_store_id: store
for vector_store_id in sorted(_collect_vector_store_ids_from_payload(payload))
if (
store := await assert_user_can_access_vector_store_id(
vector_store_id=vector_store_id,
user_api_key_dict=user_api_key_dict,
)
)
is not None
}
)
def _build_file_metadata_entry(
@ -700,11 +717,27 @@ async def rag_query(
status_code=400,
detail={"error": "retrieval_config must contain 'vector_store_id'"},
)
await _authorize_nested_vector_store_ids(
reject_caller_embedding_selection_params(payload=retrieval_config, source="retrieval_config")
resolved_stores: Final = await _authorize_nested_vector_store_ids(
payload=retrieval_config,
user_api_key_dict=user_api_key_dict,
)
# Merge litellm-managed vector store params (provider, region, embedding
# model, credentials, ...) from the registry: the same source the direct
# /vector_stores/{id}/search endpoint uses. Store-managed keys win on
# conflict so callers cannot override the store's provider or credentials.
managed_store: Final = resolved_stores.get(retrieval_config["vector_store_id"])
store_data: Final = (
await build_request_data_from_managed_vector_store(managed_store)
if managed_store is not None
else MappingProxyType({})
)
merged_retrieval_config: Final = {
**retrieval_config,
**store_data,
} # mutable-ok: litellm.aquery requires a plain dict payload
# Add litellm data
request_data: dict[str, object] = {}
request_data = await add_litellm_data_to_request(
@ -716,13 +749,18 @@ async def rag_query(
proxy_config=proxy_config,
)
verbose_proxy_logger.debug("RAG Query - model: %s, retrieval_config: %s", model, retrieval_config)
verbose_proxy_logger.debug(
"RAG Query - model: %s, vector_store_id: %s, custom_llm_provider: %s",
model,
retrieval_config["vector_store_id"],
merged_retrieval_config.get("custom_llm_provider"),
)
# Call query
response: Final = await litellm.aquery(
model=model,
messages=messages,
retrieval_config=retrieval_config,
retrieval_config=merged_retrieval_config,
rerank=rerank,
stream=stream,
router=llm_router,

View file

@ -1,3 +1,5 @@
from collections.abc import Mapping
from types import MappingProxyType
from typing import (
Annotated,
Any, # noqa: TID251 # jsonify_object in proxy/utils.py is annotated with a bare dict
@ -27,11 +29,69 @@ from litellm.types.vector_stores import IndexCreateRequest, IndexListResponse
from litellm.vector_stores.vector_store_registry import VectorStoreIndexRegistry
router: Final = APIRouter()
BLOCKED_QUERY_EMBEDDING_SELECTION_PARAMS: Final = frozenset(
{
"embedding_model",
"litellm_embedding_model",
"litellm_embedding_config",
"litellm_credential_name",
}
)
def reject_caller_embedding_selection_params(payload: Mapping[str, object], source: str) -> None:
blocked: Final = sorted(BLOCKED_QUERY_EMBEDDING_SELECTION_PARAMS & payload.keys())
if blocked:
raise HTTPException(
status_code=400,
detail={
"error": f"'{blocked[0]}' cannot be set in {source}. "
"Embedding configuration comes from the vector store's server-side registration."
},
)
########################################################
# OpenAI Compatible Endpoints
########################################################
async def build_request_data_from_managed_vector_store(
vector_store: LiteLLM_ManagedVectorStore,
) -> Mapping[str, object]:
"""
Build request params (provider, credential ref, litellm_params) from an
already-resolved managed vector store.
``litellm_embedding_config`` is resolved here, at request-handling time,
instead of at row-creation time: the resolved api_key/api_base/api_version
lives only in the returned per-request mapping and is never persisted back
to the registry cache. Legacy rows that already carry a resolved
(cleartext) config skip the lookup and pass through unchanged.
"""
top_level: Final = MappingProxyType(
{
key: vector_store.get(key)
for key in ("custom_llm_provider", "litellm_credential_name")
if key in vector_store
}
)
litellm_params: Final = vector_store.get("litellm_params") or MappingProxyType({})
embedding_model: Final = litellm_params.get("litellm_embedding_model")
if not embedding_model or litellm_params.get("litellm_embedding_config"):
return MappingProxyType({**top_level, **litellm_params})
from litellm.proxy.proxy_server import prisma_client
resolved_config: Final = await _resolve_embedding_config(
embedding_model=embedding_model, prisma_client=prisma_client
)
if not resolved_config:
return MappingProxyType({**top_level, **litellm_params})
return MappingProxyType({**top_level, **litellm_params, "litellm_embedding_config": resolved_config})
async def _update_request_data_with_litellm_managed_vector_store_registry(
data: dict,
vector_store_id: str,
@ -51,47 +111,14 @@ async def _update_request_data_with_litellm_managed_vector_store_registry(
vector_store_to_run: Final[LiteLLM_ManagedVectorStore | None] = await get_litellm_managed_vector_store(
vector_store_id=vector_store_id
)
if vector_store_to_run is not None:
if user_api_key_dict is not None:
await assert_user_can_access_vector_store(
vector_store=vector_store_to_run,
user_api_key_dict=user_api_key_dict,
)
if "custom_llm_provider" in vector_store_to_run:
data["custom_llm_provider"] = vector_store_to_run.get("custom_llm_provider")
if "litellm_credential_name" in vector_store_to_run:
data["litellm_credential_name"] = vector_store_to_run.get("litellm_credential_name")
if "litellm_params" in vector_store_to_run:
litellm_params = vector_store_to_run.get("litellm_params", {}) or {}
# Resolve ``litellm_embedding_config`` here, at request-handling
# time, instead of at row-creation time. The resolved
# ``api_key`` / ``api_base`` / ``api_version`` lives only in
# this per-request ``data`` dict and is never persisted.
# Legacy rows that already carry a resolved (cleartext)
# ``litellm_embedding_config`` skip the lookup and pass through
# unchanged so the embed call keeps working.
embedding_model: Final = litellm_params.get("litellm_embedding_model")
if embedding_model and not litellm_params.get("litellm_embedding_config"):
from litellm.proxy.proxy_server import prisma_client
resolved_config: Final = await _resolve_embedding_config(
embedding_model=embedding_model, prisma_client=prisma_client
)
if resolved_config:
# Build a fresh dict via spread instead of mutating
# ``litellm_params`` in place — the registry hands back
# a reference to its cached object, so an in-place
# update would persist the resolved cleartext into the
# in-memory cache for the lifetime of the process.
litellm_params = {
**litellm_params,
"litellm_embedding_config": resolved_config,
}
data.update(litellm_params)
return data
if vector_store_to_run is None:
return data
if user_api_key_dict is not None:
await assert_user_can_access_vector_store(
vector_store=vector_store_to_run,
user_api_key_dict=user_api_key_dict,
)
return {**data, **(await build_request_data_from_managed_vector_store(vector_store_to_run))}
@router.post(
@ -130,6 +157,7 @@ async def vector_store_search(
)
data = await _read_request_body(request=request)
reject_caller_embedding_selection_params(payload=data, source="the search request body")
data["vector_store_id"] = vector_store_id
# Check for legacy vector store registry (non-managed vector stores)

View file

@ -470,7 +470,7 @@ async def create_vector_store_in_db(
# exposed every env-stored embedding-model credential on the
# ``/vector_store/{new,info,update,list}`` responses. Keep the user's
# raw ``litellm_embedding_model`` reference; resolution now happens in
# ``_update_request_data_with_litellm_managed_vector_store_registry``
# ``build_request_data_from_managed_vector_store``
# at request-handling time so the cleartext config exists only in
# per-request memory and never reaches the database.
if litellm_params:
@ -864,7 +864,7 @@ async def update_vector_store(
# embedding-config auto-resolve previously persisted cleartext
# credentials into the row; resolution now happens at request-
# handling time in
# ``_update_request_data_with_litellm_managed_vector_store_registry``
# ``build_request_data_from_managed_vector_store``
# so this row only ever stores the user-supplied
# ``litellm_embedding_model`` reference.
if "litellm_params" in update_data:

View file

@ -14,6 +14,7 @@ import contextvars
from collections.abc import Coroutine, Iterator
from contextlib import contextmanager
from functools import partial
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final
import httpx
@ -50,6 +51,21 @@ INGESTION_REGISTRY: Final[dict[str, type[BaseRAGIngestion]]] = {
"vertex_ai": VertexAIRAGIngestion,
}
# Only these retrieval_config keys are forwarded to vector_stores.asearch as
# provider-specific params. The explicit allowlist keeps caller-controlled
# connection overrides (api_base, api_key, ...) away from the search call,
# where they could redirect store credentials to an attacker-chosen host.
_FORWARDABLE_RETRIEVAL_CONFIG_KEYS: Final = frozenset(
{
"aws_region_name",
"vector_bucket_name",
"embedding_model",
"litellm_embedding_model",
"litellm_embedding_config",
"litellm_credential_name",
}
)
def get_ingestion_class(provider: str) -> type[BaseRAGIngestion]:
"""
@ -224,13 +240,20 @@ async def _execute_query_pipeline(
raise ValueError("No query found in messages for RAG query")
# 2. Search vector store
# Forward allowlisted provider retrieval_config extras (region, embedding
# model, bucket, credential refs) to the search call; kwargs win on conflict.
provider_search_params: Final = MappingProxyType(
{k: v for k, v in retrieval_config.items() if k in _FORWARDABLE_RETRIEVAL_CONFIG_KEYS}
)
forwarded_search_params: Final = MappingProxyType({**provider_search_params, **kwargs})
with _suppressed_sub_call_billing():
search_response: Final = await litellm.vector_stores.asearch(
vector_store_id=retrieval_config["vector_store_id"],
query=query_text,
max_num_results=retrieval_config.get("top_k", 10),
custom_llm_provider=retrieval_config.get("custom_llm_provider", "openai"),
**kwargs,
router=router,
**forwarded_search_params,
)
search_provider: Final = retrieval_config.get("custom_llm_provider", "openai")

View file

@ -2360,7 +2360,7 @@ class Router:
@overload
async def acompletion(
self, model: str, messages: list[AllMessageValues], stream: Literal[True, False] = False, **kwargs
) -> CustomStreamWrapper | ModelResponse:
) -> CustomStreamWrapper | ModelResponse:
...
# fmt: on
@ -6410,8 +6410,6 @@ class Router:
"responses",
"generate_content",
"generate_content_stream",
"vector_store_search",
"vector_store_create",
"ocr",
"search",
"video_generation",
@ -6435,6 +6433,8 @@ class Router:
return sync_wrapper
if call_type in (
"vector_store_search",
"vector_store_create",
"vector_store_retrieve",
"vector_store_list",
"vector_store_update",
@ -6446,11 +6446,16 @@ class Router:
client: object | None = None,
**kwargs,
):
if custom_llm_provider and "custom_llm_provider" not in kwargs:
kwargs["custom_llm_provider"] = custom_llm_provider
if kwargs.get("model"):
return self._generic_api_call_with_fallbacks(original_function=original_function, **kwargs)
return original_function(**kwargs)
provider_kwargs: Final = (
MappingProxyType({**kwargs, "custom_llm_provider": custom_llm_provider})
if custom_llm_provider and "custom_llm_provider" not in kwargs
else MappingProxyType(kwargs)
)
if provider_kwargs.get("model"):
return self._generic_api_call_with_fallbacks(original_function=original_function, **provider_kwargs)
if call_type == "vector_store_search":
return original_function(**MappingProxyType({**provider_kwargs, "router": self}))
return original_function(**provider_kwargs)
return vector_store_sync_wrapper
@ -6626,6 +6631,7 @@ class Router:
return await self._init_vector_store_api_endpoints(
original_function=original_function,
custom_llm_provider=custom_llm_provider,
call_type=call_type,
**kwargs,
)
elif call_type in ("afile_delete", "afile_content"):
@ -6666,6 +6672,7 @@ class Router:
self,
original_function: Callable,
custom_llm_provider: str | None = None,
call_type: str | None = None,
**kwargs,
):
"""
@ -6684,6 +6691,13 @@ class Router:
**kwargs,
)
# For search, pass the router so provider transforms can resolve
# router-managed embedding models (e.g. S3 Vectors query embeddings).
# The merge also overrides any client-supplied `router` key.
if call_type == "avector_store_search":
search_kwargs: Final = MappingProxyType({**kwargs, "router": self})
return await original_function(**search_kwargs)
# Otherwise, call the original function directly
return await original_function(**kwargs)

View file

@ -7,7 +7,7 @@ import builtins
import contextvars
from collections.abc import Coroutine, Mapping
from functools import partial
from typing import Final
from typing import TYPE_CHECKING, Final
import httpx
@ -29,6 +29,9 @@ from litellm.types.vector_stores import (
from litellm.utils import ProviderConfigManager, client
from litellm.vector_stores.utils import VectorStoreRequestUtils
if TYPE_CHECKING:
from litellm.router import Router
####### ENVIRONMENT VARIABLES ###################
# Initialize any necessary instances or variables here
base_llm_http_handler = BaseLLMHTTPHandler()
@ -280,6 +283,7 @@ async def asearch(
timeout: float | httpx.Timeout | None = None,
# LiteLLM specific params,
custom_llm_provider: str | None = None,
router: "Router | None" = None,
**kwargs,
) -> VectorStoreSearchResponse:
"""
@ -308,6 +312,7 @@ async def asearch(
extra_body=extra_body,
timeout=timeout,
custom_llm_provider=custom_llm_provider,
router=router,
**kwargs,
)
@ -347,6 +352,7 @@ def search(
timeout: float | httpx.Timeout | None = None,
# LiteLLM specific params,
custom_llm_provider: str | None = None,
router: "Router | None" = None,
**kwargs,
) -> VectorStoreSearchResponse | Coroutine[object, object, VectorStoreSearchResponse]:
"""
@ -450,6 +456,7 @@ def search(
timeout=timeout or request_timeout,
_is_async=_is_async,
client=kwargs.get("client"),
router=router,
)
return response

View file

@ -1,4 +1,4 @@
from unittest.mock import MagicMock, Mock
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import pytest
@ -9,6 +9,18 @@ from litellm.llms.s3_vectors.vector_stores.transformation import (
from litellm.types.vector_stores import VectorStoreSearchResponse
def _mock_router(model_names, sync=False):
"""Router mock serving the given embedding model names."""
router = MagicMock()
router.get_model_list.return_value = [{"model_name": name} for name in model_names]
embedding_response = Mock(data=[{"embedding": [0.1, 0.2, 0.3]}])
if sync:
router.embedding = MagicMock(return_value=embedding_response)
else:
router.aembedding = AsyncMock(return_value=embedding_response)
return router
class TestS3VectorsVectorStoreConfig:
def test_init(self):
"""Test that S3VectorsVectorStoreConfig initializes correctly"""
@ -28,19 +40,174 @@ class TestS3VectorsVectorStoreConfig:
url = config.get_complete_url(None, litellm_params)
assert url == "https://s3vectors.us-west-2.api.aws"
def test_get_complete_url_missing_region(self):
"""Test that missing region raises error"""
def test_get_complete_url_missing_region(self, monkeypatch):
"""Missing region falls back to the default region (parity with ingestion)"""
monkeypatch.delenv("AWS_REGION_NAME", raising=False)
monkeypatch.delenv("AWS_REGION", raising=False)
config = S3VectorsVectorStoreConfig()
litellm_params = {}
with pytest.raises(ValueError, match="aws_region_name is required"):
config.get_complete_url(None, litellm_params)
url = config.get_complete_url(None, {})
assert url == "https://s3vectors.us-west-2.api.aws"
def test_get_complete_url_uses_env_region(self, monkeypatch):
"""Missing region param resolves from AWS_REGION_NAME env var"""
monkeypatch.setenv("AWS_REGION_NAME", "eu-west-1")
monkeypatch.delenv("AWS_REGION", raising=False)
config = S3VectorsVectorStoreConfig()
url = config.get_complete_url(None, {})
assert url == "https://s3vectors.eu-west-1.api.aws"
def test_get_complete_url_invalid_region_format(self):
"""Invalid region format raises"""
config = S3VectorsVectorStoreConfig()
with pytest.raises(ValueError, match="Invalid AWS region format"):
config.get_complete_url(None, {"aws_region_name": "Bad_Region!"})
@pytest.mark.skip(reason="Requires embedding API call, tested in integration tests")
def test_transform_search_request(self):
"""Test search request transformation"""
# This test requires making an actual embedding API call
# It's better tested in integration tests
pass
"""Full request-body transformation with a router-injected embedding"""
config = S3VectorsVectorStoreConfig()
mock_logging_obj = Mock()
mock_logging_obj.model_call_details = {}
router = _mock_router(["text-embedding-3-small"], sync=True)
url, request_body = config.transform_search_vector_store_request(
vector_store_id="test-bucket:test-index",
query="test query",
vector_store_search_optional_params={"max_num_results": 7},
api_base="https://s3vectors.us-west-2.api.aws",
litellm_logging_obj=mock_logging_obj,
litellm_params={},
extra_body=None,
router=router,
)
assert url == "https://s3vectors.us-west-2.api.aws/QueryVectors"
assert request_body == {
"vectorBucketName": "test-bucket",
"indexName": "test-index",
"queryVector": {"float32": [0.1, 0.2, 0.3]},
"topK": 7,
"returnDistance": True,
"returnMetadata": True,
}
assert mock_logging_obj.model_call_details["query"] == "test query"
@pytest.mark.asyncio
async def test_atransform_search_uses_router_for_virtual_model(self):
"""Regression: router-served embedding models must resolve via the router,
not a bare litellm.aembedding call (which has no deployment credentials)."""
config = S3VectorsVectorStoreConfig()
mock_logging_obj = Mock()
mock_logging_obj.model_call_details = {}
router = _mock_router(["my-embedding-model"])
with patch("litellm.aembedding", new=AsyncMock()) as mock_bare_aembedding: # test-quality-ok: guards that the bare-embedding path is not taken; dispatch seam is the behavior under test
url, request_body = await config.atransform_search_vector_store_request(
vector_store_id="test-bucket:test-index",
query="test query",
vector_store_search_optional_params={},
api_base="https://s3vectors.us-west-2.api.aws",
litellm_logging_obj=mock_logging_obj,
litellm_params={"embedding_model": "my-embedding-model"},
extra_body=None,
router=router,
)
router.aembedding.assert_awaited_once_with(model="my-embedding-model", input=["test query"])
mock_bare_aembedding.assert_not_awaited()
assert request_body["queryVector"]["float32"] == [0.1, 0.2, 0.3]
assert request_body["topK"] == 5 # default
@pytest.mark.asyncio
async def test_atransform_search_falls_back_when_router_does_not_serve_model(self):
"""Router present but embedding_model is not a router deployment ->
bare litellm.aembedding keeps working (provider-prefixed + env creds stores)."""
config = S3VectorsVectorStoreConfig()
mock_logging_obj = Mock()
mock_logging_obj.model_call_details = {}
router = _mock_router(["some-other-model"])
mock_bare = AsyncMock(return_value=Mock(data=[{"embedding": [0.4, 0.5]}]))
with patch("litellm.aembedding", new=mock_bare): # test-quality-ok: stubs the bare-embedding fallback whose request body the test asserts on
_, request_body = await config.atransform_search_vector_store_request(
vector_store_id="test-bucket:test-index",
query="test query",
vector_store_search_optional_params={},
api_base="https://s3vectors.us-west-2.api.aws",
litellm_logging_obj=mock_logging_obj,
litellm_params={"embedding_model": "azure/text-embedding-3-small"},
extra_body=None,
router=router,
)
mock_bare.assert_awaited_once_with(model="azure/text-embedding-3-small", input=["test query"])
router.aembedding.assert_not_awaited()
assert request_body["queryVector"]["float32"] == [0.4, 0.5]
@pytest.mark.asyncio
async def test_atransform_search_without_router_uses_bare_embedding(self):
"""Backward compat: no router -> bare litellm.aembedding as before"""
config = S3VectorsVectorStoreConfig()
mock_logging_obj = Mock()
mock_logging_obj.model_call_details = {}
mock_bare = AsyncMock(return_value=Mock(data=[{"embedding": [0.6, 0.7]}]))
with patch("litellm.aembedding", new=mock_bare): # test-quality-ok: stubs the bare-embedding fallback whose request body the test asserts on
_, request_body = await config.atransform_search_vector_store_request(
vector_store_id="test-bucket:test-index",
query="test query",
vector_store_search_optional_params={},
api_base="https://s3vectors.us-west-2.api.aws",
litellm_logging_obj=mock_logging_obj,
litellm_params={},
extra_body=None,
)
mock_bare.assert_awaited_once_with(model="text-embedding-3-small", input=["test query"])
assert request_body["queryVector"]["float32"] == [0.6, 0.7]
def test_transform_search_uses_router_for_virtual_model_sync(self):
"""Sync twin: router-served embedding model resolves via router.embedding"""
config = S3VectorsVectorStoreConfig()
mock_logging_obj = Mock()
mock_logging_obj.model_call_details = {}
router = _mock_router(["my-embedding-model"], sync=True)
with patch("litellm.embedding", new=MagicMock()) as mock_bare_embedding: # test-quality-ok: guards that the bare-embedding path is not taken; dispatch seam is the behavior under test
_, request_body = config.transform_search_vector_store_request(
vector_store_id="test-bucket:test-index",
query="test query",
vector_store_search_optional_params={},
api_base="https://s3vectors.us-west-2.api.aws",
litellm_logging_obj=mock_logging_obj,
litellm_params={"embedding_model": "my-embedding-model"},
extra_body=None,
router=router,
)
router.embedding.assert_called_once_with(model="my-embedding-model", input=["test query"])
mock_bare_embedding.assert_not_called()
assert request_body["queryVector"]["float32"] == [0.1, 0.2, 0.3]
def test_transform_search_without_router_uses_bare_embedding_sync(self):
"""Sync twin: no router -> bare litellm.embedding as before"""
config = S3VectorsVectorStoreConfig()
mock_logging_obj = Mock()
mock_logging_obj.model_call_details = {}
mock_bare = MagicMock(return_value=Mock(data=[{"embedding": [0.8, 0.9]}]))
with patch("litellm.embedding", new=mock_bare): # test-quality-ok: stubs the bare-embedding fallback whose request body the test asserts on
_, request_body = config.transform_search_vector_store_request(
vector_store_id="test-bucket:test-index",
query="test query",
vector_store_search_optional_params={},
api_base="https://s3vectors.us-west-2.api.aws",
litellm_logging_obj=mock_logging_obj,
litellm_params={},
extra_body=None,
)
mock_bare.assert_called_once_with(model="text-embedding-3-small", input=["test query"])
assert request_body["queryVector"]["float32"] == [0.8, 0.9]
def test_transform_search_request_invalid_vector_store_id(self):
"""Test that invalid vector_store_id format raises error"""

View file

@ -324,6 +324,127 @@ def test_rag_query_stream_returns_event_stream(client_internal_user):
assert "data: [DONE]" in response.text
def test_rag_query_merges_managed_store_params(client_internal_user):
"""
Regression: /v1/rag/query must consult the managed vector store registry
(like the direct /v1/vector_stores/{id}/search endpoint does) so that
provider, region, embedding model, etc. don't have to be repeated in
retrieval_config. Pre-fix the registry was never read, so managed S3
Vectors stores failed with "aws_region_name is required".
"""
import litellm
from litellm.types.utils import ModelResponse
mock_vector_store = {
"vector_store_id": "s3-store",
"custom_llm_provider": "s3_vectors",
"litellm_params": {
"aws_region_name": "eu-west-1",
"embedding_model": "my-embed",
"vector_bucket_name": "bkt",
},
}
mock_registry = MagicMock()
mock_registry.get_litellm_managed_vector_store_from_registry.return_value = mock_vector_store
mock_response = ModelResponse(
id="chatcmpl-test",
choices=[{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}],
model="gpt-4o-mini",
)
with patch( # test-quality-ok: aquery is the endpoint's downstream boundary; the forwarded config is what the test asserts
"litellm.proxy.rag_endpoints.endpoints.litellm.aquery",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_aquery, patch.object(litellm, "vector_store_registry", mock_registry), patch( # test-quality-ok: seeds the managed-store registry the merge under test reads and grants access so real store resolution runs
"litellm.proxy.vector_store_endpoints.utils.can_user_access_vector_store",
new=AsyncMock(return_value=True),
):
response = client_internal_user.post(
"/v1/rag/query",
json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "hello"}],
"retrieval_config": {"vector_store_id": "s3-store"},
},
)
assert response.status_code == 200, response.json()
mock_aquery.assert_awaited_once()
forwarded_config = mock_aquery.await_args.kwargs["retrieval_config"]
assert forwarded_config["vector_store_id"] == "s3-store"
assert forwarded_config["custom_llm_provider"] == "s3_vectors"
assert forwarded_config["aws_region_name"] == "eu-west-1"
assert forwarded_config["embedding_model"] == "my-embed"
assert forwarded_config["vector_bucket_name"] == "bkt"
def test_rag_query_store_params_win_over_user_retrieval_config(client_internal_user):
"""Registry values must win over user-supplied retrieval_config keys so callers cannot override store credentials."""
import litellm
from litellm.types.utils import ModelResponse
mock_vector_store = {
"vector_store_id": "s3-store",
"custom_llm_provider": "s3_vectors",
"litellm_params": {"aws_region_name": "eu-west-1"},
}
mock_registry = MagicMock()
mock_registry.get_litellm_managed_vector_store_from_registry.return_value = mock_vector_store
mock_response = ModelResponse(
id="chatcmpl-test",
choices=[{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}],
model="gpt-4o-mini",
)
with patch( # test-quality-ok: aquery is the endpoint's downstream boundary; the forwarded config is what the test asserts
"litellm.proxy.rag_endpoints.endpoints.litellm.aquery",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_aquery, patch.object(litellm, "vector_store_registry", mock_registry), patch( # test-quality-ok: seeds the managed-store registry the merge under test reads and grants access so real store resolution runs
"litellm.proxy.vector_store_endpoints.utils.can_user_access_vector_store",
new=AsyncMock(return_value=True),
):
response = client_internal_user.post(
"/v1/rag/query",
json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "hello"}],
"retrieval_config": {"vector_store_id": "s3-store", "aws_region_name": "us-east-1"},
},
)
assert response.status_code == 200, response.json()
forwarded_config = mock_aquery.await_args.kwargs["retrieval_config"]
assert forwarded_config["aws_region_name"] == "eu-west-1"
@pytest.mark.parametrize(
"blocked_key",
["embedding_model", "litellm_embedding_model", "litellm_embedding_config", "litellm_credential_name"],
)
def test_rag_query_rejects_caller_embedding_selection_params(client_internal_user, blocked_key):
"""
Regression: a caller must not pick the embedding model or credential used at
search time. Those resolve through the Router with the proxy's credentials,
bypassing the key's model permissions, so they may only come from the
managed store's server-side registration.
"""
response = client_internal_user.post(
"/v1/rag/query",
json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "hello"}],
"retrieval_config": {"vector_store_id": "s3-store", blocked_key: "attacker-choice"},
},
)
assert response.status_code == 400, response.json()
assert blocked_key in str(response.json())
EICAR = r"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
INGEST_REQUEST = '{"ingest_options":{"vector_store":{"custom_llm_provider":"openai"}}}'

View file

@ -3158,3 +3158,35 @@ class TestAzureAIAnalyzeNamedIndexClassification:
user_api_key_dict=self._team_member("analyze", ["read"]),
)
assert result is True
@pytest.mark.parametrize(
"blocked_key",
["embedding_model", "litellm_embedding_model", "litellm_embedding_config", "litellm_credential_name"],
)
def test_vector_store_search_rejects_caller_embedding_selection_params(blocked_key):
"""
Regression: the search request body must not pick the embedding model or
credential used to embed the query. Those resolve through the Router with
the proxy's credentials, bypassing the key's model permissions, so they may
only come from the managed store's server-side registration.
"""
from fastapi.testclient import TestClient
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.proxy_server import app
mock_auth = UserAPIKeyAuth(user_id="test_internal_user", user_role=LitellmUserRoles.INTERNAL_USER.value)
original_overrides = app.dependency_overrides.copy()
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
try:
client = TestClient(app)
response = client.post(
"/v1/vector_stores/s3-store/search",
json={"query": "hello", blocked_key: "attacker-choice"},
)
finally:
app.dependency_overrides = original_overrides
assert response.status_code == 400, response.json()
assert blocked_key in str(response.json())

View file

@ -259,6 +259,135 @@ async def test_aquery_streaming_bills_sub_call_costs_into_final_event():
assert standard_logging_object["response_cost"] >= 0.003
@pytest.mark.asyncio
async def test_aquery_forwards_provider_retrieval_config_and_router_to_search():
"""
Regression: provider-specific retrieval_config keys (aws_region_name,
embedding_model, vector_bucket_name, ...) and the router must be forwarded
to the vector store search call. Pre-fix they were silently dropped, so
/v1/rag/query failed with provider config errors (e.g. S3 Vectors
"aws_region_name is required") even when the caller supplied them.
"""
from unittest.mock import AsyncMock
from litellm.types.vector_stores import VectorStoreSearchResponse
router = litellm.Router(
model_list=[
{
"model_name": "gpt-4o-mini",
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "test-key"},
}
]
)
fake_search = AsyncMock(
return_value=VectorStoreSearchResponse(
object="vector_store.search_results.page", search_query="q", data=[]
)
)
with patch("litellm.vector_stores.asearch", new=fake_search): # test-quality-ok: asearch is the boundary the forwarding contract under test targets
response = await litellm.aquery(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "hello"}],
retrieval_config={
"vector_store_id": "bkt:idx",
"custom_llm_provider": "s3_vectors",
"top_k": 5,
"aws_region_name": "eu-west-1",
"embedding_model": "my-embed",
"vector_bucket_name": "bkt",
},
router=router,
mock_response="hi",
)
assert isinstance(response, ModelResponse)
fake_search.assert_awaited_once()
search_kwargs = fake_search.await_args.kwargs
assert search_kwargs["vector_store_id"] == "bkt:idx"
assert search_kwargs["custom_llm_provider"] == "s3_vectors"
assert search_kwargs["max_num_results"] == 5
assert search_kwargs["router"] is router
# provider-specific extras forwarded
assert search_kwargs["aws_region_name"] == "eu-west-1"
assert search_kwargs["embedding_model"] == "my-embed"
assert search_kwargs["vector_bucket_name"] == "bkt"
# consumed keys are not duplicated into the spread
assert "top_k" not in search_kwargs
@pytest.mark.asyncio
async def test_aquery_minimal_retrieval_config_forwards_no_extras():
"""
A minimal retrieval_config must not leak consumed keys (or invent extras)
into the vector store search call.
"""
from unittest.mock import AsyncMock
from litellm.types.vector_stores import VectorStoreSearchResponse
fake_search = AsyncMock(
return_value=VectorStoreSearchResponse(
object="vector_store.search_results.page", search_query="q", data=[]
)
)
with patch("litellm.vector_stores.asearch", new=fake_search): # test-quality-ok: asearch is the boundary the forwarding contract under test targets
await litellm.aquery(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "hello"}],
retrieval_config={"vector_store_id": "vs_test_123", "custom_llm_provider": "openai"},
mock_response="hi",
)
fake_search.assert_awaited_once()
search_kwargs = fake_search.await_args.kwargs
assert search_kwargs["vector_store_id"] == "vs_test_123"
assert search_kwargs["custom_llm_provider"] == "openai"
assert search_kwargs["router"] is None
leaked = {"top_k", "filters", "retrieval_filter", "aws_region_name", "embedding_model", "vector_bucket_name"}
assert not (leaked & set(search_kwargs.keys()))
@pytest.mark.asyncio
async def test_aquery_does_not_forward_connection_override_keys_to_search():
"""
Only allowlisted retrieval_config keys may reach the vector store search
call. Caller-controlled connection overrides (api_base, api_key, arbitrary
extras) must be dropped, otherwise a caller could redirect store
credentials to an attacker-chosen host.
"""
from unittest.mock import AsyncMock
from litellm.types.vector_stores import VectorStoreSearchResponse
fake_search = AsyncMock(
return_value=VectorStoreSearchResponse(
object="vector_store.search_results.page", search_query="q", data=[]
)
)
with patch("litellm.vector_stores.asearch", new=fake_search): # test-quality-ok: asearch is the boundary the forwarding contract under test targets
await litellm.aquery(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "hello"}],
retrieval_config={
"vector_store_id": "bkt:idx",
"custom_llm_provider": "s3_vectors",
"aws_region_name": "eu-west-1",
"api_base": "https://attacker.example.com",
"api_key": "attacker-key",
"arbitrary_extra": "nope",
},
mock_response="hi",
)
fake_search.assert_awaited_once()
search_kwargs = fake_search.await_args.kwargs
assert search_kwargs["aws_region_name"] == "eu-west-1"
blocked = {"api_base", "api_key", "arbitrary_extra"}
assert not (blocked & set(search_kwargs.keys()))
def test_rag_call_types_are_registered():
"""
query/aquery/ingest/aingest are @client-decorated entry points, so their

View file

@ -7575,6 +7575,118 @@ async def test_acreate_batch_request_bedrock_tags_override_deployment_tags():
assert mock_sign.call_args.kwargs["data"]["tags"] == request_tags
@pytest.mark.asyncio
async def test_avector_store_search_injects_router():
"""
Regression: router.avector_store_search must pass the router down to the
SDK search call so provider transforms can resolve router-managed
embedding models (e.g. S3 Vectors query embeddings).
"""
from litellm.types.vector_stores import VectorStoreSearchResponse
expected_response = VectorStoreSearchResponse(
object="vector_store.search_results.page", search_query="q", data=[]
)
mock_asearch = AsyncMock(return_value=expected_response)
# Router.__init__ binds asearch via a local import, so patch the module
# attribute before constructing the Router.
with patch("litellm.vector_stores.main.asearch", new=mock_asearch): # test-quality-ok: the SDK call is the only place the injected router kwarg is observable
router = litellm.Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {"model": "openai/gpt-3.5-turbo", "api_key": "test-key"},
}
]
)
search_response = await router.avector_store_search(
vector_store_id="v", query="q", custom_llm_provider="s3_vectors"
)
assert search_response is expected_response
mock_asearch.assert_awaited_once()
assert mock_asearch.await_args.kwargs["router"] is router
@pytest.mark.asyncio
async def test_avector_store_create_does_not_inject_router():
"""The router injection is gated on the search call type: the create path
must keep calling the SDK without a router kwarg."""
expected_response = {"id": "vs_1", "object": "vector_store"}
mock_acreate = AsyncMock(return_value=expected_response)
# avector_store_create(model=None) resolves acreate via a local import at
# call time, so patching after Router construction works here.
router = litellm.Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {"model": "openai/gpt-3.5-turbo", "api_key": "test-key"},
}
]
)
with patch("litellm.vector_stores.main.acreate", new=mock_acreate): # test-quality-ok: the SDK call is the only place a leaked router kwarg would surface
create_response = await router.avector_store_create(model=None, custom_llm_provider="openai")
assert create_response is expected_response
mock_acreate.assert_awaited_once()
assert "router" not in mock_acreate.await_args.kwargs
def test_vector_store_search_injects_router():
"""
Sync parity for the router injection: router.vector_store_search must pass
the router down to the SDK search call so provider transforms can resolve
router-managed embedding models, same as avector_store_search.
"""
from litellm.types.vector_stores import VectorStoreSearchResponse
expected_response = VectorStoreSearchResponse(
object="vector_store.search_results.page", search_query="q", data=[]
)
mock_search = MagicMock(return_value=expected_response)
# Router.__init__ binds search via a local import, so patch the module
# attribute before constructing the Router.
with patch("litellm.vector_stores.main.search", new=mock_search): # test-quality-ok: the SDK call is the only place the injected router kwarg is observable
router = litellm.Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {"model": "openai/gpt-3.5-turbo", "api_key": "test-key"},
}
]
)
search_response = router.vector_store_search(
vector_store_id="v", query="q", custom_llm_provider="s3_vectors"
)
assert search_response is expected_response
mock_search.assert_called_once()
assert mock_search.call_args.kwargs["router"] is router
assert mock_search.call_args.kwargs["custom_llm_provider"] == "s3_vectors"
def test_vector_store_create_does_not_inject_router():
"""The sync create path must keep calling the SDK without a router kwarg."""
expected_response = {"id": "vs_1", "object": "vector_store"}
mock_create = MagicMock(return_value=expected_response)
# Router.__init__ binds create via a local import, so patch the module
# attribute before constructing the Router.
with patch("litellm.vector_stores.main.create", new=mock_create): # test-quality-ok: the SDK call is the only place a leaked router kwarg would surface
router = litellm.Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {"model": "openai/gpt-3.5-turbo", "api_key": "test-key"},
}
]
)
create_response = router.vector_store_create(custom_llm_provider="openai")
assert create_response is expected_response
mock_create.assert_called_once()
assert "router" not in mock_create.call_args.kwargs
class TestPreRoutingStrategyRegistryLifecycle:
"""
Regression tests: a deployment leaving the model_list must release the

View file

@ -0,0 +1,78 @@
"""
Tests for litellm/vector_stores/main.py.
Pins the router threading contract for vector store search: the router is an
explicit named parameter that reaches the HTTP handler, and it must never leak
into litellm_params/kwargs where logging would model_dump() it (the #19550
serialization trap).
"""
from unittest.mock import MagicMock, patch
import litellm.vector_stores.main as vector_stores_main
from litellm.vector_stores.main import search
MOCK_SEARCH_RESPONSE = {
"object": "vector_store.search_results.page",
"search_query": "q",
"data": [],
}
def test_search_threads_router_to_handler():
"""search() must pass its router param through to the HTTP handler"""
mock_router = MagicMock()
logger = MagicMock()
with (
patch( # test-quality-ok: stubs provider config resolution; the seam under test is the router kwarg threading
"litellm.vector_stores.main.ProviderConfigManager.get_provider_vector_stores_config",
return_value=MagicMock(),
),
patch.object( # test-quality-ok: the handler call is the observable boundary for the router kwarg contract
vector_stores_main.base_llm_http_handler,
"vector_store_search_handler",
return_value=MOCK_SEARCH_RESPONSE,
) as mock_handler,
):
response = search(
vector_store_id="bkt:idx",
query="q",
custom_llm_provider="s3_vectors",
router=mock_router,
litellm_logging_obj=logger,
)
assert response == MOCK_SEARCH_RESPONSE
mock_handler.assert_called_once()
assert mock_handler.call_args.kwargs["router"] is mock_router
def test_search_router_not_in_litellm_params():
"""Regression (#19550 class): the router must stay out of GenericLiteLLMParams,
otherwise pre-call logging model_dump()s it and breaks serialization."""
mock_router = MagicMock()
logger = MagicMock()
with (
patch( # test-quality-ok: stubs provider config resolution; the seam under test is litellm_params contents
"litellm.vector_stores.main.ProviderConfigManager.get_provider_vector_stores_config",
return_value=MagicMock(),
),
patch.object( # test-quality-ok: the handler call is where a leaked router in litellm_params would surface
vector_stores_main.base_llm_http_handler,
"vector_store_search_handler",
return_value=MOCK_SEARCH_RESPONSE,
) as mock_handler,
):
search(
vector_store_id="bkt:idx",
query="q",
custom_llm_provider="s3_vectors",
router=mock_router,
litellm_logging_obj=logger,
)
litellm_params = mock_handler.call_args.kwargs["litellm_params"]
assert "router" not in litellm_params.model_dump(exclude_none=True)
assert getattr(litellm_params, "router", None) is None

View file

@ -116,16 +116,33 @@ describe("VectorStoreTester", () => {
await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1));
});
it("reports a failed search and keeps the history empty", async () => {
it("shows the backend error in the history when a search fails", async () => {
const user = userEvent.setup();
mockSearch.mockRejectedValue(new Error("boom"));
const errorBody = '{"error":{"message":"OpenAIException - api_key is required"}}';
mockSearch.mockRejectedValue(new Error(errorBody));
renderTester();
await user.type(queryInput(), "hello");
await user.click(searchButton());
await waitFor(() => expect(mockFromBackend).toHaveBeenCalledWith("Failed to search vector store"));
expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument();
await waitFor(() => expect(mockFromBackend).toHaveBeenCalledWith(errorBody));
expect(screen.getByText(`Search failed: ${errorBody}`)).toBeInTheDocument();
expect(screen.queryByText("No results found")).not.toBeInTheDocument();
expect(screen.queryByText(EMPTY_STATE)).not.toBeInTheDocument();
// the failed query stays in the input for retry
expect(queryInput()).toHaveValue("hello");
});
it('renders "No results found" for an empty result set, not an error', async () => {
const user = userEvent.setup();
mockSearch.mockResolvedValue({ object: "vector_store.search_results.page", search_query: "hello", data: [] });
renderTester();
await user.type(queryInput(), "hello");
await user.click(searchButton());
expect(await screen.findByText("No results found")).toBeInTheDocument();
expect(screen.queryByText(/search failed/i)).not.toBeInTheDocument();
});
it("clears the search history", async () => {

View file

@ -40,6 +40,7 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
{
query: string;
response: VectorStoreSearchResponse | null;
error: string | null;
timestamp: number;
}[]
>([]);
@ -59,6 +60,7 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
const historyEntry = {
query,
response,
error: null,
timestamp: Date.now(),
};
@ -66,7 +68,9 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
setQuery("");
} catch (error) {
console.error("Error searching vector store:", error);
toast.fromError("Failed to search vector store");
const errorMessage = error instanceof Error ? error.message : String(error);
toast.fromError(errorMessage);
setSearchHistory((prev) => [{ query, response: null, error: errorMessage, timestamp: Date.now() }, ...prev]);
} finally {
setIsLoading(false);
}
@ -228,7 +232,13 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
})}
</div>
) : (
<div className="text-sm text-muted-foreground">No results found</div>
<div
className={
entry.error ? "text-sm break-words text-destructive" : "text-sm text-muted-foreground"
}
>
{entry.error ? `Search failed: ${entry.error}` : "No results found"}
</div>
)}
</div>
</div>

View file

@ -6970,7 +6970,7 @@ export const vectorStoreSearchCall = async (
if (!response.ok) {
const errorData = await response.text();
await handleError(errorData);
return null;
throw new Error(errorData);
}
const data = await response.json();