diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index 29ad1a8f7c4..cb10c2384cd 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -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) diff --git a/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py index 619bb36c7aa..c01c028c0db 100644 --- a/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py @@ -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()