diff --git a/src/components/usage/RequestEventsDetailsCard.tsx b/src/components/usage/RequestEventsDetailsCard.tsx
index 6049af2..5eb18f8 100644
--- a/src/components/usage/RequestEventsDetailsCard.tsx
+++ b/src/components/usage/RequestEventsDetailsCard.tsx
@@ -12,7 +12,8 @@ import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver
import {
collectUsageDetails,
extractTotalTokens,
- normalizeAuthIndex
+ formatDurationMs,
+ normalizeAuthIndex,
} from '@/utils/usage';
import { downloadBlob } from '@/utils/download';
import styles from '@/pages/UsagePage.module.scss';
@@ -31,6 +32,7 @@ type RequestEventRow = {
sourceType: string;
authIndex: string;
failed: boolean;
+ latencyMs: number | null;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
@@ -68,7 +70,7 @@ export function RequestEventsDetailsCard({
claudeConfigs,
codexConfigs,
vertexConfigs,
- openaiProviders
+ openaiProviders,
}: RequestEventsDetailsCardProps) {
const { t, i18n } = useTranslation();
@@ -91,7 +93,7 @@ export function RequestEventsDetailsCard({
if (!key) return;
map.set(key, {
name: file.name || key,
- type: (file.type || file.provider || '').toString()
+ type: (file.type || file.provider || '').toString(),
});
});
setAuthFileMap(map);
@@ -131,7 +133,12 @@ export function RequestEventsDetailsCard({
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
? '-'
: String(authIndexRaw);
- const sourceInfo = resolveSourceDisplay(sourceRaw, authIndexRaw, sourceInfoMap, authFileMap);
+ const sourceInfo = resolveSourceDisplay(
+ sourceRaw,
+ authIndexRaw,
+ sourceInfoMap,
+ authFileMap
+ );
const source = sourceInfo.displayName;
const sourceType = sourceInfo.type;
const model = String(detail.__modelName ?? '').trim() || '-';
@@ -146,6 +153,12 @@ export function RequestEventsDetailsCard({
toNumber(detail.tokens?.total_tokens),
extractTotalTokens(detail)
);
+ const latencyMs =
+ typeof detail.latency_ms === 'number' &&
+ Number.isFinite(detail.latency_ms) &&
+ detail.latency_ms >= 0
+ ? detail.latency_ms
+ : null;
return {
id: `${timestamp}-${model}-${sourceRaw || source}-${authIndex}-${index}`,
@@ -158,23 +171,26 @@ export function RequestEventsDetailsCard({
sourceType,
authIndex,
failed: detail.failed === true,
+ latencyMs,
inputTokens,
outputTokens,
reasoningTokens,
cachedTokens,
- totalTokens
+ totalTokens,
};
})
.sort((a, b) => b.timestampMs - a.timestampMs);
}, [authFileMap, i18n.language, sourceInfoMap, usage]);
+ const hasLatencyData = useMemo(() => rows.some((row) => row.latencyMs !== null), [rows]);
+
const modelOptions = useMemo(
() => [
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(new Set(rows.map((row) => row.model))).map((model) => ({
value: model,
- label: model
- }))
+ label: model,
+ })),
],
[rows, t]
);
@@ -184,8 +200,8 @@ export function RequestEventsDetailsCard({
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(new Set(rows.map((row) => row.source))).map((source) => ({
value: source,
- label: source
- }))
+ label: source,
+ })),
],
[rows, t]
);
@@ -195,8 +211,8 @@ export function RequestEventsDetailsCard({
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(new Set(rows.map((row) => row.authIndex))).map((authIndex) => ({
value: authIndex,
- label: authIndex
- }))
+ label: authIndex,
+ })),
],
[rows, t]
);
@@ -223,8 +239,10 @@ export function RequestEventsDetailsCard({
const filteredRows = useMemo(
() =>
rows.filter((row) => {
- const modelMatched = effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter;
- const sourceMatched = effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter;
+ const modelMatched =
+ effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter;
+ const sourceMatched =
+ effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter;
const authIndexMatched =
effectiveAuthIndexFilter === ALL_FILTER || row.authIndex === effectiveAuthIndexFilter;
return modelMatched && sourceMatched && authIndexMatched;
@@ -232,10 +250,7 @@ export function RequestEventsDetailsCard({
[effectiveAuthIndexFilter, effectiveModelFilter, effectiveSourceFilter, rows]
);
- const renderedRows = useMemo(
- () => filteredRows.slice(0, MAX_RENDERED_EVENTS),
- [filteredRows]
- );
+ const renderedRows = useMemo(() => filteredRows.slice(0, MAX_RENDERED_EVENTS), [filteredRows]);
const hasActiveFilters =
effectiveModelFilter !== ALL_FILTER ||
@@ -258,11 +273,12 @@ export function RequestEventsDetailsCard({
'source_raw',
'auth_index',
'result',
+ ...(hasLatencyData ? ['latency_ms'] : []),
'input_tokens',
'output_tokens',
'reasoning_tokens',
'cached_tokens',
- 'total_tokens'
+ 'total_tokens',
];
const csvRows = filteredRows.map((row) =>
@@ -273,11 +289,12 @@ export function RequestEventsDetailsCard({
row.sourceRaw,
row.authIndex,
row.failed ? 'failed' : 'success',
+ ...(hasLatencyData ? [row.latencyMs ?? ''] : []),
row.inputTokens,
row.outputTokens,
row.reasoningTokens,
row.cachedTokens,
- row.totalTokens
+ row.totalTokens,
]
.map((value) => encodeCsv(value))
.join(',')
@@ -287,7 +304,7 @@ export function RequestEventsDetailsCard({
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
downloadBlob({
filename: `usage-events-${fileTime}.csv`,
- blob: new Blob([content], { type: 'text/csv;charset=utf-8' })
+ blob: new Blob([content], { type: 'text/csv;charset=utf-8' }),
});
};
@@ -301,20 +318,21 @@ export function RequestEventsDetailsCard({
source_raw: row.sourceRaw,
auth_index: row.authIndex,
failed: row.failed,
+ ...(hasLatencyData && row.latencyMs !== null ? { latency_ms: row.latencyMs } : {}),
tokens: {
input_tokens: row.inputTokens,
output_tokens: row.outputTokens,
reasoning_tokens: row.reasoningTokens,
cached_tokens: row.cachedTokens,
- total_tokens: row.totalTokens
- }
+ total_tokens: row.totalTokens,
+ },
}));
const content = JSON.stringify(payload, null, 2);
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
downloadBlob({
filename: `usage-events-${fileTime}.json`,
- blob: new Blob([content], { type: 'application/json;charset=utf-8' })
+ blob: new Blob([content], { type: 'application/json;charset=utf-8' }),
});
};
@@ -412,7 +430,7 @@ export function RequestEventsDetailsCard({
{t('usage_stats.request_events_limit_hint', {
shown: MAX_RENDERED_EVENTS,
- total: filteredRows.length
+ total: filteredRows.length,
})}
)}
@@ -427,6 +445,7 @@ export function RequestEventsDetailsCard({
{t('usage_stats.request_events_source')} |
{t('usage_stats.request_events_auth_index')} |
{t('usage_stats.request_events_result')} |
+ {hasLatencyData && {t('usage_stats.time')} | }
{t('usage_stats.input_tokens')} |
{t('usage_stats.output_tokens')} |
{t('usage_stats.reasoning_tokens')} |
@@ -452,11 +471,16 @@ export function RequestEventsDetailsCard({
{row.failed ? t('stats.failure') : t('stats.success')}
|
+ {hasLatencyData && {formatDurationMs(row.latencyMs)} | }
{row.inputTokens.toLocaleString()} |
{row.outputTokens.toLocaleString()} |
{row.reasoningTokens.toLocaleString()} |
diff --git a/src/components/usage/StatCards.tsx b/src/components/usage/StatCards.tsx
index 9d80b74..cc87901 100644
--- a/src/components/usage/StatCards.tsx
+++ b/src/components/usage/StatCards.tsx
@@ -1,15 +1,22 @@
import { useMemo, type CSSProperties, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Line } from 'react-chartjs-2';
-import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
+import {
+ IconDiamond,
+ IconDollarSign,
+ IconSatellite,
+ IconTimer,
+ IconTrendingUp,
+} from '@/components/ui/icons';
import {
formatCompactNumber,
+ formatDurationMs,
formatPerMinuteValue,
formatUsd,
calculateCost,
collectUsageDetails,
extractTotalTokens,
- type ModelPrice
+ type ModelPrice,
} from '@/utils/usage';
import { sparklineOptions } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './hooks/useUsageData';
@@ -47,11 +54,12 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
const hasPrices = Object.keys(modelPrices).length > 0;
- const { tokenBreakdown, rateStats, totalCost } = useMemo(() => {
+ const { tokenBreakdown, rateStats, totalCost, latencyStats } = useMemo(() => {
const empty = {
tokenBreakdown: { cachedTokens: 0, reasoningTokens: 0 },
rateStats: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 },
- totalCost: 0
+ totalCost: 0,
+ latencyStats: { averageMs: null as number | null, sampleCount: 0 },
};
if (!usage) return empty;
@@ -61,6 +69,8 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
let cachedTokens = 0;
let reasoningTokens = 0;
let totalCost = 0;
+ let latencyTotalMs = 0;
+ let latencySampleCount = 0;
const now = nowMs;
const windowMinutes = 30;
@@ -78,9 +88,22 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
if (typeof tokens.reasoning_tokens === 'number') {
reasoningTokens += tokens.reasoning_tokens;
}
+ if (
+ typeof detail.latency_ms === 'number' &&
+ Number.isFinite(detail.latency_ms) &&
+ detail.latency_ms >= 0
+ ) {
+ latencyTotalMs += detail.latency_ms;
+ latencySampleCount += 1;
+ }
const timestamp = detail.__timestampMs ?? 0;
- if (hasValidNow && Number.isFinite(timestamp) && timestamp >= windowStart && timestamp <= now) {
+ if (
+ hasValidNow &&
+ Number.isFinite(timestamp) &&
+ timestamp >= windowStart &&
+ timestamp <= now
+ ) {
requestCount += 1;
tokenCount += extractTotalTokens(detail);
}
@@ -98,9 +121,13 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
tpm: tokenCount / denominator,
windowMinutes,
requestCount,
- tokenCount
+ tokenCount,
+ },
+ totalCost,
+ latencyStats: {
+ averageMs: latencySampleCount > 0 ? latencyTotalMs / latencySampleCount : null,
+ sampleCount: latencySampleCount,
},
- totalCost
};
}, [hasPrices, modelPrices, nowMs, usage]);
@@ -123,9 +150,15 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
+ {latencyStats.sampleCount > 0 && (
+
+ {t('usage_stats.avg_time')}:{' '}
+ {loading ? '-' : formatDurationMs(latencyStats.averageMs)}
+
+ )}
>
),
- trend: sparklines.requests
+ trend: sparklines.requests,
},
{
key: 'tokens',
@@ -138,14 +171,16 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
meta: (
<>
- {t('usage_stats.cached_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
+ {t('usage_stats.cached_tokens')}:{' '}
+ {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
- {t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
+ {t('usage_stats.reasoning_tokens')}:{' '}
+ {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
>
),
- trend: sparklines.tokens
+ trend: sparklines.tokens,
},
{
key: 'rpm',
@@ -157,10 +192,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
meta: (
- {t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()}
+ {t('usage_stats.total_requests')}:{' '}
+ {loading ? '-' : rateStats.requestCount.toLocaleString()}
),
- trend: sparklines.rpm
+ trend: sparklines.rpm,
},
{
key: 'tpm',
@@ -172,10 +208,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
meta: (
- {t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
+ {t('usage_stats.total_tokens')}:{' '}
+ {loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
),
- trend: sparklines.tpm
+ trend: sparklines.tpm,
},
{
key: 'cost',
@@ -188,7 +225,8 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
meta: (
<>
- {t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
+ {t('usage_stats.total_tokens')}:{' '}
+ {loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
{!hasPrices && (
@@ -197,8 +235,8 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
)}
>
),
- trend: hasPrices ? sparklines.cost : null
- }
+ trend: hasPrices ? sparklines.cost : null,
+ },
];
return (
@@ -211,7 +249,7 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
{
'--accent': card.accent,
'--accent-soft': card.accentSoft,
- '--accent-border': card.accentBorder
+ '--accent-border': card.accentBorder,
} as CSSProperties
}
>
@@ -225,7 +263,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
{card.meta && {card.meta}
}
{card.trend ? (
-
+
) : (
)}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index a584ae9..24b6e65 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -1023,6 +1023,8 @@
"request_events_source": "Source",
"request_events_auth_index": "Auth Index",
"request_events_result": "Result",
+ "time": "Time",
+ "avg_time": "Avg Time",
"request_events_empty_title": "No request events",
"request_events_empty_desc": "No request details are available for the selected time range.",
"request_events_no_result_title": "No matching events",
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index 450688b..e837d0e 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -1026,6 +1026,8 @@
"request_events_source": "Источник",
"request_events_auth_index": "Auth Index",
"request_events_result": "Результат",
+ "time": "Время",
+ "avg_time": "Среднее время",
"request_events_empty_title": "События запросов отсутствуют",
"request_events_empty_desc": "Нет деталей запросов для выбранного диапазона времени.",
"request_events_no_result_title": "Совпадений не найдено",
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
index bb2ce2b..79e02af 100644
--- a/src/i18n/locales/zh-CN.json
+++ b/src/i18n/locales/zh-CN.json
@@ -1023,6 +1023,8 @@
"request_events_source": "来源",
"request_events_auth_index": "认证索引",
"request_events_result": "结果",
+ "time": "耗时",
+ "avg_time": "平均耗时",
"request_events_empty_title": "暂无请求事件",
"request_events_empty_desc": "当前时间范围内暂无可用的请求明细数据。",
"request_events_no_result_title": "没有匹配结果",
diff --git a/src/utils/usage.ts b/src/utils/usage.ts
index 07abb00..a8a8718 100644
--- a/src/utils/usage.ts
+++ b/src/utils/usage.ts
@@ -39,6 +39,7 @@ export interface UsageDetail {
timestamp: string;
source: string;
auth_index: number;
+ latency_ms?: number;
tokens: {
input_tokens: number;
output_tokens: number;
@@ -66,7 +67,10 @@ export interface ApiStats {
failureCount: number;
totalTokens: number;
totalCost: number;
- models: Record
;
+ models: Record<
+ string,
+ { requests: number; successCount: number; failureCount: number; tokens: number }
+ >;
}
export type UsageTimeRange = '7h' | '24h' | '7d' | 'all';
@@ -77,7 +81,7 @@ const USAGE_ENDPOINT_METHOD_REGEX = /^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s
const USAGE_TIME_RANGE_MS: Record, number> = {
'7h': 7 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
- '7d': 7 * 24 * 60 * 60 * 1000
+ '7d': 7 * 24 * 60 * 60 * 1000,
};
const isRecord = (value: unknown): value is Record =>
@@ -100,17 +104,21 @@ const createUsageSummary = (): UsageSummary => ({
totalRequests: 0,
successCount: 0,
failureCount: 0,
- totalTokens: 0
+ totalTokens: 0,
});
const toUsageSummaryFields = (summary: UsageSummary) => ({
total_requests: summary.totalRequests,
success_count: summary.successCount,
failure_count: summary.failureCount,
- total_tokens: summary.totalTokens
+ total_tokens: summary.totalTokens,
});
-export function filterUsageByTimeRange(usageData: T, range: UsageTimeRange, nowMs: number = Date.now()): T {
+export function filterUsageByTimeRange(
+ usageData: T,
+ range: UsageTimeRange,
+ nowMs: number = Date.now()
+): T {
if (range === 'all') {
return usageData;
}
@@ -180,7 +188,7 @@ export function filterUsageByTimeRange(usageData: T, range: UsageTimeRange, n
filteredModels[modelName] = {
...modelEntry,
...toUsageSummaryFields(modelSummary),
- details: filteredDetails
+ details: filteredDetails,
};
hasModelData = true;
@@ -197,7 +205,7 @@ export function filterUsageByTimeRange(usageData: T, range: UsageTimeRange, n
filteredApis[apiName] = {
...apiEntry,
...toUsageSummaryFields(apiSummary),
- models: filteredModels
+ models: filteredModels,
};
totalSummary.totalRequests += apiSummary.totalRequests;
@@ -209,7 +217,7 @@ export function filterUsageByTimeRange(usageData: T, range: UsageTimeRange, n
return {
...usageRecord,
...toUsageSummaryFields(totalSummary),
- apis: filteredApis
+ apis: filteredApis,
} as T;
}
@@ -309,7 +317,8 @@ export function normalizeUsageSourceId(
value: unknown,
masker: (val: string) => string = maskApiKey
): string {
- const raw = typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);
+ const raw =
+ typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);
const trimmed = raw.trim();
if (!trimmed) return '';
@@ -325,7 +334,10 @@ export function normalizeUsageSourceId(
return `${USAGE_SOURCE_PREFIX_TEXT}${trimmed}`;
}
-export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?: string }): string[] {
+export function buildCandidateUsageSourceIds(input: {
+ apiKey?: string;
+ prefix?: string;
+}): string[] {
const result: string[] = [];
const prefix = input.prefix?.trim();
@@ -345,7 +357,10 @@ export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?:
/**
* 对使用数据中的敏感字段进行遮罩
*/
-export function maskUsageSensitiveValue(value: unknown, masker: (val: string) => string = maskApiKey): string {
+export function maskUsageSensitiveValue(
+ value: unknown,
+ masker: (val: string) => string = maskApiKey
+): string {
if (value === null || value === undefined) {
return '';
}
@@ -357,12 +372,20 @@ export function maskUsageSensitiveValue(value: unknown, masker: (val: string) =>
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)}`);
+ 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 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;
+ 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) {
@@ -436,7 +459,7 @@ export function formatUsd(value: number): string {
const fixed = num.toFixed(2);
const parts = Number(fixed).toLocaleString(undefined, {
minimumFractionDigits: 2,
- maximumFractionDigits: 2
+ maximumFractionDigits: 2,
});
return `$${parts}`;
}
@@ -491,10 +514,12 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
const timestamp = detailRaw.timestamp;
const timestampMs = Date.parse(timestamp);
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
+ const latencyMs = extractLatencyMs(detailRaw);
details.push({
timestamp,
source: normalizeSource(detailRaw.source),
auth_index: detailRaw.auth_index as unknown as number,
+ latency_ms: latencyMs ?? undefined,
tokens: tokensRaw as unknown as UsageDetail['tokens'],
failed: detailRaw.failed === true,
__modelName: modelName,
@@ -562,10 +587,12 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail
const timestamp = detailRaw.timestamp;
const timestampMs = Date.parse(timestamp);
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
+ const latencyMs = extractLatencyMs(detailRaw);
details.push({
timestamp,
source: normalizeSource(detailRaw.source),
auth_index: detailRaw.auth_index as unknown as number,
+ latency_ms: latencyMs ?? undefined,
tokens: tokensRaw as unknown as UsageDetail['tokens'],
failed: detailRaw.failed === true,
__modelName: modelName,
@@ -605,6 +632,42 @@ export function extractTotalTokens(detail: unknown): number {
return inputTokens + outputTokens + reasoningTokens + cachedTokens;
}
+/**
+ * 从单条明细提取耗时(毫秒)
+ */
+export function extractLatencyMs(detail: unknown): number | null {
+ const record = isRecord(detail) ? detail : null;
+ const rawValue = record?.latency_ms;
+ const parsed = Number(rawValue);
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ return null;
+ }
+ return parsed;
+}
+
+/**
+ * 格式化耗时显示
+ */
+export function formatDurationMs(value: number | null | undefined): string {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ return '--';
+ }
+
+ if (parsed < 1000) {
+ return `${Math.round(parsed)} ms`;
+ }
+
+ const seconds = parsed / 1000;
+ if (seconds < 10) {
+ return `${seconds.toFixed(2).replace(/\.?0+$/, '')} s`;
+ }
+ if (seconds < 100) {
+ return `${seconds.toFixed(1).replace(/\.?0+$/, '')} s`;
+ }
+ return `${Math.round(seconds)} s`;
+}
+
/**
* 计算 token 分类统计
*/
@@ -652,7 +715,9 @@ export function calculateRecentPerMinuteRates(
details.forEach((detail) => {
const timestamp =
- typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
+ typeof detail.__timestampMs === 'number'
+ ? detail.__timestampMs
+ : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) {
return;
}
@@ -666,7 +731,7 @@ export function calculateRecentPerMinuteRates(
tpm: tokenCount / denominator,
windowMinutes: effectiveWindow,
requestCount,
- tokenCount
+ tokenCount,
};
}
@@ -694,7 +759,10 @@ export function getModelNamesFromUsage(usageData: unknown): string[] {
/**
* 计算成本数据
*/
-export function calculateCost(detail: UsageDetail, modelPrices: Record): number {
+export function calculateCost(
+ detail: UsageDetail,
+ modelPrices: Record
+): number {
const modelName = detail.__modelName || '';
const price = modelPrices[modelName];
if (!price) {
@@ -707,7 +775,9 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record 0 ? total : 0;
}
@@ -724,7 +795,10 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record): number {
+export function calculateTotalCost(
+ usageData: unknown,
+ modelPrices: Record
+): number {
const details = collectUsageDetails(usageData);
if (!details.length || !Object.keys(modelPrices).length) {
return 0;
@@ -756,7 +830,11 @@ export function loadModelPrices(): Record {
const completionRaw = Number(priceRecord?.completion);
const cacheRaw = Number(priceRecord?.cache);
- if (!Number.isFinite(promptRaw) && !Number.isFinite(completionRaw) && !Number.isFinite(cacheRaw)) {
+ if (
+ !Number.isFinite(promptRaw) &&
+ !Number.isFinite(completionRaw) &&
+ !Number.isFinite(cacheRaw)
+ ) {
return;
}
@@ -772,7 +850,7 @@ export function loadModelPrices(): Record {
normalized[model] = {
prompt,
completion,
- cache
+ cache,
};
});
return normalized;
@@ -798,14 +876,20 @@ export function saveModelPrices(prices: Record): void {
/**
* 获取 API 统计数据
*/
-export function getApiStats(usageData: unknown, modelPrices: Record): ApiStats[] {
+export function getApiStats(
+ usageData: unknown,
+ modelPrices: Record
+): ApiStats[] {
const apis = getApisRecord(usageData);
if (!apis) return [];
const result: ApiStats[] = [];
Object.entries(apis).forEach(([endpoint, apiData]) => {
if (!isRecord(apiData)) return;
- const models: Record = {};
+ const models: Record<
+ string,
+ { requests: number; successCount: number; failureCount: number; tokens: number }
+ > = {};
let derivedSuccessCount = 0;
let derivedFailureCount = 0;
let totalCost = 0;
@@ -849,7 +933,7 @@ export function getApiStats(usageData: unknown, modelPrices: Record): Array<{
+export function getModelStats(
+ usageData: unknown,
+ modelPrices: Record
+): Array<{
model: string;
requests: number;
successCount: number;
@@ -892,7 +979,10 @@ export function getModelStats(usageData: unknown, modelPrices: Record();
+ const modelMap = new Map<
+ string,
+ { requests: number; successCount: number; failureCount: number; tokens: number; cost: number }
+ >();
Object.values(apis).forEach((apiData) => {
if (!isRecord(apiData)) return;
@@ -902,7 +992,13 @@ export function getModelStats(usageData: unknown, modelPrices: Record {
if (!isRecord(modelData)) return;
- const existing = modelMap.get(modelName) || { requests: 0, successCount: 0, failureCount: 0, tokens: 0, cost: 0 };
+ const existing = modelMap.get(modelName) || {
+ requests: 0,
+ successCount: 0,
+ failureCount: 0,
+ tokens: 0,
+ cost: 0,
+ };
existing.requests += Number(modelData.total_requests) || 0;
existing.tokens += Number(modelData.total_tokens) || 0;
@@ -1012,7 +1108,9 @@ export function buildHourlySeriesByModel(
details.forEach((detail) => {
const timestamp =
- typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
+ typeof detail.__timestampMs === 'number'
+ ? detail.__timestampMs
+ : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return;
}
@@ -1069,7 +1167,9 @@ export function buildDailySeriesByModel(
details.forEach((detail) => {
const timestamp =
- typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
+ typeof detail.__timestampMs === 'number'
+ ? detail.__timestampMs
+ : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return;
}
@@ -1092,7 +1192,7 @@ export function buildDailySeriesByModel(
const labels = Array.from(labelsSet).sort();
const dataByModel = new Map();
valuesByModel.forEach((dayMap, modelName) => {
- const series = labels.map(label => dayMap.get(label) || 0);
+ const series = labels.map((label) => dayMap.get(label) || 0);
dataByModel.set(modelName, series);
});
@@ -1103,7 +1203,10 @@ export interface ChartDataset {
label: string;
data: number[];
borderColor: string;
- backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient);
+ backgroundColor:
+ | string
+ | CanvasGradient
+ | ((context: ScriptableContext<'line'>) => string | CanvasGradient);
pointBackgroundColor?: string;
pointBorderColor?: string;
fill: boolean;
@@ -1152,7 +1255,11 @@ const withAlpha = (hex: string, alpha: number) => {
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${clamped})`;
};
-const buildAreaGradient = (context: ScriptableContext<'line'>, baseHex: string, fallback: string) => {
+const buildAreaGradient = (
+ context: ScriptableContext<'line'>,
+ baseHex: string,
+ fallback: string
+) => {
const chart = context.chart;
const ctx = chart.ctx;
const area = chart.chartArea;
@@ -1178,16 +1285,17 @@ export function buildChartData(
selectedModels: string[] = [],
options: { hourWindowHours?: number } = {}
): ChartData {
- const baseSeries = period === 'hour'
- ? buildHourlySeriesByModel(usageData, metric, options.hourWindowHours)
- : buildDailySeriesByModel(usageData, metric);
+ const baseSeries =
+ period === 'hour'
+ ? buildHourlySeriesByModel(usageData, metric, options.hourWindowHours)
+ : 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 => {
+ dataByModel.forEach((values) => {
values.forEach((value, idx) => {
summed[idx] = (summed[idx] || 0) + value;
});
@@ -1200,7 +1308,9 @@ export function buildChartData(
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 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);
@@ -1215,7 +1325,7 @@ export function buildChartData(
pointBackgroundColor: style.borderColor,
pointBorderColor: style.borderColor,
fill: shouldFill,
- tension: 0.35
+ tension: 0.35,
};
});
@@ -1283,8 +1393,15 @@ export function calculateStatusBarData(
// Filter and bucket the usage details
usageDetails.forEach((detail) => {
const timestamp =
- typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
- if (!Number.isFinite(timestamp) || timestamp <= 0 || timestamp < windowStart || timestamp > now) {
+ typeof detail.__timestampMs === 'number'
+ ? detail.__timestampMs
+ : Date.parse(detail.timestamp);
+ if (
+ !Number.isFinite(timestamp) ||
+ timestamp <= 0 ||
+ timestamp < windowStart ||
+ timestamp > now
+ ) {
return;
}
@@ -1346,7 +1463,7 @@ export function calculateStatusBarData(
blockDetails,
successRate,
totalSuccess,
- totalFailure
+ totalFailure,
};
}
@@ -1364,9 +1481,7 @@ export interface ServiceHealthData {
cols: number;
}
-export function calculateServiceHealthData(
- usageDetails: UsageDetail[]
-): ServiceHealthData {
+export function calculateServiceHealthData(usageDetails: UsageDetail[]): ServiceHealthData {
const ROWS = 7;
const COLS = 96;
const BLOCK_COUNT = ROWS * COLS; // 672
@@ -1386,8 +1501,15 @@ export function calculateServiceHealthData(
usageDetails.forEach((detail) => {
const timestamp =
- typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
- if (!Number.isFinite(timestamp) || timestamp <= 0 || timestamp < windowStart || timestamp > now) {
+ typeof detail.__timestampMs === 'number'
+ ? detail.__timestampMs
+ : Date.parse(detail.timestamp);
+ if (
+ !Number.isFinite(timestamp) ||
+ timestamp <= 0 ||
+ timestamp < windowStart ||
+ timestamp > now
+ ) {
return;
}
@@ -1444,7 +1566,10 @@ export function calculateServiceHealthData(
};
}
-export function computeKeyStats(usageData: unknown, masker: (val: string) => string = maskApiKey): KeyStats {
+export function computeKeyStats(
+ usageData: unknown,
+ masker: (val: string) => string = maskApiKey
+): KeyStats {
const apis = getApisRecord(usageData);
if (!apis) {
return { bySource: {}, byAuthIndex: {} };
@@ -1499,7 +1624,7 @@ export function computeKeyStats(usageData: unknown, masker: (val: string) => str
return {
bySource: sourceStats,
- byAuthIndex: authIndexStats
+ byAuthIndex: authIndexStats,
};
}
@@ -1586,7 +1711,9 @@ export function buildHourlyTokenBreakdown(
details.forEach((detail) => {
const timestamp =
- typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
+ typeof detail.__timestampMs === 'number'
+ ? detail.__timestampMs
+ : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const normalized = new Date(timestamp);
normalized.setMinutes(0, 0, 0);
@@ -1601,9 +1728,10 @@ export function buildHourlyTokenBreakdown(
const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0;
const cached = 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,
+ typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0
);
- const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
+ const reasoning =
+ typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
dataByCategory.input[bucketIndex] += input;
dataByCategory.output[bucketIndex] += output;
@@ -1625,7 +1753,9 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri
details.forEach((detail) => {
const timestamp =
- typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
+ typeof detail.__timestampMs === 'number'
+ ? detail.__timestampMs
+ : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const dayLabel = formatDayLabel(new Date(timestamp));
if (!dayLabel) return;
@@ -1639,9 +1769,10 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri
const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0;
const cached = 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,
+ typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0
);
- const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
+ const reasoning =
+ typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
dayMap[dayLabel].input += input;
dayMap[dayLabel].output += output;
@@ -1699,7 +1830,9 @@ export function buildHourlyCostSeries(
details.forEach((detail) => {
const timestamp =
- typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
+ typeof detail.__timestampMs === 'number'
+ ? detail.__timestampMs
+ : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const normalized = new Date(timestamp);
normalized.setMinutes(0, 0, 0);
@@ -1732,7 +1865,9 @@ export function buildDailyCostSeries(
details.forEach((detail) => {
const timestamp =
- typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
+ typeof detail.__timestampMs === 'number'
+ ? detail.__timestampMs
+ : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const dayLabel = formatDayLabel(new Date(timestamp));
if (!dayLabel) return;