Skip to content

feat: experimental streaming retry — call/stream drivers + model-layer transform - #55

Closed
zirkelc wants to merge 28 commits into
mainfrom
feat/retryable-stream-text
Closed

feat: experimental streaming retry — call/stream drivers + model-layer transform#55
zirkelc wants to merge 28 commits into
mainfrom
feat/retryable-stream-text

Conversation

@zirkelc

@zirkelc zirkelc commented Jun 4, 2026

Copy link
Copy Markdown
Owner

Summary

Adds three experimental entry points that extend the existing model-layer createRetryable to streaming, addressing #50 (streamText-level deadlines) and response-encoded "soft" failures (e.g. content-filter refusals). All are opt-in under ai-retry/experimental/*; the stable API is untouched.

  • ai-retry/experimental/callcreateRetryableCall: a generic, dependency-free retry-loop driver. Hands each attempt its model plus a fresh per-attempt deadline as a number (attempt.timeout), passes the caller's abortSignal through unchanged, runs an opaque async fn, and fails over when fn throws (reusing findRetryModel, backoff, sticky/reset, telemetry). The fn applies the timeout however its call takes one — generateText({ timeout }) natively, or an AbortSignal.timeout(...) built from it. An already-aborted caller signal is a hard stop, not a retry.
  • ai-retry/experimental/streamcreateRetryableStream: a streamText/streamObject adapter built on the driver, recovering errors and deadline aborts before the first content part.
  • ai-retry/experimental/transformexperimental_transform on createRetryable + refusalTransform: convert a soft failure carried in the response into a real error the existing retry conditions handle, at the model layer.

createRetryableStream — streamText-level deadlines (#50)

Wrapping a model with createRetryable retries below streamText (at doStream), which cannot recover a streamText-level timeout.* deadline: once the call's master signal aborts, streamText finalizes its stream as aborted and discards whatever a lower retry produces (#50). createRetryableStream re-runs the whole call with the next model, so the next attempt gets a fresh deadline. (An inbound abortSignal is treated as a genuine caller cancellation — a hard stop, not failed over; per-attempt deadlines come from streamText's own timeout.*.)

  • Drop-in for streamText (args minus model); resolves once an attempt commits (first content part), detected by pumping a teed branch up to the first content/error/abort then cancelling — the caller drives the body (stream, toUIMessageStreamResponse(), …) with back-pressure past commit preserved.
  • After the first content part an attempt is committed and cannot fail over (re-running would duplicate output).
  • Reads the result's part stream via stream (AI SDK v7), falling back to fullStream (streamObject / pre-v7).
  • A streamText-level deadline aborts an internal controller, not the attempt's signal, so its abort part carries only a serialized reason string. The wrapper reconstructs a typed error from it (restoring name), so timeout() / aborted() / error.message() match a firstChunkMs/stepMs/totalMs deadline the same as a thrown error.

refusalTransform — soft failures at the model layer

A natural-language refusal ("I'm sorry, but I cannot assist…") streams as ordinary text-delta parts finishing stop — no error, no content-filter finish reason — so nothing above can see it as a failure.

experimental_transform pipes each attempt's provider stream inside doStream (below streamText). refusalTransform(phrases) buffers the leading text-delta parts and, on matching a phrase, emits an error part (a RefusalError) before any content is forwarded. Because it lands on the model layer's normal pre-content retry path, an error-based condition recovers it — under plain streamText, no call-layer wrapper:

const model = createRetryable({
  model: primary,
  retries: [error.isInstance(RefusalError).switch({ model: fallback })],
  experimental_transform: refusalTransform(["I'm sorry, but I cannot assist"]),
});
streamText({ model, prompt }); // refusal → fail over; real answer → committed

On divergence it flushes the held deltas and forwards the rest untouched; buffering is bounded by the longest phrase. A prior call-layer commit gate was explored and removed — the transform subsumes it and recovers a layer lower, through the existing error machinery.

Alternative: recover a refusal via a wrapLanguageModel middleware (example only)

refusalTransform emits an error part, which the model layer treats as a failed attempt. A standard AI SDK middleware does the same with no ai-retry-specific option — shipped as examples/middleware-refusal-recovery.ts (not in the lib). Its wrapStream sits at the model boundary and holds back every leading part — stream-start, text-start, metadata, and the text deltas — until the buffered text decides:

  • Matchcontroller.error(new RefusalError(...)) with nothing emitted yet. Unlike enqueuing an error part, controller.error makes pipeThrough cancel the source, so the upstream request is torn down (no wasted tokens).
  • Diverge / non-text content → flush everything held and forward the rest untouched. The commit trigger is an allowlist of the three pre-text part types, so a tool-call/reasoning stream is never buffered to the end and an unknown part fails safe (commit, never a false refusal).

createRetryableModel catches the error before content, and error.isInstance(RefusalError).switch({ model }) fails over under plain streamText.

AI SDK upgrade + full timeout coverage

Upgrades to the latest AI SDK (ai 7.0.35, @ai-sdk/* latest, ai-test-kit 3.0.1). 7.0.35 narrows chunkMs to gaps between content chunks and adds firstChunkMs for the pre-content window. The stream tests now cover every timeout option:

option fires recoverable at the call layer
firstChunkMs no first content chunk within X of step start
stepMs step exceeds X
totalMs total exceeds X
chunkMs gap between content chunks ❌ (post-commit, truncates)
toolMs tool execution exceeds X ❌ (post-commit)

Scope & composition

  • The stream layer is error/deadline only. Result-based switches (content-filter finish, schema) recover best below streamText — pass a createRetryable(...) as the model; the layers compose without blocking each other (tested).
  • The transform is streaming-only (doStream); a generateText/doGenerate refusal would use the result-based path.
  • A retry's low-level options.prompt is ignored at the stream layer (the prompt is on the call args); every other override is forwarded.

Tests

  • experimental/call: pure unit tests of the loop, organized to the repo convention (success / retries / disabled / onError / onRetry+overrides / attempt / RetryableOptions / RetryError / reset).
  • experimental/stream: unit (synthetic streams) + streamText integration suites, including the timeouts section above, abort reconstruction, and composition with a retryable base model.
  • experimental/transform: refusalTransform unit (TransformStream) + createRetryable integration (recovers under plain streamText, no false retry, surfaces on no-match).
  • Full suite green; tsc, oxlint, build, publint, attw all clean. Runnable examples for each path under examples/, including retryable-call-timeout-switch.ts and retryable-stream-timeout-switch.ts (switch models on a timeout) and the wrapLanguageModel middleware alternative above.

…ines

Adds two experimental APIs that complement the model-layer createRetryable:

- experimental/call — createRetryableCall, a generic, dependency-free
  retry-loop driver that selects the model and a fresh per-attempt deadline
  for each attempt and runs an opaque async fn, failing over on throw.

- experimental/stream-text — createRetryableStreamText, a streamText adapter
  built on the driver. It re-runs the whole streamText call with the next
  model when an attempt fails before producing content, which is the only
  place a streamText-level deadline (timeout.chunkMs/stepMs/totalMs or an
  inbound abortSignal) can fail over: once the master signal aborts,
  streamText finalizes its stream as aborted and discards any lower retry's
  output (issue #50). Commit is detected by pumping a teed branch up to the
  first content part, then the caller drives the body; the result is a
  fully tool-typed StreamTextResult.

Also exports the internal resolveAbortSignal helper for reuse.
@pkg-pr-new

pkg-pr-new Bot commented Jun 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

pnpm add https://pkg.pr.new/zirkelc/ai-retry@55

commit: f8bc1ac

zirkelc added 16 commits June 5, 2026 09:07
…eRetryableStream

Replace the streamText callback wiring (onChunk/onError/onAbort/onFinish) with
reading the result's fullStream: classify each part as content (commit),
error/abort (fail over), or preamble. A streamText-level deadline surfaces as a
readable `abort` part, so detection no longer depends on which callback fired.

Extract this into a new generic primitive, createRetryableStream
(experimental/stream), that works with any result exposing a re-readable
fullStream — decoupled from the streamText import — with an overridable part
classifier (default: the AI SDK fullStream protocol). createRetryableStreamText
becomes a thin typed wrapper over it, preserving the tool generics.

streamText callbacks now pass through unchanged (fire per attempt, including
recovered ones); onError defaults to a no-op so recovered attempts are not
console.error'd by streamText's default logger. The retry-level onError/onRetry
hooks remain the way to observe the final outcome.
…reateRetryableStream

Drop the streamText drop-in wrapper and its dedicated export; the generic
createRetryableStream is now the single entry point. The streamText
integration coverage (error-based retries, deadlines, deferred consumption,
callbacks, model-layer contrast) moves into create-retryable-stream.test.ts
behind an inline streamText glue.
Drop the parallel STREAM_TEXT_CONTENT_TYPES set in favor of the existing
isStreamContentPart guard, which mirrors the AI SDK onChunk content-part set.
This drops the stray 'file' type the set had added, so call-level and
model-level retries now commit at exactly the same boundary.
Drop the configurable classifyPart option and the StreamPartClassification/
ClassifyStreamPart/classifyStreamTextPart indirection; detectStreamCommit now
inlines the fullStream protocol checks directly. createRetryableStream is no
longer parameterized by a classifier.
…ll level

Bring createRetryableStream to parity with the model-layer retryable for
result-based conditions. A finish part seen before any content (e.g. a
content-filter reason with no output) is now evaluated against the configured
retryables as a result attempt, instead of resolving as a clean empty commit.

- detectStreamCommit returns the pre-content finish reason
- createRetryableCall gains a result-attempt path via a ResultRetry signal:
  a matching function retryable fails over, no match returns the result
  terminally (no onError, mirroring the model layer)
- plain fallback models are skipped for result attempts (findRetryModel
  already enforces this), so only contentFilterTriggered-style functions match

Schema-mismatch needs post-commit text and cannot apply to a stream.
…dSignal

Collapse two blocks duplicated across all five retry loops into shared
internal helpers:
- resolveBackoffDelay(retryModel, attempts): the countModelAttempts +
  calculateExponentialBackoff block (8 copies)
- retryDiesOnAbortedSignal(inboundSignal, retryModel): the aborted-signal +
  no-fresh-timeout guard (7 copies)

No behavior change; existing loop tests plus dedicated unit tests cover them.
First step toward sharing more of the retry-loop logic between the model
wrappers and the generic call driver.
… loops

The error-evaluation logic (build error attempt, notify onError, findRetryModel,
compute finalError) was duplicated across the language/image/embedding model
wrappers and the call driver. Extract a model-agnostic evaluateError<MODEL>
free function; each loop's handleError becomes a thin adapter (LM returns
finalError, image/embedding throw it, call driver inlines).

No behavior change. finalError is undefined on a match, the original error on
the first attempt, a RetryError wrapping all attempts thereafter.
The call/stream layer exists for issue #50 — deadlines and pre-content errors
that streamText finalizes as aborted. Result-based conditions (content-filter
finish reason, schema mismatch) don't have that problem: they recover below
streamText at the model layer and compose (pass a createRetryable(...) as the
model). Handling them here required a synthetic-result wrapper (ResultRetry)
and a result-attempt branch that scoped the otherwise error-only driver.

Remove all of it:
- ResultRetry type + isResultRetry guard, the result-attempt branch, and the
  disabled-path catch in createRetryableCall (attempts revert to error-only)
- the finish-reason extraction in detectStreamCommit (back to Promise<void>)
- the ResultRetry throw in createRetryableStream

The error-based content-filter path (APICallError) still works and is kept.
Result-based stays at the model layer, where it already worked.
Removing the result-based path left nothing in the driver's logic bound to
LanguageModel, and resolveModel/resolveAbortSignal are already generic. Thread
MODEL extends LanguageModel | EmbeddingModel | ImageModel through
RetryCallAttempt, RetryCall, RetryableCallOptions, and the loop, defaulting to
LanguageModel so the unparametrized aliases (used by createRetryableStream)
stay language-model-shaped.

createRetryableStream remains LanguageModel-only (it only serves streamText).
The ResolvedModel<MODEL> vs MODEL conditional gap is bridged with the same
casts evaluateError/findRetryModel already use. Added a .test-d.ts asserting
the LM default plus explicit embedding/image instantiation.
- Removed redundant contentFilterError definition from create-retryable-stream.test.ts and moved it to test-utils.ts for better organization.
- Introduced contentFilterStreamChunks in test-utils.ts to streamline the creation of test scenarios involving content-filter finishes.
- Updated create-retryable-stream.test.ts to utilize the new contentFilterStreamChunks and contentFilterError for improved clarity and maintainability.
- Enhanced test cases to cover various scenarios including pre-content errors, retries, and content-filter finishes, ensuring robust error handling and retry logic.
Integrate main's restructure (experimental/* promoted to top-level
model modules, retryables→conditions), onFailure callback, and the
AI SDK v7 / ai-test-kit migration into the stream-text retry work.

Conflict resolution:
- Keep the branch's evaluateError/resolveBackoffDelay/retryDiesOnAbortedSignal
  refactor; thread main's family-aware gateway resolver through evaluateError
  and re-add onFailure/emitFailure to the embedding/image/language wrappers.
- package.json: union of the new experimental/call + experimental/stream
  exports with main's renamed model/conditions exports.
- test-utils: keep main's ai-test-kit base, restore the branch-only error
  helpers the new call/stream tests need.
- Migrate the new call/stream/helper tests from the old mock API to
  ai-test-kit (MockLanguageModel.from, raw stream-part arrays).
When doStream's read loop threw (e.g. undici bodyTimeout or a connection
reset surfaced via controller.error) after content had already been
forwarded downstream, the catch block fell through to the retry logic and
re-streamed from a fallback model, duplicating output.

Guard the catch path with the same isStreaming check the error-part path
already uses: once past the commit point, surface the error as a stream
error part and close instead of retrying. Retry stays possible only before
the first content part.

Adds a failing-first test covering a stream that emits content then errors.
Runnable scripts exploring streaming retry behavior:
- fetch-intercept: real OpenAI with a fetch that cuts the SSE mid-stream
- offline-sim: deterministic streaming retry, no API key
- timeout-sim: reusable read-timeout fetch wrapper; recover vs truncate cases
- matrix: createRetryableModel/Stream/both across timeout and error cases
- repro-firstchunk-timeout: shows chunkMs never fires pre-content (vercel/ai#16538)
…dopt v7 `stream`

Extend createRetryableStream's call-level detection with two recoverable cases
and align with the AI SDK v7 stream property:

- Deadlines: reconstruct a typed error from an `abort` part's serialized
  `reason` when the attempt's own signal did not fire (a streamText-internal
  chunkMs/stepMs/totalMs controller), restoring `name` so `timeout()` /
  `aborted()` / `error.message()` match. A stepMs stall before first content
  now fails over instead of throwing a generic `Error('stream aborted')`.

- Content-filter refusals: add an optional CommitGate to detectStreamCommit and
  a `refusalGate(phrases)` helper. A natural-language refusal ("I'm sorry, but
  I cannot assist...") streams as text-deltas with finishReason `stop`, so the
  default first-delta commit locks it in. The gate buffers leading text, waits
  while it is a prefix of a known phrase, throws RefusalError once it matches,
  and commits once it diverges. Wired through the `commitGate` option.

- v7 `stream`: read the result's part stream from `stream` (streamText v7),
  falling back to `fullStream` (streamObject / pre-v7). `fullStream` is
  deprecated in v7.

Adds unit tests for refusalGate, integration tests nesting a retryable model as
the base model (the two retry layers compose without blocking), and repro/sim
examples for both cases.
…tion

Reorganize the two experimental test files (which grew ad-hoc during
implementation) to mirror the describe hierarchy of
retryable-language-model.test.ts:

- create-retryable-call.test.ts: success / retries / disabled / onError /
  onRetry(overrides) / attempt / RetryableOptions(maxAttempts, options,
  providerOptions, delay, timeout) / RetryError / reset.
- create-retryable-stream.test.ts: split into two top suites — the
  createRetryableStream unit suite (synthetic stream results) and the
  streamText integration suite (real streamText) — folding the scattered
  integration blocks into one, with a `composition with a retryable base
  model` subsection.

Fill coverage gaps: providerOptions and delay/backoffFactor/reset variants at
the call layer; abort-reason reconstruction for AbortError and message-match, a
custom onRefusal error, and refusalGate end-to-end through real streamText at
the stream layer.

Use ai-test-kit primitives: build model preambles with Language.streamStart/
streamText/streamError and wrap synthetic part arrays with Streams.from;
consolidate the two stall models into one parameterized helper.
…ommit gate

Move refusal (and any response-encoded "soft failure") recovery from the call
layer to the model layer, and upgrade the AI SDK to cover the full timeout set.

- experimental_transform: a new option on the retryable model
  (createRetryable). A LanguageModelStreamTransform pipes each attempt's
  provider stream inside doStream (below streamText); an `error` part it emits
  before content is handled by the existing pre-content retry path, so
  error-based conditions fail over from it. Applied fresh per attempt.
- refusalTransform(phrases): the built-in transform — buffers leading
  text-delta parts, emits a RefusalError on match (drops the refusal text),
  holds on a prefix, flushes on divergence. Exported from
  ai-retry/experimental/transform with RefusalError/RefusalOptions. Works under
  plain streamText, no call-layer wrapper.
- Remove the call-layer commit gate (commitGate/CommitGate/refusalGate): the
  transform subsumes it and, unlike a streamText transform, recovers at the
  model layer through the existing error machinery. detectStreamCommit reverts
  to error/abort/content detection only.
- Upgrade AI SDK to latest (ai 7.0.35, @ai-sdk/* latest, ai-test-kit 3.0.1).
  7.0.35 narrows chunkMs to gaps *between* content chunks and adds firstChunkMs
  for the pre-content window.
- Stream tests: a `timeouts` section covering every option — firstChunkMs /
  stepMs / totalMs recover a pre-content stall; chunkMs and toolMs fire after
  the commit point and do not. Fix a stale gateway id in the type test.

Adds refusalTransform unit + createRetryable integration tests, and
model-layer + streamText-level probe examples.
@zirkelc zirkelc changed the title feat: add createRetryableStreamText to recover streamText-level deadlines feat: experimental streaming retry — call/stream drivers + model-layer transform Jul 23, 2026
zirkelc added 11 commits July 23, 2026 16:15
… tests

- Replace hand-rolled `error`/`tool-call` part literals with `Language.streamError`
  / `Language.toolCall`; keep `text-delta`/`abort` literals (no streamText-level
  builder) and document why `streamOf` is not `Language.streamResult()`.
- Swap deprecated APIs for their replacements: `createRetryable` ->
  `createRetryableModel`; `contentFilterTriggered` -> `finishReason(...)` (finish
  case) / `error.message(...)` (error-code case); `result.toUIMessageStreamResponse`
  -> `createUIMessageStreamResponse` + `toUIMessageStream`.
A `wrapStream` middleware buffers leading text-delta parts and, on matching a
refusal phrase, calls `controller.error(new RefusalError(...))` — which stops the
source (pipeThrough cancels it) and is recoverable by the model layer's existing
error conditions under plain streamText. No ai-retry-specific option needed.
createRetryableCall now surfaces the per-attempt deadline as a number
(attempt.timeout) and passes the caller's abortSignal through unchanged,
instead of composing the timeout into the signal. The call function applies
the timeout natively (generateText({ timeout })) and forwards the signal only
for genuine cancellation; an already-aborted caller signal always stops the
loop.

Also address review on the timeout examples: use attempt.timeout in the call
example, note any pre-content deadline recovers in the stream example, and drop
the takeaway lines.
…uageModel middleware

The refusal-recovery use case is served by a standard wrapLanguageModel
middleware (see examples/middleware-refusal-recovery.ts), so the dedicated
experimental_transform model option and the ai-retry/experimental/transform
module are redundant. Remove both, plus the streamAttempt wiring and the
LanguageModelStreamTransform type.
Add a 'Experimental: call-level retries' section covering createRetryableStream
and createRetryableCall, positioned right after the streamText timeout
limitation they overcome.
zirkelc added a commit that referenced this pull request Jul 27, 2026
Add two experimental call-level retry drivers, exported from the
ai-retry/experimental/* subpaths:

- createRetryableStream: makes a streamed call (streamText/streamObject)
  retryable by reading the result's part stream up to the first content
  part. An error or abort part before content (an API error, or a
  streamText-level timeout: firstChunkMs/stepMs/totalMs) re-runs the whole
  call with the next model; once content is seen the attempt is committed.
  This is the only layer that can recover a streamText-level timeout (#50).
- createRetryableCall: the generic, entry-point-agnostic retry-loop driver
  behind createRetryableStream. Selects the model and a fresh per-attempt
  timeout for each try and invokes a caller-supplied function, so any call
  (e.g. generateText) whose timeout must be re-established per retry can fail
  over.

Refactor: extract evaluateError, resolveBackoffDelay, and
retryDiesOnAbortedSignal as shared internal helpers, reused by the language
model wrapper and the call driver.

Docs: new "Experimental: call-level retries" README section. Bump ai to
7.0.35 and all @ai-sdk/* dependencies.
@zirkelc

zirkelc commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Landed on main via squash merge as commit 1a991e7. The model-layer transform API was dropped before merge in favor of a standard wrapLanguageModel middleware (see examples/middleware-refusal-recovery.ts); the merged work is the two experimental call-level drivers createRetryableStream and createRetryableCall.

@zirkelc zirkelc closed this Jul 27, 2026
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