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
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 (
<details className="background-run" open={open}
data-openbitfun-component="background-run" data-openbitfun-part="root"
Expand All @@ -29,7 +31,8 @@ export function BackgroundRunItem({ turn, absoluteTurnIndex, forceOpen = false,
<UserMessageItem message={turn.userMessage} turnId={turn.id} absoluteTurnIndex={absoluteTurnIndex} turnStatus={turn.status} />
{turn.modelRounds.map((round, index) => <ModelRoundItem key={round.id} round={round} turnId={turn.id}
expandedThinkingItemIds={expandedThinkingItemIds} isLastRound={index === turn.modelRounds.length - 1} isTurnComplete={completed}
turnStartedAt={turn.startTime} turnEndedAt={turn.endTime} turnTokenUsage={turn.tokenUsage} />)}
turnStartedAt={turn.startTime} turnEndedAt={turn.endTime}
turnOutputDurationMs={turnOutputDurationMs} turnTokenUsage={turn.tokenUsage} />)}
</div>
) : null}
</details>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
turnStartedAt,
turnEndedAt,
turnDurationMs,
turnOutputDurationMs,
turnTokenUsage,
canvasArtifactItems = [],
expandedThinkingItemIds = [],
Expand Down Expand Up @@ -551,7 +552,9 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
);

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 ??
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export const VirtualItemRenderer = React.memo<VirtualItemRendererProps>(
turnStartedAt={item.turnStartedAt}
turnEndedAt={item.turnEndedAt}
turnDurationMs={item.turnDurationMs}
turnOutputDurationMs={item.turnOutputDurationMs}
turnTokenUsage={item.turnTokenUsage}
canvasArtifactItems={item.canvasArtifactItems}
expandedThinkingItemIds={item.layoutHints?.expandedThinkingItemIds ?? []}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface ModelRoundItemProps {
turnStartedAt?: number;
turnEndedAt?: number;
turnDurationMs?: number;
turnOutputDurationMs?: number;
turnTokenUsage?: TokenUsage;
canvasArtifactItems?: FlowToolItem[];
expandedThinkingItemIds?: string[];
Expand Down Expand Up @@ -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
);
Expand Down
3 changes: 3 additions & 0 deletions src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -667,6 +669,7 @@ describe('sessionToVirtualItems explore grouping', () => {
turnStartedAt: 900,
turnEndedAt: 2400,
turnDurationMs: 1500,
turnOutputDurationMs: 1000,
turnTokenUsage: {
inputTokens: 1200,
outputTokens: 300,
Expand Down
3 changes: 3 additions & 0 deletions src/web-ui/src/flow_chat/store/modernFlowChatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/web-ui/src/flow_chat/types/flow-chat-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Expand Down
52 changes: 50 additions & 2 deletions src/web-ui/src/flow_chat/utils/turnUsageMetrics.test.ts
Original file line number Diff line number Diff line change
@@ -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, 'streamDurationMs' | 'firstChunkMs' | 'firstVisibleOutputMs'>): 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({
Expand Down Expand Up @@ -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();
});
});
48 changes: 42 additions & 6 deletions src/web-ui/src/flow_chat/utils/turnUsageMetrics.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/web-ui/src/locales/en-US/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/web-ui/src/locales/zh-CN/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "快",
Expand Down
2 changes: 1 addition & 1 deletion src/web-ui/src/locales/zh-TW/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "快",
Expand Down
Loading