mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
Fix API key identity in usage analytics (#2008)
Integrated into release/v3.8.0
This commit is contained in:
committed by
GitHub
parent
7214efa86e
commit
e9d96fa3ff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user