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:
parent
b09276faf5
commit
e5f7167b8d
6 changed files with 51 additions and 40 deletions
|
|
@ -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 = ''; }
|
||||
var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr'];
|
||||
var resolvedVoice = voice || adminVoice || '';
|
||||
if (!adminModel && resolvedVoice) {
|
||||
if (resolvedVoice) {
|
||||
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';
|
||||
}
|
||||
|
|
@ -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';
|
||||
// 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) {
|
||||
var ext = mime.split('/')[1] || 'webm';
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const db = require('../db/database');
|
|||
const { JWT_SECRET, authMiddleware } = require('../middleware/auth');
|
||||
const { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
|
||||
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)
|
||||
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 (?, ?, ?, ?)',
|
||||
[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);
|
||||
|
||||
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) {
|
||||
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' });
|
||||
}
|
||||
|
||||
|
|
@ -343,6 +346,7 @@ router.post('/login', async (req, res) => {
|
|||
if (user.disabled) {
|
||||
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
|
||||
[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' });
|
||||
}
|
||||
|
||||
|
|
@ -361,11 +365,13 @@ router.post('/login', async (req, res) => {
|
|||
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 (?, ?, ?, ?)',
|
||||
[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);
|
||||
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
|
||||
var sessionId = generateSessionId();
|
||||
|
|
@ -468,6 +474,7 @@ router.post('/2fa/backup-codes', authMiddleware, async (req, res) => {
|
|||
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('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.
|
||||
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' }); }
|
||||
|
|
@ -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]);
|
||||
logger.audit(req.user.id, 'password_changed', 'Password changed', req, { category: 'auth' });
|
||||
notifyPasswordChanged(req.user.id);
|
||||
|
||||
var resp = { success: true, message: 'Password changed. All other sessions have been logged out.' };
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r
|
|||
var mimeType = req.file.mimetype || 'audio/webm';
|
||||
|
||||
// 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) {
|
||||
// Use /v1/audio/transcriptions endpoint (OpenAI-compatible)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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 OPENAI_VOICES = ['alloy','echo','fable','onyx','nova','shimmer','ash','sage','coral'];
|
||||
var resolvedVoice = userVoice || adminVoice || '';
|
||||
if (!adminModel && resolvedVoice) {
|
||||
if (resolvedVoice) {
|
||||
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') {
|
||||
|
|
|
|||
|
|
@ -450,12 +450,13 @@ async function callAI(messages, options) {
|
|||
|
||||
var duration = Date.now() - startTime;
|
||||
|
||||
logger.info('AI call success', {
|
||||
provider: activeProvider,
|
||||
model: result.model,
|
||||
var usage = result.usage || {};
|
||||
logger.apiCall(null, activeProvider + '/' + (result.model || model), {
|
||||
model: result.model || model,
|
||||
tokensInput: usage.prompt_tokens || 0,
|
||||
tokensOutput: usage.completion_tokens || 0,
|
||||
duration: duration,
|
||||
outputLength: result.content.length,
|
||||
tokens: result.usage
|
||||
statusCode: 200
|
||||
});
|
||||
|
||||
result.duration = duration;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ const path = require('path');
|
|||
const db = require('../db/database');
|
||||
const { redact } = require('./redact');
|
||||
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');
|
||||
if (!fs.existsSync(LOG_DIR)) {
|
||||
|
|
@ -26,6 +28,7 @@ function pushToLoki(labels, message) {
|
|||
}).catch(function() {}); // fire-and-forget
|
||||
}
|
||||
|
||||
|
||||
function estimateCost(model, inputTokens, outputTokens) {
|
||||
if (!model || !inputTokens) return 0;
|
||||
var rates = {
|
||||
|
|
@ -61,16 +64,17 @@ var logger = {
|
|||
duration_ms: e.duration || null,
|
||||
status: e.status || 'success'
|
||||
});
|
||||
pushToLoki(
|
||||
{ type: 'audit', category: e.category || 'general', action: action, status: e.status || 'success' },
|
||||
JSON.stringify({
|
||||
userId: userId, action: action, details: safeDetails,
|
||||
ip: req ? req.ip : null,
|
||||
userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null,
|
||||
sessionId: req ? (req.sessionId || null) : null,
|
||||
status: e.status || 'success'
|
||||
})
|
||||
);
|
||||
var auditLabels = { type: 'audit', category: e.category || 'general', action: action, status: e.status || 'success' };
|
||||
var auditMsg = JSON.stringify({
|
||||
userId: userId, action: action, details: safeDetails,
|
||||
ip: req ? req.ip : 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,
|
||||
model: e.model || null,
|
||||
status: e.status || 'success'
|
||||
});
|
||||
pushToLoki(auditLabels, auditMsg);
|
||||
},
|
||||
|
||||
apiCall: function(userId, endpoint, data) {
|
||||
|
|
@ -91,14 +95,13 @@ var logger = {
|
|||
ip_address: d.ip || null,
|
||||
error: d.error || null
|
||||
});
|
||||
pushToLoki(
|
||||
{ type: 'api_call', endpoint: endpoint, model: d.model || '' },
|
||||
JSON.stringify({
|
||||
userId: userId, endpoint: endpoint, model: d.model,
|
||||
tokens: (d.tokensInput||0)+(d.tokensOutput||0), cost: cost, duration: d.duration,
|
||||
ip: d.ip || null, status: d.statusCode || 200
|
||||
})
|
||||
);
|
||||
var apiLabels = { type: 'api_call', endpoint: endpoint, model: d.model || '' };
|
||||
var apiMsg = JSON.stringify({
|
||||
userId: userId, endpoint: endpoint, model: d.model,
|
||||
tokens: (d.tokensInput||0)+(d.tokensOutput||0), cost: cost, duration: d.duration,
|
||||
ip: d.ip || null, status: d.statusCode || 200
|
||||
});
|
||||
pushToLoki(apiLabels, apiMsg);
|
||||
},
|
||||
|
||||
access: function(userId, action, req, success) {
|
||||
|
|
@ -109,16 +112,16 @@ var logger = {
|
|||
user_agent: req ? (req.headers['user-agent'] || '').substring(0, 255) : null,
|
||||
success: success ? true : false
|
||||
});
|
||||
pushToLoki(
|
||||
{ type: 'access', action: action },
|
||||
JSON.stringify({
|
||||
userId: userId, action: action,
|
||||
ip: req ? req.ip : null,
|
||||
userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null,
|
||||
sessionId: req ? (req.sessionId || null) : null,
|
||||
success: success
|
||||
})
|
||||
);
|
||||
var accessLabels = { type: 'access', action: action };
|
||||
var accessMsg = JSON.stringify({
|
||||
userId: userId, action: action,
|
||||
ip: req ? req.ip : 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,
|
||||
success: success
|
||||
});
|
||||
pushToLoki(accessLabels, accessMsg);
|
||||
},
|
||||
|
||||
file: function(level, message, data) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue