mslxdff 0.1.232 → 0.1.234
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 -3
- package/docs/cli_help_mini.md +148 -0
- package/package.json +1 -1
- package/src/auto.js +264 -254
- package/src/bench/cline-bench.js +42 -42
- package/src/bench/probe.js +70 -70
- package/src/bench/report.js +162 -162
- package/src/bench/runner.js +77 -77
- package/src/bench/via-probe.js +124 -124
- package/src/bench/via-routes.js +87 -87
- package/src/bench/workbuddy-bench.js +54 -54
- package/src/chat/engine.js +160 -160
- package/src/chat/gateway.js +163 -163
- package/src/chat/orchestrator.js +234 -234
- package/src/chat/prompt.js +70 -70
- package/src/chat/repl.js +88 -88
- package/src/chat/terminal.js +135 -135
- package/src/chat/tools.js +306 -306
- package/src/chat-pipeline/index.js +155 -145
- package/src/chat-pipeline/policy.js +84 -84
- package/src/cli/commands/group.js +266 -266
- package/src/cli/commands/model/picks.js +50 -50
- package/src/cli/commands/provider/bench-via.js +247 -247
- package/src/cli/commands/provider/bench.js +141 -141
- package/src/cli/commands/provider/index.js +139 -139
- package/src/cli/commands/provider/models.js +149 -149
- package/src/cli/commands/provider/qwenwork-login.js +119 -119
- package/src/cli/commands/provider/raccoon-quota.js +156 -156
- package/src/cli/commands/sync.js +310 -310
- package/src/cli/status.js +279 -279
- package/src/daemon.js +246 -246
- package/src/model-capabilities/enrich.js +86 -86
- package/src/model-capabilities/index.js +183 -183
- package/src/model-capabilities/parse.js +70 -70
- package/src/model-trace.js +10 -2
- package/src/models.js +293 -226
- package/src/peers.js +345 -345
- package/src/providers/AGENTS.md +49 -0
- package/src/providers/agentrouter.js +238 -238
- package/src/providers/base.js +164 -164
- package/src/providers/cline/auth.js +228 -228
- package/src/providers/cline/chat.js +307 -307
- package/src/providers/cline.js +2 -2
- package/src/providers/codearts/chat.js +207 -207
- package/src/providers/dispatcher.js +147 -147
- package/src/providers/globalqwenwork/cosy.js +123 -123
- package/src/providers/globalqwenwork/crypto.js +218 -218
- package/src/providers/globalqwenwork/http.js +20 -20
- package/src/providers/globalqwenwork/rsa.js +54 -54
- package/src/providers/globalqwenwork/sse.js +268 -268
- package/src/providers/globalqwenwork/stream.js +130 -130
- package/src/providers/globalqwenwork/upstream.js +122 -122
- package/src/providers/keyring.js +60 -60
- package/src/providers/qoder/account-store.js +18 -0
- package/src/providers/qoder/chat.js +292 -203
- package/src/providers/qoder/checkin.js +25 -12
- package/src/providers/qoder/index.js +311 -249
- package/src/providers/qoder/models.js +64 -2
- package/src/providers/qoder/oauth.js +42 -1
- package/src/providers/qoder/payload.js +116 -80
- package/src/providers/qoder/request.js +7 -2
- package/src/providers/qoder/session.js +26 -10
- package/src/providers/qoder/sse.js +233 -145
- package/src/providers/qoder/sticky.js +5 -2
- package/src/providers/qoder/wire-messages.js +242 -0
- package/src/providers/qwenwork/account-store.js +133 -133
- package/src/providers/qwenwork/constants.js +67 -67
- package/src/providers/qwenwork/cosy.js +120 -120
- package/src/providers/qwenwork/crypto.js +218 -218
- package/src/providers/qwenwork/http.js +20 -20
- package/src/providers/qwenwork/index.js +337 -337
- package/src/providers/qwenwork/payload.js +144 -144
- package/src/providers/qwenwork/rsa.js +54 -54
- package/src/providers/qwenwork/sse.js +268 -268
- package/src/providers/qwenwork/stream.js +130 -130
- package/src/providers/qwenwork/upstream.js +120 -120
- package/src/providers/qwenwork.js +1 -1
- package/src/providers/raccoon/chat.js +1 -1
- package/src/providers/raccoon/const.js +219 -202
- package/src/providers/raccoon/credits.js +285 -285
- package/src/providers/raccoon/index.js +51 -6
- package/src/providers/raccoon/models.js +68 -17
- package/src/providers/raccoon/sticky.js +46 -0
- package/src/providers/registry.js +89 -89
- package/src/providers/workbuddy/chat.js +298 -298
- package/src/providers/workbuddy/reshape.js +152 -152
- package/src/providers/workbuddy.js +2 -2
- package/src/reasoning.js +32 -32
- package/src/routes/AGENTS.md +37 -0
- package/src/routes/chat/broadband-handler.js +80 -80
- package/src/routes/chat/gateway.js +46 -46
- package/src/routes/chat/relay-pipeline.js +282 -282
- package/src/routes/chat/via-route-handler.js +156 -156
- package/src/routes/hedge.js +260 -260
- package/src/routes/models-route.js +167 -167
- package/src/routes/peers.js +373 -373
- package/src/routes/stream.js +364 -347
- package/src/runtime/bootstrap.js +75 -75
- package/src/runtime/keep-warm.js +135 -135
- package/src/runtime/provider-gate.js +75 -75
- package/src/runtime/providers-setup.js +187 -187
- package/src/runtime/raccoon-checkin.js +175 -175
- package/src/server.js +85 -85
- package/src/state/schemas/allowlist.js +92 -92
- package/src/sync-opencode.js +280 -280
- package/src/transport/index.js +244 -244
- package/src/transport/pool.js +56 -56
- package/src/transport/retry.js +24 -24
- package/src/transport/sse.js +93 -93
- package/src/upstream-probe/display.js +52 -52
- package/src/upstream-probe/probe.js +49 -49
- package/src/upstream-probe/rotate.js +110 -110
- package/src/upstream-probe/start.js +45 -45
- package/src/upstream.js +355 -355
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
import { compatFetch } from "../../compat.js";
|
|
4
4
|
import { envInt } from "../base.js";
|
|
5
5
|
import { createKeyRing } from "../keyring.js";
|
|
6
|
+
import { createStickyPicker } from "./sticky.js";
|
|
6
7
|
import { loadProviderKeys } from "../../state.js";
|
|
7
8
|
import { forwardRaccoonChat } from "./chat.js";
|
|
8
|
-
import { buildRaccoonModelList, listRaccoonModels } from "./models.js";
|
|
9
|
+
import { buildRaccoonModelList, listRaccoonModels, mergeRaccoonCatalogIntoAllowlist } from "./models.js";
|
|
9
10
|
import { applyRaccoonRefresh, listRaccoonAccountDocs, raccoonCredentialFromDoc, readRaccoonAccountDoc, syncRaccoonKeys, writeRaccoonAccountFile } from "./account-store.js";
|
|
10
11
|
import { refreshRaccoonCredential } from "./login.js";
|
|
11
12
|
import { isRaccoonExpiringSoon, raccoonTokenFingerprint } from "./auth.js";
|
|
@@ -14,6 +15,14 @@ import { RACCOON_DEFAULT_COOLDOWN_MS, RACCOON_DEFAULT_QUOTA_COOLDOWN_MS } from "
|
|
|
14
15
|
/** kind → 冷却档位。auth 走短冷(重登指引已由响应体给出);quota 走长冷(按天回血)。 */
|
|
15
16
|
const COOLDOWN_BY_KIND = { auth: "short", rate_limit: "short", server: "short", quota: "long" };
|
|
16
17
|
|
|
18
|
+
/** 会话粘号 TTL:允 0(=关,退回每次调用轮转),故不能用只收正数的 envInt。 */
|
|
19
|
+
function stickyMsFromEnv(env = process.env) {
|
|
20
|
+
const raw = env?.MSLXDFF_RACCOON_STICKY_MS;
|
|
21
|
+
if (raw === undefined || String(raw).trim() === "") return 1_800_000;
|
|
22
|
+
const n = Number(raw);
|
|
23
|
+
return Number.isFinite(n) && n >= 0 ? n : 1_800_000;
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
function notLoggedInResponse() {
|
|
18
27
|
return new Response(
|
|
19
28
|
JSON.stringify({ error: { message: "raccoon: 无可用账号 —— 先运行 mslxdff -provider raccoon login", type: "auth_error" } }),
|
|
@@ -63,6 +72,17 @@ export function createRaccoonProvider({
|
|
|
63
72
|
}
|
|
64
73
|
const ring = createKeyRing(keys, { cooldownMs });
|
|
65
74
|
|
|
75
|
+
// 会话粘号:同一段对话(opts.sessionId)固定用同一个号,换会话才轮转到下一个号。
|
|
76
|
+
// 为什么不是每次调用轮转:积分按号均摊靠「会话之间」轮换就够了,而同一会话逐轮换号会丢掉上游
|
|
77
|
+
// 的 prompt 缓存亲和,也让排障时分不清「这个号到底好不好」。无会话标识时退回 reqId(同请求重试不换号)
|
|
78
|
+
// 与纯轮转,行为与旧版一致。TTL 内每次命中续期;`MSLXDFF_RACCOON_STICKY_MS=0` 关掉退回逐调用轮转。
|
|
79
|
+
const pickKey = createStickyPicker({
|
|
80
|
+
pick: () => ring.next(),
|
|
81
|
+
// 被 replace() 换掉的旧 token 已不在 ring 里 → 同样视为不可用,会话会前进到新号而不是粘住废 token
|
|
82
|
+
isCooling: (k) => !ring.keys.includes(k) || ring.isCooling(k),
|
|
83
|
+
ttlMs: stickyMsFromEnv(),
|
|
84
|
+
});
|
|
85
|
+
|
|
66
86
|
// access_token → 账号文档(office_identity / device_id / refresh_token 跟号走,取自 auths/raccoon-<uid>.json)
|
|
67
87
|
// 缓存**可失效**:重登与续期都会换盘上的 token,只建一次会让本进程永远停在旧号(见 healKeyFromDocs)
|
|
68
88
|
let docCache = null;
|
|
@@ -103,7 +123,7 @@ export function createRaccoonProvider({
|
|
|
103
123
|
const inflightRefresh = new Map();
|
|
104
124
|
|
|
105
125
|
/** 临期即续期;续期成功把新 token 回写账号文档并替换 keyring 里的旧键。失败不阻断(让上游如实报)。 */
|
|
106
|
-
async function ensureFresh(token) {
|
|
126
|
+
async function ensureFresh(token, scope) {
|
|
107
127
|
const credential = credentialFor(token);
|
|
108
128
|
if (!credential.refresh_token || !isRaccoonExpiringSoon(credential)) return credential;
|
|
109
129
|
const pending = inflightRefresh.get(token);
|
|
@@ -114,6 +134,8 @@ export function createRaccoonProvider({
|
|
|
114
134
|
if (next.access_token !== token) {
|
|
115
135
|
ring.replace(token, next.access_token);
|
|
116
136
|
docCache?.delete(token);
|
|
137
|
+
// 粘号表跟着换 token:否则该会话下一轮会认为旧 token 不可用而漂到别的号(同一账号被拆成两半)
|
|
138
|
+
pickKey.remember(scope, next.access_token);
|
|
117
139
|
}
|
|
118
140
|
// ⚠ 同 token 也必须刷新到期时间:否则 docCache 永远停在旧 expires_at,每个请求都会重复调一次 refresh
|
|
119
141
|
docCache?.set(next.access_token, { ...credential, accessToken: next.access_token, refreshToken: next.refresh_token, expiresAt: next.expires_at || "" });
|
|
@@ -153,12 +175,18 @@ export function createRaccoonProvider({
|
|
|
153
175
|
async function chat(body, opts = {}) {
|
|
154
176
|
const total = ring.keys.length;
|
|
155
177
|
if (!total) return notLoggedInResponse();
|
|
178
|
+
// 会话优先:一段对话固定一个号(积分按会话轮换均摊);没有会话标识时才退到「同请求粘号」
|
|
179
|
+
const scope = opts?.sessionId ? `sess:${opts.sessionId}` : opts?.reqId ? `req:${opts.reqId}` : null;
|
|
156
180
|
let lastRes = null;
|
|
157
181
|
let healed = false; // 一次请求内只自愈一次:防重试风暴,也防「文档里那份同样不可用」时打旋
|
|
158
182
|
for (let hop = 0; hop <= total + 1; hop++) {
|
|
159
|
-
|
|
183
|
+
let pick = "rr";
|
|
184
|
+
const key = pickKey(scope, (d) => { pick = d; });
|
|
160
185
|
if (!key) break;
|
|
161
|
-
|
|
186
|
+
if (pick !== "sticky" && pick !== "rr") {
|
|
187
|
+
console.error(`[raccoon] 会话选号 pick=${pick} account=${raccoonTokenFingerprint(key)} ${scope || ""}`);
|
|
188
|
+
}
|
|
189
|
+
const credential = await ensureFresh(key, scope);
|
|
162
190
|
const res = await forwardRaccoonChat({ body, credential, fetchImpl, timeoutMs, effort: opts?.effort ?? effort });
|
|
163
191
|
const kind = res.headers.get("x-mslxdff-raccoon-kind");
|
|
164
192
|
if (!kind) return res; // 成功或未分类:直接交出去
|
|
@@ -166,7 +194,11 @@ export function createRaccoonProvider({
|
|
|
166
194
|
if (kind === "auth" && !healed) {
|
|
167
195
|
healed = true;
|
|
168
196
|
// 自愈成功就换号重试;不给失效号记冷却(ring.replace 也不把冷却带给新号)
|
|
169
|
-
|
|
197
|
+
const healedKey = await healKeyFromDocs(key);
|
|
198
|
+
if (healedKey) {
|
|
199
|
+
pickKey.remember(scope, healedKey); // 本会话钉到救回来的号,别在下一轮又漂走
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
170
202
|
}
|
|
171
203
|
const mode = COOLDOWN_BY_KIND[kind] || "none";
|
|
172
204
|
if (mode === "long") ring.onError(key, quotaCooldownMs);
|
|
@@ -178,12 +210,25 @@ export function createRaccoonProvider({
|
|
|
178
210
|
return allCoolingResponse();
|
|
179
211
|
}
|
|
180
212
|
|
|
213
|
+
let catalogMergeTried = false;
|
|
181
214
|
async function listModels() {
|
|
182
215
|
let token = ring.keys[0];
|
|
183
216
|
// 内存号已不在文档里(重登换号)时先对齐,否则目录撞 401 后静默回落内置表,表现为「列表不是最新的」
|
|
184
217
|
if (token && !accountFor(token)) token = (await healKeyFromDocs(token)) || token;
|
|
185
218
|
if (!token) return [];
|
|
186
|
-
const { models } = await listRaccoonModels({ credential: credentialFor(token), fetchImpl });
|
|
219
|
+
const { models, source } = await listRaccoonModels({ credential: credentialFor(token), fetchImpl });
|
|
220
|
+
// 上游目录是这家唯一真相(模型 id 全由上游定义、按账号下发):每进程首次成功拉到就只增不减
|
|
221
|
+
// 并入 allowlist,否则 dispatcher 会拿登录时播种的静态白名单把新上架模型(如 …-1017)挡掉。
|
|
222
|
+
// 只在 source=upstream 时做:回落兜底表时并入的仍是静态 id,没有意义还会白写盘。
|
|
223
|
+
if (source === "upstream" && !catalogMergeTried) {
|
|
224
|
+
catalogMergeTried = true;
|
|
225
|
+
try {
|
|
226
|
+
const { added } = mergeRaccoonCatalogIntoAllowlist(models, file ? { file } : {});
|
|
227
|
+
if (added.length) console.error(`[raccoon] 上游目录新模型并入白名单: ${added.join(", ")}`);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
console.error(`[raccoon] 目录并入白名单失败(列表照常返回): ${String(e?.message || e).slice(0, 160)}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
187
232
|
return buildRaccoonModelList({ id, models });
|
|
188
233
|
}
|
|
189
234
|
|
|
@@ -41,38 +41,54 @@ export function raccoonFallbackModels() {
|
|
|
41
41
|
return RACCOON_FALLBACK_MODELS.map((m) => ({ ...m }));
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* 单条目录条目:字段名容错(缺字段即省略,不猜、不填假值)。
|
|
46
|
+
*
|
|
47
|
+
* 上游真实字段(2026-10-22 `GET /api/web/llm/v2/model_catalog` 实测):
|
|
48
|
+
* `name`=模型 id(请求体 `model` 用的就是它)、`description`=展示名(如 `DeepSeek-V4.1-Flash`)、
|
|
49
|
+
* `visible: false`=上游对自己隐藏的内部模型、`params.context_window` / `params.max_tokens`、
|
|
50
|
+
* `billing_multiplier` / `billing_effective_multiplier`。
|
|
51
|
+
* ⚠ **id 绝不能取 `description`**:旧`pickString(["modelName","name","displayName","description"])`
|
|
52
|
+
* 把 id 与展示名混在一串里,一旦上游条目的 `name` 缺失就会拿展示名当 id 去发请求(必 404/400)。
|
|
53
|
+
*/
|
|
45
54
|
function parseRaccoonModelEntry(raw) {
|
|
46
55
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
56
|
+
// 上游用 visible:false 表示「我这边不展示」,这类内部模型不进目录
|
|
57
|
+
if (raw.visible === false) return null;
|
|
47
58
|
const id = pickString(raw, ["id", "modelId", "model_id", "modelName", "name"]);
|
|
48
59
|
if (!id) return null;
|
|
49
60
|
const model = { id };
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
// 展示名单独取,且排除与 id 同值的情况(否则列表会出现「xxx · xxx」)
|
|
62
|
+
const name = pickString(raw, ["description", "displayName", "modelName", "name"]);
|
|
63
|
+
if (name && name !== id) model.name = name;
|
|
64
|
+
const params = raw.params && typeof raw.params === "object" ? raw.params : {};
|
|
65
|
+
const ctx = pickNumber(raw, ["contextWindow", "context_window", "contextLength", "context_length"]) ?? pickNumber(params, ["context_window", "contextWindow"]);
|
|
53
66
|
if (ctx !== undefined && ctx > 0) model.contextWindow = ctx;
|
|
54
|
-
const maxTokens = pickNumber(raw, ["maxTokens", "max_tokens", "maxCompletionTokens"]);
|
|
67
|
+
const maxTokens = pickNumber(raw, ["maxTokens", "max_tokens", "maxCompletionTokens"]) ?? pickNumber(params, ["max_tokens", "maxTokens"]);
|
|
55
68
|
if (maxTokens !== undefined && maxTokens > 0) model.maxTokens = maxTokens;
|
|
56
69
|
const img = pickBool(raw, ["supportsImage", "supports_image", "supportsVision"]);
|
|
57
70
|
if (img !== undefined) model.supportsImage = img;
|
|
58
|
-
const eff = pickNumber(raw, ["effectiveMultiplier", "effective_multiplier", "multiplier", "costMultiplier"]);
|
|
71
|
+
const eff = pickNumber(raw, ["effectiveMultiplier", "effective_multiplier", "billing_effective_multiplier", "multiplier", "costMultiplier"]);
|
|
59
72
|
if (eff !== undefined && eff >= 0) model.multiplier = eff;
|
|
60
|
-
const base = pickNumber(raw, ["baseMultiplier", "base_multiplier"]);
|
|
73
|
+
const base = pickNumber(raw, ["baseMultiplier", "base_multiplier", "billing_multiplier"]);
|
|
61
74
|
if (base !== undefined && base > 0) model.baseMultiplier = base;
|
|
62
75
|
return model;
|
|
63
76
|
}
|
|
64
77
|
|
|
65
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* 目录信封 → 条目数组。
|
|
80
|
+
*
|
|
81
|
+
* ⚠ **主形状是 `data.categories[]`,只有 `type: "chat"` 那组才是可路由模型**(2026-10-22 上游实测)。
|
|
82
|
+
* 旧实现只认 `data` 为数组 / `data.models` / `data.list` / `data.items` 四种,而真实报文是
|
|
83
|
+
* `{data:{categories:[...]}}` —— 结果每次都解析出 0 条、静默回落静态兜底表,症状是
|
|
84
|
+
* 「上游新上架的模型(如 `sn-deepseek-v4-1-flash-1017`)在网关里永远看不见」。
|
|
85
|
+
* 另外两条判据同样来自实测:`name` 才是模型 id(`description` 是展示名)、`visible: false`
|
|
86
|
+
* 是上游对自己隐藏的内部模型(`raccoon-*`),不该进目录。
|
|
87
|
+
*
|
|
88
|
+
* 四种旧形状继续兼容(上游改版/其他信封时兜底),按顺序取第一个非空的。
|
|
89
|
+
*/
|
|
66
90
|
export function parseRaccoonCatalog(data) {
|
|
67
|
-
const list =
|
|
68
|
-
? data
|
|
69
|
-
: Array.isArray(data?.models)
|
|
70
|
-
? data.models
|
|
71
|
-
: Array.isArray(data?.list)
|
|
72
|
-
? data.list
|
|
73
|
-
: Array.isArray(data?.items)
|
|
74
|
-
? data.items
|
|
75
|
-
: [];
|
|
91
|
+
const list = pickCatalogList(data);
|
|
76
92
|
const out = [];
|
|
77
93
|
const seen = new Set();
|
|
78
94
|
for (const raw of list) {
|
|
@@ -84,6 +100,19 @@ export function parseRaccoonCatalog(data) {
|
|
|
84
100
|
return out;
|
|
85
101
|
}
|
|
86
102
|
|
|
103
|
+
function pickCatalogList(data) {
|
|
104
|
+
const arr = (v) => (Array.isArray(v) ? v : null);
|
|
105
|
+
// 真实形状:{ data: { categories: [{ type: "chat", models: [...] }] } }
|
|
106
|
+
const root = data && typeof data === "object" && !Array.isArray(data) ? data : null;
|
|
107
|
+
const categories = arr(root?.categories) ?? arr(root?.data?.categories);
|
|
108
|
+
if (categories) {
|
|
109
|
+
const chat = categories.find((c) => c && typeof c === "object" && c.type === "chat");
|
|
110
|
+
const chatModels = arr(chat?.models);
|
|
111
|
+
if (chatModels) return chatModels;
|
|
112
|
+
}
|
|
113
|
+
return arr(data) ?? arr(root?.models) ?? arr(root?.list) ?? arr(root?.items) ?? [];
|
|
114
|
+
}
|
|
115
|
+
|
|
87
116
|
/** 倍率文案:0 → 免费;促销 → `x基→x实`;其余 → `x实`(含 x1)。 */
|
|
88
117
|
export function raccoonMultiplierLabel(model) {
|
|
89
118
|
const eff = typeof model?.multiplier === "number" ? model.multiplier : undefined;
|
|
@@ -143,6 +172,28 @@ export function seedRaccoonAllowlist({ ids, file } = {}) {
|
|
|
143
172
|
return { allowed: [...cur, ...added], added };
|
|
144
173
|
}
|
|
145
174
|
|
|
175
|
+
/**
|
|
176
|
+
* 把**上游目录**的模型 id 只增不减并入 allowlist。
|
|
177
|
+
*
|
|
178
|
+
* 为什么需要:白名单此前只在登录时按**静态兜底表**播种一次,上游后来上架的模型
|
|
179
|
+
* (如 2026-10 的 `sn-deepseek-v4-1-flash-1017`)就永远进不来 —— dispatcher 拿白名单
|
|
180
|
+
* 过滤 `listModels()`,模型看得见列表却选不到、调用直接被挡。上游目录才是这家唯一真相
|
|
181
|
+
* (积分制,模型 id 全由上游定义),故每次成功拉到目录就同步一次。
|
|
182
|
+
*
|
|
183
|
+
* **为什么只增不减**:目录是**按账号**下发的(套餐不同可见模型数不同),某次拉到一份
|
|
184
|
+
* 较短的目录不代表模型下架;双向收敛会在换号/降级套餐时把白名单越缩越小、把能用的模型删掉。
|
|
185
|
+
* 真要下架的模型留着只会 404,代价远小于误删。对齐 cline 的 `planMerge` 保守口径(ADR-0067)。
|
|
186
|
+
*/
|
|
187
|
+
export function mergeRaccoonCatalogIntoAllowlist(models, { file } = {}) {
|
|
188
|
+
const ids = (Array.isArray(models) ? models : []).map((m) => String(m?.id || "").trim()).filter(Boolean);
|
|
189
|
+
if (!ids.length) return { added: [] };
|
|
190
|
+
const opts = file ? { file } : {};
|
|
191
|
+
const cur = loadProviderAllowedModels("raccoon", opts);
|
|
192
|
+
const added = ids.filter((m) => !cur.includes(m));
|
|
193
|
+
if (added.length) saveProviderAllowedModels("raccoon", [...cur, ...added], opts);
|
|
194
|
+
return { added };
|
|
195
|
+
}
|
|
196
|
+
|
|
146
197
|
/** 对外目录条目(`/v1/models` 与 CLI models 共用形状)。 */
|
|
147
198
|
export function buildRaccoonModelList({ id = "raccoon", models } = {}) {
|
|
148
199
|
const list = models || raccoonFallbackModels();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// raccoon 会话粘号:一段对话(sessionId)固定用一个号,换会话才轮转下一个号 —— 积分按会话均摊。
|
|
2
|
+
// 语义与 qoder 的同请求粘号一致(只有该号进冷却 401/403/429/5xx 才换),差别在多两个能力:
|
|
3
|
+
// · scope 由会话(opts.sessionId)优先,没有会话标识时退到 reqId,两者都无则退回纯 round-robin;
|
|
4
|
+
// · remember():续期换 token / 换号自愈后把本会话重新钉到那个号,避免同一账号被拆成两半。
|
|
5
|
+
// 本文件是独立实现(qoder 只用同请求粘号且无需 remember),不改 qoder 的粘号逻辑。
|
|
6
|
+
export function createStickyPicker({ pick, isCooling = () => false, now = Date.now, ttlMs = 600_000, maxEntries = 200 } = {}) {
|
|
7
|
+
const seen = new Map(); // scope → { key, at }
|
|
8
|
+
|
|
9
|
+
function evict() {
|
|
10
|
+
const t = now();
|
|
11
|
+
for (const [scope, hit] of seen) if (t - hit.at >= ttlMs) seen.delete(scope);
|
|
12
|
+
while (seen.size > maxEntries) seen.delete(seen.keys().next().value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// onDecision(可选):把"这次为什么选它"回报给调用方 —— 决定必须可观测。
|
|
16
|
+
// sticky=同会话复用 / new=新会话轮转选中 / switch=上次的号在冷却(或 TTL 过期)故换 / rr=无 scope 退回轮转
|
|
17
|
+
function pickSticky(scope, onDecision) {
|
|
18
|
+
if (!scope) {
|
|
19
|
+
onDecision?.("rr");
|
|
20
|
+
return pick();
|
|
21
|
+
}
|
|
22
|
+
const hit = seen.get(scope);
|
|
23
|
+
// 粘住:同一会话内复用上次的号,除非它已被冷却(那时必须换)
|
|
24
|
+
if (hit && now() - hit.at < ttlMs && !isCooling(hit.key)) {
|
|
25
|
+
hit.at = now();
|
|
26
|
+
onDecision?.("sticky");
|
|
27
|
+
return hit.key;
|
|
28
|
+
}
|
|
29
|
+
const key = pick();
|
|
30
|
+
onDecision?.(hit ? "switch" : "new");
|
|
31
|
+
if (key) {
|
|
32
|
+
seen.set(scope, { key, at: now() });
|
|
33
|
+
if (seen.size > maxEntries) evict();
|
|
34
|
+
}
|
|
35
|
+
return key;
|
|
36
|
+
}
|
|
37
|
+
// forget:丢弃 scope 的粘号记录(令牌刷新换了 key 后必须丢弃,否则同会话还粘旧 key)
|
|
38
|
+
pickSticky.forget = (scope) => { seen.delete(scope); };
|
|
39
|
+
// remember:把 scope 的粘号改钉到指定 key(换号自愈 / 续期换 token 后,让同一会话继续留在同一个号上)
|
|
40
|
+
pickSticky.remember = (scope, key) => {
|
|
41
|
+
if (!scope || !key) return;
|
|
42
|
+
seen.set(scope, { key, at: now() });
|
|
43
|
+
if (seen.size > maxEntries) evict();
|
|
44
|
+
};
|
|
45
|
+
return pickSticky;
|
|
46
|
+
}
|
|
@@ -1,89 +1,89 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 可扩展供应商注册表:新增特殊供应商时,仅在此文件注册 + 新增对应 provider 文件即可
|
|
3
|
-
* 每个条目:{ id, match(id, baseUrl) => bool, load() => Promise<factory> }
|
|
4
|
-
* 匹配优先级:按数组顺序,首个命中即用;未命中走通用 generic
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
export const customProviders = [
|
|
8
|
-
{
|
|
9
|
-
id: "workbuddy",
|
|
10
|
-
match: (id, baseUrl) => id === "workbuddy" || String(baseUrl).includes("copilot.tencent"),
|
|
11
|
-
load: () => import("./workbuddy.js").then((m) => m.createWorkbuddyProvider),
|
|
12
|
-
},
|
|
13
|
-
{
|
|
14
|
-
id: "traework",
|
|
15
|
-
match: (id, baseUrl) => id === "traework" || String(baseUrl).includes("trae"),
|
|
16
|
-
load: () => import("./traework.js").then((m) => m.createTraeworkProvider),
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
id: "cline",
|
|
20
|
-
match: (id, baseUrl) => id === "cline" || String(baseUrl).includes("cline.bot"),
|
|
21
|
-
load: () => import("./cline.js").then((m) => m.createClineProvider),
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
id: "codearts",
|
|
25
|
-
match: (id, baseUrl) => id === "codearts" || String(baseUrl).includes("myhuaweicloud.com"),
|
|
26
|
-
load: () => import("./codearts.js").then((m) => m.createCodeartsProvider),
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
id: "qoder",
|
|
30
|
-
match: (id, baseUrl) => id === "qoder" || String(baseUrl).includes("qoder"),
|
|
31
|
-
load: () => import("./qoder/index.js").then((m) => m.createQoderProvider),
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
// ⚠ 必须排在 qwenwork 之前:qwenwork 的 match 用 `includes("qwenwork")`,
|
|
35
|
-
// 而 "globalqwenwork" 与 "gateway.qwenwork.ai"/"globalqwenwork://native" 都含该子串,
|
|
36
|
-
// 若后置会被 cn 条目抢先命中(first-match-wins),把国际站 token 打到 cn 网关。
|
|
37
|
-
id: "globalqwenwork",
|
|
38
|
-
match: (id, baseUrl) => id === "globalqwenwork" || String(baseUrl).includes("qwenwork.ai"),
|
|
39
|
-
load: () => import("./globalqwenwork/index.js").then((m) => m.createGlobalQwenworkProvider),
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
id: "qwenwork",
|
|
43
|
-
match: (id, baseUrl) => id === "qwenwork" || String(baseUrl).includes("qwenwork"),
|
|
44
|
-
load: () => import("./qwenwork/index.js").then((m) => m.createQwenworkProvider),
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
id: "zcode",
|
|
48
|
-
match: (id, baseUrl) => id === "zcode" || String(baseUrl).includes("zcode.z.ai"),
|
|
49
|
-
load: () => import("./zcode/index.js").then((m) => m.createZcodeProvider),
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
id: "railway",
|
|
53
|
-
// opencode zen free 层的另一个 IP 出口(Railway 反代),只暴露 free 模型。
|
|
54
|
-
// 匹配必须排在 generic 之前,且不能与其它条目撞(railway.app 域名足够独特)。
|
|
55
|
-
match: (id, baseUrl) => id === "railway" || String(baseUrl).includes("railway.app"),
|
|
56
|
-
load: () => import("./railway.js").then((m) => m.createRailwayProvider),
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
id: "raccoon",
|
|
60
|
-
match: (id, baseUrl) => id === "raccoon" || String(baseUrl).includes("xiaohuanxiong.com"),
|
|
61
|
-
load: () => import("./raccoon/index.js").then((m) => m.createRaccoonProvider),
|
|
62
|
-
},
|
|
63
|
-
// agentrouter 定制工厂已于 2026-10-21 摘除:现网成功率仅 ~48%(502/400/422/500 高发),
|
|
64
|
-
// 从注册表 + 预热名单移除后网关不再解析 agentrouter/* 模型。工厂文件 src/providers/agentrouter.js
|
|
65
|
-
// 与其测试保留,日后要重新启用只需在此加回一条 { id:"agentrouter", ... } 并同步 dispatcher 预热名单。
|
|
66
|
-
];
|
|
67
|
-
|
|
68
|
-
// 供 bench/probe 等需要定制化解析模型列表的场景
|
|
69
|
-
export const customNormalizers = [
|
|
70
|
-
{
|
|
71
|
-
id: "cline",
|
|
72
|
-
match: (baseUrl) => String(baseUrl).includes("cline.bot"),
|
|
73
|
-
normalize: (json) => Array.isArray(json?.free) ? json.free : null,
|
|
74
|
-
},
|
|
75
|
-
];
|
|
76
|
-
|
|
77
|
-
export async function getCustomProviderFactory(id, baseUrl) {
|
|
78
|
-
for (const entry of customProviders) {
|
|
79
|
-
try { if (entry.match(id, baseUrl)) return await entry.load(); } catch {}
|
|
80
|
-
}
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function getCustomNormalizer(baseUrl) {
|
|
85
|
-
for (const entry of customNormalizers) {
|
|
86
|
-
try { if (entry.match(baseUrl)) return entry.normalize; } catch {}
|
|
87
|
-
}
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 可扩展供应商注册表:新增特殊供应商时,仅在此文件注册 + 新增对应 provider 文件即可
|
|
3
|
+
* 每个条目:{ id, match(id, baseUrl) => bool, load() => Promise<factory> }
|
|
4
|
+
* 匹配优先级:按数组顺序,首个命中即用;未命中走通用 generic
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const customProviders = [
|
|
8
|
+
{
|
|
9
|
+
id: "workbuddy",
|
|
10
|
+
match: (id, baseUrl) => id === "workbuddy" || String(baseUrl).includes("copilot.tencent"),
|
|
11
|
+
load: () => import("./workbuddy.js").then((m) => m.createWorkbuddyProvider),
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: "traework",
|
|
15
|
+
match: (id, baseUrl) => id === "traework" || String(baseUrl).includes("trae"),
|
|
16
|
+
load: () => import("./traework.js").then((m) => m.createTraeworkProvider),
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "cline",
|
|
20
|
+
match: (id, baseUrl) => id === "cline" || String(baseUrl).includes("cline.bot"),
|
|
21
|
+
load: () => import("./cline.js").then((m) => m.createClineProvider),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "codearts",
|
|
25
|
+
match: (id, baseUrl) => id === "codearts" || String(baseUrl).includes("myhuaweicloud.com"),
|
|
26
|
+
load: () => import("./codearts.js").then((m) => m.createCodeartsProvider),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "qoder",
|
|
30
|
+
match: (id, baseUrl) => id === "qoder" || String(baseUrl).includes("qoder"),
|
|
31
|
+
load: () => import("./qoder/index.js").then((m) => m.createQoderProvider),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
// ⚠ 必须排在 qwenwork 之前:qwenwork 的 match 用 `includes("qwenwork")`,
|
|
35
|
+
// 而 "globalqwenwork" 与 "gateway.qwenwork.ai"/"globalqwenwork://native" 都含该子串,
|
|
36
|
+
// 若后置会被 cn 条目抢先命中(first-match-wins),把国际站 token 打到 cn 网关。
|
|
37
|
+
id: "globalqwenwork",
|
|
38
|
+
match: (id, baseUrl) => id === "globalqwenwork" || String(baseUrl).includes("qwenwork.ai"),
|
|
39
|
+
load: () => import("./globalqwenwork/index.js").then((m) => m.createGlobalQwenworkProvider),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "qwenwork",
|
|
43
|
+
match: (id, baseUrl) => id === "qwenwork" || String(baseUrl).includes("qwenwork"),
|
|
44
|
+
load: () => import("./qwenwork/index.js").then((m) => m.createQwenworkProvider),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "zcode",
|
|
48
|
+
match: (id, baseUrl) => id === "zcode" || String(baseUrl).includes("zcode.z.ai"),
|
|
49
|
+
load: () => import("./zcode/index.js").then((m) => m.createZcodeProvider),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "railway",
|
|
53
|
+
// opencode zen free 层的另一个 IP 出口(Railway 反代),只暴露 free 模型。
|
|
54
|
+
// 匹配必须排在 generic 之前,且不能与其它条目撞(railway.app 域名足够独特)。
|
|
55
|
+
match: (id, baseUrl) => id === "railway" || String(baseUrl).includes("railway.app"),
|
|
56
|
+
load: () => import("./railway.js").then((m) => m.createRailwayProvider),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "raccoon",
|
|
60
|
+
match: (id, baseUrl) => id === "raccoon" || String(baseUrl).includes("xiaohuanxiong.com"),
|
|
61
|
+
load: () => import("./raccoon/index.js").then((m) => m.createRaccoonProvider),
|
|
62
|
+
},
|
|
63
|
+
// agentrouter 定制工厂已于 2026-10-21 摘除:现网成功率仅 ~48%(502/400/422/500 高发),
|
|
64
|
+
// 从注册表 + 预热名单移除后网关不再解析 agentrouter/* 模型。工厂文件 src/providers/agentrouter.js
|
|
65
|
+
// 与其测试保留,日后要重新启用只需在此加回一条 { id:"agentrouter", ... } 并同步 dispatcher 预热名单。
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// 供 bench/probe 等需要定制化解析模型列表的场景
|
|
69
|
+
export const customNormalizers = [
|
|
70
|
+
{
|
|
71
|
+
id: "cline",
|
|
72
|
+
match: (baseUrl) => String(baseUrl).includes("cline.bot"),
|
|
73
|
+
normalize: (json) => Array.isArray(json?.free) ? json.free : null,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
export async function getCustomProviderFactory(id, baseUrl) {
|
|
78
|
+
for (const entry of customProviders) {
|
|
79
|
+
try { if (entry.match(id, baseUrl)) return await entry.load(); } catch {}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getCustomNormalizer(baseUrl) {
|
|
85
|
+
for (const entry of customNormalizers) {
|
|
86
|
+
try { if (entry.match(baseUrl)) return entry.normalize; } catch {}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|