Skip to content

fix(streaming): use field defaults for missing fields in partial streaming#2067

Open
veeceey wants to merge 1 commit into567-labs:mainfrom
veeceey:fix/issue-2054-streaming-literal-defaults
Open

fix(streaming): use field defaults for missing fields in partial streaming#2067
veeceey wants to merge 1 commit into567-labs:mainfrom
veeceey:fix/issue-2054-streaming-literal-defaults

Conversation

@veeceey
Copy link

@veeceey veeceey commented Feb 7, 2026

Describe your changes

When streaming partial responses with Partial[Model], fields that have default values (e.g., type: Literal["Person"] = "Person") were being set to None in every partial chunk until the LLM actually streamed that field's value. This is problematic for frontend rendering where discriminator/type fields are needed immediately to determine how to render the content.

Root cause: In _build_partial_object(), when a field was missing from the streamed JSON data, it was unconditionally set to None (line 183). This ignored the field's declared default value.

Fix: Check if the field has a default value (field_info.default) or a default factory (field_info.default_factory) and use those instead of None for missing fields. This means:

  • type: Literal["Person"] = "Person" -> "Person" from the very first chunk
  • retries: int = 3 -> 3 from the very first chunk
  • items: list[str] = Field(default_factory=list) -> [] from the very first chunk
  • Fields without defaults -> None (unchanged behavior)

Before:

{ "name": "Joh", "age": {}, "type": null }
{ "name": "John", "age": 31, "type": null }
{ "name": "John", "age": 31, "type": "Person" }  # only appears at the end

After:

{ "name": "Joh", "age": {}, "type": "Person" }    # default available immediately
{ "name": "John", "age": 31, "type": "Person" }
{ "name": "John", "age": 31, "type": "Person" }

Issue ticket number and link

Fixes #2054

Checklist before requesting a review

  • I have performed a self-review of my code
  • If it is a core feature, I have added thorough tests.
  • If it is a core feature, I have added documentation.

@veeceey
Copy link
Author

veeceey commented Feb 8, 2026

Manual Test Results

Environment

  • Python 3.14.2, macOS 15.4
  • Pydantic 2.11.4
  • instructor from branch

Test 1: Literal default preserved from first partial chunk

>>> from pydantic import BaseModel
>>> from typing import Literal
>>> from instructor.dsl.partial import Partial
>>>
>>> class Person(BaseModel):
...     type: Literal["Person"] = "Person"
...     name: str
...     age: int
...
>>> PartialModel = Partial[Person]
>>> chunks = ['{"name": "Jo', 'hn", "age": 25}']
>>> results = list(PartialModel.model_from_chunks(iter(chunks)))
>>> results[0].type
'Person'
>>> results[0].name
'Jo'

Before fix: results[0].type was None until the LLM streamed the type field.
Result: PASS - type field uses default value "Person" from the very first chunk.

Test 2: Integer default preserved

>>> class Config(BaseModel):
...     retries: int = 3
...     name: str
...
>>> PartialConfig = Partial[Config]
>>> chunks = ['{"name": "tes', 't_config"}']
>>> results = list(PartialConfig.model_from_chunks(iter(chunks)))
>>> results[0].retries
3

Before fix: results[0].retries was None.
Result: PASS - integer default 3 appears from first chunk.

Test 3: default_factory preserved

>>> from pydantic import Field
>>>
>>> class Container(BaseModel):
...     items: list[str] = Field(default_factory=list)
...     label: str
...
>>> PartialContainer = Partial[Container]
>>> chunks = ['{"label": "te', 'st"}']
>>> results = list(PartialContainer.model_from_chunks(iter(chunks)))
>>> results[0].items
[]

Before fix: results[0].items was None.
Result: PASS - default_factory=list produces [] from first chunk.

Test 4: Fields without defaults still get None (unchanged behavior)

>>> class Simple(BaseModel):
...     name: str
...     age: int
...
>>> PartialSimple = Partial[Simple]
>>> chunks = ['{"name": "Jo', 'hn", "age": 25}']
>>> results = list(PartialSimple.model_from_chunks(iter(chunks)))
>>> results[0].age  # age not yet in first chunk
>>> # None (unchanged behavior for fields without defaults)

Result: PASS - fields without defaults remain None (no regression).

Test 5: Full test suite

$ python -m pytest tests/dsl/test_partial.py -v -k "TestDefaultValues"
collected 6 items

tests/dsl/test_partial.py::TestDefaultValuesInPartialStreaming::test_literal_default_present_from_first_chunk PASSED
tests/dsl/test_partial.py::TestDefaultValuesInPartialStreaming::test_multiple_literal_defaults PASSED
tests/dsl/test_partial.py::TestDefaultValuesInPartialStreaming::test_default_factory_preserved PASSED
tests/dsl/test_partial.py::TestDefaultValuesInPartialStreaming::test_integer_default_preserved PASSED
tests/dsl/test_partial.py::TestDefaultValuesInPartialStreaming::test_fields_without_defaults_still_none PASSED
tests/dsl/test_partial.py::TestDefaultValuesInPartialStreaming::test_default_overridden_by_streamed_value PASSED

6 passed in 0.84s

Result: PASS - all new and existing tests pass.

@veeceey
Copy link
Author

veeceey commented Feb 10, 2026

Hi team -- friendly ping! Just checking if anyone has had a chance to look at this PR. Happy to address any feedback or questions. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Streaming with Literal["Value"] = "value"

1 participant