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.
- package/README.en.md +127 -0
- package/README.md +96 -43
- package/bin/dsh-model.js +8 -0
- package/dist/bridge/aggregate.js +91 -0
- package/dist/bridge/login.js +127 -0
- package/dist/bridge/runtime.js +136 -0
- package/dist/bridge/server.js +183 -0
- package/dist/bridge/workbuddy/_compat.js +52 -0
- package/dist/bridge/workbuddy/app-version.js +141 -0
- package/dist/bridge/workbuddy/auth.js +548 -0
- package/dist/bridge/workbuddy/catalog.js +154 -0
- package/dist/bridge/workbuddy/client-identity.js +234 -0
- package/dist/bridge/workbuddy/desktop-credential-protection.js +992 -0
- package/dist/bridge/workbuddy/loopback.js +52 -0
- package/dist/bridge/workbuddy/probe.js +153 -0
- package/dist/bridge/workbuddy/status-paths.js +49 -0
- package/dist/bridge/workbuddy/upstream.js +1180 -0
- package/dist/bridge/workbuddy/variants.js +101 -0
- package/dist/cli.js +215 -0
- package/dist/commands/bridge.js +68 -0
- package/dist/commands/connect.js +67 -0
- package/dist/commands/engine.js +122 -0
- package/dist/commands/key.js +75 -0
- package/dist/commands/login.js +113 -0
- package/dist/commands/logs.js +19 -0
- package/dist/commands/opencode.js +83 -0
- package/dist/commands/remote.js +129 -0
- package/dist/commands/self.js +116 -0
- package/dist/commands/service.js +72 -0
- package/dist/commands/setup.js +219 -0
- package/dist/commands/sources.js +196 -0
- package/dist/commands/status.js +216 -0
- package/dist/commands/uninstall.js +97 -0
- package/dist/commands/workbuddy.js +117 -0
- package/dist/context.js +86 -0
- package/dist/daemon/client.js +27 -0
- package/dist/daemon/core.js +850 -0
- package/dist/daemon/service.js +33 -0
- package/dist/daemon/stats.js +126 -0
- package/dist/daemon/usage.js +213 -0
- package/dist/dsh/cli.js +90 -0
- package/dist/dsh/connect.js +257 -0
- package/dist/dsh/credentials.js +55 -0
- package/dist/dsh/locate.js +129 -0
- package/dist/dsh/patch.js +106 -0
- package/dist/dsh/pick.js +59 -0
- package/dist/engine/auth.js +40 -0
- package/dist/engine/client.js +104 -0
- package/dist/engine/compat.js +94 -0
- package/dist/engine/config.js +47 -0
- package/dist/engine/install.js +118 -0
- package/dist/engine/mgmt.js +99 -0
- package/dist/engine/sourcemap.js +48 -0
- package/dist/errors.js +14 -0
- package/dist/i18n.js +39 -0
- package/dist/integrations/dshplugin.js +89 -0
- package/dist/integrations/opencode.js +101 -0
- package/dist/integrations/workbuddy.js +114 -0
- package/dist/keys.js +54 -0
- package/dist/ops.js +178 -0
- package/dist/remote/public.js +109 -0
- package/dist/secrets.js +13 -0
- package/dist/service/index.js +48 -0
- package/dist/service/launchd.js +89 -0
- package/dist/service/systemd.js +83 -0
- package/dist/sources.js +36 -0
- package/dist/state.js +90 -0
- package/dist/upstreams.js +23 -0
- package/dist/util/diff.js +30 -0
- package/dist/util/exec.js +42 -0
- package/dist/util/fs.js +107 -0
- package/dist/util/output.js +50 -0
- package/dist/util/pkg.js +13 -0
- package/dist/util/port.js +16 -0
- package/dist/util/prompt.js +40 -0
- package/dist/util/proxy.js +73 -0
- package/dist/util/redact.js +7 -0
- package/engine-manifest.json +28 -0
- package/package.json +77 -7
- package/plugin/client.js +688 -0
- package/plugin/cordis.patch.yml +4 -0
- package/plugin/index.js +231 -0
- package/docs/DESIGN.md +0 -610
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// commands/setup.ts — 一条命令:引擎(统一端点)+ OpenCode Zen + WorkBuddy bridge → 全部接进 dsh。可重复执行
|
|
2
|
+
//
|
|
3
|
+
// 设计 §1.3:所有模型都从引擎 127.0.0.1:8317/v1 出去,dsh 只认 dsh-model 一个 provider。
|
|
4
|
+
import { requireRootInVps } from '../context.js';
|
|
5
|
+
import { locateDsh } from '../dsh/locate.js';
|
|
6
|
+
import { listModels, unauthStatus, waitHealthy } from '../engine/client.js';
|
|
7
|
+
import { activateVersion, currentVersion, fetchUpstreamAsset, installVersion, loadManifest, platformTarget } from '../engine/install.js';
|
|
8
|
+
import { DshModelError, isDshModelError } from '../errors.js';
|
|
9
|
+
import { L } from '../i18n.js';
|
|
10
|
+
import { migrateDshOpencode } from '../integrations/opencode.js';
|
|
11
|
+
import { enableWorkbuddy, removeWorkbuddyPlugin, userPluginInstalled } from '../integrations/workbuddy.js';
|
|
12
|
+
import { ensureDshKey, saveKeys } from '../keys.js';
|
|
13
|
+
import { applyEngineConfig, loadAll, saveAll, syncAll } from '../ops.js';
|
|
14
|
+
import { serviceFor } from '../service/index.js';
|
|
15
|
+
import { withLock } from '../state.js';
|
|
16
|
+
import { ensureDir } from '../util/fs.js';
|
|
17
|
+
import { bold, info, next, ok, skip, warn } from '../util/output.js';
|
|
18
|
+
import { findFreePort, isPortFree } from '../util/port.js';
|
|
19
|
+
import { detectProxy, normalizeProxyUrl, redactProxy } from '../util/proxy.js';
|
|
20
|
+
import { askOpencodeKey, configureOpencode } from './opencode.js';
|
|
21
|
+
import { opencodeConfigured } from '../integrations/opencode.js';
|
|
22
|
+
import { ensureDaemon } from '../daemon/service.js';
|
|
23
|
+
import { installDshPlugin } from '../integrations/dshplugin.js';
|
|
24
|
+
import { enableCaddy } from './remote.js';
|
|
25
|
+
import { DEFAULT_PUBLIC_PORT, publicHost } from '../remote/public.js';
|
|
26
|
+
export async function setup(ctx, opts) {
|
|
27
|
+
requireRootInVps(ctx);
|
|
28
|
+
// 要问的先问完再拿锁:等你输入时不占着锁,dsh 管理页照常能用(实测:卡在问 key 时页面新增 key 报"另一个命令正在运行")
|
|
29
|
+
const opencodeKey = opts.skipOpencode || (await opencodeConfigured(ctx)) ? undefined : await askOpencodeKey();
|
|
30
|
+
return withLock(ctx, async () => {
|
|
31
|
+
const all = await loadAll(ctx);
|
|
32
|
+
await ensureDir(ctx.paths.home, { owner: ctx.owner });
|
|
33
|
+
resolveProxy(all, opts, await detectProxy(ctx.env, ctx.platform));
|
|
34
|
+
if (opts.profile)
|
|
35
|
+
all.config.dsh.profile = opts.profile;
|
|
36
|
+
const loc = await locateDsh(ctx, all.config.dsh.profile);
|
|
37
|
+
all.config.dsh.profile = loc.profile;
|
|
38
|
+
ok(L(`dsh ${loc.version ?? ''}(profile:${loc.profile})`, `dsh ${loc.version ?? ''} (profile: ${loc.profile})`));
|
|
39
|
+
await saveAll(ctx, all);
|
|
40
|
+
const failures = [];
|
|
41
|
+
const step = async (name, fn) => {
|
|
42
|
+
try {
|
|
43
|
+
await fn();
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (!isDshModelError(error))
|
|
47
|
+
throw error;
|
|
48
|
+
failures.push(name);
|
|
49
|
+
warn(`${error.message}${error.hint ? `\n ${error.hint}` : ''}`);
|
|
50
|
+
}
|
|
51
|
+
await saveAll(ctx, all);
|
|
52
|
+
};
|
|
53
|
+
// v0.2.0 的做法(dsh 内置 opencode 路由、dsh-workbuddy-connect 插件)迁到统一端点
|
|
54
|
+
await step('migrate', async () => {
|
|
55
|
+
if (await migrateDshOpencode(ctx, all))
|
|
56
|
+
ok(L('已把 OpenCode 从 dsh 内置路由迁到统一端点', 'Moved OpenCode from the dsh built-in route to the unified endpoint'));
|
|
57
|
+
if (await removeWorkbuddyPlugin(ctx, all))
|
|
58
|
+
ok(L('已移除上一版安装的 dsh-workbuddy-connect 插件,改用 dsh-model 自己的 bridge', 'Removed the dsh-workbuddy-connect plugin installed by the previous version; using dsh-model\'s own bridge'));
|
|
59
|
+
});
|
|
60
|
+
info('');
|
|
61
|
+
info(bold(L('统一端点(引擎)', 'Unified endpoint (engine)')));
|
|
62
|
+
let engineRunning = false;
|
|
63
|
+
await step('engine', async () => {
|
|
64
|
+
engineRunning = await ensureEngine(ctx, all, { port: opts.port });
|
|
65
|
+
});
|
|
66
|
+
if (failures.includes('engine')) {
|
|
67
|
+
warn(L('引擎没装好,后面的步骤都依赖它,先停在这里', 'The engine is not ready and everything else depends on it; stopping here'));
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
info('');
|
|
71
|
+
info(bold(L('守护进程', 'Daemon')));
|
|
72
|
+
await step('daemon', () => ensureDaemon(ctx, all));
|
|
73
|
+
info('');
|
|
74
|
+
info(bold('OpenCode Zen'));
|
|
75
|
+
if (opts.skipOpencode)
|
|
76
|
+
skip(L('已跳过(--skip-opencode)', 'Skipped (--skip-opencode)'));
|
|
77
|
+
else
|
|
78
|
+
await step('opencode', () => configureOpencode(ctx, all, opencodeKey !== undefined ? { key: opencodeKey } : {}));
|
|
79
|
+
info('');
|
|
80
|
+
info(bold('WorkBuddy'));
|
|
81
|
+
if (opts.skipWorkbuddy)
|
|
82
|
+
skip(L('已跳过(--skip-workbuddy)', 'Skipped (--skip-workbuddy)'));
|
|
83
|
+
else {
|
|
84
|
+
await step('workbuddy', () => enableWorkbuddy(ctx, all));
|
|
85
|
+
if (await userPluginInstalled(ctx, all))
|
|
86
|
+
warn(L('你自己装了 dsh-workbuddy-connect 插件:dsh 里会有两组 WorkBuddy 模型。不需要的话可在 dsh 的插件页移除它', 'You installed the dsh-workbuddy-connect plugin yourself: dsh will show two WorkBuddy groups. Remove it from the dsh Plugins page if you do not need it'));
|
|
87
|
+
}
|
|
88
|
+
info('');
|
|
89
|
+
info(bold(L('dsh 插件页', 'dsh plugin page')));
|
|
90
|
+
if (opts.skipDshPlugin)
|
|
91
|
+
skip(L('已跳过(--skip-dsh-plugin)', 'Skipped (--skip-dsh-plugin)'));
|
|
92
|
+
else
|
|
93
|
+
await step('dsh-plugin', () => installDshPlugin(ctx, all));
|
|
94
|
+
// vps:自动在 dsh 的域名上开对外端点(没有域名就用 IP),自己的其他设备直接能用
|
|
95
|
+
if (ctx.mode === 'vps') {
|
|
96
|
+
info('');
|
|
97
|
+
info(bold(L('对外端点', 'Public endpoint')));
|
|
98
|
+
if (all.state.remote?.mode && all.state.remote.mode !== 'off' && all.state.remote.mode !== 'caddy')
|
|
99
|
+
skip(L(`已用 ${all.state.remote.mode} 方式开放,保持不变`, `Already exposed via ${all.state.remote.mode}; unchanged`));
|
|
100
|
+
else
|
|
101
|
+
await step('public-endpoint', async () => {
|
|
102
|
+
const host = await publicHost(all.config.remote.domain);
|
|
103
|
+
const port = all.config.remote.publicPort ?? DEFAULT_PUBLIC_PORT;
|
|
104
|
+
await enableCaddy(ctx, all, host, port);
|
|
105
|
+
all.config.remote.domain = host;
|
|
106
|
+
all.config.remote.publicPort = port;
|
|
107
|
+
ok(`https://${host}:${port}/v1`);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
info('');
|
|
111
|
+
info(bold(L('同步到 dsh', 'Sync to dsh')));
|
|
112
|
+
let ids = [];
|
|
113
|
+
if (engineRunning) {
|
|
114
|
+
await step('sync', async () => {
|
|
115
|
+
ids = await syncAll(ctx, all);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
await applyEngineConfig(ctx, all);
|
|
120
|
+
skip(L('引擎没在运行,跳过同步到 dsh(启动后执行 dsh-model models sync)', 'Engine not running; skipped syncing to dsh (run dsh-model models sync once it is up)'));
|
|
121
|
+
}
|
|
122
|
+
const groups = new Map();
|
|
123
|
+
for (const id of ids) {
|
|
124
|
+
const g = id.includes('/') ? id.split('/')[0] : L('订阅', 'subscription');
|
|
125
|
+
groups.set(g, (groups.get(g) ?? 0) + 1);
|
|
126
|
+
}
|
|
127
|
+
if (groups.size)
|
|
128
|
+
info(` ${[...groups].map(([g, n]) => `${g} ${n}`).join(' · ')}`);
|
|
129
|
+
info('');
|
|
130
|
+
if (failures.length)
|
|
131
|
+
warn(L(`有 ${failures.length} 项没完成:${failures.join('、')}。修好后重新执行 dsh-model setup 即可`, `${failures.length} item(s) not done: ${failures.join(', ')}. Fix them and re-run dsh-model setup`));
|
|
132
|
+
const base = all.state.remote?.mode === 'caddy' && all.state.remote.publicUrl ? all.state.remote.publicUrl : `http://127.0.0.1:${all.config.port}/v1`;
|
|
133
|
+
next(L(`在 dsh 的模型列表里选 dsh-model 下的模型。其他软件:Base URL ${base},key 用 dsh-model key add <名称> 领取`, `Pick a dsh-model model in dsh. Other software: Base URL ${base}, get a key with dsh-model key add <name>`));
|
|
134
|
+
info(L(' 订阅上游(codex 等):dsh-model login <上游>', ' Subscription upstreams (codex etc.): dsh-model login <upstream>'));
|
|
135
|
+
return failures.length ? 1 : 0;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function resolveProxy(all, opts, found) {
|
|
139
|
+
if (opts.proxy !== undefined) {
|
|
140
|
+
all.config.proxy = opts.proxy === 'none' ? null : normalizeProxyUrl(opts.proxy);
|
|
141
|
+
}
|
|
142
|
+
else if (all.config.proxy === undefined) {
|
|
143
|
+
all.config.proxy = found?.url ?? null;
|
|
144
|
+
if (found)
|
|
145
|
+
ok(L(`检测到代理 ${redactProxy(found.url)}(来自 ${found.source})`, `Detected proxy ${redactProxy(found.url)} (from ${found.source})`));
|
|
146
|
+
}
|
|
147
|
+
if (all.config.proxy)
|
|
148
|
+
skip(L(`出站代理:${redactProxy(all.config.proxy)}(改用 --proxy <地址> 或 --proxy none)`, `Outbound proxy: ${redactProxy(all.config.proxy)} (change with --proxy <url> or --proxy none)`));
|
|
149
|
+
}
|
|
150
|
+
/** 订阅引擎:下载校验 → key → 端口 → engine.yaml → 服务 → 鉴权自检 → 同步模型到 dsh。setup --engine 与首次 login 共用 */
|
|
151
|
+
/** 返回引擎是否在运行(DSH_MODEL_SERVICE=none 时可能没跑) */
|
|
152
|
+
export async function ensureEngine(ctx, all, opts = {}) {
|
|
153
|
+
const { config, state, keys } = all;
|
|
154
|
+
if (config.proxy === undefined) {
|
|
155
|
+
const found = await detectProxy(ctx.env, ctx.platform);
|
|
156
|
+
config.proxy = found?.url ?? null;
|
|
157
|
+
}
|
|
158
|
+
await ensureDir(ctx.paths.auth, { owner: ctx.owner });
|
|
159
|
+
const manifest = await loadManifest();
|
|
160
|
+
const target = platformTarget();
|
|
161
|
+
const version = config.engine.version ?? manifest.version;
|
|
162
|
+
const asset = version === manifest.version ? manifest.assets[target] : await fetchUpstreamAsset(manifest, version, target);
|
|
163
|
+
if (!asset)
|
|
164
|
+
throw new DshModelError('unsupported_platform', L(`引擎没有 ${target} 的发布包`, `No engine build for ${target}`));
|
|
165
|
+
const installed = await installVersion(ctx, version, asset);
|
|
166
|
+
if ((await currentVersion(ctx)) !== version)
|
|
167
|
+
await activateVersion(ctx, version);
|
|
168
|
+
config.engine.version = version;
|
|
169
|
+
if (!state.engine.versions.includes(version))
|
|
170
|
+
state.engine.versions.push(version);
|
|
171
|
+
(installed ? ok : skip)(L(`引擎 CLIProxyAPI v${version}${installed ? ' 已下载并校验' : ' 已安装'}`, `Engine CLIProxyAPI v${version} ${installed ? 'downloaded and verified' : 'already installed'}`));
|
|
172
|
+
const { created } = ensureDshKey(keys);
|
|
173
|
+
if (created)
|
|
174
|
+
await saveKeys(ctx, keys);
|
|
175
|
+
if (opts.port)
|
|
176
|
+
config.port = opts.port;
|
|
177
|
+
if (!(await isPortFree(config.port)) && !(await portIsOurs(config.port, keys))) {
|
|
178
|
+
const free = await findFreePort(config.port + 1);
|
|
179
|
+
warn(L(`端口 ${config.port} 被其他程序占用,改用 ${free}`, `Port ${config.port} is taken by another program; using ${free}`));
|
|
180
|
+
config.port = free;
|
|
181
|
+
}
|
|
182
|
+
await saveAll(ctx, all);
|
|
183
|
+
await applyEngineConfig(ctx, all);
|
|
184
|
+
if (ctx.serviceDisabled) {
|
|
185
|
+
skip(L('已跳过系统服务(DSH_MODEL_SERVICE=none)', 'Skipped system service (DSH_MODEL_SERVICE=none)'));
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const svc = serviceFor(ctx);
|
|
189
|
+
await svc.install();
|
|
190
|
+
state.service = { kind: svc.kind, file: svc.spec.file, label: svc.spec.label };
|
|
191
|
+
await saveAll(ctx, all);
|
|
192
|
+
if (installed)
|
|
193
|
+
await svc.restart();
|
|
194
|
+
}
|
|
195
|
+
if (!(await waitHealthy(config.port, ctx.serviceDisabled ? 1500 : 15_000))) {
|
|
196
|
+
if (ctx.serviceDisabled) {
|
|
197
|
+
warn(L('引擎未运行(服务已跳过)', 'Engine not running (service skipped)'));
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
throw new DshModelError('engine_unhealthy', L(`引擎没有在 127.0.0.1:${config.port} 上启动`, `Engine did not come up on 127.0.0.1:${config.port}`), L('查看日志:dsh-model logs', 'Check logs: dsh-model logs'));
|
|
201
|
+
}
|
|
202
|
+
const unauth = await unauthStatus(config.port);
|
|
203
|
+
if (unauth !== 401)
|
|
204
|
+
throw new DshModelError('auth_not_enforced', L(`不带 key 的请求返回了 ${unauth}(应为 401),已停止`, `Unauthenticated request returned ${unauth} (expected 401); stopping`));
|
|
205
|
+
ok(L(`引擎已在 127.0.0.1:${config.port} 运行,鉴权已生效`, `Engine running on 127.0.0.1:${config.port} with auth enforced`));
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
async function portIsOurs(port, keys) {
|
|
209
|
+
for (const k of keys.keys.filter((x) => !x.revokedAt)) {
|
|
210
|
+
try {
|
|
211
|
+
await listModels(port, k.key);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// 不是我们
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// commands/sources.ts — 来源开关与登录(CLI 版;dsh 插件页调同一套守护进程接口)
|
|
2
|
+
// dsh-model sources
|
|
3
|
+
// dsh-model source enable|disable|logout <来源> [--accept-risk]
|
|
4
|
+
// dsh-model stats
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { control } from '../daemon/client.js';
|
|
7
|
+
import { DshModelError } from '../errors.js';
|
|
8
|
+
import { L } from '../i18n.js';
|
|
9
|
+
import { bold, dim, green, info, isJsonMode, ok, printJson, red, table, warn, yellow } from '../util/output.js';
|
|
10
|
+
import { canPrompt } from '../util/prompt.js';
|
|
11
|
+
/** "2 小时 13 分后重置" */
|
|
12
|
+
function untilText(iso) {
|
|
13
|
+
if (!iso)
|
|
14
|
+
return '';
|
|
15
|
+
const ms = Date.parse(iso) - Date.now();
|
|
16
|
+
if (!(ms > 0))
|
|
17
|
+
return L('即将重置', 'resetting');
|
|
18
|
+
const m = Math.round(ms / 60000);
|
|
19
|
+
const d = Math.floor(m / 1440);
|
|
20
|
+
const h = Math.floor((m % 1440) / 60);
|
|
21
|
+
const mm = m % 60;
|
|
22
|
+
const t = d ? L(`${d} 天 ${h} 小时`, `${d}d ${h}h`) : h ? L(`${h} 小时 ${mm} 分`, `${h}h ${mm}m`) : L(`${mm} 分`, `${mm}m`);
|
|
23
|
+
return L(`${t}后重置`, `resets in ${t}`);
|
|
24
|
+
}
|
|
25
|
+
function bar(pct) {
|
|
26
|
+
const n = Math.max(0, Math.min(10, Math.round(pct / 10)));
|
|
27
|
+
const s = '█'.repeat(n) + '░'.repeat(10 - n);
|
|
28
|
+
return pct >= 90 ? red(s) : pct >= 70 ? yellow(s) : green(s);
|
|
29
|
+
}
|
|
30
|
+
/** 每个来源的用量行(设计 §14.6) */
|
|
31
|
+
export function usageLines(s) {
|
|
32
|
+
const u = s.usage;
|
|
33
|
+
if (!u)
|
|
34
|
+
return [];
|
|
35
|
+
if (u.noAccess)
|
|
36
|
+
return [yellow(L(` 当前账号没有可用订阅(调用会被拒绝、不扣费),模型已在 dsh 中隐藏${s.subscribeUrl ? `;开通:${s.subscribeUrl}` : ''}`, ` No usable subscription on this account (calls refused, no charge); its models are hidden in dsh${s.subscribeUrl ? `; subscribe: ${s.subscribeUrl}` : ''}`))];
|
|
37
|
+
if (u.unsupported)
|
|
38
|
+
return [dim(L(' 已实测可以调用;用量暂不支持查询', ' tested OK; usage not available yet'))];
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const w of u.windows) {
|
|
41
|
+
const pct = w.usedPercent;
|
|
42
|
+
const amount = w.used !== undefined && w.limit !== undefined ? ` ${w.used}/${w.limit}` : '';
|
|
43
|
+
out.push(` ${w.label.padEnd(8)} ${pct !== undefined ? `${bar(pct)} ${Math.round(pct)}%` : ''}${amount} ${dim(untilText(w.resetAt))}`);
|
|
44
|
+
}
|
|
45
|
+
if (u.credits) {
|
|
46
|
+
const c = u.credits;
|
|
47
|
+
out.push(` ${L('积分', 'credits')} ${c.unlimited ? L('不限量', 'unlimited') : L(`剩余 ${c.remaining.toLocaleString()}${c.total ? ` / ${c.total.toLocaleString()}` : ''}`, `${c.remaining.toLocaleString()} left${c.total ? ` of ${c.total.toLocaleString()}` : ''}`)}`);
|
|
48
|
+
}
|
|
49
|
+
if (u.plan)
|
|
50
|
+
out.unshift(dim(` ${L('套餐', 'plan')}:${u.plan}`));
|
|
51
|
+
if (u.error)
|
|
52
|
+
out.push(yellow(L(` 用量暂不可用:${u.error}`, ` usage unavailable: ${u.error}`)));
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
export function pricingTag(s) {
|
|
56
|
+
const t = s.pricing?.tier;
|
|
57
|
+
return t === 'free' ? green(L('免费', 'free')) : t === 'limited' ? yellow(L('免费·额度少', 'free·limited')) : t === 'paid' ? red(L('需付费', 'paid')) : '-';
|
|
58
|
+
}
|
|
59
|
+
export async function sources(ctx, opts = {}) {
|
|
60
|
+
// 高风险来源(Claude、Antigravity)默认不列出;已登录的照常显示,方便退出登录
|
|
61
|
+
const list = (await control(ctx, 'GET', '/sources?refresh=1')).filter((s) => opts.all || !s.risky || s.loggedIn);
|
|
62
|
+
if (isJsonMode()) {
|
|
63
|
+
printJson(list);
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
info(table([
|
|
67
|
+
[L('开关', 'On'), L('来源', 'Source'), L('费用', 'Cost'), L('状态', 'State'), L('账号', 'Account'), L('模型', 'Models')],
|
|
68
|
+
...list.map((s) => [
|
|
69
|
+
s.enabled ? green('●') : dim('○'),
|
|
70
|
+
s.id,
|
|
71
|
+
pricingTag(s),
|
|
72
|
+
s.loggedIn ? (s.usage?.noAccess ? yellow(L('已登录·无订阅', 'signed in · no plan')) : s.enabled ? green(L('已接入', 'connected')) : yellow(L('已登录·关闭', 'signed in · off'))) : dim(L('未登录', 'signed out')),
|
|
73
|
+
s.account ?? (s.detail ? dim(s.detail.slice(0, 40)) : '-'),
|
|
74
|
+
s.models ? String(s.models) : '-',
|
|
75
|
+
]),
|
|
76
|
+
]));
|
|
77
|
+
const unpaid = list.filter((s) => !s.loggedIn && s.pricing);
|
|
78
|
+
if (unpaid.length) {
|
|
79
|
+
info(bold(L('\n费用说明', '\nCost')));
|
|
80
|
+
for (const s of unpaid)
|
|
81
|
+
info(` ${s.id.padEnd(13)}${L(s.pricing.zh, s.pricing.en)}${s.subscribeUrl ? dim(` ${s.subscribeUrl}`) : ''}`);
|
|
82
|
+
}
|
|
83
|
+
const withUsage = list.filter((s) => s.usage);
|
|
84
|
+
if (withUsage.length) {
|
|
85
|
+
info(bold(L('\n订阅用量', '\nSubscription usage')));
|
|
86
|
+
for (const s of withUsage) {
|
|
87
|
+
info(` ${s.label}${s.account ? dim(` ${s.account}`) : ''}`);
|
|
88
|
+
for (const line of usageLines(s))
|
|
89
|
+
info(line);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
info(dim(L('\n打开:dsh-model source enable <来源> 关闭:dsh-model source disable <来源> 高风险来源(Claude、Antigravity):dsh-model sources --all', '\nTurn on: dsh-model source enable <source> off: dsh-model source disable <source> High-risk sources (Claude, Antigravity): dsh-model sources --all')));
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
export async function source(ctx, sub, id, opts) {
|
|
96
|
+
if (!sub || !id || !['enable', 'disable', 'logout', 'on', 'off'].includes(sub)) {
|
|
97
|
+
throw new DshModelError('usage', L('用法:dsh-model source enable|disable|logout <来源>', 'Usage: dsh-model source enable|disable|logout <source>'));
|
|
98
|
+
}
|
|
99
|
+
if (sub === 'disable' || sub === 'off') {
|
|
100
|
+
await control(ctx, 'POST', `/sources/${id}/disable`);
|
|
101
|
+
ok(L(`${id} 已关闭(登录保留)`, `${id} turned off (sign-in kept)`));
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
if (sub === 'logout') {
|
|
105
|
+
await control(ctx, 'POST', `/sources/${id}/logout`);
|
|
106
|
+
ok(L(`${id} 已退出登录`, `${id} signed out`));
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
return enableInteractive(ctx, id, opts);
|
|
110
|
+
}
|
|
111
|
+
/** 打开一个来源;需要登录就在终端里完成(打印链接 / 码,回调式等你贴回地址) */
|
|
112
|
+
export async function enableInteractive(ctx, id, opts = {}) {
|
|
113
|
+
const r = await control(ctx, 'POST', `/sources/${id}/enable`, { acceptRisk: Boolean(opts.acceptRisk) });
|
|
114
|
+
if (r.riskNotice) {
|
|
115
|
+
info(r.riskNotice);
|
|
116
|
+
return 2;
|
|
117
|
+
}
|
|
118
|
+
if (r.enabled) {
|
|
119
|
+
ok(L(`${id} 已接入`, `${id} connected`));
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
if (!r.login)
|
|
123
|
+
throw new DshModelError('enable_failed', L(`${id} 没能打开`, `Could not turn on ${id}`));
|
|
124
|
+
return runLogin(ctx, id, r.login);
|
|
125
|
+
}
|
|
126
|
+
async function runLogin(ctx, id, s) {
|
|
127
|
+
info('');
|
|
128
|
+
info(bold(L('在任意设备的浏览器里打开下面的链接并授权:', 'Open this link in a browser on any device and approve:')));
|
|
129
|
+
info(`\n ${s.url}\n`);
|
|
130
|
+
if (s.userCode)
|
|
131
|
+
info(L(` 授权页要求输入的码:${bold(s.userCode)}\n`, ` Code to enter on that page: ${bold(s.userCode)}\n`));
|
|
132
|
+
let rl;
|
|
133
|
+
if (s.needsPaste) {
|
|
134
|
+
info(L('授权后浏览器会跳到一个打不开的 localhost 地址——把地址栏里的完整地址粘贴到这里,回车。', 'After approving, the browser lands on a localhost address that will not load — paste that full address here and press Enter.'));
|
|
135
|
+
if (canPrompt()) {
|
|
136
|
+
rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
137
|
+
rl.question(L('跳转地址:', 'Redirected address: '), (answer) => {
|
|
138
|
+
const url = answer.trim();
|
|
139
|
+
if (url) {
|
|
140
|
+
void control(ctx, 'POST', `/login/${s.id}/callback`, { redirectUrl: url }).catch((e) => warn(String(e.message)));
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
info(L('等待授权中(Ctrl+C 取消)…', 'Waiting for approval (Ctrl+C to cancel)…'));
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
for (;;) {
|
|
150
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
151
|
+
const st = await control(ctx, 'GET', `/login/${s.id}`);
|
|
152
|
+
if (st.status === 'ok') {
|
|
153
|
+
ok(L(`${id} 已登录并接入`, `${id} signed in and connected`));
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
if (st.status === 'error' || st.status === 'cancelled') {
|
|
157
|
+
warn(L(`登录没有完成:${st.error ?? st.status}`, `Login did not complete: ${st.error ?? st.status}`));
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
rl?.close();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const pct = (w) => (w.successRate == null ? '-' : `${Math.round(w.successRate * 100)}%`);
|
|
167
|
+
const ms = (w) => (w.avgLatencyMs == null ? '-' : w.avgLatencyMs >= 1000 ? `${(w.avgLatencyMs / 1000).toFixed(1)}s` : `${w.avgLatencyMs}ms`);
|
|
168
|
+
const tps = (w) => (w.tokensPerSec == null ? '-' : `${w.tokensPerSec}`);
|
|
169
|
+
const rate = (w) => {
|
|
170
|
+
const p = w.successRate;
|
|
171
|
+
const s = pct(w);
|
|
172
|
+
return p == null ? s : p >= 0.98 ? green(s) : p >= 0.9 ? yellow(s) : red(s);
|
|
173
|
+
};
|
|
174
|
+
export async function stats(ctx) {
|
|
175
|
+
const snap = await control(ctx, 'GET', '/stats');
|
|
176
|
+
if (isJsonMode()) {
|
|
177
|
+
printJson(snap);
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
const rows = (title, m) => {
|
|
181
|
+
const entries = Object.entries(m).sort((a, b) => b[1].d1.requests - a[1].d1.requests);
|
|
182
|
+
if (!entries.length)
|
|
183
|
+
return;
|
|
184
|
+
info(bold(`\n${title}`));
|
|
185
|
+
info(table([
|
|
186
|
+
['', L('请求(1h/24h)', 'Req (1h/24h)'), L('成功率', 'Success'), L('平均延迟', 'Avg latency'), 'tokens/s', L('最近失败', 'Last failure')],
|
|
187
|
+
...entries.map(([k, v]) => [k, `${v.h1.requests}/${v.d1.requests}`, rate(v.d1), ms(v.d1), tps(v.d1), v.d1.lastFailureAt ? v.d1.lastFailureAt.slice(5, 16).replace('T', ' ') : '-']),
|
|
188
|
+
]));
|
|
189
|
+
};
|
|
190
|
+
rows(L('按 key', 'By key'), snap.byKey);
|
|
191
|
+
rows(L('按来源', 'By source'), snap.bySource);
|
|
192
|
+
rows(L('按模型', 'By model'), snap.byModel);
|
|
193
|
+
if (!Object.keys(snap.byKey).length)
|
|
194
|
+
info(dim(L('还没有请求记录', 'No requests recorded yet')));
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// commands/status.ts — status(一屏总览)与 doctor(逐项检查,--e2e 实测每个模型)
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { availableVariants, bridgeConfigPath, loadBridgeConfig, loadCatalogs } from '../bridge/runtime.js';
|
|
4
|
+
import { anyProviderPresent } from '../dsh/connect.js';
|
|
5
|
+
import { KEY_REF, readRef } from '../dsh/credentials.js';
|
|
6
|
+
import { listAuthFiles, summarize } from '../engine/auth.js';
|
|
7
|
+
import { healthy, listModels, probeStream, probeToolCall, unauthStatus } from '../engine/client.js';
|
|
8
|
+
import { currentBinary, currentVersion } from '../engine/install.js';
|
|
9
|
+
import { L } from '../i18n.js';
|
|
10
|
+
import { opencodeConfigured } from '../integrations/opencode.js';
|
|
11
|
+
import { bridgeHealthy, userPluginInstalled } from '../integrations/workbuddy.js';
|
|
12
|
+
import { findActive, DSH_KEY_NAME } from '../keys.js';
|
|
13
|
+
import { loadAll } from '../ops.js';
|
|
14
|
+
import { serviceFor } from '../service/index.js';
|
|
15
|
+
import { UPSTREAMS } from '../upstreams.js';
|
|
16
|
+
import { exists, fileMode, readText, sha256 } from '../util/fs.js';
|
|
17
|
+
import { dim, green, info, isJsonMode, printJson, red, table, yellow } from '../util/output.js';
|
|
18
|
+
import { proxyReachable, redactProxy } from '../util/proxy.js';
|
|
19
|
+
/** 模型 id 的分组:opencode/… workbuddy/… 其余算订阅 */
|
|
20
|
+
function groupCounts(models) {
|
|
21
|
+
const g = new Map();
|
|
22
|
+
for (const m of models) {
|
|
23
|
+
const k = m.id.includes('/') ? m.id.split('/')[0] : 'subscription';
|
|
24
|
+
g.set(k, (g.get(k) ?? 0) + 1);
|
|
25
|
+
}
|
|
26
|
+
return g;
|
|
27
|
+
}
|
|
28
|
+
export async function status(ctx) {
|
|
29
|
+
const all = await loadAll(ctx);
|
|
30
|
+
const key = findActive(all.keys, DSH_KEY_NAME)?.key;
|
|
31
|
+
const engineInstalled = await exists(currentBinary(ctx));
|
|
32
|
+
const version = engineInstalled ? await currentVersion(ctx) : null;
|
|
33
|
+
const svc = engineInstalled && !ctx.serviceDisabled ? await serviceFor(ctx).status() : null;
|
|
34
|
+
const up = engineInstalled ? await healthy(all.config.port) : false;
|
|
35
|
+
const models = up && key ? await listModels(all.config.port, key).catch(() => []) : [];
|
|
36
|
+
const groups = groupCounts(models);
|
|
37
|
+
const { byUpstream } = summarize(await listAuthFiles(ctx));
|
|
38
|
+
const dshOk = await anyProviderPresent(all.state).catch(() => false);
|
|
39
|
+
const oc = await opencodeConfigured(ctx);
|
|
40
|
+
const bridgeCfg = await loadBridgeConfig(ctx.paths.home);
|
|
41
|
+
const bridgeUp = bridgeCfg ? await bridgeHealthy(bridgeCfg.port, bridgeCfg.secret) : false;
|
|
42
|
+
const cats = await loadCatalogs(ctx.paths.home);
|
|
43
|
+
const apps = await availableVariants(ctx.paths.home);
|
|
44
|
+
const logged = UPSTREAMS.filter((u) => byUpstream[u.id]?.length).map((u) => u.id);
|
|
45
|
+
const data = {
|
|
46
|
+
mode: ctx.mode,
|
|
47
|
+
home: ctx.paths.home,
|
|
48
|
+
endpoint: { version, running: up, port: all.config.port, service: svc, models: Object.fromEntries(groups) },
|
|
49
|
+
opencode: { configured: oc, models: groups.get('opencode') ?? 0 },
|
|
50
|
+
workbuddy: { apps: apps.map((v) => v.displayName), bridge: bridgeCfg ? { port: bridgeCfg.port, running: bridgeUp } : null, catalogs: cats.map((c) => ({ label: c.label, signedIn: c.signedIn, models: c.models.length, error: c.error })) },
|
|
51
|
+
subscriptions: logged,
|
|
52
|
+
dsh: all.state.dsh ? { profile: all.state.dsh.profile, providerPresent: dshOk } : null,
|
|
53
|
+
remote: all.config.remote.mode,
|
|
54
|
+
proxy: all.config.proxy ? redactProxy(all.config.proxy) : null,
|
|
55
|
+
keys: all.keys.keys.filter((k) => !k.revokedAt).map((k) => k.name),
|
|
56
|
+
};
|
|
57
|
+
if (isJsonMode()) {
|
|
58
|
+
printJson(data);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
const yes = (b, t, f) => (b ? green(t) : red(f));
|
|
62
|
+
const wbLine = !apps.length
|
|
63
|
+
? dim(L('未登录(dsh-model workbuddy login)', 'not signed in (dsh-model workbuddy login)'))
|
|
64
|
+
: !bridgeCfg
|
|
65
|
+
? yellow(L('未启用(dsh-model workbuddy enable)', 'not enabled (dsh-model workbuddy enable)'))
|
|
66
|
+
: !bridgeUp
|
|
67
|
+
? red(L(`bridge 没在运行(127.0.0.1:${bridgeCfg.port},dsh-model repair)`, `bridge not running (127.0.0.1:${bridgeCfg.port}, dsh-model repair)`))
|
|
68
|
+
: cats.map((c) => (c.signedIn ? green(`${c.label} ${c.models.length}`) : yellow(L(`${c.label} 未登录`, `${c.label} signed out`)))).join(' · ') || yellow(L('目录还没读到', 'catalog not read yet'));
|
|
69
|
+
info(table([
|
|
70
|
+
[L('统一端点', 'Endpoint'), engineInstalled ? yes(up, `127.0.0.1:${all.config.port}`, L('未运行(dsh-model repair)', 'not running (dsh-model repair)')) + dim(` v${version ?? '-'}${svc ? ` · ${svc.detail ?? '-'}` : ''}`) : red(L('未安装(dsh-model setup)', 'not installed (dsh-model setup)'))],
|
|
71
|
+
[L('模型', 'Models'), models.length ? [...groups].map(([g, n]) => `${g === 'subscription' ? L('订阅', 'subscription') : g} ${n}`).join(' · ') : yellow('0')],
|
|
72
|
+
['OpenCode Zen', oc ? green(L(`已配置 · ${groups.get('opencode') ?? 0} 个模型`, `configured · ${groups.get('opencode') ?? 0} models`)) : yellow(L('未配置(dsh-model opencode key)', 'not configured (dsh-model opencode key)'))],
|
|
73
|
+
['WorkBuddy', wbLine],
|
|
74
|
+
[L('订阅', 'Subscriptions'), logged.length ? logged.join(', ') : dim(L('无(dsh-model login <上游>)', 'none (dsh-model login <upstream>)'))],
|
|
75
|
+
['dsh', data.dsh ? yes(dshOk, `${data.dsh.profile} ✓`, L(`${data.dsh.profile}:provider 不在了(dsh-model repair)`, `${data.dsh.profile}: provider missing (dsh-model repair)`)) : yellow(L('未接入', 'not connected'))],
|
|
76
|
+
[L('远程访问', 'Remote'), all.config.remote.mode],
|
|
77
|
+
[L('出站代理', 'Proxy'), data.proxy ?? L('不使用', 'none')],
|
|
78
|
+
['Keys', data.keys.join(', ') || '-'],
|
|
79
|
+
]));
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
export async function doctor(ctx, opts) {
|
|
83
|
+
const all = await loadAll(ctx);
|
|
84
|
+
const checks = [];
|
|
85
|
+
const add = (id, level, message) => checks.push({ id, level, message });
|
|
86
|
+
const key = findActive(all.keys, DSH_KEY_NAME)?.key;
|
|
87
|
+
const homeMode = await fileMode(ctx.paths.home);
|
|
88
|
+
if (homeMode == null)
|
|
89
|
+
add('home', 'fail', L(`${ctx.paths.home} 不存在(先 setup)`, `${ctx.paths.home} missing (run setup)`));
|
|
90
|
+
else
|
|
91
|
+
add('home', homeMode & 0o077 ? 'warn' : 'ok', L(`home 权限 ${homeMode.toString(8)}`, `home mode ${homeMode.toString(8)}`));
|
|
92
|
+
for (const [id, file] of [['keys', ctx.paths.keys], ['engine-yaml', ctx.paths.engineYaml], ['secrets', join(ctx.paths.home, 'secrets.json')], ['bridge-config', bridgeConfigPath(ctx.paths.home)]]) {
|
|
93
|
+
const m = await fileMode(file);
|
|
94
|
+
if (m != null)
|
|
95
|
+
add(id, m & 0o077 ? 'warn' : 'ok', `${file.split('/').pop()} ${m.toString(8)}`);
|
|
96
|
+
}
|
|
97
|
+
const authFiles = await listAuthFiles(ctx);
|
|
98
|
+
const loose = [];
|
|
99
|
+
for (const f of authFiles)
|
|
100
|
+
if (((await fileMode(join(ctx.paths.auth, f))) ?? 0) & 0o077)
|
|
101
|
+
loose.push(f);
|
|
102
|
+
if (authFiles.length)
|
|
103
|
+
add('auth-perms', loose.length ? 'warn' : 'ok', loose.length ? L(`凭据文件权限过宽:${loose.join(', ')}`, `Credential files too permissive: ${loose.join(', ')}`) : L(`订阅凭据 ${authFiles.length} 个,权限正常`, `${authFiles.length} subscription credentials, permissions OK`));
|
|
104
|
+
const { byUpstream, other } = summarize(authFiles);
|
|
105
|
+
const multi = Object.entries(byUpstream).filter(([, f]) => f.length > 1);
|
|
106
|
+
if (multi.length)
|
|
107
|
+
add('single-account', 'warn', L(`同一上游有多个账号:${multi.map(([u]) => u).join(', ')}`, `Multiple accounts for: ${multi.map(([u]) => u).join(', ')}`));
|
|
108
|
+
if (other.length)
|
|
109
|
+
add('unknown-auth', 'warn', L(`不认识的凭据文件:${other.join(', ')}`, `Unrecognized credential files: ${other.join(', ')}`));
|
|
110
|
+
// —— 统一端点 ——
|
|
111
|
+
const engineInstalled = await exists(currentBinary(ctx));
|
|
112
|
+
const version = engineInstalled ? await currentVersion(ctx) : null;
|
|
113
|
+
add('engine', engineInstalled ? (version === all.config.engine.version ? 'ok' : 'warn') : 'fail', engineInstalled ? `v${version}` : L('未安装(dsh-model setup)', 'not installed (dsh-model setup)'));
|
|
114
|
+
if (engineInstalled && !ctx.serviceDisabled) {
|
|
115
|
+
const s = await serviceFor(ctx).status();
|
|
116
|
+
add('engine-service', s.running ? 'ok' : 'fail', `${serviceFor(ctx).kind}: ${s.detail ?? '-'}`);
|
|
117
|
+
}
|
|
118
|
+
if (all.config.proxy) {
|
|
119
|
+
const reach = await proxyReachable(all.config.proxy);
|
|
120
|
+
add('proxy', reach ? 'ok' : 'fail', reach ? L(`代理 ${redactProxy(all.config.proxy)} 可连接`, `Proxy ${redactProxy(all.config.proxy)} reachable`) : L(`代理 ${redactProxy(all.config.proxy)} 连不上(代理软件没开?dsh-model setup --proxy <地址>|none)`, `Proxy ${redactProxy(all.config.proxy)} unreachable (proxy app not running? dsh-model setup --proxy <url>|none)`));
|
|
121
|
+
}
|
|
122
|
+
const up = engineInstalled ? await healthy(all.config.port) : false;
|
|
123
|
+
if (engineInstalled)
|
|
124
|
+
add('healthz', up ? 'ok' : 'fail', `127.0.0.1:${all.config.port}`);
|
|
125
|
+
if (up) {
|
|
126
|
+
const code = await unauthStatus(all.config.port);
|
|
127
|
+
add('auth-enforced', code === 401 ? 'ok' : 'fail', L(`不带 key 请求返回 ${code}`, `unauthenticated request → ${code}`));
|
|
128
|
+
}
|
|
129
|
+
let models = [];
|
|
130
|
+
if (up && key) {
|
|
131
|
+
try {
|
|
132
|
+
models = await listModels(all.config.port, key);
|
|
133
|
+
const g = groupCounts(models);
|
|
134
|
+
add('models', models.length ? 'ok' : 'warn', models.length ? [...g].map(([k, n]) => `${k} ${n}`).join(' · ') : L('0 个模型', '0 models'));
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
add('models', 'fail', String(error.message));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// —— OpenCode / WorkBuddy ——
|
|
141
|
+
const oc = await opencodeConfigured(ctx);
|
|
142
|
+
const ocModels = models.filter((m) => m.id.startsWith('opencode/')).length;
|
|
143
|
+
add('opencode', !oc ? 'warn' : up && !ocModels ? 'fail' : 'ok', !oc ? L('OpenCode Zen 未配置(dsh-model opencode key)', 'OpenCode Zen not configured (dsh-model opencode key)') : up && !ocModels ? L('已配置但引擎里没有 opencode/ 模型(dsh-model repair)', 'configured but no opencode/ models in the engine (dsh-model repair)') : L(`OpenCode Zen:${ocModels} 个模型`, `OpenCode Zen: ${ocModels} models`));
|
|
144
|
+
const apps = await availableVariants(ctx.paths.home);
|
|
145
|
+
const bridgeCfg = await loadBridgeConfig(ctx.paths.home);
|
|
146
|
+
// 守护进程(来源开关、登录、统计、WorkBuddy 转发)总要在跑;只有刻意跳过系统服务时不查
|
|
147
|
+
if (!ctx.serviceDisabled) {
|
|
148
|
+
if (!bridgeCfg)
|
|
149
|
+
add('daemon', 'fail', L('守护进程没配置(dsh-model setup)', 'Daemon not configured (dsh-model setup)'));
|
|
150
|
+
else {
|
|
151
|
+
const bUp = await bridgeHealthy(bridgeCfg.port, bridgeCfg.secret);
|
|
152
|
+
add('daemon', bUp ? 'ok' : 'fail', bUp ? L(`守护进程 127.0.0.1:${bridgeCfg.port} 运行中`, `daemon 127.0.0.1:${bridgeCfg.port} running`) : L('守护进程没在运行(dsh-model logs --bridge;dsh-model repair)', 'daemon not running (dsh-model logs --bridge; dsh-model repair)'));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (apps.length && bridgeCfg) {
|
|
156
|
+
{
|
|
157
|
+
for (const c of await loadCatalogs(ctx.paths.home)) {
|
|
158
|
+
const inEngine = models.filter((m) => m.id.startsWith(`${c.prefix}/`)).length;
|
|
159
|
+
if (!c.signedIn)
|
|
160
|
+
add(`workbuddy-${c.key}`, 'warn', L(`${c.label}:未登录${c.error ? `(${c.error})` : ''}——在 App 里登录后执行 dsh-model workbuddy refresh`, `${c.label}: signed out${c.error ? ` (${c.error})` : ''} — sign in to the app, then dsh-model workbuddy refresh`));
|
|
161
|
+
else
|
|
162
|
+
add(`workbuddy-${c.key}`, up && !inEngine ? 'fail' : 'ok', L(`${c.label}:已登录,${c.models.length} 个模型${up ? `(引擎里 ${inEngine} 个)` : ''}${c.error ? `;${c.error}` : ''}`, `${c.label}: signed in, ${c.models.length} models${up ? ` (${inEngine} in engine)` : ''}${c.error ? `; ${c.error}` : ''}`));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (await userPluginInstalled(ctx, all))
|
|
166
|
+
add('workbuddy-plugin', 'warn', L('同时装了 dsh-workbuddy-connect 插件:dsh 里会有两组 WorkBuddy 模型', 'dsh-workbuddy-connect is also installed: dsh shows two WorkBuddy groups'));
|
|
167
|
+
}
|
|
168
|
+
// —— dsh 接线 ——
|
|
169
|
+
if (all.state.dsh) {
|
|
170
|
+
const d = all.state.dsh;
|
|
171
|
+
const present = await anyProviderPresent(all.state).catch(() => false);
|
|
172
|
+
add('dsh-provider', present ? 'ok' : models.length ? 'fail' : 'warn', present ? L(`dsh(${d.profile})里有 dsh-model`, `dsh-model present in dsh (${d.profile})`) : models.length ? L('dsh 里没有 dsh-model(dsh-model repair)', 'dsh-model missing in dsh (dsh-model repair)') : L('dsh 里还没有 dsh-model:引擎里还没有模型', 'dsh-model not in dsh yet: the engine has no models'));
|
|
173
|
+
const credText = await readText(d.credFile);
|
|
174
|
+
add('dsh-key', readRef(credText) === key ? 'ok' : 'fail', readRef(credText) === key ? L(`${KEY_REF} 与 key 一致`, `${KEY_REF} matches`) : L(`${KEY_REF} 与 key 不一致(dsh-model repair)`, `${KEY_REF} does not match (dsh-model repair)`));
|
|
175
|
+
const cm = await fileMode(d.credFile);
|
|
176
|
+
if (cm != null)
|
|
177
|
+
add('dsh-cred-perms', cm === 0o600 ? 'ok' : 'fail', L(`.credentials.yaml 权限 ${cm.toString(8)}(dsh 要求 600)`, `.credentials.yaml mode ${cm.toString(8)} (dsh requires 600)`));
|
|
178
|
+
const patchText = await readText(d.patchFile);
|
|
179
|
+
if (patchText != null && d.writtenPatchSha && sha256(patchText) !== d.writtenPatchSha) {
|
|
180
|
+
add('dsh-patch-edited', 'ok', L('cordis.patch.yml 在接线后被修改过(卸载时只会移除 dsh-model 的部分)', 'cordis.patch.yml edited since connect (uninstall will remove only dsh-model parts)'));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
add('dsh-provider', 'warn', L('还没接入 dsh(dsh-model setup)', 'Not connected to dsh (dsh-model setup)'));
|
|
185
|
+
}
|
|
186
|
+
if (ctx.env[KEY_REF])
|
|
187
|
+
add('env-override', 'warn', L(`环境变量 ${KEY_REF} 会覆盖 dsh 凭据里的值`, `Env var ${KEY_REF} overrides the value in dsh credentials`));
|
|
188
|
+
if ((await exists('/etc/caddy/dsh-model.conf')) && all.config.remote.mode !== 'caddy')
|
|
189
|
+
add('caddy-orphan', 'warn', L('发现残留的 /etc/caddy/dsh-model.conf', 'Found leftover /etc/caddy/dsh-model.conf'));
|
|
190
|
+
// —— e2e:默认每组(订阅按 owned_by,opencode/workbuddy 按前缀)取一个模型 ——
|
|
191
|
+
const e2e = [];
|
|
192
|
+
if (opts.e2e && key && models.length) {
|
|
193
|
+
const groupOf = (m) => (m.id.includes('/') ? m.id.split('/')[0] : (m.owned_by ?? m.id));
|
|
194
|
+
const pick = opts.model ? models.filter((m) => m.id === opts.model) : opts.all ? models : [...new Map(models.map((m) => [groupOf(m), m])).values()];
|
|
195
|
+
for (const m of pick) {
|
|
196
|
+
const s = await probeStream(all.config.port, key, m.id);
|
|
197
|
+
const t = await probeToolCall(all.config.port, key, m.id);
|
|
198
|
+
e2e.push({ model: m.id, stream: s.detail, tool: t.detail, ok: s.ok && t.ok });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const failed = checks.some((c) => c.level === 'fail') || e2e.some((r) => !r.ok);
|
|
202
|
+
if (isJsonMode()) {
|
|
203
|
+
printJson({ ok: !failed, checks, e2e });
|
|
204
|
+
return failed ? 1 : 0;
|
|
205
|
+
}
|
|
206
|
+
const mark = (l) => (l === 'ok' ? green('✓') : l === 'warn' ? yellow('!') : red('✗'));
|
|
207
|
+
info(table(checks.map((c) => [mark(c.level), c.id, c.message])));
|
|
208
|
+
if (e2e.length) {
|
|
209
|
+
info('');
|
|
210
|
+
info(table([[L('模型', 'Model'), L('流式', 'Stream'), L('工具调用', 'Tool call')], ...e2e.map((r) => [`${r.ok ? green('✓') : red('✗')} ${r.model}`, r.stream, r.tool])]));
|
|
211
|
+
}
|
|
212
|
+
else if (opts.e2e) {
|
|
213
|
+
info(yellow(L('没有可测的模型', 'No models to test')));
|
|
214
|
+
}
|
|
215
|
+
return failed ? 1 : 0;
|
|
216
|
+
}
|