@chatpanel/gateway 0.2.1 → 0.2.3
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 +1 -1
- package/src/router.js +34 -7
- package/src/server.js +22 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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/router.js
CHANGED
|
@@ -41,17 +41,44 @@ export function resolveDestination(model, cfg, kind) {
|
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
// Aggregate every destination's models for GET /v1/models.
|
|
44
|
+
// Aggregate every destination's models for GET /v1/models. Agents expose their
|
|
45
|
+
// own name as the model; APIs expose ONLY real model ids (never the destination
|
|
46
|
+
// id — that's a provider name, not a model).
|
|
45
47
|
export function aggregateModels(cfg) {
|
|
46
48
|
const data = [];
|
|
47
49
|
const seen = new Set();
|
|
50
|
+
const add = (id, owner) => { if (id && !seen.has(id)) { seen.add(id); data.push({ id, object: 'model', owned_by: owner }); } };
|
|
48
51
|
for (const d of listDestinations(cfg)) {
|
|
49
|
-
|
|
50
|
-
for (const m of models)
|
|
51
|
-
if (!m || seen.has(m)) continue;
|
|
52
|
-
seen.add(m);
|
|
53
|
-
data.push({ id: m, object: 'model', owned_by: d.type === 'agent' ? 'chatpanel-bridge' : (d.id || 'api') });
|
|
54
|
-
}
|
|
52
|
+
if (d.type === 'agent') for (const m of (d.models?.length ? d.models : [d.id])) add(m, 'chatpanel-bridge');
|
|
53
|
+
else for (const m of (d.models || [])) add(m, d.id);
|
|
55
54
|
}
|
|
56
55
|
return { object: 'list', data };
|
|
57
56
|
}
|
|
57
|
+
|
|
58
|
+
// Async variant: also PROXIES each API destination's own /v1/models to discover
|
|
59
|
+
// real model ids (using its saved key). Fail-open per destination.
|
|
60
|
+
export async function aggregateModelsAsync(cfg, { timeoutMs = 4000 } = {}) {
|
|
61
|
+
const base = aggregateModels(cfg);
|
|
62
|
+
const seen = new Set(base.data.map((m) => m.id));
|
|
63
|
+
const dests = listDestinations(cfg).filter((d) => d.type === 'api' && d.baseUrl);
|
|
64
|
+
await Promise.all(dests.map(async (d) => {
|
|
65
|
+
const ctrl = new AbortController();
|
|
66
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
67
|
+
try {
|
|
68
|
+
const headers = { 'content-type': 'application/json' };
|
|
69
|
+
if (d.apiKey) {
|
|
70
|
+
if (d.protocol === 'anthropic') { headers['x-api-key'] = d.apiKey; headers['anthropic-version'] = '2023-06-01'; }
|
|
71
|
+
else headers.authorization = `Bearer ${d.apiKey}`;
|
|
72
|
+
}
|
|
73
|
+
const res = await fetch(`${d.baseUrl.replace(/\/$/, '')}/models`, { headers, signal: ctrl.signal });
|
|
74
|
+
if (!res.ok) return;
|
|
75
|
+
const j = await res.json();
|
|
76
|
+
const list = Array.isArray(j?.data) ? j.data : (Array.isArray(j?.models) ? j.models : []);
|
|
77
|
+
for (const m of list) {
|
|
78
|
+
const id = typeof m === 'string' ? m : m?.id;
|
|
79
|
+
if (id && !seen.has(id)) { seen.add(id); base.data.push({ id, object: 'model', owned_by: d.id }); }
|
|
80
|
+
}
|
|
81
|
+
} catch { /* fail-open */ } finally { clearTimeout(t); }
|
|
82
|
+
}));
|
|
83
|
+
return base;
|
|
84
|
+
}
|
package/src/server.js
CHANGED
|
@@ -27,12 +27,12 @@ import { shaperFor } from './shape.js';
|
|
|
27
27
|
import { startNer } from './ner.js';
|
|
28
28
|
import { resolvePro, meter, usage } from './freegate.js';
|
|
29
29
|
import { publicConfig, applyConfigPatch, persistConfig, configPath } from './configstore.js';
|
|
30
|
-
import { resolveDestination,
|
|
30
|
+
import { resolveDestination, aggregateModelsAsync } from './router.js';
|
|
31
31
|
import * as openai from './openai.js';
|
|
32
32
|
import * as responses from './responses.js';
|
|
33
33
|
import * as anthropic from './anthropic.js';
|
|
34
34
|
|
|
35
|
-
export const VERSION = '0.2.
|
|
35
|
+
export const VERSION = '0.2.3';
|
|
36
36
|
|
|
37
37
|
const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
|
|
38
38
|
|
|
@@ -67,16 +67,24 @@ function setCors(res, origin) {
|
|
|
67
67
|
|
|
68
68
|
const STARTED_AT = Date.now();
|
|
69
69
|
|
|
70
|
+
// In-memory ring of recent request SUMMARIES for the extension's monitoring view.
|
|
71
|
+
// Counts only — never any prompt/response text or values.
|
|
72
|
+
const recentRequests = [];
|
|
73
|
+
function recordRequest(entry) {
|
|
74
|
+
recentRequests.push(entry);
|
|
75
|
+
if (recentRequests.length > 50) recentRequests.shift();
|
|
76
|
+
}
|
|
77
|
+
|
|
70
78
|
// Classify a request: which protocol kind + adapter, whether it's a redactable
|
|
71
79
|
// chat endpoint, and (api backend) which upstream base URL.
|
|
72
80
|
function route(pathname, headers, cfg) {
|
|
73
81
|
if (anthropic.matches(pathname) || 'anthropic-version' in headers) {
|
|
74
|
-
return { kind: 'anthropic', adapter: anthropic, redactable: anthropic.matches(pathname), base: cfg.upstreams
|
|
82
|
+
return { kind: 'anthropic', adapter: anthropic, redactable: anthropic.matches(pathname), base: cfg.upstreams?.anthropic?.baseUrl };
|
|
75
83
|
}
|
|
76
84
|
if (responses.matches(pathname)) {
|
|
77
|
-
return { kind: 'responses', adapter: responses, redactable: true, base: cfg.upstreams
|
|
85
|
+
return { kind: 'responses', adapter: responses, redactable: true, base: cfg.upstreams?.openai?.baseUrl };
|
|
78
86
|
}
|
|
79
|
-
return { kind: 'openai', adapter: openai, redactable: openai.matches(pathname), base: cfg.upstreams
|
|
87
|
+
return { kind: 'openai', adapter: openai, redactable: openai.matches(pathname), base: cfg.upstreams?.openai?.baseUrl };
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
function pickAgent(model, cfg) {
|
|
@@ -237,6 +245,9 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
237
245
|
uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
|
|
238
246
|
});
|
|
239
247
|
}
|
|
248
|
+
if (pathname === '/logs' && req.method === 'GET') {
|
|
249
|
+
return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only
|
|
250
|
+
}
|
|
240
251
|
if (pathname === '/config' && req.method === 'GET') {
|
|
241
252
|
const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
|
|
242
253
|
return sendJson(res, 200, publicConfig(cfg, { proUnlocked }));
|
|
@@ -253,7 +264,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
253
264
|
|
|
254
265
|
// Model discovery — aggregate every destination's models.
|
|
255
266
|
if (req.method === 'GET' && /\/models$/.test(pathname)) {
|
|
256
|
-
return sendJson(res, 200,
|
|
267
|
+
return sendJson(res, 200, await aggregateModelsAsync(cfg));
|
|
257
268
|
}
|
|
258
269
|
|
|
259
270
|
const r = route(pathname, req.headers, cfg);
|
|
@@ -270,6 +281,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
270
281
|
let vault = null;
|
|
271
282
|
let body = null;
|
|
272
283
|
let outBody = raw;
|
|
284
|
+
let redactedCount = 0;
|
|
273
285
|
if (r.redactable && req.method === 'POST' && raw.length) {
|
|
274
286
|
try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
|
|
275
287
|
if (body) {
|
|
@@ -287,6 +299,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
287
299
|
req.on('close', () => ac.abort());
|
|
288
300
|
const { vault: v, count } = await redactSegments(segs, cfg.redaction, { signal: ac.signal, isPro });
|
|
289
301
|
vault = v;
|
|
302
|
+
redactedCount = count;
|
|
290
303
|
outBody = Buffer.from(JSON.stringify(body), 'utf8');
|
|
291
304
|
}
|
|
292
305
|
}
|
|
@@ -294,8 +307,9 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
294
307
|
// Route by the requested model → a destination (agent via the bridge, or an
|
|
295
308
|
// API we forward to). Falls back to the legacy backend when none configured.
|
|
296
309
|
const dest = resolveDestination(body?.model, cfg, r.kind);
|
|
297
|
-
if (cfg.logRequests) {
|
|
298
|
-
|
|
310
|
+
if (cfg.logRequests && r.redactable) {
|
|
311
|
+
recordRequest({ t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount });
|
|
312
|
+
console.log(`[gateway] ${req.method} ${pathname} · model=${body?.model || '-'} → ${dest ? `${dest.id}(${dest.type})` : 'none'} · redacted ${redactedCount}`);
|
|
299
313
|
}
|
|
300
314
|
if (dest && dest.type === 'api') {
|
|
301
315
|
if (!dest.baseUrl) return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` });
|