@ljwei-stak/dsh-model-router 0.24.0 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dsh-plugin/client.js +2489 -4459
- package/.dsh-plugin/index.mjs +128 -50
- package/.dsh-plugin/shared/budget-reservations.mjs +26 -7
- package/.dsh-plugin/shared/execution-accounting.mjs +61 -0
- package/.dsh-plugin/shared/execution-lifecycle.mjs +5 -3
- package/.dsh-plugin/shared/official-team-runtime.mjs +5 -0
- package/.dsh-plugin/shared/router-state.mjs +17 -4
- package/.dsh-plugin/shared/run-ledger.mjs +15 -1
- package/.dsh-plugin/shared/subscription-usage.mjs +210 -0
- package/.dsh-plugin/shared/task-executors.mjs +94 -21
- package/CHANGELOG.md +18 -0
- package/README.i18n.yaml +2 -2
- package/README.md +26 -21
- package/README.zh.md +23 -19
- package/docs/INSTALLATION_GUIDE.zh.md +14 -14
- package/docs/WORKBENCH_USER_GUIDE.zh.md +9 -7
- package/docs/assets/workbench-subscription-usage.png +0 -0
- package/docs/releases/0.25.0-validation.md +41 -0
- package/docs/releases/0.25.1-validation.md +36 -0
- package/package.json +65 -60
package/.dsh-plugin/index.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
/**
|
|
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 =
|
|
419
|
-
|
|
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
|
|
422
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
905
|
-
budget = { ...budget, exceeded: budget.exceeded || 'run', estimateUsd: plan.
|
|
906
|
-
message: plan.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
1478
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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),
|