@chatpanel/gateway 0.6.66 → 0.6.69
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/server.js +53 -6
- package/src/stream.js +18 -13
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "Local privacy gateway
|
|
3
|
+
"version": "0.6.69",
|
|
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": {
|
|
7
7
|
"chatpanel-gateway": "bin/chatpanel-gateway.js"
|
package/src/server.js
CHANGED
|
@@ -55,7 +55,7 @@ import * as openai from './openai.js';
|
|
|
55
55
|
import * as responses from './responses.js';
|
|
56
56
|
import * as anthropic from './anthropic.js';
|
|
57
57
|
|
|
58
|
-
export const VERSION = '0.6.
|
|
58
|
+
export const VERSION = '0.6.69';
|
|
59
59
|
|
|
60
60
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
61
61
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -170,7 +170,7 @@ function mkTrace(sink) {
|
|
|
170
170
|
const entry = /** @type {any} */ ({ ...this.meta, timings });
|
|
171
171
|
setImmediate(() => {
|
|
172
172
|
sink(entry);
|
|
173
|
-
console.log(`[gateway] model=${entry.model || '-'} → ${entry.dest ? `${entry.dest}(${entry.type})` : 'none'} · redacted ${entry.redacted || 0}${entry.sanitized ? ` · scrubbed ${entry.sanitized} hidden` : ''}${entry.narrowed ? ` · narrowed -${entry.narrowed}` : ''} · ${fmtTimings(timings)}`);
|
|
173
|
+
console.log(`[gateway] model=${entry.model || '-'} → ${entry.dest ? `${entry.dest}(${entry.type})` : 'none'} · ${entry.redaction === 'off' ? 'redaction OFF (client request)' : `redacted ${entry.redacted || 0}`}${entry.sanitized ? ` · scrubbed ${entry.sanitized} hidden` : ''}${entry.narrowed ? ` · narrowed -${entry.narrowed}` : ''} · ${fmtTimings(timings)}`);
|
|
174
174
|
});
|
|
175
175
|
},
|
|
176
176
|
};
|
|
@@ -660,6 +660,10 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
660
660
|
const tts = ttsEngine.health();
|
|
661
661
|
return sendJson(res, 200, {
|
|
662
662
|
ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier,
|
|
663
|
+
// WHO STARTED THIS PROCESS — additive. ChatPanel Desktop sets CHATPANEL_MANAGED_BY=desktop
|
|
664
|
+
// on the login service it registers, so a client can say "provided by the desktop app"
|
|
665
|
+
// and stop offering install.sh for a gateway that is already installed. Absent otherwise.
|
|
666
|
+
...(process.env.CHATPANEL_MANAGED_BY ? { managedBy: String(process.env.CHATPANEL_MANAGED_BY).slice(0, 32) } : {}),
|
|
663
667
|
// `runtime` = 'native' (npm, fast quantized) | 'wasm' (binary, slow fp32) —
|
|
664
668
|
// the extension uses it to advise the far-faster native gateway.
|
|
665
669
|
stt: { enabled: cfg.stt?.enabled !== false, state: stt.state, ready: stt.ok, model: stt.model || cfg.stt?.model || DEFAULT_STT_MODEL, runtime: stt.runtime, dtype: stt.dtype },
|
|
@@ -676,6 +680,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
676
680
|
const health = await probeNerHealth(cfg); // live GET /health on the detector
|
|
677
681
|
return sendJson(res, 200, {
|
|
678
682
|
ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier,
|
|
683
|
+
...(process.env.CHATPANEL_MANAGED_BY ? { managedBy: String(process.env.CHATPANEL_MANAGED_BY).slice(0, 32) } : {}), // see /health
|
|
679
684
|
ner: {
|
|
680
685
|
autostart: !!cfg.ner?.autostart,
|
|
681
686
|
configured: health.configured,
|
|
@@ -1552,6 +1557,37 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1552
1557
|
}
|
|
1553
1558
|
}
|
|
1554
1559
|
|
|
1560
|
+
// ONE skill, with the prompt the list deliberately leaves out.
|
|
1561
|
+
//
|
|
1562
|
+
// /skills answers `promptChars` — a COUNT — because a list of ninety-five skills carrying
|
|
1563
|
+
// ninety-five prompt bodies is a megabyte nobody asked for. A client that wants to scope a
|
|
1564
|
+
// task to a skill needs the body itself, and the only other way to get it was to go
|
|
1565
|
+
// straight at the bridge, which is the habit the /skills comment above exists to prevent.
|
|
1566
|
+
//
|
|
1567
|
+
// The id is passed through as ONE path segment, encoded. A skill id is a file name on the
|
|
1568
|
+
// user's disk, so letting a slash or a `..` reach the bridge's reader would be asking it
|
|
1569
|
+
// to open something else; the bridge validates too, and this is the half we own.
|
|
1570
|
+
if (req.method === 'GET' && /^\/skills\/[^/]+$/.test(pathname)) {
|
|
1571
|
+
const id = decodeURIComponent(pathname.slice('/skills/'.length));
|
|
1572
|
+
if (!id || id === '.' || id === '..' || id.includes('/') || id.includes('\\')) {
|
|
1573
|
+
return sendJson(res, 400, { error: { message: 'not a skill id', type: 'invalid_request' } });
|
|
1574
|
+
}
|
|
1575
|
+
const base = String(cfg.bridge?.url || '').replace(/\/$/, '');
|
|
1576
|
+
if (!base) return sendJson(res, 503, { error: { message: 'no bridge is configured', type: 'no_bridge' } });
|
|
1577
|
+
const token = readBridgeToken(cfg.bridge?.token);
|
|
1578
|
+
try {
|
|
1579
|
+
const r = await fetch(`${base}/skills/${encodeURIComponent(id)}`, {
|
|
1580
|
+
headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
1581
|
+
signal: AbortSignal.timeout(8000),
|
|
1582
|
+
});
|
|
1583
|
+
const data = await r.json().catch(() => ({}));
|
|
1584
|
+
if (!r.ok) return sendJson(res, r.status, { error: { message: data?.error || `bridge ${r.status}`, type: 'bridge_error' } });
|
|
1585
|
+
return sendJson(res, 200, { skill: data?.skill || null });
|
|
1586
|
+
} catch (e) {
|
|
1587
|
+
return sendJson(res, 502, { error: { message: `bridge unreachable: ${e.message}`, type: 'bridge_unreachable' } });
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1555
1591
|
// Model discovery — aggregate every destination's models.
|
|
1556
1592
|
if (req.method === 'GET' && /\/models$/.test(pathname)) {
|
|
1557
1593
|
return sendJson(res, 200, await aggregateModelsAsync(cfg));
|
|
@@ -1588,6 +1624,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1588
1624
|
let redactedCount = 0;
|
|
1589
1625
|
let sanitizedCount = 0;
|
|
1590
1626
|
let narrowedTools = 0;
|
|
1627
|
+
let redactionOff = false;
|
|
1591
1628
|
let isPro = true;
|
|
1592
1629
|
// Off the hot path: only build a trace when logging is on, so it adds nothing
|
|
1593
1630
|
// when off (no clock reads, no record, no console line).
|
|
@@ -1623,14 +1660,24 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1623
1660
|
type: 'free_limit_reached',
|
|
1624
1661
|
} });
|
|
1625
1662
|
}
|
|
1626
|
-
|
|
1663
|
+
// REDACTION OFF, FOR THIS REQUEST, BECAUSE THE USER SAID SO. A note task that reads
|
|
1664
|
+
// "write about NVIDIA GPUs" had NVIDIA replaced with an organisation placeholder, and
|
|
1665
|
+
// the model — seeing only [[ORG_1]] — guessed a different company and wrote about
|
|
1666
|
+
// that. The policy is right for the corpus and wrong for the user's own instruction,
|
|
1667
|
+
// and only the user can tell which a given turn is. So an AUTHENTICATED local client
|
|
1668
|
+
// (the desktop, the extension — both hold the gateway token) may send
|
|
1669
|
+
// `X-ChatPanel-Redaction: off`; the trace records it so the ledger says "redaction
|
|
1670
|
+
// was off for this turn" rather than "0 redactions", which would read as "nothing to
|
|
1671
|
+
// redact". Anonymous callers cannot switch it off: that is what the token is for.
|
|
1672
|
+
redactionOff = String(req.headers['x-chatpanel-redaction'] || '').trim().toLowerCase() === 'off' && isAdminAuthorized(req);
|
|
1673
|
+
const segs = redactionOff ? [] : r.adapter.collectSegments(body, cfg.redaction);
|
|
1627
1674
|
const ac = new AbortController();
|
|
1628
1675
|
req.on('close', () => ac.abort());
|
|
1629
1676
|
const rd0 = trace ? trace.clock() : 0;
|
|
1630
1677
|
// Redact at the configured tier for everyone (free users get name/org
|
|
1631
1678
|
// redaction within their allowance); the custom dictionary stays capped for
|
|
1632
1679
|
// free (isPro decides that inside).
|
|
1633
|
-
const { vault: v, count, sanitized } = await redactSegments(segs, cfg.redaction, {
|
|
1680
|
+
const { vault: v, count, sanitized } = redactionOff ? { vault: null, count: 0, sanitized: 0 } : await redactSegments(segs, cfg.redaction, {
|
|
1634
1681
|
signal: ac.signal,
|
|
1635
1682
|
isPro,
|
|
1636
1683
|
// A detector is the only hop that sees the request BEFORE redaction. It is guarded
|
|
@@ -1659,7 +1706,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1659
1706
|
// tools (so privacy-aware models USE them instead of refusing). Injected
|
|
1660
1707
|
// AFTER redaction so the note isn't itself redacted. Covers BOTH the API
|
|
1661
1708
|
// forward and the relay (which reads system from this same body).
|
|
1662
|
-
if (Array.isArray(body.tools) && body.tools.length && typeof r.adapter.injectSystemNote === 'function') {
|
|
1709
|
+
if (!redactionOff && Array.isArray(body.tools) && body.tools.length && typeof r.adapter.injectSystemNote === 'function') {
|
|
1663
1710
|
r.adapter.injectSystemNote(body, placeholderToolNote({ toolData: cfg.tools?.toolData }));
|
|
1664
1711
|
}
|
|
1665
1712
|
outBody = Buffer.from(JSON.stringify(body), 'utf8');
|
|
@@ -1713,7 +1760,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1713
1760
|
});
|
|
1714
1761
|
}
|
|
1715
1762
|
if (trace) {
|
|
1716
|
-
trace.meta = { t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount, sanitized: sanitizedCount, narrowed: narrowedTools, detail: redactionDetail(vault, cfg.logDetail) };
|
|
1763
|
+
trace.meta = { t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount, sanitized: sanitizedCount, narrowed: narrowedTools, detail: redactionDetail(vault, cfg.logDetail), redaction: redactionOff ? 'off' : (cfg.redaction.tier || 'basic') };
|
|
1717
1764
|
}
|
|
1718
1765
|
if (dest && dest.type === 'api') {
|
|
1719
1766
|
if (!dest.baseUrl) { trace?.commit(); return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` }); }
|
package/src/stream.js
CHANGED
|
@@ -17,23 +17,30 @@ import { restoreText, restoreWithAliases } from '@chatpanel/pii';
|
|
|
17
17
|
// Returns a TransformStream-free chunk transformer: feed it decoded string chunks,
|
|
18
18
|
// it returns the prefix that's safe to forward now and buffers a possibly-partial
|
|
19
19
|
// trailing token. Call flush() at end-of-stream.
|
|
20
|
+
/**
|
|
21
|
+
* Split `buf` into what is safe to forward now and what may still be the start of a token.
|
|
22
|
+
*
|
|
23
|
+
* Two shapes are held back: an unterminated "[[" (a token mid-way), and a trailing SINGLE
|
|
24
|
+
* "[" — a model tokenizes "[[ORG_1]]" as "[" + "[ORG_1]]" often enough that forwarding the
|
|
25
|
+
* lone bracket left every restored name wearing one: "[NVIDIA designs GPUs". "[[" cannot
|
|
26
|
+
* legitimately appear except as a token open, and a lone "[" at a chunk edge costs nothing to
|
|
27
|
+
* wait one chunk for.
|
|
28
|
+
*/
|
|
29
|
+
function splitSafe(buf) {
|
|
30
|
+
const open = buf.lastIndexOf('[[');
|
|
31
|
+
if (open !== -1 && !buf.slice(open).includes(']]')) return [buf.slice(0, open), buf.slice(open)];
|
|
32
|
+
if (buf.endsWith('[')) return [buf.slice(0, -1), '['];
|
|
33
|
+
return [buf, ''];
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
export function makeTokenRestorer(vault) {
|
|
21
37
|
let buf = '';
|
|
22
38
|
return {
|
|
23
39
|
push(chunk) {
|
|
24
40
|
if (!vault) return chunk || '';
|
|
25
41
|
buf += chunk || '';
|
|
26
|
-
// If an unterminated "[[" sits in the tail, a token may still be forming —
|
|
27
|
-
// hold from there. "[[" can't legitimately appear except as a token open.
|
|
28
|
-
const open = buf.lastIndexOf('[[');
|
|
29
42
|
let safe;
|
|
30
|
-
|
|
31
|
-
safe = buf.slice(0, open);
|
|
32
|
-
buf = buf.slice(open);
|
|
33
|
-
} else {
|
|
34
|
-
safe = buf;
|
|
35
|
-
buf = '';
|
|
36
|
-
}
|
|
43
|
+
[safe, buf] = splitSafe(buf);
|
|
37
44
|
return restoreText(safe, vault);
|
|
38
45
|
},
|
|
39
46
|
flush() {
|
|
@@ -83,10 +90,8 @@ function makeFieldRestorer(vault, restoreFn) {
|
|
|
83
90
|
return {
|
|
84
91
|
push(chunk) {
|
|
85
92
|
buf += chunk || '';
|
|
86
|
-
const open = buf.lastIndexOf('[[');
|
|
87
93
|
let safe;
|
|
88
|
-
|
|
89
|
-
else { safe = buf; buf = ''; }
|
|
94
|
+
[safe, buf] = splitSafe(buf);
|
|
90
95
|
return restoreFn(safe, vault);
|
|
91
96
|
},
|
|
92
97
|
flush() { const out = restoreFn(buf, vault); buf = ''; return out; },
|