forked from admin/zeropost-engine
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:
+68
-11
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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()}`;
|
||||
|
||||
Reference in New Issue
Block a user