diff --git a/apps/api/src/routes/activity.spec.ts b/apps/api/src/routes/activity.spec.ts index d30e9573db..3aebe73a70 100644 --- a/apps/api/src/routes/activity.spec.ts +++ b/apps/api/src/routes/activity.spec.ts @@ -245,4 +245,149 @@ describe("activity endpoint", () => { const res = await app.request("/activity?days=7"); expect(res.status).toBe(401); }); + + test("GET /activity should correctly aggregate token counts", async () => { + // Clear existing logs and insert test data with known values + await db.delete(tables.log); + + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + await db.insert(tables.log).values([ + { + id: "token-test-1", + requestId: "token-test-1", + createdAt: today, + updatedAt: today, + organizationId: "test-org-id", + projectId: "test-project-id", + apiKeyId: "test-api-key-id", + duration: 1000, + requestedModel: "gpt-4", + requestedProvider: "openai", + usedModel: "gpt-4", + usedProvider: "openai", + responseSize: 1000, + promptTokens: "100", + completionTokens: "200", + totalTokens: "300", + cost: 0.1, + inputCost: 0.05, + outputCost: 0.05, + requestCost: 0, + messages: JSON.stringify([{ role: "user", content: "Test" }]), + mode: "api-keys", + usedMode: "api-keys", + }, + { + id: "token-test-2", + requestId: "token-test-2", + createdAt: today, + updatedAt: today, + organizationId: "test-org-id", + projectId: "test-project-id", + apiKeyId: "test-api-key-id", + duration: 1500, + requestedModel: "gpt-4", + requestedProvider: "openai", + usedModel: "gpt-4", + usedProvider: "openai", + responseSize: 1500, + promptTokens: "150", + completionTokens: "250", + totalTokens: "400", + cost: 0.15, + inputCost: 0.07, + outputCost: 0.08, + requestCost: 0, + messages: JSON.stringify([{ role: "user", content: "Test2" }]), + mode: "api-keys", + usedMode: "api-keys", + }, + { + id: "token-test-3", + requestId: "token-test-3", + createdAt: yesterday, + updatedAt: yesterday, + organizationId: "test-org-id", + projectId: "test-project-id", + apiKeyId: "test-api-key-id", + duration: 2000, + requestedModel: "claude-3-sonnet", + requestedProvider: "anthropic", + usedModel: "claude-3-sonnet", + usedProvider: "anthropic", + responseSize: 2000, + promptTokens: "300", + completionTokens: "500", + totalTokens: "800", + cost: 0.25, + inputCost: 0.1, + outputCost: 0.15, + requestCost: 0, + messages: JSON.stringify([{ role: "user", content: "Test3" }]), + mode: "api-keys", + usedMode: "api-keys", + }, + ]); + + const res = await app.request("/activity?days=7", { + headers: { + Cookie: token, + }, + }); + + expect(res.status).toBe(200); + const data = await res.json(); + + // Verify the response structure + expect(Array.isArray(data.activity)).toBe(true); + expect(data.activity.length).toBeGreaterThan(0); + + // Calculate totals from the response + const totalRequests = data.activity.reduce( + (sum: number, day: any) => sum + day.requestCount, + 0, + ); + const totalTokens = data.activity.reduce( + (sum: number, day: any) => sum + day.totalTokens, + 0, + ); + const totalInputTokens = data.activity.reduce( + (sum: number, day: any) => sum + day.inputTokens, + 0, + ); + const totalOutputTokens = data.activity.reduce( + (sum: number, day: any) => sum + day.outputTokens, + 0, + ); + const totalCost = data.activity.reduce( + (sum: number, day: any) => sum + day.cost, + 0, + ); + + // Verify correct aggregation + expect(totalRequests).toBe(3); + expect(totalTokens).toBe(1500); // 300 + 400 + 800 + expect(totalInputTokens).toBe(550); // 100 + 150 + 300 + expect(totalOutputTokens).toBe(950); // 200 + 250 + 500 + expect(totalCost).toBeCloseTo(0.5, 2); // 0.10 + 0.15 + 0.25 + + // Verify individual days + const todayData = data.activity.find((day: any) => day.requestCount === 2); + const yesterdayData = data.activity.find( + (day: any) => day.requestCount === 1, + ); + + expect(todayData).toBeDefined(); + expect(todayData.totalTokens).toBe(700); // 300 + 400 + expect(todayData.inputTokens).toBe(250); // 100 + 150 + expect(todayData.outputTokens).toBe(450); // 200 + 250 + + expect(yesterdayData).toBeDefined(); + expect(yesterdayData.totalTokens).toBe(800); + expect(yesterdayData.inputTokens).toBe(300); + expect(yesterdayData.outputTokens).toBe(500); + }); }); diff --git a/apps/api/src/routes/activity.ts b/apps/api/src/routes/activity.ts index c39fb5355a..713a33b29f 100644 --- a/apps/api/src/routes/activity.ts +++ b/apps/api/src/routes/activity.ts @@ -2,7 +2,7 @@ import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; import { HTTPException } from "hono/http-exception"; import { z } from "zod"; -import { db } from "@llmgateway/db"; +import { db, sql, tables, inArray, and, gte, lte } from "@llmgateway/db"; import type { ServerTypes } from "@/vars.js"; @@ -127,115 +127,142 @@ activity.openapi(getActivity, async (c) => { }); } - // Query logs for all projects in range - const rawLogs = await db.query.log.findMany({ - where: { - projectId: { in: projectIds }, - createdAt: { - gte: startDate, - lte: endDate, - }, - }, - }); + // Query daily aggregated data using database-level aggregation + const dailyAggregates = await db + .select({ + date: sql`DATE(${tables.log.createdAt})`.as("date"), + requestCount: sql`COUNT(*)`.as("requestCount"), + inputTokens: + sql`COALESCE(SUM(CAST(${tables.log.promptTokens} AS NUMERIC)), 0)`.as( + "inputTokens", + ), + outputTokens: + sql`COALESCE(SUM(CAST(${tables.log.completionTokens} AS NUMERIC)), 0)`.as( + "outputTokens", + ), + totalTokens: + sql`COALESCE(SUM(CAST(${tables.log.totalTokens} AS NUMERIC)), 0)`.as( + "totalTokens", + ), + cost: sql`COALESCE(SUM(${tables.log.cost}), 0)`.as("cost"), + inputCost: sql`COALESCE(SUM(${tables.log.inputCost}), 0)`.as( + "inputCost", + ), + outputCost: sql`COALESCE(SUM(${tables.log.outputCost}), 0)`.as( + "outputCost", + ), + requestCost: sql`COALESCE(SUM(${tables.log.requestCost}), 0)`.as( + "requestCost", + ), + errorCount: + sql`SUM(CASE WHEN ${tables.log.hasError} = true THEN 1 ELSE 0 END)`.as( + "errorCount", + ), + cacheCount: + sql`SUM(CASE WHEN ${tables.log.cached} = true THEN 1 ELSE 0 END)`.as( + "cacheCount", + ), + }) + .from(tables.log) + .where( + and( + inArray(tables.log.projectId, projectIds), + gte(tables.log.createdAt, startDate), + lte(tables.log.createdAt, endDate), + ), + ) + .groupBy(sql`DATE(${tables.log.createdAt})`) + .orderBy(sql`DATE(${tables.log.createdAt}) ASC`); + + // Query model breakdown data using database-level aggregation + const modelBreakdowns = await db + .select({ + date: sql`DATE(${tables.log.createdAt})`.as("date"), + usedModel: tables.log.usedModel, + usedProvider: tables.log.usedProvider, + requestCount: sql`COUNT(*)`.as("requestCount"), + inputTokens: + sql`COALESCE(SUM(CAST(${tables.log.promptTokens} AS NUMERIC)), 0)`.as( + "inputTokens", + ), + outputTokens: + sql`COALESCE(SUM(CAST(${tables.log.completionTokens} AS NUMERIC)), 0)`.as( + "outputTokens", + ), + totalTokens: + sql`COALESCE(SUM(CAST(${tables.log.totalTokens} AS NUMERIC)), 0)`.as( + "totalTokens", + ), + cost: sql`COALESCE(SUM(${tables.log.cost}), 0)`.as("cost"), + }) + .from(tables.log) + .where( + and( + inArray(tables.log.projectId, projectIds), + gte(tables.log.createdAt, startDate), + lte(tables.log.createdAt, endDate), + ), + ) + .groupBy( + sql`DATE(${tables.log.createdAt}), ${tables.log.usedModel}, ${tables.log.usedProvider}`, + ) + .orderBy( + sql`DATE(${tables.log.createdAt}) ASC, ${tables.log.usedModel} ASC`, + ); - // Process the raw logs to create the activity response - const activityMap = new Map(); - // Map to track model breakdown aggregation per day: dateStr -> modelKey -> aggregated data - const modelBreakdownMap = new Map< + // Create a map to organize model breakdowns by date + const modelBreakdownByDate = new Map< string, - Map + z.infer[] >(); - - for (const log of rawLogs) { - const promptTokens = Number(log.promptTokens || 0); - const completionTokens = Number(log.completionTokens || 0); - const totalTokens = Number(log.totalTokens || 0); - const requestCount = 1; - const totalCost = Number(log.cost || 0); - const inputCost = Number(log.inputCost || 0); - const outputCost = Number(log.outputCost || 0); - const requestCost = Number(log.requestCost || 0); - - const dateStr = log.createdAt.toISOString().split("T")[0]; - - // Create or update the day entry - if (!activityMap.has(dateStr)) { - activityMap.set(dateStr, { - date: dateStr, - requestCount: 0, - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - cost: 0, - inputCost: 0, - outputCost: 0, - requestCost: 0, - errorCount: 0, - errorRate: 0, - cacheCount: 0, - cacheRate: 0, - modelBreakdown: [], - }); - modelBreakdownMap.set(dateStr, new Map()); + for (const breakdown of modelBreakdowns) { + if (!modelBreakdownByDate.has(breakdown.date)) { + modelBreakdownByDate.set(breakdown.date, []); } - - const dayData = activityMap.get(dateStr)!; - const dayModelMap = modelBreakdownMap.get(dateStr)!; - - // Update the day totals - dayData.requestCount += requestCount; - dayData.inputTokens += promptTokens; - dayData.outputTokens += completionTokens; - dayData.totalTokens += totalTokens; - dayData.cost += totalCost; - dayData.inputCost += inputCost; - dayData.outputCost += outputCost; - dayData.requestCost += requestCost; - dayData.errorCount += log.hasError ? 1 : 0; - dayData.cacheCount += log.cached ? 1 : 0; - dayData.errorRate = - dayData.requestCount > 0 - ? (dayData.errorCount / dayData.requestCount) * 100 - : 0; - dayData.cacheRate = - dayData.requestCount > 0 - ? (dayData.cacheCount / dayData.requestCount) * 100 - : 0; - - // Aggregate model breakdown data - const model = log.usedModel || "unknown"; - const provider = log.usedProvider || "unknown"; - const modelKey = `${model}:${provider}`; - - if (!dayModelMap.has(modelKey)) { - dayModelMap.set(modelKey, { - id: model, - provider, - requestCount: 0, - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - cost: 0, - }); - } - - const modelData = dayModelMap.get(modelKey)!; - modelData.requestCount += requestCount; - modelData.inputTokens += promptTokens; - modelData.outputTokens += completionTokens; - modelData.totalTokens += totalTokens; - modelData.cost += totalCost; + modelBreakdownByDate.get(breakdown.date)!.push({ + id: breakdown.usedModel || "unknown", + provider: breakdown.usedProvider || "unknown", + requestCount: Number(breakdown.requestCount), + inputTokens: Number(breakdown.inputTokens), + outputTokens: Number(breakdown.outputTokens), + totalTokens: Number(breakdown.totalTokens), + cost: Number(breakdown.cost), + }); } - // Convert aggregated model data to arrays - for (const [dateStr, dayModelMap] of modelBreakdownMap) { - const dayData = activityMap.get(dateStr)!; - dayData.modelBreakdown = Array.from(dayModelMap.values()); - } + // Process daily aggregates and add calculated fields + const activityData = dailyAggregates.map((day) => { + // Convert database strings to numbers + const requestCount = Number(day.requestCount); + const inputTokens = Number(day.inputTokens); + const outputTokens = Number(day.outputTokens); + const totalTokens = Number(day.totalTokens); + const cost = Number(day.cost); + const inputCost = Number(day.inputCost); + const outputCost = Number(day.outputCost); + const requestCost = Number(day.requestCost); + const errorCount = Number(day.errorCount); + const cacheCount = Number(day.cacheCount); + + const errorRate = requestCount > 0 ? (errorCount / requestCount) * 100 : 0; + const cacheRate = requestCount > 0 ? (cacheCount / requestCount) * 100 : 0; - // Convert the map to an array and sort by date - const activityData = Array.from(activityMap.values()).sort((a, b) => { - return new Date(a.date).getTime() - new Date(b.date).getTime(); + return { + date: day.date, + requestCount, + inputTokens, + outputTokens, + totalTokens, + cost, + inputCost, + outputCost, + requestCost, + errorCount, + errorRate, + cacheCount, + cacheRate, + modelBreakdown: modelBreakdownByDate.get(day.date) || [], + }; }); return c.json({