dsh-xdpool 2.4.0 → 2.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,15 +1,82 @@
1
1
  # Changelog
2
2
  本项目的所有重要变更都会记录在此文件。
3
3
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
+ ## 2.4.2 — 第二道内容指纹:`Web GUI` 那句,以及 assistant 历史
5
+ > 2.4.1 之后仍有 11128。这一版补上第二道内容指纹,并把改写范围扩到 assistant 历史。修复来自社区 PR [#62](https://github.com/XDTrees/dsh-xdpool/pull/62)([@jmglsi](https://github.com/jmglsi)),实测复核与发布由维护者完成。
6
+ ### 同一条提示词里还有第二道指纹
7
+ 2.4.1 只改写了 `You are an AI agent powered by DeepSeek Harness` 那一句的开头。把这一句改掉之后,请求仍然 `400 / 11128`,因为网关还在看提示词的另一段——介绍 Web GUI 的那一对句子:
8
+
9
+ ```
10
+ You are interacting with the user through the DeepSeek Harness Web GUI
11
+ whose URL the runtime context names. When the user refers to "this page",
12
+ "this GUI", or "this app" without naming another target, they mean this GUI.
13
+ ```
14
+
15
+ 这两句是**合起来**才算的:单独发任一句都 `200`,两句凑齐就 `400`;在这对句子里做任何小改动都放行。所以这是第二道**内容指纹**,不是请求长度限制,也不是别的头。
16
+
17
+ 修法是把产品名后的 `Web GUI` 改称 `web interface`——保住原意的最小改动。改写清单仍是数组,这一条只是新的一行。
18
+
19
+ ### 被扫的是 system 和 assistant,user/tool 不扫
20
+ 同一句话放进 `assistant` 消息同样 `400`,改写后 `200`;放进 `user` 或 `tool` 则从来不管。这一条很重要:**只要历史里有一条助手回复带着这段文字,之后这个会话的每一次请求都会失败**。所以改写范围从「只改 system」扩到 system + assistant。
21
+
22
+ ### 实测(真实国内网关,改写前 → 改写后)
23
+ | 请求体 | 修复前 | 修复后 |
24
+ |---|---|---|
25
+ | 主 agent 提示词(含 Web GUI 那对句子) | `400 / 11128` | `200` |
26
+ | 同一对句子放在 `assistant` 历史里 | `400 / 11128` | `200` |
27
+ | 两句合一的提示词(两道指纹同时在) | `400 / 11128` | `200` |
28
+ | 埋进数百字长提示词中间 | `400 / 11128` | `200` |
29
+ | 只有无关填充的长提示词(基线) | `200` | `200` |
30
+
31
+ 维护者复核时额外确认了一件事:DSH 当前版本真正发出的那几句(`The runtime context names the DeepSeek Harness implementation checkout…`、`… the DeepSeek Harness Web GUI at <url>.`、`Current DeepSeek Harness Web GUI URL: …`)**原样发送就是 `200`**,不触发任何检查——它们含产品名却不是模板命中的句式。也就是说命中的是句式,不是「出现产品名」;这也是为什么没有采用「把 `DeepSeek Harness` 全局替换成 `DSH`」那种更宽的改法:它会连 `D:\DeepSeek Harness\resources\app\dsh\` 这类要求模型逐字使用的路径一起改掉,而换不来任何额外的放行。
32
+ ### 同时并入的两个社区修复
33
+ - **PR [#49](https://github.com/XDTrees/dsh-xdpool/pull/49)**([@GIGIGIGIbaby](https://github.com/GIGIGIGIbaby)):模型连通性探测的 `content` 改用内容块数组。宿主按 `content.some(...)` 校验每条出站消息,裸字符串没有 `.some`,探测在进程内就炸了,卡片上显示成 `content.some is not a function`——一个形状错误伪装成模型坏了。
34
+ - **PR [#50](https://github.com/XDTrees/dsh-xdpool/pull/50)**([@GIGIGIGIbaby](https://github.com/GIGIGIGIbaby)):禁用按钮划过时会「消失」。`.dsm-btn:disabled:hover` 里的 `background:transparent` 优先级 (0,3,0),压过了 `.dsm-btn-primary` 的填充色 (0,1,0);主按钮文字是深色、为浅底设计的,底一变透明文字就看不见了。现在这条规则只保留 `transform` 部分。
35
+ ### 其他
36
+ - 测试:新增指纹改写与被扫角色 9 例 + 运行时上下文指纹 5 例 + 探测请求形状 4 例 + 禁用按钮悬停 6 例,全套 72 文件 947 用例。
37
+ ## 2.4.1 — 11128 的真正成因:system 内容指纹
38
+ > 2.4.0 之后仍有用户撞上 `400 / 11128 Illegal API invocation from an unapproved channel`。定位下来,2.4.0 的归因是错的,这一版修的是真原因。
39
+ ### 11128 不是身份头的问题,是 system 内容里的自报句
40
+ 网关按**内容指纹**判定「这是不是官方客户端发出来的」,命中串是 system 消息里的 `You are an AI agent powered by DeepSeek Harness`。它只匹配 `role === "system"`(放进 user/tool 不管),字面子串、大小写不敏感,且带起始位置倾向——放在提示词第一行会被拦,前面垫一行普通文字就放行。
41
+
42
+ 这句话由宿主的第一方插件 `@deepseek-ai/dsh-system-prompt` 固定注入,**任何未改装的安装都会 100% 命中**,与账号、密钥、渠道、UA 全部无关——这也解释了为什么它在「1 个账号失败后不再重试」:每个账号都会失败,重试没有意义。
43
+
44
+ 修法是在发送前把 system 里的这句改写成网关放行的同义句(`powered by` → `running on`),只动命中的文本块,图片/缓存块的引用原样透传;没命中时返回同一个引用,不为「什么都没改」付深拷贝代价。改写清单做成了数组,将来网关加句只是多一行。
45
+
46
+ **实测(真实国内网关,同一账号三组对照)**:
47
+ | 请求体 | 结果 |
48
+ |---|---|
49
+ | 经本插件改写 | `429 / 6004`(通过渠道校验,仅受频率限制) |
50
+ | 原样含该句(对照) | `400 / 11128` |
51
+ | system 干净(基线) | `429 / 6004` |
52
+
53
+ ### 国际网关要求首条消息是 system
54
+ `www.workbuddy.ai` 对一个以 `user` 开头的请求体回 `400 / 11128 "first message is not system prompt"`,而**同一个请求体在国内网关是被接受的**。同一个错误码,两种完全不同成因——这正是不能只按业务码分支的原因。现在国际版请求若首条不是 system,会在最前面补一条占位 system,用户自己的消息位置和措辞一个字符都不动。
55
+
56
+ ### 思考档位「关闭」会被两区同时拒绝
57
+ 适配层把 `thinkingLevelMap.off` 钉成字面量 `'off'`,而 pi-ai 对「没有显式指定档位」的请求发的就是它——所以选「默认」和选「关闭」产生了同一个请求体。两区都不接受这个值:国际版 GPT 族回 `400 / 11133`(`extError.param === 'reasoning.effort'`),国内版在声明了 `onlyReasoning` 的模型上报错。现在发送前把它删掉,让上游走自己的默认档。**省略**是唯一在各个区、各个模型上都实测通过的写法;换成字面量 `'none'` 不是安全替代。
58
+
59
+ **实测(真实双网关,同一账号前后对照)**:
60
+
61
+ | 请求 | 修复前 | 修复后 |
62
+ |---|---|---|
63
+ | 国际版,首条为 user | `400 / 11128 first message is not system prompt` | `200` |
64
+ | 国际版,`reasoning_effort: "off"` | `400 / 11133 extError.param=reasoning.effort` | `200` |
65
+
66
+ ### 免费模型限流提示:说清配额是按会话算的
67
+ 免费车道的拒绝原文是「the provider asked to wait 74558s」——二十小时,读起来像是整个免费车道被封了。实际配额**按上游会话计**,换一个会话(或换一个模型)就另有一份额度。现在限流错误的文案补上了这一句;只有限流类错误带这句,凭据失效、模型不存在之类加上去只会引导用户去做无用重试。
68
+ ### 其他
69
+ - 测试:新增 system 指纹改写 8 例 + 区域专属请求体规则 12 例 + 免费车道会话配额提示 1 例,全套 71 文件 932 用例。
4
70
  ## 2.4.0 — 会话身份头、清单不再冻结、中文路由可存
5
71
  > 这一版收的是「用着用着就报错」的那一类问题:调用被网关判成非法渠道(11128)、国际版模型清单永远停在启动那一刻、中文名字的路由点了保存却没存下、上下文选择一选就弹回去。
6
- ### 会话身份头:修掉 400 / 11128「Illegal API invocation from an unapproved channel」
7
- 网关对「这一轮对话属于谁」有一组身份头,缺一个就整轮拒绝,报 `11128 Illegal API invocation from an unapproved channel`,看起来像密钥失效,其实是新会话没自报身份。补齐官方客户端的契约:
72
+ ### 会话身份头:补齐官方客户端的契约
73
+ 网关对「这一轮对话属于谁」有一组身份头。补齐官方客户端的契约:
8
74
  - **会话稳定三头**(同一会话内不变):`X-Conversation-ID`、`X-Conversation-Request-ID`、`acp-connection-id`。
9
75
  - **每请求两头**(逐次随机):`X-Conversation-Message-ID`、`X-Request-ID`。
10
76
  - 另附 `X-Agent-Intent`、`X-Agent-Purpose`、`x-codebuddy-request`、`X-Private-Data`、`X-Product` 与 W3C/B3 追踪族。
11
77
 
12
- 实测口径:三个稳定头缺任一个即 400 11128,齐全即 200;值本身不校验(非 UUID 字符串也过)。池车道(WorkBuddy 国内/国际)与扫码渠道的 `send` 两条路径都已接上。
78
+ > **口径更正(见 2.4.1)**:本版曾把 `400 / 11128` 归因于「缺会话身份头」。这个归因是错的——实测国内网关根本不检查这组头(不带任何身份头、system 干净时正常放行)。身份头照官方契约补齐没有坏处,但 11128 的真正成因是 system 内容里的自报句,已在 2.4.1 修掉。
79
+
13
80
  顺带修掉同源的一处:`role: "developer"` 的消息在国内网关同样触发 11128,现统一上送为 `system`。
14
81
  ### 国际版模型清单:不再冻结在启动那一刻
15
82
  `createProvider` 的 `models` 是在创建 provider 时快照下来的数组,而原来的 `invalidate()` 只重排了 profile、复用了同一个 provider 对象,宿主按对象身份记忆化 `current()`,于是**新拿到的模型清单永远送不到选择器**——表现是「重新获取模型」点了没反应、国际版清单停在开机那一刻。现在每次 `invalidate()` 重建 provider 与 profile 两个对象,并通过 adapter handle 的 `replace()` 重新登记路由,由宿主自己发出 `llm/adapters-updated`。
package/lib/bin.js CHANGED
@@ -278,6 +278,117 @@ const CHAT_IDENTITY_HEADERS = Object.freeze({
278
278
  "X-IDE-Version": "5.5.6"
279
279
  });
280
280
  /**
281
+ * System-prompt strings the gateway treats as evidence of a non-official
282
+ * client, and the rewriting that clears the check.
283
+ *
284
+ * WHY THIS EXISTS
285
+ *
286
+ * `code 11128 Illegal API invocation from an unapproved channel` has a SECOND
287
+ * trigger, separate from the conversation-identity headers below: the gateway
288
+ * matches the CONTENT of system messages against a list of strings that identify
289
+ * other agents' clients. The first-party DSH system-prompt plugin injects
290
+ * `You are an AI agent powered by DeepSeek Harness.` as the opening line of
291
+ * every conversation, so every request from an unmodified install is rejected —
292
+ * regardless of which account in the pool is used, which is why the reported
293
+ * symptom was a 400 on the first account with no retry.
294
+ *
295
+ * Boundary, measured against the live domestic gateway (429 = accepted,
296
+ * 400/11128 = blocked):
297
+ * - the match is a literal substring, BUT only near the START of a system
298
+ * message: the sentence blocked a longer prompt when it was the first line,
299
+ * and passed when one plain line was prepended to it ("trailing text after
300
+ * it" above is the same finding from the other side).
301
+ * - it is case-insensitive (`you are an ai agent powered by deepseek harness`
302
+ * is blocked too).
303
+ * - the scanned roles are `system` and `assistant`: the same sentence in a
304
+ * `user` or `tool` message passes, and it is blocked in a second system
305
+ * message too. 2.4.1 recorded this as system-only; re-measured
306
+ * 2026-10-21, an `assistant` message carrying the sentence 400s as well,
307
+ * so `prepareChatBody` rewrites both roles.
308
+ * - `deepseek harness` alone, or `powered by deepseek` alone, passes — the
309
+ * whole phrase is what matches.
310
+ * - replacing `powered by` with `running on` passes, which is the rewrite
311
+ * below: it keeps the meaning for the model and shares no substring with
312
+ * the fingerprint.
313
+ *
314
+ * A prepended blank line would equally defeat a start-anchored check, but it
315
+ * would leave the banned string in the body: the list is server-side and may
316
+ * lose its anchor tomorrow. Rewriting the phrase is what the report's repro
317
+ * verified, so that is what this does.
318
+ *
319
+ * A SECOND FINGERPRINT (measured against the live domestic gateway 2026-10-21)
320
+ *
321
+ * Once the sentence above was rewritten, requests still 400/11128'd, and the
322
+ * blocker turned out to be another region of the same DSH system prompt: the
323
+ * runtime-context sentence pair
324
+ *
325
+ * You are interacting with the user through the DeepSeek Harness Web GUI
326
+ * whose URL the runtime context names. When the user refers to "this page",
327
+ * "this GUI", or "this app" without naming another target, they mean this GUI.
328
+ *
329
+ * The pair is load-bearing: either sentence alone 200s, and so does the pair
330
+ * with any small edit inside it — so this is a second CONTENT fingerprint, not
331
+ * a prompt-length rule and not another header rule. Renaming the product's
332
+ * "Web GUI" to "web interface" is the smallest edit that keeps the meaning for
333
+ * the model and clears the check; it was verified at 200 against both a main
334
+ * agent system prompt and a subagent system prompt, on the same account that
335
+ * reproduced the 400.
336
+ *
337
+ * The same window in an `assistant` message also answers 400, and 200 after
338
+ * this rewrite; `user` and `tool` messages are not scanned. That is why the
339
+ * rewrite is applied to assistant history too — one past reply carrying a
340
+ * fingerprint would otherwise fail every later request in the conversation.
341
+ *
342
+ * The list is an ARRAY because these fingerprints are appended to over time; a
343
+ * future rejection should be one more row here, not a new code path.
344
+ */
345
+ const BANNED_SYSTEM_PHRASES = Object.freeze([[/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"], [/DeepSeek Harness Web GUI/gi, "DeepSeek Harness web interface"]]);
346
+ /**
347
+ * Rewrite fingerprinted system/assistant content, leaving everything else
348
+ * byte-identical.
349
+ *
350
+ * Message content arrives as either a plain string or an array of content blocks
351
+ * (an OpenAI-compatible body allows both), so both shapes are handled — fixing
352
+ * only the string form would miss every request that carries an image or a
353
+ * cache-control block.
354
+ *
355
+ * Returns the SAME reference when nothing matched: a long conversation's system
356
+ * prompt can be large, and paying for a deep copy to change nothing is waste.
357
+ * Only matched `text` blocks are rebuilt; image and cache blocks pass through
358
+ * untouched so the request shape survives.
359
+ */
360
+ function sanitizeSystemContent(content) {
361
+ if (typeof content === "string") return sanitizeSystemText(content);
362
+ if (!Array.isArray(content)) return content;
363
+ let changed = false;
364
+ const next = content.map((block) => {
365
+ if (typeof block !== "object" || block === null || Array.isArray(block)) return block;
366
+ const record = block;
367
+ const text = record["text"];
368
+ if (typeof text !== "string") return block;
369
+ const cleaned = sanitizeSystemText(text);
370
+ if (cleaned === text) return block;
371
+ changed = true;
372
+ return {
373
+ ...record,
374
+ text: cleaned
375
+ };
376
+ });
377
+ return changed ? next : content;
378
+ }
379
+ /**
380
+ * Apply every banned-phrase rewrite to one string.
381
+ *
382
+ * The RegExp is rebuilt per call on purpose: a shared `g`-flagged instance
383
+ * carries `lastIndex` between calls, so the second request in a conversation
384
+ * could skip a match the first one caught.
385
+ */
386
+ function sanitizeSystemText(text) {
387
+ let out = text;
388
+ for (const [pattern, replacement] of BANNED_SYSTEM_PHRASES) out = out.replace(new RegExp(pattern.source, pattern.flags), replacement);
389
+ return out;
390
+ }
391
+ /**
281
392
  * The conversation-identity headers the gateway's channel check demands.
282
393
  *
283
394
  * WHY THIS EXISTS
@@ -878,7 +989,15 @@ var WorkBuddyUpstreamClient = class {
878
989
  * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
879
990
  * force `stream: true` (the upstream rejects non-streaming), convert the
880
991
  * DSH `developer` role into `system` (upstream rejects `developer` with
881
- * business code 11128), and flatten `tool_choice` into its string form.
992
+ * business code 11128), rewrite fingerprinted system/assistant content the
993
+ * gateway reads as a non-official client (also 11128), and flatten
994
+ * `tool_choice` into its string form.
995
+ *
996
+ * This is the REGION-BLIND half. Everything here is measured good on both
997
+ * gateways, so it runs before the region is known — the pool lane picks its
998
+ * account after this call. The two region-only rules live in
999
+ * {@link prepareChatBodyFor} instead; see its comment for why they cannot be
1000
+ * folded in here.
882
1001
  */
883
1002
  prepareChatBody(raw) {
884
1003
  let body;
@@ -897,6 +1016,7 @@ var WorkBuddyUpstreamClient = class {
897
1016
  if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
898
1017
  const message = value;
899
1018
  if (message["role"] === "developer") message["role"] = "system";
1019
+ if (message["role"] === "system" || message["role"] === "assistant") message["content"] = sanitizeSystemContent(message["content"]);
900
1020
  }
901
1021
  const choice = obj["tool_choice"];
902
1022
  if (typeof choice === "string") {
@@ -923,6 +1043,61 @@ var WorkBuddyUpstreamClient = class {
923
1043
  return JSON.stringify(obj);
924
1044
  }
925
1045
  /**
1046
+ * The region-only half of body normalisation, applied once the account is
1047
+ * known.
1048
+ *
1049
+ * WHY IT IS SEPARATE
1050
+ *
1051
+ * The pool lane calls {@link prepareChatBody} BEFORE it picks an account —
1052
+ * the body has to be ready for whichever account wins. So anything that
1053
+ * depends on the region cannot live there. Two rules do:
1054
+ *
1055
+ * 1. **International requires a leading `system`.** Measured: a body whose
1056
+ * first message is `user` answers `400 / 11128 "first message is not
1057
+ * system prompt"` on `www.workbuddy.ai`, while the identical body is
1058
+ * accepted on the domestic gateway. Same code, different cause — the
1059
+ * domestic gateway uses 11128 for the `developer` role and for the
1060
+ * content fingerprint, which is why nothing may branch on the code alone.
1061
+ * A placeholder assistant prompt is prepended, never merged: every
1062
+ * caller-supplied message keeps its position and wording.
1063
+ *
1064
+ * 2. **Neither gateway accepts `reasoning_effort: "off"`.** The adapter pins
1065
+ * `thinkingLevelMap.off` to the literal `'off'` so the level stays
1066
+ * selectable, and pi-ai emits that value for any request carrying no
1067
+ * explicit level — so "Default" and "Off" produce the same body.
1068
+ * Measured: the international GPT family answers `400 / 11133`
1069
+ * (`extError.param === 'reasoning.effort'`), and the domestic gateway
1070
+ * rejects it on models that declare `onlyReasoning`. Omission is the only
1071
+ * spelling measured good everywhere; a literal `'none'` is NOT a safe
1072
+ * substitute (accepted by the GPT-5.6 family, rejected by others).
1073
+ *
1074
+ * Both rules are no-ops on a body that already complies, so this is safe to
1075
+ * run over an already-prepared body.
1076
+ */
1077
+ prepareChatBodyFor(raw, region) {
1078
+ const prepared = this.prepareChatBody(raw);
1079
+ let body;
1080
+ try {
1081
+ body = JSON.parse(prepared);
1082
+ } catch {
1083
+ return prepared;
1084
+ }
1085
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return prepared;
1086
+ const obj = body;
1087
+ if (obj["reasoning_effort"] === "off") delete obj["reasoning_effort"];
1088
+ if (region === "global") {
1089
+ const messages = obj["messages"];
1090
+ if (Array.isArray(messages)) {
1091
+ const first = messages[0];
1092
+ if (!(typeof first === "object" && first !== null && !Array.isArray(first) && first["role"] === "system")) messages.unshift({
1093
+ role: "system",
1094
+ content: "You are a helpful assistant."
1095
+ });
1096
+ }
1097
+ }
1098
+ return JSON.stringify(obj);
1099
+ }
1100
+ /**
926
1101
  * Parse a raw OpenAI chat body without normalising it.
927
1102
  *
928
1103
  * The compactor needs the message array as objects, while `chatStream` only
@@ -938,12 +1113,20 @@ var WorkBuddyUpstreamClient = class {
938
1113
  if (typeof body !== "object" || body === null || Array.isArray(body)) return void 0;
939
1114
  return body;
940
1115
  }
941
- /** Re-serialise `base` with a rewritten `messages` array, still normalised. */
942
- buildChatBody(base, messages) {
943
- return this.prepareChatBody(JSON.stringify({
1116
+ /**
1117
+ * Re-serialise `base` with a rewritten `messages` array, still normalised.
1118
+ *
1119
+ * `region` is optional so the composite lanes (aggregate / free) can keep
1120
+ * calling it without one; when supplied, the region-only rules of
1121
+ * {@link prepareChatBodyFor} ride along. Both in-repo callers are the
1122
+ * compaction paths, which always know their shim's region.
1123
+ */
1124
+ buildChatBody(base, messages, region) {
1125
+ const raw = JSON.stringify({
944
1126
  ...base,
945
1127
  messages
946
- }));
1128
+ });
1129
+ return region === void 0 ? this.prepareChatBody(raw) : this.prepareChatBodyFor(raw, region);
947
1130
  }
948
1131
  /**
949
1132
  * Run one NON-streaming completion and return the assistant text.
package/lib/client.js CHANGED
@@ -1340,7 +1340,14 @@ window.__ModuleLoader__.load({
1340
1340
  .dsm-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}
1341
1341
  .dsm-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
1342
1342
  .dsm-btn:disabled{opacity:.4;cursor:default;filter:saturate(.35);transform:none}
1343
- .dsm-btn:disabled:hover{transform:none;background:transparent}
1343
+ /* Only the transform is neutralised here. Every variant's hover background is
1344
+ already gated with ":not(:disabled)", so a disabled button never acquires one
1345
+ and this rule has nothing to undo — but background:transparent used to sit
1346
+ beside it, and at specificity (0,3,0) it beat .dsm-btn-primary's (0,1,0)
1347
+ fill. The primary label is a dark colour designed for a light background, so
1348
+ on a dark panel the fill went transparent, the text stayed dark, and the
1349
+ button appeared to vanish the moment the cursor reached it. */
1350
+ .dsm-btn:disabled:hover{transform:none}
1344
1351
  .dsm-btn-outline{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:transparent;font-weight:500}
1345
1352
  .dsm-btn-outline:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed);background:rgba(255,255,255,.04)}
1346
1353
  .dsm-btn-primary{background:var(--dsw-alias-label-primary,#e6e6e6);color:var(--dsw-alias-bg-layer-3,#202126)}
@@ -12188,7 +12195,7 @@ window.__ModuleLoader__.load({
12188
12195
  *
12189
12196
  * @module dsh-xdpool/version
12190
12197
  */
12191
- const POOL_VERSION = "2.4.0";
12198
+ const POOL_VERSION = "2.4.2";
12192
12199
  //#endregion
12193
12200
  //#region src/client/PoolCard.tsx
12194
12201
  /**
package/lib/index.d.ts CHANGED
@@ -469,9 +469,50 @@ export declare class WorkBuddyUpstreamClient {
469
469
  * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
470
470
  * force `stream: true` (the upstream rejects non-streaming), convert the
471
471
  * DSH `developer` role into `system` (upstream rejects `developer` with
472
- * business code 11128), and flatten `tool_choice` into its string form.
472
+ * business code 11128), rewrite fingerprinted system/assistant content the
473
+ * gateway reads as a non-official client (also 11128), and flatten
474
+ * `tool_choice` into its string form.
475
+ *
476
+ * This is the REGION-BLIND half. Everything here is measured good on both
477
+ * gateways, so it runs before the region is known — the pool lane picks its
478
+ * account after this call. The two region-only rules live in
479
+ * {@link prepareChatBodyFor} instead; see its comment for why they cannot be
480
+ * folded in here.
473
481
  */
474
482
  prepareChatBody(raw: string): string;
483
+ /**
484
+ * The region-only half of body normalisation, applied once the account is
485
+ * known.
486
+ *
487
+ * WHY IT IS SEPARATE
488
+ *
489
+ * The pool lane calls {@link prepareChatBody} BEFORE it picks an account —
490
+ * the body has to be ready for whichever account wins. So anything that
491
+ * depends on the region cannot live there. Two rules do:
492
+ *
493
+ * 1. **International requires a leading `system`.** Measured: a body whose
494
+ * first message is `user` answers `400 / 11128 "first message is not
495
+ * system prompt"` on `www.workbuddy.ai`, while the identical body is
496
+ * accepted on the domestic gateway. Same code, different cause — the
497
+ * domestic gateway uses 11128 for the `developer` role and for the
498
+ * content fingerprint, which is why nothing may branch on the code alone.
499
+ * A placeholder assistant prompt is prepended, never merged: every
500
+ * caller-supplied message keeps its position and wording.
501
+ *
502
+ * 2. **Neither gateway accepts `reasoning_effort: "off"`.** The adapter pins
503
+ * `thinkingLevelMap.off` to the literal `'off'` so the level stays
504
+ * selectable, and pi-ai emits that value for any request carrying no
505
+ * explicit level — so "Default" and "Off" produce the same body.
506
+ * Measured: the international GPT family answers `400 / 11133`
507
+ * (`extError.param === 'reasoning.effort'`), and the domestic gateway
508
+ * rejects it on models that declare `onlyReasoning`. Omission is the only
509
+ * spelling measured good everywhere; a literal `'none'` is NOT a safe
510
+ * substitute (accepted by the GPT-5.6 family, rejected by others).
511
+ *
512
+ * Both rules are no-ops on a body that already complies, so this is safe to
513
+ * run over an already-prepared body.
514
+ */
515
+ prepareChatBodyFor(raw: string, region: WorkBuddyRegion): string;
475
516
  /**
476
517
  * Parse a raw OpenAI chat body without normalising it.
477
518
  *
@@ -479,8 +520,15 @@ export declare class WorkBuddyUpstreamClient {
479
520
  * accepts the serialised string form.
480
521
  */
481
522
  parseChatBody(raw: string): Record<string, unknown> | undefined;
482
- /** Re-serialise `base` with a rewritten `messages` array, still normalised. */
483
- buildChatBody(base: Record<string, unknown>, messages: readonly ChatMessage[]): string;
523
+ /**
524
+ * Re-serialise `base` with a rewritten `messages` array, still normalised.
525
+ *
526
+ * `region` is optional so the composite lanes (aggregate / free) can keep
527
+ * calling it without one; when supplied, the region-only rules of
528
+ * {@link prepareChatBodyFor} ride along. Both in-repo callers are the
529
+ * compaction paths, which always know their shim's region.
530
+ */
531
+ buildChatBody(base: Record<string, unknown>, messages: readonly ChatMessage[], region?: WorkBuddyRegion): string;
484
532
  /**
485
533
  * Run one NON-streaming completion and return the assistant text.
486
534
  *
package/lib/index.js CHANGED
@@ -329,6 +329,117 @@ const CHAT_IDENTITY_HEADERS = Object.freeze({
329
329
  "X-IDE-Version": "5.5.6"
330
330
  });
331
331
  /**
332
+ * System-prompt strings the gateway treats as evidence of a non-official
333
+ * client, and the rewriting that clears the check.
334
+ *
335
+ * WHY THIS EXISTS
336
+ *
337
+ * `code 11128 Illegal API invocation from an unapproved channel` has a SECOND
338
+ * trigger, separate from the conversation-identity headers below: the gateway
339
+ * matches the CONTENT of system messages against a list of strings that identify
340
+ * other agents' clients. The first-party DSH system-prompt plugin injects
341
+ * `You are an AI agent powered by DeepSeek Harness.` as the opening line of
342
+ * every conversation, so every request from an unmodified install is rejected —
343
+ * regardless of which account in the pool is used, which is why the reported
344
+ * symptom was a 400 on the first account with no retry.
345
+ *
346
+ * Boundary, measured against the live domestic gateway (429 = accepted,
347
+ * 400/11128 = blocked):
348
+ * - the match is a literal substring, BUT only near the START of a system
349
+ * message: the sentence blocked a longer prompt when it was the first line,
350
+ * and passed when one plain line was prepended to it ("trailing text after
351
+ * it" above is the same finding from the other side).
352
+ * - it is case-insensitive (`you are an ai agent powered by deepseek harness`
353
+ * is blocked too).
354
+ * - the scanned roles are `system` and `assistant`: the same sentence in a
355
+ * `user` or `tool` message passes, and it is blocked in a second system
356
+ * message too. 2.4.1 recorded this as system-only; re-measured
357
+ * 2026-10-21, an `assistant` message carrying the sentence 400s as well,
358
+ * so `prepareChatBody` rewrites both roles.
359
+ * - `deepseek harness` alone, or `powered by deepseek` alone, passes — the
360
+ * whole phrase is what matches.
361
+ * - replacing `powered by` with `running on` passes, which is the rewrite
362
+ * below: it keeps the meaning for the model and shares no substring with
363
+ * the fingerprint.
364
+ *
365
+ * A prepended blank line would equally defeat a start-anchored check, but it
366
+ * would leave the banned string in the body: the list is server-side and may
367
+ * lose its anchor tomorrow. Rewriting the phrase is what the report's repro
368
+ * verified, so that is what this does.
369
+ *
370
+ * A SECOND FINGERPRINT (measured against the live domestic gateway 2026-10-21)
371
+ *
372
+ * Once the sentence above was rewritten, requests still 400/11128'd, and the
373
+ * blocker turned out to be another region of the same DSH system prompt: the
374
+ * runtime-context sentence pair
375
+ *
376
+ * You are interacting with the user through the DeepSeek Harness Web GUI
377
+ * whose URL the runtime context names. When the user refers to "this page",
378
+ * "this GUI", or "this app" without naming another target, they mean this GUI.
379
+ *
380
+ * The pair is load-bearing: either sentence alone 200s, and so does the pair
381
+ * with any small edit inside it — so this is a second CONTENT fingerprint, not
382
+ * a prompt-length rule and not another header rule. Renaming the product's
383
+ * "Web GUI" to "web interface" is the smallest edit that keeps the meaning for
384
+ * the model and clears the check; it was verified at 200 against both a main
385
+ * agent system prompt and a subagent system prompt, on the same account that
386
+ * reproduced the 400.
387
+ *
388
+ * The same window in an `assistant` message also answers 400, and 200 after
389
+ * this rewrite; `user` and `tool` messages are not scanned. That is why the
390
+ * rewrite is applied to assistant history too — one past reply carrying a
391
+ * fingerprint would otherwise fail every later request in the conversation.
392
+ *
393
+ * The list is an ARRAY because these fingerprints are appended to over time; a
394
+ * future rejection should be one more row here, not a new code path.
395
+ */
396
+ const BANNED_SYSTEM_PHRASES = Object.freeze([[/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"], [/DeepSeek Harness Web GUI/gi, "DeepSeek Harness web interface"]]);
397
+ /**
398
+ * Rewrite fingerprinted system/assistant content, leaving everything else
399
+ * byte-identical.
400
+ *
401
+ * Message content arrives as either a plain string or an array of content blocks
402
+ * (an OpenAI-compatible body allows both), so both shapes are handled — fixing
403
+ * only the string form would miss every request that carries an image or a
404
+ * cache-control block.
405
+ *
406
+ * Returns the SAME reference when nothing matched: a long conversation's system
407
+ * prompt can be large, and paying for a deep copy to change nothing is waste.
408
+ * Only matched `text` blocks are rebuilt; image and cache blocks pass through
409
+ * untouched so the request shape survives.
410
+ */
411
+ function sanitizeSystemContent(content) {
412
+ if (typeof content === "string") return sanitizeSystemText(content);
413
+ if (!Array.isArray(content)) return content;
414
+ let changed = false;
415
+ const next = content.map((block) => {
416
+ if (typeof block !== "object" || block === null || Array.isArray(block)) return block;
417
+ const record = block;
418
+ const text = record["text"];
419
+ if (typeof text !== "string") return block;
420
+ const cleaned = sanitizeSystemText(text);
421
+ if (cleaned === text) return block;
422
+ changed = true;
423
+ return {
424
+ ...record,
425
+ text: cleaned
426
+ };
427
+ });
428
+ return changed ? next : content;
429
+ }
430
+ /**
431
+ * Apply every banned-phrase rewrite to one string.
432
+ *
433
+ * The RegExp is rebuilt per call on purpose: a shared `g`-flagged instance
434
+ * carries `lastIndex` between calls, so the second request in a conversation
435
+ * could skip a match the first one caught.
436
+ */
437
+ function sanitizeSystemText(text) {
438
+ let out = text;
439
+ for (const [pattern, replacement] of BANNED_SYSTEM_PHRASES) out = out.replace(new RegExp(pattern.source, pattern.flags), replacement);
440
+ return out;
441
+ }
442
+ /**
332
443
  * The conversation-identity headers the gateway's channel check demands.
333
444
  *
334
445
  * WHY THIS EXISTS
@@ -1038,7 +1149,15 @@ var WorkBuddyUpstreamClient = class {
1038
1149
  * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
1039
1150
  * force `stream: true` (the upstream rejects non-streaming), convert the
1040
1151
  * DSH `developer` role into `system` (upstream rejects `developer` with
1041
- * business code 11128), and flatten `tool_choice` into its string form.
1152
+ * business code 11128), rewrite fingerprinted system/assistant content the
1153
+ * gateway reads as a non-official client (also 11128), and flatten
1154
+ * `tool_choice` into its string form.
1155
+ *
1156
+ * This is the REGION-BLIND half. Everything here is measured good on both
1157
+ * gateways, so it runs before the region is known — the pool lane picks its
1158
+ * account after this call. The two region-only rules live in
1159
+ * {@link prepareChatBodyFor} instead; see its comment for why they cannot be
1160
+ * folded in here.
1042
1161
  */
1043
1162
  prepareChatBody(raw) {
1044
1163
  let body;
@@ -1057,6 +1176,7 @@ var WorkBuddyUpstreamClient = class {
1057
1176
  if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
1058
1177
  const message = value;
1059
1178
  if (message["role"] === "developer") message["role"] = "system";
1179
+ if (message["role"] === "system" || message["role"] === "assistant") message["content"] = sanitizeSystemContent(message["content"]);
1060
1180
  }
1061
1181
  const choice = obj["tool_choice"];
1062
1182
  if (typeof choice === "string") {
@@ -1083,6 +1203,61 @@ var WorkBuddyUpstreamClient = class {
1083
1203
  return JSON.stringify(obj);
1084
1204
  }
1085
1205
  /**
1206
+ * The region-only half of body normalisation, applied once the account is
1207
+ * known.
1208
+ *
1209
+ * WHY IT IS SEPARATE
1210
+ *
1211
+ * The pool lane calls {@link prepareChatBody} BEFORE it picks an account —
1212
+ * the body has to be ready for whichever account wins. So anything that
1213
+ * depends on the region cannot live there. Two rules do:
1214
+ *
1215
+ * 1. **International requires a leading `system`.** Measured: a body whose
1216
+ * first message is `user` answers `400 / 11128 "first message is not
1217
+ * system prompt"` on `www.workbuddy.ai`, while the identical body is
1218
+ * accepted on the domestic gateway. Same code, different cause — the
1219
+ * domestic gateway uses 11128 for the `developer` role and for the
1220
+ * content fingerprint, which is why nothing may branch on the code alone.
1221
+ * A placeholder assistant prompt is prepended, never merged: every
1222
+ * caller-supplied message keeps its position and wording.
1223
+ *
1224
+ * 2. **Neither gateway accepts `reasoning_effort: "off"`.** The adapter pins
1225
+ * `thinkingLevelMap.off` to the literal `'off'` so the level stays
1226
+ * selectable, and pi-ai emits that value for any request carrying no
1227
+ * explicit level — so "Default" and "Off" produce the same body.
1228
+ * Measured: the international GPT family answers `400 / 11133`
1229
+ * (`extError.param === 'reasoning.effort'`), and the domestic gateway
1230
+ * rejects it on models that declare `onlyReasoning`. Omission is the only
1231
+ * spelling measured good everywhere; a literal `'none'` is NOT a safe
1232
+ * substitute (accepted by the GPT-5.6 family, rejected by others).
1233
+ *
1234
+ * Both rules are no-ops on a body that already complies, so this is safe to
1235
+ * run over an already-prepared body.
1236
+ */
1237
+ prepareChatBodyFor(raw, region) {
1238
+ const prepared = this.prepareChatBody(raw);
1239
+ let body;
1240
+ try {
1241
+ body = JSON.parse(prepared);
1242
+ } catch {
1243
+ return prepared;
1244
+ }
1245
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return prepared;
1246
+ const obj = body;
1247
+ if (obj["reasoning_effort"] === "off") delete obj["reasoning_effort"];
1248
+ if (region === "global") {
1249
+ const messages = obj["messages"];
1250
+ if (Array.isArray(messages)) {
1251
+ const first = messages[0];
1252
+ if (!(typeof first === "object" && first !== null && !Array.isArray(first) && first["role"] === "system")) messages.unshift({
1253
+ role: "system",
1254
+ content: "You are a helpful assistant."
1255
+ });
1256
+ }
1257
+ }
1258
+ return JSON.stringify(obj);
1259
+ }
1260
+ /**
1086
1261
  * Parse a raw OpenAI chat body without normalising it.
1087
1262
  *
1088
1263
  * The compactor needs the message array as objects, while `chatStream` only
@@ -1098,12 +1273,20 @@ var WorkBuddyUpstreamClient = class {
1098
1273
  if (typeof body !== "object" || body === null || Array.isArray(body)) return void 0;
1099
1274
  return body;
1100
1275
  }
1101
- /** Re-serialise `base` with a rewritten `messages` array, still normalised. */
1102
- buildChatBody(base, messages) {
1103
- return this.prepareChatBody(JSON.stringify({
1276
+ /**
1277
+ * Re-serialise `base` with a rewritten `messages` array, still normalised.
1278
+ *
1279
+ * `region` is optional so the composite lanes (aggregate / free) can keep
1280
+ * calling it without one; when supplied, the region-only rules of
1281
+ * {@link prepareChatBodyFor} ride along. Both in-repo callers are the
1282
+ * compaction paths, which always know their shim's region.
1283
+ */
1284
+ buildChatBody(base, messages, region) {
1285
+ const raw = JSON.stringify({
1104
1286
  ...base,
1105
1287
  messages
1106
- }));
1288
+ });
1289
+ return region === void 0 ? this.prepareChatBody(raw) : this.prepareChatBodyFor(raw, region);
1107
1290
  }
1108
1291
  /**
1109
1292
  * Run one NON-streaming completion and return the assistant text.
@@ -13440,6 +13623,24 @@ function freeFetch(url, init = {}) {
13440
13623
  });
13441
13624
  }
13442
13625
  /**
13626
+ * Add the one fact a 429 on this lane can never convey on its own.
13627
+ *
13628
+ * The free tier meters quota PER UPSTREAM SESSION, not per key and not per
13629
+ * account — the gateway's own sentence is "the provider asked to wait 74558s",
13630
+ * i.e. twenty hours, which reads like a ban on the whole provider. It is not:
13631
+ * a different conversation maps to a different session and is served
13632
+ * immediately. Saying so costs one clause and turns a message that looks like
13633
+ * "this lane is over" into one that tells the user what to do next.
13634
+ *
13635
+ * Only a rate class gets the hint: a dead credential or a missing model has
13636
+ * nothing to do with the session budget, and adding the clause there would
13637
+ * invite the user to retry something that cannot succeed.
13638
+ */
13639
+ function withSessionQuotaHint(error, retryAfterMs) {
13640
+ if (!(error instanceof AggregateError) || error.kind !== "soft_rate" || retryAfterMs === void 0 || error.message.includes("per conversation")) return error;
13641
+ return new AggregateError(error.kind, `${error.message} — this quota is counted per conversation on the free tier, so a new conversation (or another model) starts with its own budget`, error.status);
13642
+ }
13643
+ /**
13443
13644
  * POST one streaming turn and hand back chat-shaped deltas.
13444
13645
  *
13445
13646
  * The shape of the ANSWER decides how it is read, never its Content-Type: a front
@@ -13483,7 +13684,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
13483
13684
  const retryAfter = retryAfterMs(response);
13484
13685
  if (!response.ok) {
13485
13686
  const refusal = await response.text().catch(() => "");
13486
- throw classifyUpstream(response.status, refusal, retryAfter, "");
13687
+ throw withSessionQuotaHint(classifyUpstream(response.status, refusal, retryAfter, ""), retryAfter);
13487
13688
  }
13488
13689
  if (response.body === null) throw new AggregateError("server", "the free lane returned no body", response.status);
13489
13690
  let events;
@@ -13516,7 +13717,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
13516
13717
  } catch {
13517
13718
  continue;
13518
13719
  }
13519
- if (isRecord$1(decoded) && (decoded.error !== void 0 || decoded.type === "error")) throw classifyUpstream(response.status, event, retryAfter, "");
13720
+ if (isRecord$1(decoded) && (decoded.error !== void 0 || decoded.type === "error")) throw withSessionQuotaHint(classifyUpstream(response.status, event, retryAfter, ""), retryAfter);
13520
13721
  const translated = chatChunksFor(wire, decoded, tools);
13521
13722
  for (const delta of translated.frames) hooks.onDelta(delta);
13522
13723
  if (translated.usage !== void 0) usage = translated.usage;
@@ -17450,7 +17651,10 @@ async function probeModelConnectivity(llm, provider, model, options = {}) {
17450
17651
  system: "You are being called by a connection test. Answer in one short word.",
17451
17652
  messages: [{
17452
17653
  role: "user",
17453
- content: PROBE_PROMPT
17654
+ content: [{
17655
+ type: "text",
17656
+ text: PROBE_PROMPT
17657
+ }]
17454
17658
  }],
17455
17659
  maxTokens: 24,
17456
17660
  signal: timeout.signal
@@ -18329,7 +18533,7 @@ function createWorkBuddyShim(options) {
18329
18533
  };
18330
18534
  continue;
18331
18535
  }
18332
- const prepared = client.prepareChatBody(raw);
18536
+ const prepared = client.prepareChatBodyFor(raw, region ?? "cn");
18333
18537
  const result = await client.chatStream(account.credential, prepared, signal, conversationKey);
18334
18538
  if (result.ok) {
18335
18539
  const turnId = randomBytes(8).toString("hex");
@@ -18453,7 +18657,7 @@ function createWorkBuddyShim(options) {
18453
18657
  writeOpenAIError(res, 503, `${lane}_unavailable`, lane === "free" ? `${modelId} is not available right now; the free lane's model listing is refreshed on the XD Pool card` : `${modelId} is not available right now; sign in to the co-paid lane on the XD Pool card`);
18454
18658
  return;
18455
18659
  }
18456
- const prepared = client.prepareChatBody(raw);
18660
+ const prepared = client.prepareChatBodyFor(raw, region ?? "cn");
18457
18661
  const conversationKey = conversationKeyOf(raw);
18458
18662
  const tried = [];
18459
18663
  let last;
@@ -18750,7 +18954,7 @@ async function recoverFromContextOverrun(options) {
18750
18954
  ...parsed,
18751
18955
  stream: true,
18752
18956
  max_tokens: Math.max(256, Math.floor(budget / 2))
18753
- }, request);
18957
+ }, request, region ?? "cn");
18754
18958
  return await client.completeChat(summariser.credential, body, signal ?? controller.signal, conversationKeyOf(raw));
18755
18959
  } }, controller.signal);
18756
18960
  compacted = outcome.messages;
@@ -18765,7 +18969,7 @@ async function recoverFromContextOverrun(options) {
18765
18969
  ok: false,
18766
18970
  detail: compactionDetail === "" ? "compaction could not reduce the prompt" : compactionDetail
18767
18971
  };
18768
- const retryBody = client.buildChatBody(parsed, compacted);
18972
+ const retryBody = client.buildChatBody(parsed, compacted, region ?? "cn");
18769
18973
  const tried = [];
18770
18974
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
18771
18975
  if (controller.signal.aborted) return {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-xdpool",
3
3
  "displayName": "DSH XD Pool",
4
4
  "description": "One DSH model pool for every free quota you have: merge all locally signed-in WorkBuddy accounts with auto-failover, sign in to 13 vendors by QR/web, aggregate 76 vendors' free tiers, plus a usage dashboard, model routing, a local OpenAI-compatible gateway and a points-automation suite.",
5
- "version": "2.4.0",
5
+ "version": "2.4.2",
6
6
  "license": "MIT",
7
7
  "author": "XDTrees",
8
8
  "homepage": "https://github.com/XDTrees/dsh-xdpool#readme",