mslxdff 0.1.88 → 0.1.90
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 +1 -1
- package/src/bench/cline-bench.js +21 -33
- package/src/bench/probe.js +4 -11
- package/src/bench/runner.js +8 -23
- package/src/bench/via-probe.js +12 -30
- package/src/bench/workbuddy-bench.js +15 -31
- package/src/chat/engine.js +160 -0
- package/src/chat/repl.js +32 -299
- package/src/chat/terminal.js +95 -0
- package/src/chat/tool-handlers.js +67 -0
- package/src/chat/upstream.js +1 -4
- package/src/chat-pipeline/auto-race.js +124 -0
- package/src/chat-pipeline/engine.js +17 -0
- package/src/chat-pipeline/index.js +86 -0
- package/src/chat-pipeline/planner.js +31 -0
- package/src/chat-pipeline/policy.js +73 -0
- package/src/chat-pipeline/serial-trial.js +144 -0
- package/src/cli/commands/model/list-providers.js +75 -0
- package/src/cli/commands/model/list-render.js +79 -0
- package/src/cli/commands/model/list.js +208 -0
- package/src/cli/commands/model/picks.js +45 -0
- package/src/cli/commands/model/stats.js +43 -0
- package/src/cli/commands/model/status.js +47 -0
- package/src/cli/commands/model.js +17 -371
- package/src/cli/help.js +2 -1
- package/src/providers/cline/chat.js +26 -53
- package/src/providers/workbuddy/chat.js +109 -221
- package/src/routes/chat/gateway.js +23 -286
- package/src/routes/chat/relay-pipeline.js +26 -0
- package/src/routes/groups-relay.js +85 -1
- package/src/routes/index.js +7 -1
- package/src/routes/relay-queue.js +53 -0
- package/src/runtime/bootstrap.js +14 -387
- package/src/runtime/broadband-stream.js +76 -0
- package/src/runtime/broadband.js +97 -0
- package/src/runtime/group-sync.js +28 -0
- package/src/runtime/providers-setup.js +147 -0
- package/src/runtime/server-lifecycle.js +156 -0
- package/src/state/facade.js +1 -0
- package/src/state/schemas/model.js +41 -0
- package/src/transport/index.js +248 -0
- package/src/transport/pool.js +60 -0
- package/src/transport/retry.js +24 -0
- package/src/transport/sse.js +93 -0
- package/src/upstream-responses.js +64 -0
- package/src/upstream.js +106 -334
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { injectReasoningContent } from "../reasoning.js";
|
|
3
|
+
import { runHook } from "../plugins.js";
|
|
4
|
+
import { errMsg } from "../routes/helpers.js";
|
|
5
|
+
import { handleLocalRelay } from "../routes/chat/local-handler.js";
|
|
6
|
+
import { handleExhaustedAll } from "../routes/chat/exhausted-handler.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* auto 首次并发择优 — 从 engine.js 抽出的第一段。
|
|
10
|
+
* 无 prior-success 且多非冷却候选时并发竞速,胜者走 local relay。
|
|
11
|
+
* 返回 { done:true } 表示已终结(handled/exhausted),调用方直接 return;
|
|
12
|
+
* 返回 { done:false, order } 表示未终结,调用方用过滤后的 order 继续。
|
|
13
|
+
*/
|
|
14
|
+
export async function runAutoRace(ctx, deps = {}) {
|
|
15
|
+
const { localRelay = handleLocalRelay, exhaustedAll = handleExhaustedAll } = deps;
|
|
16
|
+
const {
|
|
17
|
+
reqId, requested, body, hops, useAuto, lockModel, plugins,
|
|
18
|
+
auto, upstream, perf0, stages, mark, evt, logCall, logError,
|
|
19
|
+
startedAt, handlerCtx, res,
|
|
20
|
+
} = ctx;
|
|
21
|
+
let order = ctx.order;
|
|
22
|
+
const shareKeys = ctx.shareKeys ?? ctx.policy?.shareKeys ?? {};
|
|
23
|
+
const workbuddyUid = ctx.workbuddyUid ?? ctx.policy?.workbuddyUid ?? null;
|
|
24
|
+
|
|
25
|
+
if (!(useAuto && order.length > 1 && auto && !lockModel)) return { done: false, order };
|
|
26
|
+
const statuses = auto.statuses?.() ?? {};
|
|
27
|
+
const hasPriorSuccess = Object.values(statuses).some((e) => e && typeof e === "object" && e.status === "normal");
|
|
28
|
+
const nonCoolingOrder = order.filter((m) => { try { return !auto.isCooling(m); } catch { return true; } });
|
|
29
|
+
if (hasPriorSuccess || nonCoolingOrder.length <= 1) return { done: false, order };
|
|
30
|
+
|
|
31
|
+
const concLimit = (() => {
|
|
32
|
+
const v = Number(process.env.MSLXDFF_AUTO_CONCURRENT);
|
|
33
|
+
if (Number.isInteger(v) && v > 0) return Math.min(v, nonCoolingOrder.length);
|
|
34
|
+
return Math.min(nonCoolingOrder.length, 5);
|
|
35
|
+
})();
|
|
36
|
+
let raceModels = nonCoolingOrder.slice(0, concLimit);
|
|
37
|
+
if (plugins?.length) {
|
|
38
|
+
const k = [];
|
|
39
|
+
for (const m of raceModels) {
|
|
40
|
+
const b = await runHook(plugins, "model:beforeTry", { reqId, requested, model: m, hops });
|
|
41
|
+
if (b.value === false || b.value?.skip) continue;
|
|
42
|
+
k.push(m);
|
|
43
|
+
}
|
|
44
|
+
raceModels = k;
|
|
45
|
+
}
|
|
46
|
+
if (!raceModels.length) {
|
|
47
|
+
order = order.filter((m) => !new Set(nonCoolingOrder.slice(0, concLimit)).has(m));
|
|
48
|
+
if (!order.length) {
|
|
49
|
+
await exhaustedAll({ res, body, lastErr: { model: requested, status: 502, message: "all concurrent candidates skipped by plugin" }, order: nonCoolingOrder.slice(0, concLimit), requested, handlerCtx: { ...handlerCtx, reqId, startedAt }, evt, logCall, mark, perf0, stages });
|
|
50
|
+
return { done: true };
|
|
51
|
+
}
|
|
52
|
+
return { done: false, order };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
evt("auto-concurrent-race", { reqId, models: raceModels, skippedFaulty: order.length - nonCoolingOrder.length, limit: concLimit });
|
|
56
|
+
const raceStart = performance.now();
|
|
57
|
+
const attempts = raceModels.map(async (m) => {
|
|
58
|
+
let f = { ...injectReasoningContent(m, body), model: m };
|
|
59
|
+
if (plugins?.length) {
|
|
60
|
+
const u = await runHook(plugins, "upstream:request", { reqId, requested, model: m, payload: f, stream: Boolean(body.stream) });
|
|
61
|
+
if (u.changed && u.value?.payload) f = u.value.payload;
|
|
62
|
+
}
|
|
63
|
+
let r = null;
|
|
64
|
+
try {
|
|
65
|
+
const o = {};
|
|
66
|
+
if (Object.keys(shareKeys).length) o.shareKeys = shareKeys;
|
|
67
|
+
if (workbuddyUid) o.workbuddyUid = workbuddyUid;
|
|
68
|
+
r = await upstream.chat(f, Object.keys(o).length ? o : undefined);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
if (plugins?.length) runHook(plugins, "upstream:response", { reqId, requested, model: m, status: null, ok: false, error: errMsg(e), timing: e?._t ?? null }).catch(() => {});
|
|
71
|
+
return { model: m, ok: false, error: errMsg(e), status: 502, timing: e?._t ?? null };
|
|
72
|
+
}
|
|
73
|
+
if (plugins?.length) runHook(plugins, "upstream:response", { reqId, requested, model: m, status: r instanceof Error ? null : r?.status ?? null, ok: !(r instanceof Error) && r ? r.status < 400 : false, error: r instanceof Error ? errMsg(r) : null, timing: r?._t ?? null }).catch(() => {});
|
|
74
|
+
if (r && r.status >= 400) {
|
|
75
|
+
const a = r.status === 403 && r.headers?.get?.("x-mslxdff-allowlist") === "1";
|
|
76
|
+
if (a) return { model: m, ok: false, error: "allowlist", status: 403, allowlist: true };
|
|
77
|
+
return { model: m, ok: false, error: `upstream ${r.status}`, status: r.status, res: r, timing: r._t ?? null };
|
|
78
|
+
}
|
|
79
|
+
if (r instanceof Error) return { model: m, ok: false, error: errMsg(r), status: 502 };
|
|
80
|
+
return { model: m, ok: true, res: r, status: r.status, timing: r._t ?? null };
|
|
81
|
+
});
|
|
82
|
+
const results = await Promise.allSettled(attempts);
|
|
83
|
+
const okList = results.map((r, i) => ({ r, i, model: raceModels[i] }))
|
|
84
|
+
.filter(({ r }) => r.status === "fulfilled" && r.value?.ok)
|
|
85
|
+
.map(({ r, i, model }) => ({ model, idx: i, val: r.value, t: r.value.timing?.totalMs ?? r.value.timing?.ms ?? Number.MAX_SAFE_INTEGER }));
|
|
86
|
+
if (okList.length) {
|
|
87
|
+
okList.sort((a, b) => a.t - b.t);
|
|
88
|
+
const best = okList[0];
|
|
89
|
+
const winModel = best.model;
|
|
90
|
+
evt("auto-concurrent-win", { reqId, model: winModel, timing: best.val.timing, totalMs: Math.round(performance.now() - raceStart), tried: raceModels.length });
|
|
91
|
+
for (const { r, i } of results.map((r, i) => ({ r, i }))) {
|
|
92
|
+
const m = raceModels[i];
|
|
93
|
+
if (r.status === "fulfilled" && r.value?.ok) {
|
|
94
|
+
if (m === winModel) {
|
|
95
|
+
const latencyMs = r.value.timing?.totalMs ?? Math.round(performance.now() - raceStart);
|
|
96
|
+
await auto.recordOk(m, { latencyMs });
|
|
97
|
+
try { const { savePreferredModel } = await import("../state.js"); savePreferredModel(m); evt("auto-concurrent-preferred", { reqId, model: m }); } catch {}
|
|
98
|
+
}
|
|
99
|
+
} else if (r.status === "fulfilled" && !r.value?.ok && !r.value?.allowlist) await auto.recordError(m, { status: r.value.status || 502 });
|
|
100
|
+
else if (r.status === "rejected") await auto.recordError(m, { status: 502 });
|
|
101
|
+
}
|
|
102
|
+
handlerCtx.model = winModel;
|
|
103
|
+
const lr = await localRelay({ upRes: best.val.res, model: winModel, body, order: raceModels, idx: best.idx, lastErr: null, requested, useAuto, lockModel, auto, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res });
|
|
104
|
+
if (lr.handled) return { done: true };
|
|
105
|
+
if (!lr.lastErr) return { done: true };
|
|
106
|
+
} else {
|
|
107
|
+
evt("auto-concurrent-all-fail", { reqId, tried: raceModels.length, totalMs: Math.round(performance.now() - raceStart) });
|
|
108
|
+
for (const { r, i } of results.map((r, i) => ({ r, i }))) {
|
|
109
|
+
const m = raceModels[i];
|
|
110
|
+
if (r.status === "fulfilled" && !r.value?.ok && !r.value?.allowlist) await auto.recordError(m, { status: r.value.status || 502 });
|
|
111
|
+
else if (r.status === "rejected") await auto.recordError(m, { status: 502 });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const triedSet = new Set(raceModels);
|
|
115
|
+
order = order.filter((m) => !triedSet.has(m));
|
|
116
|
+
if (!order.length) {
|
|
117
|
+
const failedStatuses = results.map((r) => (r.status === "fulfilled" ? r.value?.status : null)).filter((s) => Number.isInteger(s));
|
|
118
|
+
const lastStatus = failedStatuses[failedStatuses.length - 1] || failedStatuses[0] || 502;
|
|
119
|
+
const last = { model: raceModels[0] || requested, status: lastStatus, message: "all concurrent candidates failed" };
|
|
120
|
+
await exhaustedAll({ res, body, lastErr: last, order: raceModels, requested, handlerCtx: { ...handlerCtx, reqId, startedAt }, evt, logCall, mark, perf0, stages });
|
|
121
|
+
return { done: true };
|
|
122
|
+
}
|
|
123
|
+
return { done: false, order };
|
|
124
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { runAutoRace } from "./auto-race.js";
|
|
2
|
+
import { runSerialTrial } from "./serial-trial.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ExecutionEngine(薄编排)— auto 并发择优 → 串行 trial。
|
|
6
|
+
* 重活下沉 auto-race.js / serial-trial.js,本文件仅做顺序编排。
|
|
7
|
+
*/
|
|
8
|
+
export function createEngine(deps = {}) {
|
|
9
|
+
const { raceDeps, serialDeps } = deps;
|
|
10
|
+
async function run(_plan, state) {
|
|
11
|
+
const race = await runAutoRace(state, raceDeps);
|
|
12
|
+
if (race.done) return;
|
|
13
|
+
await runSerialTrial({ ...state, order: race.order }, serialDeps);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return { run };
|
|
17
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { analyzePolicy } from "./policy.js";
|
|
3
|
+
import { planRoute } from "./planner.js";
|
|
4
|
+
import { createEngine } from "./engine.js";
|
|
5
|
+
import { runHook } from "../plugins.js";
|
|
6
|
+
import { clientIp, summarizePrompt } from "../routes/helpers.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ChatPipeline 深模块门面 — 对外 execute(req) 单一 inlet
|
|
10
|
+
* 内部组合 Policy→Planner→Engine:解析 header/model → 产 order → 委托 engine 执行
|
|
11
|
+
* gateway 仅薄适配:readBody + request:received hook + 调 execute
|
|
12
|
+
*/
|
|
13
|
+
export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, token, plugins, maxHops } = {}) {
|
|
14
|
+
const engine = createEngine();
|
|
15
|
+
|
|
16
|
+
async function execute({ req, res } = {}) {
|
|
17
|
+
const startedAt = Date.now();
|
|
18
|
+
const reqId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
19
|
+
const perf0 = performance.now();
|
|
20
|
+
const stages = [];
|
|
21
|
+
const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
|
|
22
|
+
|
|
23
|
+
const policy = analyzePolicy({ headers: req?.headers || {}, body: req?.body || {} });
|
|
24
|
+
const { requested, useAuto, lockModel, hops, shareKeys, workbuddyUid, aliasInfo } = policy;
|
|
25
|
+
mark("parsed");
|
|
26
|
+
if (aliasInfo) { try { res?.setHeader?.("x-mslxdff-alias", aliasInfo); } catch {} }
|
|
27
|
+
// mslxdff/ 前缀或 alias 命中时,把 body.model 改写为还原后的模型(与原 gateway 语义一致)
|
|
28
|
+
if (aliasInfo && req?.body && req.body.model !== requested) {
|
|
29
|
+
req.body = { ...req.body, model: requested };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// order 推导 + plugin model:select 可改
|
|
33
|
+
let order;
|
|
34
|
+
if (lockModel) order = [requested];
|
|
35
|
+
else if (useAuto) order = auto ? await auto.candidates() : [""];
|
|
36
|
+
else order = auto ? await auto.candidatesFor(requested) : [requested];
|
|
37
|
+
if (!order.length) order = [""];
|
|
38
|
+
const canFallback = order.length > 1;
|
|
39
|
+
const canForwardPeers = Boolean(peers) && hops < (maxHops ?? 3);
|
|
40
|
+
mark("ordered");
|
|
41
|
+
|
|
42
|
+
const logCall = (model, status) => logs?.appendCall({ reqId, model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(req?.body?.stream), stages });
|
|
43
|
+
const logError = (model, status, message) => logs?.appendError({ reqId, model, auto: useAuto, status, message, stages });
|
|
44
|
+
const evt = (type, data) => {
|
|
45
|
+
const entry = { ts: Date.now(), reqId, type, ...data, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt, stages: [...stages] };
|
|
46
|
+
if (bus) bus.emit(entry);
|
|
47
|
+
logs?.appendEvent?.(entry);
|
|
48
|
+
};
|
|
49
|
+
const done = (info) => {
|
|
50
|
+
if (!plugins?.length) return;
|
|
51
|
+
runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(req?.body?.stream), durationMs: Date.now() - startedAt, ...info }).catch(() => {});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
evt("request", { reqId, hops, ip: clientIp(req), stream: Boolean(req?.body?.stream), prompt: summarizePrompt(req?.body), rawModel: policy.rawModel, requested, lockModel: lockModel || null });
|
|
55
|
+
if (aliasInfo) evt("alias", { reqId, alias: aliasInfo, rawModel: policy.rawModel, requested });
|
|
56
|
+
if (Object.keys(shareKeys).length) evt("share-keys", { reqId, providers: Object.keys(shareKeys) });
|
|
57
|
+
evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
|
|
58
|
+
|
|
59
|
+
if (plugins?.length && !lockModel) {
|
|
60
|
+
const sel = await runHook(plugins, "model:select", { reqId, requested, useAuto, order: [...order], hops, stream: Boolean(req?.body?.stream) });
|
|
61
|
+
if (sel.changed && Array.isArray(sel.value) && sel.value.length) {
|
|
62
|
+
order = sel.value.filter(Boolean);
|
|
63
|
+
if (!order.length) order = [requested];
|
|
64
|
+
evt("plugin-hook", { reqId, hook: "model:select", applied: true, order: [...order] });
|
|
65
|
+
}
|
|
66
|
+
for (const e of sel.errors) evt("plugin-hook-error", { reqId, hook: "model:select", plugin: e.plugin, error: e.error });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const handlerCtx = { reqId, model: null, body: req?.body, hops, peers, plugins, evt, logError, logCall, logs, workbuddyUid };
|
|
70
|
+
|
|
71
|
+
const plan = planRoute(policy, {
|
|
72
|
+
candidates: order,
|
|
73
|
+
viaRoute: Boolean(!useAuto && requested.includes("/") && canForwardPeers) ? { via: true } : null,
|
|
74
|
+
});
|
|
75
|
+
await engine.run(plan, {
|
|
76
|
+
reqId, startedAt, req, res, body: req?.body, policy,
|
|
77
|
+
useAuto, lockModel, requested, hops,
|
|
78
|
+
canFallback, canForwardPeers,
|
|
79
|
+
perf0, stages, mark, evt, logCall, logError, done, handlerCtx,
|
|
80
|
+
auto, upstream, peers, groups, bus, token, plugins, logs,
|
|
81
|
+
order,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { execute, _policy: analyzePolicy, _plan: planRoute, _engine: engine };
|
|
86
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { DEFAULT_AUTO_MODELS, rankModels } from "../auto.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RoutePlanner 纯决策 — 根据 Policy + auto 状态产 RoutePlan
|
|
5
|
+
* 无网络副作用,供单测注入 FakeAuto
|
|
6
|
+
*/
|
|
7
|
+
export function planRoute(policy, autoState = {}) {
|
|
8
|
+
const { requested, useAuto, lockModel } = policy;
|
|
9
|
+
const { candidates = [], errors = {}, latencies = {}, viaRoute = null } = autoState;
|
|
10
|
+
|
|
11
|
+
// 锁模型时直接单点
|
|
12
|
+
if (lockModel) {
|
|
13
|
+
return { strategy: "direct", order: [requested], concLimit: 1, hedgeDelayMs: 0 };
|
|
14
|
+
}
|
|
15
|
+
// ViaRoute 单路径(显式锁模型且 via 表命中)
|
|
16
|
+
if (viaRoute && !useAuto && requested.includes("/")) {
|
|
17
|
+
return { strategy: "via", order: [requested], via: viaRoute, concLimit: 1, hedgeDelayMs: 0 };
|
|
18
|
+
}
|
|
19
|
+
// Auto 并发择优
|
|
20
|
+
if (useAuto && candidates.length > 1) {
|
|
21
|
+
const concLimit = Math.min(candidates.length, 5);
|
|
22
|
+
return { strategy: "autoRace", order: candidates, concLimit, hedgeDelayMs: 1000 };
|
|
23
|
+
}
|
|
24
|
+
// 显式模型回退链
|
|
25
|
+
if (!useAuto && candidates.length) {
|
|
26
|
+
const others = candidates.filter((m) => m !== requested);
|
|
27
|
+
const order = requested ? [requested, ...others] : candidates;
|
|
28
|
+
return { strategy: "direct", order, concLimit: 1, hedgeDelayMs: 0 };
|
|
29
|
+
}
|
|
30
|
+
return { strategy: "direct", order: requested ? [requested] : [""], concLimit: 1, hedgeDelayMs: 0 };
|
|
31
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { normalizeModel } from "../reasoning.js";
|
|
2
|
+
import { isAutoModel } from "../auto.js";
|
|
3
|
+
import { parseHops } from "../routes/helpers.js";
|
|
4
|
+
import { parseShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
|
|
5
|
+
import { normalizeFullId, getModelAlias } from "../providers/model-id.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PolicyStage 纯函数 — 解析 model 别名 / 白名单前置 / header 透传
|
|
9
|
+
* 零网络副作用,输入 headers+body,输出 PolicyResult
|
|
10
|
+
*/
|
|
11
|
+
export function analyzePolicy({ headers = {}, body = {} } = {}) {
|
|
12
|
+
const hops = parseHops(headers["x-mslxdff-hops"] || headers["X-Mslxdff-Hops"] || "");
|
|
13
|
+
const shareKeys = parseShareKeysHeader(headers[SHARE_KEYS_HEADER] || headers["x-mslxdff-share-keys"] || "");
|
|
14
|
+
const workbuddyUid = (headers["x-mslxdff-workbuddy-uid"] || headers["x-workbuddy-uid"] || "").toString().trim();
|
|
15
|
+
const lockModel = (headers["x-mslxdff-model-lock"] || headers["X-Mslxdff-Model-Lock"] || "").toString();
|
|
16
|
+
const rawModel = body.model || "";
|
|
17
|
+
|
|
18
|
+
let normalizedRequested = normalizeModel(lockModel || rawModel || "");
|
|
19
|
+
const aliasResolved = getModelAlias(normalizedRequested);
|
|
20
|
+
if (aliasResolved) {
|
|
21
|
+
normalizedRequested = aliasResolved;
|
|
22
|
+
}
|
|
23
|
+
let requested = normalizedRequested;
|
|
24
|
+
let aliasInfo = null;
|
|
25
|
+
|
|
26
|
+
if (requested.startsWith("mslxdff/")) {
|
|
27
|
+
const rawPart = requested.slice("mslxdff/".length);
|
|
28
|
+
aliasInfo = `${requested} -> ${rawPart} (mslxdff provider stripped)`;
|
|
29
|
+
requested = rawPart;
|
|
30
|
+
const alias2 = getModelAlias(requested);
|
|
31
|
+
if (alias2) {
|
|
32
|
+
aliasInfo = `${rawModel} -> ${alias2} (mslxdff + alias)`;
|
|
33
|
+
requested = alias2;
|
|
34
|
+
}
|
|
35
|
+
} else if (aliasResolved) {
|
|
36
|
+
// 非 mslxdff 的 dash 形态已在首轮 aliasResolved 处理
|
|
37
|
+
aliasInfo = aliasResolved !== (lockModel || rawModel) ? `${rawModel} -> ${aliasResolved} (alias)` : null;
|
|
38
|
+
if (!aliasInfo) aliasInfo = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// workbuddy <uid>:model 形式的 uid 钉死在 normalizeFullId 侧处理,这里透传原始 requested 供 planner 二次剥离
|
|
42
|
+
// 若 requested 含 workbuddy/ 前缀且含 :,则尝试提取 uid
|
|
43
|
+
let extractedUid = workbuddyUid;
|
|
44
|
+
if (!extractedUid && requested.startsWith("workbuddy/") && requested.includes(":")) {
|
|
45
|
+
const after = requested.slice("workbuddy/".length);
|
|
46
|
+
const uidPart = after.split(":")[0];
|
|
47
|
+
if (uidPart) extractedUid = uidPart;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const useAuto = isAutoModel(requested);
|
|
51
|
+
|
|
52
|
+
// 对 workbuddy 前缀的 model,做 normalizeFullId 归一(剥 uid 供上游)
|
|
53
|
+
// 但保留 requested 为完整带前缀形态,供 planner 做 ViaRoute 判定
|
|
54
|
+
let normalizedForUpstream = requested;
|
|
55
|
+
try {
|
|
56
|
+
const norm = normalizeFullId(requested);
|
|
57
|
+
if (norm && norm.raw) normalizedForUpstream = norm.raw ? `${norm.provider ? norm.provider + "/" : ""}${norm.raw}` : requested;
|
|
58
|
+
} catch {}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
rawModel,
|
|
62
|
+
requested,
|
|
63
|
+
normalizedRequested,
|
|
64
|
+
normalizedForUpstream,
|
|
65
|
+
aliasInfo,
|
|
66
|
+
useAuto,
|
|
67
|
+
shareKeys,
|
|
68
|
+
workbuddyUid: extractedUid,
|
|
69
|
+
lockModel,
|
|
70
|
+
hops,
|
|
71
|
+
bodyModel: body.model || null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { injectReasoningContent } from "../reasoning.js";
|
|
3
|
+
import { runHook } from "../plugins.js";
|
|
4
|
+
import { errMsg, json } from "../routes/helpers.js";
|
|
5
|
+
import { hedgeDelayMs, shouldHedge } from "../routes/hedge.js";
|
|
6
|
+
import { handleHedge } from "../routes/chat/hedge-handler.js";
|
|
7
|
+
import { handleLocalRelay } from "../routes/chat/local-handler.js";
|
|
8
|
+
import { handlePeerRelay } from "../routes/chat/peer-handler.js";
|
|
9
|
+
import { handleBroadbandRelay } from "../routes/chat/broadband-handler.js";
|
|
10
|
+
import { handleViaRoute } from "../routes/chat/via-route-handler.js";
|
|
11
|
+
import { handleExhaustedLocal, handleExhaustedAll } from "../routes/chat/exhausted-handler.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 串行 trial — 从 engine.js 抽出的第二段:via-route 单路径 → 串行 trial →
|
|
15
|
+
* hedge/local/peer/broadband/exhausted。恒终结,返回 { done:true }。
|
|
16
|
+
*/
|
|
17
|
+
export async function runSerialTrial(ctx, deps = {}) {
|
|
18
|
+
const {
|
|
19
|
+
viaRoute = handleViaRoute,
|
|
20
|
+
hedge = handleHedge,
|
|
21
|
+
localRelay = handleLocalRelay,
|
|
22
|
+
peerRelay = handlePeerRelay,
|
|
23
|
+
broadbandRelay = handleBroadbandRelay,
|
|
24
|
+
exhaustedLocal = handleExhaustedLocal,
|
|
25
|
+
exhaustedAll = handleExhaustedAll,
|
|
26
|
+
} = deps;
|
|
27
|
+
const {
|
|
28
|
+
order, reqId, requested, body, hops, useAuto, lockModel, plugins,
|
|
29
|
+
auto, upstream, peers, groups, bus, token, canFallback, canForwardPeers,
|
|
30
|
+
perf0, stages, mark, evt, logCall, logError, done, handlerCtx,
|
|
31
|
+
res, startedAt, logs,
|
|
32
|
+
} = ctx;
|
|
33
|
+
const shareKeys = ctx.shareKeys ?? ctx.policy?.shareKeys ?? {};
|
|
34
|
+
const workbuddyUid = ctx.workbuddyUid ?? ctx.policy?.workbuddyUid ?? null;
|
|
35
|
+
|
|
36
|
+
let viaRouteLastErr = null;
|
|
37
|
+
if (!useAuto && requested && requested.includes("/") && canForwardPeers && !lockModel && peers) {
|
|
38
|
+
try {
|
|
39
|
+
const vr = await viaRoute({ model: requested, body, peers, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res, requested, useAuto, lockModel, auto });
|
|
40
|
+
if (vr.handled) return { done: true };
|
|
41
|
+
if (vr.lastErr) viaRouteLastErr = vr.lastErr;
|
|
42
|
+
} catch (e) {
|
|
43
|
+
evt("via-route-exception", { reqId, model: requested, error: errMsg(e) });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let lastErr = viaRouteLastErr;
|
|
48
|
+
for (let idx = 0; idx < order.length; idx++) {
|
|
49
|
+
const model = order[idx];
|
|
50
|
+
handlerCtx.model = model;
|
|
51
|
+
evt("model-try", { reqId, model, idx, remaining: order.length - idx });
|
|
52
|
+
if (plugins?.length) {
|
|
53
|
+
const bt = await runHook(plugins, "model:beforeTry", { reqId, requested, model, idx, hops });
|
|
54
|
+
for (const e of bt.errors) evt("plugin-hook-error", { reqId, hook: "model:beforeTry", plugin: e.plugin, error: e.error });
|
|
55
|
+
if (bt.value === false || bt.value?.skip === true) { evt("plugin-hook", { reqId, hook: "model:beforeTry", applied: true, skipped: model }); continue; }
|
|
56
|
+
}
|
|
57
|
+
let upRes = null;
|
|
58
|
+
let forwarded = { ...injectReasoningContent(model, body), model };
|
|
59
|
+
if (plugins?.length) {
|
|
60
|
+
const ur = await runHook(plugins, "upstream:request", { reqId, requested, model, payload: forwarded, stream: Boolean(body.stream) });
|
|
61
|
+
for (const e of ur.errors) evt("plugin-hook-error", { reqId, hook: "upstream:request", plugin: e.plugin, error: e.error });
|
|
62
|
+
if (ur.changed && ur.value?.payload && typeof ur.value.payload === "object") { forwarded = ur.value.payload; evt("plugin-hook", { reqId, hook: "upstream:request", applied: true, model, rewrittenModel: forwarded.model ?? null }); }
|
|
63
|
+
}
|
|
64
|
+
const tUp = performance.now();
|
|
65
|
+
evt("upstream-try", { reqId, model, attempt: idx + 1 });
|
|
66
|
+
try {
|
|
67
|
+
const chatOpts = {};
|
|
68
|
+
if (Object.keys(shareKeys).length) chatOpts.shareKeys = shareKeys;
|
|
69
|
+
if (workbuddyUid) chatOpts.workbuddyUid = workbuddyUid;
|
|
70
|
+
upRes = await upstream.chat(forwarded, Object.keys(chatOpts).length ? chatOpts : undefined);
|
|
71
|
+
evt("upstream-done", { reqId, model, ok: !(upRes instanceof Error) && upRes.status < 400, status: upRes instanceof Error ? null : upRes.status, timing: upRes._t ?? null, error: null });
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (auto) await auto.recordError(model, { message: errMsg(err) });
|
|
74
|
+
lastErr = { model, upstream: null, status: 502, message: errMsg(err) };
|
|
75
|
+
logError(model, 502, errMsg(err));
|
|
76
|
+
evt("upstream-error", { reqId, model, status: 502, message: errMsg(err), timing: err._t ?? { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - tUp) } });
|
|
77
|
+
}
|
|
78
|
+
if (plugins?.length) {
|
|
79
|
+
runHook(plugins, "upstream:response", {
|
|
80
|
+
reqId, requested, model,
|
|
81
|
+
status: upRes instanceof Error ? null : upRes instanceof Object ? (upRes.status ?? null) : null,
|
|
82
|
+
ok: !(upRes instanceof Error) && upRes ? upRes.status < 400 : false,
|
|
83
|
+
error: upRes instanceof Error ? errMsg(upRes) : null,
|
|
84
|
+
timing: upRes?._t ?? null,
|
|
85
|
+
}).catch(() => {});
|
|
86
|
+
}
|
|
87
|
+
mark(`up-${model}`);
|
|
88
|
+
if (upRes && upRes.status >= 400) {
|
|
89
|
+
const isAllowlistBlock = upRes.status === 403 && (upRes.headers?.get?.("x-mslxdff-allowlist") === "1");
|
|
90
|
+
if (isAllowlistBlock) {
|
|
91
|
+
let bodyText = null; try { bodyText = await upRes.clone().text(); } catch {}
|
|
92
|
+
let errBody = { error: `model not allowed for provider` };
|
|
93
|
+
try { errBody = bodyText ? JSON.parse(bodyText) : errBody; } catch { errBody = { error: bodyText || "model not allowed" }; }
|
|
94
|
+
if (useAuto) {
|
|
95
|
+
logError(model, 403, errBody.error || "model not allowed");
|
|
96
|
+
evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true, skipped: true });
|
|
97
|
+
lastErr = { model, upstream: upRes, status: 403, message: errBody.error || "model not allowed" };
|
|
98
|
+
if (canFallback && idx < order.length - 1) { evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: `allowlist skip ${errBody.error || "blocked"}` }); continue; }
|
|
99
|
+
return json(res, 403, errBody);
|
|
100
|
+
}
|
|
101
|
+
logError(model, 403, errBody.error || "model not allowed");
|
|
102
|
+
evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true });
|
|
103
|
+
return json(res, 403, errBody);
|
|
104
|
+
}
|
|
105
|
+
if (auto) await auto.recordError(model, { status: upRes.status });
|
|
106
|
+
lastErr = { model, upstream: upRes, status: upRes.status, message: null };
|
|
107
|
+
logError(model, upRes.status, `upstream ${upRes.status}`);
|
|
108
|
+
evt("upstream-error", { reqId, model, status: upRes.status, message: null, timing: upRes._t ?? null });
|
|
109
|
+
upRes = null;
|
|
110
|
+
}
|
|
111
|
+
if (upRes) {
|
|
112
|
+
const isStream = Boolean(body.stream);
|
|
113
|
+
const d = hedgeDelayMs();
|
|
114
|
+
const hasPeers = Boolean(peers) && peers.ordered().length > 0;
|
|
115
|
+
const doHedge = shouldHedge({ isStream, canForwardPeers, hedgeDelayMs: d, hasPeers }) && upRes.status === 200 && upRes.body;
|
|
116
|
+
if (doHedge) {
|
|
117
|
+
const hr = await hedge({ upRes, model, body, order, idx, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res, hedgeDelayMs: d });
|
|
118
|
+
if (hr.handled) return { done: true };
|
|
119
|
+
if (hr.lastErr) lastErr = hr.lastErr;
|
|
120
|
+
if (hr.upRes === null) upRes = null;
|
|
121
|
+
else if (hr.upRes) upRes = hr.upRes;
|
|
122
|
+
}
|
|
123
|
+
if (upRes) {
|
|
124
|
+
const lr = await localRelay({ upRes, model, body, order, idx, lastErr, requested, useAuto, lockModel, auto, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res });
|
|
125
|
+
if (lr.handled) return { done: true };
|
|
126
|
+
if (lr.lastErr) { lastErr = lr.lastErr; continue; }
|
|
127
|
+
return { done: true };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (canForwardPeers) {
|
|
131
|
+
const pr = await peerRelay({ model, body, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx, evt, logCall, mark, perf0, stages, startedAt, plugins, res });
|
|
132
|
+
if (pr.handled) return { done: true };
|
|
133
|
+
}
|
|
134
|
+
if (groups) {
|
|
135
|
+
const br = await broadbandRelay({ model, body, hops, lastErr, requested, useAuto, lockModel, auto, groups, token, bus, logs, handlerCtx, evt, mark, perf0, stages, res, startedAt, plugins });
|
|
136
|
+
if (br.handled) return { done: true };
|
|
137
|
+
}
|
|
138
|
+
if (canFallback) { evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: lastErr?.message || `upstream ${lastErr?.status ?? 502}` }); continue; }
|
|
139
|
+
await exhaustedLocal({ res, body, lastErr, order, handlerCtx: { ...handlerCtx, model, reqId }, evt, logCall, mark, perf0, stages, done, requested, useAuto });
|
|
140
|
+
return { done: true };
|
|
141
|
+
}
|
|
142
|
+
await exhaustedAll({ res, body, lastErr, order, requested, handlerCtx: { ...handlerCtx, reqId, startedAt }, evt, logCall, mark, perf0, stages });
|
|
143
|
+
return { done: true };
|
|
144
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 其他供应商 allowlist 段 + 遗留别名映射展示。
|
|
3
|
+
* 输入均为已算好的数据;state/model-id 走动态 import(与原内联一致,避免循环依赖)。
|
|
4
|
+
*/
|
|
5
|
+
export async function renderOtherProviders({ pickedIds, ids, fullAliases }) {
|
|
6
|
+
const { loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } = await import("../../../state.js");
|
|
7
|
+
const { loadModelAliases: _la2, getAliasForModel: _gaf } = await import("../../../providers/model-id.js");
|
|
8
|
+
try { _la2(); } catch {}
|
|
9
|
+
const configs = loadProviderConfigs();
|
|
10
|
+
const otherIds = Object.keys(configs).filter((k) => String(k).toLowerCase() !== "opencode");
|
|
11
|
+
const order2 = ["workbuddy", "clinebot", "openrouter", "bai"];
|
|
12
|
+
otherIds.sort((a, b) => {
|
|
13
|
+
const ia = order2.indexOf(a), ib = order2.indexOf(b);
|
|
14
|
+
if (ia !== -1 || ib !== -1) {
|
|
15
|
+
if (ia === -1) return 1;
|
|
16
|
+
if (ib === -1) return -1;
|
|
17
|
+
return ia - ib;
|
|
18
|
+
}
|
|
19
|
+
return a.localeCompare(b);
|
|
20
|
+
});
|
|
21
|
+
if (otherIds.length) {
|
|
22
|
+
console.log(`\n────────────────────────────────────────`);
|
|
23
|
+
console.log(`其他供应商 (allowlist,原名 + 别名) (${otherIds.length} providers):`);
|
|
24
|
+
for (const pid of otherIds) {
|
|
25
|
+
const allowed = loadProviderAllowedModels(pid);
|
|
26
|
+
const allowAny = loadProviderAllowAnyModels(pid);
|
|
27
|
+
const baseUrl = loadProviderBaseUrl(pid) || configs[pid]?.baseUrl || "";
|
|
28
|
+
const header = allowAny
|
|
29
|
+
? (allowed.length ? `allowlist ${allowed.length} (allowAny ON)` : `allowAny ON (allowlist 空=放行全部)`)
|
|
30
|
+
: (allowed.length ? `allowlist ${allowed.length} (allowAny OFF)` : `allowlist 空 + allowAny OFF = 阻塞`);
|
|
31
|
+
console.log(`\n ── ${pid} (${header})${baseUrl ? ` baseUrl=${baseUrl}` : ""} ──`);
|
|
32
|
+
if (!allowed.length) {
|
|
33
|
+
if (allowAny) {
|
|
34
|
+
console.log(` (未设 allowlist,全部模型放行) 查看 live 列表: mslxdff -provider ${pid} models`);
|
|
35
|
+
console.log(` 限制可用模型: mslxdff -provider ${pid} allowlist set <model1> <model2>`);
|
|
36
|
+
} else {
|
|
37
|
+
console.log(` 阻塞中:无可用模型 — 设白名单: mslxdff -provider ${pid} allowlist set <model1> <model2>`);
|
|
38
|
+
console.log(` 或放行全部: mslxdff -provider ${pid} allowAny on`);
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
for (const raw of allowed) {
|
|
42
|
+
const canonical = `${pid}/${raw}`;
|
|
43
|
+
let alias = null;
|
|
44
|
+
try { alias = _gaf(canonical); } catch {}
|
|
45
|
+
if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
|
|
46
|
+
const aliasStr = alias && alias !== canonical ? ` (别名: ${alias})` : "";
|
|
47
|
+
const pickedMark = pickedIds.includes(canonical) || pickedIds.includes(alias || "") ? "*" : " ";
|
|
48
|
+
console.log(` ${pickedMark} ${canonical}${aliasStr}`);
|
|
49
|
+
}
|
|
50
|
+
console.log(` 管理: mslxdff -provider ${pid} allowlist [list|add|remove|clear] | allowAny on|off`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
console.log(`\n────────────────────────────────────────`);
|
|
55
|
+
console.log(`其他供应商 (allowlist,原名 + 别名): (none — 尚未配置)`);
|
|
56
|
+
console.log(` 添加示例: mslxdff -provider add myapi https://api.example.com/v1 sk-xxx --models-path /v1/models`);
|
|
57
|
+
}
|
|
58
|
+
const aliasEntries = Object.entries(fullAliases).filter(([alias, canonical]) => {
|
|
59
|
+
if (ids.includes(canonical)) return false;
|
|
60
|
+
for (const pid of otherIds) {
|
|
61
|
+
const allowed = loadProviderAllowedModels(pid);
|
|
62
|
+
for (const raw of allowed) {
|
|
63
|
+
const can = `${pid}/${raw}`;
|
|
64
|
+
if (can === canonical) return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
69
|
+
if (aliasEntries.length) {
|
|
70
|
+
console.log(`\n 本地别名 (不在 allowlist 里的遗留映射 ${aliasEntries.length}):`);
|
|
71
|
+
for (const [alias, canonical] of aliasEntries) {
|
|
72
|
+
console.log(` ${canonical} => ${alias}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
/** 构建 id → 别名映射(原 list 两处重复逻辑收敛) */
|
|
6
|
+
export function buildAliasMap(ids, getAliasForModel) {
|
|
7
|
+
const aliasMap = {};
|
|
8
|
+
for (const id of ids) {
|
|
9
|
+
const alias = getAliasForModel(id);
|
|
10
|
+
if (alias) aliasMap[id] = alias;
|
|
11
|
+
else if (String(id).includes("/")) {
|
|
12
|
+
const dashAlias = String(id).replace(/\//g, "-");
|
|
13
|
+
if (dashAlias !== id) aliasMap[id] = dashAlias;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return aliasMap;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 读本地别名全表(遗留映射展示用) */
|
|
20
|
+
export function readFullAliases() {
|
|
21
|
+
try {
|
|
22
|
+
const aliasesFile = join(homedir(), ".config", "mslxdff", "model-aliases.json");
|
|
23
|
+
const raw = JSON.parse(readFileSync(aliasesFile, "utf8"));
|
|
24
|
+
if (raw && typeof raw === "object") return raw;
|
|
25
|
+
} catch {}
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `--provider <id>` 分支的分组渲染 */
|
|
30
|
+
export function renderProviderList({ ids, at, pickedIds, modelListProvider, sortedProvs, groups, aliasMap }) {
|
|
31
|
+
console.log(`${ids.length} model(s) for ${modelListProvider}${at} (${pickedIds.length} picked, * = picked):`);
|
|
32
|
+
const mark = (id) => (pickedIds.includes(id) ? "*" : " ");
|
|
33
|
+
for (const prov of sortedProvs) {
|
|
34
|
+
const list = groups[prov];
|
|
35
|
+
console.log(`\n ── ${prov} (${list.length}) ──`);
|
|
36
|
+
for (const id of list) {
|
|
37
|
+
const alias = aliasMap[id];
|
|
38
|
+
const aliasStr = alias ? ` (别名: ${alias})` : "";
|
|
39
|
+
console.log(` ${mark(id)} ${id}${aliasStr}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 全量 free 列表的分组渲染 */
|
|
46
|
+
export function renderFreeList({ ids, at, pickedIds, sortedProvs, groups, aliasMap }) {
|
|
47
|
+
console.log(`${ids.length} free model(s)${at} (${pickedIds.length} picked, * = picked):`);
|
|
48
|
+
const mark = (id) => (pickedIds.includes(id) ? "*" : " ");
|
|
49
|
+
for (const prov of sortedProvs) {
|
|
50
|
+
const list = groups[prov];
|
|
51
|
+
console.log(`\n ── ${prov} (${list.length}) ──`);
|
|
52
|
+
for (const id of list) {
|
|
53
|
+
const alias = aliasMap[id];
|
|
54
|
+
const aliasStr = alias ? ` (别名: ${alias})` : "";
|
|
55
|
+
console.log(` ${mark(id)} ${id}${aliasStr}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 按供应商分组 + 排序(opencode/workbuddy/clinebot/openrouter 优先) */
|
|
61
|
+
export function groupByProvider(ids) {
|
|
62
|
+
const groups = {};
|
|
63
|
+
for (const id of ids) {
|
|
64
|
+
const prov = String(id).includes("/") ? String(id).split("/")[0] : "opencode";
|
|
65
|
+
if (!groups[prov]) groups[prov] = [];
|
|
66
|
+
groups[prov].push(id);
|
|
67
|
+
}
|
|
68
|
+
const order = ["opencode", "workbuddy", "clinebot", "openrouter"];
|
|
69
|
+
const sortedProvs = Object.keys(groups).sort((a, b) => {
|
|
70
|
+
const ia = order.indexOf(a), ib = order.indexOf(b);
|
|
71
|
+
if (ia !== -1 || ib !== -1) {
|
|
72
|
+
if (ia === -1) return 1;
|
|
73
|
+
if (ib === -1) return -1;
|
|
74
|
+
return ia - ib;
|
|
75
|
+
}
|
|
76
|
+
return a.localeCompare(b);
|
|
77
|
+
});
|
|
78
|
+
return { groups, sortedProvs };
|
|
79
|
+
}
|