dsh-deepseek-web-login 0.6.1 → 0.6.3
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 +78 -0
- package/lib/index.js +191 -29
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,84 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循大致语义化版本;日期为本地时间。
|
|
4
4
|
|
|
5
|
+
## 0.6.3 — 2026-09-27
|
|
6
|
+
|
|
7
|
+
> 限流之后**任务自己接下去**,不用再手点「继续」。
|
|
8
|
+
|
|
9
|
+
### 修复
|
|
10
|
+
|
|
11
|
+
- **限流不再让整轮直接结束**:适配器现在**显式声明重试策略**
|
|
12
|
+
(`providerRetryPolicy`,上限 `maxDelayMs` 与我们的最大限流退避对齐)。
|
|
13
|
+
此前返回 `undefined` ⇒ 落到 dsh-llm 的默认上限 **10 秒**,而我们的限流退避是 **40~117 秒**
|
|
14
|
+
⇒ 策略判定"提供方要的等太久"**直接放弃重试**,`turn/end` 变成 error、界面显示
|
|
15
|
+
「本轮运行失败」,必须用户手点「继续」。现场证据(会话日志原文):
|
|
16
|
+
`failure:{code:"RATE_LIMIT", providerRetryAfterMs:49208}`。
|
|
17
|
+
- 🔴 **SSE 路径现在也会问「还能不能换号」**:此前只有 HTTP 路径走 `canFailover`,
|
|
18
|
+
而**节流最常见的形态正是 SSE**(`event: error` / `event: toast`)⇒ "能换号就给 2s 短退避、
|
|
19
|
+
让重试立刻发生并由检查点换号"这套逻辑在真实场景里从未生效过。
|
|
20
|
+
- `canFailover` 提成**单一来源**(`makeCanFailover`):HTTP 与 SSE 两条路径必须同答,
|
|
21
|
+
否则会出现"有时自己接下去、有时必须手点"。
|
|
22
|
+
|
|
23
|
+
### 修正
|
|
24
|
+
|
|
25
|
+
- `throttleBackoffMs` 的注释与代码不一致:多处写"20s 起",而实现是**先推档再计算**
|
|
26
|
+
⇒ 实际首档 **40s**。按实测值(49208ms = 40000 + 23% 抖动)校正注释,**不动行为**。
|
|
27
|
+
|
|
28
|
+
### 说明
|
|
29
|
+
|
|
30
|
+
- 重试是**有界**的(normal 模式、最多 5 次),避免账号真被封时无限打请求;
|
|
31
|
+
想要"绝不放弃"可把 `RETRY_POLICY.maxRetries` 调大或改 `mode: 'always'`(默认不开)。
|
|
32
|
+
- 封禁(`user is muted`)不受影响:解除时间远大于上限时仍按"放弃重试 + 显示解除时间"处理;
|
|
33
|
+
但**短封禁**(< 上限)现在也能自己等过去。
|
|
34
|
+
|
|
35
|
+
## 0.6.2 — 2026-09-27
|
|
36
|
+
|
|
37
|
+
> 长尾工具压成**一行**:`` `签名` — 首句 ``。目录再瘦一圈,能力依然一个不少。
|
|
38
|
+
|
|
39
|
+
### 改进
|
|
40
|
+
|
|
41
|
+
- **长尾工具改成一行格式**,并拆出独立的 `## Other tools` 小节:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
## Available tools
|
|
45
|
+
### pwsh
|
|
46
|
+
<完整描述>
|
|
47
|
+
pwsh(command: string, description: string)
|
|
48
|
+
command: …
|
|
49
|
+
description: …
|
|
50
|
+
|
|
51
|
+
## Other tools
|
|
52
|
+
|
|
53
|
+
Call these the same way. Parameter names and types are in the parentheses.
|
|
54
|
+
|
|
55
|
+
- `job_list(limit?: number)` — 列出后台任务
|
|
56
|
+
- `web_search(queries: string[])` — 联网搜索
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
这是抄 cuckoo 的(它的 `getFormattedJsApiForPrompt` 对每个工具就是 `` 1. `read(path)` — 读取文件 ``):
|
|
60
|
+
**名字 / 签名 / 一句话三样都在,但只占一行** —— 长尾工具的价值就是"让模型知道它存在、怎么传参"。
|
|
61
|
+
- 那句话取**首句**(到第一个句末点)而不是开头若干字,上限 160 字符:描述的第一句才是"这工具干什么"。
|
|
62
|
+
|
|
63
|
+
### 实测收益(13 个真实 DSH 工具定义)
|
|
64
|
+
|
|
65
|
+
| 版本 | 目录长度 | 累计省 |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| 0.5.3(JSON + 完整描述) | 18,217 | — |
|
|
68
|
+
| 0.6.0(签名 + 完整描述) | 14,815 | 19% |
|
|
69
|
+
| 0.6.1(分级:长尾 240 字) | 11,000+ | 42% |
|
|
70
|
+
| **0.6.2(长尾一行)** | **9,024** | **50%** |
|
|
71
|
+
|
|
72
|
+
按 61 个工具(50,942 字符)外推,约 **省 2.5 万字符 ≈ 6,000 token / 每轮上下文**,
|
|
73
|
+
**零额外请求**、**工具一个不少**。
|
|
74
|
+
|
|
75
|
+
### 说明
|
|
76
|
+
|
|
77
|
+
- ⚠️ **这是 head 的变化**:重启 DSH 后**第一个任务会全量重发一次**,之后照常。
|
|
78
|
+
- 🔴 **渲染顺序变了**:以前是"按 DSH 下发的原序",现在是**分组**(核心在前、长尾在后),
|
|
79
|
+
各组**内部**仍保持原序。所以目录里工具的排列与之前不同。
|
|
80
|
+
- 长尾工具丢的是「遇错怎么办」那类长指引;核心工具(真正常用的)一个字没动。
|
|
81
|
+
- 调整名单:改 `src/protocol.ts` 的 `CORE_TOOLS` 集合 —— 那是唯一的开关。
|
|
82
|
+
|
|
5
83
|
## 0.6.1 — 2026-09-27
|
|
6
84
|
|
|
7
85
|
> 工具目录按「用不用得上」分级:常用的给完整说明,长尾的只留一句话。**能力一个不少。**
|
package/lib/index.js
CHANGED
|
@@ -1479,15 +1479,20 @@ function muteUntilMs(json) {
|
|
|
1479
1479
|
* 为什么是 2 秒而不是 0:让 dsh-llm-retry 立刻重发,但仍留一点余量 ——
|
|
1480
1480
|
* 重发进入适配器时,"自动换号"的检查点要先跑完(可能含一次探活)。
|
|
1481
1481
|
* 它**不是**"等对面恢复"的退避,而是"我马上换个号再试一次"的信号。
|
|
1482
|
+
*
|
|
1483
|
+
* 导出是为了让 `adapter.ts` 声明重试策略时拿它当 `initialDelayMs`(单一来源)。
|
|
1482
1484
|
*/
|
|
1483
1485
|
const FAILOVER_RETRY_MS = 2e3;
|
|
1484
1486
|
/**
|
|
1485
|
-
*
|
|
1487
|
+
* 限流但**没得换号**时的退避(HTTP 路径用的定值)。
|
|
1486
1488
|
*
|
|
1487
1489
|
* 为什么还留着 20 秒:限流是"发太快了"的反馈,同号立刻重试只会再撞一次。
|
|
1488
1490
|
* ⚠️ 但它同时意味着"这一轮基本就断了" —— 台账实测限流后能自动重发的几次,间隔都落在
|
|
1489
1491
|
* 27.8s ~ 60s 且那几次仍然失败。所以**能换号就一定走 `FAILOVER_RETRY_MS`**,
|
|
1490
1492
|
* 别让用户在这里干等(那是"任务卡住、要手动重发"的根源)。
|
|
1493
|
+
*
|
|
1494
|
+
* ⚠️ 别拿它当"我们的最大退避":SSE 路径走的是 `throttleBackoffMs`(渐长、上限更高),
|
|
1495
|
+
* 见 `MAX_THROTTLE_RETRY_MS`。
|
|
1491
1496
|
*/
|
|
1492
1497
|
const THROTTLE_RETRY_MS = 2e4;
|
|
1493
1498
|
/**
|
|
@@ -1554,11 +1559,34 @@ function fetchImplKind() {
|
|
|
1554
1559
|
}
|
|
1555
1560
|
let throttleStreak = 0;
|
|
1556
1561
|
let lastThrottleAt = 0;
|
|
1557
|
-
/**
|
|
1562
|
+
/** 限流退避:基数 20s、每次翻倍、封顶 90s,另加 0~30% 抖动。 */
|
|
1563
|
+
const THROTTLE_BASE_MS = 2e4;
|
|
1564
|
+
const THROTTLE_MAX_MS = 9e4;
|
|
1565
|
+
const THROTTLE_JITTER_RATIO = .3;
|
|
1566
|
+
/**
|
|
1567
|
+
* 我们**可能**上报给调用方(`dsh-llm-retry`)的**最大**限流退避。
|
|
1568
|
+
*
|
|
1569
|
+
* 🔴 它必须 ≤ 适配器声明的 `providerRetryPolicy().backoff.maxDelayMs`(见 `adapter.ts`):
|
|
1570
|
+
* dsh-llm 的重试策略在 normal 模式下,一旦「提供方要的延迟 > maxDelayMs」就**直接放弃重试**
|
|
1571
|
+
* (`return next()`),整轮随即以 error 结束 —— 表现就是"任务停在限流上,要用户手点继续"。
|
|
1572
|
+
*
|
|
1573
|
+
* 实测 2026-09-27 14:35:32(会话日志原文):
|
|
1574
|
+
* `failure: {code: "RATE_LIMIT", providerRetryAfterMs: 49208}` → 默认 maxDelayMs=10000
|
|
1575
|
+
* ⇒ 一次都没重试,`turn/end` 直接是 error。所以**两边的数字必须一起看**,别只改一边。
|
|
1576
|
+
*/
|
|
1577
|
+
const MAX_THROTTLE_RETRY_MS = Math.round(THROTTLE_MAX_MS * 1.3);
|
|
1578
|
+
/**
|
|
1579
|
+
* 取下一次节流退避(ms)。
|
|
1580
|
+
*
|
|
1581
|
+
* ⚠️ 实际的**首档是 40s,不是 20s**:`noteThrottled` 先把 `throttleStreak` 加一,
|
|
1582
|
+
* 再调本函数算 `20s × 2^streak`。注释与代码曾不一致(多处写"20s 起"),
|
|
1583
|
+
* 2026-09-27 按实测值(49208ms = 40000 + 23% 抖动)校正为 40s 起。
|
|
1584
|
+
* 保序:40s → 80s → 90s(封顶),每次都加 0~30% 抖动。
|
|
1585
|
+
*/
|
|
1558
1586
|
function throttleBackoffMs(now = Date.now()) {
|
|
1559
1587
|
if (now - lastThrottleAt > 3e5) throttleStreak = 0;
|
|
1560
|
-
const base = Math.min(
|
|
1561
|
-
return base + Math.round(base *
|
|
1588
|
+
const base = Math.min(THROTTLE_BASE_MS * 2 ** throttleStreak, THROTTLE_MAX_MS);
|
|
1589
|
+
return base + Math.round(base * THROTTLE_JITTER_RATIO * Math.random());
|
|
1562
1590
|
}
|
|
1563
1591
|
/** 记录一次节流;返回本次应给的退避(ms)。 */
|
|
1564
1592
|
function noteThrottled(now = Date.now()) {
|
|
@@ -1568,6 +1596,22 @@ function noteThrottled(now = Date.now()) {
|
|
|
1568
1596
|
return throttleBackoffMs(now);
|
|
1569
1597
|
}
|
|
1570
1598
|
/**
|
|
1599
|
+
* 限流(SSE 路径)该向调用方要多久的退避。
|
|
1600
|
+
*
|
|
1601
|
+
* 能换号 ⇒ 短退避,让重试**立刻**发生(重发前的检查点会换上可用账号);
|
|
1602
|
+
* 不能 ⇒ 给足渐长退避。问不出来(没注入 / 抛错)时按"不能换号"处理:保守方向 ——
|
|
1603
|
+
* 宁可多等一会儿,也不要给一个它其实接不上的短退避(那只会更快地撞同一个限流)。
|
|
1604
|
+
*
|
|
1605
|
+
* ⚠️ `noteThrottled()` 有副作用(推高 streak),所以**只在真要长退避时才调它** ——
|
|
1606
|
+
* 能换号时不烧档位。
|
|
1607
|
+
*/
|
|
1608
|
+
function throttleRetryAfterMs(canFailover) {
|
|
1609
|
+
try {
|
|
1610
|
+
if (canFailover?.("throttled") === true) return FAILOVER_RETRY_MS;
|
|
1611
|
+
} catch {}
|
|
1612
|
+
return noteThrottled();
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1571
1615
|
* 账号级节流:「发得太频繁」。
|
|
1572
1616
|
*
|
|
1573
1617
|
* 实测 2026-09-11 16:11(SSE error 事件,不是 HTTP 429):
|
|
@@ -2571,7 +2615,7 @@ function createSseState(options = {}) {
|
|
|
2571
2615
|
event.rateLimitKind = "concurrent";
|
|
2572
2616
|
} else if (isThrottled(message)) {
|
|
2573
2617
|
event.code = "RATE_LIMIT";
|
|
2574
|
-
event.retryAfterMs =
|
|
2618
|
+
event.retryAfterMs = throttleRetryAfterMs(options.canFailover);
|
|
2575
2619
|
event.rateLimitKind = "throttled";
|
|
2576
2620
|
}
|
|
2577
2621
|
out.push(event);
|
|
@@ -2590,7 +2634,7 @@ function createSseState(options = {}) {
|
|
|
2590
2634
|
event.rateLimitKind = "concurrent";
|
|
2591
2635
|
} else if (isThrottled(full)) {
|
|
2592
2636
|
event.code = "RATE_LIMIT";
|
|
2593
|
-
event.retryAfterMs =
|
|
2637
|
+
event.retryAfterMs = throttleRetryAfterMs(options.canFailover);
|
|
2594
2638
|
event.rateLimitKind = "throttled";
|
|
2595
2639
|
}
|
|
2596
2640
|
out.push(event);
|
|
@@ -2900,19 +2944,28 @@ const defaultTransport = {
|
|
|
2900
2944
|
* - 会话失效(invalid chat session id)→ **换一个新会话透明重试一次**(用户无感);
|
|
2901
2945
|
* - 其它业务错误 → 按业务码抛出(AUTH / RATE_LIMIT / PROVIDER_ERROR…)。
|
|
2902
2946
|
*/
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2947
|
+
/**
|
|
2948
|
+
* 宿主给的「当前账号被限时,还能不能换号接着干」。
|
|
2949
|
+
*
|
|
2950
|
+
* 🔴 **单一来源**:`openCompletion`(HTTP 路径)与 `streamWebCompletion`(SSE 路径)都必须走它 ——
|
|
2951
|
+
* 两条路径的答案不一致时会出现最难查的故障:SSE 给长退避放弃重试,而 HTTP 给短退避重发,
|
|
2952
|
+
* 用户看到的就是"有时自己接下去了、有时必须手点继续"。
|
|
2953
|
+
*
|
|
2954
|
+
* 问不出来(没注入 / 抛错)时按**不能**处理 —— 保守方向:宁可让用户多点一次「继续」,
|
|
2955
|
+
* 也不要给一个它其实接不上的短退避(那只会更快地撞同一个限流)。
|
|
2956
|
+
*/
|
|
2957
|
+
function makeCanFailover(params) {
|
|
2958
|
+
return (kind) => {
|
|
2910
2959
|
try {
|
|
2911
2960
|
return params.canFailover?.(kind) === true;
|
|
2912
2961
|
} catch {
|
|
2913
2962
|
return false;
|
|
2914
2963
|
}
|
|
2915
2964
|
};
|
|
2965
|
+
}
|
|
2966
|
+
async function openCompletion(auth, params, signal, transport) {
|
|
2967
|
+
let lastFailure;
|
|
2968
|
+
const canFailover = makeCanFailover(params);
|
|
2916
2969
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2917
2970
|
const lease = await leaseSession(auth, signal, transport, params.sessionReuseTurns ?? 20, params.onDeleteSession);
|
|
2918
2971
|
const sessionId = lease.sessionId;
|
|
@@ -3050,6 +3103,7 @@ let reuseFlightTail = Promise.resolve();
|
|
|
3050
3103
|
async function* streamWebCompletion(auth, params, transport = defaultTransport) {
|
|
3051
3104
|
const controller = new AbortController();
|
|
3052
3105
|
const signal = params.signal ? AbortSignal.any([params.signal, controller.signal]) : controller.signal;
|
|
3106
|
+
const canFailover = makeCanFailover(params);
|
|
3053
3107
|
const rawLimit = params.sessionReuseTurns ?? 20;
|
|
3054
3108
|
const limit = Number.isFinite(rawLimit) ? Math.max(0, Math.floor(rawLimit)) : 20;
|
|
3055
3109
|
let release;
|
|
@@ -3141,6 +3195,7 @@ async function* streamWebCompletion(auth, params, transport = defaultTransport)
|
|
|
3141
3195
|
if (timer) clearTimeout(timer);
|
|
3142
3196
|
iterator = parseWebSse(body, {
|
|
3143
3197
|
thinkingEnabled: params.thinkingEnabled,
|
|
3198
|
+
canFailover,
|
|
3144
3199
|
onResponseMessageId: (id) => {
|
|
3145
3200
|
responseMessageId = id;
|
|
3146
3201
|
}
|
|
@@ -3353,14 +3408,36 @@ const CORE_TOOLS = /* @__PURE__ */ new Set([
|
|
|
3353
3408
|
"ask_user_question"
|
|
3354
3409
|
]);
|
|
3355
3410
|
/**
|
|
3356
|
-
*
|
|
3357
|
-
*
|
|
3411
|
+
* 长尾工具「一行说明」里那句话的上限。
|
|
3412
|
+
*
|
|
3413
|
+
* 长尾工具只占**一行**:`` `name(签名)` — 首句 ``(抄 cuckoo 的 `getFormattedJsApiForPrompt`,
|
|
3414
|
+
* 它对每个工具就是 `` 1. `read(path)` — 读取文件 ``)。
|
|
3415
|
+
* 取**首句**而不是开头 160 字 —— 描述的第一句才是"这工具干什么",后面的多是用法细节。
|
|
3358
3416
|
*/
|
|
3359
|
-
const
|
|
3417
|
+
const MAX_TAIL_DESCRIPTION_CHARS = 160;
|
|
3360
3418
|
/** 工具是否属于核心组(决定描述给多长、要不要带参数说明)。 */
|
|
3361
3419
|
function isCoreTool(name) {
|
|
3362
3420
|
return CORE_TOOLS.has(String(name ?? ""));
|
|
3363
3421
|
}
|
|
3422
|
+
/** 取描述的首句(到第一个句末点),再按上限截断。 */
|
|
3423
|
+
function tailSentence(text) {
|
|
3424
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
3425
|
+
const stop = flat.search(/\.(\s|$)/);
|
|
3426
|
+
return truncate(stop >= 0 ? flat.slice(0, stop + 1) : flat, MAX_TAIL_DESCRIPTION_CHARS);
|
|
3427
|
+
}
|
|
3428
|
+
/**
|
|
3429
|
+
* 长尾工具的**一行**形式:`` `name(签名)` — 首句 ``。
|
|
3430
|
+
*
|
|
3431
|
+
* 名字、签名、一句话三样都在,但只占一行 —— 长尾工具的价值就是"让模型知道它存在、怎么传参"。
|
|
3432
|
+
* ⚠️ 签名认不出来时(`buildToolSignature` 返回 null)不冒险拼一行,只给名字 + 首句,
|
|
3433
|
+
* 让模型知道有这个工具、但不会照着残缺签名瞎传参(它会被兜底文案要求去问用户)。
|
|
3434
|
+
*/
|
|
3435
|
+
function formatTailTool(tool) {
|
|
3436
|
+
const sentence = tailSentence(String(tool.description ?? ""));
|
|
3437
|
+
const signature = buildToolSignature(tool, false);
|
|
3438
|
+
if (signature === null) return `\`${tool.name}\` — ${sentence}`;
|
|
3439
|
+
return `\`${signature.replace(/\s+/g, " ").trim()}\` — ${sentence}`;
|
|
3440
|
+
}
|
|
3364
3441
|
/**
|
|
3365
3442
|
* 工具目录(一节)的总预算。
|
|
3366
3443
|
*
|
|
@@ -3619,17 +3696,35 @@ function buildToolSignature(tool, withParamDocs = true) {
|
|
|
3619
3696
|
/**
|
|
3620
3697
|
* 渲染工具目录。
|
|
3621
3698
|
*
|
|
3622
|
-
*
|
|
3623
|
-
*
|
|
3699
|
+
* 结构(0.6.2):
|
|
3700
|
+
*
|
|
3701
|
+
* ## Available tools ← 核心工具:一段一个(`### 名字` / 完整描述 / 紧凑签名 / 参数说明)
|
|
3702
|
+
* ## Other tools ← 长尾工具:**一行一个** —— `` - `name(签名)` — 首句 ``
|
|
3703
|
+
*
|
|
3704
|
+
* 长尾用一行格式是抄 cuckoo 的(它的 `getFormattedJsApiForPrompt` 对每个工具就是
|
|
3705
|
+
* `` 1. `read(path)` — 读取文件 ``):同样三样信息(名字 / 签名 / 一句话),
|
|
3706
|
+
* 三行能装下的,一行就够 —— 而 61 个工具里有 48 个是长尾。
|
|
3707
|
+
*
|
|
3708
|
+
* ⚠️ **两级都保留工具名与参数签名** ⇒ 模型照样能调用长尾工具,只是看不到长篇说明。
|
|
3624
3709
|
*/
|
|
3625
3710
|
function buildToolSection(tools, maxChars = MAX_TOOLS_SECTION_CHARS) {
|
|
3626
3711
|
if (!tools || tools.length === 0) return "";
|
|
3627
3712
|
const parts = ["", "## Available tools"];
|
|
3628
3713
|
let budget = Math.max(0, Math.min(MAX_TOOLS_SECTION_CHARS, maxChars));
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3714
|
+
const coreTools = tools.filter((tool) => isCoreTool(tool?.name));
|
|
3715
|
+
const tailTools = tools.filter((tool) => !isCoreTool(tool?.name));
|
|
3716
|
+
/**
|
|
3717
|
+
* 预算用尽时的兜底:**必须把剩下的工具名说出来**,并明确要求别猜参数。
|
|
3718
|
+
* 旧写法只有一句 "(remaining tools omitted for length)",模型连"还有哪些工具存在"
|
|
3719
|
+
* 都不知道,只能盲猜名字和参数 —— 实测 61 个工具里 26 个就是这样静默消失的。
|
|
3720
|
+
*/
|
|
3721
|
+
const omittedNotice = (rest) => {
|
|
3722
|
+
const names = rest.map((item) => String(item?.name ?? "")).filter(Boolean);
|
|
3723
|
+
return `\n(⚠️ The following ${names.length} tools are NOT described above (omitted for length): ${names.join(", ")}. If you need one of them, ask the user for its exact parameters — do NOT guess them.)`;
|
|
3724
|
+
};
|
|
3725
|
+
for (let index = 0; index < coreTools.length; index += 1) {
|
|
3726
|
+
const tool = coreTools[index];
|
|
3727
|
+
const signature = buildToolSignature(tool, true);
|
|
3633
3728
|
let parametersText = signature ?? "";
|
|
3634
3729
|
if (signature === null) {
|
|
3635
3730
|
let schemaText = "";
|
|
@@ -3643,17 +3738,39 @@ function buildToolSection(tools, maxChars = MAX_TOOLS_SECTION_CHARS) {
|
|
|
3643
3738
|
const block = [
|
|
3644
3739
|
"",
|
|
3645
3740
|
`### ${tool.name}`,
|
|
3646
|
-
truncate(String(tool.description ?? "").replace(/\s+/g, " ").trim(),
|
|
3741
|
+
truncate(String(tool.description ?? "").replace(/\s+/g, " ").trim(), MAX_DESCRIPTION_CHARS),
|
|
3647
3742
|
parametersText
|
|
3648
3743
|
].join("\n");
|
|
3649
3744
|
if (budget - block.length < 0) {
|
|
3650
|
-
|
|
3651
|
-
parts.
|
|
3652
|
-
break;
|
|
3745
|
+
parts.push(omittedNotice([...coreTools.slice(index), ...tailTools]));
|
|
3746
|
+
return parts.join("\n");
|
|
3653
3747
|
}
|
|
3654
3748
|
budget -= block.length;
|
|
3655
3749
|
parts.push(block);
|
|
3656
3750
|
}
|
|
3751
|
+
if (tailTools.length > 0) {
|
|
3752
|
+
const header = [
|
|
3753
|
+
"",
|
|
3754
|
+
"## Other tools",
|
|
3755
|
+
"",
|
|
3756
|
+
"Call these the same way. Parameter names and types are in the parentheses."
|
|
3757
|
+
].join("\n");
|
|
3758
|
+
if (budget - header.length < 0) {
|
|
3759
|
+
parts.push(omittedNotice(tailTools));
|
|
3760
|
+
return parts.join("\n");
|
|
3761
|
+
}
|
|
3762
|
+
budget -= header.length;
|
|
3763
|
+
parts.push(header);
|
|
3764
|
+
for (let index = 0; index < tailTools.length; index += 1) {
|
|
3765
|
+
const line = `- ${formatTailTool(tailTools[index])}`;
|
|
3766
|
+
if (budget - line.length < 0) {
|
|
3767
|
+
parts.push(omittedNotice(tailTools.slice(index)));
|
|
3768
|
+
break;
|
|
3769
|
+
}
|
|
3770
|
+
budget -= line.length;
|
|
3771
|
+
parts.push(line);
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3657
3774
|
return parts.join("\n");
|
|
3658
3775
|
}
|
|
3659
3776
|
function flattenText(blocks, out = []) {
|
|
@@ -5652,6 +5769,45 @@ function looksMidSentence(text) {
|
|
|
5652
5769
|
function allowsAutoContinue(purpose) {
|
|
5653
5770
|
return purpose === void 0 || purpose === null || purpose === "" || purpose === "chat";
|
|
5654
5771
|
}
|
|
5772
|
+
/**
|
|
5773
|
+
* 交给 dsh-llm 的**重试策略**(`LlmAdapter.providerRetryPolicy` 的返回值)。
|
|
5774
|
+
*
|
|
5775
|
+
* 🔴 为什么必须显式声明、**不能返回 undefined**:不声明就用 dsh-llm 的默认策略,其中
|
|
5776
|
+
* `maxDelayMs = 10_000`;而我们的限流退避是 **40~117s**。normal 模式下策略一旦发现
|
|
5777
|
+
* 「提供方要的延迟 > maxDelayMs」就**直接放弃重试**(`return next()`)⇒ 整轮以 error 结束、
|
|
5778
|
+
* 用户得手点「继续」。
|
|
5779
|
+
*
|
|
5780
|
+
* 实测 2026-09-27 14:35:32(会话日志原文):
|
|
5781
|
+
* `failure:{code:"RATE_LIMIT", providerRetryAfterMs:49208}` → 默认上限 10s
|
|
5782
|
+
* ⇒ **一次都没重试**,`turn/end` 直接是 error。这就是"自动换号了、但还得我发继续"的根因。
|
|
5783
|
+
*
|
|
5784
|
+
* 声明之后:提供方要的延迟 ≤ `maxDelayMs` 会被**照单等待并重试**(同一个打开的轮次里重跑
|
|
5785
|
+
* 失败步骤,历史保持一致),任务自己接下去 —— 重发时"自动换号"的检查点还会换上可用账号。
|
|
5786
|
+
*
|
|
5787
|
+
* 取值理由:
|
|
5788
|
+
* - `initialDelayMs` = `FAILOVER_RETRY_MS`:本地退避起点与"能换号"那条路一致;
|
|
5789
|
+
* - `maxDelayMs` = `MAX_THROTTLE_RETRY_MS`(117s):**必须 ≥ 我们可能上报的最大退避**,
|
|
5790
|
+
* 否则又回到"要等太久 ⇒ 放弃"。`tests/check-llm-retry.mjs` 守这个不变量;
|
|
5791
|
+
* - `maxRetries` = 5(与 dsh-llm 默认一致):**有界** —— 避免账号真被封时无限打请求。
|
|
5792
|
+
* 想更激进可以调大,或改 `mode: 'always'`(无尝试上限);那会在坏账号上一直重试,默认不开;
|
|
5793
|
+
* - `retryableCodes` 与 dsh-llm 默认一致(含 `RATE_LIMIT`)—— 我们只抛这几个码。
|
|
5794
|
+
*/
|
|
5795
|
+
const RETRY_POLICY = Object.freeze({
|
|
5796
|
+
mode: "normal",
|
|
5797
|
+
maxRetries: 5,
|
|
5798
|
+
retryableCodes: Object.freeze([
|
|
5799
|
+
"EMPTY_RESPONSE",
|
|
5800
|
+
"RATE_LIMIT",
|
|
5801
|
+
"SERVER",
|
|
5802
|
+
"TIMEOUT",
|
|
5803
|
+
"TRANSPORT"
|
|
5804
|
+
]),
|
|
5805
|
+
backoff: Object.freeze({
|
|
5806
|
+
initialDelayMs: FAILOVER_RETRY_MS,
|
|
5807
|
+
maxDelayMs: MAX_THROTTLE_RETRY_MS,
|
|
5808
|
+
jitterRatio: .2
|
|
5809
|
+
})
|
|
5810
|
+
});
|
|
5655
5811
|
/** 构造 deepseek-web 适配器(鸭子类型满足 LlmAdapter 契约,无需继承)。 */
|
|
5656
5812
|
function createAdapter(deps) {
|
|
5657
5813
|
const logger = deps.config.logger;
|
|
@@ -5669,8 +5825,14 @@ function createAdapter(deps) {
|
|
|
5669
5825
|
name: "DeepSeek 网页版(免费)"
|
|
5670
5826
|
};
|
|
5671
5827
|
},
|
|
5672
|
-
/**
|
|
5673
|
-
|
|
5828
|
+
/**
|
|
5829
|
+
* 重试策略:**必须显式给**(理由见 `RETRY_POLICY` 的注释)——
|
|
5830
|
+
* 返回 `undefined` 会落到 dsh-llm 的默认上限 10s,而我们的限流退避是 40~117s,
|
|
5831
|
+
* 于是"要等太久 ⇒ 放弃重试 ⇒ 用户手点继续"。
|
|
5832
|
+
*/
|
|
5833
|
+
providerRetryPolicy(_provider) {
|
|
5834
|
+
return RETRY_POLICY;
|
|
5835
|
+
},
|
|
5674
5836
|
/**
|
|
5675
5837
|
* 图片请求计价:本路由不声明 → undefined(消费者回落到自己的中性估算)。
|
|
5676
5838
|
*
|
|
@@ -8832,7 +8994,7 @@ async function checkForUpdate(current, fetchImpl) {
|
|
|
8832
8994
|
* 兜底常量与 package.json 的一致性由 `tests/check-smoke.mjs` 守着,不会漂。
|
|
8833
8995
|
*/
|
|
8834
8996
|
/** 与 package.json 保持一致的兜底版本(由测试保证不会漂)。 */
|
|
8835
|
-
const FALLBACK_VERSION = "0.6.
|
|
8997
|
+
const FALLBACK_VERSION = "0.6.3";
|
|
8836
8998
|
let cached;
|
|
8837
8999
|
/** 本插件版本(如 `0.1.26`)。 */
|
|
8838
9000
|
function pluginVersion() {
|