@chatpanel/gateway 0.6.19 → 0.6.20

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.19",
3
+ "version": "0.6.20",
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/models.js CHANGED
@@ -36,3 +36,12 @@ export const MODEL_CATALOG = [
36
36
  export function isKnownModel(id) {
37
37
  return MODEL_CATALOG.some((m) => m.id === id);
38
38
  }
39
+
40
+ // Accept a user-supplied ("bring your own") NER model id. STRICT `org/name` shape,
41
+ // no path traversal. We can't verify it's token-classification from the id alone —
42
+ // the engine fails open if the download/labels don't fit. Custom ids fetch from
43
+ // Hugging Face directly (the engine points remoteHost there for the custom load).
44
+ export function isValidCustomModelId(id) {
45
+ const s = String(id || '');
46
+ return /^[A-Za-z0-9][\w.-]*\/[\w.-]+$/.test(s) && !s.includes('..');
47
+ }
package/src/ner-engine.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import os from 'node:os';
18
18
  import { join } from 'node:path';
19
19
  import { existsSync, mkdirSync } from 'node:fs';
20
+ import { isKnownModel } from './models.js';
20
21
 
21
22
  const DEFAULT_MODEL = 'Xenova/bert-base-NER';
22
23
 
@@ -199,8 +200,15 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
199
200
  return false;
200
201
  }
201
202
 
203
+ // Curated catalog models come from the private dl.chatpanel.net mirror
204
+ // (ensureLib set that as remoteHost). A user's BYO id isn't mirrored, so fetch
205
+ // it from Hugging Face directly — only for this load, then restore.
206
+ const prevHost = lib.env.remoteHost;
207
+ const isCustom = !isKnownModel(modelId);
208
+ if (!haveLocal && isCustom) { try { lib.env.remoteHost = 'https://huggingface.co/'; } catch { /* optional */ } }
209
+
202
210
  _state = haveLocal ? 'loading' : 'downloading';
203
- if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[ner] downloading model ${modelId} (one-time)…`); }
211
+ if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[ner] downloading model ${modelId} (one-time${isCustom ? ', from Hugging Face' : ''})…`); }
204
212
 
205
213
  try {
206
214
  const pipe = await lib.pipeline('token-classification', modelId, {
@@ -227,6 +235,8 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
227
235
  else { _state = 'error'; }
228
236
  log(`[ner] model load failed (${e.message})${prevPipe ? ' — keeping previous model' : ' — deterministic-only'}`);
229
237
  return false;
238
+ } finally {
239
+ try { lib.env.remoteHost = prevHost; } catch { /* optional */ } // restore the mirror
230
240
  }
231
241
  }
232
242
 
package/src/server.js CHANGED
@@ -33,7 +33,7 @@ import { createHistoryStore } from './sqlite-store.js';
33
33
  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
- import { MODEL_CATALOG, isKnownModel } from './models.js';
36
+ import { MODEL_CATALOG, isKnownModel, isValidCustomModelId } from './models.js';
37
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';
@@ -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.19';
45
+ export const VERSION = '0.6.20';
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.
@@ -632,9 +632,14 @@ export function createGateway(cfg = loadConfig()) {
632
632
  // model (downloading it first if needed) and persists the choice.
633
633
  if (pathname === '/ner/models') {
634
634
  if (req.method === 'GET') {
635
- const available = MODEL_CATALOG.map((m) => ({ ...m, installed: nerEngine.modelOnDisk(m.id) }));
635
+ const active = nerEngine.health().model || cfg.ner?.model || null;
636
+ const available = /** @type {any[]} */ (MODEL_CATALOG.map((m) => ({ ...m, installed: nerEngine.modelOnDisk(m.id) })));
637
+ // Surface an active BYO (non-catalog) model so the UI shows it too.
638
+ if (active && !available.some((m) => m.id === active)) {
639
+ available.push({ id: active, label: active, lang: '—', custom: true, installed: nerEngine.modelOnDisk(active), note: 'Custom model (from Hugging Face).' });
640
+ }
636
641
  return sendJson(res, 200, {
637
- active: nerEngine.health().model || cfg.ner?.model || null,
642
+ active,
638
643
  state: nerEngine.state(),
639
644
  progress: nerEngine.progress(),
640
645
  available,
@@ -643,8 +648,9 @@ export function createGateway(cfg = loadConfig()) {
643
648
  if (req.method === 'POST') {
644
649
  let body = null;
645
650
  try { body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')); } catch { body = null; }
646
- const id = body && typeof body.id === 'string' ? body.id : null;
647
- if (!id || !isKnownModel(id)) return sendJson(res, 400, { error: { message: 'unknown model id', type: 'bad_model' } });
651
+ const id = body && typeof body.id === 'string' ? body.id.trim() : null;
652
+ // Curated catalog OR a strictly-validated BYO id (org/name, from HF).
653
+ if (!id || !(isKnownModel(id) || isValidCustomModelId(id))) return sendJson(res, 400, { error: { message: 'unknown or invalid model id', type: 'bad_model' } });
648
654
  // Persist first so a restart keeps the choice, then (re)load. Don't block the
649
655
  // response on a possibly-long download — the client polls GET for progress.
650
656
  if (cfg.ner) cfg.ner.model = id; else cfg.ner = { autostart: true, model: id, allowDownload: true, enableFullTier: true };
package/src/stt-engine.js CHANGED
@@ -44,6 +44,7 @@ let _pipe = null; // the loaded automatic-speech-recognition pipeline
44
44
  let _err = null; // last error message (for /health)
45
45
  let _initPromise = null; // single-flight init
46
46
  let _progress = null; // { model, file, pct } while downloading, else null
47
+ let _dtype = null; // the quantization actually loaded (fp32 on WASM, q8 native)
47
48
 
48
49
  // transformers.js dtype → the ONNX filename suffix it loads. Presence must check
49
50
  // the EXACT file the current runtime will fetch — otherwise a q8 install (native)
@@ -70,7 +71,7 @@ export function isReady() { return _state === 'ready' && !!_pipe; }
70
71
  export function progress() { return _progress; }
71
72
 
72
73
  export function health() {
73
- return { configured: _state !== 'off', ok: isReady(), state: _state, model: _model, error: _err };
74
+ return { configured: _state !== 'off', ok: isReady(), state: _state, model: _model, dtype: _dtype, error: _err };
74
75
  }
75
76
 
76
77
  // (Re)load a model into _pipe. Same contract as ner-engine.loadModel: fail-open,
@@ -121,9 +122,9 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
121
122
  }
122
123
  },
123
124
  });
124
- _pipe = pipe; _model = modelId; _state = 'ready'; _err = null; _progress = null;
125
+ _pipe = pipe; _model = modelId; _state = 'ready'; _err = null; _progress = null; _dtype = dtype;
125
126
  if (prevPipe && prevPipe !== pipe) { try { await prevPipe.dispose?.(); } catch { /* ignore */ } }
126
- log(`[stt] ready — model ${modelId} (in-process, offline) — local dictation active`);
127
+ log(`[stt] ready — model ${modelId} @ ${dtype} (in-process, offline) — local dictation active`);
127
128
  return true;
128
129
  } catch (e) {
129
130
  _err = e.message; _progress = null;