From eb45f3ac558e489384254b3d20f9987ec2856d71 Mon Sep 17 00:00:00 2001 From: Bo Lu Date: Sat, 25 Apr 2026 14:53:26 +0800 Subject: [PATCH] feat(usage): /usage returns three-lens cost stats (Task #11) - src/lib/usage-stats.ts: computeUsageStats(filters) builds the three SQL templates (historical/current/timeline) and returns totals + byAccount.byModel.endpoint_breakdown + daily + missing_pricing per design doc 03. - src/routes/usage/route.ts: parses ?from/?to/?account/?model/?endpoint/?lens; returns { ...primaryAccountQuotaForBackcompat, quota: { byAccount, primary }, stats }. Tests: historical from snapshots, current uses live pricing, missing_pricing list, endpoint filter. Refs #11 Co-Authored-By: Claude Opus 4 --- src/lib/usage-stats.ts | 301 ++++++++++++++++++++++++++++++++++++++ src/routes/usage/route.ts | 86 ++++++++++- tests/usage-stats.test.ts | 162 ++++++++++++++++++++ 3 files changed, 543 insertions(+), 6 deletions(-) create mode 100644 src/lib/usage-stats.ts create mode 100644 tests/usage-stats.test.ts diff --git a/src/lib/usage-stats.ts b/src/lib/usage-stats.ts new file mode 100644 index 00000000..9ed6f9b8 --- /dev/null +++ b/src/lib/usage-stats.ts @@ -0,0 +1,301 @@ +import type { Database } from "bun:sqlite" + +import { getDb } from "./db" + +export type Lens = "historical" | "current" | "timeline" + +export interface UsageStatsFilters { + from: number + to: number + account?: string + model?: string + endpoint?: string + lens: Lens +} + +export interface TokenTotals { + input: number + cached_input: number + output: number + reasoning: number + cost_usd: number | null +} + +export interface PremiumTotals { + requests: number + cost_usd: number | null +} + +export interface UsageStats { + range: { from: number; to: number } + currency: string + lens: Lens + totals: { token: TokenTotals; premium: PremiumTotals } + byAccount: Array<{ + name: string + totals: { token: TokenTotals; premium: PremiumTotals } + byModel: Array<{ + model: string + endpoint_breakdown: Record + token: TokenTotals + premium: PremiumTotals + }> + }> + daily: Array<{ + day: string + account: string + model: string + token: TokenTotals + premium: PremiumTotals + }> + missing_pricing: Array +} + +const COST_EXPRESSIONS: Record = { + historical: { + table: "usage_events ue", + cost: `( + ue.input_tokens / 1e6 * ue.input_price_snapshot + + ue.cached_input_tokens / 1e6 * ue.cached_input_price_snapshot + + ue.output_tokens / 1e6 * ue.output_price_snapshot + + ue.reasoning_tokens / 1e6 * ue.reasoning_price_snapshot + )`, + }, + current: { + table: + "usage_events ue LEFT JOIN model_pricing mp ON mp.model_id = ue.model_id", + cost: `( + ue.input_tokens / 1e6 * mp.input_per_mtok + + ue.cached_input_tokens / 1e6 * mp.cached_input_per_mtok + + ue.output_tokens / 1e6 * mp.output_per_mtok + + ue.reasoning_tokens / 1e6 * mp.reasoning_per_mtok + )`, + }, + timeline: { + table: + "usage_events ue LEFT JOIN model_pricing_versions pv ON pv.model_id = ue.model_id AND ue.ts >= pv.effective_from AND (pv.effective_to IS NULL OR ue.ts < pv.effective_to)", + cost: `( + ue.input_tokens / 1e6 * pv.input_per_mtok + + ue.cached_input_tokens / 1e6 * pv.cached_input_per_mtok + + ue.output_tokens / 1e6 * pv.output_per_mtok + + ue.reasoning_tokens / 1e6 * pv.reasoning_per_mtok + )`, + }, +} + +interface FilterClause { + sql: string + params: Array +} + +function buildFilter(f: UsageStatsFilters): FilterClause { + const where: Array = ["ue.ts BETWEEN ? AND ?"] + const params: Array = [f.from, f.to] + if (f.account) { + where.push("ue.account_name = ?") + params.push(f.account) + } + if (f.model) { + where.push("ue.model_id = ?") + params.push(f.model) + } + if (f.endpoint) { + where.push("ue.endpoint = ?") + params.push(f.endpoint) + } + return { sql: where.join(" AND "), params } +} + +interface AggregateRow { + input_tokens: number + cached_input_tokens: number + output_tokens: number + reasoning_tokens: number + cost_usd: number | null + premium_requests: number + premium_cost_usd: number | null +} + +interface ByAccountRow extends AggregateRow { + account_name: string +} + +interface ByAccountModelRow extends AggregateRow { + account_name: string + model_id: string + endpoint: string +} + +interface DailyRow extends AggregateRow { + day: string + account_name: string + model_id: string +} + +const COMMON_AGGREGATE = (cost: string) => ` + SUM(ue.input_tokens) AS input_tokens, + SUM(ue.cached_input_tokens) AS cached_input_tokens, + SUM(ue.output_tokens) AS output_tokens, + SUM(ue.reasoning_tokens) AS reasoning_tokens, + SUM(${cost}) AS cost_usd, + SUM(ue.premium_request_count) AS premium_requests, + SUM(ue.premium_request_count * COALESCE(ue.premium_unit_price_snapshot, 0)) + AS premium_cost_usd +` + +function tokenTotals(r: AggregateRow): TokenTotals { + return { + input: r.input_tokens || 0, + cached_input: r.cached_input_tokens || 0, + output: r.output_tokens || 0, + reasoning: r.reasoning_tokens || 0, + cost_usd: r.cost_usd, + } +} + +function premiumTotals(r: AggregateRow): PremiumTotals { + return { + requests: r.premium_requests || 0, + cost_usd: r.premium_cost_usd, + } +} + +function buildByAccount( + byAccountRows: Array, + byAccountModelRows: Array, +): UsageStats["byAccount"] { + return byAccountRows.map((acc) => { + const modelMap = new Map< + string, + { + model: string + endpoint_breakdown: Record + agg: AggregateRow + } + >() + for (const row of byAccountModelRows) { + if (row.account_name !== acc.account_name) continue + let entry = modelMap.get(row.model_id) + if (!entry) { + entry = { + model: row.model_id, + endpoint_breakdown: {}, + agg: { + input_tokens: 0, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + cost_usd: 0, + premium_requests: 0, + premium_cost_usd: 0, + }, + } + modelMap.set(row.model_id, entry) + } + entry.endpoint_breakdown[row.endpoint] = { + ...tokenTotals(row), + ...premiumTotals(row), + } + entry.agg.input_tokens += row.input_tokens || 0 + entry.agg.cached_input_tokens += row.cached_input_tokens || 0 + entry.agg.output_tokens += row.output_tokens || 0 + entry.agg.reasoning_tokens += row.reasoning_tokens || 0 + entry.agg.cost_usd = (entry.agg.cost_usd ?? 0) + (row.cost_usd ?? 0) + entry.agg.premium_requests += row.premium_requests || 0 + entry.agg.premium_cost_usd = + (entry.agg.premium_cost_usd ?? 0) + (row.premium_cost_usd ?? 0) + } + return { + name: acc.account_name, + totals: { token: tokenTotals(acc), premium: premiumTotals(acc) }, + byModel: [...modelMap.values()].map((m) => ({ + model: m.model, + endpoint_breakdown: m.endpoint_breakdown, + token: tokenTotals(m.agg), + premium: premiumTotals(m.agg), + })), + } + }) +} + +export function computeUsageStats(filters: UsageStatsFilters): UsageStats { + const db: Database = getDb() + const { table, cost } = COST_EXPRESSIONS[filters.lens] + const filter = buildFilter(filters) + + const totalsRow = + db + .query< + AggregateRow, + Array + >(`SELECT ${COMMON_AGGREGATE(cost)} FROM ${table} WHERE ${filter.sql}`) + .get(...filter.params) + ?? ({ + input_tokens: 0, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + cost_usd: 0, + premium_requests: 0, + premium_cost_usd: 0, + } as AggregateRow) + + const byAccountRows = db + .query>( + `SELECT ue.account_name, ${COMMON_AGGREGATE(cost)} + FROM ${table} + WHERE ${filter.sql} + GROUP BY ue.account_name + ORDER BY ue.account_name`, + ) + .all(...filter.params) + + const byAccountModelRows = db + .query>( + `SELECT ue.account_name, ue.model_id, ue.endpoint, ${COMMON_AGGREGATE(cost)} + FROM ${table} + WHERE ${filter.sql} + GROUP BY ue.account_name, ue.model_id, ue.endpoint + ORDER BY ue.account_name, ue.model_id, ue.endpoint`, + ) + .all(...filter.params) + + const dailyRows = db + .query>( + `SELECT date(ue.ts/1000, 'unixepoch', 'localtime') AS day, + ue.account_name, ue.model_id, ${COMMON_AGGREGATE(cost)} + FROM ${table} + WHERE ${filter.sql} + GROUP BY day, ue.account_name, ue.model_id + ORDER BY day, ue.account_name, ue.model_id`, + ) + .all(...filter.params) + + const missing = db + .query<{ model_id: string }, [number, number]>( + `SELECT DISTINCT model_id FROM usage_events + WHERE model_id NOT IN (SELECT model_id FROM model_pricing) + AND ts BETWEEN ? AND ?`, + ) + .all(filters.from, filters.to) + .map((r) => r.model_id) + + return { + range: { from: filters.from, to: filters.to }, + currency: "USD", + lens: filters.lens, + totals: { + token: tokenTotals(totalsRow), + premium: premiumTotals(totalsRow), + }, + byAccount: buildByAccount(byAccountRows, byAccountModelRows), + daily: dailyRows.map((r) => ({ + day: r.day, + account: r.account_name, + model: r.model_id, + token: tokenTotals(r), + premium: premiumTotals(r), + })), + missing_pricing: missing, + } +} diff --git a/src/routes/usage/route.ts b/src/routes/usage/route.ts index 847a2f94..b82d575a 100644 --- a/src/routes/usage/route.ts +++ b/src/routes/usage/route.ts @@ -1,16 +1,90 @@ import { Hono } from "hono" -import { defaultApiContext } from "~/lib/utils" +import { state } from "~/lib/state" +import { + computeUsageStats, + type Lens, + type UsageStatsFilters, +} from "~/lib/usage-stats" +import { makeApiContext } from "~/lib/utils" import { getCopilotUsage } from "~/services/github/get-copilot-usage" export const usageRoute = new Hono() +const VALID_LENSES: Array = ["historical", "current", "timeline"] +const VALID_ENDPOINTS = new Set(["chat.completions", "messages", "embeddings"]) + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000 + +function parseFilters(req: Request): UsageStatsFilters { + const url = new URL(req.url) + const now = Date.now() + const fromRaw = url.searchParams.get("from") + const toRaw = url.searchParams.get("to") + const from = fromRaw ? Number.parseInt(fromRaw, 10) : now - THIRTY_DAYS_MS + const to = toRaw ? Number.parseInt(toRaw, 10) : now + const lensRaw = url.searchParams.get("lens") ?? "historical" + const lens: Lens = + (VALID_LENSES as Array).includes(lensRaw) ? + (lensRaw as Lens) + : "historical" + const endpoint = url.searchParams.get("endpoint") + return { + from, + to, + account: url.searchParams.get("account") ?? undefined, + model: url.searchParams.get("model") ?? undefined, + endpoint: endpoint && VALID_ENDPOINTS.has(endpoint) ? endpoint : undefined, + lens, + } +} + +interface QuotaPayload { + byAccount: Array<{ name: string; quota?: unknown; error?: string }> + primary?: unknown +} + +async function fetchQuota(): Promise { + const accounts = state.pool?.accounts ?? [] + const results = await Promise.all( + accounts.map(async (account) => { + try { + const quota = await getCopilotUsage(makeApiContext(account)) + return { name: account.name, quota } + } catch (err) { + return { name: account.name, error: (err as Error).message } + } + }), + ) + const primary = results.find((r) => "quota" in r && r.quota)?.quota + return { byAccount: results, primary } +} + usageRoute.get("/", async (c) => { + const stats = (() => { + try { + return computeUsageStats(parseFilters(c.req.raw)) + } catch (err) { + console.error("Error computing usage stats:", err) + return null + } + })() + + let quota: unknown = null + let primary: unknown = null try { - const usage = await getCopilotUsage(defaultApiContext()) - return c.json(usage) - } catch (error) { - console.error("Error fetching Copilot usage:", error) - return c.json({ error: "Failed to fetch Copilot usage" }, 500) + const result = await fetchQuota() + quota = result + primary = result.primary + } catch (err) { + console.error("Error fetching Copilot quota:", err) } + + // Backwards compat: top-level fields from the old payload (when present) + // are spread from the primary account's response. + return c.json({ + ...(primary as Record | null | undefined), + quota, + stats, + }) }) diff --git a/tests/usage-stats.test.ts b/tests/usage-stats.test.ts new file mode 100644 index 00000000..9503f5eb --- /dev/null +++ b/tests/usage-stats.test.ts @@ -0,0 +1,162 @@ +import { test, expect, describe, beforeEach } from "bun:test" + +import type { Account } from "../src/lib/account-pool" + +import { __resetDbForTests, getDb, initDb } from "../src/lib/db" +import { recordUsage } from "../src/lib/usage-recorder" +import { computeUsageStats } from "../src/lib/usage-stats" + +const ACCOUNT: Account = { + name: "alice", + accountType: "individual", + githubToken: "ghu_a", + copilotToken: "tok_a", + copilotTokenRefreshAt: 0, + inFlight: 0, + lastUsedAt: 0, + failureCount: 0, +} + +function setupDb() { + __resetDbForTests() + const db = initDb(":memory:") + db.run( + "INSERT INTO accounts (name, account_type, created_at) VALUES (?, ?, ?)", + [ACCOUNT.name, ACCOUNT.accountType, Date.now()], + ) + db.run( + `INSERT INTO model_pricing ( + model_id, input_per_mtok, cached_input_per_mtok, output_per_mtok, + reasoning_per_mtok, premium_multiplier, premium_unit_price, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ["gpt-4o", 2, 0, 8, 0, 1, 0.04, Date.now()], + ) + return db +} + +const baseRecord = { + account: ACCOUNT, + modelId: "gpt-4o", + endpoint: "chat.completions" as const, + upstreamFormat: "openai" as const, + isStreaming: false, + durationMs: 100, + status: "ok" as const, +} + +describe("computeUsageStats", () => { + beforeEach(() => { + __resetDbForTests() + }) + + test("historical lens computes cost from snapshots", () => { + setupDb() + recordUsage({ + ...baseRecord, + usage: { + inputTokens: 1_000_000, + cachedInputTokens: 0, + outputTokens: 500_000, + reasoningTokens: 0, + totalTokens: 1_500_000, + }, + }) + const stats = computeUsageStats({ + from: 0, + to: Date.now() + 1, + lens: "historical", + }) + expect(stats.totals.token.input).toBe(1_000_000) + expect(stats.totals.token.output).toBe(500_000) + // 1M * $2 + 0.5M * $8 = $2 + $4 = $6 + expect(stats.totals.token.cost_usd).toBeCloseTo(6, 5) + expect(stats.byAccount).toHaveLength(1) + expect(stats.byAccount[0].byModel[0].model).toBe("gpt-4o") + }) + + test("current lens uses model_pricing live row", () => { + setupDb() + recordUsage({ + ...baseRecord, + usage: { + inputTokens: 1_000_000, + cachedInputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 1_000_000, + }, + }) + // Bump live pricing + getDb().run( + "UPDATE model_pricing SET input_per_mtok = ? WHERE model_id = ?", + [10, "gpt-4o"], + ) + const stats = computeUsageStats({ + from: 0, + to: Date.now() + 1, + lens: "current", + }) + // Now $10 per Mtok input → cost = $10 + expect(stats.totals.token.cost_usd).toBeCloseTo(10, 5) + }) + + test("missing_pricing lists models with usage but no pricing row", () => { + setupDb() + recordUsage({ + ...baseRecord, + modelId: "unknown-model", + usage: { + inputTokens: 1, + cachedInputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 1, + }, + }) + const stats = computeUsageStats({ + from: 0, + to: Date.now() + 1, + lens: "historical", + }) + expect(stats.missing_pricing).toContain("unknown-model") + }) + + test("filter by endpoint narrows the result", () => { + setupDb() + recordUsage({ + ...baseRecord, + usage: { + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 100, + }, + }) + recordUsage({ + ...baseRecord, + endpoint: "embeddings", + usage: { + inputTokens: 50, + cachedInputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 50, + }, + }) + const allStats = computeUsageStats({ + from: 0, + to: Date.now() + 1, + lens: "historical", + }) + expect(allStats.totals.token.input).toBe(150) + + const embeddingsOnly = computeUsageStats({ + from: 0, + to: Date.now() + 1, + lens: "historical", + endpoint: "embeddings", + }) + expect(embeddingsOnly.totals.token.input).toBe(50) + }) +})