dsh-agentic-router 1.4.2 → 1.6.0
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 +12 -0
- package/docs/DESIGN.md +43 -0
- package/dsh/index.js +161 -32
- package/dsh/strategies.js +62 -10
- package/package.json +2 -2
- package/dsh/package.json +0 -43
- package/dsh/plugin.test.js +0 -405
- package/dsh/strategies.test.js +0 -218
package/README.md
CHANGED
|
@@ -14,6 +14,18 @@ DeepSeek Harness(DSH)插件:**学习型智能路由(agentic router)+
|
|
|
14
14
|
向会话日志追加 `request/header` 事件(reason: `router`),走 DSH 官方模型选择优先级链
|
|
15
15
|
(本进程选择 → 会话日志最新 header → 默认模型)真实切换。
|
|
16
16
|
|
|
17
|
+
**v1.5.0 步骤级路由**:在回合级基线之上,`agent/pre-step` 按**上一步的工具调用**做
|
|
18
|
+
步骤级调整——重型工具(写代码/执行/子 agent 等,见 `HEAVY_TOOLS`)→ 下一步升级 strong
|
|
19
|
+
(pro);轻量/纯文本 → 回到回合基线档。实现"简单步骤 flash、复杂步骤 pro"的近似形态
|
|
20
|
+
(步骤切换对下一步生效,一阶延迟)。步骤级决策落盘 `steps.jsonl`,`agentic_router_stats`
|
|
21
|
+
可见 `stepSwitches` 与 `stepLog`。
|
|
22
|
+
|
|
23
|
+
**v1.6.0 跨 provider 与修复**:`resolveTier` 支持跨 provider 解析——优先当前 provider 同档候选,
|
|
24
|
+
无候选时按 providers 顺序取其它 provider 同档模型(mid 档再回退 strong/fast),`request/header`
|
|
25
|
+
同时写入目标 `provider` 与 `model`;每个会话首回合(尚无 header/provider)延迟到首个请求拿到真实
|
|
26
|
+
provider 后再决策应用;`agentic_router_force` 钉死模型、步骤级路由不再覆盖;内置价表补齐
|
|
27
|
+
`deepseek-account`,并在加载时与旧 `policy.json` 价格合并。可用配置 `crossProvider: false` 关闭跨 provider。
|
|
28
|
+
|
|
17
29
|
## 架构
|
|
18
30
|
|
|
19
31
|
```
|
package/docs/DESIGN.md
CHANGED
|
@@ -226,3 +226,46 @@ dsh-agentic-router/
|
|
|
226
226
|
- 离线回放:拿历史 session 日志重放,对比 shadow 路由与实际路由的评分分布
|
|
227
227
|
- 影子期 A/B:shadow 评分 vs 默认路由评分,达标才切 active
|
|
228
228
|
- 集成冒烟:mock ctx 注入 agent/request payload,验证 LlmCallConfig 替换与 next() 透传
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## 13. v1.5.0 步骤级路由设计(已实现)
|
|
233
|
+
|
|
234
|
+
### 13.1 动机
|
|
235
|
+
|
|
236
|
+
回合级路由(v1.4.x)每回合决策一次、整回合同一模型。用户期望"单任务内简单步骤用 flash、复杂步骤用 pro"。步骤级路由在回合级基线之上做**按步骤升降档**。
|
|
237
|
+
|
|
238
|
+
### 13.2 时序依据(为什么对下一步生效)
|
|
239
|
+
|
|
240
|
+
loop 每步顺序:`inbox.claim()`(发 `agent/inbox/claimed`)→ `systemPrompt.assemble()`(快照模型)→ `agent/pre-step` waterfall → `buildRequest`。因此:
|
|
241
|
+
|
|
242
|
+
- **inbox 决策**在组装之前 → 回合第一步即生效(零延迟,v1.4.x 主路径)
|
|
243
|
+
- **pre-step 决策**在组装之后 → 追加 header 对**下一步**生效(一阶延迟,设计如此)
|
|
244
|
+
|
|
245
|
+
### 13.3 信号:上一步的工具调用
|
|
246
|
+
|
|
247
|
+
`session.deriveMessages()` 取最后一条 assistant 消息的 `tool-call` 块(只读叶子字段):
|
|
248
|
+
|
|
249
|
+
| 上一步工具 | 下一步档位 |
|
|
250
|
+
|---|---|
|
|
251
|
+
| 重型:bash/pwsh/write/edit/batch_edit/run_code/workflow/subagent/subagent_fork/terminal/pty/mcp | **strong(pro)** |
|
|
252
|
+
| 轻量:read/glob/grep 等 | 回合基线档 |
|
|
253
|
+
| 无工具(纯文本,通常回合收尾) | 保持现状 |
|
|
254
|
+
|
|
255
|
+
### 13.4 决策与落盘
|
|
256
|
+
|
|
257
|
+
- 步骤档位 → `resolveTier` → 目标模型 ≠ 当前模型 → 追加 `request/header`(reason: `router-step`)
|
|
258
|
+
- 每步一条 `steps.jsonl`:`{turn, step, lastTools, stepTier, target, current, applied}`
|
|
259
|
+
- stats 暴露 `stepSwitches` 与 `stepLog`(最近 20 条)
|
|
260
|
+
|
|
261
|
+
### 13.5 边界与后续
|
|
262
|
+
|
|
263
|
+
- 奖励飞轮仍按回合结算(回合基线的策略选择),步骤级是确定性规则;未来可把步骤级升降档纳入学习(如按工具调用频率学阈值)
|
|
264
|
+
- 重型集合 `HEAVY_TOOLS` 是静态清单,可配置化
|
|
265
|
+
- 降级方向:重型步骤后的轻量步骤会回到基线(简单任务场景:写代码阶段 pro、前后问答 flash)
|
|
266
|
+
|
|
267
|
+
### 13.6 v1.6.0:跨 provider 解析、首回合延迟与 force 钉死
|
|
268
|
+
|
|
269
|
+
- `resolveTier` 解析顺序:同 provider 同档 → 其它 provider 同档(`crossProvider`,默认开)→ mid 回退(同 provider strong/fast → 跨 provider strong/fast);`appendRouterHeader` 同步写 `config.provider` 与 `config.model`
|
|
270
|
+
- 首回合(`currentProvider` 为空)在 inbox 只挂起,等首个 `agent/request` 拿到真实 provider 后解析并落盘,再于 `llm/stream` 延迟应用
|
|
271
|
+
- `agentic_router_force` 期间跳过步骤级升降档(force 钉死模型);force 支持 `model` 或 `provider/model`
|
package/dsh/index.js
CHANGED
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
|
|
23
23
|
import { classify, featuresOf } from './classify.js'
|
|
24
24
|
import {
|
|
25
|
-
tierOf, tierTarget, resolveTier,
|
|
25
|
+
tierOf, tierTarget, tierHasAny, resolveTier,
|
|
26
26
|
recommendRule, recommendUcb, recommendLin, recommendKnn,
|
|
27
27
|
rewardOf, applyReward, correctReward,
|
|
28
|
-
DEFAULT_PRICES, turnCostYuan, resolvePrice,
|
|
28
|
+
DEFAULT_PRICES, turnCostYuan, resolvePrice, mergePrices,
|
|
29
29
|
} from './strategies.js'
|
|
30
30
|
import { metaPick, DEFAULT_WEIGHTS } from './meta.js'
|
|
31
31
|
import { Store, defaultHome } from './store.js'
|
|
@@ -56,10 +56,15 @@ function fallbackTiers() {
|
|
|
56
56
|
|
|
57
57
|
// 当前 provider 有候选模型的档位列表(UCB/LinUCB/k-NN 只在这些档位间探索)。
|
|
58
58
|
// provider 缺失或档位表不可用时回退到全部档位(保持专家函数原有行为)。
|
|
59
|
-
function availableTiersFor(provider, modelTiers) {
|
|
59
|
+
function availableTiersFor(provider, modelTiers, crossProvider) {
|
|
60
60
|
const all = ['fast', 'mid', 'strong']
|
|
61
|
-
if (!modelTiers
|
|
62
|
-
|
|
61
|
+
if (!modelTiers) return all
|
|
62
|
+
if (provider && !crossProvider) {
|
|
63
|
+
const has = all.filter((t) => tierTarget(t, provider, modelTiers))
|
|
64
|
+
return has.length ? has : all
|
|
65
|
+
}
|
|
66
|
+
// 跨 provider(或无 provider):只看档位是否有候选(跨所有 provider),避免探索"幽灵档"
|
|
67
|
+
const has = all.filter((t) => tierHasAny(t, modelTiers))
|
|
63
68
|
return has.length ? has : all
|
|
64
69
|
}
|
|
65
70
|
|
|
@@ -69,6 +74,7 @@ function freshState() {
|
|
|
69
74
|
seq: 0,
|
|
70
75
|
decisions: [],
|
|
71
76
|
forced: null,
|
|
77
|
+
crossProvider: true,
|
|
72
78
|
pending: new Map(),
|
|
73
79
|
modelTiers: fallbackTiers(),
|
|
74
80
|
turnDecision: {},
|
|
@@ -77,6 +83,8 @@ function freshState() {
|
|
|
77
83
|
recByTurn: new Map(),
|
|
78
84
|
headerAppends: 0,
|
|
79
85
|
lastActual: null,
|
|
86
|
+
stepLog: [],
|
|
87
|
+
counters: { stepSwitches: 0 },
|
|
80
88
|
ucb: { total: 0, arms: {} },
|
|
81
89
|
lin: {
|
|
82
90
|
total: 0,
|
|
@@ -188,10 +196,10 @@ function currentProvider(session) {
|
|
|
188
196
|
return c && typeof c.provider === 'string' ? c.provider : ''
|
|
189
197
|
}
|
|
190
198
|
|
|
191
|
-
// 核心切换:向会话日志追加 request/header(reason: 'router'),
|
|
199
|
+
// 核心切换:向会话日志追加 request/header(reason: 'router' / 'router-step'),
|
|
192
200
|
// 下一轮 prompt 组装即读到新模型。fast/mid 档去掉 reasoningEffort,
|
|
193
201
|
// 让目标模型走自身默认 effort(避免 flash 不支持 max 而整步失败)。
|
|
194
|
-
function appendRouterHeader(session, targetModel) {
|
|
202
|
+
function appendRouterHeader(session, targetModel, reason = 'router', targetProvider = null) {
|
|
195
203
|
try {
|
|
196
204
|
const last = session.requestHeader()
|
|
197
205
|
const header = {}
|
|
@@ -202,15 +210,51 @@ function appendRouterHeader(session, targetModel) {
|
|
|
202
210
|
const config = {}
|
|
203
211
|
for (const k of Object.keys(prevConfig)) config[k] = prevConfig[k]
|
|
204
212
|
config.model = targetModel
|
|
213
|
+
if (targetProvider && config.provider !== targetProvider) config.provider = targetProvider
|
|
205
214
|
if (tierOf(targetModel) !== 'strong') delete config.reasoningEffort
|
|
206
215
|
header.config = config
|
|
207
|
-
session.append('request/header', { header, reason
|
|
216
|
+
session.append('request/header', { header, reason })
|
|
208
217
|
return true
|
|
209
218
|
} catch (e) {
|
|
210
219
|
return false
|
|
211
220
|
}
|
|
212
221
|
}
|
|
213
222
|
|
|
223
|
+
// 解析强制模型:支持 'model' 或 'provider/model'(跨厂商强制时需指定 provider)
|
|
224
|
+
function parseForced(forced) {
|
|
225
|
+
if (typeof forced !== 'string' || !forced) return { provider: null, model: forced || null }
|
|
226
|
+
const i = forced.indexOf('/')
|
|
227
|
+
if (i > 0) return { provider: forced.slice(0, i), model: forced.slice(i + 1) }
|
|
228
|
+
return { provider: null, model: forced }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 步骤级路由(v1.5.0):视为"重型工作"的工具调用——上一步出现它们时,
|
|
232
|
+
// 下一步升级到 strong(pro)处理其结果/继续执行;轻量工具保持回合基线。
|
|
233
|
+
const HEAVY_TOOLS = new Set([
|
|
234
|
+
'bash', 'pwsh', 'write', 'edit', 'batch_edit', 'run_code', 'workflow',
|
|
235
|
+
'subagent', 'subagent_fork', 'terminal', 'pty', 'mcp',
|
|
236
|
+
])
|
|
237
|
+
|
|
238
|
+
// 读取会话里最后一条 assistant 消息的工具调用名(只取叶子字段,不碰活数据)。
|
|
239
|
+
function lastToolCalls(session) {
|
|
240
|
+
try {
|
|
241
|
+
const msgs = typeof session.deriveMessages === 'function' ? session.deriveMessages() : null
|
|
242
|
+
if (!Array.isArray(msgs)) return []
|
|
243
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
244
|
+
const m = msgs[i]
|
|
245
|
+
if (!m || typeof m !== 'object' || m.role !== 'assistant') continue
|
|
246
|
+
const blocks = m.content
|
|
247
|
+
if (!Array.isArray(blocks)) return []
|
|
248
|
+
const out = []
|
|
249
|
+
for (const b of blocks) {
|
|
250
|
+
if (b && typeof b === 'object' && b.type === 'tool-call' && typeof b.name === 'string') out.push(b.name)
|
|
251
|
+
}
|
|
252
|
+
return out
|
|
253
|
+
}
|
|
254
|
+
} catch (e) { /* read-only observation */ }
|
|
255
|
+
return []
|
|
256
|
+
}
|
|
257
|
+
|
|
214
258
|
async function refreshModelTiers(llm) {
|
|
215
259
|
if (!llm || typeof llm.listProviders !== 'function') return null
|
|
216
260
|
const providers = await llm.listProviders()
|
|
@@ -235,12 +279,15 @@ export function apply(ctx, config = {}) {
|
|
|
235
279
|
const store = new Store(config.home || defaultHome())
|
|
236
280
|
const settleDelayMs = typeof config.settleDelayMs === 'number' ? config.settleDelayMs : SETTLE_DELAY_MS
|
|
237
281
|
const policy = store.loadPolicy()
|
|
238
|
-
|
|
282
|
+
const overlay = (policy.prices && Object.keys(policy.prices).length) ? policy.prices : (config.prices || {})
|
|
283
|
+
let prices = mergePrices(DEFAULT_PRICES, overlay)
|
|
239
284
|
const costWeight = typeof config.costWeight === 'number' ? config.costWeight : 20
|
|
240
285
|
const costCap = typeof config.costCap === 'number' ? config.costCap : 0.5
|
|
286
|
+
const crossProvider = config.crossProvider !== false
|
|
241
287
|
|
|
242
288
|
const state = freshState()
|
|
243
289
|
state.mode = policy.mode || config.mode || 'shadow'
|
|
290
|
+
state.crossProvider = crossProvider
|
|
244
291
|
if (typeof policy.forced === 'string') state.forced = policy.forced
|
|
245
292
|
|
|
246
293
|
// 飞轮恢复:重放 rewards.jsonl 重建 UCB/LinUCB/k-NN/EXP3;
|
|
@@ -321,7 +368,7 @@ export function apply(ctx, config = {}) {
|
|
|
321
368
|
const f = featuresOf(cls, text)
|
|
322
369
|
// 只探索当前 provider 有候选模型的档位(跳过无候选的"幽灵档",
|
|
323
370
|
// 如 mid 无模型时探索 mid 只会白费并回退到最贵的 strong)
|
|
324
|
-
const available = availableTiersFor(provider, state.modelTiers)
|
|
371
|
+
const available = availableTiersFor(provider, state.modelTiers, state.crossProvider)
|
|
325
372
|
const recs = {
|
|
326
373
|
rule: recommendRule(cls),
|
|
327
374
|
ucb: recommendUcb(cls, state.ucb, available),
|
|
@@ -336,17 +383,24 @@ export function apply(ctx, config = {}) {
|
|
|
336
383
|
}
|
|
337
384
|
const meta = metaPick(recs, exp3)
|
|
338
385
|
const chosenRec = meta.byName[meta.chosen] || recs.rule
|
|
339
|
-
|
|
340
|
-
? { target:
|
|
341
|
-
: resolveTier(chosenRec.tier, provider, state.modelTiers)
|
|
386
|
+
let resolved = state.forced
|
|
387
|
+
? (() => { const pf = parseForced(state.forced); return { target: pf.model, provider: pf.provider, used: 'forced', note: 'forced' } })()
|
|
388
|
+
: resolveTier(chosenRec.tier, provider, state.modelTiers, { crossProvider: state.crossProvider })
|
|
389
|
+
// 首回合/无 provider:active 模式下等首个 request 拿到真实 provider 后再解析、应用与落盘
|
|
390
|
+
const defer = state.mode === 'active' && !provider && !state.forced
|
|
391
|
+
if (defer) resolved = { target: null, provider: null, used: 'pending', note: 'provider-unknown' }
|
|
342
392
|
const target = resolved.target
|
|
393
|
+
const targetProvider = resolved.provider || null
|
|
343
394
|
state.seq += 1
|
|
344
395
|
const td = {
|
|
345
396
|
clsType: cls.type, f, recs, meta, chosen: meta.chosen,
|
|
346
397
|
finalized: false, t0: now(), toolErrors: 0, retries: 0,
|
|
347
398
|
currentModel: current, provider, costWeight, costCap,
|
|
348
399
|
estInputTokens: Math.ceil(text.length / 1.5),
|
|
349
|
-
target, applied: false, needsApply: false, actualModel: current,
|
|
400
|
+
target, targetProvider, applied: false, needsApply: false, actualModel: current,
|
|
401
|
+
awaitProvider: defer, recordCommitted: false,
|
|
402
|
+
// 回合级基线(供步骤级路由 v1.5.0 参考:无重型工具时保持该档)
|
|
403
|
+
turnTier: target ? tierOf(target) : null, turnTarget: target,
|
|
350
404
|
}
|
|
351
405
|
const record = {
|
|
352
406
|
seq: state.seq,
|
|
@@ -367,6 +421,8 @@ export function apply(ctx, config = {}) {
|
|
|
367
421
|
metaPool: meta.pool,
|
|
368
422
|
resolvedTier: resolved.used + (resolved.note ? ' [' + resolved.note + ']' : ''),
|
|
369
423
|
targetModel: state.mode === 'active' ? target : null,
|
|
424
|
+
targetProvider: state.mode === 'active' ? targetProvider : null,
|
|
425
|
+
deferred: defer,
|
|
370
426
|
applied: false,
|
|
371
427
|
appliedAt: source,
|
|
372
428
|
swapped: false,
|
|
@@ -378,10 +434,11 @@ export function apply(ctx, config = {}) {
|
|
|
378
434
|
}
|
|
379
435
|
|
|
380
436
|
// 决策记录落盘(在 applyDecision 之后调用,保证 applied/swapped 已是最终值)
|
|
381
|
-
function commitRecord(record) {
|
|
437
|
+
function commitRecord(record, td) {
|
|
382
438
|
store.appendJsonl('decisions.jsonl', record)
|
|
383
439
|
state.decisions.push(record)
|
|
384
440
|
if (state.decisions.length > 200) state.decisions.shift()
|
|
441
|
+
if (td) td.recordCommitted = true
|
|
385
442
|
}
|
|
386
443
|
|
|
387
444
|
// 应用切换:active 模式且目标与当前不同 → 追加 request/header。
|
|
@@ -390,7 +447,7 @@ export function apply(ctx, config = {}) {
|
|
|
390
447
|
if (state.mode !== 'active' || !target) return false
|
|
391
448
|
const base = td.actualModel || td.currentModel
|
|
392
449
|
if (base && target === base) return false
|
|
393
|
-
const ok = session ? appendRouterHeader(session, target) : false
|
|
450
|
+
const ok = session ? appendRouterHeader(session, target, 'router', td.targetProvider || null) : false
|
|
394
451
|
td.applied = ok
|
|
395
452
|
if (record) { record.applied = ok; record.swapped = ok }
|
|
396
453
|
if (ok) {
|
|
@@ -413,7 +470,7 @@ export function apply(ctx, config = {}) {
|
|
|
413
470
|
const r = decideAndRecord(payload, text, current, provider, 'inbox')
|
|
414
471
|
if (!r) return
|
|
415
472
|
applyDecision(session, r.key, r.td, r.record, r.target)
|
|
416
|
-
commitRecord(r.record)
|
|
473
|
+
if (!r.td.awaitProvider) commitRecord(r.record, r.td)
|
|
417
474
|
log('decision #' + state.seq + ' type=' + r.td.clsType + ' c=' + r.td.f[1] +
|
|
418
475
|
' meta=' + r.td.chosen + ' tier=' + r.record.resolvedTier +
|
|
419
476
|
' applied=' + r.td.applied + ' target=' + (r.target || '-'))
|
|
@@ -429,6 +486,40 @@ export function apply(ctx, config = {}) {
|
|
|
429
486
|
} catch (e) {
|
|
430
487
|
log('pre-step capture failed: ' + String(e))
|
|
431
488
|
}
|
|
489
|
+
// ---- 步骤级路由(v1.5.0)----
|
|
490
|
+
// pre-step 在组装之后触发 → 此处追加 header 对下一步生效(一阶延迟,设计如此)。
|
|
491
|
+
// 信号:上一步 assistant 消息的工具调用。重型工具(写代码/执行/子agent)→ 下一步
|
|
492
|
+
// 升级 strong(pro);轻量/无工具 → 回到回合基线档。
|
|
493
|
+
try {
|
|
494
|
+
const session = sessionOf(payload)
|
|
495
|
+
if (!session) return next()
|
|
496
|
+
const key = fullKey(payload)
|
|
497
|
+
const td = state.turnDecision[key]
|
|
498
|
+
if (!td || td.finalized) return next()
|
|
499
|
+
if (state.forced) return next() // 强制模型时跳过步骤级升降档(force 钉死模型)
|
|
500
|
+
const tools = lastToolCalls(session)
|
|
501
|
+
if (!tools.length) return next() // 上一步是纯文本(通常回合收尾),保持现状
|
|
502
|
+
const heavy = tools.some((n) => HEAVY_TOOLS.has(n))
|
|
503
|
+
const stepTier = heavy ? 'strong' : (td.turnTier || null)
|
|
504
|
+
if (!stepTier) return next()
|
|
505
|
+
const provider = td.provider || currentProvider(session) || ''
|
|
506
|
+
const resolved = resolveTier(stepTier, provider, state.modelTiers, { crossProvider: state.crossProvider })
|
|
507
|
+
const target = resolved.target
|
|
508
|
+
const targetProvider = resolved.provider || null
|
|
509
|
+
const cur = currentModel(session)
|
|
510
|
+
const changed = target && target !== cur
|
|
511
|
+
// 注意:pre-step 在组装之后、buildRequest(loop 追加自己的 header)之前触发。
|
|
512
|
+
// 这里直接追加会被本步 buildRequest 的 change(旧模型) 覆盖(与 v1.4.0 冷启动同款竞态)。
|
|
513
|
+
// 因此只决策并挂起,真正追加推迟到 llm/stream 收尾(此时 loop 的 header 已落盘)。
|
|
514
|
+
if (changed) { td.stepTarget = target; td.stepTargetProvider = targetProvider }
|
|
515
|
+
const stepRec = { turn: key, step: payload.step, lastTools: tools.slice(0, 5), stepTier, target, provider: targetProvider, current: cur, applied: false, pending: changed, ts: Date.now() }
|
|
516
|
+
if (changed) td.stepRec = stepRec
|
|
517
|
+
state.stepLog.push(stepRec)
|
|
518
|
+
if (state.stepLog.length > 30) state.stepLog.shift()
|
|
519
|
+
try { store.appendJsonl('steps.jsonl', stepRec) } catch (e) { /* 落盘尽力而为 */ }
|
|
520
|
+
} catch (e) {
|
|
521
|
+
log('step routing failed: ' + String(e))
|
|
522
|
+
}
|
|
432
523
|
return next()
|
|
433
524
|
})
|
|
434
525
|
|
|
@@ -442,22 +533,43 @@ export function apply(ctx, config = {}) {
|
|
|
442
533
|
if (!timer || typeof timer.interval !== 'function') settleFeedback().catch(() => {})
|
|
443
534
|
const key = fullKey(payload)
|
|
444
535
|
const model = typeof cfg.model === 'string' ? cfg.model : null
|
|
536
|
+
const provider = typeof cfg.provider === 'string' ? cfg.provider : ''
|
|
445
537
|
const td = state.turnDecision[key]
|
|
446
538
|
if (td) {
|
|
447
539
|
if (model) {
|
|
448
540
|
td.actualModel = model
|
|
449
541
|
state.lastActual = { model, turn: payload.turn, step: payload.step, key }
|
|
450
542
|
}
|
|
543
|
+
if (provider) td.provider = provider
|
|
544
|
+
if (td.awaitProvider && !td.finalized) {
|
|
545
|
+
const chosenRec = (td.recs && td.recs[td.chosen]) || td.recs.rule
|
|
546
|
+
let resolved = state.forced
|
|
547
|
+
? (() => { const pf = parseForced(state.forced); return { target: pf.model, provider: pf.provider, used: 'forced', note: 'forced' } })()
|
|
548
|
+
: resolveTier(chosenRec.tier, provider, state.modelTiers, { crossProvider: state.crossProvider })
|
|
549
|
+
td.target = resolved.target
|
|
550
|
+
td.targetProvider = resolved.provider || null
|
|
551
|
+
td.turnTier = td.target ? tierOf(td.target) : null
|
|
552
|
+
td.turnTarget = td.target
|
|
553
|
+
td.awaitProvider = false
|
|
554
|
+
if (td.rec) {
|
|
555
|
+
td.rec.resolvedTier = resolved.used + (resolved.note ? ' [' + resolved.note + ']' : '')
|
|
556
|
+
td.rec.targetModel = state.mode === 'active' ? td.target : null
|
|
557
|
+
td.rec.targetProvider = state.mode === 'active' ? td.targetProvider : null
|
|
558
|
+
td.rec.currentModel = model
|
|
559
|
+
}
|
|
560
|
+
if (!td.recordCommitted) commitRecord(td.rec, td)
|
|
561
|
+
td.needsApply = state.mode === 'active' && td.target && td.target !== model
|
|
562
|
+
log('decision #' + state.seq + ' resolved@request tier=' + td.rec.resolvedTier + ' target=' + (td.target || '-') + ' needsApply=' + td.needsApply)
|
|
563
|
+
}
|
|
451
564
|
return cfg
|
|
452
565
|
}
|
|
453
566
|
const pkey = agentKey(payload) + ':' + payload.turn
|
|
454
567
|
const text = state.pending.get(pkey) || ''
|
|
455
568
|
state.pending.delete(pkey)
|
|
456
|
-
const provider = typeof cfg.provider === 'string' ? cfg.provider : ''
|
|
457
569
|
const r = decideAndRecord(payload, text, model, provider, 'request')
|
|
458
570
|
if (r) {
|
|
459
571
|
r.td.needsApply = state.mode === 'active' && r.target && r.target !== r.td.currentModel
|
|
460
|
-
commitRecord(r.record)
|
|
572
|
+
if (!r.td.awaitProvider) commitRecord(r.record, r.td)
|
|
461
573
|
if (r.td.needsApply) log('decision #' + state.seq + ' -> apply deferred to llm/stream')
|
|
462
574
|
else log('decision #' + state.seq + ' type=' + r.td.clsType + ' meta=' + r.td.chosen + ' tier=' + r.record.resolvedTier)
|
|
463
575
|
}
|
|
@@ -499,14 +611,26 @@ export function apply(ctx, config = {}) {
|
|
|
499
611
|
const key = sid ? state.openTurns.get(sid) : null
|
|
500
612
|
const td = key ? state.turnDecision[key] : null
|
|
501
613
|
if (td && !td.finalized) {
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
614
|
+
// 延迟应用(此时循环自身 header 已落盘,追加无竞态):
|
|
615
|
+
// 1) 步骤级路由挂起的升降档(router-step)
|
|
616
|
+
// 2) request 路径兜底决策(needsApply)
|
|
617
|
+
let session = null
|
|
618
|
+
try {
|
|
619
|
+
const sessions = ctx.get('sessions')
|
|
620
|
+
if (sessions && typeof sessions.get === 'function') session = sessions.get(sid) || null
|
|
621
|
+
} catch (e) { session = null }
|
|
622
|
+
if (session) {
|
|
623
|
+
if (td.stepTarget && !state.forced && (td.stepTarget !== currentModel(session) || (td.stepTargetProvider && td.stepTargetProvider !== currentProvider(session)))) {
|
|
624
|
+
const ok = appendRouterHeader(session, td.stepTarget, 'router-step', td.stepTargetProvider || null)
|
|
625
|
+
if (ok) {
|
|
626
|
+
state.counters.stepSwitches++
|
|
627
|
+
if (td.stepRec) { td.stepRec.applied = true }
|
|
628
|
+
log('STEP-SWAP -> ' + td.stepTarget + (td.stepTargetProvider ? ' (' + td.stepTargetProvider + ')' : '') + (td.stepRec ? ' [tools: ' + td.stepRec.lastTools.join(',') + ']' : ''))
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
td.stepTarget = null
|
|
632
|
+
td.stepTargetProvider = null
|
|
633
|
+
if (td.needsApply && !td.applied) applyDecision(session, key, td, td.rec, td.target)
|
|
510
634
|
}
|
|
511
635
|
const u = td.usage || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, reasoningTokens: 0, estimated: false }
|
|
512
636
|
const model = (options && typeof options.model === 'string' && options.model) || td.currentModel || ''
|
|
@@ -559,6 +683,7 @@ export function apply(ctx, config = {}) {
|
|
|
559
683
|
const td = state.turnDecision[key]
|
|
560
684
|
if (!td || td.finalized) return
|
|
561
685
|
td.finalized = true
|
|
686
|
+
if (!td.recordCommitted && td.rec) commitRecord(td.rec, td)
|
|
562
687
|
const t1 = now()
|
|
563
688
|
const dtMs = t1 != null && td.t0 != null ? t1 - td.t0 : null
|
|
564
689
|
const r = rewardOf(td, tag, dtMs)
|
|
@@ -629,12 +754,15 @@ export function apply(ctx, config = {}) {
|
|
|
629
754
|
return {
|
|
630
755
|
mode: state.mode,
|
|
631
756
|
forcedModel: state.forced,
|
|
757
|
+
crossProvider: state.crossProvider,
|
|
632
758
|
totalDecisions: state.seq,
|
|
633
759
|
modelTiers: state.modelTiers,
|
|
634
760
|
prices,
|
|
635
|
-
mechanism: 'request/header append (
|
|
761
|
+
mechanism: 'request/header append (inbox turn-level + pre-step step-level v1.5.0)',
|
|
636
762
|
lastActual: state.lastActual,
|
|
637
763
|
headerAppends: state.headerAppends,
|
|
764
|
+
stepSwitches: state.counters.stepSwitches,
|
|
765
|
+
stepLog: state.stepLog.slice(-20),
|
|
638
766
|
flywheel: {
|
|
639
767
|
ucb: state.ucb,
|
|
640
768
|
lin: { total: state.lin.total, tierN: state.lin.tierN, tierW: state.lin.tierW },
|
|
@@ -645,7 +773,7 @@ export function apply(ctx, config = {}) {
|
|
|
645
773
|
recentRewards: store.loadJsonl('rewards.jsonl').slice(-20),
|
|
646
774
|
},
|
|
647
775
|
recent: state.decisions.slice(-20),
|
|
648
|
-
note: '切换真实性验证:headerAppends 为本进程追加的 request/header 次数;
|
|
776
|
+
note: '切换真实性验证:headerAppends 为本进程追加的 request/header 次数;stepSwitches 为步骤级升级/降级次数(stepLog 有明细);lastActual 为最近一步真实出网模型',
|
|
649
777
|
}
|
|
650
778
|
},
|
|
651
779
|
})
|
|
@@ -667,7 +795,7 @@ export function apply(ctx, config = {}) {
|
|
|
667
795
|
})
|
|
668
796
|
register({
|
|
669
797
|
name: 'agentic_router_force',
|
|
670
|
-
description: '强制后续请求使用指定模型 id(仅 active
|
|
798
|
+
description: '强制后续请求使用指定模型 id(仅 active 模式生效,支持 model 或 provider/model);clear=true 清除强制,恢复规则路由。',
|
|
671
799
|
parameters: {
|
|
672
800
|
type: 'object',
|
|
673
801
|
properties: {
|
|
@@ -688,14 +816,15 @@ export function apply(ctx, config = {}) {
|
|
|
688
816
|
store.savePolicy({ mode: state.mode, forced: args.model, prices })
|
|
689
817
|
// 立即对存活会话应用(下一请求生效)
|
|
690
818
|
if (state.mode === 'active') {
|
|
819
|
+
const pf = parseForced(args.model)
|
|
691
820
|
try {
|
|
692
821
|
const agents = ctx.get('agents')
|
|
693
822
|
if (agents && typeof agents.list === 'function') {
|
|
694
823
|
for (const agent of agents.list()) {
|
|
695
824
|
try {
|
|
696
825
|
const session = sessionOf({ agent })
|
|
697
|
-
if (session && currentModel(session) !==
|
|
698
|
-
const ok = appendRouterHeader(session,
|
|
826
|
+
if (session && currentModel(session) !== pf.model) {
|
|
827
|
+
const ok = appendRouterHeader(session, pf.model, 'router', pf.provider)
|
|
699
828
|
if (ok) state.headerAppends++
|
|
700
829
|
}
|
|
701
830
|
} catch (e) { /* per-agent best effort */ }
|
package/dsh/strategies.js
CHANGED
|
@@ -21,18 +21,45 @@ export function tierTarget(tier, provider, modelTiers) {
|
|
|
21
21
|
return null
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
export function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
// 档位是否有候选(跨所有 provider)——供 availableTiersFor 判断该档是否值得探索
|
|
25
|
+
export function tierHasAny(tier, modelTiers) {
|
|
26
|
+
const list = modelTiers && modelTiers.tiers && modelTiers.tiers[tier]
|
|
27
|
+
return Array.isArray(list) && list.length > 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 档位 → 模型 id 的解析(v1.6.0 支持跨 provider):
|
|
31
|
+
// 1) 优先当前 provider 的候选(保持会话 provider 稳定,避免无谓跨厂商)
|
|
32
|
+
// 2) 无候选时,若 crossProvider(默认开),按 providers 顺序取其它 provider 的同档候选
|
|
33
|
+
// 3) mid 档仍无候选时:先同 provider 的 strong/fast,再跨 provider 的 strong/fast
|
|
34
|
+
// 返回 { target, provider, used, note };provider 为最终选定的 provider id。
|
|
35
|
+
export function resolveTier(tier, provider, modelTiers, opts = {}) {
|
|
36
|
+
if (!modelTiers) return { target: null, provider: null, used: tier, note: 'tiers-not-ready' }
|
|
37
|
+
const cross = opts.crossProvider !== false
|
|
38
|
+
const same = (t) => {
|
|
39
|
+
const m = tierTarget(t, provider, modelTiers)
|
|
40
|
+
return m ? { provider, model: m } : null
|
|
41
|
+
}
|
|
42
|
+
const crossPick = (t) => {
|
|
43
|
+
if (!cross) return null
|
|
44
|
+
for (const p of modelTiers.providers || []) {
|
|
45
|
+
const pid = p && (p.provider || p.id || p.name)
|
|
46
|
+
if (!pid || pid === provider) continue
|
|
47
|
+
const m = tierTarget(t, pid, modelTiers)
|
|
48
|
+
if (m) return { provider: pid, model: m }
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
const isCross = (r) => !!(r && provider && r.provider !== provider)
|
|
53
|
+
|
|
54
|
+
const direct = same(tier) || crossPick(tier)
|
|
55
|
+
if (direct) return { target: direct.model, provider: direct.provider, used: tier, note: isCross(direct) ? 'cross-provider' : '' }
|
|
29
56
|
if (tier === 'mid') {
|
|
30
|
-
const strong =
|
|
31
|
-
if (strong) return { target: strong, used: 'strong', note: 'mid->strong fallback' }
|
|
32
|
-
const fast =
|
|
33
|
-
if (fast) return { target: fast, used: 'fast', note: 'mid->fast fallback' }
|
|
57
|
+
const strong = same('strong') || crossPick('strong')
|
|
58
|
+
if (strong) return { target: strong.model, provider: strong.provider, used: 'strong', note: 'mid->strong fallback' + (isCross(strong) ? ' [cross-provider]' : '') }
|
|
59
|
+
const fast = same('fast') || crossPick('fast')
|
|
60
|
+
if (fast) return { target: fast.model, provider: fast.provider, used: 'fast', note: 'mid->fast fallback' + (isCross(fast) ? ' [cross-provider]' : '') }
|
|
34
61
|
}
|
|
35
|
-
return { target: null, used: tier, note: 'no-candidate' }
|
|
62
|
+
return { target: null, provider: null, used: tier, note: 'no-candidate' }
|
|
36
63
|
}
|
|
37
64
|
|
|
38
65
|
export function recommendRule(cls) {
|
|
@@ -117,6 +144,12 @@ export function recommendKnn(cls, f, knnState, availableTiers = ['fast', 'mid',
|
|
|
117
144
|
// 4) CONSERVATIVE 保守值 + 标记 unknown,提醒用户补价格
|
|
118
145
|
export const DEFAULT_PRICES = {
|
|
119
146
|
'deepseek-official': {
|
|
147
|
+
'deepseek-flash': { in: 0.2, out: 2 },
|
|
148
|
+
'deepseek-v4-flash': { in: 0.2, out: 2 },
|
|
149
|
+
'deepseek-v4-pro': { in: 1, out: 12 },
|
|
150
|
+
},
|
|
151
|
+
'deepseek-account': {
|
|
152
|
+
'deepseek-flash': { in: 0.2, out: 2 },
|
|
120
153
|
'deepseek-v4-flash': { in: 0.2, out: 2 },
|
|
121
154
|
'deepseek-v4-pro': { in: 1, out: 12 },
|
|
122
155
|
},
|
|
@@ -127,6 +160,25 @@ export const DEFAULT_PRICES = {
|
|
|
127
160
|
},
|
|
128
161
|
}
|
|
129
162
|
|
|
163
|
+
// 价格表合并:以 base(内置默认价)为底,over(policy/config 的用户覆盖)为准。
|
|
164
|
+
// 这样新增内置价(如 deepseek-account)能对既有安装生效,同时不覆盖用户自定义价格。
|
|
165
|
+
export function mergePrices(base, over) {
|
|
166
|
+
const out = {}
|
|
167
|
+
for (const [k, v] of Object.entries(base || {})) {
|
|
168
|
+
out[k] = {}
|
|
169
|
+
for (const [k2, v2] of Object.entries(v || {})) out[k][k2] = { ...v2 }
|
|
170
|
+
}
|
|
171
|
+
for (const [k, v] of Object.entries(over || {})) {
|
|
172
|
+
if (!out[k]) out[k] = {}
|
|
173
|
+
for (const [k2, v2] of Object.entries(v || {})) {
|
|
174
|
+
if (v2 && typeof v2 === 'object' && typeof v2.in === 'number' && typeof v2.out === 'number') {
|
|
175
|
+
out[k][k2] = { in: v2.in, out: v2.out }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return out
|
|
180
|
+
}
|
|
181
|
+
|
|
130
182
|
const CONSERVATIVE = { in: 3, out: 16 }
|
|
131
183
|
|
|
132
184
|
// 解析价格并返回来源(透明标注:价格到底从哪来的)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agentic-router",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "DeepSeek Harness plugin: learning agentic router — rule + UCB + LinUCB + k-NN experts under an EXP3 meta-selector, with a durable quality-reward flywheel
|
|
3
|
+
"version": "1.6.0",
|
|
4
|
+
"description": "DeepSeek Harness plugin: learning agentic router — rule + UCB + LinUCB + k-NN experts under an EXP3 meta-selector, with a durable quality-reward flywheel, REAL model switching via the session request-header precedence chain, and STEP-LEVEL routing (v1.5.0)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "lhl",
|
package/dsh/package.json
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-agentic-router",
|
|
3
|
-
"version": "1.4.2",
|
|
4
|
-
"description": "DeepSeek Harness plugin: learning agentic router — rule + UCB + LinUCB + k-NN experts under an EXP3 meta-selector, with a durable quality-reward flywheel and REAL model switching via the session request-header precedence chain (v1.4.2)",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"author": "lhl",
|
|
8
|
-
"engines": {
|
|
9
|
-
"node": ">=22.19"
|
|
10
|
-
},
|
|
11
|
-
"keywords": [
|
|
12
|
-
"deepseek-harness",
|
|
13
|
-
"dsh",
|
|
14
|
-
"dsh-plugin",
|
|
15
|
-
"agentic-router",
|
|
16
|
-
"model-routing",
|
|
17
|
-
"bandit",
|
|
18
|
-
"exp3",
|
|
19
|
-
"flywheel",
|
|
20
|
-
"llm"
|
|
21
|
-
],
|
|
22
|
-
"exports": {
|
|
23
|
-
".": "./dsh/index.js",
|
|
24
|
-
"./dsh": "./dsh/index.js",
|
|
25
|
-
"./package.json": "./package.json"
|
|
26
|
-
},
|
|
27
|
-
"files": [
|
|
28
|
-
"dsh",
|
|
29
|
-
"cordis.patch.yml",
|
|
30
|
-
"README.md",
|
|
31
|
-
"README.en.md",
|
|
32
|
-
"docs",
|
|
33
|
-
"LICENSE"
|
|
34
|
-
],
|
|
35
|
-
"scripts": {
|
|
36
|
-
"test": "node --test"
|
|
37
|
-
},
|
|
38
|
-
"dsh": {
|
|
39
|
-
"bundle": {
|
|
40
|
-
"patch": "./cordis.patch.yml"
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
package/dsh/plugin.test.js
DELETED
|
@@ -1,405 +0,0 @@
|
|
|
1
|
-
// 插件端到端测试(v1.4.0 机制):inbox 决策 + request/header 追加真实切换;
|
|
2
|
-
// agent/request 只读透传;llm/stream 提供 request 路径的兜底应用。
|
|
3
|
-
import test from 'node:test'
|
|
4
|
-
import assert from 'node:assert/strict'
|
|
5
|
-
import { mkdtempSync } from 'node:fs'
|
|
6
|
-
import { tmpdir } from 'node:os'
|
|
7
|
-
import { join } from 'node:path'
|
|
8
|
-
import { apply, name, inject } from '../dsh/index.js'
|
|
9
|
-
import { Store } from '../dsh/store.js'
|
|
10
|
-
|
|
11
|
-
// 会话 mock:requestHeader 反映最近追加的 header(模拟真实 fold 行为)
|
|
12
|
-
function mockSession(id, startModel = 'deepseek-v4-pro') {
|
|
13
|
-
let model = startModel
|
|
14
|
-
const events = []
|
|
15
|
-
return {
|
|
16
|
-
id,
|
|
17
|
-
events,
|
|
18
|
-
append(type, data) {
|
|
19
|
-
events.push({ type, data })
|
|
20
|
-
if (type === 'request/header' && data && data.header && data.header.config && typeof data.header.config.model === 'string') {
|
|
21
|
-
model = data.header.config.model
|
|
22
|
-
}
|
|
23
|
-
},
|
|
24
|
-
requestHeader() {
|
|
25
|
-
return { config: { provider: 'deepseek-official', model }, adapterDefaults: {} }
|
|
26
|
-
},
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function mockCtx(opts = {}) {
|
|
31
|
-
const listeners = {}
|
|
32
|
-
const registered = []
|
|
33
|
-
return {
|
|
34
|
-
listeners,
|
|
35
|
-
registered,
|
|
36
|
-
get(k) {
|
|
37
|
-
if (k === 'tools') {
|
|
38
|
-
return { register: (def) => { registered.push(def); return () => {} } }
|
|
39
|
-
}
|
|
40
|
-
if (k === 'llm') {
|
|
41
|
-
return {
|
|
42
|
-
async listProviders() { return [{ id: 'deepseek-official' }] },
|
|
43
|
-
async listModels() {
|
|
44
|
-
if (opts.slowModels) return new Promise(() => {}) // 永不 resolve:模拟档位表加载未完成
|
|
45
|
-
return [{ id: 'deepseek-v4-flash' }, { id: 'deepseek-v4-pro' }]
|
|
46
|
-
},
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
if (k === 'messageFeedback') return opts.mf
|
|
50
|
-
if (k === 'timer') return opts.timer
|
|
51
|
-
if (k === 'sessions') return opts.sessions
|
|
52
|
-
if (k === 'agents') return opts.agents
|
|
53
|
-
return undefined
|
|
54
|
-
},
|
|
55
|
-
on(ev, fn) { listeners[ev] = fn; return () => {} },
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function payload(sessionId, turn, session) {
|
|
60
|
-
return { agent: { sessionId, session: session || mockSession(sessionId) }, turn, step: 1, signal: {} }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const CODE_TASK = '重构下面这段 Python 代码,使其支持多线程并发执行并安全处理异常:\n\n```python\nimport time\ndef fetch(url):\n time.sleep(1)\n```'
|
|
64
|
-
const QA_TASK = '请只用一句话回答:地球的天然卫星叫什么?'
|
|
65
|
-
|
|
66
|
-
test('插件声明', () => {
|
|
67
|
-
assert.equal(name, 'agentic-router')
|
|
68
|
-
assert.ok(inject.includes('tools'))
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
test('v1.4.0 机制:inbox 决策并追加 request/header 真实切换(shadow 不动、active 切换、request 只读透传)', async () => {
|
|
72
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-router-'))
|
|
73
|
-
const ctx = mockCtx()
|
|
74
|
-
apply(ctx, { home })
|
|
75
|
-
const inbox = ctx.listeners['agent/inbox/claimed']
|
|
76
|
-
const req = ctx.listeners['agent/request']
|
|
77
|
-
const stop = ctx.listeners['agent/turn-stopping']
|
|
78
|
-
assert.ok(inbox && req && stop, '事件监听应全部挂载')
|
|
79
|
-
assert.equal(ctx.registered.length, 5, '应注册 5 个工具')
|
|
80
|
-
|
|
81
|
-
const session = mockSession('s1')
|
|
82
|
-
const P = (t, text) => ({ agent: { sessionId: 's1', session }, turn: t, step: 1, signal: {}, message: { role: 'user', content: [{ type: 'text', text }] } })
|
|
83
|
-
const headers = () => session.events.filter((e) => e.type === 'request/header')
|
|
84
|
-
|
|
85
|
-
// shadow 模式(默认):复杂代码任务 → 记录决策,但不追加 header
|
|
86
|
-
await inbox(P(1, CODE_TASK))
|
|
87
|
-
assert.equal(headers().length, 0, 'shadow 模式不得切换')
|
|
88
|
-
|
|
89
|
-
// 切 active
|
|
90
|
-
const setMode = ctx.registered.find((t) => t.name === 'agentic_router_set_mode')
|
|
91
|
-
await setMode.execute({ mode: 'active' }, {})
|
|
92
|
-
|
|
93
|
-
// 简单问答 → 追加 flash header
|
|
94
|
-
await inbox(P(2, QA_TASK))
|
|
95
|
-
const appends = headers()
|
|
96
|
-
assert.equal(appends.length, 1, 'active 模式应追加一次 request/header')
|
|
97
|
-
assert.equal(appends[0].data.header.config.model, 'deepseek-v4-flash', '简单任务应切 flash')
|
|
98
|
-
assert.equal(appends[0].data.reason, 'router')
|
|
99
|
-
|
|
100
|
-
// agent/request 必须只读透传(不再替换配置)
|
|
101
|
-
const cfg = await req(payload('s1', 2, session), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
102
|
-
assert.equal(cfg.model, 'deepseek-v4-pro', 'agent/request 必须透传原配置')
|
|
103
|
-
|
|
104
|
-
// 收尾 → 奖励落盘且元数据完整
|
|
105
|
-
await stop(payload('s1', 2, session))
|
|
106
|
-
const store = new Store(home)
|
|
107
|
-
const rewards = store.loadJsonl('rewards.jsonl')
|
|
108
|
-
assert.equal(rewards.length, 1)
|
|
109
|
-
assert.ok(rewards[0].reward > 0)
|
|
110
|
-
assert.equal(rewards[0].turn, 's1/2')
|
|
111
|
-
assert.ok(Array.isArray(rewards[0].f) && rewards[0].f.length === 9, '奖励记录需含特征向量(供 replay)')
|
|
112
|
-
|
|
113
|
-
const decisions = store.loadJsonl('decisions.jsonl')
|
|
114
|
-
assert.equal(decisions.length, 2)
|
|
115
|
-
assert.equal(decisions[1].targetModel, 'deepseek-v4-flash')
|
|
116
|
-
assert.equal(decisions[1].applied, true, 'header 追加成功应标记 applied')
|
|
117
|
-
assert.equal(decisions[1].swapped, true, '真实切换应标记 swapped')
|
|
118
|
-
|
|
119
|
-
const stats = await ctx.registered.find((t) => t.name === 'agentic_router_stats').execute({}, {})
|
|
120
|
-
assert.equal(stats.totalDecisions, 2)
|
|
121
|
-
assert.equal(stats.mode, 'active')
|
|
122
|
-
assert.equal(stats.headerAppends, 1, 'stats 应统计追加次数')
|
|
123
|
-
assert.equal(stats.lastActual.model, 'deepseek-v4-pro')
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
test('冷启动:档位表异步加载未完成时,inbox 仍用兜底档位立即切换(v1.4.1 修复)', async () => {
|
|
127
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-router-coldstart-'))
|
|
128
|
-
const ctx = mockCtx({ slowModels: true }) // listModels 永不 resolve,模拟冷启动
|
|
129
|
-
apply(ctx, { home, mode: 'active' })
|
|
130
|
-
const inbox = ctx.listeners['agent/inbox/claimed']
|
|
131
|
-
assert.ok(inbox)
|
|
132
|
-
|
|
133
|
-
const session = mockSession('s1') // 当前 pro
|
|
134
|
-
const P = (t, text) => ({ agent: { sessionId: 's1', session }, turn: t, step: 1, signal: {}, message: { role: 'user', content: [{ type: 'text', text }] } })
|
|
135
|
-
|
|
136
|
-
// inbox 是同步的:调用后立即断言,不等待任何异步档位表加载
|
|
137
|
-
inbox(P(1, QA_TASK))
|
|
138
|
-
const appends = session.events.filter((e) => e.type === 'request/header')
|
|
139
|
-
assert.equal(appends.length, 1, '冷启动下也应立即追加 header')
|
|
140
|
-
assert.equal(appends[0].data.header.config.model, 'deepseek-v4-flash', '兜底档位表应把简单问答切到 flash')
|
|
141
|
-
assert.equal(appends[0].data.reason, 'router')
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
test('v1.4.2:UCB 不探索无候选的 mid 档——简单任务决策里 recs.ucb 不得为 mid', async () => {
|
|
145
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-router-notier-'))
|
|
146
|
-
const ctx = mockCtx() // 档位表只有 flash/pro(fast/strong),mid 无候选
|
|
147
|
-
apply(ctx, { home, mode: 'active' })
|
|
148
|
-
const inbox = ctx.listeners['agent/inbox/claimed']
|
|
149
|
-
const session = mockSession('s1')
|
|
150
|
-
const P = (t, text) => ({ agent: { sessionId: 's1', session }, turn: t, step: 1, signal: {}, message: { role: 'user', content: [{ type: 'text', text }] } })
|
|
151
|
-
|
|
152
|
-
// 连发 6 个简单问答,覆盖 UCB 冷启动轮转
|
|
153
|
-
for (let i = 1; i <= 6; i++) inbox(P(i, QA_TASK + '(第' + i + '问)'))
|
|
154
|
-
|
|
155
|
-
const store = new Store(home)
|
|
156
|
-
const decisions = store.loadJsonl('decisions.jsonl')
|
|
157
|
-
assert.equal(decisions.length, 6)
|
|
158
|
-
for (const d of decisions) {
|
|
159
|
-
assert.ok(!String(d.recs.ucb).startsWith('mid'), 'UCB 不得推荐 mid,实际 ' + d.recs.ucb)
|
|
160
|
-
assert.ok(!String(d.recs.lin).startsWith('mid'), 'LinUCB 不得推荐 mid,实际 ' + d.recs.lin)
|
|
161
|
-
}
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
test('兜底路径:request 决策 + llm/stream 延迟应用(中途热加载场景)', async () => {
|
|
165
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-router-fallback-'))
|
|
166
|
-
const session = mockSession('s1')
|
|
167
|
-
const sessions = new Map([['s1', session]])
|
|
168
|
-
const ctx = mockCtx({ sessions })
|
|
169
|
-
apply(ctx, { home, mode: 'active' })
|
|
170
|
-
const pre = ctx.listeners['agent/pre-step']
|
|
171
|
-
const req = ctx.listeners['agent/request']
|
|
172
|
-
const stream = ctx.listeners['llm/stream']
|
|
173
|
-
assert.ok(pre && req && stream)
|
|
174
|
-
|
|
175
|
-
await pre({ ...payload('s1', 1, session), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
176
|
-
const cfg = await req(payload('s1', 1, session), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
177
|
-
assert.equal(cfg.model, 'deepseek-v4-pro', 'request 路径也透传')
|
|
178
|
-
|
|
179
|
-
// 流式调用结束 → 补应用切换
|
|
180
|
-
const wrapped = stream({ sessionId: 's1', provider: 'deepseek-official' }, async function* () {
|
|
181
|
-
yield { type: 'finish', reason: 'stop' }
|
|
182
|
-
})
|
|
183
|
-
for await (const c of wrapped) { /* drain */ }
|
|
184
|
-
|
|
185
|
-
const appends = session.events.filter((e) => e.type === 'request/header')
|
|
186
|
-
assert.equal(appends.length, 1, 'llm/stream 后应补应用一次 header')
|
|
187
|
-
assert.equal(appends[0].data.header.config.model, 'deepseek-v4-flash')
|
|
188
|
-
})
|
|
189
|
-
|
|
190
|
-
test('飞轮重放:新实例从 rewards.jsonl 恢复学习状态', async () => {
|
|
191
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-router-replay-'))
|
|
192
|
-
const ctx1 = mockCtx()
|
|
193
|
-
apply(ctx1, { home })
|
|
194
|
-
const stop = ctx1.listeners['agent/turn-stopping']
|
|
195
|
-
const req = ctx1.listeners['agent/request']
|
|
196
|
-
const pre = ctx1.listeners['agent/pre-step']
|
|
197
|
-
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
198
|
-
await req(payload('s1', 1), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
199
|
-
await stop(payload('s1', 1))
|
|
200
|
-
|
|
201
|
-
// 第二个实例(模拟重启):应重放出 1 条奖励
|
|
202
|
-
const ctx2 = mockCtx()
|
|
203
|
-
apply(ctx2, { home })
|
|
204
|
-
const stats = await ctx2.registered.find((t) => t.name === 'agentic_router_stats').execute({}, {})
|
|
205
|
-
assert.equal(stats.flywheel.ucb.total, 1, '重启后 UCB 应恢复 1 次拉动')
|
|
206
|
-
assert.equal(stats.flywheel.knnPoolSize, 1)
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
test('工具失败代理:tools/result 错误 → 奖励降分', async () => {
|
|
210
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-router-toolerr-'))
|
|
211
|
-
const ctx = mockCtx()
|
|
212
|
-
apply(ctx, { home })
|
|
213
|
-
const req = ctx.listeners['agent/request']
|
|
214
|
-
const pre = ctx.listeners['agent/pre-step']
|
|
215
|
-
const stop = ctx.listeners['agent/turn-stopping']
|
|
216
|
-
const toolResult = ctx.listeners['tools/result']
|
|
217
|
-
assert.ok(toolResult)
|
|
218
|
-
|
|
219
|
-
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
220
|
-
await req(payload('s1', 1), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
221
|
-
toolResult({ agent: { sessionId: 's1' }, name: 'read' }, { status: 'error', error: 'not found' })
|
|
222
|
-
await stop(payload('s1', 1))
|
|
223
|
-
|
|
224
|
-
const store = new Store(home)
|
|
225
|
-
const rewards = store.loadJsonl('rewards.jsonl')
|
|
226
|
-
assert.equal(rewards.length, 1)
|
|
227
|
-
assert.equal(rewards[0].breakdown.toolErrors, 1)
|
|
228
|
-
assert.equal(rewards[0].reward, 0.8)
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
test('reset 工具清空飞轮', async () => {
|
|
232
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-router-reset-'))
|
|
233
|
-
const ctx = mockCtx()
|
|
234
|
-
apply(ctx, { home })
|
|
235
|
-
const req = ctx.listeners['agent/request']
|
|
236
|
-
const pre = ctx.listeners['agent/pre-step']
|
|
237
|
-
const stop = ctx.listeners['agent/turn-stopping']
|
|
238
|
-
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
239
|
-
await req(payload('s1', 1), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
240
|
-
await stop(payload('s1', 1))
|
|
241
|
-
await ctx.registered.find((t) => t.name === 'agentic_router_reset').execute({}, {})
|
|
242
|
-
const stats = await ctx.registered.find((t) => t.name === 'agentic_router_stats').execute({}, {})
|
|
243
|
-
assert.equal(stats.flywheel.ucb.total, 0)
|
|
244
|
-
assert.equal(stats.totalDecisions, 0)
|
|
245
|
-
})
|
|
246
|
-
|
|
247
|
-
test('messageFeedback 结算:回合结束 45s 后按人类点踩修正奖励', async () => {
|
|
248
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-router-mf-'))
|
|
249
|
-
const negativeItem = { messageId: 'm1', rating: 'negative', createdAt: Date.now() + 60000 }
|
|
250
|
-
const mf = { async list() { return { ok: true, value: { items: [negativeItem] } } } }
|
|
251
|
-
let sweepFn = null
|
|
252
|
-
const timer = { interval(fn) { sweepFn = fn; return () => {} } }
|
|
253
|
-
const ctx = mockCtx({ mf, timer })
|
|
254
|
-
apply(ctx, { home, settleDelayMs: 0 })
|
|
255
|
-
|
|
256
|
-
const req = ctx.listeners['agent/request']
|
|
257
|
-
const pre = ctx.listeners['agent/pre-step']
|
|
258
|
-
const stop = ctx.listeners['agent/turn-stopping']
|
|
259
|
-
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
260
|
-
await req(payload('s1', 1), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
261
|
-
await stop(payload('s1', 1))
|
|
262
|
-
|
|
263
|
-
// 结算前:奖励 1,UCB mean 1
|
|
264
|
-
const store = new Store(home)
|
|
265
|
-
assert.equal(store.loadJsonl('rewards.jsonl')[0].reward, 1)
|
|
266
|
-
|
|
267
|
-
// 触发结算轮询(settleDelayMs=0 立即到期)
|
|
268
|
-
assert.ok(sweepFn, '应注册结算轮询')
|
|
269
|
-
await sweepFn()
|
|
270
|
-
|
|
271
|
-
const rewards = store.loadJsonl('rewards.jsonl')
|
|
272
|
-
const settle = rewards.find((r) => r.type === 'feedback-settle')
|
|
273
|
-
assert.ok(settle, '应产生 feedback-settle 记录')
|
|
274
|
-
assert.equal(settle.negative, 1)
|
|
275
|
-
assert.equal(settle.applied, 1)
|
|
276
|
-
assert.equal(settle.newReward, 0.5)
|
|
277
|
-
|
|
278
|
-
const stats = await ctx.registered.find((t) => t.name === 'agentic_router_stats').execute({}, {})
|
|
279
|
-
assert.equal(stats.flywheel.ucb.arms['qa:fast'].mean, 0.5, '飞轮均值应被修正')
|
|
280
|
-
assert.equal(stats.flywheel.pendingFeedback, 0, '结算后 pending 清空')
|
|
281
|
-
})
|
|
282
|
-
|
|
283
|
-
test('messageFeedback 缺失时优雅降级(不报错、不结算)', async () => {
|
|
284
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-router-mfnone-'))
|
|
285
|
-
const ctx = mockCtx({})
|
|
286
|
-
apply(ctx, { home, settleDelayMs: 0 })
|
|
287
|
-
const req = ctx.listeners['agent/request']
|
|
288
|
-
const pre = ctx.listeners['agent/pre-step']
|
|
289
|
-
const stop = ctx.listeners['agent/turn-stopping']
|
|
290
|
-
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
291
|
-
await req(payload('s1', 1), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
292
|
-
await stop(payload('s1', 1))
|
|
293
|
-
const store = new Store(home)
|
|
294
|
-
assert.ok(store.loadJsonl('rewards.jsonl').every((r) => r.type !== 'feedback-settle'))
|
|
295
|
-
})
|
|
296
|
-
|
|
297
|
-
test('成本信号:llm/stream 透传并累加 usage,奖励按成本扣分', async () => {
|
|
298
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-router-cost-'))
|
|
299
|
-
const ctx = mockCtx({})
|
|
300
|
-
apply(ctx, { home })
|
|
301
|
-
const req = ctx.listeners['agent/request']
|
|
302
|
-
const pre = ctx.listeners['agent/pre-step']
|
|
303
|
-
const stop = ctx.listeners['agent/turn-stopping']
|
|
304
|
-
const stream = ctx.listeners['llm/stream']
|
|
305
|
-
assert.ok(stream, '应挂载 llm/stream 监听')
|
|
306
|
-
|
|
307
|
-
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
308
|
-
await req(payload('s1', 1), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
309
|
-
|
|
310
|
-
// 模拟一次流式调用:文本 + usage chunk(strong 档 100k 输入 100k 输出)
|
|
311
|
-
const chunks = []
|
|
312
|
-
const wrapped = stream({ sessionId: 's1', provider: 'deepseek-official' }, async function* () {
|
|
313
|
-
yield { type: 'text-delta', text: '月球' }
|
|
314
|
-
yield { type: 'usage', usage: { inputTokens: 100000, outputTokens: 100000 } }
|
|
315
|
-
})
|
|
316
|
-
for await (const c of wrapped) chunks.push(c)
|
|
317
|
-
assert.equal(chunks.length, 2, '所有 chunk 必须透传')
|
|
318
|
-
assert.equal(chunks[0].text, '月球')
|
|
319
|
-
|
|
320
|
-
await stop(payload('s1', 1))
|
|
321
|
-
const store = new Store(home)
|
|
322
|
-
const rewards = store.loadJsonl('rewards.jsonl')
|
|
323
|
-
// strong 档成本 = (100000×1 + 100000×12)/1e6 = 1.3 元 → 惩罚封顶 0.5
|
|
324
|
-
assert.equal(rewards[0].breakdown.costYuan, 1.3)
|
|
325
|
-
assert.equal(rewards[0].breakdown.costPenalty, 0.5)
|
|
326
|
-
assert.equal(rewards[0].reward, 0.5)
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
test('成本信号:无 usage 的 provider 走估算兜底(estimated 标记)', async () => {
|
|
330
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-router-costest-'))
|
|
331
|
-
const ctx = mockCtx({})
|
|
332
|
-
apply(ctx, { home })
|
|
333
|
-
const req = ctx.listeners['agent/request']
|
|
334
|
-
const pre = ctx.listeners['agent/pre-step']
|
|
335
|
-
const stop = ctx.listeners['agent/turn-stopping']
|
|
336
|
-
const stream = ctx.listeners['llm/stream']
|
|
337
|
-
|
|
338
|
-
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
339
|
-
await req(payload('s1', 1), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
340
|
-
const wrapped = stream({ sessionId: 's1', provider: 'deepseek-official' }, async function* () {
|
|
341
|
-
yield { type: 'text-delta', text: 'H2O' }
|
|
342
|
-
yield { type: 'finish', reason: 'stop' }
|
|
343
|
-
})
|
|
344
|
-
for await (const c of wrapped) { /* 透传 */ }
|
|
345
|
-
await stop(payload('s1', 1))
|
|
346
|
-
const rewards = new Store(home).loadJsonl('rewards.jsonl')
|
|
347
|
-
assert.ok(rewards[0].breakdown.costYuan > 0, '估算成本应 > 0')
|
|
348
|
-
assert.ok(rewards[0].breakdown.costYuan < 0.01, '小回合成本应很小')
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
test('set_prices:接入新 provider 后配置价格,成本按新价计算', async () => {
|
|
352
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-router-price-'))
|
|
353
|
-
const ctx = mockCtx({})
|
|
354
|
-
apply(ctx, { home })
|
|
355
|
-
const setPrices = ctx.registered.find((t) => t.name === 'agentic_router_set_prices')
|
|
356
|
-
const stats = ctx.registered.find((t) => t.name === 'agentic_router_stats')
|
|
357
|
-
|
|
358
|
-
// 默认无 openai 精确价 → 查询显示 prices 表
|
|
359
|
-
const view = await setPrices.execute({ provider: 'openai' }, {})
|
|
360
|
-
assert.ok(view.prices['*'], '默认含通用档位兜底')
|
|
361
|
-
|
|
362
|
-
// 配置 openai/gpt-x-pro 价格
|
|
363
|
-
const r = await setPrices.execute({ provider: 'openai', model: 'gpt-x-pro', priceIn: 15, priceOut: 60 }, {})
|
|
364
|
-
assert.equal(r.updated, 'gpt-x-pro')
|
|
365
|
-
const after = await stats.execute({}, {})
|
|
366
|
-
assert.equal(after.prices.openai['gpt-x-pro'].out, 60, '价格表热更新')
|
|
367
|
-
|
|
368
|
-
// 新 provider 决策 + 流式 usage → 成本按 15/60 计
|
|
369
|
-
const req = ctx.listeners['agent/request']
|
|
370
|
-
const pre = ctx.listeners['agent/pre-step']
|
|
371
|
-
const stop = ctx.listeners['agent/turn-stopping']
|
|
372
|
-
const stream = ctx.listeners['llm/stream']
|
|
373
|
-
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: CODE_TASK }] }] }, async () => ({}))
|
|
374
|
-
await req(payload('s1', 1), async () => ({ provider: 'openai', model: 'gpt-x-pro' }))
|
|
375
|
-
const wrapped = stream({ sessionId: 's1', provider: 'openai' }, async function* () {
|
|
376
|
-
yield { type: 'usage', usage: { inputTokens: 1000, outputTokens: 1000 } }
|
|
377
|
-
})
|
|
378
|
-
for await (const c of wrapped) { /* 透传 */ }
|
|
379
|
-
await stop(payload('s1', 1))
|
|
380
|
-
const rewards = new Store(home).loadJsonl('rewards.jsonl')
|
|
381
|
-
// (1000×15 + 1000×60)/1e6 = 0.075 元;来源 provider-model
|
|
382
|
-
assert.ok(Math.abs(rewards[0].breakdown.costYuan - 0.075) < 1e-9)
|
|
383
|
-
assert.equal(rewards[0].breakdown.pricingSource, 'provider-model')
|
|
384
|
-
})
|
|
385
|
-
|
|
386
|
-
test('未知 provider 不套用 DeepSeek 价格:用通用档位并标记来源', async () => {
|
|
387
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-router-unknown-'))
|
|
388
|
-
const ctx = mockCtx({})
|
|
389
|
-
apply(ctx, { home })
|
|
390
|
-
const req = ctx.listeners['agent/request']
|
|
391
|
-
const pre = ctx.listeners['agent/pre-step']
|
|
392
|
-
const stop = ctx.listeners['agent/turn-stopping']
|
|
393
|
-
const stream = ctx.listeners['llm/stream']
|
|
394
|
-
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
395
|
-
await req(payload('s1', 1), async () => ({ provider: 'mystery-vendor', model: 'mystery-fast' }))
|
|
396
|
-
const wrapped = stream({ sessionId: 's1', provider: 'mystery-vendor' }, async function* () {
|
|
397
|
-
yield { type: 'usage', usage: { inputTokens: 1000, outputTokens: 1000 } }
|
|
398
|
-
})
|
|
399
|
-
for await (const c of wrapped) { /* 透传 */ }
|
|
400
|
-
await stop(payload('s1', 1))
|
|
401
|
-
const rewards = new Store(home).loadJsonl('rewards.jsonl')
|
|
402
|
-
// '*' fast = (1000×1 + 1000×4)/1e6 = 0.005
|
|
403
|
-
assert.ok(Math.abs(rewards[0].breakdown.costYuan - 0.005) < 1e-9)
|
|
404
|
-
assert.equal(rewards[0].breakdown.pricingSource, 'generic-tier')
|
|
405
|
-
})
|
package/dsh/strategies.test.js
DELETED
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
// 策略与飞轮测试:UCB 冷启动轮转 / mid 回退 / 奖励公式 / applyReward 更新
|
|
2
|
-
import test from 'node:test'
|
|
3
|
-
import assert from 'node:assert/strict'
|
|
4
|
-
import {
|
|
5
|
-
tierOf, tierTarget, resolveTier,
|
|
6
|
-
recommendRule, recommendUcb, recommendLin, recommendKnn,
|
|
7
|
-
rewardOf, applyReward, correctReward,
|
|
8
|
-
DEFAULT_PRICES, turnCostYuan, resolvePrice,
|
|
9
|
-
} from '../dsh/strategies.js'
|
|
10
|
-
import { metaPick, DEFAULT_WEIGHTS } from '../dsh/meta.js'
|
|
11
|
-
|
|
12
|
-
const MODEL_TIERS = {
|
|
13
|
-
providers: [{ provider: 'deepseek-official', models: ['deepseek-v4-flash', 'deepseek-v4-pro'] }],
|
|
14
|
-
tiers: { fast: ['deepseek-official/deepseek-v4-flash'], mid: [], strong: ['deepseek-official/deepseek-v4-pro'] },
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
test('tierOf 按 id 关键词分档', () => {
|
|
18
|
-
assert.equal(tierOf('deepseek-v4-flash'), 'fast')
|
|
19
|
-
assert.equal(tierOf('deepseek-v4-pro'), 'strong')
|
|
20
|
-
assert.equal(tierOf('some-model-x'), 'mid')
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
test('UCB 冷启动:三个未探索臂∞平局时轮转,不会永远选 fast(实测修复)', () => {
|
|
24
|
-
const st = { total: 0, arms: {} }
|
|
25
|
-
const t0 = recommendUcb({ type: 'qa' }, st).tier
|
|
26
|
-
st.total = 1
|
|
27
|
-
const t1 = recommendUcb({ type: 'qa' }, st).tier
|
|
28
|
-
st.total = 2
|
|
29
|
-
const t2 = recommendUcb({ type: 'qa' }, st).tier
|
|
30
|
-
assert.deepEqual([t0, t1, t2].sort(), ['fast', 'mid', 'strong'])
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
test('UCB 利用:三臂都有样本时高均值臂胜出(未探索臂上界∞是合法探索)', () => {
|
|
34
|
-
const st = {
|
|
35
|
-
total: 30,
|
|
36
|
-
arms: {
|
|
37
|
-
'qa:fast': { n: 10, mean: 0.2 },
|
|
38
|
-
'qa:mid': { n: 10, mean: 0.1 },
|
|
39
|
-
'qa:strong': { n: 10, mean: 1 },
|
|
40
|
-
},
|
|
41
|
-
}
|
|
42
|
-
assert.equal(recommendUcb({ type: 'qa' }, st).tier, 'strong')
|
|
43
|
-
// 反之:strong 未探索时,UCB 应合法地选择探索它
|
|
44
|
-
const st2 = {
|
|
45
|
-
total: 20,
|
|
46
|
-
arms: { 'qa:fast': { n: 10, mean: 0.9 }, 'qa:mid': { n: 10, mean: 0.8 } },
|
|
47
|
-
}
|
|
48
|
-
assert.equal(recommendUcb({ type: 'qa' }, st2).tier, 'strong')
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
test('v1.4.2:availableTiers 过滤后 UCB/LinUCB 只推荐可用档位(不探索无候选的 mid)', () => {
|
|
52
|
-
const st = { total: 0, arms: {} }
|
|
53
|
-
const lin = { total: 0, tierN: { fast: 0, mid: 0, strong: 0 }, tierW: { fast: new Array(9).fill(0), mid: new Array(9).fill(0), strong: new Array(9).fill(0) } }
|
|
54
|
-
const f = [1, 0, 0.01, 0, 0, 0, 0, 0, 0]
|
|
55
|
-
for (let i = 0; i < 8; i++) {
|
|
56
|
-
const u = recommendUcb({ type: 'qa' }, st, ['fast', 'strong'])
|
|
57
|
-
const l = recommendLin({ type: 'qa' }, f, lin, ['fast', 'strong'])
|
|
58
|
-
st.total += 1
|
|
59
|
-
assert.ok(['fast', 'strong'].includes(u.tier), 'UCB 不得推荐 mid,实际 ' + u.tier)
|
|
60
|
-
assert.ok(['fast', 'strong'].includes(l.tier), 'LinUCB 不得推荐 mid,实际 ' + l.tier)
|
|
61
|
-
}
|
|
62
|
-
// k-NN:经验池里有 mid 邻居时,也不得推荐 mid
|
|
63
|
-
const pool = [
|
|
64
|
-
{ f, tier: 'fast', r: 0.9 },
|
|
65
|
-
{ f, tier: 'fast', r: 0.8 },
|
|
66
|
-
{ f, tier: 'mid', r: 0.7 },
|
|
67
|
-
{ f, tier: 'mid', r: 0.6 },
|
|
68
|
-
{ f, tier: 'strong', r: 0.4 },
|
|
69
|
-
]
|
|
70
|
-
const knn = recommendKnn({ type: 'qa' }, f, { pool }, ['fast', 'strong'])
|
|
71
|
-
assert.ok(['fast', 'strong'].includes(knn.tier), 'k-NN 不得推荐 mid,实际 ' + knn.tier)
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
test('resolveTier:mid 档无候选时向上取 strong(实测修复)', () => {
|
|
75
|
-
assert.deepEqual(resolveTier('mid', 'deepseek-official', MODEL_TIERS),
|
|
76
|
-
{ target: 'deepseek-v4-pro', used: 'strong', note: 'mid->strong fallback' })
|
|
77
|
-
assert.equal(resolveTier('fast', 'deepseek-official', MODEL_TIERS).target, 'deepseek-v4-flash')
|
|
78
|
-
assert.equal(resolveTier('strong', 'deepseek-official', MODEL_TIERS).target, 'deepseek-v4-pro')
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
test('规则表阈值:低→fast,中→mid,高→strong', () => {
|
|
82
|
-
assert.equal(recommendRule({ complexity: 0.1 }).tier, 'fast')
|
|
83
|
-
assert.equal(recommendRule({ complexity: 0.5 }).tier, 'mid')
|
|
84
|
-
assert.equal(recommendRule({ complexity: 0.8 }).tier, 'strong')
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
test('质量代理奖励:工具失败 −0.2/次(封顶 0.6),重试 −0.2/次(封顶 0.4),延迟分档', () => {
|
|
88
|
-
assert.equal(rewardOf({ toolErrors: 1, retries: 0 }, 'clean-close', 1000).reward, 0.8)
|
|
89
|
-
assert.equal(rewardOf({ toolErrors: 5, retries: 0 }, 'clean-close', 1000).reward, 0.4)
|
|
90
|
-
assert.equal(rewardOf({ toolErrors: 0, retries: 4 }, 'clean-close', 1000).reward, 0.6)
|
|
91
|
-
assert.equal(rewardOf({ toolErrors: 0, retries: 0 }, 'clean-close', 40000).reward, 0.9)
|
|
92
|
-
assert.equal(rewardOf({ toolErrors: 0, retries: 0 }, 'error', 1000).reward, 0)
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
test('applyReward:更新 UCB/LinUCB/k-NN/EXP3 四个学习器', () => {
|
|
96
|
-
const state = {
|
|
97
|
-
ucb: { total: 0, arms: {} },
|
|
98
|
-
lin: { total: 0, tierN: { fast: 0, mid: 0, strong: 0 }, tierW: { fast: new Array(9).fill(0), mid: new Array(9).fill(0), strong: new Array(9).fill(0) } },
|
|
99
|
-
knn: { pool: [] },
|
|
100
|
-
exp3: { total: 0, weights: { ...DEFAULT_WEIGHTS } },
|
|
101
|
-
}
|
|
102
|
-
const rec = {
|
|
103
|
-
reward: 0.8, clsType: 'qa', ucbTier: 'fast', linTier: 'fast',
|
|
104
|
-
f: [1, 0.1, 0.01, 0, 0, 0, 0, 0, 1],
|
|
105
|
-
chosen: 'rule', pChosen: 0.7, pool: ['ucb', 'rule'],
|
|
106
|
-
}
|
|
107
|
-
applyReward(state, rec)
|
|
108
|
-
assert.equal(state.ucb.arms['qa:fast'].n, 1)
|
|
109
|
-
assert.equal(state.ucb.arms['qa:fast'].mean, 0.8)
|
|
110
|
-
assert.equal(state.lin.tierN.fast, 1)
|
|
111
|
-
assert.equal(state.knn.pool.length, 1)
|
|
112
|
-
assert.ok(state.exp3.weights.rule > DEFAULT_WEIGHTS.rule, '被选策略权重应上升')
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
test('metaPick:冷启动只有 ucb+rule 入池,rule 初始权重 2.5 占优', () => {
|
|
116
|
-
const recs = {
|
|
117
|
-
rule: { tier: 'fast', conf: 0.5 },
|
|
118
|
-
ucb: { tier: 'fast', conf: 0.2 },
|
|
119
|
-
lin: { tier: 'fast', conf: 0.1 },
|
|
120
|
-
knn: { tier: null, conf: 0 },
|
|
121
|
-
}
|
|
122
|
-
const exp3 = { weights: { ...DEFAULT_WEIGHTS }, linGraduated: false, knnGraduated: false }
|
|
123
|
-
const m = metaPick(recs, exp3)
|
|
124
|
-
assert.deepEqual(m.pool.sort(), ['rule', 'ucb'])
|
|
125
|
-
assert.ok(Math.abs(m.ps.rule - 2.5 / 3.5) < 1e-9)
|
|
126
|
-
assert.ok(m.byName[m.chosen])
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
test('k-NN 冷启动:经验不足时给 null 档', () => {
|
|
130
|
-
const r = recommendKnn({ type: 'qa' }, [1, 0.1, 0.01, 0, 0, 0, 0, 0, 1], { pool: [] })
|
|
131
|
-
assert.equal(r.tier, null)
|
|
132
|
-
assert.equal(r.conf, 0)
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
test('k-NN 推荐:邻居经验按均值选档', () => {
|
|
136
|
-
const pool = []
|
|
137
|
-
const f = [1, 0.1, 0.01, 0, 0, 0, 0, 0, 1]
|
|
138
|
-
for (let i = 0; i < 10; i++) {
|
|
139
|
-
pool.push({ f: [1, 0.11, 0.01, 0, 0, 0, 0, 0, 1], tier: 'strong', r: 1 })
|
|
140
|
-
pool.push({ f: [1, 0.12, 0.01, 0, 0, 0, 0, 0, 1], tier: 'fast', r: 0.3 })
|
|
141
|
-
}
|
|
142
|
-
const r = recommendKnn({ type: 'qa' }, f, { pool })
|
|
143
|
-
assert.equal(r.tier, 'strong')
|
|
144
|
-
assert.ok(r.conf > 0)
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
test('correctReward:反馈修正精确回退 UCB 均值与 EXP3 权重', () => {
|
|
148
|
-
const state = {
|
|
149
|
-
ucb: { total: 1, arms: {} },
|
|
150
|
-
lin: { total: 1, tierN: { fast: 1, mid: 0, strong: 0 }, tierW: { fast: new Array(9).fill(0), mid: new Array(9).fill(0), strong: new Array(9).fill(0) } },
|
|
151
|
-
knn: { pool: [] },
|
|
152
|
-
exp3: { total: 1, weights: { ...DEFAULT_WEIGHTS } },
|
|
153
|
-
}
|
|
154
|
-
const rec = {
|
|
155
|
-
reward: 1, clsType: 'qa', ucbTier: 'fast', linTier: 'fast',
|
|
156
|
-
f: [1, 0.1, 0.01, 0, 0, 0, 0, 0, 1],
|
|
157
|
-
chosen: 'rule', pChosen: 0.7, pool: ['ucb', 'rule'],
|
|
158
|
-
}
|
|
159
|
-
applyReward(state, rec)
|
|
160
|
-
assert.equal(state.ucb.arms['qa:fast'].mean, 1)
|
|
161
|
-
const wBefore = state.exp3.weights.rule
|
|
162
|
-
|
|
163
|
-
correctReward(state, rec, 0.5)
|
|
164
|
-
assert.equal(state.ucb.arms['qa:fast'].n, 1, 'n 不变')
|
|
165
|
-
assert.equal(state.ucb.arms['qa:fast'].mean, 0.5, '均值修正为 0.5')
|
|
166
|
-
assert.ok(state.exp3.weights.rule < wBefore, 'EXP3 权重应乘性回退')
|
|
167
|
-
assert.ok(Math.abs(state.exp3.weights.rule - DEFAULT_WEIGHTS.rule * Math.exp(0.3 * (0.5 / 0.7) / 2)) < 1e-9, 'EXP3 回退为精确逆运算')
|
|
168
|
-
assert.equal(rec.reward, 0.5, '记录同步更新')
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
test('correctReward:无变化时不修正', () => {
|
|
172
|
-
const state = {
|
|
173
|
-
ucb: { total: 1, arms: {} },
|
|
174
|
-
lin: { total: 1, tierN: { fast: 1, mid: 0, strong: 0 }, tierW: { fast: new Array(9).fill(0), mid: new Array(9).fill(0), strong: new Array(9).fill(0) } },
|
|
175
|
-
knn: { pool: [] },
|
|
176
|
-
exp3: { total: 1, weights: { ...DEFAULT_WEIGHTS } },
|
|
177
|
-
}
|
|
178
|
-
const rec = { reward: 0.8, clsType: 'qa', ucbTier: 'fast', linTier: 'fast', f: [1, 0, 0, 0, 0, 0, 0, 0, 1], chosen: 'rule', pChosen: 0.7, pool: ['ucb', 'rule'] }
|
|
179
|
-
applyReward(state, rec)
|
|
180
|
-
const snapshot = JSON.stringify(state.ucb)
|
|
181
|
-
correctReward(state, rec, 0.8)
|
|
182
|
-
assert.equal(JSON.stringify(state.ucb), snapshot)
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
test('turnCostYuan:三级价格解析(缓存读计入输入、推理计入输出)', () => {
|
|
186
|
-
const usage = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: 500, reasoningTokens: 50 }
|
|
187
|
-
// 1) 精确模型价:deepseek-v4-pro = (1500×1 + 150×12)/1e6 = 0.0033
|
|
188
|
-
assert.ok(Math.abs(turnCostYuan(usage, 'deepseek-official', 'deepseek-v4-pro', 'strong', DEFAULT_PRICES) - 0.0033) < 1e-9)
|
|
189
|
-
// 2) provider 档位价:acme/xyz-pro 无精确价 → acme.strong = (1500×5 + 150×50)/1e6 = 0.015
|
|
190
|
-
const acme = { acme: { strong: { in: 5, out: 50 } } }
|
|
191
|
-
assert.ok(Math.abs(turnCostYuan(usage, 'acme', 'xyz-pro', 'strong', acme) - 0.015) < 1e-9)
|
|
192
|
-
// 3) 未知 provider 用 '*' 通用档位(绝不套用 DeepSeek 价):'*'.fast = (1500×1 + 150×4)/1e6 = 0.0021
|
|
193
|
-
assert.ok(Math.abs(turnCostYuan(usage, 'mystery', 'some-model', 'fast', DEFAULT_PRICES) - 0.0021) < 1e-9)
|
|
194
|
-
// 4) 连 '*' 都没有 → 保守兜底 (3,16) = (1500×3 + 150×16)/1e6 = 0.0069
|
|
195
|
-
assert.ok(Math.abs(turnCostYuan(usage, 'mystery', 'some-model', 'fast', {}) - 0.0069) < 1e-9)
|
|
196
|
-
assert.equal(turnCostYuan(null, 'deepseek-official', 'deepseek-v4-pro', 'strong', DEFAULT_PRICES), 0)
|
|
197
|
-
})
|
|
198
|
-
|
|
199
|
-
test('resolvePrice 返回来源标注(透明审计)', () => {
|
|
200
|
-
const acme = { acme: { strong: { in: 5, out: 50 } } }
|
|
201
|
-
assert.equal(resolvePrice('deepseek-official', 'deepseek-v4-pro', 'strong', DEFAULT_PRICES).source, 'provider-model')
|
|
202
|
-
assert.equal(resolvePrice('acme', 'xyz-pro', 'strong', acme).source, 'provider-tier')
|
|
203
|
-
assert.equal(resolvePrice('mystery', 'some-model', 'fast', DEFAULT_PRICES).source, 'generic-tier')
|
|
204
|
-
assert.equal(resolvePrice('mystery', 'some-model', 'fast', {}).source, 'unknown-conservative')
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
test('rewardOf 成本项:weight×元封顶 cap', () => {
|
|
208
|
-
const td = { toolErrors: 0, retries: 0, costYuan: 0.02, costWeight: 20, costCap: 0.5 }
|
|
209
|
-
const r = rewardOf(td, 'clean-close', 1000)
|
|
210
|
-
assert.ok(Math.abs(r.reward - 0.6) < 1e-9, '1 − 0.4 = 0.6,实际 ' + r.reward)
|
|
211
|
-
assert.equal(r.breakdown.costPenalty, 0.4)
|
|
212
|
-
assert.equal(r.breakdown.costYuan, 0.02)
|
|
213
|
-
|
|
214
|
-
const td2 = { toolErrors: 0, retries: 0, costYuan: 0.1, costWeight: 20, costCap: 0.5 }
|
|
215
|
-
const r2 = rewardOf(td2, 'clean-close', 1000)
|
|
216
|
-
assert.equal(r2.breakdown.costPenalty, 0.5, '惩罚封顶 0.5')
|
|
217
|
-
assert.equal(r2.reward, 0.5)
|
|
218
|
-
})
|