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
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LLM Proxy Gateway - Admin Dashboard</title>
|
||||
<link rel="stylesheet" href="/css/dashboard.css?v=4">
|
||||
<link rel="stylesheet" href="/css/dashboard.css?v=5">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="icon" href="img/logo-icon.png" type="image/png" sizes="any">
|
||||
<link rel="apple-touch-icon" href="img/logo-icon.png">
|
||||
@@ -166,19 +166,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Scripts (cache-busted with version query params) -->
|
||||
<script src="/js/api.js?v=4"></script>
|
||||
<script src="/js/auth.js?v=4"></script>
|
||||
<script src="/js/dashboard.js?v=4"></script>
|
||||
<script src="/js/websocket.js?v=4"></script>
|
||||
<script src="/js/charts.js?v=4"></script>
|
||||
<script src="/js/pages/overview.js?v=4"></script>
|
||||
<script src="/js/pages/analytics.js?v=4"></script>
|
||||
<script src="/js/pages/costs.js?v=4"></script>
|
||||
<script src="/js/pages/clients.js?v=4"></script>
|
||||
<script src="/js/pages/providers.js?v=4"></script>
|
||||
<script src="/js/pages/models.js?v=4"></script>
|
||||
<script src="/js/pages/monitoring.js?v=4"></script>
|
||||
<script src="/js/pages/settings.js?v=4"></script>
|
||||
<script src="/js/pages/logs.js?v=4"></script>
|
||||
<script src="/js/api.js?v=5"></script>
|
||||
<script src="/js/auth.js?v=5"></script>
|
||||
<script src="/js/dashboard.js?v=5"></script>
|
||||
<script src="/js/websocket.js?v=5"></script>
|
||||
<script src="/js/charts.js?v=5"></script>
|
||||
<script src="/js/pages/overview.js?v=5"></script>
|
||||
<script src="/js/pages/analytics.js?v=5"></script>
|
||||
<script src="/js/pages/costs.js?v=5"></script>
|
||||
<script src="/js/pages/clients.js?v=5"></script>
|
||||
<script src="/js/pages/providers.js?v=5"></script>
|
||||
<script src="/js/pages/models.js?v=5"></script>
|
||||
<script src="/js/pages/monitoring.js?v=5"></script>
|
||||
<script src="/js/pages/settings.js?v=5"></script>
|
||||
<script src="/js/pages/logs.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -17,61 +17,25 @@ class ApiClient {
|
||||
headers['Authorization'] = `Bearer ${window.authManager.token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
let result;
|
||||
try {
|
||||
console.log(`[API] Fetching ${url}...`);
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
console.log(`[API] ${url} → status=${response.status} ok=${response.ok} type=${response.headers.get('content-type')}`);
|
||||
|
||||
const text = await response.text();
|
||||
console.log(`[API] ${url} → body length=${text.length}, first 200 chars:`, text.substring(0, 200));
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(text);
|
||||
} catch (parseErr) {
|
||||
const msg = `JSON parse failed for ${url}: ${parseErr.message}. Body: ${text.substring(0, 300)}`;
|
||||
console.error(`[API] ${msg}`);
|
||||
this._addDebugEntry(url, 'JSON_PARSE_ERROR', msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
const msg = `API error for ${url}: ok=${response.ok} success=${result.success} error=${result.error} status=${response.status}`;
|
||||
console.error(`[API] ${msg}`);
|
||||
this._addDebugEntry(url, 'API_ERROR', msg);
|
||||
throw new Error(result.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
console.log(`[API] ${url} → SUCCESS, data keys:`, result.data ? Object.keys(result.data) : 'null');
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error(`[API] Request failed (${path}):`, error);
|
||||
this._addDebugEntry(url, 'EXCEPTION', error.message);
|
||||
throw error;
|
||||
result = JSON.parse(text);
|
||||
} catch (parseErr) {
|
||||
throw new Error(`JSON parse failed for ${url}: ${parseErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Visible on-page debug panel for diagnosing fetch failures
|
||||
_addDebugEntry(url, status, detail) {
|
||||
let panel = document.getElementById('api-debug-panel');
|
||||
if (!panel) {
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'api-debug-panel';
|
||||
panel.style.cssText = 'position:fixed;bottom:0;left:0;right:0;max-height:200px;overflow-y:auto;background:#1d2021;color:#fbf1c7;font-family:monospace;font-size:11px;padding:8px;z-index:99999;border-top:2px solid #cc241d;';
|
||||
const title = document.createElement('div');
|
||||
title.style.cssText = 'font-weight:bold;margin-bottom:4px;color:#fb4934;';
|
||||
title.textContent = 'API Debug Panel (remove after fixing)';
|
||||
panel.appendChild(title);
|
||||
document.body.appendChild(panel);
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const entry = document.createElement('div');
|
||||
entry.style.cssText = 'margin:2px 0;padding:2px 4px;background:#282828;border-left:3px solid ' + (status === 'EXCEPTION' ? '#fb4934' : '#fabd2f') + ';';
|
||||
entry.textContent = `[${status}] ${url}: ${detail}`;
|
||||
panel.appendChild(entry);
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async get(path) {
|
||||
|
||||
@@ -348,7 +348,7 @@ class Dashboard {
|
||||
<div class="table-container">
|
||||
<table class="table" id="usage-table">
|
||||
<thead>
|
||||
<tr><th>Date</th><th>Client</th><th>Provider</th><th>Model</th><th>Requests</th><th>Tokens</th><th>Cost</th></tr>
|
||||
<tr><th>Date</th><th>Client</th><th>Provider</th><th>Model</th><th>Requests</th><th>Tokens</th><th>Cache Read</th><th>Cache Write</th><th>Cost</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
@@ -383,7 +383,7 @@ class Dashboard {
|
||||
<div class="table-container">
|
||||
<table class="table" id="pricing-table">
|
||||
<thead>
|
||||
<tr><th>Provider</th><th>Model</th><th>Input Cost</th><th>Output Cost</th><th>Last Updated</th></tr>
|
||||
<tr><th>Provider</th><th>Model</th><th>Input Cost</th><th>Output Cost</th><th>Cache Read</th><th>Cache Write</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
@@ -184,21 +184,27 @@ class AnalyticsPage {
|
||||
if (!tableBody) return;
|
||||
|
||||
if (data.length === 0) {
|
||||
tableBody.innerHTML = '<tr><td colspan="7" class="text-center">No historical data found</td></tr>';
|
||||
tableBody.innerHTML = '<tr><td colspan="9" class="text-center">No historical data found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tableBody.innerHTML = data.map(row => `
|
||||
<tr>
|
||||
<td>${row.date}</td>
|
||||
<td><span class="badge-client">${row.client}</span></td>
|
||||
<td>${row.provider}</td>
|
||||
<td><code class="code-sm">${row.model}</code></td>
|
||||
<td>${row.requests.toLocaleString()}</td>
|
||||
<td>${window.api.formatNumber(row.tokens)}</td>
|
||||
<td>${window.api.formatCurrency(row.cost)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
tableBody.innerHTML = data.map(row => {
|
||||
const cacheRead = row.cache_read_tokens || 0;
|
||||
const cacheWrite = row.cache_write_tokens || 0;
|
||||
return `
|
||||
<tr>
|
||||
<td>${row.date}</td>
|
||||
<td><span class="badge-client">${row.client}</span></td>
|
||||
<td>${row.provider}</td>
|
||||
<td><code class="code-sm">${row.model}</code></td>
|
||||
<td>${row.requests.toLocaleString()}</td>
|
||||
<td>${window.api.formatNumber(row.tokens)}</td>
|
||||
<td>${window.api.formatNumber(cacheRead)}</td>
|
||||
<td>${window.api.formatNumber(cacheWrite)}</td>
|
||||
<td>${window.api.formatCurrency(row.cost)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
|
||||
@@ -31,7 +31,10 @@ class CostsPage {
|
||||
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
|
||||
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();
|
||||
@@ -44,6 +47,10 @@ class CostsPage {
|
||||
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">
|
||||
@@ -74,6 +81,19 @@ class CostsPage {
|
||||
</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>
|
||||
@@ -181,15 +201,24 @@ class CostsPage {
|
||||
const tableBody = document.querySelector('#pricing-table tbody');
|
||||
if (!tableBody) return;
|
||||
|
||||
tableBody.innerHTML = data.map(row => `
|
||||
<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>Now</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user