Merge pull request #193 from LucasInsight/feat/request-time-display

feat(usage): show request time in usage stats
This commit is contained in:
Supra4E8C
2026-04-11 13:20:44 +08:00
committed by GitHub
8 changed files with 524 additions and 127 deletions
+49 -11
View File
@@ -1,17 +1,15 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { formatCompactNumber, formatUsd } from '@/utils/usage';
import {
formatCompactNumber,
formatDurationMs,
formatUsd,
type ModelStatsSummary,
} from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export interface ModelStat {
model: string;
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
}
export type ModelStat = ModelStatsSummary;
export interface ModelStatsCardProps {
modelStats: ModelStat[];
@@ -19,7 +17,14 @@ export interface ModelStatsCardProps {
hasPrices: boolean;
}
type SortKey = 'model' | 'requests' | 'tokens' | 'cost' | 'successRate';
type SortKey =
| 'model'
| 'requests'
| 'tokens'
| 'cost'
| 'successRate'
| 'averageLatencyMs'
| 'totalLatencyMs';
type SortDir = 'asc' | 'desc';
interface ModelStatWithRate extends ModelStat {
@@ -48,7 +53,15 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
const dir = sortDir === 'asc' ? 1 : -1;
list.sort((a, b) => {
if (sortKey === 'model') return dir * a.model.localeCompare(b.model);
return dir * ((a[sortKey] as number) - (b[sortKey] as number));
const left = a[sortKey];
const right = b[sortKey];
const leftValid = typeof left === 'number' && Number.isFinite(left);
const rightValid = typeof right === 'number' && Number.isFinite(right);
if (!leftValid && !rightValid) return 0;
if (!leftValid) return 1;
if (!rightValid) return -1;
return dir * (left - right);
});
return list;
}, [modelStats, sortKey, sortDir]);
@@ -95,6 +108,27 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
{t('usage_stats.tokens_count')}{arrow('tokens')}
</button>
</th>
<th
className={styles.sortableHeader}
aria-sort={ariaSort('averageLatencyMs')}
>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('averageLatencyMs')}
>
{t('usage_stats.avg_time')}{arrow('averageLatencyMs')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('totalLatencyMs')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('totalLatencyMs')}
>
{t('usage_stats.total_time')}{arrow('totalLatencyMs')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
<button
type="button"
@@ -131,6 +165,10 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
</span>
</td>
<td>{formatCompactNumber(stat.tokens)}</td>
<td className={styles.durationCell}>
{formatDurationMs(stat.averageLatencyMs)}
</td>
<td className={styles.durationCell}>{formatDurationMs(stat.totalLatencyMs)}</td>
<td>
<span
className={
@@ -10,9 +10,11 @@ import type { AuthFileItem } from '@/types/authFile';
import type { CredentialInfo } from '@/types/sourceInfo';
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
import {
extractLatencyMs,
collectUsageDetails,
extractTotalTokens,
normalizeAuthIndex
formatDurationMs,
normalizeAuthIndex,
} from '@/utils/usage';
import { downloadBlob } from '@/utils/download';
import styles from '@/pages/UsagePage.module.scss';
@@ -31,6 +33,7 @@ type RequestEventRow = {
sourceType: string;
authIndex: string;
failed: boolean;
latencyMs: number | null;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
@@ -68,7 +71,7 @@ export function RequestEventsDetailsCard({
claudeConfigs,
codexConfigs,
vertexConfigs,
openaiProviders
openaiProviders,
}: RequestEventsDetailsCardProps) {
const { t, i18n } = useTranslation();
@@ -91,7 +94,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 +134,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 +154,7 @@ export function RequestEventsDetailsCard({
toNumber(detail.tokens?.total_tokens),
extractTotalTokens(detail)
);
const latencyMs = extractLatencyMs(detail);
return {
id: `${timestamp}-${model}-${sourceRaw || source}-${authIndex}-${index}`,
@@ -158,23 +167,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 +196,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 +207,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 +235,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 +246,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 +269,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 +285,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 +300,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 +314,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 +426,7 @@ export function RequestEventsDetailsCard({
<span className={styles.requestEventsLimitHint}>
{t('usage_stats.request_events_limit_hint', {
shown: MAX_RENDERED_EVENTS,
total: filteredRows.length
total: filteredRows.length,
})}
</span>
)}
@@ -427,6 +441,7 @@ export function RequestEventsDetailsCard({
<th>{t('usage_stats.request_events_source')}</th>
<th>{t('usage_stats.request_events_auth_index')}</th>
<th>{t('usage_stats.request_events_result')}</th>
{hasLatencyData && <th>{t('usage_stats.time')}</th>}
<th>{t('usage_stats.input_tokens')}</th>
<th>{t('usage_stats.output_tokens')}</th>
<th>{t('usage_stats.reasoning_tokens')}</th>
@@ -452,11 +467,18 @@ export function RequestEventsDetailsCard({
</td>
<td>
<span
className={row.failed ? styles.requestEventsResultFailed : styles.requestEventsResultSuccess}
className={
row.failed
? styles.requestEventsResultFailed
: styles.requestEventsResultSuccess
}
>
{row.failed ? t('stats.failure') : t('stats.success')}
</span>
</td>
{hasLatencyData && (
<td className={styles.durationCell}>{formatDurationMs(row.latencyMs)}</td>
)}
<td>{row.inputTokens.toLocaleString()}</td>
<td>{row.outputTokens.toLocaleString()}</td>
<td>{row.reasoningTokens.toLocaleString()}</td>
+52 -20
View File
@@ -1,15 +1,23 @@
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 {
calculateLatencyStatsFromDetails,
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,17 +55,20 @@ 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, totalMs: null as number | null, sampleCount: 0 },
};
if (!usage) return empty;
const details = collectUsageDetails(usage);
if (!details.length) return empty;
const latencyStats = calculateLatencyStatsFromDetails(details);
let cachedTokens = 0;
let reasoningTokens = 0;
let totalCost = 0;
@@ -80,7 +91,12 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
}
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 +114,10 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
tpm: tokenCount / denominator,
windowMinutes,
requestCount,
tokenCount
tokenCount,
},
totalCost
totalCost,
latencyStats,
};
}, [hasPrices, modelPrices, nowMs, usage]);
@@ -123,9 +140,15 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
<span className={styles.statMetaDot} style={{ backgroundColor: '#c65746' }} />
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
</span>
{latencyStats.sampleCount > 0 && (
<span className={styles.statMetaItem}>
{t('usage_stats.avg_time')}:{' '}
{loading ? '-' : formatDurationMs(latencyStats.averageMs)}
</span>
)}
</>
),
trend: sparklines.requests
trend: sparklines.requests,
},
{
key: 'tokens',
@@ -138,14 +161,16 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
{t('usage_stats.cached_tokens')}:{' '}
{loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
</span>
<span className={styles.statMetaItem}>
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
{t('usage_stats.reasoning_tokens')}:{' '}
{loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
</span>
</>
),
trend: sparklines.tokens
trend: sparklines.tokens,
},
{
key: 'rpm',
@@ -157,10 +182,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
meta: (
<span className={styles.statMetaItem}>
{t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()}
{t('usage_stats.total_requests')}:{' '}
{loading ? '-' : rateStats.requestCount.toLocaleString()}
</span>
),
trend: sparklines.rpm
trend: sparklines.rpm,
},
{
key: 'tpm',
@@ -172,10 +198,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
meta: (
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
{t('usage_stats.total_tokens')}:{' '}
{loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
</span>
),
trend: sparklines.tpm
trend: sparklines.tpm,
},
{
key: 'cost',
@@ -188,7 +215,8 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
{t('usage_stats.total_tokens')}:{' '}
{loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
</span>
{!hasPrices && (
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
@@ -197,8 +225,8 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
)}
</>
),
trend: hasPrices ? sparklines.cost : null
}
trend: hasPrices ? sparklines.cost : null,
},
];
return (
@@ -211,7 +239,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 +253,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
{card.meta && <div className={styles.statMetaRow}>{card.meta}</div>}
<div className={styles.statTrend}>
{card.trend ? (
<Line className={styles.sparkline} data={card.trend.data} options={sparklineOptions} />
<Line
className={styles.sparkline}
data={card.trend.data}
options={sparklineOptions}
/>
) : (
<div className={styles.statTrendPlaceholder}></div>
)}
+3
View File
@@ -1029,6 +1029,9 @@
"request_events_source": "Source",
"request_events_auth_index": "Auth Index",
"request_events_result": "Result",
"time": "Time",
"avg_time": "Avg Time",
"total_time": "Total 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",
+3
View File
@@ -1026,6 +1026,9 @@
"request_events_source": "Источник",
"request_events_auth_index": "Auth Index",
"request_events_result": "Результат",
"time": "Время",
"avg_time": "Среднее время",
"total_time": "Общее время",
"request_events_empty_title": "События запросов отсутствуют",
"request_events_empty_desc": "Нет деталей запросов для выбранного диапазона времени.",
"request_events_no_result_title": "Совпадений не найдено",
+3
View File
@@ -1029,6 +1029,9 @@
"request_events_source": "来源",
"request_events_auth_index": "认证索引",
"request_events_result": "结果",
"time": "耗时",
"avg_time": "平均耗时",
"total_time": "总耗时",
"request_events_empty_title": "暂无请求事件",
"request_events_empty_desc": "当前时间范围内暂无可用的请求明细数据。",
"request_events_no_result_title": "没有匹配结果",
+5
View File
@@ -596,6 +596,11 @@
white-space: nowrap;
}
.durationCell {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
// Pricing Section (80%比例)
.pricingSection {
display: flex;
+362 -71
View File
@@ -29,6 +29,18 @@ export interface RateStats {
tokenCount: number;
}
export interface LatencyStats {
averageMs: number | null;
totalMs: number | null;
sampleCount: number;
}
export interface DurationFormatOptions {
maxUnits?: number;
invalidText?: string;
secondDecimals?: number | 'auto';
}
export interface ModelPrice {
prompt: number;
completion: number;
@@ -39,6 +51,7 @@ export interface UsageDetail {
timestamp: string;
source: string;
auth_index: number;
latency_ms?: number;
tokens: {
input_tokens: number;
output_tokens: number;
@@ -66,7 +79,22 @@ export interface ApiStats {
failureCount: number;
totalTokens: number;
totalCost: number;
models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }>;
models: Record<
string,
{ requests: number; successCount: number; failureCount: number; tokens: number }
>;
}
export interface ModelStatsSummary {
model: string;
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
averageLatencyMs: number | null;
totalLatencyMs: number | null;
latencySampleCount: number;
}
export type UsageTimeRange = '7h' | '24h' | '7d' | 'all';
@@ -77,7 +105,7 @@ const USAGE_ENDPOINT_METHOD_REGEX = /^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s
const USAGE_TIME_RANGE_MS: Record<Exclude<UsageTimeRange, 'all'>, 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<string, unknown> =>
@@ -100,17 +128,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<T>(usageData: T, range: UsageTimeRange, nowMs: number = Date.now()): T {
export function filterUsageByTimeRange<T>(
usageData: T,
range: UsageTimeRange,
nowMs: number = Date.now()
): T {
if (range === 'all') {
return usageData;
}
@@ -180,7 +212,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
filteredModels[modelName] = {
...modelEntry,
...toUsageSummaryFields(modelSummary),
details: filteredDetails
details: filteredDetails,
};
hasModelData = true;
@@ -197,7 +229,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
filteredApis[apiName] = {
...apiEntry,
...toUsageSummaryFields(apiSummary),
models: filteredModels
models: filteredModels,
};
totalSummary.totalRequests += apiSummary.totalRequests;
@@ -209,7 +241,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
return {
...usageRecord,
...toUsageSummaryFields(totalSummary),
apis: filteredApis
apis: filteredApis,
} as T;
}
@@ -309,7 +341,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 +358,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 +381,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 +396,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 +483,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 +538,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 +611,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 +656,157 @@ 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;
if (
rawValue === null ||
rawValue === undefined ||
(typeof rawValue === 'string' && rawValue.trim() === '')
) {
return null;
}
const parsed = Number(rawValue);
if (!Number.isFinite(parsed) || parsed < 0) {
return null;
}
return parsed;
}
interface LatencyAccumulator {
totalMs: number;
sampleCount: number;
}
const createLatencyAccumulator = (): LatencyAccumulator => ({
totalMs: 0,
sampleCount: 0,
});
const addLatencySample = (
accumulator: LatencyAccumulator,
latencyMs: number | null | undefined
): void => {
if (latencyMs === null || latencyMs === undefined) {
return;
}
const parsed = Number(latencyMs);
if (!Number.isFinite(parsed) || parsed < 0) {
return;
}
accumulator.totalMs += parsed;
accumulator.sampleCount += 1;
};
const finalizeLatencyStats = (accumulator: LatencyAccumulator): LatencyStats => ({
averageMs:
accumulator.sampleCount > 0 ? accumulator.totalMs / accumulator.sampleCount : null,
totalMs: accumulator.sampleCount > 0 ? accumulator.totalMs : null,
sampleCount: accumulator.sampleCount,
});
const trimTrailingZeros = (value: string): string => value.replace(/\.?0+$/, '');
const normalizeDurationMaxUnits = (value: number | undefined): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 2;
}
return Math.min(Math.floor(parsed), 4);
};
const resolveSecondDecimalPlaces = (
seconds: number,
secondDecimals: number | 'auto' | undefined
): number => {
if (secondDecimals === 'auto' || secondDecimals === undefined) {
return seconds < 10 ? 2 : 1;
}
const parsed = Math.floor(Number(secondDecimals));
if (!Number.isFinite(parsed) || parsed < 0) {
return seconds < 10 ? 2 : 1;
}
return Math.min(parsed, 3);
};
/**
* 格式化耗时显示
*/
export function formatDurationMs(
value: number | null | undefined,
options: DurationFormatOptions = {}
): string {
const invalidText = options.invalidText ?? '--';
if (value === null || value === undefined) {
return invalidText;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return invalidText;
}
if (parsed < 1000) {
return `${Math.round(parsed)}ms`;
}
const seconds = parsed / 1000;
if (seconds < 60) {
const secondDecimalPlaces = resolveSecondDecimalPlaces(seconds, options.secondDecimals);
return `${trimTrailingZeros(seconds.toFixed(secondDecimalPlaces))}s`;
}
const totalSeconds = Math.floor(seconds);
let remainingSeconds = totalSeconds;
const days = Math.floor(remainingSeconds / 86_400);
remainingSeconds -= days * 86_400;
const hours = Math.floor(remainingSeconds / 3_600);
remainingSeconds -= hours * 3_600;
const minutes = Math.floor(remainingSeconds / 60);
remainingSeconds -= minutes * 60;
const parts = [
{ label: 'd', value: days },
{ label: 'h', value: hours },
{ label: 'm', value: minutes },
{ label: 's', value: remainingSeconds },
].filter((part) => part.value > 0);
if (!parts.length) {
return '0s';
}
return parts
.slice(0, normalizeDurationMaxUnits(options.maxUnits))
.map((part, index) => {
const shouldPad = index > 0 && (part.label === 'm' || part.label === 's');
const unitValue = shouldPad ? String(part.value).padStart(2, '0') : String(part.value);
return `${unitValue}${part.label}`;
})
.join(' ');
}
/**
* 从明细列表计算耗时统计
*/
export function calculateLatencyStatsFromDetails(details: Iterable<unknown>): LatencyStats {
const accumulator = createLatencyAccumulator();
for (const detail of details) {
addLatencySample(accumulator, extractLatencyMs(detail));
}
return finalizeLatencyStats(accumulator);
}
/**
* 计算耗时统计
*/
export function calculateLatencyStats(usageData: unknown): LatencyStats {
return calculateLatencyStatsFromDetails(collectUsageDetails(usageData));
}
/**
* 计算 token 分类统计
*/
@@ -652,7 +854,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 +870,7 @@ export function calculateRecentPerMinuteRates(
tpm: tokenCount / denominator,
windowMinutes: effectiveWindow,
requestCount,
tokenCount
tokenCount,
};
}
@@ -694,7 +898,10 @@ export function getModelNamesFromUsage(usageData: unknown): string[] {
/**
* 计算成本数据
*/
export function calculateCost(detail: UsageDetail, modelPrices: Record<string, ModelPrice>): number {
export function calculateCost(
detail: UsageDetail,
modelPrices: Record<string, ModelPrice>
): number {
const modelName = detail.__modelName || '';
const price = modelPrices[modelName];
if (!price) {
@@ -707,7 +914,9 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record<string, M
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 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
@@ -716,7 +925,8 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record<string, M
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 completionCost =
(completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
const total = promptCost + cachedCost + completionCost;
return Number.isFinite(total) && total > 0 ? total : 0;
}
@@ -724,7 +934,10 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record<string, M
/**
* 计算总成本
*/
export function calculateTotalCost(usageData: unknown, modelPrices: Record<string, ModelPrice>): number {
export function calculateTotalCost(
usageData: unknown,
modelPrices: Record<string, ModelPrice>
): number {
const details = collectUsageDetails(usageData);
if (!details.length || !Object.keys(modelPrices).length) {
return 0;
@@ -756,7 +969,11 @@ export function loadModelPrices(): Record<string, ModelPrice> {
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 +989,7 @@ export function loadModelPrices(): Record<string, ModelPrice> {
normalized[model] = {
prompt,
completion,
cache
cache,
};
});
return normalized;
@@ -798,14 +1015,20 @@ export function saveModelPrices(prices: Record<string, ModelPrice>): void {
/**
* 获取 API 统计数据
*/
export function getApiStats(usageData: unknown, modelPrices: Record<string, ModelPrice>): ApiStats[] {
export function getApiStats(
usageData: unknown,
modelPrices: Record<string, ModelPrice>
): ApiStats[] {
const apis = getApisRecord(usageData);
if (!apis) return [];
const result: ApiStats[] = [];
Object.entries(apis).forEach(([endpoint, apiData]) => {
if (!isRecord(apiData)) return;
const models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }> = {};
const models: Record<
string,
{ requests: number; successCount: number; failureCount: number; tokens: number }
> = {};
let derivedSuccessCount = 0;
let derivedFailureCount = 0;
let totalCost = 0;
@@ -849,7 +1072,7 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
requests: Number(modelData.total_requests) || 0,
successCount,
failureCount,
tokens: Number(modelData.total_tokens) || 0
tokens: Number(modelData.total_tokens) || 0,
};
derivedSuccessCount += successCount;
derivedFailureCount += failureCount;
@@ -858,10 +1081,10 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
const hasApiExplicitCounts =
typeof apiData.success_count === 'number' || typeof apiData.failure_count === 'number';
const successCount = hasApiExplicitCounts
? (Number(apiData.success_count) || 0)
? Number(apiData.success_count) || 0
: derivedSuccessCount;
const failureCount = hasApiExplicitCounts
? (Number(apiData.failure_count) || 0)
? Number(apiData.failure_count) || 0
: derivedFailureCount;
result.push({
@@ -871,7 +1094,7 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
failureCount,
totalTokens: Number(apiData.total_tokens) || 0,
totalCost,
models
models,
});
});
@@ -881,18 +1104,24 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
/**
* 获取模型统计数据
*/
export function getModelStats(usageData: unknown, modelPrices: Record<string, ModelPrice>): Array<{
model: string;
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
}> {
export function getModelStats(
usageData: unknown,
modelPrices: Record<string, ModelPrice>
): ModelStatsSummary[] {
const apis = getApisRecord(usageData);
if (!apis) return [];
const modelMap = new Map<string, { requests: number; successCount: number; failureCount: number; tokens: number; cost: number }>();
const modelMap = new Map<
string,
{
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
latency: LatencyAccumulator;
}
>();
Object.values(apis).forEach((apiData) => {
if (!isRecord(apiData)) return;
@@ -902,7 +1131,14 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
Object.entries(models).forEach(([modelName, modelData]) => {
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,
latency: createLatencyAccumulator(),
};
existing.requests += Number(modelData.total_requests) || 0;
existing.tokens += Number(modelData.total_tokens) || 0;
@@ -917,9 +1153,10 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
existing.failureCount += Number(modelData.failure_count) || 0;
}
if (details.length > 0 && (!hasExplicitCounts || price)) {
if (details.length > 0) {
details.forEach((detail) => {
const detailRecord = isRecord(detail) ? detail : null;
const latencyMs = extractLatencyMs(detailRecord);
if (!hasExplicitCounts) {
if (detailRecord?.failed === true) {
existing.failureCount += 1;
@@ -928,6 +1165,8 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
}
}
addLatencySample(existing.latency, latencyMs);
if (price && detailRecord) {
existing.cost += calculateCost(
{ ...(detailRecord as unknown as UsageDetail), __modelName: modelName },
@@ -941,7 +1180,20 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
});
return Array.from(modelMap.entries())
.map(([model, stats]) => ({ model, ...stats }))
.map(([model, stats]) => {
const latencyStats = finalizeLatencyStats(stats.latency);
return {
model,
requests: stats.requests,
successCount: stats.successCount,
failureCount: stats.failureCount,
tokens: stats.tokens,
cost: stats.cost,
averageLatencyMs: latencyStats.averageMs,
totalLatencyMs: latencyStats.totalMs,
latencySampleCount: latencyStats.sampleCount,
};
})
.sort((a, b) => b.requests - a.requests);
}
@@ -1012,7 +1264,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 +1323,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 +1348,7 @@ export function buildDailySeriesByModel(
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);
const series = labels.map((label) => dayMap.get(label) || 0);
dataByModel.set(modelName, series);
});
@@ -1103,7 +1359,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 +1411,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 +1441,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 +1464,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 +1481,7 @@ export function buildChartData(
pointBackgroundColor: style.borderColor,
pointBorderColor: style.borderColor,
fill: shouldFill,
tension: 0.35
tension: 0.35,
};
});
@@ -1283,8 +1549,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 +1619,7 @@ export function calculateStatusBarData(
blockDetails,
successRate,
totalSuccess,
totalFailure
totalFailure,
};
}
@@ -1364,9 +1637,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 +1657,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 +1722,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 +1780,7 @@ export function computeKeyStats(usageData: unknown, masker: (val: string) => str
return {
bySource: sourceStats,
byAuthIndex: authIndexStats
byAuthIndex: authIndexStats,
};
}
@@ -1586,7 +1867,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 +1884,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 +1909,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 +1925,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 +1986,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 +2021,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;