@chatpanel/gateway 0.6.53 → 0.6.55
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 +137 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.55",
|
|
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
|
@@ -40,7 +40,7 @@ import * as diarizeEngine from './diarize-engine.js';
|
|
|
40
40
|
import { MODEL_CATALOG, isKnownModel, isValidCustomModelId } from './models.js';
|
|
41
41
|
import { STT_MODEL_CATALOG, isKnownSttModel, isValidCustomSttId, DEFAULT_STT_MODEL, STT_DTYPES, isValidDtype } from './stt-models.js';
|
|
42
42
|
import * as ttsEngine from './tts-engine.js';
|
|
43
|
-
import { TTS_MODEL_CATALOG, TTS_VOICES, isKnownTtsModel, isValidCustomTtsId, isKnownVoice, isValidVoiceId, DEFAULT_TTS_MODEL, DEFAULT_TTS_VOICE, TTS_DTYPES, isValidTtsDtype, MAX_TTS_CHARS } from './tts-models.js';
|
|
43
|
+
import { TTS_MODEL_CATALOG, TTS_VOICES, isKnownTtsModel, isValidCustomTtsId, isKnownVoice, isValidVoiceId, DEFAULT_TTS_MODEL, DEFAULT_TTS_VOICE, TTS_DTYPES, isValidTtsDtype, MAX_TTS_CHARS, ttsModelHasCustomVoices } from './tts-models.js';
|
|
44
44
|
import { ttsDestination, synthesizeRemote, isValidRemoteVoice } from './tts-remote.js';
|
|
45
45
|
import * as ttsVoices from './tts-voices.js';
|
|
46
46
|
import { resolvePro, checkQuota, consume, usage } from './freegate.js';
|
|
@@ -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.55';
|
|
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,
|
|
@@ -1015,6 +1030,26 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1015
1030
|
if (!cfg.tts) cfg.tts = { enabled: true, model: DEFAULT_TTS_MODEL, voice: DEFAULT_TTS_VOICE, allowDownload: true };
|
|
1016
1031
|
if (id) cfg.tts.model = id;
|
|
1017
1032
|
if (voice) cfg.tts.voice = voice;
|
|
1033
|
+
// Switching model must revalidate the voice, or the config ends up naming a
|
|
1034
|
+
// Kokoro voice for SpeechT5 (which then has nothing to speak in) or a
|
|
1035
|
+
// recorded voice for Kokoro (which cannot use one). Both states look like
|
|
1036
|
+
// "text-to-speech is broken" from the outside, and both are reachable with
|
|
1037
|
+
// two clicks. Only rewrite when the CURRENT voice cannot work for the new
|
|
1038
|
+
// model — never override a voice the caller just set.
|
|
1039
|
+
if (id && !voice) {
|
|
1040
|
+
const wantsCustom = ttsModelHasCustomVoices(id);
|
|
1041
|
+
// "Is it a custom voice" is not enough — it must be one that still
|
|
1042
|
+
// EXISTS. A config naming a deleted voice is exactly the state that made
|
|
1043
|
+
// every later request fail with "no such saved voice".
|
|
1044
|
+
const curId = ttsVoices.parseCustomVoice(cfg.tts.voice || '');
|
|
1045
|
+
const isCustom = !!(curId && ttsVoices.getVoice(curId));
|
|
1046
|
+
if (wantsCustom && !isCustom) {
|
|
1047
|
+
const saved = ttsVoices.listVoices();
|
|
1048
|
+
cfg.tts.voice = saved.length ? `custom:${saved[0].id}` : '';
|
|
1049
|
+
} else if (!wantsCustom && isCustom) {
|
|
1050
|
+
cfg.tts.voice = DEFAULT_TTS_VOICE;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1018
1053
|
if (dtype) cfg.tts.dtype = dtype === 'auto' ? null : dtype;
|
|
1019
1054
|
try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
|
|
1020
1055
|
if (id) ttsEngine.setModel(id, { onLog: (m) => console.log(m), dtype: dtype || cfg.tts.dtype || 'auto' });
|
|
@@ -1050,15 +1085,30 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1050
1085
|
return sendJson(res, 400, { error: { message: 'need at least 1 second of 16 kHz mono audio', type: 'sample_too_short' } });
|
|
1051
1086
|
}
|
|
1052
1087
|
try {
|
|
1053
|
-
// The embedder is the speaker model diarization already uses.
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1088
|
+
// The embedder is the speaker model diarization already uses. WAIT for it
|
|
1089
|
+
// rather than bailing: it is usually already on disk, where loading takes
|
|
1090
|
+
// a couple of seconds — and the caller is holding a recording someone
|
|
1091
|
+
// just made, so returning early means they lose it and record again.
|
|
1092
|
+
// Only a genuine first-time download can outlast the ceiling, and that is
|
|
1093
|
+
// the one case worth reporting as "come back in a moment".
|
|
1056
1094
|
if (!diarizeEngine.isReady()) {
|
|
1057
|
-
diarizeEngine.download({ onLog: (m) => console.log(m) });
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1095
|
+
const load = diarizeEngine.download({ onLog: (m) => console.log(m) });
|
|
1096
|
+
const timedOut = Symbol('timeout');
|
|
1097
|
+
const raced = await Promise.race([
|
|
1098
|
+
load.then(() => null).catch((e) => e),
|
|
1099
|
+
new Promise((r) => setTimeout(() => r(timedOut), EMBEDDER_WAIT_MS)),
|
|
1100
|
+
]);
|
|
1101
|
+
if (raced === timedOut || !diarizeEngine.isReady()) {
|
|
1102
|
+
return sendJson(res, 503, {
|
|
1103
|
+
error: {
|
|
1104
|
+
message: raced === timedOut
|
|
1105
|
+
? 'the speaker model is still downloading (~100 MB) — your recording was kept, press Save again shortly'
|
|
1106
|
+
: `the speaker model failed to load: ${diarizeEngine.health().error || 'unknown error'}`,
|
|
1107
|
+
type: 'embedder_not_ready',
|
|
1108
|
+
},
|
|
1109
|
+
progress: diarizeEngine.progress(),
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1062
1112
|
}
|
|
1063
1113
|
const vec = await diarizeEngine.embed(Float32Array.from(pcm));
|
|
1064
1114
|
const saved = ttsVoices.saveVoice({ name, vec });
|
|
@@ -1071,7 +1121,16 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1071
1121
|
if (req.method === 'DELETE') {
|
|
1072
1122
|
const id = url.searchParams.get('id') || '';
|
|
1073
1123
|
// Deleting someone's voice print is not a soft delete — the file is gone.
|
|
1074
|
-
|
|
1124
|
+
const deleted = ttsVoices.deleteVoice(id);
|
|
1125
|
+
// …and the config must not keep NAMING it. A stored voice that no longer
|
|
1126
|
+
// exists makes every later request fail with "no such saved voice", which
|
|
1127
|
+
// is a confusing way to be told "you deleted that one".
|
|
1128
|
+
if (deleted && cfg.tts?.voice === `custom:${id}`) {
|
|
1129
|
+
const left = ttsVoices.listVoices();
|
|
1130
|
+
cfg.tts.voice = left.length ? `custom:${left[0].id}` : DEFAULT_TTS_VOICE;
|
|
1131
|
+
try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
|
|
1132
|
+
}
|
|
1133
|
+
return sendJson(res, 200, { deleted, voice: cfg.tts?.voice });
|
|
1075
1134
|
}
|
|
1076
1135
|
}
|
|
1077
1136
|
|
|
@@ -1098,22 +1157,12 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1098
1157
|
// not a Kokoro one), so the local catalog check would reject every valid id.
|
|
1099
1158
|
const rawVoice = body && typeof body.voice === 'string' && body.voice.trim() ? body.voice.trim() : null;
|
|
1100
1159
|
const voice = rawVoice || (dest ? dest.voice : (cfg.tts?.voice || DEFAULT_TTS_VOICE));
|
|
1101
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
const rec = ttsVoices.getVoice(customId);
|
|
1107
|
-
if (!rec) return sendJson(res, 404, { error: { message: 'no such saved voice', type: 'bad_voice' } });
|
|
1108
|
-
if (!ttsEngine.supportsCustomVoices() && ttsEngine.isReady()) {
|
|
1109
|
-
return sendJson(res, 409, { error: { message: `the active model (${ttsEngine.arch()}) cannot use a recorded voice — switch to SpeechT5`, type: 'voice_unsupported' } });
|
|
1110
|
-
}
|
|
1111
|
-
speakerEmbedding = rec.vec;
|
|
1160
|
+
// A REMOTE destination has its own voice namespace, so it is validated here;
|
|
1161
|
+
// local voices cannot be resolved until the model is loaded, because which
|
|
1162
|
+
// KIND of voice is valid depends on the architecture. See below.
|
|
1163
|
+
if (dest && !isValidRemoteVoice(voice)) {
|
|
1164
|
+
return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
|
|
1112
1165
|
}
|
|
1113
|
-
const voiceOk = customId ? true
|
|
1114
|
-
: dest ? isValidRemoteVoice(voice)
|
|
1115
|
-
: (isKnownVoice(voice) && isValidVoiceId(voice));
|
|
1116
|
-
if (!voiceOk) return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
|
|
1117
1166
|
// We synthesize WAV only. Say so rather than returning WAV bytes under an mp3
|
|
1118
1167
|
// content-type — a client that trusts the header would play noise.
|
|
1119
1168
|
const fmt = body && typeof body.response_format === 'string' ? body.response_format.toLowerCase() : 'wav';
|
|
@@ -1148,7 +1197,52 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1148
1197
|
dtype: cfg.tts?.dtype || 'auto',
|
|
1149
1198
|
});
|
|
1150
1199
|
if (!ok) return sendJson(res, 503, { error: { message: ttsEngine.health().error || 'tts model not ready', type: 'tts_unavailable' } });
|
|
1151
|
-
|
|
1200
|
+
// Voices are resolved AFTER the model is up, because what counts as a valid
|
|
1201
|
+
// voice is a property of the architecture: Kokoro takes a style-bank name,
|
|
1202
|
+
// SpeechT5 takes a recorded embedding, VITS takes neither. Resolving first
|
|
1203
|
+
// meant a `custom:` voice could reach a freshly-loaded Kokoro and fail deep
|
|
1204
|
+
// in the engine with "invalid voice id".
|
|
1205
|
+
let useVoice = voice;
|
|
1206
|
+
let speakerEmbedding = null;
|
|
1207
|
+
let customId = ttsVoices.parseCustomVoice(voice);
|
|
1208
|
+
|
|
1209
|
+
if (ttsEngine.supportsCustomVoices()) {
|
|
1210
|
+
// This model speaks ONLY in a recorded voice. If the configured one names
|
|
1211
|
+
// a built-in (switching model does not rewrite `voice`) or points at a
|
|
1212
|
+
// voice since deleted, fall back to the most recent saved one — the
|
|
1213
|
+
// caller asked to be spoken to, not for that exact voice. A voice named
|
|
1214
|
+
// EXPLICITLY in the request still fails loudly.
|
|
1215
|
+
let rec = customId ? ttsVoices.getVoice(customId) : null;
|
|
1216
|
+
if (!rec && !rawVoice) {
|
|
1217
|
+
const saved = ttsVoices.listVoices();
|
|
1218
|
+
if (saved.length) { customId = saved[0].id; rec = ttsVoices.getVoice(customId); }
|
|
1219
|
+
}
|
|
1220
|
+
if (!rec) {
|
|
1221
|
+
return sendJson(res, customId ? 404 : 400, {
|
|
1222
|
+
error: {
|
|
1223
|
+
message: customId ? 'no such saved voice' : 'this model speaks in a voice you record — add one in Settings → Text-to-speech',
|
|
1224
|
+
type: 'bad_voice',
|
|
1225
|
+
},
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
speakerEmbedding = rec.vec;
|
|
1229
|
+
useVoice = `custom:${customId}`;
|
|
1230
|
+
} else if (customId) {
|
|
1231
|
+
// Explicitly asked for a recorded voice this model cannot use — say so.
|
|
1232
|
+
// Inherited from config, though, it is just a stale setting, and refusing
|
|
1233
|
+
// to speak at all is a worse answer than speaking in the default voice.
|
|
1234
|
+
if (rawVoice) {
|
|
1235
|
+
return sendJson(res, 409, {
|
|
1236
|
+
error: { message: `the active model (${ttsEngine.arch()}) cannot use a recorded voice — switch to SpeechT5`, type: 'voice_unsupported' },
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
customId = null;
|
|
1240
|
+
useVoice = DEFAULT_TTS_VOICE;
|
|
1241
|
+
} else if (ttsEngine.supportsVoices() && !(isKnownVoice(useVoice) && isValidVoiceId(useVoice))) {
|
|
1242
|
+
return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const pcm = await ttsEngine.synth(text, { voice: useVoice, speed, speakerEmbedding });
|
|
1152
1246
|
// The ACTIVE model's rate, not the constant: a VITS/MMS model emits 16 kHz
|
|
1153
1247
|
// and writing it into a 24 kHz header plays it fast and chipmunked.
|
|
1154
1248
|
const rate = ttsEngine.sampleRate();
|
|
@@ -1158,6 +1252,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1158
1252
|
'Content-Length': String(out.length),
|
|
1159
1253
|
'Cache-Control': 'no-store',
|
|
1160
1254
|
'X-Tts-Sample-Rate': String(rate),
|
|
1255
|
+
...(customId ? { 'X-Tts-Voice': `custom:${customId}` } : {}),
|
|
1161
1256
|
});
|
|
1162
1257
|
return res.end(out);
|
|
1163
1258
|
} catch (e) {
|
|
@@ -1289,6 +1384,20 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1289
1384
|
return sendJson(res, 200, await aggregateModelsAsync(cfg));
|
|
1290
1385
|
}
|
|
1291
1386
|
|
|
1387
|
+
// Anything under a LOCAL namespace that reached here matched no route, which
|
|
1388
|
+
// almost always means the caller is newer than this gateway. Falling through to
|
|
1389
|
+
// the model proxy makes that arrive as "upstream fetch failed", pointing the
|
|
1390
|
+
// user at their model provider for a feature their gateway simply does not
|
|
1391
|
+
// have yet — so these 404 with the actual reason instead.
|
|
1392
|
+
if (LOCAL_NAMESPACES.some((ns) => pathname === ns || pathname.startsWith(`${ns}/`))) {
|
|
1393
|
+
return sendJson(res, 404, {
|
|
1394
|
+
error: {
|
|
1395
|
+
message: `this gateway (${VERSION}) has no ${pathname} — update it to use this feature`,
|
|
1396
|
+
type: 'unknown_endpoint',
|
|
1397
|
+
},
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1292
1401
|
const r = route(pathname, req.headers, cfg);
|
|
1293
1402
|
let raw;
|
|
1294
1403
|
try {
|