arcane-os 0.1.2 → 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/CHANGELOG.md +17 -0
- package/NOTICE +5 -3
- package/README.md +73 -24
- package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +67 -18
- package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +16 -5
- package/browser-runtime/ai/browser-kokoro-worker.mjs +3 -0
- package/browser-runtime/ai/browser-speech-artifacts.mjs +1108 -0
- package/browser-runtime/ai/browser-speech-providers.mjs +475 -0
- package/browser-runtime/ai/browser-speech.mjs +9 -0
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +1537 -167
- package/browser-runtime/ai/browser-wasm.mjs +46 -1
- package/browser-runtime/ai/browser-whisper-worker.mjs +3 -0
- package/browser-runtime/ai/browser-wllama-runtime.mjs +677 -132
- package/browser-runtime/ai/model-controller.mjs +138 -12
- package/browser-runtime/ai/speech-worker-client.mjs +207 -0
- package/browser-runtime/ai/speech-worker-runtime.mjs +516 -0
- package/browser-runtime/ai/wllama/index.mjs +389 -0
- package/docs/architecture.md +132 -22
- package/docs/reference/README.md +1 -1
- package/docs/reference/ai/browser-wasm.md +101 -42
- package/docs/reference/availability-and-normalization.md +19 -5
- package/docs/reference/behavioral-testing.md +18 -5
- package/docs/reference/cli.md +2 -2
- package/docs/reference/inventory/package-api.json +14 -14
- package/docs/reference/protocols.md +4 -4
- package/docs/reference/sdk-api.md +68 -38
- package/docs/work-amplification.md +8 -4
- package/package.json +7 -3
- package/runtime/ARCANE_RUNTIME_RELEASE.json +50 -20
- package/runtime/arcane/components/chat.html +280 -62
- package/runtime/arcane/components/speech.html +1113 -265
- package/runtime/arcane/entities/Chat.js +246 -43
- package/runtime/arcane/modules/AI.js +713 -162
- package/runtime/arcane/modules/AIProviderRuntime.js +2289 -0
- package/runtime/arcane/modules/AIRuntimeState.js +872 -0
- package/runtime/arcane/modules/ConfiguredAIChatSession.js +293 -27
- package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +682 -0
- package/runtime/arcane/modules/DocumentLexicalSearch.js +292 -0
- package/runtime/arcane/modules/PersistentAIChatSession.js +268 -0
- package/runtime/arcane/modules/StaticDocumentCatalog.js +25 -206
- package/schemas/arcane-lock.schema.json +6 -4
- package/src/cli/main.mjs +14 -2
- package/src/constants.mjs +1 -1
- package/src/dev-server.mjs +244 -13
- package/src/import-map.mjs +59 -1
- package/src/packager/core.mjs +2 -2
- package/src/runtime.mjs +14 -4
- package/src/sdk-browser-runtime.mjs +28 -75
- package/src/templates/workspace-template.mjs +4 -4
- package/src/toolchain.mjs +3 -0
- package/src/workspace-runtime.mjs +1 -1
- package/src/workspace.mjs +1 -1
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ARCANE_AI_ADAPTER_PROTOCOL,
|
|
3
3
|
ArcaneAIError,
|
|
4
|
+
normalizeModelSecurity,
|
|
4
5
|
normalizeArcaneAIError,
|
|
6
|
+
resolveModelSecurity,
|
|
7
|
+
sameModelSecurity,
|
|
5
8
|
} from "./model-controller.mjs";
|
|
6
9
|
import { createPackagedWllamaRuntime } from "./browser-wllama-runtime.mjs";
|
|
7
10
|
import { createStreamingSha256 } from "./internal/sha256.mjs";
|
|
11
|
+
import { arcaneEvents } from "../event-manager.mjs";
|
|
8
12
|
|
|
9
|
-
const MODEL_MANIFEST_SCHEMA = "arcane.ai.browser-wasm.model.
|
|
13
|
+
const MODEL_MANIFEST_SCHEMA = "arcane.ai.browser-wasm.model.v4";
|
|
14
|
+
const SINGLE_MODEL_MANIFEST_SCHEMA = "arcane.ai.browser-wasm.model.v3";
|
|
15
|
+
const LEGACY_MODEL_MANIFEST_SCHEMA = "arcane.ai.browser-wasm.model.v2";
|
|
10
16
|
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
|
11
17
|
const MUTABLE_PATH_PATTERN = /\/(?:resolve\/)?(?:main|master|latest)(?:\/|$)/iu;
|
|
12
18
|
const BROWSER_MODEL_SOURCES = new WeakSet();
|
|
19
|
+
const BROWSER_MODEL_SOURCE_METADATA = new WeakMap();
|
|
20
|
+
const MODEL_DESCRIPTOR_METADATA = new WeakMap();
|
|
13
21
|
const DBOPFS_MODEL_STORES = new WeakSet();
|
|
22
|
+
const V1_LLM_PROVIDER_ADAPTERS = new WeakMap();
|
|
23
|
+
const AI_PROVIDER_PROTOCOL = "arcane-ai-provider/2";
|
|
24
|
+
const AI_MODEL_AUTHORITY_PROTOCOL = "arcane-ai-model-authority/1";
|
|
25
|
+
const WEBGPU_ADAPTER_SELECTED_EVENT = "arcane.ai.browser-wasm.webgpu.adapter.selected";
|
|
26
|
+
const WEBGPU_ADAPTER_SELECTION_PROTOCOL = "arcane-ai-webgpu-adapter-selection/1";
|
|
27
|
+
const CHROME_HIGH_PERFORMANCE_GPU_FLAG_URL =
|
|
28
|
+
"chrome://flags/#force-high-performance-gpu";
|
|
29
|
+
const INTEL_VENDOR_ID = 0x8086;
|
|
30
|
+
const CAPABILITY_POLICY_PROTOCOL = "arcane-ai-browser-capability-policy/1";
|
|
31
|
+
const WLLAMA_MAX_FILE_BYTES = 2_000_000_000;
|
|
32
|
+
let highPerformanceGpuNoticeShown = false;
|
|
14
33
|
|
|
15
34
|
function fail(code, message, cause) {
|
|
16
35
|
return new ArcaneAIError(code, message, {
|
|
@@ -29,6 +48,10 @@ function throwIfAborted(signal, operation = "request") {
|
|
|
29
48
|
);
|
|
30
49
|
}
|
|
31
50
|
|
|
51
|
+
function normalizationSignal(error, signal) {
|
|
52
|
+
return error?.code === "ARCANE_AI_WORKER_TERMINATION_UNCONFIRMED" ? null : signal;
|
|
53
|
+
}
|
|
54
|
+
|
|
32
55
|
function immutableHttpsUrl(value) {
|
|
33
56
|
let url;
|
|
34
57
|
try {
|
|
@@ -53,63 +76,251 @@ function requiredText(value, field) {
|
|
|
53
76
|
return value.trim();
|
|
54
77
|
}
|
|
55
78
|
|
|
79
|
+
function modelIdText(value) {
|
|
80
|
+
const id = requiredText(value, "id");
|
|
81
|
+
for (let index = 0; index < id.length; index += 1) {
|
|
82
|
+
const code = id.charCodeAt(index);
|
|
83
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
84
|
+
const next = id.charCodeAt(index + 1);
|
|
85
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) {
|
|
86
|
+
throw new TypeError("Browser model id must contain only Unicode scalar values.");
|
|
87
|
+
}
|
|
88
|
+
index += 1;
|
|
89
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
90
|
+
throw new TypeError("Browser model id must contain only Unicode scalar values.");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (id.normalize("NFC") !== id) {
|
|
94
|
+
throw new TypeError("Browser model id must use Unicode NFC normalization.");
|
|
95
|
+
}
|
|
96
|
+
return id;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function descriptorFileName(value, url, fallbackName = null) {
|
|
100
|
+
const suppliedName = value.name;
|
|
101
|
+
let name = suppliedName;
|
|
102
|
+
if (name === undefined) {
|
|
103
|
+
const encoded = url.pathname.split("/").filter(Boolean).pop() ?? "";
|
|
104
|
+
try {
|
|
105
|
+
name = decodeURIComponent(encoded);
|
|
106
|
+
} catch {
|
|
107
|
+
name = "";
|
|
108
|
+
}
|
|
109
|
+
if (!name && fallbackName) name = fallbackName;
|
|
110
|
+
}
|
|
111
|
+
name = requiredText(name, "file name");
|
|
112
|
+
if (
|
|
113
|
+
(suppliedName !== undefined && suppliedName !== name)
|
|
114
|
+
|| name !== name.split(/[\\/]/u).pop()
|
|
115
|
+
|| name === "."
|
|
116
|
+
|| name === ".."
|
|
117
|
+
|| name.endsWith(".")
|
|
118
|
+
|| name.endsWith(" ")
|
|
119
|
+
|| /[<>:"|?*]/u.test(name)
|
|
120
|
+
|| /[\u0000-\u001f\u007f]/u.test(name)
|
|
121
|
+
) {
|
|
122
|
+
throw new TypeError("Browser model file names must be safe single filenames.");
|
|
123
|
+
}
|
|
124
|
+
return name;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function descriptorFile(value, { fallbackName = null } = {}) {
|
|
128
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
129
|
+
throw new TypeError("Each browser model file descriptor must be an object.");
|
|
130
|
+
}
|
|
131
|
+
if (
|
|
132
|
+
value.url !== undefined
|
|
133
|
+
&& value.immutableUrl !== undefined
|
|
134
|
+
&& value.url !== value.immutableUrl
|
|
135
|
+
) {
|
|
136
|
+
throw new TypeError("Browser model file url and legacy immutableUrl must match when both are provided.");
|
|
137
|
+
}
|
|
138
|
+
const url = immutableHttpsUrl(value.url ?? value.immutableUrl);
|
|
139
|
+
if (!url) {
|
|
140
|
+
throw new TypeError("Browser model file url must be immutable HTTPS without credentials or fragments.");
|
|
141
|
+
}
|
|
142
|
+
const file = {
|
|
143
|
+
name: descriptorFileName(value, url, fallbackName),
|
|
144
|
+
url: url.href,
|
|
145
|
+
};
|
|
146
|
+
if (value.bytes !== undefined) {
|
|
147
|
+
if (!Number.isSafeInteger(value.bytes) || value.bytes < 1) {
|
|
148
|
+
throw new TypeError("Browser model file bytes must be a positive safe integer when provided.");
|
|
149
|
+
}
|
|
150
|
+
file.bytes = value.bytes;
|
|
151
|
+
}
|
|
152
|
+
if (value.sha256 !== undefined) {
|
|
153
|
+
const sha256 = requiredText(value.sha256, "file sha256").toLowerCase();
|
|
154
|
+
if (!SHA256_PATTERN.test(sha256)) {
|
|
155
|
+
throw new TypeError("Browser model file sha256 must be exactly 64 hexadecimal characters when provided.");
|
|
156
|
+
}
|
|
157
|
+
file.sha256 = sha256;
|
|
158
|
+
}
|
|
159
|
+
return Object.freeze(file);
|
|
160
|
+
}
|
|
161
|
+
|
|
56
162
|
function modelDescriptor(value) {
|
|
57
163
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
58
164
|
throw new TypeError("A browser model descriptor is required.");
|
|
59
165
|
}
|
|
60
|
-
const id =
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
166
|
+
const id = modelIdText(value.id);
|
|
167
|
+
const hasFiles = value.files !== undefined;
|
|
168
|
+
const hasLegacyFile = ["url", "immutableUrl", "name", "bytes", "sha256"]
|
|
169
|
+
.some((field) => value[field] !== undefined);
|
|
170
|
+
if (hasFiles && hasLegacyFile) {
|
|
171
|
+
throw new TypeError("Browser model files[] is mutually exclusive with legacy one-file fields.");
|
|
64
172
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
173
|
+
let files;
|
|
174
|
+
let legacy = false;
|
|
175
|
+
if (hasFiles) {
|
|
176
|
+
if (!Array.isArray(value.files) || value.files.length === 0) {
|
|
177
|
+
throw new TypeError("Browser model files must be a nonempty ordered array.");
|
|
178
|
+
}
|
|
179
|
+
files = value.files.map((file) => descriptorFile(file));
|
|
180
|
+
} else {
|
|
181
|
+
legacy = true;
|
|
182
|
+
const safeId = id.replace(/[^a-z0-9._-]+/giu, "_");
|
|
183
|
+
files = [descriptorFile(value, { fallbackName: `${safeId}.gguf` })];
|
|
68
184
|
}
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
185
|
+
const names = new Set();
|
|
186
|
+
const urls = new Set();
|
|
187
|
+
for (const file of files) {
|
|
188
|
+
const nameKey = file.name.toLowerCase();
|
|
189
|
+
if (names.has(nameKey)) {
|
|
190
|
+
throw new TypeError("Browser model file names must be unique.");
|
|
191
|
+
}
|
|
192
|
+
if (urls.has(file.url)) {
|
|
193
|
+
throw new TypeError("Browser model file URLs must be unique.");
|
|
194
|
+
}
|
|
195
|
+
names.add(nameKey);
|
|
196
|
+
urls.add(file.url);
|
|
72
197
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
198
|
+
files = Object.freeze(files);
|
|
199
|
+
let descriptor;
|
|
200
|
+
if (legacy) {
|
|
201
|
+
const [file] = files;
|
|
202
|
+
descriptor = { id, url: file.url };
|
|
203
|
+
if (file.bytes !== undefined) descriptor.bytes = file.bytes;
|
|
204
|
+
if (file.sha256 !== undefined) descriptor.sha256 = file.sha256;
|
|
205
|
+
} else {
|
|
206
|
+
descriptor = { id, files };
|
|
76
207
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return
|
|
80
|
-
id,
|
|
81
|
-
name,
|
|
82
|
-
immutableUrl: immutableUrl.href,
|
|
83
|
-
bytes,
|
|
84
|
-
sha256,
|
|
85
|
-
licenseSpdx,
|
|
86
|
-
sourceRevision,
|
|
87
|
-
});
|
|
208
|
+
descriptor = Object.freeze(descriptor);
|
|
209
|
+
MODEL_DESCRIPTOR_METADATA.set(descriptor, Object.freeze({ files, legacy }));
|
|
210
|
+
return descriptor;
|
|
88
211
|
}
|
|
89
212
|
|
|
90
213
|
function publicDescriptor(source) {
|
|
214
|
+
if (source?.descriptor && MODEL_DESCRIPTOR_METADATA.has(source.descriptor)) {
|
|
215
|
+
return source.descriptor;
|
|
216
|
+
}
|
|
217
|
+
if (MODEL_DESCRIPTOR_METADATA.has(source)) return source;
|
|
218
|
+
return modelDescriptor(source);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isChromeBrowser() {
|
|
222
|
+
const userAgent = String(globalThis.navigator?.userAgent ?? "");
|
|
223
|
+
return /\b(?:Chrome|Chromium)\//u.test(userAgent)
|
|
224
|
+
&& !/\b(?:Edg|OPR)\//u.test(userAgent);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isLowerPowerIntelAdapter(adapter) {
|
|
228
|
+
const identity = [
|
|
229
|
+
adapter.vendor,
|
|
230
|
+
adapter.architecture,
|
|
231
|
+
adapter.name,
|
|
232
|
+
adapter.description,
|
|
233
|
+
].filter(Boolean).join(" ");
|
|
234
|
+
if (adapter?.vendorId !== INTEL_VENDOR_ID && !/\bintel\b/iu.test(identity)) return false;
|
|
235
|
+
return /(?:intel|integrated|xe-lp)/iu.test(identity);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function notifyChromeHighPerformanceGpu(adapter) {
|
|
239
|
+
if (
|
|
240
|
+
highPerformanceGpuNoticeShown
|
|
241
|
+
|| !isChromeBrowser()
|
|
242
|
+
|| !isLowerPowerIntelAdapter(adapter)
|
|
243
|
+
) return;
|
|
244
|
+
highPerformanceGpuNoticeShown = true;
|
|
245
|
+
try {
|
|
246
|
+
globalThis.open?.(CHROME_HIGH_PERFORMANCE_GPU_FLAG_URL, "_blank", "noopener,noreferrer");
|
|
247
|
+
} catch {
|
|
248
|
+
// Chrome may reject internal-page navigation from web content.
|
|
249
|
+
}
|
|
250
|
+
const adapterName = adapter.description || adapter.name
|
|
251
|
+
|| [adapter.vendor, adapter.architecture].filter(Boolean).join(" ")
|
|
252
|
+
|| "a lower-power Intel adapter";
|
|
253
|
+
globalThis.alert?.(
|
|
254
|
+
`Arcane selected the lower-power WebGPU adapter: ${adapterName}.\n\n`
|
|
255
|
+
+ "Enable “Force High Performance GPU” in the Chrome flags window. "
|
|
256
|
+
+ "Then completely close every Chrome window and reopen Chrome before loading the model again.\n\n"
|
|
257
|
+
+ `If the flags window did not open, paste ${CHROME_HIGH_PERFORMANCE_GPU_FLAG_URL} into Chrome.`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function emitWebgpuAdapterSelection(source, runtime) {
|
|
262
|
+
const evidence = runtime.evidence();
|
|
263
|
+
const webgpu = evidence?.webgpu;
|
|
264
|
+
if (webgpu?.observed !== true || !webgpu.adapter) return;
|
|
265
|
+
try {
|
|
266
|
+
arcaneEvents.instrument(WEBGPU_ADAPTER_SELECTED_EVENT, Object.freeze({
|
|
267
|
+
protocol: WEBGPU_ADAPTER_SELECTION_PROTOCOL,
|
|
268
|
+
providerId: "arcane-browser-wasm-wllama",
|
|
269
|
+
modelId: source.id,
|
|
270
|
+
runtimeEvidenceProtocol: evidence.protocol,
|
|
271
|
+
adapter: webgpu.adapter,
|
|
272
|
+
offload: webgpu.offload ?? null,
|
|
273
|
+
buffers: webgpu.buffers ?? null,
|
|
274
|
+
queue: webgpu.queue ?? null,
|
|
275
|
+
}), Object.freeze({
|
|
276
|
+
source: "sdk:ai/browser-wasm",
|
|
277
|
+
category: "capability",
|
|
278
|
+
}));
|
|
279
|
+
} finally {
|
|
280
|
+
notifyChromeHighPerformanceGpu(webgpu.adapter);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function sourceMetadata(source) {
|
|
285
|
+
return BROWSER_MODEL_SOURCE_METADATA.get(source)
|
|
286
|
+
?? MODEL_DESCRIPTOR_METADATA.get(publicDescriptor(source));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function manifestModelIdentity(source) {
|
|
91
290
|
return Object.freeze({
|
|
92
291
|
id: source.id,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
licenseSpdx: source.licenseSpdx,
|
|
98
|
-
sourceRevision: source.sourceRevision,
|
|
292
|
+
files: Object.freeze(sourceMetadata(source).files.map((file) => Object.freeze({
|
|
293
|
+
name: file.name,
|
|
294
|
+
url: file.url,
|
|
295
|
+
}))),
|
|
99
296
|
});
|
|
100
297
|
}
|
|
101
298
|
|
|
102
299
|
/**
|
|
103
|
-
* Creates
|
|
104
|
-
*
|
|
105
|
-
*
|
|
300
|
+
* Creates a browser download authority for one caller-supplied immutable
|
|
301
|
+
* model file set. Legacy one-file descriptors normalize to one ordered member.
|
|
302
|
+
* Effective load security decides which expected-byte checks run.
|
|
106
303
|
*/
|
|
107
304
|
export function createBrowserModelSource(descriptor, {
|
|
108
305
|
fetchImpl = null,
|
|
109
306
|
} = {}) {
|
|
110
307
|
const model = modelDescriptor(descriptor);
|
|
308
|
+
const metadata = MODEL_DESCRIPTOR_METADATA.get(model);
|
|
111
309
|
|
|
112
|
-
async function open(
|
|
310
|
+
async function open(memberOrOptions = 0, options = {}) {
|
|
311
|
+
let memberIndex = memberOrOptions;
|
|
312
|
+
if (!Number.isSafeInteger(memberOrOptions)) {
|
|
313
|
+
if (metadata.files.length !== 1) {
|
|
314
|
+
throw new TypeError("A browser model file index is required for a multi-file source.");
|
|
315
|
+
}
|
|
316
|
+
memberIndex = 0;
|
|
317
|
+
options = memberOrOptions ?? {};
|
|
318
|
+
}
|
|
319
|
+
if (memberIndex < 0 || memberIndex >= metadata.files.length) {
|
|
320
|
+
throw new RangeError("Browser model file index is out of range.");
|
|
321
|
+
}
|
|
322
|
+
const member = metadata.files[memberIndex];
|
|
323
|
+
const { signal } = options;
|
|
113
324
|
throwIfAborted(signal, "install");
|
|
114
325
|
const fetchFunction = fetchImpl ?? globalThis.fetch?.bind(globalThis);
|
|
115
326
|
if (typeof fetchFunction !== "function") {
|
|
@@ -118,7 +329,7 @@ export function createBrowserModelSource(descriptor, {
|
|
|
118
329
|
|
|
119
330
|
let response;
|
|
120
331
|
try {
|
|
121
|
-
response = await fetchFunction(
|
|
332
|
+
response = await fetchFunction(member.url, {
|
|
122
333
|
cache: "no-store",
|
|
123
334
|
credentials: "omit",
|
|
124
335
|
mode: "cors",
|
|
@@ -139,7 +350,7 @@ export function createBrowserModelSource(descriptor, {
|
|
|
139
350
|
}
|
|
140
351
|
let finalUrl;
|
|
141
352
|
try {
|
|
142
|
-
finalUrl = new URL(response.url ||
|
|
353
|
+
finalUrl = new URL(response.url || member.url);
|
|
143
354
|
} catch {
|
|
144
355
|
finalUrl = null;
|
|
145
356
|
}
|
|
@@ -151,31 +362,33 @@ export function createBrowserModelSource(descriptor, {
|
|
|
151
362
|
throw fail("ARCANE_AI_MODEL_SOURCE_INVALID", "The model response did not provide a byte stream.");
|
|
152
363
|
}
|
|
153
364
|
const header = response.headers?.get?.("content-length");
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
}
|
|
365
|
+
const reported = header === null || header === undefined || header === ""
|
|
366
|
+
? null
|
|
367
|
+
: Number(header);
|
|
164
368
|
return Object.freeze({
|
|
165
369
|
body: response.body,
|
|
166
|
-
requestedUrl:
|
|
370
|
+
requestedUrl: member.url,
|
|
167
371
|
finalUrl: finalUrl.href,
|
|
372
|
+
reportedBytes: Number.isSafeInteger(reported) && reported >= 0 ? reported : null,
|
|
168
373
|
cancel: (reason) => response.body.cancel(reason),
|
|
169
374
|
});
|
|
170
375
|
}
|
|
171
376
|
|
|
172
|
-
const
|
|
173
|
-
kind: "arcane-
|
|
377
|
+
const sourceRecord = {
|
|
378
|
+
kind: "arcane-browser-model-source",
|
|
174
379
|
...model,
|
|
175
|
-
descriptor:
|
|
380
|
+
descriptor: model,
|
|
176
381
|
open,
|
|
177
|
-
}
|
|
382
|
+
};
|
|
383
|
+
if (metadata.legacy) {
|
|
384
|
+
Object.defineProperties(sourceRecord, {
|
|
385
|
+
name: { value: metadata.files[0].name, enumerable: false },
|
|
386
|
+
immutableUrl: { value: metadata.files[0].url, enumerable: false },
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
const source = Object.freeze(sourceRecord);
|
|
178
390
|
BROWSER_MODEL_SOURCES.add(source);
|
|
391
|
+
BROWSER_MODEL_SOURCE_METADATA.set(source, metadata);
|
|
179
392
|
return source;
|
|
180
393
|
}
|
|
181
394
|
|
|
@@ -228,55 +441,229 @@ async function* byteChunks(body, signal) {
|
|
|
228
441
|
throw fail("ARCANE_AI_MODEL_SOURCE_INVALID", "The model source did not provide readable bytes.");
|
|
229
442
|
}
|
|
230
443
|
|
|
231
|
-
function
|
|
232
|
-
|
|
444
|
+
function injectiveStorageId(id) {
|
|
445
|
+
return Array.from(
|
|
446
|
+
new TextEncoder().encode(id),
|
|
447
|
+
(value) => value.toString(16).padStart(2, "0"),
|
|
448
|
+
).join("");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function storageName(source, { legacy = false } = {}) {
|
|
452
|
+
const safeId = legacy
|
|
453
|
+
? source.id.replace(/[^a-z0-9._-]+/giu, "_")
|
|
454
|
+
: `id-${injectiveStorageId(source.id)}`;
|
|
455
|
+
const models = sourceMetadata(source).files.map((file) => Object.freeze({
|
|
456
|
+
file,
|
|
457
|
+
name: `${safeId}--${file.name}`,
|
|
458
|
+
}));
|
|
233
459
|
return Object.freeze({
|
|
234
|
-
|
|
460
|
+
models: Object.freeze(models),
|
|
461
|
+
model: models[0].name,
|
|
235
462
|
manifest: `${safeId}.complete.json`,
|
|
236
463
|
});
|
|
237
464
|
}
|
|
238
465
|
|
|
239
|
-
function manifestFor(source,
|
|
466
|
+
function manifestFor(source, files) {
|
|
467
|
+
const observedBytes = files.reduce((total, file) => total + file.observedBytes, 0);
|
|
240
468
|
return Object.freeze({
|
|
241
469
|
schema: MODEL_MANIFEST_SCHEMA,
|
|
242
470
|
complete: true,
|
|
243
|
-
model:
|
|
244
|
-
|
|
471
|
+
model: manifestModelIdentity(source),
|
|
472
|
+
files: Object.freeze(files.map((file) => Object.freeze({
|
|
473
|
+
name: file.name,
|
|
474
|
+
finalUrl: file.finalUrl,
|
|
475
|
+
observedBytes: file.observedBytes,
|
|
476
|
+
}))),
|
|
477
|
+
observedBytes,
|
|
245
478
|
completedAt: new Date().toISOString(),
|
|
246
479
|
});
|
|
247
480
|
}
|
|
248
481
|
|
|
249
|
-
function
|
|
482
|
+
function manifestByteLength(manifest) {
|
|
483
|
+
return new TextEncoder().encode(`${JSON.stringify(manifest)}\n`).byteLength;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function projectedManifestByteLength(source) {
|
|
487
|
+
return manifestByteLength(manifestFor(
|
|
488
|
+
source,
|
|
489
|
+
sourceMetadata(source).files.map((file) => ({
|
|
490
|
+
name: file.name,
|
|
491
|
+
finalUrl: file.url,
|
|
492
|
+
observedBytes: file.bytes,
|
|
493
|
+
})),
|
|
494
|
+
));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function manifestKind(manifest, source) {
|
|
250
498
|
const model = manifest?.model;
|
|
251
|
-
|
|
499
|
+
const members = sourceMetadata(source).files;
|
|
500
|
+
if (
|
|
501
|
+
manifest?.schema === MODEL_MANIFEST_SCHEMA
|
|
502
|
+
&& manifest?.complete === true
|
|
503
|
+
&& model?.id === source.id
|
|
504
|
+
&& Array.isArray(model?.files)
|
|
505
|
+
&& model.files.length === members.length
|
|
506
|
+
&& model.files.every((file, index) => (
|
|
507
|
+
file?.name === members[index].name
|
|
508
|
+
&& file?.url === members[index].url
|
|
509
|
+
))
|
|
510
|
+
&& Array.isArray(manifest.files)
|
|
511
|
+
&& manifest.files.length === members.length
|
|
512
|
+
&& manifest.files.every((file, index) => (
|
|
513
|
+
file?.name === members[index].name
|
|
514
|
+
&& Number.isSafeInteger(file?.observedBytes)
|
|
515
|
+
&& file.observedBytes >= 0
|
|
516
|
+
&& immutableHttpsUrl(file?.finalUrl)
|
|
517
|
+
))
|
|
518
|
+
&& Number.isSafeInteger(manifest.observedBytes)
|
|
519
|
+
&& manifest.observedBytes >= 0
|
|
520
|
+
&& manifest.files.reduce((total, file) => total + file.observedBytes, 0)
|
|
521
|
+
=== manifest.observedBytes
|
|
522
|
+
) return "set";
|
|
523
|
+
if (!sourceMetadata(source).legacy || members.length !== 1) return null;
|
|
524
|
+
const [member] = members;
|
|
525
|
+
if (
|
|
526
|
+
manifest?.schema === SINGLE_MODEL_MANIFEST_SCHEMA
|
|
527
|
+
&& manifest?.complete === true
|
|
528
|
+
&& model?.id === source.id
|
|
529
|
+
&& model?.url === member.url
|
|
530
|
+
&& Number.isSafeInteger(manifest.observedBytes)
|
|
531
|
+
&& manifest.observedBytes >= 0
|
|
532
|
+
) return "single";
|
|
533
|
+
if (
|
|
534
|
+
manifest?.schema === LEGACY_MODEL_MANIFEST_SCHEMA
|
|
252
535
|
&& manifest?.complete === true
|
|
253
536
|
&& model?.id === source.id
|
|
254
|
-
&& model?.name ===
|
|
255
|
-
&& model?.immutableUrl ===
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
&& model?.licenseSpdx === source.licenseSpdx
|
|
259
|
-
&& model?.sourceRevision === source.sourceRevision;
|
|
537
|
+
&& model?.name === member.name
|
|
538
|
+
&& model?.immutableUrl === member.url
|
|
539
|
+
) return "legacy";
|
|
540
|
+
return null;
|
|
260
541
|
}
|
|
261
542
|
|
|
262
|
-
function progress(source, phase, loaded) {
|
|
543
|
+
function progress(source, phase, loaded, total = null, memberIndex = 0, memberLoaded = loaded) {
|
|
544
|
+
const members = sourceMetadata(source).files;
|
|
545
|
+
const member = members[memberIndex] ?? members[0];
|
|
263
546
|
return Object.freeze({
|
|
264
547
|
modelId: source.id,
|
|
265
548
|
phase,
|
|
266
549
|
loaded,
|
|
267
|
-
total
|
|
268
|
-
percent:
|
|
550
|
+
total,
|
|
551
|
+
percent: Number.isSafeInteger(total) && total > 0 ? (loaded / total) * 100 : null,
|
|
552
|
+
file: Object.freeze({
|
|
553
|
+
index: memberIndex,
|
|
554
|
+
count: members.length,
|
|
555
|
+
name: member.name,
|
|
556
|
+
loaded: memberLoaded,
|
|
557
|
+
total: member.bytes ?? null,
|
|
558
|
+
}),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function securitySnapshot(security) {
|
|
563
|
+
return Object.freeze({
|
|
564
|
+
secure: security.secure,
|
|
565
|
+
checks: Object.freeze({
|
|
566
|
+
byteLength: security.checks.byteLength,
|
|
567
|
+
sha256: security.checks.sha256,
|
|
568
|
+
}),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function assertDescriptorChecks(source, security) {
|
|
573
|
+
const files = sourceMetadata(source).files;
|
|
574
|
+
if (security.checks.byteLength && files.some((file) => file.bytes === undefined)) {
|
|
575
|
+
throw fail(
|
|
576
|
+
"ARCANE_AI_MODEL_SOURCE_INVALID",
|
|
577
|
+
"Browser model bytes is required for every file when the byteLength check is enabled.",
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
if (security.checks.sha256 && files.some((file) => file.sha256 === undefined)) {
|
|
581
|
+
throw fail(
|
|
582
|
+
"ARCANE_AI_MODEL_SOURCE_INVALID",
|
|
583
|
+
"Browser model sha256 is required for every file when the sha256 check is enabled.",
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function knownModelBytes(source) {
|
|
589
|
+
const files = sourceMetadata(source).files;
|
|
590
|
+
if (files.some((file) => file.bytes === undefined)) return null;
|
|
591
|
+
const total = files.reduce((sum, file) => sum + file.bytes, 0);
|
|
592
|
+
return Number.isSafeInteger(total) ? total : null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function oversizedModelFile(source) {
|
|
596
|
+
return sourceMetadata(source).files.find(
|
|
597
|
+
(file) => file.bytes !== undefined && file.bytes > WLLAMA_MAX_FILE_BYTES,
|
|
598
|
+
) ?? null;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function integritySnapshot(security, source, {
|
|
602
|
+
observedBytes = null,
|
|
603
|
+
byteLength = security.checks.byteLength ? "pending" : "unchecked",
|
|
604
|
+
sha256 = security.checks.sha256 ? "pending" : "unchecked",
|
|
605
|
+
actualSha256 = null,
|
|
606
|
+
files = null,
|
|
607
|
+
} = {}) {
|
|
608
|
+
const enabledStates = [];
|
|
609
|
+
if (security.checks.byteLength) enabledStates.push(byteLength);
|
|
610
|
+
if (security.checks.sha256) enabledStates.push(sha256);
|
|
611
|
+
const state = enabledStates.length === 0
|
|
612
|
+
? "unchecked"
|
|
613
|
+
: enabledStates.every((value) => value === "verified")
|
|
614
|
+
? "verified"
|
|
615
|
+
: enabledStates.some((value) => value === "failed")
|
|
616
|
+
? "failed"
|
|
617
|
+
: "pending";
|
|
618
|
+
const members = sourceMetadata(source).files;
|
|
619
|
+
const fileStates = members.map((member, index) => {
|
|
620
|
+
const evidence = files?.[index] ?? {};
|
|
621
|
+
return Object.freeze({
|
|
622
|
+
name: member.name,
|
|
623
|
+
observedBytes: evidence.observedBytes ?? null,
|
|
624
|
+
byteLength: Object.freeze({
|
|
625
|
+
enabled: security.checks.byteLength,
|
|
626
|
+
state: evidence.byteLength ?? byteLength,
|
|
627
|
+
expected: member.bytes ?? null,
|
|
628
|
+
observed: evidence.observedBytes ?? null,
|
|
629
|
+
}),
|
|
630
|
+
sha256: Object.freeze({
|
|
631
|
+
enabled: security.checks.sha256,
|
|
632
|
+
state: evidence.sha256 ?? sha256,
|
|
633
|
+
expected: member.sha256 ?? null,
|
|
634
|
+
actual: evidence.actualSha256 ?? (members.length === 1 ? actualSha256 : null),
|
|
635
|
+
}),
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
return Object.freeze({
|
|
639
|
+
state,
|
|
640
|
+
observedBytes,
|
|
641
|
+
byteLength: Object.freeze({
|
|
642
|
+
enabled: security.checks.byteLength,
|
|
643
|
+
state: byteLength,
|
|
644
|
+
expected: knownModelBytes(source),
|
|
645
|
+
observed: observedBytes,
|
|
646
|
+
}),
|
|
647
|
+
sha256: Object.freeze({
|
|
648
|
+
enabled: security.checks.sha256,
|
|
649
|
+
state: sha256,
|
|
650
|
+
expected: members.length === 1 ? members[0].sha256 ?? null : null,
|
|
651
|
+
actual: members.length === 1 ? actualSha256 : null,
|
|
652
|
+
}),
|
|
653
|
+
files: Object.freeze(fileStates),
|
|
269
654
|
});
|
|
270
655
|
}
|
|
271
656
|
|
|
272
657
|
/**
|
|
273
658
|
* Adapts an existing Arcane DBOPFS singleton without rebinding or changing any
|
|
274
659
|
* of its public methods. The completion manifest is committed only after the
|
|
275
|
-
*
|
|
660
|
+
* model file has been written. SHA-256 is read and computed only when its
|
|
661
|
+
* effective check is enabled.
|
|
276
662
|
*/
|
|
277
663
|
export function createDbopfsModelStore({
|
|
278
664
|
dbopfs,
|
|
279
665
|
tableName = "arcane_ai_browser_models",
|
|
666
|
+
estimateStorage = null,
|
|
280
667
|
} = {}) {
|
|
281
668
|
if (!dbopfs || (typeof dbopfs !== "object" && typeof dbopfs !== "function")) {
|
|
282
669
|
throw new TypeError("createDbopfsModelStore requires an existing DBOPFS instance.");
|
|
@@ -287,6 +674,9 @@ export function createDbopfsModelStore({
|
|
|
287
674
|
if (dbopfs.readyPromise !== undefined && typeof dbopfs.readyPromise?.then !== "function") {
|
|
288
675
|
throw new TypeError("The DBOPFS readyPromise must be thenable.");
|
|
289
676
|
}
|
|
677
|
+
if (estimateStorage !== null && typeof estimateStorage !== "function") {
|
|
678
|
+
throw new TypeError("estimateStorage must be a function or null.");
|
|
679
|
+
}
|
|
290
680
|
let tablePromise = null;
|
|
291
681
|
|
|
292
682
|
async function table() {
|
|
@@ -340,107 +730,451 @@ export function createDbopfsModelStore({
|
|
|
340
730
|
}
|
|
341
731
|
}
|
|
342
732
|
|
|
343
|
-
async function readManifest(name) {
|
|
733
|
+
async function readManifest(name, { removeInvalid = true } = {}) {
|
|
344
734
|
const manifestFile = await file(name);
|
|
345
735
|
if (!manifestFile) return null;
|
|
346
736
|
try {
|
|
347
737
|
return JSON.parse(await manifestFile.text());
|
|
348
738
|
} catch {
|
|
349
|
-
await removeEntry(name);
|
|
739
|
+
if (removeInvalid) await removeEntry(name);
|
|
350
740
|
return null;
|
|
351
741
|
}
|
|
352
742
|
}
|
|
353
743
|
|
|
354
|
-
async function
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
removeEntry(names.manifest),
|
|
358
|
-
removeEntry(names.model),
|
|
359
|
-
]);
|
|
744
|
+
async function removeNames(names) {
|
|
745
|
+
const removed = [await removeEntry(names.manifest)];
|
|
746
|
+
for (const entry of names.models) removed.push(await removeEntry(entry.name));
|
|
360
747
|
return removed.some(Boolean);
|
|
361
748
|
}
|
|
362
749
|
|
|
363
|
-
async function
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
750
|
+
async function remove(source) {
|
|
751
|
+
let removed = await removeNames(storageName(source));
|
|
752
|
+
if (sourceMetadata(source).legacy) {
|
|
753
|
+
const legacyNames = storageName(source, { legacy: true });
|
|
754
|
+
const legacyManifest = await readManifest(legacyNames.manifest, { removeInvalid: false });
|
|
755
|
+
if (manifestKind(legacyManifest, source)) {
|
|
756
|
+
removed = await removeNames(legacyNames) || removed;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return removed;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function writeManifest(name, manifest, signal) {
|
|
763
|
+
const encoded = new TextEncoder().encode(`${JSON.stringify(manifest)}\n`);
|
|
764
|
+
await write(name, encoded, { signal });
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function verifySha256(source, memberIndex, modelFile, {
|
|
768
|
+
signal,
|
|
769
|
+
onProgress,
|
|
770
|
+
phase,
|
|
771
|
+
completedBytes = 0,
|
|
772
|
+
totalBytes = null,
|
|
773
|
+
}) {
|
|
774
|
+
const digest = createStreamingSha256();
|
|
775
|
+
let hashed = 0;
|
|
776
|
+
for await (const chunk of byteChunks(modelFile, signal)) {
|
|
777
|
+
digest.update(chunk);
|
|
778
|
+
hashed += chunk.byteLength;
|
|
779
|
+
onProgress?.(progress(
|
|
780
|
+
source,
|
|
781
|
+
phase,
|
|
782
|
+
completedBytes + hashed,
|
|
783
|
+
totalBytes,
|
|
784
|
+
memberIndex,
|
|
785
|
+
hashed,
|
|
786
|
+
));
|
|
787
|
+
}
|
|
788
|
+
return Object.freeze({ hashed, sha256: digest.digestHex() });
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async function storagePolicy(source, { cached = null, security } = {}) {
|
|
792
|
+
if (cached) {
|
|
793
|
+
const payloadBytes = cached.observedBytes;
|
|
794
|
+
const manifestBytes = manifestByteLength(cached.manifest);
|
|
795
|
+
const requiredBytes = payloadBytes + manifestBytes;
|
|
796
|
+
return Object.freeze({
|
|
797
|
+
compatibility: "compatible",
|
|
798
|
+
code: "ARCANE_AI_MODEL_CACHE_COMPLETE",
|
|
799
|
+
requiredBytes,
|
|
800
|
+
payloadBytes,
|
|
801
|
+
manifestBytes,
|
|
802
|
+
quotaBytes: null,
|
|
803
|
+
usageBytes: null,
|
|
804
|
+
availableBytes: null,
|
|
805
|
+
measured: false,
|
|
806
|
+
admitted: true,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
if (security?.checks?.byteLength !== true) {
|
|
810
|
+
return Object.freeze({
|
|
811
|
+
compatibility: "unknown",
|
|
812
|
+
code: "ARCANE_AI_MODEL_STORAGE_REQUIREMENT_UNBOUNDED",
|
|
813
|
+
requiredBytes: null,
|
|
814
|
+
payloadBytes: null,
|
|
815
|
+
manifestBytes: null,
|
|
816
|
+
quotaBytes: null,
|
|
817
|
+
usageBytes: null,
|
|
818
|
+
availableBytes: null,
|
|
819
|
+
measured: false,
|
|
820
|
+
admitted: false,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
const payloadBytes = knownModelBytes(source);
|
|
824
|
+
const manifestBytes = payloadBytes === null ? null : projectedManifestByteLength(source);
|
|
825
|
+
const requiredBytes = payloadBytes === null || !Number.isSafeInteger(payloadBytes + manifestBytes)
|
|
826
|
+
? null
|
|
827
|
+
: payloadBytes + manifestBytes;
|
|
828
|
+
if (requiredBytes === null) {
|
|
829
|
+
return Object.freeze({
|
|
830
|
+
compatibility: "unknown",
|
|
831
|
+
code: "ARCANE_AI_MODEL_STORAGE_REQUIREMENT_UNKNOWN",
|
|
832
|
+
requiredBytes: null,
|
|
833
|
+
payloadBytes,
|
|
834
|
+
manifestBytes,
|
|
835
|
+
quotaBytes: null,
|
|
836
|
+
usageBytes: null,
|
|
837
|
+
availableBytes: null,
|
|
838
|
+
measured: false,
|
|
839
|
+
admitted: false,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
const estimator = estimateStorage
|
|
843
|
+
?? globalThis.navigator?.storage?.estimate?.bind(globalThis.navigator.storage);
|
|
844
|
+
if (typeof estimator !== "function") {
|
|
845
|
+
return Object.freeze({
|
|
846
|
+
compatibility: "unknown",
|
|
847
|
+
code: "ARCANE_AI_STORAGE_ESTIMATE_UNAVAILABLE",
|
|
848
|
+
requiredBytes,
|
|
849
|
+
payloadBytes,
|
|
850
|
+
manifestBytes,
|
|
851
|
+
quotaBytes: null,
|
|
852
|
+
usageBytes: null,
|
|
853
|
+
availableBytes: null,
|
|
854
|
+
measured: false,
|
|
855
|
+
admitted: false,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
let estimate;
|
|
859
|
+
try {
|
|
860
|
+
estimate = await estimator();
|
|
861
|
+
} catch {
|
|
862
|
+
return Object.freeze({
|
|
863
|
+
compatibility: "unknown",
|
|
864
|
+
code: "ARCANE_AI_STORAGE_ESTIMATE_FAILED",
|
|
865
|
+
requiredBytes,
|
|
866
|
+
payloadBytes,
|
|
867
|
+
manifestBytes,
|
|
868
|
+
quotaBytes: null,
|
|
869
|
+
usageBytes: null,
|
|
870
|
+
availableBytes: null,
|
|
871
|
+
measured: true,
|
|
872
|
+
admitted: false,
|
|
873
|
+
});
|
|
371
874
|
}
|
|
372
|
-
const
|
|
373
|
-
|
|
875
|
+
const quotaBytes = Number.isSafeInteger(estimate?.quota) && estimate.quota >= 0
|
|
876
|
+
? estimate.quota
|
|
877
|
+
: null;
|
|
878
|
+
const usageBytes = Number.isSafeInteger(estimate?.usage) && estimate.usage >= 0
|
|
879
|
+
? estimate.usage
|
|
880
|
+
: null;
|
|
881
|
+
const availableBytes = quotaBytes !== null && usageBytes !== null && quotaBytes >= usageBytes
|
|
882
|
+
? quotaBytes - usageBytes
|
|
883
|
+
: null;
|
|
884
|
+
const incompatible = availableBytes !== null && requiredBytes > availableBytes;
|
|
885
|
+
return Object.freeze({
|
|
886
|
+
compatibility: incompatible ? "incompatible" : availableBytes === null ? "unknown" : "compatible",
|
|
887
|
+
code: incompatible
|
|
888
|
+
? "ARCANE_AI_STORAGE_CAPACITY_INSUFFICIENT"
|
|
889
|
+
: availableBytes === null
|
|
890
|
+
? "ARCANE_AI_STORAGE_ESTIMATE_INVALID"
|
|
891
|
+
: "ARCANE_AI_STORAGE_CAPACITY_AVAILABLE",
|
|
892
|
+
requiredBytes,
|
|
893
|
+
payloadBytes,
|
|
894
|
+
manifestBytes,
|
|
895
|
+
quotaBytes,
|
|
896
|
+
usageBytes,
|
|
897
|
+
availableBytes,
|
|
898
|
+
measured: true,
|
|
899
|
+
admitted: false,
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function openCached(source, {
|
|
904
|
+
signal,
|
|
905
|
+
onProgress,
|
|
906
|
+
security = resolveModelSecurity(),
|
|
907
|
+
} = {}) {
|
|
908
|
+
assertDescriptorChecks(source, security);
|
|
909
|
+
let names = storageName(source);
|
|
910
|
+
let manifest = await readManifest(names.manifest);
|
|
911
|
+
let kind = manifestKind(manifest, source);
|
|
912
|
+
if (!kind && sourceMetadata(source).legacy) {
|
|
913
|
+
const legacyNames = storageName(source, { legacy: true });
|
|
914
|
+
const legacyManifest = await readManifest(legacyNames.manifest, { removeInvalid: false });
|
|
915
|
+
const legacyKind = manifestKind(legacyManifest, source);
|
|
916
|
+
if (legacyKind) {
|
|
917
|
+
names = legacyNames;
|
|
918
|
+
manifest = legacyManifest;
|
|
919
|
+
kind = legacyKind;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (!kind) {
|
|
923
|
+
// Model files without the exact ordered completion manifest are partial.
|
|
374
924
|
await remove(source);
|
|
375
925
|
return null;
|
|
376
926
|
}
|
|
377
|
-
const
|
|
378
|
-
|
|
927
|
+
const members = sourceMetadata(source).files;
|
|
928
|
+
const modelFiles = [];
|
|
929
|
+
const fileEvidence = [];
|
|
930
|
+
let observedBytes = 0;
|
|
379
931
|
try {
|
|
380
|
-
for
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
932
|
+
for (let index = 0; index < names.models.length; index += 1) {
|
|
933
|
+
const member = members[index];
|
|
934
|
+
const modelFile = await file(names.models[index].name);
|
|
935
|
+
if (!modelFile) {
|
|
936
|
+
await removeNames(names);
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
if (modelFile.size > WLLAMA_MAX_FILE_BYTES) {
|
|
940
|
+
await removeNames(names);
|
|
941
|
+
throw fail(
|
|
942
|
+
"ARCANE_AI_MODEL_SHARD_TOO_LARGE",
|
|
943
|
+
`Cached model file ${member.name} exceeds Wllama's ${WLLAMA_MAX_FILE_BYTES}-byte boundary.`,
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
if (kind === "set" && manifest.files[index].observedBytes !== modelFile.size) {
|
|
947
|
+
await removeNames(names);
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
if (security.checks.byteLength && modelFile.size !== member.bytes) {
|
|
951
|
+
await removeNames(names);
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
modelFiles.push(modelFile);
|
|
955
|
+
fileEvidence.push({
|
|
956
|
+
observedBytes: modelFile.size,
|
|
957
|
+
byteLength: security.checks.byteLength ? "verified" : "unchecked",
|
|
958
|
+
sha256: security.checks.sha256 ? "pending" : "unchecked",
|
|
959
|
+
actualSha256: null,
|
|
960
|
+
});
|
|
961
|
+
observedBytes += modelFile.size;
|
|
384
962
|
}
|
|
385
|
-
if (
|
|
386
|
-
await
|
|
963
|
+
if ((kind === "set" || kind === "single") && manifest.observedBytes !== observedBytes) {
|
|
964
|
+
await removeNames(names);
|
|
387
965
|
return null;
|
|
388
966
|
}
|
|
389
|
-
|
|
967
|
+
let completedBytes = 0;
|
|
968
|
+
for (let index = 0; index < modelFiles.length; index += 1) {
|
|
969
|
+
const modelFile = modelFiles[index];
|
|
970
|
+
if (security.checks.sha256) {
|
|
971
|
+
const verification = await verifySha256(source, index, modelFile, {
|
|
972
|
+
signal,
|
|
973
|
+
onProgress,
|
|
974
|
+
phase: "verify-cache",
|
|
975
|
+
completedBytes,
|
|
976
|
+
totalBytes: observedBytes,
|
|
977
|
+
});
|
|
978
|
+
fileEvidence[index].actualSha256 = verification.sha256;
|
|
979
|
+
if (
|
|
980
|
+
verification.hashed !== modelFile.size
|
|
981
|
+
|| verification.sha256 !== members[index].sha256
|
|
982
|
+
) {
|
|
983
|
+
await removeNames(names);
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
fileEvidence[index].sha256 = "verified";
|
|
987
|
+
} else {
|
|
988
|
+
onProgress?.(progress(
|
|
989
|
+
source,
|
|
990
|
+
"cache",
|
|
991
|
+
completedBytes + modelFile.size,
|
|
992
|
+
observedBytes,
|
|
993
|
+
index,
|
|
994
|
+
modelFile.size,
|
|
995
|
+
));
|
|
996
|
+
}
|
|
997
|
+
completedBytes += modelFile.size;
|
|
998
|
+
}
|
|
999
|
+
let completion = manifest;
|
|
1000
|
+
if (kind !== "set") {
|
|
1001
|
+
completion = manifestFor(source, [{
|
|
1002
|
+
name: members[0].name,
|
|
1003
|
+
finalUrl: manifest.finalUrl ?? members[0].url,
|
|
1004
|
+
observedBytes,
|
|
1005
|
+
}]);
|
|
1006
|
+
}
|
|
1007
|
+
return Object.freeze({
|
|
1008
|
+
files: Object.freeze(modelFiles),
|
|
1009
|
+
file: modelFiles.length === 1 ? modelFiles[0] : null,
|
|
1010
|
+
manifest: completion,
|
|
1011
|
+
observedBytes,
|
|
1012
|
+
integrity: integritySnapshot(security, source, {
|
|
1013
|
+
observedBytes,
|
|
1014
|
+
byteLength: security.checks.byteLength ? "verified" : "unchecked",
|
|
1015
|
+
sha256: security.checks.sha256 ? "verified" : "unchecked",
|
|
1016
|
+
actualSha256: fileEvidence[0]?.actualSha256 ?? null,
|
|
1017
|
+
files: fileEvidence,
|
|
1018
|
+
}),
|
|
1019
|
+
});
|
|
390
1020
|
} catch (error) {
|
|
391
|
-
if (!signal?.aborted) await
|
|
1021
|
+
if (!signal?.aborted) await removeNames(names);
|
|
392
1022
|
throw error;
|
|
393
1023
|
}
|
|
394
1024
|
}
|
|
395
1025
|
|
|
396
|
-
async function
|
|
1026
|
+
async function openVerified(source, { signal, onProgress } = {}) {
|
|
1027
|
+
const security = resolveModelSecurity({ load: { secure: true } });
|
|
1028
|
+
return openCached(source, { signal, onProgress, security });
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
async function install(source, { signal, onProgress, security: configuredSecurity } = {}) {
|
|
1032
|
+
const security = resolveModelSecurity({ load: configuredSecurity });
|
|
1033
|
+
assertDescriptorChecks(source, security);
|
|
397
1034
|
const names = storageName(source);
|
|
1035
|
+
const members = sourceMetadata(source).files;
|
|
398
1036
|
await remove(source);
|
|
399
|
-
const
|
|
400
|
-
const
|
|
1037
|
+
const modelFiles = [];
|
|
1038
|
+
const manifestFiles = [];
|
|
1039
|
+
const fileEvidence = [];
|
|
1040
|
+
const expectedTotal = knownModelBytes(source);
|
|
1041
|
+
let observedBytes = 0;
|
|
401
1042
|
try {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
if (
|
|
407
|
-
throw fail(
|
|
1043
|
+
for (let index = 0; index < members.length; index += 1) {
|
|
1044
|
+
const member = members[index];
|
|
1045
|
+
const opened = await source.open(index, { signal });
|
|
1046
|
+
try {
|
|
1047
|
+
if (opened.reportedBytes !== null && opened.reportedBytes > WLLAMA_MAX_FILE_BYTES) {
|
|
1048
|
+
throw fail(
|
|
1049
|
+
"ARCANE_AI_MODEL_SHARD_TOO_LARGE",
|
|
1050
|
+
`Model file ${member.name} exceeds Wllama's ${WLLAMA_MAX_FILE_BYTES}-byte boundary.`,
|
|
1051
|
+
);
|
|
408
1052
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
1053
|
+
if (
|
|
1054
|
+
security.checks.byteLength
|
|
1055
|
+
&& opened.reportedBytes !== null
|
|
1056
|
+
&& opened.reportedBytes !== member.bytes
|
|
1057
|
+
) {
|
|
1058
|
+
throw fail(
|
|
1059
|
+
"ARCANE_AI_MODEL_SIZE_MISMATCH",
|
|
1060
|
+
"A model response Content-Length did not match its expected byte length.",
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
const downloadDigest = security.checks.sha256 ? createStreamingSha256() : null;
|
|
1064
|
+
const written = await write(names.models[index].name, opened.body, {
|
|
1065
|
+
signal,
|
|
1066
|
+
async onChunk(chunk, loaded) {
|
|
1067
|
+
downloadDigest?.update(chunk);
|
|
1068
|
+
if (loaded > WLLAMA_MAX_FILE_BYTES) {
|
|
1069
|
+
throw fail(
|
|
1070
|
+
"ARCANE_AI_MODEL_SHARD_TOO_LARGE",
|
|
1071
|
+
`Model file ${member.name} exceeds Wllama's ${WLLAMA_MAX_FILE_BYTES}-byte boundary.`,
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
if (security.checks.byteLength && loaded > member.bytes) {
|
|
1075
|
+
throw fail("ARCANE_AI_MODEL_SIZE_MISMATCH", "Downloaded model file exceeded its declared size.");
|
|
1076
|
+
}
|
|
1077
|
+
onProgress?.(progress(
|
|
1078
|
+
source,
|
|
1079
|
+
"download",
|
|
1080
|
+
observedBytes + loaded,
|
|
1081
|
+
expectedTotal,
|
|
1082
|
+
index,
|
|
1083
|
+
loaded,
|
|
1084
|
+
));
|
|
1085
|
+
},
|
|
1086
|
+
});
|
|
1087
|
+
if (security.checks.byteLength && written !== member.bytes) {
|
|
1088
|
+
throw fail(
|
|
1089
|
+
"ARCANE_AI_MODEL_SIZE_MISMATCH",
|
|
1090
|
+
"Downloaded model file bytes did not match the caller-supplied expected byte length.",
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
const modelFile = await file(names.models[index].name);
|
|
1094
|
+
if (!modelFile || modelFile.size !== written) {
|
|
1095
|
+
throw fail(
|
|
1096
|
+
"ARCANE_AI_MODEL_CACHE_REJECTED",
|
|
1097
|
+
"A stored model file did not preserve the observed downloaded byte count.",
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
const evidence = {
|
|
1101
|
+
observedBytes: written,
|
|
1102
|
+
byteLength: security.checks.byteLength ? "verified" : "unchecked",
|
|
1103
|
+
sha256: security.checks.sha256 ? "pending" : "unchecked",
|
|
1104
|
+
actualSha256: null,
|
|
1105
|
+
};
|
|
1106
|
+
if (security.checks.sha256) {
|
|
1107
|
+
evidence.actualSha256 = downloadDigest.digestHex();
|
|
1108
|
+
if (evidence.actualSha256 !== member.sha256) {
|
|
1109
|
+
throw fail(
|
|
1110
|
+
"ARCANE_AI_MODEL_DIGEST_MISMATCH",
|
|
1111
|
+
"Downloaded model file bytes did not match the caller-supplied SHA-256 value.",
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
evidence.sha256 = "verified";
|
|
1115
|
+
}
|
|
1116
|
+
modelFiles.push(modelFile);
|
|
1117
|
+
fileEvidence.push(evidence);
|
|
1118
|
+
manifestFiles.push({ name: member.name, finalUrl: opened.finalUrl, observedBytes: written });
|
|
1119
|
+
observedBytes += written;
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
await opened.cancel?.(error).catch(() => undefined);
|
|
1122
|
+
throw error;
|
|
1123
|
+
}
|
|
420
1124
|
}
|
|
421
|
-
// Completion is the final storage mutation.
|
|
422
|
-
//
|
|
423
|
-
const manifest = manifestFor(source,
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
1125
|
+
// Completion is the final storage mutation. No member is admitted until
|
|
1126
|
+
// this exact ordered set manifest exists.
|
|
1127
|
+
const manifest = manifestFor(source, manifestFiles);
|
|
1128
|
+
await writeManifest(names.manifest, manifest, signal);
|
|
1129
|
+
return Object.freeze({
|
|
1130
|
+
files: Object.freeze(modelFiles),
|
|
1131
|
+
file: modelFiles.length === 1 ? modelFiles[0] : null,
|
|
1132
|
+
manifest,
|
|
1133
|
+
observedBytes,
|
|
1134
|
+
integrity: integritySnapshot(security, source, {
|
|
1135
|
+
observedBytes,
|
|
1136
|
+
byteLength: security.checks.byteLength ? "verified" : "unchecked",
|
|
1137
|
+
sha256: security.checks.sha256 ? "verified" : "unchecked",
|
|
1138
|
+
actualSha256: fileEvidence[0]?.actualSha256 ?? null,
|
|
1139
|
+
files: fileEvidence,
|
|
1140
|
+
}),
|
|
1141
|
+
});
|
|
429
1142
|
} catch (error) {
|
|
430
|
-
await opened.cancel?.(error).catch(() => undefined);
|
|
431
1143
|
await remove(source).catch(() => undefined);
|
|
432
1144
|
throw error;
|
|
433
1145
|
}
|
|
434
1146
|
}
|
|
435
1147
|
|
|
436
|
-
async function ensure(source, {
|
|
437
|
-
|
|
438
|
-
|
|
1148
|
+
async function ensure(source, {
|
|
1149
|
+
signal,
|
|
1150
|
+
onProgress,
|
|
1151
|
+
onCapabilityPolicy,
|
|
1152
|
+
offline = false,
|
|
1153
|
+
security: configuredSecurity,
|
|
1154
|
+
} = {}) {
|
|
1155
|
+
const security = resolveModelSecurity({ load: configuredSecurity });
|
|
1156
|
+
assertDescriptorChecks(source, security);
|
|
1157
|
+
const cached = await openCached(source, { signal, onProgress, security });
|
|
1158
|
+
if (cached) {
|
|
1159
|
+
const storage = await storagePolicy(source, { cached, security });
|
|
1160
|
+
onCapabilityPolicy?.(storage);
|
|
1161
|
+
return Object.freeze({ ...cached, cache: "cached", storage });
|
|
1162
|
+
}
|
|
439
1163
|
if (offline) {
|
|
440
|
-
throw fail("ARCANE_AI_MODEL_OFFLINE_MISS", "No
|
|
1164
|
+
throw fail("ARCANE_AI_MODEL_OFFLINE_MISS", "No admitted offline model cache is available.");
|
|
441
1165
|
}
|
|
442
|
-
const
|
|
443
|
-
|
|
1166
|
+
const storage = await storagePolicy(source, { security });
|
|
1167
|
+
onCapabilityPolicy?.(storage);
|
|
1168
|
+
if (storage.compatibility === "incompatible") {
|
|
1169
|
+
throw fail(
|
|
1170
|
+
storage.code,
|
|
1171
|
+
"Available browser storage is smaller than the model file set and completion manifest.",
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
const installed = await install(source, { signal, onProgress, security });
|
|
1175
|
+
const admittedStorage = await storagePolicy(source, { cached: installed, security });
|
|
1176
|
+
onCapabilityPolicy?.(admittedStorage);
|
|
1177
|
+
return Object.freeze({ ...installed, cache: "installed", storage: admittedStorage });
|
|
444
1178
|
}
|
|
445
1179
|
|
|
446
1180
|
const store = Object.freeze({
|
|
@@ -769,6 +1503,9 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
|
|
|
769
1503
|
let terminalError = null;
|
|
770
1504
|
|
|
771
1505
|
function deliver(value) {
|
|
1506
|
+
// This gate prevents delivery after public cancellation. It is not proof
|
|
1507
|
+
// that the underlying request stopped; the runtime records that separately.
|
|
1508
|
+
if (ended || linked.controller.signal.aborted) return;
|
|
772
1509
|
const chunk = request.id === undefined ? value : { ...value, id: request.id };
|
|
773
1510
|
accumulator.push(chunk);
|
|
774
1511
|
const waiter = waiters.shift();
|
|
@@ -779,6 +1516,7 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
|
|
|
779
1516
|
function finish(error = null) {
|
|
780
1517
|
ended = true;
|
|
781
1518
|
terminalError = error;
|
|
1519
|
+
if (error) chunks.length = 0;
|
|
782
1520
|
while (waiters.length) {
|
|
783
1521
|
const waiter = waiters.shift();
|
|
784
1522
|
if (error) waiter.reject(error);
|
|
@@ -790,24 +1528,25 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
|
|
|
790
1528
|
completionOptions(request, linked.controller.signal, true),
|
|
791
1529
|
deliver,
|
|
792
1530
|
);
|
|
793
|
-
const result =
|
|
794
|
-
|
|
1531
|
+
const result = (async () => {
|
|
1532
|
+
try {
|
|
1533
|
+
await terminal;
|
|
1534
|
+
throwIfAborted(linked.controller.signal);
|
|
795
1535
|
const value = accumulator.result();
|
|
796
1536
|
finish();
|
|
797
1537
|
return value;
|
|
798
|
-
}
|
|
799
|
-
(error) => {
|
|
1538
|
+
} catch (error) {
|
|
800
1539
|
const normalized = normalizeArcaneAIError(error, {
|
|
801
1540
|
kind: "llm",
|
|
802
1541
|
operation: "request",
|
|
803
|
-
signal: linked.controller.signal,
|
|
1542
|
+
signal: normalizationSignal(error, linked.controller.signal),
|
|
804
1543
|
});
|
|
805
1544
|
finish(normalized);
|
|
806
1545
|
throw normalized;
|
|
807
|
-
}
|
|
808
|
-
).finally(() => {
|
|
1546
|
+
}
|
|
1547
|
+
})().finally(() => {
|
|
809
1548
|
linked.release();
|
|
810
|
-
onSettled();
|
|
1549
|
+
onSettled(terminalError);
|
|
811
1550
|
});
|
|
812
1551
|
result.catch(() => undefined);
|
|
813
1552
|
|
|
@@ -833,8 +1572,9 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
|
|
|
833
1572
|
return cancelPromise;
|
|
834
1573
|
},
|
|
835
1574
|
async next() {
|
|
836
|
-
if (chunks.length) return { value: chunks.shift(), done: false };
|
|
837
1575
|
if (terminalError) throw terminalError;
|
|
1576
|
+
throwIfAborted(linked.controller.signal);
|
|
1577
|
+
if (chunks.length) return { value: chunks.shift(), done: false };
|
|
838
1578
|
if (ended) return { value: undefined, done: true };
|
|
839
1579
|
return new Promise((resolve, reject) => waiters.push({ resolve, reject }));
|
|
840
1580
|
},
|
|
@@ -853,24 +1593,252 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
|
|
|
853
1593
|
return Object.freeze(handle);
|
|
854
1594
|
}
|
|
855
1595
|
|
|
1596
|
+
function positiveLoadInteger(value, field, fallback, maximum) {
|
|
1597
|
+
const resolved = value === undefined ? fallback : value;
|
|
1598
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) {
|
|
1599
|
+
throw new RangeError(`${field} must be a positive safe integer no greater than ${maximum}.`);
|
|
1600
|
+
}
|
|
1601
|
+
return resolved;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
function measuredRuntimeCapabilities(runtimeCapabilities) {
|
|
1605
|
+
const measuredDeviceMemory = Number(globalThis.navigator?.deviceMemory);
|
|
1606
|
+
const deviceMemory = Number.isFinite(measuredDeviceMemory) && measuredDeviceMemory > 0
|
|
1607
|
+
? measuredDeviceMemory
|
|
1608
|
+
: null;
|
|
1609
|
+
return Object.freeze({ ...runtimeCapabilities, deviceMemory });
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function capabilityLoadPlan(runtimeCapabilities, defaults, options = {}) {
|
|
1613
|
+
const configured = { ...defaults, ...options };
|
|
1614
|
+
const hardwareConcurrency = Number.isSafeInteger(runtimeCapabilities.hardwareConcurrency)
|
|
1615
|
+
&& runtimeCapabilities.hardwareConcurrency > 0
|
|
1616
|
+
? runtimeCapabilities.hardwareConcurrency
|
|
1617
|
+
: 1;
|
|
1618
|
+
const deviceMemory = runtimeCapabilities.deviceMemory;
|
|
1619
|
+
const measuredContext = deviceMemory === null
|
|
1620
|
+
? 4_096
|
|
1621
|
+
: deviceMemory <= 2
|
|
1622
|
+
? 2_048
|
|
1623
|
+
: deviceMemory <= 4
|
|
1624
|
+
? 4_096
|
|
1625
|
+
: 4_096;
|
|
1626
|
+
const defaultContext = hardwareConcurrency <= 2
|
|
1627
|
+
? Math.min(2_048, measuredContext)
|
|
1628
|
+
: measuredContext;
|
|
1629
|
+
const defaultBatch = deviceMemory !== null && deviceMemory <= 2 ? 64 : 128;
|
|
1630
|
+
const defaultMicroBatch = deviceMemory !== null && deviceMemory <= 2 ? 32 : 64;
|
|
1631
|
+
const threads = positiveLoadInteger(
|
|
1632
|
+
configured.threads,
|
|
1633
|
+
"threads",
|
|
1634
|
+
Math.max(1, Math.min(4, hardwareConcurrency - 1 || 1)),
|
|
1635
|
+
64,
|
|
1636
|
+
);
|
|
1637
|
+
const contextTokens = positiveLoadInteger(
|
|
1638
|
+
configured.contextTokens,
|
|
1639
|
+
"contextTokens",
|
|
1640
|
+
defaultContext,
|
|
1641
|
+
1_048_576,
|
|
1642
|
+
);
|
|
1643
|
+
const batchTokens = positiveLoadInteger(
|
|
1644
|
+
configured.batchTokens,
|
|
1645
|
+
"batchTokens",
|
|
1646
|
+
Math.min(defaultBatch, contextTokens),
|
|
1647
|
+
contextTokens,
|
|
1648
|
+
);
|
|
1649
|
+
const microBatchTokens = positiveLoadInteger(
|
|
1650
|
+
configured.microBatchTokens,
|
|
1651
|
+
"microBatchTokens",
|
|
1652
|
+
Math.min(defaultMicroBatch, batchTokens),
|
|
1653
|
+
batchTokens,
|
|
1654
|
+
);
|
|
1655
|
+
return Object.freeze({
|
|
1656
|
+
threads,
|
|
1657
|
+
contextTokens,
|
|
1658
|
+
batchTokens,
|
|
1659
|
+
microBatchTokens,
|
|
1660
|
+
gpuLayers: 99_999,
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function sameLoadPlan(left, right) {
|
|
1665
|
+
return left?.threads === right?.threads
|
|
1666
|
+
&& left?.contextTokens === right?.contextTokens
|
|
1667
|
+
&& left?.batchTokens === right?.batchTokens
|
|
1668
|
+
&& left?.microBatchTokens === right?.microBatchTokens
|
|
1669
|
+
&& left?.gpuLayers === right?.gpuLayers;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
function stableModelFailure(error) {
|
|
1673
|
+
const code = typeof error?.code === "string" ? error.code : "";
|
|
1674
|
+
const message = typeof error?.message === "string" ? error.message : "";
|
|
1675
|
+
if (code === "ARCANE_AI_MODEL_SHARD_TOO_LARGE") {
|
|
1676
|
+
return Object.freeze({ code });
|
|
1677
|
+
}
|
|
1678
|
+
if (/(?:out of memory|allocation failed|failed to allocate|memory exhausted)/iu.test(message)) {
|
|
1679
|
+
return Object.freeze({ code: "ARCANE_AI_MODEL_GPU_MEMORY_INSUFFICIENT" });
|
|
1680
|
+
}
|
|
1681
|
+
if (code === "ARCANE_AI_WEBGPU_EVIDENCE_INVALID") {
|
|
1682
|
+
return Object.freeze({ code: "ARCANE_AI_MODEL_FULL_OFFLOAD_UNPROVEN" });
|
|
1683
|
+
}
|
|
1684
|
+
if (code === "ARCANE_AI_WEBGPU_REQUIRED" && /(?:offload|GPU|WebGPU)/iu.test(message)) {
|
|
1685
|
+
return Object.freeze({ code: "ARCANE_AI_MODEL_WEBGPU_REQUIREMENT_FAILED" });
|
|
1686
|
+
}
|
|
1687
|
+
return null;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function capabilityPolicy(
|
|
1691
|
+
source,
|
|
1692
|
+
runtimeCapabilities,
|
|
1693
|
+
loadPlan,
|
|
1694
|
+
storage,
|
|
1695
|
+
runtimeEvidence,
|
|
1696
|
+
state,
|
|
1697
|
+
failure = null,
|
|
1698
|
+
) {
|
|
1699
|
+
const reasons = [];
|
|
1700
|
+
const add = (code, compatibility, details = {}) => reasons.push(Object.freeze({
|
|
1701
|
+
code,
|
|
1702
|
+
compatibility,
|
|
1703
|
+
details: Object.freeze(details),
|
|
1704
|
+
}));
|
|
1705
|
+
if (runtimeCapabilities.webAssembly !== true) {
|
|
1706
|
+
add("ARCANE_AI_WEBASSEMBLY_UNAVAILABLE", "incompatible");
|
|
1707
|
+
}
|
|
1708
|
+
if (runtimeCapabilities.opfs !== true) {
|
|
1709
|
+
add("ARCANE_AI_OPFS_UNAVAILABLE", "incompatible");
|
|
1710
|
+
}
|
|
1711
|
+
if (runtimeCapabilities.secureContext !== true) {
|
|
1712
|
+
add("ARCANE_AI_SECURE_CONTEXT_REQUIRED", "incompatible");
|
|
1713
|
+
}
|
|
1714
|
+
if (runtimeCapabilities.webgpuApiPresent !== true) {
|
|
1715
|
+
add("ARCANE_AI_WEBGPU_API_UNAVAILABLE", "incompatible");
|
|
1716
|
+
}
|
|
1717
|
+
const oversized = oversizedModelFile(source);
|
|
1718
|
+
if (oversized) {
|
|
1719
|
+
add("ARCANE_AI_MODEL_SHARD_TOO_LARGE", "incompatible", {
|
|
1720
|
+
name: oversized.name,
|
|
1721
|
+
bytes: oversized.bytes,
|
|
1722
|
+
maximumBytes: WLLAMA_MAX_FILE_BYTES,
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
if (failure && failure.code !== "ARCANE_AI_MODEL_SHARD_TOO_LARGE") {
|
|
1726
|
+
add(failure.code, "incompatible");
|
|
1727
|
+
}
|
|
1728
|
+
if (storage?.compatibility === "incompatible") {
|
|
1729
|
+
add(storage.code, "incompatible", {
|
|
1730
|
+
requiredBytes: storage.requiredBytes,
|
|
1731
|
+
availableBytes: storage.availableBytes,
|
|
1732
|
+
});
|
|
1733
|
+
} else if (!storage || storage.compatibility === "unknown") {
|
|
1734
|
+
add(storage?.code ?? "ARCANE_AI_STORAGE_NOT_MEASURED", "unknown", {
|
|
1735
|
+
requiredBytes: storage?.requiredBytes ?? knownModelBytes(source),
|
|
1736
|
+
availableBytes: storage?.availableBytes ?? null,
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
const webgpu = runtimeEvidence?.webgpu;
|
|
1740
|
+
if (state === "ready" && webgpu?.observed === true) {
|
|
1741
|
+
add("ARCANE_AI_WEBGPU_EXECUTION_OBSERVED", "compatible", {
|
|
1742
|
+
requestedGpuLayers: loadPlan.gpuLayers,
|
|
1743
|
+
offloadedLayers: webgpu.offload?.layers ?? null,
|
|
1744
|
+
totalLayers: webgpu.offload?.totalLayers ?? null,
|
|
1745
|
+
queueSubmissions: webgpu.queue?.submissions ?? null,
|
|
1746
|
+
logicalBufferDescriptorBytes: webgpu.buffers?.descriptorBytes ?? null,
|
|
1747
|
+
});
|
|
1748
|
+
} else if (runtimeCapabilities.webgpuApiPresent === true) {
|
|
1749
|
+
add("ARCANE_AI_WEBGPU_EXECUTION_UNOBSERVED", "unknown", {
|
|
1750
|
+
requestedGpuLayers: loadPlan.gpuLayers,
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
const compatibility = reasons.some((reason) => reason.compatibility === "incompatible")
|
|
1754
|
+
? "incompatible"
|
|
1755
|
+
: reasons.some((reason) => reason.compatibility === "unknown")
|
|
1756
|
+
? "unknown"
|
|
1757
|
+
: "compatible";
|
|
1758
|
+
return Object.freeze({
|
|
1759
|
+
protocol: CAPABILITY_POLICY_PROTOCOL,
|
|
1760
|
+
compatibility,
|
|
1761
|
+
reasons: Object.freeze(reasons),
|
|
1762
|
+
model: Object.freeze({
|
|
1763
|
+
id: source.id,
|
|
1764
|
+
fileCount: sourceMetadata(source).files.length,
|
|
1765
|
+
declaredBytes: knownModelBytes(source),
|
|
1766
|
+
}),
|
|
1767
|
+
load: loadPlan,
|
|
1768
|
+
storage: storage ?? null,
|
|
1769
|
+
inputs: Object.freeze({
|
|
1770
|
+
hardwareConcurrency: runtimeCapabilities.hardwareConcurrency,
|
|
1771
|
+
deviceMemory: runtimeCapabilities.deviceMemory,
|
|
1772
|
+
deviceMemoryMeaning: "coarse-system-memory-gib",
|
|
1773
|
+
webAssembly: runtimeCapabilities.webAssembly,
|
|
1774
|
+
opfs: runtimeCapabilities.opfs,
|
|
1775
|
+
secureContext: runtimeCapabilities.secureContext,
|
|
1776
|
+
webgpuApiPresent: runtimeCapabilities.webgpuApiPresent,
|
|
1777
|
+
}),
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function providerModelSources(source, sources) {
|
|
1782
|
+
const list = sources === undefined ? [source] : sources;
|
|
1783
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
1784
|
+
throw new TypeError("createBrowserWasmLlmProvider requires a nonempty sources array or legacy source.");
|
|
1785
|
+
}
|
|
1786
|
+
const ids = new Set();
|
|
1787
|
+
for (const candidate of list) {
|
|
1788
|
+
if (!BROWSER_MODEL_SOURCES.has(candidate)) {
|
|
1789
|
+
throw new TypeError("Every browser-WASM model source must come from createBrowserModelSource().");
|
|
1790
|
+
}
|
|
1791
|
+
if (ids.has(candidate.id)) {
|
|
1792
|
+
throw new TypeError("Browser-WASM model source ids must be unique within one provider catalog.");
|
|
1793
|
+
}
|
|
1794
|
+
ids.add(candidate.id);
|
|
1795
|
+
}
|
|
1796
|
+
if (source !== undefined && !BROWSER_MODEL_SOURCES.has(source)) {
|
|
1797
|
+
throw new TypeError("The legacy default source must come from createBrowserModelSource().");
|
|
1798
|
+
}
|
|
1799
|
+
if (source !== undefined && !list.includes(source)) {
|
|
1800
|
+
throw new TypeError("The legacy default source must be one member of sources.");
|
|
1801
|
+
}
|
|
1802
|
+
return Object.freeze({
|
|
1803
|
+
sources: Object.freeze(list.slice()),
|
|
1804
|
+
defaultSource: source ?? list[0],
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
|
|
856
1808
|
export function createBrowserWasmLlmProvider({
|
|
857
1809
|
source,
|
|
1810
|
+
sources,
|
|
858
1811
|
store,
|
|
859
1812
|
loadDefaults = {},
|
|
1813
|
+
security,
|
|
860
1814
|
logger = console,
|
|
861
1815
|
} = {}) {
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1816
|
+
const configuredModels = providerModelSources(source, sources);
|
|
1817
|
+
const modelSources = configuredModels.sources;
|
|
1818
|
+
const defaultSource = configuredModels.defaultSource;
|
|
865
1819
|
if (!DBOPFS_MODEL_STORES.has(store)) {
|
|
866
1820
|
throw new TypeError("createBrowserWasmLlmProvider requires createDbopfsModelStore().");
|
|
867
1821
|
}
|
|
1822
|
+
const bindingSecurity = normalizeModelSecurity(security, "provider security");
|
|
1823
|
+
const runtimeLoadDefaults = { ...loadDefaults };
|
|
1824
|
+
delete runtimeLoadDefaults.security;
|
|
1825
|
+
delete runtimeLoadDefaults.offline;
|
|
1826
|
+
delete runtimeLoadDefaults.onProgress;
|
|
868
1827
|
|
|
869
1828
|
const runtime = createPackagedWllamaRuntime({ logger });
|
|
1829
|
+
const storagePolicies = new Map();
|
|
1830
|
+
const modelFailures = new Map();
|
|
1831
|
+
let activeSource = defaultSource;
|
|
1832
|
+
let activeLoadPlan = capabilityLoadPlan(
|
|
1833
|
+
measuredRuntimeCapabilities(runtime.capabilities()),
|
|
1834
|
+
runtimeLoadDefaults,
|
|
1835
|
+
);
|
|
870
1836
|
let state = "unloaded";
|
|
871
1837
|
let progressState = null;
|
|
872
1838
|
let errorState = null;
|
|
873
1839
|
let cacheState = "unknown";
|
|
1840
|
+
let activeSecurity = null;
|
|
1841
|
+
let activeIntegrity = null;
|
|
874
1842
|
let queueDepth = 0;
|
|
875
1843
|
let disposed = false;
|
|
876
1844
|
let disposing = false;
|
|
@@ -883,21 +1851,82 @@ export function createBrowserWasmLlmProvider({
|
|
|
883
1851
|
let activeCount = 0;
|
|
884
1852
|
const queue = createSerialRequestQueue((depth) => { queueDepth = depth; });
|
|
885
1853
|
|
|
1854
|
+
function sourceForModel(modelId = undefined) {
|
|
1855
|
+
if (modelId === undefined || modelId === null) return defaultSource;
|
|
1856
|
+
const candidate = modelSources.find((value) => value.id === modelId);
|
|
1857
|
+
if (!candidate) {
|
|
1858
|
+
throw fail(
|
|
1859
|
+
"ARCANE_AI_MODEL_AUTHORITY_REQUIRED",
|
|
1860
|
+
"The selected model is not present in this provider's caller-supplied catalog.",
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
return candidate;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
886
1866
|
function capabilities() {
|
|
887
|
-
const runtimeCapabilities = runtime.capabilities();
|
|
1867
|
+
const runtimeCapabilities = measuredRuntimeCapabilities(runtime.capabilities());
|
|
888
1868
|
return Object.freeze({
|
|
889
1869
|
localOnly: true,
|
|
890
1870
|
toolCalls: "structural-only",
|
|
891
1871
|
webAssembly: runtimeCapabilities.webAssembly,
|
|
892
1872
|
opfs: runtimeCapabilities.opfs,
|
|
893
1873
|
webgpu: runtimeCapabilities.webgpu,
|
|
1874
|
+
webgpuApiPresent: runtimeCapabilities.webgpuApiPresent,
|
|
1875
|
+
webgpuOperational: runtimeCapabilities.webgpuOperational,
|
|
1876
|
+
webgpuEvidenceProtocol: runtimeCapabilities.webgpuEvidenceProtocol,
|
|
1877
|
+
webgpuAdapterSelectionEvent: WEBGPU_ADAPTER_SELECTED_EVENT,
|
|
894
1878
|
crossOriginIsolated: runtimeCapabilities.crossOriginIsolated,
|
|
895
1879
|
secureContext: runtimeCapabilities.secureContext,
|
|
896
1880
|
hardwareConcurrency: runtimeCapabilities.hardwareConcurrency,
|
|
1881
|
+
deviceMemory: runtimeCapabilities.deviceMemory,
|
|
1882
|
+
orderedModelFiles: true,
|
|
1883
|
+
capabilityPolicyProtocol: CAPABILITY_POLICY_PROTOCOL,
|
|
897
1884
|
});
|
|
898
1885
|
}
|
|
899
1886
|
|
|
900
|
-
function
|
|
1887
|
+
function currentCapabilityPolicy() {
|
|
1888
|
+
const runtimeCapabilities = measuredRuntimeCapabilities(runtime.capabilities());
|
|
1889
|
+
return capabilityPolicy(
|
|
1890
|
+
activeSource,
|
|
1891
|
+
runtimeCapabilities,
|
|
1892
|
+
activeLoadPlan,
|
|
1893
|
+
storagePolicies.get(activeSource.id) ?? null,
|
|
1894
|
+
runtime.evidence(),
|
|
1895
|
+
state,
|
|
1896
|
+
modelFailures.get(activeSource.id) ?? null,
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function catalog() {
|
|
1901
|
+
const runtimeCapabilities = measuredRuntimeCapabilities(runtime.capabilities());
|
|
1902
|
+
return Object.freeze(modelSources.map((candidate) => {
|
|
1903
|
+
const selected = candidate === activeSource;
|
|
1904
|
+
const plan = selected
|
|
1905
|
+
? activeLoadPlan
|
|
1906
|
+
: capabilityLoadPlan(runtimeCapabilities, runtimeLoadDefaults);
|
|
1907
|
+
const policy = capabilityPolicy(
|
|
1908
|
+
candidate,
|
|
1909
|
+
runtimeCapabilities,
|
|
1910
|
+
plan,
|
|
1911
|
+
storagePolicies.get(candidate.id) ?? null,
|
|
1912
|
+
selected ? runtime.evidence() : null,
|
|
1913
|
+
selected ? state : "unloaded",
|
|
1914
|
+
modelFailures.get(candidate.id) ?? null,
|
|
1915
|
+
);
|
|
1916
|
+
return Object.freeze({
|
|
1917
|
+
...publicDescriptor(candidate),
|
|
1918
|
+
compatibility: policy.compatibility,
|
|
1919
|
+
compatibilityDetails: policy,
|
|
1920
|
+
});
|
|
1921
|
+
}));
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
function status(context = {}) {
|
|
1925
|
+
const effectiveSecurity = activeSecurity ?? resolveModelSecurity({
|
|
1926
|
+
app: context?.security,
|
|
1927
|
+
binding: bindingSecurity,
|
|
1928
|
+
});
|
|
1929
|
+
const integrity = activeIntegrity ?? integritySnapshot(effectiveSecurity, activeSource);
|
|
901
1930
|
return Object.freeze({
|
|
902
1931
|
protocol: ARCANE_AI_ADAPTER_PROTOCOL,
|
|
903
1932
|
provider: "arcane-browser-wasm-wllama",
|
|
@@ -905,16 +1934,38 @@ export function createBrowserWasmLlmProvider({
|
|
|
905
1934
|
loaded: state === "ready" && runtime.isLoaded(),
|
|
906
1935
|
busy: activeCount > 0,
|
|
907
1936
|
queued: Math.max(0, queueDepth - activeCount),
|
|
908
|
-
model: publicDescriptor(
|
|
1937
|
+
model: publicDescriptor(activeSource),
|
|
909
1938
|
cache: Object.freeze({ state: cacheState, schema: MODEL_MANIFEST_SCHEMA }),
|
|
1939
|
+
security: securitySnapshot(effectiveSecurity),
|
|
1940
|
+
integrity,
|
|
910
1941
|
progress: progressState,
|
|
911
1942
|
error: errorState,
|
|
912
1943
|
runtime: runtime.authority,
|
|
1944
|
+
runtimeEvidence: runtime.evidence(),
|
|
913
1945
|
capabilities: capabilities(),
|
|
1946
|
+
capabilityPolicy: currentCapabilityPolicy(),
|
|
914
1947
|
origin: globalThis.location?.origin ?? null,
|
|
915
1948
|
});
|
|
916
1949
|
}
|
|
917
1950
|
|
|
1951
|
+
function reconcileRuntimeAfterRequestError(error) {
|
|
1952
|
+
if (runtime.isLoaded()) return;
|
|
1953
|
+
const runtimeState = runtime.evidence()?.state;
|
|
1954
|
+
state = runtimeState === "error"
|
|
1955
|
+
|| error?.code === "ARCANE_AI_WORKER_TERMINATION_UNCONFIRMED"
|
|
1956
|
+
? "error"
|
|
1957
|
+
: "unloaded";
|
|
1958
|
+
progressState = null;
|
|
1959
|
+
errorState = state === "error"
|
|
1960
|
+
? Object.freeze({
|
|
1961
|
+
code: typeof error?.code === "string" ? error.code : "ARCANE_AI_RUNTIME_FAILED",
|
|
1962
|
+
message: typeof error?.message === "string"
|
|
1963
|
+
? error.message
|
|
1964
|
+
: "The browser-WASM runtime failed.",
|
|
1965
|
+
})
|
|
1966
|
+
: null;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
918
1969
|
function report(value, options, context) {
|
|
919
1970
|
progressState = value;
|
|
920
1971
|
options?.onProgress?.(value);
|
|
@@ -931,42 +1982,133 @@ export function createBrowserWasmLlmProvider({
|
|
|
931
1982
|
"The browser-WASM model cannot load while unload is in progress.",
|
|
932
1983
|
);
|
|
933
1984
|
}
|
|
934
|
-
|
|
935
|
-
|
|
1985
|
+
const requestedSource = sourceForModel(options.modelId);
|
|
1986
|
+
const effectiveSecurity = resolveModelSecurity({
|
|
1987
|
+
app: context.security,
|
|
1988
|
+
binding: bindingSecurity,
|
|
1989
|
+
load: options.security,
|
|
1990
|
+
});
|
|
1991
|
+
assertDescriptorChecks(requestedSource, effectiveSecurity);
|
|
1992
|
+
const requestedLoadPlan = capabilityLoadPlan(
|
|
1993
|
+
measuredRuntimeCapabilities(runtime.capabilities()),
|
|
1994
|
+
runtimeLoadDefaults,
|
|
1995
|
+
options,
|
|
1996
|
+
);
|
|
1997
|
+
const oversized = oversizedModelFile(requestedSource);
|
|
1998
|
+
if (oversized) {
|
|
1999
|
+
modelFailures.set(requestedSource.id, Object.freeze({
|
|
2000
|
+
code: "ARCANE_AI_MODEL_SHARD_TOO_LARGE",
|
|
2001
|
+
}));
|
|
2002
|
+
throw fail(
|
|
2003
|
+
"ARCANE_AI_MODEL_SHARD_TOO_LARGE",
|
|
2004
|
+
`Model file ${oversized.name} exceeds Wllama's ${WLLAMA_MAX_FILE_BYTES}-byte boundary.`,
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
2007
|
+
if (state === "ready") {
|
|
2008
|
+
if (activeSource !== requestedSource) {
|
|
2009
|
+
throw fail(
|
|
2010
|
+
"ARCANE_AI_MODEL_RELOAD_REQUIRED",
|
|
2011
|
+
"Unload the active browser-WASM model before selecting another model.",
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
2014
|
+
if (!sameLoadPlan(activeLoadPlan, requestedLoadPlan)) {
|
|
2015
|
+
throw fail(
|
|
2016
|
+
"ARCANE_AI_LOAD_PLAN_RELOAD_REQUIRED",
|
|
2017
|
+
"Unload the browser-WASM model before changing its context or batch load plan.",
|
|
2018
|
+
);
|
|
2019
|
+
}
|
|
2020
|
+
if (sameModelSecurity(activeSecurity, effectiveSecurity)) {
|
|
2021
|
+
activeSecurity = effectiveSecurity;
|
|
2022
|
+
return Object.freeze({ model: publicDescriptor(activeSource), status: status() });
|
|
2023
|
+
}
|
|
2024
|
+
throw fail(
|
|
2025
|
+
"ARCANE_AI_SECURITY_RELOAD_REQUIRED",
|
|
2026
|
+
"Unload the browser-WASM model before changing its effective security checks.",
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
if (loadPromise) {
|
|
2030
|
+
if (activeSource !== requestedSource) {
|
|
2031
|
+
throw fail(
|
|
2032
|
+
"ARCANE_AI_MODEL_RELOAD_REQUIRED",
|
|
2033
|
+
"The in-flight browser-WASM load owns a different model.",
|
|
2034
|
+
);
|
|
2035
|
+
}
|
|
2036
|
+
if (!sameLoadPlan(activeLoadPlan, requestedLoadPlan)) {
|
|
2037
|
+
throw fail(
|
|
2038
|
+
"ARCANE_AI_LOAD_PLAN_RELOAD_REQUIRED",
|
|
2039
|
+
"The in-flight browser-WASM load uses a different context or batch plan.",
|
|
2040
|
+
);
|
|
2041
|
+
}
|
|
2042
|
+
if (sameModelSecurity(activeSecurity, effectiveSecurity)) {
|
|
2043
|
+
activeSecurity = effectiveSecurity;
|
|
2044
|
+
return loadPromise;
|
|
2045
|
+
}
|
|
2046
|
+
throw fail(
|
|
2047
|
+
"ARCANE_AI_SECURITY_RELOAD_REQUIRED",
|
|
2048
|
+
"The in-flight browser-WASM load uses different effective security checks.",
|
|
2049
|
+
);
|
|
2050
|
+
}
|
|
936
2051
|
const externalSignal = options.signal ?? context.signal ?? null;
|
|
937
2052
|
const linked = linkAbortSignal(externalSignal);
|
|
938
2053
|
const signal = linked.controller.signal;
|
|
939
2054
|
const generation = ++lifecycleGeneration;
|
|
940
2055
|
loadAbort = linked.controller;
|
|
2056
|
+
activeSource = requestedSource;
|
|
2057
|
+
activeSecurity = effectiveSecurity;
|
|
2058
|
+
activeLoadPlan = requestedLoadPlan;
|
|
2059
|
+
activeIntegrity = integritySnapshot(effectiveSecurity, activeSource);
|
|
941
2060
|
state = "loading";
|
|
942
2061
|
progressState = null;
|
|
943
2062
|
errorState = null;
|
|
944
2063
|
loadPromise = (async () => {
|
|
945
2064
|
try {
|
|
946
2065
|
throwIfAborted(signal, "load");
|
|
947
|
-
const admitted = await store.ensure(
|
|
2066
|
+
const admitted = await store.ensure(activeSource, {
|
|
948
2067
|
signal,
|
|
949
2068
|
offline: options.offline === true,
|
|
2069
|
+
security: effectiveSecurity,
|
|
950
2070
|
onProgress: (value) => report(value, options, context),
|
|
2071
|
+
onCapabilityPolicy: (value) => { storagePolicies.set(activeSource.id, value); },
|
|
951
2072
|
});
|
|
952
2073
|
cacheState = admitted.cache;
|
|
2074
|
+
activeIntegrity = admitted.integrity;
|
|
953
2075
|
throwIfAborted(signal, "load");
|
|
954
2076
|
if (generation !== lifecycleGeneration || state !== "loading") {
|
|
955
2077
|
throw fail("ARCANE_AI_OPERATION_SUPERSEDED", "The model load was superseded by unload.");
|
|
956
2078
|
}
|
|
957
|
-
report(
|
|
2079
|
+
report(
|
|
2080
|
+
progress(activeSource, "initialize", admitted.observedBytes, admitted.observedBytes),
|
|
2081
|
+
options,
|
|
2082
|
+
context,
|
|
2083
|
+
);
|
|
958
2084
|
throwIfAborted(signal, "load");
|
|
959
2085
|
if (generation !== lifecycleGeneration || state !== "loading") {
|
|
960
2086
|
throw fail("ARCANE_AI_OPERATION_SUPERSEDED", "The model load was superseded by unload.");
|
|
961
2087
|
}
|
|
962
|
-
const
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
2088
|
+
const members = sourceMetadata(activeSource).files;
|
|
2089
|
+
const modelFiles = admitted.files.map((file, index) => (
|
|
2090
|
+
typeof globalThis.File === "function"
|
|
2091
|
+
? new File([file], members[index].name, { type: "application/octet-stream" })
|
|
2092
|
+
: file
|
|
2093
|
+
));
|
|
2094
|
+
const runtimeOptions = { ...options };
|
|
2095
|
+
delete runtimeOptions.security;
|
|
2096
|
+
delete runtimeOptions.offline;
|
|
2097
|
+
delete runtimeOptions.onProgress;
|
|
2098
|
+
delete runtimeOptions.modelId;
|
|
2099
|
+
await runtime.load(modelFiles, {
|
|
2100
|
+
...runtimeLoadDefaults,
|
|
2101
|
+
...runtimeOptions,
|
|
2102
|
+
...activeLoadPlan,
|
|
968
2103
|
signal,
|
|
969
2104
|
});
|
|
2105
|
+
if (!runtime.isLoaded()) {
|
|
2106
|
+
throw fail(
|
|
2107
|
+
"ARCANE_AI_LOAD_FAILED",
|
|
2108
|
+
"Wllama did not confirm that the model loaded successfully.",
|
|
2109
|
+
);
|
|
2110
|
+
}
|
|
2111
|
+
emitWebgpuAdapterSelection(activeSource, runtime);
|
|
970
2112
|
throwIfAborted(signal, "load");
|
|
971
2113
|
if (generation !== lifecycleGeneration || state !== "loading") {
|
|
972
2114
|
await runtime.exit();
|
|
@@ -974,14 +2116,23 @@ export function createBrowserWasmLlmProvider({
|
|
|
974
2116
|
}
|
|
975
2117
|
state = "ready";
|
|
976
2118
|
progressState = null;
|
|
977
|
-
|
|
2119
|
+
modelFailures.delete(activeSource.id);
|
|
2120
|
+
return Object.freeze({ model: publicDescriptor(activeSource), status: status() });
|
|
978
2121
|
} catch (error) {
|
|
979
|
-
|
|
980
|
-
|
|
2122
|
+
let cleanupFailure = null;
|
|
2123
|
+
try {
|
|
2124
|
+
await runtime.exit();
|
|
2125
|
+
} catch (cleanupError) {
|
|
2126
|
+
cleanupFailure = cleanupError;
|
|
2127
|
+
}
|
|
2128
|
+
const surfaced = cleanupFailure ?? error;
|
|
2129
|
+
const normalized = normalizeArcaneAIError(surfaced, {
|
|
981
2130
|
kind: "llm",
|
|
982
2131
|
operation: "load",
|
|
983
|
-
signal,
|
|
2132
|
+
signal: normalizationSignal(surfaced, signal),
|
|
984
2133
|
});
|
|
2134
|
+
const modelFailure = stableModelFailure(normalized);
|
|
2135
|
+
if (modelFailure) modelFailures.set(activeSource.id, modelFailure);
|
|
985
2136
|
if (generation === lifecycleGeneration && state === "loading") {
|
|
986
2137
|
state = "error";
|
|
987
2138
|
errorState = Object.freeze({ code: normalized.code, message: normalized.message });
|
|
@@ -1020,11 +2171,13 @@ export function createBrowserWasmLlmProvider({
|
|
|
1020
2171
|
throwIfAborted(linked.controller.signal);
|
|
1021
2172
|
return validateCompletion(completion, request.id);
|
|
1022
2173
|
} catch (error) {
|
|
1023
|
-
|
|
2174
|
+
const normalized = normalizeArcaneAIError(error, {
|
|
1024
2175
|
kind: "llm",
|
|
1025
2176
|
operation: "request",
|
|
1026
|
-
signal: linked.controller.signal,
|
|
2177
|
+
signal: normalizationSignal(error, linked.controller.signal),
|
|
1027
2178
|
});
|
|
2179
|
+
reconcileRuntimeAfterRequestError(normalized);
|
|
2180
|
+
throw normalized;
|
|
1028
2181
|
} finally {
|
|
1029
2182
|
activeCount -= 1;
|
|
1030
2183
|
activeAbort = null;
|
|
@@ -1044,11 +2197,12 @@ export function createBrowserWasmLlmProvider({
|
|
|
1044
2197
|
runtime,
|
|
1045
2198
|
request,
|
|
1046
2199
|
signal: externalSignal,
|
|
1047
|
-
onSettled() {
|
|
2200
|
+
onSettled(error) {
|
|
1048
2201
|
if (settled) return;
|
|
1049
2202
|
settled = true;
|
|
1050
2203
|
activeCount -= 1;
|
|
1051
2204
|
activeAbort = null;
|
|
2205
|
+
if (error) reconcileRuntimeAfterRequestError(error);
|
|
1052
2206
|
},
|
|
1053
2207
|
});
|
|
1054
2208
|
activeAbort = Object.freeze({
|
|
@@ -1087,12 +2241,14 @@ export function createBrowserWasmLlmProvider({
|
|
|
1087
2241
|
state = "unloaded";
|
|
1088
2242
|
progressState = null;
|
|
1089
2243
|
errorState = null;
|
|
2244
|
+
activeSecurity = null;
|
|
2245
|
+
activeIntegrity = null;
|
|
1090
2246
|
return status();
|
|
1091
2247
|
} catch (error) {
|
|
1092
2248
|
const normalized = normalizeArcaneAIError(error, {
|
|
1093
2249
|
kind: "llm",
|
|
1094
2250
|
operation: "unload",
|
|
1095
|
-
signal,
|
|
2251
|
+
signal: normalizationSignal(error, signal),
|
|
1096
2252
|
});
|
|
1097
2253
|
state = "error";
|
|
1098
2254
|
errorState = Object.freeze({ code: normalized.code, message: normalized.message });
|
|
@@ -1134,7 +2290,8 @@ export function createBrowserWasmLlmProvider({
|
|
|
1134
2290
|
return Object.freeze({
|
|
1135
2291
|
protocol: ARCANE_AI_ADAPTER_PROTOCOL,
|
|
1136
2292
|
id: "arcane-browser-wasm-wllama",
|
|
1137
|
-
model: publicDescriptor(
|
|
2293
|
+
model: publicDescriptor(defaultSource),
|
|
2294
|
+
catalog,
|
|
1138
2295
|
capabilities,
|
|
1139
2296
|
status,
|
|
1140
2297
|
load,
|
|
@@ -1148,4 +2305,217 @@ export function createBrowserWasmLlmProvider({
|
|
|
1148
2305
|
});
|
|
1149
2306
|
}
|
|
1150
2307
|
|
|
2308
|
+
function assertV1LlmAdapterSelection(selection, providerId, modelIds, role) {
|
|
2309
|
+
if (role !== "llm") {
|
|
2310
|
+
throw fail("ARCANE_AI_PROVIDER_ROLE_MISMATCH", "The browser-WASM adapter serves only the LLM role.");
|
|
2311
|
+
}
|
|
2312
|
+
if (
|
|
2313
|
+
!selection
|
|
2314
|
+
|| typeof selection !== "object"
|
|
2315
|
+
|| Array.isArray(selection)
|
|
2316
|
+
|| selection.providerId !== providerId
|
|
2317
|
+
|| !modelIds.has(selection.modelId)
|
|
2318
|
+
|| selection.localOnly !== true
|
|
2319
|
+
) {
|
|
2320
|
+
throw fail(
|
|
2321
|
+
"ARCANE_AI_MODEL_AUTHORITY_REQUIRED",
|
|
2322
|
+
"The browser-WASM adapter requires its exact local-only provider and model selection.",
|
|
2323
|
+
);
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
function provider2ByteProgress(value) {
|
|
2328
|
+
const phase = typeof value?.phase === "string" ? value.phase.trim() : "";
|
|
2329
|
+
const completed = Number(value?.loaded);
|
|
2330
|
+
const total = value?.total === null ? null : Number(value?.total);
|
|
2331
|
+
if (
|
|
2332
|
+
!phase
|
|
2333
|
+
|| !Number.isSafeInteger(completed)
|
|
2334
|
+
|| completed < 0
|
|
2335
|
+
|| (total !== null && (!Number.isSafeInteger(total) || total < 0))
|
|
2336
|
+
|| (total !== null && completed > total)
|
|
2337
|
+
) {
|
|
2338
|
+
throw fail("ARCANE_AI_PROVIDER_PROGRESS_INVALID", "The browser-WASM provider returned invalid byte progress.");
|
|
2339
|
+
}
|
|
2340
|
+
return Object.freeze({ phase, completed, total, unit: "bytes", heartbeat: false });
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
/**
|
|
2344
|
+
* Projects the existing browser-WASM LLM provider into the provider-neutral
|
|
2345
|
+
* Arcane AI /2 lifecycle without changing the provider's public v1 contract.
|
|
2346
|
+
* The adapter is local-only, never falls back, and never executes tool calls.
|
|
2347
|
+
*/
|
|
2348
|
+
export function adaptV1LlmProvider(provider) {
|
|
2349
|
+
if (!provider || typeof provider !== "object" || Array.isArray(provider)) {
|
|
2350
|
+
throw new TypeError("adaptV1LlmProvider requires an Arcane browser-WASM LLM provider.");
|
|
2351
|
+
}
|
|
2352
|
+
const existing = V1_LLM_PROVIDER_ADAPTERS.get(provider);
|
|
2353
|
+
if (existing) return existing;
|
|
2354
|
+
if (provider.protocol !== ARCANE_AI_ADAPTER_PROTOCOL) {
|
|
2355
|
+
throw new TypeError(`The browser-WASM LLM provider protocol must equal ${ARCANE_AI_ADAPTER_PROTOCOL}.`);
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
const providerId = requiredText(provider.id, "provider id");
|
|
2359
|
+
const model = modelDescriptor(provider.model);
|
|
2360
|
+
const requiredMethods = ["capabilities", "status", "load", "unload", "chat", "stream", "dispose"];
|
|
2361
|
+
const methods = Object.create(null);
|
|
2362
|
+
for (const method of requiredMethods) {
|
|
2363
|
+
if (typeof provider[method] !== "function") {
|
|
2364
|
+
throw new TypeError(`The browser-WASM LLM provider is missing ${method}().`);
|
|
2365
|
+
}
|
|
2366
|
+
methods[method] = provider[method].bind(provider);
|
|
2367
|
+
}
|
|
2368
|
+
if (methods.capabilities()?.localOnly !== true) {
|
|
2369
|
+
throw new TypeError("The browser-WASM LLM provider must be explicitly local-only.");
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
const fallbackCatalog = Object.freeze([model]);
|
|
2373
|
+
const initialCatalog = typeof provider.catalog === "function"
|
|
2374
|
+
? provider.catalog()
|
|
2375
|
+
: fallbackCatalog;
|
|
2376
|
+
if (!Array.isArray(initialCatalog) || initialCatalog.length === 0) {
|
|
2377
|
+
throw new TypeError("The browser-WASM provider catalog must be a nonempty array.");
|
|
2378
|
+
}
|
|
2379
|
+
const catalogModels = new Map();
|
|
2380
|
+
for (const entry of initialCatalog) {
|
|
2381
|
+
const descriptor = modelDescriptor(entry);
|
|
2382
|
+
if (catalogModels.has(descriptor.id)) {
|
|
2383
|
+
throw new TypeError("The browser-WASM provider catalog model ids must be unique.");
|
|
2384
|
+
}
|
|
2385
|
+
catalogModels.set(descriptor.id, descriptor);
|
|
2386
|
+
}
|
|
2387
|
+
const modelIds = new Set(catalogModels.keys());
|
|
2388
|
+
let disposed = false;
|
|
2389
|
+
|
|
2390
|
+
function assertSelection(selection, role) {
|
|
2391
|
+
assertV1LlmAdapterSelection(selection, providerId, modelIds, role);
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
function authorityFor(selection) {
|
|
2395
|
+
const selectedModel = catalogModels.get(selection.modelId);
|
|
2396
|
+
return Object.freeze({
|
|
2397
|
+
protocol: AI_MODEL_AUTHORITY_PROTOCOL,
|
|
2398
|
+
providerId,
|
|
2399
|
+
modelId: selectedModel.id,
|
|
2400
|
+
admitted: true,
|
|
2401
|
+
localOnly: true,
|
|
2402
|
+
model: selectedModel,
|
|
2403
|
+
});
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
function assertActiveSelection(selection) {
|
|
2407
|
+
const active = methods.status()?.model?.id;
|
|
2408
|
+
if (active !== selection.modelId) {
|
|
2409
|
+
throw fail(
|
|
2410
|
+
"ARCANE_AI_MODEL_NOT_READY",
|
|
2411
|
+
"The selected browser-WASM model is not the provider's active model.",
|
|
2412
|
+
);
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
function status() {
|
|
2417
|
+
const value = methods.status();
|
|
2418
|
+
if (
|
|
2419
|
+
!value
|
|
2420
|
+
|| typeof value !== "object"
|
|
2421
|
+
|| typeof value.state !== "string"
|
|
2422
|
+
|| typeof value.loaded !== "boolean"
|
|
2423
|
+
|| typeof value.busy !== "boolean"
|
|
2424
|
+
) {
|
|
2425
|
+
throw fail("ARCANE_AI_PROVIDER_STATUS_INVALID", "The browser-WASM provider returned an invalid status.");
|
|
2426
|
+
}
|
|
2427
|
+
return Object.freeze({
|
|
2428
|
+
state: disposed ? "disposed" : value.state,
|
|
2429
|
+
loaded: disposed ? false : value.loaded,
|
|
2430
|
+
busy: disposed ? false : value.busy,
|
|
2431
|
+
cache: value.cache,
|
|
2432
|
+
security: value.security,
|
|
2433
|
+
integrity: value.integrity,
|
|
2434
|
+
capabilityPolicy: value.capabilityPolicy,
|
|
2435
|
+
compatibility: value.capabilityPolicy?.compatibility ?? "unknown",
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
const adapted = Object.freeze({
|
|
2440
|
+
protocol: AI_PROVIDER_PROTOCOL,
|
|
2441
|
+
role: "llm",
|
|
2442
|
+
id: providerId,
|
|
2443
|
+
localOnly: true,
|
|
2444
|
+
catalog: () => typeof provider.catalog === "function"
|
|
2445
|
+
? provider.catalog()
|
|
2446
|
+
: fallbackCatalog,
|
|
2447
|
+
async inspect(selection, { role = "llm", signal = null } = {}) {
|
|
2448
|
+
assertSelection(selection, role);
|
|
2449
|
+
throwIfAborted(signal, "inspect");
|
|
2450
|
+
if (disposed) {
|
|
2451
|
+
return Object.freeze({
|
|
2452
|
+
available: false,
|
|
2453
|
+
code: "ARCANE_AI_DISPOSED",
|
|
2454
|
+
message: "The browser-WASM provider is disposed.",
|
|
2455
|
+
});
|
|
2456
|
+
}
|
|
2457
|
+
const capabilities = methods.capabilities();
|
|
2458
|
+
const requirements = [
|
|
2459
|
+
[capabilities?.webAssembly === true, "WebAssembly"],
|
|
2460
|
+
[capabilities?.opfs === true, "OPFS"],
|
|
2461
|
+
[capabilities?.secureContext === true, "a secure context"],
|
|
2462
|
+
[capabilities?.webgpuApiPresent === true, "the WebGPU API"],
|
|
2463
|
+
];
|
|
2464
|
+
const missing = requirements.find(([available]) => !available)?.[1] ?? null;
|
|
2465
|
+
if (missing) {
|
|
2466
|
+
return Object.freeze({
|
|
2467
|
+
available: false,
|
|
2468
|
+
code: "ARCANE_AI_PROVIDER_UNAVAILABLE",
|
|
2469
|
+
message: `The browser-WASM provider requires ${missing}.`,
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
return Object.freeze({ available: true, authority: authorityFor(selection) });
|
|
2473
|
+
},
|
|
2474
|
+
status,
|
|
2475
|
+
async load({
|
|
2476
|
+
role = "llm",
|
|
2477
|
+
selection,
|
|
2478
|
+
signal = null,
|
|
2479
|
+
progress = null,
|
|
2480
|
+
security,
|
|
2481
|
+
} = {}) {
|
|
2482
|
+
assertSelection(selection, role);
|
|
2483
|
+
throwIfAborted(signal, "load");
|
|
2484
|
+
if (progress !== null && typeof progress !== "function") {
|
|
2485
|
+
throw new TypeError("The provider/2 progress sink must be a function or null.");
|
|
2486
|
+
}
|
|
2487
|
+
await methods.load({
|
|
2488
|
+
modelId: selection.modelId,
|
|
2489
|
+
signal,
|
|
2490
|
+
security,
|
|
2491
|
+
...(progress ? { onProgress: (value) => progress(provider2ByteProgress(value)) } : {}),
|
|
2492
|
+
});
|
|
2493
|
+
return status();
|
|
2494
|
+
},
|
|
2495
|
+
request({ role = "llm", selection, operation, payload, signal = null } = {}) {
|
|
2496
|
+
assertSelection(selection, role);
|
|
2497
|
+
assertActiveSelection(selection);
|
|
2498
|
+
throwIfAborted(signal);
|
|
2499
|
+
if (operation === "chat") return methods.chat(payload, { signal });
|
|
2500
|
+
if (operation === "stream") return methods.stream(payload, { signal });
|
|
2501
|
+
throw fail("ARCANE_AI_PROVIDER_OPERATION_UNAVAILABLE", "The browser-WASM adapter supports only chat and stream.");
|
|
2502
|
+
},
|
|
2503
|
+
async unload({ role = "llm", selection, signal = null } = {}) {
|
|
2504
|
+
assertSelection(selection, role);
|
|
2505
|
+
throwIfAborted(signal, "unload");
|
|
2506
|
+
await methods.unload({ signal });
|
|
2507
|
+
return status();
|
|
2508
|
+
},
|
|
2509
|
+
async dispose({ role = "llm", selection, signal = null } = {}) {
|
|
2510
|
+
assertSelection(selection, role);
|
|
2511
|
+
throwIfAborted(signal, "dispose");
|
|
2512
|
+
await methods.dispose({ signal });
|
|
2513
|
+
disposed = true;
|
|
2514
|
+
return status();
|
|
2515
|
+
},
|
|
2516
|
+
});
|
|
2517
|
+
V1_LLM_PROVIDER_ADAPTERS.set(provider, adapted);
|
|
2518
|
+
return adapted;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
1151
2521
|
export { MODEL_MANIFEST_SCHEMA };
|