@chatpanel/gateway 0.6.28 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.28",
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.11",
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
  },
@@ -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
- mkdirSync(dirname(path), { recursive: true });
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(path, JSON.stringify(out, null, 2));
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/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
- // Download models from ChatPanel's own CDN (a branded, edge-cached proxy we
25
- // control) rather than directly from Hugging Face — so a clean install depends only
26
- // on chatpanel.net. Override with CHATPANEL_MODEL_BASE_URL (e.g. point at HF for
27
- // dev, or an air-gapped mirror). Must end with '/' (transformers appends the model
28
- // path template to it).
29
- const MODEL_HOST = (process.env.CHATPANEL_MODEL_BASE_URL || 'https://dl.chatpanel.net/models/').replace(/\/*$/, '/');
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 res = await fetch(`${d.baseUrl.replace(/\/$/, '')}/models`, { headers, signal: ctrl.signal });
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.28';
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 r = await fetch(url.replace(/\/ner\/?$/, '') + '/health', { signal: AbortSignal.timeout(2000) });
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
- upstream = await fetch(base.replace(/\/$/, '') + pathname + search, {
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
- const wantDtype = opts.dtype && opts.dtype !== 'auto' ? opts.dtype : (sttModelDtype(modelId) || runtimeDtype());
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
  }