@chatpanel/gateway 0.6.4 → 0.6.6
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 +2 -2
- package/src/config.js +15 -5
- package/src/configstore.js +10 -4
- package/src/entitlement-refresh.js +94 -0
- package/src/freegate.js +34 -22
- package/src/redact.js +9 -7
- package/src/server.js +166 -42
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.6",
|
|
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": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"node": ">=18"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@chatpanel/pii": "^0.2.
|
|
30
|
+
"@chatpanel/pii": "^0.2.9",
|
|
31
31
|
"@huggingface/transformers": "^4.2.0",
|
|
32
32
|
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c"
|
|
33
33
|
},
|
package/src/config.js
CHANGED
|
@@ -34,14 +34,16 @@ const DEFAULTS = {
|
|
|
34
34
|
allowedOrigins: [],
|
|
35
35
|
maxBodyBytes: 26214400,
|
|
36
36
|
|
|
37
|
-
// Monetization: the gateway is free to try, paid to rely on. Free =
|
|
38
|
-
// redaction (
|
|
39
|
-
//
|
|
40
|
-
//
|
|
37
|
+
// Monetization: the gateway is free to try, paid to rely on. Free = full-tier
|
|
38
|
+
// redaction (the real thing — NER names/orgs + dictionary) for a FIXED LIFETIME
|
|
39
|
+
// allowance (freegate.FREE_TOTAL_CAP redactions), then it stops. Paste a
|
|
40
|
+
// ChatPanel Pro entitlement token (the same offline-signed token the extension/
|
|
41
|
+
// bridge use) to unlock unlimited usage. `free.used` is the running lifetime
|
|
42
|
+
// count (server-authoritative; persisted here so it survives restarts).
|
|
41
43
|
pro: {
|
|
42
44
|
entitlementToken: '',
|
|
43
45
|
free: {
|
|
44
|
-
|
|
46
|
+
used: 0,
|
|
45
47
|
},
|
|
46
48
|
},
|
|
47
49
|
|
|
@@ -96,6 +98,14 @@ const DEFAULTS = {
|
|
|
96
98
|
|
|
97
99
|
// Log one line per request (method, tokens redacted) without any raw values.
|
|
98
100
|
logRequests: true,
|
|
101
|
+
|
|
102
|
+
// Optional per-request redaction breakdown attached to each log entry (shown
|
|
103
|
+
// expandable in the extension). Memory-only — never persisted to disk with the
|
|
104
|
+
// captured values; only the MODE is saved.
|
|
105
|
+
// 'off' — counts only (default; the privacy-safe baseline)
|
|
106
|
+
// 'types' — entity types + placeholder tokens (e.g. PERSON_1), no real values
|
|
107
|
+
// 'values' — real → placeholder mapping (the actual PII; opt-in, debugging)
|
|
108
|
+
logDetail: 'off',
|
|
99
109
|
};
|
|
100
110
|
|
|
101
111
|
function deepMerge(base, over) {
|
package/src/configstore.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
6
6
|
import { join, dirname } from 'node:path';
|
|
7
7
|
import os from 'node:os';
|
|
8
|
+
import { usage } from './freegate.js';
|
|
8
9
|
|
|
9
10
|
// Default to a writable per-user location, NOT process.cwd(): when the gateway
|
|
10
11
|
// runs as a login service its cwd is "/" (read-only → EROFS on save).
|
|
@@ -22,7 +23,7 @@ export function persistConfig(cfg, path = configPath()) {
|
|
|
22
23
|
destinations: cfg.destinations,
|
|
23
24
|
bridge: cfg.bridge, upstreams: cfg.upstreams, redaction: cfg.redaction,
|
|
24
25
|
ner: cfg.ner, allowedOrigins: cfg.allowedOrigins, maxBodyBytes: cfg.maxBodyBytes,
|
|
25
|
-
pro: cfg.pro, logRequests: cfg.logRequests, tools: cfg.tools,
|
|
26
|
+
pro: cfg.pro, logRequests: cfg.logRequests, logDetail: cfg.logDetail, tools: cfg.tools,
|
|
26
27
|
};
|
|
27
28
|
writeFileSync(path, JSON.stringify(out, null, 2));
|
|
28
29
|
}
|
|
@@ -45,8 +46,11 @@ export function publicConfig(cfg, { proUnlocked = false } = {}) {
|
|
|
45
46
|
},
|
|
46
47
|
ner: cfg.ner,
|
|
47
48
|
allowedOrigins: Array.isArray(cfg.allowedOrigins) ? cfg.allowedOrigins : [],
|
|
48
|
-
|
|
49
|
+
// free = lifetime trial usage ({ used, cap, remaining }) — read-only; the cap
|
|
50
|
+
// is fixed and the count is server-authoritative (never settable from the UI).
|
|
51
|
+
pro: { unlocked: proUnlocked, hasToken: !!cfg.pro?.entitlementToken, free: usage(cfg) },
|
|
49
52
|
logRequests: !!cfg.logRequests,
|
|
53
|
+
logDetail: ['types', 'values'].includes(cfg.logDetail) ? cfg.logDetail : 'off',
|
|
50
54
|
tools: {
|
|
51
55
|
autoNarrow: cfg.tools?.autoNarrow !== false,
|
|
52
56
|
maxPerTurn: Number(cfg.tools?.maxPerTurn) > 0 ? Number(cfg.tools.maxPerTurn) : 8,
|
|
@@ -101,10 +105,12 @@ export function applyConfigPatch(cfg, patch = {}) {
|
|
|
101
105
|
if (Array.isArray(patch.allowedOrigins)) cfg.allowedOrigins = patch.allowedOrigins;
|
|
102
106
|
if (patch.pro && typeof patch.pro === 'object') {
|
|
103
107
|
if (typeof patch.pro.entitlementToken === 'string') cfg.pro.entitlementToken = patch.pro.entitlementToken;
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
// NOTE: the free trial is a FIXED lifetime cap (freegate.FREE_TOTAL_CAP) and
|
|
109
|
+
// its `used` count is server-authoritative — neither is editable here, so a
|
|
110
|
+
// client can't raise the cap or reset its own trial.
|
|
106
111
|
}
|
|
107
112
|
if (typeof patch.logRequests === 'boolean') cfg.logRequests = patch.logRequests;
|
|
113
|
+
if (['off', 'types', 'values'].includes(patch.logDetail)) cfg.logDetail = patch.logDetail;
|
|
108
114
|
if (patch.tools && typeof patch.tools === 'object') {
|
|
109
115
|
cfg.tools = cfg.tools || {};
|
|
110
116
|
if ('autoNarrow' in patch.tools) cfg.tools.autoNarrow = !!patch.tools.autoNarrow;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Online entitlement re-validation — closes the refund/revoke abuse window.
|
|
2
|
+
//
|
|
3
|
+
// The gateway stores ONE offline-signed entitlement token (pushed once from the
|
|
4
|
+
// extension via POST /config). Verified purely offline (entitlement.js), that token
|
|
5
|
+
// keeps unlocking Pro until its `exp` — up to 7 days — EVEN AFTER the subscription
|
|
6
|
+
// is refunded, cancelled, or the seat is revoked. The extension/bridge avoid this
|
|
7
|
+
// by re-polling the license worker; the gateway didn't, so a refund left unlimited
|
|
8
|
+
// redaction open for the rest of the token's life.
|
|
9
|
+
//
|
|
10
|
+
// So we also re-check ONLINE on an interval: poll the worker's /entitlement for the
|
|
11
|
+
// token's install_id and either
|
|
12
|
+
// • REFRESH the stored token (still entitled) — so it never lapses while paid, and
|
|
13
|
+
// • CLEAR it (worker says valid:false → refunded/cancelled/revoked) — dropping the
|
|
14
|
+
// gateway to Free immediately instead of riding the offline exp.
|
|
15
|
+
// Network/worker errors NEVER revoke (fail-open for paying users); the offline `exp`
|
|
16
|
+
// still bounds the worst case. Bounds post-refund Pro to <= CHECK_INTERVAL_MS.
|
|
17
|
+
|
|
18
|
+
import { persistConfig, configPath } from './configstore.js';
|
|
19
|
+
|
|
20
|
+
// Same worker the extension/bridge use. Overridable for self-hosted/test.
|
|
21
|
+
const API_BASE = (process.env.CHATPANEL_API_BASE || 'https://api.chatpanel.net').replace(/\/+$/, '');
|
|
22
|
+
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1h — the autonomous post-refund Pro window
|
|
23
|
+
const FIRST_CHECK_DELAY_MS = 30 * 1000; // let the server settle before first poll
|
|
24
|
+
const MIN_RECHECK_MS = 2 * 60 * 1000; // throttle on-demand (/status) re-checks
|
|
25
|
+
|
|
26
|
+
let lastCheckAt = 0;
|
|
27
|
+
|
|
28
|
+
// The token payload carries { typ, plan, install_id, sub, exp }; we only need the
|
|
29
|
+
// install_id to ask the worker whether that seat is still entitled.
|
|
30
|
+
function installIdFromToken(token) {
|
|
31
|
+
try {
|
|
32
|
+
const head = String(token).split('.')[0];
|
|
33
|
+
const json = Buffer.from(head.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
|
|
34
|
+
const p = JSON.parse(json);
|
|
35
|
+
return typeof p.install_id === 'string' && p.install_id ? p.install_id : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function revalidate(cfg) {
|
|
42
|
+
lastCheckAt = Date.now();
|
|
43
|
+
const token = cfg.pro?.entitlementToken;
|
|
44
|
+
if (!token) return;
|
|
45
|
+
const installId = installIdFromToken(token);
|
|
46
|
+
if (!installId) return; // legacy/opaque token — leave the offline exp to bound it
|
|
47
|
+
|
|
48
|
+
let data;
|
|
49
|
+
try {
|
|
50
|
+
const r = await fetch(`${API_BASE}/entitlement?install_id=${encodeURIComponent(installId)}`, {
|
|
51
|
+
signal: AbortSignal.timeout(8000),
|
|
52
|
+
});
|
|
53
|
+
if (!r.ok) return; // worker hiccup → fail-open, retry next interval
|
|
54
|
+
data = await r.json();
|
|
55
|
+
} catch {
|
|
56
|
+
return; // offline / network error → fail-open (never revoke a paying user)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (data && data.valid && typeof data.token === 'string' && data.token) {
|
|
60
|
+
// Still entitled — adopt the freshly-signed token so Pro never lapses while paid.
|
|
61
|
+
if (data.token !== token) {
|
|
62
|
+
cfg.pro.entitlementToken = data.token;
|
|
63
|
+
try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
|
|
64
|
+
}
|
|
65
|
+
} else if (data && data.valid === false) {
|
|
66
|
+
// Refunded / cancelled / seat revoked → drop Pro NOW (don't wait out the exp).
|
|
67
|
+
cfg.pro.entitlementToken = '';
|
|
68
|
+
try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
|
|
69
|
+
console.log('[gateway] entitlement no longer valid — Pro deactivated (refund/revoke/seat lost).');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// On-demand, throttled re-check — fire-and-forget from a hot path (the extension's
|
|
74
|
+
// /status poll) so deactivation shows up within ~minutes of opening the gateway
|
|
75
|
+
// tab, not just on the hourly tick. Never awaited; safe to call often.
|
|
76
|
+
export function maybeRevalidate(cfg) {
|
|
77
|
+
if (process.env.CHATPANEL_NO_REVALIDATE) return;
|
|
78
|
+
if (!cfg.pro?.entitlementToken) return;
|
|
79
|
+
if (Date.now() - lastCheckAt < MIN_RECHECK_MS) return;
|
|
80
|
+
lastCheckAt = Date.now(); // claim the slot before the async hop (avoid stampede)
|
|
81
|
+
revalidate(cfg).catch(() => {});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Start the periodic re-check. Timers are unref'd so they never keep the process
|
|
85
|
+
// alive on their own. Returns a handle with stop() for clean shutdown.
|
|
86
|
+
export function startEntitlementRefresh(cfg) {
|
|
87
|
+
if (process.env.CHATPANEL_NO_REVALIDATE) return { stop() {} };
|
|
88
|
+
const tick = () => { revalidate(cfg).catch(() => {}); };
|
|
89
|
+
const first = setTimeout(tick, FIRST_CHECK_DELAY_MS);
|
|
90
|
+
const iv = setInterval(tick, CHECK_INTERVAL_MS);
|
|
91
|
+
if (typeof first.unref === 'function') first.unref();
|
|
92
|
+
if (typeof iv.unref === 'function') iv.unref();
|
|
93
|
+
return { stop() { clearTimeout(first); clearInterval(iv); } };
|
|
94
|
+
}
|
package/src/freegate.js
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
// Free vs Pro for the gateway runtime — the "taste" gate.
|
|
2
2
|
//
|
|
3
|
-
// Free (no entitlement token):
|
|
4
|
-
//
|
|
5
|
-
// ChatPanel entitlement token — the same
|
|
6
|
-
// bridge use) unlocks
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// Free (no entitlement token): full-tier redaction works, but only for a fixed
|
|
4
|
+
// LIFETIME number of redactions (FREE_TOTAL_CAP) — a real trial of the genuine
|
|
5
|
+
// thing, then you buy. Pro (a valid ChatPanel entitlement token — the same
|
|
6
|
+
// offline-signed token the extension and bridge use) unlocks UNLIMITED redaction.
|
|
7
|
+
// The cryptographic check (entitlement.js) means a forked UI can't unlock it —
|
|
8
|
+
// only the configured/paid token does.
|
|
9
|
+
//
|
|
10
|
+
// The cap is LIFETIME (overall), not per-day, and it is NOT user-editable — so a
|
|
11
|
+
// free user gets exactly FREE_TOTAL_CAP genuine redactions, full stop. The count
|
|
12
|
+
// persists in cfg.pro.free.used (written via configstore.persistConfig), so it
|
|
13
|
+
// survives restarts.
|
|
9
14
|
|
|
10
15
|
import { isProEntitled } from './entitlement.js';
|
|
11
16
|
|
|
17
|
+
// The lifetime free allowance. Fixed — deliberately NOT configurable.
|
|
18
|
+
export const FREE_TOTAL_CAP = 100;
|
|
19
|
+
|
|
12
20
|
const proCache = { token: null, val: false };
|
|
13
|
-
const counts = { day: '', n: 0 };
|
|
14
21
|
|
|
15
|
-
//
|
|
22
|
+
// Lifetime free usage — for the gateway's /status (the extension's monitoring).
|
|
16
23
|
export function usage(cfg) {
|
|
17
|
-
|
|
24
|
+
const used = Number(cfg.pro?.free?.used) || 0;
|
|
25
|
+
return { used, cap: FREE_TOTAL_CAP, remaining: Math.max(0, FREE_TOTAL_CAP - used) };
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
export async function resolvePro(token) {
|
|
@@ -26,19 +34,23 @@ export async function resolvePro(token) {
|
|
|
26
34
|
return val;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
// May this request still redact? Pro = always. Free = allowed until the lifetime
|
|
38
|
+
// cap is reached, then refused so the client gets a clear upsell. This only
|
|
39
|
+
// CHECKS — the count is advanced by consume() AFTER a redaction actually happens,
|
|
40
|
+
// so requests with nothing to redact don't burn the allowance.
|
|
41
|
+
export function checkQuota(cfg, isPro) {
|
|
42
|
+
if (isPro) return { allowed: true, remaining: Infinity, isPro: true };
|
|
43
|
+
const used = Number(cfg.pro?.free?.used) || 0;
|
|
44
|
+
if (used >= FREE_TOTAL_CAP) return { allowed: false, remaining: 0, used, cap: FREE_TOTAL_CAP, isPro: false };
|
|
45
|
+
return { allowed: true, remaining: FREE_TOTAL_CAP - used, used, cap: FREE_TOTAL_CAP, isPro: false };
|
|
32
46
|
}
|
|
33
47
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
export function
|
|
37
|
-
if (isPro) return
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
counts.n += 1;
|
|
43
|
-
return { allowed: true, remaining: cap - counts.n, cap, isPro: false };
|
|
48
|
+
// Record one consumed free redaction (lifetime). No-op for Pro. Mutates cfg so
|
|
49
|
+
// the caller can persist it. Returns the new used count.
|
|
50
|
+
export function consume(cfg, isPro) {
|
|
51
|
+
if (isPro) return Infinity;
|
|
52
|
+
cfg.pro = cfg.pro || {};
|
|
53
|
+
cfg.pro.free = cfg.pro.free || {};
|
|
54
|
+
cfg.pro.free.used = (Number(cfg.pro.free.used) || 0) + 1;
|
|
55
|
+
return cfg.pro.free.used;
|
|
44
56
|
}
|
package/src/redact.js
CHANGED
|
@@ -8,22 +8,24 @@
|
|
|
8
8
|
// mapping is self-consistent within the request. (Same reasoning as the
|
|
9
9
|
// extension's pii-pipeline.)
|
|
10
10
|
|
|
11
|
-
import { createVault, redactText, detectEntities,
|
|
11
|
+
import { createVault, redactText, detectEntities, gatedDictionary } from '@chatpanel/pii';
|
|
12
12
|
import * as engine from './ner-engine.js';
|
|
13
13
|
|
|
14
14
|
// tier: 'basic' | 'full'. For 'full' we run the local detector over the combined
|
|
15
15
|
// text to harvest names/orgs, then redact every segment against that entity set.
|
|
16
|
-
//
|
|
17
|
-
//
|
|
16
|
+
//
|
|
17
|
+
// Free vs Pro on the gateway: the free trial is limited by a REQUEST QUOTA
|
|
18
|
+
// (freegate.js), not by downgrading quality — so free users get the REAL tier
|
|
19
|
+
// (names/orgs via NER) within their allowance. The custom dictionary, though, is
|
|
20
|
+
// still a Pro power feature: gatedDictionary caps it to FREE_DICT_LIMIT for free.
|
|
18
21
|
export async function redactSegments(segments, redactionCfg, { signal, isPro = true } = {}) {
|
|
19
22
|
const vault = createVault();
|
|
20
23
|
const texts = segments.map((s) => s.get()).filter((t) => typeof t === 'string' && t);
|
|
21
24
|
if (texts.length === 0) return { vault, count: 0 };
|
|
22
25
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
const tier = effectiveTier({ tier: redactionCfg.tier }, isPro);
|
|
26
|
+
// Use the configured tier as-is (no free downgrade — the quota is the free gate),
|
|
27
|
+
// but keep the dictionary capped for free via the shared chatpanel-pii gate.
|
|
28
|
+
const tier = redactionCfg.tier === 'full' ? 'full' : 'basic';
|
|
27
29
|
const dictionary = gatedDictionary(redactionCfg, isPro);
|
|
28
30
|
|
|
29
31
|
// Detection source: a USER-configured external detector takes precedence; else
|
package/src/server.js
CHANGED
|
@@ -19,23 +19,24 @@
|
|
|
19
19
|
|
|
20
20
|
import { createServer } from 'node:http';
|
|
21
21
|
import { loadConfig } from './config.js';
|
|
22
|
+
import { startEntitlementRefresh, maybeRevalidate } from './entitlement-refresh.js';
|
|
22
23
|
import { redactSegments } from './redact.js';
|
|
23
24
|
import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer } from './stream.js';
|
|
24
|
-
import { restoreText,
|
|
25
|
+
import { restoreText, gatedDictionary, narrowSpecs, makeToolHarness, placeholderToolNote } from '@chatpanel/pii';
|
|
25
26
|
import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
|
|
26
27
|
import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
|
|
27
28
|
import { shaperFor } from './shape.js';
|
|
28
29
|
import { startNer } from './ner.js';
|
|
29
30
|
import * as nerEngine from './ner-engine.js';
|
|
30
31
|
import { MODEL_CATALOG, isKnownModel } from './models.js';
|
|
31
|
-
import { resolvePro,
|
|
32
|
+
import { resolvePro, checkQuota, consume, usage } from './freegate.js';
|
|
32
33
|
import { publicConfig, applyConfigPatch, persistConfig, configPath } from './configstore.js';
|
|
33
34
|
import { resolveDestination, aggregateModelsAsync } from './router.js';
|
|
34
35
|
import * as openai from './openai.js';
|
|
35
36
|
import * as responses from './responses.js';
|
|
36
37
|
import * as anthropic from './anthropic.js';
|
|
37
38
|
|
|
38
|
-
export const VERSION = '0.6.
|
|
39
|
+
export const VERSION = '0.6.6';
|
|
39
40
|
|
|
40
41
|
const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
|
|
41
42
|
|
|
@@ -100,12 +101,62 @@ function setCors(res, origin) {
|
|
|
100
101
|
|
|
101
102
|
const STARTED_AT = Date.now();
|
|
102
103
|
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
// Render a timings map as a compact one-liner for the console log.
|
|
105
|
+
function fmtTimings(t) {
|
|
106
|
+
if (!t) return '';
|
|
107
|
+
const label = { redact: 'redact', upstream: 'model', stream: 'stream', restore: 'restore', total: 'total' };
|
|
108
|
+
return Object.keys(t).map((k) => `${label[k] || k} ${t[k]}ms`).join(' · ');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Per-request timing + summary. Created ONLY when logging is on — when it's off
|
|
112
|
+
// the handler passes a null trace and every `trace?.…` call short-circuits, so we
|
|
113
|
+
// don't even read the clock: logging then adds zero latency. The committed entry
|
|
114
|
+
// is flushed to the in-memory ring via setImmediate, i.e. AFTER the response has
|
|
115
|
+
// been handed back, so recording (and the console line) never sits on the request
|
|
116
|
+
// path. `timings` are wall-clock ms per stage of the flow:
|
|
117
|
+
// redact prompt → harness[redact] → model input
|
|
118
|
+
// upstream model input → model output. Non-stream: full call. Stream: time to
|
|
119
|
+
// first token (model/connection latency). Shown as "model".
|
|
120
|
+
// stream first token → last token (generation), streaming responses only
|
|
121
|
+
// restore model output → harness[restore] → user response (non-stream; for
|
|
122
|
+
// streams restore is inline per chunk, so it's folded into stream)
|
|
123
|
+
// total end-to-end through the gateway
|
|
124
|
+
function mkTrace(sink) {
|
|
125
|
+
const start = performance.now();
|
|
126
|
+
const timings = {};
|
|
127
|
+
let done = false;
|
|
128
|
+
const mark = (name, ms) => { timings[name] = Math.round(ms * 10) / 10; };
|
|
129
|
+
return {
|
|
130
|
+
meta: {}, timings, mark,
|
|
131
|
+
// Stamp the duration of an already-completed stage (`t0` from clock()).
|
|
132
|
+
lap(name, t0) { mark(name, performance.now() - t0); },
|
|
133
|
+
clock() { return performance.now(); },
|
|
134
|
+
commit() {
|
|
135
|
+
if (done) return; done = true;
|
|
136
|
+
mark('total', performance.now() - start);
|
|
137
|
+
const entry = /** @type {any} */ ({ ...this.meta, timings });
|
|
138
|
+
setImmediate(() => {
|
|
139
|
+
sink(entry);
|
|
140
|
+
console.log(`[gateway] model=${entry.model || '-'} → ${entry.dest ? `${entry.dest}(${entry.type})` : 'none'} · redacted ${entry.redacted || 0}${entry.narrowed ? ` · narrowed -${entry.narrowed}` : ''} · ${fmtTimings(timings)}`);
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Build the optional per-request redaction breakdown from the request's vault.
|
|
147
|
+
// 'types' → [{ token:'PERSON_1', type:'PERSON' }] (no real values)
|
|
148
|
+
// 'values' → [{ token:'PERSON_1', type:'PERSON', value:'…' }] (the real PII; opt-in)
|
|
149
|
+
// Lives only in the in-memory ring — never written to disk by persistConfig.
|
|
150
|
+
function redactionDetail(vault, mode) {
|
|
151
|
+
if (!vault || !vault.byToken || (mode !== 'types' && mode !== 'values')) return undefined;
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const [token, value] of vault.byToken) {
|
|
154
|
+
const m = /^\[\[([A-Z][A-Z0-9]*)_\d+\]\]$/.exec(token);
|
|
155
|
+
const t = m ? m[1] : 'PII';
|
|
156
|
+
const bare = token.replace(/^\[\[|\]\]$/g, '');
|
|
157
|
+
out.push(mode === 'values' ? { token: bare, type: t, value } : { token: bare, type: t });
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
109
160
|
}
|
|
110
161
|
|
|
111
162
|
// Classify a request: which protocol kind + adapter, whether it's a redactable
|
|
@@ -213,27 +264,38 @@ function forwardHeaders(headers, base) {
|
|
|
213
264
|
// ---- backend: bridge -------------------------------------------------------
|
|
214
265
|
|
|
215
266
|
// Stream the bridge SSE through the OpenAI shaper, parking on a tool call.
|
|
216
|
-
|
|
267
|
+
// `trace` (when present) times the agent turn: 'upstream' = time to first token,
|
|
268
|
+
// 'stream' = first token → park/done. The turn ends at a tool-call (parked, the
|
|
269
|
+
// client runs the tool and POSTs back, resuming a fresh trace) or at onDone.
|
|
270
|
+
async function pumpRelay(res, s, shaper, trace) {
|
|
217
271
|
const restorer = makeTokenRestorer(s.vault);
|
|
272
|
+
const t0 = trace ? trace.clock() : 0;
|
|
273
|
+
let sStart = t0;
|
|
274
|
+
let first = true;
|
|
275
|
+
const tick = () => { if (trace && first) { trace.lap('upstream', t0); sStart = trace.clock(); first = false; } };
|
|
218
276
|
await pumpBridgeStream(s, {
|
|
219
|
-
onText: (text) => { const r = restorer.push(text); if (r) res.write(shaper.sseDelta(r)); },
|
|
277
|
+
onText: (text) => { tick(); const r = restorer.push(text); if (r) res.write(shaper.sseDelta(r)); },
|
|
220
278
|
onToolRequest: ({ name, restoredArgs, toolId }) => {
|
|
279
|
+
tick();
|
|
221
280
|
const tail = restorer.flush(); if (tail) res.write(shaper.sseDelta(tail));
|
|
222
281
|
res.write(shaper.sseToolCalls([{ id: toolId, name, arguments: JSON.stringify(restoredArgs) }]));
|
|
223
282
|
res.write(shaper.sseToolFinish());
|
|
224
|
-
|
|
283
|
+
if (trace) trace.lap('stream', sStart);
|
|
284
|
+
res.end(); trace?.commit(); // park: turn ends with tool_calls; the session stays alive for the follow-up
|
|
225
285
|
},
|
|
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); },
|
|
286
|
+
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(); },
|
|
287
|
+
onError: (e) => { res.write(`data: ${JSON.stringify({ error: { message: e.message, type: 'bridge_error' } })}\n\n`); res.end(); endRelaySession(s.id); trace?.commit(); },
|
|
228
288
|
});
|
|
229
289
|
}
|
|
230
290
|
|
|
231
291
|
// 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) {
|
|
292
|
+
async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg, isPro, tools, harness = null, trace = null) {
|
|
233
293
|
const { messages, system } = adapter.toTurn(body);
|
|
234
294
|
const token = readBridgeToken(cfg.bridge.token);
|
|
235
295
|
const shaper = shaperFor(kind, body?.model || agent);
|
|
236
|
-
|
|
296
|
+
// Full tier for everyone here (the free allowance is enforced by the quota gate
|
|
297
|
+
// in the main handler), but the custom dictionary stays capped for free.
|
|
298
|
+
const redactOpts = { tier: cfg.redaction.tier === 'full' ? 'full' : 'basic', dictionary: gatedDictionary(cfg.redaction, isPro), entities: [] };
|
|
237
299
|
const s = createRelaySession({ vault, redactOpts, bridgeUrl: cfg.bridge.url, token, harness });
|
|
238
300
|
const ttl = setTimeout(() => endRelaySession(s.id), 135_000); // bridge tool-call timeout is 120s
|
|
239
301
|
// The placeholder note is already in `system` (injected into the body after
|
|
@@ -241,40 +303,48 @@ async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg,
|
|
|
241
303
|
let resp;
|
|
242
304
|
try {
|
|
243
305
|
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' } }); }
|
|
306
|
+
} catch (e) { clearTimeout(ttl); endRelaySession(s.id); trace?.commit(); return sendJson(res, 502, { error: { message: `bridge: ${e.message}`, type: 'bridge_error' } }); }
|
|
245
307
|
s.reader = resp.body.getReader();
|
|
246
308
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
247
309
|
res.write(shaper.sseHead());
|
|
248
|
-
return pumpRelay(res, s, shaper);
|
|
310
|
+
return pumpRelay(res, s, shaper, trace);
|
|
249
311
|
}
|
|
250
312
|
|
|
251
313
|
// Follow-up turn carrying a tool result: feed it to the parked agent + resume.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
314
|
+
// The relay redacts the tool result with ITS vault (the main handler skips
|
|
315
|
+
// redaction for a relay-resume), so time that here as the 'redact' leg.
|
|
316
|
+
async function resumeRelay(res, s, toolContent, model, trace = null) {
|
|
317
|
+
try {
|
|
318
|
+
const rd0 = trace ? trace.clock() : 0;
|
|
319
|
+
await deliverToolResult(s, toolContent);
|
|
320
|
+
if (trace) trace.lap('redact', rd0);
|
|
321
|
+
} catch (e) { endRelaySession(s.id); trace?.commit(); return sendJson(res, 502, { error: { message: `tool-result: ${e.message}`, type: 'bridge_error' } }); }
|
|
255
322
|
const shaper = shaperFor('openai', model || 'codex');
|
|
256
323
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
257
324
|
res.write(shaper.sseHead());
|
|
258
|
-
return pumpRelay(res, s, shaper);
|
|
325
|
+
return pumpRelay(res, s, shaper, trace);
|
|
259
326
|
}
|
|
260
327
|
|
|
261
|
-
async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride, harness }, body, vault, cfg, isPro) {
|
|
328
|
+
async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride, harness, trace }, body, vault, cfg, isPro) {
|
|
262
329
|
if (!redactable) {
|
|
330
|
+
trace?.commit();
|
|
263
331
|
return sendJson(res, 404, { error: `endpoint ${pathname} not supported by the bridge backend` });
|
|
264
332
|
}
|
|
265
333
|
|
|
266
334
|
// Tool relay (OpenAI protocol + agent destinations). A follow-up request carries
|
|
267
335
|
// a tool result for a parked session; a new request with `tools` starts one.
|
|
336
|
+
// The relay streams its own multi-turn flow and commits the trace when its turn
|
|
337
|
+
// ends (parked on a tool call, or done).
|
|
268
338
|
if (kind === 'openai') {
|
|
269
339
|
const toolResult = adapter.extractLatestToolResult(body);
|
|
270
340
|
if (toolResult) {
|
|
271
341
|
const parsed = parseToolCallId(toolResult.tool_call_id);
|
|
272
342
|
const s = parsed && getRelaySession(parsed.gwId);
|
|
273
|
-
if (s) return resumeRelay(res, s, toolResult.content, body?.model);
|
|
343
|
+
if (s) return resumeRelay(res, s, toolResult.content, body?.model, trace);
|
|
274
344
|
}
|
|
275
345
|
const tools = adapter.extractTools(body);
|
|
276
346
|
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);
|
|
347
|
+
return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg) }, body, vault, cfg, isPro, tools, harness, trace);
|
|
278
348
|
}
|
|
279
349
|
}
|
|
280
350
|
|
|
@@ -291,10 +361,17 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
291
361
|
if (!wantStream) {
|
|
292
362
|
try {
|
|
293
363
|
let full = '';
|
|
364
|
+
const up0 = trace ? trace.clock() : 0;
|
|
294
365
|
await streamBridgeChat(turn, (t) => { full += t; });
|
|
366
|
+
if (trace) trace.lap('upstream', up0);
|
|
367
|
+
const rs0 = trace ? trace.clock() : 0;
|
|
368
|
+
const out = shaper.full(restoreText(full, vault));
|
|
369
|
+
if (trace) trace.lap('restore', rs0);
|
|
295
370
|
res.writeHead(200, { 'content-type': shaper.contentType });
|
|
296
|
-
|
|
371
|
+
res.end(out);
|
|
372
|
+
return trace?.commit();
|
|
297
373
|
} catch (e) {
|
|
374
|
+
trace?.commit();
|
|
298
375
|
return sendJson(res, 502, { error: { message: `bridge backend failed: ${e.message}`, type: 'bridge_error' } });
|
|
299
376
|
}
|
|
300
377
|
}
|
|
@@ -302,8 +379,12 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
302
379
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
303
380
|
res.write(shaper.sseHead());
|
|
304
381
|
const restorer = makeTokenRestorer(vault);
|
|
382
|
+
const up0 = trace ? trace.clock() : 0;
|
|
383
|
+
let sStart = up0;
|
|
305
384
|
try {
|
|
385
|
+
let first = true;
|
|
306
386
|
await streamBridgeChat(turn, (chunk) => {
|
|
387
|
+
if (trace && first) { trace.lap('upstream', up0); sStart = trace.clock(); first = false; } // time-to-first-token
|
|
307
388
|
const restored = restorer.push(chunk);
|
|
308
389
|
if (restored) res.write(shaper.sseDelta(restored));
|
|
309
390
|
});
|
|
@@ -313,13 +394,16 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
313
394
|
} catch (e) {
|
|
314
395
|
res.write(`data: ${JSON.stringify({ error: { message: e.message, type: 'bridge_error' } })}\n\n`);
|
|
315
396
|
}
|
|
397
|
+
if (trace) trace.lap('stream', sStart);
|
|
316
398
|
res.end();
|
|
399
|
+
trace?.commit();
|
|
317
400
|
}
|
|
318
401
|
|
|
319
402
|
// ---- backend: api ----------------------------------------------------------
|
|
320
403
|
|
|
321
|
-
async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol, harness }, outBody, vault) {
|
|
404
|
+
async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol, harness, trace }, outBody, vault) {
|
|
322
405
|
let upstream;
|
|
406
|
+
const up0 = trace ? trace.clock() : 0;
|
|
323
407
|
try {
|
|
324
408
|
const headers = forwardHeaders(req.headers, base);
|
|
325
409
|
// If the destination carries its own key (imported from a configured API),
|
|
@@ -334,6 +418,7 @@ async function handleApi(req, res, { adapter, kind, pathname, search, base, dest
|
|
|
334
418
|
body: ['GET', 'HEAD'].includes(req.method) ? undefined : outBody,
|
|
335
419
|
});
|
|
336
420
|
} catch (e) {
|
|
421
|
+
trace?.commit();
|
|
337
422
|
return sendJson(res, 502, { error: `upstream fetch failed: ${e.message}` });
|
|
338
423
|
}
|
|
339
424
|
|
|
@@ -342,29 +427,47 @@ async function handleApi(req, res, { adapter, kind, pathname, search, base, dest
|
|
|
342
427
|
upstream.headers.forEach((v, k) => { if (!HOP_BY_HOP.has(k.toLowerCase())) resHeaders[k] = v; });
|
|
343
428
|
|
|
344
429
|
if (ct.includes('text/event-stream') && upstream.body) {
|
|
430
|
+
if (trace) trace.lap('upstream', up0); // model latency to response headers
|
|
431
|
+
const sStart = trace ? trace.clock() : 0;
|
|
345
432
|
res.writeHead(upstream.status, resHeaders);
|
|
346
433
|
// OpenAI streaming: restore tool-call args via the harness (real, or kept
|
|
347
434
|
// redacted for remote MCP under redactRemote) while keeping visible text
|
|
348
|
-
// pseudonymized. Other protocols: generic restore.
|
|
349
|
-
|
|
350
|
-
|
|
435
|
+
// pseudonymized. Other protocols: generic restore. The 'stream' leg spans
|
|
436
|
+
// the body; commit once it finishes (total then covers the whole response).
|
|
437
|
+
const piped = kind === 'openai'
|
|
438
|
+
? pipeRestoredOpenAIStream(upstream.body, res, vault, harness)
|
|
439
|
+
: pipeRestoredStream(upstream.body, res, vault);
|
|
440
|
+
return Promise.resolve(piped).finally(() => { if (trace) trace.lap('stream', sStart); trace?.commit(); });
|
|
351
441
|
}
|
|
352
442
|
|
|
353
443
|
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
444
|
+
if (trace) trace.lap('upstream', up0);
|
|
354
445
|
if (vault && ct.includes('application/json')) {
|
|
355
446
|
try {
|
|
447
|
+
const rs0 = trace ? trace.clock() : 0;
|
|
356
448
|
const json = adapter.restoreResponse(JSON.parse(buf.toString('utf8')), vault, harness);
|
|
449
|
+
if (trace) trace.lap('restore', rs0);
|
|
357
450
|
res.writeHead(upstream.status, { ...resHeaders, 'content-type': 'application/json' });
|
|
358
|
-
|
|
451
|
+
res.end(Buffer.from(JSON.stringify(json), 'utf8'));
|
|
452
|
+
return trace?.commit();
|
|
359
453
|
} catch { /* fall through */ }
|
|
360
454
|
}
|
|
361
455
|
res.writeHead(upstream.status, resHeaders);
|
|
362
456
|
res.end(buf);
|
|
457
|
+
trace?.commit();
|
|
363
458
|
}
|
|
364
459
|
|
|
365
460
|
// ---- server ----------------------------------------------------------------
|
|
366
461
|
|
|
367
462
|
export function createGateway(cfg = loadConfig()) {
|
|
463
|
+
// Per-gateway ring of recent request summaries for the extension's monitoring
|
|
464
|
+
// view (newest last). Counts + optional redaction detail + per-stage timings —
|
|
465
|
+
// see mkTrace. Lives only here, never persisted to disk.
|
|
466
|
+
const recentRequests = [];
|
|
467
|
+
const recordRequest = (entry) => {
|
|
468
|
+
recentRequests.push(entry);
|
|
469
|
+
if (recentRequests.length > 50) recentRequests.shift();
|
|
470
|
+
};
|
|
368
471
|
return createServer(async (req, res) => {
|
|
369
472
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
370
473
|
const pathname = url.pathname;
|
|
@@ -382,6 +485,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
382
485
|
|
|
383
486
|
// --- Config API (the extension's "Gateway" tab is a client of these) ---
|
|
384
487
|
if (pathname === '/status' && req.method === 'GET') {
|
|
488
|
+
maybeRevalidate(cfg); // throttled, fire-and-forget: reflect a refund/revoke quickly
|
|
385
489
|
const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
|
|
386
490
|
const health = await probeNerHealth(cfg); // live GET /health on the detector
|
|
387
491
|
return sendJson(res, 200, {
|
|
@@ -461,7 +565,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
461
565
|
}
|
|
462
566
|
}
|
|
463
567
|
if (pathname === '/logs' && req.method === 'GET') {
|
|
464
|
-
return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only
|
|
568
|
+
return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only, unless logDetail enriches each entry
|
|
465
569
|
}
|
|
466
570
|
if (pathname === '/config' && req.method === 'GET') {
|
|
467
571
|
const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
|
|
@@ -511,6 +615,9 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
511
615
|
let redactedCount = 0;
|
|
512
616
|
let narrowedTools = 0;
|
|
513
617
|
let isPro = true;
|
|
618
|
+
// Off the hot path: only build a trace when logging is on, so it adds nothing
|
|
619
|
+
// when off (no clock reads, no record, no console line).
|
|
620
|
+
const trace = (cfg.logRequests && r.redactable && req.method === 'POST') ? mkTrace(recordRequest) : null;
|
|
514
621
|
if (r.redactable && req.method === 'POST' && raw.length) {
|
|
515
622
|
try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
|
|
516
623
|
if (body && isRelayResume(body, r.kind)) {
|
|
@@ -531,21 +638,34 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
531
638
|
body.tools = narrowSpecs(body.tools, latestUserText(body, r.kind), { cap, keep, name: toolName, description: toolDesc });
|
|
532
639
|
narrowedTools = before - body.tools.length;
|
|
533
640
|
}
|
|
534
|
-
// Free/Pro gate:
|
|
641
|
+
// Free/Pro gate: free users get the REAL thing (full-tier redaction), but
|
|
642
|
+
// only for a fixed lifetime allowance — checked here, consumed below once a
|
|
643
|
+
// redaction actually happens. Over the cap → 402 upsell.
|
|
535
644
|
isPro = await resolvePro(cfg.pro?.entitlementToken);
|
|
536
|
-
const allow =
|
|
645
|
+
const allow = checkQuota(cfg, isPro);
|
|
537
646
|
if (!allow.allowed) {
|
|
538
647
|
return sendJson(res, 402, { error: {
|
|
539
|
-
message: `ChatPanel Gateway free
|
|
648
|
+
message: `ChatPanel Gateway free trial used up (${allow.cap} redactions). Add a ChatPanel Pro entitlement token to unlock unlimited full-tier redaction (names/orgs).`,
|
|
540
649
|
type: 'free_limit_reached',
|
|
541
650
|
} });
|
|
542
651
|
}
|
|
543
652
|
const segs = r.adapter.collectSegments(body, cfg.redaction);
|
|
544
653
|
const ac = new AbortController();
|
|
545
654
|
req.on('close', () => ac.abort());
|
|
655
|
+
const rd0 = trace ? trace.clock() : 0;
|
|
656
|
+
// Redact at the configured tier for everyone (free users get genuine
|
|
657
|
+
// name/org redaction within their allowance, not a downgraded preview);
|
|
658
|
+
// the custom dictionary stays capped for free (isPro decides that inside).
|
|
546
659
|
const { vault: v, count } = await redactSegments(segs, cfg.redaction, { signal: ac.signal, isPro });
|
|
660
|
+
if (trace) trace.lap('redact', rd0);
|
|
547
661
|
vault = v;
|
|
548
662
|
redactedCount = count;
|
|
663
|
+
// Burn one lifetime free credit only when we actually redacted something,
|
|
664
|
+
// then persist so the count survives a restart. (No-op / no write for Pro.)
|
|
665
|
+
if (!isPro && count > 0) {
|
|
666
|
+
consume(cfg, isPro);
|
|
667
|
+
try { persistConfig(cfg, configPath()); } catch { /* best effort — usage is advisory */ }
|
|
668
|
+
}
|
|
549
669
|
// When tools are armed, tell the model placeholders are auto-restored for
|
|
550
670
|
// tools (so privacy-aware models USE them instead of refusing). Injected
|
|
551
671
|
// AFTER redaction so the note isn't itself redacted. Covers BOTH the API
|
|
@@ -566,31 +686,35 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
566
686
|
// Route by the requested model → a destination (agent via the bridge, or an
|
|
567
687
|
// API we forward to). Falls back to the legacy backend when none configured.
|
|
568
688
|
const dest = resolveDestination(body?.model, cfg, r.kind);
|
|
569
|
-
if (
|
|
570
|
-
|
|
571
|
-
console.log(`[gateway] ${req.method} ${pathname} · model=${body?.model || '-'} → ${dest ? `${dest.id}(${dest.type})` : 'none'} · redacted ${redactedCount}${narrowedTools ? ` · narrowed -${narrowedTools} tools` : ''}`);
|
|
689
|
+
if (trace) {
|
|
690
|
+
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) };
|
|
572
691
|
}
|
|
573
692
|
if (dest && dest.type === 'api') {
|
|
574
|
-
if (!dest.baseUrl) return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` });
|
|
693
|
+
if (!dest.baseUrl) { trace?.commit(); return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` }); }
|
|
575
694
|
if (isSelfUrl(dest.baseUrl, cfg)) {
|
|
695
|
+
trace?.commit();
|
|
576
696
|
return sendJson(res, 508, { error: { message: `destination "${dest.id}" points back at the gateway (${dest.baseUrl}) — refusing to forward (would loop).`, type: 'loop_detected' } });
|
|
577
697
|
}
|
|
578
|
-
return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol, harness }, outBody, vault);
|
|
698
|
+
return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol, harness, trace }, outBody, vault);
|
|
579
699
|
}
|
|
580
|
-
return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent, harness }, body, vault, cfg, isPro);
|
|
700
|
+
return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent, harness, trace }, body, vault, cfg, isPro);
|
|
581
701
|
});
|
|
582
702
|
}
|
|
583
703
|
|
|
584
704
|
export function start(cfg = loadConfig()) {
|
|
585
705
|
const server = createGateway(cfg);
|
|
586
706
|
const ner = startNer(cfg); // may mutate cfg.redaction when it comes up
|
|
707
|
+
// Re-validate the stored Pro entitlement online on an interval, so a refunded /
|
|
708
|
+
// revoked subscription drops the gateway to Free instead of riding the offline
|
|
709
|
+
// token to its exp (see entitlement-refresh.js).
|
|
710
|
+
const entitlement = startEntitlementRefresh(cfg);
|
|
587
711
|
server.listen(cfg.port, cfg.host, () => {
|
|
588
712
|
console.log(`ChatPanel Privacy Gateway v${VERSION} on http://${cfg.host}:${cfg.port}`);
|
|
589
713
|
console.log(` backend : ${cfg.backend}` + (cfg.backend === 'bridge' ? ` (agent: ${cfg.bridge.agent}, via ${cfg.bridge.url})` : ''));
|
|
590
714
|
console.log(` redaction: ${cfg.redaction.tier}` + (cfg.redaction.detection?.backend && cfg.redaction.detection.backend !== 'off'
|
|
591
715
|
? ` + ${cfg.redaction.detection.backend} detector` : (cfg.ner?.autostart ? ' (+ NER starting…)' : '')));
|
|
592
716
|
});
|
|
593
|
-
const shutdown = () => { ner?.stop(); server.close(() => process.exit(0)); };
|
|
717
|
+
const shutdown = () => { ner?.stop(); entitlement.stop(); server.close(() => process.exit(0)); };
|
|
594
718
|
process.on('SIGINT', shutdown);
|
|
595
719
|
process.on('SIGTERM', shutdown);
|
|
596
720
|
return server;
|