Rene Fichtmueller d43b9f5298 feat: TokenVault MVP — hybrid MCP + proxy for LLM token savings
4-package monorepo:
- @tokenvault/core: Fastify 5.x proxy server, 7-stage pipeline,
  3 provider adapters (Anthropic, OpenAI, Ollama), PostgreSQL
  ticket system, cost calculator with real provider pricing
- @tokenvault/mcp: MCP server (stdio) with tv_ticket, tv_cost,
  tv_health tools for IDE integration
- @tokenvault/client: TypeScript SDK with createTokenVaultClient()
- @tokenvault/dashboard: Single-file HTML dashboard with MAGATAMA
  CI style (indigo #6366f1), bilingual DE+EN, 4 tabs

OpenAI-compatible proxy at /v1/chat/completions — drop-in replacement.
Every LLM request becomes a trackable ticket (TV-00001).
2026-04-14 10:10:22 +02:00

287 lines
13 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TokenVault — LLM Cost Intelligence</title>
<style>
:root {
--primary: #6366f1;
--primary-light: #818cf8;
--primary-dark: #4f46e5;
--bg: #f8fafc;
--surface: #ffffff;
--text: #1e293b;
--text-muted: #64748b;
--border: #e2e8f0;
--green: #22c55e;
--red: #ef4444;
--amber: #f59e0b;
--blue: #3b82f6;
--radius: 12px;
--shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
/* Header */
.header { background:var(--surface); border-bottom:1px solid var(--border); padding:16px 24px; display:flex; align-items:center; justify-content:space-between; }
.header h1 { font-size:20px; font-weight:700; display:flex; align-items:center; gap:8px; }
.header h1 span { color:var(--primary); }
.header .lang { display:flex; gap:4px; }
.header .lang button { padding:4px 10px; border:1px solid var(--border); border-radius:6px; background:var(--surface); cursor:pointer; font-size:12px; }
.header .lang button.active { background:var(--primary); color:#fff; border-color:var(--primary); }
/* Tabs */
.tabs { display:flex; gap:0; background:var(--surface); border-bottom:2px solid var(--border); padding:0 24px; overflow-x:auto; }
.tab { padding:12px 20px; cursor:pointer; font-size:14px; font-weight:500; color:var(--text-muted); border-bottom:2px solid transparent; margin-bottom:-2px; white-space:nowrap; }
.tab:hover { color:var(--text); }
.tab.active { color:var(--primary); border-bottom-color:var(--primary); }
/* Content */
.content { max-width:1400px; margin:0 auto; padding:24px; }
/* Cards */
.cards { display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:16px; margin-bottom:24px; }
.card { background:var(--surface); border-radius:var(--radius); padding:20px; box-shadow:var(--shadow); border:1px solid var(--border); }
.card .label { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:0.5px; color:var(--text-muted); margin-bottom:4px; }
.card .value { font-size:28px; font-weight:700; }
.card .sub { font-size:12px; color:var(--text-muted); margin-top:4px; }
.card .value.green { color:var(--green); }
.card .value.primary { color:var(--primary); }
.card .value.amber { color:var(--amber); }
/* Table */
.table-wrap { background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow); border:1px solid var(--border); overflow-x:auto; }
table { width:100%; border-collapse:collapse; font-size:13px; }
th { background:#f1f5f9; text-align:left; padding:10px 14px; font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:0.5px; color:var(--text-muted); border-bottom:1px solid var(--border); }
td { padding:10px 14px; border-bottom:1px solid var(--border); }
tr:last-child td { border-bottom:none; }
tr:hover { background:#f8fafc; }
/* Badges */
.badge { display:inline-block; padding:2px 8px; border-radius:6px; font-size:11px; font-weight:600; }
.badge.completed { background:#dcfce7; color:#166534; }
.badge.cached { background:#dbeafe; color:#1e40af; }
.badge.failed { background:#fef2f2; color:#991b1b; }
.badge.pending_review { background:#fef3c7; color:#92400e; }
/* Provider badges */
.provider { display:inline-flex; align-items:center; gap:4px; padding:2px 8px; border-radius:6px; font-size:11px; font-weight:600; }
.provider.anthropic { background:#fdf4ff; color:#86198f; }
.provider.openai { background:#f0fdf4; color:#166534; }
.provider.ollama { background:#eff6ff; color:#1e40af; }
.provider.google { background:#fef9c3; color:#854d0e; }
.provider.groq { background:#fce7f3; color:#9d174d; }
/* Section */
.section { margin-bottom:24px; }
.section h2 { font-size:16px; font-weight:600; margin-bottom:12px; color:var(--text); display:flex; align-items:center; gap:8px; }
/* Charts placeholder */
.chart-placeholder { background:var(--surface); border-radius:var(--radius); padding:40px; text-align:center; color:var(--text-muted); border:1px solid var(--border); box-shadow:var(--shadow); }
.chart-row { display:grid; grid-template-columns:2fr 1fr; gap:16px; margin-bottom:24px; }
@media (max-width: 768px) { .chart-row { grid-template-columns:1fr; } }
/* Loading */
.loading { text-align:center; padding:40px; color:var(--text-muted); }
.spin { display:inline-block; width:20px; height:20px; border:2px solid var(--border); border-top-color:var(--primary); border-radius:50%; animation:spin 0.6s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
/* Refresh button */
.refresh { padding:6px 14px; background:var(--primary); color:#fff; border:none; border-radius:8px; cursor:pointer; font-size:13px; font-weight:500; }
.refresh:hover { background:var(--primary-dark); }
</style>
</head>
<body>
<div class="header">
<h1><span>Token</span>Vault <small style="font-size:12px;color:var(--text-muted);margin-left:4px">v0.1.0</small></h1>
<div style="display:flex;align-items:center;gap:12px">
<div class="lang">
<button class="active" onclick="setLang('en')">EN</button>
<button onclick="setLang('de')">DE</button>
</div>
<button class="refresh" onclick="loadAll()">Refresh</button>
</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="overview">Overview</div>
<div class="tab" data-tab="tickets">Tickets</div>
<div class="tab" data-tab="cost">Cost Analysis</div>
<div class="tab" data-tab="providers">Providers</div>
</div>
<div class="content">
<!-- Overview Tab -->
<div id="tab-overview">
<div class="cards" id="stats-cards">
<div class="loading"><div class="spin"></div> Loading...</div>
</div>
<div class="chart-row">
<div class="chart-placeholder" id="cost-timeline">Cost timeline will appear here after requests are tracked</div>
<div class="chart-placeholder" id="provider-split">Provider split will appear here</div>
</div>
</div>
<!-- Tickets Tab -->
<div id="tab-tickets" style="display:none">
<div class="section">
<h2>Recent Tickets</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Ticket</th>
<th>Provider</th>
<th>Model</th>
<th>Status</th>
<th>Tokens In</th>
<th>Tokens Out</th>
<th>Cached</th>
<th>Saved</th>
<th>Cost</th>
<th>Latency</th>
<th>Time</th>
</tr>
</thead>
<tbody id="ticket-rows">
<tr><td colspan="11" class="loading"><div class="spin"></div></td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Cost Tab -->
<div id="tab-cost" style="display:none">
<div class="cards" id="cost-cards"></div>
<div class="section">
<h2>Cost Breakdown by Provider</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Provider</th><th>Requests</th><th>Tokens In</th><th>Tokens Out</th><th>Cost</th><th>Saved</th></tr></thead>
<tbody id="breakdown-rows"></tbody>
</table>
</div>
</div>
</div>
<!-- Providers Tab -->
<div id="tab-providers" style="display:none">
<div class="section">
<h2>Configured Providers</h2>
<div id="provider-list" class="cards"></div>
</div>
</div>
</div>
<script>
const API = '/api';
let lang = 'en';
const t = {
en: { totalCost:'Total Cost', totalSaved:'Total Saved', requests:'Requests', cacheRate:'Cache Hit Rate', tokensIn:'Tokens In', tokensOut:'Tokens Out' },
de: { totalCost:'Gesamtkosten', totalSaved:'Gespart', requests:'Anfragen', cacheRate:'Cache Hit Rate', tokensIn:'Tokens Ein', tokensOut:'Tokens Aus' },
};
function setLang(l) {
lang = l;
document.querySelectorAll('.lang button').forEach(b => b.classList.toggle('active', b.textContent === l.toUpperCase()));
loadAll();
}
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.querySelectorAll('[id^="tab-"]').forEach(el => el.style.display = 'none');
document.getElementById('tab-' + tab.dataset.tab).style.display = 'block';
});
});
function fmt(n) { return typeof n === 'number' ? (n < 0.01 ? n.toFixed(6) : n.toFixed(2)) : '0'; }
function fmtK(n) { return n >= 1000000 ? (n/1000000).toFixed(1)+'M' : n >= 1000 ? (n/1000).toFixed(1)+'K' : String(n); }
function fmtTime(d) { return new Date(d).toLocaleString(lang === 'de' ? 'de-DE' : 'en-US', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }); }
async function loadStats() {
try {
const data = await (await fetch(API + '/tickets/stats?period=month')).json();
const labels = t[lang];
document.getElementById('stats-cards').innerHTML = `
<div class="card"><div class="label">${labels.totalCost}</div><div class="value primary">$${fmt(data.total_cost_usd)}</div><div class="sub">Last 30 days</div></div>
<div class="card"><div class="label">${labels.totalSaved}</div><div class="value green">$${fmt(data.total_saved_usd)}</div><div class="sub">Via compression + caching</div></div>
<div class="card"><div class="label">${labels.requests}</div><div class="value">${fmtK(data.total_requests)}</div><div class="sub">Tickets tracked</div></div>
<div class="card"><div class="label">${labels.cacheRate}</div><div class="value amber">${(data.cache_hit_rate * 100).toFixed(0)}%</div><div class="sub">Semantic cache hits</div></div>
<div class="card"><div class="label">${labels.tokensIn}</div><div class="value">${fmtK(data.total_tokens_in)}</div></div>
<div class="card"><div class="label">${labels.tokensOut}</div><div class="value">${fmtK(data.total_tokens_out)}</div></div>
`;
} catch { document.getElementById('stats-cards').innerHTML = '<div class="card"><div class="label">Status</div><div class="value">Offline</div><div class="sub">TokenVault core not reachable</div></div>'; }
}
async function loadTickets() {
try {
const data = await (await fetch(API + '/tickets?limit=50')).json();
document.getElementById('ticket-rows').innerHTML = data.tickets.map(t => `
<tr>
<td><strong>${t.ticket_display}</strong></td>
<td><span class="provider ${t.provider}">${t.provider}</span></td>
<td style="font-size:12px">${t.model}</td>
<td><span class="badge ${t.status}">${t.status}</span></td>
<td>${fmtK(t.tokens_in)}</td>
<td>${fmtK(t.tokens_out)}</td>
<td>${fmtK(t.tokens_cached)}</td>
<td style="color:var(--green)">${fmtK(t.tokens_saved)}</td>
<td>$${fmt(t.cost_usd)}</td>
<td>${t.latency_ms}ms</td>
<td style="font-size:11px;color:var(--text-muted)">${fmtTime(t.created_at)}</td>
</tr>
`).join('') || '<tr><td colspan="11" style="text-align:center;color:var(--text-muted)">No tickets yet — send your first request through the proxy</td></tr>';
} catch { document.getElementById('ticket-rows').innerHTML = '<tr><td colspan="11" class="loading">Could not load tickets</td></tr>'; }
}
async function loadBreakdown() {
try {
const [stats, breakdown] = await Promise.all([
(await fetch(API + '/cost?period=month')).json(),
(await fetch(API + '/cost/breakdown?group_by=provider')).json(),
]);
const labels = t[lang];
document.getElementById('cost-cards').innerHTML = `
<div class="card"><div class="label">${labels.totalCost} (30d)</div><div class="value primary">$${fmt(stats.total_cost_usd)}</div></div>
<div class="card"><div class="label">${labels.totalSaved}</div><div class="value green">$${fmt(stats.total_saved_usd)}</div></div>
<div class="card"><div class="label">Avg Compression</div><div class="value">${(stats.avg_compression_ratio * 100).toFixed(0)}%</div></div>
`;
document.getElementById('breakdown-rows').innerHTML = breakdown.map(b => `
<tr>
<td><span class="provider ${b.group_value}">${b.group_value}</span></td>
<td>${b.request_count}</td>
<td>${fmtK(b.tokens_in)}</td>
<td>${fmtK(b.tokens_out)}</td>
<td>$${fmt(b.cost_usd)}</td>
<td style="color:var(--green)">$${fmt(b.saved_usd)}</td>
</tr>
`).join('') || '<tr><td colspan="6">No data yet</td></tr>';
} catch {}
}
async function loadProviders() {
try {
const data = await (await fetch(API + '/health')).json();
document.getElementById('provider-list').innerHTML = data.providers.map(p => `
<div class="card">
<div class="label">${p.name.toUpperCase()}</div>
<div class="value" style="font-size:18px">${p.configured ? '✓ Active' : '✗ Not configured'}</div>
<div class="sub">${p.models} model${p.models !== 1 ? 's' : ''} available</div>
</div>
`).join('');
} catch {}
}
function loadAll() { loadStats(); loadTickets(); loadBreakdown(); loadProviders(); }
loadAll();
setInterval(loadAll, 30000);
</script>
</body>
</html>