@chatpanel/gateway 0.6.24 → 0.6.26

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.24",
3
+ "version": "0.6.26",
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
@@ -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.24';
46
+ export const VERSION = '0.6.26';
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.
@@ -499,7 +499,9 @@ export function createGateway(cfg = loadConfig()) {
499
499
  const stt = sttEngine.health();
500
500
  return sendJson(res, 200, {
501
501
  ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier,
502
- stt: { enabled: cfg.stt?.enabled !== false, state: stt.state, ready: stt.ok, model: stt.model || cfg.stt?.model || DEFAULT_STT_MODEL },
502
+ // `runtime` = 'native' (npm, fast quantized) | 'wasm' (binary, slow fp32)
503
+ // the extension uses it to advise the far-faster native gateway.
504
+ stt: { enabled: cfg.stt?.enabled !== false, state: stt.state, ready: stt.ok, model: stt.model || cfg.stt?.model || DEFAULT_STT_MODEL, runtime: stt.runtime, dtype: stt.dtype },
503
505
  });
504
506
  }
505
507
 
package/src/stt-engine.js CHANGED
@@ -39,6 +39,14 @@ export function runtimeDtype() {
39
39
  return globalThis.__CHATPANEL_WASM_PATHS__ ? 'fp32' : 'q8';
40
40
  }
41
41
 
42
+ // Which ONNX runtime is active: the standalone binary uses onnxruntime-web WASM
43
+ // (fp32-only, single-thread — ~10× slower); the npm package uses onnxruntime-node
44
+ // (native, quantized q8). The extension surfaces this so users on the slow WASM
45
+ // build know the native gateway is far faster.
46
+ export function runtimeName() {
47
+ return globalThis.__CHATPANEL_WASM_PATHS__ ? 'wasm' : 'native';
48
+ }
49
+
42
50
  let _state = 'off'; // 'off' | 'loading' | 'downloading' | 'ready' | 'error'
43
51
  let _model = null; // active model id
44
52
  let _pipe = null; // the loaded automatic-speech-recognition pipeline
@@ -72,7 +80,7 @@ export function isReady() { return _state === 'ready' && !!_pipe; }
72
80
  export function progress() { return _progress; }
73
81
 
74
82
  export function health() {
75
- return { configured: _state !== 'off', ok: isReady(), state: _state, model: _model, dtype: _dtype, error: _err };
83
+ return { configured: _state !== 'off', ok: isReady(), state: _state, model: _model, dtype: _dtype, runtime: runtimeName(), error: _err };
76
84
  }
77
85
 
78
86
  // (Re)load a model into _pipe. Same contract as ner-engine.loadModel: fail-open,
@@ -111,18 +119,28 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
111
119
  try {
112
120
  // Per-model override wins (catalog `dtype`), else the runtime-appropriate default.
113
121
  const dtype = sttModelDtype(modelId) || runtimeDtype();
114
- const pipe = await lib.pipeline('automatic-speech-recognition', modelId, {
115
- dtype,
116
- progress_callback: (p) => {
117
- if (!p) return;
118
- const pct = typeof p.progress === 'number' ? Math.round(p.progress) : (_progress?.pct ?? 0);
119
- if (p.status === 'progress' || p.status === 'download' || p.status === 'initiate') {
120
- _progress = { model: modelId, file: p.file || _progress?.file || null, pct };
121
- } else if (p.status === 'done' && p.file) {
122
- log(`[stt] fetched ${p.file}`);
123
- }
124
- },
125
- });
122
+ const progress_callback = (p) => {
123
+ if (!p) return;
124
+ const pct = typeof p.progress === 'number' ? Math.round(p.progress) : (_progress?.pct ?? 0);
125
+ if (p.status === 'progress' || p.status === 'download' || p.status === 'initiate') {
126
+ _progress = { model: modelId, file: p.file || _progress?.file || null, pct };
127
+ } else if (p.status === 'done' && p.file) {
128
+ log(`[stt] fetched ${p.file}`);
129
+ }
130
+ };
131
+ let pipe;
132
+ try {
133
+ pipe = await lib.pipeline('automatic-speech-recognition', modelId, { dtype, progress_callback });
134
+ } catch (e) {
135
+ // The dl.chatpanel.net mirror only proxies an allowlist; a catalog model
136
+ // missing from it (or any mirror hiccup) 403s. Fall back to Hugging Face so
137
+ // a download is never blocked by a mirror gap. (Custom ids already use HF.)
138
+ if (haveLocal || isCustom || !allowDownload) throw e;
139
+ log(`[stt] mirror fetch failed (${String(e.message).slice(0, 80)}) — retrying from Hugging Face…`);
140
+ lib.env.remoteHost = 'https://huggingface.co/';
141
+ lib.env.allowRemoteModels = true;
142
+ pipe = await lib.pipeline('automatic-speech-recognition', modelId, { dtype, progress_callback });
143
+ }
126
144
  _pipe = pipe; _model = modelId; _state = 'ready'; _err = null; _progress = null; _dtype = dtype;
127
145
  if (prevPipe && prevPipe !== pipe) { try { await prevPipe.dispose?.(); } catch { /* ignore */ } }
128
146
  log(`[stt] ready — model ${modelId} @ ${dtype} (in-process, offline) — local dictation active`);