@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.
- 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 +10 -0
- package/README.i18n.yaml +2 -2
- package/README.md +25 -20
- package/README.zh.md +22 -18
- package/docs/WORKBENCH_USER_GUIDE.zh.md +8 -6
- package/docs/assets/workbench-subscription-usage.png +0 -0
- package/docs/releases/0.25.0-validation.md +41 -0
- package/package.json +7 -3
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** API money and subscription quantities are separate dimensions. */
|
|
2
|
+
import { plannedPackages } from './run-ledger.mjs'
|
|
3
|
+
import { billingPlan } from './subscription-billing.mjs'
|
|
4
|
+
import { toolForProvider } from './official-tool-registry.mjs'
|
|
5
|
+
import { estimateAssignedPackage } from './router.mjs'
|
|
6
|
+
|
|
7
|
+
export function initialFunding(route, { billing = null, officialCli = false, toolId = null,
|
|
8
|
+
loginBillingFor = () => 'unknown', apiKeyFor = () => false, forceApi = false, executionChannel = null } = {}) {
|
|
9
|
+
const id = toolId ?? toolForProvider(route?.provider)?.id
|
|
10
|
+
if (forceApi) return { billing: 'api', route }
|
|
11
|
+
if (officialCli) return { billing: !apiKeyFor(id) && loginBillingFor(id) === 'subscription' ? 'subscription' : 'api', route }
|
|
12
|
+
if (!billing) return { billing: 'api', route }
|
|
13
|
+
const policy = billingPlan(route, billing.routes ?? [])
|
|
14
|
+
if (policy.mode === 'subscription-only') return { billing: 'subscription', route }
|
|
15
|
+
if (policy.mode === 'api-only' || billing.choice === 'api') return { billing: 'api', route: policy.apiRoute ?? route }
|
|
16
|
+
const sub = policy.subscription
|
|
17
|
+
if (!sub) return { billing: 'api', route }
|
|
18
|
+
if (billing.quota?.status?.(sub.key)) return { billing: 'api', route: policy.apiRoute ?? route }
|
|
19
|
+
if (sub.kind === 'plan-key') return { billing: 'subscription', route }
|
|
20
|
+
if (billing.choice === 'subscription') return { billing: 'subscription', route }
|
|
21
|
+
const login = billing.loginBillingFor?.(sub.toolId) ?? loginBillingFor(sub.toolId)
|
|
22
|
+
return { billing: route?.execution === 'api' || executionChannel === 'harness-llm'
|
|
23
|
+
|| login !== 'subscription' ? 'api' : 'subscription', route }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function attachPlanAccounting(plan, task, routes = [], context = {}) {
|
|
27
|
+
const entries = plannedPackages(plan, task).map((item, index) => {
|
|
28
|
+
const selected = item.manualAssignment?.kind === 'api' ? item.manualAssignment
|
|
29
|
+
: { provider: item.recommendedProvider, model: item.recommendedModel }
|
|
30
|
+
const route = routes.find(route => route.provider === selected.provider && route.model === selected.model) ?? selected
|
|
31
|
+
const funding = initialFunding(route, { ...context, executionChannel: item.executionChannel ?? plan.executionChannel,
|
|
32
|
+
...(context.billingChoices?.[item.id] && context.billing ? { billing: { ...context.billing, choice: context.billingChoices[item.id] } } : {}),
|
|
33
|
+
...(item.manualAssignment?.kind === 'api' ? { forceApi: true } : {}),
|
|
34
|
+
...(item.manualAssignment?.kind === 'tool' ? { officialCli: true, toolId: item.manualAssignment.toolId } : {}) })
|
|
35
|
+
const resource = plan.costBreakdown?.[index] ?? estimateAssignedPackage(route, item, task, plan.complexity?.band ?? 'balanced')
|
|
36
|
+
const changed = funding.route?.provider !== route.provider || funding.route?.model !== route.model
|
|
37
|
+
const estimate = changed || plan.subscriptionPricedRoutes?.includes(`${route.provider}/${route.model}`)
|
|
38
|
+
? estimateAssignedPackage(funding.route, item, task, plan.complexity?.band ?? 'balanced').estimatedCost
|
|
39
|
+
: item.estimatedCost ?? null
|
|
40
|
+
return { id: item.id, billing: funding.billing,
|
|
41
|
+
apiEstimatedCostUsd: funding.billing === 'subscription' ? 0 : estimate,
|
|
42
|
+
estimatedUsage: { inputTokens: resource.inputTokens ?? null, outputTokens: resource.outputTokens ?? null } }
|
|
43
|
+
})
|
|
44
|
+
const api = entries.filter(item => item.billing === 'api')
|
|
45
|
+
plan.accounting = { packages: entries, apiExposure: api.length > 0,
|
|
46
|
+
apiEstimatedCostUsd: api.some(item => !Number.isFinite(item.apiEstimatedCostUsd)) ? null
|
|
47
|
+
: api.reduce((sum, item) => sum + item.apiEstimatedCostUsd, 0) }
|
|
48
|
+
return plan.accounting
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const accountingEstimates = plan => plan.accounting.packages.map(item => ({ id: item.id,
|
|
52
|
+
billing: item.billing, estimatedCostUsd: item.apiEstimatedCostUsd }))
|
|
53
|
+
|
|
54
|
+
/** Zero marginal API expense for subscription routes; never rewrite real prices. */
|
|
55
|
+
export function planningFundingPrices(routes, context, channelForRoute = () => null) {
|
|
56
|
+
const subscriptionPricedRoutes = routes.filter(route => initialFunding(route, {
|
|
57
|
+
...context, executionChannel: channelForRoute(route),
|
|
58
|
+
}).billing === 'subscription').map(route => `${route.provider}/${route.model}`)
|
|
59
|
+
return { subscriptionPricedRoutes, pricing: Object.fromEntries(subscriptionPricedRoutes.map(key => [key,
|
|
60
|
+
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }])) }
|
|
61
|
+
}
|
|
@@ -10,7 +10,7 @@ export async function beginExecutionLifecycle({ state, buildRun, packageEstimate
|
|
|
10
10
|
const manager = createBudgetReservations({ state })
|
|
11
11
|
await manager.recoverInterruptedExecutions()
|
|
12
12
|
const started = new Map(), results = new Map(), completed = new Set()
|
|
13
|
-
const estimates = new Map(packageEstimates.map(item => [item.id, item.estimatedCostUsd ?? item.estimatedCost ?? null]))
|
|
13
|
+
const estimates = new Map(packageEstimates.map(item => [item.id, { estimatedCostUsd: item.estimatedCostUsd ?? item.estimatedCost ?? null, billing: item.billing ?? 'api' }]))
|
|
14
14
|
let workspace = {}, poisoned = null, closed = false
|
|
15
15
|
const current = (status = 'running') => {
|
|
16
16
|
const receipts = [...started].map(([id, item]) => results.get(id) ?? {
|
|
@@ -24,6 +24,7 @@ export async function beginExecutionLifecycle({ state, buildRun, packageEstimate
|
|
|
24
24
|
for (const item of run.packages ?? []) {
|
|
25
25
|
if (!started.has(item.id) || completed.has(item.id)) continue
|
|
26
26
|
item.status = 'running'; item.inFlight = true
|
|
27
|
+
if (!results.has(item.id) || results.get(item.id)?.notStarted) item.billing = estimates.get(item.id)?.billing ?? 'api'
|
|
27
28
|
if (!results.has(item.id)) Object.assign(item, { ran: false, costUsd: null, costSource: 'pending-receipt' })
|
|
28
29
|
}
|
|
29
30
|
}
|
|
@@ -65,6 +66,7 @@ export async function beginExecutionLifecycle({ state, buildRun, packageEstimate
|
|
|
65
66
|
id: initial.id,
|
|
66
67
|
current,
|
|
67
68
|
results: () => [...results.values()],
|
|
69
|
+
reservationFor: id => estimates.get(id),
|
|
68
70
|
activePackageId: () => [...started.keys()].findLast(id => !completed.has(id)),
|
|
69
71
|
async onCheckpointStart(item) {
|
|
70
72
|
const previous = started.get(item.id)
|
|
@@ -117,9 +119,9 @@ export async function beginExecutionLifecycle({ state, buildRun, packageEstimate
|
|
|
117
119
|
},
|
|
118
120
|
async reserveExtra(id, estimatedCostUsd, { confirmOverBudget: extraConfirmation = false } = {}) {
|
|
119
121
|
return required(async () => {
|
|
120
|
-
const next = new Map(estimates); next.set(id, estimatedCostUsd)
|
|
122
|
+
const next = new Map(estimates); next.set(id, { estimatedCostUsd, billing: 'api' })
|
|
121
123
|
const changed = await manager.resizeExecution({ ...identity,
|
|
122
|
-
packageEstimates: [...next].filter(([key]) => !completed.has(key)).map(([key, value]) => ({ id: key,
|
|
124
|
+
packageEstimates: [...next].filter(([key]) => !completed.has(key)).map(([key, value]) => ({ id: key, ...value })),
|
|
123
125
|
...settings, confirmOverBudget: extraConfirmation === true })
|
|
124
126
|
if (changed.accepted) { estimates.clear(); for (const [key, value] of next) estimates.set(key, value) }
|
|
125
127
|
return changed
|
|
@@ -515,6 +515,11 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
|
|
|
515
515
|
actualModel: reportedModel,
|
|
516
516
|
...(result.reportedModel !== undefined ? { reportedModel: result.reportedModel } : {}),
|
|
517
517
|
...(result.usageIncomplete === true ? { usageIncomplete: true } : {}),
|
|
518
|
+
...(result.notStarted === true || result.status === 'unsupported' ? { notStarted: true } : {}),
|
|
519
|
+
...(result.blocked === true ? { blocked: true } : {}),
|
|
520
|
+
...(['api', 'subscription'].includes(result.billing) ? { billing: result.billing } : {}),
|
|
521
|
+
...(['configured-api-key', 'process-environment', 'cli-session'].includes(result.credentialSource)
|
|
522
|
+
? { credentialSource: result.credentialSource } : {}),
|
|
518
523
|
...(typeof result.source === 'string' ? { source: result.source } : {}),
|
|
519
524
|
...(typeof result.sandboxEnforcement === 'string' ? { sandboxEnforcement: result.sandboxEnforcement } : {}),
|
|
520
525
|
executionEnvironment,
|
|
@@ -18,10 +18,12 @@ import { homedir } from 'node:os'
|
|
|
18
18
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
19
19
|
import { archiveSpending, sanitizeArchivedSpending } from './run-ledger.mjs'
|
|
20
20
|
import { sanitizeBudgetReservations } from './budget-reservations.mjs'
|
|
21
|
+
import { archiveSubscriptionUsage, invalidArchivedSubscriptionUsage, invalidSubscriptionAttempts,
|
|
22
|
+
sanitizeArchivedSubscriptionUsage } from './subscription-usage.mjs'
|
|
21
23
|
|
|
22
24
|
export const MAX_RUNS = 200
|
|
23
|
-
// Older Hosts must refuse
|
|
24
|
-
const STATE_VERSION =
|
|
25
|
+
// Older Hosts must refuse v3 instead of dropping the separate usage archive.
|
|
26
|
+
const STATE_VERSION = 3
|
|
25
27
|
|
|
26
28
|
/** Same DSH home rule as the Host: DSH_HOME (with ~ expansion) or ~/.dsh. */
|
|
27
29
|
export function resolveStateHome(env = process.env, home = homedir(), cwd = process.cwd()) {
|
|
@@ -43,7 +45,10 @@ const LOCK_STALE_MS = 15_000
|
|
|
43
45
|
const LOCK_TIMEOUT_MS = 10_000
|
|
44
46
|
|
|
45
47
|
function emptyState() {
|
|
46
|
-
return { version: STATE_VERSION, historyIncomplete: false,
|
|
48
|
+
return { version: STATE_VERSION, historyIncomplete: false, subscriptionUsageIncomplete: false,
|
|
49
|
+
onboarding: { completedAt: null }, health: null, quota: {}, authFailures: {}, runs: [], budgetReservations: {},
|
|
50
|
+
dynamicData: { liveBench: null, pricing: null, status: {}, revision: '' }, archivedSpending: sanitizeArchivedSpending(),
|
|
51
|
+
archivedSubscriptionUsage: sanitizeArchivedSubscriptionUsage(), notices: [], terminalSessions: [] }
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
function sanitizeNotices(value) {
|
|
@@ -63,6 +68,7 @@ function retainRuns(state, maxRuns) {
|
|
|
63
68
|
return true
|
|
64
69
|
})
|
|
65
70
|
state.archivedSpending = archiveSpending(evicted, state.archivedSpending)
|
|
71
|
+
state.archivedSubscriptionUsage = archiveSubscriptionUsage(evicted, state.archivedSubscriptionUsage)
|
|
66
72
|
return state
|
|
67
73
|
}
|
|
68
74
|
|
|
@@ -103,7 +109,7 @@ function invalidRunSpending(run) {
|
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
function sanitize(value, maxRuns) {
|
|
106
|
-
if (!value || typeof value !== 'object' || Array.isArray(value) || ![1, STATE_VERSION].includes(value.version)
|
|
112
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || ![1, 2, STATE_VERSION].includes(value.version)
|
|
107
113
|
|| !Array.isArray(value.runs)) {
|
|
108
114
|
const error = new Error('模型路由状态版本或结构不受支持;原文件未修改,请先恢复或升级兼容版本。')
|
|
109
115
|
error.code = 'ROUTER_STATE_UNSUPPORTED'
|
|
@@ -119,6 +125,11 @@ function sanitize(value, maxRuns) {
|
|
|
119
125
|
|| invalidArchivedSpending(value.archivedSpending) || runs.some(invalidRunSpending) || held.invalid
|
|
120
126
|
|| Object.values(held.reservations).some(entry => !runs.some(run => run.id === entry.runId))
|
|
121
127
|
|| sanitizeNotices(value.notices).some(notice => notice.kind === 'state-corrupt'),
|
|
128
|
+
subscriptionUsageIncomplete: value.subscriptionUsageIncomplete === true
|
|
129
|
+
|| invalidArchivedSubscriptionUsage(value.archivedSubscriptionUsage)
|
|
130
|
+
|| runs.some(run => invalidArchivedSubscriptionUsage(run.priorAttemptSubscriptionUsage)
|
|
131
|
+
|| [...(Array.isArray(run.packages) ? run.packages : []), ...(Array.isArray(run.reviews) ? run.reviews : [])]
|
|
132
|
+
.some(item => invalidSubscriptionAttempts(item?.subscriptionAttempts))),
|
|
122
133
|
onboarding: { completedAt: Number.isFinite(value.onboarding?.completedAt) ? value.onboarding.completedAt : null },
|
|
123
134
|
health: value.health && Array.isArray(value.health.tools) ? value.health : null,
|
|
124
135
|
quota: value.quota && typeof value.quota === 'object' && !Array.isArray(value.quota) ? value.quota : {},
|
|
@@ -128,6 +139,8 @@ function sanitize(value, maxRuns) {
|
|
|
128
139
|
budgetReservations: held.reservations,
|
|
129
140
|
dynamicData: value.dynamicData && typeof value.dynamicData === 'object' && !Array.isArray(value.dynamicData) ? value.dynamicData : { liveBench: null, pricing: null, status: {}, revision: '' },
|
|
130
141
|
archivedSpending: sanitizeArchivedSpending(value.archivedSpending),
|
|
142
|
+
archivedSubscriptionUsage: sanitizeArchivedSubscriptionUsage(value.archivedSubscriptionUsage,
|
|
143
|
+
{ legacySpending: value.archivedSpending }),
|
|
131
144
|
notices: sanitizeNotices(value.notices),
|
|
132
145
|
terminalSessions: sanitizeTerminalSessions(value.terminalSessions),
|
|
133
146
|
}, maxRuns)
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { reasoningExecutionFields, unsupportedCliReasoning } from './reasoning-execution.mjs'
|
|
8
8
|
import { packageSelectionFields } from './package-assignments.mjs'
|
|
9
|
+
import { archiveSubscriptionUsage, sanitizeArchivedSubscriptionUsage, sanitizeSubscriptionAttempts } from './subscription-usage.mjs'
|
|
9
10
|
|
|
10
11
|
export const MAX_STORED_ANSWER = 4_000
|
|
11
12
|
export const MAX_STORED_TASK = 20_000
|
|
@@ -128,7 +129,7 @@ export function actualCost(result, pricing) {
|
|
|
128
129
|
export function billingOf(result, { loginBilling = null, apiKeyPresent = false } = {}) {
|
|
129
130
|
if (!result || result.blocked) return null
|
|
130
131
|
// The subscription-first executor states the channel it billed explicitly.
|
|
131
|
-
if (result.billing === 'api' || result.billing === 'subscription') return result.billing
|
|
132
|
+
if (result.billing === 'api' || result.billing === 'subscription' || result.billing === 'unknown') return result.billing
|
|
132
133
|
if (result.channel !== 'official-cli') return 'api'
|
|
133
134
|
if (result.credentialSource === 'configured-api-key' || result.credentialSource === 'process-environment') return 'api'
|
|
134
135
|
if (!result.credentialSource && apiKeyPresent) return 'api'
|
|
@@ -137,6 +138,7 @@ export function billingOf(result, { loginBilling = null, apiKeyPresent = false }
|
|
|
137
138
|
|
|
138
139
|
/** Cost fields for storage: subscription runs keep the figure as `referenceCostUsd` only. */
|
|
139
140
|
export function billedCost(result, pricing, billing) {
|
|
141
|
+
if (billing === 'unknown') return { costUsd: null, costSource: 'billing-unknown', billing, referenceCostUsd: null }
|
|
140
142
|
const cost = actualCost(result, pricing)
|
|
141
143
|
if (billing !== 'subscription') return { ...cost, billing: billing ?? 'api', referenceCostUsd: null }
|
|
142
144
|
return { costUsd: null, costSource: cost.costSource, billing, referenceCostUsd: cost.costUsd }
|
|
@@ -373,6 +375,7 @@ function storedPackage(planned, result, pricing, finishedAt, billingFor = billin
|
|
|
373
375
|
...(result?.billingMode ? { billingMode: String(result.billingMode) } : {}),
|
|
374
376
|
...(result?.billingSwitch ? { billingSwitch: { ...result.billingSwitch } } : {}),
|
|
375
377
|
...(result?.subscriptionRoute ? { subscriptionRoute: { provider: String(result.subscriptionRoute.provider ?? ''), model: String(result.subscriptionRoute.model ?? '') } } : {}),
|
|
378
|
+
...(result?.subscriptionAttempts !== undefined ? { subscriptionAttempts: sanitizeSubscriptionAttempts(result.subscriptionAttempts) } : {}),
|
|
376
379
|
error: result?.ok ? null : (result?.error ?? null),
|
|
377
380
|
...(!result?.ok && result?.diagnostic ? { diagnostic: String(result.diagnostic).slice(0, 2_000) } : {}),
|
|
378
381
|
...(Number.isInteger(result?.exitCode) ? { exitCode: result.exitCode } : {}),
|
|
@@ -443,6 +446,8 @@ export function mergeRerun(run, execution, { rerunIds = [], pricingFor = () => n
|
|
|
443
446
|
// Retrying replaces the visible result, but earlier attempts still incurred
|
|
444
447
|
// charges (or unknown usage). Keep their calendar totals before replacement.
|
|
445
448
|
const replaced = run.packages.filter(stored => retried.has(stored.id) && results.some(item => item.id === stored.id))
|
|
449
|
+
run.priorAttemptSubscriptionUsage = archiveSubscriptionUsage([{ createdAt: run.createdAt, packages: replaced }],
|
|
450
|
+
sanitizeArchivedSubscriptionUsage(run.priorAttemptSubscriptionUsage, { legacySpending: run.priorAttemptSpending }))
|
|
446
451
|
run.priorAttemptSpending = archiveSpending([{ createdAt: run.createdAt, packages: replaced }], run.priorAttemptSpending)
|
|
447
452
|
run.packages = run.packages.map(stored => {
|
|
448
453
|
const result = results.find(item => item.id === stored.id)
|
|
@@ -460,6 +465,7 @@ export function storedResults(run) {
|
|
|
460
465
|
id: item.id, name: item.name, ok: item.ok, provider: item.provider, model: item.model,
|
|
461
466
|
channel: item.channel, answer: item.answer, error: item.error, fallback: item.fallback,
|
|
462
467
|
blocked: item.blocked, finishedAt: item.finishedAt,
|
|
468
|
+
...(item.subscriptionAttempts !== undefined ? { subscriptionAttempts: sanitizeSubscriptionAttempts(item.subscriptionAttempts) } : {}),
|
|
463
469
|
...packageSelectionFields(item),
|
|
464
470
|
...reasoningExecutionFields(item),
|
|
465
471
|
...(finite(item.durationMs) && item.durationMs >= 0 ? { durationMs: item.durationMs } : {}),
|
|
@@ -483,6 +489,10 @@ export function teamExecutionResults(plan, execution) {
|
|
|
483
489
|
id: result.id,
|
|
484
490
|
ok: result.status === 'succeeded',
|
|
485
491
|
cancelled: result.status === 'cancelled',
|
|
492
|
+
...(result.notStarted === true || result.status === 'unsupported' ? { notStarted: true } : {}),
|
|
493
|
+
...(result.blocked === true ? { blocked: true } : {}),
|
|
494
|
+
...(result.billing === 'api' || result.billing === 'subscription' || result.billing === 'unknown' ? { billing: result.billing } : {}),
|
|
495
|
+
...(typeof result.credentialSource === 'string' ? { credentialSource: result.credentialSource } : {}),
|
|
486
496
|
provider: result.provider,
|
|
487
497
|
model: result.recommendedModel,
|
|
488
498
|
...unsupportedCliReasoning({ reasoningEffort: result.recommendedReasoningEffort }),
|
|
@@ -497,6 +507,7 @@ export function teamExecutionResults(plan, execution) {
|
|
|
497
507
|
...(typeof result.source === 'string' ? { source: result.source } : {}),
|
|
498
508
|
...(typeof result.sandboxEnforcement === 'string' ? { sandboxEnforcement: result.sandboxEnforcement } : {}),
|
|
499
509
|
...(result.usageIncomplete === true ? { usageIncomplete: true } : {}),
|
|
510
|
+
...(result.subscriptionAttempts !== undefined ? { subscriptionAttempts: sanitizeSubscriptionAttempts(result.subscriptionAttempts) } : {}),
|
|
500
511
|
...(typeof result.executionEnvironment === 'string' ? { executionEnvironment: result.executionEnvironment } : {}),
|
|
501
512
|
channel: 'official-cli',
|
|
502
513
|
toolId: result.toolId ?? null,
|
|
@@ -545,6 +556,8 @@ export function buildToolRunRecord({ toolId, toolLabel, provider, model, cliMode
|
|
|
545
556
|
id: packageId, ok: succeeded, cancelled: result?.status === 'cancelled',
|
|
546
557
|
...(result?.notStarted === true || result?.status === 'unsupported' ? { notStarted: true } : {}),
|
|
547
558
|
...(result?.blocked === true ? { blocked: true } : {}),
|
|
559
|
+
...(result?.billing === 'api' || result?.billing === 'subscription' || result?.billing === 'unknown' ? { billing: result.billing } : {}),
|
|
560
|
+
...(typeof result?.credentialSource === 'string' ? { credentialSource: result.credentialSource } : {}),
|
|
548
561
|
provider: plan.directRoute.provider, model: plan.directRoute.model,
|
|
549
562
|
channel: 'official-cli', toolId,
|
|
550
563
|
requestedCliModel: Object.hasOwn(result ?? {}, 'requestedModel') ? result.requestedModel : cliModel,
|
|
@@ -554,6 +567,7 @@ export function buildToolRunRecord({ toolId, toolLabel, provider, model, cliMode
|
|
|
554
567
|
...(typeof result?.source === 'string' ? { source: result.source } : {}),
|
|
555
568
|
...(typeof result?.sandboxEnforcement === 'string' ? { sandboxEnforcement: result.sandboxEnforcement } : {}),
|
|
556
569
|
...(result?.usageIncomplete === true ? { usageIncomplete: true } : {}),
|
|
570
|
+
...(result?.subscriptionAttempts !== undefined ? { subscriptionAttempts: sanitizeSubscriptionAttempts(result.subscriptionAttempts) } : {}),
|
|
557
571
|
...(receiptTimestamp(result?.finishedAt) ? { finishedAt: result.finishedAt } : {}),
|
|
558
572
|
answer: String(result?.finalText ?? ''),
|
|
559
573
|
error: succeeded ? null : String(result?.error ?? result?.reason ?? result?.status ?? '官方 CLI 未成功完成。'),
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/** Subscription quantities are evidence of use, never API dollars or remaining quota. */
|
|
2
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
3
|
+
const counter = value => Number.isSafeInteger(value) && value >= 0
|
|
4
|
+
const timestamp = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && Number.isFinite(new Date(value).getTime())
|
|
5
|
+
const clean = value => typeof value === 'string' && value.length <= 512 && !/[\u0000-\u001f\u007f]/u.test(value) ? value : null
|
|
6
|
+
const TOKENS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']
|
|
7
|
+
const COUNTS = ['calls', 'knownUsageCalls', 'unknownUsageCalls']
|
|
8
|
+
const pad = value => String(value).padStart(2, '0')
|
|
9
|
+
const dayKey = at => { const d = new Date(at); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` }
|
|
10
|
+
const monthKey = at => { const d = new Date(at); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}` }
|
|
11
|
+
const DAYS = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/u
|
|
12
|
+
const MONTHS = /^\d{4}-(?:0[1-9]|1[0-2])$/u
|
|
13
|
+
export const MAX_SUBSCRIPTION_USAGE_ROUTES = 256
|
|
14
|
+
|
|
15
|
+
export const emptySubscriptionUsage = () => ({ calls: 0, knownUsageCalls: 0, unknownUsageCalls: 0,
|
|
16
|
+
inputTokens: null, outputTokens: null, cacheReadTokens: null, cacheWriteTokens: null })
|
|
17
|
+
const period = value => ({ ...Object.fromEntries(COUNTS.map(field => [field, counter(value?.[field]) ? value[field] : 0])),
|
|
18
|
+
...Object.fromEntries(TOKENS.map(field => [field, counter(value?.[field]) ? value[field] : null])) })
|
|
19
|
+
const identity = value => ({ provider: clean(value?.provider) || '未知供应商', toolId: clean(value?.toolId), model: clean(value?.model) })
|
|
20
|
+
const keyOf = value => JSON.stringify([value.provider, value.toolId, value.model])
|
|
21
|
+
const emptyBucket = () => ({ total: emptySubscriptionUsage(), byRoute: {} })
|
|
22
|
+
const overflowRoute = { provider: '其他(已合并)', toolId: null, model: null }
|
|
23
|
+
|
|
24
|
+
function addPeriod(target, source) {
|
|
25
|
+
for (const field of COUNTS) target[field] += source[field]
|
|
26
|
+
for (const field of TOKENS) {
|
|
27
|
+
if (source[field] === null) continue
|
|
28
|
+
const total = (target[field] ?? 0) + source[field]
|
|
29
|
+
// A quantity that cannot be represented exactly is not a reported zero.
|
|
30
|
+
target[field] = counter(total) ? total : null
|
|
31
|
+
}
|
|
32
|
+
return target
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function addRoute(bucket, route, value) {
|
|
36
|
+
let id = keyOf(route)
|
|
37
|
+
if (!Object.hasOwn(bucket.byRoute, id) && Object.keys(bucket.byRoute).length >= MAX_SUBSCRIPTION_USAGE_ROUTES - 1) {
|
|
38
|
+
route = overflowRoute; id = keyOf(route)
|
|
39
|
+
}
|
|
40
|
+
const row = bucket.byRoute[id] ??= { ...route, usage: emptySubscriptionUsage() }
|
|
41
|
+
addPeriod(row.usage, value)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function addBucket(target, source) {
|
|
45
|
+
addPeriod(target.total, source.total)
|
|
46
|
+
for (const row of Object.values(source.byRoute)) addRoute(target, identity(row), row.usage)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Legacy calendar records kept counts, but discarded every token quantity. */
|
|
50
|
+
function legacyArchive(spending) {
|
|
51
|
+
const result = { days: {}, months: {} }
|
|
52
|
+
for (const [kind, pattern] of [['days', DAYS], ['months', MONTHS]]) {
|
|
53
|
+
for (const [key, value] of Object.entries(record(spending?.[kind]) ? spending[kind] : {})) {
|
|
54
|
+
if (!pattern.test(key) || !counter(value?.subscriptionRuns) || value.subscriptionRuns === 0) continue
|
|
55
|
+
const usage = { ...emptySubscriptionUsage(), calls: value.subscriptionRuns, unknownUsageCalls: value.subscriptionRuns }
|
|
56
|
+
const bucket = result[kind][key] = { total: usage, byRoute: {} }
|
|
57
|
+
addRoute(bucket, { provider: '历史订阅(用量未保存)', toolId: null, model: null }, usage)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return result
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Bounded quantities and route identifiers only; no answers, prompts, prices or credentials. */
|
|
64
|
+
export function sanitizeArchivedSubscriptionUsage(value, { legacySpending } = {}) {
|
|
65
|
+
if (value === undefined || value === null) value = legacyArchive(legacySpending)
|
|
66
|
+
const result = { days: {}, months: {} }
|
|
67
|
+
for (const [kind, pattern, limit] of [['days', DAYS, 90], ['months', MONTHS, 24]]) {
|
|
68
|
+
const entries = Object.entries(record(value?.[kind]) ? value[kind] : {}).filter(([key, bucket]) => pattern.test(key) && record(bucket))
|
|
69
|
+
.sort(([a], [b]) => a.localeCompare(b)).slice(-limit)
|
|
70
|
+
for (const [key, bucket] of entries) {
|
|
71
|
+
const next = result[kind][key] = { total: period(bucket.total), byRoute: {} }
|
|
72
|
+
for (const row of Object.values(record(bucket.byRoute) ? bucket.byRoute : {})) {
|
|
73
|
+
if (!record(row)) continue
|
|
74
|
+
addRoute(next, identity(row), period(row.usage))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return result
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function invalidArchivedSubscriptionUsage(value) {
|
|
82
|
+
if (value === undefined) return false
|
|
83
|
+
if (!record(value)) return true
|
|
84
|
+
const invalidPeriod = value => !record(value)
|
|
85
|
+
|| COUNTS.some(field => !counter(value[field]))
|
|
86
|
+
|| TOKENS.some(field => value[field] !== null && !counter(value[field]))
|
|
87
|
+
|| value.knownUsageCalls + value.unknownUsageCalls !== value.calls
|
|
88
|
+
for (const [kind, pattern] of [['days', DAYS], ['months', MONTHS]]) {
|
|
89
|
+
if (!record(value[kind])) return true
|
|
90
|
+
for (const [key, bucket] of Object.entries(value[kind])) {
|
|
91
|
+
if (!pattern.test(key) || !record(bucket) || invalidPeriod(bucket.total) || !record(bucket.byRoute)) return true
|
|
92
|
+
for (const row of Object.values(bucket.byRoute)) {
|
|
93
|
+
if (!record(row) || !clean(row.provider) || (row.toolId !== null && !clean(row.toolId))
|
|
94
|
+
|| (row.model !== null && !clean(row.model)) || invalidPeriod(row.usage)) return true
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Only these fields from a subscription receipt can enter the durable ledger. */
|
|
102
|
+
export function sanitizeSubscriptionAttempts(value) {
|
|
103
|
+
if (value === undefined) return undefined
|
|
104
|
+
if (!Array.isArray(value)) return [{ usage: null, usageIncomplete: true, unknownEvidence: true }]
|
|
105
|
+
const attempts = value.slice(0, 16).map(item => {
|
|
106
|
+
if (!record(item)) return { usage: null, usageIncomplete: true, unknownEvidence: true }
|
|
107
|
+
return { ...Object.fromEntries(['provider', 'toolId', 'model'].filter(field => Object.hasOwn(item, field))
|
|
108
|
+
.map(field => [field, clean(item[field])])),
|
|
109
|
+
usage: record(item.usage)
|
|
110
|
+
? Object.fromEntries(TOKENS.filter(field => Object.hasOwn(item.usage, field)).map(field => [field,
|
|
111
|
+
counter(item.usage[field]) ? item.usage[field] : null])) : null,
|
|
112
|
+
...(item.ran === false ? { ran: false } : {}), ...(item.notStarted === true ? { notStarted: true } : {}),
|
|
113
|
+
...(item.blocked === true ? { blocked: true } : {}),
|
|
114
|
+
...(item.usageIncomplete === true || record(item.usage) && TOKENS.some(field => item.usage[field] !== undefined
|
|
115
|
+
&& item.usage[field] !== null && !counter(item.usage[field])) ? { usageIncomplete: true } : {}),
|
|
116
|
+
...(timestamp(item.finishedAt) ? { finishedAt: item.finishedAt } : {}),
|
|
117
|
+
...(item.unknownEvidence === true ? { unknownEvidence: true } : {}) }
|
|
118
|
+
})
|
|
119
|
+
if (value.length > 16) attempts.push({ usage: null, usageIncomplete: true, unknownEvidence: true })
|
|
120
|
+
return attempts
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function invalidSubscriptionAttempts(value) {
|
|
124
|
+
if (value === undefined) return false
|
|
125
|
+
return !Array.isArray(value) || value.length > 16 || value.some(item => !record(item) || item.unknownEvidence === true
|
|
126
|
+
|| (item.usage !== undefined && item.usage !== null && (!record(item.usage)
|
|
127
|
+
|| TOKENS.some(field => item.usage[field] !== undefined && item.usage[field] !== null && !counter(item.usage[field])))))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function began(item) {
|
|
131
|
+
const contradictory = record(item.usage) && TOKENS.some(field => item.usage[field] !== undefined
|
|
132
|
+
&& item.usage[field] !== null && item.usage[field] !== 0)
|
|
133
|
+
return contradictory || !(item.ran === false || item.notStarted === true || item.blocked === true)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function* packageEntries(item, fallbackAt) {
|
|
137
|
+
if (!record(item)) return
|
|
138
|
+
// An explicit vector is authoritative: an API result can still include its
|
|
139
|
+
// earlier subscription attempt; [] means no subscription was attempted.
|
|
140
|
+
const attempts = Object.hasOwn(item, 'subscriptionAttempts') ? sanitizeSubscriptionAttempts(item.subscriptionAttempts)
|
|
141
|
+
: item.billing === 'subscription' && item.ran === true ? [item] : []
|
|
142
|
+
for (const attempt of attempts) {
|
|
143
|
+
if (!began(attempt)) continue
|
|
144
|
+
const usage = record(attempt.usage) ? attempt.usage : null
|
|
145
|
+
const complete = attempt.usageIncomplete !== true && attempt.unknownEvidence !== true
|
|
146
|
+
&& counter(usage?.inputTokens) && counter(usage?.outputTokens)
|
|
147
|
+
&& TOKENS.every(field => usage[field] === undefined || usage[field] === null || counter(usage[field]))
|
|
148
|
+
const value = { ...emptySubscriptionUsage(), calls: 1, knownUsageCalls: complete ? 1 : 0, unknownUsageCalls: complete ? 0 : 1 }
|
|
149
|
+
for (const field of TOKENS) if (counter(usage?.[field])) value[field] = usage[field]
|
|
150
|
+
const when = timestamp(attempt.finishedAt) ? attempt.finishedAt : timestamp(item.finishedAt) ? item.finishedAt : fallbackAt
|
|
151
|
+
yield { when, route: identity(Object.fromEntries(['provider', 'toolId', 'model'].map(field => [field,
|
|
152
|
+
Object.hasOwn(attempt, field) ? attempt[field] : item[field]]))), usage: value }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function* usageEntries(runs) {
|
|
157
|
+
for (const run of Array.isArray(runs) ? runs : []) {
|
|
158
|
+
for (const item of [...(Array.isArray(run?.packages) ? run.packages : []), ...(Array.isArray(run?.reviews) ? run.reviews : [])]) {
|
|
159
|
+
yield* packageEntries(item, run?.createdAt)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function addArchive(target, source) {
|
|
165
|
+
for (const kind of ['days', 'months']) {
|
|
166
|
+
for (const [key, bucket] of Object.entries(source[kind])) addBucket(target[kind][key] ??= emptyBucket(), bucket)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function priorArchive(run) {
|
|
171
|
+
return sanitizeArchivedSubscriptionUsage(run?.priorAttemptSubscriptionUsage, { legacySpending: run?.priorAttemptSpending })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Fold only evicted/replaced records. Retained records remain live. */
|
|
175
|
+
export function archiveSubscriptionUsage(runs, archived) {
|
|
176
|
+
const result = sanitizeArchivedSubscriptionUsage(archived)
|
|
177
|
+
for (const run of Array.isArray(runs) ? runs : []) addArchive(result, priorArchive(run))
|
|
178
|
+
for (const { when, route, usage } of usageEntries(runs)) {
|
|
179
|
+
if (!timestamp(when)) continue
|
|
180
|
+
for (const [kind, key] of [['days', dayKey(when)], ['months', monthKey(when)]]) {
|
|
181
|
+
const bucket = result[kind][key] ??= emptyBucket()
|
|
182
|
+
addPeriod(bucket.total, usage); addRoute(bucket, route, usage)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return sanitizeArchivedSubscriptionUsage(result)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Known quantities only. Missing usage remains explicit instead of becoming zero. */
|
|
189
|
+
export function subscriptionUsage(runs, at = Date.now(), archived = null) {
|
|
190
|
+
const all = archiveSubscriptionUsage(runs, archived)
|
|
191
|
+
const daily = all.days[dayKey(at)] ?? emptyBucket(), monthly = all.months[monthKey(at)] ?? emptyBucket()
|
|
192
|
+
const keys = new Set([...Object.keys(daily.byRoute), ...Object.keys(monthly.byRoute)])
|
|
193
|
+
return { today: period(daily.total), month: period(monthly.total),
|
|
194
|
+
byRoute: [...keys].map(key => ({ ...identity(monthly.byRoute[key] ?? daily.byRoute[key]),
|
|
195
|
+
today: period(daily.byRoute[key]?.usage), month: period(monthly.byRoute[key]?.usage) })) }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function subscriptionUsageForPackage(item) {
|
|
199
|
+
const result = emptySubscriptionUsage()
|
|
200
|
+
for (const { usage } of packageEntries(item)) addPeriod(result, usage)
|
|
201
|
+
return result
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Includes prior retry attempts, without summing both daily and monthly copies. */
|
|
205
|
+
export function subscriptionUsageForRun(run) {
|
|
206
|
+
const result = emptySubscriptionUsage()
|
|
207
|
+
for (const bucket of Object.values(priorArchive(run).months)) addPeriod(result, bucket.total)
|
|
208
|
+
for (const { usage } of usageEntries([run])) addPeriod(result, usage)
|
|
209
|
+
return result
|
|
210
|
+
}
|