feat(usage): add configurable speed and cache rate columns

This commit is contained in:
lzt404 committed 2026-08-26 01:56:46 +08:00
1 parent 2599a8a280
commit 1af6a7cfea
7 files changed
+545 -84

No files matched your search

+7
View File
@@ -194,6 +194,11 @@ export const jaOverrides = {
'usage.events.resetColumns': '列幅をリセット',
'usage.events.resizeHint': 'ドラッグで列幅を調整、ダブルクリックでリセット',
'usage.events.dragHint': 'ヘッダーの境界をドラッグして列幅を調整',
'usage.events.columnSettings': '列設定',
'usage.events.columnSettingsDescription': 'リクエスト詳細に表示する列を選択します。',
'usage.events.columnsSelected': '{total} 列中 {selected} 列を選択',
'usage.events.selectAllColumns': 'すべて選択',
'usage.events.applyColumns': '適用',
'usage.column.time': '日時',
'usage.column.model': 'モデル',
'usage.column.provider': 'Provider',
@@ -206,7 +211,9 @@ export const jaOverrides = {
'usage.column.output': '出力',
'usage.column.reasoning': '推論',
'usage.column.cache': 'キャッシュ',
'usage.column.cacheRate': 'キャッシュ率',
'usage.column.total': '合計',
'usage.column.speed': '生成速度',
'usage.key.noRemark': 'メモなし',
'usage.previous': '前へ',
'usage.next': '次へ',
+7
View File
@@ -194,6 +194,11 @@ export const en: Record<MessageKey, string> = {
'usage.events.resetColumns': 'Reset Widths',
'usage.events.resizeHint': 'Drag to resize, double-click to reset',
'usage.events.dragHint': 'Drag column header borders to resize',
'usage.events.columnSettings': 'Column Settings',
'usage.events.columnSettingsDescription': 'Choose which columns appear in request details.',
'usage.events.columnsSelected': '{selected} of {total} columns selected',
'usage.events.selectAllColumns': 'Select All',
'usage.events.applyColumns': 'Apply',
'usage.column.time': 'Time',
'usage.column.model': 'Model',
'usage.column.provider': 'Provider',
@@ -206,7 +211,9 @@ export const en: Record<MessageKey, string> = {
'usage.column.output': 'Output',
'usage.column.reasoning': 'Reasoning',
'usage.column.cache': 'Cache',
'usage.column.cacheRate': 'Cache Rate',
'usage.column.total': 'Total',
'usage.column.speed': 'Generation Speed',
'usage.key.noRemark': 'No remark',
'usage.previous': 'Previous',
'usage.next': 'Next',
+7
View File
@@ -192,6 +192,11 @@ export const zhCN = {
'usage.events.resetColumns': '重置列宽',
'usage.events.resizeHint': '拖动调整列宽,双击重置本列',
'usage.events.dragHint': '可拖动表头边线调整每列宽度',
'usage.events.columnSettings': '列设置',
'usage.events.columnSettingsDescription': '选择要在请求明细中显示的列。',
'usage.events.columnsSelected': '已选择 {selected} / {total} 列',
'usage.events.selectAllColumns': '选择全部',
'usage.events.applyColumns': '应用',
'usage.column.time': '时间',
'usage.column.model': '模型',
'usage.column.provider': 'Provider',
@@ -204,7 +209,9 @@ export const zhCN = {
'usage.column.output': '输出',
'usage.column.reasoning': '思考',
'usage.column.cache': '缓存',
'usage.column.cacheRate': '缓存率',
'usage.column.total': '总计',
'usage.column.speed': '生成速度',
'usage.key.noRemark': '未备注',
'usage.previous': '上一页',
'usage.next': '下一页',
+282 -81
View File
@@ -6,6 +6,7 @@ import {
BarChart3,
CircleDollarSign,
Clock3,
Columns3Cog,
Database,
FilterX,
Key,
@@ -24,6 +25,7 @@ import {
} from 'lucide-react';
import { getCurrentLocale, useI18n } from '../i18n';
import type { MessageKey } from '../i18n/resources';
import { formatCacheReadRate, formatGenerationSpeed } from '../services/usageMetrics';
import { formatUsageNumber } from '../services/usageNumber';
type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing';
@@ -978,7 +980,9 @@ type EventColumnKey =
| 'total'
| 'result'
| 'latency'
| 'ttft';
| 'ttft'
| 'speed'
| 'cacheRate';
type EventColumnDef = {
key: EventColumnKey;
@@ -998,13 +1002,42 @@ const EVENT_COLUMNS: readonly EventColumnDef[] = [
{ key: 'output', labelKey: 'usage.column.output', defaultWidth: 76, minWidth: 50, align: 'center' },
{ key: 'reasoning', labelKey: 'usage.column.reasoning', defaultWidth: 76, minWidth: 50, align: 'center' },
{ key: 'cache', labelKey: 'usage.column.cache', defaultWidth: 76, minWidth: 50, align: 'center' },
{ key: 'cacheRate', labelKey: 'usage.column.cacheRate', defaultWidth: 88, minWidth: 65, align: 'center' },
{ key: 'total', labelKey: 'usage.column.total', defaultWidth: 84, minWidth: 60, align: 'center' },
{ key: 'result', labelKey: 'usage.column.result', defaultWidth: 160, minWidth: 90, align: 'center' },
{ key: 'latency', labelKey: 'usage.column.latency', defaultWidth: 88, minWidth: 65, align: 'center' },
{ key: 'ttft', labelKey: 'usage.column.ttft', defaultWidth: 88, minWidth: 65, align: 'center' },
{ key: 'speed', labelKey: 'usage.column.speed', defaultWidth: 92, minWidth: 70, align: 'center' },
] as const;
const EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v1';
const EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v1';
const getAllEventColumnKeys = () => EVENT_COLUMNS.map((column) => column.key);
const getInitialVisibleColumns = (): EventColumnKey[] => {
try {
const raw = localStorage.getItem(EVENT_VISIBLE_COLS_STORAGE_KEY);
if (raw) {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
const knownKeys = new Set<EventColumnKey>(getAllEventColumnKeys());
const seen = new Set<EventColumnKey>();
const savedKeys = parsed.filter((key): key is EventColumnKey => {
if (typeof key !== 'string' || !knownKeys.has(key as EventColumnKey) || seen.has(key as EventColumnKey)) {
return false;
}
seen.add(key as EventColumnKey);
return true;
});
if (savedKeys.length > 0) return savedKeys;
}
}
} catch {
// fallback to all columns
}
return getAllEventColumnKeys();
};
const getInitialColumnWidths = (): Record<EventColumnKey, number> => {
const initial: Record<EventColumnKey, number> = {} as any;
@@ -1053,12 +1086,140 @@ function UsageResultCell({ record }: { record: UsageRecord }) {
);
}
function UsageEventCell({
record,
columnKey,
noRemarkLabel,
}: {
record: UsageRecord;
columnKey: EventColumnKey;
noRemarkLabel: string;
}) {
switch (columnKey) {
case 'time':
return (
<td className="usage-td-time align-center" title={new Date(record.timestamp).toLocaleString()}>
{formatTime(record.timestamp)}
</td>
);
case 'model':
return (
<td className="usage-stacked-cell align-center">
<strong title={record.alias || record.model}>{record.alias || record.model}</strong>
{record.alias || record.reasoning_effort ? (
<small title={record.model}>
{record.alias ? record.model : ''}
{record.alias && record.reasoning_effort ? ' · ' : ''}
{record.reasoning_effort}
</small>
) : null}
</td>
);
case 'provider':
return (
<td className="usage-td-provider align-center" title={record.provider || undefined}>
<span className="usage-tag-pill">{record.provider || '—'}</span>
</td>
);
case 'source':
return (
<td className="usage-td-source align-center" title={record.source_display || record.source || undefined}>
<span className="usage-tag-pill">{record.source_display || record.source || '—'}</span>
</td>
);
case 'key':
return (
<td className="usage-stacked-cell align-center">
<strong title={record.api_key_remark}>{record.api_key_remark || noRemarkLabel}</strong>
<small title={record.api_key_display || undefined}>{record.api_key_display || '—'}</small>
</td>
);
case 'input':
return (
<td className="usage-td-token align-center" title={`${record.tokens.input_tokens.toLocaleString()} tokens`}>
{compactNumber(record.tokens.input_tokens)}
</td>
);
case 'output':
return (
<td className="usage-td-token align-center" title={`${record.tokens.output_tokens.toLocaleString()} tokens`}>
{compactNumber(record.tokens.output_tokens)}
</td>
);
case 'reasoning':
return (
<td className="usage-td-token align-center" title={`${record.tokens.reasoning_tokens.toLocaleString()} tokens`}>
{compactNumber(record.tokens.reasoning_tokens)}
</td>
);
case 'cache':
return (
<td
className="usage-td-token align-center"
title={`Read: ${record.tokens.cache_read_tokens.toLocaleString()} tokens${
record.tokens.cache_creation_tokens > 0
? ` / Creation: ${record.tokens.cache_creation_tokens.toLocaleString()} tokens`
: ''
}`}
>
{compactNumber(record.tokens.cache_read_tokens)}
</td>
);
case 'cacheRate': {
const value = formatCacheReadRate({
inputTokens: record.tokens.input_tokens,
cacheReadTokens: record.tokens.cache_read_tokens,
});
return <td className="usage-td-cache-rate align-center" title={value === '—' ? undefined : value}>{value}</td>;
}
case 'total':
return (
<td className="usage-td-token align-center" title={`${record.tokens.total_tokens.toLocaleString()} tokens`}>
<strong>{compactNumber(record.tokens.total_tokens)}</strong>
</td>
);
case 'result':
return <UsageResultCell record={record} />;
case 'latency':
return (
<td className="usage-td-latency align-center" title={`${record.latency_ms} ms`}>
{compactNumber(record.latency_ms)} ms
</td>
);
case 'ttft':
return (
<td className="usage-td-ttft align-center" title={record.ttft_ms == null ? undefined : `${record.ttft_ms} ms`}>
{record.ttft_ms == null ? '—' : `${compactNumber(record.ttft_ms)} ms`}
</td>
);
case 'speed': {
const value = formatGenerationSpeed({
outputTokens: record.tokens.output_tokens,
latencyMs: record.latency_ms,
ttftMs: record.ttft_ms,
});
return <td className="usage-td-speed align-center" title={value === '—' ? undefined : value}>{value}</td>;
}
}
}
function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page: number) => void }) {
const { t } = useI18n();
const [widths, setWidths] = useState<Record<EventColumnKey, number>>(getInitialColumnWidths);
const [visibleColumnKeys, setVisibleColumnKeys] = useState<EventColumnKey[]>(getInitialVisibleColumns);
const [columnSettingsOpen, setColumnSettingsOpen] = useState(false);
const [draftVisibleColumnKeys, setDraftVisibleColumnKeys] = useState<EventColumnKey[]>(visibleColumnKeys);
const [resizingCol, setResizingCol] = useState<EventColumnKey | null>(null);
const columnDialogRef = useRef<HTMLElement | null>(null);
const visibleColumnKeySet = new Set(visibleColumnKeys);
const visibleColumns = EVENT_COLUMNS.filter((column) => visibleColumnKeySet.has(column.key));
const isCustomized = EVENT_COLUMNS.some((col) => widths[col.key] !== col.defaultWidth);
const noRemarkLabel = t('usage.key.noRemark');
useEffect(() => {
if (columnSettingsOpen) columnDialogRef.current?.focus();
}, [columnSettingsOpen]);
const resetAllWidths = () => {
const defaults: Record<EventColumnKey, number> = {} as any;
@@ -1071,6 +1232,34 @@ function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page:
} catch {}
};
const openColumnSettings = () => {
setDraftVisibleColumnKeys(visibleColumnKeys);
setColumnSettingsOpen(true);
};
const toggleDraftColumn = (key: EventColumnKey) => {
setDraftVisibleColumnKeys((current) => {
if (current.includes(key)) {
return current.length > 1 ? current.filter((columnKey) => columnKey !== key) : current;
}
return EVENT_COLUMNS.filter((column) => current.includes(column.key) || column.key === key).map((column) => column.key);
});
};
const applyColumnSettings = () => {
const next = draftVisibleColumnKeys.length > 0 ? draftVisibleColumnKeys : getAllEventColumnKeys();
setVisibleColumnKeys(next);
try {
localStorage.setItem(EVENT_VISIBLE_COLS_STORAGE_KEY, JSON.stringify(next));
} catch {}
setColumnSettingsOpen(false);
};
const resetVisibleColumns = () => {
const allColumns = getAllEventColumnKeys();
setDraftVisibleColumnKeys(allColumns);
};
const resetSingleColumn = (key: EventColumnKey, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -1125,7 +1314,7 @@ function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page:
window.addEventListener('pointerup', onPointerUp);
};
const totalTableWidth = EVENT_COLUMNS.reduce((sum, col) => sum + (widths[col.key] ?? col.defaultWidth), 0);
const totalTableWidth = visibleColumns.reduce((sum, col) => sum + (widths[col.key] ?? col.defaultWidth), 0);
return (
<section className="panel usage-events-panel">
@@ -1139,6 +1328,15 @@ function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page:
</span>
</div>
<div className="usage-events-summary-right">
<button
type="button"
className="usage-col-settings-btn"
onClick={openColumnSettings}
title={t('usage.events.columnSettings')}
>
<Columns3Cog size={13} />
<span>{t('usage.events.columnSettings')}</span>
</button>
{isCustomized ? (
<button
type="button"
@@ -1160,13 +1358,13 @@ function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page:
<div className="usage-table-wrap">
<table className="usage-events-table" style={{ width: `${totalTableWidth}px`, minWidth: '100%' }}>
<colgroup>
{EVENT_COLUMNS.map((col) => (
{visibleColumns.map((col) => (
<col key={col.key} style={{ width: `${widths[col.key]}px` }} />
))}
</colgroup>
<thead>
<tr>
{EVENT_COLUMNS.map((col) => {
{visibleColumns.map((col) => {
const label = t(col.labelKey);
return (
<th
@@ -1191,83 +1389,14 @@ function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page:
<tbody>
{events.items.map((record) => (
<tr key={record.id}>
{/* time */}
<td className="usage-td-time align-center" title={new Date(record.timestamp).toLocaleString()}>
{formatTime(record.timestamp)}
</td>
{/* model */}
<td className="usage-stacked-cell align-center">
<strong title={record.alias || record.model}>{record.alias || record.model}</strong>
{record.alias || record.reasoning_effort ? (
<small title={record.model}>
{record.alias ? record.model : ''}
{record.alias && record.reasoning_effort ? ' · ' : ''}
{record.reasoning_effort}
</small>
) : null}
</td>
{/* provider */}
<td className="usage-td-provider align-center" title={record.provider || undefined}>
<span className="usage-tag-pill">{record.provider || '—'}</span>
</td>
{/* source */}
<td className="usage-td-source align-center" title={record.source_display || record.source || undefined}>
<span className="usage-tag-pill">{record.source_display || record.source || '—'}</span>
</td>
{/* key */}
<td className="usage-stacked-cell align-center">
<strong title={record.api_key_remark}>{record.api_key_remark || t('usage.key.noRemark')}</strong>
<small title={record.api_key_display || undefined}>{record.api_key_display || '—'}</small>
</td>
{/* input tokens */}
<td className="usage-td-token align-center" title={`${record.tokens.input_tokens.toLocaleString()} tokens`}>
{compactNumber(record.tokens.input_tokens)}
</td>
{/* output tokens */}
<td className="usage-td-token align-center" title={`${record.tokens.output_tokens.toLocaleString()} tokens`}>
{compactNumber(record.tokens.output_tokens)}
</td>
{/* reasoning tokens */}
<td className="usage-td-token align-center" title={`${record.tokens.reasoning_tokens.toLocaleString()} tokens`}>
{compactNumber(record.tokens.reasoning_tokens)}
</td>
{/* cache tokens */}
<td
className="usage-td-token align-center"
title={`Read: ${record.tokens.cache_read_tokens.toLocaleString()} tokens${
record.tokens.cache_creation_tokens > 0
? ` / Creation: ${record.tokens.cache_creation_tokens.toLocaleString()} tokens`
: ''
}`}
>
{compactNumber(record.tokens.cache_read_tokens)}
</td>
{/* total tokens */}
<td className="usage-td-token align-center" title={`${record.tokens.total_tokens.toLocaleString()} tokens`}>
<strong>{compactNumber(record.tokens.total_tokens)}</strong>
</td>
{/* result */}
<UsageResultCell record={record} />
{/* latency */}
<td className="usage-td-latency align-center" title={`${record.latency_ms} ms`}>
{compactNumber(record.latency_ms)} ms
</td>
{/* ttft */}
<td className="usage-td-ttft align-center" title={record.ttft_ms == null ? undefined : `${record.ttft_ms} ms`}>
{record.ttft_ms == null ? '—' : `${compactNumber(record.ttft_ms)} ms`}
</td>
{visibleColumns.map((column) => (
<UsageEventCell
key={column.key}
record={record}
columnKey={column.key}
noRemarkLabel={noRemarkLabel}
/>
))}
</tr>
))}
</tbody>
@@ -1298,6 +1427,78 @@ function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page:
{t('usage.next')}
</button>
</div>
{columnSettingsOpen ? (
<div
className="config-dialog-backdrop"
onMouseDown={(event) => event.currentTarget === event.target && setColumnSettingsOpen(false)}
>
<section
ref={columnDialogRef}
className="config-dialog usage-column-dialog"
role="dialog"
tabIndex={-1}
aria-modal="true"
aria-labelledby="usage-column-dialog-title"
onKeyDown={(event) => {
if (event.key === 'Escape') setColumnSettingsOpen(false);
}}
>
<div className="usage-column-dialog-heading">
<div>
<Columns3Cog size={19} aria-hidden="true" />
<h2 id="usage-column-dialog-title">{t('usage.events.columnSettings')}</h2>
</div>
<button
type="button"
className="icon-button quiet"
onClick={() => setColumnSettingsOpen(false)}
title={t('common.close')}
>
<X size={17} />
</button>
</div>
<p className="usage-column-dialog-description">{t('usage.events.columnSettingsDescription')}</p>
<div className="usage-column-options">
{EVENT_COLUMNS.map((column) => {
const checked = draftVisibleColumnKeys.includes(column.key);
return (
<label key={column.key} className="usage-column-option">
<input
type="checkbox"
checked={checked}
disabled={checked && draftVisibleColumnKeys.length === 1}
onChange={() => toggleDraftColumn(column.key)}
/>
<span>{t(column.labelKey)}</span>
</label>
);
})}
</div>
<div className="usage-column-dialog-footer">
<div className="usage-column-dialog-meta">
<span>
{t('usage.events.columnsSelected', {
selected: draftVisibleColumnKeys.length,
total: EVENT_COLUMNS.length,
})}
</span>
<button type="button" className="usage-column-select-all" onClick={resetVisibleColumns}>
{t('usage.events.selectAllColumns')}
</button>
</div>
<div className="usage-column-dialog-actions">
<button type="button" className="secondary-button" onClick={() => setColumnSettingsOpen(false)}>
{t('common.cancel')}
</button>
<button type="button" className="primary-button" onClick={applyColumnSettings}>
{t('usage.events.applyColumns')}
</button>
</div>
</div>
</section>
</div>
) : null}
</section>
);
}
+52
View File
@@ -0,0 +1,52 @@
type GenerationSpeedInput = {
outputTokens: number;
latencyMs: number;
ttftMs: number | null;
};
type CacheReadRateInput = {
inputTokens: number;
cacheReadTokens: number;
};
export const calculateGenerationSpeed = ({
outputTokens,
latencyMs,
ttftMs,
}: GenerationSpeedInput): number | null => {
if (
!Number.isFinite(outputTokens) ||
!Number.isFinite(latencyMs) ||
ttftMs === null ||
!Number.isFinite(ttftMs) ||
outputTokens <= 0 ||
ttftMs <= 0 ||
latencyMs <= ttftMs
) {
return null;
}
const speed = outputTokens / ((latencyMs - ttftMs) / 1_000);
return Number.isFinite(speed) && speed > 0 ? speed : null;
};
export const formatGenerationSpeed = (input: GenerationSpeedInput): string => {
const speed = calculateGenerationSpeed(input);
return speed === null ? '—' : `${speed.toFixed(1)} t/s`;
};
export const calculateCacheReadRate = ({
inputTokens,
cacheReadTokens,
}: CacheReadRateInput): number | null => {
if (!Number.isFinite(inputTokens) || !Number.isFinite(cacheReadTokens) || inputTokens <= 0) {
return null;
}
return (cacheReadTokens / inputTokens) * 100;
};
export const formatCacheReadRate = (input: CacheReadRateInput): string => {
const rate = calculateCacheReadRate(input);
return rate === null ? '—' : `${rate.toFixed(2)}%`;
};
+144 -3
View File
@@ -7300,7 +7300,8 @@ textarea:focus-visible {
opacity: 0.85;
}
.usage-col-reset-btn {
.usage-col-reset-btn,
.usage-col-settings-btn {
display: inline-flex;
align-items: center;
gap: 4px;
@@ -7316,12 +7317,135 @@ textarea:focus-visible {
transition: all 0.15s ease;
}
.usage-col-reset-btn:hover {
.usage-col-reset-btn:hover,
.usage-col-settings-btn:hover {
background: var(--theme-e8e5dc);
color: var(--theme-2d2a26);
border-color: var(--theme-d5d2cb);
}
.usage-column-dialog {
display: flex;
width: min(540px, 100%);
height: auto;
max-height: min(680px, calc(100vh - 40px));
flex-direction: column;
gap: 14px;
overflow: hidden;
}
.usage-column-dialog-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.usage-column-dialog-heading > div {
display: flex;
min-width: 0;
align-items: center;
gap: 9px;
color: var(--theme-5f5a53);
}
.usage-column-dialog-heading h2 {
margin: 0;
overflow: hidden;
color: var(--theme-2d2a26);
font-size: var(--font-size-dialog-title);
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-column-dialog-description {
margin: 0;
color: var(--theme-7a746c);
font-size: var(--font-size-caption);
line-height: 1.5;
}
.usage-column-options {
display: grid;
min-height: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
overflow: auto;
}
.usage-column-option {
display: flex;
min-width: 0;
align-items: center;
gap: 9px;
padding: 9px 11px;
border: 1px solid var(--theme-e3e1db);
border-radius: 6px;
background: var(--theme-fffdf8);
color: var(--theme-5f5a53);
cursor: pointer;
font-size: var(--font-size-caption);
}
.usage-column-option:hover {
border-color: var(--theme-d5d2cb);
background: var(--theme-faf8f4);
}
.usage-column-option input {
width: 15px;
height: 15px;
flex: 0 0 auto;
accent-color: var(--theme-3f6f98);
}
.usage-column-option:has(input:disabled) {
cursor: default;
opacity: 0.65;
}
.usage-column-dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding-top: 2px;
}
.usage-column-dialog-meta {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
color: var(--theme-8a847c);
font-size: 11px;
}
.usage-column-select-all {
padding: 0;
border: 0;
background: transparent;
color: var(--theme-5f5a53);
cursor: pointer;
font: inherit;
font-weight: 600;
}
.usage-column-select-all:hover {
color: var(--theme-2d2a26);
text-decoration: underline;
}
.usage-column-dialog-actions {
display: flex;
flex: 0 0 auto;
gap: 8px;
}
.usage-column-dialog-actions button {
min-width: 84px;
}
.usage-table-wrap {
overflow: auto;
min-height: 380px;
@@ -7447,10 +7571,27 @@ body.table-col-resizing * {
.usage-events-table td.usage-td-token,
.usage-events-table td.usage-td-latency,
.usage-events-table td.usage-td-ttft {
.usage-events-table td.usage-td-ttft,
.usage-events-table td.usage-td-speed,
.usage-events-table td.usage-td-cache-rate {
font-variant-numeric: tabular-nums;
}
@media (max-width: 560px) {
.usage-column-options {
grid-template-columns: 1fr;
}
.usage-column-dialog-footer {
align-items: stretch;
flex-direction: column;
}
.usage-column-dialog-actions button {
flex: 1;
}
}
.usage-events-table td.usage-td-provider,
.usage-events-table td.usage-td-source {
overflow: hidden;
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from 'bun:test';
import {
calculateCacheReadRate,
calculateGenerationSpeed,
formatCacheReadRate,
formatGenerationSpeed,
} from '../src/services/usageMetrics';
describe('generation speed', () => {
test('uses only the generation interval after the first token', () => {
const input = { outputTokens: 344, latencyMs: 10_600, ttftMs: 2_770 };
expect(calculateGenerationSpeed(input)).toBeCloseTo(43.9336, 4);
expect(formatGenerationSpeed(input)).toBe('43.9 t/s');
});
test.each([
{ outputTokens: 344, latencyMs: 10_600, ttftMs: 0 },
{ outputTokens: 344, latencyMs: 10_600, ttftMs: null },
{ outputTokens: 344, latencyMs: 2_770, ttftMs: 2_770 },
{ outputTokens: 344, latencyMs: 2_000, ttftMs: 2_770 },
{ outputTokens: 0, latencyMs: 10_600, ttftMs: 2_770 },
])('returns an em dash when generation speed cannot be calculated', (input) => {
expect(calculateGenerationSpeed(input)).toBeNull();
expect(formatGenerationSpeed(input)).toBe('—');
});
});
describe('cache read rate', () => {
test('calculates the percentage from cache-read and input tokens', () => {
const input = { inputTokens: 1_000, cacheReadTokens: 250 };
expect(calculateCacheReadRate(input)).toBe(25);
expect(formatCacheReadRate(input)).toBe('25.00%');
});
test('does not clamp values over 100 percent', () => {
const input = { inputTokens: 400, cacheReadTokens: 600 };
expect(calculateCacheReadRate(input)).toBe(150);
expect(formatCacheReadRate(input)).toBe('150.00%');
});
test.each([0, -1])('returns an em dash when input tokens are %s', (inputTokens) => {
const input = { inputTokens, cacheReadTokens: 250 };
expect(calculateCacheReadRate(input)).toBeNull();
expect(formatCacheReadRate(input)).toBe('—');
});
});