mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
Tighten request and provider typing across the SSE pipeline to fix nullability and inference issues in Claude compatibility, wildcard routing, usage tracking, proxy fetch, and response sanitization. Address runtime edge cases by normalizing Bailian hosts, guarding TLS session creation, preserving custom provider base URLs, and using safer OAuth form param construction during token refresh flows. Update dashboard data path exports and usage stats typing, and align E2E/unit tests with paginated API responses, internal model sync auth, and current response payload shapes.
266 lines
8.5 KiB
TypeScript
266 lines
8.5 KiB
TypeScript
/**
|
|
* Usage Stats — extracted from usageDb.js (T-15)
|
|
*
|
|
* Aggregates usage data into stats for the dashboard:
|
|
* totals, by provider/model/account/apiKey, 10-minute buckets.
|
|
*
|
|
* @module lib/usage/usageStats
|
|
*/
|
|
|
|
import { getDbInstance } from "../db/core";
|
|
import { getPendingRequests } from "./usageHistory";
|
|
import { getAccountDisplayName } from "@/lib/display/names";
|
|
import { calculateCost } from "./costCalculator";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
type UsageBucket = {
|
|
requests: number;
|
|
promptTokens: number;
|
|
completionTokens: number;
|
|
cost: number;
|
|
};
|
|
|
|
type UsageBreakdown = UsageBucket & {
|
|
rawModel?: string;
|
|
provider?: string;
|
|
lastUsed?: string;
|
|
connectionId?: string;
|
|
accountName?: string;
|
|
apiKeyId?: string | null;
|
|
apiKeyName?: string;
|
|
};
|
|
|
|
type ActiveRequest = {
|
|
model: string;
|
|
provider: string;
|
|
account: string;
|
|
count: number;
|
|
};
|
|
|
|
function asRecord(value: unknown): JsonRecord {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
|
}
|
|
|
|
function toNumber(value: unknown): number {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function toStringOrEmpty(value: unknown): string {
|
|
return typeof value === "string" ? value : "";
|
|
}
|
|
|
|
/**
|
|
* Get aggregated usage stats.
|
|
*/
|
|
export async function getUsageStats() {
|
|
const db = getDbInstance();
|
|
const rows = db.prepare("SELECT * FROM usage_history ORDER BY timestamp ASC").all() as unknown[];
|
|
|
|
const { getProviderConnections } = await import("@/lib/localDb");
|
|
let allConnections: unknown[] = [];
|
|
try {
|
|
const loadedConnections = await getProviderConnections();
|
|
allConnections = Array.isArray(loadedConnections) ? loadedConnections : [];
|
|
} catch {}
|
|
|
|
const connectionMap: Record<string, string> = {};
|
|
for (const connRaw of allConnections) {
|
|
const conn = asRecord(connRaw);
|
|
const connectionId = toStringOrEmpty(conn.id);
|
|
if (!connectionId) continue;
|
|
connectionMap[connectionId] =
|
|
toStringOrEmpty(conn.name) || toStringOrEmpty(conn.email) || connectionId;
|
|
}
|
|
|
|
const pendingRequests = getPendingRequests();
|
|
|
|
const stats: {
|
|
totalRequests: number;
|
|
totalPromptTokens: number;
|
|
totalCompletionTokens: number;
|
|
totalCost: number;
|
|
byProvider: Record<string, UsageBreakdown>;
|
|
byModel: Record<string, UsageBreakdown>;
|
|
byAccount: Record<string, UsageBreakdown>;
|
|
byApiKey: Record<string, UsageBreakdown>;
|
|
last10Minutes: UsageBucket[];
|
|
pending: ReturnType<typeof getPendingRequests>;
|
|
activeRequests: ActiveRequest[];
|
|
} = {
|
|
totalRequests: rows.length,
|
|
totalPromptTokens: 0,
|
|
totalCompletionTokens: 0,
|
|
totalCost: 0,
|
|
byProvider: {},
|
|
byModel: {},
|
|
byAccount: {},
|
|
byApiKey: {},
|
|
last10Minutes: [],
|
|
pending: pendingRequests,
|
|
activeRequests: [],
|
|
};
|
|
|
|
// Build active requests
|
|
for (const [connectionId, models] of Object.entries(pendingRequests.byAccount)) {
|
|
for (const [modelKey, count] of Object.entries(models)) {
|
|
if (count > 0) {
|
|
const accountName =
|
|
connectionMap[connectionId] || getAccountDisplayName({ id: connectionId });
|
|
const match = modelKey.match(/^(.*) \((.*)\)$/);
|
|
stats.activeRequests.push({
|
|
model: match ? match[1] : modelKey,
|
|
provider: match ? match[2] : "unknown",
|
|
account: accountName,
|
|
count,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 10-minute buckets
|
|
const now = new Date();
|
|
const currentMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000);
|
|
|
|
const bucketMap: Record<number, UsageBucket> = {};
|
|
for (let i = 0; i < 10; i++) {
|
|
const bucketTime = new Date(currentMinuteStart.getTime() - (9 - i) * 60 * 1000);
|
|
const bucketKey = bucketTime.getTime();
|
|
bucketMap[bucketKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 };
|
|
stats.last10Minutes.push(bucketMap[bucketKey]);
|
|
}
|
|
|
|
const tenMinutesAgo = new Date(currentMinuteStart.getTime() - 9 * 60 * 1000);
|
|
|
|
for (const rowRaw of rows) {
|
|
const row = asRecord(rowRaw);
|
|
const provider = toStringOrEmpty(row.provider) || "unknown";
|
|
const model = toStringOrEmpty(row.model) || "unknown";
|
|
const timestamp = toStringOrEmpty(row.timestamp) || new Date(0).toISOString();
|
|
const connectionId = toStringOrEmpty(row.connection_id) || null;
|
|
const apiKeyId = toStringOrEmpty(row.api_key_id) || null;
|
|
const apiKeyName = toStringOrEmpty(row.api_key_name) || null;
|
|
|
|
const promptTokens = toNumber(row.tokens_input);
|
|
const completionTokens = toNumber(row.tokens_output);
|
|
const entryTime = new Date(timestamp);
|
|
|
|
const entryTokens = {
|
|
input: toNumber(row.tokens_input),
|
|
output: toNumber(row.tokens_output),
|
|
cacheRead: toNumber(row.tokens_cache_read),
|
|
cacheCreation: toNumber(row.tokens_cache_creation),
|
|
reasoning: toNumber(row.tokens_reasoning),
|
|
};
|
|
const entryCost = await calculateCost(provider, model, entryTokens);
|
|
|
|
stats.totalPromptTokens += promptTokens;
|
|
stats.totalCompletionTokens += completionTokens;
|
|
stats.totalCost += entryCost;
|
|
|
|
// 10-min buckets
|
|
if (entryTime >= tenMinutesAgo && entryTime <= now) {
|
|
const entryMinuteStart = Math.floor(entryTime.getTime() / 60000) * 60000;
|
|
if (bucketMap[entryMinuteStart]) {
|
|
bucketMap[entryMinuteStart].requests++;
|
|
bucketMap[entryMinuteStart].promptTokens += promptTokens;
|
|
bucketMap[entryMinuteStart].completionTokens += completionTokens;
|
|
bucketMap[entryMinuteStart].cost += entryCost;
|
|
}
|
|
}
|
|
|
|
// By Provider
|
|
if (!stats.byProvider[provider]) {
|
|
stats.byProvider[provider] = {
|
|
requests: 0,
|
|
promptTokens: 0,
|
|
completionTokens: 0,
|
|
cost: 0,
|
|
};
|
|
}
|
|
stats.byProvider[provider].requests++;
|
|
stats.byProvider[provider].promptTokens += promptTokens;
|
|
stats.byProvider[provider].completionTokens += completionTokens;
|
|
stats.byProvider[provider].cost += entryCost;
|
|
|
|
// By Model
|
|
const modelKey = provider ? `${model} (${provider})` : model;
|
|
if (!stats.byModel[modelKey]) {
|
|
stats.byModel[modelKey] = {
|
|
requests: 0,
|
|
promptTokens: 0,
|
|
completionTokens: 0,
|
|
cost: 0,
|
|
rawModel: model,
|
|
provider,
|
|
lastUsed: timestamp,
|
|
};
|
|
}
|
|
stats.byModel[modelKey].requests++;
|
|
stats.byModel[modelKey].promptTokens += promptTokens;
|
|
stats.byModel[modelKey].completionTokens += completionTokens;
|
|
stats.byModel[modelKey].cost += entryCost;
|
|
if (new Date(timestamp) > new Date(stats.byModel[modelKey].lastUsed || timestamp)) {
|
|
stats.byModel[modelKey].lastUsed = timestamp;
|
|
}
|
|
|
|
// By Account
|
|
if (connectionId) {
|
|
const accountName =
|
|
connectionMap[connectionId] || getAccountDisplayName({ id: connectionId });
|
|
const accountKey = `${model} (${provider} - ${accountName})`;
|
|
if (!stats.byAccount[accountKey]) {
|
|
stats.byAccount[accountKey] = {
|
|
requests: 0,
|
|
promptTokens: 0,
|
|
completionTokens: 0,
|
|
cost: 0,
|
|
rawModel: model,
|
|
provider,
|
|
connectionId,
|
|
accountName,
|
|
lastUsed: timestamp,
|
|
};
|
|
}
|
|
stats.byAccount[accountKey].requests++;
|
|
stats.byAccount[accountKey].promptTokens += promptTokens;
|
|
stats.byAccount[accountKey].completionTokens += completionTokens;
|
|
stats.byAccount[accountKey].cost += entryCost;
|
|
if (new Date(timestamp) > new Date(stats.byAccount[accountKey].lastUsed || timestamp)) {
|
|
stats.byAccount[accountKey].lastUsed = timestamp;
|
|
}
|
|
}
|
|
|
|
// 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] = {
|
|
requests: 0,
|
|
promptTokens: 0,
|
|
completionTokens: 0,
|
|
cost: 0,
|
|
apiKeyId: keyId,
|
|
apiKeyName: keyName,
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
return stats;
|
|
}
|