@chatpanel/gateway 0.2.0 → 0.2.2
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/configstore.js +16 -7
- package/src/router.js +34 -7
- package/src/server.js +31 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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/configstore.js
CHANGED
|
@@ -29,7 +29,8 @@ export function persistConfig(cfg, path = configPath()) {
|
|
|
29
29
|
export function publicConfig(cfg, { proUnlocked = false } = {}) {
|
|
30
30
|
return {
|
|
31
31
|
backend: cfg.backend,
|
|
32
|
-
|
|
32
|
+
// Strip per-destination apiKey (write-only).
|
|
33
|
+
destinations: (Array.isArray(cfg.destinations) ? cfg.destinations : []).map((d) => { const { apiKey, ...rest } = d; return { ...rest, hasKey: !!apiKey }; }),
|
|
33
34
|
bridge: { url: cfg.bridge?.url, agent: cfg.bridge?.agent, hasToken: !!cfg.bridge?.token },
|
|
34
35
|
upstreams: cfg.upstreams,
|
|
35
36
|
redaction: {
|
|
@@ -50,14 +51,22 @@ export function publicConfig(cfg, { proUnlocked = false } = {}) {
|
|
|
50
51
|
export function applyConfigPatch(cfg, patch = {}) {
|
|
51
52
|
if (patch.backend === 'bridge' || patch.backend === 'api') cfg.backend = patch.backend;
|
|
52
53
|
if (Array.isArray(patch.destinations)) {
|
|
54
|
+
// Preserve a destination's apiKey when the patch omits it (it's write-only —
|
|
55
|
+
// publicConfig strips it, so the UI never round-trips it back).
|
|
56
|
+
const prev = new Map((Array.isArray(cfg.destinations) ? cfg.destinations : []).map((d) => [d.id, d]));
|
|
53
57
|
cfg.destinations = patch.destinations
|
|
54
58
|
.filter((d) => d && typeof d.id === 'string' && (d.type === 'agent' || d.type === 'api'))
|
|
55
|
-
.map((d) =>
|
|
56
|
-
id: d.id, type: d.type,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
.map((d) => {
|
|
60
|
+
const out = { id: d.id, type: d.type, models: Array.isArray(d.models) ? d.models.filter((m) => typeof m === 'string' && m) : [] };
|
|
61
|
+
if (d.type === 'agent') out.agent = d.agent || d.id;
|
|
62
|
+
if (d.type === 'api') {
|
|
63
|
+
out.baseUrl = String(d.baseUrl || '');
|
|
64
|
+
out.protocol = d.protocol === 'anthropic' ? 'anthropic' : 'openai';
|
|
65
|
+
const key = (typeof d.apiKey === 'string' && d.apiKey) ? d.apiKey : prev.get(d.id)?.apiKey;
|
|
66
|
+
if (key) out.apiKey = key;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
});
|
|
61
70
|
}
|
|
62
71
|
if (patch.bridge && typeof patch.bridge === 'object') {
|
|
63
72
|
if (typeof patch.bridge.url === 'string') cfg.bridge.url = patch.bridge.url;
|
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.2';
|
|
36
36
|
|
|
37
37
|
const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
|
|
38
38
|
|
|
@@ -71,18 +71,30 @@ const STARTED_AT = Date.now();
|
|
|
71
71
|
// chat endpoint, and (api backend) which upstream base URL.
|
|
72
72
|
function route(pathname, headers, cfg) {
|
|
73
73
|
if (anthropic.matches(pathname) || 'anthropic-version' in headers) {
|
|
74
|
-
return { kind: 'anthropic', adapter: anthropic, redactable: anthropic.matches(pathname), base: cfg.upstreams
|
|
74
|
+
return { kind: 'anthropic', adapter: anthropic, redactable: anthropic.matches(pathname), base: cfg.upstreams?.anthropic?.baseUrl };
|
|
75
75
|
}
|
|
76
76
|
if (responses.matches(pathname)) {
|
|
77
|
-
return { kind: 'responses', adapter: responses, redactable: true, base: cfg.upstreams
|
|
77
|
+
return { kind: 'responses', adapter: responses, redactable: true, base: cfg.upstreams?.openai?.baseUrl };
|
|
78
78
|
}
|
|
79
|
-
return { kind: 'openai', adapter: openai, redactable: openai.matches(pathname), base: cfg.upstreams
|
|
79
|
+
return { kind: 'openai', adapter: openai, redactable: openai.matches(pathname), base: cfg.upstreams?.openai?.baseUrl };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
function pickAgent(model, cfg) {
|
|
83
83
|
return KNOWN_AGENTS.has(model) ? model : cfg.bridge.agent;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Guard against an api destination pointing back at THIS gateway (loopback host +
|
|
87
|
+
// our own port) — forwarding there would loop forever.
|
|
88
|
+
function isSelfUrl(baseUrl, cfg) {
|
|
89
|
+
try {
|
|
90
|
+
const u = new URL(baseUrl);
|
|
91
|
+
const host = u.hostname.replace(/^\[|\]$/g, '');
|
|
92
|
+
const loop = host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
93
|
+
const port = u.port || (u.protocol === 'https:' ? '443' : '80');
|
|
94
|
+
return loop && String(port) === String(cfg.port);
|
|
95
|
+
} catch { return false; }
|
|
96
|
+
}
|
|
97
|
+
|
|
86
98
|
async function readBody(req, maxBytes) {
|
|
87
99
|
const chunks = [];
|
|
88
100
|
let size = 0;
|
|
@@ -156,12 +168,19 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
156
168
|
|
|
157
169
|
// ---- backend: api ----------------------------------------------------------
|
|
158
170
|
|
|
159
|
-
async function handleApi(req, res, { adapter, pathname, search, base }, outBody, vault) {
|
|
171
|
+
async function handleApi(req, res, { adapter, pathname, search, base, destKey, destProtocol }, outBody, vault) {
|
|
160
172
|
let upstream;
|
|
161
173
|
try {
|
|
174
|
+
const headers = forwardHeaders(req.headers, base);
|
|
175
|
+
// If the destination carries its own key (imported from a configured API),
|
|
176
|
+
// forward WITH it instead of relying on the client's auth header.
|
|
177
|
+
if (destKey) {
|
|
178
|
+
if (destProtocol === 'anthropic') { headers['x-api-key'] = destKey; delete headers.authorization; }
|
|
179
|
+
else { headers.authorization = `Bearer ${destKey}`; }
|
|
180
|
+
}
|
|
162
181
|
upstream = await fetch(base.replace(/\/$/, '') + pathname + search, {
|
|
163
182
|
method: req.method,
|
|
164
|
-
headers
|
|
183
|
+
headers,
|
|
165
184
|
body: ['GET', 'HEAD'].includes(req.method) ? undefined : outBody,
|
|
166
185
|
});
|
|
167
186
|
} catch (e) {
|
|
@@ -234,7 +253,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
234
253
|
|
|
235
254
|
// Model discovery — aggregate every destination's models.
|
|
236
255
|
if (req.method === 'GET' && /\/models$/.test(pathname)) {
|
|
237
|
-
return sendJson(res, 200,
|
|
256
|
+
return sendJson(res, 200, await aggregateModelsAsync(cfg));
|
|
238
257
|
}
|
|
239
258
|
|
|
240
259
|
const r = route(pathname, req.headers, cfg);
|
|
@@ -280,7 +299,10 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
280
299
|
}
|
|
281
300
|
if (dest && dest.type === 'api') {
|
|
282
301
|
if (!dest.baseUrl) return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` });
|
|
283
|
-
|
|
302
|
+
if (isSelfUrl(dest.baseUrl, cfg)) {
|
|
303
|
+
return sendJson(res, 508, { error: { message: `destination "${dest.id}" points back at the gateway (${dest.baseUrl}) — refusing to forward (would loop).`, type: 'loop_detected' } });
|
|
304
|
+
}
|
|
305
|
+
return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol }, outBody, vault);
|
|
284
306
|
}
|
|
285
307
|
return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent }, body, vault, cfg);
|
|
286
308
|
});
|