Skip to content

Make OpenAI-compat and Ollama engine streaming truly async (unblock the event loop)#626

Merged
jonsaadfalcon merged 10 commits into
open-jarvis:mainfrom
talyaseen:fix/async-streaming
Jul 6, 2026
Merged

Make OpenAI-compat and Ollama engine streaming truly async (unblock the event loop)#626
jonsaadfalcon merged 10 commits into
open-jarvis:mainfrom
talyaseen:fix/async-streaming

Conversation

@talyaseen

Copy link
Copy Markdown
Contributor

Problem

The OpenAI-compatible and Ollama engines expose stream() / stream_full() as async def, but internally they iterate a synchronous httpx.Client.iter_lines(). Because the sync client blocks the thread on every inter-token read, each of those blocking reads stalls the single asyncio event loop:

  • Concurrent chat requests serialize instead of interleaving — while one stream waits between tokens, no other request can make progress.
  • A single wedged upstream read (slow or hung model server) freezes the whole API, not just the one request.
  • The configured request timeout was never applied to the streaming reads, so a hung read could hang indefinitely.

Fix

Convert both engines' streaming paths to a genuinely non-blocking implementation:

  • Per-call httpx.AsyncClient + aiter_lines() — inter-token reads now yield to the event loop, so concurrent streams interleave and one slow read no longer blocks the others. The client is opened/closed by async with, keeping the async lifecycle self-contained.
  • Bounded read timeout — the engine's configured timeout is carried into the async client, so a wedged read fails at the bound (ReadTimeout) instead of hanging.
  • Typed context-overflow error — a new EngineContextLengthError (subclass of EngineConnectionError, so existing handlers still catch it) is raised when an upstream 400 body reads like a context-window overflow, letting callers show a clear "conversation too long" message. Ollama has no such signal, so it has no context branch.
  • Mid-stream transport errors mappedhttpx.RemoteProtocolError / httpx.ReadError (server dying between tokens) are mapped to EngineConnectionError alongside ConnectError / TimeoutException. The except clause is kept deliberately narrow so asyncio.CancelledError / GeneratorExit (not TransportError subclasses) keep propagating for correct cancellation.
  • Non-2xx handled explicitlynot resp.is_success covers 3xx as well as 4xx/5xx. With redirects off (the default) an unexpected 3xx previously fell through to aiter_lines() and surfaced as a silent empty stream; it now raises a clean engine error. The short error body is aread() before touching .text.

No public API change — signatures and return types are unchanged; only the internal transport becomes async.

Tests

All new tests use httpx.MockTransport (no real server), covering:

  • the async streaming path yields tokens correctly,
  • the configured timeout is applied to the async client,
  • a mid-stream disconnect maps to EngineConnectionError,
  • HTTP-error mapping (incl. 3xx and context-length 400 -> EngineContextLengthError).

Talal Alyaseen and others added 3 commits July 2, 2026 21:48
…eout

BC2: stream()/stream_full() were `async def` but iterated a SYNC httpx.Client
via iter_lines(), so every inter-token wait blocked the single uvicorn worker -
concurrent chats serialized and one wedged upstream read could freeze the whole
API. Switch both to a per-call httpx.AsyncClient + aiter_lines(), which never
blocks the event loop and applies the configured request timeout to inter-token
reads (a wedged read now fails at the timeout, not the httpx default).

Also map upstream streaming HTTP errors instead of re-raising them raw: a 400
context-window overflow becomes EngineContextLengthError (carries an
is_context_length_error marker so callers can surface a clear message); other
statuses become EngineConnectionError with detail. A per-call `_async_transport`
seam lets tests exercise the real async path with an httpx.MockTransport (no
server). Existing stream tests moved off the sync-client MagicMock to MockTransport.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…c fix

_openai_compat.py:
- treat non-2xx (incl 3xx redirects) as an error in both stream paths via
  `if not resp.is_success` so an unexpected redirect no longer falls through
  to aiter_lines as a silent EMPTY stream.
- annotate _raise_stream_http_error -> NoReturn for control-flow tracking.
- widen the stream/stream_full except tuples with httpx.RemoteProtocolError +
  ReadError so a server dying mid-stream surfaces as EngineConnectionError
  instead of propagating raw (kept narrow; CancelledError/GeneratorExit still
  propagate for correct cancellation).

ollama.py: same blocking-bug fix as _openai_compat. stream() and _run_stream()
were async def but iterated a SYNC httpx.Client + iter_lines, blocking the
single event loop between tokens. Now use a per-call httpx.AsyncClient +
aiter_lines with the configured timeout wired in, and the same narrow
mid-stream except mapping. Public interface and generate/list_models/health
unchanged; no context-length detection (Ollama exposes no clear equivalent
signal).

tests: harden the two weak timeout tests to prove the timeout is APPLIED to the
request (request.extensions["timeout"]["read"] + client.timeout), add mid-stream
RemoteProtocolError -> EngineConnectionError pins for both engines, and add
async-path pins for ollama (sync-client bomb). Guard the optional respx import
so the MockTransport-based pins run without respx installed (respx tests skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Ollama stream() and _run_stream() paths used raise_for_status(),
leaking a raw httpx.HTTPStatusError on a non-success streaming response.
This diverged from the OpenAI-compat engine, which maps via
_raise_stream_http_error to EngineConnectionError.

Replace raise_for_status() with an explicit 'if not resp.is_success'
check (covers 3xx as well as 4xx/5xx) that reads the body and raises
EngineConnectionError carrying the status + short message, mirroring the
compat helper (minus the context-length branch, which Ollama has no
signal for). A 3xx redirect no longer falls through to a silent empty
stream. The 400-with-tools -> retry-without-tools behavior is preserved
exactly; only other non-2xx responses map to the connection error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, tighten context markers

Extract the async-client factory, non-2xx error mapping, and transport-error
set that _openai_compat.py and ollama.py each hand-rolled into a shared
AsyncHTTPEngineMixin (engine/_http_async.py). Three behavior fixes ride along:

- Streams now reuse one AsyncClient per event loop instead of building a
  fresh client per call, restoring the connection pooling the old shared
  sync client provided (a per-turn TCP+TLS handshake for remote hosts).
  The client is cached per loop so CLI flows that asyncio.run() per turn
  still get a valid client; close() tears it down.
- EngineContextLengthError moves next to EngineConnectionError in
  engine/_base.py (re-exported from _openai_compat and the package root)
  alongside a shared looks_like_context_length_error() helper.
- The context-length markers are now all anchored on "context": bare
  "please reduce" / "too many tokens" matched unrelated 400 bodies
  (max_tokens validation, rate limits) and mislabeled them as
  "conversation too long". Regression test included.
LiteLLMEngine.stream was an async def calling the synchronous
litellm.completion(stream=True) and iterating its sync generator with a
plain for loop - the same event-loop-blocking bug this branch fixes for
the httpx engines, reachable through the same MultiEngine/server path.
Switch to litellm.acompletion + async for.
…paths

The new typed error was raised but nothing consumed it, so users still got
generic connection-error handling for too-long conversations:

- cli/ask.py: show the context-length message directly instead of
  "Engine error" + the check-your-server hint (misleading for this case).
- agents/errors.py: classify context-window overflows as fatal - they are
  deterministic, so retrying with backoff burns ~30s on guaranteed
  failures. Uses the marker attribute plus the shared text heuristic.
- server/stream_bridge.py + agents/hybrid/mini_swe_agent.py: replace the
  two divergent local context-length string checks with the shared
  looks_like_context_length_error() (the bridge's own check did not even
  match the new error's "context window" wording).

Also fix the remaining event-loop blockers on the non-streaming paths:
routes.py runs _handle_agent/_handle_direct (sync agent.run/engine.generate)
via asyncio.to_thread, and api_routes.py does the same for the websocket
generate() fallbacks - previously a slow non-streaming request stalled
every concurrent request, exactly the bug this branch fixes for streaming.
@ElliotSlusky

Copy link
Copy Markdown
Collaborator

This is a really strong PR — thank you! The core diagnosis (sync iter_lines() inside async def stalling the whole event loop) is exactly right, the fail-narrow exception handling with cancellation-safety comments is genuinely careful work, and the MockTransport-based tests are a pleasure to read: the "hardened" timeout pins that verify the timeout reached the wire (not just the engine attribute) are the kind of tests that keep this fixed forever.

I reviewed it in depth, and since maintainer edits were enabled I pushed three commits on top rather than bouncing it back — I hope that's welcome; happy to talk through any of it. What they address:

1. Restored connection pooling (ace9cc71). The per-call AsyncClient made every conversation turn pay a fresh TCP+TLS handshake against remote hosts, where the old shared sync client had pooled keep-alive connections. AsyncClient is safe to share across coroutines, so the shared plumbing now caches one client per event loop (per-loop because pooled connections die with their loop — CLI flows that asyncio.run() per turn still work) and close() tears it down. Your _async_transport test seam and all existing tests carry over unchanged.

2. Deduplicated the mirrored helpers (also ace9cc71). _make_async_client / _raise_stream_http_error / the transport-error tuple were near-identical between the two engines (your docstrings even said "Mirrors…" 🙂). They now live in one AsyncHTTPEngineMixin in engine/_http_async.py, so the third engine that needs this (see below) gets it for free.

3. Tightened the context-length markers (also ace9cc71). Bare "please reduce" / "too many tokens" also match unrelated 400 bodies — e.g. "please reduce max_tokens and retry" or a rate-limit "Too many requests, please reduce your request rate" — which would show users "conversation too long, start a new chat" for errors that have nothing to do with conversation length. The markers are now all anchored on "context" (your vLLM-style test still passes via "maximum context length"), with a regression test for the false-positive case.

4. Fixed the same bug in the LiteLLM engine (8f21534b). LiteLLMEngine.stream had the identical pattern — async def calling the sync litellm.completion(stream=True) with a plain for loop — and it's reachable through the same server path. Switched to acompletion + async for.

5. Wired up EngineContextLengthError and unblocked the non-streaming paths (8a3bbcb5). The new error type was raised but nothing consumed it: cli/ask.py caught it as a base EngineConnectionError and told users to check whether their server was running, the agent retry classifier treated it as retryable (burning ~30s of backoff on an error that deterministically recurs), and stream_bridge.py's own string check didn't match the new message's "context window" wording. All three now recognize it (via one shared looks_like_context_length_error() helper, replacing three divergent copies across the codebase). And since your PR description correctly called out that a wedged upstream "freezes the whole API": the non-streaming paths had the same bug — routes.py and the websocket fallbacks called sync engine.generate() / agent.run() directly on the event loop — now dispatched via asyncio.to_thread.

Test results: engine + server + agents suites green locally (495 passed; the only failures in the wider suite are the known pre-existing Rust-extension ones, unchanged by this branch).

Thanks again — this PR fixed a real operational pain point and the foundation it laid made the follow-ups easy to build on. 🚀

@ElliotSlusky ElliotSlusky left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed in depth (see comment above for the full rundown): the core async conversion is correct and well-tested, and the follow-up commits on this branch address everything the review surfaced — connection-pool reuse, the LiteLLM engine's identical blocking bug, consuming the new context-length error end-to-end, tightened error markers, and the non-streaming server paths. All 12 checks green. Thanks @talyaseen!

@ElliotSlusky

Copy link
Copy Markdown
Collaborator

@talyaseen one last mechanical step to land this: the repo's branch rules require the most recent push to be approved by someone other than its pusher — and since the review follow-up commits came from me, my approval can't certify my own push. 🙂

Could you push any trivial update to your fix/async-streaming branch (an empty commit is fine: git commit --allow-empty -m "chore: trigger review" && git push, or just click Update branch on this page if it's offered)? The moment you do, I'll re-approve and merge — everything is already green (all 12 checks passing). Thanks!

Empty commit per review request - all follow-up commits reviewed and
verified, this just resets the stale-approval branch protection check.

@ElliotSlusky ElliotSlusky left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All checks green on the latest push — merging. Thanks again for the quick turnaround, @talyaseen!

@talyaseen

Copy link
Copy Markdown
Contributor Author

All checks green on the latest push — merging. Thanks again for the quick turnaround, @talyaseen!

My Pleasure. I'm guessing the other code owner's need to review as well?

@jonsaadfalcon
jonsaadfalcon merged commit 0b14011 into open-jarvis:main Jul 6, 2026
12 checks passed
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.

3 participants