Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/openai_files_endpoints/batch_file_validation.py: 34%
92 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1import json
2from collections.abc import Iterator
3from dataclasses import dataclass
4from itertools import chain
5from typing import BinaryIO, Final, NoReturn
7from typing_extensions import assert_never
9from litellm.proxy._types import ProxyException
11_MB: Final = 1024 * 1024
14@dataclass(frozen=True, slots=True)
15class BatchLineShape:
16 required_keys: tuple[str, ...]
17 hint: str
20BATCH_LINE_SHAPE: Final = BatchLineShape(
21 required_keys=("custom_id", "method", "url", "body"),
22 hint="Each line must be a JSON object with keys custom_id, method, url, body",
23)
24PASSTHROUGH_BATCH_LINE_SHAPE: Final = BatchLineShape(
25 required_keys=("request",),
26 hint="A passthrough upload takes native Vertex batch rows, so each line must be a JSON object with a request key",
27)
30@dataclass(frozen=True, slots=True)
31class BatchFileTooLarge:
32 size_bytes: int
33 limit_mb: int
36@dataclass(frozen=True, slots=True)
37class BatchFileWrongExtension:
38 filename: str
41@dataclass(frozen=True, slots=True)
42class BatchFileEmpty:
43 pass
46@dataclass(frozen=True, slots=True)
47class BatchFileInvalidJsonLine:
48 line_number: int
51@dataclass(frozen=True, slots=True)
52class BatchFileLineNotObject:
53 line_number: int
56@dataclass(frozen=True, slots=True)
57class BatchFileMissingLineKey:
58 line_number: int
59 key: str
60 line_shape: BatchLineShape = BATCH_LINE_SHAPE
63BatchFileValidationFailure = (
64 BatchFileTooLarge
65 | BatchFileWrongExtension
66 | BatchFileEmpty
67 | BatchFileInvalidJsonLine
68 | BatchFileLineNotObject
69 | BatchFileMissingLineKey
70)
73def _file_size_bytes(file_source: bytes | BinaryIO) -> int:
74 if isinstance(file_source, bytes):
75 return len(file_source)
76 file_source.seek(0, 2)
77 size: Final = file_source.tell()
78 file_source.seek(0)
79 return size
82def _iter_lines(file_source: bytes | BinaryIO) -> Iterator[bytes]:
83 if isinstance(file_source, bytes):
84 return iter(file_source.splitlines())
85 file_source.seek(0)
86 return iter(file_source)
89def _check_line(line_number: int, raw_line: bytes, line_shape: BatchLineShape) -> BatchFileValidationFailure | None:
90 try:
91 parsed: Final = json.loads(raw_line)
92 except (json.JSONDecodeError, UnicodeDecodeError):
93 return BatchFileInvalidJsonLine(line_number=line_number)
94 if not isinstance(parsed, dict):
95 return BatchFileLineNotObject(line_number=line_number)
96 missing: Final = next((key for key in line_shape.required_keys if key not in parsed), None)
97 if missing is None:
98 return None
99 return BatchFileMissingLineKey(line_number=line_number, key=missing, line_shape=line_shape)
102def _scan_lines(file_source: bytes | BinaryIO, line_shape: BatchLineShape) -> BatchFileValidationFailure | None:
103 content_lines: Final = (
104 (line_number, raw_line)
105 for line_number, raw_line in enumerate(_iter_lines(file_source), start=1)
106 if raw_line.strip()
107 )
108 first_line: Final = next(content_lines, None)
109 if first_line is None:
110 return BatchFileEmpty()
111 return next(
112 (
113 failure
114 for line_number, raw_line in chain((first_line,), content_lines)
115 for failure in (_check_line(line_number, raw_line, line_shape),)
116 if failure is not None
117 ),
118 None,
119 )
122def check_batch_file_upload(
123 filename: str | None,
124 file_source: bytes | BinaryIO,
125 max_batch_file_size_mb: int | None,
126 line_shape: BatchLineShape = BATCH_LINE_SHAPE,
127) -> BatchFileValidationFailure | None:
128 if filename is None or not filename.lower().endswith(".jsonl"):
129 return BatchFileWrongExtension(filename=filename or "")
130 if max_batch_file_size_mb is not None and max_batch_file_size_mb > 0:
131 size_bytes: Final = _file_size_bytes(file_source)
132 if size_bytes > max_batch_file_size_mb * _MB:
133 return BatchFileTooLarge(size_bytes=size_bytes, limit_mb=max_batch_file_size_mb)
134 scan_failure: Final = _scan_lines(file_source, line_shape)
135 if not isinstance(file_source, bytes):
136 file_source.seek(0)
137 return scan_failure
140def raise_batch_file_validation_failure(failure: BatchFileValidationFailure) -> NoReturn:
141 match failure:
142 case BatchFileTooLarge(size_bytes=size_bytes, limit_mb=limit_mb):
143 raise ProxyException(
144 message=(
145 f"Batch input file is {size_bytes / _MB:.1f} MB, which exceeds the configured "
146 f"max_batch_file_size_mb of {limit_mb} MB. The file was not forwarded to the provider."
147 ),
148 type="invalid_request_error",
149 param="file",
150 code=413,
151 )
152 case BatchFileWrongExtension(filename=filename):
153 raise ProxyException(
154 message=(
155 f"Invalid file format for Batch API: '{filename}'. "
156 "Batch input files must be .jsonl files. The file was not forwarded to the provider."
157 ),
158 type="invalid_request_error",
159 param="file",
160 code=400,
161 )
162 case BatchFileEmpty():
163 raise ProxyException(
164 message="Batch input file has no request lines. The file was not forwarded to the provider.",
165 type="invalid_request_error",
166 param="file",
167 code=400,
168 )
169 case BatchFileInvalidJsonLine(line_number=line_number):
170 raise ProxyException(
171 message=(
172 f"Batch input file line {line_number} is not valid JSON. "
173 "The file was not forwarded to the provider."
174 ),
175 type="invalid_request_error",
176 param="file",
177 code=400,
178 )
179 case BatchFileLineNotObject(line_number=line_number):
180 raise ProxyException(
181 message=(
182 f"Batch input file line {line_number} must be a JSON object. "
183 "The file was not forwarded to the provider."
184 ),
185 type="invalid_request_error",
186 param="file",
187 code=400,
188 )
189 case BatchFileMissingLineKey(line_number=line_number, key=key, line_shape=line_shape):
190 raise ProxyException(
191 message=(
192 f"Missing required parameter: '{key}' (batch input file line {line_number}). "
193 f"{line_shape.hint}. "
194 "The file was not forwarded to the provider."
195 ),
196 type="invalid_request_error",
197 param=key,
198 code=400,
199 )
200 case _:
201 assert_never(failure)