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 +37 -3
- package/lib/bin.js +161 -5
- package/lib/client.js +1 -1
- package/lib/index.d.ts +51 -3
- package/lib/index.js +185 -11
- package/package.json +1 -1
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
|
-
###
|
|
7
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
-
/**
|
|
942
|
-
|
|
943
|
-
|
|
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.
|
|
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),
|
|
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
|
-
/**
|
|
483
|
-
|
|
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),
|
|
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
|
-
/**
|
|
1102
|
-
|
|
1103
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
5
|
+
"version": "2.4.1",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "XDTrees",
|
|
8
8
|
"homepage": "https://github.com/XDTrees/dsh-xdpool#readme",
|