@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 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 v0.1.0 on http://127.0.0.1:4320
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
- ### Using the api backend instead (local models / BYO keys)
76
+ ### Quick start (api backend no bridge)
72
77
 
73
- Set `backend: "api"` (see config) and the gateway forwards redacted traffic to a
74
- real provider endpoint, passing your `Authorization` / `x-api-key` through:
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
- export OPENAI_BASE_URL=http://127.0.0.1:4320/v1 # OpenAI / codex / aider / cursor
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",
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) {
@@ -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.3';
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
- // In-memory ring of recent request SUMMARIES for the extension's monitoring view.
104
- // Counts only — never any prompt/response text or values.
105
- const recentRequests = [];
106
- function recordRequest(entry) {
107
- recentRequests.push(entry);
108
- if (recentRequests.length > 50) recentRequests.shift();
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
- async function pumpRelay(res, s, shaper) {
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
- res.end(); // park: turn ends with tool_calls; the session stays alive for the follow-up
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
- async function resumeRelay(res, s, toolContent, model) {
253
- try { await deliverToolResult(s, toolContent); }
254
- catch (e) { endRelaySession(s.id); return sendJson(res, 502, { error: { message: `tool-result: ${e.message}`, type: 'bridge_error' } }); }
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
- return res.end(shaper.full(restoreText(full, vault)));
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
- if (kind === 'openai') return pipeRestoredOpenAIStream(upstream.body, res, vault, harness);
350
- return pipeRestoredStream(upstream.body, res, vault);
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
- return res.end(Buffer.from(JSON.stringify(json), 'utf8'));
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 (cfg.logRequests && r.redactable) {
558
- recordRequest({ t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount, narrowed: narrowedTools });
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