@chatpanel/gateway 0.3.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.3.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 { restoreDeep } from './stream.js';
8
+ import { restoreDeepAliases } from './stream.js';
8
9
 
9
10
  export function matches(pathname) {
10
11
  return /\/chat\/completions$/.test(pathname) || /\/completions$/.test(pathname);
@@ -58,10 +59,12 @@ export function restoreResponse(json, vault) {
58
59
  for (const choice of json?.choices || []) {
59
60
  const msg = choice?.message;
60
61
  if (!msg) continue;
61
- if (typeof msg.content === 'string') msg.content = restoreDeep(msg.content, vault);
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);
62
65
  for (const tc of msg.tool_calls || []) {
63
66
  if (tc?.function && typeof tc.function.arguments === 'string') {
64
- tc.function.arguments = restoreDeep(tc.function.arguments, vault);
67
+ tc.function.arguments = restoreDeepAliases(tc.function.arguments, vault);
65
68
  }
66
69
  }
67
70
  }
package/src/server.js CHANGED
@@ -20,7 +20,7 @@
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';
23
+ import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer } from './stream.js';
24
24
  import { restoreText, effectiveTier, gatedDictionary } from '@chatpanel/pii';
25
25
  import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
26
26
  import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
@@ -33,7 +33,7 @@ import * as openai from './openai.js';
33
33
  import * as responses from './responses.js';
34
34
  import * as anthropic from './anthropic.js';
35
35
 
36
- export const VERSION = '0.3.0';
36
+ export const VERSION = '0.3.1';
37
37
 
38
38
  const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
39
39
 
@@ -236,7 +236,7 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
236
236
 
237
237
  // ---- backend: api ----------------------------------------------------------
238
238
 
239
- 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) {
240
240
  let upstream;
241
241
  try {
242
242
  const headers = forwardHeaders(req.headers, base);
@@ -261,7 +261,10 @@ async function handleApi(req, res, { adapter, pathname, search, base, destKey, d
261
261
 
262
262
  if (ct.includes('text/event-stream') && upstream.body) {
263
263
  res.writeHead(upstream.status, resHeaders);
264
- return pipeRestoredStream(upstream.body, res, vault);
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);
265
268
  }
266
269
 
267
270
  const buf = Buffer.from(await upstream.arrayBuffer());
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.