ai 7.0.34 → 7.0.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/index.d.ts +26 -10
- package/dist/index.js +144 -47
- package/dist/index.js.map +1 -1
- package/dist/internal/index.d.ts +5 -2
- package/dist/internal/index.js +26 -3
- package/dist/internal/index.js.map +1 -1
- package/docs/03-ai-sdk-core/25-settings.mdx +15 -2
- package/docs/03-ai-sdk-core/65-devtools.mdx +6 -0
- package/docs/03-ai-sdk-harnesses/02-harness-agent.mdx +42 -0
- package/docs/04-ai-sdk-ui/03-chatbot-tool-usage.mdx +29 -0
- package/docs/04-ai-sdk-ui/21-error-handling.mdx +10 -4
- package/docs/07-reference/01-ai-sdk-core/02-stream-text.mdx +4 -4
- package/docs/07-reference/01-ai-sdk-core/16-tool-loop-agent.mdx +10 -8
- package/docs/07-reference/02-ai-sdk-ui/42-pipe-ui-message-stream-to-response.mdx +6 -1
- package/docs/08-migration-guides/24-migration-guide-6-0.mdx +28 -0
- package/package.json +2 -2
- package/src/agent/agent.ts +4 -1
- package/src/agent/pipe-agent-ui-stream-to-response.ts +1 -1
- package/src/generate-object/stream-object-result.ts +4 -1
- package/src/generate-object/stream-object.ts +1 -1
- package/src/generate-text/generate-text-events.ts +2 -1
- package/src/generate-text/stream-text-result.ts +5 -2
- package/src/generate-text/stream-text.ts +113 -33
- package/src/generate-text/tool-approval-signature.ts +54 -2
- package/src/prompt/index.ts +1 -0
- package/src/prompt/request-options.ts +20 -2
- package/src/text-stream/pipe-text-stream-to-response.ts +3 -2
- package/src/ui-message-stream/pipe-ui-message-stream-to-response.ts +3 -2
- package/src/util/create-stitchable-stream.ts +41 -15
- package/src/util/write-to-server-response.ts +2 -2
|
@@ -34,6 +34,7 @@ import { prepareTools } from '../prompt/prepare-tools';
|
|
|
34
34
|
import type { Prompt } from '../prompt/prompt';
|
|
35
35
|
import {
|
|
36
36
|
getChunkTimeoutMs,
|
|
37
|
+
getFirstChunkTimeoutMs,
|
|
37
38
|
getStepTimeoutMs,
|
|
38
39
|
getTotalTimeoutMs,
|
|
39
40
|
type RequestOptions,
|
|
@@ -157,28 +158,29 @@ const originalGenerateCallId = createIdGenerator({
|
|
|
157
158
|
size: 24,
|
|
158
159
|
});
|
|
159
160
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
161
|
+
// Chunk types that contain semantic model output. This classification is used
|
|
162
|
+
// for first-content and inter-content timeouts as well as to distinguish empty
|
|
163
|
+
// incomplete streams from incomplete streams with partial results. It is
|
|
164
|
+
// exhaustive so that new chunk types must be classified explicitly.
|
|
163
165
|
const isOutputChunkType = {
|
|
164
166
|
file: true,
|
|
165
|
-
custom:
|
|
166
|
-
source:
|
|
167
|
-
'text-start':
|
|
168
|
-
'text-end':
|
|
167
|
+
custom: false,
|
|
168
|
+
source: false,
|
|
169
|
+
'text-start': false,
|
|
170
|
+
'text-end': false,
|
|
169
171
|
'text-delta': true,
|
|
170
|
-
'reasoning-start':
|
|
171
|
-
'reasoning-end':
|
|
172
|
+
'reasoning-start': false,
|
|
173
|
+
'reasoning-end': false,
|
|
172
174
|
'reasoning-delta': true,
|
|
173
175
|
'reasoning-file': true,
|
|
174
|
-
'tool-input-start':
|
|
175
|
-
'tool-input-end':
|
|
176
|
+
'tool-input-start': false,
|
|
177
|
+
'tool-input-end': false,
|
|
176
178
|
'tool-input-delta': true,
|
|
177
|
-
'tool-approval-request':
|
|
178
|
-
'tool-approval-response':
|
|
179
|
+
'tool-approval-request': false,
|
|
180
|
+
'tool-approval-response': false,
|
|
179
181
|
'tool-call': true,
|
|
180
|
-
'tool-result':
|
|
181
|
-
'tool-error':
|
|
182
|
+
'tool-result': false,
|
|
183
|
+
'tool-error': false,
|
|
182
184
|
'tool-execution-end': false,
|
|
183
185
|
'model-call-start': false,
|
|
184
186
|
'model-call-response-metadata': false,
|
|
@@ -187,6 +189,26 @@ const isOutputChunkType = {
|
|
|
187
189
|
raw: false,
|
|
188
190
|
} as const satisfies Record<ExecuteToolsStreamPart['type'], boolean>;
|
|
189
191
|
|
|
192
|
+
function isOutputChunk(chunk: ExecuteToolsStreamPart): boolean {
|
|
193
|
+
if (!isOutputChunkType[chunk.type]) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
switch (chunk.type) {
|
|
198
|
+
case 'text-delta':
|
|
199
|
+
case 'reasoning-delta':
|
|
200
|
+
return chunk.text.length > 0;
|
|
201
|
+
case 'tool-input-delta':
|
|
202
|
+
return chunk.delta.length > 0;
|
|
203
|
+
case 'file':
|
|
204
|
+
case 'reasoning-file':
|
|
205
|
+
case 'tool-call':
|
|
206
|
+
return true;
|
|
207
|
+
default:
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
190
212
|
export type StreamTextInclude = {
|
|
191
213
|
/**
|
|
192
214
|
* Whether to retain the request body in step results.
|
|
@@ -716,9 +738,12 @@ export function streamText<
|
|
|
716
738
|
}): StreamTextResult<TOOLS, RUNTIME_CONTEXT, OUTPUT> {
|
|
717
739
|
const totalTimeoutMs = getTotalTimeoutMs(timeout);
|
|
718
740
|
const stepTimeoutMs = getStepTimeoutMs(timeout);
|
|
741
|
+
const firstChunkTimeoutMs = getFirstChunkTimeoutMs(timeout);
|
|
719
742
|
const chunkTimeoutMs = getChunkTimeoutMs(timeout);
|
|
720
743
|
const stepAbortController =
|
|
721
744
|
stepTimeoutMs != null ? new AbortController() : undefined;
|
|
745
|
+
const firstChunkAbortController =
|
|
746
|
+
firstChunkTimeoutMs != null ? new AbortController() : undefined;
|
|
722
747
|
const chunkAbortController =
|
|
723
748
|
chunkTimeoutMs != null ? new AbortController() : undefined;
|
|
724
749
|
const resolvedOnStart = onStart ?? experimental_onStart;
|
|
@@ -742,10 +767,13 @@ export function streamText<
|
|
|
742
767
|
abortSignal,
|
|
743
768
|
totalTimeoutMs,
|
|
744
769
|
stepAbortController?.signal,
|
|
770
|
+
firstChunkAbortController?.signal,
|
|
745
771
|
chunkAbortController?.signal,
|
|
746
772
|
),
|
|
747
773
|
stepTimeoutMs,
|
|
748
774
|
stepAbortController,
|
|
775
|
+
firstChunkTimeoutMs,
|
|
776
|
+
firstChunkAbortController,
|
|
749
777
|
chunkTimeoutMs,
|
|
750
778
|
chunkAbortController,
|
|
751
779
|
instructions,
|
|
@@ -930,6 +958,10 @@ class DefaultStreamTextResult<
|
|
|
930
958
|
|
|
931
959
|
private readonly addStream: (
|
|
932
960
|
stream: ReadableStream<TextStreamPart<TOOLS>>,
|
|
961
|
+
callbacks?: {
|
|
962
|
+
onError?: (error: unknown) => void;
|
|
963
|
+
onCancel?: () => void;
|
|
964
|
+
},
|
|
933
965
|
) => void;
|
|
934
966
|
|
|
935
967
|
private readonly closeStream: () => void;
|
|
@@ -951,6 +983,8 @@ class DefaultStreamTextResult<
|
|
|
951
983
|
abortSignal,
|
|
952
984
|
stepTimeoutMs,
|
|
953
985
|
stepAbortController,
|
|
986
|
+
firstChunkTimeoutMs,
|
|
987
|
+
firstChunkAbortController,
|
|
954
988
|
chunkTimeoutMs,
|
|
955
989
|
chunkAbortController,
|
|
956
990
|
instructions,
|
|
@@ -1000,6 +1034,8 @@ class DefaultStreamTextResult<
|
|
|
1000
1034
|
abortSignal: AbortSignal | undefined;
|
|
1001
1035
|
stepTimeoutMs: number | undefined;
|
|
1002
1036
|
stepAbortController: AbortController | undefined;
|
|
1037
|
+
firstChunkTimeoutMs: number | undefined;
|
|
1038
|
+
firstChunkAbortController: AbortController | undefined;
|
|
1003
1039
|
chunkTimeoutMs: number | undefined;
|
|
1004
1040
|
chunkAbortController: AbortController | undefined;
|
|
1005
1041
|
toolsContext: InferToolSetContext<TOOLS>;
|
|
@@ -1771,7 +1807,32 @@ class DefaultStreamTextResult<
|
|
|
1771
1807
|
timeoutMs: stepTimeoutMs,
|
|
1772
1808
|
});
|
|
1773
1809
|
|
|
1774
|
-
//
|
|
1810
|
+
// The first-content timeout is armed when the provider response stream
|
|
1811
|
+
// starts and is cleared by the first semantic output chunk.
|
|
1812
|
+
let firstChunkTimeoutId: ReturnType<typeof setTimeout> | undefined =
|
|
1813
|
+
undefined;
|
|
1814
|
+
|
|
1815
|
+
function startFirstChunkTimeout() {
|
|
1816
|
+
if (abortSignal?.aborted) {
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
firstChunkTimeoutId = setAbortTimeout({
|
|
1821
|
+
abortController: firstChunkAbortController,
|
|
1822
|
+
label: 'First chunk',
|
|
1823
|
+
timeoutMs: firstChunkTimeoutMs,
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
function clearFirstChunkTimeout() {
|
|
1828
|
+
if (firstChunkTimeoutId != null) {
|
|
1829
|
+
clearTimeout(firstChunkTimeoutId);
|
|
1830
|
+
firstChunkTimeoutId = undefined;
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// Chunk timeout tracking starts after semantic output begins and is
|
|
1835
|
+
// reset only by subsequent semantic output chunks.
|
|
1775
1836
|
let chunkTimeoutId: ReturnType<typeof setTimeout> | undefined =
|
|
1776
1837
|
undefined;
|
|
1777
1838
|
|
|
@@ -1799,12 +1860,24 @@ class DefaultStreamTextResult<
|
|
|
1799
1860
|
}
|
|
1800
1861
|
}
|
|
1801
1862
|
|
|
1863
|
+
function clearStepTimeouts() {
|
|
1864
|
+
clearStepTimeout();
|
|
1865
|
+
clearFirstChunkTimeout();
|
|
1866
|
+
clearChunkTimeout();
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
function cleanupStepTimeouts() {
|
|
1870
|
+
abortSignal?.removeEventListener('abort', cleanupStepTimeouts);
|
|
1871
|
+
clearStepTimeouts();
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1802
1874
|
// The step's stream is registered lazily and consumed long after this
|
|
1803
|
-
// function returns, so
|
|
1804
|
-
//
|
|
1805
|
-
//
|
|
1806
|
-
abortSignal?.addEventListener('abort',
|
|
1807
|
-
|
|
1875
|
+
// function returns, so its timers must stay armed past setup. When the
|
|
1876
|
+
// merged abort signal fires, drop all step-scoped timers so none
|
|
1877
|
+
// outlives the step.
|
|
1878
|
+
abortSignal?.addEventListener('abort', cleanupStepTimeouts, {
|
|
1879
|
+
once: true,
|
|
1880
|
+
});
|
|
1808
1881
|
|
|
1809
1882
|
try {
|
|
1810
1883
|
stepFinish = new DelayedPromise<void>();
|
|
@@ -1967,6 +2040,8 @@ class DefaultStreamTextResult<
|
|
|
1967
2040
|
),
|
|
1968
2041
|
);
|
|
1969
2042
|
|
|
2043
|
+
startFirstChunkTimeout();
|
|
2044
|
+
|
|
1970
2045
|
const streamAfterToolCallbackInvocation =
|
|
1971
2046
|
invokeToolCallbacksFromStream({
|
|
1972
2047
|
stream: languageModelStream,
|
|
@@ -2076,8 +2151,6 @@ class DefaultStreamTextResult<
|
|
|
2076
2151
|
TextStreamPart<TOOLS>
|
|
2077
2152
|
>({
|
|
2078
2153
|
async transform(chunk, controller): Promise<void> {
|
|
2079
|
-
resetChunkTimeout();
|
|
2080
|
-
|
|
2081
2154
|
if (chunk.type === 'model-call-start') {
|
|
2082
2155
|
warnings = chunk.warnings;
|
|
2083
2156
|
return; // stream start chunks are sent immediately and do not count as first chunk
|
|
@@ -2096,8 +2169,14 @@ class DefaultStreamTextResult<
|
|
|
2096
2169
|
|
|
2097
2170
|
const chunkType = chunk.type;
|
|
2098
2171
|
|
|
2099
|
-
if (
|
|
2172
|
+
if (isOutputChunk(chunk)) {
|
|
2173
|
+
if (!hasReceivedOutputChunk) {
|
|
2174
|
+
// Clear before forwarding the first output so a timeout
|
|
2175
|
+
// cannot race with already-visible generated content.
|
|
2176
|
+
clearFirstChunkTimeout();
|
|
2177
|
+
}
|
|
2100
2178
|
hasReceivedOutputChunk = true;
|
|
2179
|
+
resetChunkTimeout();
|
|
2101
2180
|
}
|
|
2102
2181
|
|
|
2103
2182
|
switch (chunkType) {
|
|
@@ -2217,8 +2296,7 @@ class DefaultStreamTextResult<
|
|
|
2217
2296
|
}),
|
|
2218
2297
|
});
|
|
2219
2298
|
|
|
2220
|
-
|
|
2221
|
-
clearChunkTimeout();
|
|
2299
|
+
cleanupStepTimeouts();
|
|
2222
2300
|
self.closeStream();
|
|
2223
2301
|
return;
|
|
2224
2302
|
}
|
|
@@ -2298,9 +2376,8 @@ class DefaultStreamTextResult<
|
|
|
2298
2376
|
}
|
|
2299
2377
|
}
|
|
2300
2378
|
|
|
2301
|
-
// Clear
|
|
2302
|
-
|
|
2303
|
-
clearChunkTimeout();
|
|
2379
|
+
// Clear this step's timeouts before the next step is started.
|
|
2380
|
+
cleanupStepTimeouts();
|
|
2304
2381
|
|
|
2305
2382
|
if (
|
|
2306
2383
|
// Continue if:
|
|
@@ -2345,12 +2422,15 @@ class DefaultStreamTextResult<
|
|
|
2345
2422
|
},
|
|
2346
2423
|
}),
|
|
2347
2424
|
),
|
|
2425
|
+
{
|
|
2426
|
+
onError: cleanupStepTimeouts,
|
|
2427
|
+
onCancel: cleanupStepTimeouts,
|
|
2428
|
+
},
|
|
2348
2429
|
);
|
|
2349
2430
|
} catch (error) {
|
|
2350
2431
|
// Setup failed before the stream was registered, so neither the
|
|
2351
2432
|
// stream's flush nor an abort will clear the timers — clear them here.
|
|
2352
|
-
|
|
2353
|
-
clearChunkTimeout();
|
|
2433
|
+
cleanupStepTimeouts();
|
|
2354
2434
|
throw error;
|
|
2355
2435
|
}
|
|
2356
2436
|
}
|
|
@@ -2678,7 +2758,7 @@ class DefaultStreamTextResult<
|
|
|
2678
2758
|
...init
|
|
2679
2759
|
}: UIMessageStreamResponseInit & UIMessageStreamOptions<UI_MESSAGE> = {},
|
|
2680
2760
|
) {
|
|
2681
|
-
pipeUIMessageStreamToResponse({
|
|
2761
|
+
return pipeUIMessageStreamToResponse({
|
|
2682
2762
|
response,
|
|
2683
2763
|
stream: this.toUIMessageStream({
|
|
2684
2764
|
originalMessages,
|
|
@@ -2696,7 +2776,7 @@ class DefaultStreamTextResult<
|
|
|
2696
2776
|
}
|
|
2697
2777
|
|
|
2698
2778
|
pipeTextStreamToResponse(response: ServerResponse, init?: ResponseInit) {
|
|
2699
|
-
pipeTextStreamToResponse({
|
|
2779
|
+
return pipeTextStreamToResponse({
|
|
2700
2780
|
response,
|
|
2701
2781
|
stream: this.textStream,
|
|
2702
2782
|
...init,
|
|
@@ -18,11 +18,37 @@ async function importKey(secret: string | Uint8Array): Promise<CryptoKey> {
|
|
|
18
18
|
);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Serialize with JSON so the encoding is injective: fields may contain any
|
|
22
|
+
// character (including newlines), and escaping + array structure keeps field
|
|
23
|
+
// boundaries unambiguous. The version prefix provides domain separation.
|
|
21
24
|
function buildPayload(
|
|
22
25
|
approvalId: string,
|
|
23
26
|
toolCallId: string,
|
|
24
27
|
toolName: string,
|
|
25
28
|
inputDigest: string,
|
|
29
|
+
): Uint8Array {
|
|
30
|
+
return encoder.encode(
|
|
31
|
+
JSON.stringify([
|
|
32
|
+
'ai-sdk-tool-approval-v1',
|
|
33
|
+
approvalId,
|
|
34
|
+
toolCallId,
|
|
35
|
+
toolName,
|
|
36
|
+
inputDigest,
|
|
37
|
+
]),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Legacy newline-joined payload. Ambiguous when any field contains the `\n`
|
|
42
|
+
// delimiter, which is why it was replaced; only used as a verify-time fallback
|
|
43
|
+
// guarded on delimiter-free fields.
|
|
44
|
+
// TODO(#17494): remove in v8 when backwards compatibility with pre-JSON
|
|
45
|
+
// signatures (approvals signed before the injective format) is no longer
|
|
46
|
+
// needed.
|
|
47
|
+
function buildLegacyPayload(
|
|
48
|
+
approvalId: string,
|
|
49
|
+
toolCallId: string,
|
|
50
|
+
toolName: string,
|
|
51
|
+
inputDigest: string,
|
|
26
52
|
): Uint8Array {
|
|
27
53
|
return encoder.encode(
|
|
28
54
|
`${approvalId}\n${toolCallId}\n${toolName}\n${inputDigest}`,
|
|
@@ -66,9 +92,35 @@ export async function verifyToolApprovalSignature({
|
|
|
66
92
|
}): Promise<boolean> {
|
|
67
93
|
const key = await importKey(secret);
|
|
68
94
|
const inputDigest = await hashCanonical(input);
|
|
69
|
-
const payload = buildPayload(approvalId, toolCallId, toolName, inputDigest);
|
|
70
95
|
const sigBytes = fromBase64url(signature);
|
|
71
|
-
|
|
96
|
+
|
|
97
|
+
const payload = buildPayload(approvalId, toolCallId, toolName, inputDigest);
|
|
98
|
+
if (await crypto.subtle.verify('HMAC', key, sigBytes, payload)) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Backwards compatibility: accept a signature produced by the legacy
|
|
103
|
+
// newline-joined format, but only when no field contains the `\n` delimiter
|
|
104
|
+
// — the exact condition that made that format ambiguous. This keeps the
|
|
105
|
+
// retupling collision closed (the attack requires a newline in a field)
|
|
106
|
+
// while still verifying benign approvals signed by an older version, e.g. a
|
|
107
|
+
// pending approval that straddles an upgrade.
|
|
108
|
+
// TODO(#17494): remove in v8 (drop buildLegacyPayload and this fallback).
|
|
109
|
+
if (
|
|
110
|
+
!approvalId.includes('\n') &&
|
|
111
|
+
!toolCallId.includes('\n') &&
|
|
112
|
+
!toolName.includes('\n')
|
|
113
|
+
) {
|
|
114
|
+
const legacyPayload = buildLegacyPayload(
|
|
115
|
+
approvalId,
|
|
116
|
+
toolCallId,
|
|
117
|
+
toolName,
|
|
118
|
+
inputDigest,
|
|
119
|
+
);
|
|
120
|
+
return crypto.subtle.verify('HMAC', key, sigBytes, legacyPayload);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return false;
|
|
72
124
|
}
|
|
73
125
|
|
|
74
126
|
export async function maybeSignApproval({
|
package/src/prompt/index.ts
CHANGED
|
@@ -5,7 +5,8 @@ import type { ToolSet } from '@ai-sdk/provider-utils';
|
|
|
5
5
|
* - A number representing milliseconds
|
|
6
6
|
* - An object with `totalMs` property for the total timeout in milliseconds
|
|
7
7
|
* - An object with `stepMs` property for the timeout of each step in milliseconds
|
|
8
|
-
* - An object with `
|
|
8
|
+
* - An object with `firstChunkMs` property for the timeout until the first content chunk of each step (streaming only)
|
|
9
|
+
* - An object with `chunkMs` property for the timeout between content chunks (streaming only)
|
|
9
10
|
* - An object with `toolMs` property for the default timeout for all tool executions
|
|
10
11
|
* - An object with `tools` property for per-tool timeout overrides using `{toolName}Ms` keys
|
|
11
12
|
*/
|
|
@@ -14,6 +15,7 @@ export type TimeoutConfiguration<TOOLS extends ToolSet> =
|
|
|
14
15
|
| {
|
|
15
16
|
totalMs?: number;
|
|
16
17
|
stepMs?: number;
|
|
18
|
+
firstChunkMs?: number;
|
|
17
19
|
chunkMs?: number;
|
|
18
20
|
toolMs?: number;
|
|
19
21
|
tools?: Partial<Record<`${keyof TOOLS & string}Ms`, number>>;
|
|
@@ -52,9 +54,25 @@ export function getStepTimeoutMs(
|
|
|
52
54
|
return timeout.stepMs;
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Extracts the first chunk timeout value in milliseconds from a TimeoutConfiguration.
|
|
59
|
+
* This timeout is for streaming only - it aborts if no content chunk is received within the specified duration.
|
|
60
|
+
*
|
|
61
|
+
* @param timeout - The timeout configuration.
|
|
62
|
+
* @returns The first chunk timeout in milliseconds, or undefined if no first chunk timeout is configured.
|
|
63
|
+
*/
|
|
64
|
+
export function getFirstChunkTimeoutMs(
|
|
65
|
+
timeout: TimeoutConfiguration<any> | undefined,
|
|
66
|
+
): number | undefined {
|
|
67
|
+
if (timeout == null || typeof timeout === 'number') {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return timeout.firstChunkMs;
|
|
71
|
+
}
|
|
72
|
+
|
|
55
73
|
/**
|
|
56
74
|
* Extracts the chunk timeout value in milliseconds from a TimeoutConfiguration.
|
|
57
|
-
* This timeout is for streaming only - it aborts if no new chunk is received within the specified duration.
|
|
75
|
+
* This timeout is for streaming only - it aborts if no new content chunk is received within the specified duration.
|
|
58
76
|
*
|
|
59
77
|
* @param timeout - The timeout configuration.
|
|
60
78
|
* @returns The chunk timeout in milliseconds, or undefined if no chunk timeout is configured.
|
|
@@ -13,6 +13,7 @@ import { writeToServerResponse } from '../util/write-to-server-response';
|
|
|
13
13
|
* @param options.statusText - Optional HTTP status text.
|
|
14
14
|
* @param options.headers - Optional response headers.
|
|
15
15
|
* @param options.stream - The text stream to pipe.
|
|
16
|
+
* @returns A promise that resolves when the stream has been written.
|
|
16
17
|
*/
|
|
17
18
|
export function pipeTextStreamToResponse({
|
|
18
19
|
response,
|
|
@@ -23,8 +24,8 @@ export function pipeTextStreamToResponse({
|
|
|
23
24
|
}: {
|
|
24
25
|
response: ServerResponse;
|
|
25
26
|
stream: ReadableStream<string>;
|
|
26
|
-
} & ResponseInit): void {
|
|
27
|
-
writeToServerResponse({
|
|
27
|
+
} & ResponseInit): Promise<void> {
|
|
28
|
+
return writeToServerResponse({
|
|
28
29
|
response,
|
|
29
30
|
status,
|
|
30
31
|
statusText,
|
|
@@ -16,6 +16,7 @@ import type { UIMessageStreamResponseInit } from './ui-message-stream-response-i
|
|
|
16
16
|
* @param options.headers - Additional HTTP headers to include in the response.
|
|
17
17
|
* @param options.stream - The UI message chunk stream to send.
|
|
18
18
|
* @param options.consumeSseStream - Optional callback to consume a copy of the SSE stream independently.
|
|
19
|
+
* @returns A promise that resolves when the stream has been written.
|
|
19
20
|
*/
|
|
20
21
|
export function pipeUIMessageStreamToResponse({
|
|
21
22
|
response,
|
|
@@ -27,7 +28,7 @@ export function pipeUIMessageStreamToResponse({
|
|
|
27
28
|
}: {
|
|
28
29
|
response: ServerResponse;
|
|
29
30
|
stream: ReadableStream<UIMessageChunk>;
|
|
30
|
-
} & UIMessageStreamResponseInit): void {
|
|
31
|
+
} & UIMessageStreamResponseInit): Promise<void> {
|
|
31
32
|
let sseStream = stream.pipeThrough(new JsonToSseTransformStream());
|
|
32
33
|
|
|
33
34
|
// when the consumeSseStream is provided, we need to tee the stream
|
|
@@ -39,7 +40,7 @@ export function pipeUIMessageStreamToResponse({
|
|
|
39
40
|
consumeSseStream({ stream: stream2 }); // no await (do not block the response)
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
writeToServerResponse({
|
|
43
|
+
return writeToServerResponse({
|
|
43
44
|
response,
|
|
44
45
|
status,
|
|
45
46
|
statusText,
|
|
@@ -8,11 +8,21 @@ import { createResolvablePromise } from './create-resolvable-promise';
|
|
|
8
8
|
*/
|
|
9
9
|
export function createStitchableStream<T>(): {
|
|
10
10
|
stream: ReadableStream<T>;
|
|
11
|
-
addStream: (
|
|
11
|
+
addStream: (
|
|
12
|
+
innerStream: ReadableStream<T>,
|
|
13
|
+
callbacks?: {
|
|
14
|
+
onError?: (error: unknown) => void;
|
|
15
|
+
onCancel?: () => void;
|
|
16
|
+
},
|
|
17
|
+
) => void;
|
|
12
18
|
close: () => void;
|
|
13
19
|
terminate: () => void;
|
|
14
20
|
} {
|
|
15
|
-
let
|
|
21
|
+
let innerStreams: Array<{
|
|
22
|
+
reader: ReadableStreamDefaultReader<T>;
|
|
23
|
+
onError?: (error: unknown) => void;
|
|
24
|
+
onCancel?: () => void;
|
|
25
|
+
}> = [];
|
|
16
26
|
let controller: ReadableStreamDefaultController<T> | null = null;
|
|
17
27
|
let isClosed = false;
|
|
18
28
|
let waitForNewStream = createResolvablePromise<void>();
|
|
@@ -21,34 +31,39 @@ export function createStitchableStream<T>(): {
|
|
|
21
31
|
isClosed = true;
|
|
22
32
|
waitForNewStream.resolve();
|
|
23
33
|
|
|
24
|
-
|
|
25
|
-
|
|
34
|
+
innerStreams.forEach(({ reader, onCancel }) => {
|
|
35
|
+
onCancel?.();
|
|
36
|
+
reader.cancel();
|
|
37
|
+
});
|
|
38
|
+
innerStreams = [];
|
|
26
39
|
controller?.close();
|
|
27
40
|
};
|
|
28
41
|
|
|
29
42
|
const processPull = async () => {
|
|
30
43
|
// Case 1: Outer stream is closed and no more inner streams
|
|
31
|
-
if (isClosed &&
|
|
44
|
+
if (isClosed && innerStreams.length === 0) {
|
|
32
45
|
controller?.close();
|
|
33
46
|
return;
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
// Case 2: No inner streams available, but outer stream is open
|
|
37
50
|
// wait for a new inner stream to be added or the outer stream to close
|
|
38
|
-
if (
|
|
51
|
+
if (innerStreams.length === 0) {
|
|
39
52
|
waitForNewStream = createResolvablePromise<void>();
|
|
40
53
|
await waitForNewStream.promise;
|
|
41
54
|
return await processPull();
|
|
42
55
|
}
|
|
43
56
|
|
|
57
|
+
const currentStream = innerStreams[0];
|
|
58
|
+
|
|
44
59
|
try {
|
|
45
|
-
const { value, done } = await
|
|
60
|
+
const { value, done } = await currentStream.reader.read();
|
|
46
61
|
|
|
47
62
|
if (done) {
|
|
48
63
|
// Case 3: Current inner stream is done
|
|
49
|
-
|
|
64
|
+
innerStreams.shift(); // Remove the finished stream
|
|
50
65
|
|
|
51
|
-
if (
|
|
66
|
+
if (innerStreams.length === 0 && isClosed) {
|
|
52
67
|
// when closed and no more inner streams, stop pulling
|
|
53
68
|
controller?.close();
|
|
54
69
|
} else {
|
|
@@ -61,8 +76,9 @@ export function createStitchableStream<T>(): {
|
|
|
61
76
|
}
|
|
62
77
|
} catch (error) {
|
|
63
78
|
// Case 5: Current inner stream throws an error
|
|
79
|
+
currentStream.onError?.(error);
|
|
64
80
|
controller?.error(error);
|
|
65
|
-
|
|
81
|
+
innerStreams.shift(); // Remove the errored stream
|
|
66
82
|
terminate(); // we have errored, terminate all streams
|
|
67
83
|
}
|
|
68
84
|
};
|
|
@@ -74,19 +90,29 @@ export function createStitchableStream<T>(): {
|
|
|
74
90
|
},
|
|
75
91
|
pull: processPull,
|
|
76
92
|
async cancel() {
|
|
77
|
-
for (const reader of
|
|
93
|
+
for (const { reader, onCancel } of innerStreams) {
|
|
94
|
+
onCancel?.();
|
|
78
95
|
await reader.cancel();
|
|
79
96
|
}
|
|
80
|
-
|
|
97
|
+
innerStreams = [];
|
|
81
98
|
isClosed = true;
|
|
82
99
|
},
|
|
83
100
|
}),
|
|
84
|
-
addStream: (
|
|
101
|
+
addStream: (
|
|
102
|
+
innerStream: ReadableStream<T>,
|
|
103
|
+
callbacks?: {
|
|
104
|
+
onError?: (error: unknown) => void;
|
|
105
|
+
onCancel?: () => void;
|
|
106
|
+
},
|
|
107
|
+
) => {
|
|
85
108
|
if (isClosed) {
|
|
86
109
|
throw new Error('Cannot add inner stream: outer stream is closed');
|
|
87
110
|
}
|
|
88
111
|
|
|
89
|
-
|
|
112
|
+
innerStreams.push({
|
|
113
|
+
reader: innerStream.getReader(),
|
|
114
|
+
...callbacks,
|
|
115
|
+
});
|
|
90
116
|
waitForNewStream.resolve();
|
|
91
117
|
},
|
|
92
118
|
|
|
@@ -98,7 +124,7 @@ export function createStitchableStream<T>(): {
|
|
|
98
124
|
isClosed = true;
|
|
99
125
|
waitForNewStream.resolve();
|
|
100
126
|
|
|
101
|
-
if (
|
|
127
|
+
if (innerStreams.length === 0) {
|
|
102
128
|
controller?.close();
|
|
103
129
|
}
|
|
104
130
|
},
|
|
@@ -19,7 +19,7 @@ export function writeToServerResponse({
|
|
|
19
19
|
statusText?: string;
|
|
20
20
|
headers?: Record<string, string | number | string[]>;
|
|
21
21
|
stream: ReadableStream<Uint8Array>;
|
|
22
|
-
}): void {
|
|
22
|
+
}): Promise<void> {
|
|
23
23
|
const statusCode = status ?? 200;
|
|
24
24
|
if (statusText !== undefined) {
|
|
25
25
|
response.writeHead(statusCode, statusText, headers);
|
|
@@ -54,5 +54,5 @@ export function writeToServerResponse({
|
|
|
54
54
|
}
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
-
read();
|
|
57
|
+
return read();
|
|
58
58
|
}
|