@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/CHANGELOG.md +8 -0
- package/dist/index.d.ts +220 -151
- package/dist/index.js +447 -172
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/convert-inline-file-data-to-uint8-array.ts +13 -1
- package/src/delete-from-api.ts +102 -0
- package/src/index.ts +2 -0
- package/src/post-multipart-stream-to-api.ts +268 -0
- package/src/response-handler.ts +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-sdk/provider-utils",
|
|
3
|
-
"version": "5.0.
|
|
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.
|
|
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
|
+
};
|
package/src/response-handler.ts
CHANGED
|
@@ -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 }) => {
|