diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 06e27cc639..7161294aa6 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -184,13 +184,17 @@ export default function ApiManagerPageClient() { const stats: Record = {}; for (const key of apiKeys) { - // Match analytics entry by key name (reliable across both systems) - const analyticsMatch = byApiKey.find((entry: any) => entry.apiKeyName === key.name); + const analyticsMatch = byApiKey.find( + (entry: any) => + entry.apiKeyId === key.id || (!entry.apiKeyId && entry.apiKeyName === key.name) + ); // The call-logs endpoint returns entries sorted by timestamp DESC, // so the first match is the most recent one. const lastUsed = - (logs || []).find((log: any) => log.apiKeyName === key.name)?.timestamp || null; + (logs || []).find( + (log: any) => log.apiKeyId === key.id || (!log.apiKeyId && log.apiKeyName === key.name) + )?.timestamp || null; stats[key.id] = { totalRequests: analyticsMatch?.requests ?? 0, diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 1eeec256b8..088bbb4288 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { getApiKeys } from "@/lib/db/apiKeys"; import { getDbInstance } from "@/lib/db/core"; function getRangeStartIso(range: string): string | null { @@ -76,6 +77,16 @@ function uniqueValues(values: Array): string[] { return result; } +function makeApiKeyUsageGroup(apiKeyId: string, fallbackName: string): string { + return apiKeyId ? `id:${apiKeyId}` : `name:${fallbackName}`; +} + +function addApiKeyAlias(target: Set, value: unknown): void { + if (typeof value !== "string") return; + const trimmed = value.trim(); + if (trimmed) target.add(trimmed); +} + function stripCodexEffortSuffix(model: string): string { return model.replace(/-(?:xhigh|high|medium|low|none)$/i, ""); } @@ -244,6 +255,13 @@ export async function GET(request: Request) { const presetsParam = searchParams.get("presets"); const db = getDbInstance(); + const apiKeys = await getApiKeys(); + const currentApiKeyNames = new Map(); + for (const apiKey of apiKeys) { + if (typeof apiKey.id === "string" && typeof apiKey.name === "string") { + currentApiKeyNames.set(apiKey.id, apiKey.name); + } + } const conditions = []; const params: Record = {}; @@ -296,7 +314,7 @@ export async function GET(request: Request) { COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens, COUNT(DISTINCT model) as uniqueModels, COUNT(DISTINCT connection_id) as uniqueAccounts, - COUNT(DISTINCT api_key_id) as uniqueApiKeys, + 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(AVG(latency_ms), 0) as avgLatencyMs, COALESCE(MIN(timestamp), '') as firstRequest, @@ -489,8 +507,8 @@ export async function GET(request: Request) { .prepare( ` SELECT - api_key_id as apiKeyId, - COALESCE(NULLIF(api_key_name, ''), NULLIF(api_key_id, ''), 'Unknown API key') as apiKeyName, + NULLIF(api_key_id, '') as apiKeyId, + COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''), 'unknown') as apiKeyGroupKey, LOWER(provider) as provider, LOWER(model) as model, COUNT(*) as requests, @@ -502,11 +520,42 @@ export async function GET(request: Request) { COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens FROM usage_history ${apiKeyWhereClause} - GROUP BY api_key_id, api_key_name, LOWER(provider), LOWER(model) + GROUP BY COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''), 'unknown'), NULLIF(api_key_id, ''), LOWER(provider), LOWER(model) ` ) .all(params) as Array>; + const apiKeyMetadataRows = db + .prepare( + ` + SELECT + NULLIF(api_key_id, '') as apiKeyId, + NULLIF(api_key_name, '') as apiKeyName, + COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''), 'unknown') as apiKeyGroupKey, + MAX(timestamp) as lastUsed + FROM usage_history + ${apiKeyWhereClause} + GROUP BY NULLIF(api_key_id, ''), NULLIF(api_key_name, '') + ORDER BY lastUsed DESC + ` + ) + .all(params) as Array>; + + const apiKeyMetadata = new Map }>(); + for (const row of apiKeyMetadataRows) { + const apiKeyId = toStringValue(row.apiKeyId); + const apiKeyGroupKey = toStringValue(row.apiKeyGroupKey, "unknown"); + const groupKey = makeApiKeyUsageGroup(apiKeyId, apiKeyGroupKey); + const existing = apiKeyMetadata.get(groupKey) || { + latestName: "", + aliases: new Set(), + }; + const apiKeyName = toStringValue(row.apiKeyName); + if (!existing.latestName && apiKeyName) existing.latestName = apiKeyName; + addApiKeyAlias(existing.aliases, apiKeyName); + apiKeyMetadata.set(groupKey, existing); + } + const weeklyRows = db .prepare( ` @@ -742,6 +791,7 @@ export async function GET(request: Request) { apiKey: string; apiKeyId: string | null; apiKeyName: string; + historicalApiKeyNames: string[]; requests: number; promptTokens: number; completionTokens: number; @@ -751,12 +801,20 @@ export async function GET(request: Request) { >(); for (const row of apiKeyRows) { const apiKeyId = toStringValue(row.apiKeyId); - const apiKeyName = toStringValue(row.apiKeyName, apiKeyId || "Unknown API key"); - const key = `${apiKeyId || "unknown"}::${apiKeyName}`; + const apiKeyGroupKey = toStringValue(row.apiKeyGroupKey, "unknown"); + const key = makeApiKeyUsageGroup(apiKeyId, apiKeyGroupKey); + const metadata = apiKeyMetadata.get(key); + const apiKeyName = + (apiKeyId ? currentApiKeyNames.get(apiKeyId) : undefined) || + metadata?.latestName || + apiKeyId || + apiKeyGroupKey || + "Unknown API key"; const existing = apiKeyMap.get(key) || { apiKey: apiKeyId && apiKeyName !== apiKeyId ? `${apiKeyName} (${apiKeyId})` : apiKeyName, apiKeyId: apiKeyId || null, apiKeyName, + historicalApiKeyNames: Array.from(metadata?.aliases || []), requests: 0, promptTokens: 0, completionTokens: 0, diff --git a/src/lib/usage/usageStats.ts b/src/lib/usage/usageStats.ts index 1138ae605b..c88d2a1668 100644 --- a/src/lib/usage/usageStats.ts +++ b/src/lib/usage/usageStats.ts @@ -8,6 +8,7 @@ */ import { getDbInstance } from "../db/core"; +import { getApiKeys } from "../db/apiKeys"; import { getPendingRequests } from "./usageHistory"; import { getAccountDisplayName } from "@/lib/display/names"; import { calculateCost } from "./costCalculator"; @@ -29,6 +30,7 @@ type UsageBreakdown = UsageBucket & { accountName?: string; apiKeyId?: string | null; apiKeyName?: string; + historicalApiKeyNames?: string[]; }; type ActiveRequest = { @@ -55,6 +57,10 @@ function toStringOrEmpty(value: unknown): string { return typeof value === "string" ? value : ""; } +function getApiKeyStatsKey(apiKeyId: string | null, apiKeyName: string | null): string { + return apiKeyId ? `id:${apiKeyId}` : `name:${apiKeyName || "unknown"}`; +} + /** * Get aggregated usage stats. * Uses UNION of recent raw data and older aggregated data when aggregation is enabled. @@ -126,6 +132,18 @@ export async function getUsageStats() { toStringOrEmpty(conn.name) || toStringOrEmpty(conn.email) || connectionId; } + const currentApiKeyNames = new Map(); + try { + const apiKeys = await getApiKeys(); + for (const apiKey of apiKeys) { + if (typeof apiKey.id === "string" && typeof apiKey.name === "string") { + currentApiKeyNames.set(apiKey.id, apiKey.name); + } + } + } catch { + // Stats can still be computed from usage_history when api_keys is unavailable. + } + const pendingRequests = getPendingRequests(); const stats: { @@ -286,26 +304,35 @@ export async function getUsageStats() { // By API key if (apiKeyId || apiKeyName) { - const keyName = apiKeyName || apiKeyId || "unknown"; - const keyId = apiKeyId || null; - const apiKey = keyId ? `${keyName} (${keyId})` : keyName; - if (!stats.byApiKey[apiKey]) { - stats.byApiKey[apiKey] = { + const key = getApiKeyStatsKey(apiKeyId, apiKeyName); + const displayName = + (apiKeyId ? currentApiKeyNames.get(apiKeyId) : undefined) || + apiKeyName || + apiKeyId || + "unknown"; + if (!stats.byApiKey[key]) { + stats.byApiKey[key] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, - apiKeyId: keyId, - apiKeyName: keyName, + apiKeyId, + apiKeyName: displayName, + historicalApiKeyNames: [], lastUsed: timestamp, }; } - stats.byApiKey[apiKey].requests++; - stats.byApiKey[apiKey].promptTokens += promptTokens; - stats.byApiKey[apiKey].completionTokens += completionTokens; - stats.byApiKey[apiKey].cost += entryCost; - if (new Date(timestamp) > new Date(stats.byApiKey[apiKey].lastUsed || timestamp)) { - stats.byApiKey[apiKey].lastUsed = timestamp; + const apiKeyStats = stats.byApiKey[key]; + if (apiKeyName && !apiKeyStats.historicalApiKeyNames?.includes(apiKeyName)) { + apiKeyStats.historicalApiKeyNames?.push(apiKeyName); + } + apiKeyStats.apiKeyName = displayName; + apiKeyStats.requests++; + apiKeyStats.promptTokens += promptTokens; + apiKeyStats.completionTokens += completionTokens; + apiKeyStats.cost += entryCost; + if (new Date(timestamp) > new Date(apiKeyStats.lastUsed || timestamp)) { + apiKeyStats.lastUsed = timestamp; } } } diff --git a/src/lib/usageAnalytics.ts b/src/lib/usageAnalytics.ts index 77fd0fc159..55b1718ac5 100644 --- a/src/lib/usageAnalytics.ts +++ b/src/lib/usageAnalytics.ts @@ -76,6 +76,13 @@ function shortModelName(model: string) { return parts[parts.length - 1] || model; } +function getApiKeyAnalyticsKey( + apiKeyId: string | null | undefined, + apiKeyName: string | null | undefined +) { + return apiKeyId ? `id:${apiKeyId}` : `name:${apiKeyName || "unknown"}`; +} + /** * Compute all analytics data from usage history * @param {Array} history - Array of usage entries @@ -184,7 +191,7 @@ export async function computeAnalytics( if (entry.model) summary.uniqueModels.add(modelShort); if (entry.connectionId) summary.uniqueAccounts.add(entry.connectionId); if (entry.apiKeyId || entry.apiKeyName) { - summary.uniqueApiKeys.add(entry.apiKeyId || entry.apiKeyName); + summary.uniqueApiKeys.add(getApiKeyAnalyticsKey(entry.apiKeyId, entry.apiKeyName)); } // Daily trend @@ -260,12 +267,14 @@ export async function computeAnalytics( // By API key if (entry.apiKeyId || entry.apiKeyName) { const keyName = entry.apiKeyName || entry.apiKeyId || "unknown"; + const key = getApiKeyAnalyticsKey(entry.apiKeyId, entry.apiKeyName); const keyLabel = entry.apiKeyId ? `${keyName} (${entry.apiKeyId})` : keyName; - if (!byApiKeyMap[keyLabel]) { - byApiKeyMap[keyLabel] = { + if (!byApiKeyMap[key]) { + byApiKeyMap[key] = { apiKey: keyLabel, apiKeyId: entry.apiKeyId || null, apiKeyName: keyName, + historicalApiKeyNames: [], requests: 0, promptTokens: 0, completionTokens: 0, @@ -273,11 +282,14 @@ export async function computeAnalytics( cost: 0, }; } - byApiKeyMap[keyLabel].requests++; - byApiKeyMap[keyLabel].promptTokens += pt; - byApiKeyMap[keyLabel].completionTokens += ct; - byApiKeyMap[keyLabel].totalTokens += totalTkns; - byApiKeyMap[keyLabel].cost += cost; + if (entry.apiKeyName && !byApiKeyMap[key].historicalApiKeyNames.includes(entry.apiKeyName)) { + byApiKeyMap[key].historicalApiKeyNames.push(entry.apiKeyName); + } + byApiKeyMap[key].requests++; + byApiKeyMap[key].promptTokens += pt; + byApiKeyMap[key].completionTokens += ct; + byApiKeyMap[key].totalTokens += totalTkns; + byApiKeyMap[key].cost += cost; } } diff --git a/src/shared/components/UsageAnalytics.tsx b/src/shared/components/UsageAnalytics.tsx index 0ab5838dc9..c1ddf023d6 100644 --- a/src/shared/components/UsageAnalytics.tsx +++ b/src/shared/components/UsageAnalytics.tsx @@ -61,16 +61,15 @@ export default function UsageAnalytics() { setError(null); // Update available keys from unfiltered data (only when no filter is active). - // Use apiKeyName as the stable identifier — it is always populated - // for every OmniRoute API key regardless of the downstream provider. if (selectedApiKeys.length === 0 && data.byApiKey?.length > 0) { const seen = new Set(); const keys: { id: string; name: string }[] = []; for (const k of data.byApiKey) { + const id = k.apiKeyId || k.apiKeyName || "unknown"; const name = k.apiKeyName || k.apiKeyId || "unknown"; - if (seen.has(name)) continue; - seen.add(name); - keys.push({ id: name, name }); + if (seen.has(id)) continue; + seen.add(id); + keys.push({ id, name }); } setAvailableApiKeys(keys); } diff --git a/tests/unit/usage-analytics-route.test.ts b/tests/unit/usage-analytics-route.test.ts index 66935d5819..6f94e5c6e0 100644 --- a/tests/unit/usage-analytics-route.test.ts +++ b/tests/unit/usage-analytics-route.test.ts @@ -155,16 +155,7 @@ test("GET /api/usage/analytics maps Codex auto-review usage to GPT-5.5 pricing", db.prepare( `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - "codex", - "codex-auto-review", - "codex-conn", - 1000, - 500, - 1, - 250, - new Date().toISOString() - ); + ).run("codex", "codex-auto-review", "codex-conn", 1000, 500, 1, 250, new Date().toISOString()); const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); const body = await response.json(); @@ -254,6 +245,65 @@ test("GET /api/usage/analytics includes cost by API key", async () => { assertClose(body.byApiKey[0].cost, body.summary.totalCost); }); +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 } }, diff --git a/tests/unit/usage-analytics.test.ts b/tests/unit/usage-analytics.test.ts index 8ff5f6662c..79a12890b2 100644 --- a/tests/unit/usage-analytics.test.ts +++ b/tests/unit/usage-analytics.test.ts @@ -9,9 +9,11 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const localDb = await import("../../src/lib/localDb.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); const usageStats = await import("../../src/lib/usage/usageStats.ts"); +const legacyUsageAnalytics = await import("../../src/lib/usageAnalytics.ts"); const callLogs = await import("../../src/lib/usage/callLogs.ts"); const { calculateCost } = await import("../../src/lib/usage/costCalculator.ts"); @@ -20,6 +22,7 @@ const clearPendingRequests = usageHistory.clearPendingRequests; async function resetStorage() { core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); clearPendingRequests(); @@ -31,6 +34,7 @@ test.beforeEach(async () => { test.after(() => { core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); @@ -258,7 +262,7 @@ test("getUsageStats aggregates totals, buckets, pending requests, and cost break assert.equal(stats.byAccount[accountKey].requests, 2); assert.equal(stats.byAccount[accountKey].accountName, "Primary Account"); - assert.equal(stats.byApiKey["Service Key (api-key-1)"].requests, 2); + assert.equal(stats.byApiKey["id:api-key-1"].requests, 2); assert.equal(stats.pending.byModel["pricing-model (pricing-provider)"], 1); assert.equal(stats.pending.byAccount[connection.id]["pricing-model (pricing-provider)"], 1); assert.deepEqual(stats.activeRequests, [ @@ -275,6 +279,90 @@ test("getUsageStats aggregates totals, buckets, pending requests, and cost break assert.equal(recentBucketTotal, 1); }); +test("getUsageStats groups renamed API key usage by stable ID", async () => { + const db = core.getDbInstance(); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + "api-key-rename", + "Current Name", + "omni-test-key", + "machine1234567890", + "[]", + 0, + now, + "omni-test-ke" + ); + + await usageHistory.saveRequestUsage({ + provider: "provider-a", + model: "model-a", + apiKeyId: "api-key-rename", + apiKeyName: "Original Name", + tokens: { input: 10, output: 5 }, + success: true, + timestamp: new Date(Date.now() - 60_000).toISOString(), + }); + await usageHistory.saveRequestUsage({ + provider: "provider-a", + model: "model-a", + apiKeyId: "api-key-rename", + apiKeyName: "Renamed Alias", + tokens: { input: 20, output: 10 }, + success: true, + timestamp: now, + }); + + const stats = await usageStats.getUsageStats(); + const row = stats.byApiKey["id:api-key-rename"]; + + assert.ok(row); + assert.equal(Object.keys(stats.byApiKey).length, 1); + assert.equal(row.apiKeyId, "api-key-rename"); + assert.equal(row.apiKeyName, "Current Name"); + assert.deepEqual(row.historicalApiKeyNames?.sort(), ["Original Name", "Renamed Alias"]); + assert.equal(row.requests, 2); + assert.equal(row.promptTokens, 30); + assert.equal(row.completionTokens, 15); +}); + +test("computeAnalytics groups renamed API key usage by stable ID", async () => { + const analytics = await legacyUsageAnalytics.computeAnalytics( + [ + { + timestamp: new Date(Date.now() - 60_000).toISOString(), + provider: "provider-a", + model: "model-a", + apiKeyId: "api-key-legacy", + apiKeyName: "Original Name", + tokens: { input: 10, output: 5 }, + }, + { + timestamp: new Date().toISOString(), + provider: "provider-a", + model: "model-a", + apiKeyId: "api-key-legacy", + apiKeyName: "Renamed Alias", + tokens: { input: 20, output: 10 }, + }, + ], + "all" + ); + + assert.equal(analytics.summary.uniqueApiKeys, 1); + assert.equal(analytics.byApiKey.length, 1); + assert.equal(analytics.byApiKey[0].apiKeyId, "api-key-legacy"); + assert.deepEqual(analytics.byApiKey[0].historicalApiKeyNames.sort(), [ + "Original Name", + "Renamed Alias", + ]); + assert.equal(analytics.byApiKey[0].requests, 2); + assert.equal(analytics.byApiKey[0].promptTokens, 30); + assert.equal(analytics.byApiKey[0].completionTokens, 15); +}); + test("recent request summaries are generated from SQLite call logs", async () => { const connection = await providersDb.createProviderConnection({ provider: "log-provider",