agentfit 0.1.5 → 0.1.7

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.
@@ -9,6 +9,7 @@
9
9
  * 🟢 You can import this file directly.
10
10
  */
11
11
  export type * from './models/Session'
12
+ export type * from './models/MessageUsage'
12
13
  export type * from './models/Image'
13
14
  export type * from './models/SyncLog'
14
15
  export type * from './models/Report'
package/lib/db.ts CHANGED
@@ -3,7 +3,7 @@ import { PrismaLibSql } from '@prisma/adapter-libsql'
3
3
  import path from 'path'
4
4
 
5
5
  function createPrisma() {
6
- const dbUrl = `file:${path.resolve(process.cwd(), 'agentfit.db')}`
6
+ const dbUrl = process.env.DATABASE_URL || `file:${path.resolve(process.cwd(), 'agentfit.db')}`
7
7
  const adapter = new PrismaLibSql({ url: dbUrl })
8
8
  return new PrismaClient({ adapter })
9
9
  }
package/lib/parse-logs.ts CHANGED
@@ -49,6 +49,15 @@ export interface ProjectSummary {
49
49
  toolCalls: Record<string, number>
50
50
  }
51
51
 
52
+ export interface ModelBreakdown {
53
+ model: string
54
+ inputTokens: number
55
+ outputTokens: number
56
+ cacheCreationTokens: number
57
+ cacheReadTokens: number
58
+ costUSD: number
59
+ }
60
+
52
61
  export interface DailyUsage {
53
62
  date: string
54
63
  sessions: number
@@ -65,6 +74,7 @@ export interface DailyUsage {
65
74
  toolCallsDetail: Record<string, number>
66
75
  interruptions: number
67
76
  rateLimitErrors: number
77
+ modelBreakdowns: ModelBreakdown[]
68
78
  }
69
79
 
70
80
  export interface OverviewStats {
@@ -359,6 +369,7 @@ export async function parseAllLogs(): Promise<UsageData> {
359
369
  toolCallsDetail: {},
360
370
  interruptions: 0,
361
371
  rateLimitErrors: 0,
372
+ modelBreakdowns: [],
362
373
  })
363
374
  }
364
375
  const daily = dailyMap.get(date)!
package/lib/pricing.ts CHANGED
@@ -1,15 +1,27 @@
1
- // Fetches pricing from LiteLLM's model pricing database (same source as ccusage)
1
+ // Fetches pricing from LiteLLM's model pricing database (same source as ccusage).
2
+ // Tiered (>200k) pricing and the speed=fast multiplier are ported from ccusage
3
+ // (MIT, © 2025 ryoppippi) — see packages/internal/src/pricing.ts in that repo.
2
4
 
3
5
  const LITELLM_PRICING_URL =
4
6
  'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
5
7
 
8
+ const TIERED_THRESHOLD = 200_000
9
+
6
10
  export interface ModelPricing {
7
11
  input_cost_per_token?: number
8
12
  output_cost_per_token?: number
9
13
  cache_creation_input_token_cost?: number
10
14
  cache_read_input_token_cost?: number
15
+ // 1M-context tiered prices (Claude/Anthropic only)
16
+ input_cost_per_token_above_200k_tokens?: number
17
+ output_cost_per_token_above_200k_tokens?: number
18
+ cache_creation_input_token_cost_above_200k_tokens?: number
19
+ cache_read_input_token_cost_above_200k_tokens?: number
20
+ provider_specific_entry?: { fast?: number }
11
21
  }
12
22
 
23
+ export type Speed = 'standard' | 'fast'
24
+
13
25
  let pricingCache: Record<string, ModelPricing> | null = null
14
26
 
15
27
  export async function loadPricing(): Promise<Record<string, ModelPricing>> {
@@ -18,32 +30,46 @@ export async function loadPricing(): Promise<Record<string, ModelPricing>> {
18
30
  try {
19
31
  const res = await fetch(LITELLM_PRICING_URL, { next: { revalidate: 86400 } })
20
32
  const data = await res.json()
21
- // Filter to Claude/Anthropic models only
22
33
  const filtered: Record<string, ModelPricing> = {}
23
34
  for (const [key, value] of Object.entries(data)) {
24
- if (
25
- key.includes('claude') ||
26
- key.includes('anthropic')
27
- ) {
28
- const v = value as Record<string, unknown>
29
- filtered[key] = {
30
- input_cost_per_token: v.input_cost_per_token as number | undefined,
31
- output_cost_per_token: v.output_cost_per_token as number | undefined,
32
- cache_creation_input_token_cost: v.cache_creation_input_token_cost as number | undefined,
33
- cache_read_input_token_cost: v.cache_read_input_token_cost as number | undefined,
34
- }
35
+ if (!key.includes('claude') && !key.includes('anthropic')) continue
36
+ const v = value as Record<string, unknown>
37
+ filtered[key] = {
38
+ input_cost_per_token: v.input_cost_per_token as number | undefined,
39
+ output_cost_per_token: v.output_cost_per_token as number | undefined,
40
+ cache_creation_input_token_cost: v.cache_creation_input_token_cost as number | undefined,
41
+ cache_read_input_token_cost: v.cache_read_input_token_cost as number | undefined,
42
+ input_cost_per_token_above_200k_tokens: v.input_cost_per_token_above_200k_tokens as
43
+ | number
44
+ | undefined,
45
+ output_cost_per_token_above_200k_tokens: v.output_cost_per_token_above_200k_tokens as
46
+ | number
47
+ | undefined,
48
+ cache_creation_input_token_cost_above_200k_tokens:
49
+ v.cache_creation_input_token_cost_above_200k_tokens as number | undefined,
50
+ cache_read_input_token_cost_above_200k_tokens:
51
+ v.cache_read_input_token_cost_above_200k_tokens as number | undefined,
52
+ provider_specific_entry:
53
+ v.provider_specific_entry && typeof v.provider_specific_entry === 'object'
54
+ ? { fast: (v.provider_specific_entry as Record<string, unknown>).fast as number | undefined }
55
+ : undefined,
35
56
  }
36
57
  }
37
58
  pricingCache = filtered
38
59
  return filtered
39
60
  } catch {
40
- // Fallback pricing if fetch fails
41
61
  return getFallbackPricing()
42
62
  }
43
63
  }
44
64
 
45
65
  function getFallbackPricing(): Record<string, ModelPricing> {
46
66
  return {
67
+ 'claude-opus-4-7': {
68
+ input_cost_per_token: 15e-6,
69
+ output_cost_per_token: 75e-6,
70
+ cache_creation_input_token_cost: 18.75e-6,
71
+ cache_read_input_token_cost: 1.5e-6,
72
+ },
47
73
  'claude-opus-4-6': {
48
74
  input_cost_per_token: 15e-6,
49
75
  output_cost_per_token: 75e-6,
@@ -55,12 +81,16 @@ function getFallbackPricing(): Record<string, ModelPricing> {
55
81
  output_cost_per_token: 15e-6,
56
82
  cache_creation_input_token_cost: 3.75e-6,
57
83
  cache_read_input_token_cost: 0.3e-6,
84
+ input_cost_per_token_above_200k_tokens: 6e-6,
85
+ output_cost_per_token_above_200k_tokens: 22.5e-6,
86
+ cache_creation_input_token_cost_above_200k_tokens: 7.5e-6,
87
+ cache_read_input_token_cost_above_200k_tokens: 0.6e-6,
58
88
  },
59
89
  'claude-haiku-4-5-20251001': {
60
- input_cost_per_token: 0.8e-6,
61
- output_cost_per_token: 4e-6,
62
- cache_creation_input_token_cost: 1e-6,
63
- cache_read_input_token_cost: 0.08e-6,
90
+ input_cost_per_token: 1e-6,
91
+ output_cost_per_token: 5e-6,
92
+ cache_creation_input_token_cost: 1.25e-6,
93
+ cache_read_input_token_cost: 0.1e-6,
64
94
  },
65
95
  }
66
96
  }
@@ -68,28 +98,43 @@ function getFallbackPricing(): Record<string, ModelPricing> {
68
98
  export function findPricing(
69
99
  model: string,
70
100
  allPricing: Record<string, ModelPricing>
71
- ): ModelPricing {
72
- // Try exact match
101
+ ): ModelPricing | null {
73
102
  if (allPricing[model]) return allPricing[model]
74
-
75
- // Try with anthropic/ prefix
76
103
  if (allPricing[`anthropic/${model}`]) return allPricing[`anthropic/${model}`]
77
104
 
78
- // Try partial match
79
105
  for (const [key, pricing] of Object.entries(allPricing)) {
80
106
  const normalizedKey = key.replace('anthropic/', '').replace('anthropic.', '')
81
- if (normalizedKey === model || normalizedKey.startsWith(model) || model.startsWith(normalizedKey)) {
107
+ if (
108
+ normalizedKey === model ||
109
+ normalizedKey.startsWith(model) ||
110
+ model.startsWith(normalizedKey)
111
+ ) {
82
112
  return pricing
83
113
  }
84
114
  }
85
115
 
86
- // Default to Sonnet pricing
87
- return allPricing['claude-sonnet-4-6'] || allPricing['anthropic/claude-sonnet-4-6'] || {
88
- input_cost_per_token: 3e-6,
89
- output_cost_per_token: 15e-6,
90
- cache_creation_input_token_cost: 3.75e-6,
91
- cache_read_input_token_cost: 0.3e-6,
116
+ // Substring fallback (mirrors ccusage's behaviour at packages/internal/src/pricing.ts:232-238)
117
+ const lower = model.toLowerCase()
118
+ for (const [key, pricing] of Object.entries(allPricing)) {
119
+ const k = key.toLowerCase()
120
+ if (k.includes(lower) || lower.includes(k)) return pricing
92
121
  }
122
+
123
+ return null
124
+ }
125
+
126
+ function tieredCost(
127
+ total: number | undefined,
128
+ base: number | undefined,
129
+ tiered: number | undefined
130
+ ): number {
131
+ if (total == null || total <= 0) return 0
132
+ if (total > TIERED_THRESHOLD && tiered != null) {
133
+ const below = Math.min(total, TIERED_THRESHOLD)
134
+ const above = Math.max(0, total - TIERED_THRESHOLD)
135
+ return above * tiered + (base != null ? below * base : 0)
136
+ }
137
+ return base != null ? total * base : 0
93
138
  }
94
139
 
95
140
  export function calculateCost(
@@ -100,13 +145,34 @@ export function calculateCost(
100
145
  cache_creation_input_tokens?: number
101
146
  cache_read_input_tokens?: number
102
147
  },
103
- allPricing: Record<string, ModelPricing>
148
+ allPricing: Record<string, ModelPricing>,
149
+ speed: Speed = 'standard'
104
150
  ): number {
105
151
  const pricing = findPricing(model, allPricing)
106
- return (
107
- (usage.input_tokens || 0) * (pricing.input_cost_per_token || 0) +
108
- (usage.output_tokens || 0) * (pricing.output_cost_per_token || 0) +
109
- (usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0) +
110
- (usage.cache_read_input_tokens || 0) * (pricing.cache_read_input_token_cost || 0)
111
- )
152
+ if (!pricing) return 0
153
+
154
+ const base =
155
+ tieredCost(
156
+ usage.input_tokens,
157
+ pricing.input_cost_per_token,
158
+ pricing.input_cost_per_token_above_200k_tokens
159
+ ) +
160
+ tieredCost(
161
+ usage.output_tokens,
162
+ pricing.output_cost_per_token,
163
+ pricing.output_cost_per_token_above_200k_tokens
164
+ ) +
165
+ tieredCost(
166
+ usage.cache_creation_input_tokens,
167
+ pricing.cache_creation_input_token_cost,
168
+ pricing.cache_creation_input_token_cost_above_200k_tokens
169
+ ) +
170
+ tieredCost(
171
+ usage.cache_read_input_tokens,
172
+ pricing.cache_read_input_token_cost,
173
+ pricing.cache_read_input_token_cost_above_200k_tokens
174
+ )
175
+
176
+ const multiplier = speed === 'fast' ? pricing.provider_specific_entry?.fast ?? 1 : 1
177
+ return base * multiplier
112
178
  }
@@ -64,6 +64,7 @@ export function getCodexUsageData(): UsageData {
64
64
  toolCallsDetail: {},
65
65
  interruptions: 0,
66
66
  rateLimitErrors: 0,
67
+ modelBreakdowns: [],
67
68
  })
68
69
  }
69
70
  const daily = dailyMap.get(date)!
package/lib/queries.ts CHANGED
@@ -4,15 +4,28 @@ import type {
4
4
  SessionSummary,
5
5
  ProjectSummary,
6
6
  DailyUsage,
7
+ ModelBreakdown,
7
8
  OverviewStats,
8
9
  } from './parse-logs'
9
10
 
10
11
  export async function getUsageData(): Promise<UsageData> {
11
- const dbSessions = await prisma.session.findMany({
12
- orderBy: { startTime: 'desc' },
13
- })
12
+ const [dbSessions, dbMessageUsages] = await Promise.all([
13
+ prisma.session.findMany({ orderBy: { startTime: 'desc' } }),
14
+ prisma.messageUsage.findMany({
15
+ select: {
16
+ date: true,
17
+ model: true,
18
+ speed: true,
19
+ inputTokens: true,
20
+ outputTokens: true,
21
+ cacheCreationTokens: true,
22
+ cacheReadTokens: true,
23
+ costUSD: true,
24
+ },
25
+ }),
26
+ ])
14
27
 
15
- if (dbSessions.length === 0) {
28
+ if (dbSessions.length === 0 && dbMessageUsages.length === 0) {
16
29
  return emptyUsageData()
17
30
  }
18
31
 
@@ -47,13 +60,12 @@ export async function getUsageData(): Promise<UsageData> {
47
60
  modelCounts: JSON.parse(s.modelCountsJson || '{}') as Record<string, number>,
48
61
  }))
49
62
 
50
- // Aggregate projects
63
+ // Aggregate projects (per-session — unaffected by message-level dedup since
64
+ // a session belongs to one project)
51
65
  const projectMap = new Map<string, ProjectSummary>()
52
- const dailyMap = new Map<string, DailyUsage>()
53
66
  const toolUsage: Record<string, number> = {}
54
67
 
55
68
  for (const s of sessions) {
56
- // Projects
57
69
  if (!projectMap.has(s.project)) {
58
70
  projectMap.set(s.project, {
59
71
  name: s.project,
@@ -75,12 +87,23 @@ export async function getUsageData(): Promise<UsageData> {
75
87
  for (const [tool, count] of Object.entries(s.toolCalls)) {
76
88
  proj.toolCalls[tool] = (proj.toolCalls[tool] || 0) + count
77
89
  }
90
+ for (const [tool, count] of Object.entries(s.toolCalls)) {
91
+ toolUsage[tool] = (toolUsage[tool] || 0) + count
92
+ }
93
+ }
78
94
 
79
- // Daily
80
- const date = s.startTime.slice(0, 10)
81
- if (!dailyMap.has(date)) {
82
- dailyMap.set(date, {
83
- date,
95
+ // Daily aggregation comes from MessageUsage so totals match ccusage:
96
+ // • per-message timestamp → correct date bucket across midnight
97
+ // • (messageId, requestId) dedup → no double-counting on session resumes
98
+ // • per-(date, model) breakdown → opus + haiku stack on the same day
99
+ // Mirrors ccusage data-loader.ts:760-901 (loadDailyUsageData).
100
+ const dailyMap = new Map<string, DailyUsage>()
101
+ const breakdownMap = new Map<string, Map<string, ModelBreakdown>>()
102
+
103
+ for (const m of dbMessageUsages) {
104
+ if (!dailyMap.has(m.date)) {
105
+ dailyMap.set(m.date, {
106
+ date: m.date,
84
107
  sessions: 0,
85
108
  messages: 0,
86
109
  userMessages: 0,
@@ -95,32 +118,84 @@ export async function getUsageData(): Promise<UsageData> {
95
118
  toolCallsDetail: {},
96
119
  interruptions: 0,
97
120
  rateLimitErrors: 0,
121
+ modelBreakdowns: [],
98
122
  })
123
+ breakdownMap.set(m.date, new Map())
99
124
  }
100
- const daily = dailyMap.get(date)!
101
- daily.sessions++
102
- daily.messages += s.totalMessages
103
- daily.userMessages += s.userMessages
104
- daily.assistantMessages += s.assistantMessages
105
- daily.inputTokens += s.inputTokens
106
- daily.outputTokens += s.outputTokens
107
- daily.cacheCreationTokens += s.cacheCreationTokens
108
- daily.cacheReadTokens += s.cacheReadTokens
109
- daily.totalTokens += s.totalTokens
110
- daily.costUSD += s.costUSD
111
- daily.toolCalls += s.toolCallsTotal
112
- daily.interruptions += s.userInterruptions
113
- daily.rateLimitErrors += s.rateLimitErrors
114
- for (const [tool, count] of Object.entries(s.toolCalls)) {
115
- daily.toolCallsDetail[tool] = (daily.toolCallsDetail[tool] || 0) + count
125
+ const d = dailyMap.get(m.date)!
126
+ d.inputTokens += m.inputTokens
127
+ d.outputTokens += m.outputTokens
128
+ d.cacheCreationTokens += m.cacheCreationTokens
129
+ d.cacheReadTokens += m.cacheReadTokens
130
+ d.totalTokens +=
131
+ m.inputTokens + m.outputTokens + m.cacheCreationTokens + m.cacheReadTokens
132
+ d.costUSD += m.costUSD
133
+
134
+ const modelKey = m.speed === 'fast' ? `${m.model}-fast` : m.model
135
+ const perModel = breakdownMap.get(m.date)!
136
+ if (!perModel.has(modelKey)) {
137
+ perModel.set(modelKey, {
138
+ model: modelKey,
139
+ inputTokens: 0,
140
+ outputTokens: 0,
141
+ cacheCreationTokens: 0,
142
+ cacheReadTokens: 0,
143
+ costUSD: 0,
144
+ })
116
145
  }
146
+ const mb = perModel.get(modelKey)!
147
+ mb.inputTokens += m.inputTokens
148
+ mb.outputTokens += m.outputTokens
149
+ mb.cacheCreationTokens += m.cacheCreationTokens
150
+ mb.cacheReadTokens += m.cacheReadTokens
151
+ mb.costUSD += m.costUSD
152
+ }
117
153
 
118
- // Tool usage
154
+ // Layer per-session counters that MessageUsage doesn't track: sessions count,
155
+ // user/assistant message counts, tool calls, interruptions.
156
+ for (const s of sessions) {
157
+ const date = s.startTime.slice(0, 10)
158
+ let d = dailyMap.get(date)
159
+ if (!d) {
160
+ d = {
161
+ date,
162
+ sessions: 0,
163
+ messages: 0,
164
+ userMessages: 0,
165
+ assistantMessages: 0,
166
+ inputTokens: 0,
167
+ outputTokens: 0,
168
+ cacheCreationTokens: 0,
169
+ cacheReadTokens: 0,
170
+ totalTokens: 0,
171
+ costUSD: 0,
172
+ toolCalls: 0,
173
+ toolCallsDetail: {},
174
+ interruptions: 0,
175
+ rateLimitErrors: 0,
176
+ modelBreakdowns: [],
177
+ }
178
+ dailyMap.set(date, d)
179
+ breakdownMap.set(date, new Map())
180
+ }
181
+ d.sessions++
182
+ d.messages += s.totalMessages
183
+ d.userMessages += s.userMessages
184
+ d.assistantMessages += s.assistantMessages
185
+ d.toolCalls += s.toolCallsTotal
186
+ d.interruptions += s.userInterruptions
187
+ d.rateLimitErrors += s.rateLimitErrors
119
188
  for (const [tool, count] of Object.entries(s.toolCalls)) {
120
- toolUsage[tool] = (toolUsage[tool] || 0) + count
189
+ d.toolCallsDetail[tool] = (d.toolCallsDetail[tool] || 0) + count
121
190
  }
122
191
  }
123
192
 
193
+ for (const [date, perModel] of breakdownMap) {
194
+ dailyMap.get(date)!.modelBreakdowns = Array.from(perModel.values()).sort((a, b) =>
195
+ a.model.localeCompare(b.model)
196
+ )
197
+ }
198
+
124
199
  const dailyArr = Array.from(dailyMap.values()).sort((a, b) => a.date.localeCompare(b.date))
125
200
  const projects = Array.from(projectMap.values()).sort((a, b) => b.totalCost - a.totalCost)
126
201
 
@@ -139,18 +214,31 @@ export async function getUsageData(): Promise<UsageData> {
139
214
  }
140
215
  }
141
216
 
217
+ // Overview token + cost totals come from the deduped MessageUsage rows so
218
+ // the overview matches the daily breakdown (which also reads MessageUsage).
219
+ const tokenTotals = dailyArr.reduce(
220
+ (acc, d) => ({
221
+ input: acc.input + d.inputTokens,
222
+ output: acc.output + d.outputTokens,
223
+ cc: acc.cc + d.cacheCreationTokens,
224
+ cr: acc.cr + d.cacheReadTokens,
225
+ cost: acc.cost + d.costUSD,
226
+ }),
227
+ { input: 0, output: 0, cc: 0, cr: 0, cost: 0 }
228
+ )
229
+
142
230
  const overview: OverviewStats = {
143
231
  totalSessions: sessions.length,
144
232
  totalProjects: projects.length,
145
233
  totalMessages: sessions.reduce((a, s) => a + s.totalMessages, 0),
146
234
  totalUserMessages: sessions.reduce((a, s) => a + s.userMessages, 0),
147
235
  totalAssistantMessages: sessions.reduce((a, s) => a + s.assistantMessages, 0),
148
- totalInputTokens: sessions.reduce((a, s) => a + s.inputTokens, 0),
149
- totalOutputTokens: sessions.reduce((a, s) => a + s.outputTokens, 0),
150
- totalCacheCreationTokens: sessions.reduce((a, s) => a + s.cacheCreationTokens, 0),
151
- totalCacheReadTokens: sessions.reduce((a, s) => a + s.cacheReadTokens, 0),
152
- totalTokens: sessions.reduce((a, s) => a + s.totalTokens, 0),
153
- totalCostUSD: sessions.reduce((a, s) => a + s.costUSD, 0),
236
+ totalInputTokens: tokenTotals.input,
237
+ totalOutputTokens: tokenTotals.output,
238
+ totalCacheCreationTokens: tokenTotals.cc,
239
+ totalCacheReadTokens: tokenTotals.cr,
240
+ totalTokens: tokenTotals.input + tokenTotals.output + tokenTotals.cc + tokenTotals.cr,
241
+ totalCostUSD: tokenTotals.cost,
154
242
  totalDurationMinutes: sessions.reduce((a, s) => a + s.durationMinutes, 0),
155
243
  totalToolCalls: sessions.reduce((a, s) => a + s.toolCallsTotal, 0),
156
244
  totalApiErrors: sessions.reduce((a, s) => a + s.apiErrors, 0),