@chatpanel/gateway 0.6.71 → 0.6.72
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/ner.js +57 -0
- package/src/server.js +94 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.72",
|
|
4
4
|
"description": "Local privacy gateway \u2014 redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/ner.js
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
// deterministic-only redaction. redact.js consults the engine directly, so we do
|
|
9
9
|
// NOT mutate cfg.redaction.detection here (that field is reserved for a user's own
|
|
10
10
|
// external detector, which takes precedence — see below).
|
|
11
|
+
//
|
|
12
|
+
// `ensureNer` (bottom) is the same thing on demand, for the case autostart cannot cover:
|
|
13
|
+
// a config that says autostart:false, weights already on disk, and a client asking for
|
|
14
|
+
// detection right now.
|
|
11
15
|
|
|
12
16
|
import * as engine from './ner-engine.js';
|
|
13
17
|
import { persistConfig, configPath } from './configstore.js';
|
|
@@ -54,3 +58,56 @@ export function startNer(cfg) {
|
|
|
54
58
|
// Nothing to kill (no child process); just stop logging after shutdown.
|
|
55
59
|
return { stop() { stopped = true; } };
|
|
56
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* START THE BUNDLED DETECTOR BECAUSE SOMEONE ASKED FOR DETECTION.
|
|
64
|
+
*
|
|
65
|
+
* `startNer` only runs at boot, and only when `ner.autostart` is on. Everything after that
|
|
66
|
+
* assumed the engine was either running or deliberately not wanted — so a config carrying
|
|
67
|
+
* `autostart:false` (older gateways wrote it, and it survives every upgrade) left POST /ner
|
|
68
|
+
* answering 503 forever, with the weights sitting on disk the whole time. The extension's
|
|
69
|
+
* composer showed "name detection is not answering", and every real turn quietly fell back
|
|
70
|
+
* to deterministic-only redaction: names, organisations and places went to the model in full.
|
|
71
|
+
*
|
|
72
|
+
* A request for entity detection IS the intent to detect, so this starts the engine on that
|
|
73
|
+
* request instead of waiting for a restart the user has no reason to perform.
|
|
74
|
+
*
|
|
75
|
+
* Two limits keep it from being a surprise. It never DOWNLOADS: weights arrive through the
|
|
76
|
+
* model manager, which shows progress, so a preview keystroke can never kick off a hundred
|
|
77
|
+
* megabytes. And it defers to a user's own external detector exactly as `startNer` does —
|
|
78
|
+
* that field means "use mine", not "use mine if it happens to be up".
|
|
79
|
+
*
|
|
80
|
+
* Returns the engine state after the attempt; never throws.
|
|
81
|
+
*/
|
|
82
|
+
export async function ensureNer(cfg, { log = (m) => console.log(m) } = {}) {
|
|
83
|
+
const det = cfg?.redaction?.detection;
|
|
84
|
+
if (det && det.backend && det.backend !== 'off') return 'external';
|
|
85
|
+
|
|
86
|
+
const st = engine.state();
|
|
87
|
+
if (st === 'ready') return st;
|
|
88
|
+
// A load already in flight (or one that already failed) — join it rather than starting a
|
|
89
|
+
// second one. engine.init() is single-flight, so this is just the wait.
|
|
90
|
+
if (st === 'loading' || st === 'downloading' || st === 'error') {
|
|
91
|
+
try { await engine.init(); } catch { /* fail-open: deterministic-only */ }
|
|
92
|
+
return engine.state();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const n = cfg?.ner || {};
|
|
96
|
+
const model = n.model || undefined;
|
|
97
|
+
if (!engine.modelOnDisk(model)) return 'not-downloaded';
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await engine.init({ model, allowDownload: false, onLog: log });
|
|
101
|
+
} catch (e) {
|
|
102
|
+
log(`[ner] on-demand load failed (${e.message}) — deterministic-only`);
|
|
103
|
+
return engine.state();
|
|
104
|
+
}
|
|
105
|
+
if (engine.isReady()) {
|
|
106
|
+
log(`[ner] started on demand — model ${engine.health().model} — entity detection active`);
|
|
107
|
+
if (n.enableFullTier !== false && cfg.redaction && cfg.redaction.tier !== 'full') {
|
|
108
|
+
cfg.redaction.tier = 'full';
|
|
109
|
+
log('[ner] full tier on — name/org redaction active');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return engine.state();
|
|
113
|
+
}
|
package/src/server.js
CHANGED
|
@@ -29,7 +29,7 @@ import { secureFetch } from './secure-fetch.js';
|
|
|
29
29
|
import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
|
|
30
30
|
import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
|
|
31
31
|
import { shaperFor } from './shape.js';
|
|
32
|
-
import { startNer } from './ner.js';
|
|
32
|
+
import { startNer, ensureNer } from './ner.js';
|
|
33
33
|
import { installTimestampedConsole } from './log.js';
|
|
34
34
|
import { saveBackupSecret, clearBackupSecret, loadBackupSecret, hasBackupSecret } from './history-store.js';
|
|
35
35
|
import { createMemoryStore } from './memory-store.js';
|
|
@@ -56,7 +56,7 @@ import * as openai from './openai.js';
|
|
|
56
56
|
import * as responses from './responses.js';
|
|
57
57
|
import * as anthropic from './anthropic.js';
|
|
58
58
|
|
|
59
|
-
export const VERSION = '0.6.
|
|
59
|
+
export const VERSION = '0.6.72';
|
|
60
60
|
|
|
61
61
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
62
62
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -281,6 +281,43 @@ function nerBaseUrl(cfg) {
|
|
|
281
281
|
return url;
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* CAN THIS GATEWAY REDACT NAMES RIGHT NOW — the question every privacy UI has to answer
|
|
286
|
+
* before it draws a shield.
|
|
287
|
+
*
|
|
288
|
+
* Deterministic patterns (emails, phones, cards, keys) always run, so a redaction with no
|
|
289
|
+
* detector behind it produces text that LOOKS redacted. `coverage` names the difference:
|
|
290
|
+
* 'names' means people, organisations and places are covered too; 'patterns' means they are
|
|
291
|
+
* not, and the client is expected to say so rather than let the shield imply it.
|
|
292
|
+
*
|
|
293
|
+
* Synchronous on purpose — it reports the engine's own state and never probes the network,
|
|
294
|
+
* so it can sit on a per-keystroke route.
|
|
295
|
+
*/
|
|
296
|
+
function detectorStatus(cfg) {
|
|
297
|
+
const det = cfg?.redaction?.detection;
|
|
298
|
+
const tier = cfg?.redaction?.tier === 'full' ? 'full' : 'basic';
|
|
299
|
+
if (det && det.backend && det.backend !== 'off') {
|
|
300
|
+
// A user's own detector: we cannot know it is up without sending it text, and a probe
|
|
301
|
+
// per keystroke is not a trade worth making. Report it as configured and let the turn
|
|
302
|
+
// itself be the test.
|
|
303
|
+
return { source: 'external', backend: det.backend, state: 'external', model: det.model || null, ready: true, tier, coverage: tier === 'full' ? 'names' : 'patterns' };
|
|
304
|
+
}
|
|
305
|
+
const h = nerEngine.health();
|
|
306
|
+
const ready = h.ok && tier === 'full';
|
|
307
|
+
return {
|
|
308
|
+
source: 'bundled',
|
|
309
|
+
state: h.state, // 'off' | 'loading' | 'downloading' | 'ready' | 'error'
|
|
310
|
+
model: h.model || cfg?.ner?.model || null,
|
|
311
|
+
// Weights on disk with the engine off is the recoverable case (it starts on the next
|
|
312
|
+
// request); no weights is the case that needs the user to install a model.
|
|
313
|
+
installed: nerEngine.modelOnDisk(cfg?.ner?.model || undefined),
|
|
314
|
+
error: h.error || null,
|
|
315
|
+
ready,
|
|
316
|
+
tier,
|
|
317
|
+
coverage: ready ? 'names' : 'patterns',
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
284
321
|
// Health of the detector for /status. The bundled IN-PROCESS engine takes
|
|
285
322
|
// precedence; its public contract URL is the gateway's own /ner (no second port).
|
|
286
323
|
// A user-configured external detector is probed over HTTP as before.
|
|
@@ -690,6 +727,9 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
690
727
|
model: health.model, // e.g. "en_core_web_sm"
|
|
691
728
|
url: health.url,
|
|
692
729
|
},
|
|
730
|
+
// Additive: one block that answers "are names covered right now", for clients that
|
|
731
|
+
// draw a privacy indicator and must not overstate it. See detectorStatus.
|
|
732
|
+
detector: detectorStatus(cfg),
|
|
693
733
|
pro: { unlocked: proUnlocked }, usage: usage(cfg),
|
|
694
734
|
uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
|
|
695
735
|
});
|
|
@@ -928,8 +968,30 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
928
968
|
return sendJson(res, health.ok ? 200 : 503, health);
|
|
929
969
|
}
|
|
930
970
|
if (req.method === 'POST') {
|
|
971
|
+
// A POST here IS the request to detect, so start the bundled engine if it is not
|
|
972
|
+
// running and its weights are already on disk (see ensureNer — it never downloads).
|
|
973
|
+
// Without this a config with `ner.autostart:false` answered 503 until a restart,
|
|
974
|
+
// and the client read that as "redaction is broken" while the model sat unused.
|
|
975
|
+
await ensureNer(cfg);
|
|
931
976
|
// In-process engine path.
|
|
932
977
|
if (nerEngine.state() !== 'off') {
|
|
978
|
+
// NOT READY IS NOT AN ANSWER. `detect()` returns [] when the pipeline is still
|
|
979
|
+
// loading or failed to load, and 200 {entities:[]} is indistinguishable from
|
|
980
|
+
// "this text contains no names" — the one lie a privacy layer must never tell.
|
|
981
|
+
// Say which state it is in instead, and let the caller show "starting…".
|
|
982
|
+
if (!nerEngine.isReady()) {
|
|
983
|
+
const h = nerEngine.health();
|
|
984
|
+
return sendJson(res, 503, {
|
|
985
|
+
error: {
|
|
986
|
+
message: h.state === 'error'
|
|
987
|
+
? `NER model failed to load: ${h.error || 'unknown error'}`
|
|
988
|
+
: `NER model is ${h.state} — not ready yet`,
|
|
989
|
+
type: h.state === 'error' ? 'ner_error' : 'ner_loading',
|
|
990
|
+
},
|
|
991
|
+
state: h.state,
|
|
992
|
+
model: h.model,
|
|
993
|
+
});
|
|
994
|
+
}
|
|
933
995
|
try {
|
|
934
996
|
const body = await readBody(req, cfg.maxBodyBytes);
|
|
935
997
|
let text = '';
|
|
@@ -942,7 +1004,22 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
942
1004
|
}
|
|
943
1005
|
// External detector proxy (user-configured endpoint).
|
|
944
1006
|
const url = nerBaseUrl(cfg);
|
|
945
|
-
if (!url)
|
|
1007
|
+
if (!url) {
|
|
1008
|
+
// The bundled engine is the intended detector here and it is not running. Say
|
|
1009
|
+
// WHY — "not configured" sent a user hunting through settings that were already
|
|
1010
|
+
// correct, when the real answer was that the model was never downloaded.
|
|
1011
|
+
const onDisk = nerEngine.modelOnDisk(cfg.ner?.model || undefined);
|
|
1012
|
+
return sendJson(res, 503, {
|
|
1013
|
+
error: {
|
|
1014
|
+
message: onDisk
|
|
1015
|
+
? 'the bundled detector could not start — deterministic-only redaction'
|
|
1016
|
+
: 'no entity detector is installed — deterministic-only redaction (install one from Gateway settings)',
|
|
1017
|
+
type: onDisk ? 'ner_error' : 'ner_not_installed',
|
|
1018
|
+
},
|
|
1019
|
+
state: nerEngine.state(),
|
|
1020
|
+
model: cfg.ner?.model || null,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
946
1023
|
try {
|
|
947
1024
|
const body = await readBody(req, cfg.maxBodyBytes);
|
|
948
1025
|
// secureFetch: scheme/host policy + resolved-IP check before POSTing raw text to the detector.
|
|
@@ -973,10 +1050,13 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
973
1050
|
let text = '';
|
|
974
1051
|
try { text = String(JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8'))?.text || ''); }
|
|
975
1052
|
catch { text = ''; }
|
|
976
|
-
if (!text) return sendJson(res, 200, { text: '', count: 0, sanitized: 0, entities: [] });
|
|
1053
|
+
if (!text) return sendJson(res, 200, { text: '', count: 0, sanitized: 0, entities: [], detector: detectorStatus(cfg) });
|
|
977
1054
|
try {
|
|
978
1055
|
let out = text;
|
|
979
1056
|
const isPro = await resolvePro(cfg.pro?.entitlementToken);
|
|
1057
|
+
// Same start-on-demand as a real turn, for the same reason — and so the preview
|
|
1058
|
+
// keeps describing the request rather than a weaker version of it.
|
|
1059
|
+
await ensureNer(cfg);
|
|
980
1060
|
const r = await redactSegments(
|
|
981
1061
|
[segment(() => out, (v) => { out = v; })],
|
|
982
1062
|
cfg.redaction,
|
|
@@ -988,6 +1068,11 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
988
1068
|
sanitized: r.sanitized || 0,
|
|
989
1069
|
tier: cfg.redaction?.tier === 'full' ? 'full' : 'basic',
|
|
990
1070
|
entities: redactionDetail(r.vault, 'types') || [],
|
|
1071
|
+
// WHAT THIS PREVIEW COULD NOT SEE — additive, and the whole reason a client can be
|
|
1072
|
+
// honest. Patterns catch emails, phones and card numbers with no detector at all,
|
|
1073
|
+
// so a preview with the detector down looks identical to a clean one: same text,
|
|
1074
|
+
// same shield, names intact. `coverage` is the difference, said out loud.
|
|
1075
|
+
detector: detectorStatus(cfg),
|
|
991
1076
|
});
|
|
992
1077
|
} catch (e) {
|
|
993
1078
|
// Fail LOUD. A preview that silently returns the original text would tell the user
|
|
@@ -1672,6 +1757,11 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1672
1757
|
// was off for this turn" rather than "0 redactions", which would read as "nothing to
|
|
1673
1758
|
// redact". Anonymous callers cannot switch it off: that is what the token is for.
|
|
1674
1759
|
redactionOff = String(req.headers['x-chatpanel-redaction'] || '').trim().toLowerCase() === 'off' && isAdminAuthorized(req);
|
|
1760
|
+
// The detector must be up BEFORE the text is redacted, not after someone notices it
|
|
1761
|
+
// wasn't. redactSegments consults the engine and falls open when it is not ready, so
|
|
1762
|
+
// a gateway whose engine never autostarted sent names through at full tier without a
|
|
1763
|
+
// word. Cheap after the first call: ready → a state check, absent weights → nothing.
|
|
1764
|
+
if (!redactionOff) await ensureNer(cfg);
|
|
1675
1765
|
const segs = redactionOff ? [] : r.adapter.collectSegments(body, cfg.redaction);
|
|
1676
1766
|
const ac = new AbortController();
|
|
1677
1767
|
req.on('close', () => ac.abort());
|