fix: auth/API logging to Loki, TTS voice auto-detection, STT ElevenLabs support

- Add logger.audit/access calls to auth route (login, login_failed,
  login_blocked, register, password_changed, 2fa_backup_code_used,
  2fa_backup_codes_regenerated) — these previously only wrote to DB
  via raw SQL, bypassing Loki shipper
- Replace logger.info with logger.apiCall in callAI() so every AI call
  ships to Loki with model, tokens, cost, duration
- Add device identifier (parsed user agent) to audit and access logs
- Fix TTS voice/model provider mismatch: auto-detect Vertex voices
  (Puck, Charon, Kore, etc.) and ElevenLabs voice IDs, override model
  to match provider regardless of what model was previously set
- Fix TTS discovery: model IDs saved to tts.voice are detected and
  redirected to tts.model (regex for openai-tts, elevenlabs, vertex-tts)
- Fix STT transcription route: add scribe/elevenlabs/transcri to the
  isTranscriptionModel regex so ElevenLabs Scribe uses /audio/transcriptions
  endpoint instead of chat completions
- Remove OpenObserve/SigNoz code from logger (reverted to Loki-only)
This commit is contained in:
Daniel 2026-04-19 21:26:33 +02:00
parent b09276faf5
commit e5f7167b8d
6 changed files with 51 additions and 40 deletions

View file

@ -566,7 +566,7 @@ router.post('/config/tts/test', async function(req, res) {
if (!adminModel && adminVoice && (adminVoice.indexOf('/') !== -1 || /^(openai-tts|tts-1|elevenlabs|vertex-gemini.*tts|openai-gpt.*tts)/i.test(adminVoice))) { adminModel = adminVoice; adminVoice = ''; } if (!adminModel && adminVoice && (adminVoice.indexOf('/') !== -1 || /^(openai-tts|tts-1|elevenlabs|vertex-gemini.*tts|openai-gpt.*tts)/i.test(adminVoice))) { adminModel = adminVoice; adminVoice = ''; }
var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr']; var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr'];
var resolvedVoice = voice || adminVoice || ''; var resolvedVoice = voice || adminVoice || '';
if (!adminModel && resolvedVoice) { if (resolvedVoice) {
if (VERTEX_VOICES.indexOf(resolvedVoice) !== -1) adminModel = 'vertex-gemini-2.5-flash-tts'; if (VERTEX_VOICES.indexOf(resolvedVoice) !== -1) adminModel = 'vertex-gemini-2.5-flash-tts';
else if (resolvedVoice.length > 15 && /^[a-zA-Z0-9]+$/.test(resolvedVoice)) adminModel = 'elevenlabs-v3'; else if (resolvedVoice.length > 15 && /^[a-zA-Z0-9]+$/.test(resolvedVoice)) adminModel = 'elevenlabs-v3';
} }
@ -770,7 +770,7 @@ router.post('/config/stt/test', async function(req, res) {
var sttModel = adminSttModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash'; var sttModel = adminSttModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash';
// base URL handled by gatewayUrl() helper // base URL handled by gatewayUrl() helper
var isTranscriptionModel = /whisper|deepgram|nova|groq/i.test(sttModel); var isTranscriptionModel = /whisper|deepgram|nova|groq|scribe|elevenlabs|transcri/i.test(sttModel);
if (isTranscriptionModel) { if (isTranscriptionModel) {
var ext = mime.split('/')[1] || 'webm'; var ext = mime.split('/')[1] || 'webm';

View file

@ -11,6 +11,7 @@ const db = require('../db/database');
const { JWT_SECRET, authMiddleware } = require('../middleware/auth'); const { JWT_SECRET, authMiddleware } = require('../middleware/auth');
const { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions'); const { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
const { notifyNewLogin, notifyPasswordChanged, notifyNewRegistration } = require('../utils/notify'); const { notifyNewLogin, notifyPasswordChanged, notifyNewRegistration } = require('../utils/notify');
var logger = require('../utils/logger');
// Check password against Have I Been Pwned (k-anonymity — only first 5 chars of SHA-1 sent) // Check password against Have I Been Pwned (k-anonymity — only first 5 chars of SHA-1 sent)
async function checkPwnedPassword(password) { async function checkPwnedPassword(password) {
@ -229,6 +230,7 @@ router.post('/register', async (req, res) => {
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)', await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[userId, 'register', req.ip, role === 'admin' ? 'First user — auto admin' : 'standard user']); [userId, 'register', req.ip, role === 'admin' ? 'First user — auto admin' : 'standard user']);
logger.audit(userId, 'register', role === 'admin' ? 'First user — auto admin' : 'standard user', req, { category: 'auth' });
notifyNewRegistration(email, name); notifyNewRegistration(email, name);
var smtpHost = await db.getSetting('smtp.host').catch(function() { return null; }) || process.env.SMTP_HOST; var smtpHost = await db.getSetting('smtp.host').catch(function() { return null; }) || process.env.SMTP_HOST;
@ -332,6 +334,7 @@ router.post('/login', async (req, res) => {
} }
if (!valid) { if (!valid) {
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]).catch(function(){}); await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]).catch(function(){});
logger.access(user.id, 'login_failed', req, false);
return res.status(401).json({ error: 'Invalid credentials' }); return res.status(401).json({ error: 'Invalid credentials' });
} }
@ -343,6 +346,7 @@ router.post('/login', async (req, res) => {
if (user.disabled) { if (user.disabled) {
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)', await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[user.id, 'login_blocked', req.ip, 'Account disabled']).catch(function(){}); [user.id, 'login_blocked', req.ip, 'Account disabled']).catch(function(){});
logger.access(user.id, 'login_blocked', req, false);
return res.status(401).json({ error: 'Invalid credentials' }); return res.status(401).json({ error: 'Invalid credentials' });
} }
@ -361,11 +365,13 @@ router.post('/login', async (req, res) => {
if (!consumed) return res.status(401).json({ error: 'Invalid 2FA code' }); if (!consumed) return res.status(401).json({ error: 'Invalid 2FA code' });
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)', await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[user.id, '2fa_backup_code_used', req.ip, 'Backup code consumed at login']).catch(function(){}); [user.id, '2fa_backup_code_used', req.ip, 'Backup code consumed at login']).catch(function(){});
logger.audit(user.id, '2fa_backup_code_used', 'Backup code consumed at login', req, { category: 'auth' });
} }
} }
var token = signAuthToken(user.id, req); var token = signAuthToken(user.id, req);
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]); await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]);
logger.access(user.id, 'login', req, true);
// Create session record // Create session record
var sessionId = generateSessionId(); var sessionId = generateSessionId();
@ -468,6 +474,7 @@ router.post('/2fa/backup-codes', authMiddleware, async (req, res) => {
var hashes = await hashBackupCodes(codes); var hashes = await hashBackupCodes(codes);
await db.run('UPDATE users SET totp_backup_codes = ? WHERE id = ?', [JSON.stringify(hashes), req.user.id]); await db.run('UPDATE users SET totp_backup_codes = ? WHERE id = ?', [JSON.stringify(hashes), req.user.id]);
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [req.user.id, '2fa_backup_codes_regenerated', req.ip]).catch(function(){}); await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [req.user.id, '2fa_backup_codes_regenerated', req.ip]).catch(function(){});
logger.audit(req.user.id, '2fa_backup_codes_regenerated', 'Backup codes regenerated', req, { category: 'auth' });
// Return plaintext codes — this is the ONLY time they are visible. // Return plaintext codes — this is the ONLY time they are visible.
res.json({ success: true, codes: codes, message: 'Save these somewhere safe — they cannot be shown again.' }); res.json({ success: true, codes: codes, message: 'Save these somewhere safe — they cannot be shown again.' });
} catch (err) { console.error('[Auth] 2FA backup codes error:', err.message); res.status(500).json({ error: 'Request failed' }); } } catch (err) { console.error('[Auth] 2FA backup codes error:', err.message); res.status(500).json({ error: 'Request failed' }); }
@ -626,6 +633,7 @@ router.post('/change-password', authMiddleware, async (req, res) => {
} }
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [req.user.id, 'password_changed', req.ip]); await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [req.user.id, 'password_changed', req.ip]);
logger.audit(req.user.id, 'password_changed', 'Password changed', req, { category: 'auth' });
notifyPasswordChanged(req.user.id); notifyPasswordChanged(req.user.id);
var resp = { success: true, message: 'Password changed. All other sessions have been logged out.' }; var resp = { success: true, message: 'Password changed. All other sessions have been logged out.' };

View file

@ -96,7 +96,7 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r
var mimeType = req.file.mimetype || 'audio/webm'; var mimeType = req.file.mimetype || 'audio/webm';
// Models that use /audio/transcriptions (Whisper, Deepgram, Groq Whisper, etc.) // Models that use /audio/transcriptions (Whisper, Deepgram, Groq Whisper, etc.)
var isTranscriptionModel = /whisper|deepgram|nova|groq/i.test(sttModel); var isTranscriptionModel = /whisper|deepgram|nova|groq|scribe|elevenlabs|transcri/i.test(sttModel);
if (isTranscriptionModel) { if (isTranscriptionModel) {
// Use /v1/audio/transcriptions endpoint (OpenAI-compatible) // Use /v1/audio/transcriptions endpoint (OpenAI-compatible)

View file

@ -42,13 +42,12 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => {
// tts.voice may contain a model ID (from discovery "Set" button) — detect and use as model // tts.voice may contain a model ID (from discovery "Set" button) — detect and use as model
if (!adminModel && adminVoice && (adminVoice.indexOf('/') !== -1 || /^(openai-tts|tts-1|elevenlabs|vertex-gemini.*tts|openai-gpt.*tts)/i.test(adminVoice))) { adminModel = adminVoice; adminVoice = ''; } if (!adminModel && adminVoice && (adminVoice.indexOf('/') !== -1 || /^(openai-tts|tts-1|elevenlabs|vertex-gemini.*tts|openai-gpt.*tts)/i.test(adminVoice))) { adminModel = adminVoice; adminVoice = ''; }
// Auto-detect model from voice name when no explicit model is set // Auto-detect model from voice — ensures model matches the voice's provider
var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr']; var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr'];
var OPENAI_VOICES = ['alloy','echo','fable','onyx','nova','shimmer','ash','sage','coral'];
var resolvedVoice = userVoice || adminVoice || ''; var resolvedVoice = userVoice || adminVoice || '';
if (!adminModel && resolvedVoice) { if (resolvedVoice) {
if (VERTEX_VOICES.indexOf(resolvedVoice) !== -1) adminModel = 'vertex-gemini-2.5-flash-tts'; if (VERTEX_VOICES.indexOf(resolvedVoice) !== -1) adminModel = 'vertex-gemini-2.5-flash-tts';
else if (resolvedVoice.length > 15 && /^[a-zA-Z0-9]+$/.test(resolvedVoice)) adminModel = 'elevenlabs-v3'; // ElevenLabs voice IDs are long alphanumeric else if (resolvedVoice.length > 15 && /^[a-zA-Z0-9]+$/.test(resolvedVoice)) adminModel = 'elevenlabs-v3';
} }
if (ttsProvider === 'google') { if (ttsProvider === 'google') {

View file

@ -450,12 +450,13 @@ async function callAI(messages, options) {
var duration = Date.now() - startTime; var duration = Date.now() - startTime;
logger.info('AI call success', { var usage = result.usage || {};
provider: activeProvider, logger.apiCall(null, activeProvider + '/' + (result.model || model), {
model: result.model, model: result.model || model,
tokensInput: usage.prompt_tokens || 0,
tokensOutput: usage.completion_tokens || 0,
duration: duration, duration: duration,
outputLength: result.content.length, statusCode: 200
tokens: result.usage
}); });
result.duration = duration; result.duration = duration;

View file

@ -3,6 +3,8 @@ const path = require('path');
const db = require('../db/database'); const db = require('../db/database');
const { redact } = require('./redact'); const { redact } = require('./redact');
const queues = require('./auditQueue'); const queues = require('./auditQueue');
var parseUA;
try { parseUA = require('./sessions').parseUserAgent; } catch(e) { parseUA = function(ua) { return ua || 'unknown'; }; }
var LOG_DIR = path.join(__dirname, '../../data/logs'); var LOG_DIR = path.join(__dirname, '../../data/logs');
if (!fs.existsSync(LOG_DIR)) { if (!fs.existsSync(LOG_DIR)) {
@ -26,6 +28,7 @@ function pushToLoki(labels, message) {
}).catch(function() {}); // fire-and-forget }).catch(function() {}); // fire-and-forget
} }
function estimateCost(model, inputTokens, outputTokens) { function estimateCost(model, inputTokens, outputTokens) {
if (!model || !inputTokens) return 0; if (!model || !inputTokens) return 0;
var rates = { var rates = {
@ -61,16 +64,17 @@ var logger = {
duration_ms: e.duration || null, duration_ms: e.duration || null,
status: e.status || 'success' status: e.status || 'success'
}); });
pushToLoki( var auditLabels = { type: 'audit', category: e.category || 'general', action: action, status: e.status || 'success' };
{ type: 'audit', category: e.category || 'general', action: action, status: e.status || 'success' }, var auditMsg = JSON.stringify({
JSON.stringify({ userId: userId, action: action, details: safeDetails,
userId: userId, action: action, details: safeDetails, ip: req ? req.ip : null,
ip: req ? req.ip : null, userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null,
userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null, device: req ? parseUA(req.headers['user-agent']) : null,
sessionId: req ? (req.sessionId || null) : null, sessionId: req ? (req.sessionId || null) : null,
status: e.status || 'success' model: e.model || null,
}) status: e.status || 'success'
); });
pushToLoki(auditLabels, auditMsg);
}, },
apiCall: function(userId, endpoint, data) { apiCall: function(userId, endpoint, data) {
@ -91,14 +95,13 @@ var logger = {
ip_address: d.ip || null, ip_address: d.ip || null,
error: d.error || null error: d.error || null
}); });
pushToLoki( var apiLabels = { type: 'api_call', endpoint: endpoint, model: d.model || '' };
{ type: 'api_call', endpoint: endpoint, model: d.model || '' }, var apiMsg = JSON.stringify({
JSON.stringify({ userId: userId, endpoint: endpoint, model: d.model,
userId: userId, endpoint: endpoint, model: d.model, tokens: (d.tokensInput||0)+(d.tokensOutput||0), cost: cost, duration: d.duration,
tokens: (d.tokensInput||0)+(d.tokensOutput||0), cost: cost, duration: d.duration, ip: d.ip || null, status: d.statusCode || 200
ip: d.ip || null, status: d.statusCode || 200 });
}) pushToLoki(apiLabels, apiMsg);
);
}, },
access: function(userId, action, req, success) { access: function(userId, action, req, success) {
@ -109,16 +112,16 @@ var logger = {
user_agent: req ? (req.headers['user-agent'] || '').substring(0, 255) : null, user_agent: req ? (req.headers['user-agent'] || '').substring(0, 255) : null,
success: success ? true : false success: success ? true : false
}); });
pushToLoki( var accessLabels = { type: 'access', action: action };
{ type: 'access', action: action }, var accessMsg = JSON.stringify({
JSON.stringify({ userId: userId, action: action,
userId: userId, action: action, ip: req ? req.ip : null,
ip: req ? req.ip : null, userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null,
userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null, device: req ? parseUA(req.headers['user-agent']) : null,
sessionId: req ? (req.sessionId || null) : null, sessionId: req ? (req.sessionId || null) : null,
success: success success: success
}) });
); pushToLoki(accessLabels, accessMsg);
}, },
file: function(level, message, data) { file: function(level, message, data) {