🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@ai-sdk/provider-utils

Package Overview
Dependencies
Maintainers
3
Versions
297
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ai-sdk/provider-utils - npm Package Compare versions

Comparing version
5.0.0-beta.30
to
5.0.0-beta.49
+19
src/cancel-response-body.ts
/**
* Cancels a response body to release the underlying connection.
*
* When a fetch Response is rejected without consuming its body (e.g. a failed
* status code, an open-redirect rejection, or a Content-Length that exceeds the
* size limit), the underlying TCP socket is not returned to the connection pool
* and may stay open until the process runs out of file descriptors. Cancelling
* the body avoids this leak.
*
* Errors thrown while cancelling are ignored: the body may already be locked,
* disturbed, or absent, none of which should mask the original rejection.
*/
export async function cancelResponseBody(response: Response): Promise<void> {
try {
await response.body?.cancel();
} catch {
// Ignore cancel errors so the original rejection is preserved.
}
}
/**
* Extracts a 1-based inclusive line range from `text`, auto-detecting the
* file's line ending (`\r\n`, `\n`, or `\r`, in that priority).
*
* Mixed line endings are not supported: detection picks one and uses it for
* both the split and the rejoin, so files that mix conventions will not slice
* cleanly. When neither `startLine` nor `endLine` is provided, the input is
* returned unchanged. `endLine` past EOF clamps to the last line.
*/
export function extractLines({
text,
startLine,
endLine,
}: {
text: string;
startLine?: number;
endLine?: number;
}): string {
if (startLine == null && endLine == null) return text;
const lineEnding = text.includes('\r\n')
? '\r\n'
: text.includes('\n')
? '\n'
: text.includes('\r')
? '\r'
: '\n';
const lines = text.split(lineEnding);
const start = Math.max(1, startLine ?? 1) - 1;
const end = Math.min(lines.length, endLine ?? lines.length);
return lines.slice(start, end).join(lineEnding);
}
import { cancelResponseBody } from './cancel-response-body';
import { DownloadError } from './download-error';
import { isBrowserRuntime } from './is-browser-runtime';
import { validateDownloadUrl } from './validate-download-url';
const MAX_DOWNLOAD_REDIRECTS = 10;
/**
* Fetches a URL while enforcing the SSRF download guard on every hop.
*
* Redirects are followed manually (`redirect: 'manual'`) so each hop is
* validated with {@link validateDownloadUrl} *before* it is requested. Relying
* on the default `redirect: 'follow'` would issue the request to a redirect
* target (e.g. an internal address) before we ever see its URL, defeating the
* SSRF guard.
*
* A `redirect: 'manual'` request yields an unreadable opaque response in the
* browser (and in other spec-compliant fetch implementations), so the redirect
* target cannot be validated here. In a real browser this is safe to follow
* natively because SSRF is not reachable (fetch is constrained by CORS and
* cannot reach a server's internal network or cloud-metadata). On any other
* runtime we cannot validate the hop, so we fail closed rather than follow it
* blindly and bypass the SSRF guard.
*
* The returned response is the final (non-redirect) response. The caller is
* responsible for checking `response.ok` and reading the body.
*
* @throws DownloadError if a hop is unsafe, the redirect limit is exceeded, or
* a redirect cannot be validated on a non-browser runtime.
*/
export async function fetchWithValidatedRedirects({
url,
headers,
abortSignal,
maxRedirects = MAX_DOWNLOAD_REDIRECTS,
}: {
url: string;
headers?: HeadersInit;
abortSignal?: AbortSignal;
maxRedirects?: number;
}): Promise<Response> {
// Per-hop request options. Only the `redirect` mode varies between hops, so
// the rest is assembled once. `headers` is omitted entirely when not provided
// so callers that send none issue a bare request.
const baseInit: RequestInit = { signal: abortSignal };
if (headers !== undefined) {
baseInit.headers = headers;
}
let currentUrl = url;
// The bound also acts as a backstop against an unterminated redirect chain.
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
validateDownloadUrl(currentUrl);
const response = await fetch(currentUrl, {
...baseInit,
redirect: 'manual',
});
if (response.type === 'opaqueredirect') {
if (!isBrowserRuntime()) {
throw new DownloadError({
url,
message: `Redirect from ${currentUrl} could not be validated and was blocked`,
});
}
return await fetch(currentUrl, { ...baseInit, redirect: 'follow' });
}
const location = response.headers.get('location');
if (response.status >= 300 && response.status < 400 && location) {
// Release the redirect response's connection before moving to the next
// hop. Whether that hop is followed or rejected by the SSRF guard, an
// unconsumed 3xx body would leak the underlying socket.
await cancelResponseBody(response);
currentUrl = new URL(location, currentUrl).toString();
continue;
}
return response;
}
throw new DownloadError({
url,
message: `Too many redirects (max ${maxRedirects})`,
});
}
/**
* Returns `true` when running in a browser.
*
* Detection keys on the presence of a global `window`, matching the browser
* check used elsewhere in this package (see `getRuntimeEnvironmentUserAgent`)
* so the SDK has a single, consistent definition of "browser". Server runtimes
* (Node.js, Deno, Bun, edge/workers) do not define `window`.
*/
export function isBrowserRuntime(
globalThisAny: any = globalThis as any,
): boolean {
return globalThisAny.window != null;
}
/**
* Returns true when `url` has the same origin (scheme + host + port) as
* `baseUrl`.
*
* Used to decide whether provider credentials may be attached to a request to a
* URL taken from a provider response (e.g. a polling or media-download URL).
* Credentials must only be sent to the provider's own origin; a response that
* names a foreign host (a CDN, or an attacker-controlled host if the response
* is tampered with) must not receive the API key.
*
* Returns false if either value is not a valid absolute URL (fail-closed).
*/
export function isSameOrigin(url: string, baseUrl: string): boolean {
try {
return new URL(url).origin === new URL(baseUrl).origin;
} catch {
return false;
}
}
/**
* Options for executing a command in the sandbox via `run` or `spawn`.
*/
type SandboxProcessOptions = {
/**
* Command to execute in the sandbox.
*/
command: string;
/**
* Working directory to execute the command in.
*/
workingDirectory?: string;
/**
* Environment variables to set for this command. Merged with the
* sandbox's default environment; values here take precedence.
* Supporting environment variables as an option is preferable from a
* security perspective, e.g. to avoid them leaking in logs.
*/
env?: Record<string, string>;
/**
* Signal that can be used to abort the command. When aborted, the running
* process is killed; for `spawn`, `wait()` rejects with the abort reason.
*/
abortSignal?: AbortSignal;
};
/**
* Options for reading a file from the sandbox.
*/
type ReadFileOptions = {
/**
* Path of the file to read.
*/
path: string;
/**
* Signal that can be used to abort the read.
*/
abortSignal?: AbortSignal;
};
/**
* Options for writing a file to the sandbox. `CONTENT` is the payload written
* to the file: a byte stream, raw bytes, or a string.
*/
type WriteFileOptions<CONTENT> = {
/**
* Path of the file to write.
*/
path: string;
/**
* Content to write to the file.
*/
content: CONTENT;
/**
* Signal that can be used to abort the write.
*/
abortSignal?: AbortSignal;
};
/**
* Sandbox session that can execute commands and read/write files.
*/
export type SandboxSession = {
/**
* Description of the sandbox environment that can be added to the agent's instructions
* so that the agent knows about relevant details such as the root directory, exposed
* ports, the public hostname, etc.
*/
readonly description: string;
/**
* Read one file from the sandbox as a stream of bytes. Resolves to `null`
* when the file does not exist.
*
* Relative path handling is implementation-defined. This is the lowest-level
* read primitive; prefer `readBinaryFile` or `readTextFile` unless you need
* to stream bytes.
*/
readonly readFile: (
options: ReadFileOptions,
) => PromiseLike<ReadableStream<Uint8Array> | null>;
/**
* Read one file from the sandbox as raw bytes. Resolves to `null` when the
* file does not exist.
*/
readonly readBinaryFile: (
options: ReadFileOptions,
) => PromiseLike<Uint8Array | null>;
/**
* Read one text file from the sandbox, decoded using the requested encoding.
* Resolves to `null` when the file does not exist.
*
* Line ranges are 1-based and inclusive. When `endLine` is past EOF the read
* returns through EOF without error.
*/
readonly readTextFile: (
options: ReadFileOptions & {
/**
* Text encoding used to decode the file bytes. Defaults to `"utf-8"`.
*/
encoding?: string;
/**
* 1-based inclusive start line. Defaults to 1.
*/
startLine?: number;
/**
* 1-based inclusive end line. When past the file's line count, the read
* returns through EOF without error.
*/
endLine?: number;
},
) => PromiseLike<string | null>;
/**
* Write one file to the sandbox from a stream of bytes. Creates parent
* directories recursively and overwrites any existing file.
*
* This is the lowest-level write primitive; prefer `writeBinaryFile` or
* `writeTextFile` when the full content is already materialized in memory.
*/
readonly writeFile: (
options: WriteFileOptions<ReadableStream<Uint8Array>>,
) => PromiseLike<void>;
/**
* Write one file to the sandbox from raw bytes. Creates parent directories
* recursively and overwrites any existing file.
*/
readonly writeBinaryFile: (
options: WriteFileOptions<Uint8Array>,
) => PromiseLike<void>;
/**
* Write one file to the sandbox from a string, encoded using the requested
* encoding. Creates parent directories recursively and overwrites any
* existing file.
*/
readonly writeTextFile: (
options: WriteFileOptions<string> & {
/**
* Text encoding used to encode the string to bytes. Defaults to `"utf-8"`.
*/
encoding?: string;
},
) => PromiseLike<void>;
/**
* Spawn a long-running process in the sandbox. Returns immediately with a
* handle that streams stdout/stderr, can be waited on, and can be killed.
*
* `run` is conceptually a thin wrapper over this primitive: spawn,
* collect both streams to strings, await `wait()`, return the result.
*/
readonly spawn: (
options: SandboxProcessOptions,
) => PromiseLike<SandboxProcess>;
/**
* Run a command in the sandbox.
*/
readonly run: (options: SandboxProcessOptions) => PromiseLike<{
/**
* Exit code returned by the command.
*/
exitCode: number;
/**
* Standard output produced by the command.
*/
stdout: string;
/**
* Standard error produced by the command.
*/
stderr: string;
}>;
};
/**
* Handle to a long-running process started via `SandboxSession.spawn`.
*/
export type SandboxProcess = {
/**
* Process identifier, if the sandbox implementation exposes one.
*/
readonly pid?: number;
/**
* Stream of bytes written by the process to standard output.
*/
readonly stdout: ReadableStream<Uint8Array>;
/**
* Stream of bytes written by the process to standard error.
*/
readonly stderr: ReadableStream<Uint8Array>;
/**
* Resolve when the process exits, yielding its exit code.
*/
wait(): PromiseLike<{ exitCode: number }>;
/**
* Terminate the process. Idempotent.
*/
kill(): PromiseLike<void>;
};
+153
-0
# @ai-sdk/provider-utils
## 5.0.0-beta.49
### Patch Changes
- b8396f0: trigger initial beta release
- Updated dependencies [b8396f0]
- @ai-sdk/provider@4.0.0-beta.19
## 5.0.0-canary.48
### Patch Changes
- aeda373: fix: only send provider credentials to same-origin response-supplied URLs
Several provider clients followed a URL taken from the provider's API response (a polling/status URL or a final media URL such as `polling_url`, `urls.get`, `result_url`, `result.sample`, or `video.uri`) and reused the authenticated headers — or appended `?key=<API_KEY>` — on that request. Because the host of the response-supplied URL was never validated, the long-lived API key was sent to whatever host the response named (a CDN in the benign case, or an attacker-chosen host if the provider response was tampered with), allowing credential exfiltration.
A new `isSameOrigin` helper is added to `@ai-sdk/provider-utils`, and the affected fetches in `@ai-sdk/black-forest-labs`, `@ai-sdk/fireworks`, `@ai-sdk/replicate`, `@ai-sdk/gladia`, `@ai-sdk/fal`, and `@ai-sdk/google` now attach credentials only when the followed URL is same-origin with the provider's configured API origin. Requests to a foreign origin are made without the credential.
- 375fdd7: fix: harden download URL SSRF guard against hostname and redirect bypasses
`validateDownloadUrl` and the file download helpers (`downloadBlob`, `download`) could be bypassed in several ways when handling untrusted URLs:
- A fully-qualified hostname with a trailing dot (e.g. `localhost.`, `myhost.local.`) skipped the localhost/`.local` blocklist.
- IPv6 addresses that embed an IPv4 address in their last 32 bits — IPv4-compatible (`::127.0.0.1`), IPv4-translated (`::ffff:0:127.0.0.1`), and NAT64 (`64:ff9b::127.0.0.1`, including the `64:ff9b:1::/48` local-use prefix) — were not decoded and checked against the private IPv4 ranges.
- Redirects were validated only _after_ `fetch` had already followed them, so the request to a redirect target (e.g. an internal/metadata address) had already been issued before the check ran.
- Several reserved/internal address ranges were not blocked: CGNAT (`100.64.0.0/10`, used by some cloud providers for internal traffic), benchmarking (`198.18.0.0/15`), IETF protocol assignments (`192.0.0.0/24`), the reserved `240.0.0.0/4` block (including the `255.255.255.255` broadcast address), and IPv6 site-local (`fec0::/10`) and multicast (`ff00::/8`).
The validator now strips trailing dots before the hostname checks and fully expands IPv6 addresses to detect embedded private IPv4 targets. The download helpers now follow redirects manually (`redirect: 'manual'`), re-validating each hop before requesting it, so an unsafe redirect target is never fetched. When a redirect cannot be inspected because the runtime returns an opaque response, the helpers fail closed (reject the redirect) on the server; only in a real browser — where SSRF is not reachable (fetch is constrained by CORS and cannot reach a server's internal network or cloud-metadata endpoints) — is the redirect followed natively so legitimate redirected downloads keep working.
- b4507d5: fix(provider-utils): cancel response body on download rejection to prevent socket leak
When a download was rejected early — because the `Content-Length` header exceeded the size limit, the response status was not ok, or a redirect resolved to a blocked URL — the fetch response body was left unconsumed and uncancelled. With WHATWG Fetch/undici this leaves the underlying TCP socket open instead of returning it to the connection pool, allowing an attacker-controlled origin to exhaust file descriptors and cause a denial of service. The body is now cancelled on all early-rejection paths in `readResponseWithSizeLimit`, `download`, and `downloadBlob`, and `fetchWithValidatedRedirects` cancels each redirect hop's body before following or rejecting the next hop.
## 5.0.0-canary.47
### Patch Changes
- bae5e2b: fix(security): re-validate tool approvals from client message history before execution
The approval-replay path in `generateText`/`streamText` (and `WorkflowAgent.stream`) reconstructed approved tool calls from the client-supplied messages array and executed them without re-validating input against the tool's schema or re-applying the approval policy. A client could forge an assistant message with a pre-approved tool-call part and have the server execute a tool with attacker-chosen arguments.
The replay path now validates HMAC signature (when `experimental_toolApprovalSecret` is configured), re-validates tool-call input against the tool's input schema, and re-resolves the approval policy before execution.
## 5.0.0-canary.46
### Patch Changes
- Updated dependencies [ce769dd]
- @ai-sdk/provider@4.0.0-canary.18
## 5.0.0-canary.45
### Patch Changes
- ee798eb: chore(provider-utils): rename `Experimental_Sandbox` to `Experimental_SandboxSession`
- daf6637: feat(provider-utils): add `env` option to `spawn` and `run` methods of `Experimental_SandboxSession`
## 5.0.0-canary.44
### Patch Changes
- 6c93e36: feat(provider-utils): add `spawnCommand` method to `Experimental_Sandbox` to allow for detached command execution
- f617ac2: feat(provider-utils): narrow `tool()` return type to `ExecutableTool<...>` when `execute` is provided
## 5.0.0-canary.43
### Patch Changes
- 7fc6bd6: Raise minimum supported Node.js version to 22. Supported versions: 22, 24, and 26.
- Updated dependencies [7fc6bd6]
- @ai-sdk/provider@4.0.0-canary.17
## 5.0.0-canary.42
### Patch Changes
- a6617c5: feat(provider-utils): add `readFile` and `writeFile` plus convenience wrappers to `Experimental_Sandbox` abstraction
## 5.0.0-canary.41
### Patch Changes
- 28dfa06: fix: support tools with optional context
- e93fa91: rename Sandbox.executeCommand to Sandbox.runCommand
## 5.0.0-canary.40
### Patch Changes
- a7de9c9: fix: make sandbox experimental
## 5.0.0-canary.39
### Patch Changes
- 105f95b: Ensure the default empty tool input schema includes `type: "object"` for OpenAI-compatible providers that require object schemas.
## 5.0.0-canary.38
### Patch Changes
- ca446f8: feat: flexible tool descriptions
## 5.0.0-canary.37
### Patch Changes
- d848405: feat: add optional `abortSignal` parameters to sandbox command execution
## 5.0.0-canary.36
### Patch Changes
- ca39020: Add an optional `workingDirectory` parameter to sandbox command execution.
## 5.0.0-canary.35
### Patch Changes
- f634bac: feat(mcp): add new McpProviderMetadata type
## 5.0.0-canary.34
### Patch Changes
- 69254e0: feat(ai): add toolMetadata for tool specific metdata
- 3015fc3: feat: sandbox shell execution abstraction
## 5.0.0-canary.33
### Patch Changes
- 2427d88: feat(ai): change Tool.sensitiveContext to telemetry.includeToolsContext and make it opt-in
## 5.0.0-canary.32
### Major Changes
- 5463d0d: feat(provider): align tool result output content file part types with top-level message file part types
### Patch Changes
- Updated dependencies [5463d0d]
- @ai-sdk/provider@4.0.0-canary.16
## 5.0.0-canary.31
### Patch Changes
- 0c4c275: trigger initial canary release
- Updated dependencies [0c4c275]
- @ai-sdk/provider@4.0.0-canary.15
## 5.0.0-beta.30

@@ -4,0 +157,0 @@

+5
-5
{
"name": "@ai-sdk/provider-utils",
"version": "5.0.0-beta.30",
"version": "5.0.0-beta.49",
"type": "module",

@@ -38,8 +38,8 @@ "license": "Apache-2.0",

"eventsource-parser": "^3.0.8",
"@ai-sdk/provider": "4.0.0-beta.14"
"@ai-sdk/provider": "4.0.0-beta.19"
},
"devDependencies": {
"@types/node": "20.17.24",
"@types/node": "22.19.19",
"msw": "2.7.0",
"tsup": "^8",
"tsup": "^8.5.1",
"typescript": "5.8.3",

@@ -53,3 +53,3 @@ "zod": "3.25.76",

"engines": {
"node": ">=18"
"node": ">=22"
},

@@ -56,0 +56,0 @@ "publishConfig": {

@@ -0,2 +1,4 @@

import { cancelResponseBody } from './cancel-response-body';
import { DownloadError } from './download-error';
import { fetchWithValidatedRedirects } from './fetch-with-validated-redirects';
import {

@@ -6,3 +8,2 @@ readResponseWithSizeLimit,

} from './read-response-with-size-limit';
import { validateDownloadUrl } from './validate-download-url';

@@ -24,14 +25,12 @@ /**

): Promise<Blob> {
validateDownloadUrl(url);
try {
const response = await fetch(url, {
signal: options?.abortSignal,
const response = await fetchWithValidatedRedirects({
url,
abortSignal: options?.abortSignal,
});
// Validate final URL after redirects to prevent SSRF via open redirect
if (response.redirected) {
validateDownloadUrl(response.url);
}
if (!response.ok) {
// Release the connection before rejecting so an error status from an
// attacker-controlled origin cannot leak open sockets.
await cancelResponseBody(response);
throw new DownloadError({

@@ -38,0 +37,0 @@ url,

@@ -21,2 +21,4 @@ export { asArray } from './as-array';

export { DownloadError } from './download-error';
export { fetchWithValidatedRedirects } from './fetch-with-validated-redirects';
export { extractLines } from './extract-lines';
export * from './extract-response-headers';

@@ -32,3 +34,5 @@ export * from './fetch-function';

export * from './is-abort-error';
export { isBrowserRuntime } from './is-browser-runtime';
export { isBuffer } from './is-buffer';
export { isSameOrigin } from './is-same-origin';
export { isNonNullable } from './is-non-nullable';

@@ -62,2 +66,3 @@ export { isProviderReference } from './is-provider-reference';

} from './provider-executed-tool-factory';
export { cancelResponseBody } from './cancel-response-body';
export {

@@ -64,0 +69,0 @@ DEFAULT_MAX_DOWNLOAD_SIZE,

@@ -0,1 +1,2 @@

import { cancelResponseBody } from './cancel-response-body';
import { DownloadError } from './download-error';

@@ -43,2 +44,5 @@

if (!isNaN(length) && length > maxBytes) {
// Cancel the body so the underlying connection is released back to the
// pool instead of being left open until the socket is exhausted.
await cancelResponseBody(response);
throw new DownloadError({

@@ -45,0 +49,0 @@ url,

@@ -139,3 +139,7 @@ import { TypeValidationError, type JSONSchema7 } from '@ai-sdk/provider';

return schema == null
? jsonSchema({ properties: {}, additionalProperties: false })
? jsonSchema({
type: 'object',
properties: {},
additionalProperties: false,
})
: isSchema(schema)

@@ -142,0 +146,0 @@ ? schema

@@ -17,14 +17,16 @@ import type { ZodPipelineDef } from 'zod/v3';

const a = parseDef(def.in._def, {
const inputSchema = parseDef(def.in._def, {
...refs,
currentPath: [...refs.currentPath, 'allOf', '0'],
});
const b = parseDef(def.out._def, {
const outputSchema = parseDef(def.out._def, {
...refs,
currentPath: [...refs.currentPath, 'allOf', a ? '1' : '0'],
currentPath: [...refs.currentPath, 'allOf', inputSchema ? '1' : '0'],
});
return {
allOf: [a, b].filter((x): x is JsonSchema7Type => x !== undefined),
allOf: [inputSchema, outputSchema].filter(
(schema): schema is JsonSchema7Type => schema !== undefined,
),
};
};

@@ -320,2 +320,46 @@ import type { JSONValue } from '@ai-sdk/provider';

| {
type: 'file';
/**
* File data as a tagged discriminated union:
*
* - `{ type: 'data', data }`: raw bytes
* (base64 string, Uint8Array, ArrayBuffer, Buffer)
* - `{ type: 'url', url }`: a URL that points to the file
* - `{ type: 'reference', reference }`: a provider reference
* from `uploadFile`
* - `{ type: 'text', text }`: inline text content (e.g. an inline
* text document)
*/
data: FileData;
/**
* Either a full IANA media type (`type/subtype`, e.g. `image/png`) or just
* the top-level IANA segment (e.g. `image`, `audio`, `video`, `text`).
*
* `*`-subtype wildcards (e.g. `image/*`) are normalized as equivalent to the
* top-level segment alone (e.g. `image`). Providers can use the helpers in
* `@ai-sdk/provider-utils` (`isFullMediaType`, `getTopLevelMediaType`,
* `detectMediaType`) to resolve the field according to their API
* requirements.
*
* @see https://www.iana.org/assignments/media-types/media-types.xhtml
*/
mediaType: string;
/**
* Optional filename of the file.
*/
filename?: string;
/**
* Provider-specific options.
*/
providerOptions?: ProviderOptions;
}
| {
/**
* @deprecated Use 'file' with mediaType + tagged data instead:
* `{ type: 'file', mediaType, data: { type: 'data', data } }`.
*/
type: 'file-data';

@@ -345,2 +389,6 @@

| {
/**
* @deprecated Use 'file' with mediaType and tagged data instead:
* `{ type: 'file', mediaType, data: { type: 'url', url: new URL(url) } }`.
*/
type: 'file-url';

@@ -357,3 +405,3 @@

*/
mediaType?: string; // Temporarily optional. TODO: make required in v8, after migration period.
mediaType?: string;

@@ -367,3 +415,4 @@ /**

/**
* @deprecated Use file-reference instead.
* @deprecated Use 'file' with tagged data instead:
* `{ type: 'file', mediaType, data: { type: 'reference', reference } }`.
*/

@@ -388,2 +437,6 @@ type: 'file-id';

| {
/**
* @deprecated Use 'file' with tagged data instead:
* `{ type: 'file', mediaType, data: { type: 'reference', reference } }`.
*/
type: 'file-reference';

@@ -404,3 +457,5 @@

/**
* @deprecated Use file-data instead.
* @deprecated Use 'file' with mediaType (e.g. 'image' or a specific
* `image/*` subtype) and tagged data instead:
* `{ type: 'file', mediaType: 'image', data: { type: 'data', data } }`.
*/

@@ -427,3 +482,5 @@ type: 'image-data';

/**
* @deprecated Use file-url instead.
* @deprecated Use 'file' with `mediaType: 'image'` (or a specific
* `image/*` subtype) and tagged data instead:
* `{ type: 'file', mediaType: 'image', data: { type: 'url', url: new URL(url) } }`.
*/

@@ -444,3 +501,5 @@ type: 'image-url';

/**
* @deprecated Use file-reference instead.
* @deprecated Use 'file' with `mediaType: 'image'` (or a specific
* `image/*` subtype) and tagged data instead:
* `{ type: 'file', mediaType: 'image', data: { type: 'reference', reference } }`.
*/

@@ -466,3 +525,5 @@ type: 'image-file-id';

/**
* @deprecated Use file-reference instead.
* @deprecated Use 'file' with `mediaType: 'image'` (or a specific
* `image/*` subtype) and tagged data instead:
* `{ type: 'file', mediaType: 'image', data: { type: 'reference', reference } }`.
*/

@@ -469,0 +530,0 @@ type: 'image-file-reference';

@@ -18,2 +18,3 @@ export type {

export type { DataContent } from './data-content';
export { isExecutableTool, type ExecutableTool } from './executable-tool';
export { executeTool } from './execute-tool';

@@ -27,3 +28,2 @@ export type {

} from './file-data';
export { isExecutableTool, type ExecutableTool } from './executable-tool';
export type { InferToolContext } from './infer-tool-context';

@@ -36,3 +36,6 @@ export type { InferToolInput } from './infer-tool-input';

export type { ProviderReference } from './provider-reference';
export type { SensitiveContext } from './sensitive-context';
export type {
SandboxSession as Experimental_SandboxSession,
SandboxProcess as Experimental_SandboxProcess,
} from './sandbox';
export type { SystemModelMessage } from './system-model-message';

@@ -48,2 +51,5 @@ export {

} from './tool';
export type { ToolApprovalRequest } from './tool-approval-request';
export type { ToolApprovalResponse } from './tool-approval-response';
export type { ToolCall } from './tool-call';
export type {

@@ -53,9 +59,6 @@ ToolExecuteFunction,

} from './tool-execute-function';
export type { ToolContent, ToolModelMessage } from './tool-model-message';
export type { ToolNeedsApprovalFunction } from './tool-needs-approval-function';
export type { ToolResult } from './tool-result';
export type { ToolSet } from './tool-set';
export type { ToolApprovalRequest } from './tool-approval-request';
export type { ToolApprovalResponse } from './tool-approval-response';
export type { ToolCall } from './tool-call';
export type { ToolContent, ToolModelMessage } from './tool-model-message';
export type { ToolResult } from './tool-result';
export type { UserContent, UserModelMessage } from './user-model-message';

@@ -1,5 +0,34 @@

import type { HasRequiredKey } from '../has-required-key';
import type { Context } from './context';
import type { Tool } from './tool';
/**
* Detects the `any` type so untyped tools can be treated as having no explicit
* context type.
*/
type IsAny<T> = 0 extends 1 & T ? true : false;
/**
* Detects exact empty object contexts, including `{}` combined with
* `undefined`, which do not provide tool-specific context properties.
*/
type IsEmptyObject<T> = keyof NonNullable<T> extends never ? true : false;
/**
* Detects context types that come from omitted or broad context declarations
* rather than a concrete tool context schema.
*/
type IsUntypedContext<CONTEXT> =
IsAny<CONTEXT> extends true
? true
: unknown extends CONTEXT
? true
: IsEmptyObject<CONTEXT> extends true
? true
: string extends keyof CONTEXT
? CONTEXT extends Context
? true
: false
: false;
/**
* Infer the context type of a tool.

@@ -9,5 +38,5 @@ */

TOOL extends Tool<any, any, infer CONTEXT>
? HasRequiredKey<CONTEXT> extends true
? CONTEXT
: never
? IsUntypedContext<CONTEXT> extends true
? never
: CONTEXT
: never;

@@ -5,12 +5,41 @@ import type { InferToolContext } from './infer-tool-context';

/**
* Builds the required portion of the tool context map for tools whose context
* type does not include `undefined`.
*/
type RequiredToolSetContext<TOOLS extends ToolSet> = {
[K in keyof TOOLS as InferToolContext<NoInfer<TOOLS[K]>> extends never
? never
: undefined extends InferToolContext<NoInfer<TOOLS[K]>>
? never
: K]: InferToolContext<NoInfer<TOOLS[K]>>;
};
/**
* Builds the optional portion of the tool context map for tools whose context
* object itself may be `undefined`.
*/
type OptionalToolSetContext<TOOLS extends ToolSet> = {
[K in keyof TOOLS as InferToolContext<NoInfer<TOOLS[K]>> extends never
? never
: undefined extends InferToolContext<NoInfer<TOOLS[K]>>
? K
: never]?: InferToolContext<NoInfer<TOOLS[K]>>;
};
/**
* Flattens intersected mapped types so type equality assertions and editor
* hovers show the resulting object shape.
*/
type Normalize<OBJECT> = { [KEY in keyof OBJECT]: OBJECT[KEY] };
/**
* Infer the context type for a tool set.
*
* The inferred type maps each tool name to its required context type.
* The inferred type maps each contextual tool name to its context type.
*
* Tools without required context properties are omitted from the result.
* Tools without concrete context are omitted. Tool contexts that include
* `undefined` are represented as optional properties.
*/
export type InferToolSetContext<TOOLS extends ToolSet> = {
[K in keyof TOOLS as InferToolContext<NoInfer<TOOLS[K]>> extends never
? never
: K]: InferToolContext<NoInfer<TOOLS[K]>>;
};
export type InferToolSetContext<TOOLS extends ToolSet> = Normalize<
RequiredToolSetContext<TOOLS> & OptionalToolSetContext<TOOLS>
>;

@@ -23,2 +23,8 @@ /**

isAutomatic?: boolean;
/**
* HMAC-SHA256 signature binding this approval to its tool call.
* Present only when `experimental_toolApprovalSecret` is configured.
*/
signature?: string;
};
import type { Context } from './context';
import type { ModelMessage } from './model-message';
import type { SandboxSession } from './sandbox';

@@ -38,2 +39,7 @@ /**

context: CONTEXT;
/**
* The sandbox environment that the tool is operating in.
*/
experimental_sandbox?: SandboxSession;
}

@@ -40,0 +46,0 @@

@@ -1,8 +0,8 @@

import type { JSONValue, SharedV4ProviderMetadata } from '@ai-sdk/provider';
import type { JSONValue, JSONObject } from '@ai-sdk/provider';
import type { FlexibleSchema } from '../schema';
import type { ToolResultOutput } from './content-part';
import type { Context } from './context';
import type { ExecutableTool } from './executable-tool';
import type { NeverOptional } from './never-optional';
import type { ProviderOptions } from './provider-options';
import type { SensitiveContext } from './sensitive-context';
import type {

@@ -13,2 +13,3 @@ ToolExecuteFunction,

import type { ToolNeedsApprovalFunction } from './tool-needs-approval-function';
import type { SandboxSession } from './sandbox';

@@ -63,2 +64,4 @@ /**

* An optional title of the tool.
*
* @deprecated Use `providerMetadata` for source-specific tool display metadata.
*/

@@ -79,7 +82,7 @@ title?: string;

* model. Instead, it is propagated onto the resulting tool call's
* `providerMetadata` so consumers can read it from tool call / result
* parts and UI message parts. This is useful for sources of dynamic
* tools (e.g. an MCP server) to identify themselves.
* `toolMetadata` so consumers can read it from tool call / result parts
* and UI message parts. This is useful for sources of dynamic tools (e.g.
* an MCP server) to identify themselves.
*/
providerMetadata?: SharedV4ProviderMetadata;
metadata?: JSONObject;

@@ -103,8 +106,2 @@ /**

/**
* Marks top-level context properties that contain sensitive data and should be excluded from telemetry.
* Properties marked as `true` are omitted from telemetry integrations.
*/
sensitiveContext?: SensitiveContext<CONTEXT>;
/**
* Whether the tool needs approval before it can be executed.

@@ -187,6 +184,17 @@ *

/**
* An optional description of what the tool does.
* Will be used by the language model to decide whether to use the tool.
* Optional description of what the tool does.
*
* Included in the tool definition sent to the language model so it can
* decide when and how to call the tool.
*
* Provide a string for a fixed description, or a function that returns a
* string from the current `context` (and optional `experimental_sandbox`) when the
* description should vary per call.
*/
description?: string;
description?:
| string
| ((options: {
context: NoInfer<CONTEXT>;
experimental_sandbox?: SandboxSession;
}) => string);

@@ -216,3 +224,3 @@ /**

/**
* Tool with user-defined input and output schemas.
* Tool with user-defined input and output schemas that is executed by the AI SDK.
*/

@@ -228,4 +236,6 @@ export type FunctionTool<

/**
* Tool that is defined at runtime (e.g. an MCP tool).
* Tool that is defined at runtime.
* The types of input and output are not known at development time.
*
* For example, MCP tools that are not known at development time.
*/

@@ -269,2 +279,4 @@ export type DynamicTool<

* user.
*
* For example, shell tools that are executed in a local shell, but have provider-defined input and output schemas.
*/

@@ -288,2 +300,4 @@ export type ProviderDefinedTool<

* provider.
*
* For example, web search tools and code execution tools that are executed by the provider itself.
*/

@@ -337,5 +351,17 @@ export type ProviderExecutedTool<

* This is useful for type inference when working with tool objects.
*
* When the input has an `execute` function, the return type narrows to
* `ExecutableTool<Tool<...>>` so that `.execute` is non-nullable without
* needing `isExecutableTool` or a `!` assertion at the call site.
*/
// Note: overload order is important for auto-completion
// Note: overload order is important for auto-completion.
// The "with execute" overload comes first so calls that include an
// `execute` function get the narrowed return type. Calls without
// `execute` fall through to the overloads below.
export function tool<INPUT, OUTPUT, CONTEXT extends Context>(
tool: Tool<INPUT, OUTPUT, CONTEXT> & {
execute: ToolExecuteFunction<INPUT, OUTPUT, CONTEXT>;
},
): ExecutableTool<Tool<INPUT, OUTPUT, CONTEXT>>;
export function tool<INPUT, OUTPUT, CONTEXT extends Context>(
tool: Tool<INPUT, OUTPUT, CONTEXT>,

@@ -342,0 +368,0 @@ ): Tool<INPUT, OUTPUT, CONTEXT>;

@@ -7,2 +7,6 @@ import { DownloadError } from './download-error';

*
* Note: this performs string/literal-IP checks only. It does not resolve DNS, so a
* hostname that resolves to a private address is not blocked here (see callers, which
* should additionally constrain egress at the network layer when handling untrusted URLs).
*
* @param url - The URL string to validate.

@@ -35,3 +39,5 @@ * @throws DownloadError if the URL is unsafe.

const hostname = parsed.hostname;
// Strip a trailing dot so a fully-qualified name like `localhost.` (which resolves
// identically to `localhost`) cannot bypass the hostname blocklist below.
const hostname = parsed.hostname.toLowerCase().replace(/\.+$/, '');

@@ -95,3 +101,3 @@ // Block empty hostname

const parts = ip.split('.').map(Number);
const [a, b] = parts;
const [a, b, c] = parts;

@@ -102,2 +108,4 @@ // 0.0.0.0/8

if (a === 10) return true;
// 100.64.0.0/10 (CGNAT, used by some cloud providers for internal traffic)
if (a === 100 && b >= 64 && b <= 127) return true;
// 127.0.0.0/8

@@ -109,4 +117,10 @@ if (a === 127) return true;

if (a === 172 && b >= 16 && b <= 31) return true;
// 192.0.0.0/24 (IETF protocol assignments)
if (a === 192 && b === 0 && c === 0) return true;
// 192.168.0.0/16
if (a === 192 && b === 168) return true;
// 198.18.0.0/15 (benchmarking)
if (a === 198 && (b === 18 || b === 19)) return true;
// 240.0.0.0/4 (reserved, includes 255.255.255.255 broadcast)
if (a >= 240) return true;

@@ -116,39 +130,107 @@ return false;

function isPrivateIPv6(ip: string): boolean {
const normalized = ip.toLowerCase();
/**
* Expands an IPv6 address string into its 8 16-bit groups, handling `::`
* compression and an optional dotted-decimal IPv4 tail (e.g. `::ffff:127.0.0.1`).
*
* @returns the 8 groups, or null if the input is not a parseable IPv6 address.
*/
function parseIPv6(ip: string): number[] | null {
// Strip an optional zone id (e.g. `fe80::1%eth0`).
let address = ip.toLowerCase();
const zoneIndex = address.indexOf('%');
if (zoneIndex !== -1) {
address = address.slice(0, zoneIndex);
}
// ::1 (loopback)
if (normalized === '::1') return true;
// :: (unspecified)
if (normalized === '::') return true;
// At most one `::` compression marker is allowed.
const halves = address.split('::');
if (halves.length > 2) return null;
// Check for IPv4-mapped addresses (::ffff:x.x.x.x or ::ffff:HHHH:HHHH)
if (normalized.startsWith('::ffff:')) {
const mappedPart = normalized.slice(7);
// Dotted-decimal form: ::ffff:127.0.0.1
if (isIPv4(mappedPart)) {
return isPrivateIPv4(mappedPart);
}
// Hex form: ::ffff:7f00:1 (URL parser normalizes to this)
const hexParts = mappedPart.split(':');
if (hexParts.length === 2) {
const high = parseInt(hexParts[0], 16);
const low = parseInt(hexParts[1], 16);
if (!isNaN(high) && !isNaN(low)) {
const a = (high >> 8) & 0xff;
const b = high & 0xff;
const c = (low >> 8) & 0xff;
const d = low & 0xff;
return isPrivateIPv4(`${a}.${b}.${c}.${d}`);
const toGroups = (segment: string): number[] | null => {
if (segment === '') return [];
const groups: number[] = [];
const parts = segment.split(':');
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
// A dotted-decimal IPv4 tail is only valid as the final part.
if (part.includes('.')) {
if (i !== parts.length - 1 || !isIPv4(part)) return null;
const [a, b, c, d] = part.split('.').map(Number);
groups.push((a << 8) | b, (c << 8) | d);
continue;
}
if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
groups.push(parseInt(part, 16));
}
return groups;
};
const head = toGroups(halves[0]);
if (head === null) return null;
if (halves.length === 2) {
const tail = toGroups(halves[1]);
if (tail === null) return null;
const fill = 8 - head.length - tail.length;
if (fill < 0) return null;
return [...head, ...new Array<number>(fill).fill(0), ...tail];
}
// fc00::/7 (unique local addresses - fc00:: and fd00::)
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
// No `::` compression: the address must contain exactly 8 groups.
return head.length === 8 ? head : null;
}
function isPrivateIPv6(ip: string): boolean {
const groups = parseIPv6(ip);
// Fail closed: if the address cannot be parsed, treat it as unsafe.
if (groups === null) return true;
const topZero = (count: number) =>
groups.slice(0, count).every(group => group === 0);
// ::1 (loopback) and :: (unspecified)
if (topZero(7) && (groups[7] === 0 || groups[7] === 1)) return true;
// fc00::/7 (unique local addresses)
if ((groups[0] & 0xfe00) === 0xfc00) return true;
// fe80::/10 (link-local)
if (normalized.startsWith('fe80')) return true;
if ((groups[0] & 0xffc0) === 0xfe80) return true;
// fec0::/10 (site-local, deprecated but still routable internally)
if ((groups[0] & 0xffc0) === 0xfec0) return true;
// ff00::/8 (multicast)
if ((groups[0] & 0xff00) === 0xff00) return true;
// Addresses that embed an IPv4 address in their last 32 bits. For these we
// extract the embedded IPv4 and reuse the IPv4 private-range checks, so that
// e.g. ::ffff:127.0.0.1 or 64:ff9b::169.254.169.254 are blocked.
const embedsIPv4 =
// ::/96 — IPv4-compatible (deprecated)
topZero(6) ||
// ::ffff:0:0/96 — IPv4-mapped (ffff in group 5)
(topZero(5) && groups[5] === 0xffff) ||
// ::ffff:0:0/96 — IPv4-translated form (ffff in group 4, group 5 zero)
(topZero(4) && groups[4] === 0xffff && groups[5] === 0) ||
// 64:ff9b::/96 — NAT64 well-known prefix
(groups[0] === 0x0064 &&
groups[1] === 0xff9b &&
groups[2] === 0 &&
groups[3] === 0 &&
groups[4] === 0 &&
groups[5] === 0) ||
// 64:ff9b:1::/48 — NAT64 local-use prefix
(groups[0] === 0x0064 && groups[1] === 0xff9b && groups[2] === 0x0001);
if (embedsIPv4) {
const a = (groups[6] >> 8) & 0xff;
const b = groups[6] & 0xff;
const c = (groups[7] >> 8) & 0xff;
const d = groups[7] & 0xff;
return isPrivateIPv4(`${a}.${b}.${c}.${d}`);
}
return false;
}
import type { Context } from './context';
/**
* Top-level context properties that contain sensitive data and should be
* excluded from telemetry.
*/
export type SensitiveContext<CONTEXT extends Context | unknown | never> =
| { [KEY in keyof CONTEXT]?: boolean }
| undefined;

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display