diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index ae2c2e9d96..f26ef58a9a 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -347,10 +347,10 @@ export async function GET(request: Request) { } } - // Compute the raw-data cutoff: rows older than this may have been rolled up to - // daily_usage_summary and deleted from usage_history. + // Raw-data cutoff: must match cleanupUsageHistory's rollup/delete boundary — + // retention.usageHistory (src/lib/db/cleanup.ts), NOT aggregation.rawDataRetentionDays. const dbSettings = getUserDatabaseSettings(); - const rawRetentionDays = dbSettings.aggregation?.rawDataRetentionDays ?? 30; + const rawRetentionDays = dbSettings.retention?.usageHistory ?? 30; const rawCutoff = new Date(); rawCutoff.setDate(rawCutoff.getDate() - rawRetentionDays); const rawCutoffIso = rawCutoff.toISOString(); diff --git a/src/lib/db/usageAnalytics.ts b/src/lib/db/usageAnalytics.ts index 3e66795425..932001b771 100644 --- a/src/lib/db/usageAnalytics.ts +++ b/src/lib/db/usageAnalytics.ts @@ -49,14 +49,14 @@ export function getUsageSummary(unifiedSource: string, params: AnalyticsParams): .prepare( ` SELECT - COUNT(*) as totalRequests, + COALESCE(SUM(requests), 0) as totalRequests, COALESCE(SUM(tokens_input), 0) as promptTokens, COALESCE(SUM(tokens_output), 0) as completionTokens, COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens, COUNT(DISTINCT model) as uniqueModels, COUNT(DISTINCT COALESCE(NULLIF(account_key, ''), NULLIF(connection_id, ''))) as uniqueAccounts, COUNT(DISTINCT COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''))) as uniqueApiKeys, - COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests, + COALESCE(SUM(CASE WHEN success = 1 THEN requests ELSE 0 END), 0) as successfulRequests, COALESCE(AVG(latency_ms), 0) as avgLatencyMs, COALESCE(MIN(timestamp), '') as firstRequest, COALESCE(MAX(timestamp), '') as lastRequest @@ -101,7 +101,7 @@ export function getDailyUsage(unifiedSource: string, params: AnalyticsParams): D ` SELECT DATE(timestamp) as date, - COUNT(*) as requests, + COALESCE(SUM(requests), 0) as requests, COALESCE(SUM(tokens_input), 0) as promptTokens, COALESCE(SUM(tokens_output), 0) as completionTokens, COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens @@ -215,7 +215,7 @@ export function getModelUsageRows(unifiedSource: string, params: AnalyticsParams LOWER(model) as model, LOWER(provider) as provider, COALESCE(NULLIF(service_tier, ''), 'standard') as serviceTier, - COUNT(*) as requests, + COALESCE(SUM(requests), 0) as requests, COALESCE(SUM(tokens_input), 0) as promptTokens, COALESCE(SUM(tokens_output), 0) as completionTokens, COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens, @@ -223,7 +223,7 @@ export function getModelUsageRows(unifiedSource: string, params: AnalyticsParams COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens, COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens, COALESCE(AVG(latency_ms), 0) as avgLatencyMs, - COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests, + COALESCE(SUM(CASE WHEN success = 1 THEN requests ELSE 0 END), 0) as successfulRequests, COALESCE(MAX(timestamp), '') as lastUsed FROM ${unifiedSource} AS _u GROUP BY LOWER(model), LOWER(provider), serviceTier @@ -298,12 +298,12 @@ export function getProviderUsageRows( ` SELECT LOWER(provider) as provider, - COUNT(*) as requests, + COALESCE(SUM(requests), 0) as requests, COALESCE(SUM(tokens_input), 0) as promptTokens, COALESCE(SUM(tokens_output), 0) as completionTokens, COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens, COALESCE(AVG(latency_ms), 0) as avgLatencyMs, - COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests + COALESCE(SUM(CASE WHEN success = 1 THEN requests ELSE 0 END), 0) as successfulRequests FROM ${unifiedSource} AS _u GROUP BY LOWER(provider) ORDER BY requests DESC @@ -553,7 +553,7 @@ export function getServiceTierUsageRows( LOWER(provider) as provider, LOWER(model) as model, COALESCE(NULLIF(service_tier, ''), 'standard') as serviceTier, - COUNT(*) as requests, + COALESCE(SUM(requests), 0) as requests, COALESCE(SUM(tokens_input), 0) as promptTokens, COALESCE(SUM(tokens_output), 0) as completionTokens, COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens, @@ -633,7 +633,7 @@ export function getWeeklyPatternRows( SELECT DATE(timestamp) as date, strftime('%w', timestamp) as dayOfWeek, - COUNT(*) as requests, + COALESCE(SUM(requests), 0) as requests, COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens FROM ${unifiedSource} AS _u GROUP BY DATE(timestamp), strftime('%w', timestamp) diff --git a/src/lib/db/usageAnalytics/sources.ts b/src/lib/db/usageAnalytics/sources.ts index d3a16da688..23ef171e9f 100644 --- a/src/lib/db/usageAnalytics/sources.ts +++ b/src/lib/db/usageAnalytics/sources.ts @@ -88,7 +88,6 @@ export function buildUnifiedSource(opts: BuildUnifiedSourceOptions): UnifiedSour unifiedParams.rawCutoffDate = rawCutoffDate; } const aggWhere = aggConditions.length > 0 ? `WHERE ${aggConditions.join(" AND ")}` : ""; - const unifiedSource = needsAggregated ? `( SELECT @@ -106,7 +105,8 @@ export function buildUnifiedSource(opts: BuildUnifiedSourceOptions): UnifiedSour connection_id, account_key, api_key_id, - api_key_name + api_key_name, + 1 as requests FROM usage_history ${rawWhere} UNION ALL @@ -121,11 +121,12 @@ export function buildUnifiedSource(opts: BuildUnifiedSourceOptions): UnifiedSour 0 as tokens_reasoning, 'standard' as service_tier, 1 as success, - 0 as latency_ms, + NULL as latency_ms, NULL as connection_id, NULL as account_key, NULL as api_key_id, - NULL as api_key_name + NULL as api_key_name, + total_requests as requests FROM daily_usage_summary ${aggWhere} )` @@ -134,10 +135,11 @@ export function buildUnifiedSource(opts: BuildUnifiedSourceOptions): UnifiedSour tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning, service_tier, success, latency_ms, - connection_id, account_key, api_key_id, api_key_name + connection_id, account_key, api_key_id, api_key_name, + 1 as requests FROM usage_history ${rawWhere} - )`; + )`; return { unifiedSource, unifiedParams }; } diff --git a/src/lib/usage/aggregateHistory.ts b/src/lib/usage/aggregateHistory.ts index b541540016..8e543d5ef8 100644 --- a/src/lib/usage/aggregateHistory.ts +++ b/src/lib/usage/aggregateHistory.ts @@ -196,7 +196,13 @@ export async function rollupUsageHistoryBeforeDate(beforeDate: string): Promise< * @returns ISO date string (YYYY-MM-DD) */ export async function getRawDataCutoffDate(): Promise { - const rawDataRetentionDays = getUserDatabaseSettings().aggregation.rawDataRetentionDays; + // The raw-data cutoff MUST match the actual rollup/delete boundary used by + // cleanupUsageHistory (src/lib/db/cleanup.ts), which is driven by + // retention.usageHistory — NOT aggregation.rawDataRetentionDays. + // Using rawDataRetentionDays (default 7 per migration 046) creates a gap: + // analytics floors raw data at day-7 while cleanup doesn't roll up until + // day-30, so the window [day-30, day-7) is excluded from BOTH UNION legs. + const rawDataRetentionDays = getUserDatabaseSettings().retention.usageHistory; const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - rawDataRetentionDays); diff --git a/tests/unit/db-usageanalytics-split.test.ts b/tests/unit/db-usageanalytics-split.test.ts index 70271c71c9..095055fd3b 100644 --- a/tests/unit/db-usageanalytics-split.test.ts +++ b/tests/unit/db-usageanalytics-split.test.ts @@ -42,6 +42,10 @@ describe("usageAnalytics/sources — buildUnifiedSource", () => { ); assert.equal(unifiedParams.rawCutoff, "2024-06-15"); assert.equal(unifiedParams.rawCutoffDate, "2024-06-15"); + // Regression: unified source must surface a `requests` column in both legs + // so COUNT(*)→SUM(requests) counts actual requests, not row count. + assert.ok(unifiedSource.includes("1 as requests"), "raw leg must surface requests column"); + assert.ok(unifiedSource.includes("total_requests as requests"), "aggregated leg must surface total_requests as requests"); }); it("drops the aggregated leg when an api-key filter is active (raw-only)", () => { @@ -57,10 +61,11 @@ describe("usageAnalytics/sources — buildUnifiedSource", () => { !unifiedSource.includes("daily_usage_summary"), "aggregated leg must be absent once an api-key filter scopes the query to raw rows" ); - // raw leg floors at @since (not @rawCutoff) and carries the api-key params + // Regression: raw-only fallback must also surface the requests column. + assert.ok(unifiedSource.includes("1 as requests"), "raw-only fallback must surface requests column"); + assert.equal(unifiedParams.rawCutoff, undefined); assert.equal(unifiedParams.since, "2024-06-01T00:00:00.000Z"); assert.equal(unifiedParams.apiKey0, "k1"); - assert.equal(unifiedParams.rawCutoff, undefined); }); it("buildPresetUnifiedSource returns the unifiedSource/unifiedParams shape", () => { @@ -119,6 +124,50 @@ describe("usageAnalytics.ts public API surface", () => { }); }); +// ── 4. Regression: COUNT(*) → SUM(requests) in all query functions ────────── +// Verifies the fix without needing a DB driver — the function source must +// contain SUM(requests), not COUNT(*), so aggregated rows with total_requests>1 +// are counted correctly instead of as 1-per-row. + +describe("usageAnalytics queries use SUM(requests) not COUNT(*)", () => { + const queriesUsingCount = [ + "getUsageSummary", + "getDailyUsage", + "getModelUsageRows", + "getProviderUsageRows", + "getServiceTierUsageRows", + ]; + + for (const fn of queriesUsingCount) { + it(`${fn} uses SUM(requests) not COUNT(*)`, () => { + const src = host[fn].toString(); + assert.ok(src.includes("SUM(requests)"), `${fn} must use SUM(requests) for correct aggregated row counting`); + assert.ok(!src.match(/COUNT\(\*\)/), `${fn} must not use COUNT(*) which undercounts aggregated rows`); + }); + } + + it("getWeeklyPatternRows inner subquery uses SUM(requests)", () => { + const src = host.getWeeklyPatternRows.toString(); + assert.ok(src.includes("SUM(requests)"), "getWeeklyPatternRows inner subquery must use SUM(requests)"); + }); + + it("getUsageSummary uses SUM(CASE WHEN success=1 THEN requests ELSE 0 END) for successfulRequests", () => { + const src = host.getUsageSummary.toString(); + assert.ok( + src.includes("SUM(CASE WHEN success = 1 THEN requests ELSE 0 END)"), + "successfulRequests must weight by requests, not count 1-per-row" + ); + }); + + it("getModelUsageRows uses SUM(CASE WHEN success=1 THEN requests ELSE 0 END) for successfulRequests", () => { + const src = host.getModelUsageRows.toString(); + assert.ok( + src.includes("SUM(CASE WHEN success = 1 THEN requests ELSE 0 END)"), + "successfulRequests must weight by requests, not count 1-per-row" + ); + }); +}); + // ── 3. sources leaf exports the builders directly ──────────────────────────── describe("sources.ts exports the builders directly", () => { diff --git a/tests/unit/usage-analytics-route.test.ts b/tests/unit/usage-analytics-route.test.ts index 016947e483..8e57e7e88b 100644 --- a/tests/unit/usage-analytics-route.test.ts +++ b/tests/unit/usage-analytics-route.test.ts @@ -333,3 +333,260 @@ test("GET /api/usage/analytics includes byAccount array with cost data", async ( ); }); +test("GET /api/usage/analytics includes cost by API key", async () => { + await seedAnalyticsData(); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.byApiKey)); + assert.equal(body.byApiKey.length, 1); + assert.equal(body.byApiKey[0].apiKeyId, "test-key"); + assert.equal(body.byApiKey[0].apiKeyName, "Primary Key"); + assertClose(body.byApiKey[0].cost, body.summary.totalCost); +}); + +test("GET /api/usage/analytics does not double-count raw and aggregated rows", async () => { + const db = core.getDbInstance(); + const today = new Date(); + const todayStr = today.toISOString().split("T")[0]; + // The raw/aggregated split boundary is retention.usageHistory (365 days by + // default — see cleanupUsageHistory in src/lib/db/cleanup.ts, which rolls up + // and deletes usage_history rows using that exact setting). Read it live + // instead of hardcoding 30 days so this fixture stays valid regardless of + // the configured retention window. + const { getUserDatabaseSettings } = await import("../../src/lib/db/databaseSettings.ts"); + const rawRetentionDays = getUserDatabaseSettings().retention.usageHistory; + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - rawRetentionDays); + const olderDate = new Date(cutoffDate); + olderDate.setDate(olderDate.getDate() - 1); + const olderDateStr = olderDate.toISOString().split("T")[0]; + + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "raw-current", 100, 50, 1, 200, today.toISOString()); + + const insertSummary = db.prepare( + `INSERT INTO daily_usage_summary (provider, model, date, total_requests, total_input_tokens, total_output_tokens, total_cost) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + insertSummary.run("openai", "gpt-4o", todayStr, 99, 9900, 9900, 0); + insertSummary.run("openai", "gpt-4o", olderDateStr, 1, 25, 10, 0); + + const response = await analyticsRoute.GET( + makeRequest("http://localhost/api/usage/analytics?range=all") + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.summary.totalRequests, 2); + assert.equal(body.summary.totalTokens, 185); +}); + +test("GET /api/usage/analytics omits global aggregates when filtering by API key", async () => { + const apiKey = await apiKeysDb.createApiKey("Scoped Key", "machine1234567890"); + const db = core.getDbInstance(); + + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + "openai", + "gpt-4o", + "scoped-conn", + apiKey.id, + "Scoped Key", + 100, + 50, + 1, + 200, + new Date().toISOString() + ); + + db.prepare( + `INSERT INTO daily_usage_summary (provider, model, date, total_requests, total_input_tokens, total_output_tokens, total_cost) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "2024-01-01", 99, 9900, 9900, 0); + + const response = await analyticsRoute.GET( + makeRequest(`http://localhost/api/usage/analytics?range=all&apiKeyIds=${apiKey.id}`) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.summary.totalRequests, 1); + assert.equal(body.summary.totalTokens, 150); + assert.equal(body.byApiKey.length, 1); + assert.equal(body.byApiKey[0].apiKeyId, apiKey.id); +}); + +test("GET /api/usage/analytics groups renamed API key usage by stable ID", async () => { + const apiKey = await apiKeysDb.createApiKey("Averyanov", "machine1234567890"); + await apiKeysDb.updateApiKeyPermissions(apiKey.id, { name: "Alexander Averyanov" }); + + const db = core.getDbInstance(); + const now = Date.now(); + const insertUsage = db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + insertUsage.run( + "openai", + "gpt-4o", + "test-conn", + apiKey.id, + "Averyanov", + 100, + 50, + 1, + 200, + new Date(now - 60_000).toISOString() + ); + insertUsage.run( + "openai", + "gpt-4o", + "test-conn", + apiKey.id, + "Desktop", + 200, + 100, + 1, + 250, + new Date(now).toISOString() + ); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.summary.uniqueApiKeys, 1); + assert.equal(body.byApiKey.length, 1); + assert.equal(body.byApiKey[0].apiKeyId, apiKey.id); + assert.equal(body.byApiKey[0].apiKeyName, "Alexander Averyanov"); + assert.deepEqual(body.byApiKey[0].historicalApiKeyNames.sort(), ["Averyanov", "Desktop"]); + assert.equal(body.byApiKey[0].requests, 2); + assert.equal(body.byApiKey[0].promptTokens, 300); + assert.equal(body.byApiKey[0].completionTokens, 150); + + const filteredResponse = await analyticsRoute.GET( + makeRequest(`http://localhost/api/usage/analytics?apiKeyIds=${apiKey.id}`) + ); + const filteredBody = await filteredResponse.json(); + + assert.equal(filteredResponse.status, 200); + assert.equal(filteredBody.summary.totalRequests, 2); + assert.equal(filteredBody.byApiKey.length, 1); + assert.equal(filteredBody.byApiKey[0].apiKeyId, apiKey.id); +}); + +test("GET /api/usage/analytics does not persist guessed API key attribution", async () => { + await localDb.updatePricing({ + openai: { "gpt-4o": { input: 2.5, output: 10 } }, + }); + await apiKeysDb.createApiKey("Unrestricted Key", "machine1234567890"); + + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "legacy-conn", null, null, 100, 50, 1, 200, new Date().toISOString()); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.byApiKey.length, 0); + + const row = db + .prepare("SELECT api_key_id, api_key_name FROM usage_history WHERE connection_id = ?") + .get("legacy-conn") as { api_key_id: string | null; api_key_name: string | null }; + assert.equal(row.api_key_id, null); + assert.equal(row.api_key_name, null); +}); + +test("GET /api/usage/analytics returns weeklyPattern for the costs dashboard", async () => { + await seedAnalyticsData(); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.weeklyPattern)); + assert.equal(body.weeklyPattern.length, 7); + assert.deepEqual( + body.weeklyPattern.map((row) => row.day), + ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + ); + assert.ok(body.weeklyPattern.some((row) => row.totalTokens > 0 && row.avgTokens > 0)); +}); + +test("GET /api/usage/analytics includes activityMap for heatmap", async () => { + await seedAnalyticsData(); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.ok(typeof body.activityMap === "object"); + assert.ok(Object.keys(body.activityMap).length > 0); +}); + +test("GET /api/usage/analytics returns 500 on database errors", async () => { + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.ok(body.summary.totalRequests === 0); +}); + +test("GET /api/usage/analytics does not throw Unknown named parameter on short range (needsAggregated=false)", async () => { + // Regression: shared params object leaked agg-only bindings (@sinceDate, @rawCutoffDate) + // into queries that don't reference them, causing better-sqlite3 to throw. + // A short range (1h) triggers needsAggregated=false because the entire window + // falls within the raw-data-only period. + const db = core.getDbInstance(); + const now = new Date(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "test-conn", 100, 50, 1, 200, now.toISOString()); + + const response = await analyticsRoute.GET( + makeRequest("http://localhost/api/usage/analytics?range=1h") + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.summary.totalRequests, 1); +}); + +test("GET /api/usage/analytics does not throw Unknown named parameter with apiKey filter on long range", async () => { + // Regression: Object.assign(presetParams, params) leaked all main-query bindings + // into preset queries that only reference preset-prefixed placeholders. + const apiKey = await apiKeysDb.createApiKey("Preset Key", "machine-preset1234"); + const db = core.getDbInstance(); + const now = new Date(); + + // Seed data old enough to trigger aggregated + preset path + for (let i = 0; i < 5; i++) { + const ts = new Date(now.getTime() - (35 + i) * 24 * 60 * 60 * 1000).toISOString(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "test-conn", apiKey.id, apiKey.name, 100, 50, 1, 200, ts); + } + + const response = await analyticsRoute.GET( + makeRequest(`http://localhost/api/usage/analytics?range=60d&apiKeyId=${apiKey.id}`) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + // Core regression check: no "Unknown named parameter" error. + // The exact count depends on raw-vs-aggregated boundary; we only need to + // confirm the endpoint returns 200 without throwing. + assert.ok(typeof body.summary.totalRequests === "number"); +}); diff --git a/tests/unit/usage-analytics.test.ts b/tests/unit/usage-analytics.test.ts index ff7e486ccc..fa6c969649 100644 --- a/tests/unit/usage-analytics.test.ts +++ b/tests/unit/usage-analytics.test.ts @@ -522,3 +522,44 @@ test("clearPendingRequests allows fresh tracking after clearing", () => { assert.equal(pending.byModel["model-d (provider-w)"], 1); assert.ok(pending.details["conn-4"]); }); + +test("getUsageSummary counts total_requests from daily_usage_summary, not 1-per-row (#usage-stats-fix)", async () => { + const db = core.getDbInstance(); + + // Insert a daily_usage_summary row with total_requests=50, 1000 input, 500 output. + // With the old COUNT(*) query this would count as 1 request; with SUM(requests) + // it must count as 50. + db.prepare(` + INSERT INTO daily_usage_summary (provider, model, date, total_requests, total_input_tokens, total_output_tokens, total_cost) + VALUES ('openai', 'gpt-4', '2024-01-10', 50, 1000, 500, 0.02) + `).run(); + + // Also insert one raw row so we can verify the UNION merges both legs. + db.prepare(` + INSERT INTO usage_history (timestamp, provider, model, tokens_input, tokens_output, success, latency_ms, service_tier) + VALUES ('2024-01-20T10:00:00.000Z', 'openai', 'gpt-4', 100, 50, 1, 200, 'standard') + `).run(); + + // Build a unified source with rawCutoffDate BETWEEN the two rows so both + // legs are exercised: aggregated leg gets the Jan 10 row, raw leg gets the Jan 20 row. + const { buildUnifiedSource } = await import("../../src/lib/db/usageAnalytics/sources.ts"); + const { getUsageSummary } = await import("../../src/lib/db/usageAnalytics.ts"); + const { unifiedSource, unifiedParams } = buildUnifiedSource({ + sinceIso: "2024-01-01T00:00:00.000Z", + untilIso: null, + rawCutoffDate: "2024-01-15", // raw leg starts at Jan 15; summary leg is before Jan 15 + apiKeyWhere: "", + apiKeyParams: {}, + }); + + const summary = getUsageSummary(unifiedSource, unifiedParams); + + // 50 from daily_usage_summary + 1 from raw usage_history = 51 + assert.equal(summary.totalRequests, 51, "totalRequests must be 50 (aggregated) + 1 (raw), not 1+1"); + // 1000 from daily_usage_summary + 100 from raw = 1100 + assert.equal(summary.promptTokens, 1100, "promptTokens must merge aggregated + raw token sums"); + // 500 from daily_usage_summary + 50 from raw = 550 + assert.equal(summary.completionTokens, 550, "completionTokens must merge aggregated + raw token sums"); + // All 51 requests are successful (aggregated leg hardcodes success=1, raw has success=1) + assert.equal(summary.successfulRequests, 51, "successfulRequests must count all rolled-up requests as successful"); +});