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/CHANGELOG.md +66 -0
- package/README.md +193 -19
- package/fixtures/usage-events.json +172 -0
- package/lib/aggregation.js +1002 -0
- package/lib/balance.js +112 -0
- package/lib/client.js +1 -2906
- package/lib/http.js +305 -0
- package/lib/index.js +2 -2119
- package/lib/ledger.js +464 -0
- package/lib/plugin.js +276 -0
- package/lib/pricing-runtime.js +282 -0
- package/lib/pricing.js +299 -36
- package/lib/session-sync.js +589 -0
- package/lib/usage-core.js +127 -0
- package/package.json +28 -3
- package/scripts/replay-fixture.mjs +155 -0
package/lib/ledger.js
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { calculateCost, normalizeCostSnapshot } from './pricing.js'
|
|
2
|
+
import { extractUsageEvent, normalizeUsageValues as usageValues, upsertUsageSample as upsertUsageSampleState, usageStepKey } from './usage-core.js'
|
|
3
|
+
|
|
4
|
+
const LEDGER_VERSION = 3
|
|
5
|
+
const PREVIOUS_LEDGER_VERSION = 2
|
|
6
|
+
const LEGACY_LEDGER_VERSION = 1
|
|
7
|
+
const LEDGER_SHARD_COUNT = 32
|
|
8
|
+
const LEDGER_UNIT_NAME = 'all_usage_ledger'
|
|
9
|
+
const LEDGER_META_KEY = '__all_usage_ledger_meta__'
|
|
10
|
+
const LEDGER_WRITE_DEBOUNCE_MS = 25
|
|
11
|
+
|
|
12
|
+
// Ledger sequences follow the DSH contract: -1 marks a missing/unavailable
|
|
13
|
+
// sequence, otherwise a non-negative safe integer is required.
|
|
14
|
+
function validLedgerSeq(value) {
|
|
15
|
+
return value === -1 || (Number.isSafeInteger(value) && value >= 0)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function pollutedLedgerKey(key) {
|
|
19
|
+
// Older builds stored raw String(seq) in event keys. Only keys that are
|
|
20
|
+
// entirely numeric text (leading zeros, signs, whitespace, decimals,
|
|
21
|
+
// exponents, huge values) are treated as bare sequence keys and rebuilt;
|
|
22
|
+
// composite keys such as positional event:N and usageStepKey forms that
|
|
23
|
+
// contain colons, letters, or hyphens are never flagged.
|
|
24
|
+
if (typeof key !== 'string') return true
|
|
25
|
+
// Bare whole values only: composite keys such as 'Infinity-session:step:1:1'
|
|
26
|
+
// are valid usageStepKey output and must keep the revision fast path.
|
|
27
|
+
if (key === 'Infinity' || key === '-Infinity' || key === 'NaN' || key === '-NaN') return true
|
|
28
|
+
if (!/^[+\-0-9.eE \t]+$/.test(key)) return false
|
|
29
|
+
if (!/^(?:0|[1-9]\d*)$/.test(key)) return true
|
|
30
|
+
return !Number.isSafeInteger(Number(key))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ledgerShardIndex(sessionId) {
|
|
34
|
+
let hash = 2166136261
|
|
35
|
+
const value = typeof sessionId === 'string' ? sessionId : String(sessionId)
|
|
36
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
37
|
+
hash ^= value.charCodeAt(index)
|
|
38
|
+
hash = Math.imul(hash, 16777619)
|
|
39
|
+
}
|
|
40
|
+
return (hash >>> 0) % LEDGER_SHARD_COUNT
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function ledgerShardName(index) {
|
|
44
|
+
return LEDGER_UNIT_NAME + '_' + String(index).padStart(2, '0')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sessionRows(snapshot) {
|
|
48
|
+
const rows = snapshot && snapshot.tables && snapshot.tables.sessions
|
|
49
|
+
return rows !== null && rows !== undefined && typeof rows === 'object' ? rows : {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function uniqueUnits(units) {
|
|
53
|
+
return Array.from(new Set(units.filter((unit) => unit !== null && unit !== undefined)))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createLedgerUnitStore(legacyUnit, shardUnits) {
|
|
57
|
+
const shards = uniqueUnits(shardUnits)
|
|
58
|
+
const allUnits = uniqueUnits([legacyUnit, ...shards])
|
|
59
|
+
return {
|
|
60
|
+
async loadAll() {
|
|
61
|
+
const snapshots = await Promise.all(shards.map((unit) => unit.loadAll()))
|
|
62
|
+
const rows = Object.create(null)
|
|
63
|
+
for (const snapshot of snapshots) {
|
|
64
|
+
for (const [key, value] of Object.entries(sessionRows(snapshot))) {
|
|
65
|
+
if (key !== LEDGER_META_KEY) rows[key] = value
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { global: {}, tables: { sessions: rows } }
|
|
69
|
+
},
|
|
70
|
+
async hasMigrationMarker() {
|
|
71
|
+
if (shardUnits[0] === undefined) return false
|
|
72
|
+
return Object.hasOwn(sessionRows(await shardUnits[0].loadAll()), LEDGER_META_KEY)
|
|
73
|
+
},
|
|
74
|
+
async putRecord(table, key, value) {
|
|
75
|
+
if (table !== 'sessions') throw new Error('usage ledger only supports the sessions table')
|
|
76
|
+
const index = key === LEDGER_META_KEY ? 0 : ledgerShardIndex(key)
|
|
77
|
+
const unit = shardUnits[index]
|
|
78
|
+
if (unit === undefined) throw new Error('usage ledger shard is unavailable: ' + index)
|
|
79
|
+
return unit.putRecord(table, key, value)
|
|
80
|
+
},
|
|
81
|
+
async close() {
|
|
82
|
+
await Promise.all(allUnits.map((unit) => unit.close().catch(() => {})))
|
|
83
|
+
},
|
|
84
|
+
shardCount: LEDGER_SHARD_COUNT,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function openLedgerUnitStore(backend) {
|
|
89
|
+
const shardUnits = []
|
|
90
|
+
let legacyUnit
|
|
91
|
+
try {
|
|
92
|
+
for (let index = 0; index < LEDGER_SHARD_COUNT; index += 1) {
|
|
93
|
+
shardUnits.push(await backend.kv.open({ name: ledgerShardName(index), version: 0, tables: ['sessions'], hasGlobal: false }))
|
|
94
|
+
}
|
|
95
|
+
const shardStore = createLedgerUnitStore(undefined, shardUnits)
|
|
96
|
+
const shardRows = sessionRows(await shardStore.loadAll())
|
|
97
|
+
if (await shardStore.hasMigrationMarker()) return { store: shardStore, rows: shardRows }
|
|
98
|
+
|
|
99
|
+
// Read the legacy monolith only during the one-time migration. Once shard 00
|
|
100
|
+
// carries the marker, startup never parses the old multi-megabyte file again.
|
|
101
|
+
legacyUnit = await backend.kv.open({ name: LEDGER_UNIT_NAME, version: 0, tables: ['sessions'], hasGlobal: false })
|
|
102
|
+
const store = createLedgerUnitStore(legacyUnit, shardUnits)
|
|
103
|
+
const rows = Object.assign(Object.create(null), shardRows)
|
|
104
|
+
const legacyRows = sessionRows(await legacyUnit.loadAll())
|
|
105
|
+
for (const [key, value] of Object.entries(legacyRows)) {
|
|
106
|
+
if (key === LEDGER_META_KEY || Object.hasOwn(rows, key)) continue
|
|
107
|
+
await store.putRecord('sessions', key, value)
|
|
108
|
+
rows[key] = value
|
|
109
|
+
}
|
|
110
|
+
await store.putRecord('sessions', LEDGER_META_KEY, { version: 1, migratedAt: Date.now() })
|
|
111
|
+
return { store, rows }
|
|
112
|
+
} catch (error) {
|
|
113
|
+
await Promise.all(uniqueUnits([legacyUnit, ...shardUnits]).map((unit) => unit.close().catch(() => {})))
|
|
114
|
+
throw error
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createLedger(host) {
|
|
119
|
+
const { state, markStatsChanged } = host
|
|
120
|
+
const { storage } = host.services
|
|
121
|
+
state.ledgerPending = state.ledgerPending || new Map()
|
|
122
|
+
state.ledgerWriteWaiters = state.ledgerWriteWaiters || new Map()
|
|
123
|
+
state.ledgerWriteRunning = state.ledgerWriteRunning === true
|
|
124
|
+
state.ledgerWriteScheduled = state.ledgerWriteScheduled === true
|
|
125
|
+
state.ledgerWriteTimer = state.ledgerWriteTimer || null
|
|
126
|
+
state.ledgerWriteFailedSessions = state.ledgerWriteFailedSessions || new Set()
|
|
127
|
+
const {
|
|
128
|
+
addTurn,
|
|
129
|
+
coerceIdentity,
|
|
130
|
+
identityFromLegacy,
|
|
131
|
+
identityFromMessage,
|
|
132
|
+
identityFromRoute,
|
|
133
|
+
makeIdentity,
|
|
134
|
+
costForUsage,
|
|
135
|
+
resolveCurrentPricing,
|
|
136
|
+
dateKeys,
|
|
137
|
+
validEventTime,
|
|
138
|
+
upsertUsageSample,
|
|
139
|
+
} = host.aggregation
|
|
140
|
+
const lastSeqOf = (...args) => host.sessionSync.lastSeqOf(...args)
|
|
141
|
+
function isLegacyTieredCost(cost) {
|
|
142
|
+
const selectedTier = cost && cost.selectedTier
|
|
143
|
+
const hasSelectedTier = selectedTier && selectedTier.type === 'context' && Number.isSafeInteger(selectedTier.size) && selectedTier.size >= 0
|
|
144
|
+
return cost !== null && cost.tiered === true && cost.status === 'priced' && !hasSelectedTier
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function nextLedgerRevision() {
|
|
148
|
+
const current = Number.isFinite(state.ledgerRevision) ? state.ledgerRevision : 0
|
|
149
|
+
state.ledgerRevision = Math.max(current + 1, Date.now())
|
|
150
|
+
return state.ledgerRevision
|
|
151
|
+
}
|
|
152
|
+
function ledgerEventKey(event, index) {
|
|
153
|
+
// Only finite safe-integer sequences are canonical event ids; anything else
|
|
154
|
+
// falls back to the positional index so two invalid events cannot overwrite
|
|
155
|
+
// each other in the ledger.
|
|
156
|
+
return Number.isSafeInteger(event.seq) && event.seq >= 0 ? String(event.seq) : 'event:' + index
|
|
157
|
+
}
|
|
158
|
+
function canFoldLedgerTail(previousRecord, events, workspaceId) {
|
|
159
|
+
if (previousRecord === null || previousRecord === undefined || previousRecord.needsUpgrade === true || previousRecord.rebuildRequired !== undefined || !Number.isSafeInteger(previousRecord.lastSeq) || previousRecord.lastSeq < 0 || !Array.isArray(events) || events.length === 0) return false
|
|
160
|
+
// A changed workspace identity (deleted and re-added workspace gets a new id)
|
|
161
|
+
// assigns every historical event to a different bucket, so the previous
|
|
162
|
+
// record can never be reused as a tail seed for the new workspace.
|
|
163
|
+
if (workspaceId !== undefined && previousRecord.workspaceId !== undefined && previousRecord.workspaceId !== workspaceId) return false
|
|
164
|
+
const startIndex = previousRecord.lastSeq + 1
|
|
165
|
+
if (startIndex >= events.length || events[startIndex] === null || typeof events[startIndex] !== 'object' || events[startIndex].seq !== startIndex) return false
|
|
166
|
+
const previousTurns = new Set(Array.isArray(previousRecord.turns) ? previousRecord.turns.map((turn) => turn && turn.turn).filter((turn) => Number.isSafeInteger(turn)) : [])
|
|
167
|
+
for (let index = startIndex, expectedSeq = startIndex; index < events.length; index += 1, expectedSeq += 1) {
|
|
168
|
+
const event = events[index]
|
|
169
|
+
if (event === null || typeof event !== 'object' || event.seq !== expectedSeq) return false
|
|
170
|
+
if (event.type === 'turn/end' && event.data && Number.isSafeInteger(event.data.turn) && previousTurns.has(event.data.turn)) return false
|
|
171
|
+
}
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
function buildLedgerRecord(session, workspaceId, source = 'scan', revision, previousRecord, sequenceClean = true) {
|
|
175
|
+
const sid = session && typeof session.id === 'string' ? session.id : ''
|
|
176
|
+
const events = session && Array.isArray(session.events) ? session.events : []
|
|
177
|
+
if (sid === '' || workspaceId === undefined) return null
|
|
178
|
+
// DSH sequences every persisted event with seq === log index, so the last
|
|
179
|
+
// event's seq is the record's lastSeq in O(1); fall back to a full scan only
|
|
180
|
+
// when the tail carries no seq (legacy or truncated logs).
|
|
181
|
+
const tailEvent = events.length > 0 ? events[events.length - 1] : null
|
|
182
|
+
const lastSeq = tailEvent !== null && Number.isSafeInteger(tailEvent.seq) && tailEvent.seq >= 0 ? tailEvent.seq : lastSeqOf(events)
|
|
183
|
+
// A changed incremental log that contains invalid sequences anywhere must
|
|
184
|
+
// be rebuilt wholesale: the fold check only validates the tail, so a bad
|
|
185
|
+
// event before the fold start would otherwise be skipped in memory while
|
|
186
|
+
// the persisted record still contains everything.
|
|
187
|
+
const canFoldTail = sequenceClean === true && canFoldLedgerTail(previousRecord, events, workspaceId)
|
|
188
|
+
const turns = canFoldTail ? new Map((previousRecord.turns || []).map((item) => [item.key, { ...item }])) : new Map()
|
|
189
|
+
const usage = canFoldTail ? new Map((previousRecord.usage || []).map((item) => [item.key, { ...item }])) : new Map()
|
|
190
|
+
const previousUsage = new Map(Array.isArray(previousRecord && previousRecord.usage) ? previousRecord.usage.map((item) => [item.key, item]) : [])
|
|
191
|
+
let currentIdentity = canFoldTail ? coerceIdentity(previousRecord.lastIdentity) : makeIdentity(null, null, null, null)
|
|
192
|
+
const startIndex = canFoldTail ? previousRecord.lastSeq + 1 : 0
|
|
193
|
+
for (let index = startIndex; index < events.length; index += 1) {
|
|
194
|
+
const event = events[index]
|
|
195
|
+
if (event === null || typeof event !== 'object') continue
|
|
196
|
+
if (canFoldTail && event.seq <= previousRecord.lastSeq) continue
|
|
197
|
+
const data = event.data
|
|
198
|
+
if (event.type === 'request/context' || event.type === 'request/header') {
|
|
199
|
+
currentIdentity = identityFromRoute(data, currentIdentity)
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
if (event.type === 'turn/end') {
|
|
203
|
+
if (!validEventTime(event.time)) continue
|
|
204
|
+
const key = ledgerEventKey(event, index)
|
|
205
|
+
const turn = data && typeof data.turn === 'number' ? data.turn : null
|
|
206
|
+
turns.set(key, { key, seq: validLedgerSeq(event.seq) ? event.seq : -1, time: event.time, workspaceId, turn, identity: currentIdentity })
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if (!validEventTime(event.time)) continue
|
|
210
|
+
const usageEvent = extractUsageEvent(event)
|
|
211
|
+
if (usageEvent === null) continue
|
|
212
|
+
const identity = usageEvent.kind === 'message' ? identityFromMessage(data, currentIdentity) : currentIdentity
|
|
213
|
+
const key = usageStepKey(sid, data, event.seq)
|
|
214
|
+
const result = upsertUsageSample(usage, {
|
|
215
|
+
key,
|
|
216
|
+
seq: validLedgerSeq(event.seq) ? event.seq : -1,
|
|
217
|
+
time: event.time,
|
|
218
|
+
workspaceId,
|
|
219
|
+
identity,
|
|
220
|
+
modelId: identity.label,
|
|
221
|
+
turn: usageEvent.turn,
|
|
222
|
+
step: usageEvent.step,
|
|
223
|
+
values: usageEvent.values,
|
|
224
|
+
}, { costPrevious: previousUsage.get(key) })
|
|
225
|
+
if (result.accepted && usageEvent.kind === 'message') currentIdentity = identity
|
|
226
|
+
}
|
|
227
|
+
return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq, source, updatedAt: nextLedgerRevision(), lastRevision: typeof revision === 'string' ? revision : undefined, sourceRevision: typeof revision === 'string' ? revision : undefined, lastIdentity: currentIdentity, turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
|
|
228
|
+
}
|
|
229
|
+
function normalizeLedgerRecord(raw, key) {
|
|
230
|
+
if (raw === null || typeof raw !== 'object' || (raw.version !== LEDGER_VERSION && raw.version !== PREVIOUS_LEDGER_VERSION && raw.version !== LEGACY_LEDGER_VERSION) || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
|
|
231
|
+
if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
|
|
232
|
+
const invalidTurn = raw.turns.some((turn) => turn === null || typeof turn !== 'object' || typeof turn.key !== 'string' || turn.workspaceId === undefined || !validEventTime(turn.time) || pollutedLedgerKey(turn.key) || (turn.seq !== undefined && !validLedgerSeq(turn.seq)))
|
|
233
|
+
const invalidUsage = raw.usage.some((item) => { const cost = normalizeCostSnapshot(item && item.cost); return item === null || typeof item !== 'object' || !validEventTime(item.time) || item.identity === undefined || pollutedLedgerKey(item.key) || (item.seq !== undefined && !validLedgerSeq(item.seq)) || cost === null || cost.pricingMode !== 'official-model' || isLegacyTieredCost(cost) })
|
|
234
|
+
// Older versions could write records whose top-level workspaceId belonged to
|
|
235
|
+
// a recreated workspace while the historical items kept the previous one;
|
|
236
|
+
// such mixed records must never be reused as a fast path.
|
|
237
|
+
const mixedWorkspace = raw.turns.some((turn) => turn && turn.workspaceId !== raw.workspaceId) || raw.usage.some((item) => item && item.workspaceId !== raw.workspaceId)
|
|
238
|
+
// The rebuild reason is persisted (enumerable) so the flag survives a
|
|
239
|
+
// failure-and-restart cycle even when normalized content alone no longer
|
|
240
|
+
// carries the evidence (e.g. lastSeq -1 after an invalid non-safe value).
|
|
241
|
+
const persistedReason = typeof raw.rebuildRequired === 'string' && raw.rebuildRequired !== '' ? raw.rebuildRequired : undefined
|
|
242
|
+
const computedReason = invalidTurn ? 'invalid-turn' : invalidUsage ? 'invalid-usage' : mixedWorkspace ? 'mixed-workspace' : (raw.lastSeq !== undefined && !validLedgerSeq(raw.lastSeq)) ? 'invalid-last-seq' : raw.version !== LEDGER_VERSION ? 'legacy-version' : !Number.isFinite(raw.updatedAt) ? 'invalid-updated-at' : undefined
|
|
243
|
+
const rebuildReason = persistedReason !== undefined ? persistedReason : computedReason
|
|
244
|
+
const needsUpgrade = rebuildReason !== undefined || !Number.isFinite(raw.updatedAt) || raw.version !== LEDGER_VERSION
|
|
245
|
+
const turnMap = new Map()
|
|
246
|
+
for (const turn of raw.turns) {
|
|
247
|
+
if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && validEventTime(turn.time)) turnMap.set(turn.key, { key: turn.key, seq: Number.isSafeInteger(turn.seq) ? turn.seq : -1, time: turn.time, workspaceId: turn.workspaceId, turn: typeof turn.turn === 'number' ? turn.turn : null, identity: coerceIdentity(turn.identity || turn.modelId) })
|
|
248
|
+
}
|
|
249
|
+
const usageMap = new Map()
|
|
250
|
+
for (const item of raw.usage) {
|
|
251
|
+
if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || !validEventTime(item.time) || item.values === null || typeof item.values !== 'object') continue
|
|
252
|
+
const identity = coerceIdentity(item.identity || item.modelId)
|
|
253
|
+
const normalizedCost = normalizeCostSnapshot(item.cost)
|
|
254
|
+
const normalized = { key: item.key, seq: Number.isSafeInteger(item.seq) ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, identity, modelId: identity.label, ...(normalizedCost === null ? {} : { cost: normalizedCost }), turn: typeof item.turn === 'number' ? item.turn : null, step: typeof item.step === 'number' ? item.step : null, values: usageValues({ inputTokens: item.values.input, outputTokens: item.values.output, cacheReadTokens: item.values.cacheRead, cacheWriteTokens: item.values.cacheWrite, reasoningTokens: item.values.reasoning }) }
|
|
255
|
+
upsertUsageSampleState(usageMap, normalized)
|
|
256
|
+
}
|
|
257
|
+
const updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : 0
|
|
258
|
+
state.ledgerRevision = Math.max(Number.isFinite(state.ledgerRevision) ? state.ledgerRevision : 0, updatedAt)
|
|
259
|
+
const lastUsage = Array.from(usageMap.values()).at(-1)
|
|
260
|
+
const lastIdentity = coerceIdentity(raw.lastIdentity || raw.sourceRevisionIdentity || (lastUsage && lastUsage.identity))
|
|
261
|
+
const normalizedRecord = { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, lastSeq: validLedgerSeq(raw.lastSeq) ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, lastRevision: typeof raw.lastRevision === 'string' ? raw.lastRevision : (typeof raw.sourceRevision === 'string' ? raw.sourceRevision : undefined), sourceRevision: typeof raw.sourceRevision === 'string' ? raw.sourceRevision : (typeof raw.lastRevision === 'string' ? raw.lastRevision : undefined), lastIdentity, turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
|
|
262
|
+
if (rebuildReason !== undefined) normalizedRecord.rebuildRequired = rebuildReason
|
|
263
|
+
Object.defineProperty(normalizedRecord, 'needsUpgrade', { value: needsUpgrade, enumerable: false, writable: true })
|
|
264
|
+
return normalizedRecord
|
|
265
|
+
}
|
|
266
|
+
function prepareLedgerRecord(record) {
|
|
267
|
+
if (record === null || record === undefined) return record
|
|
268
|
+
let changed = record.needsUpgrade === true || record.version !== LEDGER_VERSION
|
|
269
|
+
for (const item of record.usage) {
|
|
270
|
+
const cost = normalizeCostSnapshot(item.cost)
|
|
271
|
+
if (isLegacyTieredCost(cost)) {
|
|
272
|
+
item.cost = calculateCost(item.values, { ...cost, status: 'unsupported', reason: 'tiered-pricing-not-modeled' })
|
|
273
|
+
changed = true
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
if (cost !== null && cost.pricingMode === 'official-model') { item.cost = cost; continue }
|
|
277
|
+
const identity = item.identity || identityFromLegacy(item.modelId)
|
|
278
|
+
item.cost = calculateCost(item.values, resolveCurrentPricing(identity))
|
|
279
|
+
changed = true
|
|
280
|
+
}
|
|
281
|
+
if (changed) {
|
|
282
|
+
// Repacking costs must never clear the rebuild flag: only a complete
|
|
283
|
+
// rebuild from the source log produces a record that is safe to reuse.
|
|
284
|
+
record.version = LEDGER_VERSION
|
|
285
|
+
record.updatedAt = nextLedgerRevision()
|
|
286
|
+
state.ledgerRecords.set(record.sessionId, record)
|
|
287
|
+
void persistLedgerRecord(record)
|
|
288
|
+
}
|
|
289
|
+
return record
|
|
290
|
+
}
|
|
291
|
+
function applyLedgerRecord(record, materialization = 'ledger-reuse') {
|
|
292
|
+
if (record === null || record === undefined) return
|
|
293
|
+
prepareLedgerRecord(record)
|
|
294
|
+
if (record.lastIdentity !== undefined) state.sessionModel.set(record.sessionId, record.lastIdentity)
|
|
295
|
+
for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId, turn.turn, turn.identity, materialization, turn.seq)
|
|
296
|
+
for (const item of record.usage) {
|
|
297
|
+
const identity = item.identity || identityFromLegacy(item.modelId)
|
|
298
|
+
const dates = dateKeys(item.time)
|
|
299
|
+
upsertUsageSample(state.usageByStep, {
|
|
300
|
+
key: item.key,
|
|
301
|
+
seq: item.seq,
|
|
302
|
+
wsId: item.workspaceId,
|
|
303
|
+
time: item.time,
|
|
304
|
+
date: dates.local,
|
|
305
|
+
dateUtc: dates.utc,
|
|
306
|
+
values: item.values,
|
|
307
|
+
identity,
|
|
308
|
+
modelId: identity.label,
|
|
309
|
+
cost: item.cost,
|
|
310
|
+
turn: item.turn,
|
|
311
|
+
step: item.step,
|
|
312
|
+
materialization,
|
|
313
|
+
sid: record.sessionId,
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
if (record.turns.length > 0 || record.usage.length > 0) {
|
|
317
|
+
state.sessionCount.add(record.sessionId)
|
|
318
|
+
if (record.usage.length > 0) markStatsChanged('scan')
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function ledgerRank(record) {
|
|
322
|
+
return [Number.isFinite(record.lastSeq) ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, Number.isFinite(record.updatedAt) ? record.updatedAt : 0]
|
|
323
|
+
}
|
|
324
|
+
function replaceLedgerRecord(record) {
|
|
325
|
+
assertLedgerRecord(record)
|
|
326
|
+
state.ledgerRecords.set(record.sessionId, record)
|
|
327
|
+
return record
|
|
328
|
+
}
|
|
329
|
+
function assertLedgerRecord(record) {
|
|
330
|
+
if (record === null || typeof record !== 'object' || !Number.isFinite(record.updatedAt)) throw new TypeError('usage ledger updatedAt must be a finite number')
|
|
331
|
+
return record
|
|
332
|
+
}
|
|
333
|
+
function storeLedgerRecord(record) {
|
|
334
|
+
assertLedgerRecord(record)
|
|
335
|
+
const current = state.ledgerRecords.get(record.sessionId)
|
|
336
|
+
if (current !== undefined) {
|
|
337
|
+
const nextRank = ledgerRank(record)
|
|
338
|
+
const currentRank = ledgerRank(current)
|
|
339
|
+
if (nextRank[0] < currentRank[0] || (nextRank[0] === currentRank[0] && (nextRank[1] < currentRank[1] || (nextRank[1] === currentRank[1] && nextRank[2] <= currentRank[2])))) return current
|
|
340
|
+
}
|
|
341
|
+
state.ledgerRecords.set(record.sessionId, record)
|
|
342
|
+
return record
|
|
343
|
+
}
|
|
344
|
+
function resolveLedgerWaiters(sid) {
|
|
345
|
+
const waiters = state.ledgerWriteWaiters.get(sid)
|
|
346
|
+
if (waiters === undefined) return
|
|
347
|
+
state.ledgerWriteWaiters.delete(sid)
|
|
348
|
+
for (const resolve of waiters) resolve()
|
|
349
|
+
}
|
|
350
|
+
async function drainPendingLedgerWrites() {
|
|
351
|
+
while (state.ledgerPending.size > 0) {
|
|
352
|
+
const entry = state.ledgerPending.entries().next().value
|
|
353
|
+
if (entry === undefined) break
|
|
354
|
+
const [sid, record] = entry
|
|
355
|
+
state.ledgerPending.delete(sid)
|
|
356
|
+
if (state.ledgerUnit === null || state.ledgerRecords.get(sid) !== record) {
|
|
357
|
+
if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
|
|
358
|
+
continue
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
await state.ledgerUnit.putRecord('sessions', sid, record)
|
|
362
|
+
state.ledgerWriteFailedSessions.delete(sid)
|
|
363
|
+
} catch (err) {
|
|
364
|
+
state.ledgerWriteFailedSessions = state.ledgerWriteFailedSessions || new Set()
|
|
365
|
+
state.ledgerWriteFailedSessions.add(sid)
|
|
366
|
+
state.ledgerDirtySessions = state.ledgerDirtySessions || new Set()
|
|
367
|
+
state.ledgerDirtyEpochs = state.ledgerDirtyEpochs || new Map()
|
|
368
|
+
state.ledgerDirtySessions.add(sid)
|
|
369
|
+
state.ledgerDirtyEpochs.set(sid, (state.ledgerDirtyEpochs.get(sid) || 0) + 1)
|
|
370
|
+
console.error('[all-usage] usage ledger write failed:', err)
|
|
371
|
+
}
|
|
372
|
+
if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function startLedgerWrite() {
|
|
376
|
+
if (state.ledgerWriteRunning || state.ledgerPending.size === 0 || state.ledgerUnit === null) return
|
|
377
|
+
state.ledgerWriteRunning = true
|
|
378
|
+
const write = state.ledgerWriteChain.then(() => drainPendingLedgerWrites(), () => drainPendingLedgerWrites())
|
|
379
|
+
state.ledgerWriteChain = write.catch((err) => {
|
|
380
|
+
console.error('[all-usage] usage ledger queue failed:', err)
|
|
381
|
+
}).finally(() => {
|
|
382
|
+
state.ledgerWriteRunning = false
|
|
383
|
+
if (state.ledgerPending.size > 0 && state.ledgerUnit !== null) scheduleLedgerWrite()
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
function scheduleLedgerWrite() {
|
|
387
|
+
if (state.ledgerWriteScheduled || state.ledgerWriteRunning || state.ledgerPending.size === 0 || state.ledgerUnit === null) return
|
|
388
|
+
state.ledgerWriteScheduled = true
|
|
389
|
+
const run = () => {
|
|
390
|
+
state.ledgerWriteScheduled = false
|
|
391
|
+
state.ledgerWriteTimer = null
|
|
392
|
+
startLedgerWrite()
|
|
393
|
+
}
|
|
394
|
+
state.ledgerWriteTimer = setTimeout(run, LEDGER_WRITE_DEBOUNCE_MS)
|
|
395
|
+
// Keep a reference while write waiters are pending: the debounce timer is
|
|
396
|
+
// what eventually resolves persistLedgerRecord promises, and an unreferenced
|
|
397
|
+
// timer lets the event loop (and test runs) settle before the write happens.
|
|
398
|
+
if (state.ledgerWriteTimer && typeof state.ledgerWriteTimer.unref === 'function' && state.ledgerWriteWaiters.size === 0) state.ledgerWriteTimer.unref()
|
|
399
|
+
}
|
|
400
|
+
function persistLedgerRecord(record) {
|
|
401
|
+
if (record !== null && record !== undefined) assertLedgerRecord(record)
|
|
402
|
+
if (record === null || record === undefined || state.disposed || state.ledgerUnit === null) return Promise.resolve()
|
|
403
|
+
if (state.ledgerRecords.get(record.sessionId) !== record) return Promise.resolve()
|
|
404
|
+
const waiter = new Promise((resolve) => {
|
|
405
|
+
const waiters = state.ledgerWriteWaiters.get(record.sessionId) || []
|
|
406
|
+
waiters.push(resolve)
|
|
407
|
+
state.ledgerWriteWaiters.set(record.sessionId, waiters)
|
|
408
|
+
})
|
|
409
|
+
state.ledgerPending.set(record.sessionId, record)
|
|
410
|
+
scheduleLedgerWrite()
|
|
411
|
+
return waiter
|
|
412
|
+
}
|
|
413
|
+
async function drainLedgerWrites() {
|
|
414
|
+
if (state.ledgerWriteTimer !== null) {
|
|
415
|
+
clearTimeout(state.ledgerWriteTimer)
|
|
416
|
+
state.ledgerWriteTimer = null
|
|
417
|
+
}
|
|
418
|
+
state.ledgerWriteScheduled = false
|
|
419
|
+
if (state.ledgerPending.size > 0 && !state.ledgerWriteRunning && state.ledgerUnit !== null) startLedgerWrite()
|
|
420
|
+
await state.ledgerWriteChain
|
|
421
|
+
if (state.ledgerPending.size > 0 && !state.ledgerWriteRunning && state.ledgerUnit !== null) {
|
|
422
|
+
startLedgerWrite()
|
|
423
|
+
await state.ledgerWriteChain
|
|
424
|
+
}
|
|
425
|
+
for (const sid of Array.from(state.ledgerWriteWaiters.keys())) {
|
|
426
|
+
if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async function loadLedger() {
|
|
430
|
+
if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
|
|
431
|
+
try {
|
|
432
|
+
const backend = storage.backend.get('json')
|
|
433
|
+
if (backend === undefined || backend === null || backend.kv === undefined) return
|
|
434
|
+
const opened = await openLedgerUnitStore(backend)
|
|
435
|
+
if (state.disposed) { await opened.store.close(); return }
|
|
436
|
+
state.ledgerUnit = opened.store
|
|
437
|
+
for (const [key, raw] of Object.entries(opened.rows)) {
|
|
438
|
+
const record = normalizeLedgerRecord(raw, key)
|
|
439
|
+
if (record === null) console.warn('[all-usage] ignoring malformed usage ledger row:', key)
|
|
440
|
+
else state.ledgerRecords.set(key, record)
|
|
441
|
+
}
|
|
442
|
+
} catch (err) {
|
|
443
|
+
console.error('[all-usage] usage ledger unavailable:', err)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
nextLedgerRevision,
|
|
449
|
+
ledgerEventKey,
|
|
450
|
+
buildLedgerRecord,
|
|
451
|
+
normalizeLedgerRecord,
|
|
452
|
+
prepareLedgerRecord,
|
|
453
|
+
applyLedgerRecord,
|
|
454
|
+
ledgerRank,
|
|
455
|
+
replaceLedgerRecord,
|
|
456
|
+
storeLedgerRecord,
|
|
457
|
+
persistLedgerRecord,
|
|
458
|
+
drainLedgerWrites,
|
|
459
|
+
assertLedgerRecord,
|
|
460
|
+
loadLedger
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export { LEDGER_VERSION, PREVIOUS_LEDGER_VERSION, LEGACY_LEDGER_VERSION, LEDGER_SHARD_COUNT, ledgerShardIndex }
|