188 lines
6.9 KiB
JavaScript
188 lines
6.9 KiB
JavaScript
const https = require('https');
|
|
const http = require('http');
|
|
const { URL } = require('url');
|
|
const logger = require('./logger');
|
|
|
|
function normalizeBaseURL(baseURL) {
|
|
let url = (baseURL || '').trim().replace(/\/+$/, '');
|
|
if (/\/chat\/completions$/.test(url)) {
|
|
url = url.replace(/\/chat\/completions$/, '');
|
|
}
|
|
return url;
|
|
}
|
|
|
|
function buildChatURL(baseURL) {
|
|
return normalizeBaseURL(baseURL) + '/chat/completions';
|
|
}
|
|
|
|
function buildModelsURL(baseURL) {
|
|
return normalizeBaseURL(baseURL) + '/models';
|
|
}
|
|
|
|
async function fetchModels({ baseURL, apiKey }) {
|
|
const url = buildModelsURL(baseURL);
|
|
logger.log('FETCH_MODELS_START', { url });
|
|
const res = await fetch(url, {
|
|
method: 'GET',
|
|
headers: { Authorization: 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
logger.log('FETCH_MODELS_FAIL', { status: res.status, body: text.slice(0, 300) });
|
|
throw new Error('获取模型列表失败 (HTTP ' + res.status + '): ' + text.slice(0, 300));
|
|
}
|
|
const json = await res.json();
|
|
const data = json.data || json.models || [];
|
|
const models = data.map((m) => (typeof m === 'string' ? m : m.id || m.name)).filter(Boolean);
|
|
logger.log('FETCH_MODELS_OK', { count: models.length });
|
|
return models;
|
|
}
|
|
|
|
function doRequest(url, { method, headers, bodyStr, signal }) {
|
|
return new Promise((resolve, reject) => {
|
|
const u = new URL(url);
|
|
const lib = u.protocol === 'https:' ? https : http;
|
|
const opts = {
|
|
hostname: u.hostname,
|
|
port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
|
path: u.pathname + u.search,
|
|
method,
|
|
headers: Object.assign({}, headers, { 'Content-Length': Buffer.byteLength(bodyStr) }),
|
|
};
|
|
const req = lib.request(opts, (res) => resolve(res));
|
|
|
|
if (signal) {
|
|
if (signal.aborted) {
|
|
req.destroy();
|
|
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
|
|
return;
|
|
}
|
|
signal.addEventListener('abort', () => {
|
|
try { req.destroy(); } catch { /* ignore */ }
|
|
});
|
|
}
|
|
req.on('error', (err) => {
|
|
if (signal && signal.aborted) reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
|
|
else reject(err);
|
|
});
|
|
req.write(bodyStr);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function readAll(stream) {
|
|
return new Promise((resolve) => {
|
|
const chunks = [];
|
|
stream.on('data', (c) => chunks.push(c));
|
|
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
stream.on('error', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
});
|
|
}
|
|
|
|
async function streamChat({ baseURL, apiKey, model, messages, enableThinking, reasoningEffort, maxTokens, signal, onChunk, onUsage }) {
|
|
const url = buildChatURL(baseURL);
|
|
let currentMessages = messages;
|
|
let accumulated = '';
|
|
const MAX_ITER = 12;
|
|
const CONTINUE_PROMPT = '继续,请从刚才中断的地方直接接着输出,不要重复已经输出的任何内容。';
|
|
|
|
for (let iter = 0; iter < MAX_ITER; iter++) {
|
|
const body = { model, messages: currentMessages, stream: true };
|
|
if (enableThinking) {
|
|
body.thinking = { type: 'enabled' };
|
|
if (reasoningEffort) body.reasoning_effort = reasoningEffort;
|
|
}
|
|
if (maxTokens && maxTokens > 0) body.max_tokens = maxTokens;
|
|
const bodyStr = JSON.stringify(body);
|
|
|
|
logger.log('REQUEST', { iter, model, msgCount: currentMessages.length, thinking: !!enableThinking, effort: reasoningEffort || null, maxTokens: maxTokens || null, url });
|
|
|
|
let res;
|
|
try {
|
|
res = await doRequest(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
|
|
bodyStr,
|
|
signal,
|
|
});
|
|
} catch (e) {
|
|
if (e && e.name === 'AbortError') { logger.log('ABORTED_AT_REQUEST', { iter }); throw e; }
|
|
logger.log('REQUEST_ERROR', { iter, error: e.message, code: e.code });
|
|
throw e;
|
|
}
|
|
|
|
logger.log('RESPONSE_STATUS', { iter, status: res.statusCode });
|
|
|
|
if (res.statusCode !== 200) {
|
|
const text = await readAll(res);
|
|
logger.log('HTTP_ERROR', { iter, status: res.statusCode, body: text.slice(0, 500) });
|
|
throw new Error('请求失败 (HTTP ' + res.statusCode + '): ' + text.slice(0, 500));
|
|
}
|
|
|
|
const decoder = new TextDecoder('utf-8');
|
|
let buffer = '';
|
|
let finishReason = null;
|
|
let sawDone = false;
|
|
let chunkCount = 0;
|
|
let gotContentThisIter = false;
|
|
|
|
try {
|
|
for await (const chunk of res) {
|
|
buffer += decoder.decode(chunk, { stream: true });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
for (const raw of lines) {
|
|
const line = raw.trim();
|
|
if (!line || !line.startsWith('data:')) continue;
|
|
const data = line.slice(5).trim();
|
|
if (data === '[DONE]') { sawDone = true; break; }
|
|
try {
|
|
const json = JSON.parse(data);
|
|
chunkCount++;
|
|
if (json.usage && onUsage) onUsage(json.usage);
|
|
const choice = (json.choices && json.choices[0]) || {};
|
|
const delta = choice.delta || {};
|
|
const content = delta.content || '';
|
|
const reasoning = delta.reasoning_content || delta.reasoning || '';
|
|
if (content) { accumulated += content; gotContentThisIter = true; onChunk({ content, reasoning: '' }); }
|
|
if (reasoning) { onChunk({ content: '', reasoning }); }
|
|
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
} catch {
|
|
/* ignore malformed lines */
|
|
}
|
|
}
|
|
if (sawDone) break;
|
|
}
|
|
} catch (e) {
|
|
if (e && e.name === 'AbortError') { logger.log('ABORTED_IN_STREAM', { iter }); throw e; }
|
|
logger.log('STREAM_ERROR', { iter, error: e.message, code: e.code, stack: e.stack });
|
|
throw e;
|
|
}
|
|
|
|
try { res.destroy(); } catch { /* ignore */ }
|
|
|
|
logger.log('SEGMENT_END', { iter, finishReason: finishReason || null, sawDone, chunkCount, accumulatedLen: accumulated.length, gotContent: gotContentThisIter });
|
|
|
|
if (signal && signal.aborted) { logger.log('SIGNAL_ABORTED_AFTER', { iter }); throw Object.assign(new Error('Aborted'), { name: 'AbortError' }); }
|
|
|
|
const truncated = finishReason === 'length' || (!finishReason && !sawDone && gotContentThisIter);
|
|
|
|
if (truncated && iter < MAX_ITER - 1 && accumulated.length > 0) {
|
|
logger.log('AUTO_CONTINUE', { iter, reason: finishReason === 'length' ? 'length' : 'abrupt', accumulatedLen: accumulated.length });
|
|
currentMessages = messages.concat([
|
|
{ role: 'assistant', content: accumulated },
|
|
{ role: 'user', content: CONTINUE_PROMPT },
|
|
]);
|
|
continue;
|
|
}
|
|
if (iter === MAX_ITER - 1 && truncated) {
|
|
logger.log('MAX_ITER_REACHED', { accumulatedLen: accumulated.length });
|
|
}
|
|
break;
|
|
}
|
|
|
|
logger.log('STREAM_COMPLETE', { totalAccumulatedLen: accumulated.length });
|
|
}
|
|
|
|
module.exports = { fetchModels, streamChat, buildChatURL };
|