@ljwei-stak/dsh-model-router 0.24.0 → 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.
@@ -15,6 +15,8 @@ import { createRouterState } from './shared/router-state.mjs'
15
15
  import { createRunJobs } from './shared/run-jobs.mjs'
16
16
  import { createBudgetReservations, reservationSpending } from './shared/budget-reservations.mjs'
17
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'
18
20
  import { applyFeedbackProfile, buildFeedbackProfile } from './shared/adaptive-feedback.mjs'
19
21
  import { buildRoutingEvidence, parseRoutingEvidenceJson } from './shared/routing-evidence.mjs'
20
22
  import { apiReasoningRequest } from './shared/reasoning-execution.mjs'
@@ -26,7 +28,7 @@ import {
26
28
  spending, storedResults, actualCost, hasStartedQualityAttempts, teamExecutionResults, plannedPackages,
27
29
  } from './shared/run-ledger.mjs'
28
30
  import { routeBoundaries } from './shared/security-boundaries.mjs'
29
- import { createPlanFromRoutes } from './shared/harness-plan.mjs'
31
+ import { createPlanFromRoutes, channelForProvider } from './shared/harness-plan.mjs'
30
32
  import { applyManualPackagePlan } from './shared/manual-plan.mjs'
31
33
  import { parsePackageAssignments, packagePlanKey } from './shared/package-assignments.mjs'
32
34
  import { applyModelProfiles, parseModelProfilesJson } from './shared/model-profiles.mjs'
@@ -379,9 +381,9 @@ function budgetSettings(config) {
379
381
  }
380
382
  }
381
383
 
382
- function budgetFor(config, saved, estimateUsd, { checkEstimate = true } = {}) {
384
+ function budgetFor(config, saved, estimateUsd, { checkEstimate = true, apiExposure = true } = {}) {
383
385
  const settings = budgetSettings(config)
384
- 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)) {
385
387
  throw new Error('无法读取费用历史,不能核验日/月预算;请恢复存储连接后再执行。')
386
388
  }
387
389
  const recorded = spending(saved.runs, undefined, saved.archivedSpending)
@@ -389,12 +391,13 @@ function budgetFor(config, saved, estimateUsd, { checkEstimate = true } = {}) {
389
391
  const spent = { ...recorded, today: recorded.today + reserved.today, month: recorded.month + reserved.month,
390
392
  unknownToday: recorded.unknownToday + reserved.unknownToday, unknownMonth: recorded.unknownMonth + reserved.unknownMonth }
391
393
  const check = budgetCheck({ estimateUsd, spent, ...settings })
392
- if (check.limited && (checkEstimate && !check.estimateKnown || settings.dailyLimitUsd > 0 && spent.unknownToday > 0
394
+ if (!apiExposure) { check.exceeded = null; check.message = '订阅按用量单独统计,不占用 API 金额预算。' }
395
+ if (apiExposure && check.limited && (checkEstimate && !check.estimateKnown || settings.dailyLimitUsd > 0 && spent.unknownToday > 0
393
396
  || settings.monthlyLimitUsd > 0 && spent.unknownMonth > 0) && !check.exceeded) {
394
397
  check.exceeded = 'unknown'
395
398
  check.message = '存在未核对费用或缺少单价,无法核验预算;请先核对账单或明确确认继续。'
396
399
  }
397
- return { ...check, spent, reserved, ...settings }
400
+ return { ...check, spent, reserved, ...settings, apiExposure }
398
401
  }
399
402
 
400
403
  function pausedExecutionBudget(lifecycle) {
@@ -405,21 +408,42 @@ function pausedExecutionBudget(lifecycle) {
405
408
  return { ...lifecycle.budget, paused: true, exceeded: lifecycle.budget?.exceeded ?? 'unknown' }
406
409
  }
407
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
+
408
416
  function packageEstimates(plan, task) {
417
+ if (plan.accounting) return accountingEstimates(plan)
409
418
  return plannedPackages(plan, task).map(item => ({ id: item.id, estimatedCostUsd: item.estimatedCost ?? null }))
410
419
  }
411
420
 
412
- /** Reprice a subscription fallback against the route that will actually bill. */
421
+ /** Activate API money accounting only at the actual API dispatch boundary. */
413
422
  async function reserveChangedApiRoute(lifecycle, expected, route, task, config, confirmOverBudget) {
414
- if (expected?.provider === route.provider && expected?.model === route.model) return null
415
423
  const id = lifecycle.activePackageId()
416
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
417
428
  const saved = await routingSavedState(config)
418
- const estimate = createPlanFromRoutes(task, [route], { ...planDataOptions(saved, config),
419
- mode: 'direct', directProvider: route.provider, directModel: route.model }).estimatedCost
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
420
432
  const reserved = await lifecycle.reserveExtra(id, estimate, { confirmOverBudget: confirmOverBudget === true })
421
- if (reserved.accepted) return null
422
- return { ok: false, notStarted: true, error: `实际 API 路线预算未确认,已停止调用。${reserved.budget.message}` }
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] })
423
447
  }
424
448
 
425
449
  /** A changed budget stops before dispatch; storage failures retain their hold. */
@@ -436,13 +460,18 @@ async function stopExecutionLifecycle(lifecycle, error, run = lifecycle.current(
436
460
  }
437
461
 
438
462
  /** The independent-opinion tool uses the same durable API budget boundary. */
439
- export async function executeBudgetedConsult(ctx, route, task, config = {}, { signal, limit = 12_000, confirmOverBudget = false } = {}) {
463
+ export async function executeBudgetedConsult(ctx, route, task, config = {}, { signal, limit = 12_000, confirmOverBudget = false, routes = [] } = {}) {
440
464
  const problem = taskTextProblem(text(task))
441
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
442
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 }] }
443
472
  const id = randomUUID(), at = Date.now()
444
473
  const record = (status, results) => buildRunRecord({ id, createdAt: at, finishedAt: Date.now(), task, plan,
445
- execution: { status, packages: results }, workspace: process.cwd(), pricingFor: () => route.pricing ?? null, billingFor: () => 'api' })
474
+ execution: { status, packages: results }, workspace: process.cwd(), pricingFor: () => route.pricing ?? null, billingFor: () => funding.billing })
446
475
  const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), buildRun: ({ status, results }) => record(status, results),
447
476
  packageEstimates: packageEstimates(plan, task), settings: budgetSettings(config), confirmOverBudget })
448
477
  if (!lifecycle.accepted) return { status: 'paused-budget', paused: true, budget: pausedExecutionBudget(lifecycle), ok: false }
@@ -450,10 +479,13 @@ export async function executeBudgetedConsult(ctx, route, task, config = {}, { si
450
479
  try {
451
480
  await lifecycle.onCheckpointStart({ id: 'direct', provider: route.provider, model: route.model, channel: 'harness-llm' })
452
481
  result = await consultConfiguredModel(ctx, route, task, limit, signal)
453
- await lifecycle.onCheckpointResult({ ...result, id: 'direct', provider: route.provider, model: route.model, channel: 'harness-llm' })
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)
454
486
  await lifecycle.finish(record(result.cancelled ? 'cancelled' : result.ok ? 'completed' : 'failed', lifecycle.results()))
455
487
  } catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
456
- return { ...result, runId: id }
488
+ return { ...result, billing: funding.billing, accounting: plan.accounting, runId: id }
457
489
  }
458
490
 
459
491
  /**
@@ -462,6 +494,11 @@ export async function executeBudgetedConsult(ctx, route, task, config = {}, { si
462
494
  */
463
495
  export function billingForResult(result) {
464
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'
465
502
  return billingOf(result, {
466
503
  loginBilling: toolId ? healthCache.loginState(toolId)?.billing ?? null : null,
467
504
  apiKeyPresent: toolId ? apiKeyEnvPresent(toolId) : false,
@@ -566,14 +603,19 @@ export async function createRoutePlan(ctx, task, config = {}, options = {}) {
566
603
  const loggedOut = options.skipToolProbe === true
567
604
  ? (Array.isArray(options.loggedOutToolIds) ? options.loggedOutToolIds : [])
568
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)
569
609
  const plan = createPlanFromRoutes(taskText, availableRoutes, {
570
- ...planDataOptions(saved, config),
610
+ ...planDataOptions(saved, config), pricing: fundingPrices.pricing,
571
611
  mode, budgetUsd, installedToolIds: installed,
572
612
  runnableToolIds: installed.filter(id => executable.has(id)),
573
613
  preset: routingPresetOf(config, options.preset),
574
614
  loggedOutToolIds: loggedOut,
575
615
  })
576
- 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 }) }
577
619
  }
578
620
 
579
621
  /** One bounded, independent call through the same official LLM service. */
@@ -821,6 +863,7 @@ function executionHooks(ctx, options) {
821
863
  return {
822
864
  onCheckpointStart: options.onCheckpointStart,
823
865
  onCheckpointResult: options.onCheckpointResult,
866
+ onSubscriptionResult: options.onSubscriptionResult,
824
867
  skipOfficial: options.skipOfficial ?? (request => healthCache.skipReason(request)),
825
868
  onStage: stage => reportRunProgress(options, { type: 'stage', stage }),
826
869
  onPackageStart: async item => {
@@ -882,32 +925,38 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
882
925
  ? (Array.isArray(options.loggedOutToolIds) ? options.loggedOutToolIds : [])
883
926
  : loggedOutIds(await currentHealth())
884
927
  const preset = routingPresetOf(config, options.preset)
885
- 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
886
932
  ? createPlanFromRoutes(taskText, routes, {
887
- ...planDataOptions(saved, config),
933
+ ...planDataOptions(saved, config), pricing: fundingPrices.pricing,
888
934
  mode: 'direct', directProvider: options.provider, directModel: options.model,
889
935
  installedToolIds: installed, runnableToolIds, preset: presetId, loggedOutToolIds: loggedOut,
890
936
  })
891
937
  : createPlanFromRoutes(taskText, routes, {
892
- ...planDataOptions(saved, config),
938
+ ...planDataOptions(saved, config), pricing: fundingPrices.pricing,
893
939
  mode: options.planMode === 'team' ? 'team' : 'single',
894
940
  budgetUsd, installedToolIds: installed, runnableToolIds, preset: presetId, loggedOutToolIds: loggedOut,
895
941
  })
942
+ const planWith = (...args) => { const result = buildWith(...args); result.subscriptionPricedRoutes = fundingPrices.subscriptionPricedRoutes; return result }
896
943
  let plan = planWith(preset, options.budgetUsd)
897
944
  if (manual) {
898
945
  plan = applyManualPackagePlan(plan, { task: taskText, packageAssignments: options.packageAssignments,
899
946
  packagePlanKey: options.packagePlanKey }, routes, planDataOptions(saved, config))
900
947
  await validateManualTools(ctx, plan.manualAssignments, { ...options, installedToolIds: installed, readiness })
901
948
  }
902
- 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 })
903
951
  const perRunLimit = Number(options.budgetUsd) || 0
904
- if (manual && perRunLimit > 0 && (plan.estimatedCost === null || plan.estimatedCost > perRunLimit)) {
905
- budget = { ...budget, exceeded: budget.exceeded || 'run', estimateUsd: plan.estimatedCost,
906
- 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 ? '手动分配包含费用未知的目标,无法确认满足本次预算。' : '手动分配的预估费用超出本次预算。' }
907
955
  }
908
956
  if (budget.exceeded && budget.action === 'downgrade' && !direct && !manual) {
909
957
  const cheaper = planWith('economy', budget.remainingUsd > 0 ? budget.remainingUsd : options.budgetUsd)
910
- 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 })
911
960
  if (!recheck.exceeded) {
912
961
  budget = { ...recheck, downgraded: true, downgradedFrom: preset,
913
962
  message: `原方案${budget.message} 已自动降级为“省钱优先”方案(预估 ${cheaper.estimatedCost === null ? '价格待配置' : formatUsd(cheaper.estimatedCost)})。` }
@@ -958,7 +1007,11 @@ function apiUpgradeEligible(route, routes) {
958
1007
 
959
1008
  /** Route, or honor one explicit model, then run each package through its adapter. */
960
1009
  export async function executeConfiguredAssignment(ctx, task, config = {}, options = {}) {
961
- 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 })
962
1015
  if (budget.exceeded && options.confirmOverBudget !== true) {
963
1016
  return {
964
1017
  plan, execution: null, paused: true,
@@ -971,7 +1024,6 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
971
1024
  finished.push(result)
972
1025
  if (typeof options.onPackage === 'function') await options.onPackage(result)
973
1026
  } })
974
- const billing = await billingContext(config, options)
975
1027
  const limit = boundedInteger(valueOf(config, 'maxConsultOutputChars', 12_000), 12_000, 500, 50_000)
976
1028
  const startedAt = Date.now()
977
1029
  const runId = randomUUID()
@@ -1025,11 +1077,12 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
1025
1077
  if (!unknownProjection && (!Number.isFinite(projected) || perRunLimit > 0 && projected > perRunLimit)) return false
1026
1078
  const id = lifecycle.activePackageId()
1027
1079
  if (!id) return false
1028
- await lifecycle.partialResult({ ...attempts.at(-1), id, ok: false, qualityAttempts: attempts })
1080
+ const subscriptionAttempts = lifecycle.results().find(item => item.id === id)?.subscriptionAttempts ?? []
1081
+ await lifecycle.partialResult({ ...attempts.at(-1), id, ok: false, subscriptionAttempts, qualityAttempts: attempts })
1029
1082
  if (!(await lifecycle.reserveExtra(id, estimate)).accepted) return false
1030
1083
  // The extra request can be charged before its receipt arrives. Persist
1031
1084
  // both the first receipt and the unknown second attempt before dispatch.
1032
- await lifecycle.partialResult({ ...attempts.at(-1), id, ok: false, qualityAttempts: [...attempts, {
1085
+ await lifecycle.partialResult({ ...attempts.at(-1), id, ok: false, subscriptionAttempts, qualityAttempts: [...attempts, {
1033
1086
  provider: to.provider, model: to.model, channel: 'harness-llm', ok: false,
1034
1087
  usage: null, usageIncomplete: true, pricingSnapshot: to.pricing ?? null, finishedAt: Date.now(),
1035
1088
  }] }, { checkBudget: true })
@@ -1048,7 +1101,8 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
1048
1101
  ...hooks,
1049
1102
  onCheckpointStart: item => lifecycle.onCheckpointStart(item),
1050
1103
  onCheckpointResult: result => lifecycle.onCheckpointResult(result),
1051
- billing,
1104
+ onSubscriptionResult: event => checkpointSubscription(lifecycle, event),
1105
+ billing: billing ? { ...billing, routes } : null,
1052
1106
  apiFallback: gatedApi,
1053
1107
  })
1054
1108
  } catch (error) {
@@ -1195,14 +1249,24 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
1195
1249
  const retryIds = new Set([target.id, ...downstreamPackageIds(run.packages, target.id)
1196
1250
  .filter(id => run.packages.find(item => item.id === id)?.ok !== true)])
1197
1251
  const estimates = rerunEstimates(run, retryIds, target.id, override, routes, planDataOptions(refreshed, config))
1198
- 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 })
1199
1263
  if (budget.exceeded && confirmOverBudget !== true) {
1200
1264
  return { paused: true, budget: { ...budget, paused: true, message: `${budget.message} 已暂停重跑,请确认后再试。` }, run }
1201
1265
  }
1202
1266
  const cwd = text(workspace) || run.workspace
1203
1267
  if (!cwd || !(await fileExists(cwd))) throw new Error(`原运行的工作区 ${cwd || '(未记录)'} 不可用,无法重跑该步骤;请恢复该目录,或在原工作区的会话中调用 model_router_rerun_step。`)
1204
1268
  if (run.kind === 'team') return rerunTeamStep(ctx, { run, target, override, routes, budget,
1205
- ids: [...retryIds], estimates: estimates.byId, confirmOverBudget, 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 })
1206
1270
  const manualRetries = Object.fromEntries(run.packages.filter(item => retryIds.has(item.id) && item.manualAssignment)
1207
1271
  .map(item => [item.id, item.id === target.id && override ? { kind: 'api', ...override } : item.manualAssignment]))
1208
1272
  if (Object.keys(manualRetries).length) {
@@ -1229,7 +1293,7 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
1229
1293
  return next
1230
1294
  }
1231
1295
  const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), buildRun: buildRetry,
1232
- packageEstimates: [...estimates.byId].map(([id, estimatedCostUsd]) => ({ id, estimatedCostUsd })),
1296
+ packageEstimates: estimates.reservations,
1233
1297
  settings: budgetSettings(config), confirmOverBudget })
1234
1298
  if (!lifecycle.accepted) return { paused: true, run, budget: pausedExecutionBudget(lifecycle) }
1235
1299
  let execution
@@ -1255,7 +1319,8 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
1255
1319
  ...executionHooks(ctx, options),
1256
1320
  onCheckpointStart: item => lifecycle.onCheckpointStart(item),
1257
1321
  onCheckpointResult: result => lifecycle.onCheckpointResult(result),
1258
- billing: await billingContext(config, options),
1322
+ onSubscriptionResult: event => checkpointSubscription(lifecycle, event),
1323
+ billing: retryBilling,
1259
1324
  apiFallback: async ({ route, task: packageTask, signal: callSignal, subscription }) => {
1260
1325
  if (subscription) return consultConfiguredModel(ctx, route, packageTask, limit, callSignal)
1261
1326
  const id = lifecycle.activePackageId()
@@ -1311,7 +1376,7 @@ function teamPlanFromRun(run, ids, override, targetId, estimates) {
1311
1376
  * Read-only team retry: the failed step plus downstream steps that had not
1312
1377
  * succeeded run through the same signed runner; earlier answers are context.
1313
1378
  */
1314
- async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids, estimates, confirmOverBudget, cwd, root, signal, options, config = {} }) {
1379
+ async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids, estimates, reservations, confirmOverBudget, cwd, root, signal, options, config = {} }) {
1315
1380
  const mode = run.executionMode === 'workspace-write' ? 'workspace-write' : 'read-only'
1316
1381
  const executionEnvironment = teamExecutionEnvironment(run.executionEnvironment, mode)
1317
1382
  const plan = teamPlanFromRun(run, ids, override, target.id, estimates)
@@ -1339,7 +1404,7 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids,
1339
1404
  return next
1340
1405
  }
1341
1406
  const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), buildRun: buildRetry,
1342
- packageEstimates: [...estimates].map(([id, estimatedCostUsd]) => ({ id, estimatedCostUsd })), settings: budgetSettings(config), confirmOverBudget })
1407
+ packageEstimates: reservations, settings: budgetSettings(config), confirmOverBudget })
1343
1408
  if (!lifecycle.accepted) return { paused: true, run, budget: pausedExecutionBudget(lifecycle) }
1344
1409
  let execution
1345
1410
  try { execution = await runTeam({ plan, task: run.task, workspace: cwd, allowedRoot: root ?? cwd, mode, executionEnvironment,
@@ -1474,8 +1539,13 @@ export async function planToolRun(ctx, config, args = {}, { signal } = {}) {
1474
1539
  modelId = text(args.cliModel)
1475
1540
  }
1476
1541
  const estimate = route ? createPlanFromRoutes(taskText, routes, { mode: 'direct', directProvider: route.provider, directModel: route.model }).estimatedCost : null
1477
- const budget = budgetFor(config, await savedState(), Number.isFinite(estimate) ? estimate : null)
1478
- 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,
1479
1549
  provider: route?.provider ?? '', model: route?.model ?? '', cliModel: modelId }
1480
1550
  }
1481
1551
 
@@ -1494,7 +1564,7 @@ async function runPlannedTool(ctx, config, planned, { cwd, root, signal, rerunOf
1494
1564
  return run
1495
1565
  }
1496
1566
  const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), settings: budgetSettings(config), confirmOverBudget,
1497
- packageEstimates: [{ id: 'direct', estimatedCostUsd: planned.estimate ?? null }],
1567
+ packageEstimates: [{ id: 'direct', billing: planned.funding ?? 'api', estimatedCostUsd: planned.apiEstimate ?? planned.estimate ?? null }],
1498
1568
  buildRun: ({ status, results, workspace }) => makeToolRecord(results[0] ?? { status, notStarted: true }, workspace) })
1499
1569
  if (!lifecycle.accepted) {
1500
1570
  planned.budget = pausedExecutionBudget(lifecycle)
@@ -1567,9 +1637,11 @@ export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
1567
1637
  .map(item => ({ ...item, answer: text(item.answer).slice(0, 1_500) })),
1568
1638
  })),
1569
1639
  spent,
1640
+ subscriptionUsage: { ...subscriptionUsage(saved.runs, Date.now(), saved.archivedSubscriptionUsage), historyIncomplete: saved.subscriptionUsageIncomplete === true },
1570
1641
  reserved,
1571
1642
  reservations: entries.map(entry => ({ id: entry.id, runId: entry.runId, status: entry.status, updatedAt: entry.updatedAt,
1572
- unknown: entry.packages.some(item => item.estimatedCostUsd === null || entry.status === 'interrupted' && item.started),
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)),
1573
1645
  remainingEstimateUsd: entry.packages.every(item => Number.isFinite(item.estimatedCostUsd))
1574
1646
  ? entry.packages.reduce((sum, item) => sum + item.estimatedCostUsd, 0) : null })),
1575
1647
  budget: { ...budgetFor(config, saved, null, { checkEstimate: false }), historyVerified: saved.historyVerified !== false },
@@ -1699,10 +1771,12 @@ export async function planOfficialTeam(ctx, config = {}, args = {}, options = {}
1699
1771
  const supported = new Set(executableRoutes.map(route => toolForProvider(route.provider).id))
1700
1772
  const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
1701
1773
  const budgetUsd = Math.max(0, finiteNumber(args.budgetUsd, finiteNumber(configuredBudget, 0)))
1774
+ const fundingPrices = planningFundingPrices(executableRoutes, accountingContext(null, executableRoutes, true))
1702
1775
  const plan = createPlanFromRoutes(taskText, executableRoutes, {
1703
- ...planDataOptions(saved, config), mode: 'team', budgetUsd, installedToolIds: installed, forceOfficialCli: true,
1776
+ ...planDataOptions(saved, config), pricing: fundingPrices.pricing, mode: 'team', budgetUsd, installedToolIds: installed, forceOfficialCli: true,
1704
1777
  runnableToolIds: installed.filter(id => supported.has(id)), preset: routingPresetOf(config, args.preset),
1705
1778
  })
1779
+ plan.subscriptionPricedRoutes = fundingPrices.subscriptionPricedRoutes
1706
1780
  const bindingsText = text(args.cliModelsJson)
1707
1781
  if (bindingsText.length > 4_000) throw new Error('cliModelsJson 超过 4000 字符上限')
1708
1782
  let requestedModels = {}
@@ -1711,7 +1785,8 @@ export async function planOfficialTeam(ctx, config = {}, args = {}, options = {}
1711
1785
  catch { throw new Error('cliModelsJson 不是有效的 JSON 对象') }
1712
1786
  }
1713
1787
  const cliModels = resolveTeamCliModelBindings(plan, executableRoutes, requestedModels)
1714
- 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 })
1715
1790
  return { ...base, status: 'planned', plan, cliModels, budget }
1716
1791
  }
1717
1792
 
@@ -1721,7 +1796,8 @@ export async function executeOfficialTeamPlan(ctx, config = {}, planned, options
1721
1796
  if (!planned?.plan || !['read-only', 'workspace-write'].includes(planned.mode)) throw new Error('缺少有效的官方 CLI 团队计划')
1722
1797
  const executionEnvironment = teamExecutionEnvironment(planned.executionEnvironment, planned.mode)
1723
1798
  const workspace = options.workspace ?? planned.workspace
1724
- 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 })
1725
1801
  const common = { plan: planned.plan, workspace, mode: planned.mode, executionEnvironment, budget }
1726
1802
  if (budget.exceeded && options.confirmOverBudget !== true) return { ...common, status: 'paused-budget',
1727
1803
  notice: `${budget.message} 已暂停团队执行。请向用户确认后再执行。` }
@@ -1784,7 +1860,8 @@ export function decisionSummary(plan) {
1784
1860
  preset: plan?.preset ?? DEFAULT_ROUTING_PRESET,
1785
1861
  complexity: plan?.complexity ?? null,
1786
1862
  reason: plan?.reason ?? '',
1787
- estimatedCost: plan?.estimatedCost ?? null,
1863
+ accounting: plan?.accounting ?? null,
1864
+ estimatedCost: plan?.accounting ? plan.accounting.apiEstimatedCostUsd : plan?.estimatedCost ?? null,
1788
1865
  packages: packages.map(item => ({
1789
1866
  id: item.id, name: item.name, route: `${item.recommendedProvider}/${item.recommendedModel}`,
1790
1867
  difficulty: item.difficulty ?? null, estimatedCost: item.estimatedCost ?? null,
@@ -1965,7 +2042,7 @@ async function prepareWorkbenchPreview(ctx, config, supplied = {}, options = {})
1965
2042
  executor: 'official-team', executionMode: mode, executionEnvironment: planned.executionEnvironment, workspace, status: planned.status,
1966
2043
  ...(planned.reason ? { reason: planned.reason } : {}),
1967
2044
  decision, selected: planned.plan?.selected ?? null, routingBypassed: false,
1968
- 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,
1969
2046
  reasons: confirmationReasons(codes, { sandboxAvailable: typeof ctx.sandbox?.confine === 'function', executionEnvironment: planned.executionEnvironment }),
1970
2047
  readOnly: mode === 'read-only',
1971
2048
  } }
@@ -1986,7 +2063,8 @@ async function prepareWorkbenchPreview(ctx, config, supplied = {}, options = {})
1986
2063
  decision: decisionSummary(planned.plan),
1987
2064
  selected: planned.plan.selected ?? null,
1988
2065
  routingBypassed: planned.plan.routingBypassed === true,
1989
- estimatedCost: planned.plan.estimatedCost ?? null,
2066
+ accounting: planned.plan.accounting ?? null,
2067
+ estimatedCost: planned.plan.accounting ? planned.plan.accounting.apiEstimatedCostUsd : planned.plan.estimatedCost ?? null,
1990
2068
  budget: planned.budget,
1991
2069
  reasons: confirmationReasons(codes, { sandboxAvailable: typeof ctx.sandbox?.confine === 'function' }),
1992
2070
  readOnly: true,
@@ -2252,7 +2330,7 @@ export function apply(ctx, config = {}) {
2252
2330
  const route = requested ?? chooseConsultRoute(plan, routes, exec.agent)
2253
2331
  const fallback = valueOf(config, 'maxConsultOutputChars', 12_000)
2254
2332
  const limit = boundedInteger(args.outputLimit, finiteNumber(fallback, 12_000), 500, 50_000)
2255
- return jsonValue(await executeBudgetedConsult(ctx, route, args.task, config, { limit, signal: exec.signal, confirmOverBudget: args.confirmOverBudget === true }))
2333
+ return jsonValue(await executeBudgetedConsult(ctx, route, args.task, config, { routes, limit, signal: exec.signal, confirmOverBudget: args.confirmOverBudget === true }))
2256
2334
  },
2257
2335
  }))
2258
2336
  ctx.commands.register({
@@ -2469,14 +2547,14 @@ function registerOfficialToolModels(ctx, config, assertInstallPreferences) {
2469
2547
  const planned = await planToolRun(ctx, config, args, { signal: exec.signal })
2470
2548
  if (planned.mode === 'workspace-write' && sandboxMode === 'read-only') throw new Error('当前 Harness 会话为只读模式,不能请求可编辑 CLI 执行')
2471
2549
  if (planned.budget.exceeded && args.confirmOverBudget !== true) {
2472
- 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,
2473
2551
  notice: `${planned.budget.message} 已暂停,未启动 ${planned.tool.label}。请向用户说明后,以 confirmOverBudget: true 重新调用。` })
2474
2552
  }
2475
2553
  const { result, recorded } = await runPlannedTool(ctx, config, planned, { cwd, root, signal: exec.signal, confirmOverBudget: args.confirmOverBudget === true })
2476
- return jsonValue({ ...result, runId: recorded?.id ?? null, estimateUsd: planned.estimate, budget: planned.budget,
2554
+ return jsonValue({ ...result, runId: recorded?.id ?? null, estimateUsd: planned.apiEstimate ?? (planned.funding === 'subscription' ? 0 : planned.estimate), accounting: planned.accounting ?? null, budget: planned.budget,
2477
2555
  ...(!planned.modelId && planned.model ? { modelNotice: result.modelNotice
2478
2556
  ?? 'Harness 模型 ID 未经此厂商 CLI 验证;本次使用厂商 CLI 已配置的默认模型。' } : {}),
2479
- ...(planned.estimate === null ? { costNotice: '未指定带单价的 provider/model 路线,无法预估费用;仅在预算已用尽时暂停。' } : {}),
2557
+ ...(planned.funding === 'subscription' ? { costNotice: '订阅单独统计用量,不占用 API 金额预算;厂商未回报的用量保持未知。' } : planned.estimate === null ? { costNotice: '实际计费方式或 API 单价未知,请先核对后确认。' } : {}),
2480
2558
  })
2481
2559
  },
2482
2560
  }))
@@ -25,6 +25,7 @@ export function sanitizeBudgetReservations(value) {
25
25
  || !Array.isArray(entry.packages) || !Array.isArray(entry.completedIds)
26
26
  || entry.completedIds.some(id => !identifier(id))
27
27
  || entry.packages.some(item => !record(item) || !identifier(item.id)
28
+ || (item.billing !== undefined && !['api', 'subscription'].includes(item.billing))
28
29
  || (item.estimatedCostUsd !== null && !amount(item.estimatedCostUsd)) || typeof item.started !== 'boolean')
29
30
  || new Set(entry.packages.map(item => item.id)).size !== entry.packages.length
30
31
  || entry.packages.some(item => entry.completedIds.includes(item.id))) {
@@ -35,7 +36,7 @@ export function sanitizeBudgetReservations(value) {
35
36
  id: key, runId: key, ownerToken: entry.ownerToken, ownerPid: entry.ownerPid,
36
37
  status: entry.status, createdAt: entry.createdAt, updatedAt: entry.updatedAt,
37
38
  ...(timestamp(entry.interruptedAt) ? { interruptedAt: entry.interruptedAt } : {}),
38
- packages: entry.packages.map(item => ({ id: item.id, estimatedCostUsd: item.estimatedCostUsd, started: item.started })),
39
+ packages: entry.packages.map(item => ({ id: item.id, estimatedCostUsd: item.estimatedCostUsd, started: item.started, ...(item.billing ? { billing: item.billing } : {}) })),
39
40
  completedIds: [...new Set(entry.completedIds)],
40
41
  }
41
42
  }
@@ -49,6 +50,7 @@ export function reservationSpending(state, { excludeReservationId = null } = {})
49
50
  for (const entry of Object.values(state?.budgetReservations ?? {})) {
50
51
  if (!record(entry) || entry.id === excludeReservationId) continue
51
52
  for (const item of Array.isArray(entry.packages) ? entry.packages : []) {
53
+ if (item.billing === 'subscription') continue
52
54
  if (amount(item.estimatedCostUsd)) reservedUsd += item.estimatedCostUsd
53
55
  if (!amount(item.estimatedCostUsd) || (entry.status === 'interrupted' && item.started)) unknownReservations += 1
54
56
  }
@@ -68,7 +70,9 @@ function normalizedEstimates(value, run) {
68
70
  seen.add(item.id)
69
71
  const estimatedCostUsd = Object.hasOwn(item, 'estimatedCostUsd') ? item.estimatedCostUsd : item.estimatedCost ?? null
70
72
  if (estimatedCostUsd !== null && !amount(estimatedCostUsd)) throw new TypeError('工作包预估费用必须是非负有限数字或 null。')
71
- return { id: item.id, estimatedCostUsd, started: item.started === true }
73
+ if (item.billing !== undefined && !['api', 'subscription'].includes(item.billing)) throw new TypeError('预占计费通道无效。')
74
+ return { id: item.id, estimatedCostUsd: item.billing === 'subscription' ? 0 : estimatedCostUsd,
75
+ started: item.started === true, ...(item.billing ? { billing: item.billing } : {}) }
72
76
  })
73
77
  }
74
78
 
@@ -96,6 +100,7 @@ function ownedReservation(current, reservationId, ownerToken) {
96
100
  }
97
101
 
98
102
  function estimateTotal(packages) {
103
+ packages = packages.filter(item => item.billing !== 'subscription')
99
104
  if (packages.some(item => item.estimatedCostUsd === null)) return null
100
105
  const total = packages.reduce((sum, item) => sum + item.estimatedCostUsd, 0)
101
106
  return Number.isFinite(total) ? total : null
@@ -108,7 +113,10 @@ function currentBudget(current, at, packages, options, excludeReservationId = nu
108
113
  unknownToday: recorded.unknownToday + holds.unknownToday, unknownMonth: recorded.unknownMonth + holds.unknownMonth }
109
114
  const budget = budgetCheck({ estimateUsd: estimateTotal(packages), spent,
110
115
  dailyLimitUsd: options.dailyLimitUsd, monthlyLimitUsd: options.monthlyLimitUsd })
111
- const unknownBudget = budget.limited && (!budget.estimateKnown
116
+ const apiExposure = packages.some(item => item.billing !== 'subscription')
117
+ budget.apiExposure = apiExposure
118
+ if (!apiExposure) { budget.exceeded = null; budget.message = '订阅按用量单独统计,不占用 API 金额预算。' }
119
+ const unknownBudget = apiExposure && budget.limited && (!budget.estimateKnown
112
120
  || (options.dailyLimitUsd > 0 && spent.unknownToday > 0)
113
121
  || (options.monthlyLimitUsd > 0 && spent.unknownMonth > 0))
114
122
  if (unknownBudget && !budget.exceeded) budget.message = '存在未核对费用或缺少单价,无法保证本次执行符合预算;请先核对账单或明确确认继续。'
@@ -134,7 +142,7 @@ export function createBudgetReservations({ state, now = Date.now, pid = process.
134
142
  const packages = normalizedEstimates(options.packageEstimates, run)
135
143
  return state.update(current => {
136
144
  const checks = currentBudget(current, now(), packages, options)
137
- if (current.historyIncomplete) return { accepted: false, reason: 'history-incomplete', ...checks }
145
+ if (current.historyIncomplete && checks.budget.apiExposure) return { accepted: false, reason: 'history-incomplete', ...checks }
138
146
  current.budgetReservations ??= {}
139
147
  if (current.budgetReservations[run.id]) return { accepted: false, reason: 'active-run', ...checks }
140
148
  if (Object.keys(current.budgetReservations).length >= maxReservations) return { accepted: false, reason: 'reservation-capacity', ...checks }
@@ -166,7 +174,15 @@ export function createBudgetReservations({ state, now = Date.now, pid = process.
166
174
  // this dispatch boundary without double-counting our previous hold.
167
175
  const prospective = { ...current, runs: current.runs.map(item => item.id === run.id ? budgetRun : item) }
168
176
  const checks = currentBudget(prospective, now(), remaining, options, entry.id)
169
- if (current.historyIncomplete) return { accepted: false, reason: 'history-incomplete', ...checks }
177
+ const starting = entry.packages.filter(item => started.has(item.id) && !item.started)
178
+ if (starting.length && starting.every(item => item.billing === 'subscription')) {
179
+ // A later API step retains its hold and will be checked at its own
180
+ // dispatch boundary; it cannot make this subscription consume money.
181
+ Object.assign(checks.budget, { apiExposure: false, exceeded: null, estimateUsd: 0, estimateKnown: true,
182
+ message: '当前订阅步骤单独统计用量,不占用 API 金额预算。' })
183
+ checks.unknownBudget = false
184
+ }
185
+ if (current.historyIncomplete && checks.budget.apiExposure) return { accepted: false, reason: 'history-incomplete', ...checks }
170
186
  if (checks.budget.exceeded && options.confirmOverBudget !== true) return { accepted: false, reason: 'budget', ...checks }
171
187
  if (checks.unknownBudget && options.confirmOverBudget !== true) return { accepted: false, reason: 'budget-unknown', ...checks }
172
188
  }
@@ -182,7 +198,7 @@ export function createBudgetReservations({ state, now = Date.now, pid = process.
182
198
  return state.update(current => {
183
199
  const entry = ownedReservation(current, options.reservationId, options.ownerToken)
184
200
  const checks = currentBudget(current, now(), packages, options, entry.id)
185
- if (current.historyIncomplete) return { accepted: false, reason: 'history-incomplete', ...checks }
201
+ if (current.historyIncomplete && checks.budget.apiExposure) return { accepted: false, reason: 'history-incomplete', ...checks }
186
202
  if (checks.budget.exceeded && options.confirmOverBudget !== true) return { accepted: false, reason: 'budget', ...checks }
187
203
  if (checks.unknownBudget && options.confirmOverBudget !== true) return { accepted: false, reason: 'budget-unknown', ...checks }
188
204
  if (packages.some(item => entry.completedIds.includes(item.id))) throw new TypeError('已完成工作包不能重新加入剩余预占。')
@@ -190,6 +206,9 @@ export function createBudgetReservations({ state, now = Date.now, pid = process.
190
206
  for (const previous of entry.packages.filter(item => item.started)) {
191
207
  const next = packages.find(item => item.id === previous.id)
192
208
  if (!next) throw new TypeError('执行中的工作包必须先保存结果,才能释放其预占。')
209
+ if (previous.billing !== 'subscription' && next.billing === 'subscription') {
210
+ throw new TypeError('执行中的 API 调用不能通过改为订阅释放金额预占;请先保存收据。')
211
+ }
193
212
  next.started = true
194
213
  }
195
214
  entry.packages = packages
@@ -224,7 +243,7 @@ export function createBudgetReservations({ state, now = Date.now, pid = process.
224
243
  item.ran = true
225
244
  item.costUsd = null
226
245
  item.costSource = 'interrupted-usage-unknown'
227
- item.billing = item.billing === 'subscription' ? 'subscription' : 'api'
246
+ item.billing = pending.billing === 'subscription' || item.billing === 'subscription' ? 'subscription' : 'api'
228
247
  item.finishedAt = at
229
248
  }
230
249
  run.interruption = { at, unreconciled: true, pendingPackageIds: entry.packages.map(item => item.id),