@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.
@@ -9,18 +9,23 @@ import { DEFAULT_ROUTING_PRESET, normalizeRoutingPreset, routingPreset } from '.
9
9
  import { HEALTH_CACHE_MS, apiKeyEnvPresent, healthCache, runHealthCheck, subscriptionLoginOf } from './shared/tool-health.mjs'
10
10
  import { latestVersions } from './shared/latest-versions.mjs'
11
11
  import {
12
- DEFAULT_COOLDOWN_MINUTES, billingOverview, createQuotaTracker, detectQuotaExhaustion, parseQuotaPatterns, vendorKey,
12
+ DEFAULT_COOLDOWN_MINUTES, billingOverview, billingPlan, createQuotaTracker, detectQuotaExhaustion, parseQuotaPatterns, vendorKey,
13
13
  } from './shared/subscription-billing.mjs'
14
14
  import { createRouterState } from './shared/router-state.mjs'
15
15
  import { applyFeedbackProfile, buildFeedbackProfile } from './shared/adaptive-feedback.mjs'
16
+ import { buildRoutingEvidence, parseRoutingEvidenceJson } from './shared/routing-evidence.mjs'
17
+ import { apiReasoningRequest } from './shared/reasoning-execution.mjs'
18
+ import { parseQualityGateJson, runQualityCascade } from './shared/quality-gates.mjs'
16
19
  import { applyPricingSnapshot, createDynamicDataRefresher, dynamicDataSummary, routingData } from './shared/dynamic-data.mjs'
17
20
  import { projectDynamicData } from './shared/dynamic-data-view.mjs'
18
21
  import {
19
22
  budgetCheck, buildRunRecord, formatUsd, buildTeamRunRecord, buildToolRunRecord, billingOf, mergeRerun,
20
- spending, storedResults, actualCost, teamExecutionResults,
23
+ spending, storedResults, actualCost, hasStartedQualityAttempts, teamExecutionResults,
21
24
  } from './shared/run-ledger.mjs'
22
25
  import { routeBoundaries } from './shared/security-boundaries.mjs'
23
26
  import { createPlanFromRoutes } from './shared/harness-plan.mjs'
27
+ import { applyManualPackagePlan } from './shared/manual-plan.mjs'
28
+ import { parsePackageAssignments, packagePlanKey } from './shared/package-assignments.mjs'
24
29
  import { applyModelProfiles, parseModelProfilesJson } from './shared/model-profiles.mjs'
25
30
  import { registerOfficialToolsRemote } from './official-tools-remote-service.mjs'
26
31
  import { parseRunRequest } from './shared/official-tools-remote.mjs'
@@ -70,6 +75,10 @@ export const Config = z.object({
70
75
  budgetUsd: z.number().min(0).max(1_000_000).default(DEFAULT_ROUTER_SETTINGS.budgetUsd).volatile(),
71
76
  maxConsultOutputChars: z.number().step(1).min(500).max(50_000).default(12_000).volatile(),
72
77
  modelProfilesJson: z.string().max(32_000).default('[]').volatile(),
78
+ routingEvidenceJson: z.string().max(200_000).default('').volatile(),
79
+ resourceLearningEnabled: z.boolean().default(true).volatile(),
80
+ qualityEscalationEnabled: z.boolean().default(false).volatile(),
81
+ qualityGateJson: z.string().max(8_000).default('').volatile(),
73
82
  routingPreset: z.union(['economy', 'balanced', 'quality']).default(DEFAULT_ROUTING_PRESET).volatile(),
74
83
  dailyBudgetUsd: z.number().min(0).max(1_000_000).default(0).volatile(),
75
84
  monthlyBudgetUsd: z.number().min(0).max(1_000_000).default(0).volatile(),
@@ -440,6 +449,11 @@ async function routingSavedState(config) {
440
449
  // Cached source A must not masquerade as a newly configured source B.
441
450
  saved.dynamicData = projectDynamicData(refreshedData ?? saved.dynamicData, dynamicOptions(config))
442
451
  saved.routingLearning = buildFeedbackProfile(saved.runs, feedbackOptions(config))
452
+ const imported = text(valueOf(config, 'routingEvidenceJson', ''))
453
+ saved.routingEvidence = buildRoutingEvidence({
454
+ runs: valueOf(config, 'resourceLearningEnabled', true) !== false ? saved.runs : [],
455
+ qualityEvidence: imported ? parseRoutingEvidenceJson(imported) : null,
456
+ })
443
457
  return saved
444
458
  }
445
459
 
@@ -447,6 +461,7 @@ function planDataOptions(saved, config) {
447
461
  return {
448
462
  ...routingData(saved.dynamicData, dynamicOptions(config).enabled),
449
463
  learning: saved.routingLearning ?? buildFeedbackProfile(saved.runs, feedbackOptions(config)),
464
+ routingEvidence: saved.routingEvidence ?? buildRoutingEvidence(),
450
465
  }
451
466
  }
452
467
 
@@ -473,7 +488,7 @@ export async function createRoutePlan(ctx, task, config = {}, options = {}) {
473
488
  ? Promise.resolve(Array.isArray(options.installedToolIds) ? options.installedToolIds : [])
474
489
  : installedToolIds(),
475
490
  ])
476
- const readiness = options.skipToolProbe === true ? []
491
+ const readiness = Array.isArray(options.readiness) ? options.readiness : options.skipToolProbe === true ? []
477
492
  : await Promise.all(installed.map(id => officialToolReadiness(id, options.workspace ?? process.cwd())))
478
493
  const executable = new Set(Array.isArray(options.runnableToolIds)
479
494
  ? options.runnableToolIds : readiness.filter(item => item.ready && (item.readyModes ?? item.modes ?? []).includes('read-only')).map(item => item.id))
@@ -487,7 +502,7 @@ export async function createRoutePlan(ctx, task, config = {}, options = {}) {
487
502
  preset: routingPresetOf(config, options.preset),
488
503
  loggedOutToolIds: loggedOut,
489
504
  })
490
- return { ...plan, budgetStatus: budgetFor(config, saved, plan.estimatedCost) }
505
+ return { ...plan, packagePlanKey: packagePlanKey(plan, taskText), budgetStatus: budgetFor(config, saved, plan.estimatedCost) }
491
506
  }
492
507
 
493
508
  /** One bounded, independent call through the same official LLM service. */
@@ -499,6 +514,15 @@ export async function consultConfiguredModel(ctx, route, task, outputLimit = 12_
499
514
  if (!taskText) throw new Error('任务内容为空,请先描述任务。')
500
515
  throwIfAborted(signal)
501
516
  const limit = boundedInteger(outputLimit, 12_000, 1, 50_000)
517
+ const reasoning = apiReasoningRequest(route)
518
+ if (reasoning.error) {
519
+ return {
520
+ ok: false, provider, model, answer: '', truncated: false, finish: { kind: 'unknown' },
521
+ error: reasoning.error, ...reasoning.fields, notStarted: true,
522
+ }
523
+ }
524
+ const messageText = `You are an independent specialist consulted by another AI agent. Give a concise, evidence-oriented answer in the task's language.\n\nTask:\n${taskText}`
525
+ const startedAt = Date.now()
502
526
  const controller = new AbortController()
503
527
  const onAbort = () => controller.abort(signal.reason)
504
528
  signal?.addEventListener('abort', onAbort, { once: true })
@@ -506,6 +530,7 @@ export async function consultConfiguredModel(ctx, route, task, outputLimit = 12_
506
530
  let characterLimitReached = false
507
531
  let finish = { kind: 'unknown' }
508
532
  let usage = null
533
+ let usageIncomplete = false
509
534
  try {
510
535
  const stream = ctx.llm.stream({
511
536
  provider,
@@ -514,11 +539,12 @@ export async function consultConfiguredModel(ctx, route, task, outputLimit = 12_
514
539
  role: 'user',
515
540
  content: [{
516
541
  type: 'text',
517
- text: `You are an independent specialist consulted by another AI agent. Give a concise, evidence-oriented answer in the task's language.\n\nTask:\n${taskText}`,
542
+ text: messageText,
518
543
  }],
519
544
  }],
520
545
  maxTokens: Math.min(4_096, Math.max(256, Math.ceil(limit / 2))),
521
546
  signal: controller.signal,
547
+ ...reasoning.options,
522
548
  })
523
549
  for await (const chunk of stream) {
524
550
  if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
@@ -530,11 +556,11 @@ export async function consultConfiguredModel(ctx, route, task, outputLimit = 12_
530
556
  break
531
557
  }
532
558
  } else if (chunk?.type === 'usage' && chunk.usage && typeof chunk.usage === 'object') {
533
- usage = {
534
- inputTokens: finiteNumber(chunk.usage.inputTokens, 0),
535
- outputTokens: finiteNumber(chunk.usage.outputTokens, 0),
536
- cacheReadTokens: finiteNumber(chunk.usage.cacheReadTokens, 0),
537
- cacheWriteTokens: finiteNumber(chunk.usage.cacheWriteTokens, 0),
559
+ usage = {}
560
+ usageIncomplete = false
561
+ for (const key of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']) {
562
+ if (typeof chunk.usage[key] === 'number' && Number.isFinite(chunk.usage[key]) && chunk.usage[key] >= 0) usage[key] = chunk.usage[key]
563
+ else if (Object.hasOwn(chunk.usage, key) || ['inputTokens', 'outputTokens'].includes(key)) usageIncomplete = true
538
564
  }
539
565
  } else if (chunk?.type === 'finish') {
540
566
  const reason = chunk.reason
@@ -544,20 +570,44 @@ export async function consultConfiguredModel(ctx, route, task, outputLimit = 12_
544
570
  }
545
571
  }
546
572
  }
573
+ } catch (error) {
574
+ finish = { kind: signal?.aborted ? 'aborted' : 'error', error: String(error?.message ?? error).slice(0, 2_000) }
547
575
  } finally {
548
576
  signal?.removeEventListener('abort', onAbort)
549
577
  }
550
- throwIfAborted(signal)
578
+ const durationMs = Date.now() - startedAt
579
+ const inputChars = messageText.length
551
580
  const tokenLimitReached = finish.kind === 'max-tokens'
552
581
  const truncated = characterLimitReached || tokenLimitReached
553
- if (!characterLimitReached && (finish.kind === 'error' || finish.kind === 'aborted')) {
554
- return { ok: false, provider, model, answer, truncated, finish, error: finish.error }
582
+ if (signal?.aborted || !characterLimitReached && (finish.kind === 'error' || finish.kind === 'aborted')) {
583
+ return {
584
+ ok: false, provider, model, answer, truncated, finish,
585
+ ...reasoning.fields,
586
+ ...(Number.isFinite(durationMs) && durationMs >= 0 ? { durationMs } : {}),
587
+ ...(Number.isFinite(inputChars) && inputChars >= 0 ? { inputChars } : {}),
588
+ error: finish.error,
589
+ ...(signal?.aborted ? { cancelled: true } : {}),
590
+ ...(usageIncomplete ? { usageIncomplete: true } : {}),
591
+ ...(usage ? { usage } : {}),
592
+ }
555
593
  }
556
594
  if (!answer.trim()) {
557
- return { ok: false, provider, model, answer, truncated, finish, error: 'model returned no text' }
595
+ return {
596
+ ok: false, provider, model, answer, truncated, finish,
597
+ ...reasoning.fields,
598
+ ...(Number.isFinite(durationMs) && durationMs >= 0 ? { durationMs } : {}),
599
+ ...(Number.isFinite(inputChars) && inputChars >= 0 ? { inputChars } : {}),
600
+ error: 'model returned no text',
601
+ ...(usageIncomplete ? { usageIncomplete: true } : {}),
602
+ ...(usage ? { usage } : {}),
603
+ }
558
604
  }
559
605
  return {
560
606
  ok: true, provider, model, answer, truncated, finish,
607
+ ...(usageIncomplete ? { usageIncomplete: true } : {}),
608
+ ...reasoning.fields,
609
+ ...(Number.isFinite(durationMs) && durationMs >= 0 ? { durationMs } : {}),
610
+ ...(Number.isFinite(inputChars) && inputChars >= 0 ? { inputChars } : {}),
561
611
  ...(usage ? { usage } : {}),
562
612
  ...(tokenLimitReached
563
613
  ? { truncationReason: 'model-token-limit', notice: '模型达到本次调用的输出 token 上限,回答可能不完整。' }
@@ -699,6 +749,11 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
699
749
  const problem = taskTextProblem(taskText)
700
750
  if (problem) throw new Error(problem)
701
751
  const direct = text(options.provider) || text(options.model)
752
+ const manual = options.assignmentMode === 'manual'
753
+ if (options.assignmentMode !== undefined && !['algorithm', 'manual'].includes(options.assignmentMode)) throw new Error('不支持的分配方式。')
754
+ if (manual && direct) throw new Error('手动分配工作包不能同时指定全局单一模型。')
755
+ if (!manual && (options.packageAssignments !== undefined || options.packagePlanKey !== undefined)) throw new Error('工作包绑定需要选择手动分配。')
756
+ if (manual && valueOf(config, 'allowManualReassign', true) === false) throw new Error('设置中已关闭手动改派。')
702
757
  if (direct && (!text(options.provider) || !text(options.model))) throw new Error('指定模型需要同时提供 provider 和 model。')
703
758
  const saved = await routingSavedState(config)
704
759
  const routes = await routesWithLearning(ctx, config, options.signal, saved.runs, saved.dynamicData, saved.routingLearning)
@@ -708,7 +763,7 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
708
763
  const installed = options.skipToolProbe === true
709
764
  ? (Array.isArray(options.installedToolIds) ? options.installedToolIds : [])
710
765
  : await installedToolIds()
711
- const readiness = options.skipToolProbe === true ? []
766
+ const readiness = Array.isArray(options.readiness) ? options.readiness : options.skipToolProbe === true ? []
712
767
  : await Promise.all(installed.map(id => officialToolReadiness(id, options.workspace ?? process.cwd())))
713
768
  const runnableToolIds = Array.isArray(options.runnableToolIds)
714
769
  ? options.runnableToolIds
@@ -729,8 +784,18 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
729
784
  budgetUsd, installedToolIds: installed, runnableToolIds, preset: presetId, loggedOutToolIds: loggedOut,
730
785
  })
731
786
  let plan = planWith(preset, options.budgetUsd)
787
+ if (manual) {
788
+ plan = applyManualPackagePlan(plan, { task: taskText, packageAssignments: options.packageAssignments,
789
+ packagePlanKey: options.packagePlanKey }, routes, planDataOptions(saved, config))
790
+ await validateManualTools(ctx, plan.manualAssignments, { ...options, installedToolIds: installed, readiness })
791
+ }
732
792
  let budget = budgetFor(config, saved, plan.estimatedCost)
733
- if (budget.exceeded && budget.action === 'downgrade' && !direct) {
793
+ const perRunLimit = Number(options.budgetUsd) || 0
794
+ if (manual && perRunLimit > 0 && (plan.estimatedCost === null || plan.estimatedCost > perRunLimit)) {
795
+ budget = { ...budget, exceeded: budget.exceeded || 'run', estimateUsd: plan.estimatedCost,
796
+ message: plan.estimatedCost === null ? '手动分配包含费用未知的目标,无法确认满足本次预算。' : '手动分配的预估费用超出本次预算。' }
797
+ }
798
+ if (budget.exceeded && budget.action === 'downgrade' && !direct && !manual) {
734
799
  const cheaper = planWith('economy', budget.remainingUsd > 0 ? budget.remainingUsd : options.budgetUsd)
735
800
  const recheck = budgetFor(config, saved, cheaper.estimatedCost)
736
801
  if (!recheck.exceeded) {
@@ -742,9 +807,48 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
742
807
  return { taskText, direct: Boolean(direct), plan, budget, routes, preset }
743
808
  }
744
809
 
810
+ /** Check every explicit tool before any package can incur cost. */
811
+ async function validateManualTools(ctx, assignments, options = {}) {
812
+ const targets = parsePackageAssignments(assignments)
813
+ const ids = [...new Set(Object.values(targets ?? {}).filter(item => item.kind === 'tool').map(item => item.toolId))]
814
+ if (!ids.length) return
815
+ if (typeof ctx.sandbox?.confine !== 'function') throw new Error('手动分配的官方工具需要 Harness 进程沙箱;请检查执行环境后重新预览。')
816
+ const installed = options.installedToolIds ?? await installedToolIds()
817
+ const capabilities = options.executionCapabilities ?? officialToolExecutionCapabilities()
818
+ const knownReadiness = options.manualReadiness ?? options.readiness ?? []
819
+ for (const id of ids) {
820
+ if (!installed.includes(id)) throw new Error(`手动分配的官方工具 ${id} 尚未安装。`)
821
+ if (!capabilities.some(item => item.id === id && item.modes?.includes('read-only'))) throw new Error(`官方工具 ${id} 不支持当前平台的只读执行。`)
822
+ const ready = knownReadiness.find(item => item.id === id)
823
+ ?? await (options.readinessFor ?? officialToolReadiness)(id, options.workspace ?? process.cwd())
824
+ if (ready?.ready !== true || !(ready.readyModes ?? ready.modes ?? []).includes('read-only')) {
825
+ throw new Error(`官方工具 ${id} 尚未就绪:${ready?.reason ?? '请检查工具安装和只读执行支持。'}`)
826
+ }
827
+ }
828
+ }
829
+
830
+ /** Structural validation is opt-in; each API attempt retains its own price and usage. */
831
+ async function consultWithQualityGate(ctx, route, task, limit, signal, { gate = null, alternatives = [], approveUpgrade } = {}) {
832
+ if (!gate) return consultConfiguredModel(ctx, route, task, limit, signal)
833
+ const checked = await runQualityCascade({
834
+ route, task, gate, alternatives, approveUpgrade, signal,
835
+ call: request => consultConfiguredModel(ctx, request.route, request.task, limit, request.signal),
836
+ })
837
+ return { ...checked.result,
838
+ provider: checked.result.provider ?? route.provider, model: checked.result.model ?? route.model,
839
+ ...(checked.attempts.length ? { qualityAttempts: checked.attempts } : {}),
840
+ }
841
+ }
842
+
843
+ function apiUpgradeEligible(route, routes) {
844
+ return (route?.billing === undefined || route.billing === 'api-only') && route?.execution !== 'official'
845
+ && billingPlan(route, routes).subscription === null
846
+ && (route?.execution === 'api' || !toolForProvider(route?.provider))
847
+ }
848
+
745
849
  /** Route, or honor one explicit model, then run each package through its adapter. */
746
850
  export async function executeConfiguredAssignment(ctx, task, config = {}, options = {}) {
747
- const { taskText, direct, plan, budget, routes, preset } = await planAssignment(ctx, task, config, options)
851
+ const { taskText, direct, plan, budget, routes, preset } = options.preparedAssignment ?? await planAssignment(ctx, task, config, options)
748
852
  if (budget.exceeded && options.confirmOverBudget !== true) {
749
853
  return {
750
854
  plan, execution: null, paused: true,
@@ -757,6 +861,42 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
757
861
  const billing = await billingContext(config, options)
758
862
  const limit = boundedInteger(valueOf(config, 'maxConsultOutputChars', 12_000), 12_000, 500, 50_000)
759
863
  const startedAt = Date.now()
864
+ const gate = parseQualityGateJson(valueOf(config, 'qualityGateJson', ''))
865
+ const gatedApi = async ({ route, task: packageTask, signal, subscription }) => {
866
+ if (subscription) return consultConfiguredModel(ctx, route, packageTask, limit, signal)
867
+ const quality = routeQuality(route)
868
+ const alternatives = gate && !direct && route.manualPinned !== true && apiUpgradeEligible(route, routes)
869
+ && valueOf(config, 'qualityEscalationEnabled', false) === true && quality !== null
870
+ ? routes.filter(candidate => apiUpgradeEligible(candidate, routes) && routeQuality(candidate) !== null
871
+ && routeQuality(candidate) > quality && (candidate.provider !== route.provider || candidate.model !== route.model))
872
+ .slice().sort((left, right) => routeQuality(right) - routeQuality(left)
873
+ || `${left.provider}/${left.model}`.localeCompare(`${right.provider}/${right.model}`))
874
+ : []
875
+ return consultWithQualityGate(ctx, route, packageTask, limit, signal, { gate, alternatives,
876
+ approveUpgrade: async ({ to, attempts }) => {
877
+ if (valueOf(config, 'qualityEscalationEnabled', false) !== true || direct || route.manualPinned === true
878
+ || !apiUpgradeEligible(route, routes) || !apiUpgradeEligible(to, routes)) return false
879
+ const saved = await routingSavedState(config)
880
+ const limits = budgetSettings(config)
881
+ const perRunLimit = Number(plan.optimization?.budgetUsd) || 0
882
+ const constrained = limits.dailyLimitUsd > 0 || limits.monthlyLimitUsd > 0 || perRunLimit > 0
883
+ const spent = spending(saved.runs, undefined, saved.archivedSpending)
884
+ if ((limits.dailyLimitUsd > 0 && spent.unknownToday > 0) || (limits.monthlyLimitUsd > 0 && spent.unknownMonth > 0)) return false
885
+ const estimate = createPlanFromRoutes(packageTask, [to], {
886
+ ...planDataOptions(saved, config), mode: 'direct', directProvider: to.provider, directModel: to.model,
887
+ }).estimatedCost
888
+ if (!Number.isFinite(estimate) || estimate < 0) return false
889
+ const costs = [...finished.map(result => !hasStartedQualityAttempts(result)
890
+ && (result.blocked || result.notStarted || billingForResult(result) === 'subscription') ? { costUsd: 0 }
891
+ : actualCost(result, routePricing(routes, result))), ...attempts.map(result => actualCost(result, result.pricingSnapshot))]
892
+ const pending = plan.costBreakdown.slice(finished.length + 1).map(item => item.estimatedCost)
893
+ if (costs.some(item => item.costUsd === null) || pending.some(value => !Number.isFinite(value))) return !constrained
894
+ const projected = costs.reduce((sum, item) => sum + item.costUsd, estimate) + pending.reduce((sum, value) => sum + value, 0)
895
+ if (!Number.isFinite(projected) || perRunLimit > 0 && projected > perRunLimit) return false
896
+ return !budgetFor(config, saved, projected).exceeded
897
+ },
898
+ })
899
+ }
760
900
  let execution
761
901
  try {
762
902
  execution = await executeAssignmentPlan({
@@ -767,7 +907,7 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
767
907
  signal: options.signal,
768
908
  ...hooks,
769
909
  billing,
770
- apiFallback: async ({ route, task: packageTask, signal }) => consultConfiguredModel(ctx, route, packageTask, limit, signal),
910
+ apiFallback: gatedApi,
771
911
  })
772
912
  } catch (error) {
773
913
  // Steps that already ran (and may have been paid for) are always recorded with their spend.
@@ -882,12 +1022,37 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
882
1022
  const cwd = text(workspace) || run.workspace
883
1023
  if (!cwd || !(await fileExists(cwd))) throw new Error(`原运行的工作区 ${cwd || '(未记录)'} 不可用,无法重跑该步骤;请恢复该目录,或在原工作区的会话中调用 model_router_rerun_step。`)
884
1024
  if (run.kind === 'team') return rerunTeamStep(ctx, { run, target, override, routes, budget, cwd, root: text(root) || cwd, signal, options, config })
1025
+ const retryIds = new Set([target.id, ...downstreamPackageIds(run.packages, target.id)
1026
+ .filter(id => run.packages.find(item => item.id === id)?.ok !== true)])
1027
+ const manualRetries = Object.fromEntries(run.packages.filter(item => retryIds.has(item.id) && item.manualAssignment)
1028
+ .map(item => [item.id, item.id === target.id && override ? { kind: 'api', ...override } : item.manualAssignment]))
1029
+ if (Object.keys(manualRetries).length) {
1030
+ for (const [id, assignment] of Object.entries(parsePackageAssignments(manualRetries))) {
1031
+ if (assignment.kind !== 'api') continue
1032
+ const route = routes.find(item => item.provider === assignment.provider && item.model === assignment.model)
1033
+ if (!route) throw new Error('重跑所选的模型已不在官方目录中,请重新分配。')
1034
+ const policy = billingPlan(route, routes)
1035
+ if (policy.mode === 'subscription-only' || policy.subscription?.kind === 'plan-key') throw new Error('该模型使用订阅策略,不能作为手动 API 目标重跑。')
1036
+ const item = run.packages.find(entry => entry.id === id)
1037
+ if (item?.type === 'vision' && Array.isArray(route.inputModalities) && route.inputModalities.length
1038
+ && !route.inputModalities.some(value => String(value).toLowerCase() === 'image')) {
1039
+ throw new Error(`工作包 ${id} 需要图像模型,所选重跑目标仅声明其他输入能力。`)
1040
+ }
1041
+ }
1042
+ await validateManualTools(ctx, manualRetries, { ...options, workspace: cwd })
1043
+ }
885
1044
  const limit = boundedInteger(valueOf(config, 'maxConsultOutputChars', 12_000), 12_000, 500, 50_000)
1045
+ const retryGate = parseQualityGateJson(valueOf(config, 'qualityGateJson', ''))
886
1046
  const execution = await rerunAssignmentPackage({
887
1047
  task: run.task,
888
1048
  packages: run.packages.map(item => ({
889
1049
  id: item.id, name: item.name, objective: item.objective, dependsOn: item.dependsOn,
1050
+ type: item.type, purpose: item.purpose,
1051
+ ...(item.manualAssignment ? { manualAssignment: item.manualAssignment, selectionSource: 'manual',
1052
+ algorithmRecommendation: item.algorithmRecommendation } : {}),
890
1053
  recommendedProvider: item.provider ?? item.recommendedProvider, recommendedModel: item.model ?? item.recommendedModel,
1054
+ ...(item.plannedReasoningEffort && item.provider === item.recommendedProvider && item.model === item.recommendedModel
1055
+ ? { recommendedReasoningEffort: item.plannedReasoningEffort } : {}),
891
1056
  })),
892
1057
  previous: storedResults(run),
893
1058
  packageId: target.id,
@@ -899,7 +1064,9 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
899
1064
  ...(choice ? { subscriptionChoice: choice } : {}),
900
1065
  ...executionHooks(ctx, options),
901
1066
  billing: await billingContext(config, options),
902
- apiFallback: async ({ route, task: packageTask, signal: callSignal }) => consultConfiguredModel(ctx, route, packageTask, limit, callSignal),
1067
+ apiFallback: async ({ route, task: packageTask, signal: callSignal, subscription }) => subscription
1068
+ ? consultConfiguredModel(ctx, route, packageTask, limit, callSignal)
1069
+ : consultWithQualityGate(ctx, route, packageTask, limit, callSignal, { gate: retryGate }),
903
1070
  })
904
1071
  const updated = await routerStateStore().updateRun(run.id, current => {
905
1072
  mergeRerun(current, execution, { rerunIds: execution.ranIds, pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
@@ -935,6 +1102,9 @@ function teamPlanFromRun(run, ids, override, targetId) {
935
1102
  verificationChecklist: item.verificationChecklist ?? [],
936
1103
  recommendedProvider: item.id === targetId && override ? override.provider : item.recommendedProvider,
937
1104
  recommendedModel: item.id === targetId && override ? override.model : item.recommendedModel,
1105
+ ...(item.plannedReasoningEffort && !(item.id === targetId && override
1106
+ && (override.provider !== item.recommendedProvider || override.model !== item.recommendedModel))
1107
+ ? { recommendedReasoningEffort: item.plannedReasoningEffort } : {}),
938
1108
  estimatedCost: item.estimatedCost ?? null,
939
1109
  }))
940
1110
  return { mode: 'team', team: { workPackages } }
@@ -1156,6 +1326,7 @@ export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
1156
1326
  // Legacy scalar quality biases are intentionally no longer learned.
1157
1327
  biases: {},
1158
1328
  learning: saved.routingLearning,
1329
+ routingEvidence: saved.routingEvidence,
1159
1330
  dynamicData: dynamicDataSummary(saved.dynamicData, { ...dynamicOptions(config), now: Date.now() }),
1160
1331
  routingData: {
1161
1332
  ...routingData(saved.dynamicData, dynamicOptions(config).enabled),
@@ -1276,7 +1447,7 @@ export async function planOfficialTeam(ctx, config = {}, args = {}, options = {}
1276
1447
  const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
1277
1448
  const budgetUsd = Math.max(0, finiteNumber(args.budgetUsd, finiteNumber(configuredBudget, 0)))
1278
1449
  const plan = createPlanFromRoutes(taskText, executableRoutes, {
1279
- ...planDataOptions(saved, config), mode: 'team', budgetUsd, installedToolIds: installed,
1450
+ ...planDataOptions(saved, config), mode: 'team', budgetUsd, installedToolIds: installed, forceOfficialCli: true,
1280
1451
  runnableToolIds: installed.filter(id => supported.has(id)), preset: routingPresetOf(config, args.preset),
1281
1452
  })
1282
1453
  const bindingsText = text(args.cliModelsJson)
@@ -1338,6 +1509,10 @@ export function decisionSummary(plan) {
1338
1509
  difficulty: item.difficulty ?? null, estimatedCost: item.estimatedCost ?? null,
1339
1510
  channel: item.executionChannel ?? null, channelDetail: item.channelDetail ?? '',
1340
1511
  dependsOn: item.dependsOn ?? [],
1512
+ ...(item.manualAssignment ? { manualAssignment: item.manualAssignment, selectionSource: 'manual',
1513
+ algorithmRecommendation: item.algorithmRecommendation ?? null,
1514
+ ...(item.manualAssignment.kind === 'tool' ? { toolId: item.manualAssignment.toolId,
1515
+ toolLabel: getOfficialTool(item.manualAssignment.toolId)?.label ?? item.manualAssignment.toolId } : {}) } : {}),
1341
1516
  })),
1342
1517
  }
1343
1518
  }
@@ -1413,7 +1588,8 @@ export function approvalReasonCodes(name, args = {}, config = {}, { rerunRun = n
1413
1588
  if (name === 'model_router_rerun_step' && rerunRun && rerunSupport(rerunRun).writes) codes.push(rerunRun.kind === 'tool' ? 'workspace-write' : 'rerun-write')
1414
1589
  if (name === 'model_router_rerun_step' && args.subscriptionChoice === 'api') codes.push('subscription-api')
1415
1590
  if (args.confirmOverBudget === true) codes.push('over-budget')
1416
- const directRun = name === 'model_router_execute' || (name === 'model_router_rerun_step' && (!rerunRun || rerunSupport(rerunRun).kind === 'assign'))
1591
+ const directRun = name === 'model_router_execute' && args.assignmentMode !== 'manual'
1592
+ || (name === 'model_router_rerun_step' && (!rerunRun || rerunSupport(rerunRun).kind === 'assign') && rerunRun?.assignmentMode !== 'manual')
1417
1593
  if ((name === 'model_router_team_execute' && args.executionEnvironment === 'cli-native')
1418
1594
  || (name === 'model_router_rerun_step' && rerunRun?.executionEnvironment === 'cli-native')) codes.push('unsandboxed')
1419
1595
  if (directRun && valueOf(config, 'confirmUnsandboxedCli', true) !== false && mayLaunchDirectCli(args)) codes.push('unsandboxed')
@@ -1475,7 +1651,12 @@ export async function workbenchWorkspace(requested, runs = []) {
1475
1651
  return chosen
1476
1652
  }
1477
1653
 
1478
- /** Resolve one fresh preview and retain its exact CLI plan for the confirmed run. */
1654
+ function manualRequestOptions(request) {
1655
+ return request.assignmentMode === 'manual' ? { assignmentMode: 'manual',
1656
+ packageAssignments: request.packageAssignments, packagePlanKey: request.packagePlanKey } : {}
1657
+ }
1658
+
1659
+ /** Resolve one fresh preview and retain its exact plan for the confirmed run. */
1479
1660
  async function prepareWorkbenchPreview(ctx, config, supplied = {}, options = {}) {
1480
1661
  const request = parseRunRequest(supplied)
1481
1662
  const saved = await savedState()
@@ -1511,12 +1692,16 @@ async function prepareWorkbenchPreview(ctx, config, supplied = {}, options = {})
1511
1692
  const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
1512
1693
  const planned = await planAssignment(ctx, request.task, config, {
1513
1694
  provider: request.provider, model: request.model, planMode: request.planMode, preset: request.preset,
1695
+ ...manualRequestOptions(request),
1514
1696
  budgetUsd: finiteNumber(request.budgetUsd, finiteNumber(configuredBudget, 0)), workspace, signal: options.signal, ...options.planOptions,
1515
1697
  })
1516
1698
  await hydrateHealth()
1517
- const codes = approvalReasonCodes('model_router_execute', { provider: request.provider, confirmOverBudget: Boolean(planned.budget.exceeded) }, config)
1518
- return { request, preview: {
1699
+ const codes = request.assignmentMode === 'manual'
1700
+ ? planned.budget.exceeded ? ['over-budget'] : []
1701
+ : approvalReasonCodes('model_router_execute', { provider: request.provider, confirmOverBudget: Boolean(planned.budget.exceeded) }, config)
1702
+ return { request, planned, preview: {
1519
1703
  workspace,
1704
+ assignmentMode: request.assignmentMode ?? 'algorithm', packagePlanKey: packagePlanKey(planned.plan, request.task),
1520
1705
  decision: decisionSummary(planned.plan),
1521
1706
  selected: planned.plan.selected ?? null,
1522
1707
  routingBypassed: planned.plan.routingBypassed === true,
@@ -1553,8 +1738,10 @@ export async function startWorkbenchRun(ctx, config, supplied = {}, options = {}
1553
1738
  const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
1554
1739
  const result = await (options.execute ?? executeConfiguredAssignment)(ctx, request.task, config, {
1555
1740
  provider: request.provider, model: request.model, planMode: request.planMode, preset: request.preset,
1741
+ ...manualRequestOptions(request),
1556
1742
  budgetUsd: finiteNumber(request.budgetUsd, finiteNumber(configuredBudget, 0)),
1557
1743
  confirmOverBudget: confirmed.has('over-budget'), workspace: preview.workspace, signal: options.signal, ...options.planOptions,
1744
+ preparedAssignment: prepared.planned,
1558
1745
  })
1559
1746
  const awaiting = result.awaitingConfirmation ?? []
1560
1747
  return {
@@ -1980,12 +2167,15 @@ function registerOfficialToolModels(ctx, config, assertInstallPreferences) {
1980
2167
  }))
1981
2168
  ctx.tools.register(defineTool({
1982
2169
  name: 'model_router_execute',
1983
- description: 'Run a task on configured models and aggregate the results. Omit provider and model to route first. Supply both to use that one model and skip routing. auto/official profiles try the vendor headless CLI (claude -p, codex exec, gemini -p) and fall back to the Harness model API when the CLI is missing or fails. api profiles always use the model API. This call is read-only and does not apply file edits.',
2170
+ description: 'Run a task on configured models and aggregate the results. Omit provider and model to route first. Supply both to use that one model and skip routing. With assignmentMode manual, use the packagePlanKey from model_router_plan and packageAssignmentsJson to pin each package to an exact API route or registered official tool. Manual tool failures do not fall back to API. Automatic profiles retain CLI/API billing policy. This call is read-only and does not apply file edits.',
1984
2171
  parameters: {
1985
2172
  task: { type: 'string', required: true, description: 'Task to execute.' },
1986
2173
  provider: { type: 'string', description: 'Configured provider. Pair with model to bypass routing.' },
1987
2174
  model: { type: 'string', description: 'Configured model. Pair with provider to bypass routing.' },
1988
2175
  planMode: { type: 'string', enum: ['single', 'team'], description: 'Used only when provider and model are omitted.' },
2176
+ assignmentMode: { type: 'string', enum: ['algorithm', 'manual'], description: 'Use algorithm recommendations, or pin every decomposed package to the user-selected API model or official tool.' },
2177
+ packagePlanKey: { type: 'string', description: 'Required for manual mode: the current decomposition key returned by model_router_plan for the same task and mode.' },
2178
+ packageAssignmentsJson: { type: 'string', description: 'Required for manual mode: JSON object keyed by every package id, values {kind:"api",provider,model} or {kind:"tool",toolId}. Tools use their configured default model. No commands or arguments.' },
1989
2179
  budgetUsd: { type: 'number', description: 'Optional local estimate ceiling when routing. Not a vendor billing cap.' },
1990
2180
  preset: { type: 'string', enum: ['economy', 'balanced', 'quality'], description: 'Optional routing preset for this call; defaults to the saved setting.' },
1991
2181
  confirmOverBudget: { type: 'boolean', description: 'Set only after the user agreed to exceed the daily/monthly budget. Triggers an approval prompt.' },
@@ -1994,10 +2184,19 @@ function registerOfficialToolModels(ctx, config, assertInstallPreferences) {
1994
2184
  async execute(args, exec) {
1995
2185
  const { cwd } = await sessionWorkspace(ctx, exec)
1996
2186
  const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
2187
+ let assignments
2188
+ if (args.packageAssignmentsJson !== undefined) {
2189
+ if (typeof args.packageAssignmentsJson !== 'string' || args.packageAssignmentsJson.length > 32_000) throw new Error('工作包分配 JSON 超出限制。')
2190
+ try { assignments = parsePackageAssignments(JSON.parse(args.packageAssignmentsJson)) }
2191
+ catch { throw new Error('工作包分配 JSON 无效;请按包 ID 提供模型 API 或官方工具目标。') }
2192
+ }
1997
2193
  const result = await executeConfiguredAssignment(ctx, args.task, config, {
1998
2194
  provider: args.provider,
1999
2195
  model: args.model,
2000
2196
  planMode: args.planMode,
2197
+ assignmentMode: args.assignmentMode,
2198
+ ...(assignments === undefined ? {} : { packageAssignments: assignments }),
2199
+ ...(args.packagePlanKey === undefined ? {} : { packagePlanKey: args.packagePlanKey }),
2001
2200
  preset: args.preset,
2002
2201
  budgetUsd: finiteNumber(args.budgetUsd, finiteNumber(configuredBudget, 0)),
2003
2202
  confirmOverBudget: args.confirmOverBudget === true,
@@ -2016,6 +2215,7 @@ function registerOfficialToolModels(ctx, config, assertInstallPreferences) {
2016
2215
  notice: result.paused
2017
2216
  ? '已因预算暂停,未启动任何模型。请向用户说明预估费用,用户同意后再以 confirmOverBudget: true 调用。'
2018
2217
  : result.awaitingConfirmation ? SUBSCRIPTION_PAUSE_NOTICE
2218
+ : result.plan.assignmentMode === 'manual' ? '已按手动分配执行只读工作包;API 模型和官方工具目标固定,工具使用厂商默认模型,失败不会自动改派。可用 model_router_rerun_step 按保存的目标重跑。'
2019
2219
  : '官方 CLI 以只读无界面方式运行。可编辑改动仍使用 model_router_tool_run 或 model_router_team_execute。未安装、未登录、失败或配置为 api 的模型走模型目录 API;回退原因与 CLI 原始错误见 fallback。可用 model_router_rerun_step 重跑失败步骤,model_router_rate 记录评价。',
2020
2220
  })
2021
2221
  },