@andreprado/agentkit 0.1.0 → 0.1.1

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.
@@ -0,0 +1,480 @@
1
+ import { lookup as dnsLookup } from "node:dns/promises";
2
+ import type { IncomingMessage } from "node:http";
3
+ import { request as httpsRequest } from "node:https";
4
+ import type { LookupFunction } from "node:net";
5
+ import { pipeline, Readable } from "node:stream";
6
+ import { createBrotliDecompress, createUnzip } from "node:zlib";
7
+
8
+ import { AgentKitError } from "../errors";
9
+
10
+ // Outbound requests to operator-configured URLs (webhook outputs, self-hosted
11
+ // WhatsApp gateways) must not reach private networks. Checking the hostname
12
+ // text is not enough: a public name can resolve to a private address, and a
13
+ // public server can redirect to one. So every guarded request resolves DNS,
14
+ // rejects blocked addresses, pins the connection to the checked address, and
15
+ // never follows redirects.
16
+
17
+ export type OutboundResolver = (hostname: string) => Promise<string[]>;
18
+
19
+ export type ResolvedOutboundUrl = {
20
+ href: string;
21
+ address: string;
22
+ family: 4 | 6;
23
+ };
24
+
25
+ export type PinnedRequestInit = {
26
+ method: string;
27
+ headers: Record<string, string>;
28
+ body?: string;
29
+ signal?: AbortSignal;
30
+ };
31
+
32
+ export type PublicHttpsFetch = (url: string, init?: RequestInit) => Promise<Response>;
33
+
34
+ // Test seams only. Production callers never pass these, so private addresses
35
+ // stay blocked and TLS is verified against the system CA store.
36
+ export type PinnedTransportOptions = {
37
+ isBlockedAddress?: (address: string) => boolean;
38
+ ca?: string;
39
+ };
40
+
41
+ export function createPublicHttpsFetch(
42
+ label: string,
43
+ resolver: OutboundResolver = resolveOutboundAddresses,
44
+ transport: PinnedTransportOptions = {},
45
+ ): PublicHttpsFetch {
46
+ return async (url, init = {}) => {
47
+ const parsed = new URL(url);
48
+
49
+ if (parsed.protocol !== "https:") {
50
+ throw new AgentKitError("channel_payload_invalid", `${label} must use HTTPS.`);
51
+ }
52
+
53
+ if (isBlockedOutboundHost(parsed.hostname)) {
54
+ throw new AgentKitError("channel_payload_invalid", `${label} host is not allowed.`);
55
+ }
56
+
57
+ if (init.body !== undefined && init.body !== null && typeof init.body !== "string") {
58
+ throw new AgentKitError("channel_payload_invalid", `${label} requests only support string bodies.`);
59
+ }
60
+
61
+ const signal = init.signal ?? undefined;
62
+ const resolved = await resolvePublicOutboundUrl(parsed.href, label, resolver, signal, transport.isBlockedAddress);
63
+ const headers: Record<string, string> = {};
64
+
65
+ new Headers(init.headers).forEach((value, key) => {
66
+ headers[key] = value;
67
+ });
68
+
69
+ return pinnedHttpsFetch(
70
+ resolved,
71
+ {
72
+ method: init.method ?? "GET",
73
+ headers,
74
+ body: init.body ?? undefined,
75
+ signal,
76
+ },
77
+ label,
78
+ transport,
79
+ );
80
+ };
81
+ }
82
+
83
+ export async function resolvePublicOutboundUrl(
84
+ href: string,
85
+ label: string,
86
+ resolver: OutboundResolver = resolveOutboundAddresses,
87
+ signal?: AbortSignal,
88
+ isBlockedAddress: (address: string) => boolean = isBlockedOutboundHost,
89
+ ): Promise<ResolvedOutboundUrl> {
90
+ const url = new URL(href);
91
+ let addresses: string[];
92
+
93
+ try {
94
+ addresses = await resolveWithinDeadline(resolver, url.hostname, signal, label);
95
+ } catch (error) {
96
+ if (error instanceof AgentKitError) {
97
+ throw error;
98
+ }
99
+
100
+ throw new AgentKitError(
101
+ "channel_provider_unavailable",
102
+ `${label} host could not be resolved: ${error instanceof Error ? error.message : String(error)}`,
103
+ );
104
+ }
105
+
106
+ if (addresses.length === 0) {
107
+ throw new AgentKitError("channel_provider_unavailable", `${label} host did not resolve.`);
108
+ }
109
+
110
+ for (const address of addresses) {
111
+ if (isBlockedAddress(address)) {
112
+ throw new AgentKitError("channel_payload_invalid", `${label} host resolved to a blocked address.`);
113
+ }
114
+ }
115
+
116
+ const address = normalizeOutboundHost(addresses[0]);
117
+
118
+ return {
119
+ href,
120
+ address,
121
+ family: parseIpv4Octets(address) ? 4 : 6,
122
+ };
123
+ }
124
+
125
+ // Node's https.request does not follow redirects, so a 3xx response is
126
+ // returned to the caller as-is rather than being chased to another host.
127
+ export function pinnedHttpsFetch(
128
+ resolved: ResolvedOutboundUrl,
129
+ init: PinnedRequestInit,
130
+ label: string,
131
+ transport: PinnedTransportOptions = {},
132
+ ): Promise<Response> {
133
+ const url = new URL(resolved.href);
134
+ const isBlockedAddress = transport.isBlockedAddress ?? isBlockedOutboundHost;
135
+ // Node calls lookup with `all: true` (for happy-eyeballs family selection),
136
+ // which expects an array of records instead of a single address.
137
+ const lookup = ((_hostname: string, options: { all?: boolean }, callback: (...args: unknown[]) => void) => {
138
+ if (options?.all) {
139
+ callback(null, [{ address: resolved.address, family: resolved.family }]);
140
+ return;
141
+ }
142
+
143
+ callback(null, resolved.address, resolved.family);
144
+ }) as unknown as LookupFunction;
145
+
146
+ return new Promise((resolve, reject) => {
147
+ const request = httpsRequest(
148
+ url,
149
+ {
150
+ method: init.method,
151
+ headers: init.headers,
152
+ lookup,
153
+ signal: init.signal,
154
+ ...(transport.ca ? { ca: transport.ca } : {}),
155
+ },
156
+ (response) => {
157
+ // Everything here runs outside the promise executor, so any throw must
158
+ // become a rejection or it would crash the process.
159
+ try {
160
+ assertAllowedRemoteAddress(response.socket.remoteAddress, label, isBlockedAddress);
161
+ const status = response.statusCode ?? 502;
162
+ const bodyless = init.method.toUpperCase() === "HEAD" || NULL_BODY_STATUSES.has(status);
163
+
164
+ if (bodyless) {
165
+ response.resume();
166
+ }
167
+
168
+ resolve(
169
+ new Response(bodyless ? null : (Readable.toWeb(decodedResponseBody(response)) as unknown as ReadableStream<Uint8Array>), {
170
+ status,
171
+ headers: headersFromIncomingMessage(response.headers),
172
+ }),
173
+ );
174
+ } catch (error) {
175
+ response.destroy(error instanceof Error ? error : new Error(String(error)));
176
+ reject(error);
177
+ }
178
+ },
179
+ );
180
+
181
+ request.on("socket", (socket) => {
182
+ const validateSocket = () => {
183
+ try {
184
+ assertAllowedRemoteAddress(socket.remoteAddress, label, isBlockedAddress);
185
+ } catch (error) {
186
+ request.destroy(error instanceof Error ? error : new Error(String(error)));
187
+ }
188
+ };
189
+
190
+ socket.once("connect", validateSocket);
191
+ socket.once("secureConnect", validateSocket);
192
+ });
193
+ request.once("error", reject);
194
+ request.end(init.body);
195
+ });
196
+ }
197
+
198
+ function decodedResponseBody(response: IncomingMessage): Readable {
199
+ const encodings = response.headers["content-encoding"]?.toLowerCase().split(",").map((value) => value.trim()) ?? [];
200
+
201
+ if (encodings.length === 0 || !encodings.every((value) => ["gzip", "x-gzip", "deflate", "x-deflate", "br"].includes(value))) {
202
+ return response;
203
+ }
204
+
205
+ const decoders = encodings.reverse().map((encoding) => encoding === "br" ? createBrotliDecompress() : createUnzip());
206
+ // Keep decoding streamed so callers enforce limits on decoded bytes. Pipeline
207
+ // propagates source errors and destroys the socket when the reader cancels.
208
+ pipeline([response, ...decoders], () => {});
209
+ return decoders[decoders.length - 1];
210
+ }
211
+
212
+ export async function resolveOutboundAddresses(hostname: string): Promise<string[]> {
213
+ const host = normalizeOutboundHost(hostname);
214
+
215
+ if (parseIpv4Octets(host) || parseIpv6Segments(host)) {
216
+ return [host];
217
+ }
218
+
219
+ const records = await dnsLookup(host, {
220
+ all: true,
221
+ verbatim: true,
222
+ });
223
+
224
+ return records.map((record) => record.address);
225
+ }
226
+
227
+ export function isBlockedOutboundHost(hostname: string): boolean {
228
+ const host = normalizeOutboundHost(hostname);
229
+
230
+ if (
231
+ host === "localhost" ||
232
+ host.endsWith(".localhost") ||
233
+ host === "metadata.google.internal"
234
+ ) {
235
+ return true;
236
+ }
237
+
238
+ const ipv4 = parseIpv4Octets(host);
239
+
240
+ if (ipv4) {
241
+ return isBlockedIpv4Octets(ipv4);
242
+ }
243
+
244
+ const ipv6 = parseIpv6Segments(host);
245
+
246
+ if (ipv6) {
247
+ return isBlockedIpv6Segments(ipv6);
248
+ }
249
+
250
+ return false;
251
+ }
252
+
253
+ export function normalizeOutboundHost(hostname: string): string {
254
+ return hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
255
+ }
256
+
257
+ // The Fetch Response constructor rejects a body for these statuses.
258
+ const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
259
+
260
+ // Resolution happens before the request exists, so the caller's deadline has to
261
+ // cover it here; otherwise a stalled resolver outlives the request timeout.
262
+ // Nothing has been sent yet, so this failure is always safe to retry.
263
+ async function resolveWithinDeadline(
264
+ resolver: OutboundResolver,
265
+ hostname: string,
266
+ signal: AbortSignal | undefined,
267
+ label: string,
268
+ ): Promise<string[]> {
269
+ const deadlineError = () =>
270
+ new AgentKitError("channel_provider_unavailable", `${label} host resolution did not finish before the request deadline.`);
271
+
272
+ if (!signal) {
273
+ return resolver(hostname);
274
+ }
275
+
276
+ if (signal.aborted) {
277
+ throw deadlineError();
278
+ }
279
+
280
+ return new Promise((resolve, reject) => {
281
+ const onAbort = () => reject(deadlineError());
282
+
283
+ signal.addEventListener("abort", onAbort, { once: true });
284
+ resolver(hostname).then(
285
+ (addresses) => {
286
+ signal.removeEventListener("abort", onAbort);
287
+ resolve(addresses);
288
+ },
289
+ (error) => {
290
+ signal.removeEventListener("abort", onAbort);
291
+ reject(error);
292
+ },
293
+ );
294
+ });
295
+ }
296
+
297
+ function assertAllowedRemoteAddress(
298
+ address: string | undefined,
299
+ label: string,
300
+ isBlockedAddress: (address: string) => boolean,
301
+ ): void {
302
+ if (!address || isBlockedAddress(address)) {
303
+ throw new AgentKitError("channel_payload_invalid", `${label} connected to a blocked address.`);
304
+ }
305
+ }
306
+
307
+ function headersFromIncomingMessage(headers: Record<string, string | string[] | number | undefined>): Headers {
308
+ const result = new Headers();
309
+
310
+ for (const [key, value] of Object.entries(headers)) {
311
+ if (Array.isArray(value)) {
312
+ for (const item of value) {
313
+ result.append(key, item);
314
+ }
315
+ continue;
316
+ }
317
+
318
+ if (value !== undefined) {
319
+ result.set(key, String(value));
320
+ }
321
+ }
322
+
323
+ return result;
324
+ }
325
+
326
+ function parseIpv4Octets(host: string): number[] | undefined {
327
+ const parts = host.split(".");
328
+
329
+ if (parts.length !== 4) {
330
+ return undefined;
331
+ }
332
+
333
+ const octets = parts.map((part) => Number(part));
334
+
335
+ if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
336
+ return undefined;
337
+ }
338
+
339
+ return octets;
340
+ }
341
+
342
+ function isBlockedIpv4Octets(parts: number[]): boolean {
343
+ const [a, b] = parts;
344
+
345
+ return (
346
+ a === 0 ||
347
+ a === 10 ||
348
+ a === 127 ||
349
+ (a === 100 && b >= 64 && b <= 127) ||
350
+ (a === 169 && b === 254) ||
351
+ (a === 172 && b >= 16 && b <= 31) ||
352
+ (a === 192 && b === 168) ||
353
+ (a === 198 && (b === 18 || b === 19)) ||
354
+ a >= 224
355
+ );
356
+ }
357
+
358
+ function parseIpv6Segments(host: string): number[] | undefined {
359
+ if (!host.includes(":")) {
360
+ return undefined;
361
+ }
362
+
363
+ if (host === "::") {
364
+ return new Array(8).fill(0);
365
+ }
366
+
367
+ const [leftRaw, rightRaw, extra] = host.split("::");
368
+
369
+ if (extra !== undefined) {
370
+ return undefined;
371
+ }
372
+
373
+ const left = parseIpv6SegmentSide(leftRaw ?? "");
374
+ const right = parseIpv6SegmentSide(rightRaw ?? "");
375
+
376
+ if (!left || !right) {
377
+ return undefined;
378
+ }
379
+
380
+ if (rightRaw === undefined) {
381
+ return left.length === 8 ? left : undefined;
382
+ }
383
+
384
+ const missing = 8 - left.length - right.length;
385
+
386
+ if (missing < 1) {
387
+ return undefined;
388
+ }
389
+
390
+ return [...left, ...new Array(missing).fill(0), ...right];
391
+ }
392
+
393
+ function parseIpv6SegmentSide(value: string): number[] | undefined {
394
+ if (!value) {
395
+ return [];
396
+ }
397
+
398
+ const segments: number[] = [];
399
+
400
+ const parts = value.split(":");
401
+
402
+ for (const [index, part] of parts.entries()) {
403
+ if (part.includes(".")) {
404
+ if (index !== parts.length - 1) {
405
+ return undefined;
406
+ }
407
+
408
+ const octets = parseIpv4Octets(part);
409
+
410
+ if (!octets) {
411
+ return undefined;
412
+ }
413
+
414
+ segments.push((octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]);
415
+ continue;
416
+ }
417
+
418
+ if (!/^[0-9a-f]{1,4}$/i.test(part)) {
419
+ return undefined;
420
+ }
421
+
422
+ segments.push(parseInt(part, 16));
423
+ }
424
+
425
+ return segments;
426
+ }
427
+
428
+ function isBlockedIpv6Segments(parts: number[]): boolean {
429
+ if (parts.length !== 8) {
430
+ return true;
431
+ }
432
+
433
+ const embeddedIpv4 = ipv4OctetsFromIpv6Segments(parts);
434
+
435
+ if (embeddedIpv4) {
436
+ return isBlockedIpv4Octets(embeddedIpv4);
437
+ }
438
+
439
+ const isAllZero = parts.every((part) => part === 0);
440
+
441
+ if (isAllZero || (parts.slice(0, 7).every((part) => part === 0) && parts[7] === 1)) {
442
+ return true;
443
+ }
444
+
445
+ const first = parts[0];
446
+
447
+ return (
448
+ (first & 0xfe00) === 0xfc00 ||
449
+ (first & 0xffc0) === 0xfe80 ||
450
+ (first & 0xff00) === 0xff00
451
+ );
452
+ }
453
+
454
+ function ipv4OctetsFromIpv6Segments(parts: number[]): number[] | undefined {
455
+ const trailingIpv4 = [
456
+ (parts[6] >> 8) & 0xff,
457
+ parts[6] & 0xff,
458
+ (parts[7] >> 8) & 0xff,
459
+ parts[7] & 0xff,
460
+ ];
461
+
462
+ if (parts.slice(0, 5).every((part) => part === 0) && (parts[5] === 0xffff || parts[5] === 0)) {
463
+ return trailingIpv4;
464
+ }
465
+
466
+ if (parts[0] === 0x64 && parts[1] === 0xff9b && parts.slice(2, 6).every((part) => part === 0)) {
467
+ return trailingIpv4;
468
+ }
469
+
470
+ if (parts[0] === 0x2002) {
471
+ return [
472
+ (parts[1] >> 8) & 0xff,
473
+ parts[1] & 0xff,
474
+ (parts[2] >> 8) & 0xff,
475
+ parts[2] & 0xff,
476
+ ];
477
+ }
478
+
479
+ return undefined;
480
+ }
@@ -0,0 +1,54 @@
1
+ import { isAgentKitError } from "../errors";
2
+
3
+ // Deadlines for outbound provider calls. Without them a hung provider stalls
4
+ // a delivery job until the runtime's own socket defaults give up.
5
+ export const CHANNEL_SEND_TIMEOUT_MS = 15_000;
6
+ export const CHANNEL_STATUS_TIMEOUT_MS = 15_000;
7
+ export const CHANNEL_DOWNLOAD_TIMEOUT_MS = 60_000;
8
+
9
+ export type SendRequestFailure = {
10
+ retryable: boolean;
11
+ code: "channel_provider_unavailable" | "channel_send_failed";
12
+ reason: string;
13
+ };
14
+
15
+ // Classifies a failure thrown by fetch() for a non-idempotent send. A deadline
16
+ // can fire after the provider has already accepted the message, and none of
17
+ // the chat providers deduplicate on our idempotency key, so retrying a timed
18
+ // out send risks delivering the reply twice.
19
+ export function classifySendRequestError(error: unknown): SendRequestFailure {
20
+ if (isOutboundGuardRejection(error)) {
21
+ return {
22
+ retryable: false,
23
+ code: "channel_send_failed",
24
+ reason: "was blocked by the outbound network guard",
25
+ };
26
+ }
27
+
28
+ if (isDeadlineError(error)) {
29
+ return {
30
+ retryable: false,
31
+ code: "channel_send_failed",
32
+ reason: "timed out before a response; the provider may already have accepted the message, so it will not be retried",
33
+ };
34
+ }
35
+
36
+ return {
37
+ retryable: true,
38
+ code: "channel_provider_unavailable",
39
+ reason: "failed before a response",
40
+ };
41
+ }
42
+
43
+ // Downloads and status checks are safe to repeat; only a guard rejection is final.
44
+ export function isRetryableReadRequestError(error: unknown): boolean {
45
+ return !isOutboundGuardRejection(error);
46
+ }
47
+
48
+ export function isDeadlineError(error: unknown): boolean {
49
+ return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
50
+ }
51
+
52
+ function isOutboundGuardRejection(error: unknown): boolean {
53
+ return isAgentKitError(error) && error.code === "channel_payload_invalid";
54
+ }
@@ -7,6 +7,10 @@ import type {
7
7
  WebhookVerificationResult,
8
8
  } from "../channels";
9
9
  import { AgentKitError } from "../errors";
10
+ import {
11
+ CHANNEL_SEND_TIMEOUT_MS,
12
+ classifySendRequestError,
13
+ } from "./provider-fetch";
10
14
 
11
15
  const SLACK_API_BASE_URL = "https://slack.com/api";
12
16
  const SLACK_SIGNATURE_VERSION = "v0";
@@ -285,16 +289,18 @@ export async function sendSlackMessage(
285
289
 
286
290
  try {
287
291
  response = await fetcher(actualRequest.url, {
292
+ signal: AbortSignal.timeout(CHANNEL_SEND_TIMEOUT_MS),
288
293
  method: actualRequest.method,
289
294
  headers: actualRequest.headers,
290
295
  body: JSON.stringify(actualRequest.body),
291
296
  });
292
297
  } catch (error) {
298
+ const failure = classifySendRequestError(error);
293
299
  return {
294
300
  ok: false,
295
- retryable: true,
296
- code: "channel_provider_unavailable",
297
- message: `Slack chat.postMessage request failed before a response: ${redactSlackToken(
301
+ retryable: failure.retryable,
302
+ code: failure.code,
303
+ message: `Slack chat.postMessage request ${failure.reason}: ${redactSlackToken(
298
304
  error instanceof Error ? error.message : String(error),
299
305
  input.secrets.SLACK_BOT_TOKEN,
300
306
  )}`,
@@ -8,6 +8,12 @@ import type {
8
8
  WebhookVerificationResult,
9
9
  } from "../channels";
10
10
  import { AgentKitError } from "../errors";
11
+ import {
12
+ CHANNEL_DOWNLOAD_TIMEOUT_MS,
13
+ CHANNEL_SEND_TIMEOUT_MS,
14
+ classifySendRequestError,
15
+ isRetryableReadRequestError,
16
+ } from "./provider-fetch";
11
17
 
12
18
  export const telegramChannelAdapter: ChannelAdapter = {
13
19
  type: "telegram",
@@ -221,6 +227,7 @@ export async function sendTelegramMessage(
221
227
 
222
228
  try {
223
229
  response = await fetcher(actualRequest.url, {
230
+ signal: AbortSignal.timeout(CHANNEL_SEND_TIMEOUT_MS),
224
231
  method: actualRequest.method,
225
232
  headers: {
226
233
  "Content-Type": "application/json",
@@ -228,13 +235,14 @@ export async function sendTelegramMessage(
228
235
  body: JSON.stringify(actualRequest.body),
229
236
  });
230
237
  } catch (error) {
238
+ const failure = classifySendRequestError(error);
231
239
  const errorMessage = (error instanceof Error ? error.message : String(error)).replace(token, "<redacted>");
232
240
 
233
241
  return {
234
242
  ok: false,
235
- retryable: true,
236
- code: "channel_provider_unavailable",
237
- message: `Telegram sendMessage request failed before a response: ${errorMessage}`,
243
+ retryable: failure.retryable,
244
+ code: failure.code,
245
+ message: `Telegram sendMessage request ${failure.reason}: ${errorMessage}`,
238
246
  providerRequestId: request.idempotencyKey,
239
247
  providerMetadata: {
240
248
  method: request.method,
@@ -331,11 +339,14 @@ export async function downloadTelegramMedia(
331
339
  try {
332
340
  const url = new URL(`/bot${token}/getFile`, telegramApiBaseUrl(input.secrets));
333
341
  url.searchParams.set("file_id", audio.providerMediaId);
334
- fileResponse = await fetcher(url.href, { method: "GET" });
342
+ fileResponse = await fetcher(url.href, {
343
+ method: "GET",
344
+ signal: AbortSignal.timeout(CHANNEL_DOWNLOAD_TIMEOUT_MS),
345
+ });
335
346
  } catch (error) {
336
347
  return {
337
348
  ok: false,
338
- retryable: true,
349
+ retryable: isRetryableReadRequestError(error),
339
350
  code: "channel_provider_unavailable",
340
351
  message: `Telegram getFile request failed before a response: ${redactToken(
341
352
  error instanceof Error ? error.message : String(error),
@@ -397,11 +408,14 @@ export async function downloadTelegramMedia(
397
408
  let mediaResponse: Response;
398
409
 
399
410
  try {
400
- mediaResponse = await fetcher(`${telegramApiBaseUrl(input.secrets)}/file/bot${token}/${filePath}`, { method: "GET" });
411
+ mediaResponse = await fetcher(`${telegramApiBaseUrl(input.secrets)}/file/bot${token}/${filePath}`, {
412
+ method: "GET",
413
+ signal: AbortSignal.timeout(CHANNEL_DOWNLOAD_TIMEOUT_MS),
414
+ });
401
415
  } catch (error) {
402
416
  return {
403
417
  ok: false,
404
- retryable: true,
418
+ retryable: isRetryableReadRequestError(error),
405
419
  code: "channel_provider_unavailable",
406
420
  message: `Telegram file download failed before a response: ${redactToken(
407
421
  error instanceof Error ? error.message : String(error),
@@ -591,8 +605,8 @@ async function readResponseBytesWithLimit(
591
605
  }
592
606
  | {
593
607
  ok: false;
594
- retryable: false;
595
- code: "channel_audio_too_large";
608
+ retryable: boolean;
609
+ code: "channel_audio_too_large" | "channel_provider_unavailable";
596
610
  message: string;
597
611
  providerMetadata?: Record<string, unknown>;
598
612
  }
@@ -613,7 +627,18 @@ async function readResponseBytesWithLimit(
613
627
  }
614
628
 
615
629
  if (!response.body) {
616
- const audio = new Uint8Array(await response.arrayBuffer());
630
+ let audio: Uint8Array;
631
+
632
+ try {
633
+ audio = new Uint8Array(await response.arrayBuffer());
634
+ } catch (error) {
635
+ return {
636
+ ok: false,
637
+ retryable: true,
638
+ code: "channel_provider_unavailable",
639
+ message: `Media download response could not be read: ${error instanceof Error ? error.message : String(error)}`,
640
+ };
641
+ }
617
642
 
618
643
  if (audio.byteLength > maxBytes) {
619
644
  return {
@@ -659,6 +684,14 @@ async function readResponseBytesWithLimit(
659
684
 
660
685
  chunks.push(chunk.value);
661
686
  }
687
+ } catch (error) {
688
+ await reader.cancel().catch(() => undefined);
689
+ return {
690
+ ok: false,
691
+ retryable: true,
692
+ code: "channel_provider_unavailable",
693
+ message: `Media download response could not be read: ${error instanceof Error ? error.message : String(error)}`,
694
+ };
662
695
  } finally {
663
696
  reader.releaseLock();
664
697
  }