From 89c3e7c5d1eba7fbd0086a7da7121192401086a7 Mon Sep 17 00:00:00 2001 From: Pawel Date: Mon, 6 Apr 2026 22:03:47 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=82=D1=80=D0=B5=D0=BA=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=20=D0=BA=D1=8D=D1=88-=D1=82=D0=BE=D0=BA=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=20=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=D0=B0=20=D0=B2=20computeSessionCost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Кэш-токены (cache_read, cache_create) трекаются отдельно от input - Добавлен расчёт утилизации контекстного окна (средний % по всем ходам, база 200K) - getCostAnalytics расширен: totalInputTokens, totalOutputTokens, totalCacheReadTokens, totalCacheCreateTokens, avgContextPct, dailyRate, byAgent, agentNoCostData - totalSessions теперь считает только сессии с реальными данными - Обратно совместимо: новые поля добавлены, старые не изменены Co-Authored-By: Claude Opus 4.6 --- src/data.js | 85 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 8 deletions(-) diff --git a/src/data.js b/src/data.js index 83b08af..1b6d03c 100644 --- a/src/data.js +++ b/src/data.js @@ -1300,6 +1300,8 @@ function getSessionReplay(sessionId, project) { }; } +const CONTEXT_WINDOW = 200_000; // Claude's max context window (tokens) + // ── Pricing per model (per token, April 2026) ───────────── const MODEL_PRICING = { @@ -1329,11 +1331,15 @@ function getModelPricing(model) { function computeSessionCost(sessionId, project) { const found = findSessionFile(sessionId, project); - if (!found) return { cost: 0, inputTokens: 0, outputTokens: 0, model: '' }; + if (!found) return { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; let totalCost = 0; let totalInput = 0; let totalOutput = 0; + let totalCacheRead = 0; + let totalCacheCreate = 0; + let contextPctSum = 0; + let contextTurnCount = 0; let model = ''; try { @@ -1353,12 +1359,21 @@ function computeSessionCost(sessionId, project) { const cacheRead = u.cache_read_input_tokens || 0; const out = u.output_tokens || 0; - totalInput += inp + cacheCreate + cacheRead; + totalInput += inp; totalOutput += out; + totalCacheRead += cacheRead; + totalCacheCreate += cacheCreate; totalCost += inp * pricing.input + cacheCreate * pricing.cache_create + cacheRead * pricing.cache_read + out * pricing.output; + + // Track per-turn context window usage (average, not peak) + const contextThisTurn = inp + cacheCreate + cacheRead; + if (contextThisTurn > 0) { + contextPctSum += (contextThisTurn / CONTEXT_WINDOW) * 100; + contextTurnCount++; + } } // Codex: estimate from file size (no token usage in session files) } catch {} @@ -1377,7 +1392,7 @@ function computeSessionCost(sessionId, project) { } catch {} } - return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, model }; + return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreateTokens: totalCacheCreate, contextPctSum, contextTurnCount, model }; } // ── Cost analytics ──────────────────────────────────────── @@ -1386,20 +1401,59 @@ function getCostAnalytics(sessions) { const byDay = {}; const byProject = {}; const byWeek = {}; + const byAgent = {}; let totalCost = 0; let totalTokens = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalCacheCreateTokens = 0; + let globalContextPctSum = 0; + let globalContextTurnCount = 0; + let firstDate = null; + let lastDate = null; + let sessionsWithData = 0; + const agentNoCostData = {}; + for (const s of sessions) { + if (!byAgent[s.tool]) byAgent[s.tool] = { cost: 0, sessions: 0, tokens: 0, estimated: false }; + } const sessionCosts = []; for (const s of sessions) { const costData = computeSessionCost(s.id, s.project); const cost = costData.cost; - const tokens = costData.inputTokens + costData.outputTokens; - if (cost === 0 && tokens === 0) continue; + const tokens = costData.inputTokens + costData.outputTokens + costData.cacheReadTokens + costData.cacheCreateTokens; + if (cost === 0 && tokens === 0) { + if (!agentNoCostData[s.tool]) agentNoCostData[s.tool] = 0; + agentNoCostData[s.tool]++; + continue; + } + sessionsWithData++; totalCost += cost; totalTokens += tokens; - - // By day + totalInputTokens += costData.inputTokens; + totalOutputTokens += costData.outputTokens; + totalCacheReadTokens += costData.cacheReadTokens; + totalCacheCreateTokens += costData.cacheCreateTokens; + + // Per-agent breakdown + const agent = s.tool || 'unknown'; + if (!byAgent[agent]) byAgent[agent] = { cost: 0, sessions: 0, tokens: 0, estimated: false }; + byAgent[agent].cost += cost; + byAgent[agent].sessions++; + byAgent[agent].tokens += tokens; + if (agent === 'codex') byAgent[agent].estimated = true; + + // Context % across all turns + globalContextPctSum += costData.contextPctSum; + globalContextTurnCount += costData.contextTurnCount; + + // Date range const day = s.date || 'unknown'; + if (s.date) { + if (!firstDate || s.date < firstDate) firstDate = s.date; + if (!lastDate || s.date > lastDate) lastDate = s.date; + } if (!byDay[day]) byDay[day] = { cost: 0, sessions: 0, tokens: 0 }; byDay[day].cost += cost; byDay[day].sessions++; @@ -1429,14 +1483,29 @@ function getCostAnalytics(sessions) { // Sort top sessions by cost sessionCosts.sort((a, b) => b.cost - a.cost); + const days = firstDate && lastDate + ? Math.max(1, Math.round((new Date(lastDate) - new Date(firstDate)) / 86400000) + 1) + : 1; + return { totalCost, totalTokens, - totalSessions: sessions.length, + totalInputTokens, + totalOutputTokens, + totalCacheReadTokens, + totalCacheCreateTokens, + avgContextPct: globalContextTurnCount > 0 ? Math.round(globalContextPctSum / globalContextTurnCount) : 0, + dailyRate: totalCost / days, + firstDate, + lastDate, + days, + totalSessions: sessionsWithData, byDay, byWeek, byProject, topSessions: sessionCosts.slice(0, 10), + byAgent, + agentNoCostData, }; } From 11830e677a37f8596e8895e22ea70ccf5627511a Mon Sep 17 00:00:00 2001 From: Pawel Date: Mon, 6 Apr 2026 22:05:09 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=BA=D0=B0=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=BE=D0=B2=20Ope?= =?UTF-8?q?nCode=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20SQLite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - computeSessionCost: новая ветка для format=opencode, читает tokens.input/output, tokens.cache.read/write, tokens.reasoning из SQLite message.data - Валидация sessionId через /^[a-zA-Z0-9_-]+$/ (защита от SQL/shell инъекций) - getCostAnalytics: batch-запрос всех OpenCode сессий одним execSync вместо O(n) - Результат кэшируется в opencodeCostCache и переиспользуется в основном цикле - OpenCode без modelID помечается как estimated в byAgent Co-Authored-By: Claude Opus 4.6 --- src/data.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/src/data.js b/src/data.js index 1b6d03c..6b64627 100644 --- a/src/data.js +++ b/src/data.js @@ -1342,6 +1342,49 @@ function computeSessionCost(sessionId, project) { let contextTurnCount = 0; let model = ''; + // OpenCode: query SQLite directly for token data + if (found.format === 'opencode') { + const safeId = /^[a-zA-Z0-9_-]+$/.test(found.sessionId) ? found.sessionId : ''; + if (!safeId) return { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; + try { + const rows = execSync( + `sqlite3 "${OPENCODE_DB}" "SELECT data FROM message WHERE session_id = '${safeId}' AND json_extract(data, '$.role') = 'assistant' ORDER BY time_created"`, + { encoding: 'utf8', timeout: 10000 } + ).trim(); + if (rows) { + for (const row of rows.split('\n')) { + try { + const msgData = JSON.parse(row); + const t = msgData.tokens || {}; + if (!model && msgData.modelID) model = msgData.modelID; + const inp = t.input || 0; + const out = (t.output || 0) + (t.reasoning || 0); + const cacheRead = (t.cache && t.cache.read) || 0; + const cacheCreate = (t.cache && t.cache.write) || 0; + if (inp === 0 && out === 0) continue; + + const pricing = getModelPricing(msgData.modelID || model); + totalInput += inp; + totalOutput += out; + totalCacheRead += cacheRead; + totalCacheCreate += cacheCreate; + totalCost += inp * pricing.input + + cacheCreate * pricing.cache_create + + cacheRead * pricing.cache_read + + out * pricing.output; + + const contextThisTurn = inp + cacheCreate + cacheRead; + if (contextThisTurn > 0) { + contextPctSum += (contextThisTurn / CONTEXT_WINDOW) * 100; + contextTurnCount++; + } + } catch {} + } + } + } catch {} + return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreateTokens: totalCacheCreate, contextPctSum, contextTurnCount, model }; + } + try { const lines = readLines(found.file); for (const line of lines) { @@ -1419,8 +1462,50 @@ function getCostAnalytics(sessions) { } const sessionCosts = []; + // Pre-compute OpenCode costs in one batch query (avoids O(n) execSync calls) + const opencodeCostCache = {}; + const opencodeSessions = sessions.filter(s => s.tool === 'opencode'); + if (opencodeSessions.length > 0 && fs.existsSync(OPENCODE_DB)) { + try { + const batchRows = execSync( + `sqlite3 "${OPENCODE_DB}" "SELECT session_id, data FROM message WHERE json_extract(data, '$.role') = 'assistant' ORDER BY time_created"`, + { encoding: 'utf8', timeout: 30000 } + ).trim(); + if (batchRows) { + for (const row of batchRows.split('\n')) { + const sepIdx = row.indexOf('|'); + if (sepIdx < 0) continue; + const sessId = row.slice(0, sepIdx); + const jsonStr = row.slice(sepIdx + 1); + try { + const msgData = JSON.parse(jsonStr); + const t = msgData.tokens || {}; + const inp = t.input || 0; + const out = (t.output || 0) + (t.reasoning || 0); + const cacheRead = (t.cache && t.cache.read) || 0; + const cacheCreate = (t.cache && t.cache.write) || 0; + if (inp === 0 && out === 0) continue; + if (!opencodeCostCache[sessId]) opencodeCostCache[sessId] = { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; + const c = opencodeCostCache[sessId]; + if (!c.model && msgData.modelID) c.model = msgData.modelID; + const pricing = getModelPricing(msgData.modelID || c.model); + c.inputTokens += inp; + c.outputTokens += out; + c.cacheReadTokens += cacheRead; + c.cacheCreateTokens += cacheCreate; + c.cost += inp * pricing.input + cacheCreate * pricing.cache_create + cacheRead * pricing.cache_read + out * pricing.output; + const ctx = inp + cacheCreate + cacheRead; + if (ctx > 0) { c.contextPctSum += (ctx / CONTEXT_WINDOW) * 100; c.contextTurnCount++; } + } catch {} + } + } + } catch {} + } + for (const s of sessions) { - const costData = computeSessionCost(s.id, s.project); + const costData = (s.tool === 'opencode' && opencodeCostCache[s.id]) + ? opencodeCostCache[s.id] + : computeSessionCost(s.id, s.project); const cost = costData.cost; const tokens = costData.inputTokens + costData.outputTokens + costData.cacheReadTokens + costData.cacheCreateTokens; if (cost === 0 && tokens === 0) { @@ -1443,6 +1528,7 @@ function getCostAnalytics(sessions) { byAgent[agent].sessions++; byAgent[agent].tokens += tokens; if (agent === 'codex') byAgent[agent].estimated = true; + if (agent === 'opencode' && !costData.model) byAgent[agent].estimated = true; // Context % across all turns globalContextPctSum += costData.contextPctSum;