fix(wechat): fallback manage LLM providers when chat balance is exhausted
Memind CI / Test, build, and release guards (push) Failing after 4m34s
Memind CI / Test, build, and release guards (push) Failing after 4m34s
When DeepSeek returns 402, try other chat providers before giving up, and cancel the sole active task only during LLM outages so user 123 can cancel without Goose. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,6 +10,120 @@ function parseJsonReply(reply) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IMAGE_ONLY_MODEL_HINTS = /(?:image|ocr|vision|vl-ocr|文生图|图生文)/iu;
|
||||||
|
|
||||||
|
export function isLlmProviderUnavailable(result) {
|
||||||
|
if (result?.ok) return false;
|
||||||
|
if (Number(result?.status) === 402 || Number(result?.status) === 429) return true;
|
||||||
|
const message = String(result?.message ?? '').toLowerCase();
|
||||||
|
return /insufficient balance|余额不足|quota|rate limit|too many requests/u.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyChatProviderKey(key) {
|
||||||
|
if (!key || key.status === 'disabled') return false;
|
||||||
|
const label = `${key.name ?? ''} ${key.defaultModel ?? ''} ${(key.models ?? []).join(' ')}`;
|
||||||
|
return !IMAGE_ONLY_MODEL_HINTS.test(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildManageLlmMessages(text, taskList) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: [
|
||||||
|
'你是微信服务号定时自动任务管理助手,只做任务选择,不做页面生成。',
|
||||||
|
'输入包含用户原话和当前 active 定时任务列表(JSON)。',
|
||||||
|
'请严格输出 JSON,不要输出解释。',
|
||||||
|
'允许 action:',
|
||||||
|
'- cancel:用户明确要取消某个任务,且能从列表中唯一确定 taskId',
|
||||||
|
'- clarify:用户想取消但无法唯一确定是哪一个,message 用中文简短追问',
|
||||||
|
'- none:不是取消任务,或信息不足且不应猜测',
|
||||||
|
'cancel 时必须返回列表里存在的 taskId;禁止编造 taskId。',
|
||||||
|
'如果只有一条任务且用户明确要取消/停止定时任务,可以返回该 taskId。',
|
||||||
|
'如果用户在要求生成/修改 HTML 页面,而不是管理已有定时任务,返回 none。',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: JSON.stringify({
|
||||||
|
userMessage: String(text ?? '').trim(),
|
||||||
|
activeTasks: taskList,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createManageLlmChatCompletion({
|
||||||
|
llmProviderService,
|
||||||
|
modelProviderKeyId = null,
|
||||||
|
model = null,
|
||||||
|
messages,
|
||||||
|
logger = console,
|
||||||
|
} = {}) {
|
||||||
|
const candidates = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
const pushCandidate = (providerKeyId, candidateModel) => {
|
||||||
|
const key = providerKeyId ?? '__default__';
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
candidates.push({
|
||||||
|
providerKeyId,
|
||||||
|
model: candidateModel ?? model ?? null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
pushCandidate(modelProviderKeyId, model);
|
||||||
|
pushCandidate(null, model);
|
||||||
|
|
||||||
|
if (typeof llmProviderService.listKeys === 'function') {
|
||||||
|
try {
|
||||||
|
const keys = await llmProviderService.listKeys();
|
||||||
|
for (const key of keys ?? []) {
|
||||||
|
if (!isLikelyChatProviderKey(key)) continue;
|
||||||
|
pushCandidate(key.id, model ?? key.defaultModel ?? null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn?.(
|
||||||
|
'[wechat-scheduled-task-manage-llm] provider list failed:',
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastResult = null;
|
||||||
|
let sawUnavailable = false;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const result = await llmProviderService.createChatCompletion({
|
||||||
|
...(candidate.providerKeyId ? { providerKeyId: candidate.providerKeyId } : {}),
|
||||||
|
...(candidate.model ? { model: candidate.model } : {}),
|
||||||
|
temperature: 0,
|
||||||
|
messages,
|
||||||
|
});
|
||||||
|
lastResult = result;
|
||||||
|
if (result?.ok) return result;
|
||||||
|
if (isLlmProviderUnavailable(result)) {
|
||||||
|
sawUnavailable = true;
|
||||||
|
logger?.warn?.(
|
||||||
|
'[wechat-scheduled-task-manage-llm] provider unavailable:',
|
||||||
|
candidate.providerKeyId ?? 'default',
|
||||||
|
result?.message ?? 'unknown',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
logger?.warn?.(
|
||||||
|
'[wechat-scheduled-task-manage-llm] provider failed:',
|
||||||
|
candidate.providerKeyId ?? 'default',
|
||||||
|
result?.message ?? 'unknown',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(lastResult ?? { ok: false, message: '聊天模型不可用' }),
|
||||||
|
llmUnavailable: sawUnavailable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function buildScheduledTaskListPayload(tasks = []) {
|
export function buildScheduledTaskListPayload(tasks = []) {
|
||||||
return tasks.map((task) => ({
|
return tasks.map((task) => ({
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
@@ -58,36 +172,15 @@ export async function resolveScheduledTaskCancelWithLlm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const taskList = buildScheduledTaskListPayload(tasks);
|
const taskList = buildScheduledTaskListPayload(tasks);
|
||||||
|
const messages = buildManageLlmMessages(text, taskList);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await llmProviderService.createChatCompletion({
|
const result = await createManageLlmChatCompletion({
|
||||||
...(modelProviderKeyId ? { providerKeyId: modelProviderKeyId } : {}),
|
llmProviderService,
|
||||||
...(model ? { model } : {}),
|
modelProviderKeyId,
|
||||||
temperature: 0,
|
model,
|
||||||
messages: [
|
messages,
|
||||||
{
|
logger,
|
||||||
role: 'system',
|
|
||||||
content: [
|
|
||||||
'你是微信服务号定时自动任务管理助手,只做任务选择,不做页面生成。',
|
|
||||||
'输入包含用户原话和当前 active 定时任务列表(JSON)。',
|
|
||||||
'请严格输出 JSON,不要输出解释。',
|
|
||||||
'允许 action:',
|
|
||||||
'- cancel:用户明确要取消某个任务,且能从列表中唯一确定 taskId',
|
|
||||||
'- clarify:用户想取消但无法唯一确定是哪一个,message 用中文简短追问',
|
|
||||||
'- none:不是取消任务,或信息不足且不应猜测',
|
|
||||||
'cancel 时必须返回列表里存在的 taskId;禁止编造 taskId。',
|
|
||||||
'如果只有一条任务且用户明确要取消/停止定时任务,可以返回该 taskId。',
|
|
||||||
'如果用户在要求生成/修改 HTML 页面,而不是管理已有定时任务,返回 none。',
|
|
||||||
].join('\n'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: JSON.stringify({
|
|
||||||
userMessage: String(text ?? '').trim(),
|
|
||||||
activeTasks: taskList,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result?.ok) {
|
if (!result?.ok) {
|
||||||
@@ -95,7 +188,10 @@ export async function resolveScheduledTaskCancelWithLlm({
|
|||||||
'[wechat-scheduled-task-manage-llm] parse skipped:',
|
'[wechat-scheduled-task-manage-llm] parse skipped:',
|
||||||
result?.message ?? 'unknown',
|
result?.message ?? 'unknown',
|
||||||
);
|
);
|
||||||
return { action: 'none' };
|
return {
|
||||||
|
action: 'none',
|
||||||
|
...(result?.llmUnavailable ? { llmUnavailable: true } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeScheduledTaskManageLlmDecision(parseJsonReply(result.reply), tasks);
|
return normalizeScheduledTaskManageLlmDecision(parseJsonReply(result.reply), tasks);
|
||||||
|
|||||||
@@ -83,3 +83,32 @@ test('resolveScheduledTaskCancelWithLlm returns clarify when ambiguous', async (
|
|||||||
assert.equal(reply.action, 'clarify');
|
assert.equal(reply.action, 'clarify');
|
||||||
assert.match(reply.message, /新闻早报/u);
|
assert.match(reply.message, /新闻早报/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveScheduledTaskCancelWithLlm falls back to another provider when primary is unavailable', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const reply = await resolveScheduledTaskCancelWithLlm({
|
||||||
|
text: '取消定时推送苏州天气任务',
|
||||||
|
tasks: [tasks[0]],
|
||||||
|
modelProviderKeyId: 'deepseek-key',
|
||||||
|
llmProviderService: {
|
||||||
|
async listKeys() {
|
||||||
|
return [
|
||||||
|
{ id: 'deepseek-key', name: 'DeepSeek', status: 'active', defaultModel: 'deepseek-chat', models: ['deepseek-chat'] },
|
||||||
|
{ id: 'kimi-key', name: 'kimi', status: 'active', defaultModel: 'kimi-k2.6', models: ['kimi-k2.6'] },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
async createChatCompletion(payload) {
|
||||||
|
calls.push(payload.providerKeyId ?? 'default');
|
||||||
|
if ((payload.providerKeyId ?? 'default') !== 'kimi-key') {
|
||||||
|
return { ok: false, status: 402, message: '{"error":{"message":"Insufficient Balance"}}' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reply: JSON.stringify({ action: 'cancel', taskId: 'task-suzhou' }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(reply, { action: 'cancel', taskId: 'task-suzhou' });
|
||||||
|
assert.deepEqual(calls, ['deepseek-key', 'default', 'kimi-key']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -91,6 +91,20 @@ export async function handleWechatScheduledTaskIntent({
|
|||||||
if (decision.action === 'clarify') {
|
if (decision.action === 'clarify') {
|
||||||
return decision.message;
|
return decision.message;
|
||||||
}
|
}
|
||||||
|
if (decision.llmUnavailable) {
|
||||||
|
if (tasks.length === 1) {
|
||||||
|
logger.warn?.(
|
||||||
|
'[wechat-scheduled-task] outage fallback cancel for single active task',
|
||||||
|
{ userId: user.userId, taskId: tasks[0].id },
|
||||||
|
);
|
||||||
|
const cancelled = await scheduledTaskService.cancelTask({
|
||||||
|
userId: user.userId,
|
||||||
|
taskId: tasks[0].id,
|
||||||
|
});
|
||||||
|
return formatScheduledTaskCancelReply(cancelled);
|
||||||
|
}
|
||||||
|
return '暂时无法连接模型来识别要取消的任务,请稍后再试,或明确说出任务名称。';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -131,6 +131,56 @@ test('handleWechatScheduledTaskIntent passes cancel to goose when manage llm is
|
|||||||
assert.equal(reply, null);
|
assert.equal(reply, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('handleWechatScheduledTaskIntent outage-fallback cancels sole active task when manage llm is unavailable', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const reply = await handleWechatScheduledTaskIntent({
|
||||||
|
intent: {
|
||||||
|
msgType: 'text',
|
||||||
|
agentText: '取消定时推送苏州天气任务',
|
||||||
|
msgId: 'msg-123-outage',
|
||||||
|
},
|
||||||
|
user: { userId: 'user-123' },
|
||||||
|
scheduledTaskService: {
|
||||||
|
async listTasks() {
|
||||||
|
return [{
|
||||||
|
id: 'task-suzhou',
|
||||||
|
title: '每天早上6:30推送苏州市天气预报',
|
||||||
|
recurrence: 'daily',
|
||||||
|
hour: 6,
|
||||||
|
minute: 30,
|
||||||
|
timezone: 'Asia/Shanghai',
|
||||||
|
}];
|
||||||
|
},
|
||||||
|
async cancelTask(payload) {
|
||||||
|
calls.push(payload);
|
||||||
|
return {
|
||||||
|
id: 'task-suzhou',
|
||||||
|
title: '每天早上6:30推送苏州市天气预报',
|
||||||
|
status: 'cancelled',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wechatScheduledTaskManageLlmConfigService: {
|
||||||
|
async isManageLlmEnabled() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async getConfig() {
|
||||||
|
return { modelProviderKeyId: 'deepseek-key', model: 'deepseek-chat' };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
llmProviderService: {
|
||||||
|
async listKeys() {
|
||||||
|
return [{ id: 'deepseek-key', name: 'DeepSeek', status: 'active', defaultModel: 'deepseek-chat', models: ['deepseek-chat'] }];
|
||||||
|
},
|
||||||
|
async createChatCompletion() {
|
||||||
|
return { ok: false, status: 402, message: '{"error":{"message":"Insufficient Balance"}}' };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.match(reply, /已取消定时任务/u);
|
||||||
|
assert.deepEqual(calls, [{ userId: 'user-123', taskId: 'task-suzhou' }]);
|
||||||
|
});
|
||||||
|
|
||||||
test('handleWechatScheduledTaskIntent returns clarify message when manage llm is ambiguous', async () => {
|
test('handleWechatScheduledTaskIntent returns clarify message when manage llm is ambiguous', async () => {
|
||||||
const reply = await handleWechatScheduledTaskIntent({
|
const reply = await handleWechatScheduledTaskIntent({
|
||||||
intent: {
|
intent: {
|
||||||
|
|||||||
Reference in New Issue
Block a user