把 Qoder CN 订阅额度接入 DSH 当模型用
31.5/100
综合分
2
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/10/11 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
该插件未发布到 npm,按作者文档从 GitHub 源安装即可(静态检查只覆盖 npm 路径,不代表插件不可用)。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add ccccqiang/dsh-qoder-relay该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:已验证
是什么
dsh 原生插件 · model
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 20 天前
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
社区实装还没有人打卡 · 装过的第一个就是你
安装兼容性检查需源码安装仓库 package.json 标记 private,未发布到 npm,需从源码安装
展开查看检查明细 ↓
以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。
✗npm 包dsh-qoder-relay(未发布到 npm,仅可源码安装)
✓Node 引擎未声明 engines.node
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明
源码安装时若 pnpm 拦截 prepare 脚本,按其提示在 pnpm-workspace.yaml 的 allowBuilds 中放行后重跑。
验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/10/11 21:03:58
安全扫描自动 · 静态分析源码v2.7.0🔵 扫描:含敏感能力尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。
可执行外部命令读写/删除本地文件发起外部网络请求
查看扫描证据(共 36 处,展示前 33 条)
- 读取本地文件lib/index.mjs:65
if (!existsSync(p)) return {}; - 读取本地文件lib/index.mjs:67
const j = JSON.parse(readFileSync(p, "utf8")); - 读取本地文件lib/index.mjs:108
if (existsSync(statusPath)) { - 读取本地文件lib/index.mjs:109
const j = JSON.parse(readFileSync(statusPath, "utf8")); - 读取本地文件lib/index.mjs:117
if (!loggedIn && existsSync(authDir)) { - 读取本地文件lib/index.mjs:126
authDirExists: existsSync(authDir), - 发起网络请求lib/index.mjs:138
const r = await fetch(`http://${host}:${port}/health`, { signal: ac.signal }); - 执行外部命令来自配置/变量lib/index.mjs:171
const child = spawn(electronExe, [shim], { env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); - 执行外部命令固定命令lib/index.mjs:237
const r = spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], - 执行外部命令来自配置/变量lib/index.mjs:287
child = spawn(electronExe, [serverPath], { env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); - 发起网络请求lib/index.mjs:472
const res = await fetch(BASE + "/models", { signal: ac.signal }); - 读取本地文件lib/discover.mjs:72
try { if (c && existsSync(c)) return c; } catch { } - 读取本地文件lib/discover.mjs:80
if (configured && existsSync(configured)) return configured; - 读取本地文件lib/discover.mjs:81
if (process.env.QODER_SDK_ROOT && existsSync(process.env.QODER_SDK_ROOT)) return process.env.QODER_SDK_ROOT; - 读取本地文件lib/discover.mjs:85
return existsSync(base) ? base : null; - 读取本地文件server.mjs:38
const fileCfg = existsSync(CFG_PATH) - 读取本地文件server.mjs:39
? JSON.parse(readFileSync(CFG_PATH, 'utf8')) - 写入本地文件server.mjs:154
writeFileSync(argsFile, JSON.stringify(cliArgs), 'utf8'); - 删除本地文件server.mjs:159
const cleanupArgs = () => { try { unlinkSync(argsFile); } catch {} }; - 执行外部命令来自配置/变量server.mjs:171
const child = spawn(exe, [SHIM], { env, windowsHide: true }); - 写入本地文件server.mjs:779
try { writeFileSync(file, buf); files.push(file); } catch { } - 执行外部命令来自配置/变量server.mjs:891
const child = spawn(exe, [shim], { env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); - 读取本地文件server.mjs:915
const pj = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')); - 请求外部主机github.comserver.mjs:956
res = await fetch(`https://github.com/${UPDATE_REPO}/releases.atom`, { - 发起网络请求server.mjs:956
res = await fetch(`https://github.com/${UPDATE_REPO}/releases.atom`, { - 请求外部主机api.github.comserver.mjs:1000
res = await fetch(`https://api.github.com/repos/${UPDATE_REPO}/releases?per_page=10`, { - 发起网络请求server.mjs:1000
res = await fetch(`https://api.github.com/repos/${UPDATE_REPO}/releases?per_page=10`, { - 读取本地文件server.mjs:1096
authFileExists: existsSync(authUser), - 读取本地文件server.mjs:1105
if (existsSync(statusFile)) { - 读取本地文件server.mjs:1106
const j = JSON.parse(readFileSync(statusFile, "utf8")); - 执行外部命令来自配置/变量server.mjs:1177
const child = spawn(exe, [shim], { env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); - 删除本地文件server.mjs:1472
for (const f of attachments) { try { unlinkSync(f); } catch {} } - 读取本地文件shim.mjs:15
try { return readFileSync(argsFile, 'utf8'); } catch (e) {
扫描于 2026/10/31(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库
涉及外部主机:github.com、api.github.com
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
功能亮点
- 作为DSH模型使用
README
由 DeepSeek 最新模型翻译生成dsh-qoder-relay
把 Qoder CN 订阅额度做成 DSH 的一部分:一个本地 OpenAI 兼容网关,随宿主自动启停,外加一个设置页用来完成登录。
安装
前置条件
必须已安装 Qoder CN 桌面端(qoder.cn)。
网关不依赖它的 GUI 运行,但需要它的可执行文件当 Node 运行时。
装在 C: / D: / E: / F: 任一盘符都能自动探测;装在非常规位置时用 QODER_ELECTRON_EXE 环境变量指定。
方式一:CLI 安装(推荐)
dsh plugin --profile desktop install github:ccccqiang/dsh-qoder-relay#v0.3.2
方式二:手动装进 profile
编辑 $DSH_HOME/profiles/desktop/package.json,两处都要改:
{
"dependencies": {
"dsh-qoder-relay": "github:ccccqiang/dsh-qoder-relay#v0.3.2"
},
"dsh": {
"profile": {
"bundles": [
"...",
"dsh-qoder-relay"
]
}
}
}
然后安装依赖(nodeLinker: hoisted 是复制不是软链,必须跑这一步):
展开完整文档(README 剩余 1 节)
dsh-qoder-relay
把 Qoder CN 订阅额度做成 DSH 的一部分:一个本地 OpenAI 兼容网关,随宿主自动启停,外加一个设置页用来完成登录。
安装
前置条件
必须已安装 Qoder CN 桌面端(qoder.cn)。
网关不依赖它的 GUI 运行,但需要它的可执行文件当 Node 运行时。
装在 C: / D: / E: / F: 任一盘符都能自动探测;装在非常规位置时用 QODER_ELECTRON_EXE 环境变量指定。
方式一:CLI 安装(推荐)
dsh plugin --profile desktop install github:ccccqiang/dsh-qoder-relay#v0.3.2
方式二:手动装进 profile
编辑 $DSH_HOME/profiles/desktop/package.json,两处都要改:
{
"dependencies": {
"dsh-qoder-relay": "github:ccccqiang/dsh-qoder-relay#v0.3.2"
},
"dsh": {
"profile": {
"bundles": [
"...",
"dsh-qoder-relay"
]
}
}
}
然后安装依赖(nodeLinker: hoisted 是复制不是软链,必须跑这一步):
$pnpm = Get-ChildItem "$env:APPDATA\DSH Desktop\runtime-commands" -Recurse -Filter pnpm.cmd | Select-Object -First 1
& $pnpm.FullName install --dir "$env:USERPROFILE\.dsh\profiles\desktop"
方式三:本地目录
clone 到本地后把依赖写成:
"dsh-qoder-relay": "file:D:/path/to/dsh-qoder-relay"
启用模型路由
v0.4.2 起可以点按钮:装好插件完全重启 DSH 后,进「设置 → Qoder CN → 模型路由」,点一下「接入模型路由」就完事。 下面是手工方式和这个按钮实际做的事。
背景:网关装好会自己跑起来,但 DSH 不会自动发现网关的模型 —— 它只认显式声明。
你要在 profile 层写一次 provider,模型才会出现在「设置 → 模型」和模型选择器里。
文件(Windows):C:\Users\<你的用户名>\.dsh\profiles\desktop\cordis.patch.yml
情况 A:文件里还没有 llm-pi-ai 这一行
整块复制进去:
- id: llm-pi-ai
name: "@deepseek-ai/dsh-llm-pi-ai"
config:
providers:
qoder:
displayName: Qoder CN
api: openai-completions
baseURL: http://127.0.0.1:8788/v1
apiKeyEnv: QODER_RELAY_KEY
models:
- id: auto
name: Auto
contextWindow: 200000
maxTokens: 65536
- id: qoder-auto
name: Qoder Auto
contextWindow: 200000
maxTokens: 65536
- id: deepseek-v4-pro
name: DeepSeek-V4-Pro
contextWindow: 200000
maxTokens: 65536
- id: deepseek-flash
name: DeepSeek-Flash
contextWindow: 200000
maxTokens: 65536
- id: qwen3.8-max
name: Qwen3.8-Max
contextWindow: 200000
maxTokens: 65536
- id: qwen3.8-flash
name: Qwen3.8-Flash
contextWindow: 200000
maxTokens: 65536
- id: qwen3.7-max
name: Qwen3.7-Max
contextWindow: 200000
maxTokens: 65536
- id: qwen3.7-plus
name: Qwen3.7-Plus
contextWindow: 200000
maxTokens: 65536
- id: qwen3.7-flash
name: Qwen3.7-Flash
contextWindow: 200000
maxTokens: 65536
- id: glm-5.3
name: GLM-5.3yaml
contextWindow: 200000
maxTokens: 65536
- id: glm-5.3-flash
name: GLM-5.3-Flash
contextWindow: 200000
maxTokens: 65536
- id: glm-5.2
name: GLM-5.2
contextWindow: 200000
maxTokens: 65536
- id: kimi-k3
name: Kimi-K3
contextWindow: 200000
maxTokens: 65536
- id: kimi-k2.8-preview
name: Kimi-K2.8-Preview
contextWindow: 200000
maxTokens: 65536
- id: minimax-m2.7
name: MiniMax-M2.7
contextWindow: 200000
maxTokens: 65536
情况 B:已经有 llm-pi-ai 行(比如你还接了别家中转站)
不要把整行替换掉。 同一个 id 的行是整体覆盖而不是合并 —— 你原有的 provider 会一起消失。
只把 qoder: 这一段加到现有 providers: 底下,缩进和兄弟节点对齐: yaml providers: cun: # 你原来就有的,别动 displayName: ... models: ... qoder: # ← 新增这一段 displayName: Qoder CN api: openai-completions baseURL: http://127.0.0.1:8788/v1 apiKeyEnv: QODER_RELAY_KEY models:
- id: auto
name: Auto
contextWindow: 200000
maxTokens: 65536
…其余 14 个同上
模型 id 必须逐字照抄,不能改大小写:qwen3.8-max 不是 Qwen3.8-max。
想确认网关到底暴露了哪些,直接问它:curl http://127.0.0.1:8788/v1/models
加凭证
也在 profile 目录旁边,$DSH_HOME/.credentials.yaml: yaml refs: QODER_RELAY_KEY: qoder-relay-local
网关默认不校验 key,值填什么都行 —— 但它必须存在。缺了会以 MISSING_CREDENTIAL 报错,现象是模型能选中、一发消息就失败。
重启
完全退出 DSH Desktop(托盘图标右键退出),再打开。 profile patch 只在启动时读一次,不重启的话运行中的进程还是旧配置。
这条路由必须留在 profile 层。 搬到 $DSH_HOME/cordis.patch.yml(home 层)会让「设置 → 模型」那页变成空壳 —— 它只从 profile 层读取,而 home 层会整体遮住 profile 层的那一行。
更新日志
v0.4.7
DSH 底部那两个 0 动起来了 —— 上游不给 token 计数,但里面有两个真实值之前被丢掉了。
实测 result 事件(--output-format stream-json 的最后一条): json
"usage": { "input_tokens": 0, "output_tokens": 0, "context_usage_ratio": 0.156 },
"total_credits": 0.0939,
"duration_ms": 1580
input_tokens / output_tokens 确实是 0 —— 上游就是不返回 token 计数,这条老结论没错。但同一条事件里有两样能用、之前没取的东西:
- context_usage_ratio —— 真实的上下文占用比例。乘以我们声明的窗口就是本次上下文的规模(实测 0.156 × 200000 ≈ 31k,与 CLI 自带 system 的体量相符)。现在用它填 prompt_tokens。
- duration_ms —— 真实耗时,透出成 qoder_duration_ms。
输出侧没有等价字段,只能按字符估(CJK 一字算一个、其余每 4 字符算一个)填 completion_tokens。DSH 底部的 tok/s 算法是 outputTokens / 生成秒数,没有这个数就永远是 0,所以宁可给一个诚实的估计值 —— 反正它只是给 UI 一个好读数。
usage 里另外挂了三个字段说明来源:qoder_context_ratio、qoder_duration_ms (两者是上游真值)、qoder_tokens_estimated: true(明说 token 数是估的)。
计量仍然只看 qoder_credits。 它是上游直接下发的真实扣费,跟 token 估值无关。
v0.4.6
设置页的「接入模型路由」现在也会勾上图片能力 —— 之前它会把图片那一栏抹掉。
v0.4.2 那个一键接入的生成器里,模型的 input 是写死的 ["text"](当时网关确实不支持图片)。v0.4.5 打通图片之后,这个写死值就成了坑:
- 新用户点一次「接入模型路由」,拿到的是纯文本配置,图片用不了;
- 已有用户点「覆盖重写」,会把自己已经勾好的图片能力冲掉。
实测确认过这个破坏性:覆盖前 profile 里 16 条 - image,覆盖后只剩 2 条(cun 那两个), qoder 的 14 条全没了。
现在生成器里多了一份 VISION_MODELS 名单,按 v0.4.5 的实测结果给模型声明
input: [text, image];MiniMax-M2.7 仍按纯文本。名单和 README 里那张实测表同源,
上游加新模型时两处都要补。
改了 host 半边(lib/index.mjs)之后要重启 DSH。
patchReload: "live" 实测没有重新加载它 —— 文件换了、端点还在跑旧逻辑,表现就是"代码明明改了、行为没变"。别只靠热重载判断。
v0.4.5
图片能用了 —— 之前的判断是错的,通道其实支持。
早期版本把图片块压成占位符,后来又改成用 unsupported_content 拒掉整条请求,理由是「网关最终是 CLI 的 -p <prompt>,没有多模态入口」。这个理由漏了一个参数:CLI 有 --attachment <file>(--help 里一直写着)。实测 Qoder CLI 1.1.35:让 Qwen3.8-Flash 看一张写着 "42" 的图,它准确答出 42。
改动:
- 请求里的图片块(data:image/...;base64,...)落成临时文件,经 --attachment 传给 CLI;请求结束后无论成败都清掉(实测残留 0)。
- 远程 http(s) 图片 URL 跳过 —— 上游未必取得到,静默失败比不传更糟。
- 只发图、不带文字时补一句兜底 prompt,否则会被当成空请求拒掉。
逐个模型实测了视觉能力(同一张写着 42 的图,问它看见什么数字):
| 结果 | 模型 |
| 支持 | Qwen3.8-Max、Qwen3.8-Flash、Qwen3.7-Max、Qwen3.7-Plus、Qwen3.7-Flash、DeepSeek-V4-Pro、DeepSeek-Flash、GLM-5.3、GLM-5.3-Flash、GLM-5.2、Kimi-K3、Kimi-K2.8-Preview、Auto |
| 不支持 | MiniMax-M2.7 —— 上游明确回「当前模型不支持查看图片文件」 |
(qoder-auto 和 auto 是同一个上游模型,一并算支持。)
profile 里据此给这 14 个模型声明了 input: [text, image],MiniMax-M2.7 保持 [text]; provider 级 defaultInput 仍是 [text],新加的模型默认按纯文本处理,要开图片得显式写。
这跟第三方实现 @jischeng/pi-provider-qoder 的表不一致 —— 那张表标 DeepSeek-V4-Pro、 Qwen3.7-Plus 等只支持 text。差异来自通道:那张表走官方 openapi,我们走 CLI 的 --attachment,后者对所有模型都能传。以实测为准。
v0.4.4
修掉「模型只会思考、不动手」的第二个根因:历史里的 DSML 污染。
有些模型(DeepSeek 系为主)会用自己的原生工具调用标记 —— 两个全角竖线(U+FF5C)夹住 DSML 字样,后面跟 invoke / parameter 标签。这不是我们教的,是模型的"母语",DSH 自己的协议也是这个形状,模型在多轮历史里见过就照着学。
危害在于自我强化:模型一旦这样输出一次,网关认不出 → 整段当正文发给 DSH → DSH 把它记进助手历史 → 下一轮历史喂回去,模型看到"我上次就是这么调的",继续用。几轮之后再也回不到约定格式,用户看到的现象就是"只会思考、不会编辑"。
实测证据:出问题那次会话的历史里有 10 处 DSML,而 DSH 的 system prompt 里一处都没有—— 雪球是从历史滚起来的,不是 system 教的。
两道防线:
- 治本:flattenMessages() 把历史里 assistant 消息的 DSML 重写成约定格式
(rewriteDsml())。喂回去的历史干净了,雪球滚不起来。实测:含 DSML 的历史经改写后,
发给 CLI 的 prompt 里 DSML 归零、约定标记出现。
- 治标:createToolCallExtractor() 的状态机同时认两种开始/结束标记,当轮万一还是老格式也能解析出来。孤立的收尾标记(/calls 之类)会被抹掉,不会漏进正文。
协议段里也补了一句:不要使用全角竖线包裹的 DSML 标记、或任何 XML 风格的 invoke / parameter 标签 —— 那些格式不会被识别,只会被当成普通文字显示给用户。
排查要点:这类问题不能用短 prompt 复现。请求历史越干净越正常,只有被污染的多轮历史才会翻车 —— 所以「我这边重放同样的请求是好的」并不能否定用户的报告,得去翻 ~/.qoder-cn/logs/runs/<id>/manifest.json 里的真实 prompt。
v0.4.3
补上 v0.4.2 漏掉的两个字段 —— 一键接入生成的 provider 原先比你手工配的少东西。
- reasoningEfforts:给 qwen3.8-max、qwen3.8-flash、glm-5.3 三个模型补上推理强度档位(off / low / medium / high)。网关侧本来就是无条件转发的(把 --reasoning-effort 原样交给 Qoder CLI),但 DSH 的档位下拉框只有声明了 reasoningEfforts 才会出现 ——漏了它,这个功能在界面上就等于不存在。
- defaultInput: [text]:provider 级默认输入类型,模型没单独写 input 时兜底。
- 其余 12 个模型不声明档位:给不支持的模型声明,用户选了会一路传到 CLI,行为不确定。
名单按实测配置来,上游加新模型时这里要跟着补。
如果你在 v0.4.2 点过一次「接入模型路由」,重新点一次「覆盖重写」把字段补回来。
v0.4.2
设置页多了一个「接入模型路由」按钮 —— 不用再手抄那段 60 行的 yaml 了。
- 背景:DSH 不会自动发现网关上的模型,必须在 profile 层显式声明 provider。
原来只能手抄,15 个模型 × 4 行,而且同一个 id 的行是整体覆盖不是合并 ——用户照抄时很容易把自己别家中转站的 provider 一起冲掉。
- 现在:插件读网关的 /v1/models 拿到真实模型列表,走 DSH 官方的 settings.mutate (就是「设置 → 模型」页自己用的那条路)把 providers.qoder 写进去。
ops 是路径寻址的 —— applyPathOp 只改 path 指到的那一个键,兄弟 provider 一个字节都不动。
- 凭证顺手补上:QODER_RELAY_KEY 缺失时写一个占位值(网关不校验 key,但 llm-pi-ai 解析不到这个引用会抛 MISSING_CREDENTIAL,表现为能选中模型、一发消息就失败)。
- 边界都做了保护:providers.qoder 已存在时默认拒绝覆盖,要显式确认;写入前取 revision 做乐观锁,你在模型页同时编辑会被拒绝而不是被盖掉;网关没起来、llm-pi-ai 行不存在、describe() 返回异常值,都降级成清楚提示而不是抛异常。
- 手工方式仍然有效,见「启用模型路由」一节。
v0.4.1
装在 E 盘 / F 盘等非常规盘符的用户修好了 —— 之前 host 半边根本认不出安装位置。
- 探测逻辑合并成一份。原先 lib/index.mjs 和 server.mjs 各写了一遍安装位置探测,且已经漂移:host 那份只认 C: D: 前缀,网关那份认 C-F。装在 E 盘时, host 先探测失败就直接放弃拉起网关 —— 而网关自己本来认得 E 盘,根本没机会运行,用户只看到「找不到 Qoder CN 安装位置」。现在两处共用 lib/discover.mjs,盘符 A-Z 全枚举,这类漂移从结构上消失。
- config.json 不再形同虚设。它一直被 config.example.json 暗示存在、server.mjs 也一直在读,但 host 半边从来没读过 —— 于是形成死锁:host 探测失败 → 不启动 server → server 永远读不到 config.json → host 下次依旧失败。现在 host 也读它,优先级 cordis 注入 > config.json > 默认值,且空字符串不会覆盖下层配置。
- 报错能照着做了。原来只有一句「找不到 Qoder CN 安装位置」;现在直接列出三种指定方式,并给出可原样粘进 config.json 的 JSON 片段。
- 顺带统一了 sdkRoot 的探测顺序(显式配置 → 环境变量 → 从 exe 推导)。
v0.4.0
模型现在能调用 DSH 的工具了 —— 读写文件、执行命令、检索代码,跟别的模型一样。
图片输入按「不支持」处理,不再假装能看。
前者是这一版的主线,后者是配套:网关最终喂给上游的是一段文本 prompt,没有多模态入口,所以每个 Qoder 模型都该像其他纯文本模型那样被 DSH 当成"看不见图"。
两处一起改:
- profile 的 llm-pi-ai 行里,qoder 的 15 个模型都显式声明 input: [text], provider 上再挂一个 defaultInput: [text] 兜住将来新加的模型。DSH 于是会在发请求前就以 UNSUPPORTED_CONTENT 拒绝带图的轮次。
- 网关入口也拦一道:请求里出现图片块直接 400 unsupported_content。原来
contentToText() 会把图片压成 [图片已省略],模型于是对着一个看不见的图
给出"读过了"的回答 —— 那种静默降级比报错更坏。
在此之前,用 Qoder 的模型只能聊天:让它改文件,它只会把内容打印出来。原因有两层:
1. 网关从没读过请求里的 tools[],也从没产出过 tool_calls[]。DSH 把工具列表发过来直接被丢掉,模型根本不知道有工具这回事。
2. 上游也不接受 OpenAI 的 function calling —— Qoder worker CLI 是个自带工具的 agent,它的 --print 协议只吐 thinking / text 块,没有工具通道可以原样转发。
做法是一层文本协议桥(都在 server.mjs):
- 入方向:tools[] 渲染进 system prompt,约定模型用
<tool_call>{"name":"..","arguments":{..}}</tool_call> 表达调用;
- 出方向:createToolCallExtractor() 把该块从正文流里实时剥出来,翻译成 OpenAI 的 tool_calls 增量,finish_reason 相应变成 tool_calls。
工具的执行权仍然留在 DSH:没放开 CLI 自带的工具(--tools '' 保持原样)。走 --tools default 会让 Qoder 自己动手改文件,DSH 既看不到工具卡片,也没有审批与回滚。
提示措辞必须够硬(踩过的坑):第一版协议段写的是「你能调用宿主提供的工具」,结果模型转头去试 Qoder CLI 自带的工具链,拿回一串 Tool not found 和 MCP 权限报错,然后告诉用户「当前环境没有注册宿主工具」,完全不理文本协议。改成显式否认原生工具(「本环境没有给你注册任何原生函数工具,也没有可用的 MCP 工具,你对工具的所有调用都必须用下面的文本块表达」)之后,同一个模型、同一句提问,立刻稳定输出协议块。
另外两处必要改动:
- flattenMessages() 现在认 assistant.tool_calls 与 role:"tool"。原来工具结果被当成一条普通 User 消息,模型看到的是「用户凭空说了句话」,于是把同一个工具再调一次 ——多轮必然死循环。
- 子进程参数改走临时文件(QODER_RELAY_ARGS_FILE),不再塞进环境变量。
system prompt 加上工具 schema 轻松上万字符,会撞穿 Windows 进程环境块 32767 字符的上限,spawn 直接失败。
上游生态里有三个干扰源必须关掉,只关一个不够
这三条都是线上翻车才挖出来的,而且单独测任何一条都可能"看起来没问题" ——抖动率在 20%~60% 之间浮动,短 prompt、小样本全绿说明不了任何事。
1. serena MCP(~/.qoder-cn/settings.json 的 mcpServers)。它的 instructions 会跟我们的文本协议抢注意力,而这件事只在 system prompt 长的时候才暴露:短 prompt 下模型懒得验证、直接照做协议;真实 DSH 的 system prompt 一长,模型就转头去试 serena 的工具,撞上 Permission confirmation required but no interactive handler is available 之后干脆放弃动手,回头告诉用户「当前环境没有可用工具」。更致命的是协议里写着「本环境没有任何可用工具」—— 这句话在 serena 实际可见时是假话,被模型当场验证之后整段协议都不再可信。--strict-mcp-config / --mcp-config '{}' 都挡不住(那只管 mcp.json 那层),要用 --setting-sources project:serena 定义在 user 源里,实测 user 源 → 1 个 MCP 服务器,project / local 源 → No MCP servers configured。
2. qoder-context 插件(user scope,--setting-sources 挡不住它 —— plugins list 里仍然显示 Scope: user / Status: enabled)。它通过 user-prompt-submit 与 qoder-context-prompt 两个钩子介入 prompt 提交流程(~/.qoder-cn/logs/qoder-context.log 里能看到 hook request deferred until ...)。要用 --settings '{"enabledPlugins":{"qoder-context@qoderapp-bundler":false}}' 显式关掉,关掉后 plugins list 才会显示 Status: disabled。
3. CLI 的会话持久化。它默认把每轮写进 ~/.qoder-cn/projects(本机已有 393 个文件)。
网关自己就是无状态的(多轮由 flattenMessages() 拍平后整段送进去),这层持久化只会让旧会话串进新请求,用 --no-session-persistence 关掉。
登录凭证在 ~/.qoder-cn/.auth,不属于 setting source,切源后照常可用(已实测推理正常)。
参数顺序是正确性问题,不是风格问题
-p <prompt> 必须排在 argv 最前面,其余选项一律后置。只要 --system-prompt 出现在它之前,CLI 就有约 2/3 的概率收不到 prompt:模型回一句「我没有看到具体的任务内容,只有环境初始化信息」,而 ~/.qoder-cn/logs/runs/<id>/manifest.json 里 argv 明明是完整的。
实测对照(同一套参数,只改顺序):--system-prompt 在前 → 2/6 正常;-p 提到最前 → 6/6,再加上全套干扰源开关连跑 12 次仍是 12/12。
能力声明补齐到和其他路由一样
profile 里 15 个模型现在都有完整声明,每个值都有出处:
| 字段 | 值 | 出处 |
| contextWindow | 200000 | CLI 自己的运行日志:model=qfmodel ... context_window=200000(qmodel_38max 也出现过 128000)。第三方 @jischeng/pi-provider-qoder 给 CN 标的是 1M,但那是官方 openapi 路径的取值;我们走 CLI,以 CLI 实测为准 |
| maxTokens | 65536 | CLI 支持 --max-output-tokens,网关已转发 DSH 的 max_completion_tokens;这个档位与 cun 路由一致,不是上游公布的硬上限 |
| input | [text] | 网关是纯文本通道(CLI 的 -p prompt,没有多模态入口) |
| reasoningEfforts | 只有 Qwen3.8-Max / Qwen3.8-Flash / GLM-5.3 | 实测 --reasoning-effort 对思考长度确有影响;其余模型照样吐思考链,只是不提供档位选择 |
配合这些声明,网关开始转发 DSH 的 reasoning_effort 与 max_completion_tokens (→ CLI 的 --reasoning-effort / --max-output-tokens)。
v0.3.4
设置页的版本卡片现在能一键更新了。
原来只做「检查更新」,发现新版本后要用户自己去插件页动手。现在卡片多一个「立即更新到 vX.Y.Z」按钮,点击直接走 DSH 官方的 pluginManager.installBundle()。
实现要点(都在 host 半边 lib/index.mjs):
- 服务不能直接取值。webServer / pluginManager 都可能比本插件晚就绪,直接 ctx.pluginManager 会拿到 undefined。必须用
ctx.inject(["webServer", "pluginManager"], cb),等服务可用时才跑回调
—— 这是官方 dsh-plugin-manager 的用法。
- 路由注册在 host.effect() 里,返回值即 teardown,热重载不泄漏。
- 端点是 POST /qoder-relay/update,挂在宿主 Web 服务上(不是网关 8788),所以客户端用原生 fetch 而不是网关的 api()。
installBundle 走的是 dsh plugin 同一套 pnpm。如果 profile 里有别的依赖用浮动地址(如 heads/master.tar.gz),pnpm 的完整性校验会连带让本次安装失败—— 那是那些依赖的问题,与本插件无关。卡片会如实显示失败原因。
v0.3.3
真流式转发,不再等上游跑完一次性吐出。
原来 runQoder() 把 CLI 的 stdout 整个攒进变量,child.on('close') 才 resolve,网关再切成 64 字符分片瞬间发完 —— 首字节延迟等于全部生成时间。
实测同一 prompt:9.9 秒里前 9 秒零输出,然后 16 条 SSE 瞬间喷完。
- runQoder() 增加 onEvent 回调:按行切分 stdout,每收到一条完整 JSON 立刻交给下游。无 onEvent 时行为与原来完全一致。
- 新增 createBlockAggregator():把 content_block_start / content_block_delta 实时翻译成增量文本,按上游真实到达顺序吐 delta,因此 contentIndex 的推进天然正确。
- 请求主流程:流式时提前开 SSE 头,边收边发;收尾只补 finish chunk。
实测事件数由 16 增至 166,reasoning/content 逐词到达。
首字节 8.9s 是 Qoder 上游冷启动固有延迟,与本次改动无关。
v0.3.2
修复:思考链被整个丢弃。
网关起 CLI 时用的是 --output-format stream-json,但没带 --include-partial-messages。Qoder 的 assistant 事件是块级的 ——一条 assistant 只带一个 content 块,先全部 thinking、再全部 text。
原来的 extractResult() 只挑 c.type === 'text' 的块拼结果, thinking 块从头到尾没人读,于是思考内容全丢。
改动三处(都在 server.mjs):
1. runQoder() 的 cliArgs 补 --include-partial-messages。
2. extractResult() 改用 collectBlocks() 按 type 聚合所有块,
返回 { thinking, text } —— 不是"取第一条",否则多轮 thinking 块会被吞第二次。
result.result 为空时(流被截断/超时)也用聚合到的块兜底,不再整轮报错。
3. 流式分支先发 reasoning_content chunk、再发 content chunk (保持"先思考后回答"的块顺序,pi-ai 的 contentIndex 靠这个推进);首块前补一条 : qoder-relay upstream warming 注释行做心跳。
非流式在 message.reasoning_content 显式披露,exposeReasoning: false 可关。
为什么下游接得住:DSH 的 provider 层走 @earendil-works/pi-ai 的 openai-completions 适配器,它按 reasoning_content → reasoning → reasoning_text 取第一个非空字段,翻成 thinking_delta 给 UI。
管道下游完好,断点只在网关。
遗留:usage.input_tokens / output_tokens 恒为 0 是 Qoder 上游不返回 token 计数,与本次无关;计量仍走 qoder_credits。
v0.3.1
检查更新改用 releases.atom,摆脱 REST API 的匿名限流(60 次/小时)。
v0.3.0
设置页加「检查更新」卡片。
v0.2.1
修复推理路径:runQoder() 之前用了空的 electronExe,导致所有推理 502。
v0.2.0
首发。
装完先做这一步
重启 DSH(完全退出,托盘也退,再打开)。
然后打开 设置 → Qoder CN 那个 tab,点「登录 Qoder」——会给你一个设备码链接,浏览器打开授权后即可。不用敲任何命令。
重启 DSH(完全退出,托盘也退,再打开)。
然后打开 设置 → Qoder CN 那个 tab,点「登录 Qoder」——会给你一个设备码链接,浏览器打开授权后即可。不用敲任何命令。
它做了什么
Qoder CN 桌面端(默认装在 D:\Qoder CN,其他盘符也能自动探测)内部打包了完整的 headless Agent CLI:
- resources/app.asar.unpacked/node_modules/@qoder-ai/qoder-cn-agent-sdk/dist/_worker/qoder-worker-runtime.obf.mjs
- qoder-worker-runtime 1.1.35 / qoderclicn 1.1.35
让 Electron 主程序以 ELECTRON_RUN_AS_NODE=1 充当 Node 24.18.0,就能用官方 CLI 协议驱动它,复用同一套订阅额度 —— 不需要逆向签名算法,也不触碰桌面端的 DPAPI 凭证。
两套凭证是隔离的
| | 位置 | 谁在用 |
| 桌面端凭证 | %APPDATA%\com.qodercn.app.stable\auth.v1.dat(DPAPI) | Qoder 桌面端 GUI |
| CLI 凭证 | ~/.qoder-cn | 本插件(网关) |
worker 运行时不读 auth.v1.dat(实测 auth.v1 命中数为 0)。
所以桌面端和 CLI 可以各自登录、同时在线,互不干扰。
Qoder 桌面端不用开着
网关 spawn 的是 Qoder CN.exe 这个文件当运行时,跟 GUI 进程无关。
实测:GUI 完全退出(0 进程)时,网关照常启动、推理照常返回。
唯一的要求:别删 D:\Qoder CN 目录。
检查更新
设置 → Qoder CN → 版本 卡片会自动检查 GitHub 上的最新 Release。
- 有新版本时显示 当前版本 → 新版本,并给出可直接粘贴的依赖行
- 可展开查看该版本的更新说明
- 「打开发布页」跳到 GitHub Release 页面
检查结果缓存 10 分钟,避免频繁请求。
数据来源:releases.atom,不是 REST API
检查更新默认走 releases.atom(GitHub 的 Release RSS),原因:
| 来源 | 限流 | 内容 |
| api.github.com/repos/.../releases | 匿名 60 次/小时(按 IP) | 完整 |
| github.com/.../releases.atom | 无(走网页 CDN) | tag / 发布时间 / Notes 都有 |
atom 里的 tag 从 <id>tag:github.com,2008:Repository/<id>/<tag></id> 取, Notes 是 HTML,剥标签后使用。只有在 atom 解析失败时才回落到 REST API。
返回体里的 via 字段告诉你这次走的哪条路("atom" 或 "api")。
为什么由网关代理而不是浏览器直连
浏览器半边 fetch GitHub 会被跨域拦截(与登录端点同一问题),所以由网关代理。
手动检查 bash
curl http://127.0.0.1:8788/update/check
curl "http://127.0.0.1:8788/update/check?force=1" # 忽略缓存
结果缓存 10 分钟。
设置页能做什么
设置 → Qoder CN
- 登录:一键发起设备码登录,显示授权链接与实时进度
- 状态:真正跑一次 --list-models 判定可用性(不靠猜),区分「找不到安装」和「未登录」
- 模型:列出当前账号可用的全部模型
- 网关地址:显示 OpenAI 兼容端点,方便接其他客户端
- 模型路由:一键把网关的模型写进 profile 配置 —— 走 DSH 官方的 settings.mutate,只改 providers.qoder 这一项,不动你已有的其他 provider。已存在时默认不覆盖。
改完重启 DSH,模型就出现在选择器里。
15 个模型
auto、qoder-auto、deepseek-v4-pro、deepseek-flash、qwen3.8-max、qwen3.8-flash、 qwen3.7-max、qwen3.7-plus、qwen3.7-flash、glm-5.3、glm-5.3-flash、 glm-5.2、kimi-k3、kimi-k2.8-preview、minimax-m2.7
计量:只看 qoder_credits
上游不返回 token 计数,prompt_tokens / completion_tokens 恒为 0。
真实额度扣费在 usage.qoder_credits(上游直接下发)。
实测同一句 "Say OK" 的成本:
| 模型 | credits |
| DeepSeek-Flash | 0.011 |
| Qwen3.7-Flash | 0.067 |
| Kimi-K3 | 5.15 |
差 460 倍。 日常用 flash 系列,贵的省着用。
架构
DSH ──挂载──> dsh-qoder-relay ├─ lib/index.mjs host 半边:网关生命周期 + 登录服务 └─ lib/client.js 浏览器半边:设置页 UI │ ├─ spawn "Qoder CN.exe" (ELECTRON_RUN_AS_NODE=1) │ └─ qoder-worker-runtime.obf.mjs(官方 headless CLI) │ └─ gateway.qoder.com.cn ← 你的订阅额度 │ └─ 网关监听 127.0.0.1:8788 → llm-pi-ai 路由接入
文件
| 文件 | 作用 |
| lib/index.mjs | host 入口:网关生命周期 + qoderRelay 服务(登录控制) |
| lib/client.js | 浏览器入口:设置页 React 组件 |
| server.mjs | OpenAI 兼容网关 |
| lib/discover.mjs | 安装位置探测:host 与网关共用的唯一一份实现(v0.4.1 起) |
| shim.mjs | 以 Electron-as-Node 启动 Qoder worker;参数经 QODER_RELAY_ARGS(JSON) 传入 |
| cordis.patch.yml | 一行 insert,让插件管理器能按 id 定位并开关 |
为什么 client.js 是手写的
DSH 的 client bundle 契约很简单: js
window.__ModuleLoader__.load({
id: "<包名>",
factory: (require) => { / ... / return exports }
});
react / react-dom 由加载器提供,不需要打包。所以手写纯 JS + React.createElement(不用 JSX)就够,省掉 tsdown + vite 整套构建链。
注意:window.__ModuleLoader__.load( 必须是文件第一个语句,注释要放在
factory 内部 —— 加载器在执行时立即读取。
改了代码怎么办
nodeLinker: hoisted 是复制不是链接。改完必须同步并重启: powershell $src = '<你的插件源码目录>' $dst = "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-qoder-relay"
foreach($f in @('lib\index.mjs','lib\client.js','server.mjs','shim.mjs','package.json','cordis.patch.yml')){
Copy-Item -LiteralPath (Join-Path $src $f) -Destination (Join-Path $dst $f) -Force
}
然后重启 DSH。
关键设计点(改代码前必读)
| 关注点 | 做法 | 为什么 |
| 端口占用 | 先探 /health,活着就复用 | 宿主重启常见「上一个实例还没死透」,重复 listen 会 EADDRINUSE |
| 重复挂载 | generation 计数器 | profile 是 patchReload:"live",新 fiber 先建旧 fiber 后拆,不做代际判定会「新的刚装好又被旧的拆掉」 |
| 升级兼容 | 不写 peerDependencies | 没有 peers 时版本预检返回 undefined 被绕过;写了每次 DSH 升级都报不兼容 |
| 清理注册 | ctx.effect(() => { ...; return teardown }) | DSH 里 84 个插件用它。写成 ctx.on("dispose", ...) 是错的 —— 那只是事件订阅 |
| 子进程终止 | taskkill /PID x /T /F(spawnSync) | Windows 上 Electron 子进程常不在同一进程组;unref 的定时器在宿主退出时永不触发,会留孤儿 |
| 路由位置 | profile 层 cordis.patch.yml | 搬到 home 层会让「设置 → 模型」变空壳 |
排错
| 症状 | 修法 |
| 设置里没有 Qoder CN tab | 重启 DSH;确认 dsh.client 声明在(见 package.json) |
| 状态显示「找不到 Qoder CN 安装位置」 | 先确认已装 Qoder CN。v0.4.1 起各盘符(A-Z)自动枚举;仍找不到就在插件目录建 config.json,写 {"electronExe":"E:/Qoder CN/Qoder CN.exe"} |
| 状态显示「未登录」 | 点「登录 Qoder」,浏览器完成授权 |
| 模型是死的,请求超时 | 看 :8788 是否 LISTEN;检查插件开关 |
| 退出 DSH 后 :8788 还被占 | taskkill /PID <pid> /T /F |
日志:%APPDATA%\DSH Desktop\logs\host\dsh-<日期>.log,搜 qoder-relay。
已知限制
- 不支持图片输入:整条通道最终是 CLI 的 -p <prompt>,没有多模态入口。
profile 里每个 Qoder 模型都声明了 input: [text],DSH 会在发请求前就用 UNSUPPORTED_CONTENT 拒绝(聊天贴图、工具结果里的图片都一样);万一有调用绕过那一层,网关自己也会用同一个错误码拒掉整条请求,不会把图片静默丢掉。
- 工具调用是文本协议桥,不是上游原生 function calling。模型必须按约定格式输出 <tool_call> 块才会被识别。解析器做了容错(Markdown 围栏、尾随逗号、被切开的标签、流被截断时尽力解析),但模型偶尔仍会不守格式 —— 表现为该调工具却没调、直接把答案写在正文里。提示措辞对这种偏差非常敏感,改动 buildToolSystemPrompt() 后务必重测。
上游的 serena MCP 已经在启动参数里关掉(--setting-sources project);如果哪天它又出现,模型会先被它带偏、再因为协议"撒谎"而整体不信。
长 system prompt 是这个 bug 的必要条件,短 prompt 测不出来。
- 每次请求起一个 worker 进程(约 1s 冷启动)。
- 会话无状态:多轮由网关把 messages 拍平成对话文本。
- 上游不返回 token 计数,只能用 qoder_credits 计量。
- 复用的边界:若 DSH 崩溃未跑 teardown,残留网关会被下次挂载「复用」。端口固定的场景无害,但换端口时复用的会是旧配置进程。
两个让 DSH 直接打不开的坑(血泪)
这两条都表现为 "Renderer boot failed for N plugin(s)",DSH 进恢复模式,而且 client Loader 不给任何错误信息,只能靠对比可用样本定位。
坑一:host 端用 ctx.set 注册服务 js
ctx.set("qoderRelay", service) // ✗ 抛 "cannot set property without provide"
ctx.provide("qoderRelay", service) // ✓ 正确 API
而且这个错发生在 host 端 apply() 里,会让整个插件激活失败,连带渲染进程起不来。不要用没验证过的 ctx API。
本插件后来改用 ctx.on("webserver/index-inject", ...) 注入全局变量 + 网关自己的 HTTP 端点,彻底避开了 DSH 的服务协议。
坑二:client 端必须导出 exports.inject js
exports.apply = apply;
exports.inject = ['slots', 'connection']; // ← 少这行就崩,且无错误信息
return module.exports;
注意两点:
1. 这是运行时短名('slots'、'connection'),不是 package.json 里 dsh.client.inject 那种包全名('@deepseek-ai/dsh-client-ui-settings')。
两者都要有,作用不同:前者是运行时等待的服务,后者是模块图依赖。
2. slots 是注册任何 slot 的必需项。少了它,加载器不知道要等 slot 注册表就绪。
定位方法:拿一个能工作的同类第三方包做对照。
本机可用的样本是 dsh-session-recycle-bin(同样是手写的 __ModuleLoader__ 壳),直接 diff 它的 exports 块即可。
验证手法(不用重启 DSH)
client.js 可以被独立模拟执行 —— 不需要真的浏览器: js let spec = null; const win = { __ModuleLoader__: { load(s) { spec = s; } } };
const requireStub = (n) => n === "react" ? require("react") : new Proxy({}, { get: () => () => ({}) });
new Function("window", "require", code)(win, requireStub);
const exports = spec.factory(requireStub);
// 检查 exports.apply / exports.inject
跑法(PATH 里没有 node,用 Electron 当 node):
$env:ELECTRON_RUN_AS_NODE = "1"
& "$env:QODER_HOME\Qoder CN.exe" <你的探针脚本>.mjs
改 client.js 前务必先跑这个,能避免让 DSH 进恢复模式。预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
ccccqiang/dsh-qoder-relay
把 Qoder CN 订阅额度接入 DSH 当模型用
相关插件 · 模型接入
API 中继安全审计toby-bridges/api-relay-audit
面向 AI API 中转与 LLM 代理的本地安全审计。
★ 897已汉化DSH
69.4
免费搜索插件DDDMUC/dsh-free-search
DeepSeek Harness 免费搜索插件 —— 无需 API key,零成本,多引擎可切换。 一个给…
★ 761已汉化DSH
68.7
免费联网搜索liustack/modsearch
在官方 App 里模型能联网,切到 API 就不能了。ModSearch 把联网补回来:网页搜索、X…
★ 863已汉化DSH
68.5
OpenCode 免费模型FishBottle7/opencode2dsh
在 DSHDeepSeek Harness里原生使用 OpenCode Zen 的免费匿名模型。
★ 379已汉化DSH
67.8
AI 模型网关tingly-dev/tingly-box
Tingly Box Web UI 演示
★ 373已汉化DSH
67.4
WorkBuddy 账号池XDTrees/dsh-xdpool
把你能白嫖的模型额度接进 DSH 同一个池:WorkBuddy 多账号自动容错、十三家扫码登录、76…
★ 359已汉化DSH
67.1