@ai-sdk/provider-utils 5.0.0-beta.30 → 5.0.0-beta.49

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.0-beta.30",
3
+ "version": "5.0.0-beta.49",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -35,12 +35,12 @@
35
35
  "@standard-schema/spec": "^1.1.0",
36
36
  "@workflow/serde": "4.1.0",
37
37
  "eventsource-parser": "^3.0.8",
38
- "@ai-sdk/provider": "4.0.0-beta.14"
38
+ "@ai-sdk/provider": "4.0.0-beta.19"
39
39
  },
40
40
  "devDependencies": {
41
- "@types/node": "20.17.24",
41
+ "@types/node": "22.19.19",
42
42
  "msw": "2.7.0",
43
- "tsup": "^8",
43
+ "tsup": "^8.5.1",
44
44
  "typescript": "5.8.3",
45
45
  "zod": "3.25.76",
46
46
  "@vercel/ai-tsconfig": "0.0.0"
@@ -49,7 +49,7 @@
49
49
  "zod": "^3.25.76 || ^4.1.8"
50
50
  },
51
51
  "engines": {
52
- "node": ">=18"
52
+ "node": ">=22"
53
53
  },
54
54
  "publishConfig": {
55
55
  "access": "public",
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Cancels a response body to release the underlying connection.
3
+ *
4
+ * When a fetch Response is rejected without consuming its body (e.g. a failed
5
+ * status code, an open-redirect rejection, or a Content-Length that exceeds the
6
+ * size limit), the underlying TCP socket is not returned to the connection pool
7
+ * and may stay open until the process runs out of file descriptors. Cancelling
8
+ * the body avoids this leak.
9
+ *
10
+ * Errors thrown while cancelling are ignored: the body may already be locked,
11
+ * disturbed, or absent, none of which should mask the original rejection.
12
+ */
13
+ export async function cancelResponseBody(response: Response): Promise<void> {
14
+ try {
15
+ await response.body?.cancel();
16
+ } catch {
17
+ // Ignore cancel errors so the original rejection is preserved.
18
+ }
19
+ }
@@ -1,9 +1,10 @@
1
+ import { cancelResponseBody } from './cancel-response-body';
1
2
  import { DownloadError } from './download-error';
3
+ import { fetchWithValidatedRedirects } from './fetch-with-validated-redirects';
2
4
  import {
3
5
  readResponseWithSizeLimit,
4
6
  DEFAULT_MAX_DOWNLOAD_SIZE,
5
7
  } from './read-response-with-size-limit';
6
- import { validateDownloadUrl } from './validate-download-url';
7
8
 
8
9
  /**
9
10
  * Download a file from a URL and return it as a Blob.
@@ -20,18 +21,16 @@ export async function downloadBlob(
20
21
  url: string,
21
22
  options?: { maxBytes?: number; abortSignal?: AbortSignal },
22
23
  ): Promise<Blob> {
23
- validateDownloadUrl(url);
24
24
  try {
25
- const response = await fetch(url, {
26
- signal: options?.abortSignal,
25
+ const response = await fetchWithValidatedRedirects({
26
+ url,
27
+ abortSignal: options?.abortSignal,
27
28
  });
28
29
 
29
- // Validate final URL after redirects to prevent SSRF via open redirect
30
- if (response.redirected) {
31
- validateDownloadUrl(response.url);
32
- }
33
-
34
30
  if (!response.ok) {
31
+ // Release the connection before rejecting so an error status from an
32
+ // attacker-controlled origin cannot leak open sockets.
33
+ await cancelResponseBody(response);
35
34
  throw new DownloadError({
36
35
  url,
37
36
  statusCode: response.status,
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Extracts a 1-based inclusive line range from `text`, auto-detecting the
3
+ * file's line ending (`\r\n`, `\n`, or `\r`, in that priority).
4
+ *
5
+ * Mixed line endings are not supported: detection picks one and uses it for
6
+ * both the split and the rejoin, so files that mix conventions will not slice
7
+ * cleanly. When neither `startLine` nor `endLine` is provided, the input is
8
+ * returned unchanged. `endLine` past EOF clamps to the last line.
9
+ */
10
+ export function extractLines({
11
+ text,
12
+ startLine,
13
+ endLine,
14
+ }: {
15
+ text: string;
16
+ startLine?: number;
17
+ endLine?: number;
18
+ }): string {
19
+ if (startLine == null && endLine == null) return text;
20
+ const lineEnding = text.includes('\r\n')
21
+ ? '\r\n'
22
+ : text.includes('\n')
23
+ ? '\n'
24
+ : text.includes('\r')
25
+ ? '\r'
26
+ : '\n';
27
+ const lines = text.split(lineEnding);
28
+ const start = Math.max(1, startLine ?? 1) - 1;
29
+ const end = Math.min(lines.length, endLine ?? lines.length);
30
+ return lines.slice(start, end).join(lineEnding);
31
+ }
@@ -0,0 +1,87 @@
1
+ import { cancelResponseBody } from './cancel-response-body';
2
+ import { DownloadError } from './download-error';
3
+ import { isBrowserRuntime } from './is-browser-runtime';
4
+ import { validateDownloadUrl } from './validate-download-url';
5
+
6
+ const MAX_DOWNLOAD_REDIRECTS = 10;
7
+
8
+ /**
9
+ * Fetches a URL while enforcing the SSRF download guard on every hop.
10
+ *
11
+ * Redirects are followed manually (`redirect: 'manual'`) so each hop is
12
+ * validated with {@link validateDownloadUrl} *before* it is requested. Relying
13
+ * on the default `redirect: 'follow'` would issue the request to a redirect
14
+ * target (e.g. an internal address) before we ever see its URL, defeating the
15
+ * SSRF guard.
16
+ *
17
+ * A `redirect: 'manual'` request yields an unreadable opaque response in the
18
+ * browser (and in other spec-compliant fetch implementations), so the redirect
19
+ * target cannot be validated here. In a real browser this is safe to follow
20
+ * natively because SSRF is not reachable (fetch is constrained by CORS and
21
+ * cannot reach a server's internal network or cloud-metadata). On any other
22
+ * runtime we cannot validate the hop, so we fail closed rather than follow it
23
+ * blindly and bypass the SSRF guard.
24
+ *
25
+ * The returned response is the final (non-redirect) response. The caller is
26
+ * responsible for checking `response.ok` and reading the body.
27
+ *
28
+ * @throws DownloadError if a hop is unsafe, the redirect limit is exceeded, or
29
+ * a redirect cannot be validated on a non-browser runtime.
30
+ */
31
+ export async function fetchWithValidatedRedirects({
32
+ url,
33
+ headers,
34
+ abortSignal,
35
+ maxRedirects = MAX_DOWNLOAD_REDIRECTS,
36
+ }: {
37
+ url: string;
38
+ headers?: HeadersInit;
39
+ abortSignal?: AbortSignal;
40
+ maxRedirects?: number;
41
+ }): Promise<Response> {
42
+ // Per-hop request options. Only the `redirect` mode varies between hops, so
43
+ // the rest is assembled once. `headers` is omitted entirely when not provided
44
+ // so callers that send none issue a bare request.
45
+ const baseInit: RequestInit = { signal: abortSignal };
46
+ if (headers !== undefined) {
47
+ baseInit.headers = headers;
48
+ }
49
+
50
+ let currentUrl = url;
51
+ // The bound also acts as a backstop against an unterminated redirect chain.
52
+ for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
53
+ validateDownloadUrl(currentUrl);
54
+
55
+ const response = await fetch(currentUrl, {
56
+ ...baseInit,
57
+ redirect: 'manual',
58
+ });
59
+
60
+ if (response.type === 'opaqueredirect') {
61
+ if (!isBrowserRuntime()) {
62
+ throw new DownloadError({
63
+ url,
64
+ message: `Redirect from ${currentUrl} could not be validated and was blocked`,
65
+ });
66
+ }
67
+ return await fetch(currentUrl, { ...baseInit, redirect: 'follow' });
68
+ }
69
+
70
+ const location = response.headers.get('location');
71
+ if (response.status >= 300 && response.status < 400 && location) {
72
+ // Release the redirect response's connection before moving to the next
73
+ // hop. Whether that hop is followed or rejected by the SSRF guard, an
74
+ // unconsumed 3xx body would leak the underlying socket.
75
+ await cancelResponseBody(response);
76
+ currentUrl = new URL(location, currentUrl).toString();
77
+ continue;
78
+ }
79
+
80
+ return response;
81
+ }
82
+
83
+ throw new DownloadError({
84
+ url,
85
+ message: `Too many redirects (max ${maxRedirects})`,
86
+ });
87
+ }
package/src/index.ts CHANGED
@@ -18,6 +18,8 @@ export {
18
18
  } from './detect-media-type';
19
19
  export { downloadBlob } from './download-blob';
20
20
  export { DownloadError } from './download-error';
21
+ export { fetchWithValidatedRedirects } from './fetch-with-validated-redirects';
22
+ export { extractLines } from './extract-lines';
21
23
  export * from './extract-response-headers';
22
24
  export * from './fetch-function';
23
25
  export { filterNullable } from './filter-nullable';
@@ -28,7 +30,9 @@ export { getRuntimeEnvironmentUserAgent } from './get-runtime-environment-user-a
28
30
  export type { HasRequiredKey } from './has-required-key';
29
31
  export { injectJsonInstructionIntoMessages } from './inject-json-instruction';
30
32
  export * from './is-abort-error';
33
+ export { isBrowserRuntime } from './is-browser-runtime';
31
34
  export { isBuffer } from './is-buffer';
35
+ export { isSameOrigin } from './is-same-origin';
32
36
  export { isNonNullable } from './is-non-nullable';
33
37
  export { isProviderReference } from './is-provider-reference';
34
38
  export { isUrlSupported } from './is-url-supported';
@@ -57,6 +61,7 @@ export {
57
61
  createProviderExecutedToolFactory,
58
62
  type ProviderExecutedToolFactory,
59
63
  } from './provider-executed-tool-factory';
64
+ export { cancelResponseBody } from './cancel-response-body';
60
65
  export {
61
66
  DEFAULT_MAX_DOWNLOAD_SIZE,
62
67
  readResponseWithSizeLimit,
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Returns `true` when running in a browser.
3
+ *
4
+ * Detection keys on the presence of a global `window`, matching the browser
5
+ * check used elsewhere in this package (see `getRuntimeEnvironmentUserAgent`)
6
+ * so the SDK has a single, consistent definition of "browser". Server runtimes
7
+ * (Node.js, Deno, Bun, edge/workers) do not define `window`.
8
+ */
9
+ export function isBrowserRuntime(
10
+ globalThisAny: any = globalThis as any,
11
+ ): boolean {
12
+ return globalThisAny.window != null;
13
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Returns true when `url` has the same origin (scheme + host + port) as
3
+ * `baseUrl`.
4
+ *
5
+ * Used to decide whether provider credentials may be attached to a request to a
6
+ * URL taken from a provider response (e.g. a polling or media-download URL).
7
+ * Credentials must only be sent to the provider's own origin; a response that
8
+ * names a foreign host (a CDN, or an attacker-controlled host if the response
9
+ * is tampered with) must not receive the API key.
10
+ *
11
+ * Returns false if either value is not a valid absolute URL (fail-closed).
12
+ */
13
+ export function isSameOrigin(url: string, baseUrl: string): boolean {
14
+ try {
15
+ return new URL(url).origin === new URL(baseUrl).origin;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
@@ -1,3 +1,4 @@
1
+ import { cancelResponseBody } from './cancel-response-body';
1
2
  import { DownloadError } from './download-error';
2
3
 
3
4
  /**
@@ -40,6 +41,9 @@ export async function readResponseWithSizeLimit({
40
41
  if (contentLength != null) {
41
42
  const length = parseInt(contentLength, 10);
42
43
  if (!isNaN(length) && length > maxBytes) {
44
+ // Cancel the body so the underlying connection is released back to the
45
+ // pool instead of being left open until the socket is exhausted.
46
+ await cancelResponseBody(response);
43
47
  throw new DownloadError({
44
48
  url,
45
49
  message: `Download of ${url} exceeded maximum size of ${maxBytes} bytes (Content-Length: ${length}).`,
package/src/schema.ts CHANGED
@@ -136,7 +136,11 @@ export function asSchema<OBJECT>(
136
136
  schema: FlexibleSchema<OBJECT> | undefined,
137
137
  ): Schema<OBJECT> {
138
138
  return schema == null
139
- ? jsonSchema({ properties: {}, additionalProperties: false })
139
+ ? jsonSchema({
140
+ type: 'object',
141
+ properties: {},
142
+ additionalProperties: false,
143
+ })
140
144
  : isSchema(schema)
141
145
  ? schema
142
146
  : '~standard' in schema
@@ -14,16 +14,18 @@ export const parsePipelineDef = (
14
14
  return parseDef(def.out._def, refs);
15
15
  }
16
16
 
17
- const a = parseDef(def.in._def, {
17
+ const inputSchema = parseDef(def.in._def, {
18
18
  ...refs,
19
19
  currentPath: [...refs.currentPath, 'allOf', '0'],
20
20
  });
21
- const b = parseDef(def.out._def, {
21
+ const outputSchema = parseDef(def.out._def, {
22
22
  ...refs,
23
- currentPath: [...refs.currentPath, 'allOf', a ? '1' : '0'],
23
+ currentPath: [...refs.currentPath, 'allOf', inputSchema ? '1' : '0'],
24
24
  });
25
25
 
26
26
  return {
27
- allOf: [a, b].filter((x): x is JsonSchema7Type => x !== undefined),
27
+ allOf: [inputSchema, outputSchema].filter(
28
+ (schema): schema is JsonSchema7Type => schema !== undefined,
29
+ ),
28
30
  };
29
31
  };
@@ -317,6 +317,50 @@ export type ToolResultOutput =
317
317
  providerOptions?: ProviderOptions;
318
318
  }
319
319
  | {
320
+ type: 'file';
321
+
322
+ /**
323
+ * File data as a tagged discriminated union:
324
+ *
325
+ * - `{ type: 'data', data }`: raw bytes
326
+ * (base64 string, Uint8Array, ArrayBuffer, Buffer)
327
+ * - `{ type: 'url', url }`: a URL that points to the file
328
+ * - `{ type: 'reference', reference }`: a provider reference
329
+ * from `uploadFile`
330
+ * - `{ type: 'text', text }`: inline text content (e.g. an inline
331
+ * text document)
332
+ */
333
+ data: FileData;
334
+
335
+ /**
336
+ * Either a full IANA media type (`type/subtype`, e.g. `image/png`) or just
337
+ * the top-level IANA segment (e.g. `image`, `audio`, `video`, `text`).
338
+ *
339
+ * `*`-subtype wildcards (e.g. `image/*`) are normalized as equivalent to the
340
+ * top-level segment alone (e.g. `image`). Providers can use the helpers in
341
+ * `@ai-sdk/provider-utils` (`isFullMediaType`, `getTopLevelMediaType`,
342
+ * `detectMediaType`) to resolve the field according to their API
343
+ * requirements.
344
+ *
345
+ * @see https://www.iana.org/assignments/media-types/media-types.xhtml
346
+ */
347
+ mediaType: string;
348
+
349
+ /**
350
+ * Optional filename of the file.
351
+ */
352
+ filename?: string;
353
+
354
+ /**
355
+ * Provider-specific options.
356
+ */
357
+ providerOptions?: ProviderOptions;
358
+ }
359
+ | {
360
+ /**
361
+ * @deprecated Use 'file' with mediaType + tagged data instead:
362
+ * `{ type: 'file', mediaType, data: { type: 'data', data } }`.
363
+ */
320
364
  type: 'file-data';
321
365
 
322
366
  /**
@@ -341,6 +385,10 @@ export type ToolResultOutput =
341
385
  providerOptions?: ProviderOptions;
342
386
  }
343
387
  | {
388
+ /**
389
+ * @deprecated Use 'file' with mediaType and tagged data instead:
390
+ * `{ type: 'file', mediaType, data: { type: 'url', url: new URL(url) } }`.
391
+ */
344
392
  type: 'file-url';
345
393
 
346
394
  /**
@@ -352,7 +400,7 @@ export type ToolResultOutput =
352
400
  * IANA media type.
353
401
  * @see https://www.iana.org/assignments/media-types/media-types.xhtml
354
402
  */
355
- mediaType?: string; // Temporarily optional. TODO: make required in v8, after migration period.
403
+ mediaType?: string;
356
404
 
357
405
  /**
358
406
  * Provider-specific options.
@@ -361,7 +409,8 @@ export type ToolResultOutput =
361
409
  }
362
410
  | {
363
411
  /**
364
- * @deprecated Use file-reference instead.
412
+ * @deprecated Use 'file' with tagged data instead:
413
+ * `{ type: 'file', mediaType, data: { type: 'reference', reference } }`.
365
414
  */
366
415
  type: 'file-id';
367
416
 
@@ -381,6 +430,10 @@ export type ToolResultOutput =
381
430
  providerOptions?: ProviderOptions;
382
431
  }
383
432
  | {
433
+ /**
434
+ * @deprecated Use 'file' with tagged data instead:
435
+ * `{ type: 'file', mediaType, data: { type: 'reference', reference } }`.
436
+ */
384
437
  type: 'file-reference';
385
438
 
386
439
  /**
@@ -396,7 +449,9 @@ export type ToolResultOutput =
396
449
  }
397
450
  | {
398
451
  /**
399
- * @deprecated Use file-data instead.
452
+ * @deprecated Use 'file' with mediaType (e.g. 'image' or a specific
453
+ * `image/*` subtype) and tagged data instead:
454
+ * `{ type: 'file', mediaType: 'image', data: { type: 'data', data } }`.
400
455
  */
401
456
  type: 'image-data';
402
457
 
@@ -418,7 +473,9 @@ export type ToolResultOutput =
418
473
  }
419
474
  | {
420
475
  /**
421
- * @deprecated Use file-url instead.
476
+ * @deprecated Use 'file' with `mediaType: 'image'` (or a specific
477
+ * `image/*` subtype) and tagged data instead:
478
+ * `{ type: 'file', mediaType: 'image', data: { type: 'url', url: new URL(url) } }`.
422
479
  */
423
480
  type: 'image-url';
424
481
 
@@ -434,7 +491,9 @@ export type ToolResultOutput =
434
491
  }
435
492
  | {
436
493
  /**
437
- * @deprecated Use file-reference instead.
494
+ * @deprecated Use 'file' with `mediaType: 'image'` (or a specific
495
+ * `image/*` subtype) and tagged data instead:
496
+ * `{ type: 'file', mediaType: 'image', data: { type: 'reference', reference } }`.
438
497
  */
439
498
  type: 'image-file-id';
440
499
 
@@ -455,7 +514,9 @@ export type ToolResultOutput =
455
514
  }
456
515
  | {
457
516
  /**
458
- * @deprecated Use file-reference instead.
517
+ * @deprecated Use 'file' with `mediaType: 'image'` (or a specific
518
+ * `image/*` subtype) and tagged data instead:
519
+ * `{ type: 'file', mediaType: 'image', data: { type: 'reference', reference } }`.
459
520
  */
460
521
  type: 'image-file-reference';
461
522
 
@@ -15,6 +15,7 @@ export type {
15
15
  } from './content-part';
16
16
  export type { Context } from './context';
17
17
  export type { DataContent } from './data-content';
18
+ export { isExecutableTool, type ExecutableTool } from './executable-tool';
18
19
  export { executeTool } from './execute-tool';
19
20
  export type {
20
21
  FileData,
@@ -23,7 +24,6 @@ export type {
23
24
  FileDataText,
24
25
  FileDataUrl,
25
26
  } from './file-data';
26
- export { isExecutableTool, type ExecutableTool } from './executable-tool';
27
27
  export type { InferToolContext } from './infer-tool-context';
28
28
  export type { InferToolInput } from './infer-tool-input';
29
29
  export type { InferToolOutput } from './infer-tool-output';
@@ -31,7 +31,10 @@ export type { InferToolSetContext } from './infer-tool-set-context';
31
31
  export type { ModelMessage } from './model-message';
32
32
  export type { ProviderOptions } from './provider-options';
33
33
  export type { ProviderReference } from './provider-reference';
34
- export type { SensitiveContext } from './sensitive-context';
34
+ export type {
35
+ SandboxSession as Experimental_SandboxSession,
36
+ SandboxProcess as Experimental_SandboxProcess,
37
+ } from './sandbox';
35
38
  export type { SystemModelMessage } from './system-model-message';
36
39
  export {
37
40
  dynamicTool,
@@ -42,15 +45,15 @@ export {
42
45
  type ProviderExecutedTool,
43
46
  type Tool,
44
47
  } from './tool';
48
+ export type { ToolApprovalRequest } from './tool-approval-request';
49
+ export type { ToolApprovalResponse } from './tool-approval-response';
50
+ export type { ToolCall } from './tool-call';
45
51
  export type {
46
52
  ToolExecuteFunction,
47
53
  ToolExecutionOptions,
48
54
  } from './tool-execute-function';
49
- export type { ToolNeedsApprovalFunction } from './tool-needs-approval-function';
50
- export type { ToolSet } from './tool-set';
51
- export type { ToolApprovalRequest } from './tool-approval-request';
52
- export type { ToolApprovalResponse } from './tool-approval-response';
53
- export type { ToolCall } from './tool-call';
54
55
  export type { ToolContent, ToolModelMessage } from './tool-model-message';
56
+ export type { ToolNeedsApprovalFunction } from './tool-needs-approval-function';
55
57
  export type { ToolResult } from './tool-result';
58
+ export type { ToolSet } from './tool-set';
56
59
  export type { UserContent, UserModelMessage } from './user-model-message';
@@ -1,12 +1,41 @@
1
- import type { HasRequiredKey } from '../has-required-key';
1
+ import type { Context } from './context';
2
2
  import type { Tool } from './tool';
3
3
 
4
+ /**
5
+ * Detects the `any` type so untyped tools can be treated as having no explicit
6
+ * context type.
7
+ */
8
+ type IsAny<T> = 0 extends 1 & T ? true : false;
9
+
10
+ /**
11
+ * Detects exact empty object contexts, including `{}` combined with
12
+ * `undefined`, which do not provide tool-specific context properties.
13
+ */
14
+ type IsEmptyObject<T> = keyof NonNullable<T> extends never ? true : false;
15
+
16
+ /**
17
+ * Detects context types that come from omitted or broad context declarations
18
+ * rather than a concrete tool context schema.
19
+ */
20
+ type IsUntypedContext<CONTEXT> =
21
+ IsAny<CONTEXT> extends true
22
+ ? true
23
+ : unknown extends CONTEXT
24
+ ? true
25
+ : IsEmptyObject<CONTEXT> extends true
26
+ ? true
27
+ : string extends keyof CONTEXT
28
+ ? CONTEXT extends Context
29
+ ? true
30
+ : false
31
+ : false;
32
+
4
33
  /**
5
34
  * Infer the context type of a tool.
6
35
  */
7
36
  export type InferToolContext<TOOL extends Tool> =
8
37
  TOOL extends Tool<any, any, infer CONTEXT>
9
- ? HasRequiredKey<CONTEXT> extends true
10
- ? CONTEXT
11
- : never
38
+ ? IsUntypedContext<CONTEXT> extends true
39
+ ? never
40
+ : CONTEXT
12
41
  : never;
@@ -2,14 +2,43 @@ import type { InferToolContext } from './infer-tool-context';
2
2
  import type { ToolSet } from './tool-set';
3
3
 
4
4
  /**
5
- * Infer the context type for a tool set.
6
- *
7
- * The inferred type maps each tool name to its required context type.
8
- *
9
- * Tools without required context properties are omitted from the result.
5
+ * Builds the required portion of the tool context map for tools whose context
6
+ * type does not include `undefined`.
7
+ */
8
+ type RequiredToolSetContext<TOOLS extends ToolSet> = {
9
+ [K in keyof TOOLS as InferToolContext<NoInfer<TOOLS[K]>> extends never
10
+ ? never
11
+ : undefined extends InferToolContext<NoInfer<TOOLS[K]>>
12
+ ? never
13
+ : K]: InferToolContext<NoInfer<TOOLS[K]>>;
14
+ };
15
+
16
+ /**
17
+ * Builds the optional portion of the tool context map for tools whose context
18
+ * object itself may be `undefined`.
10
19
  */
11
- export type InferToolSetContext<TOOLS extends ToolSet> = {
20
+ type OptionalToolSetContext<TOOLS extends ToolSet> = {
12
21
  [K in keyof TOOLS as InferToolContext<NoInfer<TOOLS[K]>> extends never
13
22
  ? never
14
- : K]: InferToolContext<NoInfer<TOOLS[K]>>;
23
+ : undefined extends InferToolContext<NoInfer<TOOLS[K]>>
24
+ ? K
25
+ : never]?: InferToolContext<NoInfer<TOOLS[K]>>;
15
26
  };
27
+
28
+ /**
29
+ * Flattens intersected mapped types so type equality assertions and editor
30
+ * hovers show the resulting object shape.
31
+ */
32
+ type Normalize<OBJECT> = { [KEY in keyof OBJECT]: OBJECT[KEY] };
33
+
34
+ /**
35
+ * Infer the context type for a tool set.
36
+ *
37
+ * The inferred type maps each contextual tool name to its context type.
38
+ *
39
+ * Tools without concrete context are omitted. Tool contexts that include
40
+ * `undefined` are represented as optional properties.
41
+ */
42
+ export type InferToolSetContext<TOOLS extends ToolSet> = Normalize<
43
+ RequiredToolSetContext<TOOLS> & OptionalToolSetContext<TOOLS>
44
+ >;