@chatpanel/gateway 0.6.62 → 0.6.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/bridge.js +17 -3
- package/src/redact.js +17 -4
- package/src/router.js +87 -4
- package/src/server.js +40 -4
- package/src/shape.js +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.64",
|
|
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.7.
|
|
30
|
+
"@chatpanel/pii": "^0.7.1",
|
|
31
31
|
"@huggingface/transformers": "^4.2.0",
|
|
32
32
|
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c",
|
|
33
33
|
"phonemizer": "^1.2.1"
|
package/src/bridge.js
CHANGED
|
@@ -67,9 +67,11 @@ export async function openBridgeChat({ bridgeUrl, agent, token, messages, system
|
|
|
67
67
|
return res;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// Stream a turn through the bridge. Calls onText(restorableChunk) for each delta
|
|
70
|
+
// Stream a turn through the bridge. Calls onText(restorableChunk) for each delta, and
|
|
71
|
+
// onActivity(event) for everything else the agent reports — status lines, the working
|
|
72
|
+
// directory, tool calls, reasoning.
|
|
71
73
|
// of model text and returns the full (un-restored) text. Throws on bridge error.
|
|
72
|
-
export async function streamBridgeChat({ bridgeUrl, agent, token, messages, system, options, signal }, onText) {
|
|
74
|
+
export async function streamBridgeChat({ bridgeUrl, agent, token, messages, system, options, signal }, onText, onActivity = null) {
|
|
73
75
|
const res = await fetch(`${bridgeUrl.replace(/\/$/, '')}/chat`, {
|
|
74
76
|
method: 'POST',
|
|
75
77
|
headers: {
|
|
@@ -118,8 +120,20 @@ export async function streamBridgeChat({ bridgeUrl, agent, token, messages, syst
|
|
|
118
120
|
}
|
|
119
121
|
} else if (evt.type === 'error') {
|
|
120
122
|
err = new Error(evt.error || 'bridge error');
|
|
123
|
+
} else if (onActivity) {
|
|
124
|
+
// WHAT THE AGENT IS DOING, for a client that wants to show it.
|
|
125
|
+
//
|
|
126
|
+
// These used to be dropped with a comment calling them "the agent's local side
|
|
127
|
+
// effects". They are — and they are also the ONLY thing that happens for the ten
|
|
128
|
+
// seconds an agent spends reading files before it says a word. A client routed
|
|
129
|
+
// through this gateway saw a spinner and nothing else, while one talking to the
|
|
130
|
+
// bridge directly showed the work; that difference was pushing clients toward the
|
|
131
|
+
// direct path, which is the one with no redaction in it.
|
|
132
|
+
//
|
|
133
|
+
// Passed through as-is. Deciding here which of a coding agent's events are worth
|
|
134
|
+
// showing would be this file guessing at someone's UI.
|
|
135
|
+
onActivity(evt);
|
|
121
136
|
}
|
|
122
|
-
// tool / reasoning / status events are the agent's local side effects — ignore.
|
|
123
137
|
}
|
|
124
138
|
};
|
|
125
139
|
|
package/src/redact.js
CHANGED
|
@@ -20,7 +20,14 @@ import * as engine from './ner-engine.js';
|
|
|
20
20
|
// quality — free requests get the full tier (names/orgs via NER) within their
|
|
21
21
|
// allowance. The custom dictionary is capped for free: gatedDictionary limits it
|
|
22
22
|
// to FREE_DICT_LIMIT via the shared chatpanel-pii gate.
|
|
23
|
-
export async function redactSegments(segments, redactionCfg, {
|
|
23
|
+
export async function redactSegments(segments, redactionCfg, {
|
|
24
|
+
signal, isPro = true, onEgress = null, fetchImpl: fetchOverride = null,
|
|
25
|
+
// The bundled detector, INJECTED rather than reached for. An ES module namespace cannot
|
|
26
|
+
// be monkey-patched, so with a hard import there is no way to ask "does an entity the
|
|
27
|
+
// engine found actually come out the other side as a token" without downloading a model.
|
|
28
|
+
// That question went unasked for exactly that reason, and the answer was no.
|
|
29
|
+
nerEngine = engine,
|
|
30
|
+
} = {}) {
|
|
24
31
|
const vault = createVault();
|
|
25
32
|
|
|
26
33
|
// De-steganography FIRST (before detection). Invisible/format Unicode is a triple
|
|
@@ -51,7 +58,7 @@ export async function redactSegments(segments, redactionCfg, { signal, isPro = t
|
|
|
51
58
|
// @chatpanel/pii's caching / timeout / type-gating — one source of truth.
|
|
52
59
|
const det = redactionCfg.detection;
|
|
53
60
|
const useExternal = !!(det && det.backend && det.backend !== 'off');
|
|
54
|
-
const useEngine = !useExternal &&
|
|
61
|
+
const useEngine = !useExternal && nerEngine.isReady();
|
|
55
62
|
|
|
56
63
|
let entities = [];
|
|
57
64
|
if (tier === 'full' && (useExternal || useEngine)) {
|
|
@@ -61,12 +68,18 @@ export async function redactSegments(segments, redactionCfg, { signal, isPro = t
|
|
|
61
68
|
// under a second, but a cold one must be allowed to finish; on timeout the turn
|
|
62
69
|
// falls back to dictionary/deterministic-only redaction.
|
|
63
70
|
const detection = useEngine
|
|
64
|
-
|
|
71
|
+
// `transport: 'in-process'` is load-bearing, not decoration. Without it the sentinel
|
|
72
|
+
// URL below fails @chatpanel/pii's SSRF scheme check, the throw is swallowed by the
|
|
73
|
+
// fail-open path, and the bundled engine contributes NOTHING to any redaction while
|
|
74
|
+
// reporting itself ready — names and organisations reach the model in full under a
|
|
75
|
+
// config that says "full". The flag is only honoured alongside an injected fetch,
|
|
76
|
+
// which is `engine.fetchAdapter` on the next line.
|
|
77
|
+
? { backend: 'endpoint', url: 'inproc:ner', transport: 'in-process', timeoutMs: 30000, maxChars: 8000, types: det?.types }
|
|
65
78
|
: { ...det, timeoutMs: Math.max(Number(det.timeoutMs) || 0, 30000) };
|
|
66
79
|
// The in-process engine is already injected this way; `fetchOverride` is the same seam
|
|
67
80
|
// for a test, so the detector hop can be exercised without a network. Never used in
|
|
68
81
|
// production — nothing passes it but tests.
|
|
69
|
-
const fetchImpl = useEngine ?
|
|
82
|
+
const fetchImpl = useEngine ? nerEngine.fetchAdapter : (fetchOverride || undefined);
|
|
70
83
|
try {
|
|
71
84
|
entities = await detectEntities(texts.join('\n\n'), { detection }, {
|
|
72
85
|
signal,
|
package/src/router.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// }
|
|
13
13
|
|
|
14
14
|
import { secureFetch } from './secure-fetch.js';
|
|
15
|
+
import { readBridgeToken } from './bridge.js';
|
|
15
16
|
//
|
|
16
17
|
// /v1/models aggregates every destination's models so clients can discover them.
|
|
17
18
|
|
|
@@ -67,22 +68,96 @@ export function resolveDestination(model, cfg, kind, { destination = '' } = {})
|
|
|
67
68
|
// Aggregate every destination's models for GET /v1/models. Agents expose their
|
|
68
69
|
// own name as the model; APIs expose ONLY real model ids (never the destination
|
|
69
70
|
// id — that's a provider name, not a model).
|
|
71
|
+
/**
|
|
72
|
+
* WHICH API SHAPE A MODEL WANTS TO BE CALLED WITH.
|
|
73
|
+
*
|
|
74
|
+
* `owned_by` names the destination, which is a routing fact, not a calling convention — and
|
|
75
|
+
* a client needs the second one to build a request. Anthropic models take the Messages API;
|
|
76
|
+
* OpenAI-compatible ones take chat/completions (and the Responses API where the destination
|
|
77
|
+
* offers it); an agent takes neither, because the gateway synthesises the response itself
|
|
78
|
+
* from the bridge's stream.
|
|
79
|
+
*
|
|
80
|
+
* Stated here rather than inferred from the id in every client. Guessing from the name is
|
|
81
|
+
* how `claude` the local CLI agent gets called as if it were Anthropic's hosted API.
|
|
82
|
+
*/
|
|
83
|
+
function apiShapeOf(d) {
|
|
84
|
+
if (d.type === 'agent') return { api: 'agent', endpoints: ['/v1/chat/completions'] };
|
|
85
|
+
if (d.protocol === 'anthropic') return { api: 'anthropic', endpoints: ['/v1/messages'] };
|
|
86
|
+
return { api: 'openai', endpoints: ['/v1/chat/completions', '/v1/responses'] };
|
|
87
|
+
}
|
|
88
|
+
|
|
70
89
|
export function aggregateModels(cfg) {
|
|
71
90
|
const data = [];
|
|
72
91
|
const seen = new Set();
|
|
73
|
-
const add = (id, owner) => {
|
|
92
|
+
const add = (id, owner, d) => {
|
|
93
|
+
if (!id || seen.has(id)) return;
|
|
94
|
+
seen.add(id);
|
|
95
|
+
const shape = apiShapeOf(d);
|
|
96
|
+
data.push({
|
|
97
|
+
id,
|
|
98
|
+
object: 'model',
|
|
99
|
+
owned_by: owner,
|
|
100
|
+
// Additive fields an OpenAI client ignores and a ChatPanel client uses to decide how
|
|
101
|
+
// to call, and to group a picker by provider instead of by a flat list of ids.
|
|
102
|
+
provider: d.id,
|
|
103
|
+
provider_type: d.type === 'agent' ? 'agent' : (d.protocol === 'anthropic' ? 'anthropic' : 'openai'),
|
|
104
|
+
api: shape.api,
|
|
105
|
+
endpoints: shape.endpoints,
|
|
106
|
+
});
|
|
107
|
+
};
|
|
74
108
|
for (const d of listDestinations(cfg)) {
|
|
75
|
-
if (d.type === 'agent') for (const m of (d.models?.length ? d.models : [d.id])) add(m, 'chatpanel-bridge');
|
|
76
|
-
else for (const m of (d.models || [])) add(m, d.id);
|
|
109
|
+
if (d.type === 'agent') for (const m of (d.models?.length ? d.models : [d.id])) add(m, 'chatpanel-bridge', d);
|
|
110
|
+
else for (const m of (d.models || [])) add(m, d.id, d);
|
|
77
111
|
}
|
|
78
112
|
return { object: 'list', data };
|
|
79
113
|
}
|
|
80
114
|
|
|
115
|
+
/** id → installed, from the bridge's own /health. `null` when it could not be asked. */
|
|
116
|
+
async function bridgeAgentAvailability(cfg, timeoutMs) {
|
|
117
|
+
const base = String(cfg?.bridge?.url || '').replace(/\/$/, '');
|
|
118
|
+
if (!base) return null;
|
|
119
|
+
try {
|
|
120
|
+
const token = readBridgeToken(cfg.bridge?.token);
|
|
121
|
+
const res = await fetch(`${base}/health`, {
|
|
122
|
+
headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
123
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
124
|
+
});
|
|
125
|
+
if (!res.ok) return null;
|
|
126
|
+
const body = await res.json();
|
|
127
|
+
if (!Array.isArray(body?.agents)) return null;
|
|
128
|
+
return new Map(body.agents.map((a) => [a.id, !!a.available]));
|
|
129
|
+
} catch {
|
|
130
|
+
return null; // not reachable — say nothing rather than saying "none"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
81
134
|
// Async variant: also PROXIES each API destination's own /v1/models to discover
|
|
82
135
|
// real model ids (using its saved key). Fail-open per destination.
|
|
83
136
|
export async function aggregateModelsAsync(cfg, { timeoutMs = 4000 } = {}) {
|
|
84
137
|
const base = aggregateModels(cfg);
|
|
85
138
|
const seen = new Set(base.data.map((m) => m.id));
|
|
139
|
+
|
|
140
|
+
// WHICH AGENTS ARE ACTUALLY ON THIS MACHINE.
|
|
141
|
+
//
|
|
142
|
+
// The list above is the ROUTING TABLE: it names every agent the gateway would route to,
|
|
143
|
+
// whether or not that CLI is installed. On a fresh machine that is a model picker full of
|
|
144
|
+
// names that all fail on first use, which is the worst possible first five minutes.
|
|
145
|
+
//
|
|
146
|
+
// Only the bridge knows what is on disk, so the gateway asks it — once, here — rather than
|
|
147
|
+
// every client asking separately. A client that had to check for itself would need the
|
|
148
|
+
// bridge's address and token as well as ours, and the direct-to-bridge path is the one
|
|
149
|
+
// with no policy in front of it; making it necessary is how it becomes the habit.
|
|
150
|
+
//
|
|
151
|
+
// `available` is left UNDEFINED when the bridge cannot be reached. Absent means "we did not
|
|
152
|
+
// find out", which is not the same as false, and a picker that greys out every agent
|
|
153
|
+
// because one health check timed out is worse than one that says nothing.
|
|
154
|
+
const agentAvailability = await bridgeAgentAvailability(cfg, timeoutMs);
|
|
155
|
+
if (agentAvailability) {
|
|
156
|
+
for (const m of base.data) {
|
|
157
|
+
if (m.owned_by === 'chatpanel-bridge') m.available = agentAvailability.get(m.id) ?? false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
86
161
|
const dests = listDestinations(cfg).filter((d) => d.type === 'api' && d.baseUrl);
|
|
87
162
|
await Promise.all(dests.map(async (d) => {
|
|
88
163
|
const ctrl = new AbortController();
|
|
@@ -100,7 +175,15 @@ export async function aggregateModelsAsync(cfg, { timeoutMs = 4000 } = {}) {
|
|
|
100
175
|
const list = Array.isArray(j?.data) ? j.data : (Array.isArray(j?.models) ? j.models : []);
|
|
101
176
|
for (const m of list) {
|
|
102
177
|
const id = typeof m === 'string' ? m : m?.id;
|
|
103
|
-
if (id && !seen.has(id)) {
|
|
178
|
+
if (id && !seen.has(id)) {
|
|
179
|
+
seen.add(id);
|
|
180
|
+
const shape = apiShapeOf(d);
|
|
181
|
+
base.data.push({
|
|
182
|
+
id, object: 'model', owned_by: d.id, provider: d.id,
|
|
183
|
+
provider_type: d.protocol === 'anthropic' ? 'anthropic' : 'openai',
|
|
184
|
+
api: shape.api, endpoints: shape.endpoints,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
104
187
|
}
|
|
105
188
|
} catch { /* fail-open */ } finally { clearTimeout(t); }
|
|
106
189
|
}));
|
package/src/server.js
CHANGED
|
@@ -21,7 +21,7 @@ import { createServer } from 'node:http';
|
|
|
21
21
|
import { loadConfig } from './config.js';
|
|
22
22
|
import { startEntitlementRefresh, maybeRevalidate } from './entitlement-refresh.js';
|
|
23
23
|
import { redactSegments, segment } from './redact.js';
|
|
24
|
-
import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer } from './stream.js';
|
|
24
|
+
import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer, restoreDeep } from './stream.js';
|
|
25
25
|
import { restoreText, gatedDictionary, narrowSpecs, makeToolHarness, placeholderToolNote, assertEndpointUrl } from '@chatpanel/pii';
|
|
26
26
|
import { ensureGatewayToken, isAdminAuthorized } from './gateway-token.js';
|
|
27
27
|
import { secureFetch } from './secure-fetch.js';
|
|
@@ -55,7 +55,7 @@ import * as openai from './openai.js';
|
|
|
55
55
|
import * as responses from './responses.js';
|
|
56
56
|
import * as anthropic from './anthropic.js';
|
|
57
57
|
|
|
58
|
-
export const VERSION = '0.6.
|
|
58
|
+
export const VERSION = '0.6.64';
|
|
59
59
|
|
|
60
60
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
61
61
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -424,7 +424,19 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
424
424
|
if (trace && first) { trace.lap('upstream', up0); sStart = trace.clock(); first = false; } // time-to-first-token
|
|
425
425
|
const restored = restorer.push(chunk);
|
|
426
426
|
if (restored) res.write(shaper.sseDelta(restored));
|
|
427
|
-
})
|
|
427
|
+
}, shaper.sseActivity ? (evt) => {
|
|
428
|
+
// Activity is the agent describing its own work, so it can name a file it read — and
|
|
429
|
+
// it was handed PLACEHOLDERS, so what it echoes contains them. It is restored like any
|
|
430
|
+
// other text on the way back: a status line is not a side channel that skips the
|
|
431
|
+
// round trip and shows the user "[[PERSON_1]].md".
|
|
432
|
+
//
|
|
433
|
+
// restoreDeep, NOT the streaming restorer above: that one holds a partial token across
|
|
434
|
+
// chunks, and pushing an unrelated object through it would splice activity text into
|
|
435
|
+
// the middle of the assistant's message.
|
|
436
|
+
try {
|
|
437
|
+
res.write(shaper.sseActivity(restoreDeep(evt, vault)));
|
|
438
|
+
} catch { /* a client that hung up mid-turn is not a reason to fail the turn */ }
|
|
439
|
+
} : null);
|
|
428
440
|
const tail = restorer.flush();
|
|
429
441
|
if (tail) res.write(shaper.sseDelta(tail));
|
|
430
442
|
res.write(shaper.sseTail());
|
|
@@ -481,7 +493,7 @@ function resample(input, from, to) {
|
|
|
481
493
|
return out;
|
|
482
494
|
}
|
|
483
495
|
|
|
484
|
-
const LOCAL_NAMESPACES = ['/tts', '/stt', '/ner', '/diarize', '/skills', '/config', '/logs', '/status', '/admin'];
|
|
496
|
+
const LOCAL_NAMESPACES = ['/tts', '/stt', '/ner', '/diarize', '/skills', '/redact', '/config', '/logs', '/status', '/admin'];
|
|
485
497
|
|
|
486
498
|
async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol, harness, trace }, outBody, vault) {
|
|
487
499
|
let upstream;
|
|
@@ -1487,6 +1499,30 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1487
1499
|
return sendJson(res, 200, publicConfig(cfg, { proUnlocked }));
|
|
1488
1500
|
}
|
|
1489
1501
|
|
|
1502
|
+
// THE SKILLS ON THIS MACHINE, asked of the gateway rather than the bridge.
|
|
1503
|
+
//
|
|
1504
|
+
// The bridge is what reads the user's disk, so this proxies it — but a client should
|
|
1505
|
+
// have ONE address for everything. A client that talks to the gateway for models and the
|
|
1506
|
+
// bridge for skills has to know both are up, hold both tokens, and handle two failure
|
|
1507
|
+
// modes for one screen; and the direct-to-bridge path is the one with no policy in front
|
|
1508
|
+
// of it, so making it necessary for a feature is how it becomes the habit.
|
|
1509
|
+
if (req.method === 'GET' && pathname === '/skills') {
|
|
1510
|
+
const base = String(cfg.bridge?.url || '').replace(/\/$/, '');
|
|
1511
|
+
if (!base) return sendJson(res, 503, { error: { message: 'no bridge is configured', type: 'no_bridge' } });
|
|
1512
|
+
const token = readBridgeToken(cfg.bridge?.token);
|
|
1513
|
+
try {
|
|
1514
|
+
const r = await fetch(`${base}/skills`, {
|
|
1515
|
+
headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
1516
|
+
signal: AbortSignal.timeout(8000),
|
|
1517
|
+
});
|
|
1518
|
+
const data = await r.json().catch(() => ({}));
|
|
1519
|
+
if (!r.ok) return sendJson(res, r.status, { error: { message: data?.error || `bridge ${r.status}`, type: 'bridge_error' } });
|
|
1520
|
+
return sendJson(res, 200, { skills: Array.isArray(data?.skills) ? data.skills : [] });
|
|
1521
|
+
} catch (e) {
|
|
1522
|
+
return sendJson(res, 502, { error: { message: `bridge unreachable: ${e.message}`, type: 'bridge_unreachable' } });
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1490
1526
|
// Model discovery — aggregate every destination's models.
|
|
1491
1527
|
if (req.method === 'GET' && /\/models$/.test(pathname)) {
|
|
1492
1528
|
return sendJson(res, 200, await aggregateModelsAsync(cfg));
|
package/src/shape.js
CHANGED
|
@@ -38,6 +38,25 @@ export function openaiChat(model) {
|
|
|
38
38
|
sseTail() {
|
|
39
39
|
return sse({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }) + 'data: [DONE]\n\n';
|
|
40
40
|
},
|
|
41
|
+
/**
|
|
42
|
+
* What the agent is DOING, on a chunk an OpenAI client ignores.
|
|
43
|
+
*
|
|
44
|
+
* The chunk is well-formed and carries an empty delta, so a strict client sees a frame
|
|
45
|
+
* with nothing in it and moves on — which is what makes this additive rather than a
|
|
46
|
+
* change to the wire contract. A ChatPanel client reads the extra `chatpanel` key.
|
|
47
|
+
*
|
|
48
|
+
* This exists because an agent spends its first ten seconds reading files, and a client
|
|
49
|
+
* routed through the gateway had no way to know that while one talking to the bridge
|
|
50
|
+
* directly did. A protocol gap that rewards going around the redacting proxy is a
|
|
51
|
+
* security problem wearing a UI problem's clothes.
|
|
52
|
+
*/
|
|
53
|
+
sseActivity(evt) {
|
|
54
|
+
return sse({
|
|
55
|
+
...base,
|
|
56
|
+
choices: [{ index: 0, delta: {}, finish_reason: null }],
|
|
57
|
+
chatpanel: { kind: 'activity', event: evt },
|
|
58
|
+
});
|
|
59
|
+
},
|
|
41
60
|
// Tool-relay (agent destinations): emit the agent's tool call as an OpenAI
|
|
42
61
|
// tool_calls delta, then end the turn with finish_reason:tool_calls.
|
|
43
62
|
sseToolCalls(calls) {
|