| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { getDbInstance } from "../db/core"; |
| import { shouldPersistToDisk } from "./migrations"; |
| import { |
| getLoggedInputTokens, |
| getLoggedOutputTokens, |
| getPromptCacheCreationTokens, |
| getPromptCacheReadTokens, |
| getReasoningTokens, |
| } from "./tokenAccounting"; |
|
|
| type JsonRecord = Record<string, unknown>; |
|
|
| function asRecord(value: unknown): JsonRecord { |
| return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; |
| } |
|
|
| function toStringOrNull(value: unknown): string | null { |
| return typeof value === "string" && value.trim().length > 0 ? value : null; |
| } |
|
|
| 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 percentile(sortedValues: number[], p: number): number { |
| if (sortedValues.length === 0) return 0; |
| if (sortedValues.length === 1) return sortedValues[0]; |
| const bounded = Math.max(0, Math.min(1, p)); |
| const idx = Math.round((sortedValues.length - 1) * bounded); |
| return sortedValues[idx] ?? sortedValues[sortedValues.length - 1]; |
| } |
|
|
| function stdDev(values: number[], avg: number): number { |
| if (values.length <= 1) return 0; |
| const variance = values.reduce((acc, v) => acc + (v - avg) ** 2, 0) / values.length; |
| return Math.sqrt(Math.max(0, variance)); |
| } |
|
|
| |
|
|
| const pendingRequests: { |
| byModel: Record<string, number>; |
| byAccount: Record<string, Record<string, number>>; |
| } = { |
| byModel: Object.create(null) as Record<string, number>, |
| byAccount: Object.create(null) as Record<string, Record<string, number>>, |
| }; |
|
|
| |
| |
| |
| export function trackPendingRequest( |
| model: string, |
| provider: string, |
| connectionId: string | null, |
| started: boolean |
| ) { |
| const modelKey = provider ? `${model} (${provider})` : model; |
|
|
| |
| if (!Object.prototype.hasOwnProperty.call(pendingRequests.byModel, modelKey)) { |
| pendingRequests.byModel[modelKey] = 0; |
| } |
| pendingRequests.byModel[modelKey] = Math.max( |
| 0, |
| pendingRequests.byModel[modelKey] + (started ? 1 : -1) |
| ); |
|
|
| if (connectionId) { |
| if (!Object.prototype.hasOwnProperty.call(pendingRequests.byAccount, connectionId)) { |
| pendingRequests.byAccount[connectionId] = Object.create(null) as Record<string, number>; |
| } |
| if (!Object.prototype.hasOwnProperty.call(pendingRequests.byAccount[connectionId], modelKey)) { |
| pendingRequests.byAccount[connectionId][modelKey] = 0; |
| } |
| pendingRequests.byAccount[connectionId][modelKey] = Math.max( |
| 0, |
| pendingRequests.byAccount[connectionId][modelKey] + (started ? 1 : -1) |
| ); |
| } |
| } |
|
|
| |
| |
| |
| |
| export function getPendingRequests() { |
| return pendingRequests; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| export async function getUsageDb(sinceIso?: string | null) { |
| const db = getDbInstance(); |
| const rows = sinceIso |
| ? db |
| .prepare("SELECT * FROM usage_history WHERE timestamp >= ? ORDER BY timestamp ASC") |
| .all(sinceIso) |
| : db.prepare("SELECT * FROM usage_history ORDER BY timestamp ASC").all(); |
|
|
| const history = rows.map((row) => { |
| const r = asRecord(row); |
| return { |
| provider: toStringOrNull(r.provider), |
| model: toStringOrNull(r.model), |
| connectionId: toStringOrNull(r.connection_id), |
| apiKeyId: toStringOrNull(r.api_key_id), |
| apiKeyName: toStringOrNull(r.api_key_name), |
| tokens: { |
| input: toNumber(r.tokens_input), |
| output: toNumber(r.tokens_output), |
| cacheRead: toNumber(r.tokens_cache_read), |
| cacheCreation: toNumber(r.tokens_cache_creation), |
| reasoning: toNumber(r.tokens_reasoning), |
| }, |
| status: toStringOrNull(r.status), |
| success: toNumber(r.success) === 1, |
| latencyMs: toNumber(r.latency_ms), |
| timeToFirstTokenMs: toNumber(r.ttft_ms), |
| errorCode: toStringOrNull(r.error_code), |
| timestamp: toStringOrNull(r.timestamp), |
| }; |
| }); |
|
|
| return { data: { history } }; |
| } |
|
|
| |
|
|
| |
| |
| |
| export async function saveRequestUsage(entry: any) { |
| if (!shouldPersistToDisk) return; |
|
|
| try { |
| const db = getDbInstance(); |
| const timestamp = entry.timestamp || new Date().toISOString(); |
|
|
| db.prepare( |
| ` |
| INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, |
| tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning, |
| status, success, latency_ms, ttft_ms, error_code, timestamp) |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| ` |
| ).run( |
| entry.provider || null, |
| entry.model || null, |
| entry.connectionId || null, |
| entry.apiKeyId || null, |
| entry.apiKeyName || null, |
| getLoggedInputTokens(entry.tokens), |
| getLoggedOutputTokens(entry.tokens), |
| getPromptCacheReadTokens(entry.tokens), |
| getPromptCacheCreationTokens(entry.tokens), |
| getReasoningTokens(entry.tokens), |
| entry.status || null, |
| entry.success === false ? 0 : 1, |
| Number.isFinite(Number(entry.latencyMs)) ? Number(entry.latencyMs) : 0, |
| Number.isFinite(Number(entry.timeToFirstTokenMs)) |
| ? Number(entry.timeToFirstTokenMs) |
| : Number.isFinite(Number(entry.latencyMs)) |
| ? Number(entry.latencyMs) |
| : 0, |
| entry.errorCode || null, |
| timestamp |
| ); |
| } catch (error) { |
| console.error("Failed to save usage stats:", error); |
| } |
| } |
|
|
| |
|
|
| |
| |
| |
| export async function getUsageHistory(filter: any = {}) { |
| const db = getDbInstance(); |
| let sql = "SELECT * FROM usage_history"; |
| const conditions: string[] = []; |
| const params: Record<string, unknown> = {}; |
|
|
| if (filter.provider) { |
| conditions.push("provider = @provider"); |
| params.provider = filter.provider; |
| } |
| if (filter.model) { |
| conditions.push("model = @model"); |
| params.model = filter.model; |
| } |
| if (filter.startDate) { |
| conditions.push("timestamp >= @startDate"); |
| params.startDate = new Date(filter.startDate).toISOString(); |
| } |
| if (filter.endDate) { |
| conditions.push("timestamp <= @endDate"); |
| params.endDate = new Date(filter.endDate).toISOString(); |
| } |
|
|
| if (conditions.length > 0) { |
| sql += " WHERE " + conditions.join(" AND "); |
| } |
| sql += " ORDER BY timestamp ASC"; |
|
|
| const rows = db.prepare(sql).all(params); |
| return rows.map((row) => { |
| const r = asRecord(row); |
| return { |
| provider: toStringOrNull(r.provider), |
| model: toStringOrNull(r.model), |
| connectionId: toStringOrNull(r.connection_id), |
| apiKeyId: toStringOrNull(r.api_key_id), |
| apiKeyName: toStringOrNull(r.api_key_name), |
| tokens: { |
| input: toNumber(r.tokens_input), |
| output: toNumber(r.tokens_output), |
| cacheRead: toNumber(r.tokens_cache_read), |
| cacheCreation: toNumber(r.tokens_cache_creation), |
| reasoning: toNumber(r.tokens_reasoning), |
| }, |
| status: toStringOrNull(r.status), |
| success: toNumber(r.success) === 1, |
| latencyMs: toNumber(r.latency_ms), |
| timeToFirstTokenMs: toNumber(r.ttft_ms), |
| errorCode: toStringOrNull(r.error_code), |
| timestamp: toStringOrNull(r.timestamp), |
| }; |
| }); |
| } |
|
|
| export interface ModelLatencyStatsEntry { |
| provider: string; |
| model: string; |
| key: string; |
| totalRequests: number; |
| successfulRequests: number; |
| successRate: number; |
| avgLatencyMs: number; |
| p50LatencyMs: number; |
| p95LatencyMs: number; |
| p99LatencyMs: number; |
| latencyStdDev: number; |
| windowHours: number; |
| } |
|
|
| |
| |
| |
| |
| export async function getModelLatencyStats( |
| options: { windowHours?: number; minSamples?: number; maxRows?: number } = {} |
| ): Promise<Record<string, ModelLatencyStatsEntry>> { |
| const windowHours = |
| Number.isFinite(Number(options.windowHours)) && Number(options.windowHours) > 0 |
| ? Number(options.windowHours) |
| : 24; |
| const minSamples = |
| Number.isFinite(Number(options.minSamples)) && Number(options.minSamples) > 0 |
| ? Number(options.minSamples) |
| : 1; |
| const maxRows = |
| Number.isFinite(Number(options.maxRows)) && Number(options.maxRows) > 0 |
| ? Number(options.maxRows) |
| : 10000; |
|
|
| const db = getDbInstance(); |
| const sinceIso = new Date(Date.now() - windowHours * 60 * 60 * 1000).toISOString(); |
|
|
| type LatencyRow = { |
| provider: string | null; |
| model: string | null; |
| success: number | null; |
| latency_ms: number | null; |
| }; |
|
|
| const rows = db |
| .prepare( |
| ` |
| SELECT provider, model, success, latency_ms |
| FROM usage_history |
| WHERE timestamp >= @sinceIso |
| AND provider IS NOT NULL |
| AND model IS NOT NULL |
| ORDER BY timestamp DESC |
| LIMIT @maxRows |
| ` |
| ) |
| .all({ sinceIso, maxRows }) as LatencyRow[]; |
|
|
| const grouped = new Map< |
| string, |
| { |
| provider: string; |
| model: string; |
| totalRequests: number; |
| successfulRequests: number; |
| successfulLatencies: number[]; |
| allLatencies: number[]; |
| } |
| >(); |
|
|
| for (const row of rows) { |
| const provider = toStringOrNull(row.provider); |
| const model = toStringOrNull(row.model); |
| if (!provider || !model) continue; |
|
|
| const key = `${provider}/${model}`; |
| if (!grouped.has(key)) { |
| grouped.set(key, { |
| provider, |
| model, |
| totalRequests: 0, |
| successfulRequests: 0, |
| successfulLatencies: [], |
| allLatencies: [], |
| }); |
| } |
|
|
| const bucket = grouped.get(key); |
| if (!bucket) continue; |
|
|
| bucket.totalRequests += 1; |
| const isSuccess = toNumber(row.success) !== 0; |
| if (isSuccess) bucket.successfulRequests += 1; |
|
|
| const latency = toNumber(row.latency_ms); |
| if (latency > 0) { |
| bucket.allLatencies.push(latency); |
| if (isSuccess) bucket.successfulLatencies.push(latency); |
| } |
| } |
|
|
| const stats: Record<string, ModelLatencyStatsEntry> = {}; |
| for (const [key, bucket] of grouped.entries()) { |
| const baseLatencies = |
| bucket.successfulLatencies.length >= minSamples |
| ? bucket.successfulLatencies |
| : bucket.allLatencies; |
|
|
| if (baseLatencies.length < minSamples) continue; |
|
|
| const sorted = [...baseLatencies].sort((a, b) => a - b); |
| const avg = sorted.reduce((acc, n) => acc + n, 0) / sorted.length; |
| const successRate = |
| bucket.totalRequests > 0 ? bucket.successfulRequests / bucket.totalRequests : 0; |
|
|
| stats[key] = { |
| provider: bucket.provider, |
| model: bucket.model, |
| key, |
| totalRequests: bucket.totalRequests, |
| successfulRequests: bucket.successfulRequests, |
| successRate, |
| avgLatencyMs: Math.round(avg), |
| p50LatencyMs: Math.round(percentile(sorted, 0.5)), |
| p95LatencyMs: Math.round(percentile(sorted, 0.95)), |
| p99LatencyMs: Math.round(percentile(sorted, 0.99)), |
| latencyStdDev: Math.round(stdDev(sorted, avg)), |
| windowHours, |
| }; |
| } |
|
|
| return stats; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| export async function appendRequestLog({ |
| model: _model, |
| provider: _provider, |
| connectionId: _connectionId, |
| tokens: _tokens, |
| status: _status, |
| }: { |
| model?: string; |
| provider?: string; |
| connectionId?: string; |
| tokens?: any; |
| status?: string | number; |
| }) { |
| |
| } |
|
|
| |
| |
| |
| export async function getRecentLogs(limit = 200) { |
| try { |
| const db = getDbInstance(); |
| const rows = db |
| .prepare( |
| ` |
| SELECT timestamp, model, provider, account, tokens_in, tokens_out, status |
| FROM call_logs |
| ORDER BY timestamp DESC |
| LIMIT ? |
| ` |
| ) |
| .all(limit) as Array<Record<string, unknown>>; |
|
|
| return rows.map((row) => { |
| const timestamp = |
| typeof row.timestamp === "string" ? row.timestamp : new Date().toISOString(); |
| const provider = typeof row.provider === "string" ? row.provider.toUpperCase() : "-"; |
| const model = typeof row.model === "string" ? row.model : "-"; |
| const account = typeof row.account === "string" ? row.account : "-"; |
| const tokensIn = toNumber(row.tokens_in); |
| const tokensOut = toNumber(row.tokens_out); |
| const status = typeof row.status === "number" ? row.status : String(row.status || "-"); |
| return `${timestamp} | ${model} | ${provider} | ${account} | ${tokensIn} | ${tokensOut} | ${status}`; |
| }); |
| } catch (error: any) { |
| console.error("[usageDb] Failed to read recent call logs:", error.message); |
| return []; |
| } |
| } |
|
|