@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.
@@ -1,12 +1,20 @@
1
1
  /**
2
- * Deterministic budget-constrained DAG assignment, independent of model names.
3
- * Exact enumeration is bounded by the retained Cartesian candidate space;
4
- * larger spaces use a beam with a protected least-cost feasible prefix.
5
- * Optimality concerns this surrogate objective, never actual answer quality.
2
+ * Deterministic assignment over retained candidate actions, not actual answer quality.
3
+ * Small spaces enumerate exactly; larger spaces use safe boundary-state dominance
4
+ * and future-aware bounded frontier retention without a global optimality claim.
6
5
  */
7
6
  const EPSILON = 1e-12
8
7
  const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0
9
- const signature = state => JSON.stringify(state.choices.map(option => option.route))
8
+
9
+ const choiceSignature = option => {
10
+ if (option.actionId === undefined && option.effort === undefined && option.reasoningEffort === undefined) return option.route
11
+ return JSON.stringify([option.actionId ?? option.route, option.effort ?? option.reasoningEffort ?? null])
12
+ }
13
+
14
+ const signature = state => JSON.stringify(state.choices.map(choiceSignature))
15
+
16
+ const compareQuality = (left, right) =>
17
+ left.relaxedCount - right.relaxedCount || left.qualityShortfall - right.qualityShortfall
10
18
 
11
19
  function compareStates(left, right, minimizeCost = false) {
12
20
  return left.relaxedCount - right.relaxedCount
@@ -17,69 +25,261 @@ function compareStates(left, right, minimizeCost = false) {
17
25
  || compareText(signature(left), signature(right))
18
26
  }
19
27
 
28
+ // Safe DP dominance for states with the same future dependency boundary.
29
+ // Quality is lexicographic and always precedes cost/score in compareStates.
30
+ // Cost/score are Pareto compared, then switches and the deterministic signature
31
+ // break exact ties. Comparisons are strict; no epsilon is used here.
32
+ const dominates = (left, right) => {
33
+ if (left.cost > right.cost) return false
34
+ const quality = compareQuality(left, right)
35
+ if (quality < 0) return true
36
+ if (quality > 0) return false
37
+ if (left.score > right.score) return true
38
+ if (left.score < right.score) return false
39
+ if (left.cost < right.cost) return true
40
+ if (left.cost > right.cost) return false
41
+ if (left.switches !== right.switches) return left.switches < right.switches
42
+ return compareText(signature(left), signature(right)) < 0
43
+ }
44
+
45
+ const dominanceOrder = (left, right) =>
46
+ left.cost - right.cost
47
+ || compareQuality(left, right)
48
+ || right.score - left.score
49
+ || left.switches - right.switches
50
+ || compareText(signature(left), signature(right))
51
+
20
52
  export function solveCandidateAssignments({ tasks = [], pools = [], budget = Infinity, beamWidth = 256, exactLimit = 4096, handoffPenalty = 0.015, minimizeCost = false } = {}) {
21
53
  if (!tasks.length || tasks.length !== pools.length) return null
54
+ const taskCount = tasks.length
55
+ const taskIndexById = new Map()
22
56
  const preceding = new Set()
23
- for (const task of tasks) {
24
- if (!task.id || preceding.has(task.id) || (task.dependsOn ?? []).some(id => !preceding.has(id))) {
57
+ for (let index = 0; index < taskCount; index++) {
58
+ const task = tasks[index]
59
+ if (!task || !task.id || preceding.has(task.id) || (task.dependsOn ?? []).some(id => !preceding.has(id))) {
25
60
  throw new RangeError('Assignment tasks must have unique ids and known dependencies in topological order')
26
61
  }
27
62
  preceding.add(task.id)
63
+ taskIndexById.set(task.id, index)
28
64
  }
65
+
29
66
  const constrained = Number.isFinite(budget)
30
67
  const options = pools.map(pool => (Array.isArray(pool) ? pool : []).filter(option =>
31
- Number.isFinite(option.cost) && option.cost >= 0 && Number.isFinite(option.score)
68
+ option && Number.isFinite(option.cost) && option.cost >= 0 && Number.isFinite(option.score)
32
69
  && (!constrained || option.pricingKnown === true)))
33
70
  if (options.some(pool => pool.length === 0)) return null
34
- const suffixMinimum = Array(tasks.length + 1).fill(0)
71
+
72
+ const handoffRoutes = options.map(pool => pool.map(option => option.handoffRoute ?? option.route))
73
+ const maximumScores = options.map(pool => pool.reduce((best, option) => Math.max(best, option.score), -Infinity))
74
+ const dependencyIndexes = tasks.map(task => (task.dependsOn ?? []).map(id => taskIndexById.get(id)))
75
+ const lastUse = new Array(taskCount).fill(-1)
76
+ for (let index = 0; index < taskCount; index++) {
77
+ for (const depIndex of dependencyIndexes[index]) {
78
+ if (index > lastUse[depIndex]) lastUse[depIndex] = index
79
+ }
80
+ }
81
+
82
+ const suffixMinimum = new Array(taskCount + 1).fill(0)
83
+ const futureRelaxedLower = new Array(taskCount + 1).fill(0)
84
+ const futureShortfallLower = new Array(taskCount + 1).fill(0)
35
85
  let searchSpace = 1
36
86
  let searchSpaceCapped = false
37
- for (let index = tasks.length - 1; index >= 0; index--) {
38
- suffixMinimum[index] = suffixMinimum[index + 1] + Math.min(...options[index].map(option => option.cost))
87
+ for (let index = taskCount - 1; index >= 0; index--) {
88
+ let minCost = Infinity
89
+ let minRelaxedFlag = 1
90
+ let minShortfallAtFlag = Infinity
91
+ for (const option of options[index]) {
92
+ if (option.cost < minCost) minCost = option.cost
93
+ const shortfall = Math.max(0, Number(option.qualityShortfall) || 0)
94
+ const relaxedFlag = shortfall > 0 ? 1 : 0
95
+ if (relaxedFlag < minRelaxedFlag || (relaxedFlag === minRelaxedFlag && shortfall < minShortfallAtFlag)) {
96
+ minRelaxedFlag = relaxedFlag
97
+ minShortfallAtFlag = shortfall
98
+ }
99
+ }
100
+ suffixMinimum[index] = suffixMinimum[index + 1] + minCost
101
+ futureRelaxedLower[index] = futureRelaxedLower[index + 1] + minRelaxedFlag
102
+ futureShortfallLower[index] = futureShortfallLower[index + 1] + minShortfallAtFlag
39
103
  const product = searchSpace * options[index].length
40
104
  if (product > Number.MAX_SAFE_INTEGER) searchSpaceCapped = true
41
105
  searchSpace = Math.min(Number.MAX_SAFE_INTEGER, product)
42
106
  }
107
+
43
108
  if (constrained && suffixMinimum[0] > budget + EPSILON) return null
44
- const exact = !searchSpaceCapped && searchSpace <= Math.max(1, exactLimit)
45
- const width = Math.max(1, Math.floor(beamWidth) || 256)
46
- const search = { method: exact ? 'exact' : 'beam', exact, searchSpace, searchSpaceCapped, expandedStates: 0, beamPruned: 0 }
109
+ const parsedExactLimit = Number(exactLimit)
110
+ const exactThreshold = Number.isFinite(parsedExactLimit)
111
+ ? Math.max(1, parsedExactLimit)
112
+ : exactLimit === Infinity ? Number.MAX_SAFE_INTEGER : 4096
113
+ const parsedBeamWidth = Number(beamWidth)
114
+ const width = Number.isFinite(parsedBeamWidth) ? Math.max(1, Math.floor(parsedBeamWidth) || 256) : 256
115
+ const exact = !searchSpaceCapped && searchSpace <= exactThreshold
116
+
117
+ const search = {
118
+ method: exact ? 'exact' : 'beam',
119
+ exact,
120
+ strategy: exact ? 'exact-enumeration' : 'boundary-frontier-lookahead',
121
+ searchSpace,
122
+ searchSpaceCapped,
123
+ expandedStates: 0,
124
+ beamPruned: 0,
125
+ dominancePruned: 0,
126
+ peakFrontier: 0,
127
+ retainedStates: 0,
128
+ beamWidth: width,
129
+ approximate: !exact,
130
+ optimalityScope: 'retained input candidate Cartesian space',
131
+ boundCertified: exact
132
+ }
133
+
134
+ // Negative handoff penalties historically increase score for mismatches, so an
135
+ // optimistic lookahead must include the maximum possible unassigned reward.
136
+ const unassignedReward = handoffPenalty < 0 ? -handoffPenalty : 0
47
137
  let states = [{ choices: [], routesByTask: new Map(), score: 0, cost: 0, switches: 0, relaxedCount: 0, qualityShortfall: 0 }]
48
- for (let index = 0; index < tasks.length; index++) {
49
- const task = tasks[index]
138
+
139
+ for (let index = 0; index < taskCount; index++) {
50
140
  const expanded = []
51
141
  for (const state of states) {
52
- for (const option of options[index]) {
142
+ for (let optionIndex = 0; optionIndex < options[index].length; optionIndex++) {
143
+ const option = options[index][optionIndex]
53
144
  const cost = state.cost + option.cost
54
145
  if (constrained && cost + suffixMinimum[index + 1] > budget + EPSILON) continue
55
- const switches = (task.dependsOn ?? []).reduce((count, dependency) => {
56
- const route = state.routesByTask.get(dependency)
57
- return count + (route !== undefined && route !== option.route ? 1 : 0)
58
- }, 0)
146
+ let switches = 0
147
+ for (const depIndex of dependencyIndexes[index]) {
148
+ const route = state.routesByTask.get(tasks[depIndex].id)
149
+ if (route !== undefined && route !== handoffRoutes[index][optionIndex]) switches++
150
+ }
59
151
  const penalty = switches * handoffPenalty
60
152
  const shortfall = Math.max(0, Number(option.qualityShortfall) || 0)
61
153
  const routesByTask = new Map(state.routesByTask)
62
- routesByTask.set(task.id, option.route)
63
- expanded.push({ choices: [...state.choices, { ...option, handoffPenalty: penalty }], routesByTask,
64
- score: state.score + option.score - penalty, cost, switches: state.switches + switches,
65
- relaxedCount: state.relaxedCount + (shortfall > 0 ? 1 : 0), qualityShortfall: state.qualityShortfall + shortfall })
154
+ routesByTask.set(tasks[index].id, handoffRoutes[index][optionIndex])
155
+ expanded.push({
156
+ choices: [...state.choices, { ...option, handoffPenalty: penalty }],
157
+ routesByTask,
158
+ score: state.score + option.score - penalty,
159
+ cost,
160
+ switches: state.switches + switches,
161
+ relaxedCount: state.relaxedCount + (shortfall > 0 ? 1 : 0),
162
+ qualityShortfall: state.qualityShortfall + shortfall
163
+ })
66
164
  search.expandedStates++
67
165
  }
68
166
  }
69
167
  if (!expanded.length) return null
70
- expanded.sort((left, right) => compareStates(left, right, minimizeCost))
71
- if (!exact && expanded.length > width) {
72
- // A high-utility prefix must not crowd out every affordable completion.
73
- // This also works at width=1; quality relaxation remains lexicographic.
74
- states = expanded.slice(0, width)
75
- if (constrained && index < tasks.length - 1) {
76
- const cheapest = expanded.reduce((best, state) => compareStates(state, best, true) < 0 ? state : best)
77
- if (!states.includes(cheapest)) states[width - 1] = cheapest
168
+
169
+ if (exact) {
170
+ expanded.sort((left, right) => compareStates(left, right, minimizeCost))
171
+ states = expanded
172
+ } else {
173
+ // Group by the only information future transitions can observe: handoff
174
+ // routes of already assigned tasks that still have a future dependent.
175
+ const groups = new Map()
176
+ for (const state of expanded) {
177
+ const boundary = []
178
+ for (let prev = 0; prev <= index; prev++) {
179
+ if (lastUse[prev] > index) boundary.push([tasks[prev].id, state.routesByTask.get(tasks[prev].id)])
180
+ }
181
+ const key = JSON.stringify(boundary)
182
+ const group = groups.get(key)
183
+ if (group) group.push(state)
184
+ else groups.set(key, [state])
185
+ }
186
+
187
+ const compressed = []
188
+ for (const group of groups.values()) {
189
+ group.sort(dominanceOrder)
190
+ const retained = []
191
+ for (const candidate of group) {
192
+ let dominated = false
193
+ for (const keeper of retained) {
194
+ if (dominates(keeper, candidate)) {
195
+ dominated = true
196
+ break
197
+ }
198
+ }
199
+ if (dominated) search.dominancePruned++
200
+ else retained.push(candidate)
201
+ }
202
+ for (const state of retained) compressed.push(state)
78
203
  }
79
- search.beamPruned += expanded.length - states.length
80
- } else states = expanded
204
+
205
+ const scoreUpperCache = new Map()
206
+ const optimisticScore = state => {
207
+ if (scoreUpperCache.has(state)) return scoreUpperCache.get(state)
208
+ let total = state.score
209
+ for (let future = index + 1; future < taskCount; future++) {
210
+ const deps = dependencyIndexes[future]
211
+ let unassignedDeps = 0
212
+ let assignedCount = 0
213
+ const assignedRoutes = new Map()
214
+ for (const depIndex of deps) {
215
+ if (depIndex > index) unassignedDeps++
216
+ else {
217
+ const route = state.routesByTask.get(tasks[depIndex].id)
218
+ if (route !== undefined) {
219
+ assignedCount++
220
+ assignedRoutes.set(route, (assignedRoutes.get(route) ?? 0) + 1)
221
+ }
222
+ }
223
+ }
224
+ // Count predecessor routes once, rather than rescanning every edge
225
+ // for every future action. Independent future tasks need no scan.
226
+ if (!assignedCount || handoffPenalty === 0) {
227
+ total += maximumScores[future] + unassignedReward * unassignedDeps
228
+ continue
229
+ }
230
+ let best = -Infinity
231
+ for (let optionIndex = 0; optionIndex < options[future].length; optionIndex++) {
232
+ const option = options[future][optionIndex]
233
+ const matches = assignedRoutes.get(handoffRoutes[future][optionIndex]) ?? 0
234
+ let value = option.score - handoffPenalty * (assignedCount - matches)
235
+ if (unassignedDeps && unassignedReward) value += unassignedReward * unassignedDeps
236
+ if (value > best) best = value
237
+ }
238
+ total += best
239
+ }
240
+ scoreUpperCache.set(state, total)
241
+ return total
242
+ }
243
+
244
+ // Lookahead is a sorting heuristic only. Future quality is a per-task lower
245
+ // bound and ignores future handoff interactions, so it cannot certify optimality.
246
+ compressed.sort((left, right) => {
247
+ const leftRelaxed = left.relaxedCount + futureRelaxedLower[index + 1]
248
+ const rightRelaxed = right.relaxedCount + futureRelaxedLower[index + 1]
249
+ if (leftRelaxed !== rightRelaxed) return leftRelaxed - rightRelaxed
250
+ const leftShortfall = left.qualityShortfall + futureShortfallLower[index + 1]
251
+ const rightShortfall = right.qualityShortfall + futureShortfallLower[index + 1]
252
+ if (leftShortfall !== rightShortfall) return leftShortfall - rightShortfall
253
+ const leftScore = optimisticScore(left)
254
+ const rightScore = optimisticScore(right)
255
+ const leftCost = left.cost + suffixMinimum[index + 1]
256
+ const rightCost = right.cost + suffixMinimum[index + 1]
257
+ if (minimizeCost) {
258
+ if (leftCost !== rightCost) return leftCost - rightCost
259
+ if (leftScore !== rightScore) return rightScore - leftScore
260
+ } else {
261
+ if (leftScore !== rightScore) return rightScore - leftScore
262
+ if (leftCost !== rightCost) return leftCost - rightCost
263
+ }
264
+ return compareStates(left, right, minimizeCost)
265
+ })
266
+
267
+ if (compressed.length > width) {
268
+ states = compressed.slice(0, width)
269
+ search.beamPruned += compressed.length - states.length
270
+ if (constrained && index < taskCount - 1) {
271
+ let cheapest = compressed[0]
272
+ for (const state of compressed) if (compareStates(state, cheapest, true) < 0) cheapest = state
273
+ if (!states.includes(cheapest)) states[width - 1] = cheapest
274
+ }
275
+ } else states = compressed
276
+ }
277
+
278
+ if (states.length > search.peakFrontier) search.peakFrontier = states.length
81
279
  }
280
+
82
281
  states.sort((left, right) => compareStates(left, right, minimizeCost))
282
+ search.retainedStates = states.length
83
283
  const best = states[0]
84
284
  const { routesByTask, ...result } = best
85
285
  return { ...result, minimumCostLowerBound: suffixMinimum[0], search }
@@ -73,8 +73,8 @@ function annotate(channel) {
73
73
  */
74
74
  export function createPlanFromRoutes(task, availableRoutes, {
75
75
  mode = 'single', budgetUsd = 0, installedToolIds = [], runnableToolIds = [],
76
- pricing = {}, liveBench = null, liveBenchError = '', dataVersions = null, learning = null, cacheReadRatio = 0, cacheWriteRatio = 0,
77
- directProvider = '', directModel = '', preset = 'balanced', loggedOutToolIds = [],
76
+ pricing = {}, liveBench = null, liveBenchError = '', dataVersions = null, learning = null, routingEvidence = null, cacheReadRatio = 0, cacheWriteRatio = 0,
77
+ directProvider = '', directModel = '', preset = 'balanced', loggedOutToolIds = [], forceOfficialCli = false,
78
78
  } = {}) {
79
79
  const taskText = clean(task)
80
80
  if (!taskText) throw new Error('task must contain text')
@@ -96,13 +96,23 @@ export function createPlanFromRoutes(task, availableRoutes, {
96
96
  const channelOf = (provider, model) => {
97
97
  const route = routes.find(item => item.provider === provider && item.model === model)
98
98
  const key = `${String(provider ?? '')}\0${String(model ?? '')}\0${route?.execution ?? ''}`
99
- if (!channelCache.has(key)) channelCache.set(key, channelForProvider(provider, installedIds, runnableToolIds, route, loggedOutToolIds))
99
+ if (!channelCache.has(key)) {
100
+ const tool = forceOfficialCli ? toolForProvider(provider) : null
101
+ channelCache.set(key, tool ? {
102
+ kind: 'official-cli', tool: tool.id, label: tool.label,
103
+ detail: '官方 CLI 团队计划仅通过已核验的官方 CLI 执行;使用厂商默认推理档位。',
104
+ } : channelForProvider(provider, installedIds, runnableToolIds, route, loggedOutToolIds))
105
+ }
100
106
  return channelCache.get(key)
101
107
  }
102
108
  const needsImage = detectTaskTypes(taskText).includes('vision')
103
109
  const plan = buildPlan({
104
110
  text: taskText,
105
- available: routes,
111
+ available: routes.map(route => {
112
+ const channel = channelOf(route.provider, route.model)
113
+ return { ...route, executionChannel: channel.kind,
114
+ reasoningExecution: directRoute ? 'default' : channel.kind === 'harness-llm' ? 'api' : 'unsupported-cli' }
115
+ }),
106
116
  mode: directRoute ? 'single' : selectedMode,
107
117
  budgetUsd: Math.max(0, Number.isFinite(budgetUsd) ? budgetUsd : 0),
108
118
  pricing,
@@ -110,6 +120,7 @@ export function createPlanFromRoutes(task, availableRoutes, {
110
120
  liveBenchError,
111
121
  dataVersions,
112
122
  learning,
123
+ routingEvidence,
113
124
  cacheReadRatio,
114
125
  cacheWriteRatio,
115
126
  preset,
@@ -157,6 +168,7 @@ export function createPlanFromRoutes(task, availableRoutes, {
157
168
  recommendedModel: item.recommended,
158
169
  estimatedCost: plan.costBreakdown[index]?.estimatedCost ?? null,
159
170
  ...item.recommendedReasoningEffort ? { recommendedReasoningEffort: item.recommendedReasoningEffort } : {},
171
+ ...item.reasoningExecutionNotice ? { reasoningExecutionNotice: item.reasoningExecutionNotice } : {},
160
172
  ...annotate(channelOf(item.recommendedProvider, item.recommended)),
161
173
  verificationChecklist: item.purpose === 'synthesis'
162
174
  ? ['核对各工作包交付物与依赖', '记录冲突、未解决事项和最终验收结果']
@@ -0,0 +1,143 @@
1
+ import { estimateAssignedPackage } from './router.mjs'
2
+ import { getOfficialTool } from './official-tool-registry.mjs'
3
+ import { billingPlan } from './subscription-billing.mjs'
4
+ import { parsePackageAssignments, packagePlanKey } from './package-assignments.mjs'
5
+
6
+ const allocatedFields = [
7
+ 'recommendedReasoningEffort', 'preferredReasoningEffort', 'reasoningEffort',
8
+ 'plannedReasoningEffort', 'requestedReasoningEffort', 'reasoningFit',
9
+ 'reasoningOutputMultiplier', 'reasoningExecutionNotice', 'channelTool',
10
+ 'channelLabel', 'channelDetail', 'loginRequired', 'executionPreference',
11
+ 'executionChannel', 'estimatedCost', 'quality', 'qualitySource', 'pricingSource',
12
+ ]
13
+
14
+ function withoutAllocation(value) {
15
+ const result = { ...value }
16
+ for (const key of allocatedFields) delete result[key]
17
+ if (Array.isArray(result.dependsOn)) result.dependsOn = [...result.dependsOn]
18
+ return result
19
+ }
20
+
21
+ function annotation(target, tool) {
22
+ return target.kind === 'api' ? {
23
+ executionChannel: 'harness-llm',
24
+ channelDetail: '用户指定该模型目录 API;使用适配器默认推理档位。',
25
+ reasoningExecutionNotice: '手动分配使用适配器默认推理档位。',
26
+ } : {
27
+ executionChannel: 'official-cli', channelTool: tool.id, channelLabel: tool.label,
28
+ channelDetail: `${tool.label} 使用厂商工具自身配置的默认模型,不代表模型目录中的特定模型。`,
29
+ reasoningExecutionNotice: '使用厂商官方工具默认模型与默认推理档位。',
30
+ }
31
+ }
32
+
33
+ /** Apply explicit targets to the existing decomposition; never search or substitute targets. */
34
+ export function applyManualPackagePlan(plan, {
35
+ task, packageAssignments, packagePlanKey: expectedKey,
36
+ } = {}, routes, dataOptions = {}) {
37
+ const assignments = parsePackageAssignments(packageAssignments)
38
+ if (!assignments) throw new TypeError('手动分配需要提供全部工作包目标。')
39
+ const packages = plan?.team?.workPackages
40
+ if (plan?.routingBypassed || plan?.mode === 'direct' || !Array.isArray(packages)
41
+ || !packages.length || packages.length > 32) {
42
+ throw new Error('手动分配需要当前任务的工作包计划,不能用于全局指定模型。')
43
+ }
44
+ const key = packagePlanKey(plan, task)
45
+ if (!key || expectedKey !== key) throw new Error('工作包计划已变化,请重新确认分配。')
46
+ const ids = packages.map(item => item?.id)
47
+ if (new Set(ids).size !== ids.length || ids.some(id => typeof id !== 'string' || !id)) {
48
+ throw new Error('工作包计划包含无效或重复标识。')
49
+ }
50
+ const chosenIds = Object.keys(assignments)
51
+ if (chosenIds.length !== ids.length || chosenIds.some(id => !ids.includes(id))) {
52
+ throw new Error('请为全部工作包分配目标,且不能添加其他工作包。')
53
+ }
54
+ const catalog = Array.isArray(routes) ? routes : []
55
+ const choices = packages.map(item => {
56
+ const target = assignments[item.id]
57
+ if (!target) throw new Error(`工作包 ${item.id} 缺少目标。`)
58
+ if (target.kind === 'tool') {
59
+ const tool = getOfficialTool(target.toolId)
60
+ if (!tool) throw new Error(`未知的官方工具:${target.toolId}`)
61
+ return { target, tool, provider: 'official-tool', model: target.toolId }
62
+ }
63
+ const matches = catalog.filter(route => route.provider === target.provider && route.model === target.model)
64
+ if (matches.length !== 1) throw new Error(`指定模型 ${target.provider}/${target.model} 不在唯一可用目录中。`)
65
+ const route = matches[0]
66
+ const billing = billingPlan(route, catalog)
67
+ if (billing.mode === 'subscription-only' || billing.subscription?.kind === 'plan-key') {
68
+ throw new Error(`模型 ${target.provider}/${target.model} 使用订阅路线,不能作为手动 API 目标。`)
69
+ }
70
+ if (item.type === 'vision' && Array.isArray(route.inputModalities)
71
+ && route.inputModalities.length && !route.inputModalities.some(value => String(value).toLowerCase() === 'image')) {
72
+ throw new Error(`工作包 ${item.id} 需要图像模型,所选模型仅声明其他输入能力。`)
73
+ }
74
+ return { target, route, provider: target.provider, model: target.model }
75
+ })
76
+
77
+ const costBreakdown = packages.map((item, index) => {
78
+ const choice = choices[index]
79
+ const estimate = choice.route
80
+ ? estimateAssignedPackage(choice.route, item, task, plan.complexity?.band ?? 'balanced', dataOptions)
81
+ : { estimatedCost: null, quality: null, qualitySource: 'unknown', pricingSource: 'unknown',
82
+ estimateSource: 'official-tool-unknown', handoffPenalty: 0 }
83
+ return { ...withoutAllocation(estimate), stage: index + 1, purpose: item.purpose,
84
+ difficulty: item.difficulty, provider: choice.provider, model: choice.model,
85
+ estimatedCost: Number.isFinite(estimate.estimatedCost) && estimate.estimatedCost >= 0 ? estimate.estimatedCost : null,
86
+ quality: estimate.quality ?? null, qualitySource: estimate.qualitySource ?? 'unknown',
87
+ pricingSource: estimate.pricingSource ?? 'unknown', selectionSource: 'manual',
88
+ manualAssignment: { ...choice.target }, ...annotation(choice.target, choice.tool) }
89
+ })
90
+ const workPackages = packages.map((item, index) => {
91
+ const choice = choices[index], cost = costBreakdown[index]
92
+ return { ...withoutAllocation(item), recommendedProvider: choice.provider,
93
+ recommendedModel: choice.model, estimatedCost: cost.estimatedCost,
94
+ qualitySource: cost.qualitySource, pricingSource: cost.pricingSource,
95
+ manualAssignment: { ...choice.target }, selectionSource: 'manual',
96
+ algorithmRecommendation: { provider: item.recommendedProvider, model: item.recommendedModel,
97
+ channel: item.executionChannel }, ...annotation(choice.target, choice.tool) }
98
+ })
99
+ const originalSubtasks = new Map((plan.subtasks ?? []).map(item => [item.id, item]))
100
+ const subtasks = workPackages.map(item => ({ ...withoutAllocation(originalSubtasks.get(item.id) ?? item),
101
+ id: item.id, name: item.name, ...(item.objective !== undefined ? { objective: item.objective } : {}),
102
+ type: item.type, purpose: item.purpose, dependsOn: [...(item.dependsOn ?? [])],
103
+ recommendedProvider: item.recommendedProvider, recommended: item.recommendedModel,
104
+ estimatedCost: item.estimatedCost, qualitySource: item.qualitySource, pricingSource: item.pricingSource,
105
+ manualAssignment: { ...item.manualAssignment }, selectionSource: 'manual',
106
+ algorithmRecommendation: { ...item.algorithmRecommendation },
107
+ ...annotation(item.manualAssignment, choices[ids.indexOf(item.id)].tool) }))
108
+ // The public total stays unknown when any official-tool or API price is unknown.
109
+ const pricingComplete = costBreakdown.every(item => item.estimatedCost !== null)
110
+ const estimatedCost = pricingComplete ? costBreakdown.reduce((sum, item) => sum + item.estimatedCost, 0) : null
111
+ const budget = Number(plan.optimization?.budgetUsd)
112
+ const budgetUsd = Number.isFinite(budget) && budget > 0 ? budget : 0
113
+ const budgetExceeded = estimatedCost === null ? null : budgetUsd > 0 ? estimatedCost > budgetUsd : false
114
+ const selection = index => ({ provider: choices[index].provider, model: choices[index].model,
115
+ estimatedCost: costBreakdown[index].estimatedCost, qualitySource: costBreakdown[index].qualitySource,
116
+ pricingSource: costBreakdown[index].pricingSource, selectionSource: 'manual',
117
+ ...annotation(choices[index].target, choices[index].tool) })
118
+ const synthesisIndex = packages.findLastIndex(item => item.purpose === 'synthesis')
119
+ const originalOptimization = plan.optimization ?? {}
120
+ const clearedPlan = withoutAllocation(plan)
121
+ return { ...clearedPlan, assignmentMode: 'manual', manualAssignments: assignments, packagePlanKey: key,
122
+ selected: selection(0), synthesizer: plan.synthesizer ? selection(synthesisIndex >= 0 ? synthesisIndex : packages.length - 1) : null,
123
+ subtasks, team: { ...plan.team, workPackages }, costBreakdown, estimatedCost,
124
+ originalAlgorithmEstimate: plan.originalAlgorithmEstimate ?? plan.estimatedCost ?? null,
125
+ unassignableTasks: [], ...annotation(choices[0].target, choices[0].tool),
126
+ reason: '已按用户选择逐包分配模型与官方工具;执行目标不会因算法评分或预算而自动替换。',
127
+ pricingNotice: estimatedCost === null
128
+ ? '手动目标中有费用未知的模型或官方工具,无法确认总费用;工具费用不按原推荐模型计价。'
129
+ : '费用按手动选择的 API、默认推理档位和估计资源重算,不是供应商账单或硬性支出上限。',
130
+ qualityNotice: '手动分配由用户决定目标,尚未验证所选目标的任务质量;算法质量门槛不代表手动方案已经通过验证。',
131
+ toolNotice: '官方工具使用厂商自身配置的默认模型与推理档位,执行前由 Host 核验工具是否可用。',
132
+ optimization: { ...originalOptimization, manualAssignment: true, solver: 'manual-package-assignment',
133
+ search: null, optimalityScope: null, budgetUsd, budgetExceeded,
134
+ budgetFeasible: estimatedCost === null ? null : !budgetExceeded,
135
+ pricingComplete, qualityEvidenceComplete: false, constraintRelaxed: null,
136
+ baselineAllStrongCost: null, estimatedSavings: null, minimumFeasibleCost: null,
137
+ jointEffortCandidates: 0, qualityEvidenceCount: 0,
138
+ resourceEvidenceCount: costBreakdown.filter(item => item.estimateSource === 'recorded-resource-evidence').length,
139
+ distinctRoutes: new Set(choices.map(item => JSON.stringify(item.target))).size,
140
+ handoffCount: null, paretoPruned: 0, candidateTruncated: 0, qualityFiltered: 0,
141
+ qualityConstraintsEnforced: false },
142
+ }
143
+ }
@@ -12,6 +12,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod
12
12
  import { runOfficialTool, officialToolExecutionCapabilities, officialToolReadiness } from './official-tool-executor.mjs'
13
13
  import { toolForProvider } from './official-tool-registry.mjs'
14
14
  import { redactDiagnostic, verifiedDiagnostic } from './task-executors.mjs'
15
+ import { unsupportedCliReasoning } from './reasoning-execution.mjs'
15
16
 
16
17
  const MAX_GIT_OUTPUT = 64_000_000
17
18
  const GIT_TIMEOUT_MS = 120_000
@@ -455,12 +456,14 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
455
456
  if (signal?.aborted) return { status: 'cancelled', workspace: runWorkspace, results: completed,
456
457
  ...(isolated ? { baseCommit: isolated.baseCommit } : {}), ...(seeded ? { seeded } : {}) }
457
458
  const cliModel = teamCliModel(item, configuredCliModels)
459
+ const prompt = packagePrompt(task, item, [...context, ...completed])
460
+ const startedAt = Date.now()
458
461
  let result
459
462
  try {
460
463
  result = await runTool({
461
464
  toolId: item.toolId,
462
465
  modelId: cliModel,
463
- task: packagePrompt(task, item, [...context, ...completed]),
466
+ task: prompt,
464
467
  workspace: runWorkspace,
465
468
  mode,
466
469
  isolatedRoot: isolated?.isolatedRoot,
@@ -474,6 +477,7 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
474
477
  // ledger and retry even when a later adapter rejects before returning.
475
478
  result = executionException(error, signal)
476
479
  }
480
+ const durationMs = Date.now() - startedAt
477
481
  const reportedModel = reportedCliModel(item.toolId, result.reportedModel)
478
482
  const modelMismatch = result.status === 'succeeded' && cliModel && reportedModel
479
483
  && cliModel !== reportedModel
@@ -481,6 +485,9 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
481
485
  completed.push({ id: item.id, name: item.name,
482
486
  ...(item.objective ? { objective: item.objective } : {}), provider: item.recommendedProvider,
483
487
  recommendedModel: item.recommendedModel, toolId: item.toolId,
488
+ ...unsupportedCliReasoning({ reasoningEffort: item.recommendedReasoningEffort }),
489
+ ...(Number.isFinite(durationMs) && durationMs >= 0 ? { durationMs } : {}),
490
+ inputChars: prompt.length,
484
491
  requestedCliModel: Object.hasOwn(result, 'requestedModel') ? result.requestedModel : cliModel,
485
492
  actualModel: reportedModel,
486
493
  ...(result.reportedModel !== undefined ? { reportedModel: result.reportedModel } : {}),
@@ -6,6 +6,7 @@
6
6
  * accepts a command, package name, URL, argument vector, or executable path.
7
7
  */
8
8
  import { getOfficialTool, installMethodsFor } from './official-tool-registry.mjs'
9
+ import { parsePackageAssignments } from './package-assignments.mjs'
9
10
  import {
10
11
  parseTerminalRead,
11
12
  parseTerminalResize,
@@ -157,6 +158,14 @@ export function parseRunRequest(value) {
157
158
  if (request.executionMode !== undefined && !['read-only', 'workspace-write'].includes(request.executionMode)) throw new TypeError('executionMode is invalid')
158
159
  if (request.executionEnvironment !== undefined && !['harness', 'cli-native'].includes(request.executionEnvironment)) throw new TypeError('executionEnvironment is invalid')
159
160
  const officialTeam = request.executor === 'official-team'
161
+ if (request.assignmentMode !== undefined && !['algorithm', 'manual'].includes(request.assignmentMode)) throw new TypeError('assignmentMode is invalid')
162
+ const manual = request.assignmentMode === 'manual'
163
+ if (manual && (officialTeam || provider || model)) throw new TypeError('manual assignments require routed work packages without an explicit global route')
164
+ if (!manual && (request.packageAssignments !== undefined || request.packagePlanKey !== undefined)) throw new TypeError('package assignments require manual assignmentMode')
165
+ const packageAssignments = manual ? parsePackageAssignments(request.packageAssignments) : undefined
166
+ if (manual && (!packageAssignments || typeof request.packagePlanKey !== 'string' || request.packagePlanKey.length > 200 || !request.packagePlanKey)) {
167
+ throw new TypeError('manual assignments require packageAssignments and a current packagePlanKey')
168
+ }
160
169
  if (officialTeam && (request.planMode !== 'team' || provider || model)) throw new TypeError('official-team requires a team plan without an explicit route')
161
170
  if (!officialTeam && request.executionMode === 'workspace-write') throw new TypeError('workspace-write requires official-team')
162
171
  if (request.executionEnvironment === 'cli-native' && (!officialTeam || request.executionMode !== 'workspace-write')) {
@@ -170,6 +179,7 @@ export function parseRunRequest(value) {
170
179
  ...(officialTeam ? { executor: 'official-team', executionMode: request.executionMode ?? 'read-only',
171
180
  ...(request.executionEnvironment === undefined ? {} : { executionEnvironment: request.executionEnvironment }) } : {}),
172
181
  ...(provider ? { provider, model } : {}),
182
+ ...(manual ? { assignmentMode: 'manual', packageAssignments, packagePlanKey: request.packagePlanKey } : {}),
173
183
  ...(['single', 'team'].includes(request.planMode) ? { planMode: request.planMode } : {}),
174
184
  ...(['economy', 'balanced', 'quality'].includes(request.preset) ? { preset: request.preset } : {}),
175
185
  ...(request.budgetUsd !== undefined ? { budgetUsd: request.budgetUsd } : {}),