dsh-xdpool 2.4.0 → 2.4.1

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,49 @@
1
1
  # Changelog
2
2
  本项目的所有重要变更都会记录在此文件。
3
3
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
+ ## 2.4.1 — 11128 的真正成因:system 内容指纹
5
+ > 2.4.0 之后仍有用户撞上 `400 / 11128 Illegal API invocation from an unapproved channel`。定位下来,2.4.0 的归因是错的,这一版修的是真原因。
6
+ ### 11128 不是身份头的问题,是 system 内容里的自报句
7
+ 网关按**内容指纹**判定「这是不是官方客户端发出来的」,命中串是 system 消息里的 `You are an AI agent powered by DeepSeek Harness`。它只匹配 `role === "system"`(放进 user/tool 不管),字面子串、大小写不敏感,且带起始位置倾向——放在提示词第一行会被拦,前面垫一行普通文字就放行。
8
+
9
+ 这句话由宿主的第一方插件 `@deepseek-ai/dsh-system-prompt` 固定注入,**任何未改装的安装都会 100% 命中**,与账号、密钥、渠道、UA 全部无关——这也解释了为什么它在「1 个账号失败后不再重试」:每个账号都会失败,重试没有意义。
10
+
11
+ 修法是在发送前把 system 里的这句改写成网关放行的同义句(`powered by` → `running on`),只动命中的文本块,图片/缓存块的引用原样透传;没命中时返回同一个引用,不为「什么都没改」付深拷贝代价。改写清单做成了数组,将来网关加句只是多一行。
12
+
13
+ **实测(真实国内网关,同一账号三组对照)**:
14
+ | 请求体 | 结果 |
15
+ |---|---|
16
+ | 经本插件改写 | `429 / 6004`(通过渠道校验,仅受频率限制) |
17
+ | 原样含该句(对照) | `400 / 11128` |
18
+ | system 干净(基线) | `429 / 6004` |
19
+
20
+ ### 国际网关要求首条消息是 system
21
+ `www.workbuddy.ai` 对一个以 `user` 开头的请求体回 `400 / 11128 "first message is not system prompt"`,而**同一个请求体在国内网关是被接受的**。同一个错误码,两种完全不同成因——这正是不能只按业务码分支的原因。现在国际版请求若首条不是 system,会在最前面补一条占位 system,用户自己的消息位置和措辞一个字符都不动。
22
+
23
+ ### 思考档位「关闭」会被两区同时拒绝
24
+ 适配层把 `thinkingLevelMap.off` 钉成字面量 `'off'`,而 pi-ai 对「没有显式指定档位」的请求发的就是它——所以选「默认」和选「关闭」产生了同一个请求体。两区都不接受这个值:国际版 GPT 族回 `400 / 11133`(`extError.param === 'reasoning.effort'`),国内版在声明了 `onlyReasoning` 的模型上报错。现在发送前把它删掉,让上游走自己的默认档。**省略**是唯一在各个区、各个模型上都实测通过的写法;换成字面量 `'none'` 不是安全替代。
25
+
26
+ **实测(真实双网关,同一账号前后对照)**:
27
+
28
+ | 请求 | 修复前 | 修复后 |
29
+ |---|---|---|
30
+ | 国际版,首条为 user | `400 / 11128 first message is not system prompt` | `200` |
31
+ | 国际版,`reasoning_effort: "off"` | `400 / 11133 extError.param=reasoning.effort` | `200` |
32
+
33
+ ### 免费模型限流提示:说清配额是按会话算的
34
+ 免费车道的拒绝原文是「the provider asked to wait 74558s」——二十小时,读起来像是整个免费车道被封了。实际配额**按上游会话计**,换一个会话(或换一个模型)就另有一份额度。现在限流错误的文案补上了这一句;只有限流类错误带这句,凭据失效、模型不存在之类加上去只会引导用户去做无用重试。
35
+ ### 其他
36
+ - 测试:新增 system 指纹改写 8 例 + 区域专属请求体规则 12 例 + 免费车道会话配额提示 1 例,全套 71 文件 932 用例。
4
37
  ## 2.4.0 — 会话身份头、清单不再冻结、中文路由可存
5
38
  > 这一版收的是「用着用着就报错」的那一类问题:调用被网关判成非法渠道(11128)、国际版模型清单永远停在启动那一刻、中文名字的路由点了保存却没存下、上下文选择一选就弹回去。
6
- ### 会话身份头:修掉 400 / 11128「Illegal API invocation from an unapproved channel」
7
- 网关对「这一轮对话属于谁」有一组身份头,缺一个就整轮拒绝,报 `11128 Illegal API invocation from an unapproved channel`,看起来像密钥失效,其实是新会话没自报身份。补齐官方客户端的契约:
39
+ ### 会话身份头:补齐官方客户端的契约
40
+ 网关对「这一轮对话属于谁」有一组身份头。补齐官方客户端的契约:
8
41
  - **会话稳定三头**(同一会话内不变):`X-Conversation-ID`、`X-Conversation-Request-ID`、`acp-connection-id`。
9
42
  - **每请求两头**(逐次随机):`X-Conversation-Message-ID`、`X-Request-ID`。
10
43
  - 另附 `X-Agent-Intent`、`X-Agent-Purpose`、`x-codebuddy-request`、`X-Private-Data`、`X-Product` 与 W3C/B3 追踪族。
11
44
 
12
- 实测口径:三个稳定头缺任一个即 400 11128,齐全即 200;值本身不校验(非 UUID 字符串也过)。池车道(WorkBuddy 国内/国际)与扫码渠道的 `send` 两条路径都已接上。
45
+ > **口径更正(见 2.4.1)**:本版曾把 `400 / 11128` 归因于「缺会话身份头」。这个归因是错的——实测国内网关根本不检查这组头(不带任何身份头、system 干净时正常放行)。身份头照官方契约补齐没有坏处,但 11128 的真正成因是 system 内容里的自报句,已在 2.4.1 修掉。
46
+
13
47
  顺带修掉同源的一处:`role: "developer"` 的消息在国内网关同样触发 11128,现统一上送为 `system`。
14
48
  ### 国际版模型清单:不再冻结在启动那一刻
15
49
  `createProvider` 的 `models` 是在创建 provider 时快照下来的数组,而原来的 `invalidate()` 只重排了 profile、复用了同一个 provider 对象,宿主按对象身份记忆化 `current()`,于是**新拿到的模型清单永远送不到选择器**——表现是「重新获取模型」点了没反应、国际版清单停在开机那一刻。现在每次 `invalidate()` 重建 provider 与 profile 两个对象,并通过 adapter handle 的 `replace()` 重新登记路由,由宿主自己发出 `llm/adapters-updated`。
package/lib/bin.js CHANGED
@@ -278,6 +278,90 @@ 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
+ * - only `role: "system"` is inspected: the same sentence in a `user` or
304
+ * `tool` message passes, and it is blocked in a second system message too.
305
+ * - `deepseek harness` alone, or `powered by deepseek` alone, passes — the
306
+ * whole phrase is what matches.
307
+ * - replacing `powered by` with `running on` passes, which is the rewrite
308
+ * below: it keeps the meaning for the model and shares no substring with
309
+ * the fingerprint.
310
+ *
311
+ * A prepended blank line would equally defeat a start-anchored check, but it
312
+ * would leave the banned string in the body: the list is server-side and may
313
+ * lose its anchor tomorrow. Rewriting the phrase is what the report's repro
314
+ * verified, so that is what this does.
315
+ *
316
+ * The list is an ARRAY because these fingerprints are appended to over time; a
317
+ * future rejection should be one more row here, not a new code path.
318
+ */
319
+ const BANNED_SYSTEM_PHRASES = Object.freeze([[/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"]]);
320
+ /**
321
+ * Rewrite fingerprinted system content, leaving everything else byte-identical.
322
+ *
323
+ * System content arrives as either a plain string or an array of content blocks
324
+ * (an OpenAI-compatible body allows both), so both shapes are handled — fixing
325
+ * only the string form would miss every request that carries an image or a
326
+ * cache-control block.
327
+ *
328
+ * Returns the SAME reference when nothing matched: a long conversation's system
329
+ * prompt can be large, and paying for a deep copy to change nothing is waste.
330
+ * Only matched `text` blocks are rebuilt; image and cache blocks pass through
331
+ * untouched so the request shape survives.
332
+ */
333
+ function sanitizeSystemContent(content) {
334
+ if (typeof content === "string") return sanitizeSystemText(content);
335
+ if (!Array.isArray(content)) return content;
336
+ let changed = false;
337
+ const next = content.map((block) => {
338
+ if (typeof block !== "object" || block === null || Array.isArray(block)) return block;
339
+ const record = block;
340
+ const text = record["text"];
341
+ if (typeof text !== "string") return block;
342
+ const cleaned = sanitizeSystemText(text);
343
+ if (cleaned === text) return block;
344
+ changed = true;
345
+ return {
346
+ ...record,
347
+ text: cleaned
348
+ };
349
+ });
350
+ return changed ? next : content;
351
+ }
352
+ /**
353
+ * Apply every banned-phrase rewrite to one string.
354
+ *
355
+ * The RegExp is rebuilt per call on purpose: a shared `g`-flagged instance
356
+ * carries `lastIndex` between calls, so the second request in a conversation
357
+ * could skip a match the first one caught.
358
+ */
359
+ function sanitizeSystemText(text) {
360
+ let out = text;
361
+ for (const [pattern, replacement] of BANNED_SYSTEM_PHRASES) out = out.replace(new RegExp(pattern.source, pattern.flags), replacement);
362
+ return out;
363
+ }
364
+ /**
281
365
  * The conversation-identity headers the gateway's channel check demands.
282
366
  *
283
367
  * WHY THIS EXISTS
@@ -878,7 +962,15 @@ var WorkBuddyUpstreamClient = class {
878
962
  * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
879
963
  * force `stream: true` (the upstream rejects non-streaming), convert the
880
964
  * DSH `developer` role into `system` (upstream rejects `developer` with
881
- * business code 11128), and flatten `tool_choice` into its string form.
965
+ * business code 11128), rewrite system content the gateway fingerprints as a
966
+ * non-official client (also 11128), and flatten `tool_choice` into its string
967
+ * form.
968
+ *
969
+ * This is the REGION-BLIND half. Everything here is measured good on both
970
+ * gateways, so it runs before the region is known — the pool lane picks its
971
+ * account after this call. The two region-only rules live in
972
+ * {@link prepareChatBodyFor} instead; see its comment for why they cannot be
973
+ * folded in here.
882
974
  */
883
975
  prepareChatBody(raw) {
884
976
  let body;
@@ -897,6 +989,7 @@ var WorkBuddyUpstreamClient = class {
897
989
  if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
898
990
  const message = value;
899
991
  if (message["role"] === "developer") message["role"] = "system";
992
+ if (message["role"] === "system") message["content"] = sanitizeSystemContent(message["content"]);
900
993
  }
901
994
  const choice = obj["tool_choice"];
902
995
  if (typeof choice === "string") {
@@ -923,6 +1016,61 @@ var WorkBuddyUpstreamClient = class {
923
1016
  return JSON.stringify(obj);
924
1017
  }
925
1018
  /**
1019
+ * The region-only half of body normalisation, applied once the account is
1020
+ * known.
1021
+ *
1022
+ * WHY IT IS SEPARATE
1023
+ *
1024
+ * The pool lane calls {@link prepareChatBody} BEFORE it picks an account —
1025
+ * the body has to be ready for whichever account wins. So anything that
1026
+ * depends on the region cannot live there. Two rules do:
1027
+ *
1028
+ * 1. **International requires a leading `system`.** Measured: a body whose
1029
+ * first message is `user` answers `400 / 11128 "first message is not
1030
+ * system prompt"` on `www.workbuddy.ai`, while the identical body is
1031
+ * accepted on the domestic gateway. Same code, different cause — the
1032
+ * domestic gateway uses 11128 for the `developer` role and for the
1033
+ * content fingerprint, which is why nothing may branch on the code alone.
1034
+ * A placeholder assistant prompt is prepended, never merged: every
1035
+ * caller-supplied message keeps its position and wording.
1036
+ *
1037
+ * 2. **Neither gateway accepts `reasoning_effort: "off"`.** The adapter pins
1038
+ * `thinkingLevelMap.off` to the literal `'off'` so the level stays
1039
+ * selectable, and pi-ai emits that value for any request carrying no
1040
+ * explicit level — so "Default" and "Off" produce the same body.
1041
+ * Measured: the international GPT family answers `400 / 11133`
1042
+ * (`extError.param === 'reasoning.effort'`), and the domestic gateway
1043
+ * rejects it on models that declare `onlyReasoning`. Omission is the only
1044
+ * spelling measured good everywhere; a literal `'none'` is NOT a safe
1045
+ * substitute (accepted by the GPT-5.6 family, rejected by others).
1046
+ *
1047
+ * Both rules are no-ops on a body that already complies, so this is safe to
1048
+ * run over an already-prepared body.
1049
+ */
1050
+ prepareChatBodyFor(raw, region) {
1051
+ const prepared = this.prepareChatBody(raw);
1052
+ let body;
1053
+ try {
1054
+ body = JSON.parse(prepared);
1055
+ } catch {
1056
+ return prepared;
1057
+ }
1058
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return prepared;
1059
+ const obj = body;
1060
+ if (obj["reasoning_effort"] === "off") delete obj["reasoning_effort"];
1061
+ if (region === "global") {
1062
+ const messages = obj["messages"];
1063
+ if (Array.isArray(messages)) {
1064
+ const first = messages[0];
1065
+ if (!(typeof first === "object" && first !== null && !Array.isArray(first) && first["role"] === "system")) messages.unshift({
1066
+ role: "system",
1067
+ content: "You are a helpful assistant."
1068
+ });
1069
+ }
1070
+ }
1071
+ return JSON.stringify(obj);
1072
+ }
1073
+ /**
926
1074
  * Parse a raw OpenAI chat body without normalising it.
927
1075
  *
928
1076
  * The compactor needs the message array as objects, while `chatStream` only
@@ -938,12 +1086,20 @@ var WorkBuddyUpstreamClient = class {
938
1086
  if (typeof body !== "object" || body === null || Array.isArray(body)) return void 0;
939
1087
  return body;
940
1088
  }
941
- /** Re-serialise `base` with a rewritten `messages` array, still normalised. */
942
- buildChatBody(base, messages) {
943
- return this.prepareChatBody(JSON.stringify({
1089
+ /**
1090
+ * Re-serialise `base` with a rewritten `messages` array, still normalised.
1091
+ *
1092
+ * `region` is optional so the composite lanes (aggregate / free) can keep
1093
+ * calling it without one; when supplied, the region-only rules of
1094
+ * {@link prepareChatBodyFor} ride along. Both in-repo callers are the
1095
+ * compaction paths, which always know their shim's region.
1096
+ */
1097
+ buildChatBody(base, messages, region) {
1098
+ const raw = JSON.stringify({
944
1099
  ...base,
945
1100
  messages
946
- }));
1101
+ });
1102
+ return region === void 0 ? this.prepareChatBody(raw) : this.prepareChatBodyFor(raw, region);
947
1103
  }
948
1104
  /**
949
1105
  * Run one NON-streaming completion and return the assistant text.
package/lib/client.js CHANGED
@@ -12188,7 +12188,7 @@ window.__ModuleLoader__.load({
12188
12188
  *
12189
12189
  * @module dsh-xdpool/version
12190
12190
  */
12191
- const POOL_VERSION = "2.4.0";
12191
+ const POOL_VERSION = "2.4.1";
12192
12192
  //#endregion
12193
12193
  //#region src/client/PoolCard.tsx
12194
12194
  /**
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 system content the gateway fingerprints as a
473
+ * non-official client (also 11128), and flatten `tool_choice` into its string
474
+ * 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,90 @@ 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
+ * - only `role: "system"` is inspected: the same sentence in a `user` or
355
+ * `tool` message passes, and it is blocked in a second system message too.
356
+ * - `deepseek harness` alone, or `powered by deepseek` alone, passes — the
357
+ * whole phrase is what matches.
358
+ * - replacing `powered by` with `running on` passes, which is the rewrite
359
+ * below: it keeps the meaning for the model and shares no substring with
360
+ * the fingerprint.
361
+ *
362
+ * A prepended blank line would equally defeat a start-anchored check, but it
363
+ * would leave the banned string in the body: the list is server-side and may
364
+ * lose its anchor tomorrow. Rewriting the phrase is what the report's repro
365
+ * verified, so that is what this does.
366
+ *
367
+ * The list is an ARRAY because these fingerprints are appended to over time; a
368
+ * future rejection should be one more row here, not a new code path.
369
+ */
370
+ const BANNED_SYSTEM_PHRASES = Object.freeze([[/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"]]);
371
+ /**
372
+ * Rewrite fingerprinted system content, leaving everything else byte-identical.
373
+ *
374
+ * System content arrives as either a plain string or an array of content blocks
375
+ * (an OpenAI-compatible body allows both), so both shapes are handled — fixing
376
+ * only the string form would miss every request that carries an image or a
377
+ * cache-control block.
378
+ *
379
+ * Returns the SAME reference when nothing matched: a long conversation's system
380
+ * prompt can be large, and paying for a deep copy to change nothing is waste.
381
+ * Only matched `text` blocks are rebuilt; image and cache blocks pass through
382
+ * untouched so the request shape survives.
383
+ */
384
+ function sanitizeSystemContent(content) {
385
+ if (typeof content === "string") return sanitizeSystemText(content);
386
+ if (!Array.isArray(content)) return content;
387
+ let changed = false;
388
+ const next = content.map((block) => {
389
+ if (typeof block !== "object" || block === null || Array.isArray(block)) return block;
390
+ const record = block;
391
+ const text = record["text"];
392
+ if (typeof text !== "string") return block;
393
+ const cleaned = sanitizeSystemText(text);
394
+ if (cleaned === text) return block;
395
+ changed = true;
396
+ return {
397
+ ...record,
398
+ text: cleaned
399
+ };
400
+ });
401
+ return changed ? next : content;
402
+ }
403
+ /**
404
+ * Apply every banned-phrase rewrite to one string.
405
+ *
406
+ * The RegExp is rebuilt per call on purpose: a shared `g`-flagged instance
407
+ * carries `lastIndex` between calls, so the second request in a conversation
408
+ * could skip a match the first one caught.
409
+ */
410
+ function sanitizeSystemText(text) {
411
+ let out = text;
412
+ for (const [pattern, replacement] of BANNED_SYSTEM_PHRASES) out = out.replace(new RegExp(pattern.source, pattern.flags), replacement);
413
+ return out;
414
+ }
415
+ /**
332
416
  * The conversation-identity headers the gateway's channel check demands.
333
417
  *
334
418
  * WHY THIS EXISTS
@@ -1038,7 +1122,15 @@ var WorkBuddyUpstreamClient = class {
1038
1122
  * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
1039
1123
  * force `stream: true` (the upstream rejects non-streaming), convert the
1040
1124
  * DSH `developer` role into `system` (upstream rejects `developer` with
1041
- * business code 11128), and flatten `tool_choice` into its string form.
1125
+ * business code 11128), rewrite system content the gateway fingerprints as a
1126
+ * non-official client (also 11128), and flatten `tool_choice` into its string
1127
+ * form.
1128
+ *
1129
+ * This is the REGION-BLIND half. Everything here is measured good on both
1130
+ * gateways, so it runs before the region is known — the pool lane picks its
1131
+ * account after this call. The two region-only rules live in
1132
+ * {@link prepareChatBodyFor} instead; see its comment for why they cannot be
1133
+ * folded in here.
1042
1134
  */
1043
1135
  prepareChatBody(raw) {
1044
1136
  let body;
@@ -1057,6 +1149,7 @@ var WorkBuddyUpstreamClient = class {
1057
1149
  if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
1058
1150
  const message = value;
1059
1151
  if (message["role"] === "developer") message["role"] = "system";
1152
+ if (message["role"] === "system") message["content"] = sanitizeSystemContent(message["content"]);
1060
1153
  }
1061
1154
  const choice = obj["tool_choice"];
1062
1155
  if (typeof choice === "string") {
@@ -1083,6 +1176,61 @@ var WorkBuddyUpstreamClient = class {
1083
1176
  return JSON.stringify(obj);
1084
1177
  }
1085
1178
  /**
1179
+ * The region-only half of body normalisation, applied once the account is
1180
+ * known.
1181
+ *
1182
+ * WHY IT IS SEPARATE
1183
+ *
1184
+ * The pool lane calls {@link prepareChatBody} BEFORE it picks an account —
1185
+ * the body has to be ready for whichever account wins. So anything that
1186
+ * depends on the region cannot live there. Two rules do:
1187
+ *
1188
+ * 1. **International requires a leading `system`.** Measured: a body whose
1189
+ * first message is `user` answers `400 / 11128 "first message is not
1190
+ * system prompt"` on `www.workbuddy.ai`, while the identical body is
1191
+ * accepted on the domestic gateway. Same code, different cause — the
1192
+ * domestic gateway uses 11128 for the `developer` role and for the
1193
+ * content fingerprint, which is why nothing may branch on the code alone.
1194
+ * A placeholder assistant prompt is prepended, never merged: every
1195
+ * caller-supplied message keeps its position and wording.
1196
+ *
1197
+ * 2. **Neither gateway accepts `reasoning_effort: "off"`.** The adapter pins
1198
+ * `thinkingLevelMap.off` to the literal `'off'` so the level stays
1199
+ * selectable, and pi-ai emits that value for any request carrying no
1200
+ * explicit level — so "Default" and "Off" produce the same body.
1201
+ * Measured: the international GPT family answers `400 / 11133`
1202
+ * (`extError.param === 'reasoning.effort'`), and the domestic gateway
1203
+ * rejects it on models that declare `onlyReasoning`. Omission is the only
1204
+ * spelling measured good everywhere; a literal `'none'` is NOT a safe
1205
+ * substitute (accepted by the GPT-5.6 family, rejected by others).
1206
+ *
1207
+ * Both rules are no-ops on a body that already complies, so this is safe to
1208
+ * run over an already-prepared body.
1209
+ */
1210
+ prepareChatBodyFor(raw, region) {
1211
+ const prepared = this.prepareChatBody(raw);
1212
+ let body;
1213
+ try {
1214
+ body = JSON.parse(prepared);
1215
+ } catch {
1216
+ return prepared;
1217
+ }
1218
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return prepared;
1219
+ const obj = body;
1220
+ if (obj["reasoning_effort"] === "off") delete obj["reasoning_effort"];
1221
+ if (region === "global") {
1222
+ const messages = obj["messages"];
1223
+ if (Array.isArray(messages)) {
1224
+ const first = messages[0];
1225
+ if (!(typeof first === "object" && first !== null && !Array.isArray(first) && first["role"] === "system")) messages.unshift({
1226
+ role: "system",
1227
+ content: "You are a helpful assistant."
1228
+ });
1229
+ }
1230
+ }
1231
+ return JSON.stringify(obj);
1232
+ }
1233
+ /**
1086
1234
  * Parse a raw OpenAI chat body without normalising it.
1087
1235
  *
1088
1236
  * The compactor needs the message array as objects, while `chatStream` only
@@ -1098,12 +1246,20 @@ var WorkBuddyUpstreamClient = class {
1098
1246
  if (typeof body !== "object" || body === null || Array.isArray(body)) return void 0;
1099
1247
  return body;
1100
1248
  }
1101
- /** Re-serialise `base` with a rewritten `messages` array, still normalised. */
1102
- buildChatBody(base, messages) {
1103
- return this.prepareChatBody(JSON.stringify({
1249
+ /**
1250
+ * Re-serialise `base` with a rewritten `messages` array, still normalised.
1251
+ *
1252
+ * `region` is optional so the composite lanes (aggregate / free) can keep
1253
+ * calling it without one; when supplied, the region-only rules of
1254
+ * {@link prepareChatBodyFor} ride along. Both in-repo callers are the
1255
+ * compaction paths, which always know their shim's region.
1256
+ */
1257
+ buildChatBody(base, messages, region) {
1258
+ const raw = JSON.stringify({
1104
1259
  ...base,
1105
1260
  messages
1106
- }));
1261
+ });
1262
+ return region === void 0 ? this.prepareChatBody(raw) : this.prepareChatBodyFor(raw, region);
1107
1263
  }
1108
1264
  /**
1109
1265
  * Run one NON-streaming completion and return the assistant text.
@@ -13440,6 +13596,24 @@ function freeFetch(url, init = {}) {
13440
13596
  });
13441
13597
  }
13442
13598
  /**
13599
+ * Add the one fact a 429 on this lane can never convey on its own.
13600
+ *
13601
+ * The free tier meters quota PER UPSTREAM SESSION, not per key and not per
13602
+ * account — the gateway's own sentence is "the provider asked to wait 74558s",
13603
+ * i.e. twenty hours, which reads like a ban on the whole provider. It is not:
13604
+ * a different conversation maps to a different session and is served
13605
+ * immediately. Saying so costs one clause and turns a message that looks like
13606
+ * "this lane is over" into one that tells the user what to do next.
13607
+ *
13608
+ * Only a rate class gets the hint: a dead credential or a missing model has
13609
+ * nothing to do with the session budget, and adding the clause there would
13610
+ * invite the user to retry something that cannot succeed.
13611
+ */
13612
+ function withSessionQuotaHint(error, retryAfterMs) {
13613
+ if (!(error instanceof AggregateError) || error.kind !== "soft_rate" || retryAfterMs === void 0 || error.message.includes("per conversation")) return error;
13614
+ 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);
13615
+ }
13616
+ /**
13443
13617
  * POST one streaming turn and hand back chat-shaped deltas.
13444
13618
  *
13445
13619
  * The shape of the ANSWER decides how it is read, never its Content-Type: a front
@@ -13483,7 +13657,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
13483
13657
  const retryAfter = retryAfterMs(response);
13484
13658
  if (!response.ok) {
13485
13659
  const refusal = await response.text().catch(() => "");
13486
- throw classifyUpstream(response.status, refusal, retryAfter, "");
13660
+ throw withSessionQuotaHint(classifyUpstream(response.status, refusal, retryAfter, ""), retryAfter);
13487
13661
  }
13488
13662
  if (response.body === null) throw new AggregateError("server", "the free lane returned no body", response.status);
13489
13663
  let events;
@@ -13516,7 +13690,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
13516
13690
  } catch {
13517
13691
  continue;
13518
13692
  }
13519
- if (isRecord$1(decoded) && (decoded.error !== void 0 || decoded.type === "error")) throw classifyUpstream(response.status, event, retryAfter, "");
13693
+ if (isRecord$1(decoded) && (decoded.error !== void 0 || decoded.type === "error")) throw withSessionQuotaHint(classifyUpstream(response.status, event, retryAfter, ""), retryAfter);
13520
13694
  const translated = chatChunksFor(wire, decoded, tools);
13521
13695
  for (const delta of translated.frames) hooks.onDelta(delta);
13522
13696
  if (translated.usage !== void 0) usage = translated.usage;
@@ -18329,7 +18503,7 @@ function createWorkBuddyShim(options) {
18329
18503
  };
18330
18504
  continue;
18331
18505
  }
18332
- const prepared = client.prepareChatBody(raw);
18506
+ const prepared = client.prepareChatBodyFor(raw, region ?? "cn");
18333
18507
  const result = await client.chatStream(account.credential, prepared, signal, conversationKey);
18334
18508
  if (result.ok) {
18335
18509
  const turnId = randomBytes(8).toString("hex");
@@ -18453,7 +18627,7 @@ function createWorkBuddyShim(options) {
18453
18627
  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
18628
  return;
18455
18629
  }
18456
- const prepared = client.prepareChatBody(raw);
18630
+ const prepared = client.prepareChatBodyFor(raw, region ?? "cn");
18457
18631
  const conversationKey = conversationKeyOf(raw);
18458
18632
  const tried = [];
18459
18633
  let last;
@@ -18750,7 +18924,7 @@ async function recoverFromContextOverrun(options) {
18750
18924
  ...parsed,
18751
18925
  stream: true,
18752
18926
  max_tokens: Math.max(256, Math.floor(budget / 2))
18753
- }, request);
18927
+ }, request, region ?? "cn");
18754
18928
  return await client.completeChat(summariser.credential, body, signal ?? controller.signal, conversationKeyOf(raw));
18755
18929
  } }, controller.signal);
18756
18930
  compacted = outcome.messages;
@@ -18765,7 +18939,7 @@ async function recoverFromContextOverrun(options) {
18765
18939
  ok: false,
18766
18940
  detail: compactionDetail === "" ? "compaction could not reduce the prompt" : compactionDetail
18767
18941
  };
18768
- const retryBody = client.buildChatBody(parsed, compacted);
18942
+ const retryBody = client.buildChatBody(parsed, compacted, region ?? "cn");
18769
18943
  const tried = [];
18770
18944
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
18771
18945
  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.1",
6
6
  "license": "MIT",
7
7
  "author": "XDTrees",
8
8
  "homepage": "https://github.com/XDTrees/dsh-xdpool#readme",