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,33 @@
1
+ // daemon/service.ts — 确保守护进程(bridge 服务)在跑。setup 总会装它:来源开关、登录、统计都靠它
2
+ import { BRIDGE_DEFAULT_PORT, ensureBridgeConfig, loadBridgeConfig } from '../bridge/runtime.js';
3
+ import { DshModelError } from '../errors.js';
4
+ import { L } from '../i18n.js';
5
+ import { bridgeHealthy } from '../integrations/workbuddy.js';
6
+ import { serviceFor } from '../service/index.js';
7
+ import { ok, skip } from '../util/output.js';
8
+ import { findFreePort, isPortFree } from '../util/port.js';
9
+ export async function ensureDaemon(ctx, all, opts = {}) {
10
+ let port = all.config.bridge?.port || BRIDGE_DEFAULT_PORT;
11
+ const existing = await loadBridgeConfig(ctx.paths.home);
12
+ const ours = existing && (await bridgeHealthy(existing.port, existing.secret));
13
+ if (!ours && !(await isPortFree(port)))
14
+ port = await findFreePort(port + 1);
15
+ all.config.bridge = { ...all.config.bridge, port };
16
+ const cfg = await ensureBridgeConfig(ctx.paths.home, port, ctx.owner);
17
+ if (ctx.serviceDisabled) {
18
+ skip(L('已跳过守护进程服务(DSH_MODEL_SERVICE=none)', 'Skipped daemon service (DSH_MODEL_SERVICE=none)'));
19
+ return;
20
+ }
21
+ const svc = serviceFor(ctx, 'bridge', all.config.proxy);
22
+ await svc.install();
23
+ if (opts.restart !== false)
24
+ await svc.restart();
25
+ all.state.bridgeService = { kind: svc.kind, file: svc.spec.file, label: svc.spec.label };
26
+ const deadline = Date.now() + 15_000;
27
+ while (Date.now() < deadline && !(await bridgeHealthy(cfg.port, cfg.secret)))
28
+ await new Promise((r) => setTimeout(r, 300));
29
+ if (!(await bridgeHealthy(cfg.port, cfg.secret))) {
30
+ throw new DshModelError('daemon_unhealthy', L(`守护进程没有在 127.0.0.1:${cfg.port} 上启动`, `Daemon did not come up on 127.0.0.1:${cfg.port}`), L('查看日志:dsh-model logs --bridge', 'Check logs: dsh-model logs --bridge'));
31
+ }
32
+ ok(L(`守护进程已在 127.0.0.1:${cfg.port} 运行(来源开关、登录、统计)`, `Daemon running on 127.0.0.1:${cfg.port} (sources, logins, stats)`));
33
+ }
@@ -0,0 +1,126 @@
1
+ // daemon/stats.ts — 用量统计(设计 §14.3)
2
+ //
3
+ // 守护进程每 5 秒从引擎管理接口取走用量记录(引擎只保留 10 分钟),在这里累计。
4
+ // 只保留最近 24 小时的精简记录(最多 50k 条),按 key / 来源 / 模型给出 1 小时与 24 小时两个窗口:
5
+ // 请求数、失败数、成功率、平均延迟、输出速度(output_tokens / 延迟)、最后使用与最后失败时间。
6
+ // 记录里的客户端 key 只存 key 名(由 keys.json 映射),不存 key 本身。
7
+ import { join } from 'node:path';
8
+ import { atomicWrite, readJson } from '../util/fs.js';
9
+ const DAY_MS = 24 * 3600 * 1000;
10
+ const MAX_RECORDS = 50_000;
11
+ function summarize(recs) {
12
+ if (!recs.length)
13
+ return { requests: 0, failures: 0, successRate: null, avgLatencyMs: null, tokensPerSec: null, lastUsedAt: null, lastFailureAt: null };
14
+ let failures = 0;
15
+ let latSum = 0;
16
+ let okLat = 0;
17
+ let outSum = 0;
18
+ let last = 0;
19
+ let lastFail = 0;
20
+ for (const r of recs) {
21
+ latSum += r.l;
22
+ if (r.t > last)
23
+ last = r.t;
24
+ if (r.f) {
25
+ failures++;
26
+ if (r.t > lastFail)
27
+ lastFail = r.t;
28
+ }
29
+ else if (r.o > 0) {
30
+ // 没报 token 数的请求不参与速度计算,免得把平均速度拉低
31
+ okLat += r.l;
32
+ outSum += r.o;
33
+ }
34
+ }
35
+ return {
36
+ requests: recs.length,
37
+ failures,
38
+ successRate: (recs.length - failures) / recs.length,
39
+ avgLatencyMs: Math.round(latSum / recs.length),
40
+ tokensPerSec: okLat > 0 && outSum > 0 ? Math.round((outSum / (okLat / 1000)) * 10) / 10 : null,
41
+ lastUsedAt: last ? new Date(last).toISOString() : null,
42
+ lastFailureAt: lastFail ? new Date(lastFail).toISOString() : null,
43
+ };
44
+ }
45
+ /** 模型名前缀 → 来源:workbuddy/x → workbuddy;无前缀按引擎给的 provider */
46
+ export function sourceOf(rec) {
47
+ const alias = rec.alias || rec.model || '';
48
+ if (alias.includes('/'))
49
+ return alias.split('/')[0];
50
+ return rec.provider || 'unknown';
51
+ }
52
+ export class Stats {
53
+ file;
54
+ owner;
55
+ recs = [];
56
+ dirty = false;
57
+ constructor(file, owner) {
58
+ this.file = file;
59
+ this.owner = owner;
60
+ }
61
+ static path(home) {
62
+ return join(home, 'stats.json');
63
+ }
64
+ async load() {
65
+ const saved = await readJson(this.file).catch(() => null);
66
+ this.recs = (saved?.recs ?? []).filter((r) => Date.now() - r.t < DAY_MS);
67
+ }
68
+ /** 收进一批用量记录;keyName 把客户端 key 映射成名字(未知的记为 unknown) */
69
+ add(batch, keyName) {
70
+ for (const r of batch) {
71
+ const t = Date.parse(r.timestamp) || Date.now();
72
+ this.recs.push({
73
+ t,
74
+ k: keyName(r.api_key),
75
+ p: sourceOf(r),
76
+ m: r.alias || r.model || 'unknown',
77
+ l: Math.max(0, Number(r.latency_ms) || 0),
78
+ // 推理模型的 token 大多算在 reasoning_tokens 里(引擎分开记):速度要把两者都算上
79
+ o: Math.max(0, (Number(r.tokens?.output_tokens) || 0) + (Number(r.tokens?.reasoning_tokens) || 0)),
80
+ f: r.failed ? 1 : 0,
81
+ });
82
+ }
83
+ if (batch.length)
84
+ this.dirty = true;
85
+ this.prune();
86
+ }
87
+ prune() {
88
+ const cutoff = Date.now() - DAY_MS;
89
+ if (this.recs.length && this.recs[0].t < cutoff)
90
+ this.recs = this.recs.filter((r) => r.t >= cutoff);
91
+ if (this.recs.length > MAX_RECORDS)
92
+ this.recs = this.recs.slice(-MAX_RECORDS);
93
+ }
94
+ async save() {
95
+ if (!this.dirty)
96
+ return;
97
+ this.dirty = false;
98
+ await atomicWrite(this.file, JSON.stringify({ recs: this.recs }), { owner: this.owner });
99
+ }
100
+ snapshot(now = Date.now()) {
101
+ this.prune();
102
+ const h1 = now - 3600 * 1000;
103
+ const group = (key) => {
104
+ const m = new Map();
105
+ for (const r of this.recs) {
106
+ const k = key(r);
107
+ let a = m.get(k);
108
+ if (!a)
109
+ m.set(k, (a = []));
110
+ a.push(r);
111
+ }
112
+ return m;
113
+ };
114
+ const win = (recs) => ({ h1: summarize(recs.filter((r) => r.t >= h1)), d1: summarize(recs) });
115
+ const byKey = {};
116
+ for (const [k, rs] of group((r) => r.k))
117
+ byKey[k] = win(rs);
118
+ const bySource = {};
119
+ for (const [k, rs] of group((r) => r.p))
120
+ bySource[k] = win(rs);
121
+ const byModel = {};
122
+ for (const [k, rs] of group((r) => r.m))
123
+ byModel[k] = { source: rs[0].p, ...win(rs) };
124
+ return { updatedAt: new Date(now).toISOString(), byKey, bySource, byModel };
125
+ }
126
+ }
@@ -0,0 +1,213 @@
1
+ // daemon/usage.ts — 订阅用量(设计 §14.6)
2
+ //
3
+ // 用各来源已有的登录令牌,主动去调它们自己的额度接口。思路与接口来自 steipete 的 CodexBar(MIT,docs/<provider>.md):
4
+ // Codex GET chatgpt.com/backend-api/wham/usage rate_limit.primary_window / secondary_window
5
+ // Claude GET api.anthropic.com/api/oauth/usage five_hour / seven_day / seven_day_opus(anthropic-beta: oauth-2025-04-20)
6
+ // Kimi GET api.kimi.com/coding/v1/usages usage(总额度)+ limits[].window(如 5 小时)
7
+ // Grok GET cli-chat-proxy.grok.com/v1/billing?format=credits(返回格式未公开,宽松解析)
8
+ // WorkBuddy 移植来的 fetchCredits(剩余积分)
9
+ // 令牌来源:引擎 auth-dir 里的凭据文件(引擎会自动续期并写回),WorkBuddy 用 bridge 的凭据库。
10
+ // 一律只读:不刷新、不改写任何凭据文件。查询失败只在那一行显示"用量暂不可用"。
11
+ import { readFile } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ const TIMEOUT_MS = 15_000;
14
+ /** 这几家暂不支持(见设计 §14.6) */
15
+ export const USAGE_UNSUPPORTED = new Set(['antigravity', 'devin', 'meta', 'opencode']);
16
+ async function getJson(url, headers) {
17
+ const res = await fetch(url, { headers: { Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS) });
18
+ const text = await res.text();
19
+ if (!res.ok)
20
+ throw new Error(`HTTP ${res.status}${text ? `: ${text.slice(0, 120)}` : ''}`);
21
+ return text ? JSON.parse(text) : {};
22
+ }
23
+ const num = (v) => {
24
+ const n = typeof v === 'string' ? Number(v) : v;
25
+ return typeof n === 'number' && Number.isFinite(n) ? n : undefined;
26
+ };
27
+ const str = (v) => (typeof v === 'string' && v ? v : undefined);
28
+ const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : undefined);
29
+ /** 秒 / 毫秒时间戳或 ISO 字符串 → ISO */
30
+ function toIso(v) {
31
+ if (typeof v === 'string' && v) {
32
+ const t = Date.parse(v);
33
+ return Number.isNaN(t) ? undefined : new Date(t).toISOString();
34
+ }
35
+ const n = num(v);
36
+ if (n === undefined)
37
+ return undefined;
38
+ return new Date(n < 1e12 ? n * 1000 : n).toISOString();
39
+ }
40
+ /** 读引擎凭据文件里的访问令牌(字段名各家不同,宽松取) */
41
+ async function readEngineToken(ctx, cred) {
42
+ const doc = JSON.parse(await readFile(join(ctx.paths.auth, cred.name), 'utf8'));
43
+ const nested = obj(doc.token) ?? obj(doc.tokens) ?? {};
44
+ const token = str(doc.access_token) ?? str(doc.accessToken) ?? str(nested.access_token) ?? str(nested.accessToken);
45
+ if (!token)
46
+ throw new Error('no access token in credential file');
47
+ return { token, doc };
48
+ }
49
+ // —— 各家解析(导出给测试)——
50
+ export function parseCodexUsage(body) {
51
+ const b = obj(body) ?? {};
52
+ const rl = obj(b.rate_limit) ?? obj(b.rateLimit) ?? {};
53
+ const windows = [];
54
+ const win = (w, id, fallbackLabel) => {
55
+ const o = obj(w);
56
+ if (!o)
57
+ return;
58
+ const secs = num(o.limit_window_seconds) ?? num(o.window_seconds);
59
+ const label = secs ? humanWindow(secs) : fallbackLabel;
60
+ const resetAt = toIso(o.reset_at) ?? (num(o.reset_after_seconds) !== undefined ? new Date(Date.now() + num(o.reset_after_seconds) * 1000).toISOString() : undefined);
61
+ windows.push({ id, label, ...(num(o.used_percent) !== undefined ? { usedPercent: num(o.used_percent) } : {}), ...(resetAt ? { resetAt } : {}) });
62
+ };
63
+ win(rl.primary_window, 'primary', '5h');
64
+ win(rl.secondary_window, 'secondary', '7d');
65
+ const c = obj(b.credits);
66
+ const balance = num(c?.balance);
67
+ return {
68
+ ...(str(b.plan_type) ? { plan: str(b.plan_type) } : {}),
69
+ windows,
70
+ ...(c && (balance !== undefined || c.unlimited === true) ? { credits: { remaining: balance ?? 0, ...(c.unlimited === true ? { unlimited: true } : {}), label: 'credits' } } : {}),
71
+ };
72
+ }
73
+ export function parseClaudeUsage(body) {
74
+ const b = obj(body) ?? {};
75
+ const windows = [];
76
+ const add = (key, id, label) => {
77
+ const o = obj(b[key]);
78
+ if (!o)
79
+ return;
80
+ const used = num(o.utilization);
81
+ if (used === undefined && !o.resets_at)
82
+ return;
83
+ const resetAt = toIso(o.resets_at);
84
+ windows.push({ id, label, ...(used !== undefined ? { usedPercent: used } : {}), ...(resetAt ? { resetAt } : {}) });
85
+ };
86
+ add('five_hour', '5h', '5h');
87
+ add('seven_day', '7d', '7d');
88
+ add('seven_day_opus', 'opus-7d', 'Opus 7d');
89
+ add('seven_day_sonnet', 'sonnet-7d', 'Sonnet 7d');
90
+ return { windows };
91
+ }
92
+ export function parseKimiUsage(body) {
93
+ const b = obj(body) ?? {};
94
+ const windows = [];
95
+ const fromDetail = (d, id, label) => {
96
+ const o = obj(d);
97
+ if (!o)
98
+ return;
99
+ const limit = num(o.limit);
100
+ const used = num(o.used) ?? (limit !== undefined && num(o.remaining) !== undefined ? limit - num(o.remaining) : undefined);
101
+ const resetAt = toIso(o.resetTime);
102
+ windows.push({
103
+ id,
104
+ label,
105
+ ...(used !== undefined ? { used } : {}),
106
+ ...(limit !== undefined ? { limit } : {}),
107
+ ...(used !== undefined && limit ? { usedPercent: Math.round((used / limit) * 1000) / 10 } : {}),
108
+ ...(resetAt ? { resetAt } : {}),
109
+ });
110
+ };
111
+ for (const l of (Array.isArray(b.limits) ? b.limits : [])) {
112
+ const o = obj(l) ?? {};
113
+ const w = obj(o.window) ?? {};
114
+ const dur = num(w.duration) ?? 0;
115
+ const unit = String(w.timeUnit ?? '');
116
+ const secs = unit.includes('MINUTE') ? dur * 60 : unit.includes('HOUR') ? dur * 3600 : unit.includes('DAY') ? dur * 86400 : dur;
117
+ const label = secs ? humanWindow(secs) : 'limit';
118
+ fromDetail(o.detail, `w${secs}`, label);
119
+ }
120
+ fromDetail(b.usage, 'total', 'quota');
121
+ return { windows };
122
+ }
123
+ export function parseGrokBilling(body) {
124
+ const b = obj(body) ?? {};
125
+ const c = obj(b.credits) ?? b;
126
+ const remaining = num(c.remaining) ?? num(c.balance) ?? num(c.remainingCredits) ?? num(c.available);
127
+ const total = num(c.total) ?? num(c.limit) ?? num(c.totalCredits);
128
+ return { windows: [], ...(remaining !== undefined ? { credits: { remaining, ...(total !== undefined ? { total } : {}), label: 'credits' } } : {}) };
129
+ }
130
+ function humanWindow(secs) {
131
+ if (secs % 86400 === 0)
132
+ return `${secs / 86400}d`;
133
+ if (secs % 3600 === 0)
134
+ return `${secs / 3600}h`;
135
+ return `${Math.round(secs / 60)}m`;
136
+ }
137
+ // —— 取用量 ——
138
+ export async function fetchEngineUsage(ctx, def, cred) {
139
+ const { token, doc } = await readEngineToken(ctx, cred);
140
+ if (def.id === 'codex') {
141
+ const account = str(doc.account_id) ?? str(doc.accountId) ?? str(obj(doc.tokens)?.account_id);
142
+ const body = await getJson('https://chatgpt.com/backend-api/wham/usage', {
143
+ Authorization: `Bearer ${token}`,
144
+ ...(account ? { 'ChatGPT-Account-Id': account } : {}),
145
+ 'User-Agent': 'codex_cli_rs',
146
+ });
147
+ return parseCodexUsage(body);
148
+ }
149
+ if (def.id === 'claude') {
150
+ const body = await getJson('https://api.anthropic.com/api/oauth/usage', { Authorization: `Bearer ${token}`, 'anthropic-beta': 'oauth-2025-04-20' });
151
+ return parseClaudeUsage(body);
152
+ }
153
+ if (def.id === 'kimi') {
154
+ const r = parseKimiUsage(await getJson('https://api.kimi.com/coding/v1/usages', { Authorization: `Bearer ${token}` }));
155
+ // 没有 Kimi Code 订阅时接口返回 {}(实测;同一账号调模型得到 403 access_terminated)
156
+ return r.windows.length ? r : { windows: [], noAccess: true, plan: 'none' };
157
+ }
158
+ if (def.id === 'xai') {
159
+ return parseGrokBilling(await getJson('https://cli-chat-proxy.grok.com/v1/billing?format=credits', { Authorization: `Bearer ${token}` }));
160
+ }
161
+ return { windows: [], unsupported: true };
162
+ }
163
+ export async function fetchWorkbuddyUsage(rt) {
164
+ const credential = await rt.wbStore.resolve();
165
+ const credits = await rt.wbClient.fetchCredits(credential);
166
+ const unlimited = credits.accounts.some((a) => a.unlimited);
167
+ const size = credits.accounts.reduce((s, a) => s + (Number.isFinite(a.size) ? a.size : 0), 0);
168
+ return {
169
+ windows: [],
170
+ credits: { remaining: credits.total, ...(size > 0 ? { total: size } : {}), ...(unlimited ? { unlimited: true } : {}), label: 'credits' },
171
+ ...(credits.accounts.length ? { plan: packageSummary(credits.accounts.map((a) => a.packageName)) } : {}),
172
+ };
173
+ }
174
+ /** 套餐名去重计数:"个人版 + 加量包 ×38"(账号下常有几十个同名加量包,实测) */
175
+ export function packageSummary(names) {
176
+ const counts = new Map();
177
+ for (const n of names)
178
+ if (n)
179
+ counts.set(n, (counts.get(n) ?? 0) + 1);
180
+ return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(' + ');
181
+ }
182
+ /** 实测一次能不能调:发一条极短的请求(经引擎,用 dsh 的 key)。
183
+ * 被拒(402/403、insufficient_quota、payment_required、access_terminated)→ noAccess;
184
+ * 其他失败(网络、限流、上游 5xx)不下结论,抛错由调用方保留上次结果。 */
185
+ export async function probeEngineSource(port, key, model) {
186
+ const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
187
+ method: 'POST',
188
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
189
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: 'ok' }], max_tokens: 1, stream: false }),
190
+ signal: AbortSignal.timeout(60_000),
191
+ });
192
+ const text = await res.text();
193
+ if (res.ok)
194
+ return { ok: true };
195
+ return classifyProbeFailure(res.status, text);
196
+ }
197
+ export function classifyProbeFailure(status, text) {
198
+ const denied = status === 402 || status === 403 || /insufficient_quota|payment_required|permission_denied|access_terminated|subscription/i.test(text);
199
+ let msg = text;
200
+ try {
201
+ msg = String(JSON.parse(text).error?.message ?? text);
202
+ }
203
+ catch {
204
+ // 不是 JSON
205
+ }
206
+ if (denied)
207
+ return { ok: false, reason: msg.slice(0, 160) };
208
+ throw new Error(`HTTP ${status}: ${msg.slice(0, 160)}`);
209
+ }
210
+ /** 守护进程把用量写到这里,同步模型时(任何进程)据此隐藏没有订阅的来源 */
211
+ export function usageFilePath(home) {
212
+ return join(home, 'usage.json');
213
+ }
@@ -0,0 +1,90 @@
1
+ // dsh/cli.ts — 找到并调用 dsh 自己的命令行(用它的 `dsh plugin` 装 / 卸插件:带兼容性检查,会自动登记 bundles)
2
+ import { readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { VPS_ROOT } from '../context.js';
6
+ import { DshModelError } from '../errors.js';
7
+ import { L } from '../i18n.js';
8
+ import { run, runInherit, which } from '../util/exec.js';
9
+ import { exists } from '../util/fs.js';
10
+ const MAC_APPS = ['/Applications/DeepSeek Harness.app', join(homedir(), 'Applications/DeepSeek Harness.app')];
11
+ export async function locateDshCli(ctx) {
12
+ if (ctx.env.DSH_MODEL_DSH_BIN)
13
+ return ctx.env.DSH_MODEL_DSH_BIN;
14
+ if (ctx.mode === 'vps') {
15
+ try {
16
+ const env = await readFile(join(VPS_ROOT, 'state/gate.env'), 'utf8');
17
+ const bin = env.match(/^DSH_BIN=(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '');
18
+ if (bin && (await exists(bin)))
19
+ return bin;
20
+ }
21
+ catch {
22
+ // 往下找
23
+ }
24
+ }
25
+ if (ctx.platform === 'darwin') {
26
+ for (const app of MAC_APPS) {
27
+ const bin = join(app, 'Contents/Resources/runtime/cli/bin/dsh');
28
+ if (await exists(bin))
29
+ return bin;
30
+ }
31
+ }
32
+ const onPath = await which('dsh');
33
+ if (onPath)
34
+ return onPath;
35
+ throw new DshModelError('dsh_cli_not_found', L('找不到 dsh 的命令行', 'Could not find the dsh command line'), L('请确认 dsh 已安装;或设置 DSH_MODEL_DSH_BIN 指向 dsh 可执行文件', 'Make sure dsh is installed, or set DSH_MODEL_DSH_BIN to the dsh executable'));
36
+ }
37
+ function dshEnv(ctx, proxy) {
38
+ return { ...ctx.env, DSH_HOME: ctx.dshHome, ...(proxy ? { HTTPS_PROXY: proxy, HTTP_PROXY: proxy } : {}) };
39
+ }
40
+ /** vps 模式下 dsh 的文件归 dsh 用户,插件操作要以它的身份跑 */
41
+ function wrap(ctx, bin, args, proxy) {
42
+ if (ctx.runAs && ctx.isRoot) {
43
+ const keep = ['DSH_HOME', ...(proxy ? ['HTTPS_PROXY', 'HTTP_PROXY'] : [])].join(',');
44
+ return { cmd: 'sudo', args: ['-u', ctx.runAs, '-H', `--preserve-env=${keep}`, bin, ...args] };
45
+ }
46
+ return { cmd: bin, args };
47
+ }
48
+ /**
49
+ * 这个 profile 的 node_modules 当初链接的 pnpm 仓库:沿用它。
50
+ * 从终端调 dsh 和 dsh 桌面版自己用的仓库位置可能不同,不沿用就报 ERR_PNPM_UNEXPECTED_STORE(Mac 上实测)
51
+ */
52
+ async function profileStoreDir(ctx, profile) {
53
+ try {
54
+ const text = await readFile(join(ctx.dshHome, 'profiles', profile, 'node_modules', '.modules.yaml'), 'utf8');
55
+ const dir = /"?storeDir"?\s*:\s*"?([^"\n,]+)"?/.exec(text)?.[1]?.trim();
56
+ if (!dir)
57
+ return null;
58
+ // 记录的是带版本的子目录(…/store/v11),store-dir 要它的上一级
59
+ return dir.replace(/\/v\d+$/, '');
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ /** dsh plugin --profile <p> <pnpm-args...>,输出直接给用户看(pnpm 进度) */
66
+ export async function dshPlugin(ctx, profile, pnpmArgs, proxy) {
67
+ const bin = await locateDshCli(ctx);
68
+ const store = await profileStoreDir(ctx, profile);
69
+ const w = wrap(ctx, bin, ['plugin', '--profile', profile, ...pnpmArgs, ...(store ? [`--config.store-dir=${store}`] : [])], proxy);
70
+ return runInherit(w.cmd, w.args, { env: dshEnv(ctx, proxy) });
71
+ }
72
+ /** profile 的 package.json 里有没有这个依赖(有就是用户或 dsh 已经装了) */
73
+ export async function profileHasDependency(ctx, profile, name) {
74
+ try {
75
+ const pkg = JSON.parse(await readFile(join(ctx.dshHome, 'profiles', profile, 'package.json'), 'utf8'));
76
+ return pkg.dependencies?.[name] ?? null;
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ export async function dshCliVersion(ctx) {
83
+ try {
84
+ const r = await run(await locateDshCli(ctx), ['--version'], { timeoutMs: 20_000 });
85
+ return r.code === 0 ? r.stdout.trim() || null : null;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }