@ai-agent-forge/plugin-provider-failover 0.88.1

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.
@@ -0,0 +1,780 @@
1
+ /**
2
+ * 第一方 provider 主备切换插件(设计:docs/design/供应商能力槽位与主备切换设计.md §4;
3
+ * D-082 批 3b 自内核内嵌实现逐字迁出为独立插件包)。
4
+ *
5
+ * 形态与 bootstrap-retry 同族——经公共 `api.registerStreamFnWrapper` 注册面挂进
6
+ * 宿主默认 stream 装配,`{ priority: -100 }` 固定链最外层(与装载顺序无关)——但
7
+ * 熔断状态是**模块级单例**(设计 §4.2):进程内所有会话与 subagent 共享同一份,
8
+ * 不随会话 dispose,不持久化(重启后全部 CLOSED)。
9
+ *
10
+ * 判定哲学(设计 §2):只看流终态 done/error/aborted 与"是否已向上层释放内容
11
+ * 增量"两个本地事实,不解读错误类型/错误码语义;原始 errorMessage 照透传保留,
12
+ * 只用于事件归因与 diagnostics,不参与机器判定分支。aborted 不计数、不换道、
13
+ * 状态不变。换道从第一次失败即发生;`failureThreshold` 只控制"是否继续先撞主"
14
+ * (连续 N 次后熔断打开,请求直接落备用,冷却期满后以真实请求试探回主)。
15
+ *
16
+ * 与内嵌装配的公共面差异(迁移登记,行为等价):
17
+ * - 配置不再经 SettingsManager 读取:宿主经 manifest config 通道注入
18
+ * `{ version: 1, failover: <settings failover 节原样> }`;`api.config` 无
19
+ * failover 节 = 未配置,工厂直接 return(不注册任何面)。
20
+ * - 装载预检检查面改从 `api.host.modelCatalog`(plugin-sdk `ModelCatalogV1`)
21
+ * 结构获取;配置了 failover 而宿主未注入 modelCatalog 时 fail-loud 抛错
22
+ * (与"settings 有 failover 但宿主不支持"同态,不静默降级)。
23
+ * - 插件面 `LifecycleAPI` 无 hasLifecycleEvent/dispatchLifecycle(与原内嵌第一参
24
+ * `CapabilityRuntime` 的同名方法不同构):目录 gate 以 `api.lifecycle.get` 的
25
+ * 抛错/命中等价实现(宿主未定义该事件 = 零派发);派发走公共 `api.publish`
26
+ * 事件总线(source 由运行时归属插件 manifest id;Envelope 无 phase 字段——
27
+ * 本插件的可见性事件全部是 committed 相位)。
28
+ * - 进程日志走 `api.logger`(plugin-sdk `LoggerAPI`)或工厂注入的同形 deps;
29
+ * warn 文案逐字保留。
30
+ */
31
+ import { createAssistantMessageEventStream } from "@agent-forge/ai/compat";
32
+ /** plugin.json 的 manifest id(原内嵌形态的 `agent-forge.provider-failover` 随内嵌装配退役)。 */
33
+ export const PROVIDER_FAILOVER_PLUGIN_ID = "agent-forge.plugin.provider-failover";
34
+ export const PROVIDER_FAILOVER_WRAPPER_NAME = "provider-failover";
35
+ /**
36
+ * 可见性事件的 id/version:与宿主 lifecycle-catalog.ts 的 provider.failover.*
37
+ * 常量逐字同值。插件包不可 import 宿主内部模块(D-075 §0 边界白名单),此处
38
+ * 本地声明;宿主未在 lifecycle 目录定义同名事件时 `api.lifecycle.get` 抛错,
39
+ * gate 关闭、零派发(值漂移因此被发现而不是静默错投)。
40
+ */
41
+ export const PROVIDER_FAILOVER_SWITCHED_EVENT_ID = "provider.failover.switched";
42
+ export const PROVIDER_FAILOVER_SWITCHED_EVENT_VERSION = 1;
43
+ export const PROVIDER_FAILOVER_RECOVERED_EVENT_ID = "provider.failover.recovered";
44
+ export const PROVIDER_FAILOVER_RECOVERED_EVENT_VERSION = 1;
45
+ export const PROVIDER_FAILOVER_PROBE_FAILED_EVENT_ID = "provider.failover.probe.failed";
46
+ export const PROVIDER_FAILOVER_PROBE_FAILED_EVENT_VERSION = 1;
47
+ /**
48
+ * 会话日志快照通道的 customType(设计 §4.7 Phase 3):插件经公共
49
+ * `api.session.appendEntry` 在状态转移点写入快照,footer 每帧取最后一条渲染
50
+ * 摘要行——与 goal.state / todo.state / workflow.state 同构,零新公共面。
51
+ */
52
+ export const PROVIDER_FAILOVER_STATE_ENTRY_TYPE = "provider.failover.state";
53
+ /** 链默认阈值/冷却(设计 §4.1:默认值全部可省)。 */
54
+ const DEFAULT_FAILURE_THRESHOLD = 2;
55
+ const DEFAULT_COOLDOWN_INITIAL_MS = 60_000;
56
+ const DEFAULT_COOLDOWN_MAX_MS = 15 * 60_000;
57
+ /** 事件负载与 diagnostics 里的原始错误文本截断上限(只影响展示,不影响判定)。 */
58
+ const RAW_ERROR_MAX_LENGTH = 500;
59
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
60
+ function parseModelReference(reference) {
61
+ const separator = reference.indexOf("/");
62
+ if (separator <= 0 || separator === reference.length - 1)
63
+ return undefined;
64
+ return { provider: reference.slice(0, separator), modelId: reference.slice(separator + 1) };
65
+ }
66
+ function parseModelReferenceList(value, label, warn) {
67
+ if (value === undefined)
68
+ return undefined;
69
+ if (!Array.isArray(value)) {
70
+ warn(`failover ${label} must be an array of "provider/modelId" strings; ignoring it`);
71
+ return undefined;
72
+ }
73
+ const references = [];
74
+ for (const entry of value) {
75
+ if (typeof entry !== "string" || parseModelReference(entry) === undefined) {
76
+ warn(`failover ${label} contains a non-"provider/modelId" entry; skipping it`);
77
+ continue;
78
+ }
79
+ references.push(entry);
80
+ }
81
+ return references;
82
+ }
83
+ /** `backupProviders` 是纯 provider id 列表(设计 §4.1:`["proxy-a", "proxy-b"]`),匹配运行时做。 */
84
+ function parseProviderIdList(value, warn) {
85
+ if (value === undefined)
86
+ return undefined;
87
+ if (!Array.isArray(value)) {
88
+ warn("failover.backupProviders must be an array of provider id strings; ignoring it");
89
+ return undefined;
90
+ }
91
+ const providerIds = [];
92
+ for (const entry of value) {
93
+ if (typeof entry !== "string" || entry.trim() === "") {
94
+ warn("failover.backupProviders contains a non-string entry; skipping it");
95
+ continue;
96
+ }
97
+ providerIds.push(entry);
98
+ }
99
+ return providerIds;
100
+ }
101
+ /**
102
+ * 解析 settings `failover` 节。返回 undefined = 无 failover(未配置 / 无有效
103
+ * backupProviders 与 chains / 配置错误拒绝加载——后者附带一条警告,设计 §4.1
104
+ * "重复 primary 拒绝加载并警告")。`failover.enabled` 开关已移除(2026-10-03
105
+ * D-079):节点存在即装配,残留 `enabled` 键只警告并忽略,按其余字段正常解析。
106
+ */
107
+ export function parseProviderFailoverConfig(raw, warn) {
108
+ if (raw === undefined || raw === null)
109
+ return undefined;
110
+ if (!isPlainObject(raw)) {
111
+ warn("failover settings must be an object; provider failover stays disabled");
112
+ return undefined;
113
+ }
114
+ if (raw.enabled !== undefined) {
115
+ warn("failover.enabled was removed (2026-10-03): the failover node installs whenever configured; remove the key");
116
+ }
117
+ let failureThreshold = DEFAULT_FAILURE_THRESHOLD;
118
+ if (raw.failureThreshold !== undefined) {
119
+ if (typeof raw.failureThreshold !== "number" ||
120
+ !Number.isSafeInteger(raw.failureThreshold) ||
121
+ raw.failureThreshold < 1) {
122
+ warn("failover.failureThreshold must be a positive integer; using the default (2)");
123
+ }
124
+ else {
125
+ failureThreshold = raw.failureThreshold;
126
+ }
127
+ }
128
+ let cooldownInitialMs = DEFAULT_COOLDOWN_INITIAL_MS;
129
+ let cooldownMaxMs = DEFAULT_COOLDOWN_MAX_MS;
130
+ if (raw.cooldown !== undefined) {
131
+ if (!isPlainObject(raw.cooldown)) {
132
+ warn("failover.cooldown must be an object { initialMs?, maxMs? }; using the defaults");
133
+ }
134
+ else {
135
+ const initialMs = raw.cooldown.initialMs;
136
+ const maxMs = raw.cooldown.maxMs;
137
+ if (initialMs !== undefined &&
138
+ (typeof initialMs !== "number" || !Number.isSafeInteger(initialMs) || initialMs < 1)) {
139
+ warn("failover.cooldown.initialMs must be a positive integer; using the default (60000)");
140
+ }
141
+ else if (initialMs !== undefined) {
142
+ cooldownInitialMs = initialMs;
143
+ }
144
+ if (maxMs !== undefined && (typeof maxMs !== "number" || !Number.isSafeInteger(maxMs) || maxMs < 1)) {
145
+ warn("failover.cooldown.maxMs must be a positive integer; using the default (900000)");
146
+ }
147
+ else if (maxMs !== undefined) {
148
+ cooldownMaxMs = maxMs;
149
+ }
150
+ }
151
+ }
152
+ if (cooldownMaxMs < cooldownInitialMs) {
153
+ warn("failover.cooldown.maxMs is below initialMs; clamping maxMs to initialMs");
154
+ cooldownMaxMs = cooldownInitialMs;
155
+ }
156
+ const backupProviders = parseProviderIdList(raw.backupProviders, warn) ?? [];
157
+ const chains = [];
158
+ if (raw.chains !== undefined) {
159
+ if (!Array.isArray(raw.chains)) {
160
+ warn("failover.chains must be an array; ignoring explicit chains");
161
+ }
162
+ else {
163
+ const seenPrimaries = new Set();
164
+ for (const entry of raw.chains) {
165
+ if (!isPlainObject(entry)) {
166
+ warn("failover.chains entries must be objects; skipping one");
167
+ continue;
168
+ }
169
+ if (typeof entry.primary !== "string" || parseModelReference(entry.primary) === undefined) {
170
+ warn('failover.chains entry is missing a valid "provider/modelId" primary; skipping it');
171
+ continue;
172
+ }
173
+ const backups = parseModelReferenceList(entry.backups, `chains[${entry.primary}].backups`, warn) ?? [];
174
+ if (seenPrimaries.has(entry.primary)) {
175
+ // 设计 §4.1:同一 primary 出现在多条显式链 → 配置错误拒绝加载。
176
+ warn(`failover.chains declares primary "${entry.primary}" more than once; provider failover stays disabled`);
177
+ return undefined;
178
+ }
179
+ seenPrimaries.add(entry.primary);
180
+ if (backups.length > 2) {
181
+ warn(`failover chain "${entry.primary}" declares ${backups.length} backups; the worst-case retry budget grows with chain length (recommended: at most 2)`);
182
+ }
183
+ chains.push({ primary: entry.primary, backups });
184
+ }
185
+ }
186
+ }
187
+ if (backupProviders.length === 0 && chains.length === 0)
188
+ return undefined;
189
+ return { cooldownInitialMs, cooldownMaxMs, failureThreshold, backupProviders, chains };
190
+ }
191
+ const providerBreakerRegistry = new Map();
192
+ /** 预检警告去重集(设计 §4.6:每进程至多一次,与会话重复装载解耦)。 */
193
+ const preflightWarnedMessages = new Set();
194
+ /** 测试隔离钩子:清空模块级熔断单例与预检警告去重集(仅测试使用)。 */
195
+ export function resetProviderFailoverStateForTests() {
196
+ providerBreakerRegistry.clear();
197
+ preflightWarnedMessages.clear();
198
+ }
199
+ function getBreaker(chainId, config) {
200
+ let breaker = providerBreakerRegistry.get(chainId);
201
+ if (breaker === undefined) {
202
+ breaker = {
203
+ status: "closed",
204
+ consecutiveFailures: 0,
205
+ cooldownMs: config.cooldownInitialMs,
206
+ openedAtMs: 0,
207
+ };
208
+ providerBreakerRegistry.set(chainId, breaker);
209
+ }
210
+ return breaker;
211
+ }
212
+ const isCommittedDelta = (event) => event.type === "text_delta" || event.type === "thinking_delta" || event.type === "toolcall_delta";
213
+ function truncateRawError(text) {
214
+ const value = text === undefined || text === "" ? "unknown error" : text;
215
+ return value.length > RAW_ERROR_MAX_LENGTH ? `${value.slice(0, RAW_ERROR_MAX_LENGTH)}…[truncated]` : value;
216
+ }
217
+ function createThrownErrorMessage(model, error, nowMs) {
218
+ return {
219
+ role: "assistant",
220
+ content: [],
221
+ api: model.api,
222
+ provider: model.provider,
223
+ model: model.id,
224
+ usage: {
225
+ input: 0,
226
+ output: 0,
227
+ cacheRead: 0,
228
+ cacheWrite: 0,
229
+ totalTokens: 0,
230
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
231
+ },
232
+ stopReason: "error",
233
+ errorMessage: error instanceof Error ? error.message : String(error),
234
+ timestamp: nowMs,
235
+ };
236
+ }
237
+ /**
238
+ * 换道重放的凭据剔除(设计 §4.4):apiKey/headers/env 是主 provider 的预解析
239
+ * 凭据,照抄重放会把主的 key 发给备用 provider——剔除后由目标 provider 经
240
+ * 宿主 auth 解析重新注入。其余 options(signal、预算、缓存策略等)原样保留。
241
+ */
242
+ function stripCredentialOptions(options) {
243
+ if (options === undefined)
244
+ return undefined;
245
+ const { apiKey: _apiKey, headers: _headers, env: _env, ...rest } = options;
246
+ return rest;
247
+ }
248
+ /** 候选序列解析(设计 §4.1):显式链优先,否则 backupProviders 按声明顺序同 id 匹配。 */
249
+ function resolveFailoverCandidates(model, config, resolveModel) {
250
+ const reference = `${model.provider}/${model.id}`;
251
+ const chain = config.chains.find((entry) => entry.primary === reference);
252
+ if (chain !== undefined) {
253
+ const candidates = [];
254
+ for (const backup of chain.backups) {
255
+ const parsed = parseModelReference(backup);
256
+ const resolved = parsed === undefined ? undefined : resolveModel(parsed.provider, parsed.modelId);
257
+ if (resolved === undefined)
258
+ continue;
259
+ if (resolved.provider === model.provider && resolved.id === model.id)
260
+ continue;
261
+ candidates.push(resolved);
262
+ }
263
+ return candidates;
264
+ }
265
+ const candidates = [];
266
+ for (const providerId of config.backupProviders) {
267
+ // 含主 provider 自身 → 忽略该项(设计 §4.1)。
268
+ if (providerId === model.provider)
269
+ continue;
270
+ const resolved = resolveModel(providerId, model.id);
271
+ if (resolved !== undefined)
272
+ candidates.push(resolved);
273
+ }
274
+ return candidates;
275
+ }
276
+ /**
277
+ * 写一条熔断状态快照进会话日志(设计 §4.7 Phase 3)。尽力通道:失败不打断
278
+ * 换道判定,但经进程日志警告保持失败可见。
279
+ */
280
+ function writeStateSnapshot(deps, snapshot) {
281
+ if (deps.session === undefined)
282
+ return;
283
+ try {
284
+ deps.session.appendEntry(PROVIDER_FAILOVER_STATE_ENTRY_TYPE, snapshot);
285
+ }
286
+ catch (error) {
287
+ deps.hostLogger?.warn("failover state snapshot append failed", { cause: error });
288
+ }
289
+ }
290
+ /** OPEN 转移点(阈值打开 / 试探失败重开)共用的 open 快照。 */
291
+ function writeOpenStateSnapshot(deps, chainId, breaker, backupReference) {
292
+ writeStateSnapshot(deps, {
293
+ version: 1,
294
+ chainId,
295
+ status: "open",
296
+ ...(backupReference === undefined ? {} : { backupProvider: backupReference }),
297
+ probeAtMs: breaker.openedAtMs + breaker.cooldownMs,
298
+ now: breaker.openedAtMs,
299
+ });
300
+ }
301
+ function dispatchFailoverEvent(deps, request) {
302
+ try {
303
+ if (!deps.hasLifecycleEvent(request.type, request.version))
304
+ return;
305
+ deps.dispatchLifecycleEvent(request);
306
+ }
307
+ catch {
308
+ // 同上:派发入口同步抛错也不影响请求路径。
309
+ }
310
+ }
311
+ function createFailoverWrapper(deps) {
312
+ return (modelArgument, contextArgument, optionsArgument, next, host) => {
313
+ const model = modelArgument;
314
+ const context = contextArgument;
315
+ const options = optionsArgument;
316
+ const outer = createAssistantMessageEventStream();
317
+ const resolveModel = (provider, modelId) => host.resolveModel(provider, modelId);
318
+ void driveFailoverStream(deps, outer, model, context, options, next, resolveModel);
319
+ return outer;
320
+ };
321
+ }
322
+ async function driveFailoverStream(deps, outer, model, context, options, next, resolveModel) {
323
+ const { config } = deps;
324
+ const nowMs = deps.now();
325
+ const chainId = `${model.provider}/${model.id}`;
326
+ const breaker = getBreaker(chainId, config);
327
+ // 入场判定(设计 §4.3 判定表):CLOSED/HALF_OPEN 撞主,OPEN 且冷却未满落备用。
328
+ let attemptPrimary = true;
329
+ let isProbe = false;
330
+ if (breaker.status === "open") {
331
+ if (nowMs - breaker.openedAtMs >= breaker.cooldownMs) {
332
+ // 冷却期满:下一个真实请求改道回主作试探(HALF_OPEN 并发放行)。
333
+ breaker.status = "half-open";
334
+ isProbe = true;
335
+ }
336
+ else {
337
+ attemptPrimary = false;
338
+ }
339
+ }
340
+ else if (breaker.status === "half-open") {
341
+ isProbe = true;
342
+ }
343
+ const candidates = resolveFailoverCandidates(model, config, resolveModel);
344
+ // OPEN 期请求从链头重走(设计 §4.3),快照披露的换道目标 = 链头候选。
345
+ const backupReference = candidates[0] === undefined ? undefined : `${candidates[0].provider}/${candidates[0].id}`;
346
+ const attempts = [];
347
+ if (attemptPrimary)
348
+ attempts.push({ model, role: "primary" });
349
+ for (const candidate of candidates)
350
+ attempts.push({ model: candidate, role: "backup" });
351
+ if (attempts.length === 0) {
352
+ // OPEN 且候选为空:无别处可去,直发主(按普通主请求计账,不作试探)。
353
+ attempts.push({ model, role: "primary" });
354
+ }
355
+ const failures = [];
356
+ let switchOrdinal = 0;
357
+ for (let index = 0; index < attempts.length; index++) {
358
+ const attempt = attempts[index];
359
+ const attemptOptions = attempt.role === "backup" ? stripCredentialOptions(options) : options;
360
+ const result = await runFailoverAttempt(outer, next, attempt.model, context, attemptOptions, deps.now);
361
+ if (result.outcome === "done") {
362
+ if (attempt.role === "primary") {
363
+ breaker.status = "closed";
364
+ breaker.consecutiveFailures = 0;
365
+ breaker.cooldownMs = config.cooldownInitialMs;
366
+ if (isProbe) {
367
+ dispatchFailoverEvent(deps, {
368
+ type: PROVIDER_FAILOVER_RECOVERED_EVENT_ID,
369
+ version: PROVIDER_FAILOVER_RECOVERED_EVENT_VERSION,
370
+ phase: "committed",
371
+ source: PROVIDER_FAILOVER_PLUGIN_ID,
372
+ // 事件总线 correlationId 取链引用(workflow.changed 同款领域键模式)。
373
+ correlationId: chainId,
374
+ data: { version: 1, chainId, provider: model.provider },
375
+ });
376
+ // HALF_OPEN → CLOSED 转移点:footer 摘要行随最后一条 closed 快照消失。
377
+ writeStateSnapshot(deps, {
378
+ version: 1,
379
+ chainId,
380
+ status: "closed",
381
+ probeAtMs: 0,
382
+ now: deps.now(),
383
+ });
384
+ }
385
+ }
386
+ // 备用成败不影响 primary 熔断状态(设计 §4.2)。
387
+ return;
388
+ }
389
+ if (result.outcome === "aborted") {
390
+ // 用户本地取消:不计数、不换道、状态不变(设计 §2/§4.3)。
391
+ return;
392
+ }
393
+ if (result.outcome === "failed-forwarded") {
394
+ // 已过提交边界:不可重放,交回合级重试;主失败照常计数。
395
+ if (attempt.role === "primary") {
396
+ recordPrimaryFailure(deps, breaker, config, chainId, result.message, isProbe, backupReference);
397
+ }
398
+ return;
399
+ }
400
+ // failed-switchable:无可见内容,可换道重放。
401
+ failures.push(result.failure);
402
+ if (attempt.role === "primary") {
403
+ recordPrimaryFailure(deps, breaker, config, chainId, result.failure.message, isProbe, backupReference);
404
+ }
405
+ if (index + 1 < attempts.length) {
406
+ switchOrdinal += 1;
407
+ const target = attempts[index + 1];
408
+ dispatchFailoverEvent(deps, {
409
+ type: PROVIDER_FAILOVER_SWITCHED_EVENT_ID,
410
+ version: PROVIDER_FAILOVER_SWITCHED_EVENT_VERSION,
411
+ phase: "committed",
412
+ source: PROVIDER_FAILOVER_PLUGIN_ID,
413
+ // 事件总线 correlationId 取链引用(workflow.changed 同款领域键模式)。
414
+ correlationId: chainId,
415
+ data: {
416
+ version: 1,
417
+ chainId,
418
+ from: `${attempt.model.provider}/${attempt.model.id}`,
419
+ to: `${target.model.provider}/${target.model.id}`,
420
+ attempt: switchOrdinal,
421
+ rawError: truncateRawError(result.failure.message.errorMessage),
422
+ },
423
+ });
424
+ continue;
425
+ }
426
+ break;
427
+ }
428
+ // 全链尽:报链内最后一次实际失败的原始错误 + 整链摘要进 diagnostics(设计 §4.3)。
429
+ const lastFailure = failures[failures.length - 1];
430
+ if (lastFailure === undefined) {
431
+ // 不可达(至少一次 failed-switchable 才会走到这里);保守以合成错误收尾。
432
+ const message = createThrownErrorMessage(model, new Error("provider failover exhausted the chain"), deps.now());
433
+ outer.push({ type: "error", reason: "error", error: message });
434
+ outer.end();
435
+ return;
436
+ }
437
+ const diagnostic = {
438
+ type: "provider-failover",
439
+ timestamp: deps.now(),
440
+ details: {
441
+ chainId,
442
+ attempts: failures.map((failure) => ({
443
+ provider: failure.model.provider,
444
+ model: failure.model.id,
445
+ error: truncateRawError(failure.message.errorMessage),
446
+ })),
447
+ },
448
+ };
449
+ const finalMessage = {
450
+ ...lastFailure.message,
451
+ diagnostics: [...(lastFailure.message.diagnostics ?? []), diagnostic],
452
+ };
453
+ outer.push({ type: "error", reason: "error", error: finalMessage });
454
+ outer.end();
455
+ }
456
+ /**
457
+ * 主失败计账(设计 §4.2/§4.3):CLOSED 期连续 N 次达到阈值 → OPEN(冷却取
458
+ * initialMs);HALF_OPEN 试探失败 → OPEN 且冷却 ×2 封顶 maxMs(经 probe.failed
459
+ * 事件可见)。backup 的成败永远不进这里。
460
+ */
461
+ function recordPrimaryFailure(deps, breaker, config, chainId, message, isProbe, backupReference) {
462
+ if (isProbe) {
463
+ openAfterFailedProbe(deps, breaker, config, chainId, message, backupReference);
464
+ return;
465
+ }
466
+ breaker.consecutiveFailures += 1;
467
+ if (breaker.consecutiveFailures >= config.failureThreshold) {
468
+ breaker.status = "open";
469
+ breaker.cooldownMs = config.cooldownInitialMs;
470
+ breaker.openedAtMs = deps.now();
471
+ writeOpenStateSnapshot(deps, chainId, breaker, backupReference);
472
+ }
473
+ }
474
+ /** HALF_OPEN 试探失败:回 OPEN,冷却 ×2 封顶 maxMs,并派发 probe.failed(设计 §4.2)。 */
475
+ function openAfterFailedProbe(deps, breaker, config, chainId, message, backupReference) {
476
+ breaker.status = "open";
477
+ breaker.cooldownMs = Math.min(breaker.cooldownMs * 2, config.cooldownMaxMs);
478
+ breaker.openedAtMs = deps.now();
479
+ dispatchFailoverEvent(deps, {
480
+ type: PROVIDER_FAILOVER_PROBE_FAILED_EVENT_ID,
481
+ version: PROVIDER_FAILOVER_PROBE_FAILED_EVENT_VERSION,
482
+ phase: "committed",
483
+ source: PROVIDER_FAILOVER_PLUGIN_ID,
484
+ // 事件总线 correlationId 取链引用(workflow.changed 同款领域键模式)。
485
+ correlationId: chainId,
486
+ data: {
487
+ version: 1,
488
+ chainId,
489
+ provider: message.provider,
490
+ rawError: truncateRawError(message.errorMessage),
491
+ nextCooldownMs: breaker.cooldownMs,
492
+ },
493
+ });
494
+ writeOpenStateSnapshot(deps, chainId, breaker, backupReference);
495
+ }
496
+ async function runFailoverAttempt(outer, next, attemptModel, context, options, now) {
497
+ let inner;
498
+ try {
499
+ inner = (await next(attemptModel, context, options));
500
+ }
501
+ catch (error) {
502
+ const message = createThrownErrorMessage(attemptModel, error, now());
503
+ return { outcome: "failed-switchable", failure: { model: attemptModel, message } };
504
+ }
505
+ // 前奏事件缓冲(设计 §4.4):提交边界(首个内容增量)或正常完成前不释放,
506
+ // 使"无可见内容"的失败可以整段丢弃后无感重放。非增量、非终态事件(start、
507
+ // *_start、*_end 及未知扩展事件)一律入缓冲——保守归入前奏,重放安全性优先。
508
+ const buffered = [];
509
+ let committed = false;
510
+ const flushBuffer = () => {
511
+ for (const event of buffered)
512
+ outer.push(event);
513
+ buffered.length = 0;
514
+ };
515
+ try {
516
+ for await (const event of inner) {
517
+ if (event.type === "done") {
518
+ flushBuffer();
519
+ outer.push(event);
520
+ outer.end();
521
+ return { outcome: "done", message: event.message };
522
+ }
523
+ if (event.type === "error") {
524
+ if (event.reason === "aborted") {
525
+ // 不重放的终态:丢弃缓冲只发终态,避免上层收到孤立 start。
526
+ outer.push(event);
527
+ outer.end();
528
+ return { outcome: "aborted", message: event.error };
529
+ }
530
+ if (committed) {
531
+ outer.push(event);
532
+ outer.end();
533
+ return { outcome: "failed-forwarded", message: event.error };
534
+ }
535
+ return { outcome: "failed-switchable", failure: { model: attemptModel, message: event.error } };
536
+ }
537
+ if (isCommittedDelta(event)) {
538
+ if (!committed) {
539
+ committed = true;
540
+ flushBuffer();
541
+ }
542
+ outer.push(event);
543
+ continue;
544
+ }
545
+ if (committed)
546
+ outer.push(event);
547
+ else
548
+ buffered.push(event);
549
+ }
550
+ }
551
+ catch (error) {
552
+ const message = createThrownErrorMessage(attemptModel, error, now());
553
+ if (committed) {
554
+ const errorEvent = { type: "error", reason: "error", error: message };
555
+ outer.push(errorEvent);
556
+ outer.end();
557
+ return { outcome: "failed-forwarded", message };
558
+ }
559
+ return { outcome: "failed-switchable", failure: { model: attemptModel, message } };
560
+ }
561
+ // 流自然结束但没有终止信号:按设计 §2 的归一哲学视作 error 终态。
562
+ const message = createThrownErrorMessage(attemptModel, new Error("stream ended without a terminal event"), now());
563
+ if (committed) {
564
+ outer.push({ type: "error", reason: "error", error: message });
565
+ outer.end();
566
+ return { outcome: "failed-forwarded", message };
567
+ }
568
+ return { outcome: "failed-switchable", failure: { model: attemptModel, message } };
569
+ }
570
+ /**
571
+ * 稳定序列化(键排序、剔除 undefined):compat/thinkingLevelMap 的派生差异按
572
+ * 值比较,不依赖对象键序。
573
+ */
574
+ function stableStringify(value) {
575
+ if (value === undefined)
576
+ return "∅";
577
+ if (value === null || typeof value !== "object")
578
+ return JSON.stringify(value) ?? String(value);
579
+ if (Array.isArray(value))
580
+ return `[${value.map(stableStringify).join(",")}]`;
581
+ const entries = Object.entries(value)
582
+ .filter(([, entryValue]) => entryValue !== undefined)
583
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
584
+ return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`).join(",")}}`;
585
+ }
586
+ /**
587
+ * 显式链全量预检(设计 §4.6):每个 backup 逐一检查,失败条目从链中剔除并
588
+ * 警告;主不可解析 → 整链跳过。contextWindow/compat/thinkingLevelMap/maxTokens
589
+ * 差异只进警告清单、不硬拦——实施裁量:contextWindow 低于主的备用对小上下文
590
+ * 请求仍可成功,剔除条目会静默损失兜底;只有上下文逼近主窗口的请求才必失败,
591
+ * 该情形运行时走链内下一候选,有界。
592
+ */
593
+ function preflightExplicitChain(chain, checks, warn) {
594
+ const parsedPrimary = parseModelReference(chain.primary);
595
+ const primaryModel = parsedPrimary === undefined ? undefined : checks.resolveModel(parsedPrimary.provider, parsedPrimary.modelId);
596
+ if (parsedPrimary === undefined || primaryModel === undefined) {
597
+ warn(`failover chain "${chain.primary}" primary does not resolve in the model catalog; skipping the chain`);
598
+ return undefined;
599
+ }
600
+ const backups = [];
601
+ for (const backup of chain.backups) {
602
+ const parsed = parseModelReference(backup);
603
+ const backupModel = parsed === undefined ? undefined : checks.resolveModel(parsed.provider, parsed.modelId);
604
+ if (parsed === undefined || backupModel === undefined) {
605
+ warn(`failover chain "${chain.primary}" backup "${backup}" does not resolve in the model catalog; skipping it`);
606
+ continue;
607
+ }
608
+ if (!checks.hasConfiguredAuth(backupModel.provider)) {
609
+ warn(`failover chain "${chain.primary}" backup provider "${backupModel.provider}" has no configured credentials; skipping "${backup}"`);
610
+ continue;
611
+ }
612
+ if (backupModel.contextWindow < primaryModel.contextWindow) {
613
+ warn(`failover chain "${chain.primary}" backup "${backup}" context window (${backupModel.contextWindow}) is below the primary's (${primaryModel.contextWindow}); contexts sized for the primary may fail on this backup (entry kept)`);
614
+ }
615
+ if (backupModel.maxTokens !== primaryModel.maxTokens) {
616
+ warn(`failover chain "${chain.primary}" backup "${backup}" maxTokens (${backupModel.maxTokens}) differs from the primary's (${primaryModel.maxTokens}); output budget may change after switching`);
617
+ }
618
+ if (stableStringify(backupModel.compat) !== stableStringify(primaryModel.compat)) {
619
+ warn(`failover chain "${chain.primary}" backup "${backup}" compat differs from the primary's; request derivation may change after switching`);
620
+ }
621
+ if (stableStringify(backupModel.thinkingLevelMap) !== stableStringify(primaryModel.thinkingLevelMap)) {
622
+ warn(`failover chain "${chain.primary}" backup "${backup}" thinkingLevelMap differs from the primary's; reasoning level mapping may change after switching`);
623
+ }
624
+ backups.push(backup);
625
+ }
626
+ if (backups.length === 0 && chain.backups.length > 0) {
627
+ warn(`failover chain "${chain.primary}" has no usable backups left after preflight; the primary keeps no failover candidates from this chain`);
628
+ }
629
+ return { primary: chain.primary, backups };
630
+ }
631
+ /**
632
+ * backupProviders 层预检(设计 §4.6):仅检 provider 存在 + 凭据条目;模型匹配
633
+ * 留运行时(目录同 id 匹配不到时静默跳过,不算失败)。
634
+ */
635
+ function preflightBackupProviders(providerIds, checks, warn) {
636
+ const retained = [];
637
+ for (const providerId of providerIds) {
638
+ if (!checks.hasProvider(providerId)) {
639
+ warn(`failover.backupProviders entry "${providerId}" is not a known provider; skipping it`);
640
+ continue;
641
+ }
642
+ if (!checks.hasConfiguredAuth(providerId)) {
643
+ warn(`failover.backupProviders entry "${providerId}" has no configured credentials; skipping it`);
644
+ continue;
645
+ }
646
+ retained.push(providerId);
647
+ }
648
+ return retained;
649
+ }
650
+ /**
651
+ * 装载预检主体:返回过滤后的派生配置(预检失败条目已从候选源剔除)。不改变
652
+ * 运行时候选解析逻辑——过滤只发生在装载期,运行时候选解析逻辑不变。
653
+ */
654
+ function runProviderFailoverPreflight(config, checks, warn) {
655
+ const backupProviders = preflightBackupProviders(config.backupProviders, checks, warn);
656
+ const chains = [];
657
+ for (const chain of config.chains) {
658
+ const preflighted = preflightExplicitChain(chain, checks, warn);
659
+ if (preflighted !== undefined)
660
+ chains.push(preflighted);
661
+ }
662
+ return { ...config, backupProviders, chains };
663
+ }
664
+ /** wrapper 注册优先级:低值更外层;-100 保证 failover 恒在链最外层(与装载顺序无关)。 */
665
+ const PROVIDER_FAILOVER_WRAPPER_PRIORITY = -100;
666
+ /** 适配 `api.logger`(plugin-sdk LoggerAPI)为插件的进程日志警告面;未声明 logger-v1 时缺席。 */
667
+ function hostLoggerFromApi(api) {
668
+ const logger = api.logger;
669
+ if (logger === undefined)
670
+ return undefined;
671
+ return {
672
+ warn: (message, context) => {
673
+ const cause = context?.cause;
674
+ // LoggerAPI.warn 不带 cause 形参:cause 归一为单行文本进 fields。
675
+ logger.warn(message, cause === undefined ? undefined : { cause: describeCause(cause) });
676
+ },
677
+ };
678
+ }
679
+ function describeCause(cause) {
680
+ if (cause instanceof Error)
681
+ return cause.message === "" ? cause.name : `${cause.name}: ${cause.message}`;
682
+ return String(cause);
683
+ }
684
+ /**
685
+ * 把插件装配进一个已构建的 `CapabilityAPI`(公共面;工厂与测试共用同一代码路径):
686
+ *
687
+ * - `api.config` 无 `failover` 节(宿主未注入)→ 直接 return,不注册任何面;
688
+ * 残留 `failover.enabled` 键只警告并忽略(已移除,2026-10-03 D-079),节点按
689
+ * 其余字段正常解析装配。
690
+ * - 配置了解析后:宿主必须注入 `api.host.modelCatalog`(装载预检检查面来源),
691
+ * 缺席 = "settings 有 failover 但宿主不支持"——fail-loud 抛错,不静默降级。
692
+ * - 配置解析完成后同步执行装载预检(设计 §4.6):失败条目从候选源剔除并警告,
693
+ * 警告经模块级去重每进程至多一次;预检不阻断其余条目与主 provider 的正常使用。
694
+ * - 熔断状态是模块级单例:本工厂跨会话重复调用注册的是新包装器实例,但共享
695
+ * 同一份状态(设计 §4.2)。
696
+ */
697
+ function installProviderFailoverIntoApi(api, deps) {
698
+ const logger = deps.hostLogger ?? hostLoggerFromApi(api);
699
+ const warn = logger === undefined
700
+ ? (_message) => { }
701
+ : (message) => {
702
+ logger.warn(message);
703
+ };
704
+ // 注入契约:`{ version: 1, failover: <settings failover 节原样> }`;failover
705
+ // 键缺席 = 未配置(no-op)。
706
+ const config = parseProviderFailoverConfig(api.config.failover, warn);
707
+ if (config === undefined)
708
+ return;
709
+ const catalog = api.host.modelCatalog;
710
+ if (catalog === undefined) {
711
+ throw new Error("provider failover is configured but the host does not provide the modelCatalog capability service (api.host.modelCatalog); the plugin refuses to assemble without install-time preflight");
712
+ }
713
+ // P1 契约:ModelCatalogV1 的检查面(resolveModel/hasProvider/hasConfiguredAuth)
714
+ // 与预检 checks 同构,直接结构绑定;契约漂移在此处显式报型。
715
+ const preflight = catalog;
716
+ const warnPreflightOnce = (message) => {
717
+ if (preflightWarnedMessages.has(message))
718
+ return;
719
+ preflightWarnedMessages.add(message);
720
+ warn(message);
721
+ };
722
+ const preflighted = runProviderFailoverPreflight(config, preflight, warnPreflightOnce);
723
+ const now = deps.now ?? (() => Date.now());
724
+ // deps 须在此构建——api 只在工厂内可得。
725
+ const runtimeDeps = {
726
+ config: preflighted,
727
+ now,
728
+ ...(api.session === undefined ? {} : { session: api.session }),
729
+ ...(logger === undefined ? {} : { hostLogger: logger }),
730
+ hasLifecycleEvent: (type, version) => {
731
+ try {
732
+ api.lifecycle.get(type, version);
733
+ return true;
734
+ }
735
+ catch {
736
+ return false;
737
+ }
738
+ },
739
+ dispatchLifecycleEvent: (request) => {
740
+ // LifecycleAPI 无 dispatchLifecycle(模块头差异登记):等价公共面 =
741
+ // api.publish。source 由运行时归属本插件 manifest id;phase 不入
742
+ // EventEnvelope(本插件的可见性事件全部是 committed 相位);所有派发点
743
+ // 都带 correlationId = chainId,兜底项仅为满足 LifecycleDispatchRequest
744
+ // 的可选键型。
745
+ void api
746
+ .publish({
747
+ type: request.type,
748
+ version: request.version,
749
+ correlationId: request.correlationId ?? request.type,
750
+ data: request.data,
751
+ })
752
+ .catch(() => {
753
+ // 可见性事件失败不打断换道判定(宪法 §8 的尽力通道)。
754
+ });
755
+ },
756
+ };
757
+ api.registerStreamFnWrapper(PROVIDER_FAILOVER_WRAPPER_NAME, createFailoverWrapper(runtimeDeps), {
758
+ priority: PROVIDER_FAILOVER_WRAPPER_PRIORITY,
759
+ });
760
+ // 装配期初始快照(status closed)覆盖恢复会话里的陈旧 open 残留:熔断
761
+ // 状态不持久化,新进程恒 CLOSED(设计 §4.2/§4.7)。无链上下文 → chainId 空。
762
+ writeStateSnapshot(runtimeDeps, {
763
+ version: 1,
764
+ chainId: "",
765
+ status: "closed",
766
+ probeAtMs: 0,
767
+ now: runtimeDeps.now(),
768
+ });
769
+ }
770
+ /**
771
+ * 构建 provider-failover 插件工厂(设计 §4.8 的插件包形态):
772
+ * `(api: CapabilityAPI) => void`,经公共 `loadCapabilityPlugin`/manifest 通道装载。
773
+ * deps 仅用于测试注入时钟与进程日志;生产入口(entry.ts 默认导出)零参调用。
774
+ */
775
+ export function createProviderFailoverPluginFactory(deps = {}) {
776
+ return (api) => {
777
+ installProviderFailoverIntoApi(api, deps);
778
+ };
779
+ }
780
+ //# sourceMappingURL=provider-failover.js.map