dsh-workbuddy-xdpool 0.3.0 → 0.4.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.
- package/CHANGELOG.md +75 -0
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/assets/model-picker.png +0 -0
- package/assets/settings-card.png +0 -0
- package/lib/bin.js +252 -46
- package/lib/client.js +128 -13
- package/lib/index.d.ts +101 -17
- package/lib/index.js +280 -66
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { dirname, join, resolve } from "node:path";
|
|
1
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { readFile, readdir } from "node:fs/promises";
|
|
@@ -17,7 +17,18 @@ const CN_BILLING_BASE = "https://www.codebuddy.cn";
|
|
|
17
17
|
/** Global base for `workbuddy.ai` logins. */
|
|
18
18
|
const GLOBAL_BASE = "https://www.workbuddy.ai";
|
|
19
19
|
/** Client UA the desktop CLI uses. */
|
|
20
|
+
/** Client UA the desktop CLI uses — the CN gateway answers this one. */
|
|
20
21
|
const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
|
|
22
|
+
/**
|
|
23
|
+
* Desktop app UA. The global gateway serves its product config only to this
|
|
24
|
+
* client channel: the CLI UA gets a truncated roster (or an HTTP 500), which
|
|
25
|
+
* is why the international catalog must be read with the desktop spelling.
|
|
26
|
+
*/
|
|
27
|
+
const DESKTOP_UA = "WorkBuddy/5.5.2";
|
|
28
|
+
/** CN model catalog. */
|
|
29
|
+
const MODELS_CATALOG_PATH = "/v2/enterprises/personal/models";
|
|
30
|
+
/** Global product config, which carries the international model roster. */
|
|
31
|
+
const GLOBAL_CONFIG_PATH = "/v3/config";
|
|
21
32
|
const JSON_TIMEOUT_MS = 3e4;
|
|
22
33
|
const ERROR_BODY_LIMIT = 4096;
|
|
23
34
|
/** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
|
|
@@ -40,20 +51,47 @@ const HARD_CREDIT_MARKERS = [
|
|
|
40
51
|
];
|
|
41
52
|
/** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
|
|
42
53
|
const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
|
|
54
|
+
/**
|
|
55
|
+
* Hosts the international product answers on, once each has been stripped of a
|
|
56
|
+
* leading label. The WorkBuddy AI desktop app signs in at `workbuddy.ai` (and
|
|
57
|
+
* the desktop client itself lists `workbuddy.cc` alongside it); the CodeBuddy
|
|
58
|
+
* CLI signs the same international account in at `codebuddy.ai`. All are served
|
|
59
|
+
* by one gateway stack, so all are `global` — missing a spelling sends those
|
|
60
|
+
* tokens to the CN gateway, which rejects them at the openresty layer with an
|
|
61
|
+
* HTML 401 instead of a business JSON error.
|
|
62
|
+
*/
|
|
63
|
+
const GLOBAL_HOSTS = [
|
|
64
|
+
"workbuddy.ai",
|
|
65
|
+
"workbuddy.cc",
|
|
66
|
+
"codebuddy.ai"
|
|
67
|
+
];
|
|
68
|
+
/** Region for a login domain; an empty domain means CN (matching upstream tooling). */
|
|
43
69
|
function regionOf(domain) {
|
|
44
70
|
const lowered = domain.trim().toLowerCase();
|
|
45
|
-
if (lowered
|
|
46
|
-
if (lowered === "workbuddy.ai" || lowered === "workbuddy.cc") return "global";
|
|
71
|
+
for (const host of GLOBAL_HOSTS) if (lowered === host || lowered.endsWith(`.${host}`)) return "global";
|
|
47
72
|
return "cn";
|
|
48
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Gateway for a global credential.
|
|
76
|
+
*
|
|
77
|
+
* International accounts are NOT interchangeable across brand domains: a token
|
|
78
|
+
* issued at `codebuddy.ai` is rejected by the `workbuddy.ai` gateway and vice
|
|
79
|
+
* versa, so the base must follow the credential's own domain rather than one
|
|
80
|
+
* hardcoded host. Anything unrecognised falls back to the desktop app's gateway.
|
|
81
|
+
*/
|
|
82
|
+
function globalBase(credential) {
|
|
83
|
+
const lowered = credential.domain.trim().toLowerCase();
|
|
84
|
+
if (lowered === "codebuddy.ai" || lowered.endsWith(".codebuddy.ai")) return "https://www.codebuddy.ai";
|
|
85
|
+
return GLOBAL_BASE;
|
|
86
|
+
}
|
|
49
87
|
function chatBase(credential) {
|
|
50
|
-
return regionOf(credential.domain) === "global" ?
|
|
88
|
+
return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_CHAT_BASE;
|
|
51
89
|
}
|
|
52
90
|
function billingBase(credential) {
|
|
53
|
-
return regionOf(credential.domain) === "global" ?
|
|
91
|
+
return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_BILLING_BASE;
|
|
54
92
|
}
|
|
55
93
|
function originReferer(credential) {
|
|
56
|
-
return regionOf(credential.domain) === "global" ?
|
|
94
|
+
return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_BILLING_BASE;
|
|
57
95
|
}
|
|
58
96
|
/** Headers every upstream request shares. */
|
|
59
97
|
function commonHeaders(credential) {
|
|
@@ -102,8 +140,23 @@ function billingHeaders(credential) {
|
|
|
102
140
|
if (credential.domain !== "") headers["X-Domain"] = credential.domain;
|
|
103
141
|
return headers;
|
|
104
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Gateway denials that arrive as an HTML page rather than a JSON envelope.
|
|
145
|
+
*
|
|
146
|
+
* openresty / APISIX reject a request before it reaches the product when the
|
|
147
|
+
* credential is one the gateway no longer honours — most often a stale sign-in
|
|
148
|
+
* left in the auth directory. The status alone (401) is not actionable and the
|
|
149
|
+
* HTML body leaks nothing useful, so this turns it into a sentence the user can
|
|
150
|
+
* act on.
|
|
151
|
+
*/
|
|
152
|
+
function isGatewayHtmlRejection(status, text) {
|
|
153
|
+
if (status !== 401 && status !== 403) return false;
|
|
154
|
+
const head = text.slice(0, 512).toLowerCase();
|
|
155
|
+
return head.includes("<html") || head.includes("openresty") || head.includes("apisix");
|
|
156
|
+
}
|
|
105
157
|
async function readEnvelope(response) {
|
|
106
158
|
const text = await response.text();
|
|
159
|
+
if (isGatewayHtmlRejection(response.status, text)) throw new Error("the WorkBuddy gateway rejected this credential (http 401). This usually means the account is using a stale sign-in the upstream no longer accepts: sign in again in the WorkBuddy desktop app, then pick the account on the plugin card. Run `dsh-workbuddy-xdpool doctor` to list every credential found.");
|
|
107
160
|
let parsed;
|
|
108
161
|
try {
|
|
109
162
|
parsed = JSON.parse(text);
|
|
@@ -162,12 +215,57 @@ function parseCreditMultiplier(value) {
|
|
|
162
215
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
|
|
163
216
|
}
|
|
164
217
|
/** Parse the upstream's `reasoning` object; unknown shapes degrade to `{}`. */
|
|
218
|
+
/**
|
|
219
|
+
* The effort ladder the upstream's plural-form payloads declare across both
|
|
220
|
+
* gateways (the live union of every `supportedEfforts` list seen; `minimal` has
|
|
221
|
+
* never appeared). Both gateways also accept every level of it on
|
|
222
|
+
* singular-form models — medium/xhigh fold into high, low/max answer with their
|
|
223
|
+
* own budgets — so a singular `effort` value is a DEFAULT, never the model's
|
|
224
|
+
* only level.
|
|
225
|
+
*/
|
|
226
|
+
const SINGULAR_EFFORT_LADDER = [
|
|
227
|
+
"low",
|
|
228
|
+
"medium",
|
|
229
|
+
"high",
|
|
230
|
+
"xhigh",
|
|
231
|
+
"max"
|
|
232
|
+
];
|
|
233
|
+
/**
|
|
234
|
+
* True when `reasoning` arrives in the singular spelling: an `effort` string,
|
|
235
|
+
* with none of the plural-form fields alongside it. Seen on CN
|
|
236
|
+
* `deepseek-v4.1-flash` / `kimi-k3-1` / `glm-5.2` and global
|
|
237
|
+
* `deepseek-v4.1-flash` / `kimi-k3` / `gemini-3.5-flash`.
|
|
238
|
+
*/
|
|
239
|
+
function isSingularEffortForm(raw) {
|
|
240
|
+
return typeof raw["effort"] === "string" && !Array.isArray(raw["supportedEfforts"]) && typeof raw["defaultEffort"] !== "string" && typeof raw["canDisableThinking"] !== "boolean";
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Fold a singular-form `effort` into the plural shape the rest of the plugin
|
|
244
|
+
* already understands. Probes on both gateways show these models answer with
|
|
245
|
+
* distinct `reasoning_content` across the whole ladder — and do not think at
|
|
246
|
+
* all when no `reasoning_effort` is sent — so the fold widens
|
|
247
|
+
* `supportedEfforts` and carries the declared value into `defaultEffort`. An
|
|
248
|
+
* unrecognized `effort` passes through as the lone level.
|
|
249
|
+
*/
|
|
250
|
+
function singularEffortLadder(raw) {
|
|
251
|
+
const effort = typeof raw["effort"] === "string" ? raw["effort"] : void 0;
|
|
252
|
+
if (effort === void 0) return void 0;
|
|
253
|
+
return SINGULAR_EFFORT_LADDER.includes(effort) ? [...SINGULAR_EFFORT_LADDER] : [effort];
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Parse the upstream's `reasoning` object; unknown shapes degrade to
|
|
257
|
+
* `undefined`. Both spellings normalize here: the plural form passes through as
|
|
258
|
+
* declared, and the singular `effort` form folds via
|
|
259
|
+
* {@link singularEffortLadder}.
|
|
260
|
+
*/
|
|
165
261
|
function parseReasoning(value) {
|
|
166
262
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
167
263
|
const raw = value;
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
const
|
|
264
|
+
const singularForm = isSingularEffortForm(raw);
|
|
265
|
+
const effort = typeof raw["effort"] === "string" ? raw["effort"] : void 0;
|
|
266
|
+
const supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((entry) => typeof entry === "string") : singularEffortLadder(raw);
|
|
267
|
+
const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : effort;
|
|
268
|
+
const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : singularForm ? true : void 0;
|
|
171
269
|
if (supportedEfforts === void 0 && defaultEffort === void 0 && canDisableThinking === void 0) return;
|
|
172
270
|
return {
|
|
173
271
|
...supportedEfforts === void 0 || supportedEfforts.length === 0 ? {} : { supportedEfforts },
|
|
@@ -190,6 +288,7 @@ function parseUpstreamModel(value) {
|
|
|
190
288
|
const creditMultiplier = parseCreditMultiplier(raw["credits"]);
|
|
191
289
|
const reasoning = parseReasoning(raw["reasoning"]);
|
|
192
290
|
const supportsToolCall = typeof raw["supportsToolCall"] === "boolean" ? raw["supportsToolCall"] : void 0;
|
|
291
|
+
const supportsImages = typeof raw["supportsImages"] === "boolean" ? raw["supportsImages"] : void 0;
|
|
193
292
|
return {
|
|
194
293
|
id,
|
|
195
294
|
name,
|
|
@@ -199,7 +298,8 @@ function parseUpstreamModel(value) {
|
|
|
199
298
|
...reasoning === void 0 ? {} : { reasoning },
|
|
200
299
|
...descriptionZh === void 0 ? {} : { descriptionZh },
|
|
201
300
|
...descriptionEn === void 0 ? {} : { descriptionEn },
|
|
202
|
-
...supportsToolCall === void 0 ? {} : { supportsToolCall }
|
|
301
|
+
...supportsToolCall === void 0 ? {} : { supportsToolCall },
|
|
302
|
+
...supportsImages === void 0 ? {} : { supportsImages }
|
|
203
303
|
};
|
|
204
304
|
}
|
|
205
305
|
var WorkBuddyUpstreamClient = class {
|
|
@@ -303,16 +403,43 @@ var WorkBuddyUpstreamClient = class {
|
|
|
303
403
|
if (typeof data["domain"] === "string" && data["domain"] !== "") outcome.domain = data["domain"];
|
|
304
404
|
return outcome;
|
|
305
405
|
}
|
|
306
|
-
/**
|
|
406
|
+
/**
|
|
407
|
+
* Fetch the model catalog, keeping the `cli` agent's models only.
|
|
408
|
+
*
|
|
409
|
+
* The two gateways are read differently, because they answer differently:
|
|
410
|
+
*
|
|
411
|
+
* - **CN** serves the roster at `/v2/enterprises/personal/models` and expects
|
|
412
|
+
* the CLI client spelling.
|
|
413
|
+
* - **Global** serves it as part of the product config at `/v3/config`, and
|
|
414
|
+
* only to the DESKTOP client channel. Asking the global host with the CLI UA
|
|
415
|
+
* yields a truncated roster, and the CN path answers HTTP 500 there — which
|
|
416
|
+
* is what left the international provider on its static fallback.
|
|
417
|
+
*
|
|
418
|
+
* Both documents share the `{ models, agents }` entry shape, so the parsing
|
|
419
|
+
* below is common to the two branches.
|
|
420
|
+
*/
|
|
307
421
|
async fetchModels(credential, signal) {
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
422
|
+
const global = regionOf(credential.domain) === "global";
|
|
423
|
+
const url = global ? `${globalBase(credential)}${GLOBAL_CONFIG_PATH}` : `${chatBase(credential)}${MODELS_CATALOG_PATH}`;
|
|
424
|
+
const headers = global ? {
|
|
425
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
426
|
+
"Accept": "application/json",
|
|
427
|
+
...credential.uid === void 0 || credential.uid === "" ? {} : { "X-User-Id": credential.uid },
|
|
428
|
+
...credential.domain === "" ? {} : { "X-Domain": credential.domain },
|
|
429
|
+
"X-Product": "SaaS",
|
|
430
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
431
|
+
"Connection": "close",
|
|
432
|
+
"User-Agent": DESKTOP_UA
|
|
433
|
+
} : {
|
|
434
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
435
|
+
"Accept": "application/json",
|
|
436
|
+
"Origin": originReferer(credential),
|
|
437
|
+
"Referer": `${originReferer(credential)}/`,
|
|
438
|
+
"User-Agent": CLIENT_UA
|
|
439
|
+
};
|
|
440
|
+
if (!global && credential.enterpriseId !== void 0 && credential.enterpriseId !== "") headers["X-Enterprise-Id"] = credential.enterpriseId;
|
|
441
|
+
const response = await this.fetchImpl(url, {
|
|
442
|
+
headers,
|
|
316
443
|
...signal === void 0 ? {} : { signal }
|
|
317
444
|
});
|
|
318
445
|
const envelope = await readEnvelope(response);
|
|
@@ -482,7 +609,6 @@ var WorkBuddyUpstreamClient = class {
|
|
|
482
609
|
/** Live auth file name the WorkBuddy desktop app writes. */
|
|
483
610
|
const WORKBUDDY_LIVE_FILENAME = "workbuddy-desktop.info";
|
|
484
611
|
/** Snapshot files left behind by previous logins share this prefix. */
|
|
485
|
-
const SNAPSHOT_PREFIX = "workbuddy-desktop.";
|
|
486
612
|
/** Env override for the auth file or its directory. */
|
|
487
613
|
const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
|
|
488
614
|
function nonEmptyEnv(value) {
|
|
@@ -541,11 +667,13 @@ function parseWorkBuddyAuth(text, sourcePath) {
|
|
|
541
667
|
if (accessToken === "") return void 0;
|
|
542
668
|
const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
|
|
543
669
|
if (refreshExpiresAtMs !== void 0 && refreshExpiresAtMs > 0 && refreshExpiresAtMs < Date.now()) return;
|
|
670
|
+
const lastRefreshAtMs = typeof auth["lastRefreshTime"] === "number" ? expiryToMs(auth["lastRefreshTime"]) : void 0;
|
|
544
671
|
return {
|
|
545
672
|
accessToken,
|
|
546
673
|
refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
|
|
547
674
|
expiresAtMs: typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0,
|
|
548
675
|
...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
|
|
676
|
+
...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
|
|
549
677
|
...optionalString(identity["nickname"]) === void 0 ? {} : { nickname: optionalString(identity["nickname"]) },
|
|
550
678
|
...optionalString(identity["uin"]) === void 0 ? {} : { uin: optionalString(identity["uin"]) },
|
|
551
679
|
...optionalString(identity["uid"]) === void 0 ? {} : { uid: optionalString(identity["uid"]) },
|
|
@@ -558,6 +686,42 @@ function parseWorkBuddyAuth(text, sourcePath) {
|
|
|
558
686
|
* Stable account id. `uin` is the billing identity the upstream keys on and
|
|
559
687
|
* survives re-login; `uid` is the fallback.
|
|
560
688
|
*/
|
|
689
|
+
/**
|
|
690
|
+
* True when `path` is the desktop app's live sign-in (as opposed to a backup
|
|
691
|
+
* snapshot it left behind). The live file always wins: it is the session the
|
|
692
|
+
* app itself is using.
|
|
693
|
+
*/
|
|
694
|
+
function isLiveAuthFile(path) {
|
|
695
|
+
return basename(path) === WORKBUDDY_LIVE_FILENAME;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Which of two credentials for the same account the pool should keep.
|
|
699
|
+
*
|
|
700
|
+
* Ordering, highest first:
|
|
701
|
+
*
|
|
702
|
+
* 1. the live file the desktop app is signed in with;
|
|
703
|
+
* 2. the credential the upstream issued most recently (`lastRefreshAtMs`);
|
|
704
|
+
* 3. the longer stored expiry, as a fallback for documents that carry no issue
|
|
705
|
+
* time (the plugin's own refreshed copy, older builds).
|
|
706
|
+
*
|
|
707
|
+
* The stored expiry alone is NOT a freshness signal: the upstream does not
|
|
708
|
+
* rewrite it when it revokes a token, so a long-dead backup can claim to expire
|
|
709
|
+
* later than the token that actually works. Selecting on it made every upstream
|
|
710
|
+
* call return 401 while a perfectly good credential sat in the same directory.
|
|
711
|
+
*/
|
|
712
|
+
function compareFreshness(a, b) {
|
|
713
|
+
const aLive = isLiveAuthFile(a.sourcePath) ? 1 : 0;
|
|
714
|
+
const bLive = isLiveAuthFile(b.sourcePath) ? 1 : 0;
|
|
715
|
+
if (aLive !== bLive) return bLive - aLive;
|
|
716
|
+
const aIssued = a.lastRefreshAtMs ?? 0;
|
|
717
|
+
const bIssued = b.lastRefreshAtMs ?? 0;
|
|
718
|
+
if (aIssued !== bIssued) return bIssued - aIssued;
|
|
719
|
+
return b.expiresAtMs - a.expiresAtMs;
|
|
720
|
+
}
|
|
721
|
+
/** True when `candidate` should replace `incumbent` for the same account. */
|
|
722
|
+
function isFresher(candidate, incumbent) {
|
|
723
|
+
return compareFreshness(candidate, incumbent) < 0;
|
|
724
|
+
}
|
|
561
725
|
function workbuddyAccountId(credential) {
|
|
562
726
|
const stable = credential.uin ?? credential.uid ?? credential.nickname ?? "unknown";
|
|
563
727
|
return createHash("sha256").update(`workbuddy\0${stable}`).digest("hex").slice(0, 16);
|
|
@@ -569,6 +733,17 @@ function accountLabel(credential) {
|
|
|
569
733
|
return discriminator === "" ? name : `${name}#${discriminator}`;
|
|
570
734
|
}
|
|
571
735
|
/** List the auth files in one directory: the live file plus every snapshot. */
|
|
736
|
+
/**
|
|
737
|
+
* Credential files in one auth directory, freshest first.
|
|
738
|
+
*
|
|
739
|
+
* Every `*.info` file counts, not just the timestamped `workbuddy-desktop.*`
|
|
740
|
+
* snapshots: the international client signs in as `workbuddy-desktop-ai.info`
|
|
741
|
+
* (a hyphen, not a dot), so a prefix test silently dropped every global
|
|
742
|
+
* credential and the global provider then saw an empty pool.
|
|
743
|
+
*
|
|
744
|
+
* Filenames are plain strings, and the ordering here is only a first pass —
|
|
745
|
+
* `isFresher` makes the real call once each file has been parsed.
|
|
746
|
+
*/
|
|
572
747
|
async function authFilesIn(dir) {
|
|
573
748
|
let entries;
|
|
574
749
|
try {
|
|
@@ -576,10 +751,7 @@ async function authFilesIn(dir) {
|
|
|
576
751
|
} catch {
|
|
577
752
|
return [];
|
|
578
753
|
}
|
|
579
|
-
const files = entries.filter((name) =>
|
|
580
|
-
if (name === "workbuddy-desktop.info") return true;
|
|
581
|
-
return name.startsWith(SNAPSHOT_PREFIX) && name.endsWith(".info");
|
|
582
|
-
});
|
|
754
|
+
const files = entries.filter((name) => name.endsWith(".info"));
|
|
583
755
|
files.sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
|
|
584
756
|
return files.map((name) => join(dir, name));
|
|
585
757
|
}
|
|
@@ -609,6 +781,8 @@ var WorkBuddyAccountPool = class {
|
|
|
609
781
|
client;
|
|
610
782
|
refreshMarginMs;
|
|
611
783
|
accounts = [];
|
|
784
|
+
distribution;
|
|
785
|
+
/** Cursor for round-robin mode; unused under priority distribution. */
|
|
612
786
|
cursor = 0;
|
|
613
787
|
lastScanAtMs = 0;
|
|
614
788
|
preferredId;
|
|
@@ -619,6 +793,7 @@ var WorkBuddyAccountPool = class {
|
|
|
619
793
|
this.cooldownMs = options.cooldownMs ?? 6e4;
|
|
620
794
|
this.client = options.client;
|
|
621
795
|
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
796
|
+
this.distribution = options.distribution ?? "priority";
|
|
622
797
|
}
|
|
623
798
|
/**
|
|
624
799
|
* Re-apply configuration that only affects discovery and cooldown policy,
|
|
@@ -628,6 +803,7 @@ var WorkBuddyAccountPool = class {
|
|
|
628
803
|
applyConfig(options) {
|
|
629
804
|
if (options.authDirs !== void 0 && options.authDirs.length > 0) this.authDirs = options.authDirs;
|
|
630
805
|
if (options.cooldownMs !== void 0 && options.cooldownMs >= 1e3) this.cooldownMs = options.cooldownMs;
|
|
806
|
+
if (options.distribution !== void 0) this.distribution = options.distribution;
|
|
631
807
|
}
|
|
632
808
|
/** Rescan the auth directories and merge newly discovered accounts. */
|
|
633
809
|
async scan() {
|
|
@@ -652,13 +828,15 @@ var WorkBuddyAccountPool = class {
|
|
|
652
828
|
});
|
|
653
829
|
continue;
|
|
654
830
|
}
|
|
655
|
-
if ((credential
|
|
831
|
+
if (isFresher(credential, existing.credential)) byId.set(id, {
|
|
656
832
|
...existing,
|
|
657
833
|
credential,
|
|
658
834
|
label: accountLabel(credential)
|
|
659
835
|
});
|
|
660
836
|
}
|
|
661
|
-
|
|
837
|
+
const ordered = [...byId.values()];
|
|
838
|
+
ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
|
|
839
|
+
this.accounts = ordered;
|
|
662
840
|
this.lastScanAtMs = Date.now();
|
|
663
841
|
return this.accounts;
|
|
664
842
|
}
|
|
@@ -685,12 +863,24 @@ var WorkBuddyAccountPool = class {
|
|
|
685
863
|
});
|
|
686
864
|
}
|
|
687
865
|
/**
|
|
688
|
-
* Pick the
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
866
|
+
* Pick the account to serve a request.
|
|
867
|
+
*
|
|
868
|
+
* Two distributions, chosen by the `distribution` setting:
|
|
869
|
+
*
|
|
870
|
+
* - **priority** (default, and what the card ships with): one account serves
|
|
871
|
+
* every request until it is rate-limited, then the next in order takes over.
|
|
872
|
+
* Credits drain one account at a time, and a cooling account returns to the
|
|
873
|
+
* head of the queue the moment its window resets — it was never consumed, so
|
|
874
|
+
* it resumes straight away.
|
|
875
|
+
* - **round-robin**: consecutive requests rotate through the pool so spend
|
|
876
|
+
* spreads evenly across every account.
|
|
877
|
+
*
|
|
878
|
+
* In both modes an explicit user selection (`prefer`) heads the list, a
|
|
879
|
+
* cooling account is skipped for that model only, and an unrecognised setting
|
|
880
|
+
* falls back to priority.
|
|
881
|
+
*
|
|
882
|
+
* Scans on first use, and rescans when every known account is cooling down: a
|
|
883
|
+
* fresh desktop login is the usual way out of an exhausted pool.
|
|
694
884
|
*/
|
|
695
885
|
async acquire(modelId, region) {
|
|
696
886
|
if (this.accounts.length === 0) await this.scan();
|
|
@@ -700,21 +890,25 @@ var WorkBuddyAccountPool = class {
|
|
|
700
890
|
pool = this.available(Date.now(), modelId, region);
|
|
701
891
|
}
|
|
702
892
|
if (pool.length === 0) return void 0;
|
|
703
|
-
let start = this.cursor % pool.length;
|
|
704
893
|
if (this.preferredId !== void 0) {
|
|
705
894
|
const preferredIndex = pool.findIndex((account) => account.id === this.preferredId);
|
|
706
|
-
if (preferredIndex
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
if (account === void 0) continue;
|
|
711
|
-
await this.ensureFresh(account);
|
|
712
|
-
this.cursor = (start + step + 1) % pool.length;
|
|
713
|
-
return account;
|
|
895
|
+
if (preferredIndex > 0) {
|
|
896
|
+
const [preferred] = pool.splice(preferredIndex, 1);
|
|
897
|
+
if (preferred !== void 0) pool = [preferred, ...pool];
|
|
898
|
+
}
|
|
714
899
|
}
|
|
715
|
-
|
|
900
|
+
const index = this.distribution === "round-robin" ? this.cursor % pool.length : 0;
|
|
901
|
+
const account = pool[index];
|
|
902
|
+
if (account === void 0) return void 0;
|
|
903
|
+
if (this.distribution === "round-robin") this.cursor = (index + 1) % pool.length;
|
|
904
|
+
await this.ensureFresh(account);
|
|
905
|
+
return account;
|
|
716
906
|
}
|
|
717
907
|
/** Pin the account the plugin card should prefer; tokens stay out of settings. */
|
|
908
|
+
/** How the pool currently spreads requests. Shown on the card. */
|
|
909
|
+
currentDistribution() {
|
|
910
|
+
return this.distribution;
|
|
911
|
+
}
|
|
718
912
|
prefer(accountId) {
|
|
719
913
|
this.preferredId = accountId;
|
|
720
914
|
}
|
|
@@ -907,7 +1101,7 @@ var WorkBuddyCatalog = class {
|
|
|
907
1101
|
const budgets = this.selection.contextBudgets;
|
|
908
1102
|
return this.models.filter((model) => allow === void 0 || allow.has(model.id)).map((model) => {
|
|
909
1103
|
const next = { ...model };
|
|
910
|
-
if (imageSet !== void 0) next.supportsImages = imageSet.has(model.id);
|
|
1104
|
+
if (imageSet !== void 0) next.supportsImages = next.supportsImages || imageSet.has(model.id);
|
|
911
1105
|
const budget = budgets?.[model.id];
|
|
912
1106
|
if (budget !== void 0 && budget > 0 && budget < next.contextWindow) next.contextWindow = budget;
|
|
913
1107
|
return next;
|
|
@@ -955,7 +1149,7 @@ function toModelInfo(model) {
|
|
|
955
1149
|
name: model.name,
|
|
956
1150
|
contextWindow: model.contextWindow,
|
|
957
1151
|
maxOutputTokens: model.maxTokens,
|
|
958
|
-
supportsImages: model.
|
|
1152
|
+
supportsImages: model.supportsImages ?? false,
|
|
959
1153
|
...model.creditMultiplier === void 0 ? {} : { multiplier: model.creditMultiplier },
|
|
960
1154
|
...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts }
|
|
961
1155
|
};
|
|
@@ -1678,7 +1872,7 @@ function toWebModel(model, selection) {
|
|
|
1678
1872
|
async function poolWebStatus(deps, region = "cn") {
|
|
1679
1873
|
const accounts = deps.pool.list(region);
|
|
1680
1874
|
const regions = ["cn", "global"];
|
|
1681
|
-
const selection = deps.
|
|
1875
|
+
const selection = deps.catalogs[region].currentSelection();
|
|
1682
1876
|
const rows = [];
|
|
1683
1877
|
const now = Date.now();
|
|
1684
1878
|
for (const account of accounts) {
|
|
@@ -1728,7 +1922,7 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
1728
1922
|
accounts: rows,
|
|
1729
1923
|
...firstUsable === void 0 ? {} : { activeAccountId: firstUsable.id },
|
|
1730
1924
|
cooling,
|
|
1731
|
-
models: deps.
|
|
1925
|
+
models: deps.catalogs[region].current().map((model) => toWebModel({
|
|
1732
1926
|
id: model.id,
|
|
1733
1927
|
name: model.name,
|
|
1734
1928
|
contextWindow: model.contextWindow,
|
|
@@ -1741,6 +1935,7 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
1741
1935
|
}, selection)),
|
|
1742
1936
|
selection,
|
|
1743
1937
|
region,
|
|
1938
|
+
distribution: deps.pool.currentDistribution(),
|
|
1744
1939
|
regions,
|
|
1745
1940
|
shim
|
|
1746
1941
|
};
|
|
@@ -1884,6 +2079,7 @@ const DEFAULT_CONTEXT_BUDGET = 2e5;
|
|
|
1884
2079
|
const Config = z.object({
|
|
1885
2080
|
authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
|
|
1886
2081
|
cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
|
|
2082
|
+
distribution: z.union(["priority", "round-robin"]).default("priority").description("How requests are spread: priority (drain one) or round-robin"),
|
|
1887
2083
|
enabledModelIds: z.array(z.string()).default([]).description("Model ids enabled in the picker (empty = all)"),
|
|
1888
2084
|
imageModelIds: z.array(z.string()).default([]).description("Model ids accepting image input (empty = follow upstream)"),
|
|
1889
2085
|
contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Per-model context-window override, keyed by model id")
|
|
@@ -1899,6 +2095,14 @@ function setApi(next) {
|
|
|
1899
2095
|
api = next;
|
|
1900
2096
|
}
|
|
1901
2097
|
/** Assemble the runtime objects without registering anything. */
|
|
2098
|
+
/**
|
|
2099
|
+
* Assemble the runtime objects without registering anything.
|
|
2100
|
+
*
|
|
2101
|
+
* One catalog per region, mirroring the two shims: the CN and global gateways
|
|
2102
|
+
* do not advertise the same roster, and a shared catalog meant the picker showed
|
|
2103
|
+
* whichever list happened to be fetched first (always the CN one, since the
|
|
2104
|
+
* seeding step read `accounts[0]`).
|
|
2105
|
+
*/
|
|
1902
2106
|
function createCore(logger) {
|
|
1903
2107
|
const client = new WorkBuddyUpstreamClient();
|
|
1904
2108
|
return {
|
|
@@ -1906,7 +2110,10 @@ function createCore(logger) {
|
|
|
1906
2110
|
...logger === void 0 ? {} : { logger },
|
|
1907
2111
|
client
|
|
1908
2112
|
}),
|
|
1909
|
-
|
|
2113
|
+
catalogs: {
|
|
2114
|
+
cn: new WorkBuddyCatalog(),
|
|
2115
|
+
global: new WorkBuddyCatalog()
|
|
2116
|
+
},
|
|
1910
2117
|
client
|
|
1911
2118
|
};
|
|
1912
2119
|
}
|
|
@@ -1940,17 +2147,19 @@ function apply(ctx, config = {}) {
|
|
|
1940
2147
|
}
|
|
1941
2148
|
};
|
|
1942
2149
|
const applyConfigFromSource = () => {
|
|
1943
|
-
const { authFile, cooldownMs, enabledModelIds, imageModelIds, contextBudgets } = current();
|
|
2150
|
+
const { authFile, cooldownMs, distribution, enabledModelIds, imageModelIds, contextBudgets } = current();
|
|
1944
2151
|
core.pool.applyConfig({
|
|
1945
2152
|
...authFile === void 0 ? {} : { authDirs: [dirname(authFile)] },
|
|
1946
|
-
...cooldownMs === void 0 ? {} : { cooldownMs }
|
|
2153
|
+
...cooldownMs === void 0 ? {} : { cooldownMs },
|
|
2154
|
+
distribution: distribution ?? "priority"
|
|
1947
2155
|
});
|
|
1948
|
-
|
|
2156
|
+
const selection = {
|
|
1949
2157
|
...enabledModelIds === void 0 ? {} : { enabledModelIds },
|
|
1950
2158
|
...imageModelIds === void 0 ? {} : { imageModelIds },
|
|
1951
2159
|
...contextBudgets === void 0 ? {} : { contextBudgets }
|
|
1952
|
-
}
|
|
1953
|
-
|
|
2160
|
+
};
|
|
2161
|
+
core.catalogs.cn.applySelection(selection);
|
|
2162
|
+
core.catalogs.global.applySelection(selection);
|
|
1954
2163
|
};
|
|
1955
2164
|
const settingsService = ctx.settings;
|
|
1956
2165
|
if (typeof settingsService.installSection === "function") settingsService.installSection(ctx, WORKBUDDY_POOL_SETTINGS_NS, Config, config, sectionHooks);
|
|
@@ -1974,14 +2183,14 @@ function apply(ctx, config = {}) {
|
|
|
1974
2183
|
cn: createWorkBuddyShim({
|
|
1975
2184
|
pool: core.pool,
|
|
1976
2185
|
client: core.client,
|
|
1977
|
-
catalog: core.
|
|
2186
|
+
catalog: core.catalogs.cn,
|
|
1978
2187
|
logger: ctx.logger,
|
|
1979
2188
|
region: "cn"
|
|
1980
2189
|
}),
|
|
1981
2190
|
global: createWorkBuddyShim({
|
|
1982
2191
|
pool: core.pool,
|
|
1983
2192
|
client: core.client,
|
|
1984
|
-
catalog: core.
|
|
2193
|
+
catalog: core.catalogs.global,
|
|
1985
2194
|
logger: ctx.logger,
|
|
1986
2195
|
region: "global"
|
|
1987
2196
|
})
|
|
@@ -2006,7 +2215,7 @@ function apply(ctx, config = {}) {
|
|
|
2006
2215
|
});
|
|
2007
2216
|
ctx.inject(["webServer"], (webCtx) => registerPoolStatusRoute(webCtx, {
|
|
2008
2217
|
pool: core.pool,
|
|
2009
|
-
|
|
2218
|
+
catalogs: core.catalogs,
|
|
2010
2219
|
client: core.client,
|
|
2011
2220
|
shim: () => shimInfo("cn"),
|
|
2012
2221
|
saveSelection: (selection) => {
|
|
@@ -2029,7 +2238,7 @@ function apply(ctx, config = {}) {
|
|
|
2029
2238
|
async status(includeCredits = false) {
|
|
2030
2239
|
return buildStatus({
|
|
2031
2240
|
pool: core.pool,
|
|
2032
|
-
catalog: core.
|
|
2241
|
+
catalog: core.catalogs.cn,
|
|
2033
2242
|
client: core.client,
|
|
2034
2243
|
shim: shimInfo("cn"),
|
|
2035
2244
|
includeCredits
|
|
@@ -2045,13 +2254,13 @@ function apply(ctx, config = {}) {
|
|
|
2045
2254
|
const adaptersByRegion = {
|
|
2046
2255
|
cn: createWorkBuddyAdapter({
|
|
2047
2256
|
shim: shims.cn,
|
|
2048
|
-
catalog: core.
|
|
2257
|
+
catalog: core.catalogs.cn,
|
|
2049
2258
|
providerId: POOL_PROVIDER_BY_REGION.cn,
|
|
2050
2259
|
displayName: POOL_NAME_BY_REGION.cn
|
|
2051
2260
|
}),
|
|
2052
2261
|
global: createWorkBuddyAdapter({
|
|
2053
2262
|
shim: shims.global,
|
|
2054
|
-
catalog: core.
|
|
2263
|
+
catalog: core.catalogs.global,
|
|
2055
2264
|
providerId: POOL_PROVIDER_BY_REGION.global,
|
|
2056
2265
|
displayName: POOL_NAME_BY_REGION.global
|
|
2057
2266
|
})
|
|
@@ -2100,7 +2309,8 @@ function apply(ctx, config = {}) {
|
|
|
2100
2309
|
}
|
|
2101
2310
|
ctx.llm.registerModelDiscovery(WORKBUDDY_POOL_SETTINGS_NS, async (request) => {
|
|
2102
2311
|
if (request.provider !== "workbuddy-xdpool" && request.provider !== "workbuddy-xdpool-global") return [];
|
|
2103
|
-
|
|
2312
|
+
const region = request.provider === "workbuddy-xdpool-global" ? "global" : "cn";
|
|
2313
|
+
return core.catalogs[region].visible().map((model) => ({
|
|
2104
2314
|
id: model.id,
|
|
2105
2315
|
name: model.name,
|
|
2106
2316
|
contextWindow: model.contextWindow,
|
|
@@ -2120,16 +2330,20 @@ function apply(ctx, config = {}) {
|
|
|
2120
2330
|
ctx.logger.warn("dsh-workbuddy-xdpool: account discovery failed", error);
|
|
2121
2331
|
});
|
|
2122
2332
|
(async () => {
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2333
|
+
const accounts = await core.pool.scan();
|
|
2334
|
+
for (const region of ["cn", "global"]) try {
|
|
2335
|
+
const credential = accounts.find((account) => regionOf(account.credential.domain) === region)?.credential;
|
|
2336
|
+
if (credential === void 0) {
|
|
2337
|
+
ctx.logger.info?.(`dsh-workbuddy-xdpool: no ${region} account yet; keeping the static ${region} catalog`);
|
|
2338
|
+
continue;
|
|
2339
|
+
}
|
|
2126
2340
|
const models = await core.client.fetchModels(credential);
|
|
2127
|
-
core.
|
|
2128
|
-
|
|
2129
|
-
ctx.logger.info?.(`dsh-workbuddy-xdpool: live catalog seeded with ${models.length} model(s)`);
|
|
2341
|
+
core.catalogs[region].updateFromUpstream(models);
|
|
2342
|
+
ctx.logger.info?.(`dsh-workbuddy-xdpool: ${region} catalog seeded with ${models.length} model(s)`);
|
|
2130
2343
|
} catch (error) {
|
|
2131
|
-
ctx.logger.warn(
|
|
2344
|
+
ctx.logger.warn(`dsh-workbuddy-xdpool: ${region} model catalog unavailable; using static fallback`, error);
|
|
2132
2345
|
}
|
|
2346
|
+
invalidateCatalog();
|
|
2133
2347
|
})();
|
|
2134
2348
|
}, (error) => {
|
|
2135
2349
|
ctx.logger.error("dsh-workbuddy-xdpool: shim failed to listen", error);
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-workbuddy-xdpool",
|
|
3
3
|
"displayName": "DSH WorkBuddy XD Pool",
|
|
4
4
|
"description": "Merge every locally signed-in WorkBuddy account into DeepSeek Harness as one auto-failing-over model pool (multi-account rotation, live credits, daily check-in and model catalog).",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.4.1",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "aosi526",
|
|
8
8
|
"repository": {
|