diff --git a/src/web-ui/src/flow_chat/components/modern/BackgroundRunItem.tsx b/src/web-ui/src/flow_chat/components/modern/BackgroundRunItem.tsx
index 950595561c..f677e1f8cb 100644
--- a/src/web-ui/src/flow_chat/components/modern/BackgroundRunItem.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/BackgroundRunItem.tsx
@@ -4,6 +4,7 @@ import type { DialogTurn } from '../../types/flow-chat';
import { UserMessageItem } from './UserMessageItem';
import { ModelRoundItem } from './ModelRoundItem';
import { backgroundDeliveryFromMetadata } from '../../utils/backgroundDelivery';
+import { getTurnOutputDurationMs } from '../../utils/turnUsageMetrics';
import './BackgroundRunItem.scss';
/** A delivery receipt is presentation only; expanding it reads the original execution. */
@@ -15,6 +16,7 @@ export function BackgroundRunItem({ turn, absoluteTurnIndex, forceOpen = false,
const [open, setOpen] = useState(forceOpen);
useEffect(() => { if (forceOpen) setOpen(true); }, [forceOpen]);
const completed = ['completed', 'finishing'].includes(turn.status);
+ const turnOutputDurationMs = getTurnOutputDurationMs(turn.modelRounds);
return (
{turn.modelRounds.map((round, index) => )}
+ turnStartedAt={turn.startTime} turnEndedAt={turn.endTime}
+ turnOutputDurationMs={turnOutputDurationMs} turnTokenUsage={turn.tokenUsage} />)}
) : null}
diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx
index 1133c34dc5..77044a589c 100644
--- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx
@@ -371,6 +371,7 @@ export const ModelRoundItem = React.memo(
turnStartedAt,
turnEndedAt,
turnDurationMs,
+ turnOutputDurationMs,
turnTokenUsage,
canvasArtifactItems = [],
expandedThinkingItemIds = [],
@@ -551,7 +552,9 @@ export const ModelRoundItem = React.memo(
);
const completedAt = turnEndedAt ?? round.endTime;
- const usageDurationMs = turnDurationMs ??
+ // Throughput must divide by streaming time, not turn wall time: the latter
+ // also counts tool execution and waiting and would report a deflated rate.
+ const usageDurationMs = turnOutputDurationMs ?? turnDurationMs ??
(typeof turnStartedAt === 'number' && typeof completedAt === 'number'
? Math.max(0, completedAt - turnStartedAt) : undefined);
const effectiveDurationMs = turnDurationMs ??
diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx
index 384e970afd..e370e13a5e 100644
--- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx
@@ -100,6 +100,7 @@ export const VirtualItemRenderer = React.memo(
turnStartedAt={item.turnStartedAt}
turnEndedAt={item.turnEndedAt}
turnDurationMs={item.turnDurationMs}
+ turnOutputDurationMs={item.turnOutputDurationMs}
turnTokenUsage={item.turnTokenUsage}
canvasArtifactItems={item.canvasArtifactItems}
expandedThinkingItemIds={item.layoutHints?.expandedThinkingItemIds ?? []}
diff --git a/src/web-ui/src/flow_chat/components/modern/modelRoundItemMemo.ts b/src/web-ui/src/flow_chat/components/modern/modelRoundItemMemo.ts
index 4060db0eb8..5acb023f56 100644
--- a/src/web-ui/src/flow_chat/components/modern/modelRoundItemMemo.ts
+++ b/src/web-ui/src/flow_chat/components/modern/modelRoundItemMemo.ts
@@ -13,6 +13,7 @@ export interface ModelRoundItemProps {
turnStartedAt?: number;
turnEndedAt?: number;
turnDurationMs?: number;
+ turnOutputDurationMs?: number;
turnTokenUsage?: TokenUsage;
canvasArtifactItems?: FlowToolItem[];
expandedThinkingItemIds?: string[];
@@ -59,6 +60,7 @@ export function areModelRoundItemPropsEqual(prev: ModelRoundItemProps, next: Mod
prev.turnStartedAt === next.turnStartedAt &&
prev.turnEndedAt === next.turnEndedAt &&
prev.turnDurationMs === next.turnDurationMs &&
+ prev.turnOutputDurationMs === next.turnOutputDurationMs &&
prev.turnTokenUsage === next.turnTokenUsage &&
prev.canvasArtifactItems === next.canvasArtifactItems
);
diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts
index 0fc29fa7b2..905eef7759 100644
--- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts
+++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts
@@ -647,6 +647,8 @@ describe('sessionToVirtualItems explore grouping', () => {
modelRounds: [makeRound({
id: 'round-with-answer',
items: [makeTextItem('text-final', 'Here is the answer.')],
+ streamDurationMs: 1200,
+ firstChunkMs: 200,
})],
status: 'completed',
startTime: 900,
@@ -667,6 +669,7 @@ describe('sessionToVirtualItems explore grouping', () => {
turnStartedAt: 900,
turnEndedAt: 2400,
turnDurationMs: 1500,
+ turnOutputDurationMs: 1000,
turnTokenUsage: {
inputTokens: 1200,
outputTokens: 300,
diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts
index 34a64a29bf..e4e28bd403 100644
--- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts
+++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts
@@ -13,6 +13,7 @@ import { flowChatStore } from './FlowChatStore';
import { getTurnCompletionNotice } from '../utils/turnCompletionNotice';
import { createAbsoluteSessionTurnIndexResolver } from '../utils/flowChatTurnOrdinal';
import { collectCanvasArtifactToolItems } from '../utils/canvasArtifactPresentation';
+import { getTurnOutputDurationMs } from '../utils/turnUsageMetrics';
import { projectAdjacentFlowGroups } from '../grouping/groupProjection';
import { hasModelRoundNarrative } from '../grouping/roundGroups';
import { isSuppressedBackgroundTurn } from '../utils/backgroundDelivery';
@@ -261,6 +262,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] {
});
const isTurnComplete = turn.status === 'completed' || turn.status === 'cancelled' || turn.status === 'error';
+ const turnOutputDurationMs = getTurnOutputDurationMs(turn.modelRounds);
const canvasArtifactItems = collectCanvasArtifactToolItems(turn.modelRounds);
const canvasAttachmentHostRoundId = [...renderEntries]
.reverse()
@@ -293,6 +295,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] {
turnDurationMs: typeof turn.endTime === 'number'
? Math.max(0, turn.endTime - turn.startTime)
: undefined,
+ turnOutputDurationMs,
turnTokenUsage: turn.tokenUsage,
canvasArtifactItems: isTurnComplete && round.id === canvasAttachmentHostRoundId
? canvasArtifactItems
diff --git a/src/web-ui/src/flow_chat/types/flow-chat-projection.ts b/src/web-ui/src/flow_chat/types/flow-chat-projection.ts
index 5358f8cd72..a7cb6b0adc 100644
--- a/src/web-ui/src/flow_chat/types/flow-chat-projection.ts
+++ b/src/web-ui/src/flow_chat/types/flow-chat-projection.ts
@@ -64,6 +64,8 @@ type VirtualContent =
turnStartedAt?: number;
turnEndedAt?: number;
turnDurationMs?: number;
+ /** Streaming window only; the throughput denominator, not turn wall time. */
+ turnOutputDurationMs?: number;
turnTokenUsage?: TokenUsage;
canvasArtifactItems?: FlowToolItem[];
}
diff --git a/src/web-ui/src/flow_chat/utils/turnUsageMetrics.test.ts b/src/web-ui/src/flow_chat/utils/turnUsageMetrics.test.ts
index 402cf8185d..74e8779151 100644
--- a/src/web-ui/src/flow_chat/utils/turnUsageMetrics.test.ts
+++ b/src/web-ui/src/flow_chat/utils/turnUsageMetrics.test.ts
@@ -1,12 +1,19 @@
import { describe, expect, it } from 'vitest';
-import type { TokenUsage } from '../types/flow-chat';
-import { getTurnUsageMetrics } from './turnUsageMetrics';
+import type { ModelRound, TokenUsage } from '../types/flow-chat';
+import { getTurnOutputDurationMs, getTurnUsageMetrics } from './turnUsageMetrics';
const usage: TokenUsage = {
inputTokens: 10_000, cachedTokens: 7_200, outputTokens: 2_800,
totalTokens: 12_800, timestamp: 1,
};
+function round(timing: Pick): ModelRound {
+ return {
+ id: 'round', index: 0, items: [], isStreaming: false, isComplete: true,
+ status: 'completed', startTime: 0, ...timing,
+ };
+}
+
describe('getTurnUsageMetrics', () => {
it('uses input tokens as the cache denominator and output tokens as the rate numerator', () => {
expect(getTurnUsageMetrics(usage, 40_000)).toEqual({
@@ -40,4 +47,45 @@ describe('getTurnUsageMetrics', () => {
expect(getTurnUsageMetrics({ ...usage, cachedTokens: NaN, totalTokens: Infinity, outputTokens: -1 }, 1_000))
.toEqual({ totalTokens: null, cacheHitRate: null, outputRate: null, speedLevel: null });
});
+
+ it('divides by streaming time so tool execution cannot deflate the rate', () => {
+ const rounds = [round({ streamDurationMs: 12_000, firstChunkMs: 2_000 })];
+ // The turn took 90s wall time; the model only streamed for 10s.
+ expect(getTurnUsageMetrics({ ...usage, outputTokens: 2_000 }, getTurnOutputDurationMs(rounds)))
+ .toMatchObject({ outputRate: 200, speedLevel: 4 });
+ });
+});
+
+describe('getTurnOutputDurationMs', () => {
+ it('subtracts time to the first upstream item from the stream window', () => {
+ expect(getTurnOutputDurationMs([round({ streamDurationMs: 12_000, firstChunkMs: 2_000 })])).toBe(10_000);
+ });
+
+ it('falls back to the visible-output mark when the first chunk is unrecorded', () => {
+ expect(getTurnOutputDurationMs([round({ streamDurationMs: 9_000, firstVisibleOutputMs: 1_500 })])).toBe(7_500);
+ });
+
+ it('uses the whole window when no first-output mark exists', () => {
+ expect(getTurnOutputDurationMs([round({ streamDurationMs: 9_000 })])).toBe(9_000);
+ });
+
+ it('sums every round of a multi-round turn', () => {
+ expect(getTurnOutputDurationMs([
+ round({ streamDurationMs: 5_000, firstChunkMs: 1_000 }),
+ round({ streamDurationMs: 7_000, firstChunkMs: 2_000 }),
+ ])).toBe(9_000);
+ });
+
+ it('never reports a negative or invented window', () => {
+ expect(getTurnOutputDurationMs([round({ streamDurationMs: 1_000, firstChunkMs: 4_000 })])).toBeUndefined();
+ expect(getTurnOutputDurationMs([round({ streamDurationMs: 2_000, firstChunkMs: 2_000 })])).toBeUndefined();
+ });
+
+ it('skips rounds without stream timing and reports nothing when none have it', () => {
+ expect(getTurnOutputDurationMs([round({}), round({ streamDurationMs: 6_000, firstChunkMs: 1_000 })]))
+ .toBe(5_000);
+ expect(getTurnOutputDurationMs([round({}), round({})])).toBeUndefined();
+ expect(getTurnOutputDurationMs([])).toBeUndefined();
+ expect(getTurnOutputDurationMs(undefined)).toBeUndefined();
+ });
});
diff --git a/src/web-ui/src/flow_chat/utils/turnUsageMetrics.ts b/src/web-ui/src/flow_chat/utils/turnUsageMetrics.ts
index 64dbd4a3a7..4d46ed93f5 100644
--- a/src/web-ui/src/flow_chat/utils/turnUsageMetrics.ts
+++ b/src/web-ui/src/flow_chat/utils/turnUsageMetrics.ts
@@ -1,18 +1,54 @@
-import type { TokenUsage } from '../types/flow-chat';
+import type { ModelRound, TokenUsage } from '../types/flow-chat';
function isCount(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
-export function getTurnUsageMetrics(usage?: TokenUsage, durationMs?: number) {
+function isPositiveMs(value: unknown): value is number {
+ return typeof value === 'number' && Number.isFinite(value) && value > 0;
+}
+
+/**
+ * Round-level milliseconds spent actually receiving model output.
+ *
+ * `streamDurationMs` already excludes request handshake and provider queueing,
+ * but it still starts before the first token arrives. Subtract the time to the
+ * first upstream item so prompt prefill and reasoning warm-up are not charged
+ * to generation.
+ */
+function roundOutputDurationMs(round: ModelRound): number | undefined {
+ if (!isPositiveMs(round.streamDurationMs)) return undefined;
+ const firstOutputMs = isPositiveMs(round.firstChunkMs) ? round.firstChunkMs
+ : isPositiveMs(round.firstVisibleOutputMs) ? round.firstVisibleOutputMs : 0;
+ return Math.max(0, round.streamDurationMs - Math.min(firstOutputMs, round.streamDurationMs));
+}
+
+/**
+ * Milliseconds a turn spent streaming model output, summed over its rounds.
+ *
+ * Turn wall time also covers tool execution, permission waits and retries, so
+ * dividing output tokens by it understates throughput by a wide margin. Returns
+ * undefined when no round reports stream timing (older sessions), letting the
+ * caller fall back instead of inventing a rate.
+ */
+export function getTurnOutputDurationMs(rounds?: readonly ModelRound[]): number | undefined {
+ if (!rounds?.length) return undefined;
+ let totalMs = 0;
+ for (const round of rounds) {
+ totalMs += roundOutputDurationMs(round) ?? 0;
+ }
+ return totalMs > 0 ? totalMs : undefined;
+}
+
+export function getTurnUsageMetrics(usage?: TokenUsage, outputDurationMs?: number) {
const totalTokens = isCount(usage?.totalTokens) ? usage.totalTokens : null;
const cacheHitRate = isCount(usage?.cachedTokens) && isCount(usage?.inputTokens)
&& usage.inputTokens > 0 && usage.cachedTokens <= usage.inputTokens
? usage.cachedTokens / usage.inputTokens : null;
- // Turn wall time is available live and after hydration, including tool/wait time.
- // It must not be presented as the provider's pure streaming throughput.
- const rate = isCount(usage?.outputTokens) && isCount(durationMs) && durationMs > 0
- ? usage.outputTokens / (durationMs / 1000) : null;
+ // `outputDurationMs` must be the streaming window from `getTurnOutputDurationMs`,
+ // never turn wall time: tool execution and waiting would deflate the rate.
+ const rate = isCount(usage?.outputTokens) && isCount(outputDurationMs) && outputDurationMs > 0
+ ? usage.outputTokens / (outputDurationMs / 1000) : null;
const outputRate = rate !== null && Number.isFinite(rate) ? rate : null;
const speedLevel: 1 | 2 | 3 | 4 | null = outputRate === null ? null
: outputRate < 15 ? 1 : outputRate < 40 ? 2 : outputRate < 80 ? 3 : 4;
diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json
index d6db059fa2..3ad4dce79c 100644
--- a/src/web-ui/src/locales/en-US/flow-chat.json
+++ b/src/web-ui/src/locales/en-US/flow-chat.json
@@ -1732,7 +1732,7 @@
"averageRate": "Average output",
"speedLevel": "Speed level",
"rateValue": "{{value}} t/s",
- "rateBasis": "Output tokens divided by total turn time, including tools and waiting; speed levels change at 15, 40 and 80 tokens/s",
+ "rateBasis": "Output tokens divided by the time from the first token to end of stream, excluding tool execution, queueing and waiting; speed levels change at 15, 40 and 80 tokens/s",
"slow": "Slow",
"normal": "Normal",
"fast": "Fast",
diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json
index ea5e6a4471..4cf1ab09bc 100644
--- a/src/web-ui/src/locales/zh-CN/flow-chat.json
+++ b/src/web-ui/src/locales/zh-CN/flow-chat.json
@@ -1732,7 +1732,7 @@
"averageRate": "平均输出",
"speedLevel": "速度档位",
"rateValue": "{{value}} t/s",
- "rateBasis": "输出 Token 除以本轮总耗时,包含工具执行与等待;档位分界为 15、40、80 Token/秒",
+ "rateBasis": "输出 Token 除以从首个 Token 到流结束的时长,不含工具执行、排队与等待;档位分界为 15、40、80 Token/秒",
"slow": "慢",
"normal": "一般",
"fast": "快",
diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json
index 6eb4f23aa4..2fe94fd61e 100644
--- a/src/web-ui/src/locales/zh-TW/flow-chat.json
+++ b/src/web-ui/src/locales/zh-TW/flow-chat.json
@@ -1732,7 +1732,7 @@
"averageRate": "平均輸出",
"speedLevel": "速度檔位",
"rateValue": "{{value}} t/s",
- "rateBasis": "輸出 Token 除以本輪總耗時,包含工具執行與等待;檔位分界為 15、40、80 Token/秒",
+ "rateBasis": "輸出 Token 除以從首個 Token 到流結束的時長,不含工具執行、排隊與等待;檔位分界為 15、40、80 Token/秒",
"slow": "慢",
"normal": "一般",
"fast": "快",