@dmh2002/dsh-connect-qoder 0.4.0 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,100 @@
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.3 (2026-10-13)
6
+
7
+ 两条用户实拍:**国际版对话报「API 密钥无效」而卡片只说「用量已达上限」**,以及**两个区域每次保存都会让思考强度消失**。两条都不是上游的错,是我们自己把答案读丢了。
8
+
9
+ ### Fixed
10
+
11
+ - **「API 密钥无效」是假警报:宿主的分类器读的是文案正则,而我们把上游的 `HTTP 403` 原样送了进去。**
12
+ - **根因(执行验证,不是推断)**:宿主对 pi-ai 失败**不按 HTTP 状态分类,而是对文案跑正则**(`app.asar` 内 `classifyPiAiError`),且 **`401|403` 那道判据排在配额那道之前**:
13
+
14
+ ```js
15
+ if (/\b(?:401|403)\b/.test(message)) return "AUTH"; // ← 第一道,命中即返回
16
+ if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE;
17
+ ```
18
+
19
+ 本插件为溯源一直把上游原文(`上游信封错误: HTTP 403 {"code":"110","message":"Billing daily count exceeded"}`)拼进这条文案。`110` 是**配额族**(当日用量用尽,明天自愈),403 只是上游承载该业务码的习惯 —— 于是第一道正则把配额截走判成 `AUTH`,宿主再用中立文案 `message.failure.AUTH`(**「API 密钥无效」**)覆盖掉提供方自己的解释。实测 `isQuotaExceededError` 对**同一串文本**返回 `true`:分类错是**顺序**造成的,不是判据不认。
20
+ - **为什么两个界面说法不一致**:卡片读的是上游本身(`refreshUsage` → `upstream.fetchUsage`),**从不经过 shim**,所以它显示的是真实配额状态;对话走 shim,在分类那一层被读成了凭据问题。两个数字都对,只是对话那侧的名字错了。
21
+ - **修法**:新增 `hostMessageText(kind, message)` / `hostFailureMessage(kind, status, message, suffix)`,把裸的 `401/403` 改写成 `4xx`,**只对非 `auth` 档这么做**。`auth` 必须原文送达 —— 上游真吐 `401 TOKEN_EXPIRE` 时 AUTH 正是正确答案,而「未配置 PAT」那条中文没有状态码,宿主会原样显示(比换成「API 密钥无效」更有用)。状态码**由 formatter 自己加、也由它自己净化**:调用方自己拼 `(http ${status})` 会绕过净化,第一版正是这么漏的。
22
+ - **覆盖四条真路径,一条都不能少**(第一版只补了其中一条):外层 `handleRequest` catch、`resolveServing` 凭据解析 catch、`handleChat` 首字节前失败、以及**流内错误帧** `writeErrorChunk`。最后一条最容易漏:宿主对 `case "error"` 分支拿 `message.errorMessage` 跑**同一个**分类器,而此时 200 已提交、用户已经在看一个半截回复 —— 分类错在这里最难排查。
23
+ - **顺带修掉同一类缺陷的动态版本**:`模型 ${model} 不支持图像输入` 里的 model 来自**请求体**。目录里若存在 `gpt-401` 这类名字,原文送达会把「不支持图片」读成「API 密钥无效」。这条 400 同样过净化。
24
+ - **边界**:状态码必须**整词**替换(`1401`、`4030`、`v403`、`code_403` 都是溯源信息,不能动),且右边要是数字或「小数点+数字」都不算独立状态码 —— 对抗审核抓到 `401.5` 会被旧判据 `\b` 改写成非法的 `4xx.5`,已用成对环视修掉(`(?<![\w.])(?:401|403)(?!\d)(?!\.\d)`)。
25
+ - **测试不再手抄宿主正则**:`tests/host-piai.ts` 直接**执行**从 `app.asar` 逐字提取的源码(`tests/fixtures/host-piai-classifier.json`,带 asar 的 sha256 与 DSH 版本)。手抄的副本会跟宿主一起漂移,「测试通过」就只证明副本自洽 —— 这正是这处缺陷潜行一个版本的原因。另有一条自检把 golden 与**装机宿主**逐字比对(宿主没装则跳过),宿主升级会直接变红并提示重新提取。
26
+ - **每次保存都把「思考强度」丢掉:客户端手写映射漏了两个字段,而读取端的判据是 `length > 0`。**
27
+ - **机制**(有一处必须说准):schemastery **不剥未声明的键**,真正发生的是**缺席的 `z.array().required(false)` 被补成 `[]`**。所以链条是:卡片手写 `lastCatalog` 条目时**从没写过** `thinkingEfforts` / `defaultThinkingEffort` → 落盘缺席 → 解析补 `[]` → 读取端 `modelFromStored` 判 `length > 0` 不成立 → 档位选择器消失(用户实拍「每次保存都会思考强度不见了」)。schema 里那三个字段的声明仍然必需,但必需的理由是**判据与「缺席补 []」之间的落差**,不是剥键 —— `src/index.ts` 里原先写「zod 静默剥掉」的注释是错的,已更正。
28
+ - **还有一处看不见的漂移同轮修掉**:客户端写 `maxTokens`,而读取端读的是 `maxOutputTokens` —— 名字对不上,永远读不回、静默回落 32768 默认值;`nativeContextWindow` 则无人消费,纯落盘垃圾。
29
+ - **修法照同族 `dsh-connect-workbuddy` 的 `toPersistedWorkBuddyModel`**:**spread 整行再按名剔除卡片独有字段,不逐个字段手写白名单**。workbuddy 的注释写着「omitting it made one Save silently strip the declaration from every model」—— 同一处缺陷它已经出过一次诊。白名单只要漏一个字段,那次保存就把该字段从每个模型上抹掉,而且**没有任何东西会报错**(保存的落地校验只比对 `key` 列表)。三对重命名(`name`/`displayName`、`maxTokens`/`maxOutputTokens`、`creditMultiplier`/`priceFactor`)现在只在投影函数里出现一次,并与读取端逐字对齐。同时删掉与本函数重复、且写着读取端读不出来的字段名的死代码 `toPersistedQoderModel`。
30
+ - **落盘存原生窗口**(`contextWindow: model.nativeContextWindow`)是刻意的:生效窗口是用户预算的决定,读回时由 `applyContextBudgets` 重新施加;若落盘 clamp 后的值,用户撤掉预算后窗口会「焊死」回不到原生。
31
+ - **测试 +10(`tests/persisted-catalog.spec.tsx`)**:调用真正的 `buildModelsPayload`,把它**真实输出**喂给**插件自己导出的 `Config` schema**(不是手抄 schema),再按**读取端判据**断言读回了什么。同样照 workbuddy 补了一条「不产出值为 `undefined` 的属性」用例 —— 投影改成 spread 之后这条从「锦上添花」变成「必需」(显式 `undefined` 会被设置写入端那道严格 JSON codec 拒收,整次保存失败)。
32
+ - **诚实记录**:既有的 `tests/index.spec.ts` 往返用例**手写**了一份「理想的」payload 去喂 schema,它证明的是「schema 不吞字段」,而不是「客户端真的写了这些字段」—— 所以 546 个测试没有一个发现这个缺陷。真实保存路径从未被任何测试执行过,这是它长期潜伏的直接原因。变异验证:把投影退回旧手写映射 → `persisted-catalog.spec.tsx` 3 例变红,而 `index.spec.ts` 18 例**全绿**。
33
+
34
+ ## 0.4.2 (2026-10-13)
35
+
36
+ 用户实拍:国际版账号「ddd」显示 **「✗ Qoder 拒绝了这个账号的 PAT——请添加新 PAT 或换一个账号」**,并附一句「我感觉大概率还是我们出了问题」。**这个判断是对的**:账号一切正常,PAT 没有任何问题,是我们把上游的一句「今天用满了」读成了「你的凭据坏了」。0.4.1 修的是文案自相矛盾,这一版修的是**判据本身**。
37
+
38
+ 同族 `dsh-connect-workbuddy` 在 4.4.2 修掉了那层**保存/删除/导入时的忙碌遮罩**(卡片是近黑板子、spinner 与外壳不同拍),本版把该改动逐条对齐过来 —— 两族共用同一套 `dsm-*` 卡片语言,遮罩也该是同一套。
39
+
40
+ ### Fixed
41
+
42
+ - **任何 HTTP 403 都被判成「凭据被拒」—— 业务码明明就在同一个响应体里,却没被读。**
43
+ - **实测(本机真实 PAT,`scripts/probe-cosy.mjs global`,只读)**:
44
+
45
+ | 检查 | 结果 |
46
+ | --- | --- |
47
+ | PAT 换令牌 | ✅ 成功(姓名可读,`hasOauthToken: true`) |
48
+ | 模型目录 | ✅ 15 个模型,`qfmodel` 在列 |
49
+ | 积分(sash 面) | ✅ `userQuota {total:300, used:0, remaining:300}` + `addOnQuota {total:200}`,`isQuotaExceeded: false` |
50
+ | **对话(chat 面)** | ❌ `HTTP 403 {"code":"110","message":"Billing daily count exceeded"}` |
51
+ | 换 `efficient` 重试 | ❌ **同一个 110** —— 证明与模型 key 无关 |
52
+
53
+ - **官方码表**([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 只是承载码的容器。
54
+ - **三层缺陷叠加,缺一不可**(这也是它一直没被查出来的原因):
55
+ 1. `classifyBusinessCode` 只认 `105` / `115` / `402` / `6004`,**`110` 根本不在表里**,落到 `unknown`;
56
+ 2. 更关键:`403 + 110` **从来没走到过 `classifyBusinessCode`**。SSE 信封分支与 chat 的非 200 分支都只调 `classifyHttpStatus`,于是 `403 → auth`;
57
+ 3. `onServingFailure` 把上游原文**丢掉了**(参数写成 `_message`),落盘的记录只剩一句结论 —— 实测那条 `credential-rejected` 记录**连 `message` 字段都没有**,卡片因此只能空口指控,而真相就在被丢掉的那串字符里。探针路径早就带原文(`redactUpstreamText`),**两条路径写同一个文件却只有一个带**。
58
+ - **危害不止文案难听**:`credential-rejected` 在账号池里是**无条件永久排除**(`exclusionOf` 刻意不看冷却期,理由写在注释里:令牌被拒,等也没用)。这个理由对 `105` 成立、对 `110` 恰好相反 —— 一个明天自动恢复的账号被踢出池且**不自愈**,同时被要求去做一件根本无效的事(换 PAT)。
59
+ - **修法**:新增 `classifyFailure(status, detail)` —— **业务码优先于 HTTP 状态,认不出的码才回落状态**(`TOKEN_EXPIRE` 这类字符串码、非 JSON 的 body、空 body 全部维持原行为,真被拒的那一侧不能被改坏);`businessCodeOf` 把上游两种写法(`"110"` 与裸数字 `110`)归一,取不到就返回 `undefined`,**绝不猜**;官方码表按族补全(配额 / 请求 / 瞬时);SSE 信封与 `chatStream` 两条真路径接入;`onServingFailure` 的那段内联字面量抽成模块级纯函数 `liveRequestProbe`,**带上脱敏后的原文**。
60
+ - **修好误判后暴露出第二个错(同轮改掉)**:110 正确落到 `out-of-credit` 档之后,既有文案是「积分不足 / Not enough credits」—— **而这个账号实测还有 500 积分、`isQuotaExceeded: false`**。`110` 说的是「当日用量**次数**」,与余额无关;照发就是第二句自相矛盾的话(0.4.1 刚为第一句发过补丁)。改成「用量已达上限 / Usage limit reached」,排除芯片与「池里没有可用账号」的提示语**同口径**(否则同一张卡片上两个词指同一件事)。带恢复时刻的那格也去掉了「积分包」这个限定 —— 恢复时刻也可能来自上游的重置句子,写死来源就是编造。
61
+ - **现场复核(改动后打真实上游)**:同一条请求现在报 `kind=quota-exhausted`,不再是 `auth`。
62
+ - **测试 +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 名)。
63
+ - **真接线是六条,不是两条 —— 这是本轮最该诚实记下的一句。** 第一稿只给 `chatStream` 的非 200 分支和 SSE 信封补了端到端测试,**忘了还有四个调用点同样只按状态分类却一个测试都没有**(换令牌 `/jobToken`、积分主路径 `/me/usage`、签到 `/campaigns/**`、模型列表 `/model/list`)。改完那四处之后做了「退化回只看状态」的变异:前两个与信封都立刻被接住,而**模型列表那条跑出的是全绿** —— 又一个漏掉的接线。补上 `fetchModels` 钉住它之后,六个调用点各自退化**都**被对应测试接住。教训还是同一个:分判据(`classifyFailure`)写得再准,**只要有一条真路径没接线,全量测试照样绿**。
64
+ - **八组变异测试**:业务码优先整体关掉 / 信封路径退回只看状态 / `chatStream` 退回只看状态 / 换令牌路径退回只看状态 / 积分主路径退回只看状态 / 签到路径退回只看状态 / 模型列表路径退回只看状态(第一遍**没被接住**,补测试后才接住)/ 丢掉上游原文 / 从配额族删掉 `110` / `businessCodeOf` 不认裸数字 / 文案退回「积分耗尽」与「积分包 {at} 刷新」—— 各自被对应测试接住。
65
+ - **两处诚实记录**:(a) 第一轮变异(只改 `chatStream` 那一行)跑出全绿,正是「真路径没测试」的直接证据 —— 这不是免责,是当时漏掉另外四处的根因;(b) 一次变异脚本的 perl 正则报错(未闭合括号)导致该组**根本没落地**,当时的「通过」是假信号,重跑并校验「变异确已落地」之后才有结论。
66
+ - **既有误判记录会不会一直卡着用户**:不会,但要说清路径。升级后第一次对话仍会失败(今天确实用满了),而这次失败会**覆写**那条记录为 `out-of-credit` —— 它带 30 分钟兜底冷却,会自动回池。`resolveServing`(首发射给谁)**不看探针**,只有池内故障转移与卡片「已排除」芯片看,所以直接选中该账号的用户始终能正常用它。上游对 110 **没给恢复时刻**(英文原文里没有可解析的重置句子),插件因此**不自造零点**,按既有的 30 分钟兜底重试;真正的恢复以用量周期为准。
67
+ - **忙碌遮罩(保存 / 删除 / 导入时那层)改用宿主主题令牌,spinner 换成宿主同款弧线。** 对齐同族 `dsh-connect-workbuddy` 4.4.2(同一处缺陷的修复),三处自造值逐一换掉:
68
+ - 遮罩底色原写死 `rgba(0,0,0,.38)` —— 既不跟主题也没有宿主的模糊。改用宿主自己遮罩用的 `--dsw-alias-bg-mask-1`(亮 `.24` / 暗 `.5`)+ `--dsw-mask-blur`,与外壳里其他遮罩一致。
69
+ - 卡片底色原用 `--dsw-alias-bg-primary`,**这个变量在宿主令牌集里根本不存在**(宿主只有 `bg-base` / `bg-layer-1..3` / `bg-module-platform` / `bg-overlay`),于是一路回退到本文件写死的近黑 `#1b1c20` —— 这就是「一块黑板子、上面的字看不清」的根因。改用宿主给弹层用的 `--dsw-alias-bg-layer-2`,亮暗两套各自成立;阴影也换成宿主自己的 `--dsw-elevation-prominent`。
70
+ - 提示行原用 `label-tertiary` —— 那是宿主最暗的一档(亮 `neutral-bluish-600` / 暗 `neutral-bluish-400`),给元数据用的,不该承载一句要等的人必须读的话。改用 `label-secondary`。
71
+ - **spinner**:原是 18px 的 CSS border 环、`.7s` 匀速转,比宿主自己的状态转圈(`_dsh-state-dot-spin`:SVG 弧线 + dash 呼吸、`1.5s`)快了近一倍 —— 比起形状,**这个速度差才是它看着廉价的主因**。改为同款弧线几何与 `1.5s` 节奏,`currentColor` 继承所在层级,reduced-motion 降级为静态弧线(与宿主降级方式一致),并补 `aria-hidden` 免得 `role="status"` 之外多报一次。
72
+ - 测试 +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 都不报错」(同族真实踩过)。
73
+
74
+ ## 0.4.1 (2026-10-13)
75
+
76
+ 0.4.0 上线后一小时里回来的两个小修:**签到单元格那句自相矛盾的文案**(用户实拍),以及**连红 6 次的 windows CI job**。后者顺带暴露了一个流程洞:发布只跑本地 `check`(macOS),没人看 CI。
77
+
78
+ ### Fixed
79
+
80
+ - **签到那一格「已签到 · 00:39:22 后刷新」自相矛盾 —— 两句话说的是不同的窗**(用户实拍,并直接指出:「这个是 39 分钟后可以签到,所以不能显示已签到」)。
81
+ - **根因是两个字段来自两个窗口**:`checkedInToday` 是**上一窗**的 `claimStatus`,而 `checkinRefreshAtMs` 是那一窗的 `endAt` —— 也就是**下一窗什么时候开**。并排读就成了「今天已经签完,可 39 分钟后又有事可做」。`due` 态更糟:「已签到 · 现在可以领取」,七个字里自我否定。
82
+ - **修法:一格只说一句话**,四态各一句 —— 未知 / 「已签到」(上游没给开窗时刻)/ 「`HH:MM:SS` 后可再签到」/ 「现在可以签到」。去掉「已签到」**不丢任何事实**:「**再**签到」本身已经蕴含这一窗签过了。
83
+ - **「后刷新」也一并改掉**:旧措辞字面说的是「这一行多久刷新」,而那个数字其实是**可再签的时刻**,两回事。悬停提示同步点明,并与 Qoder 网页的倒计时对齐(同一个 `endAt`)。
84
+ - 样式:`next` 那格去掉 `margin-left`(它现在是这一格的主句,不再是附注),保留 `tabular-nums` 防逐秒抖动。
85
+ - **测试的结构性缺口(记下来,这类文案 bug 的唯一防线)**:`tests/pool-render.spec.tsx` 的 `t` 是**保留 key** 的假翻译器 —— 它能钉住「用了哪个 key」,**永远钉不住「这句话读起来通不通」**;而且此前**没有任何测试 import 过 `locales`**。这句矛盾文案就是这么发出去的。新增 `tests/locales.spec.ts`,用**真实 locale 表**断言用户实际看到的字符串(含 `{countdown}` 渲染后的整句),钉住「不并排已签到」「不说刷新」「title 里不放 Markdown 星号」。三组变异测试:把「已签到」并回倒计时格 / 文案改回「后刷新」/ 并回到 due 态 —— 各自被对应测试接住。
86
+
87
+ ### Tests
88
+
89
+ - **CI 的 windows job 连红 6 次 —— 断言了 Windows 表达不了的东西。** `expected 438 to be 384`(`0o666` vs `0o600`),出在 `tests/accounts.spec.ts` 的保险库权限位那条;ubuntu job 一直是绿的。
90
+ - **不是写入路径的 bug**:`src/accounts.ts` 确实请求了 `mode: 0o600`。是 node 在 Windows 上只把「只读」一位映射进 `st_mode`,普通文件一律报 `0o666` —— 写什么权限都读不回来;那边的文件保护由 `%USERPROFILE%` 的 NTFS ACL 承担,与 mode 位无关。
91
+ - **为什么红了 6 次没人管**:这条断言是 `5002aa4` **引入时就带着病**(上一个绿 run `0c865e1` 还没有这个文件),此后每次 push 都精确红在同一条上。真正的问题不是断言写错,是**没人看 CI** —— 发布流程只跑本地 `pnpm run check`(绿),而本地是 macOS,恰好是这条断言能过的平台。
92
+ - **修法**:`it.skipIf(process.platform === 'win32')`,**不是**把断言改成 `0o666` —— 那等于把平台限制钉成「正确行为」,将来写入路径真的丢掉 0600 就再也测不出来。跳过会留在 CI 报告里(已核实:windows job 打印 `accounts.spec.ts (24 tests | 1 skipped)`)。顺带把这条测试拆成两个(区域分键 vs 权限位),失败时能直接看出是哪一半。
93
+
94
+ ### Docs
95
+
96
+ - **`RELEASING.md` 新增第 5.5 步「CI 双平台必须转绿(不得跳过)」。** 上面的红灯红了 6 次而无人察觉,根因是发布门禁只有第 1 步的本地 `pnpm run check` —— 本机是 macOS,恰好是那条断言能过的平台,而 CI 的 windows job 从来没进过清单。新步骤要求:核对最新 run 的 `headSha` **就是**本次推的提交、**两个 job 都 success**(不是只看 overall)、有 skipped 时确认是有意的跳过。第 1 步旁加了指向它的警示。
97
+ - **编号用 5.5 而不重排 6-10**:正文里已有「第 7 步」「第 8 步」「第 9 步」的交叉引用(2FA、registry 核验、GitHub Release),重排会让它们全部错位 —— 沿用 §2.5 的既有先例。
98
+
5
99
  ## 0.4.0 (2026-10-13)
6
100
 
7
101
  0.3.0 发布后挖出的四条修复,外加一条有意的旧行为移除。四条里两条是 0.3.0 自己带出来的伤:**npm 上 0.3.0 的 peer 范围装不进当前 DSH**(装上即 `ERESOLVE`,必须尽快覆盖掉),以及**国际版需要的第二族令牌**;另两条是用户实拍回来的(思考强度间歇性消失、签到倒计时)。其中思考强度那条经历了一次返工 —— 第一版修复在生产路径上是**死代码**,险些带着假说明发布,经过与返工都记在条目里。
@@ -60,6 +154,7 @@
60
154
  - **上游没给 `endAt` 就不显示倒计时**,绝不用本地零点或「上次 +24h」编一个。
61
155
  - **顺带修掉一个真缺陷**:客户端最初把「已领取但上游没给时间」与「倒计时走完了」都表达成 `countdownText(...) === ''`,于是前者被渲染成「现在可以领取」—— 把「不知道下一窗什么时候开」说成了「已经可以领了」。改成四态(`off` / `unknown` / `ticking` / `due`)。测试接住了它:先写成两态时那条断言立刻失败。
62
156
  - 测试 +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` / 上游缺字段时仍然编一个 —— 各自被对应测试接住。
157
+ - **本条的单元格文案在 0.4.1 被推翻**(「已签到 · 倒计时」并排自相矛盾)—— 见 0.4.1 的第一条。发布说明里那句「已签到的行显示『已签到 · HH:MM:SS 后刷新』」随之改正。
63
158
 
64
159
  ### Changed
65
160
 
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-BNuj6Lt_.js";
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-R1OtwCEG.js";
3
3
  import { rm } from "node:fs/promises";
4
4
  //#region src/bin.ts
5
5
  /**
package/lib/client.js CHANGED
@@ -182,6 +182,54 @@ window.__ModuleLoader__.load({
182
182
  global: "qoder.com"
183
183
  };
184
184
  /**
185
+ * Project one card row into its persisted `lastCatalog` entry.
186
+ *
187
+ * **写法照 `dsh-connect-workbuddy` 的 `toPersistedWorkBuddyModel`**:spread 整行
188
+ * 再按名剔除卡片独有字段,**不逐个字段手写白名单**。这是同一处缺陷的第二次
189
+ * 出诊 —— workbuddy 的注释写着「omitting it made one Save silently strip the
190
+ * declaration from every model」:白名单只要漏一个字段,那次保存就把该字段从
191
+ * 每个模型上抹掉,而且**没有任何东西会报错**。本仓库正是这样丢掉
192
+ * `thinkingEfforts` / `defaultThinkingEffort` 的(用户实拍「每次保存都会思考
193
+ * 强度不见了」):卡片手写映射漏了这两个键,而读取端 `modelFromStored` 的判据是
194
+ * `length > 0`、缺席的声明会被 schema 补成 `[]`,于是档位选择器整个消失。
195
+ * 改成白名单只是把「漏」换了个位置,所以**必须**是 spread。
196
+ *
197
+ * 三处字段名在落盘时与卡片行不同,必须重命名(读取端 `modelFromStored` 只认
198
+ * 落盘名):`name → displayName`、`maxTokens → maxOutputTokens`、
199
+ * `creditMultiplier → priceFactor`。**这三对名字必须与读取端逐字一致**,
200
+ * 否则解析出 `undefined` → 回落默认值(`modelFromStored` 对
201
+ * `maxOutputTokens` 默认 32 768,旧代码写的 `maxTokens` 因此从没被读到过)。
202
+ *
203
+ * 剔除的卡片独有键:
204
+ * - `nativeContextWindow` —— 它的值已经装进 `contextWindow`(落盘存**原生**
205
+ * 窗口,生效窗口由 `applyContextBudgets` 在读取时重新施加,见 issue 语境)。
206
+ * - `maxTokens` / `name` / `creditMultiplier` —— 已重命名,原名不能带上去。
207
+ *
208
+ * **绝不写成 `undefined` 值**:显式 `undefined` 会挺过 `structuredClone`,被设置
209
+ * 写入端那道严格 JSON codec 拒收(`client api: settings/mutate rejected "ops"`),
210
+ * 整次保存失败。这里用解构剔除(真的没有这个键),而不是赋 `undefined`。可选键
211
+ * 缺席时由 schema 的 `required(false)` 兜底,不需要在这里补默认值。
212
+ *
213
+ * `supportsImages` 是**覆盖**而非派生:卡片的待保存草稿持有用户尚未保存的勾选,
214
+ * 而 model 上带的是上次保存过的值;从 model 派生会把用户刚勾的那一下在本次保存
215
+ * 中抹掉。它是上游自己的声明(`is_vl`),随条目落盘、但运行时能力由独立的
216
+ * `imageModelKeys` 盖印决定(同 workbuddy 的 `supportsImages` / `multimodal` 之分)。
217
+ */
218
+ function toPersistedCatalogEntry(model, overrides = {}) {
219
+ const { name, maxTokens, creditMultiplier, nativeContextWindow, thinkingEfforts, defaultThinkingEffort, contextTiers, supportsImages, ...rest } = model;
220
+ return {
221
+ ...rest,
222
+ displayName: name,
223
+ maxOutputTokens: maxTokens,
224
+ priceFactor: typeof creditMultiplier === "number" ? creditMultiplier : 1,
225
+ contextWindow: nativeContextWindow,
226
+ supportsImages: overrides.supportsImages ?? supportsImages === true,
227
+ ...thinkingEfforts === void 0 || thinkingEfforts.length === 0 ? {} : { thinkingEfforts: [...thinkingEfforts] },
228
+ ...defaultThinkingEffort === void 0 || defaultThinkingEffort === "" ? {} : { defaultThinkingEffort },
229
+ ...contextTiers === void 0 || contextTiers.length === 0 ? {} : { contextTiers: [...contextTiers] }
230
+ };
231
+ }
232
+ /**
185
233
  * Deep copy of a settings value with every `{get(): T}` live reference
186
234
  * replaced by the value it resolves to.
187
235
  *
@@ -474,6 +522,48 @@ window.__ModuleLoader__.load({
474
522
  //#endregion
475
523
  //#region src/client/BusyOverlay.tsx
476
524
  /**
525
+ * The shared busy veil's spinner: an arc that breathes while it turns.
526
+ *
527
+ * Deliberately the host's own state-spinner shape (dsh's `_dsh-state-dot-spin`)
528
+ * rather than a border-ring: a two-tone CSS ring at .7s read as a loading
529
+ * indicator from another decade and was out of step with every spinner in the
530
+ * shell. The motion lives in CSS (`dsm-qoder-veil-spin` / `-dash`), so this
531
+ * element only describes the geometry; `currentColor` lets it inherit whatever
532
+ * tier it sits in.
533
+ *
534
+ * `aria-hidden` because the veil's `role="status"` already announces the
535
+ * message — a second announcement from a decorative arc is noise.
536
+ *
537
+ * 对齐:dingminhua/dsh-connect-workbuddy 的 VeilSpinner(MIT,LaoDing)。类名换
538
+ * `dsm-qoder-*`,几何与节奏(20px / 1.5s / dash 呼吸 / currentColor)逐条沿用。
539
+ */
540
+ function VeilSpinner() {
541
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
542
+ className: "dsm-qoder-veil-spinner",
543
+ viewBox: "0 0 20 20",
544
+ "aria-hidden": "true",
545
+ focusable: "false",
546
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
547
+ className: "dsm-qoder-veil-spinner-track",
548
+ cx: "10",
549
+ cy: "10",
550
+ r: "8",
551
+ fill: "none",
552
+ stroke: "currentColor",
553
+ strokeWidth: "2"
554
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
555
+ className: "dsm-qoder-veil-spinner-arc",
556
+ cx: "10",
557
+ cy: "10",
558
+ r: "8",
559
+ fill: "none",
560
+ stroke: "currentColor",
561
+ strokeWidth: "2",
562
+ strokeLinecap: "round"
563
+ })]
564
+ });
565
+ }
566
+ /**
477
567
  * The veil. Deliberately NOT a modal dialog: it carries no focusable control
478
568
  * and no dismissal — there is nothing for the user to decide, only a step to
479
569
  * wait through.
@@ -488,10 +578,7 @@ window.__ModuleLoader__.load({
488
578
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
489
579
  className: "dsm-qoder-veil-card",
490
580
  children: [
491
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
492
- className: "dsm-qoder-veil-spinner",
493
- "aria-hidden": "true"
494
- }),
581
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VeilSpinner, {}),
495
582
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
496
583
  className: "dsm-qoder-veil-text",
497
584
  children: message
@@ -1373,17 +1460,18 @@ window.__ModuleLoader__.load({
1373
1460
  className: "dsm-qoder-pool-unknown",
1374
1461
  title: t("row.poolNeverTested"),
1375
1462
  children: t("row.poolCheckinUnknown")
1376
- }) : account.checkedInToday ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1377
- className: "dsm-qoder-pool-checked",
1378
- children: t("row.poolCheckedIn")
1379
- }), checkinCountdown.state === "ticking" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1463
+ }) : account.checkedInToday ? checkinCountdown.state === "ticking" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1380
1464
  className: "dsm-qoder-pool-checkin-next",
1381
1465
  title: t("row.poolCheckinNextHint"),
1382
1466
  children: t("row.poolCheckinNext", { countdown: checkinCountdown.text })
1383
1467
  }) : checkinCountdown.state === "due" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1384
1468
  className: "dsm-qoder-pool-checkin-due",
1469
+ title: t("row.poolCheckinDueHint"),
1385
1470
  children: t("row.poolCheckinDue")
1386
- }) : null] }) : t("row.poolNotCheckedIn")
1471
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1472
+ className: "dsm-qoder-pool-checked",
1473
+ children: t("row.poolCheckedIn")
1474
+ }) : t("row.poolNotCheckedIn")
1387
1475
  }) : null,
1388
1476
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1389
1477
  className: "dsm-qoder-pool-row-actions",
@@ -2534,11 +2622,12 @@ window.__ModuleLoader__.load({
2534
2622
  .dsm-qoder-pool-check input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
2535
2623
  .dsm-qoder-pool-check input:disabled{cursor:default;opacity:.5}
2536
2624
  .dsm-qoder-pool-checked{color:var(--dsw-alias-state-success-primary,#22a06b)}
2537
- /* 「已签到 · 09:22:23 后刷新」:倒计时是上游给的刷新时刻,逐秒变化,所以给它
2538
- 等宽数字(tabular-nums)—— 否则位数一变整格宽度就抖,表格列跟着左右晃。
2539
- 颜色刻意比「已签到」弱一档:已签到是状态,倒计时是附注,不该抢读。 */
2540
- .dsm-qoder-pool-checkin-next{margin-left:6px;font-variant-numeric:tabular-nums;font-feature-settings:"tnum";color:var(--dsw-alias-label-tertiary,#999)}
2541
- .dsm-qoder-pool-checkin-due{margin-left:6px;color:var(--dsw-alias-state-success-primary,#22a06b)}
2625
+ /* 倒计时那一格现在是**唯一**的一句话(不再与「已签到」并排),所以没有 margin-left。
2626
+ 数字逐秒变化,给它等宽数字(tabular-nums)—— 否则位数一变整格宽度就抖,表格列
2627
+ 跟着左右晃。颜色沿用单元格本身的 label-tertiary:它就是这一格的主句。 */
2628
+ .dsm-qoder-pool-checkin-next{font-variant-numeric:tabular-nums;font-feature-settings:"tnum"}
2629
+ /* 到点之后同样是主句、不缩进;绿色表示「此刻有事可做」,与「已签到」同色。 */
2630
+ .dsm-qoder-pool-checkin-due{color:var(--dsw-alias-state-success-primary,#22a06b)}
2542
2631
  .dsm-qoder-pool-unknown{color:var(--dsw-alias-label-dimmed,#9aa0a6)}
2543
2632
  .dsm-qoder-pool-member{color:var(--dsw-alias-brand-primary,#5686fe)}
2544
2633
  .dsm-qoder-pool-not-member{color:var(--dsw-alias-label-dimmed,#9aa0a6)}
@@ -2646,14 +2735,53 @@ window.__ModuleLoader__.load({
2646
2735
  .dsm-qoder-addpat-doc a{color:var(--dsw-alias-brand-primary,#5686fe);font-size:12px;line-height:18px;text-decoration:none}
2647
2736
  .dsm-qoder-addpat-doc a:hover{text-decoration:underline}
2648
2737
  /* 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
- .dsm-qoder-veil{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.38);cursor:progress}
2651
- .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,#3a3d45);border-radius:12px;background:var(--dsw-alias-bg-primary,#1b1c20);box-shadow:0 8px 28px rgba(0,0,0,.35)}
2652
- .dsm-qoder-veil-text{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#e8e9ed);text-align:center}
2653
- .dsm-qoder-veil-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#999);text-align:center}
2654
- .dsm-qoder-veil-spinner{width:18px;height:18px;border:2px solid var(--dsw-alias-border-l2,#3a3d45);border-top-color:var(--dsw-alias-brand-primary,#5686fe);border-radius:50%;animation:dsm-qoder-veil-spin .7s linear infinite}
2738
+ up after the list re-read (remove / import / add). It is NOT a modal: no
2739
+ control, no dismissal — only "this is running" and "here is why nothing
2740
+ moved yet". Fixed positioning so it covers the card regardless of which
2741
+ section started the operation, and a semi-transparent backdrop so the table
2742
+ stays visible underneath — the user is watching a specific row change, and
2743
+ hiding it would defeat the point.
2744
+
2745
+ The mask and the card surface are the HOST's own tokens, not this file's
2746
+ invention. --dsw-alias-bg-mask-1 + --dsw-mask-blur is exactly what the
2747
+ host's own overlays use (see dsh-client-ui-settings-general's Dws9Sa_mask),
2748
+ so the veil now dims the way every other overlay in the shell does instead
2749
+ of laying its own black wash on top. The previous hardcoded rgba(0,0,0,.38)
2750
+ was tuned against neither theme: too weak next to the host's .24 mask in
2751
+ light mode, and it ignored the host's blur entirely.
2752
+
2753
+ bg-layer-2 (not bg-primary, which does not exist in the host's token set
2754
+ at all and therefore always fell through to this file's near-black #1b1c20)
2755
+ is the same surface the host puts behind its panels — that near-black fallback
2756
+ is what made the veil read as a black slab with unreadable text on it. */
2757
+ .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}
2758
+ /* --dsw-elevation-prominent is the host's own panel shadow (a hairline stroke
2759
+ plus two soft layers); the previous 0 8px 28px rgba(0,0,0,.35) was a
2760
+ hand-rolled drop shadow that read as a heavy grey box. */
2761
+ .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))}
2762
+ .dsm-qoder-veil-text{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#0f1115);text-align:center}
2763
+ /* label-secondary, not label-tertiary: this line carries the operation's
2764
+ detail and must stay legible. tertiary is the host's dimmest tier
2765
+ (neutral-bluish-600 on light, neutral-bluish-400 on dark) and is meant
2766
+ for metadata, not for a sentence the user has to read while waiting. */
2767
+ .dsm-qoder-veil-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#61666b);text-align:center}
2768
+ /* The spinner proves the UI is alive when the row under it has not changed yet
2769
+ — which is precisely the confusion the veil exists to remove. It follows the
2770
+ host's own state spinner (dsh's _dsh-state-dot-spin): an SVG arc whose
2771
+ dash breathes while it turns, currentColor so it inherits the tier it sits
2772
+ in, and the same 1.5s beat. The previous 18px border-ring at .7s was roughly
2773
+ twice the host's speed and visibly out of step with every other spinner in
2774
+ the shell — that mismatch, more than the shape itself, is what made it look
2775
+ cheap.
2776
+
2777
+ Reduced-motion users get the static arc rather than a frozen ring, matching
2778
+ how the host degrades its own spinner. */
2779
+ .dsm-qoder-veil-spinner{width:20px;height:20px;color:var(--dsw-alias-label-secondary,#61666b);animation:dsm-qoder-veil-spin 1.5s linear infinite}
2780
+ .dsm-qoder-veil-spinner-track{opacity:.25}
2781
+ .dsm-qoder-veil-spinner-arc{stroke-dasharray:12 150;animation:dsm-qoder-veil-dash 1.5s ease-in-out infinite}
2655
2782
  @keyframes dsm-qoder-veil-spin{to{transform:rotate(360deg)}}
2656
- @media (prefers-reduced-motion: reduce){.dsm-qoder-veil-spinner{animation:none}}
2783
+ @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}}
2784
+ @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
2785
  `;
2658
2786
  /**
2659
2787
  * Composer credit readout styles, injected by the readout component itself.
@@ -3671,21 +3799,7 @@ window.__ModuleLoader__.load({
3671
3799
  /** Build the `lastCatalog` array + enabled/off/image selections for the save. */
3672
3800
  function buildModelsPayload(draft, models) {
3673
3801
  return {
3674
- lastCatalog: models.map((model) => ({
3675
- key: model.key,
3676
- displayName: model.name,
3677
- priceFactor: typeof model.creditMultiplier === "number" ? model.creditMultiplier : 1,
3678
- contextWindow: model.contextWindow,
3679
- nativeContextWindow: model.nativeContextWindow,
3680
- maxTokens: model.maxTokens,
3681
- supportsImages: draft.supportsImages.has(model.key),
3682
- supportsThinking: model.supportsThinking === true,
3683
- canDisableThinking: model.canDisableThinking === true,
3684
- isDefault: model.isDefault === true,
3685
- isFree: model.isFree === true,
3686
- isNew: model.isNew === true,
3687
- contextTiers: model.contextTiers
3688
- })),
3802
+ lastCatalog: models.map((model) => toPersistedCatalogEntry(model, { supportsImages: draft.supportsImages.has(model.key) })),
3689
3803
  enabledModelKeys: [...draft.enabledModelKeys],
3690
3804
  imageModelKeys: [...draft.supportsImages],
3691
3805
  offModelKeys: [...draft.offModelKeys],
@@ -4317,8 +4431,8 @@ window.__ModuleLoader__.load({
4317
4431
  "row.probeOkMs": "✓ 可用({ms})",
4318
4432
  "row.probeRateLimitedUnknown": "✗ 被限流 · 上游未给出何时恢复",
4319
4433
  "row.probeRateLimitedAt": "✗ 被限流 · {at} 之后可再用",
4320
- "row.probeOutOfCreditUnknown": "✗ 积分不足 · 上游未给出何时恢复",
4321
- "row.probeOutOfCreditAt": "✗ 积分不足 · 积分包 {at} 刷新",
4434
+ "row.probeOutOfCreditUnknown": "✗ 用量已达上限 · 上游未给出何时恢复",
4435
+ "row.probeOutOfCreditAt": "✗ 用量已达上限 · {at} 恢复",
4322
4436
  "row.probeCredentialRejected": "✗ Qoder 拒绝了这个账号的 PAT——请添加新 PAT 或换一个账号",
4323
4437
  "row.probePolicyRejected": "✗ Qoder 服务端按内容策略拒绝了这次请求——重新添加 PAT 无效",
4324
4438
  "row.probeNotFound": "✗ 上游不认识这个模型",
@@ -4330,7 +4444,7 @@ window.__ModuleLoader__.load({
4330
4444
  "row.docsHint": "如何创建 PAT?",
4331
4445
  "row.poolTitle": "账号池",
4332
4446
  "row.poolNoCandidate": "账号池里目前没有可用的账号。",
4333
- "row.poolNoCandidateHint": "成员要么在限流冷却中、要么积分耗尽、要么凭据被拒、要么上游无响应。限流、积分耗尽与瞬时故障都会自动回池——按上游给出的恢复时间,没给时间就按 30 分钟兜底,不需要你操作;只有「凭据被拒」需要换一个新 PAT。",
4447
+ "row.poolNoCandidateHint": "成员要么在限流冷却中、要么用量已达上限、要么凭据被拒、要么上游无响应。限流、用量上限与瞬时故障都会自动回池——按上游给出的恢复时间,没给时间就按 30 分钟兜底,不需要你操作;只有「凭据被拒」需要换一个新 PAT。",
4334
4448
  "row.poolEmptyHint": "这个区域还没有账号。用上面的「添加 PAT」粘贴一个,或用「导出 / 导入账号」把别的机器上的账号搬过来 —— 账号池、模型测试与每日签到都需要至少一个账号。",
4335
4449
  "row.poolTestAll": "一键测试所有账号",
4336
4450
  "row.poolCheckinAll": "一键签到所有账号",
@@ -4362,15 +4476,16 @@ window.__ModuleLoader__.load({
4362
4476
  "row.poolCheckedIn": "已签到",
4363
4477
  "row.poolNotCheckedIn": "未签到",
4364
4478
  "row.poolCheckinUnknown": "—",
4365
- "row.poolCheckinNext": "· {countdown} 后刷新",
4366
- "row.poolCheckinNextHint": "这一窗已经领过了。下一窗在上游给出的时刻开放 —— 与 Qoder 网页上的倒计时是同一个数。",
4367
- "row.poolCheckinDue": "· 现在可以领取",
4479
+ "row.poolCheckinNext": "{countdown} 后可再签到",
4480
+ "row.poolCheckinNextHint": "这一窗已经签到过了。这个数字是下一窗什么时候开(到点就能再签),与 Qoder 网页上的倒计时是同一个数 —— 不是「这一行多久刷新」。",
4481
+ "row.poolCheckinDue": "现在可以签到",
4482
+ "row.poolCheckinDueHint": "下一窗已经开了,这个账号现在可以再签。「已签到」说的是上一窗,所以这里不再重复它。",
4368
4483
  "row.poolNeverTested": "尚未测试",
4369
4484
  "row.poolTestedAt": "测试于 {at}",
4370
4485
  "row.poolCreditSoon": "{count} 积分 3 天内过期",
4371
4486
  "row.poolCreditNearest": "最近积分包 {at} 过期",
4372
4487
  "row.poolExcludedRateLimited": "被限流",
4373
- "row.poolExcludedOutOfCredit": "积分耗尽",
4488
+ "row.poolExcludedOutOfCredit": "用量已达上限",
4374
4489
  "row.poolExcludedRejected": "被拒绝",
4375
4490
  "row.poolExcludedUnusable": "不可用",
4376
4491
  "row.poolRetryAt": "{at} 之后可再用",
@@ -4583,8 +4698,8 @@ window.__ModuleLoader__.load({
4583
4698
  "row.probeOkMs": "✓ Usable ({ms})",
4584
4699
  "row.probeRateLimitedUnknown": "✗ Rate limited · the upstream gave no time for when it frees up",
4585
4700
  "row.probeRateLimitedAt": "✗ Rate limited · usable again after {at}",
4586
- "row.probeOutOfCreditUnknown": "✗ Not enough credits · the upstream gave no time for when it frees up",
4587
- "row.probeOutOfCreditAt": "✗ Not enough credits · quota refreshes {at}",
4701
+ "row.probeOutOfCreditUnknown": "✗ Usage limit reached · the upstream gave no time for when it frees up",
4702
+ "row.probeOutOfCreditAt": "✗ Usage limit reached · recovers {at}",
4588
4703
  "row.probeCredentialRejected": "✗ Qoder refused this account's PAT — add a new one or pick another account",
4589
4704
  "row.probePolicyRejected": "✗ Qoder's server refused this request under its content policy — re-adding the PAT will not help",
4590
4705
  "row.probeNotFound": "✗ The upstream does not know this model",
@@ -4596,7 +4711,7 @@ window.__ModuleLoader__.load({
4596
4711
  "row.docsHint": "How to create a PAT?",
4597
4712
  "row.poolTitle": "Account pool",
4598
4713
  "row.poolNoCandidate": "No account in this pool can be used right now.",
4599
- "row.poolNoCandidateHint": "Members are rate-limited, out of credits, rejected, or unreachable. Rate limits, drained quotas 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.",
4714
+ "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
4715
  "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
4716
  "row.poolTestAll": "Test all accounts",
4602
4717
  "row.poolCheckinAll": "Claim daily credits",
@@ -4628,15 +4743,16 @@ window.__ModuleLoader__.load({
4628
4743
  "row.poolCheckedIn": "Checked in",
4629
4744
  "row.poolNotCheckedIn": "Not checked in",
4630
4745
  "row.poolCheckinUnknown": "—",
4631
- "row.poolCheckinNext": "· next in {countdown}",
4632
- "row.poolCheckinNextHint": "This daily window has been claimed. The next one opens at the time the upstream stated (the same countdown the Qoder web page shows).",
4633
- "row.poolCheckinDue": "· claimable now",
4746
+ "row.poolCheckinNext": "Claimable again in {countdown}",
4747
+ "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.",
4748
+ "row.poolCheckinDue": "Claimable now",
4749
+ "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
4750
  "row.poolNeverTested": "Not tested yet",
4635
4751
  "row.poolTestedAt": "Tested {at}",
4636
4752
  "row.poolCreditSoon": "{count} expiring within 3 days",
4637
4753
  "row.poolCreditNearest": "Nearest package expires {at}",
4638
4754
  "row.poolExcludedRateLimited": "Rate limited",
4639
- "row.poolExcludedOutOfCredit": "Out of credits",
4755
+ "row.poolExcludedOutOfCredit": "Usage limit reached",
4640
4756
  "row.poolExcludedRejected": "Rejected",
4641
4757
  "row.poolExcludedUnusable": "Unusable",
4642
4758
  "row.poolRetryAt": "Back after {at}",
@@ -693,7 +693,7 @@ var QoderUpstreamClient = class {
693
693
  body
694
694
  });
695
695
  const text = await resp.text();
696
- if (resp.status !== 200) throw new QoderUpstreamError(classifyHttpStatus(resp.status, text), `jobToken 交换失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status);
696
+ if (resp.status !== 200) throw new QoderUpstreamError(classifyFailure(resp.status, text), `jobToken 交换失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status, businessCodeOf(text));
697
697
  let data;
698
698
  try {
699
699
  data = JSON.parse(text);
@@ -884,7 +884,7 @@ var QoderUpstreamClient = class {
884
884
  });
885
885
  if (resp.status !== 200) {
886
886
  const errText = await resp.text();
887
- throw new QoderUpstreamError(classifyHttpStatus(resp.status, errText), `聊天请求失败: HTTP ${resp.status} ${truncate(errText, 300)}`, resp.status);
887
+ throw new QoderUpstreamError(classifyFailure(resp.status, errText), `聊天请求失败: HTTP ${resp.status} ${truncate(errText, 300)}`, resp.status, businessCodeOf(errText));
888
888
  }
889
889
  if (!resp.body) throw new QoderUpstreamError("transient", "聊天响应没有 body");
890
890
  const reader = resp.body.getReader();
@@ -962,7 +962,7 @@ var QoderUpstreamClient = class {
962
962
  ...signal === void 0 ? {} : { signal }
963
963
  });
964
964
  const text = await resp.text();
965
- if (resp.status !== 200) throw new QoderUpstreamError(classifyHttpStatus(resp.status, text), `请求失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status);
965
+ if (resp.status !== 200) throw new QoderUpstreamError(classifyFailure(resp.status, text), `请求失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status, businessCodeOf(text));
966
966
  try {
967
967
  return JSON.parse(text);
968
968
  } catch {
@@ -1078,7 +1078,7 @@ var QoderUpstreamClient = class {
1078
1078
  ...signal === void 0 ? {} : { signal }
1079
1079
  });
1080
1080
  const text = await resp.text();
1081
- if (resp.status !== 200 && resp.status !== 201) throw new QoderUpstreamError(classifyHttpStatus(resp.status, text), `请求失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status);
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));
1082
1082
  try {
1083
1083
  return JSON.parse(text);
1084
1084
  } catch {
@@ -1145,7 +1145,7 @@ var QoderUpstreamClient = class {
1145
1145
  ...signal === void 0 ? {} : { signal }
1146
1146
  });
1147
1147
  const text = await resp.text();
1148
- if (resp.status !== 200) throw new QoderUpstreamError(classifyHttpStatus(resp.status, text), `请求失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status);
1148
+ if (resp.status !== 200) throw new QoderUpstreamError(classifyFailure(resp.status, text), `请求失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status, businessCodeOf(text));
1149
1149
  try {
1150
1150
  return JSON.parse(text);
1151
1151
  } catch {
@@ -1188,7 +1188,7 @@ function parseStreamDelta(dataPayload) {
1188
1188
  const scv = wrapper.statusCodeValue;
1189
1189
  if (scv !== void 0 && scv !== null && Number(scv) !== 200) {
1190
1190
  const detail = typeof wrapper.body === "string" ? wrapper.body : dataPayload.slice(0, 400);
1191
- throw new QoderUpstreamError(classifyHttpStatus(Number(scv), detail), `上游信封错误: HTTP ${scv} ${truncate(detail, 300)}`, Number(scv));
1191
+ throw new QoderUpstreamError(classifyFailure(Number(scv), detail), `上游信封错误: HTTP ${scv} ${truncate(detail, 300)}`, Number(scv), businessCodeOf(detail));
1192
1192
  }
1193
1193
  const innerStr = wrapper.body;
1194
1194
  if (typeof innerStr !== "string" || innerStr === "") return void 0;
@@ -1280,12 +1280,93 @@ function classifyHttpStatus(status, detail) {
1280
1280
  if (status === 418 || status >= 500) return "transient";
1281
1281
  return "unknown";
1282
1282
  }
1283
- /** 业务 code → 错误分类。 */
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})。 */
1284
1337
  function classifyBusinessCode(code) {
1285
1338
  if (code === "105") return "auth";
1286
- if (code === "115" || code === "402" || code === "6004") return "quota-exhausted";
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";
1287
1343
  return "unknown";
1288
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
+ ]);
1289
1370
  const CONTENT_POLICY_MARKERS = [
1290
1371
  "DataInspectionFailed",
1291
1372
  "inappropriate content",
@@ -1728,7 +1809,7 @@ var QoderAccountStore = class {
1728
1809
  //#endregion
1729
1810
  //#region src/version.ts
1730
1811
  /** The npm package version this build was produced from. */
1731
- const QODER_CONNECT_VERSION = "0.4.0";
1812
+ const QODER_CONNECT_VERSION = "0.4.3";
1732
1813
  //#endregion
1733
1814
  //#region src/account-pool-store.ts
1734
1815
  /**
package/lib/index.d.ts CHANGED
@@ -2,6 +2,8 @@ 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';
5
7
  //#endregion
6
8
  //#region src/catalog.d.ts
7
9
  interface QoderModelInfo {
@@ -47,6 +49,14 @@ interface QoderModelInfo {
47
49
  * costs nothing on the Host side either.
48
50
  */
49
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';
50
60
  //#endregion
51
61
  //#region src/version.d.ts
52
62
  /**
@@ -60,6 +70,46 @@ declare const QODER_GITHUB_URL = "https://github.com/dingminhua/dsh-connect-qode
60
70
  /** The npm package version this build was produced from. */
61
71
  declare const QODER_CONNECT_VERSION: string;
62
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
63
113
  //#region src/index.d.ts
64
114
  declare const name = "dsh-connect-qoder";
65
115
  /** The model registry and settings service required before providers register. */
@@ -122,5 +172,21 @@ declare function regionHasCredential(config: {
122
172
  regions?: Record<string, unknown> | null;
123
173
  }, region: QoderRegion): boolean;
124
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;
125
191
  //#endregion
126
- export { Config, QODER_CONNECT_VERSION, QODER_GITHUB_URL, apply, asVolatile, catalogSnapshotDeclaresEfforts, inject, name, regionEnabled, regionHasCredential, regionStateOf, serveSavedCatalog, 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-BNuj6Lt_.js";
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-R1OtwCEG.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";
@@ -445,6 +445,79 @@ const KIND_HOST_CODE = { "rate-limited": "quota_exceeded" };
445
445
  function hostErrorCode(kind) {
446
446
  return KIND_HOST_CODE[kind] ?? ERROR_MAP[kind].code;
447
447
  }
448
+ /**
449
+ * 净化写给宿主的失败文案里**裸的 401/403 数字**。
450
+ *
451
+ * 这是国际版「API 密钥无效」假警报的根因(用户实拍:对话报密钥无效、同一条
452
+ * 账号在卡片里却只是「用量已达上限」)。
453
+ *
454
+ * 宿主对 pi-ai 失败**不是**按 HTTP 状态分类,而是**对文案跑正则**
455
+ * (app.asar 内 `classifyPiAiError`):
456
+ *
457
+ * ```js
458
+ * if (/\b(?:401|403)\b/.test(message)) return "AUTH"; // ← 第一道,命中即返回
459
+ * if (isQuotaExceededError(message)) return QUOTA_EXCEEDED;
460
+ * ```
461
+ *
462
+ * 而本插件为了溯源,一直把上游原文(含 `HTTP 403 {"code":"110",…}`)拼进这条
463
+ * 文案。于是**配额耗尽被第一道正则截走判成 AUTH**,宿主用中立文案
464
+ * `message.failure.auth`(「API 密钥无效」)覆盖掉提供方消息 —— 一个明天就
465
+ * 自动恢复的账号被读成凭据坏了。实测 `isQuotaExceededError` 对同一串文本是
466
+ * `true`:分类结果是**顺序**造成的,不是判据不认。
467
+ *
468
+ * 只替换**裸数字**,且只在非 auth 类上做:
469
+ * - `auth` 必须原文照送。上游真的吐 `401 TOKEN_EXPIRE` 时,AUTH 正是正确答案;
470
+ * 而「未配置 PAT」那条中文没有状态码,宿主会显示原文(比换成「API 密钥无效」
471
+ * 更有用),所以这一档**不做**任何装饰。
472
+ * - `429`/`5xx`/`400` 不受影响:`429 → RATE_LIMIT`、`5xx → SERVER`(原地重试)、
473
+ * `400 → INVALID_REQUEST` 都是期望的分类。**状态码数字必须整词替换**,
474
+ * 否则 `1401`、`4030` 这类业务数字会被改坏(这是溯源信息)。
475
+ *
476
+ * 边界判据是**两条否定环视**(对抗审核 S1 发现 `\b` 不够):
477
+ * - `(?<![\w.])` 左侧:数字/字母/下划线/句点之前不算独立状态码,于是 `1401`、
478
+ * `v403`、`a403b`、`code_403` 全部保留;`code=403`、`HTTP 403 {` 仍然命中。
479
+ * - `(?!\d)(?!\.\d)` 右侧:后面跟数字或「小数点+数字」时不算,于是 `4030x` 保留、
480
+ * **`401.5` 这种小数不再被改写成非法的 `4xx.5`**。句末句号(`HTTP 403.`)不受
481
+ * 影响,因为 `(?!\.\d)` 要求点后紧跟数字。
482
+ *
483
+ * 换成 `4xx` 而不是删掉数字:状态码是给人读的溯源信息,抹掉会让「为什么失败」
484
+ * 变得不可查 —— 这是本项目反复吃过亏的地方。
485
+ */
486
+ function hostMessageText(kind, message) {
487
+ if (kind === "auth") return message;
488
+ return message.replace(SANITIZE_BARE_STATUS, "4xx");
489
+ }
490
+ /**
491
+ * 「裸的 401/403」的判据,只有一处定义 —— 前缀、正文、后缀都由它统一净化。
492
+ *
493
+ * 抽成常量而不是在三处各写一遍:净化一旦漏掉某一段拼接文本,那个数字就会绕过
494
+ * 它自己被送进宿主的正则分类器(前缀漏净化的那次回归就是这么来的)。
495
+ */
496
+ const SANITIZE_BARE_STATUS = /(?<![\w.])(?:401|403)(?!\d)(?!\.\d)/gu;
497
+ /**
498
+ * 净化任意写给宿主的片段(前缀、正文、后缀都走它)。
499
+ *
500
+ * `auth` 档原文照送,所以调用方判断 kind 之后再决定要不要调它。
501
+ */
502
+ function sanitizeForHost(text) {
503
+ return text.replace(SANITIZE_BARE_STATUS, "4xx");
504
+ }
505
+ /**
506
+ * 写给宿主的**完整**失败文案:带 kind 名与状态码前缀,再统一净化。
507
+ *
508
+ * 为什么要带前缀:宿主只按正则猜分类,猜不中就落到 `TRANSPORT`(不可原地重试
509
+ * 的兜底)。留一串没有任何线索的中文(例如只净化的 `上游信封错误: HTTP 4xx
510
+ * {"code":"115"}`)会被猜成 `TRANSPORT`;而带上 `quota-exhausted` 之后,
511
+ * `isQuotaExceededError` 的 `quota ... exceeded` 判据立刻命中。
512
+ *
513
+ * **状态码由本函数负责加、也由本函数负责净化,`note` 后缀同样一起净化**
514
+ * (对抗审核 S3:原先 suffix 拼在净化之后,将来它若带上游信息就会绕过净化)。
515
+ * 前置判断放在 `auth` 那一行:那一档必须原文送达,一个字符都不能改。
516
+ */
517
+ function hostFailureMessage(kind, status, message, suffix = "") {
518
+ if (kind === "auth") return message;
519
+ return sanitizeForHost(`qoder upstream ${kind} (http ${status}): ${hostMessageText(kind, message)}${suffix}`);
520
+ }
448
521
  /** HTTP 状态 + error.code 双映射表(DSH 依据 code 判定原地重试 vs failover)。 */
449
522
  const ERROR_MAP = {
450
523
  network: {
@@ -513,10 +586,12 @@ function createQoderShim(options) {
513
586
  const ready = new Promise((resolveReady, rejectReady) => {
514
587
  const server = createServer((req, res) => {
515
588
  handleRequest(req, res).catch((error) => {
516
- const status = isQoderUpstreamError(error) ? ERROR_MAP[error.kind].status : 500;
517
- const code = isQoderUpstreamError(error) ? hostErrorCode(error.kind) : "internal_error";
589
+ const upstream = isQoderUpstreamError(error);
590
+ const status = upstream ? ERROR_MAP[error.kind].status : 500;
591
+ const code = upstream ? hostErrorCode(error.kind) : "internal_error";
592
+ const raw = String(error.message ?? "internal error");
518
593
  writeJson(res, status, { error: {
519
- message: String(error.message ?? "internal error"),
594
+ message: upstream ? hostFailureMessage(error.kind, error.status, raw) : raw,
520
595
  code,
521
596
  type: "qoder_error"
522
597
  } });
@@ -656,7 +731,7 @@ function createQoderShim(options) {
656
731
  const savedEffort = options.reasoningEffortOf ? options.reasoningEffortOf(model) : void 0;
657
732
  if (messages.some((m) => Array.isArray(m.content) && m.content.some((p) => p?.type === "image_url")) && !supportsImages) {
658
733
  writeJson(res, 400, { error: {
659
- message: `模型 ${model} 不支持图像输入`,
734
+ message: hostMessageText("invalid-request", `模型 ${model} 不支持图像输入`),
660
735
  code: "invalid_request_error"
661
736
  } });
662
737
  return;
@@ -684,12 +759,14 @@ function createQoderShim(options) {
684
759
  pat: resolveFallbackCredential()
685
760
  };
686
761
  } catch (error) {
687
- const mapped = isQoderUpstreamError(error) ? ERROR_MAP[error.kind] : {
762
+ const upstream = isQoderUpstreamError(error);
763
+ const mapped = upstream ? ERROR_MAP[error.kind] : {
688
764
  status: 500,
689
765
  code: "upstream_error"
690
766
  };
767
+ const raw = String(error.message ?? "internal error");
691
768
  writeJson(res, mapped.status, { error: {
692
- message: error.message,
769
+ message: upstream ? hostFailureMessage(error.kind, error.status, raw) : raw,
693
770
  code: mapped.code,
694
771
  type: "qoder_error"
695
772
  } });
@@ -770,7 +847,7 @@ function createQoderShim(options) {
770
847
  const note = tried.length > 1 ? ` (after trying ${tried.length} accounts)` : "";
771
848
  const mapped = ERROR_MAP[error.kind];
772
849
  writeJson(res, mapped.status, { error: {
773
- message: `qoder upstream ${error.kind} (http ${error.status})${note}: ${error.message.slice(0, 400)}`,
850
+ message: hostFailureMessage(error.kind, error.status, error.message.slice(0, 400), note),
774
851
  code: hostErrorCode(error.kind),
775
852
  type: "qoder_error"
776
853
  } });
@@ -806,9 +883,11 @@ function createQoderShim(options) {
806
883
  if (res.destroyed) return;
807
884
  const errChunk = makeChunk(id, created, model);
808
885
  errChunk.choices[0].finish_reason = "error";
809
- const code = isQoderUpstreamError(error) ? ERROR_MAP[error.kind].code : "upstream_error";
886
+ const upstream = isQoderUpstreamError(error);
887
+ const code = upstream ? ERROR_MAP[error.kind].code : "upstream_error";
888
+ const raw = String(error.message ?? "internal error");
810
889
  errChunk.error = {
811
- message: String(error.message ?? "internal error"),
890
+ message: upstream ? hostFailureMessage(error.kind, error.status, raw) : raw,
812
891
  code,
813
892
  type: "qoder_error"
814
893
  };
@@ -2019,10 +2098,16 @@ const modelConfig = z.object({
2019
2098
  /**
2020
2099
  * 该模型声明的思考强度档位(`['xhigh','low','medium']` 之类,已按强度升序)。
2021
2100
  *
2022
- * 必须在这里声明,否则 zod 解析保存的 `lastCatalog` 时会**静默剥掉**它 ——
2023
- * 客户端明明写进了 payload,宿主读回来却没有,思考强度选择器于是整个不渲染
2024
- * (用户实拍「拉取的思考模式又不见了」)。`contextTiers` 同理:它在客户端被
2025
- * 写入,但同样没被声明,于是上下文预算选择器也会随之失效。
2101
+ * 必须在这里声明成 `required(false)`,否则思考强度选择器整个不渲染。但**机制
2102
+ * 不是「被 zod 剥掉」**(本注释原先这么写,是错的,已由实验更正):实测
2103
+ * schemastery **不剥未声明的键**(`nativeContextWindow` / `maxTokens` 那些都会
2104
+ * 原样透传),真正会发生的是**缺席的数组被补成 `[]`**。
2105
+ *
2106
+ * 于是缺陷链条是:客户端从不写这个字段(0.4.2 已修)→ 落盘缺席 → 解析补 `[]`
2107
+ * → 读取端 `modelFromStored` 的判据是 `length > 0` → 档位选择器消失
2108
+ * (用户实拍「拉取的思考模式又不见了」「每次保存都会思考强度不见了」)。
2109
+ * 也就是说:**声明仍然必需,必需的理由是那三个字段的判据(`length > 0` / 非空串)
2110
+ * 与「缺席补 []」之间的落差,而不是剥键。** `contextTiers` 同理。
2026
2111
  */
2027
2112
  thinkingEfforts: z.array(z.string()).required(false),
2028
2113
  defaultThinkingEffort: z.string().required(false),
@@ -2418,16 +2503,12 @@ function apply(ctx, config) {
2418
2503
  };
2419
2504
  },
2420
2505
  failoverAccount: async (tried) => nextFailoverCredential(region, tried),
2421
- onServingFailure: (accountId, kind, _message) => {
2422
- const outcome = FAIL_OVER_OUTCOME[kind];
2423
- if (outcome !== void 0 && accountId !== "") {
2424
- poolRuntime[accountId] = {
2425
- outcome,
2426
- atMs: Date.now(),
2427
- source: "live-request"
2428
- };
2429
- writePoolProbes(region, { [accountId]: poolRuntime[accountId] });
2430
- log(`pool ${region}: ${maskPat(accountId)} marked ${outcome} by live request`);
2506
+ onServingFailure: (accountId, kind, message) => {
2507
+ const probe = liveRequestProbe(kind, message);
2508
+ if (probe !== void 0 && accountId !== "") {
2509
+ poolRuntime[accountId] = probe;
2510
+ writePoolProbes(region, { [accountId]: probe });
2511
+ log(`pool ${region}: ${maskPat(accountId)} marked ${probe.outcome} by live request`);
2431
2512
  }
2432
2513
  }
2433
2514
  }),
@@ -2859,15 +2940,15 @@ function apply(ctx, config) {
2859
2940
  /** Build the transfer deps once (region-dispatched). */
2860
2941
  const transferDeps = {
2861
2942
  exportAccounts: async (region) => {
2862
- const { transferRecordsFor } = await import("./transfer-CUNaVvmi.js");
2943
+ const { transferRecordsFor } = await import("./transfer-B9YbYpwQ.js");
2863
2944
  return { accounts: transferRecordsFor(region, await stacks[region].store.vaultAccounts()) };
2864
2945
  },
2865
2946
  previewImport: async (_region, text) => {
2866
- const { previewTransferFile } = await import("./transfer-CUNaVvmi.js");
2947
+ const { previewTransferFile } = await import("./transfer-B9YbYpwQ.js");
2867
2948
  return previewTransferFile(text);
2868
2949
  },
2869
2950
  importAccounts: async (region, text, indexes) => {
2870
- const { mergeTransferRecords } = await import("./transfer-CUNaVvmi.js");
2951
+ const { mergeTransferRecords } = await import("./transfer-B9YbYpwQ.js");
2871
2952
  const { changes, imported, skipped } = mergeTransferRecords(region, text, indexes);
2872
2953
  for (const entry of changes.values()) await stacks[region].store.add(entry.pat, entry.accountName);
2873
2954
  return {
@@ -3089,5 +3170,30 @@ const FAIL_OVER_OUTCOME = {
3089
3170
  network: "unavailable",
3090
3171
  transient: "unavailable"
3091
3172
  };
3173
+ /**
3174
+ * 一次**真实请求**失败要写进账号池的那条测量;kind 不可映射时返回 undefined
3175
+ * (不写:内容策略拒绝是「这一次输入」的事实,不是账号的事实,见
3176
+ * `exclusionOf` 的 policy-rejected 分支)。
3177
+ *
3178
+ * 抽成模块级纯函数并导出,是因为它此前是 `onServingFailure` 里的一段内联字面量,
3179
+ * 而那段代码有一个**已经发生过的**缺陷:上游原文被丢掉(参数写成 `_message`),
3180
+ * 于是池里只有一句结论、没有依据 —— 用户实拍那条 `credential-rejected` 记录连
3181
+ * `message` 都没有,卡片只能空口指控「PAT 被拒」,而真相(`code 110` 当日用量
3182
+ * 用尽)就在被丢掉的那串字符里。探针路径早就带原文(`redactUpstreamText`),
3183
+ * 两条路径写同一个文件却只有一个带。
3184
+ *
3185
+ * 按本项目的既有教训(「判据与动作打包,于是动作有没有真的发生也能被断言」),
3186
+ * 把它做成可测函数而不是留在接线里:接线变异掉,测试照样全绿过一次了。
3187
+ */
3188
+ function liveRequestProbe(kind, message) {
3189
+ const outcome = FAIL_OVER_OUTCOME[kind];
3190
+ if (outcome === void 0) return void 0;
3191
+ return {
3192
+ outcome,
3193
+ atMs: Date.now(),
3194
+ source: "live-request",
3195
+ ...message === "" ? {} : { message: redactUpstreamText(message) }
3196
+ };
3197
+ }
3092
3198
  //#endregion
3093
- export { Config, QODER_CONNECT_VERSION, QODER_GITHUB_URL, apply, asVolatile, catalogSnapshotDeclaresEfforts, inject, name, regionEnabled, regionHasCredential, regionStateOf, serveSavedCatalog, settingsNamespaceOf, unwrapVolatile };
3199
+ export { Config, QODER_CONNECT_VERSION, QODER_GITHUB_URL, apply, asVolatile, catalogSnapshotDeclaresEfforts, inject, liveRequestProbe, name, regionEnabled, regionHasCredential, regionStateOf, serveSavedCatalog, settingsNamespaceOf, unwrapVolatile };
@@ -1,4 +1,4 @@
1
- import { g as qoderAccountId } from "./host-heartbeat-BNuj6Lt_.js";
1
+ import { g as qoderAccountId } from "./host-heartbeat-R1OtwCEG.js";
2
2
  import { l as QODER_REGION_TRANSFER_DOMAIN } from "./status-paths-w2WDUPVS.js";
3
3
  //#region src/transfer.ts
4
4
  /**
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.4.0",
5
+ "version": "0.4.3",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",