generated from nhcarrigan/template
3e7cb7ef60
### Explanation _No response_ ### Issue _No response_ ### 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: #111 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
import { writable, derived } from "svelte/store";
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { costTrackingStore } from "./costTracking";
|
|
import { configStore } from "./config";
|
|
|
|
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.6)
|
|
"claude-opus-4-6": { input: 5.0, output: 25.0 },
|
|
// Previous 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;
|
|
total_output_tokens: number;
|
|
total_cost_usd: number;
|
|
session_input_tokens: number;
|
|
session_output_tokens: number;
|
|
session_cost_usd: number;
|
|
model: string | null;
|
|
|
|
// New fields
|
|
messages_exchanged: number;
|
|
session_messages_exchanged: number;
|
|
code_blocks_generated: number;
|
|
session_code_blocks_generated: number;
|
|
files_edited: number;
|
|
session_files_edited: number;
|
|
files_created: number;
|
|
session_files_created: 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
|
|
export const stats = writable<UsageStats>({
|
|
total_input_tokens: 0,
|
|
total_output_tokens: 0,
|
|
total_cost_usd: 0,
|
|
session_input_tokens: 0,
|
|
session_output_tokens: 0,
|
|
session_cost_usd: 0,
|
|
model: null,
|
|
messages_exchanged: 0,
|
|
session_messages_exchanged: 0,
|
|
code_blocks_generated: 0,
|
|
session_code_blocks_generated: 0,
|
|
files_edited: 0,
|
|
session_files_edited: 0,
|
|
files_created: 0,
|
|
session_files_created: 0,
|
|
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, configStore.config], ([$stats, $config]) => {
|
|
const formatNumber = (num: number) => num.toLocaleString();
|
|
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
|
|
const formatDuration = (seconds: number) => {
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const secs = seconds % 60;
|
|
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes}m ${secs}s`;
|
|
} else if (minutes > 0) {
|
|
return `${minutes}m ${secs}s`;
|
|
} else {
|
|
return `${secs}s`;
|
|
}
|
|
};
|
|
|
|
// 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,
|
|
}));
|
|
};
|
|
|
|
// Use the model from stats if available, otherwise fall back to the configured model
|
|
const currentModel = $stats.model ?? $config.model ?? "No model selected";
|
|
|
|
return {
|
|
totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens),
|
|
totalInputTokens: formatNumber($stats.total_input_tokens),
|
|
totalOutputTokens: formatNumber($stats.total_output_tokens),
|
|
totalCost: formatCost($stats.total_cost_usd),
|
|
sessionTokens: formatNumber($stats.session_input_tokens + $stats.session_output_tokens),
|
|
sessionInputTokens: formatNumber($stats.session_input_tokens),
|
|
sessionOutputTokens: formatNumber($stats.session_output_tokens),
|
|
sessionCost: formatCost($stats.session_cost_usd),
|
|
model: currentModel,
|
|
|
|
// New formatted fields
|
|
messagesTotal: formatNumber($stats.messages_exchanged),
|
|
messagesSession: formatNumber($stats.session_messages_exchanged),
|
|
codeBlocksTotal: formatNumber($stats.code_blocks_generated),
|
|
codeBlocksSession: formatNumber($stats.session_code_blocks_generated),
|
|
filesEditedTotal: formatNumber($stats.files_edited),
|
|
filesEditedSession: formatNumber($stats.session_files_edited),
|
|
filesCreatedTotal: formatNumber($stats.files_created),
|
|
filesCreatedSession: formatNumber($stats.session_files_created),
|
|
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
|
|
export async function initStatsListener() {
|
|
// Listen for stats updates from the backend
|
|
await listen("claude:stats", (event) => {
|
|
const payload = event.payload as { stats: UsageStats };
|
|
const { stats: newStats } = payload;
|
|
|
|
// 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)
|
|
try {
|
|
const initialStats = await invoke<UsageStats>("get_persisted_stats");
|
|
stats.set(initialStats);
|
|
console.log("Loaded persisted stats:", initialStats);
|
|
} catch (error) {
|
|
console.error("Failed to load initial stats:", error);
|
|
}
|
|
}
|
|
|
|
// Reset session stats (call when starting new session)
|
|
export function resetSessionStats() {
|
|
stats.update((current) => ({
|
|
...current,
|
|
session_input_tokens: 0,
|
|
session_output_tokens: 0,
|
|
session_cost_usd: 0,
|
|
session_messages_exchanged: 0,
|
|
session_code_blocks_generated: 0,
|
|
session_files_edited: 0,
|
|
session_files_created: 0,
|
|
session_tools_usage: {},
|
|
session_duration_seconds: 0,
|
|
}));
|
|
}
|