dsh-model 0.0.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.en.md +123 -0
  2. package/README.md +92 -43
  3. package/bin/dsh-model.js +8 -0
  4. package/dist/bridge/aggregate.js +91 -0
  5. package/dist/bridge/login.js +127 -0
  6. package/dist/bridge/runtime.js +136 -0
  7. package/dist/bridge/server.js +178 -0
  8. package/dist/bridge/workbuddy/_compat.js +52 -0
  9. package/dist/bridge/workbuddy/app-version.js +141 -0
  10. package/dist/bridge/workbuddy/auth.js +548 -0
  11. package/dist/bridge/workbuddy/catalog.js +154 -0
  12. package/dist/bridge/workbuddy/client-identity.js +234 -0
  13. package/dist/bridge/workbuddy/desktop-credential-protection.js +992 -0
  14. package/dist/bridge/workbuddy/loopback.js +52 -0
  15. package/dist/bridge/workbuddy/probe.js +153 -0
  16. package/dist/bridge/workbuddy/status-paths.js +49 -0
  17. package/dist/bridge/workbuddy/upstream.js +1180 -0
  18. package/dist/bridge/workbuddy/variants.js +101 -0
  19. package/dist/cli.js +207 -0
  20. package/dist/commands/bridge.js +68 -0
  21. package/dist/commands/connect.js +67 -0
  22. package/dist/commands/engine.js +122 -0
  23. package/dist/commands/key.js +75 -0
  24. package/dist/commands/login.js +113 -0
  25. package/dist/commands/logs.js +19 -0
  26. package/dist/commands/opencode.js +77 -0
  27. package/dist/commands/remote.js +129 -0
  28. package/dist/commands/service.js +72 -0
  29. package/dist/commands/setup.js +216 -0
  30. package/dist/commands/sources.js +196 -0
  31. package/dist/commands/status.js +216 -0
  32. package/dist/commands/uninstall.js +97 -0
  33. package/dist/commands/workbuddy.js +117 -0
  34. package/dist/context.js +86 -0
  35. package/dist/daemon/client.js +27 -0
  36. package/dist/daemon/core.js +790 -0
  37. package/dist/daemon/service.js +33 -0
  38. package/dist/daemon/stats.js +126 -0
  39. package/dist/daemon/usage.js +213 -0
  40. package/dist/dsh/cli.js +72 -0
  41. package/dist/dsh/connect.js +257 -0
  42. package/dist/dsh/credentials.js +55 -0
  43. package/dist/dsh/locate.js +129 -0
  44. package/dist/dsh/patch.js +106 -0
  45. package/dist/dsh/pick.js +59 -0
  46. package/dist/engine/auth.js +40 -0
  47. package/dist/engine/client.js +104 -0
  48. package/dist/engine/compat.js +94 -0
  49. package/dist/engine/config.js +47 -0
  50. package/dist/engine/install.js +118 -0
  51. package/dist/engine/mgmt.js +99 -0
  52. package/dist/engine/sourcemap.js +48 -0
  53. package/dist/errors.js +14 -0
  54. package/dist/i18n.js +39 -0
  55. package/dist/integrations/dshplugin.js +42 -0
  56. package/dist/integrations/opencode.js +101 -0
  57. package/dist/integrations/workbuddy.js +114 -0
  58. package/dist/keys.js +54 -0
  59. package/dist/ops.js +178 -0
  60. package/dist/remote/public.js +109 -0
  61. package/dist/secrets.js +13 -0
  62. package/dist/service/index.js +46 -0
  63. package/dist/service/launchd.js +89 -0
  64. package/dist/service/systemd.js +83 -0
  65. package/dist/sources.js +36 -0
  66. package/dist/state.js +90 -0
  67. package/dist/upstreams.js +23 -0
  68. package/dist/util/diff.js +30 -0
  69. package/dist/util/exec.js +42 -0
  70. package/dist/util/fs.js +107 -0
  71. package/dist/util/output.js +50 -0
  72. package/dist/util/pkg.js +13 -0
  73. package/dist/util/port.js +16 -0
  74. package/dist/util/prompt.js +40 -0
  75. package/dist/util/proxy.js +73 -0
  76. package/dist/util/redact.js +7 -0
  77. package/engine-manifest.json +28 -0
  78. package/package.json +77 -7
  79. package/plugin/client.js +549 -0
  80. package/plugin/cordis.patch.yml +4 -0
  81. package/plugin/index.js +177 -0
  82. package/docs/DESIGN.md +0 -610
@@ -0,0 +1,101 @@
1
+ // integrations/opencode.ts — OpenCode Zen 作为引擎的 openai-compatibility 上游(统一端点,设计 §1.3)
2
+ //
3
+ // key 存在 dsh-model 自己的 secrets.json(0600),由引擎持有;模型目录取自公开的 /zen/v1/models。
4
+ // OpenCode 的免费档不能免 key 从第三方调用(官方 403 FreeTierError),所以必须有 key。
5
+ // v0.2.0 曾把 key 写进 dsh 的凭据库并启用 dsh 内置 opencode 路由:migrateDshOpencode 负责迁走。
6
+ import { applyDsh } from '../dsh/connect.js';
7
+ import { readRef } from '../dsh/credentials.js';
8
+ import { DshModelError } from '../errors.js';
9
+ import { opencodeModelsPath } from '../engine/compat.js';
10
+ import { L } from '../i18n.js';
11
+ import { loadSecrets, saveSecrets } from '../secrets.js';
12
+ import { run } from '../util/exec.js';
13
+ import { readText, writeJson } from '../util/fs.js';
14
+ export const OPENCODE_PROVIDER = 'opencode';
15
+ export const OPENCODE_REF = 'OPENCODE_API_KEY';
16
+ export const OPENCODE_MODELS_URL = 'https://opencode.ai/zen/v1/models';
17
+ export const OPENCODE_KEY_PAGE = 'https://opencode.ai/auth';
18
+ /**
19
+ * 校验 key。/zen/v1/models 是公开的,验不了 key;而且无效 key 和不带 key 一样返回
20
+ * 403 FreeTierError(实测 2026-10-08)。所以用这把 key 向一个免费模型发 1 token 请求:200 才算可用。
21
+ * 用 curl:它认 -x 代理(Node 的 fetch 不走代理);key 经 stdin 的 curl 配置传入,不出现在进程参数里。
22
+ */
23
+ export async function verifyOpencodeKey(key, proxy) {
24
+ const proxyArgs = proxy ? ['-x', proxy] : [];
25
+ const list = await run('curl', ['-s', '-m', '20', ...proxyArgs, OPENCODE_MODELS_URL], { timeoutMs: 30_000 });
26
+ let model = 'big-pickle';
27
+ try {
28
+ const ids = (JSON.parse(list.stdout).data ?? []).map((m) => m.id);
29
+ model = ids.includes('big-pickle') ? 'big-pickle' : (ids.find((id) => id.endsWith('-free')) ?? model);
30
+ }
31
+ catch {
32
+ if (list.code !== 0)
33
+ return { ok: false, status: null, detail: L(`连不上 opencode.ai${proxy ? '' : '(需要代理?dsh-model setup --proxy <地址>)'}`, `cannot reach opencode.ai${proxy ? '' : ' (proxy needed? dsh-model setup --proxy <url>)'}`) };
34
+ }
35
+ const body = JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] });
36
+ const r = await run('curl', ['-s', '-m', '40', '-w', '\n%{http_code}', '-K', '-', ...proxyArgs, '-H', 'Content-Type: application/json', '-d', body, OPENCODE_MODELS_URL.replace(/models$/, 'chat/completions')], { input: `header = "Authorization: Bearer ${key.replace(/"/g, '')}"\n`, timeoutMs: 60_000 });
37
+ const lines = r.stdout.trimEnd().split('\n');
38
+ const status = Number(lines.pop()) || null;
39
+ const text = lines.join('\n');
40
+ if (status === 200)
41
+ return { ok: true, status, detail: 'ok', model };
42
+ if (status === 403 && /FreeTierError/.test(text)) {
43
+ return {
44
+ ok: false,
45
+ status,
46
+ model,
47
+ detail: L('OpenCode 返回 FreeTierError:这把 key 没被识别(无效 key 会被当成匿名请求),或者 OpenCode 不允许在其客户端之外用免费档', 'OpenCode returned FreeTierError: the key was not recognized (an invalid key is treated as anonymous), or OpenCode does not allow its free tier outside its own client'),
48
+ };
49
+ }
50
+ if (status === 401)
51
+ return { ok: false, status, model, detail: L('key 无效', 'invalid key') };
52
+ if (status === 402 || /insufficient|balance|credit/i.test(text))
53
+ return { ok: true, status, model, detail: L('key 有效,但余额不足(免费模型应可用)', 'key valid but out of balance (free models should still work)') };
54
+ return { ok: false, status, model, detail: status ? `HTTP ${status}: ${text.slice(0, 160)}` : L('请求失败(网络 / 代理?)', 'request failed (network / proxy?)') };
55
+ }
56
+ export function validateKeyShape(key) {
57
+ if (!key || /\s/.test(key) || key.length < 16) {
58
+ throw new DshModelError('invalid_opencode_key', L('这看起来不是一个 OpenCode Zen key', 'That does not look like an OpenCode Zen key'), L(`在 ${OPENCODE_KEY_PAGE} 登录后创建 API key`, `Create an API key at ${OPENCODE_KEY_PAGE}`));
59
+ }
60
+ }
61
+ /** 拉公开模型目录写到 opencode-models.json(走 curl:它认代理) */
62
+ export async function refreshOpencodeModels(ctx, proxy) {
63
+ const r = await run('curl', ['-s', '-m', '20', ...(proxy ? ['-x', proxy] : []), OPENCODE_MODELS_URL], { timeoutMs: 30_000 });
64
+ let ids = [];
65
+ try {
66
+ ids = (JSON.parse(r.stdout).data ?? []).map((m) => m.id).filter((id) => typeof id === 'string' && id);
67
+ }
68
+ catch {
69
+ throw new DshModelError('opencode_models_failed', L(`取不到 OpenCode 模型目录${proxy ? '' : '(需要代理?)'}`, `Could not fetch the OpenCode model list${proxy ? '' : ' (proxy needed?)'}`));
70
+ }
71
+ const file = { updatedAt: new Date().toISOString(), models: ids.map((id) => ({ id })) };
72
+ await writeJson(opencodeModelsPath(ctx), file, { owner: ctx.owner });
73
+ return ids.length;
74
+ }
75
+ export async function opencodeConfigured(ctx) {
76
+ return Boolean((await loadSecrets(ctx)).opencode?.key);
77
+ }
78
+ export async function saveOpencodeKey(ctx, key, verifiedModel) {
79
+ const secrets = await loadSecrets(ctx);
80
+ secrets.opencode = { key, verifiedAt: new Date().toISOString(), ...(verifiedModel ? { verifiedModel } : {}) };
81
+ await saveSecrets(ctx, secrets);
82
+ }
83
+ export async function removeOpencodeKey(ctx) {
84
+ const secrets = await loadSecrets(ctx);
85
+ if (!secrets.opencode)
86
+ return false;
87
+ delete secrets.opencode;
88
+ await saveSecrets(ctx, secrets);
89
+ return true;
90
+ }
91
+ /** v0.2.0 → 统一端点:把 dsh-model 写进 dsh 的 OPENCODE_API_KEY 收回 secrets.json,并移除 dsh 里的 opencode 路由 */
92
+ export async function migrateDshOpencode(ctx, all) {
93
+ const dsh = all.state.dsh;
94
+ if (!dsh?.ownedProviders?.includes(OPENCODE_PROVIDER) && !dsh?.ownedRefs?.includes(OPENCODE_REF))
95
+ return false;
96
+ const key = readRef(await readText(dsh.credFile), OPENCODE_REF);
97
+ if (key && !(await opencodeConfigured(ctx)))
98
+ await saveOpencodeKey(ctx, key);
99
+ await applyDsh(ctx, all.state, { refs: { [OPENCODE_REF]: null }, providers: { [OPENCODE_PROVIDER]: null } }, { profile: all.config.dsh.profile });
100
+ return true;
101
+ }
@@ -0,0 +1,114 @@
1
+ // integrations/workbuddy.ts — WorkBuddy 经 dsh-model 自己的 bridge 接入统一端点(设计 §1.3.1)
2
+ //
3
+ // bridge 是常驻服务(com.dsh-model.bridge),读 WorkBuddy App 的登录态,对引擎提供 OpenAI 兼容接口。
4
+ // v0.2.0 曾经通过 dsh 的插件管理装 dsh-workbuddy-connect:removeWorkbuddyPlugin 负责把 dsh-model 装的那份移除。
5
+ import { readFile } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { availableVariants, loadBridgeConfig, loadCatalogs } from '../bridge/runtime.js';
8
+ import { ensureDaemon } from '../daemon/service.js';
9
+ import { dshPlugin, profileHasDependency } from '../dsh/cli.js';
10
+ import { resolveProfile } from '../dsh/locate.js';
11
+ import { DshModelError } from '../errors.js';
12
+ import { L } from '../i18n.js';
13
+ import { serviceFor } from '../service/index.js';
14
+ import { info, ok, skip, warn } from '../util/output.js';
15
+ export const WORKBUDDY_PLUGIN = 'dsh-workbuddy-connect';
16
+ export function riskNotice() {
17
+ return L('WorkBuddy 接入说明:dsh-model 的 bridge 会读取并解密 WorkBuddy App 本机保存的登录凭据(需运行 App 自带的程序取密钥),并以 WorkBuddy 客户端的身份调用其接口。这绕开了 WorkBuddy 自己的凭据保护,可能不符合其服务条款,账号存在风险;WorkBuddy 升级加密方式时可能失效。刷新后的令牌只存 dsh-model 自己的副本,不改写 App 的文件。仅限你本人自用。', "WorkBuddy notice: dsh-model's bridge reads and decrypts the WorkBuddy app's locally stored sign-in (running the app's own binary to obtain the key) and calls its API as the WorkBuddy client. This bypasses WorkBuddy's own credential protection, may conflict with its terms, and puts the account at risk; it may break when WorkBuddy changes its encryption. Refreshed tokens are kept only in dsh-model's own copy; the app's files are never rewritten. Personal use only.");
18
+ }
19
+ export async function bridgeHealthy(port, secret) {
20
+ try {
21
+ const r = await fetch(`http://127.0.0.1:${port}/healthz`, { headers: { Authorization: `Bearer ${secret}` }, signal: AbortSignal.timeout(2000) });
22
+ return r.ok;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ async function waitCatalogs(ctx, since, expected, timeoutMs = 45_000) {
29
+ const deadline = Date.now() + timeoutMs;
30
+ let cats = [];
31
+ while (Date.now() < deadline) {
32
+ cats = (await loadCatalogs(ctx.paths.home)).filter((c) => Date.parse(c.updatedAt) >= since);
33
+ if (cats.length >= expected)
34
+ return cats;
35
+ await new Promise((r) => setTimeout(r, 500));
36
+ }
37
+ return cats;
38
+ }
39
+ /** 检测 App → 首次打印说明 → bridge 配置与服务 → 等目录。返回目录(调用方随后 syncAll) */
40
+ export async function enableWorkbuddy(ctx, all) {
41
+ const variants = await availableVariants(ctx.paths.home);
42
+ if (!variants.length) {
43
+ skip(ctx.platform === 'darwin'
44
+ ? L('没检测到 WorkBuddy 桌面 App,也没有用 dsh-model 登录过;可执行 dsh-model workbuddy login', 'No WorkBuddy desktop app and no dsh-model sign-in; run dsh-model workbuddy login')
45
+ : L('服务器上没有 WorkBuddy 桌面 App:执行 dsh-model workbuddy login,在任意浏览器里授权即可', 'No WorkBuddy desktop app on this server: run dsh-model workbuddy login and approve in any browser'));
46
+ return [];
47
+ }
48
+ if (!all.config.bridge?.riskNoticeAt) {
49
+ warn(riskNotice());
50
+ all.config.bridge = { port: all.config.bridge?.port ?? 0, riskNoticeAt: new Date().toISOString() };
51
+ }
52
+ const since = Date.now();
53
+ // 守护进程(bridge)重启后会按当前登录的产品重建,并读一遍目录
54
+ await ensureDaemon(ctx, all);
55
+ if (ctx.serviceDisabled)
56
+ return loadCatalogs(ctx.paths.home);
57
+ info(L('正在读取 WorkBuddy 登录态与模型目录(首次会运行 App 自带的程序取密钥)…', 'Reading WorkBuddy sign-in and model catalog (first run executes the app binary to obtain the key)…'));
58
+ const cats = await waitCatalogs(ctx, since, variants.length);
59
+ for (const c of cats) {
60
+ if (c.signedIn)
61
+ ok(L(`${c.label}:已登录${c.nickname ? `(${c.nickname})` : ''},${c.models.length} 个模型(前缀 ${c.prefix}/)`, `${c.label}: signed in${c.nickname ? ` (${c.nickname})` : ''}, ${c.models.length} models (prefix ${c.prefix}/)`));
62
+ else
63
+ warn(L(`${c.label}:未登录${c.error ? `(${c.error})` : ''}。在 App 里登录后,bridge 会在 30 分钟内自动接入,或执行 dsh-model workbuddy refresh`, `${c.label}: not signed in${c.error ? ` (${c.error})` : ''}. After signing in to the app, the bridge picks it up within 30 minutes, or run dsh-model workbuddy refresh`));
64
+ }
65
+ if (cats.length < variants.length)
66
+ warn(L('部分 WorkBuddy 目录还没读到,稍后执行 dsh-model workbuddy refresh', 'Some WorkBuddy catalogs are not ready yet; run dsh-model workbuddy refresh later'));
67
+ return cats;
68
+ }
69
+ export async function disableWorkbuddy(ctx, all) {
70
+ if (!all.state.bridgeService && !all.config.bridge)
71
+ return false;
72
+ if (!ctx.serviceDisabled)
73
+ await serviceFor(ctx, 'bridge', all.config.proxy).uninstall();
74
+ delete all.state.bridgeService;
75
+ return true;
76
+ }
77
+ /** 请正在运行的 bridge 立刻重读登录态与目录(它会自己同步引擎和 dsh) */
78
+ export async function refreshBridge(ctx) {
79
+ const cfg = await loadBridgeConfig(ctx.paths.home);
80
+ if (!cfg)
81
+ return false;
82
+ try {
83
+ const r = await fetch(`http://127.0.0.1:${cfg.port}/refresh`, { method: 'POST', headers: { Authorization: `Bearer ${cfg.secret}` }, signal: AbortSignal.timeout(60_000) });
84
+ return r.ok;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ // —— v0.2.0 迁移:移除 dsh-model 通过 dsh 插件管理装的 dsh-workbuddy-connect ——
91
+ export async function removeWorkbuddyPlugin(ctx, all) {
92
+ const rec = all.state.plugins?.find((p) => p.name === WORKBUDDY_PLUGIN);
93
+ if (!rec?.installedByUs)
94
+ return false;
95
+ const profile = await resolveProfile(ctx, all.config.dsh.profile);
96
+ if (await profileHasDependency(ctx, profile, WORKBUDDY_PLUGIN)) {
97
+ const code = await dshPlugin(ctx, profile, ['remove', WORKBUDDY_PLUGIN], all.config.proxy);
98
+ if (code !== 0)
99
+ throw new DshModelError('plugin_remove_failed', L(`移除 ${WORKBUDDY_PLUGIN} 失败(退出码 ${code})`, `Failed to remove ${WORKBUDDY_PLUGIN} (exit code ${code})`));
100
+ }
101
+ all.state.plugins = (all.state.plugins ?? []).filter((p) => p.name !== WORKBUDDY_PLUGIN);
102
+ return true;
103
+ }
104
+ /** 用户自己装了 dsh-workbuddy-connect:dsh 里会出现两组 WorkBuddy 模型,提示一下 */
105
+ export async function userPluginInstalled(ctx, all) {
106
+ try {
107
+ const profile = await resolveProfile(ctx, all.config.dsh.profile);
108
+ const pkg = JSON.parse(await readFile(join(ctx.dshHome, 'profiles', profile, 'package.json'), 'utf8'));
109
+ return Boolean(pkg.dependencies?.[WORKBUDDY_PLUGIN]) && !all.state.plugins?.some((p) => p.name === WORKBUDDY_PLUGIN && p.installedByUs);
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
package/dist/keys.js ADDED
@@ -0,0 +1,54 @@
1
+ // keys.ts — 客户端 key(每台设备 / 每个工具一把,可单独吊销)
2
+ //
3
+ // keys.json 0600。'dsh' 这把由 setup 生成,写进 dsh 的凭据文件;其余由 key add 生成。
4
+ import { randomBytes } from 'node:crypto';
5
+ import { DshModelError } from './errors.js';
6
+ import { L } from './i18n.js';
7
+ import { readJson, writeJson } from './util/fs.js';
8
+ export const DSH_KEY_NAME = 'dsh';
9
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/;
10
+ export function generateKey() {
11
+ return `dshm_${randomBytes(32).toString('base64url')}`;
12
+ }
13
+ export async function loadKeys(ctx) {
14
+ return (await readJson(ctx.paths.keys)) ?? { keys: [] };
15
+ }
16
+ export async function saveKeys(ctx, store) {
17
+ await writeJson(ctx.paths.keys, store, { owner: ctx.owner });
18
+ }
19
+ export function activeKeys(store) {
20
+ return store.keys.filter((k) => !k.revokedAt);
21
+ }
22
+ export function findActive(store, name) {
23
+ return activeKeys(store).find((k) => k.name === name);
24
+ }
25
+ export function addKey(store, name) {
26
+ if (!NAME_RE.test(name)) {
27
+ throw new DshModelError('invalid_key_name', L(`key 名称不合法:${name}(字母数字开头,可含 . _ -,最长 40)`, `Invalid key name: ${name} (alphanumeric start, may contain . _ -, max 40)`));
28
+ }
29
+ if (findActive(store, name)) {
30
+ throw new DshModelError('key_exists', L(`已经有一把叫 ${name} 的 key`, `A key named ${name} already exists`), L(`要换新的用:dsh-model key rotate ${name}`, `To replace it: dsh-model key rotate ${name}`));
31
+ }
32
+ const entry = { name, key: generateKey(), createdAt: new Date().toISOString() };
33
+ store.keys.push(entry);
34
+ return entry;
35
+ }
36
+ export function revokeKey(store, name) {
37
+ const entry = findActive(store, name);
38
+ if (!entry)
39
+ throw new DshModelError('key_not_found', L(`没有叫 ${name} 的 key`, `No key named ${name}`));
40
+ entry.revokedAt = new Date().toISOString();
41
+ }
42
+ export function rotateKey(store, name) {
43
+ revokeKey(store, name);
44
+ const entry = { name, key: generateKey(), createdAt: new Date().toISOString() };
45
+ store.keys.push(entry);
46
+ return entry;
47
+ }
48
+ /** setup 用:没有 dsh 这把就生成 */
49
+ export function ensureDshKey(store) {
50
+ const existing = findActive(store, DSH_KEY_NAME);
51
+ if (existing)
52
+ return { entry: existing, created: false };
53
+ return { entry: addKey(store, DSH_KEY_NAME), created: true };
54
+ }
package/dist/ops.js ADDED
@@ -0,0 +1,178 @@
1
+ // ops.ts — 各命令共用的流程:读全部状态、应用引擎配置、同步模型到 dsh
2
+ import { connectDshGroups } from './dsh/connect.js';
3
+ import { listModels, waitHealthy, waitModelsChange } from './engine/client.js';
4
+ import { compatModelIndex, loadCompatUpstreams } from './engine/compat.js';
5
+ import { subscriptionMap } from './engine/sourcemap.js';
6
+ import { pickedFor } from './dsh/pick.js';
7
+ import { writeEngineConfig } from './engine/config.js';
8
+ import { DshModelError, isDshModelError } from './errors.js';
9
+ import { L } from './i18n.js';
10
+ import { DSH_KEY_NAME, findActive, loadKeys } from './keys.js';
11
+ import { serviceFor } from './service/index.js';
12
+ import { loadConfig, loadState, saveConfig, saveState } from './state.js';
13
+ import { ok, skip, warn } from './util/output.js';
14
+ export async function loadAll(ctx) {
15
+ const [config, state, keys] = await Promise.all([loadConfig(ctx), loadState(ctx), loadKeys(ctx)]);
16
+ return { config, state, keys };
17
+ }
18
+ export async function saveAll(ctx, all) {
19
+ await saveConfig(ctx, all.config);
20
+ await saveState(ctx, all.state);
21
+ }
22
+ export function dshKey(keys) {
23
+ const k = findActive(keys, DSH_KEY_NAME);
24
+ if (!k)
25
+ throw new DshModelError('not_setup', L('还没有初始化', 'Not set up yet'), L('先执行 dsh-model setup', 'Run dsh-model setup first'));
26
+ return k.key;
27
+ }
28
+ /**
29
+ * 写 engine.yaml;内容变了且服务已注册,就重启引擎并等它起来。
30
+ * 不能指望引擎的热重载:我们原子写(临时文件 + rename)会换掉文件 inode,
31
+ * Linux 上引擎的 fsnotify 盯着旧 inode,收不到变化(VPS 实测:写入后引擎一直是 0 个 OpenAI-compat)。
32
+ */
33
+ export async function applyEngineConfig(ctx, all) {
34
+ return writeEngineConfig(ctx, all.config, all.keys);
35
+ }
36
+ /**
37
+ * 兜底重启:热重载没生效时用。vps 模式下 bridge 以 dsh 用户运行,没有权限重启系统服务——
38
+ * 这时只记警告不失败(实测:bridge 写完配置后 systemctl restart 被拒)。
39
+ */
40
+ async function restartEngineBestEffort(ctx, all) {
41
+ if (ctx.serviceDisabled || !all.state.service)
42
+ return false;
43
+ try {
44
+ await serviceFor(ctx).restart();
45
+ return waitHealthy(all.config.port, 15_000);
46
+ }
47
+ catch (error) {
48
+ warn(L(`没能重启引擎(${isDshModelError(error) ? error.code : String(error)});稍后执行 dsh-model repair`, `Could not restart the engine (${isDshModelError(error) ? error.code : String(error)}); run dsh-model repair later`));
49
+ return false;
50
+ }
51
+ }
52
+ /**
53
+ * 把引擎当前的模型列表写进 dsh。dsh 没装 / 没接过线时只提示,不算失败。
54
+ * before:login/logout 前的模型 id,用来等引擎热重载完成。
55
+ */
56
+ export async function syncModels(ctx, all, opts = {}) {
57
+ const key = dshKey(all.keys);
58
+ const models = opts.before ? await waitModelsChange(all.config.port, key, opts.before) : await listModels(all.config.port, key);
59
+ const ids = models.map((m) => m.id);
60
+ const candidates = await dshCandidates(ctx, all, ids);
61
+ // 每个来源只把挑中的模型写进 dsh(管理页勾选,或默认挑选);统一端点照常提供全部
62
+ const groups = [];
63
+ for (const c of candidates.values()) {
64
+ const picked = new Set(pickedFor(c.models.map((m) => ({ id: m.id, name: m.name, rate: m.rate })), all.config.dshModels?.[c.source]));
65
+ const models = c.models.filter((m) => picked.has(m.id)).map(({ rate: _r, ...m }) => m);
66
+ if (models.length)
67
+ groups.push({ providerId: c.providerId, displayName: c.label, models });
68
+ }
69
+ try {
70
+ const r = await connectDshGroups(ctx, all.state, all.keys, {
71
+ port: all.config.port,
72
+ key,
73
+ groups,
74
+ profile: opts.profile ?? all.config.dsh.profile,
75
+ force: opts.force,
76
+ });
77
+ all.config.dsh.profile = r.location.profile;
78
+ r.warnings.forEach((w) => warn(w));
79
+ if (!opts.quiet) {
80
+ if (r.providers.length) {
81
+ const summary = groups.map((g) => `${g.displayName} ${g.models.length}`).join(' · ');
82
+ const shown = groups.reduce((n, g) => n + g.models.length, 0);
83
+ const usable = [...candidates.values()].reduce((n, c) => n + c.models.length, 0);
84
+ (r.changed ? ok : skip)(L(`dsh(${r.location.profile})显示 ${shown} 个模型(共 ${usable} 个可用):${summary}`, `dsh (${r.location.profile}) shows ${shown} of ${usable} models: ${summary}`));
85
+ }
86
+ else {
87
+ skip(L('还没有登录任何上游,dsh 里暂时没有 dsh-model 的模型', 'No upstream logged in yet, so dsh has no dsh-model models for now'));
88
+ }
89
+ }
90
+ }
91
+ catch (error) {
92
+ if (isDshModelError(error) && error.code === 'dsh_not_found') {
93
+ warn(`${error.message}${error.hint ? ` — ${error.hint}` : ''}`);
94
+ }
95
+ else {
96
+ throw error;
97
+ }
98
+ }
99
+ await saveAll(ctx, all);
100
+ return ids;
101
+ }
102
+ /**
103
+ * 每个来源可以放进 dsh 的全部模型(勾选列表也用它)。
104
+ * 订阅模型名后缀"· 订阅";已登录但没有可用订阅的来源(如 Kimi 没有 Kimi Code 套餐)不算,免得选了就报错。
105
+ */
106
+ export async function dshCandidates(ctx, all, ids) {
107
+ const list = ids ?? (await listModels(all.config.port, dshKey(all.keys))).map((m) => m.id);
108
+ const meta = compatModelIndex(await loadCompatUpstreams(ctx));
109
+ const subs = await subscriptionMap(ctx, all.config.port);
110
+ const out = new Map();
111
+ const push = (source, providerId, label, model) => {
112
+ let g = out.get(source);
113
+ if (!g)
114
+ out.set(source, (g = { source, providerId, label, models: [] }));
115
+ g.models.push(model);
116
+ };
117
+ for (const id of list) {
118
+ const m = meta.get(id);
119
+ if (m?.group) {
120
+ push(m.group, `dsh-model-${m.group}`, m.groupLabel ?? m.group, {
121
+ id,
122
+ name: m.dshName ?? m.displayName ?? id,
123
+ ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
124
+ ...(m.maxTokens ? { maxTokens: m.maxTokens } : {}),
125
+ input: m.image ? ['text', 'image'] : ['text'],
126
+ ...(m.rate !== undefined ? { rate: m.rate } : {}),
127
+ });
128
+ continue;
129
+ }
130
+ const s = subs.models.get(id);
131
+ if (s && subs.noAccess.has(s.source))
132
+ continue;
133
+ if (s)
134
+ push(s.source, `dsh-model-${s.source}`, s.label, { id, name: `${s.displayName ?? id} · ${L('订阅', 'subscription')}` });
135
+ else
136
+ push('other', all.config.dsh.providerId, L('订阅(dsh-model)', 'Subscriptions (dsh-model)'), { id, name: id });
137
+ }
138
+ return out;
139
+ }
140
+ /** 引擎里带前缀(opencode/ workbuddy/ workbuddy-ai/)的模型是否正好等于期望:不能缺,也不能多(关掉的来源要消失) */
141
+ function aliasesMatch(models, expected) {
142
+ const want = new Set(expected);
143
+ const prefixes = ['opencode/', 'workbuddy/', 'workbuddy-ai/'];
144
+ const have = models.map((m) => m.id).filter((id) => prefixes.some((p) => id.startsWith(p)));
145
+ return have.length === want.size && have.every((id) => want.has(id));
146
+ }
147
+ /** 等引擎热重载后模型列表与期望一致(openai-compatibility 上游改动之后),最多 timeoutMs */
148
+ async function waitForAliases(port, key, expected, timeoutMs = 6000) {
149
+ const deadline = Date.now() + timeoutMs;
150
+ let last = [];
151
+ while (Date.now() < deadline) {
152
+ try {
153
+ last = await listModels(port, key);
154
+ if (aliasesMatch(last, expected))
155
+ return last;
156
+ }
157
+ catch {
158
+ // 重载中
159
+ }
160
+ await new Promise((r) => setTimeout(r, 300));
161
+ }
162
+ return last;
163
+ }
164
+ /**
165
+ * 全量同步:按当前的 OpenCode key / WorkBuddy 目录重写 engine.yaml → 等引擎加载 → 把模型清单写进 dsh。
166
+ * setup、opencode key、bridge 发现目录变化时都走这里。
167
+ */
168
+ export async function syncAll(ctx, all, opts = {}) {
169
+ await applyEngineConfig(ctx, all);
170
+ const expected = [...compatModelIndex(await loadCompatUpstreams(ctx)).keys()];
171
+ const key = dshKey(all.keys);
172
+ // 不看"这次有没有写文件",而看引擎实际加载的模型是否与期望一致:别的进程可能已经写过同样的内容(VPS 实测),
173
+ // 关掉一个来源时也要等它的模型真的消失,才把清单写进 dsh
174
+ if (!aliasesMatch(await waitForAliases(all.config.port, key, expected, 8000), expected) && (await restartEngineBestEffort(ctx, all))) {
175
+ await waitForAliases(all.config.port, key, expected, 8000);
176
+ }
177
+ return syncModels(ctx, all, { quiet: opts.quiet });
178
+ }
@@ -0,0 +1,109 @@
1
+ // remote/public.ts — 对外 HTTPS 端点(vps 模式,复用 dsh-vps 的 Caddy)
2
+ //
3
+ // 引擎始终只监听 127.0.0.1;对外由 Caddy 在 dsh 已有的域名上另开一个端口转发:
4
+ // https://<dsh 的域名>:9443/v1 → 127.0.0.1:8317
5
+ // 不能复用 443:dsh-gate 对所有路径(含 /api)都要求网页登录,只带 key 的 API 客户端过不去(实测)。
6
+ // 域名自动从 dsh-vps 的站点块读(/etc/caddy/dsh-site.conf 的第一个站点);只有 IP 时用 <ip>.sslip.io,
7
+ // 这样仍能拿到正规证书。防火墙(iptables INPUT 默认 DROP)放行这个端口,并用一个开机服务保持;
8
+ // 关闭 / 卸载时全部撤销。
9
+ import { readFile, rm } from 'node:fs/promises';
10
+ import { isIP } from 'node:net';
11
+ import { run } from '../util/exec.js';
12
+ import { atomicWrite, exists } from '../util/fs.js';
13
+ import { DshModelError } from '../errors.js';
14
+ import { L } from '../i18n.js';
15
+ export const DSH_SITE_CONF = '/etc/caddy/dsh-site.conf';
16
+ export const DEFAULT_PUBLIC_PORT = 9443;
17
+ export const FIREWALL_UNIT = '/etc/systemd/system/dsh-model-firewall.service';
18
+ const FW_COMMENT = 'dsh-model';
19
+ /** dsh-vps 站点块的地址:域名,或 IP(没配域名时) */
20
+ export async function detectDshHost() {
21
+ try {
22
+ return parseSiteHost(await readFile(DSH_SITE_CONF, 'utf8'));
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /** Caddyfile 片段里第一个站点块的地址 */
29
+ export function parseSiteHost(text) {
30
+ for (const raw of text.split('\n')) {
31
+ const line = raw.trim();
32
+ if (!line || line.startsWith('#'))
33
+ continue;
34
+ const m = /^([^{}]+)\{$/.exec(line);
35
+ if (!m)
36
+ continue;
37
+ // 站点地址可能是 "https://x"、"x:443"、"x, y"
38
+ const first = m[1].trim().split(/[\s,]+/)[0].replace(/^https?:\/\//, '').replace(/:\d+$/, '');
39
+ if (!first || first.startsWith('(') || first === 'import')
40
+ continue;
41
+ return { host: first, kind: isIP(first) ? 'ip' : 'domain' };
42
+ }
43
+ return null;
44
+ }
45
+ /** 本机公网 IPv4(从默认路由的源地址取) */
46
+ export async function publicIp() {
47
+ const r = await run('ip', ['-4', 'route', 'get', '1.1.1.1']);
48
+ const m = / src (\d+\.\d+\.\d+\.\d+)/.exec(r.stdout);
49
+ return m?.[1] ?? null;
50
+ }
51
+ /** 有域名用域名;只有 IP 时用 <ip>.sslip.io(公共 DNS,解析回这个 IP),这样仍能签正规证书 */
52
+ export async function publicHost(override) {
53
+ if (override)
54
+ return override;
55
+ const site = await detectDshHost();
56
+ if (site?.kind === 'domain')
57
+ return site.host;
58
+ const ip = site?.kind === 'ip' ? site.host : await publicIp();
59
+ if (!ip)
60
+ throw new DshModelError('no_public_ip', L('查不到这台服务器的公网地址', 'Cannot determine the public address of this server'), L('用 --domain 指定:dsh-model remote enable --via caddy --domain 你的域名', 'Pass it explicitly: dsh-model remote enable --via caddy --domain your.domain'));
61
+ return `${ip.replace(/\./g, '-')}.sslip.io`;
62
+ }
63
+ export function siteBlock(host, publicPort, enginePort) {
64
+ return `# Generated by dsh-model. 由 dsh-model 生成:对外的 OpenAI 兼容端点(只认 dsh-model 的 key)\n${host}:${publicPort} {\n\treverse_proxy 127.0.0.1:${enginePort}\n}\n`;
65
+ }
66
+ async function ruleExists(bin, port) {
67
+ return (await run(bin, ['-C', 'INPUT', '-p', 'tcp', '--dport', String(port), '-m', 'comment', '--comment', FW_COMMENT, '-j', 'ACCEPT'])).code === 0;
68
+ }
69
+ /** 放行端口(IPv4 + IPv6),并装一个开机时补回规则的 oneshot 服务 */
70
+ export async function openFirewall(port) {
71
+ for (const bin of ['iptables', 'ip6tables']) {
72
+ if (!(await ruleExists(bin, port)))
73
+ await run(bin, ['-I', 'INPUT', '-p', 'tcp', '--dport', String(port), '-m', 'comment', '--comment', FW_COMMENT, '-j', 'ACCEPT']);
74
+ }
75
+ const add = (bin) => `/bin/sh -c '${bin} -C INPUT -p tcp --dport ${port} -m comment --comment ${FW_COMMENT} -j ACCEPT 2>/dev/null || ${bin} -I INPUT -p tcp --dport ${port} -m comment --comment ${FW_COMMENT} -j ACCEPT'`;
76
+ const unit = [
77
+ '# Generated by dsh-model. 由 dsh-model 生成:开机后放行对外端点的端口',
78
+ '[Unit]',
79
+ 'Description=dsh-model public endpoint firewall rule',
80
+ 'After=network-online.target',
81
+ '',
82
+ '[Service]',
83
+ 'Type=oneshot',
84
+ 'RemainAfterExit=yes',
85
+ `ExecStart=${add('iptables')}`,
86
+ `ExecStart=${add('ip6tables')}`,
87
+ '',
88
+ '[Install]',
89
+ 'WantedBy=multi-user.target',
90
+ '',
91
+ ].join('\n');
92
+ await atomicWrite(FIREWALL_UNIT, unit, { mode: 0o644 });
93
+ await run('systemctl', ['daemon-reload']);
94
+ await run('systemctl', ['enable', 'dsh-model-firewall.service']);
95
+ }
96
+ export async function closeFirewall(port) {
97
+ for (const bin of ['iptables', 'ip6tables']) {
98
+ while (await ruleExists(bin, port)) {
99
+ const r = await run(bin, ['-D', 'INPUT', '-p', 'tcp', '--dport', String(port), '-m', 'comment', '--comment', FW_COMMENT, '-j', 'ACCEPT']);
100
+ if (r.code !== 0)
101
+ break;
102
+ }
103
+ }
104
+ if (await exists(FIREWALL_UNIT)) {
105
+ await run('systemctl', ['disable', 'dsh-model-firewall.service']);
106
+ await rm(FIREWALL_UNIT, { force: true });
107
+ await run('systemctl', ['daemon-reload']);
108
+ }
109
+ }
@@ -0,0 +1,13 @@
1
+ // secrets.ts — dsh-model 自己保管的上游凭据(目前是 OpenCode Zen key)。secrets.json 0600,不进 dsh 的凭据库:
2
+ // 统一端点下 dsh 只认 dsh-model 一个 provider,上游 key 由引擎持有。
3
+ import { join } from 'node:path';
4
+ import { readJson, writeJson } from './util/fs.js';
5
+ export function secretsPath(ctx) {
6
+ return join(ctx.paths.home, 'secrets.json');
7
+ }
8
+ export async function loadSecrets(ctx) {
9
+ return (await readJson(secretsPath(ctx))) ?? {};
10
+ }
11
+ export async function saveSecrets(ctx, secrets) {
12
+ await writeJson(secretsPath(ctx), secrets, { owner: ctx.owner });
13
+ }
@@ -0,0 +1,46 @@
1
+ // service/index.ts — 引擎的系统服务:按运行模式挑 launchd / systemd --user / systemd 系统级
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { currentBinary } from '../engine/install.js';
5
+ import { PKG_ROOT } from '../util/pkg.js';
6
+ import { launchd } from './launchd.js';
7
+ import { systemd } from './systemd.js';
8
+ export const SERVICE_LABEL = 'com.dsh-model.engine';
9
+ export const UNIT_NAME = 'dsh-model-engine.service';
10
+ export const BRIDGE_LABEL = 'com.dsh-model.bridge';
11
+ export const BRIDGE_UNIT = 'dsh-model-bridge.service';
12
+ export function serviceKind(ctx) {
13
+ if (ctx.mode === 'vps')
14
+ return 'systemd-system';
15
+ return ctx.platform === 'darwin' ? 'launchd' : 'systemd-user';
16
+ }
17
+ /** bridge 是 Node 进程:用当前 node 的绝对路径,加上本包的入口 */
18
+ function bridgeProgram() {
19
+ return [process.execPath, join(PKG_ROOT, 'bin', 'dsh-model.js'), 'bridge', 'run'];
20
+ }
21
+ /**
22
+ * bridge 进程的环境:DSH_MODEL_HOME;有代理时让 Node 的 fetch 走代理(NODE_USE_ENV_PROXY,Node ≥ 22.21),
23
+ * 但国内版 WorkBuddy 的域名与本机回环直连。
24
+ */
25
+ export function bridgeEnv(ctx, proxy) {
26
+ return {
27
+ DSH_MODEL_HOME: ctx.paths.home,
28
+ ...(proxy ? { NODE_USE_ENV_PROXY: '1', HTTPS_PROXY: proxy, HTTP_PROXY: proxy, NO_PROXY: '127.0.0.1,localhost,::1,.tencent.com,.codebuddy.cn,.workbuddy.cn,.qq.com' } : {}),
29
+ };
30
+ }
31
+ export function serviceFor(ctx, name = 'engine', proxy) {
32
+ const kind = serviceKind(ctx);
33
+ const label = name === 'engine' ? (kind === 'launchd' ? SERVICE_LABEL : UNIT_NAME) : kind === 'launchd' ? BRIDGE_LABEL : BRIDGE_UNIT;
34
+ const common = {
35
+ program: name === 'engine' ? [currentBinary(ctx), '-config', ctx.paths.engineYaml] : bridgeProgram(),
36
+ workingDirectory: ctx.paths.home,
37
+ stdoutPath: join(ctx.paths.home, `${name}.stdout.log`),
38
+ home: ctx.paths.home,
39
+ ...(name === 'bridge' ? { env: bridgeEnv(ctx, proxy) } : {}),
40
+ };
41
+ if (kind === 'launchd')
42
+ return launchd({ ...common, label, file: join(homedir(), 'Library/LaunchAgents', `${label}.plist`) });
43
+ if (kind === 'systemd-user')
44
+ return systemd('systemd-user', { ...common, label, file: join(homedir(), '.config/systemd/user', label) });
45
+ return systemd('systemd-system', { ...common, label, file: join('/etc/systemd/system', label), user: ctx.runAs });
46
+ }