Merge f0eed8c56b into the batch line validation rework

Keep the strict object-name check for every MIME type and name the file from the
first record. Naming checks only that each line is a JSON object, so a file already
in Bedrock format and sent with a MIME type that skips conversion still uploads
unchanged.
This commit is contained in:
mateo-berri 2026-10-20 11:19:45 -07:00
commit fbc0239679
2 changed files with 69 additions and 21 deletions

View file

@ -375,11 +375,15 @@ def _json_object(value: object) -> Mapping[str, object] | None:
return value if isinstance(value, dict) else None
def _batch_record_shape_problem(record: object) -> str | None:
record_object: Final = _json_object(record)
if record_object is None:
return "is not a JSON object"
body: Final = _json_object(record_object.get("body"))
def _batch_json_object(line_number: int, line: str) -> _OpenAIBatchRecord:
record: Final = _load_batch_line(line_number, line)
if _json_object(record) is None:
raise _batch_line_error(line_number, "is not a JSON object")
return record
def _batch_body_problem(record: Mapping[str, object]) -> str | None:
body: Final = _json_object(record.get("body"))
if body is None:
return "has a body that is missing or not a JSON object"
if "model" in body and not isinstance(body["model"], str):
@ -388,8 +392,8 @@ def _batch_record_shape_problem(record: object) -> str | None:
def _batch_input_line(line_number: int, line: str) -> _BatchInputLine:
record: Final = _load_batch_line(line_number, line)
problem: Final = _batch_record_shape_problem(record)
record: Final = _batch_json_object(line_number, line)
problem: Final = _batch_body_problem(record)
if problem is not None:
raise _batch_line_error(line_number, problem)
return _BatchInputLine(line_number=line_number, record=record)
@ -404,14 +408,15 @@ def _parse_batch_jsonl(file_content: str) -> tuple[_BatchInputLine, ...]:
return tuple(_batch_input_line(line_number, line) for line_number, line in _non_blank_lines(file_content))
def _first_batch_record_model(file_content: str) -> str | None:
first_line: Final = next(_non_blank_lines(file_content), None)
if first_line is None:
return None
try:
return _batch_input_line(*first_line).record.get("body", {}).get("model", "")
except BedrockError:
def _batch_file_model(file_content: str) -> str | None:
records: Final = tuple(
_batch_json_object(line_number, line) for line_number, line in _non_blank_lines(file_content)
)
if not records:
return None
body: Final = _json_object(records[0].get("body"))
model: Final = body.get("model") if body is not None else None
return model if isinstance(model, str) else ""
def _batch_s3_object_name(model: str) -> str:
@ -644,7 +649,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
if purpose == "batch":
## 1. If jsonl, check if there's a model name
file_content: Final = self._get_content_from_openai_file(extracted_file_data_content)
batch_model: Final = _first_batch_record_model(file_content)
batch_model: Final = _batch_file_model(file_content)
if batch_model is not None:
return _batch_s3_object_name(batch_model)

View file

@ -4427,13 +4427,33 @@ class TestMalformedBatchJsonl:
assert exc_info.value.status_code == 400
assert "line 3" in exc_info.value.message
def test_object_name_inference_still_falls_back_for_a_non_json_file(self):
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig
@pytest.mark.parametrize("content_type", ["application/jsonl", "application/x-jsonl"])
@pytest.mark.parametrize(
"file_content",
[_MALFORMED_BATCH_JSONL, f"{_VALID_BATCH_LINE}\n\n[1, 2]\n".encode()],
ids=["invalid-json", "array-line"],
)
def test_create_file_rejects_a_malformed_line_before_uploading(self, content_type: str, file_content: bytes):
"""application/x-jsonl is not on the batch MIME allowlist, so the upload skips conversion for it; the line
check must still run so the broken file is rejected instead of landing in S3 unchanged."""
from litellm.llms.bedrock.common_utils import BedrockError
object_name = BedrockFilesConfig().get_object_name(
{"content": b"just some text, not jsonl", "filename": "notes.txt"}, purpose="batch"
)
assert object_name.endswith("notes.txt")
with respx.mock as router:
any_request: Final = router.route()
with pytest.raises(BedrockError) as exc_info:
litellm.create_file(
file=("batch.jsonl", file_content, content_type),
purpose="batch",
custom_llm_provider="bedrock",
model="bedrock/amazon.nova-pro-v1:0",
s3_bucket_name="litellm-batch-bucket",
aws_region_name="us-west-2",
aws_access_key_id="test-key-id",
aws_secret_access_key="test-secret",
)
assert exc_info.value.status_code == 400
assert "line 3" in exc_info.value.message
assert not any_request.called
_VALID_BATCH_RECORD: Final = json.loads(_VALID_BATCH_LINE)
@ -4534,3 +4554,26 @@ async def test_create_file_accepts_a_batch_file_with_a_utf8_byte_order_mark(resp
uploaded_record: Final = json.loads(put_route.calls.last.request.content)
assert uploaded_record["recordId"] == _VALID_BATCH_RECORD["custom_id"]
assert "/litellm-bedrock-files-amazon.nova-pro-v1-0-" in str(put_route.calls.last.request.url)
@pytest.mark.usefixtures(httpx_transport.__name__)
@pytest.mark.asyncio
async def test_create_file_uploads_a_bedrock_format_batch_file_unchanged_when_its_mime_type_skips_conversion(
respx_mock: respx.MockRouter,
) -> None:
put_route: Final = respx_mock.put(url__regex=r"https://s3\..+").respond(status_code=200)
bedrock_line: Final = json.dumps(
{"recordId": "req-1", "modelInput": {"messages": [{"role": "user", "content": [{"text": "Hello"}]}]}}
)
await litellm.acreate_file(
file=("batch.jsonl", f"{bedrock_line}\n".encode(), "application/x-jsonl"),
purpose="batch",
custom_llm_provider="bedrock",
s3_bucket_name="litellm-batch-bucket",
aws_region_name="us-west-2",
aws_access_key_id="test-access-key",
aws_secret_access_key="test-secret-key",
)
assert put_route.calls.last.request.content == f"{bedrock_line}\n".encode()