@chatpanel/gateway 0.2.3 → 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 +67 -6
- 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
|
|
|
@@ -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;
|
|
@@ -282,11 +342,12 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
282
342
|
let body = null;
|
|
283
343
|
let outBody = raw;
|
|
284
344
|
let redactedCount = 0;
|
|
345
|
+
let isPro = true;
|
|
285
346
|
if (r.redactable && req.method === 'POST' && raw.length) {
|
|
286
347
|
try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
|
|
287
348
|
if (body) {
|
|
288
349
|
// Free/Pro gate: meter the request and pick the effective tier.
|
|
289
|
-
|
|
350
|
+
isPro = await resolvePro(cfg.pro?.entitlementToken);
|
|
290
351
|
const allow = meter(cfg, isPro);
|
|
291
352
|
if (!allow.allowed) {
|
|
292
353
|
return sendJson(res, 402, { error: {
|
|
@@ -318,7 +379,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
318
379
|
}
|
|
319
380
|
return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol }, outBody, vault);
|
|
320
381
|
}
|
|
321
|
-
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);
|
|
322
383
|
});
|
|
323
384
|
}
|
|
324
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
|
|