guanghulab/server/ftchat/services/ft-dashscope.js

380 lines
13 KiB
JavaScript
Raw Normal View History

/**
*
* 🎯 阿里云百炼 · 微调模型调用 (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 };