mslxdff 0.1.135 → 0.1.136
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/chat-pipeline/serial-trial.js +11 -3
- package/src/cli/commands/use-group.js +8 -4
- package/src/model-capabilities/index.js +16 -3
- package/src/model-capabilities/merge.js +127 -0
- package/src/providers/classify.js +2 -2
- package/src/providers/dispatcher.js +1 -1
- package/src/providers/generic.js +47 -9
- package/src/providers/share-keys.js +1 -1
- package/src/routes/models-route.js +11 -1
- package/src/state/schemas/use-group.js +47 -6
- package/src/upstream-probe/rotate.js +1 -1
package/package.json
CHANGED
|
@@ -9,7 +9,13 @@ import { handlePeerRelay } from "../routes/chat/peer-handler.js";
|
|
|
9
9
|
import { handleBroadbandRelay } from "../routes/chat/broadband-handler.js";
|
|
10
10
|
import { handleViaRoute } from "../routes/chat/via-route-handler.js";
|
|
11
11
|
import { handleExhaustedLocal, handleExhaustedAll } from "../routes/chat/exhausted-handler.js";
|
|
12
|
-
import { shouldUseGroupForModel, isHardLocalOnly } from "../state/schemas/use-group.js";
|
|
12
|
+
import { shouldUseGroupForModel, isHardLocalOnly, isKeyProviderDirectOnly } from "../state/schemas/use-group.js";
|
|
13
|
+
|
|
14
|
+
function groupSkipReason(model) {
|
|
15
|
+
if (isHardLocalOnly(model)) return "provider local-only(禁组员,仅本机直连)";
|
|
16
|
+
if (isKeyProviderDirectOnly(model)) return "key provider default direct(仅本机直连,MSLXDFF_USE_GROUP_KEYS=1 可开组员)";
|
|
17
|
+
return "useGroup=off";
|
|
18
|
+
}
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
21
|
* 串行 trial — 从 engine.js 抽出的第二段:via-route 单路径 → 串行 trial →
|
|
@@ -43,6 +49,8 @@ export async function runSerialTrial(ctx, deps = {}) {
|
|
|
43
49
|
} catch (e) {
|
|
44
50
|
evt("via-route-exception", { reqId, model: requested, error: errMsg(e) });
|
|
45
51
|
}
|
|
52
|
+
} else if (!useAuto && requested && requested.includes("/") && canForwardPeers && !lockModel && peers) {
|
|
53
|
+
evt("group-skip", { reqId, model: requested, reason: `${groupSkipReason(requested)} (via-route)` });
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
let lastErr = viaRouteLastErr;
|
|
@@ -143,7 +151,7 @@ export async function runSerialTrial(ctx, deps = {}) {
|
|
|
143
151
|
}
|
|
144
152
|
if (canForwardPeers) {
|
|
145
153
|
if (!shouldUseGroupForModel(model)) {
|
|
146
|
-
evt("group-skip", { reqId, model, reason:
|
|
154
|
+
evt("group-skip", { reqId, model, reason: `${groupSkipReason(model)} (peer)` });
|
|
147
155
|
} else {
|
|
148
156
|
const pr = await peerRelay({ model, body, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx, evt, logCall, mark, perf0, stages, startedAt, plugins, res });
|
|
149
157
|
if (pr.handled) return { done: true };
|
|
@@ -152,7 +160,7 @@ export async function runSerialTrial(ctx, deps = {}) {
|
|
|
152
160
|
}
|
|
153
161
|
if (groups) {
|
|
154
162
|
if (!shouldUseGroupForModel(model)) {
|
|
155
|
-
evt("group-skip", { reqId, model, reason:
|
|
163
|
+
evt("group-skip", { reqId, model, reason: `${groupSkipReason(model)} (broadband)` });
|
|
156
164
|
} else {
|
|
157
165
|
const br = await broadbandRelay({ model, body, hops, lastErr, requested, useAuto, lockModel, auto, groups, token, bus, logs, handlerCtx, evt, mark, perf0, stages, res, startedAt, plugins });
|
|
158
166
|
if (br.handled) return { done: true };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadUseGroup, saveUseGroup, getEffectiveUseGroup, getUseGroupEnv } from "../../state/schemas/use-group.js";
|
|
1
|
+
import { loadUseGroup, saveUseGroup, getEffectiveUseGroup, getUseGroupEnv, getUseGroupKeysEnv } from "../../state/schemas/use-group.js";
|
|
2
2
|
import { argValue } from "../policy.js";
|
|
3
3
|
|
|
4
4
|
function parseInput(v) {
|
|
@@ -26,15 +26,18 @@ export async function handleUseGroup(args) {
|
|
|
26
26
|
const envVal = getUseGroupEnv();
|
|
27
27
|
const effective = getEffectiveUseGroup();
|
|
28
28
|
const stored = loadUseGroup();
|
|
29
|
+
const keysEnv = getUseGroupKeysEnv();
|
|
29
30
|
|
|
30
31
|
if (raw === null || raw === undefined || raw === "") {
|
|
31
32
|
// 查询模式
|
|
32
|
-
console.log(`use-group: ${effective ? "on" : "off"} (effective)`);
|
|
33
|
+
console.log(`use-group: ${effective ? "on" : "off"} (effective, 仅 opencode 免费池)`);
|
|
33
34
|
console.log(` stored: ${stored ? "on" : "off"} (state.json useGroup)`);
|
|
34
35
|
if (envVal !== null) console.log(` env MSLXDFF_USE_GROUP=${envVal ? "on" : "off"} (overrides stored)`);
|
|
36
|
+
console.log(` keys: ${keysEnv ? "on" : "off"} (env MSLXDFF_USE_GROUP_KEYS, 默认 off: 带 key 上游恒直连)`);
|
|
35
37
|
console.log(` default: on`);
|
|
36
|
-
console.log(` usage: mslxdff -use-group on|off (
|
|
38
|
+
console.log(` usage: mslxdff -use-group on|off (opencode 失败时是否走组员网络,默认 on)`);
|
|
37
39
|
console.log(` env: MSLXDFF_USE_GROUP=0|1 (优先级高于 state)`);
|
|
40
|
+
console.log(` env: MSLXDFF_USE_GROUP_KEYS=1 (把 key 供应商开回组员,cline/workbuddy 仍硬禁)`);
|
|
38
41
|
process.exit(0);
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -50,7 +53,8 @@ export async function handleUseGroup(args) {
|
|
|
50
53
|
|
|
51
54
|
saveUseGroup(parsed);
|
|
52
55
|
console.log(`use-group set to ${parsed ? "on" : "off"} (stored in state.json)`);
|
|
53
|
-
console.log(` ${parsed ? "允许" : "
|
|
56
|
+
console.log(` ${parsed ? "允许 opencode" : "opencode 也不再"}走组员网络(via-route/hedge/peer/broadband)`);
|
|
57
|
+
console.log(` 带 key 上游默认恒直连(不受本开关影响,MSLXDFF_USE_GROUP_KEYS=1 可开回,cline/workbuddy 仍硬禁)`);
|
|
54
58
|
if (!parsed) console.log(` 提示:所有请求将仅在本机重试,不再走组员中继`);
|
|
55
59
|
process.exit(0);
|
|
56
60
|
}
|
|
@@ -117,8 +117,22 @@ export function createCapabilitiesService({
|
|
|
117
117
|
function providers() {
|
|
118
118
|
return [...capsIndex.keys()].sort();
|
|
119
119
|
}
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
// readyWarm:仅用内存/磁盘缓存热身,绝不网络请求。/models 富化用它避免冷启动阻塞;
|
|
121
|
+
// 冷缓存时后台拉新(不 await),下次请求即富化。
|
|
122
|
+
async function readyWarm() {
|
|
123
|
+
const t = now();
|
|
124
|
+
if (raw && t - loadedAt < ttl) return true;
|
|
125
|
+
const disk = readCache();
|
|
126
|
+
if (disk) {
|
|
127
|
+
raw = disk;
|
|
128
|
+
capsIndex = buildIndex(disk);
|
|
129
|
+
loadedAt = t;
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
ready().catch(() => {}); // 后台拉新,失败静默(/models 原样降级)
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return { ready, readyWarm, get, list, providers, npmIndex: () => new Map(npmIndex) };
|
|
122
136
|
}
|
|
123
137
|
|
|
124
138
|
// 模块级单例:HTTP handler 懒加载,测试 _reset 后注入
|
|
@@ -128,7 +142,6 @@ export function globalCapabilities() {
|
|
|
128
142
|
return _global;
|
|
129
143
|
}
|
|
130
144
|
export function _resetGlobalCapabilities() { _global = null; }
|
|
131
|
-
|
|
132
145
|
// 缓存落盘位置:MSLXDFF_MODELS_DEV_CACHE 覆盖 > ~/.config/mslxdff/models-dev.json(与 state 同目录)
|
|
133
146
|
function defaultCacheFile() {
|
|
134
147
|
const override = process.env.MSLXDFF_MODELS_DEV_CACHE;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// /v1/models 能力富化(ADR-0022):把 models.dev 目录(+ workbuddy 原生字段兜底)
|
|
2
|
+
// 的能力合并进 /v1/models 每条 data 条目的 `capabilities` 子对象,客户端与 CLI 一眼可读。
|
|
3
|
+
// 逃生门:GET /v1/models?raw=1 保持 ADR-0016 的原始透传形状;codex 调用者恒原始 + 空 models:[]。
|
|
4
|
+
// 全程 best-effort:目录不可用 → 原样返回,绝不让 /models 因富化失败挂掉。
|
|
5
|
+
import { globalCapabilities } from "./index.js";
|
|
6
|
+
|
|
7
|
+
// "clinebot/deepseek/deepseek-v4-flash" → { provider: "clinebot", raw: "deepseek/deepseek-v4-flash" };裸 id 归 opencode
|
|
8
|
+
export function splitModelId(id) {
|
|
9
|
+
const s = String(id || "").trim();
|
|
10
|
+
const i = s.indexOf("/");
|
|
11
|
+
if (i > 0) return { provider: s.slice(0, i).toLowerCase(), raw: s.slice(i + 1) };
|
|
12
|
+
return { provider: "opencode", raw: s };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function stripFreeSuffix(raw) {
|
|
16
|
+
return raw.replace(/-free$/i, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 上游原生 wire API:muse-spark* 只认 /responses(zen /chat 500 实测);
|
|
20
|
+
// models.dev 标 npm @ai-sdk/openai 的模型上游走 responses;其余 chat。
|
|
21
|
+
export function upstreamApiFor(caps, raw) {
|
|
22
|
+
const bare = stripFreeSuffix(String(raw || ""));
|
|
23
|
+
if (/^muse-spark/i.test(bare)) return "responses";
|
|
24
|
+
if (caps?.npm === "@ai-sdk/openai") return "responses";
|
|
25
|
+
return "chat";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// caps → 对外 capabilities 子对象(只填有值的键,未收录字段不硬造)
|
|
29
|
+
export function capsPayloadFor(caps, raw) {
|
|
30
|
+
if (!caps) return null;
|
|
31
|
+
const payload = {
|
|
32
|
+
reasoning: Boolean(caps.reasoning),
|
|
33
|
+
...(caps.effortType ? { effortType: caps.effortType } : {}),
|
|
34
|
+
...(Array.isArray(caps.effortValues) && caps.effortValues.length
|
|
35
|
+
? { effortValues: caps.effortValues.map(String) }
|
|
36
|
+
: {}),
|
|
37
|
+
...(caps.defaultEffort ? { defaultEffort: String(caps.defaultEffort) } : {}),
|
|
38
|
+
imageInput: Boolean(caps.imageInput),
|
|
39
|
+
inputModalities: Array.isArray(caps.inputModalities) && caps.inputModalities.length
|
|
40
|
+
? caps.inputModalities
|
|
41
|
+
: ["text"],
|
|
42
|
+
outputModalities: Array.isArray(caps.outputModalities) && caps.outputModalities.length
|
|
43
|
+
? caps.outputModalities
|
|
44
|
+
: ["text"],
|
|
45
|
+
toolCall: Boolean(caps.toolCall),
|
|
46
|
+
...(Number(caps.context) > 0 ? { context: Number(caps.context) } : {}),
|
|
47
|
+
...(Number(caps.maxOutput) > 0 ? { maxOutput: Number(caps.maxOutput) } : {}),
|
|
48
|
+
...((caps.costIn != null || caps.costOut != null)
|
|
49
|
+
? { costIn: Number(caps.costIn) || 0, costOut: Number(caps.costOut) || 0 }
|
|
50
|
+
: {}),
|
|
51
|
+
// 网关两侧端点都收:/v1/responses 复用 ChatPipeline 翻译层;upstreamApi 记上游原生协议
|
|
52
|
+
endpoints: ["chat", "responses"],
|
|
53
|
+
upstreamApi: upstreamApiFor(caps, raw),
|
|
54
|
+
};
|
|
55
|
+
return payload;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 单条 id 的目录匹配:精确裸 id → 剥 -free 后缀 → 二级厂商前缀(clinebot/deepseek/x → deepseek/x)
|
|
59
|
+
function lookupCaps(svc, provider, raw) {
|
|
60
|
+
return (
|
|
61
|
+
svc.get(provider, raw) ||
|
|
62
|
+
svc.get(provider, stripFreeSuffix(raw)) ||
|
|
63
|
+
null
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function lookupCapsDeep(svc, provider, raw) {
|
|
68
|
+
const direct = lookupCaps(svc, provider, raw);
|
|
69
|
+
if (direct) return direct;
|
|
70
|
+
const parts = raw.split("/");
|
|
71
|
+
if (parts.length >= 2) {
|
|
72
|
+
const head = parts[0];
|
|
73
|
+
const rest = parts.slice(1).join("/");
|
|
74
|
+
return svc.get(head, rest) || svc.get(head, stripFreeSuffix(rest)) || null;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 把能力合并进 { object:"list", data:[...] } 的每条条目。
|
|
81
|
+
* capsSvc/wbSource 为测试接缝;生产用全局单例(models.dev 24h 缓存)+ workbuddy 动态源。
|
|
82
|
+
* wbSource 显式 null 禁用 workbuddy 兜底(测试用)。
|
|
83
|
+
*/
|
|
84
|
+
export async function mergeModelsList(data, { capsSvc, wbSource } = {}) {
|
|
85
|
+
const svc = capsSvc === undefined ? globalCapabilities() : capsSvc;
|
|
86
|
+
const entries = Array.isArray(data?.data) ? data.data : [];
|
|
87
|
+
if (!svc || !entries.length) return data;
|
|
88
|
+
// 只用内存/磁盘缓存热身;冷缓存绝不阻塞请求(后台拉新,下次请求即富化)
|
|
89
|
+
try {
|
|
90
|
+
const warmed = svc.readyWarm ? await svc.readyWarm() : await svc.ready();
|
|
91
|
+
if (!warmed) return data;
|
|
92
|
+
} catch {
|
|
93
|
+
return data;
|
|
94
|
+
}
|
|
95
|
+
// 第一遍:目录直查;workbuddy 未命中记下,第二遍走上游原生字段
|
|
96
|
+
const resolved = new Map(); // index -> caps
|
|
97
|
+
const needWb = []; // [index, raw]
|
|
98
|
+
entries.forEach((entry, i) => {
|
|
99
|
+
const id = String(entry?.id || "");
|
|
100
|
+
if (!id) return;
|
|
101
|
+
const { provider, raw } = splitModelId(id);
|
|
102
|
+
const caps = lookupCapsDeep(svc, provider, raw);
|
|
103
|
+
if (caps) resolved.set(i, caps);
|
|
104
|
+
else if (provider === "workbuddy" && wbSource !== null) needWb.push([i, raw]);
|
|
105
|
+
});
|
|
106
|
+
if (needWb.length) {
|
|
107
|
+
try {
|
|
108
|
+
const { workbuddyCapsFromModels, workbuddyAllModels } = await import("./index.js");
|
|
109
|
+
const wbMap = await workbuddyCapsFromModels(wbSource || workbuddyAllModels)();
|
|
110
|
+
for (const [i, raw] of needWb) {
|
|
111
|
+
const caps = wbMap?.[raw] || null;
|
|
112
|
+
if (caps) resolved.set(i, caps);
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// workbuddy 源不可用:这些条目保持无能力,不拖垮整个列表
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!resolved.size) return data;
|
|
119
|
+
const next = entries.map((entry, i) => {
|
|
120
|
+
const caps = resolved.get(i);
|
|
121
|
+
if (!caps) return entry;
|
|
122
|
+
const { raw } = splitModelId(String(entry?.id || ""));
|
|
123
|
+
const payload = capsPayloadFor(caps, raw);
|
|
124
|
+
return payload ? { ...entry, capabilities: payload } : entry;
|
|
125
|
+
});
|
|
126
|
+
return { ...data, data: next };
|
|
127
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// ADR-0015:供应商路由分类三态 + EMA 纯函数(零依赖,防循环 import)
|
|
2
|
-
// local-only:本机账号绑定(workbuddy
|
|
2
|
+
// local-only:本机账号绑定(workbuddy/cline 系),组员转发无效
|
|
3
3
|
// quota-pool:图额度不图速度(opencode free),直连先行、429 后组员兜底
|
|
4
4
|
// latency-compare:key/token 类,direct vs link+remote 比延迟
|
|
5
5
|
export function classifyProvider(id) {
|
|
6
6
|
const s = String(id || "").trim().toLowerCase();
|
|
7
|
-
if (s === "workbuddy") return "local-only";
|
|
7
|
+
if (s === "workbuddy" || s === "cline" || s === "clinebot") return "local-only";
|
|
8
8
|
if (s === "opencode") return "quota-pool";
|
|
9
9
|
return "latency-compare";
|
|
10
10
|
}
|
|
@@ -50,7 +50,7 @@ export function createProviderDispatcher(providers = [], opts = {}) {
|
|
|
50
50
|
// ADR-0008:本请求携带瞬时共享 key(shareKeys 由组员侧按 header 解析后传入)。
|
|
51
51
|
const sharedKeys = opts?.shareKeys?.[provider.id];
|
|
52
52
|
if (sharedKeys && sharedKeys.length && typeof provider.chatWithKeys === "function") {
|
|
53
|
-
return provider.chatWithKeys(forwarded, sharedKeys);
|
|
53
|
+
return provider.chatWithKeys(forwarded, sharedKeys, opts);
|
|
54
54
|
}
|
|
55
55
|
if (provider.id === "workbuddy" && workbuddyUid) {
|
|
56
56
|
return provider.chat(forwarded, { ...opts, workbuddyUid });
|
package/src/providers/generic.js
CHANGED
|
@@ -2,6 +2,8 @@ import { createKeyRing } from "./keyring.js";
|
|
|
2
2
|
import { loadProviderKeys, loadProviderBaseUrl, loadProviderModelsPath, loadProviderChatPath } from "../state.js";
|
|
3
3
|
import { envInt, joinUrl, getUndici, createAgent, collectApiKeysGeneric, createChatRunner, createListModelsRunner, createPreheatRunner } from "./base.js";
|
|
4
4
|
import { compatFetch } from "../compat.js";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { genId, opencodeUa, digestIdTail } from "../opencode-identity.js";
|
|
5
7
|
|
|
6
8
|
const { UndiciFetch } = getUndici();
|
|
7
9
|
|
|
@@ -12,6 +14,26 @@ function resolveBaseUrl(id, baseUrl) {
|
|
|
12
14
|
return "";
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
function isOpencodeHost(baseUrl) {
|
|
18
|
+
return /opencode\.ai/i.test(String(baseUrl || ""));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Console Go (zen/go) 要求 x-opencode-session 才能路由(缺失 → 400 MissingSessionID)。
|
|
22
|
+
// 会话取值:已合规(ses_ 前缀)直接用;客户端透传的任意串做 sha1 摘要派生(稳定+合规);
|
|
23
|
+
// 无则每请求 fresh(与 -chat curl / bench 直连行为一致)。
|
|
24
|
+
function resolveGoSession(sessionId) {
|
|
25
|
+
const raw = String(sessionId || "").trim();
|
|
26
|
+
if (/^ses_/.test(raw)) return raw;
|
|
27
|
+
if (raw) {
|
|
28
|
+
try {
|
|
29
|
+
return `ses_${digestIdTail(crypto.createHash("sha1").update(raw).digest())}`;
|
|
30
|
+
} catch {
|
|
31
|
+
return genId("ses_");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return genId("ses_");
|
|
35
|
+
}
|
|
36
|
+
|
|
15
37
|
export function createGenericProvider({
|
|
16
38
|
id,
|
|
17
39
|
baseUrl,
|
|
@@ -41,6 +63,7 @@ export function createGenericProvider({
|
|
|
41
63
|
const resolvedModelsPath = modelsPath || loadProviderModelsPath(id, file ? { file } : {});
|
|
42
64
|
const resolvedChatPath = chatPath || loadProviderChatPath(id, file ? { file } : {});
|
|
43
65
|
const ring = createKeyRing(collectApiKeysGeneric(id, apiKeys, apiKey, loadProviderKeys), { cooldownMs });
|
|
66
|
+
const goHost = isOpencodeHost(resolvedBase);
|
|
44
67
|
|
|
45
68
|
let dispatcher = null;
|
|
46
69
|
let agent = null;
|
|
@@ -53,7 +76,7 @@ export function createGenericProvider({
|
|
|
53
76
|
agent = a.agent; dispatcher = a.dispatcher;
|
|
54
77
|
}
|
|
55
78
|
|
|
56
|
-
function buildHeaders(body, key) {
|
|
79
|
+
function buildHeaders(body, key, opts) {
|
|
57
80
|
const isStream = body?.stream !== false;
|
|
58
81
|
const h = {
|
|
59
82
|
"Content-Type": "application/json",
|
|
@@ -61,20 +84,35 @@ export function createGenericProvider({
|
|
|
61
84
|
"User-Agent": "mslxdff",
|
|
62
85
|
};
|
|
63
86
|
if (key) h["Authorization"] = `Bearer ${key}`;
|
|
64
|
-
|
|
87
|
+
const out = { ...h, ...extraHeaders };
|
|
88
|
+
// ocgo(opencode.ai 域名):补 Console Go 路由所需的 opencode 身份头
|
|
89
|
+
if (goHost) {
|
|
90
|
+
out["User-Agent"] = opencodeUa();
|
|
91
|
+
out["x-opencode-client"] = "desktop";
|
|
92
|
+
out["x-opencode-session"] = resolveGoSession(opts?.sessionId);
|
|
93
|
+
out["x-opencode-request"] = genId("msg_");
|
|
94
|
+
out["x-opencode-project"] = "global";
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
65
97
|
}
|
|
66
98
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
99
|
+
// base 的 attemptOnce 只调 buildHeaders(body, key):用闭包把 opts.sessionId 带进去
|
|
100
|
+
function scopedRunner(activeRing, opts) {
|
|
101
|
+
return createChatRunner({
|
|
102
|
+
id, ring: activeRing, cooldownMs, retry, fetchImpl, dispatcher,
|
|
103
|
+
buildHeaders: (b, k) => buildHeaders(b, k, opts),
|
|
104
|
+
getUrl: () => joinUrl(resolvedBase, resolvedChatPath),
|
|
105
|
+
connectTimeoutMs,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
72
108
|
|
|
73
|
-
async function chat(body) {
|
|
109
|
+
async function chat(body, opts) {
|
|
110
|
+
const { runChat } = scopedRunner(ring, opts);
|
|
74
111
|
return runChat(body, ring, `MSLXDFF_${id.toUpperCase()}_KEY`);
|
|
75
112
|
}
|
|
76
|
-
async function chatWithKeys(body, keys) {
|
|
113
|
+
async function chatWithKeys(body, keys, opts) {
|
|
77
114
|
const tmp = createKeyRing(keys, { cooldownMs });
|
|
115
|
+
const { runChat } = scopedRunner(tmp, opts);
|
|
78
116
|
return runChat(body, tmp, "shared provider keys");
|
|
79
117
|
}
|
|
80
118
|
|
|
@@ -10,7 +10,7 @@ export const SHARE_KEYS_HEADER = "x-mslxdff-share-keys";
|
|
|
10
10
|
const NEVER_SHARE_IDS = new Set(["cline", "clinebot"]);
|
|
11
11
|
|
|
12
12
|
// 本节点应 cast key 到出站转发的供应商 id 集合 = 所有「本机有 key」的供应商,
|
|
13
|
-
// 减去:opencode(无 key 且恒排除)、local-only(workbuddy
|
|
13
|
+
// 减去:opencode(无 key 且恒排除)、local-only(workbuddy/cline系,本就不走组员)、刷新型凭据。
|
|
14
14
|
export function shareableProviderIds({ file } = {}) {
|
|
15
15
|
const ids = [];
|
|
16
16
|
for (const id of listProviderIdsWithKeys({ file })) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { json, errMsg } from "./helpers.js";
|
|
2
2
|
import { runHook } from "../plugins.js";
|
|
3
3
|
import { isModelAllowed } from "../state.js";
|
|
4
|
+
import { mergeModelsList } from "../model-capabilities/merge.js";
|
|
4
5
|
import { globalCapabilities } from "../model-capabilities/index.js";
|
|
5
6
|
|
|
6
7
|
// Codex 自定义 provider 拉目录要顶层 `models` 数组(codex-rs endpoint/models.rs 解 ModelsResponse{models}),
|
|
@@ -14,12 +15,21 @@ export function isCodexModelsCaller(req) {
|
|
|
14
15
|
return /(^|&)client_version=/.test(q);
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
export async function modelsHandler({ req, res, models, plugins }) {
|
|
18
|
+
export async function modelsHandler({ req, res, models, plugins, capabilities, wbSource }) {
|
|
18
19
|
if (!models) return json(res, 501, { error: "Models service not configured" });
|
|
19
20
|
const codex = isCodexModelsCaller(req);
|
|
20
21
|
const withCodex = (out) => (codex && out && typeof out === "object" ? { ...out, models: [] } : out);
|
|
21
22
|
try {
|
|
22
23
|
let data = await models.get();
|
|
24
|
+
// ADR-0022:默认把能力富化进每条条目(capabilities 子对象);?raw=1 逃生门回原始形状,
|
|
25
|
+
// codex 调用者保持原始 + 顶层空 models:[](富化白做)。best-effort,失败原样返回。
|
|
26
|
+
const qs = String(req?.url || "").split("?")[1] || "";
|
|
27
|
+
const rawMode = /(^|&)raw=1(&|$)/.test(qs);
|
|
28
|
+
if (!codex && !rawMode) {
|
|
29
|
+
try {
|
|
30
|
+
data = await mergeModelsList(data, { capsSvc: capabilities, wbSource });
|
|
31
|
+
} catch { /* 富化失败不阻塞 /models */ }
|
|
32
|
+
}
|
|
23
33
|
// 插件 hook:models:list — 返回数组可替换对外模型列表({object:"list",data:[...]} 或纯 id 数组)
|
|
24
34
|
if (plugins?.length) {
|
|
25
35
|
const ml = await runHook(plugins, "models:list", { data });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defaultStateFile, readState, writeStateImmediate } from "../store.js";
|
|
2
2
|
import { classifyProvider } from "../../providers/classify.js";
|
|
3
|
+
import { getModelAlias, normalizeProviderId, DEFAULT_PROVIDER } from "../../providers/model-id.js";
|
|
3
4
|
|
|
4
5
|
function parseBool(v) {
|
|
5
6
|
if (typeof v === "boolean") return v;
|
|
@@ -36,23 +37,63 @@ export function getEffectiveUseGroup({ file = defaultStateFile() } = {}) {
|
|
|
36
37
|
return loadUseGroup({ file });
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
// key 供应商组员开关:默认 off(仅 opencode 免费池走 peer 兜底,其他带 key 上游恒直连)。
|
|
41
|
+
// MSLXDFF_USE_GROUP_KEYS=1/on 可显式开回来(调试/弱网应急);同样受全局 off 约束。
|
|
42
|
+
export function getUseGroupKeysEnv() {
|
|
43
|
+
const raw = process.env.MSLXDFF_USE_GROUP_KEYS;
|
|
44
|
+
if (raw === undefined || raw === null || raw === "") return false;
|
|
45
|
+
return parseBool(raw) === true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 取模型对应的供应商 head:兼容 canonical(workbuddy/xxx、clinebot/xxx)、
|
|
49
|
+
// dash 别名(clinebot-xxx → 还原后取 head)、裸 id(归 opencode)。
|
|
50
|
+
// oc/ 别名归一到 opencode(与 splitModelId 一致:别名表小写 key,先小写再归一)。
|
|
51
|
+
export function providerHeadOf(model) {
|
|
52
|
+
let s = String(model || "").trim();
|
|
53
|
+
if (!s) return "";
|
|
54
|
+
try {
|
|
55
|
+
const aliased = getModelAlias(s);
|
|
56
|
+
if (aliased) s = aliased;
|
|
57
|
+
} catch {}
|
|
58
|
+
const slash = s.indexOf("/");
|
|
59
|
+
if (slash > 0) {
|
|
60
|
+
try {
|
|
61
|
+
const head = normalizeProviderId(s.slice(0, slash).toLowerCase());
|
|
62
|
+
return String(head || "").toLowerCase();
|
|
63
|
+
} catch {
|
|
64
|
+
return s.slice(0, slash).toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return DEFAULT_PROVIDER;
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
// 全局开关:off 则所有供应商都不走组员网络(via-route/hedge/peer/broadband 全禁),仅本机直连
|
|
40
|
-
// workbuddy
|
|
71
|
+
// workbuddy/cline 系硬禁组员(ADR-0015 local-only):本机账号绑定(workbuddy auths/*.json + uid / cline refreshToken),
|
|
41
72
|
// 组员没有该账号转过去也用不了,且本地直连最快——无论全局开关一律仅本机直连。
|
|
42
|
-
// model 兼容 canonical(workbuddy/xxx)与 dash(workbuddy-xxx)两种形态。
|
|
73
|
+
// model 兼容 canonical(workbuddy/xxx、clinebot/xxx)与 dash(workbuddy-xxx、cline-xxx)两种形态。
|
|
43
74
|
export function isHardLocalOnly(model) {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
const head = slash > 0 ? s.slice(0, slash) : s.split("-")[0];
|
|
75
|
+
const head = providerHeadOf(model);
|
|
76
|
+
if (!head) return false;
|
|
47
77
|
try {
|
|
48
78
|
return classifyProvider(head) === "local-only";
|
|
49
79
|
} catch {
|
|
50
|
-
return head === "workbuddy";
|
|
80
|
+
return head === "workbuddy" || head === "cline" || head === "clinebot";
|
|
51
81
|
}
|
|
52
82
|
}
|
|
53
83
|
|
|
84
|
+
// key 供应商默认直连(ADR-0023):opencode(quota-pool,裸 id/oc 前缀)沿用全局开关;
|
|
85
|
+
// 其余带前缀的一律仅本机直连,除非 MSLXDFF_USE_GROUP_KEYS=1 显式开回。
|
|
86
|
+
export function isKeyProviderDirectOnly(model) {
|
|
87
|
+
const head = providerHeadOf(model);
|
|
88
|
+
if (!head || head === "opencode") return false;
|
|
89
|
+
if (isHardLocalOnly(model)) return true;
|
|
90
|
+
if (getUseGroupKeysEnv()) return false;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
54
94
|
export function shouldUseGroupForModel(model, { file = defaultStateFile() } = {}) {
|
|
55
95
|
if (isHardLocalOnly(model)) return false;
|
|
96
|
+
if (isKeyProviderDirectOnly(model)) return false;
|
|
56
97
|
return getEffectiveUseGroup({ file });
|
|
57
98
|
}
|
|
58
99
|
|
|
@@ -7,7 +7,7 @@ import { errMsg } from "../cli/util.js";
|
|
|
7
7
|
|
|
8
8
|
export const PROBE_DELAY_MS = Number(process.env.MSLXDFF_BENCH_DELAY_MS || 120) || 0;
|
|
9
9
|
|
|
10
|
-
// 从 providerConfigs 收集探针目标:local-only(workbuddy)/quota-pool(opencode) 排除,无 baseUrl 跳过
|
|
10
|
+
// 从 providerConfigs 收集探针目标:local-only(workbuddy/cline系)/quota-pool(opencode) 排除,无 baseUrl 跳过
|
|
11
11
|
export function probeTargetsFromState({ loadProviderConfigs, loadProviderKeys, loadProviderBaseUrl } = {}) {
|
|
12
12
|
const out = [];
|
|
13
13
|
const ids = new Set(Object.keys(loadProviderConfigs?.() || {}));
|