Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79966c6730 | ||
|
|
43fb21f625 | ||
|
|
f795f18c61 | ||
|
|
a44227b69a |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
*.bak-*
|
||||
*.bak
|
||||
node_modules/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
@@ -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
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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 内容 |
|
||||
| 10 | `index.js` | `DISPLAY_NAME` 由 `WorkBuddy 中国区` 改为 `WorkBuddy`(不显示区服) |
|
||||
|
||||
代码内均以 `[自用定制 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`(原作者的版权声明必须保留)。
|
||||
@@ -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)
|
||||
@@ -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 **未公开**的接口,
|
||||
上游随时可能变动,**可用性无任何保证**。请仅限本机自用,勿用于多账号池化、代他人调用或任何形式的转售。
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)。
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
}];
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user