/** * Chutes Image App - Express Server * Features: * - Static hosting * - /api/models: fetch remote models (if MODELS_URL) or fallback to local data/models.json * - /api/generate: proxy to chutes generate API with robust payload strategy and error handling * - Security (helmet), CORS, compression, logging, timeouts */ require('dotenv').config(); const express = require('express'); const axios = require('axios'); const helmet = require('helmet'); const cors = require('cors'); const compression = require('compression'); const morgan = require('morgan'); const fs = require('fs'); const path = require('path'); const { createAxiosLogger } = require('./utils/api-logger'); const app = express(); // Env const PORT = parseInt(process.env.PORT || '3000', 10); const HOST = process.env.HOST || '0.0.0.0'; const STATIC_DIR = process.env.STATIC_DIR || 'public'; const GENERATE_API_URL = process.env.GENERATE_API_URL || 'https://image.chutes.ai/generate'; const MODELS_URL = process.env.MODELS_URL || ''; const CHUTES_API_TOKEN = process.env.CHUTES_API_TOKEN || ''; const MOCK_MODE = /^true$/i.test(process.env.MOCK_MODE || 'false'); const TIMEOUT_MS = parseInt(process.env.TIMEOUT_MS || '120000', 10); const LOG_LEVEL = process.env.LOG_LEVEL || 'dev'; // Allow end-user to optionally provide API key from frontend (header x-api-key) const ALLOW_CLIENT_API_KEY = /^true$/i.test(process.env.ALLOW_CLIENT_API_KEY || 'true'); // Strict switches to close any possibility of routing/fallback // - STRICT_NO_ROUTING=true 不做本地映射,除非前端或调用方显式传 upstream_id // - STRICT_NO_FALLBACK=true 强制禁用回落(即使前端未设置 no_fallback) const STRICT_NO_ROUTING = /^true$/i.test(process.env.STRICT_NO_ROUTING || 'false'); const STRICT_NO_FALLBACK = /^true$/i.test(process.env.STRICT_NO_FALLBACK || 'true'); // Auto fallback to another model when upstream capacity/infrastructure errors const AUTO_FALLBACK = /^true$/i.test(process.env.AUTO_FALLBACK || 'true'); // Retry strategy for transient upstream errors const RETRIES = parseInt(process.env.RETRIES || '2', 10); const RETRY_BASE_MS = parseInt(process.env.RETRY_BASE_MS || '800', 10); // Upstream auth mode: '' or 'x-api-key' (default: Authorization Bearer) const UPSTREAM_AUTH_MODE = process.env.UPSTREAM_AUTH_MODE || ''; // Force sending minimal payload (only prompt) to upstream (default false) const FORCE_MINIMAL = /^true$/i.test(process.env.FORCE_MINIMAL || 'false'); // Middlewares app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' }, // Allow embedding on Hugging Face Spaces (disable X-Frame-Options) frameguard: false, // Avoid COOP/COEP blocking when embedded in an iframe crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' }, crossOriginEmbedderPolicy: false, originAgentCluster: false })); // Enable CSP and allow inline script/style for this SPA. // Also allow connections to the upstream image API. // Allow embedding inside Hugging Face Spaces iframe via frame-ancestors/frame-src app.use(helmet.contentSecurityPolicy({ useDefaults: true, directives: { "default-src": ["'self'"], // Allow SPA inline scripts but forbid inline event attributes per CSP3 // Allow SPA inline scripts but forbid inline event attributes per CSP3 "script-src": ["'self'", "'unsafe-inline'"], "script-src-attr": ["'none'"], // Permit external stylesheet from Baomitu CDN via style-src-elem "script-src-attr": ["'none'"], // Permit external stylesheet from Baomitu CDN via style-src-elem "style-src": ["'self'", "'unsafe-inline'"], "style-src-elem": ["'self'", "'unsafe-inline'", "https://lib.baomitu.com"], "style-src-elem": ["'self'", "'unsafe-inline'", "https://lib.baomitu.com"], "img-src": ["'self'", "data:", "blob:"], // Allow Baomitu icon fonts "font-src": ["'self'", "data:", "https://lib.baomitu.com"], // Allow CSS sourcemap requests to Baomitu CDN "connect-src": ["'self'", "https://image.chutes.ai", "https://lib.baomitu.com"], // Allow Baomitu icon fonts "font-src": ["'self'", "data:", "https://lib.baomitu.com"], // Allow CSS sourcemap requests to Baomitu CDN "connect-src": ["'self'", "https://image.chutes.ai", "https://lib.baomitu.com"], "media-src": ["'self'", "data:", "blob:"], "frame-src": ["'self'", "https://huggingface.co", "https://*.huggingface.co", "https://*.hf.space"], "frame-ancestors": ["'self'", "https://huggingface.co", "https://*.huggingface.co", "https://*.hf.space"] } })); // Ensure no legacy X-Frame-Options header blocks embedding app.use((req, res, next) => { res.removeHeader('X-Frame-Options'); next(); }); app.use(cors()); app.use(compression()); app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ limit: '50mb', extended: true })); app.use(morgan(LOG_LEVEL)); // Static files const staticPath = path.resolve(__dirname, STATIC_DIR); app.use(express.static(staticPath, { etag: true, lastModified: true, maxAge: '1h', setHeaders: (res, filePath) => { if (/\.(html)$/.test(filePath)) { res.setHeader('Cache-Control', 'no-cache'); } } })); // Expose studio assets (notification sound) const studioPath = path.resolve(__dirname, 'studio'); app.use('/studio', express.static(studioPath, { etag: true, lastModified: true, maxAge: '1h' })); // Utilities const localModelsPath = path.resolve(__dirname, 'data', 'models.json'); /** * Read local models fallback file. * @returns {Promise} */ async function readLocalModels() { try { const raw = await fs.promises.readFile(localModelsPath, 'utf-8'); const data = JSON.parse(raw); if (Array.isArray(data)) return data; if (Array.isArray(data.models)) return data.models; return []; } catch (err) { console.error('Failed to read local models:', err.message); return []; } } /** * Fetch remote models from MODELS_URL if provided. * Expected to return either Array or { models: [] } * @param {string} tokenOverride optional API token per-request * @returns {Promise} */ // 创建用于获取模型列表的axios实例并启用日志记录 const axiosModels = axios.create({ timeout: Math.min(TIMEOUT_MS, 30000), validateStatus: () => true }); createAxiosLogger(axiosModels); async function fetchRemoteModels(tokenOverride = '') { if (!MODELS_URL) return null; try { const resp = await axiosModels.get(MODELS_URL, { headers: { 'Content-Type': 'application/json', ...(tokenOverride ? { 'Authorization': `Bearer ${tokenOverride}` } : (CHUTES_API_TOKEN ? { 'Authorization': `Bearer ${CHUTES_API_TOKEN}` } : {})) } }); const payload = resp.data; if (Array.isArray(payload)) return payload; if (payload && Array.isArray(payload.models)) return payload.models; return null; } catch (err) { console.error('Failed to fetch remote models:', err.message); return null; } } /** * Merge "free" flags from local list into remote list by model id or name */ function mergeFreeFlags(remoteList, localList) { const freeMap = new Map(); for (const m of localList) { const key = (m.id || m.name || '').toLowerCase(); if (key) freeMap.set(key, !!m.free); } return remoteList.map(m => { const key = (m.id || m.name || '').toLowerCase(); const free = freeMap.has(key) ? freeMap.get(key) : (typeof m.free === 'boolean' ? m.free : false); return { ...m, free }; }); } /** * Clamp helper */ function clamp(n, min, max) { if (typeof n !== 'number' || Number.isNaN(n)) return min; return Math.max(min, Math.min(max, n)); } /** * Axios instance for generate */ const axiosGen = axios.create({ timeout: TIMEOUT_MS, responseType: 'arraybuffer', // for image/jpeg validateStatus: () => true }); // 启用API日志记录 createAxiosLogger(axiosGen); function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // Routes app.get('/api/health', (req, res) => { res.json({ ok: true, mock: MOCK_MODE, version: '1.0.0', allowClientKey: ALLOW_CLIENT_API_KEY }); }); /** * GET /api/models * 1) Try remote MODELS_URL * 2) Fallback local data/models.json * 3) Ensure each model has: { id, name, free } */ app.get('/api/models', async (req, res) => { try { const localList = await readLocalModels(); const apiToken = req.headers['x-api-key'] || CHUTES_API_TOKEN; let models = await fetchRemoteModels(apiToken); if (models && models.length) { // Normalize remote items models = models.map((m, idx) => { const id = (m.id || m.slug || m.model || m.name || `model-${idx}`).toString(); const name = (m.name || id).toString(); const free = typeof m.free === 'boolean' ? m.free : false; return { id, name, free }; }); // Merge free flags and default_params from local mapping if (localList.length) { // Merge free flags for models that exist remotely models = mergeFreeFlags(models, localList); // Also merge default_params from local config const localMap = new Map(); for (const lm of localList) { const key = ((lm.id || lm.name || '') + '').toLowerCase(); if (key) localMap.set(key, lm); } models = models.map(m => { const key = ((m.id || m.name || '') + '').toLowerCase(); const local = localMap.get(key); if (local && local.default_params) { return { ...m, default_params: local.default_params }; } return m; }); // Also append local-only models (union), so new models in local config are visible in frontend const remoteKeys = new Set(models.map(m => ((m.id || m.name || '') + '').toLowerCase())); for (const lm of localList) { const key = ((lm.id || lm.name || '') + '').toLowerCase(); if (key && !remoteKeys.has(key)) { const id = (lm.id || lm.name || '').toString(); const name = (lm.name || id).toString(); const free = !!lm.free; const model = { id, name, free }; if (lm.default_params) model.default_params = lm.default_params; models.push(model); remoteKeys.add(key); } } } return res.json({ source: 'remote', models }); } // Fallback to local const normalized = localList.map((m, idx) => { const id = (m.id || m.slug || m.model || m.name || `model-${idx}`).toString(); const name = (m.name || id).toString(); const free = !!m.free; const model = { id, name, free }; if (m.default_params) model.default_params = m.default_params; return model; }); return res.json({ source: 'local', models: normalized }); } catch (err) { console.error('GET /api/models failed:', err); res.status(500).json({ ok: false, error: 'Failed to load models' }); } }); /** * POST /api/generate * Body supports two shapes: * A) { model, input_args: { prompt, negative_prompt, width, height, guidance_scale, num_inference_steps, seed } } * B) { model, prompt, negative_prompt, width, height, guidance_scale, num_inference_steps, seed } * * Server will attempt upstream with A first, then fallback to B if A fails. * Returns JSON: { ok, image (base64 data URL), contentType, meta, tried } */ app.post('/api/generate', async (req, res) => { try { if (MOCK_MODE) { // Return a tiny transparent PNG as mock const pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMAAQAABQABDQottQAAAABJRU5ErkJggg=='; return res.json({ ok: true, image: `data:image/png;base64,${pngBase64}`, contentType: 'image/png', meta: { mock: true } }); } const body = req.body || {}; const flat = { model: (body.model || (body.input_args && body.input_args.model) || '').toString(), prompt: (body.prompt ?? (body.input_args ? body.input_args.prompt : undefined) ?? '').toString(), negative_prompt: (body.negative_prompt ?? (body.input_args ? body.input_args.negative_prompt : undefined) ?? '').toString(), width: clamp(parseInt(body.width ?? (body.input_args ? body.input_args.width : 1024), 10) || 1024, 128, 2048), height: clamp(parseInt(body.height ?? (body.input_args ? body.input_args.height : 1024), 10) || 1024, 128, 2048), guidance_scale: clamp((() => { const raw = body.guidance_scale ?? (body.input_args ? body.input_args.guidance_scale : undefined); const parsed = parseFloat(raw); return Number.isNaN(parsed) ? 7.5 : parsed; })(), 0, 20), guidance_scale: clamp((() => { const raw = body.guidance_scale ?? (body.input_args ? body.input_args.guidance_scale : undefined); const parsed = parseFloat(raw); return Number.isNaN(parsed) ? 7.5 : parsed; })(), 0, 20), num_inference_steps: clamp(parseInt(body.num_inference_steps ?? (body.input_args ? body.input_args.num_inference_steps : 25), 10) || 25, 1, 100), // Seed: support top-level or input_args.seed; if missing/null -> null (omit from payload) seed: (() => { const raw = (body.seed ?? (body.input_args ? body.input_args.seed : undefined)); if (raw === null || raw === undefined || raw === '') return null; const n = Number(raw); if (!Number.isFinite(n)) return null; const clamped = Math.max(0, Math.min(4294967295, Math.trunc(n))); return clamped; })() }; if (!flat.prompt || !flat.model) { return res.status(400).json({ ok: false, error: 'model and prompt are required' }); } // Resolve an upstream model id via local mapping, with optional client override. const localList = await readLocalModels(); function resolveUpstream(id) { const key = (id || '').toLowerCase(); for (const m of localList) { const mid = (m.id || '').toLowerCase(); const name = (m.name || '').toLowerCase(); if (mid === key || name === key) { if (m.upstream_id) return m.upstream_id; } } return id; } const overrideUpstream = (body.upstream_id ?? (body.input_args ? body.input_args.upstream_id : undefined)); let targetModel = (overrideUpstream && String(overrideUpstream).trim()) ? String(overrideUpstream).trim() : (STRICT_NO_ROUTING ? flat.model : resolveUpstream(flat.model)); // honor global strict no-fallback if enabled const NO_FALLBACK = STRICT_NO_FALLBACK || Boolean(body.no_fallback ?? (body.input_args ? body.input_args.no_fallback : false)); // Resolve additional upstream configuration from local model config function getModelConfig(list, idOrName) { const key = (idOrName || '').toLowerCase(); for (const m of list) { const mid = (m.id || '').toLowerCase(); const name = (m.name || '').toLowerCase(); if (mid === key || name === key) return m; } return null; } const cfg = getModelConfig(localList, flat.model); let generateUrl = (cfg && cfg.upstream_url) ? cfg.upstream_url : GENERATE_API_URL; let preferMinimal = FORCE_MINIMAL || !!(cfg && cfg.minimal === true); const isHidream = ( typeof flat.model === 'string' && flat.model.toLowerCase() === 'hidream' ) || /hidream/i.test(generateUrl) || /hidream/i.test(String(targetModel || '')); const isQwenEdit = ( typeof flat.model === 'string' && flat.model.toLowerCase() === 'qwen-image-edit' ) || /qwen-image-edit/i.test(generateUrl) || /qwen-image-edit/i.test(String(targetModel || '')); const isZImageTurbo = ( typeof flat.model === 'string' && flat.model.toLowerCase() === 'z-image-turbo' ) || /z-image-turbo/i.test(generateUrl) || /z-image-turbo/i.test(String(targetModel || '')); const apiToken = req.headers['x-api-key'] || CHUTES_API_TOKEN; const headers = { 'Content-Type': 'application/json', 'Accept': 'image/jpeg,application/octet-stream,application/json', ...(apiToken ? { 'Authorization': `Bearer ${apiToken}` } : {}) }; // hidream: 优先使用 models.json 的 upstream_url;否则使用 HIDREAM_UPSTREAM_URL;否则保持默认 GENERATE_API_URL if (isHidream) { const envUrl = process.env.HIDREAM_UPSTREAM_URL || ''; function isValidUrl(u) { try { new URL(u); return true; } catch { return false; } } if (cfg && cfg.upstream_url && isValidUrl(cfg.upstream_url)) { generateUrl = cfg.upstream_url; } else if (envUrl && isValidUrl(envUrl)) { generateUrl = envUrl; } } // Normalize special-case upstream ids that differ from local display ids // qwen-image-edit 在上游共用 qwen-image 的路由/标识 if (isQwenEdit && String(targetModel || '').toLowerCase() === 'qwen-image-edit') { targetModel = 'qwen-image'; } // Pass-through extras (e.g., image_b64s, true_cfg_scale, resolution, shift, max_sequence_length, etc.) const inputExtras = (body && typeof body.input_args === 'object') ? { ...body.input_args } : {}; if (isHidream) { // hidream 仅接受 resolution;确保存在并移除 width/height 噪声 if (!inputExtras.resolution) { inputExtras.resolution = `${flat.width}x${flat.height}`; } delete inputExtras.width; delete inputExtras.height; } if (!isQwenEdit) { // 非 qwen-image-edit 时不传参考图与 true_cfg_scale delete inputExtras.image_b64s; delete inputExtras.true_cfg_scale; } // Z-Image-Turbo 特殊参数处理 if (isZImageTurbo) { // 保留 shift 和 max_sequence_length 参数 if (inputExtras.shift === undefined) { inputExtras.shift = 3.0; // 默认值 } if (inputExtras.max_sequence_length === undefined) { inputExtras.max_sequence_length = 512; // 默认值 } } // Build top-level extras for flat payloads (some upstreams expect image_b64s/true_cfg_scale at top-level) const topLevelExtras = {}; const maybeImageB64s = (body && (body.image_b64s ?? (body.input_args && body.input_args.image_b64s))); const maybeTrueCfg = (body && (body.true_cfg_scale ?? (body.input_args && body.input_args.true_cfg_scale))); if (isQwenEdit) { if (maybeImageB64s) topLevelExtras.image_b64s = maybeImageB64s; if (maybeTrueCfg !== undefined && maybeTrueCfg !== null) topLevelExtras.true_cfg_scale = maybeTrueCfg; } // qwen-image-edit: 校验参考图数量(1-3) if (isQwenEdit) { const imgs = inputExtras.image_b64s || topLevelExtras.image_b64s; const validCount = Array.isArray(imgs) ? imgs.length : 0; if (validCount < 1 || validCount > 3) { return res.status(400).json({ ok: false, error: 'qwen-image-edit 需要 1-3 张参考图 (image_b64s)' }); } } const commonArgs = { prompt: flat.prompt, negative_prompt: flat.negative_prompt || '', guidance_scale: flat.guidance_scale, num_inference_steps: flat.num_inference_steps, ...(flat.seed !== null ? { seed: flat.seed } : {}) }; const variantA = { model: targetModel, input_args: { ...inputExtras, ...commonArgs, width: flat.width, height: flat.height } }; const variantB = { model: targetModel, ...topLevelExtras, ...commonArgs, width: flat.width, height: flat.height }; // Hidream-specific flat payload expected by chutes-hidream endpoint (no input_args) const variantHidreamFlat = isHidream ? { prompt: flat.prompt, resolution: (inputExtras && inputExtras.resolution) ? String(inputExtras.resolution) : `${flat.width}x${flat.height}`, guidance_scale: flat.guidance_scale, num_inference_steps: flat.num_inference_steps, ...(flat.seed !== null ? { seed: flat.seed } : {}) } : null; // Z-Image-Turbo payload with input_args wrapper (required by the endpoint) const variantZImageNested = isZImageTurbo ? { input_args: { prompt: flat.prompt, height: flat.height, width: flat.width, num_inference_steps: flat.num_inference_steps, guidance_scale: flat.guidance_scale, shift: (inputExtras && inputExtras.shift !== undefined) ? Number(inputExtras.shift) : 3.0, max_sequence_length: (inputExtras && inputExtras.max_sequence_length !== undefined) ? Number(inputExtras.max_sequence_length) : 512, ...(flat.seed !== null ? { seed: flat.seed } : {}) } } : null; // Z-Image-Turbo minimal payload (top-level prompt only, as shown in curl example) const variantZImageMinimal = isZImageTurbo ? { prompt: flat.prompt, height: flat.height, width: flat.width, num_inference_steps: flat.num_inference_steps, guidance_scale: flat.guidance_scale, shift: (inputExtras && inputExtras.shift !== undefined) ? Number(inputExtras.shift) : 3.0, max_sequence_length: (inputExtras && inputExtras.max_sequence_length !== undefined) ? Number(inputExtras.max_sequence_length) : 512, ...(flat.seed !== null ? { seed: flat.seed } : {}) } : null; // Minimal payload (some models reject extended fields). Include size/steps/seed for hunyuan-image-3 compatibility const variantCMinimal = { model: targetModel, input_args: { prompt: flat.prompt, size: `${flat.width}x${flat.height}`, steps: flat.num_inference_steps, ...(flat.seed !== null ? { seed: flat.seed } : {}) } }; // Flat minimal payload for model-specific upstreams that expect top-level { prompt } const variantFlatMinimal = { prompt: flat.prompt, size: `${flat.width}x${flat.height}`, steps: flat.num_inference_steps, ...(flat.seed !== null ? { seed: flat.seed } : {}) }; // Qwen-image-edit: official top-level flat payload (no model, includes refs) const variantQwenFlat = isQwenEdit ? { prompt: flat.prompt, negative_prompt: flat.negative_prompt || '', width: flat.width, height: flat.height, guidance_scale: flat.guidance_scale, num_inference_steps: flat.num_inference_steps, ...(flat.seed !== null ? { seed: flat.seed } : {}), ...(inputExtras && inputExtras.image_b64s ? { image_b64s: inputExtras.image_b64s } : (topLevelExtras.image_b64s ? { image_b64s: topLevelExtras.image_b64s } : {})), ...(inputExtras && (inputExtras.true_cfg_scale !== undefined && inputExtras.true_cfg_scale !== null) ? { true_cfg_scale: inputExtras.true_cfg_scale } : (topLevelExtras.true_cfg_scale !== undefined ? { true_cfg_scale: topLevelExtras.true_cfg_scale } : {})) } : null; // duplicate removed async function tryCall(payload, label, url) { const resp = await axiosGen.post(url, payload, { headers }); const ctype = (resp.headers && (resp.headers['content-type'] || resp.headers['Content-Type'])) || ''; const status = resp.status; // Success: image buffer if (status >= 200 && status < 300 && /image\//i.test(ctype)) { const base64 = Buffer.from(resp.data).toString('base64'); return { ok: true, imageBase64: base64, contentType: ctype, tried: label }; } // Success: JSON response that may contain base64 or data URL if (status >= 200 && status < 300 && /application\/json/i.test(ctype)) { let raw = ''; try { raw = Buffer.from(resp.data).toString(); } catch (e) {} try { const j = JSON.parse(raw || '{}'); // Common patterns: { image: "data:..."} or { image: "", contentType: "image/jpeg" } or { data: "" } if (j && j.image) { if (typeof j.image === 'string' && j.image.startsWith('data:')) { // Already data URL; extract base64 and contentType const match = /^data:([^;]+);base64,(.*)$/i.exec(j.image); if (match) { return { ok: true, imageBase64: match[2], contentType: match[1], tried: label }; } } else if (typeof j.image === 'string') { const ct = (j.contentType || 'image/jpeg'); return { ok: true, imageBase64: j.image, contentType: ct, tried: label }; } } if (j && j.data && typeof j.data === 'string') { const ct = (j.contentType || 'image/jpeg'); return { ok: true, imageBase64: j.data, contentType: ct, tried: label }; } } catch (e) { // fallthrough to error mapping below } } // Error handling branch (non-2xx or unrecognized payload) let raw = ''; try { raw = Buffer.from(resp.data).toString(); } catch (e) {} // Friendly diagnostics mapping let code = 'UPSTREAM_ERROR'; let hint = ''; let mappedStatus = status; let detailText = ''; try { const j = JSON.parse(raw); detailText = j && (j.detail || j.message || j.error || ''); } catch (e) { detailText = raw; } const lower = (detailText || '').toLowerCase(); if (lower.includes('exhausted all available targets')) { code = 'UPSTREAM_CAPACITY_EXHAUSTED'; hint = '上游容量不足(GPU/目标不可用或排队中),请稍后重试、换模型,或降低分辨率/步数。'; mappedStatus = 503; // service unavailable } else if (status === 404 && lower.includes('model not found')) { code = 'UPSTREAM_MODEL_NOT_FOUND'; hint = '上游模型不存在或标识不匹配。请更换模型,或在 data/models.json 为该模型添加正确的 \"upstream_id\" 映射后重试。'; } else if (status === 400 && (lower.includes('invalid request') || lower.includes('invalid input'))) { code = 'UPSTREAM_INVALID_PARAMS'; hint = '上游参数不接受:尝试使用最小输入(仅 prompt)重试。'; } const err = new Error(hint || `Upstream ${label} failed: ${status} ${ctype} ${raw || ''}`); err.status = mappedStatus; err.code = code; err.hint = hint; throw err; } let result; let lastError = null; const isHunyuan = (typeof flat.model === 'string' && flat.model.toLowerCase() === 'hunyuan-image-3') || /hunyuan-image-3/i.test(generateUrl) || /hunyuan-image-3/i.test(String(targetModel || '')); // Model-specific payloads FIRST - try the correct format before generic fallbacks if (isHidream) { // HiDream: uses resolution instead of width/height try { result = await tryCall(variantHidreamFlat, 'hidream-flat', generateUrl); } catch (eH) { lastError = eH; } } else if (isZImageTurbo) { // Z-Image-Turbo: try minimal payload first (as shown in curl example) try { result = await tryCall(variantZImageMinimal, 'z-image-turbo-minimal', generateUrl); } catch (e0) { lastError = e0; } // Fallback to nested payload if (!result && variantZImageNested) { try { result = await tryCall(variantZImageNested, 'z-image-turbo-nested', generateUrl); } catch (eZ) { lastError = eZ; } } } else if (isQwenEdit) { // Qwen-image-edit: try official flat payload first try { result = await tryCall(variantQwenFlat, 'qwen-flat', generateUrl); } catch (eQ) { lastError = eQ; } } else if (isHunyuan) { // Hunyuan: uses size instead of width/height try { result = await tryCall(variantFlatMinimal, 'flat-minimal', generateUrl); } catch (e0) { lastError = e0; } if (!result) { try { result = await tryCall(variantCMinimal, 'nested-minimal', generateUrl); } catch (e1) { lastError = e1; } } } else if (preferMinimal) { // Other models with minimal preference try { result = await tryCall(variantFlatMinimal, 'flat-minimal', generateUrl); } catch (e0) { lastError = e0; } if (!result) { try { result = await tryCall(variantCMinimal, 'nested-minimal', generateUrl); } catch (e1) { lastError = e1; } } } // Generic fallback payloads - only try if model-specific didn't succeed if (!result) { try { result = await tryCall(variantA, 'nested', generateUrl); } catch (e1) { lastError = e1; } } if (!result) { try { result = await tryCall(variantB, 'flat', generateUrl); } catch (e2) { lastError = e2; } } // Final fallback logic if (!result) { // Auto fallback to another model when capacity/infrastructure errors (disabled when NO_FALLBACK=true) const capacityCodes = ['UPSTREAM_CAPACITY_EXHAUSTED','UPSTREAM_NO_INSTANCES','UPSTREAM_INFRASTRUCTURE','UPSTREAM_BAD_GATEWAY']; if (AUTO_FALLBACK && !NO_FALLBACK && lastError && capacityCodes.includes(lastError.code || '')) { function chooseFallback(currentId, list) { const key = (currentId || '').toLowerCase(); const free = list.filter(m => m.free && (m.id || m.name || '').toLowerCase() !== key); if (free.length) return (free[0].upstream_id || free[0].id || free[0].name); const any = list.find(m => (m.id || m.name || '').toLowerCase() !== key); if (any) return (any.upstream_id || any.id || any.name); return null; } const fallbackModel = chooseFallback(targetModel, localList); if (fallbackModel && fallbackModel !== targetModel) { const fbA = { ...variantA, model: fallbackModel }; const fbB = { ...variantB, model: fallbackModel }; const fbCfg = getModelConfig(localList, fallbackModel); const fbUrl = (fbCfg && fbCfg.upstream_url) ? fbCfg.upstream_url : GENERATE_API_URL; try { result = await tryCall(fbA, 'nested-fallback', fbUrl); } catch (e3) { try { result = await tryCall(fbB, 'flat-fallback', fbUrl); } catch (e4) { const status = e4.status || 502; return res.status(status).json({ ok: false, error: e4.hint || e4.message || 'Upstream error', code: e4.code || 'UPSTREAM_ERROR', upstream_model: targetModel, fallback_model: fallbackModel }); } } // success with fallback return res.json({ ok: true, image: `data:${result.contentType};base64,${result.imageBase64}`, contentType: result.contentType, meta: { model: flat.model, upstream_model: targetModel, fallback_used: true, fallback_model: fallbackModel, width: flat.width, height: flat.height, guidance_scale: flat.guidance_scale, num_inference_steps: flat.num_inference_steps, seed: flat.seed }, tried: result.tried }); } } const status = (lastError && lastError.status) || 502; return res.status(status).json({ ok: false, error: (lastError && (lastError.hint || lastError.message)) || 'Upstream error', code: (lastError && lastError.code) || 'UPSTREAM_ERROR', upstream_model: targetModel }); } return res.json({ ok: true, image: `data:${result.contentType};base64,${result.imageBase64}`, contentType: result.contentType, meta: { model: flat.model, upstream_model: targetModel, width: flat.width, height: flat.height, guidance_scale: flat.guidance_scale, num_inference_steps: flat.num_inference_steps, seed: flat.seed }, tried: result.tried }); } catch (err) { console.error('POST /api/generate failed:', err); res.status(500).json({ ok: false, error: 'Server error' }); } }); // Favicon placeholder to avoid noisy 404 in dev app.get('/favicon.ico', (req, res) => { res.status(204).end(); }); // Fallback to index.html for direct root access app.get('/', (req, res) => { res.sendFile(path.join(staticPath, 'index.html')); }); // Start server app.listen(PORT, HOST, () => { console.log(`Server running at http://${HOST}:${PORT}`); });