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