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
+68 -11
View File
@@ -1,6 +1,7 @@
const axios = require('axios');
const config = require('../config');
const pb = require('./promptBuilder');
const aiUsage = require('./aiUsage');
const client = axios.create({
baseURL: config.ai.baseUrl,
@@ -25,16 +26,46 @@ async function chat(model, systemPrompt, userPrompt, options = {}) {
};
if (temperature !== undefined) body.temperature = temperature;
const res = await client.post('/chat/completions', body, {
headers: { Authorization: `Bearer ${config.ai.apiKey}` },
});
const started = Date.now();
let res;
try {
res = await client.post('/chat/completions', body, {
headers: { Authorization: `Bearer ${config.ai.apiKey}` },
baseURL: config.ai.baseUrl, // подхватить актуальный URL (горячая перезагрузка)
});
} catch (err) {
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
requestType: 'chat',
model,
durationMs: Date.now() - started,
succeeded: false,
errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500),
}).catch(() => {});
throw err;
}
const text = res.data.choices?.[0]?.message?.content;
const usage = res.data.usage || {};
// лог успешного вызова — fire-and-forget, не блокируем основной поток
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
requestType: 'chat',
model,
promptTokens: usage.prompt_tokens,
completionTokens: usage.completion_tokens,
totalTokens: usage.total_tokens,
requestId: res.data.id,
durationMs: Date.now() - started,
succeeded: true,
}).catch(() => {});
if (!text) throw new Error('Empty response from AI gateway');
return {
text: text.trim(),
usage: res.data.usage || {},
usage,
};
}
@@ -43,14 +74,40 @@ async function chat(model, systemPrompt, userPrompt, options = {}) {
*/
async function image(prompt, options = {}) {
const { size = '1024x1024' } = options;
const res = await client.post('/images/generations', {
const started = Date.now();
let res;
try {
res = await client.post('/images/generations', {
model: config.ai.models.image,
prompt,
n: 1,
size,
}, {
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
baseURL: config.ai.imageBaseUrl,
});
} catch (err) {
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.imageBaseUrl),
requestType: 'image',
model: config.ai.models.image,
imageCount: 1,
durationMs: Date.now() - started,
succeeded: false,
errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500),
}).catch(() => {});
throw err;
}
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.imageBaseUrl),
requestType: 'image',
model: config.ai.models.image,
prompt,
n: 1,
size,
}, {
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
});
imageCount: 1,
durationMs: Date.now() - started,
succeeded: true,
}).catch(() => {});
return res.data.data?.[0]?.url || res.data.data?.[0]?.b64_json;
}
+143
View File
@@ -0,0 +1,143 @@
// Логирование AI-расхода в таблицу ai_usage.
//
// Цены прошиты в коде (USD за 1M токенов или за 1 картинку — это прямые ставки
// OpenAI/Anthropic). Реальная стоимость считается так:
// cost_usd = (prompt_tokens * input_rate + completion_tokens * output_rate) / 1e6
// cost_rub = cost_usd * provider_markup * usd_rub_rate
// provider_markup и usd_rub_rate берутся из app_settings, можно править из админки.
//
// Если модель неизвестна — лог пишется, но cost_rub = null.
const { query } = require('../config/db');
const aiContext = require('../lib/aiContext');
const settings = require('./settings');
// USD за 1 МИЛЛИОН токенов (прямые цены Anthropic/OpenAI; aiprimetech как реселлер
// добавляет наценку — она учитывается через AI_PROVIDER_MARKUP в app_settings).
const TEXT_PRICES_USD_PER_1M = {
'claude-haiku-4-5-20251001': { input: 1.0, output: 5.0 },
'claude-haiku-4-5': { input: 1.0, output: 5.0 },
'claude-haiku-4.5': { input: 1.0, output: 5.0 },
'claude-sonnet-4-6': { input: 3.0, output: 15.0 },
'claude-sonnet-4.6': { input: 3.0, output: 15.0 },
'claude-sonnet-4-5': { input: 3.0, output: 15.0 },
'claude-sonnet-4': { input: 3.0, output: 15.0 },
'gpt-5': { input: 1.25, output: 10.0 },
'gpt-5.5': { input: 1.25, output: 10.0 },
'gpt-4o': { input: 2.5, output: 10.0 },
'gpt-4o-mini': { input: 0.15, output: 0.6 },
};
// USD за 1 картинку (приблизительно — aiguoguo не публикует точно).
const IMAGE_PRICES_USD = {
'gpt-image-1': 0.04,
'gpt-image-1-mini': 0.01, // оценка, поправь после первого месяца использования
'dall-e-3': 0.04,
'dall-e-2': 0.02,
};
function providerFromBaseUrl(url) {
if (!url) return 'unknown';
if (url.includes('aiprimetech')) return 'aiprimetech';
if (url.includes('aiguoguo')) return 'aiguoguo';
if (url.includes('vsegpt')) return 'vsegpt';
try { return new URL(url).hostname; } catch (_) { return 'unknown'; }
}
async function computeCostRub({ requestType, model, promptTokens, completionTokens, imageCount }) {
let usdRubRate = 95;
let markup = 1.2;
try {
const cfg = await settings.getMany(['AI_USD_RUB_RATE', 'AI_PROVIDER_MARKUP']);
if (cfg.AI_USD_RUB_RATE) usdRubRate = parseFloat(cfg.AI_USD_RUB_RATE) || 95;
if (cfg.AI_PROVIDER_MARKUP) markup = parseFloat(cfg.AI_PROVIDER_MARKUP) || 1.2;
} catch (_) { /* fall back to defaults */ }
if (requestType === 'chat') {
const p = TEXT_PRICES_USD_PER_1M[model];
if (!p) return null;
const costUsd = ((promptTokens || 0) * p.input + (completionTokens || 0) * p.output) / 1e6;
return +(costUsd * markup * usdRubRate).toFixed(4);
}
if (requestType === 'image' || requestType === 'image_via_responses') {
const perImage = IMAGE_PRICES_USD[model];
if (perImage === undefined) return null;
return +((imageCount || 1) * perImage * markup * usdRubRate).toFixed(4);
}
return null;
}
/**
* Логирует один AI-вызов. Никогда не бросает наружу — ошибки логируются в stderr,
* чтобы баг логирования не ронял production-генерацию.
*/
async function log(o) {
try {
const ctx = aiContext.get();
const costRub = await computeCostRub({
requestType: o.requestType,
model: o.model,
promptTokens: o.promptTokens,
completionTokens: o.completionTokens,
imageCount: o.imageCount,
});
await query(
`INSERT INTO ai_usage (
service, provider, request_type, model, user_id,
prompt_tokens, completion_tokens, total_tokens, image_count,
cost_rub, request_id, meta, duration_ms, succeeded, error_message
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
[
ctx.service || 'zeropost-unknown',
o.provider || 'unknown',
o.requestType,
o.model || 'unknown',
ctx.userId ?? null,
o.promptTokens ?? null,
o.completionTokens ?? null,
o.totalTokens ?? (((o.promptTokens || 0) + (o.completionTokens || 0)) || null),
o.imageCount ?? null,
costRub,
o.requestId || null,
o.meta ? JSON.stringify(o.meta) : null,
o.durationMs ?? null,
o.succeeded !== false,
o.errorMessage || null,
]
);
} catch (err) {
console.error('[aiUsage.log] failed:', err.message);
}
}
/**
* Обёртка-измеритель: запускает async-функцию, замеряет длительность,
* вытаскивает usage из axios-ответа, логирует. Возвращает оригинальный ответ.
*/
async function wrap(meta, fn) {
const start = Date.now();
try {
const resp = await fn();
const usage = resp?.data?.usage || resp?.usage || {};
await log({
...meta,
promptTokens: usage.prompt_tokens ?? usage.input_tokens,
completionTokens: usage.completion_tokens ?? usage.output_tokens,
totalTokens: usage.total_tokens,
requestId: resp?.data?.id || resp?.id,
durationMs: Date.now() - start,
succeeded: true,
});
return resp;
} catch (err) {
await log({
...meta,
durationMs: Date.now() - start,
succeeded: false,
errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500),
});
throw err;
}
}
module.exports = { log, wrap, providerFromBaseUrl, computeCostRub };
+25 -1
View File
@@ -13,6 +13,7 @@
const axios = require('axios');
const { query } = require('../config/db');
const config = require('../config');
const aiUsage = require('./aiUsage');
const SERIES_DESCRIPTIONS = [
{
@@ -59,11 +60,13 @@ ${seriesList}
Отвечай ТОЛЬКО одним словом — slug серии (prompts / mcp-agents / cases) или "none" если статья ни к одной не подходит достаточно хорошо.
Выбирай серию только если уверен на 80%+. Лучше "none" чем неточное попадание.`;
const model = config.ai.models?.post || 'claude-haiku-4-5-20251001';
const started = Date.now();
try {
const res = await axios.post(
`${config.ai.baseUrl}/messages`,
{
model: config.ai.models?.post || 'claude-haiku-4-5-20251001',
model,
max_tokens: 10,
messages: [{ role: 'user', content: prompt }],
},
@@ -73,12 +76,33 @@ ${seriesList}
}
);
const usage = res.data?.usage || {};
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
requestType: 'chat',
model,
promptTokens: usage.input_tokens,
completionTokens: usage.output_tokens,
requestId: res.data?.id,
meta: { purpose: 'auto-series-detection' },
durationMs: Date.now() - started,
succeeded: true,
}).catch(() => {});
const raw = res.data?.content?.[0]?.text?.trim().toLowerCase() || 'none';
// Извлекаем только slug без лишнего текста
const valid = SERIES_DESCRIPTIONS.map(s => s.slug);
const found = valid.find(s => raw.includes(s));
return found || null;
} catch (err) {
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
requestType: 'chat', model,
meta: { purpose: 'auto-series-detection' },
durationMs: Date.now() - started,
succeeded: false,
errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500),
}).catch(() => {});
console.warn('[AutoSeries] Claude detection failed:', err.message.slice(0, 100));
return null;
}
+121 -38
View File
@@ -4,6 +4,7 @@ const axios = require('axios');
const config = require('../config');
const { query } = require('../config/db');
const localGen = require('./localCoverGenerator');
const aiUsage = require('./aiUsage');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
@@ -102,32 +103,65 @@ Strictly: no text, no letters, no logos, no human faces, no robots, no brains, n
* Это работает даже когда /v1/images/generations отдаёт unavailable.
*/
async function generateCoverViaResponses({ prompt }) {
const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2';
const model = config.ai.imageModelViaResponses || 'gpt-5.2';
// GPT-5 иногда не вызывает инструмент сама — даём явную инструкцию
const wrappedInput = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool.\n\n${prompt}`;
const res = await axios.post(
`${config.ai.baseUrl}/responses`,
{
model,
input: wrappedInput,
tools: [{ type: 'image_generation' }],
tool_choice: { type: 'image_generation' },
},
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 300_000, // GPT-5 reasoning + image — медленно, до 5 минут
}
);
const started = Date.now();
let res;
try {
res = await axios.post(
`${config.ai.baseUrl}/responses`,
{
model,
input: wrappedInput,
tools: [{ type: 'image_generation' }],
tool_choice: { type: 'image_generation' },
},
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 300_000, // GPT-5 reasoning + image — медленно, до 5 минут
}
);
} catch (err) {
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
requestType: 'image_via_responses',
model, imageCount: 1,
durationMs: Date.now() - started, succeeded: false,
errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500),
}).catch(() => {});
throw err;
}
const output = res.data?.output || [];
const imgCall = output.find(o => o.type === 'image_generation_call');
if (!imgCall) {
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
requestType: 'image_via_responses', model, imageCount: 1,
durationMs: Date.now() - started, succeeded: false,
errorMessage: 'No image_generation_call in response output',
}).catch(() => {});
throw new Error('No image_generation_call in response output');
}
if (!imgCall.result) {
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
requestType: 'image_via_responses', model, imageCount: 1,
durationMs: Date.now() - started, succeeded: false,
errorMessage: `image_generation_call without result, status=${imgCall.status}`,
}).catch(() => {});
throw new Error(`image_generation_call without result, status=${imgCall.status}`);
}
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
requestType: 'image_via_responses', model, imageCount: 1,
requestId: res.data?.id,
durationMs: Date.now() - started, succeeded: true,
}).catch(() => {});
const bytes = Buffer.from(imgCall.result, 'base64');
return {
bytes,
@@ -142,16 +176,41 @@ async function generateCoverViaResponses({ prompt }) {
*/
async function generateCoverViaImagesEndpoint({ prompt }) {
const model = config.ai.models?.image || 'gpt-image-1';
const res = await axios.post(
`${config.ai.baseUrl}/images/generations`,
{ model, prompt, n: 1, size: '1536x1024' },
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 120_000,
}
);
const started = Date.now();
let res;
try {
res = await axios.post(
`${config.ai.baseUrl}/images/generations`,
{ model, prompt, n: 1, size: '1536x1024' },
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 120_000,
}
);
} catch (err) {
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
requestType: 'image', model, imageCount: 1,
durationMs: Date.now() - started, succeeded: false,
errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500),
}).catch(() => {});
throw err;
}
const item = res.data?.data?.[0];
if (!item) throw new Error('Empty image response');
if (!item) {
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
requestType: 'image', model, imageCount: 1,
durationMs: Date.now() - started, succeeded: false,
errorMessage: 'Empty image response',
}).catch(() => {});
throw new Error('Empty image response');
}
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
requestType: 'image', model, imageCount: 1,
durationMs: Date.now() - started, succeeded: true,
}).catch(() => {});
if (item.b64_json) return { bytes: Buffer.from(item.b64_json, 'base64'), format: 'png' };
if (item.url) {
const r = await axios.get(item.url, { responseType: 'arraybuffer', timeout: 60_000 });
@@ -167,21 +226,46 @@ async function generateCoverViaImagesEndpoint({ prompt }) {
async function generateCoverViaImageGenerations({ prompt }) {
const model = config.ai.imageModel || 'gpt-image-1-mini';
const baseUrl = config.ai.imageBaseUrl || config.ai.baseUrl;
const res = await axios.post(
`${baseUrl}/images/generations`,
{
model,
prompt: prompt.slice(0, 4000),
n: 1,
size: '1024x1024',
},
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 120_000,
}
);
const started = Date.now();
let res;
try {
res = await axios.post(
`${baseUrl}/images/generations`,
{
model,
prompt: prompt.slice(0, 4000),
n: 1,
size: '1024x1024',
},
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 120_000,
}
);
} catch (err) {
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(baseUrl),
requestType: 'image', model, imageCount: 1,
durationMs: Date.now() - started, succeeded: false,
errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500),
}).catch(() => {});
throw err;
}
const item = res.data?.data?.[0];
if (!item) throw new Error('No image data in response');
if (!item) {
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(baseUrl),
requestType: 'image', model, imageCount: 1,
durationMs: Date.now() - started, succeeded: false,
errorMessage: 'No image data in response',
}).catch(() => {});
throw new Error('No image data in response');
}
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(baseUrl),
requestType: 'image', model, imageCount: 1,
durationMs: Date.now() - started, succeeded: true,
}).catch(() => {});
// b64_json или url
const b64 = item.b64_json;
if (b64) return { bytes: Buffer.from(b64, 'base64'), format: 'png' };
@@ -190,7 +274,6 @@ async function generateCoverViaImageGenerations({ prompt }) {
return { bytes: Buffer.from(r.data), format: 'png' };
}
throw new Error('No b64_json or url in response');
return { bytes: Buffer.from(b64, 'base64'), format: 'png' };
}
/**
+47 -15
View File
@@ -2,6 +2,7 @@ const fs = require('fs');
const path = require('path');
const axios = require('axios');
const config = require('../config');
const aiUsage = require('./aiUsage');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
@@ -79,26 +80,57 @@ Channel context: ${channel.niche || channel.name}.
Composition: 16:9 wide format, balanced, suitable for social media.
Strictly: no text, no letters, no logos, no faces of real people.`;
const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2';
const model = config.ai.imageModelViaResponses || 'gpt-5.2';
const wrappedInput = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool.\n\n${prompt}`;
const res = await axios.post(
`${config.ai.baseUrl}/responses`,
{
model,
input: wrappedInput,
tools: [{ type: 'image_generation' }],
tool_choice: { type: 'image_generation' },
},
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 300_000,
}
);
const started = Date.now();
let res;
try {
res = await axios.post(
`${config.ai.baseUrl}/responses`,
{
model,
input: wrappedInput,
tools: [{ type: 'image_generation' }],
tool_choice: { type: 'image_generation' },
},
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 300_000,
}
);
} catch (err) {
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
requestType: 'image_via_responses',
model, imageCount: 1,
meta: { channel_id: channel.id },
durationMs: Date.now() - started, succeeded: false,
errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500),
}).catch(() => {});
throw err;
}
const output = res.data?.output || [];
const imgCall = output.find(o => o.type === 'image_generation_call');
if (!imgCall?.result) throw new Error('No image generated');
if (!imgCall?.result) {
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
requestType: 'image_via_responses', model, imageCount: 1,
meta: { channel_id: channel.id },
durationMs: Date.now() - started, succeeded: false,
errorMessage: 'No image generated',
}).catch(() => {});
throw new Error('No image generated');
}
aiUsage.log({
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
requestType: 'image_via_responses', model, imageCount: 1,
requestId: res.data?.id,
meta: { channel_id: channel.id },
durationMs: Date.now() - started, succeeded: true,
}).catch(() => {});
const bytes = Buffer.from(imgCall.result, 'base64');
const tsKey = `post-${channel.id}-${Date.now()}`;