mslxdff 0.1.65 → 0.1.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/mslxdff.js +3 -3159
- package/package.json +1 -1
- package/src/chat/cooling.js +99 -0
- package/src/chat/direct.js +57 -0
- package/src/chat/gateway.js +148 -0
- package/src/chat/orchestrator.js +218 -0
- package/src/chat/sse.js +69 -0
- package/src/chat/upstream.js +59 -501
- package/src/cli/bootstrap.js +1 -0
- package/src/cli/commands/daemon.js +151 -0
- package/src/cli/commands/group.js +272 -0
- package/src/cli/commands/model.js +405 -0
- package/src/cli/commands/provider/add.js +106 -0
- package/src/cli/commands/provider/allowlist.js +99 -0
- package/src/cli/commands/provider/config.js +82 -0
- package/src/cli/commands/provider/index.js +193 -0
- package/src/cli/commands/provider/keys.js +135 -0
- package/src/cli/commands/provider/models.js +99 -0
- package/src/cli/commands/provider.js +1 -0
- package/src/cli/commands/sync.js +143 -0
- package/src/cli/commands/system.js +236 -0
- package/src/cli/commands/workbuddy.js +91 -0
- package/src/cli/format.js +119 -0
- package/src/cli/group-helpers.js +69 -0
- package/src/cli/help.js +66 -0
- package/src/cli/index.js +59 -0
- package/src/cli/interactive.js +84 -0
- package/src/cli/policy.js +119 -0
- package/src/cli/provider-row.js +73 -0
- package/src/cli/status.js +283 -0
- package/src/cli/util.js +24 -0
- package/src/providers/base.js +159 -0
- package/src/providers/dispatcher.js +18 -6
- package/src/providers/generic.js +28 -166
- package/src/providers/openrouter.js +19 -205
- package/src/providers/workbuddy/auth.js +175 -0
- package/src/providers/workbuddy/balance.js +84 -0
- package/src/providers/workbuddy/chat.js +310 -0
- package/src/providers/workbuddy/index.js +263 -0
- package/src/providers/workbuddy/models.js +111 -0
- package/src/providers/workbuddy/rotation-log.js +54 -0
- package/src/providers/workbuddy.js +2 -677
- package/src/routes/chat/broadband-handler.js +25 -46
- package/src/routes/chat/exhausted-handler.js +2 -2
- package/src/routes/chat/gateway.js +301 -0
- package/src/routes/chat/hedge-handler.js +65 -83
- package/src/routes/chat/index.js +1 -384
- package/src/routes/chat/local-handler.js +32 -61
- package/src/routes/chat/peer-handler.js +32 -23
- package/src/routes/chat/relay-pipeline.js +151 -0
- package/src/runtime/bootstrap.js +408 -0
- package/src/state/facade.js +57 -0
- package/src/state/memory.js +161 -0
- package/src/state/merge.js +26 -0
- package/src/state/persist.js +42 -0
- package/src/state/provider-config.js +143 -0
- package/src/state/schemas/allowlist.js +87 -0
- package/src/state/schemas/group.js +31 -0
- package/src/state/schemas/model.js +53 -0
- package/src/state/schemas/peer.js +31 -0
- package/src/state/schemas/port.js +10 -0
- package/src/state/schemas/provider.js +204 -0
- package/src/state/schemas/token.js +43 -0
- package/src/state/store.js +176 -0
- package/src/state.js +1 -711
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createKeyRing } from "./keyring.js";
|
|
2
|
+
import { joinModelId } from "./model-id.js";
|
|
3
|
+
|
|
4
|
+
let UndiciAgent = null;
|
|
5
|
+
let UndiciFetch = null;
|
|
6
|
+
try {
|
|
7
|
+
const mod = await import("undici");
|
|
8
|
+
UndiciAgent = mod.Agent;
|
|
9
|
+
UndiciFetch = mod.fetch;
|
|
10
|
+
} catch {}
|
|
11
|
+
|
|
12
|
+
export function envInt(name, fallback) {
|
|
13
|
+
const v = Number(process.env[name]);
|
|
14
|
+
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function joinUrl(base, path) {
|
|
18
|
+
const b = String(base || "").trim().replace(/\/+$/, "");
|
|
19
|
+
const p = String(path || "").trim();
|
|
20
|
+
if (!p) return b;
|
|
21
|
+
const pp = p.startsWith("/") ? p : `/${p}`;
|
|
22
|
+
return `${b}${pp}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sleep(ms) {
|
|
26
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getUndici() {
|
|
30
|
+
return { UndiciAgent, UndiciFetch };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createAgent({ keepAliveTimeout = 30_000, keepAliveMaxTimeout = 60_000, connections = 20 } = {}) {
|
|
34
|
+
if (!UndiciAgent) return { agent: null, dispatcher: null };
|
|
35
|
+
try {
|
|
36
|
+
const agent = new UndiciAgent({ keepAliveTimeout, keepAliveMaxTimeout, connections, pipelining: 1 });
|
|
37
|
+
return { agent, dispatcher: agent };
|
|
38
|
+
} catch {
|
|
39
|
+
return { agent: null, dispatcher: null };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function collectApiKeysGeneric(id, apiKeys, apiKey, loadKeys) {
|
|
44
|
+
const list = [
|
|
45
|
+
...(Array.isArray(apiKeys) ? apiKeys : [apiKeys].filter(Boolean)),
|
|
46
|
+
apiKey,
|
|
47
|
+
...(apiKeys === undefined && apiKey === undefined ? loadKeys(id) : []),
|
|
48
|
+
].filter((k) => typeof k === "string" && k.trim().length);
|
|
49
|
+
return [...new Set(list.map((k) => k.trim()))];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createChatRunner({ id, ring, cooldownMs, retry, fetchImpl, dispatcher, buildHeaders, getUrl, connectTimeoutMs = 30_000 }) {
|
|
53
|
+
async function attemptOnce(url, body, key, activeRing) {
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
const timer = setTimeout(() => controller.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
56
|
+
try {
|
|
57
|
+
const opts = { method: "POST", headers: buildHeaders(body, key), body: JSON.stringify(body), signal: controller.signal };
|
|
58
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
59
|
+
const res = await fetchImpl(url, opts);
|
|
60
|
+
if (res.status === 401 && !activeRing.size) return { __needKey: true };
|
|
61
|
+
return res;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return err;
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function runChat(body, activeRing, sourceKey) {
|
|
70
|
+
const url = getUrl();
|
|
71
|
+
const t0 = performance.now();
|
|
72
|
+
const attempts = [];
|
|
73
|
+
let waitMs = 0;
|
|
74
|
+
const key = activeRing.next();
|
|
75
|
+
if (!key && activeRing.size > 0) {
|
|
76
|
+
const err = new Error(`${id}: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`);
|
|
77
|
+
err._t = { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0), cooldownMs };
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
for (let attempt = 0; ; attempt++) {
|
|
81
|
+
const t = performance.now();
|
|
82
|
+
const result = await attemptOnce(url, body, key, activeRing);
|
|
83
|
+
const type = result instanceof Error ? "network" : `http${result?.status}`;
|
|
84
|
+
attempts.push({ attempt, type, ms: Math.round(performance.now() - t) });
|
|
85
|
+
if (result?.__needKey) {
|
|
86
|
+
const err = new Error(`${id}: missing ${sourceKey} (chat requires a real key)`);
|
|
87
|
+
err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
if (result instanceof Error) {
|
|
91
|
+
const entry = retry?.network;
|
|
92
|
+
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
93
|
+
activeRing.onError(key);
|
|
94
|
+
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
95
|
+
throw result;
|
|
96
|
+
}
|
|
97
|
+
const entry = retry?.[result.status];
|
|
98
|
+
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
99
|
+
if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
|
|
100
|
+
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { attemptOnce, runChat };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createListModelsRunner({ id, ring, dispatcher, fetchImpl, getUrl, mapModel }) {
|
|
109
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
110
|
+
let cache = null;
|
|
111
|
+
let fetchedAt = 0;
|
|
112
|
+
async function listModels() {
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
|
|
115
|
+
const url = getUrl();
|
|
116
|
+
const controller = new AbortController();
|
|
117
|
+
const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
|
|
118
|
+
try {
|
|
119
|
+
const headers = { Accept: "application/json" };
|
|
120
|
+
const key = ring.next();
|
|
121
|
+
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
122
|
+
const opts = { headers, signal: controller.signal };
|
|
123
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
124
|
+
const res = await fetchImpl(url, opts);
|
|
125
|
+
if (!res.ok) return [];
|
|
126
|
+
const json = await res.json().catch(() => ({}));
|
|
127
|
+
const raw = Array.isArray(json.data) ? json.data : Array.isArray(json) ? json : [];
|
|
128
|
+
const mapped = mapModel ? mapModel(raw) : raw;
|
|
129
|
+
cache = mapped.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
|
|
130
|
+
fetchedAt = now;
|
|
131
|
+
return cache;
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
} finally {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { listModels, _cache: () => cache };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createPreheatRunner({ dispatcher, fetchImpl, getUrl, id, ring, loadKeys }) {
|
|
142
|
+
async function preheat() {
|
|
143
|
+
const url = getUrl();
|
|
144
|
+
const t0 = performance.now();
|
|
145
|
+
try {
|
|
146
|
+
const headers = { Accept: "application/json" };
|
|
147
|
+
const key = (loadKeys ? loadKeys(id)[0] : ring.next()) || ring.next();
|
|
148
|
+
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
149
|
+
const opts = { headers };
|
|
150
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
151
|
+
const res = await fetchImpl(url, opts);
|
|
152
|
+
try { if (res.body) await res.text().catch(() => {}); } catch {}
|
|
153
|
+
return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return { ok: false, error: String(err?.message || err), ms: Math.round(performance.now() - t0) };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { preheat };
|
|
159
|
+
}
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { splitModelId, DEFAULT_PROVIDER, joinModelId } from "./model-id.js";
|
|
2
|
-
import { isModelAllowed, loadProviderAllowedModels, loadProviderAllowAnyModels } from "../state.js";
|
|
2
|
+
import { isModelAllowed as stateIsAllowed, loadProviderAllowedModels as stateLoadAllowed, loadProviderAllowAnyModels as stateLoadAllowAny } from "../state.js";
|
|
3
3
|
|
|
4
4
|
// 多供应商 dispatcher:把多个 Provider 聚合成一个 `upstream` 形状(chat/preheat/close),
|
|
5
5
|
// 按 body.model 的前缀路由到对应供应商,转发上游前剥掉前缀只发原始 id。
|
|
6
|
-
|
|
6
|
+
// 纯化:第二参可注入纯函数 isAllowed/getAllowed/getAllowAny,便于单测不读盘
|
|
7
|
+
export function createProviderDispatcher(providers = [], opts = {}) {
|
|
7
8
|
const byId = new Map(providers.map((p) => [p.id, p]));
|
|
9
|
+
// 纯化注入:第二参可为 {isAllowed, getAllowedModels, getAllowAny} 或直接函数 isAllowed
|
|
10
|
+
let isAllowedFn, getAllowedFn, getAllowAnyFn;
|
|
11
|
+
if (typeof opts === "function") {
|
|
12
|
+
isAllowedFn = opts;
|
|
13
|
+
getAllowedFn = stateLoadAllowed;
|
|
14
|
+
getAllowAnyFn = stateLoadAllowAny;
|
|
15
|
+
} else {
|
|
16
|
+
isAllowedFn = opts.isAllowed || stateIsAllowed;
|
|
17
|
+
getAllowedFn = opts.getAllowedModels || stateLoadAllowed;
|
|
18
|
+
getAllowAnyFn = opts.getAllowAny || stateLoadAllowAny;
|
|
19
|
+
}
|
|
8
20
|
|
|
9
21
|
function resolve(model) {
|
|
10
22
|
const split = splitModelId(model, providers.map((p) => p.id));
|
|
@@ -29,8 +41,8 @@ export function createProviderDispatcher(providers = []) {
|
|
|
29
41
|
raw = modelPart;
|
|
30
42
|
}
|
|
31
43
|
}
|
|
32
|
-
if (!
|
|
33
|
-
const allowed =
|
|
44
|
+
if (!isAllowedFn(provider.id, raw)) {
|
|
45
|
+
const allowed = getAllowedFn(provider.id) || [];
|
|
34
46
|
const msg = `model not allowed for provider "${provider.id}": "${raw}" — allowed: ${allowed.join(", ") || "(none)"} (use: mslxdff -provider ${provider.id} allowlist add <model>)`;
|
|
35
47
|
return new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-allowlist": "1" } });
|
|
36
48
|
}
|
|
@@ -58,8 +70,8 @@ export function createProviderDispatcher(providers = []) {
|
|
|
58
70
|
} catch {
|
|
59
71
|
list = [];
|
|
60
72
|
}
|
|
61
|
-
const allowed =
|
|
62
|
-
const allowAny =
|
|
73
|
+
const allowed = getAllowedFn(p.id) || [];
|
|
74
|
+
const allowAny = getAllowAnyFn(p.id);
|
|
63
75
|
const allowedSet = allowed.length ? new Set(allowed) : null;
|
|
64
76
|
// 空名单且不允许任意模型 => 该供应商不暴露任何模型(安全默认)
|
|
65
77
|
if (!allowedSet && !allowAny) continue;
|
package/src/providers/generic.js
CHANGED
|
@@ -1,36 +1,8 @@
|
|
|
1
|
-
import { joinModelId } from "./model-id.js";
|
|
2
1
|
import { createKeyRing } from "./keyring.js";
|
|
3
2
|
import { loadProviderKeys, loadProviderBaseUrl, loadProviderModelsPath, loadProviderChatPath } from "../state.js";
|
|
3
|
+
import { envInt, joinUrl, getUndici, createAgent, collectApiKeysGeneric, createChatRunner, createListModelsRunner, createPreheatRunner } from "./base.js";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
const b = String(base || "").trim().replace(/\/+$/, "");
|
|
7
|
-
const p = String(path || "").trim();
|
|
8
|
-
if (!p) return b;
|
|
9
|
-
const pp = p.startsWith("/") ? p : `/${p}`;
|
|
10
|
-
return `${b}${pp}`;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
let UndiciAgent = null;
|
|
14
|
-
let UndiciFetch = null;
|
|
15
|
-
try {
|
|
16
|
-
const mod = await import("undici");
|
|
17
|
-
UndiciAgent = mod.Agent;
|
|
18
|
-
UndiciFetch = mod.fetch;
|
|
19
|
-
} catch {}
|
|
20
|
-
|
|
21
|
-
function envInt(name, fallback) {
|
|
22
|
-
const v = Number(process.env[name]);
|
|
23
|
-
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function collectApiKeys(id, apiKeys, apiKey) {
|
|
27
|
-
const list = [
|
|
28
|
-
...(Array.isArray(apiKeys) ? apiKeys : [apiKeys].filter(Boolean)),
|
|
29
|
-
apiKey,
|
|
30
|
-
...(apiKeys === undefined && apiKey === undefined ? loadProviderKeys(id) : []),
|
|
31
|
-
].filter((k) => typeof k === "string" && k.trim().length);
|
|
32
|
-
return [...new Set(list.map((k) => k.trim()))];
|
|
33
|
-
}
|
|
5
|
+
const { UndiciFetch } = getUndici();
|
|
34
6
|
|
|
35
7
|
function resolveBaseUrl(id, baseUrl) {
|
|
36
8
|
if (baseUrl) return String(baseUrl).trim().replace(/\/+$/, "");
|
|
@@ -57,6 +29,7 @@ export function createGenericProvider({
|
|
|
57
29
|
},
|
|
58
30
|
fetchImpl,
|
|
59
31
|
headers: extraHeaders,
|
|
32
|
+
mapModel,
|
|
60
33
|
noAgent = false,
|
|
61
34
|
file,
|
|
62
35
|
} = {}) {
|
|
@@ -66,169 +39,58 @@ export function createGenericProvider({
|
|
|
66
39
|
if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
|
|
67
40
|
const resolvedModelsPath = modelsPath || loadProviderModelsPath(id, file ? { file } : {});
|
|
68
41
|
const resolvedChatPath = chatPath || loadProviderChatPath(id, file ? { file } : {});
|
|
69
|
-
|
|
70
|
-
const ring = createKeyRing(collectApiKeys(id, apiKeys, apiKey), { cooldownMs });
|
|
42
|
+
const ring = createKeyRing(collectApiKeysGeneric(id, apiKeys, apiKey, loadProviderKeys), { cooldownMs });
|
|
71
43
|
|
|
72
44
|
let dispatcher = null;
|
|
73
45
|
let agent = null;
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
});
|
|
82
|
-
dispatcher = agent;
|
|
83
|
-
} catch {}
|
|
46
|
+
if (!noAgent) {
|
|
47
|
+
const a = createAgent({
|
|
48
|
+
keepAliveTimeout: envInt("MSLXDFF_GENERIC_KEEPALIVE_TIMEOUT", 30_000),
|
|
49
|
+
keepAliveMaxTimeout: envInt("MSLXDFF_GENERIC_KEEPALIVE_MAX_TIMEOUT", 60_000),
|
|
50
|
+
connections: envInt("MSLXDFF_GENERIC_KEEPALIVE_CONNECTIONS", 20),
|
|
51
|
+
});
|
|
52
|
+
agent = a.agent; dispatcher = a.dispatcher;
|
|
84
53
|
}
|
|
85
54
|
|
|
86
55
|
function buildHeaders(body, key) {
|
|
87
56
|
const isStream = body?.stream !== false;
|
|
88
57
|
const h = {
|
|
89
58
|
"Content-Type": "application/json",
|
|
90
|
-
|
|
59
|
+
Accept: isStream ? "text/event-stream" : "*/*",
|
|
91
60
|
"User-Agent": "mslxdff",
|
|
92
61
|
};
|
|
93
62
|
if (key) h["Authorization"] = `Bearer ${key}`;
|
|
94
63
|
return { ...h, ...extraHeaders };
|
|
95
64
|
}
|
|
96
65
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (dispatcher) opts.dispatcher = dispatcher;
|
|
103
|
-
const res = await fetchImpl(url, opts);
|
|
104
|
-
if (res.status === 401 && !activeRing.size) return { __needKey: true };
|
|
105
|
-
return res;
|
|
106
|
-
} catch (err) {
|
|
107
|
-
return err;
|
|
108
|
-
} finally {
|
|
109
|
-
clearTimeout(timer);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async function runChat(body, activeRing, sourceKey) {
|
|
114
|
-
const url = joinUrl(resolvedBase, resolvedChatPath);
|
|
115
|
-
const t0 = performance.now();
|
|
116
|
-
const attempts = [];
|
|
117
|
-
let waitMs = 0;
|
|
118
|
-
const key = activeRing.next();
|
|
119
|
-
if (!key && activeRing.size > 0) {
|
|
120
|
-
const err = new Error(`${id}: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`);
|
|
121
|
-
err._t = { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0), cooldownMs };
|
|
122
|
-
throw err;
|
|
123
|
-
}
|
|
124
|
-
for (let attempt = 0; ; attempt++) {
|
|
125
|
-
const t = performance.now();
|
|
126
|
-
const result = await attemptOnce(url, body, key, activeRing);
|
|
127
|
-
const type = result instanceof Error ? "network" : `http${result?.status}`;
|
|
128
|
-
attempts.push({ attempt, type, ms: Math.round(performance.now() - t) });
|
|
129
|
-
if (result?.__needKey) {
|
|
130
|
-
const err = new Error(`${id}: missing ${sourceKey} (chat requires a real key)`);
|
|
131
|
-
err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
132
|
-
throw err;
|
|
133
|
-
}
|
|
134
|
-
if (result instanceof Error) {
|
|
135
|
-
const entry = retry?.network;
|
|
136
|
-
if (entry && attempt < entry.attempts) {
|
|
137
|
-
await sleep(entry.delayMs);
|
|
138
|
-
waitMs += entry.delayMs;
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
activeRing.onError(key);
|
|
142
|
-
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
143
|
-
throw result;
|
|
144
|
-
}
|
|
145
|
-
const entry = retry?.[result.status];
|
|
146
|
-
if (entry && attempt < entry.attempts) {
|
|
147
|
-
await sleep(entry.delayMs);
|
|
148
|
-
waitMs += entry.delayMs;
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
|
|
152
|
-
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
153
|
-
return result;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
66
|
+
const { runChat } = createChatRunner({
|
|
67
|
+
id, ring, cooldownMs, retry, fetchImpl, dispatcher, buildHeaders,
|
|
68
|
+
getUrl: () => joinUrl(resolvedBase, resolvedChatPath),
|
|
69
|
+
connectTimeoutMs,
|
|
70
|
+
});
|
|
156
71
|
|
|
157
72
|
async function chat(body) {
|
|
158
73
|
return runChat(body, ring, `MSLXDFF_${id.toUpperCase()}_KEY`);
|
|
159
74
|
}
|
|
160
|
-
|
|
161
75
|
async function chatWithKeys(body, keys) {
|
|
162
76
|
const tmp = createKeyRing(keys, { cooldownMs });
|
|
163
77
|
return runChat(body, tmp, "shared provider keys");
|
|
164
78
|
}
|
|
165
79
|
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
|
|
172
|
-
const url = joinUrl(resolvedBase, resolvedModelsPath);
|
|
173
|
-
const controller = new AbortController();
|
|
174
|
-
const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
|
|
175
|
-
try {
|
|
176
|
-
const headers = { Accept: "application/json" };
|
|
177
|
-
const key = ring.next();
|
|
178
|
-
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
179
|
-
const opts = { headers, signal: controller.signal };
|
|
180
|
-
if (dispatcher) opts.dispatcher = dispatcher;
|
|
181
|
-
const res = await fetchImpl(url, opts);
|
|
182
|
-
if (!res.ok) return [];
|
|
183
|
-
const json = await res.json().catch(() => ({}));
|
|
184
|
-
const raw = Array.isArray(json.data) ? json.data : Array.isArray(json) ? json : [];
|
|
185
|
-
// 通用:不过滤 pricing,全部前缀化
|
|
186
|
-
cache = raw.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
|
|
187
|
-
fetchedAt = now;
|
|
188
|
-
return cache;
|
|
189
|
-
} catch {
|
|
190
|
-
return [];
|
|
191
|
-
} finally {
|
|
192
|
-
clearTimeout(timer);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
80
|
+
const { listModels } = createListModelsRunner({
|
|
81
|
+
id, ring, dispatcher, fetchImpl,
|
|
82
|
+
getUrl: () => joinUrl(resolvedBase, resolvedModelsPath),
|
|
83
|
+
mapModel,
|
|
84
|
+
});
|
|
195
85
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const headers = { Accept: "application/json" };
|
|
201
|
-
const key = loadProviderKeys(id)[0] || ring.next();
|
|
202
|
-
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
203
|
-
const opts = { headers };
|
|
204
|
-
if (dispatcher) opts.dispatcher = dispatcher;
|
|
205
|
-
const res = await fetchImpl(url, opts);
|
|
206
|
-
try { if (res.body) await res.text().catch(() => {}); } catch {}
|
|
207
|
-
return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
|
|
208
|
-
} catch (err) {
|
|
209
|
-
return { ok: false, error: String(err?.message || err), ms: Math.round(performance.now() - t0) };
|
|
210
|
-
}
|
|
211
|
-
}
|
|
86
|
+
const { preheat } = createPreheatRunner({
|
|
87
|
+
dispatcher, fetchImpl, getUrl: () => joinUrl(resolvedBase, resolvedModelsPath),
|
|
88
|
+
id, ring, loadKeys: loadProviderKeys,
|
|
89
|
+
});
|
|
212
90
|
|
|
213
91
|
async function close() {
|
|
214
|
-
if (agent && typeof agent.close === "function") {
|
|
215
|
-
try { await agent.close(); } catch {}
|
|
216
|
-
}
|
|
92
|
+
if (agent && typeof agent.close === "function") { try { await agent.close(); } catch {} }
|
|
217
93
|
}
|
|
218
94
|
|
|
219
|
-
return {
|
|
220
|
-
id,
|
|
221
|
-
chat,
|
|
222
|
-
chatWithKeys,
|
|
223
|
-
listModels,
|
|
224
|
-
preheat,
|
|
225
|
-
close,
|
|
226
|
-
agent,
|
|
227
|
-
keyRing: ring,
|
|
228
|
-
baseUrl: resolvedBase,
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function sleep(ms) {
|
|
233
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
95
|
+
return { id, chat, chatWithKeys, listModels, preheat, close, agent, keyRing: ring, baseUrl: resolvedBase };
|
|
234
96
|
}
|
|
@@ -1,217 +1,31 @@
|
|
|
1
|
-
import {
|
|
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 {}
|
|
1
|
+
import { createGenericProvider } from "./generic.js";
|
|
12
2
|
|
|
13
3
|
const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
|
|
14
4
|
|
|
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
5
|
export function createOpenRouterProvider({
|
|
32
6
|
apiKeys,
|
|
33
7
|
apiKey,
|
|
34
8
|
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
9
|
fetchImpl,
|
|
45
|
-
headers
|
|
10
|
+
headers,
|
|
46
11
|
noAgent = false,
|
|
12
|
+
...rest
|
|
47
13
|
} = {}) {
|
|
48
|
-
|
|
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 {
|
|
14
|
+
return createGenericProvider({
|
|
204
15
|
id: "openrouter",
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
16
|
+
baseUrl,
|
|
17
|
+
apiKeys,
|
|
18
|
+
apiKey,
|
|
19
|
+
modelsPath: "/models",
|
|
20
|
+
chatPath: "/chat/completions",
|
|
21
|
+
headers: {
|
|
22
|
+
"HTTP-Referer": process.env.MSLXDFF_OPENROUTER_REFERER || "https://github.com/mslxdff",
|
|
23
|
+
"X-Title": process.env.MSLXDFF_OPENROUTER_TITLE || "mslxdff",
|
|
24
|
+
...(headers || {}),
|
|
25
|
+
},
|
|
26
|
+
mapModel: (raw) => raw.filter((m) => Number(m.pricing?.prompt || 0) === 0 && Number(m.pricing?.completion || 0) === 0),
|
|
27
|
+
fetchImpl,
|
|
28
|
+
noAgent,
|
|
29
|
+
...rest,
|
|
30
|
+
});
|
|
213
31
|
}
|
|
214
|
-
|
|
215
|
-
function sleep(ms) {
|
|
216
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
217
|
-
}
|