@dsh-plus/lifeboat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/lib/client.js +39 -0
- package/lib/index.d.ts +26 -0
- package/lib/index.js +440 -0
- package/package.json +53 -0
- package/src/client/client.ts +45 -0
- package/src/config.ts +54 -0
- package/src/fallback-llm.ts +273 -0
- package/src/index.ts +84 -0
- package/src/notify.ts +41 -0
- package/src/ns.ts +5 -0
- package/src/patch-file.ts +45 -0
- package/src/quarantine-api.ts +56 -0
- package/src/quarantine.ts +84 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 agguy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@dsh-plus/lifeboat",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
//#region src/client/client.ts
|
|
9
|
+
const name = "dsh-plus-lifeboat";
|
|
10
|
+
/** Runtime mirror: FiberState 是 cordis 跨包 const enum(FAILED = 3,cordis 4.0.1)。 */
|
|
11
|
+
const FIBER_FAILED = 3;
|
|
12
|
+
/** 永不回报的条目:救生艇自身与聚合 bundle。 */
|
|
13
|
+
const EXCLUDED = new Set(["dsh-plus-lifeboat", "dsh-plus-bundle-main"]);
|
|
14
|
+
const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/;
|
|
15
|
+
function apply(ctx) {
|
|
16
|
+
try {
|
|
17
|
+
const reported = /* @__PURE__ */ new Set();
|
|
18
|
+
ctx.root.on("internal/status", (fiber) => {
|
|
19
|
+
try {
|
|
20
|
+
if (fiber.state !== FIBER_FAILED) return;
|
|
21
|
+
const plugin = fiber.name;
|
|
22
|
+
if (!GUARD_RE.test(plugin) || EXCLUDED.has(plugin) || reported.has(plugin)) return;
|
|
23
|
+
reported.add(plugin);
|
|
24
|
+
fetch("/dsh-plus/lifeboat/quarantine", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "content-type": "application/json" },
|
|
27
|
+
body: JSON.stringify({ name: plugin })
|
|
28
|
+
}).catch(() => {});
|
|
29
|
+
} catch {}
|
|
30
|
+
});
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
exports.apply = apply;
|
|
36
|
+
exports.name = name;
|
|
37
|
+
return module.exports;
|
|
38
|
+
}
|
|
39
|
+
});
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import "@deepseek-ai/dsh-settings";
|
|
3
|
+
import { Context } from "@deepseek-ai/cordis";
|
|
4
|
+
|
|
5
|
+
//#region src/config.d.ts
|
|
6
|
+
|
|
7
|
+
/** 行级配置:全部有默认值,正常部署零配置。 */
|
|
8
|
+
declare const Config: z<Schemastery.ObjectS<{
|
|
9
|
+
enabled: z<boolean, boolean>;
|
|
10
|
+
patchFile: z<string, string>;
|
|
11
|
+
llmFallback: z<boolean, boolean>;
|
|
12
|
+
alertCooldownMs: z<number, number>;
|
|
13
|
+
}>, Schemastery.ObjectT<{
|
|
14
|
+
enabled: z<boolean, boolean>;
|
|
15
|
+
patchFile: z<string, string>;
|
|
16
|
+
llmFallback: z<boolean, boolean>;
|
|
17
|
+
alertCooldownMs: z<number, number>;
|
|
18
|
+
}>>;
|
|
19
|
+
type LifeboatConfig = Schemastery.TypeT<typeof Config>;
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/index.d.ts
|
|
22
|
+
declare const name = "dsh-plus-lifeboat";
|
|
23
|
+
declare const inject: readonly ["settings", "llm"];
|
|
24
|
+
declare function apply(ctx: Context, config: LifeboatConfig): void;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { Config, apply, inject, name };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { SettingsConflictError, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
4
|
+
import { copyFile, readFile, rename, writeFile } from "node:fs/promises";
|
|
5
|
+
import { parseDocument } from "yaml";
|
|
6
|
+
|
|
7
|
+
//#region src/ns.ts
|
|
8
|
+
/**
|
|
9
|
+
* settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
|
|
10
|
+
* @module lifeboat/ns
|
|
11
|
+
*/
|
|
12
|
+
const SETTINGS_NS$1 = "dsh-plus-lifeboat";
|
|
13
|
+
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/config.ts
|
|
16
|
+
/** settings 命名空间(品牌化后供 settings 服务注册/读写)。 */
|
|
17
|
+
const SETTINGS_NS = settingsNamespace(SETTINGS_NS$1);
|
|
18
|
+
/** 行级配置:全部有默认值,正常部署零配置。 */
|
|
19
|
+
const Config = z.object({
|
|
20
|
+
enabled: z.boolean().description("故障隔离总开关").default(true),
|
|
21
|
+
patchFile: z.string().description("隔离写入的 profile 用户 patch 文件绝对路径;留空自动取 $DSH_HOME/profiles/web/cordis.patch.yml").default(""),
|
|
22
|
+
llmFallback: z.boolean().description("llm-pi 缺席时自动翻译其配置到官方 llm-pi-ai 并切换默认模型").default(true),
|
|
23
|
+
alertCooldownMs: z.natural().description("同一插件重复告警的最小间隔毫秒数").default(3e5)
|
|
24
|
+
});
|
|
25
|
+
/** journal 单条记录。 */
|
|
26
|
+
const JournalEntry = z.object({
|
|
27
|
+
at: z.string(),
|
|
28
|
+
kind: z.string(),
|
|
29
|
+
detail: z.string()
|
|
30
|
+
});
|
|
31
|
+
/** LLM 应急翻译的持久状态(还原依据)。 */
|
|
32
|
+
const FallbackState = z.object({
|
|
33
|
+
active: z.boolean(),
|
|
34
|
+
originalProvider: z.string(),
|
|
35
|
+
originalModel: z.string(),
|
|
36
|
+
fallbackProvider: z.string(),
|
|
37
|
+
providers: z.array(z.string()),
|
|
38
|
+
at: z.string()
|
|
39
|
+
});
|
|
40
|
+
/** 命名空间 schema:宽松承载 journal 与翻译状态,上限截断防膨胀。 */
|
|
41
|
+
const JournalSchema = z.object({
|
|
42
|
+
journal: z.array(JournalEntry).description("救生艇操作日志(最新在尾,封顶 50 条)").default([]),
|
|
43
|
+
llmFallback: z.union([FallbackState, z.const(null)]).description("LLM 应急翻译状态;null 表示未处于降级").default(null)
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/notify.ts
|
|
48
|
+
/**
|
|
49
|
+
* 装配告警出口。返回同步派单函数:内部异步发送,失败只记日志。
|
|
50
|
+
* notify-email 出现时升级为邮件;全程 journal 兜底可溯。
|
|
51
|
+
*/
|
|
52
|
+
function installAlerter(ctx, journal) {
|
|
53
|
+
const logger = ctx.logger("lifeboat");
|
|
54
|
+
let mailer;
|
|
55
|
+
ctx.inject(["notifyEmail"], (mailCtx) => {
|
|
56
|
+
mailer = mailCtx.notifyEmail;
|
|
57
|
+
return () => {
|
|
58
|
+
mailer = void 0;
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
return (subject, text) => {
|
|
62
|
+
journal("alert", `${subject} | ${text}`);
|
|
63
|
+
const active = mailer;
|
|
64
|
+
if (!active?.sendNotice) {
|
|
65
|
+
logger.warn(`${subject} —— ${text}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
active.sendNotice({
|
|
69
|
+
subject,
|
|
70
|
+
text
|
|
71
|
+
}).then((result) => {
|
|
72
|
+
if (!result.ok) logger.warn(`邮件告警投递失败: ${result.detail}`);
|
|
73
|
+
}, (error) => {
|
|
74
|
+
logger.warn(`邮件告警异常: ${error instanceof Error ? error.message : String(error)}`);
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/patch-file.ts
|
|
81
|
+
/** 条目(含 insert 子列表)中是否已有针对 targetId 的 disabled 覆盖。 */
|
|
82
|
+
function hasDisable(entries, targetId) {
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
if (entry.id === targetId && entry.disabled === true) return true;
|
|
85
|
+
if (Array.isArray(entry.insert) && hasDisable(entry.insert, targetId)) return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 向 patch 文件追加一条禁用覆盖;返回是否实际写入(已存在禁用则跳过)。
|
|
91
|
+
* @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
|
|
92
|
+
*/
|
|
93
|
+
async function appendDisable(patchFile, targetId) {
|
|
94
|
+
const doc = parseDocument(await readFile(patchFile, "utf-8"));
|
|
95
|
+
const root = doc.toJS();
|
|
96
|
+
if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`);
|
|
97
|
+
if (hasDisable(root, targetId)) return false;
|
|
98
|
+
await copyFile(patchFile, `${patchFile}.lifeboat.bak`);
|
|
99
|
+
doc.add({
|
|
100
|
+
id: targetId,
|
|
101
|
+
disabled: true
|
|
102
|
+
});
|
|
103
|
+
const tmp = `${patchFile}.lifeboat.tmp`;
|
|
104
|
+
await writeFile(tmp, doc.toString(), "utf-8");
|
|
105
|
+
await rename(tmp, patchFile);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/quarantine.ts
|
|
111
|
+
/** Runtime mirror: FiberState 是 cordis 跨包 const enum(官方 dsh-host-plugin-inventory 同款做法)。 */
|
|
112
|
+
const FIBER_FAILED = 3;
|
|
113
|
+
/** 永不隔离的条目:救生艇自身与聚合 bundle(禁了它们等于关掉整个防线/全家)。 */
|
|
114
|
+
const EXCLUDED = new Set(["dsh-plus-lifeboat", "dsh-plus-bundle-main"]);
|
|
115
|
+
const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/;
|
|
116
|
+
/** 名字是否属于可隔离的 dsh-plus 兄弟插件(entry id 与插件 name 同字面量是本仓库约定)。 */
|
|
117
|
+
function isGuardedPlugin(name$1) {
|
|
118
|
+
return typeof name$1 === "string" && GUARD_RE.test(name$1) && !EXCLUDED.has(name$1);
|
|
119
|
+
}
|
|
120
|
+
/** 装配隔离执行器(纯逻辑,可测)。 */
|
|
121
|
+
function createQuarantine(ctx, deps) {
|
|
122
|
+
const logger = ctx.logger("lifeboat");
|
|
123
|
+
const lastAlertAt = /* @__PURE__ */ new Map();
|
|
124
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
125
|
+
async function quarantine(name$1, origin) {
|
|
126
|
+
if (!isGuardedPlugin(name$1)) return;
|
|
127
|
+
if (inFlight.has(name$1)) return;
|
|
128
|
+
inFlight.add(name$1);
|
|
129
|
+
try {
|
|
130
|
+
const written = await appendDisable(deps.patchFile, name$1);
|
|
131
|
+
deps.journal("quarantine", `${name$1}(来源 ${origin},${written ? "已写入禁用" : "已存在禁用"})`);
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
if (now - (lastAlertAt.get(name$1) ?? 0) >= deps.alertCooldownMs) {
|
|
134
|
+
lastAlertAt.set(name$1, now);
|
|
135
|
+
deps.alert(`[DSH] 插件 ${name$1} 加载失败,已自动隔离`, `检测到插件 ${name$1} 的 fiber 进入 FAILED(来源:${origin})。\n已向 ${deps.patchFile} 写入 disabled 覆盖,重启/刷新后该插件不再加载,其余插件不受影响。\n修复后删除该条覆盖即可恢复。`);
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
139
|
+
logger.warn(`隔离 ${name$1} 失败: ${message}`);
|
|
140
|
+
deps.journal("quarantine-error", `${name$1}: ${message}`);
|
|
141
|
+
deps.alert(`[DSH] 插件 ${name$1} 加载失败,自动隔离未果`, `隔离写入失败:${message}\n请手动在 patch 层禁用该插件。`);
|
|
142
|
+
} finally {
|
|
143
|
+
inFlight.delete(name$1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { quarantine };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* host 侧监听:root context 上兄弟 fiber 的 internal/status 转移。
|
|
150
|
+
* fiber.name 对 loader 条目即插件 display name(模块导出的 name 字段)。
|
|
151
|
+
*/
|
|
152
|
+
function installHostWatch(ctx, q) {
|
|
153
|
+
ctx.root.on("internal/status", (fiber) => {
|
|
154
|
+
if (fiber.state !== FIBER_FAILED) return;
|
|
155
|
+
if (!isGuardedPlugin(fiber.name)) return;
|
|
156
|
+
q.quarantine(fiber.name, "host");
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/quarantine-api.ts
|
|
162
|
+
const ROUTE = "/dsh-plus/lifeboat/quarantine";
|
|
163
|
+
function sendJson(res, status, body) {
|
|
164
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
165
|
+
res.end(JSON.stringify(body));
|
|
166
|
+
}
|
|
167
|
+
function readBody(req) {
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const chunks = [];
|
|
170
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
171
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
172
|
+
req.on("error", reject);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/** 注册哨兵回报路由(webServer 缺失时由调用方保证不调用)。 */
|
|
176
|
+
function registerQuarantineApi(ctx, q) {
|
|
177
|
+
const logger = ctx.logger("lifeboat");
|
|
178
|
+
ctx.webServer.register({
|
|
179
|
+
kind: "prefix",
|
|
180
|
+
path: ROUTE,
|
|
181
|
+
handler: async (req, res) => {
|
|
182
|
+
try {
|
|
183
|
+
if (req.method !== "POST") {
|
|
184
|
+
sendJson(res, 405, { error: "POST only" });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const body = JSON.parse(await readBody(req));
|
|
188
|
+
if (typeof body.name !== "string") {
|
|
189
|
+
sendJson(res, 400, { error: "missing name" });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
q.quarantine(body.name, "client");
|
|
193
|
+
sendJson(res, 200, { ok: true });
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
196
|
+
logger.warn(`quarantine api failed: ${message}`);
|
|
197
|
+
if (!res.headersSent) sendJson(res, 400, { error: message });
|
|
198
|
+
else res.end();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/fallback-llm.ts
|
|
206
|
+
const NS_PI_AI = settingsNamespace("llm-pi-ai");
|
|
207
|
+
const NS_AGENT_DEFAULT = settingsNamespace("agent-default-model");
|
|
208
|
+
const RAW_NS_LLM_PI = "dsh-plus-llm-pi";
|
|
209
|
+
/** 翻译路由键后缀:与源路由错开,避免 llm-pi 恢复瞬间撞 DUPLICATE_ADAPTER。 */
|
|
210
|
+
const FALLBACK_SUFFIX = "-fb";
|
|
211
|
+
/** 官方配置路径物化的 compat 子集(其余字段降级期丢弃)。 */
|
|
212
|
+
const COMPAT_WHITELIST = ["thinkingFormat", "supportsReasoningEffort"];
|
|
213
|
+
/** 官方 reasoning 词表(ModelThinkingLevel 的保守子集),不在其中的值丢弃。 */
|
|
214
|
+
const REASONING_LEVELS = new Set([
|
|
215
|
+
"off",
|
|
216
|
+
"minimal",
|
|
217
|
+
"low",
|
|
218
|
+
"medium",
|
|
219
|
+
"high",
|
|
220
|
+
"xhigh",
|
|
221
|
+
"max"
|
|
222
|
+
]);
|
|
223
|
+
/** 模型继承源(extends 的 provider 前缀)→ 协议的已知映射。 */
|
|
224
|
+
const EXTENDS_PROTOCOL = {
|
|
225
|
+
anthropic: "anthropic-messages",
|
|
226
|
+
"kimi-coding": "anthropic-messages",
|
|
227
|
+
deepseek: "openai-completions",
|
|
228
|
+
openai: "openai-completions"
|
|
229
|
+
};
|
|
230
|
+
/** 路由名里的协议线索。 */
|
|
231
|
+
const ROUTE_PROTOCOL = {
|
|
232
|
+
chat: "openai-completions",
|
|
233
|
+
completions: "openai-completions",
|
|
234
|
+
response: "openai-responses",
|
|
235
|
+
responses: "openai-responses",
|
|
236
|
+
anthropic: "anthropic-messages",
|
|
237
|
+
claude: "anthropic-messages"
|
|
238
|
+
};
|
|
239
|
+
/**
|
|
240
|
+
* 推断路由协议:显式 api > 路由名线索 > 首个模型的继承源 > openai-completions。
|
|
241
|
+
* 兜底选 openai-completions 因为多数网关提供 OpenAI 兼容面;推错时告警里注明。
|
|
242
|
+
*/
|
|
243
|
+
function inferApi(routeKey, provider) {
|
|
244
|
+
if (typeof provider.api === "string" && provider.api.length > 0) return provider.api;
|
|
245
|
+
const routeHint = ROUTE_PROTOCOL[routeKey.toLowerCase()];
|
|
246
|
+
if (routeHint !== void 0) return routeHint;
|
|
247
|
+
const source = (provider.models?.find((m) => typeof m.extends === "string")?.extends)?.split("/")[0];
|
|
248
|
+
if (source !== void 0 && EXTENDS_PROTOCOL[source] !== void 0) return EXTENDS_PROTOCOL[source];
|
|
249
|
+
return "openai-completions";
|
|
250
|
+
}
|
|
251
|
+
/** 单个 provider 翻译(纯函数)。 */
|
|
252
|
+
function translateProvider(routeKey, raw) {
|
|
253
|
+
const out = {
|
|
254
|
+
displayName: `${raw.displayName ?? routeKey} [fallback]`,
|
|
255
|
+
api: inferApi(routeKey, raw)
|
|
256
|
+
};
|
|
257
|
+
if (raw.apiKeyEnv !== void 0) out.apiKeyEnv = raw.apiKeyEnv;
|
|
258
|
+
if (raw.baseURL !== void 0) out.baseURL = raw.baseURL;
|
|
259
|
+
if (raw.headers !== void 0) out.headers = raw.headers;
|
|
260
|
+
if (raw.reasoning !== void 0 && REASONING_LEVELS.has(raw.reasoning)) out.reasoning = raw.reasoning;
|
|
261
|
+
if (raw.thinkingBudgets !== void 0) out.thinkingBudgets = raw.thinkingBudgets;
|
|
262
|
+
if (Array.isArray(raw.defaultInput) && raw.defaultInput.length > 0) out.defaultInput = raw.defaultInput;
|
|
263
|
+
if (raw.compat !== void 0) {
|
|
264
|
+
const compat = {};
|
|
265
|
+
for (const key of COMPAT_WHITELIST) if (raw.compat[key] !== void 0) compat[key] = raw.compat[key];
|
|
266
|
+
if (Object.keys(compat).length > 0) out.compat = compat;
|
|
267
|
+
}
|
|
268
|
+
if (Array.isArray(raw.models) && raw.models.length > 0) out.models = raw.models.map((m) => {
|
|
269
|
+
const model = { id: m.id };
|
|
270
|
+
if (m.name !== void 0) model.name = m.name;
|
|
271
|
+
if (m.contextWindow !== void 0) model.contextWindow = m.contextWindow;
|
|
272
|
+
if (m.maxTokens !== void 0) model.maxTokens = m.maxTokens;
|
|
273
|
+
if (Array.isArray(m.input) && m.input.length > 0) model.input = m.input;
|
|
274
|
+
return model;
|
|
275
|
+
});
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
/** 整表翻译:键加 -fb 后缀(纯函数)。 */
|
|
279
|
+
function translateProviders(providers) {
|
|
280
|
+
const out = {};
|
|
281
|
+
for (const [key, raw] of Object.entries(providers)) out[`${key}${FALLBACK_SUFFIX}`] = translateProvider(key, raw);
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
/** 读 settings.yaml 原始文档的指定段落(只读;文件/段落缺失返回 undefined)。 */
|
|
285
|
+
async function readRawSection(settingsFile, ns) {
|
|
286
|
+
const section = parseDocument(await readFile(settingsFile, "utf-8")).toJS()[ns];
|
|
287
|
+
if (typeof section !== "object" || section === null) return void 0;
|
|
288
|
+
return section;
|
|
289
|
+
}
|
|
290
|
+
/** 写 settings 带一次冲突重试(用户同时改配置时不抢,放弃并告警)。 */
|
|
291
|
+
async function writeWithRetry(write) {
|
|
292
|
+
try {
|
|
293
|
+
await write();
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (!(error instanceof SettingsConflictError)) throw error;
|
|
296
|
+
await write();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** 装配 LLM 应急翻译:启动后及 settings/llm 变化时评估一次(并发合并)。 */
|
|
300
|
+
function installLlmFallback(ctx, deps) {
|
|
301
|
+
const logger = ctx.logger("lifeboat");
|
|
302
|
+
const writes = ctx.settings;
|
|
303
|
+
const settingsFile = dshHomePath("settings.yaml");
|
|
304
|
+
let inFlight;
|
|
305
|
+
const listProviderIds = () => new Set(ctx.llm.listProviders().map((p) => p.id));
|
|
306
|
+
async function activate(provider, model) {
|
|
307
|
+
const providers = (await readRawSection(settingsFile, RAW_NS_LLM_PI).catch(() => void 0))?.providers;
|
|
308
|
+
if (providers === void 0 || providers[provider] === void 0) {
|
|
309
|
+
deps.alert("[DSH] 默认模型不可用且无配置可翻译", `默认模型的 provider "${provider}" 没有已注册 adapter,且 ${RAW_NS_LLM_PI} 配置中找不到对应条目,无法应急翻译。请手动修复。`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const translated = translateProviders(providers);
|
|
313
|
+
const fbProvider = `${provider}${FALLBACK_SUFFIX}`;
|
|
314
|
+
await writeWithRetry(() => writes.update(NS_PI_AI, { providers: translated }));
|
|
315
|
+
await writeWithRetry(() => writes.update(NS_AGENT_DEFAULT, {
|
|
316
|
+
provider: fbProvider,
|
|
317
|
+
model
|
|
318
|
+
}));
|
|
319
|
+
await deps.writeState({
|
|
320
|
+
active: true,
|
|
321
|
+
originalProvider: provider,
|
|
322
|
+
originalModel: model,
|
|
323
|
+
fallbackProvider: fbProvider,
|
|
324
|
+
providers: Object.keys(translated),
|
|
325
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
326
|
+
});
|
|
327
|
+
deps.journal("llm-fallback", `默认模型切换到 ${fbProvider}/${model}(翻译 ${Object.keys(translated).length} 个 provider)`);
|
|
328
|
+
deps.alert("[DSH] LLM 配置已应急翻译", `默认模型的 provider "${provider}" 不可用(llm-pi 缺席?),已把 ${RAW_NS_LLM_PI} 配置翻译为官方 llm-pi-ai 路由(-fb 后缀)并切换默认模型为 ${fbProvider}/${model}。源插件恢复后将自动还原。`);
|
|
329
|
+
}
|
|
330
|
+
async function revert(state) {
|
|
331
|
+
const ops = state.providers.map((key) => ({
|
|
332
|
+
op: "unset",
|
|
333
|
+
path: ["providers", key]
|
|
334
|
+
}));
|
|
335
|
+
await writeWithRetry(() => writes.mutate(NS_PI_AI, ops));
|
|
336
|
+
await writeWithRetry(() => writes.update(NS_AGENT_DEFAULT, {
|
|
337
|
+
provider: state.originalProvider,
|
|
338
|
+
model: state.originalModel
|
|
339
|
+
}));
|
|
340
|
+
await deps.writeState(null);
|
|
341
|
+
deps.journal("llm-fallback-revert", `默认模型还原为 ${state.originalProvider}/${state.originalModel}`);
|
|
342
|
+
deps.alert("[DSH] LLM 应急翻译已还原", `provider "${state.originalProvider}" 恢复健康,默认模型已还原,临时翻译路由已清除。`);
|
|
343
|
+
}
|
|
344
|
+
async function evaluate() {
|
|
345
|
+
const state = deps.readState();
|
|
346
|
+
const ids = listProviderIds();
|
|
347
|
+
if (state?.active) {
|
|
348
|
+
if (ids.has(state.originalProvider)) await revert(state);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const def = ctx.settings.get(NS_AGENT_DEFAULT);
|
|
352
|
+
if (def?.provider === void 0 || def.model === void 0) return;
|
|
353
|
+
if (ids.has(def.provider)) return;
|
|
354
|
+
await activate(def.provider, def.model);
|
|
355
|
+
}
|
|
356
|
+
const run = () => {
|
|
357
|
+
inFlight ??= evaluate().catch((error) => {
|
|
358
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
359
|
+
logger.warn(`llm fallback 评估失败: ${message}`);
|
|
360
|
+
deps.journal("llm-fallback-error", message);
|
|
361
|
+
}).finally(() => {
|
|
362
|
+
inFlight = void 0;
|
|
363
|
+
});
|
|
364
|
+
};
|
|
365
|
+
const bootTimer = setTimeout(run, 1e4);
|
|
366
|
+
ctx.effect(() => () => clearTimeout(bootTimer), "lifeboat: fallback boot timer");
|
|
367
|
+
ctx.on("ready", run);
|
|
368
|
+
const WATCHED_NS = new Set([
|
|
369
|
+
"agent-default-model",
|
|
370
|
+
"llm-pi-ai",
|
|
371
|
+
RAW_NS_LLM_PI
|
|
372
|
+
]);
|
|
373
|
+
ctx.root.on("settings/updated", (ns) => {
|
|
374
|
+
if (typeof ns === "string" && WATCHED_NS.has(ns)) run();
|
|
375
|
+
});
|
|
376
|
+
ctx.root.on("llm/adapters-updated", run);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
//#endregion
|
|
380
|
+
//#region src/index.ts
|
|
381
|
+
const name = "dsh-plus-lifeboat";
|
|
382
|
+
const inject = ["settings", "llm"];
|
|
383
|
+
/** journal 上限:只留最近 50 条,防长期运行膨胀。 */
|
|
384
|
+
const JOURNAL_CAP = 50;
|
|
385
|
+
function apply(ctx, config) {
|
|
386
|
+
const logger = ctx.logger("lifeboat");
|
|
387
|
+
let doc = {
|
|
388
|
+
journal: [],
|
|
389
|
+
llmFallback: null
|
|
390
|
+
};
|
|
391
|
+
let persist;
|
|
392
|
+
try {
|
|
393
|
+
const scope = ctx.settings.register(SETTINGS_NS, JournalSchema);
|
|
394
|
+
doc = scope.get();
|
|
395
|
+
persist = (patch) => scope.update(patch);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
logger.warn(`journal 命名空间注册失败,降级为内存态: ${error instanceof Error ? error.message : String(error)}`);
|
|
398
|
+
}
|
|
399
|
+
const journal = (kind, detail) => {
|
|
400
|
+
doc = {
|
|
401
|
+
...doc,
|
|
402
|
+
journal: [...doc.journal, {
|
|
403
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
404
|
+
kind,
|
|
405
|
+
detail
|
|
406
|
+
}].slice(-JOURNAL_CAP)
|
|
407
|
+
};
|
|
408
|
+
persist?.({ journal: doc.journal }).catch((error) => {
|
|
409
|
+
logger.warn(`journal 持久化失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
410
|
+
});
|
|
411
|
+
};
|
|
412
|
+
const alert = installAlerter(ctx, journal);
|
|
413
|
+
if (config.enabled) {
|
|
414
|
+
const quarantine = createQuarantine(ctx, {
|
|
415
|
+
patchFile: config.patchFile.length > 0 ? config.patchFile : dshHomePath("profiles", "web", "cordis.patch.yml"),
|
|
416
|
+
alertCooldownMs: config.alertCooldownMs,
|
|
417
|
+
journal,
|
|
418
|
+
alert
|
|
419
|
+
});
|
|
420
|
+
installHostWatch(ctx, quarantine);
|
|
421
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
422
|
+
registerQuarantineApi(webCtx, quarantine);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
if (config.llmFallback) installLlmFallback(ctx, {
|
|
426
|
+
journal,
|
|
427
|
+
alert,
|
|
428
|
+
readState: () => doc.llmFallback,
|
|
429
|
+
writeState: async (state) => {
|
|
430
|
+
doc = {
|
|
431
|
+
...doc,
|
|
432
|
+
llmFallback: state
|
|
433
|
+
};
|
|
434
|
+
await persist?.({ llmFallback: state });
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
//#endregion
|
|
440
|
+
export { Config, apply, inject, name };
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dsh-plus/lifeboat",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "dsh-plus service plugin: 故障救生艇——兄弟插件崩溃时自动隔离(写用户 patch 层禁用),llm-pi 缺席时把其配置临时翻译为官方 llm-pi-ai 路由,恢复后自动还原",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"default": "./lib/client.js"
|
|
15
|
+
},
|
|
16
|
+
"./src/*": "./src/*",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"lib",
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"dsh": {
|
|
24
|
+
"client": {
|
|
25
|
+
"inject": [],
|
|
26
|
+
"platform": "web"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@deepseek-ai/cordis": "4.0.1",
|
|
31
|
+
"@deepseek-ai/dsh-home-paths": "0.1.0-rc.7",
|
|
32
|
+
"@deepseek-ai/dsh-llm": "0.1.0-rc.7",
|
|
33
|
+
"@deepseek-ai/dsh-settings": "0.1.0-rc.7",
|
|
34
|
+
"@deepseek-ai/schemastery": "3.18.1",
|
|
35
|
+
"yaml": "^2.6.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@deepseek-ai/dsh-host-webserver": "0.1.0-rc.7"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/A-G-guy/dsh-plus.git",
|
|
47
|
+
"directory": "packages/lifeboat"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsdown",
|
|
51
|
+
"watch": "tsdown --watch"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器半哨兵:bundle 首位加载,在 root context 监听兄弟 fiber 的 FAILED 转移,
|
|
3
|
+
* 经同源 HTTP 回报 host 半隔离。boot 失败页是内核所有(fail-loud),本次启动
|
|
4
|
+
* 无法挽回——哨兵保证的是「刷新一次即恢复」。
|
|
5
|
+
*
|
|
6
|
+
* 零依赖铁律:不 import 任何包(含 cordis/shared),FiberState.FAILED 以字面量
|
|
7
|
+
* 3 镜像一个道理(官方 dsh-host-plugin-inventory 同款做法);任何异常止步自身。
|
|
8
|
+
* @module @dsh-plus/lifeboat/client
|
|
9
|
+
*/
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
+
|
|
12
|
+
export const name = 'dsh-plus-lifeboat'
|
|
13
|
+
|
|
14
|
+
/** Runtime mirror: FiberState 是 cordis 跨包 const enum(FAILED = 3,cordis 4.0.1)。 */
|
|
15
|
+
const FIBER_FAILED = 3
|
|
16
|
+
|
|
17
|
+
/** 永不回报的条目:救生艇自身与聚合 bundle。 */
|
|
18
|
+
const EXCLUDED = new Set(['dsh-plus-lifeboat', 'dsh-plus-bundle-main'])
|
|
19
|
+
|
|
20
|
+
const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/
|
|
21
|
+
|
|
22
|
+
export function apply(ctx: Context): void {
|
|
23
|
+
try {
|
|
24
|
+
const reported = new Set<string>()
|
|
25
|
+
ctx.root.on('internal/status', (fiber: { state: number; name: string }) => {
|
|
26
|
+
try {
|
|
27
|
+
if (fiber.state !== FIBER_FAILED) return
|
|
28
|
+
const plugin = fiber.name
|
|
29
|
+
if (!GUARD_RE.test(plugin) || EXCLUDED.has(plugin) || reported.has(plugin)) return
|
|
30
|
+
reported.add(plugin)
|
|
31
|
+
void fetch('/dsh-plus/lifeboat/quarantine', {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'content-type': 'application/json' },
|
|
34
|
+
body: JSON.stringify({ name: plugin }),
|
|
35
|
+
}).catch(() => {
|
|
36
|
+
// boot 失败期连接未必就绪;host 半的监听仍是主通道,这里失败静默。
|
|
37
|
+
})
|
|
38
|
+
} catch {
|
|
39
|
+
// 哨兵监听器绝不向外抛异常(fail-loud 启动门下,自身异常会卡死整页)。
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
} catch {
|
|
43
|
+
// apply 自身同理。
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置与 journal 单一事实源。
|
|
3
|
+
* - 行级 Config(cordis 组合层):救生艇自身行为开关,dev/prod patch 层可覆盖。
|
|
4
|
+
* - settings 命名空间 dsh-plus-lifeboat:运行期 journal 与 LLM 应急翻译状态,
|
|
5
|
+
* 经 dsh-settings-file 持久化到 $DSH_HOME/settings.yaml,重启后可据此还原。
|
|
6
|
+
* schema 刻意宽松:lifeboat 是最后防线,自身命名空间的数据问题绝不能让它起不来。
|
|
7
|
+
* @module lifeboat/config
|
|
8
|
+
*/
|
|
9
|
+
import z from '@deepseek-ai/schemastery'
|
|
10
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
11
|
+
|
|
12
|
+
import { SETTINGS_NS as NS_LITERAL } from './ns.ts'
|
|
13
|
+
|
|
14
|
+
/** settings 命名空间(品牌化后供 settings 服务注册/读写)。 */
|
|
15
|
+
export const SETTINGS_NS = settingsNamespace(NS_LITERAL)
|
|
16
|
+
|
|
17
|
+
/** 行级配置:全部有默认值,正常部署零配置。 */
|
|
18
|
+
export const Config = z.object({
|
|
19
|
+
enabled: z.boolean().description('故障隔离总开关').default(true),
|
|
20
|
+
patchFile: z.string()
|
|
21
|
+
.description('隔离写入的 profile 用户 patch 文件绝对路径;留空自动取 $DSH_HOME/profiles/web/cordis.patch.yml')
|
|
22
|
+
.default(''),
|
|
23
|
+
llmFallback: z.boolean()
|
|
24
|
+
.description('llm-pi 缺席时自动翻译其配置到官方 llm-pi-ai 并切换默认模型').default(true),
|
|
25
|
+
alertCooldownMs: z.natural().description('同一插件重复告警的最小间隔毫秒数').default(300000),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export type LifeboatConfig = Schemastery.TypeT<typeof Config>
|
|
29
|
+
|
|
30
|
+
/** journal 单条记录。 */
|
|
31
|
+
const JournalEntry = z.object({
|
|
32
|
+
at: z.string(),
|
|
33
|
+
kind: z.string(),
|
|
34
|
+
detail: z.string(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/** LLM 应急翻译的持久状态(还原依据)。 */
|
|
38
|
+
const FallbackState = z.object({
|
|
39
|
+
active: z.boolean(),
|
|
40
|
+
originalProvider: z.string(),
|
|
41
|
+
originalModel: z.string(),
|
|
42
|
+
fallbackProvider: z.string(),
|
|
43
|
+
providers: z.array(z.string()),
|
|
44
|
+
at: z.string(),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/** 命名空间 schema:宽松承载 journal 与翻译状态,上限截断防膨胀。 */
|
|
48
|
+
export const JournalSchema = z.object({
|
|
49
|
+
journal: z.array(JournalEntry).description('救生艇操作日志(最新在尾,封顶 50 条)').default([]),
|
|
50
|
+
llmFallback: z.union([FallbackState, z.const(null)]).description('LLM 应急翻译状态;null 表示未处于降级').default(null),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
export type JournalDoc = Schemastery.TypeT<typeof JournalSchema>
|
|
54
|
+
export type FallbackStateT = Schemastery.TypeT<typeof FallbackState>
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM 应急翻译:默认模型路由的 provider 无已注册 adapter 时(典型场景:llm-pi
|
|
3
|
+
* 被隔离/加载失败),把 settings.yaml 里 dsh-plus-llm-pi 的 provider 配置翻译为
|
|
4
|
+
* 官方 llm-pi-ai 命名空间的原生格式(键加 -fb 后缀避开 DUPLICATE_ADAPTER 冲突),
|
|
5
|
+
* 默认模型指到翻译路由;源 provider 恢复健康后按 journal 自动还原。
|
|
6
|
+
*
|
|
7
|
+
* 为什么读原始 settings.yaml 而不是 settings 服务:插件被禁用后其命名空间随之
|
|
8
|
+
* 注销,服务层读不到——但数据仍在文件里(只读,不修改)。
|
|
9
|
+
* 降级语义:翻译只覆盖官方物化的字段子集,dsh-plus 扩展的全量 compat 丢弃;
|
|
10
|
+
* 协议推断是启发式(显式 api > 路由名 > 模型继承源 > openai-completions 兜底)。
|
|
11
|
+
* @module lifeboat/fallback-llm
|
|
12
|
+
*/
|
|
13
|
+
import { readFile } from 'node:fs/promises'
|
|
14
|
+
|
|
15
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
16
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
|
17
|
+
import type {} from '@deepseek-ai/dsh-llm'
|
|
18
|
+
import { SettingsConflictError, settingsNamespace, type SettingsProvider } from '@deepseek-ai/dsh-settings'
|
|
19
|
+
import { parseDocument } from 'yaml'
|
|
20
|
+
|
|
21
|
+
import type { FallbackStateT } from './config.ts'
|
|
22
|
+
import type { Alerter } from './notify.ts'
|
|
23
|
+
|
|
24
|
+
const NS_PI_AI = settingsNamespace('llm-pi-ai')
|
|
25
|
+
const NS_AGENT_DEFAULT = settingsNamespace('agent-default-model')
|
|
26
|
+
const RAW_NS_LLM_PI = 'dsh-plus-llm-pi'
|
|
27
|
+
|
|
28
|
+
/** 翻译路由键后缀:与源路由错开,避免 llm-pi 恢复瞬间撞 DUPLICATE_ADAPTER。 */
|
|
29
|
+
export const FALLBACK_SUFFIX = '-fb'
|
|
30
|
+
|
|
31
|
+
/** 官方配置路径物化的 compat 子集(其余字段降级期丢弃)。 */
|
|
32
|
+
const COMPAT_WHITELIST = ['thinkingFormat', 'supportsReasoningEffort'] as const
|
|
33
|
+
|
|
34
|
+
/** 官方 reasoning 词表(ModelThinkingLevel 的保守子集),不在其中的值丢弃。 */
|
|
35
|
+
const REASONING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
|
|
36
|
+
|
|
37
|
+
/** dsh-plus-llm-pi provider 配置的松散视图(原始文件读取,未经 schema 解析)。 */
|
|
38
|
+
export interface RawProvider {
|
|
39
|
+
displayName?: string
|
|
40
|
+
apiKeyEnv?: string
|
|
41
|
+
baseURL?: string
|
|
42
|
+
api?: string
|
|
43
|
+
headers?: Record<string, string>
|
|
44
|
+
reasoning?: string
|
|
45
|
+
thinkingBudgets?: Record<string, unknown>
|
|
46
|
+
defaultInput?: string[]
|
|
47
|
+
compat?: Record<string, unknown>
|
|
48
|
+
models?: RawModel[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RawModel {
|
|
52
|
+
id: string
|
|
53
|
+
name?: string
|
|
54
|
+
extends?: string
|
|
55
|
+
contextWindow?: number
|
|
56
|
+
maxTokens?: number
|
|
57
|
+
input?: string[]
|
|
58
|
+
reasoningEfforts?: Record<string, string>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 官方 llm-pi-ai provider 档案的翻译目标形状(字段子集)。 */
|
|
62
|
+
export interface TranslatedProvider {
|
|
63
|
+
displayName?: string
|
|
64
|
+
apiKeyEnv?: string
|
|
65
|
+
baseURL?: string
|
|
66
|
+
api: string
|
|
67
|
+
headers?: Record<string, string>
|
|
68
|
+
reasoning?: string
|
|
69
|
+
thinkingBudgets?: Record<string, unknown>
|
|
70
|
+
defaultInput?: string[]
|
|
71
|
+
compat?: Record<string, unknown>
|
|
72
|
+
models?: Record<string, unknown>[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 模型继承源(extends 的 provider 前缀)→ 协议的已知映射。 */
|
|
76
|
+
const EXTENDS_PROTOCOL: Record<string, string> = {
|
|
77
|
+
anthropic: 'anthropic-messages',
|
|
78
|
+
'kimi-coding': 'anthropic-messages',
|
|
79
|
+
deepseek: 'openai-completions',
|
|
80
|
+
openai: 'openai-completions',
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 路由名里的协议线索。 */
|
|
84
|
+
const ROUTE_PROTOCOL: Record<string, string> = {
|
|
85
|
+
chat: 'openai-completions',
|
|
86
|
+
completions: 'openai-completions',
|
|
87
|
+
response: 'openai-responses',
|
|
88
|
+
responses: 'openai-responses',
|
|
89
|
+
anthropic: 'anthropic-messages',
|
|
90
|
+
claude: 'anthropic-messages',
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 推断路由协议:显式 api > 路由名线索 > 首个模型的继承源 > openai-completions。
|
|
95
|
+
* 兜底选 openai-completions 因为多数网关提供 OpenAI 兼容面;推错时告警里注明。
|
|
96
|
+
*/
|
|
97
|
+
export function inferApi(routeKey: string, provider: RawProvider): string {
|
|
98
|
+
if (typeof provider.api === 'string' && provider.api.length > 0) return provider.api
|
|
99
|
+
const routeHint = ROUTE_PROTOCOL[routeKey.toLowerCase()]
|
|
100
|
+
if (routeHint !== undefined) return routeHint
|
|
101
|
+
const firstExtends = provider.models?.find((m) => typeof m.extends === 'string')?.extends
|
|
102
|
+
const source = firstExtends?.split('/')[0]
|
|
103
|
+
if (source !== undefined && EXTENDS_PROTOCOL[source] !== undefined) return EXTENDS_PROTOCOL[source]
|
|
104
|
+
return 'openai-completions'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 单个 provider 翻译(纯函数)。 */
|
|
108
|
+
export function translateProvider(routeKey: string, raw: RawProvider): TranslatedProvider {
|
|
109
|
+
const out: TranslatedProvider = {
|
|
110
|
+
displayName: `${raw.displayName ?? routeKey} [fallback]`,
|
|
111
|
+
api: inferApi(routeKey, raw),
|
|
112
|
+
}
|
|
113
|
+
if (raw.apiKeyEnv !== undefined) out.apiKeyEnv = raw.apiKeyEnv
|
|
114
|
+
if (raw.baseURL !== undefined) out.baseURL = raw.baseURL
|
|
115
|
+
if (raw.headers !== undefined) out.headers = raw.headers
|
|
116
|
+
if (raw.reasoning !== undefined && REASONING_LEVELS.has(raw.reasoning)) out.reasoning = raw.reasoning
|
|
117
|
+
if (raw.thinkingBudgets !== undefined) out.thinkingBudgets = raw.thinkingBudgets
|
|
118
|
+
if (Array.isArray(raw.defaultInput) && raw.defaultInput.length > 0) out.defaultInput = raw.defaultInput
|
|
119
|
+
if (raw.compat !== undefined) {
|
|
120
|
+
const compat: Record<string, unknown> = {}
|
|
121
|
+
for (const key of COMPAT_WHITELIST) {
|
|
122
|
+
if (raw.compat[key] !== undefined) compat[key] = raw.compat[key]
|
|
123
|
+
}
|
|
124
|
+
if (Object.keys(compat).length > 0) out.compat = compat
|
|
125
|
+
}
|
|
126
|
+
if (Array.isArray(raw.models) && raw.models.length > 0) {
|
|
127
|
+
out.models = raw.models.map((m) => {
|
|
128
|
+
const model: Record<string, unknown> = { id: m.id }
|
|
129
|
+
if (m.name !== undefined) model.name = m.name
|
|
130
|
+
if (m.contextWindow !== undefined) model.contextWindow = m.contextWindow
|
|
131
|
+
if (m.maxTokens !== undefined) model.maxTokens = m.maxTokens
|
|
132
|
+
if (Array.isArray(m.input) && m.input.length > 0) model.input = m.input
|
|
133
|
+
return model
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
return out
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 整表翻译:键加 -fb 后缀(纯函数)。 */
|
|
140
|
+
export function translateProviders(providers: Record<string, RawProvider>): Record<string, TranslatedProvider> {
|
|
141
|
+
const out: Record<string, TranslatedProvider> = {}
|
|
142
|
+
for (const [key, raw] of Object.entries(providers)) {
|
|
143
|
+
out[`${key}${FALLBACK_SUFFIX}`] = translateProvider(key, raw)
|
|
144
|
+
}
|
|
145
|
+
return out
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 读 settings.yaml 原始文档的指定段落(只读;文件/段落缺失返回 undefined)。 */
|
|
149
|
+
export async function readRawSection(settingsFile: string, ns: string): Promise<Record<string, unknown> | undefined> {
|
|
150
|
+
const text = await readFile(settingsFile, 'utf-8')
|
|
151
|
+
const doc = parseDocument(text).toJS() as Record<string, unknown>
|
|
152
|
+
const section = doc[ns]
|
|
153
|
+
if (typeof section !== 'object' || section === null) return undefined
|
|
154
|
+
return section as Record<string, unknown>
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface SettingsWrite {
|
|
158
|
+
update(ns: ReturnType<typeof settingsNamespace>, patch: object): Promise<void>
|
|
159
|
+
mutate(ns: ReturnType<typeof settingsNamespace>, ops: readonly { op: 'set' | 'unset'; path: readonly string[]; value?: unknown }[]): Promise<void>
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 写 settings 带一次冲突重试(用户同时改配置时不抢,放弃并告警)。 */
|
|
163
|
+
async function writeWithRetry(write: () => Promise<void>): Promise<void> {
|
|
164
|
+
try {
|
|
165
|
+
await write()
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (!(error instanceof SettingsConflictError)) throw error
|
|
168
|
+
await write()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface FallbackDeps {
|
|
173
|
+
journal: (kind: string, detail: string) => void
|
|
174
|
+
alert: Alerter
|
|
175
|
+
/** 持久化翻译状态(journal 命名空间内)。 */
|
|
176
|
+
readState(): FallbackStateT | null
|
|
177
|
+
writeState(state: FallbackStateT | null): Promise<void>
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 装配 LLM 应急翻译:启动后及 settings/llm 变化时评估一次(并发合并)。 */
|
|
181
|
+
export function installLlmFallback(ctx: Context, deps: FallbackDeps): void {
|
|
182
|
+
const logger = ctx.logger('lifeboat')
|
|
183
|
+
const settings = ctx.settings as SettingsProvider
|
|
184
|
+
const writes = settings as unknown as SettingsWrite
|
|
185
|
+
const settingsFile = dshHomePath('settings.yaml')
|
|
186
|
+
let inFlight: Promise<void> | undefined
|
|
187
|
+
|
|
188
|
+
const listProviderIds = (): Set<string> =>
|
|
189
|
+
new Set(ctx.llm.listProviders().map((p) => p.id))
|
|
190
|
+
|
|
191
|
+
async function activate(provider: string, model: string): Promise<void> {
|
|
192
|
+
const section = await readRawSection(settingsFile, RAW_NS_LLM_PI).catch(() => undefined)
|
|
193
|
+
const providers = section?.providers as Record<string, RawProvider> | undefined
|
|
194
|
+
if (providers === undefined || providers[provider] === undefined) {
|
|
195
|
+
deps.alert(
|
|
196
|
+
'[DSH] 默认模型不可用且无配置可翻译',
|
|
197
|
+
`默认模型的 provider "${provider}" 没有已注册 adapter,且 ${RAW_NS_LLM_PI} 配置中找不到对应条目,无法应急翻译。请手动修复。`,
|
|
198
|
+
)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
const translated = translateProviders(providers)
|
|
202
|
+
const fbProvider = `${provider}${FALLBACK_SUFFIX}`
|
|
203
|
+
await writeWithRetry(() => writes.update(NS_PI_AI, { providers: translated }))
|
|
204
|
+
await writeWithRetry(() => writes.update(NS_AGENT_DEFAULT, { provider: fbProvider, model }))
|
|
205
|
+
await deps.writeState({
|
|
206
|
+
active: true,
|
|
207
|
+
originalProvider: provider,
|
|
208
|
+
originalModel: model,
|
|
209
|
+
fallbackProvider: fbProvider,
|
|
210
|
+
providers: Object.keys(translated),
|
|
211
|
+
at: new Date().toISOString(),
|
|
212
|
+
})
|
|
213
|
+
deps.journal('llm-fallback', `默认模型切换到 ${fbProvider}/${model}(翻译 ${Object.keys(translated).length} 个 provider)`)
|
|
214
|
+
deps.alert(
|
|
215
|
+
'[DSH] LLM 配置已应急翻译',
|
|
216
|
+
`默认模型的 provider "${provider}" 不可用(llm-pi 缺席?),已把 ${RAW_NS_LLM_PI} 配置翻译为官方 llm-pi-ai 路由(-fb 后缀)并切换默认模型为 ${fbProvider}/${model}。源插件恢复后将自动还原。`,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function revert(state: FallbackStateT): Promise<void> {
|
|
221
|
+
const ops = state.providers.map((key) => ({ op: 'unset' as const, path: ['providers', key] }))
|
|
222
|
+
await writeWithRetry(() => writes.mutate(NS_PI_AI, ops))
|
|
223
|
+
await writeWithRetry(() =>
|
|
224
|
+
writes.update(NS_AGENT_DEFAULT, { provider: state.originalProvider, model: state.originalModel }),
|
|
225
|
+
)
|
|
226
|
+
await deps.writeState(null)
|
|
227
|
+
deps.journal('llm-fallback-revert', `默认模型还原为 ${state.originalProvider}/${state.originalModel}`)
|
|
228
|
+
deps.alert(
|
|
229
|
+
'[DSH] LLM 应急翻译已还原',
|
|
230
|
+
`provider "${state.originalProvider}" 恢复健康,默认模型已还原,临时翻译路由已清除。`,
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function evaluate(): Promise<void> {
|
|
235
|
+
const state = deps.readState()
|
|
236
|
+
const ids = listProviderIds()
|
|
237
|
+
if (state?.active) {
|
|
238
|
+
if (ids.has(state.originalProvider)) await revert(state)
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
const def = ctx.settings.get(NS_AGENT_DEFAULT) as { provider?: string; model?: string } | undefined
|
|
242
|
+
if (def?.provider === undefined || def.model === undefined) return
|
|
243
|
+
if (ids.has(def.provider)) return
|
|
244
|
+
await activate(def.provider, def.model)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const run = (): void => {
|
|
248
|
+
inFlight ??= evaluate()
|
|
249
|
+
.catch((error: unknown) => {
|
|
250
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
251
|
+
logger.warn(`llm fallback 评估失败: ${message}`)
|
|
252
|
+
deps.journal('llm-fallback-error', message)
|
|
253
|
+
})
|
|
254
|
+
.finally(() => {
|
|
255
|
+
inFlight = undefined
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 首次评估延迟到启动收尾:lifeboat 首位加载时兄弟插件的 adapter 尚未注册,
|
|
260
|
+
// 立刻评估会把"还没加载"误判为"缺席"。事件订阅覆盖后续变化。
|
|
261
|
+
const bootTimer = setTimeout(run, 10_000)
|
|
262
|
+
ctx.effect(
|
|
263
|
+
() => () => clearTimeout(bootTimer),
|
|
264
|
+
'lifeboat: fallback boot timer',
|
|
265
|
+
)
|
|
266
|
+
ctx.on('ready' as never, run)
|
|
267
|
+
// 只响应与本判定相关的命名空间变化:journal 写自身命名空间,不过滤会自触发循环。
|
|
268
|
+
const WATCHED_NS = new Set(['agent-default-model', 'llm-pi-ai', RAW_NS_LLM_PI])
|
|
269
|
+
ctx.root.on('settings/updated' as never, (ns: unknown) => {
|
|
270
|
+
if (typeof ns === 'string' && WATCHED_NS.has(ns)) run()
|
|
271
|
+
})
|
|
272
|
+
ctx.root.on('llm/adapters-updated' as never, run)
|
|
273
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh 插件:故障救生艇。
|
|
3
|
+
* 职责(全部事件触发,平时零开销):
|
|
4
|
+
* 1. 故障隔离——兄弟 dsh-plus 插件 fiber FAILED 时(host 侧直接监听,浏览器侧
|
|
5
|
+
* 经哨兵回报),向 profile 用户 patch 层写入 disabled 覆盖;重启/刷新后
|
|
6
|
+
* 失败插件缺席、其余正常,修复后删覆盖即恢复。
|
|
7
|
+
* 2. LLM 应急翻译——默认模型 provider 无 adapter 时,把 dsh-plus-llm-pi 配置
|
|
8
|
+
* 临时翻译为官方 llm-pi-ai 路由并切换默认模型,恢复后自动还原。
|
|
9
|
+
* 3. 告警与 journal——一切动作记录进自身 settings 命名空间,并经 notify-email
|
|
10
|
+
* 发邮件(缺席降级为日志)。
|
|
11
|
+
*
|
|
12
|
+
* 零 dsh-plus 内部依赖铁律:不 import 本仓库任何其他包,防止共享代码故障团灭。
|
|
13
|
+
* @module @dsh-plus/lifeboat
|
|
14
|
+
*/
|
|
15
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
16
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
|
17
|
+
|
|
18
|
+
import { Config, JournalSchema, SETTINGS_NS, type FallbackStateT, type JournalDoc, type LifeboatConfig } from './config.ts'
|
|
19
|
+
import { installAlerter } from './notify.ts'
|
|
20
|
+
import { createQuarantine, installHostWatch } from './quarantine.ts'
|
|
21
|
+
import { registerQuarantineApi } from './quarantine-api.ts'
|
|
22
|
+
import { installLlmFallback } from './fallback-llm.ts'
|
|
23
|
+
|
|
24
|
+
export const name = 'dsh-plus-lifeboat'
|
|
25
|
+
|
|
26
|
+
export const inject = ['settings', 'llm'] as const
|
|
27
|
+
|
|
28
|
+
export { Config }
|
|
29
|
+
|
|
30
|
+
/** journal 上限:只留最近 50 条,防长期运行膨胀。 */
|
|
31
|
+
const JOURNAL_CAP = 50
|
|
32
|
+
|
|
33
|
+
export function apply(ctx: Context, config: LifeboatConfig): void {
|
|
34
|
+
const logger = ctx.logger('lifeboat')
|
|
35
|
+
|
|
36
|
+
// 自身命名空间:journal 与翻译状态的持久化。schema 宽松,注册失败也不能
|
|
37
|
+
// 拖垮救生艇——降级为内存态(重启丢失 journal,但隔离/翻译功能仍在)。
|
|
38
|
+
let doc: JournalDoc = { journal: [], llmFallback: null }
|
|
39
|
+
let persist: ((patch: object) => Promise<void>) | undefined
|
|
40
|
+
try {
|
|
41
|
+
const scope = ctx.settings.register(SETTINGS_NS, JournalSchema)
|
|
42
|
+
doc = scope.get()
|
|
43
|
+
persist = (patch) => scope.update(patch)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
logger.warn(`journal 命名空间注册失败,降级为内存态: ${error instanceof Error ? error.message : String(error)}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const journal = (kind: string, detail: string): void => {
|
|
49
|
+
doc = { ...doc, journal: [...doc.journal, { at: new Date().toISOString(), kind, detail }].slice(-JOURNAL_CAP) }
|
|
50
|
+
void persist?.({ journal: doc.journal }).catch((error: unknown) => {
|
|
51
|
+
logger.warn(`journal 持久化失败: ${error instanceof Error ? error.message : String(error)}`)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const alert = installAlerter(ctx, journal)
|
|
56
|
+
|
|
57
|
+
if (config.enabled) {
|
|
58
|
+
const patchFile = config.patchFile.length > 0
|
|
59
|
+
? config.patchFile
|
|
60
|
+
: dshHomePath('profiles', 'web', 'cordis.patch.yml')
|
|
61
|
+
const quarantine = createQuarantine(ctx, {
|
|
62
|
+
patchFile,
|
|
63
|
+
alertCooldownMs: config.alertCooldownMs,
|
|
64
|
+
journal,
|
|
65
|
+
alert,
|
|
66
|
+
})
|
|
67
|
+
installHostWatch(ctx, quarantine)
|
|
68
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
69
|
+
registerQuarantineApi(webCtx, quarantine)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (config.llmFallback) {
|
|
74
|
+
installLlmFallback(ctx, {
|
|
75
|
+
journal,
|
|
76
|
+
alert,
|
|
77
|
+
readState: () => doc.llmFallback,
|
|
78
|
+
writeState: async (state: FallbackStateT | null) => {
|
|
79
|
+
doc = { ...doc, llmFallback: state }
|
|
80
|
+
await persist?.({ llmFallback: state })
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 告警出口:优先复用 notify-email 服务(懒取,不 inject 不 import——notify-email
|
|
3
|
+
* 自身被隔离时救生艇仍需工作),缺席降级为 logger.warn。所有告警同时进 journal。
|
|
4
|
+
* @module lifeboat/notify
|
|
5
|
+
*/
|
|
6
|
+
import type { Context, Logger } from '@deepseek-ai/cordis'
|
|
7
|
+
|
|
8
|
+
/** notify-email 服务的最小 duck 接口(避免 import 依赖)。 */
|
|
9
|
+
interface NotifyEmailLike {
|
|
10
|
+
sendNotice?(notice: { subject: string; text: string }): Promise<{ ok: boolean; detail: string }>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type Alerter = (subject: string, text: string) => void
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 装配告警出口。返回同步派单函数:内部异步发送,失败只记日志。
|
|
17
|
+
* notify-email 出现时升级为邮件;全程 journal 兜底可溯。
|
|
18
|
+
*/
|
|
19
|
+
export function installAlerter(ctx: Context, journal: (kind: string, detail: string) => void): Alerter {
|
|
20
|
+
const logger: Logger = ctx.logger('lifeboat')
|
|
21
|
+
let mailer: NotifyEmailLike | undefined
|
|
22
|
+
ctx.inject(['notifyEmail' as never], (mailCtx) => {
|
|
23
|
+
mailer = (mailCtx as unknown as { notifyEmail: NotifyEmailLike }).notifyEmail
|
|
24
|
+
return () => {
|
|
25
|
+
mailer = undefined
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
return (subject, text) => {
|
|
29
|
+
journal('alert', `${subject} | ${text}`)
|
|
30
|
+
const active = mailer
|
|
31
|
+
if (!active?.sendNotice) {
|
|
32
|
+
logger.warn(`${subject} —— ${text}`)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
void active.sendNotice({ subject, text }).then((result) => {
|
|
36
|
+
if (!result.ok) logger.warn(`邮件告警投递失败: ${result.detail}`)
|
|
37
|
+
}, (error: unknown) => {
|
|
38
|
+
logger.warn(`邮件告警异常: ${error instanceof Error ? error.message : String(error)}`)
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/ns.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* profile 用户 patch 文件(cordis.patch.yml)的安全写入。
|
|
3
|
+
* 官方预留的逃生门:用户层支持 id 定向 disabled,覆盖 bundle 层 insert。
|
|
4
|
+
* 写入纪律:先备份、幂等(已禁用跳过)、原子落盘(tmp + rename),失败抛错由调用方告警,
|
|
5
|
+
* 绝不写半个文件、绝不改动既有条目内容。
|
|
6
|
+
* @module lifeboat/patch-file
|
|
7
|
+
*/
|
|
8
|
+
import { copyFile, readFile, rename, writeFile } from 'node:fs/promises'
|
|
9
|
+
|
|
10
|
+
import { parseDocument } from 'yaml'
|
|
11
|
+
|
|
12
|
+
/** patch 文件顶层条目(松散视图:只关心 id/disabled/insert 三个键)。 */
|
|
13
|
+
export interface PatchEntry {
|
|
14
|
+
id?: string
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
insert?: PatchEntry[]
|
|
17
|
+
[key: string]: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 条目(含 insert 子列表)中是否已有针对 targetId 的 disabled 覆盖。 */
|
|
21
|
+
export function hasDisable(entries: PatchEntry[], targetId: string): boolean {
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
if (entry.id === targetId && entry.disabled === true) return true
|
|
24
|
+
if (Array.isArray(entry.insert) && hasDisable(entry.insert, targetId)) return true
|
|
25
|
+
}
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 向 patch 文件追加一条禁用覆盖;返回是否实际写入(已存在禁用则跳过)。
|
|
31
|
+
* @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
|
|
32
|
+
*/
|
|
33
|
+
export async function appendDisable(patchFile: string, targetId: string): Promise<boolean> {
|
|
34
|
+
const text = await readFile(patchFile, 'utf-8')
|
|
35
|
+
const doc = parseDocument(text)
|
|
36
|
+
const root = doc.toJS() as unknown
|
|
37
|
+
if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`)
|
|
38
|
+
if (hasDisable(root as PatchEntry[], targetId)) return false
|
|
39
|
+
await copyFile(patchFile, `${patchFile}.lifeboat.bak`)
|
|
40
|
+
doc.add({ id: targetId, disabled: true })
|
|
41
|
+
const tmp = `${patchFile}.lifeboat.tmp`
|
|
42
|
+
await writeFile(tmp, doc.toString(), 'utf-8')
|
|
43
|
+
await rename(tmp, patchFile)
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 哨兵回报的 host 接收面:POST /dsh-plus/lifeboat/quarantine {name}
|
|
3
|
+
* 仅监听 dsh web 同源(webserver 默认 127.0.0.1),与 GUI 其余面同等暴露面。
|
|
4
|
+
* @module lifeboat/quarantine-api
|
|
5
|
+
*/
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
7
|
+
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
10
|
+
|
|
11
|
+
import type { Quarantine } from './quarantine.ts'
|
|
12
|
+
|
|
13
|
+
const ROUTE = '/dsh-plus/lifeboat/quarantine'
|
|
14
|
+
|
|
15
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
16
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
17
|
+
res.end(JSON.stringify(body))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const chunks: Buffer[] = []
|
|
23
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
24
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
|
|
25
|
+
req.on('error', reject)
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 注册哨兵回报路由(webServer 缺失时由调用方保证不调用)。 */
|
|
30
|
+
export function registerQuarantineApi(ctx: Context, q: Quarantine): void {
|
|
31
|
+
const logger = ctx.logger('lifeboat')
|
|
32
|
+
ctx.webServer.register({
|
|
33
|
+
kind: 'prefix',
|
|
34
|
+
path: ROUTE,
|
|
35
|
+
handler: async (req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
if (req.method !== 'POST') {
|
|
38
|
+
sendJson(res, 405, { error: 'POST only' })
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
const body = JSON.parse(await readBody(req)) as { name?: unknown }
|
|
42
|
+
if (typeof body.name !== 'string') {
|
|
43
|
+
sendJson(res, 400, { error: 'missing name' })
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
void q.quarantine(body.name, 'client')
|
|
47
|
+
sendJson(res, 200, { ok: true })
|
|
48
|
+
} catch (error) {
|
|
49
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
50
|
+
logger.warn(`quarantine api failed: ${message}`)
|
|
51
|
+
if (!res.headersSent) sendJson(res, 400, { error: message })
|
|
52
|
+
else res.end()
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 故障隔离:监听兄弟 dsh-plus 插件的 fiber FAILED 转移(host 侧直接监听;
|
|
3
|
+
* 浏览器侧经哨兵 HTTP 回报),把对应 loader 条目禁用写进 profile 用户 patch 层。
|
|
4
|
+
* 只处理 dsh-plus- 前缀且排除自身/bundle——官方条目与救生艇自身一概不碰。
|
|
5
|
+
* @module lifeboat/quarantine
|
|
6
|
+
*/
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
8
|
+
|
|
9
|
+
import type { Alerter } from './notify.ts'
|
|
10
|
+
import { appendDisable } from './patch-file.ts'
|
|
11
|
+
|
|
12
|
+
/** Runtime mirror: FiberState 是 cordis 跨包 const enum(官方 dsh-host-plugin-inventory 同款做法)。 */
|
|
13
|
+
const FIBER_FAILED = 3
|
|
14
|
+
|
|
15
|
+
/** 永不隔离的条目:救生艇自身与聚合 bundle(禁了它们等于关掉整个防线/全家)。 */
|
|
16
|
+
const EXCLUDED = new Set(['dsh-plus-lifeboat', 'dsh-plus-bundle-main'])
|
|
17
|
+
|
|
18
|
+
const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/
|
|
19
|
+
|
|
20
|
+
/** 名字是否属于可隔离的 dsh-plus 兄弟插件(entry id 与插件 name 同字面量是本仓库约定)。 */
|
|
21
|
+
export function isGuardedPlugin(name: unknown): name is string {
|
|
22
|
+
return typeof name === 'string' && GUARD_RE.test(name) && !EXCLUDED.has(name)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface QuarantineDeps {
|
|
26
|
+
patchFile: string
|
|
27
|
+
alertCooldownMs: number
|
|
28
|
+
journal: (kind: string, detail: string) => void
|
|
29
|
+
alert: Alerter
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Quarantine {
|
|
33
|
+
/** 隔离指定插件(幂等;冷却期内重复触发只计 journal 不写文件)。 */
|
|
34
|
+
quarantine(name: string, origin: 'host' | 'client'): Promise<void>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 装配隔离执行器(纯逻辑,可测)。 */
|
|
38
|
+
export function createQuarantine(ctx: Context, deps: QuarantineDeps): Quarantine {
|
|
39
|
+
const logger = ctx.logger('lifeboat')
|
|
40
|
+
const lastAlertAt = new Map<string, number>()
|
|
41
|
+
const inFlight = new Set<string>()
|
|
42
|
+
|
|
43
|
+
async function quarantine(name: string, origin: 'host' | 'client'): Promise<void> {
|
|
44
|
+
if (!isGuardedPlugin(name)) return
|
|
45
|
+
if (inFlight.has(name)) return
|
|
46
|
+
inFlight.add(name)
|
|
47
|
+
try {
|
|
48
|
+
const written = await appendDisable(deps.patchFile, name)
|
|
49
|
+
deps.journal('quarantine', `${name}(来源 ${origin},${written ? '已写入禁用' : '已存在禁用'})`)
|
|
50
|
+
const now = Date.now()
|
|
51
|
+
const last = lastAlertAt.get(name) ?? 0
|
|
52
|
+
if (now - last >= deps.alertCooldownMs) {
|
|
53
|
+
lastAlertAt.set(name, now)
|
|
54
|
+
deps.alert(
|
|
55
|
+
`[DSH] 插件 ${name} 加载失败,已自动隔离`,
|
|
56
|
+
`检测到插件 ${name} 的 fiber 进入 FAILED(来源:${origin})。\n` +
|
|
57
|
+
`已向 ${deps.patchFile} 写入 disabled 覆盖,重启/刷新后该插件不再加载,其余插件不受影响。\n` +
|
|
58
|
+
`修复后删除该条覆盖即可恢复。`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
63
|
+
logger.warn(`隔离 ${name} 失败: ${message}`)
|
|
64
|
+
deps.journal('quarantine-error', `${name}: ${message}`)
|
|
65
|
+
deps.alert(`[DSH] 插件 ${name} 加载失败,自动隔离未果`, `隔离写入失败:${message}\n请手动在 patch 层禁用该插件。`)
|
|
66
|
+
} finally {
|
|
67
|
+
inFlight.delete(name)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { quarantine }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* host 侧监听:root context 上兄弟 fiber 的 internal/status 转移。
|
|
76
|
+
* fiber.name 对 loader 条目即插件 display name(模块导出的 name 字段)。
|
|
77
|
+
*/
|
|
78
|
+
export function installHostWatch(ctx: Context, q: Quarantine): void {
|
|
79
|
+
ctx.root.on('internal/status', (fiber: { state: number; name: string }) => {
|
|
80
|
+
if (fiber.state !== FIBER_FAILED) return
|
|
81
|
+
if (!isGuardedPlugin(fiber.name)) return
|
|
82
|
+
void q.quarantine(fiber.name, 'host')
|
|
83
|
+
})
|
|
84
|
+
}
|