diff --git a/index.js b/index.js index 31018ac..889e703 100644 --- a/index.js +++ b/index.js @@ -90,7 +90,7 @@ 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); + console.log('[Engine] AI config loaded from app_settings: text=' + config.ai.baseUrl + ', images=routerai.ru (' + (config.ai.routeraiModel || 'gpt-5-image-mini') + ')'); // Автоматический ретрай SVG-заглушек require('./src/services/coverRetry').start(); diff --git a/src/config/index.js b/src/config/index.js index f1c230d..9197f24 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -64,21 +64,18 @@ async function reloadAi() { 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://plus.nyxos.workers.dev/v1'); - 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'); + // Текст — aiprimetech.io + 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.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'); + + // Картинки — 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; } diff --git a/src/services/aiUsage.js b/src/services/aiUsage.js index 99cac8c..0423875 100644 --- a/src/services/aiUsage.js +++ b/src/services/aiUsage.js @@ -38,15 +38,14 @@ const IMAGE_PRICES_USD = { }; // Цены routerai уже в рублях (RUB/token за image_output). -// Считаем per-image по кол-ву токенов в зависимости от модели. -// Токенов: low≈272, medium≈1056, high≈4160 для 1024×1024. +// Из реальной статистики: image_tokens = 4175 для всех моделей (всегда high quality) +// gpt-5-image-mini: 4175 × ₽0.000747 = ₽3.12 + input (~₽0.38) - output discount (~₽0.77) = ₽2.72 const ROUTERAI_IMAGE_RUB_PER_TOKEN = { - 'openai/gpt-5-image-mini': 0.000747, - 'openai/gpt-5.4-image-2': 0.002800, - 'openai/gpt-5-image': 0.003733, + 'openai/gpt-5-image-mini': 0.000747, // ≈ ₽2.72/картинку итого + 'openai/gpt-5.4-image-2': 0.002800, // ≈ ₽12.99/картинку итого + 'openai/gpt-5-image': 0.003733, // ≈ ₽17.66/картинку итого }; -// Дефолтное кол-во токенов: low (standard) и medium (hd) -const ROUTERAI_TOKENS = { low: 272, medium: 1056, high: 4160, standard: 272, hd: 1056 }; +const ROUTERAI_IMAGE_TOKENS = 4175; // реальное значение, quality param не работает function providerFromBaseUrl(url) { if (!url) return 'unknown'; @@ -72,13 +71,11 @@ async function computeCostRub({ requestType, model, promptTokens, completionToke return +(costUsd * markup * usdRubRate).toFixed(4); } if (requestType === 'image' || requestType === 'image_via_responses') { - // Routerai — цены в рублях, считаем по токенам + // Routerai — цены в рублях, считаем по image токенам const routeraiRate = ROUTERAI_IMAGE_RUB_PER_TOKEN[model]; if (routeraiRate !== undefined) { - const quality = o?.quality || 'low'; // 'low' | 'medium' | 'hd' | 'standard' - const tokens = ROUTERAI_TOKENS[quality] || 272; - const inputCost = ((o?.promptTokens || 100) / 1_000_000) * 233; // ₽233/1M input токенов - return +(tokens * routeraiRate + inputCost).toFixed(4); + const inputCost = ((o?.promptTokens || 0) / 1_000_000) * 233; + return +(ROUTERAI_IMAGE_TOKENS * routeraiRate + inputCost).toFixed(4); } const perImage = IMAGE_PRICES_USD[model]; if (perImage === undefined) return null; diff --git a/src/services/covers.js b/src/services/covers.js index caf26fb..0456d68 100644 --- a/src/services/covers.js +++ b/src/services/covers.js @@ -271,19 +271,20 @@ async function generateCoverViaImagesEndpoint({ prompt }) { * Более стабильный чем /responses, поддерживает gpt-image-1-mini/gpt-image-2. */ /** - * RouterAI — стабильный провайдер через /responses + image_generation tool. - * quality: 'low' для постов TG (₽0.25), 'medium' для обложек/VK (₽0.84) + * RouterAI /responses + gpt-5-image-mini. + * Единственный провайдер картинок. Цена: ~₽2.72/картинка. + * quality параметр routerai игнорирует — всегда high (4175 image tokens). */ -async function generateCoverViaRouterAI({ prompt, quality = 'medium' }) { +async function generateCoverViaRouterAI({ prompt }) { const base = config.ai.routeraiBaseUrl; 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(); try { const res = await axios.post(`${base}/responses`, { model, 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' }, }, { 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 }); 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 { + 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 { - img = await generateCoverViaImageGenerations({ 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'; + img = await generateCoverViaRouterAI({ prompt }); } catch (err2) { - console.warn(`[Cover] /responses failed: ${(err2.response?.data?.error?.message || err2.message).slice(0, 150)}`); - try { - 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'); - } + console.warn(`[Cover] routerai attempt 2 failed: ${(err2.response?.data?.error?.message || err2.message).slice(0, 150)}`); + img = null; } + } else { + console.warn(`[Cover] routerai failed (${status}): ${(err.response?.data?.error?.message || err.message).slice(0, 150)}`); + img = null; } - } catch (outerErr) { - // Все внешние API упали — local SVG - console.log(`[Cover] article=${articleId} → local SVG (all external APIs unavailable)`); + } + if (!img) { + // routerai недоступен — fallback на local SVG + console.log(`[Cover] article=${articleId} → local SVG (routerai unavailable)`); 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]); return localUrl; diff --git a/src/services/postImages.js b/src/services/postImages.js index 96c1fb7..65d1320 100644 --- a/src/services/postImages.js +++ b/src/services/postImages.js @@ -85,62 +85,38 @@ ${style.image_prompt_instructions ? `\nChannel visual guidelines: ${style.image_ Composition: 16:9 wide format, balanced, suitable for social media. Strictly: no text, no letters, no logos, no faces of real people.`; - // RouterAI /responses (primary) → Nyxos /images/generations (fallback) - // standard: gpt-5-image-mini + low quality (₽0.20/картинка) - // hd: gpt-5.4-image-2 + medium quality (₽2.96/картинка) — для текста на картинках - 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'; + // Единственный провайдер: routerai /responses + gpt-5-image-mini + // Цена: ~₽2.72/картинка. quality параметр не работает, всегда high. + const model = config.ai.routeraiModel || 'openai/gpt-5-image-mini'; - async function tryRouterAI() { + async function generateViaRouterAI() { const started = Date.now(); try { const res = await axios.post(`${config.ai.routeraiBaseUrl}/responses`, { model, 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' }, }, { headers: { Authorization: `Bearer ${config.ai.routeraiApiKey}` }, timeout: 120_000 }); const imgCall = (res.data?.output || []).find(o => o.type === 'image_generation_call'); 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'); } 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(() => {}); - 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(() => {}); + 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; } } let bytes; try { - bytes = await tryRouterAI(); + bytes = await generateViaRouterAI(); } catch (err) { const status = err.response?.status; - if (!status || status >= 500) { - console.warn('[postImages] RouterAI failed, trying Nyxos fallback...'); - bytes = await tryNyxos(); + if (!status || (status >= 500 && status < 600)) { + console.warn(`[postImages] routerai attempt 1 failed (${status||'timeout'}), retry in 10s...`); + await new Promise(r => setTimeout(r, 10_000)); + bytes = await generateViaRouterAI(); // бросит если снова упадёт } else { throw err; } }