dsh-router-core 0.3.11 → 0.3.13
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 +32 -3
- package/docs/ext.md +224 -0
- package/docs/pool-sticky-block.md +257 -0
- package/lib/client-registry.js +267 -95
- package/lib/client.js +267 -95
- package/lib/index.js +416 -13
- package/lib/types/ext/contract.d.ts +95 -0
- package/lib/types/ext/store.d.ts +16 -0
- package/lib/types/llm/adapter.d.ts +9 -0
- package/lib/types/router/account-pool.d.ts +58 -2
- package/lib/types/router/prefix-affinity.d.ts +6 -0
- package/lib/types/router/usage-store.d.ts +8 -0
- package/lib/types/shared.d.ts +18 -0
- package/package.json +5 -5
- package/src/client/ExtTab.tsx +143 -0
- package/src/client/RouterView.tsx +10 -2
- package/src/client/SupplierDetail.tsx +1 -1
- 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/llm/adapter.ts +22 -5
- package/src/router/account-pool.test.ts +244 -1
- package/src/router/account-pool.ts +180 -4
- package/src/router/index.ts +25 -2
- package/src/router/prefix-affinity.ts +80 -0
- package/src/router/usage-store.ts +8 -0
- package/src/shared.ts +20 -0
package/src/ext/store.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展存储 —— **开关状态与插件数据都归核心持久化**,插件不自己 file IO。
|
|
3
|
+
*
|
|
4
|
+
* 落盘 `<dataDir>/ext.json`,按扩展器 id 存:
|
|
5
|
+
* `{ "rtk": { "enabled": true, "data": { ...插件自己的东西... } } }`
|
|
6
|
+
*
|
|
7
|
+
* 为什么归核心:跟 `supplier-config.json` 一个道理——开关是用户对**核心面板**的操作,
|
|
8
|
+
* 不该由扩展插件各自决定存哪、怎么存(否则每个插件长一份互相不一致的实现,核心也无
|
|
9
|
+
* 从统一裁决「能不能开」)。插件变薄:只报自己是否可用(`ready`)+ 做事。
|
|
10
|
+
*
|
|
11
|
+
* `data` 槽是给插件的通用抽屉(2026-09 加):插件不再为了存一点状态而自己开数据目录,
|
|
12
|
+
* 统一经 `router.extStore` service 读写,落盘位置仍由核心锚定(不跟 cwd 跑)。
|
|
13
|
+
*
|
|
14
|
+
* 写法与核心其他存储一致:先写 `.tmp` 再 `rename` 原子覆盖(避免写一半崩掉留坏文件),
|
|
15
|
+
* 权限 600;持久化失败不阻断。
|
|
16
|
+
*/
|
|
17
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
interface ExtFile {
|
|
21
|
+
[extId: string]: { enabled: boolean; data?: unknown }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Entry {
|
|
25
|
+
enabled: boolean
|
|
26
|
+
data: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 扩展开关 + 插件数据存储。 */
|
|
30
|
+
export class ExtStore {
|
|
31
|
+
private fp = ''
|
|
32
|
+
private byId: Record<string, Entry> = {}
|
|
33
|
+
|
|
34
|
+
constructor(stateFile: string) {
|
|
35
|
+
this.fp = stateFile ? join(dirname(stateFile), 'ext.json') : ''
|
|
36
|
+
this.load()
|
|
37
|
+
// 一次性迁移:开关早期由扩展插件自己写在 `enhance.json`(单文件、无法按 id 区分),
|
|
38
|
+
// 现在归核心按 id 存 `ext.json`。把旧值搬过来,避免用户已开的开关掉回关。
|
|
39
|
+
// 旧文件保留不删(不可逆操作留给人工),迁移只发生在新文件还没有时。
|
|
40
|
+
this.migrateLegacy()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private migrateLegacy(): void {
|
|
44
|
+
if (this.fp === '') return
|
|
45
|
+
if (Object.keys(this.byId).length > 0) return // 已有新配置,不覆盖
|
|
46
|
+
const legacy = join(dirname(this.fp), 'enhance.json')
|
|
47
|
+
let enabled = false
|
|
48
|
+
try {
|
|
49
|
+
const f = JSON.parse(readFileSync(legacy, 'utf8')) as { enabled?: unknown }
|
|
50
|
+
enabled = f.enabled === true
|
|
51
|
+
} catch {
|
|
52
|
+
return // 没有旧文件 / 读不了 → 无需迁移
|
|
53
|
+
}
|
|
54
|
+
if (!enabled) return
|
|
55
|
+
// 旧格式只有一个开关,当时唯一的扩展器就是 rtk。
|
|
56
|
+
this.byId.rtk = { enabled: true, data: undefined }
|
|
57
|
+
this.save()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private load(): void {
|
|
61
|
+
if (this.fp === '') return
|
|
62
|
+
let raw: string
|
|
63
|
+
try {
|
|
64
|
+
raw = readFileSync(this.fp, 'utf8')
|
|
65
|
+
} catch {
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const f = JSON.parse(raw) as ExtFile
|
|
70
|
+
for (const [id, v] of Object.entries(f)) {
|
|
71
|
+
if (id === '' || v === null || typeof v !== 'object') continue
|
|
72
|
+
if (typeof v?.enabled !== 'boolean') continue
|
|
73
|
+
this.byId[id] = { enabled: v.enabled, data: v.data }
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// 损坏则用默认关
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private save(): void {
|
|
81
|
+
if (this.fp === '') return
|
|
82
|
+
try {
|
|
83
|
+
const dir = dirname(this.fp)
|
|
84
|
+
if (dir !== '' && dir !== '.') mkdirSync(dir, { recursive: true })
|
|
85
|
+
const out: ExtFile = {}
|
|
86
|
+
for (const [id, e] of Object.entries(this.byId)) {
|
|
87
|
+
// 没数据的不写 data 键,保持文件干净(旧格式兼容)。
|
|
88
|
+
out[id] = e.data === undefined ? { enabled: e.enabled } : { enabled: e.enabled, data: e.data }
|
|
89
|
+
}
|
|
90
|
+
const raw = JSON.stringify(out, null, 2)
|
|
91
|
+
const tmp = this.fp + '.tmp'
|
|
92
|
+
writeFileSync(tmp, raw, { mode: 0o600 })
|
|
93
|
+
renameSync(tmp, this.fp)
|
|
94
|
+
} catch {
|
|
95
|
+
// 持久化失败不阻断
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 是否开启(未记录过 = 默认关)。 */
|
|
100
|
+
isEnabled(id: string): boolean {
|
|
101
|
+
return this.byId[id]?.enabled === true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setEnabled(id: string, enabled: boolean): void {
|
|
105
|
+
if (id === '') return
|
|
106
|
+
const cur = this.byId[id]
|
|
107
|
+
if (cur?.enabled === enabled) return
|
|
108
|
+
this.byId[id] = { enabled, data: cur?.data }
|
|
109
|
+
this.save()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 读插件自己存的数据块(没存过 = undefined)。 */
|
|
113
|
+
readData<T = unknown>(id: string): T | undefined {
|
|
114
|
+
if (id === '') return undefined
|
|
115
|
+
return this.byId[id]?.data as T | undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 写插件自己的数据块(覆盖;与 enabled 同一文件落盘)。 */
|
|
119
|
+
writeData(id: string, value: unknown): void {
|
|
120
|
+
if (id === '') return
|
|
121
|
+
const cur = this.byId[id]
|
|
122
|
+
this.byId[id] = { enabled: cur?.enabled === true, data: value }
|
|
123
|
+
this.save()
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,8 @@ import type { SupplierEnv, SupplierModule } from './suppliers/contract.ts'
|
|
|
41
41
|
import { SupplierConfigStore } from './supplier-config.ts'
|
|
42
42
|
import { CredentialStore } from './credential-store.ts'
|
|
43
43
|
import { dataDirOf, profileDirOf } from './data-dir.ts'
|
|
44
|
+
import { ExtStore } from './ext/store.ts'
|
|
45
|
+
import type { ExtInfo, ExtStoreService, RouterExt, RouterExtService } from './ext/contract.ts'
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
48
|
* Plugin identity for cordis.yml rows — 必须与 package.json 的 name 一致。
|
|
@@ -185,6 +187,17 @@ export function apply(rawContext: unknown): void {
|
|
|
185
187
|
// 单装 dsh-router-codebuddy 时表永不出现,codebuddy 就一直不生效)。
|
|
186
188
|
ctx.provide('router.suppliers', {})
|
|
187
189
|
|
|
190
|
+
// ---- 扩展 (Ext):router.ext 注册表 + router.extStore 存储 ----
|
|
191
|
+
// 核心只做**管理面**:发现(注册表)、展示(面板/API)、代存开关与插件数据。
|
|
192
|
+
// 拦截 tools/execute / 裁决 enabled+ready / 短路执行全部归扩展插件自己 —— 见
|
|
193
|
+
// ext/contract.ts 文件头。同 router.suppliers 模式:核心持有空表,扩展插件经
|
|
194
|
+
// ctx.inject 追加进同一个 live 对象。
|
|
195
|
+
const exts: Record<string, unknown> = {}
|
|
196
|
+
ctx.provide('router.ext', exts)
|
|
197
|
+
// 开关 + 插件数据归核心持久化(<dataDir>/ext.json);插件经此 service 读写,不自己 file IO。
|
|
198
|
+
const extStore = new ExtStore(stateFile)
|
|
199
|
+
ctx.provide('router.extStore', extStore as unknown as ExtStoreService)
|
|
200
|
+
|
|
188
201
|
// 内置 + 用户 + 外部插件供应商(异步加载,完成后注册路由 + 加入路由器)
|
|
189
202
|
const registerLoaded = (loaded: LoadedSupplier): void => {
|
|
190
203
|
loadedSuppliers.push(loaded)
|
|
@@ -447,6 +460,56 @@ export function apply(rawContext: unknown): void {
|
|
|
447
460
|
writeJson(res, 200, { ok: true, requireApiKey: keys.requireApiKey })
|
|
448
461
|
})
|
|
449
462
|
|
|
463
|
+
// ---- 扩展 (Ext):扩展器列表 + 开关 ----
|
|
464
|
+
route(`${ROUTER_API_BASE}/ext`, async (req, res) => {
|
|
465
|
+
if (req.method === 'PATCH') {
|
|
466
|
+
let body: { id?: unknown; enabled?: unknown }
|
|
467
|
+
try {
|
|
468
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as { id?: unknown; enabled?: unknown }
|
|
469
|
+
} catch {
|
|
470
|
+
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
const ext = typeof body.id === 'string' ? exts[body.id] as RouterExtService[string] : undefined
|
|
474
|
+
if (!ext) {
|
|
475
|
+
writeJson(res, 404, { ok: false, error: 'extension not found' })
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
if (typeof body.enabled !== 'boolean') {
|
|
479
|
+
writeJson(res, 400, { ok: false, error: 'enabled must be a boolean' })
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
const wantOn = body.enabled
|
|
483
|
+
// 开启自检:不可用(ready !== true)的扩展**不能开启成功**——即使绕过面板
|
|
484
|
+
// 直连 API 也会被拒。由扩展器报告就绪状态,核心据此裁决。
|
|
485
|
+
if (wantOn && typeof ext.getState === 'function') {
|
|
486
|
+
const st = ext.getState()
|
|
487
|
+
if (st?.ready !== true) {
|
|
488
|
+
writeJson(res, 409, { ok: false, error: st?.detail ?? 'extension not ready' })
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// 开关归核心持久化。
|
|
493
|
+
extStore.setEnabled(ext.id, body.enabled)
|
|
494
|
+
}
|
|
495
|
+
const list: ExtInfo[] = Object.values(exts as RouterExtService)
|
|
496
|
+
.map((raw): ExtInfo | undefined => {
|
|
497
|
+
const e = raw as RouterExt
|
|
498
|
+
if (!e || typeof e.id !== 'string' || e.id === '') return undefined
|
|
499
|
+
const st = typeof e.getState === 'function' ? e.getState() : { ready: false }
|
|
500
|
+
return {
|
|
501
|
+
id: e.id,
|
|
502
|
+
name: e.name ?? e.id,
|
|
503
|
+
...(e.description !== undefined ? { description: e.description } : {}),
|
|
504
|
+
enabled: extStore.isEnabled(e.id),
|
|
505
|
+
ready: st?.ready === true,
|
|
506
|
+
...(st?.detail !== undefined ? { detail: st.detail } : {}),
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
.filter((e): e is ExtInfo => e !== undefined)
|
|
510
|
+
writeJson(res, 200, { ok: true, enhancers: list })
|
|
511
|
+
})
|
|
512
|
+
|
|
450
513
|
// ---- 概览看板:用量统计 ----
|
|
451
514
|
|
|
452
515
|
/** 解析并校验周期参数(不接受任意字符串)。 */
|
|
@@ -563,6 +626,14 @@ export function apply(rawContext: unknown): void {
|
|
|
563
626
|
|
|
564
627
|
ctx.effect(
|
|
565
628
|
() => () => {
|
|
629
|
+
// 注册表里的扩展器(插件自己 inject 的清理会摘键;这里兜底 dispose 仍在表里的)。
|
|
630
|
+
for (const raw of Object.values(exts as RouterExtService)) {
|
|
631
|
+
try {
|
|
632
|
+
;(raw as RouterExt)?.dispose?.()
|
|
633
|
+
} catch {
|
|
634
|
+
// 卸载清理失败不影响其他清理
|
|
635
|
+
}
|
|
636
|
+
}
|
|
566
637
|
for (const dispose of disposers.splice(0)) dispose()
|
|
567
638
|
router.dispose() // 内部会 flush 用量统计(防抖中的计数不能丢)
|
|
568
639
|
},
|
package/src/llm/adapter.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type LlmModelInfo,
|
|
18
18
|
type LlmReasoningEffortInfo,
|
|
19
19
|
type LlmResolvedModelInfo,
|
|
20
|
+
type ModelModality,
|
|
20
21
|
type StreamChunk,
|
|
21
22
|
} from '@deepseek-ai/dsh-llm'
|
|
22
23
|
import { mergeUsage, normalizeUsage, toTokenUsage, type UsageTokens } from '../router/usage-tokens.ts'
|
|
@@ -35,11 +36,16 @@ export interface RouterAdapterSource {
|
|
|
35
36
|
/**
|
|
36
37
|
* 把 DSH 消息序列化成 openai 兼容 wire 消息(纯文本 + tool)。
|
|
37
38
|
*
|
|
38
|
-
* `system` 必须由调用方从 `options.system` 传进来:
|
|
39
|
-
* 放在 **options.system 这个独立槽位**,不放进 messages
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
39
|
+
* `system` 必须由调用方从 `options.system` 传进来:one-shot 调用方把系统提示词
|
|
40
|
+
* 放在 **options.system 这个独立槽位**,不放进 messages。不读这个槽位,
|
|
41
|
+
* one-shot 调用方(如 compaction / session-title)的模型每一轮都拿不到身份/
|
|
42
|
+
* 规则/工具用法约束,且**不会报错**,只是行为悄悄降级。
|
|
43
|
+
*
|
|
44
|
+
* 0.1.5 起 loop-built 请求反过来:`options.system` 为 undefined,系统提示词作为
|
|
45
|
+
* **messages 里的 system-role 消息**下发(dsh-agent-loop 的 SystemProjection,
|
|
46
|
+
* 可出现在任意位置以支持提示词热更新)。两条路径都必须支持 —— 与官方
|
|
47
|
+
* dsh-llm-deepseek 一致(serializeRequest 前置 options.system,再按位置序列化
|
|
48
|
+
* messages 里的 system 消息)。
|
|
43
49
|
*/
|
|
44
50
|
function wireMessages(options: GenerateOptions, system?: string): Array<Record<string, unknown>> {
|
|
45
51
|
const out: Array<Record<string, unknown>> = []
|
|
@@ -396,6 +402,16 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
396
402
|
return out
|
|
397
403
|
}
|
|
398
404
|
|
|
405
|
+
/**
|
|
406
|
+
* 只收文本。`resolveModel` 声明 `inputModalities: ['text']` 是**必须**的:
|
|
407
|
+
* 0.1.5 的 runtime 只在 `inputModalities` 存在且不含 `image` 时才把 image 块
|
|
408
|
+
* 投影成占位文本(见 dsh-llm adapterStream 的 projectImagesForTextModel)。
|
|
409
|
+
* 不声明 = "unknown" = 不投影,而 wireMessages 只读 text 块 → 图片**静默
|
|
410
|
+
* 丢失**,模型看到一条没有图片的用户消息却毫无提示。声明后由 runtime 统一
|
|
411
|
+
* 投影,我们仍只处理文本。
|
|
412
|
+
*/
|
|
413
|
+
private static readonly INPUT_MODALITIES: readonly ModelModality[] = ['text']
|
|
414
|
+
|
|
399
415
|
async resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
|
400
416
|
const combos = await this.source.comboModels()
|
|
401
417
|
const found = combos.find((m) => m.id === model || m.name === model)
|
|
@@ -407,6 +423,7 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
407
423
|
provider: 'router',
|
|
408
424
|
id: model,
|
|
409
425
|
name,
|
|
426
|
+
inputModalities: RouterAdapter.INPUT_MODALITIES,
|
|
410
427
|
...(found?.contextWindow !== undefined ? { context: { contextWindow: found.contextWindow } } : {}),
|
|
411
428
|
reasoning: { efforts: ROUTER_REASONING_EFFORTS, defaultEffort: ReasoningEffortId('high') },
|
|
412
429
|
}
|
|
@@ -36,14 +36,257 @@ test('选号:某模型冷却中的号对该模型跳过,全冷却返回 unde
|
|
|
36
36
|
assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined)
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
-
test('round-robin
|
|
39
|
+
test('round-robin 是块轮询:块内粘住不换号(带 model)', () => {
|
|
40
40
|
const p = pool()
|
|
41
41
|
const list = accs(['a', 'b'])
|
|
42
|
+
// 块没满 → 一直粘在 a(这就是缓存命中的来源)
|
|
42
43
|
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
44
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
45
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// ============ 块轮询(docs/pool-sticky-block.md) ============
|
|
49
|
+
|
|
50
|
+
/** 连打 n 次成功请求,全部命中缓存。 */
|
|
51
|
+
const serveHits = (p: AccountPool, uid: string, n: number, model = 'm1'): void => {
|
|
52
|
+
for (let i = 0; i < n; i++) p.noteCache(uid, model, 1000)
|
|
53
|
+
}
|
|
54
|
+
/** 连打 n 次成功请求,全部未命中(cold miss)。 */
|
|
55
|
+
const serveMiss = (p: AccountPool, uid: string, n: number, model = 'm1'): void => {
|
|
56
|
+
for (let i = 0; i < n; i++) p.noteCache(uid, model, 0)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('【块】OR:计数<N(最短驻留)且没达标 → 留守,哪怕缓存已热', () => {
|
|
60
|
+
const p = pool()
|
|
61
|
+
const list = accs(['a', 'b'])
|
|
62
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
63
|
+
// 计数 15 < N(16),命中率 100% >= M → 留守(最短驻留没走完,不因热度提前跳)
|
|
64
|
+
serveHits(p, 'a', 15)
|
|
65
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('【块】OR:计数到 N 且命中率达标 → 换号(下一号也冷启动,让位均衡)', () => {
|
|
69
|
+
const p = pool()
|
|
70
|
+
const list = accs(['a', 'b'])
|
|
71
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
72
|
+
// 计数 16 >= N 且命中率 100% >= 85% → 换号
|
|
73
|
+
serveHits(p, 'a', 16)
|
|
74
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('【块】OR:命中率没达标 → 计数到 N 仍留守(继续热,不急换)', () => {
|
|
78
|
+
const p = pool()
|
|
79
|
+
const list = accs(['a', 'b'])
|
|
80
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
81
|
+
// 计数 20 全 miss → 命中率 0% < 85% → 留守(OR 语义:ratio<M 时留守)
|
|
82
|
+
serveMiss(p, 'a', 20)
|
|
83
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('【块】N_max 兜底:命中率一直不达标 → 到 N_max(48) 必换', () => {
|
|
87
|
+
const p = pool()
|
|
88
|
+
const list = accs(['a', 'b'])
|
|
89
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
90
|
+
// 一直 miss,命中率 0% 永不达标 → 不靠 N 换,靠 N_max 兜底
|
|
91
|
+
serveMiss(p, 'a', 48)
|
|
92
|
+
// 47 次后仍在 a;第 48 次触发 noteCache 的降权,随后 pick 跳过 a
|
|
93
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('【块】驻留满 N_max 仍不达标 → 该号降权,后续轮转跳过', () => {
|
|
97
|
+
const p = pool()
|
|
98
|
+
const list = accs(['a', 'b', 'c'])
|
|
99
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
100
|
+
serveMiss(p, 'a', 48) // a 判定缓存无效(降权)
|
|
101
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b') // 跳过 a
|
|
102
|
+
// b 正常服务并达标 → 切走后轮转也应跳过 a
|
|
103
|
+
serveHits(p, 'b', 16)
|
|
104
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'c')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('【块】降权不是永久判决:全池都降权时退回健康池兜底', () => {
|
|
108
|
+
const p = pool()
|
|
109
|
+
const list = accs(['a', 'b'])
|
|
110
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
111
|
+
serveMiss(p, 'a', 48)
|
|
112
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
|
|
113
|
+
serveMiss(p, 'b', 48)
|
|
114
|
+
// 两个都降权 → 不能返回 undefined(那等于服务挂了),退回健康池
|
|
115
|
+
const uid = p.pick(list, [], 'round-robin', 'm1')
|
|
116
|
+
assert.equal(uid !== undefined, true)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('【块】故障切走作废块:dropBlock 后重新选号', () => {
|
|
120
|
+
const p = pool()
|
|
121
|
+
const list = accs(['a', 'b'])
|
|
122
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
123
|
+
serveHits(p, 'a', 3) // 块才 3 次,本来会留守
|
|
124
|
+
p.dropBlock('m1')
|
|
125
|
+
// 块作废 → 从 a 之后前进,选到 b(而不是带着旧计数继续粘 a)
|
|
126
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('【块】失败请求不入块统计(失败没产生缓存,计入会污染命中率)', () => {
|
|
130
|
+
const p = pool()
|
|
131
|
+
const list = accs(['a', 'b'])
|
|
132
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
133
|
+
// 16 次全命中 → 达标切走(失败不算,否则比例被稀释永不达标)
|
|
134
|
+
serveHits(p, 'a', 16)
|
|
135
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('【块】块状态按模型隔离:m1 的驻留不影响 m2', () => {
|
|
139
|
+
const p = pool()
|
|
140
|
+
const list = accs(['a', 'b'])
|
|
141
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
142
|
+
serveHits(p, 'a', 16) // m1 上 a 已达标
|
|
43
143
|
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
|
|
144
|
+
// m2 是独立的块 → 从头开始,选 a
|
|
145
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm2'), 'a')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('【块】多号间长期均衡:块大小有界 → 请求数均分', () => {
|
|
149
|
+
const p = pool()
|
|
150
|
+
const list = accs(['a', 'b', 'c'])
|
|
151
|
+
const count: Record<string, number> = { a: 0, b: 0, c: 0 }
|
|
152
|
+
// 每个块 16 次(全命中达标),跑 12 个块
|
|
153
|
+
for (let block = 0; block < 12; block++) {
|
|
154
|
+
const uid = p.pick(list, [], 'round-robin', 'm1')!
|
|
155
|
+
count[uid]! += 1
|
|
156
|
+
serveHits(p, uid, 16)
|
|
157
|
+
}
|
|
158
|
+
// 12 块 / 3 号 → 每号 4 块
|
|
159
|
+
assert.equal(count['a'], 4)
|
|
160
|
+
assert.equal(count['b'], 4)
|
|
161
|
+
assert.equal(count['c'], 4)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// ============ 前缀亲和(docs/pool-sticky-block.md §8 的根治) ============
|
|
165
|
+
|
|
166
|
+
/** 构造一次请求的 messages(前缀 = system + 首条用户消息)。 */
|
|
167
|
+
const msgs = (sessionSeed: string, turns = 1): unknown => [
|
|
168
|
+
{ role: 'system', content: `shared agent prompt` },
|
|
169
|
+
{ role: 'user', content: `${sessionSeed} ${'x'.repeat(40)}` },
|
|
170
|
+
...Array.from({ length: turns - 1 }, () => ({ role: 'assistant', content: 'ok' })),
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
test('【亲和】同会话粘同一号:指纹相同 → 每次都返回同一个号', () => {
|
|
174
|
+
const p = pool()
|
|
175
|
+
const list = accs(['a', 'b', 'c'])
|
|
176
|
+
const m = msgs('session-1')
|
|
177
|
+
const first = p.pick(list, [], 'round-robin', 'm1', m)
|
|
178
|
+
// 连续多轮(会话增长,但前缀不变)→ 永远是第一个号
|
|
179
|
+
for (let i = 0; i < 10; i++) {
|
|
180
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('session-1', i + 2)), first)
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('【亲和】不同会话散到不同号:新指纹按游标分发', () => {
|
|
185
|
+
const p = pool()
|
|
186
|
+
const list = accs(['a', 'b'])
|
|
187
|
+
const s1 = p.pick(list, [], 'round-robin', 'm1', msgs('s1'))
|
|
188
|
+
const s2 = p.pick(list, [], 'round-robin', 'm1', msgs('s2'))
|
|
189
|
+
assert.notEqual(s1, s2, '两个会话应落到不同的号(缓存空间隔离)')
|
|
190
|
+
// 各自回访仍粘各自的号
|
|
191
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 5)), s1)
|
|
192
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s2', 5)), s2)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('【亲和】会话增长指纹稳定:前缀相同、轮数不同 → 同一号', () => {
|
|
196
|
+
const p = pool()
|
|
197
|
+
const list = accs(['a', 'b'])
|
|
198
|
+
const s1 = p.pick(list, [], 'round-robin', 'm1', msgs('s1'))
|
|
199
|
+
// 真实会话每轮 messages 变长,但前 3 条不变 → 指纹不变
|
|
200
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 30)), s1)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('【亲和】绑定的号冷却 → 会话让位,换健康号', () => {
|
|
204
|
+
const p = pool()
|
|
205
|
+
const list = accs(['a', 'b'])
|
|
206
|
+
const m = msgs('s1')
|
|
207
|
+
const first = p.pick(list, [], 'round-robin', 'm1', m)!
|
|
208
|
+
p.noteFailure(first, 'm1', 'rate_limit', '429')
|
|
209
|
+
// 绑定的号不可用 → 不能返回它(挂了),应落到别的号
|
|
210
|
+
const second = p.pick(list, [], 'round-robin', 'm1', m)
|
|
211
|
+
assert.notEqual(second, first)
|
|
212
|
+
// 且之后粘在新号上(绑定已更新)
|
|
213
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 9)), second)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('【亲和】绑定的号降权(缓存无效)→ 跳过它', () => {
|
|
217
|
+
const p = pool()
|
|
218
|
+
const list = accs(['a', 'b'])
|
|
219
|
+
const m = msgs('s1')
|
|
220
|
+
const first = p.pick(list, [], 'round-robin', 'm1', m)!
|
|
221
|
+
// 该号累计 48 次全 miss → 判定缓存无效
|
|
222
|
+
serveMiss(p, first, 48)
|
|
223
|
+
const second = p.pick(list, [], 'round-robin', 'm1', m)
|
|
224
|
+
assert.notEqual(second, first, '降权中的号不应被亲和继续选中')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('【亲和】messages 拿不到 → 回退块轮询(旧行为不破坏)', () => {
|
|
228
|
+
const p = pool()
|
|
229
|
+
const list = accs(['a', 'b'])
|
|
230
|
+
// 不传 messages → 走块驻留
|
|
231
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
44
232
|
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
233
|
+
serveHits(p, 'a', 16)
|
|
234
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
|
|
235
|
+
// 传了但算不出指纹(空/坏结构)→ 同样回退
|
|
236
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm2', []), 'a')
|
|
237
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm2', [{ nope: 1 }]), 'a')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('【亲和】绑定表按模型隔离:冷却是 (model, uid) 粒度,不串模型', () => {
|
|
241
|
+
const p = pool()
|
|
242
|
+
const list = accs(['a', 'b'])
|
|
243
|
+
const m = msgs('s1')
|
|
244
|
+
const m1 = p.pick(list, [], 'round-robin', 'm1', m)
|
|
245
|
+
// rrCursor 跨模型共享(与旧块轮询一致):m1 的新会话消耗 a 后,m2 的新会话分到 b
|
|
246
|
+
const m2 = p.pick(list, [], 'round-robin', 'm2', m)
|
|
247
|
+
assert.equal(m1, 'a')
|
|
248
|
+
assert.equal(m2, 'b')
|
|
249
|
+
// 各自回访粘各自的号(绑定表按模型分开,m1 的 a 不会盖掉 m2 的 b)
|
|
250
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 9)), 'a')
|
|
251
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm2', msgs('s1', 9)), 'b')
|
|
252
|
+
p.noteFailure('a', 'm1', 'rate_limit', '429') // 只冷 (m1, a)
|
|
253
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', m), 'b', 'm1 上让位')
|
|
254
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm2', m), 'b', 'm2 不受影响,仍粘 b')
|
|
45
255
|
})
|
|
46
256
|
|
|
257
|
+
test('【亲和】绑定表 LRU 封顶:超上限不泄漏,旧指纹被挤掉', () => {
|
|
258
|
+
const p = pool()
|
|
259
|
+
const list = accs(['a', 'b', 'c'])
|
|
260
|
+
const first = p.pick(list, [], 'round-robin', 'm1', msgs('s0'))!
|
|
261
|
+
// 灌 600 个新会话(> AFFINITY_MAX=512)→ 最老的 s0 被挤出
|
|
262
|
+
for (let i = 0; i < 600; i++) p.pick(list, [], 'round-robin', 'm1', msgs(`flood-${i}`))
|
|
263
|
+
const again = p.pick(list, [], 'round-robin', 'm1', msgs('s0'))
|
|
264
|
+
// 被挤掉后重新分发——只要不是内存无限增长即可(uid 合法性由 pool 保证)
|
|
265
|
+
assert.equal(list.some((a) => a.uid === again), true)
|
|
266
|
+
// 活跃会话(每次都访问)不被挤掉
|
|
267
|
+
const hot = p.pick(list, [], 'round-robin', 'm1', msgs('hot'))!
|
|
268
|
+
for (let i = 0; i < 600; i++) {
|
|
269
|
+
p.pick(list, [], 'round-robin', 'm1', msgs(`flood2-${i}`))
|
|
270
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('hot')), hot, '活跃会话的绑定应被 LRU 刷新保留')
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('【亲和】多模态分片 content 也能算指纹(文本片段参与,base64 不炸)', () => {
|
|
275
|
+
const p = pool()
|
|
276
|
+
const list = accs(['a', 'b'])
|
|
277
|
+
const multi = [
|
|
278
|
+
{ role: 'user', content: [{ type: 'text', text: 'look at this' }, { type: 'image_url', image_url: 'data:image/png;base64,AAAA' }] },
|
|
279
|
+
]
|
|
280
|
+
const first = p.pick(list, [], 'round-robin', 'm1', multi)
|
|
281
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', multi), first)
|
|
282
|
+
// 文本不同的另一个会话 → 不同指纹
|
|
283
|
+
const other = [
|
|
284
|
+
{ role: 'user', content: [{ type: 'text', text: 'different' }] },
|
|
285
|
+
]
|
|
286
|
+
assert.notEqual(p.pick(list, [], 'round-robin', 'm1', other), first, '文本不同应散开')
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
|
|
47
290
|
// ============ 核心新语义:冷却按 (模型, 连接) 分号 ============
|
|
48
291
|
|
|
49
292
|
test('【颗粒度】连接在模型 A 失败,只冷 (A, 该连接),不影响它调模型 B', () => {
|