UNPKG

ai

Version:

AI SDK by Vercel - build apps like ChatGPT, Claude, Gemini, and more with a single interface for any model using the Vercel AI Gateway or go direct to OpenAI, Anthropic, Google, or any other model provider.

527 lines (477 loc) • 16 kB
import { convertUint8ArrayToBase64, type ToolSet, type UserModelMessage, } from '@ai-sdk/provider-utils'; import type { TextStreamPart } from '../generate-text/stream-text-result'; import { getToolCallInputSchemaInput } from '../generate-text/tool-call'; import { convertDataContentToBase64String } from '../prompt/data-content'; import { convertToLanguageModelV4FilePart } from '../prompt/file-part-data'; import type { FileUIPart, InferUIMessageData, InferUIMessageMetadata, TextUIPart, UIMessage, } from '../ui/ui-messages'; import { isDeepEqualData } from '../util/is-deep-equal-data'; import { getModelToolErrorText } from './get-model-tool-error-text'; import type { InferUIMessageChunk, UIMessageChunk } from './ui-message-chunks'; export type ToUIMessageChunkOptions< TOOLS extends ToolSet = ToolSet, UI_MESSAGE extends UIMessage = UIMessage, > = { tools?: TOOLS; sendReasoning?: boolean; sendSources?: boolean; sendStart?: boolean; sendFinish?: boolean; /** * Formats stream errors. Also formats invalid tool calls and * application-executed tool errors when `redactToolErrors` is enabled. * Provider-executed tool execution errors bypass this callback so their * original error data survives model-message round trips. */ onError?: (error: unknown) => string; /** * Replace the error text of failed tool calls with the result of `onError`. * Defaults to `true` when `onError` is provided and to `false` otherwise. */ redactToolErrors?: boolean; messageMetadata?: InferUIMessageMetadata<UI_MESSAGE>; responseMessageId?: string; }; /** * Converts a single `TextStreamPart` (as emitted by `streamText`'s * `stream`) into a `UIMessageChunk`. * * Returns `undefined` for stream parts that do not produce UI message chunks. */ export function toUIMessageChunk< TOOLS extends ToolSet = ToolSet, UI_MESSAGE extends UIMessage = UIMessage, >( part: TextStreamPart<TOOLS>, { tools, sendReasoning = true, sendSources = false, sendStart = true, sendFinish = true, onError: onErrorOption, // an existing `onError` keeps handling tool errors, as it did before // tool errors stopped being redacted by default redactToolErrors = onErrorOption != null, messageMetadata, responseMessageId, }: ToUIMessageChunkOptions<TOOLS, UI_MESSAGE> = {}, ): InferUIMessageChunk<UI_MESSAGE> | undefined { // Stream errors never reach the model, so they are masked by default. const onError = onErrorOption ?? (() => 'An error occurred.'); // The error text of a failed tool call is not altered by default. // // In a multi-step call, the model has already received this text // (`getErrorMessage(error)`, see `createToolModelOutput`) and may have // reasoned about it and repeated it in its answer. The UI message is the // record that `convertToModelMessages` sends back to the model on the next // request. Replacing the text here // // - changes the message history after the fact. Models that bind reasoning // to the preceding messages (for example Anthropic models with preserved // thinking) reject such a history, and other models continue from an // error that they never received. // - does not keep the text from the client, because the model's reasoning // and answer are sent to the client as well. // // When the call ends with the failed tool call, the model has not received // the text yet, and replacing it would not change the history. The two // cases cannot be told apart here: whether another step follows is decided // after this chunk has been sent. Both cases use the same default. // // To keep error details away from the model and the client, throw a safe // error from the tool's `execute` function. `redactToolErrors` restores the // replacement for applications that need it. // // ADR: contributing/decisions/2026-11-12-keep-tool-error-text-unchanged-in-ui-messages.md const getToolErrorText = (error: unknown) => redactToolErrors ? onError(error) : (getModelToolErrorText(error) ?? onError(error)); // TODO simplify once dynamic is no longer needed for invalid tool inputs const isDynamic = (toolPart: { toolName: string; dynamic?: boolean }) => { const tool = tools?.[toolPart.toolName]; // provider-executed, dynamic tools are not listed in the tools object if (tool == null) { return toolPart.dynamic; } return tool?.type === 'dynamic' ? true : undefined; }; const partType = part.type; switch (partType) { case 'text-start': { return { type: 'text-start', id: part.id, ...(part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}), }; } case 'text-delta': { return { type: 'text-delta', id: part.id, delta: part.text, ...(part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}), }; } case 'text-end': { return { type: 'text-end', id: part.id, ...(part.citations != null ? { citations: part.citations } : {}), ...(part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}), }; } case 'reasoning-start': case 'reasoning-end': { if (!sendReasoning) { return undefined; } return { type: partType, id: part.id, ...(part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}), }; } case 'reasoning-delta': { if (!sendReasoning) { return undefined; } return { type: 'reasoning-delta', id: part.id, delta: part.text, ...(part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}), }; } case 'file': case 'reasoning-file': { if (partType === 'reasoning-file' && !sendReasoning) { return undefined; } return { type: part.type, mediaType: part.file.mediaType, url: `data:${part.file.mediaType};base64,${part.file.base64}`, ...(part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}), }; } case 'source': { if (!sendSources) { return undefined; } if (part.sourceType === 'url') { return { type: 'source-url', sourceId: part.id, url: part.url, title: part.title, ...(part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}), }; } if (part.sourceType === 'document') { return { type: 'source-document', sourceId: part.id, mediaType: part.mediaType, title: part.title, filename: part.filename, ...(part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}), }; } return undefined; } case 'custom': { return { type: 'custom', kind: part.kind, ...(part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}), }; } case 'tool-input-start': { const dynamic = isDynamic(part); return { type: 'tool-input-start', toolCallId: part.id, toolName: part.toolName, ...(part.inputEncoding != null ? { inputEncoding: part.inputEncoding } : {}), ...(part.providerExecuted != null ? { providerExecuted: part.providerExecuted } : {}), ...(part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}), ...(part.toolMetadata != null ? { toolMetadata: part.toolMetadata } : {}), ...(dynamic != null ? { dynamic } : {}), ...(part.title != null ? { title: part.title } : {}), }; } case 'tool-input-delta': { return { type: 'tool-input-delta', toolCallId: part.id, inputTextDelta: part.delta, }; } case 'tool-call': { const dynamic = isDynamic(part); if (part.invalid) { return { type: 'tool-input-error', toolCallId: part.toolCallId, toolName: part.toolName, input: part.input, ...(part.providerExecuted != null ? { providerExecuted: part.providerExecuted } : {}), ...(part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}), ...(part.toolMetadata != null ? { toolMetadata: part.toolMetadata } : {}), ...(dynamic != null ? { dynamic } : {}), // streamText sends the model a tool result for an invalid tool call // only when the tool is not provider-executed errorText: part.providerExecuted ? onError(part.error) : getToolErrorText(part.error), ...(part.title != null ? { title: part.title } : {}), }; } return { type: 'tool-input-available', toolCallId: part.toolCallId, toolName: part.toolName, input: part.input, ...(part.providerExecuted != null ? { providerExecuted: part.providerExecuted } : {}), ...(part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}), ...(part.toolMetadata != null ? { toolMetadata: part.toolMetadata } : {}), ...(dynamic != null ? { dynamic } : {}), ...(part.title != null ? { title: part.title } : {}), }; } case 'tool-approval-request': { const inputSchemaInput = getToolCallInputSchemaInput(part.toolCall); return { type: 'tool-approval-request', approvalId: part.approvalId, toolCallId: part.toolCall.toolCallId, ...(inputSchemaInput != null && !isDeepEqualData(inputSchemaInput.value, part.toolCall.input) ? { inputSchemaInput: inputSchemaInput.value } : {}), ...(part.reason != null ? { reason: part.reason } : {}), ...(part.isAutomatic != null ? { isAutomatic: part.isAutomatic } : {}), ...(part.signature != null ? { signature: part.signature } : {}), }; } case 'tool-approval-response': { return { type: 'tool-approval-response', approvalId: part.approvalId, approved: part.approved, ...(part.reason != null ? { reason: part.reason } : {}), ...(part.providerExecuted != null ? { providerExecuted: part.providerExecuted } : {}), }; } case 'tool-result': { const dynamic = isDynamic(part); return { type: 'tool-output-available', toolCallId: part.toolCallId, // UI stream chunks are serialized as JSON, which drops undefined // properties. Use null so tool outputs always keep the output field. output: part.output === undefined ? null : part.output, ...(part.providerExecuted != null ? { providerExecuted: part.providerExecuted } : {}), ...(part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}), ...(part.toolMetadata != null ? { toolMetadata: part.toolMetadata } : {}), ...(part.preliminary != null ? { preliminary: part.preliminary } : {}), ...(dynamic != null ? { dynamic } : {}), }; } case 'tool-error': { const dynamic = isDynamic(part); // Preserve provider error codes for model-message round trips and harness // runtime error messages. Provider-executed execution errors always // bypass onError. Application-executed errors keep the text that the // model received unless tool errors are redacted (see getToolErrorText). return { type: 'tool-output-error', toolCallId: part.toolCallId, errorText: part.providerExecuted ? typeof part.error === 'string' ? part.error : JSON.stringify(part.error) : getToolErrorText(part.error), ...(part.providerExecuted != null ? { providerExecuted: part.providerExecuted } : {}), ...(part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}), ...(part.toolMetadata != null ? { toolMetadata: part.toolMetadata } : {}), ...(dynamic != null ? { dynamic } : {}), }; } case 'tool-output-denied': { return { type: 'tool-output-denied', toolCallId: part.toolCallId, }; } case 'error': { return { type: 'error', errorText: onError(part.error), }; } case 'steering-message': { return { type: 'steering-message', id: part.id, parts: convertUserMessageToUIParts(part.message), }; } case 'start-step': { return { type: 'start-step' }; } case 'finish-step': { return { type: 'finish-step' }; } case 'start': { if (!sendStart) { return undefined; } return { type: 'start', ...(messageMetadata != null ? { messageMetadata } : {}), ...(responseMessageId != null ? { messageId: responseMessageId } : {}), } as UIMessageChunk< InferUIMessageMetadata<UI_MESSAGE>, InferUIMessageData<UI_MESSAGE> >; } case 'finish': { if (!sendFinish) { return undefined; } return { type: 'finish', finishReason: part.finishReason, ...(messageMetadata != null ? { messageMetadata } : {}), } as UIMessageChunk< InferUIMessageMetadata<UI_MESSAGE>, InferUIMessageData<UI_MESSAGE> >; } case 'abort': { return part; } case 'tool-input-end': case 'ping': case 'raw': { return undefined; } default: { const exhaustiveCheck: never = partType; throw new Error(`Unknown chunk type: ${exhaustiveCheck}`); } } } function convertUserMessageToUIParts( message: UserModelMessage, ): Array<TextUIPart | FileUIPart> { if (typeof message.content === 'string') { return [{ type: 'text', text: message.content }]; } return message.content.map((part): TextUIPart | FileUIPart => { const providerMetadata = part.providerOptions != null ? { providerMetadata: part.providerOptions } : {}; if (part.type === 'text') { return { type: 'text', text: part.text, ...providerMetadata }; } const { data, mediaType: dataUrlMediaType } = convertToLanguageModelV4FilePart( part.type === 'image' ? part.image : part.data, ); const mediaType = dataUrlMediaType ?? part.mediaType ?? 'image'; const filename = part.type === 'file' ? part.filename : undefined; const file = { type: 'file' as const, mediaType, ...(filename != null ? { filename } : {}), ...providerMetadata, }; switch (data.type) { case 'url': { return { ...file, url: data.url.toString() }; } case 'data': { return { ...file, url: `data:${mediaType};base64,${convertDataContentToBase64String(data.data)}`, }; } case 'text': { return { ...file, url: `data:${mediaType};base64,${convertUint8ArrayToBase64(new TextEncoder().encode(data.text))}`, }; } case 'reference': { // the provider reference takes precedence over the url in model messages: return { ...file, url: '', providerReference: data.reference }; } } }); }