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.

881 lines (820 loc) • 29.7 kB
import { citationSchema } from '../types/citation'; import { TypeValidationError, type JSONObject } from '@ai-sdk/provider'; import { lazySchema, safeValidateTypes, validateTypes, zodSchema, type FlexibleSchema, type MaybePromiseLike, type Tool, } from '@ai-sdk/provider-utils'; import { InvalidArgumentError } from '../error'; import { jsonValueSchema } from '../types/json-value'; import { getOwn } from '../util/get-own'; import { isDeepEqualData } from '../util/is-deep-equal-data'; import { providerMetadataSchema as providerMetadataRecordSchema } from '../types/provider-metadata'; import { z, type ZodOutput, type ZodType } from '../util/zod'; import type { DataUIPart, DynamicToolUIPart, InferUIMessageData, InferUIMessageTools, ToolUIPart, UIMessage, } from './ui-messages'; import { markToolPartAsUnavailable } from './unavailable-tool'; import { warnIfUIMessageHasDeprecatedRawInput } from './warn-if-ui-message-has-deprecated-raw-input'; /** * Returns the path to the first value that can never be part of a JSON value * (a non-finite number, bigint, symbol, or function), if there is one. */ function findInvalidJsonValuePath( value: unknown, path: PropertyKey[] = [], visited = new WeakSet<object>(), ): PropertyKey[] | undefined { switch (typeof value) { case 'number': return Number.isFinite(value) ? undefined : [...path]; case 'bigint': case 'symbol': case 'function': return [...path]; case 'object': { if (value === null || visited.has(value)) { return undefined; } visited.add(value); const keys = Array.isArray(value) ? value.keys() : Object.keys(value); for (const key of keys) { // record schemas ignore `__proto__` keys: if (key === '__proto__') continue; path.push(key); const invalidPath = findInvalidJsonValuePath( (value as Record<PropertyKey, unknown>)[key], path, visited, ); if (invalidPath != null) return invalidPath; path.pop(); } return undefined; } default: return undefined; } } /** * Rejects values that contain an invalid JSON value with a single issue before * `schema` validates them. * * The JSON value schema is a recursive union. It reports the issues of every * alternative at every nesting level around an invalid value, so the size of a * validation error would grow much faster than the (untrusted) input. * * @param jsonValuePath - Path from the validated value to the JSON value. */ function withJsonValueCheck<SCHEMA extends ZodType>( schema: SCHEMA, jsonValuePath: string[] = [], ) { return z.pipe( z.unknown().check(context => { let jsonValue = context.value; for (const key of jsonValuePath) { jsonValue = jsonValue != null && typeof jsonValue === 'object' ? (jsonValue as Record<string, unknown>)[key] : undefined; } const invalidPath = findInvalidJsonValuePath(jsonValue); if (invalidPath != null) { context.issues.push({ code: 'custom', message: 'Invalid JSON value. Expected null, a string, a finite number, ' + 'a boolean, an array, or an object.', input: context.value, path: [...jsonValuePath, ...invalidPath], }); } }), schema, ); } const toolMetadataRecordSchema: ZodType<JSONObject> = z.record( z.string(), z.optional(jsonValueSchema), ); const toolMetadataSchema = withJsonValueCheck(toolMetadataRecordSchema); const providerMetadataSchema = withJsonValueCheck(providerMetadataRecordSchema); const providerReferenceSchema = z.record(z.string(), z.string()); const uiCitationSchema = withJsonValueCheck(citationSchema, [ 'source', 'providerMetadata', ]); function isEmptyObject(value: unknown): value is Record<string, never> { return ( value != null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0 ); } function asDynamicToolPart(toolPart: ToolUIPart): DynamicToolUIPart { const { type, ...part } = toolPart; return markToolPartAsUnavailable({ ...part, type: 'dynamic-tool', toolName: type.slice(5), } as DynamicToolUIPart); } function getToolPartInputSchemaInput( toolPart: ToolUIPart, ): { value: unknown } | undefined { return toolPart.approval != null && Object.prototype.hasOwnProperty.call(toolPart.approval, 'inputSchemaInput') ? { value: toolPart.approval.inputSchemaInput } : undefined; } /** * Creates an array schema that validates its elements in order and stops at * the first invalid element. * * UI messages are untrusted input. `z.array` validates every element and * reports the issues of all invalid elements, so the memory and time needed to * reject an input would grow with the number of invalid elements in it. */ function arrayUntilFirstInvalidElement<SCHEMA extends ZodType>( selectElementSchema: (element: unknown) => SCHEMA, ) { return z.pipe( z.array(z.unknown()), z.transform((elements: unknown[], context) => { const validatedElements: Array<ZodOutput<SCHEMA>> = []; for (const [index, element] of elements.entries()) { const result = z.safeParse(selectElementSchema(element), element); if (!result.success) { for (const issue of result.error.issues) { // re-report the element's issues with their path in the array: context.issues.push({ ...issue, path: [index, ...issue.path], } as (typeof context.issues)[number]); } return z.NEVER; } validatedElements.push(result.data); } return validatedElements; }), ); } const uiMessagesSchema = lazySchema(() => { const approvalRequestedSchema = z.object({ id: z.string(), approved: z.optional(z.never()), descriptor: z.optional(z.unknown()), requestReason: z.optional(z.string()), reason: z.optional(z.never()), isAutomatic: z.optional(z.boolean()), signature: z.optional(z.string()), inputSchemaInput: z.optional(z.unknown()), }); const approvalRespondedSchema = z.extend(approvalRequestedSchema, { approved: z.boolean(), reason: z.optional(z.string()), }); const approvalGrantedSchema = z.extend(approvalRespondedSchema, { approved: z.literal(true), }); const approvalDeniedSchema = z.extend(approvalRespondedSchema, { approved: z.literal(false), }); const steeringMessagePartSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('text'), text: z.string(), providerMetadata: z.optional(providerMetadataSchema), }), z.object({ type: z.literal('file'), mediaType: z.string(), filename: z.optional(z.string()), url: z.string(), providerReference: z.optional(providerReferenceSchema), providerMetadata: z.optional(providerMetadataSchema), }), ]); // Parts with a missing or unknown type are validated against the static part // schema, whose default error would only mention the static part types. const invalidPartTypeMessage = 'Invalid UI message part type. Expected a known part type ' + '(for example "text" or "dynamic-tool") or a type starting with "data-" or "tool-".'; const staticPartSchema = z.discriminatedUnion( 'type', [ z.object({ type: z.literal('text'), text: z.string(), citations: z.optional( arrayUntilFirstInvalidElement(() => uiCitationSchema), ), state: z.optional(z.enum(['streaming', 'done'])), providerMetadata: z.optional(providerMetadataSchema), }), z.object({ type: z.literal('reasoning'), id: z.optional(z.string()), text: z.string(), state: z.optional(z.enum(['streaming', 'done'])), providerMetadata: z.optional(providerMetadataSchema), }), z.object({ type: z.literal('custom'), kind: z.string(), providerMetadata: z.optional(providerMetadataSchema), }), z.object({ type: z.literal('source-url'), sourceId: z.string(), url: z.string(), title: z.optional(z.string()), providerMetadata: z.optional(providerMetadataSchema), }), z.object({ type: z.literal('source-document'), sourceId: z.string(), mediaType: z.string(), title: z.string(), filename: z.optional(z.string()), providerMetadata: z.optional(providerMetadataSchema), }), z.object({ type: z.literal('file'), mediaType: z.string(), filename: z.optional(z.string()), url: z.string(), providerReference: z.optional(providerReferenceSchema), providerMetadata: z.optional(providerMetadataSchema), }), z.object({ type: z.literal('reasoning-file'), mediaType: z.string(), url: z.string(), providerMetadata: z.optional(providerMetadataSchema), }), z.object({ type: z.literal('steering-message'), id: z.string(), parts: arrayUntilFirstInvalidElement(() => steeringMessagePartSchema), }), z.object({ type: z.literal('step-start'), }), ], { error: issue => issue.code === 'invalid_union' ? invalidPartTypeMessage : undefined, }, ); const dataPartSchema = z.object({ type: z.string().check(z.startsWith('data-')), id: z.optional(z.string()), data: z.unknown(), }); const dynamicToolPartSchema = z.discriminatedUnion('state', [ z.object({ type: z.literal('dynamic-tool'), dynamic: z.optional(z.literal(false)), toolName: z.string(), toolCallId: z.string(), title: z.optional(z.string()), toolMetadata: z.optional(toolMetadataSchema), state: z.literal('input-streaming'), input: z.optional(z.unknown()), rawInput: z.optional(z.string()), inputEncoding: z.optional(z.enum(['json', 'text'])), providerExecuted: z.optional(z.boolean()), callProviderMetadata: z.optional(providerMetadataSchema), output: z.optional(z.never()), errorText: z.optional(z.never()), approval: z.optional(z.never()), }), z.object({ type: z.literal('dynamic-tool'), dynamic: z.optional(z.literal(false)), toolName: z.string(), toolCallId: z.string(), title: z.optional(z.string()), toolMetadata: z.optional(toolMetadataSchema), state: z.literal('input-available'), input: z.unknown(), providerExecuted: z.optional(z.boolean()), output: z.optional(z.never()), errorText: z.optional(z.never()), callProviderMetadata: z.optional(providerMetadataSchema), approval: z.optional(z.never()), }), z.object({ type: z.literal('dynamic-tool'), dynamic: z.optional(z.literal(false)), toolName: z.string(), toolCallId: z.string(), title: z.optional(z.string()), toolMetadata: z.optional(toolMetadataSchema), state: z.literal('approval-requested'), input: z.unknown(), providerExecuted: z.optional(z.boolean()), output: z.optional(z.never()), errorText: z.optional(z.never()), callProviderMetadata: z.optional(providerMetadataSchema), approval: approvalRequestedSchema, }), z.object({ type: z.literal('dynamic-tool'), dynamic: z.optional(z.literal(false)), toolName: z.string(), toolCallId: z.string(), title: z.optional(z.string()), toolMetadata: z.optional(toolMetadataSchema), state: z.literal('approval-responded'), input: z.unknown(), providerExecuted: z.optional(z.boolean()), output: z.optional(z.never()), errorText: z.optional(z.never()), callProviderMetadata: z.optional(providerMetadataSchema), approval: approvalRespondedSchema, }), z.object({ type: z.literal('dynamic-tool'), dynamic: z.optional(z.literal(false)), toolName: z.string(), toolCallId: z.string(), title: z.optional(z.string()), toolMetadata: z.optional(toolMetadataSchema), state: z.literal('output-available'), input: z.unknown(), providerExecuted: z.optional(z.boolean()), output: z.unknown(), errorText: z.optional(z.never()), callProviderMetadata: z.optional(providerMetadataSchema), resultProviderMetadata: z.optional(providerMetadataSchema), preliminary: z.optional(z.boolean()), approval: z.optional(approvalGrantedSchema), }), z.object({ type: z.literal('dynamic-tool'), dynamic: z.optional(z.literal(false)), toolName: z.string(), toolCallId: z.string(), title: z.optional(z.string()), toolMetadata: z.optional(toolMetadataSchema), state: z.literal('output-error'), input: z.optional(z.unknown()), rawInput: z.optional(z.unknown()), providerExecuted: z.optional(z.boolean()), output: z.optional(z.never()), errorText: z.string(), callProviderMetadata: z.optional(providerMetadataSchema), resultProviderMetadata: z.optional(providerMetadataSchema), approval: z.optional(approvalGrantedSchema), }), z.object({ type: z.literal('dynamic-tool'), dynamic: z.optional(z.literal(false)), toolName: z.string(), toolCallId: z.string(), title: z.optional(z.string()), toolMetadata: z.optional(toolMetadataSchema), state: z.literal('output-denied'), input: z.unknown(), providerExecuted: z.optional(z.boolean()), output: z.optional(z.never()), errorText: z.optional(z.never()), callProviderMetadata: z.optional(providerMetadataSchema), approval: approvalDeniedSchema, }), ]); const toolPartSchema = z.discriminatedUnion('state', [ z.object({ type: z.string().check(z.startsWith('tool-')), toolCallId: z.string(), title: z.optional(z.string()), toolMetadata: z.optional(toolMetadataSchema), state: z.literal('input-streaming'), providerExecuted: z.optional(z.boolean()), callProviderMetadata: z.optional(providerMetadataSchema), input: z.optional(z.unknown()), rawInput: z.optional(z.string()), inputEncoding: z.optional(z.enum(['json', 'text'])), output: z.optional(z.never()), errorText: z.optional(z.never()), approval: z.optional(z.never()), }), z.object({ type: z.string().check(z.startsWith('tool-')), toolCallId: z.string(), title: z.optional(z.string()), toolMetadata: z.optional(toolMetadataSchema), state: z.literal('input-available'), providerExecuted: z.optional(z.boolean()), input: z.unknown(), output: z.optional(z.never()), errorText: z.optional(z.never()), callProviderMetadata: z.optional(providerMetadataSchema), approval: z.optional(z.never()), }), z.object({ type: z.string().check(z.startsWith('tool-')), toolCallId: z.string(), title: z.optional(z.string()), toolMetadata: z.optional(toolMetadataSchema), state: z.literal('approval-requested'), input: z.unknown(), providerExecuted: z.optional(z.boolean()), output: z.optional(z.never()), errorText: z.optional(z.never()), callProviderMetadata: z.optional(providerMetadataSchema), approval: approvalRequestedSchema, }), z.object({ type: z.string().check(z.startsWith('tool-')), toolCallId: z.string(), title: z.optional(z.string()), toolMetadata: z.optional(toolMetadataSchema), state: z.literal('approval-responded'), input: z.unknown(), providerExecuted: z.optional(z.boolean()), output: z.optional(z.never()), errorText: z.optional(z.never()), callProviderMetadata: z.optional(providerMetadataSchema), approval: approvalRespondedSchema, }), z.object({ type: z.string().check(z.startsWith('tool-')), toolCallId: z.string(), title: z.optional(z.string()), toolMetadata: z.optional(toolMetadataSchema), state: z.literal('output-available'), providerExecuted: z.optional(z.boolean()), input: z.unknown(), output: z.unknown(), errorText: z.optional(z.never()), callProviderMetadata: z.optional(providerMetadataSchema), resultProviderMetadata: z.optional(providerMetadataSchema), preliminary: z.optional(z.boolean()), approval: z.optional(approvalGrantedSchema), }), z.object({ type: z.string().check(z.startsWith('tool-')), toolCallId: z.string(), title: z.optional(z.string()), toolMetadata: z.optional(toolMetadataSchema), state: z.literal('output-error'), providerExecuted: z.optional(z.boolean()), input: z.optional(z.unknown()), rawInput: z.optional(z.unknown()), output: z.optional(z.never()), errorText: z.string(), callProviderMetadata: z.optional(providerMetadataSchema), resultProviderMetadata: z.optional(providerMetadataSchema), approval: z.optional(approvalGrantedSchema), }), z.object({ type: z.string().check(z.startsWith('tool-')), toolCallId: z.string(), title: z.optional(z.string()), toolMetadata: z.optional(toolMetadataSchema), state: z.literal('output-denied'), providerExecuted: z.optional(z.boolean()), input: z.unknown(), output: z.optional(z.never()), errorText: z.optional(z.never()), callProviderMetadata: z.optional(providerMetadataSchema), approval: approvalDeniedSchema, }), ]); const messageSchema = z .object({ id: z.string(), role: z.enum(['system', 'user', 'assistant']), metadata: z.optional(z.unknown()), // Validate each part only against the schemas for its type. A union of // all part schemas would report the issues of every alternative for // each invalid part. parts: arrayUntilFirstInvalidElement((part: unknown) => { const type = (part as { type?: unknown } | null | undefined)?.type; if (typeof type === 'string') { if (type.startsWith('data-')) return dataPartSchema; if (type.startsWith('tool-')) return toolPartSchema; if (type === 'dynamic-tool') return dynamicToolPartSchema; } return staticPartSchema; }), }) .check(context => { const message = context.value; if (message.role !== 'assistant' && message.parts.length === 0) { context.issues.push({ continue: true, origin: 'array', code: 'too_small', minimum: 1, inclusive: true, input: message.parts, path: ['parts'], message: 'Message must contain at least one part', }); } }); return zodSchema( arrayUntilFirstInvalidElement(() => messageSchema).check( z.minLength(1, 'Messages array must not be empty'), ), ); }); export type SafeValidateUIMessagesResult<UI_MESSAGE extends UIMessage> = | { success: true; data: Array<UI_MESSAGE>; } | { success: false; error: Error; }; type ValidateUIMessagesOptions<UI_MESSAGE extends UIMessage> = { messages: unknown; metadataSchema?: FlexibleSchema<UIMessage['metadata']>; dataSchemas?: { [NAME in keyof InferUIMessageData<UI_MESSAGE> & string]?: FlexibleSchema< InferUIMessageData<UI_MESSAGE>[NAME] >; }; tools?: { [NAME in keyof InferUIMessageTools<UI_MESSAGE> & string]?: | Tool< InferUIMessageTools<UI_MESSAGE>[NAME]['input'], InferUIMessageTools<UI_MESSAGE>[NAME]['output'] > | { inputSchema: FlexibleSchema< InferUIMessageTools<UI_MESSAGE>[NAME]['input'] >; outputSchema?: FlexibleSchema< InferUIMessageTools<UI_MESSAGE>[NAME]['output'] >; }; }; experimental_refineToolInput?: { [NAME in keyof InferUIMessageTools<UI_MESSAGE> & string]?: ( input: InferUIMessageTools<UI_MESSAGE>[NAME]['input'], ) => MaybePromiseLike<InferUIMessageTools<UI_MESSAGE>[NAME]['input']>; }; }; async function safeValidateUIMessagesInternal<UI_MESSAGE extends UIMessage>( { messages, metadataSchema, dataSchemas, tools, experimental_refineToolInput, }: ValidateUIMessagesOptions<UI_MESSAGE>, { convertMissingTerminalToolsToDynamic, }: { convertMissingTerminalToolsToDynamic: boolean; }, ): Promise<SafeValidateUIMessagesResult<UI_MESSAGE>> { try { if (messages == null) { return { success: false, error: new InvalidArgumentError({ parameter: 'messages', value: messages, message: 'messages parameter must be provided', }), }; } const validatedMessages = await validateTypes({ value: messages, schema: uiMessagesSchema, }); warnIfUIMessageHasDeprecatedRawInput(validatedMessages); if (metadataSchema) { for (const [msgIdx, message] of validatedMessages.entries()) { message.metadata = await validateTypes({ value: message.metadata, schema: metadataSchema, context: { field: `messages[${msgIdx}].metadata`, entityId: message.id, }, }); } } const shouldValidateToolParts = tools != null || convertMissingTerminalToolsToDynamic; if (dataSchemas || shouldValidateToolParts) { for (const [msgIdx, message] of validatedMessages.entries()) { for (const [partIdx, part] of message.parts.entries()) { // Data part validation if (dataSchemas && part.type.startsWith('data-')) { const dataPart = part as DataUIPart<InferUIMessageData<UI_MESSAGE>>; const dataName = dataPart.type.slice(5); const dataSchema = dataSchemas[dataName]; if (!dataSchema) { return { success: false, error: new TypeValidationError({ value: dataPart.data, cause: `No data schema found for data part ${dataName}`, context: { field: `messages[${msgIdx}].parts[${partIdx}].data`, entityName: dataName, entityId: dataPart.id, }, }), }; } dataPart.data = await validateTypes({ value: dataPart.data, schema: dataSchema, context: { field: `messages[${msgIdx}].parts[${partIdx}].data`, entityName: dataName, entityId: dataPart.id, }, }); } // Tool part validation if (shouldValidateToolParts && part.type.startsWith('tool-')) { const toolPart = part as ToolUIPart< InferUIMessageTools<UI_MESSAGE> >; const toolName = toolPart.type.slice(5); const tool = tools == null ? undefined : getOwn(tools, toolName); const isTerminal = toolPart.state === 'output-available' || toolPart.state === 'output-error' || toolPart.state === 'output-denied'; if (!tool && isTerminal) { if (tools != null || convertMissingTerminalToolsToDynamic) { // Persisted terminal history can reference tools that are no // longer registered. Normalize those parts so callers do not // receive unvalidated values under current static tool types. message.parts[partIdx] = asDynamicToolPart( toolPart, ) as (typeof message.parts)[number]; } continue; } // TODO support dynamic tools if (!tool) { return { success: false, error: new TypeValidationError({ value: toolPart.input, cause: `No tool schema found for tool part ${toolName}`, context: { field: `messages[${msgIdx}].parts[${partIdx}].input`, entityName: toolName, entityId: toolPart.toolCallId, }, }), }; } const inputValidationContext = { field: `messages[${msgIdx}].parts[${partIdx}].input`, entityName: toolName, entityId: toolPart.toolCallId, }; const inputSchemaInput = getToolPartInputSchemaInput(toolPart); const inputToValidate = inputSchemaInput == null ? toolPart.input : inputSchemaInput.value; let convertToDynamic = false; // Tool input validation if ( toolPart.state !== 'input-streaming' && (toolPart.state !== 'output-error' || inputSchemaInput != null || toolPart.input !== undefined) ) { const result = await safeValidateTypes({ value: inputToValidate, schema: tool.inputSchema, context: inputValidationContext, }); let inputError: TypeValidationError | undefined; if (!result.success) { inputError = result.error; } else if (inputSchemaInput != null) { try { const refine = getOwn(experimental_refineToolInput, toolName); const reconstructedInput = refine == null ? result.value : await refine(result.value); if (!isDeepEqualData(reconstructedInput, toolPart.input)) { inputError = new TypeValidationError({ value: toolPart.input, cause: 'Tool input does not match the output reconstructed from inputSchemaInput.', context: inputValidationContext, }); } } catch (error) { inputError = new TypeValidationError({ value: inputToValidate, cause: error, context: inputValidationContext, }); } } if (inputError != null) { // Failed calls and empty terminal inputs must remain loadable, // but must not claim to match the current static input type. if ( toolPart.state === 'output-error' || (toolPart.state === 'output-available' && isEmptyObject(toolPart.input)) ) { convertToDynamic = true; } else { throw inputError; } } } // Tool output validation if (toolPart.state === 'output-available' && tool.outputSchema) { await validateTypes({ value: toolPart.output, schema: tool.outputSchema, context: { field: `messages[${msgIdx}].parts[${partIdx}].output`, entityName: toolName, entityId: toolPart.toolCallId, }, }); } if (convertToDynamic) { message.parts[partIdx] = asDynamicToolPart( toolPart, ) as (typeof message.parts)[number]; } } } } } return { success: true, data: validatedMessages as Array<UI_MESSAGE>, }; } catch (error) { const err = error as Error; return { success: false, error: err, }; } } /** * Validates a list of UI messages like `validateUIMessages`, * but instead of throwing it returns `{ success: true, data }` * or `{ success: false, error }`. */ export async function safeValidateUIMessages<UI_MESSAGE extends UIMessage>( options: ValidateUIMessagesOptions<UI_MESSAGE>, ): Promise<SafeValidateUIMessagesResult<UI_MESSAGE>> { return safeValidateUIMessagesInternal(options, { convertMissingTerminalToolsToDynamic: false, }); } /** * Validates a list of UI messages. * * Metadata, data parts, and generic tool call structures are only validated if * the corresponding schemas are provided. Otherwise, they are assumed to be * valid. */ export async function validateUIMessages<UI_MESSAGE extends UIMessage>( options: ValidateUIMessagesOptions<UI_MESSAGE>, ): Promise<Array<UI_MESSAGE>> { const response = await safeValidateUIMessages(options); if (!response.success) throw response.error; return response.data; } export async function validateUIMessagesForAgent<UI_MESSAGE extends UIMessage>( options: ValidateUIMessagesOptions<UI_MESSAGE>, ): Promise<Array<UI_MESSAGE>> { const response = await safeValidateUIMessagesInternal(options, { // Agent tool sets can include ephemeral tools (for example, tools from a // disconnected MCP server), so terminal history is converted to dynamic // tool parts when those tools are no longer registered. convertMissingTerminalToolsToDynamic: true, }); if (!response.success) throw response.error; return response.data; }