dsh-pylon-workbuddy
把 workbuddy2api 网关接进 DeepSeek Harness(dsh) 的插件。
包名
dsh-pylon-workbuddy(家族前缀dsh-pylon-)。旧名dsh-workbuddy2api只作为历史 包名出现在下面的「npm registry」告警里 —— 那是已弃用的旧包名,不是当前包名。
装上之后:
- dsh 启动时自动拉起网关,退出时自动回收;端口上已有健康网关在跑则直接复用,不重复拉起,也不会去杀别人的进程。
- 把模型目录落成「设置 → 模型」里那份可编辑的 provider —— 首次启动时读网关
GET /v1/models,把每个模型的上下文窗口 / 输出上限 / 输入模态 / 思考档位按实测值写进llm-pi-ai.providers.workbuddy;此后只在你删空它时才会重建,你在模型页筛掉的模型不会被补回来。 - 7 条管理命令:
/wb2api-setup·/wb2api-status·/wb2api-provider·/wb2api-start·/wb2api-restart·/wb2api-login·/wb2api-account。 - 一条命令完成安装:
/wb2api-setup会取回网关可执行文件(校验 SHA256)、准备运行目录、引导登录、启动网关并同步模型 —— 不需要装 Go,也不需要 python3 / bash。
插件本身很薄:协议适配、账号池、加权选号、分级熔断、定时任务、登录与 token 刷新全部留在 Go 网关侧。它只负责两件事 —— 进程生命周期,和模型目录自动化。
它解决什么
直接用手工配置把 workbuddy2api 接进 dsh(写一段 llm-pi-ai.providers.workbuddy)也能用,但有几处手工负担:
| 问题 | 手工接法 | 本插件 |
|---|---|---|
| 模型元数据 | 每个模型的 contextWindow / maxTokens / reasoningEfforts / input 全手抄;网关增删模型必须手改文件 |
首次启动读 /v1/models,按实测值把整份清单写进那段 provider 配置 |
| 进程管理 | dsh 启动前必须另行常驻 wb2a-server.exe,退出也不联动 |
插件托管子进程,生命周期绑定 dsh |
| 获取网关二进制 | 装 Go 工具链 → git clone 上游 → go build |
/wb2api-setup 自动下载 + SHA256 校验 |
| 账号登录 | 上游 login.sh 需要 bash + python3(Windows 上基本劝退) |
插件内置同款 OAuth 流程,纯 Node 实现 |
| 可分发 | 配置只存在于本机,别人拿不到 | 一个 npm 包,装上即可用 |
provider 出现在哪、谁说了算
一份配置,两个消费者。 插件的模型目录不是运行时路由,而是写进 llm-pi-ai 的
provider 配置(settings namespace 就是 profile 条目 id):
llm-pi-ai: ← profile 条目,settings namespace
config:
providers:
workbuddy: ← 插件首次启动时按 /v1/models 生成的那一份
api: openai-completions
baseURL: http://127.0.0.1:7863/v1
apiKeyEnv: WORKBUDDY2API_API_KEY
models: [...] ← 你在「设置 → 模型」里编辑的就是这里
这样做的收益是它同时是路由、又是可编辑的配置:聊天窗口的模型选择器、子代理的 模型路由、以及「设置 → 模型 → 供应商」页读到的是同一份,你在页面上删掉一个模型, 聊天选择器里立刻就没有它了。
代价是目录默认只在首次创建时从网关同步,之后不再跟着网关变 —— 这是刻意的,见
已知限制。想看网关后来多出哪些模型,用只读的
/wb2api-provider:它打印差异,不写任何东西。要把网关新增的模型补进来,用
/wb2api-provider --rebuild:它是只增不删 —— 只补网关新增的,你在模型页删掉的
不会复活(依据是插件自己记的「迄今提供过哪些模型 id」,落在 <dsh home>/.wb2api/state.json;
写入带安全网:写前把 patch 备份到 profile 之外、写后独立读回核对条目数、不对就自动回滚)。
要把网关那份完整目录整份拉进来(第一次用、provider 是手写的、或状态文件被删过时
只有这一条路),用 /wb2api-provider --rebuild --reset:它忽略那条记忆、以网关目录
为准整份覆盖,并把丢掉的 id 逐个报出来。⚠️ 代价是它会覆盖你在「设置 → 模型」里做的
筛选 —— 设置卡片上那个「完全重建(⚠️ 会覆盖你在模型页做的筛选)」按钮走的就是这条命令。
字段映射
网关的 /v1/models 已经透出全部模型元数据(internal/server/handler.go 的 modelList(),对应上游 issue #84),因此插件无需内置静态兜底表:
网关 /v1/models |
dsh LlmResolvedModelInfo |
说明 |
|---|---|---|
context_length |
context.contextWindow |
网关保证有值(四级查找链,见下) |
max_output_tokens |
defaultMaxTokens |
网关未收录时不下发该键,插件随之不声明 |
supports_images |
inputModalities |
缺席即仅 ['text'] |
reasoning_supported_efforts |
reasoning.efforts |
空则不声明整个 reasoning |
reasoning_default_effort |
reasoning.defaultEffort |
插件先校验它确实包含于 efforts 内 |
内置的
openai-completions栈不认这些非标准字段 —— 这正是当初必须手写元数据的根因,也是本插件的核心价值。网关侧对
context_length/max_output_tokens走四级查找链:上游动态值 → 静态种子表 →model.json缓存 → models.dev 按需拉取;全未命中时窗口兜底 1M、输出上限省略。cn:与global:两域同口径。
为什么不破坏上游的「按轮聚合」
本插件的请求序列化有两条硬约束:工具结果必须发成 role:"tool"、用户消息的内容与顺序在轮内逐字不变。破坏任一条,上游按对话轮聚合 RequestID 的兜底键就会在每个 step 漂移,用量明细重新碎片化 —— 且是静默的(网关照常回包)。
原理、实测截图与端到端证据:docs/turn-level-aggregation.md。
前置条件
⚠️ 上游仓库已删库(2026-09-30 确认,CI 已迁走):原
Sliverkiss/workbuddy2api现已 404(并非改名重定向)。 延续仓库为HanawaBanana/workbuddy2api(MIT,描述明确写着"原 Sliverkiss/workbuddy2api 已删库")。
release-binaries工作流已改为从延续仓库构建,并把 ref 钉死在UPSTREAM_REF = 1c9aef9ab378217316ee2ddff7eace78381b46c0(2026-09-20)—— 这正是 已发布二进制内嵌的vcs.revision(可用go version -m wb2a-server.exe复核), 因此重新构建出的产物与既有版本同源、可复现。- 升级上游时改工作流里的那一行即可;不要改回
Sliverkiss/...,那个仓库已不存在。- 本机开发时若自行
git clone,同样应 clone 延续仓库。
dsh,全局安装:
npm i -g @deepseek-ai/dsh本插件在 dsh
0.2.0-rc.2上验证通过;package.json声明的兼容区间是>=0.1.2-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0(前一段覆盖0.1.x,后一段覆盖0.2.x)。装插件时 dsh 会拿运行时的 dsh 版本去比对这个区间(
dsh-app-boot的evaluatePluginCompatibility,内部用semver.satisfies(version, range, { includePrerelease: true }))。 因此0.2.0-rc.2这类预发布版本也能正确命中 —— 这一点很容易踩坑:若按普通 semver 语义 写>=0.1.2-rc.1 <0.2.0-0,它反而匹配不上任何0.1.7+预发布版,安装会被拒绝并回滚。一个 WorkBuddy 账号(国内外账号体系相互独立,选你有的那个)。
网关二进制 —— 不用自己准备,
/wb2api-setup会自动下载并校验。
安装
⚠️ 本仓库已迁到
rumilhz/dsh-pylon-workbuddy,首次发布前 Release 是空的。 三种方式都从本仓库的 Release 取东西;要等 CI(.github/workflows/release-binaries.yml与release-plugin.yml)跑完第一个v*tag,Release 里才会有 tarball 与网关二进制。 在那之前,下面这些下载链接会 404。
方式一:一条命令直装(推荐)
不需要先下载文件 —— pnpm 支持直接以 Release 的 tarball URL 作为依赖(已实测:装出来是真实目录、非 link:):
dsh plugin --profile web add https://github.com/rumilhz/dsh-pylon-workbuddy/releases/download/v0.4.0/dsh-pylon-workbuddy-0.4.0.tgz
升级时把 URL 里的版本号换掉即可。
方式二:先下载再本地安装
在 Releases 下载 dsh-pylon-workbuddy-<版本>.tgz,然后:
dsh plugin --profile web add file:/绝对路径/dsh-pylon-workbuddy-0.4.0.tgz
方式三:从源码安装
git clone https://github.com/rumilhz/dsh-pylon-workbuddy
cd dsh-pylon-workbuddy
npm install
node node_modules/typescript/bin/tsc -p tsconfig.json # 必须先构建出 lib/
npm pack --pack-destination dist
dsh plugin --profile web add file:./dist/dsh-pylon-workbuddy-0.4.0.tgz
不要用
dsh plugin install <源码目录>:pnpm 会写成link:依赖,而 Windows 上工具进程创建的 junction 不可遍历,会导致 pnpm 全面失效。用npm pack/pnpm pack出的 tarball(解包成真实目录)可规避。
关于旧包名与 npm registry(重要,别装错):本插件的当前包名是
dsh-pylon-workbuddy(家族前缀dsh-pylon-),它替换掉了旧的dsh-workbuddy2api。而 npm 上的
dsh-workbuddy2api这个旧名已被他人占用 —— 发布者是 Boiser,线上版本为0.4.9~0.6.8(latest = 0.6.8), 与本仓库(rumilhz/dsh-pylon-workbuddy,当前0.4.0)不是同一个项目。⚠️ 版本号也不能用来区分:本仓库从
0.4.0进入这条线,npm 上那个旧名包也停在0.4.9~0.6.8。唯一可靠的区分方式是来源(URL / tarball)。因此:
- ❌ 不要执行
dsh plugin add dsh-workbuddy2api—— 旧名已被占用,它会装到别人的包,而不是本插件;- ✅ 请用上面三种方式之一,从本仓库的 GitHub Release 取
dsh-pylon-workbuddy-<版本>.tgz;- 装完请核对来源:本仓库的版本线是
0.1.x/0.3.x/0.4.x,且只从github.com/rumilhz/dsh-pylon-workbuddy的 Release 分发。本仓库未在 npm 发布。新名
dsh-pylon-workbuddy在 2026-10-15 核对时尚未被占用 (registry 返回 404),但是否发布取决于后续决定 —— 安装时认 URL / 认 tarball,不要只认包名。
装完必须重启一次 dsh(为什么)
dsh plugin add 只做两件事:把包装进 ~/.dsh/profiles/<profile>/node_modules/,以及把包名加进该 profile 的 dsh.profile.bundles。正在运行的 dsh 进程不会重新读取这个列表 —— bundles 只在启动时解析一次,插件的代码是那时被 import 进进程的。
所以:装完请重启 dsh(关闭再启动),插件与它的 workbuddy2api provider 才会出现。这不是本插件的缺陷,而是 dsh 加载模型决定的 —— 新代码还没进进程,插件自己也没有机会运行。
重启后执行 /wb2api-setup 完成初始化。
关于免重启安装
dsh 的 profile 补丁层本身是热重载的(web profile 的 patchReload 为 live),已有插件能通过宿主的热挂载能力在运行中生效。但新装的包不行:dsh plugin add 是纯磁盘操作,而运行中的进程里没有任何存活代码会去 import 这个新包。
若你的 profile 里装了 dshmarket,它具备在运行中热挂载插件的能力(hotMount)—— 前提是该插件已在其收录的 registry 里(它的安装入口只接受收录过的来源,不接受任意 URL)。本插件暂未收录,因此目前请按上面的方式安装并重启一次。
让本插件支持免重启安装是可行的(把插件提交到 dshmarket 的收录列表,或做适配),但那是另一项工作。
dsh 起来之后,在对话里执行:
/wb2api-setup
它会依次:① 定位或下载网关可执行文件(校验 SHA256)→ ② 准备运行目录(生成 config.json,随机 api_key,仅监听 127.0.0.1)→ ③ 引导你完成账号登录 → ④ 启动网关并同步模型目录。每一步的结果都会逐条汇报,失败会停在那一步并说明原因。
首次执行会问你要登录国内版还是国际版,也可直接指定:/wb2api-setup cn 或 /wb2api-setup global。
配置
插件配置写在 profile 的 cordis.patch.yml 里(或任何引用 id: pylon-workbuddy 的补丁)。全部字段都可省略:
- insert:
- id: pylon-workbuddy
name: 'dsh-pylon-workbuddy'
config:
baseURL: http://127.0.0.1:7863/v1
binaryPath: /path/to/workbuddy2api/wb2a-server.exe
repoPath: /path/to/workbuddy2api
autoStart: true
realmPrefixPolicy: strip-cn
| 字段 | 默认值 | 说明 |
|---|---|---|
baseURL |
http://127.0.0.1:7863/v1 |
网关 OpenAI 兼容端点根 |
apiKeyRef |
WORKBUDDY2API_API_KEY |
dsh 凭据库里的 ref 名 |
binaryPath |
'' |
网关可执行文件绝对路径;为空则自动探测 |
repoPath |
'' |
网关源码目录(也用作默认工作目录) |
workingDir |
'' |
子进程工作目录;为空时取 repoPath,再取插件运行目录 ~/.dsh/wb2api/ |
listenPort |
7863 |
仅用于端口占用判定与状态展示 |
autoStart |
true |
dsh 启动时是否自动拉起网关 |
autoDownloadBinary |
false |
dsh 启动时若缺二进制是否自动下载(见下) |
binaryReleaseRepo |
rumilhz/dsh-pylon-workbuddy |
预编译二进制的发布仓库(首次发 tag 前 Release 为空 → 下载 404) |
binaryReleaseBase |
'' |
覆盖下载根地址(自建镜像 / 钉版本) |
defaultRealm |
'' |
登录默认域(cn / global);为空则每次询问 |
realmPrefixPolicy |
strip-cn |
模型 id 的前缀策略,见下 |
modelsTtlSeconds |
600 |
/v1/models 缓存 TTL |
requestTimeoutSeconds |
600 |
单次请求整体超时 |
idleTimeoutSeconds |
300 |
SSE 帧间空闲超时(超时报可重试的 TIMEOUT) |
firstTokenTimeoutSeconds |
120 |
首个 token 等待超时 |
healthTimeoutSeconds |
3 |
单次探活超时 |
graceMs |
5000 |
子进程优雅退出宽限期 |
crashRestartLimit |
3 |
崩溃自动重启上限 |
env |
{} |
传给网关子进程的显式环境变量 |
网关二进制与运行目录
/wb2api-setup 会把东西装在这些位置(都在插件自己的目录下,不碰你的其它文件):
| 用途 | 位置 |
|---|---|
| 可执行文件 | ~/.dsh/wb2api/bin/wb2a-server[.exe] |
| 配置文件 | ~/.dsh/wb2api/config.json |
| 账号凭证 | ~/.dsh/wb2api/auths/ |
| 池状态 | ~/.dsh/wb2api/data/state.json |
产物来自本仓库的 Releases(GitHub Actions 从上游源码原样交叉编译,未改一行上游代码),下载后会与 SHA256SUMS.txt 比对,不匹配就拒绝安装。
生成的 config.json 有两处刻意选择:
- 只监听
127.0.0.1(上游示例的:7863会绑0.0.0.0,等于把带账号池的网关暴露到局域网); - 随机
api_key(网关在api_key为空时完全不鉴权,而/status会暴露账号与积分)。
已存在的 config.json 绝不会被覆盖 —— 你改了池参数、冷却策略或 auth_dir 都会被保留。
autoDownloadBinary 为什么默认关
隐式时机的联网下载在离线/内网环境只会刷报错,而用户从未要求联网。显式执行 /wb2api-setup 时无视该开关(那是明确意图)。想彻底零步骤的话把它设为 true。
apiKey 的解析顺序
- dsh 凭据库(
~/.dsh/.credentials.yaml的refs.WORKBUDDY2API_API_KEY) - 同名环境变量
- 网关自己的
config.json里的api_key(含插件自动生成的那份)
同机部署时第 3 条即可命中,通常无需任何额外配置。
子进程环境会被 dsh 清洗(凭据形名称与
DSH_*全部移除),所以网关需要的变量必须经上面的env显式传入。
realmPrefixPolicy:模型 id 的前缀策略
网关的模型 id 带 realm 前缀(cn:deepseek-v4.1-flash / global:gpt-5.4)。
strip-cn(默认) —— 剥掉cn:,保留global:。模型 id 与你手工配置时期一致,历史会话与预设无需迁移。keep—— 原样透出cn:/global:。双域账号并存时可显式路由到国际版账号。
只有 CN 账号时两种策略行为一致:网关的
resolveModel对无前缀名一律判为cn域。线格式与展示是两件事:无论哪种策略,插件发出请求前都会把无前缀 id 补成
cn:<id>,避免 realm 解析歧义。
命令
| 命令 | 作用 |
|---|---|
/wb2api-setup [cn|global] |
一键就绪:准备可执行文件、运行目录、账号,启动网关并同步模型目录 |
/wb2api-status |
网关状态、账号可用性(healthy/total)、各 realm 可服务性、模型数量、账号清单(启用/禁用 + 积分/冷却)、provider 归属 |
/wb2api-provider [--create|--rebuild|--rebuild --reset] |
只读打印「网关当前目录」与「你配的 provider」的差异(新增/下架/数值不一致);加 --create 只在 provider 缺失时按网关目录创建,从不改写已有清单;加 --rebuild 则只增不删地补上网关新增的模型 —— 你在模型页删掉的不会复活(判据是插件记的「迄今提供过哪些 id」,见已知限制);没有任何新增时一个字节都不写;加 --rebuild --reset(--reset 单独给也成立)则是完全重建:忽略那条记忆、以网关目录为准整份覆盖,并把丢掉的 id 逐个报出来 —— ⚠️ 它会覆盖你在模型页做的筛选,这是第一次使用时唯一能把网关完整目录拉进来的开关。三个写入开关都带写路径安全网:写前备份到 profile 之外、写后核对条目数、不对就自动回滚 |
/wb2api-start |
启动网关(幂等:已在运行则直接复用) |
/wb2api-restart |
重启网关并刷新模型目录 |
/wb2api-login [cn|global] |
登录一个 WorkBuddy 账号(浏览器授权,凭证写入 auths/) |
/wb2api-account |
查看/切换网关使用的账号(不带参数时弹出可点选列表,见下) |
关于「复用外部网关」
启动前插件会先探 GET /healthz(该端点无需鉴权):
- 端口上已有健康网关 → 直接复用,状态记为
external,插件不会再拉一个进程。这正是「以前手工常驻了一个 wb2api」的迁移场景:装完插件一切照旧可用,但那个进程仍归你管 —— dsh 退出时不会去杀它(插件不杀自己没启动的进程)。 - 端口空闲 → 解析可执行文件并 spawn,此后它的生命周期归 dsh,
dispose时一并回收。 - 端口被非网关程序占用 → 明确报错,而不是静默失败。
所以:想让网关真正由 dsh 托管,先停掉你手工启动的那个进程,再重启 dsh。/wb2api-status 的 状态: 一行会告诉你当前是哪种情形。
登录
/wb2api-login 会向 WorkBuddy 申请一条授权链接并弹窗给你打开;你在浏览器里完成授权后,它自动取回凭证、写入 auths/ 并重启网关加载。
这套流程与上游
login.sh+cmd/login同款端点、同款凭证格式(插件用 Node 复刻,因此不需要 bash 与 python3)。国内版会顺手做一次每日签到,失败不影响登录。
不带参数时会问你要登录国内版还是国际版;也可直接写 /wb2api-login cn 或 /wb2api-login global。想固定下来就设配置项 defaultRealm。
切换账号
/wb2api-account 决定网关能看见哪些账号。不带参数时弹出可点选列表(当前选择 + 每个账号的昵称、realm、积分、冷却状态),带参数则直接切:
/wb2api-account auto # 恢复全部账号参与加权轮换(默认)
/wb2api-account 2 # 只启用列表里第 2 个账号
/wb2api-account oneid_3000 # 只启用 uid 以该前缀开头的账号(前缀必须唯一命中)
机制:重命名凭证文件,不改网关一行代码。 网关只加载匹配 workbuddy*.json 的文件(internal/auth/auth.go 的 AuthFileGlob),因此把其余文件改名成 workbuddy-<uid>.json.disabled 就等于「它们不存在」。上游升级零冲突。只改名、从不删除,auto 即把后缀去掉 —— 所以状态即文件:重启 dsh、换机器、用户手工改名,读出来的选择都一致。
为什么走「停网关 → 改名 → 启网关」(所以命令不用 supervisor.restart()):网关运行中会因 token 临近过期把凭证原子写回,落点取自它内存里的旧路径 —— 可能把刚改名的文件重新创建出来,于是切换「看着成功,一分钟后又变回去」。先停进程,写回就不会发生。
较新的网关(含本插件 Release 的二进制)已加入
auths/目录热加载(每 5 秒轮询),改名本身也会被感知;但上面这个顺序仍然保留 —— 它对旧版自编译二进制是必需的,对新版也消除了改名与 token 刷新的竞态窗口。代价只是一次 1~3 秒重启。
复用外部网关时不支持切换。 此时进程不归插件管(/wb2api-status 的 状态: 一行显示 复用外部已运行的网关),停不掉 ⇒ 必然踩上面那条。命令会直接拒绝并提示:先停掉那个进程让插件托管,或改用 /wb2api-login 加号。
取舍与风险:
- 被禁用的账号不参与轮换,也不再刷新 token。长期禁用后其
refresh_token可能失效;恢复时若报会话失效,重跑/wb2api-login即可(同一 uid 覆盖同一份凭证文件)。 - 被禁用的账号不再出现在网关
/status里,因此列表里它的积分显示为「未知」(昵称与 realm 仍从文件读出)。 - 「只启用一个号」不等于「优先用这个号」:池里只剩它一个,网关无从回退。它正在冷却时请求可能 429 或 503 —— 这是「没有别的号可换」的必然表现,不是切换失败。要恢复自动兜底就
/wb2api-account auto。 - 切换只影响网关的账号集,与你在 WorkBuddy 桌面端登录的是哪个账号无关(那是另一条路线,见 Python 版 codebuddy2api)。
设置卡片的后端(宿主路由)
设置页里那张插件卡片跑在浏览器里,而网关要 Authorization: Bearer <api_key>、还是跨源回环端点 —— 把 api_key 交给页面等于把它交给页面上任何脚本。所以卡片不直连网关,它跟宿主说话,宿主跟网关说话。这一半由 src/api.ts 实现,端点在宿主 web 路由上(不是 RPC 通道):
| 端点 | 作用 |
|---|---|
GET <prefix>/panel |
一次性返回卡片要的全部数据(只读,只发 GET) |
POST <prefix>/action |
{ action: "rebuild" | "checkin" | "test" };rebuild 可再带 reset: true(完全重建:整份覆盖,见 /wb2api-provider --rebuild --reset) |
两条前缀都挂:/wb2api 与 /api/wb2api(/api 是框架自己的 API 前缀,桌面 app 的界面层只把 /api/* 转给宿主,裸路径会被那一层挡掉)。webServer 最长前缀优先,两条不会互相抢。重复注册同一个 (kind, path) 会抛错,所以注册配了 ctx.effect 反注册 —— ctx.inject 的回调会重跑(连接/热重载),不反注册第二次就挂不上。
鉴权不自己发明:用宿主自己的栅栏 connection.requestRejection(Host/Origin + 浏览器会话)。两条实测要点:
- 必须连
connection一起ctx.inject—— 只 injectwebServer拿不到栅栏;拿不到栅栏时插件不注册(「没有路由」优于「没有栅栏的路由」)。 - 必须以方法形式调用(
connection.requestRejection(req)):cordis 的服务是 Proxy,把方法摘成裸函数再调会让this落到一个没有browserAuth的影子上并抛错,于是栅栏永远 403、浏览器会话那一半根本没跑(实测:摘下来时"无 cookie"也返回 200)。 - 唯一的放宽是回环端口跳转(Host 与 Origin 两边都是回环、端口可不同)——桌面 app 代理转发时就是这个形状,与兄弟插件同一口径。401 永不放宽,非回环 Host/Origin 也永不放宽。
panel 的响应形状(拿不到的字段给 null,整块拿不到给 null + ok:false + detail,不假装有数据):
{ "ok": true,
"gateway": { "reachable": true, "origin": "http://127.0.0.1:7863", "healthy": 1, "total": 1 },
"credits": { "total": 3046, "accounts": [ { "uid": "…", "nickname": "186****5617", "realm": "cn",
"credits": 3046, "cooling": false, "disabled": false } ] },
"models": { "gatewayTotal": 48, "unknownPrice": 12, "providerModelCount": 46 },
"provider": { "exists": true, "ns": "llm-pi-ai", "lastOffered": 48 },
"usage": { "enabled": true, "since": "…", "requests": 0, "credit": 0, "uptimeSec": 2793, "models": [] },
"checkin": { "supported": true, "note": "…" } }
credits.accounts[].nickname 必定已脱敏(src/nickname.ts:/status.nickname 在本 build 实测就是手机号)。action 的三种回执:rebuild 给 addedModels/keptModels/offeredModels(均为数字)+ addedModelIds(具体 id,另有 added/addedCount/keptCount/modelCount 兼容别名),带 reset: true 时改走完全重建那条路,回执额外给 written/writtenCount 与 dropped/droppedCount/droppedModelIds(这次被覆盖掉的 id,必须点名报出来);checkin 给 {ok, attempted, status, already, detail};test 给 {ok, attempted, model, ms, creditSpent, results[], detail}。
checkin.status 是四态:ok(上游真的记了一次)/ already(幂等成功:上游说「今天已签到」,本次没有新增签到)/ failed(真失败)/ not-attempted(一个请求都没发:没有启用的 CN 账号、凭据不可用、或该 realm 没有签到端点)。ok 是幂等口径(ok === (status === "ok" || status === "already")),卡片按它决定显示 ✅ 还是 ❌ —— 「今天已签到」显示 ✅。判定口径是 code 优先、文案兜底:code === 0 ⇒ 成功;code === 10001 ⇒ 已签(2026-10-15 实测:网关自己的调度器 checkin_hours 默认 [9,21] 已经签过时,上游对第二次签到回 HTTP 400 {"code":10001,"msg":"今天已签到,请明天再来"});否则 msg 里明确出现「已签到 / 已经签到 / 已领取」⇒ 已签;其余一律 failed(绝不把真失败吞成「已签」)。同一份三态也写进 boot.log:api: checkin realm=cn attempted=true ok=true status=already detail=…。
签到只做「用户点一次」:action:"checkin" 由插件直连上游(www.codebuddy.cn/v2/billing/meter/daily-checkin,仅 CN)完成,不经网关。插件里没有任何自动签到路径(没有定时器、没有启动钩子),panel.checkin.supported 只有在存在启用的 CN 账号时才为真。理由见 合规声明:官方明文禁止「利用脚本、外挂、插件、模拟器、接口调用……刷取活跃或奖励」。
唯一会花额度的动作是 test,它必须带 confirm: true(缺了它一个请求都不发);没指定模型时只用 credits === "x0.00" 的免费档,一个都没有就拒绝而不替你挑一个收费的;调用前后各读一次 /v1/stats,回报实测花费差值。
已知限制
- 窗口/输出上限是「上游实际值 + 本地估计」的混合。 网关对
context_length走四级查找链:上游动态值权威 → 静态种子表 →model.json缓存 → models.dev 按需拉取;全未命中时兜底 1M。因此冷门模型的窗口可能是兜底值而非真实值。max_output_tokens同理,未收录时网关不下发该键,此时插件不声明defaultMaxTokens,由网关自行决定。 - 无账号时网关「启动 ≠ 可用」。 进程起来但
/healthz报healthy: 0,插件会把状态标为「运行中(无可用账号)」而不是假装成功。 - 切换账号仍会重启网关。 切换靠重命名凭证文件实现(见上)。较新的网关有 5 秒热加载能自行感知,本插件仍走一次重启以确保新旧二进制行为一致,代价是中断正在进行的请求 1~3 秒。
- 预编译产物来自本仓库的 CI,可能与上游最新提交有延迟。 想紧跟上游就自行
go build(把binaryPath指向你的产物,插件不会覆盖它)。上游若改动凭证格式或路由,本插件的适配可能滞后。 - 本仓库刚迁到
rumilhz/dsh-pylon-workbuddy,首次发v*tag 之前 Release 是空的。 那之前插件下载网关二进制会 404(安装用的.tgz同理取不到);CI 跑完release-binaries.yml后恢复正常。 - 模型目录默认只在首次创建 provider 时同步一次,之后不再跟着网关变。 这是刻意的取舍:你会在「设置 → 模型」里把自动生成的整份清单删到真正要用的那几个,任何「自动补齐 / 自动同步」都会把删掉的模型重新塞回来。因此装填逻辑是存在即什么都不做 —— 插件升级到新版本同样不会碰你筛过的清单。要手动看差异用只读的
/wb2api-provider(打印「网关有而你没配」「你配了而网关已下架」「数值不一致」三组,不写任何东西);要在 provider 缺失时按网关目录创建整份清单用/wb2api-provider --create;要把网关后来新增的模型补进来用/wb2api-provider --rebuild—— 它是只增不删:新增 = 网关目录 − 你配置里现有的 − 插件曾提供过的,你删掉的模型不会复活,没有任何新增时一个字节都不写。这条判据靠插件自己记的墓碑集合(「迄今提供过哪些模型 id」,落在<dsh home>/.wb2api/state.json,与网关自己的运行目录~/.dsh/wb2api/是两个目录)。因此有两条边界要说清楚:① 这份状态文件被删掉,删除记录就没了 —— 那之后第一次--rebuild会看到「没有上一次记录」,此时它一个都不加并说明原因(它分不清哪些是新增、哪些是你删的),同时把网关当前目录记为基线,之后的刷新只补真正新增的;② provider 若是在这个版本之前就存在的,插件从来没有它的「提供过」记录,而启动时的present不会拿你当前的清单去替你造一条(那等于把"你留着的"当成"我们提供过的",下一次--rebuild就会把你删掉的模型全加回来)—— 于是你在升级前删掉的那些模型,在第一轮--rebuild时仍然不会被补回来:那一轮会一个都不加(并把出口指明白),同时把网关当前目录立成基线,记忆从那一刻开始生效。出口是/wb2api-provider --rebuild --reset(设置卡片上那个「完全重建(⚠️ 会覆盖你在模型页做的筛选)」按钮走同一条路):它忽略这条记忆、以网关目录为准整份覆盖,并把丢掉的 id 逐个报出来;代价正是它会覆盖你在模型页做的筛选,所以它只能由你显式敲下/点下 —— 插件自己永远不会选它。--rebuild是个显式开关,插件自己永远不会主动选它。两条写入路径都带安全网:写前把 profile patch 备份到 profile 之外,写后独立读回核对条目数,条目变少就自动回滚并说明原因。如果 provider 缺失且创建失败(settings 不可用、写入被拒、网关连不上),插件会把一份可直接粘贴的 YAML 打进日志,并明确说明「此时引用它的路由指向一个不存在的 provider」—— 它不会替你猜数值,也不会删改任何既有配置。
常见问题
网关起不来,日志只有 load config: read config: open config.json: ...
网关在找不到 config.json 时直接退出(上游那段「缺配置就用默认值+环境变量」的兜底因为 os.IsNotExist 判不透包装错误而永远进不去 —— 见「为什么有几处写法看起来多余」)。
先跑 /wb2api-setup:它会补建运行目录并生成配置。若你希望自己管这份目录,就把插件配置的 repoPath 指到网关源码目录(那里有你的 config.json),插件即不会再碰 ~/.dsh/wb2api/。
网关起来了但 /healthz 一直是 healthy: 0
进程没问题,是没有可用账号。执行 /wb2api-login 登录一个账号;若已有账号,用 /wb2api-status 看它是不是正在冷却(单一账号时冷却就等于全部请求失败,见下)。
启动日志出现 provider 注册被拒(DUPLICATE_ADAPTER)
只有把 registerOwnProvider 设成 true(回退到 0.3.x 的自带路由)时才可能看到这条。
默认路径不注册自带路由,因此不会出现这个冲突。
回退路径下如果 settings 里已有同名 provider,会看到:
[workbuddy2api] provider 目录注册被拒:configurable provider "workbuddy2api" is already declared
[workbuddy2api] 适配器注册被拒(DUPLICATE_ADAPTER):an adapter for provider "workbuddy2api" is already registered
dsh 仍会正常启动(插件故意不把冲突抛出去,避免把一个本来可用的环境弄成起不来),但此时模型请求走的是 settings.yaml 里那份手工配置,自动元数据不生效。/wb2api-status 也会显示这条警告。
处理:从 ~/.dsh/settings.yaml 删除整段 llm-pi-ai.providers.workbuddy2api(连同它下面那批模型元数据),然后重启:
llm-pi-ai:
providers:
workbuddy2api: # ← 从这一行删到该 provider 段的末尾
其余引用保持不变 —— provider id 没变,这些无需改动:
agent-default-model:
provider: workbuddy2api # 保持不变
subagent-model-selection:
allowedModels:
- provider: workbuddy2api # 保持不变
仓库自带脚本 scripts/migrate-settings.ps1 可按缩进精确摘除该段并自动备份(不重写整个文件,dsh 自己的注释与键顺序都保留):
# 先预览要删什么
pwsh -File scripts/migrate-settings.ps1 -WhatIf
# 确认后执行(请在 dsh 已关闭时做)
pwsh -File scripts/migrate-settings.ps1
脚本会打印回滚命令(把自动备份拷回来即可)。
为什么必须在 dsh 关闭时执行:settings 文件是被热监听的。在 dsh 运行中删掉该段会立刻生效,而插件那份 provider 要等重启才会注册 —— 中间窗口里模型会不可用,正在进行的会话可能直接失败。
0.4.0 起这段迁移只在回退路径下才需要:默认不再注册自带路由,settings 里存在 同名 provider 不会再冲突(插件会把它当成「操作员自己写的那一份」并原样不碰)。 换句话说
scripts/migrate-settings.ps1现在是一个历史遗留工具,用不用都不影响启动。
合规声明
- 本插件不包含、不转发 workbuddy2api 的任何源码。网关源码始终来自上游仓库。
- 本仓库的 Releases 提供由本仓库 CI 从上游源码原样交叉编译的可执行文件(构建过程见
.github/workflows/release-binaries.yml:先断言上游工作区未被修改,再校验产物内嵌的vcs.modified=false)。上游不提供预编译产物,这些二进制是为了让用户不必自装 Go 工具链;许可证与免责声明仍归上游。介意第三方构建的话,请自行go build并把binaryPath指向你的产物。 - workbuddy2api 的许可与免责声明由其自身条款约束,使用前请自行阅读。
- 本插件以 MIT 许可发布,与上游无隶属关系。
开发
npm install
node node_modules/typescript/bin/tsc -p tsconfig.json # 构建(不要用 pnpm build)
node node_modules/vitest/vitest.mjs run # 单元测试
不想逐条敲上面这些命令(以及下面那条 e2e)时,用一条命令跑完全部离线闸门 ——
类型检查、单测、期望值可移植性闸门、本地 e2e(真文件系统的写路径安全网)、
独立 CLI 的离线契约、设置卡片的 client 闸门、构建(表尾那条会 emit 到 lib/):
npm run test:suites # 离线;联网的那条只报 SKIP
npm run test:suites -- --network # 追加真实网关集成(见下,会花额度)
npm run test:suites -- --no-build # 不跑表尾的构建(调用方自己构建,如 release-plugin.yml)
npm run test:suites -- --skip unit # 只想跑一部分时,被排除的会写成 SKIP(不是 PASS)
CI 跑的就是这一条命令,不是一串散装命令。ci.yml 里曾经只跑
vitest run,而 vitest.config.ts 的 include 只有 tests/unit/** ⇒
tests/e2e/** 从来没进过 CI;本机"全绿"恰恰因为有活体网关在 ⇒ 那是只在开发机上
骗人的假绿。现在加一条离线闸门只需要改 tools/_run-suites.mjs,CI 自动跟着走。
判定规则只有一条:看子进程退出码。入口不存在(比如依赖没装)一律记 SKIP
并写明原因 —— "这条检查没跑"和"这条检查过了"不会长得一样。退出码:有 FAIL ⇒ 1,否则 0。
独立 CLI(不经 dsh 插件,用来直接查网关,只读):node tools/wb2api.mjs --help(或 npm run cli -- --help)。
它的离线契约(--help / -h / 未知命令 / 未知选项 / test 缺 --model 与两道成本闸)已在上面那张套件表里,
入口缺失记 SKIP 而不是 PASS。
行为变更(2026-10-15):认不出的选项 ⇒ 用法错,退出码 2,且在拨号之前返回。 以前它们被静默忽略,而
command取的是"第一个不以-开头的词" ⇒wb2api -j一个词都认不出来 ⇒ 落到默认命令status⇒ 真去打了一趟活体网关(用户以为-j是 json)。 同一个病根的另两种形态也一起治了:--limit=30(不认=,以前整串被忽略、按默认 20 跑) 与"选项后面该有值却没有"(如结尾的--config,以前回落默认配置)。 短别名只认帮助里列出的(目前仅--help/-h):--json没有-j,--yes没有-y。
集成测试会真实发起对话(消耗账号额度),需显式开启:
$env:DSH_WB2API_E2E='1'; $env:DSH_WB2API_E2E_CONFIRM='yes'
$env:DSH_WB2API_E2E_API_KEY='<网关 api_key>'
node node_modules/vitest/vitest.mjs run --config vitest.e2e.config.ts
这条 spec 用
describe.skipIf自己跳过,而 vitest 对"自己跳过的用例"退出码仍是 0。 所以npm run test:suites -- --network会先检查上面两个环境变量:没设就把这条报成SKIP(并写明开法),而不是让一个一个字都没执行的套件冒充通过。
代码结构
| 文件 | 职责 |
|---|---|
src/index.ts |
插件入口:命令、生命周期、自动启动、provider 装填的接线 |
src/provider-config.ts |
生成「设置 → 模型」里那份可编辑 provider:namespace 探测、存在性判定、字段映射、只读差异、失败时的可粘贴 YAML |
src/config.ts |
插件配置 schema 与默认值 |
src/models.ts |
/v1/models 拉取、映射、TTL 缓存 |
src/gateway-adapter.ts |
LlmAdapter 实现:请求组装 + SSE → StreamChunk |
src/gateway-supervisor.ts |
子进程托管:探测、spawn、探活、重启、回收 |
src/accounts.ts |
账号开关:扫描 auths/ 凭证文件、按切换目标重命名(停/启网关的编排在 index.ts) |
src/gateway-binary.ts |
预编译二进制的下载、SHA256 校验、运行目录(config.json)准备 |
src/login.ts |
用 Node 复刻上游 OAuth 登录(取授权链接 → 轮询 token → 落盘凭证);requestDailyCheckin() 是唯一的签到实现(登录流程与设置卡片共用它) |
src/api.ts |
设置卡片的后端半边:<prefix>/panel 与 <prefix>/action 的数据组装(与传输无关,可离线测)+ 宿主路由的注册/反注册/双前缀/栅栏(见设置卡片的后端) |
src/setup.ts |
/wb2api-setup 的编排与逐步骤汇报 |
src/nickname.ts |
昵称脱敏:/status.nickname 在本 build 实测就是手机号,只在看起来像手机号时打码成 186****5617,其余原样(一律打码会让账号列表失去可辨识性);index.ts 与 setup.ts 共用这一份 |
src/boot-trace.ts |
插件自己的启动轨迹:<dsh home>/.wb2api/boot.log 追加写一行一个锚点(apply / settings / 凭据 / 目录 / 服务 / 墓碑 / seed),失败全吞 —— 宿主日志看不见时才靠它区分「没加载」与「加载了但某步没跑」 |
src/zip.ts |
极简 ZIP 读取(stored/deflate),避免引入解压依赖 |
src/sse.ts |
SSE 空闲超时读取、工具参数归一化、孤儿工具配对清理 |
tests/unit/turn-key-contract.spec.ts |
轮级聚合键的跨语言契约 |
docs/ |
设计文档与截图 |
为什么有几处写法看起来"多余"
这些都有具体原因,改动前请先读对应注释:
- 工具结果必须发成
role:"tool"—— harness 把结果搭载在 user 角色消息里,原样发出会让网关的TurnKey取到工具输出、每个 step 换一个聚合键,RequestID 碎片化因此静默复发。详见 轮级聚合 与tests/unit/turn-key-contract.spec.ts。 Config必须用Schema.object({...})构造 ——settings.describe()会对每个注册项调用schema.toJSON(),传裸函数会抛TypeError,连带让模型设置页、主题、sidebar 的 settings API 全部失效。- SSE 按
\n切行而不是\n\n切事件 —— 网关一帧 = 一行data:。 - 工具
id靠 Map 按index复用 —— 只有首个分片带id。 function.name只在非空时更新 —— 后续分片带""(不是undefined),直接覆盖会导致unknown tool ""。- 残缺工具参数报
max-tokens而不是tool-calls—— 报tool-calls会让 harness 执行半截 JSON 并把脏参数写进会话历史,报max-tokens才会丢弃并重试。 readWithIdleTimeout的 abort 分支也要 reject —— 只清定时器会让Promise.race永远悬空,generator 既不产出也不返回。- provider 注册冲突只记日志、不抛错 —— 冲突意味着用户还没迁移旧配置,此时抛错会让 dsh 起不来,把一个可用环境变成完全不可用。(0.4.0 起这只在
registerOwnProvider: true的回退路径上才可能发生。) - 自带 provider 的模型配置是「存在即不碰」,只创建、永不改写 —— 你会在「设置 → 模型」里把它删到只剩几个,任何自动补齐都会把删掉的模型塞回来。判定要看
describe()的value/base/user三层:只要任意一层提到这个名字就什么都不做。 - 生成配置时字段缺失就不写,并且要说出来 —— pi-ai 对缺失的
contextWindow会静默套用 262144(dsh-llm-pi-ai/lib/index.js:675、DEFAULT_CONTEXT_WINDOW在:927),猜一个数字比缺字段更危险。缺了就报「网关未下发 contextWindow」,由你决定补什么。 reasoningEfforts用恒等映射(high: high) —— 网关报的档位就是它自己接受的线上值(上游 issue #84「客户端可发现档位,不再盲传」),pi-ai 会把这个值原样塞进请求体的reasoning_effort(@earendil-works/pi-ai/dist/api/openai-completions.js:712-715),本仓库自己的适配器对同一个网关发的也是同一个字段同一个值(src/gateway-adapter.ts:391-394)。- 不在 pi-ai 档位枚举(
off/minimal/low/medium/high/xhigh/max)内的档位要过滤掉再写 ——reasoningEfforts的键是z.union(THINKING_LEVELS),多一个键会让整次mutate校验失败,连带把这次创建一起拖垮。过滤掉但在日志里点名。 - 装填步骤与自动启动是两步、不是串行 —— provider 已存在时这一步根本不需要网关;串在
supervisor.start()之后会让「网关起不来」连带吞掉「provider 归属的确认」,而那正是最需要说清楚的时候。 Config里不能加 volatile 字段 ——dsh-settings只暴露带 volatile 字段的条目,而供应商列表按条目渲染;本插件一旦被暴露,页面上就会多出第二个(不可编辑的)provider 行。tests/unit/plugin-load.spec.ts有一条用例专门钉这件事。- 切换账号的顺序必须是「停 → 改名 → 启」 —— 网关运行中会用内存里的旧
FilePath把凭证原子写回,改名会被它撤销。所以/wb2api-account刻意不用supervisor.restart()(它无法在停与启之间插一步动作);改名本身失败时也会把网关拉回来,不把环境留在「网关已停」。 - 自动下载必须同时生成
config.json—— 上游main.go写了「配置不存在就用默认值+环境变量」的兜底,但它用os.IsNotExist()判断一个已被fmt.Errorf("read config: %w", …)包装过的错误,永远为假,于是进程直接log.Fatalf退出(实测:二进制放进空目录执行,4 秒内退出,只留一行load config: read config: open config.json: …)。所以插件必须自己写这份配置。 - 生成的配置只监听
127.0.0.1且必带随机api_key—— 上游示例的:7863会绑0.0.0.0(实测netstat可见),而网关在api_key为空时完全不鉴权,/status会暴露账号与积分。 - 装二进制前必须校验 SHA256,且拿不到校验和就拒绝安装 —— 下载的是会被执行的文件。校验和清单缺失时"下都下了就装"等于没有校验。
auths/目录必须先于网关启动而存在 —— 网关只在启动时探测该目录,不可读就永久跳过auths热加载(实测日志:[watch] auths 目录 ./auths 不可读,跳过热加载监听(加账号后需手动重启)),之后新增账号不会再被自动加载。- 栅栏必须以方法形式调用(
connection.requestRejection(req)),不能摘成裸函数 —— cordis 的服务是 Proxy(createTraceable+createShadowMethod),摘下来调用时this会落到一个没有browserAuth的影子上并抛Cannot read properties of undefined (reading 'isAuthenticated');catch把它按 403 处理后,又会被「回环端口跳转容忍」放行 ⇒ 栅栏看起来在工作,其实浏览器会话那一半从没跑过(实测:摘下来时无 cookie 的请求也返回 200)。tests/unit/api.spec.ts有一条用例钉住这个调用形式。 - 宿主路由必须同时挂
/wb2api与/api/wb2api,且注册要配反注册 —— 桌面 app 的界面层只把/api/*转给宿主;而webServer.register对重复的(kind, path)会抛,ctx.inject的回调却会重跑(连接被替换、热重载)。不配对反注册的后果不是报错,而是第二次注册之后卡片再也没有数据。 - 启动轨迹写不进去也必须当没写 —— 插件自己往
<dsh home>/.wb2api/boot.log追加一行行锚点(apply: …/settings: acquired/credential: …/seed: …),因为ctx.logger的输出在这套运行形态里进不了 host 日志,而顶部那条console.log('[workbuddy2api] module loaded')只回答「插件跑没跑」——这个文件才回答「跑到哪一步、结果是什么」。它每一处都包在try/catch里全吞(src/boot-trace.ts),跟state.json同一目录但追加写、不清空不轮转:一次追踪写失败若能把启动搞挂,那它就比它诊断的那个问题更糟;而清空会让「上一次启动发生了什么」正好在最需要它的时候消失。(--rebuild的墓碑读写结果也在轨迹里 —— 「没有记录」渲染成none,与「记录是空的」0分得开。)
延伸阅读
- 轮级聚合:让一次对话只有一个 RequestID —— 为什么要按现在的方式序列化请求
- 轮级聚合键的跨语言验证工具 —— 用上游真实 Go 实现验证适配器产出
- 方案与调研记录(2026-09-15) —— 设计取舍与上游能力调研
许可
MIT
No comments yet. Be the first to write one.