@tduyng/pi-cost 0.0.0-stage → 0.0.1

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