From d154b13b4d17930c790b10d449faff475fd22dd0 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Mon, 20 Jul 2026 14:54:02 -0500 Subject: [PATCH] fix(batch): accept empty structured results --- CHANGELOG.md | 1 + instructor/batch/processor.py | 2 +- tests/test_batch_processor_coverage.py | 37 ++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fcbd6f1b..7ef830c3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] ### Fixed +- **Batch results**: Accept empty structured objects so Pydantic models can apply field defaults for OpenAI and Anthropic batch responses. - **v2 message handling**: Preserve caller-owned message lists and nested content across request preparation and retries for OpenAI-compatible, Cohere, Mistral, OpenRouter, Writer, and xAI handlers. ([#2417](https://github.com/567-labs/instructor/issues/2417), [#2428](https://github.com/567-labs/instructor/issues/2428)) - **v2 JSON extraction**: Prefer the final complete top-level JSON value in text responses and retain every JSON object when multiple objects arrive in one streaming chunk. - **v2 schemas**: Treat fields with Pydantic `default_factory` values as optional in generated OpenAI tool schemas. diff --git a/instructor/batch/processor.py b/instructor/batch/processor.py index c659bbc3f..b7fc3c6a3 100644 --- a/instructor/batch/processor.py +++ b/instructor/batch/processor.py @@ -189,7 +189,7 @@ def parse_results(self, results_content: str) -> list[BatchResult]: custom_id = data.get("custom_id", "unknown") extracted_data = self._extract_from_response(data) - if extracted_data: + if extracted_data is not None: try: # Parse into response model result = self.response_model(**extracted_data) diff --git a/tests/test_batch_processor_coverage.py b/tests/test_batch_processor_coverage.py index 03b3bc603..7bc603169 100644 --- a/tests/test_batch_processor_coverage.py +++ b/tests/test_batch_processor_coverage.py @@ -23,6 +23,10 @@ class Person(BaseModel): age: int +class DefaultedResult(BaseModel): + status: str = "ok" + + class RecordingProvider: def __init__(self, results: str = "") -> None: self.results = results @@ -275,6 +279,39 @@ def test_openai_results_distinguish_success_validation_extraction_and_json_error assert results[3].raw_data == {"raw_line": "not-json"} +@pytest.mark.parametrize( + ("model", "content"), + [ + ("openai/gpt-4.1-mini", openai_result("empty", "{}")), + ( + "anthropic/claude-sonnet", + json.dumps( + { + "custom_id": "empty", + "result": { + "type": "succeeded", + "message": {"content": [{"type": "tool_use", "input": {}}]}, + }, + } + ), + ), + ], +) +def test_parse_results_accepts_empty_object_for_defaulted_model( + provider: RecordingProvider, + model: str, + content: str, +) -> None: + del provider + processor = BatchProcessor(model, DefaultedResult) + + results = processor.parse_results(content) + + assert results == [ + BatchSuccess(custom_id="empty", result=DefaultedResult(status="ok")) + ] + + def test_anthropic_results_support_tool_use_and_text_fallback( provider: RecordingProvider, ) -> None: