首次提交
This commit is contained in:
+187
@@ -0,0 +1,187 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user