Update server.js
Browse files
server.js
CHANGED
|
@@ -6,12 +6,17 @@ import { Readable } from 'stream';
|
|
| 6 |
const app = express();
|
| 7 |
const PORT = 7860;
|
| 8 |
|
| 9 |
-
// Configuraci贸n para que funcione el l铆mite de IPs en Hugging Face Spaces
|
| 10 |
app.set('trust proxy', 1);
|
| 11 |
-
|
| 12 |
app.use(cors());
|
| 13 |
app.use(express.json());
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
// --- CONFIGURACI脫N DE PROVEEDORES ---
|
| 16 |
const PROVIDERS = [
|
| 17 |
{
|
|
@@ -19,18 +24,17 @@ const PROVIDERS = [
|
|
| 19 |
url: "https://api.llm7.io/v1/chat/completions"
|
| 20 |
},
|
| 21 |
{
|
| 22 |
-
// Nuevo proveedor: Pollinations AI
|
| 23 |
id: "pollinations",
|
| 24 |
url: "https://text.pollinations.ai/openai",
|
| 25 |
-
modelsUrl: "https://text.pollinations.ai/models"
|
|
|
|
| 26 |
},
|
| 27 |
{
|
| 28 |
-
// Nuevo proveedor gratuito y sin key
|
| 29 |
id: "airforce",
|
| 30 |
-
url: "https://api.airforce/v1/chat/completions"
|
|
|
|
| 31 |
},
|
| 32 |
{
|
| 33 |
-
// Tu espejo seguro
|
| 34 |
id: "ventarys-mirror",
|
| 35 |
url: "https://ventarys-mirror-1.hf.space/v1/chat/completions",
|
| 36 |
proxySecret: "sk-52650650a50f0v10vg150vs0v"
|
|
@@ -55,8 +59,6 @@ function isModelAllowed(modelId, modelObj = null) {
|
|
| 55 |
|
| 56 |
let currentLoad = { "llm7": 0, "pollinations": 0, "airforce": 0, "ventarys-mirror": 0 };
|
| 57 |
|
| 58 |
-
// --- L脥MITE DE TASA (Para producci贸n en ventarys.net) ---
|
| 59 |
-
// Te lo sub铆 a 25 peticiones por minuto para que tus usuarios no se topen con el error 429
|
| 60 |
const limiter = rateLimit({
|
| 61 |
windowMs: 60 * 1000,
|
| 62 |
max: 25,
|
|
@@ -75,11 +77,9 @@ app.get('/health', (req, res) => {
|
|
| 75 |
});
|
| 76 |
});
|
| 77 |
|
| 78 |
-
// --- EXTRACCI脫N Y FILTRO DE MODELOS MULTIPROVEEDOR ---
|
| 79 |
app.get('/v1/models', async (req, res) => {
|
| 80 |
try {
|
| 81 |
const fetchPromises = PROVIDERS.map(async (provider) => {
|
| 82 |
-
// Usa la URL espec铆fica de modelos si existe, si no, deduce la est谩ndar
|
| 83 |
const modelsUrl = provider.modelsUrl || provider.url.replace("/chat/completions", "/models");
|
| 84 |
const fetchHeaders = { "Content-Type": "application/json" };
|
| 85 |
|
|
@@ -87,13 +87,14 @@ app.get('/v1/models', async (req, res) => {
|
|
| 87 |
if (provider.proxySecret) fetchHeaders["X-Proxy-Secret"] = provider.proxySecret;
|
| 88 |
|
| 89 |
const resp = await fetch(modelsUrl, { method: "GET", headers: fetchHeaders });
|
| 90 |
-
if (!resp.ok)
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
const json = await resp.json();
|
| 93 |
let modelsArray = [];
|
| 94 |
|
| 95 |
-
// Traductor autom谩tico: Algunas APIs devuelven un array directo (Pollinations),
|
| 96 |
-
// otras devuelven un objeto con la propiedad "data" (OpenAI Standard)
|
| 97 |
if (Array.isArray(json)) {
|
| 98 |
modelsArray = json;
|
| 99 |
} else if (json && Array.isArray(json.data)) {
|
|
@@ -105,7 +106,7 @@ app.get('/v1/models', async (req, res) => {
|
|
| 105 |
.filter(model => isModelAllowed(model.id || model.name, model))
|
| 106 |
.map(model => ({
|
| 107 |
...model,
|
| 108 |
-
id: model.id || model.name,
|
| 109 |
owned_by: provider.id
|
| 110 |
}));
|
| 111 |
}
|
|
@@ -120,6 +121,7 @@ app.get('/v1/models', async (req, res) => {
|
|
| 120 |
|
| 121 |
res.json({ object: "list", data: allModels });
|
| 122 |
} catch (error) {
|
|
|
|
| 123 |
res.status(500).json({ error: "No se pudieron recuperar los modelos." });
|
| 124 |
}
|
| 125 |
});
|
|
@@ -129,8 +131,8 @@ app.post(['/v1/chat/completions', '/v1/images/generations'], limiter, async (req
|
|
| 129 |
const isImage = req.path === '/v1/images/generations';
|
| 130 |
const requestedModel = req.body.model;
|
| 131 |
|
| 132 |
-
// Filtro de Seguridad de Tiers
|
| 133 |
if (requestedModel && !isModelAllowed(requestedModel)) {
|
|
|
|
| 134 |
return res.status(403).json({
|
| 135 |
error: {
|
| 136 |
message: `Acceso denegado: El modelo '${requestedModel}' es de pago (Premium/Pro).`,
|
|
@@ -143,7 +145,6 @@ app.post(['/v1/chat/completions', '/v1/images/generations'], limiter, async (req
|
|
| 143 |
const startTime = Date.now();
|
| 144 |
let selectedProvider = null;
|
| 145 |
|
| 146 |
-
// Sala de Espera / Balanceo
|
| 147 |
while (Date.now() - startTime < QUEUE_TIMEOUT) {
|
| 148 |
let shuffled = [...availableProviders].sort(() => Math.random() - 0.5);
|
| 149 |
for (let provider of shuffled) {
|
|
@@ -158,10 +159,10 @@ app.post(['/v1/chat/completions', '/v1/images/generations'], limiter, async (req
|
|
| 158 |
}
|
| 159 |
|
| 160 |
if (!selectedProvider) {
|
|
|
|
| 161 |
return res.status(503).json({ error: { message: "Todas las APIs est谩n ocupadas. Por favor, reintenta.", code: 503 } });
|
| 162 |
}
|
| 163 |
|
| 164 |
-
// Funci贸n segura para liberar el Slot
|
| 165 |
let isReleased = false;
|
| 166 |
const releaseSlot = () => {
|
| 167 |
if (!isReleased) {
|
|
@@ -183,13 +184,58 @@ app.post(['/v1/chat/completions', '/v1/images/generations'], limiter, async (req
|
|
| 183 |
body: JSON.stringify(req.body)
|
| 184 |
});
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
if (isImage) {
|
| 187 |
-
const
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
}
|
| 191 |
|
| 192 |
-
//
|
| 193 |
res.writeHead(response.status, {
|
| 194 |
'Content-Type': response.headers.get('content-type') || 'text/event-stream',
|
| 195 |
'Cache-Control': 'no-cache',
|
|
@@ -201,7 +247,10 @@ app.post(['/v1/chat/completions', '/v1/images/generations'], limiter, async (req
|
|
| 201 |
stream.pipe(res);
|
| 202 |
|
| 203 |
stream.on('end', releaseSlot);
|
| 204 |
-
stream.on('error',
|
|
|
|
|
|
|
|
|
|
| 205 |
req.on('close', releaseSlot);
|
| 206 |
} else {
|
| 207 |
releaseSlot();
|
|
@@ -210,10 +259,11 @@ app.post(['/v1/chat/completions', '/v1/images/generations'], limiter, async (req
|
|
| 210 |
|
| 211 |
} catch (err) {
|
| 212 |
releaseSlot();
|
| 213 |
-
|
|
|
|
| 214 |
}
|
| 215 |
});
|
| 216 |
|
| 217 |
app.listen(PORT, '0.0.0.0', () => {
|
| 218 |
-
console.log(`馃殌 API
|
| 219 |
});
|
|
|
|
| 6 |
const app = express();
|
| 7 |
const PORT = 7860;
|
| 8 |
|
|
|
|
| 9 |
app.set('trust proxy', 1);
|
|
|
|
| 10 |
app.use(cors());
|
| 11 |
app.use(express.json());
|
| 12 |
|
| 13 |
+
// --- FUNCI脫N DE LOGS (RESPETANDO PRIVACIDAD) ---
|
| 14 |
+
// Solo se llama cuando algo falla, omitiendo IPs y mensajes (prompts).
|
| 15 |
+
function logError(providerId, reason) {
|
| 16 |
+
const timestamp = new Date().toISOString();
|
| 17 |
+
console.error(`[${timestamp}] [ERROR] Proveedor: ${providerId} | Motivo: ${reason}`);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
// --- CONFIGURACI脫N DE PROVEEDORES ---
|
| 21 |
const PROVIDERS = [
|
| 22 |
{
|
|
|
|
| 24 |
url: "https://api.llm7.io/v1/chat/completions"
|
| 25 |
},
|
| 26 |
{
|
|
|
|
| 27 |
id: "pollinations",
|
| 28 |
url: "https://text.pollinations.ai/openai",
|
| 29 |
+
modelsUrl: "https://text.pollinations.ai/models",
|
| 30 |
+
imageUrl: "https://text.pollinations.ai/openai" // Soporte compatible
|
| 31 |
},
|
| 32 |
{
|
|
|
|
| 33 |
id: "airforce",
|
| 34 |
+
url: "https://api.airforce/v1/chat/completions",
|
| 35 |
+
imageUrl: "https://api.airforce/v1/images/generations" // Soporte de im谩genes
|
| 36 |
},
|
| 37 |
{
|
|
|
|
| 38 |
id: "ventarys-mirror",
|
| 39 |
url: "https://ventarys-mirror-1.hf.space/v1/chat/completions",
|
| 40 |
proxySecret: "sk-52650650a50f0v10vg150vs0v"
|
|
|
|
| 59 |
|
| 60 |
let currentLoad = { "llm7": 0, "pollinations": 0, "airforce": 0, "ventarys-mirror": 0 };
|
| 61 |
|
|
|
|
|
|
|
| 62 |
const limiter = rateLimit({
|
| 63 |
windowMs: 60 * 1000,
|
| 64 |
max: 25,
|
|
|
|
| 77 |
});
|
| 78 |
});
|
| 79 |
|
|
|
|
| 80 |
app.get('/v1/models', async (req, res) => {
|
| 81 |
try {
|
| 82 |
const fetchPromises = PROVIDERS.map(async (provider) => {
|
|
|
|
| 83 |
const modelsUrl = provider.modelsUrl || provider.url.replace("/chat/completions", "/models");
|
| 84 |
const fetchHeaders = { "Content-Type": "application/json" };
|
| 85 |
|
|
|
|
| 87 |
if (provider.proxySecret) fetchHeaders["X-Proxy-Secret"] = provider.proxySecret;
|
| 88 |
|
| 89 |
const resp = await fetch(modelsUrl, { method: "GET", headers: fetchHeaders });
|
| 90 |
+
if (!resp.ok) {
|
| 91 |
+
logError(provider.id, `Fallo al recuperar modelos (HTTP ${resp.status})`);
|
| 92 |
+
throw new Error(`HTTP Error ${resp.status}`);
|
| 93 |
+
}
|
| 94 |
|
| 95 |
const json = await resp.json();
|
| 96 |
let modelsArray = [];
|
| 97 |
|
|
|
|
|
|
|
| 98 |
if (Array.isArray(json)) {
|
| 99 |
modelsArray = json;
|
| 100 |
} else if (json && Array.isArray(json.data)) {
|
|
|
|
| 106 |
.filter(model => isModelAllowed(model.id || model.name, model))
|
| 107 |
.map(model => ({
|
| 108 |
...model,
|
| 109 |
+
id: model.id || model.name,
|
| 110 |
owned_by: provider.id
|
| 111 |
}));
|
| 112 |
}
|
|
|
|
| 121 |
|
| 122 |
res.json({ object: "list", data: allModels });
|
| 123 |
} catch (error) {
|
| 124 |
+
logError("ProxyMain", "Error cr铆tico al agrupar la lista de modelos.");
|
| 125 |
res.status(500).json({ error: "No se pudieron recuperar los modelos." });
|
| 126 |
}
|
| 127 |
});
|
|
|
|
| 131 |
const isImage = req.path === '/v1/images/generations';
|
| 132 |
const requestedModel = req.body.model;
|
| 133 |
|
|
|
|
| 134 |
if (requestedModel && !isModelAllowed(requestedModel)) {
|
| 135 |
+
logError("ProxyMain", `Bloqueado intento de uso de modelo premium: ${requestedModel}`);
|
| 136 |
return res.status(403).json({
|
| 137 |
error: {
|
| 138 |
message: `Acceso denegado: El modelo '${requestedModel}' es de pago (Premium/Pro).`,
|
|
|
|
| 145 |
const startTime = Date.now();
|
| 146 |
let selectedProvider = null;
|
| 147 |
|
|
|
|
| 148 |
while (Date.now() - startTime < QUEUE_TIMEOUT) {
|
| 149 |
let shuffled = [...availableProviders].sort(() => Math.random() - 0.5);
|
| 150 |
for (let provider of shuffled) {
|
|
|
|
| 159 |
}
|
| 160 |
|
| 161 |
if (!selectedProvider) {
|
| 162 |
+
logError("ProxyMain", "Saturaci贸n - Todas las APIs est谩n ocupadas (Cola llena)");
|
| 163 |
return res.status(503).json({ error: { message: "Todas las APIs est谩n ocupadas. Por favor, reintenta.", code: 503 } });
|
| 164 |
}
|
| 165 |
|
|
|
|
| 166 |
let isReleased = false;
|
| 167 |
const releaseSlot = () => {
|
| 168 |
if (!isReleased) {
|
|
|
|
| 184 |
body: JSON.stringify(req.body)
|
| 185 |
});
|
| 186 |
|
| 187 |
+
// Registrar si la API del proveedor devuelve un error (sin registrar el prompt)
|
| 188 |
+
if (!response.ok) {
|
| 189 |
+
logError(selectedProvider.id, `Respuesta HTTP ${response.status} - ${response.statusText}`);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// --- MANEJO DE IM脕GENES (Conversi贸n a Base64) ---
|
| 193 |
if (isImage) {
|
| 194 |
+
const contentType = response.headers.get("content-type") || "";
|
| 195 |
+
|
| 196 |
+
// Caso 1: Devuelve un JSON est谩ndar (Posiblemente con URL en lugar de b64_json)
|
| 197 |
+
if (contentType.includes("application/json")) {
|
| 198 |
+
const jsonResp = await response.json();
|
| 199 |
+
|
| 200 |
+
if (jsonResp.data && Array.isArray(jsonResp.data)) {
|
| 201 |
+
for (let item of jsonResp.data) {
|
| 202 |
+
// Si la API nos devolvi贸 una URL en vez de base64, la convertimos nosotros
|
| 203 |
+
if (item.url && !item.b64_json) {
|
| 204 |
+
try {
|
| 205 |
+
const imgRes = await fetch(item.url);
|
| 206 |
+
const arrayBuffer = await imgRes.arrayBuffer();
|
| 207 |
+
item.b64_json = Buffer.from(arrayBuffer).toString('base64');
|
| 208 |
+
delete item.url; // Quitamos la URL para dejar tu app limpia
|
| 209 |
+
} catch (e) {
|
| 210 |
+
logError(selectedProvider.id, `Fallo al convertir la URL de imagen a Base64: ${e.message}`);
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
releaseSlot();
|
| 216 |
+
return res.status(response.status).json(jsonResp);
|
| 217 |
+
}
|
| 218 |
+
// Caso 2: Devuelve la imagen cruda (Ej: Pollinations genera binarios directos a veces)
|
| 219 |
+
else if (contentType.includes("image/")) {
|
| 220 |
+
const arrayBuffer = await response.arrayBuffer();
|
| 221 |
+
const b64 = Buffer.from(arrayBuffer).toString('base64');
|
| 222 |
+
releaseSlot();
|
| 223 |
+
|
| 224 |
+
// Lo empaquetamos como si fuera un JSON est谩ndar de OpenAI
|
| 225 |
+
return res.status(200).json({
|
| 226 |
+
created: Math.floor(Date.now() / 1000),
|
| 227 |
+
data: [{ b64_json: b64 }]
|
| 228 |
+
});
|
| 229 |
+
}
|
| 230 |
+
// Caso 3: Fallo catastr贸fico u otro texto
|
| 231 |
+
else {
|
| 232 |
+
const textResp = await response.text();
|
| 233 |
+
releaseSlot();
|
| 234 |
+
return res.status(response.status).type(contentType).send(textResp);
|
| 235 |
+
}
|
| 236 |
}
|
| 237 |
|
| 238 |
+
// --- MANEJO DE CHAT (Streaming) ---
|
| 239 |
res.writeHead(response.status, {
|
| 240 |
'Content-Type': response.headers.get('content-type') || 'text/event-stream',
|
| 241 |
'Cache-Control': 'no-cache',
|
|
|
|
| 247 |
stream.pipe(res);
|
| 248 |
|
| 249 |
stream.on('end', releaseSlot);
|
| 250 |
+
stream.on('error', (err) => {
|
| 251 |
+
logError(selectedProvider.id, `Stream roto a mitad de la respuesta: ${err.message}`);
|
| 252 |
+
releaseSlot();
|
| 253 |
+
});
|
| 254 |
req.on('close', releaseSlot);
|
| 255 |
} else {
|
| 256 |
releaseSlot();
|
|
|
|
| 259 |
|
| 260 |
} catch (err) {
|
| 261 |
releaseSlot();
|
| 262 |
+
logError(selectedProvider.id, `Fallo de red o Timeout conectando al proveedor: ${err.message}`);
|
| 263 |
+
res.status(500).json({ error: `Error de conexi贸n interna.` });
|
| 264 |
}
|
| 265 |
});
|
| 266 |
|
| 267 |
app.listen(PORT, '0.0.0.0', () => {
|
| 268 |
+
console.log(`馃殌 API Proxy (Node.js) corriendo seguro en el puerto ${PORT}`);
|
| 269 |
});
|