dsh-all-usage 1.1.2 → 1.1.3

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/lib/plugin.js ADDED
@@ -0,0 +1,276 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { createAggregation } from './aggregation.js'
3
+ import { createBalance } from './balance.js'
4
+ import { registerRoutes } from './http.js'
5
+ import { createLedger } from './ledger.js'
6
+ import { createPricingRuntime } from './pricing-runtime.js'
7
+ import { createSessionSync } from './session-sync.js'
8
+ import { createCostAccumulator, createEmptyPricingState } from './pricing.js'
9
+
10
+ const name = 'dsh-all-usage'
11
+ const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'sessionPersistence', 'storage', 'webServer']
12
+
13
+ function apply(ctx) {
14
+ const services = {
15
+ credentials: ctx.get('credentials'),
16
+ settings: ctx.get('settings'),
17
+ storage: ctx.get('storage'),
18
+ webServer: ctx.get('webServer'),
19
+ sessionPersistence: ctx.get('sessionPersistence'),
20
+ }
21
+ const state = {
22
+ wsMeta: new Map(),
23
+ pathIndex: new Map(),
24
+ memberOf: new Map(),
25
+ byDay: new Map(),
26
+ byDayUtc: new Map(),
27
+ perWorkspace: new Map(),
28
+ perModel: new Map(),
29
+ usageByStep: new Map(),
30
+ turnRecords: new Map(),
31
+ usageByLocalDate: new Map(),
32
+ usageByUtcDate: new Map(),
33
+ sessionModel: new Map(),
34
+ totals: { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() },
35
+ sessionCount: new Set(),
36
+ sessionSeq: new Map(),
37
+ chains: new Map(),
38
+ liveResyncPending: new Set(),
39
+ liveResyncTimers: new Map(),
40
+ liveResyncAttempts: new Map(),
41
+ scan: { started: false, done: false, scanned: 0, total: 0, failed: 0 },
42
+ aliases: {},
43
+ kvUnit: null,
44
+ aliasWriteChain: Promise.resolve(),
45
+ aliasesReady: Promise.resolve(),
46
+ balanceCache: { fetchedAt: 0, payload: null },
47
+ requestToken: randomBytes(32).toString('base64url'),
48
+ instanceId: randomBytes(12).toString('base64url'),
49
+ statsRevision: 0,
50
+ dataRevision: 0,
51
+ metadataRevision: 0,
52
+ scanRevision: 0,
53
+ pricingRevision: 0,
54
+ statsUpdatedAt: Date.now(),
55
+ statsDirtyScheduled: false,
56
+ statsDirtyKinds: new Set(),
57
+ sync: {
58
+ lastStartedAt: 0,
59
+ lastCompletedAt: 0,
60
+ lastErrorAt: 0,
61
+ lastErrorCode: null,
62
+ persistenceSnapshotsAvailable: false,
63
+ sessionsTotal: 0,
64
+ sessionsRead: 0,
65
+ sessionsSkippedByRevision: 0,
66
+ sessionsRestoredFromLedger: 0,
67
+ sessionsFailed: 0,
68
+ },
69
+ ledgerRecords: new Map(),
70
+ queryCache: new Map(),
71
+ recordsQueryCache: new Map(),
72
+ snapshotCache: null,
73
+ ledgerRevision: Date.now(),
74
+ ledgerUnit: null,
75
+ ledgerReady: Promise.resolve(),
76
+ ledgerWriteChain: Promise.resolve(),
77
+ ledgerPending: new Map(),
78
+ ledgerWriteWaiters: new Map(),
79
+ ledgerWriteRunning: false,
80
+ ledgerWriteScheduled: false,
81
+ ledgerWriteTimer: null,
82
+ ledgerDirtySessions: new Set(),
83
+ ledgerDirtyEpochs: new Map(),
84
+ ledgerWriteFailedSessions: new Set(),
85
+ pricingState: createEmptyPricingState(),
86
+ pricingResolutionCache: new Map(),
87
+ pricingUnit: null,
88
+ pricingReady: Promise.resolve(),
89
+ pricingWriteChain: Promise.resolve(),
90
+ pricingSyncInFlight: false,
91
+ pricingSyncTimer: null,
92
+ aggregationGeneration: 0,
93
+ disposed: false,
94
+ baselineRetryDelay: 1000,
95
+ baselineRetryScheduled: false,
96
+ baselineFallbackTimer: null,
97
+ knownSessionIds: new Set(),
98
+ reconcileHintScheduled: false,
99
+ reconcilePending: false,
100
+ reconcileInFlight: false,
101
+ reconcileTimer: null,
102
+ }
103
+
104
+ const host = { ctx, services, state, webServer: services.webServer, aggregation: null, ledger: null, pricing: null, balance: null, sessionSync: null, aliases: null }
105
+ function commitPendingStats() {
106
+ if (!state.statsDirtyScheduled) return
107
+ state.statsDirtyScheduled = false
108
+ const kinds = state.statsDirtyKinds
109
+ state.statsDirtyKinds = new Set()
110
+ if (state.disposed) return
111
+ state.statsRevision += 1
112
+ if (kinds.has('data')) state.dataRevision += 1
113
+ if (kinds.has('metadata')) state.metadataRevision += 1
114
+ if (kinds.has('scan')) state.scanRevision += 1
115
+ if (kinds.has('pricing')) state.pricingRevision += 1
116
+ state.statsUpdatedAt = Date.now()
117
+ }
118
+ function markStatsChanged(kind = 'data') {
119
+ const kinds = Array.isArray(kind) ? kind : [kind]
120
+ let recordsChanged = false
121
+ for (const value of kinds) {
122
+ if (value !== 'data' && value !== 'metadata' && value !== 'scan' && value !== 'pricing') continue
123
+ state.statsDirtyKinds.add(value)
124
+ if (value === 'data' || value === 'pricing') recordsChanged = true
125
+ }
126
+ state.snapshotCache = null
127
+ if (recordsChanged) state.recordsQueryCache.clear()
128
+ if (state.disposed || state.statsDirtyScheduled) return
129
+ state.statsDirtyScheduled = true
130
+ if (typeof queueMicrotask === 'function') queueMicrotask(commitPendingStats)
131
+ else Promise.resolve().then(commitPendingStats)
132
+ }
133
+ function resetSyncState() {
134
+ state.sync.lastStartedAt = 0
135
+ state.sync.lastCompletedAt = 0
136
+ state.sync.lastErrorAt = 0
137
+ state.sync.lastErrorCode = null
138
+ state.sync.persistenceSnapshotsAvailable = false
139
+ state.sync.sessionsTotal = 0
140
+ state.sync.sessionsRead = 0
141
+ state.sync.sessionsSkippedByRevision = 0
142
+ state.sync.sessionsRestoredFromLedger = 0
143
+ state.sync.sessionsFailed = 0
144
+ }
145
+ function beginSync() {
146
+ resetSyncState()
147
+ state.sync.lastStartedAt = Date.now()
148
+ markStatsChanged('scan')
149
+ }
150
+ function noteSyncError(code) {
151
+ state.sync.lastErrorAt = Date.now()
152
+ state.sync.lastErrorCode = code
153
+ markStatsChanged('scan')
154
+ }
155
+ function syncSnapshot() {
156
+ return {
157
+ lastStartedAt: state.sync.lastStartedAt,
158
+ lastCompletedAt: state.sync.lastCompletedAt,
159
+ lastErrorAt: state.sync.lastErrorAt,
160
+ lastErrorCode: state.sync.lastErrorCode,
161
+ persistenceSnapshotsAvailable: state.sync.persistenceSnapshotsAvailable,
162
+ sessionsTotal: state.sync.sessionsTotal,
163
+ sessionsRead: state.sync.sessionsRead,
164
+ sessionsSkippedByRevision: state.sync.sessionsSkippedByRevision,
165
+ sessionsRestoredFromLedger: state.sync.sessionsRestoredFromLedger,
166
+ sessionsFailed: state.sync.sessionsFailed,
167
+ }
168
+ }
169
+ Object.assign(host, { commitPendingStats, markStatsChanged, resetSyncState, beginSync, noteSyncError, syncSnapshot })
170
+
171
+ host.pricingSnapshot = (...args) => host.pricing.pricingSnapshot(...args)
172
+ host.syncSnapshot = (...args) => syncSnapshot(...args)
173
+ host.aggregation = createAggregation(host)
174
+ host.ledger = createLedger(host)
175
+ host.pricing = createPricingRuntime(host)
176
+ host.balance = createBalance(host)
177
+ host.sessionSync = createSessionSync(host)
178
+
179
+ async function loadAliases() {
180
+ const storage = services.storage
181
+ if (storage === undefined || storage === null) return
182
+ try {
183
+ const backend = storage.backend.get('json')
184
+ if (backend === undefined || backend === null || backend.kv === undefined) return
185
+ const unit = await backend.kv.open({ name: 'all_usage_aliases', version: 0, tables: [], hasGlobal: true })
186
+ if (state.disposed) { await unit.close().catch(() => {}); return }
187
+ state.kvUnit = unit
188
+ const snap = await unit.loadAll()
189
+ const global = snap && snap.global
190
+ if (global !== null && global !== undefined && typeof global === 'object') {
191
+ let changed = false
192
+ for (const key of Object.keys(global)) {
193
+ const value = global[key]
194
+ const next = typeof value === 'string' ? value.trim() : ''
195
+ if (next !== '' && state.aliases[key] !== next) {
196
+ state.aliases[key] = next
197
+ changed = true
198
+ }
199
+ }
200
+ if (changed) markStatsChanged('metadata')
201
+ }
202
+ } catch (err) {
203
+ console.error('[all-usage] alias storage unavailable:', err)
204
+ }
205
+ }
206
+ function persistAliases() {
207
+ if (state.disposed) return state.aliasWriteChain
208
+ const snapshotAliases = {}
209
+ for (const key of Object.keys(state.aliases)) snapshotAliases[key] = state.aliases[key]
210
+ state.aliasWriteChain = state.aliasWriteChain.then(() => {
211
+ if (state.kvUnit === null || state.kvUnit === undefined) return undefined
212
+ return state.kvUnit.setGlobal(snapshotAliases).catch((err) => {
213
+ console.error('[all-usage] alias persist failed:', err)
214
+ })
215
+ })
216
+ return state.aliasWriteChain
217
+ }
218
+ function setAlias(wsId, raw) {
219
+ if (typeof wsId !== 'string' || wsId.length === 0 || wsId.length > 256) return { ok: false, message: 'invalid-workspace', aliases: Object.assign({}, state.aliases) }
220
+ if (typeof raw !== 'string') return { ok: false, message: 'invalid-alias', aliases: Object.assign({}, state.aliases) }
221
+ const alias = raw.trim().slice(0, 80)
222
+ if (!state.wsMeta.has(wsId)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, state.aliases) }
223
+ const previous = typeof state.aliases[wsId] === 'string' ? state.aliases[wsId] : ''
224
+ if (alias === previous) return { ok: true, aliases: Object.assign({}, state.aliases) }
225
+ if (alias === '') delete state.aliases[wsId]
226
+ else state.aliases[wsId] = alias
227
+ persistAliases()
228
+ markStatsChanged('metadata')
229
+ return { ok: true, aliases: Object.assign({}, state.aliases) }
230
+ }
231
+ host.aliases = { loadAliases, persistAliases, setAlias }
232
+
233
+ ctx.effect(() => async () => {
234
+ state.disposed = true
235
+ if (state.reconcileTimer !== null) {
236
+ clearTimeout(state.reconcileTimer)
237
+ state.reconcileTimer = null
238
+ }
239
+ if (state.baselineFallbackTimer !== null) {
240
+ clearTimeout(state.baselineFallbackTimer)
241
+ state.baselineFallbackTimer = null
242
+ }
243
+ if (state.pricingSyncTimer !== null) {
244
+ clearTimeout(state.pricingSyncTimer)
245
+ state.pricingSyncTimer = null
246
+ }
247
+ for (const timer of state.liveResyncTimers.values()) clearTimeout(timer)
248
+ state.liveResyncTimers.clear()
249
+ state.liveResyncAttempts.clear()
250
+ state.liveResyncPending.clear()
251
+ state.baselineRetryScheduled = false
252
+ if (state.ledgerWriteTimer !== null) {
253
+ clearTimeout(state.ledgerWriteTimer)
254
+ state.ledgerWriteTimer = null
255
+ }
256
+ state.ledgerWriteScheduled = false
257
+ state.chains.clear()
258
+ await Promise.all([state.aliasesReady, state.ledgerReady, state.pricingReady, state.aliasWriteChain, state.pricingWriteChain])
259
+ await host.ledger.drainLedgerWrites()
260
+ const units = [state.kvUnit, state.ledgerUnit, state.pricingUnit]
261
+ state.kvUnit = null
262
+ state.ledgerUnit = null
263
+ state.pricingUnit = null
264
+ await Promise.all(units.map((unit) => unit === null || unit === undefined ? undefined : unit.close().catch(() => {})))
265
+ })
266
+
267
+ registerRoutes(host)
268
+ state.ledgerReady = host.ledger.loadLedger()
269
+ state.pricingReady = host.pricing.loadPricing().then(() => { host.pricing.schedulePricingSync() })
270
+ void host.sessionSync.runBaseline()
271
+ host.sessionSync.scheduleReconcileTimer()
272
+ state.aliasesReady = loadAliases()
273
+ }
274
+
275
+ export { name, inject, apply }
276
+ export default { name, inject, apply }
@@ -0,0 +1,282 @@
1
+ import { COST_SCHEMA_VERSION, calculateCost, fetchModelsDevCatalog, normalizeCostSnapshot, normalizePricingState, officialProviderIds, serializeCostAggregate, serializePricingState } from './pricing.js'
2
+
3
+ const LEDGER_VERSION = 3
4
+
5
+ export function createPricingRuntime(host) {
6
+ const { state, markStatsChanged } = host
7
+ const { storage } = host.services
8
+ const {
9
+ coerceIdentity,
10
+ dateKeys,
11
+ ensureDay,
12
+ ensureDayModel,
13
+ ensureDayWs,
14
+ ensureModel,
15
+ ensureWs,
16
+ addCostAggregateDirection,
17
+ adjustQueryCost,
18
+ resolveCurrentPricing,
19
+ } = host.aggregation
20
+ const persistLedgerRecord = (...args) => host.ledger.persistLedgerRecord(...args)
21
+ const drainLedgerWrites = (...args) => host.ledger.drainLedgerWrites(...args)
22
+ const nextLedgerRevision = (...args) => host.ledger.nextLedgerRevision(...args)
23
+
24
+ function adjustCostOnly(item, cost, direction) {
25
+ const identity = coerceIdentity(item.identity || item.modelId)
26
+ const dates = item && typeof item.date === 'string' && typeof item.dateUtc === 'string' ? { local: item.date, utc: item.dateUtc } : dateKeys(item.time)
27
+ const targets = []
28
+ const seen = new Set()
29
+ for (const target of [
30
+ ensureModel(identity).cost,
31
+ state.totals.cost,
32
+ ensureWs(item.wsId).cost,
33
+ ensureDay(state.byDay, dates.local).cost,
34
+ ensureDay(state.byDayUtc, dates.utc).cost,
35
+ ensureDayWs(ensureDay(state.byDay, dates.local), item.wsId).cost,
36
+ ensureDayWs(ensureDay(state.byDayUtc, dates.utc), item.wsId).cost,
37
+ ensureDayModel(ensureDay(state.byDay, dates.local), identity).cost,
38
+ ensureDayModel(ensureDay(state.byDayUtc, dates.utc), identity).cost,
39
+ ]) {
40
+ if (seen.has(target)) continue
41
+ seen.add(target)
42
+ targets.push(target)
43
+ }
44
+ for (const target of targets) addCostAggregateDirection(target, cost, direction)
45
+ }
46
+ function usedPricingModels(tierSchedules = null) {
47
+ const rows = []
48
+ const seen = new Set()
49
+ const scheduleIds = new Map()
50
+ const scheduleIdFor = (tiers) => {
51
+ if (!Array.isArray(tierSchedules) || tiers.length === 0) return null
52
+ const key = JSON.stringify(tiers)
53
+ const existing = scheduleIds.get(key)
54
+ if (existing !== undefined) return existing
55
+ const id = 'tier-' + tierSchedules.length
56
+ scheduleIds.set(key, id)
57
+ tierSchedules.push({ id, tiers: tiers.map((tier) => ({ ...tier })) })
58
+ return id
59
+ }
60
+ for (const item of state.usageByStep.values()) {
61
+ const identity = coerceIdentity(item.identity || item.modelId)
62
+ if (seen.has(identity.identityKey)) continue
63
+ seen.add(identity.identityKey)
64
+ const resolved = resolveCurrentPricing(identity)
65
+ const tiers = Array.isArray(resolved.tiers) ? resolved.tiers : []
66
+ const tierScheduleId = resolved.tiered === true ? scheduleIdFor(tiers) : null
67
+ rows.push({
68
+ identityKey: identity.identityKey,
69
+ provider: identity.provider,
70
+ requestedModel: identity.requestedModel,
71
+ actualModel: identity.actualModel,
72
+ model: identity.label,
73
+ status: resolved.status,
74
+ reason: resolved.reason || '',
75
+ pricingModel: resolved.pricingModel || null,
76
+ providerId: resolved.providerId || null,
77
+ source: resolved.source || 'none',
78
+ currency: resolved.currency || 'USD',
79
+ rates: resolved.rates || null,
80
+ tiered: resolved.tiered === true,
81
+ tierCount: tiers.length,
82
+ ...(tierScheduleId === null ? {} : { tierScheduleId }),
83
+ tieredInvalid: resolved.tieredInvalid === true,
84
+ inputTokenSemantics: resolved.inputTokenSemantics || 'fresh',
85
+ multiplier: resolved.multiplier || '1',
86
+ })
87
+ if (rows.length >= 500) break
88
+ }
89
+ rows.sort((a, b) => String(a.model).localeCompare(String(b.model)))
90
+ return rows
91
+ }
92
+ function pricingModelSearch(query, limit = 20) {
93
+ const raw = typeof query === 'string' ? query.trim().toLowerCase() : ''
94
+ if (raw === '') return []
95
+ const normalized = raw.replace(/\s+/g, ' ')
96
+ const selected = new Map()
97
+ for (const entry of state.pricingState.catalogEntries) {
98
+ const official = officialProviderIds(entry.modelId)
99
+ if (!official.has(String(entry.providerId || '').toLowerCase())) continue
100
+ const modelId = entry.modelId.toLowerCase()
101
+ const displayName = String(entry.displayName || '').toLowerCase()
102
+ if (!modelId.includes(normalized) && !displayName.includes(normalized)) continue
103
+ const score = modelId === normalized ? 0 : modelId.startsWith(normalized) ? 1 : displayName.startsWith(normalized) ? 2 : 3
104
+ const previous = selected.get(entry.modelId)
105
+ if (previous === undefined || score < previous.score) selected.set(entry.modelId, { value: entry.modelId, label: entry.displayName, providerId: entry.providerId, tiered: entry.tiered === true, tierCount: Array.isArray(entry.tiers) ? entry.tiers.length : 0, score })
106
+ }
107
+ return Array.from(selected.values()).sort((a, b) => a.score - b.score || a.value.localeCompare(b.value)).slice(0, Math.max(1, Math.min(50, Number.isInteger(limit) ? limit : 20))).map(({ score, ...entry }) => entry)
108
+ }
109
+ function pricingSnapshot(options = {}) {
110
+ const pricingStateSnapshot = state.pricingState
111
+ const detailed = options.detailed !== false
112
+ const tierSchedules = detailed ? [] : null
113
+ const snapshot = {
114
+ schemaVersion: pricingStateSnapshot.schemaVersion,
115
+ source: { ...pricingStateSnapshot.source },
116
+ sync: { ...pricingStateSnapshot.sync },
117
+ catalogModelCount: pricingStateSnapshot.catalogEntries.length,
118
+ overrideCount: pricingStateSnapshot.overrides.length,
119
+ mappingCount: pricingStateSnapshot.mappings.length,
120
+ configured: pricingStateSnapshot.catalogEntries.length > 0 || pricingStateSnapshot.overrides.length > 0 || pricingStateSnapshot.mappings.length > 0,
121
+ usedModels: usedPricingModels(tierSchedules),
122
+ cost: serializeCostAggregate(state.totals.cost),
123
+ }
124
+ if (detailed) {
125
+ snapshot.config = {
126
+ sync: { ...pricingStateSnapshot.sync },
127
+ mappings: pricingStateSnapshot.mappings.map((mapping) => ({ ...mapping })),
128
+ overrides: pricingStateSnapshot.overrides.map(({ providerId, ...entry }) => ({ ...entry, ...(Array.isArray(entry.tiers) ? { tiers: entry.tiers.map((tier) => ({ ...tier })) } : {}) })),
129
+ }
130
+ snapshot.tierSchedules = tierSchedules
131
+ }
132
+ return snapshot
133
+ }
134
+ async function loadPricing() {
135
+ if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
136
+ try {
137
+ const backend = storage.backend.get('json')
138
+ if (backend === undefined || backend === null || backend.kv === undefined) return
139
+ const unit = await backend.kv.open({ name: 'all_usage_pricing', version: 0, tables: [], hasGlobal: true })
140
+ if (state.disposed) { await unit.close().catch(() => {}); return }
141
+ state.pricingUnit = unit
142
+ const snapshot = await unit.loadAll()
143
+ const global = snapshot && snapshot.global
144
+ const raw = global && typeof global === 'object' && global.pricing !== undefined ? global.pricing : global
145
+ state.pricingState = normalizePricingState(raw)
146
+ state.pricingResolutionCache.clear()
147
+ } catch (err) {
148
+ console.error('[all-usage] pricing catalog unavailable:', err)
149
+ }
150
+ }
151
+ function persistPricing() {
152
+ if (state.disposed) return state.pricingWriteChain
153
+ const payload = { pricing: serializePricingState(state.pricingState) }
154
+ state.pricingWriteChain = state.pricingWriteChain.then(async () => {
155
+ if (state.pricingUnit === null || state.pricingUnit === undefined) return
156
+ await state.pricingUnit.setGlobal(payload)
157
+ })
158
+ state.pricingWriteChain = state.pricingWriteChain.catch((err) => {
159
+ console.error('[all-usage] pricing persist failed:', err)
160
+ })
161
+ return state.pricingWriteChain
162
+ }
163
+ async function syncPricing(force = false) {
164
+ await Promise.all([state.pricingReady, state.ledgerReady])
165
+ if (state.pricingSyncInFlight) return { ok: false, message: 'pricing-sync-in-progress', pricing: pricingSnapshot() }
166
+ const now = Date.now()
167
+ if (!force && state.pricingState.sync.lastSuccessAt > 0 && now - state.pricingState.sync.lastSuccessAt < state.pricingState.sync.intervalMs) return { ok: true, skipped: true, pricing: pricingSnapshot() }
168
+ state.pricingSyncInFlight = true
169
+ state.pricingState.sync.lastAttemptAt = now
170
+ try {
171
+ const result = await fetchModelsDevCatalog()
172
+ if (!result.ok) {
173
+ state.pricingState.source.lastError = result.error
174
+ state.pricingState.sync.lastError = result.error
175
+ // A failed attempt changes only sync health, not any configured price,
176
+ // so it must not invalidate the query snapshot/records caches.
177
+ markStatsChanged('sync-health')
178
+ await persistPricing()
179
+ return { ok: false, message: result.error, pricing: pricingSnapshot() }
180
+ }
181
+ const previousHash = state.pricingState.source && state.pricingState.source.catalogHash !== undefined ? String(state.pricingState.source.catalogHash) : ''
182
+ state.pricingState = normalizePricingState({
183
+ ...serializePricingState(state.pricingState),
184
+ source: { url: result.catalog.sourceUrl, fetchedAt: result.catalog.fetchedAt, catalogHash: result.catalog.catalogHash, lastError: '' },
185
+ sync: { ...state.pricingState.sync, lastSuccessAt: result.catalog.fetchedAt, lastError: '' },
186
+ catalogEntries: result.catalog.entries,
187
+ })
188
+ state.pricingResolutionCache.clear()
189
+ const backfill = backfillUnpricedCosts()
190
+ // Only catalog changes or newly priced usage can alter computed costs; a
191
+ // successful sync with an unchanged catalog must not invalidate the query
192
+ // snapshot, scoped caches, or records cursors. The sync-health bump still
193
+ // rebuilds the snapshot cache so lastSuccessAt is not stale on the next
194
+ // full response.
195
+ if (String(result.catalog.catalogHash || '') !== previousHash || backfill.priced > 0) markStatsChanged('pricing')
196
+ else markStatsChanged('sync-health')
197
+ await persistPricing()
198
+ await drainLedgerWrites()
199
+ return { ok: true, skipped: false, backfill, pricing: pricingSnapshot() }
200
+ } finally {
201
+ state.pricingSyncInFlight = false
202
+ schedulePricingSync()
203
+ }
204
+ }
205
+ function backfillUnpricedCosts() {
206
+ let considered = 0
207
+ let priced = 0
208
+ const touched = new Set()
209
+ for (const item of state.usageByStep.values()) {
210
+ const oldCost = normalizeCostSnapshot(item.cost)
211
+ if (oldCost !== null && oldCost.pricingMode === 'official-model' && oldCost.status === 'priced') continue
212
+ considered += 1
213
+ const next = calculateCost(item.values, resolveCurrentPricing(item.identity || item.modelId))
214
+ if (next.status !== 'priced') continue
215
+ if (oldCost !== null) {
216
+ adjustCostOnly(item, oldCost, -1)
217
+ adjustQueryCost(item, oldCost, -1)
218
+ }
219
+ item.cost = next
220
+ adjustCostOnly(item, next, 1)
221
+ adjustQueryCost(item, next, 1)
222
+ const record = state.ledgerRecords.get(item.sid)
223
+ if (record !== undefined) {
224
+ const stored = record.usage.find((candidate) => candidate.key === item.key)
225
+ if (stored !== undefined) { stored.cost = next; touched.add(record) }
226
+ }
227
+ priced += 1
228
+ }
229
+ for (const record of touched) {
230
+ record.version = LEDGER_VERSION
231
+ record.updatedAt = nextLedgerRevision()
232
+ // Backfilling prices must not clear a mixed-workspace upgrade flag: stay
233
+ // unfoldable until a rebuild normalizes every historical item.
234
+ const stillMixed = record.turns.some((turn) => turn && turn.workspaceId !== record.workspaceId) || record.usage.some((item) => item && item.workspaceId !== record.workspaceId)
235
+ record.needsUpgrade = stillMixed
236
+ void persistLedgerRecord(record)
237
+ }
238
+ let remaining = 0
239
+ for (const item of state.usageByStep.values()) {
240
+ const cost = normalizeCostSnapshot(item.cost)
241
+ if (cost === null || cost.status !== 'priced') remaining += 1
242
+ }
243
+ return { considered, priced, remaining }
244
+ }
245
+ function updatePricingState(raw, backfill) {
246
+ const input = raw && typeof raw === 'object' && raw.pricing && typeof raw.pricing === 'object' ? raw.pricing : raw
247
+ const current = serializePricingState(state.pricingState)
248
+ const merged = { ...current, ...(input && typeof input === 'object' ? input : {}) }
249
+ if (input && typeof input === 'object' && input.sync && typeof input.sync === 'object' && !Array.isArray(input.sync)) merged.sync = { ...current.sync, ...input.sync }
250
+ state.pricingState = normalizePricingState(merged)
251
+ state.pricingResolutionCache.clear()
252
+ const result = backfill === true ? backfillUnpricedCosts() : { considered: 0, priced: 0, remaining: state.totals.cost.unpricedCalls + state.totals.cost.ambiguousCalls + state.totals.cost.unsupportedCalls }
253
+ markStatsChanged('pricing')
254
+ schedulePricingSync()
255
+ return result
256
+ }
257
+ function schedulePricingSync() {
258
+ if (state.pricingSyncTimer !== null) { clearTimeout(state.pricingSyncTimer); state.pricingSyncTimer = null }
259
+ if (state.disposed || state.pricingState.sync.autoEnabled !== true) return
260
+ const elapsed = state.pricingState.sync.lastSuccessAt > 0 ? Date.now() - state.pricingState.sync.lastSuccessAt : state.pricingState.sync.intervalMs
261
+ const delay = Math.max(0, state.pricingState.sync.intervalMs - elapsed)
262
+ state.pricingSyncTimer = setTimeout(() => {
263
+ state.pricingSyncTimer = null
264
+ void syncPricing(true)
265
+ }, delay)
266
+ if (state.pricingSyncTimer && typeof state.pricingSyncTimer.unref === 'function') state.pricingSyncTimer.unref()
267
+ }
268
+
269
+
270
+ return {
271
+ adjustCostOnly,
272
+ usedPricingModels,
273
+ pricingModelSearch,
274
+ pricingSnapshot,
275
+ loadPricing,
276
+ persistPricing,
277
+ syncPricing,
278
+ backfillUnpricedCosts,
279
+ updatePricingState,
280
+ schedulePricingSync
281
+ }
282
+ }