UNPKG

assistant-stream

Version:

Streaming utilities for AI assistants

378 lines (339 loc) • 10 kB
/** * Generic message types for framework-agnostic LLM message interchange. * These types represent a common format that can be converted to/from * various LLM provider formats (AI SDK, LangChain, etc.). */ import { NO_RESULT } from "../tool/ToolResponse"; import type { ToolModelContentPart } from "../tool/tool-types"; export type GenericTextPart = { type: "text"; text: string; }; export type GenericFilePart = { type: "file"; data: string | URL; mediaType: string; filename?: string; }; export type GenericToolCallPart = { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown>; }; export type GenericToolResultPart = { type: "tool-result"; toolCallId: string; toolName: string; result: unknown; /** Explicit model-visible content for a settled tool result. */ modelContent?: readonly ToolModelContentPart[]; isError?: boolean; }; export type GenericSystemMessage = { role: "system"; content: string; }; export type GenericUserMessage = { role: "user"; content: (GenericTextPart | GenericFilePart)[]; }; export type GenericAssistantMessage = { role: "assistant"; content: (GenericTextPart | GenericFilePart | GenericToolCallPart)[]; }; export type GenericToolMessage = { role: "tool"; content: GenericToolResultPart[]; }; export type GenericMessage = | GenericSystemMessage | GenericUserMessage | GenericAssistantMessage | GenericToolMessage; type MessagePartLike = { type: string; text?: string; image?: string; audio?: { data?: string; format?: "mp3" | "wav"; }; data?: string; mimeType?: string; filename?: string; toolCallId?: string; toolName?: string; args?: Record<string, unknown>; state?: string; result?: unknown; modelContent?: readonly ToolModelContentPart[]; isError?: boolean; isPreliminary?: boolean; approval?: { approved?: boolean; resolution?: string; [key: string]: unknown; }; interrupt?: unknown; }; type AttachmentLike = { contentType?: string; content: readonly MessagePartLike[]; }; type ThreadMessageLike = { role: "system" | "user" | "assistant"; content: readonly MessagePartLike[]; attachments?: readonly AttachmentLike[]; status?: { type: string }; }; const IMAGE_MEDIA_TYPES: Record<string, string> = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", avif: "image/avif", bmp: "image/bmp", ico: "image/x-icon", tiff: "image/tiff", tif: "image/tiff", heic: "image/heic", heif: "image/heif", }; function getDataUrlMediaType(value: string): string | undefined { return value.match(/^data:([^;,]+)(?:[;,])/i)?.[1]?.toLowerCase(); } function inferImageMediaType(url: string, contentType?: string): string { // Providers reject image/* as a URL media type. const declared = contentType?.toLowerCase(); if (declared?.startsWith("image/") && !declared.includes("*")) { return declared; } // Handle data URLs: data:[<mediatype>][;base64],<data> if (/^data:/i.test(url)) { const match = url.match(/^data:([^;,]+)/i); if (match?.[1]) return match[1].toLowerCase(); } // Extract extension from URL path, ignoring query string and hash const [pathWithoutParams = ""] = url.split(/[?#]/); const ext = pathWithoutParams.split(".").pop()?.toLowerCase() ?? ""; return IMAGE_MEDIA_TYPES[ext] ?? "image/png"; } function toUrlOrString(value: string): string | URL { try { return new URL(value); } catch { return value; } } function toGenericFilePart( part: MessagePartLike, contentType?: string, ): GenericFilePart | undefined { if ( part.type === "audio" && typeof part.audio?.data === "string" && part.audio.format !== undefined ) { const mediaType = `audio/${part.audio.format}`; const base64DataUrl = part.audio.data.match( /^data:[^;,]*(?:;[^;,]+)*;base64,(.*)$/i, ); return { type: "file", data: toUrlOrString( base64DataUrl ? `data:${mediaType};base64,${base64DataUrl[1]}` : part.audio.data, ), mediaType, }; } if (part.type === "image" && part.image) { return { type: "file", data: toUrlOrString(part.image), mediaType: inferImageMediaType(part.image, contentType), ...(part.filename && { filename: part.filename }), }; } if (part.type === "file" && typeof part.data === "string") { return { type: "file", data: toUrlOrString(part.data), mediaType: (typeof part.mimeType === "string" && part.mimeType) || getDataUrlMediaType(part.data) || "application/octet-stream", ...(part.filename && { filename: part.filename }), }; } return undefined; } type ToolCallAccumulator = { textParts: (GenericTextPart | GenericFilePart | GenericToolCallPart)[]; toolResults: GenericToolResultPart[]; }; function isAwaitingHost(part: MessagePartLike): boolean { if (part.interrupt != null) return true; if (part.approval == null) return false; return ( part.approval.approved !== false && part.approval.resolution === undefined ); } function processToolCall( part: MessagePartLike, accumulator: ToolCallAccumulator, inFlight: boolean, ): boolean { if (!part.toolCallId || !part.toolName) return false; accumulator.textParts.push({ type: "tool-call", toolCallId: part.toolCallId, toolName: part.toolName, args: part.args ?? {}, }); const settled = !part.isPreliminary && (part.state === "result" || part.result !== undefined); // The in-flight message is the last one and is converted on every roundtrip, so a call in it still awaiting a decision or an execution is live rather than failed; a call in an earlier or settled message will never be answered. if (!settled && inFlight && isAwaitingHost(part)) return false; // Providers reject an assistant tool call that no tool result answers. const toolResult: GenericToolResultPart = { type: "tool-result", toolCallId: part.toolCallId, toolName: part.toolName, result: !settled ? { error: "Tool call was not completed" } : part.result === undefined ? NO_RESULT : part.result, ...(settled && part.modelContent !== undefined ? { modelContent: part.modelContent } : {}), }; if (!settled || part.isError) { toolResult.isError = true; } accumulator.toolResults.push(toolResult); return true; } function flushAccumulator( accumulator: ToolCallAccumulator, result: GenericMessage[], ): void { if (accumulator.textParts.length > 0) { result.push({ role: "assistant", content: accumulator.textParts }); accumulator.textParts = []; } if (accumulator.toolResults.length > 0) { result.push({ role: "tool", content: accumulator.toolResults }); accumulator.toolResults = []; } } function convertSystemMessage( message: ThreadMessageLike, result: GenericMessage[], ): void { const textPart = message.content?.find((p) => p?.type === "text"); if (textPart?.text) { result.push({ role: "system", content: textPart.text }); } } function convertUserMessage( message: ThreadMessageLike, result: GenericMessage[], ): void { const attachments = message.attachments ?? []; const allContent = [ ...(message.content ?? []).map((part) => ({ part, contentType: undefined, })), ...attachments.flatMap((attachment) => (attachment?.content ?? []).map((part) => ({ part, contentType: attachment.contentType, })), ), ]; const content: (GenericTextPart | GenericFilePart)[] = []; for (const { part, contentType } of allContent) { if (!part) continue; if (part.type === "text" && part.text) { content.push({ type: "text", text: part.text }); } else { const filePart = toGenericFilePart(part, contentType); if (filePart) content.push(filePart); } } if (content.length > 0) { result.push({ role: "user", content }); } } function convertAssistantMessage( message: ThreadMessageLike, result: GenericMessage[], isLast: boolean, ): void { const accumulator: ToolCallAccumulator = { textParts: [], toolResults: [], }; let hasPendingToolResults = false; const inFlight = isLast && message.status?.type !== "complete" && message.status?.type !== "incomplete"; for (const part of message.content ?? []) { if (!part) continue; if (part.type === "text" && part.text) { // Flush pending tool results before adding more text if (hasPendingToolResults) { flushAccumulator(accumulator, result); hasPendingToolResults = false; } accumulator.textParts.push({ type: "text", text: part.text }); } else if (part.type === "tool-call") { if (processToolCall(part, accumulator, inFlight)) { hasPendingToolResults = true; } } else { const filePart = toGenericFilePart(part); if (filePart) { if (hasPendingToolResults) { flushAccumulator(accumulator, result); hasPendingToolResults = false; } accumulator.textParts.push(filePart); } } } flushAccumulator(accumulator, result); } /** * Converts thread messages to generic LLM messages. * This format can then be easily converted to provider-specific formats. */ export function toGenericMessages( messages: readonly ThreadMessageLike[], ): GenericMessage[] { const result: GenericMessage[] = []; const present = messages.filter(Boolean); for (const [index, message] of present.entries()) { switch (message.role) { case "system": convertSystemMessage(message, result); break; case "user": convertUserMessage(message, result); break; case "assistant": convertAssistantMessage(message, result, index === present.length - 1); break; } } return result; }