@chatpanel/gateway 0.6.54 → 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/server.js +83 -29
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/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.
|
|
@@ -1030,6 +1030,26 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1030
1030
|
if (!cfg.tts) cfg.tts = { enabled: true, model: DEFAULT_TTS_MODEL, voice: DEFAULT_TTS_VOICE, allowDownload: true };
|
|
1031
1031
|
if (id) cfg.tts.model = id;
|
|
1032
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
|
+
}
|
|
1033
1053
|
if (dtype) cfg.tts.dtype = dtype === 'auto' ? null : dtype;
|
|
1034
1054
|
try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
|
|
1035
1055
|
if (id) ttsEngine.setModel(id, { onLog: (m) => console.log(m), dtype: dtype || cfg.tts.dtype || 'auto' });
|
|
@@ -1101,7 +1121,16 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1101
1121
|
if (req.method === 'DELETE') {
|
|
1102
1122
|
const id = url.searchParams.get('id') || '';
|
|
1103
1123
|
// Deleting someone's voice print is not a soft delete — the file is gone.
|
|
1104
|
-
|
|
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 });
|
|
1105
1134
|
}
|
|
1106
1135
|
}
|
|
1107
1136
|
|
|
@@ -1128,32 +1157,12 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1128
1157
|
// not a Kokoro one), so the local catalog check would reject every valid id.
|
|
1129
1158
|
const rawVoice = body && typeof body.voice === 'string' && body.voice.trim() ? body.voice.trim() : null;
|
|
1130
1159
|
const voice = rawVoice || (dest ? dest.voice : (cfg.tts?.voice || DEFAULT_TTS_VOICE));
|
|
1131
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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;
|
|
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' } });
|
|
1143
1165
|
}
|
|
1144
|
-
let speakerEmbedding = null;
|
|
1145
|
-
if (customId) {
|
|
1146
|
-
const rec = ttsVoices.getVoice(customId);
|
|
1147
|
-
if (!rec) return sendJson(res, 404, { error: { message: 'no such saved voice', type: 'bad_voice' } });
|
|
1148
|
-
if (!ttsEngine.supportsCustomVoices() && ttsEngine.isReady()) {
|
|
1149
|
-
return sendJson(res, 409, { error: { message: `the active model (${ttsEngine.arch()}) cannot use a recorded voice — switch to SpeechT5`, type: 'voice_unsupported' } });
|
|
1150
|
-
}
|
|
1151
|
-
speakerEmbedding = rec.vec;
|
|
1152
|
-
}
|
|
1153
|
-
const voiceOk = customId ? true
|
|
1154
|
-
: dest ? isValidRemoteVoice(voice)
|
|
1155
|
-
: (isKnownVoice(voice) && isValidVoiceId(voice));
|
|
1156
|
-
if (!voiceOk) return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
|
|
1157
1166
|
// We synthesize WAV only. Say so rather than returning WAV bytes under an mp3
|
|
1158
1167
|
// content-type — a client that trusts the header would play noise.
|
|
1159
1168
|
const fmt = body && typeof body.response_format === 'string' ? body.response_format.toLowerCase() : 'wav';
|
|
@@ -1188,7 +1197,52 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1188
1197
|
dtype: cfg.tts?.dtype || 'auto',
|
|
1189
1198
|
});
|
|
1190
1199
|
if (!ok) return sendJson(res, 503, { error: { message: ttsEngine.health().error || 'tts model not ready', type: 'tts_unavailable' } });
|
|
1191
|
-
|
|
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 });
|
|
1192
1246
|
// The ACTIVE model's rate, not the constant: a VITS/MMS model emits 16 kHz
|
|
1193
1247
|
// and writing it into a 24 kHz header plays it fast and chipmunked.
|
|
1194
1248
|
const rate = ttsEngine.sampleRate();
|