const DEFAULT_MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2';
const LEGACY_MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices';
const TOKENS_PER_PRICE_UNIT = 1_000_000;
const DEFAULT_CHART_LINE_COUNT = 3;
const MIN_CHART_LINE_COUNT = 1;
const ALL_MODELS_VALUE = 'all';
export function maskUsageSensitiveValue(value) {
if (value === null || value === undefined) {
return '';
}
const raw = typeof value === 'string' ? value : String(value);
if (!raw) {
return '';
}
const maskFn = (this && typeof this.maskApiKey === 'function') ? this.maskApiKey : (v) => v;
let masked = raw;
const queryRegex = /([?&])(api[-_]?key|key|token|access_token|authorization)=([^\s]+)/ig;
masked = masked.replace(queryRegex, (full, prefix, keyName, valuePart) => `${prefix}${keyName}=${maskFn(valuePart)}`);
const headerRegex = /(api[-_]?key|key|token|access[-_]?token|authorization)\s*([:=])\s*([A-Za-z0-9._-]+)/ig;
masked = masked.replace(headerRegex, (full, keyName, separator, valuePart) => `${keyName}${separator}${maskFn(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 => maskFn(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 maskFn(trimmed);
}
}
}
return masked;
}
// 获取API密钥的统计信息
export async function getKeyStats(usageData = null) {
try {
let usage = usageData;
if (!usage) {
const response = await this.makeRequest('/usage');
usage = response?.usage || null;
}
if (!usage) {
return { bySource: {}, byAuthIndex: {} };
}
const sourceStats = {};
const authIndexStats = {};
const ensureBucket = (bucket, key) => {
if (!bucket[key]) {
bucket[key] = { success: 0, failure: 0 };
}
return bucket[key];
};
const normalizeAuthIndex = (value) => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
return null;
};
const apis = usage.apis || {};
Object.values(apis).forEach(apiEntry => {
const models = apiEntry.models || {};
Object.values(models).forEach(modelEntry => {
const details = modelEntry.details || [];
details.forEach(detail => {
const source = this.maskUsageSensitiveValue
? this.maskUsageSensitiveValue(detail.source)
: detail.source;
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
};
} catch (error) {
console.error('获取统计信息失败:', error);
return { bySource: {}, byAuthIndex: {} };
}
}
// 加载使用统计
export async function loadUsageStats(usageData = null) {
try {
let usage = usageData;
// 如果没有传入usage数据,则调用API获取
if (!usage) {
const response = await this.makeRequest('/usage');
usage = response?.usage || null;
}
this.currentUsageData = usage;
this.ensureModelPriceState();
if (!usage) {
throw new Error('usage payload missing');
}
// 更新概览卡片
this.updateUsageOverview(usage);
this.updateChartLineSelectors(usage);
this.renderModelPriceOptions(usage);
this.renderSavedModelPrices();
// 读取当前图表周期
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
const costHourActive = document.getElementById('cost-hour-btn')?.classList.contains('active');
const requestsPeriod = requestsHourActive ? 'hour' : 'day';
const tokensPeriod = tokensHourActive ? 'hour' : 'day';
const costPeriod = costHourActive ? 'hour' : 'day';
// 初始化图表(使用当前周期)
this.initializeRequestsChart(requestsPeriod);
this.initializeTokensChart(tokensPeriod);
this.updateCostSummaryAndChart(usage, costPeriod);
// 更新API详细统计表格
this.updateApiStatsTable(usage);
} catch (error) {
console.error('加载使用统计失败:', error);
this.currentUsageData = null;
this.updateChartLineSelectors(null);
this.ensureModelPriceState();
this.renderModelPriceOptions(null);
this.renderSavedModelPrices();
this.updateCostSummaryAndChart(null);
// 清空概览数据
['total-requests', 'success-requests', 'failed-requests', 'total-tokens', 'cached-tokens', 'reasoning-tokens', 'rpm-30m', 'tpm-30m'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = '-';
});
// 清空图表
if (this.requestsChart) {
this.requestsChart.destroy();
this.requestsChart = null;
}
if (this.tokensChart) {
this.tokensChart.destroy();
this.tokensChart = null;
}
const tableElement = document.getElementById('api-stats-table');
if (tableElement) {
tableElement.innerHTML = `
${i18n.t('usage_stats.loading_error')}: ${error.message}
`;
}
}
}
// 更新使用统计概览
export function updateUsageOverview(data) {
const safeData = data || {};
document.getElementById('total-requests').textContent = safeData.total_requests ?? 0;
document.getElementById('success-requests').textContent = safeData.success_count ?? 0;
document.getElementById('failed-requests').textContent = safeData.failure_count ?? 0;
const totalTokensValue = safeData.total_tokens ?? 0;
document.getElementById('total-tokens').textContent = this.formatTokensInMillions(totalTokensValue);
const tokenBreakdown = this.calculateTokenBreakdown(safeData);
const cachedEl = document.getElementById('cached-tokens');
const reasoningEl = document.getElementById('reasoning-tokens');
if (cachedEl) {
cachedEl.textContent = this.formatTokensInMillions(tokenBreakdown.cachedTokens);
}
if (reasoningEl) {
reasoningEl.textContent = this.formatTokensInMillions(tokenBreakdown.reasoningTokens);
}
const recentRate = this.calculateRecentPerMinuteRates(30, safeData);
document.getElementById('rpm-30m').textContent = this.formatPerMinuteValue(recentRate.rpm);
document.getElementById('tpm-30m').textContent = this.formatPerMinuteValue(recentRate.tpm);
}
export function formatTokensInMillions(value) {
const num = Number(value);
if (!Number.isFinite(num)) {
return '0.00M';
}
return `${(num / 1_000_000).toFixed(2)}M`;
}
export function formatPerMinuteValue(value) {
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 getModelNamesFromUsage(usage) {
if (!usage) {
return [];
}
const apis = usage.apis || {};
const names = new Set();
Object.values(apis).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 getChartLineMaxCount() {
const idCount = Array.isArray(this.chartLineSelectIds) ? this.chartLineSelectIds.length : 0;
const configuredMax = Number(this.chartLineMaxCount);
const fallback = idCount || DEFAULT_CHART_LINE_COUNT;
const resolvedMax = Number.isFinite(configuredMax) ? configuredMax : fallback;
if (idCount > 0) {
return Math.max(MIN_CHART_LINE_COUNT, Math.min(resolvedMax, idCount));
}
return Math.max(MIN_CHART_LINE_COUNT, resolvedMax);
}
export function getVisibleChartLineCount() {
const maxCount = this.getChartLineMaxCount();
const stored = Number(this.chartLineVisibleCount);
const base = Number.isFinite(stored)
? stored
: (Array.isArray(this.chartLineSelections) ? this.chartLineSelections.length : DEFAULT_CHART_LINE_COUNT);
const resolved = Math.min(Math.max(base, MIN_CHART_LINE_COUNT), maxCount);
this.chartLineVisibleCount = resolved;
return resolved;
}
export function ensureChartLineSelectionLength(targetLength = null) {
const maxCount = this.getChartLineMaxCount();
const desiredLength = Math.min(
Math.max(targetLength ?? this.getVisibleChartLineCount(), MIN_CHART_LINE_COUNT),
maxCount
);
if (!Array.isArray(this.chartLineSelections)) {
this.chartLineSelections = Array(desiredLength).fill('none');
return this.chartLineSelections;
}
const trimmed = this.chartLineSelections.slice(0, maxCount);
if (trimmed.length < desiredLength) {
this.chartLineSelections = [...trimmed, ...Array(desiredLength - trimmed.length).fill('none')];
} else if (trimmed.length > desiredLength) {
this.chartLineSelections = trimmed.slice(0, desiredLength);
} else {
this.chartLineSelections = trimmed;
}
return this.chartLineSelections;
}
export function updateChartLineControlsUI() {
const maxCount = this.getChartLineMaxCount();
const visibleCount = this.getVisibleChartLineCount();
const counter = document.getElementById('chart-line-count');
if (counter) {
counter.textContent = `${visibleCount}/${maxCount}`;
}
const addBtn = document.getElementById('add-chart-line');
if (addBtn) {
addBtn.disabled = visibleCount >= maxCount;
}
const deleteButtons = document.querySelectorAll('.chart-line-delete');
if (deleteButtons.length) {
deleteButtons.forEach(button => {
const group = button.closest('.chart-line-group');
const index = Number.parseInt(button.getAttribute('data-line-index'), 10);
const isVisible = group
? !group.classList.contains('chart-line-hidden')
: (Number.isFinite(index) ? index < visibleCount : true);
button.disabled = visibleCount <= MIN_CHART_LINE_COUNT || !isVisible;
});
}
}
export function setChartLineVisibleCount(count) {
const maxCount = this.getChartLineMaxCount();
const nextCount = Math.min(Math.max(count, MIN_CHART_LINE_COUNT), maxCount);
const current = this.getVisibleChartLineCount();
if (nextCount === current) {
this.updateChartLineControlsUI();
return;
}
this.chartLineVisibleCount = nextCount;
this.ensureChartLineSelectionLength(nextCount);
this.updateChartLineSelectors(this.currentUsageData);
this.refreshChartsForSelections();
}
export function changeChartLineCount(delta = 0) {
const current = this.getVisibleChartLineCount();
this.setChartLineVisibleCount(current + delta);
}
export function removeChartLine(index) {
const visibleCount = this.getVisibleChartLineCount();
const normalizedIndex = Number.parseInt(index, 10);
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= visibleCount) {
return;
}
if (visibleCount <= MIN_CHART_LINE_COUNT) {
return;
}
const nextSelections = this.ensureChartLineSelectionLength(visibleCount).slice(0, visibleCount);
nextSelections.splice(normalizedIndex, 1);
this.chartLineSelections = nextSelections;
this.chartLineVisibleCount = Math.max(MIN_CHART_LINE_COUNT, visibleCount - 1);
this.updateChartLineSelectors(this.currentUsageData);
this.refreshChartsForSelections();
}
export function updateChartLineSelectors(usage) {
const modelNames = this.getModelNamesFromUsage(usage);
const selectors = this.chartLineSelectIds
.map(id => document.getElementById(id))
.filter(Boolean);
const availableCount = selectors.length || this.getChartLineMaxCount();
const visibleCount = Math.min(this.getVisibleChartLineCount(), availableCount);
this.chartLineVisibleCount = visibleCount;
this.ensureChartLineSelectionLength(visibleCount);
const wasInitialized = this.chartLineSelectionsInitialized === true;
if (!selectors.length) {
this.chartLineSelections = Array(visibleCount).fill('none');
this.chartLineSelectionsInitialized = false;
this.updateChartLineControlsUI();
return;
}
const optionsFragment = () => {
const fragment = document.createDocumentFragment();
const allOption = document.createElement('option');
allOption.value = ALL_MODELS_VALUE;
allOption.textContent = i18n.t('usage_stats.chart_line_all');
fragment.appendChild(allOption);
modelNames.forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
fragment.appendChild(option);
});
return fragment;
};
const hasModels = modelNames.length > 0;
selectors.forEach((select, index) => {
const group = select.closest('.chart-line-group');
const isVisible = index < visibleCount;
if (group) {
group.classList.toggle('chart-line-hidden', !isVisible);
}
const deleteBtn = group ? group.querySelector('.chart-line-delete') : null;
select.innerHTML = '';
select.appendChild(optionsFragment());
select.disabled = !isVisible;
if (deleteBtn) {
deleteBtn.disabled = !isVisible || visibleCount <= MIN_CHART_LINE_COUNT;
}
if (!isVisible) {
select.value = ALL_MODELS_VALUE;
}
});
if (!hasModels) {
this.chartLineSelections = Array(visibleCount).fill(ALL_MODELS_VALUE);
this.chartLineSelectionsInitialized = false;
selectors.forEach((select, index) => {
const group = select.closest('.chart-line-group');
if (group) {
group.classList.toggle('chart-line-hidden', index >= visibleCount);
}
select.value = ALL_MODELS_VALUE;
});
this.updateChartLineControlsUI();
return;
}
const nextSelections = this.ensureChartLineSelectionLength(visibleCount).slice(0, visibleCount);
const validNames = new Set([...modelNames, ALL_MODELS_VALUE]);
let hasActiveSelection = false;
for (let i = 0; i < nextSelections.length; i++) {
const selection = nextSelections[i];
if (selection && selection !== 'none' && !validNames.has(selection)) {
nextSelections[i] = ALL_MODELS_VALUE;
}
if (nextSelections[i] && nextSelections[i] !== 'none') {
hasActiveSelection = true;
}
}
const allSelectionsAreAll = nextSelections.length > 0 && nextSelections.every(value => value === ALL_MODELS_VALUE);
if (!hasActiveSelection || (!wasInitialized && allSelectionsAreAll)) {
modelNames.slice(0, nextSelections.length).forEach((name, index) => {
nextSelections[index] = name;
});
}
for (let i = 0; i < nextSelections.length; i++) {
if (!nextSelections[i] || nextSelections[i] === 'none') {
nextSelections[i] = modelNames[i % Math.max(modelNames.length, 1)] || ALL_MODELS_VALUE;
}
}
this.chartLineSelections = nextSelections;
selectors.forEach((select, index) => {
const value = this.chartLineSelections[index] || ALL_MODELS_VALUE;
select.value = index < visibleCount ? value : ALL_MODELS_VALUE;
});
this.chartLineSelectionsInitialized = hasModels;
this.updateChartLineControlsUI();
}
export function handleChartLineSelectionChange(index, value) {
const visibleCount = this.getVisibleChartLineCount();
if (index < 0 || index >= visibleCount) {
return;
}
this.ensureChartLineSelectionLength(visibleCount);
const normalized = (value && value !== 'none') ? value : ALL_MODELS_VALUE;
if (this.chartLineSelections[index] === normalized) {
return;
}
this.chartLineSelections[index] = normalized;
this.refreshChartsForSelections();
}
export function refreshChartsForSelections() {
if (!this.currentUsageData) {
return;
}
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
const requestsPeriod = requestsHourActive ? 'hour' : 'day';
const tokensPeriod = tokensHourActive ? 'hour' : 'day';
if (this.requestsChart) {
this.requestsChart.data = this.getRequestsChartData(requestsPeriod);
this.requestsChart.update();
} else {
this.initializeRequestsChart(requestsPeriod);
}
if (this.tokensChart) {
this.tokensChart.data = this.getTokensChartData(tokensPeriod);
this.tokensChart.update();
} else {
this.initializeTokensChart(tokensPeriod);
}
}
export function getActiveChartLineSelections() {
const visibleCount = this.getVisibleChartLineCount();
const selections = this.ensureChartLineSelectionLength(visibleCount).slice(0, visibleCount);
return selections
.map((value, index) => ({ model: value, index }))
.filter(item => item.model && item.model !== 'none');
}
// 收集所有请求明细,供图表等复用
export function collectUsageDetailsFromUsage(usage) {
if (!usage) {
return [];
}
const apis = usage.apis || {};
const details = [];
Object.values(apis).forEach(apiEntry => {
const models = apiEntry.models || {};
Object.entries(models).forEach(([modelName, modelEntry]) => {
const modelDetails = Array.isArray(modelEntry.details) ? modelEntry.details : [];
modelDetails.forEach(detail => {
if (detail && detail.timestamp) {
details.push({
...detail,
__modelName: modelName
});
}
});
});
});
return details;
}
export function collectUsageDetails() {
return this.collectUsageDetailsFromUsage(this.currentUsageData);
}
export function migrateLegacyModelPrices() {
try {
if (typeof localStorage === 'undefined') {
return;
}
const storageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
const hasCurrent = localStorage.getItem(storageKey);
const legacyRaw = localStorage.getItem(LEGACY_MODEL_PRICE_STORAGE_KEY);
if (!legacyRaw || hasCurrent) {
return;
}
const parsed = JSON.parse(legacyRaw);
if (!parsed || typeof parsed !== 'object') {
return;
}
const migrated = {};
Object.entries(parsed).forEach(([model, price]) => {
if (!model) return;
const prompt = Number(price?.prompt);
const completion = Number(price?.completion);
const hasPrompt = Number.isFinite(prompt);
const hasCompletion = Number.isFinite(completion);
if (!hasPrompt && !hasCompletion) {
return;
}
migrated[model] = {
prompt: hasPrompt && prompt >= 0 ? prompt * 1000 : 0,
completion: hasCompletion && completion >= 0 ? completion * 1000 : 0
};
});
if (Object.keys(migrated).length) {
localStorage.setItem(storageKey, JSON.stringify(migrated));
}
localStorage.removeItem(LEGACY_MODEL_PRICE_STORAGE_KEY);
} catch (error) {
console.warn('迁移模型价格失败:', error);
}
}
export function ensureModelPriceState() {
if (this.modelPriceInitialized) {
return;
}
this.modelPriceStorageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
this.migrateLegacyModelPrices();
this.modelPrices = this.loadModelPricesFromStorage();
this.modelPriceInitialized = true;
}
export function loadModelPricesFromStorage() {
const storageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
try {
if (typeof localStorage === 'undefined') {
return {};
}
const raw = localStorage.getItem(storageKey);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return {};
}
const normalized = {};
Object.entries(parsed).forEach(([model, price]) => {
if (!model) return;
const prompt = Number(price?.prompt);
const completion = Number(price?.completion);
if (!Number.isFinite(prompt) && !Number.isFinite(completion)) {
return;
}
normalized[model] = {
prompt: Number.isFinite(prompt) && prompt >= 0 ? prompt : 0,
completion: Number.isFinite(completion) && completion >= 0 ? completion : 0
};
});
return normalized;
} catch (error) {
console.warn('读取模型价格失败:', error);
return {};
}
}
export function persistModelPrices(prices = {}) {
const storageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
this.modelPrices = prices;
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(storageKey, JSON.stringify(prices));
} catch (error) {
console.warn('保存模型价格失败:', error);
}
}
export function renderModelPriceOptions(usage = null) {
const select = document.getElementById('model-price-model-select');
if (!select) return;
const models = this.getModelNamesFromUsage(usage);
const previousValue = select.value;
select.innerHTML = '';
const placeholderOption = document.createElement('option');
placeholderOption.value = '';
placeholderOption.textContent = i18n.t('usage_stats.model_price_select_placeholder');
select.appendChild(placeholderOption);
models.forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
select.appendChild(option);
});
select.disabled = models.length === 0;
if (models.includes(previousValue)) {
select.value = previousValue;
} else {
select.value = '';
}
this.prefillModelPriceInputs();
}
export function renderSavedModelPrices() {
const container = document.getElementById('model-price-list');
if (!container) return;
const entries = Object.entries(this.modelPrices || {});
if (!entries.length) {
container.innerHTML = `${i18n.t('usage_stats.model_price_empty')}
`;
return;
}
const rows = entries.map(([model, price]) => {
const prompt = Number(price?.prompt) || 0;
const completion = Number(price?.completion) || 0;
return `
${model}
$${prompt.toFixed(4)} / 1M
$${completion.toFixed(4)} / 1M
`;
}).join('');
container.innerHTML = `
${rows}
`;
}
export function prefillModelPriceInputs() {
const select = document.getElementById('model-price-model-select');
const promptInput = document.getElementById('model-price-prompt');
const completionInput = document.getElementById('model-price-completion');
if (!select || !promptInput || !completionInput) {
return;
}
const model = (select.value || '').trim();
const price = this.modelPrices?.[model];
if (price) {
promptInput.value = Number.isFinite(price.prompt) ? price.prompt : '';
completionInput.value = Number.isFinite(price.completion) ? price.completion : '';
} else {
promptInput.value = '';
completionInput.value = '';
}
}
export function normalizePriceValue(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return 0;
}
return Number(parsed.toFixed(6));
}
export function handleModelPriceSubmit() {
this.ensureModelPriceState();
const select = document.getElementById('model-price-model-select');
const promptInput = document.getElementById('model-price-prompt');
const completionInput = document.getElementById('model-price-completion');
if (!select || !promptInput || !completionInput) {
return;
}
const model = (select.value || '').trim();
if (!model) {
this.showNotification(i18n.t('usage_stats.model_price_model_required'), 'warning');
return;
}
const prompt = this.normalizePriceValue(promptInput.value);
const completion = this.normalizePriceValue(completionInput.value);
const next = { ...(this.modelPrices || {}) };
next[model] = { prompt, completion };
this.persistModelPrices(next);
this.renderSavedModelPrices();
this.updateCostSummaryAndChart(this.currentUsageData, this.getCostChartPeriod());
this.showNotification(i18n.t('usage_stats.model_price_saved'), 'success');
}
export function handleModelPriceReset() {
this.persistModelPrices({});
if (typeof localStorage !== 'undefined') {
const key = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
try {
localStorage.removeItem(key);
localStorage.removeItem(LEGACY_MODEL_PRICE_STORAGE_KEY);
} catch (error) {
console.warn('清除模型价格失败:', error);
}
}
this.renderSavedModelPrices();
this.prefillModelPriceInputs();
this.updateCostSummaryAndChart(this.currentUsageData, this.getCostChartPeriod());
}
export function calculateTokenBreakdown(usage = null) {
const details = this.collectUsageDetailsFromUsage(usage || this.currentUsageData);
if (!details.length) {
return { cachedTokens: 0, reasoningTokens: 0 };
}
let cachedTokens = 0;
let reasoningTokens = 0;
details.forEach(detail => {
const tokens = detail?.tokens || {};
if (typeof tokens.cached_tokens === 'number') {
cachedTokens += tokens.cached_tokens;
}
if (typeof tokens.reasoning_tokens === 'number') {
reasoningTokens += tokens.reasoning_tokens;
}
});
return { cachedTokens, reasoningTokens };
}
export function calculateRecentPerMinuteRates(windowMinutes = 30, usage = null) {
const details = this.collectUsageDetailsFromUsage(usage || this.currentUsageData);
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 += this.extractTotalTokens(detail);
});
const denominator = effectiveWindow > 0 ? effectiveWindow : 1;
return {
rpm: requestCount / denominator,
tpm: tokenCount / denominator,
windowMinutes: effectiveWindow,
requestCount,
tokenCount
};
}
export function createHourlyBucketMeta() {
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 = [];
for (let i = 0; i < 24; i++) {
const bucketStart = earliestTime + i * hourMs;
labels.push(this.formatHourLabel(new Date(bucketStart)));
}
return {
labels,
earliestTime,
bucketSize: hourMs,
lastBucketTime: earliestTime + (labels.length - 1) * hourMs
};
}
export function buildHourlySeriesByModel(metric = 'requests') {
const meta = this.createHourlyBucketMeta();
const details = this.collectUsageDetails();
const dataByModel = new Map();
let hasData = false;
if (!details.length) {
return { labels: meta.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();
if (bucketStart < meta.earliestTime || bucketStart > meta.lastBucketTime) {
return;
}
const bucketIndex = Math.floor((bucketStart - meta.earliestTime) / meta.bucketSize);
if (bucketIndex < 0 || bucketIndex >= meta.labels.length) {
return;
}
const modelName = detail.__modelName || 'Unknown';
if (!dataByModel.has(modelName)) {
dataByModel.set(modelName, new Array(meta.labels.length).fill(0));
}
const bucketValues = dataByModel.get(modelName);
if (metric === 'tokens') {
bucketValues[bucketIndex] += this.extractTotalTokens(detail);
} else {
bucketValues[bucketIndex] += 1;
}
hasData = true;
});
return { labels: meta.labels, dataByModel, hasData };
}
export function buildDailySeriesByModel(metric = 'requests') {
const details = this.collectUsageDetails();
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 = this.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' ? this.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 function buildChartDataForMetric(period = 'day', metric = 'requests') {
const baseSeries = period === 'hour'
? this.buildHourlySeriesByModel(metric)
: this.buildDailySeriesByModel(metric);
const labels = baseSeries?.labels || [];
const dataByModel = baseSeries?.dataByModel || new Map();
const activeSelections = this.getActiveChartLineSelections();
let allSeriesCache = null;
const getAllSeries = () => {
if (allSeriesCache) {
return allSeriesCache;
}
const summed = new Array(labels.length).fill(0);
dataByModel.forEach(values => {
values.forEach((value, idx) => {
summed[idx] = (summed[idx] || 0) + value;
});
});
allSeriesCache = summed;
return summed;
};
const getSeriesForSelection = (selectionValue) => {
if (selectionValue === ALL_MODELS_VALUE) {
return getAllSeries();
}
return dataByModel.get(selectionValue) || new Array(labels.length).fill(0);
};
const datasets = activeSelections.map(selection => {
const values = getSeriesForSelection(selection.model);
const style = this.chartLineStyles[selection.index % this.chartLineStyles.length] || this.chartLineStyles[0];
const label = selection.model === ALL_MODELS_VALUE
? i18n.t('usage_stats.chart_line_all')
: selection.model;
return {
label,
data: values,
borderColor: style.borderColor,
backgroundColor: style.backgroundColor,
fill: false,
tension: 0.35,
pointBackgroundColor: style.borderColor,
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: values.some(v => v > 0) ? 4 : 3
};
});
return { labels, datasets };
}
// 统一格式化小时标签
export function formatHourLabel(date) {
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) {
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 extractTotalTokens(detail) {
const tokens = detail?.tokens || {};
if (typeof tokens.total_tokens === 'number') {
return tokens.total_tokens;
}
const tokenKeys = ['input_tokens', 'output_tokens', 'reasoning_tokens', 'cached_tokens'];
return tokenKeys.reduce((sum, key) => {
const value = tokens[key];
return sum + (typeof value === 'number' ? value : 0);
}, 0);
}
export function formatUsd(value) {
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 calculateCostData(prices = null, usage = null, period = 'day') {
const priceTable = prices || this.modelPrices || {};
const usagePayload = usage || this.currentUsageData;
const entries = Object.entries(priceTable || {});
const result = { totalCost: 0, labels: [], datasets: [] };
if (!entries.length || !usagePayload) {
return result;
}
const details = this.collectUsageDetailsFromUsage(usagePayload);
if (!details.length) {
return result;
}
const normalizedDetails = details.map(detail => {
const modelName = detail.__modelName || 'Unknown';
const price = priceTable[modelName];
if (!price) {
return null;
}
const tokens = detail?.tokens || {};
const promptTokens = Number(tokens.input_tokens) || 0;
const completionTokens = Number(tokens.output_tokens) || 0;
const promptCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0);
const completionCost = (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
const detailCost = promptCost + completionCost;
const parsedTimestamp = Date.parse(detail.timestamp);
if (!Number.isFinite(detailCost) || detailCost <= 0 || Number.isNaN(parsedTimestamp)) {
return null;
}
return { modelName, cost: detailCost, timestamp: parsedTimestamp };
}).filter(Boolean);
if (!normalizedDetails.length) {
return result;
}
const totalCost = normalizedDetails.reduce((sum, item) => sum + item.cost, 0);
if (period === 'hour') {
const meta = this.createHourlyBucketMeta();
const dataByModel = new Map();
normalizedDetails.forEach(({ modelName, cost, timestamp }) => {
const normalized = new Date(timestamp);
normalized.setMinutes(0, 0, 0);
const bucketStart = normalized.getTime();
if (bucketStart < meta.earliestTime || bucketStart > meta.lastBucketTime) {
return;
}
const bucketIndex = Math.floor((bucketStart - meta.earliestTime) / meta.bucketSize);
if (bucketIndex < 0 || bucketIndex >= meta.labels.length) {
return;
}
if (!dataByModel.has(modelName)) {
dataByModel.set(modelName, new Array(meta.labels.length).fill(0));
}
const bucketValues = dataByModel.get(modelName);
bucketValues[bucketIndex] += cost;
});
const datasets = [];
dataByModel.forEach((series, modelName) => {
datasets.push({ label: modelName, data: series.map(value => Number(value.toFixed(4))) });
});
return { totalCost, labels: meta.labels, datasets };
}
const labelSet = new Set();
const costByModelDay = new Map();
normalizedDetails.forEach(({ modelName, cost, timestamp }) => {
const dayLabel = this.formatDayLabel(new Date(timestamp));
if (!dayLabel) {
return;
}
if (!costByModelDay.has(modelName)) {
costByModelDay.set(modelName, new Map());
}
const dayMap = costByModelDay.get(modelName);
dayMap.set(dayLabel, (dayMap.get(dayLabel) || 0) + cost);
labelSet.add(dayLabel);
});
const labels = Array.from(labelSet).sort();
const datasets = [];
costByModelDay.forEach((dayMap, modelName) => {
const series = labels.map(label => Number((dayMap.get(label) || 0).toFixed(4)));
datasets.push({ label: modelName, data: series });
});
return { totalCost, labels, datasets };
}
export function setCostChartPlaceholder(messageKey = null) {
const placeholder = document.getElementById('cost-chart-placeholder');
const canvas = document.getElementById('cost-chart');
if (!placeholder || !canvas) {
return;
}
if (messageKey) {
placeholder.textContent = i18n.t(messageKey);
placeholder.style.display = 'flex';
canvas.style.display = 'none';
} else {
placeholder.style.display = 'none';
canvas.style.display = 'block';
}
}
export function destroyCostChart() {
if (this.costChart) {
this.costChart.destroy();
this.costChart = null;
}
}
export function initializeCostChart(costData, period = 'day') {
const canvas = document.getElementById('cost-chart');
if (!canvas) {
return;
}
this.destroyCostChart();
const datasets = (costData.datasets || []).map((dataset, index) => {
const style = this.chartLineStyles[index % this.chartLineStyles.length] || this.chartLineStyles[0];
return {
...dataset,
borderColor: style.borderColor,
backgroundColor: style.backgroundColor || 'rgba(59, 130, 246, 0.15)',
fill: true,
tension: 0.35,
pointBackgroundColor: style.borderColor,
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: dataset.data.some(v => v > 0) ? 4 : 3
};
});
this.costChart = new Chart(canvas, {
type: 'line',
data: {
labels: costData.labels || [],
datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: true,
position: 'top',
align: 'start',
labels: {
usePointStyle: true
}
},
tooltip: {
callbacks: {
label: context => {
const label = context.dataset.label || '';
const value = Number(context.parsed.y) || 0;
return `${label}: ${this.formatUsd(value)}`;
}
}
}
},
scales: {
x: {
title: {
display: true,
text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day')
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: i18n.t('usage_stats.cost_axis_label')
},
ticks: {
callback: (value) => this.formatUsd(value).replace('$', '')
}
}
},
elements: {
line: {
borderWidth: 2
},
point: {
borderWidth: 2
}
}
}
});
this.setCostChartPlaceholder(null);
}
export function getCostChartPeriod() {
const costHourActive = document.getElementById('cost-hour-btn')?.classList.contains('active');
return costHourActive ? 'hour' : 'day';
}
export function updateCostSummaryAndChart(usage = null, period = null) {
this.ensureModelPriceState();
const totalCostEl = document.getElementById('total-cost');
const hasPrices = Object.keys(this.modelPrices || {}).length > 0;
const usagePayload = usage || this.currentUsageData;
const resolvedPeriod = period || this.getCostChartPeriod();
if (!hasPrices) {
if (totalCostEl) {
totalCostEl.textContent = '--';
}
this.destroyCostChart();
this.setCostChartPlaceholder('usage_stats.cost_need_price');
return;
}
if (!usagePayload) {
if (totalCostEl) {
totalCostEl.textContent = '--';
}
this.destroyCostChart();
this.setCostChartPlaceholder('usage_stats.cost_need_usage');
return;
}
const costData = this.calculateCostData(this.modelPrices, usagePayload, resolvedPeriod);
if (totalCostEl) {
totalCostEl.textContent = this.formatUsd(costData.totalCost);
}
if (!costData.labels.length || !costData.datasets.length) {
this.destroyCostChart();
this.setCostChartPlaceholder('usage_stats.cost_no_data');
return;
}
this.initializeCostChart(costData, resolvedPeriod);
}
// 初始化图表
export function initializeCharts() {
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
const costHourActive = document.getElementById('cost-hour-btn')?.classList.contains('active');
this.initializeRequestsChart(requestsHourActive ? 'hour' : 'day');
this.initializeTokensChart(tokensHourActive ? 'hour' : 'day');
this.updateCostSummaryAndChart(this.currentUsageData, costHourActive ? 'hour' : 'day');
}
// 初始化请求趋势图表
export function initializeRequestsChart(period = 'day') {
const ctx = document.getElementById('requests-chart');
if (!ctx) return;
// 销毁现有图表
if (this.requestsChart) {
this.requestsChart.destroy();
}
const data = this.getRequestsChartData(period);
this.requestsChart = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: true,
position: 'top',
align: 'start',
labels: {
usePointStyle: true
}
}
},
scales: {
x: {
title: {
display: true,
text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day')
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: i18n.t('usage_stats.requests_count')
}
}
},
elements: {
line: {
tension: 0.35,
borderWidth: 2
},
point: {
borderWidth: 2,
radius: 4
}
}
}
});
}
// 初始化Token使用趋势图表
export function initializeTokensChart(period = 'day') {
const ctx = document.getElementById('tokens-chart');
if (!ctx) return;
// 销毁现有图表
if (this.tokensChart) {
this.tokensChart.destroy();
}
const data = this.getTokensChartData(period);
this.tokensChart = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: true,
position: 'top',
align: 'start',
labels: {
usePointStyle: true
}
}
},
scales: {
x: {
title: {
display: true,
text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day')
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: i18n.t('usage_stats.tokens_count')
}
}
},
elements: {
line: {
tension: 0.35,
borderWidth: 2
},
point: {
borderWidth: 2,
radius: 4
}
}
}
});
}
// 获取请求图表数据
export function getRequestsChartData(period) {
if (!this.currentUsageData) {
return { labels: [], datasets: [] };
}
return this.buildChartDataForMetric(period, 'requests');
}
// 获取Token图表数据
export function getTokensChartData(period) {
if (!this.currentUsageData) {
return { labels: [], datasets: [] };
}
return this.buildChartDataForMetric(period, 'tokens');
}
// 切换请求图表时间周期
export function switchRequestsPeriod(period) {
// 更新按钮状态
document.getElementById('requests-hour-btn').classList.toggle('active', period === 'hour');
document.getElementById('requests-day-btn').classList.toggle('active', period === 'day');
// 更新图表数据
if (this.requestsChart) {
const newData = this.getRequestsChartData(period);
this.requestsChart.data = newData;
this.requestsChart.options.scales.x.title.text = i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day');
this.requestsChart.update();
}
}
// 切换Token图表时间周期
export function switchTokensPeriod(period) {
// 更新按钮状态
document.getElementById('tokens-hour-btn').classList.toggle('active', period === 'hour');
document.getElementById('tokens-day-btn').classList.toggle('active', period === 'day');
// 更新图表数据
if (this.tokensChart) {
const newData = this.getTokensChartData(period);
this.tokensChart.data = newData;
this.tokensChart.options.scales.x.title.text = i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day');
this.tokensChart.update();
}
}
export function switchCostPeriod(period) {
if (period !== 'hour' && period !== 'day') {
return;
}
const hourBtn = document.getElementById('cost-hour-btn');
const dayBtn = document.getElementById('cost-day-btn');
if (hourBtn && dayBtn) {
hourBtn.classList.toggle('active', period === 'hour');
dayBtn.classList.toggle('active', period === 'day');
}
this.updateCostSummaryAndChart(this.currentUsageData, period);
}
// 更新API详细统计表格
export function updateApiStatsTable(data) {
const container = document.getElementById('api-stats-table');
if (!container) return;
const apis = data.apis || {};
if (Object.keys(apis).length === 0) {
container.innerHTML = `${i18n.t('usage_stats.no_data')}
`;
return;
}
const hasPrices = Object.keys(this.modelPrices || {}).length > 0;
const calculateEndpointCost = (apiData) => {
if (!hasPrices) return 0;
let cost = 0;
const models = apiData.models || {};
Object.entries(models).forEach(([modelName, modelData]) => {
const price = this.modelPrices?.[modelName];
if (!price) return;
const details = Array.isArray(modelData.details) ? modelData.details : [];
details.forEach(detail => {
const tokens = detail?.tokens || {};
const promptTokens = Number(tokens.input_tokens) || 0;
const completionTokens = Number(tokens.output_tokens) || 0;
const detailCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0)
+ (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
if (Number.isFinite(detailCost) && detailCost > 0) {
cost += detailCost;
}
});
});
return Number(cost.toFixed(4));
};
let tableHtml = `
| ${i18n.t('usage_stats.api_endpoint')} |
${i18n.t('usage_stats.requests_count')} |
${i18n.t('usage_stats.tokens_count')} |
${i18n.t('usage_stats.total_cost')} |
${i18n.t('usage_stats.models')} |
`;
Object.entries(apis).forEach(([endpoint, apiData]) => {
const totalRequests = apiData.total_requests || 0;
const endpointCost = calculateEndpointCost(apiData);
const displayEndpoint = (this.maskUsageSensitiveValue
? this.maskUsageSensitiveValue(endpoint)
: (endpoint ?? '')) || '-';
const safeEndpoint = this.escapeHtml
? this.escapeHtml(displayEndpoint)
: displayEndpoint;
// 构建模型详情
let modelsHtml = '';
if (apiData.models && Object.keys(apiData.models).length > 0) {
modelsHtml = '';
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
const safeModel = this.escapeHtml ? this.escapeHtml(modelName || '') : (modelName || '');
const modelRequests = modelData.total_requests ?? 0;
const modelTokens = this.formatTokensInMillions(modelData.total_tokens ?? 0);
modelsHtml += `
${safeModel}
${modelRequests} 请求 / ${modelTokens} tokens
`;
});
modelsHtml += '
';
}
tableHtml += `
| ${safeEndpoint} |
${totalRequests} |
${this.formatTokensInMillions(apiData.total_tokens || 0)} |
${hasPrices && endpointCost > 0 ? this.formatUsd(endpointCost) : '--'} |
${modelsHtml || '-'} |
`;
});
tableHtml += '
';
container.innerHTML = tableHtml;
}
export const usageModule = {
getKeyStats,
loadUsageStats,
updateUsageOverview,
maskUsageSensitiveValue,
getModelNamesFromUsage,
getChartLineMaxCount,
getVisibleChartLineCount,
ensureChartLineSelectionLength,
updateChartLineControlsUI,
setChartLineVisibleCount,
changeChartLineCount,
removeChartLine,
updateChartLineSelectors,
handleChartLineSelectionChange,
refreshChartsForSelections,
getActiveChartLineSelections,
collectUsageDetailsFromUsage,
collectUsageDetails,
migrateLegacyModelPrices,
ensureModelPriceState,
loadModelPricesFromStorage,
persistModelPrices,
renderModelPriceOptions,
renderSavedModelPrices,
prefillModelPriceInputs,
normalizePriceValue,
handleModelPriceSubmit,
handleModelPriceReset,
calculateTokenBreakdown,
calculateRecentPerMinuteRates,
createHourlyBucketMeta,
buildHourlySeriesByModel,
buildDailySeriesByModel,
buildChartDataForMetric,
formatHourLabel,
formatTokensInMillions,
formatPerMinuteValue,
formatDayLabel,
extractTotalTokens,
formatUsd,
calculateCostData,
getCostChartPeriod,
setCostChartPlaceholder,
destroyCostChart,
initializeCostChart,
updateCostSummaryAndChart,
initializeCharts,
initializeRequestsChart,
initializeTokensChart,
getRequestsChartData,
getTokensChartData,
switchRequestsPeriod,
switchTokensPeriod,
switchCostPeriod,
updateApiStatsTable,
registerUsageListeners
};
// 订阅全局事件,基于配置加载结果渲染使用统计
export function registerUsageListeners() {
if (!this.events || typeof this.events.on !== 'function') {
return;
}
this.events.on('data:config-loaded', (event) => {
const detail = event?.detail || {};
const usageData = detail.usageData || null;
this.loadUsageStats(usageData);
});
}