@ai-sdk/provider-utils 4.0.55 → 4.0.57

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": "4.0.55",
3
+ "version": "4.0.57",
4
4
  "license": "Apache-2.0",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -1,11 +1,70 @@
1
1
  import { cancelResponseBody } from './cancel-response-body';
2
2
  import { DownloadError } from './download-error';
3
+ import type { FetchFunction } from './fetch-function';
3
4
  import { isBrowserRuntime } from './is-browser-runtime';
5
+ import { isSameOrigin } from './is-same-origin';
4
6
  import { getDefaultDownloadFetch } from './safe-node-fetch';
5
7
  import { validateDownloadUrl } from './validate-download-url';
6
8
 
7
9
  const MAX_DOWNLOAD_REDIRECTS = 10;
8
10
 
11
+ async function getValidatedFetch(
12
+ customFetch: FetchFunction | undefined,
13
+ ): Promise<FetchFunction> {
14
+ // Callers commonly pass globalThis.fetch through several abstraction
15
+ // layers. Preserve the DNS-pinned Node.js default in that case rather than
16
+ // accidentally treating it as an intentionally custom fetch.
17
+ return customFetch == null || customFetch === globalThis.fetch
18
+ ? await getDefaultDownloadFetch()
19
+ : customFetch;
20
+ }
21
+
22
+ /**
23
+ * Fetches one validated URL without following redirects.
24
+ *
25
+ * On Node.js, the default fetch validates and pins DNS results at connect time.
26
+ * An injected fetch is responsible for equivalent connect-time validation.
27
+ * Redirects are rejected by default. Callers using `redirect: 'manual'` must
28
+ * validate the Location target before issuing another request.
29
+ */
30
+ export async function fetchWithValidatedEndpoint({
31
+ url,
32
+ init,
33
+ fetch: customFetch,
34
+ trustedOrigin,
35
+ redirect = 'error',
36
+ }: {
37
+ url: string | URL;
38
+ init?: RequestInit;
39
+ fetch?: FetchFunction;
40
+ /**
41
+ * A developer-configured origin that may legitimately resolve to a private
42
+ * address. This must never be derived from untrusted response data.
43
+ */
44
+ trustedOrigin?: string;
45
+ redirect?: 'error' | 'manual';
46
+ }): Promise<Response> {
47
+ const urlText = url.toString();
48
+ const isTrusted =
49
+ trustedOrigin !== undefined && isSameOrigin(urlText, trustedOrigin);
50
+
51
+ if (!isTrusted) {
52
+ validateDownloadUrl(urlText);
53
+ }
54
+
55
+ const fetch =
56
+ isTrusted && customFetch != null
57
+ ? customFetch
58
+ : isTrusted
59
+ ? globalThis.fetch
60
+ : await getValidatedFetch(customFetch);
61
+
62
+ return await fetch(url, {
63
+ ...init,
64
+ redirect,
65
+ });
66
+ }
67
+
9
68
  /**
10
69
  * Fetches a URL while enforcing the SSRF download guard on every hop.
11
70
  *
@@ -23,6 +82,13 @@ const MAX_DOWNLOAD_REDIRECTS = 10;
23
82
  * runtime we cannot validate the hop, so we fail closed rather than follow it
24
83
  * blindly and bypass the SSRF guard.
25
84
  *
85
+ * A hop that is same-origin with `trustedOrigin` (the developer-configured
86
+ * endpoint) skips target validation: that origin is exactly what an
87
+ * unvalidated, config-derived request would fetch anyway, and validating it
88
+ * would break legitimate self-hosted / localhost deployments whose response
89
+ * URLs point back at the configured host. Hops on any other origin are always
90
+ * validated.
91
+ *
26
92
  * The returned response is the final (non-redirect) response. The caller is
27
93
  * responsible for checking `response.ok` and reading the body.
28
94
  *
@@ -39,11 +105,19 @@ export async function fetchWithValidatedRedirects({
39
105
  headers,
40
106
  abortSignal,
41
107
  maxRedirects = MAX_DOWNLOAD_REDIRECTS,
108
+ fetch: customFetch,
109
+ trustedOrigin,
42
110
  }: {
43
111
  url: string;
44
112
  headers?: HeadersInit;
45
113
  abortSignal?: AbortSignal;
46
114
  maxRedirects?: number;
115
+ fetch?: FetchFunction;
116
+ /**
117
+ * A developer-configured origin whose hops skip target validation. Must
118
+ * never be derived from response data.
119
+ */
120
+ trustedOrigin?: string;
47
121
  }): Promise<Response> {
48
122
  // Per-hop request options. Only the `redirect` mode varies between hops, so
49
123
  // the rest is assembled once. `headers` is omitted entirely when not provided
@@ -56,9 +130,22 @@ export async function fetchWithValidatedRedirects({
56
130
  let currentUrl = url;
57
131
  // The bound also acts as a backstop against an unterminated redirect chain.
58
132
  for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
59
- validateDownloadUrl(currentUrl);
133
+ // The developer-configured origin is trusted by definition; validating it
134
+ // would reject legitimate self-hosted / localhost deployments.
135
+ const isTrustedHop =
136
+ trustedOrigin !== undefined && isSameOrigin(currentUrl, trustedOrigin);
137
+
138
+ if (!isTrustedHop) {
139
+ validateDownloadUrl(currentUrl);
140
+ }
141
+
142
+ const fetch =
143
+ isTrustedHop && customFetch != null
144
+ ? customFetch
145
+ : isTrustedHop
146
+ ? globalThis.fetch
147
+ : await getValidatedFetch(customFetch);
60
148
 
61
- const fetch = await getDefaultDownloadFetch();
62
149
  const response = await fetch(currentUrl, {
63
150
  ...baseInit,
64
151
  redirect: 'manual',
@@ -74,7 +161,7 @@ export async function fetchWithValidatedRedirects({
74
161
  return await fetch(currentUrl, { ...baseInit, redirect: 'follow' });
75
162
  }
76
163
 
77
- const location = response.headers.get('location');
164
+ const location = response.headers?.get('location');
78
165
  if (response.status >= 300 && response.status < 400 && location) {
79
166
  // Release the redirect response's connection before moving to the next
80
167
  // hop. Whether that hop is followed or rejected by the SSRF guard, an
package/src/index.ts CHANGED
@@ -20,7 +20,10 @@ export {
20
20
  readResponseWithSizeLimit,
21
21
  DEFAULT_MAX_DOWNLOAD_SIZE,
22
22
  } from './read-response-with-size-limit';
23
- export { fetchWithValidatedRedirects } from './fetch-with-validated-redirects';
23
+ export {
24
+ fetchWithValidatedEndpoint,
25
+ fetchWithValidatedRedirects,
26
+ } from './fetch-with-validated-redirects';
24
27
  export * from './fetch-function';
25
28
  export { createIdGenerator, generateId, type IdGenerator } from './generate-id';
26
29
  export * from './get-error-message';
@@ -65,6 +68,11 @@ export {
65
68
  type ValidationResult,
66
69
  } from './schema';
67
70
  export { secureJsonParse } from './secure-json-parse';
71
+ export {
72
+ StreamingToolCallTracker,
73
+ type StreamingToolCallDelta,
74
+ type StreamingToolCallTrackerOptions,
75
+ } from './streaming-tool-call-tracker';
68
76
  export { stripFileExtension } from './strip-file-extension';
69
77
  export * from './uint8-utils';
70
78
  export { validateDownloadUrl } from './validate-download-url';
@@ -102,57 +102,52 @@ export function createSafeLookup(lookup: Lookup): SafeLookup {
102
102
  }
103
103
 
104
104
  let safeNodeFetchPromise: Promise<FetchFunction> | undefined;
105
- const initialGlobalFetch = globalThis.fetch;
106
- const initialGlobalFetchIsNodeDefault = isNodeDefaultFetch(initialGlobalFetch);
107
105
 
108
106
  export function isNodeRuntime(): boolean {
109
107
  const runtimeProcess = globalThis.process as
110
108
  | {
111
109
  release?: { name?: string };
112
- versions?: { bun?: string };
110
+ title?: string;
111
+ versions?: { bun?: string; deno?: string };
113
112
  }
114
113
  | undefined;
115
114
 
115
+ // Node-compatible process objects do not imply support for Node DNS/socket
116
+ // hooks. Workers identifies itself as workerd, including without navigator.
116
117
  return (
117
118
  runtimeProcess?.release?.name === 'node' &&
118
- runtimeProcess.versions?.bun == null
119
+ runtimeProcess.versions?.bun == null &&
120
+ runtimeProcess.versions?.deno == null &&
121
+ runtimeProcess.title !== 'workerd' &&
122
+ (globalThis as { EdgeRuntime?: unknown }).EdgeRuntime == null
119
123
  );
120
124
  }
121
125
 
122
126
  export async function getDefaultDownloadFetch(): Promise<FetchFunction> {
123
- if (
124
- !isNodeRuntime() ||
125
- !initialGlobalFetchIsNodeDefault ||
126
- globalThis.fetch !== initialGlobalFetch
127
- ) {
127
+ if (!isNodeRuntime()) {
128
128
  return globalThis.fetch;
129
129
  }
130
130
 
131
+ // Global fetch wrappers cannot be relied on to preserve the dispatcher
132
+ // that pins connections to validated DNS results.
131
133
  return (safeNodeFetchPromise ??= createSafeNodeFetch());
132
134
  }
133
135
 
134
- function isNodeDefaultFetch(fetch: unknown): boolean {
135
- if (typeof fetch !== 'function') {
136
- return false;
137
- }
138
-
139
- const source = Function.prototype.toString.call(fetch);
140
- return (
141
- source.includes('internal/deps/undici') ||
142
- source.includes('lazy loading of undici')
143
- );
144
- }
145
-
146
136
  async function createSafeNodeFetch(): Promise<FetchFunction> {
147
137
  // Load Node-only modules indirectly so browser bundlers do not pull undici
148
138
  // and Node built-ins into the browser-facing provider-utils entry point.
149
- const [{ createRequire }, { lookup }] = await Promise.all([
139
+ // @vercel/nft (node file trace) only recognizes an indirectly loaded createRequire when its receiver
140
+ // is named `module` and the returned require function is assigned.
141
+ // eslint-disable-next-line @next/next/no-assign-module-variable
142
+ const [module, { lookup }] = await Promise.all([
150
143
  loadNodeModule<NodeModule>('node:module'),
151
144
  loadNodeModule<NodeDns>('node:dns'),
152
145
  ]);
153
- const { Agent, fetch } = createRequire(getCurrentModulePath())(
154
- 'undici',
155
- ) as Undici;
146
+
147
+ // Assign the created require function so deployment tracers can recognize
148
+ // the static dependency without bundlers inlining undici.
149
+ const nodeRequire = module.createRequire(getCurrentModulePath());
150
+ const { Agent, fetch } = nodeRequire('undici') as Undici;
156
151
 
157
152
  const dispatcher = new Agent({
158
153
  connect: {
@@ -0,0 +1,102 @@
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
+ * Tracks whether streamed tool-call arguments contain a complete structured
25
+ * JSON value without treating a parsable prefix as a completed call.
26
+ */
27
+ export class StreamingToolCallArgumentState {
28
+ private structure: ArgumentStructure = { kind: 'undetermined' };
29
+
30
+ constructor(initialValue = '') {
31
+ this.append(initialValue);
32
+ }
33
+
34
+ get hasCompleteStructuredValue(): boolean {
35
+ return (
36
+ this.structure.kind === 'structured' && this.structure.complete === true
37
+ );
38
+ }
39
+
40
+ append(delta: string): void {
41
+ let nextStructure = this.structure;
42
+
43
+ for (const character of delta) {
44
+ if (nextStructure.kind === 'undetermined') {
45
+ if (/\s/.test(character)) {
46
+ continue;
47
+ }
48
+
49
+ if (character !== '{' && character !== '[') {
50
+ nextStructure = { kind: 'other' };
51
+ continue;
52
+ }
53
+
54
+ nextStructure = {
55
+ kind: 'structured',
56
+ stack: [character],
57
+ inString: false,
58
+ escaped: false,
59
+ complete: false,
60
+ };
61
+ continue;
62
+ }
63
+
64
+ if (nextStructure.kind !== 'structured' || nextStructure.complete) {
65
+ continue;
66
+ }
67
+
68
+ if (nextStructure.inString) {
69
+ if (nextStructure.escaped) {
70
+ nextStructure.escaped = false;
71
+ } else if (character === '\\') {
72
+ nextStructure.escaped = true;
73
+ } else if (character === '"') {
74
+ nextStructure.inString = false;
75
+ }
76
+ continue;
77
+ }
78
+
79
+ if (character === '"') {
80
+ nextStructure.inString = true;
81
+ } else if (character === '{' || character === '[') {
82
+ nextStructure.stack.push(character);
83
+ } else if (character === '}' || character === ']') {
84
+ const expectedOpening = character === '}' ? '{' : '[';
85
+ if (
86
+ nextStructure.stack[nextStructure.stack.length - 1] !==
87
+ expectedOpening
88
+ ) {
89
+ nextStructure = { kind: 'other' };
90
+ continue;
91
+ }
92
+
93
+ nextStructure.stack.pop();
94
+ if (nextStructure.stack.length === 0) {
95
+ nextStructure.complete = true;
96
+ }
97
+ }
98
+ }
99
+
100
+ this.structure = nextStructure;
101
+ }
102
+ }