dsh-model 0.0.1 → 0.15.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 (83) hide show
  1. package/README.en.md +127 -0
  2. package/README.md +96 -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 +183 -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 +215 -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 +83 -0
  27. package/dist/commands/remote.js +129 -0
  28. package/dist/commands/self.js +116 -0
  29. package/dist/commands/service.js +72 -0
  30. package/dist/commands/setup.js +219 -0
  31. package/dist/commands/sources.js +196 -0
  32. package/dist/commands/status.js +216 -0
  33. package/dist/commands/uninstall.js +97 -0
  34. package/dist/commands/workbuddy.js +117 -0
  35. package/dist/context.js +86 -0
  36. package/dist/daemon/client.js +27 -0
  37. package/dist/daemon/core.js +850 -0
  38. package/dist/daemon/service.js +33 -0
  39. package/dist/daemon/stats.js +126 -0
  40. package/dist/daemon/usage.js +213 -0
  41. package/dist/dsh/cli.js +90 -0
  42. package/dist/dsh/connect.js +257 -0
  43. package/dist/dsh/credentials.js +55 -0
  44. package/dist/dsh/locate.js +129 -0
  45. package/dist/dsh/patch.js +106 -0
  46. package/dist/dsh/pick.js +59 -0
  47. package/dist/engine/auth.js +40 -0
  48. package/dist/engine/client.js +104 -0
  49. package/dist/engine/compat.js +94 -0
  50. package/dist/engine/config.js +47 -0
  51. package/dist/engine/install.js +118 -0
  52. package/dist/engine/mgmt.js +99 -0
  53. package/dist/engine/sourcemap.js +48 -0
  54. package/dist/errors.js +14 -0
  55. package/dist/i18n.js +39 -0
  56. package/dist/integrations/dshplugin.js +89 -0
  57. package/dist/integrations/opencode.js +101 -0
  58. package/dist/integrations/workbuddy.js +114 -0
  59. package/dist/keys.js +54 -0
  60. package/dist/ops.js +178 -0
  61. package/dist/remote/public.js +109 -0
  62. package/dist/secrets.js +13 -0
  63. package/dist/service/index.js +48 -0
  64. package/dist/service/launchd.js +89 -0
  65. package/dist/service/systemd.js +83 -0
  66. package/dist/sources.js +36 -0
  67. package/dist/state.js +90 -0
  68. package/dist/upstreams.js +23 -0
  69. package/dist/util/diff.js +30 -0
  70. package/dist/util/exec.js +42 -0
  71. package/dist/util/fs.js +107 -0
  72. package/dist/util/output.js +50 -0
  73. package/dist/util/pkg.js +13 -0
  74. package/dist/util/port.js +16 -0
  75. package/dist/util/prompt.js +40 -0
  76. package/dist/util/proxy.js +73 -0
  77. package/dist/util/redact.js +7 -0
  78. package/engine-manifest.json +28 -0
  79. package/package.json +77 -7
  80. package/plugin/client.js +688 -0
  81. package/plugin/cordis.patch.yml +4 -0
  82. package/plugin/index.js +231 -0
  83. package/docs/DESIGN.md +0 -610
@@ -0,0 +1,850 @@
1
+ // daemon/core.ts — dsh-model 守护进程的核心(设计 §14)
2
+ //
3
+ // 跑在 bridge 服务进程里(com.dsh-model.bridge / dsh-model-bridge.service),所有操作都不需要 root:
4
+ // 引擎配置与 dsh 配置在 vps 模式下归 dsh 用户,引擎热重载,dsh 热加载。
5
+ // 职责:来源状态与开关、登录会话(引擎 OAuth / WorkBuddy 自有登录 / OpenCode key)、key 管理、用量统计。
6
+ // CLI 与 dsh 插件都只通过 /control/* 调这里,逻辑只有一份。
7
+ import { spawn } from 'node:child_process';
8
+ import { closeSync, openSync } from 'node:fs';
9
+ import { readFile } from 'node:fs/promises';
10
+ import { homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { latestVersion } from '../commands/self.js';
13
+ import { lang } from '../i18n.js';
14
+ import { which } from '../util/exec.js';
15
+ import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
16
+ import { randomBytes } from 'node:crypto';
17
+ import { currentBinary } from '../engine/install.js';
18
+ import { buildRuntime, availableVariants, refreshCatalog, loadCatalogs } from '../bridge/runtime.js';
19
+ import { workbuddyLogin, workbuddyLogout } from '../bridge/login.js';
20
+ import { WORKBUDDY_VARIANTS } from '../bridge/workbuddy/variants.js';
21
+ import { tightenAuthPerms } from '../engine/auth.js';
22
+ import { Mgmt } from '../engine/mgmt.js';
23
+ import { DshModelError, isDshModelError } from '../errors.js';
24
+ import { L } from '../i18n.js';
25
+ import { refreshOpencodeModels, removeOpencodeKey, saveOpencodeKey, validateKeyShape, verifyOpencodeKey } from '../integrations/opencode.js';
26
+ import { riskNotice as workbuddyRiskNotice } from '../integrations/workbuddy.js';
27
+ import { DSH_KEY_NAME, addKey, loadKeys, revokeKey, rotateKey, saveKeys } from '../keys.js';
28
+ import { applyEngineConfig, dshCandidates, dshKey, loadAll, saveAll, syncAll } from '../ops.js';
29
+ import { defaultPick, pickedFor } from '../dsh/pick.js';
30
+ import { loadSecrets } from '../secrets.js';
31
+ import { SOURCES, credsFor, findSource, paymentRequired } from '../sources.js';
32
+ import { withLock } from '../state.js';
33
+ import { riskNotice as upstreamRiskNotice } from '../upstreams.js';
34
+ import { getUpstream } from '../upstreams.js';
35
+ import { redactKey } from '../util/redact.js';
36
+ import { Stats } from './stats.js';
37
+ import { USAGE_UNSUPPORTED, probeEngineSource, fetchEngineUsage, fetchWorkbuddyUsage, usageFilePath } from './usage.js';
38
+ import { writeJson } from '../util/fs.js';
39
+ const LOGIN_POLL_MS = 2000;
40
+ const LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
41
+ const USAGE_POLL_MS = 5000;
42
+ const STATS_SAVE_MS = 60_000;
43
+ const CATALOG_REFRESH_MS = 30 * 60 * 1000;
44
+ const RESYNC_RETRY_MS = 60_000;
45
+ const USAGE_REFRESH_MS = 5 * 60 * 1000;
46
+ /** 查不了用量的来源多久重新实测一次 */
47
+ const PROBE_MS = 6 * 60 * 60 * 1000;
48
+ const log_ = (m) => log(m);
49
+ const log = (m) => console.error(`[${new Date().toISOString()}] ${m}`);
50
+ export class Daemon {
51
+ ctx;
52
+ runtimes = [];
53
+ sessions = new Map();
54
+ stats;
55
+ timers = [];
56
+ resyncTimer;
57
+ keyNames = new Map();
58
+ usageCache = new Map();
59
+ usageInflight;
60
+ constructor(ctx) {
61
+ this.ctx = ctx;
62
+ this.stats = new Stats(Stats.path(ctx.paths.home), ctx.owner);
63
+ }
64
+ // —— 生命周期 ——
65
+ async start() {
66
+ for (const v of await availableVariants(this.ctx.paths.home))
67
+ this.runtimes.push(buildRuntime(v, this.ctx.paths.home));
68
+ await this.stats.load();
69
+ await this.reloadKeyNames();
70
+ if (await this.refreshCatalogs())
71
+ void this.resync();
72
+ this.timers.push(setInterval(() => void this.refreshCatalogs().then((c) => (c ? this.resync() : undefined)), CATALOG_REFRESH_MS));
73
+ this.timers.push(setInterval(() => void this.pollUsage(), USAGE_POLL_MS));
74
+ this.timers.push(setInterval(() => void this.stats.save().catch(() => { }), STATS_SAVE_MS));
75
+ this.timers.push(setInterval(() => void this.reloadKeyNames(), STATS_SAVE_MS));
76
+ this.timers.push(setInterval(() => void this.refreshUsage(), USAGE_REFRESH_MS));
77
+ setTimeout(() => void this.refreshUsage(), 3000);
78
+ }
79
+ async stop() {
80
+ for (const t of this.timers)
81
+ clearInterval(t);
82
+ clearTimeout(this.resyncTimer);
83
+ for (const s of this.sessions.values())
84
+ s.abort?.abort();
85
+ await this.stats.save().catch(() => { });
86
+ }
87
+ async mgmt() {
88
+ const all = await loadAll(this.ctx);
89
+ return Mgmt.forCtx(this.ctx, all.config.port);
90
+ }
91
+ /** 全量同步(引擎配置 + dsh)。CLI 正持锁时稍后重试 */
92
+ async resync() {
93
+ try {
94
+ await withLock(this.ctx, async () => {
95
+ const all = await loadAll(this.ctx);
96
+ await syncAll(this.ctx, all, { quiet: true });
97
+ });
98
+ log('daemon: engine + dsh synced');
99
+ }
100
+ catch (error) {
101
+ log(`daemon: sync deferred: ${isDshModelError(error) ? error.code : String(error)}`);
102
+ clearTimeout(this.resyncTimer);
103
+ this.resyncTimer = setTimeout(() => void this.resync(), RESYNC_RETRY_MS);
104
+ }
105
+ }
106
+ async refreshCatalogs() {
107
+ let changed = false;
108
+ for (const rt of this.runtimes) {
109
+ try {
110
+ if (await refreshCatalog(this.ctx.paths.home, rt, this.ctx.owner))
111
+ changed = true;
112
+ }
113
+ catch (error) {
114
+ log(`daemon: ${rt.label} refresh failed: ${String(error)}`);
115
+ }
116
+ }
117
+ return changed;
118
+ }
119
+ // —— 用量统计 ——
120
+ async reloadKeyNames() {
121
+ const keys = await loadKeys(this.ctx).catch(() => ({ keys: [] }));
122
+ this.keyNames = new Map(keys.keys.map((k) => [k.key, k.name]));
123
+ }
124
+ async pollUsage() {
125
+ try {
126
+ const batch = await (await this.mgmt()).usageQueue(1000);
127
+ if (batch.length)
128
+ this.stats.add(batch, (k) => (k ? (this.keyNames.get(k) ?? 'unknown') : 'unknown'));
129
+ }
130
+ catch {
131
+ // 引擎重启中 / 还没配管理接口:下一轮再取
132
+ }
133
+ }
134
+ statsSnapshot() {
135
+ return this.stats.snapshot();
136
+ }
137
+ // —— 来源 ——
138
+ async sources() {
139
+ const list = (await this.sourcesRaw()).map((s) => {
140
+ const u = this.usageCache.get(s.id);
141
+ // 引擎来源:实测结果出来前不显示用量行;OpenCode 的 key 设置时已实测
142
+ if (USAGE_UNSUPPORTED.has(s.id) && s.loggedIn) {
143
+ const stub = s.kind === 'engine' ? undefined : { source: s.id, windows: [], fetchedAt: new Date().toISOString(), unsupported: true };
144
+ const usage = u ?? stub;
145
+ return usage ? { ...s, usage } : s;
146
+ }
147
+ return u && s.loggedIn ? { ...s, usage: u } : s;
148
+ });
149
+ // 排序:可用(已接入)→ 已登录但关闭 → 已登录但没有订阅 → 未登录;同档保持注册顺序
150
+ const rank = (s) => (s.loggedIn && s.enabled && !s.usage?.noAccess ? 0 : s.loggedIn && !s.enabled ? 1 : s.loggedIn ? 2 : 3);
151
+ return list.map((s, i) => ({ s, i })).sort((a, b) => rank(a.s) - rank(b.s) || a.i - b.i).map((x) => x.s);
152
+ }
153
+ async sourcesRaw() {
154
+ const all = await loadAll(this.ctx);
155
+ const disabled = new Set(all.config.disabledSources ?? []);
156
+ let creds = [];
157
+ try {
158
+ creds = await (await this.mgmt()).credentials();
159
+ }
160
+ catch {
161
+ // 引擎没起来:引擎来源一律显示未登录
162
+ }
163
+ const cats = await loadCatalogs(this.ctx.paths.home);
164
+ const secrets = await loadSecrets(this.ctx);
165
+ // 订阅来源能服务的模型数:管理接口按凭据列模型(只数没停用的)
166
+ const engineModels = new Map();
167
+ try {
168
+ const m = await this.mgmt();
169
+ for (const def of SOURCES.filter((d) => d.kind === 'engine')) {
170
+ const ids = new Set();
171
+ for (const c of credsFor(creds, def).filter((x) => !x.disabled))
172
+ for (const mm of await m.credentialModels(c.name).catch(() => []))
173
+ ids.add(mm.id);
174
+ if (ids.size)
175
+ engineModels.set(def.id, ids.size);
176
+ }
177
+ }
178
+ catch {
179
+ // 引擎没起来
180
+ }
181
+ const snap = this.stats.snapshot();
182
+ const modelCount = (prefix) => Object.entries(snap.byModel).filter(([m]) => m.startsWith(`${prefix}/`)).length;
183
+ return SOURCES.map((def) => {
184
+ const base = { id: def.id, label: def.label, kind: def.kind, login: def.login, riskAck: Boolean(def.riskAck), risky: Boolean(def.risky), pricing: def.pricing, ...(def.subscribeUrl ? { subscribeUrl: def.subscribeUrl } : {}) };
185
+ if (def.kind === 'engine') {
186
+ const mine = credsFor(creds, def);
187
+ const active = mine.filter((c) => !c.disabled);
188
+ const first = mine[0];
189
+ return {
190
+ ...base,
191
+ loggedIn: mine.length > 0,
192
+ enabled: active.length > 0,
193
+ ...(first ? { account: accountOf(first) } : {}),
194
+ ...(first?.status_message ? { detail: String(first.status_message) } : {}),
195
+ ...(active.length && active.every(paymentRequired) ? { detail: L('引擎收到 403 payment_required:当前账号没有可用订阅', 'Engine got 403 payment_required: this account has no usable subscription') } : {}),
196
+ models: engineModels.get(def.id) ?? 0,
197
+ };
198
+ }
199
+ if (def.kind === 'workbuddy') {
200
+ const c = cats.find((x) => x.prefix === def.variant);
201
+ return {
202
+ ...base,
203
+ loggedIn: Boolean(c?.signedIn),
204
+ enabled: Boolean(c?.signedIn) && !disabled.has(def.id),
205
+ ...(c?.nickname ? { account: c.nickname } : {}),
206
+ ...(c?.error && !c.signedIn ? { detail: c.error } : {}),
207
+ models: c?.signedIn ? c.models.length : 0,
208
+ };
209
+ }
210
+ const has = Boolean(secrets.opencode?.key);
211
+ return { ...base, loggedIn: has, enabled: has && !disabled.has('opencode'), models: has ? modelCount('opencode') : 0 };
212
+ });
213
+ }
214
+ async setSourceDisabled(id, off) {
215
+ await withLock(this.ctx, async () => {
216
+ const all = await loadAll(this.ctx);
217
+ const set = new Set(all.config.disabledSources ?? []);
218
+ if (off)
219
+ set.add(id);
220
+ else
221
+ set.delete(id);
222
+ all.config.disabledSources = [...set];
223
+ await saveAll(this.ctx, all);
224
+ });
225
+ }
226
+ /**
227
+ * 打开:已登录就启用;没登录就开始登录,返回登录会话(界面据此弹窗)。
228
+ * acceptRisk:claude / antigravity 首次开启要确认风险。
229
+ */
230
+ async enable(id, opts = {}) {
231
+ const def = mustSource(id);
232
+ const state = (await this.sources()).find((s) => s.id === def.id);
233
+ if (def.kind === 'opencode' && !state.loggedIn) {
234
+ throw new DshModelError('needs_key', L('OpenCode Zen 需要 API key:用 opencode/key 提交', 'OpenCode Zen needs an API key: submit it via opencode/key'));
235
+ }
236
+ if (!state.loggedIn) {
237
+ if (def.riskAck && !opts.acceptRisk)
238
+ return { enabled: false, riskNotice: upstreamRiskNotice(getUpstream(def.id)) };
239
+ return { enabled: false, login: await this.startLogin(def) };
240
+ }
241
+ if (def.kind === 'engine') {
242
+ const m = await this.mgmt();
243
+ for (const c of credsFor(await m.credentials(), def))
244
+ if (c.disabled)
245
+ await m.setDisabled(c.name, false);
246
+ this.forgetProbe(def.id);
247
+ }
248
+ else {
249
+ await this.setSourceDisabled(def.id, false);
250
+ }
251
+ await this.resync();
252
+ void this.refreshUsage();
253
+ return { enabled: true };
254
+ }
255
+ /** 关闭:只停用,登录保留 */
256
+ async disable(id) {
257
+ const def = mustSource(id);
258
+ if (def.kind === 'engine') {
259
+ const m = await this.mgmt();
260
+ for (const c of credsFor(await m.credentials(), def))
261
+ if (!c.disabled)
262
+ await m.setDisabled(c.name, true);
263
+ }
264
+ else {
265
+ await this.setSourceDisabled(def.id, true);
266
+ }
267
+ await this.resync();
268
+ }
269
+ /** 退出登录:删掉 dsh-model 保存的凭据(桌面 App 自己的登录不动) */
270
+ async logout(id) {
271
+ const def = mustSource(id);
272
+ if (def.kind === 'engine') {
273
+ const m = await this.mgmt();
274
+ for (const c of credsFor(await m.credentials(), def))
275
+ await m.deleteCredential(c.name);
276
+ }
277
+ else if (def.kind === 'workbuddy') {
278
+ const v = WORKBUDDY_VARIANTS.find((x) => x.id === def.variant);
279
+ await workbuddyLogout(v, this.ctx.paths.home);
280
+ await this.refreshCatalogs();
281
+ }
282
+ else {
283
+ await removeOpencodeKey(this.ctx);
284
+ }
285
+ await this.resync();
286
+ }
287
+ async setOpencodeKey(key, opts = {}) {
288
+ validateKeyShape(key);
289
+ const all = await loadAll(this.ctx);
290
+ let model;
291
+ if (!opts.skipVerify) {
292
+ const v = await verifyOpencodeKey(key, all.config.proxy);
293
+ if (!v.ok)
294
+ throw new DshModelError('opencode_key_rejected', L(`实测失败(${v.model ?? '-'}):${v.detail}`, `Test failed (${v.model ?? '-'}): ${v.detail}`));
295
+ model = v.model;
296
+ }
297
+ await saveOpencodeKey(this.ctx, key, model);
298
+ await refreshOpencodeModels(this.ctx, all.config.proxy);
299
+ await this.setSourceDisabled('opencode', false);
300
+ await this.resync();
301
+ return { ...(model ? { model } : {}) };
302
+ }
303
+ // —— 订阅用量 ——
304
+ /** 刷新所有已登录且已打开来源的用量(单飞:并发调用共用一次) */
305
+ refreshUsage() {
306
+ this.usageInflight ??= this.doRefreshUsage().finally(() => (this.usageInflight = undefined));
307
+ return this.usageInflight;
308
+ }
309
+ async doRefreshUsage() {
310
+ const before = [...this.usageCache.values()].filter((u) => u.noAccess).map((u) => u.source).sort().join(',');
311
+ await this.doRefreshUsageInner();
312
+ await writeJson(usageFilePath(this.ctx.paths.home), [...this.usageCache.values()], { owner: this.ctx.owner }).catch(() => { });
313
+ const after = [...this.usageCache.values()].filter((u) => u.noAccess).map((u) => u.source).sort().join(',');
314
+ // 某个来源"有没有可用订阅"变了:重新同步,dsh 里相应地隐藏 / 恢复它的模型
315
+ if (before !== after)
316
+ void this.resync();
317
+ }
318
+ async doRefreshUsageInner() {
319
+ const states = await this.sourcesRaw().catch(() => []);
320
+ let creds = [];
321
+ try {
322
+ creds = await (await this.mgmt()).credentials();
323
+ }
324
+ catch {
325
+ // 引擎没起来
326
+ }
327
+ await Promise.all(states.map(async (s) => {
328
+ const def = findSource(s.id);
329
+ if (!s.loggedIn || (USAGE_UNSUPPORTED.has(s.id) && def.kind !== 'engine')) {
330
+ this.usageCache.delete(s.id);
331
+ return;
332
+ }
333
+ if (USAGE_UNSUPPORTED.has(s.id)) {
334
+ await this.probe(s, def, creds);
335
+ return;
336
+ }
337
+ const fetchedAt = new Date().toISOString();
338
+ try {
339
+ let u;
340
+ if (def.kind === 'workbuddy') {
341
+ const rt = this.runtimes.find((r) => r.variant.id === def.variant);
342
+ if (!rt)
343
+ return;
344
+ u = await fetchWorkbuddyUsage(rt);
345
+ }
346
+ else if (def.kind === 'engine') {
347
+ const cred = credsFor(creds, def)[0];
348
+ if (!cred)
349
+ return;
350
+ u = await fetchEngineUsage(this.ctx, def, cred);
351
+ }
352
+ else
353
+ return;
354
+ this.usageCache.set(s.id, { source: s.id, fetchedAt, ...u });
355
+ }
356
+ catch (error) {
357
+ const prev = this.usageCache.get(s.id);
358
+ // 失败保留上次的读数,标上错误
359
+ this.usageCache.set(s.id, { ...(prev ?? { windows: [] }), source: s.id, fetchedAt: prev?.fetchedAt ?? fetchedAt, error: String(error.message ?? error).slice(0, 200) });
360
+ }
361
+ }));
362
+ }
363
+ /** 查不了用量的引擎来源:实测一次能不能调(每 6 小时;登录 / 重新打开后立即) */
364
+ async probe(s, def, creds) {
365
+ const prev = this.usageCache.get(s.id);
366
+ if (!s.enabled)
367
+ return;
368
+ if (prev && Date.now() - Date.parse(prev.fetchedAt) < PROBE_MS)
369
+ return;
370
+ const fetchedAt = new Date().toISOString();
371
+ try {
372
+ const cred = credsFor(creds, def).find((c) => !c.disabled);
373
+ if (!cred)
374
+ return;
375
+ // 引擎已经因 403 payment_required 冷却了它:不用再发请求(冷却期内引擎只回 503 auth_unavailable)
376
+ if (paymentRequired(cred)) {
377
+ this.usageCache.set(s.id, { source: s.id, windows: [], fetchedAt, unsupported: true, noAccess: true, plan: 'none', error: 'payment_required' });
378
+ log(`daemon: probe ${def.id} → denied (engine cooldown: payment_required)`);
379
+ return;
380
+ }
381
+ const model = def.probeModel ?? (await (await this.mgmt()).credentialModels(cred.name))[0]?.id;
382
+ if (!model)
383
+ return;
384
+ const all = await loadAll(this.ctx);
385
+ const r = await probeEngineSource(all.config.port, dshKey(all.keys), model);
386
+ this.usageCache.set(s.id, { source: s.id, windows: [], fetchedAt, unsupported: true, ...(r.ok ? {} : { noAccess: true, plan: 'none', error: r.reason }) });
387
+ log(`daemon: probe ${def.id} (${model}) → ${r.ok ? 'ok' : `denied: ${r.reason}`}`);
388
+ }
389
+ catch (error) {
390
+ // 网络 / 限流:不下结论,下一轮再试
391
+ if (prev)
392
+ this.usageCache.set(s.id, { ...prev, fetchedAt: new Date(Date.now() - PROBE_MS + 10 * 60 * 1000).toISOString() });
393
+ log(`daemon: probe ${def.id} inconclusive: ${String(error.message ?? error)}`);
394
+ }
395
+ }
396
+ /** 让某个来源下一轮重新实测 */
397
+ forgetProbe(id) {
398
+ if (USAGE_UNSUPPORTED.has(id))
399
+ this.usageCache.delete(id);
400
+ }
401
+ async usage(refresh = false) {
402
+ if (refresh)
403
+ await this.refreshUsage();
404
+ return [...this.usageCache.values()];
405
+ }
406
+ // —— 登录会话 ——
407
+ session(id) {
408
+ const s = this.sessions.get(id);
409
+ return s ? publicSession(s) : undefined;
410
+ }
411
+ async startLogin(def) {
412
+ // 同一来源只留一个进行中的会话
413
+ for (const s of this.sessions.values())
414
+ if (s.source === def.id && s.status === 'pending')
415
+ this.cancelSession(s.id);
416
+ if (def.kind === 'engine')
417
+ return this.startEngineLogin(def);
418
+ if (def.kind === 'workbuddy')
419
+ return this.startWorkbuddyLogin(def);
420
+ throw new DshModelError('no_login', L(`${def.label} 不需要登录`, `${def.label} has no login`));
421
+ }
422
+ /** 用引擎自带的 device code 登录(如 -codex-device-login):打印网址和码,引擎自己轮询并写凭据 */
423
+ async startCliDeviceLogin(def) {
424
+ const m = await this.mgmt();
425
+ const before = credsFor(await m.credentials(), def).map((c) => c.name);
426
+ const abort = new AbortController();
427
+ const child = spawn(currentBinary(this.ctx), ['-config', this.ctx.paths.engineYaml, def.deviceCliFlag, '-no-browser'], { cwd: this.ctx.paths.home, stdio: ['ignore', 'pipe', 'pipe'], signal: abort.signal });
428
+ let out = '';
429
+ const s = {
430
+ id: randomBytes(12).toString('hex'),
431
+ source: def.id,
432
+ kind: 'device',
433
+ status: 'pending',
434
+ expiresAt: new Date(Date.now() + LOGIN_TIMEOUT_MS).toISOString(),
435
+ needsPaste: false,
436
+ startedAt: new Date().toISOString(),
437
+ provider: def.engineProvider,
438
+ before,
439
+ abort,
440
+ };
441
+ const ready = new Promise((resolve, reject) => {
442
+ const onData = (b) => {
443
+ out = (out + b.toString()).slice(-4000);
444
+ const url = /device URL:\s*(\S+)/i.exec(out)?.[1];
445
+ const code = /device code:\s*(\S+)/i.exec(out)?.[1];
446
+ if (url && code) {
447
+ s.url = url;
448
+ s.userCode = code;
449
+ resolve();
450
+ }
451
+ };
452
+ child.stdout.on('data', onData);
453
+ child.stderr.on('data', onData);
454
+ child.on('error', (e) => reject(e));
455
+ child.on('exit', () => reject(new Error(out.trim().split('\n').slice(-3).join(' ') || 'login process exited')));
456
+ setTimeout(() => reject(new Error(L('30 秒内没拿到登录码', 'No device code within 30s'))), 30_000);
457
+ });
458
+ child.on('exit', (code) => {
459
+ if (s.status !== 'pending')
460
+ return;
461
+ if (code === 0)
462
+ void this.finishEngineLogin(s, def);
463
+ else {
464
+ s.status = 'error';
465
+ s.error = out.trim().split('\n').filter((l) => !/^CLIProxyAPI Version/.test(l)).slice(-2).join(' ') || `exit ${code}`;
466
+ }
467
+ });
468
+ try {
469
+ await ready;
470
+ }
471
+ catch (error) {
472
+ abort.abort();
473
+ throw new DshModelError('login_failed', L(`${def.label} 登录没能开始:${error.message}`, `Could not start ${def.label} sign-in: ${error.message}`));
474
+ }
475
+ this.sessions.set(s.id, s);
476
+ return publicSession(s);
477
+ }
478
+ async startEngineLogin(def) {
479
+ if (def.deviceCliFlag)
480
+ return this.startCliDeviceLogin(def);
481
+ const m = await this.mgmt();
482
+ const before = credsFor(await m.credentials(), def).map((c) => c.name);
483
+ const r = await m.authUrl(def.engineProvider);
484
+ const device = r.flow === 'device';
485
+ const s = {
486
+ id: r.state,
487
+ source: def.id,
488
+ kind: device ? 'device' : def.login,
489
+ status: 'pending',
490
+ url: r.url,
491
+ ...(r.user_code ? { userCode: r.user_code } : {}),
492
+ expiresAt: new Date(Date.now() + (r.expires_in ? r.expires_in * 1000 : LOGIN_TIMEOUT_MS)).toISOString(),
493
+ needsPaste: !device,
494
+ startedAt: new Date().toISOString(),
495
+ engineState: r.state,
496
+ provider: def.engineProvider,
497
+ before,
498
+ };
499
+ this.sessions.set(s.id, s);
500
+ void this.pollEngineLogin(s, def);
501
+ return publicSession(s);
502
+ }
503
+ async pollEngineLogin(s, def) {
504
+ const deadline = Date.parse(s.expiresAt ?? '') || Date.now() + LOGIN_TIMEOUT_MS;
505
+ while (s.status === 'pending' && Date.now() < deadline) {
506
+ await new Promise((r) => setTimeout(r, LOGIN_POLL_MS));
507
+ if (s.status !== 'pending')
508
+ return;
509
+ try {
510
+ const st = await (await this.mgmt()).oauthStatus(s.engineState);
511
+ if (st.status === 'ok') {
512
+ await this.finishEngineLogin(s, def);
513
+ return;
514
+ }
515
+ if (st.status === 'error' && st.error && !/unknown or expired state/i.test(st.error)) {
516
+ s.status = 'error';
517
+ s.error = st.error;
518
+ return;
519
+ }
520
+ }
521
+ catch {
522
+ // 引擎抖动:继续等
523
+ }
524
+ }
525
+ if (s.status === 'pending') {
526
+ s.status = 'error';
527
+ s.error = L('等待授权超时', 'Timed out waiting for authorization');
528
+ }
529
+ }
530
+ /** 登录成功:只留新账号(单账号原则),收紧权限,确保启用,同步 */
531
+ async finishEngineLogin(s, def) {
532
+ try {
533
+ const m = await this.mgmt();
534
+ const now = credsFor(await m.credentials(), def);
535
+ const fresh = now.filter((c) => !(s.before ?? []).includes(c.name));
536
+ if (fresh.length)
537
+ for (const c of now)
538
+ if (!fresh.includes(c))
539
+ await m.deleteCredential(c.name);
540
+ for (const c of fresh.length ? fresh : now)
541
+ if (c.disabled)
542
+ await m.setDisabled(c.name, false);
543
+ await tightenAuthPerms(this.ctx);
544
+ s.status = 'ok';
545
+ log(`daemon: ${def.label} signed in`);
546
+ this.forgetProbe(def.id);
547
+ await this.resync();
548
+ void this.refreshUsage();
549
+ }
550
+ catch (error) {
551
+ s.status = 'error';
552
+ s.error = String(error.message ?? error);
553
+ }
554
+ }
555
+ async startWorkbuddyLogin(def) {
556
+ const variant = WORKBUDDY_VARIANTS.find((v) => v.id === def.variant);
557
+ const all = await loadAll(this.ctx);
558
+ if (!all.config.bridge?.riskNoticeAt) {
559
+ all.config.bridge = { port: all.config.bridge?.port ?? 0, ...all.config.bridge, riskNoticeAt: new Date().toISOString() };
560
+ await saveAll(this.ctx, all);
561
+ log(workbuddyRiskNotice());
562
+ }
563
+ const s = {
564
+ id: `wb-${randomBytes(8).toString('hex')}`,
565
+ source: def.id,
566
+ kind: 'link',
567
+ status: 'pending',
568
+ needsPaste: false,
569
+ startedAt: new Date().toISOString(),
570
+ expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
571
+ abort: new AbortController(),
572
+ };
573
+ this.sessions.set(s.id, s);
574
+ let urlReady;
575
+ const ready = new Promise((r) => (urlReady = r));
576
+ void workbuddyLogin(variant, this.ctx.paths.home, {
577
+ proxy: all.config.proxy,
578
+ owner: this.ctx.owner,
579
+ signal: s.abort.signal,
580
+ onUrl: (url) => {
581
+ s.url = url;
582
+ urlReady();
583
+ },
584
+ })
585
+ .then(async () => {
586
+ s.status = 'ok';
587
+ if (!this.runtimes.some((r) => r.variant.id === variant.id))
588
+ this.runtimes.push(buildRuntime(variant, this.ctx.paths.home));
589
+ await this.setSourceDisabled(def.id, false);
590
+ await this.refreshCatalogs();
591
+ await this.resync();
592
+ })
593
+ .catch((error) => {
594
+ if (s.status === 'pending') {
595
+ s.status = 'error';
596
+ s.error = String(error.message ?? error);
597
+ }
598
+ urlReady();
599
+ });
600
+ // 等拿到授权链接(或失败)再返回,界面才有东西可显示
601
+ await Promise.race([ready, new Promise((r) => setTimeout(r, 30_000))]);
602
+ if (s.status === 'error')
603
+ throw new DshModelError('login_failed', s.error ?? 'login failed');
604
+ return publicSession(s);
605
+ }
606
+ /** 回调式登录:用户贴回浏览器跳转到的地址 */
607
+ async submitPaste(sessionId, redirectUrl) {
608
+ const s = this.sessions.get(sessionId);
609
+ if (!s || !s.provider)
610
+ throw new DshModelError('no_session', L('登录会话不存在或已结束', 'Login session not found or finished'));
611
+ const r = await (await this.mgmt()).oauthCallback(s.provider, redirectUrl.trim());
612
+ if (r.status !== 'ok')
613
+ throw new DshModelError('callback_rejected', r.error ?? 'callback rejected');
614
+ return publicSession(s);
615
+ }
616
+ cancelSession(sessionId) {
617
+ const s = this.sessions.get(sessionId);
618
+ if (!s || s.status !== 'pending')
619
+ return false;
620
+ s.status = 'cancelled';
621
+ s.abort?.abort();
622
+ if (s.engineState)
623
+ void this.mgmt().then((m) => m.cancel(s.engineState)).catch(() => { });
624
+ return true;
625
+ }
626
+ // —— key ——
627
+ async keys() {
628
+ const store = await loadKeys(this.ctx);
629
+ const snap = this.stats.snapshot();
630
+ return store.keys
631
+ .filter((k) => !k.revokedAt)
632
+ .map((k) => ({ name: k.name, key: redactKey(k.key), createdAt: k.createdAt, ...(snap.byKey[k.name] ? { stats: snap.byKey[k.name] } : {}) }));
633
+ }
634
+ // —— 更新 / 卸载(管理页的按钮)——
635
+ selfJob;
636
+ /** 版本与能不能在页面上自管理(VPS 上要 root,只给命令) */
637
+ async selfInfo() {
638
+ const vps = this.ctx.mode === 'vps';
639
+ let log = '';
640
+ if (this.selfJob)
641
+ log = await readFile(this.selfJob.log, 'utf8').catch(() => '');
642
+ return {
643
+ version: pkgVersion(),
644
+ latest: await latestVersion(),
645
+ canManage: !vps,
646
+ ...(this.selfJob ? { job: { action: this.selfJob.action, startedAt: this.selfJob.startedAt, log: log.replace(/\x1b\[[0-9;]*m/g, '').slice(-6000) } } : {}),
647
+ commands: vps
648
+ ? { update: 'sudo npm install -g dsh-model@latest && sudo dsh-model setup', uninstall: 'sudo dsh-model uninstall' }
649
+ : { update: 'dsh-model update', uninstall: 'dsh-model uninstall' },
650
+ };
651
+ }
652
+ /**
653
+ * 在独立进程里跑 update / uninstall:两者都会重启或移除守护进程本身,所以不能在守护进程里做。
654
+ * 用 detached 让它脱离守护进程的进程组(launchd 重启服务时不会被一起杀掉)。
655
+ */
656
+ async startSelfJob(action, opts = {}) {
657
+ if (this.ctx.mode === 'vps')
658
+ throw new DshModelError('needs_root', L('服务器上要 root 权限,请在终端里执行命令', 'On the server this needs root; run the command in a terminal'));
659
+ // 卸载会删掉 home,日志放在外面
660
+ const log = action === 'uninstall' ? join(homedir(), 'dsh-model-uninstall.log') : join(this.ctx.paths.home, 'update.log');
661
+ const args = action === 'update' ? ['update'] : ['uninstall', '--yes', '--remove-plugin', ...(opts.keepAuth ? ['--keep-auth'] : [])];
662
+ const out = openSync(log, 'w', 0o600);
663
+ const cmd = [process.execPath, join(PKG_ROOT, 'bin', 'dsh-model.js'), ...args, '--lang', lang()];
664
+ // Linux 的 systemd --user 重启服务会杀掉整个 cgroup:交给 systemd-run 另起一个
665
+ const useSystemdRun = this.ctx.platform === 'linux' && (await which('systemd-run'));
666
+ const keepEnv = ['PATH', 'HOME', 'LANG', 'DSH_HOME', 'DSH_MODEL_HOME', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY'].filter((k) => process.env[k]).map((k) => `--setenv=${k}=${process.env[k]}`);
667
+ const viaSystemd = ['--user', '--collect', '--quiet', ...keepEnv, '--setenv=NO_COLOR=1', '/bin/sh', '-c', 'log="$1"; shift; exec "$@" >"$log" 2>&1', 'sh', log, ...cmd];
668
+ const child = spawn(useSystemdRun ? 'systemd-run' : cmd[0], useSystemdRun ? viaSystemd : cmd.slice(1), {
669
+ detached: true,
670
+ stdio: ['ignore', out, out],
671
+ env: { ...process.env, NO_COLOR: '1', NODE_NO_WARNINGS: '1', ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) },
672
+ });
673
+ child.unref();
674
+ closeSync(out);
675
+ this.selfJob = { action, startedAt: new Date().toISOString(), log };
676
+ log_(`daemon: started ${action} (pid ${child.pid})`);
677
+ return { started: true, log };
678
+ }
679
+ /** 这个来源的全部模型,以及哪些显示在 dsh 里 */
680
+ async sourceModels(id) {
681
+ const def = mustSource(id);
682
+ const all = await loadAll(this.ctx);
683
+ const c = (await dshCandidates(this.ctx, all)).get(def.id);
684
+ const list = (c?.models ?? []).map((m) => ({ id: m.id, name: m.name, rate: m.rate }));
685
+ const chosen = all.config.dshModels?.[def.id];
686
+ const picked = new Set(pickedFor(list, chosen));
687
+ const rec = new Set(defaultPick(list));
688
+ return { custom: chosen !== undefined, models: list.map((m) => ({ id: m.id, name: m.name, selected: picked.has(m.id), recommended: rec.has(m.id) })) };
689
+ }
690
+ /** 保存勾选;null = 恢复默认挑选 */
691
+ async setSourceModels(id, models) {
692
+ const def = mustSource(id);
693
+ if (models !== null && !(Array.isArray(models) && models.every((m) => typeof m === 'string' && m.length < 200))) {
694
+ throw new DshModelError('bad_models', L('models 应是模型 id 列表', 'models must be a list of model ids'));
695
+ }
696
+ await withLock(this.ctx, async () => {
697
+ const all = await loadAll(this.ctx);
698
+ const next = { ...all.config.dshModels };
699
+ if (models === null)
700
+ delete next[def.id];
701
+ else
702
+ next[def.id] = [...new Set(models)];
703
+ all.config.dshModels = Object.keys(next).length ? next : undefined;
704
+ await saveAll(this.ctx, all);
705
+ });
706
+ await this.resync();
707
+ return { selected: models === null ? -1 : new Set(models).size };
708
+ }
709
+ /** 管理页"复制"用:取一把未吊销 key 的完整值(只经同源、带页面 token 的插件路由转发) */
710
+ async revealKey(name) {
711
+ const entry = (await loadKeys(this.ctx)).keys.find((k) => k.name === name && !k.revokedAt);
712
+ if (!entry)
713
+ throw new DshModelError('no_key', L(`没有这把 key:${name}`, `No such key: ${name}`));
714
+ return { name: entry.name, key: entry.key };
715
+ }
716
+ /** 访问地址:本机地址总有;vps 开了对外端点时还有公网地址 */
717
+ async endpoints() {
718
+ const all = await loadAll(this.ctx);
719
+ const pub = all.state.remote?.mode === 'caddy' ? all.state.remote.publicUrl : undefined;
720
+ return { local: `http://127.0.0.1:${all.config.port}/v1`, ...(pub ? { public: pub } : {}) };
721
+ }
722
+ /** 新增:返回完整 key(只这一次) */
723
+ async addKey(name) {
724
+ return withLock(this.ctx, async () => {
725
+ const all = await loadAll(this.ctx);
726
+ const entry = addKey(all.keys, name);
727
+ await saveKeys(this.ctx, all.keys);
728
+ await applyEngineConfig(this.ctx, all);
729
+ await this.reloadKeyNames();
730
+ return { name: entry.name, key: entry.key };
731
+ });
732
+ }
733
+ async revokeKey(name) {
734
+ if (name === DSH_KEY_NAME)
735
+ throw new DshModelError('cannot_revoke_dsh', L('dsh 用的 key 不能吊销,只能轮换', 'The dsh key cannot be revoked, only rotated'));
736
+ await withLock(this.ctx, async () => {
737
+ const all = await loadAll(this.ctx);
738
+ revokeKey(all.keys, name);
739
+ await saveKeys(this.ctx, all.keys);
740
+ await applyEngineConfig(this.ctx, all);
741
+ });
742
+ await this.reloadKeyNames();
743
+ }
744
+ async rotateKey(name) {
745
+ const entry = await withLock(this.ctx, async () => {
746
+ const all = await loadAll(this.ctx);
747
+ const e = rotateKey(all.keys, name);
748
+ await saveKeys(this.ctx, all.keys);
749
+ await applyEngineConfig(this.ctx, all);
750
+ return e;
751
+ });
752
+ await this.reloadKeyNames();
753
+ if (name === DSH_KEY_NAME) {
754
+ await this.resync(); // 新 key 写进 dsh 凭据
755
+ return { name };
756
+ }
757
+ return { name, key: entry.key };
758
+ }
759
+ // —— /control/* 路由 ——
760
+ async handle(method, path, body, query) {
761
+ const b = (body ?? {});
762
+ const seg = path.split('/').filter(Boolean);
763
+ try {
764
+ if (method === 'GET' && path === '/status') {
765
+ return ok({ sources: await this.sources(), keys: await this.keys(), stats: this.statsSnapshot(), endpoints: await this.endpoints() });
766
+ }
767
+ if (method === 'GET' && path === '/sources') {
768
+ if (query.get('refresh') === '1')
769
+ await this.refreshUsage();
770
+ return ok(await this.sources());
771
+ }
772
+ if (seg[0] === 'sources' && seg[1] && method === 'POST') {
773
+ if (seg[2] === 'enable')
774
+ return ok(await this.enable(seg[1], { acceptRisk: b.acceptRisk === true }));
775
+ if (seg[2] === 'disable')
776
+ return ok(await this.disable(seg[1]).then(() => ({ enabled: false })));
777
+ if (seg[2] === 'logout')
778
+ return ok(await this.logout(seg[1]).then(() => ({ loggedIn: false })));
779
+ }
780
+ if (seg[0] === 'sources' && seg[1] && seg[2] === 'models') {
781
+ if (method === 'GET')
782
+ return ok(await this.sourceModels(seg[1]));
783
+ if (method === 'POST')
784
+ return ok(await this.setSourceModels(seg[1], b.models === null ? null : b.models));
785
+ }
786
+ if (method === 'POST' && path === '/opencode/key') {
787
+ return ok(await this.setOpencodeKey(String(b.key ?? ''), { skipVerify: b.skipVerify === true }));
788
+ }
789
+ if (seg[0] === 'login' && seg[1]) {
790
+ if (method === 'GET') {
791
+ const s = this.session(seg[1]);
792
+ return s ? ok(s) : fail(404, 'no_session', L('登录会话不存在', 'Login session not found'));
793
+ }
794
+ if (method === 'POST' && seg[2] === 'callback')
795
+ return ok(await this.submitPaste(seg[1], String(b.redirectUrl ?? '')));
796
+ if (method === 'DELETE')
797
+ return ok({ cancelled: this.cancelSession(seg[1]) });
798
+ }
799
+ if (method === 'GET' && path === '/keys')
800
+ return ok(await this.keys());
801
+ if (method === 'POST' && path === '/keys')
802
+ return ok(await this.addKey(String(b.name ?? '')));
803
+ if (seg[0] === 'keys' && seg[1]) {
804
+ if (method === 'DELETE')
805
+ return ok(await this.revokeKey(seg[1]).then(() => ({ revoked: true })));
806
+ if (method === 'POST' && seg[2] === 'rotate')
807
+ return ok(await this.rotateKey(seg[1]));
808
+ if (method === 'POST' && seg[2] === 'reveal')
809
+ return ok(await this.revealKey(seg[1]));
810
+ }
811
+ if (method === 'GET' && path === '/stats')
812
+ return ok(this.statsSnapshot());
813
+ if (method === 'GET' && path === '/endpoints')
814
+ return ok(await this.endpoints());
815
+ if (method === 'GET' && path === '/self')
816
+ return ok(await this.selfInfo());
817
+ if (method === 'POST' && path === '/self/update')
818
+ return ok(await this.startSelfJob('update'));
819
+ if (method === 'POST' && path === '/self/uninstall')
820
+ return ok(await this.startSelfJob('uninstall', { keepAuth: b.keepAuth === true }));
821
+ if (method === 'GET' && path === '/usage')
822
+ return ok(await this.usage(query.get('refresh') === '1'));
823
+ return fail(404, 'not_found', `${method} ${path}`);
824
+ }
825
+ catch (error) {
826
+ if (isDshModelError(error))
827
+ return fail(400, error.code, error.message, error.hint);
828
+ return fail(500, 'internal', String(error.message ?? error));
829
+ }
830
+ }
831
+ }
832
+ function ok(body) {
833
+ return { status: 200, body };
834
+ }
835
+ function fail(status, code, message, hint) {
836
+ return { status, body: { error: { code, message, ...(hint ? { hint } : {}) } } };
837
+ }
838
+ function mustSource(id) {
839
+ const def = findSource(id);
840
+ if (!def)
841
+ throw new DshModelError('unknown_source', L(`不认识的来源:${id}`, `Unknown source: ${id}`), SOURCES.map((s) => s.id).join(', '));
842
+ return def;
843
+ }
844
+ function accountOf(c) {
845
+ return String(c.email ?? c.account ?? c.label ?? c.name);
846
+ }
847
+ function publicSession(s) {
848
+ const { engineState: _e, provider: _p, before: _b, abort: _a, ...pub } = s;
849
+ return pub;
850
+ }