@chatpanel/gateway 0.6.18 → 0.6.19
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 +1 -1
- package/src/server.js +12 -6
- package/src/stt-engine.js +44 -8
- package/src/stt-models.js +59 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.19",
|
|
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/server.js
CHANGED
|
@@ -34,7 +34,7 @@ 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
36
|
import { MODEL_CATALOG, isKnownModel } from './models.js';
|
|
37
|
-
import { STT_MODEL_CATALOG, isKnownSttModel, DEFAULT_STT_MODEL } from './stt-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.
|
|
45
|
+
export const VERSION = '0.6.19';
|
|
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.
|
|
@@ -666,9 +666,14 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
666
666
|
// GET/POST /stt/models catalog + progress / switch (mirrors /ner/models)
|
|
667
667
|
if (pathname === '/stt/models') {
|
|
668
668
|
if (req.method === 'GET') {
|
|
669
|
-
const
|
|
669
|
+
const active = sttEngine.health().model || cfg.stt?.model || DEFAULT_STT_MODEL;
|
|
670
|
+
const available = /** @type {any[]} */ (STT_MODEL_CATALOG.map((m) => ({ ...m, installed: sttEngine.modelOnDisk(m.id) })));
|
|
671
|
+
// Surface an active CUSTOM (non-catalog) model so the UI can show it too.
|
|
672
|
+
if (active && !available.some((m) => m.id === active)) {
|
|
673
|
+
available.push({ id: active, label: active, lang: '—', tier: 'custom', custom: true, installed: sttEngine.modelOnDisk(active), note: 'Custom model (from Hugging Face).' });
|
|
674
|
+
}
|
|
670
675
|
return sendJson(res, 200, {
|
|
671
|
-
active
|
|
676
|
+
active,
|
|
672
677
|
state: sttEngine.state(),
|
|
673
678
|
progress: sttEngine.progress(),
|
|
674
679
|
available,
|
|
@@ -677,8 +682,9 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
677
682
|
if (req.method === 'POST') {
|
|
678
683
|
let body = null;
|
|
679
684
|
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
|
-
|
|
685
|
+
const id = body && typeof body.id === 'string' ? body.id.trim() : null;
|
|
686
|
+
// Curated catalog OR a strictly-validated custom whisper id (Advanced).
|
|
687
|
+
if (!id || !(isKnownSttModel(id) || isValidCustomSttId(id))) return sendJson(res, 400, { error: { message: 'unknown or invalid model id', type: 'bad_model' } });
|
|
682
688
|
if (cfg.stt) cfg.stt.model = id; else cfg.stt = { enabled: true, model: id, allowDownload: true };
|
|
683
689
|
try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
|
|
684
690
|
sttEngine.setModel(id, { onLog: (m) => console.log(m) });
|
package/src/stt-engine.js
CHANGED
|
@@ -20,10 +20,24 @@ 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
|
|
@@ -31,13 +45,24 @@ 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
47
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
48
|
+
// transformers.js dtype → the ONNX filename suffix it loads. Presence must check
|
|
49
|
+
// the EXACT file the current runtime will fetch — otherwise a q8 install (native)
|
|
50
|
+
// looks "present" to the WASM runtime, which actually needs the fp32 file, and the
|
|
51
|
+
// offline load fails. Checking the real target makes a runtime switch re-download.
|
|
52
|
+
const DTYPE_SUFFIX = {
|
|
53
|
+
fp32: '', q8: '_quantized', int8: '_int8', uint8: '_uint8',
|
|
54
|
+
fp16: '_fp16', q4: '_q4', bnb4: '_bnb4', q4f16: '_q4f16',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function modelOnDisk(modelId = _model || DEFAULT_STT_MODEL, dtype = sttModelDtype(modelId) || runtimeDtype()) {
|
|
37
58
|
const dir = join(modelRoot(), ...modelId.split('/'), 'onnx');
|
|
38
59
|
if (!existsSync(dir)) return false;
|
|
39
|
-
|
|
40
|
-
|
|
60
|
+
const suffix = DTYPE_SUFFIX[dtype] ?? '';
|
|
61
|
+
try {
|
|
62
|
+
const files = readdirSync(dir);
|
|
63
|
+
return files.includes(`encoder_model${suffix}.onnx`)
|
|
64
|
+
&& files.some((f) => f === `decoder_model_merged${suffix}.onnx` || f === `decoder_model${suffix}.onnx`);
|
|
65
|
+
} catch { return false; }
|
|
41
66
|
}
|
|
42
67
|
|
|
43
68
|
export function state() { return _state; }
|
|
@@ -71,12 +96,21 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
|
|
|
71
96
|
return false;
|
|
72
97
|
}
|
|
73
98
|
|
|
99
|
+
// Curated catalog models come from the private dl.chatpanel.net mirror (ensureLib
|
|
100
|
+
// already set that as remoteHost). A CUSTOM ("Advanced") id isn't mirrored, so
|
|
101
|
+
// fetch it from Hugging Face directly — only for this load, then restore.
|
|
102
|
+
const prevHost = lib.env.remoteHost;
|
|
103
|
+
const isCustom = !isKnownSttModel(modelId);
|
|
104
|
+
if (!haveLocal && isCustom) { try { lib.env.remoteHost = 'https://huggingface.co/'; } catch { /* optional */ } }
|
|
105
|
+
|
|
74
106
|
_state = haveLocal ? 'loading' : 'downloading';
|
|
75
|
-
if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[stt] downloading model ${modelId} (one-time)…`); }
|
|
107
|
+
if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[stt] downloading model ${modelId} (one-time${isCustom ? ', from Hugging Face' : ''})…`); }
|
|
76
108
|
|
|
77
109
|
try {
|
|
110
|
+
// Per-model override wins (catalog `dtype`), else the runtime-appropriate default.
|
|
111
|
+
const dtype = sttModelDtype(modelId) || runtimeDtype();
|
|
78
112
|
const pipe = await lib.pipeline('automatic-speech-recognition', modelId, {
|
|
79
|
-
dtype
|
|
113
|
+
dtype,
|
|
80
114
|
progress_callback: (p) => {
|
|
81
115
|
if (!p) return;
|
|
82
116
|
const pct = typeof p.progress === 'number' ? Math.round(p.progress) : (_progress?.pct ?? 0);
|
|
@@ -97,6 +131,8 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
|
|
|
97
131
|
else { _state = 'error'; }
|
|
98
132
|
log(`[stt] model load failed (${e.message})${prevPipe ? ' — keeping previous model' : ''}`);
|
|
99
133
|
return false;
|
|
134
|
+
} finally {
|
|
135
|
+
try { lib.env.remoteHost = prevHost; } catch { /* optional */ } // restore the mirror for NER + catalog loads
|
|
100
136
|
}
|
|
101
137
|
}
|
|
102
138
|
|
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
|
|
3
|
-
// automatic-speech-recognition models —
|
|
4
|
-
// root as the NER catalog (models.js).
|
|
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 +
|
|
8
|
-
//
|
|
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: '
|
|
31
|
+
label: 'Base (multilingual)',
|
|
19
32
|
lang: '99 languages (auto-detected)',
|
|
20
|
-
|
|
21
|
-
|
|
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-
|
|
25
|
-
label: '
|
|
26
|
-
lang: '
|
|
27
|
-
|
|
28
|
-
|
|
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-
|
|
32
|
-
label: '
|
|
48
|
+
id: 'onnx-community/whisper-large-v3-turbo',
|
|
49
|
+
label: 'Large v3 Turbo (multilingual)',
|
|
33
50
|
lang: '99 languages (auto-detected)',
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
}
|