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:
@@ -1,8 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { formattedStats } from "$lib/stores/stats";
|
||||
import {
|
||||
formattedStats,
|
||||
contextWarning,
|
||||
getContextWarningMessage,
|
||||
stats,
|
||||
checkBudget,
|
||||
getBudgetStatusMessage,
|
||||
getRemainingTokenBudget,
|
||||
getRemainingCostBudget,
|
||||
} from "$lib/stores/stats";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
import { costTrackingStore, formattedCosts } from "$lib/stores/costTracking";
|
||||
import { fade } from "svelte/transition";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let showToolsBreakdown = false;
|
||||
interface Props {
|
||||
onRequestSummary?: () => void;
|
||||
onStartFreshWithContext?: () => void;
|
||||
isSummarising?: boolean;
|
||||
}
|
||||
|
||||
let { onRequestSummary, onStartFreshWithContext, isSummarising = false }: Props = $props();
|
||||
|
||||
let showToolsBreakdown = $state(false);
|
||||
let showHistoricalCosts = $state(false);
|
||||
const historicalCosts = $derived($formattedCosts);
|
||||
|
||||
// Initialize cost tracking on mount
|
||||
onMount(() => {
|
||||
costTrackingStore.refresh();
|
||||
});
|
||||
|
||||
// Subscribe to config store
|
||||
const config = configStore.config;
|
||||
|
||||
const warning = $derived($contextWarning);
|
||||
|
||||
// Budget tracking - must be defined before showCompactionOptions
|
||||
const budgetStatus = $derived(
|
||||
checkBudget(
|
||||
$stats,
|
||||
$config.budget_enabled,
|
||||
$config.session_token_budget,
|
||||
$config.session_cost_budget,
|
||||
$config.budget_warning_threshold
|
||||
)
|
||||
);
|
||||
const budgetMessage = $derived(getBudgetStatusMessage(budgetStatus));
|
||||
|
||||
// Show compaction options when context or budget is at warning/critical levels
|
||||
const showCompactionOptions = $derived(
|
||||
warning === "high" ||
|
||||
warning === "critical" ||
|
||||
budgetStatus.type === "warning" ||
|
||||
budgetStatus.type === "exceeded"
|
||||
);
|
||||
|
||||
const remainingTokens = $derived(getRemainingTokenBudget($stats, $config.session_token_budget));
|
||||
const remainingCost = $derived(getRemainingCostBudget($stats, $config.session_cost_budget));
|
||||
|
||||
// Calculate budget usage percentages for progress bars
|
||||
const tokenBudgetPercent = $derived(() => {
|
||||
const budget = $config.session_token_budget;
|
||||
if (budget === null || budget === 0) return 0;
|
||||
const used = $stats.session_input_tokens + $stats.session_output_tokens;
|
||||
return Math.min(100, (used / budget) * 100);
|
||||
});
|
||||
|
||||
const costBudgetPercent = $derived(() => {
|
||||
const budget = $config.session_cost_budget;
|
||||
if (budget === null || budget === 0) return 0;
|
||||
return Math.min(100, ($stats.session_cost_usd / budget) * 100);
|
||||
});
|
||||
|
||||
// Get the appropriate colour class for the progress bar
|
||||
function getBudgetBarClass(percent: number, warningThreshold: number): string {
|
||||
if (percent >= 100) return "budget-bar-exceeded";
|
||||
if (percent >= warningThreshold * 100) return "budget-bar-warning";
|
||||
return "budget-bar-ok";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="stats-display" transition:fade={{ duration: 200 }}>
|
||||
@@ -16,6 +92,120 @@
|
||||
<span class="stat-value">{$formattedStats.messagesSession}</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
<h3>Context Window</h3>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Used:</span>
|
||||
<span class="stat-value">{$formattedStats.contextUsed} / {$formattedStats.contextLimit}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Utilisation:</span>
|
||||
<span class="stat-value context-util {warning ? `warning-${warning}` : ''}"
|
||||
>{$formattedStats.contextUtilisation}</span
|
||||
>
|
||||
</div>
|
||||
{#if warning}
|
||||
<div class="context-warning warning-{warning}">
|
||||
{getContextWarningMessage(warning)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showCompactionOptions && (onRequestSummary || onStartFreshWithContext)}
|
||||
<div class="compaction-actions">
|
||||
{#if onRequestSummary}
|
||||
<button
|
||||
class="compaction-btn"
|
||||
onclick={onRequestSummary}
|
||||
disabled={isSummarising}
|
||||
title="Compact conversation history to reduce context usage"
|
||||
>
|
||||
{#if isSummarising}
|
||||
Compacting...
|
||||
{:else}
|
||||
Compact
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{#if onStartFreshWithContext}
|
||||
<button
|
||||
class="compaction-btn compaction-btn-primary"
|
||||
onclick={onStartFreshWithContext}
|
||||
disabled={isSummarising}
|
||||
title="Start a new conversation with context from this one"
|
||||
>
|
||||
Fresh Start
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $config.budget_enabled}
|
||||
<div class="stats-section">
|
||||
<h3>Budget</h3>
|
||||
{#if $config.session_token_budget !== null}
|
||||
<div class="budget-item">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Tokens:</span>
|
||||
<span
|
||||
class="stat-value {budgetStatus.type !== 'ok' && budgetStatus.budget_type === 'token'
|
||||
? `budget-${budgetStatus.type}`
|
||||
: ''}"
|
||||
>
|
||||
{($stats.session_input_tokens + $stats.session_output_tokens).toLocaleString()} / {$config.session_token_budget.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="budget-bar-container">
|
||||
<div
|
||||
class="budget-bar {getBudgetBarClass(
|
||||
tokenBudgetPercent(),
|
||||
$config.budget_warning_threshold
|
||||
)}"
|
||||
style="width: {tokenBudgetPercent()}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="budget-remaining">
|
||||
{remainingTokens?.toLocaleString() ?? 0} remaining ({(
|
||||
100 - tokenBudgetPercent()
|
||||
).toFixed(1)}%)
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $config.session_cost_budget !== null}
|
||||
<div class="budget-item">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Cost:</span>
|
||||
<span
|
||||
class="stat-value {budgetStatus.type !== 'ok' && budgetStatus.budget_type === 'cost'
|
||||
? `budget-${budgetStatus.type}`
|
||||
: ''}"
|
||||
>
|
||||
${$stats.session_cost_usd.toFixed(4)} / ${$config.session_cost_budget.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="budget-bar-container">
|
||||
<div
|
||||
class="budget-bar {getBudgetBarClass(
|
||||
costBudgetPercent(),
|
||||
$config.budget_warning_threshold
|
||||
)}"
|
||||
style="width: {costBudgetPercent()}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="budget-remaining">
|
||||
${remainingCost?.toFixed(4) ?? "0.0000"} remaining ({(
|
||||
100 - costBudgetPercent()
|
||||
).toFixed(1)}%)
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if budgetMessage}
|
||||
<div class="budget-warning budget-{budgetStatus.type}">
|
||||
{budgetMessage}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="stats-section">
|
||||
<h3>Tokens & Cost</h3>
|
||||
<div class="stat-row">
|
||||
@@ -49,7 +239,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if Object.keys($formattedStats.sessionToolsUsage).length > 0}
|
||||
{#if $formattedStats.sessionToolsFormatted.length > 0}
|
||||
<div class="stats-section">
|
||||
<h3 class="tools-header">
|
||||
<button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
|
||||
@@ -59,17 +249,57 @@
|
||||
</h3>
|
||||
{#if showToolsBreakdown}
|
||||
<div class="tools-breakdown">
|
||||
{#each Object.entries($formattedStats.sessionToolsUsage).sort((a, b) => b[1] - a[1]) as [tool, count] (tool)}
|
||||
<div class="stat-row stat-detail">
|
||||
<span class="stat-label">{tool}:</span>
|
||||
<span class="stat-value">{count}</span>
|
||||
{#each $formattedStats.sessionToolsFormatted.sort((a, b) => b.totalTokens - a.totalTokens) as tool (tool.name)}
|
||||
<div class="stat-row stat-detail tool-row">
|
||||
<span class="stat-label">{tool.name}:</span>
|
||||
<span class="stat-value tool-stats">
|
||||
<span class="tool-calls">{tool.callCount} calls</span>
|
||||
{#if tool.totalTokens > 0}
|
||||
<span class="tool-tokens">(~{tool.formattedTokens})</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="tools-note">* Token estimates based on attribution</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Historical Costs Section -->
|
||||
<div class="stats-section">
|
||||
<h3 class="costs-header">
|
||||
<button class="costs-toggle" onclick={() => (showHistoricalCosts = !showHistoricalCosts)}>
|
||||
Historical Costs
|
||||
<span class="toggle-icon">{showHistoricalCosts ? "▼" : "▶"}</span>
|
||||
</button>
|
||||
</h3>
|
||||
{#if !showHistoricalCosts}
|
||||
<div class="costs-quick-stats">
|
||||
<span class="cost-badge" title="Today's cost">Today: {historicalCosts.today}</span>
|
||||
<span class="cost-badge" title="This week's cost">Week: {historicalCosts.week}</span>
|
||||
<span class="cost-badge" title="This month's cost">Month: {historicalCosts.month}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showHistoricalCosts}
|
||||
<div class="historical-costs-expanded">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Today:</span>
|
||||
<span class="stat-value cost-value">{historicalCosts.today}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">This Week:</span>
|
||||
<span class="stat-value cost-value">{historicalCosts.week}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">This Month:</span>
|
||||
<span class="stat-value cost-value">{historicalCosts.month}</span>
|
||||
</div>
|
||||
<p class="costs-note">Open Settings to view detailed cost history and set alerts.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="model-info">
|
||||
<span class="model-label">Model:</span>
|
||||
<span class="model-value">{$formattedStats.model}</span>
|
||||
@@ -128,6 +358,79 @@
|
||||
color: var(--text-primary, #e5e7eb);
|
||||
}
|
||||
|
||||
.stat-cost {
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--accent-primary, #10b981);
|
||||
font-size: 0.8rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
|
||||
.tools-header {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.tools-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tools-toggle:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tools-breakdown {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.tool-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tool-stats {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-calls {
|
||||
color: var(--text-primary, #e5e7eb);
|
||||
}
|
||||
|
||||
.tool-tokens {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tools-note {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.model-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -148,4 +451,220 @@
|
||||
color: var(--text-primary, #e5e7eb);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.context-util {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.context-util.warning-moderate {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.context-util.warning-high {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.context-util.warning-critical {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.context-warning {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.context-warning.warning-moderate {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.context-warning.warning-high {
|
||||
background: rgba(249, 115, 22, 0.15);
|
||||
border: 1px solid rgba(249, 115, 22, 0.3);
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.context-warning.warning-critical {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* Budget progress bar styles */
|
||||
.budget-item {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.budget-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.budget-bar-container {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 3px;
|
||||
margin-top: 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.budget-bar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.budget-bar-ok {
|
||||
background: linear-gradient(90deg, #10b981, #34d399);
|
||||
}
|
||||
|
||||
.budget-bar-warning {
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
}
|
||||
|
||||
.budget-bar-exceeded {
|
||||
background: linear-gradient(90deg, #ef4444, #f87171);
|
||||
}
|
||||
|
||||
.budget-remaining {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.125rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Budget warning styles */
|
||||
.budget-warning {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.budget-warning.budget-warning {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.budget-warning.budget-exceeded {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.stat-value.budget-warning {
|
||||
color: #f59e0b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-value.budget-exceeded {
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Compaction action buttons */
|
||||
.compaction-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.compaction-btn {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.compaction-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent-primary);
|
||||
background: rgba(233, 69, 96, 0.1);
|
||||
}
|
||||
|
||||
.compaction-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.compaction-btn-primary {
|
||||
background: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.compaction-btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-secondary);
|
||||
border-color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
/* Historical costs styles */
|
||||
.costs-header {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.costs-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.costs-toggle:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.costs-quick-stats {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cost-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 3px;
|
||||
color: #10b981;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.historical-costs-expanded {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.cost-value {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.costs-note {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user