Skip to content

Commit 4a86d3b

Browse files
aayush-kapoorvercel-ai-sdk[bot]
authored andcommitted
fix(ai): skip stringifying text when streaming partial text (#14123)
## Background #13839 streamText with the default text output called JSON.stringify on the full accumulated text on every single streaming chunk that creates increasingly large string copies per stream casuing memory issues ## Summary - skip `JSON.stringify` when the partial output is already a string - structured outputs still go through stringify as before since they need serialization to compare. - compare the text directly - no extra full-string serialization per chunk ## Manual Verification tried reproducing via <details> <summary>repro</summary> ```ts import { openai } from '@ai-sdk/openai'; import { streamText } from 'ai'; import { run } from '../../lib/run'; run(async () => { const result = streamText({ model: openai.responses('gpt-4o-mini'), prompt: 'Write an extremely detailed 5000-word essay about the history of computing. Include every detail you can.', }); let chunks = 0; for await (const textPart of result.textStream) { chunks++; if (chunks % 100 === 0) { const mb = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1); console.log(`chunk ${chunks} — heap: ${mb}MB`); } } console.log(`\nTotal chunks: ${chunks}`); console.log( `Final heap: ${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1)}MB`, ); }); ``` </details> ## Checklist - [x] Tests have been added / updated (for bug fixes / features) - [ ] Documentation has been added / updated (for bug fixes / features) - [x] A _patch_ changeset for relevant packages has been added (for bug fixes / features - run `pnpm changeset` in the project root) - [x] I have reviewed this pull request (self-review) ## Related Issues fixes #13839
1 parent 9de7d7b commit 4a86d3b

3 files changed

Lines changed: 58 additions & 5 deletions

File tree

.changeset/empty-cups-love.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ai": patch
3+
---
4+
5+
fix(ai): skip stringifying text when streaming partial text

packages/ai/src/generate-text/stream-text.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15190,6 +15190,50 @@ describe('streamText', () => {
1519015190
`);
1519115191
});
1519215192

15193+
it('should not call JSON.stringify for string partial outputs', async () => {
15194+
const originalStringify = JSON.stringify;
15195+
const stringifySpy = vi.spyOn(JSON, 'stringify').mockImplementation(((
15196+
...args: Parameters<typeof JSON.stringify>
15197+
) => {
15198+
if (typeof args[0] === 'string') {
15199+
throw new Error(
15200+
'JSON.stringify should not be called for string partial outputs',
15201+
);
15202+
}
15203+
return originalStringify.call(JSON, ...args);
15204+
}) as typeof JSON.stringify);
15205+
15206+
try {
15207+
const result = streamText({
15208+
model: createTestModel({
15209+
stream: convertArrayToReadableStream([
15210+
{ type: 'text-start', id: '1' },
15211+
{ type: 'text-delta', id: '1', delta: 'Hello, ' },
15212+
{ type: 'text-delta', id: '1', delta: 'world!' },
15213+
{ type: 'text-end', id: '1' },
15214+
{
15215+
type: 'finish',
15216+
finishReason: { unified: 'stop', raw: 'stop' },
15217+
usage: testUsage,
15218+
},
15219+
]),
15220+
}),
15221+
prompt: 'prompt',
15222+
output: Output.text(),
15223+
});
15224+
15225+
expect(await convertAsyncIterableToArray(result.partialOutputStream))
15226+
.toMatchInlineSnapshot(`
15227+
[
15228+
"Hello, ",
15229+
"Hello, world!",
15230+
]
15231+
`);
15232+
} finally {
15233+
stringifySpy.mockRestore();
15234+
}
15235+
});
15236+
1519315237
it('should resolve output promise with the correct content', async () => {
1519415238
const result = streamText({
1519515239
model: createTestModel({

packages/ai/src/generate-text/stream-text.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,7 @@ function createOutputTransformStream<
600600
let text = '';
601601
let textChunk = '';
602602
let textProviderMetadata: ProviderMetadata | undefined = undefined;
603-
let lastPublishedJson = '';
603+
let lastPublishedValue = '';
604604

605605
function publishTextChunk({
606606
controller,
@@ -673,11 +673,15 @@ function createOutputTransformStream<
673673

674674
// null should be allowed (valid JSON value) but undefined should not:
675675
if (result !== undefined) {
676-
// only send new json if it has changed:
677-
const currentJson = JSON.stringify(result.partial);
678-
if (currentJson !== lastPublishedJson) {
676+
// only send new value if it has changed:
677+
// For string partials (text output), compare directly to avoid unnecessary JSON.stringify overhead
678+
const currentValue =
679+
typeof result.partial === 'string'
680+
? result.partial
681+
: JSON.stringify(result.partial);
682+
if (currentValue !== lastPublishedValue) {
679683
publishTextChunk({ controller, partialOutput: result.partial });
680-
lastPublishedJson = currentJson;
684+
lastPublishedValue = currentValue;
681685
}
682686
}
683687
},

0 commit comments

Comments
 (0)