dsh-workbuddy-xdpool 1.7.6 → 1.7.7

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
@@ -4,6 +4,83 @@
4
4
 
5
5
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
6
6
 
7
+ ## 1.7.7 — 别再让你为了一个「等一会儿」去重新登录
8
+
9
+ > 报错说「API 密钥无效」,于是你跑去重新登录 —— 但密钥根本没问题,只是那个模型暂时限流了。
10
+
11
+ ### 问题一:限流被说成了「密钥无效」
12
+
13
+ **你会看到**:国际版用 hy4 时,偶尔弹出「API 密钥无效」。你重新登录、换账号,都不一定管用。
14
+
15
+ **为什么**:看日志就清楚了(时间连着三行):
16
+
17
+ ```
18
+ 14:50:22 qingche526 rate-limited on hy4-preview-f → cooling + rotating
19
+ 14:50:23 aosi526 rate-limited on hy4-preview-f → cooling + rotating
20
+ 14:50:25 401: "no WorkBuddy credential found; sign in on the desktop app"
21
+ ```
22
+
23
+ 两个账号的 hy4-preview-f 都被限流**冷却**了(一小时),于是池子里暂时没有可用账号。可插件判断「有没有被限流」用的是**本次请求内**的临时记录 —— 上次请求造成的冷却,它看不见。于是它以为「一个账号都没有 → 你没登录」→ 报 **401**。
24
+
25
+ DSH 把 401 显示成「API 密钥无效」。**你被派去修一个根本没坏的东西。**
26
+
27
+ **怎么修的**:不再靠本次请求的临时记录猜,而是**直接问账号池「为什么现在没有可用账号」**,然后按真实原因报对应的错:
28
+
29
+ | 真实情况 | 以前 | 现在 |
30
+ |---|---|---|
31
+ | 模型限流中 | 401 密钥无效 | **429 该模型限流中**(等一会儿即可)|
32
+ | 账号全被关/移出 | 401 密钥无效 | **403 账号全部已关闭**(去卡片打开)|
33
+ | 积分低于保留线 | 401 密钥无效 | **402 积分不足** |
34
+ | 确实没登录 | 401 | 401(这个才是对的)|
35
+
36
+ 四种情况**需要完全相反的处理**,以前却都推给你「重新登录」。现在只有真的该重新登录时才这么说。
37
+
38
+ ### 问题二:上下文「压缩」把目标设得比原文还大
39
+
40
+ 日志里抓到的:
41
+
42
+ ```
43
+ context overrun on deepseek-v4.1-flash (~791793 tokens); compacting to ~797952
44
+ ↑ 原文 79.1 万 ↑ 目标 79.7 万
45
+ ```
46
+
47
+ **压缩目标比要压缩的东西还大** —— 等于什么都没压,重试当然又超限。而日志看起来还挺像"我处理过了"。
48
+
49
+ **为什么**:目标值是按**目录里写的窗口**算的(`deepseek-v4.1-flash` 标称 1M),取 80% → `797952`。但**标称 1M 是宣传值,不是真实生效上限** —— 否则它就不会被拒了。
50
+
51
+ **怎么修的**:**正在超限的那个请求本身就是证据**。既然它太大了,目标就必须明显小于它。现在取「按窗口算」和「按原文一半」的较小值:
52
+
53
+ ```
54
+ 原文 791793 → 目标 395896 (真正压掉一半)
55
+ ```
56
+
57
+ ### 顺便:这类报错终于有线索了
58
+
59
+ 上游的 `model_param_invalid` 里 **`param` 字段是空的** —— 它只说「参数不符合要求」,不说是哪个参数。你之前贴的那条 11133 就是这样,等于没有线索。
60
+
61
+ 现在遇到 400 时,日志会记下**请求的形状**(顶层参数名、各角色的消息条数、工具数量、内容字符数):
62
+
63
+ ```
64
+ rejected the request as invalid (model deepseek-v4.1-flash) —
65
+ keys=[max_tokens messages model reasoning_effort stream temperature tool_choice tools top_k top_p]
66
+ messages=122 (system:1 user:61 assistant:30 tool:30) tools=77 contentChars=481233
67
+ ```
68
+
69
+ **只有形状,没有内容**(对话是你的,不该进日志)。下次再遇到,日志里就能看出是哪个参数/哪个维度不对。
70
+
71
+ ### 我排查时的事
72
+
73
+ 我一开始想直接复现 11133,试了:采样参数(`top_k`/`repetition_penalty`/`temperature`/`top_p`)、推理档位(`minimal` 到 `max`,还试了无效档位)、各种消息形状(回放 `reasoning_content`、工具调用链)、消息数量(最多 362 条)、77 个工具 + 长会话、超长 prompt ——**国内版和国际版全部返回 200**。
74
+
75
+ **我没能复现它**,所以没有针对 11133 写"修复"(那会是猜测)。改的都是**从日志里坐实的**问题:那两个是确凿的。这个 11133 加上刚加的形状日志,下次出现时就能定位了。
76
+
77
+ ### 验证
78
+
79
+ - 类型检查全绿,测试 **325 项全绿**(+12)。
80
+ - 新增 `tests/pool-availability.test.ts`(7 项):池能正确区分「无人登录 / 模型限流 / 账号全关 / 积分保留 / 可用」、**限流时返回 429 且文案不含「sign in」**、真没登录时仍是 401、**不把另一个 region 的账号算进来**、过期冷却自动视为可用。
81
+ - 新增 `tests/context-budget-budget.test.ts`(5 项):**目录窗口远大于原文时目标必须更小**(用日志里那组真实数字)、窗口未知时减半、窗口小于原文时也不放大、目标不为零。
82
+ - **反向验证**:把 429 分支禁用、退回原来的 401 → 测试变红。
83
+
7
84
  ## 1.7.6 — 一个读不出来的凭据文件,不再带走整个账号池
8
85
 
9
86
  > 你切了 4 个账号登录,界面显示 4 个;重启之后只剩 2 个 —— 不是账号没了,是它们被一个文件拖累了。
package/lib/bin.js CHANGED
@@ -2846,6 +2846,70 @@ var WorkBuddyAccountPool = class {
2846
2846
  return true;
2847
2847
  });
2848
2848
  }
2849
+ /**
2850
+ * Why no account is available right now, for an accurate error.
2851
+ *
2852
+ * The pool can be empty for reasons that need OPPOSITE remedies: nobody is
2853
+ * signed in (the user must sign in), every account is rate-limited (the user
2854
+ * must wait, and retrying later works), or every account was switched off /
2855
+ * ignored (the user must re-enable one). Reporting all of them as "no
2856
+ * credential, sign in" sent users to re-authenticate over a temporary 429 —
2857
+ * observed as an "API key invalid" panel for a model that was merely cooling.
2858
+ *
2859
+ * Counts are over the region's accounts, since a provider only ever sees its
2860
+ * own gateway.
2861
+ */
2862
+ unavailableReason(modelId, region) {
2863
+ const now = Date.now();
2864
+ const inRegion = this.accounts.filter((account) => region === void 0 || regionOf(account.credential.domain) === region);
2865
+ if (inRegion.length === 0) return {
2866
+ total: 0,
2867
+ cooling: 0,
2868
+ disabled: 0,
2869
+ reason: "empty"
2870
+ };
2871
+ let cooling = 0;
2872
+ let disabled = 0;
2873
+ for (const account of inRegion) {
2874
+ if (this.disabledIds.has(account.id)) {
2875
+ disabled += 1;
2876
+ continue;
2877
+ }
2878
+ const modelCooling = modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now;
2879
+ if (account.cooldownUntilMs > now || modelCooling) {
2880
+ cooling += 1;
2881
+ continue;
2882
+ }
2883
+ const reserve = this.creditReserves.get(account.id);
2884
+ if (reserve !== void 0 && reserve > 0) {
2885
+ const balance = this.creditBalances.get(account.id);
2886
+ if (balance !== void 0 && balance <= reserve) return {
2887
+ total: inRegion.length,
2888
+ cooling,
2889
+ disabled,
2890
+ reason: "reserve"
2891
+ };
2892
+ }
2893
+ }
2894
+ if (cooling > 0) return {
2895
+ total: inRegion.length,
2896
+ cooling,
2897
+ disabled,
2898
+ reason: "cooling"
2899
+ };
2900
+ if (disabled > 0) return {
2901
+ total: inRegion.length,
2902
+ cooling,
2903
+ disabled,
2904
+ reason: "disabled"
2905
+ };
2906
+ return {
2907
+ total: inRegion.length,
2908
+ cooling,
2909
+ disabled,
2910
+ reason: "none"
2911
+ };
2912
+ }
2849
2913
  /** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
2850
2914
  pickRoundRobin(pool) {
2851
2915
  const index = this.cursor % pool.length;
package/lib/index.d.ts CHANGED
@@ -989,6 +989,25 @@ export declare class WorkBuddyAccountPool {
989
989
  * model, e.g. CLI diagnostics).
990
990
  */
991
991
  private available;
992
+ /**
993
+ * Why no account is available right now, for an accurate error.
994
+ *
995
+ * The pool can be empty for reasons that need OPPOSITE remedies: nobody is
996
+ * signed in (the user must sign in), every account is rate-limited (the user
997
+ * must wait, and retrying later works), or every account was switched off /
998
+ * ignored (the user must re-enable one). Reporting all of them as "no
999
+ * credential, sign in" sent users to re-authenticate over a temporary 429 —
1000
+ * observed as an "API key invalid" panel for a model that was merely cooling.
1001
+ *
1002
+ * Counts are over the region's accounts, since a provider only ever sees its
1003
+ * own gateway.
1004
+ */
1005
+ unavailableReason(modelId?: string, region?: WorkBuddyRegion): {
1006
+ total: number;
1007
+ cooling: number;
1008
+ disabled: number;
1009
+ reason: 'empty' | 'cooling' | 'disabled' | 'reserve' | 'none';
1010
+ };
992
1011
  /** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
993
1012
  private pickRoundRobin;
994
1013
  /**
package/lib/index.js CHANGED
@@ -2954,6 +2954,70 @@ var WorkBuddyAccountPool = class {
2954
2954
  return true;
2955
2955
  });
2956
2956
  }
2957
+ /**
2958
+ * Why no account is available right now, for an accurate error.
2959
+ *
2960
+ * The pool can be empty for reasons that need OPPOSITE remedies: nobody is
2961
+ * signed in (the user must sign in), every account is rate-limited (the user
2962
+ * must wait, and retrying later works), or every account was switched off /
2963
+ * ignored (the user must re-enable one). Reporting all of them as "no
2964
+ * credential, sign in" sent users to re-authenticate over a temporary 429 —
2965
+ * observed as an "API key invalid" panel for a model that was merely cooling.
2966
+ *
2967
+ * Counts are over the region's accounts, since a provider only ever sees its
2968
+ * own gateway.
2969
+ */
2970
+ unavailableReason(modelId, region) {
2971
+ const now = Date.now();
2972
+ const inRegion = this.accounts.filter((account) => region === void 0 || regionOf(account.credential.domain) === region);
2973
+ if (inRegion.length === 0) return {
2974
+ total: 0,
2975
+ cooling: 0,
2976
+ disabled: 0,
2977
+ reason: "empty"
2978
+ };
2979
+ let cooling = 0;
2980
+ let disabled = 0;
2981
+ for (const account of inRegion) {
2982
+ if (this.disabledIds.has(account.id)) {
2983
+ disabled += 1;
2984
+ continue;
2985
+ }
2986
+ const modelCooling = modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now;
2987
+ if (account.cooldownUntilMs > now || modelCooling) {
2988
+ cooling += 1;
2989
+ continue;
2990
+ }
2991
+ const reserve = this.creditReserves.get(account.id);
2992
+ if (reserve !== void 0 && reserve > 0) {
2993
+ const balance = this.creditBalances.get(account.id);
2994
+ if (balance !== void 0 && balance <= reserve) return {
2995
+ total: inRegion.length,
2996
+ cooling,
2997
+ disabled,
2998
+ reason: "reserve"
2999
+ };
3000
+ }
3001
+ }
3002
+ if (cooling > 0) return {
3003
+ total: inRegion.length,
3004
+ cooling,
3005
+ disabled,
3006
+ reason: "cooling"
3007
+ };
3008
+ if (disabled > 0) return {
3009
+ total: inRegion.length,
3010
+ cooling,
3011
+ disabled,
3012
+ reason: "disabled"
3013
+ };
3014
+ return {
3015
+ total: inRegion.length,
3016
+ cooling,
3017
+ disabled,
3018
+ reason: "none"
3019
+ };
3020
+ }
2957
3021
  /** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
2958
3022
  pickRoundRobin(pool) {
2959
3023
  const index = this.cursor % pool.length;
@@ -5548,6 +5612,40 @@ function isContextTooLong(body) {
5548
5612
  if (/exceeds?\s+(the\s+)?(model\s+)?context\s+(window|limit)/iu.test(body)) return true;
5549
5613
  return false;
5550
5614
  }
5615
+ /**
5616
+ * A content-free description of a request, for the log when the upstream
5617
+ * rejects it.
5618
+ *
5619
+ * `model_param_invalid` arrives with an EMPTY `param` field, so the error alone
5620
+ * says nothing about which parameter was rejected — the user is left with "the
5621
+ * request parameters do not meet the current model requirements" and no way to
5622
+ * act. Recording the shape (top-level keys, message count by role, tool count,
5623
+ * approximate prompt size) makes such a rejection diagnosable from the log.
5624
+ *
5625
+ * Deliberately SHAPE ONLY: no message content, no tool schemas, no tokens. It
5626
+ * ends up in a log file, and the conversation it describes is the user's.
5627
+ */
5628
+ function requestShape(raw) {
5629
+ try {
5630
+ const parsed = JSON.parse(raw);
5631
+ const keys = Object.keys(parsed).sort();
5632
+ const messages = Array.isArray(parsed["messages"]) ? parsed["messages"] : [];
5633
+ const byRole = /* @__PURE__ */ new Map();
5634
+ let chars = 0;
5635
+ for (const entry of messages) {
5636
+ if (typeof entry !== "object" || entry === null) continue;
5637
+ const message = entry;
5638
+ const role = typeof message["role"] === "string" ? message["role"] : "?";
5639
+ byRole.set(role, (byRole.get(role) ?? 0) + 1);
5640
+ chars += JSON.stringify(message["content"] ?? "").length;
5641
+ }
5642
+ const roles = [...byRole.entries()].map(([role, n]) => `${role}:${n}`).join(" ");
5643
+ const tools = Array.isArray(parsed["tools"]) ? parsed["tools"].length : 0;
5644
+ return `keys=[${keys.join(" ")}] messages=${messages.length}${roles === "" ? "" : ` (${roles})`} tools=${tools} contentChars=${chars}`;
5645
+ } catch {
5646
+ return "body was not parseable JSON";
5647
+ }
5648
+ }
5551
5649
  function readBody(req) {
5552
5650
  return new Promise((resolve, reject) => {
5553
5651
  const chunks = [];
@@ -5686,17 +5784,26 @@ function createWorkBuddyShim(options) {
5686
5784
  }
5687
5785
  const tried = [];
5688
5786
  let last;
5689
- let exhaustedByRateLimit = false;
5690
5787
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
5691
5788
  if (controller.signal.aborted) return;
5692
5789
  const account = await pool.acquire(modelId, region);
5693
5790
  if (account === void 0) {
5694
- if (exhaustedByRateLimit && last !== void 0) {
5791
+ const why = pool.unavailableReason(modelId, region);
5792
+ if (why.reason === "cooling") {
5695
5793
  const subject = modelId === void 0 ? "every WorkBuddy account is rate-limited" : `every account is rate-limited for model ${modelId}`;
5696
- writeOpenAIError(res, KIND_STATUS[last.kind], last.kind, `${subject} (tried ${tried.length}: ${tried.join(", ")}); resets at the upstream window — ${last.message.slice(0, 200)}`);
5794
+ const upstream = last === void 0 ? "" : ` — ${last.message.slice(0, 200)}`;
5795
+ writeOpenAIError(res, 429, "soft_rate", `${subject} (${why.cooling}/${why.total} cooling, tried ${tried.length})${tried.length === 0 ? "" : `: ${tried.join(" → ")}`}${upstream}`);
5796
+ return;
5797
+ }
5798
+ if (why.reason === "disabled") {
5799
+ writeOpenAIError(res, 403, "no_enabled_account", `all ${why.total} WorkBuddy account(s) for this gateway are switched off or ignored; re-enable one on the pool card`);
5800
+ return;
5801
+ }
5802
+ if (why.reason === "reserve") {
5803
+ writeOpenAIError(res, 402, "reserve_floor", `every WorkBuddy account is at or below its credit reserve (${why.total} account(s)); lower the reserve on the pool card or top up credits`);
5697
5804
  return;
5698
5805
  }
5699
- writeOpenAIError(res, 401, "not_signed_in", "no WorkBuddy credential found; sign in on the desktop app (or set WORKBUDDY_AUTH_FILE)");
5806
+ writeOpenAIError(res, 401, "not_signed_in", why.total === 0 ? "no WorkBuddy credential found for this gateway; sign in on the desktop app (or set WORKBUDDY_AUTH_FILE)" : `no usable WorkBuddy credential among ${why.total} account(s) for this gateway; sign in again on the desktop app`);
5700
5807
  return;
5701
5808
  }
5702
5809
  tried.push(account.label);
@@ -5720,8 +5827,8 @@ function createWorkBuddyShim(options) {
5720
5827
  logger?.warn(`dsh-workbuddy-xdpool: ${account.label} has no credits left (attempt ${attempt + 1}/${maxAttempts}); rotating`);
5721
5828
  continue;
5722
5829
  }
5830
+ if (result.kind === "client") logger?.warn(`dsh-workbuddy-xdpool: ${account.label} rejected the request as invalid (model ${modelId ?? "(none)"}) — ${requestShape(raw)}; upstream: ${result.message.slice(0, 300)}`);
5723
5831
  if (result.kind !== "soft_rate") break;
5724
- exhaustedByRateLimit = true;
5725
5832
  pool.penalize(account.id, parseRateLimitReset(result.message), modelId);
5726
5833
  logger?.warn(`dsh-workbuddy-xdpool: ${account.label} rate-limited on ${modelId ?? "(no model)"} (attempt ${attempt + 1}/${maxAttempts}); rotating`);
5727
5834
  }
@@ -5844,8 +5951,10 @@ async function recoverFromContextOverrun(options) {
5844
5951
  };
5845
5952
  const overrunTokens = estimateMessagesTokens(messages);
5846
5953
  const realWindow = options.contextWindow;
5847
- const budget = realWindow !== void 0 && realWindow > 0 ? Math.max(512, Math.floor(realWindow * .8) - 2048) : Math.max(512, Math.floor(overrunTokens / 2));
5848
- logger?.warn(`dsh-workbuddy-xdpool: context overrun on ${modelId ?? "(no model)"} (~${overrunTokens} tokens); compacting to ~${budget} and retrying once`);
5954
+ const fromWindow = realWindow !== void 0 && realWindow > 0 ? Math.max(512, Math.floor(realWindow * .8) - 2048) : Number.POSITIVE_INFINITY;
5955
+ const fromOverrun = Math.max(512, Math.floor(overrunTokens / 2));
5956
+ const budget = Math.min(fromWindow, fromOverrun);
5957
+ logger?.warn(`dsh-workbuddy-xdpool: context overrun on ${modelId ?? "(no model)"} (~${overrunTokens} tokens, catalog window ${realWindow ?? "unknown"}); compacting to ~${budget} and retrying once`);
5849
5958
  let summary;
5850
5959
  let compacted = messages;
5851
5960
  let compactionDetail = "";
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": "1.7.6",
5
+ "version": "1.7.7",
6
6
  "license": "MIT",
7
7
  "author": "XDTrees",
8
8
  "homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",