mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
feat(proxy): fast-fail validation for batch input files at /v1/files
This commit is contained in:
parent
a613773fca
commit
2a4598219d
7 changed files with 567 additions and 11 deletions
|
|
@ -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",
|
||||
|
|
|
|||
182
litellm/proxy/openai_files_endpoints/batch_file_validation.py
Normal file
182
litellm/proxy/openai_files_endpoints/batch_file_validation.py
Normal 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)
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue