@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.2.3",
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.3';
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
- 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) {
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
- const isPro = await resolvePro(cfg.pro?.entitlementToken);
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