@chatpanel/gateway 0.6.3 → 0.6.5
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/README.md +36 -5
- package/package.json +1 -1
- package/src/config.js +8 -0
- package/src/configstore.js +3 -1
- package/src/server.js +152 -35
package/README.md
CHANGED
|
@@ -43,9 +43,14 @@ You need the [ChatPanel bridge](https://github.com/chatpanel/chatpanel-bridge)
|
|
|
43
43
|
running and logged into codex/claude (the same bridge the extension uses).
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
|
+
# Standalone binary — no Node.js required:
|
|
47
|
+
curl -fsSL https://dl.chatpanel.net/gateway/install.sh | bash # macOS / Linux
|
|
48
|
+
# Windows (PowerShell): irm https://dl.chatpanel.net/gateway/install.ps1 | iex
|
|
49
|
+
|
|
50
|
+
# Or via npm (needs Node):
|
|
46
51
|
npm install -g @chatpanel/gateway
|
|
47
52
|
chatpanel-gateway
|
|
48
|
-
# → ChatPanel Privacy Gateway
|
|
53
|
+
# → ChatPanel Privacy Gateway on http://127.0.0.1:4320
|
|
49
54
|
# backend : bridge (agent: codex, via http://127.0.0.1:4319)
|
|
50
55
|
```
|
|
51
56
|
|
|
@@ -68,16 +73,42 @@ before codex sees it, and the reply is restored before opencode renders it. The
|
|
|
68
73
|
request's `model` (`codex`/`claude`/`opencode`/`pi`) picks which agent the bridge
|
|
69
74
|
drives; otherwise the configured default (`codex`) is used.
|
|
70
75
|
|
|
71
|
-
###
|
|
76
|
+
### Quick start (api backend — no bridge)
|
|
72
77
|
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
If all you want is the gateway as a **redacting proxy in front of an API model**,
|
|
79
|
+
you do **not** need the bridge. Set `backend: "api"` and (optionally) point the
|
|
80
|
+
gateway's upstream at your provider — in `~/.chatpanel/gateway.config.json`:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"backend": "api",
|
|
85
|
+
"upstreams": {
|
|
86
|
+
"openai": { "baseUrl": "https://api.openai.com" },
|
|
87
|
+
"anthropic": { "baseUrl": "https://api.anthropic.com" }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Then point your **client** at the gateway and send your **own** API key — the
|
|
93
|
+
gateway redacts, forwards to the provider with your key (it stores none), and
|
|
94
|
+
restores the reply:
|
|
75
95
|
|
|
76
96
|
```bash
|
|
77
|
-
|
|
97
|
+
# In your CLIENT's environment (NOT the gateway's — see the footgun below):
|
|
98
|
+
export OPENAI_BASE_URL=http://127.0.0.1:4320/v1 # OpenAI-compatible: codex / aider / cursor / SDKs
|
|
78
99
|
export ANTHROPIC_BASE_URL=http://127.0.0.1:4320 # Claude Code / Anthropic SDK
|
|
79
100
|
```
|
|
80
101
|
|
|
102
|
+
To target a non-OpenAI provider (a local model, OpenRouter, Azure, …) change the
|
|
103
|
+
**gateway's** `upstreams.*.baseUrl` in the config above — that's where the gateway
|
|
104
|
+
forwards to. Flow: `client → gateway (redact) → provider (your key) → gateway (restore) → client`.
|
|
105
|
+
|
|
106
|
+
> ⚠️ **Footgun:** `OPENAI_BASE_URL` means two different things — for your *client*
|
|
107
|
+
> it's "where the gateway is", for the *gateway* it's "where my upstream is". Don't
|
|
108
|
+
> set `OPENAI_BASE_URL=…:4320` in the **gateway's own** environment, or it forwards
|
|
109
|
+
> to itself (the loop guard returns 508). Set the gateway's upstream in the config
|
|
110
|
+
> file; use the env var only in the client's shell.
|
|
111
|
+
|
|
81
112
|
## Name/org redaction is built in (in-process NER, no Python)
|
|
82
113
|
|
|
83
114
|
Deterministic redaction (emails, phones, cards, SSNs, API keys, IPs) needs no
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
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/config.js
CHANGED
|
@@ -96,6 +96,14 @@ const DEFAULTS = {
|
|
|
96
96
|
|
|
97
97
|
// Log one line per request (method, tokens redacted) without any raw values.
|
|
98
98
|
logRequests: true,
|
|
99
|
+
|
|
100
|
+
// Optional per-request redaction breakdown attached to each log entry (shown
|
|
101
|
+
// expandable in the extension). Memory-only — never persisted to disk with the
|
|
102
|
+
// captured values; only the MODE is saved.
|
|
103
|
+
// 'off' — counts only (default; the privacy-safe baseline)
|
|
104
|
+
// 'types' — entity types + placeholder tokens (e.g. PERSON_1), no real values
|
|
105
|
+
// 'values' — real → placeholder mapping (the actual PII; opt-in, debugging)
|
|
106
|
+
logDetail: 'off',
|
|
99
107
|
};
|
|
100
108
|
|
|
101
109
|
function deepMerge(base, over) {
|
package/src/configstore.js
CHANGED
|
@@ -22,7 +22,7 @@ export function persistConfig(cfg, path = configPath()) {
|
|
|
22
22
|
destinations: cfg.destinations,
|
|
23
23
|
bridge: cfg.bridge, upstreams: cfg.upstreams, redaction: cfg.redaction,
|
|
24
24
|
ner: cfg.ner, allowedOrigins: cfg.allowedOrigins, maxBodyBytes: cfg.maxBodyBytes,
|
|
25
|
-
pro: cfg.pro, logRequests: cfg.logRequests, tools: cfg.tools,
|
|
25
|
+
pro: cfg.pro, logRequests: cfg.logRequests, logDetail: cfg.logDetail, tools: cfg.tools,
|
|
26
26
|
};
|
|
27
27
|
writeFileSync(path, JSON.stringify(out, null, 2));
|
|
28
28
|
}
|
|
@@ -47,6 +47,7 @@ export function publicConfig(cfg, { proUnlocked = false } = {}) {
|
|
|
47
47
|
allowedOrigins: Array.isArray(cfg.allowedOrigins) ? cfg.allowedOrigins : [],
|
|
48
48
|
pro: { unlocked: proUnlocked, hasToken: !!cfg.pro?.entitlementToken, free: cfg.pro?.free },
|
|
49
49
|
logRequests: !!cfg.logRequests,
|
|
50
|
+
logDetail: ['types', 'values'].includes(cfg.logDetail) ? cfg.logDetail : 'off',
|
|
50
51
|
tools: {
|
|
51
52
|
autoNarrow: cfg.tools?.autoNarrow !== false,
|
|
52
53
|
maxPerTurn: Number(cfg.tools?.maxPerTurn) > 0 ? Number(cfg.tools.maxPerTurn) : 8,
|
|
@@ -105,6 +106,7 @@ export function applyConfigPatch(cfg, patch = {}) {
|
|
|
105
106
|
if (Number.isFinite(cap) && cap >= 0) cfg.pro.free.maxRequestsPerDay = cap;
|
|
106
107
|
}
|
|
107
108
|
if (typeof patch.logRequests === 'boolean') cfg.logRequests = patch.logRequests;
|
|
109
|
+
if (['off', 'types', 'values'].includes(patch.logDetail)) cfg.logDetail = patch.logDetail;
|
|
108
110
|
if (patch.tools && typeof patch.tools === 'object') {
|
|
109
111
|
cfg.tools = cfg.tools || {};
|
|
110
112
|
if ('autoNarrow' in patch.tools) cfg.tools.autoNarrow = !!patch.tools.autoNarrow;
|
package/src/server.js
CHANGED
|
@@ -35,7 +35,7 @@ import * as openai from './openai.js';
|
|
|
35
35
|
import * as responses from './responses.js';
|
|
36
36
|
import * as anthropic from './anthropic.js';
|
|
37
37
|
|
|
38
|
-
export const VERSION = '0.6.
|
|
38
|
+
export const VERSION = '0.6.5';
|
|
39
39
|
|
|
40
40
|
const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
|
|
41
41
|
|
|
@@ -100,12 +100,62 @@ function setCors(res, origin) {
|
|
|
100
100
|
|
|
101
101
|
const STARTED_AT = Date.now();
|
|
102
102
|
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
// Render a timings map as a compact one-liner for the console log.
|
|
104
|
+
function fmtTimings(t) {
|
|
105
|
+
if (!t) return '';
|
|
106
|
+
const label = { redact: 'redact', upstream: 'model', stream: 'stream', restore: 'restore', total: 'total' };
|
|
107
|
+
return Object.keys(t).map((k) => `${label[k] || k} ${t[k]}ms`).join(' · ');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Per-request timing + summary. Created ONLY when logging is on — when it's off
|
|
111
|
+
// the handler passes a null trace and every `trace?.…` call short-circuits, so we
|
|
112
|
+
// don't even read the clock: logging then adds zero latency. The committed entry
|
|
113
|
+
// is flushed to the in-memory ring via setImmediate, i.e. AFTER the response has
|
|
114
|
+
// been handed back, so recording (and the console line) never sits on the request
|
|
115
|
+
// path. `timings` are wall-clock ms per stage of the flow:
|
|
116
|
+
// redact prompt → harness[redact] → model input
|
|
117
|
+
// upstream model input → model output. Non-stream: full call. Stream: time to
|
|
118
|
+
// first token (model/connection latency). Shown as "model".
|
|
119
|
+
// stream first token → last token (generation), streaming responses only
|
|
120
|
+
// restore model output → harness[restore] → user response (non-stream; for
|
|
121
|
+
// streams restore is inline per chunk, so it's folded into stream)
|
|
122
|
+
// total end-to-end through the gateway
|
|
123
|
+
function mkTrace(sink) {
|
|
124
|
+
const start = performance.now();
|
|
125
|
+
const timings = {};
|
|
126
|
+
let done = false;
|
|
127
|
+
const mark = (name, ms) => { timings[name] = Math.round(ms * 10) / 10; };
|
|
128
|
+
return {
|
|
129
|
+
meta: {}, timings, mark,
|
|
130
|
+
// Stamp the duration of an already-completed stage (`t0` from clock()).
|
|
131
|
+
lap(name, t0) { mark(name, performance.now() - t0); },
|
|
132
|
+
clock() { return performance.now(); },
|
|
133
|
+
commit() {
|
|
134
|
+
if (done) return; done = true;
|
|
135
|
+
mark('total', performance.now() - start);
|
|
136
|
+
const entry = /** @type {any} */ ({ ...this.meta, timings });
|
|
137
|
+
setImmediate(() => {
|
|
138
|
+
sink(entry);
|
|
139
|
+
console.log(`[gateway] model=${entry.model || '-'} → ${entry.dest ? `${entry.dest}(${entry.type})` : 'none'} · redacted ${entry.redacted || 0}${entry.narrowed ? ` · narrowed -${entry.narrowed}` : ''} · ${fmtTimings(timings)}`);
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Build the optional per-request redaction breakdown from the request's vault.
|
|
146
|
+
// 'types' → [{ token:'PERSON_1', type:'PERSON' }] (no real values)
|
|
147
|
+
// 'values' → [{ token:'PERSON_1', type:'PERSON', value:'…' }] (the real PII; opt-in)
|
|
148
|
+
// Lives only in the in-memory ring — never written to disk by persistConfig.
|
|
149
|
+
function redactionDetail(vault, mode) {
|
|
150
|
+
if (!vault || !vault.byToken || (mode !== 'types' && mode !== 'values')) return undefined;
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const [token, value] of vault.byToken) {
|
|
153
|
+
const m = /^\[\[([A-Z][A-Z0-9]*)_\d+\]\]$/.exec(token);
|
|
154
|
+
const t = m ? m[1] : 'PII';
|
|
155
|
+
const bare = token.replace(/^\[\[|\]\]$/g, '');
|
|
156
|
+
out.push(mode === 'values' ? { token: bare, type: t, value } : { token: bare, type: t });
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
109
159
|
}
|
|
110
160
|
|
|
111
161
|
// Classify a request: which protocol kind + adapter, whether it's a redactable
|
|
@@ -213,23 +263,32 @@ function forwardHeaders(headers, base) {
|
|
|
213
263
|
// ---- backend: bridge -------------------------------------------------------
|
|
214
264
|
|
|
215
265
|
// Stream the bridge SSE through the OpenAI shaper, parking on a tool call.
|
|
216
|
-
|
|
266
|
+
// `trace` (when present) times the agent turn: 'upstream' = time to first token,
|
|
267
|
+
// 'stream' = first token → park/done. The turn ends at a tool-call (parked, the
|
|
268
|
+
// client runs the tool and POSTs back, resuming a fresh trace) or at onDone.
|
|
269
|
+
async function pumpRelay(res, s, shaper, trace) {
|
|
217
270
|
const restorer = makeTokenRestorer(s.vault);
|
|
271
|
+
const t0 = trace ? trace.clock() : 0;
|
|
272
|
+
let sStart = t0;
|
|
273
|
+
let first = true;
|
|
274
|
+
const tick = () => { if (trace && first) { trace.lap('upstream', t0); sStart = trace.clock(); first = false; } };
|
|
218
275
|
await pumpBridgeStream(s, {
|
|
219
|
-
onText: (text) => { const r = restorer.push(text); if (r) res.write(shaper.sseDelta(r)); },
|
|
276
|
+
onText: (text) => { tick(); const r = restorer.push(text); if (r) res.write(shaper.sseDelta(r)); },
|
|
220
277
|
onToolRequest: ({ name, restoredArgs, toolId }) => {
|
|
278
|
+
tick();
|
|
221
279
|
const tail = restorer.flush(); if (tail) res.write(shaper.sseDelta(tail));
|
|
222
280
|
res.write(shaper.sseToolCalls([{ id: toolId, name, arguments: JSON.stringify(restoredArgs) }]));
|
|
223
281
|
res.write(shaper.sseToolFinish());
|
|
224
|
-
|
|
282
|
+
if (trace) trace.lap('stream', sStart);
|
|
283
|
+
res.end(); trace?.commit(); // park: turn ends with tool_calls; the session stays alive for the follow-up
|
|
225
284
|
},
|
|
226
|
-
onDone: () => { const tail = restorer.flush(); if (tail) res.write(shaper.sseDelta(tail)); res.write(shaper.sseTail()); res.end(); endRelaySession(s.id); },
|
|
227
|
-
onError: (e) => { res.write(`data: ${JSON.stringify({ error: { message: e.message, type: 'bridge_error' } })}\n\n`); res.end(); endRelaySession(s.id); },
|
|
285
|
+
onDone: () => { const tail = restorer.flush(); if (tail) res.write(shaper.sseDelta(tail)); res.write(shaper.sseTail()); if (trace) trace.lap('stream', sStart); res.end(); endRelaySession(s.id); trace?.commit(); },
|
|
286
|
+
onError: (e) => { res.write(`data: ${JSON.stringify({ error: { message: e.message, type: 'bridge_error' } })}\n\n`); res.end(); endRelaySession(s.id); trace?.commit(); },
|
|
228
287
|
});
|
|
229
288
|
}
|
|
230
289
|
|
|
231
290
|
// New tool-enabled turn: open the bridge with the client's tools as MCP specs.
|
|
232
|
-
async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg, isPro, tools, harness = null) {
|
|
291
|
+
async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg, isPro, tools, harness = null, trace = null) {
|
|
233
292
|
const { messages, system } = adapter.toTurn(body);
|
|
234
293
|
const token = readBridgeToken(cfg.bridge.token);
|
|
235
294
|
const shaper = shaperFor(kind, body?.model || agent);
|
|
@@ -241,40 +300,48 @@ async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg,
|
|
|
241
300
|
let resp;
|
|
242
301
|
try {
|
|
243
302
|
resp = await openBridgeChat({ bridgeUrl: cfg.bridge.url, agent, token, messages, system, specs: toolsToSpecs(tools), options: {}, signal: undefined });
|
|
244
|
-
} catch (e) { clearTimeout(ttl); endRelaySession(s.id); return sendJson(res, 502, { error: { message: `bridge: ${e.message}`, type: 'bridge_error' } }); }
|
|
303
|
+
} catch (e) { clearTimeout(ttl); endRelaySession(s.id); trace?.commit(); return sendJson(res, 502, { error: { message: `bridge: ${e.message}`, type: 'bridge_error' } }); }
|
|
245
304
|
s.reader = resp.body.getReader();
|
|
246
305
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
247
306
|
res.write(shaper.sseHead());
|
|
248
|
-
return pumpRelay(res, s, shaper);
|
|
307
|
+
return pumpRelay(res, s, shaper, trace);
|
|
249
308
|
}
|
|
250
309
|
|
|
251
310
|
// Follow-up turn carrying a tool result: feed it to the parked agent + resume.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
311
|
+
// The relay redacts the tool result with ITS vault (the main handler skips
|
|
312
|
+
// redaction for a relay-resume), so time that here as the 'redact' leg.
|
|
313
|
+
async function resumeRelay(res, s, toolContent, model, trace = null) {
|
|
314
|
+
try {
|
|
315
|
+
const rd0 = trace ? trace.clock() : 0;
|
|
316
|
+
await deliverToolResult(s, toolContent);
|
|
317
|
+
if (trace) trace.lap('redact', rd0);
|
|
318
|
+
} catch (e) { endRelaySession(s.id); trace?.commit(); return sendJson(res, 502, { error: { message: `tool-result: ${e.message}`, type: 'bridge_error' } }); }
|
|
255
319
|
const shaper = shaperFor('openai', model || 'codex');
|
|
256
320
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
257
321
|
res.write(shaper.sseHead());
|
|
258
|
-
return pumpRelay(res, s, shaper);
|
|
322
|
+
return pumpRelay(res, s, shaper, trace);
|
|
259
323
|
}
|
|
260
324
|
|
|
261
|
-
async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride, harness }, body, vault, cfg, isPro) {
|
|
325
|
+
async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride, harness, trace }, body, vault, cfg, isPro) {
|
|
262
326
|
if (!redactable) {
|
|
327
|
+
trace?.commit();
|
|
263
328
|
return sendJson(res, 404, { error: `endpoint ${pathname} not supported by the bridge backend` });
|
|
264
329
|
}
|
|
265
330
|
|
|
266
331
|
// Tool relay (OpenAI protocol + agent destinations). A follow-up request carries
|
|
267
332
|
// a tool result for a parked session; a new request with `tools` starts one.
|
|
333
|
+
// The relay streams its own multi-turn flow and commits the trace when its turn
|
|
334
|
+
// ends (parked on a tool call, or done).
|
|
268
335
|
if (kind === 'openai') {
|
|
269
336
|
const toolResult = adapter.extractLatestToolResult(body);
|
|
270
337
|
if (toolResult) {
|
|
271
338
|
const parsed = parseToolCallId(toolResult.tool_call_id);
|
|
272
339
|
const s = parsed && getRelaySession(parsed.gwId);
|
|
273
|
-
if (s) return resumeRelay(res, s, toolResult.content, body?.model);
|
|
340
|
+
if (s) return resumeRelay(res, s, toolResult.content, body?.model, trace);
|
|
274
341
|
}
|
|
275
342
|
const tools = adapter.extractTools(body);
|
|
276
343
|
if (tools.length && body?.stream === true) {
|
|
277
|
-
return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg) }, body, vault, cfg, isPro, tools, harness);
|
|
344
|
+
return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg) }, body, vault, cfg, isPro, tools, harness, trace);
|
|
278
345
|
}
|
|
279
346
|
}
|
|
280
347
|
|
|
@@ -291,10 +358,17 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
291
358
|
if (!wantStream) {
|
|
292
359
|
try {
|
|
293
360
|
let full = '';
|
|
361
|
+
const up0 = trace ? trace.clock() : 0;
|
|
294
362
|
await streamBridgeChat(turn, (t) => { full += t; });
|
|
363
|
+
if (trace) trace.lap('upstream', up0);
|
|
364
|
+
const rs0 = trace ? trace.clock() : 0;
|
|
365
|
+
const out = shaper.full(restoreText(full, vault));
|
|
366
|
+
if (trace) trace.lap('restore', rs0);
|
|
295
367
|
res.writeHead(200, { 'content-type': shaper.contentType });
|
|
296
|
-
|
|
368
|
+
res.end(out);
|
|
369
|
+
return trace?.commit();
|
|
297
370
|
} catch (e) {
|
|
371
|
+
trace?.commit();
|
|
298
372
|
return sendJson(res, 502, { error: { message: `bridge backend failed: ${e.message}`, type: 'bridge_error' } });
|
|
299
373
|
}
|
|
300
374
|
}
|
|
@@ -302,8 +376,12 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
302
376
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
303
377
|
res.write(shaper.sseHead());
|
|
304
378
|
const restorer = makeTokenRestorer(vault);
|
|
379
|
+
const up0 = trace ? trace.clock() : 0;
|
|
380
|
+
let sStart = up0;
|
|
305
381
|
try {
|
|
382
|
+
let first = true;
|
|
306
383
|
await streamBridgeChat(turn, (chunk) => {
|
|
384
|
+
if (trace && first) { trace.lap('upstream', up0); sStart = trace.clock(); first = false; } // time-to-first-token
|
|
307
385
|
const restored = restorer.push(chunk);
|
|
308
386
|
if (restored) res.write(shaper.sseDelta(restored));
|
|
309
387
|
});
|
|
@@ -313,13 +391,16 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
313
391
|
} catch (e) {
|
|
314
392
|
res.write(`data: ${JSON.stringify({ error: { message: e.message, type: 'bridge_error' } })}\n\n`);
|
|
315
393
|
}
|
|
394
|
+
if (trace) trace.lap('stream', sStart);
|
|
316
395
|
res.end();
|
|
396
|
+
trace?.commit();
|
|
317
397
|
}
|
|
318
398
|
|
|
319
399
|
// ---- backend: api ----------------------------------------------------------
|
|
320
400
|
|
|
321
|
-
async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol, harness }, outBody, vault) {
|
|
401
|
+
async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol, harness, trace }, outBody, vault) {
|
|
322
402
|
let upstream;
|
|
403
|
+
const up0 = trace ? trace.clock() : 0;
|
|
323
404
|
try {
|
|
324
405
|
const headers = forwardHeaders(req.headers, base);
|
|
325
406
|
// If the destination carries its own key (imported from a configured API),
|
|
@@ -334,6 +415,7 @@ async function handleApi(req, res, { adapter, kind, pathname, search, base, dest
|
|
|
334
415
|
body: ['GET', 'HEAD'].includes(req.method) ? undefined : outBody,
|
|
335
416
|
});
|
|
336
417
|
} catch (e) {
|
|
418
|
+
trace?.commit();
|
|
337
419
|
return sendJson(res, 502, { error: `upstream fetch failed: ${e.message}` });
|
|
338
420
|
}
|
|
339
421
|
|
|
@@ -342,29 +424,47 @@ async function handleApi(req, res, { adapter, kind, pathname, search, base, dest
|
|
|
342
424
|
upstream.headers.forEach((v, k) => { if (!HOP_BY_HOP.has(k.toLowerCase())) resHeaders[k] = v; });
|
|
343
425
|
|
|
344
426
|
if (ct.includes('text/event-stream') && upstream.body) {
|
|
427
|
+
if (trace) trace.lap('upstream', up0); // model latency to response headers
|
|
428
|
+
const sStart = trace ? trace.clock() : 0;
|
|
345
429
|
res.writeHead(upstream.status, resHeaders);
|
|
346
430
|
// OpenAI streaming: restore tool-call args via the harness (real, or kept
|
|
347
431
|
// redacted for remote MCP under redactRemote) while keeping visible text
|
|
348
|
-
// pseudonymized. Other protocols: generic restore.
|
|
349
|
-
|
|
350
|
-
|
|
432
|
+
// pseudonymized. Other protocols: generic restore. The 'stream' leg spans
|
|
433
|
+
// the body; commit once it finishes (total then covers the whole response).
|
|
434
|
+
const piped = kind === 'openai'
|
|
435
|
+
? pipeRestoredOpenAIStream(upstream.body, res, vault, harness)
|
|
436
|
+
: pipeRestoredStream(upstream.body, res, vault);
|
|
437
|
+
return Promise.resolve(piped).finally(() => { if (trace) trace.lap('stream', sStart); trace?.commit(); });
|
|
351
438
|
}
|
|
352
439
|
|
|
353
440
|
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
441
|
+
if (trace) trace.lap('upstream', up0);
|
|
354
442
|
if (vault && ct.includes('application/json')) {
|
|
355
443
|
try {
|
|
444
|
+
const rs0 = trace ? trace.clock() : 0;
|
|
356
445
|
const json = adapter.restoreResponse(JSON.parse(buf.toString('utf8')), vault, harness);
|
|
446
|
+
if (trace) trace.lap('restore', rs0);
|
|
357
447
|
res.writeHead(upstream.status, { ...resHeaders, 'content-type': 'application/json' });
|
|
358
|
-
|
|
448
|
+
res.end(Buffer.from(JSON.stringify(json), 'utf8'));
|
|
449
|
+
return trace?.commit();
|
|
359
450
|
} catch { /* fall through */ }
|
|
360
451
|
}
|
|
361
452
|
res.writeHead(upstream.status, resHeaders);
|
|
362
453
|
res.end(buf);
|
|
454
|
+
trace?.commit();
|
|
363
455
|
}
|
|
364
456
|
|
|
365
457
|
// ---- server ----------------------------------------------------------------
|
|
366
458
|
|
|
367
459
|
export function createGateway(cfg = loadConfig()) {
|
|
460
|
+
// Per-gateway ring of recent request summaries for the extension's monitoring
|
|
461
|
+
// view (newest last). Counts + optional redaction detail + per-stage timings —
|
|
462
|
+
// see mkTrace. Lives only here, never persisted to disk.
|
|
463
|
+
const recentRequests = [];
|
|
464
|
+
const recordRequest = (entry) => {
|
|
465
|
+
recentRequests.push(entry);
|
|
466
|
+
if (recentRequests.length > 50) recentRequests.shift();
|
|
467
|
+
};
|
|
368
468
|
return createServer(async (req, res) => {
|
|
369
469
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
370
470
|
const pathname = url.pathname;
|
|
@@ -461,7 +561,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
461
561
|
}
|
|
462
562
|
}
|
|
463
563
|
if (pathname === '/logs' && req.method === 'GET') {
|
|
464
|
-
return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only
|
|
564
|
+
return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only, unless logDetail enriches each entry
|
|
465
565
|
}
|
|
466
566
|
if (pathname === '/config' && req.method === 'GET') {
|
|
467
567
|
const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
|
|
@@ -473,6 +573,18 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
473
573
|
if (!patch || typeof patch !== 'object') return sendJson(res, 400, { error: 'invalid config patch' });
|
|
474
574
|
applyConfigPatch(cfg, patch);
|
|
475
575
|
try { persistConfig(cfg, configPath()); } catch (e) { return sendJson(res, 500, { error: `could not persist config: ${e.message}` }); }
|
|
576
|
+
// The bundled NER engine only auto-loads at startup. If the user just switched
|
|
577
|
+
// detection BACK to the bundled engine (or it never loaded because an external
|
|
578
|
+
// detector was configured at boot), load it now so it doesn't stay "not running"
|
|
579
|
+
// until a restart. (An external detector needs no engine.) Fire-and-forget.
|
|
580
|
+
const det = cfg.redaction?.detection;
|
|
581
|
+
const usingBundled = !det || !det.backend || det.backend === 'off';
|
|
582
|
+
const st = nerEngine.state();
|
|
583
|
+
if (cfg.ner?.autostart && usingBundled && st !== 'ready' && st !== 'loading' && st !== 'downloading') {
|
|
584
|
+
nerEngine.setModel(cfg.ner.model, { onLog: (m) => console.log(m) }).then((ok) => {
|
|
585
|
+
if (ok && cfg.ner?.enableFullTier && cfg.redaction.tier !== 'full') cfg.redaction.tier = 'full';
|
|
586
|
+
});
|
|
587
|
+
}
|
|
476
588
|
const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
|
|
477
589
|
return sendJson(res, 200, publicConfig(cfg, { proUnlocked }));
|
|
478
590
|
}
|
|
@@ -499,6 +611,9 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
499
611
|
let redactedCount = 0;
|
|
500
612
|
let narrowedTools = 0;
|
|
501
613
|
let isPro = true;
|
|
614
|
+
// Off the hot path: only build a trace when logging is on, so it adds nothing
|
|
615
|
+
// when off (no clock reads, no record, no console line).
|
|
616
|
+
const trace = (cfg.logRequests && r.redactable && req.method === 'POST') ? mkTrace(recordRequest) : null;
|
|
502
617
|
if (r.redactable && req.method === 'POST' && raw.length) {
|
|
503
618
|
try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
|
|
504
619
|
if (body && isRelayResume(body, r.kind)) {
|
|
@@ -531,7 +646,9 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
531
646
|
const segs = r.adapter.collectSegments(body, cfg.redaction);
|
|
532
647
|
const ac = new AbortController();
|
|
533
648
|
req.on('close', () => ac.abort());
|
|
649
|
+
const rd0 = trace ? trace.clock() : 0;
|
|
534
650
|
const { vault: v, count } = await redactSegments(segs, cfg.redaction, { signal: ac.signal, isPro });
|
|
651
|
+
if (trace) trace.lap('redact', rd0);
|
|
535
652
|
vault = v;
|
|
536
653
|
redactedCount = count;
|
|
537
654
|
// When tools are armed, tell the model placeholders are auto-restored for
|
|
@@ -554,18 +671,18 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
554
671
|
// Route by the requested model → a destination (agent via the bridge, or an
|
|
555
672
|
// API we forward to). Falls back to the legacy backend when none configured.
|
|
556
673
|
const dest = resolveDestination(body?.model, cfg, r.kind);
|
|
557
|
-
if (
|
|
558
|
-
|
|
559
|
-
console.log(`[gateway] ${req.method} ${pathname} · model=${body?.model || '-'} → ${dest ? `${dest.id}(${dest.type})` : 'none'} · redacted ${redactedCount}${narrowedTools ? ` · narrowed -${narrowedTools} tools` : ''}`);
|
|
674
|
+
if (trace) {
|
|
675
|
+
trace.meta = { t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount, narrowed: narrowedTools, detail: redactionDetail(vault, cfg.logDetail) };
|
|
560
676
|
}
|
|
561
677
|
if (dest && dest.type === 'api') {
|
|
562
|
-
if (!dest.baseUrl) return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` });
|
|
678
|
+
if (!dest.baseUrl) { trace?.commit(); return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` }); }
|
|
563
679
|
if (isSelfUrl(dest.baseUrl, cfg)) {
|
|
680
|
+
trace?.commit();
|
|
564
681
|
return sendJson(res, 508, { error: { message: `destination "${dest.id}" points back at the gateway (${dest.baseUrl}) — refusing to forward (would loop).`, type: 'loop_detected' } });
|
|
565
682
|
}
|
|
566
|
-
return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol, harness }, outBody, vault);
|
|
683
|
+
return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol, harness, trace }, outBody, vault);
|
|
567
684
|
}
|
|
568
|
-
return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent, harness }, body, vault, cfg, isPro);
|
|
685
|
+
return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent, harness, trace }, body, vault, cfg, isPro);
|
|
569
686
|
});
|
|
570
687
|
}
|
|
571
688
|
|