@chatpanel/gateway 0.3.0 → 0.4.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 +13 -1
- package/src/openai.js +6 -3
- package/src/server.js +54 -7
- package/src/stream.js +92 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"node": ">=18"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@chatpanel/pii": "^0.
|
|
31
|
+
"@chatpanel/pii": "^0.2.1"
|
|
32
32
|
},
|
|
33
33
|
"homepage": "https://chatpanel.net",
|
|
34
34
|
"repository": {
|
package/src/configstore.js
CHANGED
|
@@ -19,7 +19,7 @@ export function persistConfig(cfg, path = configPath()) {
|
|
|
19
19
|
host: cfg.host, port: cfg.port, backend: cfg.backend,
|
|
20
20
|
bridge: cfg.bridge, upstreams: cfg.upstreams, redaction: cfg.redaction,
|
|
21
21
|
ner: cfg.ner, allowedOrigins: cfg.allowedOrigins, maxBodyBytes: cfg.maxBodyBytes,
|
|
22
|
-
pro: cfg.pro, logRequests: cfg.logRequests,
|
|
22
|
+
pro: cfg.pro, logRequests: cfg.logRequests, tools: cfg.tools,
|
|
23
23
|
};
|
|
24
24
|
writeFileSync(path, JSON.stringify(out, null, 2));
|
|
25
25
|
}
|
|
@@ -44,6 +44,11 @@ export function publicConfig(cfg, { proUnlocked = false } = {}) {
|
|
|
44
44
|
allowedOrigins: Array.isArray(cfg.allowedOrigins) ? cfg.allowedOrigins : [],
|
|
45
45
|
pro: { unlocked: proUnlocked, hasToken: !!cfg.pro?.entitlementToken, free: cfg.pro?.free },
|
|
46
46
|
logRequests: !!cfg.logRequests,
|
|
47
|
+
tools: {
|
|
48
|
+
autoNarrow: cfg.tools?.autoNarrow !== false,
|
|
49
|
+
maxPerTurn: Number(cfg.tools?.maxPerTurn) > 0 ? Number(cfg.tools.maxPerTurn) : 8,
|
|
50
|
+
narrowAll: !!cfg.tools?.narrowAll,
|
|
51
|
+
},
|
|
47
52
|
};
|
|
48
53
|
}
|
|
49
54
|
|
|
@@ -96,5 +101,12 @@ export function applyConfigPatch(cfg, patch = {}) {
|
|
|
96
101
|
if (Number.isFinite(cap) && cap >= 0) cfg.pro.free.maxRequestsPerDay = cap;
|
|
97
102
|
}
|
|
98
103
|
if (typeof patch.logRequests === 'boolean') cfg.logRequests = patch.logRequests;
|
|
104
|
+
if (patch.tools && typeof patch.tools === 'object') {
|
|
105
|
+
cfg.tools = cfg.tools || {};
|
|
106
|
+
if ('autoNarrow' in patch.tools) cfg.tools.autoNarrow = !!patch.tools.autoNarrow;
|
|
107
|
+
if ('narrowAll' in patch.tools) cfg.tools.narrowAll = !!patch.tools.narrowAll;
|
|
108
|
+
const cap = Number(patch.tools.maxPerTurn);
|
|
109
|
+
if (Number.isFinite(cap) && cap >= 1) cfg.tools.maxPerTurn = Math.floor(cap);
|
|
110
|
+
}
|
|
99
111
|
return cfg;
|
|
100
112
|
}
|
package/src/openai.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// request body as in-place segments, and restores tokens in a non-streaming
|
|
4
4
|
// response. (Streaming is restored generically in stream.js.)
|
|
5
5
|
|
|
6
|
+
import { restoreText } from '@chatpanel/pii';
|
|
6
7
|
import { segment } from './redact.js';
|
|
7
|
-
import {
|
|
8
|
+
import { restoreDeepAliases } from './stream.js';
|
|
8
9
|
|
|
9
10
|
export function matches(pathname) {
|
|
10
11
|
return /\/chat\/completions$/.test(pathname) || /\/completions$/.test(pathname);
|
|
@@ -58,10 +59,12 @@ export function restoreResponse(json, vault) {
|
|
|
58
59
|
for (const choice of json?.choices || []) {
|
|
59
60
|
const msg = choice?.message;
|
|
60
61
|
if (!msg) continue;
|
|
61
|
-
|
|
62
|
+
// Visible text keeps the pseudonym (restoreText); tool-call args get the REAL
|
|
63
|
+
// value (restoreDeepAliases) so the client runs the tool on real data.
|
|
64
|
+
if (typeof msg.content === 'string') msg.content = restoreText(msg.content, vault);
|
|
62
65
|
for (const tc of msg.tool_calls || []) {
|
|
63
66
|
if (tc?.function && typeof tc.function.arguments === 'string') {
|
|
64
|
-
tc.function.arguments =
|
|
67
|
+
tc.function.arguments = restoreDeepAliases(tc.function.arguments, vault);
|
|
65
68
|
}
|
|
66
69
|
}
|
|
67
70
|
}
|
package/src/server.js
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
import { createServer } from 'node:http';
|
|
21
21
|
import { loadConfig } from './config.js';
|
|
22
22
|
import { redactSegments } from './redact.js';
|
|
23
|
-
import { pipeRestoredStream, makeTokenRestorer } from './stream.js';
|
|
24
|
-
import { restoreText, effectiveTier, gatedDictionary } from '@chatpanel/pii';
|
|
23
|
+
import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer } from './stream.js';
|
|
24
|
+
import { restoreText, effectiveTier, gatedDictionary, narrowSpecs } 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,10 +33,40 @@ 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.4.0';
|
|
37
37
|
|
|
38
38
|
const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
|
|
39
39
|
|
|
40
|
+
// Auto-narrow: arm only the top-K most-relevant MCP tools per turn (speed). Mirrors
|
|
41
|
+
// the extension's AUTO mode via the SAME shared ranker. We narrow only tools whose
|
|
42
|
+
// name looks like an MCP tool (server-prefixed) so a client's CORE tools (bash,
|
|
43
|
+
// read, edit…) are never dropped — that would break agent clients like OpenCode.
|
|
44
|
+
const DEFAULT_GATEWAY_TOOL_CAP = 8;
|
|
45
|
+
const MCP_NAME_RE = /^mcp[_-]/i;
|
|
46
|
+
const toolName = (t) => (t && t.function && t.function.name) || (t && t.name) || '';
|
|
47
|
+
const toolDesc = (t) => (t && t.function && t.function.description) || (t && t.description) || '';
|
|
48
|
+
|
|
49
|
+
// Flatten message/content shapes to the latest user text — the query we rank tools against.
|
|
50
|
+
function textFromContent(content) {
|
|
51
|
+
if (typeof content === 'string') return content;
|
|
52
|
+
if (Array.isArray(content)) return content.map((p) => (typeof p === 'string' ? p : (p && (p.text || p.content)) || '')).join(' ');
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
function latestUserText(body, kind) {
|
|
56
|
+
if (!body) return '';
|
|
57
|
+
if (kind === 'responses') {
|
|
58
|
+
const inp = body.input;
|
|
59
|
+
if (typeof inp === 'string') return inp;
|
|
60
|
+
if (Array.isArray(inp)) return inp.map((x) => textFromContent(x && (x.content ?? x))).join(' ');
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
const msgs = Array.isArray(body.messages) ? body.messages : [];
|
|
64
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
65
|
+
if (msgs[i] && msgs[i].role === 'user') return textFromContent(msgs[i].content);
|
|
66
|
+
}
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
|
|
40
70
|
const HOP_BY_HOP = new Set([
|
|
41
71
|
'host', 'connection', 'content-length', 'transfer-encoding',
|
|
42
72
|
'accept-encoding', 'content-encoding', 'keep-alive',
|
|
@@ -236,7 +266,7 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
236
266
|
|
|
237
267
|
// ---- backend: api ----------------------------------------------------------
|
|
238
268
|
|
|
239
|
-
async function handleApi(req, res, { adapter, pathname, search, base, destKey, destProtocol }, outBody, vault) {
|
|
269
|
+
async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol }, outBody, vault) {
|
|
240
270
|
let upstream;
|
|
241
271
|
try {
|
|
242
272
|
const headers = forwardHeaders(req.headers, base);
|
|
@@ -261,7 +291,10 @@ async function handleApi(req, res, { adapter, pathname, search, base, destKey, d
|
|
|
261
291
|
|
|
262
292
|
if (ct.includes('text/event-stream') && upstream.body) {
|
|
263
293
|
res.writeHead(upstream.status, resHeaders);
|
|
264
|
-
|
|
294
|
+
// OpenAI streaming: restore tool-call args with real values (aliases undone)
|
|
295
|
+
// while keeping visible text pseudonymized. Other protocols: generic restore.
|
|
296
|
+
const pipe = kind === 'openai' ? pipeRestoredOpenAIStream : pipeRestoredStream;
|
|
297
|
+
return pipe(upstream.body, res, vault);
|
|
265
298
|
}
|
|
266
299
|
|
|
267
300
|
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
@@ -342,10 +375,24 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
342
375
|
let body = null;
|
|
343
376
|
let outBody = raw;
|
|
344
377
|
let redactedCount = 0;
|
|
378
|
+
let narrowedTools = 0;
|
|
345
379
|
let isPro = true;
|
|
346
380
|
if (r.redactable && req.method === 'POST' && raw.length) {
|
|
347
381
|
try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
|
|
348
382
|
if (body) {
|
|
383
|
+
// Auto-narrow tools to the top-K most relevant for this turn (speed) —
|
|
384
|
+
// same shared ranker as the extension's AUTO mode. Only MCP-named tools
|
|
385
|
+
// are narrowed; the client's core tools (bash/read/edit…) are always kept,
|
|
386
|
+
// unless tools.narrowAll is set. Mutates body.tools BEFORE redaction so
|
|
387
|
+
// both the API forward and the bridge relay see the trimmed set.
|
|
388
|
+
const tcfg = cfg.tools || {};
|
|
389
|
+
if (tcfg.autoNarrow !== false && Array.isArray(body.tools) && body.tools.length) {
|
|
390
|
+
const cap = Number(tcfg.maxPerTurn) > 0 ? Number(tcfg.maxPerTurn) : DEFAULT_GATEWAY_TOOL_CAP;
|
|
391
|
+
const keep = tcfg.narrowAll ? null : (t) => !MCP_NAME_RE.test(toolName(t));
|
|
392
|
+
const before = body.tools.length;
|
|
393
|
+
body.tools = narrowSpecs(body.tools, latestUserText(body, r.kind), { cap, keep, name: toolName, description: toolDesc });
|
|
394
|
+
narrowedTools = before - body.tools.length;
|
|
395
|
+
}
|
|
349
396
|
// Free/Pro gate: meter the request and pick the effective tier.
|
|
350
397
|
isPro = await resolvePro(cfg.pro?.entitlementToken);
|
|
351
398
|
const allow = meter(cfg, isPro);
|
|
@@ -369,8 +416,8 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
369
416
|
// API we forward to). Falls back to the legacy backend when none configured.
|
|
370
417
|
const dest = resolveDestination(body?.model, cfg, r.kind);
|
|
371
418
|
if (cfg.logRequests && r.redactable) {
|
|
372
|
-
recordRequest({ t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount });
|
|
373
|
-
console.log(`[gateway] ${req.method} ${pathname} · model=${body?.model || '-'} → ${dest ? `${dest.id}(${dest.type})` : 'none'} · redacted ${redactedCount}`);
|
|
419
|
+
recordRequest({ t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount, narrowed: narrowedTools });
|
|
420
|
+
console.log(`[gateway] ${req.method} ${pathname} · model=${body?.model || '-'} → ${dest ? `${dest.id}(${dest.type})` : 'none'} · redacted ${redactedCount}${narrowedTools ? ` · narrowed -${narrowedTools} tools` : ''}`);
|
|
374
421
|
}
|
|
375
422
|
if (dest && dest.type === 'api') {
|
|
376
423
|
if (!dest.baseUrl) return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` });
|
package/src/stream.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// agent unchanged — its defined "permanent substitution" behavior. Only reversible
|
|
13
13
|
// [[TYPE_n]] tokens are restored here.
|
|
14
14
|
|
|
15
|
-
import { restoreText } from '@chatpanel/pii';
|
|
15
|
+
import { restoreText, restoreWithAliases } from '@chatpanel/pii';
|
|
16
16
|
|
|
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
|
|
@@ -60,6 +60,97 @@ export function restoreDeep(value, vault) {
|
|
|
60
60
|
return value;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Deep-restore for tool-call argument values (non-stream), undoing aliases too —
|
|
64
|
+
// so a client running tools LOCALLY gets the REAL value (pseudonyms included),
|
|
65
|
+
// while the model stays blinded. Mirrors the extension's restoreDeep.
|
|
66
|
+
export function restoreDeepAliases(value, vault) {
|
|
67
|
+
if (!vault) return value;
|
|
68
|
+
if (typeof value === 'string') return restoreWithAliases(value, vault);
|
|
69
|
+
if (Array.isArray(value)) return value.map((v) => restoreDeepAliases(v, vault));
|
|
70
|
+
if (value && typeof value === 'object') {
|
|
71
|
+
const out = {};
|
|
72
|
+
for (const k of Object.keys(value)) out[k] = restoreDeepAliases(value[k], vault);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A per-field tail-buffered restorer (holds a partial trailing [[token). `restoreFn`
|
|
79
|
+
// is restoreText for VISIBLE text (keep pseudonyms) or restoreWithAliases for
|
|
80
|
+
// TOOL-CALL args (real values).
|
|
81
|
+
function makeFieldRestorer(vault, restoreFn) {
|
|
82
|
+
let buf = '';
|
|
83
|
+
return {
|
|
84
|
+
push(chunk) {
|
|
85
|
+
buf += chunk || '';
|
|
86
|
+
const open = buf.lastIndexOf('[[');
|
|
87
|
+
let safe;
|
|
88
|
+
if (open !== -1 && !buf.slice(open).includes(']]')) { safe = buf.slice(0, open); buf = buf.slice(open); }
|
|
89
|
+
else { safe = buf; buf = ''; }
|
|
90
|
+
return restoreFn(safe, vault);
|
|
91
|
+
},
|
|
92
|
+
flush() { const out = restoreFn(buf, vault); buf = ''; return out; },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// OpenAI streaming restorer that restores VISIBLE content with restoreText (the
|
|
97
|
+
// model + user keep the pseudonym) but TOOL-CALL argument deltas with
|
|
98
|
+
// restoreWithAliases (the client runs the tool on the REAL value). Passes through
|
|
99
|
+
// any non-JSON event untouched.
|
|
100
|
+
export async function pipeRestoredOpenAIStream(upstreamBody, nodeRes, vault) {
|
|
101
|
+
const reader = upstreamBody.getReader();
|
|
102
|
+
const decoder = new TextDecoder();
|
|
103
|
+
let buf = '';
|
|
104
|
+
const contentR = makeFieldRestorer(vault, restoreText);
|
|
105
|
+
const argRs = new Map(); // tool_call index -> field restorer (aliases)
|
|
106
|
+
|
|
107
|
+
const handleBlock = (block) => {
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const line of block.split('\n')) {
|
|
110
|
+
if (!line.startsWith('data:')) { out.push(line); continue; }
|
|
111
|
+
const payload = line.slice(5).replace(/^\s/, '');
|
|
112
|
+
if (!payload) { out.push(line); continue; }
|
|
113
|
+
if (payload === '[DONE]') {
|
|
114
|
+
const tail = contentR.flush();
|
|
115
|
+
if (tail) out.push(`data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: tail }, finish_reason: null }] })}`);
|
|
116
|
+
out.push(line);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
let evt; try { evt = JSON.parse(payload); } catch { out.push(line); continue; }
|
|
120
|
+
for (const choice of evt.choices || []) {
|
|
121
|
+
const d = choice.delta;
|
|
122
|
+
if (!d) continue;
|
|
123
|
+
if (typeof d.content === 'string') d.content = contentR.push(d.content);
|
|
124
|
+
for (const tc of d.tool_calls || []) {
|
|
125
|
+
const idx = typeof tc.index === 'number' ? tc.index : 0;
|
|
126
|
+
if (tc.function && typeof tc.function.arguments === 'string') {
|
|
127
|
+
if (!argRs.has(idx)) argRs.set(idx, makeFieldRestorer(vault, restoreWithAliases));
|
|
128
|
+
tc.function.arguments = argRs.get(idx).push(tc.function.arguments);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
out.push(`data: ${JSON.stringify(evt)}`);
|
|
133
|
+
}
|
|
134
|
+
return out.join('\n');
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
for (;;) {
|
|
139
|
+
const { done, value } = await reader.read();
|
|
140
|
+
if (done) break;
|
|
141
|
+
buf += decoder.decode(value, { stream: true });
|
|
142
|
+
let i;
|
|
143
|
+
while ((i = buf.indexOf('\n\n')) !== -1) {
|
|
144
|
+
nodeRes.write(handleBlock(buf.slice(0, i)) + '\n\n');
|
|
145
|
+
buf = buf.slice(i + 2);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (buf) nodeRes.write(handleBlock(buf));
|
|
149
|
+
} finally {
|
|
150
|
+
nodeRes.end();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
63
154
|
// Pipe a fetch Response body (web ReadableStream) through the restorer into a
|
|
64
155
|
// Node response. Works on raw bytes decoded as UTF-8 — fine because placeholders
|
|
65
156
|
// are ASCII, so even if a multibyte char is split the token bytes are intact.
|