codebuddy-stats 1.0.0

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,302 @@
1
+ import fs from 'node:fs/promises'
2
+ import fsSync from 'node:fs'
3
+ import path from 'node:path'
4
+ import { createInterface } from 'node:readline'
5
+
6
+ import { getProjectsDir, getSettingsPath } from './paths.js'
7
+ import { DEFAULT_MODEL_ID, getPricingForModel, tokensToCost } from './pricing.js'
8
+
9
+ export const BASE_DIR = getProjectsDir()
10
+
11
+ export interface RawUsage {
12
+ prompt_tokens?: number
13
+ completion_tokens?: number
14
+ total_tokens?: number
15
+ prompt_cache_hit_tokens?: number
16
+ prompt_cache_miss_tokens?: number
17
+ cache_creation_input_tokens?: number
18
+ }
19
+
20
+ export interface UsageStats {
21
+ promptTokens: number
22
+ completionTokens: number
23
+ totalTokens: number
24
+ cacheHitTokens: number
25
+ cacheMissTokens: number
26
+ cacheWriteTokens: number
27
+ }
28
+
29
+ export interface DailyModelStats extends UsageStats {
30
+ cost: number
31
+ requests: number
32
+ }
33
+
34
+ export interface SummaryStats {
35
+ cost: number
36
+ tokens: number
37
+ requests: number
38
+ }
39
+
40
+ export interface GrandTotal extends SummaryStats {
41
+ cacheHitTokens: number
42
+ cacheMissTokens: number
43
+ }
44
+
45
+ export type DailyData = Record<string, Record<string, Record<string, DailyModelStats>>>
46
+
47
+ export interface AnalysisData {
48
+ defaultModelId: string
49
+ dailyData: DailyData
50
+ dailySummary: Record<string, SummaryStats>
51
+ modelTotals: Record<string, SummaryStats>
52
+ projectTotals: Record<string, SummaryStats>
53
+ grandTotal: GrandTotal
54
+ topModel: (SummaryStats & { id: string }) | null
55
+ topProject: (SummaryStats & { name: string }) | null
56
+ cacheHitRate: number
57
+ activeDays: number
58
+ }
59
+
60
+ export interface LoadUsageOptions {
61
+ days?: number | null
62
+ }
63
+
64
+ interface SettingsFile {
65
+ model?: unknown
66
+ }
67
+
68
+ interface JsonlRecord {
69
+ providerData?: {
70
+ rawUsage?: RawUsage
71
+ model?: unknown
72
+ }
73
+ timestamp?: unknown
74
+ }
75
+
76
+ async function loadModelFromSettings(): Promise<string> {
77
+ try {
78
+ const settingsPath = getSettingsPath()
79
+ const settingsRaw = await fs.readFile(settingsPath, 'utf8')
80
+ const settings = JSON.parse(settingsRaw) as SettingsFile
81
+ return typeof settings?.model === 'string' ? settings.model : DEFAULT_MODEL_ID
82
+ } catch {
83
+ return DEFAULT_MODEL_ID
84
+ }
85
+ }
86
+
87
+ async function findJsonlFiles(dir: string): Promise<string[]> {
88
+ try {
89
+ const dirents = await fs.readdir(dir, { withFileTypes: true })
90
+ const files = await Promise.all(
91
+ dirents.map(async dirent => {
92
+ const res = path.resolve(dir, dirent.name)
93
+ if (dirent.isDirectory()) {
94
+ return findJsonlFiles(res)
95
+ }
96
+ return res.endsWith('.jsonl') ? [res] : ([] as string[])
97
+ })
98
+ )
99
+ return files.flat()
100
+ } catch (error) {
101
+ if (typeof error === 'object' && error && 'code' in error && (error as any).code === 'ENOENT') {
102
+ return []
103
+ }
104
+ throw error
105
+ }
106
+ }
107
+
108
+ function getProjectName(filePath: string): string {
109
+ const parts = filePath.split(path.sep)
110
+ const projectsIndex = parts.lastIndexOf('projects')
111
+ if (projectsIndex !== -1 && projectsIndex < parts.length - 1) {
112
+ return parts[projectsIndex + 1] ?? 'unknown-project'
113
+ }
114
+ return 'unknown-project'
115
+ }
116
+
117
+ function extractUsageStats(usage: RawUsage): UsageStats {
118
+ const promptTokens = usage.prompt_tokens ?? 0
119
+ const completionTokens = usage.completion_tokens ?? 0
120
+ const totalTokens = usage.total_tokens ?? promptTokens + completionTokens
121
+ const cacheHitTokens = usage.prompt_cache_hit_tokens ?? 0
122
+ const cacheMissTokens =
123
+ usage.prompt_cache_miss_tokens ?? (cacheHitTokens > 0 ? Math.max(promptTokens - cacheHitTokens, 0) : 0)
124
+ const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0
125
+
126
+ return {
127
+ promptTokens,
128
+ completionTokens,
129
+ totalTokens,
130
+ cacheHitTokens,
131
+ cacheMissTokens,
132
+ cacheWriteTokens,
133
+ }
134
+ }
135
+
136
+ function computeUsageCost(
137
+ usage: RawUsage,
138
+ modelId: string | null | undefined
139
+ ): { cost: number; stats: UsageStats; modelId: string } {
140
+ const stats = extractUsageStats(usage)
141
+ const pricing = getPricingForModel(modelId)
142
+
143
+ let inputCost = 0
144
+ if (stats.cacheHitTokens || stats.cacheMissTokens || stats.cacheWriteTokens) {
145
+ inputCost += tokensToCost(stats.cacheHitTokens, pricing.cacheRead)
146
+ inputCost += tokensToCost(stats.cacheMissTokens, pricing.prompt)
147
+ inputCost += tokensToCost(stats.cacheWriteTokens, pricing.cacheWrite)
148
+ } else {
149
+ inputCost += tokensToCost(stats.promptTokens, pricing.prompt)
150
+ }
151
+
152
+ const outputCost = tokensToCost(stats.completionTokens, pricing.completion)
153
+
154
+ return { cost: inputCost + outputCost, stats, modelId: modelId || DEFAULT_MODEL_ID }
155
+ }
156
+
157
+ /**
158
+ * 加载所有用量数据
159
+ */
160
+ export async function loadUsageData(options: LoadUsageOptions = {}): Promise<AnalysisData> {
161
+ const defaultModelId = await loadModelFromSettings()
162
+ const jsonlFiles = await findJsonlFiles(BASE_DIR)
163
+
164
+ // 计算日期过滤范围
165
+ let minDate: string | null = null
166
+ if (options.days) {
167
+ const d = new Date()
168
+ d.setDate(d.getDate() - options.days + 1)
169
+ d.setHours(0, 0, 0, 0)
170
+ minDate = d.toISOString().split('T')[0] ?? null
171
+ }
172
+
173
+ // 按日期 -> 项目 -> 模型 组织的数据
174
+ const dailyData: DailyData = {}
175
+
176
+ // 按模型汇总
177
+ const modelTotals: Record<string, SummaryStats> = {}
178
+
179
+ // 按项目汇总
180
+ const projectTotals: Record<string, SummaryStats> = {}
181
+
182
+ // 总计
183
+ const grandTotal: GrandTotal = {
184
+ cost: 0,
185
+ tokens: 0,
186
+ requests: 0,
187
+ cacheHitTokens: 0,
188
+ cacheMissTokens: 0,
189
+ }
190
+
191
+ for (const filePath of jsonlFiles) {
192
+ const fileStat = await fs.stat(filePath)
193
+ if (fileStat.size === 0) continue
194
+
195
+ const fileStream = fsSync.createReadStream(filePath)
196
+ const rl = createInterface({
197
+ input: fileStream,
198
+ crlfDelay: Number.POSITIVE_INFINITY,
199
+ })
200
+
201
+ const projectName = getProjectName(filePath)
202
+
203
+ for await (const line of rl) {
204
+ try {
205
+ const record = JSON.parse(line) as JsonlRecord
206
+ const usage = record?.providerData?.rawUsage
207
+ const timestamp = record?.timestamp
208
+
209
+ if (!usage || timestamp == null) continue
210
+
211
+ const dateObj = new Date(timestamp as any)
212
+ if (Number.isNaN(dateObj.getTime())) continue
213
+ const date = dateObj.toISOString().split('T')[0]
214
+ if (!date) continue
215
+
216
+ // 日期过滤
217
+ if (minDate && date < minDate) continue
218
+
219
+ const recordModelId = record?.providerData?.model
220
+ const modelId = typeof recordModelId === 'string' ? recordModelId : null
221
+ const { cost, stats: usageStats, modelId: usedModelId } = computeUsageCost(usage, modelId)
222
+
223
+ dailyData[date] ??= {}
224
+ dailyData[date]![projectName] ??= {}
225
+ dailyData[date]![projectName]![usedModelId] ??= {
226
+ cost: 0,
227
+ promptTokens: 0,
228
+ completionTokens: 0,
229
+ totalTokens: 0,
230
+ cacheHitTokens: 0,
231
+ cacheMissTokens: 0,
232
+ cacheWriteTokens: 0,
233
+ requests: 0,
234
+ }
235
+
236
+ const dayStats = dailyData[date]![projectName]![usedModelId]!
237
+ dayStats.cost += cost
238
+ dayStats.promptTokens += usageStats.promptTokens
239
+ dayStats.completionTokens += usageStats.completionTokens
240
+ dayStats.totalTokens += usageStats.totalTokens
241
+ dayStats.cacheHitTokens += usageStats.cacheHitTokens
242
+ dayStats.cacheMissTokens += usageStats.cacheMissTokens
243
+ dayStats.cacheWriteTokens += usageStats.cacheWriteTokens
244
+ dayStats.requests += 1
245
+
246
+ modelTotals[usedModelId] ??= { cost: 0, tokens: 0, requests: 0 }
247
+ modelTotals[usedModelId]!.cost += cost
248
+ modelTotals[usedModelId]!.tokens += usageStats.totalTokens
249
+ modelTotals[usedModelId]!.requests += 1
250
+
251
+ projectTotals[projectName] ??= { cost: 0, tokens: 0, requests: 0 }
252
+ projectTotals[projectName]!.cost += cost
253
+ projectTotals[projectName]!.tokens += usageStats.totalTokens
254
+ projectTotals[projectName]!.requests += 1
255
+
256
+ grandTotal.cost += cost
257
+ grandTotal.tokens += usageStats.totalTokens
258
+ grandTotal.requests += 1
259
+ grandTotal.cacheHitTokens += usageStats.cacheHitTokens
260
+ grandTotal.cacheMissTokens += usageStats.cacheMissTokens
261
+ } catch {
262
+ // 忽略无法解析的行
263
+ }
264
+ }
265
+ }
266
+
267
+ // 计算每日汇总
268
+ const dailySummary: Record<string, SummaryStats> = {}
269
+ for (const date of Object.keys(dailyData)) {
270
+ dailySummary[date] = { cost: 0, tokens: 0, requests: 0 }
271
+ for (const project of Object.values(dailyData[date] ?? {})) {
272
+ for (const model of Object.values(project ?? {})) {
273
+ dailySummary[date]!.cost += model.cost
274
+ dailySummary[date]!.tokens += model.totalTokens
275
+ dailySummary[date]!.requests += model.requests
276
+ }
277
+ }
278
+ }
279
+
280
+ const topModelEntry = Object.entries(modelTotals).sort((a, b) => b[1].cost - a[1].cost)[0]
281
+ const topProjectEntry =
282
+ Object.entries(projectTotals).sort((a, b) => b[1].cost - a[1].cost)[0]
283
+
284
+ // 计算缓存命中率
285
+ const cacheHitRate =
286
+ grandTotal.cacheHitTokens + grandTotal.cacheMissTokens > 0
287
+ ? grandTotal.cacheHitTokens / (grandTotal.cacheHitTokens + grandTotal.cacheMissTokens)
288
+ : 0
289
+
290
+ return {
291
+ defaultModelId,
292
+ dailyData,
293
+ dailySummary,
294
+ modelTotals,
295
+ projectTotals,
296
+ grandTotal,
297
+ topModel: topModelEntry ? { id: topModelEntry[0], ...topModelEntry[1] } : null,
298
+ topProject: topProjectEntry ? { name: topProjectEntry[0], ...topProjectEntry[1] } : null,
299
+ cacheHitRate,
300
+ activeDays: Object.keys(dailyData).length,
301
+ }
302
+ }
@@ -0,0 +1,45 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * 获取 CodeBuddy 配置目录
6
+ * - Windows: %APPDATA%/CodeBuddy
7
+ * - macOS: ~/.codebuddy
8
+ * - Linux: $XDG_CONFIG_HOME/codebuddy 或 ~/.codebuddy
9
+ */
10
+ export function getConfigDir(): string {
11
+ if (process.platform === 'win32') {
12
+ const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
13
+ return path.join(appData, 'CodeBuddy')
14
+ }
15
+ if (process.platform === 'linux') {
16
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME
17
+ if (xdgConfigHome) {
18
+ return path.join(xdgConfigHome, 'codebuddy')
19
+ }
20
+ }
21
+ // macOS 和 Linux 默认
22
+ return path.join(os.homedir(), '.codebuddy')
23
+ }
24
+
25
+ /**
26
+ * 获取项目数据目录
27
+ */
28
+ export function getProjectsDir(): string {
29
+ return path.join(getConfigDir(), 'projects')
30
+ }
31
+
32
+ /**
33
+ * 获取设置文件路径
34
+ */
35
+ export function getSettingsPath(): string {
36
+ return path.join(getConfigDir(), 'settings.json')
37
+ }
38
+
39
+ /**
40
+ * 简化项目路径显示
41
+ * 保持原始名称不变
42
+ */
43
+ export function shortenProjectName(name: string): string {
44
+ return name
45
+ }
@@ -0,0 +1,128 @@
1
+ export interface PricingTier {
2
+ limit: number
3
+ pricePerMTok: number
4
+ }
5
+
6
+ export interface ModelPricing {
7
+ prompt: PricingTier[]
8
+ completion: PricingTier[]
9
+ cacheRead: PricingTier[]
10
+ cacheWrite: PricingTier[]
11
+ }
12
+
13
+ // 模型价格 (USD / 1M tokens)
14
+ function createPricing(
15
+ inputPrice: number,
16
+ cachedInputPrice: number,
17
+ outputPrice: number,
18
+ cacheWritePrice?: number
19
+ ): ModelPricing {
20
+ return {
21
+ prompt: [{ limit: Number.POSITIVE_INFINITY, pricePerMTok: inputPrice }],
22
+ completion: [{ limit: Number.POSITIVE_INFINITY, pricePerMTok: outputPrice }],
23
+ cacheRead: [{ limit: Number.POSITIVE_INFINITY, pricePerMTok: cachedInputPrice }],
24
+ cacheWrite: [
25
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: cacheWritePrice ?? inputPrice },
26
+ ],
27
+ }
28
+ }
29
+
30
+ export const MODEL_PRICING: Record<string, ModelPricing> = {
31
+ 'gpt-5.2': createPricing(1.75, 0.175, 14.0),
32
+ 'gpt-5.1': createPricing(1.25, 0.125, 10.0),
33
+ 'gpt-5': createPricing(1.25, 0.125, 10.0),
34
+ 'gpt-5-mini': createPricing(0.25, 0.025, 2.0),
35
+ 'gpt-5-nano': createPricing(0.05, 0.005, 0.4),
36
+ 'gpt-5.1-chat-latest': createPricing(1.25, 0.125, 10.0),
37
+ 'gpt-5-chat-latest': createPricing(1.25, 0.125, 10.0),
38
+ 'gpt-5.1-codex': createPricing(1.25, 0.125, 10.0),
39
+ 'gpt-5-codex': createPricing(1.25, 0.125, 10.0),
40
+
41
+ 'claude-opus-4.5': createPricing(5.0, 0.5, 25.0, 10.0),
42
+
43
+ 'claude-4.5': {
44
+ prompt: [
45
+ { limit: 200_000, pricePerMTok: 3.0 },
46
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 6.0 },
47
+ ],
48
+ completion: [
49
+ { limit: 200_000, pricePerMTok: 15.0 },
50
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 22.5 },
51
+ ],
52
+ cacheRead: [
53
+ { limit: 200_000, pricePerMTok: 0.3 },
54
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0.6 },
55
+ ],
56
+ cacheWrite: [
57
+ { limit: 200_000, pricePerMTok: 6.0 },
58
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 12.0 },
59
+ ],
60
+ },
61
+
62
+ 'gemini-3-pro': {
63
+ prompt: [
64
+ { limit: 200_000, pricePerMTok: 2.0 },
65
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 4.0 },
66
+ ],
67
+ completion: [
68
+ { limit: 200_000, pricePerMTok: 12.0 },
69
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 18.0 },
70
+ ],
71
+ cacheRead: [
72
+ { limit: 200_000, pricePerMTok: 0.2 },
73
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0.4 },
74
+ ],
75
+ cacheWrite: [
76
+ { limit: 200_000, pricePerMTok: 0.2 },
77
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0.4 },
78
+ ],
79
+ },
80
+
81
+ 'gemini-2.5-pro': {
82
+ prompt: [
83
+ { limit: 200_000, pricePerMTok: 1.25 },
84
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 2.5 },
85
+ ],
86
+ completion: [
87
+ { limit: 200_000, pricePerMTok: 10.0 },
88
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 15.0 },
89
+ ],
90
+ cacheRead: [
91
+ { limit: 200_000, pricePerMTok: 0.125 },
92
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0.25 },
93
+ ],
94
+ cacheWrite: [
95
+ { limit: 200_000, pricePerMTok: 0.125 },
96
+ { limit: Number.POSITIVE_INFINITY, pricePerMTok: 0.25 },
97
+ ],
98
+ },
99
+ }
100
+
101
+ export const DEFAULT_MODEL_ID = 'gpt-5.1' as const
102
+
103
+ export function selectTierPrice(tokens: number, tiers: PricingTier[]): number {
104
+ if (tokens <= 0) return tiers[0]?.pricePerMTok ?? 0
105
+ for (const tier of tiers) {
106
+ if (tokens <= tier.limit) {
107
+ return tier.pricePerMTok
108
+ }
109
+ }
110
+ return tiers[tiers.length - 1]?.pricePerMTok ?? 0
111
+ }
112
+
113
+ export function tokensToCost(tokens: number, tiers: PricingTier[]): number {
114
+ if (!tokens) return 0
115
+ const price = selectTierPrice(tokens, tiers)
116
+ return (tokens / 1_000_000) * price
117
+ }
118
+
119
+ export function getPricingForModel(modelId: string | null | undefined): ModelPricing {
120
+ if (modelId && MODEL_PRICING[modelId]) {
121
+ return MODEL_PRICING[modelId]
122
+ }
123
+ const fallback = MODEL_PRICING[DEFAULT_MODEL_ID]
124
+ if (!fallback) {
125
+ throw new Error(`Missing pricing for default model: ${DEFAULT_MODEL_ID}`)
126
+ }
127
+ return fallback
128
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 格式化数字,添加千分位分隔符
3
+ */
4
+ export function formatNumber(num: number | string | null | undefined): string {
5
+ const n = typeof num === 'number' ? num : Number(num ?? 0)
6
+ if (!Number.isFinite(n)) return '0'
7
+ return n.toLocaleString('en-US')
8
+ }
9
+
10
+ /**
11
+ * 格式化金额
12
+ */
13
+ export function formatCost(cost: number): string {
14
+ const n = Number.isFinite(cost) ? cost : 0
15
+ return `$${n.toFixed(2)}`
16
+ }
17
+
18
+ /**
19
+ * 格式化 tokens 数量 (K/M/B)
20
+ */
21
+ export function formatTokens(tokens: number): string {
22
+ if (tokens >= 1_000_000_000) {
23
+ return `${(tokens / 1_000_000_000).toFixed(1)}B`
24
+ }
25
+ if (tokens >= 1_000_000) {
26
+ return `${(tokens / 1_000_000).toFixed(1)}M`
27
+ }
28
+ if (tokens >= 1_000) {
29
+ return `${(tokens / 1_000).toFixed(1)}K`
30
+ }
31
+ return String(tokens)
32
+ }
33
+
34
+ /**
35
+ * 格式化百分比
36
+ */
37
+ export function formatPercent(ratio: number): string {
38
+ return `${(ratio * 100).toFixed(1)}%`
39
+ }
40
+
41
+ /**
42
+ * 截断字符串
43
+ */
44
+ export function truncate(str: string, maxLen: number): string {
45
+ if (str.length <= maxLen) return str
46
+ return str.slice(0, maxLen - 3) + '...'
47
+ }
48
+
49
+ /**
50
+ * 右对齐字符串
51
+ */
52
+ export function padLeft(str: string, len: number): string {
53
+ return str.padStart(len)
54
+ }
55
+
56
+ /**
57
+ * 左对齐字符串
58
+ */
59
+ export function padRight(str: string, len: number): string {
60
+ return str.padEnd(len)
61
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "compilerOptions": {
4
+ "target": "ES2022",
5
+ "lib": ["ES2022"],
6
+
7
+ "module": "NodeNext",
8
+ "moduleResolution": "NodeNext",
9
+ "verbatimModuleSyntax": true,
10
+
11
+ "rootDir": "./src",
12
+ "outDir": "./dist",
13
+
14
+ "strict": true,
15
+ "noUncheckedIndexedAccess": true,
16
+
17
+ "esModuleInterop": true,
18
+ "resolveJsonModule": true,
19
+ "skipLibCheck": true,
20
+
21
+ "sourceMap": true
22
+ },
23
+ "include": ["src/**/*"],
24
+ "exclude": ["node_modules", "dist"]
25
+ }