dsh-connect-workbuddy 4.5.1 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3825,7 +3825,7 @@ function generationOf(version) {
3825
3825
  //#endregion
3826
3826
  //#region src/version.ts
3827
3827
  /** The npm package version this build was produced from. */
3828
- const WORKBUDDY_CONNECT_VERSION = "4.5.1";
3828
+ const WORKBUDDY_CONNECT_VERSION = "4.6.0";
3829
3829
  //#endregion
3830
3830
  //#region src/host-heartbeat.ts
3831
3831
  /**
package/lib/index.d.ts CHANGED
@@ -1074,6 +1074,20 @@ interface WorkBuddyShim {
1074
1074
  * retry may happen before anything has been written to the client.
1075
1075
  */
1076
1076
  type WorkBuddyFailoverAccount = (triedAccountIds: readonly string[]) => Promise<WorkBuddyCredential | undefined>;
1077
+ /**
1078
+ * Why the pool had no candidate, asked only after failover came up empty.
1079
+ *
1080
+ * A separate callback rather than a richer return type on
1081
+ * {@link WorkBuddyFailoverAccount}: the loop calls that one on the HOT PATH of
1082
+ * every retry, and a policy that had to explain its refusal on every call would
1083
+ * make the common case pay for the rare one. This runs at most once per failed
1084
+ * request, and only when the answer is already going to be an error.
1085
+ *
1086
+ * Returns a short Chinese clause naming the reason (e.g. "池中其余账号都在冷却"),
1087
+ * or undefined when the host has nothing to add — in which case the reply says
1088
+ * only that no other account was available.
1089
+ */
1090
+ type WorkBuddyFailoverShortfall = () => Promise<string | undefined>;
1077
1091
  /** Constructor dependencies. */
1078
1092
  interface WorkBuddyShimOptions {
1079
1093
  store: WorkBuddyCredentialStore;
@@ -1086,6 +1100,19 @@ interface WorkBuddyShimOptions {
1086
1100
  * reported as-is.
1087
1101
  */
1088
1102
  failoverAccount?: WorkBuddyFailoverAccount;
1103
+ /**
1104
+ * Why the pool had no usable candidate, asked after failover found none.
1105
+ *
1106
+ * The failure path uses this to answer the question the user is actually left
1107
+ * with — "why did nothing else work?" — instead of only reporting that every
1108
+ * attempt failed. Without it the pool's own reasoning is invisible at exactly
1109
+ * the moment it matters, and "换个模型" reads as a guess rather than as the
1110
+ * conclusion of a search the plugin already performed.
1111
+ *
1112
+ * Optional, and its absence is a supported state: a host that does not supply
1113
+ * it produces the same reply as before this option existed.
1114
+ */
1115
+ failoverShortfall?: WorkBuddyFailoverShortfall;
1089
1116
  /**
1090
1117
  * Called before the FIRST attempt of every chat request, so the plugin can
1091
1118
  * point the store at the account the pool's ranking says should serve.
@@ -2660,7 +2687,6 @@ interface WorkBuddyWebPool {
2660
2687
  * an id whose model has left the catalog must survive a save so the user's
2661
2688
  * record of what they chose is not silently destroyed.
2662
2689
  */
2663
- targetModelIdByAccount?: Readonly<Record<string, string>>;
2664
2690
  /**
2665
2691
  * Where the target came from. `'stale'` means the SAVED id is no longer in
2666
2692
  * the catalog — a distinct state from `'none'` (no free model exists),
@@ -2953,7 +2979,6 @@ interface WorkBuddyPoolDeps {
2953
2979
  enabled: boolean;
2954
2980
  targetModelId: string;
2955
2981
  /** Per-account override of `targetModelId` (issue #61). */
2956
- targetModelIdByAccount?: Readonly<Record<string, string>>;
2957
2982
  /**
2958
2983
  * Tokens each test probe sends as saved; `0` when the user never chose.
2959
2984
  *
@@ -3051,16 +3076,11 @@ interface WorkBuddyPoolDeps {
3051
3076
  */
3052
3077
  test?(region: WorkBuddyRegion,
3053
3078
  /**
3054
- * One model for the whole batch, or a per-account resolver (issue #61).
3055
- *
3056
- * The resolver form is what lets a batch honour a per-account test model; a
3057
- * resolver that names no model yields an `unavailable` row for that account
3058
- * rather than silently testing the region default.
3079
+ * The model the whole batch is measured on — ONE value, by design: every
3080
+ * member of the batch must be probed on the same model or a green batch
3081
+ * stops meaning a single thing (see the removal note in `account-pool-run.ts`).
3059
3082
  */
3060
- modelId: string | ((accountId: string) => {
3061
- modelId?: string;
3062
- staleModelId?: string;
3063
- }), onRow?: (row: WorkBuddyPoolTestRow) => void): Promise<readonly WorkBuddyPoolTestRow[]>;
3083
+ modelId: string, onRow?: (row: WorkBuddyPoolTestRow) => void): Promise<readonly WorkBuddyPoolTestRow[]>;
3064
3084
  /** The region's live catalog, used to resolve the free target model. */
3065
3085
  catalog?(region: WorkBuddyRegion): readonly WorkBuddyModelInfo[];
3066
3086
  }
@@ -3200,7 +3220,6 @@ interface WorkBuddyPoolPreferences {
3200
3220
  * chatting with. An id that is no longer in the catalog is reported as stale
3201
3221
  * rather than replaced, so no credits are spent on a model nobody chose.
3202
3222
  */
3203
- targetModelIdByAccount?: Record<string, string>;
3204
3223
  /**
3205
3224
  * How much input a probe sends, in tokens — the pool's biggest single knob.
3206
3225
  *
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-Vd2tNdsv.js";
1
+ import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-BxBvvAZq.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, readFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -1719,20 +1719,25 @@ function describeAccount(credential) {
1719
1719
  * One short phrase for why an account was abandoned, for the in-reply notice.
1720
1720
  *
1721
1721
  * Kept to the distinction the user can act on rather than the internal kind
1722
- * name: "rate limited" says wait or add an account, "rejected" says the sign-in
1722
+ * name: "账号被限流" says wait or add an account, "登录已失效" says the sign-in
1723
1723
  * is the problem. The numeric kind would only send them to the log.
1724
+ *
1725
+ * The wording is what `failoverNoticeText` interpolates into a Chinese sentence,
1726
+ * so these are Chinese phrases rather than English ones — the reader of a failed
1727
+ * turn in this product reads Chinese, and an English clause inside Chinese prose
1728
+ * was the previous build's most visible rough edge.
1724
1729
  */
1725
1730
  function failoverReasonText(failure) {
1726
- if (failure.ok) return "unusable";
1731
+ if (failure.ok) return "不可用";
1727
1732
  switch (failure.kind) {
1728
- case "soft_rate": return "rate limited";
1729
- case "hard_credit": return "out of credits";
1730
- case "model_busy": return "throttled on this model (not the account)";
1731
- case "session_dead": return "signed out";
1732
- case "policy_reject": return "rejected by policy";
1733
- case "not_found": return "model unavailable";
1734
- case "server": return "erroring upstream";
1735
- default: return `unusable (http ${failure.status})`;
1733
+ case "soft_rate": return "被限流";
1734
+ case "hard_credit": return "积分已耗尽";
1735
+ case "model_busy": return "该模型当前繁忙(不是账号问题)";
1736
+ case "session_dead": return "登录已失效";
1737
+ case "policy_reject": return "被服务端策略拒绝";
1738
+ case "not_found": return "模型不可用";
1739
+ case "server": return "上游异常";
1740
+ default: return `不可用(http ${failure.status})`;
1736
1741
  }
1737
1742
  }
1738
1743
  /**
@@ -1747,9 +1752,13 @@ function failoverReasonText(failure) {
1747
1752
  * over is the one the user is about to keep talking to, and naming it invites
1748
1753
  * the reading that they must now manage it. What they need is to know the one
1749
1754
  * they chose did not answer, and why.
1755
+ *
1756
+ * Chinese, matching the rest of a failed turn's copy: the host renders its own
1757
+ * quota sentence in Chinese, the pool rows are Chinese, and an English clause
1758
+ * here was the one line of the reply a Chinese reader had to decode.
1750
1759
  */
1751
1760
  function failoverNoticeText(from, reason) {
1752
- return `\n\n> [dsh-connect-workbuddy] 「${from}」was ${reason} — this answer came from another account\n\n`;
1761
+ return `\n\n> [dsh-connect-workbuddy] 「${from}」${reason},已改用池中另一个账号回答\n\n`;
1753
1762
  }
1754
1763
  /** One frame carrying replacement text for a frame whose content was consumed. */
1755
1764
  function contentFrame(chunk, choice, content) {
@@ -1774,6 +1783,7 @@ function createWorkBuddyShim(options) {
1774
1783
  const { store, client, catalog } = options;
1775
1784
  const logger = options.logger;
1776
1785
  const failoverAccount = options.failoverAccount;
1786
+ const failoverShortfall = options.failoverShortfall;
1777
1787
  const prepareAccount = options.prepareAccount;
1778
1788
  const onAccountFailure = options.onAccountFailure;
1779
1789
  const onRequestBody = options.onRequestBody;
@@ -1971,7 +1981,9 @@ function createWorkBuddyShim(options) {
1971
1981
  reportFailures();
1972
1982
  if (!result.ok) {
1973
1983
  const attempts = triedAccountIds.length;
1974
- const note = attempts > 1 ? ` (after trying ${attempts} accounts)` : "";
1984
+ const note = attempts > 1 ? `(已尝试 ${attempts} 个账号)` : "";
1985
+ const shortfall = failoverShortfall === void 0 ? void 0 : await failoverShortfall().catch(() => void 0);
1986
+ const why = shortfall === void 0 ? "" : `:${shortfall}`;
1975
1987
  if (result.kind === "policy_reject") {
1976
1988
  const detail = result.detail;
1977
1989
  const lead = detail?.displayMsg ?? result.message.slice(0, 200);
@@ -1992,7 +2004,7 @@ function createWorkBuddyShim(options) {
1992
2004
  writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, message, hostErrorCode(result.kind));
1993
2005
  return;
1994
2006
  }
1995
- writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind} (http ${result.status})${note}: ${result.message.slice(0, 400)}`, hostErrorCode(result.kind));
2007
+ writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy 上游失败:${failoverReasonText(result)}(http ${result.status})${note}${why}。${result.message.slice(0, 400)}` + (failoverShortfall === void 0 ? "" : "。池中没有其他可用账号,建议换一个模型继续,或稍后等待该账号的额度恢复"), hostErrorCode(result.kind));
1996
2008
  return;
1997
2009
  }
1998
2010
  res.writeHead(200, {
@@ -2448,25 +2460,54 @@ function parseRetryAfter(value, nowMs) {
2448
2460
  return parsed;
2449
2461
  }
2450
2462
  /**
2451
- * The upstream's reset sentence, as its Chinese gateway writes it.
2463
+ * The `<timestamp> UTC±N` core of the upstream's reset sentence.
2464
+ *
2465
+ * Shared by both spellings below so the two can never drift into accepting
2466
+ * different timestamps: the offset handling and the calendar round-trip check in
2467
+ * {@link parseUpstreamResetAt} are what make a parsed value trustworthy, and two
2468
+ * copies of this shape would be two chances to loosen one of them.
2469
+ */
2470
+ const RESET_STAMP = String.raw`(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})\s*UTC\s*([+-])(\d{1,2})(?::?(\d{2}))?`;
2471
+ /**
2472
+ * The upstream's reset sentence, in BOTH spellings this service uses.
2452
2473
  *
2453
- * Measured on a live 429 (code 6004), verbatim:
2474
+ * Chinese gateway, measured on a live 429 (code 6004), verbatim:
2454
2475
  *
2455
2476
  * `您的使用量已超出频率限制,将在 2026-09-30 02:30:30 UTC+8 重置,您也可以切换其他模型继续使用。`
2456
2477
  *
2457
- * The two halves are captured separately because the second one is the whole
2458
- * reason this parser exists: the upstream DOES name a time, and the plugin was
2459
- * showing "the upstream gave no time" while the answer was sitting right there
2460
- * in the body. The earlier note in this module ("the upstream provides no
2461
- * rate-limit metadata") was drawn from the RESPONSE HEADERS alone — true as far
2462
- * as it went, and wrong as a conclusion, because the time is in the body text.
2478
+ * International gateway, measured on the same code over the same account pool,
2479
+ * verbatim:
2480
+ *
2481
+ * `usage exceeds frequency limit, but don't worry, your usage will reset at
2482
+ * 2026-10-16 11:34:04 UTC+8, alternatively, you can switch to the other
2483
+ * models to continue using it.`
2463
2484
  *
2464
- * Deliberately loose about the surrounding wording (`[\s\S]*?` on both sides)
2465
- * and strict about the parts that must not be guessed: the keyword 重置, the
2466
- * timestamp shape, and an explicit UTC offset. A message that merely mentions
2467
- * 重置 without a parsable offset yields no time, which is the honest answer.
2485
+ * WHY THERE ARE TWO. Each gateway states the time in its own language, and the
2486
+ * marker sits on a DIFFERENT SIDE of the timestamp: 重置 follows it in Chinese,
2487
+ * while `reset at` precedes it in English — so one pattern cannot cover both by
2488
+ * loosening the surrounding prose alone. The original pattern demanded the
2489
+ * Chinese keyword, so an English body parsed to nothing and the card said
2490
+ * 「上游未给出何时恢复」 about a message that had just named the exact minute.
2491
+ *
2492
+ * That is the honest-absence contract broken in the OTHER direction — not a
2493
+ * made-up time, but a real time discarded — and it is the worse of the two
2494
+ * failures, because the account stays excluded on the 30-minute fallback instead
2495
+ * of the ~18 hours upstream asked for. The pool then returns the account to
2496
+ * candidacy early and spends a request rediscovering the same 6004.
2497
+ *
2498
+ * Both patterns stay deliberately loose about the surrounding prose and strict
2499
+ * about the parts that must not be guessed: a keyword, the timestamp shape, and
2500
+ * an explicit UTC offset. A message that merely mentions a reset without a
2501
+ * parsable offset still yields no time, which remains the honest answer.
2468
2502
  */
2469
- const UPSTREAM_RESET_PATTERN = /将在?\s*(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})\s*UTC\s*([+-])(\d{1,2})(?::?(\d{2}))?[\s\S]*?重置/u;
2503
+ const UPSTREAM_RESET_PATTERN_ZH = new RegExp(`将在?\\s*${RESET_STAMP}[\\s\\S]*?重置`, "u");
2504
+ /**
2505
+ * The English spelling, anchored on `reset at` / `refresh on` IMMEDIATELY before
2506
+ * the timestamp — the same strictness as the Chinese pattern, which requires its
2507
+ * marker immediately after it. A message that mentions a reset somewhere else
2508
+ * entirely therefore still yields no time.
2509
+ */
2510
+ const UPSTREAM_RESET_PATTERN_EN = new RegExp(`(?:reset|resets|refresh|refreshes)\\s+(?:at|on)\\s+${RESET_STAMP}`, "iu");
2470
2511
  /**
2471
2512
  * Parse the reset time out of an upstream failure message.
2472
2513
  *
@@ -2484,7 +2525,7 @@ const UPSTREAM_RESET_PATTERN = /将在?\s*(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d
2484
2525
  * would be fiction in the other direction.
2485
2526
  */
2486
2527
  function parseUpstreamResetAt(message) {
2487
- const match = UPSTREAM_RESET_PATTERN.exec(message);
2528
+ const match = UPSTREAM_RESET_PATTERN_ZH.exec(message) ?? UPSTREAM_RESET_PATTERN_EN.exec(message);
2488
2529
  if (match === null) return void 0;
2489
2530
  const [, year, month, day, hour, minute, second, sign, offsetHours, offsetMinutes] = match;
2490
2531
  const offsetTotalMinutes = (sign === "-" ? -1 : 1) * (Number(offsetHours) * 60 + Number(offsetMinutes ?? "0"));
@@ -2752,22 +2793,7 @@ async function testAllAccounts(targets, modelId, deps, onRow) {
2752
2793
  const rows = [];
2753
2794
  for (const [index, target] of targets.entries()) {
2754
2795
  if (index > 0) await (deps.wait ?? defaultWait)(400);
2755
- const wanted = typeof modelId === "function" ? modelId(target.accountId) : { modelId };
2756
- if (wanted.modelId === void 0) {
2757
- const row = {
2758
- accountId: target.accountId,
2759
- accountName: target.accountName,
2760
- result: {
2761
- modelId: wanted.staleModelId ?? "",
2762
- outcome: "unavailable",
2763
- message: wanted.staleModelId === void 0 ? "this region offers no model to test" : `the test model "${wanted.staleModelId}" is no longer offered in this region`
2764
- }
2765
- };
2766
- rows.push(row);
2767
- onRow?.(row);
2768
- continue;
2769
- }
2770
- const row = await testOne(target, wanted.modelId, deps);
2796
+ const row = await testOne(target, modelId, deps);
2771
2797
  rows.push(row);
2772
2798
  onRow?.(row);
2773
2799
  }
@@ -3074,30 +3100,6 @@ function pickFreeModel(catalog) {
3074
3100
  * That is the safe direction: no credits are spent while the roster is unknown,
3075
3101
  * and the card offers to switch back to automatic.
3076
3102
  */
3077
- /**
3078
- * The model ONE account's test should target (issue #61).
3079
- *
3080
- * The region-wide `targetModelId` is one value, but `6004` is an ACCOUNT × MODEL
3081
- * condition: the reporter measured two accounts where `hy4-preview-f` answered
3082
- * `ok` and `step-5-preview` returned `429 / 6004` at the same moment. Testing
3083
- * only the region's model therefore cannot answer "can this account serve the
3084
- * model I actually use", which is the question the pool's test button is read as
3085
- * answering.
3086
- *
3087
- * A per-account override is the smallest thing that makes the question
3088
- * answerable, and it is additive: an account with no entry keeps using
3089
- * `targetModelId`, so every existing profile behaves identically.
3090
- *
3091
- * Returns a PREFERENCE, not a resolved target — `resolveTargetModel` still
3092
- * validates it against the catalog, so an override pointing at a model this
3093
- * region no longer offers is reported as stale rather than silently swapped for
3094
- * one the user did not choose.
3095
- */
3096
- function targetModelPreference(preferences, accountId) {
3097
- const perAccount = accountId === void 0 ? void 0 : preferences.targetModelIdByAccount?.[accountId];
3098
- if (perAccount !== void 0 && perAccount !== "") return perAccount;
3099
- return preferences.targetModelId;
3100
- }
3101
3103
  function resolveTargetModel(catalog, preferredId) {
3102
3104
  if (preferredId !== void 0 && preferredId !== "") return catalog.some((model) => model.id === preferredId) ? {
3103
3105
  modelId: preferredId,
@@ -3606,7 +3608,7 @@ const POOL_STORE_PREFIX = ".workbuddy-pool";
3606
3608
  function isProbe(value) {
3607
3609
  if (typeof value !== "object" || value === null) return false;
3608
3610
  const candidate = value;
3609
- return typeof candidate["outcome"] === "string" && typeof candidate["atMs"] === "number" && (candidate["retryAtMs"] === void 0 || typeof candidate["retryAtMs"] === "number") && (candidate["message"] === void 0 || typeof candidate["message"] === "string") && (candidate["accountName"] === void 0 || typeof candidate["accountName"] === "string") && (candidate["source"] === void 0 || typeof candidate["source"] === "string");
3611
+ return typeof candidate["outcome"] === "string" && typeof candidate["atMs"] === "number" && (candidate["retryAtMs"] === void 0 || typeof candidate["retryAtMs"] === "number") && (candidate["message"] === void 0 || typeof candidate["message"] === "string") && (candidate["accountName"] === void 0 || typeof candidate["accountName"] === "string") && (candidate["modelId"] === void 0 || typeof candidate["modelId"] === "string") && (candidate["source"] === void 0 || typeof candidate["source"] === "string");
3610
3612
  }
3611
3613
  /**
3612
3614
  * One region's path for measured pool facts.
@@ -3618,6 +3620,49 @@ function workbuddyPoolStorePath(region) {
3618
3620
  return join(resolveDshHome(), `${POOL_STORE_PREFIX}.${region}.json`);
3619
3621
  }
3620
3622
  /**
3623
+ * The recovery time a stored record implies — declared, or recoverable from its
3624
+ * own body.
3625
+ *
3626
+ * The field itself wins when present: it is what the writer computed with the
3627
+ * full input set (`Retry-After` header, quota-refresh point, body), and
3628
+ * re-deriving it could only lose information.
3629
+ *
3630
+ * The fallback exists because a record can state a time in its `message` and
3631
+ * still have no `retryAtMs`. That is not a hypothetical — it is what the English
3632
+ * `6004` body produced, since the parser that ran at write time matched only the
3633
+ * Chinese 「… 重置」 sentence:
3634
+ *
3635
+ * `{"code":6004,"msg":"usage exceeds frequency limit, but don't worry, your
3636
+ * usage will reset at 2026-10-16 11:34:04 UTC+8, …"}`
3637
+ *
3638
+ * leaving a record that names the exact minute it will serve again while
3639
+ * `retryDue` falls back to {@link POOL_UNKNOWN_COOLDOWN_MS}. The row then told the
3640
+ * user 「上游未给出何时恢复」 about a body that had just said when, and the pool
3641
+ * put the account back ~18 hours early to rediscover the same limit.
3642
+ *
3643
+ * Read-time healing is the only place this can be repaired for EXISTING records:
3644
+ * every consumer receives probes through {@link readPoolProbes} —
3645
+ * `accountExclusions` (the composer panel), `poolMembersOf` /
3646
+ * `localPoolMembers` (the card AND failover routing), `memberNames` — so healing
3647
+ * here fixes all of them at once. Re-deriving at each consumer instead would be
3648
+ * three copies of one rule, and the next consumer added would silently miss it.
3649
+ *
3650
+ * Not a permanent second derivation: `writePoolProbes` reads through this
3651
+ * function before it writes, so the very next write persists the healed value.
3652
+ *
3653
+ * Scoped to the two outcomes whose vocabulary HAS a stated time, matching
3654
+ * {@link WorkBuddyPoolProbe.retryAtMs}. Widening it would let an incidental
3655
+ * sentence in an `ok` body attach a countdown to a healthy account —
3656
+ * `AccountPool.tsx` prints any present `retryAtMs` beside the outcome, so that
3657
+ * would render a waiting time on an account that is not waiting for anything.
3658
+ */
3659
+ function retryAtMsOf(probe) {
3660
+ if (probe.retryAtMs !== void 0) return probe.retryAtMs;
3661
+ if (probe.outcome !== "rate-limited" && probe.outcome !== "out-of-credit") return void 0;
3662
+ if (probe.message === void 0) return void 0;
3663
+ return parseUpstreamResetAt(probe.message);
3664
+ }
3665
+ /**
3621
3666
  * Read one region's measured facts.
3622
3667
  *
3623
3668
  * Contains NO credentials. The one free-text field is `message`, and it is
@@ -3650,13 +3695,15 @@ async function readPoolProbes(region) {
3650
3695
  const probes = {};
3651
3696
  for (const [accountId, probe] of Object.entries(document.probes)) {
3652
3697
  if (accountId === "" || !isProbe(probe)) continue;
3698
+ const retryAtMs = retryAtMsOf(probe);
3653
3699
  probes[accountId] = {
3654
3700
  outcome: probe.outcome,
3655
3701
  atMs: probe.atMs,
3656
3702
  ...knownProbeSource(probe.source) === void 0 ? {} : { source: knownProbeSource(probe.source) },
3657
- ...probe.retryAtMs === void 0 ? {} : { retryAtMs: probe.retryAtMs },
3703
+ ...retryAtMs === void 0 ? {} : { retryAtMs },
3658
3704
  ...probe.message === void 0 ? {} : { message: probe.message },
3659
- ...probe.accountName === void 0 ? {} : { accountName: probe.accountName }
3705
+ ...probe.accountName === void 0 ? {} : { accountName: probe.accountName },
3706
+ ...probe.modelId === void 0 ? {} : { modelId: probe.modelId }
3660
3707
  };
3661
3708
  }
3662
3709
  return probes;
@@ -4349,7 +4396,7 @@ async function workBuddyWebPool(deps, region) {
4349
4396
  ...measured.probe.modelId === void 0 ? {} : { modelId: measured.probe.modelId }
4350
4397
  } },
4351
4398
  ...(() => {
4352
- const perRow = resolveTargetModel(catalog, targetModelPreference(preferences, account.id));
4399
+ const perRow = resolveTargetModel(catalog, preferences.targetModelId);
4353
4400
  return {
4354
4401
  ...perRow.modelId === void 0 ? {} : { testModelId: perRow.modelId },
4355
4402
  testModelSource: perRow.source,
@@ -4368,7 +4415,6 @@ async function workBuddyWebPool(deps, region) {
4368
4415
  ...target.modelId === void 0 ? {} : { targetModelId: target.modelId },
4369
4416
  ...target.staleModelId === void 0 ? {} : { staleTargetModelId: target.staleModelId },
4370
4417
  targetModelSource: target.source,
4371
- targetModelIdByAccount: preferences.targetModelIdByAccount ?? {},
4372
4418
  memberAccountIds: preferences.memberAccountIds,
4373
4419
  ...preferences.probeInputTokens === void 0 ? {} : { probeInputTokens: preferences.probeInputTokens },
4374
4420
  effectiveMemberAccountIds: effectiveMembersOf(accounts.map((account) => account.accountId), new Set(preferences.memberAccountIds)),
@@ -4613,19 +4659,17 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
4613
4659
  error: "pool test unavailable"
4614
4660
  });
4615
4661
  const preferences = pool.preferences(region);
4616
- const catalog = pool.catalog?.(region) ?? [];
4617
4662
  /**
4618
4663
  * The model ONE member is tested on (issue #61).
4619
4664
  *
4620
- * Per account, because a test model is a per-account choice: `6004` is
4621
- * an account × model condition, so a batch that tested every member on
4622
- * one model would report "可用" for an account whose own model is the
4623
- * one that is limited. Resolution still runs through
4624
- * `resolveTargetModel`, so a stale override is reported, never
4625
- * silently swapped.
4665
+ * ONE model for the whole batch, and it is also the gate: if the region
4666
+ * cannot name a target model the card is told to pick one before any
4667
+ * credits are spent. Every member is therefore measured on the SAME
4668
+ * model, which is what makes a green batch mean one thing. Resolution
4669
+ * runs through `resolveTargetModel`, so a stale target is reported,
4670
+ * never silently swapped for another model.
4626
4671
  */
4627
- const targetFor = (accountId) => resolveTargetModel(catalog, targetModelPreference(preferences, accountId));
4628
- const target = resolveTargetModel(catalog, targetModelPreference(preferences, void 0));
4672
+ const target = resolveTargetModel(pool.catalog?.(region) ?? [], preferences.targetModelId);
4629
4673
  if (target.modelId === void 0) return json(res, 409, {
4630
4674
  action: "test",
4631
4675
  modelId: void 0,
@@ -4647,7 +4691,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
4647
4691
  modelId: target.modelId
4648
4692
  });
4649
4693
  try {
4650
- await pool.test(region, targetFor, (row) => {
4694
+ await pool.test(region, target.modelId, (row) => {
4651
4695
  writeLine({ row });
4652
4696
  });
4653
4697
  writeLine({ done: true });
@@ -4985,7 +5029,6 @@ const modelConfig = z.object({
4985
5029
  const poolConfig = z.object({
4986
5030
  enabled: z.boolean().default(false).description("Whether this region's account pool is active (opt-in). While on, a failed chat request is retried against the other usable pool members before the error is reported."),
4987
5031
  targetModelId: z.string().default("").description("Model id to test; empty means pick a zero-multiplier model from this region's catalog"),
4988
- targetModelIdByAccount: z.dict(z.string()).default({}).description("Per-account test model, keyed by account id; an account with no entry uses targetModelId. Use this when an account must be tested on the model you actually chat with, because 6004 is an account-by-model condition."),
4989
5032
  /**
4990
5033
  * Sizing of the test probe, as a token count the user picks from a fixed set.
4991
5034
  *
@@ -5249,6 +5292,7 @@ function apply(ctx, config) {
5249
5292
  },
5250
5293
  prepareAccount: () => applyPoolSelection(region),
5251
5294
  failoverAccount: (triedAccountIds) => failoverAccountFor(region, triedAccountIds),
5295
+ failoverShortfall: () => explainPoolShortfall(region),
5252
5296
  onAccountFailure: (accountId, failure) => {
5253
5297
  recordAccountFailure(region, accountId, failure);
5254
5298
  }
@@ -5587,7 +5631,7 @@ function apply(ctx, config) {
5587
5631
  * rather than pretending a test ran.
5588
5632
  */
5589
5633
  testOne: async (region, accountId) => {
5590
- const target = regionTargetModel(region, accountId);
5634
+ const target = regionTargetModel(region);
5591
5635
  if (target === void 0) return void 0;
5592
5636
  const store = stacks[region].store;
5593
5637
  const credential = await store.credentialFor(accountId).catch(() => void 0);
@@ -5762,6 +5806,57 @@ function apply(ctx, config) {
5762
5806
  }
5763
5807
  };
5764
5808
  /**
5809
+ * Why the pool left a failed request with nowhere to go, in one short clause.
5810
+ *
5811
+ * Runs ONLY after a request has already failed, so it may afford work the hot
5812
+ * path cannot — but it still reads local state only (the probe store and the
5813
+ * member list), because spending a network call to explain a failure would add
5814
+ * a second delay to a request the user is already waiting on.
5815
+ *
5816
+ * It counts rather than lists. The reply is one line inside an error message,
5817
+ * and enumerating accounts there would bury the advice; the card is where a
5818
+ * per-account breakdown belongs, and it already shows one.
5819
+ *
5820
+ * Returns `undefined` when the pool is OFF or has no members — in which case
5821
+ * there is no pool story to tell, and the shim renders its own sentence
5822
+ * without one. That is a real state, not a failure: the plugin supports
5823
+ * running with no pool at all.
5824
+ */
5825
+ const explainPoolShortfall = async (region) => {
5826
+ if (!poolPreferencesOf(current(), region).enabled) return void 0;
5827
+ const members = await localPoolMembers(region);
5828
+ if (members.length === 0) return void 0;
5829
+ const nowMs = Date.now();
5830
+ const excluded = members.map((member) => ({
5831
+ id: member.account.id,
5832
+ excludedBy: exclusionOf(member.probe, nowMs)
5833
+ })).filter((entry) => entry.excludedBy !== void 0);
5834
+ if (excluded.length === 0) return "池中其余账号本次请求都已尝试过";
5835
+ const counts = /* @__PURE__ */ new Map();
5836
+ for (const entry of excluded) counts.set(entry.excludedBy, (counts.get(entry.excludedBy) ?? 0) + 1);
5837
+ return `池中已无可用账号(${[...counts.entries()].map(([reason, count]) => `${String(count)} 个${poolExclusionClause(reason)}`).join("、")})`;
5838
+ };
5839
+ /**
5840
+ * One exclusion reason as a short Chinese clause for
5841
+ * {@link explainPoolShortfall}.
5842
+ *
5843
+ * Separate from the card's own labels (`exclusion-text.ts` in the client half)
5844
+ * on purpose: those are standalone table cells ("被限流"), where these are
5845
+ * glued into a sentence and must read after a count ("1 个账号被限流"). Sharing
5846
+ * one string would force one of the two to read wrong.
5847
+ */
5848
+ function poolExclusionClause(reason) {
5849
+ switch (reason) {
5850
+ case "rate-limited": return "账号被限流";
5851
+ case "out-of-credit": return "账号积分耗尽";
5852
+ case "model-busy": return "账号撞上模型繁忙";
5853
+ case "credential-rejected": return "账号登录已失效";
5854
+ case "not-found": return "账号已不存在";
5855
+ case "unusable": return "账号不可用";
5856
+ default: return "账号不可用";
5857
+ }
5858
+ }
5859
+ /**
5765
5860
  * The last CREDITS reading per region, so a request can rank by balance
5766
5861
  * without fetching one.
5767
5862
  *
@@ -5957,8 +6052,8 @@ function apply(ctx, config) {
5957
6052
  * issue #61), so a row test and a batch test never measure different models.
5958
6053
  * Undefined mirrors the route's 409 states.
5959
6054
  */
5960
- const regionTargetModel = (region, accountId) => {
5961
- return resolveTargetModel(displayModels(current(), region), targetModelPreference(poolPreferencesOf(current(), region), accountId)).modelId;
6055
+ const regionTargetModel = (region) => {
6056
+ return resolveTargetModel(displayModels(current(), region), poolPreferencesOf(current(), region).targetModelId).modelId;
5962
6057
  };
5963
6058
  /** One region's pool preferences, with the schema's defaults applied. */
5964
6059
  /**
@@ -5975,7 +6070,6 @@ function apply(ctx, config) {
5975
6070
  return {
5976
6071
  enabled: pool?.enabled === true,
5977
6072
  targetModelId: pool?.targetModelId ?? "",
5978
- targetModelIdByAccount: { ...pool?.targetModelIdByAccount },
5979
6073
  ...probeInputTokens === void 0 ? {} : { probeInputTokens },
5980
6074
  memberAccountIds: pool?.memberAccountIds ?? []
5981
6075
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
5
- "version": "4.5.1",
5
+ "version": "4.6.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",