From 7e0714657b48469c169fae1ee82d61e8cab671b0 Mon Sep 17 00:00:00 2001 From: jesus Date: Mon, 7 Sep 2026 09:40:11 +0000 Subject: [PATCH 1/5] fix(vertex_ai): return 400 with file line number for malformed batch JSONL row Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../llms/vertex_ai/files/transformation.py | 35 ++++++---- .../files/test_vertex_ai_files_streaming.py | 65 ++++++++++++++++++- 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/litellm/llms/vertex_ai/files/transformation.py b/litellm/llms/vertex_ai/files/transformation.py index b6ad9fbcc04..032f4ed563d 100644 --- a/litellm/llms/vertex_ai/files/transformation.py +++ b/litellm/llms/vertex_ai/files/transformation.py @@ -563,16 +563,15 @@ def _openai_batch_jsonl_entry_to_vertex_rows( return ({"request": vertex_request_body},) -def _iter_stripped_lines(raw_lines: Iterable[str | bytes]) -> Iterator[str]: +def _iter_numbered_stripped_lines(raw_lines: Iterable[str | bytes]) -> Iterator[tuple[int, str]]: """Decode (when needed), strip, and drop blank lines from an iterable of lines.""" - for raw in raw_lines: - line = raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else raw - line = line.strip() + for lineno, raw in enumerate(raw_lines, start=1): + line = (raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else raw).strip() if line: - yield line + yield lineno, line -def _iter_openai_jsonl_lines(openai_file_content: FileTypes) -> Iterator[str]: +def _iter_numbered_openai_jsonl_lines(openai_file_content: FileTypes) -> Iterator[tuple[int, str]]: """ Yield non-empty JSONL lines one at a time without materializing the whole payload, so peak memory stays bounded regardless of payload size. Mirrors @@ -588,24 +587,26 @@ def _iter_openai_jsonl_lines(openai_file_content: FileTypes) -> Iterator[str]: # into a BytesIO just to iterate it line by line. newline: Final = ord("\n") start, length = 0, len(content) + lineno: int = 0 # rebind-ok: line counter advances for each streamed chunk while start < length: idx = content.find(newline, start) if idx == -1: chunk, start = content[start:], length else: chunk, start = content[start:idx], idx + 1 + lineno += 1 line = chunk.decode("utf-8").strip() if line: - yield line + yield lineno, line return if isinstance(content, str): - yield from _iter_stripped_lines(io.StringIO(content)) + yield from _iter_numbered_stripped_lines(io.StringIO(content)) return if isinstance(content, PathLike): with open(str(content), "rb") as handle: - yield from _iter_stripped_lines(handle) + yield from _iter_numbered_stripped_lines(handle) return if hasattr(content, "read"): @@ -626,17 +627,27 @@ def _iter_openai_jsonl_lines(openai_file_content: FileTypes) -> Iterator[str]: "Batch upload file handle must be seekable so it can be re-read " "for the GCS object name and the upload body." ) from e - yield from _iter_stripped_lines(content) + yield from _iter_numbered_stripped_lines(content) return raise ValueError("Unsupported file content type") +def _iter_openai_jsonl_lines(openai_file_content: FileTypes) -> Iterator[str]: + yield from (line for _, line in _iter_numbered_openai_jsonl_lines(openai_file_content)) + + def _iter_openai_jsonl_entries( openai_file_content: FileTypes, ) -> Iterator[dict[str, Any]]: - for line in _iter_openai_jsonl_lines(openai_file_content): - yield json.loads(line) + for lineno, line in _iter_numbered_openai_jsonl_lines(openai_file_content): + try: + yield json.loads(line) + except json.JSONDecodeError as e: + raise VertexAIError( + status_code=400, + message=f"Invalid JSON on line {lineno} of batch input file: {e.msg}", + ) from e def _parse_vertex_batch_output_row(line: str) -> _VertexBatchRow: diff --git a/tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_streaming.py b/tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_streaming.py index 7383513fb96..5dbc3f9c61e 100644 --- a/tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_streaming.py +++ b/tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_streaming.py @@ -31,16 +31,16 @@ from litellm.litellm_core_utils.litellm_logging import Logging from litellm.llms.base_llm.files.transformation import BaseFileUploadStream from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler +from litellm.llms.vertex_ai.common_utils import VertexAIError from litellm.llms.vertex_ai.files.transformation import ( VertexAIFilesConfig, - _OpenAIToVertexBatchUploadStream, _get_litellm_batch_custom_id_from_labels, _iter_openai_jsonl_entries, _iter_openai_jsonl_lines, _openai_batch_jsonl_entry_to_vertex_rows, + _OpenAIToVertexBatchUploadStream, ) from litellm.types.llms.openai import CreateFileRequest -from litellm.llms.vertex_ai.common_utils import VertexAIError def _upload_stream(transformed) -> BaseFileUploadStream: @@ -221,8 +221,10 @@ class TestStreamingLineIterator: content = b'{"custom_id": "first"}\nnot-json-at-all\n' gen = _iter_openai_jsonl_entries(content) assert next(gen)["custom_id"] == "first" - with pytest.raises(json.JSONDecodeError): + with pytest.raises(VertexAIError) as exc_info: next(gen) + assert exc_info.value.status_code == 400 + assert "line 2" in str(exc_info.value) class TestGetObjectNameLazyParse: @@ -476,6 +478,63 @@ class TestUploadUrl: class TestUploadStreamBody: + def test_stream_rejects_malformed_single_row(self): + stream = _OpenAIToVertexBatchUploadStream( + openai_file_content=b"this is not json\n", + map_openai_to_vertex_params=lambda body: body, + ) + with pytest.raises(VertexAIError) as exc_info: + list(stream.iter_bytes()) + assert exc_info.value.status_code == 400 + assert "line 1" in str(exc_info.value) + + def test_stream_reports_file_line_number_for_malformed_file_like_input(self): + valid_row = json.dumps( + { + "custom_id": "r1", + "method": "POST", + "url": "/v1/chat/completions", + "body": { + "model": "gemini-2.5-flash", + "messages": [{"role": "user", "content": "hi"}], + }, + } + ).encode("utf-8") + raw = valid_row + b"\n\nthis is not json\n" + stream = _OpenAIToVertexBatchUploadStream( + openai_file_content=io.BytesIO(raw), + map_openai_to_vertex_params=lambda body: body, + ) + with pytest.raises(VertexAIError) as exc_info: + list(stream.iter_bytes()) + assert "line 3" in str(exc_info.value) + + def test_stream_reports_file_line_number_for_malformed_bytes_input(self): + valid_row = json.dumps( + { + "custom_id": "r1", + "method": "POST", + "url": "/v1/chat/completions", + "body": { + "model": "gemini-2.5-flash", + "messages": [{"role": "user", "content": "hi"}], + }, + } + ).encode("utf-8") + raw = valid_row + b"\n\nthis is not json\n" + stream = _OpenAIToVertexBatchUploadStream( + openai_file_content=raw, + map_openai_to_vertex_params=lambda body: body, + ) + with pytest.raises(VertexAIError) as exc_info: + list(stream.iter_bytes()) + assert "line 3" in str(exc_info.value) + + def test_get_object_name_rejects_malformed_batch_jsonl(self): + with pytest.raises(VertexAIError) as exc_info: + VertexAIFilesConfig().get_object_name(file_data=b"not json\n", purpose="batch") + assert exc_info.value.status_code == 400 + def test_stream_matches_legacy_pipeline(self): cfg = VertexAIFilesConfig() raw = _make_openai_jsonl_bytes(120) From 0b33da566a54808c6900e4e6a75f25970c2c7140 Mon Sep 17 00:00:00 2001 From: jesus Date: Mon, 7 Sep 2026 09:40:58 +0000 Subject: [PATCH 2/5] refactor(vertex_ai): keep yield outside the JSON decode guard Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/vertex_ai/files/transformation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/litellm/llms/vertex_ai/files/transformation.py b/litellm/llms/vertex_ai/files/transformation.py index 032f4ed563d..0efc3f1f453 100644 --- a/litellm/llms/vertex_ai/files/transformation.py +++ b/litellm/llms/vertex_ai/files/transformation.py @@ -642,12 +642,13 @@ def _iter_openai_jsonl_entries( ) -> Iterator[dict[str, Any]]: for lineno, line in _iter_numbered_openai_jsonl_lines(openai_file_content): try: - yield json.loads(line) + entry: dict[str, Any] = json.loads(line) except json.JSONDecodeError as e: raise VertexAIError( status_code=400, message=f"Invalid JSON on line {lineno} of batch input file: {e.msg}", ) from e + yield entry def _parse_vertex_batch_output_row(line: str) -> _VertexBatchRow: From 2b14762d0ffdfca8a6c62fffde32d7df977d2a55 Mon Sep 17 00:00:00 2001 From: jesus Date: Mon, 7 Sep 2026 09:55:04 +0000 Subject: [PATCH 3/5] fix(http_handler): surface BaseLLMException raised while streaming a request body Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/custom_httpx/llm_http_handler.py | 26 ++++++++++-- .../custom_httpx/test_llm_http_handler.py | 41 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index f281c249c72..e1142a6e4a1 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -44,7 +44,7 @@ from litellm.llms.base_llm.base_model_iterator import ( MockResponseIterator, ) from litellm.llms.base_llm.batches.transformation import BaseBatchesConfig -from litellm.llms.base_llm.chat.transformation import BaseConfig +from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException from litellm.llms.base_llm.containers.transformation import BaseContainerConfig from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig from litellm.llms.base_llm.evals.transformation import BaseEvalsAPIConfig @@ -311,6 +311,24 @@ def _collect_ws_project_quota_callbacks() -> tuple[ProjectQuotaCallback, ...]: ) +def _find_base_llm_exception(error: BaseException, depth: int = 0) -> BaseLLMException | None: + if depth >= 10: + return None + if isinstance(error, BaseLLMException): + return error + + cause: Final = error.__cause__ + if cause is not None: + provider_exception: Final = _find_base_llm_exception(cause, depth + 1) + if provider_exception is not None: + return provider_exception + + context: Final = error.__context__ + if context is not None: + return _find_base_llm_exception(context, depth + 1) + return None + + class BaseLLMHTTPHandler: async def _make_common_async_call( self, @@ -5980,6 +5998,10 @@ class BaseLLMHTTPHandler: BaseEvalsAPIConfig, ], ): + provider_exception: Final = _find_base_llm_exception(e) + if provider_exception is not None: + raise provider_exception + received_status_code: Final = ( e.response.status_code if isinstance(e, httpx.HTTPStatusError) else getattr(e, "status_code", None) ) @@ -6000,8 +6022,6 @@ class BaseLLMHTTPHandler: error_headers = {} if provider_config is None: - from litellm.llms.base_llm.chat.transformation import BaseLLMException - raise BaseLLMException( status_code=status_code, message=error_text, diff --git a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py index 023e2d8843f..15cf01d4705 100644 --- a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py @@ -2110,6 +2110,47 @@ def test_sync_retrieve_file_content_raises_on_http_error(): assert exc_info.value.status_code == 404 +def test_handle_error_preserves_base_llm_exception_message_and_status(): + from litellm.llms.vertex_ai.common_utils import VertexAIError + from litellm.llms.vertex_ai.files.transformation import VertexAIFilesConfig + + message = "Invalid JSON on line 3 of batch input file: Expecting value" + error = VertexAIError(status_code=400, message=message) + + with pytest.raises(VertexAIError) as exc_info: + BaseLLMHTTPHandler()._handle_error(e=error, provider_config=VertexAIFilesConfig()) + + assert exc_info.value.status_code == 400 + assert exc_info.value.message == message + + +def test_handle_error_preserves_base_llm_exception_from_wrapper(): + from litellm.llms.vertex_ai.common_utils import VertexAIError + from litellm.llms.vertex_ai.files.transformation import VertexAIFilesConfig + + message = "Invalid JSON on line 3 of batch input file: Expecting value" + error = VertexAIError(status_code=400, message=message) + wrapper = httpx.ConnectError("Failed to send bytes") + wrapper.__cause__ = error + + with pytest.raises(VertexAIError) as exc_info: + BaseLLMHTTPHandler()._handle_error(e=wrapper, provider_config=VertexAIFilesConfig()) + + assert exc_info.value.status_code == 400 + assert exc_info.value.message == message + + +def test_handle_error_maps_plain_exception_to_provider_error(): + from litellm.llms.vertex_ai.common_utils import VertexAIError + from litellm.llms.vertex_ai.files.transformation import VertexAIFilesConfig + + with pytest.raises(VertexAIError) as exc_info: + BaseLLMHTTPHandler()._handle_error(e=ValueError("boom"), provider_config=VertexAIFilesConfig()) + + assert exc_info.value.status_code == 500 + assert exc_info.value.message == "boom" + + _UPSTREAM_NOT_FOUND_BODY = { "error": { "message": "Response with id 'resp_abc' not found.", From 208e37cb044e7ace0a78e0444ad0fb5ec2d1d2a8 Mon Sep 17 00:00:00 2001 From: jesus Date: Mon, 7 Sep 2026 09:55:48 +0000 Subject: [PATCH 4/5] refactor(http_handler): only follow __cause__ when unwrapping streamed body errors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/custom_httpx/llm_http_handler.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index e1142a6e4a1..f84d4d7c85f 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -318,15 +318,9 @@ def _find_base_llm_exception(error: BaseException, depth: int = 0) -> BaseLLMExc return error cause: Final = error.__cause__ - if cause is not None: - provider_exception: Final = _find_base_llm_exception(cause, depth + 1) - if provider_exception is not None: - return provider_exception - - context: Final = error.__context__ - if context is not None: - return _find_base_llm_exception(context, depth + 1) - return None + if cause is None: + return None + return _find_base_llm_exception(cause, depth + 1) class BaseLLMHTTPHandler: From fe81b84b31a18f98d9177d9c15cbb7ccfcf0b956 Mon Sep 17 00:00:00 2001 From: jesus Date: Mon, 7 Sep 2026 09:59:35 +0000 Subject: [PATCH 5/5] fix(http_handler): avoid recursive streamed error unwrapping Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/custom_httpx/llm_http_handler.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index f84d4d7c85f..df82ed52359 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -311,16 +311,15 @@ def _collect_ws_project_quota_callbacks() -> tuple[ProjectQuotaCallback, ...]: ) -def _find_base_llm_exception(error: BaseException, depth: int = 0) -> BaseLLMException | None: - if depth >= 10: - return None - if isinstance(error, BaseLLMException): - return error - - cause: Final = error.__cause__ - if cause is None: - return None - return _find_base_llm_exception(cause, depth + 1) +def _find_base_llm_exception(error: BaseException) -> BaseLLMException | None: + current: BaseException | None = error + for _ in range(10): + if current is None: + return None + if isinstance(current, BaseLLMException): + return current + current = current.__cause__ # rebind-ok: traverse the bounded exception cause chain + return None class BaseLLMHTTPHandler: