Compare commits

..
5 Commits
15 changed files with 5603 additions and 14 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
*.bak-*
*.bak
node_modules/
.DS_Store
Thumbs.db
*.log
+21 -13
View File
@@ -1,18 +1,26 @@
MIT License
Copyright (c) 2026 MuLin
Copyright (c) 2026 Axiaohungry
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
本仓库包含对上述项目的修改(修改清单见 README.CUSTOM.md)。
Modifications Copyright (c) 2026 MuLin
+84
View File
@@ -0,0 +1,84 @@
# dsh-llm-workbuddy(个人定制版)
> 本仓库是 [`@axiaohungry/dsh-llm-workbuddy`](https://github.com/Axiaohungry/dsh-llm-workbuddy) **v1.3.21 的个人自用修改版**。
> 原项目采用 **MIT** 许可,版权归原作者 **Axiaohungry** 所有;本定制版的改动见下。
> 与腾讯、WorkBuddy、CodeBuddy、DeepSeek、DSH 官方**均无关联,未获其背书**。
原始 `README.md` 为原作者文档,**未做改动**,保留作为参考。
---
## 安装(换新机器时)
DSH 桌面版(本机安装于 `E:\DeepSeekHarness`,路径按实际调整):
```powershell
& "<DSH 安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add git+<本仓库地址>
```
装完**完全退出 DSH 再启动**(客户端与 host 两侧的模块在启动时组装)。
> 若本机此前装过 npm 上的同名包,建议先
> `... plugin --profile desktop remove @axiaohungry/dsh-llm-workbuddy`,
> 否则同一个 provider(`workbuddy-cn`)会注册两次而冲突。
---
## 相对原版的改动
| # | 位置 | 改动 |
|---|---|---|
| 1 | `client.js` `WorkBuddyCreditsDock` | 移除凭证绑定控件、认证模式选择、多 Key 下拉与「当前会话 API Key」文案,只保留一行「剩余积分 xxx」 |
| 2 | `client.js` `:408` 一带 | 原版在 API Key 模式下**提前 return 不查积分**;本版改为继续查询 |
| 3 | `workbuddy-web.js` credits 路由 | 非令牌模式不再直接返回「积分查询仅支持令牌登录」,改为读当前 API Key 查账单接口 |
| 4 | `workbuddy-credits.js` `queryPersonalCredits` | 新增返回 `planName` / `planCycleEnd`(取账单中 `CapacityType=2` 且名字含「高级版/标准版/旗舰版/专业版」的条目) |
| 5 | `client.js` | 积分项改为可点击的「药丸」,hover 高亮 + 点击弹出卡片:`WorkBuddy` / 剩余积分 / 套餐 / 下次重置时间;点外部或按 Esc 关闭 |
| 6 | `client.js` CSS | dock 的 `overflow: hidden` → `visible`(否则弹出的卡片会被父容器裁掉) |
| 7 | `client.js` `mountPendingSelector` | 移除新建会话时的「新会话默认凭证」选择器 |
| 8 | `client.js` `apply()` | 注释掉 `plugins.detail.section` 注册(移除插件详情页面板)。**保留** `settings.models.provider-card`(改 Key / 拉模型列表的唯一 UI 入口) |
| 9 | `client.js` `applyMode` | 隐藏「当前 API Key」多 Key 来源下拉;Key 提示区分「已配置可更换 / 未配置可添加」,且不回显 Key 内容 |
| 10 | `index.js` | `DISPLAY_NAME` 由 `WorkBuddy 中国区` 改为 `WorkBuddy`(不显示区服) |
| 11 | `client.js` `mountModelEditor` | 「模型目录」改为**可折叠**(默认收起),避免模型列表过长;Key 输入与「添加并使用」始终可用,未配置 Key 的机器可直接填入 |
代码内均以 `[自用定制 2026-10-10]` 注释标记,便于日后与上游 diff。
---
## 为什么积分能查到(原理)
WorkBuddy 的账单接口 `POST https://www.codebuddy.cn/v2/billing/meter/get-user-resource`
**接受 API Key 作为 `Authorization: Bearer` 令牌**(原版插件只是没这么做,它认为只有网页登录令牌可用)。
请求体:
```json
{
"PageNumber": 1,
"PageSize": 100,
"ProductCode": "p_tcaca",
"Status": [0, 3],
"PackageEndTimeRangeBegin": "当前时间",
"PackageEndTimeRangeEnd": "当前时间 + 101 年"
}
```
返回的 `data.Response.Data.Accounts[]` 中:
- `CapacityType = 2` → **套餐 / 会员类**(本条用来取 `PackageName` 与 `CycleEndTime`)
- `CapacityType = 1` → 积分包(签到、活动、兑换码赠送)
> 注:`GET /console/accounts` 对 API Key 返回 **401**,只认网页登录令牌,所以套餐类信息只能从上面这个接口筛。
---
## ⚠️ 注意事项
1. **本版移除了配置 UI**,只适合**已经配好 Key 的自用场景**。若要分享给别人,请让对方用上游原版。
2. 依赖 WorkBuddy **未公开**的接口,上游随时可能改动或收紧风控,**可用性无任何保证**。
3. **仅限本机、仅供自用**:请勿用于多账号池化、代他人调用或任何形式的转售 / 托管。
4. 使用前请自行确认是否符合 WorkBuddy 的服务条款。
5. 按 MIT「按现状」提供,作者不对账号风控、额度异常或任何损失负责。
## 许可
MIT —— 见 `LICENSE`(原作者的版权声明必须保留)。
+286
View File
@@ -0,0 +1,286 @@
# DSH WorkBuddy Provider
为 DeepSeek Harness(DSH)增加 `WorkBuddy 中国区` Provider。插件通过
WorkBuddy 提供的 API Key,或 WorkBuddy 中国站的网页登录令牌调用模型,并在
DSH WebUI 中管理模型和认证方式。
> [!IMPORTANT]
> API Key 来自 **WorkBuddy**,用于调用供 WorkBuddy 使用的模型服务。本插件是第三方
> 适配器,不属于 WorkBuddy 或 DSH 官方项目。
## 功能
- 在 DSH WebUI 中使用 `WorkBuddy 中国区`;
- 支持 WorkBuddy API Key 和 WorkBuddy 中国站账号令牌;
- 两种认证模式互相独立,可以随时切换;
- API Key 支持环境变量、多个 DSH 保存值,并可通过下拉列表切换;
- 令牌支持浏览器登录、多个账号持久化、账号名称展示和下拉切换;
- 令牌模式显示剩余积分、今日请求次数和今日积分用量;
- 自动兼容 `modlens-workbuddy-cn` 等带有 WorkBuddy/CodeBuddy 标识的包装 Provider;
- 用量信息与 DSH 的 Token/缓存统计显示在输入框底部同一行;
- 自动从 WorkBuddy 获取当前账号可用模型;
- 支持编辑模型 ID、名称、上下文窗口、最大输出 Token 和模型思考档位;
- 支持添加、删除模型以及恢复在线模型目录;
- 不依赖本机 WorkBuddy CLI。
## 环境要求
- 已安装 DSH;
- Node.js `>= 22.19.0`;
- Windows、Linux 或 macOS。
插件 `1.3.7` 及以上版本要求 DSH `>= 0.1.2-alpha.2`。`1.3.9` 起兼容新版 DSH
的 settings 注册接口。该版本不再携带旧版 DSH
运行时副本,而是复用宿主 DSH 的运行时,避免更新 DSH 后出现 Provider 目录接口
(`llm/listProviders`)不兼容。`1.3.10` 起,WorkBuddy 认证助手同时兼容新旧 DSH
的 `signal` 调用约定。升级插件后请重新安装一次并重启 DSH。
插件兼容旧版 DSH 和 `0.1.7-rc.1`(`next`)的按插件条目管理配置方式。
旧版由插件接管 `llm-pi-ai`;`next` 保留内置 `llm-pi-ai` 处理自定义提供方,
WorkBuddy 使用自己的配置条目。升级后请完整退出并重新启动 DSH,再检查模型卡片与凭证模式。
桌面版 DSH `0.2.0-rc.2` 同样保留内置 `llm-pi-ai`;请从桌面端「插件 → 添加插件」
安装本插件。下方的命令行安装器仅管理 `web` 和 `headless` Profile,不会安装到桌面端。
在 `next` 上,命令行 `login` 只保存令牌;认证模式需在 WorkBuddy 模型卡片中选择
“令牌登录”,不会再写入旧版 `settings.yaml`。`next` 的模型页通过插件扩展位显示
WorkBuddy 专属认证与模型管理区域;旧版 DSH 继续使用原有编辑界面。
## 安装
在 PowerShell 或终端执行:
```powershell
npx --yes @axiaohungry/dsh-llm-workbuddy@latest install
```
安装器会为 DSH 的 `web` 和 `headless` Profile 安装插件。安装完成后重启 DSH。
只使用 WebUI 时,也可以单独安装 Web Profile:
```powershell
dsh plugin --profile web add @axiaohungry/dsh-llm-workbuddy@latest
```
### 从旧版升级
插件 npm 包现在是 `@axiaohungry/dsh-llm-workbuddy`。重新执行上面的安装命令时,安装器会在
`web` 和 `headless` Profile 中自动移除旧包(包括 `dsh-llm-workbuddy` 和
`dsh-llm-codebuddy`),再安装新包;已有 Provider、模型、
API Key 和令牌凭据会保留。
新版本内部 Provider ID 为 `workbuddy-cn`。旧配置中的 `codebuddy-cn` 会在运行时兼容,并在
切换认证模式时迁移为新 ID。
## WebUI 配置
打开 **设置 → 模型**。在 `next` 中,点击“添加模型提供商”,从“第三方模型提供商”
选择 `WorkBuddy 中国区`;已配置后直接编辑它的模型卡片。旧版 DSH 继续从提供方列表
添加或编辑。认证区域有两个模式按钮:
- `API Key`:只显示 API Key 来源和新增 Key 功能;
- `令牌登录`:只显示令牌账号、登录、切换以及账号用量信息。
切换模式后,另一种模式的账号或 Key 控件会隐藏,不会同时占用页面空间。
### 可选:会话级账号/API Key
认证区域提供“会话级账号/API Key”开关:
- **关闭(默认)**:保持旧版行为。所有会话共用模型设置中当前选中的全局账号或 API Key;
不会改变已有配置和调用方式。
- **打开**:模型设置中选择的账号或 API Key 作为未绑定会话的默认预选。每个会话也可以在
输入框底部单独选择“令牌 / API Key”以及对应账号或 Key。切换当前会话的凭证不会修改
其他会话,也不会中断已有会话上下文。
会话级绑定只保存会话 ID 与账号 ID/API Key 引用,不会把令牌或 API Key 写入会话路由配置。
关闭开关后,已保存的绑定会暂时停用;重新打开后会继续生效。新增或删除账号、Key 时,
对应的无效绑定会自动清理。
开启后,点击“新会话”时会在输入框上方显示“新会话默认凭证”,可以在发送第一条消息前
选择令牌账号或 API Key。新会话或历史未绑定会话第一次调用 WorkBuddy 模型时,插件会先把
当时的默认预选固化到该 `sessionId`,再发出模型请求。固化后,即使模型设置中的默认预选发生
变化,该会话仍继续使用自己的绑定;也可以在会话底部再次切换或解除绑定。解除后,下一次发送
会重新固化当时的默认预选。运行时请求如果缺少 `sessionId`,则使用原有全局认证配置。
模型目录仍由 DSH 统一管理,模型选择本身按会话保存。更换会话凭证后,如果该账号的模型
权限不同,请在模型设置中重新获取一次在线模型目录。
### 方式一:WorkBuddy API Key
#### 使用环境变量中的 Key
插件会自动检测:
```text
WORKBUDDY_API_KEY
```
如果这个环境变量存在,API Key 来源下拉列表会显示“环境变量 WORKBUDDY_API_KEY”。
选择它即可使用;密钥值不会显示在页面中,也不会写入 `settings.yaml`。
旧版插件使用的 `CODEBUDDY_API_KEY` 仍会作为兼容环境变量自动识别,但新配置建议统一使用
`WORKBUDDY_API_KEY`。
#### 在 DSH 中保存新的 Key
1. 选择 `API Key` 模式;
2. 在“新增 API Key”输入框粘贴 WorkBuddy Key;
3. 可填写一个名称,例如“工作账号”或“测试账号”;
4. 点击“添加并使用”;
5. 新 Key 会出现在“当前 API Key”下拉列表中,并立即切换为当前 Key。
DSH 保存的 Key 存放在 DSH 凭据服务中。可以保存多个 Key 并随时切换;环境变量 Key
和 DSH 保存的 Key 互不覆盖。删除操作只允许删除 DSH 保存的 Key,不会删除环境变量。
### 方式二:WorkBuddy 账号令牌
1. 选择 `令牌登录` 模式;
2. 点击“令牌登录”(已有账号时为“添加账号”),插件会打开 WorkBuddy 中国站;
3. 在浏览器完成账号登录;
4. 返回 DSH 后,账号会保存到本地凭据并自动启用。
再次打开页面时,可以在“令牌账号”下拉列表中切换账号。点击“添加账号”可以登录
另一个账号,点击“删除账号”会移除当前账号的本地令牌。令牌登录不需要安装
本机 WorkBuddy 命令行工具。
令牌模式下,插件会尝试显示:
- 剩余积分或企业账号的“不限量”;
- 今日请求次数;
- 今日请求消耗的积分。
积分接口属于 WorkBuddy 中国站的账号服务,仅令牌模式使用;API Key 模式不会调用该
接口。如果服务暂时不可用,页面会保留账号登录状态并显示“暂不可用”。
## 获取和编辑模型
1. 在 `WorkBuddy 中国区` 的设置中点击“获取可用模型”;
2. 插件会使用当前选中的 API Key 或令牌请求 WorkBuddy 模型目录;
3. 选择要使用的模型,按需使用“添加模型”补充,然后点击“保存模型”;
4. 再次编辑时,可以直接修改模型 ID、显示名称、上下文窗口和最大输出 Token。
“恢复在线目录”会移除手工覆盖的模型列表,恢复按在线目录读取。获取在线模型后,
若要固定当前选择或编辑结果,仍需点击“保存模型”。
模型目录按当前账号权限返回。更换 API Key 或令牌账号后,建议重新点击“获取可用模型”。
如果在线目录暂时失败,插件会使用内置目录作为兜底。
在 headless 模式指定内置目录尚未收录的 WorkBuddy 模型时,插件会在调用前查询当前
凭证可用的在线模型目录;模型仍不存在或查询失败时会明确报错。若在配置中手动指定了
模型列表,请把要使用的模型 ID 也加入该列表。
旧版 DSH 中,插件接管 `llm-pi-ai` 后也负责自定义 OpenAI Completions、OpenAI Responses
和 Anthropic Messages 提供方的模型探测;`next` 则保留 DSH 内置的自定义提供方页面与
探测器。两种方式都会请求已配置的模型端点;服务未提供标准目录时,可手工填写模型 ID。
## ModLens 兼容性
插件兼容 `modlens-workbuddy-cn`、`modlens-codebuddy-cn` 等包装 Provider。包装 Provider
会先由 ModLens 处理图片,再把请求转发给 WorkBuddy,因此它不是另一个 WorkBuddy 账号:
- 会话历史中有图片时,首次响应可能比 WorkBuddy 直连慢;旧会话中的历史图片也可能再次参与处理;
- 超时、无响应或 `429`/“配额耗尽”通常来自 ModLens 的视觉引擎,不代表 WorkBuddy 令牌或 API Key 失效;
- 纯文本任务可切换到 `workbuddy-cn/<model-id>`,绕过视觉桥接;
- WorkBuddy 的令牌积分和请求量与 ModLens 视觉引擎额度相互独立,插件不会混合统计。
- ModLens 会保留原始 `sessionId`,因此与 WorkBuddy 直连使用同一套会话凭证固化和切换规则。
遇到包装模型异常时,可运行 ModLens 提供的诊断命令检查视觉引擎状态:
```powershell
modlens doctor --json
```
如果纯文本直连正常、而带历史图片的请求变慢或失败,优先检查 ModLens 的视觉引擎登录状态、网络
和额度;切换 WorkBuddy 账号不会恢复已经耗尽的视觉引擎配额。
## 思考程度
思考档位按模型分别决定,插件不会给所有模型强行使用同一套选项。在线模型目录会声明
每个模型是否支持思考、支持哪些档位以及默认档位,WebUI 会据此显示可用选项。
常见档位包括:
```text
off / minimal / low / medium / high / xhigh / max
```
实际选项可能因模型不同而不同。未手动指定时,使用 WorkBuddy 返回的默认档位;服务端
没有声明思考能力时,插件不会额外发送思考参数。
## 认证和请求说明
- API Key 请求使用 WorkBuddy 的 OpenAI-compatible 接口;
- 令牌请求使用 WorkBuddy 中国站的登录令牌,并在过期前自动刷新;
- DSH 负责 Agent 循环、上下文、工具调用和权限;
- WorkBuddy 负责模型推理并返回结果;
- 访问令牌、刷新令牌和 DSH 保存的 API Key 不会写入模型目录或 `settings.yaml`。
## 更新
重新执行安装命令即可更新:
```powershell
npx --yes @axiaohungry/dsh-llm-workbuddy@latest install
```
更新后重启 DSH。已有的模型配置、API Key 和登录令牌会保留。
## 卸载
```powershell
npx --yes @axiaohungry/dsh-llm-workbuddy@latest uninstall
```
卸载会移除 `WorkBuddy 中国区` 的 Provider 和插件包,并备份 DSH 设置文件。为方便以后
重新安装,API Key 和登录令牌默认保留在 DSH 凭据服务中。
## 常见问题
### 看不到 WorkBuddy Provider
重启 DSH 后重新打开 **设置 → 模型**。也可以检查:
```powershell
dsh plugin --profile web list --depth 0
```
### API Key 下拉列表只有环境变量
不要使用 DSH 原生的旧密钥输入框提交。选择 `API Key` 模式,在插件自己的“新增 API Key”
区域粘贴 Key 并点击“添加并使用”。成功后新 Key 会出现在“当前 API Key”下拉列表。
### 获取模型失败
确认当前选中的 Key 来自 WorkBuddy 且仍然有效,或确认令牌账号登录状态正常。更换认证
凭据后重新获取模型目录。如果出现 `Unexpected end of JSON input`、空响应或模型页接口
`404`,请先完整退出并重新启动 DSH,再刷新页面;只刷新浏览器不会更新正在运行的后端插件。
若返回 `401` 或 `403`,则检查当前凭证及该账号的模型权限。
### 请求卡住或超时
插件会在 DSH 日志中记录不含密钥、令牌和消息内容的请求阶段:认证、HTTP 响应头、
首个模型事件、完成或取消。可用同一请求编号判断停在认证、连接还是生成阶段;
若使用 ModLens 包装 Provider,还应单独检查其视觉引擎状态与额度。
### 令牌登录后没有积分
积分查询只支持令牌模式,并依赖 WorkBuddy 中国站 billing 接口。网络、账号类型或接口
暂时不可用时,页面会显示“暂不可用”,不影响模型调用。
用量栏会识别 `workbuddy` 或 `codebuddy` 词段的 Provider ID,因此通过 ModLens 等插件
包装的 Provider(例如 `modlens-workbuddy-cn`)也可以显示当前账号用量。
### 调用时报 `500 status code (no body)`
先更新插件并重启 DSH:
```powershell
npx --yes @axiaohungry/dsh-llm-workbuddy@latest install
```
最新版会恢复 WorkBuddy 官方请求标识,兼容 API Key 和令牌模式。
### 修改后页面仍显示旧布局
完整退出并重新启动 DSH,再在浏览器执行 `Ctrl+F5` 强制刷新。
## License
[MIT](./LICENSE)
+47 -1
View File
@@ -1,3 +1,49 @@
# dsh-llm-workbuddy
把你的Work Buddy账号中的积分通过官方接口与Api Key调用出来提供给DeepSeek Harness使用!
把你的 WorkBuddy 账号中的积分通过官方接口与 API Key 调用出来,提供给 DeepSeek Harness 使用。
> 本仓库是 [`@axiaohungry/dsh-llm-workbuddy`](https://github.com/Axiaohungry/dsh-llm-workbuddy) v1.3.21 的**个人定制版**(MIT 许可)。
> 原项目版权归 **Axiaohungry** 所有;本定制版的改动见 [`README.CUSTOM.md`](./README.CUSTOM.md)。
> 原作者的完整文档保留在 [`README.UPSTREAM.md`](./README.UPSTREAM.md)。
---
## 相对原版的改动(摘要)
- 对话页只保留一行「剩余积分 xxx」——去掉凭证绑定控件、认证模式选择、多 Key 下拉与「当前会话 API Key」文案
- **API Key 模式下也能查积分**(原版仅「网页令牌登录」模式会查)
- 点击积分弹出卡片:剩余积分 / 套餐 / 下次重置时间
- 移除插件详情页面板与新建会话的「新会话默认凭证」选择器
- `DISPLAY_NAME` 由 `WorkBuddy 中国区` 改为 `WorkBuddy`
完整清单、接口原理与注意事项见 [`README.CUSTOM.md`](./README.CUSTOM.md)。
---
## 安装
DSH 桌面版:
```powershell
& "<DSH 安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add git+http://154.201.73.129:3300/MuLin/dsh-llm-workbuddy.git
```
装完**完全退出 DSH 再启动**。
> 若机器上已装过 npm 上的同名包,请先
> `... plugin --profile desktop remove @axiaohungry/dsh-llm-workbuddy`,
> 否则同一个 provider(`workbuddy-cn`)会被注册两次而冲突。
---
## 许可
MIT,见 [`LICENSE`](./LICENSE)。
**必须保留原作者 Axiaohungry 的版权声明**;本仓库的修改部分版权归 MuLin。
---
## ⚠️ 免责
本版**移除了配置 UI**,只适合**已经配好 Key 的自用场景**。依赖 WorkBuddy **未公开**的接口,
上游随时可能变动,**可用性无任何保证**。请仅限本机自用,勿用于多账号池化、代他人调用或任何形式的转售。
+315
View File
@@ -0,0 +1,315 @@
#!/usr/bin/env node
import { copyFileSync, existsSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { createRequire } from "node:module";
import { delimiter, dirname, join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { parseDocument } from "yaml";
import {
WORKBUDDY_SESSION_REF,
WORKBUDDY_SESSIONS_REF,
LEGACY_SESSION_REF,
LEGACY_SESSIONS_REF,
createWorkBuddySessionStore,
loginWorkBuddy,
parseWorkBuddySession,
parseWorkBuddySessions,
serializeWorkBuddySession,
serializeWorkBuddySessions,
upsertWorkBuddySession,
} from "./workbuddy-auth.js";
const PACKAGE = "@axiaohungry/dsh-llm-workbuddy";
const LEGACY_PACKAGES = ["dsh-llm-workbuddy", "dsh-llm-codebuddy"];
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
const PACKAGE_SPEC = `${PACKAGE}@${PACKAGE_VERSION}`;
const PROVIDER_PATH = ["llm-pi-ai", "providers", "workbuddy-cn"];
const LEGACY_PROVIDER_PATH = ["llm-pi-ai", "providers", "codebuddy-cn"];
const IGNORED_BUILDS = ["@google/genai", "protobufjs"];
const require = createRequire(import.meta.url);
function dshHome() {
return resolve(process.env.DSH_HOME || join(homedir(), ".dsh"));
}
function profileHasPackage(home, profile, packageName) {
const file = join(home, "profiles", profile, "package.json");
if (!existsSync(file)) return false;
const json = JSON.parse(readFileSync(file, "utf8"));
return Boolean(json.dependencies?.[packageName] || json.devDependencies?.[packageName]);
}
function profileHasPlugin(home, profile) {
return profileHasPackage(home, profile, PACKAGE);
}
function dshEnv() {
const pnpmPackageDir = dirname(require.resolve("pnpm"));
const pnpmBinDir = join(dirname(pnpmPackageDir), ".bin");
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "PATH";
return {
...process.env,
[pathKey]: `${pnpmBinDir}${delimiter}${process.env[pathKey] || ""}`,
npm_config_ignore_workspace_root_check: "true",
};
}
function runDsh(args) {
const result = spawnSync(process.platform === "win32" ? "dsh.cmd" : "dsh", args, {
stdio: "inherit",
shell: process.platform === "win32",
env: dshEnv(),
});
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`dsh ${args.join(" ")} 执行失败(退出码 ${result.status})`);
}
function entrySettingsVersion(output) {
const version = String(output).match(/\b(\d+)\.(\d+)\.(\d+)(?:[-.]|\b)/);
if (!version) return undefined;
const [, major, minor, patch] = version.map(Number);
return major > 0 || minor > 1 || (minor === 1 && patch >= 7);
}
function usesEntrySettings() {
const result = spawnSync(process.platform === "win32" ? "dsh.cmd" : "dsh", ["--version"], {
encoding: "utf8",
shell: process.platform === "win32",
env: dshEnv(),
});
if (result.error || result.status !== 0) throw new Error("无法检测 DSH 版本,请确认 dsh 命令可用");
const modern = entrySettingsVersion(result.stdout);
if (modern === undefined) throw new Error("无法识别 DSH 版本,令牌已保存;请在模型设置中手动选择令牌登录");
return modern;
}
function writeYamlDocument(file, document) {
const temporary = join(dirname(file), `.workbuddy-${process.pid}.tmp`);
writeFileSync(temporary, String(document), "utf8");
renameSync(temporary, file);
}
function withPnpmBuildPolicy(file, action) {
const document = parseDocument(readFileSync(file, "utf8"));
if (document.errors.length) throw new Error(`无法解析 ${file}:${document.errors[0].message}`);
const changes = [];
for (const packageName of IGNORED_BUILDS) {
const path = ["allowBuilds", packageName];
if (typeof document.getIn(path) !== "boolean") {
changes.push({ packageName, existed: document.hasIn(path), value: document.getIn(path) });
document.setIn(path, false);
}
}
if (changes.length) writeYamlDocument(file, document);
try {
return action();
} finally {
if (changes.length) {
const current = parseDocument(readFileSync(file, "utf8"));
for (const change of changes) {
const path = ["allowBuilds", change.packageName];
if (change.existed) current.setIn(path, change.value);
else current.deleteIn(path);
}
if (current.getIn(["allowBuilds"])?.items?.length === 0) current.deleteIn(["allowBuilds"]);
writeYamlDocument(file, current);
}
}
}
function cleanPnpmWorkspace(file) {
if (!existsSync(file)) return;
const document = parseDocument(readFileSync(file, "utf8"));
if (document.errors.length) throw new Error(`无法解析 ${file}:${document.errors[0].message}`);
const entries = document.getIn(["minimumReleaseAgeExclude"])?.items;
if (!entries) return;
const packageNames = [PACKAGE, ...LEGACY_PACKAGES];
const remaining = entries.map((entry) => entry.value).filter((entry) => !packageNames.some((packageName) => String(entry).startsWith(`${packageName}@`)));
if (remaining.length === entries.length) return;
if (remaining.length) document.setIn(["minimumReleaseAgeExclude"], remaining);
else document.deleteIn(["minimumReleaseAgeExclude"]);
writeYamlDocument(file, document);
}
function cleanSettings(file) {
if (!existsSync(file)) return undefined;
const source = readFileSync(file, "utf8");
const document = parseDocument(source);
if (document.errors.length) throw new Error(`无法解析 ${file}:${document.errors[0].message}`);
const paths = [PROVIDER_PATH, LEGACY_PROVIDER_PATH].filter((path) => document.hasIn(path));
if (paths.length === 0) return undefined;
for (const path of paths) document.deleteIn(path);
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const backup = `${file}.workbuddy-backup-${stamp}`;
const temporary = join(dirname(file), `.settings-workbuddy-${process.pid}.tmp`);
copyFileSync(file, backup);
writeFileSync(temporary, String(document), "utf8");
renameSync(temporary, file);
return backup;
}
function enableTokenLogin(home = dshHome()) {
const file = join(home, "settings.yaml");
const source = existsSync(file) ? readFileSync(file, "utf8") : "{}\n";
const document = parseDocument(source);
if (document.errors.length) throw new Error(`无法解析 ${file}:${document.errors[0].message}`);
if (document.hasIn(PROVIDER_PATH)) document.deleteIn([...PROVIDER_PATH, "apiKeyEnv"]);
else if (document.hasIn(LEGACY_PROVIDER_PATH)) {
const legacy = document.getIn(LEGACY_PROVIDER_PATH);
const value = legacy && typeof legacy.toJSON === "function" ? legacy.toJSON() : legacy;
document.setIn(PROVIDER_PATH, document.createNode(value && typeof value === "object" ? value : {}));
document.deleteIn([...PROVIDER_PATH, "apiKeyEnv"]);
document.deleteIn(LEGACY_PROVIDER_PATH);
}
else document.setIn(PROVIDER_PATH, {});
if (existsSync(file)) {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
copyFileSync(file, `${file}.workbuddy-backup-${stamp}`);
}
writeYamlDocument(file, document);
}
function storeLoginSession(session, home = dshHome()) {
const file = join(home, ".credentials.yaml");
const document = parseDocument(existsSync(file) ? readFileSync(file, "utf8") : "{}\n");
if (document.errors.length) throw new Error(`无法解析 ${file}:${document.errors[0].message}`);
const stored = document.get(WORKBUDDY_SESSIONS_REF)
?? document.get(LEGACY_SESSIONS_REF)
?? document.get(WORKBUDDY_SESSION_REF)
?? document.get(LEGACY_SESSION_REF);
const current = stored ? parseWorkBuddySessions(stored) : createWorkBuddySessionStore();
const next = upsertWorkBuddySession(current, session);
document.set(WORKBUDDY_SESSIONS_REF, serializeWorkBuddySessions(next));
document.set(WORKBUDDY_SESSION_REF, serializeWorkBuddySession(next.sessions.find((entry) => entry.id === next.activeId)));
document.delete(LEGACY_SESSIONS_REF);
document.delete(LEGACY_SESSION_REF);
if (existsSync(file)) {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
copyFileSync(file, `${file}.workbuddy-backup-${stamp}`);
}
writeYamlDocument(file, document);
}
async function login() {
console.log("正在打开 WorkBuddy 中国站网页登录(无需安装 WorkBuddy CLI)……");
const session = await loginWorkBuddy((url, opened) => {
if (opened) console.log("浏览器登录页已打开,请在浏览器中完成登录。");
else console.log(`无法自动打开浏览器,请手动访问:${url}`);
});
storeLoginSession(session);
if (usesEntrySettings()) {
console.log("WorkBuddy 令牌登录成功。新版 DSH 按插件条目保存配置,请在模型设置的 WorkBuddy 卡片中选择“令牌登录”;无需安装 WorkBuddy CLI。");
} else {
enableTokenLogin();
console.log("WorkBuddy 令牌登录成功,Provider 已切换为令牌模式。请重启 DSH。");
}
}
function install() {
for (const profile of ["web", "headless"]) {
runDsh(["plugin", "--profile", profile, "list", "--depth", "0"]);
const workspace = join(dshHome(), "profiles", profile, "pnpm-workspace.yaml");
withPnpmBuildPolicy(workspace, () => {
for (const legacyPackage of LEGACY_PACKAGES) {
if (profileHasPackage(dshHome(), profile, legacyPackage)) {
runDsh(["plugin", "--profile", profile, "remove", legacyPackage]);
}
}
runDsh(["plugin", "--profile", profile, "add", PACKAGE_SPEC]);
});
}
console.log("WorkBuddy Provider 已安装。请重启 DSH 后进行配置。");
}
function uninstall(home = dshHome()) {
const backup = cleanSettings(join(home, "settings.yaml"));
for (const profile of ["web", "headless"]) {
const workspace = join(home, "profiles", profile, "pnpm-workspace.yaml");
const installedPackages = [PACKAGE, ...LEGACY_PACKAGES].filter((packageName) => profileHasPackage(home, profile, packageName));
if (installedPackages.length) {
withPnpmBuildPolicy(workspace, () => {
for (const packageName of installedPackages) {
runDsh(["plugin", "--profile", profile, "remove", packageName]);
}
});
}
cleanPnpmWorkspace(workspace);
}
console.log(backup ? `WorkBuddy 配置已清理,备份:${backup}` : "未发现 WorkBuddy Provider 配置。");
console.log("插件已卸载,API Key 和登录令牌凭据保持不变。请重启 DSH。");
}
function selfTest() {
const root = mkdtempSync(join(tmpdir(), "dsh-workbuddy-cli-"));
try {
if (entrySettingsVersion("dsh 0.1.6") !== false || entrySettingsVersion("dsh 0.1.7-rc.1") !== true || entrySettingsVersion("dsh 0.2.0-rc.2") !== true) {
throw new Error("DSH version detection self-test failed");
}
const file = join(root, "settings.yaml");
writeFileSync(file, "llm-pi-ai:\n providers:\n opencode-go:\n apiKeyEnv: OPENCODE_GO_API_KEY\n codebuddy-cn:\n apiKeyEnv: WORKBUDDY_CN_API_KEY\n models:\n - id: legacy-model\n", "utf8");
enableTokenLogin(root);
const tokenMode = parseDocument(readFileSync(file, "utf8"));
if (tokenMode.hasIn([...PROVIDER_PATH, "apiKeyEnv"]) || !tokenMode.hasIn(PROVIDER_PATH) || tokenMode.hasIn(LEGACY_PROVIDER_PATH) || !tokenMode.hasIn([...PROVIDER_PATH, "models", 0, "id"])) {
throw new Error("token login settings self-test failed");
}
const backup = cleanSettings(file);
const result = parseDocument(readFileSync(file, "utf8"));
if (!backup || !existsSync(backup) || result.hasIn(PROVIDER_PATH) || !result.hasIn(["llm-pi-ai", "providers", "opencode-go"])) {
throw new Error("uninstall settings cleanup self-test failed");
}
enableTokenLogin(root);
if (!parseDocument(readFileSync(file, "utf8")).hasIn(PROVIDER_PATH)) {
throw new Error("token login provider creation self-test failed");
}
const sampleSession = {
auth: { accessToken: "test-access-token", refreshToken: "test-refresh-token", expiresAt: Date.now() + 60_000 },
account: { userId: "test-user" },
};
storeLoginSession(sampleSession, root);
const storedSession = parseDocument(readFileSync(join(root, ".credentials.yaml"), "utf8")).get(WORKBUDDY_SESSION_REF);
if (parseWorkBuddySession(storedSession).account.userId !== "test-user") {
throw new Error("token credential storage self-test failed");
}
const storedSessions = parseDocument(readFileSync(join(root, ".credentials.yaml"), "utf8")).get(WORKBUDDY_SESSIONS_REF);
const sessionStore = parseWorkBuddySessions(storedSessions);
if (sessionStore.sessions.length !== 1 || sessionStore.activeId !== sessionStore.sessions[0].id) {
throw new Error("token account list self-test failed");
}
const workspace = join(root, "pnpm-workspace.yaml");
writeFileSync(workspace, "packages:\n - .\nallowBuilds:\n '@google/genai': true\n protobufjs: pending\n", "utf8");
withPnpmBuildPolicy(workspace, () => {
const active = parseDocument(readFileSync(workspace, "utf8"));
if (active.getIn(["allowBuilds", "@google/genai"]) !== true || active.getIn(["allowBuilds", "protobufjs"]) !== false) {
throw new Error("pnpm build policy activation self-test failed");
}
});
const restored = parseDocument(readFileSync(workspace, "utf8"));
if (restored.getIn(["allowBuilds", "@google/genai"]) !== true || restored.getIn(["allowBuilds", "protobufjs"]) !== "pending") {
throw new Error("pnpm build policy self-test failed");
}
restored.setIn(["minimumReleaseAgeExclude"], ["other@1.0.0", `${PACKAGE}@1.3.1`]);
writeYamlDocument(workspace, restored);
cleanPnpmWorkspace(workspace);
const cleaned = parseDocument(readFileSync(workspace, "utf8")).getIn(["minimumReleaseAgeExclude"])?.items?.map((entry) => entry.value);
if (cleaned?.join(",") !== "other@1.0.0") throw new Error("pnpm workspace cleanup self-test failed");
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "PATH";
if (!dshEnv()[pathKey].split(delimiter)[0].endsWith(join("node_modules", ".bin"))) {
throw new Error("bundled pnpm PATH self-test failed");
}
console.log("CLI-SELF-TEST-OK");
} finally {
rmSync(root, { recursive: true, force: true });
}
}
const command = process.argv[2];
if (command === "install") install();
else if (command === "uninstall") uninstall();
else if (command === "login") await login();
else if (command === "--self-test") selfTest();
else {
console.log("用法:dsh-llm-workbuddy <install|login|uninstall>");
process.exitCode = 1;
}
+1465
View File
File diff suppressed because it is too large. Load diff
+10
View File
@@ -0,0 +1,10 @@
- id: llm-pi-ai
disabled: !!js >-
!ctx.get('profileContext')?.installAnchor ||
Number(JSON.parse(process.getBuiltinModule('fs').readFileSync(
ctx.get('profileContext').installAnchor, 'utf8')).version
.match(/^0\.1\.(\d+)/)?.[1] ?? 7) < 7
- insert:
- id: llm-workbuddy
name: '@axiaohungry/dsh-llm-workbuddy'
@@ -0,0 +1,579 @@
# WorkBuddy API 调用开发文档
> 适用对象:需要在其他 Agent、CLI、IDE 插件或本地代理中接入 CodeBuddy 模型的开发者
> 验证日期:2026-08-14
> 验证客户端:`@tencent-ai/codebuddy-code 2.136.0`
> 文档性质:基于已安装客户端、实际接口响应和本项目适配代码整理的第三方开发说明,不是腾讯官方 API 承诺。
## 1. 先说明名称关系
日常说的“调用 WorkBuddy API”实际包含两个不同角色:
| 名称 | 实际作用 |
|---|---|
| WorkBuddy | 提供或签发 API Key |
| CodeBuddy | 使用该 Key 的 Agent/CLI 产品 |
| `copilot.tencent.com` | 当前实际提供模型目录和推理接口的服务端 |
| 本项目 | 把上述服务适配为 DSH Provider |
因此,当前观察到的调用链是:
```mermaid
flowchart LR
A["WorkBuddy 提供 API Key"] --> B["CodeBuddy 或第三方 Agent"]
B --> C["GET /v3/config\n获取当前 Key 的模型权限"]
B --> D["POST /v2/chat/completions\n发起模型推理"]
C --> E["copilot.tencent.com"]
D --> E
```
不存在一个需要额外调用的“WorkBuddy 换取 CodeBuddy Token”步骤。开发者拿到有效 Key
后,直接把它用于模型目录和聊天请求。
## 2. 重要边界
1. 接口目前不是公开、稳定承诺的开发者 API,路径、Header 和字段可能随 CodeBuddy 更新。
2. 只能使用本人或组织授权的 API Key,不要绕过账号、额度、模型权限或企业策略。
3. 模型列表与 API Key 绑定,不同 Key 返回的模型 ID 可能不同。
4. 不要把模型目录写死在代码中;每次添加或替换 Key 后应重新获取 `/v3/config`。
5. API Key 只能放在环境变量、凭据服务或 Secret Manager 中,不能提交到 Git。
## 3. 接口总览
| 用途 | 方法 | 地址 | 协议 |
|---|---|---|---|
| 获取模型与产品配置 | `GET` | `https://copilot.tencent.com/v3/config` | JSON |
| 模型推理 | `POST` | `https://copilot.tencent.com/v2/chat/completions` | OpenAI Chat Completions 兼容,推荐 SSE 流式 |
当前不是 OpenAI Responses API,也不是 Anthropic Messages API。第三方 Agent 应优先实现
`openai-completions` / Chat Completions 适配。
## 4. API Key 管理
推荐环境变量:
```text
WORKBUDDY_API_KEY=<从 WorkBuddy 获取的 Key>
```
Windows PowerShell 设置用户环境变量:
```powershell
[Environment]::SetEnvironmentVariable(
"WORKBUDDY_API_KEY",
"<在本机填写,不要提交到仓库>",
"User"
)
```
新进程才能读取新设置。不要在日志中输出 Key;诊断两个 Key 是否相同时,可以比较
SHA-256 指纹,而不是打印明文。
本插件新版本使用 `WORKBUDDY_API_KEY`。为兼容旧版本,仍可读取 `CODEBUDDY_API_KEY`,但新
项目建议统一改用前者。
## 5. 获取当前 Key 可用的模型
### 5.1 请求
```http
GET /v3/config HTTP/1.1
Host: copilot.tencent.com
Accept: application/json
X-API-Key: <API_KEY>
User-Agent: CLI/unknown CodeBuddy/2.136.0
X-Product: SaaS
```
其中:
- `X-API-Key`:模型目录接口的关键鉴权 Header;
- `User-Agent`:按当前 CodeBuddy CLI 行为填写,建议版本升级时同步验证;
- `X-Product: SaaS`:标识当前产品部署类型;
- Header 名大小写不敏感。
### 5.2 响应骨架
成功时 HTTP 通常为 `200`,同时还要检查业务字段 `code`:
```json
{
"code": 0,
"msg": "OK",
"data": {
"agents": [
{
"name": "cli",
"models": ["hy3", "glm-5.3", "deepseek-v4-flash"]
}
],
"models": [
{
"id": "deepseek-v4-flash",
"name": "Deepseek-V4-Flash",
"maxInputTokens": 1000000,
"maxOutputTokens": 50000,
"maxAllowedSize": 1000000,
"supportsImages": true,
"supportsReasoning": true,
"onlyReasoning": true,
"reasoning": {
"effort": "high",
"summary": "auto"
}
}
]
}
}
```
示例只展示与适配有关的字段;实际响应还可能包含企业信息和产品功能开关。
### 5.3 正确的模型筛选算法
不能直接把 `data.models` 全部暴露给用户。正确流程是:
1. 在 `data.agents` 中找到 `name === "cli"` 的 Agent;
2. 读取该 Agent 的 `models`,这是当前 Key 对 CLI 开放的模型 ID;
3. 用这些 ID 与 `data.models[].id` 关联;
4. 丢弃没有容量信息或没有对应配置的异常条目;
5. 保留服务端顺序,避免客户端自行重排造成默认模型变化。
兼容旧响应时,可以同时检查:
```javascript
const agents = Array.isArray(data.agents)
? data.agents
: data.agent?.agents;
```
### 5.4 Node.js 获取模型示例
Node.js 22 以上可直接使用内置 `fetch`,不需要 SDK:
```javascript
const apiKey = process.env.WORKBUDDY_API_KEY;
if (!apiKey) throw new Error("缺少 WORKBUDDY_API_KEY");
const response = await fetch("https://copilot.tencent.com/v3/config", {
headers: {
accept: "application/json",
"x-api-key": apiKey,
"user-agent": "CLI/unknown CodeBuddy/2.136.0",
"x-product": "SaaS",
},
signal: AbortSignal.timeout(20_000),
});
if (!response.ok) {
throw new Error(`模型目录 HTTP ${response.status}`);
}
const body = await response.json();
if (body?.code !== 0) {
throw new Error(`模型目录业务错误:${body?.msg ?? body?.code}`);
}
const agents = Array.isArray(body.data?.agents)
? body.data.agents
: body.data?.agent?.agents;
const allowedIds = agents?.find((agent) => agent?.name === "cli")?.models ?? [];
const byId = new Map((body.data?.models ?? []).map((model) => [model.id, model]));
const models = allowedIds.flatMap((id) => {
const model = byId.get(id);
if (!model) return [];
const contextWindow = model.maxInputTokens ?? model.maxAllowedSize;
const maxTokens = model.maxOutputTokens;
if (!Number.isSafeInteger(contextWindow) || !Number.isSafeInteger(maxTokens)) return [];
return [{
id,
name: model.name ?? id,
contextWindow,
maxTokens,
supportsImages: model.supportsImages === true,
supportsReasoning: model.supportsReasoning === true,
onlyReasoning: model.onlyReasoning === true,
defaultReasoningEffort: model.reasoning?.effort,
thinkingLevelMap: model.thinkingLevelMap,
thinkingFormat: model.thinkingFormat,
}];
});
console.table(models);
```
## 6. 为什么不同账号看到的模型不同
`agents[name=cli].models` 是 Key 级别的授权结果,不是全平台公共目录。
2026-08-14 实测,同一台电脑上的两把有效 Key 返回了不同结果:
| 现象 | Key A | Key B |
|---|---|---|
| GLM 新版本 | 包含 `glm-5.3` | 最高为 `glm-5.2` |
| MiniMax ID | `minimax-m3-pay` | `minimax-m3` |
| Kimi ID | `kimi-k3-2` | `kimi-k3-1` |
因此,出现“CodeBuddy 能看到 `glm-5.3`,另一个 Agent 看不到”时,先确认两个程序实际使用
的是不是同一把 Key。不要通过硬编码 `glm-5.3` 解决,否则请求阶段仍会被服务端拒绝。
## 7. 发起 Chat Completions 请求
### 7.1 请求地址
```text
POST https://copilot.tencent.com/v2/chat/completions
```
### 7.2 鉴权 Header
当前 CodeBuddy CLI 会为模型请求同时准备:
```http
Authorization: Bearer <API_KEY>
X-API-Key: <API_KEY>
Content-Type: application/json
Accept: text/event-stream
```
本项目基于 OpenAI SDK 的适配路径使用 `Authorization: Bearer` 即可完成请求;为了更贴近
CodeBuddy CLI 并兼容服务端策略变化,独立开发的新客户端建议同时发送 `Authorization`
和 `X-API-Key`。不要把 Key 放进 URL Query。
CodeBuddy CLI 还会添加请求 ID、会话 ID、IDE 名称、产品类型等内部 Header。第三方开发的
最小客户端不应伪造这些字段;只有在服务端明确要求时再增加。
### 7.3 最小请求体
```json
{
"model": "deepseek-v4-flash",
"messages": [
{ "role": "user", "content": "请只回复:连接成功" }
],
"stream": true,
"stream_options": { "include_usage": true },
"max_tokens": 1024
}
```
注意:
- 使用 `max_tokens`,不是 `max_completion_tokens`;
- `model` 必须来自当前 Key 的 CLI 模型列表;
- 推荐 `stream: true`,这是 CodeBuddy Agent 的主要工作模式;
- `max_tokens` 不应超过目录中的 `maxOutputTokens`;
- 输入和预期输出总量不能超过模型上下文限制。
## 8. 思考能力必须逐模型处理
不能为所有模型统一写死 `high`,也不能假设所有模型都支持 `xhigh` 或 `max`。至少读取:
| 字段 | 含义 | 处理方式 |
|---|---|---|
| `supportsReasoning` | 模型是否支持推理 | `false` 时不显示思考档位,不发送推理参数 |
| `onlyReasoning` | 是否只能以推理模式工作 | `true` 时不提供 `off` |
| `reasoning.effort` | 该模型的默认思考档位 | 用户未选择时使用;不要拿一个模型的默认值套给其他模型 |
| `thinkingLevelMap` | UI 档位到线上参数的逐模型映射 | 存在时严格按映射提供选项和转换 |
| `thinkingFormat` | 推理参数协议 | 存在时按该格式转换;缺失时才使用当前端点的兼容默认 |
当前实测就存在不同默认值:部分模型为 `high`,部分模型为 `medium`。
### 8.1 选择规则
1. 用户显式选择档位:发送该模型声明支持的档位;
2. 用户选择“默认”或未选择:使用该模型自己的 `reasoning.effort`;
3. 服务端没有声明默认值:省略思考参数,让服务端决定;
4. `supportsReasoning === false`:删除所有推理参数;
5. `onlyReasoning === true`:不要提供 `off`;
6. `thinkingLevelMap` 存在:未出现在 Map 中的档位视为不支持;
7. 不要自动把不支持的档位静默替换成另一个档位,最好在请求前报错。
### 8.2 OpenAI 风格
当前适配使用的默认格式是:
```json
{
"reasoning_effort": "medium"
}
```
常见候选值为:
```text
minimal / low / medium / high / xhigh / max
```
候选值不等于所有模型均支持。以当前模型的能力声明为准。
### 8.3 其他思考格式
如果未来目录返回不同 `thinkingFormat`,适配器可能需要转换,例如:
```json
{ "reasoning": { "effort": "high" } }
```
或:
```json
{ "thinking": { "type": "enabled" }, "reasoning_effort": "high" }
```
不要同时无条件发送所有格式。应为每个模型只生成一种服务端声明的格式。
## 9. 工具调用
请求中的工具遵循 OpenAI Chat Completions 格式:
```json
{
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询天气",
"parameters": {
"type": "object",
"properties": {
"city": { "type": "string" }
},
"required": ["city"],
"additionalProperties": false
}
}
}
],
"tool_choice": "auto"
}
```
流式响应中的工具参数会分段出现在:
```text
choices[0].delta.tool_calls[].function.arguments
```
客户端必须按 `tool_calls[].index` 累积字符串,结束后再解析 JSON。执行工具后,把结果作为
`role: "tool"`、带相同 `tool_call_id` 的消息加入下一轮请求。
## 10. 图片输入
只有 `supportsImages === true` 的模型才能接收图片。OpenAI 兼容格式示例:
```json
{
"role": "user",
"content": [
{ "type": "text", "text": "描述这张图片" },
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,<BASE64>"
}
}
]
}
```
发送前应限制文件大小和 MIME 类型,不要仅根据扩展名判断图片。
## 11. SSE 流式响应解析
服务端返回 `text/event-stream`。每个事件通常形如:
```text
data: {"id":"...","choices":[{"delta":{"content":"你"}}]}
data: {"id":"...","choices":[{"delta":{"content":"好"},"finish_reason":"stop"}]}
data: [DONE]
```
需要处理的字段:
| 字段 | 用途 |
|---|---|
| `choices[0].delta.content` | 最终回答文本增量 |
| `choices[0].delta.reasoning_content` | 思考内容增量之一 |
| `choices[0].delta.reasoning` | 部分模型使用的思考内容字段 |
| `choices[0].delta.reasoning_text` | 另一种兼容思考字段 |
| `choices[0].delta.tool_calls` | 工具调用增量 |
| `choices[0].finish_reason` | `stop`、`length`、`tool_calls` 等结束原因 |
| `usage` | Token 用量,通常在开启 `include_usage` 后的尾部 Chunk 返回 |
同一个 Chunk 可能没有 `choices`,只有 `usage`,不能因此判定响应异常。
## 12. 完整 Node.js 流式示例
```javascript
const apiKey = process.env.WORKBUDDY_API_KEY;
if (!apiKey) throw new Error("缺少 WORKBUDDY_API_KEY");
const response = await fetch("https://copilot.tencent.com/v2/chat/completions", {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
"x-api-key": apiKey,
"content-type": "application/json",
accept: "text/event-stream",
},
body: JSON.stringify({
model: "deepseek-v4-flash",
messages: [{ role: "user", content: "请只回复:连接成功" }],
stream: true,
stream_options: { include_usage: true },
max_tokens: 1024,
// reasoning_effort: "high", // 只在当前模型明确支持且用户选择时添加
}),
signal: AbortSignal.timeout(300_000),
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`推理接口 HTTP ${response.status}: ${detail.slice(0, 500)}`);
}
const decoder = new TextDecoder();
let buffer = "";
let answer = "";
let reasoning = "";
for await (const chunk of response.body) {
buffer += decoder.decode(chunk, { stream: true });
let boundary;
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
const event = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
for (const line of event.split(/\r?\n/)) {
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (!data || data === "[DONE]") continue;
const payload = JSON.parse(data);
const delta = payload.choices?.[0]?.delta;
if (typeof delta?.content === "string") {
answer += delta.content;
process.stdout.write(delta.content);
}
const thought = delta?.reasoning_content ?? delta?.reasoning ?? delta?.reasoning_text;
if (typeof thought === "string") reasoning += thought;
if (payload.usage) console.error("\nusage:", payload.usage);
}
}
}
console.log("\n\nanswer:", answer);
console.log("reasoning length:", reasoning.length);
```
生产代码还需要按 `tool_calls[].index` 累积工具参数,并处理单个 SSE 事件跨网络 Chunk 的情况。
## 13. 错误处理与重试
### 13.1 模型目录
同时检查 HTTP 状态和 JSON 业务状态:
```javascript
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (body.code !== 0) throw new Error(body.msg ?? String(body.code));
```
### 13.2 常见分类
| 情况 | 建议处理 |
|---|---|
| `401` / `403` | Key 无效、过期或无权限;停止重试并要求重新填写 |
| `408` / 网络断开 | 指数退避重试 |
| `429` | 遵守 `Retry-After`;提示额度或频率限制 |
| `5xx` | 短暂退避后有限重试 |
| 模型不在 CLI 列表 | 刷新目录,不要强制调用 |
| 流结束但没有结束原因 | 视为不完整响应,不要保存为成功回答 |
流式请求只应在“尚未收到任何有效增量”时自动重试。收到文本或工具调用后再次自动重试,
可能造成重复输出或重复执行工具。
### 13.3 推荐超时
- 模型目录:20 秒总超时;
- 建立推理连接:30–60 秒;
- 流空闲超时:300 秒;
- 用户取消:通过 `AbortController` 立即向上游传播。
## 14. 缓存策略
模型目录可以按 Key 指纹缓存,但不能跨 Key 共用:
```text
cache key = SHA-256(API Key) + 客户端产品类型
```
建议:
- 缓存 5–15 分钟;
- 用户点击“刷新模型”时绕过缓存;
- 更换 Key 后立即清除旧缓存;
- 缓存失败时可以使用最近一次成功目录,但 UI 必须标注“可能过期”;
- 不要把完整 Key 写进缓存键或日志。
## 15. 适配其他 Agent 的最小接口
一个可维护的 Provider 只需要四个职责:
```text
resolveCredential() -> 安全读取 Key
discoverModels() -> 获取并按 CLI 权限过滤模型
describeModel(id) -> 返回该模型容量、模态、思考档位和默认值
stream(request) -> 转换消息并解析 SSE
```
DSH、OpenCode 或其他 Agent 自己负责:
- Agent 循环;
- 上下文裁剪与压缩;
- 工具实际执行;
- 权限确认;
- 会话持久化;
- 重试策略和用户取消。
CodeBuddy 服务端负责模型推理,不会替第三方 Agent 自动执行本地工具。
## 16. 验收清单
开发完成后至少验证:
- [ ] Key 不出现在源码、Git、日志和异常堆栈中;
- [ ] 两把不同 Key 的模型目录不会串用;
- [ ] 只显示 `agents[name=cli].models` 授权的模型;
- [ ] 新模型无需发布新代码即可出现;
- [ ] 旧模型下线后不会继续从缓存永久显示;
- [ ] 上下文窗口和最大输出来自逐模型字段;
- [ ] 非图片模型会在请求前拒绝图片;
- [ ] 非推理模型不显示思考控件;
- [ ] `onlyReasoning` 模型不显示 `off`;
- [ ] 每个模型使用自己的默认思考档位;
- [ ] 用户显式档位会转换为该模型自己的线上值;
- [ ] 能累计文本、思考和工具调用 SSE 增量;
- [ ] 用户取消能中止网络请求;
- [ ] 429 和 5xx 有限重试,不会无限循环;
- [ ] 工具调用不会因自动重试而重复执行。
## 17. 版本漂移检查
升级 CodeBuddy CLI 后,应重新核对:
1. `product.json` 和 CLI 版本;
2. 配置接口是否仍为 `/v3/config`;
3. 推理接口是否仍为 `/v2/chat/completions`;
4. 鉴权是否仍接受 `Authorization` / `X-API-Key`;
5. 模型能力字段是否新增 `thinkingLevelMap`、`thinkingFormat` 等;
6. SSE 推理和工具字段是否变化;
7. 当前 Key 实际返回的 CLI 模型目录。
本项目的可执行实现位于 [`index.js`](../index.js),安装和使用说明见
[`README.md`](../README.md)。
+982
View File
@@ -0,0 +1,982 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { credentialRef } from "@deepseek-ai/dsh-credentials";
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
import { LlmError, assertUsableApiKey, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
import { Config, PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
import * as dshSettings from "@deepseek-ai/dsh-settings";
import { createProvider } from "@earendil-works/pi-ai";
import * as openAICompletionsApi from "@earendil-works/pi-ai/api/openai-completions";
import * as openAIResponsesApi from "@earendil-works/pi-ai/api/openai-responses";
import * as anthropicMessagesApi from "@earendil-works/pi-ai/api/anthropic-messages";
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
import {
WORKBUDDY_SESSION_REF,
WORKBUDDY_SESSIONS_REF,
WORKBUDDY_SESSION_ROUTING_REF,
LEGACY_SESSION_REF,
LEGACY_SESSIONS_REF,
activeWorkBuddySession,
createWorkBuddySessionStore,
createWorkBuddySessionRoutingState,
parseWorkBuddySession,
parseWorkBuddySessions,
parseWorkBuddySessionRouting,
refreshWorkBuddySession,
serializeWorkBuddySession,
serializeWorkBuddySessionRouting,
serializeWorkBuddySessions,
sessionCacheDeadline,
sessionNeedsRefresh,
upsertWorkBuddySession,
} from "./workbuddy-auth.js";
import { installWorkBuddyWeb } from "./workbuddy-web.js";
import { probeEndpoint } from "./workbuddy-discovery.js";
export { Config };
export const name = "llm-workbuddy";
export const inject = ["llm"];
const NS = typeof dshSettings.settingsNamespace === "function" ? dshSettings.settingsNamespace("llm-pi-ai") : "llm-pi-ai";
const PROVIDER = "workbuddy-cn";
const LEGACY_PROVIDER = "codebuddy-cn";
const WORKBUDDY_PROVIDERS = new Set([PROVIDER, LEGACY_PROVIDER]);
const WORKBUDDY_PROVIDER_PATTERN = /(?:^|-)(?:work-?buddy|code-?buddy)(?:-|$)/;
const DISPLAY_NAME = "WorkBuddy";
const API_KEY_ENV = "WORKBUDDY_API_KEY";
const LEGACY_API_KEY_ENV = "CODEBUDDY_API_KEY";
const BASE_URL = "https://copilot.tencent.com/v2";
const CONFIG_URL = "https://copilot.tencent.com/v3/config";
const USER_AGENT = "CLI/unknown CodeBuddy/2.137.1";
const STREAM_IDLE_TIMEOUT_MS = 300_000;
const traceContext = new AsyncLocalStorage();
let nextTraceId = 0;
const NO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
const EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"];
const THINKING_LEVELS = ["off", ...EFFORTS];
const COMPAT = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: true,
maxTokensField: "max_tokens",
thinkingFormat: "openai",
};
function workBuddyRequestOptions(options) {
const trace = traceContext.getStore();
const fetchImpl = options?.fetch ?? globalThis.fetch;
return {
...options,
timeoutMs: options?.timeoutMs ?? STREAM_IDLE_TIMEOUT_MS,
headers: { ...(options?.headers ?? {}), "user-agent": USER_AGENT },
...(trace ? { fetch: async (input, init) => {
const started = Date.now();
traceEvent(trace, "http.start");
try {
const response = await fetchImpl(input, init);
traceEvent(trace, "http.headers", { elapsedMs: Date.now() - started, status: response.status });
return response;
} catch (error) {
traceEvent(trace, "http.error", { elapsedMs: Date.now() - started, ...errorFields(error) });
throw error;
}
} } : {}),
};
}
function errorFields(error) {
const safe = (value) => typeof value === "string" && /^[A-Za-z_][A-Za-z_0-9-]{0,39}$/.test(value) ? value : undefined;
return {
...(safe(error?.name) ? { errorName: safe(error.name) } : {}),
...(safe(error?.code) ? { errorCode: safe(error.code) } : {}),
...(Number.isInteger(error?.status) ? { status: error.status } : {}),
};
}
function traceEvent(trace, stage, details = {}) {
if (!trace) return;
console.info("[dsh-llm-workbuddy]", JSON.stringify({ request: trace.id, stage, ...details }));
}
function observedWorkBuddyStream(model, context, options) {
const trace = traceContext.getStore();
const started = Date.now();
const stream = openAICompletionsApi.streamSimple(model, context, workBuddyRequestOptions(options));
return (async function* () {
traceEvent(trace, "model.start");
let first = true;
let completed = false;
let failed = false;
try {
for await (const chunk of stream) {
if (first) {
first = false;
traceEvent(trace, "model.first-chunk", { elapsedMs: Date.now() - started });
}
yield chunk;
}
completed = true;
traceEvent(trace, "model.done", { elapsedMs: Date.now() - started });
} catch (error) {
failed = true;
traceEvent(trace, "model.error", { elapsedMs: Date.now() - started, ...errorFields(error) });
throw error;
} finally {
if (!completed && !failed) traceEvent(trace, "model.cancelled", { elapsedMs: Date.now() - started });
}
})();
}
const workBuddyApi = {
...openAICompletionsApi,
stream: (model, context, options) => openAICompletionsApi.stream(model, context, workBuddyRequestOptions(options)),
streamSimple: observedWorkBuddyStream,
};
const FALLBACK_MODELS = [
["hy3", "Hy3", 192000, 64000, true],
["glm-5.2", "GLM-5.2", 1000000, 48000, false],
["glm-5.1", "GLM-5.1", 200000, 48000, false],
["glm-5v-turbo", "GLM-5v-Turbo", 200000, 64000, true],
["minimax-m3-pay", "MiniMax-M3", 512000, 128000, true],
["minimax-m2.7", "MiniMax-M2.7", 200000, 48000, true],
["kimi-k3-2", "Kimi-K3", 1000000, 32000, true],
["kimi-k2.7", "Kimi-K2.7-Code", 256000, 32000, true],
["kimi-k2.6", "Kimi-K2.6", 256000, 32000, true],
["deepseek-v4-pro", "DeepSeek V4 Pro", 1000000, 50000, true],
["deepseek-v4-flash", "DeepSeek V4 Flash", 1000000, 50000, true],
].map(([id, modelName, contextWindow, maxTokens, images]) =>
workBuddyModel({ id, name: modelName, contextWindow, maxTokens, images }),
);
function workBuddyModel({ provider = PROVIDER, id, name: modelName, contextWindow, maxTokens, images, reasoning = true, thinkingLevelMap = { off: null }, defaultReasoningEffort, thinkingFormat }) {
return {
id,
name: modelName,
api: "openai-completions",
provider,
baseUrl: BASE_URL,
reasoning,
...(reasoning ? { thinkingLevelMap: { ...thinkingLevelMap } } : {}),
...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
input: images ? ["text", "image"] : ["text"],
cost: { ...NO_COST },
contextWindow,
maxTokens,
compat: { ...COMPAT, ...(thinkingFormat ? { thinkingFormat } : {}) },
};
}
function remoteReasoning(raw, fallback) {
const reasoning = raw.supportsReasoning ?? fallback?.reasoning ?? raw.onlyReasoning === true;
if (!reasoning) return { reasoning: false };
const declared = raw.thinkingLevelMap && typeof raw.thinkingLevelMap === "object" ? raw.thinkingLevelMap : undefined;
const thinkingLevelMap = declared
? Object.fromEntries(THINKING_LEVELS.map((level) => [level,
Object.hasOwn(declared, level) && (typeof declared[level] === "string" || declared[level] === null) ? declared[level] : null]))
: { ...(fallback?.thinkingLevelMap ?? {}), ...(raw.onlyReasoning === true ? { off: null } : {}) };
const effort = raw.reasoning?.effort;
const defaultReasoningEffort = EFFORTS.includes(effort) && thinkingLevelMap[effort] !== null ? effort : undefined;
return {
reasoning: true,
thinkingLevelMap,
...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
...(typeof raw.thinkingFormat === "string" ? { thinkingFormat: raw.thinkingFormat } : {}),
};
}
function configuredReasoning(entry, base) {
if (entry.reasoningEfforts === false) return { reasoning: false };
if (!entry.reasoningEfforts || typeof entry.reasoningEfforts !== "object") {
return base ? {
reasoning: base.reasoning,
thinkingLevelMap: base.thinkingLevelMap,
defaultReasoningEffort: base.defaultReasoningEffort,
thinkingFormat: base.compat?.thinkingFormat,
} : { reasoning: false };
}
const map = {};
for (const level of THINKING_LEVELS) {
if (!Object.hasOwn(entry.reasoningEfforts, level)) map[level] = null;
else if (!(level === "off" && entry.reasoningEfforts[level] === null)) map[level] = entry.reasoningEfforts[level];
}
return { reasoning: true, thinkingLevelMap: map, thinkingFormat: entry.compat?.thinkingFormat };
}
function positiveInteger(...values) {
return values.find((value) => Number.isSafeInteger(value) && value > 0);
}
function text(...values) {
return values.find((value) => typeof value === "string" && value.length > 0);
}
function modelsFromConfig(data) {
const agents = Array.isArray(data?.agents) ? data.agents : data?.agent?.agents;
const cli = Array.isArray(agents) ? agents.find((agent) => agent?.name === "cli") : undefined;
const allowed = Array.isArray(cli?.models) ? cli.models : [];
const source = Array.isArray(data?.models) ? data.models : [];
const byId = new Map(source.map((model) => [model?.id, model]));
return allowed.flatMap((id) => {
const raw = byId.get(id);
if (!raw) return [];
const fallback = FALLBACK_MODELS.find((model) => model.id === id);
const contextWindow = positiveInteger(raw.maxInputTokens, raw.maxAllowedSize, fallback?.contextWindow);
const maxTokens = positiveInteger(raw.maxOutputTokens, fallback?.maxTokens);
if (!contextWindow || !maxTokens) return [];
return [workBuddyModel({
id,
name: text(raw.name, fallback?.name, id),
contextWindow,
maxTokens,
images: raw.supportsImages === true || fallback?.input.includes("image") === true,
...remoteReasoning(raw, fallback),
})];
});
}
function authenticationHeaders(credential) {
const value = assertUsableApiKey(credential.value, name, credential.ref ?? API_KEY_ENV);
return credential.kind === "bearer" ? { authorization: `Bearer ${value}` } : { "x-api-key": value };
}
async function fetchWorkBuddyModels(credential, signal) {
let response;
try {
response = await fetch(CONFIG_URL, {
headers: {
accept: "application/json",
...authenticationHeaders(credential),
"user-agent": USER_AGENT,
"x-product": "SaaS",
},
signal,
});
} catch (error) {
if (signal?.aborted) throw new LlmError("WorkBuddy 模型列表获取已取消", "ABORTED", { cause: error });
throw new LlmError("无法连接 WorkBuddy 模型配置接口", "DISCOVERY_FAILED", { cause: error });
}
if (!response.ok) throw new LlmError(`WorkBuddy 模型配置接口返回 ${response.status}`, "DISCOVERY_FAILED");
const body = await response.json();
if (body?.code !== 0) throw new LlmError(`WorkBuddy 模型配置接口错误:${body?.msg ?? body?.code}`, "DISCOVERY_FAILED");
const models = modelsFromConfig(body.data);
if (models.length === 0) throw new LlmError("WorkBuddy 没有返回 CLI 可用模型", "DISCOVERY_FAILED");
return models;
}
/**
* WorkBuddy's credential is already resolved by the DSH adapter. Do not
* reuse pi-ai's DeepSeek envApiKeyAuth here: newer pi-ai releases require a
* signal argument while older DSH adapters call auth resolvers without one.
* This small adapter accepts both contracts and keeps bearer/API-key values
* opaque to the provider implementation.
*/
function workBuddyApiKeyAuth() {
return {
name: `${DISPLAY_NAME} API Key`,
login: async (interaction) => {
const signal = interaction?.signal;
signal?.throwIfAborted?.();
const key = await interaction.prompt({ type: "secret", message: `Enter ${DISPLAY_NAME} API Key` });
signal?.throwIfAborted?.();
return { type: "api_key", key };
},
resolve: async ({ credential, signal } = {}) => {
signal?.throwIfAborted?.();
if (!credential?.key) return undefined;
return {
auth: { apiKey: credential.key },
...(credential.env ? { env: credential.env } : {}),
source: "DSH credential",
};
},
};
}
function workBuddyProvider(models, provider = PROVIDER) {
return createProvider({
id: provider,
name: DISPLAY_NAME,
baseUrl: BASE_URL,
auth: { apiKey: workBuddyApiKeyAuth() },
models: models.map((model) => ({ ...model, provider })),
api: workBuddyApi,
});
}
const GENERIC_APIS = Object.freeze({
"openai-completions": openAICompletionsApi,
"openai-responses": openAIResponsesApi,
"anthropic-messages": anthropicMessagesApi,
});
const GENERIC_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
function genericApiKeyAuth(provider) {
return {
name: `${provider} API Key`,
resolve: async ({ credential, signal } = {}) => {
signal?.throwIfAborted?.();
if (!credential?.key) return undefined;
return { auth: { apiKey: credential.key }, source: "DSH credential" };
},
};
}
function genericModel(provider, source, entry) {
const reasoningEfforts = entry.reasoningEfforts;
const reasoning = reasoningEfforts !== false && reasoningEfforts && typeof reasoningEfforts === "object";
const thinkingLevelMap = reasoning
? Object.fromEntries(GENERIC_LEVELS.filter((level) => Object.hasOwn(reasoningEfforts, level)).map((level) => [level, reasoningEfforts[level]]))
: undefined;
return {
id: entry.id,
name: entry.name ?? entry.id,
api: source.api,
provider,
baseUrl: source.baseURL,
input: Array.isArray(entry.input) && entry.input.length > 0 ? [...entry.input] : [...source.defaultInput ?? ["text"]],
cost: { ...NO_COST },
contextWindow: entry.contextWindow ?? source.defaultContextWindow ?? 262144,
maxTokens: entry.maxTokens ?? source.defaultMaxTokens ?? 32768,
...(reasoning ? { reasoning: true, thinkingLevelMap } : {}),
...(entry.compat ?? source.compat ? { compat: { ...(source.compat ?? {}), ...(entry.compat ?? {}) } } : {}),
};
}
function genericProvider(provider, source = {}) {
const api = GENERIC_APIS[source.api];
if (!api || !source.baseURL || !Array.isArray(source.models) || source.models.length === 0) return undefined;
return createProvider({
id: provider,
name: source.displayName ?? provider,
baseUrl: source.baseURL,
headers: source.headers,
auth: { apiKey: genericApiKeyAuth(provider) },
models: source.models.map((entry) => genericModel(provider, source, entry)),
api,
});
}
function resolvedProfile(provider, source, piProvider, configuredMaxTokens = new Map(), requestContext) {
const apiKeyEnv = source.apiKeyEnv === undefined ? undefined : credentialRef(source.apiKeyEnv);
return {
...source,
headers: runtimeHeaders(source.headers, requestContext),
provider,
displayName: source.displayName ?? piProvider.name ?? provider,
// dsh-llm-pi-ai reads this map for every exact model during catalog
// resolution. WorkBuddy profiles have no per-model validation failures
// here, but must still provide the empty map for the shared adapter API.
modelErrors: new Map(),
...(apiKeyEnv === undefined ? {} : { apiKeyEnv }),
streamIdleTimeoutMs: source.streamIdleTimeoutMs ?? STREAM_IDLE_TIMEOUT_MS,
retryPolicy: resolveRetryPolicy(source.retryPolicy, `${name}: provider "${provider}" retryPolicy`),
configuredMaxTokens,
piProvider,
};
}
function selectBuiltinModels(base, entries) {
if (!Array.isArray(entries) || entries.length === 0) return base;
const byId = new Map(base.getModels().map((model) => [model.id, model]));
const selected = entries.flatMap((entry) => {
const model = byId.get(entry.id);
if (!model) return [];
return [{
...model,
...(entry.name ? { name: entry.name } : {}),
...(entry.contextWindow ? { contextWindow: entry.contextWindow } : {}),
...(entry.maxTokens ? { maxTokens: entry.maxTokens } : {}),
...(Array.isArray(entry.input) && entry.input.length ? { input: [...entry.input] } : {}),
}];
});
return { ...base, getModels: () => selected };
}
function selectWorkBuddyModels(base, entries) {
if (!Array.isArray(entries) || entries.length === 0) return base;
const byId = new Map(base.map((model) => [model.id, model]));
return entries.map((entry) => {
const model = byId.get(entry.id);
const reasoning = configuredReasoning(entry, model);
return workBuddyModel({
id: entry.id,
name: entry.name ?? model?.name ?? entry.id,
contextWindow: entry.contextWindow ?? model?.contextWindow ?? 262144,
maxTokens: entry.maxTokens ?? model?.maxTokens ?? 32768,
images: entry.input?.includes("image") ?? model?.input.includes("image") ?? false,
...reasoning,
});
});
}
function ownsProvider(provider, builtins, source) {
return WORKBUDDY_PROVIDERS.has(provider) || builtins.has(provider) || genericProvider(provider, source) !== undefined;
}
function runtimeHeaders(headers, requestContext) {
const base = { ...(headers ?? {}) };
if (!requestContext) return base;
return new Proxy(base, {
ownKeys(target) {
const extra = requestContext.getStore()?.headers ?? {};
return [...new Set([...Reflect.ownKeys(target), ...Reflect.ownKeys(extra)])];
},
getOwnPropertyDescriptor(target, property) {
const extra = requestContext.getStore()?.headers ?? {};
if (!Reflect.has(target, property) && !Reflect.has(extra, property)) return undefined;
return { configurable: true, enumerable: true, writable: true, value: this.get(target, property) };
},
get(target, property, receiver) {
const extra = requestContext.getStore()?.headers ?? {};
return Reflect.has(extra, property) ? extra[property] : Reflect.get(target, property, receiver);
},
});
}
function sessionBindingFor(routing, sessionId) {
if (!routing?.enabled) return undefined;
const id = typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : undefined;
return id && Object.hasOwn(routing.bindings, id) ? routing.bindings[id] : undefined;
}
function normalizedProviderName(value) {
return String(value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function isWorkBuddyProviderName(value) {
const normalized = normalizedProviderName(value);
return normalized.length > 0 && WORKBUDDY_PROVIDER_PATTERN.test(normalized);
}
function directWorkBuddyProvider(value) {
const normalized = normalizedProviderName(value);
if (normalized === PROVIDER || normalized === LEGACY_PROVIDER) return normalized;
return undefined;
}
function interruptedToolTailAssistantIndex(messages) {
let tailIndex = messages.length - 1;
while (tailIndex >= 0 && messages[tailIndex]?.role === "system") tailIndex -= 1;
if (tailIndex < 0) return -1;
const tail = messages[tailIndex];
if (tail?.role !== "user" || !Array.isArray(tail.content)) return -1;
if (!tail.content.some((block) => block?.type === "tool-result" && block.isError === true)) return -1;
for (let index = tailIndex - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.role !== "assistant") continue;
return Array.isArray(message.content) && message.content.some((block) => block?.type === "tool-call") ? index : -1;
}
return -1;
}
/**
* Make WorkBuddy replay metadata safe across direct and wrapped provider ids.
* The returned messages are request-only copies; durable session history is
* never rewritten. An interrupted tool result deliberately loses only the
* preceding assistant replayState so the model receives ordinary history.
*/
function normalizeWorkBuddyReplay(options) {
if (!Array.isArray(options?.messages)) return options;
const currentProvider = options.provider;
if (!isWorkBuddyProviderName(currentProvider)) return options;
const interruptedIndex = interruptedToolTailAssistantIndex(options.messages);
let changed = false;
const messages = options.messages.map((message, index) => {
const source = message?.source;
const state = source?.replayState;
if (message?.role !== "assistant" || !source || state?.kind !== "pi-ai" || state?.version !== 1) return message;
let nextSource = source;
const sourceProvider = source.provider;
const replayProvider = state.provider;
if (isWorkBuddyProviderName(sourceProvider) && isWorkBuddyProviderName(replayProvider)) {
const canonical = directWorkBuddyProvider(replayProvider)
?? directWorkBuddyProvider(sourceProvider)
?? directWorkBuddyProvider(currentProvider);
if (canonical && (sourceProvider !== canonical || replayProvider !== canonical)) {
nextSource = {
...nextSource,
provider: canonical,
replayState: { ...state, provider: canonical },
};
}
}
if (index === interruptedIndex && nextSource.replayState !== undefined) {
const { replayState: _ignored, ...withoutReplay } = nextSource;
nextSource = withoutReplay;
}
if (nextSource === source) return message;
changed = true;
return { ...message, source: nextSource };
});
return changed ? { ...options, messages } : options;
}
// The rc.6 pi-ai adapter rejects replay metadata it does not understand. A
// newer DSH may persist a v2 envelope, so let old adapters use the durable
// message content as provider-neutral history instead of failing the request.
function stripUnsupportedReplay(options) {
if (!Array.isArray(options?.messages)) return options;
let changed = false;
const messages = options.messages.map((message) => {
const source = message?.source;
const state = source?.replayState;
if (state === undefined || (state?.kind === "pi-ai" && state?.version === 1)) return message;
changed = true;
const { replayState: _ignored, ...sourceWithoutReplay } = source;
return { ...message, source: sourceWithoutReplay };
});
return changed ? { ...options, messages } : options;
}
function prepareWorkBuddyOptions(options, legacyReplay = true) {
const normalized = normalizeWorkBuddyReplay(options);
return legacyReplay ? stripUnsupportedReplay(normalized) : normalized;
}
function workBuddySource(config, source) {
const providers = providerSettings(config);
return Object.hasOwn(providers, PROVIDER) || Object.hasOwn(providers, LEGACY_PROVIDER)
? source
: { ...source, apiKeyEnv: source.apiKeyEnv ?? API_KEY_ENV };
}
function providerSettings(config) {
return typeof config?.providers?.get === "function" ? config.providers.get() : config?.providers ?? {};
}
function installSettingsCompat(ctx, ns, schema, entry, hooks) {
if (typeof entry?.providers?.get === "function") {
return ctx.inject(["settings"], (child) => {
child.effect(() => child.settings.configure({ auto: false }, ctx.fiber));
});
}
if (typeof dshSettings.installSettingsSection === "function") {
return dshSettings.installSettingsSection(ctx, ns, schema, entry, hooks);
}
return ctx.inject(["settings"], (settingsCtx) => {
if (!settingsCtx.settings || typeof settingsCtx.settings.installSection !== "function") {
throw new Error(`${name}: DSH settings service does not provide installSection`);
}
return settingsCtx.settings.installSection(ctx, ns, schema, entry, hooks);
});
}
export const __testing = Object.freeze({ authenticationHeaders, workBuddyApiKeyAuth, workBuddyRequestOptions, workBuddySource, providerSettings, genericProvider, modelsFromConfig, ownsProvider, runtimeHeaders, stripUnsupportedReplay, normalizeWorkBuddyReplay, prepareWorkBuddyOptions, selectWorkBuddyModels, sessionBindingFor });
export function apply(ctx, config) {
const modernSettings = typeof config?.providers?.get === "function";
const settingsNs = modernSettings ? ctx.fiber?.entry?.options?.id ?? name : NS;
installWorkBuddyWeb(ctx, settingsNs);
let current = () => config;
const requestContext = new AsyncLocalStorage();
let remoteModels;
let generation = 0;
let memoRaw;
let memoProviders;
let memoGeneration = -1;
let memoized;
const loginSessionPromises = new Map();
let remoteModelsKey;
const builtins = new Map(builtinProviders().map((provider) => [provider.id, provider]));
const effectiveConfig = () => {
const raw = current() ?? {};
const providers = providerSettings(raw);
const configured = providers[PROVIDER] ?? providers[LEGACY_PROVIDER];
return {
...raw,
providers: {
...providers,
[PROVIDER]: configured ?? { apiKeyEnv: API_KEY_ENV },
},
};
};
const profiles = () => {
const raw = effectiveConfig();
const configuredProviders = providerSettings(current());
if (memoRaw === current() && memoProviders === configuredProviders && memoGeneration === generation && memoized) return memoized;
const result = new Map();
for (const [provider, source] of Object.entries(raw.providers)) {
if (modernSettings && !WORKBUDDY_PROVIDERS.has(provider)) continue;
if (!ownsProvider(provider, builtins, source)) continue;
if (WORKBUDDY_PROVIDERS.has(provider)) {
const sourceWithAuth = workBuddySource(current(), source);
const models = selectWorkBuddyModels(remoteModels ?? FALLBACK_MODELS, source.models);
const configured = new Map((source.models ?? []).flatMap((model) =>
Number.isSafeInteger(model.maxTokens) && model.maxTokens > 0 ? [[model.id, model.maxTokens]] : [],
));
result.set(provider, resolvedProfile(provider, {
...sourceWithAuth,
headers: runtimeHeaders(sourceWithAuth.headers, requestContext),
displayName: DISPLAY_NAME,
}, workBuddyProvider(models, provider), configured, requestContext));
continue;
}
const base = builtins.get(provider);
if (!base) {
const generic = genericProvider(provider, source);
if (!generic) continue;
const configured = new Map((source.models ?? []).flatMap((model) =>
Number.isSafeInteger(model.maxTokens) && model.maxTokens > 0 ? [[model.id, model.maxTokens]] : [],
));
result.set(provider, resolvedProfile(provider, source, generic, configured));
continue;
}
const selected = selectBuiltinModels(base, source.models);
const configured = new Map((source.models ?? []).flatMap((model) =>
Number.isSafeInteger(model.maxTokens) && model.maxTokens > 0 ? [[model.id, model.maxTokens]] : [],
));
result.set(provider, resolvedProfile(provider, source, selected, configured));
}
memoRaw = current();
memoProviders = configuredProviders;
memoGeneration = generation;
memoized = result;
return result;
};
const readSessionRouting = async () => {
const credentials = ctx.get("credentials");
const env = launchEnvironmentOf(ctx);
const ref = credentialRef(WORKBUDDY_SESSION_ROUTING_REF);
const stored = await credentials?.resolve(ref);
const value = stored?.value ?? env.get(ref)?.value;
return value ? parseWorkBuddySessionRouting(value) : createWorkBuddySessionRoutingState();
};
let routingBindingQueue = Promise.resolve();
const persistDefaultSessionBinding = (sessionId, fallbackBinding) => {
const task = routingBindingQueue.then(async () => {
const latest = await readSessionRouting();
const existing = sessionBindingFor(latest, sessionId);
if (existing) return existing;
const binding = latest.lastUsed ?? fallbackBinding;
if (!binding) return undefined;
const credentials = ctx.get("credentials");
if (!credentials) throw new Error("DSH 凭据服务不可用,无法保存会话认证");
await credentials.set(credentialRef(WORKBUDDY_SESSION_ROUTING_REF), serializeWorkBuddySessionRouting({
...latest,
bindings: { ...latest.bindings, [sessionId]: binding },
}));
return binding;
});
routingBindingQueue = task.then(() => undefined, () => undefined);
return task;
};
const resolveLoginSession = async (requestedId) => {
const key = typeof requestedId === "string" && requestedId ? requestedId : "active";
let promise = loginSessionPromises.get(key);
if (!promise) {
promise = (async () => {
const credentials = ctx.get("credentials");
const env = launchEnvironmentOf(ctx);
const sessionsRef = credentialRef(WORKBUDDY_SESSIONS_REF);
const sessionRefs = [sessionsRef, credentialRef(LEGACY_SESSIONS_REF)];
let sessionsValue;
for (const ref of sessionRefs) {
const storedSessions = await credentials?.resolve(ref);
sessionsValue = storedSessions?.value ?? env.get(ref)?.value;
if (sessionsValue) break;
}
let store;
if (sessionsValue) {
store = parseWorkBuddySessions(sessionsValue);
} else {
const legacyRefs = [credentialRef(WORKBUDDY_SESSION_REF), credentialRef(LEGACY_SESSION_REF)];
let legacyValue;
for (const ref of legacyRefs) {
const storedLegacy = await credentials?.resolve(ref);
legacyValue = storedLegacy?.value ?? env.get(ref)?.value;
if (legacyValue) break;
}
if (!legacyValue) throw new Error("未找到 WorkBuddy 登录凭据");
store = createWorkBuddySessionStore([parseWorkBuddySession(legacyValue)]);
}
const active = typeof requestedId === "string" && requestedId
? store.sessions.find((entry) => entry.id === requestedId)
: activeWorkBuddySession(store);
if (!active) throw new Error("未找到 WorkBuddy 登录账号");
let session = active;
if (sessionNeedsRefresh(session)) {
const trace = traceContext.getStore();
const started = Date.now();
traceEvent(trace, "auth.refresh-start");
try {
session = { ...session, ...(await refreshWorkBuddySession(session)), updatedAt: Date.now() };
traceEvent(trace, "auth.refresh-done", { elapsedMs: Date.now() - started });
} catch (error) {
traceEvent(trace, "auth.refresh-error", { elapsedMs: Date.now() - started, ...errorFields(error) });
throw error;
}
const nextStore = {
...store,
sessions: store.sessions.map((entry) => entry.id === session.id ? session : entry),
};
await credentials?.set(sessionsRef, serializeWorkBuddySessions(nextStore));
if (nextStore.activeId === session.id) await credentials?.set(credentialRef(WORKBUDDY_SESSION_REF), serializeWorkBuddySession(session));
}
return { ...session, sessionId: active.id, expiresAt: sessionCacheDeadline(session) };
})().finally(() => {
loginSessionPromises.delete(key);
});
loginSessionPromises.set(key, promise);
}
return promise;
};
const resolveCredential = async (provider, profile, context = requestContext.getStore()) => {
context ??= {};
const ref = profile.apiKeyEnv;
const routing = WORKBUDDY_PROVIDERS.has(provider) ? await readSessionRouting() : createWorkBuddySessionRoutingState();
const sessionId = context?.sessionId ? String(context.sessionId) : undefined;
let binding = sessionBindingFor(routing, sessionId);
if (WORKBUDDY_PROVIDERS.has(provider) && routing.enabled && sessionId && !binding) {
let fallbackBinding;
if (!routing.lastUsed) {
if (ref) fallbackBinding = { mode: "api-key", apiKeyRef: ref };
else {
try {
const active = await resolveLoginSession();
fallbackBinding = { mode: "token", accountId: active.sessionId };
} catch (error) {
throw new LlmError(`${name}: 没有可用于当前会话的默认 WorkBuddy 凭证`, "MISSING_CREDENTIAL", { cause: error });
}
}
}
try {
binding = await persistDefaultSessionBinding(sessionId, fallbackBinding);
} catch (error) {
throw new LlmError(`${name}: 无法保存当前会话的 WorkBuddy 凭证绑定`, "MISSING_CREDENTIAL", { cause: error });
}
if (!binding) throw new LlmError(`${name}: 没有可用于当前会话的默认 WorkBuddy 凭证`, "MISSING_CREDENTIAL");
}
if (WORKBUDDY_PROVIDERS.has(provider) && binding?.mode === "token") {
let session;
try {
session = await resolveLoginSession(binding.accountId);
} catch (error) {
throw new LlmError(`${name}: 当前会话绑定的 WorkBuddy 登录账号不可用`, "MISSING_CREDENTIAL", { cause: error });
}
context.headers = {
...(session.account.userId ? { "X-User-Id": session.account.userId } : {}),
...(session.account.enterpriseId ? { "X-Enterprise-Id": session.account.enterpriseId, "X-Tenant-Id": session.account.enterpriseId } : {}),
...(session.auth.domain ? { "X-Domain": session.auth.domain } : {}),
};
return { value: assertUsableApiKey(session.auth.accessToken, name, "WorkBuddy login session"), kind: "bearer", sessionId: session.sessionId };
}
if (WORKBUDDY_PROVIDERS.has(provider) && binding?.mode === "api-key") {
const bindingRef = binding.apiKeyRef;
if (!bindingRef) throw new LlmError(`${name}: 当前会话绑定的 API Key 引用无效`, "MISSING_CREDENTIAL");
const stored = await ctx.get("credentials")?.resolve(credentialRef(bindingRef));
const value = stored?.value ?? launchEnvironmentOf(ctx).get(credentialRef(bindingRef))?.value;
if (value) return { value: assertUsableApiKey(value, name, bindingRef), kind: "api-key", ref: bindingRef };
throw new LlmError(`${name}: 当前会话绑定的 API Key 不可用`, "MISSING_CREDENTIAL");
}
if (!ref && WORKBUDDY_PROVIDERS.has(provider)) {
let session;
try {
session = await resolveLoginSession();
} catch (error) {
throw new LlmError(`${name}: 未找到可用的 WorkBuddy 登录令牌,请运行 dsh-llm-workbuddy login`, "MISSING_CREDENTIAL", { cause: error });
}
context.headers = {
...(session.account.userId ? { "X-User-Id": session.account.userId } : {}),
...(session.account.enterpriseId ? { "X-Enterprise-Id": session.account.enterpriseId, "X-Tenant-Id": session.account.enterpriseId } : {}),
...(session.auth.domain ? { "X-Domain": session.auth.domain } : {}),
};
return { value: assertUsableApiKey(session.auth.accessToken, name, "WorkBuddy login session"), kind: "bearer", sessionId: session.sessionId };
}
if (!ref) return { value: undefined, kind: "none" };
const stored = await ctx.get("credentials")?.resolve(ref);
let value = stored?.value ?? launchEnvironmentOf(ctx).get(ref)?.value;
if (!value && ref === API_KEY_ENV) {
const legacyRef = credentialRef(LEGACY_API_KEY_ENV);
const legacyStored = await ctx.get("credentials")?.resolve(legacyRef);
value = legacyStored?.value ?? launchEnvironmentOf(ctx).get(legacyRef)?.value;
}
if (value) return { value: assertUsableApiKey(value, name, ref), kind: "api-key", ref };
throw new LlmError(`${name}: Provider "${provider}" 缺少 API Key,请在 WebUI 的模型设置中填写`, "MISSING_CREDENTIAL");
};
const resolveApiKey = async (provider, profile) => {
const trace = WORKBUDDY_PROVIDERS.has(provider) ? traceContext.getStore() : undefined;
const started = Date.now();
traceEvent(trace, "auth.start");
try {
const credential = await resolveCredential(provider, profile);
traceEvent(trace, "auth.done", { elapsedMs: Date.now() - started, mode: credential.kind });
return credential.value;
} catch (error) {
traceEvent(trace, "auth.error", { elapsedMs: Date.now() - started, ...errorFields(error) });
throw error;
}
};
const adapter = new PiAiAdapter({
profiles,
resolveApiKey,
resolveAttachments: () => ctx.get("attachments"),
});
const refreshPromises = new Map();
const refreshWorkBuddyModels = async (provider, signal) => {
const profile = profiles().get(provider);
const credential = await resolveCredential(provider, profile);
const cacheKey = credential.kind === "bearer" ? `token:${credential.sessionId ?? "active"}` : `api:${credential.ref ?? API_KEY_ENV}`;
if (remoteModels && remoteModelsKey === cacheKey) return;
let pending = refreshPromises.get(cacheKey);
if (!pending) {
pending = (async () => {
remoteModels = await fetchWorkBuddyModels(credential, signal);
remoteModelsKey = cacheKey;
generation += 1;
})().finally(() => refreshPromises.delete(cacheKey));
refreshPromises.set(cacheKey, pending);
}
return pending;
};
const ensureWorkBuddyModel = async (provider, model, signal) => {
if (!WORKBUDDY_PROVIDERS.has(provider)) return;
if (profiles().get(provider)?.piProvider.getModels().some((entry) => entry.id === model)) return;
await refreshWorkBuddyModels(provider, signal);
};
const sessionScopedStream = (stream, options) => {
const context = { sessionId: options?.sessionId === undefined ? undefined : String(options.sessionId), headers: {}, id: ++nextTraceId };
const run = (action) => traceContext.run(context, () => requestContext.run(context, action));
const source = run(() => stream(options));
const iterator = source[Symbol.asyncIterator]();
return {
[Symbol.asyncIterator]() { return this; },
next(value) { return run(() => iterator.next(value)); },
return(value) { return run(() => iterator.return?.(value) ?? Promise.resolve({ done: true, value })); },
throw(error) { return run(() => iterator.throw?.(error) ?? Promise.reject(error)); },
};
};
const adapterStream = adapter.stream.bind(adapter);
const legacyAdapter = typeof adapter.prepareCall !== "function";
const invokeAdapterStream = (options) => adapterStream(prepareWorkBuddyOptions(options, legacyAdapter));
adapter.stream = (options) => sessionScopedStream(invokeAdapterStream, options);
// `prepareCall` was added after the DSH rc.6 adapter. Keep the direct
// `stream` path working on older hosts while wrapping prepared calls on
// newer hosts, whose runtime dispatches through the returned stream handle.
if (!legacyAdapter) {
const adapterPrepareCall = adapter.prepareCall.bind(adapter);
adapter.prepareCall = async (...args) => {
await ensureWorkBuddyModel(args[0], args[1], args[2]);
const prepared = await adapterPrepareCall(...args);
return {
...prepared,
stream: (options) => sessionScopedStream((preparedOptions) => prepared.stream(prepareWorkBuddyOptions(preparedOptions, false)), options),
};
};
} else {
// DSH 0.1.5 calls prepareCall unconditionally, while the rc.6 pi-ai
// adapter shipped without it. Keep that older adapter usable by exposing
// the same prepared-call shape from its existing methods.
adapter.prepareCall = async (provider, model, signal) => ({
model: await adapter.resolveModel(provider, model, signal),
stream: (options) => sessionScopedStream(invokeAdapterStream, options),
});
}
const resolveModel = adapter.resolveModel.bind(adapter);
adapter.resolveModel = async (provider, model, signal) => {
await ensureWorkBuddyModel(provider, model, signal);
const resolved = await resolveModel(provider, model, signal);
if (!WORKBUDDY_PROVIDERS.has(provider) || !resolved.reasoning) return resolved;
const configured = profiles().get(provider)?.piProvider.getModels().find((entry) => entry.id === model);
const effort = configured?.defaultReasoningEffort;
if (!effort || !resolved.reasoning.efforts.some((entry) => entry.id === effort)) return resolved;
return { ...resolved, reasoning: { ...resolved.reasoning, defaultEffort: effort } };
};
const listModels = adapter.listModels.bind(adapter);
adapter.listModels = async (provider) => {
if (WORKBUDDY_PROVIDERS.has(provider)) {
try {
await refreshWorkBuddyModels(provider);
} catch {
// Keep the built-in catalog available while the key or network is absent.
}
}
return listModels(provider);
};
const workBuddyDirectory = () => [{
provider: PROVIDER,
displayName: DISPLAY_NAME,
settingsNs,
settingsPath: ["providers", PROVIDER],
declared: false,
}];
const directoryEntries = () => modernSettings ? workBuddyDirectory() : [...workBuddyDirectory(), ...[...builtins.values()].flatMap((provider) => provider.auth?.apiKey ? [{
provider: provider.id,
displayName: provider.name,
settingsNs,
settingsPath: ["providers", provider.id],
declared: false,
}] : []), ...Object.entries(effectiveConfig().providers ?? {}).flatMap(([provider, source]) => {
if (WORKBUDDY_PROVIDERS.has(provider) || builtins.has(provider) || !genericProvider(provider, source)) return [];
return [{
provider,
displayName: source.displayName ?? provider,
settingsNs,
settingsPath: ["providers", provider],
declared: true,
}];
})];
let directory = ctx.llm.registerConfigurableProviders(directoryEntries());
let registration = ctx.llm.registerAdapter([...profiles().keys()], adapter);
ctx.llm.registerModelDiscovery(settingsNs, async (request, signal) => {
const discoverySignal = signal ?? request.signal;
if (WORKBUDDY_PROVIDERS.has(request.provider)) {
const profile = profiles().get(request.provider);
const credential = request.apiKey
? { value: request.apiKey, kind: "api-key", ref: API_KEY_ENV }
: await resolveCredential(request.provider, profile);
remoteModels = await fetchWorkBuddyModels(credential, discoverySignal);
remoteModelsKey = credential.kind === "bearer" ? `token:${credential.sessionId ?? "active"}` : `api:${credential.ref ?? API_KEY_ENV}`;
generation += 1;
return remoteModels.map((model) => ({
id: model.id,
name: model.name,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
}));
}
const provider = builtins.get(request.provider);
if (!provider) return probeEndpoint({ ...request, signal: discoverySignal }, { profiles, resolveCredential });
return provider.getModels().map((model) => ({
id: model.id,
name: model.name,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
}));
});
// Keep WorkBuddy out of the settings base layer so it appears in WebUI's
// "Add provider" dropdown. The runtime profile above still exists as the
// built-in implementation; selecting it only persists the credential ref.
const refreshRegistrations = () => {
memoRaw = undefined;
const providers = profiles();
registration.replace([...providers.keys()]);
directory.replace(directoryEntries());
};
if (modernSettings) ctx.on("loader/volatile-update", refreshRegistrations);
installSettingsCompat(ctx, settingsNs, Config, config ?? { providers: {} }, {
setSource(source) {
current = source;
},
onChange() {
refreshRegistrations();
},
});
}
+82
View File
@@ -0,0 +1,82 @@
{
"name": "@axiaohungry/dsh-llm-workbuddy",
"version": "1.3.21-custom.1",
"description": "[个人定制版] 基于 @axiaohungry/dsh-llm-workbuddy 1.3.21:API Key 模式下在对话页显示剩余积分,可点击查看套餐与重置时间",
"author": "Axiaohungry (original) / customized by repo owner",
"keywords": [
"deepseek-harness",
"dsh",
"workbuddy",
"llm",
"provider"
],
"type": "module",
"main": "index.js",
"exports": {
".": "./index.js",
"./client": "./client.js",
"./package.json": "./package.json"
},
"bin": {
"dsh-llm-workbuddy": "cli.js"
},
"repository": {
"type": "git",
"url": "git+http://154.201.73.129:3300/MuLin/dsh-llm-workbuddy.git"
},
"homepage": "http://154.201.73.129:3300/MuLin/dsh-llm-workbuddy#readme",
"bugs": {
"url": "http://154.201.73.129:3300/MuLin/dsh-llm-workbuddy/issues"
},
"files": [
"index.js",
"workbuddy-discovery.js",
"workbuddy-auth.js",
"workbuddy-credits.js",
"workbuddy-web.js",
"client.js",
"cli.js",
"cordis.patch.yml",
"docs/反向代理调用WorkBuddy-API开发文档.md",
"README.md",
"LICENSE"
],
"scripts": {
"check": "node --check index.js && node --check workbuddy-discovery.js && node --check workbuddy-auth.js && node --check workbuddy-credits.js && node --check workbuddy-web.js && node --check client.js && node --check cli.js && node --test test.js && node cli.js --self-test"
},
"dsh": {
"engines": {
"dsh": ">=0.1.2-alpha.2 <0.2.0 || >=0.1.5-rc.3 <0.1.5 || >=0.1.7-rc.1 <0.1.7 || 0.2.0-rc.2"
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-renderer"
],
"platform": "web"
},
"bundle": {
"patch": "./cordis.patch.yml"
}
},
"dependencies": {
"@earendil-works/pi-ai": "^0.87.1",
"pnpm": "11.19.0",
"yaml": "2.9.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-credentials": ">=0.1.2-alpha.2 <0.2.0 || >=0.1.5-rc.3 <0.1.5 || >=0.1.7-rc.1 <0.1.7 || 0.2.0-rc.2",
"@deepseek-ai/dsh-launch-environment": ">=0.1.2-alpha.2 <0.2.0 || >=0.1.5-rc.3 <0.1.5 || >=0.1.7-rc.1 <0.1.7 || 0.2.0-rc.2",
"@deepseek-ai/dsh-llm": ">=0.1.2-alpha.2 <0.2.0 || >=0.1.5-rc.3 <0.1.5 || >=0.1.7-rc.1 <0.1.7 || 0.2.0-rc.2",
"@deepseek-ai/dsh-llm-pi-ai": ">=0.1.2-alpha.2 <0.2.0 || >=0.1.5-rc.3 <0.1.5 || >=0.1.7-rc.1 <0.1.7 || 0.2.0-rc.2",
"@deepseek-ai/dsh-settings": ">=0.1.2-alpha.2 <0.2.0 || >=0.1.5-rc.3 <0.1.5 || >=0.1.7-rc.1 <0.1.7 || 0.2.0-rc.2",
"react": "^18.2.0"
},
"engines": {
"node": ">=22.19.0"
},
"publishConfig": {
"access": "public"
},
"license": "MIT"
}
+430
View File
@@ -0,0 +1,430 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";
export const WORKBUDDY_SESSION_REF = "WORKBUDDY_LOGIN_SESSION";
export const WORKBUDDY_SESSIONS_REF = "WORKBUDDY_LOGIN_SESSIONS";
export const WORKBUDDY_SESSION_ROUTING_REF = "WORKBUDDY_SESSION_ROUTING";
export const WORKBUDDY_API_KEYS_REF = "WORKBUDDY_API_KEYS";
export const LEGACY_SESSION_REF = "CODEBUDDY_LOGIN_SESSION";
export const LEGACY_SESSIONS_REF = "CODEBUDDY_LOGIN_SESSIONS";
export const LEGACY_API_KEYS_REF = "CODEBUDDY_API_KEYS";
function normalizeWorkBuddySessionBinding(binding) {
if (!binding || typeof binding !== "object") return undefined;
if (binding.mode === "token" && typeof binding.accountId === "string" && binding.accountId.trim()) {
return { mode: "token", accountId: binding.accountId.trim() };
}
if (binding.mode === "api-key" && typeof binding.apiKeyRef === "string" && binding.apiKeyRef.trim()) {
return { mode: "api-key", apiKeyRef: binding.apiKeyRef.trim() };
}
return undefined;
}
export function createWorkBuddySessionRoutingState(enabled = false, bindings = {}, lastUsed) {
const normalized = {};
if (bindings && typeof bindings === "object" && !Array.isArray(bindings)) {
for (const [sessionId, binding] of Object.entries(bindings)) {
if (typeof sessionId !== "string" || !sessionId.trim() || !binding || typeof binding !== "object") continue;
const value = normalizeWorkBuddySessionBinding(binding);
if (value) normalized[sessionId] = value;
}
}
const recent = normalizeWorkBuddySessionBinding(lastUsed);
return {
version: 1,
enabled: enabled === true,
bindings: normalized,
...(recent ? { lastUsed: recent } : {}),
};
}
export function serializeWorkBuddySessionRouting(state) {
return JSON.stringify(createWorkBuddySessionRoutingState(state?.enabled, state?.bindings, state?.lastUsed));
}
export function parseWorkBuddySessionRouting(value) {
let parsed;
try {
parsed = JSON.parse(value);
} catch (error) {
throw new Error("WorkBuddy 会话级认证配置已损坏,请重新设置", { cause: error });
}
return createWorkBuddySessionRoutingState(parsed?.enabled, parsed?.bindings, parsed?.lastUsed);
}
const BASE_URL = "https://copilot.tencent.com/v2/plugin";
const USER_AGENT = "CLI/unknown CodeBuddy/2.137.1";
const REQUEST_HEADERS = {
accept: "application/json",
"content-type": "application/json",
"user-agent": USER_AGENT,
"x-product": "SaaS",
};
const NO_ACCOUNT_HEADERS = {
"X-No-Authorization": "true",
"X-No-User-Id": "true",
"X-No-Enterprise-Id": "true",
"X-No-Department-Info": "true",
};
const NO_ID_HEADERS = {
"X-No-User-Id": "true",
"X-No-Enterprise-Id": "true",
"X-No-Department-Info": "true",
};
function calculateExpiresAt(auth, now = Date.now()) {
const result = { ...auth };
if (!result.expiresAt && Number.isFinite(result.expiresIn)) result.expiresAt = now + result.expiresIn * 1000;
if (!result.refreshExpiresAt && Number.isFinite(result.refreshExpiresIn)) result.refreshExpiresAt = now + result.refreshExpiresIn * 1000;
return result;
}
async function responseBody(response, action) {
try {
return await response.json();
} catch (error) {
throw new Error(`${action}返回了无法解析的数据`, { cause: error });
}
}
async function request(path, options, action) {
let response;
try {
response = await fetch(`${BASE_URL}${path}`, options);
} catch (error) {
if (options.signal?.aborted) throw new Error(`${action}已取消`, { cause: error });
throw new Error(`${action}无法连接 WorkBuddy 中国站`, { cause: error });
}
const body = await responseBody(response, action);
if (!response.ok || body?.code !== 0) throw new Error(`${action}失败(${body?.message ?? body?.msg ?? response.status})`);
return body.data;
}
function enterpriseHeaders(session) {
const enterpriseId = session.account?.enterpriseId;
return {
...(enterpriseId ? { "X-Enterprise-Id": enterpriseId, "X-Tenant-Id": enterpriseId } : {}),
...(session.auth?.domain ? { "X-Domain": session.auth.domain } : {}),
};
}
async function poll(path, headers, action, timeoutMs, signal) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await delay(1000, undefined, { signal });
let response;
try {
response = await fetch(`${BASE_URL}${path}`, { headers: { ...REQUEST_HEADERS, ...headers }, signal });
} catch (error) {
if (signal?.aborted) throw new Error(`${action}已取消`, { cause: error });
continue;
}
const body = await responseBody(response, action);
if (response.ok && body?.code === 0 && body.data) return body.data;
if (response.status === 401 || response.status === 403) throw new Error(`${action}失败(${body?.message ?? body?.msg ?? response.status})`);
}
throw new Error(`${action}超时`);
}
function openBrowser(url) {
const [command, args] = process.platform === "win32"
? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]]
: process.platform === "darwin"
? ["open", [url]]
: ["xdg-open", [url]];
return new Promise((resolve, reject) => {
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
child.once("spawn", () => {
child.unref();
resolve();
});
child.once("error", reject);
});
}
function textValue(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value.trim();
if (typeof value === "number" && Number.isFinite(value)) return String(value);
}
return undefined;
}
function tokenClaims(token) {
if (typeof token !== "string") return {};
try {
const payload = token.split(".")[1];
if (!payload) return {};
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function normalizeApiKeyEntry(entry, now = Date.now()) {
const ref = textValue(entry?.ref);
if (!ref || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref)) throw new Error("WorkBuddy API Key 引用无效");
const id = textValue(entry?.id) ?? `dsh:${ref}`;
return {
id,
ref,
label: textValue(entry?.label) ?? "DSH 保存的 API Key",
createdAt: Number.isFinite(entry?.createdAt) ? entry.createdAt : now,
updatedAt: Number.isFinite(entry?.updatedAt) ? entry.updatedAt : now,
};
}
export function createWorkBuddyApiKeyStore(entries = [], activeId) {
const keys = [];
for (const entry of entries) {
const normalized = normalizeApiKeyEntry(entry);
if (!keys.some((item) => item.id === normalized.id || item.ref === normalized.ref)) keys.push(normalized);
}
const selected = activeId === null
? null
: typeof activeId === "string" && keys.some((entry) => entry.id === activeId) ? activeId : keys[0]?.id;
return { version: 1, activeId: selected, entries: keys };
}
export function upsertWorkBuddyApiKey(store, entry, now = Date.now()) {
const current = createWorkBuddyApiKeyStore(store?.entries ?? [], store?.activeId);
const incoming = normalizeApiKeyEntry({ ...entry, updatedAt: now }, now);
const index = current.entries.findIndex((item) => item.id === incoming.id || item.ref === incoming.ref);
if (index >= 0) incoming.createdAt = current.entries[index].createdAt;
const entries = index >= 0
? current.entries.map((item, position) => position === index ? incoming : item)
: [...current.entries, incoming];
return { version: 1, activeId: incoming.id, entries };
}
export function serializeWorkBuddyApiKeys(store) {
const normalized = createWorkBuddyApiKeyStore(store?.entries ?? [], store?.activeId);
return JSON.stringify(normalized);
}
export function parseWorkBuddyApiKeys(value) {
let parsed;
try {
parsed = JSON.parse(value);
} catch (error) {
throw new Error("WorkBuddy API Key 列表已损坏,请重新配置", { cause: error });
}
if (!Array.isArray(parsed?.entries)) throw new Error("WorkBuddy API Key 列表格式无效,请重新配置");
return createWorkBuddyApiKeyStore(parsed.entries, parsed.activeId);
}
export function workBuddyApiKeyEntries(store) {
return (store?.entries ?? []).map(({ id, ref, label, createdAt, updatedAt }) => ({ id, ref, label, createdAt, updatedAt }));
}
function normalizeAccount(account, auth) {
// The account endpoint has returned both a flat object and wrapped objects
// across WorkBuddy versions. Keep all known wrappers in the lookup so a newly
// added account gets the same display metadata as an imported account.
const sources = [
account,
account?.account,
account?.user,
account?.userInfo,
account?.profile,
account?.data,
].filter((source) => source && typeof source === "object");
const read = (...keys) => textValue(...sources.flatMap((source) => keys.map((key) => source[key])));
const claims = tokenClaims(auth?.accessToken);
const userId = read("userId", "uid", "user_id", "id") ?? textValue(claims.userId, claims.uid, claims.user_id, claims.sub);
const enterpriseId = read("enterpriseId", "tenantId", "enterprise_id", "tenant_id")
?? textValue(claims.enterpriseId, claims.tenantId, claims.enterprise_id, claims.tenant_id);
const email = read("email", "mail", "emailAddress") ?? textValue(claims.email, claims.mail);
const uin = read("uin", "phoneNumber", "phone", "mobile", "mobilePhone")
?? textValue(claims.uin, claims.phoneNumber, claims.phone_number, claims.mobile);
const type = read("type", "accountType", "account_type");
const displayName = read("displayName", "name", "nickname", "username", "accountName")
?? uin
?? textValue(claims.displayName, claims.name, claims.nickname, claims.username, claims.preferred_username)
?? email;
return {
...(userId ? { userId } : {}),
...(enterpriseId ? { enterpriseId } : {}),
...(email ? { email } : {}),
...(uin ? { uin } : {}),
...(type ? { type } : {}),
...(displayName ? { displayName } : {}),
};
}
export function workBuddySessionId(session) {
const account = normalizeAccount(session?.account, session?.auth);
if (account.userId) return `user:${account.userId}`;
if (account.email) return `email:${account.email}`;
if (account.enterpriseId) return `enterprise:${account.enterpriseId}`;
const refreshToken = session?.auth?.refreshToken ?? session?.auth?.accessToken;
if (!refreshToken) throw new Error("WorkBuddy 登录会话缺少账号标识和令牌");
return `token:${createHash("sha256").update(refreshToken).digest("hex")}`;
}
export function workBuddySessionLabel(session) {
const account = normalizeAccount(session?.account, session?.auth);
return account.displayName ?? account.email ?? account.userId ?? account.enterpriseId ?? `账号 ${workBuddySessionId(session).slice(-8)}`;
}
export function normalizeWorkBuddySessionEntry(session, now = Date.now()) {
const normalized = {
auth: calculateExpiresAt(session?.auth),
account: normalizeAccount(session?.account, session?.auth),
};
if (!normalized.auth.accessToken || !normalized.auth.refreshToken) throw new Error("WorkBuddy 登录会话无效");
const id = typeof session?.id === "string" && session.id.trim() ? session.id : workBuddySessionId(normalized);
const createdAt = Number.isFinite(session?.createdAt) ? session.createdAt : now;
return {
id,
label: typeof session?.label === "string" && session.label.trim() ? session.label : workBuddySessionLabel(normalized),
createdAt,
updatedAt: Number.isFinite(session?.updatedAt) ? session.updatedAt : now,
...normalized,
};
}
export function createWorkBuddySessionStore(entries = [], activeId) {
const sessions = [];
for (const entry of entries) {
const normalized = normalizeWorkBuddySessionEntry(entry);
if (!sessions.some((item) => item.id === normalized.id)) sessions.push(normalized);
}
const selected = typeof activeId === "string" && sessions.some((entry) => entry.id === activeId) ? activeId : sessions[0]?.id;
return { version: 1, activeId: selected, sessions };
}
export function upsertWorkBuddySession(store, session, now = Date.now()) {
const current = createWorkBuddySessionStore(store?.sessions ?? [], store?.activeId);
const incoming = normalizeWorkBuddySessionEntry({ ...session, updatedAt: now }, now);
const index = current.sessions.findIndex((entry) => entry.id === incoming.id);
if (index >= 0) incoming.createdAt = current.sessions[index].createdAt;
const sessions = index >= 0
? current.sessions.map((entry, position) => position === index ? incoming : entry)
: [...current.sessions, incoming];
return { version: 1, activeId: incoming.id, sessions };
}
export function activeWorkBuddySession(store) {
return store?.sessions?.find((entry) => entry.id === store.activeId) ?? store?.sessions?.[0];
}
export function workBuddySessionAccounts(store) {
return (store?.sessions ?? []).map(({ id, label, account, createdAt, updatedAt }) => ({
id,
label,
accountName: label,
userId: account?.userId ?? null,
account,
createdAt,
updatedAt,
}));
}
export async function loginWorkBuddy(onAuthUrl, signal) {
const state = await request("/auth/state?platform=CLI", {
method: "POST",
headers: { ...REQUEST_HEADERS, ...NO_ACCOUNT_HEADERS },
body: "{}",
signal,
}, "创建 WorkBuddy 登录会话");
if (!state?.state || !state?.authUrl) throw new Error("WorkBuddy 登录接口没有返回登录地址");
try {
await openBrowser(state.authUrl);
onAuthUrl?.(state.authUrl, true);
} catch {
onAuthUrl?.(state.authUrl, false);
}
const auth = calculateExpiresAt(await poll(
`/auth/token?state=${encodeURIComponent(state.state)}`,
NO_ACCOUNT_HEADERS,
"等待 WorkBuddy 登录",
10 * 60_000,
signal,
));
if (!auth.accessToken || !auth.refreshToken) throw new Error("WorkBuddy 登录接口没有返回完整令牌");
const account = await poll(
`/login/account?state=${encodeURIComponent(state.state)}`,
{ ...enterpriseHeaders({ auth }), authorization: `Bearer ${auth.accessToken}`, ...NO_ID_HEADERS },
"获取 WorkBuddy 账号",
60_000,
signal,
);
return { auth, account: normalizeAccount(account, auth) };
}
export async function refreshWorkBuddySession(session, signal) {
if (!session?.auth?.refreshToken) throw new Error("WorkBuddy 登录会话缺少刷新令牌,请重新登录");
const auth = await request("/auth/token/refresh", {
method: "POST",
headers: {
...REQUEST_HEADERS,
...enterpriseHeaders(session),
"X-Refresh-Token": session.auth.refreshToken,
"X-Auth-Refresh-Source": "plugin",
},
body: "{}",
signal,
}, "刷新 WorkBuddy 登录令牌");
const fresh = calculateExpiresAt(auth);
const merged = { ...session.auth, ...fresh, refreshToken: fresh?.refreshToken ?? session.auth.refreshToken };
if (!merged.accessToken) throw new Error("WorkBuddy 刷新接口没有返回访问令牌");
return { auth: merged, account: normalizeAccount(session.account, merged) };
}
export function serializeWorkBuddySession(session) {
if (!session?.auth?.accessToken || !session?.auth?.refreshToken) throw new Error("WorkBuddy 登录会话无效");
return JSON.stringify({ auth: calculateExpiresAt(session.auth), account: normalizeAccount(session.account, session.auth) });
}
export function serializeWorkBuddySessions(store) {
const normalized = createWorkBuddySessionStore(store?.sessions ?? [], store?.activeId);
if (normalized.sessions.length === 0) throw new Error("WorkBuddy 登录账号列表为空");
return JSON.stringify(normalized);
}
export function parseWorkBuddySession(value) {
let session;
try {
session = JSON.parse(value);
} catch (error) {
throw new Error("WorkBuddy 登录凭据已损坏,请重新登录", { cause: error });
}
if (!session?.auth?.accessToken || !session?.auth?.refreshToken) throw new Error("WorkBuddy 登录凭据不完整,请重新登录");
return { auth: calculateExpiresAt(session.auth), account: normalizeAccount(session.account, session.auth) };
}
export function parseWorkBuddySessions(value) {
let parsed;
try {
parsed = JSON.parse(value);
} catch (error) {
throw new Error("WorkBuddy 登录账号列表已损坏,请重新登录", { cause: error });
}
if (Array.isArray(parsed?.sessions)) return createWorkBuddySessionStore(parsed.sessions, parsed.activeId);
if (parsed?.auth) {
const session = parseWorkBuddySession(value);
return createWorkBuddySessionStore([session], workBuddySessionId(session));
}
throw new Error("WorkBuddy 登录账号列表格式无效,请重新登录");
}
export function sessionNeedsRefresh(session, now = Date.now()) {
const expiresAt = Number(session?.auth?.expiresAt);
if (Number.isFinite(expiresAt)) return expiresAt <= now + 2 * 60_000;
try {
const payload = JSON.parse(Buffer.from(session.auth.accessToken.split(".")[1], "base64url").toString("utf8"));
return Number.isFinite(payload.exp) ? payload.exp * 1000 <= now + 2 * 60_000 : true;
} catch {
return true;
}
}
export function sessionCacheDeadline(session, now = Date.now()) {
const expiresAt = Number(session?.auth?.expiresAt);
return Number.isFinite(expiresAt)
? Math.max(now, Math.min(expiresAt - 2 * 60_000, now + 30 * 60_000))
: now + 5 * 60_000;
}
+448
View File
@@ -0,0 +1,448 @@
const DEFAULT_BILLING_HOST = "https://www.codebuddy.cn";
const BROWSER_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36";
const ENTERPRISE_EDITIONS = new Set(["ultimate", "exclusive"]);
const REMAINING_FIELDS = [
"SlicePeriodCapacityRemainPrecise",
"SlicePeriodCapacityRemain",
"CycleCapacityRemainPrecise",
"CycleCapacityRemain",
"CapacityRemainPrecise",
"CapacityRemain",
"RemainPrecise",
"Remain",
"Remaining",
"Balance",
];
const TOTAL_FIELDS = [
"SlicePeriodCapacitySizePrecise",
"SlicePeriodCapacitySize",
"CycleCapacitySizePrecise",
"CycleCapacitySize",
"CycleCapacityPrecise",
"CycleCapacity",
"CapacityPrecise",
"Capacity",
"TotalCapacityPrecise",
"TotalCapacity",
"PackageCapacity",
"Quota",
"Amount",
];
const EXPIRY_FIELDS = [
"DeductionEndTime",
"ExpiredTime",
"SlicePeriodEndTime",
"PackageEndTime",
"EndTime",
"CycleEndTime",
"ExpireTime",
"ExpirationTime",
"ValidEndTime",
"ValidPeriodEndTime",
"EndAt",
"ExpireAt",
];
const LABEL_FIELDS = ["PackageName", "PackageTypeName", "AccountName", "ProductName", "Name", "RuleName", "Description"];
const MAX_USAGE_PAGES = 100;
const PAGE_SIZE = 100;
function normalizeHost(value) {
const fallback = new URL(DEFAULT_BILLING_HOST);
if (typeof value !== "string" || !value.trim()) return fallback.origin;
try {
const candidate = new URL(value.includes("://") ? value : `https://${value}`);
if (candidate.protocol !== "https:") return fallback.origin;
const host = candidate.hostname.toLowerCase();
if (!["codebuddy.cn", "www.codebuddy.cn", "workbuddy.cn", "www.workbuddy.cn"].includes(host)) return fallback.origin;
return candidate.origin;
} catch {
return fallback.origin;
}
}
function billingHost(session) {
return normalizeHost(session?.auth?.domain);
}
function authToken(session) {
const token = typeof session?.auth?.accessToken === "string" ? session.auth.accessToken.trim() : "";
if (!token) throw new Error("WorkBuddy 登录令牌为空");
return token;
}
function billingHeaders(session, host, enterpriseId) {
const headers = {
accept: "application/json, text/plain, */*",
"content-type": "application/json",
"x-client-platform": "web",
origin: host,
referer: `${host}/profile/plans-usage`,
authorization: `Bearer ${authToken(session)}`,
"user-agent": BROWSER_USER_AGENT,
};
const domain = typeof session?.auth?.domain === "string" ? session.auth.domain.trim() : "";
if (domain) headers["x-domain"] = domain;
const userId = typeof session?.account?.userId === "string" ? session.account.userId.trim() : "";
if (userId) headers["x-user-id"] = userId;
if (enterpriseId) {
headers["x-enterprise-id"] = String(enterpriseId);
headers["x-tenant-id"] = String(enterpriseId);
}
return headers;
}
function timeoutSignal(timeoutMs, externalSignal) {
if (externalSignal) return externalSignal;
if (typeof AbortSignal?.timeout === "function") return AbortSignal.timeout(timeoutMs);
const controller = new AbortController();
setTimeout(() => controller.abort(), timeoutMs).unref?.();
return controller.signal;
}
async function readJson(response, action) {
const raw = await response.text();
if (!raw.trim()) throw new Error(`${action}返回空响应(HTTP ${response.status})`);
let payload;
try {
payload = JSON.parse(raw);
} catch (error) {
throw new Error(`${action}返回了无法解析的数据`, { cause: error });
}
if (!response.ok) throw new Error(`${action} HTTP ${response.status}: ${raw.slice(0, 160)}`);
if (payload?.code !== undefined && payload.code !== null && payload.code !== 0) {
throw new Error(`${action}失败(${payload.msg ?? payload.message ?? `code=${payload.code}`})`);
}
return payload;
}
function firstNumber(value, fields) {
for (const field of fields) {
const raw = value?.[field];
if (raw === undefined || raw === null || raw === "") continue;
const number = Number(raw);
if (Number.isFinite(number)) return number;
}
return null;
}
function parseTimestamp(value) {
if (value === undefined || value === null || value === "") return null;
if (typeof value === "number" || /^\d+(?:\.\d+)?$/.test(String(value).trim())) {
const number = Number(value);
if (!Number.isFinite(number)) return null;
return number < 1e12 ? Math.round(number * 1000) : Math.round(number);
}
const parsed = Date.parse(String(value).replace(/^(\d{4}-\d\d-\d\d)\s+/, "$1T"));
return Number.isFinite(parsed) ? parsed : null;
}
function firstTimestamp(value, fields) {
for (const field of fields) {
const parsed = parseTimestamp(value?.[field]);
if (parsed !== null) return parsed;
}
return null;
}
function firstText(value, fields) {
for (const field of fields) {
const text = value?.[field];
if (typeof text === "string" && text.trim()) return text.trim();
}
return "";
}
function extractAccounts(payload) {
const candidates = [
payload?.data?.Response?.Data?.Accounts,
payload?.data?.data?.Response?.Data?.Accounts,
payload?.data?.accounts,
payload?.data?.data?.accounts,
payload?.Response?.Data?.Accounts,
];
return candidates.find(Array.isArray) ?? [];
}
function extractCreditSegments(accounts, source = "积分") {
return (Array.isArray(accounts) ? accounts : [])
.flatMap((account) => {
const details = Array.isArray(account?.SlicePeriodUsageDetails) && account.SlicePeriodUsageDetails.length
? account.SlicePeriodUsageDetails.map((detail) => ({ ...account, ...detail }))
: [account];
return details.map((item) => {
const remaining = firstNumber(item, REMAINING_FIELDS);
if (remaining === null || remaining <= 0) return null;
const total = firstNumber(item, TOTAL_FIELDS);
return {
remaining: Number(remaining.toFixed(2)),
total: Number((total === null ? remaining : Math.max(total, remaining)).toFixed(2)),
expiresAt: firstTimestamp(item, EXPIRY_FIELDS),
source: firstText(item, LABEL_FIELDS) || source,
packageCode: item?.PackageCode ? String(item.PackageCode) : "",
};
});
})
.filter(Boolean);
}
function sortSegments(segments) {
return (Array.isArray(segments) ? segments : [])
.filter((segment) => segment && Number(segment.remaining) > 0)
.map((segment) => ({
remaining: Number(Number(segment.remaining).toFixed(2)),
total: Number(Number(segment.total || segment.remaining).toFixed(2)),
expiresAt: segment.expiresAt === null || segment.expiresAt === undefined ? null : Number(segment.expiresAt),
source: String(segment.source || "积分"),
packageCode: String(segment.packageCode || ""),
}))
.sort((left, right) => {
if (left.expiresAt === null && right.expiresAt !== null) return 1;
if (left.expiresAt !== null && right.expiresAt === null) return -1;
return (left.expiresAt || 0) - (right.expiresAt || 0);
});
}
function mergeSegments(segments) {
const merged = new Map();
for (const segment of Array.isArray(segments) ? segments : []) {
if (!segment || Number(segment.remaining) <= 0) continue;
const key = [segment.packageCode || segment.source || "积分", segment.expiresAt ?? "unknown"].join("|");
const previous = merged.get(key);
if (previous) {
previous.remaining += Number(segment.remaining) || 0;
previous.total += Number(segment.total || segment.remaining) || 0;
} else {
merged.set(key, {
remaining: Number(segment.remaining) || 0,
total: Number(segment.total || segment.remaining) || 0,
expiresAt: segment.expiresAt === undefined ? null : segment.expiresAt,
source: String(segment.source || "积分"),
packageCode: String(segment.packageCode || ""),
});
}
}
return sortSegments(Array.from(merged.values()));
}
function buildCreditResourceBody(now = new Date()) {
const end = new Date(now.getTime());
end.setFullYear(end.getFullYear() + 101);
const format = (date) => {
const pad = (value) => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
return {
PageNumber: 1,
PageSize: 100,
ProductCode: "p_tcaca",
Status: [0, 3],
PackageEndTimeRangeBegin: format(now),
PackageEndTimeRangeEnd: format(end),
};
}
async function postJson(url, session, body, action, options = {}) {
const response = await (options.fetchImpl || globalThis.fetch)(url, {
method: "POST",
headers: billingHeaders(session, options.host, options.enterpriseId),
body: JSON.stringify(body),
signal: timeoutSignal(options.timeoutMs ?? 12_000, options.signal),
});
return readJson(response, action);
}
function enterpriseUsage(payload) {
const candidates = [payload?.data, payload?.data?.data, payload?.data?.Response?.Data, payload];
const data = candidates.find((value) => value && typeof value === "object" && ("limitNum" in value || "LimitNum" in value));
if (!data) return null;
const limitNum = Number(data.limitNum ?? data.LimitNum);
if (!Number.isFinite(limitNum)) return null;
const reset = firstTimestamp(data, ["cycleResetTime", "CycleResetTime", "CycleResetTimeMs"]);
if (limitNum === -1) return { unlimited: true, credits: null, total: null, count: 0, segments: [], cycleResetTime: reset };
const credit = Number(data.credit ?? data.Credit);
const used = Number.isFinite(credit) ? credit : 0;
const credits = Math.max(0, limitNum - used);
return {
unlimited: false,
credits: Number(credits.toFixed(2)),
total: Number(limitNum.toFixed(2)),
count: 1,
segments: sortSegments([{ remaining: credits, total: limitNum, expiresAt: reset, source: "企业配额" }]),
cycleResetTime: reset,
};
}
async function queryPersonalCredits(session, host, options = {}) {
const payload = await postJson(`${host}/v2/billing/meter/get-user-resource`, session, buildCreditResourceBody(), "WorkBuddy 积分接口", { ...options, host });
const accounts = extractAccounts(payload);
let credits = 0;
for (const account of accounts) {
const remaining = firstNumber(account, [
"CycleCapacityRemainPrecise",
"CycleCapacityRemain",
"CapacityRemainPrecise",
"CapacityRemain",
]);
if (remaining !== null) credits += remaining;
}
// [自用定制 2026-10-10] 提取会员套餐:账单里 CapacityType=2 是套餐/会员类,
// CapacityType=1 是签到/活动/兑换码赠送的积分包。
const planCandidates = accounts.filter((account) => String(account?.CapacityType) === "2");
const plan = planCandidates.find((account) => /高级版|标准版|旗舰版|专业版/.test(String(account?.PackageName || "")))
?? planCandidates[0];
return {
credits: Number(credits.toFixed(2)),
count: accounts.length,
totalDosage: payload?.data?.Response?.Data?.TotalDosage ?? payload?.data?.data?.Response?.Data?.TotalDosage ?? null,
segments: mergeSegments(extractCreditSegments(accounts)),
unlimited: false,
cycleResetTime: null,
planName: plan?.PackageName ?? null,
planCycleEnd: plan?.CycleEndTime ?? null,
};
}
async function resolveEnterpriseId(session, host, options = {}) {
const uid = session?.account?.userId;
if (!uid) return "";
const response = await (options.fetchImpl || globalThis.fetch)(`${host}/console/accounts`, {
method: "GET",
headers: billingHeaders(session, host),
signal: timeoutSignal(8_000, options.signal),
});
const payload = await readJson(response, "WorkBuddy 企业账号接口");
const accounts = payload?.data?.accounts;
const first = Array.isArray(accounts) ? accounts[0] : undefined;
return typeof first?.enterpriseId === "string" ? first.enterpriseId.trim() : "";
}
async function queryEnterpriseCredits(session, host, enterpriseId, options = {}) {
const payload = await postJson(`${host}/v2/billing/meter/get-enterprise-user-usage`, session, {}, "WorkBuddy 企业积分接口", {
...options,
host,
enterpriseId,
});
const parsed = enterpriseUsage(payload);
if (!parsed) throw new Error("WorkBuddy 企业积分接口返回数据无法解析");
return parsed;
}
function pad2(value) {
return String(value).padStart(2, "0");
}
function formatLocalDateTime(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
}
function localDateString(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
function parseUsageTime(value) {
return parseTimestamp(value);
}
function usageRows(payload) {
const data = payload?.data;
const rows = data && Array.isArray(data.data) ? data.data : data && Array.isArray(data.rows) ? data.rows : [];
const total = Number(data?.total);
return { rows, total: Number.isSafeInteger(total) && total >= 0 ? total : rows.length };
}
async function queryTodayUsage(session, host, options = {}) {
const now = new Date();
const start = new Date(now.getTime());
start.setHours(0, 0, 0, 0);
const fetchImpl = options.fetchImpl || globalThis.fetch;
const records = [];
let expectedTotal = null;
let fetched = 0;
for (let pageNum = 1; pageNum <= MAX_USAGE_PAGES; pageNum += 1) {
const payload = await postJson(`${host}/billing/meter/get-user-request-usage`, session, {
startTime: formatLocalDateTime(start),
endTime: formatLocalDateTime(now),
pageNum,
pageSize: PAGE_SIZE,
}, "WorkBuddy 今日请求量接口", { ...options, host, fetchImpl, timeoutMs: options.usageTimeoutMs ?? 8_000 });
const page = usageRows(payload);
if (expectedTotal === null) expectedTotal = page.total;
if (!page.rows.length) break;
for (const row of page.rows) {
const requestTime = parseUsageTime(row?.requestTime ?? row?.RequestTime ?? row?.createdAt);
const credit = Number(row?.credit ?? row?.Credit ?? 0);
if (requestTime === null || !Number.isFinite(credit) || credit < 0) continue;
records.push({ requestTime, credit });
}
fetched += page.rows.length;
if (fetched >= expectedTotal || page.rows.length < PAGE_SIZE) break;
}
const used = records.reduce((sum, record) => sum + record.credit, 0);
return {
date: localDateString(now),
used: Number(used.toFixed(2)),
count: records.length,
synced: true,
};
}
async function retry(task, attempts = 2) {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await task();
} catch (error) {
lastError = error;
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
}
}
throw lastError;
}
export async function fetchWorkBuddyCredits(session, options = {}) {
const host = billingHost(session);
const account = session?.account && typeof session.account === "object" ? session.account : {};
let creditResult;
let creditError = null;
try {
const enterpriseId = typeof account.enterpriseId === "string" ? account.enterpriseId.trim() : "";
const enterpriseEdition = typeof account.type === "string" && ENTERPRISE_EDITIONS.has(account.type.toLowerCase());
if (enterpriseId || enterpriseEdition) {
const resolvedId = enterpriseId || await retry(() => resolveEnterpriseId(session, host, options));
if (!resolvedId) throw new Error("企业账号缺少 enterpriseId");
creditResult = await retry(() => queryEnterpriseCredits(session, host, resolvedId, options));
} else {
creditResult = await retry(() => queryPersonalCredits(session, host, options));
}
} catch (error) {
creditError = error instanceof Error ? error.message : String(error);
creditResult = { credits: null, count: 0, totalDosage: null, segments: [], unlimited: false, cycleResetTime: null };
}
let todayUsage;
let todayUsageError = null;
try {
todayUsage = await retry(() => queryTodayUsage(session, host, options));
} catch (error) {
todayUsageError = error instanceof Error ? error.message : String(error);
}
return {
...creditResult,
creditError,
todayUsage: todayUsage ?? null,
todayUsageError,
};
}
export const __testing = Object.freeze({
billingHost,
buildCreditResourceBody,
enterpriseUsage,
extractAccounts,
extractCreditSegments,
formatLocalDateTime,
mergeSegments,
normalizeHost,
queryTodayUsage,
sortSegments,
});
+113
View File
@@ -0,0 +1,113 @@
import { LlmError } from "@deepseek-ai/dsh-llm";
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
const OPENAI_APIS = new Set(["openai-completions", "openai-responses"]);
function listingUrl(baseURL, api) {
const base = baseURL.replace(/\/+$/, "");
return api === "anthropic-messages"
? `${base.endsWith("/v1") ? base : `${base}/v1`}/models?limit=1000`
: `${base}/models`;
}
function positive(...values) {
return values.find((value) => Number.isSafeInteger(value) && value > 0);
}
function nonempty(...values) {
return values.find((value) => typeof value === "string" && value.trim())?.trim();
}
async function readBounded(response) {
const declared = Number(response.headers.get("content-length"));
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
await response.body?.cancel();
throw new LlmError("模型目录响应超过 4 MiB", "DISCOVERY_FAILED");
}
if (!response.body) return "";
const reader = response.body.getReader();
const chunks = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > MAX_RESPONSE_BYTES) throw new LlmError("模型目录响应超过 4 MiB", "DISCOVERY_FAILED");
chunks.push(value);
}
} finally {
await reader.cancel().catch(() => {});
}
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(bytes);
}
function modelEntries(body) {
if (Array.isArray(body?.data)) return body.data;
if (body?.models && typeof body.models === "object" && !Array.isArray(body.models)) {
return Object.entries(body.models).flatMap(([id, value]) =>
typeof value === "object" && value !== null && !Array.isArray(value) ? [{ ...value, id }] : []);
}
throw new LlmError("端点未返回可识别的模型列表,请手工填写模型", "DISCOVERY_FAILED");
}
export async function probeEndpoint(request, { profiles, resolveCredential }) {
const profile = request.provider ? profiles().get(request.provider) : undefined;
const baseURL = nonempty(request.baseURL, profile?.baseURL);
if (!baseURL) throw new LlmError(`Provider "${request.provider ?? ""}" 缺少 baseURL,请填写 API 地址或手工填写模型`, "DISCOVERY_FAILED");
const api = request.api ?? "openai-completions";
if (!OPENAI_APIS.has(api) && api !== "anthropic-messages") {
throw new LlmError(`协议 "${api}" 不支持自动获取模型,请手工填写`, "DISCOVERY_UNSUPPORTED");
}
const key = request.apiKey || (profile ? (await resolveCredential(request.provider, profile)).value : undefined);
const headers = new Headers(profile?.headers === undefined ? undefined : Object.entries(profile.headers));
headers.set("accept", "application/json");
if (key) {
const value = String(key).trim();
if (!value || /[\r\n]/.test(value)) throw new LlmError("API Key 包含无效字符", "INVALID_CREDENTIAL");
if (api === "anthropic-messages") {
headers.delete("authorization");
headers.set("x-api-key", value);
} else {
headers.delete("x-api-key");
headers.set("authorization", `Bearer ${value}`);
}
}
if (api === "anthropic-messages") headers.set("anthropic-version", "2023-06-01");
const url = listingUrl(baseURL, api);
let response;
try {
response = await fetch(url, { headers, signal: request.signal });
} catch (error) {
if (request.signal?.aborted) throw new LlmError("模型列表获取已取消", "ABORTED", { cause: error });
throw new LlmError("无法连接模型目录端点", "DISCOVERY_FAILED", { cause: error });
}
if (!response.ok) throw new LlmError(`模型目录端点返回 HTTP ${response.status}`, "DISCOVERY_FAILED");
let body;
try {
body = JSON.parse(await readBounded(response));
} catch (error) {
if (error instanceof LlmError) throw error;
if (request.signal?.aborted) throw new LlmError("模型列表获取已取消", "ABORTED", { cause: error });
throw new LlmError("模型目录端点未返回有效 JSON", "DISCOVERY_FAILED", { cause: error });
}
return modelEntries(body).flatMap((raw) => {
const id = nonempty(raw?.id);
if (!id) return [];
const name = nonempty(raw.name, raw.display_name, raw.displayName);
const contextWindow = positive(raw.contextWindow, raw.context_window, raw.context_length, raw.max_input_tokens, raw.limit?.context);
const maxTokens = positive(raw.maxTokens, raw.max_output_tokens, raw.max_tokens, raw.top_provider?.max_completion_tokens);
return [{
id,
...(name ? { name } : {}),
...(contextWindow ? { contextWindow } : {}),
...(maxTokens ? { maxTokens } : {}),
}];
});
}
+735
View File
@@ -0,0 +1,735 @@
import { randomBytes } from "node:crypto";
import { credentialRef } from "@deepseek-ai/dsh-credentials";
import {
WORKBUDDY_API_KEYS_REF,
WORKBUDDY_SESSION_REF,
WORKBUDDY_SESSIONS_REF,
WORKBUDDY_SESSION_ROUTING_REF,
LEGACY_API_KEYS_REF,
LEGACY_SESSION_REF,
LEGACY_SESSIONS_REF,
activeWorkBuddySession,
workBuddyApiKeyEntries,
workBuddySessionAccounts,
createWorkBuddyApiKeyStore,
createWorkBuddySessionStore,
createWorkBuddySessionRoutingState,
parseWorkBuddyApiKeys,
loginWorkBuddy,
parseWorkBuddySession,
parseWorkBuddySessions,
refreshWorkBuddySession,
serializeWorkBuddyApiKeys,
serializeWorkBuddySession,
serializeWorkBuddySessions,
serializeWorkBuddySessionRouting,
parseWorkBuddySessionRouting,
sessionNeedsRefresh,
upsertWorkBuddyApiKey,
upsertWorkBuddySession,
} from "./workbuddy-auth.js";
import { fetchWorkBuddyCredits } from "./workbuddy-credits.js";
const PROVIDER = "workbuddy-cn";
const LEGACY_PROVIDER = "codebuddy-cn";
const API_KEY_ENV = "WORKBUDDY_API_KEY";
const LEGACY_API_KEY_ENV = "CODEBUDDY_API_KEY";
const ROUTE = "/dsh-llm-workbuddy/auth";
const ENV_SOURCES = new Set(["env", "user-env", "project-env"]);
export function authenticationMode(config) {
const profile = config?.providers?.[PROVIDER] ?? config?.providers?.[LEGACY_PROVIDER];
return profile && profile.apiKeyEnv === undefined ? "token" : "api-key";
}
function json(res, status, body) {
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
res.end(JSON.stringify(body));
}
function localPost(req) {
const address = req.socket.remoteAddress;
const loopback = address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
if (!loopback) return false;
const origin = req.headers.origin;
if (!origin) return req.headers["sec-fetch-site"] === "same-origin"
|| /(?:^|\s)@deepseek-ai\/dsh-desktop\/\d/.test(req.headers["user-agent"] ?? "");
if (origin === "dsh-app://app") return true;
try {
return ["127.0.0.1", "localhost", "[::1]"].includes(new URL(origin).hostname);
} catch {
return false;
}
}
async function requestBody(req) {
let raw = "";
for await (const chunk of req) {
raw += chunk.toString();
if (raw.length > 64 * 1024) throw new Error("请求体过大");
}
if (!raw.trim()) return {};
try {
const value = JSON.parse(raw);
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
} catch {
throw new Error("请求参数格式无效");
}
}
async function setMode(settings, mode, apiKeyRef = API_KEY_ENV) {
const config = settings.get("llm-pi-ai");
const providers = config?.providers ?? {};
const exists = Object.hasOwn(providers, PROVIDER);
const legacy = !exists && Object.hasOwn(providers, LEGACY_PROVIDER) ? providers[LEGACY_PROVIDER] : undefined;
const path = ["providers", PROVIDER];
if (!exists) {
const value = { ...(legacy ?? {}), ...(mode === "token" ? {} : { apiKeyEnv: apiKeyRef }) };
await settings.mutate("llm-pi-ai", [
{ op: "set", path, value },
...(legacy ? [{ op: "unset", path: ["providers", LEGACY_PROVIDER] }] : []),
]);
return;
}
await settings.mutate("llm-pi-ai", [
{
op: mode === "token" ? "unset" : "set",
path: [...path, "apiKeyEnv"],
...(mode === "api-key" ? { value: apiKeyRef } : {}),
},
...(Object.hasOwn(providers, LEGACY_PROVIDER) ? [{ op: "unset", path: ["providers", LEGACY_PROVIDER] }] : []),
]);
}
function configuredApiKeyRef(settings) {
const providers = settings.get("llm-pi-ai")?.providers ?? {};
return providers[PROVIDER]?.apiKeyEnv ?? providers[LEGACY_PROVIDER]?.apiKeyEnv ?? API_KEY_ENV;
}
function settingsAccess(service, settingsNs) {
return {
get: () => typeof service.get === "function"
? service.get(settingsNs)
: service.describe().find((entry) => entry.ns === settingsNs)?.value,
revision: () => typeof service.get !== "function" && typeof service.describe === "function"
? service.describe().find((entry) => entry.ns === settingsNs)?.revision
: undefined,
mutate: (_ns, ops, revision) => service.mutate(settingsNs, ops, revision),
};
}
function validModelOverrides(models) {
if (!Array.isArray(models) || models.length > 100) return false;
const ids = new Set();
for (const model of models) {
if (!model || typeof model !== "object" || Array.isArray(model)
|| typeof model.id !== "string" || !model.id.trim() || model.id.length > 200
|| ids.has(model.id)) return false;
ids.add(model.id);
if (model.name !== undefined && (typeof model.name !== "string" || model.name.length > 200)) return false;
for (const key of ["contextWindow", "maxTokens"]) {
if (model[key] !== undefined && (!Number.isSafeInteger(model[key]) || model[key] <= 0)) return false;
}
if (model.input !== undefined && (!Array.isArray(model.input)
|| model.input.some((value) => value !== "text" && value !== "image"))) return false;
}
return true;
}
export const __testing = Object.freeze({ settingsAccess, setMode, validModelOverrides, localPost });
function maskApiKey(value) {
const text = typeof value === "string" ? value : "";
return text.length > 4 ? `••••${text.slice(-4)}` : text ? "••••" : "";
}
function textLabel(value) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function storedApiKeyRef() {
return `WORKBUDDY_API_KEY_DSH_${Date.now().toString(36).toUpperCase()}_${randomBytes(6).toString("hex").toUpperCase()}`;
}
async function readApiKeyStore(credentials) {
for (const ref of [WORKBUDDY_API_KEYS_REF, LEGACY_API_KEYS_REF]) {
const stored = await credentials.resolve(credentialRef(ref));
if (stored?.value) return parseWorkBuddyApiKeys(stored.value);
}
return createWorkBuddyApiKeyStore();
}
async function writeApiKeyStore(credentials, store) {
const normalized = createWorkBuddyApiKeyStore(store?.entries ?? [], store?.activeId);
if (normalized.entries.length === 0) {
await credentials.unset(credentialRef(WORKBUDDY_API_KEYS_REF));
await credentials.unset(credentialRef(LEGACY_API_KEYS_REF));
return;
}
await credentials.set(credentialRef(WORKBUDDY_API_KEYS_REF), serializeWorkBuddyApiKeys(normalized));
await credentials.unset(credentialRef(LEGACY_API_KEYS_REF));
}
async function currentApiKeyState(webCtx, settings) {
const store = await readApiKeyStore(webCtx.credentials);
const apiMode = authenticationMode(settings.get("llm-pi-ai")) === "api-key";
const ref = apiMode ? configuredApiKeyRef(settings) : undefined;
const items = [];
const seenRefs = new Set();
for (const envRef of [API_KEY_ENV, LEGACY_API_KEY_ENV]) {
const environment = await webCtx.credentials.resolve(credentialRef(envRef));
if (environment?.value) {
items.push({
id: `env:${envRef}`,
kind: "environment",
label: `环境变量 ${envRef}`,
ref: envRef,
configured: true,
masked: maskApiKey(environment.value),
source: environment.source,
});
seenRefs.add(envRef);
}
}
for (const entry of workBuddyApiKeyEntries(store)) {
if (seenRefs.has(entry.ref)) continue;
const resolved = await webCtx.credentials.resolve(credentialRef(entry.ref));
items.push({
...entry,
kind: "dsh",
configured: Boolean(resolved?.value),
...(resolved?.value ? { masked: maskApiKey(resolved.value), source: resolved.source } : {}),
});
seenRefs.add(entry.ref);
}
if (ref && !seenRefs.has(ref)) {
const resolved = await webCtx.credentials.resolve(credentialRef(ref));
if (resolved?.value) {
items.push({
id: `dsh:${ref}`,
kind: ENV_SOURCES.has(resolved.source) ? "environment" : "dsh",
label: ENV_SOURCES.has(resolved.source) ? `环境变量 ${ref}` : "DSH 默认 API Key",
ref,
configured: true,
masked: maskApiKey(resolved.value),
source: resolved.source,
});
}
}
const configured = apiMode
? items.find((item) => item.ref === ref)
?? (ref === API_KEY_ENV ? items.find((item) => item.ref === LEGACY_API_KEY_ENV) : undefined)
: undefined;
const active = configured ?? items.find((item) => item.id === store.activeId) ?? items[0];
return {
apiKeys: items,
activeApiKeyId: active?.id ?? null,
apiKeyConfigured: Boolean(configured?.configured),
};
}
async function readSessionStore(credentials) {
for (const ref of [WORKBUDDY_SESSIONS_REF, LEGACY_SESSIONS_REF]) {
const stored = await credentials.resolve(credentialRef(ref));
if (stored?.value) return parseWorkBuddySessions(stored.value);
}
for (const ref of [WORKBUDDY_SESSION_REF, LEGACY_SESSION_REF]) {
const legacy = await credentials.resolve(credentialRef(ref));
if (legacy?.value) return createWorkBuddySessionStore([parseWorkBuddySession(legacy.value)]);
}
return createWorkBuddySessionStore();
}
async function writeSessionStore(credentials, store) {
const active = activeWorkBuddySession(store);
if (!active) {
await credentials.unset(credentialRef(WORKBUDDY_SESSIONS_REF));
await credentials.unset(credentialRef(WORKBUDDY_SESSION_REF));
await credentials.unset(credentialRef(LEGACY_SESSIONS_REF));
await credentials.unset(credentialRef(LEGACY_SESSION_REF));
return;
}
await credentials.set(credentialRef(WORKBUDDY_SESSIONS_REF), serializeWorkBuddySessions(store));
// Keep the old single-session reference as a compatibility pointer for older plugin versions.
await credentials.set(credentialRef(WORKBUDDY_SESSION_REF), serializeWorkBuddySession(active));
await credentials.unset(credentialRef(LEGACY_SESSIONS_REF));
await credentials.unset(credentialRef(LEGACY_SESSION_REF));
}
async function readSessionRouting(credentials) {
const stored = await credentials.resolve(credentialRef(WORKBUDDY_SESSION_ROUTING_REF));
return stored?.value ? parseWorkBuddySessionRouting(stored.value) : createWorkBuddySessionRoutingState();
}
async function writeSessionRouting(credentials, state) {
await credentials.set(credentialRef(WORKBUDDY_SESSION_ROUTING_REF), serializeWorkBuddySessionRouting(state));
}
function sessionIdOf(value) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
async function clearSessionBindings(credentials, predicate) {
const state = await readSessionRouting(credentials);
const bindings = Object.fromEntries(Object.entries(state.bindings).filter(([, binding]) => !predicate(binding)));
const clearRecent = state.lastUsed && predicate(state.lastUsed);
if (clearRecent || Object.keys(bindings).length !== Object.keys(state.bindings).length) {
await writeSessionRouting(credentials, {
...state,
bindings,
...(clearRecent ? { lastUsed: undefined } : {}),
});
}
}
async function resolveSession(webCtx, accountId, sessionId) {
const store = await readSessionStore(webCtx.credentials);
const routing = await readSessionRouting(webCtx.credentials);
const binding = routing.enabled && sessionIdOf(sessionId) ? routing.bindings[sessionIdOf(sessionId)] : undefined;
const boundAccountId = binding?.mode === "token" ? binding.accountId : undefined;
const requestedId = typeof accountId === "string" && accountId
? accountId
: boundAccountId ?? store.activeId;
let session = store.sessions.find((entry) => entry.id === requestedId) ?? activeWorkBuddySession(store);
if (!session) throw new Error("没有找到该 WorkBuddy 登录账号");
if (boundAccountId && session.id !== boundAccountId) throw new Error("当前会话绑定的 WorkBuddy 登录账号不存在");
if (sessionNeedsRefresh(session)) {
session = { ...session, ...(await refreshWorkBuddySession(session)), updatedAt: Date.now() };
const nextStore = {
...store,
sessions: store.sessions.map((entry) => entry.id === session.id ? session : entry),
};
await writeSessionStore(webCtx.credentials, nextStore);
}
return session;
}
export function installWorkBuddyWeb(ctx, settingsNs = "llm-pi-ai") {
ctx.inject(["webServer", "settings", "credentials"], (webCtx) => {
const settings = settingsAccess(webCtx.settings, settingsNs);
let loginPromise;
const currentState = async (requestedSessionId) => {
const sessionId = sessionIdOf(requestedSessionId);
const store = await readSessionStore(webCtx.credentials);
const active = activeWorkBuddySession(store);
const routing = await readSessionRouting(webCtx.credentials);
const apiKeys = await currentApiKeyState(webCtx, settings);
const globalMode = authenticationMode(settings.get("llm-pi-ai"));
const sessionBinding = routing.enabled && sessionId ? routing.bindings[sessionId] : undefined;
const recentBinding = routing.enabled ? routing.lastUsed : undefined;
const exactAccount = sessionBinding?.mode === "token" ? store.sessions.find((entry) => entry.id === sessionBinding.accountId) : undefined;
const exactApiKey = sessionBinding?.mode === "api-key" ? apiKeys.apiKeys.find((entry) => entry.ref === sessionBinding.apiKeyRef) : undefined;
const recentAccount = recentBinding?.mode === "token" ? store.sessions.find((entry) => entry.id === recentBinding.accountId) : undefined;
const recentApiKey = recentBinding?.mode === "api-key" ? apiKeys.apiKeys.find((entry) => entry.ref === recentBinding.apiKeyRef) : undefined;
const sessionBindingValid = !sessionBinding || (sessionBinding.mode === "token" ? Boolean(exactAccount) : Boolean(exactApiKey?.configured));
const recentBindingValid = !recentBinding || (recentBinding.mode === "token" ? Boolean(recentAccount) : Boolean(recentApiKey?.configured));
const effectiveBinding = sessionBinding;
const suggestedBinding = !sessionBinding && recentBindingValid ? recentBinding : undefined;
// Unbound Sessions display the new-Session default, but runtime auth only
// becomes exact after the first model call persists it for that Session.
const displayedBinding = effectiveBinding ?? suggestedBinding;
const boundAccount = displayedBinding?.mode === "token" ? (effectiveBinding ? exactAccount : recentAccount) : undefined;
const boundApiKey = displayedBinding?.mode === "api-key" ? (effectiveBinding ? exactApiKey : recentApiKey) : undefined;
const mode = displayedBinding?.mode ?? globalMode;
return {
ok: true,
mode,
globalMode,
routingEnabled: routing.enabled,
sessionId: sessionId ?? null,
sessionBinding: sessionBinding ?? null,
sessionBindingValid,
lastUsedBinding: recentBinding ?? null,
lastUsedBindingValid: recentBindingValid,
effectiveBinding: effectiveBinding ?? null,
suggestedBinding: suggestedBinding ?? null,
suggestedAccountId: suggestedBinding?.mode === "token" ? recentAccount?.id ?? null : null,
suggestedApiKeyId: suggestedBinding?.mode === "api-key" ? recentApiKey?.id ?? null : null,
authenticated: displayedBinding
? displayedBinding.mode === "token" ? Boolean(boundAccount) : Boolean(boundApiKey?.configured)
: globalMode === "token" ? active !== undefined : apiKeys.apiKeyConfigured,
activeAccountId: displayedBinding?.mode === "token" ? boundAccount?.id ?? null : active?.id ?? null,
globalActiveAccountId: active?.id ?? null,
accounts: workBuddySessionAccounts(store),
...apiKeys,
activeApiKeyId: displayedBinding?.mode === "api-key" ? boundApiKey?.id ?? null : apiKeys.activeApiKeyId,
globalActiveApiKeyId: apiKeys.activeApiKeyId,
apiKeyConfigured: mode === "api-key" && (displayedBinding?.mode === "api-key" ? Boolean(boundApiKey?.configured) : apiKeys.apiKeyConfigured),
};
};
const status = async (req, res) => {
try {
const sessionId = new URL(req.url, "http://127.0.0.1").searchParams.get("sessionId");
json(res, 200, await currentState(sessionId));
} catch (error) {
json(res, 500, { ok: false, message: error instanceof Error ? error.message : "读取 WorkBuddy 认证状态失败" });
}
};
const models = async (req, res) => {
try {
const providers = settings.get("llm-pi-ai")?.providers ?? {};
const profile = providers[PROVIDER] ?? providers[LEGACY_PROVIDER];
if (req.method === "GET") return json(res, 200, {
ok: true,
models: profile?.models ?? null,
revision: settings.revision(),
});
if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" });
if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面编辑模型" });
const body = await requestBody(req);
if (body.action === "discover") {
return json(res, 200, { ok: true, models: await webCtx.llm.discoverModels(settingsNs, { provider: PROVIDER }) });
}
if (body.action !== "save" && body.action !== "reset") return json(res, 400, { ok: false, message: "模型操作无效" });
if (body.action === "save" && !validModelOverrides(body.models)) return json(res, 400, { ok: false, message: "模型列表格式无效" });
if (profile === undefined && body.action === "reset") return json(res, 200, { ok: true, models: null, revision: settings.revision() });
const path = ["providers", providers[PROVIDER] === undefined && providers[LEGACY_PROVIDER] !== undefined ? LEGACY_PROVIDER : PROVIDER];
const ops = profile === undefined
? [{ op: "set", path, value: { apiKeyEnv: API_KEY_ENV, ...(body.action === "save" ? { models: body.models } : {}) } }]
: [body.action === "save"
? { op: "set", path: [...path, "models"], value: body.models }
: { op: "unset", path: [...path, "models"] }];
await settings.mutate("llm-pi-ai", ops, body.revision);
return json(res, 200, { ok: true, models: body.action === "save" ? body.models : null, revision: settings.revision() });
} catch (error) {
json(res, 500, { ok: false, message: error instanceof Error ? error.message : "编辑 WorkBuddy 模型失败" });
}
};
const routing = async (req, res) => {
if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" });
if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面切换会话认证" });
try {
const body = await requestBody(req);
if (typeof body.enabled !== "boolean") return json(res, 400, { ok: false, message: "会话级认证开关参数无效" });
const state = await readSessionRouting(webCtx.credentials);
await writeSessionRouting(webCtx.credentials, { ...state, enabled: body.enabled });
json(res, 200, await currentState(body.sessionId));
} catch (error) {
json(res, 500, { ok: false, message: error instanceof Error ? error.message : "切换会话级认证失败" });
}
};
const unbind = async (req, res) => {
if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" });
if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面解除会话认证" });
try {
const body = await requestBody(req);
const sessionId = sessionIdOf(body.sessionId);
if (!sessionId) return json(res, 400, { ok: false, message: "缺少当前会话 ID" });
const state = await readSessionRouting(webCtx.credentials);
if (Object.hasOwn(state.bindings, sessionId)) {
const bindings = { ...state.bindings };
delete bindings[sessionId];
await writeSessionRouting(webCtx.credentials, { ...state, bindings });
}
json(res, 200, await currentState(sessionId));
} catch (error) {
json(res, 500, { ok: false, message: error instanceof Error ? error.message : "解除会话认证失败" });
}
};
const apiKey = async (req, res) => {
if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" });
if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面切换认证方式" });
try {
const body = await requestBody(req);
const sessionId = sessionIdOf(body.sessionId);
const routingState = await readSessionRouting(webCtx.credentials);
const sessionRouting = routingState.enabled && sessionId;
let ref = API_KEY_ENV;
if (typeof body.keyId === "string" && body.keyId) {
const state = await currentApiKeyState(webCtx, settings);
const selected = state.apiKeys.find((entry) => entry.id === body.keyId);
if (!selected) return json(res, 404, { ok: false, message: "没有找到该 WorkBuddy API Key" });
if (!selected.configured) return json(res, 409, { ok: false, message: "该 API Key 已不可用,请删除后重新添加" });
ref = selected.ref;
if (!sessionRouting && !routingState.enabled) {
const store = await readApiKeyStore(webCtx.credentials);
await writeApiKeyStore(webCtx.credentials, { ...store, activeId: selected.kind === "dsh" ? selected.id : null });
}
}
credentialRef(ref);
if (routingState.enabled && !body.keyId) {
const state = await currentApiKeyState(webCtx, settings);
const selected = state.apiKeys.find((entry) => entry.ref === ref) ?? state.apiKeys.find((entry) => entry.configured);
if (!selected?.configured) return json(res, 409, { ok: false, message: "未检测到可用 WorkBuddy API Key" });
ref = selected.ref;
}
if (sessionRouting) {
await writeSessionRouting(webCtx.credentials, {
...routingState,
bindings: { ...routingState.bindings, [sessionId]: { mode: "api-key", apiKeyRef: ref } },
lastUsed: { mode: "api-key", apiKeyRef: ref },
});
} else if (routingState.enabled) {
await writeSessionRouting(webCtx.credentials, {
...routingState,
lastUsed: { mode: "api-key", apiKeyRef: ref },
});
} else {
await setMode(settings, "api-key", ref);
}
json(res, 200, await currentState(sessionId));
} catch (error) {
json(res, 500, { ok: false, message: error instanceof Error ? error.message : "切换 API Key 失败" });
}
};
const addApiKey = async (req, res) => {
if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" });
if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面保存 API Key" });
try {
const body = await requestBody(req);
const sessionId = sessionIdOf(body.sessionId);
const value = typeof body.key === "string" ? body.key.trim() : "";
if (!value) return json(res, 400, { ok: false, message: "请输入 API Key" });
if (value.length > 16 * 1024) return json(res, 413, { ok: false, message: "API Key 长度超出限制" });
const store = await readApiKeyStore(webCtx.credentials);
const ref = storedApiKeyRef();
const entry = {
id: `dsh:${ref}`,
ref,
label: textLabel(body.label) ?? `DSH API Key ${store.entries.length + 1}`,
};
await webCtx.credentials.set(credentialRef(ref), value);
try {
const next = upsertWorkBuddyApiKey(store, entry);
await writeApiKeyStore(webCtx.credentials, next);
const routingState = await readSessionRouting(webCtx.credentials);
if (routingState.enabled && sessionId) {
await writeSessionRouting(webCtx.credentials, {
...routingState,
bindings: { ...routingState.bindings, [sessionId]: { mode: "api-key", apiKeyRef: ref } },
lastUsed: { mode: "api-key", apiKeyRef: ref },
});
} else if (routingState.enabled) {
await writeSessionRouting(webCtx.credentials, {
...routingState,
lastUsed: { mode: "api-key", apiKeyRef: ref },
});
} else {
await setMode(settings, "api-key", ref);
}
} catch (error) {
await webCtx.credentials.unset(credentialRef(ref));
throw error;
}
json(res, 200, await currentState(sessionId));
} catch (error) {
json(res, 500, { ok: false, message: error instanceof Error ? error.message : "保存 API Key 失败" });
}
};
const removeApiKey = async (req, res) => {
if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" });
if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面删除 API Key" });
try {
const body = await requestBody(req);
const sessionId = sessionIdOf(body.sessionId);
const store = await readApiKeyStore(webCtx.credentials);
const entry = store.entries.find((item) => item.id === body.keyId);
if (!entry) return json(res, 404, { ok: false, message: "没有找到该 WorkBuddy API Key" });
const activeRef = configuredApiKeyRef(settings);
await webCtx.credentials.unset(credentialRef(entry.ref));
const remaining = store.entries.filter((item) => item.id !== entry.id);
await writeApiKeyStore(webCtx.credentials, { version: 1, activeId: remaining[0]?.id, entries: remaining });
await clearSessionBindings(webCtx.credentials, (binding) => binding.mode === "api-key" && binding.apiKeyRef === entry.ref);
if (authenticationMode(settings.get("llm-pi-ai")) === "api-key" && activeRef === entry.ref) {
const environment = await webCtx.credentials.resolve(credentialRef(API_KEY_ENV));
let fallback = API_KEY_ENV;
if (!environment?.value) {
for (const candidate of remaining) {
if ((await webCtx.credentials.resolve(credentialRef(candidate.ref)))?.value) {
fallback = candidate.ref;
break;
}
}
}
await setMode(settings, "api-key", fallback);
}
json(res, 200, await currentState(sessionId));
} catch (error) {
json(res, 500, { ok: false, message: error instanceof Error ? error.message : "删除 API Key 失败" });
}
};
const token = async (req, res) => {
if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" });
if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面切换认证方式" });
try {
const body = await requestBody(req);
const store = await readSessionStore(webCtx.credentials);
const sessionId = sessionIdOf(body.sessionId);
const accountId = typeof body.accountId === "string" ? body.accountId : store.activeId;
const active = store.sessions.find((entry) => entry.id === accountId);
if (!active) return json(res, 409, { ok: false, message: "没有找到该 WorkBuddy 登录账号" });
const routingState = await readSessionRouting(webCtx.credentials);
if (routingState.enabled && sessionId) {
await writeSessionRouting(webCtx.credentials, {
...routingState,
bindings: { ...routingState.bindings, [sessionId]: { mode: "token", accountId: active.id } },
lastUsed: { mode: "token", accountId: active.id },
});
} else if (routingState.enabled) {
await writeSessionRouting(webCtx.credentials, {
...routingState,
lastUsed: { mode: "token", accountId: active.id },
});
} else {
await writeSessionStore(webCtx.credentials, { ...store, activeId: active.id });
await setMode(settings, "token");
}
json(res, 200, await currentState(sessionId));
} catch (error) {
json(res, 500, { ok: false, message: error instanceof Error ? error.message : "切换令牌账号失败" });
}
};
const credits = async (req, res) => {
if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" });
if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面查询 WorkBuddy 积分" });
try {
const body = await requestBody(req);
const sessionId = sessionIdOf(body.sessionId);
const selection = await currentState(sessionId);
if (selection.mode !== "token") {
// [自用定制 2026-10-10] 账单接口接受 API Key 作为 Bearer 令牌,故 API Key 模式同样可以查积分。
// 还原:见同目录 workbuddy-web.js.bak-20261010
const keyStore = await readApiKeyStore(webCtx.credentials);
const activeKey = keyStore.entries.find((entry) => entry.id === keyStore.activeId) ?? keyStore.entries[0];
const candidates = [activeKey?.ref, API_KEY_ENV, LEGACY_API_KEY_ENV]
.filter((value) => typeof value === "string" && value);
let apiKey;
for (const ref of candidates) {
const stored = await webCtx.credentials.resolve(credentialRef(ref));
if (stored?.value) { apiKey = stored.value; break; }
}
if (!apiKey) {
return json(res, 200, {
ok: true,
accountId: null,
credits: null,
totalDosage: null,
segments: [],
unlimited: false,
cycleResetTime: null,
creditError: "未找到可用的 API Key",
todayUsage: null,
todayUsageError: "今日请求量查询仅支持 WorkBuddy 令牌登录",
});
}
const keyed = await fetchWorkBuddyCredits({
auth: { accessToken: apiKey, domain: "www.codebuddy.cn" },
account: {},
});
return json(res, 200, {
ok: true,
accountId: null,
credits: keyed.credits,
totalDosage: keyed.totalDosage,
segments: keyed.segments,
unlimited: !!keyed.unlimited,
cycleResetTime: keyed.cycleResetTime ?? null,
planName: keyed.planName ?? null,
planCycleEnd: keyed.planCycleEnd ?? null,
creditError: keyed.creditError ?? null,
todayUsage: null,
todayUsageError: "今日请求量查询仅支持 WorkBuddy 令牌登录",
});
}
const session = await resolveSession(webCtx, selection.activeAccountId ?? body.accountId, sessionId);
const result = await fetchWorkBuddyCredits(session);
json(res, 200, {
ok: true,
accountId: session.id,
credits: result.credits,
totalDosage: result.totalDosage,
segments: result.segments,
unlimited: !!result.unlimited,
cycleResetTime: result.cycleResetTime ?? null,
planName: result.planName ?? null,
planCycleEnd: result.planCycleEnd ?? null,
creditError: result.creditError ?? null,
todayUsage: result.todayUsage ?? null,
todayUsageError: result.todayUsageError ?? null,
});
} catch (error) {
json(res, 200, {
ok: true,
accountId: null,
credits: null,
totalDosage: null,
segments: [],
unlimited: false,
cycleResetTime: null,
creditError: error instanceof Error ? error.message : "查询 WorkBuddy 积分失败",
todayUsage: null,
todayUsageError: error instanceof Error ? error.message : "查询 WorkBuddy 今日请求量失败",
});
}
};
const login = async (req, res) => {
if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" });
if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面登录" });
try {
const body = await requestBody(req);
const sessionId = sessionIdOf(body.sessionId);
loginPromise ??= (async () => {
const session = await loginWorkBuddy();
const store = await readSessionStore(webCtx.credentials);
const nextStore = upsertWorkBuddySession(store, session);
await writeSessionStore(webCtx.credentials, nextStore);
const routingState = await readSessionRouting(webCtx.credentials);
if (routingState.enabled && sessionId) {
await writeSessionRouting(webCtx.credentials, {
...routingState,
bindings: { ...routingState.bindings, [sessionId]: { mode: "token", accountId: nextStore.activeId } },
lastUsed: { mode: "token", accountId: nextStore.activeId },
});
} else if (routingState.enabled) {
await writeSessionRouting(webCtx.credentials, {
...routingState,
lastUsed: { mode: "token", accountId: nextStore.activeId },
});
} else {
await setMode(settings, "token");
}
})().finally(() => {
loginPromise = undefined;
});
await loginPromise;
json(res, 200, await currentState(sessionId));
} catch (error) {
json(res, 500, { ok: false, message: error instanceof Error ? error.message : "WorkBuddy 登录失败" });
}
};
const remove = async (req, res) => {
if (req.method !== "POST") return json(res, 405, { ok: false, message: "Method not allowed" });
if (!localPost(req)) return json(res, 403, { ok: false, message: "只允许从本机 DSH 页面管理登录账号" });
try {
const body = await requestBody(req);
const sessionId = sessionIdOf(body.sessionId);
const store = await readSessionStore(webCtx.credentials);
const accountId = typeof body.accountId === "string" ? body.accountId : store.activeId;
const sessions = store.sessions.filter((entry) => entry.id !== accountId);
if (sessions.length === store.sessions.length) return json(res, 404, { ok: false, message: "没有找到该 WorkBuddy 登录账号" });
const activeId = accountId === store.activeId ? sessions[0]?.id : store.activeId;
await writeSessionStore(webCtx.credentials, { version: 1, activeId, sessions });
await clearSessionBindings(webCtx.credentials, (binding) => binding.mode === "token" && binding.accountId === accountId);
json(res, 200, await currentState(sessionId));
} catch (error) {
json(res, 500, { ok: false, message: error instanceof Error ? error.message : "删除令牌账号失败" });
}
};
webCtx.effect(() => {
const dispose = [
webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/status`, handler: status }),
webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/models`, handler: models }),
webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/routing`, handler: routing }),
webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/unbind`, handler: unbind }),
webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/api-key`, handler: apiKey }),
webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/api-key/add`, handler: addApiKey }),
webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/api-key/remove`, handler: removeApiKey }),
webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/token`, handler: token }),
webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/credits`, handler: credits }),
webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/login`, handler: login }),
webCtx.webServer.register({ kind: "exact", path: `${ROUTE}/remove`, handler: remove }),
];
return () => dispose.forEach((fn) => fn());
}, "llm-workbuddy: web login routes");
});
}