增加了空回为失败和自定义失败关键词的功能,修复了机器人提出的问题 (#153)
* Add proxy failure rules for keywords and empty responses * fix(proxy): detect failure keywords for streamed chat responses * Fix(tester): treat empty stream output as error * feat(server): default listen host to IPv6 any-address * fix(proxy): treat empty upstream content as failure * Revert "feat(server): default listen host to IPv6 any-address" This reverts commit 2ca49a3891cc3a2e37138156b17b660b6a3cb7ea. * fix(settings): validate proxy failure settings * fix: address CodeRabbit feedback in proxy failure detection * test: force NODE_ENV=test for vitest to avoid React act() prod build * fix(proxy/chat): preserve true SSE streaming (no upstream.text buffering) * fix: avoid http fallback after hijacked SSE stream * fix: wire proxy failure detection into chat and responses --------- Co-authored-by: metapi-bot <metapi-bot@local> Co-authored-by: openclaw <bot@local> Co-authored-by: openclaw <openclaw@local> Co-authored-by: openclaw-executor <executor@openclaw.local> Co-authored-by: xuyufengfei <xuyufengfei@users.noreply.github.com> Co-authored-by: cita <juricek.chen@gmail.com>
This commit is contained in:
@@ -273,6 +273,60 @@ describe('settings and auth events', () => {
|
||||
expect(runtime.systemProxyUrl).toBe('http://127.0.0.1:7890');
|
||||
});
|
||||
|
||||
it('splits proxy error keywords on newlines and commas when saving runtime settings', async () => {
|
||||
const updateResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/runtime',
|
||||
payload: {
|
||||
proxyErrorKeywords: 'quota exceeded\nbad gateway,too many requests',
|
||||
proxyEmptyContentFailEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResponse.statusCode).toBe(200);
|
||||
const updated = updateResponse.json() as {
|
||||
proxyErrorKeywords?: string[];
|
||||
proxyEmptyContentFailEnabled?: boolean;
|
||||
};
|
||||
expect(updated.proxyErrorKeywords).toEqual([
|
||||
'quota exceeded',
|
||||
'bad gateway',
|
||||
'too many requests',
|
||||
]);
|
||||
expect(updated.proxyEmptyContentFailEnabled).toBe(true);
|
||||
expect(config.proxyErrorKeywords).toEqual([
|
||||
'quota exceeded',
|
||||
'bad gateway',
|
||||
'too many requests',
|
||||
]);
|
||||
expect(config.proxyEmptyContentFailEnabled).toBe(true);
|
||||
|
||||
const rows = await db.select().from(schema.settings).all();
|
||||
const settingsMap = new Map(rows.map((row) => [row.key, row.value]));
|
||||
expect(settingsMap.get('proxy_error_keywords')).toBe(JSON.stringify([
|
||||
'quota exceeded',
|
||||
'bad gateway',
|
||||
'too many requests',
|
||||
]));
|
||||
expect(settingsMap.get('proxy_empty_content_fail_enabled')).toBe(JSON.stringify(true));
|
||||
|
||||
const getResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/settings/runtime',
|
||||
});
|
||||
expect(getResponse.statusCode).toBe(200);
|
||||
const runtime = getResponse.json() as {
|
||||
proxyErrorKeywords?: string[];
|
||||
proxyEmptyContentFailEnabled?: boolean;
|
||||
};
|
||||
expect(runtime.proxyErrorKeywords).toEqual([
|
||||
'quota exceeded',
|
||||
'bad gateway',
|
||||
'too many requests',
|
||||
]);
|
||||
expect(runtime.proxyEmptyContentFailEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('persists and returns log cleanup settings from runtime settings', async () => {
|
||||
const updateResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
|
||||
@@ -55,6 +55,8 @@ interface RuntimeSettingsBody {
|
||||
adminIpAllowlist?: string[] | string;
|
||||
routingFallbackUnitCost?: number;
|
||||
routingWeights?: Partial<RoutingWeights>;
|
||||
proxyErrorKeywords?: string[] | string;
|
||||
proxyEmptyContentFailEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface DatabaseMigrationBody {
|
||||
@@ -127,6 +129,33 @@ function toStringList(value: unknown): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseProxyErrorKeywords(value: unknown): string[] {
|
||||
const splitKeywords = (input: string): string[] => input
|
||||
.split(/\r?\n|,/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const keywords = value.flatMap((item) => {
|
||||
if (typeof item !== 'string') return [];
|
||||
return splitKeywords(item);
|
||||
});
|
||||
return keywords;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const keywords = splitKeywords(value);
|
||||
return keywords;
|
||||
}
|
||||
|
||||
throw new Error('上游错误关键词格式无效:需要 string 或 string[]');
|
||||
}
|
||||
|
||||
function parseBooleanFlag(value: unknown, label: string): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
throw new Error(`${label}格式无效:需要 boolean`);
|
||||
}
|
||||
|
||||
function isValidHttpUrl(raw: string): boolean {
|
||||
const value = String(raw || '').trim();
|
||||
if (!value) return false;
|
||||
@@ -197,6 +226,22 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
||||
config.systemProxyUrl = normalizeSiteProxyUrl(value) || '';
|
||||
return;
|
||||
}
|
||||
case 'proxy_error_keywords': {
|
||||
try {
|
||||
config.proxyErrorKeywords = parseProxyErrorKeywords(value);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'proxy_empty_content_fail_enabled': {
|
||||
try {
|
||||
config.proxyEmptyContentFailEnabled = parseBooleanFlag(value, '空内容判定失败开关');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'webhook_url': {
|
||||
if (typeof value !== 'string') return;
|
||||
config.webhookUrl = value.trim();
|
||||
@@ -347,6 +392,8 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
||||
adminIpAllowlist: config.adminIpAllowlist,
|
||||
currentAdminIp,
|
||||
systemProxyUrl: config.systemProxyUrl,
|
||||
proxyErrorKeywords: config.proxyErrorKeywords,
|
||||
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
|
||||
proxyTokenMasked: maskSecret(config.proxyToken),
|
||||
};
|
||||
}
|
||||
@@ -603,6 +650,42 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
invalidateSiteProxyCache();
|
||||
}
|
||||
|
||||
if (body.proxyErrorKeywords !== undefined) {
|
||||
let nextKeywords: string[] = [];
|
||||
try {
|
||||
nextKeywords = parseProxyErrorKeywords(body.proxyErrorKeywords);
|
||||
} catch (err: any) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: err?.message || '上游错误关键词格式无效',
|
||||
});
|
||||
}
|
||||
|
||||
if (JSON.stringify(nextKeywords) !== JSON.stringify(config.proxyErrorKeywords || [])) {
|
||||
changedLabels.push('上游错误关键词');
|
||||
}
|
||||
config.proxyErrorKeywords = nextKeywords;
|
||||
upsertSetting('proxy_error_keywords', config.proxyErrorKeywords);
|
||||
}
|
||||
|
||||
if (body.proxyEmptyContentFailEnabled !== undefined) {
|
||||
let nextValue = false;
|
||||
try {
|
||||
nextValue = parseBooleanFlag(body.proxyEmptyContentFailEnabled, '空内容判定失败开关');
|
||||
} catch (err: any) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: err?.message || '空内容判定失败开关格式无效',
|
||||
});
|
||||
}
|
||||
|
||||
if (nextValue !== config.proxyEmptyContentFailEnabled) {
|
||||
changedLabels.push('空内容判定失败');
|
||||
}
|
||||
config.proxyEmptyContentFailEnabled = nextValue;
|
||||
upsertSetting('proxy_empty_content_fail_enabled', config.proxyEmptyContentFailEnabled);
|
||||
}
|
||||
|
||||
if (body.webhookUrl !== undefined) {
|
||||
if (String(body.webhookUrl || '').trim() !== config.webhookUrl) {
|
||||
changedLabels.push('Webhook 地址');
|
||||
|
||||
Reference in New Issue
Block a user