dsh-deepseek-web-login 0.6.7 → 0.6.8

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
@@ -2,6 +2,50 @@
2
2
 
3
3
  本项目遵循大致语义化版本;日期为本地时间。
4
4
 
5
+ ## 0.6.8 — 2026-09-30
6
+
7
+ **修「凭证被服务端作废之后不自动换号、只能手动切」+ 把 AUTH 做成条件式可重试。**
8
+
9
+ 现场(用户截图 + 本机台账原文):
10
+
11
+ ```
12
+ 15:49:43 acc_de90f63b {ok:false, code:"AUTH", ms:7} ← 7ms=本地拦下,请求根本没发出去
13
+ 15:49:45 同上(会话标题) {ok:false, code:"AUTH", ms:5}
14
+ 15:49:58 ← 用户手动切到 acc_ec650e63,之后 12 次全部成功
15
+ ```
16
+
17
+ 界面显示「本轮运行失败 · API 密钥无效」,任务停在那里 —— 用户的期待是**它自己换号接下去**。
18
+
19
+ 两个根因:
20
+
21
+ 1. `auth`(凭证作废)**不在** `retryableCodes` 里 ⇒ 重试器一次都不重试。而换号检查点是
22
+ **请求前**检查点:没有下一次请求,就永远等不到它。所以"能换号"这件事在 AUTH 上从来没发生。
23
+ 2. 「AUTH 一票制标记」被 0.2.0 改成"先只读复核探活、确认失效才标记"是对的(防端点级误判),
24
+ 但**标记写完之后的下一步没人接** —— 之前只有"限流/封禁"两条路会触发换号。
25
+
26
+ 改法(让"能不能换号"成为 AUTH 唯一的开关):
27
+
28
+ - `canFailover` 增加第三种 `kind`:`'auth'`。判据抽成纯函数
29
+ `hasFailoverCandidate`(自动换号开着 + 没在切 + 账号库里还有**别的**可用号);
30
+ AUTH 刻意**不过**限流那条"窗口 + 冷却" —— 死号是永久性坏状态,等多久都不会自己好。
31
+ - 三个 AUTH 抛出点(HTTP 401/403、信封 40003/40001、"已知失效就不发请求"的拦截)
32
+ 都按它给退避:**能换号 ⇒ 5 秒**(留出复核探活那 0.9~3.5 秒)**重发一次,检查点换号接上**;
33
+ **不能换号 ⇒ 600 秒**(> `maxDelayMs`)⇒ 重试器直接放弃。
34
+ ⚠️ 这就是"条件式可重试":AUTH 进 `retryableCodes` 是为了让那条 5 秒有路可走,
35
+ **"不能换号就别重试"靠的是退避值**,不是码 —— 所以两档常量各自有用例守,只改一边必红。
36
+ - 默认没开自动换号的用户**行为完全不变**(走 600 秒那档,与加这个功能之前一致)。
37
+ - 「已知失效就不发请求」那条错误的文案跟着分流:能换号时说"正在自动换到另一个账号重试",
38
+ 不能换号时才引导去「切换」/「重登」/「校验全部」。
39
+
40
+ ⚠️ 另一个同源问题(不在本版代码里):官方桌面端装在**新环境**时,旧环境的
41
+ `~/.dsh/web-login/gate.json` 不会跟着过来 ⇒ `autoSwitchMinutes` 落回默认 **0(关闭)**
42
+ ⇒ 整条自动换号链不工作。若你也是从旧环境迁过来的,请检查
43
+ 「设置 → DeepSeek 网页登录 → 防风控 → 自动换号」是不是被重置成关闭了。
44
+
45
+ 用例 `tests/check-auth-failover.mjs`(14 项):两档退避跨在 `maxDelayMs` 两侧 + 纯判据六种边界
46
+ + 三个抛出点的接线守卫。反向验证:把 GIVEUP 退避改小、把宿主签名里的 `'auth'` 删掉,
47
+ 两个变异体都变红。
48
+
5
49
  ## 0.6.7 — 2026-09-30
6
50
 
7
51
  **修「点切换像没反应、报错在列表底部看不见」+「探活超时被当成登录态失效」。**
package/README.md CHANGED
@@ -400,12 +400,22 @@ cipher 列表哈希与 Chrome 逐字节一致 —— 且**零新依赖**(不
400
400
  对话记录存在 DSH 本地、每一轮请求都把**整段历史完整重发**、网页端不留任何会话。
401
401
  账号只是"通行证 + 额度归属",换它不影响对话内容。(真正会"失忆"的是在 DSH 里开一个新会话。)
402
402
 
403
- > ⚠️ **为什么没有「自动换号」**:账号库只提供**手动**切换,刻意不做"检测到限流就自动换一个号继续发"。
404
- > 真人不会在几分钟内换一个账号接着发消息 —— 那是极强的机器行为特征,与本文档里
405
- > 传输层指纹、随机间隔、会话清理这些"降低机器可识别性"的努力直接冲突。
403
+ > ⚠️ **关于「自动换号」(默认关闭,滑块在「设置 → DeepSeek 网页登录 → 防风控」)**:
404
+ > 它会按你设的间隔(分钟,0 = 关闭)换到下一个可用账号,**0.6.8 起还有一条"失效即换"**:
405
+ > 某个账号的凭证被服务端判为失效(`AUTH` / `40003`)时,只要库里还有别的可用账号,
406
+ > 就会自动换过去并把这一轮接着跑完 —— 不需要你再手动点「切换」。
407
+ >
408
+ > 但请想清楚再打开它:真人不会在几分钟内换一个账号接着发消息 —— 那是极强的机器行为特征,
409
+ > 与本文档里传输层指纹、随机间隔、会话清理这些"降低机器可识别性"的努力直接冲突。
406
410
  > 另外同一服务商会把多账号关联起来(同设备 / 同 IP / 同指纹 / 相近行为),
407
411
  > 一旦被判定为"同一人的多开小号",处置通常比单账号超频更重。
408
- > **账号库的目标是"在自己的多个正常账号之间切换更省事",不是"靠轮换把限流绕过去"。**
412
+ > **账号库的目标是"在自己的多个正常账号之间切换更省事",不是"靠轮换把限流绕过去"** ——
413
+ > 所以它默认是关的,间隔也别给太短(半小时起步比较稳)。
414
+ >
415
+ > ⚠️ 这个设置存在 `~/.dsh/web-login/gate.json`。**换到新环境/重装 DSH 时它不会跟着迁过去**
416
+ > (账号库也不会 —— 用「导出备份 / 导入备份」搬),重启后会回到"关闭",
417
+ > 表现为"以前会自己换号,现在不换了"。
418
+ >
409
419
  > 导出的备份文件里是可完整登录的凭证,等同于账号本身 —— 别分享、别提交到仓库。
410
420
 
411
421
  **「登录新账号」和「退出」的区别**(很容易搞混,但后果完全不同):
@@ -518,7 +528,8 @@ CI(`.github/workflows/ci.yml`)在每次推送到 `main` 与每个 PR 上跑
518
528
  | 现象 | 处理 |
519
529
  |---|---|
520
530
  | 面板「未登录」但登录窗口里已登录 | 点「从已登录窗口恢复」;或看 `loginProgress.lastError` |
521
- | `AUTH` / `40003 Authorization Failed` | 登录态过期 → 重新登录或恢复 |
531
+ | `AUTH` / `40003 Authorization Failed` | 这个账号的凭证被服务端判失效了。**0.6.8 起**:自动换号开着、且库里还有别的可用账号时,会**自动换过去把这一轮接着跑完**(不再需要你手动切号;日志里会写「将自动换到另一个可用账号重试」)。否则请重新登录这个号,或点它那一行的「切换」换一个能用的号 |
532
+ | 以前会自己换号,现在**不换了**(尤其是刚换过 DSH 环境 / 重装过之后) | 自动换号的设置存在 `~/.dsh/web-login/gate.json`,**换环境不会跟着迁过去** ⇒ 静默回到默认的「关闭」,表现为"以前会自己换号,现在不换了"。去「设置 → DeepSeek 网页登录 → 防风控」把「自动换号」重新拉起来即可(拉动一次就会写回该文件) |
522
533
  | `MISSING_CREDENTIAL` | 凭证文件不存在(`~/.dsh/web-login/`) |
523
534
  | `EMPTY_RESPONSE` | 可能触发频控或长上下文截断,属于默认可重试码 |
524
535
  | `RATE_LIMIT` | 免费额度频控,稍后重试 |
package/lib/index.js CHANGED
@@ -1624,6 +1624,28 @@ const THROTTLE_JITTER_RATIO = .3;
1624
1624
  */
1625
1625
  const MAX_THROTTLE_RETRY_MS = Math.round(THROTTLE_MAX_MS * 1.3);
1626
1626
  /**
1627
+ * AUTH 失败、**能换号**时给的重试间隔。
1628
+ *
1629
+ * 为什么要等(而不用 `FAILOVER_RETRY_MS` 的 2 秒):宿主对"被服务端判 AUTH"的账号会先做一次
1630
+ * **只读复核探活**,复核确认失效才写 `lastVerifyError`(判据见 `index.ts` 的 AUTH 分支)。实测
1631
+ * 那一次探活 0.9~3.5 秒。重发太早 ⇒ 换号检查点还没看见标记 ⇒ 又拿同一个死号发一次,白跑一轮。
1632
+ *
1633
+ * ⚠️ 必须 ≤ 适配器声明的 `maxDelayMs`(`MAX_THROTTLE_RETRY_MS`,117s),否则重试直接被放弃。
1634
+ */
1635
+ const AUTH_FAILOVER_RETRY_MS = 5e3;
1636
+ /**
1637
+ * AUTH 失败、**没得换号**时给的退避 —— 故意给到 `maxDelayMs` 之上。
1638
+ *
1639
+ * 🔴 这是"条件式可重试"的实现方式:把 AUTH 放进 `retryableCodes` 之后,
1640
+ * **没有别的号可用时重试一个已失效的凭证只会白打请求**(还多暴露在风控下),
1641
+ * 所以不靠"码"来区分,而是靠这条**判据**:
1642
+ * "要的延迟 (600s) > maxDelayMs (117s) 且 normal ⇒ dsh-llm-retry 直接放弃重试"。
1643
+ * ⇒ 默认没开自动换号的用户,行为与加这个功能之前**完全一致**。
1644
+ *
1645
+ * 实测来源:这条判据第一次被踩到是 2026-09-27(限流退避 49208 > 10000 ⇒ 一次都没重试)。
1646
+ */
1647
+ const AUTH_GIVEUP_RETRY_MS = 6e5;
1648
+ /**
1627
1649
  * 取下一次节流退避(ms)。
1628
1650
  *
1629
1651
  * ⚠️ 实际的**首档是 40s,不是 20s**:`noteThrottled` 先把 `throttleStreak` 加一,
@@ -3084,9 +3106,10 @@ async function openCompletion(auth, params, signal, transport) {
3084
3106
  const hint = code === "AUTH" ? " —— 网页登录态可能已过期,请到「设置 → DeepSeek 网页登录」重新登录" : code === "RATE_LIMIT" ? " —— 网页版限流" : "";
3085
3107
  retireSession(sessionId);
3086
3108
  params.onDeleteSession?.(sessionId);
3109
+ const authRetryAfterMs = code === "AUTH" ? canFailover("auth") ? AUTH_FAILOVER_RETRY_MS : AUTH_GIVEUP_RETRY_MS : void 0;
3087
3110
  throw new AdapterLlmError(`DeepSeek web completion failed (HTTP ${resp.status})${text ? `: ${text.slice(0, 200)}` : ""}${hint}`, code, {
3088
3111
  status: resp.status,
3089
- ...retryAfter !== void 0 ? { providerRetryAfterMs: retryAfter } : {},
3112
+ ...retryAfter !== void 0 ? { providerRetryAfterMs: retryAfter } : authRetryAfterMs !== void 0 ? { providerRetryAfterMs: authRetryAfterMs } : {},
3090
3113
  cause: new Error(text)
3091
3114
  });
3092
3115
  }
@@ -3114,10 +3137,12 @@ async function openCompletion(auth, params, signal, transport) {
3114
3137
  const busy = !muted && !!biz && isBusyGenerating(biz.msg);
3115
3138
  const throttled = !muted && !busy && !!biz && (biz.code === 40029 || isThrottled(biz.msg));
3116
3139
  const untilMs = muteUntilMs(parsed);
3117
- const failure = biz ? new AdapterLlmError(muted ? mutedMessage(untilMs) : busy ? "网页版限流:同一账号同时只能生成一条消息,稍后自动重试" : throttled ? "网页版限流:发得太频繁,稍后自动重试" : bizErrorMessage(biz.code, biz.msg), muted || busy || throttled ? "RATE_LIMIT" : isInvalidSessionError(biz) ? "TRANSPORT" : isInvalidRefFileError(biz) ? "INVALID_REF_FILE" : bizErrorCode(biz.code), {
3140
+ const failureCode = !biz ? "MALFORMED_RESPONSE" : muted || busy || throttled ? "RATE_LIMIT" : isInvalidSessionError(biz) ? "TRANSPORT" : isInvalidRefFileError(biz) ? "INVALID_REF_FILE" : bizErrorCode(biz.code);
3141
+ const failure = biz ? new AdapterLlmError(muted ? mutedMessage(untilMs) : busy ? "网页版限流:同一账号同时只能生成一条消息,稍后自动重试" : throttled ? "网页版限流:发得太频繁,稍后自动重试" : bizErrorMessage(biz.code, biz.msg), failureCode, {
3118
3142
  status: resp.status,
3119
3143
  ...muted && untilMs !== void 0 ? { providerRetryAfterMs: canFailover("muted") ? FAILOVER_RETRY_MS : Math.max(0, untilMs - Date.now()) } : {},
3120
3144
  ...muted && untilMs !== void 0 ? { mutedUntilMs: untilMs } : {},
3145
+ ...failureCode === "AUTH" ? { providerRetryAfterMs: canFailover("auth") ? AUTH_FAILOVER_RETRY_MS : AUTH_GIVEUP_RETRY_MS } : {},
3121
3146
  ...busy ? { providerRetryAfterMs: 5e3 } : {},
3122
3147
  ...throttled ? {
3123
3148
  rateLimitKind: "throttled",
@@ -5929,6 +5954,7 @@ const RETRY_POLICY = Object.freeze({
5929
5954
  maxRetries: 5,
5930
5955
  retryableCodes: Object.freeze([
5931
5956
  "EMPTY_RESPONSE",
5957
+ "AUTH",
5932
5958
  "RATE_LIMIT",
5933
5959
  "SERVER",
5934
5960
  "TIMEOUT",
@@ -6262,8 +6288,9 @@ function createAdapter(deps) {
6262
6288
  if (!hasUsableAuth(auth)) throw new AdapterLlmError("尚未登录 DeepSeek 网页版:请在「设置 → DeepSeek 网页登录」里用浏览器窗口登录,或手动粘贴 userToken。", "MISSING_CREDENTIAL");
6263
6289
  const stale = staleAuthMessage(auth);
6264
6290
  if (stale) {
6265
- logger?.warn?.(`deepseek-web: 跳过请求 —— 该账号登录态已被判定失效(${stale})`);
6266
- throw new AdapterLlmError(`这个账号的登录态已失效(${stale}),本次请求没有发出。请在「设置 → DeepSeek 网页登录」用浏览器窗口重新登录该账号;若确认它其实还能用,点账号行上的「校验全部」重新确认一次即可(只读探活,不消耗额度)。`, "AUTH");
6291
+ const canSwap = deps.canFailover?.("auth") === true;
6292
+ logger?.warn?.(`deepseek-web: 跳过请求 —— 该账号登录态已被判定失效(${stale})` + (canSwap ? ";将自动换到另一个可用账号重试" : ";没有可换的账号,需手动切换或重新登录"));
6293
+ throw new AdapterLlmError(canSwap ? `这个账号的登录态已失效(${stale}),本次请求没有发出 —— 正在自动换到另一个可用账号重试……` : `这个账号的登录态已失效(${stale}),本次请求没有发出。请在「设置 → DeepSeek 网页登录」用浏览器窗口重新登录该账号,或点该账号行上的「切换」换一个能用的号;若确认它其实还能用,点「校验全部」重新确认一次即可(只读探活,不消耗额度)。`, "AUTH", { providerRetryAfterMs: canSwap ? AUTH_FAILOVER_RETRY_MS : AUTH_GIVEUP_RETRY_MS });
6267
6294
  }
6268
6295
  const spec = resolveSpec(String(options?.model ?? ""));
6269
6296
  const { thinkingEnabled } = resolveThinking(options, spec);
@@ -6818,6 +6845,23 @@ function pickNextAccount(accounts, currentId, now, excludeIds) {
6818
6845
  return usable[(index + 1) % usable.length]?.id;
6819
6846
  }
6820
6847
  /**
6848
+ * 「这次失败能不能靠**换个号**接着干」—— `canFailover` 的纯判据(宿主三种 kind 共用同一条)。
6849
+ *
6850
+ * 抽出来是为了能单测:`canFailover` 本身是 `apply()` 里的闭包,拿不到;而这条判据正是
6851
+ * 「自动换号没生效」类故障最该被守住的地方(0.6.8 的实测现场:凭证被服务端作废,
6852
+ * 报 AUTH 之后一直不换号)。
6853
+ *
6854
+ * 三条边界都实测踩过:
6855
+ * - 自动换号关着(`minutes <= 0`)⇒ **不是**能换号(默认关闭的语义就是"别偷偷把我换到别的号上")。
6856
+ * - 正在切换 ⇒ 不是(别叠加)。
6857
+ * - 候选里没有**别的**可用账号 ⇒ 不是(切了还是自己,白等一轮)。
6858
+ */
6859
+ function hasFailoverCandidate(params) {
6860
+ if (!Number.isFinite(params.minutes) || params.minutes <= 0) return false;
6861
+ if (params.switching) return false;
6862
+ return pickNextAccount(params.accounts, params.currentId, params.now, params.excludeIds) !== void 0;
6863
+ }
6864
+ /**
6821
6865
  * 把上面的判断合成一次决策。三条切号路径,按优先级排:
6822
6866
  *
6823
6867
  * 1. **当前账号不可用**(失效 / 受限未解除)—— 例外优先,哪怕没到点也要切走:
@@ -9127,7 +9171,7 @@ async function checkForUpdate(current, fetchImpl) {
9127
9171
  * 兜底常量与 package.json 的一致性由 `tests/check-smoke.mjs` 守着,不会漂。
9128
9172
  */
9129
9173
  /** 与 package.json 保持一致的兜底版本(由测试保证不会漂)。 */
9130
- const FALLBACK_VERSION = "0.6.7";
9174
+ const FALLBACK_VERSION = "0.6.8";
9131
9175
  let cached;
9132
9176
  /** 本插件版本(如 `0.1.26`)。 */
9133
9177
  function pluginVersion() {
@@ -9770,16 +9814,22 @@ function apply(ctx, config = {}) {
9770
9814
  */
9771
9815
  const canFailover = (kind) => {
9772
9816
  const minutes = gate.settings().autoSwitchMinutes ?? 0;
9773
- if (!Number.isFinite(minutes) || minutes <= 0) return false;
9774
- if (autoSwitching) return false;
9775
9817
  const now = Date.now();
9776
9818
  if (kind === "throttled" && !isThrottleSwitchAllowed({
9777
9819
  throttledAt: now,
9778
9820
  lastSwitchAt: lastSwitchedAt,
9779
9821
  now
9780
9822
  })) return false;
9781
- const fresh = freshThrottledIds(throttleAt, now);
9782
- return pickNextAccount(listAccounts().filter((account) => !autoSwitchSkip.has(account.id)), activeAccountId(), now, fresh) !== void 0;
9823
+ const candidate = hasFailoverCandidate({
9824
+ minutes,
9825
+ switching: autoSwitching,
9826
+ accounts: listAccounts().filter((account) => !autoSwitchSkip.has(account.id)),
9827
+ currentId: activeAccountId(),
9828
+ now,
9829
+ excludeIds: freshThrottledIds(throttleAt, now)
9830
+ });
9831
+ if (!candidate) logger.info?.(`deepseek-web: 本次 ${kind ?? "未知"} 失败无法换号(自动换号=${Number.isFinite(minutes) && minutes > 0 ? `${minutes} 分钟` : "关闭"}${autoSwitching ? ",正在切换中" : ""})—— 不重试,交给用户处理`);
9832
+ return candidate;
9783
9833
  };
9784
9834
  let contextMode = applyContextMode(readContextModeSetting() ?? (config.contextMode === "chained" ? "chained" : "full"));
9785
9835
  logger.info?.(`deepseek-web: 上下文投喂=${contextMode}` + (contextMode === "chained" ? "(只发增量 + parent 指向上一条回答)" : "(每轮重发全量 prompt)"));