forked from admin/zeropost-engine
refactor: single image provider — routerai gpt-5-image-mini only
- config: убраны imageBaseUrl/imageFallbackBaseUrl/imageModel (старые провайдеры) Остались только routeraiBaseUrl, routeraiApiKey, routeraiModel - covers.js: единственная цепочка routerai→retry→local SVG Убраны generateCoverViaImageGenerations, ViaResponses (aiprimetech), ViaImagesEndpoint generateCoverViaRouterAI: убран quality параметр (routerai игнорирует) - postImages.js: убраны Nyxos/Aiguoguo, убраны isHD/imgQuality/tryNyxos - aiUsage.js: реальные цены из статистики routerai.ru: gpt-5-image-mini ~₽2.72, всегда 4175 image tokens (high quality) - index.js: лог показывает routerai вместо старого aiguoguo
This commit is contained in:
@@ -90,7 +90,7 @@ app.get('/health', (req, res) => {
|
|||||||
const start = async () => {
|
const start = async () => {
|
||||||
await migrate();
|
await migrate();
|
||||||
await config.reloadAi();
|
await config.reloadAi();
|
||||||
console.log('[Engine] AI config loaded from app_settings: text=' + config.ai.baseUrl + ', images=' + config.ai.imageBaseUrl);
|
console.log('[Engine] AI config loaded from app_settings: text=' + config.ai.baseUrl + ', images=routerai.ru (' + (config.ai.routeraiModel || 'gpt-5-image-mini') + ')');
|
||||||
|
|
||||||
// Автоматический ретрай SVG-заглушек
|
// Автоматический ретрай SVG-заглушек
|
||||||
require('./src/services/coverRetry').start();
|
require('./src/services/coverRetry').start();
|
||||||
|
|||||||
+9
-12
@@ -64,21 +64,18 @@ async function reloadAi() {
|
|||||||
const pick = (dbKey, envOld, def) =>
|
const pick = (dbKey, envOld, def) =>
|
||||||
(s[dbKey] && s[dbKey].trim()) || process.env[dbKey] || process.env[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');
|
// Текст — aiprimetech.io
|
||||||
config.ai.apiKey = pick('AI_TEXT_API_KEY', 'AI_API_KEY', null);
|
config.ai.baseUrl = pick('AI_TEXT_BASE_URL', 'AI_BASE_URL', 'https://aiprimetech.io/v1');
|
||||||
config.ai.imageBaseUrl= pick('AI_IMAGE_BASE_URL', 'AI_IMAGE_BASE_URL', 'https://plus.nyxos.workers.dev/v1');
|
config.ai.apiKey = pick('AI_TEXT_API_KEY', 'AI_API_KEY', null);
|
||||||
config.ai.imageApiKey = pick('AI_IMAGE_API_KEY', 'AI_IMAGE_API_KEY', config.ai.apiKey);
|
|
||||||
config.ai.imageFallbackBaseUrl = (s['AI_IMAGE_FALLBACK_BASE_URL'] && s['AI_IMAGE_FALLBACK_BASE_URL'].trim()) || 'https://api.aiguoguo199.com/v1';
|
|
||||||
config.ai.imageFallbackApiKey = (s['AI_IMAGE_FALLBACK_API_KEY'] && s['AI_IMAGE_FALLBACK_API_KEY'].trim()) || config.ai.imageApiKey;
|
|
||||||
config.ai.routeraiBaseUrl = (s['ROUTERAI_BASE_URL'] || 'https://routerai.ru/api/v1').trim();
|
|
||||||
config.ai.routeraiApiKey = (s['ROUTERAI_API_KEY'] || '').trim() || null;
|
|
||||||
config.ai.routeraiImageModel = (s['ROUTERAI_IMAGE_MODEL'] || 'openai/gpt-5-image-mini').trim();
|
|
||||||
config.ai.imageModel = pick('AI_IMAGE_MODEL', 'AI_MODEL_IMAGE', 'gpt-image-2');
|
|
||||||
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.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.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.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');
|
|
||||||
|
// Картинки — routerai.ru (только gpt-5-image-mini, /responses endpoint)
|
||||||
|
// Реальная цена: ₽2.72/картинка (4175 image tokens × ₽0.000747 + input - output discount)
|
||||||
|
config.ai.routeraiBaseUrl = (s['ROUTERAI_BASE_URL'] || 'https://routerai.ru/api/v1').trim();
|
||||||
|
config.ai.routeraiApiKey = (s['ROUTERAI_API_KEY'] || '').trim() || null;
|
||||||
|
config.ai.routeraiModel = 'openai/gpt-5-image-mini'; // единственная модель, quality всегда high
|
||||||
|
|
||||||
return config.ai;
|
return config.ai;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-12
@@ -38,15 +38,14 @@ const IMAGE_PRICES_USD = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Цены routerai уже в рублях (RUB/token за image_output).
|
// Цены routerai уже в рублях (RUB/token за image_output).
|
||||||
// Считаем per-image по кол-ву токенов в зависимости от модели.
|
// Из реальной статистики: image_tokens = 4175 для всех моделей (всегда high quality)
|
||||||
// Токенов: low≈272, medium≈1056, high≈4160 для 1024×1024.
|
// gpt-5-image-mini: 4175 × ₽0.000747 = ₽3.12 + input (~₽0.38) - output discount (~₽0.77) = ₽2.72
|
||||||
const ROUTERAI_IMAGE_RUB_PER_TOKEN = {
|
const ROUTERAI_IMAGE_RUB_PER_TOKEN = {
|
||||||
'openai/gpt-5-image-mini': 0.000747,
|
'openai/gpt-5-image-mini': 0.000747, // ≈ ₽2.72/картинку итого
|
||||||
'openai/gpt-5.4-image-2': 0.002800,
|
'openai/gpt-5.4-image-2': 0.002800, // ≈ ₽12.99/картинку итого
|
||||||
'openai/gpt-5-image': 0.003733,
|
'openai/gpt-5-image': 0.003733, // ≈ ₽17.66/картинку итого
|
||||||
};
|
};
|
||||||
// Дефолтное кол-во токенов: low (standard) и medium (hd)
|
const ROUTERAI_IMAGE_TOKENS = 4175; // реальное значение, quality param не работает
|
||||||
const ROUTERAI_TOKENS = { low: 272, medium: 1056, high: 4160, standard: 272, hd: 1056 };
|
|
||||||
|
|
||||||
function providerFromBaseUrl(url) {
|
function providerFromBaseUrl(url) {
|
||||||
if (!url) return 'unknown';
|
if (!url) return 'unknown';
|
||||||
@@ -72,13 +71,11 @@ async function computeCostRub({ requestType, model, promptTokens, completionToke
|
|||||||
return +(costUsd * markup * usdRubRate).toFixed(4);
|
return +(costUsd * markup * usdRubRate).toFixed(4);
|
||||||
}
|
}
|
||||||
if (requestType === 'image' || requestType === 'image_via_responses') {
|
if (requestType === 'image' || requestType === 'image_via_responses') {
|
||||||
// Routerai — цены в рублях, считаем по токенам
|
// Routerai — цены в рублях, считаем по image токенам
|
||||||
const routeraiRate = ROUTERAI_IMAGE_RUB_PER_TOKEN[model];
|
const routeraiRate = ROUTERAI_IMAGE_RUB_PER_TOKEN[model];
|
||||||
if (routeraiRate !== undefined) {
|
if (routeraiRate !== undefined) {
|
||||||
const quality = o?.quality || 'low'; // 'low' | 'medium' | 'hd' | 'standard'
|
const inputCost = ((o?.promptTokens || 0) / 1_000_000) * 233;
|
||||||
const tokens = ROUTERAI_TOKENS[quality] || 272;
|
return +(ROUTERAI_IMAGE_TOKENS * routeraiRate + inputCost).toFixed(4);
|
||||||
const inputCost = ((o?.promptTokens || 100) / 1_000_000) * 233; // ₽233/1M input токенов
|
|
||||||
return +(tokens * routeraiRate + inputCost).toFixed(4);
|
|
||||||
}
|
}
|
||||||
const perImage = IMAGE_PRICES_USD[model];
|
const perImage = IMAGE_PRICES_USD[model];
|
||||||
if (perImage === undefined) return null;
|
if (perImage === undefined) return null;
|
||||||
|
|||||||
+25
-36
@@ -271,19 +271,20 @@ async function generateCoverViaImagesEndpoint({ prompt }) {
|
|||||||
* Более стабильный чем /responses, поддерживает gpt-image-1-mini/gpt-image-2.
|
* Более стабильный чем /responses, поддерживает gpt-image-1-mini/gpt-image-2.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* RouterAI — стабильный провайдер через /responses + image_generation tool.
|
* RouterAI /responses + gpt-5-image-mini.
|
||||||
* quality: 'low' для постов TG (₽0.25), 'medium' для обложек/VK (₽0.84)
|
* Единственный провайдер картинок. Цена: ~₽2.72/картинка.
|
||||||
|
* quality параметр routerai игнорирует — всегда high (4175 image tokens).
|
||||||
*/
|
*/
|
||||||
async function generateCoverViaRouterAI({ prompt, quality = 'medium' }) {
|
async function generateCoverViaRouterAI({ prompt }) {
|
||||||
const base = config.ai.routeraiBaseUrl;
|
const base = config.ai.routeraiBaseUrl;
|
||||||
const key = config.ai.routeraiApiKey;
|
const key = config.ai.routeraiApiKey;
|
||||||
const model = config.ai.routeraiImageModel || 'openai/gpt-5-image-mini';
|
const model = config.ai.routeraiModel || 'openai/gpt-5-image-mini';
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await axios.post(`${base}/responses`, {
|
const res = await axios.post(`${base}/responses`, {
|
||||||
model,
|
model,
|
||||||
input: `Use the image_generation tool to create this illustration. Only call the tool, no text.\n\n${prompt.slice(0, 3000)}`,
|
input: `Use the image_generation tool to create this illustration. Only call the tool, no text.\n\n${prompt.slice(0, 3000)}`,
|
||||||
tools: [{ type: 'image_generation', quality }],
|
tools: [{ type: 'image_generation' }],
|
||||||
tool_choice: { type: 'image_generation' },
|
tool_choice: { type: 'image_generation' },
|
||||||
}, {
|
}, {
|
||||||
headers: { Authorization: `Bearer ${key}` },
|
headers: { Authorization: `Bearer ${key}` },
|
||||||
@@ -430,43 +431,31 @@ async function generateCover({ articleId, title, tags = [], channelId = null })
|
|||||||
const prompt = buildCoverPrompt({ title, tags, articleId, channelStyle, rubric: selectedRubric });
|
const prompt = buildCoverPrompt({ title, tags, articleId, channelStyle, rubric: selectedRubric });
|
||||||
|
|
||||||
let img;
|
let img;
|
||||||
let usedPath = 'images-generations';
|
|
||||||
|
|
||||||
// Цепочка: 1) aiguoguo /images/generations (2 попытки) → 2) aiprimetech /responses → 3) legacy → 4) local SVG
|
// Единственный провайдер картинок: routerai /responses + gpt-5-image-mini
|
||||||
|
// Цена: ~₽2.72/картинка (4175 image tokens, high quality, quality param не работает)
|
||||||
try {
|
try {
|
||||||
try {
|
img = await generateCoverViaRouterAI({ prompt });
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.response?.status;
|
||||||
|
// Ретрай при 5xx
|
||||||
|
if (!status || (status >= 500 && status < 600)) {
|
||||||
|
console.warn(`[Cover] routerai attempt 1 failed (${status||'timeout'}), retry in 10s...`);
|
||||||
|
await new Promise(r => setTimeout(r, 10_000));
|
||||||
try {
|
try {
|
||||||
img = await generateCoverViaImageGenerations({ prompt });
|
img = await generateCoverViaRouterAI({ prompt });
|
||||||
} catch (err) {
|
|
||||||
// Ретрай только при временных ошибках провайдера (5xx)
|
|
||||||
const status = err.response?.status;
|
|
||||||
if (status >= 500 && status < 600) {
|
|
||||||
console.warn(`[Cover] /images/generations ${status} — retry in 12s...`);
|
|
||||||
await new Promise(r => setTimeout(r, 12000));
|
|
||||||
img = await generateCoverViaImageGenerations({ prompt });
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[Cover] /images/generations failed: ${(err.response?.data?.error?.message || err.message).slice(0, 150)}`);
|
|
||||||
try {
|
|
||||||
img = await generateCoverViaResponses({ prompt });
|
|
||||||
usedPath = 'responses';
|
|
||||||
} catch (err2) {
|
} catch (err2) {
|
||||||
console.warn(`[Cover] /responses failed: ${(err2.response?.data?.error?.message || err2.message).slice(0, 150)}`);
|
console.warn(`[Cover] routerai attempt 2 failed: ${(err2.response?.data?.error?.message || err2.message).slice(0, 150)}`);
|
||||||
try {
|
img = null;
|
||||||
img = await generateCoverViaImagesEndpoint({ prompt });
|
|
||||||
usedPath = 'images-legacy';
|
|
||||||
} catch (err3) {
|
|
||||||
console.warn(`[Cover] legacy failed: ${(err3.response?.data?.error?.message || err3.message).slice(0, 150)}`);
|
|
||||||
throw new Error('all_external_failed');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`[Cover] routerai failed (${status}): ${(err.response?.data?.error?.message || err.message).slice(0, 150)}`);
|
||||||
|
img = null;
|
||||||
}
|
}
|
||||||
} catch (outerErr) {
|
}
|
||||||
// Все внешние API упали — local SVG
|
if (!img) {
|
||||||
console.log(`[Cover] article=${articleId} → local SVG (all external APIs unavailable)`);
|
// routerai недоступен — fallback на local SVG
|
||||||
|
console.log(`[Cover] article=${articleId} → local SVG (routerai unavailable)`);
|
||||||
const localUrl = await localGen.generateLocalCover({ articleId, title, category: tags?.[0] || '' });
|
const localUrl = await localGen.generateLocalCover({ articleId, title, category: tags?.[0] || '' });
|
||||||
await query('UPDATE articles SET cover_url=$1, updated_at=NOW() WHERE id=$2', [localUrl, articleId]);
|
await query('UPDATE articles SET cover_url=$1, updated_at=NOW() WHERE id=$2', [localUrl, articleId]);
|
||||||
return localUrl;
|
return localUrl;
|
||||||
|
|||||||
+12
-36
@@ -85,62 +85,38 @@ ${style.image_prompt_instructions ? `\nChannel visual guidelines: ${style.image_
|
|||||||
Composition: 16:9 wide format, balanced, suitable for social media.
|
Composition: 16:9 wide format, balanced, suitable for social media.
|
||||||
Strictly: no text, no letters, no logos, no faces of real people.`;
|
Strictly: no text, no letters, no logos, no faces of real people.`;
|
||||||
|
|
||||||
// RouterAI /responses (primary) → Nyxos /images/generations (fallback)
|
// Единственный провайдер: routerai /responses + gpt-5-image-mini
|
||||||
// standard: gpt-5-image-mini + low quality (₽0.20/картинка)
|
// Цена: ~₽2.72/картинка. quality параметр не работает, всегда high.
|
||||||
// hd: gpt-5.4-image-2 + medium quality (₽2.96/картинка) — для текста на картинках
|
const model = config.ai.routeraiModel || 'openai/gpt-5-image-mini';
|
||||||
const isHD = channel.image_quality === 'hd';
|
|
||||||
const model = isHD
|
|
||||||
? 'openai/gpt-5.4-image-2'
|
|
||||||
: (config.ai.routeraiImageModel || 'openai/gpt-5-image-mini');
|
|
||||||
const imgQuality = isHD ? 'medium' : 'low';
|
|
||||||
|
|
||||||
async function tryRouterAI() {
|
async function generateViaRouterAI() {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await axios.post(`${config.ai.routeraiBaseUrl}/responses`, {
|
const res = await axios.post(`${config.ai.routeraiBaseUrl}/responses`, {
|
||||||
model,
|
model,
|
||||||
input: `Use the image_generation tool to create this illustration. Only call the tool, no text.\n\n${prompt.slice(0, 3000)}`,
|
input: `Use the image_generation tool to create this illustration. Only call the tool, no text.\n\n${prompt.slice(0, 3000)}`,
|
||||||
tools: [{ type: 'image_generation', quality: imgQuality }],
|
tools: [{ type: 'image_generation' }],
|
||||||
tool_choice: { type: 'image_generation' },
|
tool_choice: { type: 'image_generation' },
|
||||||
}, { headers: { Authorization: `Bearer ${config.ai.routeraiApiKey}` }, timeout: 120_000 });
|
}, { headers: { Authorization: `Bearer ${config.ai.routeraiApiKey}` }, timeout: 120_000 });
|
||||||
const imgCall = (res.data?.output || []).find(o => o.type === 'image_generation_call');
|
const imgCall = (res.data?.output || []).find(o => o.type === 'image_generation_call');
|
||||||
if (!imgCall?.result) throw new Error('No image in RouterAI response');
|
if (!imgCall?.result) throw new Error('No image in RouterAI response');
|
||||||
aiUsage.log({ provider: 'routerai', requestType: 'image_via_responses', model, quality: imgQuality, imageCount: 1, meta: { channel_id: channel.id }, durationMs: Date.now()-started, succeeded: true }).catch(() => {});
|
aiUsage.log({ provider: 'routerai', requestType: 'image_via_responses', model, imageCount: 1, meta: { channel_id: channel.id }, durationMs: Date.now()-started, succeeded: true }).catch(() => {});
|
||||||
return Buffer.from(imgCall.result, 'base64');
|
return Buffer.from(imgCall.result, 'base64');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
aiUsage.log({ provider: 'routerai', requestType: 'image_via_responses', model, quality: imgQuality, 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(() => {});
|
aiUsage.log({ provider: 'routerai', 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tryNyxos() {
|
|
||||||
const m = config.ai.imageModel || 'gpt-image-2';
|
|
||||||
const started = Date.now();
|
|
||||||
try {
|
|
||||||
const res = await axios.post(`${config.ai.imageFallbackBaseUrl}/images/generations`,
|
|
||||||
{ model: m, prompt: prompt.slice(0, 4000), n: 1, size: '1024x1024', response_format: 'url' },
|
|
||||||
{ headers: { Authorization: `Bearer ${config.ai.imageFallbackApiKey}` }, timeout: 90_000 }
|
|
||||||
);
|
|
||||||
const item = res.data?.data?.[0];
|
|
||||||
if (!item) throw new Error('No image data');
|
|
||||||
aiUsage.log({ provider: 'nyxos', requestType: 'image', model: m, imageCount: 1, meta: { channel_id: channel.id }, succeeded: true }).catch(() => {});
|
|
||||||
if (item.url) { const r = await axios.get(item.url, { responseType: 'arraybuffer', timeout: 60_000 }); return Buffer.from(r.data); }
|
|
||||||
if (item.b64_json) return Buffer.from(item.b64_json, 'base64');
|
|
||||||
throw new Error('No url or b64_json');
|
|
||||||
} catch (err) {
|
|
||||||
aiUsage.log({ provider: 'nyxos', requestType: 'image', model: m, imageCount: 1, meta: { channel_id: channel.id }, succeeded: false, errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500) }).catch(() => {});
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let bytes;
|
let bytes;
|
||||||
try {
|
try {
|
||||||
bytes = await tryRouterAI();
|
bytes = await generateViaRouterAI();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status = err.response?.status;
|
const status = err.response?.status;
|
||||||
if (!status || status >= 500) {
|
if (!status || (status >= 500 && status < 600)) {
|
||||||
console.warn('[postImages] RouterAI failed, trying Nyxos fallback...');
|
console.warn(`[postImages] routerai attempt 1 failed (${status||'timeout'}), retry in 10s...`);
|
||||||
bytes = await tryNyxos();
|
await new Promise(r => setTimeout(r, 10_000));
|
||||||
|
bytes = await generateViaRouterAI(); // бросит если снова упадёт
|
||||||
} else { throw err; }
|
} else { throw err; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user