generated from nhcarrigan/template
feat: massive overhaul to manage costs (#103)
### Explanation _No response_ ### Issue Closes #102 ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: #103 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #103.
This commit is contained in:
@@ -187,6 +187,11 @@ describe("config store", () => {
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
budget_enabled: false,
|
||||
session_token_budget: null,
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
};
|
||||
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
@@ -227,6 +232,11 @@ describe("config store", () => {
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
budget_enabled: false,
|
||||
session_token_budget: null,
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
};
|
||||
|
||||
expect(config.model).toBeNull();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { writable, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
||||
export type BudgetAction = "warn" | "block";
|
||||
|
||||
export interface CustomThemeColors {
|
||||
bg_primary: string | null;
|
||||
@@ -37,6 +38,12 @@ export interface HikariConfig {
|
||||
profile_avatar_path: string | null;
|
||||
profile_bio: string | null;
|
||||
custom_theme_colors: CustomThemeColors;
|
||||
// Budget settings
|
||||
budget_enabled: boolean;
|
||||
session_token_budget: number | null;
|
||||
session_cost_budget: number | null;
|
||||
budget_action: BudgetAction;
|
||||
budget_warning_threshold: number;
|
||||
}
|
||||
|
||||
const defaultConfig: HikariConfig = {
|
||||
@@ -71,6 +78,11 @@ const defaultConfig: HikariConfig = {
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
budget_enabled: false,
|
||||
session_token_budget: null,
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
|
||||
@@ -11,6 +11,13 @@ import { cleanupConversationTracking } from "$lib/tauri";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { sessionsStore } from "$lib/stores/sessions";
|
||||
|
||||
export interface ConversationSummary {
|
||||
generatedAt: Date;
|
||||
content: string;
|
||||
messageCount: number;
|
||||
tokenEstimate: number;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -27,6 +34,7 @@ export interface Conversation {
|
||||
createdAt: Date;
|
||||
lastActivityAt: Date;
|
||||
attachments: Attachment[];
|
||||
summary: ConversationSummary | null;
|
||||
}
|
||||
|
||||
function createConversationsStore() {
|
||||
@@ -63,6 +71,7 @@ function createConversationsStore() {
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
attachments: [],
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -420,7 +429,12 @@ function createConversationsStore() {
|
||||
});
|
||||
},
|
||||
|
||||
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
|
||||
addLine: (
|
||||
type: TerminalLine["type"],
|
||||
content: string,
|
||||
toolName?: string,
|
||||
cost?: TerminalLine["cost"]
|
||||
) => {
|
||||
ensureInitialized();
|
||||
const activeId = get(activeConversationId);
|
||||
if (!activeId) return "";
|
||||
@@ -431,6 +445,7 @@ function createConversationsStore() {
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
toolName,
|
||||
cost,
|
||||
};
|
||||
|
||||
conversations.update((convs) => {
|
||||
@@ -451,7 +466,8 @@ function createConversationsStore() {
|
||||
conversationId: string,
|
||||
type: TerminalLine["type"],
|
||||
content: string,
|
||||
toolName?: string
|
||||
toolName?: string,
|
||||
cost?: TerminalLine["cost"]
|
||||
) => {
|
||||
ensureInitialized();
|
||||
|
||||
@@ -461,6 +477,7 @@ function createConversationsStore() {
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
toolName,
|
||||
cost,
|
||||
};
|
||||
|
||||
conversations.update((convs) => {
|
||||
@@ -636,6 +653,130 @@ function createConversationsStore() {
|
||||
return conv?.attachments || [];
|
||||
},
|
||||
|
||||
// Summary/compaction functions
|
||||
setSummary: (conversationId: string, summary: ConversationSummary) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.summary = summary;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
clearSummary: (conversationId: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.summary = null;
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
getSummary: (conversationId: string): ConversationSummary | null => {
|
||||
const convs = get(conversations);
|
||||
const conv = convs.get(conversationId);
|
||||
return conv?.summary || null;
|
||||
},
|
||||
|
||||
// Estimate token count for a conversation (rough approximation: ~4 chars per token)
|
||||
estimateTokenCount: (conversationId: string): number => {
|
||||
const convs = get(conversations);
|
||||
const conv = convs.get(conversationId);
|
||||
if (!conv) return 0;
|
||||
|
||||
const relevantLines = conv.terminalLines.filter(
|
||||
(line) => line.type === "user" || line.type === "assistant"
|
||||
);
|
||||
|
||||
const totalChars = relevantLines.reduce((sum, line) => sum + line.content.length, 0);
|
||||
return Math.ceil(totalChars / 4);
|
||||
},
|
||||
|
||||
// Get conversation content suitable for summarisation
|
||||
getConversationForSummary: (conversationId: string): string => {
|
||||
const convs = get(conversations);
|
||||
const conv = convs.get(conversationId);
|
||||
if (!conv) return "";
|
||||
|
||||
const relevantLines = conv.terminalLines.filter(
|
||||
(line) => line.type === "user" || line.type === "assistant"
|
||||
);
|
||||
|
||||
return relevantLines
|
||||
.map((line) => {
|
||||
const role = line.type === "user" ? "User" : "Assistant";
|
||||
return `${role}: ${line.content}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
},
|
||||
|
||||
// Compact conversation by keeping only recent messages
|
||||
compactConversation: (conversationId: string, keepRecentCount: number = 10) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv && conv.terminalLines.length > keepRecentCount) {
|
||||
// Keep system messages and the most recent user/assistant messages
|
||||
const systemLines = conv.terminalLines.filter(
|
||||
(line) => line.type !== "user" && line.type !== "assistant"
|
||||
);
|
||||
const chatLines = conv.terminalLines.filter(
|
||||
(line) => line.type === "user" || line.type === "assistant"
|
||||
);
|
||||
|
||||
// Keep only the most recent chat messages
|
||||
const recentChatLines = chatLines.slice(-keepRecentCount);
|
||||
|
||||
// Combine: system lines at original positions + recent chat lines
|
||||
conv.terminalLines = [...systemLines.slice(-5), ...recentChatLines];
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
// Compact conversation with a summary - clears old messages and injects summary context
|
||||
compactWithSummary: (
|
||||
conversationId: string,
|
||||
summaryContent: string,
|
||||
messageCount: number,
|
||||
tokenEstimate: number
|
||||
) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
// Store the summary
|
||||
conv.summary = {
|
||||
generatedAt: new Date(),
|
||||
content: summaryContent,
|
||||
messageCount,
|
||||
tokenEstimate,
|
||||
};
|
||||
|
||||
// Clear all messages and add a context injection message
|
||||
conv.terminalLines = [
|
||||
{
|
||||
id: generateLineId(),
|
||||
type: "system",
|
||||
content: `[Conversation compacted] Previous session had ${messageCount} messages (~${tokenEstimate.toLocaleString()} tokens). Context preserved below.`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
{
|
||||
id: generateLineId(),
|
||||
type: "system",
|
||||
content: `Previous Session Context:\n${summaryContent}`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
];
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
// Add initialization helper
|
||||
initialize: () => {
|
||||
ensureInitialized();
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { notificationManager } from "$lib/notifications/notificationManager";
|
||||
|
||||
// Types matching Rust backend
|
||||
export interface DailyCost {
|
||||
date: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cost_usd: number;
|
||||
messages_sent: number;
|
||||
sessions_count: number;
|
||||
}
|
||||
|
||||
export interface CostSummary {
|
||||
period_days: number;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cost: number;
|
||||
total_messages: number;
|
||||
total_sessions: number;
|
||||
average_daily_cost: number;
|
||||
daily_breakdown: DailyCost[];
|
||||
}
|
||||
|
||||
export type AlertType = "Daily" | "Weekly" | "Monthly";
|
||||
|
||||
export interface CostAlert {
|
||||
alert_type: AlertType;
|
||||
threshold: number;
|
||||
current_cost: number;
|
||||
}
|
||||
|
||||
export interface CostAlertThresholds {
|
||||
daily: number | null;
|
||||
weekly: number | null;
|
||||
monthly: number | null;
|
||||
}
|
||||
|
||||
// Store state
|
||||
interface CostTrackingState {
|
||||
todayCost: number;
|
||||
weekCost: number;
|
||||
monthCost: number;
|
||||
summary: CostSummary | null;
|
||||
alerts: CostAlert[];
|
||||
thresholds: CostAlertThresholds;
|
||||
isLoading: boolean;
|
||||
lastUpdated: Date | null;
|
||||
}
|
||||
|
||||
const defaultState: CostTrackingState = {
|
||||
todayCost: 0,
|
||||
weekCost: 0,
|
||||
monthCost: 0,
|
||||
summary: null,
|
||||
alerts: [],
|
||||
thresholds: { daily: null, weekly: null, monthly: null },
|
||||
isLoading: false,
|
||||
lastUpdated: null,
|
||||
};
|
||||
|
||||
function createCostTrackingStore() {
|
||||
const { subscribe, set, update } = writable<CostTrackingState>(defaultState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
async refresh() {
|
||||
update((s) => ({ ...s, isLoading: true }));
|
||||
|
||||
try {
|
||||
const [todayCost, weekCost, monthCost, alerts] = await Promise.all([
|
||||
invoke<number>("get_today_cost"),
|
||||
invoke<number>("get_week_cost"),
|
||||
invoke<number>("get_month_cost"),
|
||||
invoke<CostAlert[]>("get_cost_alerts"),
|
||||
]);
|
||||
|
||||
update((s) => ({
|
||||
...s,
|
||||
todayCost,
|
||||
weekCost,
|
||||
monthCost,
|
||||
alerts,
|
||||
isLoading: false,
|
||||
lastUpdated: new Date(),
|
||||
}));
|
||||
|
||||
// Trigger notifications for any new alerts
|
||||
if (alerts.length > 0) {
|
||||
for (const alert of alerts) {
|
||||
const message = getAlertMessage(alert);
|
||||
notificationManager.notifyCostAlert(message);
|
||||
}
|
||||
}
|
||||
|
||||
return alerts;
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh cost tracking:", error);
|
||||
update((s) => ({ ...s, isLoading: false }));
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async getSummary(days: number): Promise<CostSummary | null> {
|
||||
try {
|
||||
const summary = await invoke<CostSummary>("get_cost_summary", { days });
|
||||
update((s) => ({ ...s, summary }));
|
||||
return summary;
|
||||
} catch (error) {
|
||||
console.error("Failed to get cost summary:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async setAlertThresholds(thresholds: CostAlertThresholds) {
|
||||
try {
|
||||
await invoke("set_cost_alert_thresholds", {
|
||||
daily: thresholds.daily,
|
||||
weekly: thresholds.weekly,
|
||||
monthly: thresholds.monthly,
|
||||
});
|
||||
update((s) => ({ ...s, thresholds }));
|
||||
} catch (error) {
|
||||
console.error("Failed to set alert thresholds:", error);
|
||||
}
|
||||
},
|
||||
|
||||
async exportCsv(days: number): Promise<string | null> {
|
||||
try {
|
||||
return await invoke<string>("export_cost_csv", { days });
|
||||
} catch (error) {
|
||||
console.error("Failed to export CSV:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
set(defaultState);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const costTrackingStore = createCostTrackingStore();
|
||||
|
||||
// Derived stores for formatted values
|
||||
export const formattedCosts = derived(costTrackingStore, ($store) => ({
|
||||
today: formatCost($store.todayCost),
|
||||
week: formatCost($store.weekCost),
|
||||
month: formatCost($store.monthCost),
|
||||
todayRaw: $store.todayCost,
|
||||
weekRaw: $store.weekCost,
|
||||
monthRaw: $store.monthCost,
|
||||
}));
|
||||
|
||||
// Helper functions
|
||||
export function formatCost(cost: number): string {
|
||||
if (cost < 0.01) {
|
||||
return `$${cost.toFixed(4)}`;
|
||||
}
|
||||
if (cost < 1) {
|
||||
return `$${cost.toFixed(3)}`;
|
||||
}
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function formatAlertType(type: AlertType): string {
|
||||
switch (type) {
|
||||
case "Daily":
|
||||
return "Today";
|
||||
case "Weekly":
|
||||
return "This Week";
|
||||
case "Monthly":
|
||||
return "This Month";
|
||||
}
|
||||
}
|
||||
|
||||
export function getAlertMessage(alert: CostAlert): string {
|
||||
const period = formatAlertType(alert.alert_type);
|
||||
return `${period}'s spending (${formatCost(alert.current_cost)}) has exceeded your ${formatCost(alert.threshold)} threshold`;
|
||||
}
|
||||
+269
-12
@@ -1,7 +1,25 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { stats, formattedStats, resetSessionStats } from "./stats";
|
||||
import type { UsageStats } from "./stats";
|
||||
import {
|
||||
stats,
|
||||
formattedStats,
|
||||
resetSessionStats,
|
||||
contextWarning,
|
||||
getContextWarningMessage,
|
||||
estimateMessageCost,
|
||||
formatTokenCount,
|
||||
MODEL_PRICING,
|
||||
} from "./stats";
|
||||
import type { UsageStats, ToolTokenStats } from "./stats";
|
||||
|
||||
// Helper function to create ToolTokenStats for tests
|
||||
function toolStats(callCount: number, inputTokens = 0, outputTokens = 0): ToolTokenStats {
|
||||
return {
|
||||
call_count: callCount,
|
||||
estimated_input_tokens: inputTokens,
|
||||
estimated_output_tokens: outputTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock Tauri APIs
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
@@ -34,6 +52,11 @@ describe("stats store", () => {
|
||||
tools_usage: {},
|
||||
session_tools_usage: {},
|
||||
session_duration_seconds: 0,
|
||||
context_tokens_used: 0,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,9 +86,14 @@ describe("stats store", () => {
|
||||
session_files_edited: 2,
|
||||
files_created: 1,
|
||||
session_files_created: 1,
|
||||
tools_usage: { Read: 5, Edit: 3 },
|
||||
session_tools_usage: { Read: 2, Edit: 1 },
|
||||
tools_usage: { Read: toolStats(5), Edit: toolStats(3) },
|
||||
session_tools_usage: { Read: toolStats(2), Edit: toolStats(1) },
|
||||
session_duration_seconds: 300,
|
||||
context_tokens_used: 500,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0.25,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
};
|
||||
|
||||
stats.set(newStats);
|
||||
@@ -74,7 +102,8 @@ describe("stats store", () => {
|
||||
expect(currentStats.total_input_tokens).toBe(1000);
|
||||
expect(currentStats.total_output_tokens).toBe(2000);
|
||||
expect(currentStats.model).toBe("claude-sonnet-4");
|
||||
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
|
||||
expect(currentStats.tools_usage.Read?.call_count).toBe(5);
|
||||
expect(currentStats.tools_usage.Edit?.call_count).toBe(3);
|
||||
});
|
||||
|
||||
it("can be updated with update function", () => {
|
||||
@@ -109,9 +138,14 @@ describe("stats store", () => {
|
||||
session_files_edited: 2,
|
||||
files_created: 1,
|
||||
session_files_created: 1,
|
||||
tools_usage: { Read: 5, Edit: 3 },
|
||||
session_tools_usage: { Read: 2, Edit: 1 },
|
||||
tools_usage: { Read: toolStats(5), Edit: toolStats(3) },
|
||||
session_tools_usage: { Read: toolStats(2), Edit: toolStats(1) },
|
||||
session_duration_seconds: 300,
|
||||
context_tokens_used: 500,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0.25,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
});
|
||||
|
||||
// Reset session stats
|
||||
@@ -127,7 +161,8 @@ describe("stats store", () => {
|
||||
expect(currentStats.code_blocks_generated).toBe(3);
|
||||
expect(currentStats.files_edited).toBe(5);
|
||||
expect(currentStats.files_created).toBe(1);
|
||||
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
|
||||
expect(currentStats.tools_usage.Read?.call_count).toBe(5);
|
||||
expect(currentStats.tools_usage.Edit?.call_count).toBe(3);
|
||||
expect(currentStats.model).toBe("claude-sonnet-4");
|
||||
|
||||
// Session stats should be reset
|
||||
@@ -277,8 +312,8 @@ describe("stats store", () => {
|
||||
});
|
||||
|
||||
it("exposes tools usage directly", () => {
|
||||
const toolsUsage = { Read: 10, Edit: 5, Write: 3 };
|
||||
const sessionToolsUsage = { Read: 2, Edit: 1 };
|
||||
const toolsUsage = { Read: toolStats(10), Edit: toolStats(5), Write: toolStats(3) };
|
||||
const sessionToolsUsage = { Read: toolStats(2), Edit: toolStats(1) };
|
||||
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
@@ -331,9 +366,14 @@ describe("stats store", () => {
|
||||
session_files_edited: 1,
|
||||
files_created: 1,
|
||||
session_files_created: 0,
|
||||
tools_usage: { Read: 3 },
|
||||
session_tools_usage: { Read: 1 },
|
||||
tools_usage: { Read: toolStats(3) },
|
||||
session_tools_usage: { Read: toolStats(1) },
|
||||
session_duration_seconds: 60,
|
||||
context_tokens_used: 50,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0.025,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
};
|
||||
|
||||
stats.set(fullStats);
|
||||
@@ -343,4 +383,221 @@ describe("stats store", () => {
|
||||
expect(currentStats).toEqual(fullStats);
|
||||
});
|
||||
});
|
||||
|
||||
describe("context window tracking", () => {
|
||||
it("tracks context tokens used", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_tokens_used: 100000,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 50.0,
|
||||
}));
|
||||
|
||||
const currentStats = get(stats);
|
||||
expect(currentStats.context_tokens_used).toBe(100000);
|
||||
expect(currentStats.context_window_limit).toBe(200000);
|
||||
expect(currentStats.context_utilisation_percent).toBe(50.0);
|
||||
});
|
||||
|
||||
it("formats context stats correctly", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_tokens_used: 150000,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 75.5,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.contextUsed).toBe("150,000");
|
||||
expect(formatted.contextLimit).toBe("200,000");
|
||||
expect(formatted.contextRemaining).toBe("50,000");
|
||||
expect(formatted.contextUtilisation).toBe("75.5%");
|
||||
});
|
||||
|
||||
it("calculates remaining tokens correctly at limit", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_tokens_used: 200000,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 100.0,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.contextRemaining).toBe("0");
|
||||
});
|
||||
|
||||
it("handles over-limit gracefully", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_tokens_used: 250000,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 125.0,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.contextRemaining).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("contextWarning derived store", () => {
|
||||
it("returns null when under 50%", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_utilisation_percent: 40.0,
|
||||
}));
|
||||
|
||||
const warning = get(contextWarning);
|
||||
expect(warning).toBeNull();
|
||||
});
|
||||
|
||||
it("returns moderate when between 50-74%", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_utilisation_percent: 60.0,
|
||||
}));
|
||||
|
||||
const warning = get(contextWarning);
|
||||
expect(warning).toBe("moderate");
|
||||
});
|
||||
|
||||
it("returns high when between 75-89%", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_utilisation_percent: 80.0,
|
||||
}));
|
||||
|
||||
const warning = get(contextWarning);
|
||||
expect(warning).toBe("high");
|
||||
});
|
||||
|
||||
it("returns critical when 90%+", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
context_utilisation_percent: 95.0,
|
||||
}));
|
||||
|
||||
const warning = get(contextWarning);
|
||||
expect(warning).toBe("critical");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContextWarningMessage", () => {
|
||||
it("returns correct message for moderate warning", () => {
|
||||
const message = getContextWarningMessage("moderate");
|
||||
expect(message).toContain("50%+");
|
||||
expect(message).toContain("Consider starting a new conversation");
|
||||
});
|
||||
|
||||
it("returns correct message for high warning", () => {
|
||||
const message = getContextWarningMessage("high");
|
||||
expect(message).toContain("75%+");
|
||||
expect(message).toContain("Responses may degrade");
|
||||
});
|
||||
|
||||
it("returns correct message for critical warning", () => {
|
||||
const message = getContextWarningMessage("critical");
|
||||
expect(message).toContain("90%+");
|
||||
expect(message).toContain("Start a new conversation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTokenCount", () => {
|
||||
it("formats small numbers directly", () => {
|
||||
expect(formatTokenCount(0)).toBe("0");
|
||||
expect(formatTokenCount(100)).toBe("100");
|
||||
expect(formatTokenCount(999)).toBe("999");
|
||||
});
|
||||
|
||||
it("formats thousands with K suffix", () => {
|
||||
expect(formatTokenCount(1000)).toBe("1.0K");
|
||||
expect(formatTokenCount(1500)).toBe("1.5K");
|
||||
expect(formatTokenCount(10000)).toBe("10.0K");
|
||||
expect(formatTokenCount(999999)).toBe("1000.0K");
|
||||
});
|
||||
|
||||
it("formats millions with M suffix", () => {
|
||||
expect(formatTokenCount(1000000)).toBe("1.0M");
|
||||
expect(formatTokenCount(1500000)).toBe("1.5M");
|
||||
expect(formatTokenCount(10000000)).toBe("10.0M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateMessageCost", () => {
|
||||
it("estimates tokens at ~4 chars per token", () => {
|
||||
const result = estimateMessageCost("test", 0, null); // 4 chars = 1 token
|
||||
expect(result.messageTokens).toBe(1);
|
||||
});
|
||||
|
||||
it("rounds up partial tokens", () => {
|
||||
const result = estimateMessageCost("a", 0, null); // 1 char rounds up to 1 token
|
||||
expect(result.messageTokens).toBe(1);
|
||||
|
||||
const result2 = estimateMessageCost("abcde", 0, null); // 5 chars = 2 tokens
|
||||
expect(result2.messageTokens).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 0 tokens for empty string", () => {
|
||||
const result = estimateMessageCost("", 0, null);
|
||||
expect(result.messageTokens).toBe(0);
|
||||
expect(result.estimatedCost).toBe(0);
|
||||
});
|
||||
|
||||
it("adds context tokens to total", () => {
|
||||
const result = estimateMessageCost("test", 1000, null); // 1 token + 1000 context
|
||||
expect(result.messageTokens).toBe(1);
|
||||
expect(result.totalInputTokens).toBe(1001);
|
||||
});
|
||||
|
||||
it("calculates cost using Sonnet pricing by default", () => {
|
||||
// 100 chars = 25 tokens, $3 per million input tokens
|
||||
const result = estimateMessageCost("a".repeat(100), 0, null);
|
||||
expect(result.messageTokens).toBe(25);
|
||||
const expectedCost = (25 / 1_000_000) * 3.0;
|
||||
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
|
||||
});
|
||||
|
||||
it("uses Opus pricing for Opus models", () => {
|
||||
const result = estimateMessageCost("a".repeat(100), 0, "claude-opus-4-5-20251101");
|
||||
expect(result.messageTokens).toBe(25);
|
||||
const expectedCost = (25 / 1_000_000) * 5.0; // Opus 4.5: $5 per million input
|
||||
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
|
||||
});
|
||||
|
||||
it("uses Haiku pricing for Haiku models", () => {
|
||||
const result = estimateMessageCost("a".repeat(100), 0, "claude-3-5-haiku-20241022");
|
||||
expect(result.messageTokens).toBe(25);
|
||||
const expectedCost = (25 / 1_000_000) * 1.0; // Haiku: $1 per million
|
||||
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
|
||||
});
|
||||
|
||||
it("falls back to Sonnet pricing for unknown models", () => {
|
||||
const result = estimateMessageCost("a".repeat(100), 0, "unknown-model");
|
||||
expect(result.messageTokens).toBe(25);
|
||||
const expectedCost = (25 / 1_000_000) * 3.0; // Default Sonnet: $3 per million
|
||||
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MODEL_PRICING", () => {
|
||||
it("contains expected Opus pricing", () => {
|
||||
// Opus 4.5 has reduced pricing
|
||||
expect(MODEL_PRICING["claude-opus-4-5-20251101"]).toEqual({ input: 5.0, output: 25.0 });
|
||||
// Previous Opus models have higher pricing
|
||||
expect(MODEL_PRICING["claude-opus-4-1-20250805"]).toEqual({ input: 15.0, output: 75.0 });
|
||||
expect(MODEL_PRICING["claude-opus-4-20250514"]).toEqual({ input: 15.0, output: 75.0 });
|
||||
});
|
||||
|
||||
it("contains expected Sonnet pricing", () => {
|
||||
expect(MODEL_PRICING["claude-sonnet-4-5-20250929"]).toEqual({ input: 3.0, output: 15.0 });
|
||||
expect(MODEL_PRICING["claude-sonnet-4-20250514"]).toEqual({ input: 3.0, output: 15.0 });
|
||||
expect(MODEL_PRICING["claude-3-7-sonnet-20250219"]).toEqual({ input: 3.0, output: 15.0 });
|
||||
expect(MODEL_PRICING["claude-3-5-sonnet-20241022"]).toEqual({ input: 3.0, output: 15.0 });
|
||||
});
|
||||
|
||||
it("contains expected Haiku pricing", () => {
|
||||
expect(MODEL_PRICING["claude-haiku-4-5-20251001"]).toEqual({ input: 1.0, output: 5.0 });
|
||||
expect(MODEL_PRICING["claude-3-5-haiku-20241022"]).toEqual({ input: 1.0, output: 5.0 });
|
||||
expect(MODEL_PRICING["claude-3-haiku-20240307"]).toEqual({ input: 0.25, output: 1.25 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+211
-2
@@ -1,6 +1,66 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { costTrackingStore } from "./costTracking";
|
||||
|
||||
export type ContextWarning = "moderate" | "high" | "critical";
|
||||
export type BudgetType = "token" | "cost";
|
||||
|
||||
// Model pricing (per million tokens) - keep in sync with stats.rs
|
||||
// Source: https://platform.claude.com/docs/en/about-claude/models/overview
|
||||
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
||||
// Current generation (Claude 4.5)
|
||||
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
|
||||
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
|
||||
"claude-haiku-4-5-20251001": { input: 1.0, output: 5.0 },
|
||||
// Previous generation (Claude 4.x)
|
||||
"claude-opus-4-1-20250805": { input: 15.0, output: 75.0 },
|
||||
"claude-opus-4-20250514": { input: 15.0, output: 75.0 },
|
||||
"claude-sonnet-4-20250514": { input: 3.0, output: 15.0 },
|
||||
// Legacy (Claude 3.x)
|
||||
"claude-3-7-sonnet-20250219": { input: 3.0, output: 15.0 },
|
||||
"claude-3-5-sonnet-20241022": { input: 3.0, output: 15.0 },
|
||||
"claude-3-5-sonnet-20240620": { input: 3.0, output: 15.0 },
|
||||
"claude-3-5-haiku-20241022": { input: 1.0, output: 5.0 },
|
||||
"claude-3-opus-20240229": { input: 15.0, output: 75.0 },
|
||||
"claude-3-sonnet-20240229": { input: 3.0, output: 15.0 },
|
||||
"claude-3-haiku-20240307": { input: 0.25, output: 1.25 },
|
||||
};
|
||||
|
||||
const DEFAULT_PRICING = { input: 3.0, output: 15.0 }; // Default to Sonnet
|
||||
|
||||
export interface CostEstimate {
|
||||
messageTokens: number;
|
||||
totalInputTokens: number;
|
||||
estimatedCost: number;
|
||||
}
|
||||
|
||||
// Estimate cost for a message before sending
|
||||
export function estimateMessageCost(
|
||||
messageText: string,
|
||||
contextTokensUsed: number,
|
||||
model: string | null
|
||||
): CostEstimate {
|
||||
// Estimate tokens using ~4 chars per token heuristic
|
||||
const messageTokens = Math.ceil(messageText.length / 4);
|
||||
const totalInputTokens = contextTokensUsed + messageTokens;
|
||||
|
||||
const pricing = model ? (MODEL_PRICING[model] ?? DEFAULT_PRICING) : DEFAULT_PRICING;
|
||||
const estimatedCost = (totalInputTokens / 1_000_000) * pricing.input;
|
||||
|
||||
return { messageTokens, totalInputTokens, estimatedCost };
|
||||
}
|
||||
export type BudgetStatus =
|
||||
| { type: "ok" }
|
||||
| { type: "warning"; budget_type: BudgetType; percent_used: number }
|
||||
| { type: "exceeded"; budget_type: BudgetType };
|
||||
|
||||
// Per-tool token usage statistics
|
||||
export interface ToolTokenStats {
|
||||
call_count: number;
|
||||
estimated_input_tokens: number;
|
||||
estimated_output_tokens: number;
|
||||
}
|
||||
|
||||
export interface UsageStats {
|
||||
total_input_tokens: number;
|
||||
@@ -20,9 +80,18 @@ export interface UsageStats {
|
||||
session_files_edited: number;
|
||||
files_created: number;
|
||||
session_files_created: number;
|
||||
tools_usage: Record<string, number>;
|
||||
session_tools_usage: Record<string, number>;
|
||||
tools_usage: Record<string, ToolTokenStats>;
|
||||
session_tools_usage: Record<string, ToolTokenStats>;
|
||||
session_duration_seconds: number;
|
||||
|
||||
// Context window tracking
|
||||
context_tokens_used: number;
|
||||
context_window_limit: number;
|
||||
context_utilisation_percent: number;
|
||||
|
||||
// Cache analytics (tracks potential savings from repeated tool calls)
|
||||
potential_cache_hits: number;
|
||||
potential_cache_savings_tokens: number;
|
||||
}
|
||||
|
||||
// Main stats store
|
||||
@@ -45,8 +114,24 @@ export const stats = writable<UsageStats>({
|
||||
tools_usage: {},
|
||||
session_tools_usage: {},
|
||||
session_duration_seconds: 0,
|
||||
context_tokens_used: 0,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
});
|
||||
|
||||
// Format token count with K/M suffix
|
||||
export function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return tokens.toString();
|
||||
}
|
||||
|
||||
// Derived store for formatted display values
|
||||
export const formattedStats = derived(stats, ($stats) => {
|
||||
const formatNumber = (num: number) => num.toLocaleString();
|
||||
@@ -65,6 +150,20 @@ export const formattedStats = derived(stats, ($stats) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Format tool stats with token info
|
||||
const formatToolStats = (toolStats: Record<string, ToolTokenStats>) => {
|
||||
return Object.entries(toolStats).map(([name, stats]) => ({
|
||||
name,
|
||||
callCount: stats.call_count,
|
||||
totalTokens: stats.estimated_input_tokens + stats.estimated_output_tokens,
|
||||
formattedTokens: formatTokenCount(
|
||||
stats.estimated_input_tokens + stats.estimated_output_tokens
|
||||
),
|
||||
inputTokens: stats.estimated_input_tokens,
|
||||
outputTokens: stats.estimated_output_tokens,
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens),
|
||||
totalInputTokens: formatNumber($stats.total_input_tokens),
|
||||
@@ -88,9 +187,116 @@ export const formattedStats = derived(stats, ($stats) => {
|
||||
sessionDuration: formatDuration($stats.session_duration_seconds),
|
||||
toolsUsage: $stats.tools_usage,
|
||||
sessionToolsUsage: $stats.session_tools_usage,
|
||||
// Formatted tool stats with token info
|
||||
sessionToolsFormatted: formatToolStats($stats.session_tools_usage),
|
||||
toolsFormatted: formatToolStats($stats.tools_usage),
|
||||
|
||||
// Context window tracking
|
||||
contextUsed: formatNumber($stats.context_tokens_used),
|
||||
contextLimit: formatNumber($stats.context_window_limit),
|
||||
contextRemaining: formatNumber(
|
||||
Math.max(0, $stats.context_window_limit - $stats.context_tokens_used)
|
||||
),
|
||||
contextUtilisation: `${$stats.context_utilisation_percent.toFixed(1)}%`,
|
||||
};
|
||||
});
|
||||
|
||||
// Derived store for context warning state
|
||||
export const contextWarning = derived(stats, ($stats): ContextWarning | null => {
|
||||
if ($stats.context_utilisation_percent >= 90) {
|
||||
return "critical";
|
||||
} else if ($stats.context_utilisation_percent >= 75) {
|
||||
return "high";
|
||||
} else if ($stats.context_utilisation_percent >= 50) {
|
||||
return "moderate";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Get warning message for context utilisation
|
||||
export function getContextWarningMessage(warning: ContextWarning): string {
|
||||
switch (warning) {
|
||||
case "moderate":
|
||||
return "Context window is 50%+ full. Consider starting a new conversation for better performance.";
|
||||
case "high":
|
||||
return "Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh.";
|
||||
case "critical":
|
||||
return "Context window is nearly full (90%+)! Start a new conversation to avoid errors.";
|
||||
}
|
||||
}
|
||||
|
||||
// Budget checking functions
|
||||
export function checkBudget(
|
||||
stats: UsageStats,
|
||||
budgetEnabled: boolean,
|
||||
tokenBudget: number | null,
|
||||
costBudget: number | null,
|
||||
warningThreshold: number
|
||||
): BudgetStatus {
|
||||
if (!budgetEnabled) {
|
||||
return { type: "ok" };
|
||||
}
|
||||
|
||||
const sessionTokens = stats.session_input_tokens + stats.session_output_tokens;
|
||||
|
||||
// Check token budget
|
||||
if (tokenBudget !== null) {
|
||||
if (sessionTokens >= tokenBudget) {
|
||||
return { type: "exceeded", budget_type: "token" };
|
||||
}
|
||||
const percentUsed = sessionTokens / tokenBudget;
|
||||
if (percentUsed >= warningThreshold) {
|
||||
return { type: "warning", budget_type: "token", percent_used: percentUsed * 100 };
|
||||
}
|
||||
}
|
||||
|
||||
// Check cost budget
|
||||
if (costBudget !== null) {
|
||||
if (stats.session_cost_usd >= costBudget) {
|
||||
return { type: "exceeded", budget_type: "cost" };
|
||||
}
|
||||
const percentUsed = stats.session_cost_usd / costBudget;
|
||||
if (percentUsed >= warningThreshold) {
|
||||
return { type: "warning", budget_type: "cost", percent_used: percentUsed * 100 };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "ok" };
|
||||
}
|
||||
|
||||
// Get budget status message
|
||||
export function getBudgetStatusMessage(status: BudgetStatus): string | null {
|
||||
if (status.type === "ok") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const budgetTypeLabel = status.budget_type === "token" ? "token" : "cost";
|
||||
|
||||
if (status.type === "exceeded") {
|
||||
return `Session ${budgetTypeLabel} budget exceeded! Consider starting a new session.`;
|
||||
}
|
||||
|
||||
return `Approaching ${budgetTypeLabel} budget limit (${status.percent_used.toFixed(0)}% used).`;
|
||||
}
|
||||
|
||||
// Get remaining budget values
|
||||
export function getRemainingTokenBudget(
|
||||
stats: UsageStats,
|
||||
tokenBudget: number | null
|
||||
): number | null {
|
||||
if (tokenBudget === null) return null;
|
||||
const used = stats.session_input_tokens + stats.session_output_tokens;
|
||||
return Math.max(0, tokenBudget - used);
|
||||
}
|
||||
|
||||
export function getRemainingCostBudget(
|
||||
stats: UsageStats,
|
||||
costBudget: number | null
|
||||
): number | null {
|
||||
if (costBudget === null) return null;
|
||||
return Math.max(0, costBudget - stats.session_cost_usd);
|
||||
}
|
||||
|
||||
// Note: Cost calculation is now done in the Rust backend
|
||||
|
||||
// Initialize stats listener
|
||||
@@ -102,6 +308,9 @@ export async function initStatsListener() {
|
||||
|
||||
// The backend already tracks all totals - just set the stats directly
|
||||
stats.set(newStats);
|
||||
|
||||
// Refresh cost tracking to check for alerts (debounced - won't spam)
|
||||
costTrackingStore.refresh();
|
||||
});
|
||||
|
||||
// Load initial persisted stats from backend (no bridge required)
|
||||
|
||||
Reference in New Issue
Block a user