Streaming
Get incremental chunks as the model generates, with the same retry and caching guarantees as a normal call, plus a per-chunk idle timeout
VernLLM supports streaming responses through the same call() and cachedCall() you already use.
Set stream: true, and everything else, retries, the circuit breaker, schema validation,
tool calling, and caching keeps working exactly as it does today, just delivered incrementally
instead of as a single resolved value.
VernLLM's streaming preserves the same guarantees as non-streaming call(). Retries cover
connection failures before the first chunk arrives; a stream that fails partway through does not
retry, since replaying it would duplicate output the caller may have already rendered. See What
gets retried below. Unlike non-streaming call(), where timeoutMs bounds
the whole response, streaming only uses it to bound opening the stream and its first chunk; every
gap after that is bounded separately by chunkIdleTimeoutMs, see Per-chunk idle
timeout below.
Enabling streaming
Pass stream: true to call(). Instead of resolving to T directly, you get back a
StreamCallResult<T>, an object with two fields: chunks, an async iterable for live rendering,
and finalResult, a promise that resolves to the same value call() would have returned had
stream been omitted:
const { chunks, finalResult } = await llm.call({
userContent: 'Write a short poem about the ocean.',
jsonMode: false,
stream: true,
});
for await (const chunk of chunks) {
if (chunk.type === 'text-delta') {
process.stdout.write(chunk.delta);
}
}
const result = await finalResult; // the full, validated responseSetting stream: true changes what call() returns, from T directly to
StreamCallResult<T>. This is intentional and does not affect calls that omit stream, which
keep returning T exactly as before.
The chunk shape
Every chunk delivered through chunks is a StreamChunk, a discriminated union over three kinds
of event:
type StreamChunk =
| { type: 'text-delta'; delta: string }
| {
type: 'tool_call_delta';
index: number;
id?: string;
name?: string;
argsDelta?: string;
complete?: boolean;
}
| { type: 'usage'; usage: TokenUsage };complete: true on a tool_call_delta means argsDelta is the whole set of arguments for that
call, not a fragment. This happens for Gemini (its API can't stream function-call arguments
incrementally) and for replayed chunks from a cachedCall hit. Omitted or false for a genuine
fragment from providers that do stream incrementally (OpenAI-compatible, Anthropic, Bedrock).
Check chunk.type to branch:
for await (const chunk of chunks) {
switch (chunk.type) {
case 'text-delta':
process.stdout.write(chunk.delta);
break;
case 'tool_call_delta':
// accumulate by chunk.index, see Tool calling while streaming below
break;
case 'usage':
console.log('final token usage', chunk.usage);
break;
}
}A usage chunk, when the provider reports one, always arrives last, after every text-delta or
tool_call_delta chunk for that response. Not every provider sends usage data mid-stream; some
only report it in the same place non-streaming responses do. See the usage metering and
tracking section below.
finalResult mirrors non-streaming call()
finalResult resolves to exactly what call() would have returned for the same request had
stream been omitted: a parsed and validated T by default, or a CallWithToolsResult<T> if
tools was also set. JSON parsing, schema validation, and every other post-processing step run
against the fully accumulated response, not against individual chunks.
This means you can treat chunks purely as a live rendering concern and still get the exact same
typed, validated result your application already relies on:
const { chunks, finalResult } = await llm.call({
userContent: resumeText,
schema: CandidateSchema,
stream: true,
});
for await (const chunk of chunks) {
if (chunk.type === 'text-delta') renderPartialText(chunk.delta);
}
// result is CandidateSchema's inferred type, validated the same way a
// non-streaming call() would validate it
const result = await finalResult;If the stream produces no chunks at all, finalResult rejects with LLMError('Empty LLM response', 'api'), the same error a non-streaming call throws for an empty response.
chunks is single-use
chunks is an async iterable, not a replayable stream. Iterate it once, from one place. Iterating
it more than once, or from two places concurrently, does not replay or fork the sequence, both
consumers share the same underlying buffered stream and split the chunks between them.
Stopping iteration early (breaking out of a for await) does not cancel the underlying request
either. The stream keeps running in the background regardless, since finalResult still needs to
settle. If you only care about the final value and never plan to render live output, it's fine to
never touch chunks at all and just await finalResult.
Chunks emitted before you start iterating are not lost. They're buffered internally for the
duration of one stream, so starting to read chunks a little late still sees everything from the
beginning. That backlog is capped, an unusually large response whose chunks is never read at all
has its oldest buffered chunks evicted once the backlog grows large. Ordinary consumption, even
started somewhat late, stays far under that limit.
Tool calling while streaming
tools and stream combine freely. Tool call arguments arrive incrementally as
tool_call_delta chunks, one or more per tool call, keyed by index so you can tell multiple
concurrent tool calls apart:
const { chunks, finalResult } = await llm.call({
userContent: 'What is the weather in New York?',
tools: [weatherTool],
stream: true,
});
for await (const chunk of chunks) {
if (chunk.type === 'tool_call_delta') {
console.log(`tool ${chunk.index}: ${chunk.name ?? ''}${chunk.argsDelta ?? ''}`);
}
}
const result = await finalResult; // CallWithToolsResult<T>, same shape as non-streaming
if (result.type === 'tool_calls') {
for (const call of result.toolCalls) {
console.log(call.name, call.arguments); // fully accumulated, parsed arguments
}
}argsDelta carries a fragment of the JSON-encoded arguments string as it streams in, not parsed
JSON. VernLLM assembles and parses the full arguments for you once the stream completes, so
result.toolCalls[i].arguments on finalResult is the same fully parsed object you'd get from a
non-streaming tool call. Treat argsDelta as display-only unless you have a specific reason to
parse it incrementally yourself.
See Tool Calling for everything else about the tool calling
contract, none of which changes when stream is also set.
Usage metering and tracking while streaming
reserveUsage and refundUsage work the same way they do for non-streaming calls, with one
difference in timing: reserveUsage runs before the stream opens, but because call() must hand
back { chunks, finalResult } before the real outcome of the stream is known, the refund/report
step is deferred onto finalResult instead of happening inline. onUsage fires once finalResult
resolves successfully; onUsageFailure fires if the stream fails after usage was already reserved.
See Usage Metering and Usage Tracking for the full reference, both apply unchanged.
What gets retried
Streaming keeps the same retry policy as non-streaming call(), applied around opening the
stream rather than the whole response:
Failure before the first chunk
Treated like any other transient failure, retried per the configured backoff policy, same as a non-streaming request that fails before receiving a response.
Failure after at least one chunk
Not retried. Once chunks have started arriving, the caller may already be rendering them, so
VernLLM surfaces the error on finalResult rather than silently retrying and producing
duplicate or inconsistent output.
A mid-stream failure rejects finalResult with a normalized LLMError, not chunks itself. If
you only iterate chunks and never await or otherwise observe finalResult, attach a no-op
.catch() to it, or Node will report an unhandled rejection once the stream fails.
The circuit breaker, when configured, records a failure for the stream-opening step, matching this same before-first-chunk/after-first-chunk split. Success, correspondingly, is only recorded once the stream fully completes, not when the first chunk arrives, so a connection that opens and then hangs isn't masked as a success. An idle-timeout failure (see below) is the one exception on the failure side: it still counts even though it happens after the first chunk, since a provider that reliably streams one chunk and then hangs is exactly the kind of unhealthy behavior the breaker exists to catch. See Circuit Breaker and Retries for the underlying policy this reuses.
Per-chunk idle timeout
timeoutMs only bounds opening the stream and its first chunk. Every gap after that is bounded
separately, by chunkIdleTimeoutMs (default 30000, pass 0 or Infinity to disable). Without
it, a connection that opens fine, delivers one chunk, then hangs would never fail on its own.
The clock resets on every chunk, including provider keep-alive pings (see below), so it measures
the gap since the most recent chunk, not the stream's total duration. If it elapses, finalResult
rejects with LLMError('timeout'), same as the stream-open timeout, and the underlying request is
aborted rather than left running in the background.
const llm = new VernLLM({
client,
model: 'gpt-4o-mini',
chunkIdleTimeoutMs: 15_000, // fail if no chunk arrives for 15s
});Override it per call for routes that need a different value than the instance default, reasoning models with documented long silent gaps, for example:
const { chunks, finalResult } = await llm.call({
userContent: 'Solve this step by step: ...',
reasoningEffort: 'high',
stream: true,
chunkIdleTimeoutMs: 120_000, // this call only, instance default unaffected
});A value larger than setTimeout can represent (~24.8 days) is capped at that ceiling rather than
silently firing almost immediately, the underlying platform timer's own behavior for oversized
delays.
Provider keep-alive pings
Some providers send periodic keep-alive signals during long pauses in generation: Anthropic's
documented ping events during extended thinking, or an SSE comment line used as a heartbeat.
fromAnthropic and fromFetch (for the default SSE framing) both recognize these and reset the
idle-timeout clock on them. They carry no content, so nothing is surfaced to chunks or
finalResult.
Caching a streaming call
cachedCall() supports stream: true in any combination with tools. The behavior differs
depending on whether the call is a cache hit, a cache miss, or a miss that coalesces with an
in-flight call for the same key:
Cache miss, no other caller in flight
Opens a real stream and relays its chunks live to the caller, exactly like a non-cached
streaming call, while writing finalResult to the cache in the background once it settles.
Cache hit
No live generation happens. finalResult resolves immediately to the cached value, and chunks
is a synthesized one-shot replay built from it, so for await (const c of chunks) works
identically whether the call was a hit or a miss.
Miss, but another call for the same key is already in flight
This caller has no live chunks of its own to relay. It's treated like a delayed hit:
finalResult shares the in-flight trigger's promise, and chunks is a one-shot replay built
once that promise settles.
const { chunks, finalResult } = await llm.cachedCall({
cacheKey: `poem:${topic}`,
ttl: 3600,
call: {
userContent: `Write a short poem about ${topic}.`,
jsonMode: false,
stream: true,
},
});
for await (const chunk of chunks) {
if (chunk.type === 'text-delta') process.stdout.write(chunk.delta);
}
const result = await finalResult;A replayed chunks (from a hit or a coalesced joiner) is not a re-simulated token-by-token
playback of the original stream. This is deliberate: VernLLM does not fake "live" delivery to
preserve a UX feel, the same reasoning already applies to non-streaming cache hits resolving
instantly. A coalesced caller therefore does not see the trigger's live tokens, only the completed
result once it's ready. For a plain text response this means one flat chunk with the full content;
for a tool-call response it means one tool_call_delta per cached tool call (each with its full
arguments in one shot), followed by an optional trailing text chunk.
See Caching for everything else about cachedCall, coalescing,
and cache adapters, all of which apply the same way to streaming calls.
A note on the call() overload
Typescript only resolves call() to the streaming overload, returning StreamCallResult<T>, when
stream: true is visible as a literal at the call site. A similar caveat applies to tools, see
the Tool Calling docs: unlike tools,
stream currently has no conditional-value overload of its own, so a conditionally-set stream
always falls back to the non-streaming Promise<T> overload, not just when params was
pre-widened to CallParams<T>.
If you build params as a plain CallParams<T> and conditionally set stream on it, TypeScript
picks the non-streaming Promise<T> overload regardless of what stream turns out to be at
runtime. The actual runtime result still follows stream, so this mismatch is silent rather than
a compile error:
const params: CallParams<string> = {
userContent: 'Write a short poem about the ocean.',
jsonMode: false,
stream: someCondition,
};
// TypeScript infers Promise<string> here, but the real result is a
// StreamCallResult<string> whenever `stream` evaluates to true. Narrow or
// cast accordingly rather than relying on the static return type.
const result = await llm.call(params);Provider support
Every adapter implements createStream on top of the same client passed to fromOpenAICompatible,
fromAnthropic, fromGemini, or fromBedrock, no separate streaming client is needed:
| Provider | Native mechanism |
|---|---|
| OpenAI-compatible | Server-Sent Events, same endpoint as create with stream: true |
| Anthropic | Server-Sent Events over the Messages API's streaming mode |
| Gemini | The SDK's own streaming method |
| Bedrock | ConverseStreamCommand |
fromFetch | Server-Sent Events by default, or a custom frame format via parseStreamFrames |
See each adapter's own page for anything streaming-specific to that provider, and Custom Providers - fromFetch for wiring up streaming through the raw HTTP escape hatch.
Whether a client passed to an adapter needs to implement anything extra for streaming to work
depends on the adapter, most SDKs already expose a streaming method the adapter can call directly.
A client that lacks streaming support entirely makes stream: true throw a clear
LLMError('validation') rather than a confusing runtime failure.