@chatpanel/gateway 0.6.27 → 0.6.29
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 +2 -2
- package/src/configstore.js +9 -3
- package/src/diarize-engine.js +34 -3
- package/src/ner-engine.js +30 -6
- package/src/parakeet-engine.js +241 -0
- package/src/router.js +4 -1
- package/src/server.js +10 -4
- package/src/stt-engine.js +59 -4
- package/src/stt-models.js +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.29",
|
|
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": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"node": ">=18"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@chatpanel/pii": "^0.2.
|
|
30
|
+
"@chatpanel/pii": "^0.2.12",
|
|
31
31
|
"@huggingface/transformers": "^4.2.0",
|
|
32
32
|
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c"
|
|
33
33
|
},
|
package/src/configstore.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// configure it live over the localhost API (GET/POST /config). The gateway stays
|
|
3
3
|
// authoritative — the extension is just a UI client.
|
|
4
4
|
|
|
5
|
-
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { writeFileSync, mkdirSync, chmodSync } from 'node:fs';
|
|
6
6
|
import { join, dirname } from 'node:path';
|
|
7
7
|
import os from 'node:os';
|
|
8
8
|
import { usage } from './freegate.js';
|
|
@@ -15,7 +15,10 @@ export function configPath(env = process.env) {
|
|
|
15
15
|
|
|
16
16
|
// Persist the user-editable subset (not derived runtime state).
|
|
17
17
|
export function persistConfig(cfg, path = configPath()) {
|
|
18
|
-
|
|
18
|
+
// 0700 the dir + 0600 the file: this JSON holds per-destination apiKeys, the
|
|
19
|
+
// detector key, and the entitlement/bridge tokens — same secret-at-rest posture
|
|
20
|
+
// as the history key/secret files, so it isn't left world-readable on a shared host.
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
19
22
|
const out = {
|
|
20
23
|
host: cfg.host, port: cfg.port, backend: cfg.backend,
|
|
21
24
|
// Destinations (the configured agents + API models) MUST persist — otherwise a
|
|
@@ -25,7 +28,10 @@ export function persistConfig(cfg, path = configPath()) {
|
|
|
25
28
|
ner: cfg.ner, stt: cfg.stt, allowedOrigins: cfg.allowedOrigins, maxBodyBytes: cfg.maxBodyBytes,
|
|
26
29
|
pro: cfg.pro, logRequests: cfg.logRequests, logDetail: cfg.logDetail, tools: cfg.tools,
|
|
27
30
|
};
|
|
28
|
-
writeFileSync
|
|
31
|
+
// mode on writeFileSync only applies when CREATING the file; chmod after covers an
|
|
32
|
+
// already-existing (pre-fix, possibly 0644) config too. Best-effort for non-POSIX.
|
|
33
|
+
writeFileSync(path, JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
34
|
+
try { chmodSync(path, 0o600); } catch { /* platforms without POSIX perms */ }
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
// Safe view for GET /config — never leak secrets (the entitlement + bridge tokens
|
package/src/diarize-engine.js
CHANGED
|
@@ -99,9 +99,17 @@ export function download({ onLog } = {}) {
|
|
|
99
99
|
return _initPromise;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
const SR = 16000;
|
|
103
|
+
|
|
104
|
+
function l2norm(v) {
|
|
105
|
+
let n = 0; for (const x of v) n += x * x;
|
|
106
|
+
n = Math.sqrt(n) || 1;
|
|
107
|
+
const o = new Float32Array(v.length);
|
|
108
|
+
for (let i = 0; i < v.length; i++) o[i] = v[i] / n;
|
|
109
|
+
return o;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function embedOne(audio) {
|
|
105
113
|
try {
|
|
106
114
|
const inputs = await _processor(audio);
|
|
107
115
|
const out = await _model(inputs);
|
|
@@ -110,6 +118,29 @@ export async function embed(audio) {
|
|
|
110
118
|
} catch { return null; }
|
|
111
119
|
}
|
|
112
120
|
|
|
121
|
+
// Embed a mono 16 kHz Float32 segment → L2-normalized x-vector, or null if not
|
|
122
|
+
// ready. For longer turns we embed several overlapping ~2 s windows and AVERAGE
|
|
123
|
+
// the normalized vectors — a more stable speaker fingerprint than one embed of
|
|
124
|
+
// the whole (noisy) segment. Cheap on the native runtime (runs once per turn).
|
|
125
|
+
export async function embed(audio) {
|
|
126
|
+
if (!isReady() || !(audio instanceof Float32Array) || !audio.length) return null;
|
|
127
|
+
const win = 2 * SR;
|
|
128
|
+
const hop = Math.round(1.3 * SR);
|
|
129
|
+
if (audio.length <= Math.round(win * 1.4)) {
|
|
130
|
+
const v = await embedOne(audio);
|
|
131
|
+
return v ? l2norm(v) : null;
|
|
132
|
+
}
|
|
133
|
+
const vecs = [];
|
|
134
|
+
for (let s = 0; s + win <= audio.length; s += hop) {
|
|
135
|
+
const v = await embedOne(audio.subarray(s, s + win));
|
|
136
|
+
if (v) vecs.push(l2norm(v));
|
|
137
|
+
}
|
|
138
|
+
if (!vecs.length) { const v = await embedOne(audio); return v ? l2norm(v) : null; }
|
|
139
|
+
const avg = new Float32Array(vecs[0].length);
|
|
140
|
+
for (const v of vecs) for (let i = 0; i < avg.length; i++) avg[i] += v[i];
|
|
141
|
+
return l2norm(avg); // mean of unit vectors, renormalized
|
|
142
|
+
}
|
|
143
|
+
|
|
113
144
|
export function _reset() { _state = 'off'; _model = null; _processor = null; _err = null; _initPromise = null; _progress = null; }
|
|
114
145
|
export function _setForTest(model, processor) { _model = model; _processor = processor; _state = model ? 'ready' : 'off'; }
|
|
115
146
|
|
package/src/ner-engine.js
CHANGED
|
@@ -18,15 +18,39 @@ import os from 'node:os';
|
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
20
20
|
import { isKnownModel } from './models.js';
|
|
21
|
+
import { isLoopbackHost, isPrivateHost, isMetadataHost } from '@chatpanel/pii';
|
|
21
22
|
|
|
22
23
|
const DEFAULT_MODEL = 'Xenova/bert-base-NER';
|
|
24
|
+
const DEFAULT_MODEL_HOST = 'https://dl.chatpanel.net/models/';
|
|
23
25
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
26
|
+
// Where model weights are fetched from — ChatPanel's own edge-cached CDN by default,
|
|
27
|
+
// so a clean install depends only on chatpanel.net. CHATPANEL_MODEL_BASE_URL can
|
|
28
|
+
// override it (HF for dev, or an air-gapped LAN mirror) — but that env var is a
|
|
29
|
+
// download-redirect vector: an attacker who sets it could serve a malicious ONNX
|
|
30
|
+
// model into the runtime. So we VALIDATE the override before trusting it: http(s)
|
|
31
|
+
// only, never cloud metadata, and no PLAINTEXT http to a PUBLIC host (a LAN/loopback
|
|
32
|
+
// mirror on http is fine — that's the air-gap case). Anything else falls back to the
|
|
33
|
+
// signed default and logs loudly. (True per-file checksum verification is the
|
|
34
|
+
// remaining H3 step — needs a committed {model→sha256} manifest.)
|
|
35
|
+
export function resolveModelHost() {
|
|
36
|
+
const raw = process.env.CHATPANEL_MODEL_BASE_URL;
|
|
37
|
+
if (!raw) return DEFAULT_MODEL_HOST;
|
|
38
|
+
const fallback = (why) => {
|
|
39
|
+
console.warn(`[models] ignoring CHATPANEL_MODEL_BASE_URL (${raw}): ${why} — using ${DEFAULT_MODEL_HOST}`);
|
|
40
|
+
return DEFAULT_MODEL_HOST;
|
|
41
|
+
};
|
|
42
|
+
let u;
|
|
43
|
+
try { u = new URL(raw); } catch { return fallback('not a valid URL'); }
|
|
44
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return fallback(`scheme ${u.protocol} not allowed`);
|
|
45
|
+
if (isMetadataHost(u.hostname)) return fallback('points at cloud metadata');
|
|
46
|
+
const localish = isLoopbackHost(u.hostname) || isPrivateHost(u.hostname);
|
|
47
|
+
if (u.protocol === 'http:' && !localish) return fallback('plaintext http:// to a public host (use https or a LAN/loopback mirror)');
|
|
48
|
+
console.warn(`[models] ⚠ model weights will be downloaded from ${u.origin} (CHATPANEL_MODEL_BASE_URL override), not ${DEFAULT_MODEL_HOST}`);
|
|
49
|
+
return raw.replace(/\/*$/, '/');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Must end with '/' (transformers appends the model path template to it).
|
|
53
|
+
const MODEL_HOST = resolveModelHost();
|
|
30
54
|
|
|
31
55
|
let _state = 'off'; // 'off' | 'loading' | 'downloading' | 'ready' | 'error'
|
|
32
56
|
let _model = null; // active model id, e.g. 'Xenova/bert-base-NER'
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// In-process speech-to-text — NVIDIA Parakeet TDT (transducer) via onnxruntime.
|
|
2
|
+
//
|
|
3
|
+
// Whisper (stt-engine.js) is an encoder-DECODER seq2seq model and loads through the
|
|
4
|
+
// transformers.js `automatic-speech-recognition` pipeline. Parakeet is a *transducer*
|
|
5
|
+
// (Token-and-Duration Transducer / TDT on a FastConformer encoder) — a fundamentally
|
|
6
|
+
// different architecture the transformers.js pipeline can't drive (it errors with
|
|
7
|
+
// `Unsupported model type "nemo-conformer-tdt"`). So we run the ONNX graphs directly
|
|
8
|
+
// on the same onnxruntime the gateway already ships, with a hand-written greedy TDT
|
|
9
|
+
// decode loop.
|
|
10
|
+
//
|
|
11
|
+
// Why bother: Parakeet-TDT-0.6b-v3 is multilingual (25 European languages, auto-
|
|
12
|
+
// detected — no forced-language step) and runs ~35× realtime at int8 on CPU, several
|
|
13
|
+
// times faster than Whisper at comparable accuracy. It's the fast local-dictation path.
|
|
14
|
+
//
|
|
15
|
+
// Model layout (istupakov/onnx-asr export, e.g. `istupakov/parakeet-tdt-0.6b-v3-onnx`):
|
|
16
|
+
// nemo128.onnx mel preprocessor (waveforms → 128-bin log-mel features)
|
|
17
|
+
// encoder-model[.int8].onnx FastConformer encoder (features → [B,D,T'] frames)
|
|
18
|
+
// decoder_joint[.int8].onnx fused prediction-net (LSTM) + joint network
|
|
19
|
+
// vocab.txt "<piece> <id>" per line; the last line is "<blk> <id>"
|
|
20
|
+
//
|
|
21
|
+
// This module owns ONE concern: load those graphs and turn 16 kHz mono Float32 PCM
|
|
22
|
+
// into text. The streaming/session/redaction/diarization layer lives in stt-engine.js
|
|
23
|
+
// and calls us through a tiny adapter, so nothing downstream needs to know the engine.
|
|
24
|
+
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, createWriteStream, renameSync, statSync } from 'node:fs';
|
|
27
|
+
import { Readable } from 'node:stream';
|
|
28
|
+
import { modelRoot } from './ner-engine.js';
|
|
29
|
+
|
|
30
|
+
// transformers.js reports these `model_type`s for the transducer exports. Any of them
|
|
31
|
+
// means "not a whisper pipeline model — route here instead".
|
|
32
|
+
const TRANSDUCER_MODEL_TYPES = new Set([
|
|
33
|
+
'nemo-conformer-tdt', 'nemo-conformer-rnnt', 'parakeet_tdt', 'parakeet-tdt', 'parakeet_rnnt',
|
|
34
|
+
]);
|
|
35
|
+
export function isTransducerModelType(mt) {
|
|
36
|
+
return TRANSDUCER_MODEL_TYPES.has(String(mt || '').trim());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Parakeet ships its own quantizations (standard QDQ/QOperator int8 — NOT whisper's
|
|
40
|
+
// block-quantized q8, so it loads on BOTH the native and WASM runtimes). Default to
|
|
41
|
+
// int8: ~690 MB total vs ~2.5 GB for fp32, with negligible accuracy loss for ASR.
|
|
42
|
+
export const PARAKEET_DEFAULT_DTYPE = 'int8';
|
|
43
|
+
export function parakeetDtype(d) {
|
|
44
|
+
return d === 'fp32' ? 'fp32' : PARAKEET_DEFAULT_DTYPE; // only int8 | fp32 are exported
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The repo files this engine needs for a given precision. `encoder-model.onnx` (fp32)
|
|
48
|
+
// carries its weights in a sibling `.onnx.data` external-data file that ORT loads
|
|
49
|
+
// automatically when it sits next to the graph — so we must fetch it too.
|
|
50
|
+
function filesFor(dtype) {
|
|
51
|
+
const s = parakeetDtype(dtype) === 'fp32' ? '' : '.int8';
|
|
52
|
+
const files = ['config.json', 'vocab.txt', 'nemo128.onnx', `encoder-model${s}.onnx`, `decoder_joint-model${s}.onnx`];
|
|
53
|
+
if (!s) files.push('encoder-model.onnx.data'); // fp32 external weights
|
|
54
|
+
return files;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parakeetDir(modelId) {
|
|
58
|
+
return join(modelRoot(), ...String(modelId).split('/'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Present on disk = every required file exists and is non-empty (a truncated download
|
|
62
|
+
// must not read as "installed"). Mirrors stt/ner `modelOnDisk` intent.
|
|
63
|
+
export function parakeetOnDisk(modelId, dtype = PARAKEET_DEFAULT_DTYPE) {
|
|
64
|
+
const dir = parakeetDir(modelId);
|
|
65
|
+
if (!existsSync(dir)) return false;
|
|
66
|
+
try {
|
|
67
|
+
return filesFor(dtype).every((f) => { const p = join(dir, f); return existsSync(p) && statSync(p).size > 0; });
|
|
68
|
+
} catch { return false; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── onnxruntime, matching the gateway's runtime (native npm vs WASM binary) ──────────
|
|
72
|
+
// The npm gateway uses native onnxruntime-node (fast). The standalone binary embeds the
|
|
73
|
+
// onnxruntime-web WASM runtime and hands us its paths via __CHATPANEL_WASM_PATHS__ (the
|
|
74
|
+
// same global ner-engine keys off) — configure ORT-web from it. Memoized once.
|
|
75
|
+
let _ortPromise = null;
|
|
76
|
+
function getOrt() {
|
|
77
|
+
if (_ortPromise) return _ortPromise;
|
|
78
|
+
_ortPromise = (async () => {
|
|
79
|
+
const wasmPaths = globalThis.__CHATPANEL_WASM_PATHS__ || null;
|
|
80
|
+
const mod = await import(wasmPaths ? 'onnxruntime-web' : 'onnxruntime-node');
|
|
81
|
+
const ort = mod.InferenceSession ? mod : (mod.default || mod);
|
|
82
|
+
if (wasmPaths) {
|
|
83
|
+
try { ort.env.wasm.numThreads = 1; ort.env.wasm.proxy = false; ort.env.wasm.wasmPaths = wasmPaths; } catch { /* optional */ }
|
|
84
|
+
}
|
|
85
|
+
return ort;
|
|
86
|
+
})();
|
|
87
|
+
return _ortPromise;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── download (only when a model isn't already on disk) ───────────────────────────────
|
|
91
|
+
// Custom/BYO STT ids aren't on the dl.chatpanel.net mirror, so — like stt-engine's
|
|
92
|
+
// custom path — fetch straight from Hugging Face. Streamed to a .part file then renamed,
|
|
93
|
+
// so an interrupted download never looks complete. `onProgress({ file, pct })` drives
|
|
94
|
+
// the extension's model-manager UI.
|
|
95
|
+
async function downloadFile(modelId, file, dir, { onProgress, log } = {}) {
|
|
96
|
+
const url = `https://huggingface.co/${modelId}/resolve/main/${encodeURIComponent(file).replace(/%2F/g, '/')}`;
|
|
97
|
+
const res = await fetch(url, { redirect: 'follow' });
|
|
98
|
+
if (!res.ok || !res.body) throw new Error(`fetch ${file} → HTTP ${res.status}`);
|
|
99
|
+
const total = Number(res.headers.get('content-length')) || 0;
|
|
100
|
+
const tmp = join(dir, `${file}.part`);
|
|
101
|
+
const out = createWriteStream(tmp);
|
|
102
|
+
let got = 0, lastPct = -1;
|
|
103
|
+
const src = Readable.fromWeb(res.body);
|
|
104
|
+
src.on('data', (chunk) => {
|
|
105
|
+
got += chunk.length;
|
|
106
|
+
if (total) { const pct = Math.round((got / total) * 100); if (pct !== lastPct) { lastPct = pct; onProgress?.({ file, pct }); } }
|
|
107
|
+
});
|
|
108
|
+
await new Promise((resolve, reject) => {
|
|
109
|
+
src.pipe(out);
|
|
110
|
+
out.on('finish', resolve); out.on('error', reject); src.on('error', reject);
|
|
111
|
+
});
|
|
112
|
+
renameSync(tmp, join(dir, file));
|
|
113
|
+
log?.(`[parakeet] fetched ${file}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function ensureFiles(modelId, dtype, { onProgress, log } = {}) {
|
|
117
|
+
const dir = parakeetDir(modelId);
|
|
118
|
+
mkdirSync(dir, { recursive: true });
|
|
119
|
+
for (const file of filesFor(dtype)) {
|
|
120
|
+
const dest = join(dir, file);
|
|
121
|
+
if (existsSync(dest) && statSync(dest).size > 0) continue;
|
|
122
|
+
log?.(`[parakeet] downloading ${file}…`);
|
|
123
|
+
await downloadFile(modelId, file, dir, { onProgress, log });
|
|
124
|
+
}
|
|
125
|
+
return dir;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── vocab / detokenize ───────────────────────────────────────────────────────────────
|
|
129
|
+
// vocab.txt: "<piece> <id>" per line. SentencePiece marks a word boundary with ▁
|
|
130
|
+
// (U+2581); replace it with a space. The final line "<blk> <id>" is the blank/SOS id.
|
|
131
|
+
function loadVocab(dir) {
|
|
132
|
+
const vocab = [];
|
|
133
|
+
let blank = -1;
|
|
134
|
+
for (const line of readFileSync(join(dir, 'vocab.txt'), 'utf8').split('\n')) {
|
|
135
|
+
if (!line) continue;
|
|
136
|
+
const sp = line.lastIndexOf(' ');
|
|
137
|
+
if (sp < 0) continue;
|
|
138
|
+
const id = parseInt(line.slice(sp + 1), 10);
|
|
139
|
+
const tok = line.slice(0, sp);
|
|
140
|
+
if (!Number.isFinite(id)) continue;
|
|
141
|
+
vocab[id] = tok.replace(/▁/g, ' ');
|
|
142
|
+
if (tok === '<blk>') blank = id;
|
|
143
|
+
}
|
|
144
|
+
if (blank < 0) blank = vocab.length - 1; // fall back to the last id (export convention)
|
|
145
|
+
return { vocab, blank };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function argmax(arr, from, to) {
|
|
149
|
+
let bi = from, bv = arr[from];
|
|
150
|
+
for (let i = from + 1; i < to; i++) if (arr[i] > bv) { bv = arr[i]; bi = i; }
|
|
151
|
+
return bi;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const DURATIONS = 5; // TDT duration head bins → advance 0..4 encoder frames
|
|
155
|
+
const MAX_SYMBOLS = 10; // cap non-blank emissions per frame (anti-runaway)
|
|
156
|
+
|
|
157
|
+
// ── recognizer ───────────────────────────────────────────────────────────────────────
|
|
158
|
+
// Loads the three ONNX sessions once; `transcribe()` is stateless per call (fresh LSTM
|
|
159
|
+
// state), so it's safe to call for every streaming segment. Serialize calls externally
|
|
160
|
+
// (stt-engine already funnels decodes through one chain).
|
|
161
|
+
export async function loadRecognizer({ modelId, dtype = PARAKEET_DEFAULT_DTYPE, allowDownload = true, onProgress, log = () => {} }) {
|
|
162
|
+
const dt = parakeetDtype(dtype);
|
|
163
|
+
const onDisk = parakeetOnDisk(modelId, dt);
|
|
164
|
+
if (!onDisk && !allowDownload) throw new Error('model not on disk and downloads disabled');
|
|
165
|
+
const dir = onDisk ? parakeetDir(modelId) : await ensureFiles(modelId, dt, { onProgress, log });
|
|
166
|
+
|
|
167
|
+
const ort = await getOrt();
|
|
168
|
+
const s = dt === 'fp32' ? '' : '.int8';
|
|
169
|
+
const opts = { executionProviders: ['cpu'], graphOptimizationLevel: 'all', logSeverityLevel: 3 };
|
|
170
|
+
const [prep, encoder, decoder] = await Promise.all([
|
|
171
|
+
ort.InferenceSession.create(join(dir, 'nemo128.onnx'), opts),
|
|
172
|
+
ort.InferenceSession.create(join(dir, `encoder-model${s}.onnx`), opts),
|
|
173
|
+
ort.InferenceSession.create(join(dir, `decoder_joint-model${s}.onnx`), opts),
|
|
174
|
+
]);
|
|
175
|
+
const { vocab, blank } = loadVocab(dir);
|
|
176
|
+
const VOCAB = blank + 1; // token logits span [0, VOCAB); duration logits follow
|
|
177
|
+
const Tensor = ort.Tensor;
|
|
178
|
+
|
|
179
|
+
async function transcribe(float32) {
|
|
180
|
+
if (!(float32 instanceof Float32Array) || float32.length < 400) return '';
|
|
181
|
+
const n = float32.length;
|
|
182
|
+
|
|
183
|
+
// 1) mel features (done in-graph — no manual DSP).
|
|
184
|
+
const pr = await prep.run({
|
|
185
|
+
waveforms: new Tensor('float32', float32, [1, n]),
|
|
186
|
+
waveforms_lens: new Tensor('int64', BigInt64Array.from([BigInt(n)]), [1]),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// 2) FastConformer encoder → outputs [1, D, T'] (channels-first, TIME LAST).
|
|
190
|
+
const er = await encoder.run({ audio_signal: pr.features, length: pr.features_lens });
|
|
191
|
+
const enc = er.outputs;
|
|
192
|
+
const [, D, T] = enc.dims;
|
|
193
|
+
const encLen = Number(er.encoded_lengths.data[0]);
|
|
194
|
+
const ed = enc.data; // element (0,d,t) at index d*T + t
|
|
195
|
+
|
|
196
|
+
// 3) greedy TDT decode. Per encoder frame: run the fused prednet+joint on the
|
|
197
|
+
// previous token + LSTM state; split the logits into token- and duration-heads.
|
|
198
|
+
// Only a NON-BLANK emission appends a token and advances the LSTM state; the
|
|
199
|
+
// duration argmax says how many frames to jump (0..4). duration==0 lets us emit
|
|
200
|
+
// another symbol at the same frame (up to MAX_SYMBOLS) — this is what makes TDT
|
|
201
|
+
// faster than plain RNN-T.
|
|
202
|
+
let h = new Float32Array(2 * 640);
|
|
203
|
+
let c = new Float32Array(2 * 640);
|
|
204
|
+
const tokens = [];
|
|
205
|
+
const frame = new Float32Array(D);
|
|
206
|
+
let t = 0, emitted = 0;
|
|
207
|
+
const guard = encLen * (MAX_SYMBOLS + 1) + 8; // hard stop; the loop always advances, but be safe
|
|
208
|
+
for (let iter = 0; t < encLen && iter < guard; iter++) {
|
|
209
|
+
for (let d = 0; d < D; d++) frame[d] = ed[d * T + t];
|
|
210
|
+
const prev = tokens.length ? tokens[tokens.length - 1] : blank;
|
|
211
|
+
const out = await decoder.run({
|
|
212
|
+
encoder_outputs: new Tensor('float32', frame, [1, D, 1]),
|
|
213
|
+
targets: new Tensor('int32', Int32Array.from([prev]), [1, 1]),
|
|
214
|
+
target_length: new Tensor('int32', Int32Array.from([1]), [1]),
|
|
215
|
+
input_states_1: new Tensor('float32', h, [2, 1, 640]),
|
|
216
|
+
input_states_2: new Tensor('float32', c, [2, 1, 640]),
|
|
217
|
+
});
|
|
218
|
+
const logits = out.outputs.data;
|
|
219
|
+
const tok = argmax(logits, 0, VOCAB);
|
|
220
|
+
const durIdx = argmax(logits, VOCAB, VOCAB + DURATIONS) - VOCAB; // 0..4
|
|
221
|
+
if (tok !== blank) {
|
|
222
|
+
h = out.output_states_1.data; c = out.output_states_2.data; // advance state only on emit
|
|
223
|
+
tokens.push(tok);
|
|
224
|
+
emitted++;
|
|
225
|
+
}
|
|
226
|
+
if (durIdx > 0) { t += durIdx; emitted = 0; }
|
|
227
|
+
else if (tok === blank || emitted >= MAX_SYMBOLS) { t += 1; emitted = 0; }
|
|
228
|
+
// else duration==0 & non-blank & under cap → stay on this frame, emit again
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let text = '';
|
|
232
|
+
for (const id of tokens) text += vocab[id] ?? '';
|
|
233
|
+
return text.replace(/^\s+/, '').replace(/\s+/g, ' ').trimEnd();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function dispose() {
|
|
237
|
+
for (const sess of [prep, encoder, decoder]) { try { sess.release?.(); } catch { /* ignore */ } }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { transcribe, dispose, dtype: dt, dir };
|
|
241
|
+
}
|
package/src/router.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// baseUrl, protocol, (api) where to forward + 'openai'|'anthropic'
|
|
11
11
|
// models: [..], models this destination serves (for /v1/models)
|
|
12
12
|
// }
|
|
13
|
+
|
|
14
|
+
import { assertEndpointUrl } from '@chatpanel/pii';
|
|
13
15
|
//
|
|
14
16
|
// /v1/models aggregates every destination's models so clients can discover them.
|
|
15
17
|
|
|
@@ -87,7 +89,8 @@ export async function aggregateModelsAsync(cfg, { timeoutMs = 4000 } = {}) {
|
|
|
87
89
|
if (d.protocol === 'anthropic') { headers['x-api-key'] = d.apiKey; headers['anthropic-version'] = '2023-06-01'; }
|
|
88
90
|
else headers.authorization = `Bearer ${d.apiKey}`;
|
|
89
91
|
}
|
|
90
|
-
const
|
|
92
|
+
const modelsUrl = assertEndpointUrl(`${d.baseUrl.replace(/\/$/, '')}/models`).toString(); // SSRF guard (skips a blocked dest via the catch)
|
|
93
|
+
const res = await fetch(modelsUrl, { headers, signal: ctrl.signal });
|
|
91
94
|
if (!res.ok) return;
|
|
92
95
|
const j = await res.json();
|
|
93
96
|
const list = Array.isArray(j?.data) ? j.data : (Array.isArray(j?.models) ? j.models : []);
|
package/src/server.js
CHANGED
|
@@ -22,7 +22,7 @@ import { loadConfig } from './config.js';
|
|
|
22
22
|
import { startEntitlementRefresh, maybeRevalidate } from './entitlement-refresh.js';
|
|
23
23
|
import { redactSegments, segment } from './redact.js';
|
|
24
24
|
import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer } from './stream.js';
|
|
25
|
-
import { restoreText, gatedDictionary, narrowSpecs, makeToolHarness, placeholderToolNote } from '@chatpanel/pii';
|
|
25
|
+
import { restoreText, gatedDictionary, narrowSpecs, makeToolHarness, placeholderToolNote, assertEndpointUrl } from '@chatpanel/pii';
|
|
26
26
|
import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
|
|
27
27
|
import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
|
|
28
28
|
import { shaperFor } from './shape.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.29';
|
|
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.
|
|
@@ -255,7 +255,8 @@ async function probeNerHealth(cfg) {
|
|
|
255
255
|
const url = nerBaseUrl(cfg);
|
|
256
256
|
if (!url) return { configured: false, ok: false, url: null, model: null };
|
|
257
257
|
try {
|
|
258
|
-
const
|
|
258
|
+
const healthUrl = assertEndpointUrl(url.replace(/\/ner\/?$/, '') + '/health').toString();
|
|
259
|
+
const r = await fetch(healthUrl, { signal: AbortSignal.timeout(2000) });
|
|
259
260
|
if (!r.ok) return { configured: true, ok: false, url, model: null };
|
|
260
261
|
const j = await r.json().catch(() => ({}));
|
|
261
262
|
return { configured: true, ok: true, url, model: j.model || null };
|
|
@@ -425,7 +426,11 @@ async function handleApi(req, res, { adapter, kind, pathname, search, base, dest
|
|
|
425
426
|
if (destProtocol === 'anthropic') { headers['x-api-key'] = destKey; delete headers.authorization; }
|
|
426
427
|
else { headers.authorization = `Bearer ${destKey}`; }
|
|
427
428
|
}
|
|
428
|
-
|
|
429
|
+
// SSRF guard on the config-supplied upstream: block cloud-metadata + non-http(s)
|
|
430
|
+
// BEFORE the fetch. Loopback/LAN stay allowed (Ollama/LM Studio/homelab are the
|
|
431
|
+
// point of a BYO gateway); only the credential-theft pivot is refused.
|
|
432
|
+
const upstreamUrl = assertEndpointUrl(base.replace(/\/$/, '') + pathname + search).toString();
|
|
433
|
+
upstream = await fetch(upstreamUrl, {
|
|
429
434
|
method: req.method,
|
|
430
435
|
headers,
|
|
431
436
|
body: ['GET', 'HEAD'].includes(req.method) ? undefined : outBody,
|
|
@@ -621,6 +626,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
621
626
|
if (!url) return sendJson(res, 503, { error: { message: 'NER not configured — deterministic-only redaction', type: 'ner_off' } });
|
|
622
627
|
try {
|
|
623
628
|
const body = await readBody(req, cfg.maxBodyBytes);
|
|
629
|
+
assertEndpointUrl(url); // block metadata/non-http(s) before POSTing raw text to the detector
|
|
624
630
|
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: AbortSignal.timeout(8000) });
|
|
625
631
|
const text = await r.text();
|
|
626
632
|
res.writeHead(r.status, { 'content-type': 'application/json' });
|
package/src/stt-engine.js
CHANGED
|
@@ -20,7 +20,8 @@ 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, sttModelDtype, isKnownSttModel } from './stt-models.js';
|
|
23
|
+
import { DEFAULT_STT_MODEL, isEnglishOnly, sttModelDtype, isKnownSttModel, sttModelEngine } from './stt-models.js';
|
|
24
|
+
import * as parakeet from './parakeet-engine.js';
|
|
24
25
|
import * as diarize from './diarize-engine.js';
|
|
25
26
|
|
|
26
27
|
export const SAMPLE_RATE = 16000; // fixed wire contract: 16 kHz mono Float32 PCM
|
|
@@ -65,6 +66,8 @@ const DTYPE_SUFFIX = {
|
|
|
65
66
|
};
|
|
66
67
|
|
|
67
68
|
export function modelOnDisk(modelId = _model || DEFAULT_STT_MODEL, dtype = sttModelDtype(modelId) || runtimeDtype()) {
|
|
69
|
+
// Transducer models (parakeet) have a different file layout + engine — delegate.
|
|
70
|
+
if (sttModelEngine(modelId) === 'parakeet-tdt') return parakeet.parakeetOnDisk(modelId, parakeet.parakeetDtype(dtype));
|
|
68
71
|
const dir = join(modelRoot(), ...modelId.split('/'), 'onnx');
|
|
69
72
|
if (!existsSync(dir)) return false;
|
|
70
73
|
const suffix = DTYPE_SUFFIX[dtype] ?? '';
|
|
@@ -87,6 +90,9 @@ export function health() {
|
|
|
87
90
|
// and a failed SWITCH keeps the previous working pipeline.
|
|
88
91
|
/** @param {string} modelId @param {{ log?: (m: string) => void, allowDownload?: boolean, dtype?: string }} [opts] */
|
|
89
92
|
async function loadModel(modelId, { log = () => {}, allowDownload = true, dtype: dtypeOverride = null } = {}) {
|
|
93
|
+
// Transducer models aren't whisper pipelines — hand off to the parakeet engine.
|
|
94
|
+
if (sttModelEngine(modelId) === 'parakeet-tdt') return loadParakeet(modelId, { log, allowDownload, dtype: dtypeOverride });
|
|
95
|
+
|
|
90
96
|
const prevPipe = _pipe;
|
|
91
97
|
const prevModel = _model;
|
|
92
98
|
let lib;
|
|
@@ -160,6 +166,52 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true, dtype:
|
|
|
160
166
|
}
|
|
161
167
|
}
|
|
162
168
|
|
|
169
|
+
// Load a Parakeet TDT (transducer) model via parakeet-engine.js (raw onnxruntime), and
|
|
170
|
+
// expose it to the session layer as a whisper-shaped `_pipe(audio) → { text }` adapter,
|
|
171
|
+
// so decodeSession/streaming/redaction/diarization all work unchanged. Fail-open and
|
|
172
|
+
// keep-previous-on-switch-failure, exactly like the whisper path above.
|
|
173
|
+
/** @param {string} modelId @param {{ log?: (m: string) => void, allowDownload?: boolean, dtype?: string|null }} [opts] */
|
|
174
|
+
async function loadParakeet(modelId, { log = () => {}, allowDownload = true, dtype: dtypeOverride = null } = {}) {
|
|
175
|
+
const prevPipe = _pipe;
|
|
176
|
+
const prevModel = _model;
|
|
177
|
+
const dtype = parakeet.parakeetDtype(dtypeOverride && dtypeOverride !== 'auto' ? dtypeOverride : PARAKEET_RUNTIME_DTYPE());
|
|
178
|
+
const haveLocal = parakeet.parakeetOnDisk(modelId, dtype);
|
|
179
|
+
if (!haveLocal && !allowDownload) {
|
|
180
|
+
_state = 'error'; _err = 'model not on disk and downloads disabled';
|
|
181
|
+
log(`[stt] model ${modelId} not installed and downloads disabled`);
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
_state = haveLocal ? 'loading' : 'downloading';
|
|
185
|
+
if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[stt] downloading model ${modelId} (one-time, from Hugging Face)…`); }
|
|
186
|
+
try {
|
|
187
|
+
const rec = await parakeet.loadRecognizer({
|
|
188
|
+
modelId, dtype, allowDownload, log,
|
|
189
|
+
onProgress: (p) => { _progress = { model: modelId, file: p.file || null, pct: typeof p.pct === 'number' ? p.pct : (_progress?.pct ?? 0) }; },
|
|
190
|
+
});
|
|
191
|
+
// whisper-shaped adapter: ignores the whisper `{ language, task }` opts (parakeet
|
|
192
|
+
// auto-detects language) and returns { text }. `__parakeet` flags the language-ID
|
|
193
|
+
// short-circuit; `dispose` frees the ORT sessions on switch.
|
|
194
|
+
const adapter = async (audio) => ({ text: await rec.transcribe(audio) });
|
|
195
|
+
adapter.__parakeet = true;
|
|
196
|
+
adapter.dispose = () => rec.dispose();
|
|
197
|
+
_pipe = adapter; _model = modelId; _state = 'ready'; _err = null; _progress = null; _dtype = dtype;
|
|
198
|
+
if (prevPipe && prevPipe !== adapter) { try { await prevPipe.dispose?.(); } catch { /* ignore */ } }
|
|
199
|
+
log(`[stt] ready — model ${modelId} @ ${dtype} (parakeet-tdt, in-process, offline) — local dictation active`);
|
|
200
|
+
return true;
|
|
201
|
+
} catch (e) {
|
|
202
|
+
_err = e.message; _progress = null;
|
|
203
|
+
if (prevPipe) { _pipe = prevPipe; _model = prevModel; _state = 'ready'; }
|
|
204
|
+
else { _state = 'error'; }
|
|
205
|
+
log(`[stt] model load failed (${e.message})${prevPipe ? ' — keeping previous model' : ''}`);
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Parakeet only ships int8 + fp32 exports (no whisper-style q8). int8 loads and runs on
|
|
211
|
+
// BOTH runtimes; only force fp32 if a caller explicitly asks. Independent of the whisper
|
|
212
|
+
// runtimeDtype (which returns q8/fp32).
|
|
213
|
+
function PARAKEET_RUNTIME_DTYPE() { return parakeet.PARAKEET_DEFAULT_DTYPE; }
|
|
214
|
+
|
|
163
215
|
// Load the configured model once, on FIRST USE (never at gateway startup — the
|
|
164
216
|
// download is deferred until someone actually dictates). Single-flight.
|
|
165
217
|
export function init(cfg = {}) {
|
|
@@ -174,8 +226,11 @@ export function init(cfg = {}) {
|
|
|
174
226
|
export async function setModel(modelId, opts = {}) {
|
|
175
227
|
const log = typeof opts.onLog === 'function' ? opts.onLog : () => {};
|
|
176
228
|
if (!modelId) return false;
|
|
177
|
-
// Re-load if the model OR the requested precision changed.
|
|
178
|
-
|
|
229
|
+
// Re-load if the model OR the requested precision changed. Parakeet has its own
|
|
230
|
+
// dtype domain (int8/fp32), independent of whisper's q8/fp32 runtimeDtype.
|
|
231
|
+
const wantDtype = sttModelEngine(modelId) === 'parakeet-tdt'
|
|
232
|
+
? parakeet.parakeetDtype(opts.dtype && opts.dtype !== 'auto' ? opts.dtype : parakeet.PARAKEET_DEFAULT_DTYPE)
|
|
233
|
+
: (opts.dtype && opts.dtype !== 'auto' ? opts.dtype : (sttModelDtype(modelId) || runtimeDtype()));
|
|
179
234
|
if (modelId === _model && isReady() && _dtype === wantDtype) return true;
|
|
180
235
|
return loadModel(modelId, { log, allowDownload: opts.allowDownload !== false, dtype: opts.dtype });
|
|
181
236
|
}
|
|
@@ -348,7 +403,7 @@ async function decodeSession(s, { flush = false } = {}) {
|
|
|
348
403
|
|
|
349
404
|
// Auto-detect the spoken language on the session's first voiced audio (≥1s),
|
|
350
405
|
// then pin it. An explicit client `lang` wins; `.en` models skip all of this.
|
|
351
|
-
if (!s.lang && !s.langTried && !isEnglishOnly(_model) && audio.length >= SAMPLE_RATE) {
|
|
406
|
+
if (!s.lang && !s.langTried && !isEnglishOnly(_model) && !_pipe?.__parakeet && audio.length >= SAMPLE_RATE) {
|
|
352
407
|
s.langTried = true;
|
|
353
408
|
const detected = await detectLanguage(audio);
|
|
354
409
|
if (detected) { s.lang = detected; emit(s, { type: 'language', lang: detected }); }
|
package/src/stt-models.js
CHANGED
|
@@ -53,8 +53,35 @@ export const STT_MODEL_CATALOG = [
|
|
|
53
53
|
ramMB: 3200,
|
|
54
54
|
note: 'Best accuracy. For powerful machines; use the native (npm) gateway for speed.',
|
|
55
55
|
},
|
|
56
|
+
{
|
|
57
|
+
// NOT a whisper/seq2seq model — a NeMo TDT transducer. It doesn't run through the
|
|
58
|
+
// transformers.js ASR pipeline; the STT engine routes `engine: 'parakeet-tdt'`
|
|
59
|
+
// models to parakeet-engine.js (raw onnxruntime + a greedy TDT decode). Multilingual
|
|
60
|
+
// (25 European languages, auto-detected) and ~35× realtime at int8 on the native
|
|
61
|
+
// (npm) gateway — the fast local-dictation path. WASM runs it but slower.
|
|
62
|
+
id: 'istupakov/parakeet-tdt-0.6b-v3-onnx',
|
|
63
|
+
label: 'Parakeet TDT 0.6B v3 (multilingual, fast)',
|
|
64
|
+
lang: '25 European languages (auto-detected)',
|
|
65
|
+
tier: 'accurate',
|
|
66
|
+
engine: 'parakeet-tdt',
|
|
67
|
+
recommended: true, // our default recommendation: faster + more accurate than Whisper.
|
|
68
|
+
approxMB: 690, // int8: encoder 652 + decoder_joint 18 + preprocessor
|
|
69
|
+
ramMB: 1600,
|
|
70
|
+
note: 'Recommended — NVIDIA Parakeet transducer. Several× faster than Whisper at similar or better accuracy, English + 24 EU languages. One-time download; best on the native (npm) gateway.',
|
|
71
|
+
},
|
|
56
72
|
];
|
|
57
73
|
|
|
74
|
+
// The model we steer users to (a bigger, on-demand download — NOT the boot default,
|
|
75
|
+
// which stays a small model so first dictation works instantly). The settings UI
|
|
76
|
+
// surfaces this so users can install it after the gateway is running.
|
|
77
|
+
export const RECOMMENDED_STT_MODEL = 'istupakov/parakeet-tdt-0.6b-v3-onnx';
|
|
78
|
+
|
|
79
|
+
// STT engine backing a model. Default 'whisper' = the transformers.js ASR pipeline;
|
|
80
|
+
// 'parakeet-tdt' = the raw-onnxruntime transducer engine (parakeet-engine.js).
|
|
81
|
+
export function sttModelEngine(id) {
|
|
82
|
+
return sttModel(id)?.engine || 'whisper';
|
|
83
|
+
}
|
|
84
|
+
|
|
58
85
|
export function isKnownSttModel(id) {
|
|
59
86
|
return STT_MODEL_CATALOG.some((m) => m.id === id);
|
|
60
87
|
}
|