dsh-router-core 0.3.11 → 0.3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -1
- package/docs/ext.md +224 -0
- package/docs/pool-sticky-block.md +206 -0
- package/lib/client-registry.js +266 -94
- package/lib/client.js +266 -94
- package/lib/index.js +263 -4
- package/lib/types/ext/contract.d.ts +95 -0
- package/lib/types/ext/store.d.ts +16 -0
- package/lib/types/router/account-pool.d.ts +47 -1
- package/lib/types/router/usage-store.d.ts +8 -0
- package/lib/types/shared.d.ts +18 -0
- package/package.json +1 -1
- package/src/client/ExtTab.tsx +143 -0
- package/src/client/RouterView.tsx +10 -2
- package/src/client/router.css +19 -0
- package/src/ext/contract.ts +115 -0
- package/src/ext/store.test.ts +144 -0
- package/src/ext/store.ts +125 -0
- package/src/index.ts +71 -0
- package/src/router/account-pool.test.ts +118 -1
- package/src/router/account-pool.ts +115 -3
- package/src/router/index.ts +9 -1
- package/src/router/usage-store.ts +8 -0
- package/src/shared.ts +20 -0
package/lib/index.js
CHANGED
|
@@ -77,6 +77,14 @@ const TRANSIENT_COOLDOWN_MS = 3e4;
|
|
|
77
77
|
const BACKOFF_BASE_MS = 2e3;
|
|
78
78
|
const BACKOFF_MAX_MS = 5 * MINUTE;
|
|
79
79
|
const BACKOFF_MAX_LEVEL = 15;
|
|
80
|
+
/** 块轮询:命中率达标就放行(`留守 = 计数<N OR 命中率<M`)。见 stickyBlock 说明。 */
|
|
81
|
+
const BLOCK_HIT_RATIO = .85;
|
|
82
|
+
/** 块轮询:最短驻留。新接到这个号的最少连续服务次数(防冷启动误切)。 */
|
|
83
|
+
const BLOCK_MIN = 16;
|
|
84
|
+
/** 块轮询:绝对兜底。轮次过多(max)无论命中率如何都让位,防死守 + 保均衡下限。 */
|
|
85
|
+
const BLOCK_MAX = 48;
|
|
86
|
+
/** 块轮询:判定「缓存无效」后的跳过时长。 */
|
|
87
|
+
const BLOCK_DEMOTE_MS = 10 * MINUTE;
|
|
80
88
|
/** 指数退避:等级 1 = BACKOFF_BASE_MS,每级翻倍,封顶 BACKOFF_MAX_MS。 */
|
|
81
89
|
function backoffMs(level) {
|
|
82
90
|
return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, level - 1), BACKOFF_MAX_MS);
|
|
@@ -91,6 +99,10 @@ var AccountPool = class {
|
|
|
91
99
|
/** uid → 连接级禁用 / 手动暂停。 */
|
|
92
100
|
byUid = /* @__PURE__ */ new Map();
|
|
93
101
|
rrCursor = 0;
|
|
102
|
+
/** (supplier, model) → 块轮询驻留状态。 */
|
|
103
|
+
blocks = /* @__PURE__ */ new Map();
|
|
104
|
+
/** (supplier, model, uid) → 缓存无效降权。 */
|
|
105
|
+
demoted = /* @__PURE__ */ new Map();
|
|
94
106
|
constructor(supplierId = "") {
|
|
95
107
|
this.supplierId = supplierId;
|
|
96
108
|
}
|
|
@@ -156,9 +168,19 @@ var AccountPool = class {
|
|
|
156
168
|
}
|
|
157
169
|
/**
|
|
158
170
|
* 按策略为「某个模型」选一个健康账号。
|
|
171
|
+
*
|
|
172
|
+
* round-robin 走**块轮询**:一次轮转内连续服务若干请求,而不是每请求换号。
|
|
173
|
+
* 设计见 docs/pool-sticky-block.md。要点:
|
|
174
|
+
* - 留守 = `计数 < N OR 命中率 < M`(OR,用户拍板):
|
|
175
|
+
* 命中率达标(缓存热了)→ 换号保均衡;计数到 N → 换号止损。
|
|
176
|
+
* 单号最多连续服务 N 次,不会因命中率低而死守到 N_max(打架场景关键)。
|
|
177
|
+
* - 切号因子的思路:命中率是**留守**条件不是切号条件。反接会正反馈死锁
|
|
178
|
+
* (切→cold miss→命中率 0%→触发切号→…),见文档 §3。
|
|
179
|
+
* - `N_max` 绝对兜底:命中率永远不达标的号不能无限驻留(防死锁 + 防均衡归零)。
|
|
180
|
+
*
|
|
159
181
|
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
160
182
|
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
161
|
-
* @param strategy fallback 取第一个健康 / round-robin
|
|
183
|
+
* @param strategy fallback 取第一个健康 / round-robin 块轮转健康号
|
|
162
184
|
* @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
|
|
163
185
|
* @returns 选中的 uid;无健康账号返回 undefined
|
|
164
186
|
*/
|
|
@@ -168,10 +190,69 @@ var AccountPool = class {
|
|
|
168
190
|
const healthy = [...poolOrder.filter((uid) => byUid.has(uid)), ...accounts.map((a) => a.uid).filter((uid) => !poolOrder.includes(uid))].filter((uid) => this.healthy(uid, modelId, now));
|
|
169
191
|
if (healthy.length === 0) return void 0;
|
|
170
192
|
if (strategy !== "round-robin") return healthy[0];
|
|
171
|
-
const
|
|
172
|
-
|
|
193
|
+
const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now));
|
|
194
|
+
const pool = usable.length > 0 ? usable : healthy;
|
|
195
|
+
const block = this.blocks.get(this.blockKey(modelId));
|
|
196
|
+
if (block !== void 0 && pool.includes(block.uid) && this.blockKeeps(block)) return block.uid;
|
|
197
|
+
const uid = pool[(block === void 0 ? this.rrCursor : Math.max(0, pool.indexOf(block.uid) + 1)) % pool.length];
|
|
198
|
+
this.rrCursor = (pool.indexOf(uid) + 1) % pool.length;
|
|
199
|
+
this.blocks.set(this.blockKey(modelId), {
|
|
200
|
+
uid,
|
|
201
|
+
served: 0,
|
|
202
|
+
hits: 0
|
|
203
|
+
});
|
|
173
204
|
return uid;
|
|
174
205
|
}
|
|
206
|
+
/** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
|
|
207
|
+
isDemoted(uid, modelId, now) {
|
|
208
|
+
const e = this.demoted.get(this.key(modelId, uid));
|
|
209
|
+
return e !== void 0 && e.until > now;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* 块是否还应驻留在当前号上。
|
|
213
|
+
*
|
|
214
|
+
* OR 语义(用户拍板):`留守 = 计数 < N OR 命中率 < M`。
|
|
215
|
+
* 单一账号内部:一个号先被轮到(冷启动/热),然后
|
|
216
|
+
* - 命中率**爬上去**(>= M,缓存热了)→ 让位,轮到下一个号(均衡)
|
|
217
|
+
* - 命中率**没上去**(冷启动/短块)→ 留守,继续热(不急着跳)
|
|
218
|
+
* - 轮次**实在太多**(计数 >= N_max)→ 也换(防死守 + 保均衡下限)
|
|
219
|
+
*
|
|
220
|
+
* 关键:命中率是**留守**条件不是切号执行器(反接会正反馈死锁,
|
|
221
|
+
* 见文档 §3)。换号由「命中达标 OR 份额用够」触发,都不是「命中率低」。
|
|
222
|
+
*
|
|
223
|
+
* 每个号各自守着「这个会话在自己账号上的那段前缀」,轮流热;
|
|
224
|
+
* 命中率 = 块的命中占比。单会话下每个号块都从冷到热各付一次,
|
|
225
|
+
* 收益来自块内连续驻留(缺了它就跟每请求轮询一样)。
|
|
226
|
+
*/
|
|
227
|
+
blockKeeps(block) {
|
|
228
|
+
if (block.served >= BLOCK_MAX) return false;
|
|
229
|
+
const ratio = block.served === 0 ? 0 : block.hits / block.served;
|
|
230
|
+
return block.served < BLOCK_MIN || ratio < BLOCK_HIT_RATIO;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* 记录一次成功请求的缓存命中情况(块轮询的反馈信号)。
|
|
234
|
+
*
|
|
235
|
+
* 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错切。
|
|
236
|
+
* @param uid 实际服务的号
|
|
237
|
+
* @param modelId 模型(块粒度)
|
|
238
|
+
* @param cachedTokens 上游报的命中 token 数;0 = 全量重算
|
|
239
|
+
*/
|
|
240
|
+
noteCache(uid, modelId, cachedTokens) {
|
|
241
|
+
const k = this.blockKey(modelId);
|
|
242
|
+
const block = this.blocks.get(k);
|
|
243
|
+
if (block === void 0 || block.uid !== uid) return;
|
|
244
|
+
block.served += 1;
|
|
245
|
+
if (cachedTokens > 0) block.hits += 1;
|
|
246
|
+
if (block.served >= BLOCK_MAX && block.hits / block.served < BLOCK_HIT_RATIO) this.demoted.set(this.key(modelId, uid), { until: Date.now() + BLOCK_DEMOTE_MS });
|
|
247
|
+
}
|
|
248
|
+
/** 故障切走时作废当前块:换号后前缀归属变了,块计数不能沿用。 */
|
|
249
|
+
dropBlock(modelId) {
|
|
250
|
+
this.blocks.delete(this.blockKey(modelId));
|
|
251
|
+
}
|
|
252
|
+
/** 块状态键:与冷却同粒度 (supplier, model),不含 uid。 */
|
|
253
|
+
blockKey(modelId) {
|
|
254
|
+
return `${this.supplierId}${SEP}${modelId}`;
|
|
255
|
+
}
|
|
175
256
|
/** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
|
|
176
257
|
noteFailure(uid, modelId, state, message) {
|
|
177
258
|
const rule = RULES[state];
|
|
@@ -1867,6 +1948,7 @@ var Router = class {
|
|
|
1867
1948
|
ok,
|
|
1868
1949
|
durationMs: Date.now() - probe.startedAt,
|
|
1869
1950
|
ttfbMs: probe.ttfbMs,
|
|
1951
|
+
...ok && probe.uid !== "" ? { uid: probe.uid } : {},
|
|
1870
1952
|
...error === void 0 ? {} : { error }
|
|
1871
1953
|
}, usage);
|
|
1872
1954
|
};
|
|
@@ -2005,6 +2087,7 @@ var Router = class {
|
|
|
2005
2087
|
return false;
|
|
2006
2088
|
}
|
|
2007
2089
|
pool.noteFailure(uid, req.model, r.state, r.message);
|
|
2090
|
+
pool.dropBlock(req.model);
|
|
2008
2091
|
if (trace !== void 0) {
|
|
2009
2092
|
trace.attempts += 1;
|
|
2010
2093
|
trace.lastState = r.state;
|
|
@@ -2019,7 +2102,10 @@ var Router = class {
|
|
|
2019
2102
|
probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
|
|
2020
2103
|
probe.uid = uid;
|
|
2021
2104
|
}
|
|
2022
|
-
if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0))
|
|
2105
|
+
if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)) {
|
|
2106
|
+
pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0);
|
|
2107
|
+
return true;
|
|
2108
|
+
}
|
|
2023
2109
|
pool.noteFailure(uid, req.model, "transport", "stream failed before first byte");
|
|
2024
2110
|
if (trace !== void 0) {
|
|
2025
2111
|
trace.attempts += 1;
|
|
@@ -3328,6 +3414,116 @@ function dshHome() {
|
|
|
3328
3414
|
return resolve(value);
|
|
3329
3415
|
}
|
|
3330
3416
|
//#endregion
|
|
3417
|
+
//#region src/ext/store.ts
|
|
3418
|
+
/**
|
|
3419
|
+
* 扩展存储 —— **开关状态与插件数据都归核心持久化**,插件不自己 file IO。
|
|
3420
|
+
*
|
|
3421
|
+
* 落盘 `<dataDir>/ext.json`,按扩展器 id 存:
|
|
3422
|
+
* `{ "rtk": { "enabled": true, "data": { ...插件自己的东西... } } }`
|
|
3423
|
+
*
|
|
3424
|
+
* 为什么归核心:跟 `supplier-config.json` 一个道理——开关是用户对**核心面板**的操作,
|
|
3425
|
+
* 不该由扩展插件各自决定存哪、怎么存(否则每个插件长一份互相不一致的实现,核心也无
|
|
3426
|
+
* 从统一裁决「能不能开」)。插件变薄:只报自己是否可用(`ready`)+ 做事。
|
|
3427
|
+
*
|
|
3428
|
+
* `data` 槽是给插件的通用抽屉(2026-09 加):插件不再为了存一点状态而自己开数据目录,
|
|
3429
|
+
* 统一经 `router.extStore` service 读写,落盘位置仍由核心锚定(不跟 cwd 跑)。
|
|
3430
|
+
*
|
|
3431
|
+
* 写法与核心其他存储一致:先写 `.tmp` 再 `rename` 原子覆盖(避免写一半崩掉留坏文件),
|
|
3432
|
+
* 权限 600;持久化失败不阻断。
|
|
3433
|
+
*/
|
|
3434
|
+
/** 扩展开关 + 插件数据存储。 */
|
|
3435
|
+
var ExtStore = class {
|
|
3436
|
+
fp = "";
|
|
3437
|
+
byId = {};
|
|
3438
|
+
constructor(stateFile) {
|
|
3439
|
+
this.fp = stateFile ? join(dirname(stateFile), "ext.json") : "";
|
|
3440
|
+
this.load();
|
|
3441
|
+
this.migrateLegacy();
|
|
3442
|
+
}
|
|
3443
|
+
migrateLegacy() {
|
|
3444
|
+
if (this.fp === "") return;
|
|
3445
|
+
if (Object.keys(this.byId).length > 0) return;
|
|
3446
|
+
const legacy = join(dirname(this.fp), "enhance.json");
|
|
3447
|
+
let enabled = false;
|
|
3448
|
+
try {
|
|
3449
|
+
enabled = JSON.parse(readFileSync(legacy, "utf8")).enabled === true;
|
|
3450
|
+
} catch {
|
|
3451
|
+
return;
|
|
3452
|
+
}
|
|
3453
|
+
if (!enabled) return;
|
|
3454
|
+
this.byId.rtk = {
|
|
3455
|
+
enabled: true,
|
|
3456
|
+
data: void 0
|
|
3457
|
+
};
|
|
3458
|
+
this.save();
|
|
3459
|
+
}
|
|
3460
|
+
load() {
|
|
3461
|
+
if (this.fp === "") return;
|
|
3462
|
+
let raw;
|
|
3463
|
+
try {
|
|
3464
|
+
raw = readFileSync(this.fp, "utf8");
|
|
3465
|
+
} catch {
|
|
3466
|
+
return;
|
|
3467
|
+
}
|
|
3468
|
+
try {
|
|
3469
|
+
const f = JSON.parse(raw);
|
|
3470
|
+
for (const [id, v] of Object.entries(f)) {
|
|
3471
|
+
if (id === "" || v === null || typeof v !== "object") continue;
|
|
3472
|
+
if (typeof v?.enabled !== "boolean") continue;
|
|
3473
|
+
this.byId[id] = {
|
|
3474
|
+
enabled: v.enabled,
|
|
3475
|
+
data: v.data
|
|
3476
|
+
};
|
|
3477
|
+
}
|
|
3478
|
+
} catch {}
|
|
3479
|
+
}
|
|
3480
|
+
save() {
|
|
3481
|
+
if (this.fp === "") return;
|
|
3482
|
+
try {
|
|
3483
|
+
const dir = dirname(this.fp);
|
|
3484
|
+
if (dir !== "" && dir !== ".") mkdirSync(dir, { recursive: true });
|
|
3485
|
+
const out = {};
|
|
3486
|
+
for (const [id, e] of Object.entries(this.byId)) out[id] = e.data === void 0 ? { enabled: e.enabled } : {
|
|
3487
|
+
enabled: e.enabled,
|
|
3488
|
+
data: e.data
|
|
3489
|
+
};
|
|
3490
|
+
const raw = JSON.stringify(out, null, 2);
|
|
3491
|
+
const tmp = this.fp + ".tmp";
|
|
3492
|
+
writeFileSync(tmp, raw, { mode: 384 });
|
|
3493
|
+
renameSync(tmp, this.fp);
|
|
3494
|
+
} catch {}
|
|
3495
|
+
}
|
|
3496
|
+
/** 是否开启(未记录过 = 默认关)。 */
|
|
3497
|
+
isEnabled(id) {
|
|
3498
|
+
return this.byId[id]?.enabled === true;
|
|
3499
|
+
}
|
|
3500
|
+
setEnabled(id, enabled) {
|
|
3501
|
+
if (id === "") return;
|
|
3502
|
+
const cur = this.byId[id];
|
|
3503
|
+
if (cur?.enabled === enabled) return;
|
|
3504
|
+
this.byId[id] = {
|
|
3505
|
+
enabled,
|
|
3506
|
+
data: cur?.data
|
|
3507
|
+
};
|
|
3508
|
+
this.save();
|
|
3509
|
+
}
|
|
3510
|
+
/** 读插件自己存的数据块(没存过 = undefined)。 */
|
|
3511
|
+
readData(id) {
|
|
3512
|
+
if (id === "") return void 0;
|
|
3513
|
+
return this.byId[id]?.data;
|
|
3514
|
+
}
|
|
3515
|
+
/** 写插件自己的数据块(覆盖;与 enabled 同一文件落盘)。 */
|
|
3516
|
+
writeData(id, value) {
|
|
3517
|
+
if (id === "") return;
|
|
3518
|
+
const cur = this.byId[id];
|
|
3519
|
+
this.byId[id] = {
|
|
3520
|
+
enabled: cur?.enabled === true,
|
|
3521
|
+
data: value
|
|
3522
|
+
};
|
|
3523
|
+
this.save();
|
|
3524
|
+
}
|
|
3525
|
+
};
|
|
3526
|
+
//#endregion
|
|
3331
3527
|
//#region src/index.ts
|
|
3332
3528
|
/**
|
|
3333
3529
|
* Plugin identity for cordis.yml rows — 必须与 package.json 的 name 一致。
|
|
@@ -3401,6 +3597,10 @@ function apply(rawContext) {
|
|
|
3401
3597
|
log(`supplier unregistered: ${id}`);
|
|
3402
3598
|
};
|
|
3403
3599
|
ctx.provide("router.suppliers", {});
|
|
3600
|
+
const exts = {};
|
|
3601
|
+
ctx.provide("router.ext", exts);
|
|
3602
|
+
const extStore = new ExtStore(stateFile);
|
|
3603
|
+
ctx.provide("router.extStore", extStore);
|
|
3404
3604
|
const registerLoaded = (loaded) => {
|
|
3405
3605
|
loadedSuppliers.push(loaded);
|
|
3406
3606
|
router.add(loaded.supplier);
|
|
@@ -3703,6 +3903,62 @@ function apply(rawContext) {
|
|
|
3703
3903
|
requireApiKey: keys.requireApiKey
|
|
3704
3904
|
});
|
|
3705
3905
|
});
|
|
3906
|
+
route(`${ROUTER_API_BASE}/ext`, async (req, res) => {
|
|
3907
|
+
if (req.method === "PATCH") {
|
|
3908
|
+
let body;
|
|
3909
|
+
try {
|
|
3910
|
+
body = JSON.parse(await readBody(req, 65536));
|
|
3911
|
+
} catch {
|
|
3912
|
+
writeJson(res, 400, {
|
|
3913
|
+
ok: false,
|
|
3914
|
+
error: "invalid JSON body"
|
|
3915
|
+
});
|
|
3916
|
+
return;
|
|
3917
|
+
}
|
|
3918
|
+
const ext = typeof body.id === "string" ? exts[body.id] : void 0;
|
|
3919
|
+
if (!ext) {
|
|
3920
|
+
writeJson(res, 404, {
|
|
3921
|
+
ok: false,
|
|
3922
|
+
error: "extension not found"
|
|
3923
|
+
});
|
|
3924
|
+
return;
|
|
3925
|
+
}
|
|
3926
|
+
if (typeof body.enabled !== "boolean") {
|
|
3927
|
+
writeJson(res, 400, {
|
|
3928
|
+
ok: false,
|
|
3929
|
+
error: "enabled must be a boolean"
|
|
3930
|
+
});
|
|
3931
|
+
return;
|
|
3932
|
+
}
|
|
3933
|
+
if (body.enabled && typeof ext.getState === "function") {
|
|
3934
|
+
const st = ext.getState();
|
|
3935
|
+
if (st?.ready !== true) {
|
|
3936
|
+
writeJson(res, 409, {
|
|
3937
|
+
ok: false,
|
|
3938
|
+
error: st?.detail ?? "extension not ready"
|
|
3939
|
+
});
|
|
3940
|
+
return;
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
extStore.setEnabled(ext.id, body.enabled);
|
|
3944
|
+
}
|
|
3945
|
+
writeJson(res, 200, {
|
|
3946
|
+
ok: true,
|
|
3947
|
+
enhancers: Object.values(exts).map((raw) => {
|
|
3948
|
+
const e = raw;
|
|
3949
|
+
if (!e || typeof e.id !== "string" || e.id === "") return void 0;
|
|
3950
|
+
const st = typeof e.getState === "function" ? e.getState() : { ready: false };
|
|
3951
|
+
return {
|
|
3952
|
+
id: e.id,
|
|
3953
|
+
name: e.name ?? e.id,
|
|
3954
|
+
...e.description !== void 0 ? { description: e.description } : {},
|
|
3955
|
+
enabled: extStore.isEnabled(e.id),
|
|
3956
|
+
ready: st?.ready === true,
|
|
3957
|
+
...st?.detail !== void 0 ? { detail: st.detail } : {}
|
|
3958
|
+
};
|
|
3959
|
+
}).filter((e) => e !== void 0)
|
|
3960
|
+
});
|
|
3961
|
+
});
|
|
3706
3962
|
/** 解析并校验周期参数(不接受任意字符串)。 */
|
|
3707
3963
|
const periodOf = (raw) => {
|
|
3708
3964
|
return raw === "24h" || raw === "7d" || raw === "30d" ? raw : "today";
|
|
@@ -3803,6 +4059,9 @@ function apply(rawContext) {
|
|
|
3803
4059
|
});
|
|
3804
4060
|
});
|
|
3805
4061
|
ctx.effect(() => () => {
|
|
4062
|
+
for (const raw of Object.values(exts)) try {
|
|
4063
|
+
raw?.dispose?.();
|
|
4064
|
+
} catch {}
|
|
3806
4065
|
for (const dispose of disposers.splice(0)) dispose();
|
|
3807
4066
|
router.dispose();
|
|
3808
4067
|
}, "dsh-router: teardown");
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展 (Ext) 通用契约 —— dsh-router 的「扩展插件」扩展点(不是供应商)。
|
|
3
|
+
*
|
|
4
|
+
* 分工(2026-09 重构:核心只做管理,不做拦截):
|
|
5
|
+
* - **dsh-router 核心 = 管理面**:持有 `router.ext` 注册表(发现)、渲染面板与
|
|
6
|
+
* `/router/api/ext`(展示)、代存开关与插件数据(`router.extStore` →
|
|
7
|
+
* `<dataDir>/ext.json`)。核心**不挂 `tools/execute`、不裁决、不改写**。
|
|
8
|
+
* - **扩展插件 = 执行面**:自己挂监听、自己问 `isEnabled`、自己按 ready 裁决、
|
|
9
|
+
* 自己短路执行。怎么改一条命令是插件的**私事**,不在本契约里。
|
|
10
|
+
*
|
|
11
|
+
* 为什么把拦截从核心挪走:
|
|
12
|
+
* `tools/execute` 是任何插件都能自己挂的 around-dispatch waterfall(插件 ctx 不在
|
|
13
|
+
* agent scope 下就能收到全部派发)。核心代挂只在一个消费者(rtk)时是纯亏损:为留
|
|
14
|
+
* 40 行委派逻辑,付了共享表 + inject 广播 + 契约两处同步的代价。核心归位成管理面
|
|
15
|
+
* 后,扩展插件与 dsh-router 只在**注册表 + 存储**两处耦合。
|
|
16
|
+
*
|
|
17
|
+
* ponytail: 天花板 —— 核心代挂原本顺带解决「多个扩展插件各自挂监听会互相踩 / 顺序
|
|
18
|
+
* 不可控」。这个保护现在没了;目前只有 rtk 一家消费,无所谓。**第二家 ext 出现时
|
|
19
|
+
* 要重新收敛顺序**(升级路径:核心暴露一个按顺序委派的共享工具方法,而不是收回拦截)。
|
|
20
|
+
*
|
|
21
|
+
* 契约要点:
|
|
22
|
+
* - 扩展器只有**声明**(id / name / description)+ `getState()`;核心拿这些渲染面板。
|
|
23
|
+
* - `getState()` 只报**运行时事实**(ready 是否可用、detail 说明),不报开关。
|
|
24
|
+
* - **开关与数据归核心持久化**(`router.extStore` → `<dataDir>/ext.json`),插件不自己
|
|
25
|
+
* file IO —— 落盘位置由核心锚定(不跟 cwd 跑),读写经核心,插件无状态。
|
|
26
|
+
*/
|
|
27
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
28
|
+
/** 扩展器唯一 id(注册键)。 */
|
|
29
|
+
export type ExtId = string;
|
|
30
|
+
/**
|
|
31
|
+
* 扩展器当前状态(**运行时事实**,由插件现算,不持久化)。
|
|
32
|
+
*
|
|
33
|
+
* 注意:`enabled` 不在这里 —— 开关状态归核心持久化(`<dataDir>/ext.json`),
|
|
34
|
+
* 插件只负责做事 + 报自己是否可用。核心把两者合并后再给面板用。
|
|
35
|
+
*/
|
|
36
|
+
export interface ExtState {
|
|
37
|
+
/** 运行时是否就绪(如 RTK 二进制是否可用);false 时即使开启也不生效。 */
|
|
38
|
+
ready: boolean;
|
|
39
|
+
/** 不就绪时的一句话说明(如「本机未装 rtk」),面板红字显示。 */
|
|
40
|
+
detail?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 注册到 `router.ext` 表的扩展器实现。核心(面板/API)只认这几个成员,
|
|
44
|
+
* 插件可以携带更多内部状态。
|
|
45
|
+
*/
|
|
46
|
+
export interface RouterExt {
|
|
47
|
+
/** 扩展器 id,也是 `router.ext` 表里的注册键。 */
|
|
48
|
+
readonly id: ExtId;
|
|
49
|
+
/** 面板显示名(如「RTK」)。 */
|
|
50
|
+
readonly name: string;
|
|
51
|
+
/** 面板副标题说明。 */
|
|
52
|
+
readonly description?: string;
|
|
53
|
+
/** 当前运行时状态(ready + 不就绪时的说明)。 */
|
|
54
|
+
getState(): ExtState;
|
|
55
|
+
/** 卸载清理(表删除时由 dsh-router 调用,可选)。 */
|
|
56
|
+
dispose?(): void;
|
|
57
|
+
}
|
|
58
|
+
/** 面板/API 用的一条扩展器信息 = 核心存的开关 + 插件报的运行时事实。 */
|
|
59
|
+
export interface ExtInfo {
|
|
60
|
+
id: string;
|
|
61
|
+
name: string;
|
|
62
|
+
description?: string;
|
|
63
|
+
/** 核心持久化的开关(默认关)。 */
|
|
64
|
+
enabled: boolean;
|
|
65
|
+
/** 插件报的运行时就绪状态。 */
|
|
66
|
+
ready: boolean;
|
|
67
|
+
/** 不就绪时的说明(面板红字)。 */
|
|
68
|
+
detail?: string;
|
|
69
|
+
}
|
|
70
|
+
/** `router.ext` 共享聚合表:`{ [extId]: RouterExt }`(同一 live 对象可追加)。 */
|
|
71
|
+
export interface RouterExtService {
|
|
72
|
+
[extId: string]: RouterExt;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 扩展的存储面(核心 provide,插件 inject)。
|
|
76
|
+
*
|
|
77
|
+
* 插件**不自己 file IO**:落盘位置由核心用 `dataDirOf(ctx.baseUrl)` 锚定(不跟
|
|
78
|
+
* process.cwd() 跑),读写都经核心,同一文件、同一次原子写。
|
|
79
|
+
*
|
|
80
|
+
* 落盘形状:`<dataDir>/ext.json` = `{ "<id>": { "enabled": boolean, "data": unknown } }`
|
|
81
|
+
*/
|
|
82
|
+
export interface ExtStoreService {
|
|
83
|
+
/** 是否开启(未记录过 = 默认关)。 */
|
|
84
|
+
isEnabled(id: string): boolean;
|
|
85
|
+
/** 置开关(面板/API 调)。 */
|
|
86
|
+
setEnabled(id: string, enabled: boolean): void;
|
|
87
|
+
/** 读插件自己的数据块(没有 = undefined)。 */
|
|
88
|
+
readData<T = unknown>(id: string): T | undefined;
|
|
89
|
+
/** 写插件自己的数据块(覆盖;与 enabled 同文件原子落盘)。 */
|
|
90
|
+
writeData(id: string, value: unknown): void;
|
|
91
|
+
}
|
|
92
|
+
/** 读取当前 router.ext 表(同一 live 对象,可追加)——扩展插件用它注册自己。 */
|
|
93
|
+
export declare function currentExts(ctx: Context): RouterExtService | undefined;
|
|
94
|
+
/** 读取扩展存储服务——扩展插件用它读开关、读写自己存的数据。 */
|
|
95
|
+
export declare function currentExtStore(ctx: Context): ExtStoreService | undefined;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** 扩展开关 + 插件数据存储。 */
|
|
2
|
+
export declare class ExtStore {
|
|
3
|
+
private fp;
|
|
4
|
+
private byId;
|
|
5
|
+
constructor(stateFile: string);
|
|
6
|
+
private migrateLegacy;
|
|
7
|
+
private load;
|
|
8
|
+
private save;
|
|
9
|
+
/** 是否开启(未记录过 = 默认关)。 */
|
|
10
|
+
isEnabled(id: string): boolean;
|
|
11
|
+
setEnabled(id: string, enabled: boolean): void;
|
|
12
|
+
/** 读插件自己存的数据块(没存过 = undefined)。 */
|
|
13
|
+
readData<T = unknown>(id: string): T | undefined;
|
|
14
|
+
/** 写插件自己的数据块(覆盖;与 enabled 同一文件落盘)。 */
|
|
15
|
+
writeData(id: string, value: unknown): void;
|
|
16
|
+
}
|
|
@@ -31,6 +31,10 @@ export declare class AccountPool {
|
|
|
31
31
|
/** uid → 连接级禁用 / 手动暂停。 */
|
|
32
32
|
private byUid;
|
|
33
33
|
private rrCursor;
|
|
34
|
+
/** (supplier, model) → 块轮询驻留状态。 */
|
|
35
|
+
private blocks;
|
|
36
|
+
/** (supplier, model, uid) → 缓存无效降权。 */
|
|
37
|
+
private demoted;
|
|
34
38
|
constructor(supplierId?: string);
|
|
35
39
|
private key;
|
|
36
40
|
private entry;
|
|
@@ -46,13 +50,55 @@ export declare class AccountPool {
|
|
|
46
50
|
private healthy;
|
|
47
51
|
/**
|
|
48
52
|
* 按策略为「某个模型」选一个健康账号。
|
|
53
|
+
*
|
|
54
|
+
* round-robin 走**块轮询**:一次轮转内连续服务若干请求,而不是每请求换号。
|
|
55
|
+
* 设计见 docs/pool-sticky-block.md。要点:
|
|
56
|
+
* - 留守 = `计数 < N OR 命中率 < M`(OR,用户拍板):
|
|
57
|
+
* 命中率达标(缓存热了)→ 换号保均衡;计数到 N → 换号止损。
|
|
58
|
+
* 单号最多连续服务 N 次,不会因命中率低而死守到 N_max(打架场景关键)。
|
|
59
|
+
* - 切号因子的思路:命中率是**留守**条件不是切号条件。反接会正反馈死锁
|
|
60
|
+
* (切→cold miss→命中率 0%→触发切号→…),见文档 §3。
|
|
61
|
+
* - `N_max` 绝对兜底:命中率永远不达标的号不能无限驻留(防死锁 + 防均衡归零)。
|
|
62
|
+
*
|
|
49
63
|
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
50
64
|
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
51
|
-
* @param strategy fallback 取第一个健康 / round-robin
|
|
65
|
+
* @param strategy fallback 取第一个健康 / round-robin 块轮转健康号
|
|
52
66
|
* @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
|
|
53
67
|
* @returns 选中的 uid;无健康账号返回 undefined
|
|
54
68
|
*/
|
|
55
69
|
pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string): string | undefined;
|
|
70
|
+
/** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
|
|
71
|
+
private isDemoted;
|
|
72
|
+
/**
|
|
73
|
+
* 块是否还应驻留在当前号上。
|
|
74
|
+
*
|
|
75
|
+
* OR 语义(用户拍板):`留守 = 计数 < N OR 命中率 < M`。
|
|
76
|
+
* 单一账号内部:一个号先被轮到(冷启动/热),然后
|
|
77
|
+
* - 命中率**爬上去**(>= M,缓存热了)→ 让位,轮到下一个号(均衡)
|
|
78
|
+
* - 命中率**没上去**(冷启动/短块)→ 留守,继续热(不急着跳)
|
|
79
|
+
* - 轮次**实在太多**(计数 >= N_max)→ 也换(防死守 + 保均衡下限)
|
|
80
|
+
*
|
|
81
|
+
* 关键:命中率是**留守**条件不是切号执行器(反接会正反馈死锁,
|
|
82
|
+
* 见文档 §3)。换号由「命中达标 OR 份额用够」触发,都不是「命中率低」。
|
|
83
|
+
*
|
|
84
|
+
* 每个号各自守着「这个会话在自己账号上的那段前缀」,轮流热;
|
|
85
|
+
* 命中率 = 块的命中占比。单会话下每个号块都从冷到热各付一次,
|
|
86
|
+
* 收益来自块内连续驻留(缺了它就跟每请求轮询一样)。
|
|
87
|
+
*/
|
|
88
|
+
private blockKeeps;
|
|
89
|
+
/**
|
|
90
|
+
* 记录一次成功请求的缓存命中情况(块轮询的反馈信号)。
|
|
91
|
+
*
|
|
92
|
+
* 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错切。
|
|
93
|
+
* @param uid 实际服务的号
|
|
94
|
+
* @param modelId 模型(块粒度)
|
|
95
|
+
* @param cachedTokens 上游报的命中 token 数;0 = 全量重算
|
|
96
|
+
*/
|
|
97
|
+
noteCache(uid: string, modelId: string, cachedTokens: number): void;
|
|
98
|
+
/** 故障切走时作废当前块:换号后前缀归属变了,块计数不能沿用。 */
|
|
99
|
+
dropBlock(modelId: string): void;
|
|
100
|
+
/** 块状态键:与冷却同粒度 (supplier, model),不含 uid。 */
|
|
101
|
+
private blockKey;
|
|
56
102
|
/** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
|
|
57
103
|
noteFailure(uid: string, modelId: string, state: AccountState, message: string): void;
|
|
58
104
|
/** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。不解除禁用。 */
|
|
@@ -15,6 +15,14 @@ export interface UsageRecord {
|
|
|
15
15
|
durationMs: number;
|
|
16
16
|
/** 首字节延迟(ms);非流式为 0。 */
|
|
17
17
|
ttfbMs: number;
|
|
18
|
+
/**
|
|
19
|
+
* 实际服务的账号 uid(成功时回填)。
|
|
20
|
+
*
|
|
21
|
+
* 有了它才能把命中率按**号**下钻——prompt cache 是 per-account 命名空间,
|
|
22
|
+
* 只看供应商/模型无法判断「是某个号的缓存没热,还是策略在乱切号」。
|
|
23
|
+
* 块轮询(docs/pool-sticky-block.md)的调参完全依赖这个归因。
|
|
24
|
+
*/
|
|
25
|
+
uid?: string;
|
|
18
26
|
/** 失败原因(ok=false 时)。 */
|
|
19
27
|
error?: string;
|
|
20
28
|
}
|
package/lib/types/shared.d.ts
CHANGED
|
@@ -132,6 +132,24 @@ export interface RouterSettingsResponse {
|
|
|
132
132
|
error?: string;
|
|
133
133
|
requireApiKey?: boolean;
|
|
134
134
|
}
|
|
135
|
+
/** 一条扩展器(`/router/api/ext`)。 */
|
|
136
|
+
export interface RouterExtItem {
|
|
137
|
+
id: string;
|
|
138
|
+
name: string;
|
|
139
|
+
description?: string;
|
|
140
|
+
/** 是否启用改写(面板开关)。 */
|
|
141
|
+
enabled?: boolean;
|
|
142
|
+
/** 运行时是否就绪(如 RTK 二进制是否可用)。 */
|
|
143
|
+
ready?: boolean;
|
|
144
|
+
/** 一句话状态说明(如「本机未装 rtk」)。 */
|
|
145
|
+
detail?: string;
|
|
146
|
+
}
|
|
147
|
+
/** `/router/api/ext` response(扩展插件列表 + 开关)。 */
|
|
148
|
+
export interface RouterExtResponse {
|
|
149
|
+
ok: boolean;
|
|
150
|
+
error?: string;
|
|
151
|
+
enhancers?: RouterExtItem[];
|
|
152
|
+
}
|
|
135
153
|
/** 供应商模型(含启用状态)。 */
|
|
136
154
|
export interface RouterSupplierModel {
|
|
137
155
|
id: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-router-core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|