arnaucube
arnaucube/study-labPublic Self-hosted repository
add button for api tokens usage info
arnaucube git@arnaucube.com
Mon, 17 Aug 2026 10:05:13 +0200
commit

281be5315c990f4f9b3d101748a68d20ed1a6ff8

parent

41bbd4e5b86939878e81a483c147b4be3e748f83

4 files changed, 277 insertions(+), 12 deletions(-)

jump to
M README.md → README.md

@@ -15,6 +15,7 @@ - Persistent sessions and API settings in `localStorage`

- Lightweight vanilla HTML, CSS, and JavaScript - Responsive concept map for desktop and small screens - Persistent light/dark theme with system-theme detection +- Monthly token tracking with per-model details and a local billing estimate - Large-answer optimizations: throttled stream painting and `content-visibility` ## Run locally

@@ -52,10 +53,13 @@ The complete parent-path conversation is sent with each request. This makes every branch self-contained and avoids coupling local history to provider-side response IDs.

## Storage and security -The browser stores two local keys: +The browser stores three local keys: - `study-pal:v1` — conversation nodes - `study-pal:settings:v1` — API key, model, endpoint, and system instructions +- `study-pal:usage:v1` — token usage and estimated request costs recorded by this browser + +The usage button in the header counts tokens reported by completed Responses API requests made from this browser. Its estimated USD amount uses a local pricing table and is not an invoice: it cannot see other browsers or applications, historical requests made before tracking was added, credits, taxes, or special pricing. Use the linked OpenAI usage dashboard as the source of truth for billing. This design is convenient for a private tool on your own laptop, but browser storage is not a secure secret vault. Any JavaScript executing on the same origin can read it. Therefore:
M app.js → app.js

@@ -3,6 +3,22 @@ 'use strict';

const STORAGE_KEY = 'study-pal:v1'; const SETTINGS_KEY = 'study-pal:settings:v1'; + const USAGE_KEY = 'study-pal:usage:v1'; + // USD per million tokens. Last checked against OpenAI Docs on 2026-08-17. + const MODEL_PRICES = [ + { match: /^gpt-5\.6-terra(?:-|$)/, input: 2, cached: .2, output: 12 }, + { match: /^gpt-5\.6-luna(?:-|$)/, input: .2, cached: .02, output: 1.2 }, + { match: /^gpt-5\.6-sol(?:-|$)/, input: 5, cached: .5, output: 30 }, + { match: /^gpt-5\.6$/, input: 5, cached: .5, output: 30 }, + { match: /^gpt-5\.4-mini(?:-|$)/, input: .75, cached: .075, output: 4.5 }, + { match: /^gpt-5\.4(?:-20\d{2}|$)/, input: 2.5, cached: .25, output: 15 }, + { match: /^gpt-5-mini(?:-|$)/, input: .25, cached: .025, output: 2 }, + { match: /^gpt-5-nano(?:-|$)/, input: .05, cached: .005, output: .4 }, + { match: /^gpt-5(?:-20\d{2}|$)/, input: 1.25, cached: .125, output: 10 }, + { match: /^gpt-4\.1-mini(?:-|$)/, input: .4, cached: .1, output: 1.6 }, + { match: /^gpt-4\.1(?:-20\d{2}|$)/, input: 2, cached: .5, output: 8 }, + { match: /^gpt-4o-mini(?:-|$)/, input: .15, cached: .075, output: .6 } + ]; const DEFAULT_SETTINGS = { apiKey: '', model: 'gpt-5-mini',

@@ -18,11 +34,16 @@ nodeCount: $('#node-count'), branchCount: $('#branch-count'), branchContext: $('#branch-context'),

branchLabel: $('#branch-label'), selectionMenu: $('#selection-menu'), selectionPreview: $('#selection-preview'), selectionAction: $('#selection-action'), selectionCustom: $('#selection-custom'), settings: $('#settings-dialog'), apiKey: $('#api-key'), model: $('#model'), apiUrl: $('#api-url'), systemPrompt: $('#system-prompt'), - keyStatus: $('#key-status'), toast: $('#toast') + keyStatus: $('#key-status'), toast: $('#toast'), usageDialog: $('#usage-dialog'), + monthTokens: $('#month-tokens'), usageMonth: $('#usage-month'), usageTotal: $('#usage-total'), + usageCost: $('#usage-cost'), usageRequests: $('#usage-requests'), usageInput: $('#usage-input'), + usageCached: $('#usage-cached'), usageOutput: $('#usage-output'), usageModels: $('#usage-models'), + usageRecent: $('#usage-recent'), usageNote: $('#usage-note') }; let settings = loadJSON(SETTINGS_KEY, DEFAULT_SETTINGS); let state = normalizeState(loadJSON(STORAGE_KEY, { nodes: [], activeId: null })); + let usageRecords = loadUsage(); let activeRequest = null; let selectedText = null; let saveTimer = 0;

@@ -31,6 +52,13 @@

function loadJSON(key, fallback) { try { return { ...fallback, ...JSON.parse(localStorage.getItem(key) || '{}') }; } catch { return { ...fallback }; } + } + + function loadUsage() { + try { + const records = JSON.parse(localStorage.getItem(USAGE_KEY) || '[]'); + return Array.isArray(records) ? records.filter(record => record && Number.isFinite(record.timestamp)) : []; + } catch { return []; } } function normalizeState(value) {

@@ -236,6 +264,7 @@ renderAll({ scroll: true });

setBusy(true); const controller = new AbortController(); activeRequest = controller; + let completedUsage = null; try { const response = await fetch(settings.apiUrl, {

@@ -252,9 +281,13 @@ }

if (!response.body) throw new Error('This browser did not provide a streaming response body.'); await consumeSSE(response.body, event => { if (event.type === 'response.output_text.delta') node.answer += event.delta || ''; + if (event.type === 'response.completed' || event.type === 'response.done') { + completedUsage = event.response?.usage || event.usage || completedUsage; + } if (event.type === 'response.failed') throw new Error(event.response?.error?.message || 'The model response failed.'); }, () => updateStreamedNode(node)); node.status = 'complete'; + if (completedUsage) recordUsage(node.id, settings.model, settings.apiUrl, completedUsage); } catch (error) { node.status = 'error'; node.error = error.name === 'AbortError' ? 'Response stopped.' : error.message;

@@ -276,22 +309,24 @@ if (paintQueued) return;

paintQueued = true; setTimeout(() => { paintQueued = false; onPaint(); }, 70); }; + const processBlock = block => { + for (const line of block.split(/\r?\n/)) { + if (!line.startsWith('data:')) continue; + const data = line.slice(5).trim(); + if (!data || data === '[DONE]') continue; + onEvent(JSON.parse(data)); + queuePaint(); + } + }; while (true) { const { done, value } = await reader.read(); buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); const blocks = buffer.split(/\r?\n\r?\n/); buffer = blocks.pop() || ''; - for (const block of blocks) { - for (const line of block.split(/\r?\n/)) { - if (!line.startsWith('data:')) continue; - const data = line.slice(5).trim(); - if (!data || data === '[DONE]') continue; - onEvent(JSON.parse(data)); - queuePaint(); - } - } + for (const block of blocks) processBlock(block); if (done) break; } + if (buffer.trim()) processBlock(buffer); onPaint(); }

@@ -334,6 +369,121 @@ try { localStorage.setItem('study-pal:theme', next); } catch {}

updateThemeButton(); } + function priceForModel(model) { + const key = String(model || '').toLowerCase(); + return MODEL_PRICES.find(price => price.match.test(key)) || null; + } + + function isOpenAIEndpoint(apiUrl) { + try { return new URL(apiUrl).hostname === 'api.openai.com'; } + catch { return false; } + } + + function recordUsage(nodeId, model, apiUrl, rawUsage) { + if (usageRecords.some(record => record.nodeId === nodeId)) return; + const inputTokens = Number(rawUsage.input_tokens) || 0; + const outputTokens = Number(rawUsage.output_tokens) || 0; + const cachedTokens = Math.min(inputTokens, Number(rawUsage.input_tokens_details?.cached_tokens) || 0); + const totalTokens = Number(rawUsage.total_tokens) || inputTokens + outputTokens; + const price = isOpenAIEndpoint(apiUrl) ? priceForModel(model) : null; + const costUSD = price + ? (((inputTokens - cachedTokens) * price.input) + (cachedTokens * price.cached) + (outputTokens * price.output)) / 1_000_000 + : null; + usageRecords.push({ + id: uid(), nodeId, timestamp: Date.now(), model: String(model || 'unknown'), + inputTokens, cachedTokens, outputTokens, totalTokens, costUSD, + rates: price ? { input: price.input, cached: price.cached, output: price.output } : null + }); + // Keep bounded history while retaining roughly a year of normal personal use. + usageRecords = usageRecords.slice(-2000); + try { localStorage.setItem(USAGE_KEY, JSON.stringify(usageRecords)); } + catch { showToast('Usage was returned, but local usage history is full'); } + updateUsageButton(); + } + + function currentMonthRecords() { + const now = new Date(); + return usageRecords.filter(record => { + const date = new Date(record.timestamp); + return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth(); + }); + } + + function sum(records, key) { return records.reduce((total, record) => total + (Number(record[key]) || 0), 0); } + + function formatTokens(value, compact = false) { + const number = Number(value) || 0; + if (!compact || number < 1000) return Math.round(number).toLocaleString(); + if (number < 1_000_000) return `${(number / 1000).toFixed(number < 10_000 ? 1 : 0)}K`; + return `${(number / 1_000_000).toFixed(number < 10_000_000 ? 1 : 0)}M`; + } + + function formatCost(value) { + if (!Number.isFinite(value)) return 'Unpriced'; + if (value > 0 && value < .01) return '<$0.01'; + return value.toLocaleString(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: value < 1 ? 4 : 2 }); + } + + function updateUsageButton() { + const total = sum(currentMonthRecords(), 'totalTokens'); + els.monthTokens.textContent = formatTokens(total, true); + const label = `${formatTokens(total)} tokens tracked this month`; + $('#open-usage').title = label; + $('#open-usage').setAttribute('aria-label', `${label}. Open API usage details`); + } + + function usageRowsByModel(records) { + const groups = new Map(); + for (const record of records) { + const model = String(record.model || 'unknown'); + if (!groups.has(model)) groups.set(model, { model, totalTokens: 0, costUSD: 0, unpriced: 0 }); + const group = groups.get(model); + group.totalTokens += Number(record.totalTokens) || 0; + if (Number.isFinite(record.costUSD)) group.costUSD += record.costUSD; + else group.unpriced += 1; + } + return [...groups.values()].sort((a, b) => b.totalTokens - a.totalTokens); + } + + function renderUsage() { + const records = currentMonthRecords().sort((a, b) => b.timestamp - a.timestamp); + const totalCost = records.reduce((total, record) => total + (Number.isFinite(record.costUSD) ? record.costUSD : 0), 0); + const unpriced = records.filter(record => !Number.isFinite(record.costUSD)).length; + els.usageMonth.textContent = new Date().toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); + els.usageTotal.textContent = formatTokens(sum(records, 'totalTokens')); + els.usageCost.textContent = records.length && unpriced === records.length + ? 'Unpriced' + : `${formatCost(totalCost)}${unpriced ? ' +' : ''}`; + els.usageRequests.textContent = formatTokens(records.length); + els.usageInput.textContent = formatTokens(sum(records, 'inputTokens')); + els.usageCached.textContent = formatTokens(sum(records, 'cachedTokens')); + els.usageOutput.textContent = formatTokens(sum(records, 'outputTokens')); + + const groups = usageRowsByModel(records); + els.usageModels.innerHTML = groups.length ? groups.map(group => ` + <div class="usage-row"> + <span class="model-name" title="${escapeHTML(group.model)}">${escapeHTML(group.model)}</span> + <span class="row-tokens">${formatTokens(group.totalTokens)} tokens</span> + <span class="row-cost">${group.unpriced ? 'Partly unpriced' : formatCost(group.costUSD)}</span> + </div>`).join('') : '<p class="usage-empty">No tracked requests this month.</p>'; + + els.usageRecent.innerHTML = records.length ? records.slice(0, 12).map(record => ` + <div class="usage-row"> + <span class="model-name">${new Date(record.timestamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span> + <span class="row-tokens">${formatTokens(record.totalTokens)} tokens</span> + <span class="row-cost">${formatCost(record.costUSD)}</span> + </div>`).join('') : '<p class="usage-empty">Completed requests will appear here.</p>'; + + els.usageNote.textContent = unpriced + ? `${unpriced} request${unpriced === 1 ? '' : 's'} could not be estimated because the model or endpoint has no local price entry.` + : 'Estimate uses standard OpenAI text-token prices recorded when each request completed. Pricing table checked August 17, 2026.'; + } + + function openUsage() { + renderUsage(); + els.usageDialog.showModal(); + } + function showSelectionMenu(text, nodeId, rect) { selectedText = { text: text.slice(0, 5000), nodeId }; els.selectionPreview.textContent = text.length > 240 ? `${text.slice(0, 240)}…` : text;

@@ -404,8 +554,19 @@ });

$('#close-selection').addEventListener('click', hideSelectionMenu); window.addEventListener('scroll', hideSelectionMenu, { passive: true }); $('#open-settings').addEventListener('click', openSettings); + $('#open-usage').addEventListener('click', openUsage); $('#theme-toggle').addEventListener('click', toggleTheme); $('#close-settings').addEventListener('click', () => els.settings.close()); + $('#close-usage').addEventListener('click', () => els.usageDialog.close()); + $('#reset-usage').addEventListener('click', () => { + if (!usageRecords.length || confirm('Reset the locally tracked API usage history? This cannot be undone.')) { + usageRecords = []; + localStorage.removeItem(USAGE_KEY); + updateUsageButton(); + renderUsage(); + showToast('Local usage history reset'); + } + }); $('#toggle-key').addEventListener('click', event => { const show = els.apiKey.type === 'password'; els.apiKey.type = show ? 'text' : 'password'; event.target.textContent = show ? 'Hide' : 'Show'; }); $('#settings-form').addEventListener('submit', event => { event.preventDefault();

@@ -426,5 +587,6 @@ $('#expand-map').addEventListener('click', () => { document.body.classList.remove('map-collapsed'); document.body.classList.add('map-open'); $('#expand-map').hidden = true; });

updateKeyStatus(); updateThemeButton(); + updateUsageButton(); renderAll(); })();
M index.html → index.html

@@ -22,13 +22,16 @@ <span class="brand-mark" aria-hidden="true">S</span>

<span>Study Pal</span> </a> <div class="top-actions"> + <button id="open-usage" class="usage-button" type="button" aria-haspopup="dialog" aria-label="Open API usage details"> + <span id="month-tokens">0</span> <span class="usage-caption">tokens</span> + </button> <button id="theme-toggle" class="theme-toggle" type="button" aria-label="Switch to dark theme" title="Switch theme"> <span aria-hidden="true">☾</span> </button> <button id="new-session" class="button button-ghost" type="button">New map</button> <button id="open-settings" class="button button-dark" type="button" aria-haspopup="dialog"> <span class="status-dot" id="key-status" aria-hidden="true"></span> - Settings + <span class="settings-label">Settings</span> </button> </div> </header>

@@ -151,6 +154,61 @@ <div class="security-callout"><strong>Local-use note</strong> — any script running on this origin can access local storage. Use a restricted project key and never deploy this frontend publicly with a saved key.</div>

<div class="dialog-actions"> <button id="forget-key" class="button button-ghost danger" type="button">Forget key</button> <button class="button button-dark" value="save" type="submit">Save settings</button> + </div> + </form> + </dialog> + + <dialog id="usage-dialog" class="settings-dialog usage-dialog"> + <form method="dialog"> + <div class="dialog-heading"> + <div> + <span class="eyebrow">Local estimate</span> + <h2>API usage</h2> + </div> + <button id="close-usage" class="icon-button" type="button" aria-label="Close API usage">×</button> + </div> + <p class="settings-copy"><span id="usage-month"></span> · Requests made from this browser</p> + + <div class="usage-summary"> + <div class="usage-stat primary"> + <span>Tokens</span> + <strong id="usage-total">0</strong> + </div> + <div class="usage-stat"> + <span>Estimated cost</span> + <strong id="usage-cost">$0.00</strong> + </div> + <div class="usage-stat"> + <span>Requests</span> + <strong id="usage-requests">0</strong> + </div> + </div> + + <section class="usage-section" aria-labelledby="token-breakdown-title"> + <h3 id="token-breakdown-title">Token breakdown</h3> + <div class="token-breakdown"> + <span>Input <strong id="usage-input">0</strong></span> + <span>Cached input <strong id="usage-cached">0</strong></span> + <span>Output <strong id="usage-output">0</strong></span> + </div> + </section> + + <section class="usage-section" aria-labelledby="models-title"> + <h3 id="models-title">By model</h3> + <div id="usage-models" class="usage-models"></div> + </section> + + <section class="usage-section" aria-labelledby="recent-title"> + <h3 id="recent-title">Recent requests</h3> + <div id="usage-recent" class="usage-recent"></div> + </section> + + <p id="usage-note" class="usage-note"></p> + <p class="usage-disclaimer">This is not your OpenAI invoice. It excludes requests from other browsers and applications, incomplete requests without returned usage, taxes, credits, tools, special processing, and usage recorded before this tracker was added. Prices can change.</p> + <div class="dialog-actions"> + <button id="reset-usage" class="button button-ghost danger" type="button">Reset local history</button> + <a class="button button-ghost usage-dashboard" href="https://platform.openai.com/usage" target="_blank" rel="noopener noreferrer">Official dashboard ↗</a> + <button class="button button-dark" value="close" type="submit">Done</button> </div> </form> </dialog>
M styles.css → styles.css

@@ -56,6 +56,9 @@ }

.brand { display: flex; align-items: center; gap: 10px; color: inherit; text-decoration: none; font-weight: 760; letter-spacing: -.02em; } .brand-mark { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50% 48% 44% 52%; background: var(--ink); color: white; font-family: Georgia, serif; font-style: italic; } .top-actions { display: flex; align-items: center; gap: 8px; } +.usage-button { border: 1px solid var(--line); border-radius: 999px; background: var(--card); padding: 8px 12px; cursor: pointer; color: var(--muted); font-size: .72rem; font-variant-numeric: tabular-nums; white-space: nowrap; transition: transform .15s, border-color .15s; } +.usage-button:hover { transform: translateY(-1px); border-color: var(--sage); } +.usage-button #month-tokens { color: var(--ink); font-weight: 800; } .theme-toggle { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid var(--line); border-radius: 50%; background: transparent; cursor: pointer; font-size: 1rem; transition: transform .18s, background .18s; } .theme-toggle:hover { background: var(--card); transform: rotate(8deg); } .button { border: 1px solid transparent; border-radius: 999px; padding: 9px 15px; cursor: pointer; font-weight: 650; font-size: .86rem; transition: transform .15s, background .15s; }

@@ -176,6 +179,29 @@ .settings-dialog summary { cursor: pointer; color: var(--sage-dark); font-size: .78rem; font-weight: 700; }

.security-callout { background: #f4eee5; color: #6d5945; border-radius: 9px; padding: 10px 12px; font-size: .7rem; line-height: 1.5; } .dialog-actions { display: flex; justify-content: space-between; margin-top: 18px; } .danger { color: #9d443e; } +.usage-dialog { width: min(620px, calc(100vw - 28px)); } +.usage-summary { display: grid; grid-template-columns: 1.25fr 1fr .75fr; gap: 9px; margin: 18px 0 20px; } +.usage-stat { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); } +.usage-stat.primary { background: #e8efeb; border-color: #cad8d0; } +.usage-stat span { display: block; margin-bottom: 6px; color: var(--muted); font-size: .65rem; text-transform: uppercase; letter-spacing: .08em; } +.usage-stat strong { display: block; overflow: hidden; text-overflow: ellipsis; font: 400 1.35rem/1.1 Georgia, serif; font-variant-numeric: tabular-nums; } +.usage-section { margin: 17px 0; } +.usage-section h3 { margin: 0 0 8px; font-size: .69rem; letter-spacing: .09em; text-transform: uppercase; } +.token-breakdown { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; } +.token-breakdown span { padding: 10px; color: var(--muted); font-size: .68rem; } +.token-breakdown span + span { border-left: 1px solid var(--line); } +.token-breakdown strong { display: block; margin-top: 3px; color: var(--ink); font-size: .85rem; font-variant-numeric: tabular-nums; } +.usage-models, .usage-recent { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; } +.usage-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 12px; padding: 9px 11px; font-size: .72rem; } +.usage-row + .usage-row { border-top: 1px solid var(--line); } +.usage-row .model-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: "SFMono-Regular", Consolas, monospace; } +.usage-row .row-tokens, .usage-row .row-cost { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; } +.usage-recent { max-height: 176px; overflow-y: auto; } +.usage-empty { margin: 0; padding: 15px; color: var(--muted); text-align: center; font-size: .74rem; } +.usage-note { margin: 14px 0 5px; padding: 9px 11px; border-radius: 9px; background: #f1f2ed; color: var(--muted); font-size: .7rem; line-height: 1.45; } +.usage-disclaimer { color: var(--muted); font-size: .64rem; line-height: 1.45; } +.usage-dashboard { text-decoration: none; text-align: center; } +.usage-dialog .dialog-actions { gap: 8px; align-items: center; } .toast { position: fixed; z-index: 60; left: 50%; bottom: 24px; transform: translate(-50%, 20px); background: var(--ink); color: white; padding: 9px 14px; border-radius: 9px; font-size: .76rem; opacity: 0; pointer-events: none; transition: .2s; } .toast.show { opacity: 1; transform: translate(-50%, 0); }

@@ -194,6 +220,7 @@ .topbar { height: 60px; padding: 0 13px; }

.app-shell { padding-top: 60px; } .map-panel { top: 60px; } .top-actions .button { padding: 8px 10px; } + #new-session { display: none; } .welcome { width: calc(100% - 28px); } .welcome h1 { font-size: 3.2rem; } .conversation { width: calc(100% - 26px); padding-top: 25px; }

@@ -202,6 +229,18 @@ .hint { display: none; }

.answer { grid-template-columns: 25px minmax(0,1fr); gap: 9px; } .answer-mark { width: 25px; height: 25px; } .answer-actions { margin-left: 34px; } + .usage-summary { grid-template-columns: 1fr 1fr; } + .usage-stat:last-child { grid-column: 1 / -1; } + .usage-dialog form { padding: 19px; } + .usage-dialog .dialog-actions { display: grid; grid-template-columns: 1fr 1fr; } + .usage-dialog .dialog-actions .button-dark { grid-column: 1 / -1; } +} +@media (max-width: 390px) { + .brand > span:last-child, .usage-caption, .settings-label { display: none; } + .top-actions { gap: 5px; } + .usage-button { min-width: 44px; padding-inline: 9px; } + .token-breakdown { grid-template-columns: 1fr; } + .token-breakdown span + span { border-left: 0; border-top: 1px solid var(--line); } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation: none !important; transition: none !important; } }

@@ -233,3 +272,5 @@ :root[data-theme="dark"] .selection-menu textarea { background: var(--card); }

:root[data-theme="dark"] .selection-menu blockquote { background: #1a211e; } :root[data-theme="dark"] .security-callout { background: #30291f; color: #d1b99e; } :root[data-theme="dark"] .error-text { color: #f0aaa2; background: #3c2422; } +:root[data-theme="dark"] .usage-stat.primary { background: #223029; border-color: #35493f; } +:root[data-theme="dark"] .usage-note { background: #1a211e; }