Skip to content

Commit bc24722

Browse files
authored
feat (ai): Add finishReason as a promise on StreamObjectResult to match StreamTextResult (#6161)
## Background - `Promise<FinishReason>` is an available property on `StreamText` API, but not `StreamObject` API. - Without this change, devs need to observe the full stream instead of just await'ing a promise ## Summary - Update `StreamObjectResult` interface - Add implementation bookkeeping for `finishReasonPromise` ## Verification - Finish reason promise is implemented exactly the same as other promises for `streamObject` - Finish reason promise logic is the same as the promise in `streamText` - Using this new promise, it resolves as expected with the other promises
1 parent 56c232b commit bc24722

File tree

5 files changed

+68
-1
lines changed

5 files changed

+68
-1
lines changed

‎.changeset/old-dodos-carry.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+
feat (ai): Add finishReason as a promise on StreamObjectResult to match StreamTextResult

‎packages/ai/src/generate-object/stream-object-result.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ Additional response information.
4040
*/
4141
readonly response: Promise<LanguageModelResponseMetadata>;
4242

43+
/**
44+
The reason why the generation finished. Taken from the last step.
45+
46+
Resolved when the response is finished.
47+
*/
48+
readonly finishReason: Promise<FinishReason>;
49+
4350
/**
4451
The generated object (typed according to the schema). Resolved when the response is finished.
4552
*/

‎packages/ai/src/generate-object/stream-object.test-d.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,21 @@ import { JSONValue } from '@ai-sdk/provider';
22
import { expectTypeOf } from 'vitest';
33
import { z } from 'zod/v4';
44
import { AsyncIterableStream } from '../util/async-iterable-stream';
5+
import { FinishReason } from '../types';
56
import { streamObject } from './stream-object';
67

78
describe('streamObject', () => {
9+
it('should have finishReason property with correct type', () => {
10+
const result = streamObject({
11+
schema: z.object({ number: z.number() }),
12+
model: undefined!,
13+
});
14+
15+
expectTypeOf<typeof result.finishReason>().toEqualTypeOf<
16+
Promise<FinishReason>
17+
>();
18+
});
19+
820
it('should support enum types', async () => {
921
const result = await streamObject({
1022
output: 'enum',

‎packages/ai/src/generate-object/stream-object.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,39 @@ describe('streamObject', () => {
563563
});
564564
});
565565

566+
describe('result.finishReason', () => {
567+
it('should resolve with finish reason', async () => {
568+
const result = streamObject({
569+
model: new MockLanguageModelV2({
570+
doStream: async () => ({
571+
stream: convertArrayToReadableStream([
572+
{ type: 'text-start', id: '1' },
573+
{ type: 'text-delta', id: '1', delta: '{ ' },
574+
{ type: 'text-delta', id: '1', delta: '"content": ' },
575+
{ type: 'text-delta', id: '1', delta: `"Hello, ` },
576+
{ type: 'text-delta', id: '1', delta: `world` },
577+
{ type: 'text-delta', id: '1', delta: `!"` },
578+
{ type: 'text-delta', id: '1', delta: ' }' },
579+
{ type: 'text-end', id: '1' },
580+
{
581+
type: 'finish',
582+
finishReason: 'stop',
583+
usage: testUsage,
584+
},
585+
]),
586+
}),
587+
}),
588+
schema: z.object({ content: z.string() }),
589+
prompt: 'prompt',
590+
});
591+
592+
// consume stream (runs in parallel)
593+
convertAsyncIterableToArray(result.partialObjectStream);
594+
595+
expect(await result.finishReason).toStrictEqual('stop');
596+
});
597+
});
598+
566599
describe('options.onFinish', () => {
567600
it('should be called when a valid object is generated', async () => {
568601
let result: Parameters<

‎packages/ai/src/generate-object/stream-object.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ import { recordSpan } from '../telemetry/record-span';
4141
import { selectTelemetryAttributes } from '../telemetry/select-telemetry-attributes';
4242
import { stringifyForTelemetry } from '../telemetry/stringify-for-telemetry';
4343
import { TelemetrySettings } from '../telemetry/telemetry-settings';
44-
import { CallWarning, LanguageModel } from '../types/language-model';
44+
import {
45+
CallWarning,
46+
FinishReason,
47+
LanguageModel,
48+
} from '../types/language-model';
4549
import { LanguageModelRequestMetadata } from '../types/language-model-request-metadata';
4650
import { LanguageModelResponseMetadata } from '../types/language-model-response-metadata';
4751
import { ProviderMetadata } from '../types/provider-metadata';
@@ -358,6 +362,7 @@ class DefaultStreamObjectResult<PARTIAL, RESULT, ELEMENT_STREAM>
358362
new DelayedPromise<LanguageModelRequestMetadata>();
359363
private readonly _response =
360364
new DelayedPromise<LanguageModelResponseMetadata>();
365+
private readonly _finishReason = new DelayedPromise<FinishReason>();
361366

362367
private readonly baseStream: ReadableStream<ObjectStreamPart<PARTIAL>>;
363368

@@ -702,6 +707,7 @@ class DefaultStreamObjectResult<PARTIAL, RESULT, ELEMENT_STREAM>
702707
...fullResponse,
703708
headers: response?.headers,
704709
});
710+
self._finishReason.resolve(finishReason ?? 'unknown');
705711

706712
// resolve the object promise with the latest object:
707713
const validationResult =
@@ -870,6 +876,10 @@ class DefaultStreamObjectResult<PARTIAL, RESULT, ELEMENT_STREAM>
870876
return this._response.promise;
871877
}
872878

879+
get finishReason() {
880+
return this._finishReason.promise;
881+
}
882+
873883
get partialObjectStream(): AsyncIterableStream<PARTIAL> {
874884
return createAsyncIterableStream(
875885
this.baseStream.pipeThrough(

0 commit comments

Comments
 (0)