@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.53",
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
- const DEFAULTS = {
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
 
@@ -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.53';
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
- voices: ttsEngine.arch() === 'vits' ? [] : TTS_VOICES.map((v) => ({ ...v, installed: ttsEngine.voiceOnDisk(v.id, active) })),
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. If it is
1054
- // not resident yet, start it and say so a ~100 MB download is not
1055
- // something to do silently while the user waits on a spinner.
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
- return sendJson(res, 503, {
1059
- error: { message: 'the speaker model is downloading (~100 MB) — try again in a moment', type: 'embedder_not_ready' },
1060
- progress: diarizeEngine.progress(),
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
- return sendJson(res, 200, { deleted: ttsVoices.deleteVoice(id) });
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
- // `custom:<id>` names a saved voice. It is resolved to an embedding here so
1102
- // the engine never has to know where voices are stored.
1103
- const customId = dest ? null : ttsVoices.parseCustomVoice(voice);
1104
- let speakerEmbedding = null;
1105
- if (customId) {
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
- const pcm = await ttsEngine.synth(text, { voice, speed, speakerEmbedding });
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 {