Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
70 changes: 64 additions & 6 deletions src/app/api/usage/analytics/route.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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, "");
}
Expand Down Expand Up @@ -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> = {};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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(
`
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
53 changes: 40 additions & 13 deletions src/lib/usage/usageStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -29,6 +30,7 @@ type UsageBreakdown = UsageBucket & {
accountName?: string;
apiKeyId?: string | null;
apiKeyName?: string;
historicalApiKeyNames?: string[];
};

type ActiveRequest = {
Expand All @@ -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.
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
}
}
}
Expand Down
28 changes: 20 additions & 8 deletions src/lib/usageAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -260,24 +267,29 @@ 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,
totalTokens: 0,
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;
}
}

Expand Down
9 changes: 4 additions & 5 deletions src/shared/components/UsageAnalytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading
Loading