litellm/tests/unit/proxy/rag_endpoints/test_rag_endpoints.py
Shivam Rawat c58fc0c996
fix(rag)!: use team provider credentials and allow viewer and JWT team access (#45497)
* fix(rag): use team provider credentials and allow viewer and JWT team access

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(rag): assert persisted managed store data excludes team credentials

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(jwt): run real team model checks in the route denial test

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(rag): scope team provider credentials to openai vector stores

Bedrock and other providers kept their pre-PR credential and region
resolution: a team bedrock deployment's region no longer overrides the
caller's region on RAG query. Query tests now fake the provider at the
HTTP boundary with a real vector store registry.

* fix(rag): use only the team's own OpenAI deployment for RAG credentials

* fix(rag): ignore caller credential fields the query search never uses when picking team credentials

* fix(rag): treat blank credential fields as absent when picking team credentials

* test(rag): delete the stores the integration ingests create before the owned proxy exits

* fix(proxy): let a restricted key use the team deployment its wildcard or model covers

A key restricted to gpt-4o on a team whose own OpenAI deployment is openai/*
never resolved that deployment's credentials, because the key allowlist was
matched against the deployment's public name literally. The allowlist now
matches a wildcard on either side the way request authorization does, through
the auth layer's own helper, so /rag, /files and /batches resolve the team
deployment for every key that may call a model on it.

* fix(batches): read a restricted key's batch input through its team deployment

The batch access check downloaded the input JSONL with the proxy's OPENAI_API_KEY while the upload and the create used the team deployment, so a key restricted under a team openai/* deployment could not create a batch when the two keys belong to different OpenAI projects. The read now resolves the team deployment the way the create does when no model pins it

Adds the audit cells for keys restricted under the team wildcard on /rag, /files and /batches, and for the rag-vs-chat split of a key restricted to a global exact-name deployment

* fix(rag): load deployments oldest first, reject file_id on stores that cannot take one, and show ingest errors in the dashboard

* fix(rag): pick the team deployment with the lowest id, return 400 for caller mistakes, keep the ingest id in errors, and show partial success in the dashboard

* fix(rag): pick the team's oldest OpenAI deployment so adding one never moves the team

* fix(rag): keep config file team deployments first and try JWT teams in sorted order

A team's config file OpenAI deployments now win in file order, as on the
base, before its DB ones oldest first, so upgrading never moves a team that
has both. A JWT in several teams tries them in sorted order, so every worker
resolves the same team.

* fix(rag): return 400 for Bedrock, S3 Vectors and Gemini store config mistakes on ingest

* fix(rag): keep forwarded provider keys on /rag/query and mixed JWT team claims working

A forwarded x-api-key now counts as a caller credential, so the /rag/query
search uses it over the team deployment as at the merge base. Sorting the
JWT team claim compares entries as text, so a stray number or null no
longer turns into a 401. A Gemini store with no proxy key is a 500 again,
and a non-string vector_store_id keeps the merge base's 400 message.

* fix(rag): let a forwarded provider key win over team credentials on files, batches and ingest

* fix(rag): keep team credentials when a request carries its own key or api_base

A forwarded x-api-key or a body api_key now replaces only the key of the team
deployment, keeping its api_base, as chat completions does. A body
litellm_credential_name no longer turns team credentials off on /v1/batches.

* fix(batches): keep the team key on the team host when a request sends only api_base

A request api_base now replaces the team deployment's api_base only when the
request also brings its own api_key, and a non-string api_key or api_base is
ignored, as on the merge base.

* fix(files): ignore a blank request api_key or api_base next to team credentials

* fix(files): answer 400 for a request api_key that cannot be sent as an HTTP header

* fix(rag): honor a body drop_params for a forwarded key that cannot be an HTTP header

* fix(rag): read the request field for drop_params only on multipart ingest bodies

* fix(rag): type the ingest body read for drop_params

* fix(batches): count a model-routed batch's input with the deployment key it is created with

* fix(batches): reject a batch create whose x-litellm-model headers name different models

* fix(rag): treat a null provider and whitespace store credentials as unset when picking team credentials

* refactor(files): type the team deployment the credential resolver ranks

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-17 23:51:48 +00:00

1758 lines
70 KiB
Python

"""
Tests for RAG proxy endpoints.
"""
import io
import json
from collections.abc import Iterator, Mapping, Sequence
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
import respx
from fastapi.testclient import TestClient
import litellm
from litellm import Router
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.proxy_server import app
from litellm.types.vector_stores import LiteLLM_ManagedVectorStore
from litellm.vector_stores.vector_store_registry import VectorStoreRegistry
_TEAM_PROVIDER_KEY: Final = "team-openai-key"
_REGISTRY_PROVIDER_KEY: Final = "registry-openai-key"
_ENVIRONMENT_OPENAI_KEY: Final = "environment-openai-key"
_TEAM_ID: Final = "team-a"
_TEAM_OPENAI_BASE: Final = "https://team-openai.example/v1"
_REGISTRY_OPENAI_BASE: Final = "https://registry-openai.example/v1"
_OPENAI_SEARCH_PAGE: Final = {"object": "vector_store.search_results.page", "search_query": "q", "data": []}
_BEDROCK_KNOWLEDGE_BASE_ID: Final = "KB12345678"
_CALLER_AWS_ACCESS_KEY: Final = "caller-access-key"
_TEAM_AWS_ACCESS_KEY: Final = "team-access-key"
_HEADER_UNSAFE_PROVIDER_KEY: Final = "sk-forwarded\u00a0".encode("latin-1")
@pytest.fixture
def client_internal_user_viewer():
"""Test client with internal_user_viewer auth."""
mock_auth = UserAPIKeyAuth(
user_id="test_viewer_user",
user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
)
original_overrides = app.dependency_overrides.copy()
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
try:
yield TestClient(app)
finally:
app.dependency_overrides = original_overrides
@pytest.fixture
def client_internal_user():
"""Test client with internal_user auth (can create new vector stores)."""
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:
yield TestClient(app)
finally:
app.dependency_overrides = original_overrides
@pytest.fixture
def client_team_a() -> Iterator[TestClient]:
mock_auth: Final = UserAPIKeyAuth(user_id="test_team_user", team_id=_TEAM_ID)
original_overrides: Final = app.dependency_overrides.copy()
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
try:
yield TestClient(app)
finally:
app.dependency_overrides = original_overrides
def test_internal_user_viewer_rag_ingest_without_vector_store_id_allowed(client_internal_user_viewer):
with patch(
"litellm.proxy.rag_endpoints.endpoints.litellm.aingest",
new_callable=AsyncMock,
return_value={"status": "completed", "vector_store_id": "vs_new", "file_id": "file_123"},
) as mock_aingest:
response: Final = client_internal_user_viewer.post(
"/v1/rag/ingest",
files={"file": ("sample.txt", io.BytesIO(b"test content"), "text/plain")},
data={"request": '{"ingest_options":{"vector_store":{"custom_llm_provider":"openai"}}}'},
)
assert response.status_code == 200, response.json()
assert response.json()["status"] == "completed"
mock_aingest.assert_awaited_once()
def test_internal_user_viewer_rag_ingest_with_vector_store_id_passes_check(
client_internal_user_viewer,
):
"""
internal_user_viewer with vector_store_id passes the role check.
(Actual ingest may fail due to missing API keys, but we get past 403.)
"""
with patch(
"litellm.proxy.rag_endpoints.endpoints.litellm.aingest",
new_callable=AsyncMock,
return_value={"vector_store_id": "vs_existing", "file_id": "file_123"},
):
response = client_internal_user_viewer.post(
"/v1/rag/ingest",
files={"file": ("sample.txt", io.BytesIO(b"test content"), "text/plain")},
data={
"request": '{"ingest_options":{"vector_store":{"custom_llm_provider":"openai","vector_store_id":"vs_699651f6b6688191b0a210c00a686d20"}}}'
},
)
# Should not be 403 (role check passed)
assert response.status_code != 403, (
f"internal_user_viewer with vector_store_id should pass role check. Response: {response.json()}"
)
def test_internal_user_rag_ingest_without_vector_store_id_allowed(client_internal_user):
"""
internal_user can create new vector stores (no vector_store_id required).
"""
with patch(
"litellm.proxy.rag_endpoints.endpoints.litellm.aingest",
new_callable=AsyncMock,
return_value={"vector_store_id": "vs_new", "file_id": "file_123"},
):
response = client_internal_user.post(
"/v1/rag/ingest",
files={"file": ("sample.txt", io.BytesIO(b"test content"), "text/plain")},
data={"request": '{"ingest_options":{"vector_store":{"custom_llm_provider":"openai"}}}'},
)
# Should not be 403
assert response.status_code != 403, (
f"internal_user should be allowed to create new vector stores. Response: {response.json()}"
)
@pytest.mark.parametrize(
"blocked_field",
[
"vertex_credentials",
"vertex_ai_credentials",
"aws_access_key_id",
"aws_secret_access_key",
"aws_session_token",
"api_key",
"api_base",
],
)
def test_rag_ingest_blocks_clientside_credentials(client_internal_user, blocked_field):
"""
Credential fields in ingest_options.vector_store must be rejected.
Accepting user-supplied credentials (e.g. vertex_credentials with
type=external_account + credential_source.file=/proc/1/environ) allows
any authenticated user to exfiltrate host secrets via SSRF through
google-auth's identity_pool credential refresh.
"""
payload = {
"ingest_options": {
"vector_store": {
"custom_llm_provider": "vertex_ai",
"vertex_project": "x",
blocked_field: {
"type": "external_account",
"token_url": "http://attacker.example/sts",
},
}
}
}
response = client_internal_user.post(
"/v1/rag/ingest",
json={
**payload,
"file": {
"filename": "q.txt",
"content": "dGVzdA==",
"content_type": "text/plain",
},
},
)
assert response.status_code == 400, (
f"Expected 400 when '{blocked_field}' is set clientside, got {response.status_code}: {response.json()}"
)
body = response.json()
assert blocked_field in str(body), f"Response should mention '{blocked_field}': {body}"
class TestRagIngestSSRFBlocked:
"""
aws_sts_endpoint and related credential-redirect fields must be rejected
in ingest_options.vector_store. Without this guard, any authenticated
client can coerce the proxy to make a signed STS AssumeRole call to an
attacker-controlled server, leaking the instance profile credentials.
"""
@pytest.mark.parametrize(
"field,value",
[
("aws_sts_endpoint", "https://attacker.example/sts"),
("aws_web_identity_token", "fake-token"),
("aws_bedrock_runtime_endpoint", "https://attacker.example/bedrock"),
],
)
def test_ssrf_field_in_vector_store_config_rejected(self, field, value, client_internal_user):
payload = {
"file_url": "https://example.com/doc.pdf",
"ingest_options": {
"vector_store": {
"custom_llm_provider": "bedrock",
field: value,
}
},
}
response = client_internal_user.post(
"/v1/rag/ingest",
json=payload,
)
assert response.status_code == 400, (
f"{field} in ingest_options.vector_store should be rejected (400), "
f"got {response.status_code}: {response.json()}"
)
body = response.json()
detail = body.get("detail", {})
error_text = detail.get("error", "") if isinstance(detail, dict) else str(detail)
assert field in error_text, f"Error should name the offending field: {error_text}"
def test_clean_bedrock_ingest_options_not_rejected(self, client_internal_user):
with patch(
"litellm.proxy.rag_endpoints.endpoints.litellm.aingest",
new_callable=AsyncMock,
return_value={"vector_store_id": "vs_bedrock", "file_id": "file_123"},
):
response = client_internal_user.post(
"/v1/rag/ingest",
json={
"file_url": "https://example.com/doc.pdf",
"ingest_options": {"vector_store": {"custom_llm_provider": "bedrock"}},
},
)
assert response.status_code != 400, f"Clean Bedrock ingest_options should not be rejected: {response.json()}"
S3_REGISTRY_STORE = {
"vector_store_id": "s3-store",
"custom_llm_provider": "s3_vectors",
"litellm_params": {"aws_region_name": "eu-west-1", "vector_bucket_name": "bkt", "index_name": "docs"},
}
DB_MANAGED_STORE = {
"vector_store_id": "db-store",
"custom_llm_provider": "openai",
"litellm_credential_name": None,
"litellm_params": {"ttl_days": 7},
}
AZURE_REGISTRY_STORE = {
"vector_store_id": "my-azure-index",
"custom_llm_provider": "azure_ai",
"litellm_params": {
"api_key": "azure-search-key",
"api_base": "https://search.example.net",
"api_version": "2024-07-01",
},
}
BEDROCK_REGISTRY_STORE = {
"vector_store_id": "kb-store",
"custom_llm_provider": "bedrock",
"litellm_params": {
"aws_region_name": "eu-west-1",
"aws_access_key_id": "AKIA-registry",
"aws_secret_access_key": "registry-secret",
},
}
CREDENTIALED_REGISTRY_STORE = {
"vector_store_id": "cred-store",
"custom_llm_provider": "openai",
"litellm_credential_name": "registry-openai",
"litellm_params": {},
}
VERTEX_REGISTRY_STORE = {
"vector_store_id": "projects/registry-project/locations/us-central1/ragCorpora/42",
"custom_llm_provider": "vertex_ai",
"litellm_params": {"vertex_project": "registry-project", "vertex_location": "us-central1"},
}
UNSUPPORTED_INGEST_PROVIDER_ERROR = (
"Provider '{provider}' is not supported for RAG ingestion. "
"Supported providers: openai, bedrock, gemini, s3_vectors, vertex_ai"
)
def _registry_with(store):
registry = MagicMock()
registry.get_litellm_managed_vector_store_from_registry.return_value = store
return registry
def _ingest_form(vector_store, **request_fields):
return {
"files": {"file": ("sample.txt", io.BytesIO(b"test content"), "text/plain")},
"data": {"request": json.dumps({"ingest_options": {"vector_store": vector_store}, **request_fields})},
}
def _ingest_json(vector_store, **request_fields):
return {
"json": {
"file_url": "https://example.com/doc.txt",
"ingest_options": {"vector_store": vector_store},
**request_fields,
}
}
def _patched_ingest_boundary(registry_store, aingest_response):
return (
patch( # test-quality-ok: aingest is the endpoint's downstream boundary; tests assert the forwarded options
"litellm.proxy.rag_endpoints.endpoints.litellm.aingest",
new=AsyncMock(return_value=aingest_response),
),
patch.object( # test-quality-ok: seeds the managed-store registry the merge under test reads
litellm,
"vector_store_registry",
_registry_with(registry_store),
),
)
def _patched_prisma_client(prisma_client):
return patch( # test-quality-ok: proxy module global, no injection seam
"litellm.proxy.proxy_server.prisma_client",
prisma_client,
)
def _team_provider_router() -> Router:
return Router(
model_list=[
{
"model_name": "openai/team-provider",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": _TEAM_PROVIDER_KEY,
"api_base": _TEAM_OPENAI_BASE,
},
"model_info": {"team_id": _TEAM_ID},
},
_completion_deployment(),
]
)
def _completion_deployment() -> dict[str, object]:
return {
"model_name": "gpt-4o-mini",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "completion-key",
"mock_response": "hello",
},
}
def _team_bedrock_router() -> Router:
return Router(
model_list=[
{
"model_name": "bedrock/team-provider",
"litellm_params": {
"model": "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
"aws_region_name": "us-west-2",
"aws_access_key_id": _TEAM_AWS_ACCESS_KEY,
"aws_secret_access_key": "team-secret-key",
},
"model_info": {"team_id": _TEAM_ID},
},
_completion_deployment(),
]
)
def _managed_openai_store(vector_store_id: str, litellm_params: Mapping[str, object]) -> LiteLLM_ManagedVectorStore:
return LiteLLM_ManagedVectorStore(
vector_store_id=vector_store_id,
custom_llm_provider="openai",
litellm_credential_name=None,
litellm_params=dict(litellm_params),
)
def _post_rag_query(
client: TestClient,
*,
retrieval_config: Mapping[str, object],
managed_stores: Sequence[LiteLLM_ManagedVectorStore],
router: Router,
headers: Mapping[str, str | bytes] | None = None,
body: Mapping[str, object] | None = None,
) -> httpx.Response:
with (
patch.object( # test-quality-ok: seeds the real managed-store registry the query path reads
litellm,
"vector_store_registry",
VectorStoreRegistry(vector_stores=list(managed_stores)),
),
patch("litellm.proxy.proxy_server.llm_router", router),
_patched_prisma_client(None),
):
return client.post(
"/v1/rag/query",
json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "hello"}],
"retrieval_config": dict(retrieval_config),
**(body or {}),
},
headers=dict(headers or {}),
)
def test_rag_ingest_forwards_team_provider_credentials_without_persisting_them(client_team_a):
router: Final = _team_provider_router()
prisma_client: Final = MagicMock()
managed_vector_store_table: Final = prisma_client.db.litellm_managedvectorstorestable
managed_vector_store_table.find_unique = AsyncMock(return_value=None)
created_row: Final = MagicMock()
created_row.model_dump.return_value = {
"vector_store_id": "vs_new",
"custom_llm_provider": "openai",
"litellm_params": "{}",
"vector_store_metadata": "{}",
}
managed_vector_store_table.create = AsyncMock(return_value=created_row)
aingest_patch, registry_patch = _patched_ingest_boundary(
None,
{"status": "completed", "vector_store_id": "vs_new", "file_id": "file_123"},
)
with (
aingest_patch as mock_aingest,
registry_patch,
patch("litellm.proxy.proxy_server.llm_router", router),
_patched_prisma_client(prisma_client),
):
response: Final = client_team_a.post(
"/v1/rag/ingest",
**_ingest_form({"custom_llm_provider": "openai"}),
)
assert response.status_code == 200, response.json()
forwarded: Final = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]
assert forwarded["api_key"] == _TEAM_PROVIDER_KEY
assert forwarded["api_base"] == _TEAM_OPENAI_BASE
managed_vector_store_table.create.assert_awaited_once()
saved_data: Final = managed_vector_store_table.create.await_args.kwargs["data"]
assert saved_data["vector_store_id"] == "vs_new"
assert _TEAM_PROVIDER_KEY not in json.dumps(saved_data, default=str)
def test_rag_ingest_uses_the_forwarded_provider_key_over_team_provider_credentials(client_team_a):
forwarded_provider_key: Final = "sk-forwarded-provider-key"
aingest_patch, registry_patch = _patched_ingest_boundary(
None,
{"status": "completed", "vector_store_id": "vs_new", "file_id": "file_123"},
)
with (
aingest_patch as mock_aingest,
registry_patch,
patch("litellm.proxy.proxy_server.llm_router", _team_provider_router()),
patch.dict("litellm.proxy.proxy_server.general_settings", {"forward_llm_provider_auth_headers": True}),
_patched_prisma_client(None),
):
response: Final = client_team_a.post(
"/v1/rag/ingest",
headers={"Authorization": "Bearer sk-litellm-virtual-key", "x-api-key": forwarded_provider_key},
**_ingest_form({"custom_llm_provider": "openai"}),
)
assert response.status_code == 200, response.json()
forwarded: Final = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]
assert forwarded["api_key"] == forwarded_provider_key
assert forwarded["api_base"] == _TEAM_OPENAI_BASE
@pytest.mark.parametrize("ingest_request", [_ingest_form, _ingest_json])
def test_rag_ingest_drop_params_drops_a_forwarded_key_that_cannot_be_an_http_header(client_team_a, ingest_request):
aingest_patch, registry_patch = _patched_ingest_boundary(
None,
{"status": "completed", "vector_store_id": "vs_new", "file_id": "file_123"},
)
with (
aingest_patch as mock_aingest,
registry_patch,
patch("litellm.proxy.proxy_server.llm_router", _team_provider_router()),
patch.dict("litellm.proxy.proxy_server.general_settings", {"forward_llm_provider_auth_headers": True}),
_patched_prisma_client(None),
):
response: Final = client_team_a.post(
"/v1/rag/ingest",
headers={"Authorization": "Bearer sk-litellm-virtual-key", "x-api-key": _HEADER_UNSAFE_PROVIDER_KEY},
**ingest_request({"custom_llm_provider": "openai"}, drop_params=True),
)
assert response.status_code == 200, response.json()
forwarded: Final = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]
assert forwarded["api_key"] == _TEAM_PROVIDER_KEY
assert forwarded["api_base"] == _TEAM_OPENAI_BASE
@pytest.mark.parametrize("unread_request_field", ["not json", json.dumps({"drop_params": False})])
def test_rag_ingest_json_body_ignores_a_request_field_it_does_not_read(client_team_a, unread_request_field):
aingest_patch, registry_patch = _patched_ingest_boundary(
None,
{"status": "completed", "vector_store_id": "vs_new", "file_id": "file_123"},
)
with (
aingest_patch as mock_aingest,
registry_patch,
patch("litellm.proxy.proxy_server.llm_router", _team_provider_router()),
patch.dict("litellm.proxy.proxy_server.general_settings", {"forward_llm_provider_auth_headers": True}),
_patched_prisma_client(None),
):
response: Final = client_team_a.post(
"/v1/rag/ingest",
headers={"Authorization": "Bearer sk-litellm-virtual-key", "x-api-key": _HEADER_UNSAFE_PROVIDER_KEY},
**_ingest_json({"custom_llm_provider": "openai"}, drop_params=True, request=unread_request_field),
)
assert response.status_code == 200, response.json()
assert mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]["api_key"] == _TEAM_PROVIDER_KEY
def test_rag_ingest_caller_credential_name_suppresses_team_provider_credentials(client_team_a):
aingest_patch, registry_patch = _patched_ingest_boundary(
None,
{"status": "completed", "vector_store_id": "vs_new", "file_id": "file_123"},
)
with (
aingest_patch as mock_aingest,
registry_patch,
patch("litellm.proxy.proxy_server.llm_router", _team_provider_router()),
_patched_prisma_client(None),
):
response: Final = client_team_a.post(
"/v1/rag/ingest",
**_ingest_form({"custom_llm_provider": "openai", "litellm_credential_name": "caller-openai"}),
)
assert response.status_code == 200, response.json()
forwarded: Final = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]
assert forwarded["litellm_credential_name"] == "caller-openai"
assert "api_key" not in forwarded
def test_rag_ingest_managed_store_credentials_override_team_provider_credentials(client_team_a):
managed_store: Final = _managed_openai_store("registry-store-with-key", {"api_key": _REGISTRY_PROVIDER_KEY})
aingest_patch, registry_patch = _patched_ingest_boundary(
managed_store,
{"status": "completed", "vector_store_id": "registry-store-with-key", "file_id": "file_123"},
)
with (
aingest_patch as mock_aingest,
registry_patch,
patch("litellm.proxy.proxy_server.llm_router", _team_provider_router()),
patch(
"litellm.proxy.vector_store_endpoints.utils.can_user_access_vector_store",
new=AsyncMock(return_value=True),
),
_patched_prisma_client(None),
):
response: Final = client_team_a.post(
"/v1/rag/ingest",
**_ingest_form({"vector_store_id": "registry-store-with-key"}),
)
assert response.status_code == 200, response.json()
forwarded: Final = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]
assert forwarded["api_key"] == _REGISTRY_PROVIDER_KEY
assert forwarded["api_key"] != _TEAM_PROVIDER_KEY
def test_rag_ingest_managed_store_without_credentials_uses_team_provider_credentials(client_team_a):
managed_store: Final = _managed_openai_store("registry-store-without-key", {})
aingest_patch, registry_patch = _patched_ingest_boundary(
managed_store,
{"status": "completed", "vector_store_id": "registry-store-without-key", "file_id": "file_123"},
)
with (
aingest_patch as mock_aingest,
registry_patch,
patch("litellm.proxy.proxy_server.llm_router", _team_provider_router()),
patch(
"litellm.proxy.vector_store_endpoints.utils.can_user_access_vector_store",
new=AsyncMock(return_value=True),
),
_patched_prisma_client(None),
):
response: Final = client_team_a.post(
"/v1/rag/ingest",
**_ingest_form({"vector_store_id": "registry-store-without-key"}),
)
assert response.status_code == 200, response.json()
forwarded: Final = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]
assert forwarded["api_key"] == _TEAM_PROVIDER_KEY
@pytest.mark.parametrize("blank_credential_name", [None, "", " "])
def test_rag_ingest_blank_caller_credential_name_uses_team_provider_credentials(client_team_a, blank_credential_name):
aingest_patch, registry_patch = _patched_ingest_boundary(
None,
{"status": "completed", "vector_store_id": "vs_new", "file_id": "file_123"},
)
with (
aingest_patch as mock_aingest,
registry_patch,
patch("litellm.proxy.proxy_server.llm_router", _team_provider_router()),
_patched_prisma_client(None),
):
response: Final = client_team_a.post(
"/v1/rag/ingest",
**_ingest_form({"custom_llm_provider": "openai", "litellm_credential_name": blank_credential_name}),
)
assert response.status_code == 200, response.json()
forwarded: Final = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]
assert forwarded["api_key"] == _TEAM_PROVIDER_KEY
assert forwarded["api_base"] == _TEAM_OPENAI_BASE
def test_rag_ingest_failed_upstream_status_is_returned_without_persisting_store(client_internal_user):
prisma_client: Final = MagicMock()
managed_vector_store_table: Final = prisma_client.db.litellm_managedvectorstorestable
managed_vector_store_table.find_unique = AsyncMock(return_value=None)
managed_vector_store_table.create = AsyncMock()
with (
patch(
"litellm.proxy.rag_endpoints.endpoints.litellm.aingest",
new=AsyncMock(
return_value={
"id": "ingest_abc",
"status": "failed",
"error": "unauthorized",
"error_status_code": 401,
}
),
),
patch("litellm.vector_store_registry", None),
_patched_prisma_client(prisma_client),
):
response: Final = client_internal_user.post(
"/v1/rag/ingest",
**_ingest_form({"custom_llm_provider": "openai"}),
)
assert response.status_code == 401, response.json()
assert response.json() == {"detail": {"id": "ingest_abc", "status": "failed", "error": "unauthorized"}}
managed_vector_store_table.create.assert_not_called()
@pytest.mark.usefixtures("httpx_transport")
@pytest.mark.parametrize(
"caller_fields",
[
{},
{"api_key": None},
{"api_base": None},
{"api_key": "sk-caller-key-the-search-never-uses"},
{"custom_llm_provider": None},
],
)
def test_rag_query_forwards_team_provider_credentials_to_unmanaged_search(
client_team_a: TestClient, respx_mock: respx.MockRouter, caller_fields: Mapping[str, object]
) -> None:
search: Final = respx_mock.post(f"{_TEAM_OPENAI_BASE}/vector_stores/provider-native-store/search").respond(
json=_OPENAI_SEARCH_PAGE
)
response: Final = _post_rag_query(
client_team_a,
retrieval_config={"vector_store_id": "provider-native-store", "custom_llm_provider": "openai", **caller_fields},
managed_stores=(),
router=_team_provider_router(),
)
assert response.status_code == 200, response.json()
assert search.call_count == 1
assert search.calls.last.request.headers["authorization"] == f"Bearer {_TEAM_PROVIDER_KEY}"
@pytest.mark.usefixtures("httpx_transport")
def test_rag_query_managed_store_credentials_override_team_provider_credentials(
client_team_a: TestClient, respx_mock: respx.MockRouter
) -> None:
search: Final = respx_mock.post(f"{_REGISTRY_OPENAI_BASE}/vector_stores/registry-store-with-key/search").respond(
json=_OPENAI_SEARCH_PAGE
)
response: Final = _post_rag_query(
client_team_a,
retrieval_config={"vector_store_id": "registry-store-with-key"},
managed_stores=(
_managed_openai_store(
"registry-store-with-key",
{"api_key": _REGISTRY_PROVIDER_KEY, "api_base": _REGISTRY_OPENAI_BASE},
),
),
router=_team_provider_router(),
)
assert response.status_code == 200, response.json()
assert search.call_count == 1
assert search.calls.last.request.headers["authorization"] == f"Bearer {_REGISTRY_PROVIDER_KEY}"
@pytest.mark.usefixtures("httpx_transport")
def test_rag_query_managed_store_without_credentials_uses_team_provider_credentials(
client_team_a: TestClient, respx_mock: respx.MockRouter
) -> None:
search: Final = respx_mock.post(f"{_TEAM_OPENAI_BASE}/vector_stores/registry-store-without-key/search").respond(
json=_OPENAI_SEARCH_PAGE
)
response: Final = _post_rag_query(
client_team_a,
retrieval_config={"vector_store_id": "registry-store-without-key"},
managed_stores=(_managed_openai_store("registry-store-without-key", {}),),
router=_team_provider_router(),
)
assert response.status_code == 200, response.json()
assert search.call_count == 1
assert search.calls.last.request.headers["authorization"] == f"Bearer {_TEAM_PROVIDER_KEY}"
@pytest.mark.usefixtures("httpx_transport")
@pytest.mark.parametrize("blank_key", ["", " "])
def test_rag_query_managed_store_with_an_empty_key_uses_team_provider_credentials(
client_team_a: TestClient, respx_mock: respx.MockRouter, blank_key: str
) -> None:
search: Final = respx_mock.post(f"{_TEAM_OPENAI_BASE}/vector_stores/registry-store-blank-key/search").respond(
json=_OPENAI_SEARCH_PAGE
)
response: Final = _post_rag_query(
client_team_a,
retrieval_config={"vector_store_id": "registry-store-blank-key"},
managed_stores=(_managed_openai_store("registry-store-blank-key", {"api_key": blank_key}),),
router=_team_provider_router(),
)
assert response.status_code == 200, response.json()
assert search.call_count == 1
assert search.calls.last.request.headers["authorization"] == f"Bearer {_TEAM_PROVIDER_KEY}"
@pytest.mark.usefixtures("httpx_transport")
def test_rag_query_without_a_team_deployment_keeps_the_environment_openai_credentials(
client_internal_user: TestClient, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("OPENAI_API_KEY", _ENVIRONMENT_OPENAI_KEY)
for unset in ("OPENAI_BASE_URL", "OPENAI_API_BASE"):
monkeypatch.delenv(unset, raising=False)
search: Final = respx_mock.post("https://api.openai.com/v1/vector_stores/provider-native-store/search").respond(
json=_OPENAI_SEARCH_PAGE
)
response: Final = _post_rag_query(
client_internal_user,
retrieval_config={"vector_store_id": "provider-native-store", "custom_llm_provider": "openai"},
managed_stores=(),
router=_team_provider_router(),
)
assert response.status_code == 200, response.json()
assert search.call_count == 1
assert search.calls.last.request.headers["authorization"] == f"Bearer {_ENVIRONMENT_OPENAI_KEY}"
@pytest.mark.usefixtures("httpx_transport")
def test_rag_query_search_uses_the_forwarded_provider_key_over_team_provider_credentials(
client_team_a: TestClient, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
forwarded_provider_key: Final = "sk-forwarded-provider-key"
monkeypatch.setenv("OPENAI_API_KEY", _ENVIRONMENT_OPENAI_KEY)
for unset in ("OPENAI_BASE_URL", "OPENAI_API_BASE"):
monkeypatch.delenv(unset, raising=False)
search: Final = respx_mock.post(f"{_TEAM_OPENAI_BASE}/vector_stores/provider-native-store/search").respond(
json=_OPENAI_SEARCH_PAGE
)
with patch.dict("litellm.proxy.proxy_server.general_settings", {"forward_llm_provider_auth_headers": True}):
response: Final = _post_rag_query(
client_team_a,
retrieval_config={"vector_store_id": "provider-native-store", "custom_llm_provider": "openai"},
managed_stores=(),
router=_team_provider_router(),
headers={"Authorization": "Bearer sk-litellm-virtual-key", "x-api-key": forwarded_provider_key},
)
assert response.status_code == 200, response.json()
assert search.call_count == 1
assert search.calls.last.request.headers["authorization"] == f"Bearer {forwarded_provider_key}"
@pytest.mark.usefixtures("httpx_transport")
def test_rag_query_drop_params_drops_a_forwarded_key_that_cannot_be_an_http_header(
client_team_a: TestClient, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("OPENAI_API_KEY", _ENVIRONMENT_OPENAI_KEY)
for unset in ("OPENAI_BASE_URL", "OPENAI_API_BASE"):
monkeypatch.delenv(unset, raising=False)
search: Final = respx_mock.post(f"{_TEAM_OPENAI_BASE}/vector_stores/provider-native-store/search").respond(
json=_OPENAI_SEARCH_PAGE
)
with patch.dict("litellm.proxy.proxy_server.general_settings", {"forward_llm_provider_auth_headers": True}):
response: Final = _post_rag_query(
client_team_a,
retrieval_config={"vector_store_id": "provider-native-store", "custom_llm_provider": "openai"},
managed_stores=(),
router=_team_provider_router(),
headers={"Authorization": "Bearer sk-litellm-virtual-key", "x-api-key": _HEADER_UNSAFE_PROVIDER_KEY},
body={"drop_params": True},
)
assert response.status_code == 200, response.json()
assert search.call_count == 1
assert search.calls.last.request.headers["authorization"] == f"Bearer {_TEAM_PROVIDER_KEY}"
@pytest.mark.parametrize(
"retrieval_config, error",
[
({"custom_llm_provider": "openai"}, "retrieval_config must contain 'vector_store_id'"),
({"vector_store_id": 7, "custom_llm_provider": "openai"}, "vector_store_id must be a non-empty string"),
],
)
def test_rag_query_names_what_is_wrong_with_the_vector_store_id(
client_team_a: TestClient, retrieval_config: Mapping[str, object], error: str
) -> None:
response: Final = _post_rag_query(
client_team_a, retrieval_config=retrieval_config, managed_stores=(), router=_team_provider_router()
)
assert response.status_code == 400, response.json()
assert error in response.text
@pytest.mark.usefixtures("httpx_transport")
def test_rag_query_bedrock_search_keeps_the_callers_region_and_credentials_over_team_deployment(
client_team_a: TestClient, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("AWS_ACCESS_KEY_ID", _CALLER_AWS_ACCESS_KEY)
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "caller-secret-key")
for unset in ("AWS_BEARER_TOKEN_BEDROCK", "AWS_SESSION_TOKEN", "AWS_PROFILE", "AWS_REGION_NAME", "AWS_REGION"):
monkeypatch.delenv(unset, raising=False)
retrieve: Final = respx_mock.post(
url__regex=rf"^https://bedrock-agent-runtime\.[a-z0-9-]+\.amazonaws\.com/knowledgebases/{_BEDROCK_KNOWLEDGE_BASE_ID}/retrieve$"
).respond(json={"retrievalResults": []})
response: Final = _post_rag_query(
client_team_a,
retrieval_config={
"vector_store_id": _BEDROCK_KNOWLEDGE_BASE_ID,
"custom_llm_provider": "bedrock",
"aws_region_name": "us-east-1",
},
managed_stores=(),
router=_team_bedrock_router(),
)
assert response.status_code == 200, response.json()
assert retrieve.call_count == 1
sent: Final = retrieve.calls.last.request
assert sent.url.host == "bedrock-agent-runtime.us-east-1.amazonaws.com"
assert f"Credential={_CALLER_AWS_ACCESS_KEY}/" in sent.headers["authorization"]
def test_rag_ingest_resolves_registry_store_provider_and_params(client_internal_user):
aingest_patch, registry_patch = _patched_ingest_boundary(
S3_REGISTRY_STORE, {"vector_store_id": "s3-store", "file_id": "file_123"}
)
with (
aingest_patch as mock_aingest,
registry_patch,
_patched_prisma_client(None),
):
response = client_internal_user.post("/v1/rag/ingest", **_ingest_form({"vector_store_id": "s3-store"}))
assert response.status_code == 200, response.json()
mock_aingest.assert_awaited_once()
forwarded = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]
assert forwarded["vector_store_id"] == "s3-store"
assert forwarded["custom_llm_provider"] == "s3_vectors"
assert forwarded["aws_region_name"] == "eu-west-1"
assert forwarded["vector_bucket_name"] == "bkt"
assert forwarded["index_name"] == "docs"
def test_rag_ingest_registry_store_wins_over_request_provider_and_params(client_internal_user):
aingest_patch, registry_patch = _patched_ingest_boundary(
S3_REGISTRY_STORE, {"vector_store_id": "s3-store", "file_id": "file_123"}
)
with (
aingest_patch as mock_aingest,
registry_patch,
_patched_prisma_client(None),
):
response = client_internal_user.post(
"/v1/rag/ingest",
**_ingest_form(
{"vector_store_id": "s3-store", "custom_llm_provider": "openai", "aws_region_name": "us-east-1"}
),
)
assert response.status_code == 200, response.json()
forwarded = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]
assert forwarded["custom_llm_provider"] == "s3_vectors"
assert forwarded["aws_region_name"] == "eu-west-1"
def test_rag_ingest_registry_store_drops_caller_destinations_and_keeps_upload_options(client_internal_user):
aingest_patch, registry_patch = _patched_ingest_boundary(
BEDROCK_REGISTRY_STORE, {"vector_store_id": "kb-store", "file_id": "file_123"}
)
with (
aingest_patch as mock_aingest,
registry_patch,
_patched_prisma_client(None),
):
response = client_internal_user.post(
"/v1/rag/ingest",
**_ingest_form(
{
"vector_store_id": "kb-store",
"s3_bucket": "someone-elses-bucket",
"s3_prefix": "other-kb/",
"vector_bucket_name": "someone-elses-vectors",
"index_name": "other-index",
"vertex_project": "other-project",
"data_source_id": "DS2",
"wait_for_ingestion": True,
"ingestion_timeout": 60,
}
),
)
assert response.status_code == 200, response.json()
forwarded = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]
assert forwarded == {
"vector_store_id": "kb-store",
"custom_llm_provider": "bedrock",
"aws_region_name": "eu-west-1",
"aws_access_key_id": "AKIA-registry",
"aws_secret_access_key": "registry-secret",
"data_source_id": "DS2",
"wait_for_ingestion": True,
"ingestion_timeout": 60,
}
def test_rag_ingest_unmanaged_store_keeps_the_callers_full_config(client_internal_user):
caller_config = {
"vector_store_id": "KB-unmanaged",
"custom_llm_provider": "bedrock",
"s3_bucket": "callers-bucket",
"s3_prefix": "docs/",
}
aingest_patch, registry_patch = _patched_ingest_boundary(
None, {"vector_store_id": "KB-unmanaged", "file_id": "file_123"}
)
with (
aingest_patch as mock_aingest,
registry_patch,
_patched_prisma_client(None),
):
response = client_internal_user.post("/v1/rag/ingest", **_ingest_form(caller_config))
assert response.status_code == 200, response.json()
assert mock_aingest.await_args.kwargs["ingest_options"]["vector_store"] == caller_config
def test_rag_ingest_db_managed_store_drops_the_callers_credential_name(client_internal_user):
aingest_patch, registry_patch = _patched_ingest_boundary(
DB_MANAGED_STORE, {"vector_store_id": "db-store", "file_id": "file_123"}
)
with (
aingest_patch as mock_aingest,
registry_patch,
_patched_prisma_client(None),
):
response = client_internal_user.post(
"/v1/rag/ingest",
**_ingest_form({"vector_store_id": "db-store", "litellm_credential_name": "team-openai"}),
)
assert response.status_code == 200, response.json()
forwarded = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]
assert "litellm_credential_name" not in forwarded
assert forwarded["custom_llm_provider"] == "openai"
assert forwarded["ttl_days"] == 7
def test_rag_ingest_registry_store_credential_name_beats_the_callers(client_internal_user):
aingest_patch, registry_patch = _patched_ingest_boundary(
CREDENTIALED_REGISTRY_STORE, {"vector_store_id": "cred-store", "file_id": "file_123"}
)
with (
aingest_patch as mock_aingest,
registry_patch,
_patched_prisma_client(None),
):
response = client_internal_user.post(
"/v1/rag/ingest",
**_ingest_form({"vector_store_id": "cred-store", "litellm_credential_name": "team-openai"}),
)
assert response.status_code == 200, response.json()
forwarded = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]
assert forwarded["litellm_credential_name"] == "registry-openai"
def test_rag_ingest_registry_store_keeps_the_callers_vertex_embedding_throttle(client_internal_user):
aingest_patch, registry_patch = _patched_ingest_boundary(
VERTEX_REGISTRY_STORE, {"vector_store_id": VERTEX_REGISTRY_STORE["vector_store_id"], "file_id": "file_123"}
)
with (
aingest_patch as mock_aingest,
registry_patch,
_patched_prisma_client(None),
):
response = client_internal_user.post(
"/v1/rag/ingest",
**_ingest_form(
{
"vector_store_id": VERTEX_REGISTRY_STORE["vector_store_id"],
"max_embedding_requests_per_min": 500,
"vector_db_config": {"pinecone": {"index_name": "attacker-index"}},
}
),
)
assert response.status_code == 200, response.json()
assert mock_aingest.await_args.kwargs["ingest_options"]["vector_store"] == {
"vector_store_id": VERTEX_REGISTRY_STORE["vector_store_id"],
"custom_llm_provider": "vertex_ai",
"vertex_project": "registry-project",
"vertex_location": "us-central1",
"max_embedding_requests_per_min": 500,
}
def test_rag_ingest_rejects_registry_store_provider_without_ingestion_support(client_internal_user):
aingest_patch, registry_patch = _patched_ingest_boundary(
AZURE_REGISTRY_STORE, {"vector_store_id": "my-azure-index", "file_id": "file_123"}
)
with (
aingest_patch as mock_aingest,
registry_patch,
_patched_prisma_client(None),
):
response = client_internal_user.post("/v1/rag/ingest", **_ingest_form({"vector_store_id": "my-azure-index"}))
assert response.status_code == 400, response.json()
assert response.json()["detail"]["error"] == UNSUPPORTED_INGEST_PROVIDER_ERROR.format(provider="azure_ai")
mock_aingest.assert_not_awaited()
def test_rag_ingest_rejects_request_provider_without_ingestion_support(client_internal_user):
with (
patch( # test-quality-ok: aingest is the endpoint's downstream boundary; the test asserts it is never reached
"litellm.proxy.rag_endpoints.endpoints.litellm.aingest",
new=AsyncMock(return_value={"vector_store_id": "vs_new", "file_id": "file-test"}),
) as mock_aingest,
patch("litellm.vector_store_registry", None), # test-quality-ok: proxy module global, no injection seam
):
response = client_internal_user.post(
"/v1/rag/ingest",
json={"file_id": "file-test", "ingest_options": {"vector_store": {"custom_llm_provider": "milvus"}}},
)
assert response.status_code == 400, response.json()
assert response.json() == {"detail": {"error": UNSUPPORTED_INGEST_PROVIDER_ERROR.format(provider="milvus")}}
mock_aingest.assert_not_awaited()
def test_rag_ingest_rejects_non_string_provider(client_internal_user):
with (
patch( # test-quality-ok: aingest is the endpoint's downstream boundary; the test asserts it is never reached
"litellm.proxy.rag_endpoints.endpoints.litellm.aingest",
new=AsyncMock(return_value={"vector_store_id": "vs_new", "file_id": "file-test"}),
) as mock_aingest,
patch("litellm.vector_store_registry", None), # test-quality-ok: proxy module global, no injection seam
):
response = client_internal_user.post(
"/v1/rag/ingest",
json={
"file_id": "file-test",
"ingest_options": {"vector_store": {"custom_llm_provider": {"provider": "milvus"}}},
},
)
assert response.status_code == 400, response.json()
assert response.json() == {"detail": {"error": "custom_llm_provider must be a string"}}
mock_aingest.assert_not_awaited()
def test_rag_ingest_never_creates_db_row_for_registry_store(client_internal_user):
prisma_client = MagicMock()
prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(return_value=None)
create_in_db = AsyncMock()
aingest_patch, registry_patch = _patched_ingest_boundary(
S3_REGISTRY_STORE, {"vector_store_id": "s3-store", "file_id": "file_123"}
)
with (
aingest_patch,
registry_patch,
_patched_prisma_client(prisma_client),
patch( # test-quality-ok: the DB write boundary the guard under test must never reach
"litellm.proxy.vector_store_endpoints.management_endpoints.create_vector_store_in_db",
new=create_in_db,
),
):
response = client_internal_user.post("/v1/rag/ingest", **_ingest_form({"vector_store_id": "s3-store"}))
assert response.status_code == 200, response.json()
prisma_client.db.litellm_managedvectorstorestable.find_unique.assert_awaited_once()
create_in_db.assert_not_awaited()
prisma_client.db.litellm_managedvectorstorestable.update.assert_not_called()
def test_rag_ingest_fresh_store_creates_db_row_with_the_requesters_params(client_internal_user):
prisma_client = MagicMock()
prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(return_value=None)
create_in_db = AsyncMock()
with (
patch( # test-quality-ok: aingest is the endpoint's downstream boundary; persistence is what the test asserts
"litellm.proxy.rag_endpoints.endpoints.litellm.aingest",
new=AsyncMock(return_value={"vector_store_id": "vs_new", "file_id": "file_123"}),
),
patch("litellm.vector_store_registry", None), # test-quality-ok: proxy module global, no injection seam
_patched_prisma_client(prisma_client),
patch( # test-quality-ok: the DB write boundary whose inputs the test asserts
"litellm.proxy.vector_store_endpoints.management_endpoints.create_vector_store_in_db",
new=create_in_db,
),
):
response = client_internal_user.post(
"/v1/rag/ingest",
**_ingest_form({"custom_llm_provider": "bedrock", "aws_region_name": "us-east-1"}),
)
assert response.status_code == 200, response.json()
create_in_db.assert_awaited_once()
created = create_in_db.await_args.kwargs
assert created["vector_store_id"] == "vs_new"
assert created["custom_llm_provider"] == "bedrock"
assert created["litellm_params"] == {"aws_region_name": "us-east-1"}
def test_rag_ingest_hands_persistence_the_requesters_options_not_registry_credentials(client_internal_user):
save_helper = AsyncMock()
aingest_patch, registry_patch = _patched_ingest_boundary(
BEDROCK_REGISTRY_STORE, {"vector_store_id": "kb-store", "file_id": "file_123"}
)
with (
aingest_patch as mock_aingest,
registry_patch,
_patched_prisma_client(MagicMock()),
patch( # test-quality-ok: the persistence seam whose inputs the test asserts
"litellm.proxy.rag_endpoints.endpoints._save_vector_store_to_db_from_rag_ingest",
new=save_helper,
),
):
response = client_internal_user.post("/v1/rag/ingest", **_ingest_form({"vector_store_id": "kb-store"}))
assert response.status_code == 200, response.json()
forwarded = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]
assert forwarded["aws_secret_access_key"] == "registry-secret"
save_helper.assert_awaited_once()
assert save_helper.await_args.kwargs["ingest_options"]["vector_store"] == {"vector_store_id": "kb-store"}
assert save_helper.await_args.kwargs["store_is_managed"] is True
async def test_save_vector_store_from_rag_ingest_appends_file_to_db_managed_store():
from litellm.proxy.rag_endpoints.endpoints import _save_vector_store_to_db_from_rag_ingest
existing_row = MagicMock()
existing_row.vector_store_metadata = {"ingested_files": [{"file_id": "file_old"}]}
prisma_client = MagicMock()
table = prisma_client.db.litellm_managedvectorstorestable
table.find_unique = AsyncMock(return_value=existing_row)
table.update = AsyncMock()
create_in_db = AsyncMock()
with patch( # test-quality-ok: the DB write boundary the append branch must not reach
"litellm.proxy.vector_store_endpoints.management_endpoints.create_vector_store_in_db",
new=create_in_db,
):
await _save_vector_store_to_db_from_rag_ingest(
response={"vector_store_id": "vs_db_managed", "file_id": "file_new"},
ingest_options={"vector_store": {"vector_store_id": "vs_db_managed"}},
prisma_client=prisma_client,
user_api_key_dict=UserAPIKeyAuth(user_id="user-1", team_id="team-1"),
store_is_managed=True,
)
create_in_db.assert_not_awaited()
table.update.assert_awaited_once()
stored_metadata = json.loads(table.update.await_args.kwargs["data"]["vector_store_metadata"])
assert [entry["file_id"] for entry in stored_metadata["ingested_files"]] == ["file_old", "file_new"]
async def test_save_vector_store_from_rag_ingest_still_creates_row_for_fresh_store():
from litellm.proxy.rag_endpoints.endpoints import _save_vector_store_to_db_from_rag_ingest
prisma_client = MagicMock()
prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(return_value=None)
create_in_db = AsyncMock()
with patch( # test-quality-ok: the DB write boundary whose inputs the test asserts
"litellm.proxy.vector_store_endpoints.management_endpoints.create_vector_store_in_db",
new=create_in_db,
):
await _save_vector_store_to_db_from_rag_ingest(
response={"vector_store_id": "vs_new", "file_id": "file_new"},
ingest_options={"vector_store": {"custom_llm_provider": "bedrock", "aws_region_name": "us-east-1"}},
prisma_client=prisma_client,
user_api_key_dict=UserAPIKeyAuth(user_id="user-1", team_id="team-1"),
store_is_managed=False,
)
create_in_db.assert_awaited_once()
created = create_in_db.await_args.kwargs
assert created["vector_store_id"] == "vs_new"
assert created["custom_llm_provider"] == "bedrock"
assert created["litellm_params"] == {"aws_region_name": "us-east-1"}
assert created["team_id"] == "team-1"
def test_rag_query_returns_response_cost_header(client_internal_user):
"""
/v1/rag/query must surface the completion cost via the
x-litellm-response-cost response header, like /v1/chat/completions does.
"""
from litellm.types.utils import ModelResponse
mock_response = ModelResponse(
id="chatcmpl-test",
choices=[
{
"index": 0,
"message": {"role": "assistant", "content": "The codename is AZURE-FALCON-42."},
"finish_reason": "stop",
}
],
model="gpt-4o-mini",
usage={"prompt_tokens": 35, "completion_tokens": 14, "total_tokens": 49},
)
mock_response._hidden_params["response_cost"] = 3.45e-06
with (
patch(
"litellm.proxy.rag_endpoints.endpoints.litellm.aquery",
new_callable=AsyncMock,
return_value=mock_response,
),
patch("litellm.vector_store_registry", None),
patch("litellm.proxy.proxy_server.prisma_client", None),
):
response = client_internal_user.post(
"/v1/rag/query",
json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "What is the codename?"}],
"retrieval_config": {
"vector_store_id": "vs_test_123",
"custom_llm_provider": "openai",
},
},
)
assert response.status_code == 200, response.json()
assert response.headers.get("x-litellm-response-cost") == "3.45e-06"
@pytest.mark.parametrize(
("upstream_error", "expected_status"),
[
(litellm.BadRequestError(message="filter andAll needs two clauses", model="kb", llm_provider="bedrock"), 400),
(litellm.NotFoundError(message="Knowledge Base does not exist", model="kb", llm_provider="bedrock"), 404),
(RuntimeError("pipeline blew up"), 500),
],
)
def test_rag_query_surfaces_upstream_status_code(client_internal_user, upstream_error, expected_status):
"""A vector store rejection must reach the caller with its own status code, never a blanket 500."""
with (
patch( # test-quality-ok: the handler calls the module-level litellm.aquery directly; no injection seam
"litellm.proxy.rag_endpoints.endpoints.litellm.aquery",
new=AsyncMock(side_effect=upstream_error),
),
patch("litellm.vector_store_registry", None), # test-quality-ok: proxy module global, no injection seam
patch(
"litellm.proxy.proxy_server.prisma_client", None
), # test-quality-ok: proxy module global, no injection seam
):
response = client_internal_user.post(
"/v1/rag/query",
json={
"model": "bedrock/us.anthropic.claude-sonnet-5",
"messages": [{"role": "user", "content": "How was this document ingested?"}],
"retrieval_config": {
"vector_store_id": "L7INRFMVQT",
"custom_llm_provider": "bedrock",
"retrieval_filter": {"andAll": [{"equals": {"key": "department", "value": "billing"}}]},
},
},
)
assert response.status_code == expected_status, response.text
assert str(upstream_error) in response.json()["detail"]["error"]
def test_rag_query_stream_returns_event_stream(client_internal_user):
"""
A stream=true /v1/rag/query must return an SSE response. Returning the raw
stream wrapper makes FastAPI try to serialize it, which raises and turns
every streaming RAG query into a 500; the stream then never drains, so its
single billing event (which carries the folded sub-call costs) never fires.
"""
import litellm as litellm_module
async def fake_aquery(**kwargs):
return await litellm_module.acompletion(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "What is the codename?"}],
mock_response="The codename is AZURE-FALCON-42.",
stream=True,
api_key="test-key",
)
with (
patch(
"litellm.proxy.rag_endpoints.endpoints.litellm.aquery",
new=AsyncMock(side_effect=fake_aquery),
),
patch("litellm.vector_store_registry", None),
patch("litellm.proxy.proxy_server.prisma_client", None),
):
response = client_internal_user.post(
"/v1/rag/query",
json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "What is the codename?"}],
"retrieval_config": {
"vector_store_id": "vs_test_123",
"custom_llm_provider": "openai",
},
"stream": True,
},
)
assert response.status_code == 200, response.text
assert response.headers.get("content-type", "").startswith("text/event-stream")
assert '"object":"chat.completion.chunk"' in response.text
assert "data: [DONE]" in response.text
def test_rag_query_stream_pings_while_retrieval_is_still_running(client_internal_user, monkeypatch):
import asyncio
import litellm as litellm_module
monkeypatch.setattr(litellm_module, "sse_keepalive_ping_interval_seconds", 0.05)
async def slow_aquery(**kwargs):
await asyncio.sleep(0.3)
return await litellm_module.acompletion(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "What is the codename?"}],
mock_response="The codename is AZURE-FALCON-42.",
stream=True,
api_key="test-key",
)
with (
patch( # test-quality-ok: the handler calls the module-level litellm.aquery directly; no injection seam
"litellm.proxy.rag_endpoints.endpoints.litellm.aquery",
new=AsyncMock(side_effect=slow_aquery),
),
patch("litellm.vector_store_registry", None), # test-quality-ok: proxy module global, no injection seam
patch(
"litellm.proxy.proxy_server.prisma_client", None
), # test-quality-ok: proxy module global, no injection seam
):
response = client_internal_user.post(
"/v1/rag/query",
json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "What is the codename?"}],
"retrieval_config": {
"vector_store_id": "vs_test_123",
"custom_llm_provider": "openai",
},
"stream": True,
},
)
assert response.status_code == 200, response.text
assert response.headers.get("content-type", "").startswith("text/event-stream")
assert response.headers["x-accel-buffering"] == "no"
assert response.text.startswith(": ping\n\n")
assert response.text.count(": ping\n\n") >= 3
assert '"object":"chat.completion.chunk"' in response.text
assert response.text.endswith("data: [DONE]\n\n")
def test_rag_query_stream_keeps_response_headers_when_retrieval_beats_the_keepalive(client_internal_user, monkeypatch):
import litellm as litellm_module
monkeypatch.setattr(litellm_module, "sse_keepalive_ping_interval_seconds", 5)
async def fast_aquery(**kwargs):
response = await litellm_module.acompletion(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "What is the codename?"}],
mock_response="The codename is AZURE-FALCON-42.",
stream=True,
api_key="test-key",
)
response._hidden_params["response_cost"] = 3.45e-06
return response
with (
patch( # test-quality-ok: the handler calls the module-level litellm.aquery directly; no injection seam
"litellm.proxy.rag_endpoints.endpoints.litellm.aquery",
new=AsyncMock(side_effect=fast_aquery),
),
patch("litellm.vector_store_registry", None), # test-quality-ok: proxy module global, no injection seam
patch(
"litellm.proxy.proxy_server.prisma_client", None
), # test-quality-ok: proxy module global, no injection seam
):
response = client_internal_user.post(
"/v1/rag/query",
json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "What is the codename?"}],
"retrieval_config": {
"vector_store_id": "vs_test_123",
"custom_llm_provider": "openai",
},
"stream": True,
},
)
assert response.status_code == 200, response.text
assert response.headers.get("content-type", "").startswith("text/event-stream")
assert response.headers.get("x-litellm-response-cost") == "3.45e-06"
assert not response.text.startswith(": ping")
assert '"object":"chat.completion.chunk"' in response.text
assert response.text.endswith("data: [DONE]\n\n")
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"
def test_rag_query_forwards_managed_store_credentials_to_search(client_internal_user):
"""
Regression for LIT-6773: the registry store's api_key / api_base and its
provider extras (Milvus outputFields, milvus_text_field) must reach the
vector store search the way the direct /v1/vector_stores/{id}/search
endpoint forwards them. Pre-fix the RAG path allowlisted them away and a
managed Milvus store 500'd with "MILVUS_API_KEY is not set".
"""
import litellm
from litellm import Router
from litellm.types.vector_stores import VectorStoreSearchResponse
mock_vector_store = {
"vector_store_id": "customer_kb",
"custom_llm_provider": "milvus",
"litellm_params": {
"vector_store_id": "customer_kb",
"custom_llm_provider": "milvus",
"api_base": "http://127.0.0.1:19530",
"api_key": "root:Milvus",
"litellm_embedding_model": "multilingual-e5-large",
"milvus_text_field": "book_intro_text",
"outputFields": ["book_intro_text"],
},
}
mock_registry = MagicMock()
mock_registry.get_litellm_managed_vector_store_from_registry.return_value = mock_vector_store
fake_search = AsyncMock(
return_value=VectorStoreSearchResponse(object="vector_store.search_results.page", search_query="q", data=[])
)
router = Router(
model_list=[
{
"model_name": "gpt-4o-mini",
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-test", "mock_response": "hi"},
}
]
)
with (
patch("litellm.vector_stores.asearch", new=fake_search), # test-quality-ok: the search boundary under test
patch.object(litellm, "vector_store_registry", mock_registry), # test-quality-ok: seeds the store under test
patch("litellm.proxy.proxy_server.llm_router", router), # test-quality-ok: mock-response router for completion
patch( # test-quality-ok: store access is not under test, so the request reaches the search boundary
"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": "which database is built for similarity search?"}],
"retrieval_config": {"vector_store_id": "customer_kb", "custom_llm_provider": "milvus", "top_k": 2},
},
)
assert response.status_code == 200, response.json()
fake_search.assert_awaited_once()
search_kwargs = fake_search.await_args.kwargs
assert search_kwargs["vector_store_id"] == "customer_kb"
assert search_kwargs["custom_llm_provider"] == "milvus"
assert search_kwargs["max_num_results"] == 2
assert search_kwargs["api_base"] == "http://127.0.0.1:19530"
assert search_kwargs["api_key"] == "root:Milvus"
assert search_kwargs["litellm_embedding_model"] == "multilingual-e5-large"
assert search_kwargs["milvus_text_field"] == "book_intro_text"
assert search_kwargs["outputFields"] == ["book_intro_text"]
@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"}}}'
def _multipart_ingest_request(*, filename: str, content: bytes, content_type: str):
from starlette.requests import Request
boundary = "litellmuploadtestboundary"
head = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
f"Content-Type: {content_type}\r\n\r\n"
).encode()
tail = (
f"\r\n--{boundary}\r\n"
f'Content-Disposition: form-data; name="request"\r\n\r\n'
f"{INGEST_REQUEST}\r\n"
f"--{boundary}--\r\n"
).encode()
body = head + content + tail
scope = {
"type": "http",
"method": "POST",
"path": "/v1/rag/ingest",
"headers": [
(b"content-type", f"multipart/form-data; boundary={boundary}".encode()),
(b"content-length", str(len(body)).encode()),
],
"state": {},
}
async def receive():
return {"type": "http.request", "body": body, "more_body": False}
return Request(scope, receive)
class TestVectorStoreUploadControls:
"""End-to-end enforcement of pentest M4 upload controls on /v1/rag/ingest."""
def test_eicar_upload_blocked_by_malware_scanner(self, client_internal_user):
response = client_internal_user.post(
"/v1/rag/ingest",
files={"file": ("clean_name.txt", io.BytesIO(EICAR.encode()), "text/plain")},
data={"request": INGEST_REQUEST},
)
assert response.status_code == 400, response.text
assert response.json()["detail"]["reason"] == "malware_detected"
def test_executable_upload_rejected(self, client_internal_user):
elf = b"\x7fELF\x02\x01\x01\x00" + b"\x00" * 40
response = client_internal_user.post(
"/v1/rag/ingest",
files={"file": ("doc.txt", io.BytesIO(elf), "text/plain")},
data={"request": INGEST_REQUEST},
)
assert response.status_code == 400, response.text
assert response.json()["detail"]["reason"] == "executable_not_allowed"
def test_zip_archive_upload_rejected(self, client_internal_user):
response = client_internal_user.post(
"/v1/rag/ingest",
files={"file": ("doc.pdf", io.BytesIO(b"PK\x03\x04\x14\x00\x00\x00payload"), "application/pdf")},
data={"request": INGEST_REQUEST},
)
assert response.status_code == 400, response.text
assert response.json()["detail"]["reason"] == "archive_not_allowed"
async def test_clean_text_upload_gets_server_generated_filename(self):
from litellm.proxy.rag_endpoints.endpoints import parse_rag_ingest_request
from litellm.proxy.rag_endpoints.upload_security import EicarTestMalwareScanner
request = _multipart_ingest_request(
filename="../../etc/passwd",
content=b"benign document text\n",
content_type="text/plain",
)
_options, file_data, _url, _file_id = await parse_rag_ingest_request(request, scanner=EicarTestMalwareScanner())
assert file_data is not None
server_filename, content_bytes, secured_content_type = file_data
assert server_filename != "../../etc/passwd"
assert "/" not in server_filename and "\\" not in server_filename
assert server_filename.endswith(".txt")
assert secured_content_type == "text/plain"
assert content_bytes == b"benign document text\n"