agent-accelerator 0.4.5 → 0.5.0

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.
Files changed (45) hide show
  1. package/README.md +149 -57
  2. package/bunfig.toml +1 -0
  3. package/package.json +2 -3
  4. package/src/agent/agent.ts +401 -120
  5. package/src/agent/context.ts +11 -16
  6. package/src/agent/delegation.ts +111 -274
  7. package/src/agent/loop.ts +253 -98
  8. package/src/agent/subagent.ts +4 -12
  9. package/src/agent/worker.ts +435 -0
  10. package/src/index.ts +39 -5
  11. package/src/models/catalog-cache.ts +65 -25
  12. package/src/models/catalog.ts +21 -2
  13. package/src/providers/google.ts +282 -74
  14. package/src/providers/openai-compat.ts +61 -41
  15. package/src/providers/openai.ts +46 -31
  16. package/src/providers/openrouter.ts +42 -31
  17. package/src/providers/shared.ts +110 -16
  18. package/src/providers.ts +119 -6
  19. package/src/scheduler/scheduled-actions.md +41 -0
  20. package/src/scheduler/scheduler.ts +814 -0
  21. package/src/scheduler/store.ts +263 -0
  22. package/src/scheduler/time.ts +148 -0
  23. package/src/scheduler/tool.ts +151 -0
  24. package/src/session/store.ts +211 -104
  25. package/src/streaming/event-stream.ts +6 -4
  26. package/src/telemetry-file.ts +31 -0
  27. package/src/telemetry.ts +65 -0
  28. package/src/tools/executor.ts +163 -38
  29. package/src/tools/subagent-result.ts +60 -0
  30. package/src/types/agent.ts +57 -20
  31. package/src/types/model.ts +18 -0
  32. package/src/types/response.ts +2 -0
  33. package/src/types/scheduler.ts +81 -0
  34. package/src/types/tool.ts +15 -0
  35. package/src/utils/cache.ts +14 -5
  36. package/src/utils/documents.ts +112 -52
  37. package/src/utils/errors.ts +11 -0
  38. package/src/utils/headers.ts +15 -15
  39. package/src/utils/home.ts +30 -0
  40. package/src/utils/media.ts +57 -12
  41. package/src/utils/raw-capture.ts +76 -0
  42. package/src/utils/retry.ts +11 -3
  43. package/src/utils/safe-io.ts +252 -0
  44. package/src/utils/session.ts +7 -8
  45. package/src/data/README.md +0 -84
@@ -71,7 +71,7 @@ export interface CreateExplicitCacheOptions {
71
71
 
72
72
  /**
73
73
  * Creates an explicit cached content object using Google Gemini's cachedContents API.
74
- * REST: POST https://generativelanguage.googleapis.com/v1beta/cachedContents?key=...
74
+ * REST: POST https://generativelanguage.googleapis.com/v1beta/cachedContents (key in the `x-goog-api-key` header)
75
75
  */
76
76
  /**
77
77
  * Creates a Google Gemini cachedContents resource through the REST API.
@@ -125,11 +125,13 @@ export async function createExplicitCache(
125
125
  (payload as any).tool_config = options.toolConfig;
126
126
  }
127
127
 
128
- const url = `${baseUrl}/cachedContents?key=${apiKey}`;
128
+ // The key travels in a header, never in the URL (URLs end up in proxy and access logs).
129
+ const url = `${baseUrl}/cachedContents`;
129
130
  const response = await fetch(url, {
130
131
  method: "POST",
131
132
  headers: {
132
133
  "Content-Type": "application/json",
134
+ "x-goog-api-key": apiKey,
133
135
  },
134
136
  body: JSON.stringify(payload),
135
137
  });
@@ -152,8 +154,15 @@ export async function createExplicitCache(
152
154
  /** Header-name deny-list (case-insensitive substring match). */
153
155
  const REDACT_HEADER_SUBSTRINGS = ["key", "token", "secret", "cookie", "authorization"];
154
156
 
155
- /** Secret-looking value prefixes. */
156
- const REDACT_VALUE_PREFIXES = ["sk-", "AIza", "gsk-", "xox-"];
157
+ /**
158
+ * Credential-looking values. Anchored on a non-alphanumeric boundary so plain
159
+ * words that merely contain "sk-" (e.g. `Task-Manager`, `Disk-Cache`) are NOT
160
+ * redacted, while `sk-…`, `gsk_…`, `xoxb-…` and `AIza…` tokens are.
161
+ */
162
+ const SENSITIVE_VALUE_PATTERNS: RegExp[] = [
163
+ /(^|[^A-Za-z0-9])(sk|gsk|xox[a-z]?)[-_][A-Za-z0-9_-]{8,}/,
164
+ /(^|[^A-Za-z0-9])AIza[A-Za-z0-9_-]{10,}/,
165
+ ];
157
166
 
158
167
  function headerNameIsSensitive(name: string): boolean {
159
168
  const lower = String(name ?? "").toLowerCase();
@@ -163,7 +172,7 @@ function headerNameIsSensitive(name: string): boolean {
163
172
  function valueLooksSensitive(value: unknown): boolean {
164
173
  if (typeof value !== "string") return false;
165
174
  const v = value.trim();
166
- if (REDACT_VALUE_PREFIXES.some((p) => v.includes(p))) return true;
175
+ if (SENSITIVE_VALUE_PATTERNS.some((re) => re.test(v))) return true;
167
176
  // Bearer-style long tokens: "Bearer <20+ chars>"
168
177
  if (/^bearer\s+\S{20,}/i.test(v)) return true;
169
178
  return false;
@@ -6,6 +6,7 @@ import { normalizeMediaInput } from "./media.ts";
6
6
  import { base64ToBytes } from "./base64.ts";
7
7
  import { escapeXml } from "./serialization.ts";
8
8
  import { getModelFromCatalog } from "../models/catalog.ts";
9
+ import { assertPathAllowed, fetchRemoteBytes, RemoteFetchError } from "./safe-io.ts";
9
10
 
10
11
  /**
11
12
  * Client-side document conversion (anydoc engine).
@@ -104,6 +105,16 @@ export interface ConvertDocumentOptions {
104
105
  maxChars?: number;
105
106
  /** Abort signal honored by the remote-fetch leg. */
106
107
  signal?: AbortSignal;
108
+ /**
109
+ * Allowed local roots for path inputs (see `AgentConfig.fileAccess`).
110
+ * Omitted = unrestricted (developer-supplied inputs); `[]` denies every path.
111
+ */
112
+ roots?: readonly string[];
113
+ /**
114
+ * Input is model/user-controlled: private/internal hosts are refused for
115
+ * remote URLs. The built-in tool always sets this.
116
+ */
117
+ untrusted?: boolean;
107
118
  }
108
119
 
109
120
  /** Normalized conversion result (pre-XML-envelope). */
@@ -296,7 +307,23 @@ export async function convertDocumentInput(
296
307
  try {
297
308
  // Remote URL leg: fetch bytes first (anydoc reads files, not URLs).
298
309
  if (typeof input === "string" && isHttpUrl(input)) {
299
- const res = await fetch(input, { signal: opts.signal });
310
+ let res;
311
+ try {
312
+ res = await fetchRemoteBytes(input, {
313
+ signal: opts.signal,
314
+ maxBytes: MAX_DOCUMENT_FETCH_BYTES,
315
+ allowPrivateNetwork: !opts.untrusted,
316
+ });
317
+ } catch (err) {
318
+ if (err instanceof RemoteFetchError) {
319
+ throw new DocumentConversionError(
320
+ err.reason === "too_large" ? "resourceLimit" : "blocked",
321
+ `[Agent Accelerator] Could not download document "${input}": ${err.message}`,
322
+ input
323
+ );
324
+ }
325
+ throw err;
326
+ }
300
327
  if (!res.ok) {
301
328
  throw new DocumentConversionError(
302
329
  "io",
@@ -304,19 +331,12 @@ export async function convertDocumentInput(
304
331
  input
305
332
  );
306
333
  }
307
- const buf = new Uint8Array(await res.arrayBuffer());
308
- if (buf.byteLength > MAX_DOCUMENT_FETCH_BYTES) {
309
- throw new DocumentConversionError(
310
- "resourceLimit",
311
- `[Agent Accelerator] Remote document "${input}" is ${(buf.byteLength / 1_000_000).toFixed(1)} MB, over the ${MAX_DOCUMENT_FETCH_BYTES / 1_000_000} MB fetch cap.`,
312
- input
313
- );
314
- }
334
+ const buf = res.bytes;
315
335
  const name = filenameOpt || basenameOf(input);
316
336
  const inferred = resolveDocumentFormat({
317
337
  filename: name,
318
338
  format: opts.format,
319
- mimeType: res.headers.get("content-type") || opts.mimeType,
339
+ mimeType: res.contentType || opts.mimeType,
320
340
  });
321
341
  let wire = canonicalizeFormat(anydoc, inferred);
322
342
  if (!wire) {
@@ -340,6 +360,17 @@ export async function convertDocumentInput(
340
360
 
341
361
  // Local path leg: the engine reads + detects from the path itself.
342
362
  if (typeof input === "string" && looksLikePath(input)) {
363
+ if (opts.roots) {
364
+ try {
365
+ await assertPathAllowed(input, opts.roots);
366
+ } catch (err) {
367
+ throw new DocumentConversionError(
368
+ "blocked",
369
+ `[Agent Accelerator] Could not read document "${input.slice(0, 120)}": ${err instanceof Error ? err.message : String(err)}`,
370
+ input
371
+ );
372
+ }
373
+ }
343
374
  const name = filenameOpt || basenameOf(input);
344
375
  const markdown = await anydoc.toMarkdown(input);
345
376
  const inferred = resolveDocumentFormat({ filename: name, format: opts.format });
@@ -424,7 +455,7 @@ export function modelSupportsFileInput(providerId: string, modelId: string): boo
424
455
  */
425
456
  export async function preprocessFilePartsForBypass(
426
457
  messages: Message[],
427
- opts: { providerId: string; modelId: string; signal?: AbortSignal; maxChars?: number }
458
+ opts: { providerId: string; modelId: string; signal?: AbortSignal; maxChars?: number; roots?: readonly string[] }
428
459
  ): Promise<void> {
429
460
  if (modelSupportsFileInput(opts.providerId, opts.modelId)) return;
430
461
  for (const m of messages) {
@@ -451,6 +482,7 @@ export async function preprocessFilePartsForBypass(
451
482
  mimeType,
452
483
  maxChars: opts.maxChars,
453
484
  signal: opts.signal,
485
+ roots: opts.roots,
454
486
  });
455
487
  const destination =
456
488
  filename ||
@@ -466,8 +498,75 @@ export async function preprocessFilePartsForBypass(
466
498
  }
467
499
  }
468
500
 
501
+ /** Options for {@link createConvertDocumentTool}. */
502
+ export interface ConvertDocumentToolOptions {
503
+ /**
504
+ * Local roots the model may read from. Defaults to `[process.cwd()]`; pass
505
+ * `[]` to deny every local path (URLs only).
506
+ */
507
+ roots?: readonly string[];
508
+ }
509
+
510
+ /**
511
+ * Builds the model-callable document converter. The `destination` is chosen
512
+ * by the model, so it is always treated as untrusted: local paths must sit
513
+ * inside `roots`, and remote URLs may not target private/internal hosts.
514
+ *
515
+ * @example `const tool = createConvertDocumentTool({ roots: ["./docs"] });`
516
+ */
517
+ export function createConvertDocumentTool(options: ConvertDocumentToolOptions = {}): ToolDefinition {
518
+ return tool({
519
+ name: "convert_document_to_markdown",
520
+ description:
521
+ "Convert a document file to Markdown text. Call this when the user provides a document file path or URL, or asks about the contents of a document. " +
522
+ "Supported formats: PDF (.pdf), Word (.doc, .docx, .docm), PowerPoint (.ppt, .pps, .pot, .pptx, .pptm, .ppsx, .ppsm), " +
523
+ "Excel (.xls, .xlsx, .xlsm, .xlsb), OpenDocument (.odt, .ods, .odp), RTF (.rtf), EPUB (.epub), CSV (.csv). " +
524
+ "Returns the content wrapped in <Document name destination> tags. Do not use for images, audio, or video.",
525
+ input: z.object({
526
+ destination: z
527
+ .string()
528
+ .min(1)
529
+ .describe("Local file path or http(s) URL of the document to convert."),
530
+ format: z
531
+ .string()
532
+ .optional()
533
+ .describe(
534
+ "Explicit format override, e.g. 'pdf', 'docx', 'xlsx', 'csv'. Inferred from the destination when omitted."
535
+ ),
536
+ maxChars: z
537
+ .number()
538
+ .int()
539
+ .positive()
540
+ .optional()
541
+ .describe("Maximum Markdown characters to return (default 100000). Excess is truncated with a marker."),
542
+ }),
543
+ timeoutMs: 60_000,
544
+ execute: async ({ destination, format, maxChars }, ctx) => {
545
+ const dest = String(destination ?? "").trim();
546
+ if (!dest) {
547
+ return "Error: convert_document_to_markdown needs a non-empty destination (local file path or http(s) URL).";
548
+ }
549
+ try {
550
+ const doc = await convertDocumentInput(dest, {
551
+ format,
552
+ maxChars,
553
+ signal: ctx?.signal,
554
+ untrusted: true,
555
+ roots: options.roots ?? [process.cwd()],
556
+ });
557
+ return buildDocumentXml(doc.name, dest, doc.markdown);
558
+ } catch (err) {
559
+ const detail = err instanceof Error ? err.message : String(err);
560
+ return `Error: ${detail}`;
561
+ }
562
+ },
563
+ });
564
+ }
565
+
469
566
  /**
470
- * Built-in model-callable document converter. Register it and move on:
567
+ * Built-in model-callable document converter with the default policy (local
568
+ * paths limited to the working directory, no private-network URLs). Use
569
+ * {@link createConvertDocumentTool} to choose other roots.
471
570
  *
472
571
  * @example
473
572
  * ```ts
@@ -475,43 +574,4 @@ export async function preprocessFilePartsForBypass(
475
574
  * const agent = new Agent({ model, tools: { convert_document_to_markdown } });
476
575
  * ```
477
576
  */
478
- export const convert_document_to_markdown: ToolDefinition = tool({
479
- name: "convert_document_to_markdown",
480
- description:
481
- "Convert a document file to Markdown text. Call this when the user provides a document file path or URL, or asks about the contents of a document. " +
482
- "Supported formats: PDF (.pdf), Word (.doc, .docx, .docm), PowerPoint (.ppt, .pps, .pot, .pptx, .pptm, .ppsx, .ppsm), " +
483
- "Excel (.xls, .xlsx, .xlsm, .xlsb), OpenDocument (.odt, .ods, .odp), RTF (.rtf), EPUB (.epub), CSV (.csv). " +
484
- "Returns the content wrapped in <Document name destination> tags. Do not use for images, audio, or video.",
485
- input: z.object({
486
- destination: z
487
- .string()
488
- .min(1)
489
- .describe("Local file path or http(s) URL of the document to convert."),
490
- format: z
491
- .string()
492
- .optional()
493
- .describe(
494
- "Explicit format override, e.g. 'pdf', 'docx', 'xlsx', 'csv'. Inferred from the destination when omitted."
495
- ),
496
- maxChars: z
497
- .number()
498
- .int()
499
- .positive()
500
- .optional()
501
- .describe("Maximum Markdown characters to return (default 100000). Excess is truncated with a marker."),
502
- }),
503
- timeoutMs: 60_000,
504
- execute: async ({ destination, format, maxChars }, ctx) => {
505
- const dest = String(destination ?? "").trim();
506
- if (!dest) {
507
- return "Error: convert_document_to_markdown needs a non-empty destination (local file path or http(s) URL).";
508
- }
509
- try {
510
- const doc = await convertDocumentInput(dest, { format, maxChars, signal: ctx?.signal });
511
- return buildDocumentXml(doc.name, dest, doc.markdown);
512
- } catch (err) {
513
- const detail = err instanceof Error ? err.message : String(err);
514
- return `Error: ${detail}`;
515
- }
516
- },
517
- });
577
+ export const convert_document_to_markdown: ToolDefinition = createConvertDocumentTool();
@@ -86,6 +86,17 @@ export function toConciseProviderError(error: unknown, providerId: string, model
86
86
  ...(typeof err?.errorType === "string"
87
87
  ? { errorType: { value: err.errorType, enumerable: false } }
88
88
  : {}),
89
+ // Forwarded when present (e.g. Google's `not_found` chain-eviction code,
90
+ // which carries no HTTP status on SSE error frames). Survives re-wraps
91
+ // so chain-expiry detectors never lose it.
92
+ ...((typeof err?.code === "string" || typeof err?.code === "number") && err.code !== ""
93
+ ? { code: { value: err.code, enumerable: false } }
94
+ : {}),
95
+ // Server rate-limit hint in ms (from Retry-After); survives re-wraps so
96
+ // `withRetries` can honor it instead of hammering a 429.
97
+ ...(typeof err?.retryAfterMs === "number" && Number.isFinite(err.retryAfterMs)
98
+ ? { retryAfterMs: { value: err.retryAfterMs, enumerable: false } }
99
+ : {}),
89
100
  ...(typeof (err as { raw?: unknown })?.raw === "string"
90
101
  ? { raw: { value: (err as { raw: string }).raw.slice(0, 500), enumerable: false } }
91
102
  : {}),
@@ -24,7 +24,7 @@ export function getPackageVersion(): string {
24
24
  const v = (globalThis as any).process?.env?.npm_package_version;
25
25
  if (typeof v === "string" && v) return v;
26
26
  } catch {}
27
- return "0.4.5";
27
+ return "0.5.0";
28
28
  }
29
29
 
30
30
  function getAgentAccelUserAgent(): string {
@@ -55,9 +55,9 @@ export interface AttributionConfig {
55
55
  let attribution: AttributionConfig = {};
56
56
 
57
57
  /**
58
- * Configures global attribution headers. Defaults to none (no hardcoded
59
- * site); OpenRouter keeps its legacy `sashvat.com` / `Agent Accelerator`
60
- * fallback when no custom attribution is set (backward compat).
58
+ * Configures global attribution headers (OpenRouter `HTTP-Referer` / `X-Title`).
59
+ * Defaults to none: nothing is sent unless you call this, so your traffic is
60
+ * never credited to a site or app you did not choose.
61
61
  * @example `setAttribution({ referer: "https://example.com", title: "My App" });`
62
62
  */
63
63
  export function setAttribution(cfg: AttributionConfig): void {
@@ -151,9 +151,8 @@ function stripForBrowser(headers: Record<string, string>): Record<string, string
151
151
  /**
152
152
  * Builds provider-specific session, cache-affinity, and attribution headers.
153
153
  *
154
- * Q-17: attribution is configurable via `setAttribution()` (default none).
155
- * OpenRouter keeps its legacy `https://sashvat.com` / `Agent Accelerator`
156
- * fallback when no custom attribution is set. Custom (non-first-class)
154
+ * Q-17: attribution is configurable via `setAttribution()` (default none; no
155
+ * referer/title is ever sent unless you set one). Custom (non-first-class)
157
156
  * prefixes receive minimal `x-session-id`/`x-client-request-id` affinity only
158
157
  * (no `session_id` alias, no attribution) when a session id is present.
159
158
  * `customHeaders` merge case-insensitively against canonical names.
@@ -193,14 +192,18 @@ export function buildSessionHeaders(
193
192
  return key !== undefined ? headers[key] : undefined;
194
193
  };
195
194
 
195
+ // OpenRouter app attribution is strictly opt-in (`setAttribution`): by default
196
+ // a user's traffic is not credited to any third-party app or site.
197
+ const applyOpenRouterAttribution = (): void => {
198
+ if (attr.referer !== undefined && getHeader("HTTP-Referer") === undefined) headers["HTTP-Referer"] = attr.referer;
199
+ if (attr.title !== undefined && getHeader("X-Title") === undefined) headers["X-Title"] = attr.title;
200
+ };
201
+
196
202
  if (sessionId && !browser) {
197
203
  if (provider === "openrouter") {
198
204
  setHeaderCaseInsensitive(headers, "x-session-id", sessionId);
199
205
  setHeaderCaseInsensitive(headers, "x-client-request-id", sessionId);
200
- const referer = attr.referer ?? "https://sashvat.com";
201
- const title = attr.title ?? "Agent Accelerator";
202
- if (getHeader("HTTP-Referer") === undefined) headers["HTTP-Referer"] = referer;
203
- if (getHeader("X-Title") === undefined) headers["X-Title"] = title;
206
+ applyOpenRouterAttribution();
204
207
  } else if (provider === "google") {
205
208
  if (getHeader("x-goog-api-client") === undefined) {
206
209
  headers["x-goog-api-client"] = "agent-accel/1.0";
@@ -219,10 +222,7 @@ export function buildSessionHeaders(
219
222
  setHeaderCaseInsensitive(headers, "x-client-request-id", sessionId);
220
223
  }
221
224
  } else if (provider === "openrouter") {
222
- const referer = attr.referer ?? "https://sashvat.com";
223
- const title = attr.title ?? "Agent Accelerator";
224
- if (getHeader("HTTP-Referer") === undefined) headers["HTTP-Referer"] = referer;
225
- if (getHeader("X-Title") === undefined) headers["X-Title"] = title;
225
+ applyOpenRouterAttribution();
226
226
  }
227
227
 
228
228
  if (provider === "google" && !browser && getHeader("x-goog-api-client") === undefined) {
@@ -0,0 +1,30 @@
1
+ import * as path from "node:path";
2
+ import { getEnv } from "./env.ts";
3
+
4
+ /**
5
+ * The Agent Accelerator home: ONE folder that holds everything the SDK stores
6
+ * (`sessions/`, `scheduled_actions/`, `telemetry/`, `catalog/`), so nothing is
7
+ * scattered across the project or the package.
8
+ */
9
+ let defaultHome: string | undefined;
10
+ const listeners = new Set<() => void>();
11
+
12
+ /**
13
+ * Sets where the home lives for every Agent created afterwards, so nothing lands
14
+ * in the working directory unless you want it there. Call once at startup;
15
+ * `setHome()` clears it.
16
+ */
17
+ export function setHome(dir?: string): void {
18
+ defaultHome = dir ? path.resolve(dir) : undefined;
19
+ for (const fn of listeners) fn();
20
+ }
21
+
22
+ /** The home folder: explicit `home`, else `setHome()`, else `AGENT_ACCEL_HOME`, else `.agent-accel/` in the working directory. */
23
+ export function resolveHome(home?: string): string {
24
+ return path.resolve(home || defaultHome || getEnv("AGENT_ACCEL_HOME") || ".agent-accel");
25
+ }
26
+
27
+ /** Runs `fn` whenever `setHome` changes the default (the catalog cache reloads from the new place). */
28
+ export function onHomeChange(fn: () => void): void {
29
+ listeners.add(fn);
30
+ }
@@ -1,6 +1,7 @@
1
1
  // NOTE: no static node:fs import — it breaks browser bundles. Local file
2
2
  // paths are loaded via a lazy dynamic import so browsers never resolve it.
3
- import { bytesToBase64 } from "./base64.ts";
3
+ import { bytesToBase64, base64ToBytes } from "./base64.ts";
4
+ import { fetchRemoteBytes } from "./safe-io.ts";
4
5
 
5
6
  export interface NormalizedMedia {
6
7
  mimeType: string;
@@ -32,6 +33,51 @@ export function inferMimeType(input: string, fallback = "application/octet-strea
32
33
  return fallback;
33
34
  }
34
35
 
36
+ const ascii = (bytes: Uint8Array, start: number, text: string): boolean =>
37
+ bytes.length >= start + text.length && [...text].every((ch, i) => bytes[start + i] === ch.charCodeAt(0));
38
+
39
+ /**
40
+ * Identifies common image/audio/video/document formats from their magic bytes.
41
+ * Used when no MIME type was supplied (raw bytes, base64, extension-less or
42
+ * `application/octet-stream` downloads) so providers receive a real type
43
+ * instead of a generic one they would reject.
44
+ *
45
+ * @example `sniffMimeType(pngBytes) // "image/png"`
46
+ */
47
+ export function sniffMimeType(bytes: Uint8Array): string | undefined {
48
+ if (bytes.length < 4) return undefined;
49
+ if (bytes[0] === 0x89 && ascii(bytes, 1, "PNG")) return "image/png";
50
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
51
+ if (ascii(bytes, 0, "GIF8")) return "image/gif";
52
+ if (ascii(bytes, 0, "RIFF") && ascii(bytes, 8, "WEBP")) return "image/webp";
53
+ if (ascii(bytes, 0, "RIFF") && ascii(bytes, 8, "WAVE")) return "audio/wav";
54
+ if (ascii(bytes, 0, "%PDF")) return "application/pdf";
55
+ if (ascii(bytes, 0, "OggS")) return "audio/ogg";
56
+ if (ascii(bytes, 0, "ID3") || (bytes[0] === 0xff && ((bytes[1] ?? 0) & 0xe0) === 0xe0)) return "audio/mp3";
57
+ if (ascii(bytes, 4, "ftyp")) return ascii(bytes, 8, "qt") ? "video/quicktime" : "video/mp4";
58
+ if (bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) return "video/webm";
59
+ return undefined;
60
+ }
61
+
62
+ const GENERIC_MIME = new Set(["", "application/octet-stream", "binary/octet-stream"]);
63
+
64
+ /** Picks the best MIME type: explicit hint, then a specific candidate, then magic bytes, then the fallback. */
65
+ function resolveMime(explicit: string | undefined, candidate: string | null | undefined, bytes: Uint8Array, fallback: string): string {
66
+ if (explicit) return explicit;
67
+ const specific = candidate && !GENERIC_MIME.has(candidate.split(";")[0]!.trim().toLowerCase()) ? candidate : undefined;
68
+ return specific || sniffMimeType(bytes) || candidate || fallback;
69
+ }
70
+
71
+ /** Magic-byte sniff of a base64 payload from its first bytes only (no full decode). */
72
+ function sniffBase64(b64: string): string | undefined {
73
+ try {
74
+ const head = b64.replace(/\s/g, "").slice(0, 32);
75
+ return sniffMimeType(base64ToBytes(head.slice(0, head.length - (head.length % 4))));
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
35
81
  /**
36
82
  * Normalizes an image, audio, or video input into raw base64 and data URL
37
83
  */
@@ -47,7 +93,7 @@ export async function normalizeMediaInput(
47
93
  // 1. If input is ArrayBuffer or Uint8Array
48
94
  if (input instanceof Uint8Array || input instanceof ArrayBuffer) {
49
95
  const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
50
- const mimeType = explicitMimeType || "application/octet-stream";
96
+ const mimeType = resolveMime(explicitMimeType, undefined, bytes, "application/octet-stream");
51
97
  const base64Data = bytesToBase64(bytes);
52
98
  return {
53
99
  mimeType,
@@ -70,16 +116,14 @@ export async function normalizeMediaInput(
70
116
 
71
117
  // 3. If input is a remote HTTP URL (check before base64 to avoid misclassifying URL as base64)
72
118
  if (typeof input === "string" && (input.startsWith("http://") || input.startsWith("https://"))) {
73
- const response = await fetch(input);
119
+ // Developer-supplied URL: private hosts are allowed (intranet assets),
120
+ // but redirects are re-checked and the download is size-capped.
121
+ const response = await fetchRemoteBytes(input, { allowPrivateNetwork: true });
74
122
  if (!response.ok) {
75
123
  throw new Error(`Failed to fetch media from URL: ${input} (${response.statusText})`);
76
124
  }
77
- const arrayBuffer = await response.arrayBuffer();
78
- const buffer = new Uint8Array(arrayBuffer);
79
- const mimeType =
80
- explicitMimeType ||
81
- response.headers.get("content-type") ||
82
- inferMimeType(input);
125
+ const buffer = response.bytes;
126
+ const mimeType = resolveMime(explicitMimeType, response.contentType, buffer, inferMimeType(input));
83
127
  const base64Data = bytesToBase64(buffer);
84
128
  return {
85
129
  mimeType,
@@ -100,7 +144,7 @@ export async function normalizeMediaInput(
100
144
  /^[A-Za-z0-9+/=\n\r]+$/.test(input.slice(0, 200)) &&
101
145
  input.length % 4 === 0
102
146
  ) {
103
- const mimeType = explicitMimeType || "image/jpeg";
147
+ const mimeType = explicitMimeType || sniffBase64(input) || "image/jpeg";
104
148
  return {
105
149
  mimeType,
106
150
  base64Data: input.replace(/\s/g, ""),
@@ -113,7 +157,8 @@ export async function normalizeMediaInput(
113
157
  try {
114
158
  const { promises: fs } = await import("node:fs");
115
159
  const buffer = await fs.readFile(input);
116
- const mimeType = explicitMimeType || inferMimeType(input);
160
+ const byExtension = inferMimeType(input, "");
161
+ const mimeType = resolveMime(explicitMimeType, byExtension, buffer, "application/octet-stream");
117
162
  const base64Data = bytesToBase64(buffer);
118
163
  return {
119
164
  mimeType,
@@ -125,7 +170,7 @@ export async function normalizeMediaInput(
125
170
  const trimmed = input.replace(/\s/g, "");
126
171
  const isMaybeBase64 = trimmed.length > 100 && /^[A-Za-z0-9+/=\n\r]+$/.test(trimmed.slice(0, 500)) && trimmed.length % 4 === 0;
127
172
  if (isMaybeBase64) {
128
- const mimeType = explicitMimeType || "image/jpeg";
173
+ const mimeType = explicitMimeType || sniffBase64(trimmed) || "image/jpeg";
129
174
  return {
130
175
  mimeType,
131
176
  base64Data: trimmed,
@@ -0,0 +1,76 @@
1
+ import type { ProviderRawData } from "../types/model.ts";
2
+
3
+ /** How much of the wire exchange `AgentResponse.raw` keeps (see `AgentConfig.captureRaw`). */
4
+ export type CaptureRawMode = false | "redacted" | true;
5
+
6
+ function byteLength(value: unknown): number {
7
+ try {
8
+ return JSON.stringify(value)?.length ?? 0;
9
+ } catch {
10
+ return 0;
11
+ }
12
+ }
13
+
14
+ function toolNames(tools: unknown): string[] | undefined {
15
+ if (!Array.isArray(tools)) return undefined;
16
+ const names = tools
17
+ .map((t) => (t as { name?: unknown; function?: { name?: unknown } })?.name ?? (t as { function?: { name?: unknown } })?.function?.name)
18
+ .filter((n): n is string => typeof n === "string");
19
+ return names.length > 0 ? names : undefined;
20
+ }
21
+
22
+ /** Body summary that keeps the audit-useful shape and none of the content. */
23
+ function summarizeRequestBody(body: unknown): unknown {
24
+ if (body === null || body === undefined) return body;
25
+ const b = (typeof body === "object" ? body : {}) as Record<string, unknown>;
26
+ const items = Array.isArray(b["messages"]) ? b["messages"] : Array.isArray(b["input"]) ? b["input"] : undefined;
27
+ return {
28
+ redacted: true,
29
+ bytes: byteLength(body),
30
+ ...(typeof b["model"] === "string" ? { model: b["model"] } : {}),
31
+ ...(items ? { items: items.length } : {}),
32
+ ...(toolNames(b["tools"]) ? { tools: toolNames(b["tools"]) } : {}),
33
+ ...(b["stream"] === true ? { stream: true } : {}),
34
+ };
35
+ }
36
+
37
+ function summarizeResponseBody(body: unknown): unknown {
38
+ if (body === null || body === undefined) return body;
39
+ const b = (typeof body === "object" ? body : {}) as Record<string, unknown>;
40
+ return {
41
+ redacted: true,
42
+ bytes: byteLength(body),
43
+ ...(typeof b["id"] === "string" ? { id: b["id"] } : {}),
44
+ ...(typeof b["status"] === "string" ? { status: b["status"] } : {}),
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Applies the `captureRaw` policy to a provider audit record.
50
+ * - `true`: keep everything (headers are always redacted by the adapters).
51
+ * - `"redacted"` (default): keep URL, method, headers and status, but replace
52
+ * request/response BODIES with a content-free summary. Prompts, files and
53
+ * model output never leak through `response.raw` / `toJSON()` into logs.
54
+ * - `false`: keep only URL, method and HTTP status.
55
+ */
56
+ export function applyCaptureRaw(raw: ProviderRawData | undefined, mode: CaptureRawMode | undefined): ProviderRawData | undefined {
57
+ if (!raw || mode === true) return raw;
58
+ const redactedMode = mode !== false; // undefined -> default "redacted"
59
+ const request = raw.request
60
+ ? {
61
+ url: raw.request.url,
62
+ method: raw.request.method,
63
+ headers: redactedMode ? raw.request.headers : {},
64
+ body: redactedMode ? summarizeRequestBody(raw.request.body) : null,
65
+ }
66
+ : raw.request;
67
+ const response = raw.response
68
+ ? {
69
+ status: raw.response.status,
70
+ statusText: raw.response.statusText,
71
+ headers: redactedMode ? raw.response.headers : {},
72
+ body: redactedMode ? summarizeResponseBody(raw.response.body) : null,
73
+ }
74
+ : undefined;
75
+ return { request, ...(response ? { response } : {}) } as ProviderRawData;
76
+ }
@@ -31,8 +31,10 @@ export function isTransientError(error: unknown): boolean {
31
31
 
32
32
  function readRetryAfterMs(error: unknown): number | undefined {
33
33
  const err = error as Record<string, unknown>;
34
+ // Preferred: milliseconds attached by the adapter from the Retry-After header.
35
+ const direct = err["retryAfterMs"];
36
+ if (typeof direct === "number" && Number.isFinite(direct) && direct >= 0) return Math.min(60_000, direct);
34
37
  const raw =
35
- (err["retryAfterMs"] as unknown) ??
36
38
  ((err["response"] as Record<string, unknown> | undefined)?.["headers"] as Record<string, unknown> | undefined)?.["retry-after"] ??
37
39
  ((err["headers"] as Record<string, unknown> | undefined)?.["retry-after"]);
38
40
  if (raw === undefined) return undefined;
@@ -73,11 +75,13 @@ export async function withRetries<T>(
73
75
  signal?: AbortSignal;
74
76
  label?: { providerId?: string; modelId?: string };
75
77
  retryBudgetMs?: number;
78
+ baseDelayMs?: number;
76
79
  }
77
80
  ): Promise<T> {
78
81
  const maxRetries = options?.maxRetries ?? 2;
79
82
  const maxDelay = options?.maxRetryDelayMs ?? 5000;
80
83
  const budgetMs = options?.retryBudgetMs ?? 30_000;
84
+ const baseDelay = options?.baseDelayMs ?? 250;
81
85
  const started = Date.now();
82
86
  const fail = (error: unknown): never => {
83
87
  if (options?.label?.providerId && options?.label?.modelId) {
@@ -94,8 +98,12 @@ export async function withRetries<T>(
94
98
  if (attempt >= maxRetries || !isTransientError(error)) fail(error);
95
99
  if (Date.now() - started >= budgetMs) fail(error);
96
100
  const retryAfter = readRetryAfterMs(error);
97
- const backoff = Math.min(maxDelay, 250 * 2 ** attempt + Math.random() * 100);
98
- const delay = retryAfter !== undefined ? Math.min(maxDelay, Math.max(backoff, retryAfter)) : backoff;
101
+ const backoff = Math.min(maxDelay, baseDelay * 2 ** attempt + Math.random() * 100);
102
+ // A server-provided Retry-After is honored in full (it is the provider's
103
+ // own instruction), but never past the retry budget: waiting that long is
104
+ // pointless, so surface the 429 now instead of sleeping and failing anyway.
105
+ const delay = retryAfter !== undefined ? Math.max(backoff, retryAfter) : backoff;
106
+ if (retryAfter !== undefined && Date.now() - started + delay > budgetMs) fail(error);
99
107
  attempt += 1;
100
108
  await sleep(delay, options?.signal);
101
109
  }