mslxdff 0.1.64 → 0.1.66
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/bin/mslxdff.js +3 -3122
- package/package.json +1 -1
- package/src/chat/prompt.js +7 -2
- package/src/chat/repl.js +8 -4
- package/src/chat/upstream.js +271 -36
- package/src/cli/bootstrap.js +408 -0
- package/src/cli/commands/daemon.js +151 -0
- package/src/cli/commands/group.js +272 -0
- package/src/cli/commands/model.js +405 -0
- package/src/cli/commands/provider/add.js +106 -0
- package/src/cli/commands/provider/allowlist.js +99 -0
- package/src/cli/commands/provider/config.js +82 -0
- package/src/cli/commands/provider/index.js +193 -0
- package/src/cli/commands/provider/keys.js +135 -0
- package/src/cli/commands/provider/models.js +99 -0
- package/src/cli/commands/provider.js +1 -0
- package/src/cli/commands/sync.js +143 -0
- package/src/cli/commands/system.js +236 -0
- package/src/cli/commands/workbuddy.js +91 -0
- package/src/cli/format.js +119 -0
- package/src/cli/group-helpers.js +69 -0
- package/src/cli/help.js +66 -0
- package/src/cli/index.js +59 -0
- package/src/cli/interactive.js +84 -0
- package/src/cli/policy.js +119 -0
- package/src/cli/status.js +332 -0
- package/src/cli/util.js +24 -0
- package/src/models.js +15 -5
- package/src/providers/base.js +159 -0
- package/src/providers/generic.js +26 -166
- package/src/providers/keyring.js +19 -1
- package/src/providers/openrouter.js +32 -162
- package/src/providers/workbuddy.js +200 -131
- package/src/routes/chat/gateway.js +301 -0
- package/src/routes/chat/index.js +1 -239
- package/src/state/facade.js +57 -0
- package/src/state/memory.js +180 -0
- package/src/state/persist.js +42 -0
- package/src/state/schemas/allowlist.js +87 -0
- package/src/state/schemas/group.js +31 -0
- package/src/state/schemas/model.js +53 -0
- package/src/state/schemas/peer.js +31 -0
- package/src/state/schemas/port.js +10 -0
- package/src/state/schemas/provider.js +254 -0
- package/src/state/schemas/token.js +43 -0
- package/src/state/store.js +202 -0
- package/src/state.js +1 -711
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { createGroupsService, createBansService, refreshGroupMembers, syncPeersFromMembers } from "../../groups.js";
|
|
2
|
+
import { createPeersService } from "../../peers.js";
|
|
3
|
+
import { loadToken, loadGroupsJoined, saveGroupsJoined } from "../../state.js";
|
|
4
|
+
import { groupIs, markJoined, probeHealth, syncAllJoinedGroups } from "../group-helpers.js";
|
|
5
|
+
import { errMsg } from "../util.js";
|
|
6
|
+
import { argValue } from "../policy.js";
|
|
7
|
+
|
|
8
|
+
export async function handleGroupCreate(args) {
|
|
9
|
+
const createGroupArg = argValue(args, "-creategroup", "--creategroup") || groupIs("create", args);
|
|
10
|
+
if (!createGroupArg) return false;
|
|
11
|
+
const groups = createGroupsService({});
|
|
12
|
+
const peers = createPeersService({});
|
|
13
|
+
const name = createGroupArg;
|
|
14
|
+
const { created } = groups.create(name);
|
|
15
|
+
markJoined({ name, leaderUrl: "", myUrl: "", memberName: "leader" });
|
|
16
|
+
const synced = await syncAllJoinedGroups({ peers, groups });
|
|
17
|
+
const s = synced.find((x) => x.name === name);
|
|
18
|
+
console.log(created ? `group created: ${name}` : `group already exists: ${name}`);
|
|
19
|
+
console.log(`members on this node: ${s ? `${s.total} (failover: ${s.added})` : "?"}`);
|
|
20
|
+
console.log(`others join with: mslxdff -addtogroup <this-node-host> ${name}`);
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function handleGroupCommand(args) {
|
|
25
|
+
const groupCmd = argValue(args, "-group", "--group");
|
|
26
|
+
if (!groupCmd || groupCmd === "create") return false;
|
|
27
|
+
const groups = createGroupsService({});
|
|
28
|
+
const peers = createPeersService({});
|
|
29
|
+
const rest = args.slice(args.indexOf("-group") + 1);
|
|
30
|
+
const [action, a] = rest;
|
|
31
|
+
if (action === "sync") {
|
|
32
|
+
const synced = await syncAllJoinedGroups({ peers, groups });
|
|
33
|
+
if (!synced.length) {
|
|
34
|
+
console.log("not joined to any group (use -addtogroup or -creategroup)");
|
|
35
|
+
} else {
|
|
36
|
+
for (const s of synced) {
|
|
37
|
+
if (s.error) console.log(`${s.name}: sync failed — ${s.error}`);
|
|
38
|
+
else console.log(`${s.name}: ${s.total} member(s), ${s.added} failover target(s) configured`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} else if (action === "leave" && a) {
|
|
42
|
+
const before = loadGroupsJoined().filter((g) => g.name === a).length;
|
|
43
|
+
saveGroupsJoined(loadGroupsJoined().filter((g) => g.name !== a));
|
|
44
|
+
const removed = peers.removeByGroup(a);
|
|
45
|
+
console.log(before ? `left group "${a}" (${removed} member(s) removed)` : `not a member of group "${a}"`);
|
|
46
|
+
} else if (action === "list") {
|
|
47
|
+
const joinedList = loadGroupsJoined();
|
|
48
|
+
if (!joinedList.length) {
|
|
49
|
+
console.log("no groups on this node");
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
const { token } = await loadToken();
|
|
53
|
+
const fetchImpl = (url, opts) => fetch(url, { ...opts, signal: AbortSignal.timeout(1500) });
|
|
54
|
+
for (const g of joinedList) {
|
|
55
|
+
const isLeader = !g.leaderUrl;
|
|
56
|
+
let members;
|
|
57
|
+
if (isLeader) {
|
|
58
|
+
members = groups.list()[g.name]?.members || {};
|
|
59
|
+
} else {
|
|
60
|
+
try {
|
|
61
|
+
members = await refreshGroupMembers(g.name, {
|
|
62
|
+
leaderUrl: g.leaderUrl,
|
|
63
|
+
memberName: g.memberName,
|
|
64
|
+
url: g.myUrl,
|
|
65
|
+
token,
|
|
66
|
+
fetchImpl,
|
|
67
|
+
});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.log(`${g.name} (members unavailable — leader unreachable: ${errMsg(err)})`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const entries = Object.entries(members).filter(([id]) => id !== "leader");
|
|
74
|
+
console.log(`${g.name} (${entries.length} member${entries.length === 1 ? "" : "s"})`);
|
|
75
|
+
const probes = await Promise.all(
|
|
76
|
+
entries.map(([id, m]) => probeHealth({ id, url: m?.url || id, kind: m?.kind, lastSeen: m?.lastSeen, publicIp: m?.publicIp }))
|
|
77
|
+
);
|
|
78
|
+
const leaderEntry = members.leader ? Object.entries(members).find(([id]) => id === "leader") : null;
|
|
79
|
+
const leaderProbe = leaderEntry ? await probeHealth({ id: "leader", url: leaderEntry[1].url }) : null;
|
|
80
|
+
const display = leaderProbe ? [...probes, leaderProbe] : probes;
|
|
81
|
+
let seq = 0;
|
|
82
|
+
for (const r of display) {
|
|
83
|
+
const m = entries.find(([eid]) => eid === r.id)?.[1] || (r.id === "leader" ? leaderEntry?.[1] : null);
|
|
84
|
+
const isBb = r.kind === "broadband" || String(r.url || "").startsWith("relay://") || m?.kind === "broadband";
|
|
85
|
+
if (isBb) {
|
|
86
|
+
const ago = r.lastSeen ? `${Math.round((Date.now() - r.lastSeen) / 1000)}s ago` : "no heartbeat yet";
|
|
87
|
+
const ip = r.publicIp || m?.publicIp || "?";
|
|
88
|
+
const via = r.stale ? "stale" : `via leader ${ago}`;
|
|
89
|
+
const stateBb = `${via} ip=${ip}`;
|
|
90
|
+
if (r.id === "leader") {
|
|
91
|
+
console.log(` leader ${r.url} ${stateBb}`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
seq += 1;
|
|
95
|
+
const label = r.id && r.id !== r.url ? ` [${r.id}]` : "";
|
|
96
|
+
console.log(` ${seq}. ${r.url}${label} [broadband] ${stateBb}`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const state = r.fail ? `fail ${r.fail}` : `ok ${r.ms}ms`;
|
|
100
|
+
if (r.id === "leader") {
|
|
101
|
+
console.log(` leader ${r.url} ${state}`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
seq += 1;
|
|
105
|
+
const label = r.id && r.id !== r.url ? ` [${r.id}]` : "";
|
|
106
|
+
console.log(` ${seq}. ${r.url}${label} ${state}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
console.log(`\njoined groups (${joinedList.length}):`);
|
|
110
|
+
for (const g of joinedList) console.log(` ${g.name} ${g.leaderUrl || "(this node is the leader)"}`);
|
|
111
|
+
} else if (action === "remove" && a) {
|
|
112
|
+
const seq = Number(a);
|
|
113
|
+
if (!Number.isInteger(seq) || seq < 1) {
|
|
114
|
+
console.error(`usage: mslxdff -group remove <seq>`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
const joined = loadGroupsJoined().find((g) => !g.leaderUrl);
|
|
118
|
+
if (!joined) {
|
|
119
|
+
console.error("group remove requires being the leader — this node leads no group");
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
const members = groups.list()[joined.name]?.members || {};
|
|
123
|
+
const entries = Object.entries(members).filter(([id]) => id !== "leader");
|
|
124
|
+
const target = entries[seq - 1];
|
|
125
|
+
if (!target) {
|
|
126
|
+
console.error(`member #${seq} not found — group "${joined.name}" has ${entries.length} member(s)`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
const [id, m] = target;
|
|
130
|
+
try {
|
|
131
|
+
const removed = groups.removeMember(joined.name, { url: m.url });
|
|
132
|
+
if (removed) console.log(`removed ${m.url} from "${joined.name}"`);
|
|
133
|
+
else console.log(`member ${m.url} already gone from "${joined.name}"`);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error(`remove failed: ${errMsg(err)}`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
console.error("usage: mslxdff -group sync | -group leave <name> | -group list | -group remove <seq> | -creategroup <name>");
|
|
140
|
+
}
|
|
141
|
+
process.exit(0);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function handleAddToGroup(args) {
|
|
145
|
+
const addToGroupIdx = args.findIndex((x) => x === "-addtogroup" || x === "--addtogroup");
|
|
146
|
+
if (addToGroupIdx < 0) return false;
|
|
147
|
+
const rawArgs = args.slice(addToGroupIdx + 1);
|
|
148
|
+
const isBroadband = rawArgs.includes("--broadband");
|
|
149
|
+
const filtered = rawArgs.filter((a) => a !== "--broadband");
|
|
150
|
+
const [leaderHost, name] = filtered;
|
|
151
|
+
if (!leaderHost || !name || filtered.length > 2) {
|
|
152
|
+
console.error("usage: mslxdff -addtogroup <leader-host> <name> [--broadband]");
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
const groups = createGroupsService({});
|
|
156
|
+
const peers = createPeersService({});
|
|
157
|
+
const myToken = (await loadToken()).token;
|
|
158
|
+
const leaderUrl = leaderHost.includes("://")
|
|
159
|
+
? leaderHost.replace(/\/+$/, "")
|
|
160
|
+
: `http://${leaderHost}${leaderHost.includes(":") ? "" : ":8989"}`;
|
|
161
|
+
const kind = isBroadband ? "broadband" : "static";
|
|
162
|
+
let joinBody;
|
|
163
|
+
if (isBroadband) {
|
|
164
|
+
const relayId = `relay://${myToken.slice(0, 8)}`;
|
|
165
|
+
joinBody = { name, key: name, leaderUrl, url: relayId, token: myToken, kind: "broadband" };
|
|
166
|
+
} else {
|
|
167
|
+
const { effectivePort } = await import("../policy.js");
|
|
168
|
+
const myPort = effectivePort(args);
|
|
169
|
+
joinBody = { name, key: name, leaderUrl, myPort, token: myToken, kind: "static" };
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const res = await fetch(`${leaderUrl}/v1/groups/join`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { "Content-Type": "application/json" },
|
|
175
|
+
body: JSON.stringify(joinBody),
|
|
176
|
+
});
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
const text = await res.text().catch(() => "");
|
|
179
|
+
throw new Error(`join failed (HTTP ${res.status}): ${text}`);
|
|
180
|
+
}
|
|
181
|
+
const data = await res.json();
|
|
182
|
+
const myUrl = data.you?.url || joinBody.url || "";
|
|
183
|
+
const memberName = isBroadband ? myUrl : myUrl;
|
|
184
|
+
markJoined({ name, leaderUrl, myUrl, memberName, kind });
|
|
185
|
+
const synced = await syncAllJoinedGroups({ peers, groups });
|
|
186
|
+
const s = synced.find((x) => x.name === name);
|
|
187
|
+
console.log(`joined group "${name}" at ${leaderUrl}${isBroadband ? " [broadband]" : ""}`);
|
|
188
|
+
if (s?.error) console.log(` local failover setup failed: ${s.error}`);
|
|
189
|
+
else console.log(` ${s?.added ?? 0} failover target(s) configured${isBroadband ? " (broadband via leader, local 127.0.0.1)" : ""}`);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
console.error(`join failed: ${err.message}`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
process.exit(0);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function handleResetBan(args) {
|
|
198
|
+
const resetBanArg = argValue(args, "-resetban", "--resetban");
|
|
199
|
+
if (resetBanArg === null && !args.includes("-resetban") && !args.includes("--resetban")) return false;
|
|
200
|
+
const bans = createBansService({});
|
|
201
|
+
const ip = resetBanArg || null;
|
|
202
|
+
bans.clear(ip || undefined);
|
|
203
|
+
console.log(ip ? `ban cleared for ${ip}` : "all bans cleared");
|
|
204
|
+
process.exit(0);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function handleLeaveGroup(args) {
|
|
208
|
+
const leaveAll = args.includes("-leavegroup") || args.includes("--leavegroup") || args.includes("-leave-groups");
|
|
209
|
+
if (!leaveAll) return false;
|
|
210
|
+
const peers = createPeersService({});
|
|
211
|
+
const joined = loadGroupsJoined();
|
|
212
|
+
if (!joined.length) {
|
|
213
|
+
console.log("not joined to any group");
|
|
214
|
+
process.exit(0);
|
|
215
|
+
}
|
|
216
|
+
const myToken = (await loadToken()).token;
|
|
217
|
+
const leaders = [];
|
|
218
|
+
for (const g of joined) {
|
|
219
|
+
if (g.leaderUrl) {
|
|
220
|
+
const peersRemoved = peers.removeByGroup(g.name);
|
|
221
|
+
try {
|
|
222
|
+
const res = await fetch(`${g.leaderUrl}/v1/groups/leave`, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: {
|
|
225
|
+
"Content-Type": "application/json",
|
|
226
|
+
"Authorization": `Bearer ${myToken}`,
|
|
227
|
+
},
|
|
228
|
+
body: JSON.stringify({ name: g.name }),
|
|
229
|
+
});
|
|
230
|
+
if (res.ok) console.log(`${g.name}: left (deregistered from ${g.leaderUrl})`);
|
|
231
|
+
else console.log(`${g.name}: left locally (leader said: ${await res.text().catch(() => `HTTP ${res.status}`)})`);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
console.log(`${g.name}: left locally (leader unreachable: ${errMsg(err)})`);
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
leaders.push(g.name);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const left = joined.filter((g) => g.leaderUrl).map((g) => g.name);
|
|
240
|
+
saveGroupsJoined(loadGroupsJoined().filter((g) => !left.includes(g.name)));
|
|
241
|
+
console.log(`left ${left.length} group(s)`);
|
|
242
|
+
if (leaders.length) {
|
|
243
|
+
console.log(`\nskipped ${leaders.length} group(s) where this node is the leader:`);
|
|
244
|
+
for (const n of leaders) console.log(` ${n} — leaders can't leave; disband it with: mslxdff -delgroup ${n}`);
|
|
245
|
+
}
|
|
246
|
+
process.exit(0);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function handleDelGroup(args) {
|
|
250
|
+
const delGroupName = argValue(args, "-delgroup", "--delgroup");
|
|
251
|
+
if (!delGroupName) return false;
|
|
252
|
+
const groups = createGroupsService({});
|
|
253
|
+
const peers = createPeersService({});
|
|
254
|
+
const local = groups.list()[delGroupName];
|
|
255
|
+
if (!local) {
|
|
256
|
+
const joined0 = loadGroupsJoined().find((g) => g.name === delGroupName);
|
|
257
|
+
if (joined0?.leaderUrl) {
|
|
258
|
+
console.log(`"${delGroupName}" is led by ${joined0.leaderUrl} — you are a member, use -leavegroup to leave it`);
|
|
259
|
+
} else if (joined0) {
|
|
260
|
+
console.log(`"${delGroupName}" exists in local state but has no group definition — nothing to delete`);
|
|
261
|
+
} else {
|
|
262
|
+
console.log(`group "${delGroupName}" not found on this node`);
|
|
263
|
+
}
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
const disbanded = groups.delete(delGroupName);
|
|
267
|
+
const members = Object.values(disbanded?.members || {});
|
|
268
|
+
peers.removeByGroup(delGroupName);
|
|
269
|
+
saveGroupsJoined(loadGroupsJoined().filter((g) => g.name !== delGroupName));
|
|
270
|
+
console.log(`group "${delGroupName}" disbanded (${members.length} member${members.length === 1 ? "" : "s"} removed)`);
|
|
271
|
+
process.exit(0);
|
|
272
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { createModelsService } from "../../models.js";
|
|
5
|
+
import { createUpstreamClient } from "../../upstream.js";
|
|
6
|
+
import { logDir } from "../../logs.js";
|
|
7
|
+
import { loadModelErrors, savePreferredModel, loadPreferredModel, loadModelPicks, saveModelPicks } from "../../state.js";
|
|
8
|
+
import { getPreferredModel } from "../../auto.js";
|
|
9
|
+
import { fmtShanghaiYMDHM } from "../../time.js";
|
|
10
|
+
import { readModelsCache } from "../util.js";
|
|
11
|
+
import { pickInteractiveMulti } from "../interactive.js";
|
|
12
|
+
|
|
13
|
+
export async function handleModel(args) {
|
|
14
|
+
if (!(args.includes("-model") || args.includes("-models"))) return false;
|
|
15
|
+
const idx = args.findIndex((x) => x === "-model" || x === "-models");
|
|
16
|
+
const sub = args[idx + 1];
|
|
17
|
+
if (sub === "refresh") {
|
|
18
|
+
const models = createModelsService({
|
|
19
|
+
baseUrl: process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
|
|
20
|
+
headers: createUpstreamClient({}).headers,
|
|
21
|
+
refreshMs: 0,
|
|
22
|
+
cacheFile: join(logDir(), "models.json"),
|
|
23
|
+
});
|
|
24
|
+
try {
|
|
25
|
+
const list = await models.get();
|
|
26
|
+
const ids = (list.data || []).map((m) => m.id).filter(Boolean);
|
|
27
|
+
console.log(`refreshed: ${ids.length} free model(s)`);
|
|
28
|
+
for (const id of ids) console.log(` ${id}`);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.error(`could not refresh models: ${String(err?.message || err)}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
if (sub === "status") {
|
|
36
|
+
const statuses = loadModelErrors();
|
|
37
|
+
const cacheFile = join(logDir(), "models.json");
|
|
38
|
+
const cached = readModelsCache(cacheFile);
|
|
39
|
+
const ids = new Set([
|
|
40
|
+
...(cached?.data || []).map((m) => m.id),
|
|
41
|
+
...Object.keys(statuses),
|
|
42
|
+
]);
|
|
43
|
+
for (const id of ids) {
|
|
44
|
+
const e = statuses[id];
|
|
45
|
+
const st = typeof e === "number" ? "error" : e?.status || "normal";
|
|
46
|
+
const at = typeof e === "number" ? e : e?.at;
|
|
47
|
+
const when = at ? ` (${fmtShanghaiYMDHM ? fmtShanghaiYMDHM(at) : at})` : "";
|
|
48
|
+
const extra = e?.code ? ` HTTP ${e.code}` : "";
|
|
49
|
+
console.log(` ${id} ${st}${when}${extra}`);
|
|
50
|
+
}
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
if (sub === "set" && args[idx + 2]) {
|
|
54
|
+
const id = args[idx + 2];
|
|
55
|
+
savePreferredModel(id);
|
|
56
|
+
const picks = [...new Set([...loadModelPicks(), id])];
|
|
57
|
+
saveModelPicks(picks);
|
|
58
|
+
console.log(`default model set to: ${id} (daemon hot-reloads on next request)`);
|
|
59
|
+
console.log(`picked: ${picks.join(", ") || "(none)"} (auto will pick within these)`);
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
if (sub === "pick" && args[idx + 2] && args[idx + 2] !== "clear") {
|
|
63
|
+
const picks = [...new Set([...loadModelPicks(), args[idx + 2]])];
|
|
64
|
+
saveModelPicks(picks);
|
|
65
|
+
console.log(`picked: ${picks.join(", ") || "(none)"} (auto will pick within these)`);
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
if (sub === "pick" && args[idx + 2] === "clear") {
|
|
69
|
+
saveModelPicks([]);
|
|
70
|
+
console.log("picks cleared — auto uses the full model list again");
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
if (sub === "unpick" && args[idx + 2]) {
|
|
74
|
+
const picks = loadModelPicks().filter((x) => x !== args[idx + 2]);
|
|
75
|
+
saveModelPicks(picks);
|
|
76
|
+
console.log(`picked: ${picks.join(", ") || "(none)"}${picks.length === 0 ? " (auto uses full list)" : ""}`);
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
if (sub === "picks") {
|
|
80
|
+
const picks = loadModelPicks();
|
|
81
|
+
if (!picks.length) {
|
|
82
|
+
console.log("no picks — auto uses the full model list");
|
|
83
|
+
} else {
|
|
84
|
+
console.log(`${picks.length} picked model(s), auto only selects within these:`);
|
|
85
|
+
}
|
|
86
|
+
for (const id of picks) console.log(` ${id}`);
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
let modelListProvider = null;
|
|
90
|
+
let modelListJson = false;
|
|
91
|
+
if (sub === "list") {
|
|
92
|
+
const restArgs = args.slice(idx + 2);
|
|
93
|
+
for (let i = 0; i < restArgs.length; i++) {
|
|
94
|
+
const a = String(restArgs[i] || "");
|
|
95
|
+
if (a === "--json" || a === "-json") modelListJson = true;
|
|
96
|
+
else if (a === "--provider" || a === "-provider" || a === "--providerId") { modelListProvider = String(restArgs[i + 1] || "").trim() || null; i++; }
|
|
97
|
+
else if (!a.startsWith("-") && !modelListProvider) modelListProvider = a;
|
|
98
|
+
}
|
|
99
|
+
if (modelListProvider) {
|
|
100
|
+
const { normalizeProviderId } = await import("../../providers/model-id.js");
|
|
101
|
+
const nid = normalizeProviderId(modelListProvider);
|
|
102
|
+
modelListProvider = nid || modelListProvider.toLowerCase();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (sub !== undefined && sub !== "list") {
|
|
106
|
+
console.error("usage: mslxdff -models (interactive multi-pick) | mslxdff -model list [--provider <id>] [--json] | mslxdff -model set <id> | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear | mslxdff -model picks | mslxdff -model status | mslxdff -model refresh");
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
const cacheFile = join(logDir(), "models.json");
|
|
110
|
+
async function tryRefreshModels() {
|
|
111
|
+
try {
|
|
112
|
+
const models = createModelsService({
|
|
113
|
+
baseUrl: process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
|
|
114
|
+
headers: createUpstreamClient({}).headers,
|
|
115
|
+
refreshMs: 0,
|
|
116
|
+
cacheFile,
|
|
117
|
+
});
|
|
118
|
+
const list = await Promise.race([
|
|
119
|
+
models.get(),
|
|
120
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error("refresh timeout")), 4000)),
|
|
121
|
+
]);
|
|
122
|
+
return list;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
let ids = [];
|
|
129
|
+
let cachedAt = null;
|
|
130
|
+
let refreshed = null;
|
|
131
|
+
refreshed = await tryRefreshModels();
|
|
132
|
+
if (refreshed?.data) {
|
|
133
|
+
ids = (refreshed.data || []).map((m) => m.id).filter(Boolean);
|
|
134
|
+
cachedAt = refreshed.cachedAt || Date.now();
|
|
135
|
+
} else {
|
|
136
|
+
const cached = readModelsCache(cacheFile);
|
|
137
|
+
if (cached) {
|
|
138
|
+
ids = (cached.data || []).map((m) => m.id).filter(Boolean);
|
|
139
|
+
cachedAt = cached.cachedAt || null;
|
|
140
|
+
} else {
|
|
141
|
+
throw new Error("no cached models and refresh failed");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (modelListProvider) {
|
|
145
|
+
const prov = String(modelListProvider).toLowerCase();
|
|
146
|
+
const filtered = ids.filter((id) => {
|
|
147
|
+
const slash = String(id).indexOf("/");
|
|
148
|
+
const p = slash > 0 ? String(id).slice(0, slash).toLowerCase() : "opencode";
|
|
149
|
+
return p === prov;
|
|
150
|
+
});
|
|
151
|
+
if (prov !== "opencode" && filtered.length === 0) {
|
|
152
|
+
try {
|
|
153
|
+
const { loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } = await import("../../state.js");
|
|
154
|
+
const { loadModelAliases, getAliasForModel } = await import("../../providers/model-id.js");
|
|
155
|
+
try { loadModelAliases(); } catch {}
|
|
156
|
+
const allowed = loadProviderAllowedModels(prov);
|
|
157
|
+
const allowAny = loadProviderAllowAnyModels(prov);
|
|
158
|
+
const baseUrl = loadProviderBaseUrl(prov);
|
|
159
|
+
if (modelListJson) {
|
|
160
|
+
const data = allowed.length
|
|
161
|
+
? allowed.map((raw) => ({ id: `${prov}/${raw}`, object: "model" }))
|
|
162
|
+
: [];
|
|
163
|
+
console.log(JSON.stringify({ object: "list", data }, null, 2));
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
if (!allowed.length) {
|
|
167
|
+
if (allowAny) {
|
|
168
|
+
console.log(`provider "${prov}" allowAny ON (allowlist 空=放行全部)${baseUrl ? ` baseUrl=${baseUrl}` : ""}`);
|
|
169
|
+
console.log(` (未设 allowlist,全部模型放行) 查看 live 列表: mslxdff -provider ${prov} models`);
|
|
170
|
+
} else {
|
|
171
|
+
console.log(`no models for provider "${prov}" — allowlist 空 + allowAny OFF = 阻塞`);
|
|
172
|
+
console.log(` 设白名单: mslxdff -provider ${prov} allowlist set <model1> <model2> 或 mslxdff -provider ${prov} allowAny on`);
|
|
173
|
+
console.log(` live 查看: mslxdff -provider ${prov} models`);
|
|
174
|
+
}
|
|
175
|
+
process.exit(0);
|
|
176
|
+
}
|
|
177
|
+
const at2 = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
|
|
178
|
+
console.log(`${allowed.length} model(s) for ${prov}${at2} (allowlist,原名 + 别名):`);
|
|
179
|
+
const pickedIds2 = loadModelPicks();
|
|
180
|
+
for (const raw of allowed) {
|
|
181
|
+
const canonical = `${prov}/${raw}`;
|
|
182
|
+
let alias = null;
|
|
183
|
+
try { alias = getAliasForModel(canonical); } catch {}
|
|
184
|
+
if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
|
|
185
|
+
const aliasStr = alias && alias !== canonical ? ` (别名: ${alias})` : "";
|
|
186
|
+
const mark2 = pickedIds2.includes(canonical) || (alias && pickedIds2.includes(alias)) ? "*" : " ";
|
|
187
|
+
console.log(` ${mark2} ${canonical}${aliasStr}`);
|
|
188
|
+
}
|
|
189
|
+
process.exit(0);
|
|
190
|
+
} catch {}
|
|
191
|
+
}
|
|
192
|
+
ids = filtered;
|
|
193
|
+
if (modelListJson) {
|
|
194
|
+
console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
if (!ids.length) {
|
|
198
|
+
console.log(`no models for provider "${prov}" — try: mslxdff -provider ${prov} models or mslxdff -model refresh`);
|
|
199
|
+
process.exit(0);
|
|
200
|
+
}
|
|
201
|
+
} else if (modelListJson) {
|
|
202
|
+
console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
|
|
203
|
+
process.exit(0);
|
|
204
|
+
}
|
|
205
|
+
if (!ids.length) {
|
|
206
|
+
console.log("no models available — try: mslxdff -model refresh");
|
|
207
|
+
process.exit(0);
|
|
208
|
+
}
|
|
209
|
+
if (sub === undefined && process.stdin.isTTY && process.stdout.isTTY) {
|
|
210
|
+
const statuses = loadModelErrors();
|
|
211
|
+
const current = getPreferredModel();
|
|
212
|
+
const pickedIds = loadModelPicks();
|
|
213
|
+
const combinedIds = [...ids];
|
|
214
|
+
const seen = new Set(combinedIds);
|
|
215
|
+
try {
|
|
216
|
+
const { loadProviderConfigs, loadProviderAllowedModels } = await import("../../state.js");
|
|
217
|
+
const configs = loadProviderConfigs();
|
|
218
|
+
for (const pid of Object.keys(configs).filter((k) => String(k).toLowerCase() !== "opencode")) {
|
|
219
|
+
const allowed = loadProviderAllowedModels(pid);
|
|
220
|
+
for (const raw of allowed) {
|
|
221
|
+
const canonical = `${pid}/${raw}`;
|
|
222
|
+
if (!seen.has(canonical)) {
|
|
223
|
+
seen.add(canonical);
|
|
224
|
+
combinedIds.push(canonical);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch {}
|
|
229
|
+
for (const pid of pickedIds) {
|
|
230
|
+
if (!seen.has(pid)) {
|
|
231
|
+
seen.add(pid);
|
|
232
|
+
combinedIds.push(pid);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const items = combinedIds.map((id) => {
|
|
236
|
+
const e = statuses[id];
|
|
237
|
+
return {
|
|
238
|
+
id,
|
|
239
|
+
status: typeof e === "number" ? "error" : e?.status || "normal",
|
|
240
|
+
current: id === current,
|
|
241
|
+
picked: pickedIds.includes(id),
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
const result = await pickInteractiveMulti(items, new Set(pickedIds), Math.max(0, items.findIndex((x) => x.current)));
|
|
245
|
+
if (!result) {
|
|
246
|
+
console.log("cancelled — picks unchanged");
|
|
247
|
+
process.exit(0);
|
|
248
|
+
}
|
|
249
|
+
saveModelPicks([...result]);
|
|
250
|
+
console.log(`saved ${result.size} picked model(s): ${[...result].join(", ") || "(none — auto uses full list)"}`);
|
|
251
|
+
process.exit(0);
|
|
252
|
+
}
|
|
253
|
+
const at = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
|
|
254
|
+
const pickedIds = loadModelPicks();
|
|
255
|
+
const mark = (id) => (pickedIds.includes(id) ? "*" : " ");
|
|
256
|
+
const groups = {};
|
|
257
|
+
for (const id of ids) {
|
|
258
|
+
const prov = String(id).includes("/") ? String(id).split("/")[0] : "opencode";
|
|
259
|
+
if (!groups[prov]) groups[prov] = [];
|
|
260
|
+
groups[prov].push(id);
|
|
261
|
+
}
|
|
262
|
+
const order = ["opencode", "workbuddy", "clinebot", "openrouter"];
|
|
263
|
+
const sortedProvs = Object.keys(groups).sort((a, b) => {
|
|
264
|
+
const ia = order.indexOf(a), ib = order.indexOf(b);
|
|
265
|
+
if (ia !== -1 || ib !== -1) {
|
|
266
|
+
if (ia === -1) return 1;
|
|
267
|
+
if (ib === -1) return -1;
|
|
268
|
+
return ia - ib;
|
|
269
|
+
}
|
|
270
|
+
return a.localeCompare(b);
|
|
271
|
+
});
|
|
272
|
+
if (modelListProvider) {
|
|
273
|
+
console.log(`${ids.length} model(s) for ${modelListProvider}${at} (${pickedIds.length} picked, * = picked):`);
|
|
274
|
+
let aliasMap = {};
|
|
275
|
+
try {
|
|
276
|
+
const { loadModelAliases, getAliasForModel } = await import("../../providers/model-id.js");
|
|
277
|
+
loadModelAliases();
|
|
278
|
+
for (const id of ids) {
|
|
279
|
+
const alias = getAliasForModel(id);
|
|
280
|
+
if (alias) aliasMap[id] = alias;
|
|
281
|
+
else if (String(id).includes("/")) {
|
|
282
|
+
const dashAlias = String(id).replace(/\//g, "-");
|
|
283
|
+
if (dashAlias !== id) aliasMap[id] = dashAlias;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch {}
|
|
287
|
+
for (const prov of sortedProvs) {
|
|
288
|
+
const list = groups[prov];
|
|
289
|
+
console.log(`\n ── ${prov} (${list.length}) ──`);
|
|
290
|
+
for (const id of list) {
|
|
291
|
+
const alias = aliasMap[id];
|
|
292
|
+
const aliasStr = alias ? ` (别名: ${alias})` : "";
|
|
293
|
+
console.log(` ${mark(id)} ${id}${aliasStr}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
|
|
297
|
+
} else {
|
|
298
|
+
console.log(`${ids.length} free model(s)${at} (${pickedIds.length} picked, * = picked):`);
|
|
299
|
+
let aliasMap = {};
|
|
300
|
+
let fullAliases = {};
|
|
301
|
+
try {
|
|
302
|
+
const { loadModelAliases, getAliasForModel } = await import("../../providers/model-id.js");
|
|
303
|
+
loadModelAliases();
|
|
304
|
+
for (const id of ids) {
|
|
305
|
+
const alias = getAliasForModel(id);
|
|
306
|
+
if (alias) aliasMap[id] = alias;
|
|
307
|
+
else if (String(id).includes("/")) {
|
|
308
|
+
const dashAlias = String(id).replace(/\//g, "-");
|
|
309
|
+
if (dashAlias !== id) aliasMap[id] = dashAlias;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const aliasesFile = join(homedir(), ".config", "mslxdff", "model-aliases.json");
|
|
314
|
+
const raw = JSON.parse(readFileSync(aliasesFile, "utf8"));
|
|
315
|
+
if (raw && typeof raw === "object") fullAliases = raw;
|
|
316
|
+
} catch {}
|
|
317
|
+
} catch {}
|
|
318
|
+
for (const prov of sortedProvs) {
|
|
319
|
+
const list = groups[prov];
|
|
320
|
+
console.log(`\n ── ${prov} (${list.length}) ──`);
|
|
321
|
+
for (const id of list) {
|
|
322
|
+
const alias = aliasMap[id];
|
|
323
|
+
const aliasStr = alias ? ` (别名: ${alias})` : "";
|
|
324
|
+
console.log(` ${mark(id)} ${id}${aliasStr}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
const { loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } = await import("../../state.js");
|
|
329
|
+
const { loadModelAliases: _la2, getAliasForModel: _gaf } = await import("../../providers/model-id.js");
|
|
330
|
+
try { _la2(); } catch {}
|
|
331
|
+
const configs = loadProviderConfigs();
|
|
332
|
+
const otherIds = Object.keys(configs).filter((k) => String(k).toLowerCase() !== "opencode");
|
|
333
|
+
const order2 = ["workbuddy", "clinebot", "openrouter", "bai"];
|
|
334
|
+
otherIds.sort((a, b) => {
|
|
335
|
+
const ia = order2.indexOf(a), ib = order2.indexOf(b);
|
|
336
|
+
if (ia !== -1 || ib !== -1) {
|
|
337
|
+
if (ia === -1) return 1;
|
|
338
|
+
if (ib === -1) return -1;
|
|
339
|
+
return ia - ib;
|
|
340
|
+
}
|
|
341
|
+
return a.localeCompare(b);
|
|
342
|
+
});
|
|
343
|
+
if (otherIds.length) {
|
|
344
|
+
console.log(`\n────────────────────────────────────────`);
|
|
345
|
+
console.log(`其他供应商 (allowlist,原名 + 别名) (${otherIds.length} providers):`);
|
|
346
|
+
for (const pid of otherIds) {
|
|
347
|
+
const allowed = loadProviderAllowedModels(pid);
|
|
348
|
+
const allowAny = loadProviderAllowAnyModels(pid);
|
|
349
|
+
const baseUrl = loadProviderBaseUrl(pid) || configs[pid]?.baseUrl || "";
|
|
350
|
+
const header = allowAny
|
|
351
|
+
? (allowed.length ? `allowlist ${allowed.length} (allowAny ON)` : `allowAny ON (allowlist 空=放行全部)`)
|
|
352
|
+
: (allowed.length ? `allowlist ${allowed.length} (allowAny OFF)` : `allowlist 空 + allowAny OFF = 阻塞`);
|
|
353
|
+
console.log(`\n ── ${pid} (${header})${baseUrl ? ` baseUrl=${baseUrl}` : ""} ──`);
|
|
354
|
+
if (!allowed.length) {
|
|
355
|
+
if (allowAny) {
|
|
356
|
+
console.log(` (未设 allowlist,全部模型放行) 查看 live 列表: mslxdff -provider ${pid} models`);
|
|
357
|
+
console.log(` 限制可用模型: mslxdff -provider ${pid} allowlist set <model1> <model2>`);
|
|
358
|
+
} else {
|
|
359
|
+
console.log(` 阻塞中:无可用模型 — 设白名单: mslxdff -provider ${pid} allowlist set <model1> <model2>`);
|
|
360
|
+
console.log(` 或放行全部: mslxdff -provider ${pid} allowAny on`);
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
for (const raw of allowed) {
|
|
364
|
+
const canonical = `${pid}/${raw}`;
|
|
365
|
+
let alias = null;
|
|
366
|
+
try { alias = _gaf(canonical); } catch {}
|
|
367
|
+
if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
|
|
368
|
+
const aliasStr = alias && alias !== canonical ? ` (别名: ${alias})` : "";
|
|
369
|
+
const pickedMark = pickedIds.includes(canonical) || pickedIds.includes(alias || "") ? "*" : " ";
|
|
370
|
+
console.log(` ${pickedMark} ${canonical}${aliasStr}`);
|
|
371
|
+
}
|
|
372
|
+
console.log(` 管理: mslxdff -provider ${pid} allowlist [list|add|remove|clear] | allowAny on|off`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
console.log(`\n────────────────────────────────────────`);
|
|
377
|
+
console.log(`其他供应商 (allowlist,原名 + 别名): (none — 尚未配置)`);
|
|
378
|
+
console.log(` 添加示例: mslxdff -provider add myapi https://api.example.com/v1 sk-xxx --models-path /v1/models`);
|
|
379
|
+
}
|
|
380
|
+
const aliasEntries = Object.entries(fullAliases).filter(([alias, canonical]) => {
|
|
381
|
+
if (ids.includes(canonical)) return false;
|
|
382
|
+
for (const pid of otherIds) {
|
|
383
|
+
const allowed = loadProviderAllowedModels(pid);
|
|
384
|
+
for (const raw of allowed) {
|
|
385
|
+
const can = `${pid}/${raw}`;
|
|
386
|
+
if (can === canonical) return false;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return true;
|
|
390
|
+
});
|
|
391
|
+
if (aliasEntries.length) {
|
|
392
|
+
console.log(`\n 本地别名 (不在 allowlist 里的遗留映射 ${aliasEntries.length}):`);
|
|
393
|
+
for (const [alias, canonical] of aliasEntries) {
|
|
394
|
+
console.log(` ${canonical} => ${alias}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
} catch {}
|
|
398
|
+
console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
|
|
399
|
+
}
|
|
400
|
+
} catch (err) {
|
|
401
|
+
console.error(`could not fetch models: ${String(err?.message || err)}`);
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
process.exit(0);
|
|
405
|
+
}
|