feat(proxy): fast-fail validation for batch input files at /v1/files

This commit is contained in:
mateo-berri 2026-08-19 14:43:22 -07:00
parent a613773fca
commit 2a4598219d
7 changed files with 567 additions and 11 deletions

View file

@ -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",

View file

@ -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)

View file

@ -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)),

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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