@ai-sdk/provider-utils 5.0.34 → 5.0.35

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/provider-utils",
3
- "version": "5.0.34",
3
+ "version": "5.0.35",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -32,7 +32,7 @@
32
32
  }
33
33
  },
34
34
  "dependencies": {
35
- "@ai-sdk/provider": "4.0.9",
35
+ "@ai-sdk/provider": "4.0.10",
36
36
  "@standard-schema/spec": "^1.1.0",
37
37
  "@workflow/serde": "4.1.0",
38
38
  "eventsource-parser": "^3.0.8",
@@ -1,3 +1,4 @@
1
+ import { UnsupportedFunctionalityError } from '@ai-sdk/provider';
1
2
  import type { FilePart } from './types/content-part';
2
3
  import { convertBase64ToUint8Array } from './uint8-utils';
3
4
 
@@ -13,10 +14,21 @@ type InlineFileData = Extract<
13
14
  * - `{ type: 'data', data: Uint8Array | Buffer }` → returned as-is
14
15
  * - `{ type: 'data', data: ArrayBuffer }` → wrapped in a `Uint8Array`
15
16
  * - `{ type: 'data', data: string }` → decoded as base64
17
+ *
18
+ * `{ type: 'stream' }` data is rejected: providers without streaming upload
19
+ * support funnel here and surface a clear `UnsupportedFunctionalityError`.
16
20
  */
17
21
  export function convertInlineFileDataToUint8Array(
18
- data: InlineFileData,
22
+ data: InlineFileData | { type: 'stream'; stream: ReadableStream<Uint8Array> },
19
23
  ): Uint8Array {
24
+ if (data.type === 'stream') {
25
+ const error = new UnsupportedFunctionalityError({
26
+ functionality: 'streaming file upload',
27
+ });
28
+ // the caller's stream is never consumed on this path — release it
29
+ void data.stream.cancel(error).catch(() => {});
30
+ throw error;
31
+ }
20
32
  if (data.type === 'text') {
21
33
  return new TextEncoder().encode(data.text);
22
34
  }
@@ -0,0 +1,102 @@
1
+ import { APICallError } from '@ai-sdk/provider';
2
+ import { extractResponseHeaders } from './extract-response-headers';
3
+ import type { FetchFunction } from './fetch-function';
4
+ import { getRuntimeEnvironmentUserAgent } from './get-runtime-environment-user-agent';
5
+ import { handleFetchError } from './handle-fetch-error';
6
+ import { isAbortError } from './is-abort-error';
7
+ import type { ResponseHandler } from './response-handler';
8
+ import { VERSION } from './version';
9
+ import { withUserAgentSuffix } from './with-user-agent-suffix';
10
+
11
+ // use function to allow for mocking in tests:
12
+ const getOriginalFetch = () => globalThis.fetch;
13
+
14
+ /**
15
+ * Sends a DELETE request. For URLs built from developer-configured endpoints
16
+ * only — there is no untrusted-URL validation path (use `getFromApi` with
17
+ * `validateUrl` for response-supplied URLs).
18
+ */
19
+ export const deleteFromApi = async <T>({
20
+ url,
21
+ headers = {},
22
+ failedResponseHandler,
23
+ successfulResponseHandler,
24
+ abortSignal,
25
+ fetch = getOriginalFetch(),
26
+ }: {
27
+ url: string;
28
+ headers?: Record<string, string | undefined>;
29
+ failedResponseHandler: ResponseHandler<Error>;
30
+ successfulResponseHandler: ResponseHandler<T>;
31
+ abortSignal?: AbortSignal;
32
+ fetch?: FetchFunction;
33
+ }) => {
34
+ try {
35
+ const response = await fetch(url, {
36
+ method: 'DELETE',
37
+ headers: withUserAgentSuffix(
38
+ headers,
39
+ `ai-sdk/provider-utils/${VERSION}`,
40
+ getRuntimeEnvironmentUserAgent(),
41
+ ),
42
+ signal: abortSignal,
43
+ });
44
+
45
+ const responseHeaders = extractResponseHeaders(response);
46
+
47
+ if (!response.ok) {
48
+ let errorInformation: {
49
+ value: Error;
50
+ responseHeaders?: Record<string, string> | undefined;
51
+ };
52
+
53
+ try {
54
+ errorInformation = await failedResponseHandler({
55
+ response,
56
+ url,
57
+ requestBodyValues: {},
58
+ });
59
+ } catch (error) {
60
+ if (isAbortError(error) || APICallError.isInstance(error)) {
61
+ throw error;
62
+ }
63
+
64
+ throw new APICallError({
65
+ message: 'Failed to process error response',
66
+ cause: error,
67
+ statusCode: response.status,
68
+ url,
69
+ responseHeaders,
70
+ requestBodyValues: {},
71
+ });
72
+ }
73
+
74
+ throw errorInformation.value;
75
+ }
76
+
77
+ try {
78
+ return await successfulResponseHandler({
79
+ response,
80
+ url,
81
+ requestBodyValues: {},
82
+ });
83
+ } catch (error) {
84
+ if (error instanceof Error) {
85
+ if (isAbortError(error) || APICallError.isInstance(error)) {
86
+ throw error;
87
+ }
88
+ }
89
+
90
+ throw new APICallError({
91
+ message: 'Failed to process successful response',
92
+ cause: error,
93
+ statusCode: response.status,
94
+ url,
95
+ responseHeaders,
96
+ requestBodyValues: {},
97
+ });
98
+ }
99
+ } catch (error) {
100
+ throw handleFetchError({ error, url, requestBodyValues: {} });
101
+ }
102
+ };
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ export {
22
22
  } from './create-provider-stream-error';
23
23
  export * from './delay';
24
24
  export { DelayedPromise } from './delayed-promise';
25
+ export * from './delete-from-api';
25
26
  export {
26
27
  detectMediaType,
27
28
  getTopLevelMediaType,
@@ -64,6 +65,7 @@ export { normalizeBatchRequestCounts } from './normalize-batch-request-counts';
64
65
  export * from './parse-json';
65
66
  export { parseJsonEventStream } from './parse-json-event-stream';
66
67
  export { parseProviderOptions } from './parse-provider-options';
68
+ export * from './post-multipart-stream-to-api';
67
69
  export * from './post-to-api';
68
70
  export {
69
71
  createProviderDefinedToolFactory,
@@ -0,0 +1,268 @@
1
+ import { APICallError } from '@ai-sdk/provider';
2
+ import { convertAsyncIteratorToReadableStream } from './convert-async-iterator-to-readable-stream';
3
+ import { extractResponseHeaders } from './extract-response-headers';
4
+ import type { FetchFunction } from './fetch-function';
5
+ import { generateId } from './generate-id';
6
+ import { getRuntimeEnvironmentUserAgent } from './get-runtime-environment-user-agent';
7
+ import { handleFetchError } from './handle-fetch-error';
8
+ import { isAbortError } from './is-abort-error';
9
+ import type { ResponseHandler } from './response-handler';
10
+ import { VERSION } from './version';
11
+ import { withUserAgentSuffix } from './with-user-agent-suffix';
12
+
13
+ // use function to allow for mocking in tests:
14
+ const getOriginalFetch = () => globalThis.fetch;
15
+
16
+ /**
17
+ * A part of a streaming multipart/form-data request body.
18
+ *
19
+ * Parts are emitted in array order, which providers may depend on
20
+ * (e.g. xAI requires expiry fields to precede the file part).
21
+ */
22
+ export type MultipartStreamPart =
23
+ | { type: 'field'; name: string; value: string }
24
+ | {
25
+ type: 'file';
26
+ name: string;
27
+ filename?: string;
28
+ mediaType?: string;
29
+ content: ReadableStream<Uint8Array> | Uint8Array;
30
+ };
31
+
32
+ // header parameter values must not smuggle CR/LF or break the quoted-string
33
+ function escapeMultipartHeaderValue(value: string): string {
34
+ return value
35
+ .replace(/[\r\n]/g, '')
36
+ .replace(/\\/g, '\\\\')
37
+ .replace(/"/g, '\\"');
38
+ }
39
+
40
+ interface MultipartBody {
41
+ stream: ReadableStream<Uint8Array>;
42
+ /**
43
+ * Deterministically releases every stream part after a failed or abandoned
44
+ * request — fetch implementations do not reliably cancel streaming request
45
+ * bodies on rejection/abort. Safe to call multiple times.
46
+ */
47
+ dispose: (reason?: unknown) => Promise<void>;
48
+ }
49
+
50
+ function createMultipartBody(
51
+ parts: Array<MultipartStreamPart>,
52
+ boundary: string,
53
+ ): MultipartBody {
54
+ const encoder = new TextEncoder();
55
+
56
+ let disposed = false;
57
+ let activeReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
58
+ const enteredStreams = new Set<ReadableStream<Uint8Array>>();
59
+
60
+ async function* emitParts(): AsyncGenerator<Uint8Array> {
61
+ for (const part of parts) {
62
+ if (disposed) return;
63
+
64
+ const disposition = `--${boundary}\r\nContent-Disposition: form-data; name="${escapeMultipartHeaderValue(part.name)}"`;
65
+
66
+ if (part.type === 'field') {
67
+ yield encoder.encode(`${disposition}\r\n\r\n${part.value}\r\n`);
68
+ continue;
69
+ }
70
+
71
+ // a filename parameter is always emitted: without it, multipart
72
+ // parsers treat the part as a scalar field (FormData defaults to
73
+ // "blob" as well)
74
+ const filenameParameter = `; filename="${escapeMultipartHeaderValue(
75
+ part.filename ?? 'blob',
76
+ )}"`;
77
+ const mediaType = (part.mediaType ?? 'application/octet-stream').replace(
78
+ /[\r\n]/g,
79
+ '',
80
+ );
81
+
82
+ yield encoder.encode(
83
+ `${disposition}${filenameParameter}\r\nContent-Type: ${mediaType}\r\n\r\n`,
84
+ );
85
+
86
+ if (part.content instanceof Uint8Array) {
87
+ yield part.content;
88
+ } else {
89
+ // synchronous handoff (no awaits) so dispose() sees either the
90
+ // active reader or an un-entered stream, never neither
91
+ const reader = part.content.getReader();
92
+ activeReader = reader;
93
+ enteredStreams.add(part.content);
94
+
95
+ let finished = false;
96
+ try {
97
+ while (true) {
98
+ const { done, value } = await reader.read();
99
+ if (done || disposed) {
100
+ finished = done;
101
+ break;
102
+ }
103
+ yield value;
104
+ }
105
+ } finally {
106
+ activeReader = undefined;
107
+ // early teardown (dispose/cancel): release the source stream
108
+ if (!finished) {
109
+ await reader.cancel().catch(() => {});
110
+ }
111
+ reader.releaseLock();
112
+ }
113
+
114
+ if (disposed) return;
115
+ }
116
+
117
+ yield encoder.encode('\r\n');
118
+ }
119
+
120
+ yield encoder.encode(`--${boundary}--\r\n`);
121
+ }
122
+
123
+ return {
124
+ stream: convertAsyncIteratorToReadableStream(emitParts()),
125
+ async dispose(reason?: unknown) {
126
+ if (disposed) return;
127
+ disposed = true;
128
+
129
+ // an active reader holds the lock; cancelling it resolves a pending
130
+ // read() and propagates cancellation to the underlying source
131
+ const reader = activeReader;
132
+ if (reader != null) {
133
+ await reader.cancel(reason).catch(() => {});
134
+ }
135
+
136
+ // streams the generator never entered are still unlocked
137
+ for (const part of parts) {
138
+ if (
139
+ part.type === 'file' &&
140
+ !(part.content instanceof Uint8Array) &&
141
+ !enteredStreams.has(part.content)
142
+ ) {
143
+ await part.content.cancel(reason).catch(() => {});
144
+ }
145
+ }
146
+ },
147
+ };
148
+ }
149
+
150
+ /**
151
+ * POSTs a multipart/form-data body as a request stream, so file parts backed
152
+ * by a `ReadableStream` are sent without buffering the full file in memory.
153
+ *
154
+ * Requires a fetch implementation that supports streaming request bodies
155
+ * (`duplex: 'half'`). Callers with fully buffered payloads can keep using
156
+ * `postFormDataToApi`.
157
+ */
158
+ export const postMultipartStreamToApi = async <T>({
159
+ url,
160
+ headers = {},
161
+ parts,
162
+ failedResponseHandler,
163
+ successfulResponseHandler,
164
+ abortSignal,
165
+ fetch = getOriginalFetch(),
166
+ }: {
167
+ url: string;
168
+ headers?: Record<string, string | undefined>;
169
+ parts: Array<MultipartStreamPart>;
170
+ failedResponseHandler: ResponseHandler<Error>;
171
+ successfulResponseHandler: ResponseHandler<T>;
172
+ abortSignal?: AbortSignal;
173
+ fetch?: FetchFunction;
174
+ }) => {
175
+ const boundary = `ai-sdk-multipart-${generateId()}`;
176
+
177
+ // stream contents cannot be replayed for error reporting; expose part
178
+ // names, field values, and file placeholders only
179
+ const requestBodyValues = Object.fromEntries(
180
+ parts.map(part => [
181
+ part.name,
182
+ part.type === 'field'
183
+ ? part.value
184
+ : `<file:${part.filename ?? part.name}>`,
185
+ ]),
186
+ );
187
+
188
+ const body = createMultipartBody(parts, boundary);
189
+
190
+ try {
191
+ const requestInit: RequestInit & { duplex: 'half' } = {
192
+ method: 'POST',
193
+ headers: withUserAgentSuffix(
194
+ {
195
+ ...headers,
196
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
197
+ },
198
+ `ai-sdk/provider-utils/${VERSION}`,
199
+ getRuntimeEnvironmentUserAgent(),
200
+ ),
201
+ body: body.stream,
202
+ duplex: 'half',
203
+ signal: abortSignal,
204
+ };
205
+
206
+ const response = await fetch(url, requestInit);
207
+
208
+ const responseHeaders = extractResponseHeaders(response);
209
+
210
+ if (!response.ok) {
211
+ let errorInformation: {
212
+ value: Error;
213
+ responseHeaders?: Record<string, string> | undefined;
214
+ };
215
+
216
+ try {
217
+ errorInformation = await failedResponseHandler({
218
+ response,
219
+ url,
220
+ requestBodyValues,
221
+ });
222
+ } catch (error) {
223
+ if (isAbortError(error) || APICallError.isInstance(error)) {
224
+ throw error;
225
+ }
226
+
227
+ throw new APICallError({
228
+ message: 'Failed to process error response',
229
+ cause: error,
230
+ statusCode: response.status,
231
+ url,
232
+ responseHeaders,
233
+ requestBodyValues,
234
+ });
235
+ }
236
+
237
+ throw errorInformation.value;
238
+ }
239
+
240
+ try {
241
+ return await successfulResponseHandler({
242
+ response,
243
+ url,
244
+ requestBodyValues,
245
+ });
246
+ } catch (error) {
247
+ if (error instanceof Error) {
248
+ if (isAbortError(error) || APICallError.isInstance(error)) {
249
+ throw error;
250
+ }
251
+ }
252
+
253
+ throw new APICallError({
254
+ message: 'Failed to process successful response',
255
+ cause: error,
256
+ statusCode: response.status,
257
+ url,
258
+ responseHeaders,
259
+ requestBodyValues,
260
+ });
261
+ }
262
+ } catch (error) {
263
+ // fetch implementations do not reliably cancel streaming request bodies
264
+ // on rejection/abort — release every source part deterministically
265
+ await body.dispose(error);
266
+ throw handleFetchError({ error, url, requestBodyValues });
267
+ }
268
+ };
@@ -327,6 +327,32 @@ export const createBinaryResponseHandler =
327
327
  }
328
328
  };
329
329
 
330
+ /**
331
+ * Passes the response body through as a `ReadableStream<Uint8Array>` without
332
+ * buffering it (unlike `createBinaryResponseHandler`). The consumer is
333
+ * responsible for draining or cancelling the stream.
334
+ */
335
+ export const createBinaryStreamResponseHandler =
336
+ (): ResponseHandler<ReadableStream<Uint8Array>> =>
337
+ async ({ response, url, requestBodyValues }) => {
338
+ const responseHeaders = extractResponseHeaders(response);
339
+
340
+ if (response.body == null) {
341
+ throw new EmptyResponseBodyError({});
342
+ }
343
+
344
+ return {
345
+ responseHeaders,
346
+ value: wrapResponseBodyStream({
347
+ stream: response.body,
348
+ url,
349
+ requestBodyValues,
350
+ statusCode: response.status,
351
+ responseHeaders,
352
+ }),
353
+ };
354
+ };
355
+
330
356
  export const createStatusCodeErrorResponseHandler =
331
357
  (): ResponseHandler<APICallError> =>
332
358
  async ({ response, url, requestBodyValues }) => {