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.
- package/README.en.md +123 -0
- package/README.md +92 -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 +178 -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 +207 -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 +77 -0
- package/dist/commands/remote.js +129 -0
- package/dist/commands/service.js +72 -0
- package/dist/commands/setup.js +216 -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 +790 -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 +72 -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 +42 -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 +46 -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 +549 -0
- package/plugin/cordis.patch.yml +4 -0
- package/plugin/index.js +177 -0
- package/docs/DESIGN.md +0 -610
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// engine/client.ts — 调本机引擎的 HTTP 端点
|
|
2
|
+
export function baseUrl(port) {
|
|
3
|
+
return `http://127.0.0.1:${port}`;
|
|
4
|
+
}
|
|
5
|
+
async function get(url, key, timeoutMs = 3000) {
|
|
6
|
+
return fetch(url, { headers: key ? { Authorization: `Bearer ${key}` } : {}, signal: AbortSignal.timeout(timeoutMs) });
|
|
7
|
+
}
|
|
8
|
+
export async function healthy(port) {
|
|
9
|
+
try {
|
|
10
|
+
return (await get(`${baseUrl(port)}/healthz`)).ok;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function waitHealthy(port, timeoutMs = 10_000) {
|
|
17
|
+
const deadline = Date.now() + timeoutMs;
|
|
18
|
+
while (Date.now() < deadline) {
|
|
19
|
+
if (await healthy(port))
|
|
20
|
+
return true;
|
|
21
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/** 不带 key 访问 /v1/models 的状态码(期望 401:说明鉴权生效) */
|
|
26
|
+
export async function unauthStatus(port) {
|
|
27
|
+
try {
|
|
28
|
+
return (await get(`${baseUrl(port)}/v1/models`)).status;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function listModels(port, key) {
|
|
35
|
+
const res = await get(`${baseUrl(port)}/v1/models`, key);
|
|
36
|
+
if (!res.ok)
|
|
37
|
+
throw new Error(`GET /v1/models: HTTP ${res.status}`);
|
|
38
|
+
const body = (await res.json());
|
|
39
|
+
return (body.data ?? []).filter((m) => typeof m.id === 'string' && m.id);
|
|
40
|
+
}
|
|
41
|
+
/** 等引擎热重载后模型列表变化(login/logout 之后),最多 timeoutMs;返回最后一次结果 */
|
|
42
|
+
export async function waitModelsChange(port, key, before, timeoutMs = 4000) {
|
|
43
|
+
const deadline = Date.now() + timeoutMs;
|
|
44
|
+
const sig = (ms) => ms.map((m) => m.id).sort().join('\n');
|
|
45
|
+
const prev = [...before].sort().join('\n');
|
|
46
|
+
let last = [];
|
|
47
|
+
while (Date.now() < deadline) {
|
|
48
|
+
try {
|
|
49
|
+
last = await listModels(port, key);
|
|
50
|
+
if (sig(last) !== prev)
|
|
51
|
+
return last;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// 引擎重载中
|
|
55
|
+
}
|
|
56
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
57
|
+
}
|
|
58
|
+
return last;
|
|
59
|
+
}
|
|
60
|
+
/** e2e:流式请求,期望收到内容并以 [DONE] 收尾 */
|
|
61
|
+
export async function probeStream(port, key, model) {
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(`${baseUrl(port)}/v1/chat/completions`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
66
|
+
body: JSON.stringify({ model, stream: true, max_tokens: 16, messages: [{ role: 'user', content: 'Reply with: ok' }] }),
|
|
67
|
+
signal: AbortSignal.timeout(60_000),
|
|
68
|
+
});
|
|
69
|
+
if (!res.ok)
|
|
70
|
+
return { ok: false, detail: `HTTP ${res.status} ${(await res.text()).slice(0, 160)}` };
|
|
71
|
+
const text = await res.text();
|
|
72
|
+
const gotContent = /"content"\s*:\s*"[^"]/.test(text) || /"reasoning(_content)?"\s*:\s*"[^"]/.test(text);
|
|
73
|
+
const done = text.includes('[DONE]');
|
|
74
|
+
return { ok: gotContent && done, detail: gotContent ? (done ? 'ok' : 'no [DONE]') : 'no content' };
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
return { ok: false, detail: String(error.message) };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** e2e:工具调用,期望返回 get_time 的 tool_call */
|
|
81
|
+
export async function probeToolCall(port, key, model) {
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(`${baseUrl(port)}/v1/chat/completions`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
model,
|
|
88
|
+
stream: false,
|
|
89
|
+
messages: [{ role: 'user', content: 'What time is it now? Use the get_time tool.' }],
|
|
90
|
+
tools: [{ type: 'function', function: { name: 'get_time', description: 'Get the current time', parameters: { type: 'object', properties: {} } } }],
|
|
91
|
+
tool_choice: 'required',
|
|
92
|
+
}),
|
|
93
|
+
signal: AbortSignal.timeout(90_000),
|
|
94
|
+
});
|
|
95
|
+
if (!res.ok)
|
|
96
|
+
return { ok: false, detail: `HTTP ${res.status} ${(await res.text()).slice(0, 160)}` };
|
|
97
|
+
const body = (await res.json());
|
|
98
|
+
const name = body.choices?.[0]?.message?.tool_calls?.[0]?.function?.name;
|
|
99
|
+
return { ok: name === 'get_time', detail: name ? `tool_call ${name}` : 'no tool_call' };
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return { ok: false, detail: String(error.message) };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// engine/compat.ts — 挂到引擎的 openai-compatibility 上游:OpenCode Zen 与 WorkBuddy bridge
|
|
2
|
+
//
|
|
3
|
+
// 客户端看到的模型名带前缀(alias = <prefix>/<上游 id>),和订阅模型分开,也方便 dsh 里分组。
|
|
4
|
+
// bridge 在本机回环:强制 proxy-url: direct,否则全局代理会把对 127.0.0.1 的请求也转走。
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { bridgeConfigPath, loadCatalogs } from '../bridge/runtime.js';
|
|
7
|
+
import { loadSecrets } from '../secrets.js';
|
|
8
|
+
import { readJson } from '../util/fs.js';
|
|
9
|
+
export const OPENCODE_BASE = 'https://opencode.ai/zen/v1';
|
|
10
|
+
export const OPENCODE_PREFIX = 'opencode';
|
|
11
|
+
export function opencodeModelsPath(ctx) {
|
|
12
|
+
return join(ctx.paths.home, 'opencode-models.json');
|
|
13
|
+
}
|
|
14
|
+
export async function loadCompatUpstreams(ctx) {
|
|
15
|
+
const out = [];
|
|
16
|
+
const secrets = await loadSecrets(ctx);
|
|
17
|
+
const disabled = new Set((await readJson(join(ctx.paths.home, 'config.json')))?.disabledSources ?? []);
|
|
18
|
+
if (secrets.opencode?.key && !disabled.has('opencode')) {
|
|
19
|
+
const list = await readJson(opencodeModelsPath(ctx));
|
|
20
|
+
const models = (list?.models ?? []).map((m) => ({
|
|
21
|
+
name: m.id,
|
|
22
|
+
alias: `${OPENCODE_PREFIX}/${m.id}`,
|
|
23
|
+
displayName: `OpenCode · ${m.id}`,
|
|
24
|
+
dshName: /-free$|^big-pickle$/.test(m.id) ? `${m.id} · Free` : m.id,
|
|
25
|
+
group: 'opencode',
|
|
26
|
+
groupLabel: 'OpenCode Zen',
|
|
27
|
+
}));
|
|
28
|
+
if (models.length)
|
|
29
|
+
out.push({ name: 'opencode', label: 'OpenCode Zen', baseUrl: OPENCODE_BASE, apiKey: secrets.opencode.key, models });
|
|
30
|
+
}
|
|
31
|
+
const bridge = await readJson(bridgeConfigPath(ctx.paths.home));
|
|
32
|
+
if (bridge) {
|
|
33
|
+
for (const c of await loadCatalogs(ctx.paths.home)) {
|
|
34
|
+
if (!c.signedIn || !c.models.length || disabled.has(c.prefix))
|
|
35
|
+
continue;
|
|
36
|
+
out.push({
|
|
37
|
+
name: c.prefix,
|
|
38
|
+
label: c.label,
|
|
39
|
+
baseUrl: `http://127.0.0.1:${bridge.port}/${c.key}/v1`,
|
|
40
|
+
apiKey: bridge.secret,
|
|
41
|
+
direct: true,
|
|
42
|
+
models: (() => {
|
|
43
|
+
// 同组里重名(如 hy3 与 hy3-x 都叫 Hy3)就在名字后加上 id 区分
|
|
44
|
+
const count = new Map();
|
|
45
|
+
for (const m of c.models)
|
|
46
|
+
count.set(m.name || m.id, (count.get(m.name || m.id) ?? 0) + 1);
|
|
47
|
+
return c.models.map((m) => {
|
|
48
|
+
const base = m.name || m.id;
|
|
49
|
+
const name = (count.get(base) ?? 0) > 1 ? `${base} (${m.id})` : base;
|
|
50
|
+
const billing = m.billing;
|
|
51
|
+
const rate = billing?.credits?.replace(/\s*credits?$/i, '').trim();
|
|
52
|
+
const tags = [rate, ...(billing?.badges ?? [])].filter((x) => Boolean(x));
|
|
53
|
+
return {
|
|
54
|
+
name: m.id,
|
|
55
|
+
alias: `${c.prefix}/${m.id}`,
|
|
56
|
+
displayName: `${c.label} · ${base}`,
|
|
57
|
+
dshName: [name, ...tags].join(' · '),
|
|
58
|
+
...(rate && /^x?\d+(\.\d+)?$/i.test(rate) ? { rate: Number(rate.replace(/^x/i, '')) } : {}),
|
|
59
|
+
group: c.prefix,
|
|
60
|
+
groupLabel: c.label,
|
|
61
|
+
contextWindow: m.contextWindow,
|
|
62
|
+
maxTokens: m.maxTokens,
|
|
63
|
+
image: m.supportsImages,
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
})(),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/** 渲染成引擎 v8 的 api-keys.openai-compatibility 段 */
|
|
73
|
+
export function renderCompat(upstreams) {
|
|
74
|
+
return upstreams.map((u) => ({
|
|
75
|
+
name: u.name,
|
|
76
|
+
'base-url': u.baseUrl,
|
|
77
|
+
keys: [{ 'api-key': u.apiKey, ...(u.direct ? { 'proxy-url': 'direct' } : {}) }],
|
|
78
|
+
models: u.models.map((m) => ({
|
|
79
|
+
name: m.name,
|
|
80
|
+
alias: m.alias,
|
|
81
|
+
...(m.displayName ? { 'display-name': m.displayName } : {}),
|
|
82
|
+
...(m.contextWindow ? { 'max-context-length': m.contextWindow } : {}),
|
|
83
|
+
'input-modalities': m.image ? ['text', 'image'] : ['text'],
|
|
84
|
+
})),
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
/** 给 dsh 的模型清单补元数据(名字、上下文、图片) */
|
|
88
|
+
export function compatModelIndex(upstreams) {
|
|
89
|
+
const map = new Map();
|
|
90
|
+
for (const u of upstreams)
|
|
91
|
+
for (const m of u.models)
|
|
92
|
+
map.set(m.alias, m);
|
|
93
|
+
return map;
|
|
94
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// engine/config.ts — 由 config.json + keys.json 生成 engine.yaml(CLIProxyAPI v8 格式)
|
|
2
|
+
//
|
|
3
|
+
// 安全底线:api-keys 为空时引擎完全不鉴权(实测),所以没有可用 key 就拒绝生成。
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
import { DshModelError } from '../errors.js';
|
|
6
|
+
import { L } from '../i18n.js';
|
|
7
|
+
import { activeKeys } from '../keys.js';
|
|
8
|
+
import { readText, writeInPlace } from '../util/fs.js';
|
|
9
|
+
import { loadCompatUpstreams, renderCompat } from './compat.js';
|
|
10
|
+
import { ensureMgmt } from './mgmt.js';
|
|
11
|
+
const HEADER = [
|
|
12
|
+
'# GENERATED BY dsh-model — DO NOT EDIT. Run `dsh-model setup` to regenerate.',
|
|
13
|
+
'# 由 dsh-model 生成,请勿手改。需要重新生成请执行 `dsh-model setup`。',
|
|
14
|
+
].join('\n');
|
|
15
|
+
export function renderEngineConfig(ctx, config, keys, port = config.port, compat = [], mgmtSecret = '') {
|
|
16
|
+
const apiKeys = activeKeys(keys).map((k) => k.key);
|
|
17
|
+
if (apiKeys.length === 0) {
|
|
18
|
+
throw new DshModelError('no_api_keys', L('没有可用的 key,拒绝生成引擎配置(空 key 列表会让引擎不做鉴权)', 'No active keys; refusing to write engine config (an empty key list disables engine auth)'));
|
|
19
|
+
}
|
|
20
|
+
const doc = {
|
|
21
|
+
'config-version': 8,
|
|
22
|
+
server: { host: '127.0.0.1', port },
|
|
23
|
+
// 管理接口:有密钥才打开(只监听本机、不开网页控制台),给 dsh-model 自己做登录 / 开关 / 用量统计(设计 §14.4)
|
|
24
|
+
management: { 'allow-remote': false, 'secret-key': mgmtSecret, 'disable-control-panel': true },
|
|
25
|
+
access: { 'api-keys': apiKeys },
|
|
26
|
+
oauth: { 'auth-dir': ctx.paths.auth },
|
|
27
|
+
routing: { strategy: 'fill-first' },
|
|
28
|
+
...(config.proxy ? { requests: { 'proxy-url': config.proxy } } : {}),
|
|
29
|
+
...(compat.length ? { 'api-keys': { 'openai-compatibility': renderCompat(compat) } } : {}),
|
|
30
|
+
observability: {
|
|
31
|
+
logs: { 'logging-to-file': true, 'logs-max-total-size-mb': 50, 'request-log': false },
|
|
32
|
+
// 用量记录给守护进程统计 key / 来源 / 模型的速度与稳定性;它每 5 秒取一次,保留 10 分钟足够兜住停顿
|
|
33
|
+
...(mgmtSecret ? { usage: { 'usage-statistics-enabled': true, 'redis-usage-queue-retention-seconds': 600 } } : {}),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
return `${HEADER}\n${YAML.stringify(doc)}`;
|
|
37
|
+
}
|
|
38
|
+
/** 写 engine.yaml;内容没变就不动(避免引擎无谓的热重载)。返回是否写了 */
|
|
39
|
+
export async function writeEngineConfig(ctx, config, keys) {
|
|
40
|
+
const mgmt = await ensureMgmt(ctx);
|
|
41
|
+
const text = renderEngineConfig(ctx, config, keys, config.port, await loadCompatUpstreams(ctx), mgmt.secret);
|
|
42
|
+
if ((await readText(ctx.paths.engineYaml)) === text)
|
|
43
|
+
return false;
|
|
44
|
+
// 原地写:保留 inode,让引擎(含 Linux)的 fsnotify 能收到变化并热重载,谁写都行、不用重启
|
|
45
|
+
await writeInPlace(ctx.paths.engineYaml, text, { owner: ctx.owner });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// engine/install.ts — 下载、校验、解压、切换引擎版本
|
|
2
|
+
//
|
|
3
|
+
// 只信任仓库里 engine-manifest.json 的 sha256;校验不过直接失败。
|
|
4
|
+
// 版本目录 engine/versions/<v>/,current 软链指向在用版本,升级与回滚只切软链。
|
|
5
|
+
import { createWriteStream } from 'node:fs';
|
|
6
|
+
import { chmod, mkdir, readFile, readlink, rename, rm, symlink } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { Readable } from 'node:stream';
|
|
9
|
+
import { pipeline } from 'node:stream/promises';
|
|
10
|
+
import { DshModelError } from '../errors.js';
|
|
11
|
+
import { L } from '../i18n.js';
|
|
12
|
+
import { run } from '../util/exec.js';
|
|
13
|
+
import { ensureDir, exists, sha256 } from '../util/fs.js';
|
|
14
|
+
import { PKG_ROOT } from '../util/pkg.js';
|
|
15
|
+
export const ENGINE_BIN = 'cli-proxy-api';
|
|
16
|
+
export async function loadManifest() {
|
|
17
|
+
return JSON.parse(await readFile(join(PKG_ROOT, 'engine-manifest.json'), 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
/** darwin-aarch64 / linux-amd64 …(与上游发布包命名一致) */
|
|
20
|
+
export function platformTarget(platform = process.platform, arch = process.arch) {
|
|
21
|
+
const a = arch === 'arm64' ? 'aarch64' : arch === 'x64' ? 'amd64' : arch;
|
|
22
|
+
return `${platform}-${a}`;
|
|
23
|
+
}
|
|
24
|
+
export function versionBinary(ctx, version) {
|
|
25
|
+
return join(ctx.paths.versionsDir, version, ENGINE_BIN);
|
|
26
|
+
}
|
|
27
|
+
export function currentBinary(ctx) {
|
|
28
|
+
return join(ctx.paths.current, ENGINE_BIN);
|
|
29
|
+
}
|
|
30
|
+
export async function currentVersion(ctx) {
|
|
31
|
+
try {
|
|
32
|
+
const target = await readlink(ctx.paths.current);
|
|
33
|
+
return target.split('/').filter(Boolean).pop() ?? null;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** 从上游发布的 checksums.txt 取某版本的资产(engine upgrade <ver> 指定了清单外版本时用) */
|
|
40
|
+
export async function fetchUpstreamAsset(manifest, version, target) {
|
|
41
|
+
const [os, arch] = target.split('-');
|
|
42
|
+
const base = `https://github.com/${manifest.repo}/releases/download/v${version}`;
|
|
43
|
+
const file = `CLIProxyAPI_${version}_${os}_${arch}.tar.gz`;
|
|
44
|
+
const res = await fetch(`${base}/checksums.txt`);
|
|
45
|
+
if (!res.ok)
|
|
46
|
+
throw new DshModelError('engine_version_not_found', L(`找不到引擎版本 v${version}(HTTP ${res.status})`, `Engine version v${version} not found (HTTP ${res.status})`));
|
|
47
|
+
const line = (await res.text()).split('\n').find((l) => l.trim().endsWith(file));
|
|
48
|
+
const sha = line?.trim().split(/\s+/)[0];
|
|
49
|
+
if (!sha || !/^[0-9a-f]{64}$/.test(sha))
|
|
50
|
+
throw new DshModelError('engine_checksum_missing', L(`v${version} 的 checksums.txt 里没有 ${file}`, `${file} is missing from v${version} checksums.txt`));
|
|
51
|
+
return { file, url: `${base}/${file}`, sha256: sha };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 安装某版本到 versions/<v>/(已装就跳过)。返回是否真的装了。
|
|
55
|
+
* DSH_MODEL_ENGINE_ARCHIVE 可指向本地 tar.gz(离线 / 测试),同样要过 sha256 校验。
|
|
56
|
+
*/
|
|
57
|
+
export async function installVersion(ctx, version, asset) {
|
|
58
|
+
const bin = versionBinary(ctx, version);
|
|
59
|
+
if (await exists(bin))
|
|
60
|
+
return false;
|
|
61
|
+
await ensureDir(ctx.paths.tmp, { owner: ctx.owner });
|
|
62
|
+
await mkdir(ctx.paths.versionsDir, { recursive: true });
|
|
63
|
+
const archive = join(ctx.paths.tmp, asset.file);
|
|
64
|
+
const partial = join(ctx.paths.versionsDir, `${version}.partial`);
|
|
65
|
+
try {
|
|
66
|
+
const local = ctx.env.DSH_MODEL_ENGINE_ARCHIVE;
|
|
67
|
+
let data;
|
|
68
|
+
if (local) {
|
|
69
|
+
data = await readFile(local);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
const res = await fetch(asset.url);
|
|
73
|
+
if (!res.ok || !res.body)
|
|
74
|
+
throw new DshModelError('engine_download_failed', L(`下载引擎失败:HTTP ${res.status}`, `Engine download failed: HTTP ${res.status}`), asset.url);
|
|
75
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(archive, { mode: 0o600 }));
|
|
76
|
+
data = await readFile(archive);
|
|
77
|
+
}
|
|
78
|
+
const actual = sha256(data);
|
|
79
|
+
if (actual !== asset.sha256) {
|
|
80
|
+
throw new DshModelError('engine_checksum_mismatch', L(`引擎文件校验失败:期望 ${asset.sha256},实际 ${actual}`, `Engine checksum mismatch: expected ${asset.sha256}, got ${actual}`), L('文件可能被篡改或下载不完整,已中止。', 'The file may be tampered with or incomplete; aborted.'));
|
|
81
|
+
}
|
|
82
|
+
if (local) {
|
|
83
|
+
const { writeFile } = await import('node:fs/promises');
|
|
84
|
+
await writeFile(archive, data, { mode: 0o600 });
|
|
85
|
+
}
|
|
86
|
+
await rm(partial, { recursive: true, force: true });
|
|
87
|
+
await mkdir(partial, { recursive: true });
|
|
88
|
+
const tar = await run('tar', ['-xzf', archive, '-C', partial]);
|
|
89
|
+
if (tar.code !== 0)
|
|
90
|
+
throw new DshModelError('engine_extract_failed', L(`解压引擎失败:${tar.stderr.trim()}`, `Failed to extract engine: ${tar.stderr.trim()}`));
|
|
91
|
+
if (!(await exists(join(partial, ENGINE_BIN))))
|
|
92
|
+
throw new DshModelError('engine_extract_failed', L(`压缩包里没有 ${ENGINE_BIN}`, `${ENGINE_BIN} not found in archive`));
|
|
93
|
+
await chmod(join(partial, ENGINE_BIN), 0o755);
|
|
94
|
+
await rename(partial, join(ctx.paths.versionsDir, version));
|
|
95
|
+
if (ctx.owner)
|
|
96
|
+
await run('chown', ['-R', `${ctx.owner.uid}:${ctx.owner.gid}`, ctx.paths.engineDir]);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await rm(archive, { force: true });
|
|
101
|
+
await rm(partial, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** 原子切换 current 软链:先建临时软链再 rename 覆盖 */
|
|
105
|
+
export async function activateVersion(ctx, version) {
|
|
106
|
+
const tmp = `${ctx.paths.current}.tmp`;
|
|
107
|
+
await rm(tmp, { force: true });
|
|
108
|
+
await symlink(join('versions', version), tmp);
|
|
109
|
+
if (ctx.owner)
|
|
110
|
+
await run('chown', ['-h', `${ctx.owner.uid}:${ctx.owner.gid}`, tmp]);
|
|
111
|
+
await rename(tmp, ctx.paths.current);
|
|
112
|
+
}
|
|
113
|
+
/** 跑一下二进制读版本号(首行 "CLIProxyAPI Version: 8.0.13, Commit: …") */
|
|
114
|
+
export async function probeBinaryVersion(bin) {
|
|
115
|
+
const r = await run(bin, ['-h'], { timeoutMs: 10_000 });
|
|
116
|
+
const m = (r.stdout + r.stderr).match(/Version:\s*v?(\d+\.\d+\.\d+)/);
|
|
117
|
+
return m?.[1] ?? null;
|
|
118
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// engine/mgmt.ts — 引擎管理接口(v8)的客户端
|
|
2
|
+
//
|
|
3
|
+
// 管理接口只监听本机,用随机强密钥(mgmt.json,0600),只有 dsh-model 自己(CLI 与守护进程)使用;
|
|
4
|
+
// 浏览器永远拿不到它。用途:来源登录(OAuth)、停用 / 启用 / 删除凭据、读取用量记录。
|
|
5
|
+
// 接口格式对照 v8.0.13 实测(2026-10-09):
|
|
6
|
+
// GET /oauth/auth-url?provider= → {status,url,state,flow?:"device",user_code?,expires_in?}
|
|
7
|
+
// GET /oauth/status?state= → {status:"wait"|"ok"|"error", error?}
|
|
8
|
+
// POST /oauth/callback ← {provider, redirect_url}(回调式登录:用户把跳转地址贴回来)
|
|
9
|
+
// DELETE /oauth/session?state=
|
|
10
|
+
// GET /credentials → {files:[...]}
|
|
11
|
+
// PATCH /credentials/status ← {name, disabled}
|
|
12
|
+
// DELETE /credentials?name=
|
|
13
|
+
// GET /observability/usage/queue?count= → 用量记录数组(取走即删)
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { DshModelError } from '../errors.js';
|
|
17
|
+
import { L } from '../i18n.js';
|
|
18
|
+
import { readJson, writeJson } from '../util/fs.js';
|
|
19
|
+
export function mgmtPath(ctx) {
|
|
20
|
+
return join(ctx.paths.home, 'mgmt.json');
|
|
21
|
+
}
|
|
22
|
+
export async function loadMgmt(ctx) {
|
|
23
|
+
return readJson(mgmtPath(ctx));
|
|
24
|
+
}
|
|
25
|
+
/** 没有就生成;引擎配置据此打开管理接口 */
|
|
26
|
+
export async function ensureMgmt(ctx) {
|
|
27
|
+
const existing = await loadMgmt(ctx);
|
|
28
|
+
if (existing?.secret)
|
|
29
|
+
return existing;
|
|
30
|
+
const cfg = { secret: `dshg_${randomBytes(32).toString('base64url')}` };
|
|
31
|
+
await writeJson(mgmtPath(ctx), cfg, { owner: ctx.owner });
|
|
32
|
+
return cfg;
|
|
33
|
+
}
|
|
34
|
+
export class Mgmt {
|
|
35
|
+
port;
|
|
36
|
+
secret;
|
|
37
|
+
constructor(port, secret) {
|
|
38
|
+
this.port = port;
|
|
39
|
+
this.secret = secret;
|
|
40
|
+
}
|
|
41
|
+
static async forCtx(ctx, port) {
|
|
42
|
+
const cfg = await loadMgmt(ctx);
|
|
43
|
+
if (!cfg)
|
|
44
|
+
throw new DshModelError('mgmt_not_configured', L('引擎管理接口还没配置(dsh-model setup)', 'Engine management API not configured (dsh-model setup)'));
|
|
45
|
+
return new Mgmt(port, cfg.secret);
|
|
46
|
+
}
|
|
47
|
+
async call(method, path, body, timeoutMs = 15_000) {
|
|
48
|
+
const res = await fetch(`http://127.0.0.1:${this.port}/v8/management${path}`, {
|
|
49
|
+
method,
|
|
50
|
+
headers: { Authorization: `Bearer ${this.secret}`, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
|
51
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
52
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
53
|
+
});
|
|
54
|
+
const text = await res.text();
|
|
55
|
+
let data = undefined;
|
|
56
|
+
try {
|
|
57
|
+
data = text ? JSON.parse(text) : undefined;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// 非 JSON
|
|
61
|
+
}
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const msg = data?.error ?? text.slice(0, 200);
|
|
64
|
+
throw new DshModelError('mgmt_error', L(`引擎管理接口 ${method} ${path} 失败(HTTP ${res.status}):${msg}`, `Engine management ${method} ${path} failed (HTTP ${res.status}): ${msg}`));
|
|
65
|
+
}
|
|
66
|
+
return data;
|
|
67
|
+
}
|
|
68
|
+
authUrl(provider) {
|
|
69
|
+
return this.call('GET', `/oauth/auth-url?provider=${encodeURIComponent(provider)}`);
|
|
70
|
+
}
|
|
71
|
+
oauthStatus(state) {
|
|
72
|
+
// 未知 state 也返回 200 + {status:"error"},不当异常
|
|
73
|
+
return this.call('GET', `/oauth/status?state=${encodeURIComponent(state)}`);
|
|
74
|
+
}
|
|
75
|
+
oauthCallback(provider, redirectUrl) {
|
|
76
|
+
return this.call('POST', '/oauth/callback', { provider, redirect_url: redirectUrl });
|
|
77
|
+
}
|
|
78
|
+
cancel(state) {
|
|
79
|
+
return this.call('DELETE', `/oauth/session?state=${encodeURIComponent(state)}`);
|
|
80
|
+
}
|
|
81
|
+
async credentials() {
|
|
82
|
+
const r = await this.call('GET', '/credentials');
|
|
83
|
+
return r.files ?? [];
|
|
84
|
+
}
|
|
85
|
+
async credentialModels(name) {
|
|
86
|
+
const r = await this.call('GET', `/credentials/models?name=${encodeURIComponent(name)}`);
|
|
87
|
+
return r.models ?? [];
|
|
88
|
+
}
|
|
89
|
+
setDisabled(name, disabled) {
|
|
90
|
+
return this.call('PATCH', '/credentials/status', { name, disabled });
|
|
91
|
+
}
|
|
92
|
+
deleteCredential(name) {
|
|
93
|
+
return this.call('DELETE', `/credentials?name=${encodeURIComponent(name)}`);
|
|
94
|
+
}
|
|
95
|
+
async usageQueue(count = 1000) {
|
|
96
|
+
const r = await this.call('GET', `/observability/usage/queue?count=${count}`);
|
|
97
|
+
return Array.isArray(r) ? r : [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// engine/sourcemap.ts — 引擎原生(订阅)模型属于哪个来源
|
|
2
|
+
//
|
|
3
|
+
// /v1/models 里订阅模型没有前缀(如 kimi-k2.6,owned_by=moonshot),靠管理接口
|
|
4
|
+
// GET /credentials/models?name= 逐个凭据取它能服务的模型,映射回来源(kimi / codex / claude …)。
|
|
5
|
+
// 同时给出"没有可用订阅"的来源:用量接口判定的 noAccess,或引擎记录的 payment_required 冷却。
|
|
6
|
+
import { usageFilePath } from '../daemon/usage.js';
|
|
7
|
+
import { SOURCES, credsFor, paymentRequired } from '../sources.js';
|
|
8
|
+
import { readJson } from '../util/fs.js';
|
|
9
|
+
import { Mgmt } from './mgmt.js';
|
|
10
|
+
export async function subscriptionMap(ctx, port) {
|
|
11
|
+
const models = new Map();
|
|
12
|
+
const noAccess = new Set();
|
|
13
|
+
const usage = (await readJson(usageFilePath(ctx.paths.home)).catch(() => null)) ?? [];
|
|
14
|
+
for (const u of usage)
|
|
15
|
+
if (u.noAccess)
|
|
16
|
+
noAccess.add(u.source);
|
|
17
|
+
let mgmt;
|
|
18
|
+
try {
|
|
19
|
+
mgmt = await Mgmt.forCtx(ctx, port);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return { models, noAccess };
|
|
23
|
+
}
|
|
24
|
+
let creds = [];
|
|
25
|
+
try {
|
|
26
|
+
creds = await mgmt.credentials();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return { models, noAccess };
|
|
30
|
+
}
|
|
31
|
+
for (const def of SOURCES.filter((s) => s.kind === 'engine')) {
|
|
32
|
+
const mine = credsFor(creds, def).filter((c) => !c.disabled);
|
|
33
|
+
if (mine.length && mine.every(paymentRequired))
|
|
34
|
+
noAccess.add(def.id);
|
|
35
|
+
for (const c of mine) {
|
|
36
|
+
try {
|
|
37
|
+
for (const m of await mgmt.credentialModels(c.name)) {
|
|
38
|
+
if (!models.has(m.id))
|
|
39
|
+
models.set(m.id, { source: def.id, label: def.label, ...(m.display_name ? { displayName: m.display_name } : {}) });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// 单个凭据取不到就跳过
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { models, noAccess };
|
|
48
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// errors.ts — 带稳定错误码的错误。code 不随语言变化(--json 输出与测试依赖它),message 跟随语言
|
|
2
|
+
export class DshModelError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
hint;
|
|
5
|
+
constructor(code, message, hint) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'DshModelError';
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.hint = hint;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function isDshModelError(error) {
|
|
13
|
+
return error instanceof DshModelError;
|
|
14
|
+
}
|
package/dist/i18n.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// i18n.ts — 中英双语输出
|
|
2
|
+
//
|
|
3
|
+
// 写法:L('中文', 'English'),两种文字挨着写,改一处时另一处就在眼前(与 dsh-vps 一致)。
|
|
4
|
+
// 语言优先级:--lang > DSH_MODEL_LANG > LC_ALL / LC_MESSAGES / LANG > Intl > zh
|
|
5
|
+
let current = '';
|
|
6
|
+
/** 'zh' / 'en';认不出来返回 '' */
|
|
7
|
+
export function normalizeLang(value) {
|
|
8
|
+
const s = String(value ?? '').trim().toLowerCase();
|
|
9
|
+
if (!s)
|
|
10
|
+
return '';
|
|
11
|
+
return s === 'zh' || s.startsWith('zh-') || s.startsWith('zh_') ? 'zh' : 'en';
|
|
12
|
+
}
|
|
13
|
+
/** 系统语言(LANG 之类,或 Intl 给的) */
|
|
14
|
+
export function systemLang(env = process.env) {
|
|
15
|
+
const fromEnv = env.LC_ALL || env.LC_MESSAGES || env.LANG || '';
|
|
16
|
+
if (fromEnv && !/^(C|POSIX)(\.|$)/i.test(fromEnv))
|
|
17
|
+
return normalizeLang(fromEnv) || 'zh';
|
|
18
|
+
// 服务器上常见 LANG=C / C.UTF-8 或干脆没设:本项目以中文为主,这时默认中文(英文用 --lang en 或 DSH_MODEL_LANG=en)
|
|
19
|
+
if (fromEnv || process.platform !== 'darwin')
|
|
20
|
+
return 'zh';
|
|
21
|
+
try {
|
|
22
|
+
return normalizeLang(Intl.DateTimeFormat().resolvedOptions().locale) || 'zh';
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return 'zh';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** 在命令行解析完后调用一次;flag 为 --lang 的值 */
|
|
29
|
+
export function initLang(flag, env = process.env) {
|
|
30
|
+
current = normalizeLang(flag) || normalizeLang(env.DSH_MODEL_LANG) || systemLang(env);
|
|
31
|
+
return current;
|
|
32
|
+
}
|
|
33
|
+
export function lang() {
|
|
34
|
+
return current || initLang();
|
|
35
|
+
}
|
|
36
|
+
/** 中英两份文字,按当前语言挑一份 */
|
|
37
|
+
export function L(zh, en) {
|
|
38
|
+
return lang() === 'en' ? en : zh;
|
|
39
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// integrations/dshplugin.ts — 把 dsh-model 自己装进 dsh 当插件(设置 → dsh-model 页面,设计 §14.5)
|
|
2
|
+
//
|
|
3
|
+
// 用 dsh 自己的插件管理:dsh plugin --profile <p> add <本包目录>。pnpm 对目录用 link:,
|
|
4
|
+
// 所以以后 npm 更新 dsh-model,插件跟着更新,不用重装。卸载时只移除 dsh-model 装的那份。
|
|
5
|
+
import { dshPlugin, profileHasDependency } from '../dsh/cli.js';
|
|
6
|
+
import { resolveProfile } from '../dsh/locate.js';
|
|
7
|
+
import { DshModelError } from '../errors.js';
|
|
8
|
+
import { L } from '../i18n.js';
|
|
9
|
+
import { PKG_ROOT } from '../util/pkg.js';
|
|
10
|
+
import { ok, skip } from '../util/output.js';
|
|
11
|
+
export const PLUGIN_NAME = 'dsh-model';
|
|
12
|
+
export async function installDshPlugin(ctx, all) {
|
|
13
|
+
const profile = await resolveProfile(ctx, all.config.dsh.profile);
|
|
14
|
+
const existing = await profileHasDependency(ctx, profile, PLUGIN_NAME);
|
|
15
|
+
if (existing) {
|
|
16
|
+
skip(L(`dsh 插件已安装(${existing})`, `dsh plugin already installed (${existing})`));
|
|
17
|
+
if (!all.state.plugins?.some((p) => p.name === PLUGIN_NAME)) {
|
|
18
|
+
all.state.plugins = [...(all.state.plugins ?? []), { name: PLUGIN_NAME, version: existing, installedByUs: existing.startsWith('link:'), installedAt: new Date().toISOString() }];
|
|
19
|
+
}
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const code = await dshPlugin(ctx, profile, ['add', PKG_ROOT, '--config.strict-dep-builds=false'], all.config.proxy);
|
|
23
|
+
const installed = await profileHasDependency(ctx, profile, PLUGIN_NAME);
|
|
24
|
+
if (code !== 0 || !installed) {
|
|
25
|
+
throw new DshModelError('plugin_install_failed', L(`把 dsh-model 装进 dsh 失败(退出码 ${code})`, `Failed to install dsh-model into dsh (exit code ${code})`), L('可以在 dsh 的插件页手动安装', 'You can install it from the dsh Plugins page'));
|
|
26
|
+
}
|
|
27
|
+
all.state.plugins = [...(all.state.plugins ?? []).filter((p) => p.name !== PLUGIN_NAME), { name: PLUGIN_NAME, version: installed, installedByUs: true, installedAt: new Date().toISOString() }];
|
|
28
|
+
ok(L('dsh 插件已安装:在 dsh「设置 → dsh-model」里管理来源、用量和 key(刷新一下页面)', 'dsh plugin installed: manage sources, usage and keys in dsh Settings → dsh-model (reload the page)'));
|
|
29
|
+
}
|
|
30
|
+
export async function removeDshPlugin(ctx, all) {
|
|
31
|
+
const rec = all.state.plugins?.find((p) => p.name === PLUGIN_NAME);
|
|
32
|
+
if (!rec?.installedByUs)
|
|
33
|
+
return false;
|
|
34
|
+
const profile = await resolveProfile(ctx, all.config.dsh.profile);
|
|
35
|
+
if (await profileHasDependency(ctx, profile, PLUGIN_NAME)) {
|
|
36
|
+
const code = await dshPlugin(ctx, profile, ['remove', PLUGIN_NAME], all.config.proxy);
|
|
37
|
+
if (code !== 0)
|
|
38
|
+
throw new DshModelError('plugin_remove_failed', L(`从 dsh 移除 dsh-model 插件失败(退出码 ${code})`, `Failed to remove the dsh-model plugin from dsh (exit code ${code})`));
|
|
39
|
+
}
|
|
40
|
+
all.state.plugins = (all.state.plugins ?? []).filter((p) => p.name !== PLUGIN_NAME);
|
|
41
|
+
return true;
|
|
42
|
+
}
|