@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.
- package/.dsh-plugin/client.js +872 -80
- package/.dsh-plugin/index.mjs +225 -25
- package/.dsh-plugin/shared/assignment-solver.mjs +235 -35
- package/.dsh-plugin/shared/harness-plan.mjs +16 -4
- package/.dsh-plugin/shared/manual-plan.mjs +143 -0
- package/.dsh-plugin/shared/official-team-runtime.mjs +8 -1
- package/.dsh-plugin/shared/official-tools-remote.mjs +10 -0
- package/.dsh-plugin/shared/package-assignments.mjs +71 -0
- package/.dsh-plugin/shared/quality-gates.mjs +169 -0
- package/.dsh-plugin/shared/reasoning-execution.mjs +92 -0
- package/.dsh-plugin/shared/router-state.mjs +2 -1
- package/.dsh-plugin/shared/router.mjs +124 -12
- package/.dsh-plugin/shared/routing-evidence.mjs +720 -0
- package/.dsh-plugin/shared/run-ledger.mjs +85 -3
- package/.dsh-plugin/shared/task-executors.mjs +182 -27
- package/CHANGELOG.md +14 -0
- package/README.i18n.yaml +2 -2
- package/README.md +12 -8
- package/README.zh.md +12 -8
- package/docs/INSTALLATION_GUIDE.zh.md +12 -12
- package/docs/MANUAL_TASK_ASSIGNMENT.zh.md +46 -0
- package/docs/ROUTING_ALGORITHM_OPTIMIZATION.zh.md +91 -0
- package/docs/WORKBENCH_USER_GUIDE.zh.md +2 -2
- package/docs/releases/0.22.0-validation.md +28 -0
- package/package.json +15 -4
|
@@ -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)
|
|
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 *
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1025
|
-
|
|
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
|
|
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,
|