ai
Version:
AI SDK by Vercel - The AI Toolkit for TypeScript and JavaScript
1,708 lines (1,657 loc) • 640 kB
text/typescript
import {
LanguageModelV3,
LanguageModelV3CallOptions,
LanguageModelV3FunctionTool,
LanguageModelV3Prompt,
LanguageModelV3ProviderTool,
LanguageModelV3StreamPart,
LanguageModelV3Usage,
SharedV3ProviderMetadata,
SharedV3Warning,
} from '@ai-sdk/provider';
import {
delay,
DelayedPromise,
dynamicTool,
jsonSchema,
ModelMessage,
tool,
Tool,
ToolExecuteFunction,
} from '@ai-sdk/provider-utils';
import {
convertArrayToReadableStream,
convertAsyncIterableToArray,
convertReadableStreamToArray,
convertResponseStreamToArray,
mockId,
} from '@ai-sdk/provider-utils/test';
import assert from 'node:assert';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
vitest,
} from 'vitest';
import { z } from 'zod/v4';
import { Output } from '..';
import * as logWarningsModule from '../logger/log-warnings';
import { MockLanguageModelV3 } from '../test/mock-language-model-v3';
import { createMockServerResponse } from '../test/mock-server-response';
import { MockTracer } from '../test/mock-tracer';
import { mockValues } from '../test/mock-values';
import {
asLanguageModelUsage,
createNullLanguageModelUsage,
} from '../types/usage';
import { StepResult } from './step-result';
import { stepCountIs } from './stop-condition';
import { streamText, StreamTextOnFinishCallback } from './stream-text';
import { StreamTextResult, TextStreamPart } from './stream-text-result';
import { ToolSet } from './tool-set';
const defaultSettings = () =>
({
prompt: 'prompt',
experimental_generateMessageId: mockId({ prefix: 'msg' }),
_internal: {
generateId: mockId({ prefix: 'id' }),
},
onError: () => {},
}) as const;
const testUsage: LanguageModelV3Usage = {
inputTokens: {
total: 3,
noCache: 3,
cacheRead: undefined,
cacheWrite: undefined,
},
outputTokens: {
total: 10,
text: 10,
reasoning: undefined,
},
};
const testUsage2: LanguageModelV3Usage = {
inputTokens: {
total: 3,
noCache: 3,
cacheRead: 0,
cacheWrite: 0,
},
outputTokens: {
total: 10,
text: 10,
reasoning: 10,
},
};
function createTestModel({
warnings = [],
stream = convertArrayToReadableStream([
{
type: 'stream-start',
warnings,
},
{
type: 'response-metadata',
id: 'id-0',
modelId: 'mock-model-id',
timestamp: new Date(0),
},
{ type: 'text-start', id: '1' },
{ type: 'text-delta', id: '1', delta: 'Hello' },
{ type: 'text-delta', id: '1', delta: ', ' },
{ type: 'text-delta', id: '1', delta: `world!` },
{ type: 'text-end', id: '1' },
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: testUsage,
providerMetadata: {
testProvider: { testKey: 'testValue' },
},
},
]),
request = undefined,
response = undefined,
}: {
stream?: ReadableStream<LanguageModelV3StreamPart>;
request?: { body: string };
response?: { headers: Record<string, string> };
warnings?: SharedV3Warning[];
} = {}): LanguageModelV3 {
return new MockLanguageModelV3({
doStream: async () => ({ stream, request, response, warnings }),
});
}
const modelWithSources = new MockLanguageModelV3({
doStream: async () => ({
stream: convertArrayToReadableStream([
{
type: 'source',
sourceType: 'url',
id: '123',
url: 'https://example.com',
title: 'Example',
providerMetadata: { provider: { custom: 'value' } },
},
{ type: 'text-start', id: '1' },
{ type: 'text-delta', id: '1', delta: 'Hello!' },
{ type: 'text-end', id: '1' },
{
type: 'source',
sourceType: 'url',
id: '456',
url: 'https://example.com/2',
title: 'Example 2',
providerMetadata: { provider: { custom: 'value2' } },
},
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: testUsage,
},
]),
}),
});
const modelWithDocumentSources = new MockLanguageModelV3({
doStream: async () => ({
stream: convertArrayToReadableStream([
{
type: 'source',
sourceType: 'document',
id: 'doc-123',
mediaType: 'application/pdf',
title: 'Document Example',
filename: 'example.pdf',
providerMetadata: { provider: { custom: 'doc-value' } },
},
{ type: 'text-start', id: '1' },
{ type: 'text-delta', id: '1', delta: 'Hello from document!' },
{ type: 'text-end', id: '1' },
{
type: 'source',
sourceType: 'document',
id: 'doc-456',
mediaType: 'text/plain',
title: 'Text Document',
providerMetadata: { provider: { custom: 'doc-value2' } },
},
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: testUsage,
},
]),
}),
});
const modelWithFiles = new MockLanguageModelV3({
doStream: async () => ({
stream: convertArrayToReadableStream([
{
type: 'file',
data: 'Hello World',
mediaType: 'text/plain',
},
{ type: 'text-start', id: '1' },
{ type: 'text-delta', id: '1', delta: 'Hello!' },
{ type: 'text-end', id: '1' },
{
type: 'file',
data: 'QkFVRw==',
mediaType: 'image/jpeg',
},
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: testUsage,
},
]),
}),
});
const modelWithReasoning = new MockLanguageModelV3({
doStream: async () => ({
stream: convertArrayToReadableStream([
{
type: 'response-metadata',
id: 'id-0',
modelId: 'mock-model-id',
timestamp: new Date(0),
},
{ type: 'reasoning-start', id: '1' },
{
type: 'reasoning-delta',
id: '1',
delta: 'I will open the conversation',
},
{
type: 'reasoning-delta',
id: '1',
delta: ' with witty banter.',
},
{
type: 'reasoning-delta',
id: '1',
delta: '',
providerMetadata: {
testProvider: { signature: '1234567890' },
} as SharedV3ProviderMetadata,
},
{ type: 'reasoning-end', id: '1' },
{
type: 'reasoning-start',
id: '2',
providerMetadata: {
testProvider: { redactedData: 'redacted-reasoning-data' },
},
},
{ type: 'reasoning-end', id: '2' },
{ type: 'reasoning-start', id: '3' },
{
type: 'reasoning-delta',
id: '3',
delta: ' Once the user has relaxed,',
},
{
type: 'reasoning-delta',
id: '3',
delta: ' I will pry for valuable information.',
},
{
type: 'reasoning-end',
id: '3',
providerMetadata: {
testProvider: { signature: '1234567890' },
} as SharedV3ProviderMetadata,
},
{
type: 'reasoning-start',
id: '4',
providerMetadata: {
testProvider: { signature: '1234567890' },
} as SharedV3ProviderMetadata,
},
{
type: 'reasoning-delta',
id: '4',
delta: ' I need to think about',
},
{
type: 'reasoning-delta',
id: '4',
delta: ' this problem carefully.',
},
{
type: 'reasoning-start',
id: '5',
providerMetadata: {
testProvider: { signature: '1234567890' },
} as SharedV3ProviderMetadata,
},
{
type: 'reasoning-delta',
id: '5',
delta: ' The best solution',
},
{
type: 'reasoning-delta',
id: '5',
delta: ' requires careful',
},
{
type: 'reasoning-delta',
id: '5',
delta: ' consideration of all factors.',
},
{
type: 'reasoning-end',
id: '4',
providerMetadata: {
testProvider: { signature: '0987654321' },
} as SharedV3ProviderMetadata,
},
{
type: 'reasoning-end',
id: '5',
providerMetadata: {
testProvider: { signature: '0987654321' },
} as SharedV3ProviderMetadata,
},
{ type: 'text-start', id: '1' },
{ type: 'text-delta', id: '1', delta: 'Hi' },
{ type: 'text-delta', id: '1', delta: ' there!' },
{
type: 'text-end',
id: '1',
providerMetadata: {
testProvider: { signature: '0987654321' },
} as SharedV3ProviderMetadata,
},
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: testUsage,
},
]),
}),
});
describe('streamText', () => {
let logWarningsSpy: ReturnType<typeof vitest.spyOn>;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date(0));
logWarningsSpy = vitest
.spyOn(logWarningsModule, 'logWarnings')
.mockImplementation(() => {});
});
afterEach(() => {
vi.useRealTimers();
logWarningsSpy.mockRestore();
});
describe('result.textStream', () => {
it('should send text deltas', async () => {
const result = streamText({
model: new MockLanguageModelV3({
doStream: async ({ prompt }) => {
expect(prompt).toStrictEqual([
{
role: 'user',
content: [{ type: 'text', text: 'test-input' }],
providerOptions: undefined,
},
]);
return {
stream: convertArrayToReadableStream([
{ type: 'text-start', id: '1' },
{ type: 'text-delta', id: '1', delta: 'Hello' },
{ type: 'text-delta', id: '1', delta: ', ' },
{ type: 'text-delta', id: '1', delta: `world!` },
{ type: 'text-end', id: '1' },
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: testUsage,
},
]),
};
},
}),
prompt: 'test-input',
});
expect(
await convertAsyncIterableToArray(result.textStream),
).toStrictEqual(['Hello', ', ', 'world!']);
});
it('should filter out empty text deltas', async () => {
const result = streamText({
model: createTestModel({
stream: convertArrayToReadableStream([
{ type: 'text-start', id: '1' },
{ type: 'text-delta', id: '1', delta: '' },
{ type: 'text-delta', id: '1', delta: 'Hello' },
{ type: 'text-delta', id: '1', delta: '' },
{ type: 'text-delta', id: '1', delta: ', ' },
{ type: 'text-delta', id: '1', delta: '' },
{ type: 'text-delta', id: '1', delta: 'world!' },
{ type: 'text-delta', id: '1', delta: '' },
{ type: 'text-end', id: '1' },
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: testUsage,
},
]),
}),
prompt: 'test-input',
});
expect(
await convertAsyncIterableToArray(result.textStream),
).toMatchSnapshot();
});
it('should not include reasoning content in textStream', async () => {
const result = streamText({
model: modelWithReasoning,
...defaultSettings(),
});
expect(
await convertAsyncIterableToArray(result.textStream),
).toMatchSnapshot();
});
});
describe('result.fullStream', () => {
it('should send text deltas', async () => {
const result = streamText({
model: new MockLanguageModelV3({
doStream: async ({ prompt }) => {
expect(prompt).toStrictEqual([
{
role: 'user',
content: [{ type: 'text', text: 'test-input' }],
providerOptions: undefined,
},
]);
return {
stream: convertArrayToReadableStream([
{
type: 'response-metadata',
id: 'response-id',
modelId: 'response-model-id',
timestamp: new Date(5000),
},
{ type: 'text-start', id: '1' },
{ type: 'text-delta', id: '1', delta: 'Hello' },
{ type: 'text-delta', id: '1', delta: ', ' },
{ type: 'text-delta', id: '1', delta: `world!` },
{ type: 'text-end', id: '1' },
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: testUsage,
},
]),
};
},
}),
prompt: 'test-input',
});
expect(await convertAsyncIterableToArray(result.fullStream))
.toMatchInlineSnapshot(`
[
{
"type": "start",
},
{
"request": {},
"type": "start-step",
"warnings": [],
},
{
"id": "1",
"type": "text-start",
},
{
"id": "1",
"providerMetadata": undefined,
"text": "Hello",
"type": "text-delta",
},
{
"id": "1",
"providerMetadata": undefined,
"text": ", ",
"type": "text-delta",
},
{
"id": "1",
"providerMetadata": undefined,
"text": "world!",
"type": "text-delta",
},
{
"id": "1",
"type": "text-end",
},
{
"finishReason": "stop",
"providerMetadata": undefined,
"rawFinishReason": "stop",
"response": {
"headers": undefined,
"id": "response-id",
"modelId": "response-model-id",
"timestamp": 1970-01-01T00:00:05.000Z,
},
"type": "finish-step",
"usage": {
"cachedInputTokens": undefined,
"inputTokenDetails": {
"cacheReadTokens": undefined,
"cacheWriteTokens": undefined,
"noCacheTokens": 3,
},
"inputTokens": 3,
"outputTokenDetails": {
"reasoningTokens": undefined,
"textTokens": 10,
},
"outputTokens": 10,
"raw": undefined,
"reasoningTokens": undefined,
"totalTokens": 13,
},
},
{
"finishReason": "stop",
"rawFinishReason": "stop",
"totalUsage": {
"cachedInputTokens": undefined,
"inputTokenDetails": {
"cacheReadTokens": undefined,
"cacheWriteTokens": undefined,
"noCacheTokens": 3,
},
"inputTokens": 3,
"outputTokenDetails": {
"reasoningTokens": undefined,
"textTokens": 10,
},
"outputTokens": 10,
"reasoningTokens": undefined,
"totalTokens": 13,
},
"type": "finish",
},
]
`);
});
it('should send reasoning deltas', async () => {
const result = streamText({
model: modelWithReasoning,
...defaultSettings(),
});
expect(await convertAsyncIterableToArray(result.fullStream))
.toMatchInlineSnapshot(`
[
{
"type": "start",
},
{
"request": {},
"type": "start-step",
"warnings": [],
},
{
"id": "1",
"type": "reasoning-start",
},
{
"id": "1",
"providerMetadata": undefined,
"text": "I will open the conversation",
"type": "reasoning-delta",
},
{
"id": "1",
"providerMetadata": undefined,
"text": " with witty banter.",
"type": "reasoning-delta",
},
{
"id": "1",
"providerMetadata": {
"testProvider": {
"signature": "1234567890",
},
},
"text": "",
"type": "reasoning-delta",
},
{
"id": "1",
"type": "reasoning-end",
},
{
"id": "2",
"providerMetadata": {
"testProvider": {
"redactedData": "redacted-reasoning-data",
},
},
"type": "reasoning-start",
},
{
"id": "2",
"type": "reasoning-end",
},
{
"id": "3",
"type": "reasoning-start",
},
{
"id": "3",
"providerMetadata": undefined,
"text": " Once the user has relaxed,",
"type": "reasoning-delta",
},
{
"id": "3",
"providerMetadata": undefined,
"text": " I will pry for valuable information.",
"type": "reasoning-delta",
},
{
"id": "3",
"providerMetadata": {
"testProvider": {
"signature": "1234567890",
},
},
"type": "reasoning-end",
},
{
"id": "4",
"providerMetadata": {
"testProvider": {
"signature": "1234567890",
},
},
"type": "reasoning-start",
},
{
"id": "4",
"providerMetadata": undefined,
"text": " I need to think about",
"type": "reasoning-delta",
},
{
"id": "4",
"providerMetadata": undefined,
"text": " this problem carefully.",
"type": "reasoning-delta",
},
{
"id": "5",
"providerMetadata": {
"testProvider": {
"signature": "1234567890",
},
},
"type": "reasoning-start",
},
{
"id": "5",
"providerMetadata": undefined,
"text": " The best solution",
"type": "reasoning-delta",
},
{
"id": "5",
"providerMetadata": undefined,
"text": " requires careful",
"type": "reasoning-delta",
},
{
"id": "5",
"providerMetadata": undefined,
"text": " consideration of all factors.",
"type": "reasoning-delta",
},
{
"id": "4",
"providerMetadata": {
"testProvider": {
"signature": "0987654321",
},
},
"type": "reasoning-end",
},
{
"id": "5",
"providerMetadata": {
"testProvider": {
"signature": "0987654321",
},
},
"type": "reasoning-end",
},
{
"id": "1",
"type": "text-start",
},
{
"id": "1",
"providerMetadata": undefined,
"text": "Hi",
"type": "text-delta",
},
{
"id": "1",
"providerMetadata": undefined,
"text": " there!",
"type": "text-delta",
},
{
"id": "1",
"providerMetadata": {
"testProvider": {
"signature": "0987654321",
},
},
"type": "text-end",
},
{
"finishReason": "stop",
"providerMetadata": undefined,
"rawFinishReason": "stop",
"response": {
"headers": undefined,
"id": "id-0",
"modelId": "mock-model-id",
"timestamp": 1970-01-01T00:00:00.000Z,
},
"type": "finish-step",
"usage": {
"cachedInputTokens": undefined,
"inputTokenDetails": {
"cacheReadTokens": undefined,
"cacheWriteTokens": undefined,
"noCacheTokens": 3,
},
"inputTokens": 3,
"outputTokenDetails": {
"reasoningTokens": undefined,
"textTokens": 10,
},
"outputTokens": 10,
"raw": undefined,
"reasoningTokens": undefined,
"totalTokens": 13,
},
},
{
"finishReason": "stop",
"rawFinishReason": "stop",
"totalUsage": {
"cachedInputTokens": undefined,
"inputTokenDetails": {
"cacheReadTokens": undefined,
"cacheWriteTokens": undefined,
"noCacheTokens": 3,
},
"inputTokens": 3,
"outputTokenDetails": {
"reasoningTokens": undefined,
"textTokens": 10,
},
"outputTokens": 10,
"reasoningTokens": undefined,
"totalTokens": 13,
},
"type": "finish",
},
]
`);
});
it('should send sources', async () => {
const result = streamText({
model: modelWithSources,
...defaultSettings(),
});
expect(await convertAsyncIterableToArray(result.fullStream))
.toMatchInlineSnapshot(`
[
{
"type": "start",
},
{
"request": {},
"type": "start-step",
"warnings": [],
},
{
"id": "123",
"providerMetadata": {
"provider": {
"custom": "value",
},
},
"sourceType": "url",
"title": "Example",
"type": "source",
"url": "https://example.com",
},
{
"id": "1",
"type": "text-start",
},
{
"id": "1",
"providerMetadata": undefined,
"text": "Hello!",
"type": "text-delta",
},
{
"id": "1",
"type": "text-end",
},
{
"id": "456",
"providerMetadata": {
"provider": {
"custom": "value2",
},
},
"sourceType": "url",
"title": "Example 2",
"type": "source",
"url": "https://example.com/2",
},
{
"finishReason": "stop",
"providerMetadata": undefined,
"rawFinishReason": "stop",
"response": {
"headers": undefined,
"id": "id-0",
"modelId": "mock-model-id",
"timestamp": 1970-01-01T00:00:00.000Z,
},
"type": "finish-step",
"usage": {
"cachedInputTokens": undefined,
"inputTokenDetails": {
"cacheReadTokens": undefined,
"cacheWriteTokens": undefined,
"noCacheTokens": 3,
},
"inputTokens": 3,
"outputTokenDetails": {
"reasoningTokens": undefined,
"textTokens": 10,
},
"outputTokens": 10,
"raw": undefined,
"reasoningTokens": undefined,
"totalTokens": 13,
},
},
{
"finishReason": "stop",
"rawFinishReason": "stop",
"totalUsage": {
"cachedInputTokens": undefined,
"inputTokenDetails": {
"cacheReadTokens": undefined,
"cacheWriteTokens": undefined,
"noCacheTokens": 3,
},
"inputTokens": 3,
"outputTokenDetails": {
"reasoningTokens": undefined,
"textTokens": 10,
},
"outputTokens": 10,
"reasoningTokens": undefined,
"totalTokens": 13,
},
"type": "finish",
},
]
`);
});
it('should send files', async () => {
const result = streamText({
model: modelWithFiles,
...defaultSettings(),
});
expect(await convertAsyncIterableToArray(result.fullStream))
.toMatchInlineSnapshot(`
[
{
"type": "start",
},
{
"request": {},
"type": "start-step",
"warnings": [],
},
{
"file": DefaultGeneratedFileWithType {
"base64Data": "Hello World",
"mediaType": "text/plain",
"type": "file",
"uint8ArrayData": undefined,
},
"type": "file",
},
{
"id": "1",
"type": "text-start",
},
{
"id": "1",
"providerMetadata": undefined,
"text": "Hello!",
"type": "text-delta",
},
{
"id": "1",
"type": "text-end",
},
{
"file": DefaultGeneratedFileWithType {
"base64Data": "QkFVRw==",
"mediaType": "image/jpeg",
"type": "file",
"uint8ArrayData": undefined,
},
"type": "file",
},
{
"finishReason": "stop",
"providerMetadata": undefined,
"rawFinishReason": "stop",
"response": {
"headers": undefined,
"id": "id-0",
"modelId": "mock-model-id",
"timestamp": 1970-01-01T00:00:00.000Z,
},
"type": "finish-step",
"usage": {
"cachedInputTokens": undefined,
"inputTokenDetails": {
"cacheReadTokens": undefined,
"cacheWriteTokens": undefined,
"noCacheTokens": 3,
},
"inputTokens": 3,
"outputTokenDetails": {
"reasoningTokens": undefined,
"textTokens": 10,
},
"outputTokens": 10,
"raw": undefined,
"reasoningTokens": undefined,
"totalTokens": 13,
},
},
{
"finishReason": "stop",
"rawFinishReason": "stop",
"totalUsage": {
"cachedInputTokens": undefined,
"inputTokenDetails": {
"cacheReadTokens": undefined,
"cacheWriteTokens": undefined,
"noCacheTokens": 3,
},
"inputTokens": 3,
"outputTokenDetails": {
"reasoningTokens": undefined,
"textTokens": 10,
},
"outputTokens": 10,
"reasoningTokens": undefined,
"totalTokens": 13,
},
"type": "finish",
},
]
`);
});
it('should use fallback response metadata when response metadata is not provided', async () => {
const result = streamText({
model: new MockLanguageModelV3({
doStream: async ({ prompt }) => {
expect(prompt).toStrictEqual([
{
role: 'user',
content: [{ type: 'text', text: 'test-input' }],
providerOptions: undefined,
},
]);
return {
stream: convertArrayToReadableStream([
{ type: 'text-start', id: '1' },
{ type: 'text-delta', id: '1', delta: 'Hello' },
{ type: 'text-delta', id: '1', delta: ', ' },
{ type: 'text-delta', id: '1', delta: `world!` },
{ type: 'text-end', id: '1' },
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: testUsage,
},
]),
};
},
}),
prompt: 'test-input',
_internal: {
generateId: mockValues('id-2000'),
},
});
expect(await convertAsyncIterableToArray(result.fullStream))
.toMatchInlineSnapshot(`
[
{
"type": "start",
},
{
"request": {},
"type": "start-step",
"warnings": [],
},
{
"id": "1",
"type": "text-start",
},
{
"id": "1",
"providerMetadata": undefined,
"text": "Hello",
"type": "text-delta",
},
{
"id": "1",
"providerMetadata": undefined,
"text": ", ",
"type": "text-delta",
},
{
"id": "1",
"providerMetadata": undefined,
"text": "world!",
"type": "text-delta",
},
{
"id": "1",
"type": "text-end",
},
{
"finishReason": "stop",
"providerMetadata": undefined,
"rawFinishReason": "stop",
"response": {
"headers": undefined,
"id": "id-2000",
"modelId": "mock-model-id",
"timestamp": 1970-01-01T00:00:00.000Z,
},
"type": "finish-step",
"usage": {
"cachedInputTokens": undefined,
"inputTokenDetails": {
"cacheReadTokens": undefined,
"cacheWriteTokens": undefined,
"noCacheTokens": 3,
},
"inputTokens": 3,
"outputTokenDetails": {
"reasoningTokens": undefined,
"textTokens": 10,
},
"outputTokens": 10,
"raw": undefined,
"reasoningTokens": undefined,
"totalTokens": 13,
},
},
{
"finishReason": "stop",
"rawFinishReason": "stop",
"totalUsage": {
"cachedInputTokens": undefined,
"inputTokenDetails": {
"cacheReadTokens": undefined,
"cacheWriteTokens": undefined,
"noCacheTokens": 3,
},
"inputTokens": 3,
"outputTokenDetails": {
"reasoningTokens": undefined,
"textTokens": 10,
},
"outputTokens": 10,
"reasoningTokens": undefined,
"totalTokens": 13,
},
"type": "finish",
},
]
`);
});
it('should send tool calls', async () => {
const result = streamText({
model: new MockLanguageModelV3({
doStream: async ({ prompt, tools, toolChoice }) => {
expect(tools).toStrictEqual([
{
type: 'function',
name: 'tool1',
description: undefined,
inputSchema: {
$schema: 'http://json-schema.org/draft-07/schema#',
additionalProperties: false,
properties: { value: { type: 'string' } },
required: ['value'],
type: 'object',
},
providerOptions: undefined,
},
]);
expect(toolChoice).toStrictEqual({ type: 'required' });
expect(prompt).toStrictEqual([
{
role: 'user',
content: [{ type: 'text', text: 'test-input' }],
providerOptions: undefined,
},
]);
return {
stream: convertArrayToReadableStream([
{
type: 'response-metadata',
id: 'id-0',
modelId: 'mock-model-id',
timestamp: new Date(0),
},
{
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'tool1',
input: `{ "value": "value" }`,
providerMetadata: {
testProvider: {
signature: 'sig',
},
},
},
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: testUsage,
},
]),
};
},
}),
tools: {
tool1: tool({
title: 'Tool 1',
inputSchema: z.object({ value: z.string() }),
}),
},
toolChoice: 'required',
prompt: 'test-input',
});
expect(
await convertAsyncIterableToArray(result.fullStream),
).toMatchSnapshot();
});
it('should send tool call deltas', async () => {
const result = streamText({
model: createTestModel({
stream: convertArrayToReadableStream([
{
type: 'response-metadata',
id: 'id-0',
modelId: 'mock-model-id',
timestamp: new Date(0),
},
{
type: 'tool-input-start',
id: 'call_O17Uplv4lJvD6DVdIvFFeRMw',
toolName: 'test-tool',
},
{
type: 'tool-input-delta',
id: 'call_O17Uplv4lJvD6DVdIvFFeRMw',
delta: '{"',
},
{
type: 'tool-input-delta',
id: 'call_O17Uplv4lJvD6DVdIvFFeRMw',
delta: 'value',
},
{
type: 'tool-input-delta',
id: 'call_O17Uplv4lJvD6DVdIvFFeRMw',
delta: '":"',
},
{
type: 'tool-input-delta',
id: 'call_O17Uplv4lJvD6DVdIvFFeRMw',
delta: 'Spark',
},
{
type: 'tool-input-delta',
id: 'call_O17Uplv4lJvD6DVdIvFFeRMw',
delta: 'le',
},
{
type: 'tool-input-delta',
id: 'call_O17Uplv4lJvD6DVdIvFFeRMw',
delta: ' Day',
},
{
type: 'tool-input-delta',
id: 'call_O17Uplv4lJvD6DVdIvFFeRMw',
delta: '"}',
},
{
type: 'tool-input-end',
id: 'call_O17Uplv4lJvD6DVdIvFFeRMw',
},
{
type: 'tool-call',
toolCallId: 'call_O17Uplv4lJvD6DVdIvFFeRMw',
toolName: 'test-tool',
input: '{"value":"Sparkle Day"}',
},
{
type: 'finish',
finishReason: { unified: 'tool-calls', raw: undefined },
usage: testUsage2,
},
]),
}),
tools: {
'test-tool': tool({
inputSchema: z.object({ value: z.string() }),
}),
},
toolChoice: 'required',
prompt: 'test-input',
});
expect(await convertAsyncIterableToArray(result.fullStream))
.toMatchInlineSnapshot(`
[
{
"type": "start",
},
{
"request": {},
"type": "start-step",
"warnings": [],
},
{
"dynamic": false,
"id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
"title": undefined,
"toolName": "test-tool",
"type": "tool-input-start",
},
{
"delta": "{"",
"id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
"type": "tool-input-delta",
},
{
"delta": "value",
"id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
"type": "tool-input-delta",
},
{
"delta": "":"",
"id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
"type": "tool-input-delta",
},
{
"delta": "Spark",
"id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
"type": "tool-input-delta",
},
{
"delta": "le",
"id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
"type": "tool-input-delta",
},
{
"delta": " Day",
"id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
"type": "tool-input-delta",
},
{
"delta": ""}",
"id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
"type": "tool-input-delta",
},
{
"id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
"type": "tool-input-end",
},
{
"input": {
"value": "Sparkle Day",
},
"providerExecuted": undefined,
"providerMetadata": undefined,
"title": undefined,
"toolCallId": "call_O17Uplv4lJvD6DVdIvFFeRMw",
"toolName": "test-tool",
"type": "tool-call",
},
{
"finishReason": "tool-calls",
"providerMetadata": undefined,
"rawFinishReason": undefined,
"response": {
"headers": undefined,
"id": "id-0",
"modelId": "mock-model-id",
"timestamp": 1970-01-01T00:00:00.000Z,
},
"type": "finish-step",
"usage": {
"cachedInputTokens": 0,
"inputTokenDetails": {
"cacheReadTokens": 0,
"cacheWriteTokens": 0,
"noCacheTokens": 3,
},
"inputTokens": 3,
"outputTokenDetails": {
"reasoningTokens": 10,
"textTokens": 10,
},
"outputTokens": 10,
"raw": undefined,
"reasoningTokens": 10,
"totalTokens": 13,
},
},
{
"finishReason": "tool-calls",
"rawFinishReason": undefined,
"totalUsage": {
"cachedInputTokens": 0,
"inputTokenDetails": {
"cacheReadTokens": 0,
"cacheWriteTokens": 0,
"noCacheTokens": 3,
},
"inputTokens": 3,
"outputTokenDetails": {
"reasoningTokens": 10,
"textTokens": 10,
},
"outputTokens": 10,
"reasoningTokens": 10,
"totalTokens": 13,
},
"type": "finish",
},
]
`);
});
it('should pass through providerMetadata on tool-input-start', async () => {
const result = streamText({
model: createTestModel({
stream: convertArrayToReadableStream([
{
type: 'response-metadata',
id: 'id-0',
modelId: 'mock-model-id',
timestamp: new Date(0),
},
{
type: 'tool-input-start',
id: 'call-1',
toolName: 'test-tool',
providerMetadata: {
testProvider: { someKey: 'someValue' },
},
},
{
type: 'tool-input-delta',
id: 'call-1',
delta: '{"value":"test"}',
},
{
type: 'tool-input-end',
id: 'call-1',
},
{
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'test-tool',
input: '{"value":"test"}',
},
{
type: 'finish',
finishReason: { unified: 'tool-calls', raw: undefined },
usage: testUsage2,
},
]),
}),
tools: {
'test-tool': tool({
inputSchema: z.object({ value: z.string() }),
}),
},
toolChoice: 'required',
prompt: 'test-input',
});
const chunks = await convertAsyncIterableToArray(result.fullStream);
const toolInputStart = chunks.find(
(c): c is Extract<typeof c, { type: 'tool-input-start' }> =>
c.type === 'tool-input-start',
);
expect(toolInputStart?.providerMetadata).toEqual({
testProvider: { someKey: 'someValue' },
});
});
it('should send tool results', async () => {
const result = streamText({
model: createTestModel({
stream: convertArrayToReadableStream([
{
type: 'response-metadata',
id: 'id-0',
modelId: 'mock-model-id',
timestamp: new Date(0),
},
{
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'tool1',
input: `{ "value": "value" }`,
},
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: testUsage,
},
]),
}),
tools: {
tool1: tool({
title: 'Tool 1',
inputSchema: z.object({ value: z.string() }),
execute: async (input, options) => {
expect(input).toStrictEqual({ value: 'value' });
expect(options.messages).toStrictEqual([
{ role: 'user', content: 'test-input' },
]);
return `${input.value}-result`;
},
}),
},
prompt: 'test-input',
});
expect(
await convertAsyncIterableToArray(result.fullStream),
).toMatchSnapshot();
});
it('should send delayed asynchronous tool results', async () => {
const result = streamText({
model: createTestModel({
stream: convertArrayToReadableStream([
{
type: 'response-metadata',
id: 'id-0',
modelId: 'mock-model-id',
timestamp: new Date(0),
},
{
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'tool1',
input: `{ "value": "value" }`,
},
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: testUsage,
},
]),
}),
tools: {
tool1: {
title: 'Tool 1',
inputSchema: z.object({ value: z.string() }),
execute: async ({ value }) => {
await delay(50); // delay to show bug where step finish is sent before tool result
return `${value}-result`;
},
},
},
prompt: 'test-input',
});
expect(
await convertAsyncIterableToArray(result.fullStream),
).toMatchSnapshot();
});
it('should filter out empty text deltas', async () => {
const result = streamText({
model: createTestModel({
stream: convertArrayToReadableStream([
{
type: 'response-metadata',
id: 'id-0',
modelId: 'mock-model-id',
timestamp: new Date(0),
},
{ type: 'text-start', id: '1' },
{ type: 'text-delta', id: '1', delta: '' },
{ type: 'text-delta', id: '1', delta: 'Hello' },
{ type: 'text-delta', id: '1', delta: '' },
{ type: 'text-delta', id: '1', delta: ', ' },
{ type: 'text-delta', id: '1', delta: '' },
{ type: 'text-delta', id: '1', delta: 'world!' },
{ type: 'text-delta', id: '1', delta: '' },
{ type: 'text-end', id: '1' },
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: testUsage,
},
]),
}),
prompt: 'test-input',
});
expect(await convertAsyncIterableToArray(result.fullStream))
.toMatchInlineSnapshot(`
[
{
"type": "start",
},
{
"request": {},
"type": "start-step",
"warnings": [],
},
{
"id": "1",
"type": "text-start",
},
{
"id": "1",
"providerMetadata": undefined,
"text": "Hello",
"type": "text-delta",
},
{
"id": "1",
"providerMetadata": undefined,
"text": ", ",
"type": "text-delta",
},
{
"id": "1",
"providerMetadata": undefined,
"text": "world!",
"type": "text-delta",
},
{
"id": "1",
"type": "text-end",
},
{
"finishReason": "stop",
"providerMetadata": undefined,
"rawFinishReason": "stop",
"response": {
"headers": undefined,
"id": "id-0",
"modelId": "mock-model-id",
"timestamp": 1970-01-01T00:00:00.000Z,
},
"type": "finish-step",
"usage": {
"cachedInputTokens": undefined,
"inputTokenDetails": {
"cacheReadTokens": undefined,
"cacheWriteTokens": undefined,
"noCacheTokens": 3,
},
"inputTokens": 3,
"outputTokenDetails": {
"reasoningTokens": undefined,
"textTokens": 10,
},
"outputTokens": 10,
"raw": undefined,
"reasoningTokens": undefined,
"totalTokens": 13,
},
},
{
"finishReason": "stop",
"rawFinishReason": "stop",
"totalUsage": {
"cachedInputTokens": undefined,
"inputTokenDetails": {
"cacheReadTokens": undefined,
"cacheWriteTokens": undefined,
"noCacheTokens": 3,
},
"inputTokens": 3,
"outputTokenDetails": {
"reasoningTokens": undefined,
"textTokens": 10,
},
"outputTokens": 10,
"reasoningTokens": undefined,
"totalTokens": 13,
},
"type": "finish",
},
]
`);
});
});
describe('errors', () => {
it('should swallow error to prevent server crash', async () => {
const result = streamText({
model: new MockLanguageModelV3({
doStream: async () => {
throw new Error('test error');
},
}),
prompt: 'test-input',
onError: () => {},
});
expect(
await convertAsyncIterableToArray(result.textStream),
).toMatchSnapshot();
});
it('should forward error in doStream as error stream part', async () => {
const result = streamText({
model: new MockLanguageModelV3({
doStream: async () => {
throw new Error('test error');
},
}),
prompt: 'test-input',
onError: () => {},
});
expect(
await convertAsyncIterableToArray(result.fullStream),
).toStrictEqual([
{
type: 'start',