mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-01 02:02:20 +00:00
fix(files): opt-out flag, bounded read, and fail-closed source for the vector store upload controls
This commit is contained in:
parent
cc26342a3a
commit
328fe2aec2
9 changed files with 219 additions and 55 deletions
|
|
@ -2767,7 +2767,8 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
description=(
|
||||
"controls run on every upload that lands in a vector store (/v1/rag/ingest and /v1/files with purpose "
|
||||
"assistants or user_data); rag_ingest.malware_scanner names the scanner instance as <module>.<instance> "
|
||||
"the way custom_auth does, and unset runs the EICAR test scanner - https://docs.litellm.ai/docs/rag_ingest"
|
||||
"the way custom_auth does, and unset runs the EICAR test scanner; rag_ingest.files_api_controls: false lets "
|
||||
"the /v1/files uploads through unchecked - https://docs.litellm.ai/docs/rag_ingest"
|
||||
),
|
||||
)
|
||||
max_parallel_requests: int | None = Field(
|
||||
|
|
|
|||
|
|
@ -94,7 +94,12 @@ from litellm.proxy.openai_files_endpoints.general_upload_validation import (
|
|||
coerce_optional_str_list_setting,
|
||||
raise_upload_validation_failure,
|
||||
)
|
||||
from litellm.proxy.rag_endpoints.upload_security import MalwareScanner, RejectedUpload, validate_upload
|
||||
from litellm.proxy.rag_endpoints.upload_security import (
|
||||
MAX_UPLOAD_SIZE_BYTES,
|
||||
MalwareScanner,
|
||||
RejectedUpload,
|
||||
validate_upload,
|
||||
)
|
||||
from litellm.proxy.utils import PrismaClient, ProxyLogging, is_known_model
|
||||
from litellm.repositories.table_repositories import ManagedFileRepository
|
||||
from litellm.router import Router
|
||||
|
|
@ -105,6 +110,7 @@ from litellm.types.llms.openai import (
|
|||
OpenAIFileObject,
|
||||
OpenAIFilesPurpose,
|
||||
)
|
||||
from litellm.types.proxy.rag_ingest import RagIngestSettings
|
||||
|
||||
router: Final = APIRouter()
|
||||
|
||||
|
|
@ -531,14 +537,33 @@ async def route_create_file(
|
|||
_VECTOR_STORE_UPLOAD_PURPOSES: Final[frozenset[str]] = frozenset({"assistants", "user_data"})
|
||||
|
||||
|
||||
async def _reject_unsafe_vector_store_upload(
|
||||
purpose: str,
|
||||
file_source: bytes | BinaryIO,
|
||||
scanner: MalwareScanner,
|
||||
) -> None:
|
||||
if purpose not in _VECTOR_STORE_UPLOAD_PURPOSES or not isinstance(file_source, bytes):
|
||||
return
|
||||
validation: Final = await asyncio.to_thread(validate_upload, content=file_source, scanner=scanner)
|
||||
def _vector_store_upload_controls_apply(purpose: str, settings: RagIngestSettings) -> bool:
|
||||
return purpose in _VECTOR_STORE_UPLOAD_PURPOSES and settings.files_api_controls
|
||||
|
||||
|
||||
def _upload_read_limit_bytes(max_file_size_mb: int | None, controls_apply: bool) -> int | None:
|
||||
control_limit: Final = MAX_UPLOAD_SIZE_BYTES + 1 if controls_apply else None
|
||||
if max_file_size_mb is None or max_file_size_mb <= 0:
|
||||
return control_limit
|
||||
admin_limit: Final = max_file_size_mb * MB + 1
|
||||
return admin_limit if control_limit is None else min(admin_limit, control_limit)
|
||||
|
||||
|
||||
def _in_memory_upload(file_source: bytes | BinaryIO) -> bytes:
|
||||
if isinstance(file_source, bytes):
|
||||
return file_source
|
||||
raise ProxyException(
|
||||
message="Uploads with purpose assistants or user_data must be read into memory before the upload controls run.",
|
||||
type=ProxyErrorTypes.internal_server_error.value,
|
||||
param="file",
|
||||
code=500,
|
||||
)
|
||||
|
||||
|
||||
async def _reject_unsafe_vector_store_upload(content: bytes, scanner: MalwareScanner) -> None:
|
||||
validation: Final = await asyncio.to_thread(
|
||||
validate_upload, content=content, scanner=scanner, max_size_bytes=MAX_UPLOAD_SIZE_BYTES
|
||||
)
|
||||
if isinstance(validation, RejectedUpload):
|
||||
raise ProxyException(
|
||||
message=f"{validation.message} Rejection reason: {validation.reason.value}.",
|
||||
|
|
@ -598,6 +623,7 @@ async def create_file(
|
|||
llm_router,
|
||||
proxy_config,
|
||||
proxy_logging_obj,
|
||||
rag_ingest_settings,
|
||||
rag_upload_malware_scanner,
|
||||
version,
|
||||
)
|
||||
|
|
@ -613,18 +639,21 @@ async def create_file(
|
|||
raise_upload_validation_failure(unsafe_filename_failure)
|
||||
|
||||
max_file_size_mb: Final = coerce_optional_int_setting(general_settings.get("max_file_size_mb"))
|
||||
controls_apply: Final = _vector_store_upload_controls_apply(purpose, rag_ingest_settings)
|
||||
read_limit_bytes: Final = _upload_read_limit_bytes(max_file_size_mb, controls_apply)
|
||||
|
||||
# Batch uploads can be gigabytes. Starlette has already spooled the upload
|
||||
# to disk, so stream from that handle instead of reading it into memory.
|
||||
# Other uploads stay in-memory bytes, bounded to max_file_size_mb (plus one
|
||||
# byte, to still tell "exactly at the limit" from "over it") when it is set,
|
||||
# so an oversized upload cannot be read to completion before it is rejected.
|
||||
# Other uploads stay in-memory bytes, bounded to the smaller of max_file_size_mb
|
||||
# and the vector store cap (plus one byte, to still tell "exactly at the limit"
|
||||
# from "over it") when either applies, so an oversized upload cannot be read to
|
||||
# completion before it is rejected.
|
||||
file_source: bytes | BinaryIO
|
||||
if purpose == "batch":
|
||||
await file.seek(0)
|
||||
file_source = file.file
|
||||
elif max_file_size_mb is not None and max_file_size_mb > 0:
|
||||
file_source = await file.read(max_file_size_mb * MB + 1)
|
||||
elif read_limit_bytes is not None:
|
||||
file_source = await file.read(read_limit_bytes)
|
||||
else:
|
||||
file_source = await file.read()
|
||||
custom_llm_provider = (
|
||||
|
|
@ -677,7 +706,8 @@ async def create_file(
|
|||
if blocked_extension_failure is not None:
|
||||
raise_upload_validation_failure(blocked_extension_failure)
|
||||
|
||||
await _reject_unsafe_vector_store_upload(purpose, file_source, rag_upload_malware_scanner)
|
||||
if controls_apply:
|
||||
await _reject_unsafe_vector_store_upload(_in_memory_upload(file_source), rag_upload_malware_scanner)
|
||||
|
||||
if passthrough:
|
||||
_validate_passthrough_upload(
|
||||
|
|
|
|||
|
|
@ -745,7 +745,8 @@ from litellm.proxy.rag_endpoints.endpoints import router as rag_router
|
|||
from litellm.proxy.rag_endpoints.upload_security import (
|
||||
EicarTestMalwareScanner,
|
||||
MalwareScanner,
|
||||
MalwareScannerConfigError,
|
||||
RagIngestConfigError,
|
||||
parse_rag_ingest_settings,
|
||||
resolve_malware_scanner,
|
||||
)
|
||||
from litellm.proxy.rerank_endpoints.endpoints import router as rerank_router
|
||||
|
|
@ -867,6 +868,7 @@ from litellm.types.proxy.model_deprecation import (
|
|||
DEFAULT_DEPRECATION_WARN_DAYS,
|
||||
ModelDeprecationResponse,
|
||||
)
|
||||
from litellm.types.proxy.rag_ingest import RagIngestSettings
|
||||
from litellm.types.proxy.spend_capture_rate import SpendCaptureProvider, SpendCaptureRateCheckSettings
|
||||
from litellm.types.realtime import RealtimeQueryParams
|
||||
from litellm.types.router import (
|
||||
|
|
@ -1042,6 +1044,7 @@ def cleanup_router_config_variables():
|
|||
otel_logging, \
|
||||
user_custom_auth, \
|
||||
rag_upload_malware_scanner, \
|
||||
rag_ingest_settings, \
|
||||
user_custom_auth_path, \
|
||||
user_custom_key_generate, \
|
||||
user_custom_key_update, \
|
||||
|
|
@ -1061,6 +1064,7 @@ def cleanup_router_config_variables():
|
|||
otel_logging = None
|
||||
user_custom_auth = None
|
||||
rag_upload_malware_scanner = EicarTestMalwareScanner()
|
||||
rag_ingest_settings = RagIngestSettings()
|
||||
user_custom_auth_path = None
|
||||
user_custom_key_generate = None
|
||||
user_custom_key_update = None
|
||||
|
|
@ -2573,6 +2577,7 @@ native_background_mode: list[str] = [] # Models that should use native provider
|
|||
polling_cache_ttl: int = 3600 # Default 1 hour TTL for polling cache
|
||||
user_custom_auth = None
|
||||
rag_upload_malware_scanner: MalwareScanner = EicarTestMalwareScanner()
|
||||
rag_ingest_settings: RagIngestSettings = RagIngestSettings()
|
||||
user_custom_key_generate = None
|
||||
# Sentinel: prevents PKCE-no-Redis advisory from re-logging on config hot-reload.
|
||||
# Tests that need to reset it can patch 'litellm.proxy.proxy_server._pkce_no_redis_warning_emitted'.
|
||||
|
|
@ -5950,6 +5955,7 @@ class ProxyConfig:
|
|||
otel_logging, \
|
||||
user_custom_auth, \
|
||||
rag_upload_malware_scanner, \
|
||||
rag_ingest_settings, \
|
||||
user_custom_auth_path, \
|
||||
user_custom_key_generate, \
|
||||
user_custom_key_update, \
|
||||
|
|
@ -6366,13 +6372,17 @@ class ProxyConfig:
|
|||
|
||||
TypeAdapter(MCPAdvertisedVersions).validate_python(general_settings["mcp_advertised_versions"])
|
||||
|
||||
parsed_rag_ingest_settings: Final = parse_rag_ingest_settings(general_settings.get("rag_ingest"))
|
||||
if isinstance(parsed_rag_ingest_settings, RagIngestConfigError):
|
||||
raise ValueError(parsed_rag_ingest_settings.message)
|
||||
resolved_malware_scanner: Final = resolve_malware_scanner(
|
||||
general_settings.get("rag_ingest"),
|
||||
parsed_rag_ingest_settings,
|
||||
config_file_path=config_file_path,
|
||||
load_instance=get_instance_fn,
|
||||
)
|
||||
if isinstance(resolved_malware_scanner, MalwareScannerConfigError):
|
||||
if isinstance(resolved_malware_scanner, RagIngestConfigError):
|
||||
raise ValueError(resolved_malware_scanner.message)
|
||||
rag_ingest_settings = parsed_rag_ingest_settings
|
||||
rag_upload_malware_scanner = resolved_malware_scanner
|
||||
|
||||
if os.getenv("NUM_WORKERS", "1") != "1" and redis_usage_cache is None:
|
||||
|
|
|
|||
|
|
@ -123,39 +123,42 @@ class EicarTestMalwareScanner:
|
|||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MalwareScannerConfigError:
|
||||
class RagIngestConfigError:
|
||||
message: str
|
||||
|
||||
|
||||
InstanceLoader: TypeAlias = Callable[[str, str | None], object]
|
||||
|
||||
|
||||
def parse_rag_ingest_settings(rag_ingest: object) -> RagIngestSettings | RagIngestConfigError:
|
||||
if rag_ingest is None:
|
||||
return RagIngestSettings()
|
||||
try:
|
||||
return RagIngestSettings.model_validate(rag_ingest)
|
||||
except ValidationError as e:
|
||||
return RagIngestConfigError(f"general_settings.rag_ingest is invalid: {e}")
|
||||
|
||||
|
||||
def resolve_malware_scanner(
|
||||
rag_ingest: object,
|
||||
settings: RagIngestSettings,
|
||||
*,
|
||||
config_file_path: str | None,
|
||||
load_instance: InstanceLoader,
|
||||
) -> MalwareScanner | MalwareScannerConfigError:
|
||||
try:
|
||||
settings: Final = RagIngestSettings() if rag_ingest is None else RagIngestSettings.model_validate(rag_ingest)
|
||||
except ValidationError as e:
|
||||
return MalwareScannerConfigError(f"general_settings.rag_ingest is invalid: {e}")
|
||||
) -> MalwareScanner | RagIngestConfigError:
|
||||
if settings.malware_scanner is None:
|
||||
return EicarTestMalwareScanner()
|
||||
try:
|
||||
loaded: Final = load_instance(settings.malware_scanner, config_file_path)
|
||||
except Exception as e:
|
||||
return MalwareScannerConfigError(
|
||||
f"{MALWARE_SCANNER_OPTION}={settings.malware_scanner!r} could not be loaded: {e}"
|
||||
)
|
||||
return RagIngestConfigError(f"{MALWARE_SCANNER_OPTION}={settings.malware_scanner!r} could not be loaded: {e}")
|
||||
if isinstance(loaded, type):
|
||||
return MalwareScannerConfigError(
|
||||
return RagIngestConfigError(
|
||||
f"{MALWARE_SCANNER_OPTION}={settings.malware_scanner!r} names the class {loaded.__qualname__}; "
|
||||
"name an instance of it instead"
|
||||
)
|
||||
if isinstance(loaded, MalwareScanner) and callable(loaded.scan):
|
||||
return loaded
|
||||
return MalwareScannerConfigError(
|
||||
return RagIngestConfigError(
|
||||
f"{MALWARE_SCANNER_OPTION}={settings.malware_scanner!r} resolved to a {type(loaded).__qualname__}, "
|
||||
"which has no scan(content: bytes) -> ScanResult method"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ class RagIngestSettings(BaseModel):
|
|||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
malware_scanner: str | None = Field(
|
||||
None,
|
||||
default=None,
|
||||
description=(
|
||||
"The scanner every vector-store upload goes through, as <module>.<instance> where the module sits "
|
||||
"next to config.yaml or is importable, resolved the way custom_auth is. The instance must expose "
|
||||
|
|
@ -15,3 +15,12 @@ class RagIngestSettings(BaseModel):
|
|||
"the EICAR test scanner, which flags only the EICAR test file"
|
||||
),
|
||||
)
|
||||
files_api_controls: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Whether /v1/files uploads with purpose assistants or user_data run the upload controls (format, size "
|
||||
"and malware checks) before reaching the provider. false lets those uploads through unchecked, for a "
|
||||
"deployment that sends the provider a format the controls reject on that route, such as DOCX or "
|
||||
"images. /v1/rag/ingest runs the controls whatever this is set to"
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ from litellm.proxy.management_endpoints.internal_user_endpoints import ui_view_u
|
|||
from litellm.proxy.openai_files_endpoints.file_content_streaming_handler import (
|
||||
FileContentStreamingHandler,
|
||||
)
|
||||
from litellm.proxy.openai_files_endpoints.general_upload_validation import MB
|
||||
from litellm.proxy.proxy_server import app
|
||||
from litellm.proxy.rag_endpoints.upload_security import MAX_UPLOAD_SIZE_BYTES
|
||||
from litellm.types.llms.openai import (
|
||||
FileListPage,
|
||||
HttpxBinaryResponseContent,
|
||||
|
|
@ -6033,3 +6035,70 @@ def test_create_file_other_purposes_skip_the_vector_store_upload_controls(
|
|||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert captured["file"][0] == name
|
||||
|
||||
|
||||
def test_create_file_for_vector_store_purposes_lets_uploads_through_when_files_api_controls_is_off(
|
||||
monkeypatch, llm_router: Router
|
||||
):
|
||||
from litellm.proxy.rag_endpoints.upload_security import ScanResult, ScanVerdict
|
||||
from litellm.types.proxy.rag_ingest import RagIngestSettings
|
||||
|
||||
class _InfectedScanner:
|
||||
def scan(self, content: bytes) -> ScanResult:
|
||||
return ScanResult(verdict=ScanVerdict.INFECTED, signature="Custom.Sig")
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.rag_upload_malware_scanner", _InfectedScanner())
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.rag_ingest_settings", RagIngestSettings(files_api_controls=False))
|
||||
zip_bytes: Final = b"PK\x03\x04\x14\x00\x00\x00payload"
|
||||
resp, captured = _post_file_as_admin(
|
||||
monkeypatch, llm_router, purpose="assistants", name="deck.zip", content=zip_bytes, ctype="application/zip"
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert captured["file"][0] == "deck.zip"
|
||||
assert captured["file"][1] == zip_bytes
|
||||
|
||||
|
||||
def test_create_file_for_vector_store_purposes_reads_at_most_the_cap_plus_one_byte(monkeypatch, llm_router: Router):
|
||||
from litellm.proxy.openai_files_endpoints import files_endpoints as fe
|
||||
|
||||
monkeypatch.setattr(fe, "MAX_UPLOAD_SIZE_BYTES", 10)
|
||||
resp, captured = _post_file_as_admin(
|
||||
monkeypatch, llm_router, purpose="assistants", name="notes.txt", content=b"x" * 20, ctype="text/plain"
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
message = resp.json()["error"]["message"]
|
||||
assert "Uploaded file is 11 bytes" in message
|
||||
assert "Rejection reason: file_too_large." in message
|
||||
assert captured == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"max_file_size_mb, controls_apply, expected",
|
||||
[
|
||||
(None, True, MAX_UPLOAD_SIZE_BYTES + 1),
|
||||
(0, True, MAX_UPLOAD_SIZE_BYTES + 1),
|
||||
(1, True, MB + 1),
|
||||
(1024, True, MAX_UPLOAD_SIZE_BYTES + 1),
|
||||
(1, False, MB + 1),
|
||||
(None, False, None),
|
||||
],
|
||||
)
|
||||
def test_upload_read_limit_is_the_smaller_of_the_admin_limit_and_the_vector_store_cap(
|
||||
max_file_size_mb, controls_apply, expected
|
||||
):
|
||||
from litellm.proxy.openai_files_endpoints.files_endpoints import _upload_read_limit_bytes
|
||||
|
||||
assert _upload_read_limit_bytes(max_file_size_mb, controls_apply) == expected
|
||||
|
||||
|
||||
def test_vector_store_upload_controls_refuse_a_source_that_was_not_read_into_memory():
|
||||
from io import BytesIO
|
||||
|
||||
from litellm.proxy._types import ProxyException
|
||||
from litellm.proxy.openai_files_endpoints.files_endpoints import _in_memory_upload
|
||||
|
||||
assert _in_memory_upload(b"in memory") == b"in memory"
|
||||
with pytest.raises(ProxyException) as raised:
|
||||
_in_memory_upload(BytesIO(b"spooled to disk"))
|
||||
assert raised.value.code == "500"
|
||||
assert raised.value.param == "file"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from litellm.proxy.rag_endpoints.upload_security import (
|
|||
EICAR_TEST_SIGNATURE,
|
||||
DetectedFormat,
|
||||
EicarTestMalwareScanner,
|
||||
MalwareScannerConfigError,
|
||||
RagIngestConfigError,
|
||||
RejectedUpload,
|
||||
RejectionReason,
|
||||
ScanResult,
|
||||
|
|
@ -23,10 +23,12 @@ from litellm.proxy.rag_endpoints.upload_security import (
|
|||
SecuredUpload,
|
||||
generate_safe_filename,
|
||||
inspect_content,
|
||||
parse_rag_ingest_settings,
|
||||
resolve_malware_scanner,
|
||||
safe_download_headers,
|
||||
validate_upload,
|
||||
)
|
||||
from litellm.types.proxy.rag_ingest import RagIngestSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -196,20 +198,47 @@ def _loader_raising(error: Exception):
|
|||
return load_instance
|
||||
|
||||
|
||||
def test_parse_rag_ingest_settings_unset_block_runs_every_control_with_the_eicar_scanner():
|
||||
for rag_ingest in (None, {}, {"malware_scanner": None}):
|
||||
assert parse_rag_ingest_settings(rag_ingest) == RagIngestSettings(
|
||||
malware_scanner=None, files_api_controls=True
|
||||
), rag_ingest
|
||||
|
||||
|
||||
def test_parse_rag_ingest_settings_reads_the_files_api_opt_out():
|
||||
parsed = parse_rag_ingest_settings({"malware_scanner": "custom_scanner.scanner", "files_api_controls": False})
|
||||
assert parsed == RagIngestSettings(malware_scanner="custom_scanner.scanner", files_api_controls=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rag_ingest",
|
||||
[
|
||||
{"malware_scaner": "custom_scanner.scanner"},
|
||||
{"malware_scanner": 42},
|
||||
{"files_api_controls": "sometimes"},
|
||||
"custom_scanner.scanner",
|
||||
["x"],
|
||||
],
|
||||
)
|
||||
def test_parse_rag_ingest_settings_rejects_an_invalid_block(rag_ingest):
|
||||
parsed = parse_rag_ingest_settings(rag_ingest)
|
||||
assert isinstance(parsed, RagIngestConfigError)
|
||||
assert "general_settings.rag_ingest" in parsed.message
|
||||
|
||||
|
||||
def test_resolve_malware_scanner_unset_runs_the_eicar_test_scanner():
|
||||
load_instance, calls = _loader_returning(_CLEAN_SCANNER)
|
||||
for rag_ingest in (None, {}, {"malware_scanner": None}):
|
||||
resolved = resolve_malware_scanner(
|
||||
rag_ingest, config_file_path="/etc/litellm/config.yaml", load_instance=load_instance
|
||||
)
|
||||
assert isinstance(resolved, EicarTestMalwareScanner), rag_ingest
|
||||
resolved = resolve_malware_scanner(
|
||||
RagIngestSettings(), config_file_path="/etc/litellm/config.yaml", load_instance=load_instance
|
||||
)
|
||||
assert isinstance(resolved, EicarTestMalwareScanner)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_resolve_malware_scanner_loads_the_configured_instance_next_to_the_config():
|
||||
load_instance, calls = _loader_returning(_INFECTED_SCANNER)
|
||||
resolved = resolve_malware_scanner(
|
||||
{"malware_scanner": "custom_scanner.scanner"},
|
||||
RagIngestSettings(malware_scanner="custom_scanner.scanner"),
|
||||
config_file_path="/etc/litellm/config.yaml",
|
||||
load_instance=load_instance,
|
||||
)
|
||||
|
|
@ -230,11 +259,11 @@ def test_resolve_malware_scanner_loads_the_configured_instance_next_to_the_confi
|
|||
def test_resolve_malware_scanner_rejects_an_object_that_is_not_a_scanner(loaded, expected_fragment):
|
||||
load_instance, _calls = _loader_returning(loaded)
|
||||
resolved = resolve_malware_scanner(
|
||||
{"malware_scanner": "custom_scanner.scanner"},
|
||||
RagIngestSettings(malware_scanner="custom_scanner.scanner"),
|
||||
config_file_path=None,
|
||||
load_instance=load_instance,
|
||||
)
|
||||
assert isinstance(resolved, MalwareScannerConfigError)
|
||||
assert isinstance(resolved, RagIngestConfigError)
|
||||
assert "general_settings.rag_ingest.malware_scanner" in resolved.message
|
||||
assert "custom_scanner.scanner" in resolved.message
|
||||
assert expected_fragment in resolved.message
|
||||
|
|
@ -251,22 +280,11 @@ def test_resolve_malware_scanner_rejects_an_object_that_is_not_a_scanner(loaded,
|
|||
)
|
||||
def test_resolve_malware_scanner_reports_a_failed_load_with_the_option_name(error):
|
||||
resolved = resolve_malware_scanner(
|
||||
{"malware_scanner": "custom_scanner.scanner"},
|
||||
RagIngestSettings(malware_scanner="custom_scanner.scanner"),
|
||||
config_file_path=None,
|
||||
load_instance=_loader_raising(error),
|
||||
)
|
||||
assert isinstance(resolved, MalwareScannerConfigError)
|
||||
assert isinstance(resolved, RagIngestConfigError)
|
||||
assert "general_settings.rag_ingest.malware_scanner" in resolved.message
|
||||
assert str(error) in resolved.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rag_ingest",
|
||||
[{"malware_scaner": "custom_scanner.scanner"}, {"malware_scanner": 42}, "custom_scanner.scanner", ["x"]],
|
||||
)
|
||||
def test_resolve_malware_scanner_rejects_an_invalid_rag_ingest_block(rag_ingest):
|
||||
load_instance, calls = _loader_returning(_CLEAN_SCANNER)
|
||||
resolved = resolve_malware_scanner(rag_ingest, config_file_path=None, load_instance=load_instance)
|
||||
assert isinstance(resolved, MalwareScannerConfigError)
|
||||
assert "general_settings.rag_ingest" in resolved.message
|
||||
assert calls == []
|
||||
|
|
|
|||
|
|
@ -3522,8 +3522,10 @@ async def test_load_config_role_permissions_usable_by_jwt_auth(tmp_path):
|
|||
async def test_load_config_wires_the_configured_malware_scanner_into_uploads(tmp_path, monkeypatch):
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
from litellm.proxy.rag_endpoints.upload_security import EicarTestMalwareScanner, ScanVerdict
|
||||
from litellm.types.proxy.rag_ingest import RagIngestSettings
|
||||
|
||||
monkeypatch.setattr(proxy_server_module, "rag_upload_malware_scanner", EicarTestMalwareScanner())
|
||||
monkeypatch.setattr(proxy_server_module, "rag_ingest_settings", RagIngestSettings(files_api_controls=False))
|
||||
(tmp_path / "boot_scanner.py").write_text(
|
||||
"from litellm.proxy.rag_endpoints.upload_security import ScanResult, ScanVerdict\n"
|
||||
"class FlagEverything:\n"
|
||||
|
|
@ -3539,10 +3541,26 @@ async def test_load_config_wires_the_configured_malware_scanner_into_uploads(tmp
|
|||
)
|
||||
await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file))
|
||||
assert proxy_server_module.rag_upload_malware_scanner.scan(b"plain text").verdict is ScanVerdict.INFECTED
|
||||
assert proxy_server_module.rag_ingest_settings.files_api_controls is True
|
||||
|
||||
config_file.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"model_list": [],
|
||||
"general_settings": {
|
||||
"rag_ingest": {"malware_scanner": "boot_scanner.scanner", "files_api_controls": False}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file))
|
||||
assert proxy_server_module.rag_ingest_settings.files_api_controls is False
|
||||
assert proxy_server_module.rag_upload_malware_scanner.scan(b"plain text").verdict is ScanVerdict.INFECTED
|
||||
|
||||
config_file.write_text(yaml.dump({"model_list": []}))
|
||||
await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file))
|
||||
assert isinstance(proxy_server_module.rag_upload_malware_scanner, EicarTestMalwareScanner)
|
||||
assert proxy_server_module.rag_ingest_settings.files_api_controls is True
|
||||
|
||||
config_file.write_text(
|
||||
yaml.dump(
|
||||
|
|
|
|||
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -28451,7 +28451,7 @@ export interface components {
|
|||
* @default 30
|
||||
*/
|
||||
proxy_config_reload_interval_seconds: number;
|
||||
/** @description controls run on every upload that lands in a vector store (/v1/rag/ingest and /v1/files with purpose assistants or user_data); rag_ingest.malware_scanner names the scanner instance as <module>.<instance> the way custom_auth does, and unset runs the EICAR test scanner - https://docs.litellm.ai/docs/rag_ingest */
|
||||
/** @description controls run on every upload that lands in a vector store (/v1/rag/ingest and /v1/files with purpose assistants or user_data); rag_ingest.malware_scanner names the scanner instance as <module>.<instance> the way custom_auth does, and unset runs the EICAR test scanner; rag_ingest.files_api_controls: false lets the /v1/files uploads through unchecked - https://docs.litellm.ai/docs/rag_ingest */
|
||||
rag_ingest?: components["schemas"]["RagIngestSettings"] | null;
|
||||
/**
|
||||
* Reject Clientside Metadata Tags
|
||||
|
|
@ -39055,6 +39055,12 @@ export interface components {
|
|||
};
|
||||
/** RagIngestSettings */
|
||||
RagIngestSettings: {
|
||||
/**
|
||||
* Files Api Controls
|
||||
* @description Whether /v1/files uploads with purpose assistants or user_data run the upload controls (format, size and malware checks) before reaching the provider. false lets those uploads through unchecked, for a deployment that sends the provider a format the controls reject on that route, such as DOCX or images. /v1/rag/ingest runs the controls whatever this is set to
|
||||
* @default true
|
||||
*/
|
||||
files_api_controls: boolean;
|
||||
/**
|
||||
* Malware Scanner
|
||||
* @description The scanner every vector-store upload goes through, as <module>.<instance> where the module sits next to config.yaml or is importable, resolved the way custom_auth is. The instance must expose scan(content: bytes) -> ScanResult and be safe to call from several threads at once. Unset runs the EICAR test scanner, which flags only the EICAR test file
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue