@ljwei-stak/dsh-model-router 0.14.0 → 0.16.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.
@@ -14,6 +14,7 @@
14
14
  import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
15
15
  import { homedir } from 'node:os'
16
16
  import { dirname, isAbsolute, join, resolve } from 'node:path'
17
+ import { archiveSpending, sanitizeArchivedSpending } from './run-ledger.mjs'
17
18
 
18
19
  export const MAX_RUNS = 200
19
20
  const STATE_VERSION = 1
@@ -38,7 +39,7 @@ const LOCK_STALE_MS = 15_000
38
39
  const LOCK_TIMEOUT_MS = 10_000
39
40
 
40
41
  function emptyState() {
41
- return { version: STATE_VERSION, onboarding: { completedAt: null }, health: null, quota: {}, authFailures: {}, runs: [], notices: [], terminalSessions: [] }
42
+ return { version: STATE_VERSION, onboarding: { completedAt: null }, health: null, quota: {}, authFailures: {}, runs: [], dynamicData: { liveBench: null, pricing: null, status: {}, revision: '' }, archivedSpending: sanitizeArchivedSpending(), notices: [], terminalSessions: [] }
42
43
  }
43
44
 
44
45
  function sanitizeNotices(value) {
@@ -47,19 +48,28 @@ function sanitizeNotices(value) {
47
48
  : []
48
49
  }
49
50
 
50
- function sanitize(value) {
51
+ function retainRuns(state, maxRuns) {
52
+ const removed = Math.max(0, state.runs.length - maxRuns)
53
+ state.archivedSpending = archiveSpending(state.runs.slice(0, removed), state.archivedSpending)
54
+ state.runs = state.runs.slice(removed)
55
+ return state
56
+ }
57
+
58
+ function sanitize(value, maxRuns) {
51
59
  if (!value || typeof value !== 'object' || value.version !== STATE_VERSION) return emptyState()
52
- return {
60
+ return retainRuns({
53
61
  version: STATE_VERSION,
54
62
  onboarding: { completedAt: Number.isFinite(value.onboarding?.completedAt) ? value.onboarding.completedAt : null },
55
63
  health: value.health && Array.isArray(value.health.tools) ? value.health : null,
56
64
  quota: value.quota && typeof value.quota === 'object' && !Array.isArray(value.quota) ? value.quota : {},
57
65
  authFailures: Object.fromEntries(Object.entries(value.authFailures && typeof value.authFailures === 'object' && !Array.isArray(value.authFailures) ? value.authFailures : {})
58
66
  .filter(([, entry]) => Number.isFinite(entry?.at))),
59
- runs: Array.isArray(value.runs) ? value.runs.filter(run => run && typeof run.id === 'string').slice(-MAX_RUNS) : [],
67
+ runs: Array.isArray(value.runs) ? value.runs.filter(run => run && typeof run.id === 'string') : [],
68
+ dynamicData: value.dynamicData && typeof value.dynamicData === 'object' && !Array.isArray(value.dynamicData) ? value.dynamicData : { liveBench: null, pricing: null, status: {}, revision: '' },
69
+ archivedSpending: sanitizeArchivedSpending(value.archivedSpending),
60
70
  notices: sanitizeNotices(value.notices),
61
71
  terminalSessions: sanitizeTerminalSessions(value.terminalSessions),
62
- }
72
+ }, maxRuns)
63
73
  }
64
74
 
65
75
  function sanitizeTerminalSessions(value) {
@@ -110,6 +120,7 @@ async function acquireLock(lockFile, { staleMs = LOCK_STALE_MS, timeoutMs = LOCK
110
120
  }
111
121
 
112
122
  export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUNS, now = Date.now } = {}) {
123
+ const historyLimit = Number.isInteger(maxRuns) && maxRuns > 0 ? Math.min(maxRuns, MAX_RUNS) : MAX_RUNS
113
124
  let cache = null
114
125
  let cacheKey = ''
115
126
  let chain = Promise.resolve()
@@ -127,7 +138,7 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
127
138
  if (error?.code === 'ENOENT') return { state: emptyState(), corrupt: false }
128
139
  throw error
129
140
  }
130
- try { return { state: sanitize(JSON.parse(raw)), corrupt: false } }
141
+ try { return { state: sanitize(JSON.parse(raw), historyLimit), corrupt: false } }
131
142
  catch {
132
143
  const at = now()
133
144
  const backup = `${file}.corrupt-${stamp(at)}`
@@ -171,7 +182,7 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
171
182
  const state = await load()
172
183
  if (pendingNotices.length) { state.notices.push(...pendingNotices); pendingNotices = [] }
173
184
  const result = await change(state)
174
- state.runs = state.runs.slice(-maxRuns)
185
+ retainRuns(state, historyLimit)
175
186
  state.notices = sanitizeNotices(state.notices)
176
187
  state.terminalSessions = sanitizeTerminalSessions(state.terminalSessions)
177
188
  await persist(state)
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { DEFAULT_ROUTING_PRESET, normalizeRoutingPreset, presetFloor, presetWeights } from './routing-presets.mjs'
10
10
  import { liveBenchRow } from './livebench.mjs'
11
+ import { solveCandidateAssignments } from './assignment-solver.mjs'
11
12
 
12
13
  export const OBJECTIVE_WEIGHTS = Object.freeze({
13
14
  simple: Object.freeze({ quality: 0.28, cost: 0.45, latency: 0.14, specialty: 0.04, reasoning: 0.07, risk: 0.02 }),
@@ -194,11 +195,17 @@ function specialtyMatch(model, taskType, liveScores = {}) {
194
195
  }
195
196
 
196
197
  function qualityForTask(row, taskType) {
197
- const base = asScore(row?.liveScores?.[taskType]) ?? asScore(row?.liveOverall) ?? row?.metadata?.quality ?? row?.quality ?? 0
198
- // User ratings and reviews nudge a route by at most a few points.
198
+ const base = asScore(row?.liveScores?.[taskType]) ?? asScore(row?.liveOverall) ?? row?.metadata?.quality ?? row?.baseQuality ?? row?.quality ?? 0
199
+ // Legacy programmatic scalar bias only. Production feedback uses an
200
+ // independent preferenceAdjustments utility term, never this quality path.
199
201
  return row?.qualityBias ? clamp(base + row.qualityBias) : base
200
202
  }
201
203
 
204
+ function qualitySourceForTask(row, taskType) {
205
+ return asScore(row?.liveScores?.[taskType]) !== undefined || asScore(row?.liveOverall) !== undefined
206
+ ? 'livebench' : row?.fallbackQualitySource ?? row?.qualitySource ?? 'unknown'
207
+ }
208
+
202
209
  function specialtyForTask(row, taskType) {
203
210
  return specialtyMatch(row.metadata, taskType, row.liveScores)
204
211
  }
@@ -258,23 +265,9 @@ function normalizedCacheRatios(cacheReadRatio = 0, cacheWriteRatio = 0) {
258
265
  return { read, write }
259
266
  }
260
267
 
261
- function effectivePricing(pricing, cacheReadRatio = 0, cacheWriteRatio = 0) {
262
- const { read, write } = normalizedCacheRatios(cacheReadRatio, cacheWriteRatio)
263
- return {
264
- input: (1 - read - write) * Number(pricing.input)
265
- + read * Number(pricing.cacheRead)
266
- + write * Number(pricing.cacheWrite),
267
- output: Number(pricing.output),
268
- }
269
- }
270
-
271
- function costScore(pricing, maxCost, cacheReadRatio = 0, cacheWriteRatio = 0) {
272
- if (pricing === null) return 0
273
- const effective = effectivePricing(pricing, cacheReadRatio, cacheWriteRatio)
274
- const mean = (effective.input + effective.output) / 2
275
- if (maxCost <= 0) return mean === 0 ? 1 : 0
276
- return clamp(1 - mean / maxCost)
277
- }
268
+ // A preference scale, not a vendor quote. Independent of the configured pool,
269
+ // so adding an irrelevant expensive model cannot flatten every cost penalty.
270
+ export const COST_REFERENCE_PRICING = Object.freeze({ input: 1, output: 5, cacheRead: 1, cacheWrite: 1, currency: 'USD' })
278
271
 
279
272
  export function estimateCost(model, text, outputTokens = 900, pricingOverrides = {}, cacheReadRatio = 0, cacheWriteRatio = 0) {
280
273
  const pricing = pricingFor(model, pricingOverrides)
@@ -679,38 +672,26 @@ function reasoningDecision(row, task) {
679
672
  }
680
673
  }
681
674
 
682
- function candidateUtility(row, task, weights, maxCost, usedRoutes, cacheReadRatio = 0, cacheWriteRatio = 0) {
675
+ function candidateUtility(row, task, weights, cacheReadRatio = 0, cacheWriteRatio = 0, text = '', complexity = 'balanced') {
683
676
  const quality = qualityForTask(row, task.type)
677
+ // Subjective satisfaction is an independent, bounded utility term. It
678
+ // must never raise benchmark quality, quality floors or evidence labels.
679
+ const rawPreference = row.preferenceAdjustments?.[task.type]
680
+ const preferenceAdjustment = Number.isFinite(rawPreference) ? clamp(rawPreference, -0.1, 0.1) : 0
684
681
  const floor = Number(task.qualityFloor ?? taskQualityFloor('complex', task))
685
682
  const qualityGap = Math.max(0, floor - quality)
686
- // Reuse avoids handoff overhead and is preferable when the same affordable
687
- // route is suitable for independent, low-risk work packages.
688
- const duplicatePenalty = 0
689
- const synthesisPreference = task.purpose === 'synthesis' && /deepseek[- ]?v4[- ]?pro/i.test(row.model) ? 0.025 : 0
690
683
  const reasoning = reasoningDecision(row, task)
691
- const cost = clamp(costScore(row.pricing, maxCost, cacheReadRatio, cacheWriteRatio) / Math.sqrt(reasoning.multiplier.output))
684
+ const reference = taskCost({ pricing: COST_REFERENCE_PRICING, reasoningKnown: true, reasoningEfforts: [] }, task, text, complexity, cacheReadRatio, cacheWriteRatio)
685
+ const cost = row.pricing === null ? 0 : 1 / (1 + taskCost(row, task, text, complexity, cacheReadRatio, cacheWriteRatio) / Math.max(reference, 1e-12))
692
686
  const score = weights.quality * quality
693
687
  + weights.cost * cost
694
688
  + weights.latency * (1 - clamp(row.latency * reasoning.multiplier.latency))
695
689
  + weights.specialty * specialtyForTask(row, task.type)
696
690
  + (weights.reasoning ?? 0) * reasoning.reasoningFit
697
691
  - weights.risk * row.risk
698
- - duplicatePenalty
699
692
  - qualityGap * (task.criticality ?? 0.75)
700
- + synthesisPreference
701
- return { score, floor, qualityGap, ...reasoning }
702
- }
703
-
704
- function chooseAssignment(rows, task, weights, maxCost, usedRoutes, preferred, cacheReadRatio = 0, cacheWriteRatio = 0) {
705
- const ordered = rows
706
- .map(row => ({ row, decision: candidateUtility(row, task, weights, maxCost, usedRoutes, cacheReadRatio, cacheWriteRatio) }))
707
- .sort((left, right) => right.decision.score - left.decision.score)
708
- const feasible = ordered.filter(item => qualityForTask(item.row, task.type) >= item.decision.floor)
709
- const chosen = (preferred === true ? feasible : feasible.filter(item => !usedRoutes.has(routeKey(item.row.provider, item.row.model))))[0]
710
- ?? feasible[0]
711
- ?? ordered[0]
712
- if (chosen === undefined) return { row: null, relaxed: true, floor: 0, qualityGap: 1 }
713
- return { row: chosen.row, relaxed: qualityForTask(chosen.row, task.type) < chosen.decision.floor, floor: chosen.decision.floor, qualityGap: chosen.decision.qualityGap }
693
+ + preferenceAdjustment
694
+ return { score, floor, qualityGap, preferenceAdjustment, ...reasoning }
714
695
  }
715
696
 
716
697
  function taskCost(row, task, text, complexity, cacheReadRatio = 0, cacheWriteRatio = 0) {
@@ -760,10 +741,14 @@ function dominates(left, right, task, text, complexity, cacheReadRatio, cacheWri
760
741
  return noWorse && strictlyBetter
761
742
  }
762
743
 
763
- function candidatePool(rows, task, weights, maxCost, text, complexity, cacheReadRatio, cacheWriteRatio) {
764
- const eligibleRows = task.type === 'vision'
744
+ function eligibleRowsForTask(rows, task) {
745
+ return task.type === 'vision'
765
746
  ? rows.filter(row => row.inputModalities.length === 0 || row.inputModalities.includes('image'))
766
747
  : rows
748
+ }
749
+
750
+ function candidatePool(rows, task, weights, text, complexity, cacheReadRatio, cacheWriteRatio, dependencySensitive = false) {
751
+ const eligibleRows = eligibleRowsForTask(rows, task)
767
752
  const floor = Number(task.qualityFloor ?? 0)
768
753
  const feasible = eligibleRows.filter(row => qualityForTask(row, task.type) >= floor)
769
754
  const source = feasible.length > 0
@@ -772,109 +757,60 @@ function candidatePool(rows, task, weights, maxCost, text, complexity, cacheRead
772
757
  const taskWeights = weightsForTask(weights, task)
773
758
  const scored = source.map(row => ({
774
759
  row,
775
- decision: candidateUtility(row, task, taskWeights, maxCost, new Set(), cacheReadRatio, cacheWriteRatio),
760
+ decision: candidateUtility(row, task, taskWeights, cacheReadRatio, cacheWriteRatio, text, complexity),
776
761
  cost: taskCost(row, task, text, complexity, cacheReadRatio, cacheWriteRatio),
777
762
  }))
778
- const frontier = scored.filter(item => !source.some(other => other !== item.row && dominates(other, item.row, task, text, complexity, cacheReadRatio, cacheWriteRatio)))
763
+ // Local dominance is unsafe when route identity changes a later handoff.
764
+ // Even without dependencies, a pruning proof must preserve actual utility
765
+ // (including route-specific preferences), not just nominal score axes.
766
+ const frontier = dependencySensitive ? scored : scored.filter(item => !scored.some(other => other !== item
767
+ && other.decision.score >= item.decision.score
768
+ && dominates(other.row, item.row, task, text, complexity, cacheReadRatio, cacheWriteRatio)))
779
769
  const essential = [
780
- scored.slice().sort((left, right) => left.cost - right.cost || compareRowsStable(left.row, right.row))[0],
770
+ scored.filter(item => item.row.pricing !== null).sort((left, right) => left.cost - right.cost || compareRowsStable(left.row, right.row))[0],
781
771
  scored.slice().sort((left, right) => right.decision.score - left.decision.score || compareRowsStable(left.row, right.row))[0],
782
772
  scored.slice().sort((left, right) => qualityForTask(right.row, task.type) - qualityForTask(left.row, task.type) || compareRowsStable(left.row, right.row))[0],
783
773
  ].filter(Boolean)
784
- const ordered = [...frontier, ...essential]
774
+ const all = [...frontier, ...essential]
785
775
  .filter((item, index, all) => all.findIndex(candidate => candidate.row === item.row) === index)
786
776
  .sort((left, right) => right.decision.score - left.decision.score || left.cost - right.cost || compareRowsStable(left.row, right.row))
777
+ const anchors = [...new Set(essential)]
778
+ // Reserve anchors before truncation: adding expensive high-score routes
779
+ // must never remove the only budget-feasible, quality-eligible candidate.
780
+ const ordered = [...anchors, ...all.filter(item => !anchors.includes(item))]
787
781
  .slice(0, ROUTING_CANDIDATE_LIMIT)
782
+ .sort((left, right) => right.decision.score - left.decision.score || left.cost - right.cost || compareRowsStable(left.row, right.row))
788
783
  return {
789
784
  options: ordered,
790
785
  relaxed: feasible.length === 0,
791
- pruned: Math.max(0, eligibleRows.length - ordered.length),
786
+ pruned: Math.max(0, scored.length - all.length),
787
+ filtered: Math.max(0, eligibleRows.length - source.length),
788
+ truncated: Math.max(0, all.length - ordered.length),
789
+ dependencySensitive,
792
790
  }
793
791
  }
794
792
 
795
- function stateSignature(state) {
796
- return state.assignments.map(assignment => `${routeKey(assignment.row?.provider, assignment.row?.model)}@${assignment.decision?.reasoningEffort ?? 'provider-default'}`).join('|')
797
- }
798
-
799
- function compareUtilityStates(left, right) {
800
- return left.relaxedCount - right.relaxedCount
801
- || left.qualityShortfall - right.qualityShortfall
802
- || right.score - left.score
803
- || left.cost - right.cost
804
- || left.switches - right.switches
805
- || compareText(stateSignature(left), stateSignature(right))
806
- }
807
-
808
- function compareCostStates(left, right) {
809
- return left.relaxedCount - right.relaxedCount
810
- || left.qualityShortfall - right.qualityShortfall
811
- || left.cost - right.cost
812
- || right.score - left.score
813
- || left.switches - right.switches
814
- || compareText(stateSignature(left), stateSignature(right))
815
- }
816
-
817
- function solveAssignments({ rows, tasks, weights, maxCost, text, complexity, budget, cacheReadRatio, cacheWriteRatio, minimizeCost = false }) {
818
- const pools = tasks.map(task => candidatePool(rows, task, weights, maxCost, text, complexity, cacheReadRatio, cacheWriteRatio))
819
- if (pools.some(pool => pool.options.length === 0)) return null
820
- const suffixMinimum = Array(tasks.length + 1).fill(0)
821
- for (let index = tasks.length - 1; index >= 0; index -= 1) {
822
- const costOptions = Number.isFinite(budget)
823
- ? pools[index].options.filter(option => option.row.pricing !== null)
824
- : pools[index].options
825
- if (costOptions.length === 0) return null
826
- suffixMinimum[index] = suffixMinimum[index + 1] + Math.min(...costOptions.map(option => option.cost))
827
- }
828
- if (Number.isFinite(budget) && suffixMinimum[0] > budget + 1e-12) return null
829
-
830
- let states = [{ assignments: [], routesByTask: new Map(), usedRoutes: new Set(), score: 0, cost: 0, switches: 0, relaxedCount: 0, qualityShortfall: 0 }]
831
- for (let index = 0; index < tasks.length; index += 1) {
832
- const task = tasks[index]
833
- const pool = pools[index]
834
- const expanded = []
835
- for (const state of states) {
836
- for (const option of pool.options) {
837
- if (Number.isFinite(budget) && option.row.pricing === null) continue
838
- const nextCost = state.cost + option.cost
839
- if (Number.isFinite(budget) && nextCost + suffixMinimum[index + 1] > budget + 1e-12) continue
840
- const taskWeights = weightsForTask(weights, task)
841
- const decision = candidateUtility(option.row, task, taskWeights, maxCost, state.usedRoutes, cacheReadRatio, cacheWriteRatio)
842
- const route = routeKey(option.row.provider, option.row.model)
843
- const dependencySwitches = (task.dependsOn ?? []).reduce((count, dependency) => {
844
- const dependencyRoute = state.routesByTask.get(dependency)
845
- return count + (dependencyRoute !== undefined && dependencyRoute !== route ? 1 : 0)
846
- }, 0)
847
- const handoffPenalty = dependencySwitches * 0.015
848
- const qualityShortfall = Math.max(0, decision.floor - qualityForTask(option.row, task.type))
849
- const usedRoutes = new Set(state.usedRoutes)
850
- usedRoutes.add(route)
851
- const routesByTask = new Map(state.routesByTask)
852
- routesByTask.set(task.id, route)
853
- expanded.push({
854
- assignments: [...state.assignments, { task, row: option.row, decision: { ...decision, relaxed: qualityShortfall > 0 }, estimatedCost: option.cost, handoffPenalty }],
855
- routesByTask,
856
- usedRoutes,
857
- score: state.score + decision.score - handoffPenalty,
858
- cost: nextCost,
859
- switches: state.switches + dependencySwitches,
860
- relaxedCount: state.relaxedCount + (qualityShortfall > 0 ? 1 : 0),
861
- qualityShortfall: state.qualityShortfall + qualityShortfall,
862
- })
863
- }
864
- }
865
- if (expanded.length === 0) return null
866
- expanded.sort(minimizeCost ? compareCostStates : compareUtilityStates)
867
- states = expanded.slice(0, ROUTING_BEAM_WIDTH)
868
- }
869
- states.sort(minimizeCost ? compareCostStates : compareUtilityStates)
793
+ function solveAssignments({ rows, tasks, weights, text, complexity, budget, cacheReadRatio, cacheWriteRatio, minimizeCost = false }) {
794
+ const dependencySensitive = tasks.some(task => (task.dependsOn ?? []).length > 0)
795
+ const pools = tasks.map(task => candidatePool(rows, task, weights, text, complexity, cacheReadRatio, cacheWriteRatio, dependencySensitive))
796
+ const result = solveCandidateAssignments({ tasks, pools: pools.map((pool, index) => pool.options.map(option => ({
797
+ route: routeKey(option.row.provider, option.row.model), score: option.decision.score, cost: option.cost,
798
+ qualityShortfall: Math.max(0, option.decision.floor - qualityForTask(option.row, tasks[index].type)),
799
+ pricingKnown: option.row.pricing !== null, payload: option,
800
+ }))), budget, beamWidth: ROUTING_BEAM_WIDTH, minimizeCost })
801
+ if (!result) return null
870
802
  return {
871
- ...states[0],
803
+ ...result,
804
+ assignments: result.choices.map((choice, index) => ({ task: tasks[index], row: choice.payload.row,
805
+ decision: { ...choice.payload.decision, relaxed: choice.qualityShortfall > 0 },
806
+ estimatedCost: choice.cost, handoffPenalty: choice.handoffPenalty })),
807
+ usedRoutes: new Set(result.choices.map(choice => choice.route)),
872
808
  candidatePools: pools,
873
- minimumFeasibleCost: suffixMinimum[0],
809
+ minimumFeasibleCost: result.minimumCostLowerBound,
874
810
  }
875
811
  }
876
812
 
877
- export function buildPlan({ text = '', available = [], mode = 'collective', pricing = {}, liveBench = null, liveBenchError = '', budgetUsd = 0, cacheReadRatio = 0, cacheWriteRatio = 0, preset = DEFAULT_ROUTING_PRESET } = {}) {
813
+ export function buildPlan({ text = '', available = [], mode = 'collective', pricing = {}, liveBench = null, liveBenchError = '', dataVersions = null, learning = null, budgetUsd = 0, cacheReadRatio = 0, cacheWriteRatio = 0, preset = DEFAULT_ROUTING_PRESET } = {}) {
878
814
  const presetId = normalizeRoutingPreset(preset)
879
815
  const firstLine = String(text ?? '').split(/\r?\n/u)[0].trim()
880
816
  const transformOnly = /^(?:请|帮我)?(?:总结|概括|翻译|摘要|解释)(?:以下|下列|下面|这份|这些)/u.test(firstLine)
@@ -894,18 +830,22 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
894
830
  return {
895
831
  provider: String(entry.provider ?? ''),
896
832
  model: String(entry.model ?? ''),
833
+ benchmarkModel: typeof entry.benchmarkModel === 'string' ? entry.benchmarkModel : undefined,
897
834
  reasoningEfforts: [...new Set(reasoningEfforts)],
898
835
  defaultReasoningEffort: entry.defaultReasoningEffort === undefined ? undefined : String(entry.defaultReasoningEffort),
899
836
  reasoningKnown: entry.reasoningKnown === true
900
837
  || (entry.reasoningKnown === undefined && Array.isArray(entry.reasoningEfforts)),
901
838
  quality: asScore(entry.quality),
902
839
  qualityBias: Number.isFinite(entry.qualityBias) ? clamp(entry.qualityBias, -0.05, 0.05) : 0,
840
+ preferenceAdjustments: entry.preferenceAdjustments ?? null,
903
841
  qualitySource: entry.qualitySource === 'user' ? 'user' : 'route',
904
842
  latency: asScore(entry.latency),
905
843
  risk: asScore(entry.risk),
906
844
  specialties: Array.isArray(entry.specialties) ? entry.specialties.filter(item => typeof item === 'string') : null,
907
845
  pricing: normalizePricing({ route: entry.pricing ?? entry.price }).route ?? null,
908
- pricingSource: entry.pricingSource === 'user' ? 'user' : 'route',
846
+ pricingSource: entry.pricingSource === 'user' ? 'user' : entry.pricingSource === 'dynamic' ? 'dynamic' : 'route',
847
+ pricingVersion: entry.pricingVersion ?? null,
848
+ snapshotAsOf: entry.snapshotAsOf ?? null,
909
849
  inputModalities: Array.isArray(entry.inputModalities) ? entry.inputModalities.map(item => String(item).toLowerCase()) : [],
910
850
  }
911
851
  })
@@ -922,10 +862,16 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
922
862
  ...(route.risk === undefined ? {} : { risk: route.risk }),
923
863
  ...(route.specialties === null ? {} : { specialties: route.specialties }),
924
864
  }
925
- const live = liveBenchRow(liveBench, route.model)
865
+ const live = liveBenchRow(liveBench, route.benchmarkModel ?? route.model)
926
866
  const liveScores = live?.scores ?? {}
927
- const liveOverall = asScore(live?.overall)
928
- const quality = clamp((asScore(liveScores?.[taskType]) ?? liveOverall ?? asScore(metadata.quality) ?? 0) + route.qualityBias)
867
+ // A category average cannot provide evidence for an unmeasured task.
868
+ // Legacy cached category rows have no provenance: use their task scores
869
+ // but do not promote an ambiguous overall to cross-task evidence.
870
+ const liveOverall = live?.overallSource === 'explicit' || (live?.overallSource === undefined && Object.keys(liveScores).length === 0)
871
+ ? asScore(live?.overall) : undefined
872
+ const baseQuality = asScore(metadata.quality) ?? 0
873
+ const quality = clamp((asScore(liveScores?.[taskType]) ?? liveOverall ?? baseQuality) + route.qualityBias)
874
+ const fallbackQualitySource = route.quality !== undefined ? route.qualitySource : catalog ? 'catalog-heuristic' : 'unknown'
929
875
  const qualitySource = liveOverall !== undefined || asScore(liveScores?.[taskType]) !== undefined
930
876
  ? 'livebench' : route.quality !== undefined ? route.qualitySource : catalog ? 'catalog-heuristic' : 'unknown'
931
877
  const userPrice = normalizedPrices[normalize(`${route.provider}/${route.model}`)] ?? normalizedPrices[normalize(route.model)]
@@ -940,9 +886,14 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
940
886
  model: route.model,
941
887
  metadata,
942
888
  quality,
889
+ baseQuality,
890
+ fallbackQualitySource,
943
891
  qualityBias: route.qualityBias,
892
+ preferenceAdjustments: route.preferenceAdjustments,
944
893
  qualitySource,
945
894
  pricingSource,
895
+ pricingVersion: route.pricingVersion,
896
+ snapshotAsOf: route.snapshotAsOf,
946
897
  liveScores,
947
898
  liveOverall,
948
899
  latency: metadata.latency ?? 0.5,
@@ -960,10 +911,6 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
960
911
  }, text, 900, {}, cacheReadRatio, cacheWriteRatio),
961
912
  })
962
913
  }
963
- const maxCost = Math.max(1, ...rows.filter(row => row.pricing !== null).map(row => {
964
- const effective = effectivePricing(row.pricing, cacheReadRatio, cacheWriteRatio)
965
- return effective.input + effective.output
966
- }))
967
914
  // Presets tilt each package's weights and quality floor; balanced is a no-op.
968
915
  const taskNodes = taskPackages(taskType, text, complexity.band).map(task => presetId === DEFAULT_ROUTING_PRESET ? task : {
969
916
  ...task,
@@ -978,7 +925,6 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
978
925
  rows,
979
926
  tasks: taskNodes,
980
927
  weights,
981
- maxCost,
982
928
  text,
983
929
  complexity: complexity.band,
984
930
  budget: Number.POSITIVE_INFINITY,
@@ -990,7 +936,6 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
990
936
  rows,
991
937
  tasks: taskNodes,
992
938
  weights,
993
- maxCost,
994
939
  text,
995
940
  complexity: complexity.band,
996
941
  budget,
@@ -1003,7 +948,6 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1003
948
  rows,
1004
949
  tasks: taskNodes,
1005
950
  weights,
1006
- maxCost,
1007
951
  text,
1008
952
  complexity: complexity.band,
1009
953
  budget: Number.POSITIVE_INFINITY,
@@ -1017,7 +961,8 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1017
961
  const usedRoutes = optimized?.usedRoutes ?? new Set()
1018
962
  const constraintRelaxed = (optimized?.relaxedCount ?? 0) > 0
1019
963
  for (const row of rows) {
1020
- row.score = candidateUtility(row, taskNodes[0] ?? { type: taskType, qualityFloor: QUALITY_FLOORS[complexity.band] }, weights, maxCost, new Set(), cacheReadRatio, cacheWriteRatio).score
964
+ row.score = candidateUtility(row, taskNodes[0] ?? { type: taskType, qualityFloor: QUALITY_FLOORS[complexity.band] }, weights, cacheReadRatio, cacheWriteRatio, text, complexity.band).score
965
+ if (row.pricing !== null && taskNodes[0]) row.estimatedCost = taskCost(row, taskNodes[0], text, complexity.band, cacheReadRatio, cacheWriteRatio)
1021
966
  }
1022
967
  rows.sort((left, right) => right.score - left.score || compareRowsStable(left, right))
1023
968
  const selectedAssignment = assignments[0]
@@ -1032,7 +977,7 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1032
977
  difficulty: task.difficulty,
1033
978
  recommended: row?.model ?? '待发现模型',
1034
979
  recommendedProvider: row?.provider ?? '',
1035
- qualitySource: row?.qualitySource ?? 'unknown',
980
+ qualitySource: qualitySourceForTask(row, task.type),
1036
981
  pricingSource: row?.pricingSource ?? 'unknown',
1037
982
  recommendedReasoningEffort: decision?.reasoningEffort,
1038
983
  preferredReasoningEffort: decision?.preferredReasoningEffort ?? task.preferredReasoningEffort,
@@ -1060,23 +1005,24 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1060
1005
  cacheWriteTokens: tokens.cacheWriteTokens,
1061
1006
  outputTokens: tokens.outputTokens,
1062
1007
  estimatedCost: row?.pricing === null || row === null ? null : Number(taskEstimate.toFixed(6)),
1063
- quality: row?.qualitySource === 'unknown' || row === null ? null : Number(qualityForTask(row, task.type).toFixed(3)),
1064
- qualitySource: row?.qualitySource ?? 'unknown',
1008
+ quality: qualitySourceForTask(row, task.type) === 'unknown' || row === null ? null : Number(qualityForTask(row, task.type).toFixed(3)),
1009
+ qualitySource: qualitySourceForTask(row, task.type),
1065
1010
  pricingSource: row?.pricingSource ?? 'unknown',
1066
1011
  handoffPenalty: Number(Number(handoffPenalty ?? 0).toFixed(3)),
1067
1012
  }
1068
1013
  })
1069
1014
  const pricingComplete = assignments.length > 0 && assignments.every(({ row }) => row?.pricing !== null)
1070
- const totalEstimate = pricingComplete ? costBreakdown.reduce((sum, row) => sum + row.estimatedCost, 0) : null
1015
+ const totalEstimate = pricingComplete ? assignments.reduce((sum, { task, row, estimatedCost }) => sum
1016
+ + (estimatedCost ?? taskCost(row, task, text, complexity.band, cacheReadRatio, cacheWriteRatio)), 0) : null
1071
1017
  const baselineRows = assignments.map(({ task }) => {
1072
- const strongest = rows.reduce((best, row) => qualityForTask(row, task.type) > (best === null ? -1 : qualityForTask(best, task.type)) ? row : best, null)
1018
+ const strongest = eligibleRowsForTask(rows, task).reduce((best, row) => qualityForTask(row, task.type) > (best === null ? -1 : qualityForTask(best, task.type)) ? row : best, null)
1073
1019
  return { task, strongest }
1074
1020
  })
1075
1021
  const baselineCost = baselineRows.every(item => item.strongest?.pricing !== null && item.strongest !== null)
1076
1022
  ? baselineRows.reduce((sum, { task, strongest }) => sum + taskCost(strongest, task, text, complexity.band, cacheReadRatio, cacheWriteRatio), 0)
1077
1023
  : null
1078
- const qualityEvidenceComplete = assignments.length > 0 && assignments.every(({ row }) => ['livebench', 'route', 'user'].includes(row?.qualitySource))
1079
- && baselineRows.every(({ strongest }) => ['livebench', 'route', 'user'].includes(strongest?.qualitySource))
1024
+ const qualityEvidenceComplete = assignments.length > 0 && assignments.every(({ row, task }) => ['livebench', 'route', 'user'].includes(qualitySourceForTask(row, task.type)))
1025
+ && baselineRows.every(({ strongest, task }) => ['livebench', 'route', 'user'].includes(qualitySourceForTask(strongest, task.type)))
1080
1026
  const budgetExceeded = Number(budgetUsd) > 0 && totalEstimate !== null ? totalEstimate > Number(budgetUsd) : null
1081
1027
  const savings = baselineCost === null || totalEstimate === null || !qualityEvidenceComplete
1082
1028
  ? null : baselineCost <= 0 ? 0 : clamp((baselineCost - totalEstimate) / baselineCost)
@@ -1087,7 +1033,7 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1087
1033
  ? unassignableTasks.length > 0
1088
1034
  ? `图像工作包 ${unassignableTasks.join('、')} 没有可用的图像模型,无法形成完整分配计划。`
1089
1035
  : '尚未发现可用模型,保留 Harness 原始模型选择。'
1090
- : `${complexity.band === 'simple' ? '低复杂度优先成本、响应速度与较低推理开销' : complexity.band === 'balanced' ? '在质量、成本、推理等级、延迟与风险之间平衡' : '高复杂度执行包含推理等级的依赖感知全局约束分配'};任务类型为 ${taskType},已对 ${String(subtasks.length)} 个工作包进行 Pareto 剪枝和有界组合搜索。`
1036
+ : `${complexity.band === 'simple' ? '低复杂度优先成本、响应速度与较低推理开销' : complexity.band === 'balanced' ? '在质量、成本、推理等级、延迟与风险之间平衡' : '高复杂度执行包含推理等级的依赖感知约束分配'};任务类型为 ${taskType},${String(subtasks.length)} 个工作包采用${optimized?.search?.exact ? '保留候选空间内的精确搜索' : '保留候选空间内的有界近似搜索'}。质量分与门槛是未校准的估计,不是答对概率。`
1091
1037
  return {
1092
1038
  mode,
1093
1039
  preset: presetId,
@@ -1098,16 +1044,33 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1098
1044
  taskTypes: [...new Set(taskNodes.map(task => task.type).filter(type => type !== 'reasoning'))],
1099
1045
  objectiveWeights: weights,
1100
1046
  candidates: rows.slice(0, 8).map(row => {
1101
- const decision = candidateUtility(row, taskNodes[0] ?? { type: taskType, qualityFloor: QUALITY_FLOORS[complexity.band], preferredReasoningEffort: complexity.band === 'simple' ? 'low' : 'medium' }, weights, maxCost, new Set(), cacheReadRatio, cacheWriteRatio)
1102
- return { provider: row.provider, model: row.model, score: Number(row.score.toFixed(3)), quality: row.qualitySource === 'unknown' ? null : Number(row.quality.toFixed(3)), qualitySource: row.qualitySource, specialty: Number(row.specialty.toFixed(3)), reasoningEffort: decision.reasoningEffort, preferredReasoningEffort: decision.preferredReasoningEffort, reasoningFit: Number(decision.reasoningFit.toFixed(3)), reasoningKnown: row.reasoningKnown, reasoningEfforts: row.reasoningEfforts, estimatedCost: row.estimatedCost === null ? null : Number(row.estimatedCost.toFixed(6)), inputPrice: row.pricing?.input ?? null, outputPrice: row.pricing?.output ?? null, pricingSource: row.pricingSource }
1047
+ const decision = candidateUtility(row, taskNodes[0] ?? { type: taskType, qualityFloor: QUALITY_FLOORS[complexity.band], preferredReasoningEffort: complexity.band === 'simple' ? 'low' : 'medium' }, weights, cacheReadRatio, cacheWriteRatio, text, complexity.band)
1048
+ return { provider: row.provider, model: row.model, score: Number(row.score.toFixed(3)), preferenceAdjustment: decision.preferenceAdjustment, quality: row.qualitySource === 'unknown' ? null : Number(row.quality.toFixed(3)), qualitySource: row.qualitySource, specialty: Number(row.specialty.toFixed(3)), reasoningEffort: decision.reasoningEffort, preferredReasoningEffort: decision.preferredReasoningEffort, reasoningFit: Number(decision.reasoningFit.toFixed(3)), reasoningKnown: row.reasoningKnown, reasoningEfforts: row.reasoningEfforts, estimatedCost: row.estimatedCost === null ? null : Number(row.estimatedCost.toFixed(6)), inputPrice: row.pricing?.input ?? null, outputPrice: row.pricing?.output ?? null, pricingSource: row.pricingSource, pricingVersion: row.pricingVersion, pricingAsOf: row.snapshotAsOf }
1103
1049
  }),
1104
- selected: selected === null ? null : { provider: selected.provider, model: selected.model, reasoningEffort: selectedAssignment?.decision?.reasoningEffort, estimatedCost: selected.estimatedCost === null ? null : Number(selected.estimatedCost.toFixed(6)), qualitySource: selected.qualitySource, pricingSource: selected.pricingSource },
1050
+ selected: selected === null ? null : { provider: selected.provider, model: selected.model, reasoningEffort: selectedAssignment?.decision?.reasoningEffort, estimatedCost: selected.estimatedCost === null ? null : Number(selected.estimatedCost.toFixed(6)), qualitySource: qualitySourceForTask(selected, selectedAssignment?.task.type ?? taskType), pricingSource: selected.pricingSource },
1105
1051
  subtasks,
1106
1052
  synthesizer: synthesizer == null ? null : { provider: synthesizer.provider, model: synthesizer.model, reasoningEffort: synthesizerAssignment?.decision?.reasoningEffort },
1107
- estimatedCost: totalEstimate === null ? null : Number(totalEstimate.toFixed(6)),
1053
+ // Preserve precision for downstream budget gates; formatting belongs to UI.
1054
+ estimatedCost: totalEstimate,
1108
1055
  costBreakdown,
1109
1056
  optimization: {
1110
- solver: 'pareto-pruned quality-constrained beam assignment',
1057
+ dataVersions,
1058
+ personalization: learning ? {
1059
+ policyVersion: learning.policyVersion, revision: learning.revision,
1060
+ enabled: learning.enabled, scope: learning.scope, window: learning.window,
1061
+ feedbackCount: learning.feedbackCount, effectiveWeight: learning.effectiveWeight,
1062
+ halfLifeDays: learning.halfLifeDays, priorWeight: learning.priorWeight,
1063
+ maxAdjustment: learning.maxAdjustment, resetAt: learning.resetAt,
1064
+ semantics: 'subjective-utility-not-objective-quality',
1065
+ } : null,
1066
+ solver: 'dependency-safe quality-constrained exact/beam assignment',
1067
+ qualitySemantics: 'uncalibrated-surrogate',
1068
+ costUtility: { method: '1/(1+estimated-task-cost/reference-task-cost)', referencePricing: COST_REFERENCE_PRICING },
1069
+ search: optimized?.search ?? null,
1070
+ optimalityScope: 'retained-candidates-and-surrogate-objective',
1071
+ candidateTruncated: (optimized?.candidatePools ?? []).reduce((sum, pool) => sum + pool.truncated, 0),
1072
+ qualityFiltered: (optimized?.candidatePools ?? []).reduce((sum, pool) => sum + pool.filtered, 0),
1073
+ localParetoDisabled: (optimized?.candidatePools ?? []).some(pool => pool.dependencySensitive),
1111
1074
  qualityFloor: QUALITY_FLOORS[complexity.band],
1112
1075
  budgetUsd: Number(Number(budgetUsd) > 0 ? Number(budgetUsd) : 0),
1113
1076
  cacheReadRatio: normalizedCacheRatios(cacheReadRatio, cacheWriteRatio).read,
@@ -1124,8 +1087,8 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1124
1087
  beamWidth: ROUTING_BEAM_WIDTH,
1125
1088
  budgetFeasible: budget <= 0 ? (pricingComplete ? true : null) : budgetPlan !== null,
1126
1089
  minimumFeasibleCost: minimumFeasibleCost === null ? null : Number(Number(minimumFeasibleCost).toFixed(6)),
1127
- liveBench: liveBench?.fetchedAt
1128
- ? { source: liveBench.source ?? 'livebench', fetchedAt: liveBench.fetchedAt, models: Object.keys(liveBench.models ?? {}).length, stale: String(liveBenchError).length > 0, error: String(liveBenchError || '') }
1090
+ liveBench: (liveBench?.fetchedAt ?? liveBench?.verifiedAt)
1091
+ ? { source: liveBench.source ?? 'livebench', fetchedAt: liveBench.fetchedAt ?? liveBench.verifiedAt, verifiedAt: liveBench.verifiedAt ?? liveBench.fetchedAt, publishedAt: liveBench.publishedAt ?? null, version: liveBench.version ?? null, models: Object.keys(liveBench.models ?? {}).length, stale: String(liveBenchError).length > 0, error: String(liveBenchError || '') }
1129
1092
  : { source: 'experimental-baseline', fetchedAt: null, models: 0, stale: false, error: String(liveBenchError || '') },
1130
1093
  },
1131
1094
  reason,