@ljwei-stak/dsh-model-router 0.23.1 → 0.25.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.
@@ -13,6 +13,10 @@ import {
13
13
  } from './shared/subscription-billing.mjs'
14
14
  import { createRouterState } from './shared/router-state.mjs'
15
15
  import { createRunJobs } from './shared/run-jobs.mjs'
16
+ import { createBudgetReservations, reservationSpending } from './shared/budget-reservations.mjs'
17
+ import { beginExecutionLifecycle } from './shared/execution-lifecycle.mjs'
18
+ import { attachPlanAccounting, accountingEstimates, initialFunding, planningFundingPrices } from './shared/execution-accounting.mjs'
19
+ import { subscriptionUsage } from './shared/subscription-usage.mjs'
16
20
  import { applyFeedbackProfile, buildFeedbackProfile } from './shared/adaptive-feedback.mjs'
17
21
  import { buildRoutingEvidence, parseRoutingEvidenceJson } from './shared/routing-evidence.mjs'
18
22
  import { apiReasoningRequest } from './shared/reasoning-execution.mjs'
@@ -21,10 +25,10 @@ import { applyPricingSnapshot, createDynamicDataRefresher, dynamicDataSummary, r
21
25
  import { projectDynamicData } from './shared/dynamic-data-view.mjs'
22
26
  import {
23
27
  budgetCheck, buildRunRecord, formatUsd, buildTeamRunRecord, buildToolRunRecord, billingOf, mergeRerun,
24
- spending, storedResults, actualCost, hasStartedQualityAttempts, teamExecutionResults,
28
+ spending, storedResults, actualCost, hasStartedQualityAttempts, teamExecutionResults, plannedPackages,
25
29
  } from './shared/run-ledger.mjs'
26
30
  import { routeBoundaries } from './shared/security-boundaries.mjs'
27
- import { createPlanFromRoutes } from './shared/harness-plan.mjs'
31
+ import { createPlanFromRoutes, channelForProvider } from './shared/harness-plan.mjs'
28
32
  import { applyManualPackagePlan } from './shared/manual-plan.mjs'
29
33
  import { parsePackageAssignments, packagePlanKey } from './shared/package-assignments.mjs'
30
34
  import { applyModelProfiles, parseModelProfilesJson } from './shared/model-profiles.mjs'
@@ -377,13 +381,111 @@ function budgetSettings(config) {
377
381
  }
378
382
  }
379
383
 
380
- function budgetFor(config, saved, estimateUsd) {
384
+ function budgetFor(config, saved, estimateUsd, { checkEstimate = true, apiExposure = true } = {}) {
381
385
  const settings = budgetSettings(config)
382
- if ((saved.storageAvailable === false || saved.historyVerified === false) && (settings.dailyLimitUsd > 0 || settings.monthlyLimitUsd > 0)) {
386
+ if (apiExposure && (saved.storageAvailable === false || saved.historyVerified === false) && (settings.dailyLimitUsd > 0 || settings.monthlyLimitUsd > 0)) {
383
387
  throw new Error('无法读取费用历史,不能核验日/月预算;请恢复存储连接后再执行。')
384
388
  }
385
- const spent = spending(saved.runs, undefined, saved.archivedSpending)
386
- return { ...budgetCheck({ estimateUsd, spent, ...settings }), spent, ...settings }
389
+ const recorded = spending(saved.runs, undefined, saved.archivedSpending)
390
+ const reserved = reservationSpending(saved)
391
+ const spent = { ...recorded, today: recorded.today + reserved.today, month: recorded.month + reserved.month,
392
+ unknownToday: recorded.unknownToday + reserved.unknownToday, unknownMonth: recorded.unknownMonth + reserved.unknownMonth }
393
+ const check = budgetCheck({ estimateUsd, spent, ...settings })
394
+ if (!apiExposure) { check.exceeded = null; check.message = '订阅按用量单独统计,不占用 API 金额预算。' }
395
+ if (apiExposure && check.limited && (checkEstimate && !check.estimateKnown || settings.dailyLimitUsd > 0 && spent.unknownToday > 0
396
+ || settings.monthlyLimitUsd > 0 && spent.unknownMonth > 0) && !check.exceeded) {
397
+ check.exceeded = 'unknown'
398
+ check.message = '存在未核对费用或缺少单价,无法核验预算;请先核对账单或明确确认继续。'
399
+ }
400
+ return { ...check, spent, reserved, ...settings, apiExposure }
401
+ }
402
+
403
+ function pausedExecutionBudget(lifecycle) {
404
+ if (['history-incomplete', 'active-run', 'reservation-capacity'].includes(lifecycle.reason)) {
405
+ throw new Error(lifecycle.reason === 'active-run' ? '该运行仍在执行或费用待核对,不能重复启动。'
406
+ : lifecycle.reason === 'history-incomplete' ? '费用历史不完整,不能核验执行预算。' : '待核对运行过多,请先核对执行记录和费用。')
407
+ }
408
+ return { ...lifecycle.budget, paused: true, exceeded: lifecycle.budget?.exceeded ?? 'unknown' }
409
+ }
410
+
411
+ function accountingContext(billing = null, routes = [], officialCli = false) {
412
+ return { billing: billing ? { ...billing, routes } : null, officialCli,
413
+ loginBillingFor: id => subscriptionLoginOf(healthCache.loginState(id)), apiKeyFor: id => apiKeyEnvPresent(id) }
414
+ }
415
+
416
+ function packageEstimates(plan, task) {
417
+ if (plan.accounting) return accountingEstimates(plan)
418
+ return plannedPackages(plan, task).map(item => ({ id: item.id, estimatedCostUsd: item.estimatedCost ?? null }))
419
+ }
420
+
421
+ /** Activate API money accounting only at the actual API dispatch boundary. */
422
+ async function reserveChangedApiRoute(lifecycle, expected, route, task, config, confirmOverBudget) {
423
+ const id = lifecycle.activePackageId()
424
+ if (!id) throw new Error('缺少执行检查点,不能启动 API 路线。')
425
+ const previous = lifecycle.results().find(item => item.id === id)
426
+ const held = lifecycle.reservationFor(id)
427
+ const unchanged = expected?.provider === route.provider && expected?.model === route.model
428
+ const saved = await routingSavedState(config)
429
+ const estimate = held?.billing === 'api' && unchanged ? held.estimatedCostUsd
430
+ : createPlanFromRoutes(task, [route], { ...planDataOptions(saved, config),
431
+ mode: 'direct', directProvider: route.provider, directModel: route.model }).estimatedCost
432
+ const reserved = await lifecycle.reserveExtra(id, estimate, { confirmOverBudget: confirmOverBudget === true })
433
+ if (!reserved.accepted) return { ok: false, notStarted: true,
434
+ subscriptionAttempts: previous?.subscriptionAttempts ?? [],
435
+ error: `实际 API 路线预算未确认,已停止调用。${reserved.budget.message}` }
436
+ if (held?.billing === 'subscription' || previous?.billing === 'subscription') {
437
+ await lifecycle.partialResult({ id, provider: route.provider, model: route.model, billing: 'api',
438
+ channel: 'harness-llm', ok: false, notStarted: true, answer: '', usage: null,
439
+ subscriptionAttempts: previous?.subscriptionAttempts ?? [] }, { checkBudget: true })
440
+ }
441
+ return null
442
+ }
443
+
444
+ async function checkpointSubscription(lifecycle, event) {
445
+ await lifecycle.partialResult({ ...event, billing: 'subscription', channel: event.channel ?? 'harness-llm',
446
+ ok: false, answer: '', subscriptionAttempts: event.subscriptionAttempts ?? [event] })
447
+ }
448
+
449
+ /** A changed budget stops before dispatch; storage failures retain their hold. */
450
+ async function stopExecutionLifecycle(lifecycle, error, run = lifecycle.current('interrupted')) {
451
+ if (error?.code === 'ROUTER_BUDGET_CHANGED') {
452
+ run.status = 'paused-budget'
453
+ run.error = error.message
454
+ run.finishedAt = Date.now()
455
+ try { await lifecycle.finish(run); error.runId = run.id; return true }
456
+ catch { await lifecycle.interrupt(run); return false }
457
+ }
458
+ await lifecycle.interrupt(run)
459
+ return false
460
+ }
461
+
462
+ /** The independent-opinion tool uses the same durable API budget boundary. */
463
+ export async function executeBudgetedConsult(ctx, route, task, config = {}, { signal, limit = 12_000, confirmOverBudget = false, routes = [] } = {}) {
464
+ const problem = taskTextProblem(text(task))
465
+ if (problem) throw new Error(problem)
466
+ const catalog = [route, ...routes.filter(item => item.provider !== route.provider || item.model !== route.model)]
467
+ const funding = initialFunding(route, { billing: { routes: catalog }, executionChannel: 'harness-llm' })
468
+ route = catalog.find(item => item.provider === funding.route?.provider && item.model === funding.route?.model) ?? funding.route
469
+ const plan = createPlanFromRoutes(task, [route], { mode: 'direct', directProvider: route.provider, directModel: route.model })
470
+ plan.accounting = { apiExposure: funding.billing === 'api', apiEstimatedCostUsd: funding.billing === 'subscription' ? 0 : plan.estimatedCost,
471
+ packages: [{ id: 'direct', billing: funding.billing, apiEstimatedCostUsd: funding.billing === 'subscription' ? 0 : plan.estimatedCost }] }
472
+ const id = randomUUID(), at = Date.now()
473
+ const record = (status, results) => buildRunRecord({ id, createdAt: at, finishedAt: Date.now(), task, plan,
474
+ execution: { status, packages: results }, workspace: process.cwd(), pricingFor: () => route.pricing ?? null, billingFor: () => funding.billing })
475
+ const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), buildRun: ({ status, results }) => record(status, results),
476
+ packageEstimates: packageEstimates(plan, task), settings: budgetSettings(config), confirmOverBudget })
477
+ if (!lifecycle.accepted) return { status: 'paused-budget', paused: true, budget: pausedExecutionBudget(lifecycle), ok: false }
478
+ let result
479
+ try {
480
+ await lifecycle.onCheckpointStart({ id: 'direct', provider: route.provider, model: route.model, channel: 'harness-llm' })
481
+ result = await consultConfiguredModel(ctx, route, task, limit, signal)
482
+ const receipt = { ...result, id: 'direct', provider: route.provider, model: route.model, channel: 'harness-llm', billing: funding.billing, finishedAt: Date.now() }
483
+ if (funding.billing === 'subscription') receipt.subscriptionAttempts = [{ provider: route.provider, model: route.model,
484
+ usage: result.usage ?? null, usageIncomplete: result.usageIncomplete === true, notStarted: result.notStarted === true, finishedAt: receipt.finishedAt }]
485
+ await lifecycle.onCheckpointResult(receipt)
486
+ await lifecycle.finish(record(result.cancelled ? 'cancelled' : result.ok ? 'completed' : 'failed', lifecycle.results()))
487
+ } catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
488
+ return { ...result, billing: funding.billing, accounting: plan.accounting, runId: id }
387
489
  }
388
490
 
389
491
  /**
@@ -392,6 +494,11 @@ function budgetFor(config, saved, estimateUsd) {
392
494
  */
393
495
  export function billingForResult(result) {
394
496
  const toolId = result?.toolId ?? null
497
+ if (result?.billing === 'unknown') return 'unknown'
498
+ const login = toolId ? subscriptionLoginOf(healthCache.loginState(toolId)) : 'unknown'
499
+ if (result?.channel === 'official-cli' && !result.billing
500
+ && !['configured-api-key', 'process-environment'].includes(result.credentialSource)
501
+ && !apiKeyEnvPresent(toolId) && !['api-key', 'subscription'].includes(login)) return 'unknown'
395
502
  return billingOf(result, {
396
503
  loginBilling: toolId ? healthCache.loginState(toolId)?.billing ?? null : null,
397
504
  apiKeyPresent: toolId ? apiKeyEnvPresent(toolId) : false,
@@ -496,14 +603,19 @@ export async function createRoutePlan(ctx, task, config = {}, options = {}) {
496
603
  const loggedOut = options.skipToolProbe === true
497
604
  ? (Array.isArray(options.loggedOutToolIds) ? options.loggedOutToolIds : [])
498
605
  : loggedOutIds(await currentHealth())
606
+ const planningBilling = await billingContext(config, options)
607
+ const context = accountingContext(planningBilling, availableRoutes)
608
+ const fundingPrices = planningFundingPrices(availableRoutes, context, route => channelForProvider(route.provider, installed, [...executable], route, loggedOut).kind)
499
609
  const plan = createPlanFromRoutes(taskText, availableRoutes, {
500
- ...planDataOptions(saved, config),
610
+ ...planDataOptions(saved, config), pricing: fundingPrices.pricing,
501
611
  mode, budgetUsd, installedToolIds: installed,
502
612
  runnableToolIds: installed.filter(id => executable.has(id)),
503
613
  preset: routingPresetOf(config, options.preset),
504
614
  loggedOutToolIds: loggedOut,
505
615
  })
506
- return { ...plan, packagePlanKey: packagePlanKey(plan, taskText), budgetStatus: budgetFor(config, saved, plan.estimatedCost) }
616
+ plan.subscriptionPricedRoutes = fundingPrices.subscriptionPricedRoutes
617
+ attachPlanAccounting(plan, taskText, availableRoutes, context)
618
+ return { ...plan, packagePlanKey: packagePlanKey(plan, taskText), budgetStatus: budgetFor(config, saved, plan.accounting.apiEstimatedCostUsd, { apiExposure: plan.accounting.apiExposure }) }
507
619
  }
508
620
 
509
621
  /** One bounded, independent call through the same official LLM service. */
@@ -674,7 +786,7 @@ function reviewScore(answer) {
674
786
  * 质量回路: a stronger configured model spot-checks answers from cheaper
675
787
  * routes. `sample` reviews a random share; `always` reviews every cheaper answer.
676
788
  */
677
- export async function reviewRunPackages(ctx, run, routes, config, { signal, random = Math.random } = {}) {
789
+ export async function reviewRunPackages(ctx, run, routes, config, { signal, random = Math.random, lifecycle } = {}) {
678
790
  const mode = valueOf(config, 'reviewMode', 'off')
679
791
  if (mode !== 'sample' && mode !== 'always') return []
680
792
  const rate = Math.min(1, Math.max(0, finiteNumber(valueOf(config, 'reviewSampleRate', 0.2), 0.2)))
@@ -696,6 +808,24 @@ export async function reviewRunPackages(ctx, run, routes, config, { signal, rand
696
808
  `工作包:${item.name}\n${item.objective.slice(0, 1_500)}`,
697
809
  `待审结果:\n${item.answer.slice(0, 3_500)}`,
698
810
  ].join('\n\n')
811
+ const reviewId = `review:${item.id}`
812
+ let pendingReview = null
813
+ if (lifecycle) {
814
+ const estimate = createPlanFromRoutes(prompt, [reviewer], { mode: 'direct', directProvider: reviewer.provider,
815
+ directModel: reviewer.model }).estimatedCost
816
+ const reserved = await lifecycle.reserveExtra(reviewId, estimate)
817
+ if (!reserved.accepted) { item.review = { score: null, summary: '预算未确认,已跳过额外审阅。', at: Date.now() }; continue }
818
+ pendingReview = { id: reviewId, packageId: item.id, provider: reviewer.provider, model: reviewer.model,
819
+ status: 'running', inFlight: true, ran: false, costUsd: null, billing: 'api', finishedAt: Date.now() }
820
+ run.reviews.push(pendingReview)
821
+ try { await lifecycle.saveRun(run, { startedIds: [reviewId] }) }
822
+ catch (error) {
823
+ if (error.code !== 'ROUTER_BUDGET_CHANGED') throw error
824
+ run.reviews = run.reviews.filter(entry => entry !== pendingReview)
825
+ item.review = { score: null, summary: '预算已变化,已跳过额外审阅。', at: Date.now() }
826
+ break
827
+ }
828
+ }
699
829
  try {
700
830
  const result = await consultConfiguredModel(ctx, reviewer, prompt, 1_200, signal)
701
831
  const score = result.ok ? reviewScore(result.answer) : null
@@ -707,9 +837,16 @@ export async function reviewRunPackages(ctx, run, routes, config, { signal, rand
707
837
  }
708
838
  const cost = actualCost(result, reviewer.pricing ?? null)
709
839
  const entry = { packageId: item.id, provider: reviewer.provider, model: reviewer.model, ran: true, finishedAt: Date.now(), usage: result.usage ?? null, ...cost, billing: 'api', referenceCostUsd: null }
710
- run.reviews.push(entry)
840
+ if (pendingReview) Object.assign(pendingReview, entry, { status: 'completed', inFlight: false })
841
+ else run.reviews.push(entry)
711
842
  reviews.push({ ...entry, score })
843
+ if (lifecycle) await lifecycle.saveRun(run, { completedIds: [reviewId] })
712
844
  } catch (error) {
845
+ if (error.code === 'ROUTER_CHECKPOINT_FAILED') throw error
846
+ if (pendingReview) {
847
+ Object.assign(pendingReview, { status: 'failed', inFlight: false, ran: true, costUsd: null, finishedAt: Date.now() })
848
+ await lifecycle.saveRun(run, { completedIds: [reviewId] })
849
+ }
713
850
  if (signal?.aborted) break
714
851
  item.review = { provider: reviewer.provider, model: reviewer.model, score: null, summary: `审阅失败:${errorText(error).slice(0, 200)}`, at: Date.now() }
715
852
  }
@@ -724,6 +861,9 @@ async function reportRunProgress(options, event) {
724
861
 
725
862
  function executionHooks(ctx, options) {
726
863
  return {
864
+ onCheckpointStart: options.onCheckpointStart,
865
+ onCheckpointResult: options.onCheckpointResult,
866
+ onSubscriptionResult: options.onSubscriptionResult,
727
867
  skipOfficial: options.skipOfficial ?? (request => healthCache.skipReason(request)),
728
868
  onStage: stage => reportRunProgress(options, { type: 'stage', stage }),
729
869
  onPackageStart: async item => {
@@ -785,32 +925,38 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
785
925
  ? (Array.isArray(options.loggedOutToolIds) ? options.loggedOutToolIds : [])
786
926
  : loggedOutIds(await currentHealth())
787
927
  const preset = routingPresetOf(config, options.preset)
788
- const planWith = (presetId, budgetUsd) => direct
928
+ const planningBilling = await billingContext(config, options)
929
+ const fundingPrices = planningFundingPrices(routes, accountingContext(planningBilling, routes),
930
+ route => channelForProvider(route.provider, installed, runnableToolIds, route, loggedOut).kind)
931
+ const buildWith = (presetId, budgetUsd) => direct
789
932
  ? createPlanFromRoutes(taskText, routes, {
790
- ...planDataOptions(saved, config),
933
+ ...planDataOptions(saved, config), pricing: fundingPrices.pricing,
791
934
  mode: 'direct', directProvider: options.provider, directModel: options.model,
792
935
  installedToolIds: installed, runnableToolIds, preset: presetId, loggedOutToolIds: loggedOut,
793
936
  })
794
937
  : createPlanFromRoutes(taskText, routes, {
795
- ...planDataOptions(saved, config),
938
+ ...planDataOptions(saved, config), pricing: fundingPrices.pricing,
796
939
  mode: options.planMode === 'team' ? 'team' : 'single',
797
940
  budgetUsd, installedToolIds: installed, runnableToolIds, preset: presetId, loggedOutToolIds: loggedOut,
798
941
  })
942
+ const planWith = (...args) => { const result = buildWith(...args); result.subscriptionPricedRoutes = fundingPrices.subscriptionPricedRoutes; return result }
799
943
  let plan = planWith(preset, options.budgetUsd)
800
944
  if (manual) {
801
945
  plan = applyManualPackagePlan(plan, { task: taskText, packageAssignments: options.packageAssignments,
802
946
  packagePlanKey: options.packagePlanKey }, routes, planDataOptions(saved, config))
803
947
  await validateManualTools(ctx, plan.manualAssignments, { ...options, installedToolIds: installed, readiness })
804
948
  }
805
- let budget = budgetFor(config, saved, plan.estimatedCost)
949
+ attachPlanAccounting(plan, taskText, routes, accountingContext(planningBilling, routes))
950
+ let budget = budgetFor(config, saved, plan.accounting.apiEstimatedCostUsd, { apiExposure: plan.accounting.apiExposure })
806
951
  const perRunLimit = Number(options.budgetUsd) || 0
807
- if (manual && perRunLimit > 0 && (plan.estimatedCost === null || plan.estimatedCost > perRunLimit)) {
808
- budget = { ...budget, exceeded: budget.exceeded || 'run', estimateUsd: plan.estimatedCost,
809
- message: plan.estimatedCost === null ? '手动分配包含费用未知的目标,无法确认满足本次预算。' : '手动分配的预估费用超出本次预算。' }
952
+ if (manual && perRunLimit > 0 && (plan.accounting.apiEstimatedCostUsd === null || plan.accounting.apiEstimatedCostUsd > perRunLimit)) {
953
+ budget = { ...budget, exceeded: budget.exceeded || 'run', estimateUsd: plan.accounting.apiEstimatedCostUsd,
954
+ message: plan.accounting.apiEstimatedCostUsd === null ? '手动分配包含费用未知的目标,无法确认满足本次预算。' : '手动分配的预估费用超出本次预算。' }
810
955
  }
811
956
  if (budget.exceeded && budget.action === 'downgrade' && !direct && !manual) {
812
957
  const cheaper = planWith('economy', budget.remainingUsd > 0 ? budget.remainingUsd : options.budgetUsd)
813
- const recheck = budgetFor(config, saved, cheaper.estimatedCost)
958
+ attachPlanAccounting(cheaper, taskText, routes, accountingContext(planningBilling, routes))
959
+ const recheck = budgetFor(config, saved, cheaper.accounting.apiEstimatedCostUsd, { apiExposure: cheaper.accounting.apiExposure })
814
960
  if (!recheck.exceeded) {
815
961
  budget = { ...recheck, downgraded: true, downgradedFrom: preset,
816
962
  message: `原方案${budget.message} 已自动降级为“省钱优先”方案(预估 ${cheaper.estimatedCost === null ? '价格待配置' : formatUsd(cheaper.estimatedCost)})。` }
@@ -861,7 +1007,11 @@ function apiUpgradeEligible(route, routes) {
861
1007
 
862
1008
  /** Route, or honor one explicit model, then run each package through its adapter. */
863
1009
  export async function executeConfiguredAssignment(ctx, task, config = {}, options = {}) {
864
- const { taskText, direct, plan, budget, routes, preset } = options.preparedAssignment ?? await planAssignment(ctx, task, config, options)
1010
+ const { taskText, direct, plan, budget: previewBudget, routes, preset } = options.preparedAssignment ?? await planAssignment(ctx, task, config, options)
1011
+ const billing = await billingContext(config, options)
1012
+ attachPlanAccounting(plan, taskText, routes, accountingContext(billing, routes))
1013
+ const budget = { ...previewBudget, ...budgetFor(config, await savedState(), plan.accounting.apiEstimatedCostUsd, { apiExposure: plan.accounting.apiExposure }) }
1014
+ if (plan.accounting.apiExposure && previewBudget.exceeded === 'run') Object.assign(budget, { exceeded: 'run', message: previewBudget.message })
865
1015
  if (budget.exceeded && options.confirmOverBudget !== true) {
866
1016
  return {
867
1017
  plan, execution: null, paused: true,
@@ -874,12 +1024,26 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
874
1024
  finished.push(result)
875
1025
  if (typeof options.onPackage === 'function') await options.onPackage(result)
876
1026
  } })
877
- const billing = await billingContext(config, options)
878
1027
  const limit = boundedInteger(valueOf(config, 'maxConsultOutputChars', 12_000), 12_000, 500, 50_000)
879
1028
  const startedAt = Date.now()
1029
+ const runId = randomUUID()
1030
+ const makeRecord = execution => buildRunRecord({ id: runId, createdAt: startedAt, finishedAt: Date.now(),
1031
+ task: taskText, plan, execution, preset: plan.preset ?? preset, workspace: options.workspace ?? '',
1032
+ pricingFor: item => routePricing(routes, item), billingFor: billingForResult,
1033
+ budget: { estimateUsd: budget.estimateUsd, exceeded: budget.exceeded, downgraded: budget.downgraded === true,
1034
+ confirmed: options.confirmOverBudget === true } })
1035
+ const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(),
1036
+ packageEstimates: packageEstimates(plan, taskText), settings: budgetSettings(config),
1037
+ confirmOverBudget: options.confirmOverBudget === true,
1038
+ buildRun: ({ status, results }) => makeRecord({ status, packages: results }) })
1039
+ if (!lifecycle.accepted) return { plan, execution: null, paused: true, budget: pausedExecutionBudget(lifecycle) }
880
1040
  const gate = parseQualityGateJson(valueOf(config, 'qualityGateJson', ''))
881
1041
  const gatedApi = async ({ route, task: packageTask, signal, subscription }) => {
882
1042
  if (subscription) return consultConfiguredModel(ctx, route, packageTask, limit, signal)
1043
+ const plannedPackage = plannedPackages(plan, taskText).find(item => item.id === lifecycle.activePackageId())
1044
+ const denied = await reserveChangedApiRoute(lifecycle, { provider: plannedPackage?.recommendedProvider,
1045
+ model: plannedPackage?.recommendedModel }, route, packageTask, config, options.confirmOverBudget)
1046
+ if (denied) return denied
883
1047
  const quality = routeQuality(route)
884
1048
  const alternatives = gate && !direct && route.manualPinned !== true && apiUpgradeEligible(route, routes)
885
1049
  && valueOf(config, 'qualityEscalationEnabled', false) === true && quality !== null
@@ -906,10 +1070,23 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
906
1070
  && (result.blocked || result.notStarted || billingForResult(result) === 'subscription') ? { costUsd: 0 }
907
1071
  : actualCost(result, routePricing(routes, result))), ...attempts.map(result => actualCost(result, result.pricingSnapshot))]
908
1072
  const pending = plan.costBreakdown.slice(finished.length + 1).map(item => item.estimatedCost)
909
- if (costs.some(item => item.costUsd === null) || pending.some(value => !Number.isFinite(value))) return !constrained
910
- const projected = costs.reduce((sum, item) => sum + item.costUsd, estimate) + pending.reduce((sum, value) => sum + value, 0)
911
- if (!Number.isFinite(projected) || perRunLimit > 0 && projected > perRunLimit) return false
912
- return !budgetFor(config, saved, projected).exceeded
1073
+ const unknownProjection = costs.some(item => item.costUsd === null) || pending.some(value => !Number.isFinite(value))
1074
+ if (unknownProjection && constrained) return false
1075
+ const projected = unknownProjection ? null
1076
+ : costs.reduce((sum, item) => sum + item.costUsd, estimate) + pending.reduce((sum, value) => sum + value, 0)
1077
+ if (!unknownProjection && (!Number.isFinite(projected) || perRunLimit > 0 && projected > perRunLimit)) return false
1078
+ const id = lifecycle.activePackageId()
1079
+ if (!id) return false
1080
+ const subscriptionAttempts = lifecycle.results().find(item => item.id === id)?.subscriptionAttempts ?? []
1081
+ await lifecycle.partialResult({ ...attempts.at(-1), id, ok: false, subscriptionAttempts, qualityAttempts: attempts })
1082
+ if (!(await lifecycle.reserveExtra(id, estimate)).accepted) return false
1083
+ // The extra request can be charged before its receipt arrives. Persist
1084
+ // both the first receipt and the unknown second attempt before dispatch.
1085
+ await lifecycle.partialResult({ ...attempts.at(-1), id, ok: false, subscriptionAttempts, qualityAttempts: [...attempts, {
1086
+ provider: to.provider, model: to.model, channel: 'harness-llm', ok: false,
1087
+ usage: null, usageIncomplete: true, pricingSnapshot: to.pricing ?? null, finishedAt: Date.now(),
1088
+ }] }, { checkBudget: true })
1089
+ return true
913
1090
  },
914
1091
  })
915
1092
  }
@@ -922,40 +1099,50 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
922
1099
  workspace: options.workspace,
923
1100
  signal: options.signal,
924
1101
  ...hooks,
925
- billing,
1102
+ onCheckpointStart: item => lifecycle.onCheckpointStart(item),
1103
+ onCheckpointResult: result => lifecycle.onCheckpointResult(result),
1104
+ onSubscriptionResult: event => checkpointSubscription(lifecycle, event),
1105
+ billing: billing ? { ...billing, routes } : null,
926
1106
  apiFallback: gatedApi,
927
1107
  })
928
1108
  } catch (error) {
929
1109
  // Steps that already ran (and may have been paid for) are always recorded with their spend.
930
1110
  const message = String(error?.message ?? error)
931
- const partial = { status: options.signal?.aborted ? 'cancelled' : 'failed', packages: finished, aggregate: '', error: message }
1111
+ const receipts = new Map([...finished, ...lifecycle.results(), ...(error.execution?.packages ?? [])].map(item => [item.id, item]))
1112
+ const partial = { status: options.signal?.aborted ? 'cancelled' : 'failed', packages: [...receipts.values()], aggregate: '', error: message }
932
1113
  const run = buildRunRecord({
933
- id: randomUUID(), createdAt: startedAt, finishedAt: Date.now(), task: taskText, plan, execution: partial,
1114
+ id: runId, createdAt: startedAt, finishedAt: Date.now(), task: taskText, plan, execution: partial,
934
1115
  preset: plan.preset ?? preset, workspace: options.workspace ?? '',
935
1116
  pricingFor: item => routePricing(routes, item), billingFor: billingForResult,
936
1117
  budget: { estimateUsd: budget.estimateUsd, exceeded: budget.exceeded, downgraded: budget.downgraded === true, confirmed: budget.exceeded ? true : undefined },
937
1118
  })
938
1119
  run.error = message.slice(0, 2_000)
939
1120
  let stored = true
940
- try { await routerStateStore().appendRun(run) } catch { stored = false }
1121
+ try {
1122
+ if (error.code === 'ROUTER_BUDGET_CHANGED') stored = await stopExecutionLifecycle(lifecycle, error, run)
1123
+ else if (error.code === 'ROUTER_CHECKPOINT_FAILED') { await lifecycle.interrupt(run); stored = false }
1124
+ else await lifecycle.finish(run)
1125
+ } catch { stored = false; await lifecycle.interrupt(run) }
941
1126
  const wrapped = new Error(`${message}${finished.length ? `(已完成 ${finished.length} 个步骤,${stored ? `已记录到运行 ${run.id},费用计入预算` : '但运行记录保存失败'})` : ''}`)
942
1127
  wrapped.runId = stored ? run.id : null
943
1128
  wrapped.cause = error
944
1129
  throw wrapped
945
1130
  }
946
1131
  const run = buildRunRecord({
947
- id: randomUUID(), createdAt: startedAt, finishedAt: Date.now(), task: taskText, plan, execution,
1132
+ id: runId, createdAt: startedAt, finishedAt: Date.now(), task: taskText, plan, execution,
948
1133
  preset: plan.preset ?? preset, workspace: options.workspace ?? '',
949
1134
  pricingFor: item => routePricing(routes, item),
950
1135
  billingFor: billingForResult,
951
1136
  budget: { estimateUsd: budget.estimateUsd, exceeded: budget.exceeded, downgraded: budget.downgraded === true, confirmed: budget.exceeded ? true : undefined },
952
1137
  })
953
1138
  await reportRunProgress(options, { type: 'stage', stage: 'reviewing' })
954
- const reviews = await reviewRunPackages(ctx, run, routes, config, { signal: options.signal, random: options.random })
1139
+ let reviews
1140
+ try { reviews = await reviewRunPackages(ctx, run, routes, config, { signal: options.signal, random: options.random, lifecycle }) }
1141
+ catch (error) { await lifecycle.interrupt(run); throw error }
955
1142
  if (options.signal?.aborted) { run.status = 'cancelled'; execution.status = 'cancelled' }
956
1143
  await reportRunProgress(options, { type: 'stage', stage: 'saving' })
957
1144
  let stored = true
958
- try { await routerStateStore().appendRun(run) } catch { stored = false }
1145
+ try { await lifecycle.finish(run) } catch { stored = false; await lifecycle.interrupt(run) }
959
1146
  const awaiting = pausedSteps(execution)
960
1147
  return { plan, execution, budget, runId: run.id, run, reviews, stored, ...(awaiting.length ? { awaitingConfirmation: awaiting } : {}) }
961
1148
  }
@@ -1022,6 +1209,7 @@ function rerunEstimates(run, ids, targetId, override, routes, dataOptions) {
1022
1209
 
1023
1210
  export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, provider, model, confirmOverBudget = false, confirmWrite = false, confirmUnsandboxed = false, subscriptionChoice = null, workspace, root, signal, ...options } = {}) {
1024
1211
  const { run, saved } = await storedRun(runId)
1212
+ if (saved.budgetReservations?.[run.id]) throw new Error('该运行仍在执行或费用待核对,请先核对原任务与厂商账单,不能直接重跑。')
1025
1213
  const target = run.packages.find(item => item.id === text(packageId))
1026
1214
  if (!target) throw new Error(`运行记录中没有工作包 ${text(packageId)}`)
1027
1215
  const choice = text(subscriptionChoice) || null
@@ -1061,14 +1249,24 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
1061
1249
  const retryIds = new Set([target.id, ...downstreamPackageIds(run.packages, target.id)
1062
1250
  .filter(id => run.packages.find(item => item.id === id)?.ok !== true)])
1063
1251
  const estimates = rerunEstimates(run, retryIds, target.id, override, routes, planDataOptions(refreshed, config))
1064
- const budget = budgetFor(config, saved, estimates.total)
1252
+ const retryBilling = await billingContext(config, options)
1253
+ const retryPlan = teamPlanFromRun(run, [...retryIds], override, target.id, estimates.byId)
1254
+ if (run.kind !== 'team') for (const item of retryPlan.team.workPackages) {
1255
+ const previous = run.packages.find(entry => entry.id === item.id)
1256
+ if (!override || item.id !== target.id) { item.recommendedProvider = previous.provider ?? previous.recommendedProvider; item.recommendedModel = previous.model ?? previous.recommendedModel }
1257
+ }
1258
+ const retryContext = accountingContext(retryBilling, routes, run.kind === 'team')
1259
+ retryContext.billingChoices = choice ? { [target.id]: choice } : {}
1260
+ attachPlanAccounting(retryPlan, run.task, routes, retryContext)
1261
+ estimates.reservations = accountingEstimates(retryPlan)
1262
+ const budget = budgetFor(config, saved, retryPlan.accounting.apiEstimatedCostUsd, { apiExposure: retryPlan.accounting.apiExposure })
1065
1263
  if (budget.exceeded && confirmOverBudget !== true) {
1066
1264
  return { paused: true, budget: { ...budget, paused: true, message: `${budget.message} 已暂停重跑,请确认后再试。` }, run }
1067
1265
  }
1068
1266
  const cwd = text(workspace) || run.workspace
1069
1267
  if (!cwd || !(await fileExists(cwd))) throw new Error(`原运行的工作区 ${cwd || '(未记录)'} 不可用,无法重跑该步骤;请恢复该目录,或在原工作区的会话中调用 model_router_rerun_step。`)
1070
1268
  if (run.kind === 'team') return rerunTeamStep(ctx, { run, target, override, routes, budget,
1071
- ids: [...retryIds], estimates: estimates.byId, cwd, root: text(root) || cwd, signal, options, config })
1269
+ ids: [...retryIds], estimates: estimates.byId, reservations: estimates.reservations, confirmOverBudget, cwd, root: text(root) || cwd, signal, options, config })
1072
1270
  const manualRetries = Object.fromEntries(run.packages.filter(item => retryIds.has(item.id) && item.manualAssignment)
1073
1271
  .map(item => [item.id, item.id === target.id && override ? { kind: 'api', ...override } : item.manualAssignment]))
1074
1272
  if (Object.keys(manualRetries).length) {
@@ -1088,7 +1286,18 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
1088
1286
  }
1089
1287
  const limit = boundedInteger(valueOf(config, 'maxConsultOutputChars', 12_000), 12_000, 500, 50_000)
1090
1288
  const retryGate = parseQualityGateJson(valueOf(config, 'qualityGateJson', ''))
1091
- const execution = await rerunAssignmentPackage({
1289
+ const buildRetry = ({ status, results }) => {
1290
+ const next = structuredClone(run)
1291
+ mergeRerun(next, { status, packages: results }, { rerunIds: [...retryIds], pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
1292
+ for (const item of next.packages) if (retryIds.has(item.id)) item.estimatedCost = estimates.byId.get(item.id) ?? null
1293
+ return next
1294
+ }
1295
+ const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), buildRun: buildRetry,
1296
+ packageEstimates: estimates.reservations,
1297
+ settings: budgetSettings(config), confirmOverBudget })
1298
+ if (!lifecycle.accepted) return { paused: true, run, budget: pausedExecutionBudget(lifecycle) }
1299
+ let execution
1300
+ try { execution = await rerunAssignmentPackage({
1092
1301
  task: run.task,
1093
1302
  packages: run.packages.map(item => ({
1094
1303
  id: item.id, name: item.name, objective: item.objective, dependsOn: item.dependsOn,
@@ -1108,17 +1317,22 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
1108
1317
  signal,
1109
1318
  ...(choice ? { subscriptionChoice: choice } : {}),
1110
1319
  ...executionHooks(ctx, options),
1111
- billing: await billingContext(config, options),
1112
- apiFallback: async ({ route, task: packageTask, signal: callSignal, subscription }) => subscription
1113
- ? consultConfiguredModel(ctx, route, packageTask, limit, callSignal)
1114
- : consultWithQualityGate(ctx, route, packageTask, limit, callSignal, { gate: retryGate }),
1115
- })
1116
- const updated = await routerStateStore().updateRun(run.id, current => {
1117
- mergeRerun(current, execution, { rerunIds: execution.ranIds, pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
1118
- for (const item of current.packages) {
1119
- if (execution.ranIds.includes(item.id)) item.estimatedCost = estimates.byId.get(item.id) ?? null
1120
- }
1121
- })
1320
+ onCheckpointStart: item => lifecycle.onCheckpointStart(item),
1321
+ onCheckpointResult: result => lifecycle.onCheckpointResult(result),
1322
+ onSubscriptionResult: event => checkpointSubscription(lifecycle, event),
1323
+ billing: retryBilling,
1324
+ apiFallback: async ({ route, task: packageTask, signal: callSignal, subscription }) => {
1325
+ if (subscription) return consultConfiguredModel(ctx, route, packageTask, limit, callSignal)
1326
+ const id = lifecycle.activePackageId()
1327
+ const previous = run.packages.find(item => item.id === id)
1328
+ const expected = override && id === target.id ? override
1329
+ : { provider: previous?.provider ?? previous?.recommendedProvider, model: previous?.model ?? previous?.recommendedModel }
1330
+ const denied = await reserveChangedApiRoute(lifecycle, expected, route, packageTask, config, confirmOverBudget)
1331
+ return denied ?? consultWithQualityGate(ctx, route, packageTask, limit, callSignal, { gate: retryGate })
1332
+ },
1333
+ }) } catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
1334
+ const updated = buildRetry({ status: execution.status, results: execution.packages })
1335
+ try { await lifecycle.finish(updated) } catch (error) { await lifecycle.interrupt(updated); throw error }
1122
1336
  const awaiting = pausedSteps(execution)
1123
1337
  return { run: updated, execution, budget, ...(awaiting.length ? { awaitingConfirmation: awaiting } : {}) }
1124
1338
  }
@@ -1162,7 +1376,7 @@ function teamPlanFromRun(run, ids, override, targetId, estimates) {
1162
1376
  * Read-only team retry: the failed step plus downstream steps that had not
1163
1377
  * succeeded run through the same signed runner; earlier answers are context.
1164
1378
  */
1165
- async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids, estimates, cwd, root, signal, options, config = {} }) {
1379
+ async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids, estimates, reservations, confirmOverBudget, cwd, root, signal, options, config = {} }) {
1166
1380
  const mode = run.executionMode === 'workspace-write' ? 'workspace-write' : 'read-only'
1167
1381
  const executionEnvironment = teamExecutionEnvironment(run.executionEnvironment, mode)
1168
1382
  const plan = teamPlanFromRun(run, ids, override, target.id, estimates)
@@ -1175,10 +1389,31 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids,
1175
1389
  .map(item => ({ id: item.id, name: item.name, finalText: item.answer }))
1176
1390
  const runTeam = options.runTeam ?? runOfficialTeam
1177
1391
  const installed = Array.isArray(options.installedToolIds) ? options.installedToolIds : await installedToolIds()
1178
- const execution = await runTeam({ plan, task: run.task, workspace: cwd, allowedRoot: root ?? cwd, mode, executionEnvironment,
1392
+ const buildRetry = ({ status, results, workspace = {} }) => {
1393
+ const next = structuredClone(run)
1394
+ const converted = teamExecutionResults(plan, { status, results })
1395
+ if (override) {
1396
+ const item = converted.packages.find(item => item.id === target.id)
1397
+ if (item) item.reassigned = true
1398
+ }
1399
+ mergeRerun(next, converted, { rerunIds: ids, pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
1400
+ for (const item of next.packages) if (ids.includes(item.id)) item.estimatedCost = estimates.get(item.id) ?? null
1401
+ next.executionEnvironment = executionEnvironment
1402
+ if (mode === 'workspace-write' && workspace.workspace) next.isolatedWorkspace = workspace.workspace
1403
+ if (workspace.baseCommit) next.baseCommit = workspace.baseCommit
1404
+ return next
1405
+ }
1406
+ const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), buildRun: buildRetry,
1407
+ packageEstimates: reservations, settings: budgetSettings(config), confirmOverBudget })
1408
+ if (!lifecycle.accepted) return { paused: true, run, budget: pausedExecutionBudget(lifecycle) }
1409
+ let execution
1410
+ try { execution = await runTeam({ plan, task: run.task, workspace: cwd, allowedRoot: root ?? cwd, mode, executionEnvironment,
1179
1411
  installedIds: installed, cliModels, signal, sandbox: ctx.sandbox, previous, onlyIds: ids,
1412
+ onCheckpointStart: item => lifecycle.onCheckpointStart(item), onCheckpointResult: result => lifecycle.onCheckpointResult(result),
1413
+ onWorkspace: value => lifecycle.onWorkspace(value),
1180
1414
  ...(mode === 'workspace-write' ? { seedFrom: { workspace: run.isolatedWorkspace, baseCommit: run.baseCommit } } : {}),
1181
- ...(options.runtime ? { runtime: options.runtime } : {}) })
1415
+ ...(options.runtime ? { runtime: options.runtime } : {}) }) }
1416
+ catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
1182
1417
  const converted = teamExecutionResults(plan, execution)
1183
1418
  if (override) {
1184
1419
  const entry = converted.packages.find(item => item.id === target.id)
@@ -1190,8 +1425,9 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids,
1190
1425
  const note = await noteCliQuota(config, result.toolId, `${result.error ?? ''}\n${result.outputTail ?? ''}`)
1191
1426
  if (note) quotaNotes.push([result.id, note])
1192
1427
  }
1193
- const updated = await routerStateStore().updateRun(run.id, current => {
1194
- mergeRerun(current, converted, { rerunIds: ids, pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
1428
+ const updated = buildRetry({ status: execution.status, results: execution.results,
1429
+ workspace: { workspace: execution.workspace, baseCommit: execution.baseCommit } })
1430
+ const current = updated
1195
1431
  for (const item of current.packages) {
1196
1432
  if (ids.includes(item.id)) item.estimatedCost = estimates.get(item.id) ?? null
1197
1433
  }
@@ -1214,7 +1450,7 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids,
1214
1450
  if (cliModels[target.id]) bindings[target.id] = cliModels[target.id]
1215
1451
  current.cliModels = bindings
1216
1452
  }
1217
- })
1453
+ try { await lifecycle.finish(updated) } catch (error) { await lifecycle.interrupt(updated); throw error }
1218
1454
  return { run: updated, execution: { ...converted, ranIds: ids, raw: execution }, budget }
1219
1455
  }
1220
1456
 
@@ -1240,6 +1476,7 @@ async function rerunToolRun(ctx, config, { run, confirmOverBudget, confirmWrite,
1240
1476
  const cwd = text(workspace) || run.workspace
1241
1477
  if (!cwd || !(await fileExists(cwd))) throw new Error('原运行的工作区不可用,无法重跑;请在原工作区的会话中重试。')
1242
1478
  const { result, recorded } = await runPlannedTool(ctx, config, planned, { cwd, root: text(root) || cwd, signal, rerunOf: run.id,
1479
+ confirmOverBudget,
1243
1480
  ...(options.runTask ? { runTask: options.runTask } : {}) })
1244
1481
  const execution = { status: result.status, packages: recorded?.packages ?? [], ranIds: ['direct'], raw: result }
1245
1482
  return { run: recorded ?? run, execution, budget: planned.budget, rerunOf: run.id, newRunId: recorded?.id ?? null }
@@ -1302,22 +1539,51 @@ export async function planToolRun(ctx, config, args = {}, { signal } = {}) {
1302
1539
  modelId = text(args.cliModel)
1303
1540
  }
1304
1541
  const estimate = route ? createPlanFromRoutes(taskText, routes, { mode: 'direct', directProvider: route.provider, directModel: route.model }).estimatedCost : null
1305
- const budget = budgetFor(config, await savedState(), Number.isFinite(estimate) ? estimate : null)
1306
- return { toolId, tool, taskText, mode, modelId, route, routes, estimate: Number.isFinite(estimate) ? estimate : null, budget,
1542
+ const funding = initialFunding(route ?? {}, { ...accountingContext(null, routes, true), toolId })
1543
+ const apiEstimate = funding.billing === 'subscription' ? 0 : estimate
1544
+ const budget = budgetFor(config, await savedState(), apiEstimate, { apiExposure: funding.billing === 'api' })
1545
+ const resources = route ? estimateAssignedPackage(route, { id: 'direct', objective: taskText, type: 'general' }, taskText) : null
1546
+ const accounting = { apiEstimatedCostUsd: apiEstimate, apiExposure: funding.billing === 'api', packages: [{ id: 'direct', billing: funding.billing,
1547
+ apiEstimatedCostUsd: apiEstimate, estimatedUsage: { inputTokens: resources?.inputTokens ?? null, outputTokens: resources?.outputTokens ?? null } }] }
1548
+ return { toolId, tool, taskText, mode, modelId, route, routes, accounting, funding: funding.billing, apiEstimate, estimate: Number.isFinite(estimate) ? estimate : null, budget,
1307
1549
  provider: route?.provider ?? '', model: route?.model ?? '', cliModel: modelId }
1308
1550
  }
1309
1551
 
1310
1552
  /** Run a planned tool call and record it; `rerunOf` links a rerun to the original run. */
1311
- async function runPlannedTool(ctx, config, planned, { cwd, root, signal, rerunOf = null, runTask = runOfficialTask }) {
1553
+ async function runPlannedTool(ctx, config, planned, { cwd, root, signal, rerunOf = null, confirmOverBudget = false, runTask = runOfficialTask }) {
1312
1554
  const startedAt = Date.now()
1313
- const result = await runTask({ toolId: planned.toolId, task: planned.taskText, modelId: planned.modelId, workspace: cwd, allowedRoot: root ?? cwd,
1314
- mode: planned.mode, signal, sandbox: ctx.sandbox })
1315
- const recorded = result.status === 'unsupported' ? null : await recordToolRun({
1316
- toolId: planned.toolId, task: planned.taskText, provider: planned.provider, model: planned.model, cliModel: planned.cliModel,
1317
- mode: planned.mode, workspace: cwd, result, startedAt, routes: planned.routes, config,
1318
- estimatedCost: planned.estimate, budget: planned.budget, rerunOf,
1319
- })
1320
- return { result, recorded }
1555
+ const runId = randomUUID()
1556
+ const makeToolRecord = (result, location = {}) => {
1557
+ const run = buildToolRunRecord({ id: runId, createdAt: startedAt, finishedAt: Date.now(),
1558
+ toolId: planned.toolId, toolLabel: planned.tool?.label ?? planned.toolId, task: planned.taskText,
1559
+ provider: planned.provider, model: planned.model, cliModel: planned.cliModel,
1560
+ mode: planned.mode, workspace: cwd, result, estimatedCost: planned.estimate, rerunOf,
1561
+ pricingFor: item => routePricing(planned.routes, item), billingFor: billingForResult, budget: planned.budget })
1562
+ if (location.isolatedWorkspace) run.isolatedWorkspace = location.isolatedWorkspace
1563
+ if (location.baseCommit) run.baseCommit = location.baseCommit
1564
+ return run
1565
+ }
1566
+ const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), settings: budgetSettings(config), confirmOverBudget,
1567
+ packageEstimates: [{ id: 'direct', billing: planned.funding ?? 'api', estimatedCostUsd: planned.apiEstimate ?? planned.estimate ?? null }],
1568
+ buildRun: ({ status, results, workspace }) => makeToolRecord(results[0] ?? { status, notStarted: true }, workspace) })
1569
+ if (!lifecycle.accepted) {
1570
+ planned.budget = pausedExecutionBudget(lifecycle)
1571
+ return { result: { status: 'paused-budget', budget: planned.budget }, recorded: null }
1572
+ }
1573
+ let result
1574
+ try {
1575
+ result = await runTask({ toolId: planned.toolId, task: planned.taskText, modelId: planned.modelId, workspace: cwd, allowedRoot: root ?? cwd,
1576
+ mode: planned.mode, signal, sandbox: ctx.sandbox,
1577
+ onCheckpointStart: item => lifecycle.onCheckpointStart(item), onCheckpointResult: item => lifecycle.onCheckpointResult(item),
1578
+ onWorkspace: value => lifecycle.onWorkspace(value) })
1579
+ } catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
1580
+ const recorded = makeToolRecord(result, { isolatedWorkspace: result.isolatedWorkspace, baseCommit: result.baseCommit })
1581
+ try {
1582
+ if (result.status !== 'succeeded') await annotateCliQuota(recorded, config,
1583
+ [{ id: 'direct', toolId: planned.toolId, text: `${result.error ?? result.reason ?? ''}\n${result.outputTail ?? ''}` }])
1584
+ await lifecycle.finish(recorded)
1585
+ } catch (error) { await lifecycle.interrupt(recorded); throw error }
1586
+ return { result, recorded: result.status === 'unsupported' ? null : recorded }
1321
1587
  }
1322
1588
 
1323
1589
  /** Ledger record for model_router_tool_run. */
@@ -1356,9 +1622,13 @@ export async function rateRecordedResult({ runId, packageId, rating, expectedFin
1356
1622
 
1357
1623
  /** Everything the workbench shows: recent runs, spending, budget, learned biases and settings. */
1358
1624
  export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
1625
+ await createBudgetReservations({ state: routerStateStore() }).recoverInterruptedExecutions()
1359
1626
  const saved = await routingSavedState(config)
1360
1627
  const spent = spending(saved.runs, undefined, saved.archivedSpending)
1361
1628
  const settings = budgetSettings(config)
1629
+ const entries = Object.values(saved.budgetReservations ?? {})
1630
+ const reserved = { ...reservationSpending(saved), activeCount: entries.filter(item => item.status === 'active').length,
1631
+ interruptedCount: entries.filter(item => item.status === 'interrupted').length }
1362
1632
  return {
1363
1633
  runs: saved.runs.slice(-limit).reverse().map(run => ({
1364
1634
  ...run,
@@ -1367,7 +1637,14 @@ export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
1367
1637
  .map(item => ({ ...item, answer: text(item.answer).slice(0, 1_500) })),
1368
1638
  })),
1369
1639
  spent,
1370
- budget: { ...settings, ...budgetCheck({ estimateUsd: null, spent, ...settings }), historyVerified: saved.historyVerified !== false },
1640
+ subscriptionUsage: { ...subscriptionUsage(saved.runs, Date.now(), saved.archivedSubscriptionUsage), historyIncomplete: saved.subscriptionUsageIncomplete === true },
1641
+ reserved,
1642
+ reservations: entries.map(entry => ({ id: entry.id, runId: entry.runId, status: entry.status, updatedAt: entry.updatedAt,
1643
+ billing: entry.packages.every(item => item.billing === 'subscription') ? 'subscription' : entry.packages.some(item => item.billing === 'subscription') ? 'mixed' : 'api',
1644
+ unknown: entry.packages.some(item => item.billing !== 'subscription' && (item.estimatedCostUsd === null || entry.status === 'interrupted' && item.started)),
1645
+ remainingEstimateUsd: entry.packages.every(item => Number.isFinite(item.estimatedCostUsd))
1646
+ ? entry.packages.reduce((sum, item) => sum + item.estimatedCostUsd, 0) : null })),
1647
+ budget: { ...budgetFor(config, saved, null, { checkEstimate: false }), historyVerified: saved.historyVerified !== false },
1371
1648
  storageAvailable: saved.storageAvailable !== false,
1372
1649
  storageNotice: saved.storageAvailable === false ? '执行记录存储暂不可用;下方费用与学习摘要尚未核验,不能当作零花费或没有反馈。'
1373
1650
  : saved.historyVerified === false ? '此前执行记录已损坏或不完整,当前摘要只包含可读记录;不能视为完整费用历史,请从备份恢复后再核验预算。' : '',
@@ -1494,10 +1771,12 @@ export async function planOfficialTeam(ctx, config = {}, args = {}, options = {}
1494
1771
  const supported = new Set(executableRoutes.map(route => toolForProvider(route.provider).id))
1495
1772
  const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
1496
1773
  const budgetUsd = Math.max(0, finiteNumber(args.budgetUsd, finiteNumber(configuredBudget, 0)))
1774
+ const fundingPrices = planningFundingPrices(executableRoutes, accountingContext(null, executableRoutes, true))
1497
1775
  const plan = createPlanFromRoutes(taskText, executableRoutes, {
1498
- ...planDataOptions(saved, config), mode: 'team', budgetUsd, installedToolIds: installed, forceOfficialCli: true,
1776
+ ...planDataOptions(saved, config), pricing: fundingPrices.pricing, mode: 'team', budgetUsd, installedToolIds: installed, forceOfficialCli: true,
1499
1777
  runnableToolIds: installed.filter(id => supported.has(id)), preset: routingPresetOf(config, args.preset),
1500
1778
  })
1779
+ plan.subscriptionPricedRoutes = fundingPrices.subscriptionPricedRoutes
1501
1780
  const bindingsText = text(args.cliModelsJson)
1502
1781
  if (bindingsText.length > 4_000) throw new Error('cliModelsJson 超过 4000 字符上限')
1503
1782
  let requestedModels = {}
@@ -1506,7 +1785,8 @@ export async function planOfficialTeam(ctx, config = {}, args = {}, options = {}
1506
1785
  catch { throw new Error('cliModelsJson 不是有效的 JSON 对象') }
1507
1786
  }
1508
1787
  const cliModels = resolveTeamCliModelBindings(plan, executableRoutes, requestedModels)
1509
- const budget = budgetFor(config, await savedState(), plan.estimatedCost)
1788
+ attachPlanAccounting(plan, taskText, executableRoutes, accountingContext(null, executableRoutes, true))
1789
+ const budget = budgetFor(config, await savedState(), plan.accounting.apiEstimatedCostUsd, { apiExposure: plan.accounting.apiExposure })
1510
1790
  return { ...base, status: 'planned', plan, cliModels, budget }
1511
1791
  }
1512
1792
 
@@ -1516,7 +1796,8 @@ export async function executeOfficialTeamPlan(ctx, config = {}, planned, options
1516
1796
  if (!planned?.plan || !['read-only', 'workspace-write'].includes(planned.mode)) throw new Error('缺少有效的官方 CLI 团队计划')
1517
1797
  const executionEnvironment = teamExecutionEnvironment(planned.executionEnvironment, planned.mode)
1518
1798
  const workspace = options.workspace ?? planned.workspace
1519
- const budget = budgetFor(config, await savedState(), planned.plan.estimatedCost)
1799
+ attachPlanAccounting(planned.plan, planned.taskText, planned.routes, accountingContext(null, planned.routes, true))
1800
+ const budget = budgetFor(config, await savedState(), planned.plan.accounting.apiEstimatedCostUsd, { apiExposure: planned.plan.accounting.apiExposure })
1520
1801
  const common = { plan: planned.plan, workspace, mode: planned.mode, executionEnvironment, budget }
1521
1802
  if (budget.exceeded && options.confirmOverBudget !== true) return { ...common, status: 'paused-budget',
1522
1803
  notice: `${budget.message} 已暂停团队执行。请向用户确认后再执行。` }
@@ -1528,15 +1809,39 @@ export async function executeOfficialTeamPlan(ctx, config = {}, planned, options
1528
1809
  reason: '官方 Harness 进程沙箱不可用,不能执行官方 CLI 团队。' }
1529
1810
  throwIfAborted(options.signal)
1530
1811
  const startedAt = Date.now()
1531
- const execution = await (options.runTeam ?? runOfficialTeam)({ plan: planned.plan, task: planned.taskText, workspace,
1812
+ const runId = randomUUID()
1813
+ const makeTeamRecord = (execution, location = {}) => {
1814
+ const run = buildTeamRunRecord({ id: runId, createdAt: startedAt, finishedAt: Date.now(), task: planned.taskText,
1815
+ plan: planned.plan, execution, mode: planned.mode, workspace,
1816
+ preset: planned.plan.preset ?? DEFAULT_ROUTING_PRESET, pricingFor: item => routePricing(planned.routes, item), billingFor: billingForResult,
1817
+ budget: { estimateUsd: budget.estimateUsd, confirmed: options.confirmOverBudget === true } })
1818
+ run.executionEnvironment = executionEnvironment
1819
+ run.cliModels = { ...planned.cliModels }
1820
+ if (planned.mode === 'workspace-write' && location.workspace) run.isolatedWorkspace = location.workspace
1821
+ if (location.baseCommit) run.baseCommit = location.baseCommit
1822
+ return run
1823
+ }
1824
+ const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), settings: budgetSettings(config),
1825
+ confirmOverBudget: options.confirmOverBudget === true, packageEstimates: packageEstimates(planned.plan, planned.taskText),
1826
+ buildRun: ({ status, results, workspace: location }) => makeTeamRecord({ status, results }, location) })
1827
+ if (!lifecycle.accepted) return { ...common, status: 'paused-budget', budget: pausedExecutionBudget(lifecycle) }
1828
+ let execution
1829
+ try { execution = await (options.runTeam ?? runOfficialTeam)({ plan: planned.plan, task: planned.taskText, workspace,
1532
1830
  allowedRoot: options.root ?? workspace, mode: planned.mode, executionEnvironment, installedIds: planned.installed,
1533
1831
  cliModels: planned.cliModels, signal: options.signal, sandbox: ctx.sandbox,
1534
1832
  onPackageStart: item => reportRunProgress(options, { type: 'package-start', package: item }),
1535
1833
  onPackage: item => reportRunProgress(options, { type: 'package-complete', package: item }),
1536
- onStage: stage => reportRunProgress(options, { type: 'stage', stage }) })
1834
+ onStage: stage => reportRunProgress(options, { type: 'stage', stage }),
1835
+ onCheckpointStart: item => lifecycle.onCheckpointStart(item), onCheckpointResult: result => lifecycle.onCheckpointResult(result),
1836
+ onWorkspace: value => lifecycle.onWorkspace(value) }) }
1837
+ catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
1537
1838
  await reportRunProgress(options, { type: 'stage', stage: 'saving' })
1538
- const run = await recordTeamRun({ task: planned.taskText, plan: planned.plan, execution, mode: planned.mode, executionEnvironment, workspace,
1539
- routes: planned.routes, cliModels: planned.cliModels, budget, startedAt, config })
1839
+ const run = makeTeamRecord(execution, { workspace: execution.workspace, baseCommit: execution.baseCommit })
1840
+ try {
1841
+ await annotateCliQuota(run, config, (execution.results ?? []).filter(item => item.status !== 'succeeded' && item.toolId)
1842
+ .map(item => ({ id: item.id, toolId: item.toolId, text: `${item.error ?? ''}\n${item.outputTail ?? ''}` })))
1843
+ await lifecycle.finish(run)
1844
+ } catch (error) { await lifecycle.interrupt(run); throw error }
1540
1845
  return { ...common, status: execution.status, execution, run, runId: run?.id ?? null,
1541
1846
  rerunNotice: planned.mode === 'read-only'
1542
1847
  ? '失败的工作包可用 model_router_rerun_step 单步重跑(只重跑该步及其未完成的下游)。'
@@ -1555,7 +1860,8 @@ export function decisionSummary(plan) {
1555
1860
  preset: plan?.preset ?? DEFAULT_ROUTING_PRESET,
1556
1861
  complexity: plan?.complexity ?? null,
1557
1862
  reason: plan?.reason ?? '',
1558
- estimatedCost: plan?.estimatedCost ?? null,
1863
+ accounting: plan?.accounting ?? null,
1864
+ estimatedCost: plan?.accounting ? plan.accounting.apiEstimatedCostUsd : plan?.estimatedCost ?? null,
1559
1865
  packages: packages.map(item => ({
1560
1866
  id: item.id, name: item.name, route: `${item.recommendedProvider}/${item.recommendedModel}`,
1561
1867
  difficulty: item.difficulty ?? null, estimatedCost: item.estimatedCost ?? null,
@@ -1634,7 +1940,7 @@ const APPROVAL_TEXT = Object.freeze({
1634
1940
  */
1635
1941
  export function approvalReasonCodes(name, args = {}, config = {}, { rerunRun = null } = {}) {
1636
1942
  const codes = []
1637
- const runKinds = ['model_router_execute', 'model_router_rerun_step', 'model_router_team_execute', 'model_router_tool_run']
1943
+ const runKinds = ['model_router_execute', 'model_router_rerun_step', 'model_router_team_execute', 'model_router_tool_run', 'model_router_consult']
1638
1944
  if (!runKinds.includes(name)) return codes
1639
1945
  if ((name === 'model_router_tool_run' || name === 'model_router_team_execute') && args.mode === 'workspace-write') codes.push('workspace-write')
1640
1946
  if (name === 'model_router_rerun_step' && rerunRun && rerunSupport(rerunRun).writes) codes.push(rerunRun.kind === 'tool' ? 'workspace-write' : 'rerun-write')
@@ -1736,7 +2042,7 @@ async function prepareWorkbenchPreview(ctx, config, supplied = {}, options = {})
1736
2042
  executor: 'official-team', executionMode: mode, executionEnvironment: planned.executionEnvironment, workspace, status: planned.status,
1737
2043
  ...(planned.reason ? { reason: planned.reason } : {}),
1738
2044
  decision, selected: planned.plan?.selected ?? null, routingBypassed: false,
1739
- estimatedCost: planned.plan?.estimatedCost ?? null, budget: planned.budget,
2045
+ accounting: planned.plan?.accounting ?? null, estimatedCost: planned.plan?.accounting ? planned.plan.accounting.apiEstimatedCostUsd : planned.plan?.estimatedCost ?? null, budget: planned.budget,
1740
2046
  reasons: confirmationReasons(codes, { sandboxAvailable: typeof ctx.sandbox?.confine === 'function', executionEnvironment: planned.executionEnvironment }),
1741
2047
  readOnly: mode === 'read-only',
1742
2048
  } }
@@ -1757,7 +2063,8 @@ async function prepareWorkbenchPreview(ctx, config, supplied = {}, options = {})
1757
2063
  decision: decisionSummary(planned.plan),
1758
2064
  selected: planned.plan.selected ?? null,
1759
2065
  routingBypassed: planned.plan.routingBypassed === true,
1760
- estimatedCost: planned.plan.estimatedCost ?? null,
2066
+ accounting: planned.plan.accounting ?? null,
2067
+ estimatedCost: planned.plan.accounting ? planned.plan.accounting.apiEstimatedCostUsd : planned.plan.estimatedCost ?? null,
1761
2068
  budget: planned.budget,
1762
2069
  reasons: confirmationReasons(codes, { sandboxAvailable: typeof ctx.sandbox?.confine === 'function' }),
1763
2070
  readOnly: true,
@@ -2012,6 +2319,7 @@ export function apply(ctx, config = {}) {
2012
2319
  provider: { type: 'string', description: 'Configured provider id; pair with model.' },
2013
2320
  model: { type: 'string', description: 'Configured model id; pair with provider.' },
2014
2321
  outputLimit: { type: 'number', description: 'Maximum returned characters, from 500 to 50000.' },
2322
+ confirmOverBudget: { type: 'boolean', description: 'Only after explicit user approval when the API budget is exceeded or unverified.' },
2015
2323
  },
2016
2324
  output: JSON_OUTPUT,
2017
2325
  async execute(args, exec) {
@@ -2022,7 +2330,7 @@ export function apply(ctx, config = {}) {
2022
2330
  const route = requested ?? chooseConsultRoute(plan, routes, exec.agent)
2023
2331
  const fallback = valueOf(config, 'maxConsultOutputChars', 12_000)
2024
2332
  const limit = boundedInteger(args.outputLimit, finiteNumber(fallback, 12_000), 500, 50_000)
2025
- return jsonValue(await consultConfiguredModel(ctx, route, args.task, limit, exec.signal))
2333
+ return jsonValue(await executeBudgetedConsult(ctx, route, args.task, config, { routes, limit, signal: exec.signal, confirmOverBudget: args.confirmOverBudget === true }))
2026
2334
  },
2027
2335
  }))
2028
2336
  ctx.commands.register({
@@ -2239,14 +2547,14 @@ function registerOfficialToolModels(ctx, config, assertInstallPreferences) {
2239
2547
  const planned = await planToolRun(ctx, config, args, { signal: exec.signal })
2240
2548
  if (planned.mode === 'workspace-write' && sandboxMode === 'read-only') throw new Error('当前 Harness 会话为只读模式,不能请求可编辑 CLI 执行')
2241
2549
  if (planned.budget.exceeded && args.confirmOverBudget !== true) {
2242
- return jsonValue({ status: 'paused-budget', budget: { ...planned.budget, paused: true }, estimateUsd: planned.estimate,
2550
+ return jsonValue({ status: 'paused-budget', budget: { ...planned.budget, paused: true }, estimateUsd: planned.apiEstimate ?? (planned.funding === 'subscription' ? 0 : planned.estimate), accounting: planned.accounting ?? null,
2243
2551
  notice: `${planned.budget.message} 已暂停,未启动 ${planned.tool.label}。请向用户说明后,以 confirmOverBudget: true 重新调用。` })
2244
2552
  }
2245
- const { result, recorded } = await runPlannedTool(ctx, config, planned, { cwd, root, signal: exec.signal })
2246
- return jsonValue({ ...result, runId: recorded?.id ?? null, estimateUsd: planned.estimate, budget: planned.budget,
2553
+ const { result, recorded } = await runPlannedTool(ctx, config, planned, { cwd, root, signal: exec.signal, confirmOverBudget: args.confirmOverBudget === true })
2554
+ return jsonValue({ ...result, runId: recorded?.id ?? null, estimateUsd: planned.apiEstimate ?? (planned.funding === 'subscription' ? 0 : planned.estimate), accounting: planned.accounting ?? null, budget: planned.budget,
2247
2555
  ...(!planned.modelId && planned.model ? { modelNotice: result.modelNotice
2248
2556
  ?? 'Harness 模型 ID 未经此厂商 CLI 验证;本次使用厂商 CLI 已配置的默认模型。' } : {}),
2249
- ...(planned.estimate === null ? { costNotice: '未指定带单价的 provider/model 路线,无法预估费用;仅在预算已用尽时暂停。' } : {}),
2557
+ ...(planned.funding === 'subscription' ? { costNotice: '订阅单独统计用量,不占用 API 金额预算;厂商未回报的用量保持未知。' } : planned.estimate === null ? { costNotice: '实际计费方式或 API 单价未知,请先核对后确认。' } : {}),
2250
2558
  })
2251
2559
  },
2252
2560
  }))