mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
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:
commit
b600f02fc2
28 changed files with 918 additions and 90 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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"}}}'
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
0
tests/test_litellm/vector_stores/__init__.py
Normal file
0
tests/test_litellm/vector_stores/__init__.py
Normal file
78
tests/test_litellm/vector_stores/test_main.py
Normal file
78
tests/test_litellm/vector_stores/test_main.py
Normal 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
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue