@dmh2002/dsh-connect-qoder 0.4.2 → 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,35 @@
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
+
5
34
  ## 0.4.2 (2026-10-13)
6
35
 
7
36
  用户实拍:国际版账号「ddd」显示 **「✗ Qoder 拒绝了这个账号的 PAT——请添加新 PAT 或换一个账号」**,并附一句「我感觉大概率还是我们出了问题」。**这个判断是对的**:账号一切正常,PAT 没有任何问题,是我们把上游的一句「今天用满了」读成了「你的凭据坏了」。0.4.1 修的是文案自相矛盾,这一版修的是**判据本身**。
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-DOru8uF9.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
  *
@@ -3751,21 +3799,7 @@ window.__ModuleLoader__.load({
3751
3799
  /** Build the `lastCatalog` array + enabled/off/image selections for the save. */
3752
3800
  function buildModelsPayload(draft, models) {
3753
3801
  return {
3754
- lastCatalog: models.map((model) => ({
3755
- key: model.key,
3756
- displayName: model.name,
3757
- priceFactor: typeof model.creditMultiplier === "number" ? model.creditMultiplier : 1,
3758
- contextWindow: model.contextWindow,
3759
- nativeContextWindow: model.nativeContextWindow,
3760
- maxTokens: model.maxTokens,
3761
- supportsImages: draft.supportsImages.has(model.key),
3762
- supportsThinking: model.supportsThinking === true,
3763
- canDisableThinking: model.canDisableThinking === true,
3764
- isDefault: model.isDefault === true,
3765
- isFree: model.isFree === true,
3766
- isNew: model.isNew === true,
3767
- contextTiers: model.contextTiers
3768
- })),
3802
+ lastCatalog: models.map((model) => toPersistedCatalogEntry(model, { supportsImages: draft.supportsImages.has(model.key) })),
3769
3803
  enabledModelKeys: [...draft.enabledModelKeys],
3770
3804
  imageModelKeys: [...draft.supportsImages],
3771
3805
  offModelKeys: [...draft.offModelKeys],
@@ -1809,7 +1809,7 @@ var QoderAccountStore = class {
1809
1809
  //#endregion
1810
1810
  //#region src/version.ts
1811
1811
  /** The npm package version this build was produced from. */
1812
- const QODER_CONNECT_VERSION = "0.4.2";
1812
+ const QODER_CONNECT_VERSION = "0.4.3";
1813
1813
  //#endregion
1814
1814
  //#region src/account-pool-store.ts
1815
1815
  /**
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-DOru8uF9.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),
@@ -2855,15 +2940,15 @@ function apply(ctx, config) {
2855
2940
  /** Build the transfer deps once (region-dispatched). */
2856
2941
  const transferDeps = {
2857
2942
  exportAccounts: async (region) => {
2858
- const { transferRecordsFor } = await import("./transfer-BQMtjWb5.js");
2943
+ const { transferRecordsFor } = await import("./transfer-B9YbYpwQ.js");
2859
2944
  return { accounts: transferRecordsFor(region, await stacks[region].store.vaultAccounts()) };
2860
2945
  },
2861
2946
  previewImport: async (_region, text) => {
2862
- const { previewTransferFile } = await import("./transfer-BQMtjWb5.js");
2947
+ const { previewTransferFile } = await import("./transfer-B9YbYpwQ.js");
2863
2948
  return previewTransferFile(text);
2864
2949
  },
2865
2950
  importAccounts: async (region, text, indexes) => {
2866
- const { mergeTransferRecords } = await import("./transfer-BQMtjWb5.js");
2951
+ const { mergeTransferRecords } = await import("./transfer-B9YbYpwQ.js");
2867
2952
  const { changes, imported, skipped } = mergeTransferRecords(region, text, indexes);
2868
2953
  for (const entry of changes.values()) await stacks[region].store.add(entry.pat, entry.accountName);
2869
2954
  return {
@@ -1,4 +1,4 @@
1
- import { g as qoderAccountId } from "./host-heartbeat-DOru8uF9.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.2",
5
+ "version": "0.4.3",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",