700 lines
20 KiB
TypeScript
700 lines
20 KiB
TypeScript
/**
|
|
* Transforms EnhancedChunk[] into SessionConversation structure.
|
|
*
|
|
* This module converts chunk-based data into a flat list of ChatItems
|
|
* (UserGroups, SystemGroups, AIGroups) for a chat-style display.
|
|
* Each item is independent - no pairing between user and AI chunks.
|
|
*/
|
|
|
|
import {
|
|
isAssistantMessage,
|
|
isEnhancedAIChunk,
|
|
isEnhancedCompactChunk,
|
|
isEnhancedSystemChunk,
|
|
isEnhancedUserChunk,
|
|
} from '@renderer/types/data';
|
|
import { getFirstSegment, hasPathSeparator, isRelativePath } from '@renderer/utils/pathUtils';
|
|
import { isCommandContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
|
|
import { createLogger } from '@shared/utils/logger';
|
|
|
|
import type {
|
|
EnhancedAIChunk,
|
|
EnhancedChunk,
|
|
EnhancedCompactChunk,
|
|
EnhancedSystemChunk,
|
|
EnhancedUserChunk,
|
|
ParsedMessage,
|
|
Process,
|
|
SemanticStep,
|
|
} from '@renderer/types/data';
|
|
import type {
|
|
AIGroup,
|
|
AIGroupStatus,
|
|
AIGroupSummary,
|
|
AIGroupTokens,
|
|
ChatItem,
|
|
CommandInfo,
|
|
CompactGroup,
|
|
FileReference,
|
|
ImageData,
|
|
SessionConversation,
|
|
SystemGroup,
|
|
UserGroup,
|
|
UserGroupContent,
|
|
} from '@renderer/types/groups';
|
|
|
|
const logger = createLogger('Util:groupTransformer');
|
|
|
|
// =============================================================================
|
|
// Constants
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Regex pattern for detecting slash commands.
|
|
* Matches: /command-name [optional args]
|
|
* Uses non-greedy matching and limited repetition to prevent ReDoS.
|
|
*/
|
|
// eslint-disable-next-line security/detect-unsafe-regex -- Pattern is safe: limited to 1000 chars and used on bounded user input
|
|
const COMMAND_PATTERN = /\/([a-z][a-z-]{0,50})(?:\s+(\S[^\n]{0,1000}))?$/gim;
|
|
|
|
/**
|
|
* Maximum characters to extract for thinking preview.
|
|
*/
|
|
const THINKING_PREVIEW_LENGTH = 100;
|
|
|
|
// =============================================================================
|
|
// Main Transformation Function
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Transforms EnhancedChunk[] into SessionConversation.
|
|
*
|
|
* Produces a flat list of independent ChatItems (user, system, AI).
|
|
* Each chunk type becomes its own item - no pairing or grouping.
|
|
*
|
|
* @param chunks - Array of enhanced chunks with semantic steps
|
|
* @param _subagents - Array of all subagents in the session (unused, processes come from chunks)
|
|
* @param isOngoing - Whether the session is still in progress (marks last AI group)
|
|
* @returns SessionConversation structure for chat-style rendering
|
|
*/
|
|
export function transformChunksToConversation(
|
|
chunks: EnhancedChunk[],
|
|
_subagents: Process[],
|
|
isOngoing: boolean = false
|
|
): SessionConversation {
|
|
if (!chunks || chunks.length === 0) {
|
|
return {
|
|
sessionId: '',
|
|
items: [],
|
|
totalUserGroups: 0,
|
|
totalSystemGroups: 0,
|
|
totalAIGroups: 0,
|
|
totalCompactGroups: 0,
|
|
};
|
|
}
|
|
|
|
const items: ChatItem[] = [];
|
|
let userCount = 0;
|
|
let systemCount = 0;
|
|
let aiCount = 0;
|
|
let compactCount = 0;
|
|
|
|
for (const chunk of chunks) {
|
|
if (isEnhancedUserChunk(chunk)) {
|
|
items.push({
|
|
type: 'user',
|
|
group: createUserGroupFromChunk(chunk, userCount++),
|
|
});
|
|
} else if (isEnhancedSystemChunk(chunk)) {
|
|
items.push({
|
|
type: 'system',
|
|
group: createSystemGroup(chunk),
|
|
});
|
|
systemCount++;
|
|
} else if (isEnhancedAIChunk(chunk)) {
|
|
items.push({
|
|
type: 'ai',
|
|
group: createAIGroupFromChunk(chunk, aiCount),
|
|
});
|
|
aiCount++;
|
|
} else if (isEnhancedCompactChunk(chunk)) {
|
|
items.push({
|
|
type: 'compact',
|
|
group: createCompactGroup(chunk),
|
|
});
|
|
compactCount++;
|
|
} else {
|
|
const unhandledChunkType =
|
|
'chunkType' in chunk ? (chunk as EnhancedChunk).chunkType : 'unknown';
|
|
logger.warn('Unhandled chunk type:', unhandledChunkType);
|
|
}
|
|
}
|
|
|
|
// Post-pass: enrich CompactGroups with token deltas
|
|
let phaseCounter = 1;
|
|
for (let i = 0; i < items.length; i++) {
|
|
if (items[i].type === 'compact') {
|
|
phaseCounter++;
|
|
const compactItem = items[i] as { type: 'compact'; group: CompactGroup };
|
|
compactItem.group.startingPhaseNumber = phaseCounter;
|
|
|
|
// Find last AI group before and first AI group after
|
|
const preAi = findLastAiBefore(items, i);
|
|
const postAi = findFirstAiAfter(items, i);
|
|
if (preAi && postAi) {
|
|
const pre = getLastAssistantTotalTokens(preAi);
|
|
// Use FIRST assistant message after compaction — it reflects the actual
|
|
// compacted context size before the AI generates more content.
|
|
const post = getFirstAssistantTotalTokens(postAi);
|
|
if (pre !== undefined && post !== undefined) {
|
|
compactItem.group.tokenDelta = {
|
|
preCompactionTokens: pre,
|
|
postCompactionTokens: post,
|
|
delta: post - pre,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If session is ongoing, mark the last AI group (but don't override interrupted status)
|
|
if (isOngoing && aiCount > 0) {
|
|
// Find the last AI item and mark it as ongoing
|
|
for (let i = items.length - 1; i >= 0; i--) {
|
|
const item = items[i];
|
|
if (item.type === 'ai') {
|
|
const currentStatus = item.group.status;
|
|
// Don't override 'interrupted' status - interruption takes precedence over ongoing
|
|
if (currentStatus !== 'interrupted') {
|
|
(item.group as AIGroup & { isOngoing?: boolean }).isOngoing = true;
|
|
(item.group as AIGroup & { status?: AIGroupStatus }).status = 'in_progress';
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
sessionId: chunks[0]?.id ?? 'unknown',
|
|
items,
|
|
totalUserGroups: userCount,
|
|
totalSystemGroups: systemCount,
|
|
totalAIGroups: aiCount,
|
|
totalCompactGroups: compactCount,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// UserGroup Creation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Creates a UserGroup from an EnhancedUserChunk.
|
|
*
|
|
* @param chunk - The user chunk to transform
|
|
* @param index - Index within the session (for ordering)
|
|
* @returns UserGroup with parsed content
|
|
*/
|
|
function createUserGroupFromChunk(chunk: EnhancedUserChunk, index: number): UserGroup {
|
|
return createUserGroup(chunk.userMessage, index);
|
|
}
|
|
|
|
/**
|
|
* Creates a UserGroup from a ParsedMessage.
|
|
*
|
|
* @param message - The user's input message
|
|
* @param index - Index within the session (for ordering)
|
|
* @returns UserGroup with parsed content
|
|
*/
|
|
function createUserGroup(message: ParsedMessage, index: number): UserGroup {
|
|
const content = extractUserGroupContent(message);
|
|
|
|
return {
|
|
id: `user-${message.uuid}`,
|
|
message,
|
|
timestamp: message.timestamp,
|
|
content,
|
|
index,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extracts and parses content from a user message.
|
|
*
|
|
* @param message - The user message to parse
|
|
* @returns Parsed UserGroupContent
|
|
*/
|
|
function extractUserGroupContent(message: ParsedMessage): UserGroupContent {
|
|
let rawText = '';
|
|
const images: ImageData[] = [];
|
|
const fileReferences: FileReference[] = [];
|
|
|
|
// Extract text from content
|
|
// Note: Image handling not yet implemented - images are not part of ContentBlock type
|
|
if (typeof message.content === 'string') {
|
|
rawText = message.content;
|
|
} else if (Array.isArray(message.content)) {
|
|
for (const block of message.content) {
|
|
if (block.type === 'text' && block.text) {
|
|
rawText += block.text;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sanitize content for display (handles XML tags from command messages)
|
|
// This converts <command-name>/model</command-name> to "/model"
|
|
const sanitizedText = sanitizeDisplayContent(rawText);
|
|
|
|
// Check if this is a command message (for special handling)
|
|
const isCommand = isCommandContent(rawText);
|
|
|
|
// Extract commands from the sanitized text (for inline /commands in regular messages)
|
|
// For command messages, the command is already extracted as sanitizedText
|
|
const commands = isCommand ? [] : extractCommands(sanitizedText);
|
|
|
|
// Extract file references (@file.ts) from sanitized text
|
|
fileReferences.push(...extractFileReferences(sanitizedText));
|
|
|
|
// For command messages, use the sanitized command as display text
|
|
// For regular messages, remove inline commands from display
|
|
let displayText = sanitizedText;
|
|
if (!isCommand) {
|
|
for (const cmd of commands) {
|
|
displayText = displayText.replace(cmd.raw, '').trim();
|
|
}
|
|
}
|
|
|
|
return {
|
|
text: displayText || undefined,
|
|
rawText: sanitizedText, // Use sanitized version as rawText for display
|
|
commands,
|
|
images,
|
|
fileReferences,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extracts commands from text using regex.
|
|
*
|
|
* @param text - Text to parse for commands
|
|
* @returns Array of CommandInfo objects
|
|
*/
|
|
function extractCommands(text: string): CommandInfo[] {
|
|
if (!text) return [];
|
|
|
|
const commands: CommandInfo[] = [];
|
|
let match: RegExpExecArray | null;
|
|
|
|
// Reset regex state
|
|
COMMAND_PATTERN.lastIndex = 0;
|
|
|
|
while ((match = COMMAND_PATTERN.exec(text)) !== null) {
|
|
const [fullMatch, commandName, args] = match;
|
|
commands.push({
|
|
name: commandName,
|
|
args: args?.trim(),
|
|
raw: fullMatch,
|
|
startIndex: match.index,
|
|
endIndex: match.index + fullMatch.length,
|
|
});
|
|
}
|
|
|
|
return commands;
|
|
}
|
|
|
|
/**
|
|
* Known directory prefixes that identify file references.
|
|
*/
|
|
const KNOWN_DIRS = new Set([
|
|
'src',
|
|
'apps',
|
|
'app',
|
|
'lib',
|
|
'types',
|
|
'packages',
|
|
'components',
|
|
'utils',
|
|
'services',
|
|
'hooks',
|
|
'store',
|
|
'renderer',
|
|
'main',
|
|
'preload',
|
|
'public',
|
|
'assets',
|
|
'config',
|
|
'tests',
|
|
'test',
|
|
'specs',
|
|
'spec',
|
|
'e2e',
|
|
'docs',
|
|
'scripts',
|
|
'screens',
|
|
'features',
|
|
'pages',
|
|
'views',
|
|
'models',
|
|
'controllers',
|
|
'routes',
|
|
'middleware',
|
|
'api',
|
|
'common',
|
|
'shared',
|
|
'core',
|
|
'modules',
|
|
'client',
|
|
'server',
|
|
'web',
|
|
'mobile',
|
|
'native',
|
|
'electron',
|
|
'node_modules',
|
|
]);
|
|
|
|
/**
|
|
* Simple pattern for detecting @ mentions that could be file paths.
|
|
* The filtering logic in extractFileReferences determines validity.
|
|
*/
|
|
const FILE_REF_PATTERN = /@([~a-zA-Z0-9._/-]+)/g;
|
|
|
|
/**
|
|
* Checks if a path looks like a valid file reference.
|
|
* Must start with known dir, contain /, or start with ./ or ../
|
|
*/
|
|
function isValidFileRef(path: string): boolean {
|
|
// Check for relative path indicators
|
|
if (isRelativePath(path)) {
|
|
return true;
|
|
}
|
|
// Check if starts with known directory
|
|
const first = getFirstSegment(path);
|
|
if (KNOWN_DIRS.has(first)) {
|
|
return true;
|
|
}
|
|
// Check if contains a path separator (indicates directory structure)
|
|
if (hasPathSeparator(path) && path.length > 2) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Extracts file references (@file.ts) from text.
|
|
*
|
|
* @param text - Text to parse for file references
|
|
* @returns Array of FileReference objects
|
|
*/
|
|
export function extractFileReferences(text: string): FileReference[] {
|
|
if (!text) return [];
|
|
|
|
const references: FileReference[] = [];
|
|
// Reset regex state before use
|
|
FILE_REF_PATTERN.lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = FILE_REF_PATTERN.exec(text)) !== null) {
|
|
const [fullMatch, path] = match;
|
|
// Only include if it looks like a valid file reference
|
|
if (isValidFileRef(path)) {
|
|
references.push({
|
|
path,
|
|
raw: fullMatch,
|
|
});
|
|
}
|
|
}
|
|
|
|
return references;
|
|
}
|
|
|
|
// =============================================================================
|
|
// SystemGroup Creation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Creates a SystemGroup from an EnhancedSystemChunk.
|
|
*
|
|
* @param chunk - The system chunk to transform
|
|
* @returns SystemGroup with command output
|
|
*/
|
|
function createSystemGroup(chunk: EnhancedSystemChunk): SystemGroup {
|
|
return {
|
|
id: chunk.id, // Use stable chunk ID instead of array index
|
|
message: chunk.message,
|
|
timestamp: chunk.startTime,
|
|
commandOutput: chunk.commandOutput,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// CompactGroup Creation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Creates a CompactGroup from an EnhancedCompactChunk.
|
|
*
|
|
* @param chunk - The compact chunk to transform
|
|
* @returns CompactGroup marking where conversation was compacted, with message content
|
|
*/
|
|
function createCompactGroup(chunk: EnhancedCompactChunk): CompactGroup {
|
|
return {
|
|
id: chunk.id, // Use stable chunk ID instead of array index
|
|
timestamp: chunk.startTime,
|
|
message: chunk.message, // Pass through the compact summary message
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// AIGroup Creation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Creates an AIGroup from an EnhancedAIChunk.
|
|
*
|
|
* @param chunk - The AI chunk to transform
|
|
* @param turnIndex - 0-based index of this AI group within the session
|
|
* @returns AIGroup with semantic steps and metrics
|
|
*/
|
|
function createAIGroupFromChunk(chunk: EnhancedAIChunk, turnIndex: number): AIGroup {
|
|
const steps = chunk.semanticSteps;
|
|
|
|
// Calculate timing from all steps
|
|
const startTime = steps.length > 0 ? steps[0].startTime : chunk.startTime;
|
|
const endTime =
|
|
steps.length > 0
|
|
? (steps[steps.length - 1].endTime ?? steps[steps.length - 1].startTime)
|
|
: chunk.endTime;
|
|
const durationMs = endTime.getTime() - startTime.getTime();
|
|
|
|
// Find any source assistant message for token calculation
|
|
const sourceMessage = chunk.responses.find((msg) => isAssistantMessage(msg)) ?? null;
|
|
|
|
// Calculate tokens from all steps
|
|
const tokens = calculateTokensFromSteps(steps, sourceMessage);
|
|
|
|
// Generate summary from all steps
|
|
const summary = computeAIGroupSummary(steps);
|
|
|
|
// Determine status from all steps
|
|
const status = determineAIGroupStatus(steps);
|
|
|
|
return {
|
|
id: chunk.id, // Use stable chunk ID instead of array index
|
|
turnIndex,
|
|
startTime,
|
|
endTime,
|
|
durationMs,
|
|
steps,
|
|
tokens,
|
|
summary,
|
|
status,
|
|
processes: chunk.processes,
|
|
chunkId: chunk.id,
|
|
metrics: chunk.metrics,
|
|
responses: chunk.responses,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculates token metrics from semantic steps and source message.
|
|
*
|
|
* @param steps - Semantic steps in this AI Group
|
|
* @param sourceMessage - Source assistant message (if available)
|
|
* @returns Token metrics
|
|
*/
|
|
function calculateTokensFromSteps(
|
|
steps: SemanticStep[],
|
|
sourceMessage: ParsedMessage | null | undefined
|
|
): AIGroupTokens {
|
|
let input = 0;
|
|
let output = 0;
|
|
let cached = 0;
|
|
let thinking = 0;
|
|
|
|
// Sum from steps
|
|
for (const step of steps) {
|
|
if (step.tokens) {
|
|
input += step.tokens.input ?? 0;
|
|
output += step.tokens.output ?? 0;
|
|
cached += step.tokens.cached ?? 0;
|
|
}
|
|
if (step.tokenBreakdown) {
|
|
input += step.tokenBreakdown.input ?? 0;
|
|
output += step.tokenBreakdown.output ?? 0;
|
|
cached += step.tokenBreakdown.cacheRead ?? 0;
|
|
}
|
|
if (step.type === 'thinking' && step.tokens?.output) {
|
|
thinking += step.tokens.output;
|
|
}
|
|
}
|
|
|
|
// Override with source message usage if available (more accurate)
|
|
if (sourceMessage?.usage) {
|
|
input = sourceMessage.usage.input_tokens ?? 0;
|
|
output = sourceMessage.usage.output_tokens ?? 0;
|
|
cached = sourceMessage.usage.cache_read_input_tokens ?? 0;
|
|
}
|
|
|
|
return {
|
|
input,
|
|
output,
|
|
cached,
|
|
thinking,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// AIGroup Summary & Status Computation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Computes summary statistics for an AIGroup's collapsed view.
|
|
*
|
|
* @param steps - Semantic steps in the AI Group
|
|
* @returns Summary statistics
|
|
*/
|
|
function computeAIGroupSummary(steps: SemanticStep[]): AIGroupSummary {
|
|
let thinkingPreview: string | undefined;
|
|
let toolCallCount = 0;
|
|
let outputMessageCount = 0;
|
|
let subagentCount = 0;
|
|
let totalDurationMs = 0;
|
|
let totalTokens = 0;
|
|
let outputTokens = 0;
|
|
let cachedTokens = 0;
|
|
|
|
for (const step of steps) {
|
|
// Extract thinking preview from first thinking step
|
|
if (!thinkingPreview && step.type === 'thinking' && step.content.thinkingText) {
|
|
const fullText = step.content.thinkingText;
|
|
thinkingPreview =
|
|
fullText.length > THINKING_PREVIEW_LENGTH
|
|
? fullText.slice(0, THINKING_PREVIEW_LENGTH) + '...'
|
|
: fullText;
|
|
}
|
|
|
|
// Count step types
|
|
if (step.type === 'tool_call') toolCallCount++;
|
|
if (step.type === 'output') outputMessageCount++;
|
|
if (step.type === 'subagent') subagentCount++;
|
|
|
|
// Sum duration
|
|
totalDurationMs += step.durationMs ?? 0;
|
|
|
|
// Sum tokens
|
|
if (step.tokens) {
|
|
totalTokens += (step.tokens.input ?? 0) + (step.tokens.output ?? 0);
|
|
outputTokens += step.tokens.output ?? 0;
|
|
cachedTokens += step.tokens.cached ?? 0;
|
|
}
|
|
if (step.tokenBreakdown) {
|
|
totalTokens += step.tokenBreakdown.input + step.tokenBreakdown.output;
|
|
outputTokens += step.tokenBreakdown.output;
|
|
cachedTokens += step.tokenBreakdown.cacheRead;
|
|
}
|
|
}
|
|
|
|
return {
|
|
thinkingPreview,
|
|
toolCallCount,
|
|
outputMessageCount,
|
|
subagentCount,
|
|
totalDurationMs,
|
|
totalTokens,
|
|
outputTokens,
|
|
cachedTokens,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Determines the status of an AIGroup based on its steps.
|
|
*
|
|
* @param steps - Semantic steps in the AI Group
|
|
* @returns AIGroupStatus
|
|
*/
|
|
function determineAIGroupStatus(steps: SemanticStep[]): AIGroupStatus {
|
|
if (steps.length === 0) return 'error';
|
|
|
|
// Check for interruption
|
|
const hasInterruption = steps.some((step) => step.type === 'interruption');
|
|
if (hasInterruption) return 'interrupted';
|
|
|
|
// Check for errors
|
|
const hasError = steps.some((step) => step.type === 'tool_result' && step.content.isError);
|
|
if (hasError) return 'error';
|
|
|
|
// Check if any step is incomplete (no endTime)
|
|
const hasIncomplete = steps.some((step) => !step.endTime);
|
|
if (hasIncomplete) return 'in_progress';
|
|
|
|
// Otherwise, complete
|
|
return 'complete';
|
|
}
|
|
|
|
// =============================================================================
|
|
// CompactGroup Enrichment Helpers
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Find the last AI group before a given index in the items array.
|
|
*/
|
|
function findLastAiBefore(items: ChatItem[], index: number): AIGroup | null {
|
|
for (let i = index - 1; i >= 0; i--) {
|
|
if (items[i].type === 'ai') return items[i].group as AIGroup;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Find the first AI group after a given index in the items array.
|
|
*/
|
|
function findFirstAiAfter(items: ChatItem[], index: number): AIGroup | null {
|
|
for (let i = index + 1; i < items.length; i++) {
|
|
if (items[i].type === 'ai') return items[i].group as AIGroup;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get total tokens from the last assistant message in an AI group.
|
|
* Sums input_tokens, output_tokens, cache_read_input_tokens, and cache_creation_input_tokens.
|
|
*/
|
|
function getLastAssistantTotalTokens(aiGroup: AIGroup): number | undefined {
|
|
const responses = aiGroup.responses || [];
|
|
for (let i = responses.length - 1; i >= 0; i--) {
|
|
const msg = responses[i];
|
|
if (msg.type === 'assistant' && msg.usage) {
|
|
return (
|
|
(msg.usage.input_tokens ?? 0) +
|
|
(msg.usage.output_tokens ?? 0) +
|
|
(msg.usage.cache_read_input_tokens ?? 0) +
|
|
(msg.usage.cache_creation_input_tokens ?? 0)
|
|
);
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Get total tokens from the FIRST assistant message in an AI group.
|
|
* Used for post-compaction token measurement: the first response after compaction
|
|
* reflects the actual compacted context size before the AI generates more content.
|
|
*/
|
|
function getFirstAssistantTotalTokens(aiGroup: AIGroup): number | undefined {
|
|
const responses = aiGroup.responses || [];
|
|
for (const msg of responses) {
|
|
if (msg.type === 'assistant' && msg.usage) {
|
|
return (
|
|
(msg.usage.input_tokens ?? 0) +
|
|
(msg.usage.output_tokens ?? 0) +
|
|
(msg.usage.cache_read_input_tokens ?? 0) +
|
|
(msg.usage.cache_creation_input_tokens ?? 0)
|
|
);
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helper Functions
|
|
// =============================================================================
|