@chatpanel/gateway 0.2.3 → 0.3.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 +1 -1
- package/src/openai.js +22 -3
- package/src/server.js +73 -9
- package/src/shape.js +8 -0
- 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.3.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": {
|
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);
|
|
@@ -37,15 +38,33 @@ export function toTurn(body) {
|
|
|
37
38
|
return { messages: Array.isArray(body?.messages) ? body.messages : [], system: '' };
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
// Tool-relay: the client's tool definitions, and (on a follow-up request) the
|
|
42
|
+
// most recent tool result the client sent back.
|
|
43
|
+
export function extractTools(body) {
|
|
44
|
+
return Array.isArray(body?.tools) ? body.tools : [];
|
|
45
|
+
}
|
|
46
|
+
export function extractLatestToolResult(body) {
|
|
47
|
+
const msgs = Array.isArray(body?.messages) ? body.messages : [];
|
|
48
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
49
|
+
const m = msgs[i];
|
|
50
|
+
if (m?.role === 'tool' && m.tool_call_id) {
|
|
51
|
+
return { tool_call_id: m.tool_call_id, content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
40
57
|
// Restore a buffered (non-streaming) response: assistant text + tool-call args.
|
|
41
58
|
export function restoreResponse(json, vault) {
|
|
42
59
|
for (const choice of json?.choices || []) {
|
|
43
60
|
const msg = choice?.message;
|
|
44
61
|
if (!msg) continue;
|
|
45
|
-
|
|
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);
|
|
46
65
|
for (const tc of msg.tool_calls || []) {
|
|
47
66
|
if (tc?.function && typeof tc.function.arguments === 'string') {
|
|
48
|
-
tc.function.arguments =
|
|
67
|
+
tc.function.arguments = restoreDeepAliases(tc.function.arguments, vault);
|
|
49
68
|
}
|
|
50
69
|
}
|
|
51
70
|
}
|
package/src/server.js
CHANGED
|
@@ -20,9 +20,10 @@
|
|
|
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 } from '@chatpanel/pii';
|
|
25
|
-
import { streamBridgeChat, readBridgeToken } from './bridge.js';
|
|
23
|
+
import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer } from './stream.js';
|
|
24
|
+
import { restoreText, effectiveTier, gatedDictionary } from '@chatpanel/pii';
|
|
25
|
+
import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
|
|
26
|
+
import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
|
|
26
27
|
import { shaperFor } from './shape.js';
|
|
27
28
|
import { startNer } from './ner.js';
|
|
28
29
|
import { resolvePro, meter, usage } from './freegate.js';
|
|
@@ -32,7 +33,7 @@ import * as openai from './openai.js';
|
|
|
32
33
|
import * as responses from './responses.js';
|
|
33
34
|
import * as anthropic from './anthropic.js';
|
|
34
35
|
|
|
35
|
-
export const VERSION = '0.
|
|
36
|
+
export const VERSION = '0.3.1';
|
|
36
37
|
|
|
37
38
|
const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
|
|
38
39
|
|
|
@@ -131,11 +132,70 @@ function forwardHeaders(headers, base) {
|
|
|
131
132
|
|
|
132
133
|
// ---- backend: bridge -------------------------------------------------------
|
|
133
134
|
|
|
134
|
-
|
|
135
|
+
// Stream the bridge SSE through the OpenAI shaper, parking on a tool call.
|
|
136
|
+
async function pumpRelay(res, s, shaper) {
|
|
137
|
+
const restorer = makeTokenRestorer(s.vault);
|
|
138
|
+
await pumpBridgeStream(s, {
|
|
139
|
+
onText: (text) => { const r = restorer.push(text); if (r) res.write(shaper.sseDelta(r)); },
|
|
140
|
+
onToolRequest: ({ name, restoredArgs, toolId }) => {
|
|
141
|
+
const tail = restorer.flush(); if (tail) res.write(shaper.sseDelta(tail));
|
|
142
|
+
res.write(shaper.sseToolCalls([{ id: toolId, name, arguments: JSON.stringify(restoredArgs) }]));
|
|
143
|
+
res.write(shaper.sseToolFinish());
|
|
144
|
+
res.end(); // park: turn ends with tool_calls; the session stays alive for the follow-up
|
|
145
|
+
},
|
|
146
|
+
onDone: () => { const tail = restorer.flush(); if (tail) res.write(shaper.sseDelta(tail)); res.write(shaper.sseTail()); res.end(); endRelaySession(s.id); },
|
|
147
|
+
onError: (e) => { res.write(`data: ${JSON.stringify({ error: { message: e.message, type: 'bridge_error' } })}\n\n`); res.end(); endRelaySession(s.id); },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// New tool-enabled turn: open the bridge with the client's tools as MCP specs.
|
|
152
|
+
async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg, isPro, tools) {
|
|
153
|
+
const { messages, system } = adapter.toTurn(body);
|
|
154
|
+
const token = readBridgeToken(cfg.bridge.token);
|
|
155
|
+
const shaper = shaperFor(kind, body?.model || agent);
|
|
156
|
+
const redactOpts = { tier: effectiveTier({ tier: cfg.redaction.tier }, isPro), dictionary: gatedDictionary(cfg.redaction, isPro), entities: [] };
|
|
157
|
+
const s = createRelaySession({ vault, redactOpts, bridgeUrl: cfg.bridge.url, token });
|
|
158
|
+
const ttl = setTimeout(() => endRelaySession(s.id), 135_000); // bridge tool-call timeout is 120s
|
|
159
|
+
let resp;
|
|
160
|
+
try {
|
|
161
|
+
resp = await openBridgeChat({ bridgeUrl: cfg.bridge.url, agent, token, messages, system, specs: toolsToSpecs(tools), options: {}, signal: undefined });
|
|
162
|
+
} catch (e) { clearTimeout(ttl); endRelaySession(s.id); return sendJson(res, 502, { error: { message: `bridge: ${e.message}`, type: 'bridge_error' } }); }
|
|
163
|
+
s.reader = resp.body.getReader();
|
|
164
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
165
|
+
res.write(shaper.sseHead());
|
|
166
|
+
return pumpRelay(res, s, shaper);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Follow-up turn carrying a tool result: feed it to the parked agent + resume.
|
|
170
|
+
async function resumeRelay(res, s, toolContent, model) {
|
|
171
|
+
try { await deliverToolResult(s, toolContent); }
|
|
172
|
+
catch (e) { endRelaySession(s.id); return sendJson(res, 502, { error: { message: `tool-result: ${e.message}`, type: 'bridge_error' } }); }
|
|
173
|
+
const shaper = shaperFor('openai', model || 'codex');
|
|
174
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
175
|
+
res.write(shaper.sseHead());
|
|
176
|
+
return pumpRelay(res, s, shaper);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride }, body, vault, cfg, isPro) {
|
|
135
180
|
if (!redactable) {
|
|
136
181
|
return sendJson(res, 404, { error: `endpoint ${pathname} not supported by the bridge backend` });
|
|
137
182
|
}
|
|
138
183
|
|
|
184
|
+
// Tool relay (OpenAI protocol + agent destinations). A follow-up request carries
|
|
185
|
+
// a tool result for a parked session; a new request with `tools` starts one.
|
|
186
|
+
if (kind === 'openai') {
|
|
187
|
+
const toolResult = adapter.extractLatestToolResult(body);
|
|
188
|
+
if (toolResult) {
|
|
189
|
+
const parsed = parseToolCallId(toolResult.tool_call_id);
|
|
190
|
+
const s = parsed && getRelaySession(parsed.gwId);
|
|
191
|
+
if (s) return resumeRelay(res, s, toolResult.content, body?.model);
|
|
192
|
+
}
|
|
193
|
+
const tools = adapter.extractTools(body);
|
|
194
|
+
if (tools.length && body?.stream === true) {
|
|
195
|
+
return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg) }, body, vault, cfg, isPro, tools);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
139
199
|
const { messages, system } = adapter.toTurn(body);
|
|
140
200
|
const agent = agentOverride || pickAgent(body?.model, cfg);
|
|
141
201
|
const wantStream = body?.stream === true;
|
|
@@ -176,7 +236,7 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
176
236
|
|
|
177
237
|
// ---- backend: api ----------------------------------------------------------
|
|
178
238
|
|
|
179
|
-
async function handleApi(req, res, { adapter, pathname, search, base, destKey, destProtocol }, outBody, vault) {
|
|
239
|
+
async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol }, outBody, vault) {
|
|
180
240
|
let upstream;
|
|
181
241
|
try {
|
|
182
242
|
const headers = forwardHeaders(req.headers, base);
|
|
@@ -201,7 +261,10 @@ async function handleApi(req, res, { adapter, pathname, search, base, destKey, d
|
|
|
201
261
|
|
|
202
262
|
if (ct.includes('text/event-stream') && upstream.body) {
|
|
203
263
|
res.writeHead(upstream.status, resHeaders);
|
|
204
|
-
|
|
264
|
+
// OpenAI streaming: restore tool-call args with real values (aliases undone)
|
|
265
|
+
// while keeping visible text pseudonymized. Other protocols: generic restore.
|
|
266
|
+
const pipe = kind === 'openai' ? pipeRestoredOpenAIStream : pipeRestoredStream;
|
|
267
|
+
return pipe(upstream.body, res, vault);
|
|
205
268
|
}
|
|
206
269
|
|
|
207
270
|
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
@@ -282,11 +345,12 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
282
345
|
let body = null;
|
|
283
346
|
let outBody = raw;
|
|
284
347
|
let redactedCount = 0;
|
|
348
|
+
let isPro = true;
|
|
285
349
|
if (r.redactable && req.method === 'POST' && raw.length) {
|
|
286
350
|
try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
|
|
287
351
|
if (body) {
|
|
288
352
|
// Free/Pro gate: meter the request and pick the effective tier.
|
|
289
|
-
|
|
353
|
+
isPro = await resolvePro(cfg.pro?.entitlementToken);
|
|
290
354
|
const allow = meter(cfg, isPro);
|
|
291
355
|
if (!allow.allowed) {
|
|
292
356
|
return sendJson(res, 402, { error: {
|
|
@@ -318,7 +382,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
318
382
|
}
|
|
319
383
|
return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol }, outBody, vault);
|
|
320
384
|
}
|
|
321
|
-
return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent }, body, vault, cfg);
|
|
385
|
+
return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent }, body, vault, cfg, isPro);
|
|
322
386
|
});
|
|
323
387
|
}
|
|
324
388
|
|
package/src/shape.js
CHANGED
|
@@ -38,6 +38,14 @@ export function openaiChat(model) {
|
|
|
38
38
|
sseTail() {
|
|
39
39
|
return sse({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }) + 'data: [DONE]\n\n';
|
|
40
40
|
},
|
|
41
|
+
// Tool-relay (agent destinations): emit the agent's tool call as an OpenAI
|
|
42
|
+
// tool_calls delta, then end the turn with finish_reason:tool_calls.
|
|
43
|
+
sseToolCalls(calls) {
|
|
44
|
+
return sse({ ...base, choices: [{ index: 0, delta: { tool_calls: calls.map((c, i) => ({ index: i, id: c.id, type: 'function', function: { name: c.name, arguments: c.arguments } })) }, finish_reason: null }] });
|
|
45
|
+
},
|
|
46
|
+
sseToolFinish() {
|
|
47
|
+
return sse({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }) + 'data: [DONE]\n\n';
|
|
48
|
+
},
|
|
41
49
|
};
|
|
42
50
|
}
|
|
43
51
|
|
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.
|