assistant-stream
Version:
Streaming utilities for AI assistants
378 lines (339 loc) • 10 kB
text/typescript
/**
* 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;
}