api / server.js
Juanoto2012's picture
Update server.js
9e50e4e verified
Raw
History Blame
12.6 kB
import express from 'express';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import { Readable } from 'stream';
const app = express();
const PORT = 7860;
app.set('trust proxy', 1);
app.use(cors());
// CR脥TICO: Aumentar l铆mite a 50mb para soportar im谩genes en Base64 (Visi贸n / Multimodal)
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
// --- FUNCI脫N DE LOGS ---
function logError(providerId, reason) {
const timestamp = new Date().toISOString();
console.error(`[${timestamp}] [ERROR] Proveedor: ${providerId} | Motivo: ${reason}`);
}
// --- CONFIGURACI脫N DE PROVEEDORES ---
const PROVIDERS = [
{
id: "llm7",
url: "https://api.llm7.io/v1/chat/completions"
},
{
id: "pollinations",
url: "https://text.pollinations.ai/openai",
modelsUrl: "https://text.pollinations.ai/models",
imageUrl: "https://image.pollinations.ai/prompt" // Endpoint nativo de im谩genes
},
{
id: "airforce",
url: "https://api.airforce/v1/chat/completions",
imageUrl: "https://api.airforce/v1/images/generations" // Soporte de im谩genes
},
{
id: "ventarys-mirror",
url: "https://ventarys-mirror-1.hf.space/v1/chat/completions",
proxySecret: "sk-52650650a50f0v10vg150vs0v"
}
];
const MAX_PER_PROVIDER = 3;
const QUEUE_TIMEOUT = 25000;
let currentLoad = { "llm7": 0, "pollinations": 0, "airforce": 0, "ventarys-mirror": 0 };
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 25,
message: { error: { message: "L铆mite alcanzado. Espera 1 minuto entre mensajes.", code: 429 } },
standardHeaders: true,
legacyHeaders: false,
});
// --- AYUDANTES PARA FILTRAR MODELOS ---
const IMAGE_KEYWORDS = ["flux", "dall", "midjourney", "sdxl", "stable-diffusion", "image", "vision"];
const AUDIO_KEYWORDS = ["suno", "udio", "music", "audio", "song", "voice", "tts"];
function isImageModel(id) {
if (!id) return false;
const lowerId = id.toLowerCase();
return IMAGE_KEYWORDS.some(kw => lowerId.includes(kw));
}
function isAudioModel(id, type) {
if (type === 'audio' || type === 'music') return true;
if (!id) return false;
const lowerId = id.toLowerCase();
return AUDIO_KEYWORDS.some(kw => lowerId.includes(kw));
}
async function fetchAllModels() {
const fetchPromises = PROVIDERS.map(async (provider) => {
const modelsUrl = provider.modelsUrl || provider.url.replace("/chat/completions", "/models");
const fetchHeaders = { "Content-Type": "application/json" };
if (provider.apiKey) fetchHeaders["Authorization"] = `Bearer ${provider.apiKey}`;
if (provider.proxySecret) fetchHeaders["X-Proxy-Secret"] = provider.proxySecret;
try {
const resp = await fetch(modelsUrl, { method: "GET", headers: fetchHeaders });
if (!resp.ok) return [];
const json = await resp.json();
let modelsArray = [];
if (Array.isArray(json)) modelsArray = json;
else if (json && Array.isArray(json.data)) modelsArray = json.data;
if (modelsArray.length > 0) {
return modelsArray
// Eliminamos los modelos de m煤sica de ra铆z para que no ensucien ninguna lista
.filter(model => !isAudioModel(model.id || model.name, model.type))
.map(model => ({
...model,
id: model.id || model.name,
owned_by: provider.id
}));
}
return [];
} catch (error) {
return [];
}
});
const results = await Promise.allSettled(fetchPromises);
let allModels = [];
results.forEach(result => {
if (result.status === "fulfilled") allModels = allModels.concat(result.value);
});
return allModels;
}
// --- RUTAS INFORMATIVAS ---
app.get('/health', (req, res) => {
res.json({
status: "online",
type: "hf-node-proxy",
providers: PROVIDERS.map(p => p.id),
current_load: currentLoad
});
});
// Endpoint para modelos de TEXTO
app.get('/v1/models', async (req, res) => {
try {
const allModels = await fetchAllModels();
// Filtrar modelos asegurando que NO sean de im谩genes
const textModels = allModels.filter(m => !isImageModel(m.id) && m.type !== 'image').map(m => {
m.type = 'text'; // Forzar etiqueta
return m;
});
res.json({ object: "list", data: textModels });
} catch (error) {
logError("ProxyMain", "Error recuperando modelos de texto.");
res.status(500).json({ error: "No se pudieron recuperar los modelos." });
}
});
// Endpoint para modelos de IMAGEN
app.get('/v1/images/models', async (req, res) => {
try {
const allModels = await fetchAllModels();
// Filtrar modelos asegurando que S脥 sean de im谩genes
let imageModels = allModels.filter(m => isImageModel(m.id) || m.type === 'image').map(m => {
m.type = 'image'; // Forzar etiqueta
return m;
});
// Garantizar que la App siempre reconozca los modelos base aunque la API origen no los liste
const baseImages = [
{ id: "flux", object: "model", type: "image", owned_by: "system", tier: "standard" },
{ id: "dall-e-3", object: "model", type: "image", owned_by: "system", tier: "standard" }
];
baseImages.forEach(baseMod => {
if (!imageModels.find(m => m.id.toLowerCase() === baseMod.id)) {
imageModels.push(baseMod);
}
});
res.json({ object: "list", data: imageModels });
} catch (error) {
logError("ProxyMain", "Error recuperando modelos de imagen.");
res.status(500).json({ error: "No se pudieron recuperar los modelos." });
}
});
// --- RUTA PRINCIPAL DE GENERACI脫N ---
app.post(['/v1/chat/completions', '/v1/images/generations'], limiter, async (req, res) => {
const isImage = req.path === '/v1/images/generations';
const availableProviders = isImage ? PROVIDERS.filter(p => p.imageUrl) : PROVIDERS;
const startTime = Date.now();
let selectedProvider = null;
// Sistema de cola inteligente
while (Date.now() - startTime < QUEUE_TIMEOUT) {
let shuffled = [...availableProviders].sort(() => Math.random() - 0.5);
for (let provider of shuffled) {
if (currentLoad[provider.id] < MAX_PER_PROVIDER) {
selectedProvider = provider;
currentLoad[provider.id]++;
break;
}
}
if (selectedProvider) break;
await new Promise(r => setTimeout(r, 1500));
}
if (!selectedProvider) {
logError("ProxyMain", "Saturaci贸n - Todas las APIs est谩n ocupadas");
return res.status(503).json({ error: { message: "Todas las APIs est谩n ocupadas. Por favor, reintenta.", code: 503 } });
}
let isReleased = false;
const releaseSlot = () => {
if (!isReleased) {
currentLoad[selectedProvider.id] = Math.max(0, currentLoad[selectedProvider.id] - 1);
isReleased = true;
}
};
try {
let targetUrl = isImage ? selectedProvider.imageUrl : selectedProvider.url;
let reqMethod = "POST";
let reqBody = JSON.stringify(req.body);
const fetchHeaders = { "Content-Type": "application/json" };
if (selectedProvider.apiKey) fetchHeaders["Authorization"] = `Bearer ${selectedProvider.apiKey}`;
if (selectedProvider.proxySecret) fetchHeaders["X-Proxy-Secret"] = selectedProvider.proxySecret;
// Adaptador especializado para Pollinations Im谩genes (Formato nativo GET)
if (isImage && selectedProvider.id === "pollinations") {
const prompt = req.body.prompt || "A random image";
const seed = Math.floor(Math.random() * 10000000);
// Extraer resoluci贸n del body (por defecto cuadrado si no se provee)
let width = 1024, height = 1024;
if (req.body.size) {
const parts = req.body.size.split('x');
if (parts.length === 2) { width = parseInt(parts[0]); height = parseInt(parts[1]); }
}
// Ajustar el modelo si el usuario pidi贸 uno en espec铆fico
const pollModel = req.body.model && req.body.model.includes('flux') ? 'flux' : 'dall-e-3';
targetUrl = `${targetUrl}/${encodeURIComponent(prompt)}?seed=${seed}&width=${width}&height=${height}&model=${pollModel}&nologo=true`;
reqMethod = "GET";
reqBody = undefined;
delete fetchHeaders["Content-Type"];
}
const response = await fetch(targetUrl, {
method: reqMethod,
headers: fetchHeaders,
body: reqBody
});
if (!response.ok) {
logError(selectedProvider.id, `Respuesta HTTP ${response.status} - ${response.statusText}`);
}
// --- MANEJO EXCLUSIVO DE IM脕GENES (Estandarizaci贸n a Base64 - Estilo Aqua AI) ---
if (isImage) {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const jsonResp = await response.json();
// Si la API remota nos responde con JSON, extraemos las URL para transformarlas a Base64
if (jsonResp.data && Array.isArray(jsonResp.data)) {
for (let item of jsonResp.data) {
if (item.url && !item.b64_json) {
try {
const imgRes = await fetch(item.url);
const arrayBuffer = await imgRes.arrayBuffer();
item.b64_json = Buffer.from(arrayBuffer).toString('base64');
delete item.url;
} catch (e) {
logError(selectedProvider.id, `Fallo convirtiendo URL a Base64: ${e.message}`);
}
}
}
}
releaseSlot();
return res.status(response.status).json(jsonResp);
}
else if (contentType.includes("image/")) {
// Si la API nos devuelve el binario directo (Ej. Pollinations)
const arrayBuffer = await response.arrayBuffer();
const b64 = Buffer.from(arrayBuffer).toString('base64');
releaseSlot();
// Estandarizar respuesta para tu frontend (Aqua AI / OpenAI compatible)
return res.status(200).json({
created: Math.floor(Date.now() / 1000),
data: [{ b64_json: b64 }]
});
}
else {
// Ca铆da libre para errores o comportamientos inesperados de los proveedores
const textResp = await response.text();
releaseSlot();
return res.status(response.status).type(contentType).send(textResp);
}
}
// --- MANEJO EXCLUSIVO DE TEXTO (Streaming) ---
res.writeHead(response.status, {
'Content-Type': response.headers.get('content-type') || 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
if (response.body) {
const stream = Readable.fromWeb(response.body);
stream.pipe(res);
stream.on('end', releaseSlot);
stream.on('error', (err) => {
logError(selectedProvider.id, `Stream roto a mitad de respuesta: ${err.message}`);
releaseSlot();
});
req.on('close', releaseSlot);
} else {
releaseSlot();
res.end();
}
} catch (err) {
releaseSlot();
logError(selectedProvider ? selectedProvider.id : "Proxy", `Fallo o Timeout conectando al proveedor: ${err.message}`);
res.status(500).json({ error: { message: "Error de conexi贸n interna."} });
}
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`馃殌 API Proxy (Node.js) corriendo seguro en el puerto ${PORT}`);
});