DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

rumilhz /

rumilhz/dsh-pylon-workbuddy

Verified

DeepSeek Harness (dsh) plugin: supervise a workbuddy2api gateway and register its models as an editable LLM provider. Self-hosted, no upstream wrapping.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@6fffb22e

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 延续仓库。
  1. 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+ 预发布版,安装会被拒绝并回滚。

  2. 一个 WorkBuddy 账号(国内外账号体系相互独立,选你有的那个)。

  3. 网关二进制 —— 不用自己准备,/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 的解析顺序

  1. dsh 凭据库(~/.dsh/.credentials.yaml 的 refs.WORKBUDDY2API_API_KEY)
  2. 同名环境变量
  3. 网关自己的 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 —— 只 inject webServer 拿不到栅栏;拿不到栅栏时插件不注册(「没有路由」优于「没有栅栏的路由」)。
  • 必须以方法形式调用(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

—/ 5

No ratings yet

Verified DSH bundle

Commit 6fffb22e87f9

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout