@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 +9 -0
- package/package.json +1 -1
- package/src/ner-engine.js +21 -12
- package/src/server.js +14 -5
- package/src/stt-engine.js +36 -20
- package/src/stt-models.js +16 -0
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.
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
164
|
-
|
|
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
|
+
}
|