mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 19:30:51 +08:00
907 lines
26 KiB
TypeScript
907 lines
26 KiB
TypeScript
/**
|
||
* 使用统计相关工具
|
||
* 迁移自基线 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<string, KeyStatBucket>;
|
||
byAuthIndex: Record<string, KeyStatBucket>;
|
||
}
|
||
|
||
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<string, { requests: number; tokens: number }>;
|
||
}
|
||
|
||
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<string, any>).forEach((apiEntry) => {
|
||
const models = apiEntry?.models || {};
|
||
Object.entries(models as Record<string, any>).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<string>();
|
||
Object.values(apis as Record<string, any>).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<string, ModelPrice>): 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<string, ModelPrice>): 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<string, ModelPrice> {
|
||
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<string, ModelPrice> = {};
|
||
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<string, ModelPrice>): 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<string, ModelPrice>): ApiStats[] {
|
||
if (!usageData?.apis) {
|
||
return [];
|
||
}
|
||
const apis = usageData.apis;
|
||
const result: ApiStats[] = [];
|
||
|
||
Object.entries(apis as Record<string, any>).forEach(([endpoint, apiData]) => {
|
||
const models: Record<string, { requests: number; tokens: number }> = {};
|
||
let totalCost = 0;
|
||
|
||
const modelsData = apiData?.models || {};
|
||
Object.entries(modelsData as Record<string, any>).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<string, ModelPrice>): Array<{
|
||
model: string;
|
||
requests: number;
|
||
tokens: number;
|
||
cost: number;
|
||
}> {
|
||
if (!usageData?.apis) {
|
||
return [];
|
||
}
|
||
|
||
const modelMap = new Map<string, { requests: number; tokens: number; cost: number }>();
|
||
|
||
Object.values(usageData.apis as Record<string, any>).forEach(apiData => {
|
||
const models = apiData?.models || {};
|
||
Object.entries(models as Record<string, any>).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<string, number[]>;
|
||
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<string, number[]>();
|
||
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<string, number[]>;
|
||
hasData: boolean;
|
||
} {
|
||
const details = collectUsageDetails(usageData);
|
||
const valuesByModel = new Map<string, Map<string, number>>();
|
||
const labelsSet = new Set<string>();
|
||
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<string, number[]>();
|
||
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<string, KeyStatBucket> = {};
|
||
const authIndexStats: Record<string, KeyStatBucket> = {};
|
||
|
||
const ensureBucket = (bucket: Record<string, KeyStatBucket>, 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
|
||
};
|
||
}
|