daili-usage-keeper / web /src /lib /usage.ts
pjpjq's picture
fix: build usage keeper from source
b034029 verified
import type {
ApiSummaryItem,
EventRow,
ModelSummaryItem,
PricingEntry,
RateStats,
SummaryCardValue,
TokenBreakdown,
TrendPoint,
TrendSeries,
UsageDetail,
UsageSeriesDimension,
UsageSnapshot,
UsageTimeRange,
} from './types'
interface UsageEventWithNames extends UsageDetail {
apiName: string
modelName: string
}
const SERIES_COLORS = ['#2563eb', '#7c3aed', '#10b981', '#f97316', '#dc2626', '#0891b2', '#f59e0b']
export function formatNumber(value: number): string {
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 4 }).format(value)
}
function getPriceMap(pricing: PricingEntry[]): Map<string, PricingEntry> {
return new Map(pricing.map((entry) => [entry.model, entry]))
}
function formatLocalDayKey(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function calculateEventCost(event: UsageEventWithNames, priceMap: Map<string, PricingEntry>): number {
const pricing = priceMap.get(event.modelName)
if (!pricing) return 0
return (
(event.tokens.input_tokens / 1_000_000) * pricing.prompt_price_per_1m +
(event.tokens.output_tokens / 1_000_000) * pricing.completion_price_per_1m +
(event.tokens.cached_tokens / 1_000_000) * pricing.cache_price_per_1m
)
}
export function collectUsageEvents(usage: UsageSnapshot): UsageEventWithNames[] {
const events: UsageEventWithNames[] = []
for (const [apiName, api] of Object.entries(usage.apis)) {
for (const [modelName, model] of Object.entries(api.models)) {
for (const detail of model.details ?? []) {
events.push({
...detail,
apiName,
modelName,
})
}
}
}
return events.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp))
}
export function filterUsageSnapshot(usage: UsageSnapshot, range: UsageTimeRange): UsageSnapshot {
if (range === 'all' || range === 'custom') {
return usage
}
const events = collectUsageEvents(usage)
if (events.length === 0) {
return usage
}
const latestTimestamp = Math.max(...events.map((event) => Date.parse(event.timestamp)).filter((value) => Number.isFinite(value)))
if (!Number.isFinite(latestTimestamp)) {
return usage
}
const presetWindowMs: Partial<Record<UsageTimeRange, number>> = {
'4h': 4 * 60 * 60 * 1000,
'8h': 8 * 60 * 60 * 1000,
'12h': 12 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
}
const nowMs = Date.now()
const threshold = range === 'today'
? new Date(nowMs).setHours(0, 0, 0, 0)
: latestTimestamp - (presetWindowMs[range] ?? 0)
const upperThreshold = range === 'today' ? nowMs : Number.POSITIVE_INFINITY
const filtered: UsageSnapshot = {
total_requests: 0,
success_count: 0,
failure_count: 0,
total_tokens: 0,
requests_by_day: {},
requests_by_hour: {},
tokens_by_day: {},
tokens_by_hour: {},
apis: {},
}
for (const event of events) {
const timestampMs = Date.parse(event.timestamp)
if (!Number.isFinite(timestampMs) || timestampMs < threshold || timestampMs > upperThreshold) {
continue
}
const api = filtered.apis[event.apiName] ?? {
total_requests: 0,
success_count: 0,
failure_count: 0,
total_tokens: 0,
models: {},
}
const model = api.models[event.modelName] ?? {
total_requests: 0,
success_count: 0,
failure_count: 0,
total_tokens: 0,
details: [],
}
model.total_requests += 1
model.total_tokens += event.tokens.total_tokens
const modelDetails = model.details ?? (model.details = [])
modelDetails.push({
timestamp: event.timestamp,
latency_ms: event.latency_ms,
source: event.source,
auth_index: event.auth_index,
failed: event.failed,
tokens: event.tokens,
})
api.total_requests += 1
api.total_tokens += event.tokens.total_tokens
filtered.total_requests += 1
filtered.total_tokens += event.tokens.total_tokens
if (event.failed) {
model.failure_count += 1
api.failure_count += 1
filtered.failure_count += 1
} else {
model.success_count += 1
api.success_count += 1
filtered.success_count += 1
}
const time = new Date(event.timestamp)
const dayKey = formatLocalDayKey(time)
const hourKey = `${time.toISOString().slice(0, 13)}:00:00Z`
filtered.requests_by_day[dayKey] = (filtered.requests_by_day[dayKey] ?? 0) + 1
filtered.requests_by_hour[hourKey] = (filtered.requests_by_hour[hourKey] ?? 0) + 1
filtered.tokens_by_day[dayKey] = (filtered.tokens_by_day[dayKey] ?? 0) + event.tokens.total_tokens
filtered.tokens_by_hour[hourKey] = (filtered.tokens_by_hour[hourKey] ?? 0) + event.tokens.total_tokens
api.models[event.modelName] = model
filtered.apis[event.apiName] = api
}
return filtered
}
export function buildRateStats(usage: UsageSnapshot): RateStats {
const events = collectUsageEvents(usage)
if (events.length === 0) {
return { rpm: 0, tpm: 0, requestCount: 0, tokenCount: 0, windowMinutes: 30 }
}
const latestTimestamp = Math.max(...events.map((event) => Date.parse(event.timestamp)).filter((value) => Number.isFinite(value)))
const windowMinutes = 30
const threshold = latestTimestamp - windowMinutes * 60 * 1000
const windowEvents = events.filter((event) => Date.parse(event.timestamp) >= threshold)
const requestCount = windowEvents.length
const tokenCount = windowEvents.reduce((sum, event) => sum + event.tokens.total_tokens, 0)
return {
rpm: requestCount / windowMinutes,
tpm: tokenCount / windowMinutes,
requestCount,
tokenCount,
windowMinutes,
}
}
export function buildSummaryCards(usage: UsageSnapshot, pricing: PricingEntry[]): SummaryCardValue[] {
const rateStats = buildRateStats(usage)
const tokenBreakdown = buildTokenBreakdown(usage)
const totalCost = buildCostSummary(usage, pricing)
const hasPricing = pricing.length > 0
return [
{
key: 'requests',
label: 'Total requests',
value: formatNumber(usage.total_requests),
hint: `${formatNumber(usage.success_count)} success / ${formatNumber(usage.failure_count)} failed`,
accent: '#2563eb',
},
{
key: 'tokens',
label: 'Total tokens',
value: formatNumber(usage.total_tokens),
hint: `${formatNumber(tokenBreakdown.cachedTokens)} cached / ${formatNumber(tokenBreakdown.reasoningTokens)} reasoning`,
accent: '#7c3aed',
},
{
key: 'rpm',
label: 'RPM (30m)',
value: formatNumber(Number(rateStats.rpm.toFixed(2))),
hint: `${formatNumber(rateStats.requestCount)} requests in last ${rateStats.windowMinutes}m`,
accent: '#10b981',
},
{
key: 'tpm',
label: 'TPM (30m)',
value: formatNumber(Number(rateStats.tpm.toFixed(2))),
hint: `${formatNumber(rateStats.tokenCount)} tokens in last ${rateStats.windowMinutes}m`,
accent: '#f97316',
},
{
key: 'cost',
label: 'Total cost',
value: hasPricing ? `$${formatNumber(totalCost)}` : '--',
hint: hasPricing ? 'Calculated from saved backend pricing' : 'Save model pricing to unlock cost analytics',
accent: '#f59e0b',
},
]
}
export function buildCostSummary(usage: UsageSnapshot, pricing: PricingEntry[]): number {
const priceMap = getPriceMap(pricing)
return collectUsageEvents(usage).reduce((sum, event) => sum + calculateEventCost(event, priceMap), 0)
}
export function buildApiSummary(usage: UsageSnapshot, pricing: PricingEntry[]): ApiSummaryItem[] {
const priceMap = getPriceMap(pricing)
return Object.entries(usage.apis)
.map(([apiName, api]) => {
const models = Object.entries(api.models)
.map(([modelName, model]) => {
const detailEvents = (model.details ?? []).map((detail) => ({ ...detail, apiName, modelName }))
const totalCost = detailEvents.reduce((sum, event) => sum + calculateEventCost(event, priceMap), 0)
return {
modelName,
totalRequests: model.total_requests,
successCount: model.success_count,
failureCount: model.failure_count,
totalTokens: model.total_tokens,
totalCost,
}
})
.sort((a, b) => b.totalTokens - a.totalTokens)
return {
apiName,
totalRequests: api.total_requests,
successCount: api.success_count,
failureCount: api.failure_count,
totalTokens: api.total_tokens,
modelCount: models.length,
totalCost: models.reduce((sum, model) => sum + model.totalCost, 0),
models,
}
})
.sort((a, b) => b.totalTokens - a.totalTokens)
}
export function buildModelSummary(usage: UsageSnapshot, pricing: PricingEntry[]): ModelSummaryItem[] {
const priceMap = getPriceMap(pricing)
return Object.entries(usage.apis)
.flatMap(([apiName, api]) =>
Object.entries(api.models).map(([modelName, model]) => {
const details = model.details ?? []
const latencyValues = details.map((detail) => detail.latency_ms).filter((value) => Number.isFinite(value))
const totalLatencyMs = latencyValues.reduce((sum, value) => sum + value, 0)
const totalCost = details
.map((detail) => ({ ...detail, apiName, modelName }))
.reduce((sum, event) => sum + calculateEventCost(event, priceMap), 0)
return {
apiName,
modelName,
totalRequests: model.total_requests,
successCount: model.success_count,
failureCount: model.failure_count,
totalTokens: model.total_tokens,
averageLatencyMs: latencyValues.length > 0 ? Math.round(totalLatencyMs / latencyValues.length) : 0,
totalLatencyMs,
successRate: model.total_requests > 0 ? (model.success_count / model.total_requests) * 100 : 100,
totalCost,
}
}),
)
.sort((a, b) => b.totalTokens - a.totalTokens)
}
export function buildRecentEvents(usage: UsageSnapshot, limit = 12): EventRow[] {
return collectUsageEvents(usage)
.map((event) => ({
timestamp: event.timestamp,
apiName: event.apiName,
modelName: event.modelName,
source: event.source || '-',
authIndex: event.auth_index || '-',
failed: event.failed,
latencyMs: event.latency_ms,
inputTokens: event.tokens.input_tokens,
outputTokens: event.tokens.output_tokens,
reasoningTokens: event.tokens.reasoning_tokens,
cachedTokens: event.tokens.cached_tokens,
totalTokens: event.tokens.total_tokens,
}))
.slice(0, limit)
}
export function buildTrendPoints(series: Record<string, number>): TrendPoint[] {
return Object.entries(series)
.sort(([left], [right]) => left.localeCompare(right))
.map(([label, value]) => ({ label, value }))
}
export function buildSeriesTrends(usage: UsageSnapshot, dimension: UsageSeriesDimension, metric: 'requests' | 'tokens'): TrendSeries[] {
if (dimension === 'all') {
return [
{
key: metric,
label: metric === 'requests' ? 'All requests' : 'All tokens',
color: SERIES_COLORS[0],
data: buildTrendPoints(metric === 'requests' ? usage.requests_by_hour : usage.tokens_by_hour),
},
]
}
const grouped = new Map<string, Record<string, number>>()
for (const event of collectUsageEvents(usage)) {
const key = dimension === 'api' ? event.apiName : event.modelName
const hourKey = `${new Date(event.timestamp).toISOString().slice(0, 13)}:00:00Z`
const bucket = grouped.get(key) ?? {}
bucket[hourKey] = (bucket[hourKey] ?? 0) + (metric === 'requests' ? 1 : event.tokens.total_tokens)
grouped.set(key, bucket)
}
return [...grouped.entries()]
.map(([key, values], index) => ({
key,
label: key,
color: SERIES_COLORS[index % SERIES_COLORS.length],
data: buildTrendPoints(values),
}))
.sort((left, right) => {
const leftTotal = left.data.reduce((sum, point) => sum + point.value, 0)
const rightTotal = right.data.reduce((sum, point) => sum + point.value, 0)
return rightTotal - leftTotal
})
.slice(0, 5)
}
export function buildCostTrendPoints(usage: UsageSnapshot, pricing: PricingEntry[]): TrendPoint[] {
const priceMap = getPriceMap(pricing)
const buckets: Record<string, number> = {}
for (const event of collectUsageEvents(usage)) {
const hourKey = `${new Date(event.timestamp).toISOString().slice(0, 13)}:00:00Z`
buckets[hourKey] = (buckets[hourKey] ?? 0) + calculateEventCost(event, priceMap)
}
return buildTrendPoints(buckets)
}
export function buildTokenBreakdown(usage: UsageSnapshot): TokenBreakdown {
return Object.values(usage.apis).reduce(
(totals, api) => {
for (const model of Object.values(api.models)) {
for (const detail of model.details ?? []) {
totals.inputTokens += detail.tokens.input_tokens
totals.outputTokens += detail.tokens.output_tokens
totals.reasoningTokens += detail.tokens.reasoning_tokens
totals.cachedTokens += detail.tokens.cached_tokens
}
}
return totals
},
{
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
cachedTokens: 0,
},
)
}