dsh-agentic-router 1.0.0 → 1.3.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.en.md CHANGED
@@ -40,21 +40,42 @@ dsh plugin --profile web add dsh-agentic-router
40
40
  | `agentic_router_set_mode` | shadow (default) / active (real swaps) / off (bypass) |
41
41
  | `agentic_router_force` | force a model id (active mode only); clear=true to release |
42
42
  | `agentic_router_reset` | wipe flywheel learning state, keep mode |
43
+ | `agentic_router_set_prices` | set/query per-provider per-model (or per-tier) prices |
43
44
 
44
45
  ## Data flywheel (persistence)
45
46
 
46
47
  Directory: `${DSH_HOME:-~/.dsh}/storages/dsh-agentic-router/` (override with `AGENTIC_ROUTER_HOME`)
47
48
 
48
49
  - `decisions.jsonl` — one line per decision: task type/complexity, all four recommendations, meta pick, actual route, swapped?
49
- - `rewards.jsonl` — one line per reward with breakdown (tool errors/retries/latency) + feature vector; **replayed on startup to restore learning state**
50
+ - `rewards.jsonl` — one line per reward with breakdown (tool errors/retries/latency) + feature vector; **replayed on startup to restore learning state**; human-feedback settlements (`feedback-settle` lines) correct the replayed rewards exactly
51
+ - Human feedback: 45s after a turn closes, the plugin polls `messageFeedback` (the Web UI 👍/👎); a thumbs-down costs −0.5, a thumbs-up adds +0.1, applied as an exact correction to the already-applied reward
50
52
  - `policy.json` — mode and forced model (atomic write)
51
53
 
52
- Reward: clean close starts at 1; tool failures −0.2 each (cap 0.6), model retries −0.2 each (cap 0.4), latency >30s −0.1 / >90s −0.3; errored turns score 0.
54
+ Reward: clean close starts at 1; tool failures −0.2 each (cap 0.6), model retries −0.2 each (cap 0.4),
55
+ **cost** min(0.5, 20 × turn cost in CNY), latency >30s −0.1 / >90s −0.3; errored turns score 0.
56
+
57
+ **Cost signal (token billing)**: the plugin wraps `llm/stream` and accumulates each model
58
+ call's `usage` chunk (input/output/cache-read/reasoning tokens); providers without usage
59
+ fall back to length-based estimates (flagged `estimated`).
60
+
61
+ **Three-level price resolution** (CNY per million tokens): `provider→model` exact price >
62
+ `provider→tier` price > `*` generic tier > conservative fallback. **Unknown providers
63
+ never inherit DeepSeek pricing** — they use the generic tier and carry a `pricingSource`
64
+ audit field. DeepSeek's official pricing ships as default; add other models at runtime via
65
+ the `agentic_router_set_prices` tool (persisted to policy.json) or at install via
66
+ `config.prices`:
67
+
68
+ ```js
69
+ { 'deepseek-official': { 'deepseek-v4-pro': { in: 1, out: 12 } },
70
+ openai: { 'gpt-x-pro': { in: 15, out: 60 } },
71
+ '*': { fast: { in: 1, out: 4 }, mid: { in: 2, out: 8 }, strong: { in: 3, out: 16 } } }
72
+ ```
53
73
 
54
74
  ## Known limits
55
75
 
56
76
  - Model tiers are recognized by id keywords (flash→fast, pro→strong); real swaps happen only when more than one tier exists
57
- - Rewards are proxies: a mediocre-but-successful answer still scores full marks, so wire up UI feedback (messageFeedback) for production use
77
+ - Feedback settles after a 45s window (configurable via `settleDelayMs`); unsettled pending entries are lost on restart; late feedback may be attributed to a later turn
78
+ - Feedback only works where the deployment's Web UI actually writes it (this plugin only reads)
58
79
  - LinUCB / k-NN need dozens of turns before they graduate into the meta pool
59
80
 
60
81
  ## Development
package/README.md CHANGED
@@ -40,16 +40,33 @@ dsh plugin --profile web add dsh-agentic-router
40
40
  | `agentic_router_set_mode` | 切换 shadow(默认)/ active(真切换)/ off(旁路) |
41
41
  | `agentic_router_force` | 强制指定模型 id(active 生效),clear=true 清除 |
42
42
  | `agentic_router_reset` | 清空飞轮学习状态,保留模式 |
43
+ | `agentic_router_set_prices` | 配置/查询某 provider 某模型(或档位)的价格 |
43
44
 
44
45
  ## 数据飞轮(落盘)
45
46
 
46
47
  目录:`${DSH_HOME:-~/.dsh}/storages/dsh-agentic-router/`(`AGENTIC_ROUTER_HOME` 可覆盖)
47
48
 
48
49
  - `decisions.jsonl` — 每条决策:任务类型/复杂度/四专家推荐/元层选择/实际路由/是否切换
49
- - `rewards.jsonl` — 每条奖励:明细(工具失败/重试/延迟)+ 特征向量,**重启后重放恢复学习状态**
50
+ - `rewards.jsonl` — 每条奖励:明细(工具失败/重试/延迟)+ 特征向量,**重启后重放恢复学习状态**;人类反馈结算(`feedback-settle` 行)在重放时精确修正
51
+ - 人类反馈:回合结束 45s 后查询 `messageFeedback`(Web UI 的 👍/👎),点踩 −0.5、点赞 +0.1,修正已应用的奖励(UCB 均值/EXP3 权重精确回退)
50
52
  - `policy.json` — 模式与强制模型(原子写)
51
53
 
52
- 奖励公式:干净收尾 1 分起,工具失败 −0.2/次(封顶 0.6)、模型重试 −0.2/次(封顶 0.4)、延迟 >30s −0.1 / >90s −0.3;出错回合记 0。
54
+ 奖励公式:干净收尾 1 分起,工具失败 −0.2/次(封顶 0.6)、模型重试 −0.2/次(封顶 0.4)、
55
+ **成本** min(0.5, 20×回合成本元)、延迟 >30s −0.1 / >90s −0.3;出错回合记 0。
56
+
57
+ **成本信号(token 计费)**:透传 `llm/stream`,累加每个模型调用的 `usage` chunk
58
+ (输入/输出/缓存读/推理 token);无 usage 的 provider 按文本长度估算(标 `estimated`)。
59
+
60
+ **价格表三级解析**(元/百万 token):`provider→model` 精确价 > `provider→档位` 价 >
61
+ `*` 通用档位 > 保守兜底。**未知 provider 绝不套用 DeepSeek 价格**,用通用档位并标记
62
+ `pricingSource`(审计字段)。默认含 DeepSeek 官方口径,接入其他模型用
63
+ `agentic_router_set_prices` 工具配价(持久化到 policy.json),或安装时传 `config.prices`:
64
+
65
+ ```js
66
+ { 'deepseek-official': { 'deepseek-v4-pro': { in: 1, out: 12 } },
67
+ openai: { 'gpt-x-pro': { in: 15, out: 60 } },
68
+ '*': { fast: { in: 1, out: 4 }, mid: { in: 2, out: 8 }, strong: { in: 3, out: 16 } } }
69
+ ```
53
70
 
54
71
  ## 分类器(规则先验,<1ms)
55
72
 
@@ -59,7 +76,8 @@ dsh plugin --profile web add dsh-agentic-router
59
76
  ## 已知边界
60
77
 
61
78
  - 模型档位靠 id 关键词识别(flash→fast、pro→strong);可用模型多于一档时路由才真正切换
62
- - 奖励信号是"代理":任务成功但质量平庸的情况(尤其模型池内质量差距大时)仍可能被记满分,正式场景建议接入 UI 反馈(messageFeedback)
79
+ - 反馈结算窗口 45s(可配置 `settleDelayMs`),窗口内无反馈按代理奖励结算;重启会丢失未结算的 pending 条目;晚到的反馈可能归属到其后的回合
80
+ - 反馈依赖部署的 Web UI 实际产生数据(本插件只读取,不写入)
63
81
  - 毕业策略(LinUCB/k-NN)需要数十个回合样本才会进元层池
64
82
 
65
83
  ## 开发
package/dsh/index.js CHANGED
@@ -15,7 +15,8 @@ import { classify, featuresOf } from './classify.js'
15
15
  import {
16
16
  tierOf, tierTarget, resolveTier,
17
17
  recommendRule, recommendUcb, recommendLin, recommendKnn,
18
- rewardOf, applyReward,
18
+ rewardOf, applyReward, correctReward,
19
+ DEFAULT_PRICES, turnCostYuan, resolvePrice,
19
20
  } from './strategies.js'
20
21
  import { metaPick, DEFAULT_WEIGHTS } from './meta.js'
21
22
  import { Store, defaultHome } from './store.js'
@@ -25,6 +26,8 @@ export const inject = ['tools']
25
26
 
26
27
  const LIN_GRADUATION = 50 // LinUCB 毕业样本线
27
28
  const KNN_GRADUATION = 30 // k-NN 毕业经验线
29
+ const SETTLE_DELAY_MS = 45000 // 反馈结算窗口:回合结束 45s 后查询 messageFeedback
30
+ const SWEEP_MS = 15000 // 结算轮询周期
28
31
 
29
32
  function freshState() {
30
33
  return {
@@ -36,6 +39,8 @@ function freshState() {
36
39
  modelTiers: null,
37
40
  turnDecision: {},
38
41
  openTurns: new Map(),
42
+ pendingFeedback: [],
43
+ recByTurn: new Map(),
39
44
  ucb: { total: 0, arms: {} },
40
45
  lin: {
41
46
  total: 0,
@@ -124,24 +129,86 @@ async function refreshModelTiers(llm) {
124
129
  export function apply(ctx, config = {}) {
125
130
  const tools = ctx.get('tools')
126
131
  const llm = ctx.get('llm')
132
+ const mf = ctx.get('messageFeedback')
133
+ const timer = ctx.get('timer')
127
134
  const store = new Store(config.home || defaultHome())
135
+ const settleDelayMs = typeof config.settleDelayMs === 'number' ? config.settleDelayMs : SETTLE_DELAY_MS
128
136
  const policy = store.loadPolicy()
137
+ let prices = (policy.prices && Object.keys(policy.prices).length) ? policy.prices : (config.prices || DEFAULT_PRICES)
138
+ const costWeight = typeof config.costWeight === 'number' ? config.costWeight : 20
139
+ const costCap = typeof config.costCap === 'number' ? config.costCap : 0.5
129
140
 
130
141
  const state = freshState()
131
142
  state.mode = policy.mode || config.mode || 'shadow'
132
143
  if (typeof policy.forced === 'string') state.forced = policy.forced
133
144
 
134
- // 飞轮恢复:重放 rewards.jsonl 重建 UCB/LinUCB/k-NN/EXP3
145
+ // 飞轮恢复:重放 rewards.jsonl 重建 UCB/LinUCB/k-NN/EXP3;
146
+ // feedback-settle 行在基础奖励重放后按 turn 精确修正
135
147
  const rewards = store.loadJsonl('rewards.jsonl')
148
+ const byTurn = new Map()
136
149
  for (const rec of rewards) {
137
- if (typeof rec.reward === 'number' && Array.isArray(rec.f)) applyReward(state, rec)
150
+ if (rec.type === 'feedback-settle') continue
151
+ if (typeof rec.reward === 'number' && Array.isArray(rec.f)) {
152
+ applyReward(state, rec)
153
+ byTurn.set(rec.turn, rec)
154
+ }
155
+ }
156
+ for (const rec of rewards) {
157
+ if (rec.type !== 'feedback-settle') continue
158
+ const baseRec = byTurn.get(rec.turn)
159
+ if (baseRec) correctReward(state, baseRec, rec.newReward)
138
160
  }
161
+ state.recByTurn = byTurn
139
162
  state.decisions = store.loadJsonl('decisions.jsonl').slice(-60)
140
163
  state.seq = state.decisions.length ? state.decisions[state.decisions.length - 1].seq : 0
141
164
 
142
165
  const log = (msg) => console.log('[agentic-router]', msg)
143
166
  if (rewards.length) log('replayed ' + rewards.length + ' rewards; ucb.total=' + state.ucb.total)
144
167
 
168
+ let mfWarned = false
169
+ // 反馈结算:对已过结算窗口的回合查询 messageFeedback,按人类 👍/👎 修正奖励
170
+ async function settleFeedback() {
171
+ if (!mf || typeof mf.list !== 'function') {
172
+ if (!mfWarned) { log('messageFeedback service absent — 反馈奖励停用'); mfWarned = true }
173
+ return
174
+ }
175
+ const tNow = now()
176
+ if (tNow == null) return
177
+ const remaining = []
178
+ for (const p of state.pendingFeedback) {
179
+ if (tNow - p.t0 < settleDelayMs) { remaining.push(p); continue }
180
+ try {
181
+ const res = await mf.list({ sessionId: p.sessionId })
182
+ let neg = 0
183
+ let pos = 0
184
+ if (res && res.ok === true && res.value && Array.isArray(res.value.items)) {
185
+ for (const it of res.value.items) {
186
+ if (typeof it.createdAt !== 'number' || it.createdAt < p.t0) continue
187
+ if (it.rating === 'negative') neg += 1
188
+ else if (it.rating === 'positive') pos += 1
189
+ }
190
+ }
191
+ const newReward = Math.max(0, Math.min(1, Math.round((p.appliedReward - 0.5 * neg + 0.1 * pos) * 100) / 100))
192
+ if (newReward !== p.appliedReward) {
193
+ const baseRec = state.recByTurn.get(p.key)
194
+ if (baseRec) correctReward(state, baseRec, newReward)
195
+ store.appendJsonl('rewards.jsonl', {
196
+ type: 'feedback-settle', turn: p.key, sessionId: p.sessionId,
197
+ negative: neg, positive: pos, applied: p.appliedReward, newReward,
198
+ })
199
+ log('feedback settle ' + p.key + ': ' + p.appliedReward + ' -> ' + newReward + ' (neg=' + neg + ' pos=' + pos + ')')
200
+ }
201
+ } catch (e) {
202
+ log('feedback settle failed: ' + String(e))
203
+ }
204
+ }
205
+ state.pendingFeedback = remaining
206
+ }
207
+
208
+ if (timer && typeof timer.interval === 'function') {
209
+ timer.interval(() => { settleFeedback().catch((e) => log('settle sweep failed: ' + String(e))) }, SWEEP_MS)
210
+ }
211
+
145
212
  // ---- 事件挂载 ----
146
213
  ctx.on('agent/pre-step', (payload, next) => {
147
214
  try {
@@ -157,6 +224,7 @@ export function apply(ctx, config = {}) {
157
224
  const cfg = await next()
158
225
  if (!cfg || typeof cfg !== 'object') return cfg
159
226
  try {
227
+ if (!timer || typeof timer.interval !== 'function') settleFeedback().catch(() => {})
160
228
  const key = fullKey(payload)
161
229
  if (state.turnDecision[key]) return cfg
162
230
  const pkey = agentKey(payload) + ':' + payload.turn
@@ -209,6 +277,8 @@ export function apply(ctx, config = {}) {
209
277
  state.turnDecision[key] = {
210
278
  clsType: cls.type, f, recs, meta, chosen: meta.chosen,
211
279
  finalized: false, t0: now(), toolErrors: 0, retries: 0,
280
+ currentModel: current, provider, costWeight, costCap,
281
+ estInputTokens: Math.ceil(text.length / 1.5),
212
282
  }
213
283
  state.openTurns.set(agentKey(payload), key)
214
284
  store.appendJsonl('decisions.jsonl', record)
@@ -242,6 +312,52 @@ export function apply(ctx, config = {}) {
242
312
  return next()
243
313
  })
244
314
 
315
+ // 成本信号:透传每次流式模型调用,累加 usage chunk 到当前 open turn;
316
+ // 无 usage 的 provider 用文本长度估算(estimated=true)
317
+ ctx.on('llm/stream', function (options, next) {
318
+ return (async function* () {
319
+ let usage = null
320
+ let outTextLen = 0
321
+ try {
322
+ for await (const chunk of next()) {
323
+ if (chunk && chunk.type === 'usage' && chunk.usage) usage = chunk.usage
324
+ if (chunk && chunk.type === 'text-delta' && typeof chunk.text === 'string') outTextLen += chunk.text.length
325
+ yield chunk
326
+ }
327
+ } finally {
328
+ try {
329
+ const sid = options && (options.sessionId || (options.session && options.session.id))
330
+ const key = sid ? state.openTurns.get(sid) : null
331
+ const td = key ? state.turnDecision[key] : null
332
+ if (td && !td.finalized) {
333
+ const u = td.usage || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, reasoningTokens: 0, estimated: false }
334
+ if (usage && typeof usage.inputTokens === 'number') {
335
+ u.inputTokens += usage.inputTokens
336
+ u.outputTokens += usage.outputTokens || 0
337
+ u.cacheReadTokens += usage.cacheReadTokens || 0
338
+ u.reasoningTokens += usage.reasoningTokens || 0
339
+ u.estimated = false
340
+ } else {
341
+ u.inputTokens += td.estInputTokens || 0
342
+ u.outputTokens += Math.round(outTextLen / 2)
343
+ u.estimated = true
344
+ }
345
+ td.usage = u
346
+ const model = typeof td.currentModel === 'string' ? td.currentModel : ''
347
+ const tier = tierOf(model)
348
+ const provider = (options && typeof options.provider === 'string' && options.provider) || td.provider || ''
349
+ const rp = resolvePrice(provider, model, tier, prices)
350
+ td.costYuan = turnCostYuan(u, provider, model, tier, prices)
351
+ td.pricingSource = rp.source
352
+ log('usage on ' + key + ': in=' + u.inputTokens + ' out=' + u.outputTokens + (u.estimated ? ' (估)' : '') + ' cost=¥' + td.costYuan.toFixed(4) + ' [' + rp.source + ']')
353
+ }
354
+ } catch (e) {
355
+ log('usage hook failed: ' + String(e))
356
+ }
357
+ }
358
+ })()
359
+ })
360
+
245
361
  ctx.on('tools/result', (exec, result) => {
246
362
  try {
247
363
  if (!isErrorResult(result)) return
@@ -283,6 +399,15 @@ export function apply(ctx, config = {}) {
283
399
  }
284
400
  applyReward(state, rec)
285
401
  store.appendJsonl('rewards.jsonl', rec)
402
+ state.recByTurn.set(key, rec)
403
+ if (state.recByTurn.size > 500) {
404
+ const first = state.recByTurn.keys().next().value
405
+ if (first !== undefined) state.recByTurn.delete(first)
406
+ }
407
+ if (mf && typeof mf.list === 'function' && td.t0 != null) {
408
+ state.pendingFeedback.push({ key, sessionId: agentKey(payload), t0: td.t0, appliedReward: rec.reward })
409
+ if (state.pendingFeedback.length > 200) state.pendingFeedback.shift()
410
+ }
286
411
  const ak = agentKey(payload)
287
412
  if (state.openTurns.get(ak) === key) state.openTurns.delete(ak)
288
413
  log('reward ' + rec.reward + ' ' + tag + ' on ' + key + ' breakdown=' + JSON.stringify(r.breakdown))
@@ -325,11 +450,14 @@ export function apply(ctx, config = {}) {
325
450
  forcedModel: state.forced,
326
451
  totalDecisions: state.seq,
327
452
  modelTiers: state.modelTiers,
453
+ prices,
328
454
  flywheel: {
329
455
  ucb: state.ucb,
330
456
  lin: { total: state.lin.total, tierN: state.lin.tierN, tierW: state.lin.tierW },
331
457
  knnPoolSize: state.knn.pool.length,
332
458
  exp3: state.exp3,
459
+ pendingFeedback: state.pendingFeedback.length,
460
+ prices,
333
461
  recentRewards: store.loadJsonl('rewards.jsonl').slice(-20),
334
462
  },
335
463
  recent: state.decisions.slice(-20),
@@ -348,7 +476,7 @@ export function apply(ctx, config = {}) {
348
476
  isConcurrencySafe: () => true,
349
477
  async execute(args) {
350
478
  state.mode = args.mode
351
- store.savePolicy({ mode: state.mode, forced: state.forced })
479
+ store.savePolicy({ mode: state.mode, forced: state.forced, prices })
352
480
  return { mode: state.mode }
353
481
  },
354
482
  })
@@ -367,17 +495,45 @@ export function apply(ctx, config = {}) {
367
495
  async execute(args) {
368
496
  if (args && args.clear) {
369
497
  state.forced = null
370
- store.savePolicy({ mode: state.mode, forced: null })
498
+ store.savePolicy({ mode: state.mode, forced: null, prices })
371
499
  return { forced: null }
372
500
  }
373
501
  if (args && typeof args.model === 'string') {
374
502
  state.forced = args.model
375
- store.savePolicy({ mode: state.mode, forced: args.model })
503
+ store.savePolicy({ mode: state.mode, forced: args.model, prices })
376
504
  return { forced: args.model }
377
505
  }
378
506
  return { forced: state.forced, error: '需要 model 或 clear=true' }
379
507
  },
380
508
  })
509
+ register({
510
+ name: 'agentic_router_set_prices',
511
+ description: '配置/查询某 provider 某模型(或档位)的价格(元/百万 tokens,{in, out})。不带 priceIn/priceOut 时返回当前价格表。接入新模型后用它补价格,未知价格按保守值估算并标记 unknown。',
512
+ parameters: {
513
+ type: 'object',
514
+ properties: {
515
+ provider: { type: 'string', description: 'provider id,如 openai、anthropic、deepseek-official' },
516
+ model: { type: 'string', description: '可选:精确模型 id;省略则设置该 provider 的档位价' },
517
+ tier: { type: 'string', enum: ['fast', 'mid', 'strong'], description: '档位(未给 model 时必填)' },
518
+ priceIn: { type: 'number', description: '输入价(元/百万 tokens),负数视为查询模式' },
519
+ priceOut: { type: 'number', description: '输出价(元/百万 tokens),负数视为查询模式' },
520
+ },
521
+ required: ['provider'],
522
+ },
523
+ output: OUT('agentic router 价格表'),
524
+ isConcurrencySafe: () => true,
525
+ async execute(args) {
526
+ if (typeof args.priceIn !== 'number' || typeof args.priceOut !== 'number' || args.priceIn < 0 || args.priceOut < 0) {
527
+ return { prices, note: '查看模式:传 priceIn/priceOut 与 model 或 tier 进行设置' }
528
+ }
529
+ const byProvider = prices[args.provider] || (prices[args.provider] = {})
530
+ if (args.model) byProvider[args.model] = { in: args.priceIn, out: args.priceOut }
531
+ else if (args.tier) byProvider[args.tier] = { in: args.priceIn, out: args.priceOut }
532
+ else return { error: '需要 model 或 tier', prices }
533
+ store.savePolicy({ mode: state.mode, forced: state.forced, prices })
534
+ return { prices, updated: args.model || (args.provider + '/' + args.tier) }
535
+ },
536
+ })
381
537
  register({
382
538
  name: 'agentic_router_reset',
383
539
  description: '清空飞轮学习状态(决策与奖励历史),保留当前模式;用于重新开始学习或清理测试数据。',
package/dsh/strategies.js CHANGED
@@ -105,28 +105,76 @@ export function recommendKnn(cls, f, knnState) {
105
105
  return { tier: best.tier, reason: 'knn k=' + k + ' neighbors score=' + Math.round(best.score * 100) / 100, conf: Math.min(0.8, pool.length / 100) }
106
106
  }
107
107
 
108
- // 质量代理奖励:干净收尾 1 分起,扣工具失败(0.2/次,封顶 0.6)、模型重试(0.2/次,封顶 0.4)、延迟(>90s −0.3, >30s −0.1)
108
+ // ---- 成本信号(token 计费) ----
109
+ // 价格表(元/百万 tokens)三级解析:
110
+ // 1) prices[provider][model] 精确模型价(最高优先)
111
+ // 2) prices[provider][tier] 该 provider 的档位价
112
+ // 3) prices['*'][tier] 通用档位兜底(未知 provider 不套用 DeepSeek 价)
113
+ // 4) CONSERVATIVE 保守值 + 标记 unknown,提醒用户补价格
114
+ export const DEFAULT_PRICES = {
115
+ 'deepseek-official': {
116
+ 'deepseek-v4-flash': { in: 0.2, out: 2 },
117
+ 'deepseek-v4-pro': { in: 1, out: 12 },
118
+ },
119
+ '*': {
120
+ fast: { in: 1, out: 4 },
121
+ mid: { in: 2, out: 8 },
122
+ strong: { in: 3, out: 16 },
123
+ },
124
+ }
125
+
126
+ const CONSERVATIVE = { in: 3, out: 16 }
127
+
128
+ // 解析价格并返回来源(透明标注:价格到底从哪来的)
129
+ export function resolvePrice(provider, model, tier, prices) {
130
+ const table = prices || DEFAULT_PRICES
131
+ const byProvider = table[provider]
132
+ if (byProvider && byProvider[model]) return { price: byProvider[model], source: 'provider-model' }
133
+ if (byProvider && byProvider[tier]) return { price: byProvider[tier], source: 'provider-tier' }
134
+ const generic = table['*']
135
+ if (generic && generic[tier]) return { price: generic[tier], source: 'generic-tier' }
136
+ return { price: CONSERVATIVE, source: 'unknown-conservative' }
137
+ }
138
+
139
+ // 单回合成本(元):缓存读计入输入,推理 token 计入输出
140
+ export function turnCostYuan(usage, provider, model, tier, prices) {
141
+ if (!usage) return 0
142
+ const { price } = resolvePrice(provider, model, tier, prices)
143
+ const input = (usage.inputTokens || 0) + (usage.cacheReadTokens || 0)
144
+ const output = (usage.outputTokens || 0) + (usage.reasoningTokens || 0)
145
+ return (price.in * input + price.out * output) / 1e6
146
+ }
147
+
148
+ // 质量代理奖励:干净收尾 1 分起,扣工具失败(0.2/次,封顶 0.6)、模型重试(0.2/次,封顶 0.4)、
149
+ // 延迟(>90s −0.3, >30s −0.1)、成本(min(cap, weight × 元),默认 weight=20 即每 0.01 元 −0.2)
109
150
  export function rewardOf(td, tag, dtMs) {
110
151
  if (tag !== 'clean-close') {
111
- return { reward: 0, breakdown: { base: 0, toolErrors: td.toolErrors || 0, retries: td.retries || 0, dtMs } }
152
+ return { reward: 0, breakdown: { base: 0, toolErrors: td.toolErrors || 0, retries: td.retries || 0, dtMs, costYuan: td.costYuan || 0, costPenalty: 0, pricingSource: td.pricingSource || 'n/a' } }
112
153
  }
113
154
  let reward = 1
114
155
  const toolPenalty = Math.min(0.6, 0.2 * (td.toolErrors || 0))
115
156
  const retryPenalty = Math.min(0.4, 0.2 * (td.retries || 0))
157
+ const costCap = typeof td.costCap === 'number' ? td.costCap : 0.5
158
+ const costWeight = typeof td.costWeight === 'number' ? td.costWeight : 20
159
+ const costYuan = typeof td.costYuan === 'number' ? td.costYuan : 0
160
+ const costPenalty = Math.min(costCap, costWeight * costYuan)
116
161
  reward -= toolPenalty
117
162
  reward -= retryPenalty
163
+ reward -= costPenalty
118
164
  if (dtMs != null) {
119
165
  if (dtMs > 90000) reward -= 0.3
120
166
  else if (dtMs > 30000) reward -= 0.1
121
167
  }
122
168
  return {
123
169
  reward: Math.max(0, Math.round(reward * 100) / 100),
124
- breakdown: { base: 1, toolErrors: td.toolErrors || 0, toolPenalty, retries: td.retries || 0, retryPenalty, dtMs },
170
+ breakdown: { base: 1, toolErrors: td.toolErrors || 0, toolPenalty, retries: td.retries || 0, retryPenalty, costYuan, costPenalty, dtMs, pricingSource: td.pricingSource || 'n/a' },
125
171
  }
126
172
  }
127
173
 
128
174
  // 把一条奖励记录应用到飞轮(UCB/LinUCB/k-NN/EXP3),replay 与实时更新共用
129
175
  export function applyReward(state, rec) {
176
+ // 记录本次线性更新的学习率,供反馈结算修正精确回退
177
+ rec.linLr = 1 / (1 + (state.lin.tierN[rec.linTier] || 0))
130
178
  const armKey = rec.clsType + ':' + rec.ucbTier
131
179
  const arm = state.ucb.arms[armKey] || (state.ucb.arms[armKey] = { n: 0, mean: 0 })
132
180
  arm.n += 1
@@ -151,3 +199,30 @@ export function applyReward(state, rec) {
151
199
  }
152
200
  state.exp3.total += 1
153
201
  }
202
+
203
+ // 反馈结算修正:把已应用奖励改为 newReward(人类在反馈窗口内点了 👍/👎 之后)
204
+ // UCB 均值修正(n 不变);LinUCB 用原始学习率做精确梯度回退;k-NN 追加修正经验;
205
+ // EXP3 按乘性更新做精确逆修正。
206
+ export function correctReward(state, rec, newReward) {
207
+ const delta = newReward - rec.reward
208
+ if (!delta) return { delta: 0 }
209
+
210
+ const arm = state.ucb.arms[rec.clsType + ':' + rec.ucbTier]
211
+ if (arm && arm.n > 0) arm.mean = arm.mean + delta / arm.n
212
+
213
+ const lw = state.lin.tierW[rec.linTier]
214
+ if (lw && Array.isArray(rec.f)) {
215
+ const lr = typeof rec.linLr === 'number' ? rec.linLr : 1 / (1 + (state.lin.tierN[rec.linTier] || 0))
216
+ for (let i = 0; i < rec.f.length; i++) lw[i] = (lw[i] || 0) + lr * delta * rec.f[i]
217
+ }
218
+
219
+ if (state.knn && Array.isArray(rec.f)) state.knn.pool.push({ f: rec.f, tier: rec.ucbTier, r: newReward })
220
+
221
+ if (rec.pChosen > 0 && rec.pool && rec.pool.length > 0) {
222
+ state.exp3.weights[rec.chosen] = (state.exp3.weights[rec.chosen] || 0) *
223
+ Math.exp(0.3 * delta / (rec.pChosen * rec.pool.length))
224
+ }
225
+
226
+ rec.reward = newReward
227
+ return { delta }
228
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agentic-router",
3
- "version": "1.0.0",
3
+ "version": "1.3.0",
4
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",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "LICENSE"
34
34
  ],
35
35
  "scripts": {
36
- "test": "node --test test/"
36
+ "test": "node --test"
37
37
  },
38
38
  "dsh": {
39
39
  "bundle": {