@tduyng/pi-cost 0.0.0-stage → 0.0.2
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/LICENSE +178 -0
- package/README.md +69 -2
- package/package.json +47 -4
- package/src/cost/config.ts +46 -0
- package/src/cost/index.ts +460 -0
- package/src/cost/logic.ts +513 -0
- package/src/cost/overhead.ts +70 -0
- package/src/statusline/index.ts +162 -0
- package/src/statusline/logic.ts +68 -0
- package/src/timestamps/index.ts +43 -0
- package/src/timestamps/logic.ts +77 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import { createReadStream } from 'node:fs'
|
|
3
|
+
import { createInterface } from 'node:readline'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import type { JsonObject, JsonValue } from '@earendil-works/pi-ai'
|
|
6
|
+
import { Validator } from 'ata-validator'
|
|
7
|
+
import { t } from 'ata-validator/t'
|
|
8
|
+
|
|
9
|
+
const JsonRecord = t.object({}, { additionalProperties: true })
|
|
10
|
+
const isJsonRecord = new Validator(JsonRecord).isValidObject
|
|
11
|
+
const JsonArray = t.array(t.unknown())
|
|
12
|
+
const isJsonArray = new Validator(JsonArray).isValidObject
|
|
13
|
+
const NonemptyString = t.string({ minLength: 1 })
|
|
14
|
+
const isNonemptyString = new Validator(NonemptyString).isValidObject
|
|
15
|
+
const TimestampString = t.string()
|
|
16
|
+
const isTimestampString = new Validator(TimestampString).isValidObject
|
|
17
|
+
const TimestampNumber = t.number()
|
|
18
|
+
const isTimestampNumber = new Validator(TimestampNumber).isValidObject
|
|
19
|
+
|
|
20
|
+
function isRecord<T>(value: T): value is T & JsonObject {
|
|
21
|
+
return isJsonRecord(value)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isArray<T>(value: T): value is T & JsonValue[] {
|
|
25
|
+
return isJsonArray(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LimitRow {
|
|
29
|
+
label: string
|
|
30
|
+
percent?: number
|
|
31
|
+
resetAt?: number
|
|
32
|
+
detail?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TokenTotals {
|
|
36
|
+
input: number
|
|
37
|
+
output: number
|
|
38
|
+
cacheRead: number
|
|
39
|
+
cacheWrite: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SessionTotals extends TokenTotals {
|
|
43
|
+
turns: number
|
|
44
|
+
wallMs: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ProviderTokenRow extends TokenTotals {
|
|
48
|
+
provider: string
|
|
49
|
+
window: string
|
|
50
|
+
costUsd: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface UsageRecord extends TokenTotals {
|
|
54
|
+
provider: string
|
|
55
|
+
at: number
|
|
56
|
+
costUsd: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AnthropicWindow {
|
|
60
|
+
utilization?: number | null
|
|
61
|
+
resets_at?: string | number | null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AnthropicUsagePayload {
|
|
65
|
+
five_hour?: AnthropicWindow | null
|
|
66
|
+
seven_day?: AnthropicWindow | null
|
|
67
|
+
seven_day_opus?: AnthropicWindow | null
|
|
68
|
+
seven_day_sonnet?: AnthropicWindow | null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface OpenCodeUsagePayload {
|
|
72
|
+
usage?: {
|
|
73
|
+
rolling?: { status?: string | null; percent?: number | null; resetsAt?: string | null } | null
|
|
74
|
+
weekly?: { status?: string | null; percent?: number | null; resetsAt?: string | null } | null
|
|
75
|
+
monthly?: { status?: string | null; percent?: number | null; resetsAt?: string | null } | null
|
|
76
|
+
} | null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CommandCodeWindow {
|
|
80
|
+
used?: number | null
|
|
81
|
+
cap?: number | null
|
|
82
|
+
resetAt?: string | number | null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface CommandCodeCreditsPayload {
|
|
86
|
+
credits?: {
|
|
87
|
+
monthlyCredits?: number | null
|
|
88
|
+
purchasedCredits?: number | null
|
|
89
|
+
freeCredits?: number | null
|
|
90
|
+
} | null
|
|
91
|
+
windowLimits?: {
|
|
92
|
+
fiveHour?: CommandCodeWindow | null
|
|
93
|
+
weekly?: CommandCodeWindow | null
|
|
94
|
+
} | null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function finite(value: number | null | undefined): number | undefined {
|
|
98
|
+
const numeric = value ?? Number.NaN
|
|
99
|
+
return Number.isFinite(numeric) ? numeric : undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resetAtMs(value: string | number | null | undefined): number | undefined {
|
|
103
|
+
if (value === null || value === undefined) return undefined
|
|
104
|
+
let parsed: number
|
|
105
|
+
if (isTimestampNumber(value)) {
|
|
106
|
+
parsed = value
|
|
107
|
+
} else if (isTimestampString(value)) {
|
|
108
|
+
const text = value.trim()
|
|
109
|
+
parsed = /^\d+$/.test(text) ? Number(text) : Date.parse(text)
|
|
110
|
+
} else {
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return undefined
|
|
114
|
+
return parsed < 1e12 ? parsed * 1000 : parsed
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface ClaudeCredentialsBlob {
|
|
118
|
+
claudeAiOauth?: { accessToken?: string }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function claudeAccessToken(raw: string | undefined): string | undefined {
|
|
122
|
+
if (!raw) return undefined
|
|
123
|
+
let parsed: ClaudeCredentialsBlob
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(raw) as ClaudeCredentialsBlob
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
if (!parsed || !isRecord(parsed)) return undefined
|
|
130
|
+
const oauth = parsed['claudeAiOauth']
|
|
131
|
+
if (!isRecord(oauth)) return undefined
|
|
132
|
+
const token = oauth['accessToken']
|
|
133
|
+
return isNonemptyString(token) ? token : undefined
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function dollars(used: number | undefined, limit: number | undefined): string | undefined {
|
|
137
|
+
return used !== undefined && limit !== undefined ? `$${used.toFixed(2)} / $${limit.toFixed(2)}` : undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function exhaustedResetAt(payload: AnthropicUsagePayload): number | undefined {
|
|
141
|
+
const windows = [payload.five_hour, payload.seven_day, payload.seven_day_opus, payload.seven_day_sonnet]
|
|
142
|
+
|
|
143
|
+
let latest: number | undefined
|
|
144
|
+
for (const window of windows) {
|
|
145
|
+
const utilization = finite(window?.utilization)
|
|
146
|
+
const resetAt = resetAtMs(window?.resets_at)
|
|
147
|
+
|
|
148
|
+
if (utilization === undefined || utilization < 100 || resetAt === undefined) continue
|
|
149
|
+
latest = latest === undefined ? resetAt : Math.max(latest, resetAt)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return latest
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function commandCodeRows(payload: CommandCodeCreditsPayload): LimitRow[] {
|
|
156
|
+
const rows: LimitRow[] = []
|
|
157
|
+
const windows = [
|
|
158
|
+
['Session (5h)', payload.windowLimits?.fiveHour],
|
|
159
|
+
['Week', payload.windowLimits?.weekly]
|
|
160
|
+
] as const
|
|
161
|
+
for (const [label, window] of windows) {
|
|
162
|
+
const used = finite(window?.used)
|
|
163
|
+
const cap = finite(window?.cap)
|
|
164
|
+
if (used === undefined || cap === undefined || cap <= 0) continue
|
|
165
|
+
rows.push({
|
|
166
|
+
label,
|
|
167
|
+
percent: (used / cap) * 100,
|
|
168
|
+
resetAt: resetAtMs(window?.resetAt),
|
|
169
|
+
detail: dollars(used, cap)
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
const credits = payload.credits
|
|
173
|
+
if (credits) {
|
|
174
|
+
const remaining =
|
|
175
|
+
(finite(credits.monthlyCredits) ?? 0) +
|
|
176
|
+
(finite(credits.purchasedCredits) ?? 0) +
|
|
177
|
+
(finite(credits.freeCredits) ?? 0)
|
|
178
|
+
rows.push({ label: 'Credits left', detail: `$${remaining.toFixed(2)}` })
|
|
179
|
+
}
|
|
180
|
+
return rows
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function openCodeRows(payload: OpenCodeUsagePayload): LimitRow[] {
|
|
184
|
+
const rows: LimitRow[] = []
|
|
185
|
+
const windows = [
|
|
186
|
+
['Session (5h)', payload.usage?.rolling],
|
|
187
|
+
['Week', payload.usage?.weekly],
|
|
188
|
+
['Month', payload.usage?.monthly]
|
|
189
|
+
] as const
|
|
190
|
+
for (const [label, window] of windows) {
|
|
191
|
+
const percent = finite(window?.percent)
|
|
192
|
+
if (percent === undefined) continue
|
|
193
|
+
rows.push({ label, percent, resetAt: resetAtMs(window?.resetsAt) })
|
|
194
|
+
}
|
|
195
|
+
return rows
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function anthropicRows(payload: AnthropicUsagePayload): LimitRow[] {
|
|
199
|
+
const rows: LimitRow[] = []
|
|
200
|
+
const windows = [
|
|
201
|
+
['Session (5h)', payload.five_hour],
|
|
202
|
+
['Week', payload.seven_day],
|
|
203
|
+
['Week (Opus)', payload.seven_day_opus],
|
|
204
|
+
['Week (Sonnet)', payload.seven_day_sonnet]
|
|
205
|
+
] as const
|
|
206
|
+
for (const [label, window] of windows) {
|
|
207
|
+
const percent = finite(window?.utilization)
|
|
208
|
+
if (percent === undefined) continue
|
|
209
|
+
rows.push({ label, percent, resetAt: resetAtMs(window?.resets_at) })
|
|
210
|
+
}
|
|
211
|
+
return rows
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const BAR_WIDTH = 24
|
|
215
|
+
|
|
216
|
+
export function bar(percent: number): string {
|
|
217
|
+
const clamped = Math.max(0, Math.min(100, percent))
|
|
218
|
+
const filled = Math.round((clamped / 100) * BAR_WIDTH)
|
|
219
|
+
return '█'.repeat(filled) + '·'.repeat(BAR_WIDTH - filled)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function formatReset(resetAt: number | undefined, now = Date.now()): string | undefined {
|
|
223
|
+
if (resetAt === undefined || resetAt <= now) return undefined
|
|
224
|
+
const date = new Date(resetAt)
|
|
225
|
+
const time = date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
|
|
226
|
+
if (new Date(now).toDateString() === date.toDateString()) return `resets ${time}`
|
|
227
|
+
return `resets ${date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} at ${time}`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function formatTokens(tokens: number): string {
|
|
231
|
+
if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(1)}B`
|
|
232
|
+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
|
|
233
|
+
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`
|
|
234
|
+
return String(tokens)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function formatTokenCell(tokens: number): string {
|
|
238
|
+
return tokens === 0 ? '·' : formatTokens(tokens)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function formatCacheHit(totals: TokenTotals): string | undefined {
|
|
242
|
+
const prompt = totals.input + totals.cacheRead + totals.cacheWrite
|
|
243
|
+
if (prompt === 0) return undefined
|
|
244
|
+
return `${Math.round((totals.cacheRead / prompt) * 100)}%`
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function formatDuration(ms: number): string {
|
|
248
|
+
const totalMinutes = Math.floor(ms / 60_000)
|
|
249
|
+
const hours = Math.floor(totalMinutes / 60)
|
|
250
|
+
if (hours > 0) return `${hours}h ${totalMinutes % 60}m`
|
|
251
|
+
if (totalMinutes > 0) return `${totalMinutes}m`
|
|
252
|
+
return `${Math.round(ms / 1000)}s`
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface SessionTurn {
|
|
256
|
+
usage: { input: number; output: number; cacheRead: number; cacheWrite: number } | undefined
|
|
257
|
+
timestamp: string
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function sessionTotals(turns: SessionTurn[], now = Date.now()): SessionTotals {
|
|
261
|
+
const totals: SessionTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0, wallMs: 0 }
|
|
262
|
+
let firstMs: number | undefined
|
|
263
|
+
for (const turn of turns) {
|
|
264
|
+
const started = Date.parse(turn.timestamp)
|
|
265
|
+
if (Number.isFinite(started) && (firstMs === undefined || started < firstMs)) firstMs = started
|
|
266
|
+
if (!turn.usage) continue
|
|
267
|
+
totals.input += turn.usage.input
|
|
268
|
+
totals.output += turn.usage.output
|
|
269
|
+
totals.cacheRead += turn.usage.cacheRead
|
|
270
|
+
totals.cacheWrite += turn.usage.cacheWrite
|
|
271
|
+
totals.turns += 1
|
|
272
|
+
}
|
|
273
|
+
if (firstMs !== undefined) totals.wallMs = Math.max(0, now - firstMs)
|
|
274
|
+
return totals
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
interface RawSessionLine {
|
|
278
|
+
type?: string
|
|
279
|
+
timestamp?: string
|
|
280
|
+
message?: {
|
|
281
|
+
role?: string
|
|
282
|
+
provider?: string
|
|
283
|
+
timestamp?: number
|
|
284
|
+
usage?: {
|
|
285
|
+
input?: number
|
|
286
|
+
output?: number
|
|
287
|
+
cacheRead?: number
|
|
288
|
+
cacheWrite?: number
|
|
289
|
+
cost?: { total?: number | null }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function parseUsageLine(line: string): UsageRecord | undefined {
|
|
295
|
+
if (!line.includes('"usage"')) return undefined
|
|
296
|
+
let entry: RawSessionLine
|
|
297
|
+
try {
|
|
298
|
+
entry = JSON.parse(line) as RawSessionLine
|
|
299
|
+
} catch {
|
|
300
|
+
return undefined
|
|
301
|
+
}
|
|
302
|
+
const usage = entry.message?.usage
|
|
303
|
+
if (entry.type !== 'message' || entry.message?.role !== 'assistant' || !usage) return undefined
|
|
304
|
+
const at = resetAtMs(entry.message.timestamp ?? entry.timestamp)
|
|
305
|
+
if (at === undefined) return undefined
|
|
306
|
+
return {
|
|
307
|
+
provider: entry.message.provider ?? 'unknown',
|
|
308
|
+
at,
|
|
309
|
+
costUsd: Math.max(0, finite(usage.cost?.total) ?? 0),
|
|
310
|
+
input: usage.input ?? 0,
|
|
311
|
+
output: usage.output ?? 0,
|
|
312
|
+
cacheRead: usage.cacheRead ?? 0,
|
|
313
|
+
cacheWrite: usage.cacheWrite ?? 0
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function monthStart(now: number): number {
|
|
318
|
+
const date = new Date(now)
|
|
319
|
+
return new Date(date.getFullYear(), date.getMonth(), 1).getTime()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const TOKEN_WINDOWS = [
|
|
323
|
+
['5h', (now: number) => now - 5 * 3_600_000],
|
|
324
|
+
['7d', (now: number) => now - 7 * 86_400_000],
|
|
325
|
+
['30d', (now: number) => now - 30 * 86_400_000],
|
|
326
|
+
['month', monthStart]
|
|
327
|
+
] as const
|
|
328
|
+
|
|
329
|
+
export function tokenScanSince(now = Date.now()): number {
|
|
330
|
+
return Math.min(...TOKEN_WINDOWS.map(([, start]) => start(now)))
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function totalTokens(totals: TokenTotals): number {
|
|
334
|
+
return totals.input + totals.output + totals.cacheRead + totals.cacheWrite
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface CachedFile {
|
|
338
|
+
mtimeMs: number
|
|
339
|
+
size: number
|
|
340
|
+
records: UsageRecord[]
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export interface CachedQuota {
|
|
344
|
+
rows: LimitRow[]
|
|
345
|
+
fetchedAt: number
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export interface CostCache {
|
|
349
|
+
quota: Record<string, CachedQuota>
|
|
350
|
+
files: Record<string, CachedFile>
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const CACHE_VERSION = 2
|
|
354
|
+
|
|
355
|
+
export const MAX_COST_CACHE_BYTES = 8 * 1024 * 1024
|
|
356
|
+
|
|
357
|
+
export const QUOTA_STALE_MS = 24 * 3_600_000
|
|
358
|
+
|
|
359
|
+
export function cachedQuota(cache: CostCache, provider: string, now = Date.now()): CachedQuota | undefined {
|
|
360
|
+
const cached = cache.quota[provider]
|
|
361
|
+
return cached && now - cached.fetchedAt <= QUOTA_STALE_MS ? cached : undefined
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function cachedRecords(cache: CostCache, since: number): UsageRecord[] {
|
|
365
|
+
return Object.values(cache.files).flatMap((entry) => {
|
|
366
|
+
if (!entry || !Array.isArray(entry.records)) return []
|
|
367
|
+
return entry.records.filter((record) => record != null && record.at >= since)
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function emptyCostCache(): CostCache {
|
|
372
|
+
return { quota: {}, files: {} }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function readCostCache(file: string): Promise<CostCache> {
|
|
376
|
+
const stat = await fs.stat(file).catch(() => undefined)
|
|
377
|
+
if (!stat || stat.size > MAX_COST_CACHE_BYTES) return emptyCostCache()
|
|
378
|
+
const raw = await fs.readFile(file, 'utf8').catch(() => undefined)
|
|
379
|
+
if (raw === undefined) return emptyCostCache()
|
|
380
|
+
try {
|
|
381
|
+
const parsed = JSON.parse(raw) as Partial<CostCache> & { version?: number }
|
|
382
|
+
if (parsed.version !== CACHE_VERSION) return emptyCostCache()
|
|
383
|
+
return { quota: parsed.quota ?? {}, files: parsed.files ?? {} }
|
|
384
|
+
} catch {
|
|
385
|
+
return emptyCostCache()
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The cache is the one artefact here that outlives the command that wrote it, and
|
|
391
|
+
* `/cost` reads provider API keys to ask each provider for a usage window. Nothing
|
|
392
|
+
* puts those keys in a row today, so the writer is where the property is made true
|
|
393
|
+
* rather than left to every caller staying careful.
|
|
394
|
+
*/
|
|
395
|
+
const SECRET_KEY = /^(?:access_?token|api_?key|authorization|token|secret)$/i
|
|
396
|
+
const SECRET_VALUE = /\b(?:sk-ant-[A-Za-z0-9_-]{8,}|Bearer\s+[A-Za-z0-9._~+/-]{12,})/g
|
|
397
|
+
|
|
398
|
+
export function redactSecrets<T>(value: T): T {
|
|
399
|
+
if (isNonemptyString(value)) return value.replace(SECRET_VALUE, '[redacted]') as T
|
|
400
|
+
if (isArray(value)) return value.map((entry) => redactSecrets(entry)) as T
|
|
401
|
+
if (!isRecord(value)) return value
|
|
402
|
+
return Object.fromEntries(
|
|
403
|
+
Object.entries(value).map(([key, entry]) => [key, SECRET_KEY.test(key) ? '[redacted]' : redactSecrets(entry)])
|
|
404
|
+
) as T
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function replaceFile(file: string, serialized: string): Promise<void> {
|
|
408
|
+
const temporary = `${file}.${process.pid}.tmp`
|
|
409
|
+
await fs.writeFile(temporary, serialized)
|
|
410
|
+
await fs.rename(temporary, file)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export async function writeCostCache(file: string, cache: CostCache): Promise<void> {
|
|
414
|
+
let payload = { version: CACHE_VERSION, ...redactSecrets(cache) }
|
|
415
|
+
let serialized = JSON.stringify(payload)
|
|
416
|
+
if (Buffer.byteLength(serialized) <= MAX_COST_CACHE_BYTES) {
|
|
417
|
+
await replaceFile(file, serialized).catch(() => {})
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const files = Object.entries(cache.files).toSorted((a, b) => b[1].mtimeMs - a[1].mtimeMs)
|
|
422
|
+
for (const [name] of files) {
|
|
423
|
+
delete payload.files[name]
|
|
424
|
+
serialized = JSON.stringify(payload)
|
|
425
|
+
if (Buffer.byteLength(serialized) <= MAX_COST_CACHE_BYTES) break
|
|
426
|
+
}
|
|
427
|
+
if (Buffer.byteLength(serialized) <= MAX_COST_CACHE_BYTES) {
|
|
428
|
+
await replaceFile(file, serialized).catch(() => {})
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function fileRecords(file: string, since: number): Promise<UsageRecord[]> {
|
|
433
|
+
const input = createReadStream(file, { encoding: 'utf8' })
|
|
434
|
+
const lines = createInterface({ input, crlfDelay: Infinity })
|
|
435
|
+
const records: UsageRecord[] = []
|
|
436
|
+
try {
|
|
437
|
+
for await (const line of lines) {
|
|
438
|
+
const record = parseUsageLine(line)
|
|
439
|
+
if (record && record.at >= since) records.push(record)
|
|
440
|
+
}
|
|
441
|
+
return records
|
|
442
|
+
} catch {
|
|
443
|
+
return []
|
|
444
|
+
} finally {
|
|
445
|
+
lines.close()
|
|
446
|
+
input.destroy()
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export async function scanUsage(roots: readonly string[], since: number, cache: CostCache): Promise<UsageRecord[]> {
|
|
451
|
+
const files: Record<string, CachedFile> = {}
|
|
452
|
+
const records: UsageRecord[] = []
|
|
453
|
+
for (const root of roots) {
|
|
454
|
+
const names = await fs.readdir(root, { recursive: true }).catch(() => [])
|
|
455
|
+
for (const name of names) {
|
|
456
|
+
if (!name.endsWith('.jsonl')) continue
|
|
457
|
+
const file = path.join(root, name)
|
|
458
|
+
const stat = await fs.stat(file).catch(() => undefined)
|
|
459
|
+
if (!stat || stat.mtimeMs < since) continue
|
|
460
|
+
const cached = cache.files[file]
|
|
461
|
+
const entry: CachedFile =
|
|
462
|
+
cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size
|
|
463
|
+
? {
|
|
464
|
+
...cached,
|
|
465
|
+
records: Array.isArray(cached.records)
|
|
466
|
+
? cached.records.filter((record) => record != null && record.at >= since)
|
|
467
|
+
: []
|
|
468
|
+
}
|
|
469
|
+
: { mtimeMs: stat.mtimeMs, size: stat.size, records: await fileRecords(file, since) }
|
|
470
|
+
files[file] = entry
|
|
471
|
+
for (const record of entry.records) if (record.at >= since) records.push(record)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
cache.files = files
|
|
475
|
+
return records
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const EXCLUDED_TOKEN_PROVIDERS = new Set(['gmi', 'mistral', 'google', 'claude-bridge', 'commandcode'])
|
|
479
|
+
|
|
480
|
+
export function providerTokenRows(
|
|
481
|
+
records: UsageRecord[],
|
|
482
|
+
now = Date.now(),
|
|
483
|
+
shown?: readonly string[]
|
|
484
|
+
): ProviderTokenRow[] {
|
|
485
|
+
const present = [...new Set(records.map((record) => record.provider))]
|
|
486
|
+
const providers = shown
|
|
487
|
+
? present.filter((provider) => shown.includes(provider))
|
|
488
|
+
: present.filter((provider) => !EXCLUDED_TOKEN_PROVIDERS.has(provider))
|
|
489
|
+
const groups = providers.map((provider) =>
|
|
490
|
+
TOKEN_WINDOWS.map(([window, start]) => {
|
|
491
|
+
const since = start(now)
|
|
492
|
+
const row: ProviderTokenRow = {
|
|
493
|
+
provider,
|
|
494
|
+
window,
|
|
495
|
+
input: 0,
|
|
496
|
+
output: 0,
|
|
497
|
+
cacheRead: 0,
|
|
498
|
+
cacheWrite: 0,
|
|
499
|
+
costUsd: 0
|
|
500
|
+
}
|
|
501
|
+
for (const record of records) {
|
|
502
|
+
if (record.provider !== provider || record.at < since) continue
|
|
503
|
+
row.input += record.input
|
|
504
|
+
row.output += record.output
|
|
505
|
+
row.cacheRead += record.cacheRead
|
|
506
|
+
row.cacheWrite += record.cacheWrite
|
|
507
|
+
row.costUsd += record.costUsd
|
|
508
|
+
}
|
|
509
|
+
return row
|
|
510
|
+
}).filter((row) => totalTokens(row) > 0)
|
|
511
|
+
)
|
|
512
|
+
return groups.toSorted((a, b) => (a[0]?.provider ?? '').localeCompare(b[0]?.provider ?? '')).flat()
|
|
513
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { Validator, type Infer } from 'ata-validator'
|
|
5
|
+
import { t } from 'ata-validator/t'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What an extension spent on its own model calls, in one append-only file next to the other
|
|
9
|
+
* agent state. A child session and a harness-loop writer bill to the plan the person already
|
|
10
|
+
* has, and neither lands in the parent's transcript, so `/cost` cannot see them without this.
|
|
11
|
+
*/
|
|
12
|
+
const OverheadSchema = t.object({
|
|
13
|
+
at: t.number(),
|
|
14
|
+
extension: t.string(),
|
|
15
|
+
sessionId: t.string(),
|
|
16
|
+
purpose: t.string(),
|
|
17
|
+
input: t.number(),
|
|
18
|
+
output: t.number(),
|
|
19
|
+
costUsd: t.number()
|
|
20
|
+
})
|
|
21
|
+
const isOverheadSchema = new Validator(OverheadSchema).isValidObject
|
|
22
|
+
|
|
23
|
+
export type OverheadRecord = Infer<typeof OverheadSchema>
|
|
24
|
+
|
|
25
|
+
export interface ExtensionSpend {
|
|
26
|
+
calls: number
|
|
27
|
+
input: number
|
|
28
|
+
output: number
|
|
29
|
+
costUsd: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface OverheadTotals extends ExtensionSpend {
|
|
33
|
+
byExtension: Map<string, ExtensionSpend>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function overheadPath(agentDir: string): string {
|
|
37
|
+
return join(agentDir, 'overhead.jsonl')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readOverhead(agentDir: string, sessionId: string): OverheadRecord[] {
|
|
41
|
+
const path = overheadPath(agentDir)
|
|
42
|
+
if (!existsSync(path)) return []
|
|
43
|
+
const out: OverheadRecord[] = []
|
|
44
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
45
|
+
if (line.trim() === '') continue
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(line)
|
|
48
|
+
if (isOverheadSchema(parsed) && parsed.sessionId === sessionId) out.push(parsed)
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function sumOverhead(records: OverheadRecord[]): OverheadTotals {
|
|
55
|
+
const empty = (): ExtensionSpend => ({ calls: 0, input: 0, output: 0, costUsd: 0 })
|
|
56
|
+
const totals: OverheadTotals = { ...empty(), byExtension: new Map() }
|
|
57
|
+
for (const record of records) {
|
|
58
|
+
const row = totals.byExtension.get(record.extension) ?? empty()
|
|
59
|
+
row.calls += 1
|
|
60
|
+
row.input += record.input
|
|
61
|
+
row.output += record.output
|
|
62
|
+
row.costUsd += record.costUsd
|
|
63
|
+
totals.byExtension.set(record.extension, row)
|
|
64
|
+
totals.calls += 1
|
|
65
|
+
totals.input += record.input
|
|
66
|
+
totals.output += record.output
|
|
67
|
+
totals.costUsd += record.costUsd
|
|
68
|
+
}
|
|
69
|
+
return totals
|
|
70
|
+
}
|