@chatpanel/gateway 0.6.18 → 0.6.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.18",
3
+ "version": "0.6.20",
4
4
  "description": "Local privacy gateway — redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/models.js CHANGED
@@ -36,3 +36,12 @@ export const MODEL_CATALOG = [
36
36
  export function isKnownModel(id) {
37
37
  return MODEL_CATALOG.some((m) => m.id === id);
38
38
  }
39
+
40
+ // Accept a user-supplied ("bring your own") NER model id. STRICT `org/name` shape,
41
+ // no path traversal. We can't verify it's token-classification from the id alone —
42
+ // the engine fails open if the download/labels don't fit. Custom ids fetch from
43
+ // Hugging Face directly (the engine points remoteHost there for the custom load).
44
+ export function isValidCustomModelId(id) {
45
+ const s = String(id || '');
46
+ return /^[A-Za-z0-9][\w.-]*\/[\w.-]+$/.test(s) && !s.includes('..');
47
+ }
package/src/ner-engine.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import os from 'node:os';
18
18
  import { join } from 'node:path';
19
19
  import { existsSync, mkdirSync } from 'node:fs';
20
+ import { isKnownModel } from './models.js';
20
21
 
21
22
  const DEFAULT_MODEL = 'Xenova/bert-base-NER';
22
23
 
@@ -199,8 +200,15 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
199
200
  return false;
200
201
  }
201
202
 
203
+ // Curated catalog models come from the private dl.chatpanel.net mirror
204
+ // (ensureLib set that as remoteHost). A user's BYO id isn't mirrored, so fetch
205
+ // it from Hugging Face directly — only for this load, then restore.
206
+ const prevHost = lib.env.remoteHost;
207
+ const isCustom = !isKnownModel(modelId);
208
+ if (!haveLocal && isCustom) { try { lib.env.remoteHost = 'https://huggingface.co/'; } catch { /* optional */ } }
209
+
202
210
  _state = haveLocal ? 'loading' : 'downloading';
203
- if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[ner] downloading model ${modelId} (one-time)…`); }
211
+ if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[ner] downloading model ${modelId} (one-time${isCustom ? ', from Hugging Face' : ''})…`); }
204
212
 
205
213
  try {
206
214
  const pipe = await lib.pipeline('token-classification', modelId, {
@@ -227,6 +235,8 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
227
235
  else { _state = 'error'; }
228
236
  log(`[ner] model load failed (${e.message})${prevPipe ? ' — keeping previous model' : ' — deterministic-only'}`);
229
237
  return false;
238
+ } finally {
239
+ try { lib.env.remoteHost = prevHost; } catch { /* optional */ } // restore the mirror
230
240
  }
231
241
  }
232
242
 
package/src/server.js CHANGED
@@ -33,8 +33,8 @@ import { createHistoryStore } from './sqlite-store.js';
33
33
  import { ingestBackup } from './backup-ingest.js';
34
34
  import * as nerEngine from './ner-engine.js';
35
35
  import * as sttEngine from './stt-engine.js';
36
- import { MODEL_CATALOG, isKnownModel } from './models.js';
37
- import { STT_MODEL_CATALOG, isKnownSttModel, DEFAULT_STT_MODEL } from './stt-models.js';
36
+ import { MODEL_CATALOG, isKnownModel, isValidCustomModelId } from './models.js';
37
+ import { STT_MODEL_CATALOG, isKnownSttModel, isValidCustomSttId, DEFAULT_STT_MODEL } from './stt-models.js';
38
38
  import { resolvePro, checkQuota, consume, usage } from './freegate.js';
39
39
  import { publicConfig, applyConfigPatch, persistConfig, configPath } from './configstore.js';
40
40
  import { resolveDestination, aggregateModelsAsync } from './router.js';
@@ -42,7 +42,7 @@ import * as openai from './openai.js';
42
42
  import * as responses from './responses.js';
43
43
  import * as anthropic from './anthropic.js';
44
44
 
45
- export const VERSION = '0.6.18';
45
+ export const VERSION = '0.6.20';
46
46
 
47
47
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
48
48
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -632,9 +632,14 @@ export function createGateway(cfg = loadConfig()) {
632
632
  // model (downloading it first if needed) and persists the choice.
633
633
  if (pathname === '/ner/models') {
634
634
  if (req.method === 'GET') {
635
- const available = MODEL_CATALOG.map((m) => ({ ...m, installed: nerEngine.modelOnDisk(m.id) }));
635
+ const active = nerEngine.health().model || cfg.ner?.model || null;
636
+ const available = /** @type {any[]} */ (MODEL_CATALOG.map((m) => ({ ...m, installed: nerEngine.modelOnDisk(m.id) })));
637
+ // Surface an active BYO (non-catalog) model so the UI shows it too.
638
+ if (active && !available.some((m) => m.id === active)) {
639
+ available.push({ id: active, label: active, lang: '—', custom: true, installed: nerEngine.modelOnDisk(active), note: 'Custom model (from Hugging Face).' });
640
+ }
636
641
  return sendJson(res, 200, {
637
- active: nerEngine.health().model || cfg.ner?.model || null,
642
+ active,
638
643
  state: nerEngine.state(),
639
644
  progress: nerEngine.progress(),
640
645
  available,
@@ -643,8 +648,9 @@ export function createGateway(cfg = loadConfig()) {
643
648
  if (req.method === 'POST') {
644
649
  let body = null;
645
650
  try { body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')); } catch { body = null; }
646
- const id = body && typeof body.id === 'string' ? body.id : null;
647
- if (!id || !isKnownModel(id)) return sendJson(res, 400, { error: { message: 'unknown model id', type: 'bad_model' } });
651
+ const id = body && typeof body.id === 'string' ? body.id.trim() : null;
652
+ // Curated catalog OR a strictly-validated BYO id (org/name, from HF).
653
+ if (!id || !(isKnownModel(id) || isValidCustomModelId(id))) return sendJson(res, 400, { error: { message: 'unknown or invalid model id', type: 'bad_model' } });
648
654
  // Persist first so a restart keeps the choice, then (re)load. Don't block the
649
655
  // response on a possibly-long download — the client polls GET for progress.
650
656
  if (cfg.ner) cfg.ner.model = id; else cfg.ner = { autostart: true, model: id, allowDownload: true, enableFullTier: true };
@@ -666,9 +672,14 @@ export function createGateway(cfg = loadConfig()) {
666
672
  // GET/POST /stt/models catalog + progress / switch (mirrors /ner/models)
667
673
  if (pathname === '/stt/models') {
668
674
  if (req.method === 'GET') {
669
- const available = STT_MODEL_CATALOG.map((m) => ({ ...m, installed: sttEngine.modelOnDisk(m.id) }));
675
+ const active = sttEngine.health().model || cfg.stt?.model || DEFAULT_STT_MODEL;
676
+ const available = /** @type {any[]} */ (STT_MODEL_CATALOG.map((m) => ({ ...m, installed: sttEngine.modelOnDisk(m.id) })));
677
+ // Surface an active CUSTOM (non-catalog) model so the UI can show it too.
678
+ if (active && !available.some((m) => m.id === active)) {
679
+ available.push({ id: active, label: active, lang: '—', tier: 'custom', custom: true, installed: sttEngine.modelOnDisk(active), note: 'Custom model (from Hugging Face).' });
680
+ }
670
681
  return sendJson(res, 200, {
671
- active: sttEngine.health().model || cfg.stt?.model || DEFAULT_STT_MODEL,
682
+ active,
672
683
  state: sttEngine.state(),
673
684
  progress: sttEngine.progress(),
674
685
  available,
@@ -677,8 +688,9 @@ export function createGateway(cfg = loadConfig()) {
677
688
  if (req.method === 'POST') {
678
689
  let body = null;
679
690
  try { body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')); } catch { body = null; }
680
- const id = body && typeof body.id === 'string' ? body.id : null;
681
- if (!id || !isKnownSttModel(id)) return sendJson(res, 400, { error: { message: 'unknown model id', type: 'bad_model' } });
691
+ const id = body && typeof body.id === 'string' ? body.id.trim() : null;
692
+ // Curated catalog OR a strictly-validated custom whisper id (Advanced).
693
+ if (!id || !(isKnownSttModel(id) || isValidCustomSttId(id))) return sendJson(res, 400, { error: { message: 'unknown or invalid model id', type: 'bad_model' } });
682
694
  if (cfg.stt) cfg.stt.model = id; else cfg.stt = { enabled: true, model: id, allowDownload: true };
683
695
  try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
684
696
  sttEngine.setModel(id, { onLog: (m) => console.log(m) });
package/src/stt-engine.js CHANGED
@@ -20,24 +20,50 @@ import { join } from 'node:path';
20
20
  import { existsSync, readdirSync } from 'node:fs';
21
21
  import { randomUUID } from 'node:crypto';
22
22
  import { ensureLib, modelRoot } from './ner-engine.js';
23
- import { DEFAULT_STT_MODEL, isEnglishOnly } from './stt-models.js';
23
+ import { DEFAULT_STT_MODEL, isEnglishOnly, sttModelDtype, isKnownSttModel } from './stt-models.js';
24
24
 
25
25
  export const SAMPLE_RATE = 16000; // fixed wire contract: 16 kHz mono Float32 PCM
26
26
 
27
+ // The right whisper quantization depends on the ONNX RUNTIME, not the OS:
28
+ // • native onnxruntime-node (the npm gateway) loads the small, fast `q8`
29
+ // (_quantized) exports — best size + speed.
30
+ // • onnxruntime-web WASM (the standalone binary — SAME wasm on macOS/Windows/
31
+ // Linux, so this is inherently cross-platform) CANNOT load the block-quantized
32
+ // exports (q8/int8/uint8 → MatMulNBits "missing scale"; fp16 → graph error);
33
+ // of the loadable ones only `fp32` is fast enough for real-time (q4/bnb4 are
34
+ // ~8× slower). Verified empirically against the bundled ORT-web build.
35
+ // The binary entry sets __CHATPANEL_WASM_PATHS__, so that global tells us which
36
+ // runtime we're on. A model may override via `dtype` in the STT catalog.
37
+ export function runtimeDtype() {
38
+ return globalThis.__CHATPANEL_WASM_PATHS__ ? 'fp32' : 'q8';
39
+ }
40
+
27
41
  let _state = 'off'; // 'off' | 'loading' | 'downloading' | 'ready' | 'error'
28
42
  let _model = null; // active model id
29
43
  let _pipe = null; // the loaded automatic-speech-recognition pipeline
30
44
  let _err = null; // last error message (for /health)
31
45
  let _initPromise = null; // single-flight init
32
46
  let _progress = null; // { model, file, pct } while downloading, else null
33
-
34
- // Whisper exports ship encoder+decoder ONNX files (not the single model.onnx the
35
- // NER check looks for), so presence = "any encoder_model*.onnx on disk".
36
- export function modelOnDisk(modelId = _model || DEFAULT_STT_MODEL) {
47
+ let _dtype = null; // the quantization actually loaded (fp32 on WASM, q8 native)
48
+
49
+ // transformers.js dtype the ONNX filename suffix it loads. Presence must check
50
+ // the EXACT file the current runtime will fetch — otherwise a q8 install (native)
51
+ // looks "present" to the WASM runtime, which actually needs the fp32 file, and the
52
+ // offline load fails. Checking the real target makes a runtime switch re-download.
53
+ const DTYPE_SUFFIX = {
54
+ fp32: '', q8: '_quantized', int8: '_int8', uint8: '_uint8',
55
+ fp16: '_fp16', q4: '_q4', bnb4: '_bnb4', q4f16: '_q4f16',
56
+ };
57
+
58
+ export function modelOnDisk(modelId = _model || DEFAULT_STT_MODEL, dtype = sttModelDtype(modelId) || runtimeDtype()) {
37
59
  const dir = join(modelRoot(), ...modelId.split('/'), 'onnx');
38
60
  if (!existsSync(dir)) return false;
39
- try { return readdirSync(dir).some((f) => /^encoder_model.*\.onnx$/.test(f)); }
40
- catch { return false; }
61
+ const suffix = DTYPE_SUFFIX[dtype] ?? '';
62
+ try {
63
+ const files = readdirSync(dir);
64
+ return files.includes(`encoder_model${suffix}.onnx`)
65
+ && files.some((f) => f === `decoder_model_merged${suffix}.onnx` || f === `decoder_model${suffix}.onnx`);
66
+ } catch { return false; }
41
67
  }
42
68
 
43
69
  export function state() { return _state; }
@@ -45,7 +71,7 @@ export function isReady() { return _state === 'ready' && !!_pipe; }
45
71
  export function progress() { return _progress; }
46
72
 
47
73
  export function health() {
48
- return { configured: _state !== 'off', ok: isReady(), state: _state, model: _model, error: _err };
74
+ return { configured: _state !== 'off', ok: isReady(), state: _state, model: _model, dtype: _dtype, error: _err };
49
75
  }
50
76
 
51
77
  // (Re)load a model into _pipe. Same contract as ner-engine.loadModel: fail-open,
@@ -71,12 +97,21 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
71
97
  return false;
72
98
  }
73
99
 
100
+ // Curated catalog models come from the private dl.chatpanel.net mirror (ensureLib
101
+ // already set that as remoteHost). A CUSTOM ("Advanced") id isn't mirrored, so
102
+ // fetch it from Hugging Face directly — only for this load, then restore.
103
+ const prevHost = lib.env.remoteHost;
104
+ const isCustom = !isKnownSttModel(modelId);
105
+ if (!haveLocal && isCustom) { try { lib.env.remoteHost = 'https://huggingface.co/'; } catch { /* optional */ } }
106
+
74
107
  _state = haveLocal ? 'loading' : 'downloading';
75
- if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[stt] downloading model ${modelId} (one-time)…`); }
108
+ if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[stt] downloading model ${modelId} (one-time${isCustom ? ', from Hugging Face' : ''})…`); }
76
109
 
77
110
  try {
111
+ // Per-model override wins (catalog `dtype`), else the runtime-appropriate default.
112
+ const dtype = sttModelDtype(modelId) || runtimeDtype();
78
113
  const pipe = await lib.pipeline('automatic-speech-recognition', modelId, {
79
- dtype: 'q8',
114
+ dtype,
80
115
  progress_callback: (p) => {
81
116
  if (!p) return;
82
117
  const pct = typeof p.progress === 'number' ? Math.round(p.progress) : (_progress?.pct ?? 0);
@@ -87,9 +122,9 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
87
122
  }
88
123
  },
89
124
  });
90
- _pipe = pipe; _model = modelId; _state = 'ready'; _err = null; _progress = null;
125
+ _pipe = pipe; _model = modelId; _state = 'ready'; _err = null; _progress = null; _dtype = dtype;
91
126
  if (prevPipe && prevPipe !== pipe) { try { await prevPipe.dispose?.(); } catch { /* ignore */ } }
92
- log(`[stt] ready — model ${modelId} (in-process, offline) — local dictation active`);
127
+ log(`[stt] ready — model ${modelId} @ ${dtype} (in-process, offline) — local dictation active`);
93
128
  return true;
94
129
  } catch (e) {
95
130
  _err = e.message; _progress = null;
@@ -97,6 +132,8 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
97
132
  else { _state = 'error'; }
98
133
  log(`[stt] model load failed (${e.message})${prevPipe ? ' — keeping previous model' : ''}`);
99
134
  return false;
135
+ } finally {
136
+ try { lib.env.remoteHost = prevHost; } catch { /* optional */ } // restore the mirror for NER + catalog loads
100
137
  }
101
138
  }
102
139
 
package/src/stt-models.js CHANGED
@@ -1,38 +1,57 @@
1
1
  // Catalog of speech-to-text (whisper) models the gateway can run, surfaced in the
2
- // extension's Gateway settings and dictation UI. All are ONNX (transformers.js)
3
- // automatic-speech-recognition models — same engine, download plumbing, and model
4
- // root as the NER catalog (models.js). Sizes are the on-disk q8 footprint, approx.
2
+ // extension's Gateway settings and dictation UI so users pick by their machine's
3
+ // resources. All are ONNX (transformers.js) automatic-speech-recognition models —
4
+ // same engine, download plumbing, and model root as the NER catalog (models.js).
5
+ //
6
+ // `tier` groups them for the picker (light / balanced / accurate / max). `ramMB`
7
+ // is a rough working-set hint. `approxMB` is the DOWNLOAD size for the dtype the
8
+ // current runtime will actually fetch — it differs by runtime (the WASM binary
9
+ // downloads fp32; the native npm build downloads q8), so it's a range, and the
10
+ // gateway reports the exact bytes as they stream.
5
11
  //
6
12
  // Adding a model: any onnx-community/* (or Xenova/*) whisper export works. Verify
7
- // it loads + transcribes before listing it here. `.en` models are English-only and
8
- // reject language/task options sttDecodeOptions() below handles that split.
13
+ // it loads on BOTH runtimes (native q8 + WASM fp32) before listing it the WASM
14
+ // runtime can't load block-quantized (q8/int8) or fp16 exports (see stt-engine
15
+ // runtimeDtype). `.en` models are English-only and reject language/task options.
9
16
 
10
- // Default is MULTILINGUAL on purpose: whisper auto-detects the spoken language
11
- // per segment when no `language` is pinned — dictation must "just work" in any
12
- // language without a setting. `.en` models are the speed opt-in, not the default.
13
17
  export const DEFAULT_STT_MODEL = 'onnx-community/whisper-base';
14
18
 
15
19
  export const STT_MODEL_CATALOG = [
20
+ {
21
+ id: 'onnx-community/whisper-tiny.en',
22
+ label: 'Tiny (English)',
23
+ lang: 'English',
24
+ tier: 'light',
25
+ approxMB: 150, // fp32 on WASM; ~40 on native q8
26
+ ramMB: 400,
27
+ note: 'Fastest, lightest. English only — great for quick dictation on any machine.',
28
+ },
16
29
  {
17
30
  id: 'onnx-community/whisper-base',
18
- label: 'Multilingual — balanced',
31
+ label: 'Base (multilingual)',
19
32
  lang: '99 languages (auto-detected)',
20
- approxMB: 105,
21
- note: 'Default. Detects the spoken language automatically; good accuracy.',
33
+ tier: 'balanced',
34
+ approxMB: 300, // fp32 on WASM; ~80 on native q8
35
+ ramMB: 700,
36
+ note: 'Default. Detects the spoken language automatically; good accuracy at real-time speed.',
22
37
  },
23
38
  {
24
- id: 'onnx-community/whisper-tiny.en',
25
- label: 'English — fastest',
26
- lang: 'English',
27
- approxMB: 60,
28
- note: 'Real-time on modest hardware; English only.',
39
+ id: 'onnx-community/whisper-small',
40
+ label: 'Small (multilingual)',
41
+ lang: '99 languages (auto-detected)',
42
+ tier: 'accurate',
43
+ approxMB: 950, // fp32 on WASM; ~250 on native q8
44
+ ramMB: 1600,
45
+ note: 'Noticeably more accurate; needs a faster CPU to keep up in real time.',
29
46
  },
30
47
  {
31
- id: 'onnx-community/whisper-small',
32
- label: 'Multilingual accurate',
48
+ id: 'onnx-community/whisper-large-v3-turbo',
49
+ label: 'Large v3 Turbo (multilingual)',
33
50
  lang: '99 languages (auto-detected)',
34
- approxMB: 330,
35
- note: 'Best accuracy; needs a faster machine for real-time use.',
51
+ tier: 'max',
52
+ approxMB: 1600, // native q8 recommended; heavy on WASM
53
+ ramMB: 3200,
54
+ note: 'Best accuracy. For powerful machines; use the native (npm) gateway for speed.',
36
55
  },
37
56
  ];
38
57
 
@@ -40,7 +59,27 @@ export function isKnownSttModel(id) {
40
59
  return STT_MODEL_CATALOG.some((m) => m.id === id);
41
60
  }
42
61
 
62
+ export function sttModel(id) {
63
+ return STT_MODEL_CATALOG.find((m) => m.id === id) || null;
64
+ }
65
+
43
66
  // English-only exports reject `language`/`task` generation options.
44
67
  export function isEnglishOnly(id) {
45
68
  return /\.en$/.test(String(id || ''));
46
69
  }
70
+
71
+ // A model may pin an explicit dtype (overriding the runtime default). None do by
72
+ // default — the engine's runtimeDtype() picks q8 (native) / fp32 (WASM) — but
73
+ // this is the seam for a model that needs a specific quantization.
74
+ export function sttModelDtype(id) {
75
+ return sttModel(id)?.dtype || null;
76
+ }
77
+
78
+ // Accept a user-supplied ("Advanced") whisper model id. STRICT: `org/name` shape,
79
+ // must be a whisper export, no path traversal. Curated catalog ids come from the
80
+ // private dl.chatpanel.net mirror; custom ids are fetched from Hugging Face
81
+ // directly (the engine points remoteHost there only for the custom load).
82
+ export function isValidCustomSttId(id) {
83
+ const s = String(id || '');
84
+ return /^[A-Za-z0-9][\w.-]*\/[\w.-]+$/.test(s) && /whisper/i.test(s) && !s.includes('..');
85
+ }