diff --git a/instructor/v2/dsl/partial.py b/instructor/v2/dsl/partial.py index 464d32ca8..c18c5cbf6 100644 --- a/instructor/v2/dsl/partial.py +++ b/instructor/v2/dsl/partial.py @@ -439,13 +439,18 @@ def model_from_chunks( yield obj # Final validation: only validate if the JSON is structurally complete - # If JSON is incomplete (stream ended mid-object), skip validation + # If JSON is incomplete (stream ended mid-object), skip validation. + # Validate the accumulated JSON itself rather than a + # model_dump(exclude_none=True) round-trip, which would strip fields + # the model legitimately returned as null and make required-but-nullable + # fields fail re-validation as "missing". if final_obj is not None: original_model = getattr(cls, "_original_model", None) if original_model is not None: - if is_json_complete(potential_object.strip() or "{}"): + json_str = potential_object.strip() or "{}" + if is_json_complete(json_str): original_model.model_validate( - final_obj.model_dump(exclude_none=True), **kwargs + from_json(json_str.encode()), **kwargs ) @classmethod @@ -474,13 +479,18 @@ async def model_from_chunks_async( yield obj # Final validation: only validate if the JSON is structurally complete - # If JSON is incomplete (stream ended mid-object), skip validation + # If JSON is incomplete (stream ended mid-object), skip validation. + # Validate the accumulated JSON itself rather than a + # model_dump(exclude_none=True) round-trip, which would strip fields + # the model legitimately returned as null and make required-but-nullable + # fields fail re-validation as "missing". if final_obj is not None: original_model = getattr(cls, "_original_model", None) if original_model is not None: - if is_json_complete(potential_object.strip() or "{}"): + json_str = potential_object.strip() or "{}" + if is_json_complete(json_str): original_model.model_validate( - final_obj.model_dump(exclude_none=True), **kwargs + from_json(json_str.encode()), **kwargs ) @staticmethod diff --git a/tests/dsl/test_partial.py b/tests/dsl/test_partial.py index 2d242bd0e..760bc8a55 100644 --- a/tests/dsl/test_partial.py +++ b/tests/dsl/test_partial.py @@ -960,6 +960,71 @@ async def async_chunks(): assert "age" in str(exc_info.value) + def test_final_validation_accepts_explicit_null_for_required_nullable_field(self): + """Explicit JSON null for a required-but-nullable field should validate. + + The final validation must not confuse a field the model explicitly + returned as null with a field that was never streamed at all. + """ + + class ModelWithNullable(BaseModel): + name: str + email: Optional[str] # Required, but nullable + + PartialModel = Partial[ModelWithNullable] + + chunks = ['{"name": "Al', 'ice", "email"', ": null}"] + + results = list(_partial_api(PartialModel).model_from_chunks(iter(chunks))) + assert len(results) > 0 + final = results[-1] + assert final.name == "Alice" + assert final.email is None + + def test_final_validation_still_rejects_absent_required_nullable_field(self): + """A required nullable field genuinely absent from complete JSON still fails.""" + + class ModelWithNullable(BaseModel): + name: str + email: Optional[str] # Required, but nullable + + PartialModel = Partial[ModelWithNullable] + + chunks = ['{"name": "Alice"}'] # 'email' truly missing + + with pytest.raises(ValidationError) as exc_info: + list(_partial_api(PartialModel).model_from_chunks(iter(chunks))) + + assert "email" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_async_final_validation_accepts_explicit_null_for_required_nullable_field( + self, + ): + """Async streaming should also accept explicit nulls for nullable fields.""" + + class ModelWithNullable(BaseModel): + name: str + email: Optional[str] # Required, but nullable + + PartialModel = Partial[ModelWithNullable] + + async def async_chunks(): + yield '{"name": "Al' + yield 'ice", "email"' + yield ": null}" + + results = [] + async for obj in _partial_api(PartialModel).model_from_chunks_async( + async_chunks() + ): + results.append(obj) + + assert len(results) > 0 + final = results[-1] + assert final.name == "Alice" + assert final.email is None + class TestRecursiveModels: """Test that Partial handles self-referential models without infinite recursion."""