mslxdff 0.1.233 → 0.1.235

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.
Files changed (106) hide show
  1. package/bin/mslxdff.js +3 -3
  2. package/docs/cli_help_mini.md +149 -0
  3. package/package.json +1 -1
  4. package/src/auto.js +264 -264
  5. package/src/bench/cline-bench.js +42 -42
  6. package/src/bench/probe.js +70 -70
  7. package/src/bench/report.js +162 -162
  8. package/src/bench/runner.js +77 -77
  9. package/src/bench/via-probe.js +124 -124
  10. package/src/bench/via-routes.js +87 -87
  11. package/src/bench/workbuddy-bench.js +54 -54
  12. package/src/chat/engine.js +160 -160
  13. package/src/chat/gateway.js +163 -163
  14. package/src/chat/orchestrator.js +234 -234
  15. package/src/chat/prompt.js +70 -70
  16. package/src/chat/repl.js +88 -88
  17. package/src/chat/terminal.js +135 -135
  18. package/src/chat/tools.js +306 -306
  19. package/src/chat-pipeline/index.js +155 -155
  20. package/src/chat-pipeline/policy.js +84 -84
  21. package/src/chat-pipeline/serial-trial.js +16 -3
  22. package/src/chat-pipeline/upstream-5xx-retry.js +72 -0
  23. package/src/cli/commands/group.js +266 -266
  24. package/src/cli/commands/model/picks.js +50 -50
  25. package/src/cli/commands/provider/bench-via.js +247 -247
  26. package/src/cli/commands/provider/bench.js +141 -141
  27. package/src/cli/commands/provider/index.js +139 -139
  28. package/src/cli/commands/provider/models.js +149 -149
  29. package/src/cli/commands/provider/qwenwork-login.js +119 -119
  30. package/src/cli/commands/provider/raccoon-quota.js +156 -156
  31. package/src/cli/commands/sync.js +310 -310
  32. package/src/cli/status.js +279 -279
  33. package/src/daemon.js +246 -246
  34. package/src/model-capabilities/enrich.js +86 -86
  35. package/src/model-capabilities/index.js +183 -183
  36. package/src/model-capabilities/parse.js +70 -70
  37. package/src/models.js +293 -293
  38. package/src/peers.js +345 -345
  39. package/src/providers/AGENTS.md +49 -0
  40. package/src/providers/agentrouter.js +238 -238
  41. package/src/providers/base.js +164 -164
  42. package/src/providers/cline/auth.js +228 -228
  43. package/src/providers/cline/chat.js +307 -307
  44. package/src/providers/cline.js +2 -2
  45. package/src/providers/codearts/chat.js +207 -207
  46. package/src/providers/dispatcher.js +147 -147
  47. package/src/providers/globalqwenwork/cosy.js +123 -123
  48. package/src/providers/globalqwenwork/crypto.js +218 -218
  49. package/src/providers/globalqwenwork/http.js +20 -20
  50. package/src/providers/globalqwenwork/rsa.js +54 -54
  51. package/src/providers/globalqwenwork/sse.js +268 -268
  52. package/src/providers/globalqwenwork/stream.js +130 -130
  53. package/src/providers/globalqwenwork/upstream.js +122 -122
  54. package/src/providers/keyring.js +60 -60
  55. package/src/providers/qoder/chat.js +292 -292
  56. package/src/providers/qoder/index.js +311 -311
  57. package/src/providers/qoder/sse.js +233 -233
  58. package/src/providers/qwenwork/account-store.js +133 -133
  59. package/src/providers/qwenwork/constants.js +67 -67
  60. package/src/providers/qwenwork/cosy.js +120 -120
  61. package/src/providers/qwenwork/crypto.js +218 -218
  62. package/src/providers/qwenwork/http.js +20 -20
  63. package/src/providers/qwenwork/index.js +337 -337
  64. package/src/providers/qwenwork/payload.js +144 -144
  65. package/src/providers/qwenwork/rsa.js +54 -54
  66. package/src/providers/qwenwork/sse.js +268 -268
  67. package/src/providers/qwenwork/stream.js +130 -130
  68. package/src/providers/qwenwork/upstream.js +120 -120
  69. package/src/providers/qwenwork.js +1 -1
  70. package/src/providers/raccoon/const.js +219 -219
  71. package/src/providers/raccoon/credits.js +285 -285
  72. package/src/providers/raccoon/index.js +36 -4
  73. package/src/providers/raccoon/sticky.js +46 -0
  74. package/src/providers/registry.js +89 -89
  75. package/src/providers/workbuddy/chat.js +298 -298
  76. package/src/providers/workbuddy/reshape.js +152 -152
  77. package/src/providers/workbuddy.js +2 -2
  78. package/src/reasoning.js +32 -32
  79. package/src/routes/AGENTS.md +37 -0
  80. package/src/routes/chat/broadband-handler.js +80 -80
  81. package/src/routes/chat/gateway.js +46 -46
  82. package/src/routes/chat/peer-handler.js +14 -4
  83. package/src/routes/chat/relay-pipeline.js +282 -282
  84. package/src/routes/chat/via-route-handler.js +156 -156
  85. package/src/routes/hedge.js +260 -260
  86. package/src/routes/models-route.js +167 -167
  87. package/src/routes/peer-serial.js +5 -2
  88. package/src/routes/peers.js +373 -373
  89. package/src/routes/stream.js +364 -364
  90. package/src/runtime/bootstrap.js +75 -75
  91. package/src/runtime/keep-warm.js +135 -135
  92. package/src/runtime/provider-gate.js +75 -75
  93. package/src/runtime/providers-setup.js +187 -187
  94. package/src/runtime/raccoon-checkin.js +175 -175
  95. package/src/server.js +85 -85
  96. package/src/state/schemas/allowlist.js +92 -92
  97. package/src/sync-opencode.js +280 -280
  98. package/src/transport/index.js +244 -244
  99. package/src/transport/pool.js +56 -56
  100. package/src/transport/retry.js +24 -24
  101. package/src/transport/sse.js +93 -93
  102. package/src/upstream-probe/display.js +52 -52
  103. package/src/upstream-probe/probe.js +49 -49
  104. package/src/upstream-probe/rotate.js +110 -110
  105. package/src/upstream-probe/start.js +45 -45
  106. package/src/upstream.js +355 -355
@@ -3,6 +3,7 @@
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
9
  import { buildRaccoonModelList, listRaccoonModels, mergeRaccoonCatalogIntoAllowlist } from "./models.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
- const key = ring.next();
183
+ let pick = "rr";
184
+ const key = pickKey(scope, (d) => { pick = d; });
160
185
  if (!key) break;
161
- const credential = await ensureFresh(key);
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
- if (await healKeyFromDocs(key)) continue;
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);
@@ -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
+ }