Skip to content

Commit bbd82e8

Browse files
committed
feat(usage): add model-bucketed live cost estimates
1 parent 8aaff84 commit bbd82e8

6 files changed

Lines changed: 821 additions & 3 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { afterEach, describe, expect, it } from 'bun:test'
2+
import { estimateProviderCost, withUsageCostEstimate } from './usageCostEstimate'
3+
import type { UsageProviderOverview, UsageOverview } from './usageScanner'
4+
5+
function createProvider(provider: 'claude' | 'codex', totalTokens: number, last30DaysTokens: number): UsageProviderOverview {
6+
return {
7+
provider,
8+
available: true,
9+
roots: [],
10+
filesScanned: 1,
11+
parseErrors: 0,
12+
eventCount: 2,
13+
last30DaysEventCount: 1,
14+
allTime: {
15+
inputTokens: totalTokens,
16+
cachedInputTokens: 0,
17+
cacheReadTokens: 0,
18+
cacheCreationTokens: 0,
19+
outputTokens: 0,
20+
reasoningOutputTokens: 0,
21+
totalTokens
22+
},
23+
last30Days: {
24+
inputTokens: last30DaysTokens,
25+
cachedInputTokens: 0,
26+
cacheReadTokens: 0,
27+
cacheCreationTokens: 0,
28+
outputTokens: 0,
29+
reasoningOutputTokens: 0,
30+
totalTokens: last30DaysTokens
31+
},
32+
models: [
33+
{
34+
model: provider === 'claude' ? 'claude-sonnet-4-20250514' : 'gpt-5-codex',
35+
eventCount: 2,
36+
last30DaysEventCount: 1,
37+
allTime: {
38+
inputTokens: totalTokens,
39+
cachedInputTokens: 0,
40+
cacheReadTokens: 0,
41+
cacheCreationTokens: 0,
42+
outputTokens: 0,
43+
reasoningOutputTokens: 0,
44+
totalTokens
45+
},
46+
last30Days: {
47+
inputTokens: last30DaysTokens,
48+
cachedInputTokens: 0,
49+
cacheReadTokens: 0,
50+
cacheCreationTokens: 0,
51+
outputTokens: 0,
52+
reasoningOutputTokens: 0,
53+
totalTokens: last30DaysTokens
54+
}
55+
}
56+
]
57+
}
58+
}
59+
60+
describe('usageCostEstimate', () => {
61+
const originalFetch = globalThis.fetch
62+
63+
afterEach(() => {
64+
delete process.env.HAPI_USAGE_ESTIMATE_CLAUDE_USD_PER_MTOK
65+
delete process.env.HAPI_USAGE_ESTIMATE_CODEX_USD_PER_MTOK
66+
delete process.env.HAPI_USAGE_ESTIMATE_CLAUDE_MODEL
67+
delete process.env.HAPI_USAGE_ESTIMATE_CODEX_MODEL
68+
globalThis.fetch = originalFetch
69+
})
70+
71+
it('estimates provider cost from total tokens', async () => {
72+
process.env.HAPI_USAGE_ESTIMATE_CLAUDE_USD_PER_MTOK = '5'
73+
const provider = createProvider('claude', 3_000_000, 500_000)
74+
const estimate = await estimateProviderCost(provider)
75+
76+
expect(estimate.usdPerMillionTokens).toBeCloseTo(5, 6)
77+
expect(estimate.allTimeUsd).toBeCloseTo(15, 6)
78+
expect(estimate.last30DaysUsd).toBeCloseTo(2.5, 6)
79+
expect(estimate.rateSource).toBe('env')
80+
})
81+
82+
it('attaches estimate to both providers', async () => {
83+
process.env.HAPI_USAGE_ESTIMATE_CLAUDE_USD_PER_MTOK = '5'
84+
process.env.HAPI_USAGE_ESTIMATE_CODEX_USD_PER_MTOK = '5'
85+
const overview: UsageOverview = {
86+
generatedAt: Date.parse('2026-02-27T00:00:00.000Z'),
87+
windowDays: 30,
88+
claude: createProvider('claude', 1_000_000, 100_000),
89+
codex: createProvider('codex', 2_000_000, 250_000)
90+
}
91+
92+
const withEstimate = await withUsageCostEstimate(overview)
93+
94+
expect(withEstimate.claude.estimatedCost.allTimeUsd).toBe(5)
95+
expect(withEstimate.codex.estimatedCost.allTimeUsd).toBe(10)
96+
})
97+
98+
it('uses model buckets when LiteLLM pricing is available', async () => {
99+
globalThis.fetch = (async () =>
100+
new Response(JSON.stringify({
101+
'anthropic/claude-sonnet-4-20250514': {
102+
input_cost_per_token: 3e-6,
103+
output_cost_per_token: 15e-6
104+
},
105+
'anthropic/claude-opus-4-20250514': {
106+
input_cost_per_token: 15e-6,
107+
output_cost_per_token: 75e-6
108+
}
109+
}), { status: 200 })) as unknown as typeof fetch
110+
111+
const provider: UsageProviderOverview = {
112+
provider: 'claude',
113+
available: true,
114+
roots: [],
115+
filesScanned: 1,
116+
parseErrors: 0,
117+
eventCount: 2,
118+
last30DaysEventCount: 2,
119+
allTime: {
120+
inputTokens: 1_000_000,
121+
cachedInputTokens: 0,
122+
cacheReadTokens: 0,
123+
cacheCreationTokens: 0,
124+
outputTokens: 1_000_000,
125+
reasoningOutputTokens: 0,
126+
totalTokens: 2_000_000
127+
},
128+
last30Days: {
129+
inputTokens: 1_000_000,
130+
cachedInputTokens: 0,
131+
cacheReadTokens: 0,
132+
cacheCreationTokens: 0,
133+
outputTokens: 1_000_000,
134+
reasoningOutputTokens: 0,
135+
totalTokens: 2_000_000
136+
},
137+
models: [
138+
{
139+
model: 'claude-sonnet-4-20250514',
140+
eventCount: 1,
141+
last30DaysEventCount: 1,
142+
allTime: {
143+
inputTokens: 1_000_000,
144+
cachedInputTokens: 0,
145+
cacheReadTokens: 0,
146+
cacheCreationTokens: 0,
147+
outputTokens: 0,
148+
reasoningOutputTokens: 0,
149+
totalTokens: 1_000_000
150+
},
151+
last30Days: {
152+
inputTokens: 1_000_000,
153+
cachedInputTokens: 0,
154+
cacheReadTokens: 0,
155+
cacheCreationTokens: 0,
156+
outputTokens: 0,
157+
reasoningOutputTokens: 0,
158+
totalTokens: 1_000_000
159+
}
160+
},
161+
{
162+
model: 'claude-opus-4-20250514',
163+
eventCount: 1,
164+
last30DaysEventCount: 1,
165+
allTime: {
166+
inputTokens: 0,
167+
cachedInputTokens: 0,
168+
cacheReadTokens: 0,
169+
cacheCreationTokens: 0,
170+
outputTokens: 1_000_000,
171+
reasoningOutputTokens: 0,
172+
totalTokens: 1_000_000
173+
},
174+
last30Days: {
175+
inputTokens: 0,
176+
cachedInputTokens: 0,
177+
cacheReadTokens: 0,
178+
cacheCreationTokens: 0,
179+
outputTokens: 1_000_000,
180+
reasoningOutputTokens: 0,
181+
totalTokens: 1_000_000
182+
}
183+
}
184+
]
185+
}
186+
187+
const estimate = await estimateProviderCost(provider)
188+
expect(estimate.rateSource).toBe('litelm')
189+
expect(estimate.allTimeUsd).toBeCloseTo(78, 6)
190+
expect(estimate.usdPerMillionTokens).toBeCloseTo(39, 6)
191+
expect(estimate.pricingModel).toBe('mixed(2)')
192+
})
193+
})

0 commit comments

Comments
 (0)