feat: experimental streaming retry — call/stream drivers + model-layer transform - #55
Closed
zirkelc wants to merge 28 commits into
Closed
feat: experimental streaming retry — call/stream drivers + model-layer transform#55zirkelc wants to merge 28 commits into
zirkelc wants to merge 28 commits into
Conversation
…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.
commit: |
…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.
… 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.
Owner
Author
|
Landed on |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds three experimental entry points that extend the existing model-layer
createRetryableto streaming, addressing #50 (streamText-level deadlines) and response-encoded "soft" failures (e.g. content-filter refusals). All are opt-in underai-retry/experimental/*; the stable API is untouched.ai-retry/experimental/call—createRetryableCall: 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'sabortSignalthrough unchanged, runs an opaque asyncfn, and fails over whenfnthrows (reusingfindRetryModel, backoff, sticky/reset, telemetry). Thefnapplies the timeout however its call takes one —generateText({ timeout })natively, or anAbortSignal.timeout(...)built from it. An already-aborted caller signal is a hard stop, not a retry.ai-retry/experimental/stream—createRetryableStream: astreamText/streamObjectadapter built on the driver, recovering errors and deadline aborts before the first content part.ai-retry/experimental/transform—experimental_transformoncreateRetryable+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
createRetryableretries belowstreamText(atdoStream), which cannot recover astreamText-leveltimeout.*deadline: once the call's master signal aborts,streamTextfinalizes its stream as aborted and discards whatever a lower retry produces (#50).createRetryableStreamre-runs the whole call with the next model, so the next attempt gets a fresh deadline. (An inboundabortSignalis treated as a genuine caller cancellation — a hard stop, not failed over; per-attempt deadlines come fromstreamText's owntimeout.*.)streamText(args minusmodel); 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.stream(AI SDK v7), falling back tofullStream(streamObject/ pre-v7).streamText-level deadline aborts an internal controller, not the attempt's signal, so itsabortpart carries only a serialized reason string. The wrapper reconstructs a typed error from it (restoringname), sotimeout()/aborted()/error.message()match afirstChunkMs/stepMs/totalMsdeadline the same as a thrown error.refusalTransform— soft failures at the model layerA natural-language refusal ("I'm sorry, but I cannot assist…") streams as ordinary
text-deltaparts finishingstop— no error, nocontent-filterfinish reason — so nothing above can see it as a failure.experimental_transformpipes each attempt's provider stream insidedoStream(belowstreamText).refusalTransform(phrases)buffers the leading text-delta parts and, on matching a phrase, emits anerrorpart (aRefusalError) 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 plainstreamText, no call-layer wrapper: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
wrapLanguageModelmiddleware (example only)refusalTransformemits anerrorpart, which the model layer treats as a failed attempt. A standard AI SDK middleware does the same with no ai-retry-specific option — shipped asexamples/middleware-refusal-recovery.ts(not in the lib). ItswrapStreamsits at the model boundary and holds back every leading part —stream-start,text-start, metadata, and the text deltas — until the buffered text decides:controller.error(new RefusalError(...))with nothing emitted yet. Unlike enqueuing an error part,controller.errormakespipeThroughcancel the source, so the upstream request is torn down (no wasted tokens).createRetryableModelcatches the error before content, anderror.isInstance(RefusalError).switch({ model })fails over under plainstreamText.AI SDK upgrade + full timeout coverage
Upgrades to the latest AI SDK (
ai7.0.35,@ai-sdk/*latest,ai-test-kit3.0.1). 7.0.35 narrowschunkMsto gaps between content chunks and addsfirstChunkMsfor the pre-content window. The stream tests now cover every timeout option:firstChunkMsstepMstotalMschunkMstoolMsScope & composition
streamText— pass acreateRetryable(...)as themodel; the layers compose without blocking each other (tested).doStream); agenerateText/doGeneraterefusal would use the result-based path.options.promptis 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) +streamTextintegration suites, including thetimeoutssection above, abort reconstruction, and composition with a retryable base model.experimental/transform:refusalTransformunit (TransformStream) +createRetryableintegration (recovers under plainstreamText, no false retry, surfaces on no-match).tsc,oxlint, build, publint, attw all clean. Runnable examples for each path underexamples/, includingretryable-call-timeout-switch.tsandretryable-stream-timeout-switch.ts(switch models on a timeout) and thewrapLanguageModelmiddleware alternative above.