@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.
@@ -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
+ }