跳到主要内容

dsh-connect-qoder

已验证

@eghrhegpe/dsh-connect-qoder · v0.5.0 · MIT · Web 界面

Bring the Qoder models already signed in on this machine into DeepSeek Harness with zero configuration.

安装

dsh plugin add @eghrhegpe/dsh-connect-qoder

用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。

源码

标签

作者

说明文档

DSH Connect Qoder

把本机已登录的 Qoder 模型接入 DeepSeek Harness, 零配置即可在 DSH 的模型选择器里使用你的 Qoder 账号额度。

国内版 Qoder CN 与国际版 Qoder 是两个并行的 provider(qoder-cn / qoder), 装哪个就出现哪一组模型,两个都装就两组并存,各自使用自己的账号与额度。 image

工作原理

DSH PiAiAdapter(每个区域一套)
  -> 安全 loopback shim(随机端口 + 进程内随机 secret)
  -> COSY 签名 + 自定义 base64 编码
  -> 国内版 https://gateway.qoder.com.cn/
  -> 国际版 https://api3.qoder.sh/
  -> Qoder 双层包装 SSE
  -> OpenAI SSE
  -> DSH 本地执行工具并回传结果

Qoder 不是 OpenAI 兼容端点,所以需要三样东西:

  1. COSY 签名头 —— 每个网关请求都带 Cosy-* 头与 Authorization: Bearer COSY.<payload>.<sig>, 签名是对 base64 负载、RSA 包装的 AES 密钥、时间戳、请求体与签名路径做 MD5。
  2. 置换 base64 请求体 —— 查询串里的 Encode=1 表示 JSON 体要先 base64、再按换过的字母表 逐字符替换、最后按三分之一旋转。
  3. 双层 SSE —— 每个 data: 帧是一个信封对象,它的 body 字段本身又是 JSON 字符串, 里面才是 OpenAI 风格的 chunk。

凭据来源

插件复用 Qoder 桌面应用自己的登录状态,不启动额外的 OAuth 流程,也不写入应用的文件 (凭据文件以只读方式打开)。

Qoder 把登录信息放在 Chromium OSCrypt 格式的凭据文件里(v10 + nonce + 密文 + tag,AES-256-GCM)。 新版(0.3.x)的凭据直接保存在 <userData>/auth.v1.dat,旧版(0.2.x 及更早)则放在 VS Code 风格的 state.vscdb SQLite 数据库里。两种布局都支持,新版优先尝试。 密文用的密钥保存在应用的 Local State 中,由操作系统 keystore 包裹 —— Windows 上是当前用户 作用域的 DPAPI,因此同一用户下的进程都能解开它。 Node 没有内置 DPAPI 绑定,这一步交给 PowerShell,并通过临时文件交换结果(不使用管道), 这样在禁止管道 stdio 的沙箱里同样可用。

没有桌面应用登录时,可以用官方文档的 PAT 兜底:设置 QODERCN_PAT(国内版)或 QODER_PAT(国际版),插件会用它换取 job token。

平台边界:零配置路径只在 Windows 成立。 解密链路是 PowerShell + DPAPI(Crypt32.dll), src/host/ 里没有任何 macOS / Linux 解包分支——在其它平台上应用目录找得到(应用数据根目录 已按平台解析,见下),但凭据读不出来(loadCredential 返回 undefined,该区域不注册), 只剩上面的 PAT 兜底。非 Windows 上的失败说的是真话:runUnwrap 在 spawn 之前就判平台, 记下的是"这个版本只在 Windows 上读得到登录",而不是一条在 Mac 上显示的 Windows 路径; 账号面板的提示也随之改为指向 PAT,而不是让一个已经装了客户端的用户去确认"客户端在不在"。

这不是"没有跨平台客户端",而是客户端已跨平台、插件尚未跟上:Qoder 桌面版在 macOS 12+ / Linux (.deb/.rpm) / HarmonyOS 上都有下载(qoder.com.cn/download), 但 src/host/ 只实现了 Windows 这一条解密链。缺口在两处,全部登记在 docs/KNOWN_GAPS.md 第 6 条(跨平台凭据链未实现):

  1. OS keystore 封装(含 key 派生):Windows 走 DPAPI;macOS 需 Keychain(security), 且 Chromium 在 macOS 上对 encrypted_key 还要做 PBKDF2-HMAC-SHA1 派生("peanuts" 常量 + 1003 次迭代)——与 DPAPI 直解是两种算法,不是换个命令;Linux 需 libsecret(secret-tool) 或 Chromium 在 Linux 上的 peanuts 硬编码 key 兜底。
  2. 跨平台 CI 与实机验证:GitHub Actions 的 macos / ubuntu runner 可以编译并跑单测,但 Keychain 弹窗、签名打包、secret-tool 的 D-Bus session 都得在实机或 runner 上验。

已修的两项(原缺口共四处):应用数据根目录现在按平台解析(Windows %APPDATA、 macOS ~/Library/Application Support、Linux $XDG_CONFIG_HOME/~/.config)——此前直接读 process.env.APPDATA,而该变量在 macOS 上不存在,于是探测得到空串、把"应用装着但读不出密钥" 谎报成"您没登录";MACHINE_OS 也补上了 darwin 档(本机实测过网关对 x86_64_darwin / aarch64_darwin 一律正常应答)。

剩下的是实机工作:service 名、PBKDF2 参数、Linux 的变体都必须实测确认,写出来就是盲代码。 macOS / Linux 用户只能走 PAT 兜底,或自行从本仓库移植。

CI 的 Ubuntu 绿灯说明测试在那边能跑,不等于零配置在 Linux 上存在。

安装

从 npm 安装(推荐)

dsh plugin --profile web add @eghrhegpe/dsh-connect-qoder

从 DSH 市场安装(DSH 桌面用户)

本仓库是 hdhgsysh/dsh-connect-qoder 的 fork。

可在 DSH 的「插件市场」里粘贴这个源:

github:eghrhegpe/dsh-connect-qoder

github: 源是零配置的:宿主产物 lib/(host + client bundle)随仓库提交,克隆即可加载, 安装过程不需要批准构建脚本、也不需要拉 devDependencies。若 DSH 反而提示该包有待批准的构建 脚本,说明它拿到了一份缺 lib/ 的旧克隆——删掉重装即可(lib/ 自 docs/issues/19 起必须 入库,CI 有新鲜度门禁)。

注意:npm 上不带 scope 的 dsh-connect-qoder 由上游发布,装到的是上游那份; 本 fork 自己发的是 scoped 包 @eghrhegpe/dsh-connect-qoder(上面那条),二者不是同一个包。 npm 安装每次发布都会现场重建产物(prepack),同版本号下 npm 的产物比仓库提交里更新的 可能性更大——追新走 npm,github: 源装的是对应提交时刻的产物。

或本地开发模式:

dsh plugin --profile web add <本仓库路径>

本地开发时 lib/ 对应的是提交时刻的源码:改过 src/ 后先 npm install && npm run build,再让 DSH 指向本目录,否则跑的还是旧产物。

安装后需要重启 DSH 进程:bundle 的 patch 在启动时读取。

凭据安全

插件不存储任何凭据:

  • 复用本机已登录的 Qoder 桌面应用的凭据文件(新版 auth.v1.dat / 旧版 state.vscdb, 均以只读方式打开,不做任何写入)。
  • 进程内随机 bearer token 绑定 loopback 端口;Qoder 真实的 RSA 包装 key 与 session key 不会离开本插件的沙箱。

已知行为

  • 始终思考的模型:部分模型(如 GLM-5.3-Flash、Kimi-K3)声明了推理档位但不允许关闭思考, 对它们发送 enable_thinking: false 会被上游以 provider_error 1210 拒绝。插件从模型目录 识别这类模型并完全省略该字段,让模型使用自己的默认档位。
  • 上游错误可见:上游失败时返回的是普通 200 帧里的错误对象,而不是 chunk。插件把它翻译成 一条可读的错误,而不是让用户看到一个空的助手回合。
  • 今日请求次数用完(不是"排队"):Qoder 每天有请求次数上限,用满后上游会带一个以小时计的 重试提示(约两小时)把它伪装成排队。此前插件照单全收,显示成"重试延迟:7350 毫秒", 而这个等待怎么等都没用——次数要到日期切换才重置,不是排队排空的。现在它是一个独立状态: 不重试、不显示误导性的延迟,明确告知约几小时后重置,并指出仍然有效的两条路: 22:00-08:00 的错峰价(折后价,通常不计入或少计日次数)与每日签到。 状态码也从此前的 502("Qoder 坏了")改为 429("今天用完了")。判别点在 src/host/errors.ts 的 classifyUpstreamError:先认 110、再认队列标记—— 顺序是承重的,因为这个报文本身带着全部队列标记,由 test/daily-limit.test.js 钉住。
  • 国际版额度:国际版的试用额度可能已用尽(isQuotaExceeded),此时目录请求会返回 403 Login expired,该区域就不会显示模型;国内版不受影响。
  • 账号状态四档与卡片重读:插件入口读不出登录凭据的区域不注册 provider(启动日志会说 原因)。卡片顶部的「当前账号」面板收敛成版本条(对齐 WorkBuddy 的 tab 条):每个 区域一个胶囊——状态点(ok 绿 / expired 红 / needs-app 琥珀 / signed-out 中性)+ 区域名 +「模型」开关;点哪个胶囊,账号详情、用量与模型列表都只看那一版,区域名因此 在卡片上只出现一次(模型行的区域 badge 已随之移除)。身份详情只带 name / email / 到期日,不带任何凭据(判定在 src/host/account-state.ts,纯本地证据、可注入可直测)。 两个按钮各管一件事:「重读登录」 让宿主丢弃凭据缓存、重读应用存储,并把启动时未能上线的区域现在就上线(重建并重新 注册 adapter,失败时回滚到原注册,不影响已在服务的区域)——重新登录后不必再重启 DSH; 面板每次渲染只读已缓存的密钥,不会同步起 PowerShell(那会阻塞整个 DSH 进程,最坏 30 s), 所以解不开的机器也能立刻看到"读不到"和原因;即便真的需要解包(点「重读登录」时)也是 异步等待——本机实测调用 29 ms 就返回、等待期间 DSH 照常响应别的请求,而同步版本会冻结 490 ms;「重读登录」是您主动点的,那一次是真读,不受缓存与失败窗口限制。 密钥缓存与 Local State 文件绑定,Qoder 重装换掉主密钥后插件会立刻重新解包,不必重启 DSH; 「在线确认」是账号流程里唯一的联网调用(fetchUserInfo),回答「上游现在还认不认 这个登录」,失败按 classifyUpstreamError 分档(sign-in-expired / 其他)。
  • 每一版的模型可以单独关掉(enabledRegions,版本条上的「模型」开关,对齐 WorkBuddy 的 per-tab 供应商开关):取消勾选的一版向 DSH 提供零个模型,它的 模型组按「空目录即隐藏」的同一条规则从选择器消失,但登录、用量与模型筛选全部 保留,重新勾选即恢复,无需重启 DSH;卡片模型列表与选择器用同一个 regionEnabledFor 谓词过滤,两个界面不会打架。
  • 刷新失败会说出来,而不是留着旧数据装作没事:
    • 上游当前没有模型(账号被收窄 / 模型全部下线)是结果不是故障——插件会照实把目录清空, 并把该版本的模型组从选择器撤下。此前这两种情况走同一条静默路径,于是旧名单永远留着, 点"刷新计费"也不可能有任何变化。
    • 抓取失败(网络、凭据、上游 5xx)保留上一份好数据,并把卡片上的时间标成 「上次更新:X(刷新失败)」,而不是给一个全新的时间假装刚更新过。
    • Qoder 改了接口格式(HTTP 200 但返回的信封不认识,例如分组改名)是一档独立状态, 卡片直接说"请更新插件(重新登录没有用)",且不显示时间戳——它不会被当成"排队"慢慢重试。 判别边界取自实测(probe/model-shape.mjs,两端真实账号):空分组用 [] 表达, 所以"0 个模型"是可信结果、必须落盘,而"分组键缺失/类型变了"才是格式变化。
  • 卡片接口只接受同源请求(安全):七条路由里三条是 POST 且能改状态——「签到领积分」 (真实账号变更)、「保存模型勾选与图像模式」、「重读登录」。这些接口此前只按"来源主机名是不是 本机"判断,因此本机任意端口上的任意 HTTP 服务都能让它的网页代为调用(浏览器会挡跨域 读响应,但操作已经发生)。现在要求来源的地址与端口与请求实际拨到的地址一致 (比对请求自己的 Host 头,所以不写死端口、DSH 换端口也不用改),且必须是回环地址。 判定在 src/host/routes.ts 的 loopbackRequest,由 test/route-gates.test.js 覆盖。
  • 依赖 Qoder 客户端接口(非官方开放 API),Qoder 更新后插件可能需要随之调整。
  • 设置命名空间由宿主决定,不能自选(0.2 代起):describe() 用 Loader 条目 id 作为 ns(本 bundle 是 llm-qoder,不是 dsh-connect-qoder),而「设置 → 模型」 页按精确匹配查 namespaces.get(entry.settingsNs)。若插件宣告的命名空间与宿主实际 服务的不一致,该 provider 会被判为「未配置」,整行从页面上消失——不报错、不灰显, 而插件本身仍在正常注册和应答,非常难查。因此 settingsNs 一律经 settingsNamespaceOf(ctx) 从 ctx.fiber.entry.options.id 推导,常量只作为宿主不暴露 条目 id 时的回落值(对齐 WorkBuddy 2.1.0 的同款修复)。
  • 设置保存走插件的 __save 主机端点 + 读回校验(对齐 WorkBuddy 同修):DSH 0.2 代的 客户端设置 scope set() 在原子写重试耗尽后会静默返回成功而不落盘,卡片改为「主机端点 权威写、本地写仅作镜像、写后读回确认」,保存按钮只会显示真实结果。在宿主未为本插件注册设置 命名空间行的环境(如 link: 开发安装)下端点会 503,卡片如实显示失败而不是假"已保存"; 正式(registry)安装下该端点可用。
  • 每日签到:Qoder 每天 10:00(UTC+8)给每个账号发一轮 CLAIM_BENEFIT 活动,一轮只能领一次, 重复领取是幂等的(replayed,不再发放)。卡片在「我的用量」右侧放一张签到小卡片 + 三态按钮 (立即签到 / 签到中… / 今日已签到)——只有你点才会领,插件不会自动签到。每次点击都先重读一次 活动列表取当轮 id,绝不复用渲染时的旧 id(一天一轮,旧 id 就是上一轮的);领取后用量缓存作废 重读,因为 Credits 落在同一块面板显示的 Add-on 资源包里。两个过滤器是照真实返回加的:同一账号 下的另一个 VIEW_DETAILS 活动同样带着 claimStatus: "CLAIMED" 却没有 benefit,只按状态挑会对 它去领;而列表顶层的 claimable 在「已领过」和「没有活动」时都是 false,不能当判据。 卡片右上角大字是主机解析出的当轮额度(checkin.amount,来自活动自身的 benefit),领过之后 仍然显示——签到卡片因此不是「一行两段短文案占满整行」,而是与用量面板并排的一张卡;窄卡片下 自动换行到面板下方。额度落在 addOnQuota 里,是因为国内版免费层的计划额度为 0,签到攒的资源包 是它唯一的额度。
  • 国际版签到行需要 umid 机器身份:国际版 GET /sash/api/v1/me/campaigns 把每日 CLAIM_BENEFIT 轮次按机器身份下发——请求须携带桌面应用 umid 服务的 Cosy-MachineToken/Code/Type 头,否则只回常驻的 VIEW_DETAILS 横幅,签到卡片按 active: false 设计不渲染。插件在 OpenAPI 请求上自动附加这组头(Windows 上定位安装 目录的 resources\umid\runtime-info.exe 读取,失败静默降级为原头组;国内版与 PAT 不受影响;发现与验证记录在 docs/KNOWN_GAPS.md 第 8 条(国际版 campaigns 端点按 umid 机器身份门控每日签到))。

目录

文件 作用
src/host/credentials.ts 从 Qoder 应用读取并解密登录凭据(密钥缓存与 Local State 绑定、失败窗口 60 s、只读缓存的变体、不阻塞的异步解包)
src/host/upstream.ts COSY 签名、请求体编码、目录与对话流
src/host/shim.ts 面向 pi-ai 的 OpenAI 兼容回环端点
src/host/adapter.ts pi-ai provider 与 PiAiAdapter profile(被关的 provider 以零模型组呈现,由 DSH 自行隐藏)
src/host/catalog-entry.ts 目录条目的归一化、模型过滤(含按区域开关)与卡片行投影(无 peer 依赖)
src/host/catalog-store.ts 目录的磁盘缓存与原子落盘(无 peer 依赖)
src/host/catalog-refresh.ts 一次目录刷新的结果如何落地:空目录也是结果(照实清空并推进 fetchedAt),只有失败才保留上一份,且失败按 credential / no-credential / fetch / protocol-shape-changed 分档(无 peer 依赖)
src/host/credential-cache.ts 凭据缓存与「登录失效后重读」规则,以及 env-PAT 兑换的 429 节流闸门(平台自述窗口优先、否则指数退避;401/403 停泊,仅「重读登录」释放;无 peer 依赖)
src/host/throttle-store.ts 兑换节流闸门的磁盘持久化(只存未过期的 rate-limit 窗口、parked 停泊与令牌一律不落盘,原子写,无 peer 依赖)
src/host/account-payload.ts 账号面板三条路由共用的那份应答:逐区域状态 + 开关映射,以及「渲染读缓存 / 重读登录读真」这一个开关(从 index.ts 抽出以便直测,无 peer 依赖)
src/host/account-state.ts 每区域账号状态四档判定(ok / expired / needs-app / signed-out;纯本地证据、不含凭据,无 peer 依赖);三种读取模式(默认 / cachedOnly 不解包 / force 忽略失败窗口)
src/host/settings-save.ts 设置命名空间的解析(0.2 代由宿主推导,插件不能自选)、设置写入、按区域合并与落盘读回校验(无 peer 依赖)
src/host/pi-model.ts pi-ai 模型描述符的构造(纯函数,无 peer 依赖)
src/host/adapter-models.ts 单个区域向 DSH 提供的模型列表:区域开关(只认显式 true)、勾选过滤、最大上下文开关、逐模型图像模式(从 adapter.ts 抽出以便直测,无 peer 依赖)
src/host/region-gate.ts 一个区域能否作为 provider 上线:三档拒绝(无登录 / 已过期 / 读不到)各自的判定与日志级别(从 index.ts 抽出以便直测,无 peer 依赖)
src/host/lifecycle.ts 插件 fiber 退出时要撤销的东西:路由注册的注销句柄收集与释放(宿主是否随 fiber 回收无法从插件侧确认,故两种语义都正确;无 peer 依赖)
src/host/preferences.ts 四个设置项的读取与 volatile 解包(enabledRegions 区域开关:缺失/非对象一律读作开启,只有显式 false 才关)
src/host/offpeak.ts 错峰窗口与费率算术(无 peer 依赖)
src/host/single-flight.ts 同类异步任务的并发合并:刷新在途时,后来的调用并入同一次请求(目录/用量刷新用,无 peer 依赖)
src/host/claim.ts 每日签到:当轮活动的挑选、可领状态判定与领取结果的归一化(纯函数,无 peer 依赖)
src/host/errors.ts 上游错误帧的判定:105(登录没了)与 10605(在排队)的分诊,队列提示的提取,以及协议形状变化这一档(ProtocolShapeChangedError,retryable: false 所以它不会被当成"排队"慢慢等)(无 peer 依赖)
src/host/time.ts 上游时间戳的单一换算(秒 / 毫秒 / RFC 3339 → epoch 毫秒),曾经的两份副本行为不一致(无 peer 依赖)
src/host/volatile.ts volatile 活引用 { get() } 的解包,此前散在三处(无 peer 依赖)
src/host/http-utils.ts 回环路由共用的 JSON / OpenAI 形状错误响应(no-store,卡片轮询读不到陈旧数据;无 peer 依赖)
src/host/domain.ts 各宿主模块标注时共用的领域词表(Region / CatalogEntry / Promotion / CatalogOutcome 等)。只有类型、没有运行时值,所以不进产物、不改变任何 bundle 字节;刻意不描述任何 peer 模块的形状——那些包由宿主运行时提供、此处只有 declare module 空壳,凭空造一个"看起来对"的接口比 any 更危险,因为它不会承认自己不知道(无 peer 依赖)
src/host/routes.ts 每条卡片路由共用的两道闸:方法检查(405 带 Allow、HEAD 交给 GET)与同源来源检查(403;比对 Host 头,POST 路由靠它挡住本机其它端口的网页),以及带 64 KiB 上限的 JSON body 读取器(无 peer 依赖)
src/host/handlers.ts 七条卡片路由的 handler,(req, res, deps) => Promise<void> 形态:方法/来源两道闸 + 业务判定 + 应答全在这(含签到 claimTodayFor 与「重读登录」的失败保护),index.ts 只保留 webServer.register 与 deps 装配(从 index.ts 抽出以便直测,无 peer 依赖)
src/host/publish-regions.ts 把当前区域集发布给宿主(registerAdapter + registerConfigurableProviders)以及失败时的三层回滚:先释放旧的一对,新注册失败则释放半注册的 adapter 并把旧的一对装回去,装不回去则如实留空而不谎称已注册(从 index.ts 抽出以便直测,宿主两处注册接缝靠注入,无 peer 依赖)
src/host/route-mount.ts 一组路由的挂载判定:要么全挂上、要么全撤下。三个账号路由是一个功能,宿主若只收下 /account 而拒了 /account/confirm,旧写法会留下一个"能读状态、两个按钮却不动"的半截面板,且日志写着"account routes unavailable"——那句话是假的,比没有日志更坏,因为它让人不再往下查。组内任一注册被拒即回滚已拿到的 release 并如实报失败(无 peer 依赖)
src/host/dispose-order.ts fiber 注销时的次序(次序本身就是内容):provider 注册先撤、路由先于 runtime 拆(否则 issue 13 那条泄漏)、disposed 必须在 clearInterval 之前置位——否则一次还在途的 refreshCatalog().then 会把一个全新的 interval 装到已经没人管的 runtime 上,成为活过插件的僵尸凭据解析循环。此前这条只由注释担保(无 peer 依赖)
src/host/index.ts 按区域注册 provider 的插件入口,与七条路由的注册接线(handler 本体在 handlers.ts;发布与回滚的判定在 publish-regions.ts;挂载分组在 route-mount.ts;注销次序在 dispose-order.ts)

docs/docs-qoder-cn/ 是从 Qoder CN 官方文档抓回并清洗的资料(纯 Markdown,源 .txt 已删), 由根目录的 convert-qoder-docs.ps1 一次性转换而成——它只处理这一批抓取件,不是常规构建步骤; 产物由 test/docs-markdown.test.js 守着,改动转换规则后重跑该测试即可。文档与代码的对应关系 (哪条系数/活动/签到事实落在哪个模块)记录在该目录的 Qoder CN 文档与插件设计佐证.md。

卡片侧的源码在 src/client/(产物是 lib/client.js,react 由宿主提供,产物不打包它):

| src/client/paths.ts | 卡片用到的五条插件路由 | | src/client/styles.ts | 卡片样式与 installStyles(dsm-* 一套与 dsh-connect-workbuddy 逐字一致,原因见文件头) | | src/client/settings-write.ts | 「写入后读回校验」的浏览器半边 | | src/client/controller.ts | 卡片可编辑状态机(staged/saved 三字段、dirty、save、discard 与派生的模型视图)——无 React、无 DOM,可被 node:test 直接测 | | src/client/copy.ts | 卡片文案聚合入口(中/英),index.ts 与 card.tsx 只从这里取 | | src/client/copy-row.ts copy-usage.ts copy-account.ts | 按面板拆分的三段文案:模型行与错峰、用量与签到、账号与区域标签条 | | src/client/card-model.ts | 卡片的浏览器无关决策层:视图模型类型词汇 + 纯规则(offPeakState / rateAt / windowLabelOf / refreshNoticeKey / 格式化 / describeThrown——卡片所有 catch 块给用户看的失败文案都过它)。无 React、无 DOM、无 fetch,因此 Node 测试可直接 import 真规则,而非镜像副本(这是向 sensenova snapshot.ts 对齐的那一刀;describeThrown 也从 card.tsx 挪进来,拆掉装配层与两个容器之间的循环 import) | | src/client/http.ts | 卡片唯一的 fetch 收口:getJson / postJson,模型名册读与两台容器(用量 / 账号)共六道宿主路由 fetch 都只通过这里(向 sensenova http.ts 看齐);GET / POST 失败都先取宿主的 error 字段(如 loopback origin 检查的 origin-not-trusted),无才回落 HTTP {status};GET 200 但 body 解析失败按 unparseable JSON body 抛出而非当空数据,避免「假 undefined 失败」 | | src/client/usage-panel.tsx | 用量容器 QoderUsagePanel:自有额度读 + 每日签到领取两条 fetch,渲染 RegionUsage / CheckinCard(二者留在 card.tsx 作为无 hook 纯展示件) | | src/client/account-panel.tsx | 账号容器 QoderAccountPanel:区域条 + 选中区域登录卡,四道 fetch(读 / 重读 / 确认 / 开关写),开关写仍带完整 enabledRegions map | | src/client/card.tsx | 卡片的装配层 QoderPluginCard(把 QoderAccountPanel / QoderUsagePanel 与模型列表拼起来,纯规则委托 card-model.ts、可编辑状态委托 controller.ts、名册读走 http.ts)+ 三个无 hook 纯展示件(RegionUsage / CheckinCard / QuotaBlock) | | src/client/index.ts | 注册入口(apply / inject / name) | | src/client/react-shim.d.ts | 最小 React 类型垫片(只管类型检查,不参与构建、不随包发布) |

构建产物:lib/ 不是源码

源码全在 src/,lib/ 是构建产物,但受版本管理**(docs/issues/19:DSH 市场的 github: 源安装正是把这两个文件原样发给用户的,缺了它装出来的插件没有宿主入口,所以 lib/ 必须入库,.gitignore 里不许再出现 /lib/)。改 src/ 后跑 npm run build 并把 lib/ 与源码同一次提交(CI 有新鲜度门禁:重建产物必须与提交里的一致);发到 registry 的那一份仍由 prepack 现场构建。npm run build 从 src/ 重建出两个文件:

产物 由谁构建 内容
lib/index.js tsdown -c tsdown.config.mjs(npm run build:host) 上面那张表里全部 src/host/*.ts 打成的单个 ESM bundle——发布面就是 package.json#main 一个入口,peer 包一律外置
lib/client.js node scripts/build-client.mjs(npm run build:client) 注入到宿主设置页的那张卡片,外面套着 window.__ModuleLoader__.load(...) 的加载器外壳

这些源码不是原始手稿,是还原出来的:2026-09 用 docs/history/restore-client-src.mjs 把当时的 产物按 //#region 标记机械切分而成,模块边界来自产物,card.ts 那一段在产物里没有标记、 是按引用关系推断的。还原后做过一次对拍——把卡片里每个纯函数用同一组输入各调一遍,新旧产物的 返回值与抛错逐条一致(方法记录在 docs/issues/17-client-source-restore.md;现在的探针清单与 调用数以 npm run verify:bundle 的输出为准,不再在文档里写死数字)。

产物是构建输出:改源码重建,不要手改产物。test/client-bundle.test.js 从产物里提取并 执行卡片的纯函数,所以产物一改那里的断言就得跟着看一眼。

test/*.test.js 直接 import src/host/*.ts(Node 原生剥类型),不经过 lib/——所以一套 测试跑的是源码,产物陈旧与否由 npm run verify:host 单独把关。

probe/ 下是一次性只读探针,每个文件头写明 WHY 与 Run,不参与构建、也不被测试收集。 其中 probe/host-compat.mjs 回答的是「本机装的 DSH 是什么版本、本仓库的 peer 声明它还认不认」—— 宿主把代码打在 app.asar 里,这件事从仓库内部看不出来。手法与三个会浪费时间的坑记在 docs/howto/host-version-probe.md。

测试

npm run verify          # 下面五条串起来,全过才算过
npm run typecheck       # tsc -p tsconfig.json,源码全量类型检查(0 error)
npm test                # node --test "test/*.test.js"
npm run test:coverage   # 同上 + 覆盖率门槛(行 68 / 分支 82 / 函数 66,跌破即失败)
npm run verify:deploy   # 比对已部署副本与本仓库,报告漂移
npm run build           # 从 src/ 重建 lib/(宿主 bundle + 卡片产物)

npm run verify 跑五件事,每件回答一个不同的问题:

步骤 回答什么 全过时的输出
typecheck src/** 的每个 .ts 都过类型检查 tsc 退出 0、无输出
npm test 卡片逻辑与宿主半边没被改坏 最后一行 # fail 0
build(宿主 + 卡片) lib/index.js 与 lib/client.js 确实由 src/ 生成 MATCH: … byte-for-byte identical
verify:host 宿主 bundle 不陈旧、公开面与 peer 外置都还在 all 11 checks passed(装了 tsdown 时 11 条;没装时 9 条,见下)
verify:bundle 重建产物与上一份已发布产物的行为一致 behaviour: IDENTICAL + OK: …

中间那条是最要紧的:宿主与卡片两步都只比较、不认账。它过了,就说明产物不是手抄进来的 副本——改 src/ 而产物不变的情况会在这里红。verify:host 补的是迁移带来的新缺口:源码 在 src/host/、产物在 lib/ 之后,「改了源码忘了重建」第一次成为可能的错误;lib/ 如今 受版本管理(docs/issues/19),这道本地对拍之外,CI 的 git diff --exit-code -- lib 门禁 也会在推上去时看见它。

绿灯不等于门禁有效。 这几道门禁每条都用故意的破坏验过:build 缺关键串时会拒绝写入 (第一次构建摇掉全部模块、只剩 84 行,bundler 仍然退出 0);verify:host 往 lib/index.js 尾上追加一行就让 fresh 那条红(the artifact is stale)并以非 0 退出;verify:bundle 把 探针下界从 Math.max(0, …) 改成 1,就会在 formatCountdown|undefined|0 上报出 00:00:00 → 00:00:01 并以非 0 退出。怀疑门禁时照这个法子再破一次,比看它绿不绿有用。

npm run build 需要构建器(tsdown),它是 devDependency,跑之前先 npm install。 测试与 typecheck 都不需要 lib/:测试直接 import src/** 的源码(Node 原生剥类型), verify:host 在没有 tsdown 时打印一声响亮的 SKIP 并以 0 退出。

两条关于这道 SKIP 的实话,免得它被当成 CI 的实际路径:

  • CI 永远走不到 SKIP:两个 job 都是先 npm install 再 npm run build,tsdown 一定在。 那是给裸检出直接 node scripts/verify-host-bundle.mjs 的人准备的。
  • npm run verify 也走不到:它自己的 build 那步就需要 tsdown,会先在 build 失败。 换句话说,这一条不可能靠跳过而"绿着通过"。

verify:bundle 另有一条容易误读的性质,2026-10 起它的行为是:baseline 不再默认取 HEAD (CI 的 build job 先跑 npm run build,那时 HEAD:lib/client.js 就是工作树产物本身,跟自己比 必然 IDENTICAL,什么也没证明),而是自动回溯历史(上限 80 条),选最新一条 lib/client.js 与工作树不同的提交作为 baseline——「最新的、其产物与我不一样的那个 ref」才是这个问题该有的 问法。固定的 ref(比如 HEAD^)是错答案:lib/client.js 大多数提交根本没碰(本分支上曾连续 24 条提交都相同),任何固定 ref 都可能恰好逐字节相同。回溯无果(80 条内全相同,或浅克隆没有 历史可走)时退回 HEAD,而那是一次失败而非放行:FAIL (vacuous) 并 exit 1,浅克隆分支 还单独报出(修法是给 checkout 加 fetch-depth: 0,CI 的 build job 正是为此设置的;修法恰好 相反——走完全相同是传一个显式 ref,浅克隆是改 checkout,所以分开报)。确需跨大距离对照某份 已发布产物时仍传 --baseline <ref>。

对拍覆盖不到渲染:JSX 被 stub 成 null,所以改了 UI 仍然要在浏览器里看一眼(展开卡片 → 切区域 → 改图像档位 → 保存 → 看错峰倒计时)。

卡片侧测三层,别互相替代

卡片的可编辑逻辑(dirty / save / discard、staged 与 saved、per-region 允许清单、名称 过滤)住在 src/client/controller.ts 的 QoderCardController 里,是个纯类——不 import React、不碰 DOM、不发 fetch。这不是顺手为之,是为了让测它不需要浏览器:

层 测什么 跑在哪里
test/controller.test.js(16 条) 状态机的规则:什么时候算 dirty、拒绝写入怎么呈现、discard 回滚到哪、auto 怎么写最小、过滤为什么只窄化视图 裸 node:test,无 jsdom、无 React
test/card-dom.test.js(9 条) 产物级渲染接线:QoderPluginCard 真的把快照渲染成了用户看见的 DOM、按钮 disabled 条件对不对 jsdom,驱动 lib/client.js(出货包)
浏览器手动 视觉与交互手感(样式、错峰倒计时动效) 人类

规则在 controller、接线在 card-dom,两者都在 CI 里,谁都不能替谁:只测 controller 会漏掉 「快照没接到 JSX」这种接线错误,只测 card-dom 会把状态机的每条规则塞进 jsdom、慢且脆。改 规则去 test/controller.test.js,改渲染去 test/card-dom.test.js——一条规则同时出现在两个 文件里,通常是抽得不够干净,而不是「保险起见各写一遍」。

useSyncExternalStore 的签名(含「为什么 subscribe / getSnapshot 必须做箭头属性、 getSnapshot 为什么必须引用稳定」)写在 src/client/react-shim.d.ts 里,改 store 接口先看 那段注释。

.npmrc 里的 legacy-peer-deps=true 是必需的、不是随手加的:本包的 peer 依赖是 @deepseek-ai/*,由宿主在运行时提供,不在公共 registry 上,npm 自动安装 peer 会在装到 devDependencies 之前就失败。

verify:deploy 存在的理由和上面那些测试一样:一台机器上可以同时装着好几个版本的本插件。 link: 安装是指向本仓库的符号链接、永远最新;市场安装是复制,停在安装那一刻, 而且两边 package.json 的 version 一样——任何按版本判断新旧的升级路径都会认为「已是最新」。 该脚本比对 lib/** 的内容哈希、文件清单,以及三个由真实缺陷换来的标记 (错峰 active 门、账号状态模块、账号路由),并把「版本号相同但内容不同」单独标出来。 test/deploy-drift.test.js 用假目录树钉住这套判定。

CI 在 Node 22.19 / 24 × Ubuntu / Windows 上跑(.github/workflows/test.yml)—— Windows 不是冗余:shim 绑定回环监听、目录缓存依赖 rename 覆盖、凭据读取要调 PowerShell,这些在别的平台上行为不同。

测试只用 Node 内置的 node:test,不需要安装任何依赖——也不需要安装 peer 依赖, 这是刻意的:src/host/ 中凡是纯逻辑的部分都放在无 peer 依赖的模块里(见上表), 这样它们才能被直接 import 并断言真实的实现,而不是在测试里手抄一份。

几个文件存在的理由,都是因为曾经出过问题:

  • test/catalog-fields.test.js 与 test/model-row.test.js —— 错峰机制曾因 promotion.active / promotion.timezone 在 Host 投影时被丢掉而全程哑火, 而当时的测试是绿的,因为它测的是自己手抄的副本。同一个毛病在客户端又犯过一次: 卡片自己算窗口时漏看 promotion.active,把拿不到的折扣价显示给用户, 而模型选择器按 before 价计费,两个界面自相矛盾。 model-row.test.js 现在逐状态对拍两个门控;client-bundle.test.js 更进一步, 直接从产物里提取卡片的 offPeakState 并执行——删掉那行门控会让它变红, 而只会让 model-row.test.js 保持绿色。副本不是防线。
  • test/credential-cache.test.js —— 「重新登录无需重启」这条卖点的完整链路: 网关拒绝 → 谓词判定 → 置失效标志 → 下次请求重读。此前只有两端被测。也覆盖 env-PAT 兑换的 429 节流闸门:平台自述窗口被 honour、窗口内的 poll 不重探 (兑换计数是证据)、二次拒绝指数翻倍、401/403 停泊仅「重读登录」释放、5xx 不闸门。
  • test/exchange-throttle-e2e.test.js —— 上面那条闸门的端到端版:真实的 exchangePat 走 fetch 打到真实 fake platform,「poll 有没有重探」的答案是 fake 自己的请求计数器,而不是插件自述。与姊妹插件的 429 e2e 同一标准。
  • test/throttle-store.test.js —— 节流闸门的磁盘持久化,用真实临时目录跑: 未过期的 rate-limit 窗口跨重启读回、parked 与令牌一律不落盘、过期窗口在读取时 丢弃、原子写不留半截文件。
  • test/credential-invalidation.test.js —— 上面那条链路上的两个纯谓词。
  • test/account-state.test.js —— 账号状态四档判定(src/host/account-state.ts): 全注入的存储读器 + 真实临时目录跑目录存在性检查,钉住「判定只信本地证据」 与「状态记录不含任何凭据材料」两条不变量。
  • test/errors-classify.test.js —— 105 与 10605 的优先级决定了「提示用户重新登录」 还是「排队等待」,两者弄反的代价完全不同。
  • test/settings-save.test.js —— DSH 0.2 代上 set() 会静默成功而不落盘; 这段代码用「写入→读回→深比较」把假成功变成显式失败,测试里直接模拟 「mutate 成功但文档没变」的那个场景。也钉住了两件靠肉眼会漏的事: 命名空间只做全等匹配(llm-qoder-extra 不算我们的),字段白名单用 Object.hasOwn(constructor 不是一个字段)。
  • test/pi-model.test.js —— toPiModel 抽离成纯函数后的直接覆盖: compat.supportsDeveloperRole: false 与「不声明 maxTokens」这两处, 失效时每个请求都会 403,或长回复被截成 finish: max-tokens。
  • test/catalog-store.test.js —— 目录缓存的原子落盘,用真实临时目录跑, 在 CI 所在的平台上实测 rename 覆盖行为,而不是在注释里假设。
  • test/shim.test.js —— 回环端点的鉴权与 /v1/models 过滤;对着真实 HTTP 服务器说话,Host 头用裸 socket 发送(fetch 禁止设置该头)。
  • test/oscrypt.test.js —— 凭据解密往返;夹具用真实 AES-256-GCM 构造, key 是固定哈希,失败可复现。
  • test/upstream-protocol.test.js —— 编码与签名。两个盲区是原理上不可测的, 已在文件头写明。
  • test/upstream-messages.test.js —— 消息与工具调用翻译,注释里自称 「最重要的一件事」,此前零覆盖。
  • test/docs-markdown.test.js —— docs/docs-qoder-cn/*.md 是一次性转换的产物: convert-qoder-docs.ps1 把抓回的原始资料洗成纯 Markdown,随后原始 .txt 被删除, 于是「转换是否忠实」在事后无法从内容本身判断,而 docs-facts.test.js 只管散文断言、 不读这个目录。2026-10 因此放过去两类静默污染:转义顺序写反(先 < 后 &,把刚生成的 &lt; 二次转成 &amp;lt;)让两段「Temporarily hidden … Keep for restoration」的隐藏 注释渲染成可见文本,同一 bug 又把链接里的 \& 转成 \&amp;、弄坏三个订阅链接。 门禁只查转换器真能损坏的形状(双重转义、注释开合、围栏配对、MDX 残留),并带存活守卫 (文件数与最小长度)——否则目录被改名或清空时,循环跑零次、门禁照样全绿。

尚未覆盖的部分集中登记在 docs/KNOWN_GAPS.md,不在各文件里重复叙述—— 重复三处正是「手抄副本」那类问题的文档版。

免责声明

仅供个人学习研究使用,仅驱动使用者自己的 Qoder 账号在本机调用。使用者需遵守 Qoder 的服务条款, 因使用本项目产生的后果由使用者自行承担。本项目与 Qoder、DeepSeek 均无关联。

插件除「每日签到」外的所有请求都是只读的;签到是它唯一会对 Qoder 账号产生变更的操作(领取 Qoder 正在向你自己的账号发放的额度),它不会自动执行,每次都要手动点击。是否使用它、以及它是否符合 Qoder 当前的服务条款,请自行判断。

许可证

MIT