agent-accelerator 0.4.0 → 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.
- package/README.md +149 -57
- package/bunfig.toml +1 -0
- package/package.json +2 -3
- package/src/agent/agent.ts +414 -118
- package/src/agent/context.ts +11 -16
- package/src/agent/delegation.ts +111 -274
- package/src/agent/loop.ts +327 -99
- package/src/agent/subagent.ts +4 -12
- package/src/agent/worker.ts +435 -0
- package/src/index.ts +58 -5
- package/src/models/catalog-cache.ts +65 -25
- package/src/models/catalog.ts +47 -2
- package/src/providers/google.ts +338 -81
- package/src/providers/openai-compat.ts +171 -61
- package/src/providers/openai.ts +174 -44
- package/src/providers/openrouter.ts +129 -44
- package/src/providers/shared.ts +169 -17
- package/src/providers.ts +375 -11
- package/src/scheduler/scheduled-actions.md +41 -0
- package/src/scheduler/scheduler.ts +814 -0
- package/src/scheduler/store.ts +263 -0
- package/src/scheduler/time.ts +148 -0
- package/src/scheduler/tool.ts +151 -0
- package/src/session/store.ts +229 -113
- package/src/streaming/event-stream.ts +6 -4
- package/src/telemetry-file.ts +31 -0
- package/src/telemetry.ts +65 -0
- package/src/tools/executor.ts +163 -38
- package/src/tools/subagent-result.ts +60 -0
- package/src/types/agent.ts +74 -22
- package/src/types/model.ts +39 -0
- package/src/types/response.ts +9 -0
- package/src/types/scheduler.ts +81 -0
- package/src/types/tool.ts +15 -0
- package/src/utils/cache.ts +14 -5
- package/src/utils/documents.ts +112 -52
- package/src/utils/env.ts +21 -7
- package/src/utils/errors.ts +26 -1
- package/src/utils/headers.ts +15 -15
- package/src/utils/home.ts +30 -0
- package/src/utils/media.ts +57 -12
- package/src/utils/raw-capture.ts +76 -0
- package/src/utils/retry.ts +11 -3
- package/src/utils/safe-io.ts +252 -0
- package/src/utils/session.ts +7 -8
- package/src/data/README.md +0 -84
package/src/utils/media.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
78
|
-
const
|
|
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
|
|
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
|
+
}
|
package/src/utils/retry.ts
CHANGED
|
@@ -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,
|
|
98
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// NOTE: no static node:fs / node:path / node:dns imports: they break browser
|
|
2
|
+
// bundles. Everything Node-specific is loaded lazily inside the helpers.
|
|
3
|
+
import type { Message } from "../types/message.ts";
|
|
4
|
+
import { SecurityPolicyError } from "../types/errors.ts";
|
|
5
|
+
|
|
6
|
+
/** Default cap for one remote media/document download (bytes). */
|
|
7
|
+
export const MAX_REMOTE_FETCH_BYTES = 200_000_000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* True when a media/document string would be read from the local filesystem
|
|
11
|
+
* by `normalizeMediaInput` (i.e. it is not a data URL, an http(s) URL, or an
|
|
12
|
+
* embedded base64 payload). Mirrors the classification order used there.
|
|
13
|
+
*/
|
|
14
|
+
export function isLocalPathInput(input: unknown): input is string {
|
|
15
|
+
if (typeof input !== "string" || !input) return false;
|
|
16
|
+
if (input.startsWith("data:")) return false;
|
|
17
|
+
if (input.startsWith("http://") || input.startsWith("https://")) return false;
|
|
18
|
+
if (
|
|
19
|
+
!input.includes("\\") &&
|
|
20
|
+
input.length > 100 &&
|
|
21
|
+
/^[A-Za-z0-9+/=\n\r]+$/.test(input.slice(0, 200)) &&
|
|
22
|
+
input.length % 4 === 0
|
|
23
|
+
) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MAX_REDIRECTS = 5;
|
|
30
|
+
|
|
31
|
+
/** Raised when a remote fetch is blocked by policy or exceeds its size cap. */
|
|
32
|
+
export class RemoteFetchError extends SecurityPolicyError {
|
|
33
|
+
readonly reason: "blocked" | "too_large";
|
|
34
|
+
constructor(reason: "blocked" | "too_large", message: string, url?: string) {
|
|
35
|
+
super(message, url ? { url } : undefined);
|
|
36
|
+
this.name = "RemoteFetchError";
|
|
37
|
+
this.reason = reason;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Local path containment (`fileAccess.roots`)
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Throws {@link SecurityPolicyError} unless `target` resolves (after symlinks)
|
|
47
|
+
* inside one of `roots`. An empty `roots` list denies every local path. URL
|
|
48
|
+
* schemes other than plain paths (`file:`, `ftp:`, …) are always rejected.
|
|
49
|
+
*/
|
|
50
|
+
export async function assertPathAllowed(target: string, roots: readonly string[]): Promise<void> {
|
|
51
|
+
if (/^[a-z][a-z0-9+.-]+:/i.test(target)) {
|
|
52
|
+
throw new SecurityPolicyError(
|
|
53
|
+
`[Agent Accelerator] Path "${target.slice(0, 80)}" uses a URL scheme; only plain local paths or http(s) URLs are allowed.`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const path = await import("node:path");
|
|
57
|
+
const fs = await import("node:fs");
|
|
58
|
+
const real = (p: string): string => {
|
|
59
|
+
try {
|
|
60
|
+
return fs.realpathSync(p);
|
|
61
|
+
} catch {
|
|
62
|
+
return path.resolve(p);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const resolved = real(path.resolve(target));
|
|
66
|
+
for (const root of roots) {
|
|
67
|
+
const base = real(path.resolve(root));
|
|
68
|
+
const rel = path.relative(base, resolved);
|
|
69
|
+
if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) return;
|
|
70
|
+
}
|
|
71
|
+
throw new SecurityPolicyError(
|
|
72
|
+
`[Agent Accelerator] Path "${target.slice(0, 120)}" is outside the allowed fileAccess roots` +
|
|
73
|
+
`${roots.length === 0 ? " (no local paths are allowed)" : ""}.`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const MEDIA_KEYS = ["image", "audio", "video", "file"] as const;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validates every local-path media part in `messages` against `roots`. Used by
|
|
81
|
+
* the loop each turn so developer-supplied parts, steered prompts and
|
|
82
|
+
* imported sessions all obey `fileAccess.roots` the same way.
|
|
83
|
+
*/
|
|
84
|
+
export async function enforceFileAccess(messages: Message[], roots: readonly string[]): Promise<void> {
|
|
85
|
+
for (const m of messages) {
|
|
86
|
+
if (!Array.isArray(m.content)) continue;
|
|
87
|
+
for (const part of m.content) {
|
|
88
|
+
const p = part as unknown as Record<string, unknown>;
|
|
89
|
+
const key = MEDIA_KEYS.find((k) => k === p?.type);
|
|
90
|
+
if (!key) continue;
|
|
91
|
+
const value = p[key];
|
|
92
|
+
if (isLocalPathInput(value)) await assertPathAllowed(value, roots);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Remote fetch guard (SSRF + size cap)
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
function ipv4Private(a: number, b: number): boolean {
|
|
102
|
+
return (
|
|
103
|
+
a === 0 ||
|
|
104
|
+
a === 10 ||
|
|
105
|
+
a === 127 ||
|
|
106
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
107
|
+
(a === 169 && b === 254) ||
|
|
108
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
109
|
+
(a === 192 && b === 168)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** True for loopback, link-local, private, CGNAT and unique-local addresses. */
|
|
114
|
+
export function isPrivateAddress(address: string): boolean {
|
|
115
|
+
const host = address.replace(/^\[|\]$/g, "").toLowerCase();
|
|
116
|
+
const v4 = host.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
117
|
+
if (v4) return ipv4Private(Number(v4[1]), Number(v4[2]));
|
|
118
|
+
if (host.includes(":")) {
|
|
119
|
+
if (host === "::" || host === "::1") return true;
|
|
120
|
+
const mapped = host.match(/^::ffff:(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
121
|
+
if (mapped) return ipv4Private(Number(mapped[1]), Number(mapped[2]));
|
|
122
|
+
return /^f[cd]/.test(host) || /^fe[89ab]/.test(host);
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isPrivateHostname(host: string): boolean {
|
|
128
|
+
return (
|
|
129
|
+
host === "localhost" ||
|
|
130
|
+
host.endsWith(".localhost") ||
|
|
131
|
+
host.endsWith(".local") ||
|
|
132
|
+
host.endsWith(".internal") ||
|
|
133
|
+
isPrivateAddress(host)
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function assertRemoteUrlAllowed(url: URL, allowPrivateNetwork: boolean): Promise<void> {
|
|
138
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
139
|
+
throw new RemoteFetchError("blocked", `[Agent Accelerator] Only http(s) URLs are allowed (got ${url.protocol}).`, url.href);
|
|
140
|
+
}
|
|
141
|
+
if (allowPrivateNetwork) return;
|
|
142
|
+
const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
143
|
+
if (isPrivateHostname(host)) {
|
|
144
|
+
throw new RemoteFetchError("blocked", `[Agent Accelerator] Refusing to fetch private/internal host "${host}".`, url.href);
|
|
145
|
+
}
|
|
146
|
+
if (/^[\d.]+$/.test(host) || host.includes(":")) return; // IP literal already checked
|
|
147
|
+
try {
|
|
148
|
+
const dns = await import("node:dns");
|
|
149
|
+
const addrs = await dns.promises.lookup(host, { all: true });
|
|
150
|
+
if (addrs.some((a) => isPrivateAddress(a.address))) {
|
|
151
|
+
throw new RemoteFetchError("blocked", `[Agent Accelerator] Host "${host}" resolves to a private/internal address.`, url.href);
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err instanceof RemoteFetchError) throw err;
|
|
155
|
+
// Resolution failed (or non-Node runtime): fetch will surface its own error.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function readCapped(res: Response, maxBytes: number, url: string): Promise<Uint8Array> {
|
|
160
|
+
const tooLarge = () =>
|
|
161
|
+
new RemoteFetchError(
|
|
162
|
+
"too_large",
|
|
163
|
+
`[Agent Accelerator] Remote resource exceeds the ${(maxBytes / 1_000_000).toFixed(0)} MB fetch cap.`,
|
|
164
|
+
url
|
|
165
|
+
);
|
|
166
|
+
const declared = Number(res.headers.get("content-length"));
|
|
167
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
168
|
+
try { await res.body?.cancel(); } catch {}
|
|
169
|
+
throw tooLarge();
|
|
170
|
+
}
|
|
171
|
+
if (!res.body) {
|
|
172
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
173
|
+
if (buf.byteLength > maxBytes) throw tooLarge();
|
|
174
|
+
return buf;
|
|
175
|
+
}
|
|
176
|
+
const reader = res.body.getReader();
|
|
177
|
+
const chunks: Uint8Array[] = [];
|
|
178
|
+
let total = 0;
|
|
179
|
+
for (;;) {
|
|
180
|
+
const { done, value } = await reader.read();
|
|
181
|
+
if (done) break;
|
|
182
|
+
total += value.byteLength;
|
|
183
|
+
if (total > maxBytes) {
|
|
184
|
+
try { await reader.cancel(); } catch {}
|
|
185
|
+
throw tooLarge();
|
|
186
|
+
}
|
|
187
|
+
chunks.push(value);
|
|
188
|
+
}
|
|
189
|
+
const out = new Uint8Array(total);
|
|
190
|
+
let offset = 0;
|
|
191
|
+
for (const c of chunks) {
|
|
192
|
+
out.set(c, offset);
|
|
193
|
+
offset += c.byteLength;
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Result of {@link fetchRemoteBytes}; `bytes` is empty when `ok` is false. */
|
|
199
|
+
export interface RemoteBytes {
|
|
200
|
+
ok: boolean;
|
|
201
|
+
status: number;
|
|
202
|
+
statusText: string;
|
|
203
|
+
contentType: string | null;
|
|
204
|
+
bytes: Uint8Array;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Fetches a URL with redirect re-validation and a hard byte cap. Redirects
|
|
209
|
+
* are followed manually (max 5) so every hop is checked. With
|
|
210
|
+
* `allowPrivateNetwork: false` loopback/LAN/link-local/cloud-metadata hosts
|
|
211
|
+
* (including DNS names that resolve to them) are refused.
|
|
212
|
+
*/
|
|
213
|
+
export async function fetchRemoteBytes(
|
|
214
|
+
input: string,
|
|
215
|
+
opts: { signal?: AbortSignal; maxBytes?: number; allowPrivateNetwork?: boolean } = {}
|
|
216
|
+
): Promise<RemoteBytes> {
|
|
217
|
+
const maxBytes = opts.maxBytes ?? MAX_REMOTE_FETCH_BYTES;
|
|
218
|
+
const allowPrivate = opts.allowPrivateNetwork ?? false;
|
|
219
|
+
let current = new URL(input);
|
|
220
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
221
|
+
await assertRemoteUrlAllowed(current, allowPrivate);
|
|
222
|
+
const res = await fetch(current.href, { signal: opts.signal, redirect: "manual" });
|
|
223
|
+
if ((res as { type?: string }).type === "opaqueredirect") {
|
|
224
|
+
// Browser runtimes hide the Location header: only trusted callers may follow blindly.
|
|
225
|
+
if (!allowPrivate) {
|
|
226
|
+
throw new RemoteFetchError("blocked", "[Agent Accelerator] Redirects cannot be validated in this runtime.", current.href);
|
|
227
|
+
}
|
|
228
|
+
const followed = await fetch(current.href, { signal: opts.signal });
|
|
229
|
+
return {
|
|
230
|
+
ok: followed.ok,
|
|
231
|
+
status: followed.status,
|
|
232
|
+
statusText: followed.statusText,
|
|
233
|
+
contentType: followed.headers.get("content-type"),
|
|
234
|
+
bytes: followed.ok ? await readCapped(followed, maxBytes, current.href) : new Uint8Array(0),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const location = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
|
|
238
|
+
if (location) {
|
|
239
|
+
try { await res.body?.cancel(); } catch {}
|
|
240
|
+
current = new URL(location, current);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
ok: res.ok,
|
|
245
|
+
status: res.status,
|
|
246
|
+
statusText: res.statusText,
|
|
247
|
+
contentType: res.headers.get("content-type"),
|
|
248
|
+
bytes: res.ok ? await readCapped(res, maxBytes, current.href) : new Uint8Array(0),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
throw new RemoteFetchError("blocked", `[Agent Accelerator] Too many redirects (> ${MAX_REDIRECTS}).`, input);
|
|
252
|
+
}
|
package/src/utils/session.ts
CHANGED
|
@@ -8,26 +8,25 @@ import type { ContentPart } from "../types/message.ts";
|
|
|
8
8
|
import { ConfigError } from "../types/errors.ts";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Creates
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Creates a unique, provider-safe session ID for cache affinity.
|
|
11
|
+
* Creates a unique, provider-safe session ID for cache affinity: a bare random
|
|
12
|
+
* UUID by default, or `${prefix}-<uuid>` when a prefix is given (clamped to 64 chars).
|
|
15
13
|
*
|
|
16
|
-
* @example `const sessionId = createSessionId(
|
|
14
|
+
* @example `const sessionId = createSessionId();`
|
|
17
15
|
*/
|
|
18
|
-
export function createSessionId(prefix
|
|
16
|
+
export function createSessionId(prefix?: string): string {
|
|
17
|
+
const head = prefix ? `${prefix}-` : "";
|
|
19
18
|
let id: string;
|
|
20
19
|
try {
|
|
21
20
|
const webCrypto =
|
|
22
21
|
(globalThis as any)?.crypto ?? (typeof crypto !== "undefined" ? crypto : undefined);
|
|
23
22
|
if (webCrypto && typeof webCrypto.randomUUID === "function") {
|
|
24
|
-
id = `${
|
|
23
|
+
id = `${head}${webCrypto.randomUUID()}`;
|
|
25
24
|
} else {
|
|
26
25
|
throw new Error("no randomUUID");
|
|
27
26
|
}
|
|
28
27
|
} catch {
|
|
29
28
|
// Fallback — still clamp
|
|
30
|
-
id = `${
|
|
29
|
+
id = `${head}${Math.random().toString(36).slice(2, 11)}-${Date.now().toString(36)}`;
|
|
31
30
|
}
|
|
32
31
|
return clampCacheKey(id) || id;
|
|
33
32
|
}
|
package/src/data/README.md
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
# Model Catalog Cache (`src/data/`)
|
|
2
|
-
|
|
3
|
-
This directory is the local destination for the dynamic model catalog cache used by **Agent Accelerator**.
|
|
4
|
-
|
|
5
|
-
Agent Accelerator references a catalog of **7,500+ models across 200+ providers** for token limits, context windows, modality support, reasoning configurations, and per-token pricing (input, output, cache-read, cache-write).
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## Zero-Bloat Distribution Architecture
|
|
10
|
-
|
|
11
|
-
To keep the repository and published npm/Bun package lightweight and blazing fast:
|
|
12
|
-
|
|
13
|
-
1. **Excluded from Production Package & Git**:
|
|
14
|
-
* The ~4.5 MB full catalog snapshot (`models.dev.json`) is **never** committed to git and is **excluded** from the npm/Bun distribution bundle.
|
|
15
|
-
* The file is tracked in `.gitignore` and `.npmignore`.
|
|
16
|
-
|
|
17
|
-
2. **Dynamic 12-Hour Automated TTL Cache**:
|
|
18
|
-
* On agent execution (`runAgentLoop` / `streamAgentLoop`), Agent Accelerator checks the status of `src/data/models.dev.json`.
|
|
19
|
-
* **Cache Hit (< 12h)**: Instant memory/disk load with zero network overhead.
|
|
20
|
-
* **Cache Miss / Expired (≥ 12h)**: Automatically downloads the latest catalog directly from `https://models.dev/api.json`, caches it to `src/data/models.dev.json`, and loads the catalog into memory.
|
|
21
|
-
* **Resilience**: If an existing cache exists on disk, temporary upstream network issues will safely continue using the local cached copy.
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
## Developer Controls & Management
|
|
26
|
-
|
|
27
|
-
### 1. CLI Script
|
|
28
|
-
|
|
29
|
-
You can manually inspect or refresh the model catalog anytime via the developer CLI script:
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
# Refresh catalog if expired (or download if missing)
|
|
33
|
-
bun run update-models
|
|
34
|
-
|
|
35
|
-
# Force download regardless of age
|
|
36
|
-
bun run update-models --force
|
|
37
|
-
|
|
38
|
-
# Custom TTL (e.g., 24 hours)
|
|
39
|
-
bun src/update-models.ts --ttl=24h
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
---
|
|
43
|
-
|
|
44
|
-
### 2. Programmatic API
|
|
45
|
-
|
|
46
|
-
All catalog cache lifecycle controls are exported from the root package:
|
|
47
|
-
|
|
48
|
-
```typescript
|
|
49
|
-
import {
|
|
50
|
-
refreshModelCatalog,
|
|
51
|
-
ensureModelCatalogFresh,
|
|
52
|
-
getCatalogStatus,
|
|
53
|
-
setCatalogTTL,
|
|
54
|
-
getModelFromCatalog,
|
|
55
|
-
} from "agent-accelerator";
|
|
56
|
-
|
|
57
|
-
// 1. Check current cache status
|
|
58
|
-
const status = getCatalogStatus();
|
|
59
|
-
console.log(status);
|
|
60
|
-
// {
|
|
61
|
-
// isExpired: false,
|
|
62
|
-
// cachedAt: 1773400000000,
|
|
63
|
-
// ttlMs: 43200000,
|
|
64
|
-
// modelCount: 7696,
|
|
65
|
-
// providerCount: 213,
|
|
66
|
-
// source: "cache"
|
|
67
|
-
// }
|
|
68
|
-
|
|
69
|
-
// 2. Refresh on-demand (e.g. in a cron job or startup hook)
|
|
70
|
-
await refreshModelCatalog({ force: true });
|
|
71
|
-
|
|
72
|
-
// 3. Customize runtime TTL (e.g., 6 hours)
|
|
73
|
-
setCatalogTTL(6 * 60 * 60 * 1000);
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
---
|
|
77
|
-
|
|
78
|
-
## Structure
|
|
79
|
-
|
|
80
|
-
```text
|
|
81
|
-
src/data/
|
|
82
|
-
├── README.md # This documentation
|
|
83
|
-
└── models.dev.json # Auto-generated on first run (gitignored, excluded from bundle)
|
|
84
|
-
```
|