Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/web-ui/src/infrastructure/api/tokenUsageStatisticsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface UsageTrendPoint {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
/** Tokens written into the provider cache for this bucket. */
cacheWriteTokens: number;
/** 0.0..=1.0 when the bucket has cache telemetry. */
cacheHitRate: number | null;
Expand All @@ -70,6 +71,7 @@ export interface UsageStatistics {
totalInputTokens: number;
totalOutputTokens: number;
totalCachedTokens: number;
/** Tokens written into provider caches across the selected range. */
totalCacheWriteTokens: number;
/** Prompt input tokens from requests that reported cache telemetry. */
totalCacheReportedInputTokens: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ const SAMPLE_STATS: UsageStatistics = {
inputTokens: 1_000_000,
outputTokens: 100_000,
cacheReadTokens: 900_000,
cacheWriteTokens: 0,
cacheWriteTokens: 50_000,
cacheHitRate: 0.9,
},
{
Expand Down Expand Up @@ -197,10 +197,69 @@ describe('UsageStatisticsConfig', () => {
expect(container.querySelector('[data-bf-part="trendPanel"]')).not.toBeNull();
expect(container.querySelectorAll('.bitfun-usage-stats__donut').length).toBe(3);
expect(container.querySelectorAll('[data-bf-part="trendPanel"] svg').length).toBe(1);
expect(container.textContent).not.toContain('trend.legend.cacheCreation');
// Hit rate is truncated to two decimals, never rounded up.
expect(container.textContent).toContain('95.00%');
});

it('keeps idle hit-rate points continuous but splits active telemetry gaps', async () => {
const idlePoint = {
...SAMPLE_STATS.trend[0],
bucket: '2026-08-16T12:00:00.000Z',
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheHitRate: null,
};
const activeGapPoint = {
...SAMPLE_STATS.trend[0],
bucket: '2026-08-16T14:00:00.000Z',
cacheHitRate: null,
};
getStatisticsMock.mockResolvedValue({
...SAMPLE_STATS,
trend: [
SAMPLE_STATS.trend[0],
idlePoint,
{ ...SAMPLE_STATS.trend[1], bucket: '2026-08-16T13:00:00.000Z' },
activeGapPoint,
{ ...SAMPLE_STATS.trend[1], bucket: '2026-08-16T15:00:00.000Z' },
],
});

await render();

expect(container.querySelectorAll('[data-cache-hit-rate-segment="line"]')).toHaveLength(1);
expect(container.querySelectorAll('[data-cache-hit-rate-segment="point"]')).toHaveLength(1);

const hoverCapture = container.querySelector(
'.bitfun-usage-stats__trend-svg > rect[fill="transparent"]',
) as SVGRectElement;
vi.spyOn(hoverCapture, 'getBoundingClientRect').mockReturnValue({
left: 0,
width: 400,
} as DOMRect);

await act(async () => {
hoverCapture.dispatchEvent(new MouseEvent('mousemove', {
bubbles: true,
clientX: 300,
}));
});
let tooltipRows = container.querySelectorAll('.bitfun-usage-stats__trend-tooltip-row');
expect(tooltipRows[tooltipRows.length - 1]?.textContent).toContain('–');

await act(async () => {
hoverCapture.dispatchEvent(new MouseEvent('mousemove', {
bubbles: true,
clientX: 100,
}));
});
tooltipRows = container.querySelectorAll('.bitfun-usage-stats__trend-tooltip-row');
expect(tooltipRows[tooltipRows.length - 1]?.textContent).toContain('0.00%');
});

it('keeps same-named models distinct and labels deleted configurations', async () => {
getStatisticsMock.mockResolvedValue({
...SAMPLE_STATS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import './UsageStatisticsConfig.scss';
const SERIES_COLORS = {
input: 'var(--bf-appearance-token-color-accent-500)',
output: 'var(--bf-appearance-token-color-success)',
cacheCreation: 'var(--bf-appearance-token-color-warning)',
cacheRead: 'var(--bf-appearance-token-color-cyan-500)',
cacheHitRate: 'var(--bf-appearance-token-color-purple-500)',
} as const;
Expand Down Expand Up @@ -366,13 +365,12 @@ interface TrendChartProps {
}

const TREND_SERIES: {
key: 'inputTokens' | 'outputTokens' | 'cacheReadTokens' | 'cacheWriteTokens';
key: 'inputTokens' | 'outputTokens' | 'cacheReadTokens';
color: string;
legendKey: string;
}[] = [
{ key: 'inputTokens', color: SERIES_COLORS.input, legendKey: 'trend.legend.input' },
{ key: 'outputTokens', color: SERIES_COLORS.output, legendKey: 'trend.legend.output' },
{ key: 'cacheWriteTokens', color: SERIES_COLORS.cacheCreation, legendKey: 'trend.legend.cacheCreation' },
{ key: 'cacheReadTokens', color: SERIES_COLORS.cacheRead, legendKey: 'trend.legend.cacheRead' },
];

Expand All @@ -391,6 +389,18 @@ function niceMax(value: number): number {
return nice * magnitude;
}

function cacheHitRateForTrend(
point: UsageStatistics['trend'][number],
): number | null {
if (point.cacheHitRate !== null) return point.cacheHitRate;

const isIdleBucket = point.inputTokens === 0
&& point.outputTokens === 0
&& point.cacheReadTokens === 0
&& point.cacheWriteTokens === 0;
return isIdleBucket ? 0 : null;
}

const TrendChart: React.FC<TrendChartProps> = ({ points, granularity, timeZone }) => {
const { t, formatDate } = useI18n('settings/usage-statistics');
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
Expand All @@ -404,7 +414,6 @@ const TrendChart: React.FC<TrendChartProps> = ({ points, granularity, timeZone }
point.inputTokens,
point.outputTokens,
point.cacheReadTokens,
point.cacheWriteTokens,
), 0),
);
const yTicks = 4;
Expand All @@ -416,8 +425,8 @@ const TrendChart: React.FC<TrendChartProps> = ({ points, granularity, timeZone }
const yFor = (value: number): number => (
PAD_TOP + plotHeight - (value / maxTokens) * plotHeight
);
const rateFor = (value: number | null): number | null => (
value === null ? null : PAD_TOP + plotHeight - value * plotHeight
const rateFor = (value: number): number => (
PAD_TOP + plotHeight - value * plotHeight
);

const xTickIndexes = useMemo(() => {
Expand All @@ -430,6 +439,27 @@ const TrendChart: React.FC<TrendChartProps> = ({ points, granularity, timeZone }
if (points.length === 0) return null;

const hovered = hoverIndex !== null ? points[hoverIndex] : null;
const hoveredHitRate = hovered ? cacheHitRateForTrend(hovered) : null;

// Synthesized idle buckets sit at 0% to keep ordinary idle stretches
// continuous. Active buckets without cache telemetry remain real gaps so
// the chart does not claim that an unsupported provider had a 0% hit rate.
const hitRateSegments: Array<Array<{ x: number; y: number }>> = [];
{
let current: Array<{ x: number; y: number }> = [];
points.forEach((point, index) => {
const rate = cacheHitRateForTrend(point);
if (rate === null) {
if (current.length > 0) {
hitRateSegments.push(current);
current = [];
}
return;
}
current.push({ x: xFor(index), y: rateFor(rate) });
});
if (current.length > 0) hitRateSegments.push(current);
}

return (
<div className="bitfun-usage-stats__trend">
Expand Down Expand Up @@ -490,22 +520,31 @@ const TrendChart: React.FC<TrendChartProps> = ({ points, granularity, timeZone }
/>
))}

{/* Cache hit rate (right axis, dashed) */}
<polyline
points={points
.map((point, index) => {
const y = rateFor(point.cacheHitRate);
return y === null ? '' : `${xFor(index)},${y}`;
})
.filter(Boolean)
.join(' ')}
fill="none"
stroke={SERIES_COLORS.cacheHitRate}
strokeWidth="2"
strokeDasharray="4 4"
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* Cache hit rate (right axis, dashed). */}
{hitRateSegments.map((segment, segmentIndex) =>
segment.length === 1 ? (
<circle
key={`rate-segment-${segmentIndex}`}
cx={segment[0].x}
cy={segment[0].y}
r="2.5"
fill={SERIES_COLORS.cacheHitRate}
data-cache-hit-rate-segment="point"
/>
) : (
<polyline
key={`rate-segment-${segmentIndex}`}
points={segment.map(point => `${point.x},${point.y}`).join(' ')}
fill="none"
stroke={SERIES_COLORS.cacheHitRate}
strokeWidth="2"
strokeDasharray="4 4"
strokeLinejoin="round"
strokeLinecap="round"
data-cache-hit-rate-segment="line"
/>
),
)}

{/* Hover capture */}
<rect
Expand All @@ -530,6 +569,30 @@ const TrendChart: React.FC<TrendChartProps> = ({ points, granularity, timeZone }
y2={PAD_TOP + plotHeight}
className="bitfun-usage-stats__trend-cursor"
/>
{/* Hover markers: one dot per series so small values stay visible
where a zero-baseline token axis would otherwise flatten them
(e.g. 276K next to a 20M peak). */}
{TREND_SERIES.map((series) => (
<circle
key={`hover-dot-${series.key}`}
cx={xFor(hoverIndex)}
cy={yFor(hovered[series.key])}
r="3.5"
fill={series.color}
stroke="var(--bf-appearance-token-element-bg-soft)"
strokeWidth="1"
/>
))}
{hoveredHitRate !== null && (
<circle
cx={xFor(hoverIndex)}
cy={rateFor(hoveredHitRate)}
r="3.5"
fill={SERIES_COLORS.cacheHitRate}
stroke="var(--bf-appearance-token-element-bg-soft)"
strokeWidth="1"
/>
)}
<g className="bitfun-usage-stats__trend-tooltip">
<rect
x={Math.min(Math.max(xFor(hoverIndex) - 92, PAD_LEFT), CHART_WIDTH - PAD_RIGHT - 184)}
Expand All @@ -548,12 +611,12 @@ const TrendChart: React.FC<TrendChartProps> = ({ points, granularity, timeZone }
{[
{ label: t('trend.legend.input'), value: hovered.inputTokens, color: SERIES_COLORS.input },
{ label: t('trend.legend.output'), value: hovered.outputTokens, color: SERIES_COLORS.output },
{ label: t('trend.legend.cacheCreation'), value: hovered.cacheWriteTokens, color: SERIES_COLORS.cacheCreation },
{ label: t('trend.legend.cacheRead'), value: hovered.cacheReadTokens, color: SERIES_COLORS.cacheRead },
{
label: t('trend.legend.cacheHitRate'),
value: hovered.cacheHitRate === null ? null : `${Math.round(hovered.cacheHitRate * 100)}%`,
value: hoveredHitRate,
color: SERIES_COLORS.cacheHitRate,
isRate: true,
},
].map((row, index) => (
<text
Expand All @@ -563,7 +626,12 @@ const TrendChart: React.FC<TrendChartProps> = ({ points, granularity, timeZone }
className="bitfun-usage-stats__trend-tooltip-row"
>
<tspan fill={row.color}>● </tspan>
{row.label}: {row.value === null ? '–' : formatTokens(row.value as number)}
{row.label}:{' '}
{row.isRate
? formatHitRate(row.value as number | null)
: row.value === null
? '–'
: formatTokens(row.value as number)}
</text>
))}
</g>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@
"legend": {
"input": "Input",
"output": "Output",
"cacheCreation": "Cache Creation",
"cacheRead": "Cache Read",
"cacheHitRate": "Cache Hit Rate"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@
"legend": {
"input": "Input",
"output": "Output",
"cacheCreation": "Cache Creation",
"cacheRead": "Cache Read",
"cacheHitRate": "缓存命中率"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@
"legend": {
"input": "Input",
"output": "Output",
"cacheCreation": "Cache Creation",
"cacheRead": "Cache Read",
"cacheHitRate": "快取命中率"
}
Expand Down