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 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 || !provider) return all
62
- const has = all.filter((t) => tierTarget(t, provider, modelTiers))
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: 'router' })
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
- let prices = (policy.prices && Object.keys(policy.prices).length) ? policy.prices : (config.prices || DEFAULT_PRICES)
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
- const resolved = state.forced
340
- ? { target: state.forced, used: 'forced', note: 'forced' }
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
- // 兜底应用:request 路径决策未应用时,在 header 落盘后补切换
503
- if (td.needsApply && !td.applied) {
504
- let session = null
505
- try {
506
- const sessions = ctx.get('sessions')
507
- if (sessions && typeof sessions.get === 'function') session = sessions.get(sid) || null
508
- } catch (e) { session = null }
509
- if (session) applyDecision(session, key, td, td.rec, td.target)
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 (agent/inbox/claimed)',
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 次数;lastActual 为最近一步真实出网模型;decisions[].applied=true 且 swapped=true 即真实切换成功',
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 模式生效);clear=true 清除强制,恢复规则路由。',
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) !== args.model) {
698
- const ok = appendRouterHeader(session, args.model)
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
- // 档位 → 模型 id 的解析;mid 档无候选时向上取 strong(保质量),再取 fast
25
- export function resolveTier(tier, provider, modelTiers) {
26
- if (!modelTiers) return { target: null, used: tier, note: 'tiers-not-ready' }
27
- const target = tierTarget(tier, provider, modelTiers)
28
- if (target) return { target, used: tier, note: '' }
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 = tierTarget('strong', provider, modelTiers)
31
- if (strong) return { target: strong, used: 'strong', note: 'mid->strong fallback' }
32
- const fast = tierTarget('fast', provider, modelTiers)
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.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)",
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
- }
@@ -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
- })
@@ -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
- })