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.
- package/README.md +83 -112
- package/{SYSTEM_PROMPT_AGENT.md → examples/prompts/SYSTEM_PROMPT_AGENT.md} +1 -1
- package/package.json +14 -9
- package/src/agent/agent.ts +191 -71
- package/src/agent/context.ts +1 -0
- package/src/agent/delegation.ts +61 -12
- package/src/agent/loop.ts +71 -48
- package/src/data/README.md +6 -6
- package/src/index.ts +130 -45
- package/src/models/catalog-cache.ts +60 -9
- package/src/models/catalog.ts +53 -7
- package/src/providers/google.ts +926 -0
- package/src/providers/openai-compat.ts +1147 -0
- package/src/providers/openai.ts +959 -0
- package/src/providers/openrouter-responses.ts +949 -0
- package/src/providers/openrouter.ts +1037 -0
- package/src/{ai-sdk → providers}/registry.ts +43 -55
- package/src/providers.ts +490 -0
- package/src/streaming/sse-parser.ts +6 -4
- package/src/tools/executor.ts +19 -6
- package/src/tools/schema.ts +21 -11
- package/src/types/agent.ts +8 -1
- package/src/types/core.ts +1 -1
- package/src/types/message.ts +5 -0
- package/src/types/model.ts +3 -7
- package/src/types/provider-payloads.ts +2 -84
- package/src/types/tool.ts +6 -0
- package/src/update-models.ts +56 -0
- package/src/utils/cache.ts +1 -1
- package/src/utils/documents.ts +517 -0
- package/src/utils/env.ts +0 -7
- package/src/{ai-sdk → utils}/errors.ts +61 -2
- package/src/utils/headers.ts +10 -20
- package/src/utils/media.ts +5 -2
- package/src/utils/retry.ts +89 -0
- package/src/utils/serialization.ts +15 -0
- package/src/ai-sdk/converters.ts +0 -342
- package/src/ai-sdk/executor.ts +0 -454
- package/src/ai-sdk/index.ts +0 -55
- package/src/ai-sdk/model-provider.ts +0 -303
- package/src/ai-sdk/options.ts +0 -306
- package/src/ai-sdk/provider.ts +0 -415
- package/src/tokens/counter.ts +0 -136
- /package/{SYSTEM_PROMPT.md → examples/prompts/SYSTEM_PROMPT.md} +0 -0
- /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
|
|
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, {
|
package/src/utils/headers.ts
CHANGED
|
@@ -4,7 +4,15 @@ import { clampCacheKey } from "./cache.ts";
|
|
|
4
4
|
|
|
5
5
|
export function isBrowserRuntime(): boolean {
|
|
6
6
|
try {
|
|
7
|
-
|
|
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 === "
|
|
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
|
}
|
package/src/utils/media.ts
CHANGED
|
@@ -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)) &&
|