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:
+121
-38
@@ -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' };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user