Files
GopherGate/static/js/pages/costs.js
hobokenchicken db5824f0fb
Some checks failed
CI / Check (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Release Build (push) Has been cancelled
feat: add cache token tracking and cache-aware cost calculation
Track cache_read_tokens and cache_write_tokens end-to-end: parse from
provider responses (OpenAI, DeepSeek, Grok, Gemini), persist to SQLite,
apply cache-aware pricing from the model registry, and surface in API
responses and the dashboard.

- Add cache fields to ProviderResponse, StreamUsage, RequestLog structs
- Parse cached_tokens (OpenAI/Grok), prompt_cache_hit/miss (DeepSeek),
  cachedContentTokenCount (Gemini) from provider responses
- Send stream_options.include_usage for streaming; capture real usage
  from final SSE chunk in AggregatingStream
- ALTER TABLE migration for cache_read_tokens/cache_write_tokens columns
- Cache-aware cost formula using registry cache_read/cache_write rates
- Update Provider trait calculate_cost signature across all providers
- Add cache_read_tokens/cache_write_tokens to Usage API response
- Dashboard: cache hit rate card, cache columns in pricing and usage
  tables, cache token aggregation in SQL queries
- Remove API debug panel and verbose console logging from api.js
- Bump static asset cache-bust to v5
2026-03-02 14:45:21 -05:00

239 lines
9.3 KiB
JavaScript

// Costs Page Module
class CostsPage {
constructor() {
this.costData = null;
this.init();
}
async init() {
// Load data
await Promise.all([
this.loadCostStats(),
this.loadCostsChart(),
this.loadBudgetTracking(),
this.loadPricingTable()
]);
// Setup event listeners
this.setupEventListeners();
}
async loadCostStats() {
try {
const data = await window.api.get('/usage/summary');
this.costData = {
totalCost: data.total_cost,
todayCost: data.today_cost,
weekCost: data.total_cost * 0.4, // Placeholder for weekly logic
monthCost: data.total_cost,
avgDailyCost: data.total_cost / 30, // Simplified
costTrend: 5.2,
budgetUsed: Math.min(Math.round((data.total_cost / 100) * 100), 100), // Assuming $100 budget
projectedMonthEnd: data.today_cost * 30,
cacheReadTokens: data.total_cache_read_tokens || 0,
cacheWriteTokens: data.total_cache_write_tokens || 0,
totalTokens: data.total_tokens || 0,
};
this.renderCostStats();
} catch (error) {
console.error('Error loading cost stats:', error);
}
}
renderCostStats() {
const container = document.getElementById('cost-stats');
if (!container) return;
const cacheHitRate = this.costData.totalTokens > 0
? ((this.costData.cacheReadTokens / this.costData.totalTokens) * 100).toFixed(1)
: '0.0';
container.innerHTML = `
<div class="stat-card">
<div class="stat-icon warning">
<i class="fas fa-dollar-sign"></i>
</div>
<div class="stat-content">
<div class="stat-value">${window.api.formatCurrency(this.costData.totalCost)}</div>
<div class="stat-label">Total Cost</div>
<div class="stat-change positive">
<i class="fas fa-arrow-up"></i>
${window.api.formatCurrency(this.costData.todayCost)} today
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon success">
<i class="fas fa-calendar-alt"></i>
</div>
<div class="stat-content">
<div class="stat-value">${window.api.formatCurrency(this.costData.monthCost)}</div>
<div class="stat-label">This Month</div>
<div class="stat-change">
<i class="fas fa-chart-line"></i>
${window.api.formatCurrency(this.costData.avgDailyCost)}/day avg
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon primary">
<i class="fas fa-bolt"></i>
</div>
<div class="stat-content">
<div class="stat-value">${cacheHitRate}%</div>
<div class="stat-label">Cache Hit Rate</div>
<div class="stat-change">
${window.api.formatNumber(this.costData.cacheReadTokens)} cached tokens
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon danger">
<i class="fas fa-piggy-bank"></i>
</div>
<div class="stat-content">
<div class="stat-value">${this.costData.budgetUsed}%</div>
<div class="stat-label">Budget Used</div>
<div class="stat-change">
$${this.costData.projectedMonthEnd.toFixed(2)} projected
</div>
</div>
</div>
`;
}
async loadCostsChart() {
try {
const cm = window.chartManager || await window.waitForChartManager();
if (!cm) { this.showEmptyChart('costs-chart', 'Chart system unavailable'); return; }
const data = await window.api.get('/usage/providers');
if (!data || data.length === 0) {
this.showEmptyChart('costs-chart', 'No provider spending data yet');
return;
}
const chartData = {
labels: data.map(item => item.provider),
datasets: [{
label: 'Cost ($)',
data: data.map(item => item.cost),
color: '#fe8019'
}]
};
cm.createBarChart('costs-chart', chartData);
} catch (error) {
console.error('Error loading costs chart:', error);
this.showEmptyChart('costs-chart', 'Failed to load spending data');
}
}
showEmptyChart(canvasId, message) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const container = canvas.closest('.chart-container');
if (container) {
canvas.style.display = 'none';
let msg = container.querySelector('.empty-chart-msg');
if (!msg) {
msg = document.createElement('div');
msg.className = 'empty-chart-msg';
msg.style.cssText = 'display:flex;align-items:center;justify-content:center;height:200px;color:var(--fg4);font-size:0.9rem;';
container.appendChild(msg);
}
msg.textContent = message;
}
}
async loadBudgetTracking() {
const container = document.getElementById('budget-progress');
if (!container) return;
try {
const providers = await window.api.get('/providers');
container.innerHTML = providers.filter(p => p.id !== 'ollama').map(provider => {
const used = provider.low_credit_threshold; // Not quite right but using available fields
const balance = provider.credit_balance;
const percentage = balance > 0 ? Math.max(0, Math.min(100, (1 - (used / balance)) * 100)) : 0;
return `
<div class="budget-item" style="margin-bottom: 1.5rem;">
<div class="budget-header" style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span class="budget-name" style="font-weight: 600;">${provider.name} Balance</span>
<span class="budget-amount">${window.api.formatCurrency(balance)}</span>
</div>
<div class="progress-bar" style="height: 8px; background: var(--bg2); border-radius: 4px; overflow: hidden;">
<div class="progress-fill" style="width: ${percentage}%; height: 100%; background: ${balance < used ? 'var(--red)' : 'var(--green)'};"></div>
</div>
<div class="budget-footer" style="display: flex; justify-content: space-between; margin-top: 0.5rem; font-size: 0.75rem; color: var(--fg4);">
<span>Threshold: ${window.api.formatCurrency(used)}</span>
<span>Status: ${balance < used ? 'LOW' : 'Healthy'}</span>
</div>
</div>
`;
}).join('');
} catch (error) {
console.error('Error loading budgets:', error);
}
}
async loadPricingTable() {
try {
const data = await window.api.get('/models');
this.renderPricingTable(data);
} catch (error) {
console.error('Error loading pricing data:', error);
}
}
renderPricingTable(data) {
const tableBody = document.querySelector('#pricing-table tbody');
if (!tableBody) return;
tableBody.innerHTML = data.map(row => {
const cacheRead = row.cache_read_cost != null
? `${window.api.formatCurrency(row.cache_read_cost)} / 1M`
: '<span style="color:var(--fg4)">--</span>';
const cacheWrite = row.cache_write_cost != null
? `${window.api.formatCurrency(row.cache_write_cost)} / 1M`
: '<span style="color:var(--fg4)">--</span>';
return `
<tr>
<td><span class="badge-client">${row.provider.toUpperCase()}</span></td>
<td><code class="code-sm">${row.id}</code></td>
<td>${window.api.formatCurrency(row.prompt_cost)} / 1M</td>
<td>${window.api.formatCurrency(row.completion_cost)} / 1M</td>
<td>${cacheRead}</td>
<td>${cacheWrite}</td>
</tr>
`;
}).join('');
}
setupEventListeners() {
// ...
}
refresh() {
this.loadCostStats();
this.loadCostsChart();
this.loadBudgetTracking();
this.loadPricingTable();
}
}
window.initCosts = async () => {
window.costsPage = new CostsPage();
};