@chatpanel/gateway 0.6.25 → 0.6.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,6 +54,15 @@ chatpanel-gateway
54
54
  # backend : bridge (agent: codex, via http://127.0.0.1:4319)
55
55
  ```
56
56
 
57
+ > **Binary vs. npm — same features, very different local-AI speed.** Both run
58
+ > identical redaction/routing. But the standalone binary runs the local models
59
+ > (speech-to-text, diarization, NER) on the **WASM** runtime — **fp32-only,
60
+ > single-threaded, slow**. The **npm** install uses the **native** runtime with
61
+ > **quantized (q8)** models — in our tests **~10× faster** speech-to-text
62
+ > (real-time even on larger, more accurate models). If you'll use voice/meeting
63
+ > features, install via **npm**. Don't run both — they can shadow each other on
64
+ > `PATH`; check `GET /health` → `stt.runtime` (`native` vs `wasm`).
65
+
57
66
  Then point your front-end agent at it. **opencode** (`opencode.json`):
58
67
 
59
68
  ```jsonc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.25",
3
+ "version": "0.6.27",
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/ner-engine.js CHANGED
@@ -179,6 +179,7 @@ export async function ensureLib() {
179
179
  // (Re)load a specific model into _pipe. Downloads it first if missing (and allowed).
180
180
  // Reusable by init() and setModel(). Fail-open: on error, state='error', _pipe stays
181
181
  // whatever it was (so a failed SWITCH doesn't kill a working detector).
182
+ /** @param {string} modelId @param {{ log?: (m: string) => void, allowDownload?: boolean }} [opts] */
182
183
  async function loadModel(modelId, { log = () => {}, allowDownload = true } = {}) {
183
184
  const prevPipe = _pipe;
184
185
  const prevModel = _model;
@@ -211,18 +212,26 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
211
212
  if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[ner] downloading model ${modelId} (one-time${isCustom ? ', from Hugging Face' : ''})…`); }
212
213
 
213
214
  try {
214
- const pipe = await lib.pipeline('token-classification', modelId, {
215
- dtype: 'q8',
216
- progress_callback: (p) => {
217
- if (!p) return;
218
- const pct = typeof p.progress === 'number' ? Math.round(p.progress) : (_progress?.pct ?? 0);
219
- if (p.status === 'progress' || p.status === 'download' || p.status === 'initiate') {
220
- _progress = { model: modelId, file: p.file || _progress?.file || null, pct };
221
- } else if (p.status === 'done' && p.file) {
222
- log(`[ner] fetched ${p.file}`);
223
- }
224
- },
225
- });
215
+ const progress_callback = (p) => {
216
+ if (!p) return;
217
+ const pct = typeof p.progress === 'number' ? Math.round(p.progress) : (_progress?.pct ?? 0);
218
+ if (p.status === 'progress' || p.status === 'download' || p.status === 'initiate') {
219
+ _progress = { model: modelId, file: p.file || _progress?.file || null, pct };
220
+ } else if (p.status === 'done' && p.file) {
221
+ log(`[ner] fetched ${p.file}`);
222
+ }
223
+ };
224
+ let pipe;
225
+ try {
226
+ pipe = await lib.pipeline('token-classification', modelId, { dtype: 'q8', progress_callback });
227
+ } catch (e) {
228
+ // Mirror gap → fall back to Hugging Face (same as stt-engine).
229
+ if (haveLocal || isCustom || !allowDownload) throw e;
230
+ log(`[ner] mirror fetch failed (${String(e.message).slice(0, 80)}) — retrying from Hugging Face…`);
231
+ lib.env.remoteHost = 'https://huggingface.co/';
232
+ lib.env.allowRemoteModels = true;
233
+ pipe = await lib.pipeline('token-classification', modelId, { dtype: 'q8', progress_callback });
234
+ }
226
235
  // Swap in the new pipeline, dispose the old one (free its WASM/native session).
227
236
  _pipe = pipe; _model = modelId; _state = 'ready'; _err = null; _progress = null;
228
237
  if (prevPipe && prevPipe !== pipe) { try { await prevPipe.dispose?.(); } catch { /* ignore */ } }
package/src/server.js CHANGED
@@ -35,7 +35,7 @@ import * as nerEngine from './ner-engine.js';
35
35
  import * as sttEngine from './stt-engine.js';
36
36
  import * as diarizeEngine from './diarize-engine.js';
37
37
  import { MODEL_CATALOG, isKnownModel, isValidCustomModelId } from './models.js';
38
- import { STT_MODEL_CATALOG, isKnownSttModel, isValidCustomSttId, DEFAULT_STT_MODEL } from './stt-models.js';
38
+ import { STT_MODEL_CATALOG, isKnownSttModel, isValidCustomSttId, DEFAULT_STT_MODEL, STT_DTYPES, isValidDtype } from './stt-models.js';
39
39
  import { resolvePro, checkQuota, consume, usage } from './freegate.js';
40
40
  import { publicConfig, applyConfigPatch, persistConfig, configPath } from './configstore.js';
41
41
  import { resolveDestination, aggregateModelsAsync } from './router.js';
@@ -43,7 +43,7 @@ import * as openai from './openai.js';
43
43
  import * as responses from './responses.js';
44
44
  import * as anthropic from './anthropic.js';
45
45
 
46
- export const VERSION = '0.6.25';
46
+ export const VERSION = '0.6.27';
47
47
 
48
48
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
49
49
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -686,6 +686,12 @@ export function createGateway(cfg = loadConfig()) {
686
686
  state: sttEngine.state(),
687
687
  progress: sttEngine.progress(),
688
688
  available,
689
+ // Precision (quantization) picker: current choice + selectable options +
690
+ // the dtype actually loaded. On WASM only fp32 loads (see runtimeDtype).
691
+ dtype: cfg.stt?.dtype || 'auto',
692
+ loadedDtype: sttEngine.health().dtype,
693
+ runtime: sttEngine.health().runtime,
694
+ dtypes: STT_DTYPES,
689
695
  });
690
696
  }
691
697
  if (req.method === 'POST') {
@@ -694,10 +700,13 @@ export function createGateway(cfg = loadConfig()) {
694
700
  const id = body && typeof body.id === 'string' ? body.id.trim() : null;
695
701
  // Curated catalog OR a strictly-validated custom whisper id (Advanced).
696
702
  if (!id || !(isKnownSttModel(id) || isValidCustomSttId(id))) return sendJson(res, 400, { error: { message: 'unknown or invalid model id', type: 'bad_model' } });
703
+ // Optional precision override (q8/q4/fp16/…); 'auto' clears it.
704
+ const dtype = typeof body.dtype === 'string' && isValidDtype(body.dtype) ? body.dtype : undefined;
697
705
  if (cfg.stt) cfg.stt.model = id; else cfg.stt = { enabled: true, model: id, allowDownload: true };
706
+ if (dtype) cfg.stt.dtype = dtype === 'auto' ? null : dtype;
698
707
  try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
699
- sttEngine.setModel(id, { onLog: (m) => console.log(m) });
700
- return sendJson(res, 202, { accepted: true, active: id, state: sttEngine.state(), progress: sttEngine.progress() });
708
+ sttEngine.setModel(id, { onLog: (m) => console.log(m), dtype: dtype || cfg.stt.dtype || 'auto' });
709
+ return sendJson(res, 202, { accepted: true, active: id, dtype: cfg.stt.dtype || 'auto', state: sttEngine.state(), progress: sttEngine.progress() });
701
710
  }
702
711
  }
703
712
  // Speaker (diarization) model manager — the "who said what" x-vector model.
@@ -725,7 +734,7 @@ export function createGateway(cfg = loadConfig()) {
725
734
  // Kick the model load on first use (single-flight; downloads once). The
726
735
  // client follows progress on the session's SSE stream.
727
736
  if (!sttEngine.isReady()) {
728
- sttEngine.init({ model: cfg.stt?.model || DEFAULT_STT_MODEL, allowDownload: cfg.stt?.allowDownload !== false, onLog: (m) => console.log(m) });
737
+ sttEngine.init({ model: cfg.stt?.model || DEFAULT_STT_MODEL, allowDownload: cfg.stt?.allowDownload !== false, dtype: cfg.stt?.dtype || undefined, onLog: (m) => console.log(m) });
729
738
  }
730
739
  // Diarization is another OPTIONAL stage: load its model only when a session
731
740
  // asks for it (never on the dictation path).
package/src/stt-engine.js CHANGED
@@ -85,8 +85,8 @@ export function health() {
85
85
 
86
86
  // (Re)load a model into _pipe. Same contract as ner-engine.loadModel: fail-open,
87
87
  // and a failed SWITCH keeps the previous working pipeline.
88
- /** @param {string} modelId @param {{ log?: (m: string) => void, allowDownload?: boolean }} [opts] */
89
- async function loadModel(modelId, { log = () => {}, allowDownload = true } = {}) {
88
+ /** @param {string} modelId @param {{ log?: (m: string) => void, allowDownload?: boolean, dtype?: string }} [opts] */
89
+ async function loadModel(modelId, { log = () => {}, allowDownload = true, dtype: dtypeOverride = null } = {}) {
90
90
  const prevPipe = _pipe;
91
91
  const prevModel = _model;
92
92
  let lib;
@@ -98,7 +98,13 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
98
98
  return false;
99
99
  }
100
100
 
101
- const haveLocal = modelOnDisk(modelId);
101
+ // Resolve the precision up front so the on-disk check targets the RIGHT files —
102
+ // else switching precision (e.g. q8→fp16) would see the q8 files as "present" and
103
+ // try to load fp16 offline, which fails.
104
+ const chosen = dtypeOverride && dtypeOverride !== 'auto' ? dtypeOverride : null;
105
+ const dtype = chosen || sttModelDtype(modelId) || runtimeDtype();
106
+
107
+ const haveLocal = modelOnDisk(modelId, dtype);
102
108
  lib.env.allowRemoteModels = haveLocal ? false : !!allowDownload;
103
109
  if (!haveLocal && !allowDownload) {
104
110
  _state = 'error'; _err = 'model not on disk and downloads disabled';
@@ -117,20 +123,28 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
117
123
  if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[stt] downloading model ${modelId} (one-time${isCustom ? ', from Hugging Face' : ''})…`); }
118
124
 
119
125
  try {
120
- // Per-model override wins (catalog `dtype`), else the runtime-appropriate default.
121
- const dtype = sttModelDtype(modelId) || runtimeDtype();
122
- const pipe = await lib.pipeline('automatic-speech-recognition', modelId, {
123
- dtype,
124
- progress_callback: (p) => {
125
- if (!p) return;
126
- const pct = typeof p.progress === 'number' ? Math.round(p.progress) : (_progress?.pct ?? 0);
127
- if (p.status === 'progress' || p.status === 'download' || p.status === 'initiate') {
128
- _progress = { model: modelId, file: p.file || _progress?.file || null, pct };
129
- } else if (p.status === 'done' && p.file) {
130
- log(`[stt] fetched ${p.file}`);
131
- }
132
- },
133
- });
126
+ const progress_callback = (p) => {
127
+ if (!p) return;
128
+ const pct = typeof p.progress === 'number' ? Math.round(p.progress) : (_progress?.pct ?? 0);
129
+ if (p.status === 'progress' || p.status === 'download' || p.status === 'initiate') {
130
+ _progress = { model: modelId, file: p.file || _progress?.file || null, pct };
131
+ } else if (p.status === 'done' && p.file) {
132
+ log(`[stt] fetched ${p.file}`);
133
+ }
134
+ };
135
+ let pipe;
136
+ try {
137
+ pipe = await lib.pipeline('automatic-speech-recognition', modelId, { dtype, progress_callback });
138
+ } catch (e) {
139
+ // The dl.chatpanel.net mirror only proxies an allowlist; a catalog model
140
+ // missing from it (or any mirror hiccup) 403s. Fall back to Hugging Face so
141
+ // a download is never blocked by a mirror gap. (Custom ids already use HF.)
142
+ if (haveLocal || isCustom || !allowDownload) throw e;
143
+ log(`[stt] mirror fetch failed (${String(e.message).slice(0, 80)}) — retrying from Hugging Face…`);
144
+ lib.env.remoteHost = 'https://huggingface.co/';
145
+ lib.env.allowRemoteModels = true;
146
+ pipe = await lib.pipeline('automatic-speech-recognition', modelId, { dtype, progress_callback });
147
+ }
134
148
  _pipe = pipe; _model = modelId; _state = 'ready'; _err = null; _progress = null; _dtype = dtype;
135
149
  if (prevPipe && prevPipe !== pipe) { try { await prevPipe.dispose?.(); } catch { /* ignore */ } }
136
150
  log(`[stt] ready — model ${modelId} @ ${dtype} (in-process, offline) — local dictation active`);
@@ -153,15 +167,17 @@ export function init(cfg = {}) {
153
167
  const log = typeof cfg.onLog === 'function' ? cfg.onLog : () => {};
154
168
  _model = cfg.model || DEFAULT_STT_MODEL;
155
169
  _state = 'loading';
156
- _initPromise = loadModel(_model, { log, allowDownload: cfg.allowDownload !== false });
170
+ _initPromise = loadModel(_model, { log, allowDownload: cfg.allowDownload !== false, dtype: cfg.dtype });
157
171
  return _initPromise;
158
172
  }
159
173
 
160
174
  export async function setModel(modelId, opts = {}) {
161
175
  const log = typeof opts.onLog === 'function' ? opts.onLog : () => {};
162
176
  if (!modelId) return false;
163
- if (modelId === _model && isReady()) return true;
164
- return loadModel(modelId, { log, allowDownload: opts.allowDownload !== false });
177
+ // Re-load if the model OR the requested precision changed.
178
+ const wantDtype = opts.dtype && opts.dtype !== 'auto' ? opts.dtype : (sttModelDtype(modelId) || runtimeDtype());
179
+ if (modelId === _model && isReady() && _dtype === wantDtype) return true;
180
+ return loadModel(modelId, { log, allowDownload: opts.allowDownload !== false, dtype: opts.dtype });
165
181
  }
166
182
 
167
183
  // ── Streaming sessions ──────────────────────────────────────────────────────────
package/src/stt-models.js CHANGED
@@ -85,3 +85,19 @@ export function isValidCustomSttId(id) {
85
85
  const s = String(id || '');
86
86
  return /^[A-Za-z0-9][\w.-]*\/[\w.-]+$/.test(s) && !s.includes('..');
87
87
  }
88
+
89
+ // Selectable quantizations (precision). 'auto' = let the runtime decide (q8 on
90
+ // native, fp32 on WASM). Each is a transformers.js dtype; a model must actually
91
+ // ship that ONNX variant (the load fails open otherwise). Ordered fastest→best.
92
+ export const STT_DTYPES = [
93
+ { id: 'auto', label: 'Auto (recommended)', note: 'q8 on the native gateway, fp32 on the WASM binary.' },
94
+ { id: 'q8', label: 'q8 — fast, balanced', note: 'Int8. Best speed/quality trade-off (native default).' },
95
+ { id: 'q4', label: 'q4 — smallest & fastest', note: '4-bit. Least memory; slightly lower accuracy.' },
96
+ { id: 'int8', label: 'int8', note: 'Alternative int8 export.' },
97
+ { id: 'fp16', label: 'fp16 — more accurate', note: 'Half precision. Larger, a bit slower.' },
98
+ { id: 'fp32', label: 'fp32 — most accurate (slow)', note: 'Full precision. Largest + slowest; the only one that loads on the WASM binary.' },
99
+ ];
100
+
101
+ export function isValidDtype(d) {
102
+ return STT_DTYPES.some((x) => x.id === d);
103
+ }