zelin-w15 / worker.js
Zelin Deployer
Worker zelin-w15: RigoChat-7B inference node
e0c8fe6
Raw
History Blame Contribute Delete
14.3 kB
/**
* worker.js — Zelin Cluster Worker
* ==================================
* Runs RigoChat-7B-v2 via node-llama-cpp and exposes
* an HTTP inference API for the cluster orchestrator.
*
* Endpoints:
* GET /health → { status, model, uptime, memMB }
* POST /inference → { result, tokens, latencyMs }
* POST /inference/stream → NDJSON stream of tokens
* POST /embed → { vector } (if embed model loaded)
*/
import http from 'http';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
import { existsSync, mkdirSync, readdirSync } from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT ?? '7860', 10);
const MODEL_DIR = process.env.MODEL_DIR ?? path.join(__dirname, 'models');
const AUTH_KEY = process.env.CLUSTER_AUTH_KEY ?? 'zelin-cluster';
const WORKER_ID = process.env.WORKER_ID ?? `worker-${Math.random().toString(36).slice(2, 6)}`;
// Model config from env or defaults
const MODEL_REPO = process.env.MODEL_REPO ?? 'IIC/RigoChat-7b-v2-GGUF';
const MODEL_FILE = process.env.MODEL_FILE ?? 'rigochat-7b-v2-Q4_K_M.gguf';
const CONTEXT_SIZE = parseInt(process.env.CONTEXT_SIZE ?? '4096', 10);
const MAX_TOKENS = parseInt(process.env.MAX_TOKENS ?? '512', 10);
// State
let model = null;
let llama = null;
let modelReady = false;
let modelLoading = false;
let modelError = null;
const startTime = Date.now();
// Inference semaphore (1 at a time for CPU)
let busy = false;
const queue = [];
function withLock(fn, timeoutMs = 120_000) {
return new Promise((resolve, reject) => {
const tryRun = () => {
if (busy) {
const t = setTimeout(() => reject(new Error('Lock timeout')), timeoutMs);
queue.push(() => { clearTimeout(t); tryRun(); });
return;
}
busy = true;
fn()
.then(resolve)
.catch(reject)
.finally(() => {
busy = false;
if (queue.length > 0) queue.shift()();
});
};
tryRun();
});
}
// ── Load Model ──────────────────────────────────────────────────────────────
async function loadModel() {
if (modelReady || modelLoading) return modelReady;
modelLoading = true;
console.log(`[Worker ${WORKER_ID}] Loading model ${MODEL_FILE}...`);
try {
const { getLlama } = await import('node-llama-cpp');
llama = await getLlama();
mkdirSync(MODEL_DIR, { recursive: true });
const modelPath = path.join(MODEL_DIR, MODEL_FILE);
// Download model if needed
if (!existsSync(modelPath)) {
console.log(`[Worker ${WORKER_ID}] Downloading model from ${MODEL_REPO}...`);
const { createModelDownloader } = await import('node-llama-cpp');
const downloader = await createModelDownloader({
modelUri: `hf:${MODEL_REPO}/${MODEL_FILE}`,
dirPath: MODEL_DIR,
onProgress: ({ downloadedSize, totalSize }) => {
const pct = totalSize ? Math.round(downloadedSize / totalSize * 100) : '?';
process.stdout.write(`\r[Worker ${WORKER_ID}] Downloading... ${pct}%`);
},
});
await downloader.download();
console.log(`\n[Worker ${WORKER_ID}] Model downloaded`);
}
// Find actual model file (HF may add prefix)
let actualPath = modelPath;
if (!existsSync(actualPath)) {
const files = readdirSync(MODEL_DIR).filter(f => f.endsWith('.gguf'));
const baseName = MODEL_FILE.replace('.gguf', '');
const found = files.find(f => f.includes(baseName) || f.includes('rigochat') || f.includes('RigoChat'));
if (found) actualPath = path.join(MODEL_DIR, found);
}
model = await llama.loadModel({ modelPath: actualPath, gpuLayers: 0 });
modelReady = true;
modelLoading = false;
console.log(`[Worker ${WORKER_ID}] Model ready! RAM: ${Math.round(os.totalmem() / 1024 / 1024)}MB total, ${Math.round(os.freemem() / 1024 / 1024)}MB free`);
return true;
} catch (err) {
modelError = err.message;
modelLoading = false;
console.error(`[Worker ${WORKER_ID}] Model load failed:`, err.message);
return false;
}
}
// ── Inference ───────────────────────────────────────────────────────────────
async function runInference(messages, maxTokens = 300, temperature = 0.7) {
if (!modelReady) throw new Error('Model not ready');
return withLock(async () => {
const { LlamaChatSession } = await import('node-llama-cpp');
const ctx = await model.createContext({ contextSize: CONTEXT_SIZE });
// Build system prompt with style
const baseSystem = messages.find(m => m.role === 'system')?.content ?? '';
const stylePrefix = 'Responde en español casual argentino. Máx 2 líneas. Sin mayúsculas al inicio. Sin punto final. Sin emojis salvo que sea muy gracioso.\n\n';
const systemFinal = baseSystem.includes('español casual') ? baseSystem : stylePrefix + baseSystem;
const session = new LlamaChatSession({
contextSequence: ctx.getSequence(),
systemPrompt: systemFinal,
});
const userMessages = messages.filter(m => m.role !== 'system');
const lastUser = userMessages[userMessages.length - 1];
let result = '';
if (lastUser) {
// Inject recent history
for (const msg of userMessages.slice(0, -1)) {
if (msg.role === 'user') {
await session.prompt(msg.content ?? '', { maxTokens: 1 }).catch(() => {});
}
}
result = await session.prompt(lastUser.content ?? '', {
maxTokens,
temperature,
topP: 0.9,
topK: 40,
minP: 0.05,
repeatPenalty: {
penalty: 1.35,
lastTokens: 96,
frequencyPenalty: 0.1,
presencePenalty: 0.05,
},
});
}
session.dispose?.();
ctx.dispose?.();
let cleaned = result.trim();
// Clean artifacts
cleaned = cleaned.replace(/^PASO \d+:?\s*/i, '');
cleaned = cleaned.replace(/^Respuesta final:?\s*/i, '');
cleaned = cleaned.replace(/^Mi respuesta:?\s*/i, '');
cleaned = cleaned.replace(/^Zelin:?\s*/i, '');
return cleaned || result.trim();
});
}
// ── Two-Pass Thinking (for complex queries) ────────────────────────────────
async function runThinking(messages, maxTokens = 400, temperature = 0.6) {
if (!modelReady) throw new Error('Model not ready');
return withLock(async () => {
const { LlamaChatSession } = await import('node-llama-cpp');
const ctx = await model.createContext({ contextSize: CONTEXT_SIZE });
const baseSystem = messages.find(m => m.role === 'system')?.content ?? '';
const stylePrefix = 'Responde en español casual argentino. Máx 2 líneas. Sin mayúsculas al inicio. Sin punto final.\n\n';
const systemFinal = baseSystem.includes('español casual') ? baseSystem : stylePrefix + baseSystem;
const session = new LlamaChatSession({
contextSequence: ctx.getSequence(),
systemPrompt: systemFinal,
});
const userMessages = messages.filter(m => m.role !== 'system');
const lastUser = userMessages[userMessages.length - 1];
if (!lastUser) throw new Error('No user message');
// Inject history
for (const msg of userMessages.slice(0, -1)) {
if (msg.role === 'user') {
await session.prompt(msg.content ?? '', { maxTokens: 1 }).catch(() => {});
}
}
// Pass 1: Think
const thinkPrompt = `PIENSA INTERNAMENTE (NO muestres esto). Analiza:
1. ¿Qué te preguntan? Intención real.
2. ¿Hay ironía o sarcasmo?
3. ¿Cómo respondería Zelin (argentina, casual, minúsculas)?
PIENSA y luego darás tu respuesta final.`;
try {
await session.prompt(thinkPrompt + '\n\nMensaje: ' + (lastUser.content ?? ''), {
maxTokens: 200,
temperature: 0.3,
topP: 0.85,
topK: 30,
repeatPenalty: { penalty: 1.15, lastTokens: 64 },
});
// Pass 2: Respond
const result = await session.prompt('Ahora da SOLO tu respuesta final como Zelin (1-2 líneas, español casual, minúsculas, sin punto final):', {
maxTokens,
temperature,
topP: 0.9,
topK: 40,
repeatPenalty: { penalty: 1.35, lastTokens: 96, frequencyPenalty: 0.1, presencePenalty: 0.05 },
});
session.dispose?.();
ctx.dispose?.();
let cleaned = result.trim();
cleaned = cleaned.replace(/^PASO \d+:?\s*/i, '');
cleaned = cleaned.replace(/^Respuesta final:?\s*/i, '');
cleaned = cleaned.replace(/^Zelin:?\s*/i, '');
return cleaned || result.trim();
} catch {
session.dispose?.();
ctx.dispose?.();
throw new Error('Thinking inference failed');
}
});
}
// ── HTTP Server ─────────────────────────────────────────────────────────────
const server = http.createServer(async (req, res) => {
// CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const url = new URL(req.url, `http://localhost:${PORT}`);
// ── Health Check ──────────────────────────────────────────────────────
if (url.pathname === '/health' && req.method === 'GET') {
const mem = process.memoryUsage();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: modelReady ? 'ready' : (modelLoading ? 'loading' : 'error'),
workerId: WORKER_ID,
model: MODEL_FILE,
uptime: Math.round((Date.now() - startTime) / 1000),
memMB: Math.round(mem.rss / 1024 / 1024),
busy,
error: modelError,
}));
return;
}
// ── Auth check for POST endpoints ─────────────────────────────────────
if (req.method === 'POST') {
const auth = req.headers['authorization'];
if (auth !== `Bearer ${AUTH_KEY}`) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
}
// ── Inference ─────────────────────────────────────────────────────────
if (url.pathname === '/inference' && req.method === 'POST') {
if (!modelReady) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Model not ready', status: modelLoading ? 'loading' : 'error' }));
return;
}
try {
const body = await readBody(req);
const { messages, maxTokens = 300, temperature = 0.7, thinking = false } = JSON.parse(body);
if (!messages || !Array.isArray(messages)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'messages array required' }));
return;
}
const startMs = Date.now();
const result = thinking
? await runThinking(messages, maxTokens, temperature)
: await runInference(messages, maxTokens, temperature);
const latencyMs = Date.now() - startMs;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
result,
worker: WORKER_ID,
latencyMs,
tokens: result?.split(/\s+/).length ?? 0,
thinking,
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message, worker: WORKER_ID }));
}
return;
}
// ── Status (detailed) ─────────────────────────────────────────────────
if (url.pathname === '/status' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
workerId: WORKER_ID,
model: MODEL_FILE,
modelRepo: MODEL_REPO,
ready: modelReady,
loading: modelLoading,
error: modelError,
busy,
uptime: Math.round((Date.now() - startTime) / 1000),
memMB: Math.round(process.memoryUsage().rss / 1024 / 1024),
totalMemMB: Math.round(os.totalmem() / 1024 / 1024),
freeMemMB: Math.round(os.freemem() / 1024 / 1024),
cpus: os.cpus().length,
}));
return;
}
// ── Root ──────────────────────────────────────────────────────────────
if (url.pathname === '/' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<html><body><h1>Zelin Worker ${WORKER_ID}</h1><p>Status: ${modelReady ? 'READY' : (modelLoading ? 'LOADING' : 'ERROR')}</p><p>Model: ${MODEL_FILE}</p></body></html>`);
return;
}
// 404
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
});
function readBody(req) {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', chunk => data += chunk);
req.on('end', () => resolve(data));
req.on('error', reject);
});
}
// ── Start ───────────────────────────────────────────────────────────────────
server.listen(PORT, '0.0.0.0', () => {
console.log(`[Worker ${WORKER_ID}] HTTP server on :${PORT}`);
loadModel().then(ok => {
if (ok) console.log(`[Worker ${WORKER_ID}] Ready for inference!`);
else console.error(`[Worker ${WORKER_ID}] FAILED to load model`);
});
});