Update server.js
Browse files
server.js
CHANGED
|
@@ -13,7 +13,7 @@ app.use(cors());
|
|
| 13 |
app.use(express.json({ limit: '50mb' }));
|
| 14 |
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
| 15 |
|
| 16 |
-
// --- FUNCIÓN DE LOGS
|
| 17 |
function logError(providerId, reason) {
|
| 18 |
const timestamp = new Date().toISOString();
|
| 19 |
console.error(`[${timestamp}] [ERROR] Proveedor: ${providerId} | Motivo: ${reason}`);
|
|
@@ -46,19 +46,6 @@ const PROVIDERS = [
|
|
| 46 |
const MAX_PER_PROVIDER = 3;
|
| 47 |
const QUEUE_TIMEOUT = 25000;
|
| 48 |
|
| 49 |
-
const BLOCKED_TIERS = ["pro", "premium", "ultra", "vip", "plus", "enterprise", "max"];
|
| 50 |
-
|
| 51 |
-
function isModelAllowed(modelId, modelObj = null) {
|
| 52 |
-
if (!modelId) return true;
|
| 53 |
-
const lowerId = modelId.toLowerCase();
|
| 54 |
-
if (modelObj && modelObj.tier) {
|
| 55 |
-
const tier = modelObj.tier.toLowerCase();
|
| 56 |
-
if (tier === "pro" || tier === "premium" || tier === "vip") return false;
|
| 57 |
-
if (tier === "free" || tier === "standard") return true;
|
| 58 |
-
}
|
| 59 |
-
return !BLOCKED_TIERS.some(keyword => lowerId.includes(keyword));
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
let currentLoad = { "llm7": 0, "pollinations": 0, "airforce": 0, "ventarys-mirror": 0 };
|
| 63 |
|
| 64 |
const limiter = rateLimit({
|
|
@@ -69,30 +56,34 @@ const limiter = rateLimit({
|
|
| 69 |
legacyHeaders: false,
|
| 70 |
});
|
| 71 |
|
| 72 |
-
// ---
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
status: "online",
|
| 76 |
-
type: "hf-node-proxy",
|
| 77 |
-
providers: PROVIDERS.map(p => p.id),
|
| 78 |
-
current_load: currentLoad
|
| 79 |
-
});
|
| 80 |
-
});
|
| 81 |
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
const resp = await fetch(modelsUrl, { method: "GET", headers: fetchHeaders });
|
| 92 |
-
if (!resp.ok)
|
| 93 |
-
logError(provider.id, `Fallo al recuperar modelos (HTTP ${resp.status})`);
|
| 94 |
-
return [];
|
| 95 |
-
}
|
| 96 |
|
| 97 |
const json = await resp.json();
|
| 98 |
let modelsArray = [];
|
|
@@ -102,7 +93,8 @@ app.get('/v1/models', async (req, res) => {
|
|
| 102 |
|
| 103 |
if (modelsArray.length > 0) {
|
| 104 |
return modelsArray
|
| 105 |
-
|
|
|
|
| 106 |
.map(model => ({
|
| 107 |
...model,
|
| 108 |
id: model.id || model.name,
|
|
@@ -110,23 +102,71 @@ app.get('/v1/models', async (req, res) => {
|
|
| 110 |
}));
|
| 111 |
}
|
| 112 |
return [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
});
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
});
|
| 120 |
|
| 121 |
-
// Garantizar que la App siempre reconozca modelos
|
| 122 |
-
|
| 123 |
-
{ id: "flux", object: "model", type: "image", owned_by: "system" },
|
| 124 |
-
{ id: "dall-e-3", object: "model", type: "image", owned_by: "system" }
|
| 125 |
-
|
| 126 |
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
} catch (error) {
|
| 129 |
-
logError("ProxyMain", "Error
|
| 130 |
res.status(500).json({ error: "No se pudieron recuperar los modelos." });
|
| 131 |
}
|
| 132 |
});
|
|
@@ -134,22 +174,12 @@ app.get('/v1/models', async (req, res) => {
|
|
| 134 |
// --- RUTA PRINCIPAL DE GENERACIÓN ---
|
| 135 |
app.post(['/v1/chat/completions', '/v1/images/generations'], limiter, async (req, res) => {
|
| 136 |
const isImage = req.path === '/v1/images/generations';
|
| 137 |
-
const requestedModel = req.body.model;
|
| 138 |
-
|
| 139 |
-
if (requestedModel && !isModelAllowed(requestedModel)) {
|
| 140 |
-
logError("ProxyMain", `Bloqueado intento de uso de modelo premium: ${requestedModel}`);
|
| 141 |
-
return res.status(403).json({
|
| 142 |
-
error: {
|
| 143 |
-
message: `Acceso denegado: El modelo '${requestedModel}' es de pago.`,
|
| 144 |
-
type: "model_not_allowed", code: 403
|
| 145 |
-
}
|
| 146 |
-
});
|
| 147 |
-
}
|
| 148 |
-
|
| 149 |
const availableProviders = isImage ? PROVIDERS.filter(p => p.imageUrl) : PROVIDERS;
|
|
|
|
| 150 |
const startTime = Date.now();
|
| 151 |
let selectedProvider = null;
|
| 152 |
|
|
|
|
| 153 |
while (Date.now() - startTime < QUEUE_TIMEOUT) {
|
| 154 |
let shuffled = [...availableProviders].sort(() => Math.random() - 0.5);
|
| 155 |
for (let provider of shuffled) {
|
|
@@ -185,12 +215,22 @@ app.post(['/v1/chat/completions', '/v1/images/generations'], limiter, async (req
|
|
| 185 |
if (selectedProvider.apiKey) fetchHeaders["Authorization"] = `Bearer ${selectedProvider.apiKey}`;
|
| 186 |
if (selectedProvider.proxySecret) fetchHeaders["X-Proxy-Secret"] = selectedProvider.proxySecret;
|
| 187 |
|
| 188 |
-
// Adaptador para Pollinations Imágenes (
|
| 189 |
if (isImage && selectedProvider.id === "pollinations") {
|
| 190 |
const prompt = req.body.prompt || "A random image";
|
| 191 |
const seed = Math.floor(Math.random() * 10000000);
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
reqMethod = "GET";
|
| 195 |
reqBody = undefined;
|
| 196 |
delete fetchHeaders["Content-Type"];
|
|
@@ -206,16 +246,16 @@ app.post(['/v1/chat/completions', '/v1/images/generations'], limiter, async (req
|
|
| 206 |
logError(selectedProvider.id, `Respuesta HTTP ${response.status} - ${response.statusText}`);
|
| 207 |
}
|
| 208 |
|
| 209 |
-
// --- MANEJO DE IMÁGENES (
|
| 210 |
if (isImage) {
|
| 211 |
const contentType = response.headers.get("content-type") || "";
|
| 212 |
|
| 213 |
if (contentType.includes("application/json")) {
|
| 214 |
const jsonResp = await response.json();
|
| 215 |
|
|
|
|
| 216 |
if (jsonResp.data && Array.isArray(jsonResp.data)) {
|
| 217 |
for (let item of jsonResp.data) {
|
| 218 |
-
// Si nos devuelve URL en vez de base64, lo descargamos y convertimos para la app
|
| 219 |
if (item.url && !item.b64_json) {
|
| 220 |
try {
|
| 221 |
const imgRes = await fetch(item.url);
|
|
@@ -231,26 +271,27 @@ app.post(['/v1/chat/completions', '/v1/images/generations'], limiter, async (req
|
|
| 231 |
releaseSlot();
|
| 232 |
return res.status(response.status).json(jsonResp);
|
| 233 |
}
|
| 234 |
-
// Manejo de la respuesta nativa de imágenes (Pollinations)
|
| 235 |
else if (contentType.includes("image/")) {
|
|
|
|
| 236 |
const arrayBuffer = await response.arrayBuffer();
|
| 237 |
const b64 = Buffer.from(arrayBuffer).toString('base64');
|
| 238 |
releaseSlot();
|
| 239 |
|
| 240 |
-
//
|
| 241 |
return res.status(200).json({
|
| 242 |
created: Math.floor(Date.now() / 1000),
|
| 243 |
data: [{ b64_json: b64 }]
|
| 244 |
});
|
| 245 |
}
|
| 246 |
else {
|
|
|
|
| 247 |
const textResp = await response.text();
|
| 248 |
releaseSlot();
|
| 249 |
return res.status(response.status).type(contentType).send(textResp);
|
| 250 |
}
|
| 251 |
}
|
| 252 |
|
| 253 |
-
// --- MANEJO DE
|
| 254 |
res.writeHead(response.status, {
|
| 255 |
'Content-Type': response.headers.get('content-type') || 'text/event-stream',
|
| 256 |
'Cache-Control': 'no-cache',
|
|
|
|
| 13 |
app.use(express.json({ limit: '50mb' }));
|
| 14 |
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
| 15 |
|
| 16 |
+
// --- FUNCIÓN DE LOGS ---
|
| 17 |
function logError(providerId, reason) {
|
| 18 |
const timestamp = new Date().toISOString();
|
| 19 |
console.error(`[${timestamp}] [ERROR] Proveedor: ${providerId} | Motivo: ${reason}`);
|
|
|
|
| 46 |
const MAX_PER_PROVIDER = 3;
|
| 47 |
const QUEUE_TIMEOUT = 25000;
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
let currentLoad = { "llm7": 0, "pollinations": 0, "airforce": 0, "ventarys-mirror": 0 };
|
| 50 |
|
| 51 |
const limiter = rateLimit({
|
|
|
|
| 56 |
legacyHeaders: false,
|
| 57 |
});
|
| 58 |
|
| 59 |
+
// --- AYUDANTES PARA FILTRAR MODELOS ---
|
| 60 |
+
const IMAGE_KEYWORDS = ["flux", "dall", "midjourney", "sdxl", "stable-diffusion", "image", "vision"];
|
| 61 |
+
const AUDIO_KEYWORDS = ["suno", "udio", "music", "audio", "song", "voice", "tts"];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
+
function isImageModel(id) {
|
| 64 |
+
if (!id) return false;
|
| 65 |
+
const lowerId = id.toLowerCase();
|
| 66 |
+
return IMAGE_KEYWORDS.some(kw => lowerId.includes(kw));
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function isAudioModel(id, type) {
|
| 70 |
+
if (type === 'audio' || type === 'music') return true;
|
| 71 |
+
if (!id) return false;
|
| 72 |
+
const lowerId = id.toLowerCase();
|
| 73 |
+
return AUDIO_KEYWORDS.some(kw => lowerId.includes(kw));
|
| 74 |
+
}
|
| 75 |
|
| 76 |
+
async function fetchAllModels() {
|
| 77 |
+
const fetchPromises = PROVIDERS.map(async (provider) => {
|
| 78 |
+
const modelsUrl = provider.modelsUrl || provider.url.replace("/chat/completions", "/models");
|
| 79 |
+
const fetchHeaders = { "Content-Type": "application/json" };
|
| 80 |
+
|
| 81 |
+
if (provider.apiKey) fetchHeaders["Authorization"] = `Bearer ${provider.apiKey}`;
|
| 82 |
+
if (provider.proxySecret) fetchHeaders["X-Proxy-Secret"] = provider.proxySecret;
|
| 83 |
+
|
| 84 |
+
try {
|
| 85 |
const resp = await fetch(modelsUrl, { method: "GET", headers: fetchHeaders });
|
| 86 |
+
if (!resp.ok) return [];
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
const json = await resp.json();
|
| 89 |
let modelsArray = [];
|
|
|
|
| 93 |
|
| 94 |
if (modelsArray.length > 0) {
|
| 95 |
return modelsArray
|
| 96 |
+
// Eliminamos los modelos de música de raíz para que no ensucien ninguna lista
|
| 97 |
+
.filter(model => !isAudioModel(model.id || model.name, model.type))
|
| 98 |
.map(model => ({
|
| 99 |
...model,
|
| 100 |
id: model.id || model.name,
|
|
|
|
| 102 |
}));
|
| 103 |
}
|
| 104 |
return [];
|
| 105 |
+
} catch (error) {
|
| 106 |
+
return [];
|
| 107 |
+
}
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
const results = await Promise.allSettled(fetchPromises);
|
| 111 |
+
let allModels = [];
|
| 112 |
+
results.forEach(result => {
|
| 113 |
+
if (result.status === "fulfilled") allModels = allModels.concat(result.value);
|
| 114 |
+
});
|
| 115 |
+
return allModels;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// --- RUTAS INFORMATIVAS ---
|
| 119 |
+
app.get('/health', (req, res) => {
|
| 120 |
+
res.json({
|
| 121 |
+
status: "online",
|
| 122 |
+
type: "hf-node-proxy",
|
| 123 |
+
providers: PROVIDERS.map(p => p.id),
|
| 124 |
+
current_load: currentLoad
|
| 125 |
+
});
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
// Endpoint para modelos de TEXTO
|
| 129 |
+
app.get('/v1/models', async (req, res) => {
|
| 130 |
+
try {
|
| 131 |
+
const allModels = await fetchAllModels();
|
| 132 |
+
// Filtrar modelos asegurando que NO sean de imágenes
|
| 133 |
+
const textModels = allModels.filter(m => !isImageModel(m.id) && m.type !== 'image').map(m => {
|
| 134 |
+
m.type = 'text'; // Forzar etiqueta
|
| 135 |
+
return m;
|
| 136 |
});
|
| 137 |
|
| 138 |
+
res.json({ object: "list", data: textModels });
|
| 139 |
+
} catch (error) {
|
| 140 |
+
logError("ProxyMain", "Error recuperando modelos de texto.");
|
| 141 |
+
res.status(500).json({ error: "No se pudieron recuperar los modelos." });
|
| 142 |
+
}
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
// Endpoint para modelos de IMAGEN
|
| 146 |
+
app.get('/v1/images/models', async (req, res) => {
|
| 147 |
+
try {
|
| 148 |
+
const allModels = await fetchAllModels();
|
| 149 |
+
// Filtrar modelos asegurando que SÍ sean de imágenes
|
| 150 |
+
let imageModels = allModels.filter(m => isImageModel(m.id) || m.type === 'image').map(m => {
|
| 151 |
+
m.type = 'image'; // Forzar etiqueta
|
| 152 |
+
return m;
|
| 153 |
});
|
| 154 |
|
| 155 |
+
// Garantizar que la App siempre reconozca los modelos base aunque la API origen no los liste
|
| 156 |
+
const baseImages = [
|
| 157 |
+
{ id: "flux", object: "model", type: "image", owned_by: "system", tier: "standard" },
|
| 158 |
+
{ id: "dall-e-3", object: "model", type: "image", owned_by: "system", tier: "standard" }
|
| 159 |
+
];
|
| 160 |
|
| 161 |
+
baseImages.forEach(baseMod => {
|
| 162 |
+
if (!imageModels.find(m => m.id.toLowerCase() === baseMod.id)) {
|
| 163 |
+
imageModels.push(baseMod);
|
| 164 |
+
}
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
res.json({ object: "list", data: imageModels });
|
| 168 |
} catch (error) {
|
| 169 |
+
logError("ProxyMain", "Error recuperando modelos de imagen.");
|
| 170 |
res.status(500).json({ error: "No se pudieron recuperar los modelos." });
|
| 171 |
}
|
| 172 |
});
|
|
|
|
| 174 |
// --- RUTA PRINCIPAL DE GENERACIÓN ---
|
| 175 |
app.post(['/v1/chat/completions', '/v1/images/generations'], limiter, async (req, res) => {
|
| 176 |
const isImage = req.path === '/v1/images/generations';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
const availableProviders = isImage ? PROVIDERS.filter(p => p.imageUrl) : PROVIDERS;
|
| 178 |
+
|
| 179 |
const startTime = Date.now();
|
| 180 |
let selectedProvider = null;
|
| 181 |
|
| 182 |
+
// Sistema de cola inteligente
|
| 183 |
while (Date.now() - startTime < QUEUE_TIMEOUT) {
|
| 184 |
let shuffled = [...availableProviders].sort(() => Math.random() - 0.5);
|
| 185 |
for (let provider of shuffled) {
|
|
|
|
| 215 |
if (selectedProvider.apiKey) fetchHeaders["Authorization"] = `Bearer ${selectedProvider.apiKey}`;
|
| 216 |
if (selectedProvider.proxySecret) fetchHeaders["X-Proxy-Secret"] = selectedProvider.proxySecret;
|
| 217 |
|
| 218 |
+
// Adaptador especializado para Pollinations Imágenes (Formato nativo GET)
|
| 219 |
if (isImage && selectedProvider.id === "pollinations") {
|
| 220 |
const prompt = req.body.prompt || "A random image";
|
| 221 |
const seed = Math.floor(Math.random() * 10000000);
|
| 222 |
+
|
| 223 |
+
// Extraer resolución del body (por defecto cuadrado si no se provee)
|
| 224 |
+
let width = 1024, height = 1024;
|
| 225 |
+
if (req.body.size) {
|
| 226 |
+
const parts = req.body.size.split('x');
|
| 227 |
+
if (parts.length === 2) { width = parseInt(parts[0]); height = parseInt(parts[1]); }
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
// Ajustar el modelo si el usuario pidió uno en específico
|
| 231 |
+
const pollModel = req.body.model && req.body.model.includes('flux') ? 'flux' : 'dall-e-3';
|
| 232 |
+
|
| 233 |
+
targetUrl = `${targetUrl}/${encodeURIComponent(prompt)}?seed=${seed}&width=${width}&height=${height}&model=${pollModel}&nologo=true`;
|
| 234 |
reqMethod = "GET";
|
| 235 |
reqBody = undefined;
|
| 236 |
delete fetchHeaders["Content-Type"];
|
|
|
|
| 246 |
logError(selectedProvider.id, `Respuesta HTTP ${response.status} - ${response.statusText}`);
|
| 247 |
}
|
| 248 |
|
| 249 |
+
// --- MANEJO EXCLUSIVO DE IMÁGENES (Estandarización a Base64 - Estilo Aqua AI) ---
|
| 250 |
if (isImage) {
|
| 251 |
const contentType = response.headers.get("content-type") || "";
|
| 252 |
|
| 253 |
if (contentType.includes("application/json")) {
|
| 254 |
const jsonResp = await response.json();
|
| 255 |
|
| 256 |
+
// Si la API remota nos responde con JSON, extraemos las URL para transformarlas a Base64
|
| 257 |
if (jsonResp.data && Array.isArray(jsonResp.data)) {
|
| 258 |
for (let item of jsonResp.data) {
|
|
|
|
| 259 |
if (item.url && !item.b64_json) {
|
| 260 |
try {
|
| 261 |
const imgRes = await fetch(item.url);
|
|
|
|
| 271 |
releaseSlot();
|
| 272 |
return res.status(response.status).json(jsonResp);
|
| 273 |
}
|
|
|
|
| 274 |
else if (contentType.includes("image/")) {
|
| 275 |
+
// Si la API nos devuelve el binario directo (Ej. Pollinations)
|
| 276 |
const arrayBuffer = await response.arrayBuffer();
|
| 277 |
const b64 = Buffer.from(arrayBuffer).toString('base64');
|
| 278 |
releaseSlot();
|
| 279 |
|
| 280 |
+
// Estandarizar respuesta para tu frontend (Aqua AI / OpenAI compatible)
|
| 281 |
return res.status(200).json({
|
| 282 |
created: Math.floor(Date.now() / 1000),
|
| 283 |
data: [{ b64_json: b64 }]
|
| 284 |
});
|
| 285 |
}
|
| 286 |
else {
|
| 287 |
+
// Caída libre para errores o comportamientos inesperados de los proveedores
|
| 288 |
const textResp = await response.text();
|
| 289 |
releaseSlot();
|
| 290 |
return res.status(response.status).type(contentType).send(textResp);
|
| 291 |
}
|
| 292 |
}
|
| 293 |
|
| 294 |
+
// --- MANEJO EXCLUSIVO DE TEXTO (Streaming) ---
|
| 295 |
res.writeHead(response.status, {
|
| 296 |
'Content-Type': response.headers.get('content-type') || 'text/event-stream',
|
| 297 |
'Cache-Control': 'no-cache',
|