agent-accelerator 0.1.0 → 0.2.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 +83 -112
  2. package/{SYSTEM_PROMPT_AGENT.md → examples/prompts/SYSTEM_PROMPT_AGENT.md} +1 -1
  3. package/package.json +14 -9
  4. package/src/agent/agent.ts +191 -71
  5. package/src/agent/context.ts +1 -0
  6. package/src/agent/delegation.ts +61 -12
  7. package/src/agent/loop.ts +71 -48
  8. package/src/data/README.md +6 -6
  9. package/src/index.ts +130 -45
  10. package/src/models/catalog-cache.ts +60 -9
  11. package/src/models/catalog.ts +53 -7
  12. package/src/providers/google.ts +926 -0
  13. package/src/providers/openai-compat.ts +1147 -0
  14. package/src/providers/openai.ts +959 -0
  15. package/src/providers/openrouter-responses.ts +949 -0
  16. package/src/providers/openrouter.ts +1037 -0
  17. package/src/{ai-sdk → providers}/registry.ts +43 -55
  18. package/src/providers.ts +490 -0
  19. package/src/streaming/sse-parser.ts +6 -4
  20. package/src/tools/executor.ts +19 -6
  21. package/src/tools/schema.ts +21 -11
  22. package/src/types/agent.ts +8 -1
  23. package/src/types/core.ts +1 -1
  24. package/src/types/message.ts +5 -0
  25. package/src/types/model.ts +3 -7
  26. package/src/types/provider-payloads.ts +2 -84
  27. package/src/types/tool.ts +6 -0
  28. package/src/update-models.ts +56 -0
  29. package/src/utils/cache.ts +1 -1
  30. package/src/utils/documents.ts +517 -0
  31. package/src/utils/env.ts +0 -7
  32. package/src/{ai-sdk → utils}/errors.ts +61 -2
  33. package/src/utils/headers.ts +10 -20
  34. package/src/utils/media.ts +5 -2
  35. package/src/utils/retry.ts +89 -0
  36. package/src/utils/serialization.ts +15 -0
  37. package/src/ai-sdk/converters.ts +0 -342
  38. package/src/ai-sdk/executor.ts +0 -454
  39. package/src/ai-sdk/index.ts +0 -55
  40. package/src/ai-sdk/model-provider.ts +0 -303
  41. package/src/ai-sdk/options.ts +0 -306
  42. package/src/ai-sdk/provider.ts +0 -415
  43. package/src/tokens/counter.ts +0 -136
  44. /package/{SYSTEM_PROMPT.md → examples/prompts/SYSTEM_PROMPT.md} +0 -0
  45. /package/{SYSTEM_PROMPT_TOOLS.md → examples/prompts/SYSTEM_PROMPT_TOOLS.md} +0 -0
@@ -0,0 +1,517 @@
1
+ import { z } from "zod";
2
+ import { tool } from "../tools/tool.ts";
3
+ import type { ToolDefinition } from "../types/tool.ts";
4
+ import type { Message } from "../types/message.ts";
5
+ import { normalizeMediaInput } from "./media.ts";
6
+ import { base64ToBytes } from "./base64.ts";
7
+ import { escapeXml } from "./serialization.ts";
8
+ import { getModelFromCatalog } from "../models/catalog.ts";
9
+
10
+ /**
11
+ * Client-side document conversion (anydoc engine).
12
+ *
13
+ * Converts PDF, Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, and CSV
14
+ * inputs into Markdown text, so models WITHOUT native document parsing can
15
+ * still read them. Models WITH native support should keep using it — this is
16
+ * an explicit opt-in escape hatch, never an automatic fallback.
17
+ *
18
+ * The `@firecrawl/anydoc` package is an OPTIONAL peer dependency and is only
19
+ * ever loaded via dynamic import, so installs without it (and browser bundles)
20
+ * are unaffected. Importing this module alone never touches native code.
21
+ */
22
+
23
+ /** Default cap for converted Markdown length (chars) — bounds context usage. */
24
+ export const DEFAULT_DOCUMENT_MAX_CHARS = 100_000;
25
+
26
+ /** Maximum bytes fetched from a remote document URL. */
27
+ export const MAX_DOCUMENT_FETCH_BYTES = 50_000_000;
28
+
29
+ const EXTENSION_TO_FORMAT: Record<string, string> = {
30
+ pdf: "pdf",
31
+ doc: "doc",
32
+ docx: "docx",
33
+ docm: "docm",
34
+ ppt: "ppt",
35
+ pps: "pps",
36
+ pot: "pot",
37
+ pptx: "pptx",
38
+ pptm: "pptm",
39
+ ppsx: "ppsx",
40
+ ppsm: "ppsm",
41
+ xls: "xls",
42
+ xlsx: "xlsx",
43
+ xlsm: "xlsm",
44
+ xlsb: "xlsb",
45
+ odt: "odt",
46
+ ods: "ods",
47
+ odp: "odp",
48
+ rtf: "rtf",
49
+ epub: "epub",
50
+ csv: "csv",
51
+ };
52
+
53
+ const MIME_TO_FORMAT: Record<string, string> = {
54
+ "application/pdf": "pdf",
55
+ "text/csv": "csv",
56
+ "application/csv": "csv",
57
+ "application/msword": "doc",
58
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
59
+ "application/vnd.ms-excel": "xls",
60
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
61
+ "application/vnd.ms-powerpoint": "ppt",
62
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
63
+ "application/rtf": "rtf",
64
+ "text/rtf": "rtf",
65
+ "application/epub+zip": "epub",
66
+ "application/vnd.oasis.opendocument.text": "odt",
67
+ "application/vnd.oasis.opendocument.spreadsheet": "ods",
68
+ "application/vnd.oasis.opendocument.presentation": "odp",
69
+ };
70
+
71
+ /** Thrown when a document cannot be converted (anydoc codes pass through). */
72
+ export class DocumentConversionError extends Error {
73
+ readonly code: string;
74
+ readonly file?: string;
75
+
76
+ /**
77
+ * Creates a conversion failure carrying the engine's error code.
78
+ *
79
+ * @param code anydoc `ConvertErrorCode` (`needsOcr`, `encrypted`, …) or
80
+ * `missing_dependency` / `io` / `conversion_failed` for wrapper-level faults.
81
+ * @param message Human-readable one-liner (already prefixed).
82
+ * @param file Optional file label (path, URL, or display name).
83
+ */
84
+ constructor(code: string, message: string, file?: string) {
85
+ super(message);
86
+ this.name = "DocumentConversionError";
87
+ this.code = code;
88
+ if (file !== undefined) this.file = file;
89
+ if (Error.captureStackTrace) {
90
+ Error.captureStackTrace(this, DocumentConversionError);
91
+ }
92
+ }
93
+ }
94
+
95
+ /** Options accepted by {@link convertDocumentToMarkdown} / {@link convertDocumentInput}. */
96
+ export interface ConvertDocumentOptions {
97
+ /** Display name / format hint (e.g. `"data.csv"`). Required for extension-less bytes. */
98
+ filename?: string;
99
+ /** Explicit format override (`"pdf" | "docx" | "xlsx" | "csv" | …`). Wins over inference. */
100
+ format?: string;
101
+ /** MIME hint used when no filename is available. */
102
+ mimeType?: string;
103
+ /** Maximum Markdown chars returned (default {@link DEFAULT_DOCUMENT_MAX_CHARS}). */
104
+ maxChars?: number;
105
+ /** Abort signal honored by the remote-fetch leg. */
106
+ signal?: AbortSignal;
107
+ }
108
+
109
+ /** Normalized conversion result (pre-XML-envelope). */
110
+ export interface ConvertedDocument {
111
+ markdown: string;
112
+ name: string;
113
+ format: string;
114
+ truncated: boolean;
115
+ }
116
+
117
+ function missingDependencyError(): DocumentConversionError {
118
+ return new DocumentConversionError(
119
+ "missing_dependency",
120
+ "[Agent Accelerator] Document conversion needs the optional peer '@firecrawl/anydoc'. Install it with: bun add @firecrawl/anydoc"
121
+ );
122
+ }
123
+
124
+ type AnyDocModule = typeof import("@firecrawl/anydoc");
125
+
126
+ let anydocModule: AnyDocModule | undefined;
127
+ let anydocMissing = false;
128
+
129
+ /** Loads the optional anydoc engine (dynamic import only — never bundled). */
130
+ async function loadAnydoc(): Promise<AnyDocModule> {
131
+ if (anydocModule) return anydocModule;
132
+ if (anydocMissing) throw missingDependencyError();
133
+ try {
134
+ anydocModule = await import("@firecrawl/anydoc");
135
+ return anydocModule;
136
+ } catch {
137
+ anydocMissing = true;
138
+ throw missingDependencyError();
139
+ }
140
+ }
141
+
142
+ /**
143
+ * True when the optional conversion engine can be loaded.
144
+ *
145
+ * @example `if (await isAnydocAvailable()) { … }`
146
+ */
147
+ export async function isAnydocAvailable(): Promise<boolean> {
148
+ if (anydocModule) return true;
149
+ try {
150
+ await import("@firecrawl/anydoc");
151
+ return true;
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Resolves an anydoc format name from explicit, filename, or MIME hints.
159
+ *
160
+ * @example `resolveDocumentFormat({ filename: "data.csv" }) // "csv"`
161
+ */
162
+ export function resolveDocumentFormat(opts: {
163
+ filename?: string;
164
+ format?: string;
165
+ mimeType?: string;
166
+ }): string | undefined {
167
+ if (opts.format && opts.format.trim()) return opts.format.trim().toLowerCase();
168
+ if (opts.filename) {
169
+ const clean = opts.filename.split("?")[0]!.split("#")[0]!;
170
+ const ext = clean.split(".").pop()?.toLowerCase();
171
+ if (ext) {
172
+ const mapped = EXTENSION_TO_FORMAT[ext];
173
+ if (mapped) return mapped;
174
+ }
175
+ }
176
+ if (opts.mimeType) {
177
+ const mime = opts.mimeType.split(";")[0]!.trim().toLowerCase();
178
+ const mapped = MIME_TO_FORMAT[mime];
179
+ if (mapped) return mapped;
180
+ }
181
+ return undefined;
182
+ }
183
+
184
+ /**
185
+ * Truncates converted Markdown to a char budget with a visible marker.
186
+ *
187
+ * @example `truncateMarkdown(md, 1000)`
188
+ */
189
+ export function truncateMarkdown(markdown: string, maxChars?: number): { text: string; truncated: boolean } {
190
+ const limit = maxChars && maxChars > 0 ? Math.floor(maxChars) : DEFAULT_DOCUMENT_MAX_CHARS;
191
+ if (markdown.length <= limit) return { text: markdown, truncated: false };
192
+ return {
193
+ text:
194
+ `${markdown.slice(0, limit)}\n\n[Truncated: showing ${limit} of ${markdown.length} chars. Pass a larger maxChars to read more.]`,
195
+ truncated: true,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Wraps converted Markdown in the canonical `<Document>` envelope.
201
+ *
202
+ * @example `buildDocumentXml("report.pdf", "./report.pdf", md)`
203
+ */
204
+ export function buildDocumentXml(name: string, destination: string, markdown: string): string {
205
+ return `<Document name="${escapeXml(name)}" destination="${escapeXml(destination)}">\n${escapeXml(markdown)}\n</Document>`;
206
+ }
207
+
208
+ /** Wire format accepted by `toMarkdownBytes` (the engine's `Format` enum, by query). */
209
+ type AnyDocWireFormat = Parameters<AnyDocModule["toMarkdownBytes"]>[1];
210
+
211
+ /**
212
+ * Canonicalizes an inferred format name through the engine
213
+ * (`pptm` → `pptx`, `.csv` → `csv`). Unknown names pass through untouched —
214
+ * the engine validates and rejects with a coded error.
215
+ */
216
+ function canonicalizeFormat(
217
+ anydoc: AnyDocModule,
218
+ name: string | undefined
219
+ ): Exclude<AnyDocWireFormat, undefined> {
220
+ if (!name) return null;
221
+ try {
222
+ const canonical: unknown = anydoc.formatFromExtension(name.startsWith(".") ? name : `.${name}`);
223
+ if (typeof canonical === "string" && canonical) {
224
+ return canonical as Exclude<AnyDocWireFormat, undefined>;
225
+ }
226
+ } catch {
227
+ // fall through to the raw name
228
+ }
229
+ return name as unknown as Exclude<AnyDocWireFormat, undefined>;
230
+ }
231
+
232
+ function isHttpUrl(value: string): boolean {
233
+ return value.startsWith("http://") || value.startsWith("https://");
234
+ }
235
+
236
+ function isDataUrl(value: string): boolean {
237
+ return value.startsWith("data:");
238
+ }
239
+
240
+ /** True for plausible local paths (excludes URLs, data URLs, and raw base64). */
241
+ function looksLikePath(value: string): boolean {
242
+ if (!value || value.length > 4096) return false;
243
+ if (isHttpUrl(value) || isDataUrl(value)) return false;
244
+ if (value.includes("\n") || value.includes("\0")) return false;
245
+ if (/^[A-Za-z0-9+/=\s]+$/.test(value) && value.length > 100 && value.length % 4 === 0) return false;
246
+ return true;
247
+ }
248
+
249
+ function basenameOf(value: string): string {
250
+ const clean = value.split("?")[0]!.split("#")[0]!;
251
+ const parts = clean.split(/[\\/]/);
252
+ return parts.pop() || clean;
253
+ }
254
+
255
+ function toDocumentConversionError(err: unknown, file?: string): DocumentConversionError {
256
+ if (err instanceof DocumentConversionError) return err;
257
+ const code = (err as { code?: unknown })?.code;
258
+ const detail = err instanceof Error ? err.message : String(err);
259
+ const where = file ? ` "${file}"` : "";
260
+ if (typeof code === "string" && code) {
261
+ const hints: Record<string, string> = {
262
+ needsOcr: " The document has scanned/image-only pages, which need OCR (not enabled).",
263
+ encrypted: " The document is password-protected.",
264
+ unsupported:
265
+ " Pass an explicit format (e.g. format: \"csv\") or filename so the format can be determined.",
266
+ };
267
+ const hint = hints[code] ?? "";
268
+ return new DocumentConversionError(
269
+ code,
270
+ `[Agent Accelerator] Could not convert document${where} (${code}): ${detail}.${hint}`,
271
+ file
272
+ );
273
+ }
274
+ return new DocumentConversionError(
275
+ "conversion_failed",
276
+ `[Agent Accelerator] Could not convert document${where}: ${detail}.`,
277
+ file
278
+ );
279
+ }
280
+
281
+ /**
282
+ * Converts a document destination to normalized Markdown + metadata.
283
+ *
284
+ * Accepts a local file path (read by the engine directly), an http(s) URL
285
+ * (fetched first, capped at {@link MAX_DOCUMENT_FETCH_BYTES}), a data URL,
286
+ * raw base64, or binary bytes. Throws {@link DocumentConversionError} — the
287
+ * built-in tool converts these into model-readable `Error: …` strings instead.
288
+ */
289
+ export async function convertDocumentInput(
290
+ input: string | Uint8Array | ArrayBuffer,
291
+ opts: ConvertDocumentOptions = {}
292
+ ): Promise<ConvertedDocument> {
293
+ const anydoc = await loadAnydoc();
294
+ const filenameOpt = opts.filename?.trim() || undefined;
295
+
296
+ try {
297
+ // Remote URL leg: fetch bytes first (anydoc reads files, not URLs).
298
+ if (typeof input === "string" && isHttpUrl(input)) {
299
+ const res = await fetch(input, { signal: opts.signal });
300
+ if (!res.ok) {
301
+ throw new DocumentConversionError(
302
+ "io",
303
+ `[Agent Accelerator] Could not download document "${input}" (HTTP ${res.status} ${res.statusText}).`,
304
+ input
305
+ );
306
+ }
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
+ }
315
+ const name = filenameOpt || basenameOf(input);
316
+ const inferred = resolveDocumentFormat({
317
+ filename: name,
318
+ format: opts.format,
319
+ mimeType: res.headers.get("content-type") || opts.mimeType,
320
+ });
321
+ let wire = canonicalizeFormat(anydoc, inferred);
322
+ if (!wire) {
323
+ try {
324
+ wire = anydoc.formatFromBytes(buf);
325
+ } catch {
326
+ wire = null;
327
+ }
328
+ }
329
+ if (!wire) {
330
+ throw new DocumentConversionError(
331
+ "unsupported",
332
+ `[Agent Accelerator] Could not determine the format of "${input}". Pass filename (e.g. "data.csv") or format explicitly.`,
333
+ input
334
+ );
335
+ }
336
+ const markdown = await anydoc.toMarkdownBytes(buf, wire);
337
+ const { text, truncated } = truncateMarkdown(markdown, opts.maxChars);
338
+ return { markdown: text, name, format: String(wire), truncated };
339
+ }
340
+
341
+ // Local path leg: the engine reads + detects from the path itself.
342
+ if (typeof input === "string" && looksLikePath(input)) {
343
+ const name = filenameOpt || basenameOf(input);
344
+ const markdown = await anydoc.toMarkdown(input);
345
+ const inferred = resolveDocumentFormat({ filename: name, format: opts.format });
346
+ const format = String(canonicalizeFormat(anydoc, inferred) ?? inferred ?? "unknown");
347
+ const { text, truncated } = truncateMarkdown(markdown, opts.maxChars);
348
+ return { markdown: text, name, format, truncated };
349
+ }
350
+
351
+ // Bytes / data URL / base64 leg: normalize first, format must resolve.
352
+ const normalized = await normalizeMediaInput(input, opts.mimeType);
353
+ const name = filenameOpt || "document";
354
+ const inferred = resolveDocumentFormat({
355
+ filename: opts.filename,
356
+ format: opts.format,
357
+ mimeType: normalized.mimeType,
358
+ });
359
+ const bytes = base64ToBytes(normalized.base64Data);
360
+ let wire = canonicalizeFormat(anydoc, inferred);
361
+ if (!wire) {
362
+ try {
363
+ wire = anydoc.formatFromBytes(bytes);
364
+ } catch {
365
+ wire = null;
366
+ }
367
+ }
368
+ if (!wire) {
369
+ throw new DocumentConversionError(
370
+ "unsupported",
371
+ `[Agent Accelerator] Could not determine the document format. Pass filename (e.g. "data.csv") or format explicitly.`,
372
+ name
373
+ );
374
+ }
375
+ const markdown = await anydoc.toMarkdownBytes(bytes, wire);
376
+ const { text, truncated } = truncateMarkdown(markdown, opts.maxChars);
377
+ return { markdown: text, name, format: String(wire), truncated };
378
+ } catch (err) {
379
+ const label =
380
+ typeof input === "string" && (isHttpUrl(input) || (looksLikePath(input) && input.length < 1024))
381
+ ? input
382
+ : (opts.filename ?? "document");
383
+ throw toDocumentConversionError(err, label);
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Converts a document to Markdown text. Explicit dev-facing primitive —
389
+ * throws {@link DocumentConversionError} on failure.
390
+ *
391
+ * @example `const md = await convertDocumentToMarkdown("./report.xlsx");`
392
+ */
393
+ export async function convertDocumentToMarkdown(
394
+ input: string | Uint8Array | ArrayBuffer,
395
+ opts: ConvertDocumentOptions = {}
396
+ ): Promise<string> {
397
+ const doc = await convertDocumentInput(input, opts);
398
+ return doc.markdown;
399
+ }
400
+
401
+ /**
402
+ * True when the model can receive `file` parts natively (catalog `pdf` input).
403
+ * Unknown models count as capable — the provider verdict stands, matching
404
+ * `assertModalitiesSupported`.
405
+ *
406
+ * @example `modelSupportsFileInput("google", "gemini-3.5-flash-lite")`
407
+ */
408
+ export function modelSupportsFileInput(providerId: string, modelId: string): boolean {
409
+ try {
410
+ const spec = getModelFromCatalog(providerId, modelId);
411
+ if (!spec) return true;
412
+ return spec.modalities?.input?.includes("pdf") ?? true;
413
+ } catch {
414
+ return true;
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Implements `bypassInputFileModality`: rewrites `file` parts to `<Document>`
420
+ * Markdown text when the model lacks native support. Idempotent (converted
421
+ * parts are plain text afterwards) and persistent in history for prefix-cache
422
+ * stability. Conversion failures degrade to a readable placeholder so the run
423
+ * continues.
424
+ */
425
+ export async function preprocessFilePartsForBypass(
426
+ messages: Message[],
427
+ opts: { providerId: string; modelId: string; signal?: AbortSignal; maxChars?: number }
428
+ ): Promise<void> {
429
+ if (modelSupportsFileInput(opts.providerId, opts.modelId)) return;
430
+ for (const m of messages) {
431
+ if (!Array.isArray(m.content)) continue;
432
+ for (let i = 0; i < m.content.length; i++) {
433
+ const part = m.content[i];
434
+ if (!part || (part as { type?: unknown }).type !== "file") continue;
435
+ const raw = (part as { file?: unknown; filename?: unknown; mimeType?: unknown }).file as
436
+ | string
437
+ | Uint8Array
438
+ | ArrayBuffer;
439
+ const filename =
440
+ typeof (part as { filename?: unknown }).filename === "string"
441
+ ? ((part as { filename?: string }).filename as string)
442
+ : undefined;
443
+ const mimeType =
444
+ typeof (part as { mimeType?: unknown }).mimeType === "string"
445
+ ? ((part as { mimeType?: string }).mimeType as string)
446
+ : undefined;
447
+ const display = filename || "document";
448
+ try {
449
+ const doc = await convertDocumentInput(raw, {
450
+ filename,
451
+ mimeType,
452
+ maxChars: opts.maxChars,
453
+ signal: opts.signal,
454
+ });
455
+ const destination =
456
+ filename ||
457
+ (typeof raw === "string" && (isHttpUrl(raw) || (looksLikePath(raw) && raw.length < 1024))
458
+ ? raw
459
+ : doc.name);
460
+ m.content[i] = { type: "text", text: buildDocumentXml(doc.name, destination, doc.markdown) };
461
+ } catch (err) {
462
+ const reason = err instanceof Error ? err.message : String(err);
463
+ m.content[i] = { type: "text", text: `[Document "${display}" could not be read: ${reason}]` };
464
+ }
465
+ }
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Built-in model-callable document converter. Register it and move on:
471
+ *
472
+ * @example
473
+ * ```ts
474
+ * import { convert_document_to_markdown } from "agent-accelerator";
475
+ * const agent = new Agent({ model, tools: { convert_document_to_markdown } });
476
+ * ```
477
+ */
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
+ });
package/src/utils/env.ts CHANGED
@@ -49,13 +49,6 @@ export function getApiKey(provider: string, explicitKey?: string, env?: Provider
49
49
  getEnv("GOOGLE_API_KEY") ||
50
50
  getEnv("GOOGLE_GENAI_API_KEY")
51
51
  );
52
- case "opencode":
53
- case "opencode-go":
54
- return (
55
- getEnv("OPENCODE_API_KEY") ||
56
- getEnv("OPENCODE_ZEN_API_KEY") ||
57
- getEnv("OPENCODE_GO_API_KEY")
58
- );
59
52
  case "openrouter":
60
53
  return getEnv("OPENROUTER_API_KEY");
61
54
  case "openai":
@@ -45,7 +45,7 @@ function displayModel(providerId: string, modelId: string): string {
45
45
  }
46
46
 
47
47
  /**
48
- * Collapses a Vercel `APICallError` (which carries the full request/response
48
+ * Collapses a raw provider error (which carries the full request/response
49
49
  * dump as enumerable props) into a one-line actionable error. Raw details stay
50
50
  * available but non-enumerable, so runtime dumps stay small.
51
51
  */
@@ -69,6 +69,12 @@ export function toConciseProviderError(error: unknown, providerId: string, model
69
69
  value: typeof err?.responseBody === "string" ? err.responseBody.slice(0, 500) : undefined,
70
70
  enumerable: false,
71
71
  },
72
+ // Forwarded when present (e.g. OpenRouter's canonical `error_type`,
73
+ // which disambiguates lossy native codes). Survives re-wraps so retry
74
+ // helpers never strip it.
75
+ ...(typeof err?.errorType === "string"
76
+ ? { errorType: { value: err.errorType, enumerable: false } }
77
+ : {}),
72
78
  });
73
79
  return concise;
74
80
  }
@@ -109,8 +115,61 @@ export function assertModalitiesSupported(
109
115
  if (!supported) return;
110
116
  const missing = [...needed].filter((k) => !supported.includes(k));
111
117
  if (missing.length === 0) return;
118
+ const docHint = missing.includes("pdf")
119
+ ? " Documents can be converted client-side with the convert_document_to_markdown tool (bun add @firecrawl/anydoc) or Agent bypassInputFileModality: true."
120
+ : "";
112
121
  const err = new Error(
113
- `[${displayModel(providerId, modelId)}] unsupported ${missing.join("+")} input (supports: ${supported.join(", ") || "text"}). Use a capable model or drop the ${missing.join("+")} part.`
122
+ `[${displayModel(providerId, modelId)}] unsupported ${missing.join("+")} input (supports: ${supported.join(", ") || "text"}). Use a capable model or drop the ${missing.join("+")} part.${docHint}`
123
+ );
124
+ err.name = "AgentAccelProviderError";
125
+ Object.defineProperties(err, {
126
+ [CONCISE]: { value: true, enumerable: false },
127
+ provider: { value: providerId, enumerable: false },
128
+ model: { value: modelId, enumerable: false },
129
+ });
130
+ throw err;
131
+ }
132
+
133
+ /**
134
+ * Rejects `video` parts on the OpenAI Responses transport.
135
+ *
136
+ * That wire has no video shape — only `input_text`/`input_image`/`input_file`
137
+ * exist in either vendor's Responses docs — so a video part sent as
138
+ * `input_file` lands in the router's document parser and 400s confusingly
139
+ * (`Failed to parse the file ... Provide a PDF document`, observed live on
140
+ * `openrouter/stealth/space-bunny-alpha` under the old Responses skin despite
141
+ * its catalog `video` flag, which is chat-transport oriented). Fail fast with
142
+ * a clear one-liner instead; video stays Gemini-only, and OpenRouter Chat
143
+ * Completions carries `video_url` natively (no guard there).
144
+ */
145
+ export function assertNoVideoPartsOnResponses(
146
+ context: ProviderContext,
147
+ providerId: string,
148
+ modelId: string
149
+ ): void {
150
+ let hasVideo = false;
151
+ for (const msg of context.messages) {
152
+ if (!Array.isArray((msg as any)?.content)) continue;
153
+ for (const part of (msg as any).content) {
154
+ if ((part as any)?.type === "video") {
155
+ hasVideo = true;
156
+ break;
157
+ }
158
+ }
159
+ if (hasVideo) break;
160
+ }
161
+ if (!hasVideo) return;
162
+ let supported = "text, image";
163
+ try {
164
+ const known = getModelFromCatalog(providerId, modelId)?.modalities?.input;
165
+ // List transport-usable modalities only: `video` is excluded even when
166
+ // the catalog flags it, since this error exists precisely because the
167
+ // Responses wire cannot carry it (listing it as "supported" would
168
+ // contradict the rejection).
169
+ if (known && known.length > 0) supported = known.filter((m) => m !== "video").join(", ") || "text";
170
+ } catch {}
171
+ const err = new Error(
172
+ `[${displayModel(providerId, modelId)}] unsupported video input (the Responses API accepts text/image/file only; supports: ${supported}). Use a video-capable model (e.g. google/gemini-*) or drop the video part.`
114
173
  );
115
174
  err.name = "AgentAccelProviderError";
116
175
  Object.defineProperties(err, {
@@ -4,7 +4,15 @@ import { clampCacheKey } from "./cache.ts";
4
4
 
5
5
  export function isBrowserRuntime(): boolean {
6
6
  try {
7
- return typeof (globalThis as any).window !== "undefined" && typeof (globalThis as any).window.document !== "undefined";
7
+ const g = globalThis as any;
8
+ if (typeof g.window !== "undefined" && typeof g.window.document !== "undefined") return true;
9
+ // Web Workers / Service Workers have no window.document but are still
10
+ // CORS-constrained browser scopes: custom x-* headers trigger preflights
11
+ // the providers never allow-list (surfaces as `TypeError: Failed to fetch`).
12
+ if (typeof g.WorkerGlobalScope !== "undefined") return true;
13
+ if (typeof g.importScripts === "function") return true;
14
+ if (typeof g.navigator !== "undefined" && g.navigator?.product === "ReactNative") return true;
15
+ return false;
8
16
  } catch {
9
17
  return false;
10
18
  }
@@ -25,8 +33,6 @@ const BROWSER_DROPPED = new Set([
25
33
  "x-session-id",
26
34
  "x-client-request-id",
27
35
  "session_id",
28
- "x-opencode-session",
29
- "x-opencode-client",
30
36
  "x-goog-api-client",
31
37
  ]);
32
38
 
@@ -66,14 +72,7 @@ export function buildSessionHeaders(
66
72
  const sessionId = clampCacheKey(rawSessionId);
67
73
 
68
74
  if (sessionId && !browser) {
69
- if (provider === "opencode" || provider === "opencode-go") {
70
- headers["x-opencode-session"] = sessionId;
71
- headers["x-session-id"] = sessionId;
72
- headers["x-client-request-id"] = sessionId;
73
- headers["session_id"] = sessionId;
74
- headers["x-opencode-client"] = "agent-accel";
75
- headers["User-Agent"] = getAgentAccelUserAgent();
76
- } else if (provider === "openrouter") {
75
+ if (provider === "openrouter") {
77
76
  headers["x-session-id"] = sessionId;
78
77
  headers["x-client-request-id"] = sessionId;
79
78
  headers["HTTP-Referer"] = "https://sashvat.com";
@@ -96,15 +95,6 @@ export function buildSessionHeaders(
96
95
  headers["x-goog-api-client"] = "agent-accel/1.0";
97
96
  }
98
97
 
99
- if (
100
- (provider === "opencode" || provider === "opencode-go") &&
101
- !browser &&
102
- !headers["x-opencode-client"]
103
- ) {
104
- headers["x-opencode-client"] = "agent-accel";
105
- headers["User-Agent"] = getAgentAccelUserAgent();
106
- }
107
-
108
98
  if (browser) return stripForBrowser(headers);
109
99
  return headers;
110
100
  }
@@ -88,10 +88,13 @@ export async function normalizeMediaInput(
88
88
  };
89
89
  }
90
90
 
91
- // 4. If input is already raw base64 string (stricter: length threshold + no file markers)
91
+ // 4. If input is already raw base64 string (stricter: length threshold + no file markers).
92
+ // Note: `/` is a valid base64 character (RFC 4648 index 63), so it must NOT
93
+ // exclude this path — POSIX file paths fail the base64 charset test below
94
+ // on their own (`.`, `-`, short length). Only backslashes (Windows paths)
95
+ // are excluded up front.
92
96
  if (
93
97
  typeof input === "string" &&
94
- !input.includes("/") && // file paths contain /
95
98
  !input.includes("\\") &&
96
99
  input.length > 100 &&
97
100
  /^[A-Za-z0-9+/=\n\r]+$/.test(input.slice(0, 200)) &&