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
+55 -7
View File
@@ -1,21 +1,30 @@
require('dotenv').config();
module.exports = {
// settings подгружаем лениво, чтобы избежать циклической зависимости
// (settings → config/db → config/index). require здесь синхронный, но settings.getMany async.
let _settingsSvc = null;
function settingsSvc() {
if (!_settingsSvc) _settingsSvc = require('../services/settings');
return _settingsSvc;
}
const config = {
port: parseInt(process.env.ZEROPOST_PORT || 3030),
// AI gateway (OpenAI-compatible: aiprimetech.io)
// AI gateway — значения мутируются reloadAi() из app_settings.
// Дефолты ниже — последний fallback, если БД и .env пустые.
ai: {
baseUrl: process.env.AI_BASE_URL || 'https://aiprimetech.io/v1',
apiKey: process.env.AI_API_KEY,
imageApiKey: process.env.AI_IMAGE_API_KEY || process.env.AI_API_KEY,
imageBaseUrl: process.env.AI_IMAGE_BASE_URL || process.env.AI_BASE_URL || 'https://aiprimetech.io/v1',
apiKey: process.env.AI_API_KEY || null,
imageApiKey: process.env.AI_IMAGE_API_KEY || process.env.AI_API_KEY || null,
imageBaseUrl: process.env.AI_IMAGE_BASE_URL || 'https://api.aiguoguo199.com/v1',
imageModel: process.env.AI_MODEL_IMAGE || 'gpt-image-1-mini',
// Per-task model selection — tune cost vs quality here
imageModelViaResponses: process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.5',
models: {
post: process.env.AI_MODEL_POST || 'claude-haiku-4-5-20251001',
article: process.env.AI_MODEL_ARTICLE || 'claude-sonnet-4-6',
topics: process.env.AI_MODEL_TOPICS || 'claude-haiku-4-5-20251001',
image: process.env.AI_MODEL_IMAGE || 'gpt-image-1',
image: process.env.AI_MODEL_IMAGE || 'gpt-image-1-mini',
},
},
@@ -32,3 +41,42 @@ module.exports = {
},
internalSecret: process.env.INTERNAL_SECRET || 'dev-secret-change-in-prod',
};
/**
* Перегружает блок ai из app_settings.
* Приоритет: app_settings.value → process.env[НОВОЕ_ИМЯ] → process.env[СТАРОЕ_ИМЯ] → дефолт.
* Старое имя ENV нужно для безопасной миграции — если БД пуста, .env держит прод на ногах.
* Мутирует config.ai в месте, чтобы все require'нувшие config продолжали видеть актуальные значения.
*/
async function reloadAi() {
const keys = [
'AI_TEXT_BASE_URL', 'AI_TEXT_API_KEY',
'AI_TEXT_MODEL_POST', 'AI_TEXT_MODEL_ARTICLE', 'AI_TEXT_MODEL_TOPICS',
'AI_IMAGE_BASE_URL', 'AI_IMAGE_API_KEY',
'AI_IMAGE_MODEL', 'AI_IMAGE_MODEL_VIA_RESPONSES',
];
let s = {};
try {
s = await settingsSvc().getMany(keys);
} catch (err) {
console.warn('[config.reloadAi] DB unavailable, using ENV fallback:', err.message);
}
const pick = (dbKey, envOld, def) =>
(s[dbKey] && s[dbKey].trim()) || process.env[dbKey] || process.env[envOld] || def;
config.ai.baseUrl = pick('AI_TEXT_BASE_URL', 'AI_BASE_URL', 'https://aiprimetech.io/v1');
config.ai.apiKey = pick('AI_TEXT_API_KEY', 'AI_API_KEY', null);
config.ai.imageBaseUrl= pick('AI_IMAGE_BASE_URL', 'AI_IMAGE_BASE_URL', 'https://api.aiguoguo199.com/v1');
config.ai.imageApiKey = pick('AI_IMAGE_API_KEY', 'AI_IMAGE_API_KEY', config.ai.apiKey);
config.ai.imageModel = pick('AI_IMAGE_MODEL', 'AI_MODEL_IMAGE', 'gpt-image-1-mini');
config.ai.imageModelViaResponses = pick('AI_IMAGE_MODEL_VIA_RESPONSES', 'AI_MODEL_IMAGE_VIA_RESPONSES', 'gpt-5.5');
config.ai.models.post = pick('AI_TEXT_MODEL_POST', 'AI_MODEL_POST', 'claude-haiku-4-5-20251001');
config.ai.models.article = pick('AI_TEXT_MODEL_ARTICLE', 'AI_MODEL_ARTICLE', 'claude-sonnet-4-6');
config.ai.models.topics = pick('AI_TEXT_MODEL_TOPICS', 'AI_MODEL_TOPICS', 'claude-haiku-4-5-20251001');
config.ai.models.image = pick('AI_IMAGE_MODEL', 'AI_MODEL_IMAGE', 'gpt-image-1-mini');
return config.ai;
}
config.reloadAi = reloadAi;
module.exports = config;
+32
View File
@@ -0,0 +1,32 @@
// Контекст вызова — пробрасывается через AsyncLocalStorage, чтобы
// сервисы (ai.js, covers.js, postImages.js, articleAutoSeries.js) могли
// логировать расход не получая лишних параметров.
//
// Заполняется middleware в index.js (на основе URL-префикса) либо
// явно из воркеров/cron: aiContext.run({ service: 'zeropost-blog', userId: 0 }, fn).
const { AsyncLocalStorage } = require('node:async_hooks');
const als = new AsyncLocalStorage();
function run(ctx, fn) {
return als.run({ ...ctx }, fn);
}
function get() {
return als.getStore() || { service: 'zeropost-unknown', userId: null };
}
function service() {
return get().service;
}
function userId() {
return get().userId;
}
// Удобно для воркеров: оборачивает функцию в run() с заданным контекстом.
function withContext(ctx, fn) {
return (...args) => als.run({ ...ctx }, () => fn(...args));
}
module.exports = { run, get, service, userId, withContext };
+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;
+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()}`;