@chatpanel/gateway 0.6.65 → 0.6.68
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/router.js +33 -0
- package/src/server.js +37 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "Local privacy gateway
|
|
3
|
+
"version": "0.6.68",
|
|
4
|
+
"description": "Local privacy gateway \u2014 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": {
|
|
7
7
|
"chatpanel-gateway": "bin/chatpanel-gateway.js"
|
package/src/router.js
CHANGED
|
@@ -86,6 +86,32 @@ function apiShapeOf(d) {
|
|
|
86
86
|
return { api: 'openai', endpoints: ['/v1/chat/completions', '/v1/responses'] };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* CAN THIS DESTINATION ANSWER AT ALL — before anyone sends a turn to it.
|
|
91
|
+
*
|
|
92
|
+
* This list is a ROUTING TABLE. It names every model the gateway would forward to, and a
|
|
93
|
+
* provider that lists four hundred can be configured with no key at all — so the list is
|
|
94
|
+
* mostly names that cannot answer. A user picks one, and finds out at 08:00 when a scheduled
|
|
95
|
+
* job comes back `Missing Authentication header` or `upstream fetch failed`, quoting undici
|
|
96
|
+
* about a destination they never knowingly chose.
|
|
97
|
+
*
|
|
98
|
+
* Local endpoints are the exception that must NOT be marked unconfigured: llama.cpp, Ollama
|
|
99
|
+
* and LM Studio take no key, and greying them out would hide the one setup that needs
|
|
100
|
+
* nothing. So this asks only the question it can answer honestly — "is a credential
|
|
101
|
+
* required here, and is one saved" — and says nothing about whether the server is up.
|
|
102
|
+
*
|
|
103
|
+
* Returns `''` when there is nothing to report.
|
|
104
|
+
*/
|
|
105
|
+
const LOCAL_HOST = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0|[^/]*\.local)(:|\/|$)/i;
|
|
106
|
+
function unconfiguredReason(d) {
|
|
107
|
+
if (!d || d.type === 'agent') return '';
|
|
108
|
+
const base = String(d.baseUrl || '');
|
|
109
|
+
if (!base) return 'no endpoint URL is set for this provider';
|
|
110
|
+
if (LOCAL_HOST.test(base)) return ''; // a local server needs no key
|
|
111
|
+
if (d.apiKey || d.hasKey) return '';
|
|
112
|
+
return `no API key is saved for ${d.id}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
89
115
|
export function aggregateModels(cfg) {
|
|
90
116
|
const data = [];
|
|
91
117
|
const seen = new Set();
|
|
@@ -93,10 +119,15 @@ export function aggregateModels(cfg) {
|
|
|
93
119
|
if (!id || seen.has(id)) return;
|
|
94
120
|
seen.add(id);
|
|
95
121
|
const shape = apiShapeOf(d);
|
|
122
|
+
const blocked = unconfiguredReason(d);
|
|
96
123
|
data.push({
|
|
97
124
|
id,
|
|
98
125
|
object: 'model',
|
|
99
126
|
owned_by: owner,
|
|
127
|
+
// `configured` is about SETUP, not liveness: false means a turn sent here is known to
|
|
128
|
+
// fail for a reason the user can fix in Settings. Absent liveness is deliberate — the
|
|
129
|
+
// gateway does not probe every provider to draw a list.
|
|
130
|
+
...(blocked ? { configured: false, reason: blocked } : {}),
|
|
100
131
|
// Additive fields an OpenAI client ignores and a ChatPanel client uses to decide how
|
|
101
132
|
// to call, and to group a picker by provider instead of by a flat list of ids.
|
|
102
133
|
provider: d.id,
|
|
@@ -237,10 +268,12 @@ export async function aggregateModelsAsync(cfg, { timeoutMs = 4000 } = {}) {
|
|
|
237
268
|
if (id && !seen.has(id)) {
|
|
238
269
|
seen.add(id);
|
|
239
270
|
const shape = apiShapeOf(d);
|
|
271
|
+
const blocked = unconfiguredReason(d);
|
|
240
272
|
base.data.push({
|
|
241
273
|
id, object: 'model', owned_by: d.id, provider: d.id,
|
|
242
274
|
provider_type: d.protocol === 'anthropic' ? 'anthropic' : 'openai',
|
|
243
275
|
api: shape.api, endpoints: shape.endpoints,
|
|
276
|
+
...(blocked ? { configured: false, reason: blocked } : {}),
|
|
244
277
|
});
|
|
245
278
|
}
|
|
246
279
|
}
|
package/src/server.js
CHANGED
|
@@ -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.68';
|
|
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.
|
|
@@ -660,6 +660,10 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
660
660
|
const tts = ttsEngine.health();
|
|
661
661
|
return sendJson(res, 200, {
|
|
662
662
|
ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier,
|
|
663
|
+
// WHO STARTED THIS PROCESS — additive. ChatPanel Desktop sets CHATPANEL_MANAGED_BY=desktop
|
|
664
|
+
// on the login service it registers, so a client can say "provided by the desktop app"
|
|
665
|
+
// and stop offering install.sh for a gateway that is already installed. Absent otherwise.
|
|
666
|
+
...(process.env.CHATPANEL_MANAGED_BY ? { managedBy: String(process.env.CHATPANEL_MANAGED_BY).slice(0, 32) } : {}),
|
|
663
667
|
// `runtime` = 'native' (npm, fast quantized) | 'wasm' (binary, slow fp32) —
|
|
664
668
|
// the extension uses it to advise the far-faster native gateway.
|
|
665
669
|
stt: { enabled: cfg.stt?.enabled !== false, state: stt.state, ready: stt.ok, model: stt.model || cfg.stt?.model || DEFAULT_STT_MODEL, runtime: stt.runtime, dtype: stt.dtype },
|
|
@@ -676,6 +680,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
676
680
|
const health = await probeNerHealth(cfg); // live GET /health on the detector
|
|
677
681
|
return sendJson(res, 200, {
|
|
678
682
|
ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier,
|
|
683
|
+
...(process.env.CHATPANEL_MANAGED_BY ? { managedBy: String(process.env.CHATPANEL_MANAGED_BY).slice(0, 32) } : {}), // see /health
|
|
679
684
|
ner: {
|
|
680
685
|
autostart: !!cfg.ner?.autostart,
|
|
681
686
|
configured: health.configured,
|
|
@@ -1552,6 +1557,37 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1552
1557
|
}
|
|
1553
1558
|
}
|
|
1554
1559
|
|
|
1560
|
+
// ONE skill, with the prompt the list deliberately leaves out.
|
|
1561
|
+
//
|
|
1562
|
+
// /skills answers `promptChars` — a COUNT — because a list of ninety-five skills carrying
|
|
1563
|
+
// ninety-five prompt bodies is a megabyte nobody asked for. A client that wants to scope a
|
|
1564
|
+
// task to a skill needs the body itself, and the only other way to get it was to go
|
|
1565
|
+
// straight at the bridge, which is the habit the /skills comment above exists to prevent.
|
|
1566
|
+
//
|
|
1567
|
+
// The id is passed through as ONE path segment, encoded. A skill id is a file name on the
|
|
1568
|
+
// user's disk, so letting a slash or a `..` reach the bridge's reader would be asking it
|
|
1569
|
+
// to open something else; the bridge validates too, and this is the half we own.
|
|
1570
|
+
if (req.method === 'GET' && /^\/skills\/[^/]+$/.test(pathname)) {
|
|
1571
|
+
const id = decodeURIComponent(pathname.slice('/skills/'.length));
|
|
1572
|
+
if (!id || id === '.' || id === '..' || id.includes('/') || id.includes('\\')) {
|
|
1573
|
+
return sendJson(res, 400, { error: { message: 'not a skill id', type: 'invalid_request' } });
|
|
1574
|
+
}
|
|
1575
|
+
const base = String(cfg.bridge?.url || '').replace(/\/$/, '');
|
|
1576
|
+
if (!base) return sendJson(res, 503, { error: { message: 'no bridge is configured', type: 'no_bridge' } });
|
|
1577
|
+
const token = readBridgeToken(cfg.bridge?.token);
|
|
1578
|
+
try {
|
|
1579
|
+
const r = await fetch(`${base}/skills/${encodeURIComponent(id)}`, {
|
|
1580
|
+
headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
1581
|
+
signal: AbortSignal.timeout(8000),
|
|
1582
|
+
});
|
|
1583
|
+
const data = await r.json().catch(() => ({}));
|
|
1584
|
+
if (!r.ok) return sendJson(res, r.status, { error: { message: data?.error || `bridge ${r.status}`, type: 'bridge_error' } });
|
|
1585
|
+
return sendJson(res, 200, { skill: data?.skill || null });
|
|
1586
|
+
} catch (e) {
|
|
1587
|
+
return sendJson(res, 502, { error: { message: `bridge unreachable: ${e.message}`, type: 'bridge_unreachable' } });
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1555
1591
|
// Model discovery — aggregate every destination's models.
|
|
1556
1592
|
if (req.method === 'GET' && /\/models$/.test(pathname)) {
|
|
1557
1593
|
return sendJson(res, 200, await aggregateModelsAsync(cfg));
|