@x1a0f3n9/dsh-failover-queue 0.1.11
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/README.md +64 -0
- package/README.zh.md +62 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +2261 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +274 -0
- package/lib/index.js +574 -0
- package/package.json +92 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
|
|
3
|
+
//#region src/queue.ts
|
|
4
|
+
/** Stable identity for cooldown and de-dupe. */
|
|
5
|
+
function routeKey(route) {
|
|
6
|
+
return `${route.provider}\0${route.model}`;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Clamp a queue index into `[0, length)` (or `0` when empty).
|
|
10
|
+
* @param index - requested index.
|
|
11
|
+
* @param length - queue length.
|
|
12
|
+
* @returns a usable index.
|
|
13
|
+
*/
|
|
14
|
+
function clampIndex(index, length) {
|
|
15
|
+
if (length <= 0) return 0;
|
|
16
|
+
if (!Number.isFinite(index)) return 0;
|
|
17
|
+
const whole = Math.trunc(index);
|
|
18
|
+
if (whole < 0) return 0;
|
|
19
|
+
if (whole >= length) return length - 1;
|
|
20
|
+
return whole;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Move one queue item from `from` to `to`.
|
|
24
|
+
* @param queue - current ordered routes.
|
|
25
|
+
* @param from - source index.
|
|
26
|
+
* @param to - destination index.
|
|
27
|
+
* @returns a new array.
|
|
28
|
+
*/
|
|
29
|
+
function reorderQueue(queue, from, to) {
|
|
30
|
+
if (from === to) return [...queue];
|
|
31
|
+
if (from < 0 || from >= queue.length) return [...queue];
|
|
32
|
+
if (to < 0 || to >= queue.length) return [...queue];
|
|
33
|
+
const next = [...queue];
|
|
34
|
+
const [item] = next.splice(from, 1);
|
|
35
|
+
if (item === void 0) return next;
|
|
36
|
+
next.splice(to, 0, item);
|
|
37
|
+
return next;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Follow the same route after a drag, not the same slot.
|
|
41
|
+
* @param currentIndex - active index before the move.
|
|
42
|
+
* @param from - dragged index.
|
|
43
|
+
* @param to - drop index.
|
|
44
|
+
* @returns the index of the previously active route.
|
|
45
|
+
*/
|
|
46
|
+
function indexAfterReorder(currentIndex, from, to) {
|
|
47
|
+
if (currentIndex === from) return to;
|
|
48
|
+
if (from < currentIndex && to >= currentIndex) return currentIndex - 1;
|
|
49
|
+
if (from > currentIndex && to <= currentIndex) return currentIndex + 1;
|
|
50
|
+
return currentIndex;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Next uncooled route after `currentIndex`, wrapping once.
|
|
54
|
+
* @param queue - ordered routes.
|
|
55
|
+
* @param currentIndex - failed slot.
|
|
56
|
+
* @param isCooled - true when this route should be skipped.
|
|
57
|
+
* @returns the next index, or `undefined` when nothing remains.
|
|
58
|
+
*/
|
|
59
|
+
function advanceIndex(queue, currentIndex, isCooled) {
|
|
60
|
+
if (queue.length < 2) return void 0;
|
|
61
|
+
const start = clampIndex(currentIndex, queue.length);
|
|
62
|
+
for (let step = 1; step < queue.length; step += 1) {
|
|
63
|
+
const index = (start + step) % queue.length;
|
|
64
|
+
const route = queue[index];
|
|
65
|
+
if (route !== void 0 && !isCooled(route)) return index;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* True when this failure should skip remaining same-route retries.
|
|
70
|
+
* @param code - provider-neutral `LlmFailure.code`.
|
|
71
|
+
* @param immediateCodes - configured trip codes.
|
|
72
|
+
*/
|
|
73
|
+
function shouldFailoverImmediately(code, immediateCodes) {
|
|
74
|
+
return immediateCodes.includes(code);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Drop duplicate provider+model pairs, keeping the first occurrence.
|
|
78
|
+
* @param queue - possibly messy user input.
|
|
79
|
+
*/
|
|
80
|
+
function dedupeQueue(queue) {
|
|
81
|
+
const seen = /* @__PURE__ */ new Set();
|
|
82
|
+
const next = [];
|
|
83
|
+
for (const route of queue) {
|
|
84
|
+
const provider = route.provider.trim();
|
|
85
|
+
const model = route.model.trim();
|
|
86
|
+
if (provider === "" || model === "") continue;
|
|
87
|
+
const key = routeKey({
|
|
88
|
+
provider,
|
|
89
|
+
model
|
|
90
|
+
});
|
|
91
|
+
if (seen.has(key)) continue;
|
|
92
|
+
seen.add(key);
|
|
93
|
+
next.push({
|
|
94
|
+
provider,
|
|
95
|
+
model,
|
|
96
|
+
...route.label === void 0 || route.label.trim() === "" ? {} : { label: route.label.trim() }
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return next;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Names the chip and queue rows show for one route.
|
|
103
|
+
* Prefers the live catalog's provider/model titles, then the stored label.
|
|
104
|
+
* @param route - queue slot.
|
|
105
|
+
* @param candidates - advertised catalog, possibly empty.
|
|
106
|
+
*/
|
|
107
|
+
function routeDisplay(route, candidates = []) {
|
|
108
|
+
const hit = candidates.find((row) => row.provider === route.provider && row.model === route.model);
|
|
109
|
+
return {
|
|
110
|
+
provider: hit?.providerName.trim() || route.provider,
|
|
111
|
+
model: route.label?.trim() || hit?.name.trim() || route.model
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/circuit.ts
|
|
117
|
+
/**
|
|
118
|
+
* One route's Closed / Open / HalfOpen breaker.
|
|
119
|
+
*
|
|
120
|
+
* HalfOpen allows a single in-flight probe. Open becomes HalfOpen after
|
|
121
|
+
* `timeoutMs`. Immediate failures (AUTH / RATE_LIMIT / NO_ADAPTER) open on
|
|
122
|
+
* the first hit. Memory-only: a process restart starts Closed.
|
|
123
|
+
*/
|
|
124
|
+
var CircuitBreaker = class {
|
|
125
|
+
state = "closed";
|
|
126
|
+
failures = 0;
|
|
127
|
+
successes = 0;
|
|
128
|
+
openedAt = null;
|
|
129
|
+
halfOpenPermit = 0;
|
|
130
|
+
/**
|
|
131
|
+
* @param config - thresholds and Open timeout.
|
|
132
|
+
*/
|
|
133
|
+
constructor(config) {
|
|
134
|
+
this.config = config;
|
|
135
|
+
}
|
|
136
|
+
/** Copy of counters for badges and tests. */
|
|
137
|
+
snapshot() {
|
|
138
|
+
return {
|
|
139
|
+
state: this.state,
|
|
140
|
+
failures: this.failures,
|
|
141
|
+
successes: this.successes,
|
|
142
|
+
openedAt: this.openedAt
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Whether this route may be selected. Open → HalfOpen when the wait elapses.
|
|
147
|
+
* Does not consume the probe permit.
|
|
148
|
+
* @param now - epoch ms.
|
|
149
|
+
*/
|
|
150
|
+
isAvailable(now) {
|
|
151
|
+
this.maybeHalfOpen(now);
|
|
152
|
+
return this.state === "closed" || this.state === "half_open";
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Reserve this route for one request. HalfOpen consumes the single permit.
|
|
156
|
+
* @param now - epoch ms.
|
|
157
|
+
*/
|
|
158
|
+
allowProbe(now) {
|
|
159
|
+
this.maybeHalfOpen(now);
|
|
160
|
+
if (this.state === "closed") return {
|
|
161
|
+
allowed: true,
|
|
162
|
+
halfOpen: false
|
|
163
|
+
};
|
|
164
|
+
if (this.state === "half_open") {
|
|
165
|
+
if (this.halfOpenPermit >= 1) return {
|
|
166
|
+
allowed: false,
|
|
167
|
+
halfOpen: true
|
|
168
|
+
};
|
|
169
|
+
this.halfOpenPermit = 1;
|
|
170
|
+
return {
|
|
171
|
+
allowed: true,
|
|
172
|
+
halfOpen: true
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
allowed: false,
|
|
177
|
+
halfOpen: false
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** Probe succeeded. Two successes close a HalfOpen breaker. */
|
|
181
|
+
recordSuccess() {
|
|
182
|
+
this.releaseProbe();
|
|
183
|
+
this.failures = 0;
|
|
184
|
+
if (this.state !== "half_open") return;
|
|
185
|
+
this.successes += 1;
|
|
186
|
+
if (this.successes >= this.config.successThreshold) this.transitionClosed();
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Probe or Closed request failed.
|
|
190
|
+
* @param now - epoch ms used as Open timestamp.
|
|
191
|
+
* @param immediate - open on this hit regardless of `failureThreshold`.
|
|
192
|
+
*/
|
|
193
|
+
recordFailure(now, immediate = false) {
|
|
194
|
+
this.releaseProbe();
|
|
195
|
+
this.failures += 1;
|
|
196
|
+
this.successes = 0;
|
|
197
|
+
if (this.state === "half_open" || immediate || this.failures >= this.config.failureThreshold) this.transitionOpen(now);
|
|
198
|
+
}
|
|
199
|
+
/** Drop the HalfOpen permit without changing health (cancel / abandon). */
|
|
200
|
+
releaseProbe() {
|
|
201
|
+
this.halfOpenPermit = 0;
|
|
202
|
+
}
|
|
203
|
+
maybeHalfOpen(now) {
|
|
204
|
+
if (this.state !== "open" || this.openedAt === null) return;
|
|
205
|
+
if (now - this.openedAt < this.config.timeoutMs) return;
|
|
206
|
+
this.state = "half_open";
|
|
207
|
+
this.successes = 0;
|
|
208
|
+
this.halfOpenPermit = 0;
|
|
209
|
+
}
|
|
210
|
+
transitionOpen(now) {
|
|
211
|
+
this.state = "open";
|
|
212
|
+
this.openedAt = now;
|
|
213
|
+
this.successes = 0;
|
|
214
|
+
this.halfOpenPermit = 0;
|
|
215
|
+
}
|
|
216
|
+
transitionClosed() {
|
|
217
|
+
this.state = "closed";
|
|
218
|
+
this.failures = 0;
|
|
219
|
+
this.successes = 0;
|
|
220
|
+
this.openedAt = null;
|
|
221
|
+
this.halfOpenPermit = 0;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
/** Per-route breaker map. Missing keys start Closed. */
|
|
225
|
+
var CircuitBank = class {
|
|
226
|
+
breakers = /* @__PURE__ */ new Map();
|
|
227
|
+
/**
|
|
228
|
+
* @param config - shared knobs for every route.
|
|
229
|
+
*/
|
|
230
|
+
constructor(config) {
|
|
231
|
+
this.config = config;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Breaker for one provider+model pair.
|
|
235
|
+
* @param key - {@link routeKey}.
|
|
236
|
+
*/
|
|
237
|
+
get(key) {
|
|
238
|
+
const existing = this.breakers.get(key);
|
|
239
|
+
if (existing !== void 0) return existing;
|
|
240
|
+
const created = new CircuitBreaker(this.config);
|
|
241
|
+
this.breakers.set(key, created);
|
|
242
|
+
return created;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Badge rows for the live queue. Touches `isAvailable` so Open can show
|
|
246
|
+
* HalfOpen after the wait without consuming a permit.
|
|
247
|
+
* @param queue - ordered routes.
|
|
248
|
+
* @param now - epoch ms.
|
|
249
|
+
*/
|
|
250
|
+
health(queue, now) {
|
|
251
|
+
return queue.map((route) => {
|
|
252
|
+
const breaker = this.get(routeKey(route));
|
|
253
|
+
breaker.isAvailable(now);
|
|
254
|
+
const snap = breaker.snapshot();
|
|
255
|
+
return {
|
|
256
|
+
provider: route.provider,
|
|
257
|
+
model: route.model,
|
|
258
|
+
state: snap.state,
|
|
259
|
+
failures: snap.failures
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
/**
|
|
265
|
+
* First Closed route, else first HalfOpen with a free permit. Never sticky.
|
|
266
|
+
* @param queue - P1…Pn.
|
|
267
|
+
* @param bank - per-route breakers.
|
|
268
|
+
* @param now - epoch ms.
|
|
269
|
+
* @param skip - route key to ignore (the attempt that just failed).
|
|
270
|
+
*/
|
|
271
|
+
function pickFirstAvailable(queue, bank, now, skip) {
|
|
272
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
273
|
+
const route = queue[index];
|
|
274
|
+
if (route === void 0) continue;
|
|
275
|
+
const key = routeKey(route);
|
|
276
|
+
if (skip !== void 0 && key === skip) continue;
|
|
277
|
+
const breaker = bank.get(key);
|
|
278
|
+
if (!breaker.isAvailable(now)) continue;
|
|
279
|
+
const probe = breaker.allowProbe(now);
|
|
280
|
+
if (!probe.allowed) continue;
|
|
281
|
+
return {
|
|
282
|
+
index,
|
|
283
|
+
route,
|
|
284
|
+
halfOpen: probe.halfOpen
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Queue index of the first available route, without consuming a probe permit.
|
|
290
|
+
* @param queue - P1…Pn.
|
|
291
|
+
* @param bank - per-route breakers.
|
|
292
|
+
* @param now - epoch ms.
|
|
293
|
+
* @param skip - route key to ignore.
|
|
294
|
+
*/
|
|
295
|
+
function firstAvailableIndex(queue, bank, now, skip) {
|
|
296
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
297
|
+
const route = queue[index];
|
|
298
|
+
if (route === void 0) continue;
|
|
299
|
+
const key = routeKey(route);
|
|
300
|
+
if (skip !== void 0 && key === skip) continue;
|
|
301
|
+
if (bank.get(key).isAvailable(now)) return index;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Badge tone for one queue row.
|
|
306
|
+
* @param health - host snapshot for this route, if any.
|
|
307
|
+
*/
|
|
308
|
+
function circuitTone(health) {
|
|
309
|
+
if (health === void 0) return "ok";
|
|
310
|
+
if (health.state === "open") return "open";
|
|
311
|
+
if (health.state === "half_open") return "probe";
|
|
312
|
+
return health.failures > 0 ? "probe" : "ok";
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Match a queue route to a health row.
|
|
316
|
+
* @param route - queue slot.
|
|
317
|
+
* @param circuits - host snapshot.
|
|
318
|
+
*/
|
|
319
|
+
function healthFor(route, circuits) {
|
|
320
|
+
return circuits.find((row) => row.provider === route.provider && row.model === route.model);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
//#endregion
|
|
324
|
+
//#region src/command.ts
|
|
325
|
+
/**
|
|
326
|
+
* Parse `/failover` arguments.
|
|
327
|
+
* @param rawInput - text after the command name.
|
|
328
|
+
*/
|
|
329
|
+
function parseFailoverArg(rawInput) {
|
|
330
|
+
const arg = rawInput.trim().toLowerCase();
|
|
331
|
+
if (arg === "" || arg === "status") return { kind: "status" };
|
|
332
|
+
if (arg === "on" || arg === "enable") return { kind: "on" };
|
|
333
|
+
if (arg === "off" || arg === "disable") return { kind: "off" };
|
|
334
|
+
if (arg === "__candidates" || arg === "candidates") return { kind: "candidates" };
|
|
335
|
+
return {
|
|
336
|
+
kind: "error",
|
|
337
|
+
text: "Usage: /failover [on|off|status]"
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region src/config.ts
|
|
343
|
+
/** Runtime schema. */
|
|
344
|
+
const Config = z.object({
|
|
345
|
+
cooldownMs: z.number().min(0).default(6e4),
|
|
346
|
+
failureThreshold: z.number().min(1).default(2),
|
|
347
|
+
successThreshold: z.number().min(1).default(2),
|
|
348
|
+
immediateCodes: z.array(z.string()).default([
|
|
349
|
+
"AUTH",
|
|
350
|
+
"RATE_LIMIT",
|
|
351
|
+
"NO_ADAPTER"
|
|
352
|
+
])
|
|
353
|
+
});
|
|
354
|
+
/**
|
|
355
|
+
* Apply schema defaults.
|
|
356
|
+
* @param config - raw plugin config, possibly partial.
|
|
357
|
+
*/
|
|
358
|
+
function resolveConfig(config = {}) {
|
|
359
|
+
return Config(config);
|
|
360
|
+
}
|
|
361
|
+
/** Settings namespace (hyphenated; settings forbids dots). */
|
|
362
|
+
const SETTINGS_NAMESPACE = "dsh-failover-queue";
|
|
363
|
+
const QueueRouteSchema = z.object({
|
|
364
|
+
provider: z.string(),
|
|
365
|
+
model: z.string(),
|
|
366
|
+
label: z.string().default("")
|
|
367
|
+
});
|
|
368
|
+
const CircuitHealthSchema = z.object({
|
|
369
|
+
provider: z.string(),
|
|
370
|
+
model: z.string(),
|
|
371
|
+
state: z.string().default("closed"),
|
|
372
|
+
failures: z.number().min(0).default(0)
|
|
373
|
+
});
|
|
374
|
+
/** Persisted user document. */
|
|
375
|
+
const FailoverSettingsSchema = z.object({
|
|
376
|
+
enabled: z.boolean().default(false),
|
|
377
|
+
currentIndex: z.number().step(1).min(0).default(0),
|
|
378
|
+
queue: z.array(QueueRouteSchema).default([]),
|
|
379
|
+
circuits: z.array(CircuitHealthSchema).default([])
|
|
380
|
+
});
|
|
381
|
+
/** Empty queue, failover off. */
|
|
382
|
+
const DEFAULT_SETTINGS = {
|
|
383
|
+
enabled: false,
|
|
384
|
+
currentIndex: 0,
|
|
385
|
+
queue: [],
|
|
386
|
+
circuits: []
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region src/types.ts
|
|
391
|
+
/** Marker the `/failover __candidates` handler prefixes onto JSON. */
|
|
392
|
+
const CANDIDATES_MARKER = "FAILOVER_CANDIDATES_V1";
|
|
393
|
+
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/index.ts
|
|
396
|
+
const name = "dsh-failover-queue";
|
|
397
|
+
const inject = ["llm"];
|
|
398
|
+
/**
|
|
399
|
+
* Mount settings, `/failover`, and the request overlay.
|
|
400
|
+
* @param ctx - plugin context; requires `llm`.
|
|
401
|
+
* @param config - raw plugin config.
|
|
402
|
+
*/
|
|
403
|
+
function apply(ctx, config = {}) {
|
|
404
|
+
const resolved = resolveConfig(config);
|
|
405
|
+
const bank = new CircuitBank({
|
|
406
|
+
failureThreshold: resolved.failureThreshold,
|
|
407
|
+
successThreshold: resolved.successThreshold,
|
|
408
|
+
timeoutMs: resolved.cooldownMs
|
|
409
|
+
});
|
|
410
|
+
const flights = /* @__PURE__ */ new Map();
|
|
411
|
+
let liveIndex;
|
|
412
|
+
let settings;
|
|
413
|
+
const read = () => {
|
|
414
|
+
const raw = settings?.get() ?? DEFAULT_SETTINGS;
|
|
415
|
+
const queue = dedupeQueue(raw.queue);
|
|
416
|
+
return {
|
|
417
|
+
enabled: raw.enabled,
|
|
418
|
+
queue,
|
|
419
|
+
currentIndex: clampIndex(liveIndex ?? raw.currentIndex, queue.length),
|
|
420
|
+
circuits: bank.health(queue, Date.now())
|
|
421
|
+
};
|
|
422
|
+
};
|
|
423
|
+
const publish = (index) => {
|
|
424
|
+
if (index !== void 0) liveIndex = index;
|
|
425
|
+
const state = read();
|
|
426
|
+
settings?.update({
|
|
427
|
+
currentIndex: state.currentIndex,
|
|
428
|
+
circuits: state.circuits
|
|
429
|
+
});
|
|
430
|
+
};
|
|
431
|
+
ctx.inject(["settings"], (scoped) => {
|
|
432
|
+
settings = scoped.settings.register(SETTINGS_NAMESPACE, FailoverSettingsSchema, { base: DEFAULT_SETTINGS });
|
|
433
|
+
publish();
|
|
434
|
+
return () => {
|
|
435
|
+
settings = void 0;
|
|
436
|
+
};
|
|
437
|
+
});
|
|
438
|
+
ctx.inject(["commands"], (scoped) => {
|
|
439
|
+
const dispose = scoped.commands.register({
|
|
440
|
+
name: "failover",
|
|
441
|
+
description: "Turn P1/P2/P3 failover on or off, or print the queue",
|
|
442
|
+
input: { hint: "[on|off|status]" },
|
|
443
|
+
handler: (invocation) => handleFailoverCommand(ctx, read, settings, invocation.rawInput)
|
|
444
|
+
});
|
|
445
|
+
return () => {
|
|
446
|
+
dispose();
|
|
447
|
+
};
|
|
448
|
+
});
|
|
449
|
+
ctx.on("agent/request", async (payload, next) => {
|
|
450
|
+
const call = await next();
|
|
451
|
+
const state = read();
|
|
452
|
+
if (!state.enabled || state.queue.length === 0) return call;
|
|
453
|
+
const now = Date.now();
|
|
454
|
+
const pick = pickFirstAvailable(state.queue, bank, now);
|
|
455
|
+
if (pick === void 0) return call;
|
|
456
|
+
flights.set(agentKey(payload), {
|
|
457
|
+
key: routeKey(pick.route),
|
|
458
|
+
halfOpen: pick.halfOpen
|
|
459
|
+
});
|
|
460
|
+
publish(pick.index);
|
|
461
|
+
return {
|
|
462
|
+
...call,
|
|
463
|
+
provider: pick.route.provider,
|
|
464
|
+
model: pick.route.model
|
|
465
|
+
};
|
|
466
|
+
});
|
|
467
|
+
ctx.on("agent/request-error", async (payload, next) => {
|
|
468
|
+
const state = read();
|
|
469
|
+
const immediate = state.enabled && shouldFailoverImmediately(payload.failure.code, resolved.immediateCodes);
|
|
470
|
+
let downstream;
|
|
471
|
+
if (!immediate) {
|
|
472
|
+
downstream = await next();
|
|
473
|
+
if (downstream?.kind === "retry") return downstream;
|
|
474
|
+
}
|
|
475
|
+
if (!state.enabled || state.queue.length === 0) return downstream;
|
|
476
|
+
const now = Date.now();
|
|
477
|
+
const id = agentKey(payload);
|
|
478
|
+
const failedKey = flights.get(id)?.key ?? routeKeyFromIndex(state);
|
|
479
|
+
if (failedKey !== void 0) {
|
|
480
|
+
bank.get(failedKey).recordFailure(now, immediate);
|
|
481
|
+
flights.delete(id);
|
|
482
|
+
}
|
|
483
|
+
if (state.queue.length < 2) {
|
|
484
|
+
publish();
|
|
485
|
+
return downstream;
|
|
486
|
+
}
|
|
487
|
+
const nextIndex = firstAvailableIndex(state.queue, bank, now, failedKey);
|
|
488
|
+
if (nextIndex === void 0) {
|
|
489
|
+
publish();
|
|
490
|
+
return downstream;
|
|
491
|
+
}
|
|
492
|
+
publish(nextIndex);
|
|
493
|
+
return { kind: "retry" };
|
|
494
|
+
}, { prepend: true });
|
|
495
|
+
ctx.on("agent/assistant-stream", (payload) => {
|
|
496
|
+
if (payload.frame?.type !== "end") return;
|
|
497
|
+
if (payload.frame.outcome?.kind !== "committed") return;
|
|
498
|
+
if (payload.frame.outcome.eventType !== "assistant/message") return;
|
|
499
|
+
const id = agentKey(payload);
|
|
500
|
+
const flight = flights.get(id);
|
|
501
|
+
if (flight === void 0) return;
|
|
502
|
+
flights.delete(id);
|
|
503
|
+
bank.get(flight.key).recordSuccess();
|
|
504
|
+
publish();
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
function routeKeyFromIndex(state) {
|
|
508
|
+
const route = state.queue[state.currentIndex];
|
|
509
|
+
return route === void 0 ? void 0 : routeKey(route);
|
|
510
|
+
}
|
|
511
|
+
function agentKey(payload) {
|
|
512
|
+
if (typeof payload !== "object" || payload === null) return "default";
|
|
513
|
+
const agent = payload.agent;
|
|
514
|
+
return typeof agent?.id === "string" && agent.id !== "" ? agent.id : "default";
|
|
515
|
+
}
|
|
516
|
+
async function handleFailoverCommand(ctx, read, settings, rawInput) {
|
|
517
|
+
const verb = parseFailoverArg(rawInput);
|
|
518
|
+
if (verb.kind === "error") return {
|
|
519
|
+
kind: "error",
|
|
520
|
+
text: verb.text
|
|
521
|
+
};
|
|
522
|
+
if (verb.kind === "candidates") {
|
|
523
|
+
const candidates = await listCandidates(ctx);
|
|
524
|
+
return {
|
|
525
|
+
kind: "success",
|
|
526
|
+
text: `${CANDIDATES_MARKER}\n${JSON.stringify(candidates)}`
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
if (verb.kind === "on" || verb.kind === "off") {
|
|
530
|
+
if (settings === void 0) return {
|
|
531
|
+
kind: "error",
|
|
532
|
+
text: "Failover settings are not available yet."
|
|
533
|
+
};
|
|
534
|
+
await settings.update({ enabled: verb.kind === "on" });
|
|
535
|
+
return {
|
|
536
|
+
kind: "success",
|
|
537
|
+
text: verb.kind === "on" ? "Failover on. Requests prefer P1; P2/P3 are backups. Recovered P1 is probed and selected again." : "Failover off. The session model is used as-is."
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
const state = read();
|
|
541
|
+
if (state.queue.length === 0) return {
|
|
542
|
+
kind: "success",
|
|
543
|
+
text: state.enabled ? "Failover is on, but the queue is empty. Open the P chip on the composer to add routes." : "Failover is off. Queue is empty."
|
|
544
|
+
};
|
|
545
|
+
const lines = state.queue.map((route, index) => {
|
|
546
|
+
const mark = index === state.currentIndex ? "*" : " ";
|
|
547
|
+
const label = route.label?.trim() || `${route.provider}/${route.model}`;
|
|
548
|
+
const health = state.circuits?.find((row) => row.provider === route.provider && row.model === route.model);
|
|
549
|
+
const badge = health === void 0 || health.state === "closed" ? "" : ` [${health.state}]`;
|
|
550
|
+
return `${mark} P${index + 1} ${label} (${route.provider} ${route.model})${badge}`;
|
|
551
|
+
});
|
|
552
|
+
return {
|
|
553
|
+
kind: "success",
|
|
554
|
+
text: `${state.enabled ? "Failover on" : "Failover off"}\n${lines.join("\n")}`
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
async function listCandidates(ctx) {
|
|
558
|
+
const llm = ctx.llm;
|
|
559
|
+
if (llm === void 0) return [];
|
|
560
|
+
const out = [];
|
|
561
|
+
for (const provider of llm.listProviders()) {
|
|
562
|
+
const models = await llm.listModels(provider.id);
|
|
563
|
+
for (const model of models) out.push({
|
|
564
|
+
provider: provider.id,
|
|
565
|
+
providerName: provider.name,
|
|
566
|
+
model: model.id,
|
|
567
|
+
name: model.name
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
//#endregion
|
|
574
|
+
export { CircuitBank, CircuitBreaker, Config, SETTINGS_NAMESPACE, advanceIndex, apply, circuitTone, clampIndex, dedupeQueue, firstAvailableIndex, healthFor, indexAfterReorder, inject, name, parseFailoverArg, pickFirstAvailable, reorderQueue, resolveConfig, routeDisplay, routeKey };
|
package/package.json
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@x1a0f3n9/dsh-failover-queue",
|
|
3
|
+
"version": "0.1.11",
|
|
4
|
+
"description": "CC Switch-style P1/P2/P3 model failover for DeepSeek Harness. Circuit probe failback to P1.",
|
|
5
|
+
"author": "LunFengChen",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/LunFengChen/dsh-failover-queue.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/LunFengChen/dsh-failover-queue",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/LunFengChen/dsh-failover-queue/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.19"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "lib/index.js",
|
|
23
|
+
"types": "lib/index.d.ts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./lib/index.d.ts",
|
|
27
|
+
"default": "./lib/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./client": {
|
|
30
|
+
"types": "./src/client/index.ts",
|
|
31
|
+
"default": "./lib/client.js"
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"lib/index.js",
|
|
37
|
+
"lib/index.d.ts",
|
|
38
|
+
"lib/client.js",
|
|
39
|
+
"lib/client.js.map",
|
|
40
|
+
"cordis.patch.yml",
|
|
41
|
+
"README.md",
|
|
42
|
+
"README.zh.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"dsh": {
|
|
46
|
+
"plugin": {
|
|
47
|
+
"title": "Failover queue",
|
|
48
|
+
"titleZh": "故障转移队列",
|
|
49
|
+
"description": "P1/P2/P3 provider+model failover with a composer toggle and drag-reorder panel.",
|
|
50
|
+
"descriptionZh": "P1/P2/P3 供应商+模型故障转移。输入框开关,点进去拖拽改优先级。"
|
|
51
|
+
},
|
|
52
|
+
"bundle": {
|
|
53
|
+
"patch": "./cordis.patch.yml"
|
|
54
|
+
},
|
|
55
|
+
"client": {
|
|
56
|
+
"platform": "web",
|
|
57
|
+
"inject": [
|
|
58
|
+
"@x1a0f3n9/dsh-client-locale",
|
|
59
|
+
"@x1a0f3n9/dsh-client-ui-conversation",
|
|
60
|
+
"@x1a0f3n9/dsh-client-ui-settings"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"scripts": {
|
|
65
|
+
"build": "tsdown",
|
|
66
|
+
"test": "vitest run",
|
|
67
|
+
"typecheck": "tsdown"
|
|
68
|
+
},
|
|
69
|
+
"peerDependencies": {
|
|
70
|
+
"@deepseek-ai/cordis": ">=4.0.0",
|
|
71
|
+
"@deepseek-ai/schemastery": ">=3.0.0"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@types/node": "^24.3.0",
|
|
75
|
+
"@types/react": "~18.3.1",
|
|
76
|
+
"react": "^18.2.0",
|
|
77
|
+
"tsdown": "^0.16.0",
|
|
78
|
+
"typescript": "^5.9.2",
|
|
79
|
+
"vitest": "^3.2.4"
|
|
80
|
+
},
|
|
81
|
+
"keywords": [
|
|
82
|
+
"deepseek-harness",
|
|
83
|
+
"dsh",
|
|
84
|
+
"xfdsh",
|
|
85
|
+
"plugin",
|
|
86
|
+
"failover",
|
|
87
|
+
"priority",
|
|
88
|
+
"p1",
|
|
89
|
+
"p2",
|
|
90
|
+
"p3"
|
|
91
|
+
]
|
|
92
|
+
}
|