forked from admin/zeropost-engine
AI config migration to app_settings + ai_usage logging
* config/index.js: добавлен reloadAi() — асинхронно подтягивает значения из app_settings (категория ai_providers), мутирует config.ai in-place. Сервисы продолжают использовать config.ai.* синхронно. 3-уровневый fallback: app_settings.value → process.env (новое имя) → process.env (старое имя) → дефолт. * index.js: добавлен await config.reloadAi() в startup после migrate(). Добавлен middleware AsyncLocalStorage для проброса service/userId в AI-сервисы. Сервис определяется по URL-префиксу (zeropost-blog vs zeropost-tool). Подмонтирован роут /api/usage. * routes/settings.js: PUT и invalidate вызывают config.reloadAi() после изменения настройки категории ai_providers — горячая перезагрузка без рестарта PM2. * routes/usage.js (новый): GET /api/usage/summary?range&group_by — сводка расходов с разбивкой по сервису/провайдеру/модели. GET /api/usage/recent — последние вызовы. * lib/aiContext.js (новый): обёртка над AsyncLocalStorage для service/userId. * services/aiUsage.js (новый): log() с расчётом cost_rub по справочнику цен Anthropic/OpenAI (USD/1M токенов или USD/картинку) × markup × usd_rub. Никогда не бросает наружу — ошибки логирования не валят генерацию. * services/ai.js: chat() и image() обёрнуты в try/catch с aiUsage.log(). Все вышестоящие функции (generatePost, transformPost, generateTopics, generateArticle) идут через chat() — покрытие 100%. * services/covers.js: 3 call sites хукнуты (generateCoverViaResponses, generateCoverViaImagesEndpoint, generateCoverViaImageGenerations). Заодно поправлен process.env.AI_MODEL_IMAGE_VIA_RESPONSES → config.ai.imageModelViaResponses. Удалён мёртвый код после throw. * services/postImages.js: 1 call site (/responses + image_generation tool). Тот же fix env-ref. * services/articleAutoSeries.js: 1 call site (/messages, Anthropic-формат). usage.input_tokens / output_tokens корректно парсится. Verify: * Все 11 строк app_settings ai_providers заполнены значениями из .env. * Горячая перезагрузка: PUT /api/settings/admin/AI_TEXT_MODEL_POST -> runtime обновился без рестарта PM2. * Live test: ai.chat() через aiprimetech, 118+49 токенов, 0.0414 ₽ — запись в ai_usage с правильным service/provider/model. * GET /api/usage/summary возвращает корректные totals + breakdown.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const settings = require('../services/settings');
|
||||
const config = require('../config');
|
||||
|
||||
// GET /api/settings/admin?category=photo_search — список всех настроек, опц. фильтр.
|
||||
router.get('/admin', async (req, res) => {
|
||||
@@ -14,21 +15,27 @@ router.get('/admin', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/settings/admin/:key — обновить значение одной настройки
|
||||
// PUT /api/settings/admin/:key — обновить значение одной настройки.
|
||||
// Если поле относится к ai_providers — сразу перечитываем AI-конфиг в память,
|
||||
// чтобы новые значения вступили в силу без рестарта PM2.
|
||||
router.put('/admin/:key', async (req, res) => {
|
||||
try {
|
||||
const { value } = req.body || {};
|
||||
const row = await settings.set(req.params.key, value ?? null);
|
||||
if (!row) return res.status(404).json({ error: 'Setting key not found' });
|
||||
if (row.category === 'ai_providers') {
|
||||
await config.reloadAi();
|
||||
}
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/settings/admin/invalidate — принудительно сбросить кэш
|
||||
// POST /api/settings/admin/invalidate — принудительно сбросить кэш и перечитать AI.
|
||||
router.post('/admin/invalidate', async (req, res) => {
|
||||
settings.invalidate();
|
||||
await config.reloadAi();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { query } = require('../config/db');
|
||||
|
||||
// Допустимые временные окна.
|
||||
const RANGES = {
|
||||
today: "(NOW() AT TIME ZONE 'Europe/Moscow')::date",
|
||||
week: "NOW() - INTERVAL '7 days'",
|
||||
month: "NOW() - INTERVAL '30 days'",
|
||||
all: "TIMESTAMP 'epoch'",
|
||||
};
|
||||
|
||||
// Допустимые группировки (whitelist — никаких string-injection из query).
|
||||
const GROUP_COLUMNS = {
|
||||
service: 'service',
|
||||
provider: 'provider',
|
||||
model: 'model',
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/usage/summary?range=today&group_by=service
|
||||
*
|
||||
* Возвращает суммарные показатели + разбивку.
|
||||
* range: today | week | month | all (default: today)
|
||||
* group_by: service | provider | model (default: service)
|
||||
* service: (опц.) фильтр по конкретному сервису
|
||||
*/
|
||||
router.get('/summary', async (req, res) => {
|
||||
try {
|
||||
const range = RANGES[req.query.range] ? req.query.range : 'today';
|
||||
const groupBy = GROUP_COLUMNS[req.query.group_by] || GROUP_COLUMNS.service;
|
||||
const serviceFilter = req.query.service || null;
|
||||
|
||||
const sinceExpr = RANGES[range];
|
||||
const params = [];
|
||||
let where = `created_at >= ${sinceExpr}`;
|
||||
if (serviceFilter) {
|
||||
params.push(serviceFilter);
|
||||
where += ` AND service = $${params.length}`;
|
||||
}
|
||||
|
||||
// totals
|
||||
const totalsQ = await query(`
|
||||
SELECT
|
||||
COUNT(*) AS calls,
|
||||
COUNT(*) FILTER (WHERE succeeded) AS succeeded,
|
||||
COUNT(*) FILTER (WHERE NOT succeeded) AS failed,
|
||||
COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
|
||||
COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
|
||||
COALESCE(SUM(image_count), 0) AS image_count,
|
||||
COALESCE(SUM(cost_rub), 0) AS cost_rub,
|
||||
COALESCE(AVG(duration_ms)::int, 0) AS avg_duration_ms
|
||||
FROM ai_usage WHERE ${where}
|
||||
`, params);
|
||||
|
||||
// breakdown
|
||||
const breakdownQ = await query(`
|
||||
SELECT
|
||||
${groupBy} AS key,
|
||||
COUNT(*) AS calls,
|
||||
COUNT(*) FILTER (WHERE NOT succeeded) AS failed,
|
||||
COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
|
||||
COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
|
||||
COALESCE(SUM(image_count), 0) AS image_count,
|
||||
COALESCE(SUM(cost_rub), 0) AS cost_rub
|
||||
FROM ai_usage WHERE ${where}
|
||||
GROUP BY ${groupBy}
|
||||
ORDER BY cost_rub DESC NULLS LAST
|
||||
`, params);
|
||||
|
||||
const totals = totalsQ.rows[0] || {};
|
||||
res.json({
|
||||
range,
|
||||
group_by: groupBy,
|
||||
since: null, // подставит фронт, если нужно
|
||||
totals: {
|
||||
calls: parseInt(totals.calls) || 0,
|
||||
succeeded: parseInt(totals.succeeded) || 0,
|
||||
failed: parseInt(totals.failed) || 0,
|
||||
prompt_tokens: parseInt(totals.prompt_tokens) || 0,
|
||||
completion_tokens: parseInt(totals.completion_tokens) || 0,
|
||||
image_count: parseInt(totals.image_count) || 0,
|
||||
cost_rub: parseFloat(totals.cost_rub) || 0,
|
||||
avg_duration_ms: parseInt(totals.avg_duration_ms) || 0,
|
||||
},
|
||||
breakdown: breakdownQ.rows.map(r => ({
|
||||
key: r.key,
|
||||
calls: parseInt(r.calls) || 0,
|
||||
failed: parseInt(r.failed) || 0,
|
||||
prompt_tokens: parseInt(r.prompt_tokens) || 0,
|
||||
completion_tokens: parseInt(r.completion_tokens) || 0,
|
||||
image_count: parseInt(r.image_count) || 0,
|
||||
cost_rub: parseFloat(r.cost_rub) || 0,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[usage/summary] error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/usage/recent?limit=20
|
||||
* Последние вызовы с подробностями — для отладки/просмотра в админке.
|
||||
*/
|
||||
router.get('/recent', async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
|
||||
const r = await query(`
|
||||
SELECT id, service, provider, request_type, model,
|
||||
prompt_tokens, completion_tokens, image_count,
|
||||
cost_rub, duration_ms, succeeded, error_message, created_at
|
||||
FROM ai_usage ORDER BY id DESC LIMIT $1
|
||||
`, [limit]);
|
||||
res.json(r.rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user