From 85fc4498cc5eed967d5ed812a8e0d95f1b0fa330 Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Thu, 8 Oct 2026 16:22:04 -0700 Subject: [PATCH] fix(batches): an unreadable error file no longer drops the output line events Costing counts an error file it could not fetch as no failed lines; line logging refetched it and the raise aborted the whole fan-out, so the successful lines were never emitted. The error file now reads as empty with a warning. --- litellm/batches/batch_line_item_logging.py | 18 +++++++++++- .../batches/test_batch_line_item_logging.py | 29 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/litellm/batches/batch_line_item_logging.py b/litellm/batches/batch_line_item_logging.py index 854f8c2ef34..bde63efb30f 100644 --- a/litellm/batches/batch_line_item_logging.py +++ b/litellm/batches/batch_line_item_logging.py @@ -419,6 +419,22 @@ async def _fetch_managed_file_or_empty( ) +async def _fetch_error_file_or_empty( + batch: LiteLLMBatch, + custom_llm_provider: _BatchLineProvider, + fetch_params: dict[str, object] | None, # mutable-ok: batch_utils file fetch takes the shared litellm_params dict +) -> bytes: + try: + return await _fetch_managed_file_or_empty(batch.error_file_id, custom_llm_provider, fetch_params) + except Exception as e: # noqa: BLE001 # costing counts an unreadable error file as no failed lines, so the output lines still go out + verbose_logger.warning( + "batch line-item callbacks could not fetch the error file, emitting the output lines only. batch_id=%s error=%s", + batch.id, + e, + ) + return b"" + + async def log_batch_line_items( batch: LiteLLMBatch, custom_llm_provider: str, @@ -488,7 +504,7 @@ async def log_batch_line_items( error_content: Final = ( result_files.error if result_files is not None and result_files.error is not None - else await _fetch_managed_file_or_empty(batch.error_file_id, line_provider, fetch_params) + else await _fetch_error_file_or_empty(batch, line_provider, fetch_params) ) for content in (output_content, error_content): for entry in _output_entries(content): diff --git a/tests/unit/batches/test_batch_line_item_logging.py b/tests/unit/batches/test_batch_line_item_logging.py index d383546b7bf..aea0ae63ebe 100644 --- a/tests/unit/batches/test_batch_line_item_logging.py +++ b/tests/unit/batches/test_batch_line_item_logging.py @@ -1241,6 +1241,35 @@ async def test_supplied_result_files_skip_the_output_and_error_fetch(recorder): assert file_mock.await_args.kwargs["file_id"] == "input-file-1" +def _error_file_unreadable(file_id: str, **_kwargs): + if file_id == "error-file-1": + raise ValueError("error file gone") + return SimpleNamespace(content=_FILE_BYTES[file_id]) + + +@pytest.mark.asyncio +async def test_an_unreadable_error_file_still_emits_the_output_lines(recorder): + file_mock: Final = AsyncMock(side_effect=_error_file_unreadable) + with patch( + "litellm.files.main.afile_content", file_mock + ): # test-quality-ok: afile_content is the provider boundary; no injection seam for managed file fetch + emitted: Final = await log_batch_line_items( + batch=_batch(), + custom_llm_provider="openai", + parent=_parent_logging(), + model_name="gpt-4o", + litellm_params=None, + model_info=None, + result_files=BatchResultFiles(output=OUTPUT_JSONL, error=None), + claim_cache=DualCache(), + ) + + assert emitted == 1 + assert len(recorder.success_events) == 1 + assert len(recorder.failure_events) == 0 + assert sorted(call.kwargs["file_id"] for call in file_mock.await_args_list) == ["error-file-1", "input-file-1"] + + @pytest.mark.asyncio async def test_empty_result_files_fall_back_to_fetching_everything(recorder): file_mock: Final = AsyncMock(side_effect=_file_content)