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,136 @@
|
|
|
1
|
+
// bridge/runtime.ts — bridge 的运行时:按本机装了哪些 WorkBuddy App 建 runtime,刷新目录并落盘
|
|
2
|
+
//
|
|
3
|
+
// 目录落在 $DSH_MODEL_HOME/workbuddy/catalog-<key>.json,CLI 据此生成引擎的 openai-compatibility 上游与 dsh 的模型清单。
|
|
4
|
+
// 刷新的令牌只存 bridge 自己的副本(移植代码的 WorkBuddyCredentialStore 负责),从不改写 App 的凭据文件。
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { atomicWrite, exists, readJson, writeJson } from '../util/fs.js';
|
|
9
|
+
import { WorkBuddyCredentialStore } from './workbuddy/auth.js';
|
|
10
|
+
import { FALLBACK_WORKBUDDY_AI_MODELS, FALLBACK_WORKBUDDY_MODELS, WorkBuddyCatalog } from './workbuddy/catalog.js';
|
|
11
|
+
import { WorkBuddyUpstreamClient } from './workbuddy/upstream.js';
|
|
12
|
+
import { WORKBUDDY_VARIANTS } from './workbuddy/variants.js';
|
|
13
|
+
import { atRestKeyProviderFor } from './workbuddy/desktop-credential-protection.js';
|
|
14
|
+
export const BRIDGE_DEFAULT_PORT = 18317;
|
|
15
|
+
/** 产品 → 路由前缀 / 引擎里的模型前缀 */
|
|
16
|
+
export const VARIANT_KEYS = {
|
|
17
|
+
workbuddy: { key: 'cn', prefix: 'workbuddy' },
|
|
18
|
+
'workbuddy-ai': { key: 'ai', prefix: 'workbuddy-ai' },
|
|
19
|
+
};
|
|
20
|
+
export function bridgeDir(home) {
|
|
21
|
+
return join(home, 'workbuddy');
|
|
22
|
+
}
|
|
23
|
+
export function bridgeConfigPath(home) {
|
|
24
|
+
return join(home, 'bridge.json');
|
|
25
|
+
}
|
|
26
|
+
export function catalogPath(home, key) {
|
|
27
|
+
return join(bridgeDir(home), `catalog-${key}.json`);
|
|
28
|
+
}
|
|
29
|
+
export async function loadBridgeConfig(home) {
|
|
30
|
+
return readJson(bridgeConfigPath(home));
|
|
31
|
+
}
|
|
32
|
+
export async function ensureBridgeConfig(home, port, owner) {
|
|
33
|
+
const existing = await loadBridgeConfig(home);
|
|
34
|
+
if (existing && existing.port === port)
|
|
35
|
+
return existing;
|
|
36
|
+
const cfg = { port, secret: existing?.secret ?? `dshb_${randomBytes(32).toString('base64url')}` };
|
|
37
|
+
await writeJson(bridgeConfigPath(home), cfg, { owner });
|
|
38
|
+
return cfg;
|
|
39
|
+
}
|
|
40
|
+
/** 本机装了哪几个 WorkBuddy App(看 Electron 可执行文件) */
|
|
41
|
+
export async function installedVariants() {
|
|
42
|
+
// 测试用:不去发现本机真实的 WorkBuddy App(免得读到真实凭据)
|
|
43
|
+
if (process.platform !== 'darwin' || process.env.DSH_MODEL_NO_APP_DISCOVERY === '1')
|
|
44
|
+
return [];
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const v of WORKBUDDY_VARIANTS) {
|
|
47
|
+
const p = v.electron?.macOS?.defaultPath;
|
|
48
|
+
if (!p)
|
|
49
|
+
continue;
|
|
50
|
+
if ((await exists(p)) || (await exists(join(homedir(), p))))
|
|
51
|
+
out.push(v);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* bridge 要服务的产品:装了桌面 App 的(macOS),或用 dsh-model workbuddy login 登录过的(任何平台,含 VPS)。
|
|
57
|
+
*/
|
|
58
|
+
export async function availableVariants(home) {
|
|
59
|
+
const apps = await installedVariants();
|
|
60
|
+
const out = [...apps];
|
|
61
|
+
for (const v of WORKBUDDY_VARIANTS) {
|
|
62
|
+
if (!out.includes(v) && (await exists(join(bridgeDir(home), v.ownFilename))))
|
|
63
|
+
out.push(v);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/** 这个产品有没有 dsh-model 自己登录的那份令牌 */
|
|
68
|
+
export async function hasOwnLogin(home, variant) {
|
|
69
|
+
return exists(join(bridgeDir(home), variant.ownFilename));
|
|
70
|
+
}
|
|
71
|
+
export async function loadCatalogs(home) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const { key } of Object.values(VARIANT_KEYS)) {
|
|
74
|
+
const c = await readJson(catalogPath(home, key));
|
|
75
|
+
if (c)
|
|
76
|
+
out.push(c);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
export function buildRuntime(variant, home) {
|
|
81
|
+
const ids = VARIANT_KEYS[variant.id] ?? { key: variant.id, prefix: variant.id };
|
|
82
|
+
const client = new WorkBuddyUpstreamClient();
|
|
83
|
+
// 密钥提供器要带 macOS 的 App 发现(mdfind / 默认路径),否则不会去找 WorkBuddy 自带的 Electron(实测)
|
|
84
|
+
const store = new WorkBuddyCredentialStore({
|
|
85
|
+
variant,
|
|
86
|
+
keyProvider: atRestKeyProviderFor(variant),
|
|
87
|
+
refresh: (credential) => client.refreshToken(credential),
|
|
88
|
+
ownPath: join(bridgeDir(home), variant.ownFilename),
|
|
89
|
+
// 非 macOS 没有可解密的桌面凭据(CodeBuddy CLI 在 Linux 上也加密存储,密钥来源未知):只用 dsh-model 自己登录的那份
|
|
90
|
+
...(process.platform === 'darwin' ? {} : { desktopPath: join(bridgeDir(home), '.no-desktop-credential') }),
|
|
91
|
+
});
|
|
92
|
+
const catalog = new WorkBuddyCatalog(variant.id === 'workbuddy-ai' ? FALLBACK_WORKBUDDY_AI_MODELS : FALLBACK_WORKBUDDY_MODELS);
|
|
93
|
+
// 没登录时不对外报模型:报了也只会失败
|
|
94
|
+
catalog.setVisible(false);
|
|
95
|
+
return { key: ids.key, prefix: ids.prefix, label: variant.displayName, variant, store, client, catalog, wbCatalog: catalog, wbClient: client, wbStore: store };
|
|
96
|
+
}
|
|
97
|
+
/** 刷新一个产品的登录状态与目录,并落盘。返回目录是否有变化 */
|
|
98
|
+
export async function refreshCatalog(home, rt, owner) {
|
|
99
|
+
const before = await readJson(catalogPath(home, rt.key));
|
|
100
|
+
const file = { variant: rt.variant.id, label: rt.label, key: rt.key, prefix: rt.prefix, signedIn: false, updatedAt: new Date().toISOString(), models: [] };
|
|
101
|
+
try {
|
|
102
|
+
const status = await rt.wbStore.status();
|
|
103
|
+
if (status.state === 'signed-in') {
|
|
104
|
+
const credential = await rt.wbStore.resolve();
|
|
105
|
+
let models;
|
|
106
|
+
try {
|
|
107
|
+
models = await rt.wbClient.fetchModels(credential, AbortSignal.timeout(20_000));
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
// 目录接口失败不等于不能聊:用内置兜底目录(移植代码自带)
|
|
111
|
+
models = rt.wbCatalog.fallback();
|
|
112
|
+
file.error = `catalog: ${String(error.message ?? error).slice(0, 200)}`;
|
|
113
|
+
}
|
|
114
|
+
rt.wbCatalog.set(models);
|
|
115
|
+
rt.wbCatalog.setVisible(true);
|
|
116
|
+
file.signedIn = true;
|
|
117
|
+
if (status.nickname)
|
|
118
|
+
file.nickname = status.nickname;
|
|
119
|
+
file.models = [...rt.wbCatalog.current()];
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
rt.wbCatalog.setVisible(false);
|
|
123
|
+
if (status.reason)
|
|
124
|
+
file.error = status.reason;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
rt.wbCatalog.setVisible(false);
|
|
129
|
+
file.error = String(error.message ?? error).slice(0, 300);
|
|
130
|
+
}
|
|
131
|
+
// 倍率与优惠(限时免费、夜间折扣)会随时间变:也算变化,让 dsh 里的名字跟着更新
|
|
132
|
+
const sig = (c) => JSON.stringify(c ? { s: c.signedIn, m: c.models.map((m) => [m.id, m.name, m.contextWindow, m.maxTokens, m.supportsImages, m.billing ?? null]) } : null);
|
|
133
|
+
const changed = sig(before) !== sig(file);
|
|
134
|
+
await atomicWrite(catalogPath(home, rt.key), JSON.stringify(file, null, 2) + '\n', { owner });
|
|
135
|
+
return changed;
|
|
136
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// bridge/server.ts — dsh-model 的 WorkBuddy bridge:把 WorkBuddy 私有接口变成本机 OpenAI 兼容接口
|
|
2
|
+
//
|
|
3
|
+
// 只绑 127.0.0.1,要求内部密钥(引擎的 openai-compatibility 上游带着它来);Host / Origin 必须是回环,挡 DNS rebinding。
|
|
4
|
+
// 路由按产品分前缀:/cn/v1/...(WorkBuddy 国内版)、/ai/v1/...(WorkBuddy AI 国际版)。
|
|
5
|
+
// 协议细节(凭据解密、客户端身份、刷新、目录)来自移植的 ./workbuddy/*(MIT,见其 LICENSE)。
|
|
6
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
7
|
+
import { initLang } from '../i18n.js';
|
|
8
|
+
import { createServer } from 'node:http';
|
|
9
|
+
import { Readable } from 'node:stream';
|
|
10
|
+
import { aggregateSse } from './aggregate.js';
|
|
11
|
+
import { hostIsLoopback, originIsLoopback } from './workbuddy/loopback.js';
|
|
12
|
+
import { extractDisplayErrorMessage, prepareChatBody } from './workbuddy/upstream.js';
|
|
13
|
+
const BODY_LIMIT = 64 * 1024 * 1024;
|
|
14
|
+
const KIND_STATUS = {
|
|
15
|
+
hard_credit: 402,
|
|
16
|
+
soft_rate: 429,
|
|
17
|
+
session_dead: 401,
|
|
18
|
+
not_found: 502,
|
|
19
|
+
server: 502,
|
|
20
|
+
client: 400,
|
|
21
|
+
};
|
|
22
|
+
function json(res, status, body) {
|
|
23
|
+
const payload = JSON.stringify(body);
|
|
24
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
25
|
+
res.end(payload);
|
|
26
|
+
}
|
|
27
|
+
function oaiError(res, status, code, message) {
|
|
28
|
+
json(res, status, { error: { message, type: code, code } });
|
|
29
|
+
}
|
|
30
|
+
function readBody(req) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const chunks = [];
|
|
33
|
+
let size = 0;
|
|
34
|
+
req.on('data', (c) => {
|
|
35
|
+
size += c.length;
|
|
36
|
+
if (size > BODY_LIMIT) {
|
|
37
|
+
reject(new Error('request body too large'));
|
|
38
|
+
req.destroy();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
chunks.push(c);
|
|
42
|
+
});
|
|
43
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
44
|
+
req.on('error', reject);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export function createBridge(options) {
|
|
48
|
+
const expected = Buffer.from(options.secret);
|
|
49
|
+
// variants 可能在运行中增加(新登录了一个产品),每次请求现查
|
|
50
|
+
const log = options.log ?? (() => { });
|
|
51
|
+
const authorized = (req) => {
|
|
52
|
+
const m = /^Bearer\s+(.+)$/i.exec(String(req.headers.authorization ?? '').trim());
|
|
53
|
+
if (!m)
|
|
54
|
+
return false;
|
|
55
|
+
const got = Buffer.from(m[1]);
|
|
56
|
+
return got.length === expected.length && timingSafeEqual(got, expected);
|
|
57
|
+
};
|
|
58
|
+
const server = createServer((req, res) => {
|
|
59
|
+
void handle(req, res).catch((error) => {
|
|
60
|
+
log(`bridge: unhandled ${String(error)}`);
|
|
61
|
+
if (!res.headersSent)
|
|
62
|
+
oaiError(res, 500, 'internal', 'bridge internal error');
|
|
63
|
+
else
|
|
64
|
+
res.end();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
async function handle(req, res) {
|
|
68
|
+
if (!hostIsLoopback(req.headers.host))
|
|
69
|
+
return oaiError(res, 403, 'host_not_allowed', 'Host must be loopback');
|
|
70
|
+
if (!originIsLoopback(req.headers.origin))
|
|
71
|
+
return oaiError(res, 403, 'origin_not_allowed', 'Origin must be loopback');
|
|
72
|
+
if (!authorized(req))
|
|
73
|
+
return oaiError(res, 401, 'unauthorized', 'missing or invalid bearer');
|
|
74
|
+
const fullUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
75
|
+
const url = fullUrl.pathname.replace(/\/+$/, '') || '/';
|
|
76
|
+
if (req.method === 'GET' && url === '/healthz')
|
|
77
|
+
return json(res, 200, { ok: true });
|
|
78
|
+
if (req.method === 'GET' && url === '/status') {
|
|
79
|
+
const out = {};
|
|
80
|
+
for (const v of options.variants) {
|
|
81
|
+
const st = await v.store.status().catch((e) => ({ signedIn: false, error: String(e) }));
|
|
82
|
+
out[v.key] = { label: v.label, models: v.catalog.current().length, auth: st };
|
|
83
|
+
}
|
|
84
|
+
return json(res, 200, out);
|
|
85
|
+
}
|
|
86
|
+
if (url.startsWith('/control/') && options.onControl) {
|
|
87
|
+
let body = undefined;
|
|
88
|
+
if (req.method !== 'GET' && req.method !== 'DELETE') {
|
|
89
|
+
const raw = (await readBody(req)).toString('utf8');
|
|
90
|
+
try {
|
|
91
|
+
body = raw ? JSON.parse(raw) : undefined;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return oaiError(res, 400, 'invalid_json', 'request body is not JSON');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// 提示语跟着调用方的语言(dsh 页面 / CLI 的 --lang);守护进程自己由 launchd 启动时没有 LANG
|
|
98
|
+
const lang = String(req.headers['x-dsh-model-lang'] ?? '');
|
|
99
|
+
if (lang === 'zh' || lang === 'en')
|
|
100
|
+
initLang(lang);
|
|
101
|
+
const r = await options.onControl(req.method ?? 'GET', url.slice('/control'.length), body, fullUrl.searchParams);
|
|
102
|
+
return json(res, r.status, r.body);
|
|
103
|
+
}
|
|
104
|
+
if (req.method === 'POST' && url === '/refresh') {
|
|
105
|
+
if (!options.onRefresh)
|
|
106
|
+
return oaiError(res, 404, 'not_found', 'refresh not supported');
|
|
107
|
+
return json(res, 200, { ok: true, result: await options.onRefresh() });
|
|
108
|
+
}
|
|
109
|
+
const m = /^\/([a-z]+)\/v1\/(models|chat\/completions)$/.exec(url);
|
|
110
|
+
const variant = m ? options.variants.find((v) => v.key === m[1]) : undefined;
|
|
111
|
+
if (!m || !variant)
|
|
112
|
+
return oaiError(res, 404, 'not_found', `no such route: ${req.method} ${url}`);
|
|
113
|
+
if (m[2] === 'models' && req.method === 'GET') {
|
|
114
|
+
return json(res, 200, { object: 'list', data: variant.catalog.current().map((model) => ({ id: model.id, object: 'model', created: 0, owned_by: 'workbuddy' })) });
|
|
115
|
+
}
|
|
116
|
+
if (m[2] === 'chat/completions' && req.method === 'POST')
|
|
117
|
+
return chat(variant, req, res);
|
|
118
|
+
return oaiError(res, 405, 'method_not_allowed', `${req.method} ${url}`);
|
|
119
|
+
}
|
|
120
|
+
async function chat(variant, req, res) {
|
|
121
|
+
if (!String(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json'))
|
|
122
|
+
return oaiError(res, 415, 'unsupported_media_type', 'Content-Type must be application/json');
|
|
123
|
+
let credential;
|
|
124
|
+
try {
|
|
125
|
+
credential = await variant.store.resolve();
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
return oaiError(res, 401, 'not_signed_in', `${variant.label}: ${String(error.message ?? error)} — sign in again in the ${variant.label} app`);
|
|
129
|
+
}
|
|
130
|
+
const raw = (await readBody(req)).toString('utf8');
|
|
131
|
+
// OpenAI 规范:不写 stream 就是非流式。曾按 "!== false" 判断,没写 stream 的请求被当成流式,
|
|
132
|
+
// 把上游的 ": heartbeat" 原样透传给引擎,引擎既解析不了 JSON,也拿不到 token 用量(VPS 实测)
|
|
133
|
+
let wantsStream = false;
|
|
134
|
+
try {
|
|
135
|
+
wantsStream = JSON.parse(raw).stream === true;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return oaiError(res, 400, 'invalid_json', 'request body is not JSON');
|
|
139
|
+
}
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
res.on('close', () => controller.abort());
|
|
142
|
+
const result = await variant.client.chatStream(credential, prepareChatBody(raw), controller.signal);
|
|
143
|
+
if (!result.ok) {
|
|
144
|
+
const detail = extractDisplayErrorMessage(result.message) ?? result.message.slice(0, 400);
|
|
145
|
+
return oaiError(res, KIND_STATUS[result.kind], result.kind, `${variant.label} upstream ${result.kind} (http ${result.status}): ${detail}`);
|
|
146
|
+
}
|
|
147
|
+
const body = Readable.fromWeb(result.response.body);
|
|
148
|
+
if (!wantsStream) {
|
|
149
|
+
let text = '';
|
|
150
|
+
for await (const chunk of body)
|
|
151
|
+
text += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
|
|
152
|
+
return json(res, 200, aggregateSse(text));
|
|
153
|
+
}
|
|
154
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
|
|
155
|
+
let sawDone = false;
|
|
156
|
+
body.on('data', (c) => {
|
|
157
|
+
if (c.includes('[DONE]'))
|
|
158
|
+
sawDone = true;
|
|
159
|
+
});
|
|
160
|
+
body.on('error', (error) => {
|
|
161
|
+
log(`bridge: ${variant.label} stream failed mid-flight: ${String(error)}`);
|
|
162
|
+
// 上游中途断开:补发 [DONE],让客户端正常收尾而不是挂住
|
|
163
|
+
if (!sawDone && res.writable)
|
|
164
|
+
res.end('data: [DONE]\n\n');
|
|
165
|
+
});
|
|
166
|
+
body.pipe(res);
|
|
167
|
+
}
|
|
168
|
+
const ready = new Promise((resolve, reject) => {
|
|
169
|
+
server.once('error', reject);
|
|
170
|
+
server.listen(options.port, options.host ?? '127.0.0.1', () => {
|
|
171
|
+
const addr = server.address();
|
|
172
|
+
resolve(typeof addr === 'object' && addr ? addr.port : options.port);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
return {
|
|
176
|
+
server,
|
|
177
|
+
ready,
|
|
178
|
+
close: () => new Promise((resolve) => {
|
|
179
|
+
server.close(() => resolve());
|
|
180
|
+
server.closeAllConnections();
|
|
181
|
+
}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// _compat.ts — 移植代码原本依赖 dsh 的 @deepseek-ai/dsh-atomic-write 与 @deepseek-ai/dsh-home-paths,这里给出 dsh-model 的等价实现。
|
|
2
|
+
// resolveDshHome() 在移植代码里只用来放自己的状态文件(凭据副本、版本缓存),所以指向 bridge 的状态目录,而不是 dsh 的 DSH_HOME。
|
|
3
|
+
import { open, rm } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { atomicWrite, ensureDir } from '../../util/fs.js';
|
|
7
|
+
/** bridge 的状态目录:$DSH_MODEL_HOME/workbuddy(默认 ~/.dsh-model/workbuddy) */
|
|
8
|
+
export function resolveDshHome() {
|
|
9
|
+
const home = process.env.DSH_MODEL_HOME ? resolve(process.env.DSH_MODEL_HOME) : join(homedir(), '.dsh-model');
|
|
10
|
+
return join(home, 'workbuddy');
|
|
11
|
+
}
|
|
12
|
+
export async function writeFileAtomic(file, data, options = {}) {
|
|
13
|
+
await ensureDir(dirname(file), { mode: options.dirMode ?? 0o700 });
|
|
14
|
+
await atomicWrite(file, data, { mode: options.mode ?? 0o600 });
|
|
15
|
+
}
|
|
16
|
+
/** 跨进程文件锁(bridge 与 CLI 可能同时刷新令牌):<file>.lock 用 O_EXCL 抢占,过期 30s 视为遗留 */
|
|
17
|
+
export async function withFileLock(file, fn) {
|
|
18
|
+
const lock = `${file}.lock`;
|
|
19
|
+
await ensureDir(dirname(lock), { mode: 0o700 });
|
|
20
|
+
const deadline = Date.now() + 15_000;
|
|
21
|
+
for (;;) {
|
|
22
|
+
try {
|
|
23
|
+
const handle = await open(lock, 'wx', 0o600);
|
|
24
|
+
await handle.writeFile(String(Date.now()));
|
|
25
|
+
await handle.close();
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code !== 'EEXIST')
|
|
30
|
+
throw error;
|
|
31
|
+
try {
|
|
32
|
+
const { stat } = await import('node:fs/promises');
|
|
33
|
+
if (Date.now() - (await stat(lock)).mtimeMs > 30_000) {
|
|
34
|
+
await rm(lock, { force: true });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (Date.now() > deadline)
|
|
42
|
+
throw new Error(`timed out waiting for lock ${lock}`);
|
|
43
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return await fn();
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await rm(lock, { force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Ported from dsh-workbuddy-connect v0.7.1 (commit 390582e04da4e8e8bf746d71997440e4b716ec47),
|
|
2
|
+
// https://github.com/corrinehu/dsh-workbuddy-connect — MIT License, Copyright (c) 2026 Corrine Hu.
|
|
3
|
+
// See LICENSE.dsh-workbuddy-connect in this directory. Changes: imports adapted to dsh-model (./_compat.js).
|
|
4
|
+
/**
|
|
5
|
+
* The international desktop app's version, used as the `/v3/config` UA.
|
|
6
|
+
*
|
|
7
|
+
* The App-shaped catalog is served only to a User-Agent carrying the product
|
|
8
|
+
* name (see `docs/workbuddy-ai-international-research-2026-09-11.md` §2.7).
|
|
9
|
+
* That document's conclusion recommended the space form `WorkBuddy AI/<v>`;
|
|
10
|
+
* re-measured on 2026-09-11 the *space* form is rejected (HTTP 400, code
|
|
11
|
+
* 12403) while the terse `WorkBuddyAI/<v>` form — with or without the space
|
|
12
|
+
* removed — returns the 21-model App document. The UA is therefore built from
|
|
13
|
+
* the form verified in code, not from the earlier prose.
|
|
14
|
+
*
|
|
15
|
+
* The version feeds the international catalog UA and the chat identity's
|
|
16
|
+
* `X-IDE-Version`: a missing App, an unreadable plist, or a bad cached value
|
|
17
|
+
* degrades to the last saved value and finally to a compiled-in constant, and
|
|
18
|
+
* never blocks credential use or the provider.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-workbuddy-connect/app-version
|
|
21
|
+
*/
|
|
22
|
+
import { readFile } from 'node:fs/promises';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { writeFileAtomic } from './_compat.js';
|
|
26
|
+
import { resolveDshHome } from './_compat.js';
|
|
27
|
+
/**
|
|
28
|
+
* Last-resort UA version.
|
|
29
|
+
*
|
|
30
|
+
* The gateway ignores the version number when splitting the UA (research §2.7.2
|
|
31
|
+
* item 2: `CLI/1.0.0`, `CLI/99.0.0` and the real version all return the same
|
|
32
|
+
* document), so this constant is a shape requirement rather than a currency
|
|
33
|
+
* claim. It is *not* used to infer anything about model capabilities.
|
|
34
|
+
*/
|
|
35
|
+
export const FALLBACK_APP_VERSION = '5.5.2';
|
|
36
|
+
/** Basename of the saved version under `$DSH_HOME`. */
|
|
37
|
+
export const WORKBUDDY_APP_VERSION_FILENAME = '.workbuddy-ai-version.json';
|
|
38
|
+
/**
|
|
39
|
+
* Whether a string is safe to interpolate into an HTTP header.
|
|
40
|
+
*
|
|
41
|
+
* Strict on purpose: the value reaches a header, so anything that could split
|
|
42
|
+
* the request (CR, LF, spaces beyond the separator) or inject a second UA
|
|
43
|
+
* token must never pass. The App's own version is always `N.N.N` or `N.N.N.N`.
|
|
44
|
+
*/
|
|
45
|
+
export function validAppVersion(value) {
|
|
46
|
+
return typeof value === 'string' && /^\d{1,6}(?:\.\d{1,6}){1,3}$/u.test(value);
|
|
47
|
+
}
|
|
48
|
+
/** macOS App-bundle roots: system-wide first, then the user's own install. */
|
|
49
|
+
function macAppRoots() {
|
|
50
|
+
return ['/Applications', join(homedir(), 'Applications')];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Read `CFBundleShortVersionString` out of an `Info.plist`.
|
|
54
|
+
*
|
|
55
|
+
* Parsed as XML rather than grepped, because the plist contains several
|
|
56
|
+
* `<string>` values and a regex would be one unrelated key away from
|
|
57
|
+
* returning the wrong one. A binary plist has no `<dict>` in its bytes and is
|
|
58
|
+
* reported as unreadable (the saved value then applies) rather than guessed at.
|
|
59
|
+
*/
|
|
60
|
+
export async function readBundleVersion(plistPath) {
|
|
61
|
+
let text;
|
|
62
|
+
try {
|
|
63
|
+
text = await readFile(plistPath, 'utf8');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
// `<key>CFBundleShortVersionString</key>` followed by the next `<string>`.
|
|
69
|
+
const match = /<key>\s*CFBundleShortVersionString\s*<\/key>\s*<string>([^<]*)<\/string>/u.exec(text);
|
|
70
|
+
const version = match?.[1]?.trim();
|
|
71
|
+
return validAppVersion(version) ? version : undefined;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The installed international App's version, or `undefined` when it is not
|
|
75
|
+
* installed (or not readable).
|
|
76
|
+
*
|
|
77
|
+
* Windows and Linux have no verified bundle-metadata location yet, so this
|
|
78
|
+
* returns `undefined` there and the saved/fallback value is used instead of
|
|
79
|
+
* guessing a path — the same discipline the credential discovery follows.
|
|
80
|
+
*/
|
|
81
|
+
export async function installedAppVersion() {
|
|
82
|
+
if (process.platform !== 'darwin')
|
|
83
|
+
return undefined;
|
|
84
|
+
for (const root of macAppRoots()) {
|
|
85
|
+
const bundle = join(root, 'WorkBuddy AI.app');
|
|
86
|
+
const version = await readBundleVersion(join(bundle, 'Contents', 'Info.plist'));
|
|
87
|
+
if (version !== undefined)
|
|
88
|
+
return { version, bundle };
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
/** Saved-version file path under the Harness home. */
|
|
93
|
+
export function appVersionPath() {
|
|
94
|
+
return join(resolveDshHome(), WORKBUDDY_APP_VERSION_FILENAME);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the UA version: installed App first, then the last saved value, then
|
|
98
|
+
* the compiled-in fallback.
|
|
99
|
+
*
|
|
100
|
+
* A value read from the App is written back immediately, so an uninstalled App
|
|
101
|
+
* or an unreadable plist later still has the last real version to fall back
|
|
102
|
+
* on. The write is best-effort: failing to cache a version must never fail the
|
|
103
|
+
* catalog request that asked for it.
|
|
104
|
+
*/
|
|
105
|
+
export async function resolveAppVersion(options = {}) {
|
|
106
|
+
const path = options.path ?? appVersionPath();
|
|
107
|
+
const installed = await (options.installed ?? installedAppVersion)();
|
|
108
|
+
if (installed !== undefined && validAppVersion(installed.version)) {
|
|
109
|
+
try {
|
|
110
|
+
await writeFileAtomic(path, `${JSON.stringify({ version: installed.version, bundle: installed.bundle, observedAt: Date.now() }, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// A read-only home is not a reason to serve no catalog.
|
|
114
|
+
}
|
|
115
|
+
return { version: installed.version, source: 'installed', bundle: installed.bundle };
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const saved = JSON.parse(await readFile(path, 'utf8'));
|
|
119
|
+
if (typeof saved === 'object' && saved !== null) {
|
|
120
|
+
const version = saved['version'];
|
|
121
|
+
if (validAppVersion(version))
|
|
122
|
+
return { version, source: 'saved' };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Absent or malformed cache: fall through to the compiled-in default.
|
|
127
|
+
}
|
|
128
|
+
return { version: FALLBACK_APP_VERSION, source: 'fallback' };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Build the App-shaped User-Agent for catalog requests.
|
|
132
|
+
*
|
|
133
|
+
* `WorkBuddyAI/<version>` with no space is the form measured to reach the App
|
|
134
|
+
* document; the space form is rejected with 400/12403. Throws on an invalid
|
|
135
|
+
* version rather than sending a malformed header.
|
|
136
|
+
*/
|
|
137
|
+
export function appUserAgent(version) {
|
|
138
|
+
if (!validAppVersion(version))
|
|
139
|
+
throw new Error(`invalid WorkBuddy AI version for User-Agent: ${JSON.stringify(version)}`);
|
|
140
|
+
return `WorkBuddyAI/${version}`;
|
|
141
|
+
}
|