@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,460 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import {
5
+ DynamicBorder,
6
+ type ExtensionAPI,
7
+ getAgentDir,
8
+ type ExtensionCommandContext,
9
+ type Theme
10
+ } from '@earendil-works/pi-coding-agent'
11
+ import { Container, matchesKey, Text, truncateToWidth } from '@earendil-works/pi-tui'
12
+
13
+ import { addTurnCost, formatCostUsd, type ModelRates } from '../statusline/logic.js'
14
+ import { loadCostConfig } from './config.js'
15
+ import {
16
+ type AnthropicUsagePayload,
17
+ anthropicRows,
18
+ bar,
19
+ cachedQuota,
20
+ cachedRecords,
21
+ type CommandCodeCreditsPayload,
22
+ commandCodeRows,
23
+ formatCacheHit,
24
+ formatDuration,
25
+ formatReset,
26
+ formatTokenCell,
27
+ formatTokens,
28
+ type LimitRow,
29
+ type OpenCodeUsagePayload,
30
+ claudeAccessToken,
31
+ openCodeRows,
32
+ type ProviderTokenRow,
33
+ providerTokenRows,
34
+ readCostCache,
35
+ scanUsage,
36
+ type SessionTotals,
37
+ type SessionTurn,
38
+ sessionTotals,
39
+ tokenScanSince,
40
+ totalTokens,
41
+ writeCostCache
42
+ } from './logic.js'
43
+ import { readOverhead, sumOverhead, type OverheadTotals } from './overhead.js'
44
+
45
+ const PANEL_SHARE = 0.7
46
+ const REQUEST_TIMEOUT_MS = 10_000
47
+ const KEYCHAIN_TIMEOUT_MS = 5_000
48
+ const LABEL_WIDTH = 20
49
+ const TOKEN_COLUMN_WIDTH = 9
50
+ const TOKEN_HEADERS = ['in', 'out', 'cache rd', 'cache wr', 'total', 'cost']
51
+ const PROVIDER_WIDTH = 15
52
+
53
+ interface SessionSpend {
54
+ costUsd: number
55
+ totals: SessionTotals
56
+ overhead: OverheadTotals
57
+ }
58
+
59
+ interface Section {
60
+ provider: string
61
+ title: string
62
+ rows?: LimitRow[]
63
+ error?: string
64
+ fetchedAt?: number
65
+ }
66
+
67
+ interface QuotaSection {
68
+ provider: string
69
+ title: string
70
+ probe: (ctx: ExtensionCommandContext, pi: ExtensionAPI) => Promise<Section | undefined>
71
+ }
72
+
73
+ const QUOTA_SECTIONS: readonly QuotaSection[] = [
74
+ { provider: 'commandcode', title: 'Command Code', probe: commandCodeSection },
75
+ { provider: 'opencode-go', title: 'OpenCode Go', probe: openCodeGoSection },
76
+ { provider: 'claude', title: 'Claude', probe: (_ctx, pi) => claudeSection(pi) }
77
+ ]
78
+
79
+ async function getJson<T>(url: string, headers: Record<string, string>): Promise<T> {
80
+ const response = await fetch(url, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) })
81
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
82
+ return (await response.json()) as T
83
+ }
84
+
85
+ function errorText(cause: unknown): string {
86
+ return cause instanceof Error ? cause.message : String(cause)
87
+ }
88
+
89
+ export async function claudeToken(
90
+ pi: ExtensionAPI,
91
+ platform: NodeJS.Platform = process.platform
92
+ ): Promise<string | undefined> {
93
+ let raw: string | undefined
94
+ if (platform === 'darwin') {
95
+ const result = await pi.exec('security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], {
96
+ cwd: os.homedir(),
97
+ timeout: KEYCHAIN_TIMEOUT_MS
98
+ })
99
+ if (result.code && result.code !== 44) throw new Error(`keychain lookup failed (exit ${result.code})`)
100
+ raw = result.stdout
101
+ }
102
+ if (!raw) {
103
+ raw = await fs.readFile(path.join(os.homedir(), '.claude', '.credentials.json'), 'utf8').catch(() => undefined)
104
+ }
105
+ return claudeAccessToken(raw)
106
+ }
107
+
108
+ export async function fetchAnthropicUsage(token: string): Promise<AnthropicUsagePayload> {
109
+ return getJson<AnthropicUsagePayload>('https://api.anthropic.com/api/oauth/usage', {
110
+ authorization: `Bearer ${token}`,
111
+ 'anthropic-beta': 'oauth-2025-04-20',
112
+ accept: 'application/json'
113
+ })
114
+ }
115
+
116
+ async function commandCodeSection(ctx: ExtensionCommandContext): Promise<Section | undefined> {
117
+ const key = await ctx.modelRegistry.getApiKeyForProvider('commandcode').catch(() => undefined)
118
+ if (!key) return undefined
119
+ const section: Section = { provider: 'commandcode', title: 'Command Code' }
120
+ try {
121
+ const payload = await getJson<CommandCodeCreditsPayload>('https://api.commandcode.ai/alpha/billing/credits', {
122
+ authorization: `Bearer ${key}`,
123
+ accept: 'application/json'
124
+ })
125
+ section.rows = commandCodeRows(payload)
126
+ if (section.rows.length === 0) section.error = 'no quota reported for this key'
127
+ } catch (error) {
128
+ section.error = errorText(error)
129
+ }
130
+ return section
131
+ }
132
+
133
+ async function openCodeGoSection(ctx: ExtensionCommandContext): Promise<Section | undefined> {
134
+ const key =
135
+ (await ctx.modelRegistry.getApiKeyForProvider('opencode-go').catch(() => undefined)) ||
136
+ process.env['OPENCODE_API_KEY']
137
+ if (!key) return undefined
138
+ const section: Section = { provider: 'opencode-go', title: 'OpenCode Go' }
139
+ try {
140
+ const payload = await getJson<OpenCodeUsagePayload>('https://opencode.ai/zen/go/v1/usage', {
141
+ authorization: `Bearer ${key}`,
142
+ accept: 'application/json'
143
+ })
144
+ section.rows = openCodeRows(payload)
145
+ if (section.rows.length === 0) section.error = 'no usage windows reported'
146
+ } catch (error) {
147
+ section.error = errorText(error)
148
+ }
149
+ return section
150
+ }
151
+
152
+ async function claudeSection(pi: ExtensionAPI): Promise<Section | undefined> {
153
+ const token = await claudeToken(pi)
154
+ if (!token) return undefined
155
+ const section: Section = { provider: 'claude', title: 'Claude' }
156
+ try {
157
+ section.rows = anthropicRows(await fetchAnthropicUsage(token))
158
+ if (section.rows.length === 0) section.error = 'no usage windows reported'
159
+ } catch (error) {
160
+ section.error = errorText(error)
161
+ }
162
+ return section
163
+ }
164
+
165
+ function modelRates(ctx: ExtensionCommandContext): ModelRates | undefined {
166
+ const cost = ctx.model?.cost
167
+ return cost
168
+ ? { input: cost.input, output: cost.output, cacheRead: cost.cacheRead, cacheWrite: cost.cacheWrite }
169
+ : undefined
170
+ }
171
+
172
+ function sessionSpend(ctx: ExtensionCommandContext, overheadRecords: ReturnType<typeof readOverhead>): SessionSpend {
173
+ const turns: SessionTurn[] = []
174
+ let costUsd = 0
175
+ let collapsed = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
176
+ const currentRates = modelRates(ctx)
177
+ const ratesFor = (provider: string, model: string): ModelRates | undefined => {
178
+ const found = ctx.modelRegistry.find(provider, model)
179
+ const cost = found?.cost
180
+ return cost
181
+ ? { input: cost.input, output: cost.output, cacheRead: cost.cacheRead, cacheWrite: cost.cacheWrite }
182
+ : undefined
183
+ }
184
+ for (const entry of ctx.sessionManager.getBranch()) {
185
+ if (entry.type === 'branch_summary') {
186
+ if (entry.usage) {
187
+ const usage = entry.usage
188
+ collapsed = {
189
+ input: collapsed.input + usage.input,
190
+ output: collapsed.output + usage.output,
191
+ cacheRead: collapsed.cacheRead + usage.cacheRead,
192
+ cacheWrite: collapsed.cacheWrite + usage.cacheWrite
193
+ }
194
+ costUsd = addTurnCost(costUsd, usage, currentRates)
195
+ }
196
+ continue
197
+ }
198
+ if (entry.type !== 'message') continue
199
+ if (entry.message.role !== 'assistant') {
200
+ turns.push({ usage: undefined, timestamp: entry.timestamp })
201
+ continue
202
+ }
203
+ const usage = entry.message.usage
204
+ costUsd = addTurnCost(costUsd, usage, ratesFor(entry.message.provider, entry.message.model) ?? currentRates)
205
+ turns.push({ usage, timestamp: entry.timestamp })
206
+ }
207
+ const totals = sessionTotals(turns)
208
+ totals.input += collapsed.input
209
+ totals.output += collapsed.output
210
+ totals.cacheRead += collapsed.cacheRead
211
+ totals.cacheWrite += collapsed.cacheWrite
212
+ return { costUsd, totals, overhead: sumOverhead(overheadRecords) }
213
+ }
214
+
215
+ function sessionLines(session: SessionSpend, theme: Theme): string[] {
216
+ const label = (text: string) => theme.fg('dim', text.padEnd(LABEL_WIDTH))
217
+ const totals = session.totals
218
+ const hit = formatCacheHit(totals)
219
+ const extensions = [...session.overhead.byExtension].map(([name, spend]) => `${name} ${spend.calls}`).join(', ')
220
+ return [
221
+ ` ${label('Cost')}${formatCostUsd(session.costUsd)}`,
222
+ ...(session.overhead.calls === 0
223
+ ? []
224
+ : [
225
+ ` ${label('Extensions')}${formatCostUsd(session.overhead.costUsd)} · ${formatTokens(session.overhead.input)} in · ${formatTokens(session.overhead.output)} out · ${session.overhead.calls} calls (${extensions})`
226
+ ]),
227
+ ` ${label('Tokens')}${formatTokens(totals.input)} in · ${formatTokens(totals.output)} out · ${formatTokens(totals.cacheRead)} cache read · ${formatTokens(totals.cacheWrite)} cache write`,
228
+ ...(hit === undefined
229
+ ? []
230
+ : [` ${label('Cache hit')}${hit} ${theme.fg('dim', 'of prompt tokens served from cache')}`]),
231
+ ` ${label('Turns')}${totals.turns} in ${formatDuration(totals.wallMs)}`
232
+ ]
233
+ }
234
+
235
+ function tokenRowLine(row: ProviderTokenRow, repeated: boolean, cost: string, theme: Theme): string {
236
+ const label = `${(repeated ? '' : row.provider).padEnd(PROVIDER_WIDTH)}${row.window}`
237
+ const breakdown = [row.input, row.output, row.cacheRead, row.cacheWrite, totalTokens(row)]
238
+ .map((value) => formatTokenCell(value).padStart(TOKEN_COLUMN_WIDTH))
239
+ .join('')
240
+ return ` ${theme.fg('dim', label.padEnd(LABEL_WIDTH))}${theme.fg('dim', breakdown + cost.padStart(TOKEN_COLUMN_WIDTH))}`
241
+ }
242
+
243
+ function tokenLines(
244
+ rows: ProviderTokenRow[],
245
+ theme: Theme,
246
+ note: string | undefined,
247
+ costShown: (provider: string) => boolean
248
+ ): string[] {
249
+ const headers = TOKEN_HEADERS.map((header) => header.padStart(TOKEN_COLUMN_WIDTH)).join('')
250
+ const lines = [
251
+ `${theme.fg('accent', 'Tokens by provider')} ${theme.fg('dim', 'all projects')}`,
252
+ ` ${' '.repeat(LABEL_WIDTH)}${theme.fg('dim', headers)}`,
253
+ ...rows.map((row, index) => {
254
+ const cost = !costShown(row.provider) ? '' : row.costUsd > 0 ? formatCostUsd(row.costUsd) : '·'
255
+ return tokenRowLine(row, row.provider === rows[index - 1]?.provider, cost, theme)
256
+ })
257
+ ]
258
+ if (note !== undefined) lines.push(` ${theme.fg('dim', note)}`)
259
+ return lines
260
+ }
261
+
262
+ function severity(percent: number): 'error' | 'warning' | 'success' {
263
+ if (percent >= 90) return 'error'
264
+ if (percent >= 70) return 'warning'
265
+ return 'success'
266
+ }
267
+
268
+ function rowLine(row: LimitRow, theme: Theme): string {
269
+ const detail = [row.detail, formatReset(row.resetAt)].filter(Boolean).join(' ')
270
+ const label = ` ${theme.fg('dim', row.label.padEnd(LABEL_WIDTH))}`
271
+ if (row.percent === undefined) return `${label}${detail}`
272
+ const percent = `${Math.round(row.percent)}%`.padStart(4)
273
+ return [
274
+ label,
275
+ theme.fg(severity(row.percent), bar(row.percent)),
276
+ ` ${percent}`,
277
+ detail ? ` ${theme.fg('dim', detail)}` : ''
278
+ ].join('')
279
+ }
280
+
281
+ function sectionLines(section: Section, theme: Theme): string[] {
282
+ const age =
283
+ section.fetchedAt === undefined
284
+ ? ''
285
+ : ` ${theme.fg('dim', `${formatDuration(Date.now() - section.fetchedAt)} old`)}`
286
+ const lines = [`${theme.fg('accent', `${section.title} (${section.provider})`)}${age}`]
287
+ if (section.error !== undefined) lines.push(` ${theme.fg('warning', section.error)}`)
288
+ if (section.rows) lines.push(...section.rows.map((row) => rowLine(row, theme)))
289
+ else if (section.error === undefined) lines.push(` ${theme.fg('dim', 'loading…')}`)
290
+ return lines
291
+ }
292
+
293
+ export default function costExtension(pi: ExtensionAPI): void {
294
+ pi.registerCommand('cost', {
295
+ description: 'Show session spend and live provider rate-limit windows',
296
+ handler: async (_args, ctx) => {
297
+ if (ctx.mode !== 'tui') {
298
+ ctx.ui.notify('/cost requires TUI mode.', 'warning')
299
+ return
300
+ }
301
+
302
+ const cacheFile = path.join(getAgentDir(), 'cost-cache.json')
303
+ const cache = await readCostCache(cacheFile)
304
+ const { config, problem } = loadCostConfig()
305
+ if (problem !== undefined) ctx.ui.notify(problem, 'warning')
306
+ const costShown = (provider: string) => config.cost === undefined || config.cost.includes(provider)
307
+ const quotaSections = QUOTA_SECTIONS.filter(
308
+ ({ provider }) => config.limits === undefined || config.limits.includes(provider)
309
+ )
310
+
311
+ const sections = new Map<string, Section>(
312
+ quotaSections.map(({ provider, title }) => {
313
+ const cached = cachedQuota(cache, provider)
314
+ return [provider, cached ? { provider, title, ...cached } : { provider, title }]
315
+ })
316
+ )
317
+ let render: (() => void) | undefined
318
+
319
+ const settle = (provider: string, section: Section) => {
320
+ const previous = sections.get(provider)
321
+ if (section.error !== undefined && previous?.rows) {
322
+ sections.set(provider, { ...section, rows: previous.rows, fetchedAt: previous.fetchedAt })
323
+ } else {
324
+ sections.set(provider, section)
325
+ if (section.rows) cache.quota[provider] = { rows: section.rows, fetchedAt: Date.now() }
326
+ }
327
+ render?.()
328
+ }
329
+
330
+ const resolve = (probe: Promise<Section | undefined>, provider: string) =>
331
+ probe.then(
332
+ (section) => {
333
+ if (section) {
334
+ settle(provider, section)
335
+ return
336
+ }
337
+ sections.delete(provider)
338
+ delete cache.quota[provider]
339
+ render?.()
340
+ },
341
+ (cause: unknown) =>
342
+ settle(provider, {
343
+ provider,
344
+ title: sections.get(provider)?.title ?? provider,
345
+ error: errorText(cause)
346
+ })
347
+ )
348
+
349
+ const probes = quotaSections.map(({ provider, probe }) => resolve(probe(ctx, pi), provider))
350
+
351
+ const sessionDir = ctx.sessionManager.getSessionDir()
352
+ const agentDir = getAgentDir()
353
+ const defaultRoot = path.join(agentDir, 'sessions')
354
+ // Background jobs keep their own sessions under the agent directory, and their
355
+ // spend is this suite's least visible cost, so they are scanned with the rest.
356
+ const scanRoots = sessionDir.startsWith(defaultRoot + path.sep)
357
+ ? [defaultRoot, path.join(agentDir, 'jobs')]
358
+ : [sessionDir]
359
+ const since = tokenScanSince()
360
+ const seeded = cachedRecords(cache, since)
361
+ let windowRows = seeded.length > 0 ? providerTokenRows(seeded, Date.now(), config.providers) : undefined
362
+ const scan = scanUsage(scanRoots, since, cache).then((records) => {
363
+ windowRows = providerTokenRows(records, Date.now(), config.providers)
364
+ render?.()
365
+ })
366
+
367
+ void Promise.allSettled([...probes, scan]).then(() => writeCostCache(cacheFile, cache))
368
+
369
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
370
+ const container = new Container()
371
+ const border = new DynamicBorder((s: string) => theme.fg('accent', s))
372
+ const body = new Text('', 1, 0)
373
+ const footer = new Text('', 1, 0)
374
+ container.addChild(border)
375
+ container.addChild(body)
376
+ container.addChild(footer)
377
+ container.addChild(border)
378
+
379
+ const usage = ctx.getContextUsage()
380
+ const overheadRecords = readOverhead(getAgentDir(), ctx.sessionManager.getSessionId())
381
+ const header = [
382
+ ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
383
+ usage?.percent != null ? `context ${Math.round(usage.percent)}%` : undefined
384
+ ]
385
+ .filter(Boolean)
386
+ .join(' · ')
387
+
388
+ let lines: string[] = []
389
+ let scroll = 0
390
+
391
+ const viewport = (): number => {
392
+ const rows = tui.terminal.rows || 24
393
+ const panelRows = Math.max(5, Math.min(rows - 2, Math.floor(rows * PANEL_SHARE)))
394
+ return Math.max(1, panelRows - 3)
395
+ }
396
+
397
+ render = () => {
398
+ const session = sessionSpend(ctx, overheadRecords)
399
+ lines = [
400
+ `${theme.fg('accent', theme.bold('Cost and limits'))} ${theme.fg('dim', header)}`,
401
+ '',
402
+ theme.fg('accent', 'Session'),
403
+ ...sessionLines(session, theme)
404
+ ]
405
+ for (const section of sections.values()) lines.push('', ...sectionLines(section, theme))
406
+ if (sections.size === 0) {
407
+ lines.push('', theme.fg('warning', 'No provider credentials found for a quota lookup.'))
408
+ }
409
+ lines.push(
410
+ '',
411
+ ...tokenLines(
412
+ windowRows ?? [],
413
+ theme,
414
+ windowRows ? undefined : 'reading past sessions…',
415
+ costShown
416
+ )
417
+ )
418
+ tui.requestRender()
419
+ }
420
+ render()
421
+
422
+ return {
423
+ render: (width: number) => {
424
+ const height = viewport()
425
+ const maxScroll = Math.max(0, lines.length - height)
426
+ scroll = Math.min(scroll, maxScroll)
427
+ const inner = Math.max(10, width - 2)
428
+ const visible = lines.slice(scroll, scroll + height)
429
+ while (visible.length < height) visible.push('')
430
+ body.setText(visible.map((line) => truncateToWidth(line, inner, '...')).join('\n'))
431
+ const position =
432
+ maxScroll > 0
433
+ ? `${scroll + 1}-${Math.min(lines.length, scroll + height)}/${lines.length} · ↑/↓ scroll · `
434
+ : ''
435
+ footer.setText(theme.fg('dim', `${position}Enter or Esc to close`))
436
+ return container.render(width)
437
+ },
438
+ invalidate: () => container.invalidate(),
439
+ handleInput: (data: string) => {
440
+ if (matchesKey(data, 'enter') || matchesKey(data, 'escape')) {
441
+ render = undefined
442
+ done(undefined)
443
+ return
444
+ }
445
+ const height = viewport()
446
+ const maxScroll = Math.max(0, lines.length - height)
447
+ let next = scroll
448
+ if (matchesKey(data, 'down')) next += 1
449
+ else if (matchesKey(data, 'up')) next -= 1
450
+ else if (matchesKey(data, 'pageDown')) next += height
451
+ else if (matchesKey(data, 'pageUp')) next -= height
452
+ else return
453
+ scroll = Math.min(Math.max(0, next), maxScroll)
454
+ tui.requestRender()
455
+ }
456
+ }
457
+ })
458
+ }
459
+ })
460
+ }