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:
Ник (Claude)
2026-06-08 20:21:04 +03:00
parent 594cc01fe6
commit 449d1fa728
10 changed files with 643 additions and 74 deletions
+9 -2
View File
@@ -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 });
});
+121
View File
@@ -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;