mslxdff 0.1.227 → 0.1.229
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/docs/cli_help_mini.md +4 -7
- package/package.json +1 -1
- package/src/chat-pipeline/index.js +6 -2
- package/src/chat-pipeline/serial-trial.js +3 -1
- package/src/cli/commands/provider/cline-free.js +19 -9
- package/src/cli/commands/provider/index.js +1 -1
- package/src/cli/commands/provider/raccoon-quota.js +156 -109
- package/src/cli/format.js +2 -0
- package/src/providers/AGENTS.md +1 -1
- package/src/providers/agentrouter.js +225 -0
- package/src/providers/base.js +164 -164
- package/src/providers/cline/allowlist-sync.js +76 -38
- package/src/providers/cline/free-catalog.js +9 -6
- package/src/providers/dispatcher.js +146 -146
- package/src/providers/raccoon/const.js +202 -180
- package/src/providers/raccoon/credits.js +285 -185
- package/src/providers/raccoon/sse.js +1 -1
- package/src/providers/registry.js +7 -0
- package/src/routes/peer-probe.js +53 -0
- package/src/routes/peer-serial.js +56 -6
- package/src/routes/peers.js +8 -0
- package/src/runtime/raccoon-checkin.js +175 -126
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// AgentRouter 定制供应商:OpenAI 兼容聚合网关,有三道硬门槛(2026-10-21 现网 curl 实测):
|
|
2
|
+
// ① 客户端身份白名单 —— UA 为 `mslxdff`/`curl`/浏览器一律 401 `unauthorized client detected`,
|
|
3
|
+
// 只放行 `opencode/*` 与 `claude-cli/<ver> (external…)`;
|
|
4
|
+
// ② 模型分端点 —— `deepseek-v4-flash` 吃 /v1/chat/completions,`gpt-6.1-sol`/`gpt-6-astra`
|
|
5
|
+
// 对该端点回 400「请改用 /v1/responses」,只在 responses 通道可用;
|
|
6
|
+
// ③ 内容闸 —— 正文含 `x-anthropic` 字样稳定 500 `sensitive words detected`(连 user 消息也拦,
|
|
7
|
+
// 与 workbuddy 只扫 system/assistant 不同),见 stripAgentRouterSensitive。
|
|
8
|
+
// 故不复用 generic(其 UA 写死 `mslxdff`、responses 判定只认 models.dev npm 索引),这里自组装,
|
|
9
|
+
// 底层仍复用 base.js 的 runner 与 responses-channel 的协议互转。
|
|
10
|
+
import { createKeyRing } from "./keyring.js";
|
|
11
|
+
import { loadProviderBaseUrl, loadProviderKeys } from "../state.js";
|
|
12
|
+
import { createChatRunner, createAgent, envInt, getUndici, joinUrl, collectApiKeysGeneric } from "./base.js";
|
|
13
|
+
import { createResponsesChannel, resolveResponsesPath } from "./responses-channel.js";
|
|
14
|
+
import { compatFetch } from "../compat.js";
|
|
15
|
+
import { opencodeUa } from "../opencode-identity.js";
|
|
16
|
+
import { joinModelId } from "./model-id.js";
|
|
17
|
+
|
|
18
|
+
const { UndiciFetch } = getUndici();
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_AGENTROUTER_BASE = "https://agentrouter.org";
|
|
21
|
+
export const MODELS_PATH = "/v1/models";
|
|
22
|
+
export const CHAT_PATH = "/v1/chat/completions";
|
|
23
|
+
export const RESPONSES_PATH = "/v1/responses";
|
|
24
|
+
|
|
25
|
+
// 只吃 responses 的模型族:现网实测 gpt-6* 打 chat 端点回 400 并显式要求改 /v1/responses。
|
|
26
|
+
const RESPONSES_PREFIXES = ["gpt-6"];
|
|
27
|
+
|
|
28
|
+
export function isAgentRouterResponsesModel(model) {
|
|
29
|
+
const m = String(model || "").trim().toLowerCase();
|
|
30
|
+
if (!m) return false;
|
|
31
|
+
return RESPONSES_PREFIXES.some((p) => m.startsWith(p));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// baseUrl 归一:缺省取 state/env,剥掉尾部 `/v1`(路径常量自带 `/v1`,避免 /v1/v1)
|
|
35
|
+
export function resolveAgentRouterBase(baseUrl) {
|
|
36
|
+
const raw = baseUrl || loadProviderBaseUrl("agentrouter") || DEFAULT_AGENTROUTER_BASE;
|
|
37
|
+
return String(raw).trim().replace(/\/+$/, "").replace(/\/v1$/i, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// responses 通道内部两处 stream 判据不一致(响应通道用 `!== false` 决定读法,body 转换用
|
|
41
|
+
// `=== true` 决定发什么):客户端不显式给 stream 时,会去把未转换的上游 JSON 当 chat 形状读。
|
|
42
|
+
// 这里先把意图钉成布尔,两条路径就同源了。
|
|
43
|
+
export function normalizeResponsesBody(body) {
|
|
44
|
+
if (!body || typeof body !== "object") return body;
|
|
45
|
+
return { ...body, stream: body.stream === true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// AgentRouter 内容闸:稳定拦截正文里含 `x-anthropic` 字样的请求(2026-10-21 现网实测:
|
|
49
|
+
// `500 sensitive words detected` / `400 content-blocked`,连 `x-anthropic` 一个词也拦;
|
|
50
|
+
// `x-google`/`x-meta`/`x-openai`/`anthropic` 均放行——这是对 Anthropic 内部计费头/代理指纹的反代理风控)。
|
|
51
|
+
// 与 workbuddy 的 CC_SENTENCES 剥离同类,但**必须覆盖所有 role**(workbuddy 只扫 system/assistant,
|
|
52
|
+
// 而 agentrouter 连 user/tool 消息里出现该串也拦)。用零宽空格断开子串匹配、不动其余内容。
|
|
53
|
+
// 返回新对象(messages 浅拷贝),不污染调用方 body(dispatcher 可能复用同一 body 做兜底重试)。
|
|
54
|
+
const AR_SENSITIVE = /x-anthropic/gi;
|
|
55
|
+
|
|
56
|
+
export function stripAgentRouterSensitive(obj) {
|
|
57
|
+
if (!obj || typeof obj !== "object") return obj;
|
|
58
|
+
if (!Array.isArray(obj.messages)) return obj;
|
|
59
|
+
const mask = (text) => (typeof text === "string" && AR_SENSITIVE.test(text) ? text.replace(AR_SENSITIVE, "xanthropic") : text);
|
|
60
|
+
const out = { ...obj, messages: obj.messages.map((m) => {
|
|
61
|
+
if (!m || typeof m !== "object") return m;
|
|
62
|
+
const nm = { ...m };
|
|
63
|
+
if (typeof m.content === "string") nm.content = mask(m.content);
|
|
64
|
+
else if (Array.isArray(m.content)) nm.content = m.content.map((p) => (p && typeof p === "object" && typeof p.text === "string" ? { ...p, text: mask(p.text) } : p));
|
|
65
|
+
if (typeof m.name === "string") nm.name = mask(m.name);
|
|
66
|
+
return nm;
|
|
67
|
+
}) };
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createAgentRouterProvider({
|
|
72
|
+
id = "agentrouter",
|
|
73
|
+
baseUrl,
|
|
74
|
+
apiKeys,
|
|
75
|
+
apiKey,
|
|
76
|
+
connectTimeoutMs = envInt("MSLXDFF_AGENTROUTER_TIMEOUT_MS", 60_000),
|
|
77
|
+
cooldownMs = envInt("MSLXDFF_AGENTROUTER_COOLDOWN_MS", 30_000),
|
|
78
|
+
fetchImpl,
|
|
79
|
+
file,
|
|
80
|
+
} = {}) {
|
|
81
|
+
if (id !== "agentrouter") throw new Error(`agentrouter provider id mismatch: ${id}`);
|
|
82
|
+
const resolvedBase = resolveAgentRouterBase(baseUrl);
|
|
83
|
+
if (!fetchImpl) fetchImpl = UndiciFetch || compatFetch;
|
|
84
|
+
|
|
85
|
+
const ring = createKeyRing(
|
|
86
|
+
collectApiKeysGeneric(id, apiKeys, apiKey, (pid) => loadProviderKeys(pid, file ? { file } : {})),
|
|
87
|
+
{ cooldownMs },
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
let agent = null; let dispatcher = null;
|
|
91
|
+
{
|
|
92
|
+
const a = createAgent({
|
|
93
|
+
keepAliveTimeout: envInt("MSLXDFF_AGENTROUTER_KEEPALIVE_TIMEOUT", 30_000),
|
|
94
|
+
keepAliveMaxTimeout: envInt("MSLXDFF_AGENTROUTER_KEEPALIVE_MAX_TIMEOUT", 60_000),
|
|
95
|
+
connections: envInt("MSLXDFF_AGENTROUTER_KEEPALIVE_CONNECTIONS", 20),
|
|
96
|
+
});
|
|
97
|
+
agent = a.agent; dispatcher = a.dispatcher;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 身份 UA:白名单只认 opencode / claude-cli,缺了恒 401。单独 env 可覆盖,不联动全局。
|
|
101
|
+
function identityUa() {
|
|
102
|
+
return process.env.MSLXDFF_AGENTROUTER_UA || opencodeUa();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildHeaders(body, key) {
|
|
106
|
+
const isStream = body?.stream !== false;
|
|
107
|
+
const h = {
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
Accept: isStream ? "text/event-stream" : "*/*",
|
|
110
|
+
"User-Agent": identityUa(),
|
|
111
|
+
};
|
|
112
|
+
if (key) h.Authorization = `Bearer ${key}`;
|
|
113
|
+
return h;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function jsonHeaders(key) {
|
|
117
|
+
const h = { Accept: "application/json", "User-Agent": identityUa() };
|
|
118
|
+
if (key) h.Authorization = `Bearer ${key}`;
|
|
119
|
+
return h;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const { runChat } = createChatRunner({
|
|
123
|
+
id, ring, cooldownMs, fetchImpl, dispatcher, buildHeaders,
|
|
124
|
+
getUrl: () => joinUrl(resolvedBase, CHAT_PATH),
|
|
125
|
+
connectTimeoutMs,
|
|
126
|
+
// 非流式走原生(SDK 通道只服务 opencode zen 的形状),responses 另走专用通道
|
|
127
|
+
sdkDispatch: { enabled: false },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const responses = createResponsesChannel({
|
|
131
|
+
id,
|
|
132
|
+
url: joinUrl(resolvedBase, resolveResponsesPath(id, RESPONSES_PATH)),
|
|
133
|
+
connectTimeoutMs,
|
|
134
|
+
cooldownMs,
|
|
135
|
+
// 关 SDK:其缺省会强塞 include:["reasoning.encrypted_content"] / store:false,agentrouter 未验证支持
|
|
136
|
+
sdkEnabled: false,
|
|
137
|
+
buildHeaders,
|
|
138
|
+
fetchImpl,
|
|
139
|
+
dispatcher,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
async function chat(body, opts) {
|
|
143
|
+
const sourceKey = `MSLXDFF_AGENTROUTER_KEY`;
|
|
144
|
+
const clean = stripAgentRouterSensitive(body);
|
|
145
|
+
if (isAgentRouterResponsesModel(clean?.model)) return responses.run(normalizeResponsesBody(clean), ring, sourceKey, opts);
|
|
146
|
+
return runChat(clean, ring, sourceKey);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function chatWithKeys(body, keys, opts) {
|
|
150
|
+
const tmp = createKeyRing(keys, { cooldownMs });
|
|
151
|
+
const clean = stripAgentRouterSensitive(body);
|
|
152
|
+
if (isAgentRouterResponsesModel(clean?.model)) return responses.run(normalizeResponsesBody(clean), tmp, "shared provider keys", opts);
|
|
153
|
+
const scoped = createChatRunner({
|
|
154
|
+
id, ring: tmp, cooldownMs, fetchImpl, dispatcher, buildHeaders,
|
|
155
|
+
getUrl: () => joinUrl(resolvedBase, CHAT_PATH),
|
|
156
|
+
connectTimeoutMs,
|
|
157
|
+
sdkDispatch: { enabled: false },
|
|
158
|
+
});
|
|
159
|
+
return scoped.runChat(clean, tmp, "shared provider keys");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let modelsCache = { at: 0, list: [] };
|
|
163
|
+
const MODELS_TTL_MS = 10 * 60 * 1000;
|
|
164
|
+
|
|
165
|
+
async function fetchModels() {
|
|
166
|
+
const controller = new AbortController();
|
|
167
|
+
const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
|
|
168
|
+
try {
|
|
169
|
+
const key = ring.next() || ring.keys[0];
|
|
170
|
+
const opts = { headers: jsonHeaders(key), signal: controller.signal };
|
|
171
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
172
|
+
const res = await fetchImpl(joinUrl(resolvedBase, MODELS_PATH), opts);
|
|
173
|
+
if (!res.ok) throw new Error(`${id} models: http ${res.status}`);
|
|
174
|
+
const json = await res.json().catch(() => ({}));
|
|
175
|
+
return Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : [];
|
|
176
|
+
} finally {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function listModels() {
|
|
182
|
+
if (modelsCache.list.length && Date.now() - modelsCache.at < MODELS_TTL_MS) return modelsCache.list;
|
|
183
|
+
let raw = [];
|
|
184
|
+
try {
|
|
185
|
+
raw = await fetchModels();
|
|
186
|
+
} catch {
|
|
187
|
+
return modelsCache.list; // 拉取失败给上次缓存(首次为空),不阻断启动
|
|
188
|
+
}
|
|
189
|
+
const list = raw
|
|
190
|
+
.filter((m) => m && typeof m.id === "string" && m.id.trim())
|
|
191
|
+
.map((m) => ({
|
|
192
|
+
id: joinModelId(id, m.id),
|
|
193
|
+
object: "model",
|
|
194
|
+
name: m.name || m.id,
|
|
195
|
+
owned_by: m.owned_by || "agentrouter",
|
|
196
|
+
...(m.created ? { created: m.created } : {}),
|
|
197
|
+
}));
|
|
198
|
+
modelsCache = { at: Date.now(), list };
|
|
199
|
+
return list;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function preheat() {
|
|
203
|
+
try {
|
|
204
|
+
const m = await listModels();
|
|
205
|
+
return { ok: m.length > 0, models: m.length };
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return { ok: false, error: String(err?.message || err).slice(0, 120) };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function close() { if (agent?.close) try { await agent.close(); } catch {} }
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
id,
|
|
215
|
+
chat,
|
|
216
|
+
chatWithKeys,
|
|
217
|
+
listModels,
|
|
218
|
+
preheat,
|
|
219
|
+
close,
|
|
220
|
+
agent,
|
|
221
|
+
keyRing: ring,
|
|
222
|
+
baseUrl: resolvedBase,
|
|
223
|
+
responsesUrl: responses.url,
|
|
224
|
+
};
|
|
225
|
+
}
|
package/src/providers/base.js
CHANGED
|
@@ -1,164 +1,164 @@
|
|
|
1
|
-
import { createKeyRing } from "./keyring.js";
|
|
2
|
-
import { joinModelId } from "./model-id.js";
|
|
3
|
-
import { getUndici as compatGetUndici, compatFetch } from "../compat.js";
|
|
4
|
-
import { createSdkDispatch } from "../upstream-engine/sdk/dispatch.js";
|
|
5
|
-
|
|
6
|
-
const { fetch: UndiciFetch, Agent: UndiciAgent } = compatGetUndici();
|
|
7
|
-
|
|
8
|
-
export function envInt(name, fallback) {
|
|
9
|
-
const v = Number(process.env[name]);
|
|
10
|
-
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function joinUrl(base, path) {
|
|
14
|
-
const b = String(base || "").trim().replace(/\/+$/, "");
|
|
15
|
-
const p = String(path || "").trim();
|
|
16
|
-
if (!p) return b;
|
|
17
|
-
const pp = p.startsWith("/") ? p : `/${p}`;
|
|
18
|
-
return `${b}${pp}`;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function sleep(ms) {
|
|
22
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function getUndici() {
|
|
26
|
-
return { UndiciAgent, UndiciFetch, fetch: UndiciFetch || compatFetch, Agent: UndiciAgent };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function createAgent({ keepAliveTimeout = 30_000, keepAliveMaxTimeout = 60_000, connections = 20 } = {}) {
|
|
30
|
-
if (!UndiciAgent) return { agent: null, dispatcher: null };
|
|
31
|
-
try {
|
|
32
|
-
const agent = new UndiciAgent({ keepAliveTimeout, keepAliveMaxTimeout, connections, pipelining: 1 });
|
|
33
|
-
return { agent, dispatcher: agent };
|
|
34
|
-
} catch {
|
|
35
|
-
return { agent: null, dispatcher: null };
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function collectApiKeysGeneric(id, apiKeys, apiKey, loadKeys) {
|
|
40
|
-
const list = [
|
|
41
|
-
...(Array.isArray(apiKeys) ? apiKeys : [apiKeys].filter(Boolean)),
|
|
42
|
-
apiKey,
|
|
43
|
-
...(apiKeys === undefined && apiKey === undefined ? loadKeys(id) : []),
|
|
44
|
-
].filter((k) => typeof k === "string" && k.trim().length);
|
|
45
|
-
return [...new Set(list.map((k) => k.trim()))];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function createChatRunner({ id, ring, cooldownMs, retry, fetchImpl, dispatcher, buildHeaders, getUrl, connectTimeoutMs = 30_000, sdkDispatch } = {}) {
|
|
49
|
-
// SDK 通道(缺省):仅流式;非流式委派原生,避免把 JSON 客户端 SSE 化。
|
|
50
|
-
const sdk = sdkDispatch === undefined ? createSdkDispatch({ id, providerName: id }) : sdkDispatch;
|
|
51
|
-
|
|
52
|
-
async function attemptOnce(url, body, key, activeRing) {
|
|
53
|
-
if (sdk?.enabled && body?.stream !== false) {
|
|
54
|
-
let r;
|
|
55
|
-
try { r = await sdk.trySdk({ url, body, headers: buildHeaders(body, key), fetchImpl }); }
|
|
56
|
-
catch (err) { return err; } // 交 runChat 的 network 重试/冷却
|
|
57
|
-
if (r) return r.status === 401 && !activeRing.size ? { __needKey: true } : r;
|
|
58
|
-
}
|
|
59
|
-
const controller = new AbortController();
|
|
60
|
-
const timer = setTimeout(() => controller.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
61
|
-
try {
|
|
62
|
-
const opts = { method: "POST", headers: buildHeaders(body, key), body: JSON.stringify(body), signal: controller.signal };
|
|
63
|
-
if (dispatcher) opts.dispatcher = dispatcher;
|
|
64
|
-
const res = await fetchImpl(url, opts);
|
|
65
|
-
if (res.status === 401 && !activeRing.size) return { __needKey: true };
|
|
66
|
-
return res;
|
|
67
|
-
} catch (err) {
|
|
68
|
-
return err;
|
|
69
|
-
} finally {
|
|
70
|
-
clearTimeout(timer);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async function runChat(body, activeRing, sourceKey) {
|
|
75
|
-
const url = getUrl();
|
|
76
|
-
const t0 = performance.now();
|
|
77
|
-
const attempts = [];
|
|
78
|
-
let waitMs = 0;
|
|
79
|
-
const key = activeRing.next();
|
|
80
|
-
if (!key && activeRing.size > 0) {
|
|
81
|
-
const err = new Error(`${id}: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`);
|
|
82
|
-
err._t = { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0), cooldownMs };
|
|
83
|
-
throw err;
|
|
84
|
-
}
|
|
85
|
-
for (let attempt = 0; ; attempt++) {
|
|
86
|
-
const t = performance.now();
|
|
87
|
-
const result = await attemptOnce(url, body, key, activeRing);
|
|
88
|
-
const type = result instanceof Error ? "network" : `http${result?.status}`;
|
|
89
|
-
attempts.push({ attempt, type, ms: Math.round(performance.now() - t) });
|
|
90
|
-
if (result?.__needKey) {
|
|
91
|
-
const err = new Error(`${id}: missing ${sourceKey} (chat requires a real key)`);
|
|
92
|
-
err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
93
|
-
throw err;
|
|
94
|
-
}
|
|
95
|
-
if (result instanceof Error) {
|
|
96
|
-
const entry = retry?.network;
|
|
97
|
-
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
98
|
-
activeRing.onError(key);
|
|
99
|
-
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
100
|
-
throw result;
|
|
101
|
-
}
|
|
102
|
-
const entry = retry?.[result.status];
|
|
103
|
-
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
104
|
-
if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
|
|
105
|
-
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
106
|
-
return result;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return { attemptOnce, runChat };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function createListModelsRunner({ id, ring, dispatcher, fetchImpl, getUrl, mapModel }) {
|
|
114
|
-
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
115
|
-
let cache = null;
|
|
116
|
-
let fetchedAt = 0;
|
|
117
|
-
async function listModels() {
|
|
118
|
-
const now = Date.now();
|
|
119
|
-
if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
|
|
120
|
-
const url = getUrl();
|
|
121
|
-
const controller = new AbortController();
|
|
122
|
-
const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
|
|
123
|
-
try {
|
|
124
|
-
const headers = { Accept: "application/json" };
|
|
125
|
-
const key = ring.next();
|
|
126
|
-
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
127
|
-
const opts = { headers, signal: controller.signal };
|
|
128
|
-
if (dispatcher) opts.dispatcher = dispatcher;
|
|
129
|
-
const res = await fetchImpl(url, opts);
|
|
130
|
-
if (!res.ok) return [];
|
|
131
|
-
const json = await res.json().catch(() => ({}));
|
|
132
|
-
const raw = Array.isArray(json.data) ? json.data : Array.isArray(json) ? json : [];
|
|
133
|
-
const mapped = mapModel ? mapModel(raw) : raw;
|
|
134
|
-
cache = mapped.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
|
|
135
|
-
fetchedAt = now;
|
|
136
|
-
return cache;
|
|
137
|
-
} catch {
|
|
138
|
-
return [];
|
|
139
|
-
} finally {
|
|
140
|
-
clearTimeout(timer);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return { listModels, _cache: () => cache };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export function createPreheatRunner({ dispatcher, fetchImpl, getUrl, id, ring, loadKeys }) {
|
|
147
|
-
async function preheat() {
|
|
148
|
-
const url = getUrl();
|
|
149
|
-
const t0 = performance.now();
|
|
150
|
-
try {
|
|
151
|
-
const headers = { Accept: "application/json" };
|
|
152
|
-
const key = (loadKeys ? loadKeys(id)[0] : ring.next()) || ring.next();
|
|
153
|
-
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
154
|
-
const opts = { headers };
|
|
155
|
-
if (dispatcher) opts.dispatcher = dispatcher;
|
|
156
|
-
const res = await fetchImpl(url, opts);
|
|
157
|
-
try { if (res.body) await res.text().catch(() => {}); } catch {}
|
|
158
|
-
return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
|
|
159
|
-
} catch (err) {
|
|
160
|
-
return { ok: false, error: String(err?.message || err), ms: Math.round(performance.now() - t0) };
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return { preheat };
|
|
164
|
-
}
|
|
1
|
+
import { createKeyRing } from "./keyring.js";
|
|
2
|
+
import { joinModelId } from "./model-id.js";
|
|
3
|
+
import { getUndici as compatGetUndici, compatFetch } from "../compat.js";
|
|
4
|
+
import { createSdkDispatch } from "../upstream-engine/sdk/dispatch.js";
|
|
5
|
+
|
|
6
|
+
const { fetch: UndiciFetch, Agent: UndiciAgent } = compatGetUndici();
|
|
7
|
+
|
|
8
|
+
export function envInt(name, fallback) {
|
|
9
|
+
const v = Number(process.env[name]);
|
|
10
|
+
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function joinUrl(base, path) {
|
|
14
|
+
const b = String(base || "").trim().replace(/\/+$/, "");
|
|
15
|
+
const p = String(path || "").trim();
|
|
16
|
+
if (!p) return b;
|
|
17
|
+
const pp = p.startsWith("/") ? p : `/${p}`;
|
|
18
|
+
return `${b}${pp}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function sleep(ms) {
|
|
22
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getUndici() {
|
|
26
|
+
return { UndiciAgent, UndiciFetch, fetch: UndiciFetch || compatFetch, Agent: UndiciAgent };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createAgent({ keepAliveTimeout = 30_000, keepAliveMaxTimeout = 60_000, connections = 20 } = {}) {
|
|
30
|
+
if (!UndiciAgent) return { agent: null, dispatcher: null };
|
|
31
|
+
try {
|
|
32
|
+
const agent = new UndiciAgent({ keepAliveTimeout, keepAliveMaxTimeout, connections, pipelining: 1 });
|
|
33
|
+
return { agent, dispatcher: agent };
|
|
34
|
+
} catch {
|
|
35
|
+
return { agent: null, dispatcher: null };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function collectApiKeysGeneric(id, apiKeys, apiKey, loadKeys) {
|
|
40
|
+
const list = [
|
|
41
|
+
...(Array.isArray(apiKeys) ? apiKeys : [apiKeys].filter(Boolean)),
|
|
42
|
+
apiKey,
|
|
43
|
+
...(apiKeys === undefined && apiKey === undefined ? loadKeys(id) : []),
|
|
44
|
+
].filter((k) => typeof k === "string" && k.trim().length);
|
|
45
|
+
return [...new Set(list.map((k) => k.trim()))];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createChatRunner({ id, ring, cooldownMs, retry, fetchImpl, dispatcher, buildHeaders, getUrl, connectTimeoutMs = 30_000, sdkDispatch } = {}) {
|
|
49
|
+
// SDK 通道(缺省):仅流式;非流式委派原生,避免把 JSON 客户端 SSE 化。
|
|
50
|
+
const sdk = sdkDispatch === undefined ? createSdkDispatch({ id, providerName: id }) : sdkDispatch;
|
|
51
|
+
|
|
52
|
+
async function attemptOnce(url, body, key, activeRing) {
|
|
53
|
+
if (sdk?.enabled && body?.stream !== false) {
|
|
54
|
+
let r;
|
|
55
|
+
try { r = await sdk.trySdk({ url, body, headers: buildHeaders(body, key), fetchImpl }); }
|
|
56
|
+
catch (err) { return err; } // 交 runChat 的 network 重试/冷却
|
|
57
|
+
if (r) return r.status === 401 && !activeRing.size ? { __needKey: true } : r;
|
|
58
|
+
}
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
const timer = setTimeout(() => controller.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
61
|
+
try {
|
|
62
|
+
const opts = { method: "POST", headers: buildHeaders(body, key), body: JSON.stringify(body), signal: controller.signal };
|
|
63
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
64
|
+
const res = await fetchImpl(url, opts);
|
|
65
|
+
if (res.status === 401 && !activeRing.size) return { __needKey: true };
|
|
66
|
+
return res;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return err;
|
|
69
|
+
} finally {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function runChat(body, activeRing, sourceKey) {
|
|
75
|
+
const url = getUrl();
|
|
76
|
+
const t0 = performance.now();
|
|
77
|
+
const attempts = [];
|
|
78
|
+
let waitMs = 0;
|
|
79
|
+
const key = activeRing.next();
|
|
80
|
+
if (!key && activeRing.size > 0) {
|
|
81
|
+
const err = new Error(`${id}: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`);
|
|
82
|
+
err._t = { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0), cooldownMs };
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
for (let attempt = 0; ; attempt++) {
|
|
86
|
+
const t = performance.now();
|
|
87
|
+
const result = await attemptOnce(url, body, key, activeRing);
|
|
88
|
+
const type = result instanceof Error ? "network" : `http${result?.status}`;
|
|
89
|
+
attempts.push({ attempt, type, ms: Math.round(performance.now() - t) });
|
|
90
|
+
if (result?.__needKey) {
|
|
91
|
+
const err = new Error(`${id}: missing ${sourceKey} (chat requires a real key)`);
|
|
92
|
+
err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
if (result instanceof Error) {
|
|
96
|
+
const entry = retry?.network;
|
|
97
|
+
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
98
|
+
activeRing.onError(key);
|
|
99
|
+
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
100
|
+
throw result;
|
|
101
|
+
}
|
|
102
|
+
const entry = retry?.[result.status];
|
|
103
|
+
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
104
|
+
if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
|
|
105
|
+
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { attemptOnce, runChat };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createListModelsRunner({ id, ring, dispatcher, fetchImpl, getUrl, mapModel }) {
|
|
114
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
115
|
+
let cache = null;
|
|
116
|
+
let fetchedAt = 0;
|
|
117
|
+
async function listModels() {
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
|
|
120
|
+
const url = getUrl();
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
|
|
123
|
+
try {
|
|
124
|
+
const headers = { Accept: "application/json" };
|
|
125
|
+
const key = ring.next();
|
|
126
|
+
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
127
|
+
const opts = { headers, signal: controller.signal };
|
|
128
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
129
|
+
const res = await fetchImpl(url, opts);
|
|
130
|
+
if (!res.ok) return [];
|
|
131
|
+
const json = await res.json().catch(() => ({}));
|
|
132
|
+
const raw = Array.isArray(json.data) ? json.data : Array.isArray(json) ? json : [];
|
|
133
|
+
const mapped = mapModel ? mapModel(raw) : raw;
|
|
134
|
+
cache = mapped.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
|
|
135
|
+
fetchedAt = now;
|
|
136
|
+
return cache;
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
} finally {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { listModels, _cache: () => cache };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function createPreheatRunner({ dispatcher, fetchImpl, getUrl, id, ring, loadKeys }) {
|
|
147
|
+
async function preheat() {
|
|
148
|
+
const url = getUrl();
|
|
149
|
+
const t0 = performance.now();
|
|
150
|
+
try {
|
|
151
|
+
const headers = { Accept: "application/json" };
|
|
152
|
+
const key = (loadKeys ? loadKeys(id)[0] : ring.next()) || ring.next();
|
|
153
|
+
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
154
|
+
const opts = { headers };
|
|
155
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
156
|
+
const res = await fetchImpl(url, opts);
|
|
157
|
+
try { if (res.body) await res.text().catch(() => {}); } catch {}
|
|
158
|
+
return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return { ok: false, error: String(err?.message || err), ms: Math.round(performance.now() - t0) };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { preheat };
|
|
164
|
+
}
|