@dmh2002/dsh-connect-qoder 0.4.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 CHANGED
@@ -2,6 +2,71 @@
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
+
5
70
  ## 0.4.0 (2026-10-13)
6
71
 
7
72
  0.3.0 发布后挖出的四条修复,外加一条有意的旧行为移除。四条里两条是 0.3.0 自己带出来的伤:**npm 上 0.3.0 的 peer 范围装不进当前 DSH**(装上即 `ERESOLVE`,必须尽快覆盖掉),以及**国际版需要的第二族令牌**;另两条是用户实拍回来的(思考强度间歇性消失、签到倒计时)。其中思考强度那条经历了一次返工 —— 第一版修复在生产路径上是**死代码**,险些带着假说明发布,经过与返工都记在条目里。
@@ -60,6 +125,7 @@
60
125
  - **上游没给 `endAt` 就不显示倒计时**,绝不用本地零点或「上次 +24h」编一个。
61
126
  - **顺带修掉一个真缺陷**:客户端最初把「已领取但上游没给时间」与「倒计时走完了」都表达成 `countdownText(...) === ''`,于是前者被渲染成「现在可以领取」—— 把「不知道下一窗什么时候开」说成了「已经可以领了」。改成四态(`off` / `unknown` / `ticking` / `due`)。测试接住了它:先写成两态时那条断言立刻失败。
62
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 后刷新』」随之改正。
63
129
 
64
130
  ### Changed
65
131
 
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-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)("span", {
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 ? /* @__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", {
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
- }) : null] }) : t("row.poolNotCheckedIn")
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
- /* 「已签到 · 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)}
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
- .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}
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
- @media (prefers-reduced-motion: reduce){.dsm-qoder-veil-spinner{animation:none}}
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": "✗ 积分不足 · 积分包 {at} 刷新",
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": "成员要么在限流冷却中、要么积分耗尽、要么凭据被拒、要么上游无响应。限流、积分耗尽与瞬时故障都会自动回池——按上游给出的恢复时间,没给时间就按 30 分钟兜底,不需要你操作;只有「凭据被拒」需要换一个新 PAT。",
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": "· {countdown} 后刷新",
4366
- "row.poolCheckinNextHint": "这一窗已经领过了。下一窗在上游给出的时刻开放 —— 与 Qoder 网页上的倒计时是同一个数。",
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": "✗ Not enough credits · the upstream gave no time for when it frees up",
4587
- "row.probeOutOfCreditAt": "✗ Not enough credits · quota refreshes {at}",
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, 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.",
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": "· 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",
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": "Out of credits",
4721
+ "row.poolExcludedOutOfCredit": "Usage limit reached",
4640
4722
  "row.poolExcludedRejected": "Rejected",
4641
4723
  "row.poolExcludedUnusable": "Unusable",
4642
4724
  "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.2";
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-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";
@@ -2418,16 +2418,12 @@ function apply(ctx, config) {
2418
2418
  };
2419
2419
  },
2420
2420
  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`);
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`);
2431
2427
  }
2432
2428
  }
2433
2429
  }),
@@ -2859,15 +2855,15 @@ function apply(ctx, config) {
2859
2855
  /** Build the transfer deps once (region-dispatched). */
2860
2856
  const transferDeps = {
2861
2857
  exportAccounts: async (region) => {
2862
- const { transferRecordsFor } = await import("./transfer-CUNaVvmi.js");
2858
+ const { transferRecordsFor } = await import("./transfer-BQMtjWb5.js");
2863
2859
  return { accounts: transferRecordsFor(region, await stacks[region].store.vaultAccounts()) };
2864
2860
  },
2865
2861
  previewImport: async (_region, text) => {
2866
- const { previewTransferFile } = await import("./transfer-CUNaVvmi.js");
2862
+ const { previewTransferFile } = await import("./transfer-BQMtjWb5.js");
2867
2863
  return previewTransferFile(text);
2868
2864
  },
2869
2865
  importAccounts: async (region, text, indexes) => {
2870
- const { mergeTransferRecords } = await import("./transfer-CUNaVvmi.js");
2866
+ const { mergeTransferRecords } = await import("./transfer-BQMtjWb5.js");
2871
2867
  const { changes, imported, skipped } = mergeTransferRecords(region, text, indexes);
2872
2868
  for (const entry of changes.values()) await stacks[region].store.add(entry.pat, entry.accountName);
2873
2869
  return {
@@ -3089,5 +3085,30 @@ const FAIL_OVER_OUTCOME = {
3089
3085
  network: "unavailable",
3090
3086
  transient: "unavailable"
3091
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
+ }
3092
3113
  //#endregion
3093
- export { Config, QODER_CONNECT_VERSION, QODER_GITHUB_URL, apply, asVolatile, catalogSnapshotDeclaresEfforts, inject, name, regionEnabled, regionHasCredential, regionStateOf, serveSavedCatalog, settingsNamespaceOf, unwrapVolatile };
3114
+ 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-DOru8uF9.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.2",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",