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
7 changes: 6 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export default defineConfig([
},
},
{
// Frontend architectural boundary - prevent services imports
// Frontend architectural boundary - prevent services and tokenizer imports
files: ["src/components/**", "src/contexts/**", "src/hooks/**", "src/App.tsx"],
rules: {
"no-restricted-imports": [
Expand All @@ -205,6 +205,11 @@ export default defineConfig([
message:
"Frontend code cannot import from services/. Use IPC or move shared code to utils/.",
},
{
group: ["**/tokens/tokenizer", "**/tokens/tokenStatsCalculator"],
message:
"Frontend code cannot import tokenizer (2MB+ encodings). Use @/utils/tokens/usageAggregator for aggregation or @/utils/tokens/modelStats for pricing.",
},
],
},
],
Expand Down
2 changes: 1 addition & 1 deletion src/components/ChatMetaSidebar/CostsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import styled from "@emotion/styled";
import { useChatContext } from "@/contexts/ChatContext";
import { TooltipWrapper, Tooltip, HelpIndicator } from "../Tooltip";
import { getModelStats } from "@/utils/tokens/modelStats";
import { sumUsageHistory } from "@/utils/tokens/tokenStatsCalculator";
import { sumUsageHistory } from "@/utils/tokens/usageAggregator";
import { usePersistedState } from "@/hooks/usePersistedState";
import { ToggleGroup, type ToggleOption } from "../ToggleGroup";
import { use1MContext } from "@/hooks/use1MContext";
Expand Down
2 changes: 1 addition & 1 deletion src/types/chatStats.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ChatUsageDisplay } from "@/utils/tokens/tokenStatsCalculator";
import type { ChatUsageDisplay } from "@/utils/tokens/usageAggregator";

export interface TokenConsumer {
name: string; // "User", "Assistant", "bash", "readFile", etc.
Expand Down
64 changes: 1 addition & 63 deletions src/utils/tokens/tokenStatsCalculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,7 @@ import type { ChatStats, TokenConsumer } from "@/types/chatStats";
import type { LanguageModelV2Usage } from "@ai-sdk/provider";
import { getTokenizerForModel, countTokensForData, getToolDefinitionTokens } from "./tokenizer";
import { getModelStats } from "./modelStats";

export interface ChatUsageComponent {
tokens: number;
cost_usd?: number; // undefined if model pricing unknown
}

/**
* Enhanced usage type for display that includes provider-specific cache stats
*/
export interface ChatUsageDisplay {
// Input is the part of the input that was not cached. So,
// totalInput = input + cached (cacheCreate is separate for billing)
input: ChatUsageComponent;
cached: ChatUsageComponent;
cacheCreate: ChatUsageComponent; // Cache creation tokens (separate billing concept)

// Output is the part of the output excluding reasoning, so
// totalOutput = output + reasoning
output: ChatUsageComponent;
reasoning: ChatUsageComponent;
}
import type { ChatUsageDisplay } from "./usageAggregator";

/**
* Create a display-friendly usage object from AI SDK usage
Expand Down Expand Up @@ -109,48 +89,6 @@ export function createDisplayUsage(
};
}

/**
* Sum multiple ChatUsageDisplay objects into a single cumulative display
* Used for showing total costs across multiple API responses
*/
export function sumUsageHistory(usageHistory: ChatUsageDisplay[]): ChatUsageDisplay | undefined {
if (usageHistory.length === 0) return undefined;

// Track if any costs are undefined (model pricing unknown)
let hasUndefinedCosts = false;

const sum: ChatUsageDisplay = {
input: { tokens: 0, cost_usd: 0 },
cached: { tokens: 0, cost_usd: 0 },
cacheCreate: { tokens: 0, cost_usd: 0 },
output: { tokens: 0, cost_usd: 0 },
reasoning: { tokens: 0, cost_usd: 0 },
};

for (const usage of usageHistory) {
// Iterate over each component and sum tokens and costs
for (const key of Object.keys(sum) as Array<keyof ChatUsageDisplay>) {
sum[key].tokens += usage[key].tokens;
if (usage[key].cost_usd === undefined) {
hasUndefinedCosts = true;
} else {
sum[key].cost_usd = (sum[key].cost_usd ?? 0) + (usage[key].cost_usd ?? 0);
}
}
}

// If any costs were undefined, set all to undefined
if (hasUndefinedCosts) {
sum.input.cost_usd = undefined;
sum.cached.cost_usd = undefined;
sum.cacheCreate.cost_usd = undefined;
sum.output.cost_usd = undefined;
sum.reasoning.cost_usd = undefined;
}

return sum;
}

/**
* Calculate token statistics from raw CmuxMessages
* This is the single source of truth for token counting
Expand Down
71 changes: 71 additions & 0 deletions src/utils/tokens/usageAggregator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Usage aggregation utilities for cost calculation
*
* IMPORTANT: This file must NOT import tokenizer to avoid pulling
* 2MB+ of encoding data into the renderer process.
*
* Separated from tokenStatsCalculator.ts to keep tokenizer in main process only.
*/

export interface ChatUsageComponent {
tokens: number;
cost_usd?: number; // undefined if model pricing unknown
}

/**
* Enhanced usage type for display that includes provider-specific cache stats
*/
export interface ChatUsageDisplay {
// Input is the part of the input that was not cached. So,
// totalInput = input + cached (cacheCreate is separate for billing)
input: ChatUsageComponent;
cached: ChatUsageComponent;
cacheCreate: ChatUsageComponent; // Cache creation tokens (separate billing concept)

// Output is the part of the output excluding reasoning, so
// totalOutput = output + reasoning
output: ChatUsageComponent;
reasoning: ChatUsageComponent;
}

/**
* Sum multiple ChatUsageDisplay objects into a single cumulative display
* Used for showing total costs across multiple API responses
*/
export function sumUsageHistory(usageHistory: ChatUsageDisplay[]): ChatUsageDisplay | undefined {
if (usageHistory.length === 0) return undefined;

// Track if any costs are undefined (model pricing unknown)
let hasUndefinedCosts = false;

const sum: ChatUsageDisplay = {
input: { tokens: 0, cost_usd: 0 },
cached: { tokens: 0, cost_usd: 0 },
cacheCreate: { tokens: 0, cost_usd: 0 },
output: { tokens: 0, cost_usd: 0 },
reasoning: { tokens: 0, cost_usd: 0 },
};

for (const usage of usageHistory) {
// Iterate over each component and sum tokens and costs
for (const key of Object.keys(sum) as Array<keyof ChatUsageDisplay>) {
sum[key].tokens += usage[key].tokens;
if (usage[key].cost_usd === undefined) {
hasUndefinedCosts = true;
} else {
sum[key].cost_usd = (sum[key].cost_usd ?? 0) + (usage[key].cost_usd ?? 0);
}
}
}

// If any costs were undefined, set all to undefined
if (hasUndefinedCosts) {
sum.input.cost_usd = undefined;
sum.cached.cost_usd = undefined;
sum.cacheCreate.cost_usd = undefined;
sum.output.cost_usd = undefined;
sum.reasoning.cost_usd = undefined;
}

return sum;
}