v3: rewrite — structured logging, config hot-reload, request tracing

- Binary-safe multipart parser (Buffer-based)
- Structured JSON logging with daily rotation (7-day, 10MB)
- Config hot-reload (5s TTL, mtime-based)
- Request tracing: X-Request-ID, crypto.randomUUID()
- Enhanced /health: uptime, voice config, stats, RouterAI probe
- Graceful shutdown: SIGTERM/SIGINT with drain
- Timeout 30s on all https.request calls
- Body size limit 50MB
- Replace url.parse() with WHATWG new URL()
- Sanitized error responses
- Modular handler architecture

Reviewed by: kimi-k2.7-code, qwen3.6-plus, deepseek-v4-flash
Spec by: Roko (architect)
Rewrite by: kimi-k2.7-code via OpenCode
This commit is contained in:
Roko
2026-07-08 13:24:35 +07:00
parent c68ee43232
commit 98eb5c6257
2 changed files with 508 additions and 216 deletions
+29
View File
@@ -0,0 +1,29 @@
# Changelog
## v3.0.0 (2026-07-08)
### Added
- Structured JSON logging with daily rotation (7-day retention, 10MB limit)
- Config hot-reload — reads openclaw.json every 5s, no restart needed
- Request tracing via `crypto.randomUUID()` and `X-Request-ID` header
- Health endpoint: uptime, current voice config, RouterAI connectivity probe, request stats
- Graceful shutdown on SIGTERM/SIGINT with in-flight request draining
- Upstream request timeout (30s) on all RouterAI calls
- Body size limit (50MB) to prevent DoS
### Fixed
- **CRITICAL:** Binary-safe multipart parser — no longer corrupts non-ASCII audio bytes
- Replace deprecated `url.parse()` with WHATWG `new URL()` API
- `response_format` removal now logged as WARN (was silently mutating)
- Whisper stderr captured and logged (was silently discarded)
- Error responses sanitized — no raw upstream errors leaked to client
- Root path (`/`) redirects to `/health` (was shadowing health endpoint)
### Changed
- Refactored into modular handler functions (`handleModels`, `handleSpeech`, `handleTranscriptions`, `handleHealth`)
- Config now has safe defaults; survives missing/broken `openclaw.json`
- `/v1/models` returns the configured TTS model from config, not hardcoded
- All file operations in `~/openclaw/logs/` directory
### Removed
- v2 monolithic request handler
+479 -216
View File
@@ -1,272 +1,535 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* RouterAI Voice Proxy v2 — теперь с локальным STT через whisper.cpp * RouterAI Voice Proxy v3
* *
* STT (multipart) → 1) пытается routerai, 2) фолбэк на whisper.cpp * Bridges OpenClaw Gateway (OpenAI-compatible API) to RouterAI.
* TTS (JSON) → прозрачный прокси на routerai * Handles TTS proxying and STT (RouterAI first, whisper.cpp fallback).
*
* @version 3.0.0
*/ */
const http = require('http'); const http = require('http');
const url = require('url'); const https = require('https');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const https = require('https');
// Конфиг
const WHISPER_BIN = '/tmp/whisper.cpp/main'; const WHISPER_BIN = '/tmp/whisper.cpp/main';
const WHISPER_MODEL = '/tmp/whisper.cpp/models/ggml-tiny.bin'; const WHISPER_MODEL = '/tmp/whisper.cpp/models/ggml-tiny.bin';
const TMP_DIR = '/tmp/whisper_uploads'; const TMP_DIR = '/tmp/whisper_uploads';
const PORT = parseInt(process.env.PORT || '9998'); const PORT = parseInt(process.env.PORT || '9998', 10);
const MAX_BODY = 50 * 1024 * 1024;
const configPath = path.join(process.env.HOME || '/home/openclaw', '.openclaw', 'openclaw.json'); const UPSTREAM_TIMEOUT = 30000;
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); const HOME = process.env.HOME || '/home/openclaw';
const API_KEY = cfg.models?.providers?.['routerai-ru']?.apiKey || process.env.ROUTERAI_API_KEY; const CONFIG_PATH = path.join(HOME, '.openclaw', 'openclaw.json');
const ROUTERAI_BASE = 'https://routerai.ru/api/v1'; const LOG_DIR = path.join(HOME, '.openclaw', 'logs');
const LOG_FILE = path.join(LOG_DIR, 'voice-proxy.log');
fs.mkdirSync(LOG_DIR, { recursive: true });
fs.mkdirSync(TMP_DIR, { recursive: true }); fs.mkdirSync(TMP_DIR, { recursive: true });
// ============ Утилиты ============ const stats = { tts_total: 0, stt_total: 0, errors_total: 0, startTime: Date.now() };
let configCache = null;
let configMtime = 0;
let configLastCheck = 0;
const CONFIG_TTL = 5000;
let activeRequests = 0;
let shuttingDown = false;
let lastProbe = { ok: false, time: 0 };
/** @returns {object} Cached or freshly loaded configuration. */
function loadConfig() {
const now = Date.now();
if (now - configLastCheck < CONFIG_TTL && configCache) return configCache;
configLastCheck = now;
try {
const stat = fs.statSync(CONFIG_PATH);
if (stat.mtimeMs === configMtime && configCache) return configCache;
configMtime = stat.mtimeMs;
const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
const tts = data.messages?.tts?.providers?.openai || {};
configCache = {
model: tts.model || 'google/gemini-3.1-flash-tts-preview',
voice: tts.speakerVoice || 'Kore',
responseFormat: tts.responseFormat || 'mp3',
apiKey: process.env.ROUTERAI_API_KEY || data.models?.providers?.['routerai-ru']?.apiKey || '',
baseUrl: process.env.ROUTERAI_BASE_URL || 'https://routerai.ru/api/v1'
};
} catch (e) {
if (!configCache) {
configCache = {
model: 'google/gemini-3.1-flash-tts-preview',
voice: 'Kore',
responseFormat: 'mp3',
apiKey: process.env.ROUTERAI_API_KEY || '',
baseUrl: process.env.ROUTERAI_BASE_URL || 'https://routerai.ru/api/v1'
};
}
log('WARN', { message: 'Config read failed, using defaults', error: e.message });
}
return configCache;
}
/** Rotate log file when it exceeds 10MB or crosses a day boundary. */
function rotateLogIfNeeded() {
try {
if (!fs.existsSync(LOG_FILE)) return;
const stat = fs.statSync(LOG_FILE);
const now = new Date();
const mtime = new Date(stat.mtime);
const sameDay = now.toISOString().slice(0, 10) === mtime.toISOString().slice(0, 10);
if (stat.size > 10 * 1024 * 1024 || !sameDay) {
const suffix = mtime.toISOString().slice(0, 10);
let dest = path.join(LOG_DIR, `voice-proxy-${suffix}.log`);
let i = 1;
while (fs.existsSync(dest)) dest = path.join(LOG_DIR, `voice-proxy-${suffix}-${i++}.log`);
fs.renameSync(LOG_FILE, dest);
cleanupOldLogs();
}
} catch (e) {
console.error('Log rotation error:', e.message);
}
}
/** Delete log files older than 7 days. */
function cleanupOldLogs() {
try {
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
for (const f of fs.readdirSync(LOG_DIR)) {
if (!f.endsWith('.log') || f === 'voice-proxy.log') continue;
const p = path.join(LOG_DIR, f);
if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p);
}
} catch (e) {
console.error('Log cleanup error:', e.message);
}
}
/** Append a structured JSON log line. */
function log(level, data) {
try {
rotateLogIfNeeded();
fs.appendFileSync(LOG_FILE, JSON.stringify({ ts: new Date().toISOString(), level, ...data }) + '\n');
} catch (e) {
console.error('Logger error:', e.message);
}
}
/** Send a sanitized error response with request tracing. */
function errorResponse(res, status, requestId, clientMessage) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: clientMessage || 'Internal server error', request_id: requestId }));
}
/** Collect the request body, rejecting payloads above MAX_BODY. */
function collectBody(req) { function collectBody(req) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks = []; const chunks = [];
req.on('data', chunk => chunks.push(chunk)); let size = 0;
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY) {
req.destroy();
reject(new Error('Request body too large'));
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks))); req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject); req.on('error', reject);
}); });
} }
/**
* Parse multipart/form-data directly from a raw Buffer.
* Avoids string conversion that corrupts non-ASCII audio bytes.
*
* @param {Buffer} body
* @param {string} boundary
* @returns {Array<{name, filename, contentType, data: Buffer}>}
*/
function parseMultipart(body, boundary) { function parseMultipart(body, boundary) {
const sep = Buffer.from(`--${boundary}`);
const crlf = Buffer.from('\r\n');
const crlfcrlf = Buffer.from('\r\n\r\n');
const parts = []; const parts = [];
const parts_raw = body.split(`--${boundary}`); let pos = 0;
for (const part of parts_raw) { while (true) {
if (!part || part.trim() === '' || part.trim() === '--') continue; let start = body.indexOf(sep, pos);
const headerEnd = part.indexOf('\r\n\r\n'); if (start === -1) break;
if (headerEnd === -1) continue; start += sep.length;
const headersRaw = part.substring(0, headerEnd); if (start + 2 <= body.length && body[start] === 45 && body[start + 1] === 45) break;
let content = part.substring(headerEnd + 4); if (start + 1 < body.length && body[start] === 13 && body[start + 1] === 10) start += 2;
if (content.endsWith('\r\n')) content = content.slice(0, -2); const headerEnd = body.indexOf(crlfcrlf, start);
const contentDisposition = headersRaw.match(/Content-Disposition:\s*form-data;\s*(.+)/i); if (headerEnd === -1) break;
const contentType = headersRaw.match(/Content-Type:\s*(.+)/i); const headersRaw = body.toString('utf8', start, headerEnd);
const contentStart = headerEnd + 4;
const next = body.indexOf(Buffer.concat([crlf, sep]), contentStart);
let contentEnd = next !== -1 ? next : body.length;
if (contentEnd >= 2 && body[contentEnd - 2] === 13 && body[contentEnd - 1] === 10) contentEnd -= 2;
const params = {}; const params = {};
if (contentDisposition) { const cd = headersRaw.match(/Content-Disposition:\s*form-data;\s*(.+)/i);
for (const p of contentDisposition[1].split(';')) { if (cd) {
for (const p of cd[1].split(';')) {
const m = p.match(/\s*(\w+)\s*=\s*"([^"]+)"/); const m = p.match(/\s*(\w+)\s*=\s*"([^"]+)"/);
if (m) params[m[1]] = m[2]; if (m) params[m[1]] = m[2];
} }
} }
const ct = headersRaw.match(/Content-Type:\s*(.+)/i);
parts.push({ parts.push({
name: params.name, name: params.name,
filename: params.filename, filename: params.filename,
contentType: contentType ? contentType[1].trim() : null, contentType: ct ? ct[1].trim() : null,
data: Buffer.from(content, 'binary') data: body.subarray(contentStart, contentEnd)
}); });
if (next === -1) break;
pos = next + 2;
} }
return parts; return parts;
} }
// ============ Основной сервер ============ /** Probe RouterAI connectivity with a short timeout. */
async function probeRouterAI() {
const cfg = loadConfig();
const now = Date.now();
if (now - lastProbe.time < 5000) return lastProbe.ok;
const ok = await new Promise((resolve) => {
const urlObj = new URL(cfg.baseUrl);
const rq = https.request({
hostname: urlObj.hostname,
port: urlObj.port || 443,
path: urlObj.pathname,
method: 'GET',
headers: { 'Authorization': `Bearer ${cfg.apiKey}` }
}, (rp) => { rp.resume(); resolve(rp.statusCode < 500); });
rq.setTimeout(5000, () => { rq.destroy(); resolve(false); });
rq.on('error', () => resolve(false));
rq.end();
});
lastProbe = { ok, time: now };
return ok;
}
/** GET /v1/models — returns the configured TTS model. */
async function handleModels(req, res, requestId) {
const cfg = loadConfig();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
object: 'list',
data: [
{ id: 'whisper-1', object: 'model', created: 1710000000, owned_by: 'openai' },
{ id: 'gpt-4o-mini-transcribe', object: 'model', created: 1710000000, owned_by: 'openai' },
{ id: cfg.model, object: 'model', created: 1710000000, owned_by: 'google' }
]
}));
}
/** POST /v1/audio/speech — proxy TTS to RouterAI. */
async function handleSpeech(req, res, body, requestId) {
const cfg = loadConfig();
const start = Date.now();
stats.tts_total++;
let speechBody = body.toString('utf8');
let clientModel = null;
let clientVoice = null;
try {
const parsed = JSON.parse(speechBody);
clientModel = parsed.model;
clientVoice = parsed.voice;
if (parsed.response_format && ['mp3', 'opus', 'wav', 'pcm'].includes(parsed.response_format)) {
delete parsed.response_format;
log('WARN', {
requestId,
endpoint: '/v1/audio/speech',
message: 'Removed unsupported response_format from TTS request'
});
}
if (!parsed.model) parsed.model = cfg.model;
if (!parsed.voice) parsed.voice = cfg.voice;
speechBody = JSON.stringify(parsed);
} catch (e) {
// Pass through non-JSON bodies unchanged.
}
const bodyBuf = Buffer.from(speechBody, 'utf8');
const urlObj = new URL(`${cfg.baseUrl}/audio/speech`);
const proxyReq = https.request({
hostname: urlObj.hostname,
port: urlObj.port || 443,
path: urlObj.pathname,
method: 'POST',
headers: {
'Authorization': `Bearer ${cfg.apiKey}`,
'Content-Type': req.headers['content-type'] || 'application/json',
'Content-Length': bodyBuf.length,
'X-Request-ID': requestId
}
}, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
const latency = Date.now() - start;
if (proxyRes.statusCode >= 400) {
stats.errors_total++;
log('ERROR', {
requestId,
endpoint: '/v1/audio/speech',
model: clientModel || cfg.model,
voice: clientVoice || cfg.voice,
latency_ms: latency,
status: proxyRes.statusCode,
error: 'TTS upstream error'
});
} else {
log('INFO', {
requestId,
endpoint: '/v1/audio/speech',
model: clientModel || cfg.model,
voice: clientVoice || cfg.voice,
latency_ms: latency,
status: proxyRes.statusCode
});
}
});
proxyReq.setTimeout(UPSTREAM_TIMEOUT, () => proxyReq.destroy(new Error('Upstream timeout')));
proxyReq.on('error', (e) => {
stats.errors_total++;
log('ERROR', {
requestId,
endpoint: '/v1/audio/speech',
error: e.message,
latency_ms: Date.now() - start
});
errorResponse(res, 502, requestId, 'Upstream TTS error');
});
proxyReq.write(bodyBuf);
proxyReq.end();
}
/** POST /v1/audio/transcriptions — STT via RouterAI or whisper.cpp fallback. */
async function handleTranscriptions(req, res, body, requestId) {
const cfg = loadConfig();
const start = Date.now();
stats.stt_total++;
const isMultipart = req.headers['content-type']?.includes('multipart/form-data');
const boundary = req.headers['content-type']?.match(/boundary=([^;]+)/i)?.[1];
let model = 'whisper-1';
let audioData = null;
let audioFormat = 'wav';
if (isMultipart && boundary) {
const parts = parseMultipart(body, boundary);
for (const part of parts) {
if (part.name === 'model') model = part.data.toString('utf8').trim();
else if (part.name === 'file') {
audioData = part.data;
if (part.filename) {
audioFormat = path.extname(part.filename).slice(1).toLowerCase() || 'wav';
}
}
}
} else {
try {
const json = JSON.parse(body.toString('utf8'));
model = json.model || model;
if (json.input_audio?.data) {
audioData = Buffer.from(json.input_audio.data, 'base64');
audioFormat = json.input_audio.format || 'wav';
}
} catch (e) {}
}
if (!audioData) {
log('WARN', { requestId, endpoint: '/v1/audio/transcriptions', error: 'No audio data' });
errorResponse(res, 400, requestId, 'No audio data');
return;
}
let text = null;
let usedRouterAI = false;
try {
const payload = JSON.stringify({
model,
input_audio: { data: audioData.toString('base64'), format: audioFormat },
language: 'ru'
});
const urlObj = new URL(`${cfg.baseUrl}/audio/transcriptions`);
text = await new Promise((resolve, reject) => {
const rq = https.request({
hostname: urlObj.hostname,
port: urlObj.port || 443,
path: urlObj.pathname,
method: 'POST',
headers: {
'Authorization': `Bearer ${cfg.apiKey}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
'X-Request-ID': requestId
}
}, (rp) => {
const chunks = [];
rp.on('data', (c) => chunks.push(c));
rp.on('end', () => {
const data = Buffer.concat(chunks).toString('utf8');
try {
const j = JSON.parse(data);
if (j.text !== undefined) resolve(j.text);
else reject(new Error('No text in response'));
} catch (e) {
reject(new Error('Bad JSON response'));
}
});
});
rq.setTimeout(UPSTREAM_TIMEOUT, () => rq.destroy(new Error('Upstream timeout')));
rq.on('error', reject);
rq.write(payload);
rq.end();
});
usedRouterAI = true;
} catch (e) {
log('WARN', {
requestId,
endpoint: '/v1/audio/transcriptions',
error: `RouterAI STT failed: ${e.message}`
});
}
if (text === null) {
const whisperOk = fs.existsSync(WHISPER_BIN) && fs.existsSync(WHISPER_MODEL);
if (!whisperOk) {
stats.errors_total++;
log('ERROR', { requestId, endpoint: '/v1/audio/transcriptions', error: 'No STT backend available' });
errorResponse(res, 502, requestId, 'STT backend unavailable');
return;
}
const tmpFile = path.join(TMP_DIR, `audio_${Date.now()}.${audioFormat}`);
try {
fs.writeFileSync(tmpFile, audioData);
text = await new Promise((resolve, reject) => {
const proc = spawn(WHISPER_BIN, ['-m', WHISPER_MODEL, '-f', tmpFile, '-l', 'ru', '--no-timestamps']);
let output = '';
proc.stdout.on('data', (d) => output += d.toString());
proc.stderr.on('data', (d) => console.error(`whisper.cpp stderr: ${d.toString().trim()}`));
proc.on('close', () => {
const lines = output.trim().split('\n').filter((l) => l.trim());
resolve(lines.length ? lines[lines.length - 1].trim() : '');
});
proc.on('error', reject);
});
model = 'whisper-cpp-tiny';
} catch (e) {
stats.errors_total++;
log('ERROR', { requestId, endpoint: '/v1/audio/transcriptions', error: e.message });
errorResponse(res, 500, requestId, 'Local STT failed');
return;
} finally {
try { fs.unlinkSync(tmpFile); } catch (e) {}
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ text, model, object: 'text' }));
log('INFO', {
requestId,
endpoint: '/v1/audio/transcriptions',
model,
latency_ms: Date.now() - start,
text_preview: text.slice(0, 60),
status: 200,
provider: usedRouterAI ? 'routerai' : 'whisper.cpp'
});
}
/** GET /health — enhanced status with connectivity probe and stats. */
async function handleHealth(req, res, requestId) {
const cfg = loadConfig();
const whisperOk = fs.existsSync(WHISPER_BIN) && fs.existsSync(WHISPER_MODEL);
const routeraiOk = await probeRouterAI();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok',
service: 'routerai-voice-proxy',
version: '3.0.0',
uptime_seconds: Math.floor((Date.now() - stats.startTime) / 1000),
tts: { model: cfg.model, voice: cfg.voice, provider: 'routerai' },
stt: { routerai: routeraiOk, whisper_local: whisperOk },
stats: { tts_total: stats.tts_total, stt_total: stats.stt_total, errors_total: stats.errors_total }
}));
}
/** Gracefully stop accepting requests and exit after draining or timeout. */
function gracefulShutdown(signal) {
log('INFO', { message: `Received ${signal}, starting graceful shutdown`, activeRequests });
shuttingDown = true;
server.close(() => {
log('INFO', { message: 'Server closed gracefully' });
process.exit(0);
});
setTimeout(() => {
log('WARN', { message: 'Forced shutdown, active requests remaining', activeRequests });
process.exit(1);
}, UPSTREAM_TIMEOUT).unref();
}
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
const parsed = url.parse(req.url, true); if (shuttingDown) {
const pathname = parsed.pathname; res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Service shutting down' }));
return;
}
activeRequests++;
res.once('close', () => { activeRequests--; });
const requestId = req.headers['x-request-id'] || crypto.randomUUID();
res.setHeader('X-Request-ID', requestId);
res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', '*'); res.setHeader('Access-Control-Allow-Headers', '*');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
try { try {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const body = await collectBody(req); const body = await collectBody(req);
if (url.pathname === '/v1/models' && req.method === 'GET') {
// ===== GET /v1/models ===== await handleModels(req, res, requestId);
if (pathname === '/v1/models' && req.method === 'GET') { } else if (url.pathname === '/v1/audio/speech' && req.method === 'POST') {
res.writeHead(200, { 'Content-Type': 'application/json' }); await handleSpeech(req, res, body, requestId);
res.end(JSON.stringify({ } else if (url.pathname === '/v1/audio/transcriptions' && req.method === 'POST') {
object: 'list', await handleTranscriptions(req, res, body, requestId);
data: [ } else if (url.pathname === '/health' && req.method === 'GET') {
{ id: 'whisper-1', object: 'model', created: 1710000000, owned_by: 'openai' }, await handleHealth(req, res, requestId);
{ id: 'gpt-4o-mini-transcribe', object: 'model', created: 1710000000, owned_by: 'openai' }, } else if (url.pathname === '/' && req.method === 'GET') {
{ id: 'google/gemini-3.1-flash-tts-preview', object: 'model', created: 1710000000, owned_by: 'google' } res.writeHead(301, { Location: '/health' });
] res.end();
})); } else {
return; errorResponse(res, 404, requestId, 'Not found');
} }
// ===== POST /v1/audio/speech (TTS proxy) =====
if (pathname === '/v1/audio/speech' && req.method === 'POST') {
let speechBody = body.toString('utf8');
try {
const parsed = JSON.parse(speechBody);
// Удаляем response_format — routerai не поддерживает mp3/opus
if (['mp3','opus','wav','pcm'].includes(parsed.response_format)) {
delete parsed.response_format;
speechBody = JSON.stringify(parsed);
}
} catch(e) {}
const bodyBuf = Buffer.from(speechBody, 'utf8');
const urlObj = new URL(`${ROUTERAI_BASE}/audio/speech`);
const options = {
hostname: urlObj.hostname, port: 443, path: urlObj.pathname,
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': req.headers['content-type'] || 'application/json',
'Content-Length': bodyBuf.length
}
};
const proxyReq = https.request(options, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
});
proxyReq.on('error', e => { res.writeHead(502); res.end(JSON.stringify({ error: e.message })); });
proxyReq.write(bodyBuf);
proxyReq.end();
return;
}
// ===== POST /v1/audio/transcriptions (STT) =====
if (pathname === '/v1/audio/transcriptions' && req.method === 'POST') {
const isMultipart = req.headers['content-type']?.includes('multipart/form-data');
const boundary = req.headers['content-type']?.match(/boundary=([^;]+)/i)?.[1];
let model = 'whisper-1';
let audioData = null;
let audioFormat = 'wav';
if (isMultipart && boundary) {
const parts = parseMultipart(body.toString('binary'), boundary);
for (const part of parts) {
if (part.name === 'model') {
model = part.data.toString('utf8').trim();
} else if (part.name === 'file') {
audioData = part.data;
if (part.filename) {
audioFormat = path.extname(part.filename).slice(1).toLowerCase() || 'wav';
}
}
}
} else {
// JSON запрос (уже в формате routerai)
try {
const json = JSON.parse(body.toString('utf8'));
model = json.model || model;
if (json.input_audio?.data) {
audioData = Buffer.from(json.input_audio.data, 'base64');
audioFormat = json.input_audio.format || 'wav';
}
} catch(e) {}
}
if (!audioData) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'No audio data' }));
return;
}
// Пробуем сначала routerai
try {
const b64 = audioData.toString('base64');
const payload = JSON.stringify({
model: model,
input_audio: { data: b64, format: audioFormat },
language: 'ru'
});
const result = await new Promise((resolve, reject) => {
const urlObj = new URL(`${ROUTERAI_BASE}/audio/transcriptions`);
const opts = {
hostname: urlObj.hostname, port: 443, path: urlObj.pathname,
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const rq = https.request(opts, (rp) => {
let d = '';
rp.on('data', c => d += c);
rp.on('end', () => {
try {
const j = JSON.parse(d);
if (j.text !== undefined) resolve(j.text);
else reject(new Error('No text in response: ' + d.slice(0, 100)));
} catch(e) { reject(new Error('Bad JSON: ' + d.slice(0, 100))); }
});
});
rq.on('error', reject);
rq.write(payload);
rq.end();
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ text: result, model: model, object: 'text' }));
console.log(`routerai STT OK: "${result.slice(0, 50)}"`);
return;
} catch (e) {
console.log(`routerai STT failed (${e.message}), falling back to whisper.cpp`);
}
// Фолбэк: whisper.cpp
const tmpFile = path.join(TMP_DIR, `audio_${Date.now()}.${audioFormat}`);
fs.writeFileSync(tmpFile, audioData);
try {
const text = await new Promise((resolve, reject) => {
const proc = spawn(WHISPER_BIN, ['-m', WHISPER_MODEL, '-f', tmpFile, '-l', 'ru', '--no-timestamps']);
let output = '';
proc.stdout.on('data', d => output += d.toString());
proc.stderr.on('data', d => {}); // Игнорируем stderr (там прогресс)
proc.on('close', (code) => {
const lines = output.trim().split('\n').filter(l => l.trim());
const lastLine = lines[lines.length - 1] || '';
resolve(lastLine.trim());
});
proc.on('error', reject);
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ text: text, model: 'whisper-cpp-tiny', object: 'text' }));
console.log(`whisper.cpp STT OK: "${text.slice(0, 50)}"`);
} catch (e) {
res.writeHead(500);
res.end(JSON.stringify({ error: e.message }));
console.error(`whisper.cpp error: ${e.message}`);
} finally {
try { fs.unlinkSync(tmpFile); } catch(e) {}
}
return;
}
// ===== Health =====
if (pathname === '/health' || pathname === '/') {
const whisperExists = fs.existsSync(WHISPER_BIN) && fs.existsSync(WHISPER_MODEL);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok',
service: 'routerai-voice-proxy',
whisper_local: whisperExists,
routerai: !!API_KEY
}));
return;
}
res.writeHead(404);
res.end(JSON.stringify({ error: 'Not found' }));
} catch (e) { } catch (e) {
console.error('Request error:', e); stats.errors_total++;
res.writeHead(500); log('ERROR', { requestId, error: e.message });
res.end(JSON.stringify({ error: e.message })); if (e.message === 'Request body too large') {
errorResponse(res, 413, requestId, 'Request body exceeds 50MB limit');
} else {
errorResponse(res, 500, requestId, 'Internal server error');
}
} }
}); });
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
server.listen(PORT, '127.0.0.1', () => { server.listen(PORT, '127.0.0.1', () => {
const cfg = loadConfig();
const whisperOk = fs.existsSync(WHISPER_BIN) && fs.existsSync(WHISPER_MODEL); const whisperOk = fs.existsSync(WHISPER_BIN) && fs.existsSync(WHISPER_MODEL);
console.log(`🟢 RouterAI Voice Proxy v2 — http://127.0.0.1:${PORT}`); log('INFO', {
console.log(` STT: routerai → ${whisperOk ? 'whisper.cpp (фолбэк)' : 'только routerai'}`); message: 'RouterAI Voice Proxy started',
console.log(` TTS: прокси → ${ROUTERAI_BASE}/audio/speech`); version: '3.0.0',
port: PORT,
tts_model: cfg.model,
whisper_local: whisperOk
});
console.log(`RouterAI Voice Proxy v3 — http://127.0.0.1:${PORT}`);
}); });