dsh-connect-workbuddy 4.14.0 → 4.15.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 +133 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +65 -49
- package/lib/{host-heartbeat-B1I95fKS.js → host-heartbeat-DtQIBMYm.js} +526 -63
- package/lib/index.d.ts +20 -0
- package/lib/index.js +96 -54
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,138 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.15.1 (2026-10-21)
|
|
4
|
+
|
|
5
|
+
> 版本号是 **patch**:没有接口与配置变更。发往上游的请求在**没有被网关拒绝时仍然逐字节不变**;改动的只有「被渠道校验拒绝之后」那一段——多一级重试。路由、模型、凭据、计费与换号规则未变。
|
|
6
|
+
|
|
7
|
+
### Fixes
|
|
8
|
+
|
|
9
|
+
- **4.15.0 上线后仍有 11128:网关还会按「提示词里的一段长文本」匹配,不只看开头**
|
|
10
|
+
- **真实报文**(2026-10-21 19:1x,国内区、插件 4.15.0、requestId `06e7950a…`):回显「system:You are an AI agent powered by DeepSeek Harness.」(插件已加中性头)——**每条被扫消息的开头都已经在中性头之后**,网关仍然 400。这直接证伪了「只匹配开头」这个 4.13.0 时代的结论。
|
|
11
|
+
- **实测**(同账号 / 同模型 / 同请求头,只改 body;把宿主的真实失败请求逐段二分):
|
|
12
|
+
- 触发点是 system 提示词里 **221 个字符**的一段(DSH 的 Web GUI 段落前两句:「You are interacting with the user through the DeepSeek Harness Web GUI … they mean this GUI.」);
|
|
13
|
+
- 这一段**单独**发就 400;**任何一处**变化就 200(改写一个词、替换引号里的某个词、甚至插入一个零宽空格都行);
|
|
14
|
+
- 在这段外面加前缀或后缀**不影响**结果 ⇒ 精确子串匹配,不是开头规则;
|
|
15
|
+
- 段落里的**单句**各自发都是 200 —— 所以按「开头」设计的归一化永远看不到它。
|
|
16
|
+
- **为什么之前的两级都救不了**:前缀归一化与强制中性头都只动「开头」,而这次命中的是提示词**中间**的一段。
|
|
17
|
+
- **改法**:新增 `perturbChannelFingerprints()` —— 在被扫消息(`system` / `assistant`,含每个 text part)的**句读处**插入 U+200B 零宽空格。对读者不可见、对模型无意义(词一个都没改),但任何跨句的指纹都会被截断。**只在网关已经拒绝之后**使用,正常请求逐字节不变。
|
|
18
|
+
- **重试阶梯**(同一个账号,`channel_reject` 仍不换号):首次失败 → ① `forceNeutralHead`(无处可改就跳过)→ 仍失败 → ② `perturbChannelFingerprints`(无处可改就跳过)。回复里会写明用了哪几级(「并已用强制中性头与一次不可见扰动重试过」)。
|
|
19
|
+
- **代价**:被拒的那一轮最多多两个请求;账号池不记这两次失败(拒的是请求,不是账号)。
|
|
20
|
+
|
|
21
|
+
- **`perturbChannelFingerprints()` 的最终形态**:在被扫消息(`system` / `assistant`,含每个 text part)的**句末标点后**与**分句逗号/分号后**插入 U+200B;对已含 U+200B 的内容幂等跳过;`undefined` 表示「改了也没用」,调用方据此不发请求。`prepareChatBody` 与它的关系是「前者管已知开头,后者管未知的中段」,两者互不覆盖。
|
|
22
|
+
|
|
23
|
+
- **回复会写明用了几级重试**:「并已用强制中性头重试过」「并已用一次不可见扰动重试过」「并已用强制中性头与一次不可见扰动重试过」。这一行只在这两级真的跑过时出现。
|
|
24
|
+
|
|
25
|
+
### Tests
|
|
26
|
+
|
|
27
|
+
- `tests/issue98-channel-gate.spec.ts` 新增 5 条 `perturbChannelFingerprints` 用例:句读处插入、`system`/`assistant` 两种角色都插而 `user`/`tool` 不动、parts 数组与中日文标点、幂等且无改动时返回 `undefined`、畸形 body 不抛错。
|
|
28
|
+
- `tests/issue98-shim.spec.ts` 新增 2 条:开头级无救时**仍会**走扰动级(第二份 body 只多了不可见字符);扰动级把被拒请求救成 200。
|
|
29
|
+
- 既有断言按新阶梯更新:重试全失败时的文案改为同时写出两级。
|
|
30
|
+
|
|
31
|
+
### 真实上游核验
|
|
32
|
+
|
|
33
|
+
- 用**宿主的真实失败请求**(`/tmp/failing-body.json`:10,956 字符 system + 5 条 user + 44 个 tools)在真实国内网关跑完整阶梯:`prepareChatBody` → **400**;`forceNeutralHead` → `undefined`(开头已在中性头之后,证实 4.15.0 那一级在这里是空操作);`perturbChannelFingerprints` → **200**。
|
|
34
|
+
- 同一份 body 上把开头换成 `You are CodeBuddy…` / `You are WorkBuddy…` 仍然 **400**——这是「换成腾讯身份」不能作为解法的直接证据。
|
|
35
|
+
|
|
36
|
+
## 4.15.0 (2026-10-21)
|
|
37
|
+
|
|
38
|
+
> 版本号是 **minor**:有两处用户可见的行为变更 ——(1)`assistant` 历史消息也会被归一化(此前只处理 `system`),(2)渠道校验失败后插件会用「强制中性头」在**同一个账号**上重试一次。**没有被网关拒绝的请求逐字节不变**;被拒的那一次多花一个请求。路由 id、模型 id、凭据、计费与换号规则一律未变。
|
|
39
|
+
|
|
40
|
+
### Fixes
|
|
41
|
+
|
|
42
|
+
- **issue #98:网关的渠道校验也扫 `assistant` 历史消息,而插件只归一到 `system` —— 一个被污染过的会话从此每一步都失败**
|
|
43
|
+
- **实测**(2026-10-21,国内网关 `copilot.tencent.com`,同账号 / 模型 `deepseek-v4.1-flash` / 请求头,**只改 body**;`max_tokens: 8`):
|
|
44
|
+
|
|
45
|
+
| body 里唯一的变化 | 结果 |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `system` 开头 = `You are an AI agent powered by DeepSeek Harness.` | 400 / 11128 |
|
|
48
|
+
| **`assistant` 历史消息开头** = 同一句 | **400 / 11128** |
|
|
49
|
+
| 同一句放在 `assistant` 消息内容**中间** | 200 |
|
|
50
|
+
| 给那条 `assistant` 消息开头补一行 `You are a helpful assistant.` | 200 |
|
|
51
|
+
| `user` 消息开头 = 同一句 | 200(不扫 user) |
|
|
52
|
+
| `tool` 结果开头 = 同一句 | 200(不扫 tool) |
|
|
53
|
+
| `system` 的 content 是 parts 数组、命中在**第 2 个** text part | 400 |
|
|
54
|
+
| 同上,但每个 text part 都补了中性头 | 200 |
|
|
55
|
+
| `system` 开头是当前宿主的 `You are a coding agent powered by …` | 200(不在名单里) |
|
|
56
|
+
|
|
57
|
+
- **它解释了 issue #98 的每一条症状**:只要会话里有一条 `assistant` 消息**开头**是被拦的身份(模型把系统提示词原样复述出来、会话恢复/重放、折叠摘要留下的痕迹),**这个会话之后的每一轮**都会被拒——「单轮跑到 7 分钟多才第一次报错」「继续仍报同一个错」「同一账号另一个会话正常」「重启后 1 分 22 秒失败」都是这一条的形状。换账号、换区域、重装官方客户端都不能改变它,因为被拒的是**请求里的那一行历史**。
|
|
58
|
+
- **改了什么**:`neutralizeChannelFingerprints()` 现在对 `system` 与 `assistant` **两种角色**做同一件事(字符串 content 或 parts 数组里的**每个** text part,命中已知指纹就在最前面补一行中性头)。此前只处理 `system`,所以 `assistant` 里那一行**原样发出**。
|
|
59
|
+
- **为什么可以改历史消息**:这是唯一能救活会话的做法——那一行在**宿主的历史里**,插件只能改它发出去的那一份。改动只有一行前缀,`user` / `tool` / `tool_calls` 结构与工具配对一律不动;宿主自己的会话记录也不改(插件只重写请求体)。
|
|
60
|
+
|
|
61
|
+
- **`system` 的 content 是 parts 数组时,4.13.0 的归一化一个字节都没生效**
|
|
62
|
+
- 归一化只认 `typeof content === 'string'`,于是数组形态的 system 消息(多段文本、图文混排)整个跳过。实测:命中放在**第 2 个** text part 照样 400;每个 part 都补中性头才 200。
|
|
63
|
+
- 现在逐个 text part 判断并前置;非 text part(图片等)与未命中的 part **逐字节不动**,畸形 part 只跳过、不抛错。
|
|
64
|
+
|
|
65
|
+
- **渠道校验失败后,用「强制中性头」在同一个账号上重试一次(结构性兜底)**
|
|
66
|
+
- **不能靠「把名单列全」**:那份名单是网关自己的数据,插件只持有**实测快照**(`UNAPPROVED_CHANNEL_OPENINGS`)。网关加一条,插件就漏一条,而漏掉的那条会让**整个会话**死掉。猜下一条的名字不是修法。
|
|
67
|
+
- **所以改成不看名单**:首次被渠道校验拒绝后,`forceNeutralHead()` 给**每一条** `system` 与 `assistant` 消息(含每个 text part)的最前面补中性头,再发一次。实测:任何被拦的开头,只要被挪到内容开头之外就 200——网关读的是**开头**,不是内容。
|
|
68
|
+
- **同一个账号**:闸读的是请求,不是账号,所以 `channel_reject` 本来就不换号;这一次重试也不换(换号只会烧真实额度,再拿同一个 11128)。
|
|
69
|
+
- **只在需要时**:如果每条开头都已经是中性头(`forceNeutralHead` 返回 `undefined`),不重试——那只会把同样的字节再发一遍。
|
|
70
|
+
- **不记假账**:第一次失败是提示词的问题,不是账号的问题,所以它不进账号池的失败统计;重试若也失败,按最后一次的结果记账。
|
|
71
|
+
|
|
72
|
+
- **回复不再谎报「命中的 system 提示开头」(issue #98 的核心误导)**
|
|
73
|
+
- **旧行为**:从**归一化之后**的 body 里取第一条 `system` 的第一行,而插件补的中性头正好在第一条 ⇒ 回复永远说「命中的 system 提示开头:「You are a helpful assistant.」」。issue #98 里 10 位报告者收到的都是这句,而这句话单独发是 200,真正被拦的那一行从未出现在回复里。
|
|
74
|
+
- **新行为**:从**宿主原始 body**(归一化之前)取,**逐条**列出每个 `system` 与 `assistant` 消息的开头(parts 数组按 part 计),每条标明角色与插件是否已覆盖:
|
|
75
|
+
- `「system:…」(插件已加中性头)` —— 插件会改写它;
|
|
76
|
+
- `「system:…」(不在插件已知名单里,网关可能新增了词条)` —— 这一行**原样发出**,是真正该看的那条;
|
|
77
|
+
- 超过 4 条时只列前 4 条,并写明还剩几条。
|
|
78
|
+
- **不再说「命中的」**:网关从不告诉插件它匹配了哪一条,声称知道就是这次误导的根因。
|
|
79
|
+
|
|
80
|
+
- **同一族业务码的另两道门现在会点名,而不是转述网关的托词**
|
|
81
|
+
- `11128` 有三个来源,`displayTips` 对三者**一字不差**(「这次调用没通过渠道校验,可能不是官方客户端发出的」)。另两道(`conversation id missing`、`first message is not system prompt`)此前落进通用 `client` 兜底,读者拿到的是「该请求被上游按参数拒绝……请按上面的提示调整后重试」+那句误导文案——与本次报告者粘贴回来的那条 400 逐字一致。
|
|
82
|
+
- 现在按 `msg` 点名(仍**只认 msg,绝不认 displayTips**,分类与宿主错误码保持 `client` / 400):缺会话 id 时说明网关要三枚会话头、插件每次都带;首条不是 system 时说明网关要求首条 system、插件在有消息时会补上,因此出现即代表这次请求本身没有消息或没走插件通道。
|
|
83
|
+
|
|
84
|
+
### 评估后**没有**采纳:把中性头换成腾讯自家产品身份
|
|
85
|
+
|
|
86
|
+
- **提议**(issue #98 末尾):把 `You are a helpful assistant.` 换成 `You are CodeBuddy, Tencent's official AI coding assistant.`。理由成立——腾讯不会把自家客户端写进黑名单,这条「结构上安全」。
|
|
87
|
+
- **实测代价**(同一天、同一账号、同一份 DSH 提示词,**只换开头一行**,问「你是哪个产品的助手?」):
|
|
88
|
+
|
|
89
|
+
| 中性头 | 回答 |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `You are a helpful assistant.`(现状) | 我是 DeepSeek 的编程助手。 |
|
|
92
|
+
| `You are CodeBuddy, Tencent's official AI coding assistant.` | 我是腾讯云 CodeBuddy 的 AI 编程助手。 |
|
|
93
|
+
| `You are WorkBuddy, an AI agent developed by Tencent.` | 我是腾讯 WorkBuddy 的 AI 助手。 |
|
|
94
|
+
|
|
95
|
+
前者不改变模型对自身身份的表述,后两者会**串味**——这是所有请求都要付的质量代价,不只是被拦的那几次。
|
|
96
|
+
- **它也不解决 issue #98**:真正被拦的是**会话里的 `assistant` 历史**,不是开头那一行;中性头当时实测仍是 200,报告者把它当成「被拦的行」是插件的回显造成的误解。
|
|
97
|
+
- **结论**:默认不变。「强制中性头」+「不可见扰动」已经覆盖了两类风险,且不必冒充。若将来中性头本身被拉黑,最小改动是把中性头**做成可配置**(默认仍为中性),而不是默认冒充——这留待届时有证据再评估。
|
|
98
|
+
|
|
99
|
+
### Tests
|
|
100
|
+
|
|
101
|
+
- `tests/issue98-channel-gate.spec.ts`(新增):把上表的边界逐条钉住——parts 数组的第 1/第 2 个 part 都归一化、非 text part 与非命中 part 逐字节不动、畸形 part 不抛错、`assistant` 角色同样归一化、`isUnapprovedChannelOpening` 只匹配开头且大小写不敏感、`forceNeutralHead` 覆盖两种角色且空内容不碰、`openingEvidenceOfBody` 按角色与 part 上报并区分「插件已加中性头」与「不在名单里」、`conversation id missing` / `first message is not system prompt` 两道门的判据(code 与措辞两半都要,请求 id 里恰好含数字不算)。
|
|
102
|
+
- `tests/issue98-shim.spec.ts`(新增):挂载**真实的 shim**,只替换上游应答——重试一次即成功(且发出去的第二份 body 在 `system` 与 `assistant` 两种消息前都补了中性头)、重试不换号且第一次失败不记账号账、无法改变时不重试、重试也失败时回复点名的是**宿主的**开头(含 `assistant:`)而不是插件自己的占位行、回复里不再出现「命中的 system 提示开头」与旧兜底句、两道旁门各自点名且不转述误导文案、宿主错误码仍是 `client` / 400。
|
|
103
|
+
- `tests/issue90-channel-reject.spec.ts` 改写:旧的三条按「从归一化后的 body 回显」写的用例方向相反(那正是本次修掉的误导),改为按新契约断言;分类与「不浪费换号」等护栏原样保留。
|
|
104
|
+
|
|
105
|
+
### 真实上游核验
|
|
106
|
+
|
|
107
|
+
- 2026-10-21 共 **200 余次只读对照**(每次 `max_tokens: 8`,未改动任何账号设置):国内网关覆盖开头/中间/parts/角色/多 system/缺头/内容指纹定位(逐段二分 + 前缀增长 + 单点改写的双向验证)等 180 余组,国际网关 20 余组(国际不拦 DSH 身份,只拦 Claude Code;`user` 首条仍是 `first message is not system prompt`)。
|
|
108
|
+
- **内容指纹那一组的判定是「同一份 body 只改一处」**:原样 400、任意一处改动 200、外面加前后缀仍 400,三种同时成立才敢下「精确子串」的结论。
|
|
109
|
+
- 关键结论都经过**双向**确认:既能复现 400,也能用同一份 body 只改开头一行拿到 200(否则无法排除账号、区域或时间窗的影响)。
|
|
110
|
+
|
|
111
|
+
## 4.14.1 (2026-10-21)
|
|
112
|
+
|
|
113
|
+
> 版本号是 **patch**:没有接口、配置项或请求结构的变更,发往上游的请求体**一个字节都没变**。本版只改「哪些上游条目算作模型」这一处判断,以及它带出的一个目录退化路径。
|
|
114
|
+
|
|
115
|
+
### Fixes
|
|
116
|
+
|
|
117
|
+
- **不再把上游的 `auto` 路由器当成模型列出来**
|
|
118
|
+
- **它不是模型**:国内区 `cli` 名单里的 `auto` 是一个**路由决策**,网关在请求时把它解析成它认为合适的那一个(实测 → `hy4-preview`)。所以它的价格、上下文窗口与行为都由别人在请求时决定,插件无法如实描述;把它列成一行,用户看到的将是一行「倍率未知、行为不定」的条目。
|
|
119
|
+
- **它还制造重复**:同一份名单里本来就有它会被解析到的那个真实模型(`hy4-preview`),于是选择器出现两行指向同一个上游模型、其中一行没有倍率。
|
|
120
|
+
- **改在名单构建的单一位置**(`selectCliModels`),因此卡片、模型选择器、探针与发现路由看到的是同一份结果,不会出现「卡片上没有、选择器里还有」。
|
|
121
|
+
- **用显式表而不是名字启发式**:新增 `ROUTER_MODEL_IDS` / `isRouterModelId`(`src/native-modality.ts`),与既有的 `nativeModalityOf(...) === 'router'` 共用同一个来源。路由是上游的概念、没有声明字段,按名字猜迟早会误伤真实模型。
|
|
122
|
+
- **静态兜底同步移除**:`FALLBACK_WORKBUDDY_MODELS` 里也有 `auto`。只改实时名单的话,它会在离线/首帧路径上复活——那是这类不一致里最难察觉的一种。
|
|
123
|
+
- **空名单守卫挪到并入保留模型之后**:否则名单只剩 router 时,守卫会在合并前抛错,把「上游改了 router」放大成「这个区域一个模型都没有」。现在「上游名单为空、但用户仍有在跑的真实模型」会正常降级为后者。
|
|
124
|
+
|
|
125
|
+
- **勾选与目录零交集时退回整个目录,而不是返回空**
|
|
126
|
+
- 真实触发路径正是一位「只勾过 `auto`」的用户:4.14.0 里他还能勾选它,本版不再提供之后,他的 `enabledModelIds` 与目录的交集为空,运行时目录会变成空的——那是 issue #32 的故障形态(选择器突然没有模型,且没有任何报错)。
|
|
127
|
+
- `deriveCatalog` 现在只在「非空勾选 ∩ 目录 = ∅」时退回整个目录。用户勾选表达的是「要一些模型开着」,不是「一个都不要」;空目录仍然返回空,这条只处理「勾选不再匹配任何东西」。
|
|
128
|
+
|
|
129
|
+
### Tests
|
|
130
|
+
|
|
131
|
+
- `tests/upstream.spec.ts` 新增 2 条:**router 绝不作为模型出现**(名单里 `auto` 与 `hy4-preview` 同时存在时,结果只有后者);**名单只剩 router 时保留模型仍然生效**——这条专门钉住「守卫在合并前还是合并后」这个坑,守卫放错位置它会红。
|
|
132
|
+
- `tests/catalog.spec.ts` 新增/改写:两区静态兜底都不含 `auto`;勾选只命中 router 时退回整个目录而不是空。
|
|
133
|
+
- 既有两条把 `auto` 当素材的用例改用真实模型(`hy3`),避免测试本身依赖刚被删掉的行为。
|
|
134
|
+
- **真实上游核验**(只读 GET,不消耗积分):国内区 `cli` 名单 20 → **19** 条,`auto` 已不在其中;其余 9 枚活动徽标一个不少,与 WorkBuddy 客户端逐条一致。
|
|
135
|
+
|
|
3
136
|
## 4.14.0 (2026-10-21)
|
|
4
137
|
|
|
5
138
|
> 版本号是 **minor**:有用户可见的功能与行为变更(模型行新增上游活动徽标;国内区改以 App 身份读产品配置,因此目录与客户端一致)。发往上游的**聊天**请求体、路由 id、凭据、计费与换号规则一律未变——变的只有**读取模型目录**那一次 GET 的 User-Agent。
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL,
|
|
2
|
+
import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, pt as findWorkbuddyAppExecutableWithSource, r as isHeartbeatProcessAlive, st as WORKBUDDY_APP_EXECUTABLE_ENV, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, xt as hostPeersInfo, y as WorkBuddyCredentialStore } from "./host-heartbeat-DtQIBMYm.js";
|
|
3
3
|
import { realpathSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
//#region src/bin.ts
|
package/lib/client.js
CHANGED
|
@@ -1501,6 +1501,71 @@ window.__ModuleLoader__.load({
|
|
|
1501
1501
|
return t("row.poolProbeDaysAgo", { count: Math.round(age / DAY_MS) });
|
|
1502
1502
|
}
|
|
1503
1503
|
//#endregion
|
|
1504
|
+
//#region src/native-modality.ts
|
|
1505
|
+
/**
|
|
1506
|
+
* Reviewed vendor classifications, keyed by EXACT model id (never a prefix).
|
|
1507
|
+
* Ids absent from this record are `unknown` — see {@link nativeModalityOf}.
|
|
1508
|
+
*/
|
|
1509
|
+
const NATIVE_MODALITY_BY_MODEL_ID = {
|
|
1510
|
+
"deepseek-v4-pro": "text",
|
|
1511
|
+
"glm-5.1": "text",
|
|
1512
|
+
"glm-5.2": "text",
|
|
1513
|
+
"glm-5.3": "text",
|
|
1514
|
+
"hy3": "text",
|
|
1515
|
+
"hy4-preview": "text",
|
|
1516
|
+
"deepseek-v4.1-flash": "multimodal",
|
|
1517
|
+
"glm-5.3-flash": "multimodal",
|
|
1518
|
+
"glm-5v-turbo": "multimodal",
|
|
1519
|
+
"kimi-k2.6": "multimodal",
|
|
1520
|
+
"kimi-k2.7": "multimodal",
|
|
1521
|
+
"kimi-k3-1": "multimodal",
|
|
1522
|
+
"minimax-m3": "multimodal"
|
|
1523
|
+
};
|
|
1524
|
+
/**
|
|
1525
|
+
* Ids the upstream lists in the model roster that are ROUTERS, not models.
|
|
1526
|
+
*
|
|
1527
|
+
* `auto` (CN) is the one observed today: the gateway resolves it to whichever
|
|
1528
|
+
* model it considers the best fit (measured: CN `auto` → `hy4-preview`), so its
|
|
1529
|
+
* price, context window and behaviour are decided at request time by something
|
|
1530
|
+
* else. Offering it as a row advertises a model whose terms the plugin cannot
|
|
1531
|
+
* state, and it duplicates a real row already in the same roster.
|
|
1532
|
+
*
|
|
1533
|
+
* Kept as an explicit table rather than a heuristic: a router is an upstream
|
|
1534
|
+
* concept with no declared flag, and guessing from the name would eventually
|
|
1535
|
+
* hide a real model. Add an id here only after the same kind of evidence.
|
|
1536
|
+
*/
|
|
1537
|
+
const ROUTER_MODEL_IDS = /* @__PURE__ */ new Set(["auto"]);
|
|
1538
|
+
/** Whether this id is an upstream router rather than a model. */
|
|
1539
|
+
function isRouterModelId(modelId) {
|
|
1540
|
+
return ROUTER_MODEL_IDS.has(modelId);
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Native modality of one model id. Exact match only: a new model never
|
|
1544
|
+
* inherits a classification from its family, its display name, or the
|
|
1545
|
+
* platform's image flag.
|
|
1546
|
+
*/
|
|
1547
|
+
function nativeModalityOf(modelId) {
|
|
1548
|
+
if (isRouterModelId(modelId)) return "router";
|
|
1549
|
+
return NATIVE_MODALITY_BY_MODEL_ID[modelId] ?? "unknown";
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Whether a model refresh should PRE-CHECK this model's image box.
|
|
1553
|
+
*
|
|
1554
|
+
* Only a documented `multimodal` is pre-checked. `text` is documented as
|
|
1555
|
+
* unable to read images, and `unknown` is deliberately left unchecked rather
|
|
1556
|
+
* than guessed — the whole reason this table exists is that the platform flag
|
|
1557
|
+
* guessed wrong. A platform `supportsImages: false` still vetoes, so a
|
|
1558
|
+
* documented-multimodal id can never be pre-checked against the platform's
|
|
1559
|
+
* explicit "no".
|
|
1560
|
+
*
|
|
1561
|
+
* The user can always tick a box by hand; this decides a DEFAULT only, and the
|
|
1562
|
+
* effective runtime flag remains the saved `imageModelIds`.
|
|
1563
|
+
*/
|
|
1564
|
+
function imageDefaultFor(info) {
|
|
1565
|
+
if (info.supportsImages === false) return false;
|
|
1566
|
+
return NATIVE_MODALITY_BY_MODEL_ID[info.id] === "multimodal";
|
|
1567
|
+
}
|
|
1568
|
+
//#endregion
|
|
1504
1569
|
//#region src/promo.ts
|
|
1505
1570
|
/**
|
|
1506
1571
|
* `'HH:mm'` → minutes since midnight, or undefined for anything else.
|
|
@@ -3735,55 +3800,6 @@ window.__ModuleLoader__.load({
|
|
|
3735
3800
|
});
|
|
3736
3801
|
}
|
|
3737
3802
|
//#endregion
|
|
3738
|
-
//#region src/native-modality.ts
|
|
3739
|
-
/**
|
|
3740
|
-
* Reviewed vendor classifications, keyed by EXACT model id (never a prefix).
|
|
3741
|
-
* Ids absent from this record are `unknown` — see {@link nativeModalityOf}.
|
|
3742
|
-
*/
|
|
3743
|
-
const NATIVE_MODALITY_BY_MODEL_ID = {
|
|
3744
|
-
"deepseek-v4-pro": "text",
|
|
3745
|
-
"glm-5.1": "text",
|
|
3746
|
-
"glm-5.2": "text",
|
|
3747
|
-
"glm-5.3": "text",
|
|
3748
|
-
"hy3": "text",
|
|
3749
|
-
"hy4-preview": "text",
|
|
3750
|
-
"deepseek-v4.1-flash": "multimodal",
|
|
3751
|
-
"glm-5.3-flash": "multimodal",
|
|
3752
|
-
"glm-5v-turbo": "multimodal",
|
|
3753
|
-
"kimi-k2.6": "multimodal",
|
|
3754
|
-
"kimi-k2.7": "multimodal",
|
|
3755
|
-
"kimi-k3-1": "multimodal",
|
|
3756
|
-
"minimax-m3": "multimodal"
|
|
3757
|
-
};
|
|
3758
|
-
/** The `auto` entry is a router that picks a model, not a model itself. */
|
|
3759
|
-
const ROUTER_MODEL_ID = "auto";
|
|
3760
|
-
/**
|
|
3761
|
-
* Native modality of one model id. Exact match only: a new model never
|
|
3762
|
-
* inherits a classification from its family, its display name, or the
|
|
3763
|
-
* platform's image flag.
|
|
3764
|
-
*/
|
|
3765
|
-
function nativeModalityOf(modelId) {
|
|
3766
|
-
if (modelId === ROUTER_MODEL_ID) return "router";
|
|
3767
|
-
return NATIVE_MODALITY_BY_MODEL_ID[modelId] ?? "unknown";
|
|
3768
|
-
}
|
|
3769
|
-
/**
|
|
3770
|
-
* Whether a model refresh should PRE-CHECK this model's image box.
|
|
3771
|
-
*
|
|
3772
|
-
* Only a documented `multimodal` is pre-checked. `text` is documented as
|
|
3773
|
-
* unable to read images, and `unknown` is deliberately left unchecked rather
|
|
3774
|
-
* than guessed — the whole reason this table exists is that the platform flag
|
|
3775
|
-
* guessed wrong. A platform `supportsImages: false` still vetoes, so a
|
|
3776
|
-
* documented-multimodal id can never be pre-checked against the platform's
|
|
3777
|
-
* explicit "no".
|
|
3778
|
-
*
|
|
3779
|
-
* The user can always tick a box by hand; this decides a DEFAULT only, and the
|
|
3780
|
-
* effective runtime flag remains the saved `imageModelIds`.
|
|
3781
|
-
*/
|
|
3782
|
-
function imageDefaultFor(info) {
|
|
3783
|
-
if (info.supportsImages === false) return false;
|
|
3784
|
-
return NATIVE_MODALITY_BY_MODEL_ID[info.id] === "multimodal";
|
|
3785
|
-
}
|
|
3786
|
-
//#endregion
|
|
3787
3803
|
//#region src/client/promo-badge.ts
|
|
3788
3804
|
/**
|
|
3789
3805
|
* The UI's language, as the browser reports it.
|
|
@@ -962,6 +962,71 @@ function clearAtRestKeyCache() {
|
|
|
962
962
|
cachedAppExecutable = void 0;
|
|
963
963
|
}
|
|
964
964
|
//#endregion
|
|
965
|
+
//#region src/native-modality.ts
|
|
966
|
+
/**
|
|
967
|
+
* Reviewed vendor classifications, keyed by EXACT model id (never a prefix).
|
|
968
|
+
* Ids absent from this record are `unknown` — see {@link nativeModalityOf}.
|
|
969
|
+
*/
|
|
970
|
+
const NATIVE_MODALITY_BY_MODEL_ID = {
|
|
971
|
+
"deepseek-v4-pro": "text",
|
|
972
|
+
"glm-5.1": "text",
|
|
973
|
+
"glm-5.2": "text",
|
|
974
|
+
"glm-5.3": "text",
|
|
975
|
+
"hy3": "text",
|
|
976
|
+
"hy4-preview": "text",
|
|
977
|
+
"deepseek-v4.1-flash": "multimodal",
|
|
978
|
+
"glm-5.3-flash": "multimodal",
|
|
979
|
+
"glm-5v-turbo": "multimodal",
|
|
980
|
+
"kimi-k2.6": "multimodal",
|
|
981
|
+
"kimi-k2.7": "multimodal",
|
|
982
|
+
"kimi-k3-1": "multimodal",
|
|
983
|
+
"minimax-m3": "multimodal"
|
|
984
|
+
};
|
|
985
|
+
/**
|
|
986
|
+
* Ids the upstream lists in the model roster that are ROUTERS, not models.
|
|
987
|
+
*
|
|
988
|
+
* `auto` (CN) is the one observed today: the gateway resolves it to whichever
|
|
989
|
+
* model it considers the best fit (measured: CN `auto` → `hy4-preview`), so its
|
|
990
|
+
* price, context window and behaviour are decided at request time by something
|
|
991
|
+
* else. Offering it as a row advertises a model whose terms the plugin cannot
|
|
992
|
+
* state, and it duplicates a real row already in the same roster.
|
|
993
|
+
*
|
|
994
|
+
* Kept as an explicit table rather than a heuristic: a router is an upstream
|
|
995
|
+
* concept with no declared flag, and guessing from the name would eventually
|
|
996
|
+
* hide a real model. Add an id here only after the same kind of evidence.
|
|
997
|
+
*/
|
|
998
|
+
const ROUTER_MODEL_IDS = /* @__PURE__ */ new Set(["auto"]);
|
|
999
|
+
/** Whether this id is an upstream router rather than a model. */
|
|
1000
|
+
function isRouterModelId(modelId) {
|
|
1001
|
+
return ROUTER_MODEL_IDS.has(modelId);
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Native modality of one model id. Exact match only: a new model never
|
|
1005
|
+
* inherits a classification from its family, its display name, or the
|
|
1006
|
+
* platform's image flag.
|
|
1007
|
+
*/
|
|
1008
|
+
function nativeModalityOf(modelId) {
|
|
1009
|
+
if (isRouterModelId(modelId)) return "router";
|
|
1010
|
+
return NATIVE_MODALITY_BY_MODEL_ID[modelId] ?? "unknown";
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Whether a model refresh should PRE-CHECK this model's image box.
|
|
1014
|
+
*
|
|
1015
|
+
* Only a documented `multimodal` is pre-checked. `text` is documented as
|
|
1016
|
+
* unable to read images, and `unknown` is deliberately left unchecked rather
|
|
1017
|
+
* than guessed — the whole reason this table exists is that the platform flag
|
|
1018
|
+
* guessed wrong. A platform `supportsImages: false` still vetoes, so a
|
|
1019
|
+
* documented-multimodal id can never be pre-checked against the platform's
|
|
1020
|
+
* explicit "no".
|
|
1021
|
+
*
|
|
1022
|
+
* The user can always tick a box by hand; this decides a DEFAULT only, and the
|
|
1023
|
+
* effective runtime flag remains the saved `imageModelIds`.
|
|
1024
|
+
*/
|
|
1025
|
+
function imageDefaultFor(info) {
|
|
1026
|
+
if (info.supportsImages === false) return false;
|
|
1027
|
+
return NATIVE_MODALITY_BY_MODEL_ID[info.id] === "multimodal";
|
|
1028
|
+
}
|
|
1029
|
+
//#endregion
|
|
965
1030
|
//#region src/off-thinking.ts
|
|
966
1031
|
/**
|
|
967
1032
|
* Ids observed to REFUSE `reasoning_effort: "off"` with HTTP 400 despite
|
|
@@ -1533,8 +1598,15 @@ const CHANNEL_REJECT_MARKERS = ["unapproved channel", "illegal api invocation"];
|
|
|
1533
1598
|
* be read as the prompt rule: a confidently wrong diagnosis is worse here than
|
|
1534
1599
|
* the generic `client` reply, because it sends the user to rewrite a system
|
|
1535
1600
|
* prompt that is not at fault.
|
|
1601
|
+
*
|
|
1602
|
+
* Each is also exported as its own predicate ({@link isConversationIdGateBody},
|
|
1603
|
+
* {@link isFirstMessageGateBody}) so the reply can NAME the gate instead of
|
|
1604
|
+
* relaying the upstream's `displayTips` — which is the SAME misleading
|
|
1605
|
+
* sentence for all three members of the `11128` family.
|
|
1536
1606
|
*/
|
|
1537
|
-
const
|
|
1607
|
+
const CONVERSATION_ID_GATE_MARKER = "conversation id missing";
|
|
1608
|
+
const FIRST_MESSAGE_GATE_MARKER = "first message is not system prompt";
|
|
1609
|
+
const OTHER_CHANNEL_CODE_GATES = [CONVERSATION_ID_GATE_MARKER, FIRST_MESSAGE_GATE_MARKER];
|
|
1538
1610
|
/**
|
|
1539
1611
|
* Whether a failure body is the MODEL-scoped throttle rather than an account
|
|
1540
1612
|
* limit.
|
|
@@ -1572,52 +1644,57 @@ function isChannelRejectBody(body) {
|
|
|
1572
1644
|
return CHANNEL_REJECT_MARKERS.some((marker) => lower.includes(marker));
|
|
1573
1645
|
}
|
|
1574
1646
|
/**
|
|
1575
|
-
*
|
|
1647
|
+
* Whether a failure body is the CONVERSATION-ID gate (`11128`).
|
|
1576
1648
|
*
|
|
1577
|
-
*
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
*
|
|
1649
|
+
* One of the two gates that share the channel gate's business code, and one
|
|
1650
|
+
* of the two this plugin's diagnosis EXCLUDES: the prompt is not at fault
|
|
1651
|
+
* here, the request headers are — which is why it must never be reported as
|
|
1652
|
+
* "the gateway refused your system prompt".
|
|
1581
1653
|
*
|
|
1582
|
-
*
|
|
1583
|
-
*
|
|
1584
|
-
*
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
1654
|
+
* It is exported anyway, so a reply can NAME it. The upstream's `displayTips`
|
|
1655
|
+
* for all three `11128` gates is the identical sentence ("这次调用没通过渠道
|
|
1656
|
+
* 校验,可能不是官方客户端发出的"), so the tips cannot tell them apart — only
|
|
1657
|
+
* `msg` can. Hence the wording is the criterion and the code is the
|
|
1658
|
+
* corroboration, never the other way round.
|
|
1587
1659
|
*
|
|
1588
|
-
*
|
|
1589
|
-
*
|
|
1660
|
+
* BOTH halves are required, and the code is matched in its quoted-key form
|
|
1661
|
+
* (`"code":11128`): a request id is hexadecimal and can contain the digits
|
|
1662
|
+
* `11128` by coincidence, and a bare substring search over an arbitrary
|
|
1663
|
+
* failure body is how a red herring gets promoted into a diagnosis.
|
|
1590
1664
|
*/
|
|
1591
|
-
function
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
if (opening !== "") openings.push(clip(opening, SYSTEM_OPENING_LIMIT));
|
|
1612
|
-
}
|
|
1613
|
-
return openings;
|
|
1665
|
+
function isConversationIdGateBody(body) {
|
|
1666
|
+
const lower = body.toLowerCase();
|
|
1667
|
+
return lower.includes(CHANNEL_REJECT_CODE) && lower.includes(CONVERSATION_ID_GATE_MARKER);
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Whether a failure body is the FIRST-MESSAGE gate (`11128`).
|
|
1671
|
+
*
|
|
1672
|
+
* The global gateway's own rule: a chat request must OPEN with a system
|
|
1673
|
+
* message, and a user-first body is refused with `11128` / "first message is
|
|
1674
|
+
* not system prompt". The plugin's `ensureSystemHead()` satisfies it, so a
|
|
1675
|
+
* body carrying this wording says the request never had a system head — not
|
|
1676
|
+
* that a system prompt was fingerprinted.
|
|
1677
|
+
*
|
|
1678
|
+
* Excluded from the prompt diagnosis for the same reason as
|
|
1679
|
+
* {@link isConversationIdGateBody}, and named by the same rule: the CODE plus
|
|
1680
|
+
* the WORDING, case-insensitively, code in its quoted-key form.
|
|
1681
|
+
*/
|
|
1682
|
+
function isFirstMessageGateBody(body) {
|
|
1683
|
+
const lower = body.toLowerCase();
|
|
1684
|
+
return lower.includes(CHANNEL_REJECT_CODE) && lower.includes(FIRST_MESSAGE_GATE_MARKER);
|
|
1614
1685
|
}
|
|
1686
|
+
/**
|
|
1687
|
+
* The OPENINGS of the `system` messages in a prepared request body.
|
|
1688
|
+
*
|
|
1689
|
+
* REMOVED in favour of {@link systemOpeningEvidenceOfBody}, which answers the
|
|
1690
|
+
* same question ("what did the gate actually see?") for BOTH string and
|
|
1691
|
+
* array-shaped content and can also say whether an opening is the plugin's own
|
|
1692
|
+
* neutral head. Two functions that both claim to describe what the gate read
|
|
1693
|
+
* are two truths that drift — and the array-shaped case is exactly where the
|
|
1694
|
+
* old one was blind.
|
|
1695
|
+
*/
|
|
1615
1696
|
/** Longest system-prompt opening echoed back to the user, in characters. */
|
|
1616
1697
|
const SYSTEM_OPENING_LIMIT = 120;
|
|
1617
|
-
function firstLineOf(content) {
|
|
1618
|
-
const newline = content.indexOf("\n");
|
|
1619
|
-
return newline === -1 ? content : content.slice(0, newline);
|
|
1620
|
-
}
|
|
1621
1698
|
function clip(text, limit) {
|
|
1622
1699
|
return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
|
|
1623
1700
|
}
|
|
@@ -1915,31 +1992,389 @@ const WORKBUDDY_FALLBACK_SYSTEM_PROMPT = "You are a helpful assistant.";
|
|
|
1915
1992
|
* snapshot, not a theory of what the gate "means"; a gateway adding a new
|
|
1916
1993
|
* opening is not caught until a body carries one, and the prepend only wins
|
|
1917
1994
|
* while the gate keeps matching at offset 0.
|
|
1995
|
+
*
|
|
1996
|
+
* THE LIST CANNOT BE COMPLETE, AND THAT IS WHY {@link forceNeutralHead}
|
|
1997
|
+
* EXISTS. This is a snapshot of the upstream's own list, taken by measuring
|
|
1998
|
+
* openings one at a time; the upstream can extend it at any time and the
|
|
1999
|
+
* plugin cannot see it. So the plugin's own prepend is a best-effort filter,
|
|
2000
|
+
* not a guarantee — and the self-healing retry is what covers the openings
|
|
2001
|
+
* nobody has measured yet.
|
|
1918
2002
|
*/
|
|
1919
2003
|
const UNAPPROVED_CHANNEL_OPENINGS = ["you are an ai agent powered by deepseek harness", "you are claude code, anthropic's official cli for claude"];
|
|
1920
2004
|
/**
|
|
1921
|
-
*
|
|
1922
|
-
*
|
|
2005
|
+
* Whether this text OPENS with an opening the gateways refuse.
|
|
2006
|
+
*
|
|
2007
|
+
* The gate is a PREFIX match on the START of a `system` message, so this is
|
|
2008
|
+
* the one predicate that decides it — used by the request path (which prepends
|
|
2009
|
+
* the neutral head on a hit) and by the failure path (which names the
|
|
2010
|
+
* offending line).
|
|
2011
|
+
*
|
|
2012
|
+
* THREE MEASURED RULES are folded in here, all from the live CN gateway on
|
|
2013
|
+
* 2026-10-21 (account `ba217455…`, `deepseek-v4.1-flash`, every other request
|
|
2014
|
+
* field held fixed):
|
|
1923
2015
|
*
|
|
1924
|
-
*
|
|
1925
|
-
*
|
|
1926
|
-
* answer
|
|
1927
|
-
*
|
|
2016
|
+
* 1. Only the START counts — the same sentence mid-content answers 200.
|
|
2017
|
+
* 2. LEADING WHITESPACE is trimmed before the comparison — " <banned>" and
|
|
2018
|
+
* "\n<banned>" both answer 200 today.
|
|
2019
|
+
* 3. The comparison is CASE-INSENSITIVE — the lower-cased sentence fails too.
|
|
2020
|
+
*
|
|
2021
|
+
* (2) is a gift that can be withdrawn, so the comparison is made the way a
|
|
2022
|
+
* trimming gate would make it even while the gateway is lenient: a rule that
|
|
2023
|
+
* only holds while the gateway forgets to trim is a rule waiting to break.
|
|
2024
|
+
*
|
|
2025
|
+
* The list itself stays the measured snapshot. It is deliberately NOT
|
|
2026
|
+
* extended by guesswork: an opening nobody measured is an opening nobody knows
|
|
2027
|
+
* the gateway refuses, and inventing entries would neutralise prompts that
|
|
2028
|
+
* answer 200 today.
|
|
2029
|
+
*/
|
|
2030
|
+
function isUnapprovedChannelOpening(text) {
|
|
2031
|
+
const opening = text.replace(/^\s+/u, "").toLowerCase();
|
|
2032
|
+
return UNAPPROVED_CHANNEL_OPENINGS.some((prefix) => opening.startsWith(prefix));
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Whether this text already begins with the plugin's own neutral head.
|
|
2036
|
+
*
|
|
2037
|
+
* The question the failure path has to answer per system message: "is the
|
|
2038
|
+
* line the gateway refused OURS, or the HOST's?" — the plugin's head is the
|
|
2039
|
+
* one line it has ever added to a prompt, so it is the only opening that
|
|
2040
|
+
* cannot be a third-party-agent fingerprint.
|
|
2041
|
+
*/
|
|
2042
|
+
function startsWithNeutralHead(text) {
|
|
2043
|
+
return text.replace(/^\s+/u, "").toLowerCase().startsWith(WORKBUDDY_FALLBACK_SYSTEM_PROMPT.toLowerCase());
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* The roles whose message OPENINGS the gateway scans.
|
|
2047
|
+
*
|
|
2048
|
+
* MEASURED on the live CN gateway (2026-10-21, every other request field held
|
|
2049
|
+
* fixed): `system` and `assistant` are scanned; `user` and `tool` are not (a
|
|
2050
|
+
* body whose `user` or `tool` content opens with the banned identity answers
|
|
2051
|
+
* 200). That second role is the one the plugin missed until 4.15.0, and it is
|
|
2052
|
+
* why issue #98's sessions stayed broken: an `assistant` history message that
|
|
2053
|
+
* opens with the fingerprint poisons EVERY later turn of that session, on
|
|
2054
|
+
* every account and in every region.
|
|
2055
|
+
*
|
|
2056
|
+
* `developer` is not listed because `prepareChatBody` rewrites it to `system`
|
|
2057
|
+
* before this module looks at the body — and because both gateways refuse
|
|
2058
|
+
* `developer` outright, so neutralising its content would not save the request.
|
|
2059
|
+
*/
|
|
2060
|
+
function scansOpening(role) {
|
|
2061
|
+
if (typeof role !== "string") return false;
|
|
2062
|
+
const name = role.trim().toLowerCase();
|
|
2063
|
+
return name === "system" || name === "assistant";
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* Move every scanned message (`system` and `assistant`) off the content
|
|
2067
|
+
* fingerprint above by prepending the placeholder head.
|
|
2068
|
+
*
|
|
2069
|
+
* A no-op for a body whose messages start with anything else, so a request the
|
|
2070
|
+
* gateways already answer keeps its prompt byte-for-byte — only the messages
|
|
2071
|
+
* the gate would refuse grow, by one short line.
|
|
2072
|
+
*
|
|
2073
|
+
* BOTH ROLES, because the gate reads both. Until 4.15.0 this only handled
|
|
2074
|
+
* `system`, and the failure that left behind is issue #98: a conversation
|
|
2075
|
+
* whose HISTORY contains an assistant message opening with the banned identity
|
|
2076
|
+
* (the model echoing its own prompt, a restored session, a compaction
|
|
2077
|
+
* artefact) was refused on every single turn, and no account or region could
|
|
2078
|
+
* change a byte of it.
|
|
2079
|
+
*
|
|
2080
|
+
* ARRAY CONTENT IS HANDLED PART BY PART, and that is not a nicety. Measured on
|
|
2081
|
+
* the live CN gateway (2026-10-21): when a scanned message's `content` is an
|
|
2082
|
+
* array of parts, the gate reads EVERY TEXT PART of it — a banned opening in
|
|
2083
|
+
* the FIRST part is refused, and a banned opening in the SECOND part is
|
|
2084
|
+
* refused just the same. Prepending to the first part only would therefore
|
|
2085
|
+
* fix the first half of the failure and leave the second half to the users who
|
|
2086
|
+
* report it again.
|
|
1928
2087
|
*/
|
|
1929
2088
|
function neutralizeChannelFingerprints(obj) {
|
|
1930
2089
|
const messages = obj["messages"];
|
|
1931
2090
|
if (!Array.isArray(messages)) return;
|
|
1932
2091
|
for (const value of messages) {
|
|
2092
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
2093
|
+
const message = value;
|
|
2094
|
+
if (!scansOpening(message["role"])) continue;
|
|
2095
|
+
const content = message["content"];
|
|
2096
|
+
if (typeof content === "string") {
|
|
2097
|
+
if (!isUnapprovedChannelOpening(content)) continue;
|
|
2098
|
+
message["content"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${content}`;
|
|
2099
|
+
continue;
|
|
2100
|
+
}
|
|
2101
|
+
if (!Array.isArray(content)) continue;
|
|
2102
|
+
for (const part of content) {
|
|
2103
|
+
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
|
|
2104
|
+
const record = part;
|
|
2105
|
+
const type = record["type"];
|
|
2106
|
+
if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
|
|
2107
|
+
const text = record["text"];
|
|
2108
|
+
if (typeof text !== "string" || !isUnapprovedChannelOpening(text)) continue;
|
|
2109
|
+
record["text"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${text}`;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* A body in which EVERY scanned opening (`system` and `assistant`) is moved
|
|
2115
|
+
* behind the neutral head.
|
|
2116
|
+
*
|
|
2117
|
+
* THE ONE-SHOT SELF-HEALING RETRY'S BODY BUILDER. The gateway's fingerprint
|
|
2118
|
+
* list is a SNAPSHOT the upstream can extend at any time, and the plugin
|
|
2119
|
+
* cannot see it: the two entries in {@link UNAPPROVED_CHANNEL_OPENINGS} are
|
|
2120
|
+
* what was MEASURED on 2026-10-21, not a contract. So the day the gateway
|
|
2121
|
+
* adds an entry, this plugin's own prepend stops matching and the banned
|
|
2122
|
+
* opening goes out on the wire unchanged — exactly the dead end issue #98
|
|
2123
|
+
* describes, and exactly what a longer guess-list cannot prevent (nobody can
|
|
2124
|
+
* measure the next entry before it bites).
|
|
2125
|
+
*
|
|
2126
|
+
* The remedy is a second attempt that does not depend on knowing the
|
|
2127
|
+
* fingerprint at all: this builds a body in which EVERY scanned message,
|
|
2128
|
+
* whatever it opens with, gets the neutral head in front. The gateway matches
|
|
2129
|
+
* the START of a scanned message, so a neutral first line moves the whole
|
|
2130
|
+
* opening out of the matched region — whether the banned text is a known
|
|
2131
|
+
* entry, a new one, or a fingerprint this plugin has never heard of.
|
|
2132
|
+
*
|
|
2133
|
+
* `assistant` messages are included for the measured reason
|
|
2134
|
+
* {@link neutralizeChannelFingerprints} documents: a history message that opens
|
|
2135
|
+
* with a refused identity is what keeps a whole session failing, so a retry
|
|
2136
|
+
* that only moved the system prompt would spend a request to be refused for the
|
|
2137
|
+
* same reason.
|
|
2138
|
+
*
|
|
2139
|
+
* WHEN IT IS USED: only AFTER the gateway refused the first attempt with the
|
|
2140
|
+
* channel gate. A request the gateway already answers keeps its bytes
|
|
2141
|
+
* unchanged, because this function is never called for it — the normal path
|
|
2142
|
+
* is {@link prepareChatBody}, which only touches the openings it has MEASURED
|
|
2143
|
+
* as fingerprinted.
|
|
2144
|
+
*
|
|
2145
|
+
* `undefined` means "this body cannot change": every scanned opening already
|
|
2146
|
+
* starts with the neutral head, so a retry would send the same bytes and
|
|
2147
|
+
* spend a request for the same refusal. The caller uses that to avoid
|
|
2148
|
+
* spending the round trip.
|
|
2149
|
+
*
|
|
2150
|
+
* Empty contents are skipped: an empty string cannot open with anything, so it
|
|
2151
|
+
* cannot be the culprit, and an assistant message that carries `tool_calls`
|
|
2152
|
+
* with `content: ""` is a replay shape this function has no reason to touch.
|
|
2153
|
+
*
|
|
2154
|
+
* Never throws. A body it cannot read is the upstream's to reject, and a
|
|
2155
|
+
* parse error must not turn a retry into a crash.
|
|
2156
|
+
*/
|
|
2157
|
+
function forceNeutralHead(bodyJson) {
|
|
2158
|
+
let parsed;
|
|
2159
|
+
try {
|
|
2160
|
+
parsed = JSON.parse(bodyJson);
|
|
2161
|
+
} catch {
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
2165
|
+
const obj = parsed;
|
|
2166
|
+
const messages = obj["messages"];
|
|
2167
|
+
if (!Array.isArray(messages)) return void 0;
|
|
2168
|
+
let changed = false;
|
|
2169
|
+
for (const value of messages) {
|
|
2170
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
2171
|
+
const message = value;
|
|
2172
|
+
if (!scansOpening(message["role"])) continue;
|
|
2173
|
+
const content = message["content"];
|
|
2174
|
+
if (typeof content === "string") {
|
|
2175
|
+
if (content.trim() === "" || startsWithNeutralHead(content)) continue;
|
|
2176
|
+
message["content"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${content}`;
|
|
2177
|
+
changed = true;
|
|
2178
|
+
continue;
|
|
2179
|
+
}
|
|
2180
|
+
if (!Array.isArray(content)) continue;
|
|
2181
|
+
for (const part of content) {
|
|
2182
|
+
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
|
|
2183
|
+
const record = part;
|
|
2184
|
+
const type = record["type"];
|
|
2185
|
+
if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
|
|
2186
|
+
const text = record["text"];
|
|
2187
|
+
if (typeof text !== "string" || text.trim() === "" || startsWithNeutralHead(text)) continue;
|
|
2188
|
+
record["text"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${text}`;
|
|
2189
|
+
changed = true;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
if (!changed) return void 0;
|
|
2193
|
+
return JSON.stringify(obj);
|
|
2194
|
+
}
|
|
2195
|
+
/**
|
|
2196
|
+
* The invisible character used to break a content fingerprint.
|
|
2197
|
+
*
|
|
2198
|
+
* U+200B ZERO WIDTH SPACE: it renders as nothing, it is not whitespace for the
|
|
2199
|
+
* gateway's own matching, and a model reads straight through it. Measured on
|
|
2200
|
+
* the live CN gateway: putting one inside the refused span turns 400 into 200,
|
|
2201
|
+
* while the text the model reads is unchanged.
|
|
2202
|
+
*/
|
|
2203
|
+
const ZERO_WIDTH_SPACE = "";
|
|
2204
|
+
/**
|
|
2205
|
+
* A body whose scanned message TEXT is perturbed just enough to break an exact
|
|
2206
|
+
* content fingerprint.
|
|
2207
|
+
*
|
|
2208
|
+
* THE SECOND HALF OF ISSUE #98, found on 2026-10-21 after 4.15.0 shipped. The
|
|
2209
|
+
* gateway does not only match the OPENING of a scanned message; it also matches
|
|
2210
|
+
* one long substring INSIDE the system prompt. Measured byte-exactly on a real
|
|
2211
|
+
* failed request (CN gateway, `deepseek-v4.1-flash`, the host's own body):
|
|
2212
|
+
*
|
|
2213
|
+
* - the refusal is reproducible, and it is not the opening: the plugin had
|
|
2214
|
+
* already prepended the neutral head, and the request still answered 400;
|
|
2215
|
+
* - bisecting the host's 10,956-character system prompt by lines and then by
|
|
2216
|
+
* sentences found a 221-character span: the DSH Web-GUI paragraph's first
|
|
2217
|
+
* two sentences ("You are interacting with the user through the DeepSeek
|
|
2218
|
+
* Harness Web GUI … they mean this GUI.");
|
|
2219
|
+
* - that span ALONE reproduces the 400, and the SAME span with ANY change
|
|
2220
|
+
* inside it answers 200 — one character rewritten, one quoted word
|
|
2221
|
+
* replaced, even a zero-width space inserted. An outer prefix or suffix
|
|
2222
|
+
* does not help, so the match is an exact substring, not an opening rule;
|
|
2223
|
+
* - the paragraph's individual sentences each answer 200, which is why the
|
|
2224
|
+
* plugin's opening-based normalisation never saw this.
|
|
2225
|
+
*
|
|
2226
|
+
* SO THE RESCUE CANNOT BE "prepend a line" (that is {@link forceNeutralHead},
|
|
2227
|
+
* and it is a no-op here). Nor can it be a list of refused spans: the list is
|
|
2228
|
+
* the upstream's own data, it already grew once without notice, and it will
|
|
2229
|
+
* grow again. What DOES generalise is the observation that an exact-substring
|
|
2230
|
+
* match dies on any perturbation inside it — so this inserts a zero-width space
|
|
2231
|
+
* at every sentence and clause boundary of the scanned messages, which is
|
|
2232
|
+
* invisible to the reader, meaningless to the model, and inside the refused
|
|
2233
|
+
* span with near-certainty for any prose fingerprint.
|
|
2234
|
+
*
|
|
2235
|
+
* WHEN IT IS USED: only as the last retry, after the gateway already refused
|
|
2236
|
+
* the request AND the neutral head could not change anything. Requests the
|
|
2237
|
+
* gateway answers keep their bytes untouched.
|
|
2238
|
+
*
|
|
2239
|
+
* Idempotent: a body that already carries U+200B in a scanned message is left
|
|
2240
|
+
* alone, so a retry never stacks perturbations. Returns `undefined` when
|
|
2241
|
+
* nothing would change, which is the caller's signal not to spend a request.
|
|
2242
|
+
*
|
|
2243
|
+
* Never throws: a malformed body is the upstream's to reject, and a parse error
|
|
2244
|
+
* must not turn a rescue into a crash.
|
|
2245
|
+
*/
|
|
2246
|
+
function perturbChannelFingerprints(bodyJson) {
|
|
2247
|
+
let parsed;
|
|
2248
|
+
try {
|
|
2249
|
+
parsed = JSON.parse(bodyJson);
|
|
2250
|
+
} catch {
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
2254
|
+
const obj = parsed;
|
|
2255
|
+
const messages = obj["messages"];
|
|
2256
|
+
if (!Array.isArray(messages)) return void 0;
|
|
2257
|
+
let changed = false;
|
|
2258
|
+
const perturb = (text) => {
|
|
2259
|
+
if (text.includes(ZERO_WIDTH_SPACE)) return void 0;
|
|
2260
|
+
const next = text.replace(/([.!?])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(/([。!?])/gu, `$1${ZERO_WIDTH_SPACE}`).replace(/([,;])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`);
|
|
2261
|
+
return next === text ? void 0 : next;
|
|
2262
|
+
};
|
|
2263
|
+
for (const value of messages) {
|
|
2264
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
2265
|
+
const message = value;
|
|
2266
|
+
if (!scansOpening(message["role"])) continue;
|
|
2267
|
+
const content = message["content"];
|
|
2268
|
+
if (typeof content === "string") {
|
|
2269
|
+
const next = perturb(content);
|
|
2270
|
+
if (next !== void 0) {
|
|
2271
|
+
message["content"] = next;
|
|
2272
|
+
changed = true;
|
|
2273
|
+
}
|
|
2274
|
+
continue;
|
|
2275
|
+
}
|
|
2276
|
+
if (!Array.isArray(content)) continue;
|
|
2277
|
+
for (const part of content) {
|
|
2278
|
+
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
|
|
2279
|
+
const record = part;
|
|
2280
|
+
const type = record["type"];
|
|
2281
|
+
if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
|
|
2282
|
+
const text = record["text"];
|
|
2283
|
+
if (typeof text !== "string") continue;
|
|
2284
|
+
const next = perturb(text);
|
|
2285
|
+
if (next !== void 0) {
|
|
2286
|
+
record["text"] = next;
|
|
2287
|
+
changed = true;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
if (!changed) return void 0;
|
|
2292
|
+
return JSON.stringify(obj);
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* The openings of every scanned message in a body, one entry per TEXT PART.
|
|
2296
|
+
*
|
|
2297
|
+
* `system` AND `assistant`, because both are read by the gate (measured; see
|
|
2298
|
+
* {@link scansOpening}). `developer` counts as a system message because the
|
|
2299
|
+
* plugin rewrites `developer` → `system` on the wire.
|
|
2300
|
+
*
|
|
2301
|
+
* Array content contributes ONE ENTRY PER PART, for the same reason the request
|
|
2302
|
+
* path neutralises every part: the gate reads every text part, so "which line
|
|
2303
|
+
* tripped it" can be any of them.
|
|
2304
|
+
*
|
|
2305
|
+
* Capped at {@link SYSTEM_EVIDENCE_LIMIT} entries, and never throws — losing
|
|
2306
|
+
* the failure measurement is not an acceptable price for reading an excerpt.
|
|
2307
|
+
*/
|
|
2308
|
+
function openingEvidenceOfBody(bodyJson) {
|
|
2309
|
+
let parsed;
|
|
2310
|
+
try {
|
|
2311
|
+
parsed = JSON.parse(bodyJson);
|
|
2312
|
+
} catch {
|
|
2313
|
+
return [];
|
|
2314
|
+
}
|
|
2315
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
|
|
2316
|
+
const messages = parsed["messages"];
|
|
2317
|
+
if (!Array.isArray(messages)) return [];
|
|
2318
|
+
const evidence = [];
|
|
2319
|
+
for (let index = 0; index < messages.length; index++) {
|
|
2320
|
+
if (evidence.length >= SYSTEM_EVIDENCE_LIMIT) break;
|
|
2321
|
+
const value = messages[index];
|
|
1933
2322
|
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
1934
2323
|
const message = value;
|
|
1935
2324
|
const role = message["role"];
|
|
1936
|
-
if (typeof role !== "string"
|
|
2325
|
+
if (typeof role !== "string") continue;
|
|
2326
|
+
const roleName = role.trim().toLowerCase();
|
|
2327
|
+
if (roleName !== "system" && roleName !== "assistant" && roleName !== "developer") continue;
|
|
2328
|
+
const roleOfEvidence = roleName === "assistant" ? "assistant" : "system";
|
|
1937
2329
|
const content = message["content"];
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
if (
|
|
1941
|
-
|
|
2330
|
+
const texts = [];
|
|
2331
|
+
if (typeof content === "string") texts.push(content);
|
|
2332
|
+
else if (Array.isArray(content)) for (const part of content) {
|
|
2333
|
+
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
|
|
2334
|
+
const record = part;
|
|
2335
|
+
const type = record["type"];
|
|
2336
|
+
if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
|
|
2337
|
+
const text = record["text"];
|
|
2338
|
+
if (typeof text === "string") texts.push(text);
|
|
2339
|
+
}
|
|
2340
|
+
for (const text of texts) {
|
|
2341
|
+
if (evidence.length >= SYSTEM_EVIDENCE_LIMIT) break;
|
|
2342
|
+
const opening = firstNonEmptyLineOf(text).replace(/\s+/gu, " ").trim();
|
|
2343
|
+
if (opening === "") continue;
|
|
2344
|
+
evidence.push({
|
|
2345
|
+
opening: clip(opening, SYSTEM_OPENING_LIMIT),
|
|
2346
|
+
role: roleOfEvidence,
|
|
2347
|
+
neutralized: startsWithNeutralHead(opening) || isUnapprovedChannelOpening(opening),
|
|
2348
|
+
messageIndex: index
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
1942
2351
|
}
|
|
2352
|
+
return evidence;
|
|
2353
|
+
}
|
|
2354
|
+
/**
|
|
2355
|
+
* How many openings the failure path reports.
|
|
2356
|
+
*
|
|
2357
|
+
* Eight is generous: the gate scans every system message and every text part
|
|
2358
|
+
* of it, so a body with more than a handful of openings is not a prompt the
|
|
2359
|
+
* reader can act on anyway — the reply names the first ones and the failure
|
|
2360
|
+
* body carries the rest.
|
|
2361
|
+
*
|
|
2362
|
+
* The cap is HERE and not in the reply: this function reports everything it
|
|
2363
|
+
* found (up to eight) and the reply renders as many of them as fit, saying
|
|
2364
|
+
* how many were left out. Truncating the evidence before the reply sees it
|
|
2365
|
+
* would make "N more" unanswerable.
|
|
2366
|
+
*/
|
|
2367
|
+
const SYSTEM_EVIDENCE_LIMIT = 8;
|
|
2368
|
+
/**
|
|
2369
|
+
* The first line of a text that carries something.
|
|
2370
|
+
*
|
|
2371
|
+
* Blank lines are SKIPPED: the neutral head is followed by an empty line, so
|
|
2372
|
+
* the head itself is the line worth reporting, and a content that opens with
|
|
2373
|
+
* blank lines still has an opening.
|
|
2374
|
+
*/
|
|
2375
|
+
function firstNonEmptyLineOf(content) {
|
|
2376
|
+
for (const line of content.split("\n")) if (line.trim() !== "") return line;
|
|
2377
|
+
return "";
|
|
1943
2378
|
}
|
|
1944
2379
|
/**
|
|
1945
2380
|
* Make the conversation open with a system message, prepending the fallback
|
|
@@ -2328,6 +2763,18 @@ function parseUpstreamModel(value) {
|
|
|
2328
2763
|
* is exposed rather than nothing: the roster is an upstream detail that may
|
|
2329
2764
|
* change, and an empty answer would silently disarm the provider.
|
|
2330
2765
|
*
|
|
2766
|
+
* ROUTERS ARE NOT MODELS. The upstream lists `auto` (CN) and its global
|
|
2767
|
+
* equivalents alongside real models, but those entries are ROUTING decisions,
|
|
2768
|
+
* not models: the gateway resolves them to whichever model it thinks fits, so
|
|
2769
|
+
* a row for one would offer a name whose price, context window and behaviour
|
|
2770
|
+
* are all decided at request time by something else. Selected this way it also
|
|
2771
|
+
* duplicates a real row — upstream resolves CN `auto` to `hy4-preview`, which
|
|
2772
|
+
* is already in the same roster. They are dropped here, at the single place
|
|
2773
|
+
* the roster is built, so the card, the picker, the probe and the discovery
|
|
2774
|
+
* route all agree. A user who already had one enabled keeps it through
|
|
2775
|
+
* `keepIds` below: this removes the OFFER, never a running model (issue #32's
|
|
2776
|
+
* failure mode).
|
|
2777
|
+
*
|
|
2331
2778
|
* `keepIds` is the user's own selection riding along with the refresh. The
|
|
2332
2779
|
* upstream roster may drop a model the user is actually running — the app
|
|
2333
2780
|
* channel's `cli` roster dropped `minimax-m2.7` on 2026-10-21 while the SAME
|
|
@@ -2351,10 +2798,11 @@ function selectCliModels(rawModels, agents, keepIds = []) {
|
|
|
2351
2798
|
}
|
|
2352
2799
|
}
|
|
2353
2800
|
const ids = cliIds !== void 0 && cliIds.length > 0 ? cliIds : [...byId.keys()];
|
|
2354
|
-
const models = ids.map((id) => byId.get(id)).filter((model) => model !== void 0);
|
|
2355
|
-
if (models.length === 0) throw new Error("workbuddy model catalog resolved to an empty list");
|
|
2801
|
+
const models = ids.filter((id) => !isRouterModelId(id)).map((id) => byId.get(id)).filter((model) => model !== void 0);
|
|
2356
2802
|
const kept = keepIds.filter((id) => !ids.includes(id)).map((id) => byId.get(id)).filter((model) => model !== void 0);
|
|
2357
|
-
|
|
2803
|
+
const resolved = [...models, ...kept];
|
|
2804
|
+
if (resolved.length === 0) throw new Error("workbuddy model catalog resolved to an empty list");
|
|
2805
|
+
return resolved;
|
|
2358
2806
|
}
|
|
2359
2807
|
/**
|
|
2360
2808
|
* The roster plus the promotion badges the SAME document carries.
|
|
@@ -4255,14 +4703,14 @@ var WorkBuddyCredentialStore = class {
|
|
|
4255
4703
|
* Static CLI models captured from the CN endpoint (2026-08-30). The upstream
|
|
4256
4704
|
* refresh replaces this list at startup; it exists so the provider registers
|
|
4257
4705
|
* with a usable catalog even while the first fetch is in flight or offline.
|
|
4706
|
+
*
|
|
4707
|
+
* The upstream's `auto` ROUTER is deliberately absent, matching what
|
|
4708
|
+
* `selectCliModels` now does to the live roster: it resolves to a different
|
|
4709
|
+
* model per request, so it is not a row this plugin can describe. Keeping it
|
|
4710
|
+
* here would have reintroduced it on the offline/第一帧 path only, which is the
|
|
4711
|
+
* hardest kind of inconsistency to notice.
|
|
4258
4712
|
*/
|
|
4259
4713
|
const FALLBACK_WORKBUDDY_MODELS = [
|
|
4260
|
-
{
|
|
4261
|
-
id: "auto",
|
|
4262
|
-
name: "Auto",
|
|
4263
|
-
contextWindow: 168e3,
|
|
4264
|
-
maxTokens: 32e3
|
|
4265
|
-
},
|
|
4266
4714
|
{
|
|
4267
4715
|
id: "hy3",
|
|
4268
4716
|
name: "Hy3",
|
|
@@ -4510,8 +4958,23 @@ function applyContextBudgets(catalog, budgets = {}) {
|
|
|
4510
4958
|
contextWindow: budgets[model.id] === void 0 ? model.contextWindow : Math.min(model.contextWindow, budgets[model.id])
|
|
4511
4959
|
}));
|
|
4512
4960
|
}
|
|
4961
|
+
/**
|
|
4962
|
+
* Derive the runtime catalog from the last-refreshed directory plus the user's
|
|
4963
|
+
* selection.
|
|
4964
|
+
*
|
|
4965
|
+
* An empty selection means "never configured" and serves the whole directory.
|
|
4966
|
+
* But a NON-empty selection that intersects the directory in nothing must NOT
|
|
4967
|
+
* serve nothing: that is the issue #32 failure mode (the picker empties out),
|
|
4968
|
+
* and it now has a realistic trigger — a user whose only ticked model was the
|
|
4969
|
+
* upstream's `auto` router, which this version stops offering. Such a selection
|
|
4970
|
+
* degrades to the whole directory rather than to silence: the user asked for
|
|
4971
|
+
* "some models to be on", and the alternative is a region with no models at
|
|
4972
|
+
* all. An empty directory still yields an empty result; the guard is only about
|
|
4973
|
+
* a selection that no longer matches anything.
|
|
4974
|
+
*/
|
|
4513
4975
|
function deriveCatalog(catalog, enabled, budgets = {}) {
|
|
4514
|
-
|
|
4976
|
+
const selected = enabled.size === 0 ? catalog : catalog.filter((model) => enabled.has(model.id));
|
|
4977
|
+
return applyContextBudgets(selected.length === 0 && enabled.size > 0 ? catalog : selected, budgets);
|
|
4515
4978
|
}
|
|
4516
4979
|
/**
|
|
4517
4980
|
* Mutable catalog shared by the shim's `/v1/models` and the adapter.
|
|
@@ -4770,7 +5233,7 @@ function generationOf(version) {
|
|
|
4770
5233
|
//#endregion
|
|
4771
5234
|
//#region src/version.ts
|
|
4772
5235
|
/** The npm package version this build was produced from. */
|
|
4773
|
-
const WORKBUDDY_CONNECT_VERSION = "4.
|
|
5236
|
+
const WORKBUDDY_CONNECT_VERSION = "4.15.1";
|
|
4774
5237
|
//#endregion
|
|
4775
5238
|
//#region src/host-heartbeat.ts
|
|
4776
5239
|
/**
|
|
@@ -4928,4 +5391,4 @@ async function clearHostHeartbeat() {
|
|
|
4928
5391
|
await rm(workbuddyHostHeartbeatPath(), { force: true });
|
|
4929
5392
|
}
|
|
4930
5393
|
//#endregion
|
|
4931
|
-
export {
|
|
5394
|
+
export { lastRefusalFor as $, workbuddyAccountId as A, isCredentialRejectedError as B, defaultDesktopAuthDirs as C, withFileLock as Ct, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, parseCreditMultiplier as G, knownRegionOf as H, classifyUpstreamError as I, perturbChannelFingerprints as J, parseReasoning as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, defaultOffModelIds as Q, forceNeutralHead as R, defaultDesktopAuthCandidates as S, resolveDshHome as St, hasEncryptedCredentialFields as T, modelIdOfBody as U, isFirstMessageGateBody as V, openingEvidenceOfBody as W, regionOf as X, prepareChatBody as Y, REFUSES_OFF_MODEL_IDS as Z, WORKBUDDY_AUTH_FILENAME as _, macosNestedAppBundles as _t, readHostHeartbeat as a, imageDefaultFor as at, WorkBuddyEncryptedCredentialError as b, workbuddyAppExecutableCandidates as bt, WORKBUDDY_CONNECT_VERSION as c, clearAtRestKeyCache as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, fetchAtRestKeyPayload as dt, offDefaultFor as et, WorkBuddyCatalog as f, findWorkbuddyAppExecutable as ft, ENCRYPTED_CREDENTIAL_CODE as g, macosBundleExecutable as gt, fallbackModelsFor as h, isWorkbuddyBundle as ht, processStartTimeMs as i, NATIVE_MODALITY_BY_MODEL_ID as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, deriveAtRestKey as lt, deriveCatalog as m, isEncryptedFieldWrapper as mt, clearHostHeartbeat as n, recordRefusal as nt, workbuddyHostHeartbeatPath as o, nativeModalityOf as ot, applyContextBudgets as p, findWorkbuddyAppExecutableWithSource as pt, parseUpstreamModel as q, isHeartbeatProcessAlive as r, withEffectiveOff as rt, writeHostHeartbeat as s, WORKBUDDY_APP_EXECUTABLE_ENV as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, offRefused as tt, FALLBACK_WORKBUDDY_MODELS as u, deriveAtRestKeyId as ut, WORKBUDDY_AUTH_FILE_ENV as v, openEncryptedField as vt, defaultDesktopAuthPath as w, writeFileAtomic as wt, authFileName as x, hostPeersInfo as xt, WorkBuddyCredentialStore as y, readAtRestKey as yt, isConversationIdGateBody as z };
|
package/lib/index.d.ts
CHANGED
|
@@ -1286,6 +1286,12 @@ type WorkBuddyModelInfo = WorkBuddyUpstreamModel;
|
|
|
1286
1286
|
* Static CLI models captured from the CN endpoint (2026-08-30). The upstream
|
|
1287
1287
|
* refresh replaces this list at startup; it exists so the provider registers
|
|
1288
1288
|
* with a usable catalog even while the first fetch is in flight or offline.
|
|
1289
|
+
*
|
|
1290
|
+
* The upstream's `auto` ROUTER is deliberately absent, matching what
|
|
1291
|
+
* `selectCliModels` now does to the live roster: it resolves to a different
|
|
1292
|
+
* model per request, so it is not a row this plugin can describe. Keeping it
|
|
1293
|
+
* here would have reintroduced it on the offline/第一帧 path only, which is the
|
|
1294
|
+
* hardest kind of inconsistency to notice.
|
|
1289
1295
|
*/
|
|
1290
1296
|
declare const FALLBACK_WORKBUDDY_MODELS: readonly WorkBuddyModelInfo[];
|
|
1291
1297
|
/**
|
|
@@ -1312,6 +1318,20 @@ declare function fallbackModelsFor(region: 'cn' | 'global'): readonly WorkBuddyM
|
|
|
1312
1318
|
* never been configured must still serve models rather than nothing.
|
|
1313
1319
|
*/
|
|
1314
1320
|
type WorkBuddyContextBudget = number;
|
|
1321
|
+
/**
|
|
1322
|
+
* Derive the runtime catalog from the last-refreshed directory plus the user's
|
|
1323
|
+
* selection.
|
|
1324
|
+
*
|
|
1325
|
+
* An empty selection means "never configured" and serves the whole directory.
|
|
1326
|
+
* But a NON-empty selection that intersects the directory in nothing must NOT
|
|
1327
|
+
* serve nothing: that is the issue #32 failure mode (the picker empties out),
|
|
1328
|
+
* and it now has a realistic trigger — a user whose only ticked model was the
|
|
1329
|
+
* upstream's `auto` router, which this version stops offering. Such a selection
|
|
1330
|
+
* degrades to the whole directory rather than to silence: the user asked for
|
|
1331
|
+
* "some models to be on", and the alternative is a region with no models at
|
|
1332
|
+
* all. An empty directory still yields an empty result; the guard is only about
|
|
1333
|
+
* a selection that no longer matches anything.
|
|
1334
|
+
*/
|
|
1315
1335
|
declare function deriveCatalog(catalog: readonly WorkBuddyModelInfo[], enabled: ReadonlySet<string>, budgets?: Readonly<Record<string, WorkBuddyContextBudget | undefined>>): WorkBuddyModelInfo[];
|
|
1316
1336
|
/**
|
|
1317
1337
|
* Mutable catalog shared by the shim's `/v1/models` and the adapter.
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as lastRefusalFor, A as workbuddyAccountId, B as isCredentialRejectedError, C as defaultDesktopAuthDirs, Ct as withFileLock, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as parseCreditMultiplier, H as knownRegionOf, I as classifyUpstreamError, J as perturbChannelFingerprints, K as parseReasoning, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as defaultOffModelIds, R as forceNeutralHead, S as defaultDesktopAuthCandidates, St as resolveDshHome, T as hasEncryptedCredentialFields, U as modelIdOfBody, V as isFirstMessageGateBody, W as openingEvidenceOfBody, X as regionOf, Y as prepareChatBody, Z as REFUSES_OFF_MODEL_IDS, _ as WORKBUDDY_AUTH_FILENAME, _t as macosNestedAppBundles, a as readHostHeartbeat, at as imageDefaultFor, b as WorkBuddyEncryptedCredentialError, bt as workbuddyAppExecutableCandidates, c as WORKBUDDY_CONNECT_VERSION, ct as clearAtRestKeyCache, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as fetchAtRestKeyPayload, et as offDefaultFor, f as WorkBuddyCatalog, ft as findWorkbuddyAppExecutable, g as ENCRYPTED_CREDENTIAL_CODE, gt as macosBundleExecutable, h as fallbackModelsFor, ht as isWorkbuddyBundle, i as processStartTimeMs, it as NATIVE_MODALITY_BY_MODEL_ID, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as deriveAtRestKey, m as deriveCatalog, mt as isEncryptedFieldWrapper, n as clearHostHeartbeat, nt as recordRefusal, o as workbuddyHostHeartbeatPath, ot as nativeModalityOf, p as applyContextBudgets, q as parseUpstreamModel, r as isHeartbeatProcessAlive, rt as withEffectiveOff, s as writeHostHeartbeat, st as WORKBUDDY_APP_EXECUTABLE_ENV, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as offRefused, u as FALLBACK_WORKBUDDY_MODELS, ut as deriveAtRestKeyId, v as WORKBUDDY_AUTH_FILE_ENV, vt as openEncryptedField, w as defaultDesktopAuthPath, wt as writeFileAtomic, x as authFileName, y as WorkBuddyCredentialStore, yt as readAtRestKey, z as isConversationIdGateBody } from "./host-heartbeat-DtQIBMYm.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -2960,6 +2960,43 @@ function clippedUpstreamText(text, limit) {
|
|
|
2960
2960
|
return `${cut.trimEnd()}…`;
|
|
2961
2961
|
}
|
|
2962
2962
|
/**
|
|
2963
|
+
* The channel-gate reply's evidence sentence: what the gateway actually read.
|
|
2964
|
+
*
|
|
2965
|
+
* Read from the HOST's body, not from the prepared one, because the prepared
|
|
2966
|
+
* body has the plugin's neutral head in front of every opening it recognises —
|
|
2967
|
+
* so the first line it could report is the plugin's own placeholder, which is
|
|
2968
|
+
* exactly the false lead issue #98's reporters were given. They were told the
|
|
2969
|
+
* gateway had refused `You are a helpful assistant.`, a line that returns 200
|
|
2970
|
+
* when sent alone.
|
|
2971
|
+
*
|
|
2972
|
+
* Each entry is labelled with its ROLE (`system` / `assistant`) because both are
|
|
2973
|
+
* scanned and the difference is what tells the reader where to look: an
|
|
2974
|
+
* `assistant` entry is a history message, i.e. the session is poisoned and every
|
|
2975
|
+
* later turn will fail the same way until that line is changed.
|
|
2976
|
+
*
|
|
2977
|
+
* Each entry also says whether the plugin's own neutralisation already covers
|
|
2978
|
+
* it. An entry marked "not in the plugin's list" is the actionable one: that
|
|
2979
|
+
* opening goes on the wire verbatim, so either the host wrote it or the
|
|
2980
|
+
* gateway's list grew.
|
|
2981
|
+
*
|
|
2982
|
+
* Capped at {@link CHANNEL_EVIDENCE_LIMIT} entries: a session can carry dozens
|
|
2983
|
+
* of system/assistant messages, and a wall of them would bury the one that
|
|
2984
|
+
* matters. The remainder is counted instead of listed.
|
|
2985
|
+
*/
|
|
2986
|
+
function channelEvidenceText(bodyJson) {
|
|
2987
|
+
const entries = openingEvidenceOfBody(bodyJson);
|
|
2988
|
+
if (entries.length === 0) return "(请求里没有可读的 system 或 assistant 消息开头。)";
|
|
2989
|
+
const shown = entries.slice(0, CHANNEL_EVIDENCE_LIMIT);
|
|
2990
|
+
const rendered = shown.map((entry) => {
|
|
2991
|
+
const label = entry.neutralized ? "(插件已加中性头)" : "(不在插件已知名单里,网关可能新增了词条)";
|
|
2992
|
+
return `「${entry.role}:${entry.opening}」${label}`;
|
|
2993
|
+
}).join("、");
|
|
2994
|
+
const hidden = entries.length - shown.length;
|
|
2995
|
+
return `请求里被网关扫描的消息开头:${rendered}${hidden > 0 ? `……另有 ${hidden} 条未列出。` : "。"}`;
|
|
2996
|
+
}
|
|
2997
|
+
/** How many message openings the channel-gate reply lists. */
|
|
2998
|
+
const CHANNEL_EVIDENCE_LIMIT = 4;
|
|
2999
|
+
/**
|
|
2963
3000
|
* The line prepended to an answer that came from a failover account.
|
|
2964
3001
|
*
|
|
2965
3002
|
* Block-quoted and bracketed so it reads as machinery rather than as the model's
|
|
@@ -3284,6 +3321,56 @@ function createWorkBuddyShim(options) {
|
|
|
3284
3321
|
const controller = new AbortController();
|
|
3285
3322
|
res.on("close", () => controller.abort());
|
|
3286
3323
|
let result = await client.chatStream(credential, prepared, controller.signal);
|
|
3324
|
+
/**
|
|
3325
|
+
* The retry ladder that does not depend on knowing the fingerprint
|
|
3326
|
+
* (issue #98, both halves).
|
|
3327
|
+
*
|
|
3328
|
+
* The gateway refuses the request for data the plugin cannot see: a list of
|
|
3329
|
+
* refused `system`/`assistant` OPENINGS (the plugin holds a measured
|
|
3330
|
+
* SNAPSHOT, `UNAPPROVED_CHANNEL_OPENINGS`) and, since 2026-10-21, at least
|
|
3331
|
+
* one long substring INSIDE the prompt. Every account and every region
|
|
3332
|
+
* sends the same bytes, so without a rescue the only outcome is a dead end
|
|
3333
|
+
* the reader cannot get out of.
|
|
3334
|
+
*
|
|
3335
|
+
* Two rungs, both on the SAME credential (the gate reads the request, not
|
|
3336
|
+
* the account, which is why `channel_reject` is not failover-worthy):
|
|
3337
|
+
*
|
|
3338
|
+
* 1. `forceNeutralHead` — move every scanned opening behind the neutral
|
|
3339
|
+
* head. Measured: whatever the banned opening is, moving it off the
|
|
3340
|
+
* start of the content answers 200.
|
|
3341
|
+
* 2. `perturbChannelFingerprints` — insert zero-width spaces at the
|
|
3342
|
+
* sentence boundaries of the scanned messages, which breaks an
|
|
3343
|
+
* exact-substring fingerprint without changing a word the model reads.
|
|
3344
|
+
* This is the rung that rescues the inside-the-prompt fingerprints,
|
|
3345
|
+
* where rung 1 is a no-op.
|
|
3346
|
+
*
|
|
3347
|
+
* Both return `undefined` when they would change nothing; that is the
|
|
3348
|
+
* signal not to spend a request on a body that would be refused identically.
|
|
3349
|
+
*
|
|
3350
|
+
* Nothing has been written to the client at this point (`!ok` means the
|
|
3351
|
+
* upstream answered non-2xx before any stream started), so a retry cannot
|
|
3352
|
+
* splice two responses together. The first attempt's failure is NOT booked
|
|
3353
|
+
* as an account failure: it was the request that was refused, not the
|
|
3354
|
+
* credential.
|
|
3355
|
+
*/
|
|
3356
|
+
let retriedWithForcedHead = false;
|
|
3357
|
+
let retriedWithPerturbation = false;
|
|
3358
|
+
if (!result.ok && result.kind === "channel_reject" && !controller.signal.aborted) {
|
|
3359
|
+
const forced = forceNeutralHead(prepared);
|
|
3360
|
+
if (forced !== void 0) {
|
|
3361
|
+
logger?.warn("dsh-connect-workbuddy: the channel gate refused the body; retrying once with the neutral head forced in front of every opening");
|
|
3362
|
+
result = await client.chatStream(credential, forced, controller.signal);
|
|
3363
|
+
retriedWithForcedHead = true;
|
|
3364
|
+
}
|
|
3365
|
+
if (!result.ok && result.kind === "channel_reject" && !controller.signal.aborted) {
|
|
3366
|
+
const rescued = perturbChannelFingerprints(forced ?? prepared);
|
|
3367
|
+
if (rescued !== void 0) {
|
|
3368
|
+
logger?.warn("dsh-connect-workbuddy: the channel gate refused the body again; retrying once with an invisible perturbation of the scanned text");
|
|
3369
|
+
result = await client.chatStream(credential, rescued, controller.signal);
|
|
3370
|
+
retriedWithPerturbation = true;
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3287
3374
|
const triedAccountIds = [];
|
|
3288
3375
|
/**
|
|
3289
3376
|
* The account the request STARTED on, and the reason it was abandoned.
|
|
@@ -3390,9 +3477,12 @@ function createWorkBuddyShim(options) {
|
|
|
3390
3477
|
if (detail?.upstreamCode !== void 0) meta.push(`code ${detail.upstreamCode}`);
|
|
3391
3478
|
if (detail?.requestId !== void 0) meta.push(`requestId ${detail.requestId}`);
|
|
3392
3479
|
const stamp = meta.length === 0 ? "" : `(${meta.join(",")})`;
|
|
3393
|
-
const
|
|
3394
|
-
const
|
|
3395
|
-
|
|
3480
|
+
const evidence = channelEvidenceText(raw);
|
|
3481
|
+
const retries = [];
|
|
3482
|
+
if (retriedWithForcedHead) retries.push("强制中性头");
|
|
3483
|
+
if (retriedWithPerturbation) retries.push("一次不可见扰动");
|
|
3484
|
+
const retryNote = retries.length === 0 ? "" : `,并已用${retries.join("与")}重试过`;
|
|
3485
|
+
const message = `workbuddy 上游失败:${failoverReasonText(result)}(http ${result.status})${note}${stamp}。${evidence}该请求被 WorkBuddy 网关的渠道校验拦截,上游把它描述成「不是官方客户端发出的」,但那是误导性文案:网关匹配的是每一条 system 与 assistant 消息的开头,与账号、积分、额度、网络都无关,换账号或换区域也不会改变结果。插件已对已知的第三方 agent 指纹做归一化` + retryNote + ";若上面标着「不在插件已知名单里」的开头来自宿主或其他插件,请修改该提示;新的指纹可反馈到 issue 里";
|
|
3396
3486
|
writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, message, hostErrorCode(result.kind));
|
|
3397
3487
|
return;
|
|
3398
3488
|
}
|
|
@@ -3401,10 +3491,11 @@ function createWorkBuddyShim(options) {
|
|
|
3401
3491
|
if (detail?.upstreamCode !== void 0) meta.push(`code ${detail.upstreamCode}`);
|
|
3402
3492
|
if (detail?.requestId !== void 0) meta.push(`requestId ${detail.requestId}`);
|
|
3403
3493
|
const stamp = meta.length === 0 ? "" : `(${meta.join(",")})`;
|
|
3494
|
+
const siblingGate = isConversationIdGateBody(result.message) ? "该请求被上游以「conversation id missing」拒绝:网关要求每个请求带上会话 id 头(X-Conversation-ID、X-Conversation-Request-ID、X-Conversation-Message-ID),插件每次都会带上这三个头;仍出现说明这次请求没有走插件的聊天通道,请升级插件或反馈 issue" : isFirstMessageGateBody(result.message) ? "该请求被上游以「first message is not system prompt」拒绝:网关要求首条消息是 system 消息;插件在请求带消息时会保证这一点,所以出现这条说明这次请求本身没有消息,或没有走插件的聊天通道,请升级插件或反馈 issue" : void 0;
|
|
3404
3495
|
const refusedTier = reasoningTierRefusal(result);
|
|
3405
3496
|
const receipt = refusedTier ?? detail?.tips ?? detail?.displayMsg ?? clippedUpstreamText(result.message, 400);
|
|
3406
3497
|
const advice = refusedTier !== void 0 ? "这不是账号或网络问题:该模型实测拒绝「off」推理档位,请到模型卡片里取消该行的「可关闭思考」勾选后重试" : result.kind === "client" ? "该请求被上游按参数拒绝:与网络、账号、额度都无关,换账号也不会改变结果,请按上面的提示调整后重试" : shortfall === void 0 ? "" : "建议换一个模型继续,或稍后等待该账号的额度恢复";
|
|
3407
|
-
const body = advice === "" ? receipt : `${receipt.replace(/[。.]$/u, "")}。${advice}`;
|
|
3498
|
+
const body = siblingGate !== void 0 ? siblingGate : advice === "" ? receipt : `${receipt.replace(/[。.]$/u, "")}。${advice}`;
|
|
3408
3499
|
writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy 上游失败:${failoverReasonText(result)}(http ${result.status})${note}${why}${stamp}。${body}`, hostErrorCode(result.kind));
|
|
3409
3500
|
return;
|
|
3410
3501
|
}
|
|
@@ -6173,55 +6264,6 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
6173
6264
|
}, "dsh-connect-workbuddy: Web status route");
|
|
6174
6265
|
}
|
|
6175
6266
|
//#endregion
|
|
6176
|
-
//#region src/native-modality.ts
|
|
6177
|
-
/**
|
|
6178
|
-
* Reviewed vendor classifications, keyed by EXACT model id (never a prefix).
|
|
6179
|
-
* Ids absent from this record are `unknown` — see {@link nativeModalityOf}.
|
|
6180
|
-
*/
|
|
6181
|
-
const NATIVE_MODALITY_BY_MODEL_ID = {
|
|
6182
|
-
"deepseek-v4-pro": "text",
|
|
6183
|
-
"glm-5.1": "text",
|
|
6184
|
-
"glm-5.2": "text",
|
|
6185
|
-
"glm-5.3": "text",
|
|
6186
|
-
"hy3": "text",
|
|
6187
|
-
"hy4-preview": "text",
|
|
6188
|
-
"deepseek-v4.1-flash": "multimodal",
|
|
6189
|
-
"glm-5.3-flash": "multimodal",
|
|
6190
|
-
"glm-5v-turbo": "multimodal",
|
|
6191
|
-
"kimi-k2.6": "multimodal",
|
|
6192
|
-
"kimi-k2.7": "multimodal",
|
|
6193
|
-
"kimi-k3-1": "multimodal",
|
|
6194
|
-
"minimax-m3": "multimodal"
|
|
6195
|
-
};
|
|
6196
|
-
/** The `auto` entry is a router that picks a model, not a model itself. */
|
|
6197
|
-
const ROUTER_MODEL_ID = "auto";
|
|
6198
|
-
/**
|
|
6199
|
-
* Native modality of one model id. Exact match only: a new model never
|
|
6200
|
-
* inherits a classification from its family, its display name, or the
|
|
6201
|
-
* platform's image flag.
|
|
6202
|
-
*/
|
|
6203
|
-
function nativeModalityOf(modelId) {
|
|
6204
|
-
if (modelId === ROUTER_MODEL_ID) return "router";
|
|
6205
|
-
return NATIVE_MODALITY_BY_MODEL_ID[modelId] ?? "unknown";
|
|
6206
|
-
}
|
|
6207
|
-
/**
|
|
6208
|
-
* Whether a model refresh should PRE-CHECK this model's image box.
|
|
6209
|
-
*
|
|
6210
|
-
* Only a documented `multimodal` is pre-checked. `text` is documented as
|
|
6211
|
-
* unable to read images, and `unknown` is deliberately left unchecked rather
|
|
6212
|
-
* than guessed — the whole reason this table exists is that the platform flag
|
|
6213
|
-
* guessed wrong. A platform `supportsImages: false` still vetoes, so a
|
|
6214
|
-
* documented-multimodal id can never be pre-checked against the platform's
|
|
6215
|
-
* explicit "no".
|
|
6216
|
-
*
|
|
6217
|
-
* The user can always tick a box by hand; this decides a DEFAULT only, and the
|
|
6218
|
-
* effective runtime flag remains the saved `imageModelIds`.
|
|
6219
|
-
*/
|
|
6220
|
-
function imageDefaultFor(info) {
|
|
6221
|
-
if (info.supportsImages === false) return false;
|
|
6222
|
-
return NATIVE_MODALITY_BY_MODEL_ID[info.id] === "multimodal";
|
|
6223
|
-
}
|
|
6224
|
-
//#endregion
|
|
6225
6267
|
//#region src/index.ts
|
|
6226
6268
|
/** Stable Cordis plugin name. */
|
|
6227
6269
|
const name = "dsh-connect-workbuddy";
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-connect-workbuddy",
|
|
3
3
|
"displayName": "DSH Connect WorkBuddy",
|
|
4
4
|
"description": "把本机登录的 WorkBuddy 模型(国内版与国际版)接入 DeepSeek Harness:每次刷新按上游目录自动读取每个模型的积分倍率,支持逐模型图片输入开关、多账号切换与账号池自动换号,并提供每日签到、卡片与输入框里的胶囊积分读数等只读积分概览。",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.15.1",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|