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
239 lines
9.3 KiB
JavaScript
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();
|
|
};
|