mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-26 06:03:19 +00:00
* fix(vector-stores): keep proxy context out of provider vector store metadata The proxy merged its request context (user_api_key_* fields, endpoint, user_agent, requester_ip_address, litellm_api_version) and the router's deployment info into the vector store request's metadata. For vector stores that field is also OpenAI's own metadata, which is capped at 16 keys, so a store created with a team key was full on creation and every later metadata update returned 400. Vector store routes now keep that context in litellm_metadata, like batches, files and responses, and the router does the same for avector_store_create. Provider-side metadata holds only what the caller sent. Adds db_scripts/strip_vector_store_internal_metadata.py, an opt-in script that removes the proxy-written keys from stores created before this change. It is a dry run unless --apply is passed. * fix(vector-stores): page the metadata cleanup script without recursion and tolerate non-string values Listing every store recursed once per page, which overflows the interpreter stack on large projects. Pages are now walked iteratively. Metadata values are no longer required to be strings, and a malformed response exits 2 with a fixed message instead of a traceback that could echo metadata values. * fix(vector-stores): read proxy agent id, tags and spend_logs_metadata from litellm_metadata Vector store routes now keep proxy context in litellm_metadata, but the v3 limiter only read agent_id from metadata, and logging let provider metadata tags or spend_logs_metadata replace the proxy's values. A string tags value also crashed get_request_tags. The limiter now prefers the proxy-resolved agent_id in litellm_metadata. Logging combines list tags from both buckets (metadata first, deduplicated) and merges dict spend_logs_metadata with litellm_metadata winning, ignoring values of the wrong type. * fix(vector-stores): narrow the logging fallback to mistyped caller values The tag union and spend_logs_metadata merge changed logged tags and spend_logs_metadata on every route that has both metadata buckets, deduped tags quadratically, and stopped background interaction polling from keeping string tags as sent. Caller values now win exactly as before, and the proxy's litellm_metadata tags or spend_logs_metadata only replace a caller value of the wrong type. The limiter reads agent ids through a pydantic model instead of a cast and ignores non-string ids. * fix(vector-stores): log the proxy's full tag list on litellm_metadata routes On routes that keep proxy context in litellm_metadata, the proxy builds litellm_metadata.tags from the caller's tags plus key, team and header tags, but get_request_tags preferred the caller's metadata.tags. A caller sending metadata.tags on a vector store call dropped the key's tags from request_tags, which tag spend tracking reads. get_request_tags now prefers a non-empty litellm_metadata tag list, and falls back to metadata.tags as before. * fix(vector-stores): strip LiteLLM-written keys at the provider boundary instead of moving metadata buckets Moving vector store proxy context into litellm_metadata broke shared readers (agent limits, request tags, merged logging metadata), and fixing them widened the change to every litellm_metadata route. This reverts the bucket move, the router change and the reader fixes, so proxy context stays in metadata exactly as on main. Vector store create and update now drop the keys LiteLLM itself writes (user_api_key*, requester and deployment keys, agent ids) from the metadata sent to the provider, in the shared optional-param helper both paths use. The cleanup script reuses the same key list. * fix(vector-stores): pass non-string metadata keys through the provider metadata filter * fix(vector-stores): strip LiteLLM-written metadata keys only on proxy and router calls * fix(vector-stores): send the provider exactly the metadata the caller sent through the proxy * fix(vector-stores): snapshot caller metadata from the raw request body and keep it out of logged request bodies * fix(vector-stores): copy the parsed-body metadata when the raw request body cannot be reparsed
202 lines
7.7 KiB
Python
202 lines
7.7 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.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, aupdate, 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]
|