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:
@@ -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
|
||||
+395
-132
@@ -1,147 +1,326 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* RouterAI Voice Proxy v2 — теперь с локальным STT через whisper.cpp
|
||||
* RouterAI Voice Proxy v3
|
||||
*
|
||||
* STT (multipart) → 1) пытается routerai, 2) фолбэк на whisper.cpp
|
||||
* TTS (JSON) → прозрачный прокси на routerai
|
||||
* Bridges OpenClaw Gateway (OpenAI-compatible API) to RouterAI.
|
||||
* Handles TTS proxying and STT (RouterAI first, whisper.cpp fallback).
|
||||
*
|
||||
* @version 3.0.0
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const url = require('url');
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { spawn } = require('child_process');
|
||||
const https = require('https');
|
||||
|
||||
// Конфиг
|
||||
const WHISPER_BIN = '/tmp/whisper.cpp/main';
|
||||
const WHISPER_MODEL = '/tmp/whisper.cpp/models/ggml-tiny.bin';
|
||||
const TMP_DIR = '/tmp/whisper_uploads';
|
||||
const PORT = parseInt(process.env.PORT || '9998');
|
||||
|
||||
const configPath = path.join(process.env.HOME || '/home/openclaw', '.openclaw', 'openclaw.json');
|
||||
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
const API_KEY = cfg.models?.providers?.['routerai-ru']?.apiKey || process.env.ROUTERAI_API_KEY;
|
||||
const ROUTERAI_BASE = 'https://routerai.ru/api/v1';
|
||||
const PORT = parseInt(process.env.PORT || '9998', 10);
|
||||
const MAX_BODY = 50 * 1024 * 1024;
|
||||
const UPSTREAM_TIMEOUT = 30000;
|
||||
const HOME = process.env.HOME || '/home/openclaw';
|
||||
const CONFIG_PATH = path.join(HOME, '.openclaw', 'openclaw.json');
|
||||
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 });
|
||||
|
||||
// ============ Утилиты ============
|
||||
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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
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('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) {
|
||||
const sep = Buffer.from(`--${boundary}`);
|
||||
const crlf = Buffer.from('\r\n');
|
||||
const crlfcrlf = Buffer.from('\r\n\r\n');
|
||||
const parts = [];
|
||||
const parts_raw = body.split(`--${boundary}`);
|
||||
for (const part of parts_raw) {
|
||||
if (!part || part.trim() === '' || part.trim() === '--') continue;
|
||||
const headerEnd = part.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) continue;
|
||||
const headersRaw = part.substring(0, headerEnd);
|
||||
let content = part.substring(headerEnd + 4);
|
||||
if (content.endsWith('\r\n')) content = content.slice(0, -2);
|
||||
const contentDisposition = headersRaw.match(/Content-Disposition:\s*form-data;\s*(.+)/i);
|
||||
const contentType = headersRaw.match(/Content-Type:\s*(.+)/i);
|
||||
let pos = 0;
|
||||
while (true) {
|
||||
let start = body.indexOf(sep, pos);
|
||||
if (start === -1) break;
|
||||
start += sep.length;
|
||||
if (start + 2 <= body.length && body[start] === 45 && body[start + 1] === 45) break;
|
||||
if (start + 1 < body.length && body[start] === 13 && body[start + 1] === 10) start += 2;
|
||||
const headerEnd = body.indexOf(crlfcrlf, start);
|
||||
if (headerEnd === -1) break;
|
||||
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 = {};
|
||||
if (contentDisposition) {
|
||||
for (const p of contentDisposition[1].split(';')) {
|
||||
const cd = headersRaw.match(/Content-Disposition:\s*form-data;\s*(.+)/i);
|
||||
if (cd) {
|
||||
for (const p of cd[1].split(';')) {
|
||||
const m = p.match(/\s*(\w+)\s*=\s*"([^"]+)"/);
|
||||
if (m) params[m[1]] = m[2];
|
||||
}
|
||||
}
|
||||
const ct = headersRaw.match(/Content-Type:\s*(.+)/i);
|
||||
parts.push({
|
||||
name: params.name,
|
||||
filename: params.filename,
|
||||
contentType: contentType ? contentType[1].trim() : null,
|
||||
data: Buffer.from(content, 'binary')
|
||||
contentType: ct ? ct[1].trim() : null,
|
||||
data: body.subarray(contentStart, contentEnd)
|
||||
});
|
||||
if (next === -1) break;
|
||||
pos = next + 2;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const parsed = url.parse(req.url, true);
|
||||
const pathname = parsed.pathname;
|
||||
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
|
||||
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
||||
|
||||
try {
|
||||
const body = await collectBody(req);
|
||||
|
||||
// ===== GET /v1/models =====
|
||||
if (pathname === '/v1/models' && req.method === 'GET') {
|
||||
/** 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: 'google/gemini-3.1-flash-tts-preview', object: 'model', created: 1710000000, owned_by: 'google' }
|
||||
{ id: cfg.model, object: 'model', created: 1710000000, owned_by: 'google' }
|
||||
]
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== POST /v1/audio/speech (TTS proxy) =====
|
||||
if (pathname === '/v1/audio/speech' && req.method === 'POST') {
|
||||
/** 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);
|
||||
// Удаляем response_format — routerai не поддерживает mp3/opus
|
||||
if (['mp3','opus','wav','pcm'].includes(parsed.response_format)) {
|
||||
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.
|
||||
}
|
||||
} 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,
|
||||
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 ${API_KEY}`,
|
||||
'Authorization': `Bearer ${cfg.apiKey}`,
|
||||
'Content-Type': req.headers['content-type'] || 'application/json',
|
||||
'Content-Length': bodyBuf.length
|
||||
'Content-Length': bodyBuf.length,
|
||||
'X-Request-ID': requestId
|
||||
}
|
||||
};
|
||||
const proxyReq = https.request(options, (proxyRes) => {
|
||||
}, (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.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') {
|
||||
/** 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.toString('binary'), 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') {
|
||||
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';
|
||||
@@ -149,7 +328,6 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// JSON запрос (уже в формате routerai)
|
||||
try {
|
||||
const json = JSON.parse(body.toString('utf8'));
|
||||
model = json.model || model;
|
||||
@@ -157,116 +335,201 @@ const server = http.createServer(async (req, res) => {
|
||||
audioData = Buffer.from(json.input_audio.data, 'base64');
|
||||
audioFormat = json.input_audio.format || 'wav';
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (!audioData) {
|
||||
res.writeHead(400);
|
||||
res.end(JSON.stringify({ error: 'No audio data' }));
|
||||
log('WARN', { requestId, endpoint: '/v1/audio/transcriptions', error: 'No audio data' });
|
||||
errorResponse(res, 400, requestId, 'No audio data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Пробуем сначала routerai
|
||||
let text = null;
|
||||
let usedRouterAI = false;
|
||||
try {
|
||||
const b64 = audioData.toString('base64');
|
||||
const payload = JSON.stringify({
|
||||
model: model,
|
||||
input_audio: { data: b64, format: audioFormat },
|
||||
model,
|
||||
input_audio: { data: audioData.toString('base64'), 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,
|
||||
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 ${API_KEY}`,
|
||||
'Authorization': `Bearer ${cfg.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload)
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
'X-Request-ID': requestId
|
||||
}
|
||||
};
|
||||
const rq = https.request(opts, (rp) => {
|
||||
let d = '';
|
||||
rp.on('data', c => d += c);
|
||||
}, (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(d);
|
||||
const j = JSON.parse(data);
|
||||
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))); }
|
||||
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();
|
||||
});
|
||||
|
||||
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;
|
||||
usedRouterAI = true;
|
||||
} catch (e) {
|
||||
console.log(`routerai STT failed (${e.message}), falling back to whisper.cpp`);
|
||||
log('WARN', {
|
||||
requestId,
|
||||
endpoint: '/v1/audio/transcriptions',
|
||||
error: `RouterAI STT failed: ${e.message}`
|
||||
});
|
||||
}
|
||||
|
||||
// Фолбэк: whisper.cpp
|
||||
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}`);
|
||||
fs.writeFileSync(tmpFile, audioData);
|
||||
|
||||
try {
|
||||
const text = await new Promise((resolve, reject) => {
|
||||
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 => {}); // Игнорируем 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.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: 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;
|
||||
}
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Health =====
|
||||
if (pathname === '/health' || pathname === '/') {
|
||||
const whisperExists = fs.existsSync(WHISPER_BIN) && fs.existsSync(WHISPER_MODEL);
|
||||
/** 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',
|
||||
whisper_local: whisperExists,
|
||||
routerai: !!API_KEY
|
||||
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) => {
|
||||
if (shuttingDown) {
|
||||
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-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end(JSON.stringify({ error: 'Not found' }));
|
||||
|
||||
try {
|
||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||
const body = await collectBody(req);
|
||||
if (url.pathname === '/v1/models' && req.method === 'GET') {
|
||||
await handleModels(req, res, requestId);
|
||||
} else if (url.pathname === '/v1/audio/speech' && req.method === 'POST') {
|
||||
await handleSpeech(req, res, body, requestId);
|
||||
} else if (url.pathname === '/v1/audio/transcriptions' && req.method === 'POST') {
|
||||
await handleTranscriptions(req, res, body, requestId);
|
||||
} else if (url.pathname === '/health' && req.method === 'GET') {
|
||||
await handleHealth(req, res, requestId);
|
||||
} else if (url.pathname === '/' && req.method === 'GET') {
|
||||
res.writeHead(301, { Location: '/health' });
|
||||
res.end();
|
||||
} else {
|
||||
errorResponse(res, 404, requestId, 'Not found');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Request error:', e);
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ error: e.message }));
|
||||
stats.errors_total++;
|
||||
log('ERROR', { requestId, 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', () => {
|
||||
const cfg = loadConfig();
|
||||
const whisperOk = fs.existsSync(WHISPER_BIN) && fs.existsSync(WHISPER_MODEL);
|
||||
console.log(`🟢 RouterAI Voice Proxy v2 — http://127.0.0.1:${PORT}`);
|
||||
console.log(` STT: routerai → ${whisperOk ? 'whisper.cpp (фолбэк)' : 'только routerai'}`);
|
||||
console.log(` TTS: прокси → ${ROUTERAI_BASE}/audio/speech`);
|
||||
log('INFO', {
|
||||
message: 'RouterAI Voice Proxy started',
|
||||
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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user