From 98eb5c62578c32c548eb1d99baa6488a37310345 Mon Sep 17 00:00:00 2001 From: Roko Date: Wed, 8 Jul 2026 13:24:35 +0700 Subject: [PATCH] =?UTF-8?q?v3:=20rewrite=20=E2=80=94=20structured=20loggin?= =?UTF-8?q?g,=20config=20hot-reload,=20request=20tracing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 29 ++ routerai-voice-proxy.js | 695 +++++++++++++++++++++++++++------------- 2 files changed, 508 insertions(+), 216 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..077176d --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/routerai-voice-proxy.js b/routerai-voice-proxy.js index a9b9604..87ee87d 100644 --- a/routerai-voice-proxy.js +++ b/routerai-voice-proxy.js @@ -1,272 +1,535 @@ #!/usr/bin/env node /** - * RouterAI Voice Proxy v2 — теперь с локальным STT через whisper.cpp - * - * STT (multipart) → 1) пытается routerai, 2) фолбэк на whisper.cpp - * TTS (JSON) → прозрачный прокси на routerai + * RouterAI Voice Proxy v3 + * + * 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; +} + +/** 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 parsed = url.parse(req.url, true); - const pathname = parsed.pathname; - + 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; } + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } try { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const body = await collectBody(req); - - // ===== GET /v1/models ===== - if (pathname === '/v1/models' && req.method === 'GET') { - 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' } - ] - })); - return; + 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'); } - - // ===== 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) { - 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}`); });