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.

254 lines (229 loc) • 8.77 kB
import type { ToolSet } from '@ai-sdk/provider-utils'; import type { TextStreamPart, UIMessageStreamOptions, } from '../generate-text/stream-text-result'; import { logWarnings } from '../logger/log-warnings'; import type { UIMessage } from '../ui/ui-messages'; import { getModelToolErrorText } from './get-model-tool-error-text'; import { getResponseUIMessageId } from './get-response-ui-message-id'; import { handleUIMessageStreamFinish } from './handle-ui-message-stream-finish'; import type { InferUIMessageChunk, UIMessageChunk } from './ui-message-chunks'; import type { UIMessageStreamOutcome } from './ui-message-stream-outcome'; import { toUIMessageChunk } from './to-ui-message-chunk'; /** * Converts a stream of `TextStreamPart<TOOLS>` chunks (as emitted by * `streamText`'s `stream`) into a stream of `UIMessageChunk`s suitable for * UI message streaming, including response message ID injection and * step and stream end callbacks. */ export function toUIMessageStream< TOOLS extends ToolSet = ToolSet, UI_MESSAGE extends UIMessage = UIMessage, >({ stream, tools, sendReasoning = true, sendSources = false, sendStart = true, sendFinish = true, onError, redactToolErrors, messageMetadata, originalMessages, generateMessageId, onStepEnd, onStepFinish, onEnd, onFinish, }: { stream: ReadableStream<TextStreamPart<TOOLS>>; tools?: TOOLS; } & UIMessageStreamOptions<UI_MESSAGE>): ReadableStream< InferUIMessageChunk<UI_MESSAGE> > { let outcome: UIMessageStreamOutcome = { status: 'unknown' }; let hasFatalFailure = false; // streamText follows each invalid tool call with a tool-error part so the // model receives a tool result. The tool-input-error chunk already reports // the failure, so the tool-error is skipped to avoid a second onError call. // TODO AI SDK 8: remove once invalid tool calls no longer inject tool-error parts const invalidToolCallIds = new Set<string>(); // When tool errors are redacted (`redactToolErrors`, which defaults to `true` // with a custom `onError`), a failed tool call has two error texts: the model // receives `getErrorMessage(error)` with the next step, and the UI receives // the `onError` result. Reasoning from a later step was produced after the // model read its text, so UI messages that contain both no longer reproduce // the history that this reasoning belongs to. let mismatchedToolCallIdInCurrentStep: string | undefined; let mismatchedToolCallIdInEarlierStep: string | undefined; let hasWarnedAboutToolErrorMismatch = false; const setSourceOutcome = (newOutcome: UIMessageStreamOutcome) => { if ( !hasFatalFailure && outcome.status !== 'completed' && outcome.status !== 'aborted' && newOutcome.status !== 'unknown' && (outcome.status === 'unknown' || newOutcome.status !== 'failed') ) { outcome = newOutcome; } }; const failOutcome = (error: unknown) => { hasFatalFailure = true; outcome = { status: 'failed', error }; }; const responseMessageId = generateMessageId != null ? getResponseUIMessageId({ originalMessages, responseMessageId: generateMessageId, }) : undefined; const sourceReader = stream.getReader(); let sourceReaderReleased = false; let sourceStreamCancelled = false; const releaseSourceReader = () => { if (!sourceReaderReleased) { sourceReader.releaseLock(); sourceReaderReleased = true; } }; const sourceStream = new ReadableStream<TextStreamPart<TOOLS>>({ async pull(controller) { try { const { done, value } = await sourceReader.read(); if (done) { releaseSourceReader(); if (!sourceStreamCancelled) { controller.close(); } } else { controller.enqueue(value); } } catch (error) { releaseSourceReader(); if (!sourceStreamCancelled) { failOutcome(error); controller.error(error); } } }, async cancel(reason) { sourceStreamCancelled = true; if (sourceReaderReleased) { return; } try { await sourceReader.cancel(reason); } finally { releaseSourceReader(); } }, }); const uiMessageChunkStream = sourceStream.pipeThrough( new TransformStream({ transform: async (part, controller) => { try { if (part.type === 'tool-call' && part.invalid) { invalidToolCallIds.add(part.toolCallId); } const isInvalidToolCallError = part.type === 'tool-error' && !part.providerExecuted && invalidToolCallIds.delete(part.toolCallId); const messageMetadataValue = messageMetadata?.({ part }); const uiMessageChunk = isInvalidToolCallError ? undefined : toUIMessageChunk(part, { tools, sendReasoning, sendSources, sendStart, sendFinish, onError, redactToolErrors, messageMetadata: messageMetadataValue, responseMessageId, }); if (part.type === 'start-step') { // the model receives the tool errors of a step with the next step mismatchedToolCallIdInEarlierStep ??= mismatchedToolCallIdInCurrentStep; mismatchedToolCallIdInCurrentStep = undefined; } else if ( (part.type === 'tool-error' || part.type === 'tool-call') && !part.providerExecuted && (uiMessageChunk?.type === 'tool-output-error' || uiMessageChunk?.type === 'tool-input-error') ) { const { errorText } = uiMessageChunk as UIMessageChunk & { type: 'tool-output-error' | 'tool-input-error'; }; const modelErrorText = getModelToolErrorText(part.error); if (modelErrorText != null && errorText !== modelErrorText) { mismatchedToolCallIdInCurrentStep ??= part.toolCallId; } } else if ( uiMessageChunk?.type === 'reasoning-start' && mismatchedToolCallIdInEarlierStep != null && !hasWarnedAboutToolErrorMismatch ) { hasWarnedAboutToolErrorMismatch = true; logWarnings({ warnings: [ { type: 'other', message: `The error text sent to the UI for tool call "${mismatchedToolCallIdInEarlierStep}" differs from the error text the model received, ` + 'and reasoning from a later step was sent to the UI. ' + 'Converting these UI messages back to model messages changes the tool result. ' + 'Models that bind reasoning to the preceding messages (for example Anthropic models with preserved thinking) reject that history. ' + 'See https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage#tool-error-text-and-message-history', }, ], }); } if (uiMessageChunk != null && part.type !== 'finish-step') { controller.enqueue(uiMessageChunk); } // start and finish events already include metadata in the converted // chunk; for other part types emit a separate message-metadata chunk if ( messageMetadataValue != null && part.type !== 'start' && part.type !== 'finish' ) { controller.enqueue({ type: 'message-metadata', messageMetadata: messageMetadataValue, }); } // Apply step metadata before the boundary triggers a UI snapshot. if (uiMessageChunk != null && part.type === 'finish-step') { controller.enqueue(uiMessageChunk); } if (part.type === 'finish') { setSourceOutcome({ status: 'completed' }); } else if (part.type === 'abort') { setSourceOutcome({ status: 'aborted' }); } else if (part.type === 'error') { setSourceOutcome({ status: 'failed', error: part.error }); } } catch (error) { failOutcome(error); throw error; } }, }), ); return handleUIMessageStreamFinish({ stream: uiMessageChunkStream, messageId: responseMessageId ?? generateMessageId?.(), originalMessages, onStepEnd: onStepEnd ?? onStepFinish, onEnd: onEnd ?? onFinish, onError: onError ?? (() => 'An error occurred.'), getOutcome: () => outcome, }); }