mslxdff 0.1.136 → 0.1.138
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/package.json +2 -2
- package/src/chat/gateway.js +1 -1
- package/src/chat/prompt.js +1 -1
- package/src/chat/terminal.js +1 -1
- package/src/cli/commands/model/list-providers.js +1 -1
- package/src/cli/commands/model/list-render.js +2 -2
- package/src/cli/commands/provider/add.js +2 -1
- package/src/cli/commands/provider/bench-via.js +1 -1
- package/src/cli/commands/provider/cline-free.js +94 -0
- package/src/cli/commands/provider/cline-login.js +20 -15
- package/src/cli/commands/provider/index.js +22 -0
- package/src/cli/commands/provider/models.js +4 -1
- package/src/cli/commands/provider/workbuddy-login.js +1 -1
- package/src/cli/commands/workbuddy.js +14 -21
- package/src/cli/index.js +3 -0
- package/src/compat.js +23 -5
- package/src/model-capabilities/merge.js +2 -2
- package/src/providers/classify.js +1 -1
- package/src/providers/cline/auth.js +1 -1
- package/src/providers/cline/chat.js +14 -11
- package/src/providers/cline/free-catalog.js +71 -0
- package/src/providers/cline/index.js +2 -16
- package/src/providers/cline/models.js +28 -51
- package/src/providers/model-id.js +4 -4
- package/src/providers/registry.js +1 -1
- package/src/providers/share-keys.js +6 -1
- package/src/providers/workbuddy/account-store.js +73 -7
- package/src/providers/workbuddy/chat.js +1 -1
- package/src/providers/workbuddy/index.js +10 -20
- package/src/readline-compat.js +9 -10
- package/src/runtime/providers-setup.js +9 -0
- package/src/runtime/server-lifecycle.js +2 -2
- package/src/state/migrations/cline-unify.js +94 -0
- package/src/state/migrations.js +33 -0
- package/src/state/schemas/token.js +6 -2
- package/src/state/schemas/use-group.js +4 -4
- package/src/sync-workbuddy.js +1 -1
- package/src/upstream-engine/index.js +1 -1
|
@@ -41,14 +41,7 @@ export function createClineProvider({
|
|
|
41
41
|
if (!fetchImpl) fetchImpl = UndiciFetch || compatFetch;
|
|
42
42
|
|
|
43
43
|
const rawKeys = collectApiKeysGeneric(id, apiKeys, apiKey, (pid) => loadProviderKeys(pid, file ? { file } : {}));
|
|
44
|
-
|
|
45
|
-
let extraKeys = [];
|
|
46
|
-
try {
|
|
47
|
-
const altId = id === "cline" ? "clinebot" : "cline";
|
|
48
|
-
const alt = loadProviderKeys(altId, file ? { file } : {});
|
|
49
|
-
if (alt.length) extraKeys = alt;
|
|
50
|
-
} catch {}
|
|
51
|
-
const allKeys = [...new Set([...rawKeys, ...extraKeys].map((k) => String(k).trim()).filter(Boolean))];
|
|
44
|
+
const allKeys = [...new Set(rawKeys.map((k) => String(k).trim()).filter(Boolean))];
|
|
52
45
|
const hasRefresh = allKeys.some((k) => isRefreshToken(k, id));
|
|
53
46
|
const ring = createKeyRing(allKeys.filter((k) => !isRefreshToken(k, id)), { cooldownMs });
|
|
54
47
|
|
|
@@ -78,14 +71,7 @@ export function createClineProvider({
|
|
|
78
71
|
// 优先写 providerConfigs,兼容旧路径由 saveProviderConfig 处理
|
|
79
72
|
saveProviderConfig(id, { baseUrl: resolvedBase, keys: next }, file ? { file } : {});
|
|
80
73
|
}
|
|
81
|
-
//
|
|
82
|
-
const altId = id === "cline" ? "clinebot" : "cline";
|
|
83
|
-
const cur2 = loadProviderKeys(altId, file ? { file } : {});
|
|
84
|
-
const idx2 = cur2.indexOf(oldRefreshToken);
|
|
85
|
-
if (idx2 >= 0) {
|
|
86
|
-
const next2 = [...cur2]; next2[idx2] = newRefreshToken;
|
|
87
|
-
saveProviderConfig(altId, { baseUrl: resolvedBase, keys: next2 }, file ? { file } : {});
|
|
88
|
-
}
|
|
74
|
+
// 只回写 cline 自己(旧 id 轮换回写已随 id 统一删除,见 .scratch/cline-unify/SPEC.md §4.1)
|
|
89
75
|
} catch {}
|
|
90
76
|
},
|
|
91
77
|
});
|
|
@@ -3,6 +3,7 @@ import { compatFetch } from "../../compat.js";
|
|
|
3
3
|
import { joinModelId } from "../model-id.js";
|
|
4
4
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
5
5
|
import { dirname } from "node:path";
|
|
6
|
+
import { fetchFreeCatalog, catalogUrl, FALLBACK_FREE as BUNDLED_FREE } from "./free-catalog.js";
|
|
6
7
|
|
|
7
8
|
const { UndiciFetch } = getUndici();
|
|
8
9
|
|
|
@@ -18,30 +19,21 @@ export function createModelsService({ id, baseUrl, modelsPath, fetchImpl, dispat
|
|
|
18
19
|
let cache = null;
|
|
19
20
|
let fetchedAt = 0;
|
|
20
21
|
|
|
21
|
-
// 离线兜底:与上游 recommended-models 的 free
|
|
22
|
-
// 上游挂了/401 时也不返回空数组,保证 -provider
|
|
23
|
-
const FALLBACK_FREE = [
|
|
24
|
-
{ id: "cline-free/deepseek-v4.1-flash", name: "deepseek-v4.1-flash" },
|
|
25
|
-
{ id: "cline-free/muse-spark-1.3-contributor", name: "muse-spark-1.3-contributor" },
|
|
26
|
-
{ id: "z-ai/glm-5.3-flash", name: "glm-5.3-flash" },
|
|
27
|
-
{ id: "cline-free/solar-pro4", name: "solar-pro4" },
|
|
28
|
-
{ id: "poolside/laguna-s-2.1:free", name: "laguna-s-2.1:free" },
|
|
29
|
-
];
|
|
22
|
+
// 离线兜底:与上游 recommended-models 的 free 对齐(常量在 free-catalog.js,2026-09-20 实测 5 个)。
|
|
23
|
+
// 上游挂了/401 时也不返回空数组,保证 -provider cline models 与 picks 仍有免费可用。
|
|
30
24
|
|
|
31
25
|
function fallbackList() {
|
|
32
|
-
const out =
|
|
26
|
+
const out = BUNDLED_FREE.map((m) => ({ ...m, id: joinModelId(id, m.id) }));
|
|
33
27
|
cache = out; fetchedAt = Date.now();
|
|
34
28
|
return out;
|
|
35
29
|
}
|
|
36
30
|
|
|
37
|
-
// 端点归一化:官方取 {bareHost}/api/v1/ai/cline/recommended-models
|
|
38
|
-
// baseUrl 可能是裸 host(https://api.cline.bot)也可能是带 /api/v1
|
|
39
|
-
// 统一收敛到 …/api/v1/ai/cline/recommended-models;用户自定义 path 原样尊重。
|
|
31
|
+
// 端点归一化:官方取 {bareHost}/api/v1/ai/cline/recommended-models(URL 拼装与 CLI 共用 free-catalog.catalogUrl)。
|
|
32
|
+
// baseUrl 可能是裸 host(https://api.cline.bot)也可能是带 /api/v1 的,统一收敛;用户自定义 path 原样尊重。
|
|
40
33
|
function resolveModelsUrl() {
|
|
41
34
|
const custom = modelsPath && modelsPath !== "/models" && modelsPath !== "/ai/cline/recommended-models";
|
|
42
35
|
if (custom) return joinUrl(resolvedBase, resolvedPath);
|
|
43
|
-
|
|
44
|
-
return joinUrl(bare, "/api/v1/ai/cline/recommended-models");
|
|
36
|
+
return catalogUrl(resolvedBase);
|
|
45
37
|
}
|
|
46
38
|
|
|
47
39
|
async function listModels() {
|
|
@@ -85,7 +77,7 @@ export function createModelsService({ id, baseUrl, modelsPath, fetchImpl, dispat
|
|
|
85
77
|
} catch {}
|
|
86
78
|
}
|
|
87
79
|
|
|
88
|
-
// Note:
|
|
80
|
+
// Note: cline free 目录三处同源(CLI 直查/聚合目录/兜底,统一在 free-catalog.js)+ daemon 启动自检快照(diff 写 daemon.log,不自动改 picks)— 见 .agents/notes/implemented/bug-fix/2026-09-16-clinebot-free-catalog-unify.md
|
|
89
81
|
function detectFreeChanges(ids) {
|
|
90
82
|
const prev = readSnapshotFree();
|
|
91
83
|
if (!prev) {
|
|
@@ -104,46 +96,31 @@ export function createModelsService({ id, baseUrl, modelsPath, fetchImpl, dispat
|
|
|
104
96
|
return { added, removed };
|
|
105
97
|
}
|
|
106
98
|
|
|
107
|
-
// 启动自检入口:拉 recommended-models → 填缓存 → 对比快照报 free
|
|
99
|
+
// 启动自检入口:拉 recommended-models → 填缓存 → 对比快照报 free 增删(拉取与 CLI 共用 free-catalog.fetchFreeCatalog)。
|
|
108
100
|
async function checkFreeUpdates() {
|
|
109
|
-
const url = resolveModelsUrl();
|
|
110
101
|
const t0 = performance.now();
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
let freeIds = null;
|
|
119
|
-
try {
|
|
120
|
-
const text = await res.text().catch(() => "");
|
|
121
|
-
if (res.ok && text && isClineBotHost(resolvedBase)) {
|
|
122
|
-
const json = JSON.parse(text);
|
|
123
|
-
if (Array.isArray(json.free)) {
|
|
124
|
-
const valid = json.free.filter((m) => m && typeof m.id === "string");
|
|
125
|
-
if (valid.length) {
|
|
126
|
-
cache = valid.map((m) => ({ ...m, id: joinModelId(id, m.id) }));
|
|
127
|
-
fetchedAt = Date.now();
|
|
128
|
-
freeIds = valid.map((m) => m.id);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
} catch {}
|
|
133
|
-
let change = null;
|
|
134
|
-
if (freeIds && snapshotPath) { try { change = detectFreeChanges(freeIds); } catch {} }
|
|
135
|
-
return {
|
|
136
|
-
ok: res.ok,
|
|
137
|
-
status: res.status,
|
|
138
|
-
ms: Math.round(performance.now() - t0),
|
|
139
|
-
...(change?.added ? { freeAdded: change.added, freeRemoved: change.removed } : {}),
|
|
140
|
-
};
|
|
141
|
-
} catch (err) {
|
|
142
|
-
return { ok: false, error: String(err?.message || err), ms: Math.round(performance.now() - t0) };
|
|
102
|
+
const key = (loadKeys ? loadKeys(id)[0] : null) || (ring ? ring.next() : null);
|
|
103
|
+
const authorization = key && !String(key).includes(".") ? `Bearer ${key}` : undefined;
|
|
104
|
+
const cat = await fetchFreeCatalog({ baseUrl: resolvedBase, fetchImpl, dispatcher, authorization });
|
|
105
|
+
const ms = () => Math.round(performance.now() - t0);
|
|
106
|
+
const httpOk = cat.status >= 200 && cat.status < 300;
|
|
107
|
+
if (!httpOk || !cat.models.length || !isClineBotHost(resolvedBase)) {
|
|
108
|
+
return { ok: httpOk, status: cat.status, ...(httpOk ? {} : { error: cat.error }), ms: ms() };
|
|
143
109
|
}
|
|
110
|
+
const valid = cat.models;
|
|
111
|
+
cache = valid.map((m) => ({ ...m, id: joinModelId(id, m.id) }));
|
|
112
|
+
fetchedAt = Date.now();
|
|
113
|
+
let change = null;
|
|
114
|
+
if (snapshotPath) { try { change = detectFreeChanges(valid.map((m) => m.id)); } catch {} }
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
status: cat.status,
|
|
118
|
+
ms: ms(),
|
|
119
|
+
...(change?.added ? { freeAdded: change.added, freeRemoved: change.removed } : {}),
|
|
120
|
+
};
|
|
144
121
|
}
|
|
145
122
|
|
|
146
|
-
// preheat 保留为别名:dispatcher 已不再调
|
|
123
|
+
// preheat 保留为别名:dispatcher 已不再调 cline,手动/测试/未来钩子仍可用,行为与自检一致
|
|
147
124
|
async function preheat() {
|
|
148
125
|
return checkFreeUpdates();
|
|
149
126
|
}
|
|
@@ -12,9 +12,9 @@ import os from "node:os";
|
|
|
12
12
|
|
|
13
13
|
export const DEFAULT_PROVIDER = "opencode";
|
|
14
14
|
|
|
15
|
-
const ALIASES = { oc: "opencode", opencode: "opencode" };
|
|
15
|
+
const ALIASES = { oc: "opencode", opencode: "opencode", clinebot: "cline", "cline-bot": "cline" };
|
|
16
16
|
|
|
17
|
-
// WorkBuddy 别名映射(alias → canonical):
|
|
17
|
+
// WorkBuddy 别名映射(alias → canonical):cline-z-ai-glm-5.3-flash → cline/z-ai/glm-5.3-flash
|
|
18
18
|
const _modelAliases = new Map();
|
|
19
19
|
|
|
20
20
|
function aliasFile() {
|
|
@@ -62,7 +62,7 @@ export function getModelAlias(id) {
|
|
|
62
62
|
return _modelAliases.get(key) || null;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
// 反向查询:canonical id → alias(如
|
|
65
|
+
// 反向查询:canonical id → alias(如 cline/z-ai/glm-5.3-flash → cline-z-ai-glm-5.3-flash)
|
|
66
66
|
export function getAliasForModel(canonicalId) {
|
|
67
67
|
const c = String(canonicalId || "").trim();
|
|
68
68
|
if (!_aliasLoaded) { try { loadModelAliases(); } catch {} }
|
|
@@ -116,7 +116,7 @@ export function toFullId(provider, raw) {
|
|
|
116
116
|
export function normalizeFullId(id, knownProviders = []) {
|
|
117
117
|
const s = String(id || "").trim();
|
|
118
118
|
if (!s) return "";
|
|
119
|
-
// 1. 查别名表(
|
|
119
|
+
// 1. 查别名表(cline-z-ai-glm-5.3-flash → cline/z-ai/glm-5.3-flash)
|
|
120
120
|
const aliased = getModelAlias(s);
|
|
121
121
|
if (aliased) return aliased;
|
|
122
122
|
// 2. 已含 /,走原有逻辑
|
|
@@ -12,7 +12,7 @@ export const customProviders = [
|
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
id: "cline",
|
|
15
|
-
match: (id, baseUrl) => id === "cline" ||
|
|
15
|
+
match: (id, baseUrl) => id === "cline" || String(baseUrl).includes("cline.bot"),
|
|
16
16
|
load: () => import("./cline.js").then((m) => m.createClineProvider),
|
|
17
17
|
},
|
|
18
18
|
];
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
import { splitModelId, DEFAULT_PROVIDER } from "./model-id.js";
|
|
4
4
|
import { classifyProvider } from "./classify.js";
|
|
5
5
|
import { loadProviderKeys, listProviderIdsWithKeys } from "../state.js";
|
|
6
|
+
import { isRefreshToken } from "./cline/headers.js";
|
|
6
7
|
|
|
7
8
|
export const SHARE_KEYS_HEADER = "x-mslxdff-share-keys";
|
|
8
9
|
|
|
9
10
|
// 硬排除:refresh-token 型凭据(对端刷新会轮换 token,与本机互相踢下线)——正确性问题。
|
|
10
|
-
|
|
11
|
+
// 二层保护:① 按 id 硬排除(cline 恒 local-only);② 按凭据形状兜底(见 shareableProviderIds)。
|
|
12
|
+
const NEVER_SHARE_IDS = new Set(["cline"]);
|
|
11
13
|
|
|
12
14
|
// 本节点应 cast key 到出站转发的供应商 id 集合 = 所有「本机有 key」的供应商,
|
|
13
15
|
// 减去:opencode(无 key 且恒排除)、local-only(workbuddy/cline系,本就不走组员)、刷新型凭据。
|
|
@@ -17,6 +19,9 @@ export function shareableProviderIds({ file } = {}) {
|
|
|
17
19
|
if (id === DEFAULT_PROVIDER) continue;
|
|
18
20
|
if (NEVER_SHARE_IDS.has(id)) continue;
|
|
19
21
|
try { if (classifyProvider(id) === "local-only") continue; } catch {}
|
|
22
|
+
// 形状兜底:id 白名单可能有残留(迁移未跑/回滚/手改 config),只要该供应商现有 key 里
|
|
23
|
+
// 有一个是 refreshToken 形态,就一律不外借——refreshToken 借出去会被对端刷新轮换,双向踢下线。
|
|
24
|
+
try { if (loadProviderKeys(id, { file }).some((k) => isRefreshToken(k, id))) continue; } catch {}
|
|
20
25
|
ids.push(id);
|
|
21
26
|
}
|
|
22
27
|
return ids;
|
|
@@ -1,17 +1,83 @@
|
|
|
1
1
|
// WorkBuddy 账号落盘单一源:auths/workbuddy-<uid>.json(0600 tmp+rename)+ state.json keys/auths 按 uid 去重。
|
|
2
2
|
// token-auto / device-login / 刷新(服务端 saveFn 与 CLI 定时任务)共用同一实现,安全细节只维护一份。
|
|
3
3
|
// Note: 刷新落盘只走 applyTokenRefresh,别再各自内联一套 — 见 .agents/notes/implemented/architecture/2026-09-17-workbuddy-persist-single-seam.md
|
|
4
|
-
|
|
4
|
+
// Note: 凭据目录别再用 cwd 兜底 — 见 .agents/notes/implemented/architecture/2026-09-20-workbuddy-authdir-follows-state.md
|
|
5
|
+
import { writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync, readdirSync, readFileSync } from "node:fs";
|
|
5
6
|
import { join, dirname } from "node:path";
|
|
6
7
|
import { tmpdir } from "node:os";
|
|
7
|
-
import { isTestEnv } from "../../state/store.js";
|
|
8
|
+
import { isTestEnv, defaultStateFile } from "../../state/store.js";
|
|
9
|
+
|
|
10
|
+
// 凭据目录单一真相:跟着「账本」(state 文件)走 —— 默认 ~/.config/mslxdff/auths。
|
|
11
|
+
// 旧实现末尾兜底 `join(process.cwd(), "auths")`:在任意目录里起服务就把企业长效
|
|
12
|
+
// refreshToken 写进那个目录,而 .gitignore 只管自己所在的仓库 → 凭据落到不受保护的目录
|
|
13
|
+
// (本机实测:两个真实账号因此分居 `~/.config/mslxdff/auths` 与 `项目根/auths` 两处)。已修。
|
|
14
|
+
// 纯策略:写入目录的唯一目标(explicit > 测试隔离 > 「跟账本走」= state 文件同目录/auths)。
|
|
15
|
+
// 抽成纯函数是为了可测 —— `node --test` 下 isTestEnv() 恒真,环境态没法在测试里翻面。
|
|
16
|
+
export function authDirFor({ explicit = "", testEnv = false, stateFile } = {}) {
|
|
17
|
+
if (explicit) return explicit;
|
|
18
|
+
if (testEnv) return join(tmpdir(), "mslxdff-test-auths");
|
|
19
|
+
return join(dirname(stateFile), "auths");
|
|
20
|
+
}
|
|
8
21
|
|
|
9
22
|
export function resolveAuthDir() {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
23
|
+
return authDirFor({
|
|
24
|
+
explicit: process.env.WORKBUDDY_AUTH_DIR || "",
|
|
25
|
+
testEnv: isTestEnv(),
|
|
26
|
+
stateFile: defaultStateFile(),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 纯策略:算出读取候选目录(主位置优先,历史 cwd/auths 只读兜底,相同则去重)。
|
|
31
|
+
// 抽成纯函数是为了可测 —— `node --test` 下 isTestEnv() 恒真,环境态没法在测试里翻面。
|
|
32
|
+
export function authDirCandidates({ primary, explicit = "", testEnv = false, cwdDir = "" } = {}) {
|
|
33
|
+
const dirs = [primary];
|
|
34
|
+
if (!explicit && !testEnv && cwdDir && cwdDir !== primary) dirs.push(cwdDir);
|
|
35
|
+
return dirs;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 读取候选(迁移期兼容):主位置优先,历史 `cwd()/auths` 只读兜底。
|
|
39
|
+
// 显式 WORKBUDDY_AUTH_DIR 或测试环境不兜底:既避免测试扫到真实目录,也避免重新引入 cwd 依赖。
|
|
40
|
+
export function resolveAuthDirs() {
|
|
41
|
+
return authDirCandidates({
|
|
42
|
+
primary: resolveAuthDir(),
|
|
43
|
+
explicit: process.env.WORKBUDDY_AUTH_DIR || "",
|
|
44
|
+
testEnv: isTestEnv(),
|
|
45
|
+
cwdDir: join(process.cwd(), "auths"),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 按 uid 定位账号文件(主位置优先,旧位置兜底);找不到返回 null。
|
|
50
|
+
export function findAccountFile(uid) {
|
|
51
|
+
for (const dir of resolveAuthDirs()) {
|
|
52
|
+
const fp = join(dir, `workbuddy-${uid}.json`);
|
|
53
|
+
if (existsSync(fp)) return fp;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 扫描账号文件(主位置先扫,同 uid 以主位置为准),返回已校验的凭证行。
|
|
59
|
+
// 读取侧单一真相:provider 构造、CLI 账号加载、token-auto 都走这里,别再各自 readdir 一套。
|
|
60
|
+
export function listAccountDocs({ dirs } = {}) {
|
|
61
|
+
const out = [];
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
for (const dir of dirs || resolveAuthDirs()) {
|
|
64
|
+
let files = [];
|
|
65
|
+
try {
|
|
66
|
+
if (!existsSync(dir)) continue;
|
|
67
|
+
files = readdirSync(dir).filter((f) => f.startsWith("workbuddy-") && f.endsWith(".json"));
|
|
68
|
+
} catch { continue; }
|
|
69
|
+
for (const f of files) {
|
|
70
|
+
try {
|
|
71
|
+
const doc = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
72
|
+
const uid = doc?.account?.uid;
|
|
73
|
+
if (!uid || !doc?.auth?.accessToken) continue;
|
|
74
|
+
if (seen.has(String(uid))) continue;
|
|
75
|
+
seen.add(String(uid));
|
|
76
|
+
out.push({ uid: String(uid), file: join(dir, f), dir, doc });
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
15
81
|
}
|
|
16
82
|
|
|
17
83
|
function jwtExp(token) {
|
|
@@ -102,7 +102,7 @@ export function createChatService({
|
|
|
102
102
|
|
|
103
103
|
// SDK 通道(缺省启用):缺省底层走 @ai-sdk/openai-compatible,`legacy`/关闭词
|
|
104
104
|
// (局部 MSLXDFF_WORKBUDDY_SDK,未设则继承全局 MSLXDFF_UPSTREAM_ENGINE)回退原生 transport;
|
|
105
|
-
//
|
|
105
|
+
// 不可用(未安装)自动回退并告警一次。上层轮换/刷新/reshape 全链复用。
|
|
106
106
|
// Note: 为什么默认 SDK、翻译层代价与回退语义 — 见 .agents/notes/implemented/feature/2026-09-12-workbuddy-sdk-channel.md
|
|
107
107
|
// 上游非标字段(thinking / reasoning_effort / reasoning_content / tool_choice 字符串)不依赖 SDK 白名单:
|
|
108
108
|
// payload.js 前置语义改写 + AI SDK providerOptions 透传(非白名单 key 原样 spread 进 body,2.0.41 实测)。
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { createKeyRing } from "../keyring.js";
|
|
2
2
|
import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, WORKBUDDY_DEFAULT_BASE_URL, loadProviderModelsPath, loadProviderChatPath } from "../../state.js";
|
|
3
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
3
|
import { compatFetch } from "../../compat.js";
|
|
5
|
-
import { join } from "node:path";
|
|
6
4
|
import { envInt, joinUrl, getUndici, createAgent } from "../base.js";
|
|
7
5
|
import { createAuthService, isAuthError, isInsufficientStatus, decodeJwtExp } from "./auth.js";
|
|
8
|
-
import { applyTokenRefresh,
|
|
6
|
+
import { applyTokenRefresh, listAccountDocs } from "./account-store.js";
|
|
9
7
|
import { createChatService } from "./chat.js";
|
|
10
8
|
import { createModelsService } from "./models.js";
|
|
11
9
|
import { createBalanceCache, getCachedBalance as defaultGetCached, setCachedBalance as defaultSetCached } from "./balance.js";
|
|
@@ -61,24 +59,16 @@ export function createWorkbuddyProvider({
|
|
|
61
59
|
let authList = Array.isArray(auths) && auths.length ? auths : authsFromState;
|
|
62
60
|
|
|
63
61
|
if (!authList.length && !keys.length) {
|
|
62
|
+
// 账号目录读取统一走 listAccountDocs:主位置(跟 state 走)优先,旧 cwd/auths 只读兜底
|
|
64
63
|
try {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
authList.push({
|
|
74
|
-
uid: j.account.uid,
|
|
75
|
-
domain: j.auth.domain || "www.codebuddy.cn",
|
|
76
|
-
enterpriseId: j.account.enterpriseId || "",
|
|
77
|
-
refreshToken: j.auth.refreshToken || "",
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
} catch {}
|
|
81
|
-
}
|
|
64
|
+
for (const { uid, doc } of listAccountDocs()) {
|
|
65
|
+
if (!keys.includes(doc.auth.accessToken)) keys.push(doc.auth.accessToken);
|
|
66
|
+
authList.push({
|
|
67
|
+
uid,
|
|
68
|
+
domain: doc.auth.domain || "www.codebuddy.cn",
|
|
69
|
+
enterpriseId: doc.account.enterpriseId || "",
|
|
70
|
+
refreshToken: doc.auth.refreshToken || "",
|
|
71
|
+
});
|
|
82
72
|
}
|
|
83
73
|
} catch {}
|
|
84
74
|
}
|
package/src/readline-compat.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// readline
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// readline 统一走回调版 `node:readline`(question 包成 Promise),
|
|
2
|
+
// 顺带给 -chat 一个人话版本门(项目要求 Node >=18,ADR-0024)。
|
|
3
|
+
// 保留理由:回调版在 18+ 行为一致且无 import 期风险(`node:readline/promises`
|
|
4
|
+
// 若被误用于旧运行时会直接 ERR_UNKNOWN_BUILTIN_MODULE,连报错都来不及打)。
|
|
5
5
|
import readline from "node:readline";
|
|
6
|
+
import { MIN_NODE_MAJOR, nodeMajor } from "./compat.js";
|
|
6
7
|
|
|
7
8
|
export function createInterface(opts) {
|
|
8
9
|
const rl = readline.createInterface(opts);
|
|
@@ -11,13 +12,11 @@ export function createInterface(opts) {
|
|
|
11
12
|
return rl;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export
|
|
15
|
-
return Number(String(process.versions?.node || "0").split(".")[0]) || 0;
|
|
16
|
-
}
|
|
15
|
+
export { nodeMajor };
|
|
17
16
|
|
|
18
|
-
// -chat
|
|
19
|
-
//
|
|
20
|
-
export function assertChatNode({ min =
|
|
17
|
+
// -chat 版本门:项目要求 Node >=18(ADR-0024),不满足直接给人话 + 升级指引。
|
|
18
|
+
// 返回 true=通过,false=已打印升级指引。
|
|
19
|
+
export function assertChatNode({ min = MIN_NODE_MAJOR } = {}) {
|
|
21
20
|
const major = nodeMajor();
|
|
22
21
|
if (major >= min) return true;
|
|
23
22
|
console.error(`Node 版本过旧(当前 v${process.versions.node}),-chat 需要 Node ${min}+(推荐 20+)。`);
|
|
@@ -19,6 +19,15 @@ import { errMsg } from "../cli/util.js";
|
|
|
19
19
|
* 无参(读 env/state 自身),返回 ctx 由门面接力 server-lifecycle。
|
|
20
20
|
*/
|
|
21
21
|
export async function setupProviders() {
|
|
22
|
+
// 供应商 id 统一(旧 cline id → cline):必须在实例化任何 provider 之前跑;幂等,失败不阻断启动
|
|
23
|
+
try {
|
|
24
|
+
const { runStateMigrations } = await import("../state/migrations.js");
|
|
25
|
+
const mig = await runStateMigrations({ file: defaultStateFile() });
|
|
26
|
+
if (mig.applied.length) console.log(`state migrations applied: ${mig.applied.join(", ")}`);
|
|
27
|
+
for (const e of mig.errors) console.log(`state migration failed: ${e.id} — ${e.error}`);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
console.log(`state migrations failed: ${errMsg(err)}`);
|
|
30
|
+
}
|
|
22
31
|
const { token, created } = await loadToken();
|
|
23
32
|
const pkgRoot2 = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
24
33
|
const pluginDirs = resolvePluginDirs({ pkgRoot: pkgRoot2 });
|
|
@@ -123,10 +123,10 @@ export async function startServerLifecycle({ VERSION, token, created, upstream,
|
|
|
123
123
|
else if (r.ok) console.log(`[preheat] opencode models ok ${r.status} ${r.ms}ms`);
|
|
124
124
|
else console.log(`[preheat] opencode models failed ${r.error || r.status || ""} ${r.ms || 0}ms`);
|
|
125
125
|
}).catch(() => {});
|
|
126
|
-
//
|
|
126
|
+
// cline free 目录自检(独立于 opencode 连接预热:dispatcher.preheat 只做默认供应商)
|
|
127
127
|
// 见 .agents/notes/implemented/simplification/2026-09-16-preheat-opencode-only.md
|
|
128
128
|
try {
|
|
129
|
-
const cline = upstream?.byId?.get?.("
|
|
129
|
+
const cline = upstream?.byId?.get?.("cline");
|
|
130
130
|
if (cline?.checkFreeUpdates) void cline.checkFreeUpdates().catch(() => {});
|
|
131
131
|
} catch {}
|
|
132
132
|
}, 100).unref?.();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 幂等状态迁移:把遗留 id 的 cline 配置合并进 `cline` 并删除旧键(供应商 id 统一)。
|
|
3
|
+
* 见 .scratch/cline-unify/SPEC.md §4.5;语义:keys 去重 + 剔除 sk_ 旧式 key、allowlist 求并、baseUrl 收敛为根。
|
|
4
|
+
* 绝不触碰 modelPicks / modelErrors / modelLatencies(旧 clinebot/* 键交给既有 prune --orphans)。
|
|
5
|
+
*/
|
|
6
|
+
import { chmodSync, copyFileSync, existsSync } from "node:fs";
|
|
7
|
+
import { defaultStateFile, readState, writeStateImmediate } from "../store.js";
|
|
8
|
+
import { normalizeAllowedModel, normalizeEndpointPath } from "../provider-config.js";
|
|
9
|
+
import { appendEvent } from "../../logs.js";
|
|
10
|
+
|
|
11
|
+
export const CLINE_DEFAULT_BASE_URL = "https://api.cline.bot";
|
|
12
|
+
export const LEGACY_CLINE_IDS = ["clinebot", "cline-bot"];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* cline 的 chat URL = `baseUrl` + `loadProviderChatPath()`;而 loadProviderChatPath 缺省**恒**返回
|
|
16
|
+
* `/chat/completions`(src/providers/cline/index.js 里那个按 `/api/v1` 智能兜底的 defaultChat 是死代码,
|
|
17
|
+
* 永远轮不到它生效)。所以 baseUrl 必须自带 `/api/v1`,否则会拼成 `https://api.cline.bot/chat/completions`
|
|
18
|
+
* → 上游 404(2026-09-20 端到端实测)。只对 cline 官方域补全,自定义/代理 baseUrl 不做猜测。
|
|
19
|
+
*/
|
|
20
|
+
export function normalizeClineBaseUrl(u) {
|
|
21
|
+
const s = String(u || "").trim().replace(/\/+$/, "");
|
|
22
|
+
if (!s) return `${CLINE_DEFAULT_BASE_URL}/api/v1`;
|
|
23
|
+
if (/\/api\/v1$/i.test(s)) return s;
|
|
24
|
+
if (/^https?:\/\/api\.cline\.bot$/i.test(s)) return `${s}/api/v1`;
|
|
25
|
+
return s;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function uniq(list) { return [...new Set(list)]; }
|
|
29
|
+
function strList(v) { return Array.isArray(v) ? v.map((x) => String(x || "").trim()).filter(Boolean) : []; }
|
|
30
|
+
// sk_ 旧式 API key(D3:cline 只认 refreshToken,数据层剔除)
|
|
31
|
+
function isLegacySkKey(k) { return /^sk[-_]/i.test(k); }
|
|
32
|
+
|
|
33
|
+
/** 合并后的 cline 配置:只重建 cline 自己的字段,不吸收旧 id 的 allowAnyModels(避免意外放宽安全默认) */
|
|
34
|
+
function mergeConfig(cl, bot) {
|
|
35
|
+
const next = {
|
|
36
|
+
baseUrl: normalizeClineBaseUrl(cl.baseUrl || bot.baseUrl),
|
|
37
|
+
keys: uniq([...strList(cl.keys), ...strList(bot.keys)]).filter((k) => !isLegacySkKey(k)),
|
|
38
|
+
allowedModels: uniq([...strList(cl.allowedModels), ...strList(bot.allowedModels)]
|
|
39
|
+
.map((m) => normalizeAllowedModel(m, "cline")).filter(Boolean)),
|
|
40
|
+
};
|
|
41
|
+
if (Array.isArray(cl.auths) && cl.auths.length) next.auths = cl.auths;
|
|
42
|
+
if (typeof cl.allowAnyModels === "boolean") next.allowAnyModels = cl.allowAnyModels;
|
|
43
|
+
const modelsPath = normalizeEndpointPath(cl.modelsPath || bot.modelsPath || "");
|
|
44
|
+
if (modelsPath) next.modelsPath = modelsPath;
|
|
45
|
+
const chatPath = normalizeEndpointPath(cl.chatPath || bot.chatPath || "");
|
|
46
|
+
if (chatPath) next.chatPath = chatPath;
|
|
47
|
+
return next;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 备份到同目录 state.json.bak-<ISO时间戳>,权限收紧 0600(含凭据,绝不外泄) */
|
|
51
|
+
function backupState(file, now = new Date()) {
|
|
52
|
+
const stamp = now.toISOString().replace(/[:.]/g, "-");
|
|
53
|
+
const target = `${file}.bak-${stamp}`;
|
|
54
|
+
copyFileSync(file, target);
|
|
55
|
+
try { chmodSync(target, 0o600); } catch {}
|
|
56
|
+
return target;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {{file?:string, dryRun?:boolean, backup?:boolean}} opts
|
|
61
|
+
* @returns {{applied:boolean, changed:boolean, reason?:string, before?:object, after?:object, backup?:string}}
|
|
62
|
+
* `applied` 仅在真正落盘时为 true;before/after 只含计数,绝不含凭据明文。
|
|
63
|
+
*/
|
|
64
|
+
export function clineUnifyMigration({ file = defaultStateFile(), dryRun = false, backup = true } = {}) {
|
|
65
|
+
const configs = readState(file).providerConfigs || {};
|
|
66
|
+
const legacyId = LEGACY_CLINE_IDS.find((pid) => configs[pid] && typeof configs[pid] === "object");
|
|
67
|
+
if (!legacyId) return { applied: false, changed: false, reason: "no legacy cline id" };
|
|
68
|
+
const bot = configs[legacyId];
|
|
69
|
+
const cl = configs.cline && typeof configs.cline === "object" ? configs.cline : {};
|
|
70
|
+
const next = mergeConfig(cl, bot);
|
|
71
|
+
const before = {
|
|
72
|
+
keys: strList(cl.keys).length + strList(bot.keys).length,
|
|
73
|
+
allowedModels: uniq([...strList(cl.allowedModels), ...strList(bot.allowedModels)]).length,
|
|
74
|
+
};
|
|
75
|
+
const after = { keys: next.keys.length, allowedModels: next.allowedModels.length };
|
|
76
|
+
if (dryRun) return { applied: false, changed: true, reason: "dry-run", before, after };
|
|
77
|
+
let backupPath = null;
|
|
78
|
+
if (backup) {
|
|
79
|
+
try { if (existsSync(file)) backupPath = backupState(file); } catch {}
|
|
80
|
+
}
|
|
81
|
+
const merged = { ...configs, cline: next };
|
|
82
|
+
for (const pid of LEGACY_CLINE_IDS) delete merged[pid];
|
|
83
|
+
writeStateImmediate(file, { providerConfigs: merged });
|
|
84
|
+
try {
|
|
85
|
+
appendEvent({
|
|
86
|
+
type: "cline-unify-migrated",
|
|
87
|
+
from: legacyId,
|
|
88
|
+
keys: after.keys,
|
|
89
|
+
allowedModels: after.allowedModels,
|
|
90
|
+
backup: backupPath ? backupPath.replace(/\\/g, "/").split("/").pop() : null,
|
|
91
|
+
});
|
|
92
|
+
} catch {}
|
|
93
|
+
return { applied: true, changed: true, reason: `merged ${legacyId}`, before, after, ...(backupPath ? { backup: backupPath } : {}) };
|
|
94
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 状态迁移运行器:按注册顺序执行(每个迁移自身幂等),供 daemon bootstrap
|
|
3
|
+
* (src/runtime/providers-setup.js,实例化任何 provider 之前)与 CLI 单点调用。
|
|
4
|
+
* 失败只收集不抛出:迁移不该拖垮 daemon 启动。
|
|
5
|
+
*/
|
|
6
|
+
import { defaultStateFile } from "./store.js";
|
|
7
|
+
import { clineUnifyMigration } from "./migrations/cline-unify.js";
|
|
8
|
+
|
|
9
|
+
export const STATE_MIGRATIONS = [
|
|
10
|
+
{ id: "cline-unify", run: (opts) => clineUnifyMigration(opts) },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {{file?:string, dryRun?:boolean, backup?:boolean}} opts
|
|
15
|
+
* @returns {Promise<{applied:string[], skipped:string[], errors:object[], details:object}>}
|
|
16
|
+
*/
|
|
17
|
+
export async function runStateMigrations({ file = defaultStateFile(), dryRun = false, backup = true } = {}) {
|
|
18
|
+
const applied = [];
|
|
19
|
+
const skipped = [];
|
|
20
|
+
const errors = [];
|
|
21
|
+
const details = {};
|
|
22
|
+
for (const m of STATE_MIGRATIONS) {
|
|
23
|
+
try {
|
|
24
|
+
const r = await m.run({ file, dryRun, backup });
|
|
25
|
+
details[m.id] = r;
|
|
26
|
+
if (r?.applied) applied.push(m.id);
|
|
27
|
+
else skipped.push(m.id);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
errors.push({ id: m.id, error: String(err?.message || err) });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { applied, skipped, errors, details };
|
|
33
|
+
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync, chmodSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { defaultStateFile, tokenFile, generateToken, readState, writeStateImmediate, getEntry } from "../store.js";
|
|
4
4
|
import { fmtShanghaiYMDHMS } from "../../time.js";
|
|
5
5
|
|
|
6
|
+
// 主 Bearer token 镜像落盘:0600 + 显式 chmod。
|
|
7
|
+
// 注意 writeFileSync 的 mode 只在“新建文件”时生效,已存在的文件权限不会被它改;
|
|
8
|
+
// 升级前生成的老文件会永远停在 0644(同机其他用户可读),所以必须再 chmod 一次兜住。
|
|
6
9
|
function syncTokenFile(token, file) {
|
|
7
10
|
try {
|
|
8
11
|
const tf = tokenFile(file);
|
|
9
12
|
mkdirSync(dirname(tf), { recursive: true });
|
|
10
|
-
writeFileSync(tf, String(token || "").trim() + "\n", "utf8");
|
|
13
|
+
writeFileSync(tf, String(token || "").trim() + "\n", { encoding: "utf8", mode: 0o600 });
|
|
14
|
+
try { chmodSync(tf, 0o600); } catch {}
|
|
11
15
|
} catch {}
|
|
12
16
|
}
|
|
13
17
|
|
|
@@ -45,8 +45,8 @@ export function getUseGroupKeysEnv() {
|
|
|
45
45
|
return parseBool(raw) === true;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
// 取模型对应的供应商 head:兼容 canonical(workbuddy/xxx、
|
|
49
|
-
// dash 别名(
|
|
48
|
+
// 取模型对应的供应商 head:兼容 canonical(workbuddy/xxx、cline/xxx)、
|
|
49
|
+
// dash 别名(cline-xxx → 还原后取 head)、裸 id(归 opencode)。
|
|
50
50
|
// oc/ 别名归一到 opencode(与 splitModelId 一致:别名表小写 key,先小写再归一)。
|
|
51
51
|
export function providerHeadOf(model) {
|
|
52
52
|
let s = String(model || "").trim();
|
|
@@ -70,14 +70,14 @@ export function providerHeadOf(model) {
|
|
|
70
70
|
// 全局开关:off 则所有供应商都不走组员网络(via-route/hedge/peer/broadband 全禁),仅本机直连
|
|
71
71
|
// workbuddy/cline 系硬禁组员(ADR-0015 local-only):本机账号绑定(workbuddy auths/*.json + uid / cline refreshToken),
|
|
72
72
|
// 组员没有该账号转过去也用不了,且本地直连最快——无论全局开关一律仅本机直连。
|
|
73
|
-
// model 兼容 canonical(workbuddy/xxx、
|
|
73
|
+
// model 兼容 canonical(workbuddy/xxx、cline/xxx)与 dash(workbuddy-xxx、cline-xxx)两种形态。
|
|
74
74
|
export function isHardLocalOnly(model) {
|
|
75
75
|
const head = providerHeadOf(model);
|
|
76
76
|
if (!head) return false;
|
|
77
77
|
try {
|
|
78
78
|
return classifyProvider(head) === "local-only";
|
|
79
79
|
} catch {
|
|
80
|
-
return head === "workbuddy" || head === "cline"
|
|
80
|
+
return head === "workbuddy" || head === "cline";
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
package/src/sync-workbuddy.js
CHANGED
|
@@ -119,7 +119,7 @@ export async function syncToWorkbuddy({ id, token, port, file, keep } = {}) {
|
|
|
119
119
|
if (arr[idx].useCustomProtocol === undefined) arr[idx].useCustomProtocol = false;
|
|
120
120
|
action = "updated";
|
|
121
121
|
} else {
|
|
122
|
-
// 检查归一化 id 是否与已有条目冲突(如已存在
|
|
122
|
+
// 检查归一化 id 是否与已有条目冲突(如已存在 cline-z-ai-glm-5.3-flash 但不是本地 URL)
|
|
123
123
|
const conflict = arr.findIndex((m) => m && m.id === wbId);
|
|
124
124
|
if (conflict >= 0) {
|
|
125
125
|
// 已有同名条目且非我们写的,跳过不覆盖
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// 上游引擎选择(ADR-0017):sdk=AI SDK wire 层(缺省),legacy
|
|
1
|
+
// 上游引擎选择(ADR-0017):sdk=AI SDK wire 层(缺省),legacy=原实现(显式指定或 SDK 装载失败回退)。
|
|
2
2
|
// Note: 缺省即 sdk;chat 类走 /chat/completions(@ai-sdk/openai-compatible),
|
|
3
3
|
// responses 类(muse-spark*)走 /responses(@ai-sdk/openai);非流式与 anon 重试委派 legacy,
|
|
4
4
|
// SDK 不可用自动回退并告警一次 — 见 .scratch/ai-sdk-upstream/{SPEC.md,SPEC-p4-turnon.md,SPEC-p3-responses.md} 与 docs/adr/0017
|