@ljwei-stak/dsh-model-router 0.23.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dsh-plugin/client.js +2534 -4455
- package/.dsh-plugin/index.mjs +387 -79
- package/.dsh-plugin/shared/budget-reservations.mjs +273 -0
- package/.dsh-plugin/shared/execution-accounting.mjs +61 -0
- package/.dsh-plugin/shared/execution-lifecycle.mjs +143 -0
- package/.dsh-plugin/shared/official-team-runtime.mjs +42 -13
- package/.dsh-plugin/shared/official-tools-runtime.mjs +3 -2
- package/.dsh-plugin/shared/router-state.mjs +36 -8
- package/.dsh-plugin/shared/run-ledger.mjs +21 -2
- package/.dsh-plugin/shared/subscription-usage.mjs +210 -0
- package/.dsh-plugin/shared/task-executors.mjs +125 -23
- package/.dsh-plugin/shared/zcode-bundle.mjs +3 -3
- package/CHANGELOG.md +23 -0
- package/README.i18n.yaml +2 -2
- package/README.md +35 -22
- package/README.zh.md +32 -20
- package/docs/WORKBENCH_USER_GUIDE.zh.md +8 -6
- package/docs/assets/workbench-budget-reservations.png +0 -0
- package/docs/assets/workbench-subscription-usage.png +0 -0
- package/docs/releases/0.24.0-validation.md +41 -0
- package/docs/releases/0.25.0-validation.md +41 -0
- package/package.json +11 -3
|
@@ -17,9 +17,13 @@ import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/
|
|
|
17
17
|
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
|
+
import { sanitizeBudgetReservations } from './budget-reservations.mjs'
|
|
21
|
+
import { archiveSubscriptionUsage, invalidArchivedSubscriptionUsage, invalidSubscriptionAttempts,
|
|
22
|
+
sanitizeArchivedSubscriptionUsage } from './subscription-usage.mjs'
|
|
20
23
|
|
|
21
24
|
export const MAX_RUNS = 200
|
|
22
|
-
|
|
25
|
+
// Older Hosts must refuse v3 instead of dropping the separate usage archive.
|
|
26
|
+
const STATE_VERSION = 3
|
|
23
27
|
|
|
24
28
|
/** Same DSH home rule as the Host: DSH_HOME (with ~ expansion) or ~/.dsh. */
|
|
25
29
|
export function resolveStateHome(env = process.env, home = homedir(), cwd = process.cwd()) {
|
|
@@ -41,7 +45,10 @@ const LOCK_STALE_MS = 15_000
|
|
|
41
45
|
const LOCK_TIMEOUT_MS = 10_000
|
|
42
46
|
|
|
43
47
|
function emptyState() {
|
|
44
|
-
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: [] }
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
function sanitizeNotices(value) {
|
|
@@ -51,9 +58,17 @@ function sanitizeNotices(value) {
|
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
function retainRuns(state, maxRuns) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
// A partial run must stay addressable until its hold is reconciled. Archiving
|
|
62
|
+
// it and later upserting a checkpoint would count its receipts twice.
|
|
63
|
+
const protectedIds = new Set(Object.values(state.budgetReservations ?? {}).map(entry => entry.runId))
|
|
64
|
+
let remove = Math.max(0, state.runs.length - Math.max(maxRuns, protectedIds.size))
|
|
65
|
+
const evicted = []
|
|
66
|
+
state.runs = state.runs.filter(run => {
|
|
67
|
+
if (remove > 0 && !protectedIds.has(run.id)) { remove -= 1; evicted.push(run); return false }
|
|
68
|
+
return true
|
|
69
|
+
})
|
|
70
|
+
state.archivedSpending = archiveSpending(evicted, state.archivedSpending)
|
|
71
|
+
state.archivedSubscriptionUsage = archiveSubscriptionUsage(evicted, state.archivedSubscriptionUsage)
|
|
57
72
|
return state
|
|
58
73
|
}
|
|
59
74
|
|
|
@@ -94,28 +109,38 @@ function invalidRunSpending(run) {
|
|
|
94
109
|
}
|
|
95
110
|
|
|
96
111
|
function sanitize(value, maxRuns) {
|
|
97
|
-
if (!value || typeof value !== 'object' || Array.isArray(value) || value.version
|
|
112
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || ![1, 2, STATE_VERSION].includes(value.version)
|
|
98
113
|
|| !Array.isArray(value.runs)) {
|
|
99
114
|
const error = new Error('模型路由状态版本或结构不受支持;原文件未修改,请先恢复或升级兼容版本。')
|
|
100
115
|
error.code = 'ROUTER_STATE_UNSUPPORTED'
|
|
101
116
|
throw error
|
|
102
117
|
}
|
|
103
118
|
const runs = value.runs.filter(run => run && typeof run.id === 'string')
|
|
119
|
+
const held = sanitizeBudgetReservations(value.budgetReservations)
|
|
104
120
|
return retainRuns({
|
|
105
121
|
version: STATE_VERSION,
|
|
106
122
|
// Unlike bounded notices, an incomplete-history marker cannot silently
|
|
107
123
|
// expire. Restoring a verified state file is required to recover budgets.
|
|
108
124
|
historyIncomplete: value.historyIncomplete === true || runs.length !== value.runs.length
|
|
109
|
-
|| invalidArchivedSpending(value.archivedSpending) || runs.some(invalidRunSpending)
|
|
125
|
+
|| invalidArchivedSpending(value.archivedSpending) || runs.some(invalidRunSpending) || held.invalid
|
|
126
|
+
|| Object.values(held.reservations).some(entry => !runs.some(run => run.id === entry.runId))
|
|
110
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))),
|
|
111
133
|
onboarding: { completedAt: Number.isFinite(value.onboarding?.completedAt) ? value.onboarding.completedAt : null },
|
|
112
134
|
health: value.health && Array.isArray(value.health.tools) ? value.health : null,
|
|
113
135
|
quota: value.quota && typeof value.quota === 'object' && !Array.isArray(value.quota) ? value.quota : {},
|
|
114
136
|
authFailures: Object.fromEntries(Object.entries(value.authFailures && typeof value.authFailures === 'object' && !Array.isArray(value.authFailures) ? value.authFailures : {})
|
|
115
137
|
.filter(([, entry]) => Number.isFinite(entry?.at))),
|
|
116
138
|
runs,
|
|
139
|
+
budgetReservations: held.reservations,
|
|
117
140
|
dynamicData: value.dynamicData && typeof value.dynamicData === 'object' && !Array.isArray(value.dynamicData) ? value.dynamicData : { liveBench: null, pricing: null, status: {}, revision: '' },
|
|
118
141
|
archivedSpending: sanitizeArchivedSpending(value.archivedSpending),
|
|
142
|
+
archivedSubscriptionUsage: sanitizeArchivedSubscriptionUsage(value.archivedSubscriptionUsage,
|
|
143
|
+
{ legacySpending: value.archivedSpending }),
|
|
119
144
|
notices: sanitizeNotices(value.notices),
|
|
120
145
|
terminalSessions: sanitizeTerminalSessions(value.terminalSessions),
|
|
121
146
|
}, maxRuns)
|
|
@@ -172,7 +197,10 @@ async function acquireLock(lockFile, { staleMs = LOCK_STALE_MS, timeoutMs = LOCK
|
|
|
172
197
|
if (await readFile(lockFile, 'utf8').catch(() => null) === owner) await unlink(lockFile).catch(() => {})
|
|
173
198
|
}
|
|
174
199
|
} catch (error) {
|
|
175
|
-
|
|
200
|
+
// Windows can report a sharing/delete-pending race as EPERM while
|
|
201
|
+
// another owner closes and removes its lock. Retry within the same
|
|
202
|
+
// bounded wait; never treat this as permission to remove a live lock.
|
|
203
|
+
if (error?.code !== 'EEXIST' && !(process.platform === 'win32' && error?.code === 'EPERM')) throw error
|
|
176
204
|
const info = await stat(lockFile).catch(() => null)
|
|
177
205
|
const owner = await readFile(lockFile, 'utf8').catch(() => null)
|
|
178
206
|
if (info && owner !== null && now() - info.mtimeMs > staleMs && ownerIsDead(ownerPid(owner))) {
|
|
@@ -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
|
|
@@ -13,6 +14,7 @@ export const MAX_STORED_TASK = 20_000
|
|
|
13
14
|
export const MAX_QUALITY_BIAS = 0.04
|
|
14
15
|
|
|
15
16
|
const finite = value => typeof value === 'number' && Number.isFinite(value)
|
|
17
|
+
const receiptTimestamp = value => finite(value) && value >= 0 && Number.isFinite(new Date(value).getTime())
|
|
16
18
|
const nonEmptyText = value => (typeof value === 'string' && value.length > 0 ? value : null)
|
|
17
19
|
const routeKey = (provider, model) => `${String(provider ?? '')}\u0000${String(model ?? '')}`
|
|
18
20
|
|
|
@@ -127,7 +129,7 @@ export function actualCost(result, pricing) {
|
|
|
127
129
|
export function billingOf(result, { loginBilling = null, apiKeyPresent = false } = {}) {
|
|
128
130
|
if (!result || result.blocked) return null
|
|
129
131
|
// The subscription-first executor states the channel it billed explicitly.
|
|
130
|
-
if (result.billing === 'api' || result.billing === 'subscription') return result.billing
|
|
132
|
+
if (result.billing === 'api' || result.billing === 'subscription' || result.billing === 'unknown') return result.billing
|
|
131
133
|
if (result.channel !== 'official-cli') return 'api'
|
|
132
134
|
if (result.credentialSource === 'configured-api-key' || result.credentialSource === 'process-environment') return 'api'
|
|
133
135
|
if (!result.credentialSource && apiKeyPresent) return 'api'
|
|
@@ -136,6 +138,7 @@ export function billingOf(result, { loginBilling = null, apiKeyPresent = false }
|
|
|
136
138
|
|
|
137
139
|
/** Cost fields for storage: subscription runs keep the figure as `referenceCostUsd` only. */
|
|
138
140
|
export function billedCost(result, pricing, billing) {
|
|
141
|
+
if (billing === 'unknown') return { costUsd: null, costSource: 'billing-unknown', billing, referenceCostUsd: null }
|
|
139
142
|
const cost = actualCost(result, pricing)
|
|
140
143
|
if (billing !== 'subscription') return { ...cost, billing: billing ?? 'api', referenceCostUsd: null }
|
|
141
144
|
return { costUsd: null, costSource: cost.costSource, billing, referenceCostUsd: cost.costUsd }
|
|
@@ -372,6 +375,7 @@ function storedPackage(planned, result, pricing, finishedAt, billingFor = billin
|
|
|
372
375
|
...(result?.billingMode ? { billingMode: String(result.billingMode) } : {}),
|
|
373
376
|
...(result?.billingSwitch ? { billingSwitch: { ...result.billingSwitch } } : {}),
|
|
374
377
|
...(result?.subscriptionRoute ? { subscriptionRoute: { provider: String(result.subscriptionRoute.provider ?? ''), model: String(result.subscriptionRoute.model ?? '') } } : {}),
|
|
378
|
+
...(result?.subscriptionAttempts !== undefined ? { subscriptionAttempts: sanitizeSubscriptionAttempts(result.subscriptionAttempts) } : {}),
|
|
375
379
|
error: result?.ok ? null : (result?.error ?? null),
|
|
376
380
|
...(!result?.ok && result?.diagnostic ? { diagnostic: String(result.diagnostic).slice(0, 2_000) } : {}),
|
|
377
381
|
...(Number.isInteger(result?.exitCode) ? { exitCode: result.exitCode } : {}),
|
|
@@ -382,7 +386,7 @@ function storedPackage(planned, result, pricing, finishedAt, billingFor = billin
|
|
|
382
386
|
// silently rewrite historical spend calculated from usage.
|
|
383
387
|
pricingSnapshot: pricing ? { ...pricing } : null,
|
|
384
388
|
...cost,
|
|
385
|
-
finishedAt,
|
|
389
|
+
finishedAt: receiptTimestamp(result?.finishedAt) ? result.finishedAt : finishedAt,
|
|
386
390
|
review: planned.review ?? null,
|
|
387
391
|
rating: planned.rating ?? null,
|
|
388
392
|
}
|
|
@@ -442,6 +446,8 @@ export function mergeRerun(run, execution, { rerunIds = [], pricingFor = () => n
|
|
|
442
446
|
// Retrying replaces the visible result, but earlier attempts still incurred
|
|
443
447
|
// charges (or unknown usage). Keep their calendar totals before replacement.
|
|
444
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 }))
|
|
445
451
|
run.priorAttemptSpending = archiveSpending([{ createdAt: run.createdAt, packages: replaced }], run.priorAttemptSpending)
|
|
446
452
|
run.packages = run.packages.map(stored => {
|
|
447
453
|
const result = results.find(item => item.id === stored.id)
|
|
@@ -459,6 +465,7 @@ export function storedResults(run) {
|
|
|
459
465
|
id: item.id, name: item.name, ok: item.ok, provider: item.provider, model: item.model,
|
|
460
466
|
channel: item.channel, answer: item.answer, error: item.error, fallback: item.fallback,
|
|
461
467
|
blocked: item.blocked, finishedAt: item.finishedAt,
|
|
468
|
+
...(item.subscriptionAttempts !== undefined ? { subscriptionAttempts: sanitizeSubscriptionAttempts(item.subscriptionAttempts) } : {}),
|
|
462
469
|
...packageSelectionFields(item),
|
|
463
470
|
...reasoningExecutionFields(item),
|
|
464
471
|
...(finite(item.durationMs) && item.durationMs >= 0 ? { durationMs: item.durationMs } : {}),
|
|
@@ -482,12 +489,17 @@ export function teamExecutionResults(plan, execution) {
|
|
|
482
489
|
id: result.id,
|
|
483
490
|
ok: result.status === 'succeeded',
|
|
484
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 } : {}),
|
|
485
496
|
provider: result.provider,
|
|
486
497
|
model: result.recommendedModel,
|
|
487
498
|
...unsupportedCliReasoning({ reasoningEffort: result.recommendedReasoningEffort }),
|
|
488
499
|
...reasoningExecutionFields(result),
|
|
489
500
|
...(finite(result.durationMs) && result.durationMs >= 0 ? { durationMs: result.durationMs } : {}),
|
|
490
501
|
...(finite(result.inputChars) && result.inputChars >= 0 ? { inputChars: result.inputChars } : {}),
|
|
502
|
+
...(receiptTimestamp(result.finishedAt) ? { finishedAt: result.finishedAt } : {}),
|
|
491
503
|
requestedCliModel: result.requestedCliModel ?? null,
|
|
492
504
|
actualModel: result.actualModel ?? null,
|
|
493
505
|
...(result.reportedModel !== undefined ? { reportedModel: result.reportedModel } : {}),
|
|
@@ -495,6 +507,7 @@ export function teamExecutionResults(plan, execution) {
|
|
|
495
507
|
...(typeof result.source === 'string' ? { source: result.source } : {}),
|
|
496
508
|
...(typeof result.sandboxEnforcement === 'string' ? { sandboxEnforcement: result.sandboxEnforcement } : {}),
|
|
497
509
|
...(result.usageIncomplete === true ? { usageIncomplete: true } : {}),
|
|
510
|
+
...(result.subscriptionAttempts !== undefined ? { subscriptionAttempts: sanitizeSubscriptionAttempts(result.subscriptionAttempts) } : {}),
|
|
498
511
|
...(typeof result.executionEnvironment === 'string' ? { executionEnvironment: result.executionEnvironment } : {}),
|
|
499
512
|
channel: 'official-cli',
|
|
500
513
|
toolId: result.toolId ?? null,
|
|
@@ -541,6 +554,10 @@ export function buildToolRunRecord({ toolId, toolLabel, provider, model, cliMode
|
|
|
541
554
|
status: result?.status ?? 'pending',
|
|
542
555
|
packages: [{
|
|
543
556
|
id: packageId, ok: succeeded, cancelled: result?.status === 'cancelled',
|
|
557
|
+
...(result?.notStarted === true || result?.status === 'unsupported' ? { notStarted: true } : {}),
|
|
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 } : {}),
|
|
544
561
|
provider: plan.directRoute.provider, model: plan.directRoute.model,
|
|
545
562
|
channel: 'official-cli', toolId,
|
|
546
563
|
requestedCliModel: Object.hasOwn(result ?? {}, 'requestedModel') ? result.requestedModel : cliModel,
|
|
@@ -550,6 +567,8 @@ export function buildToolRunRecord({ toolId, toolLabel, provider, model, cliMode
|
|
|
550
567
|
...(typeof result?.source === 'string' ? { source: result.source } : {}),
|
|
551
568
|
...(typeof result?.sandboxEnforcement === 'string' ? { sandboxEnforcement: result.sandboxEnforcement } : {}),
|
|
552
569
|
...(result?.usageIncomplete === true ? { usageIncomplete: true } : {}),
|
|
570
|
+
...(result?.subscriptionAttempts !== undefined ? { subscriptionAttempts: sanitizeSubscriptionAttempts(result.subscriptionAttempts) } : {}),
|
|
571
|
+
...(receiptTimestamp(result?.finishedAt) ? { finishedAt: result.finishedAt } : {}),
|
|
553
572
|
answer: String(result?.finalText ?? ''),
|
|
554
573
|
error: succeeded ? null : String(result?.error ?? result?.reason ?? result?.status ?? '官方 CLI 未成功完成。'),
|
|
555
574
|
...(!succeeded && typeof result?.diagnostic === 'string' && result.diagnostic.trim()
|
|
@@ -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
|
+
}
|