Fix API key identity in usage analytics (#2008)

Integrated into release/v3.8.0
This commit is contained in:
Alexander Averyanov
2026-05-07 14:49:23 +03:00
committed by GitHub
parent 7214efa86e
commit e9d96fa3ff
7 changed files with 284 additions and 46 deletions

View File

@@ -184,13 +184,17 @@ export default function ApiManagerPageClient() {
const stats: Record<string, KeyUsageStats> = {};
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,

View File

@@ -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 | null | undefined>): string[] {
return result;
}
function makeApiKeyUsageGroup(apiKeyId: string, fallbackName: string): string {
return apiKeyId ? `id:${apiKeyId}` : `name:${fallbackName}`;
}
function addApiKeyAlias(target: Set<string>, 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<string, string>();
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<string, string> = {};
@@ -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<Record<string, unknown>>;
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<Record<string, unknown>>;
const apiKeyMetadata = new Map<string, { latestName: string; aliases: Set<string> }>();
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<string>(),
};
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,

View File

@@ -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<string, string>();
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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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<string>();
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);
}

View File

@@ -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 } },

View File

@@ -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",