UNPKG

assistant-stream

Version:

Streaming utilities for AI assistants

730 lines (685 loc) • 21.3 kB
import type { AssistantStreamChunk } from "../AssistantStreamChunk"; import { generateId } from "../utils/generateId"; import type { AssistantMessage, AssistantMessageStatus, AssistantMessageTiming, TextPart, ToolCallPart, SourcePart, AssistantMessagePart, ReasoningPart, FilePart, DataPart, } from "../utils/types"; import { GorpStreamAccumulator } from "../gorp/GorpStreamAccumulator"; import { IncrementalJsonObjectParser } from "../../utils/json/incremental-json-object-parser"; import type { ReadonlyJSONValue } from "../../utils"; import { TimingTracker } from "./TimingTracker"; /** * Object spread materializes the deprecated `content` alias into a data * property, so it is redefined after `parts` to keep tracking the replacement. */ const withParts = ( message: AssistantMessage, parts: AssistantMessage["parts"], ): AssistantMessage => ({ ...message, parts, get content() { return this.parts; }, }); const appendPart = ( message: AssistantMessage, part: AssistantMessagePart, ): AssistantMessage => withParts(message, [...message.parts, part]); export const createInitialMessage = ({ unstable_state = null, }: { /** @deprecated Experimental since 2025-05-20. Not scheduled for removal; the API may change in any release. */ unstable_state?: ReadonlyJSONValue; } = {}): AssistantMessage => ({ role: "assistant", status: { type: "running" }, parts: [], get content() { return this.parts; }, metadata: { unstable_state, unstable_data: [], unstable_annotations: [], steps: [], custom: {}, }, }); type WarnOnce = (key: string, message: string) => void; const MAX_WARNED_KEYS = 16; const updatePartForPath = ( message: AssistantMessage, chunk: AssistantStreamChunk, warnOnce: WarnOnce, updater: (part: AssistantMessagePart) => AssistantMessagePart, ): AssistantMessage => { const part = chunk.path.length === 1 ? message.parts[chunk.path[0]!] : undefined; if (part === undefined) { warnOnce( `no-part:${chunk.type}`, `Dropped ${chunk.type} chunk: no part at path [${chunk.path.join(", ")}]`, ); return message; } const partIndex = chunk.path[0]!; const updatedPart = updater(part); if (updatedPart === part) return message; return withParts(message, [ ...message.parts.slice(0, partIndex), updatedPart, ...message.parts.slice(partIndex + 1), ]); }; const handlePartStart = ( message: AssistantMessage, chunk: AssistantStreamChunk & { readonly type: "part-start" }, warnOnce: WarnOnce, ): AssistantMessage => { const partInit = chunk.part; if (partInit.type === "text" || partInit.type === "reasoning") { const newTextPart: TextPart | ReasoningPart = { type: partInit.type, text: "", status: { type: "running" }, ...(partInit.providerMetadata != null && { providerMetadata: partInit.providerMetadata, }), ...(partInit.type === "reasoning" && partInit.unstable_summary !== undefined ? { unstable_summary: partInit.unstable_summary } : undefined), ...(partInit.type === "reasoning" ? { timing: { startedAt: Date.now() } } : undefined), ...(partInit.parentId && { parentId: partInit.parentId }), }; return appendPart(message, newTextPart); } else if (partInit.type === "tool-call") { const newToolCallPart: ToolCallPart = { type: "tool-call", state: "partial-call", status: { type: "running", isArgsComplete: false }, toolCallId: partInit.toolCallId, toolName: partInit.toolName, argsText: "", args: {}, timing: { startedAt: Date.now() }, ...(partInit.parentId && { parentId: partInit.parentId }), }; return appendPart(message, newToolCallPart); } else if (partInit.type === "source") { const newSourcePart: SourcePart = partInit.sourceType === "document" ? { type: "source", sourceType: "document", id: partInit.id, title: partInit.title, mediaType: partInit.mediaType ?? "application/octet-stream", ...(partInit.filename ? { filename: partInit.filename } : undefined), ...(partInit.parentId && { parentId: partInit.parentId }), } : { type: "source", sourceType: partInit.sourceType, id: partInit.id, url: partInit.url, ...(partInit.title ? { title: partInit.title } : undefined), ...(partInit.parentId && { parentId: partInit.parentId }), }; return appendPart(message, newSourcePart); } else if (partInit.type === "file") { const newFilePart: FilePart = { type: "file", mimeType: partInit.mimeType, data: partInit.data, ...(partInit.parentId && { parentId: partInit.parentId }), }; return appendPart(message, newFilePart); } else if (partInit.type === "data") { const newDataPart: DataPart = { type: "data", name: partInit.name, data: partInit.data, ...(partInit.parentId && { parentId: partInit.parentId }), }; return appendPart(message, newDataPart); } else { const unsupportedType = String((partInit as { type?: unknown }).type); warnOnce( `unsupported-part-start:${unsupportedType}`, `Unsupported part-start type ${unsupportedType}: inserting an empty reasoning part to preserve part indices`, ); // reasoning rather than a data sentinel: auiV0Encode rejects data parts, so a data placeholder would fail cloud persistence const placeholderPart: ReasoningPart = { type: "reasoning", text: "", status: { type: "running" }, }; return appendPart(message, placeholderPart); } }; const handleToolCallArgsTextFinish = ( message: AssistantMessage, chunk: AssistantStreamChunk & { readonly type: "tool-call-args-text-finish"; }, warnOnce: WarnOnce, ): AssistantMessage => { return updatePartForPath(message, chunk, warnOnce, (part) => { if (part.type !== "tool-call") { warnOnce( "wrong-part:tool-call-args-text-finish", "Dropped tool-call-args-text-finish chunk: part is not a tool-call", ); return part; } if (part.state !== "partial-call") return part; return { ...part, state: "call", status: part.status.type === "running" ? { type: "running", isArgsComplete: true } : part.status, }; }); }; const handleToolCallApproval = ( message: AssistantMessage, chunk: AssistantStreamChunk & { readonly type: "tool-call-approval" }, warnOnce: WarnOnce, ): AssistantMessage => { return updatePartForPath(message, chunk, warnOnce, (part) => { if (part.type !== "tool-call") { warnOnce( "wrong-part:tool-call-approval", "Dropped tool-call-approval chunk: part is not a tool-call", ); return part; } return { ...part, approval: chunk.approval }; }); }; const handlePartFinish = ( message: AssistantMessage, chunk: AssistantStreamChunk & { readonly type: "part-finish" }, warnOnce: WarnOnce, ): AssistantMessage => { return updatePartForPath(message, chunk, warnOnce, (part) => { if (part.type === "tool-call" && part.isPreliminary) return part; const providerMetadata = (part.type === "text" || part.type === "reasoning") && chunk.providerMetadata != null ? { providerMetadata: chunk.providerMetadata } : {}; if (part.type === "reasoning" && part.timing !== undefined) { return { ...part, ...providerMetadata, status: { type: "complete", reason: "unknown" }, timing: { ...part.timing, completedAt: part.timing.completedAt ?? Date.now(), }, }; } return { ...part, ...providerMetadata, status: { type: "complete", reason: "unknown" }, }; }); }; const handleTextDelta = ( message: AssistantMessage, chunk: AssistantStreamChunk & { type: "text-delta" }, warnOnce: WarnOnce, parserByPart: WeakMap<object, IncrementalJsonObjectParser>, ): AssistantMessage => { return updatePartForPath(message, chunk, warnOnce, (part) => { if (part.type === "text" || part.type === "reasoning") { return { ...part, text: part.text + chunk.textDelta, ...(chunk.providerMetadata != null && { providerMetadata: chunk.providerMetadata, }), }; } else if (part.type === "tool-call") { const newArgsText = part.argsText + chunk.textDelta; const existingParser = parserByPart.get(part); const parser = existingParser ? existingParser.append(chunk.textDelta) : newArgsText.length === 0 ? IncrementalJsonObjectParser.from("") : IncrementalJsonObjectParser.from(newArgsText, part.args); const newArgs = parser.currentArgs; const updatedPart = { ...part, argsText: newArgsText, args: newArgs }; parserByPart.set(updatedPart, parser); return updatedPart; } else { warnOnce( "wrong-part:text-delta", "Dropped text-delta chunk: part is neither text nor tool-call", ); return part; } }); }; const handleResult = ( message: AssistantMessage, chunk: AssistantStreamChunk & { type: "result" }, warnOnce: WarnOnce, ): AssistantMessage => { return updatePartForPath(message, chunk, warnOnce, (part) => { if (part.type === "tool-call") { const isPreliminary = chunk.isPreliminary === true; if (isPreliminary && part.state === "result") return part; const { isPreliminary: _isPreliminary, modelContent: _modelContent, artifact: _artifact, messages: _messages, ...basePart }: typeof part & { readonly messages?: ReadonlyJSONValue } = part; const runningStatus = part.status.type === "running" ? part.status : { type: "running" as const, isArgsComplete: part.state !== "partial-call", }; if (isPreliminary) { return { ...basePart, state: part.state === "partial-call" ? "partial-call" : "call", ...(chunk.artifact !== undefined ? { artifact: chunk.artifact } : {}), result: chunk.result, isError: chunk.isError ?? false, isPreliminary: true, ...(chunk.modelContent !== undefined ? { modelContent: chunk.modelContent } : {}), ...(chunk.messages !== undefined ? { messages: chunk.messages } : {}), status: runningStatus, }; } return { ...basePart, state: "result", ...(part.timing !== undefined ? { timing: { ...part.timing, completedAt: part.timing.completedAt ?? Date.now(), }, } : {}), ...(chunk.artifact !== undefined ? { artifact: chunk.artifact } : {}), result: chunk.result, isError: chunk.isError ?? false, ...(chunk.modelContent !== undefined ? { modelContent: chunk.modelContent } : {}), ...(chunk.messages !== undefined ? { messages: chunk.messages } : {}), status: { type: "complete", reason: "stop" }, }; } else { warnOnce( "wrong-part:result", "Dropped result chunk: part is not a tool-call", ); return part; } }); }; const handleMessageFinish = ( message: AssistantMessage, chunk: AssistantStreamChunk & { type: "message-finish" }, ): AssistantMessage => { // avoid edge case where providers send finish chunks that overwrite message error status (issue #2181) if ( message.status?.type === "incomplete" && message.status?.reason === "error" ) { return message; } const newStatus = getStatus(chunk); return { ...message, status: newStatus }; }; const getStatus = ( chunk: | (AssistantStreamChunk & { type: "message-finish" }) | (AssistantStreamChunk & { type: "step-finish" }), ): AssistantMessageStatus => { if (chunk.finishReason === "tool-calls") { return { type: "requires-action", reason: "tool-calls", }; } else if ( chunk.finishReason === "stop" || chunk.finishReason === "unknown" ) { return { type: "complete", reason: chunk.finishReason, }; } else { return { type: "incomplete", reason: chunk.finishReason, }; } }; const handleAnnotations = ( message: AssistantMessage, chunk: AssistantStreamChunk & { type: "annotations" }, ): AssistantMessage => { return { ...message, metadata: { ...message.metadata, unstable_annotations: [ ...message.metadata.unstable_annotations, ...chunk.annotations, ], }, }; }; const handleData = ( message: AssistantMessage, chunk: AssistantStreamChunk & { type: "data" }, ): AssistantMessage => { return { ...message, metadata: { ...message.metadata, unstable_data: [...message.metadata.unstable_data, ...chunk.data], }, }; }; const handleStepStart = ( message: AssistantMessage, chunk: AssistantStreamChunk & { type: "step-start" }, ): AssistantMessage => { return { ...message, metadata: { ...message.metadata, steps: [ ...message.metadata.steps, { state: "started", messageId: chunk.messageId }, ], }, }; }; const handleStepFinish = ( message: AssistantMessage, chunk: AssistantStreamChunk & { type: "step-finish" }, ): AssistantMessage => { const steps = message.metadata.steps.slice(); const lastIndex = steps.length - 1; // Check if the previous step is a step-start (has state "started") if (steps.length > 0 && steps[lastIndex]?.state === "started") { steps[lastIndex] = { ...steps[lastIndex], state: "finished", finishReason: chunk.finishReason, usage: chunk.usage, isContinued: chunk.isContinued, }; } else { // If no previous step-start exists, append a finished step steps.push({ state: "finished", messageId: generateId(), finishReason: chunk.finishReason, usage: chunk.usage, isContinued: chunk.isContinued, }); } return { ...message, metadata: { ...message.metadata, steps, }, }; }; const handleErrorChunk = ( message: AssistantMessage, chunk: AssistantStreamChunk & { type: "error" }, ): AssistantMessage => { const severity = chunk.severity === "critical" || chunk.severity === "warning" || chunk.severity === "info" ? chunk.severity : undefined; return { ...message, status: { type: "incomplete", reason: "error", error: { code: chunk.code ?? "unknown", message: chunk.error ?? "unknown error", ...(severity !== undefined && { severity }), }, }, }; }; const handleUpdateState = ( message: AssistantMessage, chunk: AssistantStreamChunk & { type: "update-state" }, acc: GorpStreamAccumulator, ): AssistantMessage => { acc.append(chunk.operations); return { ...message, metadata: { ...message.metadata, unstable_state: acc.state, }, }; }; const sumFinishedStepOutputTokens = (message: AssistantMessage): number => { let outputTokens = 0; for (const step of message.metadata.steps) { if (step.state === "finished" && step.usage) { outputTokens += step.usage.outputTokens; } } return outputTokens; }; const computeTiming = ( tracker: TimingTracker, message: AssistantMessage, finalOutputTokens = 0, ): AssistantMessageTiming => { const outputTokens = finalOutputTokens > 0 ? finalOutputTokens : sumFinishedStepOutputTokens(message); if (outputTokens > 0) return tracker.getTiming(outputTokens); let totalText = ""; for (const part of message.parts) { if (part.type === "text" || part.type === "reasoning") { totalText += part.text; } } return tracker.getTiming(undefined, totalText || undefined); }; const throttleCallback = (callback: () => void) => { let hasScheduled = false; return () => { if (hasScheduled) return; hasScheduled = true; queueMicrotask(() => { hasScheduled = false; callback(); }); }; }; export class AssistantMessageAccumulator extends TransformStream< AssistantStreamChunk, AssistantMessage > { constructor({ initialMessage, throttle, onError, strict = true, }: { initialMessage?: AssistantMessage; throttle?: boolean; onError?: (error: string) => void; strict?: boolean | undefined; } = {}) { let message = initialMessage ?? createInitialMessage(); let stateAccumulator: GorpStreamAccumulator | undefined; let finalOutputTokens: number | undefined; const tracker = new TimingTracker(); const parserByPart = new WeakMap<object, IncrementalJsonObjectParser>(); const warnedKeys = new Set<string>(); const warnOnce: WarnOnce = (key, warning) => { if (warnedKeys.has(key) || warnedKeys.size >= MAX_WARNED_KEYS) return; warnedKeys.add(key); console.warn(warning); }; let controller: | TransformStreamDefaultController<AssistantMessage> | undefined; const emitChunk = throttle ? throttleCallback(() => { controller?.enqueue(message); }) : () => { controller?.enqueue(message); }; super({ start(c) { controller = c; }, transform(chunk) { tracker.recordChunk(); const type = chunk.type; switch (type) { case "part-start": message = handlePartStart(message, chunk, warnOnce); if (chunk.part.type === "tool-call") { tracker.recordToolCallStart(chunk.part.toolCallId); } break; case "tool-call-args-text-finish": message = handleToolCallArgsTextFinish(message, chunk, warnOnce); break; case "tool-call-approval": message = handleToolCallApproval(message, chunk, warnOnce); break; case "part-finish": message = handlePartFinish(message, chunk, warnOnce); break; case "text-delta": { const next = handleTextDelta( message, chunk, warnOnce, parserByPart, ); if (next !== message) tracker.recordFirstToken(); message = next; break; } case "result": message = handleResult(message, chunk, warnOnce); break; case "message-finish": finalOutputTokens = chunk.usage?.outputTokens; message = handleMessageFinish(message, chunk); break; case "annotations": message = handleAnnotations(message, chunk); break; case "data": message = handleData(message, chunk); break; case "step-start": message = handleStepStart(message, chunk); break; case "step-finish": message = handleStepFinish(message, chunk); break; case "error": message = handleErrorChunk(message, chunk); onError?.(chunk.error); break; case "update-state": stateAccumulator ??= new GorpStreamAccumulator( message.metadata.unstable_state, { strict }, ); message = handleUpdateState(message, chunk, stateAccumulator); break; default: { const unhandledType: never = type; throw new Error(`Unsupported chunk type: ${unhandledType}`); } } if (message.status.type !== "running") { message = { ...message, metadata: { ...message.metadata, timing: computeTiming(tracker, message, finalOutputTokens), }, }; } emitChunk(); }, flush(controller) { if (message.status?.type === "running") { // Check if there are any tool calls that require action const requiresAction = message.parts?.some( (part) => part.type === "tool-call" && (part.state === "call" || part.state === "partial-call") && (part.result === undefined || part.isPreliminary), ) ?? false; message = handleMessageFinish(message, { type: "message-finish", path: [], finishReason: requiresAction ? "tool-calls" : "unknown", usage: { inputTokens: 0, outputTokens: 0, }, }); message = { ...message, metadata: { ...message.metadata, timing: computeTiming(tracker, message, finalOutputTokens), }, }; controller.enqueue(message); } }, }); } }