dsh-model 0.0.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.en.md +123 -0
  2. package/README.md +92 -43
  3. package/bin/dsh-model.js +8 -0
  4. package/dist/bridge/aggregate.js +91 -0
  5. package/dist/bridge/login.js +127 -0
  6. package/dist/bridge/runtime.js +136 -0
  7. package/dist/bridge/server.js +178 -0
  8. package/dist/bridge/workbuddy/_compat.js +52 -0
  9. package/dist/bridge/workbuddy/app-version.js +141 -0
  10. package/dist/bridge/workbuddy/auth.js +548 -0
  11. package/dist/bridge/workbuddy/catalog.js +154 -0
  12. package/dist/bridge/workbuddy/client-identity.js +234 -0
  13. package/dist/bridge/workbuddy/desktop-credential-protection.js +992 -0
  14. package/dist/bridge/workbuddy/loopback.js +52 -0
  15. package/dist/bridge/workbuddy/probe.js +153 -0
  16. package/dist/bridge/workbuddy/status-paths.js +49 -0
  17. package/dist/bridge/workbuddy/upstream.js +1180 -0
  18. package/dist/bridge/workbuddy/variants.js +101 -0
  19. package/dist/cli.js +207 -0
  20. package/dist/commands/bridge.js +68 -0
  21. package/dist/commands/connect.js +67 -0
  22. package/dist/commands/engine.js +122 -0
  23. package/dist/commands/key.js +75 -0
  24. package/dist/commands/login.js +113 -0
  25. package/dist/commands/logs.js +19 -0
  26. package/dist/commands/opencode.js +77 -0
  27. package/dist/commands/remote.js +129 -0
  28. package/dist/commands/service.js +72 -0
  29. package/dist/commands/setup.js +216 -0
  30. package/dist/commands/sources.js +196 -0
  31. package/dist/commands/status.js +216 -0
  32. package/dist/commands/uninstall.js +97 -0
  33. package/dist/commands/workbuddy.js +117 -0
  34. package/dist/context.js +86 -0
  35. package/dist/daemon/client.js +27 -0
  36. package/dist/daemon/core.js +790 -0
  37. package/dist/daemon/service.js +33 -0
  38. package/dist/daemon/stats.js +126 -0
  39. package/dist/daemon/usage.js +213 -0
  40. package/dist/dsh/cli.js +72 -0
  41. package/dist/dsh/connect.js +257 -0
  42. package/dist/dsh/credentials.js +55 -0
  43. package/dist/dsh/locate.js +129 -0
  44. package/dist/dsh/patch.js +106 -0
  45. package/dist/dsh/pick.js +59 -0
  46. package/dist/engine/auth.js +40 -0
  47. package/dist/engine/client.js +104 -0
  48. package/dist/engine/compat.js +94 -0
  49. package/dist/engine/config.js +47 -0
  50. package/dist/engine/install.js +118 -0
  51. package/dist/engine/mgmt.js +99 -0
  52. package/dist/engine/sourcemap.js +48 -0
  53. package/dist/errors.js +14 -0
  54. package/dist/i18n.js +39 -0
  55. package/dist/integrations/dshplugin.js +42 -0
  56. package/dist/integrations/opencode.js +101 -0
  57. package/dist/integrations/workbuddy.js +114 -0
  58. package/dist/keys.js +54 -0
  59. package/dist/ops.js +178 -0
  60. package/dist/remote/public.js +109 -0
  61. package/dist/secrets.js +13 -0
  62. package/dist/service/index.js +46 -0
  63. package/dist/service/launchd.js +89 -0
  64. package/dist/service/systemd.js +83 -0
  65. package/dist/sources.js +36 -0
  66. package/dist/state.js +90 -0
  67. package/dist/upstreams.js +23 -0
  68. package/dist/util/diff.js +30 -0
  69. package/dist/util/exec.js +42 -0
  70. package/dist/util/fs.js +107 -0
  71. package/dist/util/output.js +50 -0
  72. package/dist/util/pkg.js +13 -0
  73. package/dist/util/port.js +16 -0
  74. package/dist/util/prompt.js +40 -0
  75. package/dist/util/proxy.js +73 -0
  76. package/dist/util/redact.js +7 -0
  77. package/engine-manifest.json +28 -0
  78. package/package.json +77 -7
  79. package/plugin/client.js +549 -0
  80. package/plugin/cordis.patch.yml +4 -0
  81. package/plugin/index.js +177 -0
  82. package/docs/DESIGN.md +0 -610
package/README.en.md ADDED
@@ -0,0 +1,123 @@
1
+ <div align="center">
2
+
3
+ # dsh-model
4
+
5
+ **Many model sources behind one OpenAI-compatible endpoint — for dsh and every other tool you use.**
6
+
7
+ <p>
8
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/AIcivilization/dsh-model" alt="MIT license"></a>
9
+ <a href="https://www.npmjs.com/package/dsh-model"><img src="https://img.shields.io/npm/v/dsh-model?color=cb3837&logo=npm" alt="npm version"></a>
10
+ <img src="https://img.shields.io/badge/DeepSeek%20Harness-%E2%89%A5%200.2.0--rc.2-4176E6" alt="DeepSeek Harness ≥ 0.2.0-rc.2">
11
+ <img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-blue" alt="Platform: macOS / Linux">
12
+ <img src="https://img.shields.io/badge/node-%E2%89%A5%2020-339933?logo=node.js&logoColor=white" alt="Node ≥ 20">
13
+ <a href="https://github.com/router-for-me/CLIProxyAPI"><img src="https://img.shields.io/badge/engine-CLIProxyAPI%208.0.13-555" alt="Engine: CLIProxyAPI 8.0.13"></a>
14
+ </p>
15
+
16
+ English · [中文](README.md)
17
+
18
+ </div>
19
+
20
+ ## What it is
21
+
22
+ You may have several model sources at once: free WorkBuddy credits, an OpenCode Zen key, paid Codex or Kimi subscriptions. Each has its own API and sign-in, and every tool needs its own setup.
23
+
24
+ dsh-model turns them into **one address and one kind of key**:
25
+
26
+ - **In dsh**: the models show up in dsh's model list automatically, grouped by source. Nothing to configure.
27
+ - **Everywhere else**: any editor or script that speaks the OpenAI API uses the same address and key to reach every model.
28
+ - **Low maintenance**: one command to install; a key is always required; uninstall restores dsh's config exactly.
29
+ - **On a server**: runs next to [dsh-vps](https://github.com/AIcivilization/dsh-vps) on the same VPS and opens a public address on dsh's own domain, so your laptop and phone can use it too.
30
+
31
+ > A personal tool: one person, one account per provider, not a public service.
32
+
33
+ ## Quick start
34
+
35
+ ```bash
36
+ npm install -g dsh-model
37
+ dsh-model setup
38
+ ```
39
+
40
+ On a dsh-vps server, use `sudo dsh-model setup`.
41
+
42
+ `setup` is safe to re-run. It:
43
+
44
+ 1. downloads and verifies the engine and runs it on `127.0.0.1:8317/v1`, with key auth enforced;
45
+ 2. connects OpenCode Zen (enter your key; it is tested before being saved) and WorkBuddy (dsh-model's own bridge);
46
+ 3. installs the management page into dsh and syncs the models into dsh;
47
+ 4. on a VPS, also opens a public address `https://<dsh's domain>:9443/v1` (or `<IP>.sslip.io` when there is no domain).
48
+
49
+ ## Manage it in dsh: Settings → dsh-model
50
+
51
+ | Section | What it holds |
52
+ |---|---|
53
+ | **Endpoint** | Local and public addresses, each with a Copy button |
54
+ | **Sources** | One switch per provider: on connects (and signs in if needed), off pauses and keeps the sign-in. Each row is tagged Free / Free · limited / Paid and shows plan usage or remaining credits |
55
+ | **Models** | Pick which models of each source appear in dsh (5 suggested per source by default); the rest stay available through the endpoint |
56
+ | **API keys** | OpenAI-style, one per device. Requests, success rate, latency and speed over 24 hours; copy, rotate, revoke |
57
+ | **Model stats** | Success rate, latency and speed per model |
58
+
59
+ dsh-model does every sign-in itself. No provider CLI is installed and the server needs no browser:
60
+
61
+ - **WorkBuddy, Codex, Kimi, Grok, Muse**: open the link and approve; if a code is needed, the dialog shows it;
62
+ - **Devin, Claude, Antigravity**: these send the browser to a fixed local address that will not load. That is expected: bring the address back and press "Paste from clipboard".
63
+
64
+ If a source is signed in but has no usable plan (say, a Kimi account without Kimi Code), dsh-model detects it, hides its models in dsh and shows where to subscribe.
65
+
66
+ > After updating dsh-model, restart dsh once so the management page picks up the new code. Model and switch changes need no restart.
67
+
68
+ ## Sources and cost
69
+
70
+ | Source | Cost | Notes |
71
+ |---|---|---|
72
+ | WorkBuddy / WorkBuddy AI | Free | Credits come with the account |
73
+ | Codex | Paid | ChatGPT Plus / Pro |
74
+ | Kimi | Paid | Kimi Code plan |
75
+ | Grok | Paid | SuperGrok / X Premium+ |
76
+ | Muse | Paid | Muse Code plan |
77
+ | Devin | Paid | The free tier is refused in testing |
78
+ | OpenCode Zen | Paid | Pay as you go after a top-up; its free models only work inside the OpenCode app |
79
+ | Claude, Antigravity | High risk, hidden by default | The providers have banned third-party use before |
80
+
81
+ dsh-model only connects; it provides no quota of its own.
82
+
83
+ ## Commands
84
+
85
+ | Command | What it does |
86
+ |---|---|
87
+ | `setup` | Install / repair everything (idempotent) |
88
+ | `sources` | Sources: switch, cost, sign-in state, usage |
89
+ | `source enable\|disable\|logout <source>` | Turn on (signing in if needed) / off / sign out |
90
+ | `key list\|add\|revoke\|rotate <name>` | Access keys, one per device |
91
+ | `stats` | Success rate, latency and speed by key, source and model |
92
+ | `status` / `doctor [--e2e]` | Overview / health checks |
93
+ | `models [sync]` | List models / sync them to dsh |
94
+ | `remote enable --via caddy\|ssh\|tailscale` / `remote disable` | Remote access (on a VPS, setup already enables caddy) |
95
+ | `engine version\|upgrade\|rollback` | Engine version |
96
+ | `logs [--bridge]` / `repair` | Logs / repair |
97
+ | `uninstall` | Clean uninstall |
98
+
99
+ Every command accepts `--lang zh|en` and `--json`.
100
+
101
+ ## Architecture
102
+
103
+ ```
104
+ dsh · editors · scripts ──Bearer key──▶ engine 127.0.0.1:8317/v1 (the only entry)
105
+ │ ▲ VPS: Caddy https://<dsh domain>:9443
106
+ ┌────────────────────────────┼─────────────────────────┐
107
+ ▼ ▼ ▼
108
+ subscription OAuth (Codex, Kimi…) opencode/* (your key) workbuddy/* → dsh-model's bridge
109
+ ```
110
+
111
+ - The engine is [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) (MIT), version-pinned and sha256-verified. The WorkBuddy protocol layer is ported from [dsh-workbuddy-connect](https://github.com/corrinehu/dsh-workbuddy-connect) (MIT); the usage endpoints follow [CodexBar](https://github.com/steipete/CodexBar) (MIT).
112
+ - dsh-model writes only two things into dsh: the model providers and `refs.DSH_MODEL_API_KEY`. On uninstall they are restored byte for byte if nobody changed them.
113
+
114
+ See the [design doc](docs/DESIGN.md) (Chinese).
115
+
116
+ ## Notes
117
+
118
+ - Using subscription and WorkBuddy credentials outside the official clients may break the providers' terms and put the account at risk. Decide for yourself.
119
+ - macOS and Linux, Node ≥ 20.
120
+
121
+ ## License
122
+
123
+ MIT
package/README.md CHANGED
@@ -1,73 +1,122 @@
1
+ <div align="center">
2
+
1
3
  # dsh-model
2
4
 
3
- > **无需 API key,装上就能用前沿模型。** No login, no sign-up, no API key — the models are just there.
5
+ **把多家模型汇成一个 OpenAI 兼容地址,dsh 和你的其他软件都能直接用。**
6
+
7
+ <p>
8
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/AIcivilization/dsh-model" alt="MIT license"></a>
9
+ <a href="https://www.npmjs.com/package/dsh-model"><img src="https://img.shields.io/npm/v/dsh-model?color=cb3837&logo=npm" alt="npm version"></a>
10
+ <img src="https://img.shields.io/badge/DeepSeek%20Harness-%E2%89%A5%200.2.0--rc.2-4176E6" alt="DeepSeek Harness ≥ 0.2.0-rc.2">
11
+ <img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-blue" alt="Platform: macOS / Linux">
12
+ <img src="https://img.shields.io/badge/node-%E2%89%A5%2020-339933?logo=node.js&logoColor=white" alt="Node ≥ 20">
13
+ <a href="https://github.com/router-for-me/CLIProxyAPI"><img src="https://img.shields.io/badge/engine-CLIProxyAPI%208.0.13-555" alt="Engine: CLIProxyAPI 8.0.13"></a>
14
+ </p>
15
+
16
+ [English](README.en.md) · 中文
17
+
18
+ </div>
19
+
20
+ ## 它是什么
21
+
22
+ 你可能同时有好几个模型来源:WorkBuddy 的免费积分、OpenCode Zen 的 key,还有已经付费的 Codex、Kimi 订阅。它们的接口和登录方式各不相同,每个软件都要分别配置。
4
23
 
5
- 一条命令,把你本机(或海外 VPS)的 Codex、Claude Code、Grok Build、Muse 等 CLI 订阅,变成 dsh 和所有本地软件都能用的模型——OpenAI 兼容,填个地址就行。
24
+ dsh-model 把它们合成**一个地址、一种 key**:
6
25
 
7
- ## 它解决什么
26
+ - **dsh 里**:装好后自动出现在 dsh 的模型列表里,按来源分组,不用手动配置。
27
+ - **其他软件**:编辑器、脚本,凡是支持 OpenAI 接口的,填上同一个地址和 key 就能用全部模型。
28
+ - **省心**:一条命令装好;始终要求 key,不会出现谁都能用的情况;卸载时把 dsh 的配置原样还原。
29
+ - **服务器上**:可以和 [dsh-vps](https://github.com/AIcivilization/dsh-vps) 装在同一台 VPS 上,自动用 dsh 的域名开一个对外地址,你的电脑、手机都能访问。
8
30
 
9
- - 装了 Claude Code / Codex 订阅,却没法在 dsh 里直接用;
10
- - 想在编辑器插件、脚本、其他 Agent 框架里用订阅模型,到处买 API key;
11
- - 国内连不上国际模型:海外 VPS 部署 dsh-model 作中继,国内直连 VPS 即可;
12
- - 一个 key 都没有:内置免费模型池(DeepSeek V4 Flash、MiMo、Hy3、Muse Spark 等),开箱即聊。
31
+ > 个人自用工具:一个人、每家一个账号,不对外提供服务。
13
32
 
14
33
  ## 快速开始
15
34
 
16
35
  ```bash
17
36
  npm install -g dsh-model
18
- dsh-model setup # 安装引擎 + 内置免费模型,接入 dsh
37
+ dsh-model setup
19
38
  ```
20
39
 
21
- 打开 dsh → 获取可用模型 → 直接对话。想用 GPT/Claude 再增量登录:
40
+ 服务器上(装了 dsh-vps 的 VPS)用 `sudo dsh-model setup`。
22
41
 
23
- ```bash
24
- dsh-model login codex # device-code 登录,按提示在浏览器完成
25
- ```
42
+ `setup` 可以重复执行,它会依次:
26
43
 
27
- 海外中继(国内用户推荐):
44
+ 1. 下载并校验引擎,在本机 `127.0.0.1:8317/v1` 上运行,强制 key 鉴权;
45
+ 2. 接入 OpenCode Zen(输入你的 key,先实测再保存)和 WorkBuddy(dsh-model 自己的 bridge);
46
+ 3. 把管理页装进 dsh,把模型同步进 dsh;
47
+ 4. 在 VPS 上,额外开通对外地址 `https://<dsh 的域名>:9443/v1`(没有域名时用 `<IP>.sslip.io`)。
28
48
 
29
- ```bash
30
- # 在海外 VPS 上
31
- dsh-model setup && dsh-model relay enable
32
- # 国内任何软件填 https://<vps>/v1 + token
33
- ```
49
+ ## 在 dsh 里管理:设置 → dsh-model
34
50
 
35
- ## 架构
51
+ | 区块 | 内容 |
52
+ |---|---|
53
+ | **访问地址** | 本机地址和外网地址,各带「复制」按钮 |
54
+ | **来源** | 每家一个开关:打开就接入(没登录会弹出登录框),关闭只停用、保留登录。每行标出「免费 / 免费·额度少 / 需付费」,下面显示套餐用量或剩余积分 |
55
+ | **选模型** | 每个来源选哪些模型显示在 dsh 里,默认每家推荐 5 个,其余的经统一地址照样可用 |
56
+ | **API key** | OpenAI 格式,每台设备一把。显示 24 小时内的请求数、成功率、延迟和速度,可复制、轮换、吊销 |
57
+ | **模型统计** | 按模型显示成功率、延迟和速度 |
36
58
 
37
- ```
38
- dsh · 编辑器 · 脚本 · 任意 OpenAI 兼容软件
39
- │
40
- ▼
41
- dsh-model(薄集成层:setup / connect-dsh / relay / status)
42
- │
43
- ▼
44
- CLIProxyAPI 引擎(成熟核心:各家 OAuth 直连 + 全协议出口)
45
- ```
59
+ 登录全由 dsh-model 自己完成,服务器上不装各家的 CLI,也不需要浏览器:
60
+
61
+ - **WorkBuddy、Codex、Kimi、Grok、Muse**:打开链接授权即可,要验证码的弹窗里会给出;
62
+ - **Devin、Claude、Antigravity**:这几家会把浏览器跳到一个固定的本机地址,页面打不开是正常的。把地址栏里的地址带回来,点「从剪贴板粘贴」即可。
63
+
64
+ 已登录但没有可用套餐的来源(比如 Kimi 账号没有开通 Kimi Code),dsh-model 会实测出来,把它的模型从 dsh 里隐藏,并给出开通链接。
65
+
66
+ > 更新 dsh-model 后,要重启一次 dsh,管理页的新代码才会生效。模型和开关的变化不需要重启。
67
+
68
+ ## 来源与费用
46
69
 
47
- - 引擎:[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)(Go,MIT),上游 OAuth 直连 Codex / Claude Code / Grok Build / Muse / Gemini / Kimi 等;
48
- - dsh-model 自身只做安装编排、dsh 接线(`connect-dsh` / 干净卸载)、已装探测、海外中继产品化、免费模型池;
49
- - 不自建界面——模型选择与使用全部发生在 dsh 的 Web UI(VPS 场景配合 [dsh-vps](https://github.com/AIcivilization/dsh-vps))。
70
+ | 来源 | 费用 | 说明 |
71
+ |---|---|---|
72
+ | WorkBuddy / WorkBuddy AI | 免费 | 账号自带积分 |
73
+ | Codex | 需付费 | ChatGPT Plus / Pro |
74
+ | Kimi | 需付费 | Kimi Code 套餐 |
75
+ | Grok | 需付费 | SuperGrok / X Premium+ |
76
+ | Muse | 需付费 | Muse Code 套餐 |
77
+ | Devin | 需付费 | 免费档实测调用会被拒 |
78
+ | OpenCode Zen | 需付费 | 充值后按量计费;它的免费模型只能在 OpenCode 软件里用 |
79
+ | Claude、Antigravity | 高风险,默认隐藏 | 服务商有封禁第三方使用的先例 |
50
80
 
51
- 完整设计见 [docs/DESIGN.md](docs/DESIGN.md)。
81
+ dsh-model 只负责转接,不提供额度。
52
82
 
53
- ## 命令
83
+ ## 常用命令
54
84
 
55
85
  | 命令 | 作用 |
56
86
  |---|---|
57
- | `dsh-model setup` | 安装引擎、内置免费模型、接入 dsh |
58
- | `dsh-model connect-dsh` / `disconnect-dsh` | 接线 / 干净还原 dsh 配置 |
59
- | `dsh-model login <cli>` | device-code 登录各家订阅 |
60
- | `dsh-model relay enable` | 海外中继模式(公网 + token) |
61
- | `dsh-model status` / `doctor` | 已装/登录状态 / 自检 |
62
- | `dsh-model uninstall` | 干净卸载,不留痕 |
87
+ | `setup` | 安装 / 修复全部(可重复执行) |
88
+ | `sources` | 来源列表:开关、费用、登录状态、用量 |
89
+ | `source enable\|disable\|logout <来源>` | 打开(需要时登录)/ 关闭 / 退出登录 |
90
+ | `key list\|add\|revoke\|rotate <名称>` | 访问 key,每台设备一把 |
91
+ | `stats` | 按 key、来源、模型的成功率、延迟和速度 |
92
+ | `status` / `doctor [--e2e]` | 总览 / 逐项自检 |
93
+ | `models [sync]` | 列出模型 / 同步到 dsh |
94
+ | `remote enable --via caddy\|ssh\|tailscale` / `remote disable` | 远程访问(VPS 上 setup 已自动开通 caddy) |
95
+ | `engine version\|upgrade\|rollback` | 引擎版本 |
96
+ | `logs [--bridge]` / `repair` | 日志 / 修复 |
97
+ | `uninstall` | 干净卸载 |
98
+
99
+ 所有命令都支持 `--lang zh|en` 和 `--json`。
100
+
101
+ ## 架构
102
+
103
+ ```
104
+ dsh · 编辑器 · 脚本 ──Bearer key──▶ 引擎 127.0.0.1:8317/v1(唯一入口)
105
+ │ ▲ VPS:Caddy https://<dsh 域名>:9443
106
+ ┌────────────────────────┼────────────────────────┐
107
+ ▼ ▼ ▼
108
+ 订阅 OAuth(Codex、Kimi…) opencode/*(你的 key) workbuddy/* → dsh-model 的 bridge
109
+ ```
63
110
 
64
- ## 状态
111
+ - 引擎是 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)(MIT),版本锁定并校验 sha256。WorkBuddy 协议层移植自 [dsh-workbuddy-connect](https://github.com/corrinehu/dsh-workbuddy-connect)(MIT),订阅用量的查询接口参考了 [CodexBar](https://github.com/steipete/CodexBar)(MIT)。
112
+ - dsh 里只写两样东西:模型 provider 和 `refs.DSH_MODEL_API_KEY`。卸载时,如果这两处没被改过,就逐字节还原。
65
113
 
66
- 开发中(M0 验证阶段)。平台:macOS / Linux(VPS)优先,Windows 延后。
114
+ 详见 [设计文档](docs/DESIGN.md)。
67
115
 
68
- ## 免责声明
116
+ ## 注意
69
117
 
70
- 个人学习研究用途。dsh-model 是将你本人已授权的 CLI 订阅封装为本地 API 的工具,请遵守各家服务商的服务条款,自行承担账号风险。不支持也不鼓励多账号、配额规避等行为。
118
+ - 订阅和 WorkBuddy 的凭据在官方客户端之外使用,可能不符合服务商的条款,账号有风险,请自行判断。
119
+ - 支持 macOS 和 Linux,需要 Node ≥ 20。
71
120
 
72
121
  ## License
73
122
 
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import('../dist/cli.js').then((m) => m.main(process.argv.slice(2))).then(
3
+ (code) => process.exit(code ?? 0),
4
+ (error) => {
5
+ console.error(error?.stack ?? String(error))
6
+ process.exit(1)
7
+ },
8
+ )
@@ -0,0 +1,91 @@
1
+ // bridge/aggregate.ts — 把 OpenAI chat.completion.chunk 的 SSE 流拼成一个非流式 chat.completion
2
+ //
3
+ // WorkBuddy 上游只给流式(prepareChatBody 强制 stream:true),而引擎对非流式客户端会发 stream:false,
4
+ // 期待一个完整的 JSON。这里按 OpenAI 的增量规则累积:content / reasoning_content 拼接,
5
+ // tool_calls 按 index 合并 id、name 与 arguments 片段,finish_reason 与 usage 取最后出现的值。
6
+ export function aggregateSse(text) {
7
+ let id = '';
8
+ let model = '';
9
+ let created = Math.floor(Date.now() / 1000);
10
+ let usage;
11
+ const choices = new Map();
12
+ for (const rawLine of text.split('\n')) {
13
+ const line = rawLine.trim();
14
+ if (!line.startsWith('data:'))
15
+ continue;
16
+ const data = line.slice(5).trim();
17
+ if (!data || data === '[DONE]')
18
+ continue;
19
+ let chunk;
20
+ try {
21
+ chunk = JSON.parse(data);
22
+ }
23
+ catch {
24
+ continue;
25
+ }
26
+ if (typeof chunk.id === 'string' && chunk.id)
27
+ id = chunk.id;
28
+ if (typeof chunk.model === 'string' && chunk.model)
29
+ model = chunk.model;
30
+ if (typeof chunk.created === 'number')
31
+ created = chunk.created;
32
+ if (chunk.usage && typeof chunk.usage === 'object')
33
+ usage = chunk.usage;
34
+ for (const c of (Array.isArray(chunk.choices) ? chunk.choices : [])) {
35
+ const index = typeof c.index === 'number' ? c.index : 0;
36
+ let acc = choices.get(index);
37
+ if (!acc) {
38
+ acc = { index, content: '', reasoning: '', toolCalls: new Map(), finishReason: null, role: 'assistant' };
39
+ choices.set(index, acc);
40
+ }
41
+ const delta = (c.delta ?? c.message ?? {});
42
+ if (typeof delta.role === 'string')
43
+ acc.role = delta.role;
44
+ if (typeof delta.content === 'string')
45
+ acc.content += delta.content;
46
+ const reasoning = delta.reasoning_content ?? delta.reasoning;
47
+ if (typeof reasoning === 'string')
48
+ acc.reasoning += reasoning;
49
+ for (const tc of (Array.isArray(delta.tool_calls) ? delta.tool_calls : [])) {
50
+ const ti = typeof tc.index === 'number' ? tc.index : acc.toolCalls.size;
51
+ let t = acc.toolCalls.get(ti);
52
+ if (!t) {
53
+ t = { type: 'function', function: { name: '', arguments: '' } };
54
+ acc.toolCalls.set(ti, t);
55
+ }
56
+ if (typeof tc.id === 'string' && tc.id)
57
+ t.id = tc.id;
58
+ const fn = (tc.function ?? {});
59
+ if (typeof fn.name === 'string')
60
+ t.function.name += fn.name;
61
+ if (typeof fn.arguments === 'string')
62
+ t.function.arguments += fn.arguments;
63
+ }
64
+ if (typeof c.finish_reason === 'string' && c.finish_reason)
65
+ acc.finishReason = c.finish_reason;
66
+ }
67
+ }
68
+ const ordered = [...choices.values()].sort((a, b) => a.index - b.index);
69
+ if (!ordered.length)
70
+ ordered.push({ index: 0, content: '', reasoning: '', toolCalls: new Map(), finishReason: 'stop', role: 'assistant' });
71
+ return {
72
+ id: id || `chatcmpl-bridge-${Date.now()}`,
73
+ object: 'chat.completion',
74
+ created,
75
+ model,
76
+ choices: ordered.map((c) => {
77
+ const tools = [...c.toolCalls.entries()].sort(([a], [b]) => a - b).map(([i, t]) => ({ ...t, id: t.id ?? `call_${i}` }));
78
+ return {
79
+ index: c.index,
80
+ message: {
81
+ role: c.role,
82
+ content: c.content || (tools.length ? null : ''),
83
+ ...(c.reasoning ? { reasoning_content: c.reasoning } : {}),
84
+ ...(tools.length ? { tool_calls: tools } : {}),
85
+ },
86
+ finish_reason: c.finishReason ?? (tools.length ? 'tool_calls' : 'stop'),
87
+ };
88
+ }),
89
+ ...(usage ? { usage } : {}),
90
+ };
91
+ }
@@ -0,0 +1,127 @@
1
+ // bridge/login.ts — dsh-model 自己完成 WorkBuddy 登录(不需要桌面 App,也不需要 CodeBuddy CLI)
2
+ //
3
+ // 流程与 WorkBuddy 内置 CLI 的 cli-external-link 相同(读其 product.json 与登录源码确认,2026-10-08):
4
+ // POST {base}/v2/plugin/auth/state?platform=<workbuddy|workbuddy-ai> → { state, authUrl }
5
+ // 用户在任意设备的浏览器打开 authUrl 并授权(服务器上不需要浏览器,也没有本地回调)
6
+ // 轮询 GET {base}/v2/plugin/auth/token?state=… → 令牌
7
+ // GET {base}/v2/plugin/login/account?state=…(Bearer) → 账号(uid / 企业 / 昵称)
8
+ // 令牌按移植凭据库的"自有副本"格式写进 $DSH_MODEL_HOME/workbuddy/<ownFilename>(0600),bridge 直接用。
9
+ // 网络请求走 curl:它认 -x 代理(Node 的 fetch 不走代理);国内版域名强制直连。
10
+ import { join } from 'node:path';
11
+ import { DshModelError } from '../errors.js';
12
+ import { L } from '../i18n.js';
13
+ import { run } from '../util/exec.js';
14
+ import { atomicWrite, ensureDir } from '../util/fs.js';
15
+ import { bridgeDir } from './runtime.js';
16
+ export const LOGIN_ENDPOINTS = {
17
+ workbuddy: { base: 'https://www.workbuddy.cn', platform: 'workbuddy' },
18
+ 'workbuddy-ai': { base: 'https://www.workbuddy.ai', platform: 'workbuddy-ai' },
19
+ };
20
+ const NO_AUTH_HEADERS = ['X-No-Authorization: true', 'X-No-User-Id: true', 'X-No-Enterprise-Id: true', 'X-No-Department-Info: true'];
21
+ const POLL_INTERVAL_MS = 2000;
22
+ /** 一次 JSON 请求。headers 经 stdin 的 curl 配置传入,令牌不出现在进程参数里 */
23
+ async function curlJson(method, url, headers, proxy) {
24
+ const direct = /\.workbuddy\.cn$|\.codebuddy\.cn$|\.tencent\.com$|^127\.0\.0\.1$|^localhost$/.test(new URL(url).hostname);
25
+ const cfg = [...headers, 'Content-Type: application/json', 'Accept: application/json'].map((h) => `header = "${h.replace(/"/g, '')}"`).join('\n') + '\n';
26
+ const args = ['-s', '-m', '20', '-w', '\n%{http_code}', '-X', method, '-K', '-', ...(proxy && !direct ? ['-x', proxy] : ['--noproxy', '*']), ...(method === 'POST' ? ['-d', '{}'] : []), url];
27
+ const r = await run('curl', args, { input: cfg, timeoutMs: 30_000 });
28
+ if (r.code !== 0 && !r.stdout) {
29
+ throw new DshModelError('workbuddy_network', L(`连不上 ${new URL(url).host}(curl 退出码 ${r.code})${proxy ? '' : ';海外 / 国际版可能需要代理:dsh-model setup --proxy <地址>'}`, `Cannot reach ${new URL(url).host} (curl exit ${r.code})${proxy ? '' : '; the international site may need a proxy: dsh-model setup --proxy <url>'}`));
30
+ }
31
+ const lines = r.stdout.trimEnd().split('\n');
32
+ const status = Number(lines.pop()) || 0;
33
+ let body = {};
34
+ try {
35
+ body = JSON.parse(lines.join('\n'));
36
+ }
37
+ catch {
38
+ // 非 JSON(网关错误页等)
39
+ }
40
+ return { status, body };
41
+ }
42
+ const str = (v) => (typeof v === 'string' && v ? v : undefined);
43
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
44
+ /** 秒或毫秒的时间戳统一成毫秒 */
45
+ const toMs = (v) => (v < 1e12 ? v * 1000 : v);
46
+ export async function workbuddyLogin(variant, home, opts) {
47
+ const known = LOGIN_ENDPOINTS[variant.id];
48
+ if (!known)
49
+ throw new DshModelError('workbuddy_unknown_variant', `unknown WorkBuddy product: ${variant.id}`);
50
+ // 测试用:DSH_MODEL_WORKBUDDY_BASE 指向本机假服务
51
+ const ep = process.env.DSH_MODEL_WORKBUDDY_BASE ? { ...known, base: process.env.DSH_MODEL_WORKBUDDY_BASE } : known;
52
+ const st = await curlJson('POST', `${ep.base}/v2/plugin/auth/state?platform=${ep.platform}`, NO_AUTH_HEADERS, opts.proxy);
53
+ const state = str(st.body.data?.state);
54
+ const authUrl = str(st.body.data?.authUrl);
55
+ if (!state || !authUrl) {
56
+ throw new DshModelError('workbuddy_auth_state_failed', L(`${variant.displayName} 没有返回授权链接(HTTP ${st.status}${st.body.msg ? `:${st.body.msg}` : ''})`, `${variant.displayName} returned no authorization link (HTTP ${st.status}${st.body.msg ? `: ${st.body.msg}` : ''})`));
57
+ }
58
+ opts.onUrl(authUrl);
59
+ // 轮询令牌:授权前服务端返回"等待中",授权后返回令牌
60
+ const deadline = Date.now() + (opts.timeoutMs ?? 10 * 60_000);
61
+ let token;
62
+ while (Date.now() < deadline) {
63
+ if (opts.signal?.aborted)
64
+ throw new DshModelError('workbuddy_login_cancelled', L('已取消登录', 'Login cancelled'));
65
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
66
+ let res;
67
+ try {
68
+ res = await curlJson('GET', `${ep.base}/v2/plugin/auth/token?state=${encodeURIComponent(state)}`, NO_AUTH_HEADERS, opts.proxy);
69
+ }
70
+ catch {
71
+ continue; // 偶发网络抖动:继续等
72
+ }
73
+ if (str(res.body.data?.accessToken)) {
74
+ token = res.body.data;
75
+ break;
76
+ }
77
+ }
78
+ if (!token)
79
+ throw new DshModelError('workbuddy_login_timeout', L('等待授权超时(10 分钟)', 'Timed out waiting for authorization (10 minutes)'), L('重新执行 dsh-model workbuddy login', 'Run dsh-model workbuddy login again'));
80
+ const accessToken = str(token.accessToken);
81
+ // 账号信息:uid / 企业 / 昵称决定请求头(X-User-Id 等),拿不到就用 X-No-* 头
82
+ let account = {};
83
+ for (let i = 0; i < 5; i++) {
84
+ try {
85
+ const a = await curlJson('GET', `${ep.base}/v2/plugin/login/account?state=${encodeURIComponent(state)}`, [`Authorization: Bearer ${accessToken}`, 'X-No-User-Id: true', 'X-No-Enterprise-Id: true', 'X-No-Department-Info: true'], opts.proxy);
86
+ if (a.body.data && typeof a.body.data === 'object') {
87
+ account = a.body.data;
88
+ break;
89
+ }
90
+ }
91
+ catch {
92
+ // 重试
93
+ }
94
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
95
+ }
96
+ const expiresIn = num(token.expiresIn);
97
+ const expiresAt = num(token.expiresAt);
98
+ const refreshExpiresAt = num(token.refreshExpiresAt);
99
+ const enterpriseId = str(account.enterpriseId) ?? str(token.enterpriseId);
100
+ const nickname = str(account.nickname) ?? str(account.userNickname) ?? str(account.name);
101
+ const credential = {
102
+ accessToken,
103
+ refreshToken: str(token.refreshToken) ?? '',
104
+ expiresAtMs: expiresAt ? toMs(expiresAt) : expiresIn ? Date.now() + expiresIn * 1000 : 0,
105
+ ...(refreshExpiresAt ? { refreshExpiresAtMs: toMs(refreshExpiresAt) } : {}),
106
+ // 凭据库按 domain 判断国内 / 国际:令牌没给就用登录站点的域名
107
+ domain: str(token.domain) ?? str(account.domain) ?? new URL(ep.base).hostname,
108
+ uid: str(account.uid) ?? str(account.userId) ?? str(token.uid) ?? '',
109
+ ...(enterpriseId ? { enterpriseId } : {}),
110
+ ...(nickname ? { nickname } : {}),
111
+ source: 'dsh',
112
+ };
113
+ const dir = bridgeDir(home);
114
+ await ensureDir(dir, { owner: opts.owner });
115
+ await atomicWrite(join(dir, variant.ownFilename), JSON.stringify({ version: 1, credential }, null, 2) + '\n', { mode: 0o600, owner: opts.owner });
116
+ return credential;
117
+ }
118
+ /** 退出登录:删掉 dsh-model 保存的那份(App 的文件不动) */
119
+ export async function workbuddyLogout(variant, home) {
120
+ const { rm } = await import('node:fs/promises');
121
+ const { exists } = await import('../util/fs.js');
122
+ const file = join(bridgeDir(home), variant.ownFilename);
123
+ if (!(await exists(file)))
124
+ return false;
125
+ await rm(file, { force: true });
126
+ return true;
127
+ }
@@ -0,0 +1,136 @@
1
+ // bridge/runtime.ts — bridge 的运行时:按本机装了哪些 WorkBuddy App 建 runtime,刷新目录并落盘
2
+ //
3
+ // 目录落在 $DSH_MODEL_HOME/workbuddy/catalog-<key>.json,CLI 据此生成引擎的 openai-compatibility 上游与 dsh 的模型清单。
4
+ // 刷新的令牌只存 bridge 自己的副本(移植代码的 WorkBuddyCredentialStore 负责),从不改写 App 的凭据文件。
5
+ import { randomBytes } from 'node:crypto';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { atomicWrite, exists, readJson, writeJson } from '../util/fs.js';
9
+ import { WorkBuddyCredentialStore } from './workbuddy/auth.js';
10
+ import { FALLBACK_WORKBUDDY_AI_MODELS, FALLBACK_WORKBUDDY_MODELS, WorkBuddyCatalog } from './workbuddy/catalog.js';
11
+ import { WorkBuddyUpstreamClient } from './workbuddy/upstream.js';
12
+ import { WORKBUDDY_VARIANTS } from './workbuddy/variants.js';
13
+ import { atRestKeyProviderFor } from './workbuddy/desktop-credential-protection.js';
14
+ export const BRIDGE_DEFAULT_PORT = 18317;
15
+ /** 产品 → 路由前缀 / 引擎里的模型前缀 */
16
+ export const VARIANT_KEYS = {
17
+ workbuddy: { key: 'cn', prefix: 'workbuddy' },
18
+ 'workbuddy-ai': { key: 'ai', prefix: 'workbuddy-ai' },
19
+ };
20
+ export function bridgeDir(home) {
21
+ return join(home, 'workbuddy');
22
+ }
23
+ export function bridgeConfigPath(home) {
24
+ return join(home, 'bridge.json');
25
+ }
26
+ export function catalogPath(home, key) {
27
+ return join(bridgeDir(home), `catalog-${key}.json`);
28
+ }
29
+ export async function loadBridgeConfig(home) {
30
+ return readJson(bridgeConfigPath(home));
31
+ }
32
+ export async function ensureBridgeConfig(home, port, owner) {
33
+ const existing = await loadBridgeConfig(home);
34
+ if (existing && existing.port === port)
35
+ return existing;
36
+ const cfg = { port, secret: existing?.secret ?? `dshb_${randomBytes(32).toString('base64url')}` };
37
+ await writeJson(bridgeConfigPath(home), cfg, { owner });
38
+ return cfg;
39
+ }
40
+ /** 本机装了哪几个 WorkBuddy App(看 Electron 可执行文件) */
41
+ export async function installedVariants() {
42
+ // 测试用:不去发现本机真实的 WorkBuddy App(免得读到真实凭据)
43
+ if (process.platform !== 'darwin' || process.env.DSH_MODEL_NO_APP_DISCOVERY === '1')
44
+ return [];
45
+ const out = [];
46
+ for (const v of WORKBUDDY_VARIANTS) {
47
+ const p = v.electron?.macOS?.defaultPath;
48
+ if (!p)
49
+ continue;
50
+ if ((await exists(p)) || (await exists(join(homedir(), p))))
51
+ out.push(v);
52
+ }
53
+ return out;
54
+ }
55
+ /**
56
+ * bridge 要服务的产品:装了桌面 App 的(macOS),或用 dsh-model workbuddy login 登录过的(任何平台,含 VPS)。
57
+ */
58
+ export async function availableVariants(home) {
59
+ const apps = await installedVariants();
60
+ const out = [...apps];
61
+ for (const v of WORKBUDDY_VARIANTS) {
62
+ if (!out.includes(v) && (await exists(join(bridgeDir(home), v.ownFilename))))
63
+ out.push(v);
64
+ }
65
+ return out;
66
+ }
67
+ /** 这个产品有没有 dsh-model 自己登录的那份令牌 */
68
+ export async function hasOwnLogin(home, variant) {
69
+ return exists(join(bridgeDir(home), variant.ownFilename));
70
+ }
71
+ export async function loadCatalogs(home) {
72
+ const out = [];
73
+ for (const { key } of Object.values(VARIANT_KEYS)) {
74
+ const c = await readJson(catalogPath(home, key));
75
+ if (c)
76
+ out.push(c);
77
+ }
78
+ return out;
79
+ }
80
+ export function buildRuntime(variant, home) {
81
+ const ids = VARIANT_KEYS[variant.id] ?? { key: variant.id, prefix: variant.id };
82
+ const client = new WorkBuddyUpstreamClient();
83
+ // 密钥提供器要带 macOS 的 App 发现(mdfind / 默认路径),否则不会去找 WorkBuddy 自带的 Electron(实测)
84
+ const store = new WorkBuddyCredentialStore({
85
+ variant,
86
+ keyProvider: atRestKeyProviderFor(variant),
87
+ refresh: (credential) => client.refreshToken(credential),
88
+ ownPath: join(bridgeDir(home), variant.ownFilename),
89
+ // 非 macOS 没有可解密的桌面凭据(CodeBuddy CLI 在 Linux 上也加密存储,密钥来源未知):只用 dsh-model 自己登录的那份
90
+ ...(process.platform === 'darwin' ? {} : { desktopPath: join(bridgeDir(home), '.no-desktop-credential') }),
91
+ });
92
+ const catalog = new WorkBuddyCatalog(variant.id === 'workbuddy-ai' ? FALLBACK_WORKBUDDY_AI_MODELS : FALLBACK_WORKBUDDY_MODELS);
93
+ // 没登录时不对外报模型:报了也只会失败
94
+ catalog.setVisible(false);
95
+ return { key: ids.key, prefix: ids.prefix, label: variant.displayName, variant, store, client, catalog, wbCatalog: catalog, wbClient: client, wbStore: store };
96
+ }
97
+ /** 刷新一个产品的登录状态与目录,并落盘。返回目录是否有变化 */
98
+ export async function refreshCatalog(home, rt, owner) {
99
+ const before = await readJson(catalogPath(home, rt.key));
100
+ const file = { variant: rt.variant.id, label: rt.label, key: rt.key, prefix: rt.prefix, signedIn: false, updatedAt: new Date().toISOString(), models: [] };
101
+ try {
102
+ const status = await rt.wbStore.status();
103
+ if (status.state === 'signed-in') {
104
+ const credential = await rt.wbStore.resolve();
105
+ let models;
106
+ try {
107
+ models = await rt.wbClient.fetchModels(credential, AbortSignal.timeout(20_000));
108
+ }
109
+ catch (error) {
110
+ // 目录接口失败不等于不能聊:用内置兜底目录(移植代码自带)
111
+ models = rt.wbCatalog.fallback();
112
+ file.error = `catalog: ${String(error.message ?? error).slice(0, 200)}`;
113
+ }
114
+ rt.wbCatalog.set(models);
115
+ rt.wbCatalog.setVisible(true);
116
+ file.signedIn = true;
117
+ if (status.nickname)
118
+ file.nickname = status.nickname;
119
+ file.models = [...rt.wbCatalog.current()];
120
+ }
121
+ else {
122
+ rt.wbCatalog.setVisible(false);
123
+ if (status.reason)
124
+ file.error = status.reason;
125
+ }
126
+ }
127
+ catch (error) {
128
+ rt.wbCatalog.setVisible(false);
129
+ file.error = String(error.message ?? error).slice(0, 300);
130
+ }
131
+ // 倍率与优惠(限时免费、夜间折扣)会随时间变:也算变化,让 dsh 里的名字跟着更新
132
+ const sig = (c) => JSON.stringify(c ? { s: c.signedIn, m: c.models.map((m) => [m.id, m.name, m.contextWindow, m.maxTokens, m.supportsImages, m.billing ?? null]) } : null);
133
+ const changed = sig(before) !== sig(file);
134
+ await atomicWrite(catalogPath(home, rt.key), JSON.stringify(file, null, 2) + '\n', { owner });
135
+ return changed;
136
+ }