@chatpanel/gateway 0.6.50 → 0.6.51

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.50",
3
+ "version": "0.6.51",
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
@@ -111,9 +111,23 @@ const DEFAULTS = {
111
111
  // as stt: the model downloads on FIRST synthesis, never on gateway boot.
112
112
  tts: {
113
113
  enabled: true,
114
+ // 'local' = Kokoro in this process, nothing leaves the machine (the default).
115
+ // 'openai' | 'elevenlabs' = a remote voice. Auth is passed through from the
116
+ // client as everywhere else here; no key is stored. See src/tts-remote.js for
117
+ // why remote synthesis redacts by default and what that costs.
118
+ provider: 'local',
114
119
  model: 'onnx-community/Kokoro-82M-v1.0-ONNX',
115
120
  voice: 'af_heart',
116
121
  allowDownload: true,
122
+ remote: {
123
+ baseUrl: '', // '' = the provider's own default endpoint
124
+ model: '',
125
+ voice: '',
126
+ // Redact before the text leaves this machine. Audio cannot be un-redacted,
127
+ // so this genuinely means the voice says "PERSON_1" — set false only if you
128
+ // accept sending real values to the vendor.
129
+ redact: true,
130
+ },
117
131
  },
118
132
 
119
133
  // Log one line per request (method, tokens redacted) without any raw values.
package/src/server.js CHANGED
@@ -41,6 +41,7 @@ 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
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';
44
+ import { ttsDestination, synthesizeRemote, isValidRemoteVoice } from './tts-remote.js';
44
45
  import { resolvePro, checkQuota, consume, usage } from './freegate.js';
45
46
  import { publicConfig, applyConfigPatch, applyNerModelSelection, persistConfig, configPath } from './configstore.js';
46
47
  import { resolveDestination, aggregateModelsAsync, listDestinations } from './router.js';
@@ -51,7 +52,7 @@ import * as openai from './openai.js';
51
52
  import * as responses from './responses.js';
52
53
  import * as anthropic from './anthropic.js';
53
54
 
54
- export const VERSION = '0.6.50';
55
+ export const VERSION = '0.6.51';
55
56
 
56
57
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
57
58
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -1025,14 +1026,41 @@ export function createGateway(cfg = loadConfig()) {
1025
1026
  const text = body && typeof (body.input ?? body.text) === 'string' ? String(body.input ?? body.text).trim() : '';
1026
1027
  if (!text) return sendJson(res, 400, { error: { message: 'text is required', type: 'bad_request' } });
1027
1028
  if (text.length > MAX_TTS_CHARS) return sendJson(res, 413, { error: { message: `text too long (max ${MAX_TTS_CHARS} chars)`, type: 'too_long' } });
1028
- const voice = body && typeof body.voice === 'string' && body.voice.trim() ? body.voice.trim() : (cfg.tts?.voice || DEFAULT_TTS_VOICE);
1029
- if (!(isKnownVoice(voice) && isValidVoiceId(voice))) return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
1030
1029
  const speed = Number.isFinite(body.speed) ? Math.min(2, Math.max(0.5, body.speed)) : 1;
1030
+ const dest = ttsDestination(cfg);
1031
+ // A remote destination has its own voice namespace (an ElevenLabs voice id is
1032
+ // not a Kokoro one), so the local catalog check would reject every valid id.
1033
+ const rawVoice = body && typeof body.voice === 'string' && body.voice.trim() ? body.voice.trim() : null;
1034
+ const voice = rawVoice || (dest ? dest.voice : (cfg.tts?.voice || DEFAULT_TTS_VOICE));
1035
+ const voiceOk = dest ? isValidRemoteVoice(voice) : (isKnownVoice(voice) && isValidVoiceId(voice));
1036
+ if (!voiceOk) return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
1031
1037
  // We synthesize WAV only. Say so rather than returning WAV bytes under an mp3
1032
1038
  // content-type — a client that trusts the header would play noise.
1033
1039
  const fmt = body && typeof body.response_format === 'string' ? body.response_format.toLowerCase() : 'wav';
1034
1040
  if (fmt !== 'wav' && fmt !== 'pcm') return sendJson(res, 400, { error: { message: `unsupported response_format "${fmt}" — this gateway synthesizes wav`, type: 'bad_format' } });
1035
1041
  try {
1042
+ // Remote destination: the caller's own auth goes upstream, the text is
1043
+ // redacted first unless this destination explicitly opted out, and the
1044
+ // vendor's own audio format is passed straight through rather than
1045
+ // re-wrapped — we did not synthesize it and must not claim its container.
1046
+ if (dest) {
1047
+ const { audio, contentType, redacted } = await synthesizeRemote({
1048
+ dest, text, voice, speed,
1049
+ auth: req.headers.authorization || req.headers['xi-api-key'] || '',
1050
+ redaction: cfg.redaction,
1051
+ isPro: await resolvePro(cfg.pro?.entitlementToken),
1052
+ });
1053
+ res.writeHead(200, {
1054
+ 'Content-Type': contentType,
1055
+ 'Content-Length': String(audio.length),
1056
+ 'Cache-Control': 'no-store',
1057
+ // Say what actually left. A caller that asked for privacy can verify it,
1058
+ // and one that turned it off can see that it is off.
1059
+ 'X-Tts-Provider': dest.kind,
1060
+ 'X-Tts-Redacted': String(redacted),
1061
+ });
1062
+ return res.end(audio);
1063
+ }
1036
1064
  const ok = await ttsEngine.ready({
1037
1065
  onLog: (m) => console.log(m),
1038
1066
  allowDownload: cfg.tts?.allowDownload !== false,
@@ -0,0 +1,109 @@
1
+ // Remote text-to-speech destinations — ElevenLabs, OpenAI, or anything that speaks
2
+ // the OpenAI /audio/speech shape (Groq, a local server, an OpenAI-compatible proxy).
3
+ //
4
+ // TWO RULES, and they are the whole reason this is a separate module from
5
+ // tts-engine.js rather than a branch inside it.
6
+ //
7
+ // 1. NO KEY LIVES HERE. config.js says it outright — "the gateway forwards the
8
+ // CLIENT's own auth header upstream (it stores no provider keys)" — and a TTS
9
+ // destination is not the place to break that. The caller sends its own
10
+ // Authorization (or xi-api-key) and we pass it through. Config holds the ROUTE.
11
+ //
12
+ // 2. THE PRIVACY DECISION INVERTS. Local synthesis speaks restored text because the
13
+ // words never leave the machine; there is nothing to protect them from. The
14
+ // moment the destination is remote, the text IS leaving, to a vendor, and every
15
+ // other egress in this gateway is redacted first. So remote synthesis redacts by
16
+ // default.
17
+ //
18
+ // That trade is real and cannot be papered over: audio cannot be un-redacted the
19
+ // way a text completion can, so a redacted remote voice literally says
20
+ // "PERSON_1". The honest options are (a) placeholders spoken aloud, or (b) real
21
+ // PII sent to a vendor — and the user picks, per destination, with `redact`. The
22
+ // default is the safe one and the choice is logged via onEgress, so sending real
23
+ // names to ElevenLabs is something someone DID, not something that happened.
24
+
25
+ import { secureFetch } from './secure-fetch.js';
26
+ import { redactSegments, segment } from './redact.js';
27
+
28
+ export const TTS_PROVIDERS = ['local', 'openai', 'elevenlabs'];
29
+
30
+ export function isValidTtsProvider(p) {
31
+ return TTS_PROVIDERS.includes(String(p || ''));
32
+ }
33
+
34
+ /** The configured destination, or null when synthesis is local (the default). */
35
+ export function ttsDestination(cfg = {}) {
36
+ const t = cfg.tts || {};
37
+ const kind = t.provider || 'local';
38
+ if (kind === 'local' || !isValidTtsProvider(kind)) return null;
39
+ const r = t.remote || {};
40
+ return {
41
+ kind,
42
+ baseUrl: String(r.baseUrl || (kind === 'elevenlabs' ? 'https://api.elevenlabs.io/v1' : 'https://api.openai.com/v1')).replace(/\/+$/, ''),
43
+ model: r.model || (kind === 'elevenlabs' ? 'eleven_multilingual_v2' : 'tts-1'),
44
+ voice: r.voice || (kind === 'elevenlabs' ? '21m00Tcm4TlvDq8ikWAM' : 'alloy'),
45
+ // Undefined means "not set", which must read as ON — an absent flag is never
46
+ // permission to send someone's name to a vendor.
47
+ redact: r.redact !== false,
48
+ };
49
+ }
50
+
51
+ // A remote voice id lands in a URL PATH for ElevenLabs, so it is shape-checked
52
+ // rather than trusted: no slashes, no traversal, no query smuggling.
53
+ export function isValidRemoteVoice(v) {
54
+ return /^[A-Za-z0-9_-]{1,64}$/.test(String(v || ''));
55
+ }
56
+
57
+ /**
58
+ * Synthesize remotely. Returns { audio: Buffer, contentType, redacted } — `redacted`
59
+ * is the count of values replaced, so the caller can report what actually left.
60
+ *
61
+ * @param {{dest: any, text: string, voice?: string, speed?: number, auth?: string,
62
+ * redaction?: any, isPro?: boolean, onEgress?: Function, fetchImpl?: Function}} opts
63
+ */
64
+ export async function synthesizeRemote({ dest, text, voice, speed = 1, auth, redaction, isPro = true, onEgress = null, fetchImpl = null } = {}) {
65
+ if (!dest) throw new Error('no remote TTS destination configured');
66
+ const pick = voice || dest.voice;
67
+ if (!isValidRemoteVoice(pick)) throw new Error(`invalid remote voice id: ${pick}`);
68
+
69
+ // Redact BEFORE anything is built, so there is no path where the raw string
70
+ // reaches a request body by accident.
71
+ let out = String(text);
72
+ let redacted = 0;
73
+ if (dest.redact && redaction) {
74
+ const box = { text: out };
75
+ const r = await redactSegments([segment(() => box.text, (v) => { box.text = v; })], redaction, { isPro, onEgress });
76
+ out = box.text;
77
+ redacted = r?.count || 0;
78
+ }
79
+
80
+ const fetcher = fetchImpl || secureFetch;
81
+ const headers = { 'Content-Type': 'application/json' };
82
+ let url, body;
83
+ if (dest.kind === 'elevenlabs') {
84
+ url = `${dest.baseUrl}/text-to-speech/${encodeURIComponent(pick)}`;
85
+ body = { text: out, model_id: dest.model };
86
+ // ElevenLabs uses its own header. Accept either form from the caller so a
87
+ // generic OpenAI client can drive it too.
88
+ const key = stripBearer(auth);
89
+ if (key) headers['xi-api-key'] = key;
90
+ } else {
91
+ url = `${dest.baseUrl}/audio/speech`;
92
+ body = { model: dest.model, input: out, voice: pick, response_format: 'wav', speed };
93
+ if (auth) headers.Authorization = auth.startsWith('Bearer ') ? auth : `Bearer ${auth}`;
94
+ }
95
+
96
+ const res = await fetcher(url, { method: 'POST', headers, body: JSON.stringify(body) });
97
+ if (!res.ok) {
98
+ let detail = `HTTP ${res.status}`;
99
+ try { detail = (await res.text()).slice(0, 300) || detail; } catch { /* no body */ }
100
+ throw new Error(`remote tts failed: ${detail}`);
101
+ }
102
+ const audio = Buffer.from(await res.arrayBuffer());
103
+ return { audio, contentType: res.headers.get('content-type') || 'audio/mpeg', redacted };
104
+ }
105
+
106
+ function stripBearer(a) {
107
+ const s = String(a || '').trim();
108
+ return s.toLowerCase().startsWith('bearer ') ? s.slice(7).trim() : s;
109
+ }