arcane-os 0.1.0 → 0.1.2
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/NOTICE +10 -0
- package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +80 -4
- package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +69 -0
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +1151 -0
- package/browser-runtime/ai/browser-wasm.mjs +44 -0
- package/browser-runtime/ai/browser-wllama-runtime.mjs +390 -0
- package/browser-runtime/ai/internal/sha256.mjs +166 -0
- package/browser-runtime/ai/model-controller.mjs +581 -0
- package/browser-runtime/ai/wllama/LICENCE +21 -0
- package/browser-runtime/ai/wllama/index.mjs +3494 -0
- package/browser-runtime/ai/wllama/llama.cpp-LICENSE +21 -0
- package/browser-runtime/ai/wllama/wllama.wasm +0 -0
- package/docs/publishing.md +23 -18
- package/docs/reference/README.md +9 -5
- package/docs/reference/ai/browser-wasm.md +335 -0
- package/docs/reference/availability-and-normalization.md +17 -0
- package/docs/reference/behavioral-testing.md +8 -0
- package/docs/reference/cli.md +86 -3
- package/docs/reference/event-manager.md +15 -6
- package/docs/reference/inventory/package-api.json +84 -4
- package/docs/reference/protocols.md +113 -14
- package/docs/reference/sdk-api.md +244 -11
- package/package.json +8 -5
- package/runtime/ARCANE_RUNTIME_RELEASE.json +1 -1
- package/schemas/arcane-lock.schema.json +17 -6
- package/src/dev-server.mjs +2 -1
- package/src/doctor.mjs +1 -1
- package/src/import-map.mjs +25 -1
- package/src/sdk-browser-runtime.mjs +134 -17
- package/src/templates/workspace-template.mjs +6 -0
- package/src/workspace.mjs +7 -1
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ARCANE_AI_ADAPTER_PROTOCOL,
|
|
3
|
+
ArcaneAIError,
|
|
4
|
+
normalizeArcaneAIError,
|
|
5
|
+
} from "./model-controller.mjs";
|
|
6
|
+
import { createPackagedWllamaRuntime } from "./browser-wllama-runtime.mjs";
|
|
7
|
+
import { createStreamingSha256 } from "./internal/sha256.mjs";
|
|
8
|
+
|
|
9
|
+
const MODEL_MANIFEST_SCHEMA = "arcane.ai.browser-wasm.model.v2";
|
|
10
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
|
11
|
+
const MUTABLE_PATH_PATTERN = /\/(?:resolve\/)?(?:main|master|latest)(?:\/|$)/iu;
|
|
12
|
+
const BROWSER_MODEL_SOURCES = new WeakSet();
|
|
13
|
+
const DBOPFS_MODEL_STORES = new WeakSet();
|
|
14
|
+
|
|
15
|
+
function fail(code, message, cause) {
|
|
16
|
+
return new ArcaneAIError(code, message, {
|
|
17
|
+
cause,
|
|
18
|
+
kind: "llm",
|
|
19
|
+
operation: "request",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function throwIfAborted(signal, operation = "request") {
|
|
24
|
+
if (!signal?.aborted) return;
|
|
25
|
+
throw new ArcaneAIError(
|
|
26
|
+
"ARCANE_AI_REQUEST_ABORTED",
|
|
27
|
+
"The Arcane AI request was cancelled.",
|
|
28
|
+
{ cause: signal.reason, kind: "llm", operation },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function immutableHttpsUrl(value) {
|
|
33
|
+
let url;
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(value);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (
|
|
40
|
+
url.protocol !== "https:"
|
|
41
|
+
|| url.username
|
|
42
|
+
|| url.password
|
|
43
|
+
|| url.hash
|
|
44
|
+
|| MUTABLE_PATH_PATTERN.test(url.pathname)
|
|
45
|
+
) return null;
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function requiredText(value, field) {
|
|
50
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
51
|
+
throw new TypeError(`Browser model ${field} must be a nonempty string.`);
|
|
52
|
+
}
|
|
53
|
+
return value.trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function modelDescriptor(value) {
|
|
57
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
58
|
+
throw new TypeError("A browser model descriptor is required.");
|
|
59
|
+
}
|
|
60
|
+
const id = requiredText(value.id, "id");
|
|
61
|
+
const name = requiredText(value.name, "name");
|
|
62
|
+
if (name !== name.split(/[\\/]/u).pop() || name === "." || name === "..") {
|
|
63
|
+
throw new TypeError("Browser model name must be a single filename.");
|
|
64
|
+
}
|
|
65
|
+
const immutableUrl = immutableHttpsUrl(value.immutableUrl);
|
|
66
|
+
if (!immutableUrl) {
|
|
67
|
+
throw new TypeError("Browser model immutableUrl must be immutable HTTPS without credentials or fragments.");
|
|
68
|
+
}
|
|
69
|
+
const bytes = Number(value.bytes);
|
|
70
|
+
if (!Number.isSafeInteger(bytes) || bytes < 1) {
|
|
71
|
+
throw new TypeError("Browser model bytes must be a positive safe integer.");
|
|
72
|
+
}
|
|
73
|
+
const sha256 = requiredText(value.sha256, "sha256").toLowerCase();
|
|
74
|
+
if (!SHA256_PATTERN.test(sha256)) {
|
|
75
|
+
throw new TypeError("Browser model sha256 must be exactly 64 lowercase hexadecimal characters.");
|
|
76
|
+
}
|
|
77
|
+
const licenseSpdx = requiredText(value.licenseSpdx, "licenseSpdx");
|
|
78
|
+
const sourceRevision = requiredText(value.sourceRevision, "sourceRevision");
|
|
79
|
+
return Object.freeze({
|
|
80
|
+
id,
|
|
81
|
+
name,
|
|
82
|
+
immutableUrl: immutableUrl.href,
|
|
83
|
+
bytes,
|
|
84
|
+
sha256,
|
|
85
|
+
licenseSpdx,
|
|
86
|
+
sourceRevision,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function publicDescriptor(source) {
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
id: source.id,
|
|
93
|
+
name: source.name,
|
|
94
|
+
immutableUrl: source.immutableUrl,
|
|
95
|
+
bytes: source.bytes,
|
|
96
|
+
sha256: source.sha256,
|
|
97
|
+
licenseSpdx: source.licenseSpdx,
|
|
98
|
+
sourceRevision: source.sourceRevision,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Creates an authenticated browser download authority for one caller-supplied
|
|
104
|
+
* immutable model. Arcane verifies the response bytes; it never trusts an
|
|
105
|
+
* ETag, server digest, mutable model catalog, or URL helper.
|
|
106
|
+
*/
|
|
107
|
+
export function createBrowserModelSource(descriptor, {
|
|
108
|
+
fetchImpl = null,
|
|
109
|
+
} = {}) {
|
|
110
|
+
const model = modelDescriptor(descriptor);
|
|
111
|
+
|
|
112
|
+
async function open({ signal } = {}) {
|
|
113
|
+
throwIfAborted(signal, "install");
|
|
114
|
+
const fetchFunction = fetchImpl ?? globalThis.fetch?.bind(globalThis);
|
|
115
|
+
if (typeof fetchFunction !== "function") {
|
|
116
|
+
throw fail("ARCANE_AI_MODEL_SOURCE_UNAVAILABLE", "Browser fetch is unavailable.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let response;
|
|
120
|
+
try {
|
|
121
|
+
response = await fetchFunction(model.immutableUrl, {
|
|
122
|
+
cache: "no-store",
|
|
123
|
+
credentials: "omit",
|
|
124
|
+
mode: "cors",
|
|
125
|
+
redirect: "follow",
|
|
126
|
+
referrerPolicy: "no-referrer",
|
|
127
|
+
signal,
|
|
128
|
+
});
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (signal?.aborted || error?.name === "AbortError") throwIfAborted(signal, "install");
|
|
131
|
+
throw fail("ARCANE_AI_MODEL_DOWNLOAD_FAILED", "The model download failed.", error);
|
|
132
|
+
}
|
|
133
|
+
if (!response?.ok) {
|
|
134
|
+
await response?.body?.cancel?.().catch(() => undefined);
|
|
135
|
+
throw fail(
|
|
136
|
+
"ARCANE_AI_MODEL_DOWNLOAD_FAILED",
|
|
137
|
+
`The model server returned HTTP ${response?.status ?? "unknown"}.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
let finalUrl;
|
|
141
|
+
try {
|
|
142
|
+
finalUrl = new URL(response.url || model.immutableUrl);
|
|
143
|
+
} catch {
|
|
144
|
+
finalUrl = null;
|
|
145
|
+
}
|
|
146
|
+
if (finalUrl?.protocol !== "https:") {
|
|
147
|
+
await response.body?.cancel?.().catch(() => undefined);
|
|
148
|
+
throw fail("ARCANE_AI_MODEL_REDIRECT_BLOCKED", "The model response left HTTPS.");
|
|
149
|
+
}
|
|
150
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
151
|
+
throw fail("ARCANE_AI_MODEL_SOURCE_INVALID", "The model response did not provide a byte stream.");
|
|
152
|
+
}
|
|
153
|
+
const header = response.headers?.get?.("content-length");
|
|
154
|
+
if (header !== null && header !== undefined && header !== "") {
|
|
155
|
+
const reported = Number(header);
|
|
156
|
+
if (!Number.isSafeInteger(reported) || reported !== model.bytes) {
|
|
157
|
+
await response.body.cancel().catch(() => undefined);
|
|
158
|
+
throw fail(
|
|
159
|
+
"ARCANE_AI_MODEL_SIZE_MISMATCH",
|
|
160
|
+
`The model server reported ${String(header)} bytes; expected ${model.bytes}.`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return Object.freeze({
|
|
165
|
+
body: response.body,
|
|
166
|
+
requestedUrl: model.immutableUrl,
|
|
167
|
+
finalUrl: finalUrl.href,
|
|
168
|
+
cancel: (reason) => response.body.cancel(reason),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const source = Object.freeze({
|
|
173
|
+
kind: "arcane-authenticated-browser-model-source",
|
|
174
|
+
...model,
|
|
175
|
+
descriptor: publicDescriptor(model),
|
|
176
|
+
open,
|
|
177
|
+
});
|
|
178
|
+
BROWSER_MODEL_SOURCES.add(source);
|
|
179
|
+
return source;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function* byteChunks(body, signal) {
|
|
183
|
+
if (body instanceof Uint8Array || body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
|
|
184
|
+
throwIfAborted(signal, "install");
|
|
185
|
+
yield body instanceof Uint8Array
|
|
186
|
+
? body
|
|
187
|
+
: new Uint8Array(body.buffer ?? body, body.byteOffset ?? 0, body.byteLength);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (body && typeof body.stream === "function") {
|
|
191
|
+
yield* byteChunks(body.stream(), signal);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (body && typeof body.getReader === "function") {
|
|
195
|
+
const reader = body.getReader();
|
|
196
|
+
try {
|
|
197
|
+
while (true) {
|
|
198
|
+
throwIfAborted(signal, "install");
|
|
199
|
+
let removeAbort = () => undefined;
|
|
200
|
+
const aborted = new Promise((_, reject) => {
|
|
201
|
+
if (!signal) return;
|
|
202
|
+
const onAbort = () => {
|
|
203
|
+
void reader.cancel(signal.reason).catch(() => undefined);
|
|
204
|
+
reject(new ArcaneAIError(
|
|
205
|
+
"ARCANE_AI_REQUEST_ABORTED",
|
|
206
|
+
"The Arcane AI request was cancelled.",
|
|
207
|
+
{ cause: signal.reason, kind: "llm", operation: "install" },
|
|
208
|
+
));
|
|
209
|
+
};
|
|
210
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
211
|
+
removeAbort = () => signal.removeEventListener("abort", onAbort);
|
|
212
|
+
});
|
|
213
|
+
let step;
|
|
214
|
+
try {
|
|
215
|
+
step = signal ? await Promise.race([reader.read(), aborted]) : await reader.read();
|
|
216
|
+
} finally {
|
|
217
|
+
removeAbort();
|
|
218
|
+
}
|
|
219
|
+
const { done, value } = step;
|
|
220
|
+
if (done) return;
|
|
221
|
+
yield value instanceof Uint8Array ? value : new Uint8Array(value);
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
if (signal?.aborted) await reader.cancel(signal.reason).catch(() => undefined);
|
|
225
|
+
reader.releaseLock?.();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
throw fail("ARCANE_AI_MODEL_SOURCE_INVALID", "The model source did not provide readable bytes.");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function storageName(source) {
|
|
232
|
+
const safeId = source.id.replace(/[^a-z0-9._-]+/giu, "_");
|
|
233
|
+
return Object.freeze({
|
|
234
|
+
model: `${safeId}--${source.name}`,
|
|
235
|
+
manifest: `${safeId}.complete.json`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function manifestFor(source, finalUrl) {
|
|
240
|
+
return Object.freeze({
|
|
241
|
+
schema: MODEL_MANIFEST_SCHEMA,
|
|
242
|
+
complete: true,
|
|
243
|
+
model: publicDescriptor(source),
|
|
244
|
+
finalUrl,
|
|
245
|
+
completedAt: new Date().toISOString(),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function manifestMatches(manifest, source) {
|
|
250
|
+
const model = manifest?.model;
|
|
251
|
+
return manifest?.schema === MODEL_MANIFEST_SCHEMA
|
|
252
|
+
&& manifest?.complete === true
|
|
253
|
+
&& model?.id === source.id
|
|
254
|
+
&& model?.name === source.name
|
|
255
|
+
&& model?.immutableUrl === source.immutableUrl
|
|
256
|
+
&& model?.bytes === source.bytes
|
|
257
|
+
&& model?.sha256 === source.sha256
|
|
258
|
+
&& model?.licenseSpdx === source.licenseSpdx
|
|
259
|
+
&& model?.sourceRevision === source.sourceRevision;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function progress(source, phase, loaded) {
|
|
263
|
+
return Object.freeze({
|
|
264
|
+
modelId: source.id,
|
|
265
|
+
phase,
|
|
266
|
+
loaded,
|
|
267
|
+
total: source.bytes,
|
|
268
|
+
percent: source.bytes ? (loaded / source.bytes) * 100 : null,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Adapts an existing Arcane DBOPFS singleton without rebinding or changing any
|
|
274
|
+
* of its public methods. The completion manifest is committed only after the
|
|
275
|
+
* exact model file has been written and hashed.
|
|
276
|
+
*/
|
|
277
|
+
export function createDbopfsModelStore({
|
|
278
|
+
dbopfs,
|
|
279
|
+
tableName = "arcane_ai_browser_models",
|
|
280
|
+
} = {}) {
|
|
281
|
+
if (!dbopfs || (typeof dbopfs !== "object" && typeof dbopfs !== "function")) {
|
|
282
|
+
throw new TypeError("createDbopfsModelStore requires an existing DBOPFS instance.");
|
|
283
|
+
}
|
|
284
|
+
if (typeof dbopfs.getTableHandle !== "function") {
|
|
285
|
+
throw new TypeError("The DBOPFS instance is missing getTableHandle().");
|
|
286
|
+
}
|
|
287
|
+
if (dbopfs.readyPromise !== undefined && typeof dbopfs.readyPromise?.then !== "function") {
|
|
288
|
+
throw new TypeError("The DBOPFS readyPromise must be thenable.");
|
|
289
|
+
}
|
|
290
|
+
let tablePromise = null;
|
|
291
|
+
|
|
292
|
+
async function table() {
|
|
293
|
+
if (dbopfs.readyPromise) await dbopfs.readyPromise;
|
|
294
|
+
tablePromise ||= Promise.resolve(dbopfs.getTableHandle(tableName));
|
|
295
|
+
const handle = await tablePromise;
|
|
296
|
+
if (!handle || typeof handle.getFileHandle !== "function" || typeof handle.removeEntry !== "function") {
|
|
297
|
+
throw fail("ARCANE_AI_STORAGE_UNAVAILABLE", "DBOPFS did not provide an OPFS table handle.");
|
|
298
|
+
}
|
|
299
|
+
return handle;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function removeEntry(name) {
|
|
303
|
+
try {
|
|
304
|
+
await (await table()).removeEntry(name);
|
|
305
|
+
return true;
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (error?.name === "NotFoundError" || error?.code === "ENOENT") return false;
|
|
308
|
+
throw fail("ARCANE_AI_STORAGE_DELETE_FAILED", `Unable to remove DBOPFS entry ${name}.`, error);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function file(name) {
|
|
313
|
+
try {
|
|
314
|
+
const handle = await (await table()).getFileHandle(name, { create: false });
|
|
315
|
+
return await handle.getFile();
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (error?.name === "NotFoundError" || error?.code === "ENOENT") return null;
|
|
318
|
+
throw fail("ARCANE_AI_STORAGE_READ_FAILED", `Unable to read DBOPFS entry ${name}.`, error);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function write(name, body, { signal, onChunk } = {}) {
|
|
323
|
+
const directory = await table();
|
|
324
|
+
const handle = await directory.getFileHandle(name, { create: true });
|
|
325
|
+
const writable = await handle.createWritable();
|
|
326
|
+
let written = 0;
|
|
327
|
+
try {
|
|
328
|
+
for await (const chunk of byteChunks(body, signal)) {
|
|
329
|
+
await writable.write(chunk);
|
|
330
|
+
written += chunk.byteLength;
|
|
331
|
+
await onChunk?.(chunk, written);
|
|
332
|
+
}
|
|
333
|
+
throwIfAborted(signal, "install");
|
|
334
|
+
await writable.close();
|
|
335
|
+
return written;
|
|
336
|
+
} catch (error) {
|
|
337
|
+
await writable.abort?.(error).catch(() => undefined);
|
|
338
|
+
await directory.removeEntry(name).catch(() => undefined);
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function readManifest(name) {
|
|
344
|
+
const manifestFile = await file(name);
|
|
345
|
+
if (!manifestFile) return null;
|
|
346
|
+
try {
|
|
347
|
+
return JSON.parse(await manifestFile.text());
|
|
348
|
+
} catch {
|
|
349
|
+
await removeEntry(name);
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function remove(source) {
|
|
355
|
+
const names = storageName(source);
|
|
356
|
+
const removed = await Promise.all([
|
|
357
|
+
removeEntry(names.manifest),
|
|
358
|
+
removeEntry(names.model),
|
|
359
|
+
]);
|
|
360
|
+
return removed.some(Boolean);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function openVerified(source, { signal, onProgress } = {}) {
|
|
364
|
+
const names = storageName(source);
|
|
365
|
+
const manifest = await readManifest(names.manifest);
|
|
366
|
+
if (!manifestMatches(manifest, source)) {
|
|
367
|
+
// A model file without the exact completion manifest is a partial, even
|
|
368
|
+
// when the manifest is missing or malformed rather than merely stale.
|
|
369
|
+
await remove(source);
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
const modelFile = await file(names.model);
|
|
373
|
+
if (!modelFile || modelFile.size !== source.bytes) {
|
|
374
|
+
await remove(source);
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
const digest = createStreamingSha256();
|
|
378
|
+
let hashed = 0;
|
|
379
|
+
try {
|
|
380
|
+
for await (const chunk of byteChunks(modelFile, signal)) {
|
|
381
|
+
digest.update(chunk);
|
|
382
|
+
hashed += chunk.byteLength;
|
|
383
|
+
onProgress?.(progress(source, "verify-cache", hashed));
|
|
384
|
+
}
|
|
385
|
+
if (hashed !== source.bytes || digest.digestHex() !== source.sha256) {
|
|
386
|
+
await remove(source);
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
return Object.freeze({ file: modelFile, manifest });
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if (!signal?.aborted) await remove(source);
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function install(source, { signal, onProgress } = {}) {
|
|
397
|
+
const names = storageName(source);
|
|
398
|
+
await remove(source);
|
|
399
|
+
const opened = await source.open({ signal });
|
|
400
|
+
const digest = createStreamingSha256();
|
|
401
|
+
try {
|
|
402
|
+
const written = await write(names.model, opened.body, {
|
|
403
|
+
signal,
|
|
404
|
+
async onChunk(chunk, loaded) {
|
|
405
|
+
digest.update(chunk);
|
|
406
|
+
if (loaded > source.bytes) {
|
|
407
|
+
throw fail("ARCANE_AI_MODEL_SIZE_MISMATCH", "Downloaded model exceeded its declared size.");
|
|
408
|
+
}
|
|
409
|
+
onProgress?.(progress(source, "download", loaded));
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
const actualSha256 = digest.digestHex();
|
|
413
|
+
if (written !== source.bytes || actualSha256 !== source.sha256) {
|
|
414
|
+
throw fail(
|
|
415
|
+
actualSha256 === source.sha256
|
|
416
|
+
? "ARCANE_AI_MODEL_SIZE_MISMATCH"
|
|
417
|
+
: "ARCANE_AI_MODEL_DIGEST_MISMATCH",
|
|
418
|
+
"Downloaded model bytes did not match the caller-supplied authority.",
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
// Completion is the final storage mutation. A file without this exact
|
|
422
|
+
// manifest is never admitted for inference or offline reuse.
|
|
423
|
+
const manifest = manifestFor(source, opened.finalUrl);
|
|
424
|
+
const encoded = new TextEncoder().encode(`${JSON.stringify(manifest)}\n`);
|
|
425
|
+
await write(names.manifest, encoded, { signal });
|
|
426
|
+
const admitted = await openVerified(source, { signal, onProgress });
|
|
427
|
+
if (!admitted) throw fail("ARCANE_AI_MODEL_CACHE_REJECTED", "The completed model cache failed revalidation.");
|
|
428
|
+
return admitted;
|
|
429
|
+
} catch (error) {
|
|
430
|
+
await opened.cancel?.(error).catch(() => undefined);
|
|
431
|
+
await remove(source).catch(() => undefined);
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function ensure(source, { signal, onProgress, offline = false } = {}) {
|
|
437
|
+
const cached = await openVerified(source, { signal, onProgress });
|
|
438
|
+
if (cached) return Object.freeze({ ...cached, cache: "verified" });
|
|
439
|
+
if (offline) {
|
|
440
|
+
throw fail("ARCANE_AI_MODEL_OFFLINE_MISS", "No verified offline model cache is available.");
|
|
441
|
+
}
|
|
442
|
+
const installed = await install(source, { signal, onProgress });
|
|
443
|
+
return Object.freeze({ ...installed, cache: "installed" });
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const store = Object.freeze({
|
|
447
|
+
kind: "arcane-dbopfs-model-store",
|
|
448
|
+
tableName,
|
|
449
|
+
adapter: dbopfs,
|
|
450
|
+
ready: () => table().then(() => undefined),
|
|
451
|
+
openVerified,
|
|
452
|
+
install,
|
|
453
|
+
ensure,
|
|
454
|
+
remove,
|
|
455
|
+
});
|
|
456
|
+
DBOPFS_MODEL_STORES.add(store);
|
|
457
|
+
return store;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function linkAbortSignal(externalSignal) {
|
|
461
|
+
const controller = new AbortController();
|
|
462
|
+
const forward = () => controller.abort(externalSignal.reason);
|
|
463
|
+
if (externalSignal?.aborted) forward();
|
|
464
|
+
else externalSignal?.addEventListener?.("abort", forward, { once: true });
|
|
465
|
+
return Object.freeze({
|
|
466
|
+
controller,
|
|
467
|
+
release() {
|
|
468
|
+
externalSignal?.removeEventListener?.("abort", forward);
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function createSerialRequestQueue(onDepth) {
|
|
474
|
+
let tail = Promise.resolve();
|
|
475
|
+
let depth = 0;
|
|
476
|
+
|
|
477
|
+
function abortError(signal) {
|
|
478
|
+
return new ArcaneAIError(
|
|
479
|
+
"ARCANE_AI_REQUEST_ABORTED",
|
|
480
|
+
"The Arcane AI request was cancelled.",
|
|
481
|
+
{ cause: signal?.reason, kind: "llm", operation: "request" },
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function schedule(operation, signal = null) {
|
|
486
|
+
depth += 1;
|
|
487
|
+
onDepth(depth);
|
|
488
|
+
let started = false;
|
|
489
|
+
let outerSettled = false;
|
|
490
|
+
let resolveOuter;
|
|
491
|
+
let rejectOuter;
|
|
492
|
+
const result = new Promise((resolve, reject) => {
|
|
493
|
+
resolveOuter = resolve;
|
|
494
|
+
rejectOuter = reject;
|
|
495
|
+
});
|
|
496
|
+
const onAbort = () => {
|
|
497
|
+
if (started || outerSettled) return;
|
|
498
|
+
outerSettled = true;
|
|
499
|
+
rejectOuter(abortError(signal));
|
|
500
|
+
};
|
|
501
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
502
|
+
if (signal?.aborted) onAbort();
|
|
503
|
+
|
|
504
|
+
const task = tail.catch(() => undefined).then(async () => {
|
|
505
|
+
started = true;
|
|
506
|
+
if (signal?.aborted) throw abortError(signal);
|
|
507
|
+
return operation();
|
|
508
|
+
}).then(
|
|
509
|
+
(value) => {
|
|
510
|
+
if (!outerSettled) {
|
|
511
|
+
outerSettled = true;
|
|
512
|
+
resolveOuter(value);
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
(error) => {
|
|
516
|
+
if (!outerSettled) {
|
|
517
|
+
outerSettled = true;
|
|
518
|
+
rejectOuter(error);
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
);
|
|
522
|
+
tail = task.catch(() => undefined).finally(() => {
|
|
523
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
524
|
+
depth -= 1;
|
|
525
|
+
onDepth(depth);
|
|
526
|
+
});
|
|
527
|
+
return result;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function openStream(operation, signal = null) {
|
|
531
|
+
let resolveReady;
|
|
532
|
+
let rejectReady;
|
|
533
|
+
let readySettled = false;
|
|
534
|
+
let started = false;
|
|
535
|
+
const ready = new Promise((resolve, reject) => {
|
|
536
|
+
resolveReady = resolve;
|
|
537
|
+
rejectReady = reject;
|
|
538
|
+
});
|
|
539
|
+
const onAbort = () => {
|
|
540
|
+
if (started || readySettled) return;
|
|
541
|
+
readySettled = true;
|
|
542
|
+
rejectReady(abortError(signal));
|
|
543
|
+
};
|
|
544
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
545
|
+
if (signal?.aborted) onAbort();
|
|
546
|
+
schedule(async () => {
|
|
547
|
+
started = true;
|
|
548
|
+
if (signal?.aborted) throw abortError(signal);
|
|
549
|
+
try {
|
|
550
|
+
const handle = await operation();
|
|
551
|
+
if (!readySettled) {
|
|
552
|
+
readySettled = true;
|
|
553
|
+
resolveReady(handle);
|
|
554
|
+
}
|
|
555
|
+
await handle.result;
|
|
556
|
+
} catch (error) {
|
|
557
|
+
if (!readySettled) {
|
|
558
|
+
readySettled = true;
|
|
559
|
+
rejectReady(error);
|
|
560
|
+
}
|
|
561
|
+
throw error;
|
|
562
|
+
} finally {
|
|
563
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
564
|
+
}
|
|
565
|
+
}, signal).catch(() => undefined);
|
|
566
|
+
return ready;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return Object.freeze({
|
|
570
|
+
schedule,
|
|
571
|
+
openStream,
|
|
572
|
+
idle: () => tail,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function responseFormat(structuredOutput) {
|
|
577
|
+
if (structuredOutput === undefined || structuredOutput === null || structuredOutput === false) {
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
if (structuredOutput === true || structuredOutput === "json") {
|
|
581
|
+
return Object.freeze({ type: "json_object" });
|
|
582
|
+
}
|
|
583
|
+
if (typeof structuredOutput !== "object" || Array.isArray(structuredOutput)) {
|
|
584
|
+
throw new TypeError("structuredOutput must be false, true, \"json\", or a JSON Schema object.");
|
|
585
|
+
}
|
|
586
|
+
return Object.freeze({
|
|
587
|
+
type: "json_schema",
|
|
588
|
+
json_schema: Object.freeze({ name: "arcane_response", strict: true, schema: structuredOutput }),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function completionOptions(request, abortSignal, stream) {
|
|
593
|
+
if (!Array.isArray(request?.messages)) throw new TypeError("messages must be an array.");
|
|
594
|
+
const options = {
|
|
595
|
+
messages: request.messages,
|
|
596
|
+
stream,
|
|
597
|
+
abortSignal,
|
|
598
|
+
};
|
|
599
|
+
const copy = [
|
|
600
|
+
["temperature", "temperature"],
|
|
601
|
+
["topK", "top_k"],
|
|
602
|
+
["top_k", "top_k"],
|
|
603
|
+
["topP", "top_p"],
|
|
604
|
+
["top_p", "top_p"],
|
|
605
|
+
["minP", "min_p"],
|
|
606
|
+
["min_p", "min_p"],
|
|
607
|
+
["repeatPenalty", "penalty_repeat"],
|
|
608
|
+
["penalty_repeat", "penalty_repeat"],
|
|
609
|
+
["maxTokens", "max_tokens"],
|
|
610
|
+
["max_tokens", "max_tokens"],
|
|
611
|
+
["seed", "seed"],
|
|
612
|
+
["stop", "stop"],
|
|
613
|
+
];
|
|
614
|
+
for (const [source, target] of copy) {
|
|
615
|
+
if (request[source] !== undefined) options[target] = request[source];
|
|
616
|
+
}
|
|
617
|
+
if (request.tools !== undefined) {
|
|
618
|
+
if (!Array.isArray(request.tools)) throw new TypeError("tools must be an array.");
|
|
619
|
+
options.tools = request.tools;
|
|
620
|
+
}
|
|
621
|
+
if (request.toolChoice !== undefined) options.tool_choice = request.toolChoice;
|
|
622
|
+
if (request.tool_choice !== undefined) options.tool_choice = request.tool_choice;
|
|
623
|
+
if (request.parallelToolCalls !== undefined) options.parallel_tool_calls = request.parallelToolCalls;
|
|
624
|
+
if (request.parallel_tool_calls !== undefined) options.parallel_tool_calls = request.parallel_tool_calls;
|
|
625
|
+
const format = responseFormat(request.structuredOutput);
|
|
626
|
+
if (format) options.response_format = format;
|
|
627
|
+
return options;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function validateToolCalls(message) {
|
|
631
|
+
if (message?.tool_calls === undefined) return;
|
|
632
|
+
if (!Array.isArray(message.tool_calls)) {
|
|
633
|
+
throw fail("ARCANE_AI_INVALID_PROVIDER_RESULT", "The model returned malformed tool calls.");
|
|
634
|
+
}
|
|
635
|
+
const ids = new Set();
|
|
636
|
+
for (const call of message.tool_calls) {
|
|
637
|
+
if (
|
|
638
|
+
typeof call?.id !== "string"
|
|
639
|
+
|| !call.id
|
|
640
|
+
|| ids.has(call.id)
|
|
641
|
+
|| call.type !== "function"
|
|
642
|
+
|| typeof call.function?.name !== "string"
|
|
643
|
+
|| !call.function.name
|
|
644
|
+
|| typeof call.function?.arguments !== "string"
|
|
645
|
+
) {
|
|
646
|
+
throw fail("ARCANE_AI_INVALID_PROVIDER_RESULT", "The model returned malformed tool calls.");
|
|
647
|
+
}
|
|
648
|
+
ids.add(call.id);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function validateCompletion(value, requestId) {
|
|
653
|
+
if (
|
|
654
|
+
!value
|
|
655
|
+
|| typeof value !== "object"
|
|
656
|
+
|| !Array.isArray(value.choices)
|
|
657
|
+
|| value.choices.length === 0
|
|
658
|
+
) {
|
|
659
|
+
throw fail("ARCANE_AI_INVALID_PROVIDER_RESULT", "The model returned an invalid chat completion.");
|
|
660
|
+
}
|
|
661
|
+
const indexes = new Set();
|
|
662
|
+
for (const choice of value.choices) {
|
|
663
|
+
if (!Number.isSafeInteger(choice?.index) || choice.index < 0 || indexes.has(choice.index)) {
|
|
664
|
+
throw fail("ARCANE_AI_INVALID_PROVIDER_RESULT", "The model returned an invalid choice index.");
|
|
665
|
+
}
|
|
666
|
+
indexes.add(choice.index);
|
|
667
|
+
validateToolCalls(choice.message);
|
|
668
|
+
}
|
|
669
|
+
return requestId === undefined ? value : Object.freeze({ ...value, id: requestId });
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function createCompletionAccumulator(modelId, requestId) {
|
|
673
|
+
const choices = new Map();
|
|
674
|
+
let base = { id: requestId ?? null, object: "chat.completion", model: modelId, choices: [] };
|
|
675
|
+
|
|
676
|
+
function choice(index) {
|
|
677
|
+
const key = index ?? 0;
|
|
678
|
+
if (!Number.isSafeInteger(key) || key < 0) {
|
|
679
|
+
throw fail("ARCANE_AI_INVALID_PROVIDER_RESULT", "The model returned an invalid stream choice index.");
|
|
680
|
+
}
|
|
681
|
+
if (!choices.has(key)) {
|
|
682
|
+
choices.set(key, {
|
|
683
|
+
index: key,
|
|
684
|
+
role: "assistant",
|
|
685
|
+
content: "",
|
|
686
|
+
sawContent: false,
|
|
687
|
+
reasoning: "",
|
|
688
|
+
sawReasoning: false,
|
|
689
|
+
finish_reason: null,
|
|
690
|
+
tools: new Map(),
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
return choices.get(key);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function push(value) {
|
|
697
|
+
if (!value || typeof value !== "object") return;
|
|
698
|
+
base = { ...base, ...value, id: requestId ?? value.id ?? base.id, choices: [] };
|
|
699
|
+
for (const item of Array.isArray(value.choices) ? value.choices : []) {
|
|
700
|
+
const record = choice(item.index);
|
|
701
|
+
const delta = item.delta ?? {};
|
|
702
|
+
if (typeof delta.role === "string") record.role = delta.role;
|
|
703
|
+
if (typeof delta.content === "string") {
|
|
704
|
+
record.content += delta.content;
|
|
705
|
+
record.sawContent = true;
|
|
706
|
+
}
|
|
707
|
+
if (typeof delta.reasoning_content === "string") {
|
|
708
|
+
record.reasoning += delta.reasoning_content;
|
|
709
|
+
record.sawReasoning = true;
|
|
710
|
+
}
|
|
711
|
+
if (item.finish_reason !== undefined) record.finish_reason = item.finish_reason;
|
|
712
|
+
if (delta.tool_calls !== undefined && !Array.isArray(delta.tool_calls)) {
|
|
713
|
+
throw fail("ARCANE_AI_INVALID_PROVIDER_RESULT", "The model returned malformed streamed tool calls.");
|
|
714
|
+
}
|
|
715
|
+
for (const fragment of delta.tool_calls ?? []) {
|
|
716
|
+
if (!Number.isSafeInteger(fragment?.index) || fragment.index < 0) {
|
|
717
|
+
throw fail("ARCANE_AI_INVALID_PROVIDER_RESULT", "A streamed tool call had no valid index.");
|
|
718
|
+
}
|
|
719
|
+
const tool = record.tools.get(fragment.index) ?? {
|
|
720
|
+
index: fragment.index,
|
|
721
|
+
id: "",
|
|
722
|
+
type: "",
|
|
723
|
+
name: "",
|
|
724
|
+
arguments: "",
|
|
725
|
+
};
|
|
726
|
+
if (typeof fragment.id === "string" && !tool.id) tool.id = fragment.id;
|
|
727
|
+
if (typeof fragment.type === "string") tool.type = fragment.type;
|
|
728
|
+
if (typeof fragment.function?.name === "string") tool.name += fragment.function.name;
|
|
729
|
+
if (typeof fragment.function?.arguments === "string") tool.arguments += fragment.function.arguments;
|
|
730
|
+
record.tools.set(fragment.index, tool);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function result() {
|
|
736
|
+
const completion = {
|
|
737
|
+
...base,
|
|
738
|
+
object: "chat.completion",
|
|
739
|
+
choices: [...choices.values()].sort((a, b) => a.index - b.index).map((record) => {
|
|
740
|
+
const message = {
|
|
741
|
+
role: record.role,
|
|
742
|
+
content: record.sawContent ? record.content : null,
|
|
743
|
+
};
|
|
744
|
+
if (record.sawReasoning) message.reasoning_content = record.reasoning;
|
|
745
|
+
if (record.tools.size) {
|
|
746
|
+
message.tool_calls = [...record.tools.values()]
|
|
747
|
+
.sort((a, b) => a.index - b.index)
|
|
748
|
+
.map((tool) => ({
|
|
749
|
+
id: tool.id,
|
|
750
|
+
type: tool.type,
|
|
751
|
+
function: { name: tool.name, arguments: tool.arguments },
|
|
752
|
+
}));
|
|
753
|
+
}
|
|
754
|
+
return { index: record.index, message, finish_reason: record.finish_reason };
|
|
755
|
+
}),
|
|
756
|
+
};
|
|
757
|
+
return validateCompletion(completion, requestId);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return Object.freeze({ push, result });
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function callbackStreamHandle({ runtime, request, signal, onSettled }) {
|
|
764
|
+
const linked = linkAbortSignal(signal);
|
|
765
|
+
const accumulator = createCompletionAccumulator(request.model ?? null, request.id);
|
|
766
|
+
const chunks = [];
|
|
767
|
+
const waiters = [];
|
|
768
|
+
let ended = false;
|
|
769
|
+
let terminalError = null;
|
|
770
|
+
|
|
771
|
+
function deliver(value) {
|
|
772
|
+
const chunk = request.id === undefined ? value : { ...value, id: request.id };
|
|
773
|
+
accumulator.push(chunk);
|
|
774
|
+
const waiter = waiters.shift();
|
|
775
|
+
if (waiter) waiter.resolve({ value: chunk, done: false });
|
|
776
|
+
else chunks.push(chunk);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function finish(error = null) {
|
|
780
|
+
ended = true;
|
|
781
|
+
terminalError = error;
|
|
782
|
+
while (waiters.length) {
|
|
783
|
+
const waiter = waiters.shift();
|
|
784
|
+
if (error) waiter.reject(error);
|
|
785
|
+
else waiter.resolve({ value: undefined, done: true });
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const terminal = runtime.stream(
|
|
790
|
+
completionOptions(request, linked.controller.signal, true),
|
|
791
|
+
deliver,
|
|
792
|
+
);
|
|
793
|
+
const result = Promise.resolve(terminal).then(
|
|
794
|
+
() => {
|
|
795
|
+
const value = accumulator.result();
|
|
796
|
+
finish();
|
|
797
|
+
return value;
|
|
798
|
+
},
|
|
799
|
+
(error) => {
|
|
800
|
+
const normalized = normalizeArcaneAIError(error, {
|
|
801
|
+
kind: "llm",
|
|
802
|
+
operation: "request",
|
|
803
|
+
signal: linked.controller.signal,
|
|
804
|
+
});
|
|
805
|
+
finish(normalized);
|
|
806
|
+
throw normalized;
|
|
807
|
+
},
|
|
808
|
+
).finally(() => {
|
|
809
|
+
linked.release();
|
|
810
|
+
onSettled();
|
|
811
|
+
});
|
|
812
|
+
result.catch(() => undefined);
|
|
813
|
+
|
|
814
|
+
let cancelPromise = null;
|
|
815
|
+
const handle = {
|
|
816
|
+
result,
|
|
817
|
+
async cancel(reason = "The browser-WASM request was cancelled.") {
|
|
818
|
+
if (ended) return false;
|
|
819
|
+
cancelPromise ||= (async () => {
|
|
820
|
+
linked.controller.abort(reason);
|
|
821
|
+
try {
|
|
822
|
+
await terminal;
|
|
823
|
+
} catch {
|
|
824
|
+
// The stable public error is available from result.
|
|
825
|
+
}
|
|
826
|
+
try {
|
|
827
|
+
await result;
|
|
828
|
+
} catch {
|
|
829
|
+
// Cancellation is expected to reject the terminal result.
|
|
830
|
+
}
|
|
831
|
+
return true;
|
|
832
|
+
})();
|
|
833
|
+
return cancelPromise;
|
|
834
|
+
},
|
|
835
|
+
async next() {
|
|
836
|
+
if (chunks.length) return { value: chunks.shift(), done: false };
|
|
837
|
+
if (terminalError) throw terminalError;
|
|
838
|
+
if (ended) return { value: undefined, done: true };
|
|
839
|
+
return new Promise((resolve, reject) => waiters.push({ resolve, reject }));
|
|
840
|
+
},
|
|
841
|
+
async return(value) {
|
|
842
|
+
await this.cancel("The stream consumer stopped before completion.");
|
|
843
|
+
return { value, done: true };
|
|
844
|
+
},
|
|
845
|
+
async throw(error) {
|
|
846
|
+
await this.cancel(error);
|
|
847
|
+
throw normalizeArcaneAIError(error, { kind: "llm", operation: "request" });
|
|
848
|
+
},
|
|
849
|
+
[Symbol.asyncIterator]() {
|
|
850
|
+
return this;
|
|
851
|
+
},
|
|
852
|
+
};
|
|
853
|
+
return Object.freeze(handle);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
export function createBrowserWasmLlmProvider({
|
|
857
|
+
source,
|
|
858
|
+
store,
|
|
859
|
+
loadDefaults = {},
|
|
860
|
+
logger = console,
|
|
861
|
+
} = {}) {
|
|
862
|
+
if (!BROWSER_MODEL_SOURCES.has(source)) {
|
|
863
|
+
throw new TypeError("createBrowserWasmLlmProvider requires createBrowserModelSource().");
|
|
864
|
+
}
|
|
865
|
+
if (!DBOPFS_MODEL_STORES.has(store)) {
|
|
866
|
+
throw new TypeError("createBrowserWasmLlmProvider requires createDbopfsModelStore().");
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const runtime = createPackagedWllamaRuntime({ logger });
|
|
870
|
+
let state = "unloaded";
|
|
871
|
+
let progressState = null;
|
|
872
|
+
let errorState = null;
|
|
873
|
+
let cacheState = "unknown";
|
|
874
|
+
let queueDepth = 0;
|
|
875
|
+
let disposed = false;
|
|
876
|
+
let disposing = false;
|
|
877
|
+
let disposePromise = null;
|
|
878
|
+
let loadPromise = null;
|
|
879
|
+
let loadAbort = null;
|
|
880
|
+
let unloadPromise = null;
|
|
881
|
+
let lifecycleGeneration = 0;
|
|
882
|
+
let activeAbort = null;
|
|
883
|
+
let activeCount = 0;
|
|
884
|
+
const queue = createSerialRequestQueue((depth) => { queueDepth = depth; });
|
|
885
|
+
|
|
886
|
+
function capabilities() {
|
|
887
|
+
const runtimeCapabilities = runtime.capabilities();
|
|
888
|
+
return Object.freeze({
|
|
889
|
+
localOnly: true,
|
|
890
|
+
toolCalls: "structural-only",
|
|
891
|
+
webAssembly: runtimeCapabilities.webAssembly,
|
|
892
|
+
opfs: runtimeCapabilities.opfs,
|
|
893
|
+
webgpu: runtimeCapabilities.webgpu,
|
|
894
|
+
crossOriginIsolated: runtimeCapabilities.crossOriginIsolated,
|
|
895
|
+
secureContext: runtimeCapabilities.secureContext,
|
|
896
|
+
hardwareConcurrency: runtimeCapabilities.hardwareConcurrency,
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function status() {
|
|
901
|
+
return Object.freeze({
|
|
902
|
+
protocol: ARCANE_AI_ADAPTER_PROTOCOL,
|
|
903
|
+
provider: "arcane-browser-wasm-wllama",
|
|
904
|
+
state,
|
|
905
|
+
loaded: state === "ready" && runtime.isLoaded(),
|
|
906
|
+
busy: activeCount > 0,
|
|
907
|
+
queued: Math.max(0, queueDepth - activeCount),
|
|
908
|
+
model: publicDescriptor(source),
|
|
909
|
+
cache: Object.freeze({ state: cacheState, schema: MODEL_MANIFEST_SCHEMA }),
|
|
910
|
+
progress: progressState,
|
|
911
|
+
error: errorState,
|
|
912
|
+
runtime: runtime.authority,
|
|
913
|
+
capabilities: capabilities(),
|
|
914
|
+
origin: globalThis.location?.origin ?? null,
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function report(value, options, context) {
|
|
919
|
+
progressState = value;
|
|
920
|
+
options?.onProgress?.(value);
|
|
921
|
+
context?.reportProgress?.(value);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async function load(options = {}, context = {}) {
|
|
925
|
+
if (disposed || disposing) {
|
|
926
|
+
throw fail("ARCANE_AI_DISPOSED", "The browser-WASM provider is disposed or disposing.");
|
|
927
|
+
}
|
|
928
|
+
if (unloadPromise || state === "unloading") {
|
|
929
|
+
throw fail(
|
|
930
|
+
"ARCANE_AI_OPERATION_SUPERSEDED",
|
|
931
|
+
"The browser-WASM model cannot load while unload is in progress.",
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
if (state === "ready") return Object.freeze({ model: publicDescriptor(source), status: status() });
|
|
935
|
+
if (loadPromise) return loadPromise;
|
|
936
|
+
const externalSignal = options.signal ?? context.signal ?? null;
|
|
937
|
+
const linked = linkAbortSignal(externalSignal);
|
|
938
|
+
const signal = linked.controller.signal;
|
|
939
|
+
const generation = ++lifecycleGeneration;
|
|
940
|
+
loadAbort = linked.controller;
|
|
941
|
+
state = "loading";
|
|
942
|
+
progressState = null;
|
|
943
|
+
errorState = null;
|
|
944
|
+
loadPromise = (async () => {
|
|
945
|
+
try {
|
|
946
|
+
throwIfAborted(signal, "load");
|
|
947
|
+
const admitted = await store.ensure(source, {
|
|
948
|
+
signal,
|
|
949
|
+
offline: options.offline === true,
|
|
950
|
+
onProgress: (value) => report(value, options, context),
|
|
951
|
+
});
|
|
952
|
+
cacheState = admitted.cache;
|
|
953
|
+
throwIfAborted(signal, "load");
|
|
954
|
+
if (generation !== lifecycleGeneration || state !== "loading") {
|
|
955
|
+
throw fail("ARCANE_AI_OPERATION_SUPERSEDED", "The model load was superseded by unload.");
|
|
956
|
+
}
|
|
957
|
+
report(progress(source, "initialize", source.bytes), options, context);
|
|
958
|
+
throwIfAborted(signal, "load");
|
|
959
|
+
if (generation !== lifecycleGeneration || state !== "loading") {
|
|
960
|
+
throw fail("ARCANE_AI_OPERATION_SUPERSEDED", "The model load was superseded by unload.");
|
|
961
|
+
}
|
|
962
|
+
const modelFile = typeof globalThis.File === "function"
|
|
963
|
+
? new File([admitted.file], source.name, { type: "application/octet-stream" })
|
|
964
|
+
: admitted.file;
|
|
965
|
+
await runtime.load([modelFile], {
|
|
966
|
+
...loadDefaults,
|
|
967
|
+
...options,
|
|
968
|
+
signal,
|
|
969
|
+
});
|
|
970
|
+
throwIfAborted(signal, "load");
|
|
971
|
+
if (generation !== lifecycleGeneration || state !== "loading") {
|
|
972
|
+
await runtime.exit();
|
|
973
|
+
throw fail("ARCANE_AI_OPERATION_SUPERSEDED", "The model load was superseded by unload.");
|
|
974
|
+
}
|
|
975
|
+
state = "ready";
|
|
976
|
+
progressState = null;
|
|
977
|
+
return Object.freeze({ model: publicDescriptor(source), status: status() });
|
|
978
|
+
} catch (error) {
|
|
979
|
+
await runtime.exit().catch(() => undefined);
|
|
980
|
+
const normalized = normalizeArcaneAIError(error, {
|
|
981
|
+
kind: "llm",
|
|
982
|
+
operation: "load",
|
|
983
|
+
signal,
|
|
984
|
+
});
|
|
985
|
+
if (generation === lifecycleGeneration && state === "loading") {
|
|
986
|
+
state = "error";
|
|
987
|
+
errorState = Object.freeze({ code: normalized.code, message: normalized.message });
|
|
988
|
+
}
|
|
989
|
+
throw normalized;
|
|
990
|
+
} finally {
|
|
991
|
+
if (loadAbort === linked.controller) loadAbort = null;
|
|
992
|
+
linked.release();
|
|
993
|
+
loadPromise = null;
|
|
994
|
+
}
|
|
995
|
+
})();
|
|
996
|
+
return loadPromise;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function assertReady() {
|
|
1000
|
+
if (disposed || disposing) {
|
|
1001
|
+
throw fail("ARCANE_AI_DISPOSED", "The browser-WASM provider is disposed or disposing.");
|
|
1002
|
+
}
|
|
1003
|
+
if (state !== "ready" || !runtime.isLoaded()) {
|
|
1004
|
+
throw fail("ARCANE_AI_NOT_READY", "The browser-WASM model must be loaded before use.");
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
async function chat(request = {}, context = {}) {
|
|
1009
|
+
const externalSignal = request.signal ?? context.signal ?? null;
|
|
1010
|
+
return queue.schedule(async () => {
|
|
1011
|
+
throwIfAborted(externalSignal);
|
|
1012
|
+
assertReady();
|
|
1013
|
+
const linked = linkAbortSignal(externalSignal);
|
|
1014
|
+
activeAbort = linked.controller;
|
|
1015
|
+
activeCount += 1;
|
|
1016
|
+
try {
|
|
1017
|
+
const completion = await runtime.chat(
|
|
1018
|
+
completionOptions(request, linked.controller.signal, false),
|
|
1019
|
+
);
|
|
1020
|
+
throwIfAborted(linked.controller.signal);
|
|
1021
|
+
return validateCompletion(completion, request.id);
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
throw normalizeArcaneAIError(error, {
|
|
1024
|
+
kind: "llm",
|
|
1025
|
+
operation: "request",
|
|
1026
|
+
signal: linked.controller.signal,
|
|
1027
|
+
});
|
|
1028
|
+
} finally {
|
|
1029
|
+
activeCount -= 1;
|
|
1030
|
+
activeAbort = null;
|
|
1031
|
+
linked.release();
|
|
1032
|
+
}
|
|
1033
|
+
}, externalSignal);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function stream(request = {}, context = {}) {
|
|
1037
|
+
const externalSignal = request.signal ?? context.signal ?? null;
|
|
1038
|
+
return queue.openStream(async () => {
|
|
1039
|
+
throwIfAborted(externalSignal);
|
|
1040
|
+
assertReady();
|
|
1041
|
+
activeCount += 1;
|
|
1042
|
+
let settled = false;
|
|
1043
|
+
const handle = callbackStreamHandle({
|
|
1044
|
+
runtime,
|
|
1045
|
+
request,
|
|
1046
|
+
signal: externalSignal,
|
|
1047
|
+
onSettled() {
|
|
1048
|
+
if (settled) return;
|
|
1049
|
+
settled = true;
|
|
1050
|
+
activeCount -= 1;
|
|
1051
|
+
activeAbort = null;
|
|
1052
|
+
},
|
|
1053
|
+
});
|
|
1054
|
+
activeAbort = Object.freeze({
|
|
1055
|
+
abort: (reason) => handle.cancel(reason),
|
|
1056
|
+
});
|
|
1057
|
+
return handle;
|
|
1058
|
+
}, externalSignal);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
async function unload(options = {}, context = {}) {
|
|
1062
|
+
if (unloadPromise) return unloadPromise;
|
|
1063
|
+
if (state === "unloaded" && !runtime.isLoaded()) return status();
|
|
1064
|
+
const signal = options.signal ?? context.signal ?? null;
|
|
1065
|
+
lifecycleGeneration += 1;
|
|
1066
|
+
state = "unloading";
|
|
1067
|
+
unloadPromise = (async () => {
|
|
1068
|
+
try {
|
|
1069
|
+
let activeCancellation;
|
|
1070
|
+
try {
|
|
1071
|
+
activeCancellation = Promise.resolve(
|
|
1072
|
+
activeAbort?.abort?.("The browser-WASM model is unloading."),
|
|
1073
|
+
);
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
activeCancellation = Promise.reject(error);
|
|
1076
|
+
}
|
|
1077
|
+
activeCancellation.catch(() => undefined);
|
|
1078
|
+
loadAbort?.abort("The browser-WASM model is unloading.");
|
|
1079
|
+
// Abort the public request signal before runtime.exit() force-rejects
|
|
1080
|
+
// the pinned Wllama task gate and terminates its Worker.
|
|
1081
|
+
await runtime.exit();
|
|
1082
|
+
await activeCancellation.catch(() => undefined);
|
|
1083
|
+
await loadPromise?.catch(() => undefined);
|
|
1084
|
+
await queue.idle();
|
|
1085
|
+
throwIfAborted(signal, "unload");
|
|
1086
|
+
await runtime.exit();
|
|
1087
|
+
state = "unloaded";
|
|
1088
|
+
progressState = null;
|
|
1089
|
+
errorState = null;
|
|
1090
|
+
return status();
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
const normalized = normalizeArcaneAIError(error, {
|
|
1093
|
+
kind: "llm",
|
|
1094
|
+
operation: "unload",
|
|
1095
|
+
signal,
|
|
1096
|
+
});
|
|
1097
|
+
state = "error";
|
|
1098
|
+
errorState = Object.freeze({ code: normalized.code, message: normalized.message });
|
|
1099
|
+
throw normalized;
|
|
1100
|
+
} finally {
|
|
1101
|
+
unloadPromise = null;
|
|
1102
|
+
}
|
|
1103
|
+
})();
|
|
1104
|
+
return unloadPromise;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async function dispose(options = {}, context = {}) {
|
|
1108
|
+
if (disposed) return status();
|
|
1109
|
+
if (disposePromise) return disposePromise;
|
|
1110
|
+
disposing = true;
|
|
1111
|
+
disposePromise = (async () => {
|
|
1112
|
+
try {
|
|
1113
|
+
await unload(options, context);
|
|
1114
|
+
disposed = true;
|
|
1115
|
+
return status();
|
|
1116
|
+
} finally {
|
|
1117
|
+
disposing = false;
|
|
1118
|
+
disposePromise = null;
|
|
1119
|
+
}
|
|
1120
|
+
})();
|
|
1121
|
+
return disposePromise;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async function probe(options = {}) {
|
|
1125
|
+
if (disposed || disposing) {
|
|
1126
|
+
throw fail("ARCANE_AI_DISPOSED", "The browser-WASM provider is disposed or disposing.");
|
|
1127
|
+
}
|
|
1128
|
+
if (state !== "unloaded" || runtime.isLoaded()) {
|
|
1129
|
+
throw fail("ARCANE_AI_NOT_READY", "Unload the model before running the no-model WASM probe.");
|
|
1130
|
+
}
|
|
1131
|
+
return runtime.probe(options);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
return Object.freeze({
|
|
1135
|
+
protocol: ARCANE_AI_ADAPTER_PROTOCOL,
|
|
1136
|
+
id: "arcane-browser-wasm-wllama",
|
|
1137
|
+
model: publicDescriptor(source),
|
|
1138
|
+
capabilities,
|
|
1139
|
+
status,
|
|
1140
|
+
load,
|
|
1141
|
+
unload,
|
|
1142
|
+
chat,
|
|
1143
|
+
stream,
|
|
1144
|
+
streamChat: stream,
|
|
1145
|
+
use: chat,
|
|
1146
|
+
probe,
|
|
1147
|
+
dispose,
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
export { MODEL_MANIFEST_SCHEMA };
|