@chatpanel/gateway 0.4.2 → 0.5.0
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 +2 -0
- package/src/openai.js +7 -4
- package/src/server.js +72 -16
- package/src/stream.js +8 -3
- package/src/toolrelay.js +7 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"node": ">=18"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@chatpanel/pii": "^0.2.
|
|
33
|
+
"@chatpanel/pii": "^0.2.3"
|
|
34
34
|
},
|
|
35
35
|
"homepage": "https://chatpanel.net",
|
|
36
36
|
"repository": {
|
package/src/configstore.js
CHANGED
|
@@ -48,6 +48,7 @@ export function publicConfig(cfg, { proUnlocked = false } = {}) {
|
|
|
48
48
|
autoNarrow: cfg.tools?.autoNarrow !== false,
|
|
49
49
|
maxPerTurn: Number(cfg.tools?.maxPerTurn) > 0 ? Number(cfg.tools.maxPerTurn) : 8,
|
|
50
50
|
narrowAll: !!cfg.tools?.narrowAll,
|
|
51
|
+
toolData: cfg.tools?.toolData === 'redactRemote' ? 'redactRemote' : 'real',
|
|
51
52
|
},
|
|
52
53
|
};
|
|
53
54
|
}
|
|
@@ -105,6 +106,7 @@ export function applyConfigPatch(cfg, patch = {}) {
|
|
|
105
106
|
cfg.tools = cfg.tools || {};
|
|
106
107
|
if ('autoNarrow' in patch.tools) cfg.tools.autoNarrow = !!patch.tools.autoNarrow;
|
|
107
108
|
if ('narrowAll' in patch.tools) cfg.tools.narrowAll = !!patch.tools.narrowAll;
|
|
109
|
+
if (patch.tools.toolData === 'real' || patch.tools.toolData === 'redactRemote') cfg.tools.toolData = patch.tools.toolData;
|
|
108
110
|
const cap = Number(patch.tools.maxPerTurn);
|
|
109
111
|
if (Number.isFinite(cap) && cap >= 1) cfg.tools.maxPerTurn = Math.floor(cap);
|
|
110
112
|
}
|
package/src/openai.js
CHANGED
|
@@ -55,16 +55,19 @@ export function extractLatestToolResult(body) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
// Restore a buffered (non-streaming) response: assistant text + tool-call args.
|
|
58
|
-
export function restoreResponse(json, vault) {
|
|
58
|
+
export function restoreResponse(json, vault, harness = null) {
|
|
59
59
|
for (const choice of json?.choices || []) {
|
|
60
60
|
const msg = choice?.message;
|
|
61
61
|
if (!msg) continue;
|
|
62
|
-
// Visible text keeps the pseudonym (restoreText); tool-call args
|
|
63
|
-
//
|
|
62
|
+
// Visible text keeps the pseudonym (restoreText); tool-call args go through the
|
|
63
|
+
// shared harness — real values so the client runs the tool on real data, or the
|
|
64
|
+
// redacted token kept for remote MCP tools under "redact remote".
|
|
64
65
|
if (typeof msg.content === 'string') msg.content = restoreText(msg.content, vault);
|
|
65
66
|
for (const tc of msg.tool_calls || []) {
|
|
66
67
|
if (tc?.function && typeof tc.function.arguments === 'string') {
|
|
67
|
-
tc.function.arguments =
|
|
68
|
+
tc.function.arguments = harness
|
|
69
|
+
? harness.toTool(tc.function.name, tc.function.arguments)
|
|
70
|
+
: restoreDeepAliases(tc.function.arguments, vault);
|
|
68
71
|
}
|
|
69
72
|
}
|
|
70
73
|
}
|
package/src/server.js
CHANGED
|
@@ -21,7 +21,7 @@ import { createServer } from 'node:http';
|
|
|
21
21
|
import { loadConfig } from './config.js';
|
|
22
22
|
import { redactSegments } from './redact.js';
|
|
23
23
|
import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer } from './stream.js';
|
|
24
|
-
import { restoreText, effectiveTier, gatedDictionary, narrowSpecs } from '@chatpanel/pii';
|
|
24
|
+
import { restoreText, effectiveTier, gatedDictionary, narrowSpecs, makeToolHarness } from '@chatpanel/pii';
|
|
25
25
|
import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
|
|
26
26
|
import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
|
|
27
27
|
import { shaperFor } from './shape.js';
|
|
@@ -33,7 +33,7 @@ import * as openai from './openai.js';
|
|
|
33
33
|
import * as responses from './responses.js';
|
|
34
34
|
import * as anthropic from './anthropic.js';
|
|
35
35
|
|
|
36
|
-
export const VERSION = '0.
|
|
36
|
+
export const VERSION = '0.5.0';
|
|
37
37
|
|
|
38
38
|
const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
|
|
39
39
|
|
|
@@ -150,6 +150,28 @@ function sendJson(res, status, obj) {
|
|
|
150
150
|
res.end(JSON.stringify(obj));
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// The detector URL (…:9009/ner) and its sibling /health. Returns null when no
|
|
154
|
+
// detector is wired (deterministic-only).
|
|
155
|
+
function nerBaseUrl(cfg) {
|
|
156
|
+
const url = cfg.redaction?.detection?.url;
|
|
157
|
+
if (!url || cfg.redaction?.detection?.backend === 'off') return null;
|
|
158
|
+
return url;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Live health probe of the bundled/configured NER (GET /health → { ok, model }).
|
|
162
|
+
async function probeNerHealth(cfg) {
|
|
163
|
+
const url = nerBaseUrl(cfg);
|
|
164
|
+
if (!url) return { configured: false, ok: false, url: null, model: null };
|
|
165
|
+
try {
|
|
166
|
+
const r = await fetch(url.replace(/\/ner\/?$/, '') + '/health', { signal: AbortSignal.timeout(2000) });
|
|
167
|
+
if (!r.ok) return { configured: true, ok: false, url, model: null };
|
|
168
|
+
const j = await r.json().catch(() => ({}));
|
|
169
|
+
return { configured: true, ok: true, url, model: j.model || null };
|
|
170
|
+
} catch {
|
|
171
|
+
return { configured: true, ok: false, url, model: null };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
153
175
|
function forwardHeaders(headers, base) {
|
|
154
176
|
const out = {};
|
|
155
177
|
for (const [k, v] of Object.entries(headers)) {
|
|
@@ -179,12 +201,12 @@ async function pumpRelay(res, s, shaper) {
|
|
|
179
201
|
}
|
|
180
202
|
|
|
181
203
|
// New tool-enabled turn: open the bridge with the client's tools as MCP specs.
|
|
182
|
-
async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg, isPro, tools) {
|
|
204
|
+
async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg, isPro, tools, harness = null) {
|
|
183
205
|
const { messages, system } = adapter.toTurn(body);
|
|
184
206
|
const token = readBridgeToken(cfg.bridge.token);
|
|
185
207
|
const shaper = shaperFor(kind, body?.model || agent);
|
|
186
208
|
const redactOpts = { tier: effectiveTier({ tier: cfg.redaction.tier }, isPro), dictionary: gatedDictionary(cfg.redaction, isPro), entities: [] };
|
|
187
|
-
const s = createRelaySession({ vault, redactOpts, bridgeUrl: cfg.bridge.url, token });
|
|
209
|
+
const s = createRelaySession({ vault, redactOpts, bridgeUrl: cfg.bridge.url, token, harness });
|
|
188
210
|
const ttl = setTimeout(() => endRelaySession(s.id), 135_000); // bridge tool-call timeout is 120s
|
|
189
211
|
let resp;
|
|
190
212
|
try {
|
|
@@ -206,7 +228,7 @@ async function resumeRelay(res, s, toolContent, model) {
|
|
|
206
228
|
return pumpRelay(res, s, shaper);
|
|
207
229
|
}
|
|
208
230
|
|
|
209
|
-
async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride }, body, vault, cfg, isPro) {
|
|
231
|
+
async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride, harness }, body, vault, cfg, isPro) {
|
|
210
232
|
if (!redactable) {
|
|
211
233
|
return sendJson(res, 404, { error: `endpoint ${pathname} not supported by the bridge backend` });
|
|
212
234
|
}
|
|
@@ -222,7 +244,7 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
222
244
|
}
|
|
223
245
|
const tools = adapter.extractTools(body);
|
|
224
246
|
if (tools.length && body?.stream === true) {
|
|
225
|
-
return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg) }, body, vault, cfg, isPro, tools);
|
|
247
|
+
return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg) }, body, vault, cfg, isPro, tools, harness);
|
|
226
248
|
}
|
|
227
249
|
}
|
|
228
250
|
|
|
@@ -266,7 +288,7 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
266
288
|
|
|
267
289
|
// ---- backend: api ----------------------------------------------------------
|
|
268
290
|
|
|
269
|
-
async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol }, outBody, vault) {
|
|
291
|
+
async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol, harness }, outBody, vault) {
|
|
270
292
|
let upstream;
|
|
271
293
|
try {
|
|
272
294
|
const headers = forwardHeaders(req.headers, base);
|
|
@@ -291,16 +313,17 @@ async function handleApi(req, res, { adapter, kind, pathname, search, base, dest
|
|
|
291
313
|
|
|
292
314
|
if (ct.includes('text/event-stream') && upstream.body) {
|
|
293
315
|
res.writeHead(upstream.status, resHeaders);
|
|
294
|
-
// OpenAI streaming: restore tool-call args
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
return
|
|
316
|
+
// OpenAI streaming: restore tool-call args via the harness (real, or kept
|
|
317
|
+
// redacted for remote MCP under redactRemote) while keeping visible text
|
|
318
|
+
// pseudonymized. Other protocols: generic restore.
|
|
319
|
+
if (kind === 'openai') return pipeRestoredOpenAIStream(upstream.body, res, vault, harness);
|
|
320
|
+
return pipeRestoredStream(upstream.body, res, vault);
|
|
298
321
|
}
|
|
299
322
|
|
|
300
323
|
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
301
324
|
if (vault && ct.includes('application/json')) {
|
|
302
325
|
try {
|
|
303
|
-
const json = adapter.restoreResponse(JSON.parse(buf.toString('utf8')), vault);
|
|
326
|
+
const json = adapter.restoreResponse(JSON.parse(buf.toString('utf8')), vault, harness);
|
|
304
327
|
res.writeHead(upstream.status, { ...resHeaders, 'content-type': 'application/json' });
|
|
305
328
|
return res.end(Buffer.from(JSON.stringify(json), 'utf8'));
|
|
306
329
|
} catch { /* fall through */ }
|
|
@@ -330,14 +353,41 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
330
353
|
// --- Config API (the extension's "Gateway" tab is a client of these) ---
|
|
331
354
|
if (pathname === '/status' && req.method === 'GET') {
|
|
332
355
|
const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
|
|
333
|
-
const
|
|
356
|
+
const health = await probeNerHealth(cfg); // live GET /health on the detector
|
|
334
357
|
return sendJson(res, 200, {
|
|
335
358
|
ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier,
|
|
336
|
-
ner: {
|
|
359
|
+
ner: {
|
|
360
|
+
autostart: !!cfg.ner?.autostart,
|
|
361
|
+
configured: health.configured,
|
|
362
|
+
ready: health.ok, // the detector actually answered /health
|
|
363
|
+
model: health.model, // e.g. "en_core_web_sm"
|
|
364
|
+
url: health.url,
|
|
365
|
+
},
|
|
337
366
|
pro: { unlocked: proUnlocked }, usage: usage(cfg),
|
|
338
367
|
uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
|
|
339
368
|
});
|
|
340
369
|
}
|
|
370
|
+
// Proxy the detector so it's checkable on the gateway's own port (the NER runs
|
|
371
|
+
// on its own port, e.g. 9009 — hitting :4320/ner used to 404). GET → health.
|
|
372
|
+
if (pathname === '/ner') {
|
|
373
|
+
const url = nerBaseUrl(cfg);
|
|
374
|
+
if (!url) return sendJson(res, 503, { error: { message: 'NER not configured — deterministic-only redaction', type: 'ner_off' } });
|
|
375
|
+
if (req.method === 'GET') {
|
|
376
|
+
const health = await probeNerHealth(cfg);
|
|
377
|
+
return sendJson(res, health.ok ? 200 : 503, health);
|
|
378
|
+
}
|
|
379
|
+
if (req.method === 'POST') {
|
|
380
|
+
try {
|
|
381
|
+
const body = await readBody(req, cfg.maxBodyBytes);
|
|
382
|
+
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: AbortSignal.timeout(8000) });
|
|
383
|
+
const text = await r.text();
|
|
384
|
+
res.writeHead(r.status, { 'content-type': 'application/json' });
|
|
385
|
+
return res.end(text);
|
|
386
|
+
} catch (e) {
|
|
387
|
+
return sendJson(res, 502, { error: { message: `NER unreachable: ${e.message}`, type: 'ner_unreachable' } });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
341
391
|
if (pathname === '/logs' && req.method === 'GET') {
|
|
342
392
|
return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only
|
|
343
393
|
}
|
|
@@ -412,6 +462,12 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
412
462
|
}
|
|
413
463
|
}
|
|
414
464
|
|
|
465
|
+
// THE shared tool harness — same one the extension uses. The gateway only needs
|
|
466
|
+
// ② (tool args): restore to real for the client to run, or keep the redacted
|
|
467
|
+
// token for remote MCP tools when tools.toolData is "redactRemote". Results are
|
|
468
|
+
// re-redacted by the NEXT request's normal redaction, so ③ isn't needed here.
|
|
469
|
+
const harness = makeToolHarness({ vault, toolData: cfg.tools?.toolData });
|
|
470
|
+
|
|
415
471
|
// Route by the requested model → a destination (agent via the bridge, or an
|
|
416
472
|
// API we forward to). Falls back to the legacy backend when none configured.
|
|
417
473
|
const dest = resolveDestination(body?.model, cfg, r.kind);
|
|
@@ -424,9 +480,9 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
424
480
|
if (isSelfUrl(dest.baseUrl, cfg)) {
|
|
425
481
|
return sendJson(res, 508, { error: { message: `destination "${dest.id}" points back at the gateway (${dest.baseUrl}) — refusing to forward (would loop).`, type: 'loop_detected' } });
|
|
426
482
|
}
|
|
427
|
-
return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol }, outBody, vault);
|
|
483
|
+
return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol, harness }, outBody, vault);
|
|
428
484
|
}
|
|
429
|
-
return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent }, body, vault, cfg, isPro);
|
|
485
|
+
return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent, harness }, body, vault, cfg, isPro);
|
|
430
486
|
});
|
|
431
487
|
}
|
|
432
488
|
|
package/src/stream.js
CHANGED
|
@@ -97,12 +97,16 @@ function makeFieldRestorer(vault, restoreFn) {
|
|
|
97
97
|
// model + user keep the pseudonym) but TOOL-CALL argument deltas with
|
|
98
98
|
// restoreWithAliases (the client runs the tool on the REAL value). Passes through
|
|
99
99
|
// any non-JSON event untouched.
|
|
100
|
-
export async function pipeRestoredOpenAIStream(upstreamBody, nodeRes, vault) {
|
|
100
|
+
export async function pipeRestoredOpenAIStream(upstreamBody, nodeRes, vault, harness = null) {
|
|
101
101
|
const reader = upstreamBody.getReader();
|
|
102
102
|
const decoder = new TextDecoder();
|
|
103
103
|
let buf = '';
|
|
104
104
|
const contentR = makeFieldRestorer(vault, restoreText);
|
|
105
|
-
const argRs = new Map();
|
|
105
|
+
const argRs = new Map(); // tool_call index -> field restorer
|
|
106
|
+
const toolNames = new Map(); // tool_call index -> name (for redact-remote decisions)
|
|
107
|
+
// Tool args go through the shared harness (real, or kept-redacted for remote MCP
|
|
108
|
+
// under redactRemote); without a harness, default to real values (aliases undone).
|
|
109
|
+
const argFn = (idx) => (text, v) => (harness ? harness.toTool(toolNames.get(idx) || '', text) : restoreWithAliases(text, v));
|
|
106
110
|
|
|
107
111
|
const handleBlock = (block) => {
|
|
108
112
|
const out = [];
|
|
@@ -123,8 +127,9 @@ export async function pipeRestoredOpenAIStream(upstreamBody, nodeRes, vault) {
|
|
|
123
127
|
if (typeof d.content === 'string') d.content = contentR.push(d.content);
|
|
124
128
|
for (const tc of d.tool_calls || []) {
|
|
125
129
|
const idx = typeof tc.index === 'number' ? tc.index : 0;
|
|
130
|
+
if (tc.function && typeof tc.function.name === 'string' && tc.function.name) toolNames.set(idx, tc.function.name);
|
|
126
131
|
if (tc.function && typeof tc.function.arguments === 'string') {
|
|
127
|
-
if (!argRs.has(idx)) argRs.set(idx, makeFieldRestorer(vault,
|
|
132
|
+
if (!argRs.has(idx)) argRs.set(idx, makeFieldRestorer(vault, argFn(idx)));
|
|
128
133
|
tc.function.arguments = argRs.get(idx).push(tc.function.arguments);
|
|
129
134
|
}
|
|
130
135
|
}
|
package/src/toolrelay.js
CHANGED
|
@@ -35,9 +35,9 @@ export function toolsToSpecs(tools) {
|
|
|
35
35
|
.map((t) => ({ name: t.function.name, description: t.function.description || '', parameters: t.function.parameters || { type: 'object', properties: {} } }));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export function createRelaySession({ vault, redactOpts, bridgeUrl, token }) {
|
|
38
|
+
export function createRelaySession({ vault, redactOpts, bridgeUrl, token, harness = null }) {
|
|
39
39
|
const id = randomUUID().slice(0, 8);
|
|
40
|
-
const s = { id, reader: null, decoder: new TextDecoder(), buf: '', bridgeSessionId: null, toolId: null, vault: vault || createVault(), redactOpts: redactOpts || { tier: 'basic' }, bridgeUrl, token, done: false };
|
|
40
|
+
const s = { id, reader: null, decoder: new TextDecoder(), buf: '', bridgeSessionId: null, toolId: null, vault: vault || createVault(), redactOpts: redactOpts || { tier: 'basic' }, bridgeUrl, token, harness, done: false };
|
|
41
41
|
sessions.set(id, s);
|
|
42
42
|
return s;
|
|
43
43
|
}
|
|
@@ -68,8 +68,11 @@ export async function pumpBridgeStream(s, handlers) {
|
|
|
68
68
|
handlers.onText(evt.text);
|
|
69
69
|
} else if (evt.type === 'tool_request') {
|
|
70
70
|
s.bridgeSessionId = evt.session; s.toolId = evt.id;
|
|
71
|
-
//
|
|
72
|
-
|
|
71
|
+
// ② via the shared harness: real values so the CLIENT runs the tool on
|
|
72
|
+
// them, or the redacted token kept for remote MCP under "redact remote".
|
|
73
|
+
const restoredArgs = s.harness
|
|
74
|
+
? s.harness.toTool(evt.name, evt.input ?? {})
|
|
75
|
+
: restoreDeep(evt.input ?? {}, s.vault);
|
|
73
76
|
handlers.onToolRequest({ name: evt.name, restoredArgs, toolId: encodeToolCallId(s.id, evt.id) });
|
|
74
77
|
return 'parked';
|
|
75
78
|
} else if (evt.type === 'done') {
|