@chatpanel/gateway 0.4.3 → 0.5.1
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 +27 -15
- 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.1",
|
|
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.4"
|
|
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, placeholderToolNote } 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.1';
|
|
37
37
|
|
|
38
38
|
const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
|
|
39
39
|
|
|
@@ -201,16 +201,21 @@ async function pumpRelay(res, s, shaper) {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
// New tool-enabled turn: open the bridge with the client's tools as MCP specs.
|
|
204
|
-
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) {
|
|
205
205
|
const { messages, system } = adapter.toTurn(body);
|
|
206
206
|
const token = readBridgeToken(cfg.bridge.token);
|
|
207
207
|
const shaper = shaperFor(kind, body?.model || agent);
|
|
208
208
|
const redactOpts = { tier: effectiveTier({ tier: cfg.redaction.tier }, isPro), dictionary: gatedDictionary(cfg.redaction, isPro), entities: [] };
|
|
209
|
-
const s = createRelaySession({ vault, redactOpts, bridgeUrl: cfg.bridge.url, token });
|
|
209
|
+
const s = createRelaySession({ vault, redactOpts, bridgeUrl: cfg.bridge.url, token, harness });
|
|
210
210
|
const ttl = setTimeout(() => endRelaySession(s.id), 135_000); // bridge tool-call timeout is 120s
|
|
211
|
+
// Tell the agent placeholders are auto-restored for tools, so privacy-aware agents
|
|
212
|
+
// (Codex/Claude) USE them instead of refusing. Appended after redaction.
|
|
213
|
+
const sysWithNote = (vault && tools?.length)
|
|
214
|
+
? `${system || ''}\n\n${placeholderToolNote({ toolData: cfg.tools?.toolData })}`.trim()
|
|
215
|
+
: system;
|
|
211
216
|
let resp;
|
|
212
217
|
try {
|
|
213
|
-
resp = await openBridgeChat({ bridgeUrl: cfg.bridge.url, agent, token, messages, system, specs: toolsToSpecs(tools), options: {}, signal: undefined });
|
|
218
|
+
resp = await openBridgeChat({ bridgeUrl: cfg.bridge.url, agent, token, messages, system: sysWithNote, specs: toolsToSpecs(tools), options: {}, signal: undefined });
|
|
214
219
|
} catch (e) { clearTimeout(ttl); endRelaySession(s.id); return sendJson(res, 502, { error: { message: `bridge: ${e.message}`, type: 'bridge_error' } }); }
|
|
215
220
|
s.reader = resp.body.getReader();
|
|
216
221
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
@@ -228,7 +233,7 @@ async function resumeRelay(res, s, toolContent, model) {
|
|
|
228
233
|
return pumpRelay(res, s, shaper);
|
|
229
234
|
}
|
|
230
235
|
|
|
231
|
-
async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride }, body, vault, cfg, isPro) {
|
|
236
|
+
async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride, harness }, body, vault, cfg, isPro) {
|
|
232
237
|
if (!redactable) {
|
|
233
238
|
return sendJson(res, 404, { error: `endpoint ${pathname} not supported by the bridge backend` });
|
|
234
239
|
}
|
|
@@ -244,7 +249,7 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
244
249
|
}
|
|
245
250
|
const tools = adapter.extractTools(body);
|
|
246
251
|
if (tools.length && body?.stream === true) {
|
|
247
|
-
return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg) }, body, vault, cfg, isPro, tools);
|
|
252
|
+
return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg) }, body, vault, cfg, isPro, tools, harness);
|
|
248
253
|
}
|
|
249
254
|
}
|
|
250
255
|
|
|
@@ -288,7 +293,7 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
288
293
|
|
|
289
294
|
// ---- backend: api ----------------------------------------------------------
|
|
290
295
|
|
|
291
|
-
async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol }, outBody, vault) {
|
|
296
|
+
async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol, harness }, outBody, vault) {
|
|
292
297
|
let upstream;
|
|
293
298
|
try {
|
|
294
299
|
const headers = forwardHeaders(req.headers, base);
|
|
@@ -313,16 +318,17 @@ async function handleApi(req, res, { adapter, kind, pathname, search, base, dest
|
|
|
313
318
|
|
|
314
319
|
if (ct.includes('text/event-stream') && upstream.body) {
|
|
315
320
|
res.writeHead(upstream.status, resHeaders);
|
|
316
|
-
// OpenAI streaming: restore tool-call args
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
return
|
|
321
|
+
// OpenAI streaming: restore tool-call args via the harness (real, or kept
|
|
322
|
+
// redacted for remote MCP under redactRemote) while keeping visible text
|
|
323
|
+
// pseudonymized. Other protocols: generic restore.
|
|
324
|
+
if (kind === 'openai') return pipeRestoredOpenAIStream(upstream.body, res, vault, harness);
|
|
325
|
+
return pipeRestoredStream(upstream.body, res, vault);
|
|
320
326
|
}
|
|
321
327
|
|
|
322
328
|
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
323
329
|
if (vault && ct.includes('application/json')) {
|
|
324
330
|
try {
|
|
325
|
-
const json = adapter.restoreResponse(JSON.parse(buf.toString('utf8')), vault);
|
|
331
|
+
const json = adapter.restoreResponse(JSON.parse(buf.toString('utf8')), vault, harness);
|
|
326
332
|
res.writeHead(upstream.status, { ...resHeaders, 'content-type': 'application/json' });
|
|
327
333
|
return res.end(Buffer.from(JSON.stringify(json), 'utf8'));
|
|
328
334
|
} catch { /* fall through */ }
|
|
@@ -461,6 +467,12 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
461
467
|
}
|
|
462
468
|
}
|
|
463
469
|
|
|
470
|
+
// THE shared tool harness — same one the extension uses. The gateway only needs
|
|
471
|
+
// ② (tool args): restore to real for the client to run, or keep the redacted
|
|
472
|
+
// token for remote MCP tools when tools.toolData is "redactRemote". Results are
|
|
473
|
+
// re-redacted by the NEXT request's normal redaction, so ③ isn't needed here.
|
|
474
|
+
const harness = makeToolHarness({ vault, toolData: cfg.tools?.toolData });
|
|
475
|
+
|
|
464
476
|
// Route by the requested model → a destination (agent via the bridge, or an
|
|
465
477
|
// API we forward to). Falls back to the legacy backend when none configured.
|
|
466
478
|
const dest = resolveDestination(body?.model, cfg, r.kind);
|
|
@@ -473,9 +485,9 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
473
485
|
if (isSelfUrl(dest.baseUrl, cfg)) {
|
|
474
486
|
return sendJson(res, 508, { error: { message: `destination "${dest.id}" points back at the gateway (${dest.baseUrl}) — refusing to forward (would loop).`, type: 'loop_detected' } });
|
|
475
487
|
}
|
|
476
|
-
return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol }, outBody, vault);
|
|
488
|
+
return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol, harness }, outBody, vault);
|
|
477
489
|
}
|
|
478
|
-
return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent }, body, vault, cfg, isPro);
|
|
490
|
+
return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent, harness }, body, vault, cfg, isPro);
|
|
479
491
|
});
|
|
480
492
|
}
|
|
481
493
|
|
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') {
|