diff --git a/index.js b/index.js index 64140dc..101fa5d 100644 --- a/index.js +++ b/index.js @@ -19,6 +19,7 @@ const scheduledPostsRoutes = require('./src/routes/scheduledPosts'); const channelStatsRoutes = require('./src/routes/channelStats'); const calendarRoutes = require('./src/routes/calendar'); const metricsRoutes = require('./src/routes/metrics'); +const usageRoutes = require('./src/routes/usage'); // Start queue worker require('./src/workers/generation'); @@ -46,6 +47,24 @@ app.use((req, res, next) => { next(); }); +// AI usage context — приклеивает к каждому запросу service + user_id, +// чтобы сервисы (ai.js, covers.js, postImages.js, articleAutoSeries.js) +// логировали расход без явного проброса параметров. +const aiContext = require('./src/lib/aiContext'); +app.use((req, res, next) => { + let service = 'zeropost-other'; + // Блог-сторона zeropost.ru: статьи, серии, авто-публикация, темы. + if (/^\/api\/(articles|autogen|series|notes|categories|stats|posts|scheduled-posts|generate)/.test(req.path)) { + service = 'zeropost-blog'; + // SaaS-сторона app.zeropost.ru: пользовательские посты, каналы, календарь, аналитика. + } else if (/^\/api\/(user-posts|calendar|channels|channel-stats|metrics|photo-search)/.test(req.path)) { + service = 'zeropost-tool'; + } + const userIdRaw = req.headers['x-user-id']; + const userId = userIdRaw ? parseInt(userIdRaw, 10) || null : null; + aiContext.run({ service, userId }, () => next()); +}); + app.use('/api/generate', generateRoutes); app.use('/api/channels', channelsRoutes); app.use('/api/posts', postsRoutes); @@ -62,6 +81,7 @@ app.use('/api/scheduled-posts', scheduledPostsRoutes); app.use('/api/channel-stats', channelStatsRoutes); app.use('/api/calendar', calendarRoutes); app.use('/api/metrics', metricsRoutes); +app.use('/api/usage', usageRoutes); app.get('/health', (req, res) => { res.json({ ok: true, service: 'zeropost-engine', time: new Date() }); @@ -69,6 +89,8 @@ app.get('/health', (req, res) => { const start = async () => { await migrate(); + await config.reloadAi(); + console.log('[Engine] AI config loaded from app_settings: text=' + config.ai.baseUrl + ', images=' + config.ai.imageBaseUrl); app.listen(config.port, () => { console.log(`[Engine] Running on port ${config.port}`); }); diff --git a/src/config/index.js b/src/config/index.js index fa44340..ff142ac 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -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; diff --git a/src/lib/aiContext.js b/src/lib/aiContext.js new file mode 100644 index 0000000..fa7fe1e --- /dev/null +++ b/src/lib/aiContext.js @@ -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 }; diff --git a/src/routes/settings.js b/src/routes/settings.js index 3eb1c3c..1c7faa8 100644 --- a/src/routes/settings.js +++ b/src/routes/settings.js @@ -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 }); }); diff --git a/src/routes/usage.js b/src/routes/usage.js new file mode 100644 index 0000000..fc16450 --- /dev/null +++ b/src/routes/usage.js @@ -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; diff --git a/src/services/ai.js b/src/services/ai.js index f0fff0e..8551a36 100644 --- a/src/services/ai.js +++ b/src/services/ai.js @@ -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; } diff --git a/src/services/aiUsage.js b/src/services/aiUsage.js new file mode 100644 index 0000000..0fbf3b3 --- /dev/null +++ b/src/services/aiUsage.js @@ -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 }; diff --git a/src/services/articleAutoSeries.js b/src/services/articleAutoSeries.js index df58be1..01eb6c7 100644 --- a/src/services/articleAutoSeries.js +++ b/src/services/articleAutoSeries.js @@ -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; } diff --git a/src/services/covers.js b/src/services/covers.js index 4fe2dab..62dff60 100644 --- a/src/services/covers.js +++ b/src/services/covers.js @@ -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' }; } /** diff --git a/src/services/postImages.js b/src/services/postImages.js index 6d281d1..b156d1f 100644 --- a/src/services/postImages.js +++ b/src/services/postImages.js @@ -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()}`;