Skip to content

feat (ai): Add finishReason as a promise on StreamObjectResult to match StreamTextResult #6161

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 29, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/old-dodos-carry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'ai': patch
---

feat (ai): Add finishReason as a promise on StreamObjectResult to match StreamTextResult
7 changes: 7 additions & 0 deletions packages/ai/src/generate-object/stream-object-result.ts
Original file line number Diff line number Diff line change
@@ -40,6 +40,13 @@ Additional response information.
*/
readonly response: Promise<LanguageModelResponseMetadata>;

/**
The reason why the generation finished. Taken from the last step.

Resolved when the response is finished.
*/
readonly finishReason: Promise<FinishReason>;

/**
The generated object (typed according to the schema). Resolved when the response is finished.
*/
12 changes: 12 additions & 0 deletions packages/ai/src/generate-object/stream-object.test-d.ts
Original file line number Diff line number Diff line change
@@ -2,9 +2,21 @@ import { JSONValue } from '@ai-sdk/provider';
import { expectTypeOf } from 'vitest';
import { z } from 'zod/v4';
import { AsyncIterableStream } from '../util/async-iterable-stream';
import { FinishReason } from '../types';
import { streamObject } from './stream-object';

describe('streamObject', () => {
it('should have finishReason property with correct type', () => {
const result = streamObject({
schema: z.object({ number: z.number() }),
model: undefined!,
});

expectTypeOf<typeof result.finishReason>().toEqualTypeOf<
Promise<FinishReason>
>();
});

it('should support enum types', async () => {
const result = await streamObject({
output: 'enum',
33 changes: 33 additions & 0 deletions packages/ai/src/generate-object/stream-object.test.ts
Original file line number Diff line number Diff line change
@@ -563,6 +563,39 @@ describe('streamObject', () => {
});
});

describe('result.finishReason', () => {
it('should resolve with finish reason', async () => {
const result = streamObject({
model: new MockLanguageModelV2({
doStream: async () => ({
stream: convertArrayToReadableStream([
{ type: 'text-start', id: '1' },
{ type: 'text-delta', id: '1', delta: '{ ' },
{ type: 'text-delta', id: '1', delta: '"content": ' },
{ type: 'text-delta', id: '1', delta: `"Hello, ` },
{ type: 'text-delta', id: '1', delta: `world` },
{ type: 'text-delta', id: '1', delta: `!"` },
{ type: 'text-delta', id: '1', delta: ' }' },
{ type: 'text-end', id: '1' },
{
type: 'finish',
finishReason: 'stop',
usage: testUsage,
},
]),
}),
}),
schema: z.object({ content: z.string() }),
prompt: 'prompt',
});

// consume stream (runs in parallel)
convertAsyncIterableToArray(result.partialObjectStream);

expect(await result.finishReason).toStrictEqual('stop');
});
});

describe('options.onFinish', () => {
it('should be called when a valid object is generated', async () => {
let result: Parameters<
12 changes: 11 additions & 1 deletion packages/ai/src/generate-object/stream-object.ts
Original file line number Diff line number Diff line change
@@ -41,7 +41,11 @@ import { recordSpan } from '../telemetry/record-span';
import { selectTelemetryAttributes } from '../telemetry/select-telemetry-attributes';
import { stringifyForTelemetry } from '../telemetry/stringify-for-telemetry';
import { TelemetrySettings } from '../telemetry/telemetry-settings';
import { CallWarning, LanguageModel } from '../types/language-model';
import {
CallWarning,
FinishReason,
LanguageModel,
} from '../types/language-model';
import { LanguageModelRequestMetadata } from '../types/language-model-request-metadata';
import { LanguageModelResponseMetadata } from '../types/language-model-response-metadata';
import { ProviderMetadata } from '../types/provider-metadata';
@@ -358,6 +362,7 @@ class DefaultStreamObjectResult<PARTIAL, RESULT, ELEMENT_STREAM>
new DelayedPromise<LanguageModelRequestMetadata>();
private readonly _response =
new DelayedPromise<LanguageModelResponseMetadata>();
private readonly _finishReason = new DelayedPromise<FinishReason>();

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

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

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

get finishReason() {
return this._finishReason.promise;
}

get partialObjectStream(): AsyncIterableStream<PARTIAL> {
return createAsyncIterableStream(
this.baseStream.pipeThrough(