diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 8e57327b31b..6f352b73290 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2439,6 +2439,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): None, description="max request size in MB, if a request is larger than this size it will be rejected", ) + max_batch_file_size_mb: int | None = Field( + None, + description="max batch input file size in MB for /v1/files uploads with purpose=batch, if a file is larger than this size it will be rejected before being forwarded to the provider", + ) max_response_size_mb: int | None = Field( None, description="max response size in MB, if a response is larger than this size it will be rejected", diff --git a/litellm/proxy/openai_files_endpoints/batch_file_validation.py b/litellm/proxy/openai_files_endpoints/batch_file_validation.py new file mode 100644 index 00000000000..bf6f6e2f829 --- /dev/null +++ b/litellm/proxy/openai_files_endpoints/batch_file_validation.py @@ -0,0 +1,182 @@ +import json +from collections.abc import Iterator +from dataclasses import dataclass +from itertools import chain +from typing import BinaryIO, Final, NoReturn, assert_never + +from litellm.proxy._types import ProxyException + +BATCH_LINE_REQUIRED_KEYS: Final = ("custom_id", "method", "url", "body") +_MB: Final = 1024 * 1024 + + +@dataclass(frozen=True, slots=True) +class BatchFileTooLarge: + size_bytes: int + limit_mb: int + + +@dataclass(frozen=True, slots=True) +class BatchFileWrongExtension: + filename: str + + +@dataclass(frozen=True, slots=True) +class BatchFileEmpty: + pass + + +@dataclass(frozen=True, slots=True) +class BatchFileInvalidJsonLine: + line_number: int + + +@dataclass(frozen=True, slots=True) +class BatchFileLineNotObject: + line_number: int + + +@dataclass(frozen=True, slots=True) +class BatchFileMissingLineKey: + line_number: int + key: str + + +BatchFileValidationFailure = ( + BatchFileTooLarge + | BatchFileWrongExtension + | BatchFileEmpty + | BatchFileInvalidJsonLine + | BatchFileLineNotObject + | BatchFileMissingLineKey +) + + +def _file_size_bytes(file_source: bytes | BinaryIO) -> int: + if isinstance(file_source, bytes): + return len(file_source) + file_source.seek(0, 2) + size: Final = file_source.tell() + file_source.seek(0) + return size + + +def _iter_lines(file_source: bytes | BinaryIO) -> Iterator[bytes]: + if isinstance(file_source, bytes): + return iter(file_source.splitlines()) + file_source.seek(0) + return iter(file_source) + + +def _check_line(line_number: int, raw_line: bytes) -> BatchFileValidationFailure | None: + try: + parsed: Final = json.loads(raw_line) + except (json.JSONDecodeError, UnicodeDecodeError): + return BatchFileInvalidJsonLine(line_number=line_number) + if not isinstance(parsed, dict): + return BatchFileLineNotObject(line_number=line_number) + missing: Final = next((key for key in BATCH_LINE_REQUIRED_KEYS if key not in parsed), None) + if missing is None: + return None + return BatchFileMissingLineKey(line_number=line_number, key=missing) + + +def _scan_lines(file_source: bytes | BinaryIO) -> BatchFileValidationFailure | None: + content_lines: Final = ( + (line_number, raw_line) + for line_number, raw_line in enumerate(_iter_lines(file_source), start=1) + if raw_line.strip() + ) + first_line: Final = next(content_lines, None) + if first_line is None: + return BatchFileEmpty() + return next( + ( + failure + for line_number, raw_line in chain((first_line,), content_lines) + for failure in (_check_line(line_number, raw_line),) + if failure is not None + ), + None, + ) + + +def check_batch_file_upload( + filename: str | None, + file_source: bytes | BinaryIO, + max_batch_file_size_mb: int | None, +) -> BatchFileValidationFailure | None: + if filename is None or not filename.lower().endswith(".jsonl"): + return BatchFileWrongExtension(filename=filename or "") + if max_batch_file_size_mb is not None: + size_bytes: Final = _file_size_bytes(file_source) + if size_bytes > max_batch_file_size_mb * _MB: + return BatchFileTooLarge(size_bytes=size_bytes, limit_mb=max_batch_file_size_mb) + scan_failure: Final = _scan_lines(file_source) + if not isinstance(file_source, bytes): + file_source.seek(0) + return scan_failure + + +def raise_batch_file_validation_failure(failure: BatchFileValidationFailure) -> NoReturn: + match failure: + case BatchFileTooLarge(size_bytes=size_bytes, limit_mb=limit_mb): + raise ProxyException( + message=( + f"Batch input file is {size_bytes / _MB:.1f} MB, which exceeds the configured " + f"max_batch_file_size_mb of {limit_mb} MB. The file was not forwarded to the provider." + ), + type="invalid_request_error", + param="file", + code=413, + ) + case BatchFileWrongExtension(filename=filename): + raise ProxyException( + message=( + f"Invalid file format for Batch API: '{filename}'. " + "Batch input files must be .jsonl files. The file was not forwarded to the provider." + ), + type="invalid_request_error", + param="file", + code=400, + ) + case BatchFileEmpty(): + raise ProxyException( + message="Batch input file has no request lines. The file was not forwarded to the provider.", + type="invalid_request_error", + param="file", + code=400, + ) + case BatchFileInvalidJsonLine(line_number=line_number): + raise ProxyException( + message=( + f"Batch input file line {line_number} is not valid JSON. " + "The file was not forwarded to the provider." + ), + type="invalid_request_error", + param="file", + code=400, + ) + case BatchFileLineNotObject(line_number=line_number): + raise ProxyException( + message=( + f"Batch input file line {line_number} must be a JSON object. " + "The file was not forwarded to the provider." + ), + type="invalid_request_error", + param="file", + code=400, + ) + case BatchFileMissingLineKey(line_number=line_number, key=key): + raise ProxyException( + message=( + f"Missing required parameter: '{key}' (batch input file line {line_number}). " + f"Each line must be a JSON object with keys {', '.join(BATCH_LINE_REQUIRED_KEYS)}. " + "The file was not forwarded to the provider." + ), + type="invalid_request_error", + param=key, + code=400, + ) + case _: + assert_never(failure) diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 361b5b920e2..b7200de8fb6 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -21,6 +21,7 @@ from fastapi import ( UploadFile, status, ) +from pydantic import TypeAdapter import litellm from litellm import CreateFileRequest, get_secret_str @@ -41,6 +42,10 @@ from litellm.proxy.common_utils.openai_endpoint_utils import ( get_custom_llm_provider_from_request_headers, get_custom_llm_provider_from_request_query, ) +from litellm.proxy.openai_files_endpoints.batch_file_validation import ( + check_batch_file_upload, + raise_batch_file_validation_failure, +) from litellm.proxy.openai_files_endpoints.common_utils import ( _is_base64_encoded_unified_file_id, add_internal_model_credentials, @@ -65,6 +70,8 @@ from litellm.types.llms.openai import ( router: Final = APIRouter() +_MAX_BATCH_FILE_SIZE_MB_ADAPTER: Final = TypeAdapter(int | None) + files_config = None @@ -361,18 +368,27 @@ async def create_file( # Prepare the data for forwarding - # Replace with: valid_purposes: Final = get_args(OpenAIFilesPurpose) if purpose not in valid_purposes: - raise HTTPException( - status_code=400, - detail={ - "error": f"Invalid purpose: {purpose}. Must be one of: {valid_purposes}", - }, + raise ProxyException( + message=f"Invalid purpose: {purpose}. Must be one of: {valid_purposes}", + type="invalid_request_error", + param="purpose", + code=400, ) # Cast purpose to OpenAIFilesPurpose type purpose = cast(OpenAIFilesPurpose, purpose) + if purpose == "batch": + batch_file_failure: Final = await asyncio.to_thread( + check_batch_file_upload, + file.filename, + file_source, + _MAX_BATCH_FILE_SIZE_MB_ADAPTER.validate_python(general_settings.get("max_batch_file_size_mb")), + ) + if batch_file_failure is not None: + raise_batch_file_validation_failure(batch_file_failure) + data = {} # Parse expires_after if provided @@ -552,6 +568,8 @@ async def create_file( user_api_key_dict=user_api_key_dict, original_exception=e, request_data=data ) verbose_proxy_logger.exception("litellm.proxy.proxy_server.create_file(): Exception occured - %s", e) + if isinstance(e, ProxyException): + raise e if isinstance(e, HTTPException): raise ProxyException( message=getattr(e, "message", str(e.detail)), diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index cae735be988..f08c9887a92 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -15689,6 +15689,7 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro "max_parallel_requests": "Integer", "global_max_parallel_requests": "Integer", "max_request_size_mb": "Integer", + "max_batch_file_size_mb": "Integer", "max_response_size_mb": "Integer", "proxy_config_reload_interval_seconds": "Integer", "pass_through_endpoints": "PydanticModel", diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_batch_file_validation.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_batch_file_validation.py new file mode 100644 index 00000000000..b4b0c5eb492 --- /dev/null +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_batch_file_validation.py @@ -0,0 +1,170 @@ +import io + +import pytest + +from litellm.proxy._types import ProxyException +from litellm.proxy.openai_files_endpoints.batch_file_validation import ( + BATCH_LINE_REQUIRED_KEYS, + BatchFileEmpty, + BatchFileInvalidJsonLine, + BatchFileLineNotObject, + BatchFileMissingLineKey, + BatchFileTooLarge, + BatchFileWrongExtension, + check_batch_file_upload, + raise_batch_file_validation_failure, +) + +VALID_LINE = ( + b'{"custom_id": "req-1", "method": "POST", "url": "/v1/chat/completions",' + b' "body": {"model": "gpt-4.1-nano", "messages": [{"role": "user", "content": "hi"}]}}' +) + + +def test_valid_bytes_pass(): + assert check_batch_file_upload("batch.jsonl", VALID_LINE + b"\n" + VALID_LINE + b"\n", 10) is None + + +def test_valid_binaryio_passes_and_resets_position(): + handle = io.BytesIO(VALID_LINE + b"\n" + VALID_LINE + b"\n") + handle.seek(17) + assert check_batch_file_upload("batch.jsonl", handle, 10) is None + assert handle.tell() == 0 + + +def test_uppercase_extension_accepted(): + assert check_batch_file_upload("BATCH.JSONL", VALID_LINE, None) is None + + +@pytest.mark.parametrize("filename", ["batch.csv", "batch.json", "batch", None]) +def test_wrong_extension_rejected(filename): + assert check_batch_file_upload(filename, VALID_LINE, None) == BatchFileWrongExtension(filename=filename or "") + + +def test_size_over_cap_rejected_for_bytes(): + content = b"x" * (2 * 1024 * 1024) + assert check_batch_file_upload("batch.jsonl", content, 1) == BatchFileTooLarge( + size_bytes=len(content), limit_mb=1 + ) + + +def test_size_over_cap_rejected_for_binaryio(): + content = b"x" * (2 * 1024 * 1024) + assert check_batch_file_upload("batch.jsonl", io.BytesIO(content), 1) == BatchFileTooLarge( + size_bytes=len(content), limit_mb=1 + ) + + +def test_size_exactly_at_cap_allowed(): + line = VALID_LINE + b"\n" + padding_key = b'{"custom_id": "pad", "method": "POST", "url": "/v1/chat/completions", "body": {"note": "' + pad_line = padding_key + b"a" * (1024 * 1024 - len(line) - len(padding_key) - len(b'"}}\n')) + b'"}}\n' + content = line + pad_line + assert len(content) == 1024 * 1024 + assert check_batch_file_upload("batch.jsonl", content, 1) is None + + +def test_no_cap_skips_size_check(): + content = (VALID_LINE + b"\n") * 5000 + assert check_batch_file_upload("batch.jsonl", content, None) is None + + +@pytest.mark.parametrize("content", [b"", b"\n\n", b" \n\t\n"]) +def test_empty_file_rejected(content): + assert check_batch_file_upload("batch.jsonl", content, None) == BatchFileEmpty() + + +def test_invalid_json_line_rejected_with_line_number(): + content = VALID_LINE + b"\n" + b"not json at all\n" + VALID_LINE + b"\n" + assert check_batch_file_upload("batch.jsonl", content, None) == BatchFileInvalidJsonLine(line_number=2) + + +def test_non_utf8_line_rejected_as_invalid_json(): + assert check_batch_file_upload("batch.jsonl", b"\xff\xfe\x00\x01\n", None) == BatchFileInvalidJsonLine( + line_number=1 + ) + + +def test_non_object_line_rejected(): + content = VALID_LINE + b"\n" + b'["custom_id", "method"]\n' + assert check_batch_file_upload("batch.jsonl", content, None) == BatchFileLineNotObject(line_number=2) + + +@pytest.mark.parametrize("missing_key", BATCH_LINE_REQUIRED_KEYS) +def test_missing_required_key_rejected(missing_key): + import json + + line_dict = { + "custom_id": "req-1", + "method": "POST", + "url": "/v1/chat/completions", + "body": {"model": "gpt-4.1-nano"}, + } + del line_dict[missing_key] + content = VALID_LINE + b"\n" + json.dumps(line_dict).encode() + b"\n" + assert check_batch_file_upload("batch.jsonl", content, None) == BatchFileMissingLineKey( + line_number=2, key=missing_key + ) + + +def test_blank_lines_do_not_shift_line_numbers(): + content = b"\n" + VALID_LINE + b"\n\n" + b"broken\n" + assert check_batch_file_upload("batch.jsonl", content, None) == BatchFileInvalidJsonLine(line_number=4) + + +def test_failed_scan_leaves_handle_open_and_reset(): + handle = io.BytesIO(b"not json\n" + VALID_LINE + b"\n") + assert check_batch_file_upload("batch.jsonl", handle, None) == BatchFileInvalidJsonLine(line_number=1) + assert not handle.closed + assert handle.tell() == 0 + + +def test_scan_stops_at_first_failure(): + class ExplodingLines(io.BytesIO): + def __init__(self): + super().__init__(b"not json\n" + VALID_LINE + b"\n") + self.lines_read = 0 + + def __next__(self): + self.lines_read += 1 + return super().__next__() + + handle = ExplodingLines() + assert check_batch_file_upload("batch.jsonl", handle, None) == BatchFileInvalidJsonLine(line_number=1) + assert handle.lines_read == 1 + + +@pytest.mark.parametrize( + "failure, expected_code, expected_param, expected_fragments", + [ + ( + BatchFileTooLarge(size_bytes=220200960, limit_mb=10), + "413", + "file", + ("210.0 MB", "max_batch_file_size_mb", "10 MB", "not forwarded"), + ), + ( + BatchFileWrongExtension(filename="batch.csv"), + "400", + "file", + ("batch.csv", ".jsonl", "not forwarded"), + ), + (BatchFileEmpty(), "400", "file", ("no request lines", "not forwarded")), + (BatchFileInvalidJsonLine(line_number=3), "400", "file", ("line 3", "not valid JSON")), + (BatchFileLineNotObject(line_number=2), "400", "file", ("line 2", "JSON object")), + ( + BatchFileMissingLineKey(line_number=5, key="method"), + "400", + "method", + ("'method'", "line 5", "custom_id, method, url, body"), + ), + ], +) +def test_failures_map_to_openai_shaped_proxy_exceptions(failure, expected_code, expected_param, expected_fragments): + with pytest.raises(ProxyException) as exc_info: + raise_batch_file_validation_failure(failure) + assert exc_info.value.code == expected_code + assert exc_info.value.type == "invalid_request_error" + assert exc_info.value.param == expected_param + for fragment in expected_fragments: + assert fragment in exc_info.value.message diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py index e363a266688..b02045a3713 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py @@ -225,7 +225,10 @@ def test_invalid_purpose(mocker: MockerFixture, monkeypatch, llm_router: Router) assert response.status_code == 400 print(f"response: {response.json()}") - assert "Invalid purpose: my-bad-purpose" in response.json()["error"]["message"] + error = response.json()["error"] + assert "Invalid purpose: my-bad-purpose" in error["message"] + assert error["type"] == "invalid_request_error" + assert error["param"] == "purpose" def test_get_file_content_rejects_raw_cloud_storage_uri(llm_router: Router): @@ -1599,7 +1602,7 @@ def _post_file_with_team_metadata( user_key = UserAPIKeyAuth(api_key="test-key", team_metadata=team_metadata) app.dependency_overrides[user_api_key_auth] = lambda: user_key - test_file = ("mydata.jsonl", b'{"prompt": "Hello"}', "application/json") + test_file = ("mydata.jsonl", b'{"custom_id": "req-1", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "hi"}]}}', "application/jsonl") try: response = client.post( "/v1/files", @@ -1703,7 +1706,7 @@ def _post_file_raw( user_key = UserAPIKeyAuth(api_key="test-key", team_metadata=team_metadata) app.dependency_overrides[user_api_key_auth] = lambda: user_key - test_file = ("mydata.jsonl", b'{"prompt": "Hello"}', "application/json") + test_file = ("mydata.jsonl", b'{"custom_id": "req-1", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "hi"}]}}', "application/jsonl") try: response = client.post( "/v1/files", @@ -2749,7 +2752,7 @@ def test_create_file_provider_only_resolves_named_vertex_credentials( try: response = client.post( "/v1/files", - files={"file": ("batch.jsonl", b"{}", "application/jsonl")}, + files={"file": ("batch.jsonl", b'{"custom_id": "req-1", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "hi"}]}}', "application/jsonl")}, data={"purpose": "batch"}, headers={ "Authorization": "Bearer test-key", @@ -2991,7 +2994,7 @@ def test_create_file_provider_only_skips_other_team_vertex_deployment( try: response = client.post( "/v1/files", - files={"file": ("batch.jsonl", b"{}", "application/jsonl")}, + files={"file": ("batch.jsonl", b'{"custom_id": "req-1", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "hi"}]}}', "application/jsonl")}, data={"purpose": "batch"}, headers={ "Authorization": "Bearer test-key", @@ -3341,3 +3344,176 @@ def test_raw_provider_file_id_retrieve_allowed_when_managed_files_not_required( assert response.status_code == 200, response.text mock_retrieve.assert_called_once() + + +VALID_BATCH_LINE = ( + b'{"custom_id": "req-1", "method": "POST", "url": "/v1/chat/completions",' + b' "body": {"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "hi"}]}}\n' +) + + +def _setup_batch_upload_endpoint(monkeypatch, llm_router: Router) -> list: + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + from litellm.proxy.openai_files_endpoints import files_endpoints as fe + + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + setup_proxy_logging_object(monkeypatch, llm_router) + + forwarded_calls: list = [] + + async def fake_route_create_file(**kwargs): + forwarded_calls.append(kwargs) + return OpenAIFileObject( + id="dummy-id", + object="file", + bytes=0, + created_at=1234567890, + filename="batch.jsonl", + purpose="batch", + status="uploaded", + ) + + monkeypatch.setattr(fe, "route_create_file", fake_route_create_file) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user" + ) + return forwarded_calls + + +def _teardown_batch_upload_endpoint(): + import litellm.proxy.proxy_server as ps + + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +def test_create_file_batch_over_max_batch_file_size_mb_rejected_before_forwarding( + monkeypatch, llm_router: Router +): + import litellm.proxy.proxy_server as ps + + forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router) + monkeypatch.setitem(ps.general_settings, "max_batch_file_size_mb", 1) + + oversized = VALID_BATCH_LINE * (2 * 1024 * 1024 // len(VALID_BATCH_LINE) + 1) + try: + response = client.post( + "/v1/files", + files={"file": ("batch.jsonl", oversized, "application/jsonl")}, + data={"purpose": "batch"}, + headers={"Authorization": "Bearer test-key"}, + ) + finally: + _teardown_batch_upload_endpoint() + + assert response.status_code == 413, response.text + error = response.json()["error"] + assert error["type"] == "invalid_request_error" + assert error["param"] == "file" + assert "max_batch_file_size_mb" in error["message"] + assert "1 MB" in error["message"] + assert forwarded_calls == [] + + +def test_create_file_batch_under_max_batch_file_size_mb_forwards(monkeypatch, llm_router: Router): + import litellm.proxy.proxy_server as ps + + forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router) + monkeypatch.setitem(ps.general_settings, "max_batch_file_size_mb", 1) + + try: + response = client.post( + "/v1/files", + files={"file": ("batch.jsonl", VALID_BATCH_LINE, "application/jsonl")}, + data={"purpose": "batch"}, + headers={"Authorization": "Bearer test-key"}, + ) + finally: + _teardown_batch_upload_endpoint() + + assert response.status_code == 200, response.text + assert len(forwarded_calls) == 1 + + +def test_create_file_batch_wrong_extension_rejected_before_forwarding(monkeypatch, llm_router: Router): + forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router) + + try: + response = client.post( + "/v1/files", + files={"file": ("batch.csv", VALID_BATCH_LINE, "text/csv")}, + data={"purpose": "batch"}, + headers={"Authorization": "Bearer test-key"}, + ) + finally: + _teardown_batch_upload_endpoint() + + assert response.status_code == 400, response.text + error = response.json()["error"] + assert error["type"] == "invalid_request_error" + assert error["param"] == "file" + assert "batch.csv" in error["message"] + assert ".jsonl" in error["message"] + assert forwarded_calls == [] + + +def test_create_file_batch_missing_line_key_rejected_before_forwarding(monkeypatch, llm_router: Router): + forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router) + + bad_line = b'{"custom_id": "req-1", "url": "/v1/chat/completions", "body": {"model": "gpt-3.5-turbo"}}\n' + try: + response = client.post( + "/v1/files", + files={"file": ("batch.jsonl", VALID_BATCH_LINE + bad_line, "application/jsonl")}, + data={"purpose": "batch"}, + headers={"Authorization": "Bearer test-key"}, + ) + finally: + _teardown_batch_upload_endpoint() + + assert response.status_code == 400, response.text + error = response.json()["error"] + assert error["type"] == "invalid_request_error" + assert error["param"] == "method" + assert "line 2" in error["message"] + assert forwarded_calls == [] + + +def test_create_file_batch_invalid_json_line_rejected_before_forwarding(monkeypatch, llm_router: Router): + forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router) + + try: + response = client.post( + "/v1/files", + files={"file": ("batch.jsonl", b"this is not jsonl\n", "application/jsonl")}, + data={"purpose": "batch"}, + headers={"Authorization": "Bearer test-key"}, + ) + finally: + _teardown_batch_upload_endpoint() + + assert response.status_code == 400, response.text + error = response.json()["error"] + assert error["param"] == "file" + assert "line 1" in error["message"] + assert "not valid JSON" in error["message"] + assert forwarded_calls == [] + + +def test_create_file_non_batch_purpose_skips_batch_validation(monkeypatch, llm_router: Router): + forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router) + + try: + response = client.post( + "/v1/files", + files={"file": ("notes.txt", b"plain text, not jsonl", "text/plain")}, + data={"purpose": "user_data"}, + headers={"Authorization": "Bearer test-key"}, + ) + finally: + _teardown_batch_upload_endpoint() + + assert response.status_code == 200, response.text + assert len(forwarded_calls) == 1 diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 4c63586d4d6..d6569457cec 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -24052,6 +24052,11 @@ export interface components { * @description require a key for all calls to proxy */ master_key?: string | null; + /** + * Max Batch File Size Mb + * @description max batch input file size in MB for /v1/files uploads with purpose=batch, if a file is larger than this size it will be rejected before being forwarded to the provider + */ + max_batch_file_size_mb?: number | null; /** * Max Parallel Requests * @description maximum parallel requests for each api key