🐛 fix(chat): track operation usage in status tray (#15736)

This commit is contained in:
Arvin Xu
2026-06-13 11:55:39 +08:00
committed by GitHub
parent 03b9d07d0b
commit 800b534741
16 changed files with 492 additions and 92 deletions
+34
View File
@@ -0,0 +1,34 @@
{
"generatingPhrases": [
"Working",
"Drafting",
"Thinking",
"Computing",
"Brewing",
"Synthesizing",
"Crunching",
"Architecting",
"Composing",
"Orchestrating",
"Sketching",
"Noodling",
"Pondering",
"Crafting",
"Flambéing",
"Simmering",
"Whirring",
"Wrangling",
"Polishing",
"Preparing the answer",
"Baking",
"Channeling",
"Coalescing",
"Deciphering",
"Forging",
"Harmonizing",
"Improvising",
"Inferring",
"Tinkering",
"Zigzagging"
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"generatingPhrases": [
"处理中",
"思考中",
"推理中",
"分析中",
"检索线索中",
"梳理上下文中",
"整理思路中",
"生成回复中",
"准备答案中",
"撰写内容中",
"组织语言中",
"完善表达中",
"核对细节中",
"提炼重点中",
"串联信息中",
"构建方案中",
"拆解问题中",
"验证思路中",
"整合结果中",
"润色答案中",
"继续推进中",
"认真执行中",
"快速处理中",
"稳步推进中",
"灵感生成中",
"校准方向中",
"收束结论中",
"补全细节中",
"输出结果中",
"收尾处理中"
]
}
+2 -3
View File
@@ -21,12 +21,11 @@ import defaultHome from '@/locales/default/home';
import { normalizeLocale } from '@/locales/resources';
import { isOnServerSide } from '@/utils/env';
import { unwrapESMModule } from '@/utils/esm/unwrapESMModule';
import { loadI18nNamespaceModule } from '@/utils/i18n/loadI18nNamespaceModule';
const mergeNamespace = (
fallbackResources: Record<string, string>,
localeResources: Record<string, string>,
fallbackResources: Record<string, unknown>,
localeResources: Record<string, unknown>,
) => ({
...fallbackResources,
...localeResources,
+2
View File
@@ -32,6 +32,7 @@ import notification from './notification';
import oauth from './oauth';
import onboarding from './onboarding';
import openInApp from './openInApp';
import opStatusTray from './opStatusTray';
import pageShare from './pageShare';
import plugin from './plugin';
import portal from './portal';
@@ -84,6 +85,7 @@ const resources = {
notification,
oauth,
onboarding,
opStatusTray,
openInApp,
pageShare,
plugin,
@@ -0,0 +1,34 @@
export default {
generatingPhrases: [
'Working',
'Drafting',
'Thinking',
'Computing',
'Brewing',
'Synthesizing',
'Crunching',
'Architecting',
'Composing',
'Orchestrating',
'Sketching',
'Noodling',
'Pondering',
'Crafting',
'Flambéing',
'Simmering',
'Whirring',
'Wrangling',
'Polishing',
'Preparing the answer',
'Baking',
'Channeling',
'Coalescing',
'Deciphering',
'Forging',
'Harmonizing',
'Improvising',
'Inferring',
'Tinkering',
'Zigzagging',
],
};
@@ -14,8 +14,15 @@ import {
type OperationType,
} from '@/store/chat/slices/operation/types';
import { shinyTextStyles } from '@/styles';
import {
calculateOperationUsageMetrics,
hasOperationUsageMetrics,
mergeOperationUsageMetrics,
type OperationUsageMetrics,
} from '@/utils/operationUsageMetrics';
import { contextSelectors, dataSelectors, useConversationStore } from '../store';
import { parseStatusPhrases, pickStableStatusPhrase } from './OpStatusTray/logic';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
@@ -240,108 +247,109 @@ interface MetricItem {
}
const OpStatusTray = memo<OpStatusTrayProps>(({ topAttached }) => {
const { t } = useTranslation('chat');
const { t } = useTranslation(['chat', 'opStatusTray']);
const context = useConversationStore(contextSelectors.context);
const dbMessages = useConversationStore(dataSelectors.dbMessages);
// Detect any running AI-runtime op (excludes sub-ops like callLLM/toolCalling)
// and capture the earliest start time as the op's anchor.
const startTime = useChatStore((s) => {
const operationState = useChatStore((s) => {
const ops = operationSelectors.getOperationsByContext(context)(s);
let earliest: number | undefined;
for (const op of ops) {
if (
op.status !== 'running' ||
op.metadata.isAborting ||
!AI_RUNTIME_OPERATION_TYPES.includes(op.type)
) {
continue;
}
if (earliest === undefined || op.metadata.startTime < earliest) {
earliest = op.metadata.startTime;
}
}
return earliest;
});
let activity: ActivityKey | undefined;
let earliestStart: number | undefined;
let latestActivityStart = -1;
let statusSeed: string | undefined;
let stepCount = 0;
let usageMetrics: OperationUsageMetrics | undefined;
const runtimeOperationIds: string[] = [];
// The most recently started running sub-op decides the streaming phase.
// Server-side runtimes surface no sub-ops on the client, so fall back to
// 'generating' — the dominant phase for plain server-streamed chat.
const activity = useChatStore((s): ActivityKey => {
const ops = operationSelectors.getOperationsByContext(context)(s);
let current: ActivityKey | undefined;
let latest = -1;
for (const op of ops) {
if (op.status !== 'running' || op.metadata.isAborting) continue;
const mapped = resolveActivity(op.type);
if (!mapped) continue;
if (op.metadata.startTime > latest) {
latest = op.metadata.startTime;
current = mapped;
if (mapped && op.metadata.startTime > latestActivityStart) {
latestActivityStart = op.metadata.startTime;
activity = mapped;
}
}
return current ?? 'generating';
});
const steps = useChatStore((s) => {
const ops = operationSelectors.getOperationsByContext(context)(s);
let stepCount = 0;
for (const op of ops) {
if (
op.status !== 'running' ||
op.metadata.isAborting ||
!AI_RUNTIME_OPERATION_TYPES.includes(op.type)
) {
if (!AI_RUNTIME_OPERATION_TYPES.includes(op.type)) {
continue;
}
runtimeOperationIds.push(op.id);
stepCount = Math.max(stepCount, normalizeStepCount(op.metadata.stepCount));
}
if (hasOperationUsageMetrics(op.metadata.usageMetrics)) {
usageMetrics = mergeOperationUsageMetrics(usageMetrics, op.metadata.usageMetrics);
}
return stepCount;
if (earliestStart === undefined || op.metadata.startTime < earliestStart) {
earliestStart = op.metadata.startTime;
statusSeed = op.id;
}
}
return {
activity: activity ?? 'generating',
operationIdsKey: runtimeOperationIds.join('|'),
startTime: earliestStart,
statusSeed,
steps: stepCount,
usageMetrics,
};
});
const operationsByMessage = useChatStore((s) => s.operationsByMessage);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!startTime) return;
if (!operationState.startTime) return;
setNow(Date.now());
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [startTime]);
}, [operationState.startTime]);
// Aggregate tokens / cost across the current conversation.
// New code reads usage only from the top-level message field.
const { totalCost, totalTokens } = useMemo(() => {
let tokens = 0;
let cost = 0;
for (const m of dbMessages) {
if (m.role !== 'assistant') continue;
const usage = m.usage;
if (!usage) continue;
tokens += usage.totalTokens ?? 0;
cost += usage.cost ?? 0;
}
return { totalCost: cost, totalTokens: tokens };
}, [dbMessages]);
const operationIds = useMemo(
() => new Set(operationState.operationIdsKey.split('|').filter(Boolean)),
[operationState.operationIdsKey],
);
if (!startTime) return null;
// Fallback for older / reloaded operation state: derive usage from messages
// produced by this operation when live operation metadata is unavailable.
const fallbackMetrics = useMemo(() => {
return calculateOperationUsageMetrics(dbMessages, operationIds, operationsByMessage);
}, [dbMessages, operationIds, operationsByMessage]);
const elapsed = now - startTime;
const costLabel = t('opStatusTray.cost');
const stepLabel = t('opStatusTray.steps');
const tokenLabel = t('opStatusTray.tokens', { defaultValue: 'tokens' });
if (!operationState.startTime) return null;
const { totalCost, totalTokens } = hasOperationUsageMetrics(operationState.usageMetrics)
? operationState.usageMetrics
: fallbackMetrics;
const elapsed = now - operationState.startTime;
const costLabel = t('chat:opStatusTray.cost');
const stepLabel = t('chat:opStatusTray.steps');
const tokenLabel = t('chat:opStatusTray.tokens', { defaultValue: 'tokens' });
const generatingPhrases = parseStatusPhrases(
t('opStatusTray:generatingPhrases', {
defaultValue: [],
returnObjects: true,
}),
);
const randomGeneratingStatus =
pickStableStatusPhrase(
generatingPhrases,
operationState.statusSeed ?? String(operationState.startTime),
) ?? t('chat:opStatusTray.status.generating');
const statusText =
operationState.activity === 'generating'
? randomGeneratingStatus
: t(`chat:opStatusTray.status.${operationState.activity}`);
// Zero-valued metrics render nothing; steps only matter for long-running
// multi-step tasks, so a single step stays hidden too.
const metrics = [
steps > 1
operationState.steps > 1
? {
icon: FootprintsIcon,
key: 'steps',
label: stepLabel,
title: `${steps} ${stepLabel}`,
value: String(steps),
title: `${operationState.steps} ${stepLabel}`,
value: String(operationState.steps),
}
: undefined,
totalTokens > 0
@@ -404,9 +412,7 @@ const OpStatusTray = memo<OpStatusTrayProps>(({ topAttached }) => {
>
<span className={cx(styles.metric, styles.statusMetric)}>
<ActivityGlyph />
<span className={cx(styles.statusText, shinyTextStyles.shinyText)}>
{t(`opStatusTray.status.${activity}`)}...
</span>
<span className={cx(styles.statusText, shinyTextStyles.shinyText)}>{statusText}...</span>
<span className={styles.timerValue}>{formatDuration(elapsed)}</span>
</span>
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { parseStatusPhrases, pickStableStatusPhrase } from './logic';
describe('OpStatusTray logic', () => {
describe('status phrases', () => {
it('parses a pipe-delimited localized phrase list', () => {
expect(parseStatusPhrases('Working | Flambéing | 努力干活中 | ')).toEqual([
'Working',
'Flambéing',
'努力干活中',
]);
});
it('parses a localized phrase array', () => {
expect(parseStatusPhrases(['Working', 'Flambéing', '', 1])).toEqual(['Working', 'Flambéing']);
});
it('picks a stable phrase for the same operation seed', () => {
const phrases = ['Working', 'Thinking', 'Flambéing'];
expect(pickStableStatusPhrase(phrases, 'op-123')).toBe(
pickStableStatusPhrase(phrases, 'op-123'),
);
expect(phrases).toContain(pickStableStatusPhrase(phrases, 'op-123'));
});
});
});
@@ -0,0 +1,29 @@
export const parseStatusPhrases = (raw: unknown): string[] => {
if (Array.isArray(raw)) {
return raw
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean);
}
if (typeof raw !== 'string') return [];
return raw
.split('|')
.map((item) => item.trim())
.filter(Boolean);
};
const hashString = (input: string): number => {
let hash = 0x81_1c_9d_c5;
for (let i = 0; i < input.length; i += 1) {
hash ^= input.charCodeAt(i);
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
}
return hash;
};
export const pickStableStatusPhrase = (phrases: string[], seed: string): string | undefined => {
if (phrases.length === 0) return undefined;
return phrases[hashString(seed) % phrases.length];
};
@@ -37,7 +37,10 @@ function createMockStore() {
internal_toggleToolCallingStreaming: vi.fn(),
markUnreadCompleted: vi.fn(),
operations: {
'op-1': { context: { agentId: 'agent-1', scope: 'session', topicId: 'topic-1' } },
'op-1': {
context: { agentId: 'agent-1', scope: 'session', topicId: 'topic-1' },
metadata: { startTime: 0, usageMetrics: { totalTokens: 100 } },
},
} as Record<string, any>,
replaceMessages: vi.fn(),
startOperation: vi.fn(() => {
@@ -622,6 +625,29 @@ describe('createGatewayEventHandler', () => {
expect(store.replaceMessages).not.toHaveBeenCalled();
});
it('should accumulate turn metadata usage onto the operation', async () => {
const store = createMockStore();
const handler = createHandler(store);
handler(
makeEvent('step_complete', {
phase: 'turn_metadata',
usage: { cost: 0.2, totalInputTokens: 40, totalOutputTokens: 10, totalTokens: 50 },
}),
);
await flush();
expect(store.updateOperationMetadata).toHaveBeenCalledWith('op-1', {
usageMetrics: {
totalCost: 0.2,
totalInputTokens: 40,
totalOutputTokens: 10,
totalTokens: 150,
},
});
expect(store.replaceMessages).not.toHaveBeenCalled();
});
});
describe('agent_runtime_end', () => {
@@ -159,14 +159,19 @@ function createMockStore(overrides: Record<string, any> = {}) {
// for each subagent run, mirroring `startOperation`'s contract just
// enough that the executor can build dispatchers + completion calls.
let subOpCounter = 0;
return {
const store = {
associateMessageWithOperation: vi.fn(),
completeOperation: vi.fn(),
drainQueuedMessages: vi.fn(() => []),
internal_dispatchMessage: vi.fn(),
internal_toggleToolCallingStreaming: vi.fn(),
markUnreadCompleted: vi.fn(),
operations: {} as Record<string, any>,
operations: {
'op-1': {
context: { agentId: 'agent-1', scope: 'main', topicId: 'topic-1' },
metadata: { startTime: 0 },
},
} as Record<string, any>,
refreshMessages: vi.fn(async () => {}),
refreshThreads: vi.fn(async () => {}),
replaceMessages: vi.fn(),
@@ -181,6 +186,19 @@ function createMockStore(overrides: Record<string, any> = {}) {
updateTopicMetadata: vi.fn().mockResolvedValue(undefined),
...overrides,
} as any;
if (!store.updateOperationMetadata) {
store.updateOperationMetadata = vi.fn((operationId: string, metadata: Record<string, any>) => {
const operation = store.operations[operationId];
if (!operation) return;
operation.metadata = {
...operation.metadata,
...metadata,
};
});
}
return store;
}
const defaultContext = {
@@ -726,7 +744,7 @@ describe('heterogeneousAgentExecutor DB persistence', () => {
// Realistic CC partial-messages flow: message_start primes the turn,
// assistant events echo a stale usage, message_delta carries the final.
await runWithEvents([
const { store } = await runWithEvents([
ccInit(),
ccMessageStart('msg_01'),
ccAssistant('msg_01', [{ text: 'a', type: 'text' }]),
@@ -772,6 +790,13 @@ describe('heterogeneousAgentExecutor DB persistence', () => {
// No cache tokens for this turn — these fields should be absent
expect(u2.inputCachedTokens).toBeUndefined();
expect(u2.inputWriteCacheTokens).toBeUndefined();
expect(store.operations['op-1'].metadata.usageMetrics).toEqual({
totalCost: 0,
totalInputTokens: 650,
totalOutputTokens: 130,
totalTokens: 780,
});
});
it('should ignore stale usage on assistant events (from message_start echo)', async () => {
@@ -19,6 +19,7 @@ import { messageService } from '@/services/message';
import { emitClientAgentSignalSourceEvent } from '@/store/chat/slices/aiChat/actions/agentSignalBridge';
import type { ChatStore } from '@/store/chat/store';
import { notifyDesktopHumanApprovalRequired } from '@/store/chat/utils/desktopNotification';
import { addUsageToOperationMetrics, type OperationUsageLike } from '@/utils/operationUsageMetrics';
// Lazy-loaded to break the import cycle:
// gateway.ts → gatewayEventHandler.ts → executors/index.ts (which pulls in
@@ -55,6 +56,10 @@ interface ToolPayloadIdentity {
toolCallId?: string;
}
type StepCompleteDataWithUsage = StepCompleteData & {
usage?: OperationUsageLike | null;
};
/**
* Extract `{ identifier, apiName, params, toolCallId }` from a stream event's
* tool payload. Returns `undefined` when the payload is malformed so the
@@ -500,7 +505,14 @@ export const createGatewayEventHandler = (
}
case 'step_complete': {
const data = event.data as StepCompleteData | undefined;
const data = event.data as StepCompleteDataWithUsage | undefined;
if (data?.phase === 'turn_metadata' && data.usage) {
const operation = get().operations[operationId];
get().updateOperationMetadata(operationId, {
usageMetrics: addUsageToOperationMetrics(operation?.metadata?.usageMetrics, data.usage),
});
}
// Refresh on execution_complete to ensure final step state is consistent
if (data?.phase === 'execution_complete') {
@@ -39,6 +39,7 @@ import { threadService } from '@/services/thread';
import { type ChatStore, useChatStore } from '@/store/chat/store';
import { resolveNotificationNavigatePath } from '@/store/chat/utils/desktopNotification';
import { markdownToTxt } from '@/utils/markdownToTxt';
import { addUsageToOperationMetrics } from '@/utils/operationUsageMetrics';
import { messageMapKey } from '../../../utils/messageMapKey';
import { mergeQueuedMessages } from '../../operation/types';
@@ -1227,6 +1228,13 @@ export const executeHeterogeneousAgent = async (
return;
}
if (turnUsage) {
const operation = get().operations[operationId];
get().updateOperationMetadata(operationId, {
usageMetrics: addUsageToOperationMetrics(operation?.metadata?.usageMetrics, turnUsage),
});
}
if (event.data.model) lastModel = event.data.model;
if (event.data.provider) lastProvider = event.data.provider;
const updateValue: Record<string, any> = {};
@@ -4,11 +4,11 @@ import type {
} from './loadI18nNamespaceModule';
// eager: true — all locale JSON inlined at build time, synchronous access at runtime
const defaultModules = import.meta.glob<{ default: Record<string, string> }>(
const defaultModules = import.meta.glob<{ default: Record<string, unknown> }>(
'/packages/locales/src/default/*.ts',
{ eager: true },
);
const localeModules = import.meta.glob<{ default: Record<string, string> }>('/locales/*/*.json', {
const localeModules = import.meta.glob<{ default: Record<string, unknown> }>('/locales/*/*.json', {
eager: true,
});
@@ -17,7 +17,7 @@ const getLocaleKey = (lng: string, ns: string) => `/locales/${lng}/${ns}.json`;
export const loadI18nNamespaceModule = async (
params: LoadI18nNamespaceModuleParams,
): Promise<{ default: Record<string, string> }> => {
): Promise<{ default: Record<string, unknown> }> => {
const { defaultLang, normalizeLocale, lng, ns } = params;
if (lng === defaultLang) {
@@ -42,7 +42,7 @@ export type {
export const loadI18nNamespaceModuleWithFallback = async (
params: LoadI18nNamespaceModuleWithFallbackParams,
): Promise<{ default: Record<string, string> }> => {
): Promise<{ default: Record<string, unknown> }> => {
const { onFallback, ...rest } = params;
try {
return await loadI18nNamespaceModule(rest);
@@ -4,36 +4,36 @@ import type {
} from './loadI18nNamespaceModule';
// Use import.meta.glob so Vite can statically analyze and avoid CJS/dynamic import issues
const defaultLoaders = import.meta.glob<{ default: Record<string, string> }>(
const defaultLoaders = import.meta.glob<{ default: Record<string, unknown> }>(
'/packages/locales/src/default/*.ts',
);
const localeLoaders = import.meta.glob<{ default: Record<string, string> }>('/locales/*/*.json');
const localeLoaders = import.meta.glob<{ default: Record<string, unknown> }>('/locales/*/*.json');
const getDefaultKey = (ns: string) => `/packages/locales/src/default/${ns}.ts`;
const getLocaleKey = (lng: string, ns: string) => `/locales/${lng}/${ns}.json`;
export const loadI18nNamespaceModule = async (
params: LoadI18nNamespaceModuleParams,
): Promise<{ default: Record<string, string> }> => {
): Promise<{ default: Record<string, unknown> }> => {
const { defaultLang, normalizeLocale, lng, ns } = params;
if (lng === defaultLang) {
const key = getDefaultKey(ns);
const load = defaultLoaders[key];
if (!load) throw new Error(`Missing default namespace: ${ns}`);
return load() as Promise<{ default: Record<string, string> }>;
return load() as Promise<{ default: Record<string, unknown> }>;
}
const normalizedLng = normalizeLocale(lng);
const localeKey = getLocaleKey(normalizedLng, ns);
const loadLocale = localeLoaders[localeKey];
if (loadLocale) {
return loadLocale() as Promise<{ default: Record<string, string> }>;
return loadLocale() as Promise<{ default: Record<string, unknown> }>;
}
const loadDefault = defaultLoaders[getDefaultKey(ns)];
if (!loadDefault) throw new Error(`Missing default namespace: ${ns}`);
return loadDefault() as Promise<{ default: Record<string, string> }>;
return loadDefault() as Promise<{ default: Record<string, unknown> }>;
};
export type {
@@ -43,7 +43,7 @@ export type {
export const loadI18nNamespaceModuleWithFallback = async (
params: LoadI18nNamespaceModuleWithFallbackParams,
): Promise<{ default: Record<string, string> }> => {
): Promise<{ default: Record<string, unknown> }> => {
const { onFallback, ...rest } = params;
try {
return await loadI18nNamespaceModule(rest);
@@ -51,6 +51,6 @@ export const loadI18nNamespaceModuleWithFallback = async (
onFallback?.({ error, lng: rest.lng, ns: rest.ns });
const loadDefault = defaultLoaders[getDefaultKey(rest.ns)];
if (!loadDefault) throw error;
return loadDefault() as Promise<{ default: Record<string, string> }>;
return loadDefault() as Promise<{ default: Record<string, unknown> }>;
}
};
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import {
addUsageToOperationMetrics,
calculateOperationUsageMetrics,
hasOperationUsageMetrics,
} from './operationUsageMetrics';
describe('operationUsageMetrics', () => {
describe('calculateOperationUsageMetrics', () => {
it('sums only assistant usage associated with the current operation', () => {
const metrics = calculateOperationUsageMetrics(
[
{ id: 'old-step', role: 'assistant', usage: { cost: 9, totalTokens: 9_000_000 } },
{
id: 'current-step-1',
role: 'assistant',
usage: { cost: 1, totalInputTokens: 900, totalOutputTokens: 300, totalTokens: 1200 },
},
{
id: 'current-step-2',
role: 'assistant',
usage: { cost: 2, totalInputTokens: 700, totalOutputTokens: 100, totalTokens: 800 },
},
{ id: 'current-tool', role: 'tool', usage: { cost: 9, totalTokens: 999_999 } },
{ id: 'unmapped', role: 'assistant', usage: { cost: 0.5, totalTokens: 5000 } },
],
new Set(['op-current']),
{
'current-step-1': ['op-current'],
'current-step-2': ['op-current', 'reasoning-op'],
'current-tool': ['op-current'],
'old-step': ['op-old'],
},
);
expect(metrics).toEqual({
totalCost: 3,
totalInputTokens: 1600,
totalOutputTokens: 400,
totalTokens: 2000,
});
});
it('returns zero metrics when no runtime operation is active', () => {
const metrics = calculateOperationUsageMetrics(
[{ id: 'message', role: 'assistant', usage: { cost: 0.01, totalTokens: 100 } }],
new Set(),
{ message: ['op-1'] },
);
expect(metrics).toEqual({
totalCost: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalTokens: 0,
});
});
});
describe('addUsageToOperationMetrics', () => {
it('adds a per-step usage delta onto existing operation metrics', () => {
const metrics = addUsageToOperationMetrics(
{ totalCost: 1, totalInputTokens: 100, totalOutputTokens: 50, totalTokens: 150 },
{ cost: 2, totalInputTokens: 300, totalOutputTokens: 80, totalTokens: 380 },
);
expect(metrics).toEqual({
totalCost: 3,
totalInputTokens: 400,
totalOutputTokens: 130,
totalTokens: 530,
});
expect(hasOperationUsageMetrics(metrics)).toBe(true);
});
});
});
+86
View File
@@ -0,0 +1,86 @@
export interface OperationUsageLike {
cost?: number | null;
totalInputTokens?: number | null;
totalOutputTokens?: number | null;
totalTokens?: number | null;
}
export interface OperationUsageMetrics {
totalCost: number;
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
}
interface OperationUsageMessage {
id: string;
role?: string;
usage?: OperationUsageLike | null;
}
const normalizeNumber = (value: unknown): number => {
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
return value;
};
export const EMPTY_OPERATION_USAGE_METRICS: OperationUsageMetrics = {
totalCost: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalTokens: 0,
};
export const mergeOperationUsageMetrics = (
left?: Partial<OperationUsageMetrics> | null,
right?: Partial<OperationUsageMetrics> | null,
): OperationUsageMetrics => ({
totalCost: normalizeNumber(left?.totalCost) + normalizeNumber(right?.totalCost),
totalInputTokens:
normalizeNumber(left?.totalInputTokens) + normalizeNumber(right?.totalInputTokens),
totalOutputTokens:
normalizeNumber(left?.totalOutputTokens) + normalizeNumber(right?.totalOutputTokens),
totalTokens: normalizeNumber(left?.totalTokens) + normalizeNumber(right?.totalTokens),
});
export const usageToOperationMetrics = (
usage?: OperationUsageLike | null,
): OperationUsageMetrics => ({
totalCost: normalizeNumber(usage?.cost),
totalInputTokens: normalizeNumber(usage?.totalInputTokens),
totalOutputTokens: normalizeNumber(usage?.totalOutputTokens),
totalTokens: normalizeNumber(usage?.totalTokens),
});
export const addUsageToOperationMetrics = (
metrics?: Partial<OperationUsageMetrics> | null,
usage?: OperationUsageLike | null,
): OperationUsageMetrics => mergeOperationUsageMetrics(metrics, usageToOperationMetrics(usage));
export const hasOperationUsageMetrics = (
metrics?: Partial<OperationUsageMetrics> | null,
): metrics is OperationUsageMetrics =>
normalizeNumber(metrics?.totalCost) > 0 ||
normalizeNumber(metrics?.totalInputTokens) > 0 ||
normalizeNumber(metrics?.totalOutputTokens) > 0 ||
normalizeNumber(metrics?.totalTokens) > 0;
export const calculateOperationUsageMetrics = (
messages: OperationUsageMessage[],
operationIds: Set<string>,
operationsByMessage: Record<string, string[]>,
): OperationUsageMetrics => {
if (operationIds.size === 0) return EMPTY_OPERATION_USAGE_METRICS;
let metrics: OperationUsageMetrics = EMPTY_OPERATION_USAGE_METRICS;
for (const message of messages) {
if (message.role !== 'assistant') continue;
const messageOperationIds = operationsByMessage[message.id];
if (!messageOperationIds?.some((id) => operationIds.has(id))) continue;
metrics = addUsageToOperationMetrics(metrics, message.usage);
}
return metrics;
};