arnaucube
arnaucube/study-labPublic Self-hosted repository
improve apis usage view: get from api monthly usage till date, add cost per model (i/o) at model selection
arnaucube git@arnaucube.com
Thu, 27 Aug 2026 07:59:09 +0200
commit

a9818bccc332c00535e4d273d05b4c9f70f4f497

parent

e38e0c9fe0050cf17c1aa3e810ce82ba482c83e4

5 files changed, 248 insertions(+), 15 deletions(-)

jump to
M README.md → README.md

@@ -26,6 +26,8 @@ - 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 +- month-to-date actual spend retrieved from the OpenAI organization Costs API and the configured OpenRouter key +- live input and output token pricing beside model names in Settings - independent chat-history clearing and per-conversation deletion - multiple persistent maps with a browsable conversation sidebar - per-map PDF and Markdown context, uploaded once through the Files API

@@ -67,6 +69,8 @@ 4. Select the radio button on the model card that should be used by default.

5. Save and ask a question. Settings stores the OpenAI and OpenRouter API keys once at the top. Model cards below can independently choose a provider, model ID, and endpoint; one card is selected as the default connection. A fresh installation starts with one OpenAI model card, and **+ Add model** creates more. + +The usage dialog can retrieve actual month-to-date provider spend. OpenRouter uses the configured API key and reports spend for that key. OpenAI's organization Costs API requires a separate Admin API key created by an organization owner; because that key has elevated privileges, Study Lab keeps it only in `sessionStorage` for the current browser tab rather than in persistent local storage. Provider-reported totals may lag recent requests and can differ from invoices because of taxes, credit-purchase fees, or other adjustments. To add reference material, choose **+ PDF / MD** beside the composer and select one or more PDF or Markdown (`.md`) files. The files are attached to the current map and included with questions sent through the same provider, including questions asked from older branches. Each file must be under 50 MB, and files attached through one provider in a map must total under 50 MB.
M app.js → app.js

@@ -6,6 +6,7 @@ const STORAGE_KEY = `${APP_PREFIX}:v1`;

const SETTINGS_KEY = `${APP_PREFIX}:settings:v1`; const USAGE_KEY = `${APP_PREFIX}:usage:v1`; const OPENROUTER_PRICES_KEY = `${APP_PREFIX}:openrouter-prices:v1`; + const OPENAI_ADMIN_SESSION_KEY = `${APP_PREFIX}:openai-admin-key`; const TREE_ZOOM_KEY = `${APP_PREFIX}:tree-zoom`; const THEME_KEY = `${APP_PREFIX}:theme`; const FILE_REQUEST_LIMIT = 50 * 1024 * 1024;

@@ -49,14 +50,17 @@ prompt: $('#prompt'), send: $('#send'), tree: $('#tree'), mapEmpty: $('#map-empty'),

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'), - openaiApiKey: $('#openai-api-key'), openrouterApiKey: $('#openrouter-api-key'), modelConfigs: $('#model-configs'), + openaiApiKey: $('#openai-api-key'), openrouterApiKey: $('#openrouter-api-key'), openaiAdminKey: $('#openai-admin-key'), modelConfigs: $('#model-configs'), openaiModelOptions: $('#openai-model-options'), openrouterModelOptions: $('#openrouter-model-options'), systemPrompt: $('#system-prompt'), 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'), usageDashboard: $('#usage-dashboard'), historyList: $('#history-list'), + usageRecent: $('#usage-recent'), usageNote: $('#usage-note'), historyList: $('#history-list'), + actualOpenAICost: $('#actual-openai-cost'), actualOpenAIStatus: $('#actual-openai-status'), + actualOpenRouterCost: $('#actual-openrouter-cost'), actualOpenRouterStatus: $('#actual-openrouter-status'), + actualCombinedCost: $('#actual-combined-cost'), actualCombinedStatus: $('#actual-combined-status'), historyEmpty: $('#history-empty'), historyToggle: $('#toggle-history'), attachments: $('#attachments'), uploadFile: $('#upload-file'), fileInput: $('#file-input'), activeConnection: $('#active-connection'), activeModel: $('#active-model'), activeProvider: $('#active-provider'),

@@ -83,6 +87,8 @@ let pendingG = false;

let pendingGTimer = 0; let treeZoom = loadTreeZoom(); let installPrompt = null; + let actualSpendRequest = 0; + let modelPriceTimer = 0; function loadJSON(key, fallback) { try { return JSON.parse(localStorage.getItem(key) || 'null') || fallback; }

@@ -936,6 +942,8 @@ activeModelId: settings.activeModelId, apiKeys: settings.apiKeys, models: settings.models

}); els.openaiApiKey.value = settingsDraft.apiKeys.openai || ''; els.openrouterApiKey.value = settingsDraft.apiKeys.openrouter || ''; + try { els.openaiAdminKey.value = sessionStorage.getItem(OPENAI_ADMIN_SESSION_KEY) || ''; } + catch { els.openaiAdminKey.value = ''; } fillModelOptions(els.openaiModelOptions, PROVIDERS.openai.models); fillModelOptions(els.openrouterModelOptions, PROVIDERS.openrouter.models); renderModelCards();

@@ -969,6 +977,7 @@ <label>Model

<input class="model-name-input" type="text" value="${escapeHTML(connection.model)}" placeholder="${escapeHTML(defaults.model)}" list="${connection.provider}-model-options" spellcheck="false" required> </label> </div> + <div class="model-price" aria-live="polite">Checking pricing…</div> <details class="provider-advanced"> <summary>Endpoint</summary> <label>Responses API endpoint

@@ -978,6 +987,7 @@ </details>

<button class="remove-model" type="button"${settingsDraft.models.length === 1 ? ' disabled' : ''} aria-label="Remove model ${index + 1}">Remove model</button> </section>`; }).join(''); + updateAllModelPrices(); } function captureSettingsForm() {

@@ -995,6 +1005,11 @@ };

}); settingsDraft.activeModelId = $('input[name="default-model"]:checked', els.modelConfigs)?.value || settingsDraft.models[0]?.id; + try { + const adminKey = els.openaiAdminKey.value.trim(); + if (adminKey) sessionStorage.setItem(OPENAI_ADMIN_SESSION_KEY, adminKey); + else sessionStorage.removeItem(OPENAI_ADMIN_SESSION_KEY); + } catch {} } function updateModelCardSelection() {

@@ -1042,6 +1057,37 @@ const key = String(model || '').toLowerCase();

return MODEL_PRICES.find(price => price.match.test(key)) || null; } + function formatModelRate(value) { + if (!Number.isFinite(value)) return 'Unavailable'; + return value.toLocaleString(undefined, { + style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 6 + }); + } + + async function updateModelPrice(card) { + if (!card) return; + const preview = $('.model-price', card); + const provider = $('.model-provider', card)?.value; + const model = $('.model-name-input', card)?.value.trim(); + if (!preview) return; + preview.classList.remove('unavailable'); + if (!model) { preview.textContent = 'Enter a model name to see pricing.'; return; } + preview.textContent = provider === 'openrouter' ? 'Checking OpenRouter pricing…' : 'Checking pricing…'; + const price = provider === 'openrouter' ? await openRouterPriceForModel(model) : priceForModel(model); + if (!card.isConnected || $('.model-provider', card)?.value !== provider || $('.model-name-input', card)?.value.trim() !== model) return; + if (!price) { + preview.textContent = 'Pricing unavailable for this model.'; + preview.classList.add('unavailable'); + return; + } + preview.classList.remove('unavailable'); + preview.innerHTML = `<span>Input <strong>${formatModelRate(price.input)}</strong></span><span>Output <strong>${formatModelRate(price.output)}</strong></span><small>per 1M tokens</small>`; + } + + function updateAllModelPrices() { + els.modelConfigs.querySelectorAll('[data-model-card]').forEach(card => updateModelPrice(card)); + } + function isOpenAIEndpoint(apiUrl) { try { return new URL(apiUrl).hostname === 'api.openai.com'; } catch { return false; }

@@ -1059,7 +1105,8 @@ const response = await fetch(`https://openrouter.ai/api/v1/model/${path}`, {

headers: { 'Accept': 'application/json' } }); if (!response.ok) return validCached ? cached : null; - const pricing = (await response.json()).data?.pricing; + const rawPricing = (await response.json()).data?.pricing; + const pricing = Array.isArray(rawPricing) ? rawPricing[0] : rawPricing; const prompt = pricing?.prompt == null ? NaN : Number(pricing.prompt); const completion = pricing?.completion == null ? NaN : Number(pricing.completion); const cacheRead = pricing?.input_cache_read == null ? NaN : Number(pricing.input_cache_read);

@@ -1128,6 +1175,15 @@ if (value > 0 && value < .01) return '<$0.01';

return value.toLocaleString(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: value < 1 ? 4 : 2 }); } + function formatDetailedCost(value) { + if (!Number.isFinite(value)) return 'Unpriced'; + if (value === 0) return '$0.00'; + const decimalPlaces = value > 0 && value < .01 ? 10 : (value < 1 ? 4 : 2); + return value.toLocaleString(undefined, { + style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: decimalPlaces + }); + } + function updateUsageButton() { const total = sum(currentMonthRecords(), 'totalTokens'); els.monthTokens.textContent = formatTokens(total, true);

@@ -1173,9 +1229,12 @@ </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="recent-request-copy"> + <span class="request-date">${new Date(record.timestamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span> + <span class="model-name" title="${escapeHTML(record.model || 'unknown')}">${escapeHTML(record.model || 'unknown')}</span> + </span> <span class="row-tokens">${formatTokens(record.totalTokens)} tokens</span> - <span class="row-cost">${formatCost(record.costUSD)}</span> + <span class="row-cost">${formatDetailedCost(record.costUSD)}</span> </div>`).join('') : '<p class="usage-empty">Completed requests will appear here.</p>'; els.usageNote.textContent = unpriced

@@ -1183,12 +1242,104 @@ ? `${unpriced} request${unpriced === 1 ? '' : 's'} could not be estimated because its model price could not be resolved.`

: 'Estimates use the rate recorded when each request completed: the local OpenAI table or OpenRouter\'s model-pricing API.'; } + function monthStartUnix() { + const now = new Date(); + return Math.floor(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) / 1000); + } + + async function responseError(response, fallback) { + try { + const data = await response.json(); + return data.error?.message || data.message || fallback; + } catch { return fallback; } + } + + async function fetchOpenAIActualSpend(adminKey) { + let page = ''; + let total = 0; + let pages = 0; + do { + const params = new URLSearchParams({ + start_time: String(monthStartUnix()), + end_time: String(Math.floor(Date.now() / 1000) + 1), + bucket_width: '1d', limit: '31' + }); + if (page) params.set('page', page); + const response = await fetch(`https://api.openai.com/v1/organization/costs?${params}`, { + headers: { 'Authorization': `Bearer ${adminKey}`, 'Content-Type': 'application/json' } + }); + if (!response.ok) throw new Error(await responseError(response, `OpenAI billing request failed (${response.status})`)); + const data = await response.json(); + for (const bucket of data.data || []) { + for (const result of bucket.results || []) { + if (result.amount?.currency && result.amount.currency !== 'usd') continue; + total += Number(result.amount?.value) || 0; + } + } + page = data.has_more ? String(data.next_page || '') : ''; + pages += 1; + } while (page && pages < 10); + return total; + } + + async function fetchOpenRouterActualSpend(apiKey) { + const response = await fetch('https://openrouter.ai/api/v1/key', { + headers: { 'Authorization': `Bearer ${apiKey}`, 'Accept': 'application/json' } + }); + if (!response.ok) throw new Error(await responseError(response, `OpenRouter billing request failed (${response.status})`)); + const value = Number((await response.json()).data?.usage_monthly); + if (!Number.isFinite(value)) throw new Error('OpenRouter did not return month-to-date usage for this key.'); + return value; + } + + function setActualSpendState(provider, state, detail = '') { + const cost = provider === 'openai' ? els.actualOpenAICost : els.actualOpenRouterCost; + const status = provider === 'openai' ? els.actualOpenAIStatus : els.actualOpenRouterStatus; + status.classList.toggle('error', state === 'error'); + if (state === 'loading') { cost.textContent = '…'; status.textContent = 'Contacting provider…'; } + else if (state === 'missing') { cost.textContent = '—'; status.textContent = detail; } + else if (state === 'error') { cost.textContent = '—'; status.textContent = detail; } + else { cost.textContent = formatCost(detail); status.textContent = 'Retrieved just now'; } + } + + async function refreshActualSpend() { + const requestId = ++actualSpendRequest; + const refreshButton = $('#refresh-actual-spend'); + const openRouterKey = settings.apiKeys.openrouter; + let openAIAdminKey = ''; + try { openAIAdminKey = sessionStorage.getItem(OPENAI_ADMIN_SESSION_KEY) || ''; } catch {} + const tasks = []; + const totals = {}; + + if (openAIAdminKey) { + setActualSpendState('openai', 'loading'); + tasks.push(fetchOpenAIActualSpend(openAIAdminKey) + .then(value => { totals.openai = value; if (requestId === actualSpendRequest) setActualSpendState('openai', 'ready', value); }) + .catch(error => { if (requestId === actualSpendRequest) setActualSpendState('openai', 'error', error.message); })); + } else setActualSpendState('openai', 'missing', 'Add an Admin API key in Settings'); + + if (openRouterKey) { + setActualSpendState('openrouter', 'loading'); + tasks.push(fetchOpenRouterActualSpend(openRouterKey) + .then(value => { totals.openrouter = value; if (requestId === actualSpendRequest) setActualSpendState('openrouter', 'ready', value); }) + .catch(error => { if (requestId === actualSpendRequest) setActualSpendState('openrouter', 'error', error.message); })); + } else setActualSpendState('openrouter', 'missing', 'Add an OpenRouter API key in Settings'); + + refreshButton.disabled = true; + els.actualCombinedCost.textContent = '…'; + els.actualCombinedStatus.textContent = 'Retrieving available totals…'; + await Promise.all(tasks); + if (requestId !== actualSpendRequest) return; + refreshButton.disabled = false; + const available = Object.values(totals); + els.actualCombinedCost.textContent = available.length ? formatCost(available.reduce((sum, value) => sum + value, 0)) : '—'; + els.actualCombinedStatus.textContent = available.length === 2 ? 'Both providers' : available.length ? 'One provider available' : 'No provider totals available'; + } + function openUsage() { renderUsage(); - const openRouter = settings.provider === 'openrouter'; - els.usageDashboard.href = openRouter ? 'https://openrouter.ai/activity' : 'https://platform.openai.com/usage'; - els.usageDashboard.textContent = `${openRouter ? 'OpenRouter' : 'OpenAI'} dashboard ↗`; els.usageDialog.showModal(); + refreshActualSpend(); } function createNewMap() {

@@ -1482,6 +1633,7 @@ window.addEventListener('scroll', hideSelectionMenu, { passive: true });

$('#open-settings').addEventListener('click', openSettings); els.activeConnection.addEventListener('click', openSettings); $('#open-usage').addEventListener('click', openUsage); + $('#refresh-actual-spend').addEventListener('click', refreshActualSpend); $('#theme-toggle').addEventListener('click', toggleTheme); $('#toggle-history').addEventListener('click', toggleHistory); els.conversationSearch.addEventListener('input', () => {

@@ -1520,7 +1672,8 @@ const input = document.getElementById(event.currentTarget.dataset.keyInput);

const show = input.type === 'password'; input.type = show ? 'text' : 'password'; event.currentTarget.textContent = show ? 'Hide' : 'Show'; - event.currentTarget.setAttribute('aria-label', `${show ? 'Hide' : 'Show'} ${input.id === 'openrouter-api-key' ? 'OpenRouter' : 'OpenAI'} API key`); + const keyName = input.id === 'openrouter-api-key' ? 'OpenRouter' : input.id === 'openai-admin-key' ? 'OpenAI Admin' : 'OpenAI'; + event.currentTarget.setAttribute('aria-label', `${show ? 'Hide' : 'Show'} ${keyName} API key`); })); $('#add-model').addEventListener('click', () => { captureSettingsForm();

@@ -1541,6 +1694,14 @@ if (connection.model === PROVIDERS[previousProvider].model) connection.model = PROVIDERS[provider].model;

if (connection.apiUrl === PROVIDERS[previousProvider].apiUrl) connection.apiUrl = PROVIDERS[provider].apiUrl; connection.provider = provider; renderModelCards(); + }); + els.modelConfigs.addEventListener('input', event => { + if (!event.target.matches('.model-name-input')) return; + const card = event.target.closest('[data-model-card]'); + const preview = $('.model-price', card); + if (preview) preview.textContent = 'Checking pricing…'; + clearTimeout(modelPriceTimer); + modelPriceTimer = setTimeout(() => updateModelPrice(card), 350); }); els.modelConfigs.addEventListener('click', event => { const button = event.target.closest('.remove-model');
M index.html → index.html

@@ -201,6 +201,14 @@ <input id="openrouter-api-key" type="password" placeholder="sk-or-v1-…" autocomplete="off" spellcheck="false">

<button class="toggle-key" type="button" data-key-input="openrouter-api-key" aria-label="Show OpenRouter API key">Show</button> </div> </label> + <label class="admin-key-setting"> + OpenAI Admin API key <span class="optional-label">billing only</span> + <div class="secret-field"> + <input id="openai-admin-key" type="password" placeholder="sk-admin-…" autocomplete="off" spellcheck="false"> + <button class="toggle-key" type="button" data-key-input="openai-admin-key" aria-label="Show OpenAI Admin API key">Show</button> + </div> + <small class="field-help">Required for actual organization spend. Kept only for this browser tab and never saved to local storage.</small> + </label> </div> </section> <div class="model-settings-heading">

@@ -217,7 +225,7 @@ System instructions

<textarea id="system-prompt" rows="3">You are a patient technical tutor. Use clear Markdown and LaTeX where useful. Use mathematical language, assume graduate level knowledge. Build on the conversation context and explain assumptions.</textarea> </label> </details> - <div class="security-callout"><strong>Local-use note</strong> — any script running on this origin can access local storage. Use a restricted API key and never deploy this frontend publicly with a saved key.</div> + <div class="security-callout"><strong>Local-use note</strong> — any script running on this origin can access browser storage. Use restricted inference keys, treat an OpenAI Admin key as highly privileged, and never deploy this frontend publicly with saved keys.</div> <div class="dialog-actions"> <button id="clear-history" class="button button-ghost danger" type="button">Clear chat history</button> <button class="button button-dark" value="save" type="submit">Save settings</button>

@@ -229,12 +237,44 @@ <dialog id="usage-dialog" class="settings-dialog usage-dialog">

<form method="dialog"> <div class="dialog-heading"> <div> - <span class="eyebrow">Local estimate</span> + <span class="eyebrow">Provider billing</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> + <p class="settings-copy"><span id="usage-month"></span> · Month to date</p> + + <section class="actual-spend" aria-labelledby="actual-spend-title"> + <div class="usage-section-heading"> + <div> + <h3 id="actual-spend-title">Actual provider spend</h3> + <p>Retrieved directly from each provider.</p> + </div> + <button id="refresh-actual-spend" class="button button-ghost" type="button">Refresh</button> + </div> + <div class="actual-spend-grid" aria-live="polite"> + <div class="actual-spend-card"> + <span>OpenAI organization</span> + <strong id="actual-openai-cost">—</strong> + <small id="actual-openai-status">Admin key required</small> + </div> + <div class="actual-spend-card"> + <span>OpenRouter API key</span> + <strong id="actual-openrouter-cost">—</strong> + <small id="actual-openrouter-status">API key required</small> + </div> + <div class="actual-spend-card total"> + <span>Combined available</span> + <strong id="actual-combined-cost">—</strong> + <small id="actual-combined-status">Waiting to refresh</small> + </div> + </div> + <p class="actual-spend-note">OpenAI reports organization-wide costs and requires an organization-owner Admin API key. OpenRouter reports usage for the configured API key.</p> + </section> + + <div class="usage-section-heading local-usage-heading"> + <div><h3>Local browser estimate</h3><p>Only completed requests recorded by Study Lab in this browser.</p></div> + </div> <div class="usage-summary"> <div class="usage-stat primary">

@@ -271,10 +311,11 @@ <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> + <p class="usage-disclaimer">Provider totals can lag behind recent requests and may exclude taxes, credit-purchase fees, or other invoice adjustments. The local estimate excludes requests from other browsers and applications, incomplete requests without returned usage, and usage recorded before this tracker was added.</p> <div class="dialog-actions"> <button id="reset-usage" class="button button-ghost danger" type="button">Reset local history</button> - <a id="usage-dashboard" class="button button-ghost usage-dashboard" href="https://platform.openai.com/usage" target="_blank" rel="noopener noreferrer">OpenAI dashboard ↗</a> + <a class="button button-ghost usage-dashboard" href="https://platform.openai.com/usage" target="_blank" rel="noopener noreferrer">OpenAI ↗</a> + <a class="button button-ghost usage-dashboard" href="https://openrouter.ai/activity" target="_blank" rel="noopener noreferrer">OpenRouter ↗</a> <button class="button button-dark" value="close" type="submit">Done</button> </div> </form>
M service-worker.js → service-worker.js

@@ -1,4 +1,4 @@

-const CACHE_NAME = 'study-lab-shell-v1'; +const CACHE_NAME = 'study-lab-shell-v4'; const APP_SHELL = [ './', './index.html',
M styles.css → styles.css

@@ -270,6 +270,8 @@ .api-key-settings { margin: 18px 0 22px; padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--paper); }

.api-key-settings h3, .model-settings-heading h3 { margin: 0; font-size: .88rem; } .api-key-grid, .model-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } .api-key-grid label { margin: 12px 0 0; } +.api-key-grid .admin-key-setting { grid-column: 1 / -1; } +.optional-label { margin-left: 4px; color: var(--muted); font-size: .6rem; font-weight: 500; text-transform: uppercase; letter-spacing: .06em; } .model-settings-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 10px; } .model-settings-heading p { margin: 3px 0 0; color: var(--muted); font-size: .7rem; } .add-model { flex: 0 0 auto; color: var(--sage-dark); }

@@ -284,6 +286,11 @@ .settings-dialog .default-model { display: flex; align-items: center; gap: 7px; margin: 0; color: var(--muted); cursor: pointer; font-size: .68rem; }

.settings-dialog .default-model input { width: auto; margin: 0; padding: 0; accent-color: var(--sage-dark); box-shadow: none; } .model-config.active .default-model { color: var(--sage-dark); } .model-config label { margin: 12px 0; } +.model-price { min-height: 30px; display: flex; align-items: center; gap: 13px; margin: -2px 0 12px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--card); color: var(--muted); font-size: .66rem; } +.model-price span { white-space: nowrap; } +.model-price strong { margin-left: 3px; color: var(--ink); font-variant-numeric: tabular-nums; } +.model-price small { margin-left: auto; color: var(--muted); font-size: .58rem; white-space: nowrap; } +.model-price.unavailable { border-style: dashed; background: transparent; } .provider-advanced { margin-top: 12px; } .provider-advanced label { margin-bottom: 0; } .remove-model { display: block; margin: 11px 0 0 auto; padding: 0; border: 0; background: transparent; color: #9d443e; cursor: pointer; font-size: .66rem; }

@@ -300,6 +307,20 @@ .dialog-actions { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-top: 18px; }

#settings-dialog .dialog-actions .button-dark { margin-left: auto; } .danger { color: #9d443e; } .usage-dialog { width: min(620px, calc(100vw - 28px)); } +.actual-spend { margin: 18px 0 23px; padding-bottom: 21px; border-bottom: 1px solid var(--line); } +.usage-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.usage-section-heading h3 { margin: 0; font-size: .74rem; letter-spacing: .09em; text-transform: uppercase; } +.usage-section-heading p { margin: 4px 0 0; color: var(--muted); font-size: .68rem; } +.usage-section-heading .button { flex: 0 0 auto; padding: 7px 12px; font-size: .72rem; } +.actual-spend-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 9px; margin-top: 11px; } +.actual-spend-card { min-width: 0; padding: 13px; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); } +.actual-spend-card.total { border-color: #cad8d0; background: #e8efeb; } +.actual-spend-card > span { display: block; overflow: hidden; margin-bottom: 7px; color: var(--muted); font-size: .61rem; letter-spacing: .07em; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.actual-spend-card strong { display: block; overflow: hidden; font: 400 1.3rem/1.1 Georgia, serif; font-variant-numeric: tabular-nums; text-overflow: ellipsis; } +.actual-spend-card small { display: block; min-height: 2.6em; margin-top: 6px; color: var(--muted); font-size: .59rem; line-height: 1.3; } +.actual-spend-card small.error { color: #9d443e; } +.actual-spend-note { margin: 9px 2px 0; color: var(--muted); font-size: .62rem; line-height: 1.45; } +.local-usage-heading { margin-bottom: -7px; } .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; }

@@ -315,6 +336,9 @@ .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; } +.recent-request-copy { min-width: 0; display: grid; gap: 3px; } +.recent-request-copy .request-date { color: var(--ink); font-size: .7rem; } +.recent-request-copy .model-name { color: var(--muted); font-size: .63rem; } .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; }

@@ -365,6 +389,9 @@ .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; } .shortcut-list { grid-template-columns: 118px 1fr; gap: 9px 11px; } + .actual-spend-grid { grid-template-columns: 1fr; } + .model-price { flex-wrap: wrap; gap: 5px 13px; } + .model-price small { width: 100%; margin-left: 0; } .install-button { width: 34px; padding: 0; font-size: 0; } .install-button::before { content: "↓"; font-size: 1rem; } }