fix(byllm): async streaming awaits an async generator, so every subclass but Model crashes (#8932) - #8955
Conversation
…ass but Model crashes (jaseci-labs#8932) BaseLLM.adispatch_streaming awaits model_call_with_stream_async and then iterates the result, which requires a coroutine returning an async iterator. The base implementation contained a yield, making it an async generator instead, and awaiting an async generator raises TypeError. Model was unaffected only because it defines its own coroutine. Move the yielding loop into a nested _iter and return it, so the method itself is a coroutine and matches both the caller and Model. The declaration now says AsyncIterator, unambiguous where AsyncGenerator was not: the old annotation reads correctly for either shape, which is why no checker caught the mismatch. MockLLM needs a second fix on top. It streams from its configured outputs rather than through the model_call layer, so a corrected base still reached model_call_with_stream and raised NotImplementedError. Add adispatch_streaming mirroring dispatch_streaming, the way adispatch_no_streaming already mirrors dispatch_no_streaming. Declaring MockLLM.adispatch_streaming deepens resolution in mockllm.impl.jac and surfaces a pre-existing type error at dispatch_no_streaming, where a str literal is passed to add_message(message: MessageType). Annotated `any` to hold the file at its previous clean check result without changing runtime behavior; the underlying looseness pre-dates this change. Affected: MockLLM, LocalLLM, ModelPool and any user-defined subclass, on the async streaming path only (stream=True with no tools bound). Present since at least 0.34.17. Three regression tests: the shape assertion on the base method, an end-to-end drain on a subclass that inherits it, and one on MockLLM. All three fail before this change and pass after; the byllm suite goes 113 to 116 passed.
Local validationCI passing only shows nothing broke. These are the behaviours the fix has to preserve, measured locally on macOS 15.6 / Python 3.14, dev build at Behavioural battle test, 10/10 (stable across 3 consecutive runs)Why these particular ones:
Close semantics, checked separately before I called scenario 5 a passScenario 5 failed on first run, so I compared all three paths against each other instead of assuming: The fixed base matches My first two attempts at scenario 5 measured the harness rather than the implementation: the first held a reference across the Suite counts, baselined against
|
test_byllm.jac |
full tests/ dir |
|
|---|---|---|
main |
113 passed, 1 skipped | 227 passed, 3 failed, 1 skipped |
| this PR | 116 passed, 1 skipped | 230 passed, 3 failed, 1 skipped |
Exactly +3, the three new tests, and no change in failures. The 3 failures are test_mtir_integration.jac (imported function scope resolution, same function name different files resolve distinctly, submodule MTIR keyed by import fullname), which fail identically on unmodified main and are unrelated to this change.
Per-file jac check counts are unchanged from main on every touched file, and llm.jac improves from 38 errors to 37.
Reverting the fix still fails the new tests
Fix reverted, tests kept: 113 passed, 1 failed, 2 error, 1 skipped. Exactly the three new tests fail, with TypeError: 'async_generator' object can't be awaited at basellm.impl.jac:1673. They guard the defect rather than merely passing alongside it.
On the two red checks
The only difference between those two commits is one file: A one-line markdown fragment cannot change a browser journey or an installer test. The byllm code is byte-identical across both runs. What each failure actually is:
The job that does exercise this change, Happy to re-run the two red jobs to confirm, just say the word. |
… use the shared seam jaseci-labs#8932 noted that FakeLLM's model_call_with_stream_async override existed only to work around the broken base and could go once it was fixed. Removed, so FakeLLM now inherits the base method: the shape LocalLLM and ModelPool have, and the one the regression tests need to exercise. The new tests move onto FakeLLM and mk_run instead of carrying their own chunk objects and MTRuntime builder. Release note cut to one line.
…spatch_streaming The three tests above call adispatch_streaming directly, and the existing end-to-end test mocks acall_llm outright, so nothing exercised the chain a user actually hits: codegen -> Jac.acall_llm -> ainvoke -> adispatch_streaming. Fails with TypeError: 'async_generator' object can't be awaited before the fix.
…_run usage jaseci-labs#8479 moved usage off the LLM object onto mt_run and routed it through _record_usage. The async override still appended to self._usage_history and raised AttributeError once main landed.
|
Superseding my earlier evidence comment, which described a pre-merge state and no longer matches the code. Current status against Four regression tests, all red-then-green verified in an isolated worktree against
The last one was missing at first. The other three call
Stub boundary, stated plainly. No real provider call anywhere. One thing a reviewer should know: after CI is 23/23 green on this head. |
kugesan1105
left a comment
There was a problem hiding this comment.
Validated locally on f21932d (Linux, byllm suite: main 89 passed, PR 92 passed). The fix for the stated bug is real: the issue repro passes, and all four new tests are red on main and green here.
Three things are still open after this PR, all pre-existing on main but all inside what #8932 claims to fix:
Model.ainvokenever delegates to_mock_delegate/_local_delegate; onlyModel.invokedoes (model.impl.jac ~L74). So the issue's own user surface still fails here:
glob llm = Model(model_name="mockllm", config={"outputs": ["hello world"]});
async def ask(topic: str) -> str by llm(stream=True);
sync : hello world
async: FAILED BadRequestError litellm.BadRequestError: LLM Provider NOT provided. ... model=mockllm
Same for async non-stream and local: models. The e2e test uses FakeLLM directly so it never hits this.
-
ModelPoolwithstrategy="cost-based-routing"still crashes. That branch ofmodel_call_with_streamreturns a list (collects the whole stream in a thread), and the inherited wrapper calls it on the loop thread and then hands the list tonext(). Measured here: 0 loop ticks during the stream, thenTypeError: 'list' object is not an iterator. The ModelPool scenario in the validation comment stubbedmodel_call_with_stream, so it did not see this. See inline suggestion. -
Commit b07e74f deletes
model_call_no_stream_async raises and logs AuthenticationError, which is unrelated and passes on main. Please restore it.
Small note on the write-up: the split-file explanation for why nothing caught this is not the reason. A single-file async def with yield awaited two lines later also passes jac check clean; the checker just never treats a yielding async def as an async generator function.
Happy to merge once 3 is restored, with 1 and 2 either fixed here or filed as follow-ups so #8932 is not closed as covering Model("mockllm"), local: and ModelPool in full.
…op thread Review on jaseci-labs#8955, points 2 and 3. ModelPool's cost-based-routing branch of model_call_with_stream collects the whole stream and returns a list. The wrapper called it on the event loop thread and then handed the result to next(), so that strategy blocked the loop for the whole stream and then raised TypeError: 'list' object is not an iterator. Call it via to_thread and wrap in iter(), which the sync path already tolerates because it does 'for chunk in response'. Also restores 'model_call_no_stream_async raises and logs AuthenticationError', which b07e74f removed by accident while rewriting the block around it.
…face test to a fixture The review fix moved the provider call off the loop thread, so add tests that an eager list-returning provider no longer blocks the loop and that a generator provider is still consumed lazily. The by llm(stream=True) surface test moves into a fixture. Declared at module level in test_byllm.jac it perturbed the two tests that compile a fixture with JacProgram(), taking them from pass to 'Expected a compiled Python AST module'.
…8932 tests Four draining code paths collapse to two helpers: _drain_8932 takes a stream and an optional pace, _stream_8932 drives an LLM through it, and the end-to-end test reuses the first instead of inlining a third. _ListStream8932 and _EagerStream8932 were the same stub, so the list case is the eager one with no build delay. Net 23 lines lighter, same 96 passed and the same 7 red without the fix.
Review on jaseci-labs#8955, point 1. Model('mockllm') and Model('local:...') build an inner delegate in postinit, and Model.invoke hands work to it. Model.ainvoke never did, on either branch, so the async side fell through to super.ainvoke and reached litellm with a model name no provider knows: sync : hello world async: BadRequestError ... LLM Provider NOT provided ... model=mockllm Mirror invoke's delegate check at both ainvoke call sites. Two tests cover the async streaming and no-stream paths through Model('mockllm'); both fail with that BadRequestError before this change. local: models take the same branch but are not covered by a test, since driving one needs llama-cpp and a model file.
An 'any' local widened what the checker had to resolve through the byllm type graph and surfaced 13 latent Pillow-stub errors in types.impl/media.impl.jac, a file this branch does not touch. jac-check passed at 36dbea7 and failed on both attempts at 2bd5879, which is the commit that added the local. BaseLLM | None is the accurate type: _mock_delegate is MockLLM | None and _local_delegate is BaseLLM | None.
|
Thanks — all three are addressed in this PR, and the checker correction is right. 3. Deleted test. Restored. Confirmed with 2. ModelPool cost-based-routing. Applied your suggestion. That branch returns a list, so Also a laziness test, since moving the call to a thread could have buffered generator providers: production gaps stay >0.08s under a paced consumer. 1. One gap I want visible rather than implied: On the write-up. You are right and I was wrong. The split declaration/impl files are not why this escaped; a single-file Numbers, against the upstream side of the merge: 89 matches upstream main exactly. Also worth flagging two things I broke and fixed along the way, in case they matter to you: my first CI is 24/24 green. |
kugesan1105
left a comment
There was a problem hiding this comment.
Re-validated live on d5d3908. All three items are fixed:
Model("mockllm")on the async path now reaches the delegate. Sameby llm(stream=True)program that failed before:
sync : hello world
async stream: hello world
async nostream: hello world
- ModelPool
cost-based-routingstreams and the loop stays free (29 ticks during a 0.3s stream, 0 before). - The AuthenticationError test is back.
Suite locally: 98 passed, 1 skipped, matching your count. Good from my side.
Fixes #8932.
The bug
BaseLLM.adispatch_streamingawaitsmodel_call_with_stream_asyncand then iterates the result:That is the coroutine-returning-an-async-iterator contract.
Model.model_call_with_stream_asynchonors it: noyield, it returns the provider stream. TheBaseLLMimplementation did not. Its body contained ayield, which makes calling it produce anasync_generator, and awaiting anasync_generatorraisesTypeError.Modelwas the only class that worked. Every other subclass inherits the base method and crashes.Why no checker caught it
Both shapes are legally annotated
-> AsyncGenerator[object, None]. For an async generator that annotation describes what it yields; for a coroutine it describes what awaiting it returns. Only the presence ofyieldin the body distinguishes them, and in Jac the declaration and the body live in different files. The declaration now saysAsyncIterator[object], which reads correctly for one shape only.Changes
BaseLLM.model_call_with_stream_asyncmoves its yielding loop into a nested_iterand returns it, so the method itself is a coroutine and matches both the caller andModel.MockLLMgetsadispatch_streaming. It streams from its configured outputs rather than through themodel_calllayer, so a corrected base still reachedmodel_call_with_streamand raisedNotImplementedError. The new override mirrorsdispatch_streamingthe wayadispatch_no_streamingalready mirrorsdispatch_no_streaming, usingasyncio.sleepso the mock does not block the event loop.Without change 2 the issue is only half fixed, since
MockLLMis named in it.Scope
Affected:
MockLLM,LocalLLM,ModelPooland any user-definedBaseLLMsubclass, on the async streaming path only.ainvokeroutes there forstream=Truewith no tools bound; with tools it falls toasyncio.to_thread(self.invoke, ...), the sync path, which was never broken. Reproduces identically onv0.34.17, so this is not a recent regression.Tests
Three regression tests in
test_byllm.jac:BaseLLM.model_call_with_stream_asyncis a coroutine function and not an async generator functionLocalLLMandModelPoolhaveMockLLMAll three fail before this change and pass after. The suite goes from 113 to 116 passed:
One thing worth a reviewer's eye
Declaring
MockLLM.adispatch_streamingdeepens name resolution inmockllm.impl.jacand surfaces a pre-existing type error indispatch_no_streaming, where astrliteral is passed toadd_message(message: MessageType). I annotated that localanyto hold the file at its previous clean check result without changing runtime behavior. The underlying looseness pre-dates this change and is untouched; happy to split it out if you would rather see it fixed properly.jac checkcounts are otherwise unchanged frommainper file, andllm.jacdrops from 38 errors to 37.The commit uses
--no-verify. The pre-commit hook runsjac checkper staged file and fails on pre-existing errors: a whitespace-only change to an otherwise untouchedllm.jacon cleanmainfails identically with 38 errors, 239 warnings, so the hook cannot pass on these files regardless of content.