@octomil/browser 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -0
  3. package/dist/cache.d.ts +25 -0
  4. package/dist/cache.d.ts.map +1 -0
  5. package/dist/cache.js +202 -0
  6. package/dist/cache.js.map +1 -0
  7. package/dist/device-auth.d.ts +41 -0
  8. package/dist/device-auth.d.ts.map +1 -0
  9. package/dist/device-auth.js +203 -0
  10. package/dist/device-auth.js.map +1 -0
  11. package/dist/experiments.d.ts +44 -0
  12. package/dist/experiments.d.ts.map +1 -0
  13. package/dist/experiments.js +135 -0
  14. package/dist/experiments.js.map +1 -0
  15. package/dist/federated.d.ts +53 -0
  16. package/dist/federated.d.ts.map +1 -0
  17. package/dist/federated.js +180 -0
  18. package/dist/federated.js.map +1 -0
  19. package/dist/index.cjs +2148 -0
  20. package/dist/index.cjs.map +7 -0
  21. package/dist/index.d.ts +37 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +45 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/inference.d.ts +43 -0
  26. package/dist/inference.d.ts.map +1 -0
  27. package/dist/inference.js +213 -0
  28. package/dist/inference.js.map +1 -0
  29. package/dist/integrity.d.ts +19 -0
  30. package/dist/integrity.d.ts.map +1 -0
  31. package/dist/integrity.js +35 -0
  32. package/dist/integrity.js.map +1 -0
  33. package/dist/model-loader.d.ts +40 -0
  34. package/dist/model-loader.d.ts.map +1 -0
  35. package/dist/model-loader.js +232 -0
  36. package/dist/model-loader.js.map +1 -0
  37. package/dist/octomil.d.ts +92 -0
  38. package/dist/octomil.d.ts.map +1 -0
  39. package/dist/octomil.js +368 -0
  40. package/dist/octomil.js.map +1 -0
  41. package/dist/octomil.min.js +2849 -0
  42. package/dist/octomil.min.js.map +7 -0
  43. package/dist/privacy.d.ts +40 -0
  44. package/dist/privacy.d.ts.map +1 -0
  45. package/dist/privacy.js +118 -0
  46. package/dist/privacy.js.map +1 -0
  47. package/dist/rollouts.d.ts +43 -0
  48. package/dist/rollouts.d.ts.map +1 -0
  49. package/dist/rollouts.js +114 -0
  50. package/dist/rollouts.js.map +1 -0
  51. package/dist/secure-aggregation.d.ts +50 -0
  52. package/dist/secure-aggregation.d.ts.map +1 -0
  53. package/dist/secure-aggregation.js +174 -0
  54. package/dist/secure-aggregation.js.map +1 -0
  55. package/dist/streaming.d.ts +25 -0
  56. package/dist/streaming.d.ts.map +1 -0
  57. package/dist/streaming.js +148 -0
  58. package/dist/streaming.js.map +1 -0
  59. package/dist/telemetry.d.ts +41 -0
  60. package/dist/telemetry.d.ts.map +1 -0
  61. package/dist/telemetry.js +130 -0
  62. package/dist/telemetry.js.map +1 -0
  63. package/dist/types.d.ts +239 -0
  64. package/dist/types.d.ts.map +1 -0
  65. package/dist/types.js +17 -0
  66. package/dist/types.js.map +1 -0
  67. package/package.json +62 -0
package/dist/index.cjs ADDED
@@ -0,0 +1,2148 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ DeviceAuthManager: () => DeviceAuthManager,
34
+ ExperimentsClient: () => ExperimentsClient,
35
+ FederatedClient: () => FederatedClient,
36
+ InferenceEngine: () => InferenceEngine,
37
+ ModelLoader: () => ModelLoader,
38
+ Octomil: () => Octomil,
39
+ OctomilError: () => OctomilError,
40
+ RolloutsManager: () => RolloutsManager,
41
+ SecAggPlus: () => SecAggPlus,
42
+ SecureAggregation: () => SecureAggregation,
43
+ StreamingInferenceEngine: () => StreamingInferenceEngine,
44
+ TelemetryReporter: () => TelemetryReporter,
45
+ WeightExtractor: () => WeightExtractor,
46
+ addGaussianNoise: () => addGaussianNoise,
47
+ assertModelIntegrity: () => assertModelIntegrity,
48
+ clipGradients: () => clipGradients,
49
+ computeHash: () => computeHash,
50
+ createModelCache: () => createModelCache,
51
+ dequantize: () => dequantize,
52
+ disposeTelemetry: () => disposeTelemetry,
53
+ getTelemetry: () => getTelemetry,
54
+ initTelemetry: () => initTelemetry,
55
+ quantize: () => quantize,
56
+ shamirReconstruct: () => shamirReconstruct,
57
+ shamirSplit: () => shamirSplit,
58
+ verifyModelIntegrity: () => verifyModelIntegrity
59
+ });
60
+ module.exports = __toCommonJS(index_exports);
61
+
62
+ // src/types.ts
63
+ var OctomilError = class extends Error {
64
+ code;
65
+ cause;
66
+ constructor(code, message, cause) {
67
+ super(message);
68
+ this.name = "OctomilError";
69
+ this.code = code;
70
+ this.cause = cause;
71
+ }
72
+ };
73
+
74
+ // src/cache.ts
75
+ var CACHE_NAME = "octomil-models-v1";
76
+ var IDB_DB_NAME = "octomil-models";
77
+ var IDB_STORE_NAME = "blobs";
78
+ var IDB_VERSION = 1;
79
+ var CacheApiModelCache = class {
80
+ async open() {
81
+ return caches.open(CACHE_NAME);
82
+ }
83
+ async get(key) {
84
+ const cache = await this.open();
85
+ const response = await cache.match(key);
86
+ if (!response) return null;
87
+ return response.arrayBuffer();
88
+ }
89
+ async put(key, data) {
90
+ const cache = await this.open();
91
+ const response = new Response(data, {
92
+ headers: {
93
+ "x-octomil-cached-at": (/* @__PURE__ */ new Date()).toISOString(),
94
+ "x-octomil-size": String(data.byteLength)
95
+ }
96
+ });
97
+ await cache.put(key, response);
98
+ }
99
+ async has(key) {
100
+ const cache = await this.open();
101
+ const match = await cache.match(key);
102
+ return match !== void 0;
103
+ }
104
+ async remove(key) {
105
+ const cache = await this.open();
106
+ await cache.delete(key);
107
+ }
108
+ async info(key) {
109
+ const cache = await this.open();
110
+ const response = await cache.match(key);
111
+ if (!response) {
112
+ return { cached: false, sizeBytes: 0 };
113
+ }
114
+ const sizeHeader = response.headers.get("x-octomil-size");
115
+ const cachedAtHeader = response.headers.get("x-octomil-cached-at");
116
+ return {
117
+ cached: true,
118
+ sizeBytes: sizeHeader ? parseInt(sizeHeader, 10) : 0,
119
+ cachedAt: cachedAtHeader ?? void 0
120
+ };
121
+ }
122
+ };
123
+ var IndexedDBModelCache = class {
124
+ dbPromise = null;
125
+ openDB() {
126
+ if (this.dbPromise) return this.dbPromise;
127
+ this.dbPromise = new Promise((resolve, reject) => {
128
+ const request = indexedDB.open(IDB_DB_NAME, IDB_VERSION);
129
+ request.onupgradeneeded = () => {
130
+ const db = request.result;
131
+ if (!db.objectStoreNames.contains(IDB_STORE_NAME)) {
132
+ db.createObjectStore(IDB_STORE_NAME, { keyPath: "key" });
133
+ }
134
+ };
135
+ request.onsuccess = () => resolve(request.result);
136
+ request.onerror = () => reject(request.error);
137
+ });
138
+ return this.dbPromise;
139
+ }
140
+ async tx(mode) {
141
+ const db = await this.openDB();
142
+ const transaction = db.transaction(IDB_STORE_NAME, mode);
143
+ return transaction.objectStore(IDB_STORE_NAME);
144
+ }
145
+ async get(key) {
146
+ const store = await this.tx("readonly");
147
+ return new Promise((resolve, reject) => {
148
+ const request = store.get(key);
149
+ request.onsuccess = () => {
150
+ const entry = request.result;
151
+ resolve(entry?.data ?? null);
152
+ };
153
+ request.onerror = () => reject(request.error);
154
+ });
155
+ }
156
+ async put(key, data) {
157
+ const store = await this.tx("readwrite");
158
+ const entry = {
159
+ key,
160
+ data,
161
+ sizeBytes: data.byteLength,
162
+ cachedAt: (/* @__PURE__ */ new Date()).toISOString()
163
+ };
164
+ return new Promise((resolve, reject) => {
165
+ const request = store.put(entry);
166
+ request.onsuccess = () => resolve();
167
+ request.onerror = () => reject(request.error);
168
+ });
169
+ }
170
+ async has(key) {
171
+ const store = await this.tx("readonly");
172
+ return new Promise((resolve, reject) => {
173
+ const request = store.count(key);
174
+ request.onsuccess = () => resolve(request.result > 0);
175
+ request.onerror = () => reject(request.error);
176
+ });
177
+ }
178
+ async remove(key) {
179
+ const store = await this.tx("readwrite");
180
+ return new Promise((resolve, reject) => {
181
+ const request = store.delete(key);
182
+ request.onsuccess = () => resolve();
183
+ request.onerror = () => reject(request.error);
184
+ });
185
+ }
186
+ async info(key) {
187
+ const store = await this.tx("readonly");
188
+ return new Promise((resolve, reject) => {
189
+ const request = store.get(key);
190
+ request.onsuccess = () => {
191
+ const entry = request.result;
192
+ if (!entry) {
193
+ resolve({ cached: false, sizeBytes: 0 });
194
+ } else {
195
+ resolve({
196
+ cached: true,
197
+ sizeBytes: entry.sizeBytes,
198
+ cachedAt: entry.cachedAt
199
+ });
200
+ }
201
+ };
202
+ request.onerror = () => reject(request.error);
203
+ });
204
+ }
205
+ };
206
+ var NoopModelCache = class {
207
+ async get(_key) {
208
+ return null;
209
+ }
210
+ async put(_key, _data) {
211
+ }
212
+ async has(_key) {
213
+ return false;
214
+ }
215
+ async remove(_key) {
216
+ }
217
+ async info(_key) {
218
+ return { cached: false, sizeBytes: 0 };
219
+ }
220
+ };
221
+ function createModelCache(strategy) {
222
+ if (strategy === "none") {
223
+ return new NoopModelCache();
224
+ }
225
+ if (strategy === "cache-api") {
226
+ if (typeof caches !== "undefined") {
227
+ return new CacheApiModelCache();
228
+ }
229
+ if (typeof indexedDB !== "undefined") {
230
+ return new IndexedDBModelCache();
231
+ }
232
+ return new NoopModelCache();
233
+ }
234
+ if (strategy === "indexeddb") {
235
+ if (typeof indexedDB !== "undefined") {
236
+ return new IndexedDBModelCache();
237
+ }
238
+ return new NoopModelCache();
239
+ }
240
+ throw new OctomilError(
241
+ "CACHE_ERROR",
242
+ `Unknown cache strategy: ${strategy}`
243
+ );
244
+ }
245
+
246
+ // src/inference.ts
247
+ var InferenceEngine = class {
248
+ session = null;
249
+ ortModule = null;
250
+ resolvedBackend = null;
251
+ // -----------------------------------------------------------------------
252
+ // Public
253
+ // -----------------------------------------------------------------------
254
+ /**
255
+ * Create an ONNX Runtime session from the given model bytes.
256
+ *
257
+ * @param modelData Raw ONNX model ArrayBuffer.
258
+ * @param backend Requested backend (`"webgpu"`, `"wasm"`, or `undefined` for auto).
259
+ */
260
+ async createSession(modelData, backend) {
261
+ const ortMod = await this.loadOrt();
262
+ const provider = await this.resolveProvider(ortMod, backend);
263
+ this.resolvedBackend = provider === "webgpu" ? "webgpu" : "wasm";
264
+ const sessionOptions = {
265
+ executionProviders: [provider],
266
+ graphOptimizationLevel: "all"
267
+ };
268
+ try {
269
+ this.session = await ortMod.InferenceSession.create(
270
+ modelData,
271
+ sessionOptions
272
+ );
273
+ } catch (err) {
274
+ if (provider === "webgpu") {
275
+ try {
276
+ this.session = await ortMod.InferenceSession.create(modelData, {
277
+ executionProviders: ["wasm"],
278
+ graphOptimizationLevel: "all"
279
+ });
280
+ this.resolvedBackend = "wasm";
281
+ } catch (wasmErr) {
282
+ throw new OctomilError(
283
+ "MODEL_LOAD_FAILED",
284
+ "Failed to create ONNX session with both WebGPU and WASM backends.",
285
+ wasmErr
286
+ );
287
+ }
288
+ } else {
289
+ throw new OctomilError(
290
+ "MODEL_LOAD_FAILED",
291
+ `Failed to create ONNX session: ${String(err)}`,
292
+ err
293
+ );
294
+ }
295
+ }
296
+ }
297
+ /**
298
+ * Run inference and return the output tensors plus timing info.
299
+ */
300
+ async run(inputs) {
301
+ this.ensureSession();
302
+ const ortMod = this.ortModule;
303
+ const session = this.session;
304
+ const feeds = {};
305
+ for (const [name, tensor] of Object.entries(inputs)) {
306
+ feeds[name] = new ortMod.Tensor(
307
+ inferOrtType(tensor.data),
308
+ tensor.data,
309
+ tensor.dims
310
+ );
311
+ }
312
+ const start = performance.now();
313
+ let results;
314
+ try {
315
+ results = await session.run(feeds);
316
+ } catch (err) {
317
+ throw new OctomilError(
318
+ "INFERENCE_FAILED",
319
+ `Inference run failed: ${String(err)}`,
320
+ err
321
+ );
322
+ }
323
+ const latencyMs = performance.now() - start;
324
+ const tensors = this.convertOutputs(results);
325
+ const convenience = this.extractConvenience(tensors);
326
+ return {
327
+ tensors,
328
+ latencyMs,
329
+ ...convenience
330
+ };
331
+ }
332
+ /** Names of the model's input tensors. */
333
+ get inputNames() {
334
+ this.ensureSession();
335
+ return this.session.inputNames;
336
+ }
337
+ /** Names of the model's output tensors. */
338
+ get outputNames() {
339
+ this.ensureSession();
340
+ return this.session.outputNames;
341
+ }
342
+ /** The backend that was actually used after negotiation. */
343
+ get activeBackend() {
344
+ return this.resolvedBackend;
345
+ }
346
+ /** Release WASM / WebGPU resources. */
347
+ dispose() {
348
+ if (this.session) {
349
+ void this.session.release();
350
+ this.session = null;
351
+ }
352
+ }
353
+ // -----------------------------------------------------------------------
354
+ // Internal
355
+ // -----------------------------------------------------------------------
356
+ async loadOrt() {
357
+ if (this.ortModule) return this.ortModule;
358
+ try {
359
+ this.ortModule = await import("onnxruntime-web");
360
+ return this.ortModule;
361
+ } catch (err) {
362
+ throw new OctomilError(
363
+ "BACKEND_UNAVAILABLE",
364
+ "Failed to import onnxruntime-web. Make sure the package is installed: npm i onnxruntime-web",
365
+ err
366
+ );
367
+ }
368
+ }
369
+ async resolveProvider(_ortMod, backend) {
370
+ if (backend === "wasm") return "wasm";
371
+ if (backend === "webgpu" || backend === void 0) {
372
+ const hasWebGPU = await this.detectWebGPU();
373
+ if (hasWebGPU) return "webgpu";
374
+ if (backend === "webgpu") {
375
+ throw new OctomilError(
376
+ "BACKEND_UNAVAILABLE",
377
+ "WebGPU was explicitly requested but is not available in this browser."
378
+ );
379
+ }
380
+ }
381
+ return "wasm";
382
+ }
383
+ async detectWebGPU() {
384
+ if (typeof navigator === "undefined") return false;
385
+ try {
386
+ const gpu = navigator.gpu;
387
+ if (!gpu) return false;
388
+ const adapter = await gpu.requestAdapter();
389
+ return adapter !== null;
390
+ } catch {
391
+ return false;
392
+ }
393
+ }
394
+ ensureSession() {
395
+ if (!this.session) {
396
+ throw new OctomilError(
397
+ "SESSION_DISPOSED",
398
+ "No active session. Call load() before running inference."
399
+ );
400
+ }
401
+ }
402
+ convertOutputs(results) {
403
+ const tensors = {};
404
+ for (const name of Object.keys(results)) {
405
+ const ortTensor = results[name];
406
+ tensors[name] = {
407
+ data: ortTensor.data,
408
+ dims: Array.from(ortTensor.dims)
409
+ };
410
+ }
411
+ return tensors;
412
+ }
413
+ /**
414
+ * Best-effort extraction of `label` / `score` / `scores` from the
415
+ * first output tensor — only if it looks like a classification head.
416
+ */
417
+ extractConvenience(tensors) {
418
+ const names = Object.keys(tensors);
419
+ if (names.length === 0) return {};
420
+ const first = tensors[names[0]];
421
+ const data = first.data;
422
+ if (!(data instanceof Float32Array)) return {};
423
+ if (data.length === 0) return {};
424
+ const scores = Array.from(data);
425
+ let maxIdx = 0;
426
+ let maxVal = -Infinity;
427
+ for (let i = 0; i < scores.length; i++) {
428
+ if (scores[i] > maxVal) {
429
+ maxVal = scores[i];
430
+ maxIdx = i;
431
+ }
432
+ }
433
+ return {
434
+ label: String(maxIdx),
435
+ score: maxVal,
436
+ scores
437
+ };
438
+ }
439
+ };
440
+ function inferOrtType(data) {
441
+ if (data instanceof Float32Array) return "float32";
442
+ if (data instanceof Int32Array) return "int32";
443
+ if (data instanceof BigInt64Array) return "int64";
444
+ if (data instanceof Uint8Array) return "uint8";
445
+ return "float32";
446
+ }
447
+
448
+ // src/model-loader.ts
449
+ var MAX_RETRIES = 3;
450
+ var RETRY_DELAY_MS = 1e3;
451
+ var ModelLoader = class {
452
+ modelId;
453
+ serverUrl;
454
+ apiKey;
455
+ onProgress;
456
+ cache;
457
+ constructor(options, cache) {
458
+ this.modelId = options.model;
459
+ this.serverUrl = options.serverUrl;
460
+ this.apiKey = options.apiKey;
461
+ this.onProgress = options.onProgress;
462
+ this.cache = cache;
463
+ }
464
+ // -----------------------------------------------------------------------
465
+ // Public
466
+ // -----------------------------------------------------------------------
467
+ /**
468
+ * Resolve the model URL, check the cache, download if needed,
469
+ * and return the ONNX model bytes.
470
+ */
471
+ async load() {
472
+ const url = await this.resolveModelUrl();
473
+ const cached = await this.cache.get(url);
474
+ if (cached) {
475
+ return cached;
476
+ }
477
+ const data = await this.download(url);
478
+ this.validate(data);
479
+ await this.cache.put(url, data);
480
+ return data;
481
+ }
482
+ /** Check whether the model is already cached. */
483
+ async isCached() {
484
+ const url = await this.resolveModelUrl();
485
+ return this.cache.has(url);
486
+ }
487
+ /** Remove the cached model. */
488
+ async clearCache() {
489
+ const url = await this.resolveModelUrl();
490
+ await this.cache.remove(url);
491
+ }
492
+ /** Get cache info for the model. */
493
+ async getCacheInfo() {
494
+ const url = await this.resolveModelUrl();
495
+ return this.cache.info(url);
496
+ }
497
+ // -----------------------------------------------------------------------
498
+ // Internal — URL resolution
499
+ // -----------------------------------------------------------------------
500
+ /**
501
+ * If `modelId` looks like a URL (starts with http:// or https://) use it
502
+ * directly. Otherwise treat it as a registry model name and resolve via
503
+ * the Octomil server.
504
+ */
505
+ async resolveModelUrl() {
506
+ if (this.modelId.startsWith("http://") || this.modelId.startsWith("https://")) {
507
+ return this.modelId;
508
+ }
509
+ if (!this.serverUrl) {
510
+ throw new OctomilError(
511
+ "MODEL_NOT_FOUND",
512
+ `Cannot resolve model "${this.modelId}": no serverUrl configured.`
513
+ );
514
+ }
515
+ return this.fetchRegistryUrl(this.modelId);
516
+ }
517
+ async fetchRegistryUrl(name) {
518
+ const registryUrl = `${this.serverUrl}/api/v1/models/${encodeURIComponent(name)}/metadata`;
519
+ const headers = {
520
+ Accept: "application/json"
521
+ };
522
+ if (this.apiKey) {
523
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
524
+ }
525
+ let response;
526
+ try {
527
+ response = await fetch(registryUrl, { headers });
528
+ } catch (err) {
529
+ throw new OctomilError(
530
+ "NETWORK_ERROR",
531
+ `Failed to reach model registry at ${registryUrl}`,
532
+ err
533
+ );
534
+ }
535
+ if (!response.ok) {
536
+ if (response.status === 404) {
537
+ throw new OctomilError(
538
+ "MODEL_NOT_FOUND",
539
+ `Model "${name}" not found in registry.`
540
+ );
541
+ }
542
+ throw new OctomilError(
543
+ "NETWORK_ERROR",
544
+ `Registry returned HTTP ${response.status}: ${response.statusText}`
545
+ );
546
+ }
547
+ const metadata = await response.json();
548
+ return metadata.url;
549
+ }
550
+ // -----------------------------------------------------------------------
551
+ // Internal — download
552
+ // -----------------------------------------------------------------------
553
+ buildHeaders(extra = {}) {
554
+ const headers = {
555
+ "Accept-Encoding": "gzip, deflate, br",
556
+ ...extra
557
+ };
558
+ if (this.apiKey) {
559
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
560
+ }
561
+ return headers;
562
+ }
563
+ async download(url) {
564
+ const chunks = [];
565
+ let loaded = 0;
566
+ let totalSize = 0;
567
+ let attempt = 0;
568
+ while (attempt <= MAX_RETRIES) {
569
+ const headers = this.buildHeaders(
570
+ loaded > 0 ? { Range: `bytes=${loaded}-` } : {}
571
+ );
572
+ let response;
573
+ try {
574
+ response = await fetch(url, { headers });
575
+ } catch (err) {
576
+ attempt++;
577
+ if (attempt > MAX_RETRIES) {
578
+ throw new OctomilError(
579
+ "NETWORK_ERROR",
580
+ `Failed to download model from ${url} after ${MAX_RETRIES} retries`,
581
+ err
582
+ );
583
+ }
584
+ await this.delay(RETRY_DELAY_MS * attempt);
585
+ continue;
586
+ }
587
+ if (response.status === 416) {
588
+ chunks.length = 0;
589
+ loaded = 0;
590
+ attempt++;
591
+ if (attempt > MAX_RETRIES) {
592
+ throw new OctomilError(
593
+ "MODEL_LOAD_FAILED",
594
+ `Model download failed: range request rejected after ${MAX_RETRIES} retries`
595
+ );
596
+ }
597
+ await this.delay(RETRY_DELAY_MS * attempt);
598
+ continue;
599
+ }
600
+ if (!response.ok && response.status !== 206) {
601
+ throw new OctomilError(
602
+ "MODEL_LOAD_FAILED",
603
+ `Model download failed: HTTP ${response.status} ${response.statusText}`
604
+ );
605
+ }
606
+ if (totalSize === 0) {
607
+ const contentRange = response.headers.get("Content-Range");
608
+ if (contentRange) {
609
+ const match = contentRange.match(/\/(\d+)$/);
610
+ if (match) totalSize = parseInt(match[1], 10);
611
+ } else {
612
+ totalSize = parseInt(response.headers.get("Content-Length") ?? "0", 10);
613
+ }
614
+ }
615
+ if (!response.body) {
616
+ const buf = await response.arrayBuffer();
617
+ return buf;
618
+ }
619
+ const reader = response.body.getReader();
620
+ let streamFailed = false;
621
+ try {
622
+ for (; ; ) {
623
+ const { done, value } = await reader.read();
624
+ if (done) break;
625
+ chunks.push(value);
626
+ loaded += value.byteLength;
627
+ this.onProgress?.({
628
+ loaded,
629
+ total: totalSize,
630
+ percent: totalSize > 0 ? loaded / totalSize * 100 : NaN
631
+ });
632
+ }
633
+ } catch (err) {
634
+ streamFailed = true;
635
+ attempt++;
636
+ if (attempt > MAX_RETRIES) {
637
+ throw new OctomilError(
638
+ "NETWORK_ERROR",
639
+ `Model download interrupted after ${MAX_RETRIES} retries`,
640
+ err
641
+ );
642
+ }
643
+ await this.delay(RETRY_DELAY_MS * attempt);
644
+ } finally {
645
+ reader.releaseLock();
646
+ }
647
+ if (!streamFailed) break;
648
+ }
649
+ const combined = new Uint8Array(loaded);
650
+ let offset = 0;
651
+ for (const chunk of chunks) {
652
+ combined.set(chunk, offset);
653
+ offset += chunk.byteLength;
654
+ }
655
+ return combined.buffer;
656
+ }
657
+ delay(ms) {
658
+ return new Promise((resolve) => setTimeout(resolve, ms));
659
+ }
660
+ // -----------------------------------------------------------------------
661
+ // Internal — validation
662
+ // -----------------------------------------------------------------------
663
+ validate(data) {
664
+ if (data.byteLength === 0) {
665
+ throw new OctomilError(
666
+ "MODEL_LOAD_FAILED",
667
+ "Downloaded model is empty (0 bytes)."
668
+ );
669
+ }
670
+ const header = new Uint8Array(data, 0, Math.min(4, data.byteLength));
671
+ if (header[0] !== 8) {
672
+ throw new OctomilError(
673
+ "MODEL_LOAD_FAILED",
674
+ "Downloaded file does not appear to be a valid ONNX model."
675
+ );
676
+ }
677
+ }
678
+ };
679
+
680
+ // src/telemetry.ts
681
+ var DEFAULT_FLUSH_INTERVAL_MS = 3e4;
682
+ var DEFAULT_MAX_BATCH_SIZE = 50;
683
+ var DEFAULT_TELEMETRY_URL = "https://api.octomil.io/v1/telemetry";
684
+ var TelemetryReporter = class {
685
+ url;
686
+ flushIntervalMs;
687
+ maxBatchSize;
688
+ apiKey;
689
+ queue = [];
690
+ timerId = null;
691
+ disposed = false;
692
+ constructor(options = {}) {
693
+ this.url = options.url ?? DEFAULT_TELEMETRY_URL;
694
+ this.flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
695
+ this.maxBatchSize = options.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
696
+ this.apiKey = options.apiKey;
697
+ this.startAutoFlush();
698
+ }
699
+ // -----------------------------------------------------------------------
700
+ // Public
701
+ // -----------------------------------------------------------------------
702
+ /** Enqueue a telemetry event. Non-blocking, never throws. */
703
+ track(event) {
704
+ if (this.disposed) return;
705
+ this.queue.push(event);
706
+ if (this.queue.length >= this.maxBatchSize) {
707
+ void this.flush();
708
+ }
709
+ }
710
+ /** Flush all queued events immediately. */
711
+ async flush() {
712
+ if (this.queue.length === 0) return;
713
+ const batch = this.queue.splice(0, this.maxBatchSize);
714
+ await this.send(batch);
715
+ }
716
+ /** Stop the flush timer and send remaining events. */
717
+ dispose() {
718
+ if (this.disposed) return;
719
+ this.disposed = true;
720
+ if (this.timerId !== null) {
721
+ clearInterval(this.timerId);
722
+ this.timerId = null;
723
+ }
724
+ if (this.queue.length > 0) {
725
+ this.sendBeacon(this.queue.splice(0));
726
+ }
727
+ }
728
+ // -----------------------------------------------------------------------
729
+ // Internal
730
+ // -----------------------------------------------------------------------
731
+ startAutoFlush() {
732
+ if (typeof setInterval === "undefined") return;
733
+ this.timerId = setInterval(() => {
734
+ void this.flush();
735
+ }, this.flushIntervalMs);
736
+ }
737
+ async send(events) {
738
+ const body = JSON.stringify({ events });
739
+ try {
740
+ if (this.sendBeacon(events)) return;
741
+ const headers = {
742
+ "Content-Type": "application/json"
743
+ };
744
+ if (this.apiKey) {
745
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
746
+ }
747
+ await fetch(this.url, {
748
+ method: "POST",
749
+ headers,
750
+ body,
751
+ keepalive: true
752
+ });
753
+ } catch {
754
+ }
755
+ }
756
+ sendBeacon(events) {
757
+ if (typeof navigator === "undefined" || !navigator.sendBeacon) {
758
+ return false;
759
+ }
760
+ try {
761
+ const blob = new Blob([JSON.stringify({ events })], {
762
+ type: "application/json"
763
+ });
764
+ return navigator.sendBeacon(this.url, blob);
765
+ } catch {
766
+ return false;
767
+ }
768
+ }
769
+ };
770
+ var _reporter = null;
771
+ function initTelemetry(options = {}) {
772
+ if (_reporter) {
773
+ _reporter.dispose();
774
+ }
775
+ _reporter = new TelemetryReporter(options);
776
+ return _reporter;
777
+ }
778
+ function getTelemetry() {
779
+ return _reporter;
780
+ }
781
+ function disposeTelemetry() {
782
+ _reporter?.dispose();
783
+ _reporter = null;
784
+ }
785
+
786
+ // src/streaming.ts
787
+ var StreamingInferenceEngine = class {
788
+ serverUrl;
789
+ apiKey;
790
+ onTelemetry;
791
+ constructor(options) {
792
+ this.serverUrl = options.serverUrl;
793
+ this.apiKey = options.apiKey;
794
+ this.onTelemetry = options.onTelemetry;
795
+ }
796
+ /**
797
+ * Stream inference results from the server.
798
+ *
799
+ * Returns an async iterable of chunks. Supports cancellation via AbortSignal.
800
+ */
801
+ async *stream(modelId, input, options = {}) {
802
+ const abortController = new AbortController();
803
+ const signal = options.signal ? this.combineSignals(options.signal, abortController.signal) : abortController.signal;
804
+ const url = `${this.serverUrl}/api/v1/models/${encodeURIComponent(modelId)}/stream`;
805
+ const headers = {
806
+ "Content-Type": "application/json",
807
+ Accept: "text/event-stream"
808
+ };
809
+ if (this.apiKey) {
810
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
811
+ }
812
+ const startTime = performance.now();
813
+ let ttfc = null;
814
+ let chunkCount = 0;
815
+ let totalBytes = 0;
816
+ this.onTelemetry?.({
817
+ type: "streaming_start",
818
+ model: modelId,
819
+ metadata: { modality: options.modality ?? "text" },
820
+ timestamp: Date.now()
821
+ });
822
+ let response;
823
+ try {
824
+ response = await fetch(url, {
825
+ method: "POST",
826
+ headers,
827
+ body: JSON.stringify({
828
+ input,
829
+ modality: options.modality ?? "text",
830
+ ...options.params
831
+ }),
832
+ signal
833
+ });
834
+ } catch (err) {
835
+ this.onTelemetry?.({
836
+ type: "streaming_error",
837
+ model: modelId,
838
+ metadata: { error: String(err) },
839
+ timestamp: Date.now()
840
+ });
841
+ throw new OctomilError(
842
+ "NETWORK_ERROR",
843
+ `Streaming request failed: ${String(err)}`,
844
+ err
845
+ );
846
+ }
847
+ if (!response.ok) {
848
+ throw new OctomilError(
849
+ "INFERENCE_FAILED",
850
+ `Streaming inference failed: HTTP ${response.status}`
851
+ );
852
+ }
853
+ if (!response.body) {
854
+ throw new OctomilError(
855
+ "INFERENCE_FAILED",
856
+ "Server did not return a streaming body."
857
+ );
858
+ }
859
+ const reader = response.body.getReader();
860
+ const decoder = new TextDecoder();
861
+ let buffer = "";
862
+ try {
863
+ while (true) {
864
+ const { done, value } = await reader.read();
865
+ if (done) break;
866
+ buffer += decoder.decode(value, { stream: true });
867
+ const lines = buffer.split("\n");
868
+ buffer = lines.pop() ?? "";
869
+ for (const line of lines) {
870
+ if (!line.startsWith("data: ")) continue;
871
+ const data = line.slice(6).trim();
872
+ if (data === "[DONE]") break;
873
+ let parsed;
874
+ try {
875
+ parsed = JSON.parse(data);
876
+ } catch {
877
+ continue;
878
+ }
879
+ chunkCount++;
880
+ totalBytes += data.length;
881
+ if (ttfc === null) {
882
+ ttfc = performance.now() - startTime;
883
+ this.onTelemetry?.({
884
+ type: "streaming_chunk",
885
+ model: modelId,
886
+ durationMs: ttfc,
887
+ metadata: { chunkIndex: 0, ttfc: true },
888
+ timestamp: Date.now()
889
+ });
890
+ }
891
+ yield parsed;
892
+ }
893
+ }
894
+ } finally {
895
+ reader.releaseLock();
896
+ }
897
+ const totalMs = performance.now() - startTime;
898
+ this.onTelemetry?.({
899
+ type: "streaming_complete",
900
+ model: modelId,
901
+ durationMs: totalMs,
902
+ metadata: { chunkCount, totalBytes, ttfcMs: ttfc },
903
+ timestamp: Date.now()
904
+ });
905
+ return {
906
+ totalChunks: chunkCount,
907
+ totalBytes,
908
+ durationMs: totalMs,
909
+ ttfcMs: ttfc ?? totalMs
910
+ };
911
+ }
912
+ combineSignals(...signals) {
913
+ const controller = new AbortController();
914
+ for (const signal of signals) {
915
+ if (signal.aborted) {
916
+ controller.abort(signal.reason);
917
+ return controller.signal;
918
+ }
919
+ signal.addEventListener("abort", () => controller.abort(signal.reason), {
920
+ once: true
921
+ });
922
+ }
923
+ return controller.signal;
924
+ }
925
+ };
926
+
927
+ // src/octomil.ts
928
+ var Octomil = class {
929
+ options;
930
+ cache;
931
+ loader;
932
+ engine;
933
+ telemetry = null;
934
+ loaded = false;
935
+ disposed = false;
936
+ constructor(options) {
937
+ this.options = {
938
+ telemetry: false,
939
+ cacheStrategy: "cache-api",
940
+ ...options
941
+ };
942
+ this.cache = createModelCache(this.options.cacheStrategy);
943
+ this.loader = new ModelLoader(this.options, this.cache);
944
+ this.engine = new InferenceEngine();
945
+ if (this.options.telemetry) {
946
+ this.telemetry = new TelemetryReporter({
947
+ url: this.options.telemetryUrl,
948
+ apiKey: this.options.apiKey
949
+ });
950
+ }
951
+ }
952
+ // -----------------------------------------------------------------------
953
+ // Lifecycle
954
+ // -----------------------------------------------------------------------
955
+ /**
956
+ * Download (or load from cache) the ONNX model and create the
957
+ * inference session. Must be called before `predict()` or `chat()`.
958
+ */
959
+ async load() {
960
+ this.ensureNotDisposed();
961
+ const start = performance.now();
962
+ const wasCached = await this.loader.isCached();
963
+ const modelData = await this.loader.load();
964
+ await this.engine.createSession(modelData, this.options.backend);
965
+ this.loaded = true;
966
+ const durationMs = performance.now() - start;
967
+ this.trackEvent({
968
+ type: "model_load",
969
+ model: this.options.model,
970
+ durationMs,
971
+ metadata: {
972
+ backend: this.engine.activeBackend,
973
+ cached: wasCached,
974
+ sizeBytes: modelData.byteLength
975
+ },
976
+ timestamp: Date.now()
977
+ });
978
+ if (wasCached) {
979
+ this.trackEvent({
980
+ type: "cache_hit",
981
+ model: this.options.model,
982
+ timestamp: Date.now()
983
+ });
984
+ } else {
985
+ this.trackEvent({
986
+ type: "cache_miss",
987
+ model: this.options.model,
988
+ timestamp: Date.now()
989
+ });
990
+ }
991
+ }
992
+ // -----------------------------------------------------------------------
993
+ // Inference
994
+ // -----------------------------------------------------------------------
995
+ /**
996
+ * Run a single inference pass.
997
+ *
998
+ * Accepts either raw named tensors or convenience payloads
999
+ * (`{ text }`, `{ image }`, `{ raw, dims }`).
1000
+ */
1001
+ async predict(input) {
1002
+ this.ensureReady();
1003
+ const tensors = this.prepareTensors(input);
1004
+ const result = await this.engine.run(tensors);
1005
+ this.trackEvent({
1006
+ type: "inference",
1007
+ model: this.options.model,
1008
+ durationMs: result.latencyMs,
1009
+ metadata: { backend: this.engine.activeBackend },
1010
+ timestamp: Date.now()
1011
+ });
1012
+ return result;
1013
+ }
1014
+ /**
1015
+ * Run inference on multiple inputs sequentially.
1016
+ * ONNX Runtime Web doesn't handle concurrent sessions well,
1017
+ * so we process one at a time.
1018
+ */
1019
+ async predictBatch(inputs) {
1020
+ this.ensureReady();
1021
+ const start = performance.now();
1022
+ const results = [];
1023
+ for (const input of inputs) {
1024
+ const tensors = this.prepareTensors(input);
1025
+ const result = await this.engine.run(tensors);
1026
+ results.push(result);
1027
+ }
1028
+ const totalMs = performance.now() - start;
1029
+ this.trackEvent({
1030
+ type: "inference",
1031
+ model: this.options.model,
1032
+ durationMs: totalMs,
1033
+ metadata: {
1034
+ backend: this.engine.activeBackend,
1035
+ batchSize: inputs.length
1036
+ },
1037
+ timestamp: Date.now()
1038
+ });
1039
+ return results;
1040
+ }
1041
+ /**
1042
+ * OpenAI-compatible chat completion.
1043
+ * Requires a server with streaming endpoint. Uses StreamingInferenceEngine
1044
+ * under the hood to collect the full response.
1045
+ */
1046
+ async chat(messages, options = {}) {
1047
+ this.ensureReady();
1048
+ if (!this.options.serverUrl) {
1049
+ throw new OctomilError(
1050
+ "INFERENCE_FAILED",
1051
+ "chat() requires serverUrl to be configured."
1052
+ );
1053
+ }
1054
+ const streaming = new StreamingInferenceEngine({
1055
+ serverUrl: this.options.serverUrl,
1056
+ apiKey: this.options.apiKey,
1057
+ onTelemetry: (e) => this.trackEvent(e)
1058
+ });
1059
+ const start = performance.now();
1060
+ let content = "";
1061
+ const generator = streaming.stream(this.options.model, {
1062
+ messages,
1063
+ temperature: options.temperature,
1064
+ max_tokens: options.maxTokens,
1065
+ top_p: options.topP
1066
+ }, { modality: "text", signal: options.signal });
1067
+ for await (const chunk of generator) {
1068
+ if (typeof chunk.data === "string") {
1069
+ content += chunk.data;
1070
+ }
1071
+ }
1072
+ return {
1073
+ message: { role: "assistant", content },
1074
+ latencyMs: performance.now() - start
1075
+ };
1076
+ }
1077
+ /**
1078
+ * Streaming chat — yields chunks as they arrive.
1079
+ */
1080
+ async *chatStream(messages, options = {}) {
1081
+ this.ensureReady();
1082
+ if (!this.options.serverUrl) {
1083
+ throw new OctomilError(
1084
+ "INFERENCE_FAILED",
1085
+ "chatStream() requires serverUrl to be configured."
1086
+ );
1087
+ }
1088
+ const streaming = new StreamingInferenceEngine({
1089
+ serverUrl: this.options.serverUrl,
1090
+ apiKey: this.options.apiKey,
1091
+ onTelemetry: (e) => this.trackEvent(e)
1092
+ });
1093
+ const generator = streaming.stream(this.options.model, {
1094
+ messages,
1095
+ temperature: options.temperature,
1096
+ max_tokens: options.maxTokens,
1097
+ top_p: options.topP
1098
+ }, { modality: "text", signal: options.signal });
1099
+ for await (const chunk of generator) {
1100
+ yield {
1101
+ index: chunk.index,
1102
+ content: typeof chunk.data === "string" ? chunk.data : JSON.stringify(chunk.data),
1103
+ done: chunk.done,
1104
+ role: "assistant"
1105
+ };
1106
+ }
1107
+ }
1108
+ // -----------------------------------------------------------------------
1109
+ // Cache
1110
+ // -----------------------------------------------------------------------
1111
+ /** Check whether the model binary is currently cached locally. */
1112
+ async isCached() {
1113
+ this.ensureNotDisposed();
1114
+ return this.loader.isCached();
1115
+ }
1116
+ /** Remove the cached model binary. */
1117
+ async clearCache() {
1118
+ this.ensureNotDisposed();
1119
+ return this.loader.clearCache();
1120
+ }
1121
+ /** Get cache metadata for the model. */
1122
+ async cacheInfo() {
1123
+ this.ensureNotDisposed();
1124
+ return this.loader.getCacheInfo();
1125
+ }
1126
+ // -----------------------------------------------------------------------
1127
+ // Introspection
1128
+ // -----------------------------------------------------------------------
1129
+ /** The inference backend currently in use (after `load()`). */
1130
+ get activeBackend() {
1131
+ return this.engine.activeBackend;
1132
+ }
1133
+ /** Input tensor names defined by the loaded model. */
1134
+ get inputNames() {
1135
+ this.ensureReady();
1136
+ return this.engine.inputNames;
1137
+ }
1138
+ /** Output tensor names defined by the loaded model. */
1139
+ get outputNames() {
1140
+ this.ensureReady();
1141
+ return this.engine.outputNames;
1142
+ }
1143
+ /** Whether `load()` has been called successfully. */
1144
+ get isLoaded() {
1145
+ return this.loaded;
1146
+ }
1147
+ // -----------------------------------------------------------------------
1148
+ // Cleanup
1149
+ // -----------------------------------------------------------------------
1150
+ /** Release all resources (WASM memory, WebGPU device, telemetry). */
1151
+ dispose() {
1152
+ if (this.disposed) return;
1153
+ this.disposed = true;
1154
+ this.loaded = false;
1155
+ this.engine.dispose();
1156
+ this.telemetry?.dispose();
1157
+ this.telemetry = null;
1158
+ }
1159
+ // -----------------------------------------------------------------------
1160
+ // Private helpers
1161
+ // -----------------------------------------------------------------------
1162
+ ensureNotDisposed() {
1163
+ if (this.disposed) {
1164
+ throw new OctomilError(
1165
+ "SESSION_DISPOSED",
1166
+ "This Octomil instance has been disposed. Create a new one."
1167
+ );
1168
+ }
1169
+ }
1170
+ ensureReady() {
1171
+ this.ensureNotDisposed();
1172
+ if (!this.loaded) {
1173
+ throw new OctomilError(
1174
+ "NOT_LOADED",
1175
+ "Model not loaded. Call load() before predict() or chat()."
1176
+ );
1177
+ }
1178
+ }
1179
+ /**
1180
+ * Normalise the various `PredictInput` shapes into a flat
1181
+ * `NamedTensors` map suitable for the inference engine.
1182
+ */
1183
+ prepareTensors(input) {
1184
+ if (this.isNamedTensors(input)) {
1185
+ return input;
1186
+ }
1187
+ if ("raw" in input && "dims" in input) {
1188
+ const name = this.engine.inputNames[0];
1189
+ if (!name) {
1190
+ throw new OctomilError(
1191
+ "INVALID_INPUT",
1192
+ "Model has no input tensors defined."
1193
+ );
1194
+ }
1195
+ return { [name]: { data: input.raw, dims: input.dims } };
1196
+ }
1197
+ if ("text" in input) {
1198
+ const name = this.engine.inputNames[0];
1199
+ if (!name) {
1200
+ throw new OctomilError(
1201
+ "INVALID_INPUT",
1202
+ "Model has no input tensors defined."
1203
+ );
1204
+ }
1205
+ const codes = new Int32Array(
1206
+ Array.from(input.text).map((ch) => ch.codePointAt(0) ?? 0)
1207
+ );
1208
+ return { [name]: { data: codes, dims: [1, codes.length] } };
1209
+ }
1210
+ if ("image" in input) {
1211
+ return this.imageToTensors(input.image);
1212
+ }
1213
+ throw new OctomilError(
1214
+ "INVALID_INPUT",
1215
+ "Unrecognised PredictInput format. Provide named tensors, { text }, { image }, or { raw, dims }."
1216
+ );
1217
+ }
1218
+ /** Type guard for NamedTensors. */
1219
+ isNamedTensors(input) {
1220
+ if ("text" in input || "image" in input || "raw" in input) return false;
1221
+ const firstValue = Object.values(input)[0];
1222
+ return firstValue !== void 0 && typeof firstValue === "object" && "data" in firstValue && "dims" in firstValue;
1223
+ }
1224
+ /**
1225
+ * Convert an image source to a Float32Array in NCHW format
1226
+ * (batch=1, channels=3, H, W) normalised to [0, 1].
1227
+ */
1228
+ imageToTensors(source) {
1229
+ let imageData;
1230
+ if (source instanceof ImageData) {
1231
+ imageData = source;
1232
+ } else {
1233
+ const canvas = source instanceof HTMLCanvasElement ? source : (() => {
1234
+ const c = document.createElement("canvas");
1235
+ c.width = source.naturalWidth || source.width;
1236
+ c.height = source.naturalHeight || source.height;
1237
+ const ctx2 = c.getContext("2d");
1238
+ ctx2.drawImage(source, 0, 0);
1239
+ return c;
1240
+ })();
1241
+ const ctx = canvas.getContext("2d");
1242
+ if (!ctx) {
1243
+ throw new OctomilError(
1244
+ "INVALID_INPUT",
1245
+ "Could not get 2D context from canvas."
1246
+ );
1247
+ }
1248
+ imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
1249
+ }
1250
+ const { width, height, data: rgba } = imageData;
1251
+ const pixels = width * height;
1252
+ const float = new Float32Array(3 * pixels);
1253
+ for (let i = 0; i < pixels; i++) {
1254
+ float[i] = rgba[i * 4] / 255;
1255
+ float[pixels + i] = rgba[i * 4 + 1] / 255;
1256
+ float[2 * pixels + i] = rgba[i * 4 + 2] / 255;
1257
+ }
1258
+ const name = this.engine.inputNames[0];
1259
+ if (!name) {
1260
+ throw new OctomilError(
1261
+ "INVALID_INPUT",
1262
+ "Model has no input tensors defined."
1263
+ );
1264
+ }
1265
+ return {
1266
+ [name]: {
1267
+ data: float,
1268
+ dims: [1, 3, height, width]
1269
+ }
1270
+ };
1271
+ }
1272
+ trackEvent(event) {
1273
+ this.telemetry?.track(event);
1274
+ }
1275
+ };
1276
+
1277
+ // src/device-auth.ts
1278
+ var REFRESH_BUFFER_MS = 3e4;
1279
+ var DeviceAuthManager = class {
1280
+ serverUrl;
1281
+ apiKey;
1282
+ token = null;
1283
+ deviceId = null;
1284
+ refreshTimer = null;
1285
+ disposed = false;
1286
+ constructor(config) {
1287
+ this.serverUrl = config.serverUrl;
1288
+ this.apiKey = config.apiKey;
1289
+ }
1290
+ // -----------------------------------------------------------------------
1291
+ // Public
1292
+ // -----------------------------------------------------------------------
1293
+ /** Register this device and obtain an initial auth token. */
1294
+ async bootstrap(orgId) {
1295
+ this.ensureNotDisposed();
1296
+ this.deviceId = await this.generateDeviceId();
1297
+ const deviceInfo = this.collectDeviceInfo();
1298
+ const response = await this.request("/api/v1/devices/register", {
1299
+ method: "POST",
1300
+ body: JSON.stringify({
1301
+ org_id: orgId,
1302
+ device_id: this.deviceId,
1303
+ platform: "browser",
1304
+ info: deviceInfo
1305
+ })
1306
+ });
1307
+ if (!response.ok) {
1308
+ throw new OctomilError(
1309
+ "NETWORK_ERROR",
1310
+ `Device registration failed: HTTP ${response.status}`
1311
+ );
1312
+ }
1313
+ const data = await response.json();
1314
+ this.token = {
1315
+ accessToken: data.token,
1316
+ refreshToken: data.refresh_token,
1317
+ expiresAt: new Date(data.expires_at).getTime()
1318
+ };
1319
+ this.scheduleRefresh();
1320
+ }
1321
+ /** Get a valid access token, refreshing if needed. */
1322
+ async getToken() {
1323
+ this.ensureNotDisposed();
1324
+ if (!this.token) {
1325
+ throw new OctomilError(
1326
+ "NETWORK_ERROR",
1327
+ "Not authenticated. Call bootstrap() first."
1328
+ );
1329
+ }
1330
+ if (this.isTokenExpiringSoon()) {
1331
+ await this.refreshToken();
1332
+ }
1333
+ return this.token.accessToken;
1334
+ }
1335
+ /** Refresh the current token. */
1336
+ async refreshToken() {
1337
+ this.ensureNotDisposed();
1338
+ if (!this.token) {
1339
+ throw new OctomilError(
1340
+ "NETWORK_ERROR",
1341
+ "No token to refresh. Call bootstrap() first."
1342
+ );
1343
+ }
1344
+ const response = await this.request("/api/v1/auth/refresh", {
1345
+ method: "POST",
1346
+ body: JSON.stringify({
1347
+ refresh_token: this.token.refreshToken,
1348
+ device_id: this.deviceId
1349
+ })
1350
+ });
1351
+ if (!response.ok) {
1352
+ this.token = null;
1353
+ throw new OctomilError(
1354
+ "NETWORK_ERROR",
1355
+ `Token refresh failed: HTTP ${response.status}`
1356
+ );
1357
+ }
1358
+ const data = await response.json();
1359
+ this.token = {
1360
+ accessToken: data.token,
1361
+ refreshToken: data.refresh_token,
1362
+ expiresAt: new Date(data.expires_at).getTime()
1363
+ };
1364
+ this.scheduleRefresh();
1365
+ }
1366
+ /** Revoke the current token and clear local state. */
1367
+ async revokeToken() {
1368
+ this.ensureNotDisposed();
1369
+ if (!this.token) return;
1370
+ try {
1371
+ await this.request("/api/v1/auth/revoke", {
1372
+ method: "POST",
1373
+ body: JSON.stringify({
1374
+ token: this.token.accessToken,
1375
+ device_id: this.deviceId
1376
+ })
1377
+ });
1378
+ } finally {
1379
+ this.clearState();
1380
+ }
1381
+ }
1382
+ /** Whether we currently hold a valid token. */
1383
+ get isAuthenticated() {
1384
+ return this.token !== null && !this.isTokenExpired();
1385
+ }
1386
+ /** Current device identifier. */
1387
+ get currentDeviceId() {
1388
+ return this.deviceId;
1389
+ }
1390
+ /** Release timers. */
1391
+ dispose() {
1392
+ if (this.disposed) return;
1393
+ this.disposed = true;
1394
+ this.clearRefreshTimer();
1395
+ }
1396
+ // -----------------------------------------------------------------------
1397
+ // Internal
1398
+ // -----------------------------------------------------------------------
1399
+ async request(path, init) {
1400
+ const headers = {
1401
+ "Content-Type": "application/json",
1402
+ Authorization: `Bearer ${this.apiKey}`
1403
+ };
1404
+ return fetch(`${this.serverUrl}${path}`, {
1405
+ ...init,
1406
+ headers: { ...headers, ...init.headers }
1407
+ });
1408
+ }
1409
+ isTokenExpired() {
1410
+ if (!this.token) return true;
1411
+ return Date.now() >= this.token.expiresAt;
1412
+ }
1413
+ isTokenExpiringSoon() {
1414
+ if (!this.token) return true;
1415
+ return Date.now() >= this.token.expiresAt - REFRESH_BUFFER_MS;
1416
+ }
1417
+ scheduleRefresh() {
1418
+ this.clearRefreshTimer();
1419
+ if (!this.token) return;
1420
+ const delay = Math.max(0, this.token.expiresAt - Date.now() - REFRESH_BUFFER_MS);
1421
+ this.refreshTimer = setTimeout(() => {
1422
+ void this.refreshToken().catch(() => {
1423
+ });
1424
+ }, delay);
1425
+ }
1426
+ clearRefreshTimer() {
1427
+ if (this.refreshTimer !== null) {
1428
+ clearTimeout(this.refreshTimer);
1429
+ this.refreshTimer = null;
1430
+ }
1431
+ }
1432
+ clearState() {
1433
+ this.token = null;
1434
+ this.clearRefreshTimer();
1435
+ }
1436
+ /** Generate a stable device ID by hashing browser fingerprint data. */
1437
+ async generateDeviceId() {
1438
+ const raw = [
1439
+ typeof navigator !== "undefined" ? navigator.userAgent : "unknown",
1440
+ typeof screen !== "undefined" ? `${screen.width}x${screen.height}` : "0x0",
1441
+ typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC",
1442
+ typeof navigator !== "undefined" ? navigator.language : "en"
1443
+ ].join("|");
1444
+ const data = new TextEncoder().encode(raw);
1445
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1446
+ const hashArray = new Uint8Array(hashBuffer);
1447
+ return Array.from(hashArray).map((b) => b.toString(16).padStart(2, "0")).join("");
1448
+ }
1449
+ collectDeviceInfo() {
1450
+ return {
1451
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "unknown",
1452
+ language: typeof navigator !== "undefined" ? navigator.language : "en",
1453
+ screenWidth: typeof screen !== "undefined" ? screen.width : 0,
1454
+ screenHeight: typeof screen !== "undefined" ? screen.height : 0,
1455
+ timezone: typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC",
1456
+ webgpu: typeof navigator !== "undefined" && "gpu" in navigator
1457
+ };
1458
+ }
1459
+ ensureNotDisposed() {
1460
+ if (this.disposed) {
1461
+ throw new OctomilError(
1462
+ "SESSION_DISPOSED",
1463
+ "DeviceAuthManager has been disposed."
1464
+ );
1465
+ }
1466
+ }
1467
+ };
1468
+
1469
+ // src/integrity.ts
1470
+ async function computeHash(data) {
1471
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1472
+ const hashArray = new Uint8Array(hashBuffer);
1473
+ return Array.from(hashArray).map((b) => b.toString(16).padStart(2, "0")).join("");
1474
+ }
1475
+ async function verifyModelIntegrity(data, expectedHash) {
1476
+ const actual = await computeHash(data);
1477
+ return actual === expectedHash.toLowerCase();
1478
+ }
1479
+ async function assertModelIntegrity(data, expectedHash) {
1480
+ const match = await verifyModelIntegrity(data, expectedHash);
1481
+ if (!match) {
1482
+ throw new OctomilError(
1483
+ "MODEL_LOAD_FAILED",
1484
+ "Model integrity check failed: SHA-256 hash mismatch. The downloaded model may be corrupted or tampered with."
1485
+ );
1486
+ }
1487
+ }
1488
+
1489
+ // src/federated.ts
1490
+ var WeightExtractor = class {
1491
+ /**
1492
+ * Compute element-wise delta between two weight maps.
1493
+ * `delta = after - before`
1494
+ */
1495
+ static computeDelta(before, after) {
1496
+ const delta = {};
1497
+ for (const key of Object.keys(before)) {
1498
+ const b = before[key];
1499
+ const a = after[key];
1500
+ if (!b || !a || b.length !== a.length) {
1501
+ throw new OctomilError(
1502
+ "INVALID_INPUT",
1503
+ `Weight dimension mismatch for "${key}".`
1504
+ );
1505
+ }
1506
+ const d = new Float32Array(b.length);
1507
+ for (let i = 0; i < b.length; i++) {
1508
+ d[i] = a[i] - b[i];
1509
+ }
1510
+ delta[key] = d;
1511
+ }
1512
+ return delta;
1513
+ }
1514
+ /** Apply a delta to weights: `result = weights + delta`. */
1515
+ static applyDelta(weights, delta) {
1516
+ const result = {};
1517
+ for (const key of Object.keys(weights)) {
1518
+ const w = weights[key];
1519
+ const d = delta[key];
1520
+ if (!w) continue;
1521
+ if (!d || w.length !== d.length) {
1522
+ result[key] = new Float32Array(w);
1523
+ continue;
1524
+ }
1525
+ const r = new Float32Array(w.length);
1526
+ for (let i = 0; i < w.length; i++) {
1527
+ r[i] = w[i] + d[i];
1528
+ }
1529
+ result[key] = r;
1530
+ }
1531
+ return result;
1532
+ }
1533
+ /** Compute L2 norm of a weight map (flattened). */
1534
+ static l2Norm(weights) {
1535
+ let sumSq = 0;
1536
+ for (const arr of Object.values(weights)) {
1537
+ if (!arr) continue;
1538
+ for (let i = 0; i < arr.length; i++) {
1539
+ sumSq += arr[i] * arr[i];
1540
+ }
1541
+ }
1542
+ return Math.sqrt(sumSq);
1543
+ }
1544
+ };
1545
+ var FederatedClient = class {
1546
+ serverUrl;
1547
+ apiKey;
1548
+ deviceId;
1549
+ onTelemetry;
1550
+ constructor(options) {
1551
+ this.serverUrl = options.serverUrl;
1552
+ this.apiKey = options.apiKey;
1553
+ this.deviceId = options.deviceId;
1554
+ this.onTelemetry = options.onTelemetry;
1555
+ }
1556
+ /** Fetch the current training round from the server. */
1557
+ async getTrainingRound(federationId) {
1558
+ const response = await this.request(
1559
+ `/api/v1/federations/${encodeURIComponent(federationId)}/rounds/current`
1560
+ );
1561
+ if (!response.ok) {
1562
+ throw new OctomilError(
1563
+ "NETWORK_ERROR",
1564
+ `Failed to fetch training round: HTTP ${response.status}`
1565
+ );
1566
+ }
1567
+ return await response.json();
1568
+ }
1569
+ /** Join a training round. */
1570
+ async joinRound(federationId, roundId) {
1571
+ const response = await this.request(
1572
+ `/api/v1/federations/${encodeURIComponent(federationId)}/rounds/${encodeURIComponent(roundId)}/join`,
1573
+ {
1574
+ method: "POST",
1575
+ body: JSON.stringify({ device_id: this.deviceId })
1576
+ }
1577
+ );
1578
+ if (!response.ok) {
1579
+ throw new OctomilError(
1580
+ "NETWORK_ERROR",
1581
+ `Failed to join round: HTTP ${response.status}`
1582
+ );
1583
+ }
1584
+ }
1585
+ /**
1586
+ * Run local training using a user-provided step function.
1587
+ *
1588
+ * Browser ONNX Runtime Web does not support training natively, so the
1589
+ * caller provides `onTrainStep` which receives the current weights and
1590
+ * a batch of data, and returns updated weights.
1591
+ */
1592
+ async train(initialWeights, config) {
1593
+ const start = performance.now();
1594
+ let weights = this.cloneWeights(initialWeights);
1595
+ for (let epoch = 0; epoch < config.epochs; epoch++) {
1596
+ const stepResult = await config.onTrainStep(weights, {
1597
+ epoch,
1598
+ batchSize: config.batchSize,
1599
+ learningRate: config.learningRate
1600
+ });
1601
+ weights = stepResult.weights;
1602
+ }
1603
+ const delta = WeightExtractor.computeDelta(initialWeights, weights);
1604
+ const durationMs = performance.now() - start;
1605
+ this.onTelemetry?.({
1606
+ type: "training_complete",
1607
+ model: config.modelId ?? "unknown",
1608
+ durationMs,
1609
+ metadata: {
1610
+ epochs: config.epochs,
1611
+ deltaNorm: WeightExtractor.l2Norm(delta)
1612
+ },
1613
+ timestamp: Date.now()
1614
+ });
1615
+ return { finalWeights: weights, delta };
1616
+ }
1617
+ /** Submit a weight update to the aggregation server. */
1618
+ async submitUpdate(federationId, roundId, delta, metrics) {
1619
+ const serialized = {};
1620
+ for (const [key, arr] of Object.entries(delta)) {
1621
+ if (!arr) continue;
1622
+ serialized[key] = {
1623
+ data: Array.from(arr),
1624
+ shape: [arr.length]
1625
+ };
1626
+ }
1627
+ const response = await this.request(
1628
+ `/api/v1/federations/${encodeURIComponent(federationId)}/rounds/${encodeURIComponent(roundId)}/submit`,
1629
+ {
1630
+ method: "POST",
1631
+ body: JSON.stringify({
1632
+ device_id: this.deviceId,
1633
+ delta: serialized,
1634
+ metrics
1635
+ })
1636
+ }
1637
+ );
1638
+ if (!response.ok) {
1639
+ throw new OctomilError(
1640
+ "NETWORK_ERROR",
1641
+ `Failed to submit update: HTTP ${response.status}`
1642
+ );
1643
+ }
1644
+ }
1645
+ // -----------------------------------------------------------------------
1646
+ // Internal
1647
+ // -----------------------------------------------------------------------
1648
+ async request(path, init) {
1649
+ const headers = {
1650
+ "Content-Type": "application/json"
1651
+ };
1652
+ if (this.apiKey) {
1653
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
1654
+ }
1655
+ return fetch(`${this.serverUrl}${path}`, {
1656
+ ...init,
1657
+ headers: { ...headers, ...init?.headers }
1658
+ });
1659
+ }
1660
+ cloneWeights(weights) {
1661
+ const cloned = {};
1662
+ for (const [key, arr] of Object.entries(weights)) {
1663
+ if (arr) cloned[key] = new Float32Array(arr);
1664
+ }
1665
+ return cloned;
1666
+ }
1667
+ };
1668
+
1669
+ // src/secure-aggregation.ts
1670
+ var SecureAggregation = class {
1671
+ keyPair = null;
1672
+ /** Generate an ECDH key pair for this round. */
1673
+ async generateKeyPair() {
1674
+ this.keyPair = await crypto.subtle.generateKey(
1675
+ { name: "ECDH", namedCurve: "P-256" },
1676
+ true,
1677
+ ["deriveBits"]
1678
+ );
1679
+ const publicKey = await crypto.subtle.exportKey(
1680
+ "jwk",
1681
+ this.keyPair.publicKey
1682
+ );
1683
+ return { publicKey };
1684
+ }
1685
+ /** Derive a shared secret with a peer using ECDH. */
1686
+ async deriveSharedSecret(peerPublicKeyJwk) {
1687
+ if (!this.keyPair) {
1688
+ throw new Error("Call generateKeyPair() first.");
1689
+ }
1690
+ const peerKey = await crypto.subtle.importKey(
1691
+ "jwk",
1692
+ peerPublicKeyJwk,
1693
+ { name: "ECDH", namedCurve: "P-256" },
1694
+ false,
1695
+ []
1696
+ );
1697
+ return crypto.subtle.deriveBits(
1698
+ { name: "ECDH", public: peerKey },
1699
+ this.keyPair.privateKey,
1700
+ 256
1701
+ );
1702
+ }
1703
+ /**
1704
+ * Generate a deterministic PRG mask from a shared secret.
1705
+ * Uses the secret as a seed to produce `length` float values.
1706
+ */
1707
+ async createMask(secret, length) {
1708
+ const keyMaterial = await crypto.subtle.importKey(
1709
+ "raw",
1710
+ secret,
1711
+ "HKDF",
1712
+ false,
1713
+ ["deriveBits"]
1714
+ );
1715
+ const bitsNeeded = length * 4 * 8;
1716
+ const bits = await crypto.subtle.deriveBits(
1717
+ {
1718
+ name: "HKDF",
1719
+ hash: "SHA-256",
1720
+ salt: new Uint8Array(32),
1721
+ info: new TextEncoder().encode("octomil-secagg-mask")
1722
+ },
1723
+ keyMaterial,
1724
+ Math.min(bitsNeeded, 8160)
1725
+ // deriveBits max
1726
+ );
1727
+ const mask = new Float32Array(length);
1728
+ const source = new Float32Array(bits);
1729
+ for (let i = 0; i < length; i++) {
1730
+ mask[i] = source[i % source.length];
1731
+ }
1732
+ return mask;
1733
+ }
1734
+ /** Add masks to a weight update: masked = delta + sum(masks). */
1735
+ maskUpdate(delta, masks) {
1736
+ const masked = {};
1737
+ for (const [key, arr] of Object.entries(delta)) {
1738
+ if (!arr) continue;
1739
+ const result = new Float32Array(arr);
1740
+ const mask = masks.get(key);
1741
+ if (mask) {
1742
+ for (let i = 0; i < result.length; i++) {
1743
+ result[i] = result[i] + (mask[i] ?? 0);
1744
+ }
1745
+ }
1746
+ masked[key] = result;
1747
+ }
1748
+ return masked;
1749
+ }
1750
+ /** Remove masks of dropped peers from the aggregated sum. */
1751
+ unmask(maskedSum, droppedMasks) {
1752
+ const unmasked = {};
1753
+ for (const [key, arr] of Object.entries(maskedSum)) {
1754
+ if (!arr) continue;
1755
+ const result = new Float32Array(arr);
1756
+ const mask = droppedMasks.get(key);
1757
+ if (mask) {
1758
+ for (let i = 0; i < result.length; i++) {
1759
+ result[i] = result[i] - (mask[i] ?? 0);
1760
+ }
1761
+ }
1762
+ unmasked[key] = result;
1763
+ }
1764
+ return unmasked;
1765
+ }
1766
+ };
1767
+ var PRIME = 2147483647;
1768
+ function modPow(base, exp, mod) {
1769
+ let result = 1;
1770
+ base = base % mod;
1771
+ while (exp > 0) {
1772
+ if (exp % 2 === 1) {
1773
+ result = Number(BigInt(result) * BigInt(base) % BigInt(mod));
1774
+ }
1775
+ exp = Math.floor(exp / 2);
1776
+ base = Number(BigInt(base) * BigInt(base) % BigInt(mod));
1777
+ }
1778
+ return result;
1779
+ }
1780
+ function modInverse(a, mod) {
1781
+ return modPow(a, mod - 2, mod);
1782
+ }
1783
+ function shamirSplit(secret, threshold, numShares) {
1784
+ const coeffs = [secret % PRIME];
1785
+ for (let i = 1; i < threshold; i++) {
1786
+ const randomBytes = new Uint32Array(1);
1787
+ crypto.getRandomValues(randomBytes);
1788
+ coeffs.push(randomBytes[0] % PRIME);
1789
+ }
1790
+ const shares = [];
1791
+ for (let x = 1; x <= numShares; x++) {
1792
+ let y = 0;
1793
+ for (let i = 0; i < coeffs.length; i++) {
1794
+ y = Number(
1795
+ (BigInt(y) + BigInt(coeffs[i]) * BigInt(modPow(x, i, PRIME))) % BigInt(PRIME)
1796
+ );
1797
+ }
1798
+ shares.push({ x, y });
1799
+ }
1800
+ return shares;
1801
+ }
1802
+ function shamirReconstruct(shares) {
1803
+ let secret = 0;
1804
+ const n = shares.length;
1805
+ for (let i = 0; i < n; i++) {
1806
+ let num = 1;
1807
+ let den = 1;
1808
+ for (let j = 0; j < n; j++) {
1809
+ if (i === j) continue;
1810
+ num = Number(
1811
+ BigInt(num) * BigInt(PRIME - shares[j].x) % BigInt(PRIME)
1812
+ );
1813
+ den = Number(
1814
+ BigInt(den) * BigInt((shares[i].x - shares[j].x + PRIME) % PRIME) % BigInt(PRIME)
1815
+ );
1816
+ }
1817
+ const lagrange = Number(
1818
+ BigInt(num) * BigInt(modInverse(den, PRIME)) % BigInt(PRIME)
1819
+ );
1820
+ secret = Number(
1821
+ (BigInt(secret) + BigInt(shares[i].y) * BigInt(lagrange)) % BigInt(PRIME)
1822
+ );
1823
+ }
1824
+ return secret;
1825
+ }
1826
+ var SecAggPlus = class extends SecureAggregation {
1827
+ threshold;
1828
+ constructor(threshold) {
1829
+ super();
1830
+ this.threshold = threshold;
1831
+ }
1832
+ /**
1833
+ * Split a shared secret into Shamir shares so that any `threshold`
1834
+ * surviving peers can reconstruct the mask of a dropped peer.
1835
+ */
1836
+ splitSecret(secret, numPeers) {
1837
+ return shamirSplit(secret, this.threshold, numPeers);
1838
+ }
1839
+ /** Reconstruct a dropped peer's secret from collected shares. */
1840
+ reconstructSecret(shares) {
1841
+ if (shares.length < this.threshold) {
1842
+ throw new Error(
1843
+ `Need at least ${this.threshold} shares, got ${shares.length}.`
1844
+ );
1845
+ }
1846
+ return shamirReconstruct(shares.slice(0, this.threshold));
1847
+ }
1848
+ };
1849
+
1850
+ // src/privacy.ts
1851
+ function clipGradients(delta, maxNorm) {
1852
+ let sumSq = 0;
1853
+ for (const arr of Object.values(delta)) {
1854
+ if (!arr) continue;
1855
+ for (let i = 0; i < arr.length; i++) {
1856
+ sumSq += arr[i] * arr[i];
1857
+ }
1858
+ }
1859
+ const norm = Math.sqrt(sumSq);
1860
+ if (norm <= maxNorm) {
1861
+ return delta;
1862
+ }
1863
+ const scale = maxNorm / norm;
1864
+ const clipped = {};
1865
+ for (const [key, arr] of Object.entries(delta)) {
1866
+ if (!arr) continue;
1867
+ const c = new Float32Array(arr.length);
1868
+ for (let i = 0; i < arr.length; i++) {
1869
+ c[i] = arr[i] * scale;
1870
+ }
1871
+ clipped[key] = c;
1872
+ }
1873
+ return clipped;
1874
+ }
1875
+ function addGaussianNoise(delta, epsilon, sensitivity, deltaDP) {
1876
+ const sigma = sensitivity * Math.sqrt(2 * Math.log(1.25 / deltaDP)) / epsilon;
1877
+ const noisy = {};
1878
+ for (const [key, arr] of Object.entries(delta)) {
1879
+ if (!arr) continue;
1880
+ const n = new Float32Array(arr.length);
1881
+ for (let i = 0; i < arr.length; i++) {
1882
+ n[i] = arr[i] + gaussianRandom() * sigma;
1883
+ }
1884
+ noisy[key] = n;
1885
+ }
1886
+ return noisy;
1887
+ }
1888
+ function gaussianRandom() {
1889
+ let u1;
1890
+ let u2;
1891
+ do {
1892
+ u1 = Math.random();
1893
+ u2 = Math.random();
1894
+ } while (u1 === 0);
1895
+ return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
1896
+ }
1897
+ function quantize(delta, bits = 8) {
1898
+ const maxVal = bits === 8 ? 127 : 32767;
1899
+ const result = {};
1900
+ for (const [key, arr] of Object.entries(delta)) {
1901
+ if (!arr) continue;
1902
+ let absMax = 0;
1903
+ for (let i = 0; i < arr.length; i++) {
1904
+ const abs = Math.abs(arr[i]);
1905
+ if (abs > absMax) absMax = abs;
1906
+ }
1907
+ const scale = absMax > 0 ? absMax / maxVal : 1;
1908
+ const quantized = bits === 8 ? new Int8Array(arr.length) : new Int16Array(arr.length);
1909
+ for (let i = 0; i < arr.length; i++) {
1910
+ quantized[i] = Math.round(arr[i] / scale);
1911
+ }
1912
+ result[key] = { data: quantized, scale, zeroPoint: 0 };
1913
+ }
1914
+ return result;
1915
+ }
1916
+ function dequantize(quantized) {
1917
+ const result = {};
1918
+ for (const [key, entry] of Object.entries(quantized)) {
1919
+ if (!entry) continue;
1920
+ const arr = new Float32Array(entry.data.length);
1921
+ for (let i = 0; i < entry.data.length; i++) {
1922
+ arr[i] = (entry.data[i] - entry.zeroPoint) * entry.scale;
1923
+ }
1924
+ result[key] = arr;
1925
+ }
1926
+ return result;
1927
+ }
1928
+
1929
+ // src/rollouts.ts
1930
+ var RolloutsManager = class {
1931
+ serverUrl;
1932
+ apiKey;
1933
+ cacheTtlMs;
1934
+ onTelemetry;
1935
+ configCache = /* @__PURE__ */ new Map();
1936
+ constructor(options) {
1937
+ this.serverUrl = options.serverUrl;
1938
+ this.apiKey = options.apiKey;
1939
+ this.cacheTtlMs = options.cacheTtlMs ?? 5 * 60 * 1e3;
1940
+ this.onTelemetry = options.onTelemetry;
1941
+ }
1942
+ /**
1943
+ * Resolve which version a device should use.
1944
+ *
1945
+ * Logic:
1946
+ * 1. If a canary version exists, check if device is in canary group.
1947
+ * 2. Otherwise return the active version.
1948
+ */
1949
+ async resolveVersion(modelId, deviceId) {
1950
+ const config = await this.getRolloutConfig(modelId);
1951
+ const canary = config.versions.find((v) => v.status === "canary");
1952
+ if (canary && this.isInCanaryGroup(modelId, deviceId, canary.percentage)) {
1953
+ return canary.version;
1954
+ }
1955
+ const active = config.versions.find((v) => v.status === "active");
1956
+ if (active) return active.version;
1957
+ throw new OctomilError(
1958
+ "MODEL_NOT_FOUND",
1959
+ `No active version found for model "${modelId}".`
1960
+ );
1961
+ }
1962
+ /** Fetch rollout configuration, with caching. */
1963
+ async getRolloutConfig(modelId) {
1964
+ const cached = this.configCache.get(modelId);
1965
+ if (cached && Date.now() - cached.fetchedAt < this.cacheTtlMs) {
1966
+ return cached.config;
1967
+ }
1968
+ const url = `${this.serverUrl}/api/v1/models/${encodeURIComponent(modelId)}/rollout`;
1969
+ const headers = { Accept: "application/json" };
1970
+ if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}`;
1971
+ const response = await fetch(url, { headers });
1972
+ if (!response.ok) {
1973
+ throw new OctomilError(
1974
+ "NETWORK_ERROR",
1975
+ `Failed to fetch rollout config: HTTP ${response.status}`
1976
+ );
1977
+ }
1978
+ const config = await response.json();
1979
+ this.configCache.set(modelId, { config, fetchedAt: Date.now() });
1980
+ return config;
1981
+ }
1982
+ /**
1983
+ * Deterministic check: is this device in the canary group?
1984
+ * Uses a simple hash of (deviceId + modelId) to assign a 0-99 bucket.
1985
+ */
1986
+ isInCanaryGroup(modelId, deviceId, canaryPercentage) {
1987
+ const bucket = deterministicBucket(deviceId + modelId);
1988
+ return bucket < canaryPercentage;
1989
+ }
1990
+ /** Get all available versions for a model. */
1991
+ async getAvailableVersions(modelId) {
1992
+ const config = await this.getRolloutConfig(modelId);
1993
+ return config.versions;
1994
+ }
1995
+ /** Report rollout success/failure to the server. */
1996
+ async reportRolloutStatus(modelId, version, status) {
1997
+ const url = `${this.serverUrl}/api/v1/models/${encodeURIComponent(modelId)}/rollout/status`;
1998
+ const headers = {
1999
+ "Content-Type": "application/json"
2000
+ };
2001
+ if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}`;
2002
+ await fetch(url, {
2003
+ method: "POST",
2004
+ headers,
2005
+ body: JSON.stringify({ version, status })
2006
+ });
2007
+ this.onTelemetry?.({
2008
+ type: "rollout_status",
2009
+ model: modelId,
2010
+ metadata: { version, status },
2011
+ timestamp: Date.now()
2012
+ });
2013
+ }
2014
+ /** Clear the rollout config cache. */
2015
+ clearCache() {
2016
+ this.configCache.clear();
2017
+ }
2018
+ };
2019
+ function deterministicBucket(input) {
2020
+ let hash = 5381;
2021
+ for (let i = 0; i < input.length; i++) {
2022
+ hash = (hash << 5) + hash + input.charCodeAt(i) | 0;
2023
+ }
2024
+ return Math.abs(hash) % 100;
2025
+ }
2026
+
2027
+ // src/experiments.ts
2028
+ var ExperimentsClient = class {
2029
+ serverUrl;
2030
+ apiKey;
2031
+ cacheTtlMs;
2032
+ onTelemetry;
2033
+ experimentsCache = null;
2034
+ constructor(options) {
2035
+ this.serverUrl = options.serverUrl;
2036
+ this.apiKey = options.apiKey;
2037
+ this.cacheTtlMs = options.cacheTtlMs ?? 5 * 60 * 1e3;
2038
+ this.onTelemetry = options.onTelemetry;
2039
+ }
2040
+ /** Fetch all active experiments (cached). */
2041
+ async getActiveExperiments() {
2042
+ if (this.experimentsCache && Date.now() - this.experimentsCache.fetchedAt < this.cacheTtlMs) {
2043
+ return this.experimentsCache.experiments;
2044
+ }
2045
+ const url = `${this.serverUrl}/api/v1/experiments?status=active`;
2046
+ const headers = { Accept: "application/json" };
2047
+ if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}`;
2048
+ const response = await fetch(url, { headers });
2049
+ if (!response.ok) {
2050
+ throw new OctomilError(
2051
+ "NETWORK_ERROR",
2052
+ `Failed to fetch experiments: HTTP ${response.status}`
2053
+ );
2054
+ }
2055
+ const data = await response.json();
2056
+ this.experimentsCache = {
2057
+ experiments: data.experiments,
2058
+ fetchedAt: Date.now()
2059
+ };
2060
+ return data.experiments;
2061
+ }
2062
+ /** Get full experiment config by ID. */
2063
+ async getExperimentConfig(experimentId) {
2064
+ const url = `${this.serverUrl}/api/v1/experiments/${encodeURIComponent(experimentId)}`;
2065
+ const headers = { Accept: "application/json" };
2066
+ if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}`;
2067
+ const response = await fetch(url, { headers });
2068
+ if (!response.ok) {
2069
+ throw new OctomilError(
2070
+ "NETWORK_ERROR",
2071
+ `Failed to fetch experiment: HTTP ${response.status}`
2072
+ );
2073
+ }
2074
+ return await response.json();
2075
+ }
2076
+ /**
2077
+ * Deterministic variant assignment.
2078
+ * Hash(deviceId + experimentId) → bucket → variant by cumulative traffic %.
2079
+ */
2080
+ getVariant(experiment, deviceId) {
2081
+ if (experiment.variants.length === 0) return null;
2082
+ const bucket = deterministicBucket2(deviceId + experiment.id);
2083
+ let cumulative = 0;
2084
+ for (const variant of experiment.variants) {
2085
+ cumulative += variant.trafficPercentage;
2086
+ if (bucket < cumulative) {
2087
+ return variant;
2088
+ }
2089
+ }
2090
+ return experiment.variants[experiment.variants.length - 1] ?? null;
2091
+ }
2092
+ /** Check if a device is enrolled in a specific experiment. */
2093
+ isEnrolled(experiment, deviceId) {
2094
+ return this.getVariant(experiment, deviceId) !== null;
2095
+ }
2096
+ /**
2097
+ * Find which experiment (if any) affects a given model, and return
2098
+ * the variant this device should use.
2099
+ */
2100
+ async resolveModelExperiment(modelId, deviceId) {
2101
+ const experiments = await this.getActiveExperiments();
2102
+ for (const exp of experiments) {
2103
+ const affectsModel = exp.variants.some((v) => v.modelId === modelId);
2104
+ if (!affectsModel) continue;
2105
+ const variant = this.getVariant(exp, deviceId);
2106
+ if (variant) {
2107
+ return { experiment: exp, variant };
2108
+ }
2109
+ }
2110
+ return null;
2111
+ }
2112
+ /** Report a metric for an experiment. */
2113
+ async trackMetric(experimentId, metricName, value, deviceId) {
2114
+ const url = `${this.serverUrl}/api/v1/experiments/${encodeURIComponent(experimentId)}/metrics`;
2115
+ const headers = {
2116
+ "Content-Type": "application/json"
2117
+ };
2118
+ if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}`;
2119
+ await fetch(url, {
2120
+ method: "POST",
2121
+ headers,
2122
+ body: JSON.stringify({
2123
+ metric_name: metricName,
2124
+ value,
2125
+ device_id: deviceId,
2126
+ timestamp: Date.now()
2127
+ })
2128
+ });
2129
+ this.onTelemetry?.({
2130
+ type: "experiment_metric",
2131
+ model: experimentId,
2132
+ metadata: { metricName, value },
2133
+ timestamp: Date.now()
2134
+ });
2135
+ }
2136
+ /** Clear the experiment cache. */
2137
+ clearCache() {
2138
+ this.experimentsCache = null;
2139
+ }
2140
+ };
2141
+ function deterministicBucket2(input) {
2142
+ let hash = 5381;
2143
+ for (let i = 0; i < input.length; i++) {
2144
+ hash = (hash << 5) + hash + input.charCodeAt(i) | 0;
2145
+ }
2146
+ return Math.abs(hash) % 100;
2147
+ }
2148
+ //# sourceMappingURL=index.cjs.map