Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions instructor/v2/dsl/partial.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions tests/dsl/test_partial.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down