@ai-sdk/provider-utils 5.0.47 → 5.0.50
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 +31 -0
- package/dist/experimental-evaluation/index.js +33 -47
- package/dist/experimental-evaluation/index.js.map +1 -1
- package/dist/index.d.ts +66 -5
- package/dist/index.js +424 -176
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/download-blob.ts +2 -2
- package/src/fetch-untrusted-url.ts +86 -0
- package/src/fetch-with-validated-redirects.ts +3 -0
- package/src/index.ts +1 -0
- package/src/is-url-supported.ts +5 -3
- package/src/sanitize-request-headers.ts +5 -3
- package/src/streaming-tool-call-argument-state.ts +100 -0
- package/src/streaming-tool-call-tracker.ts +313 -36
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.50",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
}
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@ai-sdk/provider": "4.0.
|
|
40
|
+
"@ai-sdk/provider": "4.0.19",
|
|
41
41
|
"@standard-schema/spec": "^1.1.0",
|
|
42
42
|
"@workflow/serde": "4.1.0",
|
|
43
43
|
"eventsource-parser": "^3.0.8",
|
package/src/download-blob.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cancelResponseBody } from './cancel-response-body';
|
|
2
2
|
import { DownloadError } from './download-error';
|
|
3
|
-
import {
|
|
3
|
+
import { fetchUntrustedUrl } from './fetch-untrusted-url';
|
|
4
4
|
import {
|
|
5
5
|
readResponseWithSizeLimit,
|
|
6
6
|
DEFAULT_MAX_DOWNLOAD_SIZE,
|
|
@@ -22,7 +22,7 @@ export async function downloadBlob(
|
|
|
22
22
|
options?: { maxBytes?: number; abortSignal?: AbortSignal },
|
|
23
23
|
): Promise<Blob> {
|
|
24
24
|
try {
|
|
25
|
-
const response = await
|
|
25
|
+
const response = await fetchUntrustedUrl({
|
|
26
26
|
url,
|
|
27
27
|
abortSignal: options?.abortSignal,
|
|
28
28
|
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { fetchWithValidatedRedirects } from './fetch-with-validated-redirects';
|
|
2
|
+
import { isSameOrigin } from './is-same-origin';
|
|
3
|
+
import { sanitizeRequestHeaders } from './sanitize-request-headers';
|
|
4
|
+
|
|
5
|
+
// Providers can use arbitrary credential header names. Only established
|
|
6
|
+
// non-credential request metadata is safe to forward without an origin assertion.
|
|
7
|
+
const SAFE_UNTRUSTED_FIRST_HOP_HEADERS = new Set([
|
|
8
|
+
'accept',
|
|
9
|
+
'accept-language',
|
|
10
|
+
'baggage',
|
|
11
|
+
'cache-control',
|
|
12
|
+
'idempotency-key',
|
|
13
|
+
'if-match',
|
|
14
|
+
'if-modified-since',
|
|
15
|
+
'if-none-match',
|
|
16
|
+
'if-range',
|
|
17
|
+
'if-unmodified-since',
|
|
18
|
+
'pragma',
|
|
19
|
+
'range',
|
|
20
|
+
'traceparent',
|
|
21
|
+
'tracestate',
|
|
22
|
+
'user-agent',
|
|
23
|
+
'x-correlation-id',
|
|
24
|
+
'x-request-id',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fetches an untrusted URL with first-hop credential isolation and validated
|
|
29
|
+
* redirects. Uses the URL validation, DNS-pinned Node.js transport, redirect
|
|
30
|
+
* limits, and cross-origin header stripping of {@link fetchWithValidatedRedirects}.
|
|
31
|
+
* An injected fetch must provide equivalent connect-time DNS validation.
|
|
32
|
+
*
|
|
33
|
+
* Without a matching `credentialedOrigin` (or `trustedOrigin` when it is
|
|
34
|
+
* omitted), only allowlisted request metadata is sent on the first hop.
|
|
35
|
+
* Arbitrary caller headers require an explicit matching origin because vendor
|
|
36
|
+
* credential names cannot be inferred safely. Proxy, metadata, cookie, and
|
|
37
|
+
* hop-by-hop headers are sanitized even for a matching origin.
|
|
38
|
+
*
|
|
39
|
+
* `trustedOrigin` also exempts same-origin hops from URL validation, allowing
|
|
40
|
+
* developer-configured private endpoints. Both origin options must come from
|
|
41
|
+
* developer configuration, never from untrusted response data.
|
|
42
|
+
*
|
|
43
|
+
* This is an opt-in alternative to `fetchWithValidatedRedirects`, whose
|
|
44
|
+
* existing first-hop header behavior is preserved for compatibility.
|
|
45
|
+
*/
|
|
46
|
+
export async function fetchUntrustedUrl({
|
|
47
|
+
headers,
|
|
48
|
+
credentialedOrigin,
|
|
49
|
+
untrustedFirstHopHeaders,
|
|
50
|
+
...options
|
|
51
|
+
}: Parameters<typeof fetchWithValidatedRedirects>[0] & {
|
|
52
|
+
/**
|
|
53
|
+
* The developer-configured origin allowed to receive arbitrary caller
|
|
54
|
+
* headers on the first hop. Defaults to `trustedOrigin` when omitted.
|
|
55
|
+
* An explicit value takes precedence over `trustedOrigin` and does not
|
|
56
|
+
* exempt the URL from validation.
|
|
57
|
+
*/
|
|
58
|
+
credentialedOrigin?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Additional sanitized header names safe to disclose to an untrusted first
|
|
61
|
+
* hop. Use only for non-credential protocol metadata. Credentials require
|
|
62
|
+
* a matching `credentialedOrigin` instead. Names are case-insensitive.
|
|
63
|
+
*/
|
|
64
|
+
untrustedFirstHopHeaders?: readonly string[];
|
|
65
|
+
}): Promise<Response> {
|
|
66
|
+
let firstHopHeaders: Headers | undefined;
|
|
67
|
+
if (headers !== undefined) {
|
|
68
|
+
firstHopHeaders = sanitizeRequestHeaders(headers);
|
|
69
|
+
const origin = credentialedOrigin ?? options.trustedOrigin;
|
|
70
|
+
|
|
71
|
+
if (origin === undefined || !isSameOrigin(options.url, origin)) {
|
|
72
|
+
const allowedHeaders = new Set([
|
|
73
|
+
...SAFE_UNTRUSTED_FIRST_HOP_HEADERS,
|
|
74
|
+
...(untrustedFirstHopHeaders ?? []).map(name => name.toLowerCase()),
|
|
75
|
+
]);
|
|
76
|
+
firstHopHeaders = new Headers(
|
|
77
|
+
[...firstHopHeaders].filter(([name]) => allowedHeaders.has(name)),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return fetchWithValidatedRedirects({
|
|
83
|
+
...options,
|
|
84
|
+
headers: firstHopHeaders,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -83,6 +83,9 @@ export async function fetchWithValidatedEndpoint({
|
|
|
83
83
|
* Request headers are also protected: {@link sanitizeRequestHeaders} strips
|
|
84
84
|
* proxy/metadata/cookie/hop-by-hop headers before the first request, and all
|
|
85
85
|
* caller headers except `User-Agent` are dropped on a cross-origin redirect.
|
|
86
|
+
* Credentials and custom headers are preserved on the first hop for backwards
|
|
87
|
+
* compatibility. The caller must ensure that the initial URL may receive them.
|
|
88
|
+
* Use `fetchUntrustedUrl` for URLs that require first-hop credential isolation.
|
|
86
89
|
* The fetch spec only strips `Authorization` on cross-origin redirects because
|
|
87
90
|
* in a browser, CORS preflighting protects custom headers; there is no CORS on
|
|
88
91
|
* the server, so provider API keys carried in custom headers (e.g. `x-key`)
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ export {
|
|
|
42
42
|
export { extractLines } from './extract-lines';
|
|
43
43
|
export * from './extract-response-headers';
|
|
44
44
|
export * from './fetch-function';
|
|
45
|
+
export { fetchUntrustedUrl } from './fetch-untrusted-url';
|
|
45
46
|
export { filterNullable } from './filter-nullable';
|
|
46
47
|
export { createIdGenerator, generateId, type IdGenerator } from './generate-id';
|
|
47
48
|
export * from './get-error-message';
|
package/src/is-url-supported.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Checks if the given URL is supported natively by the model.
|
|
3
3
|
*
|
|
4
|
-
* @param mediaType - The media type of the URL. Case-
|
|
4
|
+
* @param mediaType - The media type of the URL. Case-insensitive. May be a full
|
|
5
5
|
* `type/subtype`, a wildcard `type/*`, or just the
|
|
6
6
|
* top-level segment (e.g. `image`).
|
|
7
7
|
* @param url - The URL to check.
|
|
8
|
-
* @param supportedUrls - A record where keys are case-
|
|
8
|
+
* @param supportedUrls - A record where keys are case-insensitive media types (or '*')
|
|
9
9
|
* and values are arrays of RegExp patterns for URLs.
|
|
10
10
|
*
|
|
11
11
|
* @returns `true` if the URL matches a pattern under the specific media type
|
|
@@ -46,7 +46,9 @@ export function isUrlSupported({
|
|
|
46
46
|
if (isTopLevelOnly) {
|
|
47
47
|
return `${mediaType}/` === mediaTypePrefix;
|
|
48
48
|
}
|
|
49
|
-
return
|
|
49
|
+
return mediaTypePrefix.endsWith('/')
|
|
50
|
+
? mediaType.startsWith(mediaTypePrefix)
|
|
51
|
+
: mediaType === mediaTypePrefix;
|
|
50
52
|
})
|
|
51
53
|
.flatMap(({ regexes }) => regexes)
|
|
52
54
|
// check if any pattern matches the url:
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* transport headers (RFC 7230 §6.1).
|
|
5
5
|
*
|
|
6
6
|
* `Authorization` and other credential-bearing caller headers (e.g. `x-key`)
|
|
7
|
-
* are intentionally not listed
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* are intentionally not listed because trusted provider requests may need
|
|
8
|
+
* them. `fetchUntrustedUrl` separately restricts an untrusted first hop to an
|
|
9
|
+
* explicit allowlist of non-credential request metadata. Both it and
|
|
10
|
+
* `fetchWithValidatedRedirects` drop all caller headers except the user-agent
|
|
11
|
+
* on a cross-origin redirect.
|
|
10
12
|
*/
|
|
11
13
|
const BLOCKED_REQUEST_HEADERS: readonly string[] = [
|
|
12
14
|
// Hop-by-hop / transport (RFC 7230 §6.1)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
type ArgumentStructure =
|
|
2
|
+
| { kind: 'undetermined' }
|
|
3
|
+
| { kind: 'other' }
|
|
4
|
+
| {
|
|
5
|
+
kind: 'structured';
|
|
6
|
+
stack: Array<'{' | '['>;
|
|
7
|
+
inString: boolean;
|
|
8
|
+
escaped: boolean;
|
|
9
|
+
complete: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function startsWithStructuredValue(
|
|
13
|
+
value: string | null | undefined,
|
|
14
|
+
): boolean {
|
|
15
|
+
if (typeof value !== 'string') {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const firstCharacter = value.trimStart()[0];
|
|
20
|
+
return firstCharacter === '{' || firstCharacter === '[';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Incrementally tracks whether streamed tool-call arguments contain a complete
|
|
25
|
+
* structured JSON value. This is intentionally structural rather than a JSON
|
|
26
|
+
* parse: a currently parsable scalar can still be the prefix of a later value.
|
|
27
|
+
*/
|
|
28
|
+
export class StreamingToolCallArgumentState {
|
|
29
|
+
private structure: ArgumentStructure = { kind: 'undetermined' };
|
|
30
|
+
|
|
31
|
+
constructor(initialValue = '') {
|
|
32
|
+
this.append(initialValue);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get hasCompleteStructuredValue(): boolean {
|
|
36
|
+
return (
|
|
37
|
+
this.structure.kind === 'structured' && this.structure.complete === true
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
append(delta: string): void {
|
|
42
|
+
let nextStructure = this.structure;
|
|
43
|
+
|
|
44
|
+
for (const character of delta) {
|
|
45
|
+
if (nextStructure.kind === 'undetermined') {
|
|
46
|
+
if (/\s/.test(character)) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (character !== '{' && character !== '[') {
|
|
51
|
+
nextStructure = { kind: 'other' };
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
nextStructure = {
|
|
56
|
+
kind: 'structured',
|
|
57
|
+
stack: [character],
|
|
58
|
+
inString: false,
|
|
59
|
+
escaped: false,
|
|
60
|
+
complete: false,
|
|
61
|
+
};
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (nextStructure.kind !== 'structured' || nextStructure.complete) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (nextStructure.inString) {
|
|
70
|
+
if (nextStructure.escaped) {
|
|
71
|
+
nextStructure.escaped = false;
|
|
72
|
+
} else if (character === '\\') {
|
|
73
|
+
nextStructure.escaped = true;
|
|
74
|
+
} else if (character === '"') {
|
|
75
|
+
nextStructure.inString = false;
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (character === '"') {
|
|
81
|
+
nextStructure.inString = true;
|
|
82
|
+
} else if (character === '{' || character === '[') {
|
|
83
|
+
nextStructure.stack.push(character);
|
|
84
|
+
} else if (character === '}' || character === ']') {
|
|
85
|
+
const expectedOpening = character === '}' ? '{' : '[';
|
|
86
|
+
if (nextStructure.stack.at(-1) !== expectedOpening) {
|
|
87
|
+
nextStructure = { kind: 'other' };
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
nextStructure.stack.pop();
|
|
92
|
+
if (nextStructure.stack.length === 0) {
|
|
93
|
+
nextStructure.complete = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.structure = nextStructure;
|
|
99
|
+
}
|
|
100
|
+
}
|