@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.2.2",
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.2.2';
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
- async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride }, body, vault, cfg) {
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
- const isPro = await resolvePro(cfg.pro?.entitlementToken);
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
- console.log(`[gateway] ${req.method} ${pathname} · model=${body?.model || '-'} ${dest ? `${dest.id}(${dest.type})` : 'none'}`);
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