fuzzyworld

dm / commit

feat: show per-turn and session cost estimates in sidebar

6b2c8b7f9f641605a9e82dc94acd8ec2a3f790cc · Pavel Safronov · 2026-05-27 17:49:55 -0700 · parents aab0d5c4cfed

feat: show per-turn and session cost estimates in sidebar

Reads input/output token counts from the Anthropic streaming response
(message_start and message_delta events) and computes USD cost using
hardcoded per-model pricing. Displays "Last turn" and "Total" in a
new Session Cost sidebar section. Cost accumulates across tool-use
loop iterations and persists across page reloads with the session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 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);