dsh-connect-workbuddy 3.4.0 → 3.4.2

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 CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.4.2 (2026-10-03)
4
+
5
+ ### Fixes
6
+
7
+ - **换号重试之间补上 2 秒间隔,不再零延迟连发。**
8
+ - **现象**:一次请求撞上限流后,换号循环会**零间隔**地把池里剩下的账号挨个试一遍——实测 4 个账号在 **35 秒内全部失败**(最后两次只隔 1 秒),而它们全部撞的是同一个 429。
9
+ - **根因**:上游的限流(业务码 6004)**按请求体积/频率触发**,零间隔连发等于自己把每个候选都推到同一堵墙上。批量测试早就为此在账号之间等 `POOL_BATCH_GAP_MS`(400ms),**换号循环却什么都没有**——两者的差别没有理由。
10
+ - **修法**:每次换号前等待 `FAILOVER_ACCOUNT_GAP_MS`(2 秒)。比批测的 400ms 更长,因为换号重试是**用户在等的真实请求**,不是探测——它需要给上游的窗口留出喘息,而不只是避免自己踩到限流。
11
+ - **等待是可中断的**:客户端断开时立刻结束等待并退出循环,不会白等 2 秒,也不会拿一个已经作废的 signal 去把剩余账号试完。
12
+ - 顺带修正:等待结束后重新检查一次中断状态——否则客户端已断开时,剩余账号会被逐个发出去(每个都立即因 signal 作废而失败),白白写下一串假的账号失败测量。
13
+ - 测试:新增一条用例钉住「两次尝试之间确实等待了 ≥1.9 秒」,变异验证(把间隔改成 0)必红。
14
+
15
+ ## 3.4.1 (2026-10-02)
16
+
17
+ ### Fixes
18
+
19
+ - **「要等几小时才恢复」的配额错误不再被宿主当成「等两秒就好」的瞬时抖动(issue #28)。**
20
+ - **现象**:上游 429 / code 6004 的原文是「您的使用量已超出频率限制,**将在 13:37:03 重置**」——要等几小时。本插件把它正确分类成 `soft_rate`(`src/upstream.ts`),但写回宿主时 `type` 与 `code` 都用了 `soft_rate` 这个内部名字。
21
+ - **根因**:宿主决定「这个失败要不要在原地重试」靠的是从 `type + code + message` 里匹配**几个英文措辞**(`@deepseek-ai/dsh-llm` 的 `isQuotaExceededError`)。`soft_rate` 不在那份名单里,上游原话又是中文,于是宿主只能按 `429` 的默认含义理解——进入 `RATE_LIMIT`,而 `RATE_LIMIT` **在**宿主的重试集合里(`DEFAULT_RETRYABLE_CODES`),`QUOTA` **不在**。宿主于是原地重试 5 次、指数退避,把预算烧光;而真正能救场的跨供应商故障转移(`dsh-subagent-default-model` 的 `failoverEnabled`)根本没被叫到——实测一次会话 1228 个 step、只切换过 1 次供应商、以 429 收场,`models` 里其它供应商全程闲置。
22
+ - **修法**:只翻译写回宿主的 `code`——`soft_rate` → `quota_exceeded`(宿主认得这个词,且它符合上游实际在说的事)。**HTTP 状态码仍是 429**(那才是上游返回的东西,改成 402 是谎报),上游原话仍原样留在 `message` 里给人看。`type` 保持插件自己的内部名字,便于读日志与报错。
23
+ - **只翻译这一处**:其余 kind 本来就落在重试集合之外(`hard_credit` 402 → 按状态就是 QUOTA,`session_dead` 401 → AUTH,502/400 → OTHER),所以**不做多余翻译**——没有人需要的翻译只是等着漂移的谎言。插件**自己的**错误(`unauthorized` / `not_found` / `host_not_allowed` / `internal`)也一律不翻译:把自身的 401/404 说成「配额用尽」,会让宿主去为一个换供应商也治不好的错误做故障转移。
24
+ - 测试直接调用**宿主自己的** `isQuotaExceededError`(`@deepseek-ai/dsh-llm` 导出)来钉住这件事,而不是在测试里抄一份正则——抄一份只能证明两份副本今天一致。三次变异验证:撤回翻译 / 把翻译扩大到所有 kind / 让本地错误也翻译,均会变红。
25
+
3
26
  ## 3.4.0 (2026-10-02)
4
27
 
5
28
  ### Fixes
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { E as legacyWorkbuddyOwnAuthPath, H as WORKBUDDY_APP_EXECUTABLE_ENV, N as WorkBuddyUpstreamClient, S as defaultDesktopAuthDirs, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, q as findWorkbuddyAppExecutable, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, x as defaultDesktopAuthCandidates } from "./host-heartbeat-C2_Zmuqq.js";
2
+ import { E as legacyWorkbuddyOwnAuthPath, H as WORKBUDDY_APP_EXECUTABLE_ENV, N as WorkBuddyUpstreamClient, S as defaultDesktopAuthDirs, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, q as findWorkbuddyAppExecutable, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, x as defaultDesktopAuthCandidates } from "./host-heartbeat-Sq5JwluI.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
@@ -2552,7 +2552,7 @@ function generationOf(version) {
2552
2552
  //#endregion
2553
2553
  //#region src/version.ts
2554
2554
  /** The npm package version this build was produced from. */
2555
- const WORKBUDDY_CONNECT_VERSION = "3.4.0";
2555
+ const WORKBUDDY_CONNECT_VERSION = "3.4.2";
2556
2556
  //#endregion
2557
2557
  //#region src/host-heartbeat.ts
2558
2558
  /**
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as readAtRestKey, A as CREDENTIAL_REJECTED_CODE, B as prepareChatBody, C as defaultDesktopAuthPath, D as parseWorkBuddyAuth, E as legacyWorkbuddyOwnAuthPath, F as declaredTools, G as deriveAtRestKeyId, H as WORKBUDDY_APP_EXECUTABLE_ENV, I as isCredentialRejectedError, J as isEncryptedFieldWrapper, K as fetchAtRestKeyPayload, L as parseCreditMultiplier, M as WorkBuddyCredentialRejectedError, N as WorkBuddyUpstreamClient, O as workbuddyAccountId, P as classifyUpstreamError, Q as openEncryptedField, R as parseReasoning, S as defaultDesktopAuthDirs, T as isEncryptedCredentialError, U as clearAtRestKeyCache, V as regionOf, W as deriveAtRestKey, X as macosBundleExecutable, Y as isWorkbuddyBundle, Z as macosNestedAppBundles, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, b as authFileName, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as workbuddyAppExecutableCandidates, f as WorkBuddyCatalog, g as WORKBUDDY_AUTH_FILENAME, h as ENCRYPTED_CREDENTIAL_CODE, i as processStartTimeMs, j as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, m as fallbackModelsFor, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as deriveCatalog, q as findWorkbuddyAppExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, w as hasEncryptedCredentialFields, x as defaultDesktopAuthCandidates, y as WorkBuddyEncryptedCredentialError, z as parseUpstreamModel } from "./host-heartbeat-C2_Zmuqq.js";
1
+ import { $ as readAtRestKey, A as CREDENTIAL_REJECTED_CODE, B as prepareChatBody, C as defaultDesktopAuthPath, D as parseWorkBuddyAuth, E as legacyWorkbuddyOwnAuthPath, F as declaredTools, G as deriveAtRestKeyId, H as WORKBUDDY_APP_EXECUTABLE_ENV, I as isCredentialRejectedError, J as isEncryptedFieldWrapper, K as fetchAtRestKeyPayload, L as parseCreditMultiplier, M as WorkBuddyCredentialRejectedError, N as WorkBuddyUpstreamClient, O as workbuddyAccountId, P as classifyUpstreamError, Q as openEncryptedField, R as parseReasoning, S as defaultDesktopAuthDirs, T as isEncryptedCredentialError, U as clearAtRestKeyCache, V as regionOf, W as deriveAtRestKey, X as macosBundleExecutable, Y as isWorkbuddyBundle, Z as macosNestedAppBundles, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, b as authFileName, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as workbuddyAppExecutableCandidates, f as WorkBuddyCatalog, g as WORKBUDDY_AUTH_FILENAME, h as ENCRYPTED_CREDENTIAL_CODE, i as processStartTimeMs, j as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, m as fallbackModelsFor, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as deriveCatalog, q as findWorkbuddyAppExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, w as hasEncryptedCredentialFields, x as defaultDesktopAuthCandidates, y as WorkBuddyEncryptedCredentialError, z as parseUpstreamModel } from "./host-heartbeat-Sq5JwluI.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -1118,6 +1118,19 @@ function finish(parts, calls) {
1118
1118
  * @module dsh-connect-workbuddy/shim
1119
1119
  */
1120
1120
  const REQUEST_BODY_LIMIT = 67108864;
1121
+ /**
1122
+ * Pause between failover attempts, after one account's request failed.
1123
+ *
1124
+ * The upstream's rate limit (6004) fires on request volume — firing retries at
1125
+ * zero gap makes every candidate hit the same wall, and 4 accounts can all fail
1126
+ * in under a second. The batch test already waits `POOL_BATCH_GAP_MS` (400ms)
1127
+ * between accounts for exactly this reason; the failover loop used to wait
1128
+ * nothing. 2 seconds is longer than the batch's 400 because a failover retry is
1129
+ * a real request the user is waiting on, not a probe — it needs enough room for
1130
+ * the upstream's window to breathe, not just enough to avoid self-inflicted
1131
+ * rate-limiting.
1132
+ */
1133
+ const FAILOVER_ACCOUNT_GAP_MS = 2e3;
1121
1134
  /** Loopback hostnames the shim's own in-process client uses. */
1122
1135
  const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
1123
1136
  "127.0.0.1",
@@ -1171,6 +1184,46 @@ const KIND_STATUS = {
1171
1184
  server: 502,
1172
1185
  client: 400
1173
1186
  };
1187
+ /**
1188
+ * The `code` spelling the HOST is expected to classify each upstream failure by.
1189
+ *
1190
+ * The host decides whether a failed request may be retried IN PLACE (5 attempts
1191
+ * with backoff) or must be handed to cross-provider failover by reading English
1192
+ * phrases out of `type + " " + code + " " + message` — see
1193
+ * `isQuotaExceededError` in `@deepseek-ai/dsh-llm`. Only `RATE_LIMIT`, `SERVER`,
1194
+ * `TIMEOUT`, `TRANSPORT` and `EMPTY_RESPONSE` are in its retry set, so the
1195
+ * spelling decides which of two very different things happens next.
1196
+ *
1197
+ * `soft_rate` is the one kind that spelling got wrong. The upstream's 429 says
1198
+ * "usage exceeds the frequency limit, and it resets at 13:37" — hours away, not
1199
+ * two seconds. Sent as `soft_rate` it reads as an ordinary rate limit, enters
1200
+ * the in-place retry set, and spends the whole retry budget on an endpoint that
1201
+ * cannot recover within it; by the time cross-provider failover is offered, the
1202
+ * turn has already failed. The upstream's own words are Chinese, so the
1203
+ * phrase-based classifier cannot see the distinction either — the label is the
1204
+ * only channel left to carry it.
1205
+ *
1206
+ * `quota_exceeded` is the minimal honest translation: it is the WORDS the host
1207
+ * recognises, and it matches what the upstream is actually saying. The HTTP
1208
+ * status stays 429 (that is what the upstream returned — relabelling it 402
1209
+ * would misreport the upstream), and the upstream's own message is still passed
1210
+ * through verbatim for a human to read.
1211
+ *
1212
+ * Every other kind already lands outside the retry set by its status alone
1213
+ * (`hard_credit` 402 → quota, `session_dead` 401 → auth, 502/400 → other), so
1214
+ * none of them is listed: a translation nobody needs is a lie waiting to drift.
1215
+ */
1216
+ const KIND_HOST_CODE = { soft_rate: "quota_exceeded" };
1217
+ /**
1218
+ * The `code` to write for an upstream failure, given its classified kind.
1219
+ *
1220
+ * `type` deliberately stays the plugin's own kind: it is what a human reads in
1221
+ * a log or a bug report, and it keeps the two fields individually meaningful.
1222
+ * Only `code` carries the host-facing translation.
1223
+ */
1224
+ function hostErrorCode(kind) {
1225
+ return KIND_HOST_CODE[kind] ?? kind;
1226
+ }
1174
1227
  function writeJson(res, status, body) {
1175
1228
  const payload = JSON.stringify(body);
1176
1229
  res.writeHead(status, {
@@ -1179,11 +1232,19 @@ function writeJson(res, status, body) {
1179
1232
  });
1180
1233
  res.end(payload);
1181
1234
  }
1182
- function writeOpenAIError(res, status, kind, message) {
1235
+ /**
1236
+ * Write a JSON error the way an OpenAI-compatible client expects.
1237
+ *
1238
+ * `code` defaults to `type`, which is right for every error THIS shim raises on
1239
+ * its own (`unauthorized`, `not_found`, …): those are not upstream failures and
1240
+ * must not be disguised as one. The upstream failure path passes an explicit
1241
+ * `code` so the host classifies it correctly — see {@link KIND_HOST_CODE}.
1242
+ */
1243
+ function writeOpenAIError(res, status, kind, message, code) {
1183
1244
  writeJson(res, status, { error: {
1184
1245
  message,
1185
1246
  type: kind,
1186
- code: kind
1247
+ code: code ?? kind
1187
1248
  } });
1188
1249
  }
1189
1250
  /** Read a request body with a size cap; over-limit bodies fail the request. */
@@ -1697,6 +1758,16 @@ function createWorkBuddyShim(options) {
1697
1758
  const next = await failoverAccount(triedAccountIds).catch(() => void 0);
1698
1759
  if (next === void 0) break;
1699
1760
  if (triedAccountIds.includes(workbuddyAccountId(next))) break;
1761
+ if (controller.signal.aborted) break;
1762
+ await new Promise((resolve) => {
1763
+ if (controller.signal.aborted) return resolve();
1764
+ const timer = setTimeout(resolve, FAILOVER_ACCOUNT_GAP_MS);
1765
+ controller.signal.addEventListener("abort", () => {
1766
+ clearTimeout(timer);
1767
+ resolve();
1768
+ }, { once: true });
1769
+ });
1770
+ if (controller.signal.aborted) break;
1700
1771
  logger?.warn(`dsh-connect-workbuddy: retrying chat on another pool account after ${result.kind} (http ${result.status})`);
1701
1772
  credential = next;
1702
1773
  result = await client.chatStream(credential, prepared, controller.signal);
@@ -1706,7 +1777,7 @@ function createWorkBuddyShim(options) {
1706
1777
  if (!result.ok) {
1707
1778
  const attempts = triedAccountIds.length;
1708
1779
  const note = attempts > 1 ? ` (after trying ${attempts} accounts)` : "";
1709
- writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind} (http ${result.status})${note}: ${result.message.slice(0, 400)}`);
1780
+ writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind} (http ${result.status})${note}: ${result.message.slice(0, 400)}`, hostErrorCode(result.kind));
1710
1781
  return;
1711
1782
  }
1712
1783
  res.writeHead(200, {
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": "3.4.0",
5
+ "version": "3.4.2",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",