273 lines
6.7 KiB
JavaScript

/**
* vLLM SSE streaming proxy
* Replaces the DashScope-based ft-dashscope.js for mother model inference.
* SSE streaming to local vLLM via SSH tunnel (localhost:8000).
* NO system prompt injection.
*/
'use strict';
const https = require('https');
const http = require('http');
const VLLM_ENDPOINT = process.env.VLLM_ENDPOINT || 'http://localhost:8000';
const VLLM_MODEL = process.env.VLLM_MODEL || 'qwen2.5-7b-sft';
/**
* Parse VLLM_ENDPOINT URL into components
*/
function parseEndpoint() {
const url = new URL(VLLM_ENDPOINT);
return {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
protocol: url.protocol,
useTls: url.protocol === 'https:'
};
}
/**
* Stream chat completion from vLLM (SSE) to the response stream.
*
* @param {Array<{role:string,content:string}>} messages - Messages array
* @param {object} options
* @param {number} options.maxTokens - Max new tokens
* @param {number} options.temperature - Sampling temperature
* @param {AbortSignal} [options.signal] - Abort signal
* @returns {Promise<string>} - The complete response text
*/
async function streamChat(messages, options = {}) {
const {
maxTokens = 1024,
temperature = 0.7,
signal = null
} = options;
const endpoint = parseEndpoint();
const body = JSON.stringify({
model: VLLM_MODEL,
messages: messages,
max_tokens: maxTokens,
temperature: temperature,
stream: true
});
const transport = endpoint.useTls ? https : http;
return new Promise((resolve, reject) => {
const req = transport.request({
hostname: endpoint.hostname,
port: endpoint.port,
path: '/v1/chat/completions',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
}
}, (res) => {
let buffer = '';
let fullResponse = '';
res.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
const jsonStr = trimmed.slice(6);
if (jsonStr === '[DONE]') continue;
try {
const parsed = JSON.parse(jsonStr);
const choices = parsed.choices || [];
for (const choice of choices) {
const delta = choice.delta || {};
const content = delta.content || '';
fullResponse += content;
}
} catch (e) {
// Skip malformed JSON lines
}
}
});
res.on('end', () => resolve(fullResponse));
res.on('error', reject);
});
req.on('error', reject);
if (signal) {
signal.addEventListener('abort', () => req.destroy());
}
req.write(body);
req.end();
});
}
/**
* Pipe SSE stream from vLLM directly to the HTTP response
* Used for real-time chat where the browser consumes the stream
*/
function pipeChat(messages, res, options = {}) {
const {
maxTokens = 1024,
temperature = 0.7,
signal = null
} = options;
const endpoint = parseEndpoint();
const body = JSON.stringify({
model: VLLM_MODEL,
messages: messages,
max_tokens: maxTokens,
temperature: temperature,
stream: true
});
const transport = endpoint.useTls ? https : http;
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
const req = transport.request({
hostname: endpoint.hostname,
port: endpoint.port,
path: '/v1/chat/completions',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
}
}, (vllmRes) => {
let buffer = '';
vllmRes.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
const jsonStr = trimmed.slice(6);
if (jsonStr === '[DONE]') {
res.write('data: [DONE]\n\n');
continue;
}
try {
const parsed = JSON.parse(jsonStr);
const choices = parsed.choices || [];
for (const choice of choices) {
const delta = choice.delta || {};
const content = delta.content || '';
const finishReason = choice.finish_reason;
res.write(JSON.stringify({
delta: content,
finish_reason: finishReason
}) + '\n');
}
} catch (e) {
// Skip malformed lines
}
}
});
vllmRes.on('end', () => {
res.write('data: [DONE]\n\n');
res.end();
});
vllmRes.on('error', (err) => {
res.write(JSON.stringify({ error: err.message }) + '\n');
res.end();
});
});
req.on('error', (err) => {
res.write(JSON.stringify({ error: 'vLLM connection failed: ' + err.message }) + '\n');
res.end();
});
if (signal) {
signal.addEventListener('abort', () => {
req.destroy();
if (!res.writableEnded) res.end();
});
}
req.write(body);
req.end();
}
/**
* Non-streaming chat completion (for memory compression etc.)
*/
async function chatOnce(messages, options = {}) {
const {
maxTokens = 512,
temperature = 0.7
} = options;
const endpoint = parseEndpoint();
const body = JSON.stringify({
model: VLLM_MODEL,
messages: messages,
max_tokens: maxTokens,
temperature: temperature,
stream: false
});
const transport = endpoint.useTls ? https : http;
return new Promise((resolve, reject) => {
const req = transport.request({
hostname: endpoint.hostname,
port: endpoint.port,
path: '/v1/chat/completions',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
}
}, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk.toString());
res.on('end', () => {
try {
const parsed = JSON.parse(data);
const content = parsed?.choices?.[0]?.message?.content || '';
resolve(content);
} catch (e) {
reject(new Error('Failed to parse vLLM response: ' + e.message));
}
});
res.on('error', reject);
});
req.on('error', reject);
req.write(body);
req.end();
});
}
function getStatus() {
return {
endpoint: VLLM_ENDPOINT,
model: VLLM_MODEL
};
}
module.exports = {
streamChat,
pipeChat,
chatOnce,
getStatus
};