dm / commit
feat: show per-turn and session cost estimates in sidebar
public/index.html | 52 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/public/index.html b/public/index.html index ca41061..910edca 100644 --- a/public/index.html +++ b/public/index.html @@ -983,6 +983,20 @@ body { <ul id="inventory-list"></ul> </div> + <!-- Session Cost --> + <div class="sidebar-section"> + <div class="sidebar-title">Session Cost</div> + <div class="stat-row"> + <span class="stat-label">Last turn</span> + <span class="stat-value" id="cost-last-turn">—</span> + </div> + <div class="stat-row"> + <span class="stat-label">Total</span> + <span class="stat-value" id="cost-total">$0.0000</span> + </div> + <p style="font-size:0.65rem;color:var(--text-dim);margin-top:4px;font-style:italic">Estimates · <a href="https://www.anthropic.com/pricing" target="_blank" rel="noopener" style="color:var(--text-dim)">verify pricing</a></p> + </div> + <!-- Dice Roller --> <div class="sidebar-section"> <div class="sidebar-title">Dice Roller</div> @@ -1267,6 +1281,22 @@ let gameState = null; let conversationHistory = []; let isBusy = false; +// Pricing in USD per 1M tokens — verify at anthropic.com/pricing +const MODEL_PRICING = { + 'claude-haiku-4-5-20251001': { input: 0.80, output: 4.00 }, + 'claude-sonnet-4-5': { input: 3.00, output: 15.00 }, + 'claude-opus-4-7': { input: 15.00, output: 75.00 }, +}; + +let sessionCost = 0; +let lastTurnCost = 0; + +function updateCostDisplay() { + const fmt = n => `$${n.toFixed(4)}`; + document.getElementById('cost-last-turn').textContent = lastTurnCost === 0 ? '—' : fmt(lastTurnCost); + document.getElementById('cost-total').textContent = fmt(sessionCost); +} + // ── SESSION PERSISTENCE ─────────────────────────────── function saveSession() { @@ -1277,6 +1307,7 @@ function saveSession() { gameState: stateToSave, conversationHistory, messagesHTML: document.getElementById('messages').innerHTML, + sessionCost, })); } catch (e) { console.warn('Session save failed (storage full?):', e); @@ -1306,13 +1337,17 @@ async function tryResumeSession() { document.getElementById('header-title').textContent = `${gameState.character.name} — ${gameState.character.race} ${gameState.character.class}`; + sessionCost = session.sessionCost || 0; + lastTurnCost = 0; + updateSidebar(); buildDiceRoller(); + updateCostDisplay(); const msgEl = document.getElementById('messages'); msgEl.scrollTop = msgEl.scrollHeight; - console.log(`▶ Resumed session — ${conversationHistory.length} messages, Lv ${gameState.character.level} ${gameState.character.name}`); + console.log(`▶ Resumed session — ${conversationHistory.length} messages, Lv ${gameState.character.level} ${gameState.character.name}, $${sessionCost.toFixed(5)} spent`); return true; } @@ -1756,6 +1791,8 @@ async function sendMessage(text, showAsPlayer = true) { let statUpdate = null; let firstChunk = true; let loopIteration = 0; + let turnInputTokens = 0; + let turnOutputTokens = 0; while (true) { loopIteration++; @@ -1836,7 +1873,9 @@ async function sendMessage(text, showAsPlayer = true) { try { data = JSON.parse(line.slice(6)); } catch { continue; } eventCount++; - if (data.type === 'content_block_start') { + if (data.type === 'message_start') { + turnInputTokens += data.message?.usage?.input_tokens ?? 0; + } else if (data.type === 'content_block_start') { blocks[data.index] = { ...data.content_block, _buf: '' }; console.log(` ▶ block_start [${data.index}] type=${data.content_block.type}`); } else if (data.type === 'content_block_delta') { @@ -1858,6 +1897,7 @@ async function sendMessage(text, showAsPlayer = true) { } } else if (data.type === 'message_delta') { stopReason = data.delta.stop_reason; + turnOutputTokens += data.usage?.output_tokens ?? 0; console.log(` ✓ message_delta stop_reason=${stopReason} events=${eventCount}`); } else if (data.type === 'error') { console.error(' 🔴 stream error event:', data); @@ -1898,8 +1938,14 @@ async function sendMessage(text, showAsPlayer = true) { finalizeDMMessage(dmEl, rawText); if (statUpdate) applyStatUpdate(statUpdate); conversationHistory.push({ role: 'assistant', content: rawText }); + + const pricing = MODEL_PRICING['claude-haiku-4-5-20251001'] ?? { input: 0.80, output: 4.00 }; + lastTurnCost = (turnInputTokens * pricing.input + turnOutputTokens * pricing.output) / 1_000_000; + sessionCost += lastTurnCost; + updateCostDisplay(); + console.log(`✅ Turn complete — ${turnInputTokens}in/${turnOutputTokens}out tokens, turn $${lastTurnCost.toFixed(5)}, session $${sessionCost.toFixed(5)}`); + saveSession(); - console.log(`✅ Turn complete. History length: ${conversationHistory.length}`); } catch (err) { console.error('🔴 sendMessage error:', err);