@ljwei-stak/dsh-model-router 0.21.1 → 0.22.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.
@@ -0,0 +1,71 @@
1
+ import { getOfficialTool } from './official-tool-registry.mjs'
2
+
3
+ const plain = value => value !== null && typeof value === 'object'
4
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
5
+ const safeText = (value, limit) => typeof value === 'string' && value.trim().length > 0
6
+ && value.trim().length <= limit && !/[\u0000-\u001f\u007f]/u.test(value)
7
+ const safeKey = key => typeof key === 'string' && key === key.trim() && safeText(key, 128)
8
+ && !['__proto__', 'constructor', 'prototype'].includes(key)
9
+
10
+ /** Explicit package targets only; callers must separately match the current plan and catalog. */
11
+ export function parsePackageAssignments(value) {
12
+ if (value === undefined) return undefined
13
+ if (!plain(value)) throw new TypeError('packageAssignments must be an object')
14
+ const keys = Reflect.ownKeys(value)
15
+ if (!keys.length || keys.length > 32 || keys.some(key => !safeKey(key))) {
16
+ throw new TypeError('packageAssignments requires 1..32 safe package ids')
17
+ }
18
+ const result = {}
19
+ for (const id of keys) {
20
+ const target = value[id]
21
+ if (!plain(target)) throw new TypeError(`Invalid package assignment: ${id}`)
22
+ const allowed = target.kind === 'api' ? ['kind', 'provider', 'model']
23
+ : target.kind === 'tool' ? ['kind', 'toolId'] : null
24
+ if (!allowed || Reflect.ownKeys(target).some(key => !allowed.includes(key))) {
25
+ throw new TypeError(`Unsupported package assignment fields: ${id}`)
26
+ }
27
+ if (target.kind === 'api') {
28
+ if (!safeText(target.provider, 160) || !safeText(target.model, 240)) {
29
+ throw new TypeError(`API assignment requires provider and model: ${id}`)
30
+ }
31
+ result[id] = { kind: 'api', provider: target.provider.trim(), model: target.model.trim() }
32
+ } else {
33
+ if (!safeText(target.toolId, 64) || !getOfficialTool(target.toolId.trim())) {
34
+ throw new TypeError(`Unknown official tool assignment: ${id}`)
35
+ }
36
+ result[id] = { kind: 'tool', toolId: target.toolId.trim() }
37
+ }
38
+ }
39
+ return result
40
+ }
41
+
42
+ /** Bounded intent metadata shared by execution, storage and retries. */
43
+ export function packageSelectionFields(item) {
44
+ if (item?.manualAssignment === undefined) return {}
45
+ const target = parsePackageAssignments({ target: item.manualAssignment }).target
46
+ const recommendation = {}
47
+ for (const key of ['provider', 'model', 'reasoningEffort', 'channel', 'executionChannel', 'qualitySource',
48
+ 'recommendedProvider', 'recommendedModel', 'recommendedReasoningEffort']) {
49
+ const value = item.algorithmRecommendation?.[key]
50
+ if (safeText(value, 240)) recommendation[key] = value.trim()
51
+ }
52
+ return { manualAssignment: target, selectionSource: 'manual',
53
+ ...(Object.keys(recommendation).length ? { algorithmRecommendation: recommendation } : {}) }
54
+ }
55
+
56
+ /** Browser/Host identity for the task decomposition, not an authentication signature. */
57
+ export function packagePlanKey(plan, task = '') {
58
+ const packages = plan?.team?.workPackages
59
+ if (plan?.routingBypassed || plan?.mode === 'direct' || !Array.isArray(packages) || !packages.length) return null
60
+ const fields = packages.map(item => [item?.id ?? '', item?.name ?? '', item?.objective ?? '',
61
+ item?.type ?? '', item?.purpose ?? '', Array.isArray(item?.dependsOn) ? [...item.dependsOn] : []])
62
+ const text = JSON.stringify([String(task ?? '').trim(), fields])
63
+ let first = 2166136261
64
+ let second = 2246822507
65
+ for (let index = 0; index < text.length; index++) {
66
+ const code = text.charCodeAt(index)
67
+ first = Math.imul(first ^ code, 16777619) >>> 0
68
+ second = Math.imul(second ^ code, 3266489909) >>> 0
69
+ }
70
+ return `package-plan-v1-${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}`
71
+ }
@@ -0,0 +1,169 @@
1
+ /** Structural output checks only: passing these checks does not establish semantic correctness. */
2
+ const TYPES = new Set(['string', 'number', 'boolean', 'object', 'array', 'null'])
3
+ const FIELDS = new Set(['kind', 'requiredKeys', 'fieldTypes'])
4
+ const STRUCTURE_ERROR = '输出未满足配置的结构验收要求。'
5
+ const plainObject = value => value !== null && typeof value === 'object'
6
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
7
+ const safeKey = key => typeof key === 'string' && key.length > 0 && key.length <= 128
8
+ && key.trim().length > 0 && !/[\u0000-\u001f\u007f]/u.test(key)
9
+ && !['__proto__', 'prototype', 'constructor'].includes(key)
10
+
11
+ function normalizeGate(value) {
12
+ if (!plainObject(value)) throw new TypeError('Quality gate must be a JSON object')
13
+ const keys = Object.keys(value)
14
+ if (!keys.length) return null
15
+ if (keys.some(key => !FIELDS.has(key)) || value.kind !== 'json-object') {
16
+ throw new TypeError('Unsupported quality gate field or kind')
17
+ }
18
+ const requiredKeys = Object.hasOwn(value, 'requiredKeys') ? value.requiredKeys : []
19
+ if (!Array.isArray(requiredKeys) || requiredKeys.length > 32 || requiredKeys.some(key => !safeKey(key))) {
20
+ throw new TypeError('requiredKeys must contain at most 32 safe keys')
21
+ }
22
+ const fieldTypes = Object.hasOwn(value, 'fieldTypes') ? value.fieldTypes : {}
23
+ if (!plainObject(fieldTypes) || Object.keys(fieldTypes).length > 32
24
+ || Object.entries(fieldTypes).some(([key, type]) => !safeKey(key) || !TYPES.has(type))) {
25
+ throw new TypeError('fieldTypes must map safe keys to supported JSON types')
26
+ }
27
+ return { kind: 'json-object', requiredKeys: [...requiredKeys], fieldTypes: { ...fieldTypes } }
28
+ }
29
+
30
+ export function parseQualityGateJson(text = '') {
31
+ if (typeof text !== 'string') throw new TypeError('Quality gate JSON must be a string')
32
+ if (!text.trim()) return null
33
+ let value
34
+ try { value = JSON.parse(text) } catch { throw new TypeError('Quality gate must be valid JSON') }
35
+ return normalizeGate(value)
36
+ }
37
+
38
+ function matchesType(value, type) {
39
+ if (type === 'null') return value === null
40
+ if (type === 'array') return Array.isArray(value)
41
+ if (type === 'object') return plainObject(value)
42
+ return typeof value === type && (type !== 'number' || Number.isFinite(value))
43
+ }
44
+
45
+ export function validateAnswer(answer, gate) {
46
+ if (gate == null) return { ok: true, kind: null, errors: [] }
47
+ const rule = normalizeGate(gate)
48
+ if (!rule) return { ok: true, kind: null, errors: [] }
49
+ const errors = []
50
+ let object
51
+ try {
52
+ if (typeof answer !== 'string') throw new TypeError('text required')
53
+ object = JSON.parse(answer)
54
+ if (!plainObject(object)) throw new TypeError('object required')
55
+ } catch {
56
+ return { ok: false, kind: rule.kind, errors: ['Expected one complete JSON object.'] }
57
+ }
58
+ // JSON.parse accepts overflows such as 1e999; reject them even in nested data.
59
+ const pending = [object]
60
+ while (pending.length) {
61
+ const value = pending.pop()
62
+ if (typeof value === 'number' && !Number.isFinite(value)) {
63
+ errors.push('JSON numbers must be finite.')
64
+ break
65
+ }
66
+ if (value !== null && typeof value === 'object') for (const child of Object.values(value)) pending.push(child)
67
+ }
68
+ for (const key of rule.requiredKeys) {
69
+ if (!Object.hasOwn(object, key)) errors.push(`Missing required key: ${key}`)
70
+ }
71
+ for (const [key, type] of Object.entries(rule.fieldTypes)) {
72
+ if (Object.hasOwn(object, key) && !matchesType(object[key], type)) errors.push(`Invalid type for ${key}: expected ${type}`)
73
+ }
74
+ return { ok: errors.length === 0, kind: rule.kind, errors }
75
+ }
76
+
77
+ const ATTEMPT_FIELDS = [
78
+ 'ok', 'answer', 'error', 'usage', 'usageIncomplete', 'reportedCostUsd', 'channel', 'billing', 'billingMode',
79
+ 'actualModel', 'reportedModel', 'source', 'cancelled', 'blocked', 'paused', 'truncated', 'timedOut',
80
+ 'notStarted', 'ran', 'status', 'finishReason', 'reasoningEffort', 'plannedReasoningEffort', 'requestedReasoningEffort', 'reasoningEffortStatus', 'inputChars',
81
+ ]
82
+ function copyEvidence(value, seen = new WeakMap()) {
83
+ if (value === null || typeof value !== 'object') return typeof value === 'function' ? undefined : value
84
+ if (!Array.isArray(value) && !plainObject(value)) return undefined
85
+ if (seen.has(value)) return undefined
86
+ const copy = Array.isArray(value) ? [] : {}
87
+ seen.set(value, copy)
88
+ for (const key of Object.keys(value)) {
89
+ if (key === 'attempts' || key === '__proto__') continue
90
+ copy[key] = copyEvidence(value[key], seen)
91
+ }
92
+ return copy
93
+ }
94
+ function attemptRecord(result, route, startedAt, finishedAt) {
95
+ const attempt = {}
96
+ for (const key of ATTEMPT_FIELDS) if (Object.hasOwn(result, key)) attempt[key] = copyEvidence(result[key])
97
+ return { ...attempt, provider: result.provider ?? route.provider, model: result.model ?? route.model,
98
+ pricingSnapshot: plainObject(route.pricing) ? { ...route.pricing } : null,
99
+ startedAt, finishedAt, durationMs: Math.max(0, finishedAt - startedAt) }
100
+ }
101
+ const cancelledResult = result => ({ ...result, ok: false, cancelled: true, error: '执行已取消。' })
102
+ const stopped = result => ['cancelled', 'blocked', 'paused', 'truncated', 'timedOut', 'notStarted']
103
+ .find(key => result[key] === true || result.status === key)
104
+ const configuredRoute = route => plainObject(route) && typeof route.provider === 'string'
105
+ && route.provider.trim().length > 0 && typeof route.model === 'string' && route.model.trim().length > 0
106
+
107
+ /** At most two externally supplied API calls. approveUpgrade owns budget and billing authorization. */
108
+ export async function runQualityCascade({ route, task, gate = null, call, alternatives = [], approveUpgrade, signal } = {}) {
109
+ if (!configuredRoute(route) || typeof call !== 'function') throw new TypeError('A configured route and call function are required')
110
+ const rule = gate == null ? null : normalizeGate(gate)
111
+ const attempts = []
112
+ const check = result => validateAnswer(result?.answer, rule)
113
+ if (signal?.aborted) {
114
+ const result = cancelledResult({ answer: '', notStarted: true })
115
+ return { result, attempts, validation: check(result) }
116
+ }
117
+ async function invoke(selected) {
118
+ const startedAt = Date.now()
119
+ let result
120
+ try {
121
+ result = await call({ route: selected, task, signal })
122
+ if (!plainObject(result)) result = { ok: false, error: 'API未返回有效结果。', ran: 'unknown', usageIncomplete: true }
123
+ } catch (error) {
124
+ result = { ok: false, error: 'API调用失败。', ran: 'unknown', usageIncomplete: true,
125
+ ...(signal?.aborted || error?.name === 'AbortError' ? { cancelled: true } : {}) }
126
+ }
127
+ attempts.push(attemptRecord(result, selected, startedAt, Date.now()))
128
+ return result
129
+ }
130
+ const first = await invoke(route)
131
+ let validation = check(first)
132
+ if (!rule || first.ok !== true) return { result: first, attempts, validation }
133
+ let result = first
134
+ let escalated = false
135
+ let upgradeReason = validation.ok ? 'passed' : 'no-alternative'
136
+ const stop = stopped(first)
137
+ if (!validation.ok && stop) upgradeReason = stop
138
+ else if (!validation.ok && signal?.aborted) {
139
+ result = cancelledResult(first)
140
+ upgradeReason = 'cancelled'
141
+ } else if (!validation.ok) {
142
+ const alternative = (Array.isArray(alternatives) ? alternatives : []).find(candidate => configuredRoute(candidate)
143
+ && (candidate.provider !== route.provider || candidate.model !== route.model))
144
+ if (alternative) {
145
+ if (typeof approveUpgrade !== 'function') upgradeReason = 'approval-required'
146
+ else {
147
+ let approved = false
148
+ try {
149
+ // Pass snapshots so a callback cannot erase already incurred attempt evidence.
150
+ approved = await approveUpgrade({ from: copyEvidence(route), to: copyEvidence(alternative),
151
+ attempts: attempts.map(item => copyEvidence(item)) })
152
+ upgradeReason = approved === true ? 'approved' : 'approval-denied'
153
+ } catch { upgradeReason = 'approval-error' }
154
+ if (signal?.aborted) {
155
+ result = cancelledResult(first)
156
+ upgradeReason = 'cancelled'
157
+ } else if (approved === true) {
158
+ escalated = true
159
+ result = await invoke(alternative)
160
+ validation = check(result)
161
+ upgradeReason = validation.ok && result.ok === true && !stopped(result) ? 'passed-after-upgrade' : 'upgrade-failed'
162
+ }
163
+ }
164
+ }
165
+ }
166
+ const qualityValidation = { ...validation, escalated, upgradeReason }
167
+ if (!validation.ok && !result.cancelled) result = { ...result, ok: false, error: STRUCTURE_ERROR }
168
+ return { result: { ...result, qualityValidation }, attempts, validation }
169
+ }
@@ -0,0 +1,92 @@
1
+ // "requested" records an exact SDK control sent by the caller. It does not
2
+ // establish the provider's actual effort, which requires separate evidence.
3
+ const EFFORT_STATUSES = new Set(['requested', 'unsupported', 'unapplied'])
4
+
5
+ const nonEmptyEffort = value => (typeof value === 'string' && value.length > 0 ? value : null)
6
+
7
+ const allowedEffortIds = route => (Array.isArray(route?.reasoningEfforts)
8
+ ? route.reasoningEfforts.map(item => {
9
+ if (typeof item === 'string' && item.length > 0) return item
10
+ if (item && typeof item === 'object' && typeof item.id === 'string' && item.id.length > 0) return item.id
11
+ return null
12
+ }).filter(item => item !== null)
13
+ : [])
14
+
15
+ /** Copy only trusted reasoning-effort fields; never infer actual effort. */
16
+ export function reasoningExecutionFields(value) {
17
+ if (!value || typeof value !== 'object') return {}
18
+ const fields = {}
19
+ const planned = nonEmptyEffort(value.plannedReasoningEffort)
20
+ if (planned) fields.plannedReasoningEffort = planned
21
+ const requested = nonEmptyEffort(value.requestedReasoningEffort)
22
+ if (requested) fields.requestedReasoningEffort = requested
23
+ if (EFFORT_STATUSES.has(value.reasoningEffortStatus)) fields.reasoningEffortStatus = value.reasoningEffortStatus
24
+ return fields
25
+ }
26
+
27
+ /**
28
+ * Validate an explicit API-call effort against the route's exact allowed IDs.
29
+ * A valid request is returned separately so the caller can spread it into the
30
+ * SDK call only after validation. No default or aliased effort is chosen.
31
+ */
32
+ export function apiReasoningRequest(route) {
33
+ const source = route && typeof route === 'object' ? route : {}
34
+ if (!Object.hasOwn(source, 'reasoningEffort') || source.reasoningEffort === undefined || source.reasoningEffort === null) {
35
+ return { options: {}, fields: {} }
36
+ }
37
+ const effort = source.reasoningEffort
38
+ const planned = nonEmptyEffort(effort)
39
+ const fields = planned ? { plannedReasoningEffort: planned } : {}
40
+ if (planned && allowedEffortIds(source).includes(effort)) {
41
+ return {
42
+ options: { reasoningEffort: effort },
43
+ fields: { ...fields, requestedReasoningEffort: effort, reasoningEffortStatus: 'requested' },
44
+ }
45
+ }
46
+ return {
47
+ options: {},
48
+ fields: { ...fields, reasoningEffortStatus: 'unsupported' },
49
+ error: `不支持的推理强度 ID:${String(effort)};请使用 route.reasoningEfforts 中的精确 ID。`,
50
+ }
51
+ }
52
+
53
+ /** CLI mappings are not verified, so an explicit route effort stays unsupported. */
54
+ export function unsupportedCliReasoning(route) {
55
+ const effort = nonEmptyEffort(route?.reasoningEffort)
56
+ return effort ? { plannedReasoningEffort: effort, reasoningEffortStatus: 'unsupported' } : {}
57
+ }
58
+
59
+ /**
60
+ * Build the execution route for one work package. The planned adapter-owned
61
+ * effort survives only when the package keeps its planned identity. A manual
62
+ * override on another provider/model must not inherit the original effort.
63
+ */
64
+ export function reasoningRouteForAssignment(route, item, override) {
65
+ const next = { ...(route && typeof route === 'object' ? route : {}) }
66
+ delete next.reasoningEffort
67
+ const plannedProvider = nonEmptyEffort(item?.recommendedProvider) ?? nonEmptyEffort(next.provider)
68
+ const plannedModel = nonEmptyEffort(item?.recommendedModel) ?? nonEmptyEffort(next.model)
69
+ const overrideProvider = nonEmptyEffort(override?.provider)
70
+ const overrideModel = nonEmptyEffort(override?.model)
71
+ const samePlannedIdentity = (!overrideProvider || overrideProvider === plannedProvider)
72
+ && (!overrideModel || overrideModel === plannedModel)
73
+ if (samePlannedIdentity) {
74
+ const recommended = nonEmptyEffort(item?.recommendedReasoningEffort)
75
+ if (recommended) next.reasoningEffort = recommended
76
+ }
77
+ const explicitOverride = nonEmptyEffort(override?.reasoningEffort)
78
+ if (explicitOverride) next.reasoningEffort = explicitOverride
79
+ return next
80
+ }
81
+
82
+ /** Preserve an effort across billing only for the same provider/model identity. */
83
+ export function routeForReasoningSwitch(source, target) {
84
+ let next = { ...(target && typeof target === 'object' ? target : {}) }
85
+ const sameIdentity = String(source?.provider ?? '') === String(next.provider ?? '')
86
+ && String(source?.model ?? '') === String(next.model ?? '')
87
+ if (sameIdentity) next = { ...source, ...next }
88
+ const effort = nonEmptyEffort(source?.reasoningEffort)
89
+ if (sameIdentity && effort) next.reasoningEffort = effort
90
+ else delete next.reasoningEffort
91
+ return next
92
+ }
@@ -82,7 +82,8 @@ function invalidRunSpending(run) {
82
82
  if (!Array.isArray(run[field])) return true
83
83
  for (const item of run[field]) {
84
84
  if (!record(item) || (item.ran !== undefined && typeof item.ran !== 'boolean')) return true
85
- if (['costUsd', 'referenceCostUsd'].some(name => item[name] !== undefined && item[name] !== null && !nonnegative(item[name]))) return true
85
+ if (['costUsd', 'referenceCostUsd', 'knownCostUsd'].some(name => item[name] !== undefined && item[name] !== null && !nonnegative(item[name]))) return true
86
+ if (item.unknownAttemptCount !== undefined && (!Number.isInteger(item.unknownAttemptCount) || item.unknownAttemptCount < 1 || item.unknownAttemptCount > 2)) return true
86
87
  if (item.ran === true || nonnegative(item.costUsd)) {
87
88
  const at = item.finishedAt ?? run.createdAt
88
89
  if (!nonnegative(at) || !Number.isFinite(new Date(at).getTime())) return true
@@ -9,6 +9,7 @@
9
9
  import { DEFAULT_ROUTING_PRESET, normalizeRoutingPreset, presetFloor, presetWeights } from './routing-presets.mjs'
10
10
  import { liveBenchRow } from './livebench.mjs'
11
11
  import { solveCandidateAssignments } from './assignment-solver.mjs'
12
+ import { predictQuality, predictResources } from './routing-evidence.mjs'
12
13
 
13
14
  export const OBJECTIVE_WEIGHTS = Object.freeze({
14
15
  simple: Object.freeze({ quality: 0.28, cost: 0.45, latency: 0.14, specialty: 0.04, reasoning: 0.07, risk: 0.02 }),
@@ -135,7 +136,8 @@ export function textFromMessages(messages) {
135
136
 
136
137
  export function classifyTask(text) {
137
138
  const value = String(text ?? '')
138
- if (value.length < 80 && /翻译|解释|translate|explain/i.test(value)) return 'general'
139
+ if (value.length < 80 && /翻译|解释|translate|explain/i.test(value)
140
+ && !detectTaskTypes(value).some(type => ['vision', 'math', 'code', 'research'].includes(type))) return 'general'
139
141
  return detectTaskTypes(value)[0] ?? 'general'
140
142
  }
141
143
 
@@ -195,6 +197,7 @@ function specialtyMatch(model, taskType, liveScores = {}) {
195
197
  }
196
198
 
197
199
  function qualityForTask(row, taskType) {
200
+ if (row?.queryEvidence?.type === taskType) return row.queryEvidence.quality
198
201
  const base = asScore(row?.liveScores?.[taskType]) ?? asScore(row?.liveOverall) ?? row?.metadata?.quality ?? row?.baseQuality ?? row?.quality ?? 0
199
202
  // Legacy programmatic scalar bias only. Production feedback uses an
200
203
  // independent preferenceAdjustments utility term, never this quality path.
@@ -202,6 +205,7 @@ function qualityForTask(row, taskType) {
202
205
  }
203
206
 
204
207
  function qualitySourceForTask(row, taskType) {
208
+ if (row?.queryEvidence?.type === taskType) return 'recorded-query-evidence'
205
209
  return asScore(row?.liveScores?.[taskType]) !== undefined || asScore(row?.liveOverall) !== undefined
206
210
  ? 'livebench' : row?.fallbackQualitySource ?? row?.qualitySource ?? 'unknown'
207
211
  }
@@ -616,6 +620,7 @@ function compareText(left, right) {
616
620
 
617
621
  function compareRowsStable(left, right) {
618
622
  return compareText(routeKey(left.provider, left.model), routeKey(right.provider, right.model))
623
+ || compareText(String(left.actionId ?? ''), String(right.actionId ?? ''))
619
624
  }
620
625
 
621
626
  function reasoningEffortRank(effort) {
@@ -648,6 +653,12 @@ export function selectReasoningEffort(efforts, preferred = 'medium') {
648
653
 
649
654
  function reasoningDecision(row, task) {
650
655
  const preferred = String(task.preferredReasoningEffort ?? 'medium')
656
+ if (row.reasoningExecution === 'unsupported-cli' || row.reasoningExecution === 'default') return {
657
+ reasoningEffort: undefined, reasoningFit: 0.55, preferredReasoningEffort: preferred,
658
+ multiplier: REASONING_EFFORT_MULTIPLIERS.medium,
659
+ reasoningExecutionNotice: row.reasoningExecution === 'default' ? '指定模型使用适配器默认推理档位。'
660
+ : 'CLI 推理档位尚无经验证的映射,按厂商默认档位执行。',
661
+ }
651
662
  const efforts = Array.isArray(row.reasoningEfforts) ? row.reasoningEfforts : []
652
663
  if (efforts.length === 0) {
653
664
  const knownUnsupported = row.reasoningKnown === true
@@ -662,7 +673,7 @@ function reasoningDecision(row, task) {
662
673
  const preferredRank = reasoningEffortRank(preferred)
663
674
  // On an equal distance the shared selector prefers the lower effort to
664
675
  // avoid unnecessary latency and output-token cost.
665
- const chosen = selectReasoningEffort(efforts, preferred)
676
+ const chosen = Object.hasOwn(row, 'actionEffort') ? row.actionEffort : selectReasoningEffort(efforts, preferred)
666
677
  const distance = Math.abs(reasoningEffortRank(chosen) - preferredRank)
667
678
  return {
668
679
  reasoningEffort: chosen,
@@ -683,9 +694,13 @@ function candidateUtility(row, task, weights, cacheReadRatio = 0, cacheWriteRati
683
694
  const reasoning = reasoningDecision(row, task)
684
695
  const reference = taskCost({ pricing: COST_REFERENCE_PRICING, reasoningKnown: true, reasoningEfforts: [] }, task, text, complexity, cacheReadRatio, cacheWriteRatio)
685
696
  const cost = row.pricing === null ? 0 : 1 / (1 + taskCost(row, task, text, complexity, cacheReadRatio, cacheWriteRatio) / Math.max(reference, 1e-12))
697
+ const resources = resourceTokenBudget(row, task, text, complexity, cacheReadRatio, cacheWriteRatio)
698
+ const latencyUtility = Number.isFinite(resources.estimatedLatencyMs)
699
+ ? 1 / (1 + resources.estimatedLatencyMs / 10_000)
700
+ : 1 - clamp(row.latency * reasoning.multiplier.latency)
686
701
  const score = weights.quality * quality
687
702
  + weights.cost * cost
688
- + weights.latency * (1 - clamp(row.latency * reasoning.multiplier.latency))
703
+ + weights.latency * latencyUtility
689
704
  + weights.specialty * specialtyForTask(row, task.type)
690
705
  + (weights.reasoning ?? 0) * reasoning.reasoningFit
691
706
  - weights.risk * row.risk
@@ -696,8 +711,7 @@ function candidateUtility(row, task, weights, cacheReadRatio = 0, cacheWriteRati
696
711
 
697
712
  function taskCost(row, task, text, complexity, cacheReadRatio = 0, cacheWriteRatio = 0) {
698
713
  if (row?.pricing === null) return 0 // Search sentinel; the public estimate remains null.
699
- const decision = row === null ? null : reasoningDecision(row, task)
700
- const tokens = taskTokenBudget(text, { ...task, reasoningEffort: decision?.reasoningEffort }, complexity, cacheReadRatio, cacheWriteRatio)
714
+ const tokens = resourceTokenBudget(row, task, text, complexity, cacheReadRatio, cacheWriteRatio)
701
715
  return row === null
702
716
  ? 0
703
717
  : (((tokens.inputTokens - tokens.cacheReadTokens - tokens.cacheWriteTokens) * row.pricing.input)
@@ -706,6 +720,81 @@ function taskCost(row, task, text, complexity, cacheReadRatio = 0, cacheWriteRat
706
720
  + (tokens.outputTokens * row.pricing.output)) / 1_000_000
707
721
  }
708
722
 
723
+ /** Empirical quantiles are predictions, never a joint budget certificate. */
724
+ function resourceTokenBudget(row, task, text, complexity, cacheReadRatio = 0, cacheWriteRatio = 0) {
725
+ const effort = row ? reasoningDecision(row, task).reasoningEffort : undefined
726
+ const fallback = taskTokenBudget(text, { ...task, reasoningEffort: effort }, complexity, cacheReadRatio, cacheWriteRatio)
727
+ if (!row?.routingEvidence) return { ...fallback, estimateSource: 'heuristic', resourceSampleCount: 0 }
728
+ const key = JSON.stringify([task.type, task.purpose, effort ?? '', String(text).length, cacheReadRatio, cacheWriteRatio])
729
+ if (row.resourceCache.has(key)) return row.resourceCache.get(key)
730
+ const observed = predictResources(row.routingEvidence, {
731
+ provider: row.provider, model: row.model, reasoningEffort: effort ?? '',
732
+ channel: row.executionChannel ?? 'harness-llm', type: task.type, purpose: task.purpose,
733
+ inputChars: String(text).length,
734
+ })
735
+ let result = { ...fallback, estimateSource: 'heuristic', resourceSampleCount: 0 }
736
+ if (observed) {
737
+ const promptRatio = observed.p90?.promptCharRatio ?? observed.promptCharRatio
738
+ const chars = Number.isFinite(promptRatio) ? String(text).length * promptRatio : fallback.inputTokens * 3.7
739
+ const inputTokens = Math.ceil(chars * observed.p90.inputTokenRatio)
740
+ const outputTokens = Math.ceil(observed.p90.outputTokens)
741
+ if (Number.isSafeInteger(inputTokens) && inputTokens >= 0 && Number.isSafeInteger(outputTokens) && outputTokens >= 0) {
742
+ const ratios = normalizedCacheRatios(cacheReadRatio, cacheWriteRatio)
743
+ const cacheReadTokens = Math.min(inputTokens, Math.round(inputTokens * ratios.read))
744
+ const cacheWriteTokens = Math.min(inputTokens - cacheReadTokens, Math.round(inputTokens * ratios.write))
745
+ result = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
746
+ estimateSource: observed.source, resourceSampleCount: observed.sampleCount,
747
+ estimatedLatencyMs: observed.p90.durationMs, empiricalQuantile: 0.9 }
748
+ }
749
+ }
750
+ row.resourceCache.set(key, result)
751
+ return result
752
+ }
753
+
754
+ /** Estimate one pinned API package without changing the task decomposition or choosing an effort. */
755
+ export function estimateAssignedPackage(route, task, text, complexity = 'balanced', {
756
+ pricing = {}, routingEvidence = null, cacheReadRatio = 0, cacheWriteRatio = 0,
757
+ } = {}) {
758
+ const prices = normalizePricing(pricing)
759
+ const override = prices[normalize(`${route.provider}/${route.model}`)] ?? prices[normalize(route.model)]
760
+ const selectedPrice = override ?? normalizePricing({ route: route.pricing }).route ?? null
761
+ const row = { provider: route.provider, model: route.model, pricing: selectedPrice,
762
+ reasoningExecution: 'default', executionChannel: 'harness-llm', routingEvidence,
763
+ resourceCache: new Map() }
764
+ const resources = resourceTokenBudget(row, task, text, complexity, cacheReadRatio, cacheWriteRatio)
765
+ const cost = selectedPrice === null ? null : taskCost(row, task, text, complexity, cacheReadRatio, cacheWriteRatio)
766
+ return { provider: route.provider, model: route.model, purpose: task.purpose, difficulty: task.difficulty,
767
+ ...resources, estimatedCost: Number.isFinite(cost) ? Number(cost.toFixed(6)) : null,
768
+ pricingSource: selectedPrice === null ? 'unknown' : override ? 'user' : route.pricingSource ?? 'route',
769
+ quality: null, qualitySource: 'manual-selection', handoffPenalty: 0 }
770
+ }
771
+
772
+ /** Expand only actions with their own task evidence; cold starts keep the old selector. */
773
+ function taskActions(row, task) {
774
+ const text = task.objective || row.routingText
775
+ const key = JSON.stringify([task.type, task.purpose, task.preferredReasoningEffort, text])
776
+ if (row.actionCache.has(key)) return row.actionCache.get(key)
777
+ const nearest = reasoningDecision(row, task).reasoningEffort
778
+ const qualityAt = effort => {
779
+ const qualityKey = JSON.stringify([task.type, effort ?? '', text])
780
+ if (!row.qualityCache.has(qualityKey)) row.qualityCache.set(qualityKey, predictQuality(row.routingEvidence, {
781
+ text, type: task.type, provider: row.provider, model: row.model,
782
+ reasoningEffort: effort ?? '', channel: row.executionChannel ?? 'harness-llm',
783
+ }))
784
+ return row.qualityCache.get(qualityKey)
785
+ }
786
+ let alternatives = []
787
+ if (row.reasoningExecution === 'api') alternatives = (row.reasoningEfforts ?? []).map(effort => ({ effort, quality: qualityAt(effort) })).filter(item => item.quality)
788
+ const joint = alternatives.length >= 2
789
+ const actions = joint ? alternatives : [{ effort: nearest, quality: qualityAt(nearest) }]
790
+ const variants = actions.map(({ effort, quality }) => ({ ...row, actionEffort: effort,
791
+ actionId: JSON.stringify([row.provider, row.model, effort ?? null]), jointEffort: joint,
792
+ queryEvidence: quality ? { ...quality, type: task.type } : null,
793
+ }))
794
+ row.actionCache.set(key, variants)
795
+ return variants
796
+ }
797
+
709
798
  function dominates(left, right, task, text, complexity, cacheReadRatio, cacheWriteRatio) {
710
799
  if (left.pricing === null || right.pricing === null) return false
711
800
  const leftReasoning = reasoningDecision(left, task)
@@ -742,9 +831,10 @@ function dominates(left, right, task, text, complexity, cacheReadRatio, cacheWri
742
831
  }
743
832
 
744
833
  function eligibleRowsForTask(rows, task) {
745
- return task.type === 'vision'
834
+ const eligible = task.type === 'vision'
746
835
  ? rows.filter(row => row.inputModalities.length === 0 || row.inputModalities.includes('image'))
747
836
  : rows
837
+ return eligible.flatMap(row => taskActions(row, task))
748
838
  }
749
839
 
750
840
  function candidatePool(rows, task, weights, text, complexity, cacheReadRatio, cacheWriteRatio, dependencySensitive = false) {
@@ -795,13 +885,14 @@ function solveAssignments({ rows, tasks, weights, text, complexity, budget, cach
795
885
  const pools = tasks.map(task => candidatePool(rows, task, weights, text, complexity, cacheReadRatio, cacheWriteRatio, dependencySensitive))
796
886
  const result = solveCandidateAssignments({ tasks, pools: pools.map((pool, index) => pool.options.map(option => ({
797
887
  route: routeKey(option.row.provider, option.row.model), score: option.decision.score, cost: option.cost,
888
+ actionId: option.row.actionId, handoffRoute: routeKey(option.row.provider, option.row.model),
798
889
  qualityShortfall: Math.max(0, option.decision.floor - qualityForTask(option.row, tasks[index].type)),
799
890
  pricingKnown: option.row.pricing !== null, payload: option,
800
891
  }))), budget, beamWidth: ROUTING_BEAM_WIDTH, minimizeCost })
801
892
  if (!result) return null
802
893
  return {
803
894
  ...result,
804
- assignments: result.choices.map((choice, index) => ({ task: tasks[index], row: choice.payload.row,
895
+ assignments: result.choices.map((choice, index) => ({ task: tasks[index], row: { ...choice.payload.row, estimatedCost: choice.payload.row.pricing === null ? null : choice.cost },
805
896
  decision: { ...choice.payload.decision, relaxed: choice.qualityShortfall > 0 },
806
897
  estimatedCost: choice.cost, handoffPenalty: choice.handoffPenalty })),
807
898
  usedRoutes: new Set(result.choices.map(choice => choice.route)),
@@ -810,7 +901,7 @@ function solveAssignments({ rows, tasks, weights, text, complexity, budget, cach
810
901
  }
811
902
  }
812
903
 
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 } = {}) {
904
+ export function buildPlan({ text = '', available = [], mode = 'collective', pricing = {}, liveBench = null, liveBenchError = '', dataVersions = null, learning = null, routingEvidence = null, budgetUsd = 0, cacheReadRatio = 0, cacheWriteRatio = 0, preset = DEFAULT_ROUTING_PRESET } = {}) {
814
905
  const presetId = normalizeRoutingPreset(preset)
815
906
  const firstLine = String(text ?? '').split(/\r?\n/u)[0].trim()
816
907
  const transformOnly = /^(?:请|帮我)?(?:总结|概括|翻译|摘要|解释)(?:以下|下列|下面|这份|这些)/u.test(firstLine)
@@ -830,6 +921,8 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
830
921
  return {
831
922
  provider: String(entry.provider ?? ''),
832
923
  model: String(entry.model ?? ''),
924
+ executionChannel: ['harness-llm', 'official-cli'].includes(entry.executionChannel) ? entry.executionChannel : undefined,
925
+ reasoningExecution: ['api', 'unsupported-cli', 'default'].includes(entry.reasoningExecution) ? entry.reasoningExecution : undefined,
833
926
  benchmarkModel: typeof entry.benchmarkModel === 'string' ? entry.benchmarkModel : undefined,
834
927
  reasoningEfforts: [...new Set(reasoningEfforts)],
835
928
  defaultReasoningEffort: entry.defaultReasoningEffort === undefined ? undefined : String(entry.defaultReasoningEffort),
@@ -902,6 +995,9 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
902
995
  reasoningEfforts: route.reasoningEfforts,
903
996
  defaultReasoningEffort: route.defaultReasoningEffort,
904
997
  reasoningKnown: route.reasoningKnown,
998
+ reasoningExecution: route.reasoningExecution,
999
+ executionChannel: route.executionChannel,
1000
+ routingEvidence, routingText: text, actionCache: new Map(), qualityCache: new Map(), resourceCache: new Map(),
905
1001
  inputModalities: route.inputModalities,
906
1002
  pricing: pricingRow,
907
1003
  score: 0,
@@ -982,13 +1078,14 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
982
1078
  recommendedReasoningEffort: decision?.reasoningEffort,
983
1079
  preferredReasoningEffort: decision?.preferredReasoningEffort ?? task.preferredReasoningEffort,
984
1080
  reasoningFit: Number(Number(decision?.reasoningFit ?? 0).toFixed(3)),
1081
+ ...(decision?.reasoningExecutionNotice ? { reasoningExecutionNotice: decision.reasoningExecutionNotice } : {}),
985
1082
  purpose: task.purpose,
986
1083
  criticality: task.criticality,
987
1084
  qualityFloor: Number(task.qualityFloor.toFixed(3)),
988
1085
  dependsOn: [...(task.dependsOn ?? [])],
989
1086
  }))
990
1087
  const costBreakdown = assignments.map(({ task, row, decision, estimatedCost, handoffPenalty }, index) => {
991
- const tokens = taskTokenBudget(text, { ...task, reasoningEffort: decision?.reasoningEffort }, complexity.band, cacheReadRatio, cacheWriteRatio)
1088
+ const tokens = resourceTokenBudget(row, task, text, complexity.band, cacheReadRatio, cacheWriteRatio)
992
1089
  const taskEstimate = estimatedCost ?? taskCost(row, task, text, complexity.band, cacheReadRatio, cacheWriteRatio)
993
1090
  return {
994
1091
  stage: index + 1,
@@ -1004,6 +1101,11 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1004
1101
  cacheReadTokens: tokens.cacheReadTokens,
1005
1102
  cacheWriteTokens: tokens.cacheWriteTokens,
1006
1103
  outputTokens: tokens.outputTokens,
1104
+ estimateSource: tokens.estimateSource,
1105
+ resourceSampleCount: tokens.resourceSampleCount,
1106
+ ...(Number.isFinite(tokens.estimatedLatencyMs) ? { estimatedLatencyMs: tokens.estimatedLatencyMs } : {}),
1107
+ ...(tokens.empiricalQuantile ? { empiricalQuantile: tokens.empiricalQuantile } : {}),
1108
+ ...(row?.queryEvidence ? { queryEvidenceCount: row.queryEvidence.sampleCount, querySimilarity: row.queryEvidence.similarity } : {}),
1007
1109
  estimatedCost: row?.pricing === null || row === null ? null : Number(taskEstimate.toFixed(6)),
1008
1110
  quality: qualitySourceForTask(row, task.type) === 'unknown' || row === null ? null : Number(qualityForTask(row, task.type).toFixed(3)),
1009
1111
  qualitySource: qualitySourceForTask(row, task.type),
@@ -1021,8 +1123,9 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1021
1123
  const baselineCost = baselineRows.every(item => item.strongest?.pricing !== null && item.strongest !== null)
1022
1124
  ? baselineRows.reduce((sum, { task, strongest }) => sum + taskCost(strongest, task, text, complexity.band, cacheReadRatio, cacheWriteRatio), 0)
1023
1125
  : null
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)))
1126
+ const knownQualitySources = ['livebench', 'route', 'user', 'recorded-query-evidence']
1127
+ const qualityEvidenceComplete = assignments.length > 0 && assignments.every(({ row, task }) => knownQualitySources.includes(qualitySourceForTask(row, task.type)))
1128
+ && baselineRows.every(({ strongest, task }) => knownQualitySources.includes(qualitySourceForTask(strongest, task.type)))
1026
1129
  const budgetExceeded = Number(budgetUsd) > 0 && totalEstimate !== null ? totalEstimate > Number(budgetUsd) : null
1027
1130
  const savings = baselineCost === null || totalEstimate === null || !qualityEvidenceComplete
1028
1131
  ? null : baselineCost <= 0 ? 0 : clamp((baselineCost - totalEstimate) / baselineCost)
@@ -1043,7 +1146,11 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1043
1146
  taskType,
1044
1147
  taskTypes: [...new Set(taskNodes.map(task => task.type).filter(type => type !== 'reasoning'))],
1045
1148
  objectiveWeights: weights,
1046
- candidates: rows.slice(0, 8).map(row => {
1149
+ candidates: eligibleRowsForTask(rows, taskNodes[0]).map(row => ({ ...row,
1150
+ score: candidateUtility(row, taskNodes[0], weights, cacheReadRatio, cacheWriteRatio, text, complexity.band).score,
1151
+ quality: qualityForTask(row, taskNodes[0].type), qualitySource: qualitySourceForTask(row, taskNodes[0].type),
1152
+ estimatedCost: row.pricing === null ? null : taskCost(row, taskNodes[0], text, complexity.band, cacheReadRatio, cacheWriteRatio),
1153
+ })).sort((left, right) => right.score - left.score || compareRowsStable(left, right)).slice(0, 8).map(row => {
1047
1154
  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
1155
  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 }
1049
1156
  }),
@@ -1055,6 +1162,11 @@ export function buildPlan({ text = '', available = [], mode = 'collective', pric
1055
1162
  costBreakdown,
1056
1163
  optimization: {
1057
1164
  dataVersions,
1165
+ evidenceRevision: routingEvidence?.revision ?? null,
1166
+ qualityEvidenceCount: assignments.filter(({ row }) => row.queryEvidence).length,
1167
+ resourceEvidenceCount: costBreakdown.filter(item => item.estimateSource === 'recorded-resource-evidence').length,
1168
+ jointEffortCandidates: (optimized?.candidatePools ?? []).reduce((sum, pool) => sum + pool.options.filter(item => item.row.jointEffort).length, 0),
1169
+ uncertaintySemantics: 'empirical-prediction-not-calibrated-probability-or-joint-budget-guarantee',
1058
1170
  personalization: learning ? {
1059
1171
  policyVersion: learning.policyVersion, revision: learning.revision,
1060
1172
  enabled: learning.enabled, scope: learning.scope, window: learning.window,