380 lines
13 KiB
JavaScript
380 lines
13 KiB
JavaScript
/**
|
||
* ═══════════════════════════════════════════════════════════
|
||
* 🎯 阿里云百炼 · 微调模型调用 (DashScope compatible-mode)
|
||
* ═══════════════════════════════════════════════════════════
|
||
*
|
||
* 编号: ZY-FTCHAT-DS-001
|
||
* 守护: 铸渊 · ICE-GL-ZY001
|
||
*
|
||
* 配置:
|
||
* FT_DASHSCOPE_API_KEY — 单独区分商业模型与微调模型的密钥
|
||
* FT_MODEL_SYSTEM — 微调系统线 (默认 qwen3-8b-ft-202604281809-9f30 · 冰朔 D69 提供)
|
||
* FT_MODEL_NAIPPING — 微调奶瓶线 (默认同系统线 · 待奶瓶专属微调上线后再分流)
|
||
* FT_MODEL_FALLBACK — 当微调模型不存在/无权限时自动降级到的基础模型
|
||
* (默认 qwen-turbo) · 保证 model_not_found 时聊天不挂
|
||
*
|
||
* 使用 OpenAI 兼容模式: /compatible-mode/v1/chat/completions
|
||
* SSE 流式 + 非流式降级。
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const https = require('https');
|
||
|
||
const ENDPOINT_HOST = 'dashscope.aliyuncs.com';
|
||
const ENDPOINT_PATH = '/compatible-mode/v1/chat/completions';
|
||
|
||
const MODEL_SYSTEM = process.env.FT_MODEL_SYSTEM || 'qwen3-8b-ft-202604281809-9f30';
|
||
const MODEL_NAIPPING = process.env.FT_MODEL_NAIPPING || 'qwen3-8b-ft-202604281809-9f30';
|
||
const MODEL_FALLBACK = process.env.FT_MODEL_FALLBACK || 'qwen-turbo';
|
||
|
||
// 已确认在 DashScope 账号下不存在的模型 ID, 后续直接走 fallback, 避免每轮都 404
|
||
const missingModels = new Set();
|
||
|
||
function pickModel(variant) {
|
||
const primary = variant === 'naipping' ? MODEL_NAIPPING : MODEL_SYSTEM;
|
||
if (missingModels.has(primary)) return MODEL_FALLBACK;
|
||
return primary;
|
||
}
|
||
|
||
function getApiKey() {
|
||
const key = process.env.FT_DASHSCOPE_API_KEY;
|
||
if (!key) throw new Error('FT_DASHSCOPE_API_KEY 未配置');
|
||
return key;
|
||
}
|
||
|
||
/**
|
||
* 内部: 单次流式调用 (不带 fallback). 遇到 model_not_found 直接 reject, 由外层决定是否重试。
|
||
*/
|
||
function _streamChatOnce(args) {
|
||
const { variant, messages, res, modelOverride } = args;
|
||
const apiKey = getApiKey();
|
||
const model = modelOverride || pickModel(variant);
|
||
|
||
const body = JSON.stringify({
|
||
model,
|
||
messages,
|
||
stream: true,
|
||
max_tokens: 2048,
|
||
temperature: 0.8
|
||
});
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const req = https.request({
|
||
hostname: ENDPOINT_HOST,
|
||
path: ENDPOINT_PATH,
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${apiKey}`,
|
||
'Accept': 'text/event-stream',
|
||
'Content-Length': Buffer.byteLength(body)
|
||
},
|
||
timeout: 120000
|
||
}, (upstream) => {
|
||
if (upstream.statusCode !== 200) {
|
||
let errBuf = '';
|
||
upstream.on('data', c => { errBuf += c; });
|
||
upstream.on('end', () => {
|
||
const msg = `DashScope HTTP ${upstream.statusCode}: ${errBuf.slice(0, 300)}`;
|
||
console.error('[FTCHAT DS]', msg);
|
||
// 标记缺失模型, 抛出可识别错误供外层重试
|
||
let isModelNotFound = false;
|
||
try {
|
||
const parsed = JSON.parse(errBuf);
|
||
isModelNotFound = parsed && parsed.error && parsed.error.code === 'model_not_found';
|
||
} catch (_e) { /* ignore */ }
|
||
if (isModelNotFound) {
|
||
missingModels.add(model);
|
||
const err = new Error(msg);
|
||
err.modelNotFound = true;
|
||
err.attemptedModel = model;
|
||
return reject(err);
|
||
}
|
||
reject(new Error(msg));
|
||
});
|
||
return;
|
||
}
|
||
|
||
let fullText = '';
|
||
let buf = '';
|
||
let usage = null;
|
||
|
||
upstream.on('data', (chunk) => {
|
||
buf += chunk.toString('utf8');
|
||
const lines = buf.split('\n');
|
||
buf = lines.pop();
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed.startsWith('data:')) continue;
|
||
const payload = trimmed.slice(5).trim();
|
||
if (payload === '[DONE]') {
|
||
try { res.write('data: [DONE]\n\n'); } catch (_e) { /* ignore */ }
|
||
continue;
|
||
}
|
||
let parsed;
|
||
try { parsed = JSON.parse(payload); } catch (_e) { continue; }
|
||
if (parsed.usage) usage = parsed.usage;
|
||
const delta = parsed.choices && parsed.choices[0] && parsed.choices[0].delta;
|
||
const piece = delta && delta.content;
|
||
if (piece) {
|
||
fullText += piece;
|
||
try {
|
||
res.write(`data: ${JSON.stringify({ delta: piece })}\n\n`);
|
||
} catch (_e) { /* response closed */ }
|
||
}
|
||
}
|
||
});
|
||
|
||
upstream.on('end', () => {
|
||
resolve({ full: fullText, usage, model });
|
||
});
|
||
|
||
upstream.on('error', (err) => {
|
||
console.error('[FTCHAT DS] upstream error:', err.message);
|
||
reject(err);
|
||
});
|
||
});
|
||
|
||
req.on('timeout', () => {
|
||
req.destroy(new Error('upstream timeout'));
|
||
});
|
||
req.on('error', (err) => {
|
||
console.error('[FTCHAT DS] request error:', err.message);
|
||
reject(err);
|
||
});
|
||
|
||
req.write(body);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 流式调用 DashScope, 通过 SSE 把 delta 推送到 res(已设置 SSE 头)
|
||
* model_not_found 时自动降级到 FT_MODEL_FALLBACK (默认 qwen-turbo).
|
||
* @param {object} args
|
||
* @param {string} args.variant
|
||
* @param {Array} args.messages OpenAI 格式: [{role, content}]
|
||
* @param {object} args.res Express response (已 writeHead text/event-stream)
|
||
* @returns {Promise<{ full: string, usage?: object, model: string }>}
|
||
*/
|
||
async function streamChat(args) {
|
||
try {
|
||
return await _streamChatOnce(args);
|
||
} catch (err) {
|
||
if (err && err.modelNotFound && err.attemptedModel !== MODEL_FALLBACK) {
|
||
console.warn(
|
||
`[FTCHAT DS] model "${err.attemptedModel}" not accessible, falling back to "${MODEL_FALLBACK}"`
|
||
);
|
||
try {
|
||
args.res.write(
|
||
`data: ${JSON.stringify({ notice: `微调模型暂不可用, 已自动降级到 ${MODEL_FALLBACK}` })}\n\n`
|
||
);
|
||
} catch (_e) { /* ignore */ }
|
||
return _streamChatOnce(Object.assign({}, args, { modelOverride: MODEL_FALLBACK }));
|
||
}
|
||
// 其他错误: 把上游真实错误透传给前端 (而不是模糊的 '上游模型暂不可用'), 便于诊断
|
||
try {
|
||
args.res.write(`data: ${JSON.stringify({ error: true, message: err.message || '上游模型暂不可用' })}\n\n`);
|
||
} catch (_e) { /* ignore */ }
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 非流式调用(用于 memory-agent 压缩等场景)
|
||
* model_not_found 时自动降级到 FT_MODEL_FALLBACK
|
||
* @returns {Promise<string>}
|
||
*/
|
||
async function chatOnce(args) {
|
||
const { variant, messages, max_tokens } = args;
|
||
const tryModel = async (model) => {
|
||
const apiKey = getApiKey();
|
||
const body = JSON.stringify({
|
||
model,
|
||
messages,
|
||
stream: false,
|
||
max_tokens: max_tokens || 800,
|
||
temperature: 0.5
|
||
});
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const req = https.request({
|
||
hostname: ENDPOINT_HOST,
|
||
path: ENDPOINT_PATH,
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${apiKey}`,
|
||
'Content-Length': Buffer.byteLength(body)
|
||
},
|
||
timeout: 60000
|
||
}, (upstream) => {
|
||
let buf = '';
|
||
upstream.on('data', c => { buf += c; });
|
||
upstream.on('end', () => {
|
||
if (upstream.statusCode !== 200) {
|
||
let isModelNotFound = false;
|
||
try {
|
||
const parsed = JSON.parse(buf);
|
||
isModelNotFound = parsed && parsed.error && parsed.error.code === 'model_not_found';
|
||
} catch (_e) { /* ignore */ }
|
||
if (isModelNotFound) {
|
||
missingModels.add(model);
|
||
const err = new Error(`DashScope HTTP ${upstream.statusCode}: ${buf.slice(0, 200)}`);
|
||
err.modelNotFound = true;
|
||
err.attemptedModel = model;
|
||
return reject(err);
|
||
}
|
||
return reject(new Error(`DashScope HTTP ${upstream.statusCode}: ${buf.slice(0, 200)}`));
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(buf);
|
||
const content = parsed.choices && parsed.choices[0] && parsed.choices[0].message && parsed.choices[0].message.content;
|
||
resolve(content || '');
|
||
} catch (e) {
|
||
reject(e);
|
||
}
|
||
});
|
||
});
|
||
req.on('timeout', () => req.destroy(new Error('upstream timeout')));
|
||
req.on('error', reject);
|
||
req.write(body);
|
||
req.end();
|
||
});
|
||
};
|
||
|
||
const primary = pickModel(variant);
|
||
try {
|
||
return await tryModel(primary);
|
||
} catch (err) {
|
||
if (err && err.modelNotFound && err.attemptedModel !== MODEL_FALLBACK) {
|
||
console.warn(
|
||
`[FTCHAT DS] (chatOnce) model "${err.attemptedModel}" not accessible, falling back to "${MODEL_FALLBACK}"`
|
||
);
|
||
return tryModel(MODEL_FALLBACK);
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ═════════════════════════════════════════════════════════════
|
||
* 纯字节管道 (铸渊 · 2026-05-02)
|
||
* ═════════════════════════════════════════════════════════════
|
||
* 上游百炼 SSE 字节直接 pipe 到浏览器, 服务端零解析、零打包。
|
||
* 仅在以下两种情况打断管道:
|
||
* 1. 上游非 200 → 收集错误体, 判定 model_not_found 后由 pipeChat 决定降级
|
||
* 2. 上游 timeout / 网络错误 → reject 给上层 (此时 res headers 可能尚未发出)
|
||
*
|
||
* 与 streamChat 的区别:
|
||
* - streamChat: 服务端解析 SSE → 抽出 delta.content → 重新发 `{delta: piece}` 帧
|
||
* (本轮已弃用于聊天链路, 仅保留兼容)
|
||
* - pipeChat: `upstream.pipe(res)`, 字节透传, 浏览器直接读百炼原生格式
|
||
*/
|
||
function _pipeChatOnce(args) {
|
||
const { variant, messages, res, modelOverride } = args;
|
||
const apiKey = getApiKey();
|
||
const model = modelOverride || pickModel(variant);
|
||
|
||
const body = JSON.stringify({
|
||
model,
|
||
messages,
|
||
stream: true,
|
||
max_tokens: 2048,
|
||
temperature: 0.8
|
||
});
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const upReq = https.request({
|
||
hostname: ENDPOINT_HOST,
|
||
path: ENDPOINT_PATH,
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${apiKey}`,
|
||
'Accept': 'text/event-stream',
|
||
'Content-Length': Buffer.byteLength(body)
|
||
},
|
||
timeout: 120000
|
||
}, (upstream) => {
|
||
if (upstream.statusCode !== 200) {
|
||
let errBuf = '';
|
||
upstream.on('data', c => { errBuf += c; });
|
||
upstream.on('end', () => {
|
||
const msg = `DashScope HTTP ${upstream.statusCode}: ${errBuf.slice(0, 300)}`;
|
||
console.error('[FTCHAT DS pipe]', msg);
|
||
let isModelNotFound = false;
|
||
try {
|
||
const parsed = JSON.parse(errBuf);
|
||
isModelNotFound = parsed && parsed.error && parsed.error.code === 'model_not_found';
|
||
} catch (_e) { /* ignore */ }
|
||
if (isModelNotFound) {
|
||
missingModels.add(model);
|
||
const err = new Error(msg);
|
||
err.modelNotFound = true;
|
||
err.attemptedModel = model;
|
||
return reject(err);
|
||
}
|
||
reject(new Error(msg));
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 200 OK: 字节级管道. 上游 SSE 头透传给浏览器
|
||
if (!res.headersSent) {
|
||
res.writeHead(200, {
|
||
'Content-Type': upstream.headers['content-type'] || 'text/event-stream; charset=utf-8',
|
||
'Cache-Control': 'no-cache, no-transform',
|
||
'Connection': 'keep-alive',
|
||
'X-Accel-Buffering': 'no'
|
||
});
|
||
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
||
}
|
||
|
||
upstream.pipe(res, { end: false });
|
||
upstream.on('end', () => resolve({ model }));
|
||
upstream.on('error', (err) => {
|
||
console.error('[FTCHAT DS pipe] upstream error:', err.message);
|
||
reject(err);
|
||
});
|
||
});
|
||
|
||
upReq.on('timeout', () => upReq.destroy(new Error('upstream timeout')));
|
||
upReq.on('error', (err) => {
|
||
console.error('[FTCHAT DS pipe] request error:', err.message);
|
||
reject(err);
|
||
});
|
||
|
||
upReq.write(body);
|
||
upReq.end();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 纯字节管道版本: 把上游百炼 SSE 字节直接 pipe 到 res.
|
||
* model_not_found 时自动降级到 FT_MODEL_FALLBACK (仅当 res headers 尚未发出时).
|
||
* 上游 200 之后, 服务端不再解析任何字节 — 浏览器直接读 OpenAI 兼容 SSE 格式.
|
||
*
|
||
* @returns {Promise<{ model: string }>}
|
||
*/
|
||
async function pipeChat(args) {
|
||
try {
|
||
return await _pipeChatOnce(args);
|
||
} catch (err) {
|
||
if (
|
||
err && err.modelNotFound &&
|
||
err.attemptedModel !== MODEL_FALLBACK &&
|
||
args.res && !args.res.headersSent
|
||
) {
|
||
console.warn(
|
||
`[FTCHAT DS pipe] model "${err.attemptedModel}" not accessible, falling back to "${MODEL_FALLBACK}"`
|
||
);
|
||
return _pipeChatOnce(Object.assign({}, args, { modelOverride: MODEL_FALLBACK }));
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
module.exports = { streamChat, pipeChat, chatOnce, pickModel, MODEL_SYSTEM, MODEL_NAIPPING, MODEL_FALLBACK };
|