dsh-qoder-link
v1.0.8
Published
DeepSeek Harness LLM adapter for Qoder CN subscriptions, built on the official @qodercn-ai/qodercn-agent-sdk. Reuses the local qoderclicn login, and exposes a fully configurable settings page (per-model context window, session pool, timeouts, model deny l
Maintainers
Readme
dsh-qoder-link
把 Qoder CN 订阅接入 DeepSeek Harness(DSH) 的 LLM 适配器插件。
- 基于官方 CN Agent SDK
@qodercn-ai/qodercn-agent-sdk, 不逆向协议、不伪造客户端身份。 - 零凭据:复用本机
qoderclicn登录态,不需要 API key。 - 带独立设置页:模型逐个启用/关闭、逐个指定上下文窗口,改动即时生效。
为什么需要它
上游 @jiamingzang/dsh-llm-qoder 只依赖国际版
SDK @qoder-ai/qoder-agent-sdk,而那个包的品牌是编译期硬编码的:
var xr = "global", zn = xr === "cn" // ← 永远选不中 CN 分支
v = zn ? { cliCommands: ["qoderclicn"], configDirName: ".qoder-cn" }
: { cliCommands: ["qodercli"], configDirName: ".qoder" }于是只装了 qoderclicn、登录态在 ~/.qoder-cn 的机器上,它永远找不到 CLI,模型选择器里
什么都不出现。本插件改用官方 CN 包(brand: cn),从根上解决。
安装
第一步:装插件
点侧边栏的 插件 → 添加插件,把 dsh-qoder-link 填进输入框,然后点「安装」。
也可以换成另外两种来源:
github:k1-h2/dsh-qoder-link(或https://github.com/k1-h2/dsh-qoder-link)——装仓库的最新提交- 本地目录路径——装你自己改过的版本
安装源保持默认即可(中国大陆镜像源),它只决定插件及其 npm 依赖从哪里下载。
SDK 声明了安装脚本(下载它自己的 worker runtime),pnpm 默认不执行,所以界面会弹出
「需要允许安装脚本」——点「允许这些脚本并重试」就行。授权记在当前 profile 里,只问这
一次;不授权也能用,SDK 会回退到 PATH 上的系统 qoderclicn。
装完按界面提示重启 DSH(「已安装,下次启动后加载」)。重启后设置里出现 Qoder CN 一项。
第二步:装好并登录 Qoder CN CLI
插件不持有任何凭据,内层会话直接复用本机 CLI 的登录态(SDK 的 qodercliAuth()),所以 CLI
必须装好、登录,模型列表才有内容。
设置 → Qoder CN,页面底部「Qoder CN CLI」分组点 一键配置:跑的就是随包发布的
scripts/setup-qoderclicn.bat——缺 CLI 就装,未登录就拉起浏览器
授权,输出实时打在页面上;跑完 1 秒自动收起,点「查看输出」可以再翻出来,同时模型列表自动刷新。
重新登录只重跑授权那一步,用来换账号或补一次过期的登录。这一步只支持 Windows,因为脚本是
.bat;脚本自己的输出是英文——批处理里放中文时,cmd.exe 在没有控制台被调用(正是页面这种调用
方式)会算错文件位置,把半行当命令跑,详见 NOTICE。
不想点按钮,就自己按顺序执行——那个脚本做的正是这几步:
npm install -g @qodercn-ai/qoderclicn # CLI 本体,官网 https://qoder.com/cli
qoderclicn --version # 能打印版本号才算装上
qoderclicn login # 拉起浏览器,用 Qoder CN 账号登录
qoderclicn status # 列出 Username / User Type 即登录成功
qoderclicn --list-models # 列出订阅实际可用的模型- 也可以直接双击
scripts/setup-qoderclicn.bat:一次做完上面全部,结尾停住等你看输出。 login没有非交互参数,走浏览器授权;成功后凭证落在~/.qoder-cn/.auth(国际版是.qoder, 本插件只认 CN 这一份)。- 登录过期或换账号:页面上点「重新登录」,或者自己重跑一次
qoderclicn login覆盖旧凭证。 - CLI 自带升级:
qoderclicn update,装坏了用qoderclicn rollback。 status里没有Username、或--list-models拉不出模型,DSH 里的模型列表就一定为空—— 先修这一步,再查插件。
设置页
设置 → Qoder CN(与「通用设置 / 模型 / 内置插件」并列的独立一项)。整页就是一张模型清单:
模型 启用 上下文窗口
☑ Qwen3.8-Max · x0.5 ▢ [默认 · 200K ▾] (200K / 400K / 1M)
☑ Qwen3.8-Flash · 免费 ▢ [默认 · 200K ▾]
☐ Kimi-K3 · x1.4 ▢ [默认 · 200K ▾]- 列表来自账号实时目录,不是写死的:上游新增的模型自动出现且默认启用。这也是「关闭」用 拒绝清单而不是允许清单存储的原因——允许清单会把后来新增的模型静默藏掉。
- 倍率取自上游
priceFactor:0显示为「免费」;上游没给倍率的模型不显示后缀,不凭空补数。 - 窗口下拉列出上游为该模型声明的所有可选窗口,默认档跟随上游声明的会话窗口。 窗口越大,DSH 的自动压缩触发得越晚。
- 顶部有搜索、只看已启用、刷新列表、全部启用 / 全部关闭,并显示
已启用 N / 总数。
高级设置(可折叠,默认收起)收纳其余 10 个标量参数:分组名称、并发会话数、列表缓存、默认 思考档位、输出上限、三个超时(工具结果 / 模型列表 / 中断宽限)、旁路轮次、模型别名。每项带中文 说明,改过的项旁边会出现「恢复默认」。
Qoder CN CLI 分组在页面底部,管第二步那件事:「一键配置」「重新登录」两个按钮,加一块只在跑
的时候展开的输出区域——结束后 1 秒自动收起,「查看输出」可以随时再展开。模型列表拉不出来时,页面
直接红字说明是本机 CLI 没配置好,并把「一键配置」放在那句提示旁边。浏览器起不了本地进程,所以按钮
走的是宿主侧的 POST /api/llm-qoder-cn/setup(mode 只有 configure 和 login 两个取值),由
插件去跑随包发布的脚本。
只服务官方模型。 Qoder CN 也提供「账号自己添加的模型」(自带 API Key 的 BYOK 路线,上游另用
一条 qoder-byok 路由承载),但 DSH 这边用不上——插件只走订阅自己的目录,因此只注册一个 provider
路由 qoder-cn,并在目录层丢弃上游标记为 user/custom 的条目。模型选择器里因此只有一个
Qoder CN 分组,没有第二个自定义分组。
用 YAML 配置
设置页之外,也可以直接在 profile 的 cordis.patch.yml 里写:
- id: llm-qoder-cn
name: dsh-qoder-link
config:
displayName: Qoder CN (工作号)
maxSessions: 4
disabledModels: [mmodel] # 关闭的模型,不出现在选择器里
modelContextWindows:
qmodel_38max: 1000000 # 按模型指定窗口(token)常见问题
缓存命中一直显示 0%? v1.0.5 已修。根因不在映射:CLI 每轮都算出了真实用量,但在把用量帧
送出来之前会主动清零四个 token 计数(input_tokens / output_tokens /
cache_read_input_tokens / cache_creation_input_tokens),只留 credits 与
context_usage_ratio;它自带一个开关(env 目录里的 EXPOSE_TOKEN_USAGE),打开后才原样送出。
src/cli.ts 现在把这个开关随子进程环境一起交给 CLI——CN 包读
QODERCN_EXPOSE_TOKEN_USAGE,国际包读 QODER_EXPOSE_TOKEN_USAGE,两个都设,因为由哪个包应答
是查找决定的。
实测(本插件跑通,同一段前缀连续两轮):
| 轮 | CLI 报的 input_tokens | 其中 cache_read | 插件上报 | 界面 |
|---|---|---|---|---|
| 1 | 9 811 | 0 | 输入 9 811 / 输出 860 / 缓存 0 | 命中 0% |
| 2 | 10 686 | 9 805 | 输入 881 / 缓存读 9 805 / 输出 100 | 命中 92% |
第二轮那个 input_tokens 是整个 prompt(CLI 自己的 context_usage_ratio 就是
input_tokens ÷ 窗口),缓存的那部分含在里面,所以 meteredUsage() 把两个
cache 桶从 inputTokens 里减出去——DSH 的四个桶互不重叠,不减等于把缓存前缀算两遍。四个桶(含 0)
与 totalTokens 也必须齐全:harness 只在记账完整时展示每轮用量行,少一个桶又没有 totalTokens
佐证,整轮的用量详情都不显示。
哪个用量帧能信? CLI 会把用量记录挂在好几个帧上,
而只有一个是真的属于「这一次请求」:message_delta,即每次 provider 请求结束时发出的那一帧。
其余三个都是坑——message_start 与组装后的 assistant 消息里的四个计数恒为 0(请求没结束前
没有数);result 帧则是内层整轮的总和,把这个回合里每一次 provider 请求的用量全部相加。
一个宿主 step 只对应一次 provider 请求,但内层的一"轮"要等模型不再调用工具才结束——宿主工具每往返
一次就是一次新请求,全部发生在同一个内层回合里。所以 result 帧的数在长 agentic 运行后会是真实
prompt 的若干倍:实测一个让模型连调四次工具的回合,各请求分别用 608/705/771/837,result 帧报
2 921。若把这个数当成当前上下文,长跑结束后 DSH 的上下文计量会被顶到
~9.8M / 1M(100%)并一直挂着,直到下一次取样才回正(开发中确实这么踩过一次)。
现在占用样本只由 requestUsageSample() 提供:仅接受带真实计数(至少一个桶 > 0)
的 stream_event 帧,assistant 与 result 帧一律不采信。contextUsageRatio 仍是每请求的
prompt 比率,所以回退路径不受影响。
仍然退回估算的只有三种情况:CLI 版本早于这个开关、SDK 回退到进程内 worker transport(拿不到自定义
环境)、某一轮在结果帧之前就结束了。那时 usage() 按「真实用量帧 → CLI 的 context 比率 → 字符
估算」三级回退,上游一旦有数就立刻切过去。
上下文占用是按什么算的? 拿到真实计量时就是 provider 自己数的最后一次请求的 prompt:
inputTokens + cacheRead + cacheWrite,除以窗口。拿不到时,上游每帧仍带 context_usage_ratio
——它自己量出的「上下文用了多少」——本插件把它乘上窗口换成 token,再交给 DSH 的计量环。
这条回退路径上有个坑(v1.0.1 踩过、v1.0.2 修):这个比率的分母是 CLI 实际运行的窗口,
不是你配置的窗口。
CLI 只接受模型自己声明的窗口(见目录里的 availableContextWindows),配一个它不支持的窗口会被
静默忽略——例如给只支持 200K 的 auto 配 1M,CLI 仍按 200K 跑,若拿 1M 去乘就虚高 5 倍。
所以插件现在分两个窗口:给 DSH 的是你配置的值,给比率做分母的是 cliContextWindow()
(模型声明里匹配得上就用你的,匹配不上就用模型自己的)。同时窗口会经
resolveModel.parameters.contextWindow 真正下发给 CLI——在此之前它只告诉了 DSH,CLI 一直按默认
200K 跑,配了 1M 也白配。
比率本身是可信的:实测在 257/1007/2257 字符三档下,比率 × 窗口 与 CLI 自报的
getContextUsage().usedPercentage 在量化误差内一致,且随内容线性增长。注意 DSH 环上的总数
(inputTokens + cacheRead + cacheWrite)包含 qodercli 自身的开销——它自己的系统提示、skills
与工具定义,约 120–200 token。环里「系统提示词 / 工具定义 / 对话消息」三项是 DSH 自己按
4 字符/token 估的本地分类,合计会低于总数,这是正常的,不是虚高。
tok/s 为什么偏低? DSH 算 tok/s = 输出 token ÷ 解码耗时,而输出 token 由本插件上报。
v1.0.5 起这个数是 provider 自己数的 output_tokens(同一个计量开关),所以下面的估算修正只在拿不到
计量时生效——凡是模型生成的内容都必须计入,估算那一侧曾是两个原因,v1.0.3 与 v1.0.4 各修一个:
- 换算率(v1.0.3)。实测本后端的分词器:中文 1.77 字符/token、英文 5.20 字符/token,
而原先统一按
4 字符/token估,中文因此被低估 2.26 倍。现在按字符集分别计价。 - 工具调用参数(v1.0.4)。原先只统计正文与思考,工具调用的 name/arguments 一个字符都没算,
而 agentic 回合里输出的大头正是它——实测一个三次工具调用的回合中占 86% 的字符。DSH 自己的
计量器是把工具调用计价为
name + arguments的(dsh-token-meter的estimateContent), 漏掉它会让多步任务的 tok/s 低到十分之一量级。
实测效果:同一段中文回复 20.9 → 45.8 tok/s;一个工具调用回合 72.5 → 311.2 tok/s。
计数逻辑在 src/tokens.ts 的 TurnOutputAccounting,由 14 个单元测试钉住,其中一条专门断言
「工具参数必须计入」。
设置点了没反应、一直「保存中」? v0.6.0 已修。原因是 .volatile() 字段的改动不会重启
插件,而适配器在挂载时把配置抄进了自己的字段,于是永远按旧值作答——写入成功、磁盘正确、
插件读不到。详见下方「开发须知」。
回答完模型的问题、或工具调用结束后,有时等好几秒才继续,有时干脆不再回复? v1.0.6 已修。 根因是工具结果的配对状态被当成了"每轮"的,而它其实是"每会话"的。
qodercli 会用两个不同身份通知同一次工具调用:canUseTool 先拿到它自己的 tool_use id,而真正
需要宿主结果的那个 MCP tools/call handler 拿不到任何 id。两者顺序一致,靠一个 FIFO 配对。
实测(真实 CLI,1.1.68)这个 handler 会在它所属的那一轮已经结束之后才触发——宿主一看到工具
调用块就立刻把结果交回来,而 CLI 过几毫秒才派发 MCP 消息:
6198ms [endTurn] kind=tool-calls
6198ms [host] 没有 parked handler -> 缓冲在 key=call_80d292a7…
6217ms [mcp handler] ENTER | shift 到 toolUseId=undefined
6217ms [mcp handler] PARK 在 key=anonymous-1
14226ms [mcp handler] TIMED OUT after 8009ms而 resetTurnState() 恰恰在每轮开始时清空了那个 id 队列,于是把 handler 正要认领的 id 丢掉了:
handler 落到兜底键、park 满整个 toolResultTimeoutSeconds(默认 120 秒),宿主那条结果则永远
留在缓冲里没人读。为什么是"有概率":宿主工具慢(pwsh 跑几秒)时 handler 在轮次结束前就
park 好了,走的是另一条路;工具快(read、ask_user_question)时才输掉这个竞态。所以"问模型
问题"和"工具刚结束"这两类操作最容易中招。而 120 秒远超人的耐心,表现就是"一直不回复",只能停掉
对话重发——重发后模型重新发一次调用,这次竞态恰好落在另一边,于是"好了",但修的是运气不是 bug。
现在配对状态由会话持有,只在确实会取消派发的地方丢弃:轮次被 abort / 失败时丢掉这一轮没能
认领的通知(否则会把下一轮的 handler 挤错一个位置),会话销毁时整体释放。逻辑抽成了
ToolResultPairing,由 7 条单元测试钉住;其中"快结果必须交给轮次结束后才到达的 handler"一条
是会分辨的那条——在它与 claim 之间插一次 forgetUnclaimed()(即旧 resetTurnState() 在轮次
边界做的事),它就会 park 满超时并拿到 toolUseId=anonymous-1 的取消文本,与线上实测的失败
形态逐字一致。
同一个症状,换了一个原因? v1.0.7 已修。v1.0.6 之后日志里仍能看到 120 秒超时,但这次不是配对 丢了,而是超时用错了对象。
宿主要等这一轮的所有工具执行完,才在下一次模型请求里一次性带回全部结果。所以只要有一个工具
跑得久(构建、目录扫描、自带 300 秒预算的 pwsh),同批里早已完成的调用也得一起等——它的结果
明明已经就绪,只等着随下一次请求送达。旧实现从 handler 进入时开始计 120 秒,到点就把这次调用当成
"宿主没回"取消掉:
10:07:53 [mcp handler] PARK call_00_QFqbaum… (write,结果其实立刻就有)
10:07:53 [host] pwsh 开始执行(自身预算 300 秒)
10:09:53 [mcp handler] TIMED OUT after 120s ← 把 write 误杀
10:10:38 [host] 第一次请求到达,带着全部三条结果单工具同样会中:10-20 16:41 的一次 pwsh 超过 120 秒,16:43 被超时取消,而宿主当时还在执行它。
现在成对调用不再有墙钟期限:结果只可能随宿主的下一次请求到达,等多久取决于宿主工具,而不是
一个常数。真正能判定"这次调用没人会回答"的信号也正是那次请求本身——请求边界
(beginHostRequest())会立即结算宿主没有带回结果的已 park 调用,并把尚未触发的通知标记为已作废
(handler 之后到达时直接拿到取消结果,而不是再 park 两分钟)。只有完全没配上对的 handler
(拿不到 toolUseId,无法把结果与调用对应)才继续用 toolResultTimeoutSeconds 兜底,默认仍是
120 秒。
工具调用结束后整轮再也不回复? v1.0.8 已修。v1.0.7 拿掉了成对调用的墙钟,但漏了一种没人能 来救的情况:请求边界只结算"它运行时已经 park 好"的 handler,而边界之后才到达的通知它管不 到——如果那次调用宿主压根没见过,就永远不会有人结算它,而唯一能结算它的下一次请求边界,偏偏要等 这一轮先结束,这一轮又正卡在这个 handler 上。
触发它的是一次裸名工具调用。内层模型偶尔不写限定名(write 而不是 mcp__dsh-host__write),
qodercli 自己解析不到,就地判错并不等宿主继续跑它自己的循环——内层循环因此比宿主的轮次边界跑
前面一轮;它随即带限定名重试,而重试的工具块落在两次宿主请求之间的窗口里,会话当时没有可投递
的宿主流,块被丢弃,宿主从未见过这次调用,但 CLI 照样把它派发给了 MCP handler:
22:38:49.444 [cli] 模型以裸名 write 调用 -> Error: Tool "write" not found(就地失败,不等宿主)
22:38:49.506 [host] 宿主照常执行 write,文件确实写出了;22:38:52.812 开下一轮
22:38:52.786 [cli] 带限定名重试的工具块到达 -> 此刻没有宿主流 -> 丢弃(宿主从未见到)
22:40:37.687 [mcp handler] PARK call_2b54e8a6… ← 没有任何请求会为它带回结果
22:48:24.594 [user] 点停止 -> CLI 取消挂起的工具调用,轮次才解开(实测静默 572 秒)现在 claim() 在 park 之前先确认这次调用宿主确实见过(hostVisible:适配器真的把工具块投递
给宿主的那些 id)。宿主没见过的 id 立即拿到与请求边界相同的取消文本,不再空等;宿主见过的调用依旧
没有墙钟,pwsh 跑几分钟照样等它的真实结果。判别性的一条测试是「宿主从未见过的调用要立即失
败」——修复前它会 park 满 600 秒(测试超时失败),修复后立刻返回取消结果。
实现要点
- warm 会话:每个宿主 session id 对应一个常驻内层
query()子进程,对话延续与工具轮次都 发生在会话内部,超出maxSessions按插入序 LRU 淘汰。 - 工具桥接:宿主工具经进程内 MCP server 暴露给内层模型,qodercli 一次执行一个调用、宿主
一次回传整轮结果;配对状态(
ToolResultPairing)由会话持有而不是轮次,因为 handler 可能在 它那一轮结束之后才触发。成对调用等待宿主的下一次请求并由该请求结算;只有未配上对的 handler 受toolResultTimeoutSeconds兜底。宿主从未见过的调用(工具块落在两次宿主请求之间被丢弃, 而 CLI 仍派发了 MCP 消息)不 park,立即返回取消结果——否则这一轮没人能解开。 - 旁路请求:标题生成、压缩摘要走冷启动一次性调用,不占用 warm 会话。
- 真实计量:CLI 默认把用量帧里的 token 计数清零,插件在启动子进程时带上它的
EXPOSE_TOKEN_USAGE开关;总量、缓存命中与 tok/s 因此都是 provider 实测值,只有拿不到计量时才 按「context 比率 → 字符估算」兜底。占用样本只取自每次请求结束的那一帧(requestUsageSample(),见「哪个用量帧能信?」),内层整轮的合计值不参与上下文计算。 - 溢出可恢复:内层上下文超限按
CONTEXT_WINDOW_EXCEEDED上报,交给 harness 自动压缩, 而不是让整轮报废。
开发须知
改这个仓库前,有两点 DSH 行为必须先知道,否则会「毫无提示地坏掉」。
1. 字段不 .volatile(),设置页就永远不出现。 DSH 用 volatileForm(schema) 构造表单,它
只保留带 meta.volatile 的节点;字段全是非 volatile 的 object 会塌缩成 undefined,
describe() 随即 return [] 丢掉整个命名空间——插件照常加载、模型照常可用,但命名空间
从未下发到客户端。此外,DSH 的自动配置页渲染在「内置插件」分区内部,要独立顶层导航项就得
自带 client.js 注册 settings.section。
2. .volatile() 的值必须在「用的时候」读,不能在挂载时读。 一次只改 volatile 字段的写入,
loader 会把新值写进运行中插件已经持有的那个 Volatile 引用,然后跳过重启——apply()
不会再被调用(cordis-plugin-loader 的 Entry.update → _commitVolatile):
const pending = volatileOnly && this._commitVolatile() ? [] : changes
if (!pending.length && !force) return // 不重启,不重新 apply在 apply()(或构造函数)里把配置抄进自己的字段,就等于永久冻结。本插件的做法:
src/live.ts:Live<T>(值或 thunk)+readLive(),选项既接受普通值,也 接受读取实时单元格的 thunk。src/config.ts的liveConfig():每个字段返回 thunk,每次读取才从Volatile取值;resolveConfig()在它之上实现,两者语义不会漂移。- 适配器、会话池、模型目录一律按需读。
updateVolatile()只把新值写进同一个引用,所以 thunk 天然看到新值——不用重启,也不丢 warm 会话。 - 只有
displayName会被复制进选择器的分组目录,无法惰性化;插件监听loader/volatile-update,只在这个字段变化时用replace()就地重发。
test/live.test.ts 精确复现 loader 的就地提交并断言适配器在不重新
apply() 的情况下跟着变;用 0.5.0 的构建跑同一段代码会失败。
本插件的读写链路。 读走插件自建路由 GET /api/llm-qoder-cn/models(模型目录来自 CLI,进
不了配置表单);写走 ctx.configForms,与官方「模型」页共用带修订号的写入队列,并发编辑会被
拒绝而不是静默覆盖。宿主提交完成后才回应写入,页面因此先按新状态显示、随后核对,核对不
通过才报错;单次写入 20 秒未落定会放弃并解除控件禁用,避免一个卡住的调用锁死整页。
构建与测试
pnpm install # 首次会下载 qoderclicn worker runtime
pnpm run build:clean # 清掉 lib/ 再构建;tsc 出 .d.ts,tsdown 出 lib/index.js
pnpm test # vitest别用 pnpm build 验证改动。 它的命令是 tsc && tsdown:类型一错,&& 就短路,tsdown
根本不跑,于是 lib/index.js 保持旧内容——而 lib/ 才是 DSH 真正加载、npm 真正发布的东西。
如果只盯着源码或只跑测试,会误以为改动已生效。发布路径上的 prepublishOnly 用的是
build:clean(先删 lib/ 再构建),所以类型错误会让发布失败而不是发出陈旧包。
注意 pnpm build 与 pnpm run build 在 PowerShell 里可能给出不同的退出码(前者经
pnpm 的内置脚本别名,后者经 run)。判断构建是否成功请直接看 lib/index.js 的时间戳,
或跑 build:clean——不要只信退出码。
合规与风险
本插件通过官方 Agent SDK 复用你自己的 Qoder 账号登录态:不逆向私有协议、不伪造客户端身份 或请求头、不自动化领取签到积分,也不向任何第三方域名发送数据(只与 Qoder 官方服务通信)。
但请自行判断:
- 「以订阅额度驱动第三方宿主」是否符合你的 Qoder 服务条款,由你自行判断。 本项目无法替 Qoder 解释,也不保证账号不被限制。
- 上游接口与 SDK 随时可能变更,届时本插件可能失效。
- 请仅用于驱动你自己的账号,勿转售或超出个人合理使用范围。
许可
MIT。本仓库是 JiamingZang/dsh-llm-qodersdk
(MIT)的衍生作品,上游许可原文与版权声明保留在 LICENSE.original,
出处与改动清单见 NOTICE。
