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/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.endsWith(".workbuddy.ai") || lowered.endsWith(".workbuddy.cc")) return "global";
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" ? GLOBAL_BASE : CN_CHAT_BASE;
88
+ return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_CHAT_BASE;
51
89
  }
52
90
  function billingBase(credential) {
53
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
91
+ return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_BILLING_BASE;
54
92
  }
55
93
  function originReferer(credential) {
56
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
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 supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((effort) => typeof effort === "string") : void 0;
169
- const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : void 0;
170
- const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : void 0;
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
- /** GET the personal model catalog, keeping the `cli` agent's models only. */
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 response = await this.fetchImpl(`${chatBase(credential)}/console/enterprises/personal/models`, {
309
- headers: {
310
- "Authorization": `Bearer ${credential.accessToken}`,
311
- "Accept": "application/json",
312
- "Origin": originReferer(credential),
313
- "Referer": `${originReferer(credential)}/`,
314
- "User-Agent": CLIENT_UA
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.expiresAtMs ?? 0) > (existing.credential.expiresAtMs ?? 0)) byId.set(id, {
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
- this.accounts = [...byId.values()];
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 next usable account for an optional model. Scans on first use,
689
- * and rescans when every known account is cooling down — a fresh desktop
690
- * login is the usual way out of an exhausted pool. A preferred
691
- * (user-selected) account that is healthy is tried first; otherwise the
692
- * cursor round-robins so consecutive requests spread across accounts and a
693
- * still-cooling preferred account is skipped.
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 !== -1) start = preferredIndex;
707
- }
708
- for (let step = 0; step < pool.length; step += 1) {
709
- const account = pool[(start + step) % pool.length];
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
- return pool[0];
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.multimodal ?? false,
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.catalog.currentSelection();
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.catalog.current().map((model) => toWebModel({
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
- catalog: new WorkBuddyCatalog(),
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
- core.catalog.applySelection({
2156
+ const selection = {
1949
2157
  ...enabledModelIds === void 0 ? {} : { enabledModelIds },
1950
2158
  ...imageModelIds === void 0 ? {} : { imageModelIds },
1951
2159
  ...contextBudgets === void 0 ? {} : { contextBudgets }
1952
- });
1953
- invalidateCatalog();
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.catalog,
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.catalog,
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
- catalog: core.catalog,
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.catalog,
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.catalog,
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.catalog,
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
- return core.catalog.visible().map((model) => ({
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
- try {
2124
- const credential = (await core.pool.scan())[0]?.credential;
2125
- if (credential === void 0) return;
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.catalog.updateFromUpstream(models);
2128
- invalidateCatalog();
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("dsh-workbuddy-xdpool: live model catalog unavailable; using static fallback", error);
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.3.0",
5
+ "version": "0.4.1",
6
6
  "license": "MIT",
7
7
  "author": "aosi526",
8
8
  "repository": {