@chatpanel/gateway 0.2.2 → 0.3.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 +1 -1
- package/src/openai.js +16 -0
- package/src/server.js +83 -8
- package/src/shape.js +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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": {
|
package/src/openai.js
CHANGED
|
@@ -37,6 +37,22 @@ export function toTurn(body) {
|
|
|
37
37
|
return { messages: Array.isArray(body?.messages) ? body.messages : [], system: '' };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Tool-relay: the client's tool definitions, and (on a follow-up request) the
|
|
41
|
+
// most recent tool result the client sent back.
|
|
42
|
+
export function extractTools(body) {
|
|
43
|
+
return Array.isArray(body?.tools) ? body.tools : [];
|
|
44
|
+
}
|
|
45
|
+
export function extractLatestToolResult(body) {
|
|
46
|
+
const msgs = Array.isArray(body?.messages) ? body.messages : [];
|
|
47
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
48
|
+
const m = msgs[i];
|
|
49
|
+
if (m?.role === 'tool' && m.tool_call_id) {
|
|
50
|
+
return { tool_call_id: m.tool_call_id, content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
// Restore a buffered (non-streaming) response: assistant text + tool-call args.
|
|
41
57
|
export function restoreResponse(json, vault) {
|
|
42
58
|
for (const choice of json?.choices || []) {
|
package/src/server.js
CHANGED
|
@@ -21,8 +21,9 @@ import { createServer } from 'node:http';
|
|
|
21
21
|
import { loadConfig } from './config.js';
|
|
22
22
|
import { redactSegments } from './redact.js';
|
|
23
23
|
import { pipeRestoredStream, makeTokenRestorer } from './stream.js';
|
|
24
|
-
import { restoreText } from '@chatpanel/pii';
|
|
25
|
-
import { streamBridgeChat, readBridgeToken } from './bridge.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.0';
|
|
36
37
|
|
|
37
38
|
const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
|
|
38
39
|
|
|
@@ -67,6 +68,14 @@ function setCors(res, origin) {
|
|
|
67
68
|
|
|
68
69
|
const STARTED_AT = Date.now();
|
|
69
70
|
|
|
71
|
+
// In-memory ring of recent request SUMMARIES for the extension's monitoring view.
|
|
72
|
+
// Counts only — never any prompt/response text or values.
|
|
73
|
+
const recentRequests = [];
|
|
74
|
+
function recordRequest(entry) {
|
|
75
|
+
recentRequests.push(entry);
|
|
76
|
+
if (recentRequests.length > 50) recentRequests.shift();
|
|
77
|
+
}
|
|
78
|
+
|
|
70
79
|
// Classify a request: which protocol kind + adapter, whether it's a redactable
|
|
71
80
|
// chat endpoint, and (api backend) which upstream base URL.
|
|
72
81
|
function route(pathname, headers, cfg) {
|
|
@@ -123,11 +132,70 @@ function forwardHeaders(headers, base) {
|
|
|
123
132
|
|
|
124
133
|
// ---- backend: bridge -------------------------------------------------------
|
|
125
134
|
|
|
126
|
-
|
|
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) {
|
|
127
180
|
if (!redactable) {
|
|
128
181
|
return sendJson(res, 404, { error: `endpoint ${pathname} not supported by the bridge backend` });
|
|
129
182
|
}
|
|
130
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
|
+
|
|
131
199
|
const { messages, system } = adapter.toTurn(body);
|
|
132
200
|
const agent = agentOverride || pickAgent(body?.model, cfg);
|
|
133
201
|
const wantStream = body?.stream === true;
|
|
@@ -237,6 +305,9 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
237
305
|
uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
|
|
238
306
|
});
|
|
239
307
|
}
|
|
308
|
+
if (pathname === '/logs' && req.method === 'GET') {
|
|
309
|
+
return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only
|
|
310
|
+
}
|
|
240
311
|
if (pathname === '/config' && req.method === 'GET') {
|
|
241
312
|
const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
|
|
242
313
|
return sendJson(res, 200, publicConfig(cfg, { proUnlocked }));
|
|
@@ -270,11 +341,13 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
270
341
|
let vault = null;
|
|
271
342
|
let body = null;
|
|
272
343
|
let outBody = raw;
|
|
344
|
+
let redactedCount = 0;
|
|
345
|
+
let isPro = true;
|
|
273
346
|
if (r.redactable && req.method === 'POST' && raw.length) {
|
|
274
347
|
try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
|
|
275
348
|
if (body) {
|
|
276
349
|
// Free/Pro gate: meter the request and pick the effective tier.
|
|
277
|
-
|
|
350
|
+
isPro = await resolvePro(cfg.pro?.entitlementToken);
|
|
278
351
|
const allow = meter(cfg, isPro);
|
|
279
352
|
if (!allow.allowed) {
|
|
280
353
|
return sendJson(res, 402, { error: {
|
|
@@ -287,6 +360,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
287
360
|
req.on('close', () => ac.abort());
|
|
288
361
|
const { vault: v, count } = await redactSegments(segs, cfg.redaction, { signal: ac.signal, isPro });
|
|
289
362
|
vault = v;
|
|
363
|
+
redactedCount = count;
|
|
290
364
|
outBody = Buffer.from(JSON.stringify(body), 'utf8');
|
|
291
365
|
}
|
|
292
366
|
}
|
|
@@ -294,8 +368,9 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
294
368
|
// Route by the requested model → a destination (agent via the bridge, or an
|
|
295
369
|
// API we forward to). Falls back to the legacy backend when none configured.
|
|
296
370
|
const dest = resolveDestination(body?.model, cfg, r.kind);
|
|
297
|
-
if (cfg.logRequests) {
|
|
298
|
-
|
|
371
|
+
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}`);
|
|
299
374
|
}
|
|
300
375
|
if (dest && dest.type === 'api') {
|
|
301
376
|
if (!dest.baseUrl) return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` });
|
|
@@ -304,7 +379,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
304
379
|
}
|
|
305
380
|
return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol }, outBody, vault);
|
|
306
381
|
}
|
|
307
|
-
return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent }, body, vault, cfg);
|
|
382
|
+
return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent }, body, vault, cfg, isPro);
|
|
308
383
|
});
|
|
309
384
|
}
|
|
310
385
|
|
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
|
|