From 542c801370774bc7df9047d1d9f73728eafc9b33 Mon Sep 17 00:00:00 2001 From: Pawel Date: Wed, 8 Apr 2026 01:00:23 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20cost=20attribution=20breakdown=20=D0=B2?= =?UTF-8?q?=20Token=20Breakdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен стэкованный бар стоимости по типам токенов: - Output (красный) — обычно 80-95% стоимости (в 5× дороже input) - Input (синий) — прямые входные токены - Cache write / Cache read — запись и чтение кэша Пропорции рассчитаны через Sonnet-baseline ratios, спроецированные на реальный totalCost — корректно работает при mix моделей (Opus/Sonnet/Haiku). Дополнительные метрики: - Cache hit rate (% входящих токенов обслужены из кэша) - Cache savings (~сколько сэкономил кэш vs полная стоимость input) Новые поля в /api/analytics/cost: inputCostEst, outputCostEst, cacheReadCostEst, cacheCreateCostEst, cacheSavings, cacheHitRate --- src/data.js | 19 +++++++++++ src/frontend/analytics.js | 43 +++++++++++++++++++++++ src/frontend/styles.css | 72 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/src/data.js b/src/data.js index 557d875..4c14fdc 100644 --- a/src/data.js +++ b/src/data.js @@ -2698,6 +2698,19 @@ function _computeCostAnalytics(sessions) { _saveCostDiskCache(); + // Cost breakdown by token type (approximated using Sonnet pricing as baseline). + // Not perfectly accurate for mixed-model usage, but directionally correct for attribution. + const p = MODEL_PRICING['claude-sonnet-4-6']; + const inputCostEst = totalInputTokens * p.input; + const outputCostEst = totalOutputTokens * p.output; + const cacheReadCostEst = totalCacheReadTokens * p.cache_read; + const cacheCreateCostEst = totalCacheCreateTokens * p.cache_create; + // Cache savings: what cache-read tokens would have cost at full input price + const cacheSavings = totalCacheReadTokens * (p.input - p.cache_read); + const totalInputSide = totalInputTokens + totalCacheReadTokens + totalCacheCreateTokens; + const cacheHitRate = totalInputSide > 0 + ? Math.round(totalCacheReadTokens / totalInputSide * 100) : 0; + return { totalCost, totalTokens, @@ -2721,6 +2734,12 @@ function _computeCostAnalytics(sessions) { last1hCost, todayCost, hoursElapsedToday: Math.max(1, hoursElapsedToday), + inputCostEst, + outputCostEst, + cacheReadCostEst, + cacheCreateCostEst, + cacheSavings, + cacheHitRate, }; } diff --git a/src/frontend/analytics.js b/src/frontend/analytics.js index cabf110..017ba3b 100644 --- a/src/frontend/analytics.js +++ b/src/frontend/analytics.js @@ -98,6 +98,49 @@ async function renderAnalytics(container) { html += '
' + data.avgContextPct + '%Avg context usedof 200K
'; } html += ''; + + // ── Cost attribution stacked bar ────────────────────────── + // Uses Sonnet-baseline ratios projected onto actual totalCost. + // Ratios are model-agnostic (Claude output/input is ~5:1 across all tiers). + if (data.outputCostEst !== undefined && data.totalCost > 0) { + var estTotal = data.inputCostEst + data.outputCostEst + data.cacheReadCostEst + data.cacheCreateCostEst; + var sharePct = function(v) { return estTotal > 0 ? (v / estTotal * 100) : 0; }; + var actualOf = function(v) { return (sharePct(v) / 100 * data.totalCost); }; + + var outPct = sharePct(data.outputCostEst).toFixed(1); + var inPct = sharePct(data.inputCostEst).toFixed(1); + var cwPct = sharePct(data.cacheCreateCostEst).toFixed(1); + var crPct = sharePct(data.cacheReadCostEst).toFixed(1); + + html += '
'; + html += '
Where your money goes
'; + html += '
'; + if (parseFloat(outPct) > 0) html += '
'; + if (parseFloat(inPct) > 0) html += '
'; + if (parseFloat(cwPct) > 0) html += '
'; + if (parseFloat(crPct) > 0) html += '
'; + html += '
'; + html += '
'; + html += 'Output ~' + outPct + '% (~$' + actualOf(data.outputCostEst).toFixed(2) + ')'; + html += 'Input ~' + inPct + '% (~$' + actualOf(data.inputCostEst).toFixed(2) + ')'; + if (parseFloat(cwPct) > 0) html += 'Cache write ~' + cwPct + '%'; + if (parseFloat(crPct) > 0) html += 'Cache read ~' + crPct + '%'; + html += '
'; + + if (data.cacheHitRate > 0 || data.cacheSavings > 0) { + html += '
'; + if (data.cacheHitRate > 0) { + var hitColor = data.cacheHitRate >= 60 ? 'var(--accent-green)' : data.cacheHitRate >= 30 ? '#f59e0b' : 'var(--text-muted)'; + html += 'Cache hit rate: ' + data.cacheHitRate + '%'; + } + if (data.cacheSavings > 0.001) { + html += 'Cache saved ~$' + data.cacheSavings.toFixed(0) + ' vs no-cache'; + } + html += '
'; + } + html += '
'; + } + html += ''; } diff --git a/src/frontend/styles.css b/src/frontend/styles.css index be798f4..4cd1dc3 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -2741,6 +2741,78 @@ body { .token-cache-create { border-color: rgba(251, 191, 36, 0.3); } .token-context { border-color: rgba(168, 85, 247, 0.3); } +/* ── Cost attribution bar ─────────────────────────────────── */ +.cost-attr-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.cost-attr-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 10px; +} + +.cost-attr-note { + font-size: 11px; + font-weight: 400; + color: var(--text-muted); +} + +.cost-attr-bar { + display: flex; + height: 20px; + border-radius: 6px; + overflow: hidden; + gap: 1px; + margin-bottom: 10px; +} + +.cost-attr-seg { + transition: opacity 0.15s; +} +.cost-attr-seg:hover { opacity: 0.75; cursor: default; } + +.seg-output { background: #ef4444; } +.seg-input { background: var(--accent-blue); } +.seg-cw { background: #f59e0b; } +.seg-cr { background: var(--accent-green); } + +.cost-attr-legend { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-bottom: 10px; +} + +.cost-attr-item { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--text-secondary); +} + +.cost-attr-dot { + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; +} + +.cache-metrics { + display: flex; + gap: 20px; + flex-wrap: wrap; +} + +.cache-metric { + font-size: 12px; + color: var(--text-muted); +} + /* ── Subscription vs API ──────────────────────────────────── */ .subscription-section { margin-top: 8px; }