@dmh2002/dsh-connect-qoder 0.3.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +129 -0
- package/README.en.md +3 -3
- package/README.md +4 -4
- package/lib/bin.js +1 -1
- package/lib/client.js +117 -35
- package/lib/{host-heartbeat-Cx880iUX.js → host-heartbeat-DOru8uF9.js} +165 -13
- package/lib/index.d.ts +137 -1
- package/lib/index.js +121 -34
- package/lib/{transfer-BepnYpWY.js → transfer-BQMtjWb5.js} +1 -1
- package/package.json +29 -27
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,135 @@
|
|
|
2
2
|
|
|
3
3
|
格式参照 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## 0.4.2 (2026-10-13)
|
|
6
|
+
|
|
7
|
+
用户实拍:国际版账号「ddd」显示 **「✗ Qoder 拒绝了这个账号的 PAT——请添加新 PAT 或换一个账号」**,并附一句「我感觉大概率还是我们出了问题」。**这个判断是对的**:账号一切正常,PAT 没有任何问题,是我们把上游的一句「今天用满了」读成了「你的凭据坏了」。0.4.1 修的是文案自相矛盾,这一版修的是**判据本身**。
|
|
8
|
+
|
|
9
|
+
同族 `dsh-connect-workbuddy` 在 4.4.2 修掉了那层**保存/删除/导入时的忙碌遮罩**(卡片是近黑板子、spinner 与外壳不同拍),本版把该改动逐条对齐过来 —— 两族共用同一套 `dsm-*` 卡片语言,遮罩也该是同一套。
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **任何 HTTP 403 都被判成「凭据被拒」—— 业务码明明就在同一个响应体里,却没被读。**
|
|
14
|
+
- **实测(本机真实 PAT,`scripts/probe-cosy.mjs global`,只读)**:
|
|
15
|
+
|
|
16
|
+
| 检查 | 结果 |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| PAT 换令牌 | ✅ 成功(姓名可读,`hasOauthToken: true`) |
|
|
19
|
+
| 模型目录 | ✅ 15 个模型,`qfmodel` 在列 |
|
|
20
|
+
| 积分(sash 面) | ✅ `userQuota {total:300, used:0, remaining:300}` + `addOnQuota {total:200}`,`isQuotaExceeded: false` |
|
|
21
|
+
| **对话(chat 面)** | ❌ `HTTP 403 {"code":"110","message":"Billing daily count exceeded"}` |
|
|
22
|
+
| 换 `efficient` 重试 | ❌ **同一个 110** —— 证明与模型 key 无关 |
|
|
23
|
+
|
|
24
|
+
- **官方码表**([docs.qoder.com/zh/cli/sdk/errors](https://docs.qoder.com/zh/cli/sdk/errors))是决定性依据:`105` 才是「登录态或 Access Token 已过期 → 换新凭据」;**`110` = 「达到当日用量限制 → 等待用量周期重置」**,`113`/`115`–`119`/`122` 同属用量与配额族。也就是说「403 就是凭据坏了」这个推断**在协议层面根本不成立**——403 只是承载码的容器。
|
|
25
|
+
- **三层缺陷叠加,缺一不可**(这也是它一直没被查出来的原因):
|
|
26
|
+
1. `classifyBusinessCode` 只认 `105` / `115` / `402` / `6004`,**`110` 根本不在表里**,落到 `unknown`;
|
|
27
|
+
2. 更关键:`403 + 110` **从来没走到过 `classifyBusinessCode`**。SSE 信封分支与 chat 的非 200 分支都只调 `classifyHttpStatus`,于是 `403 → auth`;
|
|
28
|
+
3. `onServingFailure` 把上游原文**丢掉了**(参数写成 `_message`),落盘的记录只剩一句结论 —— 实测那条 `credential-rejected` 记录**连 `message` 字段都没有**,卡片因此只能空口指控,而真相就在被丢掉的那串字符里。探针路径早就带原文(`redactUpstreamText`),**两条路径写同一个文件却只有一个带**。
|
|
29
|
+
- **危害不止文案难听**:`credential-rejected` 在账号池里是**无条件永久排除**(`exclusionOf` 刻意不看冷却期,理由写在注释里:令牌被拒,等也没用)。这个理由对 `105` 成立、对 `110` 恰好相反 —— 一个明天自动恢复的账号被踢出池且**不自愈**,同时被要求去做一件根本无效的事(换 PAT)。
|
|
30
|
+
- **修法**:新增 `classifyFailure(status, detail)` —— **业务码优先于 HTTP 状态,认不出的码才回落状态**(`TOKEN_EXPIRE` 这类字符串码、非 JSON 的 body、空 body 全部维持原行为,真被拒的那一侧不能被改坏);`businessCodeOf` 把上游两种写法(`"110"` 与裸数字 `110`)归一,取不到就返回 `undefined`,**绝不猜**;官方码表按族补全(配额 / 请求 / 瞬时);SSE 信封与 `chatStream` 两条真路径接入;`onServingFailure` 的那段内联字面量抽成模块级纯函数 `liveRequestProbe`,**带上脱敏后的原文**。
|
|
31
|
+
- **修好误判后暴露出第二个错(同轮改掉)**:110 正确落到 `out-of-credit` 档之后,既有文案是「积分不足 / Not enough credits」—— **而这个账号实测还有 500 积分、`isQuotaExceeded: false`**。`110` 说的是「当日用量**次数**」,与余额无关;照发就是第二句自相矛盾的话(0.4.1 刚为第一句发过补丁)。改成「用量已达上限 / Usage limit reached」,排除芯片与「池里没有可用账号」的提示语**同口径**(否则同一张卡片上两个词指同一件事)。带恢复时刻的那格也去掉了「积分包」这个限定 —— 恢复时刻也可能来自上游的重置句子,写死来源就是编造。
|
|
32
|
+
- **现场复核(改动后打真实上游)**:同一条请求现在报 `kind=quota-exhausted`,不再是 `auth`。
|
|
33
|
+
- **测试 +27(515 → 542)**:`tests/upstream.spec.ts` +17(逐字锚定现场抓到的 `HTTP 403 {"code":"110","message":"Billing daily count exceeded"}`;整族码覆盖;`105` 仍判 auth;认不出的码回落;**六条真路径全部端到端钉住**),`tests/index.spec.ts` +6(`liveRequestProbe` 的映射、原文必带、空原因不写成空串、**PAT 不落盘**、内容策略与不可映射 kind 不写池),`tests/locales.spec.ts` +4(渲染后的实际文字,不是 key 名)。
|
|
34
|
+
- **真接线是六条,不是两条 —— 这是本轮最该诚实记下的一句。** 第一稿只给 `chatStream` 的非 200 分支和 SSE 信封补了端到端测试,**忘了还有四个调用点同样只按状态分类却一个测试都没有**(换令牌 `/jobToken`、积分主路径 `/me/usage`、签到 `/campaigns/**`、模型列表 `/model/list`)。改完那四处之后做了「退化回只看状态」的变异:前两个与信封都立刻被接住,而**模型列表那条跑出的是全绿** —— 又一个漏掉的接线。补上 `fetchModels` 钉住它之后,六个调用点各自退化**都**被对应测试接住。教训还是同一个:分判据(`classifyFailure`)写得再准,**只要有一条真路径没接线,全量测试照样绿**。
|
|
35
|
+
- **八组变异测试**:业务码优先整体关掉 / 信封路径退回只看状态 / `chatStream` 退回只看状态 / 换令牌路径退回只看状态 / 积分主路径退回只看状态 / 签到路径退回只看状态 / 模型列表路径退回只看状态(第一遍**没被接住**,补测试后才接住)/ 丢掉上游原文 / 从配额族删掉 `110` / `businessCodeOf` 不认裸数字 / 文案退回「积分耗尽」与「积分包 {at} 刷新」—— 各自被对应测试接住。
|
|
36
|
+
- **两处诚实记录**:(a) 第一轮变异(只改 `chatStream` 那一行)跑出全绿,正是「真路径没测试」的直接证据 —— 这不是免责,是当时漏掉另外四处的根因;(b) 一次变异脚本的 perl 正则报错(未闭合括号)导致该组**根本没落地**,当时的「通过」是假信号,重跑并校验「变异确已落地」之后才有结论。
|
|
37
|
+
- **既有误判记录会不会一直卡着用户**:不会,但要说清路径。升级后第一次对话仍会失败(今天确实用满了),而这次失败会**覆写**那条记录为 `out-of-credit` —— 它带 30 分钟兜底冷却,会自动回池。`resolveServing`(首发射给谁)**不看探针**,只有池内故障转移与卡片「已排除」芯片看,所以直接选中该账号的用户始终能正常用它。上游对 110 **没给恢复时刻**(英文原文里没有可解析的重置句子),插件因此**不自造零点**,按既有的 30 分钟兜底重试;真正的恢复以用量周期为准。
|
|
38
|
+
- **忙碌遮罩(保存 / 删除 / 导入时那层)改用宿主主题令牌,spinner 换成宿主同款弧线。** 对齐同族 `dsh-connect-workbuddy` 4.4.2(同一处缺陷的修复),三处自造值逐一换掉:
|
|
39
|
+
- 遮罩底色原写死 `rgba(0,0,0,.38)` —— 既不跟主题也没有宿主的模糊。改用宿主自己遮罩用的 `--dsw-alias-bg-mask-1`(亮 `.24` / 暗 `.5`)+ `--dsw-mask-blur`,与外壳里其他遮罩一致。
|
|
40
|
+
- 卡片底色原用 `--dsw-alias-bg-primary`,**这个变量在宿主令牌集里根本不存在**(宿主只有 `bg-base` / `bg-layer-1..3` / `bg-module-platform` / `bg-overlay`),于是一路回退到本文件写死的近黑 `#1b1c20` —— 这就是「一块黑板子、上面的字看不清」的根因。改用宿主给弹层用的 `--dsw-alias-bg-layer-2`,亮暗两套各自成立;阴影也换成宿主自己的 `--dsw-elevation-prominent`。
|
|
41
|
+
- 提示行原用 `label-tertiary` —— 那是宿主最暗的一档(亮 `neutral-bluish-600` / 暗 `neutral-bluish-400`),给元数据用的,不该承载一句要等的人必须读的话。改用 `label-secondary`。
|
|
42
|
+
- **spinner**:原是 18px 的 CSS border 环、`.7s` 匀速转,比宿主自己的状态转圈(`_dsh-state-dot-spin`:SVG 弧线 + dash 呼吸、`1.5s`)快了近一倍 —— 比起形状,**这个速度差才是它看着廉价的主因**。改为同款弧线几何与 `1.5s` 节奏,`currentColor` 继承所在层级,reduced-motion 降级为静态弧线(与宿主降级方式一致),并补 `aria-hidden` 免得 `role="status"` 之外多报一次。
|
|
43
|
+
- 测试 +4(`tests/styles.spec.ts` 6 → 10)。**其中一条断言第一版是空转的,已修**:`toContain('--dsw-alias-bg-layer-2')` 对全文匹配 —— 该令牌在文件里另有 7 处出处,于是把遮罩退回自造值也照样通过。改成按 `ruleOf(...)` 取**遮罩规则自己的声明体**断言,五组变异(卡片底色退回 `bg-primary` / 遮罩退回写死黑幕 / hint 退回 `label-tertiary` / spinner 退回 18px border 环 / 删掉 dash keyframes)才各自转红。另有一条守卫按**源文件顶层语句数**断言,专防「模板字符串里的反引号提前截断整个样式表、而 typecheck 与 build 都不报错」(同族真实踩过)。
|
|
44
|
+
|
|
45
|
+
## 0.4.1 (2026-10-13)
|
|
46
|
+
|
|
47
|
+
0.4.0 上线后一小时里回来的两个小修:**签到单元格那句自相矛盾的文案**(用户实拍),以及**连红 6 次的 windows CI job**。后者顺带暴露了一个流程洞:发布只跑本地 `check`(macOS),没人看 CI。
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **签到那一格「已签到 · 00:39:22 后刷新」自相矛盾 —— 两句话说的是不同的窗**(用户实拍,并直接指出:「这个是 39 分钟后可以签到,所以不能显示已签到」)。
|
|
52
|
+
- **根因是两个字段来自两个窗口**:`checkedInToday` 是**上一窗**的 `claimStatus`,而 `checkinRefreshAtMs` 是那一窗的 `endAt` —— 也就是**下一窗什么时候开**。并排读就成了「今天已经签完,可 39 分钟后又有事可做」。`due` 态更糟:「已签到 · 现在可以领取」,七个字里自我否定。
|
|
53
|
+
- **修法:一格只说一句话**,四态各一句 —— 未知 / 「已签到」(上游没给开窗时刻)/ 「`HH:MM:SS` 后可再签到」/ 「现在可以签到」。去掉「已签到」**不丢任何事实**:「**再**签到」本身已经蕴含这一窗签过了。
|
|
54
|
+
- **「后刷新」也一并改掉**:旧措辞字面说的是「这一行多久刷新」,而那个数字其实是**可再签的时刻**,两回事。悬停提示同步点明,并与 Qoder 网页的倒计时对齐(同一个 `endAt`)。
|
|
55
|
+
- 样式:`next` 那格去掉 `margin-left`(它现在是这一格的主句,不再是附注),保留 `tabular-nums` 防逐秒抖动。
|
|
56
|
+
- **测试的结构性缺口(记下来,这类文案 bug 的唯一防线)**:`tests/pool-render.spec.tsx` 的 `t` 是**保留 key** 的假翻译器 —— 它能钉住「用了哪个 key」,**永远钉不住「这句话读起来通不通」**;而且此前**没有任何测试 import 过 `locales`**。这句矛盾文案就是这么发出去的。新增 `tests/locales.spec.ts`,用**真实 locale 表**断言用户实际看到的字符串(含 `{countdown}` 渲染后的整句),钉住「不并排已签到」「不说刷新」「title 里不放 Markdown 星号」。三组变异测试:把「已签到」并回倒计时格 / 文案改回「后刷新」/ 并回到 due 态 —— 各自被对应测试接住。
|
|
57
|
+
|
|
58
|
+
### Tests
|
|
59
|
+
|
|
60
|
+
- **CI 的 windows job 连红 6 次 —— 断言了 Windows 表达不了的东西。** `expected 438 to be 384`(`0o666` vs `0o600`),出在 `tests/accounts.spec.ts` 的保险库权限位那条;ubuntu job 一直是绿的。
|
|
61
|
+
- **不是写入路径的 bug**:`src/accounts.ts` 确实请求了 `mode: 0o600`。是 node 在 Windows 上只把「只读」一位映射进 `st_mode`,普通文件一律报 `0o666` —— 写什么权限都读不回来;那边的文件保护由 `%USERPROFILE%` 的 NTFS ACL 承担,与 mode 位无关。
|
|
62
|
+
- **为什么红了 6 次没人管**:这条断言是 `5002aa4` **引入时就带着病**(上一个绿 run `0c865e1` 还没有这个文件),此后每次 push 都精确红在同一条上。真正的问题不是断言写错,是**没人看 CI** —— 发布流程只跑本地 `pnpm run check`(绿),而本地是 macOS,恰好是这条断言能过的平台。
|
|
63
|
+
- **修法**:`it.skipIf(process.platform === 'win32')`,**不是**把断言改成 `0o666` —— 那等于把平台限制钉成「正确行为」,将来写入路径真的丢掉 0600 就再也测不出来。跳过会留在 CI 报告里(已核实:windows job 打印 `accounts.spec.ts (24 tests | 1 skipped)`)。顺带把这条测试拆成两个(区域分键 vs 权限位),失败时能直接看出是哪一半。
|
|
64
|
+
|
|
65
|
+
### Docs
|
|
66
|
+
|
|
67
|
+
- **`RELEASING.md` 新增第 5.5 步「CI 双平台必须转绿(不得跳过)」。** 上面的红灯红了 6 次而无人察觉,根因是发布门禁只有第 1 步的本地 `pnpm run check` —— 本机是 macOS,恰好是那条断言能过的平台,而 CI 的 windows job 从来没进过清单。新步骤要求:核对最新 run 的 `headSha` **就是**本次推的提交、**两个 job 都 success**(不是只看 overall)、有 skipped 时确认是有意的跳过。第 1 步旁加了指向它的警示。
|
|
68
|
+
- **编号用 5.5 而不重排 6-10**:正文里已有「第 7 步」「第 8 步」「第 9 步」的交叉引用(2FA、registry 核验、GitHub Release),重排会让它们全部错位 —— 沿用 §2.5 的既有先例。
|
|
69
|
+
|
|
70
|
+
## 0.4.0 (2026-10-13)
|
|
71
|
+
|
|
72
|
+
0.3.0 发布后挖出的四条修复,外加一条有意的旧行为移除。四条里两条是 0.3.0 自己带出来的伤:**npm 上 0.3.0 的 peer 范围装不进当前 DSH**(装上即 `ERESOLVE`,必须尽快覆盖掉),以及**国际版需要的第二族令牌**;另两条是用户实拍回来的(思考强度间歇性消失、签到倒计时)。其中思考强度那条经历了一次返工 —— 第一版修复在生产路径上是**死代码**,险些带着假说明发布,经过与返工都记在条目里。
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- **国际版积分与签到恒为 `credential-rejected` —— 国际版需要【两族并存】的令牌,而插件只有一族**(用户实拍;cn 侧完全正常,所以这个问题只在国际版显形)。
|
|
77
|
+
- **实测(本机两个真实 PAT,只读;唯一变动的是「怎么换令牌」)**:
|
|
78
|
+
|
|
79
|
+
| 面 | 认哪一族 | 国际版 + 旧令牌 | 国际版 + 新令牌 |
|
|
80
|
+
| --- | --- | --- | --- |
|
|
81
|
+
| chat / cosy(模型对话) | `/algo/api/v3/user/jobToken` 的 `securityOauthToken` | ✅ got-delta | ❌ 403 code 105 登录态失效 |
|
|
82
|
+
| sash / 额度(积分、签到) | `/api/v1/jobToken/exchange` 的 `token` | ❌ 401 TOKEN_EXPIRE | ✅ 200 |
|
|
83
|
+
|
|
84
|
+
国内版两族**等价**,所以这个差异在国内版永远看不出来 —— 这就是它反复查不出来的原因,也是「模型能用、只有积分读不到」这个奇怪组合的来源。
|
|
85
|
+
- **更早的两处误判**(记在这里,因为它们塑造了排查路径):先把 401 归因为「PAT 失效」,再归因为「国际版 sash 不开放该账号」。两者都被矩阵证伪 —— 截图显示网页端有 300 套餐 + 100 资源包积分,且换一枚新令牌后 sash 全线 200。
|
|
86
|
+
- **修法**:`QoderSessionHolder` 增加 `sashToken` / `sashExpireAtMs`;`exchangeJobToken` 里**额外**换一次 `/api/v1/jobToken/exchange`,`jobTokenGet`(用量)与 `sashRequest`(签到/活动)的 Authorization 改用它,chat / cosy 那一族原样不动。
|
|
87
|
+
- **必须在交换处 `await`**:放后台的话,紧接着的第一次读积分会带着空令牌去,拿到一个**与「真被拒」无法区分**的 401(实测:第一次 401、第二次 200)。这个坑本轮真实踩到了。
|
|
88
|
+
- **body 键是 `personal_token`**:`personal_access_token`(eghrhegpe/dsh-connect-qoder 用的拼写)在当前上游回 **400 BadRequest**,两个区域都是。
|
|
89
|
+
- **sash 交换失败不抛**:只让「积分读不到」,不让 chat / 对话跟着挂 —— 那是两条独立链路。
|
|
90
|
+
- **测试**:新增 `tests/live-intl-session.spec.ts`(**打真实上游,默认跳过**,`QODER_LIVE_TESTS=1` 打开)钉住 7 条事实:两区域各自的积分、签到、对话,外加两个区域不共用 baseUrl。改动前它精确失败在「global 积分 / 签到」两条而 chat 两条仍绿;改动后 7 条全绿。
|
|
91
|
+
- 另有 6 条桩测固定契约(两族都换、sash 用新令牌、失败不抛、body 键、以及**第一发就带对令牌**的竞态守卫),全部做过变异测试:回落旧令牌 / 换回 fire-and-forget 各自被接住。
|
|
92
|
+
- 参考实现:Liki4/qodercli2api、shuishuipingan/qoder2api-hub、eghrhegpe/dsh-connect-qoder(三者都走 `/api/v1/jobToken/exchange`)。
|
|
93
|
+
|
|
94
|
+
- **peer 范围静默排除整条 `0.2.0-rc.*` 宿主线 —— 从 npm 装到当前 DSH 就会 `ERESOLVE`。**
|
|
95
|
+
- **现象**:13 条 `@deepseek-ai/dsh-*` peer 写的是 `>=0.1.7-rc.1 <0.3.0-0`。这条范围**看着很宽,却匹配不到任何 `0.2.0-rc.*`**:node-semver 只有当范围里*某个*比较符与该版本的 `major.minor.patch` 元组完全一致、且自身也带预发布标签时,才放行预发布版本;`0.1.7-rc.1` 与 `0.3.0-0` 都不落在 `0.2.0` 元组上,于是 `0.2.0-rc.1` / `0.2.0-rc.2` 被**静默**排除。而 `@deepseek-ai/dsh` 的 `latest` **正是 `0.2.0-rc.2`** —— 从 npm 安装本插件的用户会撞上 `ERESOLVE`,还得自己手工绕过。
|
|
96
|
+
- **为什么本地与 CI 全绿**:本机 profile 是 `link:` 安装,**完全不走 peer 解析**;而 `tests/dsh-line.spec.ts` 当时自带一个只认「两个比较符」的简易实现,它对预发布版本的判断与 node-semver **相反** —— 一条**假绿**测试,而且它断言的恰好是错误的那一侧(用例写着「0.2.x 线仍放行」,实际并不放行)。
|
|
97
|
+
- **修法**:范围改为**两条分支** `>=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0`(peer 与 dev 共 26 处一并改),`tests/dsh-line.spec.ts` 的比较器换成**真实的 node-semver**(`semver` 提为显式 devDependency —— 此前只作为传递依赖存在),删掉已无用的手写比较器;新增两条用例:已发布的 `0.2.0-rc.*` 必须被放行(回归)、有意排除的 `0.1.7-alpha.*` / `0.2.1-alpha.*` / `0.3.0-rc.1` 仍须被挡在外;并让 peer 与 dev 的口径一致。两条 README 的「受支持的宿主范围」表与 `RELEASING.md` 的发布核对清单同步更新。
|
|
98
|
+
- **依据**:`awesome-dsh-plugin/awesome-dsh-plugin` 的 `contributing.md`「A peer range without an explicit prerelease branch silently excludes every prerelease build of the harness」。
|
|
99
|
+
- **思考强度「有时候有、有时候没有」—— 目录只在启动时拉一次,而保存的快照全是空档位**(用户实拍)。
|
|
100
|
+
- **实测本机状态**(只读检查,未发起任何领取):保存的 `regions.cn.lastCatalog` 里 **14 个模型的 `thinkingEfforts` 全部是 `[]`**,`defaultThinkingEffort` 一个都没有;而同一时刻上游实时目录里 **9 个模型明确声明了档位**(`qmodel_38max` / `qfmodel` → `low, medium, xhigh` 默认 medium;`dmodel` → `high, max` 默认 max;`dfmodel` / `gmodel` / `kmodel` / `gfmodel` / `gm51model` / `kmodel_latest` 各自不同)。`[]` 是**旧 schema 把档位字段剥掉时**留下的产物 —— 也就是说这份快照根本不是「上游没声明」,而是「它来自一个没拉到档位的时刻」。
|
|
101
|
+
- **为什么是间歇性的**:`fullCatalog`(唯一带档位的目录)是**纯内存**的,而目录只在**启动时**拉一次。于是:点一次「从 Qoder 刷新」→ 实时目录装上 → 档位出现;重启 / 重新读取 → 实时目录空 → 退回那份全空快照 → 档位消失。「有没有点过刷新、进程活了多久」就是「有时候有、有时候没有」的全部来源。
|
|
102
|
+
- **修法**:`displayModels`(卡片 `/usage` 路由读的就是它)读到「本进程还没成功拉过实时目录 + 手上的目录一个档位都没声明」时,触发一次后台重拉(fire-and-forget + 在飞标记去重,**不阻塞**这次读 —— `displayModels` 是被 `/usage` 路由同步调用的读路径,在里面 await 网络会让每次读卡片都等一次上游),**继续服务手上的目录**:模型不能从卡片上消失,缺的只是档位,拉回来之后的下一次读取自然补上。「本进程还没拉到实时目录」这个守卫不可省 —— 上游**真的**没声明档位是合法状态,去掉守卫后每 60 秒一次的卡片轮询都会拖一发模型列表请求,永不停。
|
|
103
|
+
- **返工记录(第一版修复是死代码,险些带着假说明发布)**:第一版把重拉挂在 `displayModels` 的「运行时目录为空」分支上,三层缺陷叠加,使它在生产路径上**一次都不会执行**:
|
|
104
|
+
1. 调用处 `if (!serve.models) return serve.fallback` —— `serve.models` 恒为数组,而 `[]` 是 truthy,这个分支永远不走;
|
|
105
|
+
2. `serveSavedCatalog` 的 stale 路径返回 `fallback: []`,兜底目录从未被填上(与它自己的文档注释「返回 `{ models: [], fallback: [...] }`」直接矛盾);
|
|
106
|
+
3. 根因:运行时目录 `catalog` 构造时就种了 9 个静态兜底模型、`set()` 拒绝空数组,所以 `live.length > 0` 恒成立、永远提前返回 —— 挂在它后面的整个 lastCatalog 分支(`serveSavedCatalog` 唯一的调用处)不可达。
|
|
107
|
+
当时的集成测试只断言 `ctx.llm.listModels`(适配器路径,读运行时目录),恰好绕开 `displayModels` —— 于是测试全绿、修复无效,CHANGELOG 差一点写下一个没发生的修复。这轮把空档位判据挪到**真正被服务的 live 列表**上、删掉不可达分支,并把测试改成走**真实的 `/usage` 路由**(假 webServer 捕获路由,见「签到积分不刷新」那条)。
|
|
108
|
+
- **判据用 `length > 0` 而不是「字段在不在」**:旧 schema 留下的是**空数组**而不是缺席,所以「字段在」恰恰不能作为「声明过」的证据。两种「没有档位」(旧快照的空数组 vs 上游真的没声明)从数据上分不开,**要不要重拉由目录来源决定**(见修法里的守卫);判据与动作继续打包在 `serveSavedCatalog(models, onStale)` 里 —— 拆开接线时,把调用处变异掉单测照样全绿。
|
|
109
|
+
- **测试**(`tests/index.spec.ts`,+2 走真实 `/usage` 路由):一条钉**自愈** —— 启动拉取失败后,第一次读卡片**继续服务旧快照里的模型**(模型不消失、无档位),同时触发一次重拉;落地后下一次读,档位来自实时目录。另一条钉**守卫** —— 上游诚实声明「无档位」时,实时目录落地后的卡片轮询不再打模型列表端点。三组变异测试:删掉触发(复现原死代码)/ 去掉守卫(读路径变打网络循环)/ 恢复 `models: []` 的旧契约 —— 各自被对应测试接住。
|
|
110
|
+
- **每日签到:领取之后不说「还有多久刷新」—— 而「已领取」这句话离开它是残缺的**(用户实拍官网页面上的 `09:22:23` 倒计时,并指出领取成功要判断是否有倒计时)。
|
|
111
|
+
- **先去看接口,而不是猜**:用本地那个能用的 PAT 只读拉了一次 `GET /sash/api/v1/me/campaigns`(**没有领取任何东西**)。实测活动行是:
|
|
112
|
+
```json
|
|
113
|
+
{ "campaignId": "01a12102-…", "campaignKey": "act-20261009-057",
|
|
114
|
+
"actionType": "CLAIM_BENEFIT", "claimStatus": "CLAIMED",
|
|
115
|
+
"startAt": 1791770400, "endAt": 1791856740,
|
|
116
|
+
"benefit": { "kind": "CREDITS", "amount": 100,
|
|
117
|
+
"validity": { "mode": "RELATIVE_DAYS", "days": 30 } } }
|
|
118
|
+
```
|
|
119
|
+
换算(UTC+8):`startAt` = 当天 **10:00:00**,`endAt` = 次日 **09:59:00** —— 与活动文案「每日 10:00(UTC+8)刷新」一致。所以页面上那个 `09:22:23` 就是 **`endAt − 当前时间`**。
|
|
120
|
+
- **领取不是「今天到此为止」**:领取只是把这一窗从 `CLAIMABLE` 变成 `CLAIMED`,而下一窗什么时候开,上游早就写在 `endAt` 里了。此前插件只回一个 `already-claimed` / `claimed`,UI 只能说「已签到」—— 用户会以为要等到第二天零点。
|
|
121
|
+
- **修法**(`endAt` 是 epoch **秒**,转毫秒只发生在它变成时间戳的那一刻;解析层保持秒,否则 `startAt`/`endAt` 单位会不一致):
|
|
122
|
+
- `QoderCampaign` 解析 `startAt` / `endAt`;`QoderCheckinOutcome` 在 `claimed` **与 `already-claimed` 两条路**(含 409 幂等)都带上 `nextRefreshAtMs`;
|
|
123
|
+
- 宿主快照从 `Record<id, boolean>` 升级为 `{ claimed, refreshAtMs? }`,写进成员行的 `checkinRefreshAtMs`;
|
|
124
|
+
- 客户端新增 `countdownText()`(`HH:MM:SS`,秒级向上取整,小时不封顶),由既有的 20 秒 `nowMs` 心跳重绘;样式用等宽数字(tabular-nums),否则位数一变整格宽度就抖、表格列跟着晃。
|
|
125
|
+
- **上游没给 `endAt` 就不显示倒计时**,绝不用本地零点或「上次 +24h」编一个。
|
|
126
|
+
- **顺带修掉一个真缺陷**:客户端最初把「已领取但上游没给时间」与「倒计时走完了」都表达成 `countdownText(...) === ''`,于是前者被渲染成「现在可以领取」—— 把「不知道下一窗什么时候开」说成了「已经可以领了」。改成四态(`off` / `unknown` / `ticking` / `due`)。测试接住了它:先写成两态时那条断言立刻失败。
|
|
127
|
+
- 测试 +17(`tests/checkin-countdown.spec.ts` 新建 7 条、`tests/upstream.spec.ts` +5、`tests/pool-render.spec.tsx` +5),**用实测值锚定**(`endAt = 1791856740`,倒计时断言精确到 `09:22:23`)。四组变异测试:`already-claimed` 丢掉刷新时刻 / `unknown` 塌成 `due` / 宿主快照丢掉 `refreshAtMs` / 上游缺字段时仍然编一个 —— 各自被对应测试接住。
|
|
128
|
+
- **本条的单元格文案在 0.4.1 被推翻**(「已签到 · 倒计时」并排自相矛盾)—— 见 0.4.1 的第一条。发布说明里那句「已签到的行显示『已签到 · HH:MM:SS 后刷新』」随之改正。
|
|
129
|
+
|
|
130
|
+
### Changed
|
|
131
|
+
|
|
132
|
+
- **回复流不再注入「将由账号 … 回答本次问题」。** 账号池成员接管请求时,此前会在回复正文最前面带一行这个提示(`poolServingNotice`,账号标识打码)。宿主侧不再提供该注入点,回复正文从模型自己的输出开始;shim 的注入能力仍保留,只是本插件不再用它。
|
|
133
|
+
|
|
5
134
|
## 0.3.0 (2026-10-12)
|
|
6
135
|
|
|
7
136
|
三条主线:**每日签到真的接通了**(我早期把它判成「Qoder 没有签到」,那是没实测就下的结论)、**与 dsh-connect-workbuddy 的逐模块对等审计**,以及收尾的**国际版(global)对等审计**(结论见 [`docs/audit/AUDIT-REPORT.md`](docs/audit/AUDIT-REPORT.md))。对等审计的每条都以 workbuddy 的已验证行为为基准,并对照 pi-ai / DSH 宿主源码逐条核实。
|
package/README.en.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<h1 align="center">dsh-connect-qoder</h1>
|
|
6
6
|
|
|
7
|
-
<p align="center"><b>Connect Qoder (Lingma
|
|
7
|
+
<p align="center"><b>Connect Qoder (Lingma CN / international) models to DeepSeek Harness, with an account pool, daily check-in, credit readouts and model management.</b></p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="README.md">中文</a> ·
|
|
@@ -81,7 +81,7 @@ dsh plugin --profile web add @dmh2002/dsh-connect-qoder # Web
|
|
|
81
81
|
|
|
82
82
|
Then **restart that profile's DSH process** (the host half of a bundle loads at startup). You can also search for `dsh-connect-qoder` in DSH's "Plugin market / Bundles" and install it in one click.
|
|
83
83
|
|
|
84
|
-
> The npm package name carries the `@dmh2002/` scope
|
|
84
|
+
> The npm package name carries the `@dmh2002/` scope. The plugin's **configuration identity inside DSH does not** — the settings namespace, the `/plugins/dsh-connect-qoder/…` routes and the CLI program name all stay `dsh-connect-qoder`, so anyone upgrading from 0.2.0 keeps their saved configuration with no need to re-enter PATs. But the **browser bundle's module id and the settings card's slot keys are both keyed by the scoped package name** (`plugins.bundle.config` takes the bare package name, `plugins.row.config` takes `<package name>#<row id>`, and the row id stays unscoped); these are injected from `package.json` at build time, so a rename cannot leave them behind.
|
|
85
85
|
|
|
86
86
|
### From source (development)
|
|
87
87
|
|
|
@@ -207,7 +207,7 @@ pnpm exec dsh-connect-qoder doctor --json # machine-readable output
|
|
|
207
207
|
|
|
208
208
|
| Host | Peer range |
|
|
209
209
|
| --- | --- |
|
|
210
|
-
| DSH (`@deepseek-ai/*`) | `>=0.1.7-rc.1 <0.3.0-0` |
|
|
210
|
+
| DSH (`@deepseek-ai/*`) | `>=0.1.7-rc.1 <0.2.0-0 \|\| >=0.2.0-rc.1 <0.3.0-0` |
|
|
211
211
|
| Node.js | `^22.19.0 \|\| >=24.0.0` |
|
|
212
212
|
|
|
213
213
|
> Enforced by `tests/dsh-line.spec.ts`: the upper bound must carry `-0` (DSH only ships prereleases; `<0.3.0` would block nothing).
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<h1 align="center">dsh-connect-qoder</h1>
|
|
6
6
|
|
|
7
|
-
<p align="center"><b>把 Qoder
|
|
7
|
+
<p align="center"><b>把 Qoder(通义灵码国内版 / 国际版)的模型接入 DeepSeek Harness,并提供账号池、每日签到、积分读数与模型管理。</b></p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="README.en.md">English</a> ·
|
|
@@ -81,7 +81,7 @@ dsh plugin --profile web add @dmh2002/dsh-connect-qoder # Web
|
|
|
81
81
|
|
|
82
82
|
装完**重启该 profile 的 DSH 进程**(bundle 的 host 半边在启动时加载)。也可以在 DSH 的「插件市场 / Bundles」里搜 `dsh-connect-qoder` 一键安装。
|
|
83
83
|
|
|
84
|
-
> npm 包名带 `@dmh2002/`
|
|
84
|
+
> npm 包名带 `@dmh2002/` 作用域。插件在 DSH 内的**配置标识不带作用域**——设置命名空间、`/plugins/dsh-connect-qoder/…` 路由与命令行程序名都仍是 `dsh-connect-qoder`,所以 0.2.0 之前装过的用户升级后配置照旧生效,不用重填 PAT。但**浏览器 bundle 的模块 id、以及设置卡片两个槽位的 key 都按带作用域的包名建**(`plugins.bundle.config` 用裸包名,`plugins.row.config` 用 `<包名>#<行 id>`,行 id 仍不带作用域),这几处由构建期从 `package.json` 注入,改包名不会漏下它们。
|
|
85
85
|
|
|
86
86
|
### 从源码构建(开发)
|
|
87
87
|
|
|
@@ -207,10 +207,10 @@ pnpm exec dsh-connect-qoder doctor --json # 机器可读输出
|
|
|
207
207
|
|
|
208
208
|
| 宿主 | peer 范围 |
|
|
209
209
|
| --- | --- |
|
|
210
|
-
| DSH(`@deepseek-ai/*`) | `>=0.1.7-rc.1 <0.3.0-0` |
|
|
210
|
+
| DSH(`@deepseek-ai/*`) | `>=0.1.7-rc.1 <0.2.0-0 \|\| >=0.2.0-rc.1 <0.3.0-0` |
|
|
211
211
|
| Node.js | `^22.19.0 \|\| >=24.0.0` |
|
|
212
212
|
|
|
213
|
-
> 范围由 `tests/dsh-line.spec.ts`
|
|
213
|
+
> 范围由 `tests/dsh-line.spec.ts` 强制,且**必须写成两条分支**:DSH 至今只发预发布,而 node-semver 只有当范围里*某个*比较符与该版本的 `major.minor.patch` 元组完全一致、且自身也带预发布标签时,才会放行预发布版本。只写 `>=0.1.7-rc.1 <0.3.0-0` 会**静默排除整条 `0.2.0-rc.*` 线**——而 `0.2.0-rc.2` 正是 `@deepseek-ai/dsh` 当前的 `latest`,用户装到就会 `ERESOLVE`。上界仍须带 `-0`(`<0.3.0` 不挡任何东西)。
|
|
214
214
|
|
|
215
215
|
## 安全说明
|
|
216
216
|
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { S as deriveFingerprint, _ as piAiRuntimeInfo, d as qoderAccountsPath, f as QODER_PAT_ENV_VARS, h as patFromEnv, i as readHostHeartbeat, n as isHeartbeatProcessAlive, o as qoderPoolStorePath, r as qoderHostHeartbeatPath, u as QoderAccountStore, v as QODER_REGIONS, x as isQoderUpstreamError, y as QoderUpstreamClient } from "./host-heartbeat-
|
|
2
|
+
import { S as deriveFingerprint, _ as piAiRuntimeInfo, d as qoderAccountsPath, f as QODER_PAT_ENV_VARS, h as patFromEnv, i as readHostHeartbeat, n as isHeartbeatProcessAlive, o as qoderPoolStorePath, r as qoderHostHeartbeatPath, u as QoderAccountStore, v as QODER_REGIONS, x as isQoderUpstreamError, y as QoderUpstreamClient } from "./host-heartbeat-DOru8uF9.js";
|
|
3
3
|
import { rm } from "node:fs/promises";
|
|
4
4
|
//#region src/bin.ts
|
|
5
5
|
/**
|
package/lib/client.js
CHANGED
|
@@ -474,6 +474,48 @@ window.__ModuleLoader__.load({
|
|
|
474
474
|
//#endregion
|
|
475
475
|
//#region src/client/BusyOverlay.tsx
|
|
476
476
|
/**
|
|
477
|
+
* The shared busy veil's spinner: an arc that breathes while it turns.
|
|
478
|
+
*
|
|
479
|
+
* Deliberately the host's own state-spinner shape (dsh's `_dsh-state-dot-spin`)
|
|
480
|
+
* rather than a border-ring: a two-tone CSS ring at .7s read as a loading
|
|
481
|
+
* indicator from another decade and was out of step with every spinner in the
|
|
482
|
+
* shell. The motion lives in CSS (`dsm-qoder-veil-spin` / `-dash`), so this
|
|
483
|
+
* element only describes the geometry; `currentColor` lets it inherit whatever
|
|
484
|
+
* tier it sits in.
|
|
485
|
+
*
|
|
486
|
+
* `aria-hidden` because the veil's `role="status"` already announces the
|
|
487
|
+
* message — a second announcement from a decorative arc is noise.
|
|
488
|
+
*
|
|
489
|
+
* 对齐:dingminhua/dsh-connect-workbuddy 的 VeilSpinner(MIT,LaoDing)。类名换
|
|
490
|
+
* `dsm-qoder-*`,几何与节奏(20px / 1.5s / dash 呼吸 / currentColor)逐条沿用。
|
|
491
|
+
*/
|
|
492
|
+
function VeilSpinner() {
|
|
493
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
494
|
+
className: "dsm-qoder-veil-spinner",
|
|
495
|
+
viewBox: "0 0 20 20",
|
|
496
|
+
"aria-hidden": "true",
|
|
497
|
+
focusable: "false",
|
|
498
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
499
|
+
className: "dsm-qoder-veil-spinner-track",
|
|
500
|
+
cx: "10",
|
|
501
|
+
cy: "10",
|
|
502
|
+
r: "8",
|
|
503
|
+
fill: "none",
|
|
504
|
+
stroke: "currentColor",
|
|
505
|
+
strokeWidth: "2"
|
|
506
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
507
|
+
className: "dsm-qoder-veil-spinner-arc",
|
|
508
|
+
cx: "10",
|
|
509
|
+
cy: "10",
|
|
510
|
+
r: "8",
|
|
511
|
+
fill: "none",
|
|
512
|
+
stroke: "currentColor",
|
|
513
|
+
strokeWidth: "2",
|
|
514
|
+
strokeLinecap: "round"
|
|
515
|
+
})]
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
477
519
|
* The veil. Deliberately NOT a modal dialog: it carries no focusable control
|
|
478
520
|
* and no dismissal — there is nothing for the user to decide, only a step to
|
|
479
521
|
* wait through.
|
|
@@ -488,10 +530,7 @@ window.__ModuleLoader__.load({
|
|
|
488
530
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
489
531
|
className: "dsm-qoder-veil-card",
|
|
490
532
|
children: [
|
|
491
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
492
|
-
className: "dsm-qoder-veil-spinner",
|
|
493
|
-
"aria-hidden": "true"
|
|
494
|
-
}),
|
|
533
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(VeilSpinner, {}),
|
|
495
534
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
496
535
|
className: "dsm-qoder-veil-text",
|
|
497
536
|
children: message
|
|
@@ -1373,17 +1412,18 @@ window.__ModuleLoader__.load({
|
|
|
1373
1412
|
className: "dsm-qoder-pool-unknown",
|
|
1374
1413
|
title: t("row.poolNeverTested"),
|
|
1375
1414
|
children: t("row.poolCheckinUnknown")
|
|
1376
|
-
}) : account.checkedInToday ?
|
|
1377
|
-
className: "dsm-qoder-pool-checked",
|
|
1378
|
-
children: t("row.poolCheckedIn")
|
|
1379
|
-
}), checkinCountdown.state === "ticking" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1415
|
+
}) : account.checkedInToday ? checkinCountdown.state === "ticking" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1380
1416
|
className: "dsm-qoder-pool-checkin-next",
|
|
1381
1417
|
title: t("row.poolCheckinNextHint"),
|
|
1382
1418
|
children: t("row.poolCheckinNext", { countdown: checkinCountdown.text })
|
|
1383
1419
|
}) : checkinCountdown.state === "due" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1384
1420
|
className: "dsm-qoder-pool-checkin-due",
|
|
1421
|
+
title: t("row.poolCheckinDueHint"),
|
|
1385
1422
|
children: t("row.poolCheckinDue")
|
|
1386
|
-
}) :
|
|
1423
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1424
|
+
className: "dsm-qoder-pool-checked",
|
|
1425
|
+
children: t("row.poolCheckedIn")
|
|
1426
|
+
}) : t("row.poolNotCheckedIn")
|
|
1387
1427
|
}) : null,
|
|
1388
1428
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1389
1429
|
className: "dsm-qoder-pool-row-actions",
|
|
@@ -2534,11 +2574,12 @@ window.__ModuleLoader__.load({
|
|
|
2534
2574
|
.dsm-qoder-pool-check input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
|
|
2535
2575
|
.dsm-qoder-pool-check input:disabled{cursor:default;opacity:.5}
|
|
2536
2576
|
.dsm-qoder-pool-checked{color:var(--dsw-alias-state-success-primary,#22a06b)}
|
|
2537
|
-
/*
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
.dsm-qoder-pool-checkin-next{
|
|
2541
|
-
|
|
2577
|
+
/* 倒计时那一格现在是**唯一**的一句话(不再与「已签到」并排),所以没有 margin-left。
|
|
2578
|
+
数字逐秒变化,给它等宽数字(tabular-nums)—— 否则位数一变整格宽度就抖,表格列
|
|
2579
|
+
跟着左右晃。颜色沿用单元格本身的 label-tertiary:它就是这一格的主句。 */
|
|
2580
|
+
.dsm-qoder-pool-checkin-next{font-variant-numeric:tabular-nums;font-feature-settings:"tnum"}
|
|
2581
|
+
/* 到点之后同样是主句、不缩进;绿色表示「此刻有事可做」,与「已签到」同色。 */
|
|
2582
|
+
.dsm-qoder-pool-checkin-due{color:var(--dsw-alias-state-success-primary,#22a06b)}
|
|
2542
2583
|
.dsm-qoder-pool-unknown{color:var(--dsw-alias-label-dimmed,#9aa0a6)}
|
|
2543
2584
|
.dsm-qoder-pool-member{color:var(--dsw-alias-brand-primary,#5686fe)}
|
|
2544
2585
|
.dsm-qoder-pool-not-member{color:var(--dsw-alias-label-dimmed,#9aa0a6)}
|
|
@@ -2646,14 +2687,53 @@ window.__ModuleLoader__.load({
|
|
|
2646
2687
|
.dsm-qoder-addpat-doc a{color:var(--dsw-alias-brand-primary,#5686fe);font-size:12px;line-height:18px;text-decoration:none}
|
|
2647
2688
|
.dsm-qoder-addpat-doc a:hover{text-decoration:underline}
|
|
2648
2689
|
/* The shared busy veil: a fixed overlay for operations whose result only shows
|
|
2649
|
-
up after the list re-read. It is NOT a modal
|
|
2650
|
-
|
|
2651
|
-
.
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2690
|
+
up after the list re-read (remove / import / add). It is NOT a modal: no
|
|
2691
|
+
control, no dismissal — only "this is running" and "here is why nothing
|
|
2692
|
+
moved yet". Fixed positioning so it covers the card regardless of which
|
|
2693
|
+
section started the operation, and a semi-transparent backdrop so the table
|
|
2694
|
+
stays visible underneath — the user is watching a specific row change, and
|
|
2695
|
+
hiding it would defeat the point.
|
|
2696
|
+
|
|
2697
|
+
The mask and the card surface are the HOST's own tokens, not this file's
|
|
2698
|
+
invention. --dsw-alias-bg-mask-1 + --dsw-mask-blur is exactly what the
|
|
2699
|
+
host's own overlays use (see dsh-client-ui-settings-general's Dws9Sa_mask),
|
|
2700
|
+
so the veil now dims the way every other overlay in the shell does instead
|
|
2701
|
+
of laying its own black wash on top. The previous hardcoded rgba(0,0,0,.38)
|
|
2702
|
+
was tuned against neither theme: too weak next to the host's .24 mask in
|
|
2703
|
+
light mode, and it ignored the host's blur entirely.
|
|
2704
|
+
|
|
2705
|
+
bg-layer-2 (not bg-primary, which does not exist in the host's token set
|
|
2706
|
+
at all and therefore always fell through to this file's near-black #1b1c20)
|
|
2707
|
+
is the same surface the host puts behind its panels — that near-black fallback
|
|
2708
|
+
is what made the veil read as a black slab with unreadable text on it. */
|
|
2709
|
+
.dsm-qoder-veil{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.32));backdrop-filter:var(--dsw-mask-blur);cursor:progress}
|
|
2710
|
+
/* --dsw-elevation-prominent is the host's own panel shadow (a hairline stroke
|
|
2711
|
+
plus two soft layers); the previous 0 8px 28px rgba(0,0,0,.35) was a
|
|
2712
|
+
hand-rolled drop shadow that read as a heavy grey box. */
|
|
2713
|
+
.dsm-qoder-veil-card{display:flex;flex-direction:column;align-items:center;gap:8px;max-width:min(340px,80vw);padding:16px 20px;border:1px solid var(--dsw-alias-border-l2,#0000001a);border-radius:12px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:var(--dsw-elevation-prominent,0 8px 28px rgba(0,0,0,.35))}
|
|
2714
|
+
.dsm-qoder-veil-text{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#0f1115);text-align:center}
|
|
2715
|
+
/* label-secondary, not label-tertiary: this line carries the operation's
|
|
2716
|
+
detail and must stay legible. tertiary is the host's dimmest tier
|
|
2717
|
+
(neutral-bluish-600 on light, neutral-bluish-400 on dark) and is meant
|
|
2718
|
+
for metadata, not for a sentence the user has to read while waiting. */
|
|
2719
|
+
.dsm-qoder-veil-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#61666b);text-align:center}
|
|
2720
|
+
/* The spinner proves the UI is alive when the row under it has not changed yet
|
|
2721
|
+
— which is precisely the confusion the veil exists to remove. It follows the
|
|
2722
|
+
host's own state spinner (dsh's _dsh-state-dot-spin): an SVG arc whose
|
|
2723
|
+
dash breathes while it turns, currentColor so it inherits the tier it sits
|
|
2724
|
+
in, and the same 1.5s beat. The previous 18px border-ring at .7s was roughly
|
|
2725
|
+
twice the host's speed and visibly out of step with every other spinner in
|
|
2726
|
+
the shell — that mismatch, more than the shape itself, is what made it look
|
|
2727
|
+
cheap.
|
|
2728
|
+
|
|
2729
|
+
Reduced-motion users get the static arc rather than a frozen ring, matching
|
|
2730
|
+
how the host degrades its own spinner. */
|
|
2731
|
+
.dsm-qoder-veil-spinner{width:20px;height:20px;color:var(--dsw-alias-label-secondary,#61666b);animation:dsm-qoder-veil-spin 1.5s linear infinite}
|
|
2732
|
+
.dsm-qoder-veil-spinner-track{opacity:.25}
|
|
2733
|
+
.dsm-qoder-veil-spinner-arc{stroke-dasharray:12 150;animation:dsm-qoder-veil-dash 1.5s ease-in-out infinite}
|
|
2655
2734
|
@keyframes dsm-qoder-veil-spin{to{transform:rotate(360deg)}}
|
|
2656
|
-
@
|
|
2735
|
+
@keyframes dsm-qoder-veil-dash{0%{stroke-dasharray:12 150;stroke-dashoffset:0}50%{stroke-dasharray:24 150;stroke-dashoffset:-6}to{stroke-dasharray:12 150;stroke-dashoffset:0}}
|
|
2736
|
+
@media (prefers-reduced-motion: reduce){.dsm-qoder-veil-spinner{animation:none}.dsm-qoder-veil-spinner-arc{animation:none;stroke-dasharray:18 150;stroke-dashoffset:-3}}
|
|
2657
2737
|
`;
|
|
2658
2738
|
/**
|
|
2659
2739
|
* Composer credit readout styles, injected by the readout component itself.
|
|
@@ -4317,8 +4397,8 @@ window.__ModuleLoader__.load({
|
|
|
4317
4397
|
"row.probeOkMs": "✓ 可用({ms})",
|
|
4318
4398
|
"row.probeRateLimitedUnknown": "✗ 被限流 · 上游未给出何时恢复",
|
|
4319
4399
|
"row.probeRateLimitedAt": "✗ 被限流 · {at} 之后可再用",
|
|
4320
|
-
"row.probeOutOfCreditUnknown": "✗
|
|
4321
|
-
"row.probeOutOfCreditAt": "✗
|
|
4400
|
+
"row.probeOutOfCreditUnknown": "✗ 用量已达上限 · 上游未给出何时恢复",
|
|
4401
|
+
"row.probeOutOfCreditAt": "✗ 用量已达上限 · {at} 恢复",
|
|
4322
4402
|
"row.probeCredentialRejected": "✗ Qoder 拒绝了这个账号的 PAT——请添加新 PAT 或换一个账号",
|
|
4323
4403
|
"row.probePolicyRejected": "✗ Qoder 服务端按内容策略拒绝了这次请求——重新添加 PAT 无效",
|
|
4324
4404
|
"row.probeNotFound": "✗ 上游不认识这个模型",
|
|
@@ -4330,7 +4410,7 @@ window.__ModuleLoader__.load({
|
|
|
4330
4410
|
"row.docsHint": "如何创建 PAT?",
|
|
4331
4411
|
"row.poolTitle": "账号池",
|
|
4332
4412
|
"row.poolNoCandidate": "账号池里目前没有可用的账号。",
|
|
4333
|
-
"row.poolNoCandidateHint": "
|
|
4413
|
+
"row.poolNoCandidateHint": "成员要么在限流冷却中、要么用量已达上限、要么凭据被拒、要么上游无响应。限流、用量上限与瞬时故障都会自动回池——按上游给出的恢复时间,没给时间就按 30 分钟兜底,不需要你操作;只有「凭据被拒」需要换一个新 PAT。",
|
|
4334
4414
|
"row.poolEmptyHint": "这个区域还没有账号。用上面的「添加 PAT」粘贴一个,或用「导出 / 导入账号」把别的机器上的账号搬过来 —— 账号池、模型测试与每日签到都需要至少一个账号。",
|
|
4335
4415
|
"row.poolTestAll": "一键测试所有账号",
|
|
4336
4416
|
"row.poolCheckinAll": "一键签到所有账号",
|
|
@@ -4362,15 +4442,16 @@ window.__ModuleLoader__.load({
|
|
|
4362
4442
|
"row.poolCheckedIn": "已签到",
|
|
4363
4443
|
"row.poolNotCheckedIn": "未签到",
|
|
4364
4444
|
"row.poolCheckinUnknown": "—",
|
|
4365
|
-
"row.poolCheckinNext": "
|
|
4366
|
-
"row.poolCheckinNextHint": "
|
|
4367
|
-
"row.poolCheckinDue": "
|
|
4445
|
+
"row.poolCheckinNext": "{countdown} 后可再签到",
|
|
4446
|
+
"row.poolCheckinNextHint": "这一窗已经签到过了。这个数字是下一窗什么时候开(到点就能再签),与 Qoder 网页上的倒计时是同一个数 —— 不是「这一行多久刷新」。",
|
|
4447
|
+
"row.poolCheckinDue": "现在可以签到",
|
|
4448
|
+
"row.poolCheckinDueHint": "下一窗已经开了,这个账号现在可以再签。「已签到」说的是上一窗,所以这里不再重复它。",
|
|
4368
4449
|
"row.poolNeverTested": "尚未测试",
|
|
4369
4450
|
"row.poolTestedAt": "测试于 {at}",
|
|
4370
4451
|
"row.poolCreditSoon": "{count} 积分 3 天内过期",
|
|
4371
4452
|
"row.poolCreditNearest": "最近积分包 {at} 过期",
|
|
4372
4453
|
"row.poolExcludedRateLimited": "被限流",
|
|
4373
|
-
"row.poolExcludedOutOfCredit": "
|
|
4454
|
+
"row.poolExcludedOutOfCredit": "用量已达上限",
|
|
4374
4455
|
"row.poolExcludedRejected": "被拒绝",
|
|
4375
4456
|
"row.poolExcludedUnusable": "不可用",
|
|
4376
4457
|
"row.poolRetryAt": "{at} 之后可再用",
|
|
@@ -4583,8 +4664,8 @@ window.__ModuleLoader__.load({
|
|
|
4583
4664
|
"row.probeOkMs": "✓ Usable ({ms})",
|
|
4584
4665
|
"row.probeRateLimitedUnknown": "✗ Rate limited · the upstream gave no time for when it frees up",
|
|
4585
4666
|
"row.probeRateLimitedAt": "✗ Rate limited · usable again after {at}",
|
|
4586
|
-
"row.probeOutOfCreditUnknown": "✗
|
|
4587
|
-
"row.probeOutOfCreditAt": "✗
|
|
4667
|
+
"row.probeOutOfCreditUnknown": "✗ Usage limit reached · the upstream gave no time for when it frees up",
|
|
4668
|
+
"row.probeOutOfCreditAt": "✗ Usage limit reached · recovers {at}",
|
|
4588
4669
|
"row.probeCredentialRejected": "✗ Qoder refused this account's PAT — add a new one or pick another account",
|
|
4589
4670
|
"row.probePolicyRejected": "✗ Qoder's server refused this request under its content policy — re-adding the PAT will not help",
|
|
4590
4671
|
"row.probeNotFound": "✗ The upstream does not know this model",
|
|
@@ -4596,7 +4677,7 @@ window.__ModuleLoader__.load({
|
|
|
4596
4677
|
"row.docsHint": "How to create a PAT?",
|
|
4597
4678
|
"row.poolTitle": "Account pool",
|
|
4598
4679
|
"row.poolNoCandidate": "No account in this pool can be used right now.",
|
|
4599
|
-
"row.poolNoCandidateHint": "Members are rate-limited,
|
|
4680
|
+
"row.poolNoCandidateHint": "Members are rate-limited, at their usage limit, rejected, or unreachable. Rate limits, usage limits and transient failures return to the pool on their own — at the time the upstream states, or after a 30-minute fallback. Only a rejected credential needs a new PAT.",
|
|
4600
4681
|
"row.poolEmptyHint": "This region has no accounts yet. Use “Add PAT” above to paste one, or “Export / import accounts” to bring them over from another machine — the pool, the model tests and the daily check-in all need at least one account.",
|
|
4601
4682
|
"row.poolTestAll": "Test all accounts",
|
|
4602
4683
|
"row.poolCheckinAll": "Claim daily credits",
|
|
@@ -4628,15 +4709,16 @@ window.__ModuleLoader__.load({
|
|
|
4628
4709
|
"row.poolCheckedIn": "Checked in",
|
|
4629
4710
|
"row.poolNotCheckedIn": "Not checked in",
|
|
4630
4711
|
"row.poolCheckinUnknown": "—",
|
|
4631
|
-
"row.poolCheckinNext": "
|
|
4632
|
-
"row.poolCheckinNextHint": "This daily window has been claimed. The
|
|
4633
|
-
"row.poolCheckinDue": "
|
|
4712
|
+
"row.poolCheckinNext": "Claimable again in {countdown}",
|
|
4713
|
+
"row.poolCheckinNextHint": "This daily window has been claimed. The number is when the NEXT window opens (claim again then) — the same countdown the Qoder web page shows, not how often this row refreshes.",
|
|
4714
|
+
"row.poolCheckinDue": "Claimable now",
|
|
4715
|
+
"row.poolCheckinDueHint": "The next window has opened, so this account can claim again now. \"Checked in\" referred to the previous window, so it is not repeated here.",
|
|
4634
4716
|
"row.poolNeverTested": "Not tested yet",
|
|
4635
4717
|
"row.poolTestedAt": "Tested {at}",
|
|
4636
4718
|
"row.poolCreditSoon": "{count} expiring within 3 days",
|
|
4637
4719
|
"row.poolCreditNearest": "Nearest package expires {at}",
|
|
4638
4720
|
"row.poolExcludedRateLimited": "Rate limited",
|
|
4639
|
-
"row.poolExcludedOutOfCredit": "
|
|
4721
|
+
"row.poolExcludedOutOfCredit": "Usage limit reached",
|
|
4640
4722
|
"row.poolExcludedRejected": "Rejected",
|
|
4641
4723
|
"row.poolExcludedUnusable": "Unusable",
|
|
4642
4724
|
"row.poolRetryAt": "Back after {at}",
|
|
@@ -564,6 +564,14 @@ const CHAT_PATH = "/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=
|
|
|
564
564
|
* 注意是 **v1**(用量那套是 v2)。
|
|
565
565
|
*/
|
|
566
566
|
const CAMPAIGNS_PATH = "/sash/api/v1/me/campaigns";
|
|
567
|
+
/**
|
|
568
|
+
* sash 面专用的令牌交换端点(CLI / 网页那一族)。
|
|
569
|
+
*
|
|
570
|
+
* 与 {@link JOB_TOKEN_PATH}(桌面客户端那一族)**并存**,不是替代 —— 实测国际版
|
|
571
|
+
* 的 chat 面只认前者换来的 `securityOauthToken`,而 sash / 额度面只认这里换来的
|
|
572
|
+
* `token`;国内版两族等价。见 `QoderSessionHolder.sashToken`。
|
|
573
|
+
*/
|
|
574
|
+
const SASH_TOKEN_PATH = "/api/v1/jobToken/exchange";
|
|
567
575
|
const CAMPAIGNS_QUERY = "?forceRefresh=true";
|
|
568
576
|
/** sash 接口要求的客户端标识(参考实现如此,缺了会被拒)。 */
|
|
569
577
|
const SASH_CLIENT_TYPE = "10";
|
|
@@ -685,7 +693,7 @@ var QoderUpstreamClient = class {
|
|
|
685
693
|
body
|
|
686
694
|
});
|
|
687
695
|
const text = await resp.text();
|
|
688
|
-
if (resp.status !== 200) throw new QoderUpstreamError(
|
|
696
|
+
if (resp.status !== 200) throw new QoderUpstreamError(classifyFailure(resp.status, text), `jobToken 交换失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status, businessCodeOf(text));
|
|
689
697
|
let data;
|
|
690
698
|
try {
|
|
691
699
|
data = JSON.parse(text);
|
|
@@ -717,12 +725,67 @@ var QoderUpstreamClient = class {
|
|
|
717
725
|
jobToken,
|
|
718
726
|
expireAtMs: jobToken.expireTime > 0 ? jobToken.expireTime : 0
|
|
719
727
|
};
|
|
728
|
+
await this.refreshSashToken(credential, holder);
|
|
720
729
|
this.holders.set(credential, holder);
|
|
721
730
|
this.currentKey = credential;
|
|
722
731
|
this.log(`jobToken 已交换: ${jobToken.name} (${jobToken.id})`);
|
|
723
732
|
return jobToken;
|
|
724
733
|
}
|
|
725
734
|
/**
|
|
735
|
+
* 换一枚 sash 面专用的令牌(`POST /api/v1/jobToken/exchange`)。
|
|
736
|
+
*
|
|
737
|
+
* **不抛**:这一族换不到时 sash 那一支会回落到旧令牌(国内版本来就够用),
|
|
738
|
+
* 而整条交换失败只该让「积分读不到」,不该让 chat / 对话跟着挂 —— 那是完全
|
|
739
|
+
* 不同的两条链路。
|
|
740
|
+
*
|
|
741
|
+
* body 键是 `personal_token`:实测 `personal_access_token`(eghrhegpe 那个 fork
|
|
742
|
+
* 用的拼写)在当前上游回 **400 BadRequest**,两个区域都是。
|
|
743
|
+
*/
|
|
744
|
+
async refreshSashToken(credential, holder) {
|
|
745
|
+
const url = `${this.region.openapi}${SASH_TOKEN_PATH}`;
|
|
746
|
+
try {
|
|
747
|
+
const resp = await this.fetchImpl(url, {
|
|
748
|
+
method: "POST",
|
|
749
|
+
headers: {
|
|
750
|
+
"content-type": "application/json",
|
|
751
|
+
accept: "application/json"
|
|
752
|
+
},
|
|
753
|
+
body: JSON.stringify({ personal_token: credential })
|
|
754
|
+
});
|
|
755
|
+
const text = await resp.text();
|
|
756
|
+
if (resp.status !== 200) {
|
|
757
|
+
this.log(`sash 令牌交换失败(HTTP ${resp.status}),sash 面将回落到旧令牌: ${truncate(text, 120)}`);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const data = parseJson(text);
|
|
761
|
+
const raw = data === void 0 ? {} : data;
|
|
762
|
+
const token = typeof raw["token"] === "string" && raw["token"] !== "" ? raw["token"] : typeof raw["job_token"] === "string" && raw["job_token"] !== "" ? raw["job_token"] : typeof raw["jobToken"] === "string" && raw["jobToken"] !== "" ? raw["jobToken"] : "";
|
|
763
|
+
if (token === "") {
|
|
764
|
+
this.log(`sash 令牌交换响应里没有 token 字段: ${truncate(text, 120)}`);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
holder.sashToken = token;
|
|
768
|
+
const expiry = typeof raw["expires_at"] === "string" ? Date.parse(raw["expires_at"]) : NaN;
|
|
769
|
+
holder.sashExpireAtMs = Number.isFinite(expiry) ? expiry : 0;
|
|
770
|
+
this.log(`sash 令牌已交换(有效期至 ${holder.sashExpireAtMs > 0 ? new Date(holder.sashExpireAtMs).toISOString() : "未知"})`);
|
|
771
|
+
} catch (error) {
|
|
772
|
+
this.log(`sash 令牌交换异常,sash 面将回落到旧令牌: ${String(error?.message ?? error).slice(0, 120)}`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* 取该会话用于 **sash 面** 的 Authorization 值。
|
|
777
|
+
*
|
|
778
|
+
* 新令牌优先(国际版必需);没有就回落到旧的 `securityOauthToken`(国内版
|
|
779
|
+
* 两个面都认它,而且它是 chat 在用的那一个 —— 回落不会破坏任何东西)。
|
|
780
|
+
*
|
|
781
|
+
* 返回 `''` 表示两族都没有:那一支必须报「读不到」,绝不能带上一个空 bearer
|
|
782
|
+
* 去换一个 401 回来(那样分不清是没令牌还是令牌被拒)。
|
|
783
|
+
*/
|
|
784
|
+
sashBearerOf(holder) {
|
|
785
|
+
if (typeof holder.sashToken === "string" && holder.sashToken !== "") return holder.sashToken;
|
|
786
|
+
return holder.jobToken.securityOauthToken;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
726
789
|
* 确保存在未过期会话。credential 提供 PAT 或 refreshToken(`drt-` 前缀)。
|
|
727
790
|
* 已有有效会话时直接复用(不重复交换)。
|
|
728
791
|
*/
|
|
@@ -821,7 +884,7 @@ var QoderUpstreamClient = class {
|
|
|
821
884
|
});
|
|
822
885
|
if (resp.status !== 200) {
|
|
823
886
|
const errText = await resp.text();
|
|
824
|
-
throw new QoderUpstreamError(
|
|
887
|
+
throw new QoderUpstreamError(classifyFailure(resp.status, errText), `聊天请求失败: HTTP ${resp.status} ${truncate(errText, 300)}`, resp.status, businessCodeOf(errText));
|
|
825
888
|
}
|
|
826
889
|
if (!resp.body) throw new QoderUpstreamError("transient", "聊天响应没有 body");
|
|
827
890
|
const reader = resp.body.getReader();
|
|
@@ -888,8 +951,8 @@ var QoderUpstreamClient = class {
|
|
|
888
951
|
* 但**设备风控三件套属于 sash 面**,按 `requiresDeviceRisk` 附加 —— 见下。
|
|
889
952
|
*/
|
|
890
953
|
async jobTokenGet(holder, url, signal) {
|
|
891
|
-
const oauthToken = holder
|
|
892
|
-
if (oauthToken === "") throw new QoderUpstreamError("auth", "
|
|
954
|
+
const oauthToken = this.sashBearerOf(holder);
|
|
955
|
+
if (oauthToken === "") throw new QoderUpstreamError("auth", "两次令牌交换都没有拿到可用令牌,无法查询用量", 401);
|
|
893
956
|
const resp = await this.fetchImpl(url, {
|
|
894
957
|
headers: {
|
|
895
958
|
accept: "application/json",
|
|
@@ -899,7 +962,7 @@ var QoderUpstreamClient = class {
|
|
|
899
962
|
...signal === void 0 ? {} : { signal }
|
|
900
963
|
});
|
|
901
964
|
const text = await resp.text();
|
|
902
|
-
if (resp.status !== 200) throw new QoderUpstreamError(
|
|
965
|
+
if (resp.status !== 200) throw new QoderUpstreamError(classifyFailure(resp.status, text), `请求失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status, businessCodeOf(text));
|
|
903
966
|
try {
|
|
904
967
|
return JSON.parse(text);
|
|
905
968
|
} catch {
|
|
@@ -995,8 +1058,8 @@ var QoderUpstreamClient = class {
|
|
|
995
1058
|
* region 条件附加 —— 不要无条件发,多余的头在另一侧是噪音。
|
|
996
1059
|
*/
|
|
997
1060
|
async sashRequest(method, holder, url, body, signal) {
|
|
998
|
-
const oauthToken = holder
|
|
999
|
-
if (oauthToken === "") throw new QoderUpstreamError("auth", "
|
|
1061
|
+
const oauthToken = this.sashBearerOf(holder);
|
|
1062
|
+
if (oauthToken === "") throw new QoderUpstreamError("auth", "两次令牌交换都没有拿到可用令牌,无法访问签到接口", 401);
|
|
1000
1063
|
const resp = await this.fetchImpl(url, {
|
|
1001
1064
|
method,
|
|
1002
1065
|
headers: {
|
|
@@ -1015,7 +1078,7 @@ var QoderUpstreamClient = class {
|
|
|
1015
1078
|
...signal === void 0 ? {} : { signal }
|
|
1016
1079
|
});
|
|
1017
1080
|
const text = await resp.text();
|
|
1018
|
-
if (resp.status !== 200 && resp.status !== 201) throw new QoderUpstreamError(
|
|
1081
|
+
if (resp.status !== 200 && resp.status !== 201) throw new QoderUpstreamError(classifyFailure(resp.status, text), `请求失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status, businessCodeOf(text));
|
|
1019
1082
|
try {
|
|
1020
1083
|
return JSON.parse(text);
|
|
1021
1084
|
} catch {
|
|
@@ -1082,7 +1145,7 @@ var QoderUpstreamClient = class {
|
|
|
1082
1145
|
...signal === void 0 ? {} : { signal }
|
|
1083
1146
|
});
|
|
1084
1147
|
const text = await resp.text();
|
|
1085
|
-
if (resp.status !== 200) throw new QoderUpstreamError(
|
|
1148
|
+
if (resp.status !== 200) throw new QoderUpstreamError(classifyFailure(resp.status, text), `请求失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status, businessCodeOf(text));
|
|
1086
1149
|
try {
|
|
1087
1150
|
return JSON.parse(text);
|
|
1088
1151
|
} catch {
|
|
@@ -1125,7 +1188,7 @@ function parseStreamDelta(dataPayload) {
|
|
|
1125
1188
|
const scv = wrapper.statusCodeValue;
|
|
1126
1189
|
if (scv !== void 0 && scv !== null && Number(scv) !== 200) {
|
|
1127
1190
|
const detail = typeof wrapper.body === "string" ? wrapper.body : dataPayload.slice(0, 400);
|
|
1128
|
-
throw new QoderUpstreamError(
|
|
1191
|
+
throw new QoderUpstreamError(classifyFailure(Number(scv), detail), `上游信封错误: HTTP ${scv} ${truncate(detail, 300)}`, Number(scv), businessCodeOf(detail));
|
|
1129
1192
|
}
|
|
1130
1193
|
const innerStr = wrapper.body;
|
|
1131
1194
|
if (typeof innerStr !== "string" || innerStr === "") return void 0;
|
|
@@ -1217,12 +1280,93 @@ function classifyHttpStatus(status, detail) {
|
|
|
1217
1280
|
if (status === 418 || status >= 500) return "transient";
|
|
1218
1281
|
return "unknown";
|
|
1219
1282
|
}
|
|
1220
|
-
/**
|
|
1283
|
+
/**
|
|
1284
|
+
* 从上游错误体里取**业务码**(`{"code":"110","message":"…"}`)。
|
|
1285
|
+
*
|
|
1286
|
+
* 上游在这一点上有过两种写法:字符串 `"110"` 与裸数字 `110`,都见过;这里
|
|
1287
|
+
* 一律归一成字符串。取不到(不是 JSON、没有 code、code 不是串/数)返回
|
|
1288
|
+
* `undefined` —— 调用方据此回落到 HTTP 状态分类,**绝不**把「读不出码」当成
|
|
1289
|
+
* 某种结论。
|
|
1290
|
+
*/
|
|
1291
|
+
function businessCodeOf(detail) {
|
|
1292
|
+
const trimmed = detail.trim();
|
|
1293
|
+
if (!trimmed.startsWith("{")) return void 0;
|
|
1294
|
+
let parsed;
|
|
1295
|
+
try {
|
|
1296
|
+
parsed = JSON.parse(trimmed);
|
|
1297
|
+
} catch {
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
1301
|
+
const raw = parsed.code;
|
|
1302
|
+
if (typeof raw === "string" && raw !== "") return raw;
|
|
1303
|
+
if (typeof raw === "number" && Number.isInteger(raw)) return String(raw);
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* 一次失败的分类:**业务码优先于 HTTP 状态**,状态只作兜底。
|
|
1307
|
+
*
|
|
1308
|
+
* 这是本轮的根因所在(用户实拍:国际版「Qoder 拒绝了这个账号的 PAT」)。实测
|
|
1309
|
+
* 上游对**当日用量用尽**回的是
|
|
1310
|
+
*
|
|
1311
|
+
* ```
|
|
1312
|
+
* HTTP 403 {"code":"110","message":"Billing daily count exceeded"}
|
|
1313
|
+
* ```
|
|
1314
|
+
*
|
|
1315
|
+
* 而旧实现只看状态:`403 → auth → credential-rejected → 「请添加新 PAT」`。
|
|
1316
|
+
* 那句话不只是文案难听 —— `credential-rejected` 在账号池里是**无条件永久排除**
|
|
1317
|
+
* (`exclusionOf` 连冷却期都不看,理由写得很对:令牌被拒,等也没用)。于是
|
|
1318
|
+
* 一个只是今天用满、明天自动恢复的账号被踢出池且**不会自愈**,同时被要求去做
|
|
1319
|
+
* 一件根本无效的事(换 PAT)。
|
|
1320
|
+
*
|
|
1321
|
+
* 官方码表(docs.qoder.com/zh/cli/sdk/errors)里 `105` 才是「登录态或 Access
|
|
1322
|
+
* Token 已过期」;`110`/`113`/`115`–`119`/`122` 全是**用量与配额**族。也就是说
|
|
1323
|
+
* 「403 就是凭据坏了」这个推断在协议层面根本不成立。
|
|
1324
|
+
*
|
|
1325
|
+
* 认识的码说了算;不认识的(如 `TOKEN_EXPIRE` 这类字符串码)回落状态分类,
|
|
1326
|
+
* 行为与改动前一致。
|
|
1327
|
+
*/
|
|
1328
|
+
function classifyFailure(status, detail) {
|
|
1329
|
+
const code = businessCodeOf(detail);
|
|
1330
|
+
if (code !== void 0) {
|
|
1331
|
+
const byCode = classifyBusinessCode(code);
|
|
1332
|
+
if (byCode !== "unknown") return byCode;
|
|
1333
|
+
}
|
|
1334
|
+
return classifyHttpStatus(status, detail);
|
|
1335
|
+
}
|
|
1336
|
+
/** 业务 code → 错误分类(官方码表,见 {@link classifyFailure})。 */
|
|
1221
1337
|
function classifyBusinessCode(code) {
|
|
1222
1338
|
if (code === "105") return "auth";
|
|
1223
|
-
if (code
|
|
1339
|
+
if (QUOTA_CODES.has(code)) return "quota-exhausted";
|
|
1340
|
+
if (code === "406") return "content-policy";
|
|
1341
|
+
if (REQUEST_CODES.has(code)) return "invalid-request";
|
|
1342
|
+
if (TRANSIENT_CODES.has(code)) return "transient";
|
|
1224
1343
|
return "unknown";
|
|
1225
1344
|
}
|
|
1345
|
+
const QUOTA_CODES = /* @__PURE__ */ new Set([
|
|
1346
|
+
"110",
|
|
1347
|
+
"113",
|
|
1348
|
+
"115",
|
|
1349
|
+
"116",
|
|
1350
|
+
"117",
|
|
1351
|
+
"118",
|
|
1352
|
+
"119",
|
|
1353
|
+
"122",
|
|
1354
|
+
"402",
|
|
1355
|
+
"6004"
|
|
1356
|
+
]);
|
|
1357
|
+
/** 416 范围/结构无法满足 · 430 能力不支持 · 80411 输入过长 · 80412 媒体过多。 */
|
|
1358
|
+
const REQUEST_CODES = /* @__PURE__ */ new Set([
|
|
1359
|
+
"416",
|
|
1360
|
+
"430",
|
|
1361
|
+
"80411",
|
|
1362
|
+
"80412"
|
|
1363
|
+
]);
|
|
1364
|
+
const TRANSIENT_CODES = /* @__PURE__ */ new Set([
|
|
1365
|
+
"500",
|
|
1366
|
+
"10408",
|
|
1367
|
+
"10500",
|
|
1368
|
+
"10605"
|
|
1369
|
+
]);
|
|
1226
1370
|
const CONTENT_POLICY_MARKERS = [
|
|
1227
1371
|
"DataInspectionFailed",
|
|
1228
1372
|
"inappropriate content",
|
|
@@ -1233,6 +1377,14 @@ const CONTENT_POLICY_MARKERS = [
|
|
|
1233
1377
|
function isContentPolicy(detail) {
|
|
1234
1378
|
return CONTENT_POLICY_MARKERS.some((marker) => detail.includes(marker));
|
|
1235
1379
|
}
|
|
1380
|
+
/** Parse JSON, or undefined when the body is not JSON at all. Never throws. */
|
|
1381
|
+
function parseJson(text) {
|
|
1382
|
+
try {
|
|
1383
|
+
return JSON.parse(text);
|
|
1384
|
+
} catch {
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1236
1388
|
function truncate(text, max) {
|
|
1237
1389
|
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
1238
1390
|
}
|
|
@@ -1657,7 +1809,7 @@ var QoderAccountStore = class {
|
|
|
1657
1809
|
//#endregion
|
|
1658
1810
|
//#region src/version.ts
|
|
1659
1811
|
/** The npm package version this build was produced from. */
|
|
1660
|
-
const QODER_CONNECT_VERSION = "0.
|
|
1812
|
+
const QODER_CONNECT_VERSION = "0.4.2";
|
|
1661
1813
|
//#endregion
|
|
1662
1814
|
//#region src/account-pool-store.ts
|
|
1663
1815
|
/**
|
package/lib/index.d.ts
CHANGED
|
@@ -2,6 +2,42 @@ import z from "@deepseek-ai/schemastery";
|
|
|
2
2
|
import { Context } from "@deepseek-ai/cordis";
|
|
3
3
|
//#region src/upstream.d.ts
|
|
4
4
|
type QoderRegion = 'cn' | 'global';
|
|
5
|
+
/** 上游错误分类,供 shim 映射为 DSH 可判定的 code。 */
|
|
6
|
+
type UpstreamErrorKind = 'network' | 'auth' | 'rate-limited' | 'invalid-request' | 'content-policy' | 'quota-exhausted' | 'transient' | 'unknown';
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/catalog.d.ts
|
|
9
|
+
interface QoderModelInfo {
|
|
10
|
+
key: string;
|
|
11
|
+
displayName: string;
|
|
12
|
+
/** 积分倍率(模型名内嵌 `· xN.NN`)。 */
|
|
13
|
+
priceFactor: number;
|
|
14
|
+
/** 原生输入窗口(由 context_config 首档或 max_input_tokens 决定)。 */
|
|
15
|
+
contextWindow: number;
|
|
16
|
+
maxOutputTokens: number;
|
|
17
|
+
supportsImages: boolean;
|
|
18
|
+
supportsThinking: boolean;
|
|
19
|
+
/** 可完全关闭思考(thinking_config.disabled 存在)。 */
|
|
20
|
+
canDisableThinking: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* 上游声明的思考强度档位(`thinking_config.enabled.efforts` 的键,按强度
|
|
23
|
+
* 升序)。不存在时该模型没有可选档位,卡片不渲染这一项。
|
|
24
|
+
*
|
|
25
|
+
* 实测 Qoder 的档位名不统一:Qwen 系用 `low / medium / xhigh`,DeepSeek/GLM
|
|
26
|
+
* 用 `low / high / max`——所以只做展示,不做跨模型比较。上游给出的键序也不
|
|
27
|
+
* 可靠(JSON 里可能先出现 `xhigh`),必须按语义排序后再显示。
|
|
28
|
+
*/
|
|
29
|
+
thinkingEfforts?: string[];
|
|
30
|
+
/** 上游标为默认的档位(`efforts[x].is_default`)。 */
|
|
31
|
+
defaultThinkingEffort?: string;
|
|
32
|
+
isDefault: boolean;
|
|
33
|
+
isFree: boolean;
|
|
34
|
+
isNew: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* 网关声明可用的上下文档位(`context_config` 各项的 token_count,升序)。
|
|
37
|
+
* 不存在时无档位可选,卡片不渲染预算选择器。
|
|
38
|
+
*/
|
|
39
|
+
contextTiers?: number[];
|
|
40
|
+
}
|
|
5
41
|
//#endregion
|
|
6
42
|
//#region src/status-paths.d.ts
|
|
7
43
|
/**
|
|
@@ -13,6 +49,14 @@ type QoderRegion = 'cn' | 'global';
|
|
|
13
49
|
* costs nothing on the Host side either.
|
|
14
50
|
*/
|
|
15
51
|
declare const QODER_GITHUB_URL = "https://github.com/dingminhua/dsh-connect-qoder";
|
|
52
|
+
/**
|
|
53
|
+
* Outcome vocabulary of one model probe, shared by the Host and the card.
|
|
54
|
+
*
|
|
55
|
+
* Mirrors `QoderProbeOutcome` in `./probe.ts`; kept as its own declaration
|
|
56
|
+
* here because this module is the node-free bridge and must not import the
|
|
57
|
+
* Host-side probe module.
|
|
58
|
+
*/
|
|
59
|
+
type QoderWebProbeOutcome = 'ok' | 'rate-limited' | 'out-of-credit' | 'credential-rejected' | 'policy-rejected' | 'unavailable' | 'not-found' | 'failed';
|
|
16
60
|
//#endregion
|
|
17
61
|
//#region src/version.d.ts
|
|
18
62
|
/**
|
|
@@ -26,6 +70,46 @@ declare const QODER_GITHUB_URL = "https://github.com/dingminhua/dsh-connect-qode
|
|
|
26
70
|
/** The npm package version this build was produced from. */
|
|
27
71
|
declare const QODER_CONNECT_VERSION: string;
|
|
28
72
|
//#endregion
|
|
73
|
+
//#region src/account-pool.d.ts
|
|
74
|
+
/**
|
|
75
|
+
* Which of the pool's writers produced a measurement.
|
|
76
|
+
*
|
|
77
|
+
* `test-batch` — the user pressed "test", so the outcome is a deliberate
|
|
78
|
+
* measurement of the target model.
|
|
79
|
+
* `live-request` — a real chat request failed and its failure was recorded so
|
|
80
|
+
* the NEXT request starts from a measurement instead of rediscovering the
|
|
81
|
+
* same wall.
|
|
82
|
+
*/
|
|
83
|
+
type QoderPoolProbeSource = 'test-batch' | 'live-request';
|
|
84
|
+
/**
|
|
85
|
+
* What the pool knows about ONE account's last test of the target model.
|
|
86
|
+
*
|
|
87
|
+
* `undefined` means "never tested", deliberately distinct from every measured
|
|
88
|
+
* outcome: an untested account is a candidate (the user may want to use it
|
|
89
|
+
* now) whereas a measured failure is not.
|
|
90
|
+
*/
|
|
91
|
+
interface QoderPoolProbe {
|
|
92
|
+
outcome: QoderWebProbeOutcome;
|
|
93
|
+
/** When this measurement was taken, in epoch ms. */
|
|
94
|
+
atMs: number;
|
|
95
|
+
/**
|
|
96
|
+
* WHAT WROTE THIS MEASUREMENT. Absent for any measurement written before the
|
|
97
|
+
* field existed, so old probe files stay readable — an absent source means
|
|
98
|
+
* "unknown", never a guess.
|
|
99
|
+
*/
|
|
100
|
+
source?: QoderPoolProbeSource;
|
|
101
|
+
/**
|
|
102
|
+
* When this account may be usable again, when the upstream stated a time.
|
|
103
|
+
* ABSENT when the upstream named no time — never a locally invented one.
|
|
104
|
+
*/
|
|
105
|
+
retryAtMs?: number;
|
|
106
|
+
/**
|
|
107
|
+
* The upstream's own words on the failure, already redacted by the probe.
|
|
108
|
+
* ABSENT for a success.
|
|
109
|
+
*/
|
|
110
|
+
message?: string;
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
29
113
|
//#region src/index.d.ts
|
|
30
114
|
declare const name = "dsh-connect-qoder";
|
|
31
115
|
/** The model registry and settings service required before providers register. */
|
|
@@ -39,6 +123,42 @@ interface Config {
|
|
|
39
123
|
regions: Record<string, unknown>;
|
|
40
124
|
}
|
|
41
125
|
declare const Config: z<Config>;
|
|
126
|
+
/**
|
|
127
|
+
* 一份目录是否**声明了**思考档位。
|
|
128
|
+
*
|
|
129
|
+
* 「一个模型都没有档位」有两种完全不同的含义,必须分开:
|
|
130
|
+
* 1. 上游当前没声明(`thinkingEfforts` 字段缺席或为空)—— 那它是「上游没
|
|
131
|
+
* 档位」的诚实答案;
|
|
132
|
+
* 2. 这份目录来自一次没拉到档位的时刻 —— 本机实测 14 个 cn 模型的
|
|
133
|
+
* `thinkingEfforts` 全是 `[]`,是旧 schema 把字段剥掉时的产物。用它当答案,
|
|
134
|
+
* 卡片就不渲染档位选择器(用户实拍「思考强度有时候有、有时候没有」:点一次
|
|
135
|
+
* 「从 Qoder 刷新」就有,重启后就没)。
|
|
136
|
+
*
|
|
137
|
+
* 两种情况从数据上分不开(空数组与缺席都判「没声明」),要不要重拉由**目录的
|
|
138
|
+
* 来源**决定:调用方只在「本进程还没成功拉到实时目录」时才据此触发重拉(见
|
|
139
|
+
* displayModels)—— 实时目录没档位是上游的诚实答案,重拉多少次都一样。
|
|
140
|
+
*
|
|
141
|
+
* 判据用 `length > 0` 而不是「字段在不在」:旧 schema 留下的是**空数组**而不是
|
|
142
|
+
* 缺席,所以「字段在」恰恰不能作为「声明过」的证据。
|
|
143
|
+
*/
|
|
144
|
+
declare function catalogSnapshotDeclaresEfforts(models: readonly {
|
|
145
|
+
thinkingEfforts?: readonly string[];
|
|
146
|
+
}[]): boolean;
|
|
147
|
+
/**
|
|
148
|
+
* 「手上的目录能不能当档位答案」——**判据与动作打包在一起**。
|
|
149
|
+
*
|
|
150
|
+
* 拆开成两个函数(判据一个、调用处自己判)会留下一条没被测试覆盖的接线:把调用处
|
|
151
|
+
* 变异掉,单测仍然全绿(第一版就是这么自我欺骗的)。这里让规则自己执行动作并
|
|
152
|
+
* 返回结论,于是「动作有没有真的发生」也能被断言。
|
|
153
|
+
*
|
|
154
|
+
* 检测到「一个档位都没声明」时**不改动要服务的模型列表**:模型不能从卡片上
|
|
155
|
+
* 消失,缺的只是档位 —— 后台拉回来之后的下一次读取自然补上。返回 `stale`
|
|
156
|
+
* 标记这次调用有没有触发过拉取。
|
|
157
|
+
*/
|
|
158
|
+
declare function serveSavedCatalog(models: readonly QoderModelInfo[], onStale: () => void): {
|
|
159
|
+
models: readonly QoderModelInfo[];
|
|
160
|
+
stale: boolean;
|
|
161
|
+
};
|
|
42
162
|
/** 某区域的配置状态(缺省返回空对象)。 */
|
|
43
163
|
declare function regionStateOf(config: {
|
|
44
164
|
regions?: Record<string, unknown> | null;
|
|
@@ -52,5 +172,21 @@ declare function regionHasCredential(config: {
|
|
|
52
172
|
regions?: Record<string, unknown> | null;
|
|
53
173
|
}, region: QoderRegion): boolean;
|
|
54
174
|
declare function apply(ctx: Context, config: Config): void;
|
|
175
|
+
/**
|
|
176
|
+
* 一次**真实请求**失败要写进账号池的那条测量;kind 不可映射时返回 undefined
|
|
177
|
+
* (不写:内容策略拒绝是「这一次输入」的事实,不是账号的事实,见
|
|
178
|
+
* `exclusionOf` 的 policy-rejected 分支)。
|
|
179
|
+
*
|
|
180
|
+
* 抽成模块级纯函数并导出,是因为它此前是 `onServingFailure` 里的一段内联字面量,
|
|
181
|
+
* 而那段代码有一个**已经发生过的**缺陷:上游原文被丢掉(参数写成 `_message`),
|
|
182
|
+
* 于是池里只有一句结论、没有依据 —— 用户实拍那条 `credential-rejected` 记录连
|
|
183
|
+
* `message` 都没有,卡片只能空口指控「PAT 被拒」,而真相(`code 110` 当日用量
|
|
184
|
+
* 用尽)就在被丢掉的那串字符里。探针路径早就带原文(`redactUpstreamText`),
|
|
185
|
+
* 两条路径写同一个文件却只有一个带。
|
|
186
|
+
*
|
|
187
|
+
* 按本项目的既有教训(「判据与动作打包,于是动作有没有真的发生也能被断言」),
|
|
188
|
+
* 把它做成可测函数而不是留在接线里:接线变异掉,测试照样全绿过一次了。
|
|
189
|
+
*/
|
|
190
|
+
declare function liveRequestProbe(kind: UpstreamErrorKind, message: string): QoderPoolProbe | undefined;
|
|
55
191
|
//#endregion
|
|
56
|
-
export { Config, QODER_CONNECT_VERSION, QODER_GITHUB_URL, apply, asVolatile, inject, name, regionEnabled, regionHasCredential, regionStateOf, settingsNamespaceOf, unwrapVolatile };
|
|
192
|
+
export { Config, QODER_CONNECT_VERSION, QODER_GITHUB_URL, apply, asVolatile, catalogSnapshotDeclaresEfforts, inject, liveRequestProbe, name, regionEnabled, regionHasCredential, regionStateOf, serveSavedCatalog, settingsNamespaceOf, unwrapVolatile };
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as piAiRuntimeInfo, a as writeHostHeartbeat, b as QoderUpstreamError, c as writePoolProbes, h as patFromEnv, l as QODER_CONNECT_VERSION, m as maskPat, p as isQoderAccountId, s as readPoolProbes, t as clearHostHeartbeat, u as QoderAccountStore, x as isQoderUpstreamError, y as QoderUpstreamClient } from "./host-heartbeat-
|
|
1
|
+
import { _ as piAiRuntimeInfo, a as writeHostHeartbeat, b as QoderUpstreamError, c as writePoolProbes, h as patFromEnv, l as QODER_CONNECT_VERSION, m as maskPat, p as isQoderAccountId, s as readPoolProbes, t as clearHostHeartbeat, u as QoderAccountStore, x as isQoderUpstreamError, y as QoderUpstreamClient } from "./host-heartbeat-DOru8uF9.js";
|
|
2
2
|
import { _ as regionOfStatusUrl, a as QODER_MODELS_REFRESH_PATH, c as QODER_REGIONS, d as QODER_SETTINGS_NS, f as QODER_TRANSFER_PATH, g as poolActionOf, h as poolAccountIdOf, i as QODER_GITHUB_URL, m as accountActionOf, n as QODER_ACCOUNTS_REFRESH_PATH, o as QODER_POOL_PATH, p as QODER_USAGE_PATH, r as QODER_ACCOUNT_CREDITS_PATH, s as QODER_PROBE_PATH, t as QODER_ACCOUNTS_PATH, u as QODER_SAVE_PATH, v as transferActionOf, y as unwrapVolatileDeep } from "./status-paths-w2WDUPVS.js";
|
|
3
3
|
import z from "@deepseek-ai/schemastery";
|
|
4
4
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
@@ -2077,6 +2077,49 @@ const regionConfig = z.object({
|
|
|
2077
2077
|
lastCatalog: z.array(modelConfig).default([])
|
|
2078
2078
|
});
|
|
2079
2079
|
const Config = z.object({ regions: asVolatile(z.dict(regionConfig).default({}).description("Per-region Qoder settings, keyed cn | global")) });
|
|
2080
|
+
/**
|
|
2081
|
+
* 一份目录是否**声明了**思考档位。
|
|
2082
|
+
*
|
|
2083
|
+
* 「一个模型都没有档位」有两种完全不同的含义,必须分开:
|
|
2084
|
+
* 1. 上游当前没声明(`thinkingEfforts` 字段缺席或为空)—— 那它是「上游没
|
|
2085
|
+
* 档位」的诚实答案;
|
|
2086
|
+
* 2. 这份目录来自一次没拉到档位的时刻 —— 本机实测 14 个 cn 模型的
|
|
2087
|
+
* `thinkingEfforts` 全是 `[]`,是旧 schema 把字段剥掉时的产物。用它当答案,
|
|
2088
|
+
* 卡片就不渲染档位选择器(用户实拍「思考强度有时候有、有时候没有」:点一次
|
|
2089
|
+
* 「从 Qoder 刷新」就有,重启后就没)。
|
|
2090
|
+
*
|
|
2091
|
+
* 两种情况从数据上分不开(空数组与缺席都判「没声明」),要不要重拉由**目录的
|
|
2092
|
+
* 来源**决定:调用方只在「本进程还没成功拉到实时目录」时才据此触发重拉(见
|
|
2093
|
+
* displayModels)—— 实时目录没档位是上游的诚实答案,重拉多少次都一样。
|
|
2094
|
+
*
|
|
2095
|
+
* 判据用 `length > 0` 而不是「字段在不在」:旧 schema 留下的是**空数组**而不是
|
|
2096
|
+
* 缺席,所以「字段在」恰恰不能作为「声明过」的证据。
|
|
2097
|
+
*/
|
|
2098
|
+
function catalogSnapshotDeclaresEfforts(models) {
|
|
2099
|
+
return models.some((model) => (model.thinkingEfforts?.length ?? 0) > 0);
|
|
2100
|
+
}
|
|
2101
|
+
/**
|
|
2102
|
+
* 「手上的目录能不能当档位答案」——**判据与动作打包在一起**。
|
|
2103
|
+
*
|
|
2104
|
+
* 拆开成两个函数(判据一个、调用处自己判)会留下一条没被测试覆盖的接线:把调用处
|
|
2105
|
+
* 变异掉,单测仍然全绿(第一版就是这么自我欺骗的)。这里让规则自己执行动作并
|
|
2106
|
+
* 返回结论,于是「动作有没有真的发生」也能被断言。
|
|
2107
|
+
*
|
|
2108
|
+
* 检测到「一个档位都没声明」时**不改动要服务的模型列表**:模型不能从卡片上
|
|
2109
|
+
* 消失,缺的只是档位 —— 后台拉回来之后的下一次读取自然补上。返回 `stale`
|
|
2110
|
+
* 标记这次调用有没有触发过拉取。
|
|
2111
|
+
*/
|
|
2112
|
+
function serveSavedCatalog(models, onStale) {
|
|
2113
|
+
if (catalogSnapshotDeclaresEfforts(models)) return {
|
|
2114
|
+
models,
|
|
2115
|
+
stale: false
|
|
2116
|
+
};
|
|
2117
|
+
onStale();
|
|
2118
|
+
return {
|
|
2119
|
+
models,
|
|
2120
|
+
stale: true
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
2080
2123
|
/** 某区域的配置状态(缺省返回空对象)。 */
|
|
2081
2124
|
function regionStateOf(config, region) {
|
|
2082
2125
|
const stored = unwrapVolatile(config.regions)?.[region];
|
|
@@ -2302,21 +2345,47 @@ function apply(ctx, config) {
|
|
|
2302
2345
|
* **运行时目录只含用户勾选的模型**(DSH 的选择器读它),而卡片必须看到**完整
|
|
2303
2346
|
* 目录**,否则未勾选的模型根本不出现在卡片里,用户再也勾不上它们。
|
|
2304
2347
|
*
|
|
2305
|
-
*
|
|
2306
|
-
*
|
|
2307
|
-
*
|
|
2308
|
-
*
|
|
2348
|
+
* 实时目录(`fullCatalog`)优先于运行时目录:实时目录反映上游**当前**声明了
|
|
2349
|
+
* 什么(含思考档位),而运行时目录是启动时由保存的 lastCatalog 装配的 ——
|
|
2350
|
+
* 那是上一次保存时的旧快照(issue #32 的教训:保存槽位丢了目录时必须显示
|
|
2351
|
+
* 实时目录,否则卡片会把用户正在用的模型报成「已下架」)。
|
|
2352
|
+
*
|
|
2353
|
+
* 「思考强度有时候有、有时候没有」(用户实拍)的机制也在这条读路径上:
|
|
2354
|
+
* `fullCatalog` 是纯内存的、只在启动时拉一次;启动那次失败的话,卡片每次
|
|
2355
|
+
* 读到的都是启动装配的旧快照 —— 一个档位都没有,直到用户手动点一次
|
|
2356
|
+
* 「从 Qoder 刷新」。所以读到「本进程还没成功拉过实时目录 + 手上的目录一个
|
|
2357
|
+
* 档位都没声明」时,**触发一次后台重拉**(fire-and-forget,见
|
|
2358
|
+
* ensureCatalogRefresh)并且**继续服务手上的目录** —— 模型不能从卡片上消失,
|
|
2359
|
+
* 缺的只是档位,拉回来之后下一次读取自然补上。
|
|
2309
2360
|
*/
|
|
2310
|
-
function displayModels(
|
|
2311
|
-
const
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
}
|
|
2318
|
-
return QODER_FALLBACK_MODELS.map(fallbackModelInfo);
|
|
2361
|
+
function displayModels(region) {
|
|
2362
|
+
const full = fullCatalog[region];
|
|
2363
|
+
const live = full.length > 0 ? full : stacks[region].catalog.current();
|
|
2364
|
+
if (full.length === 0) serveSavedCatalog(live, () => {
|
|
2365
|
+
ensureCatalogRefresh(region);
|
|
2366
|
+
});
|
|
2367
|
+
return live;
|
|
2319
2368
|
}
|
|
2369
|
+
/**
|
|
2370
|
+
* 幂等地触发一次该区域的目录拉取(**不等待**)。
|
|
2371
|
+
*
|
|
2372
|
+
* 为什么不等待:`displayModels` 是**读**路径,被 `/usage` 路由同步调用;在里面
|
|
2373
|
+
* await 网络会让「卡片读一次快照」这个纯读动作每次都等一次上游。改为
|
|
2374
|
+
* fire-and-forget + 在飞标记去重,拉取完成后下一次读取自然拿到新目录。
|
|
2375
|
+
*
|
|
2376
|
+
* 为什么需要它:目录此前**只在启动时拉一次**(`refreshModels`),而 `fullCatalog`
|
|
2377
|
+
* 是纯内存的。于是「思考强度」在重启后必然退化成那份空快照 —— 除非用户恰好
|
|
2378
|
+
* 去点一次「从 Qoder 刷新」。有没有点过刷新、以及进程活多久,就是「有时候有、
|
|
2379
|
+
* 有时候没有」的全部来源。
|
|
2380
|
+
*/
|
|
2381
|
+
const catalogRefreshInFlight = {};
|
|
2382
|
+
const ensureCatalogRefresh = (region) => {
|
|
2383
|
+
if (catalogRefreshInFlight[region] === true) return;
|
|
2384
|
+
catalogRefreshInFlight[region] = true;
|
|
2385
|
+
refreshModels(region).finally(() => {
|
|
2386
|
+
catalogRefreshInFlight[region] = false;
|
|
2387
|
+
});
|
|
2388
|
+
};
|
|
2320
2389
|
let invalidateCatalog = () => {};
|
|
2321
2390
|
for (const region of QODER_REGIONS) {
|
|
2322
2391
|
const upstream = new QoderUpstreamClient(region, { logger: log });
|
|
@@ -2349,20 +2418,13 @@ function apply(ctx, config) {
|
|
|
2349
2418
|
};
|
|
2350
2419
|
},
|
|
2351
2420
|
failoverAccount: async (tried) => nextFailoverCredential(region, tried),
|
|
2352
|
-
onServingFailure: (accountId, kind,
|
|
2353
|
-
const
|
|
2354
|
-
if (
|
|
2355
|
-
poolRuntime[accountId] =
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
source: "live-request"
|
|
2359
|
-
};
|
|
2360
|
-
writePoolProbes(region, { [accountId]: poolRuntime[accountId] });
|
|
2361
|
-
log(`pool ${region}: ${maskPat(accountId)} marked ${outcome} by live request`);
|
|
2421
|
+
onServingFailure: (accountId, kind, message) => {
|
|
2422
|
+
const probe = liveRequestProbe(kind, message);
|
|
2423
|
+
if (probe !== void 0 && accountId !== "") {
|
|
2424
|
+
poolRuntime[accountId] = probe;
|
|
2425
|
+
writePoolProbes(region, { [accountId]: probe });
|
|
2426
|
+
log(`pool ${region}: ${maskPat(accountId)} marked ${probe.outcome} by live request`);
|
|
2362
2427
|
}
|
|
2363
|
-
},
|
|
2364
|
-
poolServingNotice: (accountId) => {
|
|
2365
|
-
return selectedAccountId(config, region) === accountId ? void 0 : `将由账号 ${maskPat(accountId)} 回答本次问题`;
|
|
2366
2428
|
}
|
|
2367
2429
|
}),
|
|
2368
2430
|
store,
|
|
@@ -2651,7 +2713,7 @@ function apply(ctx, config) {
|
|
|
2651
2713
|
return models;
|
|
2652
2714
|
}
|
|
2653
2715
|
const refreshModels = (region) => {
|
|
2654
|
-
(async () => {
|
|
2716
|
+
return (async () => {
|
|
2655
2717
|
const serving = await resolveServing(region);
|
|
2656
2718
|
if (!serving) {
|
|
2657
2719
|
log(`models refresh skipped for ${region}: no PAT configured`);
|
|
@@ -2793,15 +2855,15 @@ function apply(ctx, config) {
|
|
|
2793
2855
|
/** Build the transfer deps once (region-dispatched). */
|
|
2794
2856
|
const transferDeps = {
|
|
2795
2857
|
exportAccounts: async (region) => {
|
|
2796
|
-
const { transferRecordsFor } = await import("./transfer-
|
|
2858
|
+
const { transferRecordsFor } = await import("./transfer-BQMtjWb5.js");
|
|
2797
2859
|
return { accounts: transferRecordsFor(region, await stacks[region].store.vaultAccounts()) };
|
|
2798
2860
|
},
|
|
2799
2861
|
previewImport: async (_region, text) => {
|
|
2800
|
-
const { previewTransferFile } = await import("./transfer-
|
|
2862
|
+
const { previewTransferFile } = await import("./transfer-BQMtjWb5.js");
|
|
2801
2863
|
return previewTransferFile(text);
|
|
2802
2864
|
},
|
|
2803
2865
|
importAccounts: async (region, text, indexes) => {
|
|
2804
|
-
const { mergeTransferRecords } = await import("./transfer-
|
|
2866
|
+
const { mergeTransferRecords } = await import("./transfer-BQMtjWb5.js");
|
|
2805
2867
|
const { changes, imported, skipped } = mergeTransferRecords(region, text, indexes);
|
|
2806
2868
|
for (const entry of changes.values()) await stacks[region].store.add(entry.pat, entry.accountName);
|
|
2807
2869
|
return {
|
|
@@ -2911,8 +2973,8 @@ function apply(ctx, config) {
|
|
|
2911
2973
|
submitRegionSettings(region, { accountId: "" });
|
|
2912
2974
|
},
|
|
2913
2975
|
catalogs: {
|
|
2914
|
-
cn: { current: () => [...displayModels(
|
|
2915
|
-
global: { current: () => [...displayModels(
|
|
2976
|
+
cn: { current: () => [...displayModels("cn")] },
|
|
2977
|
+
global: { current: () => [...displayModels("global")] }
|
|
2916
2978
|
},
|
|
2917
2979
|
contextBudgets: (region) => contextBudgetsOf(config, region),
|
|
2918
2980
|
thinkingEfforts: (region) => thinkingEffortsOf(config, region),
|
|
@@ -3023,5 +3085,30 @@ const FAIL_OVER_OUTCOME = {
|
|
|
3023
3085
|
network: "unavailable",
|
|
3024
3086
|
transient: "unavailable"
|
|
3025
3087
|
};
|
|
3088
|
+
/**
|
|
3089
|
+
* 一次**真实请求**失败要写进账号池的那条测量;kind 不可映射时返回 undefined
|
|
3090
|
+
* (不写:内容策略拒绝是「这一次输入」的事实,不是账号的事实,见
|
|
3091
|
+
* `exclusionOf` 的 policy-rejected 分支)。
|
|
3092
|
+
*
|
|
3093
|
+
* 抽成模块级纯函数并导出,是因为它此前是 `onServingFailure` 里的一段内联字面量,
|
|
3094
|
+
* 而那段代码有一个**已经发生过的**缺陷:上游原文被丢掉(参数写成 `_message`),
|
|
3095
|
+
* 于是池里只有一句结论、没有依据 —— 用户实拍那条 `credential-rejected` 记录连
|
|
3096
|
+
* `message` 都没有,卡片只能空口指控「PAT 被拒」,而真相(`code 110` 当日用量
|
|
3097
|
+
* 用尽)就在被丢掉的那串字符里。探针路径早就带原文(`redactUpstreamText`),
|
|
3098
|
+
* 两条路径写同一个文件却只有一个带。
|
|
3099
|
+
*
|
|
3100
|
+
* 按本项目的既有教训(「判据与动作打包,于是动作有没有真的发生也能被断言」),
|
|
3101
|
+
* 把它做成可测函数而不是留在接线里:接线变异掉,测试照样全绿过一次了。
|
|
3102
|
+
*/
|
|
3103
|
+
function liveRequestProbe(kind, message) {
|
|
3104
|
+
const outcome = FAIL_OVER_OUTCOME[kind];
|
|
3105
|
+
if (outcome === void 0) return void 0;
|
|
3106
|
+
return {
|
|
3107
|
+
outcome,
|
|
3108
|
+
atMs: Date.now(),
|
|
3109
|
+
source: "live-request",
|
|
3110
|
+
...message === "" ? {} : { message: redactUpstreamText(message) }
|
|
3111
|
+
};
|
|
3112
|
+
}
|
|
3026
3113
|
//#endregion
|
|
3027
|
-
export { Config, QODER_CONNECT_VERSION, QODER_GITHUB_URL, apply, asVolatile, inject, name, regionEnabled, regionHasCredential, regionStateOf, settingsNamespaceOf, unwrapVolatile };
|
|
3114
|
+
export { Config, QODER_CONNECT_VERSION, QODER_GITHUB_URL, apply, asVolatile, catalogSnapshotDeclaresEfforts, inject, liveRequestProbe, name, regionEnabled, regionHasCredential, regionStateOf, serveSavedCatalog, settingsNamespaceOf, unwrapVolatile };
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@dmh2002/dsh-connect-qoder",
|
|
3
3
|
"displayName": "DSH Connect Qoder",
|
|
4
4
|
"description": "把 Qoder(通义灵码国内版 / 国际版)接入 DeepSeek Harness:PAT 多账号池、每日签到、积分读数与模型管理。",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.4.2",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|
|
@@ -88,46 +88,48 @@
|
|
|
88
88
|
},
|
|
89
89
|
"peerDependencies": {
|
|
90
90
|
"@deepseek-ai/cordis": ">=4.0.1-rc.1 <5.0.0 || >=4.0.2 <5.0.0",
|
|
91
|
-
"@deepseek-ai/dsh-atomic-write": ">=0.1.7-rc.1 <0.3.0-0",
|
|
92
|
-
"@deepseek-ai/dsh-attachment": ">=0.1.7-rc.1 <0.3.0-0",
|
|
93
|
-
"@deepseek-ai/dsh-client-
|
|
94
|
-
"@deepseek-ai/dsh-client-ui-
|
|
95
|
-
"@deepseek-ai/dsh-client-ui-
|
|
96
|
-
"@deepseek-ai/dsh-client-ui-settings
|
|
97
|
-
"@deepseek-ai/dsh-client-ui-
|
|
98
|
-
"@deepseek-ai/dsh-client-
|
|
99
|
-
"@deepseek-ai/dsh-home-paths": ">=0.1.7-rc.1 <0.3.0-0",
|
|
100
|
-
"@deepseek-ai/dsh-host-webserver": ">=0.1.7-rc.1 <0.3.0-0",
|
|
101
|
-
"@deepseek-ai/dsh-llm": ">=0.1.7-rc.1 <0.3.0-0",
|
|
102
|
-
"@deepseek-ai/dsh-llm-pi-ai": ">=0.1.7-rc.1 <0.3.0-0",
|
|
103
|
-
"@deepseek-ai/dsh-settings": ">=0.1.7-rc.1 <0.3.0-0",
|
|
91
|
+
"@deepseek-ai/dsh-atomic-write": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
92
|
+
"@deepseek-ai/dsh-attachment": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
93
|
+
"@deepseek-ai/dsh-client-locale": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
94
|
+
"@deepseek-ai/dsh-client-ui-primitives": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
95
|
+
"@deepseek-ai/dsh-client-ui-renderer": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
96
|
+
"@deepseek-ai/dsh-client-ui-settings": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
97
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
98
|
+
"@deepseek-ai/dsh-client-ui-slots": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
99
|
+
"@deepseek-ai/dsh-home-paths": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
100
|
+
"@deepseek-ai/dsh-host-webserver": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
101
|
+
"@deepseek-ai/dsh-llm": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
102
|
+
"@deepseek-ai/dsh-llm-pi-ai": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
103
|
+
"@deepseek-ai/dsh-settings": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
104
104
|
"@deepseek-ai/schemastery": ">=3.18.1-rc.1 <4.0.0 || >=3.18.2 <4.0.0",
|
|
105
105
|
"@earendil-works/pi-ai": ">=0.85.0 <0.88.0",
|
|
106
106
|
"react": "^18.2.0"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|
|
109
109
|
"@deepseek-ai/cordis": ">=4.0.1 <5.0.0 || >=4.0.2 <5.0.0",
|
|
110
|
-
"@deepseek-ai/dsh-atomic-write": ">=0.1.7-rc.1 <0.3.0-0",
|
|
111
|
-
"@deepseek-ai/dsh-attachment": ">=0.1.7-rc.1 <0.3.0-0",
|
|
112
|
-
"@deepseek-ai/dsh-client-locale": ">=0.1.7-rc.1 <0.3.0-0",
|
|
113
|
-
"@deepseek-ai/dsh-client-ui-primitives": ">=0.1.7-rc.1 <0.3.0-0",
|
|
114
|
-
"@deepseek-ai/dsh-client-ui-renderer": ">=0.1.7-rc.1 <0.3.0-0",
|
|
115
|
-
"@deepseek-ai/dsh-client-ui-settings": ">=0.1.7-rc.1 <0.3.0-0",
|
|
116
|
-
"@deepseek-ai/dsh-client-ui-settings-plugins": ">=0.1.7-rc.1 <0.3.0-0",
|
|
117
|
-
"@deepseek-ai/dsh-client-ui-slots": ">=0.1.7-rc.1 <0.3.0-0",
|
|
118
|
-
"@deepseek-ai/dsh-home-paths": ">=0.1.7-rc.1 <0.3.0-0",
|
|
119
|
-
"@deepseek-ai/dsh-host-webserver": ">=0.1.7-rc.1 <0.3.0-0",
|
|
120
|
-
"@deepseek-ai/dsh-llm": ">=0.1.7-rc.1 <0.3.0-0",
|
|
121
|
-
"@deepseek-ai/dsh-llm-pi-ai": ">=0.1.7-rc.1 <0.3.0-0",
|
|
122
|
-
"@deepseek-ai/dsh-settings": ">=0.1.7-rc.1 <0.3.0-0",
|
|
110
|
+
"@deepseek-ai/dsh-atomic-write": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
111
|
+
"@deepseek-ai/dsh-attachment": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
112
|
+
"@deepseek-ai/dsh-client-locale": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
113
|
+
"@deepseek-ai/dsh-client-ui-primitives": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
114
|
+
"@deepseek-ai/dsh-client-ui-renderer": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
115
|
+
"@deepseek-ai/dsh-client-ui-settings": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
116
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
117
|
+
"@deepseek-ai/dsh-client-ui-slots": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
118
|
+
"@deepseek-ai/dsh-home-paths": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
119
|
+
"@deepseek-ai/dsh-host-webserver": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
120
|
+
"@deepseek-ai/dsh-llm": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
121
|
+
"@deepseek-ai/dsh-llm-pi-ai": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
122
|
+
"@deepseek-ai/dsh-settings": ">=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
|
|
123
123
|
"@deepseek-ai/schemastery": ">=3.18.1 <4.0.0 || >=3.18.2 <4.0.0",
|
|
124
124
|
"@earendil-works/pi-ai": ">=0.85.0 <0.88.0",
|
|
125
125
|
"@types/node": "^22.20.0",
|
|
126
126
|
"@types/react": "~18.3.31",
|
|
127
127
|
"@types/react-dom": "18.3.7",
|
|
128
|
+
"@types/semver": "^7.8.0",
|
|
128
129
|
"jsdom": "^29.1.1",
|
|
129
130
|
"react": "^18.3.1",
|
|
130
131
|
"react-dom": "18.3.1",
|
|
132
|
+
"semver": "^7.8.5",
|
|
131
133
|
"tsdown": "^0.22.2",
|
|
132
134
|
"typescript": "^5.9.3",
|
|
133
135
|
"vitest": "^4.1.8"
|