assistant-stream
Version:
Streaming utilities for AI assistants
830 lines (788 loc) • 29.4 kB
text/typescript
import type { AssistantStreamChunk } from "../../AssistantStreamChunk";
import { AssistantTransformStream } from "../../utils/stream/AssistantTransformStream";
import { PipeableTransformStream } from "../../utils/stream/PipeableTransformStream";
import { type DataStreamChunk, DataStreamStreamChunkType } from "./chunk-types";
import { LineDecoderStream } from "../../utils/stream/LineDecoderStream";
import {
DataStreamChunkDecoder,
DataStreamChunkEncoder,
} from "./serialization";
import {
type AssistantMetaStreamChunk,
AssistantMetaTransformStream,
} from "../../utils/stream/AssistantMetaTransformStream";
import type { AssistantStreamEncoder } from "../../AssistantStream";
import { createToolCallPartRegistry } from "../tool-call-part-registry";
export type DataStreamOptions = {
strict?: boolean | undefined;
/** Maximum UTF-16 code units accepted in one protocol line. */
maxLineLength?: number | undefined;
};
type ValueRule = (value: unknown) => boolean;
type ValueFields = Record<string, unknown>;
const isString = (value: unknown) => typeof value === "string";
const isArray = (value: unknown) => Array.isArray(value);
const isObject = (value: unknown): value is ValueFields =>
typeof value === "object" && value !== null && !Array.isArray(value);
const optional = (check: (value: unknown) => boolean) => (value: unknown) =>
value === undefined || value === null || check(value);
const isBoolean = (value: unknown) => typeof value === "boolean";
const present = (value: unknown) => value !== undefined;
const objectWith = (
fields: Record<string, (value: unknown) => boolean>,
): ValueRule => {
const entries = Object.entries(fields);
return (value) =>
isObject(value) && entries.every(([key, check]) => check(value[key]));
};
const unchecked = () => true;
const isPath = (value: unknown) =>
Array.isArray(value) && value.every(isString);
const isStateOperation = (value: unknown) =>
isObject(value) &&
isPath(value["path"]) &&
(value["type"] === "set"
? present(value["value"])
: value["type"] === "append-text" && isString(value["value"]));
const FINISH_REASONS: ReadonlySet<unknown> = new Set([
"stop",
"length",
"content-filter",
"tool-calls",
"error",
"other",
"unknown",
]);
const readFinishFields = (value: {
finishReason: unknown;
usage?: unknown;
}) => ({
finishReason: (FINISH_REASONS.has(value.finishReason)
? value.finishReason
: "other") as Extract<
AssistantStreamChunk,
{ type: "message-finish" }
>["finishReason"],
usage: isObject(value.usage)
? (value.usage as Extract<
AssistantStreamChunk,
{ type: "message-finish" }
>["usage"])
: { inputTokens: 0, outputTokens: 0 },
});
const VALUE_RULES: Record<DataStreamStreamChunkType, ValueRule> = {
[DataStreamStreamChunkType.TextDelta]: isString,
[DataStreamStreamChunkType.Data]: isArray,
// readErrorValue turns any payload into an error chunk, so a malformed
// error frame still surfaces as an error instead of being dropped.
[DataStreamStreamChunkType.Error]: unchecked,
[DataStreamStreamChunkType.Annotation]: isArray,
[DataStreamStreamChunkType.ToolCall]: objectWith({
toolCallId: isString,
toolName: isString,
args: optional((value) => typeof value === "object"),
}),
[DataStreamStreamChunkType.ToolCallResult]: objectWith({
toolCallId: isString,
isError: optional(isBoolean),
isPreliminary: optional(isBoolean),
modelContent: optional(isArray),
}),
[DataStreamStreamChunkType.StartToolCall]: objectWith({
toolCallId: isString,
toolName: isString,
parentId: optional(isString),
}),
[DataStreamStreamChunkType.ToolCallArgsTextDelta]: objectWith({
toolCallId: isString,
argsTextDelta: isString,
isFinal: optional(isBoolean),
}),
[DataStreamStreamChunkType.FinishMessage]: objectWith({
finishReason: isString,
usage: optional(isObject),
}),
[DataStreamStreamChunkType.FinishStep]: objectWith({
finishReason: isString,
usage: optional(isObject),
isContinued: optional(isBoolean),
}),
[DataStreamStreamChunkType.StartStep]: objectWith({
messageId: isString,
}),
[DataStreamStreamChunkType.ReasoningDelta]: isString,
[DataStreamStreamChunkType.Source]: objectWith({
parentId: optional(isString),
}),
[DataStreamStreamChunkType.RedactedReasoning]: unchecked,
[DataStreamStreamChunkType.ReasoningSignature]: unchecked,
[DataStreamStreamChunkType.File]: objectWith({
data: isString,
mimeType: isString,
parentId: optional(isString),
}),
[DataStreamStreamChunkType.AuiUpdateStateOperations]: (value) =>
Array.isArray(value) && value.every(isStateOperation),
[DataStreamStreamChunkType.AuiTextDelta]: objectWith({
textDelta: isString,
parentId: isString,
}),
[DataStreamStreamChunkType.AuiReasoningDelta]: objectWith({
reasoningDelta: isString,
parentId: isString,
}),
[DataStreamStreamChunkType.AuiDataPart]: objectWith({
name: isString,
data: present,
parentId: optional(isString),
}),
[DataStreamStreamChunkType.AuiReasoningPartStart]: objectWith({
unstable_summary: optional(isString),
parentId: optional(isString),
}),
[DataStreamStreamChunkType.AuiToolCallApproval]: objectWith({
toolCallId: isString,
approval: objectWith({
id: isString,
approved: (value) => value === undefined || isBoolean(value),
}),
}),
};
const ERROR_SEVERITIES: ReadonlySet<unknown> = new Set([
"critical",
"warning",
"info",
]);
const readErrorValue = (
value: unknown,
): Extract<AssistantStreamChunk, { type: "error" }> => {
if (typeof value === "string")
return { type: "error", path: [], error: value };
if (
typeof value === "object" &&
value !== null &&
"error" in value &&
typeof value.error === "string"
) {
const code = "code" in value ? value.code : undefined;
const severity = "severity" in value ? value.severity : undefined;
return {
type: "error",
path: [],
error: value.error,
...(typeof code === "string" ? { code } : {}),
...(ERROR_SEVERITIES.has(severity)
? { severity: severity as "critical" | "warning" | "info" }
: {}),
};
}
return { type: "error", path: [], error: JSON.stringify(value) ?? "" };
};
export class DataStreamEncoder
extends PipeableTransformStream<AssistantStreamChunk, Uint8Array<ArrayBuffer>>
implements AssistantStreamEncoder
{
headers = new Headers({
"Content-Type": "text/plain; charset=utf-8",
"x-vercel-ai-data-stream": "v1",
});
constructor() {
super((readable) => {
const openToolCallArgs = new Map<string, boolean>();
const finishToolCallArgs = (
controller: TransformStreamDefaultController<DataStreamChunk>,
toolCallId: string,
) => {
const hasArgsText = openToolCallArgs.get(toolCallId);
if (hasArgsText === undefined) return;
openToolCallArgs.delete(toolCallId);
controller.enqueue({
type: DataStreamStreamChunkType.ToolCallArgsTextDelta,
value: {
toolCallId,
// A decoder that predates `isFinal` appends this delta and settles
// on what it has, and it skips its own empty-object default once
// any delta has arrived. The frame therefore has to carry the
// default itself rather than leave it to the decoder.
argsTextDelta: hasArgsText ? "" : "{}",
isFinal: true,
},
});
};
const finishOpenToolCallArgs = (
controller: TransformStreamDefaultController<DataStreamChunk>,
) => {
for (const toolCallId of openToolCallArgs.keys()) {
finishToolCallArgs(controller, toolCallId);
}
};
const transform = new TransformStream<
AssistantMetaStreamChunk,
DataStreamChunk
>({
transform(chunk, controller) {
const type = chunk.type;
switch (type) {
case "part-start": {
const part = chunk.part;
if (part.type === "tool-call") {
const { type, ...value } = part;
controller.enqueue({
type: DataStreamStreamChunkType.StartToolCall,
value,
});
openToolCallArgs.set(part.toolCallId, false);
}
if (part.type === "source") {
const { type, ...value } = part;
controller.enqueue({
type: DataStreamStreamChunkType.Source,
value,
});
}
if (part.type === "file") {
const { type, ...value } = part;
controller.enqueue({
type: DataStreamStreamChunkType.File,
value,
});
}
if (part.type === "data") {
const { type, ...value } = part;
controller.enqueue({
type: DataStreamStreamChunkType.AuiDataPart,
value,
});
}
// Reasoning otherwise reaches the wire only through its text
// deltas, which cannot carry a summary and emit nothing at all
// for a part that never appends text. The frame is emitted only
// when there is a summary to carry, so a stream that does not
// use the field is unchanged.
if (
part.type === "reasoning" &&
part.unstable_summary !== undefined
) {
controller.enqueue({
type: DataStreamStreamChunkType.AuiReasoningPartStart,
value: {
unstable_summary: part.unstable_summary,
...(part.parentId !== undefined
? { parentId: part.parentId }
: {}),
},
});
}
break;
}
case "text-delta": {
const part = chunk.meta;
switch (part.type) {
case "text": {
if (part.parentId) {
controller.enqueue({
type: DataStreamStreamChunkType.AuiTextDelta,
value: {
textDelta: chunk.textDelta,
parentId: part.parentId,
},
});
} else {
controller.enqueue({
type: DataStreamStreamChunkType.TextDelta,
value: chunk.textDelta,
});
}
break;
}
case "reasoning": {
if (part.parentId) {
controller.enqueue({
type: DataStreamStreamChunkType.AuiReasoningDelta,
value: {
reasoningDelta: chunk.textDelta,
parentId: part.parentId,
},
});
} else {
controller.enqueue({
type: DataStreamStreamChunkType.ReasoningDelta,
value: chunk.textDelta,
});
}
break;
}
case "tool-call": {
if (!openToolCallArgs.has(part.toolCallId)) break;
openToolCallArgs.set(part.toolCallId, true);
controller.enqueue({
type: DataStreamStreamChunkType.ToolCallArgsTextDelta,
value: {
toolCallId: part.toolCallId,
argsTextDelta: chunk.textDelta,
},
});
break;
}
default:
throw new Error(
`Unsupported part type for text-delta: ${part.type}`,
);
}
break;
}
case "result": {
// Only tool-call parts can have results.
const part = chunk.meta;
if (part.type !== "tool-call") {
throw new Error(
`Result chunk on non-tool-call part not supported: ${part.type}`,
);
}
openToolCallArgs.delete(part.toolCallId);
controller.enqueue({
type: DataStreamStreamChunkType.ToolCallResult,
value: {
toolCallId: part.toolCallId,
result: chunk.result,
artifact: chunk.artifact,
...(chunk.isError ? { isError: chunk.isError } : {}),
...(chunk.isPreliminary ? { isPreliminary: true } : {}),
...(chunk.modelContent !== undefined
? { modelContent: chunk.modelContent }
: {}),
...(chunk.messages !== undefined
? { messages: chunk.messages }
: {}),
},
});
break;
}
case "step-start": {
const { type, ...value } = chunk;
controller.enqueue({
type: DataStreamStreamChunkType.StartStep,
value,
});
break;
}
case "step-finish": {
finishOpenToolCallArgs(controller);
const { type, ...value } = chunk;
controller.enqueue({
type: DataStreamStreamChunkType.FinishStep,
value,
});
break;
}
case "message-finish": {
finishOpenToolCallArgs(controller);
const { type, ...value } = chunk;
controller.enqueue({
type: DataStreamStreamChunkType.FinishMessage,
value,
});
break;
}
case "error": {
// A warning or info error does not end the message, so tool-call
// arguments still streaming stay open across it. Only the encoder
// can make this call: older encoders never send severity, so a
// decoder cannot rely on it, and a closed args stream is reported
// as an explicit final args frame rather than inferred from the
// error. An error without metadata stays a bare string, the shape
// older decoders and other data stream parsers read.
if (chunk.severity !== "warning" && chunk.severity !== "info")
finishOpenToolCallArgs(controller);
controller.enqueue({
type: DataStreamStreamChunkType.Error,
value:
chunk.code === undefined && chunk.severity === undefined
? chunk.error
: {
error: chunk.error,
...(chunk.code !== undefined
? { code: chunk.code }
: {}),
...(chunk.severity !== undefined
? { severity: chunk.severity }
: {}),
},
});
break;
}
case "annotations": {
controller.enqueue({
type: DataStreamStreamChunkType.Annotation,
value: chunk.annotations,
});
break;
}
case "data": {
controller.enqueue({
type: DataStreamStreamChunkType.Data,
value: chunk.data,
});
break;
}
case "update-state": {
controller.enqueue({
type: DataStreamStreamChunkType.AuiUpdateStateOperations,
value: chunk.operations,
});
break;
}
case "tool-call-args-text-finish": {
finishToolCallArgs(controller, chunk.meta.toolCallId);
break;
}
case "tool-call-approval": {
if (chunk.meta.type !== "tool-call") break;
controller.enqueue({
type: DataStreamStreamChunkType.AuiToolCallApproval,
value: {
toolCallId: chunk.meta.toolCallId,
approval: chunk.approval,
},
});
break;
}
case "part-finish": {
if (chunk.meta.type === "tool-call") {
finishToolCallArgs(controller, chunk.meta.toolCallId);
}
break;
}
default: {
const exhaustiveCheck: never = type;
throw new Error(`Unsupported chunk type: ${exhaustiveCheck}`);
}
}
},
flush(controller) {
finishOpenToolCallArgs(controller);
},
});
return readable
.pipeThrough(new AssistantMetaTransformStream())
.pipeThrough(transform)
.pipeThrough(new DataStreamChunkEncoder())
.pipeThrough(new TextEncoderStream());
});
}
}
export class DataStreamDecoder extends PipeableTransformStream<
Uint8Array<ArrayBuffer>,
AssistantStreamChunk
> {
constructor(options: DataStreamOptions = {}) {
const strict = options.strict ?? true;
super((readable) => {
const toolCallPartRegistry = createToolCallPartRegistry();
const warnedDroppedArgs = new Set<string>();
const loggedDrops = new Set<string>();
const logDropped = (key: string, message: string) => {
if (loggedDrops.has(key) || loggedDrops.size >= 20) return;
loggedDrops.add(key);
console.error(message);
};
const closeOpenToolCallArgs = () => {
toolCallPartRegistry.closeOpenArgsText();
};
const transform = new AssistantTransformStream<DataStreamChunk>({
strict,
transform(chunk, controller) {
const { type, value } = chunk;
const rule = Object.prototype.hasOwnProperty.call(VALUE_RULES, type)
? VALUE_RULES[type]
: undefined;
if (rule && !rule(value)) {
const preview = JSON.stringify(value)?.slice(0, 200);
if (strict)
throw new Error(
`Invalid value for data-stream chunk type "${type}": ${preview}`,
);
logDropped(
`value:${type}`,
`Dropped data-stream chunk with invalid value for type "${type}": ${preview}`,
);
return;
}
switch (type) {
case DataStreamStreamChunkType.ReasoningDelta:
controller.appendReasoning(value);
break;
case DataStreamStreamChunkType.TextDelta:
controller.appendText(value);
break;
case DataStreamStreamChunkType.AuiTextDelta:
controller
.withParentId(value.parentId)
.appendText(value.textDelta);
break;
case DataStreamStreamChunkType.AuiReasoningPartStart: {
const target = value.parentId
? controller.withParentId(value.parentId)
: controller;
// Opening through appendReasoning registers the part as the
// current reasoning append target, so the deltas that follow
// extend it instead of opening a second part.
const unstable_summary = value.unstable_summary ?? undefined;
target.appendReasoning("", {
...(unstable_summary !== undefined ? { unstable_summary } : {}),
});
break;
}
case DataStreamStreamChunkType.AuiReasoningDelta:
controller
.withParentId(value.parentId)
.appendReasoning(value.reasoningDelta);
break;
case DataStreamStreamChunkType.StartToolCall: {
const { toolCallId, toolName, parentId } = value;
const ctrl = parentId
? controller.withParentId(parentId)
: controller;
if (toolCallPartRegistry.tryGet(toolCallId)) {
if (strict)
throw new Error(
`Encountered duplicate tool call id: ${toolCallId}`,
);
logDropped(
`duplicate:${toolCallId}`,
`Dropped duplicate tool call start: ${toolCallId}`,
);
break;
}
toolCallPartRegistry.start(toolCallId, () =>
ctrl.addToolCallPart({
toolCallId,
toolName,
}),
);
break;
}
case DataStreamStreamChunkType.ToolCallArgsTextDelta: {
const { toolCallId, argsTextDelta, isFinal } = value;
const toolCallController =
toolCallPartRegistry.tryGet(toolCallId);
if (!toolCallController) {
if (strict)
throw new Error(
`Encountered tool call with unknown id: ${toolCallId}`,
);
logDropped(
`args:${toolCallId}`,
`Dropped args delta for unknown tool call: ${toolCallId}`,
);
break;
}
if (toolCallPartRegistry.isArgsTextClosed(toolCallController)) {
if (!warnedDroppedArgs.has(toolCallId)) {
warnedDroppedArgs.add(toolCallId);
console.warn(
`Dropped tool-call args delta for closed args stream: ${toolCallId}`,
);
}
break;
}
if (argsTextDelta.length > 0) {
toolCallPartRegistry.appendArgsText(
toolCallController,
argsTextDelta,
);
}
if (isFinal === true) {
toolCallPartRegistry.closeArgsText(toolCallController);
}
break;
}
case DataStreamStreamChunkType.ToolCallResult: {
const {
toolCallId,
artifact,
result,
isError,
isPreliminary,
modelContent,
messages,
} = value;
const toolCallController =
toolCallPartRegistry.tryGet(toolCallId);
if (!toolCallController) {
if (strict)
throw new Error(
`Encountered tool call result with unknown id: ${toolCallId}`,
);
logDropped(
`result:${toolCallId}`,
`Dropped result for unknown tool call: ${toolCallId}`,
);
break;
}
toolCallPartRegistry.setResponse(toolCallController, {
artifact,
result,
isError,
...(isPreliminary ? { isPreliminary: true } : {}),
...(modelContent != null ? { modelContent } : {}),
...(messages !== undefined ? { messages } : {}),
});
break;
}
case DataStreamStreamChunkType.AuiToolCallApproval: {
const { toolCallId, approval } = value;
const toolCallController =
toolCallPartRegistry.tryGet(toolCallId);
if (!toolCallController) {
if (strict)
throw new Error(
`Encountered tool call approval with unknown id: ${toolCallId}`,
);
logDropped(
`approval:${toolCallId}`,
`Dropped approval for unknown tool call: ${toolCallId}`,
);
break;
}
toolCallController.setApproval(approval);
break;
}
case DataStreamStreamChunkType.ToolCall: {
const { toolCallId, toolName } = value;
const args = value.args ?? undefined;
const toolCallController =
toolCallPartRegistry.tryGet(toolCallId) ??
toolCallPartRegistry.start(toolCallId, () =>
controller.addToolCallPart({
toolCallId,
toolName,
}),
);
if (
args !== undefined &&
!toolCallPartRegistry.hasArgsText(toolCallController) &&
!toolCallPartRegistry.isArgsTextClosed(toolCallController)
) {
toolCallPartRegistry.appendArgsText(
toolCallController,
JSON.stringify(args),
);
}
toolCallPartRegistry.closeArgsText(toolCallController);
break;
}
case DataStreamStreamChunkType.FinishMessage:
closeOpenToolCallArgs();
controller.enqueue({
...value,
...readFinishFields(value),
type: "message-finish",
path: [],
});
break;
case DataStreamStreamChunkType.StartStep:
controller.enqueue({
...value,
type: "step-start",
path: [],
});
break;
case DataStreamStreamChunkType.FinishStep:
closeOpenToolCallArgs();
controller.enqueue({
...value,
...readFinishFields(value),
isContinued: value.isContinued ?? false,
type: "step-finish",
path: [],
});
break;
case DataStreamStreamChunkType.Data:
controller.enqueue({
type: "data",
path: [],
data: value,
});
break;
case DataStreamStreamChunkType.Annotation:
controller.enqueue({
type: "annotations",
path: [],
annotations: value,
});
break;
case DataStreamStreamChunkType.Source: {
const { parentId, ...sourceData } = value;
const ctrl = parentId
? controller.withParentId(parentId)
: controller;
ctrl.appendSource({
...sourceData,
type: "source",
});
break;
}
case DataStreamStreamChunkType.Error: {
// An error frame cannot say whether it ends the message, since
// older encoders send no severity. A producer that ends one
// closes its open args streams with a final args frame ahead of
// the error, and the step, message and stream ends close
// whatever is left.
controller.enqueue(readErrorValue(value));
break;
}
case DataStreamStreamChunkType.File: {
const { parentId, ...fileData } = value;
const ctrl = parentId
? controller.withParentId(parentId)
: controller;
ctrl.appendFile({
...fileData,
type: "file",
});
break;
}
case DataStreamStreamChunkType.AuiDataPart:
controller.appendData({
...value,
type: "data",
});
break;
case DataStreamStreamChunkType.AuiUpdateStateOperations:
controller.enqueue({
type: "update-state",
path: [],
operations: value,
});
break;
case DataStreamStreamChunkType.ReasoningSignature:
case DataStreamStreamChunkType.RedactedReasoning:
// ignore these for now
break;
default: {
const exhaustiveCheck: never = type;
if (strict)
throw new Error(`unsupported chunk type: ${exhaustiveCheck}`);
logDropped(
`type:${exhaustiveCheck as string}`,
`Dropped unsupported chunk type: ${exhaustiveCheck as string}`,
);
}
}
},
flush() {
closeOpenToolCallArgs();
toolCallPartRegistry.closeAll();
},
});
return readable
.pipeThrough(new TextDecoderStream())
.pipeThrough(
new LineDecoderStream({ maxLineLength: options.maxLineLength }),
)
.pipeThrough(new DataStreamChunkDecoder())
.pipeThrough(transform);
});
}
}