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 };