mslxdff 0.1.55 → 0.1.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -160
- package/bin/mslxdff.js +666 -26
- package/package.json +3 -2
- package/src/chat/config.js +10 -0
- package/src/chat/index.js +6 -0
- package/src/chat/prompt.js +63 -0
- package/src/chat/repl.js +245 -0
- package/src/chat/spinner.js +38 -0
- package/src/chat/stats.js +170 -0
- package/src/chat/store.js +48 -0
- package/src/chat/tools.js +304 -0
- package/src/chat/upstream.js +90 -0
- package/src/models.js +90 -2
- package/src/providers/dispatcher.js +100 -0
- package/src/providers/generic.js +221 -0
- package/src/providers/index.js +6 -0
- package/src/providers/keyring.js +38 -0
- package/src/providers/model-id.js +36 -0
- package/src/providers/opencode.js +20 -0
- package/src/providers/openrouter.js +217 -0
- package/src/providers/share-keys.js +53 -0
- package/src/providers/workbuddy-balance.js +76 -0
- package/src/providers/workbuddy.js +563 -0
- package/src/routes/chat/index.js +20 -1
- package/src/routes/peers.js +12 -7
- package/src/routes/stream.js +9 -0
- package/src/state.js +299 -4
- package/src/time.js +73 -0
- package/src/upstream.js +126 -8
- package/docs/adr/0001-reasoning-content-injection.md +0 -14
- package/docs/adr/0002-models-free-filter.md +0 -12
- package/docs/adr/0003-zero-state-no-auth.md +0 -10
- package/docs/adr/0004-bearer-token.md +0 -18
- package/docs/adr/0005-peer-mesh.md +0 -53
- package/docs/adr/0006-broadband-member.md +0 -103
- package/docs/agents/domain.md +0 -51
- package/docs/agents/issue-tracker.md +0 -30
- package/docs/agents/triage-labels.md +0 -15
- package/docs/plugins.md +0 -185
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { joinModelId } from "./model-id.js";
|
|
2
|
+
import { createKeyRing } from "./keyring.js";
|
|
3
|
+
import { loadProviderKeys, loadProviderBaseUrl } from "../state.js";
|
|
4
|
+
|
|
5
|
+
let UndiciAgent = null;
|
|
6
|
+
let UndiciFetch = null;
|
|
7
|
+
try {
|
|
8
|
+
const mod = await import("undici");
|
|
9
|
+
UndiciAgent = mod.Agent;
|
|
10
|
+
UndiciFetch = mod.fetch;
|
|
11
|
+
} catch {}
|
|
12
|
+
|
|
13
|
+
function envInt(name, fallback) {
|
|
14
|
+
const v = Number(process.env[name]);
|
|
15
|
+
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function collectApiKeys(id, apiKeys, apiKey) {
|
|
19
|
+
const list = [
|
|
20
|
+
...(Array.isArray(apiKeys) ? apiKeys : [apiKeys].filter(Boolean)),
|
|
21
|
+
apiKey,
|
|
22
|
+
...(apiKeys === undefined && apiKey === undefined ? loadProviderKeys(id) : []),
|
|
23
|
+
].filter((k) => typeof k === "string" && k.trim().length);
|
|
24
|
+
return [...new Set(list.map((k) => k.trim()))];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveBaseUrl(id, baseUrl) {
|
|
28
|
+
if (baseUrl) return String(baseUrl).trim().replace(/\/+$/, "");
|
|
29
|
+
const env = loadProviderBaseUrl(id);
|
|
30
|
+
if (env) return env;
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createGenericProvider({
|
|
35
|
+
id,
|
|
36
|
+
baseUrl,
|
|
37
|
+
apiKeys,
|
|
38
|
+
apiKey,
|
|
39
|
+
connectTimeoutMs = Number(process.env.MSLXDFF_GENERIC_TIMEOUT_MS) || 30_000,
|
|
40
|
+
cooldownMs = envInt("MSLXDFF_GENERIC_COOLDOWN_MS", 30_000),
|
|
41
|
+
retry = {
|
|
42
|
+
network: { attempts: 2, delayMs: 300 },
|
|
43
|
+
429: { attempts: 1, delayMs: 100 },
|
|
44
|
+
502: { attempts: 1, delayMs: 100 },
|
|
45
|
+
503: { attempts: 1, delayMs: 100 },
|
|
46
|
+
504: { attempts: 1, delayMs: 100 },
|
|
47
|
+
},
|
|
48
|
+
fetchImpl,
|
|
49
|
+
headers: extraHeaders,
|
|
50
|
+
noAgent = false,
|
|
51
|
+
} = {}) {
|
|
52
|
+
if (!id) throw new Error("generic provider requires id");
|
|
53
|
+
const resolvedBase = resolveBaseUrl(id, baseUrl);
|
|
54
|
+
if (!resolvedBase) throw new Error(`generic provider ${id}: missing baseUrl`);
|
|
55
|
+
if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
|
|
56
|
+
|
|
57
|
+
const ring = createKeyRing(collectApiKeys(id, apiKeys, apiKey), { cooldownMs });
|
|
58
|
+
|
|
59
|
+
let dispatcher = null;
|
|
60
|
+
let agent = null;
|
|
61
|
+
if (UndiciAgent && !noAgent) {
|
|
62
|
+
try {
|
|
63
|
+
agent = new UndiciAgent({
|
|
64
|
+
keepAliveTimeout: envInt("MSLXDFF_GENERIC_KEEPALIVE_TIMEOUT", 30_000),
|
|
65
|
+
keepAliveMaxTimeout: envInt("MSLXDFF_GENERIC_KEEPALIVE_MAX_TIMEOUT", 60_000),
|
|
66
|
+
connections: envInt("MSLXDFF_GENERIC_KEEPALIVE_CONNECTIONS", 20),
|
|
67
|
+
pipelining: 1,
|
|
68
|
+
});
|
|
69
|
+
dispatcher = agent;
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildHeaders(body, key) {
|
|
74
|
+
const isStream = body?.stream !== false;
|
|
75
|
+
const h = {
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
"Accept": isStream ? "text/event-stream" : "*/*",
|
|
78
|
+
"User-Agent": "mslxdff",
|
|
79
|
+
};
|
|
80
|
+
if (key) h["Authorization"] = `Bearer ${key}`;
|
|
81
|
+
return { ...h, ...extraHeaders };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function attemptOnce(url, body, key, activeRing) {
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
const timer = setTimeout(() => controller.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
87
|
+
try {
|
|
88
|
+
const opts = { method: "POST", headers: buildHeaders(body, key), body: JSON.stringify(body), signal: controller.signal };
|
|
89
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
90
|
+
const res = await fetchImpl(url, opts);
|
|
91
|
+
if (res.status === 401 && !activeRing.size) return { __needKey: true };
|
|
92
|
+
return res;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return err;
|
|
95
|
+
} finally {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function runChat(body, activeRing, sourceKey) {
|
|
101
|
+
const url = `${resolvedBase}/chat/completions`;
|
|
102
|
+
const t0 = performance.now();
|
|
103
|
+
const attempts = [];
|
|
104
|
+
let waitMs = 0;
|
|
105
|
+
const key = activeRing.next();
|
|
106
|
+
if (!key && activeRing.size > 0) {
|
|
107
|
+
const err = new Error(`${id}: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`);
|
|
108
|
+
err._t = { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0), cooldownMs };
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
for (let attempt = 0; ; attempt++) {
|
|
112
|
+
const t = performance.now();
|
|
113
|
+
const result = await attemptOnce(url, body, key, activeRing);
|
|
114
|
+
const type = result instanceof Error ? "network" : `http${result?.status}`;
|
|
115
|
+
attempts.push({ attempt, type, ms: Math.round(performance.now() - t) });
|
|
116
|
+
if (result?.__needKey) {
|
|
117
|
+
const err = new Error(`${id}: missing ${sourceKey} (chat requires a real key)`);
|
|
118
|
+
err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
if (result instanceof Error) {
|
|
122
|
+
const entry = retry?.network;
|
|
123
|
+
if (entry && attempt < entry.attempts) {
|
|
124
|
+
await sleep(entry.delayMs);
|
|
125
|
+
waitMs += entry.delayMs;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
activeRing.onError(key);
|
|
129
|
+
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
130
|
+
throw result;
|
|
131
|
+
}
|
|
132
|
+
const entry = retry?.[result.status];
|
|
133
|
+
if (entry && attempt < entry.attempts) {
|
|
134
|
+
await sleep(entry.delayMs);
|
|
135
|
+
waitMs += entry.delayMs;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
|
|
139
|
+
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function chat(body) {
|
|
145
|
+
return runChat(body, ring, `MSLXDFF_${id.toUpperCase()}_KEY`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function chatWithKeys(body, keys) {
|
|
149
|
+
const tmp = createKeyRing(keys, { cooldownMs });
|
|
150
|
+
return runChat(body, tmp, "shared provider keys");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
154
|
+
let cache = null;
|
|
155
|
+
let fetchedAt = 0;
|
|
156
|
+
async function listModels() {
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
|
|
159
|
+
const url = `${resolvedBase}/models`;
|
|
160
|
+
const controller = new AbortController();
|
|
161
|
+
const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
|
|
162
|
+
try {
|
|
163
|
+
const headers = { Accept: "application/json" };
|
|
164
|
+
const key = ring.next();
|
|
165
|
+
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
166
|
+
const opts = { headers, signal: controller.signal };
|
|
167
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
168
|
+
const res = await fetchImpl(url, opts);
|
|
169
|
+
if (!res.ok) return [];
|
|
170
|
+
const json = await res.json().catch(() => ({}));
|
|
171
|
+
const raw = Array.isArray(json.data) ? json.data : Array.isArray(json) ? json : [];
|
|
172
|
+
// 通用:不过滤 pricing,全部前缀化
|
|
173
|
+
cache = raw.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
|
|
174
|
+
fetchedAt = now;
|
|
175
|
+
return cache;
|
|
176
|
+
} catch {
|
|
177
|
+
return [];
|
|
178
|
+
} finally {
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function preheat() {
|
|
184
|
+
const url = `${resolvedBase}/models`;
|
|
185
|
+
const t0 = performance.now();
|
|
186
|
+
try {
|
|
187
|
+
const headers = { Accept: "application/json" };
|
|
188
|
+
const key = loadProviderKeys(id)[0] || ring.next();
|
|
189
|
+
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
190
|
+
const opts = { headers };
|
|
191
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
192
|
+
const res = await fetchImpl(url, opts);
|
|
193
|
+
try { if (res.body) await res.text().catch(() => {}); } catch {}
|
|
194
|
+
return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return { ok: false, error: String(err?.message || err), ms: Math.round(performance.now() - t0) };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function close() {
|
|
201
|
+
if (agent && typeof agent.close === "function") {
|
|
202
|
+
try { await agent.close(); } catch {}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
id,
|
|
208
|
+
chat,
|
|
209
|
+
chatWithKeys,
|
|
210
|
+
listModels,
|
|
211
|
+
preheat,
|
|
212
|
+
close,
|
|
213
|
+
agent,
|
|
214
|
+
keyRing: ring,
|
|
215
|
+
baseUrl: resolvedBase,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function sleep(ms) {
|
|
220
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
221
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createProviderDispatcher } from "./dispatcher.js";
|
|
2
|
+
export { createOpenCodeProvider } from "./opencode.js";
|
|
3
|
+
export { createOpenRouterProvider } from "./openrouter.js";
|
|
4
|
+
export { createGenericProvider } from "./generic.js";
|
|
5
|
+
export { createWorkbuddyProvider } from "./workbuddy.js";
|
|
6
|
+
export { splitModelId, joinModelId, normalizeProviderId, DEFAULT_PROVIDER } from "./model-id.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// 多 key 轮转器:round-robin 取键 + 出错冷却隔离。
|
|
2
|
+
// 语义:某个 key 出错后 cooldownMs 内不再使用;全部 key 都在冷却 → next() 返回 null
|
|
3
|
+
// (调用方应视为该供应商暂时失效,直接报错而非降级为单 key)。
|
|
4
|
+
export const DEFAULT_COOLDOWN_MS = 30_000;
|
|
5
|
+
|
|
6
|
+
export function createKeyRing(keys = [], { cooldownMs = DEFAULT_COOLDOWN_MS, now = Date.now } = {}) {
|
|
7
|
+
const list = [...new Set(keys.filter((k) => typeof k === "string" && k.trim().length))];
|
|
8
|
+
const errAt = new Map();
|
|
9
|
+
let cursor = 0;
|
|
10
|
+
|
|
11
|
+
function isCooling(key) {
|
|
12
|
+
const t = errAt.get(key);
|
|
13
|
+
return t != null && now() - t < cooldownMs;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function next() {
|
|
17
|
+
if (!list.length) return null;
|
|
18
|
+
for (let i = 0; i < list.length; i++) {
|
|
19
|
+
const idx = (cursor + i) % list.length;
|
|
20
|
+
const key = list[idx];
|
|
21
|
+
if (!isCooling(key)) {
|
|
22
|
+
cursor = (idx + 1) % list.length;
|
|
23
|
+
return key;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function onError(key) {
|
|
30
|
+
if (list.includes(key)) errAt.set(key, now());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function available() {
|
|
34
|
+
return list.filter((k) => !isCooling(k)).length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { next, onError, available, size: list.length, cooldownMs, keys: [...list] };
|
|
38
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// 模型 ID 前缀解析:`<provider>/<rawId>`。默认供应商(opencode)保持裸 id(向后兼容),
|
|
2
|
+
// 其它供应商必须带前缀,请求按前缀路由到对应上游通道。
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_PROVIDER = "opencode";
|
|
5
|
+
|
|
6
|
+
const ALIASES = { oc: "opencode", opencode: "opencode" };
|
|
7
|
+
|
|
8
|
+
export function normalizeProviderId(p) {
|
|
9
|
+
const id = String(p || "").trim();
|
|
10
|
+
return ALIASES[id] || id;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// 把(可能带前缀的)模型 id 拆成 { provider, raw, prefixed }
|
|
14
|
+
// - 无前缀(含默认供应商裸 id)→ provider = DEFAULT_PROVIDER
|
|
15
|
+
// - 前缀是已知供应商 → 对应路由;开头的 `oc/` 视为 opencode 别名
|
|
16
|
+
export function splitModelId(id, knownProviders = []) {
|
|
17
|
+
const s = String(id || "");
|
|
18
|
+
if (!s) return { provider: DEFAULT_PROVIDER, raw: s, prefixed: false };
|
|
19
|
+
const idx = s.indexOf("/");
|
|
20
|
+
if (idx > 0) {
|
|
21
|
+
const head = normalizeProviderId(s.slice(0, idx));
|
|
22
|
+
const known = new Set([DEFAULT_PROVIDER, ...knownProviders]);
|
|
23
|
+
if (known.has(head)) {
|
|
24
|
+
return { provider: head, raw: s.slice(idx + 1), prefixed: true };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { provider: DEFAULT_PROVIDER, raw: s, prefixed: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 生成对外暴露的模型 id:默认供应商裸 id(向后兼容),其它供应商带前缀
|
|
31
|
+
export function joinModelId(provider, raw, { force = false } = {}) {
|
|
32
|
+
provider = normalizeProviderId(provider || "");
|
|
33
|
+
raw = String(raw || "");
|
|
34
|
+
if (provider === DEFAULT_PROVIDER && !force) return raw;
|
|
35
|
+
return `${provider}/${raw}`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createUpstreamClient } from "../upstream.js";
|
|
2
|
+
import { joinModelId } from "./model-id.js";
|
|
3
|
+
|
|
4
|
+
// opencode Provider:复用现有 createUpstreamClient 的全部能力(头/重试/匿名免费通道/keep-alive 预热),
|
|
5
|
+
// 模型 id 保持裸 id(向后兼容)。
|
|
6
|
+
export function createOpenCodeProvider({ upstream, modelsService, baseUrl, authToken, hooks } = {}) {
|
|
7
|
+
const client = upstream ?? createUpstreamClient({ baseUrl, authToken, hooks });
|
|
8
|
+
return {
|
|
9
|
+
id: "opencode",
|
|
10
|
+
upstream: client,
|
|
11
|
+
chat: (body) => client.chat(body),
|
|
12
|
+
preheat: (args) => client.preheat(args),
|
|
13
|
+
close: () => client.close(),
|
|
14
|
+
async listModels() {
|
|
15
|
+
const data = await modelsService?.get?.();
|
|
16
|
+
const list = data?.data ?? [];
|
|
17
|
+
return list.map((m) => ({ ...m, id: joinModelId("opencode", m.id) }));
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { joinModelId } from "./model-id.js";
|
|
2
|
+
import { createKeyRing } from "./keyring.js";
|
|
3
|
+
import { loadProviderKeys } from "../state.js";
|
|
4
|
+
|
|
5
|
+
let UndiciAgent = null;
|
|
6
|
+
let UndiciFetch = null;
|
|
7
|
+
try {
|
|
8
|
+
const mod = await import("undici");
|
|
9
|
+
UndiciAgent = mod.Agent;
|
|
10
|
+
UndiciFetch = mod.fetch;
|
|
11
|
+
} catch {}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
|
|
14
|
+
|
|
15
|
+
function envInt(name, fallback) {
|
|
16
|
+
const v = Number(process.env[name]);
|
|
17
|
+
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 把 `apiKeys`(数组) + `apiKey`(单串) + state 的 key 合并成去重数组。
|
|
21
|
+
// state 为空时只看显式传入;两者皆空 → [](无 key)。
|
|
22
|
+
function collectApiKeys(apiKeys, apiKey) {
|
|
23
|
+
const list = [
|
|
24
|
+
...(Array.isArray(apiKeys) ? apiKeys : [apiKeys].filter(Boolean)),
|
|
25
|
+
apiKey,
|
|
26
|
+
...(apiKeys === undefined && apiKey === undefined ? loadProviderKeys("openrouter") : []),
|
|
27
|
+
].filter((k) => typeof k === "string" && k.trim().length);
|
|
28
|
+
return [...new Set(list.map((k) => k.trim()))];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createOpenRouterProvider({
|
|
32
|
+
apiKeys,
|
|
33
|
+
apiKey,
|
|
34
|
+
baseUrl = process.env.MSLXDFF_OPENROUTER_BASE_URL || DEFAULT_BASE_URL,
|
|
35
|
+
connectTimeoutMs = Number(process.env.MSLXDFF_OPENROUTER_TIMEOUT_MS) || 30_000,
|
|
36
|
+
cooldownMs = envInt("MSLXDFF_OPENROUTER_COOLDOWN_MS", 30_000),
|
|
37
|
+
retry = {
|
|
38
|
+
network: { attempts: 2, delayMs: 300 },
|
|
39
|
+
429: { attempts: 1, delayMs: 100 },
|
|
40
|
+
502: { attempts: 1, delayMs: 100 },
|
|
41
|
+
503: { attempts: 1, delayMs: 100 },
|
|
42
|
+
504: { attempts: 1, delayMs: 100 },
|
|
43
|
+
},
|
|
44
|
+
fetchImpl,
|
|
45
|
+
headers: extraHeaders,
|
|
46
|
+
noAgent = false,
|
|
47
|
+
} = {}) {
|
|
48
|
+
if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
|
|
49
|
+
|
|
50
|
+
const ring = createKeyRing(collectApiKeys(apiKeys, apiKey), { cooldownMs });
|
|
51
|
+
|
|
52
|
+
let dispatcher = null;
|
|
53
|
+
let agent = null;
|
|
54
|
+
if (UndiciAgent && !noAgent) {
|
|
55
|
+
try {
|
|
56
|
+
agent = new UndiciAgent({
|
|
57
|
+
keepAliveTimeout: envInt("MSLXDFF_OA_KEEPALIVE_TIMEOUT", 30_000),
|
|
58
|
+
keepAliveMaxTimeout: envInt("MSLXDFF_OA_KEEPALIVE_MAX_TIMEOUT", 60_000),
|
|
59
|
+
connections: envInt("MSLXDFF_OA_KEEPALIVE_CONNECTIONS", 20),
|
|
60
|
+
pipelining: 1,
|
|
61
|
+
});
|
|
62
|
+
dispatcher = agent;
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildHeaders(body, key) {
|
|
67
|
+
const isStream = body?.stream !== false;
|
|
68
|
+
const h = {
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
"Accept": isStream ? "text/event-stream" : "*/*",
|
|
71
|
+
"User-Agent": "mslxdff",
|
|
72
|
+
};
|
|
73
|
+
if (key) h["Authorization"] = `Bearer ${key}`;
|
|
74
|
+
h["HTTP-Referer"] = process.env.MSLXDFF_OPENROUTER_REFERER || "https://github.com/mslxdff";
|
|
75
|
+
h["X-Title"] = process.env.MSLXDFF_OPENROUTER_TITLE || "mslxdff";
|
|
76
|
+
return { ...h, ...extraHeaders };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function attemptOnce(url, body, key, activeRing) {
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
const timer = setTimeout(() => controller.abort(new Error(`openrouter timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
82
|
+
try {
|
|
83
|
+
const opts = { method: "POST", headers: buildHeaders(body, key), body: JSON.stringify(body), signal: controller.signal };
|
|
84
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
85
|
+
const res = await fetchImpl(url, opts);
|
|
86
|
+
if (res.status === 401 && !activeRing.size) return { __needKey: true };
|
|
87
|
+
return res;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return err;
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function runChat(body, activeRing, sourceKey) {
|
|
96
|
+
const url = `${baseUrl}/chat/completions`;
|
|
97
|
+
const t0 = performance.now();
|
|
98
|
+
const attempts = [];
|
|
99
|
+
let waitMs = 0;
|
|
100
|
+
|
|
101
|
+
// 轮转取一个可用 key;仅当"有 key 但全部在冷却"才算供应商暂时失效。
|
|
102
|
+
// 没有配置任何 key 时不在这拦截——下降到 401 无 key 报错路径(__needKey)。
|
|
103
|
+
const key = activeRing.next();
|
|
104
|
+
if (!key && activeRing.size > 0) {
|
|
105
|
+
const err = new Error(`openrouter: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`);
|
|
106
|
+
err._t = { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0), cooldownMs };
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (let attempt = 0; ; attempt++) {
|
|
111
|
+
const t = performance.now();
|
|
112
|
+
const result = await attemptOnce(url, body, key, activeRing);
|
|
113
|
+
const type = result instanceof Error ? "network" : `http${result?.status}`;
|
|
114
|
+
attempts.push({ attempt, type, ms: Math.round(performance.now() - t) });
|
|
115
|
+
if (result?.__needKey) {
|
|
116
|
+
const err = new Error(`openrouter: missing ${sourceKey} (chat requires a real key)`);
|
|
117
|
+
err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
if (result instanceof Error) {
|
|
121
|
+
const entry = retry?.network;
|
|
122
|
+
if (entry && attempt < entry.attempts) {
|
|
123
|
+
await sleep(entry.delayMs);
|
|
124
|
+
waitMs += entry.delayMs;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
activeRing.onError(key);
|
|
128
|
+
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
129
|
+
throw result;
|
|
130
|
+
}
|
|
131
|
+
const entry = retry?.[result.status];
|
|
132
|
+
if (entry && attempt < entry.attempts) {
|
|
133
|
+
await sleep(entry.delayMs);
|
|
134
|
+
waitMs += entry.delayMs;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
|
|
138
|
+
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function chat(body) {
|
|
144
|
+
return runChat(body, ring, "MSLXDFF_OPENROUTER_KEY");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ADR-0008 瞬时共享:用组员方传来的临时 key 发布请求(不污染本 provider 的持久 ring)。
|
|
148
|
+
async function chatWithKeys(body, keys) {
|
|
149
|
+
const tmp = createKeyRing(keys, { cooldownMs });
|
|
150
|
+
return runChat(body, tmp, "shared provider keys");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 拉免费模型:pricing 全 0 + 前缀化;可匿名
|
|
154
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
155
|
+
let cache = null;
|
|
156
|
+
let fetchedAt = 0;
|
|
157
|
+
async function listModels() {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
|
|
160
|
+
const url = `${baseUrl}/models`;
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
const timer = setTimeout(() => controller.abort(new Error("openrouter models timed out")), 15_000);
|
|
163
|
+
try {
|
|
164
|
+
const opts = { headers: { Accept: "application/json" }, signal: controller.signal };
|
|
165
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
166
|
+
const res = await fetchImpl(url, opts);
|
|
167
|
+
if (!res.ok) return [];
|
|
168
|
+
const json = await res.json().catch(() => ({}));
|
|
169
|
+
const raw = Array.isArray(json.data) ? json.data : [];
|
|
170
|
+
const free = raw.filter((m) =>
|
|
171
|
+
Number(m.pricing?.prompt || 0) === 0 && Number(m.pricing?.completion || 0) === 0
|
|
172
|
+
);
|
|
173
|
+
cache = free.map((m) => ({ ...m, id: joinModelId("openrouter", m.id) }));
|
|
174
|
+
fetchedAt = now;
|
|
175
|
+
return cache;
|
|
176
|
+
} catch {
|
|
177
|
+
return [];
|
|
178
|
+
} finally {
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function preheat() {
|
|
184
|
+
const url = `${baseUrl}/models`;
|
|
185
|
+
const t0 = performance.now();
|
|
186
|
+
try {
|
|
187
|
+
const opts = { headers: { Accept: "application/json" } };
|
|
188
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
189
|
+
const res = await fetchImpl(url, opts);
|
|
190
|
+
try { if (res.body) await res.text().catch(() => {}); } catch {}
|
|
191
|
+
return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
|
|
192
|
+
} catch (err) {
|
|
193
|
+
return { ok: false, error: String(err?.message || err), ms: Math.round(performance.now() - t0) };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function close() {
|
|
198
|
+
if (agent && typeof agent.close === "function") {
|
|
199
|
+
try { await agent.close(); } catch {}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
id: "openrouter",
|
|
205
|
+
chat,
|
|
206
|
+
chatWithKeys,
|
|
207
|
+
listModels,
|
|
208
|
+
preheat,
|
|
209
|
+
close,
|
|
210
|
+
agent,
|
|
211
|
+
keyRing: ring,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function sleep(ms) {
|
|
216
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
217
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// ADR-0008:供应商 key 瞬时共享 —— 转发时附带、组员一次性借用,用完即弃。
|
|
2
|
+
// header: `x-mslxdff-share-keys: <providerId>=<key1>,<key2>;<providerId2>=<key3>`
|
|
3
|
+
import { splitModelId, DEFAULT_PROVIDER } from "./model-id.js";
|
|
4
|
+
import { loadProviderKeys, loadProviderShareKeys } from "../state.js";
|
|
5
|
+
|
|
6
|
+
export const SHARE_KEYS_HEADER = "x-mslxdff-share-keys";
|
|
7
|
+
|
|
8
|
+
// 本节点应 cast key 到出站转发的供应商 id 集合(开启 share 且配了 key 的)。
|
|
9
|
+
// share 只在"有 key 且开关为 true"时发生;否则不附带,避免空头泄露配置意图。
|
|
10
|
+
// 默认供应商(opencode)恒排除:它无 key 可共享(public 即可),限流以 IP 为主,
|
|
11
|
+
// 分散 IP 由 peer 转发天然实现,不需要也不应走 key 共享。
|
|
12
|
+
export function shareableProviderIds({ file } = {}) {
|
|
13
|
+
const ids = new Set();
|
|
14
|
+
const raw = process.env.MSLXDFF_SHARE_PROVIDERS || ""; // 高级:显式白名单,逗号分隔
|
|
15
|
+
const explicit = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
16
|
+
if (explicit.length) {
|
|
17
|
+
for (const id of explicit) {
|
|
18
|
+
if (id === DEFAULT_PROVIDER) continue; // opencode 永远不可 share
|
|
19
|
+
ids.add(id);
|
|
20
|
+
}
|
|
21
|
+
return [...ids];
|
|
22
|
+
}
|
|
23
|
+
for (const id of ["openrouter"]) {
|
|
24
|
+
if (id !== DEFAULT_PROVIDER && loadProviderKeys(id, { file }).length && loadProviderShareKeys(id, { file })) ids.add(id);
|
|
25
|
+
}
|
|
26
|
+
return [...ids];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 组装 share header 值(只含 shareable 且当前请求命中前缀的供应商)。
|
|
30
|
+
// splitModelId 用 shareableProviderIds() 作为已知集合:命中 → 该供应商在此节点 shareable;
|
|
31
|
+
// 未命中或裸 id(默认供应商 opencode)→ 不附带。返回 null 表示本请求不需要附带。
|
|
32
|
+
export function buildShareKeysHeader(model, { file } = {}) {
|
|
33
|
+
const split = splitModelId(model, shareableProviderIds({ file }));
|
|
34
|
+
if (!split.provider || split.provider === DEFAULT_PROVIDER) return null;
|
|
35
|
+
const keys = loadProviderKeys(split.provider, { file });
|
|
36
|
+
if (!keys.length) return null;
|
|
37
|
+
return `${split.provider}=${keys.join(",")}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 组员侧:解析 share header 为 { providerId -> keys[] }
|
|
41
|
+
// 防御:默认供应商(opencode)即使出现在 header 一律忽略——它不该有共享 key。
|
|
42
|
+
export function parseShareKeysHeader(value) {
|
|
43
|
+
const out = {};
|
|
44
|
+
if (!value) return out;
|
|
45
|
+
for (const seg of String(value).split(";")) {
|
|
46
|
+
const [provider, keysPart] = seg.split("=");
|
|
47
|
+
const id = String(provider || "").trim();
|
|
48
|
+
if (!id || id === DEFAULT_PROVIDER || !keysPart) continue;
|
|
49
|
+
const keys = String(keysPart).split(",").map((k) => k.trim()).filter(Boolean);
|
|
50
|
+
if (keys.length) out[id] = keys;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const BALANCE_TTL_MS = 5 * 60 * 1000;
|
|
2
|
+
|
|
3
|
+
const balanceCache = new Map(); // uid -> { total, dailyPacks, activeCount, nextExpire, fetchedAt }
|
|
4
|
+
|
|
5
|
+
export function getBalanceCache() { return balanceCache; }
|
|
6
|
+
export function clearBalanceCache() { balanceCache.clear(); }
|
|
7
|
+
|
|
8
|
+
export function getCachedBalance(uid) {
|
|
9
|
+
const v = balanceCache.get(String(uid));
|
|
10
|
+
if (!v) return null;
|
|
11
|
+
if (Date.now() - v.fetchedAt > BALANCE_TTL_MS) {
|
|
12
|
+
balanceCache.delete(String(uid));
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return v;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function setCachedBalance(uid, data) {
|
|
19
|
+
const v = { ...data, fetchedAt: Date.now() };
|
|
20
|
+
balanceCache.set(String(uid), v);
|
|
21
|
+
return v;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function fetchBalance({ uid, key, auth, domain, baseUrl = "https://www.codebuddy.cn", fetchImpl } = {}) {
|
|
25
|
+
const at = key || auth?.accessToken || "";
|
|
26
|
+
const u = uid || auth?.uid || "";
|
|
27
|
+
const d = domain || auth?.domain || "www.codebuddy.cn";
|
|
28
|
+
if (!u || !at) return null;
|
|
29
|
+
const fetcher = fetchImpl || fetch;
|
|
30
|
+
const body = JSON.stringify({
|
|
31
|
+
PageNumber: 1, PageSize: 100, ProductCode: "p_tcaca", Status: [0, 3],
|
|
32
|
+
PackageEndTimeRangeBegin: "2026-08-01 00:00:00",
|
|
33
|
+
PackageEndTimeRangeEnd: "2030-01-01 00:00:00",
|
|
34
|
+
});
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetcher(`${baseUrl.replace(/\/+$/, "")}/v2/billing/meter/get-user-resource`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
Authorization: `Bearer ${at}`,
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
"X-User-Id": u,
|
|
42
|
+
"X-Domain": d,
|
|
43
|
+
},
|
|
44
|
+
body,
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok) return null;
|
|
47
|
+
const j = await res.json().catch(() => null);
|
|
48
|
+
const accs = j?.data?.Response?.Data?.Accounts || j?.data?.Response?.data?.Accounts || [];
|
|
49
|
+
// fallback for stub shape
|
|
50
|
+
const accounts = Array.isArray(accs) ? accs : [];
|
|
51
|
+
const active = accounts.filter(a => a.Status === 0);
|
|
52
|
+
const total = active.reduce((s, a) => s + Number(a.CycleCapacityRemainPrecise || a.CycleCapacityRemain || 0), 0);
|
|
53
|
+
const dailyPacks = active.filter(a => a.PackageName?.includes("裂变包") && String(a.CycleCapacitySizePrecise) === "100");
|
|
54
|
+
const sorted = [...active].sort((a,b)=> new Date(a.CycleEndTime) - new Date(b.CycleEndTime));
|
|
55
|
+
const nextExpire = sorted[0]?.CycleEndTime || null;
|
|
56
|
+
const data = {
|
|
57
|
+
total: Number(total.toFixed(2)),
|
|
58
|
+
dailyPacks: dailyPacks.length,
|
|
59
|
+
activeCount: active.length,
|
|
60
|
+
nextExpire,
|
|
61
|
+
fetchedAt: Date.now(),
|
|
62
|
+
};
|
|
63
|
+
// string total for CLI compatibility
|
|
64
|
+
data.totalStr = total.toFixed(2);
|
|
65
|
+
setCachedBalance(u, data);
|
|
66
|
+
return data;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function getBalanceWithCache({ uid, key, auth, domain, baseUrl, fetchImpl } = {}) {
|
|
73
|
+
const cached = getCachedBalance(uid || auth?.uid);
|
|
74
|
+
if (cached) return cached;
|
|
75
|
+
return fetchBalance({ uid, key, auth, domain, baseUrl, fetchImpl });
|
|
76
|
+
}
|