litellm/tests/unit/vector_stores/test_main.py
Otis Cui c13ecb3085
fix(exceptions)!: honor status code when mapping OpenAI invalid_request_error exceptions (#32537)
* fix: openai exception mapper ignored status code for invalid_request_error

_map_openai_exception coerced anything whose body carried type
invalid_request_error into BadRequestError, guarded only by excluding the single
phrase Incorrect API key provided, and this ran before the status-code checks.
OpenAI and openai-compatible providers return 401 and 404 bodies with type
invalid_request_error, so an auth failure like Invalid API key mapped to a 400
instead of AuthenticationError, and a 404 mapped to BadRequestError instead of
NotFoundError. That changes the status returned to proxy clients and router
retry, cooldown, and fallback handling. Mirror the Azure mapper by only coercing
to BadRequestError when status_code is None or 400, so 401 and 404 fall through
to their existing handlers.

* fix(exceptions): map OpenAI-family invalid_request_error 404s to NotFoundError

Move the contributor's test to the mapped unit test file, assert the raised
class, status code and message, and cover the 400 and status-less cases that
must still raise BadRequestError

Add a regression test for the flow the bug was found on. Retrieving a missing
OpenAI vector store, sync or async, now raises NotFoundError with status 404

The router test that pinned an OpenAI 404 typed invalid_request_error as
BadRequestError now expects NotFoundError

* fix(a2a): answer a provider NotFoundError as JSON-RPC invalid params

* fix(exceptions)!: limit the invalid_request_error status guard to 401 and 404

Every other status keeps raising BadRequestError as on main, so 402-415, 429 and 5xx bodies typed invalid_request_error keep their class and router retry behavior. A2A goes back to main's mapping, which answers NotFoundError and AuthenticationError as JSON-RPC internal errors.

* fix(exceptions): keep the provider error fields on mapped 401 and 404 invalid_request_errors

Raise AuthenticationError and NotFoundError straight from the invalid_request_error
branch with the provider body, so code, param and type survive and the proxy's
error.type matches main. The Rust mapper copy skips 401 and 404 the same way.

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-29 08:05:47 -07:00

245 lines
9.1 KiB
Python

"""
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 wrapped in the embedding
executor, and it must never leak into litellm_params/kwargs where logging would
model_dump() it (the #19550 serialization trap).
"""
import json
from collections.abc import Mapping
from typing import Final
from unittest.mock import MagicMock, patch
import httpx
import pytest
import litellm
import litellm.vector_stores.main as vector_stores_main
from litellm.llms.base_llm.vector_store.transformation import (
RouterVectorStoreEmbeddingExecutor,
)
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.vector_stores.main import acreate, aretrieve, aupdate, retrieve, search
MOCK_SEARCH_RESPONSE = {
"object": "vector_store.search_results.page",
"search_query": "q",
"data": [],
}
@pytest.mark.parametrize("query", ["q", ["q", "another question"]])
def test_search_wraps_router_into_the_handler_embedding_executor(query: str | list[str]):
"""search() hands the HTTP handler a Router-backed embedding executor carrying the
request metadata, and no bare router kwarg (LIT-6750)"""
mock_router = MagicMock()
logger = MagicMock()
with (
patch( # test-quality-ok: stubs provider config resolution; the seam under test is the executor 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 executor 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=query,
custom_llm_provider="s3_vectors",
router=mock_router,
litellm_logging_obj=logger,
litellm_metadata={"user_api_key_team_id": "team-a"},
)
assert response == MOCK_SEARCH_RESPONSE
mock_handler.assert_called_once()
assert "router" not in mock_handler.call_args.kwargs
assert mock_handler.call_args.kwargs["query"] == query
executor = mock_handler.call_args.kwargs["embedding_executor"]
assert isinstance(executor, RouterVectorStoreEmbeddingExecutor)
assert executor.router is mock_router
assert dict(executor.metadata) == {"user_api_key_team_id": "team-a"}
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
def test_search_forwards_top_level_user_context_to_bedrock_retrieve():
"""Regression (LIT-4415): a top-level userContext, the shape the OpenAI SDK's extra_body
produces on the proxy path, reaches the Bedrock Retrieve request body."""
client = MagicMock(spec=HTTPHandler)
client.post.return_value = MagicMock(status_code=200, json=MagicMock(return_value={"retrievalResults": []}))
search(
vector_store_id="kb123",
query="q",
custom_llm_provider="bedrock",
aws_region_name="us-west-2",
aws_access_key_id="test-key-id",
aws_secret_access_key="test-secret-key",
userContext={"userId": "alice@example.com"},
client=client,
litellm_logging_obj=MagicMock(),
)
posted = json.loads(client.post.call_args.kwargs["data"])
assert posted["userContext"] == {"userId": "alice@example.com"}
assert posted["retrievalQuery"] == {"text": "q"}
PROXY_STAMPED_METADATA: Final = {
"team": "llmproxy",
"user_api_key": "hashed-key",
"user_api_key_hash": "hashed-key",
"user_api_key_team_id": "team-1",
"requester_ip_address": "127.0.0.1",
"user_agent": "curl/8.7.1",
"endpoint": "http://localhost:4000/v1/vector_stores",
"litellm_api_version": "1.0.0",
"deployment": "openai/gpt-5",
"deployment_model_name": "gpt-5",
"api_base": "https://api.openai.com/v1",
"agent_id": "agent-1",
"actor_agent_id": "agent-2",
"target_agent_id": "agent-3",
"billing_agent_id": "agent-4",
"agent_execution_mode": "autonomous",
"verified_human_user_id": "user-1",
"litellm_caller_session_id": "session-1",
"user_api_keyboard": "qwerty",
1: "a",
}
def _copy(metadata: Mapping[object, object] | None) -> dict[object, object] | None:
return None if metadata is None else dict(metadata)
async def _metadata_sent_to_provider(
operation: str, metadata: Mapping[object, object] | None, **kwargs: object
) -> list[object]:
sent_metadata: Final[list[object]] = []
def echo_vector_store(request: httpx.Request) -> httpx.Response:
body: Final = json.loads(request.content)
sent_metadata.append(body.get("metadata"))
return httpx.Response(
200,
json={
"id": "vs_1",
"object": "vector_store",
"created_at": 0,
"name": "x",
"usage_bytes": 0,
"file_counts": {"in_progress": 0, "completed": 0, "failed": 0, "cancelled": 0, "total": 0},
"status": "completed",
"metadata": body.get("metadata"),
},
)
client: Final = AsyncHTTPHandler(transport=httpx.MockTransport(echo_vector_store))
if operation == "create":
await acreate(name="x", metadata=_copy(metadata), api_key="sk-test", client=client, **kwargs)
else:
await aupdate(vector_store_id="vs_1", metadata=_copy(metadata), api_key="sk-test", client=client, **kwargs)
return sent_metadata
@pytest.mark.asyncio
@pytest.mark.parametrize("operation", ["create", "update"])
async def test_proxy_vector_store_writes_send_only_the_callers_metadata_to_the_provider(operation: str):
sent_metadata: Final = await _metadata_sent_to_provider(
operation,
PROXY_STAMPED_METADATA,
proxy_server_request={"url": "http://localhost:4000/v1/vector_stores", "method": "POST", "body": {}},
)
assert sent_metadata == [{"team": "llmproxy", "user_api_keyboard": "qwerty", "1": "a"}]
@pytest.mark.asyncio
@pytest.mark.parametrize("operation", ["create", "update"])
@pytest.mark.parametrize(
"callers_metadata", [{"team": "llmproxy", "endpoint": "ingest", "deployment": "prod", "agent_id": "bot-7"}, None]
)
async def test_direct_sdk_vector_store_writes_send_the_callers_metadata_unchanged(
operation: str, callers_metadata: Mapping[object, object] | None
):
sent_metadata: Final = await _metadata_sent_to_provider(operation, callers_metadata)
assert sent_metadata == [callers_metadata]
def _vector_store_not_found(request: httpx.Request) -> httpx.Response:
return httpx.Response(
404,
json={
"error": {
"message": "No vector store found with id 'vs_missing'.",
"type": "invalid_request_error",
"param": "vector_store_id",
"code": None,
}
},
request=request,
)
async def _retrieve_missing_openai_vector_store(is_async: bool) -> object:
transport: Final = httpx.MockTransport(_vector_store_not_found)
if is_async:
return await aretrieve(
vector_store_id="vs_missing",
custom_llm_provider="openai",
api_key="sk-test",
client=AsyncHTTPHandler(transport=transport),
)
return retrieve(
vector_store_id="vs_missing",
custom_llm_provider="openai",
api_key="sk-test",
client=HTTPHandler(client=httpx.Client(transport=transport)),
)
@pytest.mark.asyncio
@pytest.mark.parametrize("is_async", [True, False])
async def test_retrieving_a_missing_openai_vector_store_raises_not_found(is_async: bool):
with pytest.raises(litellm.NotFoundError) as caught:
await _retrieve_missing_openai_vector_store(is_async)
assert caught.value.status_code == 404
assert "No vector store found with id 'vs_missing'." in caught.value.message