Skip to content

fix(esc): mid-stream cancellation for OpenAI-compatible + Minimax providers - #145

Merged
ericleepi314 merged 1 commit into
mainfrom
fix/esc-cancel-openai-compatible
May 15, 2026
Merged

fix(esc): mid-stream cancellation for OpenAI-compatible + Minimax providers#145
ericleepi314 merged 1 commit into
mainfrom
fix/esc-cancel-openai-compatible

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

Summary

User-reported symptom

After #144 merged, a user testing with LiteLLM → Anthropic Claude Opus 4.7 reported ESC still took ~10s during the model's "Thinking…" phase. The call path went through openai_compatible.py (LiteLLM exposes an OpenAI-compatible API), not anthropic_provider.py, so #144's listener never registered. This PR closes that gap.

Changes

  • src/providers/openai_compatible.py — two defenses in chat_stream_response:

    • Response-close listener: registered on abort_signal via add_listener(..., once=True). Calls stream.response.close() to close the underlying httpx socket. The SDK's blocking next-chunk read raises immediately. Handles the user's exact case (long gap between chunks during extended thinking / tool_use generation).
    • In-loop abort check: if abort_signal.aborted: break at the top of each for chunk in stream: iteration. Catches the SDK-prefetched-chunks case where the listener's close lands one iteration late.
    • Register-then-recheck race-safe ordering (matches fix(esc): cancel streaming API call when abort signal trips #144's pattern).
    • Signal-state-authoritative exception translation in the except Exception block.
    • finally block detaches the listener so long-lived controllers don't accumulate listeners.
  • src/providers/minimax_provider.py — Minimax uses the anthropic SDK against its compatible endpoint, so it gets the AnthropicProvider treatment (response-close listener; no in-loop check needed because the with ... as stream: only exposes text_stream).

  • tests/test_openai_compat_abort_signal.py (new, 6 tests):

    • Pre-abort fast-path skips client.chat.completions.create (leaf-level assert_not_called())
    • Mid-stream close: synthetic 500ms stream + mid-stream trip returns within 1s, verifies stream.response.close() was called
    • Load-bearing in-loop check: passes an on_text_chunk callback, asserts seen == ["first"] not ["first", "second"]. Mutation-tested by deleting the in-loop check and watching the test fail with the exact expected message.
    • Normal-completion regression check
    • abort_signal=None legacy parity
    • Listener detachment after normal completion
  • tests/test_minimax_abort_signal.py (new, 4 tests): same shape as the Anthropic tests, with _ensure_client as the fast-path sentinel.

Test plan

  • 6 new OpenAI-compat tests pass (1 mutation-verified load-bearing)
  • 4 new Minimax tests pass
  • 15 total provider abort tests pass (5 Anthropic + 4 Minimax + 6 OpenAI-compat)
  • 4419 broader related tests pass
  • Critic subagent review: APPROVE (after fixing a stale docstring on this round and a non-load-bearing in-loop test on the prior round)
  • Manual: kick off a long prompt on the LiteLLM-proxied stack, press ESC during the model's "Thinking…" phase, should unwind in ~50ms instead of 10s

Follow-up (deferred)

Three-way duplication of the response-close-listener pattern across AnthropicProvider, MinimaxProvider, and OpenAICompatibleProvider. The three contexts differ enough (Anthropic has the watchdog + non-streaming fallback, OpenAI-compat has bare iterator + in-loop check, Minimax has with-block + get_final_message) that premature extraction would either grow the helper to a 4-knob API or leak abstraction. Will file as a separate refactor PR.

🤖 Generated with Claude Code

…viders

PR #144 added abort-signal-aware streaming for ``AnthropicProvider``,
but ``OpenAICompatibleProvider`` and ``MinimaxProvider`` got only the
pre-call fast-path. Users running through LiteLLM / GLM / OpenAI /
DeepSeek — the most common "OpenAI-compatible proxy → Claude" stack —
still saw ESC wait the full model latency before the post-API abort
check fired. Same 20+ second symptom from before #144.

Port the response-close listener pattern from #144's
AnthropicProvider:

* Register a listener on the abort signal that calls
  ``stream.response.close()`` to close the underlying HTTP socket.
  Closes interrupt the SDK's blocking next-chunk read so the iterator
  raises immediately, even when the model is in a multi-second gap
  between chunks (extended thinking, tool_use generation).
* For OpenAI-compatible providers, additionally add an in-loop
  ``if abort_signal.aborted: break`` check at the top of each
  ``for chunk in stream`` iteration. Covers the case where chunks
  arrive back-to-back fast enough that the listener's close lands
  one iteration late, or where the SDK has already prefetched
  chunks past the close point.
* Signal-state-authoritative exception translation in the
  ``except Exception`` block — different SDK versions raise
  different exception classes when the response is closed
  mid-read, so the signal is the only stable abort indicator.
* Register-then-recheck ordering closes the sub-microsecond race
  where ``_fire`` can snapshot the listener list and silently drop
  a freshly-appended listener.
* ``finally`` block detaches the listener so long-lived
  controllers (the REPL engine's, reused across many turns) don't
  accumulate dead listeners.

Minimax wraps the anthropic SDK against its compatible endpoint,
so it gets the AnthropicProvider treatment (no in-loop check — the
``with client.messages.stream(...) as stream:`` pattern only exposes
``text_stream``, not a generic iterator).

Ten regression tests pin the contract:

* ``test_openai_compat_abort_signal.py`` (6 tests) — pre-abort
  fast-path with leaf-level ``assert_not_called()``, mid-stream
  close via response.close + timing bound, **load-bearing**
  in-loop check (asserts ``on_text_chunk`` saw only "first" not
  "second" — mutation-verified by deleting the in-loop check and
  watching the test fail), normal-completion regression check,
  ``abort_signal=None`` legacy parity, listener detachment.
* ``test_minimax_abort_signal.py`` (4 tests) — same shape as
  AnthropicProvider, with ``_ensure_client`` as the fast-path
  sentinel.

Three-way duplication of the close-listener pattern (Anthropic,
Minimax, OpenAI-compat) is acknowledged. Extracting a shared helper
is left as a follow-up — the three providers' surrounding contexts
differ enough (Anthropic has the watchdog + non-streaming fallback,
OpenAI-compat has bare-iterator semantics, Minimax has the
``with``-block + ``get_final_message``) that a premature extraction
would either grow the helper to a 4-knob API or leak abstraction.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ericleepi314
ericleepi314 merged commit 0f5212a into main May 15, 2026
singlaamitesh pushed a commit to singlaamitesh/clawcodex that referenced this pull request Jul 7, 2026
…l-openai-compatible

fix(esc): mid-stream cancellation for OpenAI-compatible + Minimax providers
singlaamitesh pushed a commit to singlaamitesh/clawcodex that referenced this pull request Jul 7, 2026
…ncel

PRs agentforce314#144 and agentforce314#145 added abort-signal-aware streaming for three
providers (Anthropic, Minimax, OpenAI-compatible). The pattern across
all three is structurally identical — pre-call fast-path, register-
then-recheck listener closing the stream's response on abort, signal-
state-authoritative exception translation, listener detachment, post-
stream recheck — but the bookkeeping was inlined and triplicated.
Adding a fourth provider would have meant a fourth copy.

Extract the pattern into ``src/providers/_stream_abort.py``:

* ``StreamAbortGuard(abort_signal)`` — one per call. ``abort_signal=None``
  makes every method a no-op so providers can use it unconditionally.
* ``raise_if_pre_aborted()`` — pre-call fast-path raising ``AbortError``.
* ``raise_if_post_aborted()`` — same check at a different boundary.
* ``aborted`` property — cheap in-loop check (OpenAI-compat path).
* ``reraise_if_aborted(original_exc)`` — translate SDK exceptions to
  ``AbortError`` via ``raise ... from``; no-op if signal didn't fire.
* ``with guard.attach(stream):`` — register a listener that closes
  ``stream.response`` on abort; detach in ``__exit__``. ``__exit__``
  ALSO closes the response if ``signal.aborted`` is True at exit, as
  a safety net for the race window where ``AbortSignal._fire``
  snapshots the listener list, the consumer thread observes
  ``aborted=True``, breaks out, detaches the listener, and the
  snapshot's iteration then runs against an empty list — that path
  would otherwise leak the underlying httpx response open. The close
  is idempotent on httpx (``if not self.is_closed`` guard) so the
  double-fire path is safe.

Provider line counts:

  anthropic_provider.py    -88 lines
  minimax_provider.py      -73 lines
  openai_compatible.py    -212 lines
  _stream_abort.py        +211 (mostly docstring)

Net: providers shrink from 374 lines of abort bookkeeping to 110
(SDK-specific iteration shape only). The provider-level tests from
agentforce314#144 and agentforce314#145 (15 tests across three files) pass unmodified, proving
behavior preservation end-to-end.

Seventeen new unit tests in ``tests/test_stream_abort_guard.py`` pin
the helper's contract directly: pre/post fast-paths, ``aborted``
property semantics, ``attach`` lifecycle (register, detach, race-
recovery via register-then-recheck), close-failure tolerance, no-
response-attribute graceful degradation, ``reraise_if_aborted``
translation + cause chaining + no-op, and — critically — the
``__exit__`` close-on-abort safety net. The safety-net test
mutation-verified: removing the ``__exit__`` close branch makes the
test fail with the exact expected assertion.

The OpenAI-compat in-loop test gains a ``stream.response.close.called``
assertion to pin the close at the provider level too.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
singlaamitesh pushed a commit to singlaamitesh/clawcodex that referenced this pull request Jul 7, 2026
PRs agentforce314#145 / agentforce314#146 added mid-stream ESC cancellation for OpenAI-compatible
providers via a ``stream.response.close()`` listener. That works for
direct connections, but a user on LiteLLM-proxied Claude Opus 4.7
reported it still hangs indefinitely — ``response.close()`` from the
listener thread doesn't actually terminate the SDK's blocking socket
read in that configuration, so the iterator keeps draining bytes and
the agent loop's cancel boundary never sees the abort.

Two earlier attempts confirmed the diagnosis:

* Adding ``signal.pthread_kill(main_thread, SIGINT)`` to the listener
  DID unblock the iteration but propagated ``KeyboardInterrupt``
  uncaught past every layer of exception handling, exiting the entire
  ``python -m src.cli`` process. The signal hits the main thread at
  the next bytecode boundary, which is rarely inside our narrow
  ``except KeyboardInterrupt`` window.
* The TypeScript reference at
  ``typescript/src/services/api/openaiShim.ts`` works because
  JavaScript's native ``fetch + AbortSignal`` integration makes
  ``reader.read()`` reject with ``AbortError`` when the signal trips.
  Python's sync ``httpx`` has no equivalent.

Fix: decouple the main thread's response time from the SDK's
cooperation. The SDK iteration runs on a daemon worker thread that
pushes chunks into a ``queue.Queue``. The main thread polls with a
100 ms timeout and re-checks ``guard.aborted`` between ticks. On
abort, raise ``AbortError`` immediately and orphan the worker — it
dies when the underlying connection eventually closes. The cost is
some wasted bandwidth on the orphaned read; the benefit is that ESC
unwinds within ~100 ms regardless of LiteLLM / httpx / chunked-
transfer behavior.

Specifics:

* Worker pushes each chunk into the queue, then a ``_DONE`` sentinel
  in a ``finally`` so the main thread can break the loop on either
  normal exhaustion or worker exception.
* Worker catches ``BaseException`` so ``KeyboardInterrupt`` /
  ``SystemExit`` from the worker thread are routed back to the
  main thread for re-raise rather than silently dropped.
* Main-thread loop checks abort in the ``Queue.Empty`` branch (bounds
  ESC latency at one tick) AND after processing each chunk (preserves
  the chunk we just received, matching the in-loop-check semantics
  pinned by the existing chunk-list regression test).
* Abort sites call ``guard.raise_if_post_aborted()`` instead of
  hardcoding ``AbortError("user_interrupt")`` so a non-default abort
  reason on the signal (future ``"rate_limit_backoff"`` etc.) is
  preserved.
* Updated method docstring explains the worker indirection and the
  Python-vs-JS-fetch contrast so the next engineer touching this
  doesn't "simplify" the queue away.

Two new regression tests:

* ``test_abort_unwinds_promptly_even_when_iterator_never_returns``
  uses a ``_StuckStream`` whose ``__iter__`` blocks on a never-set
  Event. Without the worker+queue, the main thread would block on
  ``next(stream)`` forever; mutation-verified by reverting to the
  pre-fix code and watching pytest hang past an 8-second gtimeout.
* ``test_normal_completion_still_captures_final_usage`` pins the
  drain invariant: the main thread must process the final
  empty-choices/populated-usage chunk before breaking on ``_DONE``,
  otherwise OpenAI's ``stream_options.include_usage=True`` token
  counts would silently regress.

Anthropic and Minimax providers are unchanged. They use the
``anthropic`` SDK whose ``client.messages.stream(...)`` works reliably
against direct connections, and no user has reported the LiteLLM-style
proxy buffering against them. If that changes, the same worker+queue
pattern ports cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.

1 participant