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.
- package/CHANGELOG.md +96 -0
- package/docs/assets/quota-notice-inline-mock.html +96 -0
- package/docs/assets/quota-notice-inline-mock.png +0 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +436 -158
- package/lib/{host-heartbeat-Vd2tNdsv.js → host-heartbeat-BxBvvAZq.js} +1 -1
- package/lib/index.d.ts +31 -12
- package/lib/index.js +182 -88
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
-
*
|
|
3055
|
-
*
|
|
3056
|
-
*
|
|
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
|
|
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-
|
|
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: "
|
|
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 "
|
|
1731
|
+
if (failure.ok) return "不可用";
|
|
1727
1732
|
switch (failure.kind) {
|
|
1728
|
-
case "soft_rate": return "
|
|
1729
|
-
case "hard_credit": return "
|
|
1730
|
-
case "model_busy": return "
|
|
1731
|
-
case "session_dead": return "
|
|
1732
|
-
case "policy_reject": return "
|
|
1733
|
-
case "not_found": return "
|
|
1734
|
-
case "server": return "
|
|
1735
|
-
default: return
|
|
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}
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2458
|
-
*
|
|
2459
|
-
*
|
|
2460
|
-
*
|
|
2461
|
-
*
|
|
2462
|
-
*
|
|
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
|
-
*
|
|
2465
|
-
*
|
|
2466
|
-
*
|
|
2467
|
-
*
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
...
|
|
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,
|
|
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
|
-
*
|
|
4621
|
-
*
|
|
4622
|
-
*
|
|
4623
|
-
*
|
|
4624
|
-
* `resolveTargetModel`, so a stale
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
5961
|
-
return resolveTargetModel(displayModels(current(), region),
|
|
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
|
+
"version": "4.6.0",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|