diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 1050fcfa3d..57b2aeb69c 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -37,7 +37,11 @@ export async function GET(request) { const { searchParams } = new URL(request.url); const range = searchParams.get("range") || "30d"; - const db = await getUsageDb(); + // Cap history load to last 365 days — the heatmap never looks beyond that, + // and all named ranges (1d/7d/30d/90d/ytd) fall within this window. + const heatmapSince = new Date(); + heatmapSince.setDate(heatmapSince.getDate() - 365); + const db = await getUsageDb(heatmapSince.toISOString()); const history = db.data.history || []; // Build connection map for account names diff --git a/src/app/api/v1/search/analytics/route.ts b/src/app/api/v1/search/analytics/route.ts index b224f6660a..2e11685504 100644 --- a/src/app/api/v1/search/analytics/route.ts +++ b/src/app/api/v1/search/analytics/route.ts @@ -16,39 +16,38 @@ export async function GET(req: Request) { try { const db = getDbInstance(); - // Total search requests - const totalRow = db - .prepare(`SELECT COUNT(*) as cnt FROM call_logs WHERE request_type = 'search'`) - .get() as { cnt: number }; - const total = totalRow?.cnt ?? 0; - - // Today's searches (UTC date) + // Single aggregated query for all scalar metrics — replaces 5 separate round-trips const todayStart = new Date(); todayStart.setUTCHours(0, 0, 0, 0); - const todayRow = db - .prepare( - `SELECT COUNT(*) as cnt FROM call_logs WHERE request_type = 'search' AND timestamp >= ?` - ) - .get(todayStart.toISOString()) as { cnt: number }; - const today = todayRow?.cnt ?? 0; + const todayIso = todayStart.toISOString(); - // Errors - const errRow = db + type StatsRow = { + total: number; + today: number; + errors: number; + avg_duration: number | null; + cached: number; + }; + const statsRow = db .prepare( - `SELECT COUNT(*) as cnt FROM call_logs WHERE request_type = 'search' AND (status >= 400 OR error IS NOT NULL)` + `SELECT + COUNT(*) as total, + COALESCE(SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END), 0) as today, + COALESCE(SUM(CASE WHEN status >= 400 OR error IS NOT NULL THEN 1 ELSE 0 END), 0) as errors, + AVG(CASE WHEN duration > 0 THEN duration END) as avg_duration, + COALESCE(SUM(CASE WHEN duration > 0 AND duration < 5 THEN 1 ELSE 0 END), 0) as cached + FROM call_logs + WHERE request_type = 'search'` ) - .get() as { cnt: number }; - const errors = errRow?.cnt ?? 0; + .get(todayIso) as StatsRow | undefined; - // Avg duration - const durRow = db - .prepare( - `SELECT AVG(duration) as avg FROM call_logs WHERE request_type = 'search' AND duration > 0` - ) - .get() as { avg: number | null }; - const avgDurationMs = Math.round(durRow?.avg ?? 0); + const total = statsRow?.total ?? 0; + const today = statsRow?.today ?? 0; + const errors = statsRow?.errors ?? 0; + const avgDurationMs = Math.round(statsRow?.avg_duration ?? 0); + const cached = statsRow?.cached ?? 0; - // Per-provider breakdown (provider column stores search provider id) + // Per-provider breakdown const provRows = db .prepare( `SELECT provider, COUNT(*) as cnt @@ -74,14 +73,6 @@ export async function GET(req: Request) { totalCostUsd += cost; } - // Cached: very fast responses (< 5ms) indicate cache hits - const cachedRow = db - .prepare( - `SELECT COUNT(*) as cnt FROM call_logs - WHERE request_type = 'search' AND duration > 0 AND duration < 5` - ) - .get() as { cnt: number }; - const cached = cachedRow?.cnt ?? 0; const cacheHitRate = total > 0 ? Math.round((cached / total) * 100) : 0; return NextResponse.json({ diff --git a/src/lib/usage/costCalculator.ts b/src/lib/usage/costCalculator.ts index a4561eb722..bf8db747d7 100644 --- a/src/lib/usage/costCalculator.ts +++ b/src/lib/usage/costCalculator.ts @@ -18,7 +18,7 @@ * @param {string} model * @returns {string} */ -function normalizeModelName(model) { +export function normalizeModelName(model) { if (!model || !model.includes("/")) return model; const parts = model.split("/"); return parts[parts.length - 1]; @@ -41,6 +41,41 @@ function toNumber(value: unknown, fallback = 0): number { * @param {Object} tokens * @returns {Promise} Cost in USD */ +/** + * Compute cost synchronously from a pre-fetched pricing record. + * Use this when pricing has already been loaded (e.g. in batch analytics). + */ +export function computeCostFromPricing( + pricing: Record | null | undefined, + tokens: any +): number { + if (!pricing || !tokens) return 0; + const inputPrice = toNumber(pricing.input, 0); + const cachedPrice = toNumber(pricing.cached, inputPrice); + const outputPrice = toNumber(pricing.output, 0); + const reasoningPrice = toNumber(pricing.reasoning, outputPrice); + const cacheCreationPrice = toNumber(pricing.cache_creation, inputPrice); + + let cost = 0; + const inputTokens = tokens.input ?? tokens.prompt_tokens ?? tokens.input_tokens ?? 0; + const cachedTokens = + tokens.cacheRead ?? tokens.cached_tokens ?? tokens.cache_read_input_tokens ?? 0; + const nonCachedInput = Math.max(0, inputTokens - cachedTokens); + cost += nonCachedInput * (inputPrice / 1_000_000); + if (cachedTokens > 0) cost += cachedTokens * (cachedPrice / 1_000_000); + + const outputTokens = tokens.output ?? tokens.completion_tokens ?? tokens.output_tokens ?? 0; + cost += outputTokens * (outputPrice / 1_000_000); + + const reasoningTokens = tokens.reasoning ?? tokens.reasoning_tokens ?? 0; + if (reasoningTokens > 0) cost += reasoningTokens * (reasoningPrice / 1_000_000); + + const cacheCreationTokens = tokens.cacheCreation ?? tokens.cache_creation_input_tokens ?? 0; + if (cacheCreationTokens > 0) cost += cacheCreationTokens * (cacheCreationPrice / 1_000_000); + + return cost; +} + export async function calculateCost(provider, model, tokens) { if (!tokens || !provider || !model) return 0; diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index 56bc075f98..afb56e2cb6 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -108,9 +108,13 @@ export function getPendingRequests() { * Returns an object compatible with the old LowDB interface. * Only `api/usage/analytics/route.js` uses this — it reads `db.data.history`. */ -export async function getUsageDb() { +export async function getUsageDb(sinceIso?: string | null) { const db = getDbInstance(); - const rows = db.prepare("SELECT * FROM usage_history ORDER BY timestamp ASC").all(); + const rows = sinceIso + ? db + .prepare("SELECT * FROM usage_history WHERE timestamp >= ? ORDER BY timestamp ASC") + .all(sinceIso) + : db.prepare("SELECT * FROM usage_history ORDER BY timestamp ASC").all(); const history = rows.map((row) => { const r = asRecord(row); diff --git a/src/lib/usageAnalytics.ts b/src/lib/usageAnalytics.ts index 174e498392..5319179c5e 100644 --- a/src/lib/usageAnalytics.ts +++ b/src/lib/usageAnalytics.ts @@ -5,7 +5,10 @@ * summary cards, daily trends, activity heatmap, model breakdown, etc. */ -import { calculateCost } from "@/lib/usageDb"; +import { + normalizeModelName as normModel, + computeCostFromPricing, +} from "@/lib/usage/costCalculator"; /** * Compute date range boundaries @@ -128,8 +131,31 @@ export async function computeAnalytics( } } + // Pre-fetch pricing for all unique (provider, model) pairs — one DB round-trip + // per unique pair instead of one per entry, then compute costs synchronously. + const { getPricingForModel } = await import("@/lib/localDb"); + const pricingCache = new Map | null>(); + const uniquePairs = new Set(entries.map((e) => `${e.provider}|||${e.model}`)); + await Promise.all( + [...uniquePairs].map(async (key) => { + const [provider, model] = key.split("|||"); + let pricing = (await getPricingForModel(provider, model)) as Record | null; + if (!pricing) { + const normalized = normModel(model); + if (normalized !== model) { + pricing = (await getPricingForModel(provider, normalized)) as Record< + string, + unknown + > | null; + } + } + pricingCache.set(key, pricing ?? null); + }) + ); + // ---- Single pass over filtered entries for everything else ---- - for (const entry of entries) { + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; const pt = entry.tokens?.input ?? entry.tokens?.prompt_tokens ?? 0; const ct = entry.tokens?.output ?? entry.tokens?.completion_tokens ?? 0; const totalTkns = pt + ct; @@ -138,13 +164,8 @@ export async function computeAnalytics( const dayOfWeek = entryDate.getDay(); const modelShort = shortModelName(entry.model); - // Cost - let cost = 0; - try { - cost = await calculateCost(entry.provider, entry.model, entry.tokens); - } catch { - /* ignore */ - } + const pricingKey = `${entry.provider}|||${entry.model}`; + const cost = computeCostFromPricing(pricingCache.get(pricingKey), entry.tokens); // Summary summary.promptTokens += pt;