/** * 使用统计相关工具 * 迁移自基线 modules/usage.js 的纯逻辑部分 */ import type { ScriptableContext } from 'chart.js'; import { maskApiKey } from './format'; export interface KeyStatBucket { success: number; failure: number; } export interface KeyStats { bySource: Record; byAuthIndex: Record; } export interface TokenBreakdown { cachedTokens: number; reasoningTokens: number; } export interface RateStats { rpm: number; tpm: number; windowMinutes: number; requestCount: number; tokenCount: number; } export interface ModelPrice { prompt: number; completion: number; cache: number; } export interface UsageDetail { timestamp: string; source: string; auth_index: number; tokens: { input_tokens: number; output_tokens: number; reasoning_tokens: number; cached_tokens: number; cache_tokens?: number; total_tokens: number; }; failed: boolean; __modelName?: string; } export interface ApiStats { endpoint: string; totalRequests: number; totalTokens: number; totalCost: number; models: Record; } const TOKENS_PER_PRICE_UNIT = 1_000_000; const MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2'; const normalizeAuthIndex = (value: any) => { if (typeof value === 'number' && Number.isFinite(value)) { return value.toString(); } if (typeof value === 'string') { const trimmed = value.trim(); return trimmed ? trimmed : null; } return null; }; /** * 对使用数据中的敏感字段进行遮罩 */ export function maskUsageSensitiveValue(value: unknown, masker: (val: string) => string = maskApiKey): string { if (value === null || value === undefined) { return ''; } const raw = typeof value === 'string' ? value : String(value); if (!raw) { return ''; } let masked = raw; const queryRegex = /([?&])(api[-_]?key|key|token|access_token|authorization)=([^&#\s]+)/gi; masked = masked.replace(queryRegex, (_full, prefix, keyName, valuePart) => `${prefix}${keyName}=${masker(valuePart)}`); const headerRegex = /(api[-_]?key|key|token|access[-_]?token|authorization)\s*([:=])\s*([A-Za-z0-9._-]+)/gi; masked = masked.replace(headerRegex, (_full, keyName, separator, valuePart) => `${keyName}${separator}${masker(valuePart)}`); const keyLikeRegex = /(sk-[A-Za-z0-9]{6,}|AI[a-zA-Z0-9_-]{6,}|AIza[0-9A-Za-z-_]{8,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/g; masked = masked.replace(keyLikeRegex, (match) => masker(match)); if (masked === raw) { const trimmed = raw.trim(); if (trimmed && !/\s/.test(trimmed)) { const looksLikeKey = /^sk-/i.test(trimmed) || /^AI/i.test(trimmed) || /^AIza/i.test(trimmed) || /^hf_/i.test(trimmed) || /^pk_/i.test(trimmed) || /^rk_/i.test(trimmed) || (!/[\\/]/.test(trimmed) && (/\d/.test(trimmed) || trimmed.length >= 10)) || trimmed.length >= 24; if (looksLikeKey) { return masker(trimmed); } } } return masked; } /** * 格式化 tokens 为百万单位 */ export function formatTokensInMillions(value: number): string { const num = Number(value); if (!Number.isFinite(num)) { return '0.00M'; } return `${(num / 1_000_000).toFixed(2)}M`; } /** * 格式化每分钟数值 */ export function formatPerMinuteValue(value: number): string { const num = Number(value); if (!Number.isFinite(num)) { return '0.00'; } const abs = Math.abs(num); if (abs >= 1000) { return Math.round(num).toLocaleString(); } if (abs >= 100) { return num.toFixed(0); } if (abs >= 10) { return num.toFixed(1); } return num.toFixed(2); } /** * 格式化紧凑数字 */ export function formatCompactNumber(value: number): string { const num = Number(value); if (!Number.isFinite(num)) { return '0'; } const abs = Math.abs(num); if (abs >= 1_000_000) { return `${(num / 1_000_000).toFixed(1)}M`; } if (abs >= 1_000) { return `${(num / 1_000).toFixed(1)}K`; } return abs >= 1 ? num.toFixed(0) : num.toFixed(2); } /** * 格式化美元 */ export function formatUsd(value: number): string { const num = Number(value); if (!Number.isFinite(num)) { return '$0.00'; } const fixed = num.toFixed(2); const parts = Number(fixed).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return `$${parts}`; } /** * 从使用数据中收集所有请求明细 */ export function collectUsageDetails(usageData: any): UsageDetail[] { if (!usageData) { return []; } const apis = usageData.apis || {}; const details: UsageDetail[] = []; Object.values(apis as Record).forEach((apiEntry) => { const models = apiEntry?.models || {}; Object.entries(models as Record).forEach(([modelName, modelEntry]) => { const modelDetails = Array.isArray(modelEntry.details) ? modelEntry.details : []; modelDetails.forEach((detail: any) => { if (detail && detail.timestamp) { details.push({ ...detail, __modelName: modelName }); } }); }); }); return details; } /** * 从单条明细提取总 tokens */ export function extractTotalTokens(detail: any): number { const tokens = detail?.tokens || {}; if (typeof tokens.total_tokens === 'number') { return tokens.total_tokens; } const inputTokens = typeof tokens.input_tokens === 'number' ? tokens.input_tokens : 0; const outputTokens = typeof tokens.output_tokens === 'number' ? tokens.output_tokens : 0; const reasoningTokens = typeof tokens.reasoning_tokens === 'number' ? tokens.reasoning_tokens : 0; const cachedTokens = Math.max( typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0, typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0 ); return inputTokens + outputTokens + reasoningTokens + cachedTokens; } /** * 计算 token 分类统计 */ export function calculateTokenBreakdown(usageData: any): TokenBreakdown { const details = collectUsageDetails(usageData); if (!details.length) { return { cachedTokens: 0, reasoningTokens: 0 }; } let cachedTokens = 0; let reasoningTokens = 0; details.forEach(detail => { const tokens = detail?.tokens || {}; cachedTokens += Math.max( typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0, typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0 ); if (typeof tokens.reasoning_tokens === 'number') { reasoningTokens += tokens.reasoning_tokens; } }); return { cachedTokens, reasoningTokens }; } /** * 计算最近 N 分钟的 RPM/TPM */ export function calculateRecentPerMinuteRates(windowMinutes: number = 30, usageData: any): RateStats { const details = collectUsageDetails(usageData); const effectiveWindow = Number.isFinite(windowMinutes) && windowMinutes > 0 ? windowMinutes : 30; if (!details.length) { return { rpm: 0, tpm: 0, windowMinutes: effectiveWindow, requestCount: 0, tokenCount: 0 }; } const now = Date.now(); const windowStart = now - effectiveWindow * 60 * 1000; let requestCount = 0; let tokenCount = 0; details.forEach(detail => { const timestamp = Date.parse(detail.timestamp); if (Number.isNaN(timestamp) || timestamp < windowStart) { return; } requestCount += 1; tokenCount += extractTotalTokens(detail); }); const denominator = effectiveWindow > 0 ? effectiveWindow : 1; return { rpm: requestCount / denominator, tpm: tokenCount / denominator, windowMinutes: effectiveWindow, requestCount, tokenCount }; } /** * 从使用数据获取模型名称列表 */ export function getModelNamesFromUsage(usageData: any): string[] { if (!usageData) { return []; } const apis = usageData.apis || {}; const names = new Set(); Object.values(apis as Record).forEach(apiEntry => { const models = apiEntry?.models || {}; Object.keys(models).forEach(modelName => { if (modelName) { names.add(modelName); } }); }); return Array.from(names).sort((a, b) => a.localeCompare(b)); } /** * 计算成本数据 */ export function calculateCost(detail: any, modelPrices: Record): number { const modelName = detail.__modelName || ''; const price = modelPrices[modelName]; if (!price) { return 0; } const tokens = detail?.tokens || {}; const rawInputTokens = Number(tokens.input_tokens); const rawCompletionTokens = Number(tokens.output_tokens); const rawCachedTokensPrimary = Number(tokens.cached_tokens); const rawCachedTokensAlternate = Number(tokens.cache_tokens); const inputTokens = Number.isFinite(rawInputTokens) ? Math.max(rawInputTokens, 0) : 0; const completionTokens = Number.isFinite(rawCompletionTokens) ? Math.max(rawCompletionTokens, 0) : 0; const cachedTokens = Math.max( Number.isFinite(rawCachedTokensPrimary) ? Math.max(rawCachedTokensPrimary, 0) : 0, Number.isFinite(rawCachedTokensAlternate) ? Math.max(rawCachedTokensAlternate, 0) : 0 ); const promptTokens = Math.max(inputTokens - cachedTokens, 0); const promptCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0); const cachedCost = (cachedTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.cache) || 0); const completionCost = (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0); const total = promptCost + cachedCost + completionCost; return Number.isFinite(total) && total > 0 ? total : 0; } /** * 计算总成本 */ export function calculateTotalCost(usageData: any, modelPrices: Record): number { const details = collectUsageDetails(usageData); if (!details.length || !Object.keys(modelPrices).length) { return 0; } return details.reduce((sum, detail) => sum + calculateCost(detail, modelPrices), 0); } /** * 从 localStorage 加载模型价格 */ export function loadModelPrices(): Record { try { if (typeof localStorage === 'undefined') { return {}; } const raw = localStorage.getItem(MODEL_PRICE_STORAGE_KEY); if (!raw) { return {}; } const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object') { return {}; } const normalized: Record = {}; Object.entries(parsed).forEach(([model, price]: [string, any]) => { if (!model) return; const promptRaw = Number(price?.prompt); const completionRaw = Number(price?.completion); const cacheRaw = Number(price?.cache); if (!Number.isFinite(promptRaw) && !Number.isFinite(completionRaw) && !Number.isFinite(cacheRaw)) { return; } const prompt = Number.isFinite(promptRaw) && promptRaw >= 0 ? promptRaw : 0; const completion = Number.isFinite(completionRaw) && completionRaw >= 0 ? completionRaw : 0; const cache = Number.isFinite(cacheRaw) && cacheRaw >= 0 ? cacheRaw : Number.isFinite(promptRaw) && promptRaw >= 0 ? promptRaw : prompt; normalized[model] = { prompt, completion, cache }; }); return normalized; } catch { return {}; } } /** * 保存模型价格到 localStorage */ export function saveModelPrices(prices: Record): void { try { if (typeof localStorage === 'undefined') { return; } localStorage.setItem(MODEL_PRICE_STORAGE_KEY, JSON.stringify(prices)); } catch { console.warn('保存模型价格失败'); } } /** * 获取 API 统计数据 */ export function getApiStats(usageData: any, modelPrices: Record): ApiStats[] { if (!usageData?.apis) { return []; } const apis = usageData.apis; const result: ApiStats[] = []; Object.entries(apis as Record).forEach(([endpoint, apiData]) => { const models: Record = {}; let totalCost = 0; const modelsData = apiData?.models || {}; Object.entries(modelsData as Record).forEach(([modelName, modelData]) => { models[modelName] = { requests: modelData.total_requests || 0, tokens: modelData.total_tokens || 0 }; const price = modelPrices[modelName]; if (price) { const details = Array.isArray(modelData.details) ? modelData.details : []; details.forEach((detail: any) => { totalCost += calculateCost({ ...detail, __modelName: modelName }, modelPrices); }); } }); result.push({ endpoint: maskUsageSensitiveValue(endpoint) || endpoint, totalRequests: apiData.total_requests || 0, totalTokens: apiData.total_tokens || 0, totalCost, models }); }); return result; } /** * 获取模型统计数据 */ export function getModelStats(usageData: any, modelPrices: Record): Array<{ model: string; requests: number; tokens: number; cost: number; }> { if (!usageData?.apis) { return []; } const modelMap = new Map(); Object.values(usageData.apis as Record).forEach(apiData => { const models = apiData?.models || {}; Object.entries(models as Record).forEach(([modelName, modelData]) => { const existing = modelMap.get(modelName) || { requests: 0, tokens: 0, cost: 0 }; existing.requests += modelData.total_requests || 0; existing.tokens += modelData.total_tokens || 0; const price = modelPrices[modelName]; if (price) { const details = Array.isArray(modelData.details) ? modelData.details : []; details.forEach((detail: any) => { existing.cost += calculateCost({ ...detail, __modelName: modelName }, modelPrices); }); } modelMap.set(modelName, existing); }); }); return Array.from(modelMap.entries()) .map(([model, stats]) => ({ model, ...stats })) .sort((a, b) => b.requests - a.requests); } /** * 格式化小时标签 */ export function formatHourLabel(date: Date): string { if (!(date instanceof Date)) { return ''; } const month = (date.getMonth() + 1).toString().padStart(2, '0'); const day = date.getDate().toString().padStart(2, '0'); const hour = date.getHours().toString().padStart(2, '0'); return `${month}-${day} ${hour}:00`; } /** * 格式化日期标签 */ export function formatDayLabel(date: Date): string { if (!(date instanceof Date)) { return ''; } const year = date.getFullYear(); const month = (date.getMonth() + 1).toString().padStart(2, '0'); const day = date.getDate().toString().padStart(2, '0'); return `${year}-${month}-${day}`; } /** * 构建小时级别的数据序列 */ export function buildHourlySeriesByModel(usageData: any, metric: 'requests' | 'tokens' = 'requests'): { labels: string[]; dataByModel: Map; hasData: boolean; } { const hourMs = 60 * 60 * 1000; const now = new Date(); const currentHour = new Date(now); currentHour.setMinutes(0, 0, 0); const earliestBucket = new Date(currentHour); earliestBucket.setHours(earliestBucket.getHours() - 23); const earliestTime = earliestBucket.getTime(); const labels: string[] = []; for (let i = 0; i < 24; i++) { const bucketStart = earliestTime + i * hourMs; labels.push(formatHourLabel(new Date(bucketStart))); } const details = collectUsageDetails(usageData); const dataByModel = new Map(); let hasData = false; if (!details.length) { return { labels, dataByModel, hasData }; } details.forEach(detail => { const timestamp = Date.parse(detail.timestamp); if (Number.isNaN(timestamp)) { return; } const normalized = new Date(timestamp); normalized.setMinutes(0, 0, 0); const bucketStart = normalized.getTime(); const lastBucketTime = earliestTime + (labels.length - 1) * hourMs; if (bucketStart < earliestTime || bucketStart > lastBucketTime) { return; } const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs); if (bucketIndex < 0 || bucketIndex >= labels.length) { return; } const modelName = detail.__modelName || 'Unknown'; if (!dataByModel.has(modelName)) { dataByModel.set(modelName, new Array(labels.length).fill(0)); } const bucketValues = dataByModel.get(modelName)!; if (metric === 'tokens') { bucketValues[bucketIndex] += extractTotalTokens(detail); } else { bucketValues[bucketIndex] += 1; } hasData = true; }); return { labels, dataByModel, hasData }; } /** * 构建日级别的数据序列 */ export function buildDailySeriesByModel(usageData: any, metric: 'requests' | 'tokens' = 'requests'): { labels: string[]; dataByModel: Map; hasData: boolean; } { const details = collectUsageDetails(usageData); const valuesByModel = new Map>(); const labelsSet = new Set(); let hasData = false; if (!details.length) { return { labels: [], dataByModel: new Map(), hasData }; } details.forEach(detail => { const timestamp = Date.parse(detail.timestamp); if (Number.isNaN(timestamp)) { return; } const dayLabel = formatDayLabel(new Date(timestamp)); if (!dayLabel) { return; } const modelName = detail.__modelName || 'Unknown'; if (!valuesByModel.has(modelName)) { valuesByModel.set(modelName, new Map()); } const modelDayMap = valuesByModel.get(modelName)!; const increment = metric === 'tokens' ? extractTotalTokens(detail) : 1; modelDayMap.set(dayLabel, (modelDayMap.get(dayLabel) || 0) + increment); labelsSet.add(dayLabel); hasData = true; }); const labels = Array.from(labelsSet).sort(); const dataByModel = new Map(); valuesByModel.forEach((dayMap, modelName) => { const series = labels.map(label => dayMap.get(label) || 0); dataByModel.set(modelName, series); }); return { labels, dataByModel, hasData }; } export interface ChartDataset { label: string; data: number[]; borderColor: string; backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient); fill: boolean; tension: number; } export interface ChartData { labels: string[]; datasets: ChartDataset[]; } const CHART_COLORS = [ { borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.15)' }, { borderColor: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.15)' }, { borderColor: '#f59e0b', backgroundColor: 'rgba(245, 158, 11, 0.15)' }, { borderColor: '#ef4444', backgroundColor: 'rgba(239, 68, 68, 0.15)' }, { borderColor: '#8b5cf6', backgroundColor: 'rgba(139, 92, 246, 0.15)' }, { borderColor: '#06b6d4', backgroundColor: 'rgba(6, 182, 212, 0.15)' }, { borderColor: '#ec4899', backgroundColor: 'rgba(236, 72, 153, 0.15)' }, { borderColor: '#84cc16', backgroundColor: 'rgba(132, 204, 22, 0.15)' }, { borderColor: '#f97316', backgroundColor: 'rgba(249, 115, 22, 0.15)' }, ]; const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => { const normalized = hex.trim().replace('#', ''); if (normalized.length !== 6) { return null; } const r = Number.parseInt(normalized.slice(0, 2), 16); const g = Number.parseInt(normalized.slice(2, 4), 16); const b = Number.parseInt(normalized.slice(4, 6), 16); if (![r, g, b].every((channel) => Number.isFinite(channel))) { return null; } return { r, g, b }; }; const withAlpha = (hex: string, alpha: number) => { const rgb = hexToRgb(hex); if (!rgb) { return hex; } const clamped = clamp(alpha, 0, 1); return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${clamped})`; }; const buildAreaGradient = (context: ScriptableContext<'line'>, baseHex: string, fallback: string) => { const chart = context.chart; const ctx = chart.ctx; const area = chart.chartArea; if (!area) { return fallback; } const gradient = ctx.createLinearGradient(0, area.top, 0, area.bottom); gradient.addColorStop(0, withAlpha(baseHex, 0.28)); gradient.addColorStop(0.6, withAlpha(baseHex, 0.12)); gradient.addColorStop(1, withAlpha(baseHex, 0.02)); return gradient; }; /** * 构建图表数据 */ export function buildChartData( usageData: any, period: 'hour' | 'day' = 'day', metric: 'requests' | 'tokens' = 'requests', selectedModels: string[] = [] ): ChartData { const baseSeries = period === 'hour' ? buildHourlySeriesByModel(usageData, metric) : buildDailySeriesByModel(usageData, metric); const { labels, dataByModel } = baseSeries; // Build "All" series as sum of all models const getAllSeries = (): number[] => { const summed = new Array(labels.length).fill(0); dataByModel.forEach(values => { values.forEach((value, idx) => { summed[idx] = (summed[idx] || 0) + value; }); }); return summed; }; // Determine which models to show const modelsToShow = selectedModels.length > 0 ? selectedModels : ['all']; const datasets: ChartDataset[] = modelsToShow.map((model, index) => { const isAll = model === 'all'; const data = isAll ? getAllSeries() : (dataByModel.get(model) || new Array(labels.length).fill(0)); const colorIndex = index % CHART_COLORS.length; const style = CHART_COLORS[colorIndex]; const shouldFill = modelsToShow.length === 1 || (isAll && modelsToShow.length > 1); return { label: isAll ? 'All Models' : model, data, borderColor: style.borderColor, backgroundColor: shouldFill ? (ctx) => buildAreaGradient(ctx, style.borderColor, style.backgroundColor) : style.backgroundColor, fill: shouldFill, tension: 0.35 }; }); return { labels, datasets }; } /** * 依据 usage 数据计算密钥使用统计 */ /** * 状态栏单个格子的状态 */ export type StatusBlockState = 'success' | 'failure' | 'mixed' | 'idle'; /** * 状态栏数据 */ export interface StatusBarData { blocks: StatusBlockState[]; successRate: number; totalSuccess: number; totalFailure: number; } /** * 计算状态栏数据(最近1小时,分为20个5分钟的时间块) * 注意:20个块 × 5分钟 = 100分钟,但我们只使用最近60分钟的数据 * 所以实际只有最后12个块可能有数据,前8个块将始终为 idle */ export function calculateStatusBarData( usageDetails: UsageDetail[], sourceFilter?: string, authIndexFilter?: number ): StatusBarData { const BLOCK_COUNT = 20; const BLOCK_DURATION_MS = 5 * 60 * 1000; // 5 minutes const HOUR_MS = 60 * 60 * 1000; const now = Date.now(); const hourAgo = now - HOUR_MS; // Initialize blocks const blockStats: Array<{ success: number; failure: number }> = Array.from( { length: BLOCK_COUNT }, () => ({ success: 0, failure: 0 }) ); let totalSuccess = 0; let totalFailure = 0; // Filter and bucket the usage details usageDetails.forEach((detail) => { const timestamp = Date.parse(detail.timestamp); if (Number.isNaN(timestamp) || timestamp < hourAgo || timestamp > now) { return; } // Apply filters if provided if (sourceFilter !== undefined && detail.source !== sourceFilter) { return; } if (authIndexFilter !== undefined && detail.auth_index !== authIndexFilter) { return; } // Calculate which block this falls into (0 = oldest, 19 = newest) const ageMs = now - timestamp; const blockIndex = BLOCK_COUNT - 1 - Math.floor(ageMs / BLOCK_DURATION_MS); if (blockIndex >= 0 && blockIndex < BLOCK_COUNT) { if (detail.failed) { blockStats[blockIndex].failure += 1; totalFailure += 1; } else { blockStats[blockIndex].success += 1; totalSuccess += 1; } } }); // Convert stats to block states const blocks: StatusBlockState[] = blockStats.map((stat) => { if (stat.success === 0 && stat.failure === 0) { return 'idle'; } if (stat.failure === 0) { return 'success'; } if (stat.success === 0) { return 'failure'; } return 'mixed'; }); // Calculate success rate const total = totalSuccess + totalFailure; const successRate = total > 0 ? (totalSuccess / total) * 100 : 100; return { blocks, successRate, totalSuccess, totalFailure }; } export function computeKeyStats(usageData: any, masker: (val: string) => string = maskApiKey): KeyStats { if (!usageData) { return { bySource: {}, byAuthIndex: {} }; } const sourceStats: Record = {}; const authIndexStats: Record = {}; const ensureBucket = (bucket: Record, key: string) => { if (!bucket[key]) { bucket[key] = { success: 0, failure: 0 }; } return bucket[key]; }; const apis = usageData.apis || {}; Object.values(apis as any).forEach((apiEntry: any) => { const models = apiEntry?.models || {}; Object.values(models as any).forEach((modelEntry: any) => { const details = modelEntry?.details || []; details.forEach((detail: any) => { const source = maskUsageSensitiveValue(detail?.source, masker); const authIndexKey = normalizeAuthIndex(detail?.auth_index); const isFailed = detail?.failed === true; if (source) { const bucket = ensureBucket(sourceStats, source); if (isFailed) { bucket.failure += 1; } else { bucket.success += 1; } } if (authIndexKey) { const bucket = ensureBucket(authIndexStats, authIndexKey); if (isFailed) { bucket.failure += 1; } else { bucket.success += 1; } } }); }); }); return { bySource: sourceStats, byAuthIndex: authIndexStats }; }