@chatpanel/gateway 0.6.53 → 0.6.54
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/config.js +4 -1
- package/src/configstore.js +6 -1
- package/src/server.js +66 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.54",
|
|
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/config.js
CHANGED
|
@@ -9,7 +9,10 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// Exported so tests can assert that every section here survives persistConfig's
|
|
13
|
+
// allowlist — a new section that is not persisted reverts on restart, and that
|
|
14
|
+
// reads as a broken feature rather than an unsaved setting.
|
|
15
|
+
export const DEFAULTS = {
|
|
13
16
|
host: '127.0.0.1',
|
|
14
17
|
port: 4320,
|
|
15
18
|
|
package/src/configstore.js
CHANGED
|
@@ -19,13 +19,18 @@ export function persistConfig(cfg, path = configPath()) {
|
|
|
19
19
|
// detector key, and the entitlement/bridge tokens — same secret-at-rest posture
|
|
20
20
|
// as the history key/secret files, so it isn't left world-readable on a shared host.
|
|
21
21
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
22
|
+
// NOTE: this is an explicit allowlist, so a NEW config section does not persist
|
|
23
|
+
// until it is added here — and the symptom is a setting that silently reverts on
|
|
24
|
+
// restart, which reads as "the feature is broken" rather than "it was not saved".
|
|
25
|
+
// tests/configstore.test.js fails if a key in DEFAULTS is neither listed here nor
|
|
26
|
+
// deliberately excluded below.
|
|
22
27
|
const out = {
|
|
23
28
|
host: cfg.host, port: cfg.port, backend: cfg.backend,
|
|
24
29
|
// Destinations (the configured agents + API models) MUST persist — otherwise a
|
|
25
30
|
// restart drops them and every model falls back to the default OpenAI upstream.
|
|
26
31
|
destinations: cfg.destinations,
|
|
27
32
|
bridge: cfg.bridge, upstreams: cfg.upstreams, redaction: cfg.redaction,
|
|
28
|
-
ner: cfg.ner, stt: cfg.stt, allowedOrigins: cfg.allowedOrigins, maxBodyBytes: cfg.maxBodyBytes,
|
|
33
|
+
ner: cfg.ner, stt: cfg.stt, tts: cfg.tts, allowedOrigins: cfg.allowedOrigins, maxBodyBytes: cfg.maxBodyBytes,
|
|
29
34
|
pro: cfg.pro, logRequests: cfg.logRequests, logDetail: cfg.logDetail, tools: cfg.tools,
|
|
30
35
|
};
|
|
31
36
|
// mode on writeFileSync only applies when CREATING the file; chmod after covers an
|
package/src/server.js
CHANGED
|
@@ -53,7 +53,7 @@ import * as openai from './openai.js';
|
|
|
53
53
|
import * as responses from './responses.js';
|
|
54
54
|
import * as anthropic from './anthropic.js';
|
|
55
55
|
|
|
56
|
-
export const VERSION = '0.6.
|
|
56
|
+
export const VERSION = '0.6.54';
|
|
57
57
|
|
|
58
58
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
59
59
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -454,6 +454,16 @@ export function joinUpstream(base, pathname, search = '') {
|
|
|
454
454
|
return b + pathname + search;
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
+
// Paths this gateway serves ITSELF. Used only to tell "you asked for a local
|
|
458
|
+
// feature I do not have" apart from "you asked me to proxy something upstream" —
|
|
459
|
+
// without it, calling a route added in a newer version reports a provider failure.
|
|
460
|
+
// How long to wait for the speaker model before telling the caller to retry. Long
|
|
461
|
+
// enough to cover loading one already on disk (seconds) plus a slow first fetch,
|
|
462
|
+
// short enough that a stuck download does not hold a request open forever.
|
|
463
|
+
const EMBEDDER_WAIT_MS = 90_000;
|
|
464
|
+
|
|
465
|
+
const LOCAL_NAMESPACES = ['/tts', '/stt', '/ner', '/diarize', '/skills', '/config', '/logs', '/status', '/admin'];
|
|
466
|
+
|
|
457
467
|
async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol, harness, trace }, outBody, vault) {
|
|
458
468
|
let upstream;
|
|
459
469
|
const up0 = trace ? trace.clock() : 0;
|
|
@@ -989,7 +999,12 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
989
999
|
supportsVoices: ttsEngine.supportsVoices(),
|
|
990
1000
|
supportsCustomVoices: ttsEngine.supportsCustomVoices(),
|
|
991
1001
|
sampleRate: ttsEngine.sampleRate(),
|
|
992
|
-
|
|
1002
|
+
// Built-in voices belong to Kokoro alone. VITS is single-speaker and
|
|
1003
|
+
// SpeechT5 speaks only in a RECORDED voice, so offering Kokoro's list
|
|
1004
|
+
// for either would be offering choices that cannot take effect.
|
|
1005
|
+
voices: ttsEngine.arch() && ttsEngine.arch() !== 'style-tts2'
|
|
1006
|
+
? []
|
|
1007
|
+
: TTS_VOICES.map((v) => ({ ...v, installed: ttsEngine.voiceOnDisk(v.id, active) })),
|
|
993
1008
|
dtype: cfg.tts?.dtype || 'auto',
|
|
994
1009
|
loadedDtype: ttsEngine.health().dtype,
|
|
995
1010
|
runtime: ttsEngine.health().runtime,
|
|
@@ -1050,15 +1065,30 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1050
1065
|
return sendJson(res, 400, { error: { message: 'need at least 1 second of 16 kHz mono audio', type: 'sample_too_short' } });
|
|
1051
1066
|
}
|
|
1052
1067
|
try {
|
|
1053
|
-
// The embedder is the speaker model diarization already uses.
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1068
|
+
// The embedder is the speaker model diarization already uses. WAIT for it
|
|
1069
|
+
// rather than bailing: it is usually already on disk, where loading takes
|
|
1070
|
+
// a couple of seconds — and the caller is holding a recording someone
|
|
1071
|
+
// just made, so returning early means they lose it and record again.
|
|
1072
|
+
// Only a genuine first-time download can outlast the ceiling, and that is
|
|
1073
|
+
// the one case worth reporting as "come back in a moment".
|
|
1056
1074
|
if (!diarizeEngine.isReady()) {
|
|
1057
|
-
diarizeEngine.download({ onLog: (m) => console.log(m) });
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1075
|
+
const load = diarizeEngine.download({ onLog: (m) => console.log(m) });
|
|
1076
|
+
const timedOut = Symbol('timeout');
|
|
1077
|
+
const raced = await Promise.race([
|
|
1078
|
+
load.then(() => null).catch((e) => e),
|
|
1079
|
+
new Promise((r) => setTimeout(() => r(timedOut), EMBEDDER_WAIT_MS)),
|
|
1080
|
+
]);
|
|
1081
|
+
if (raced === timedOut || !diarizeEngine.isReady()) {
|
|
1082
|
+
return sendJson(res, 503, {
|
|
1083
|
+
error: {
|
|
1084
|
+
message: raced === timedOut
|
|
1085
|
+
? 'the speaker model is still downloading (~100 MB) — your recording was kept, press Save again shortly'
|
|
1086
|
+
: `the speaker model failed to load: ${diarizeEngine.health().error || 'unknown error'}`,
|
|
1087
|
+
type: 'embedder_not_ready',
|
|
1088
|
+
},
|
|
1089
|
+
progress: diarizeEngine.progress(),
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1062
1092
|
}
|
|
1063
1093
|
const vec = await diarizeEngine.embed(Float32Array.from(pcm));
|
|
1064
1094
|
const saved = ttsVoices.saveVoice({ name, vec });
|
|
@@ -1100,7 +1130,17 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1100
1130
|
const voice = rawVoice || (dest ? dest.voice : (cfg.tts?.voice || DEFAULT_TTS_VOICE));
|
|
1101
1131
|
// `custom:<id>` names a saved voice. It is resolved to an embedding here so
|
|
1102
1132
|
// the engine never has to know where voices are stored.
|
|
1103
|
-
|
|
1133
|
+
let customId = dest ? null : ttsVoices.parseCustomVoice(voice);
|
|
1134
|
+
// The active model may REQUIRE an embedding while the configured voice still
|
|
1135
|
+
// names a built-in one — switching to SpeechT5 does not rewrite `voice`, and
|
|
1136
|
+
// a client that never sends one inherits whatever was there. Rather than
|
|
1137
|
+
// failing with "record a voice" at someone who already has, fall back to the
|
|
1138
|
+
// most recent saved voice. Erroring is right only when there is genuinely
|
|
1139
|
+
// none to use.
|
|
1140
|
+
if (!dest && !customId && ttsEngine.supportsCustomVoices()) {
|
|
1141
|
+
const saved = ttsVoices.listVoices();
|
|
1142
|
+
if (saved.length) customId = saved[0].id;
|
|
1143
|
+
}
|
|
1104
1144
|
let speakerEmbedding = null;
|
|
1105
1145
|
if (customId) {
|
|
1106
1146
|
const rec = ttsVoices.getVoice(customId);
|
|
@@ -1158,6 +1198,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1158
1198
|
'Content-Length': String(out.length),
|
|
1159
1199
|
'Cache-Control': 'no-store',
|
|
1160
1200
|
'X-Tts-Sample-Rate': String(rate),
|
|
1201
|
+
...(customId ? { 'X-Tts-Voice': `custom:${customId}` } : {}),
|
|
1161
1202
|
});
|
|
1162
1203
|
return res.end(out);
|
|
1163
1204
|
} catch (e) {
|
|
@@ -1289,6 +1330,20 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1289
1330
|
return sendJson(res, 200, await aggregateModelsAsync(cfg));
|
|
1290
1331
|
}
|
|
1291
1332
|
|
|
1333
|
+
// Anything under a LOCAL namespace that reached here matched no route, which
|
|
1334
|
+
// almost always means the caller is newer than this gateway. Falling through to
|
|
1335
|
+
// the model proxy makes that arrive as "upstream fetch failed", pointing the
|
|
1336
|
+
// user at their model provider for a feature their gateway simply does not
|
|
1337
|
+
// have yet — so these 404 with the actual reason instead.
|
|
1338
|
+
if (LOCAL_NAMESPACES.some((ns) => pathname === ns || pathname.startsWith(`${ns}/`))) {
|
|
1339
|
+
return sendJson(res, 404, {
|
|
1340
|
+
error: {
|
|
1341
|
+
message: `this gateway (${VERSION}) has no ${pathname} — update it to use this feature`,
|
|
1342
|
+
type: 'unknown_endpoint',
|
|
1343
|
+
},
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1292
1347
|
const r = route(pathname, req.headers, cfg);
|
|
1293
1348
|
let raw;
|
|
1294
1349
|
try {
|