Skip to content

Commit 8a5ed0a

Browse files
authored
Merge pull request #13 from ai-2070/structured
Structured Output Fixes
2 parents ea2591d + 7fa6674 commit 8a5ed0a

2 files changed

Lines changed: 209 additions & 23 deletions

File tree

src/l0/_structured.py

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -265,19 +265,15 @@ async def buffering_iterator() -> RawStream:
265265
for attempt in range(max_attempts):
266266
try:
267267
# _internal_run expects a callable factory
268-
# Handle both direct async iterators and factory functions
269-
def make_stream_factory(
270-
src: AwaitableStreamSource,
271-
) -> AwaitableStreamFactory:
272-
if callable(src) and not hasattr(src, "__anext__"):
273-
# It's already a factory
274-
return src
275-
else:
276-
# It's a direct async iterator - wrap in factory
277-
# Note: This only works once per stream!
278-
return lambda: cast(RawStream, src)
279-
280-
stream_factory = make_stream_factory(stream_source)
268+
# For factory functions, pass them directly so _internal_run can call fresh on retries
269+
# For direct async iterators (already wrapped in buffering factory above),
270+
# wrap in a lambda - the buffering factory handles replay
271+
if callable(stream_source) and not hasattr(stream_source, "__anext__"):
272+
# It's a factory - pass it directly to _internal_run
273+
stream_factory = cast(AwaitableStreamFactory, stream_source)
274+
else:
275+
# It's a direct async iterator (wrapped in buffering factory)
276+
stream_factory = lambda src=stream_source: cast(RawStream, src)
281277

282278
# Run through L0 runtime
283279
result = await _internal_run(
@@ -890,17 +886,18 @@ async def buffering_iterator() -> RawStream:
890886
for stream_source in all_streams:
891887
for attempt in range(max_attempts):
892888
try:
889+
# _internal_run expects a callable factory
890+
# For factory functions, pass them directly so _internal_run can call fresh on retries
891+
# For direct async iterators (already wrapped in buffering factory above),
892+
# wrap in a lambda - the buffering factory handles replay
893+
if callable(stream_source) and not hasattr(stream_source, "__anext__"):
894+
# It's a factory - pass it directly to _internal_run
895+
stream_factory = cast(AwaitableStreamFactory, stream_source)
896+
else:
897+
# It's a direct async iterator (wrapped in buffering factory)
898+
stream_factory = lambda src=stream_source: cast(RawStream, src)
893899

894-
def make_stream_factory(
895-
src: AwaitableStreamSource,
896-
) -> AwaitableStreamFactory:
897-
if callable(src) and not hasattr(src, "__anext__"):
898-
return src
899-
else:
900-
return lambda: cast(RawStream, src)
901-
902-
stream_factory = make_stream_factory(stream_source)
903-
900+
# Run through L0 runtime
904901
result = await _internal_run(
905902
stream=stream_factory,
906903
on_event=on_event,

tests/test_structured.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,195 @@ async def json_stream():
681681
assert "list[SimpleModel]" in result.telemetry.schema_name
682682

683683

684+
class TestStreamFactoryRetryBehavior:
685+
"""Test that factory functions are called fresh on each retry attempt.
686+
687+
This tests the fix for the "stream already consumed" bug where factory
688+
functions were not being called on each retry, causing streams to be
689+
reused and fail with "ReadableStream is locked" or similar errors.
690+
"""
691+
692+
@pytest.mark.asyncio
693+
async def test_factory_called_fresh_on_each_retry_structured(self):
694+
"""Test that factory is called fresh on each retry in structured()."""
695+
factory_call_count = 0
696+
697+
def stream_factory():
698+
nonlocal factory_call_count
699+
factory_call_count += 1
700+
701+
async def gen():
702+
if factory_call_count == 1:
703+
# First attempt: invalid JSON that will fail validation
704+
yield Event(type=EventType.TOKEN, text='{"wrong": "field"}')
705+
else:
706+
# Subsequent attempts: valid JSON
707+
yield Event(type=EventType.TOKEN, text='{"value": "success"}')
708+
yield Event(type=EventType.COMPLETE)
709+
710+
return gen()
711+
712+
result = await structured(
713+
schema=SimpleModel,
714+
stream=stream_factory,
715+
retry=Retry(attempts=3),
716+
)
717+
718+
assert result.data.value == "success"
719+
assert factory_call_count == 2 # Called twice: first failed, second succeeded
720+
721+
@pytest.mark.asyncio
722+
async def test_factory_called_fresh_on_each_retry_structured_array(self):
723+
"""Test that factory is called fresh on each retry in structured_array()."""
724+
factory_call_count = 0
725+
726+
def stream_factory():
727+
nonlocal factory_call_count
728+
factory_call_count += 1
729+
730+
async def gen():
731+
if factory_call_count == 1:
732+
# First attempt: invalid JSON that will fail validation
733+
yield Event(type=EventType.TOKEN, text='[{"wrong": "field"}]')
734+
else:
735+
# Subsequent attempts: valid JSON
736+
yield Event(type=EventType.TOKEN, text='[{"value": "success"}]')
737+
yield Event(type=EventType.COMPLETE)
738+
739+
return gen()
740+
741+
result = await structured_array(
742+
item_schema=SimpleModel,
743+
stream=stream_factory,
744+
retry=Retry(attempts=3),
745+
)
746+
747+
assert len(result.data) == 1
748+
assert result.data[0].value == "success"
749+
assert factory_call_count == 2 # Called twice: first failed, second succeeded
750+
751+
@pytest.mark.asyncio
752+
async def test_factory_exhausts_all_retries_on_persistent_failure(self):
753+
"""Test that all retry attempts are used when validation keeps failing."""
754+
factory_call_count = 0
755+
756+
def stream_factory():
757+
nonlocal factory_call_count
758+
factory_call_count += 1
759+
760+
async def gen():
761+
# Always return invalid JSON
762+
yield Event(type=EventType.TOKEN, text='{"wrong": "field"}')
763+
yield Event(type=EventType.COMPLETE)
764+
765+
return gen()
766+
767+
with pytest.raises(ValueError, match="Schema validation failed"):
768+
await structured(
769+
schema=SimpleModel,
770+
stream=stream_factory,
771+
retry=Retry(attempts=3),
772+
)
773+
774+
# Factory should have been called 3 times (once per retry attempt)
775+
assert factory_call_count == 3
776+
777+
@pytest.mark.asyncio
778+
async def test_factory_not_called_after_success(self):
779+
"""Test that factory is not called again after successful validation."""
780+
factory_call_count = 0
781+
782+
def stream_factory():
783+
nonlocal factory_call_count
784+
factory_call_count += 1
785+
786+
async def gen():
787+
# Always return valid JSON
788+
yield Event(type=EventType.TOKEN, text='{"value": "test"}')
789+
yield Event(type=EventType.COMPLETE)
790+
791+
return gen()
792+
793+
result = await structured(
794+
schema=SimpleModel,
795+
stream=stream_factory,
796+
retry=Retry(attempts=5),
797+
)
798+
799+
assert result.data.value == "test"
800+
assert factory_call_count == 1 # Only called once since first attempt succeeded
801+
802+
@pytest.mark.asyncio
803+
async def test_fallback_factory_called_fresh_on_retry(self):
804+
"""Test that fallback factory functions are also called fresh on retry."""
805+
main_call_count = 0
806+
fallback_call_count = 0
807+
808+
def main_factory():
809+
nonlocal main_call_count
810+
main_call_count += 1
811+
812+
async def gen():
813+
# Main always fails
814+
yield Event(type=EventType.TOKEN, text='{"wrong": "field"}')
815+
yield Event(type=EventType.COMPLETE)
816+
817+
return gen()
818+
819+
def fallback_factory():
820+
nonlocal fallback_call_count
821+
fallback_call_count += 1
822+
823+
async def gen():
824+
if fallback_call_count == 1:
825+
# First fallback attempt fails
826+
yield Event(type=EventType.TOKEN, text='{"also_wrong": "field"}')
827+
else:
828+
# Second fallback attempt succeeds
829+
yield Event(type=EventType.TOKEN, text='{"value": "from_fallback"}')
830+
yield Event(type=EventType.COMPLETE)
831+
832+
return gen()
833+
834+
result = await structured(
835+
schema=SimpleModel,
836+
stream=main_factory,
837+
fallbacks=[fallback_factory],
838+
retry=Retry(attempts=2),
839+
)
840+
841+
assert result.data.value == "from_fallback"
842+
assert main_call_count == 2 # Main tried twice
843+
assert fallback_call_count == 2 # Fallback tried twice, second succeeded
844+
845+
@pytest.mark.asyncio
846+
async def test_async_factory_called_fresh_on_retry(self):
847+
"""Test that async factory functions are called fresh on each retry."""
848+
factory_call_count = 0
849+
850+
async def async_stream_factory():
851+
nonlocal factory_call_count
852+
factory_call_count += 1
853+
854+
async def gen():
855+
if factory_call_count == 1:
856+
yield Event(type=EventType.TOKEN, text='{"wrong": "field"}')
857+
else:
858+
yield Event(type=EventType.TOKEN, text='{"value": "async_success"}')
859+
yield Event(type=EventType.COMPLETE)
860+
861+
return gen()
862+
863+
result = await structured(
864+
schema=SimpleModel,
865+
stream=async_stream_factory,
866+
retry=Retry(attempts=3),
867+
)
868+
869+
assert result.data.value == "async_success"
870+
assert factory_call_count == 2
871+
872+
684873
class TestStructuredStrictMode:
685874
"""Test strict_mode parameter for rejecting extra fields."""
686875

0 commit comments

Comments
 (0)