import consola from "consola" import path from "node:path" import { PATHS } from "~/lib/paths" import { registerProcessCleanup } from "~/lib/process-cleanup" import { isSqliteRuntimeSupported, SqliteDbStore, type SqliteDatabase, } from "~/lib/sqlite" export type TokenUsageSource = "copilot" | "provider" export type TokenUsageEndpoint = | "chat_completions" | "embeddings" | "messages" | "provider_messages" | "responses" export type TokenUsagePeriod = "day" | "week" | "month" export interface UsageTokens { cache_creation_input_tokens?: number | null cache_read_input_tokens?: number | null input_tokens?: number | null output_tokens?: number | null total_tokens?: number | null } export interface PersistedTokenUsageEvent { cache_creation_input_tokens: number cache_read_input_tokens: number created_at_ms: number created_at_utc: string endpoint: TokenUsageEndpoint input_tokens: number model: string output_tokens: number provider_name: string | null session_id: string source: TokenUsageSource total_tokens: number trace_id: string user_id: string } export interface TokenUsageTotals { cache_creation_input_tokens: number cache_read_input_tokens: number input_tokens: number output_tokens: number request_count: number total_tokens: number } export interface TokenUsageModelSummary extends TokenUsageTotals { model: string } export interface TokenUsageEventRecord { cache_creation_input_tokens: number cache_read_input_tokens: number created_at_ms: number created_at_utc: string endpoint: TokenUsageEndpoint id: number input_tokens: number model: string output_tokens: number provider_name: string | null session_id: string source: TokenUsageSource total_tokens: number trace_id: string user_id: string } export interface TokenUsageSummary { byModel: Array period: TokenUsagePeriod range: { end_ms: number end_utc: string start_ms: number start_utc: string } totals: TokenUsageTotals } export interface TokenUsageDailyBucket { byModel: Array date: string end_ms: number start_ms: number totals: TokenUsageTotals } export interface TokenUsageDailySummary { byModel: Array days: Array period: TokenUsagePeriod range: { end_ms: number end_utc: string start_ms: number start_utc: string } totals: TokenUsageTotals } export interface TokenUsageEventsPage { items: Array page: number page_size: number period: TokenUsagePeriod range: { end_ms: number end_utc: string start_ms: number start_utc: string } total: number total_pages: number } const DB_PATH_ENV = "COPILOT_API_SQLITE_DB_PATH" const DEFAULT_DB_FILENAME = "copilot-api.sqlite" let writeQueue: Promise = Promise.resolve() function getDbPath(): string { return ( process.env[DB_PATH_ENV] ?? path.join(PATHS.APP_DIR, DEFAULT_DB_FILENAME) ) } const tokenUsageDbStore = new SqliteDbStore({ getPath: getDbPath, initialize: initializeTokenUsageDb, }) function getDb(): Promise { return tokenUsageDbStore.getDb() } export function isTokenUsageStorageEnabled(): boolean { return isSqliteRuntimeSupported() } function initializeTokenUsageDb(db: SqliteDatabase): void { db.exec("PRAGMA journal_mode = WAL") db.exec("PRAGMA busy_timeout = 5000") db.exec(` CREATE TABLE IF NOT EXISTS token_usage_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at_ms INTEGER NOT NULL, created_at_utc TEXT NOT NULL, trace_id TEXT NOT NULL, session_id TEXT NOT NULL DEFAULT '', user_id TEXT NOT NULL DEFAULT '', source TEXT NOT NULL, endpoint TEXT NOT NULL, provider_name TEXT, model TEXT NOT NULL, input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, cache_read_input_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0, total_tokens INTEGER NOT NULL DEFAULT 0 ) `) ensureColumn(db, "user_id", "TEXT NOT NULL DEFAULT ''") ensureColumn(db, "total_tokens", "INTEGER NOT NULL DEFAULT 0") db.exec(` CREATE INDEX IF NOT EXISTS idx_token_usage_events_created_at_ms ON token_usage_events(created_at_ms) `) db.exec(` CREATE INDEX IF NOT EXISTS idx_token_usage_events_model ON token_usage_events(model) `) db.exec(` CREATE INDEX IF NOT EXISTS idx_token_usage_events_trace_id ON token_usage_events(trace_id) `) db.exec(` CREATE INDEX IF NOT EXISTS idx_token_usage_events_session_id ON token_usage_events(session_id) `) db.exec(` CREATE INDEX IF NOT EXISTS idx_token_usage_events_user_id ON token_usage_events(user_id) `) } function ensureColumn( db: SqliteDatabase, name: string, definition: string, ): void { const rows = db .prepare("PRAGMA table_info(token_usage_events)") .all() as Array> const hasColumn = rows.some((row) => row.name === name) if (!hasColumn) { db.exec(`ALTER TABLE token_usage_events ADD COLUMN ${name} ${definition}`) } } export function normalizeToken(value: number | null | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) { return 0 } return Math.max(0, Math.floor(value)) } export function normalizeOptionalToken( value: number | null | undefined, ): number | undefined { return value === null || value === undefined ? undefined : normalizeToken(value) } export function hasAnyToken(tokens: UsageTokens): boolean { return ( normalizeToken(tokens.input_tokens) > 0 || normalizeToken(tokens.output_tokens) > 0 || normalizeToken(tokens.cache_read_input_tokens) > 0 || normalizeToken(tokens.cache_creation_input_tokens) > 0 || normalizeToken(tokens.total_tokens) > 0 ) } export function resolveTotalTokens(input: UsageTokens): number { const explicitTotal = normalizeOptionalToken(input.total_tokens) if (explicitTotal !== undefined) { return explicitTotal } return ( normalizeToken(input.input_tokens) + normalizeToken(input.output_tokens) + normalizeToken(input.cache_read_input_tokens) + normalizeToken(input.cache_creation_input_tokens) ) } async function writeTokenUsageEvent( event: PersistedTokenUsageEvent, ): Promise { const db = await getDb() db.prepare( ` INSERT INTO token_usage_events ( created_at_ms, created_at_utc, trace_id, session_id, user_id, source, endpoint, provider_name, model, input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens, total_tokens ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( event.created_at_ms, event.created_at_utc, event.trace_id, event.session_id, event.user_id, event.source, event.endpoint, event.provider_name, event.model, event.input_tokens, event.output_tokens, event.cache_read_input_tokens, event.cache_creation_input_tokens, event.total_tokens, ) } export function enqueueTokenUsageWrite(event: PersistedTokenUsageEvent): void { if (!isTokenUsageStorageEnabled()) { return } writeQueue = writeQueue .then(() => writeTokenUsageEvent(event)) .catch((error: unknown) => { consola.warn("Failed to record token usage", error) }) } async function flushTokenUsageEvents(): Promise { let currentQueue = writeQueue while (true) { await currentQueue if (currentQueue === writeQueue) { return } currentQueue = writeQueue } } function getPeriodRange(period: TokenUsagePeriod, now = new Date()) { const start = new Date(now) start.setHours(0, 0, 0, 0) switch (period) { case "day": { break } case "week": { start.setDate(start.getDate() - 6) break } case "month": { start.setDate(start.getDate() - 29) break } default: { break } } const end = new Date(start) switch (period) { case "day": { end.setDate(end.getDate() + 1) break } case "week": { end.setDate(end.getDate() + 7) break } case "month": { end.setDate(end.getDate() + 30) break } default: { break } } return { endMs: end.getTime(), startMs: start.getTime(), } } function formatLocalDate(date: Date): string { const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, "0") const day = String(date.getDate()).padStart(2, "0") return `${year}-${month}-${day}` } function createDailyIntervals(range: { endMs: number; startMs: number }) { const intervals: Array<{ date: string endMs: number startMs: number }> = [] const cursor = new Date(range.startMs) while (cursor.getTime() < range.endMs) { const startMs = cursor.getTime() const next = new Date(cursor) next.setDate(next.getDate() + 1) const endMs = Math.min(next.getTime(), range.endMs) intervals.push({ date: formatLocalDate(cursor), endMs, startMs, }) cursor.setTime(endMs) } return intervals } function createEmptyTotals(): TokenUsageTotals { return { cache_creation_input_tokens: 0, cache_read_input_tokens: 0, input_tokens: 0, output_tokens: 0, request_count: 0, total_tokens: 0, } } function addTotals(target: TokenUsageTotals, next: TokenUsageTotals): void { target.cache_creation_input_tokens += next.cache_creation_input_tokens target.cache_read_input_tokens += next.cache_read_input_tokens target.input_tokens += next.input_tokens target.output_tokens += next.output_tokens target.request_count += next.request_count target.total_tokens += next.total_tokens } function createEmptySummary(period: TokenUsagePeriod): TokenUsageSummary { const range = getPeriodRange(period) return { byModel: [], period, range: { end_ms: range.endMs, end_utc: new Date(range.endMs).toISOString(), start_ms: range.startMs, start_utc: new Date(range.startMs).toISOString(), }, totals: createEmptyTotals(), } } function createEmptyDailySummary( period: TokenUsagePeriod, ): TokenUsageDailySummary { const range = getPeriodRange(period) return { byModel: [], days: createDailyIntervals(range).map((interval) => ({ byModel: [], date: interval.date, end_ms: interval.endMs, start_ms: interval.startMs, totals: createEmptyTotals(), })), period, range: { end_ms: range.endMs, end_utc: new Date(range.endMs).toISOString(), start_ms: range.startMs, start_utc: new Date(range.startMs).toISOString(), }, totals: createEmptyTotals(), } } function createEmptyEventsPage(input: { page: number pageSize: number period: TokenUsagePeriod }): TokenUsageEventsPage { const range = getPeriodRange(input.period) const page = Math.max(1, Math.floor(input.page)) const pageSize = Math.min(100, Math.max(1, Math.floor(input.pageSize))) return { items: [], page, page_size: pageSize, period: input.period, range: { end_ms: range.endMs, end_utc: new Date(range.endMs).toISOString(), start_ms: range.startMs, start_utc: new Date(range.startMs).toISOString(), }, total: 0, total_pages: 1, } } function rangePayload(range: { endMs: number; startMs: number }) { return { end_ms: range.endMs, end_utc: new Date(range.endMs).toISOString(), start_ms: range.startMs, start_utc: new Date(range.startMs).toISOString(), } } function numberFromRow( row: Record | undefined, key: string, ): number { const value = row?.[key] return typeof value === "number" && Number.isFinite(value) ? value : 0 } function totalsFromRow( row: Record | undefined, ): TokenUsageTotals { return { cache_creation_input_tokens: numberFromRow( row, "cache_creation_input_tokens", ), cache_read_input_tokens: numberFromRow(row, "cache_read_input_tokens"), input_tokens: numberFromRow(row, "input_tokens"), output_tokens: numberFromRow(row, "output_tokens"), request_count: numberFromRow(row, "request_count"), total_tokens: numberFromRow(row, "total_tokens"), } } function modelSummaryFromRow( row: Record, ): TokenUsageModelSummary { return { ...totalsFromRow(row), model: typeof row.model === "string" ? row.model : "unknown", } } function stringFromRow(row: Record, key: string): string { const value = row[key] return typeof value === "string" ? value : "" } function nullableStringFromRow( row: Record, key: string, ): string | null { const value = row[key] return typeof value === "string" ? value : null } function usageEventFromRow( row: Record, ): TokenUsageEventRecord { return { cache_creation_input_tokens: numberFromRow( row, "cache_creation_input_tokens", ), cache_read_input_tokens: numberFromRow(row, "cache_read_input_tokens"), created_at_ms: numberFromRow(row, "created_at_ms"), created_at_utc: stringFromRow(row, "created_at_utc"), endpoint: stringFromRow(row, "endpoint") as TokenUsageEndpoint, id: numberFromRow(row, "id"), input_tokens: numberFromRow(row, "input_tokens"), model: stringFromRow(row, "model") || "unknown", output_tokens: numberFromRow(row, "output_tokens"), provider_name: nullableStringFromRow(row, "provider_name"), session_id: stringFromRow(row, "session_id"), source: stringFromRow(row, "source") as TokenUsageSource, total_tokens: numberFromRow(row, "total_tokens"), trace_id: stringFromRow(row, "trace_id"), user_id: stringFromRow(row, "user_id"), } } function getTotalsRow( db: SqliteDatabase, range: { endMs: number; startMs: number }, ): Record | undefined { return db .prepare( ` SELECT COUNT(*) AS request_count, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens, COALESCE(SUM(cache_read_input_tokens), 0) AS cache_read_input_tokens, COALESCE(SUM(cache_creation_input_tokens), 0) AS cache_creation_input_tokens, COALESCE(SUM(total_tokens), 0) AS total_tokens FROM token_usage_events WHERE created_at_ms >= ? AND created_at_ms < ? `, ) .get(range.startMs, range.endMs) as Record | undefined } function getModelRows( db: SqliteDatabase, range: { endMs: number; startMs: number }, ): Array> { return db .prepare( ` SELECT model, COUNT(*) AS request_count, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens, COALESCE(SUM(cache_read_input_tokens), 0) AS cache_read_input_tokens, COALESCE(SUM(cache_creation_input_tokens), 0) AS cache_creation_input_tokens, COALESCE(SUM(total_tokens), 0) AS total_tokens FROM token_usage_events WHERE created_at_ms >= ? AND created_at_ms < ? GROUP BY model ORDER BY total_tokens DESC, model ASC `, ) .all(range.startMs, range.endMs) as Array> } function createDailyBucket( interval: { date: string; endMs: number; startMs: number }, rows: Array>, ): TokenUsageDailyBucket { const byModel = rows.map((row) => modelSummaryFromRow(row)) const totals = createEmptyTotals() for (const model of byModel) { addTotals(totals, model) } return { byModel, date: interval.date, end_ms: interval.endMs, start_ms: interval.startMs, totals, } } export async function getTokenUsageSummary( period: TokenUsagePeriod, ): Promise { if (!isTokenUsageStorageEnabled()) { return createEmptySummary(period) } await flushTokenUsageEvents() const range = getPeriodRange(period) const db = await getDb() const totalsRow = getTotalsRow(db, range) const byModelRows = getModelRows(db, range) return { byModel: byModelRows.map((row) => modelSummaryFromRow(row)), period, range: rangePayload(range), totals: totalsFromRow(totalsRow), } } export async function getTokenUsageDailySummary( period: TokenUsagePeriod, ): Promise { if (!isTokenUsageStorageEnabled()) { return createEmptyDailySummary(period) } await flushTokenUsageEvents() const range = getPeriodRange(period) const db = await getDb() const intervals = createDailyIntervals(range) return { byModel: getModelRows(db, range).map((row) => modelSummaryFromRow(row)), days: intervals.map((interval) => createDailyBucket(interval, getModelRows(db, interval)), ), period, range: rangePayload(range), totals: totalsFromRow(getTotalsRow(db, range)), } } export async function getTokenUsageEventsPage(input: { page: number pageSize: number period: TokenUsagePeriod }): Promise { if (!isTokenUsageStorageEnabled()) { return createEmptyEventsPage(input) } await flushTokenUsageEvents() const range = getPeriodRange(input.period) const page = Math.max(1, Math.floor(input.page)) const pageSize = Math.min(100, Math.max(1, Math.floor(input.pageSize))) const offset = (page - 1) * pageSize const db = await getDb() const totalRow = db .prepare( ` SELECT COUNT(*) AS total FROM token_usage_events WHERE created_at_ms >= ? AND created_at_ms < ? `, ) .get(range.startMs, range.endMs) as Record | undefined const rows = db .prepare( ` SELECT id, created_at_ms, created_at_utc, trace_id, session_id, user_id, source, endpoint, provider_name, model, input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens, total_tokens FROM token_usage_events WHERE created_at_ms >= ? AND created_at_ms < ? ORDER BY created_at_ms DESC, id DESC LIMIT ? OFFSET ? `, ) .all(range.startMs, range.endMs, pageSize, offset) as Array< Record > const total = numberFromRow(totalRow, "total") return { items: rows.map((row) => usageEventFromRow(row)), page, page_size: pageSize, period: input.period, range: { end_ms: range.endMs, end_utc: new Date(range.endMs).toISOString(), start_ms: range.startMs, start_utc: new Date(range.startMs).toISOString(), }, total, total_pages: Math.max(1, Math.ceil(total / pageSize)), } } export async function closeUsageStore(): Promise { await flushTokenUsageEvents() await tokenUsageDbStore.close({ beforeClose: (db) => { try { db.exec("PRAGMA wal_checkpoint(TRUNCATE)") } catch { // Ignore cleanup errors in tests. } }, }) writeQueue = Promise.resolve() } registerProcessCleanup(closeUsageStore)