chutes / server.js
Logankunfall's picture
pr3 (#3)
86bd8c5 verified
/**
* 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<Array>}
*/
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<Array|null>}
*/
// 创建用于获取模型列表的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: "<base64>", contentType: "image/jpeg" } or { data: "<base64>" }
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}`);
});