Make OpenAI-compat and Ollama engine streaming truly async (unblock the event loop)#626
Conversation
…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.
|
This is a really strong PR — thank you! The core diagnosis (sync 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 ( 2. Deduplicated the mirrored helpers (also 3. Tightened the context-length markers (also 4. Fixed the same bug in the LiteLLM engine ( 5. Wired up 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. 🚀 |
# Conflicts: # src/openjarvis/agents/hybrid/mini_swe_agent.py
ElliotSlusky
left a comment
There was a problem hiding this comment.
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!
|
@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 |
Empty commit per review request - all follow-up commits reviewed and verified, this just resets the stale-approval branch protection check.
ElliotSlusky
left a comment
There was a problem hiding this comment.
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? |
Problem
The OpenAI-compatible and Ollama engines expose
stream()/stream_full()asasync def, but internally they iterate a synchronoushttpx.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:timeoutwas 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:
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 byasync with, keeping the async lifecycle self-contained.timeoutis carried into the async client, so a wedged read fails at the bound (ReadTimeout) instead of hanging.EngineContextLengthError(subclass ofEngineConnectionError, 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.httpx.RemoteProtocolError/httpx.ReadError(server dying between tokens) are mapped toEngineConnectionErroralongsideConnectError/TimeoutException. The except clause is kept deliberately narrow soasyncio.CancelledError/GeneratorExit(notTransportErrorsubclasses) keep propagating for correct cancellation.not resp.is_successcovers 3xx as well as 4xx/5xx. With redirects off (the default) an unexpected 3xx previously fell through toaiter_lines()and surfaced as a silent empty stream; it now raises a clean engine error. The short error body isaread()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:EngineConnectionError,EngineContextLengthError).