oxedyne/daimond/www/js/ledger.js
15.5 KiB, 1 run
created by r2519314175:1391, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* ============================================================ |
| 2 | Daimond — per-turn cost ledger (DaimondLedger) |
| 3 | ------------------------------------------------------------ |
| 4 | An append-only record of what each turn cost, kept in |
| 5 | localStorage so spend survives a reload. Every turn the app |
| 6 | completes is handed to `record`, which prices it through |
| 7 | `DaimondPricing` and stores a compact entry. The getters roll the |
| 8 | log up into day, session, weekly and monthly totals for the meters. |
| 9 | |
| 10 | Storage is bounded: entries older than ~90 days are pruned on |
| 11 | write, so the log cannot grow without limit. A corrupt or |
| 12 | absent store degrades to an empty ledger rather than throwing. |
| 13 | |
| 14 | Depends on `window.DaimondPricing` (loaded first). Attaches a |
| 15 | single global, `window.DaimondLedger`. |
| 16 | ============================================================ */ |
| 17 | (function () { |
| 18 | 'use strict'; |
| 19 | |
| 20 | var KEY = 'daimond-ledger'; // localStorage key |
| 21 | var DAY_MS = 24 * 60 * 60 * 1000; // one day in ms |
| 22 | var PRUNE_MS = 90 * DAY_MS; // retain ~90 days |
| 23 | var WEEK_MS = 7 * DAY_MS; // rolling week |
| 24 | var MONTH_MS = 30 * DAY_MS; // rolling month |
| 25 | var SESSION_GAP_MS = 15 * 60 * 1000; // a ≥15 min gap ends a session |
| 26 | |
| 27 | // ── Store I/O ────────────────────────────────────────────── |
| 28 | // Read the whole log. Any parse failure or non-array value |
| 29 | // yields an empty log so a corrupt store never propagates. |
| 30 | function load() { |
| 31 | try { |
| 32 | var raw = localStorage.getItem(KEY); |
| 33 | if (!raw) return []; |
| 34 | var arr = JSON.parse(raw); |
| 35 | return Array.isArray(arr) ? arr : []; |
| 36 | } catch (e) { |
| 37 | return []; |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | // ── One-time repricing of historical guesses ─────────────── |
| 42 | // Entries priced before 2026-07-31 were guessed from a rate table that ran |
| 43 | // about six times high (direct-provider list prices, cached tokens billed |
| 44 | // at the full input rate). The table is fixed, but the old guesses sat in |
| 45 | // the log and kept inflating every total -- the user rightly did not trust |
| 46 | // them. So an entry the provider did NOT bill (`r` absent) is re-priced |
| 47 | // once under the corrected table, keeping the original figure in `u0` so |
| 48 | // nothing is silently rewritten without a trace. A reported entry is money |
| 49 | // that actually moved and is never touched. |
| 50 | var repricedThisLife = false; // one pass per page life is enough |
| 51 | function reprice(entries) { |
| 52 | if (repricedThisLife) return entries; |
| 53 | if (!window.DaimondPricing || typeof window.DaimondPricing.priceFor !== 'function') { |
| 54 | return entries; // pricing not loaded yet -- try again on the next read. |
| 55 | } |
| 56 | repricedThisLife = true; |
| 57 | var changed = false; |
| 58 | for (var i = 0; i < entries.length; i++) { |
| 59 | var e = entries[i]; |
| 60 | if (!e || e.r || e.rp) continue; |
| 61 | var res; |
| 62 | try { res = window.DaimondPricing.priceFor(e.m || '', e.p || 0, e.c || 0, e.ca || 0, e.pv || ''); } |
| 63 | catch (err) { continue; } |
| 64 | if (!res || typeof res.usd !== 'number') continue; |
| 65 | e.u0 = e.u; // the figure as originally guessed. |
| 66 | e.u = res.usd; |
| 67 | e.e = !!res.estimated; |
| 68 | e.rp = 1; // repriced -- never again. |
| 69 | changed = true; |
| 70 | } |
| 71 | if (changed) save(entries); |
| 72 | return entries; |
| 73 | } |
| 74 | |
| 75 | // Persist the log, swallowing quota/availability errors: a |
| 76 | // failed write must never break the turn that triggered it. |
| 77 | function save(entries) { |
| 78 | try { |
| 79 | localStorage.setItem(KEY, JSON.stringify(entries)); |
| 80 | } catch (e) { |
| 81 | /* quota or unavailable — spend stays in-memory this session */ |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // Drop entries older than the retention window, bounding |
| 86 | // storage. `now` is supplied so pruning shares the caller's |
| 87 | // clock with the write that triggered it. |
| 88 | function prune(entries, now) { |
| 89 | var cutoff = now - PRUNE_MS; |
| 90 | return entries.filter(function (e) { return e && typeof e.t === 'number' && e.t >= cutoff; }); |
| 91 | } |
| 92 | |
| 93 | // ── Recording ────────────────────────────────────────────── |
| 94 | |
| 95 | /// Price and append one completed turn. |
| 96 | /// |
| 97 | /// The caller supplies `ts` (epoch-ms) so the ledger never |
| 98 | /// reads the clock on the write path; the getters own the |
| 99 | /// notion of "now". Fields: |
| 100 | /// ts — epoch-ms of the turn. |
| 101 | /// model — model id, for pricing and breakdowns. |
| 102 | /// promptTokens — input tokens. |
| 103 | /// completionTokens — output tokens. |
| 104 | /// cachedTokens — cached-input tokens (subset of prompt). |
| 105 | /// costUsd — what the PROVIDER said the turn cost. |
| 106 | /// provider — provider id, for a per-key breakdown. |
| 107 | /// |
| 108 | /// A reported `costUsd` is stored VERBATIM and the entry is |
| 109 | /// flagged `r`. It is the money that actually moved, so nothing |
| 110 | /// re-derives it: pricing a turn from token counts and a rate |
| 111 | /// table is a guess about a router's negotiated price and a |
| 112 | /// cache discount, and that guess ran about six times high. |
| 113 | /// Absent a reported figure the table prices it as before, now |
| 114 | /// with the real cached count. |
| 115 | /// |
| 116 | /// The stored entry is compact: `{ t, m, p, c, ca, u, pv, r, e }` |
| 117 | /// where `u` is USD. Returns the entry, or null when the input is |
| 118 | /// unusable. |
| 119 | function record(turn) { |
| 120 | if (!turn || typeof turn.ts !== 'number') return null; |
| 121 | var model = turn.model || ''; |
| 122 | var provider = turn.provider || ''; |
| 123 | var p = Math.max(0, turn.promptTokens || 0); |
| 124 | var c = Math.max(0, turn.completionTokens || 0); |
| 125 | var ca = Math.max(0, turn.cachedTokens || 0); |
| 126 | var reported = (typeof turn.costUsd === 'number' && isFinite(turn.costUsd) |
| 127 | && turn.costUsd > 0) ? turn.costUsd : null; |
| 128 | |
| 129 | var usd = 0, estimated = false; |
| 130 | if (reported !== null) { |
| 131 | usd = reported; |
| 132 | } else if (window.DaimondPricing && typeof window.DaimondPricing.priceFor === 'function') { |
| 133 | // Price through DaimondPricing; if it is somehow absent, record |
| 134 | // a zero-cost entry rather than throwing (tokens are kept). |
| 135 | var res = window.DaimondPricing.priceFor(model, p, c, ca, provider); |
| 136 | usd = (res && typeof res.usd === 'number') ? res.usd : 0; |
| 137 | estimated = !!(res && res.estimated); |
| 138 | } |
| 139 | |
| 140 | // `e` marks a cost nobody published a rate for, so a total containing one |
| 141 | // can be shown as approximate rather than stated as fact. `r` marks the |
| 142 | // opposite and stronger case: the provider said what it charged, so the |
| 143 | // figure is not an approximation at all and must not be dressed as one. |
| 144 | var entry = { t: turn.ts, m: model, p: p, c: c, ca: ca, u: usd, e: estimated }; |
| 145 | if (provider) entry.pv = provider; |
| 146 | if (reported !== null) entry.r = 1; |
| 147 | var entries = load(); |
| 148 | entries.push(entry); |
| 149 | entries = prune(entries, turn.ts); |
| 150 | save(entries); |
| 151 | return entry; |
| 152 | } |
| 153 | |
| 154 | // ── Aggregation ──────────────────────────────────────────── |
| 155 | // Tokens counted in a total are prompt + completion (cached |
| 156 | // tokens are a subset of the prompt, so they are not added |
| 157 | // again). |
| 158 | function tokensOf(e) { return (e.p || 0) + (e.c || 0); } |
| 159 | |
| 160 | // Entries at or after `since`, chronologically sorted. |
| 161 | function since(entries, since) { |
| 162 | return entries |
| 163 | .filter(function (e) { return e && typeof e.t === 'number' && e.t >= since; }) |
| 164 | .sort(function (a, b) { return a.t - b.t; }); |
| 165 | } |
| 166 | |
| 167 | // Sum a slice of entries into `{ usd, tokens, estimated, reportedUsd }`. |
| 168 | // |
| 169 | // `reportedUsd` is the part of the total the providers themselves stated. |
| 170 | // A caller can then say which it is holding: equal to `usd` means every |
| 171 | // turn in the window came with a bill, and there is nothing approximate |
| 172 | // about it. Old entries carry no `r`, so they count as priced -- which is |
| 173 | // what they were. |
| 174 | function sum(slice) { |
| 175 | var usd = 0, tokens = 0, estimated = false, reportedUsd = 0; |
| 176 | for (var i = 0; i < slice.length; i++) { |
| 177 | usd += slice[i].u || 0; |
| 178 | tokens += tokensOf(slice[i]); |
| 179 | if (slice[i].e) estimated = true; |
| 180 | if (slice[i].r) reportedUsd += slice[i].u || 0; |
| 181 | } |
| 182 | return { usd: usd, tokens: tokens, estimated: estimated, reportedUsd: reportedUsd }; |
| 183 | } |
| 184 | |
| 185 | // The current session: walk the sorted log back from the most |
| 186 | // recent entry, keeping entries while each is within the |
| 187 | // session gap of its successor. The first larger gap ends the |
| 188 | // session, so the slice is the tail of uninterrupted activity. |
| 189 | // |
| 190 | // The same gap ends the session against NOW: a tail that stopped |
| 191 | // twenty minutes ago is the PREVIOUS session, not this one. Without |
| 192 | // this, last night's spend read as "This session" all morning -- a |
| 193 | // figure that never moved and so could never be trusted. |
| 194 | function sessionSlice(entries, now) { |
| 195 | var sorted = entries |
| 196 | .filter(function (e) { return e && typeof e.t === 'number'; }) |
| 197 | .sort(function (a, b) { return a.t - b.t; }); |
| 198 | if (sorted.length === 0) return []; |
| 199 | if (typeof now === 'number' && now - sorted[sorted.length - 1].t >= SESSION_GAP_MS) { |
| 200 | return []; // the last activity already ended its session. |
| 201 | } |
| 202 | var start = sorted.length - 1; |
| 203 | for (var i = sorted.length - 1; i > 0; i--) { |
| 204 | if (sorted[i].t - sorted[i - 1].t < SESSION_GAP_MS) start = i - 1; |
| 205 | else break; |
| 206 | } |
| 207 | return sorted.slice(start); |
| 208 | } |
| 209 | |
| 210 | /// Rolled-up totals for the meters: `{ day, session, week, month }`, |
| 211 | /// each `{ usd, tokens }`. Day is local midnight to now -- a calendar |
| 212 | /// question, so NOT a rolling 24h, which would start the day at a |
| 213 | /// different time every hour. Session is the tail of activity with |
| 214 | /// no ≥15 min gap; week and month are the rolling last 7 and 30 |
| 215 | /// days. This getter reads the clock (`Date.now`). |
| 216 | function totals() { |
| 217 | var entries = reprice(load()); |
| 218 | var now = Date.now(); |
| 219 | // Midnight today, local, as `series` also takes it: the day window |
| 220 | // is today's calendar day, not the trailing 24 hours. |
| 221 | var d0 = new Date(); |
| 222 | d0.setHours(0, 0, 0, 0); |
| 223 | return { |
| 224 | day: sum(since(entries, d0.getTime())), |
| 225 | session: sum(sessionSlice(entries, now)), |
| 226 | week: sum(since(entries, now - WEEK_MS)), |
| 227 | month: sum(since(entries, now - MONTH_MS)), |
| 228 | }; |
| 229 | } |
| 230 | |
| 231 | // The entries a named period covers: 'session', 'week' or 'month' |
| 232 | // (anything else reads as a month, the widest and safest default). |
| 233 | function periodSlice(entries, period, now) { |
| 234 | if (period === 'session') return sessionSlice(entries, now); |
| 235 | if (period === 'week') return since(entries, now - WEEK_MS); |
| 236 | return since(entries, now - MONTH_MS); |
| 237 | } |
| 238 | |
| 239 | /// Per-model breakdown for a period, for a future UI. `period` |
| 240 | /// is one of 'session', 'week', 'month' (default 'month'). |
| 241 | /// Returns an array of `{ model, usd, tokens, turns }`, sorted |
| 242 | /// by descending cost. This getter reads the clock. |
| 243 | function perModel(period) { |
| 244 | var entries = reprice(load()); |
| 245 | var slice = periodSlice(entries, period, Date.now()); |
| 246 | |
| 247 | var by = {}; // model id → accumulator |
| 248 | for (var i = 0; i < slice.length; i++) { |
| 249 | var e = slice[i]; |
| 250 | var m = e.m || ''; |
| 251 | if (!by[m]) by[m] = { model: m, usd: 0, tokens: 0, turns: 0, reportedUsd: 0 }; |
| 252 | by[m].usd += e.u || 0; |
| 253 | by[m].tokens += tokensOf(e); |
| 254 | by[m].turns += 1; |
| 255 | if (e.r) by[m].reportedUsd += e.u || 0; |
| 256 | } |
| 257 | var out = []; |
| 258 | for (var k in by) out.push(by[k]); |
| 259 | out.sort(function (a, b) { return b.usd - a.usd; }); |
| 260 | return out; |
| 261 | } |
| 262 | |
| 263 | /// Per-provider breakdown from `since` (epoch-ms) to now. |
| 264 | /// |
| 265 | /// This is what a manual credit tally counts down: the user says |
| 266 | /// "I had $12 as of now", and what they have left is that figure |
| 267 | /// minus everything spent on that provider's key SINCE that |
| 268 | /// moment. So the window is an explicit instant, not one of the |
| 269 | /// named periods -- a rolling month cannot answer the question. |
| 270 | /// |
| 271 | /// Entries written before providers were recorded carry no `pv` |
| 272 | /// and are grouped under `''`; a caller asking about a named |
| 273 | /// provider therefore never sees them, which is right, since |
| 274 | /// nothing knows whose key they spent. |
| 275 | /// |
| 276 | /// Returns `[{ provider, usd, tokens, turns, reportedUsd }]`, |
| 277 | /// dearest first. Reads no clock: `since` is the whole window. |
| 278 | function perProvider(sinceMs) { |
| 279 | var from = (typeof sinceMs === 'number' && isFinite(sinceMs)) ? sinceMs : 0; |
| 280 | var slice = since(reprice(load()), from); |
| 281 | var by = {}; // provider id → accumulator |
| 282 | for (var i = 0; i < slice.length; i++) { |
| 283 | var e = slice[i]; |
| 284 | var pv = e.pv || ''; |
| 285 | if (!by[pv]) by[pv] = { provider: pv, usd: 0, tokens: 0, turns: 0, reportedUsd: 0 }; |
| 286 | by[pv].usd += e.u || 0; |
| 287 | by[pv].tokens += tokensOf(e); |
| 288 | by[pv].turns += 1; |
| 289 | if (e.r) by[pv].reportedUsd += e.u || 0; |
| 290 | } |
| 291 | var out = []; |
| 292 | for (var k in by) out.push(by[k]); |
| 293 | out.sort(function (a, b) { return b.usd - a.usd; }); |
| 294 | return out; |
| 295 | } |
| 296 | |
| 297 | /// What the one-time reprice changed inside a period: |
| 298 | /// `{ turns, usd, was }` over the entries it touched -- `usd` as they |
| 299 | /// price now, `was` as they were first guessed. `period` is 'session', |
| 300 | /// 'week' or 'month' (default 'month'). This getter reads the clock. |
| 301 | /// |
| 302 | /// A total that quietly halves is a total nobody trusts, so the panel |
| 303 | /// quotes the figure the period used to read. Only an entry carrying |
| 304 | /// both the `rp` mark and its original `u0` counts: a billed turn was |
| 305 | /// never touched, and a guess made since the table was fixed was |
| 306 | /// always right. A window holding none answers with zeros, so the |
| 307 | /// explanation retires itself as the log ages the old entries out. |
| 308 | function repriced(period) { |
| 309 | var slice = periodSlice(reprice(load()), period, Date.now()); |
| 310 | var out = { turns: 0, usd: 0, was: 0 }; |
| 311 | for (var i = 0; i < slice.length; i++) { |
| 312 | var e = slice[i]; |
| 313 | if (!e || !e.rp || typeof e.u0 !== 'number') continue; |
| 314 | out.turns += 1; |
| 315 | out.usd += e.u || 0; |
| 316 | out.was += e.u0; |
| 317 | } |
| 318 | return out; |
| 319 | } |
| 320 | |
| 321 | // A local calendar day key, 'YYYY-MM-DD', for bucketing a graph. |
| 322 | function dayKey(d) { |
| 323 | var y = d.getFullYear(); |
| 324 | var m = d.getMonth() + 1; |
| 325 | var day = d.getDate(); |
| 326 | return y + '-' + (m < 10 ? '0' + m : m) + '-' + (day < 10 ? '0' + day : day); |
| 327 | } |
| 328 | |
| 329 | /// Daily spend buckets for the last `days` calendar days (default 30), |
| 330 | /// oldest first, for a time graph. Every day in the window is present even |
| 331 | /// when nothing was spent, so the graph has no gaps to mislead the eye. |
| 332 | /// Each bucket is `{ day, ts, usd, tokens, turns }`. Reads the clock. |
| 333 | function series(days) { |
| 334 | var n = (typeof days === 'number' && days > 0) ? Math.floor(days) : 30; |
| 335 | var entries = reprice(load()); |
| 336 | // Midnight today, local, is the newest bucket's day. |
| 337 | var d0 = new Date(); |
| 338 | d0.setHours(0, 0, 0, 0); |
| 339 | var buckets = []; |
| 340 | var index = {}; // dayKey → position in buckets |
| 341 | for (var i = n - 1; i >= 0; i--) { |
| 342 | var d = new Date(d0.getTime() - i * DAY_MS); |
| 343 | var key = dayKey(d); |
| 344 | index[key] = buckets.length; |
| 345 | buckets.push({ day: key, ts: d.getTime(), usd: 0, tokens: 0, turns: 0 }); |
| 346 | } |
| 347 | for (var j = 0; j < entries.length; j++) { |
| 348 | var e = entries[j]; |
| 349 | if (!e || typeof e.t !== 'number') continue; |
| 350 | var pos = index[dayKey(new Date(e.t))]; |
| 351 | if (pos === undefined) continue; // outside the window |
| 352 | buckets[pos].usd += e.u || 0; |
| 353 | buckets[pos].tokens += tokensOf(e); |
| 354 | buckets[pos].turns += 1; |
| 355 | } |
| 356 | return buckets; |
| 357 | } |
| 358 | |
| 359 | /// Erase the entire ledger (e.g. a user "clear spend" action). |
| 360 | function clear() { |
| 361 | try { localStorage.removeItem(KEY); } catch (e) { /* ignore */ } |
| 362 | } |
| 363 | |
| 364 | /// The raw priced turns, `[{ t, u }]` (epoch-ms and USD), for a |
| 365 | /// consumer that needs the samples themselves rather than a |
| 366 | /// rolled-up total — the spend governor learns a baseline from |
| 367 | /// them. A thin projection of the store, so the storage key |
| 368 | /// stays owned here and is never read twice. |
| 369 | function samples() { |
| 370 | return reprice(load()).map(function (e) { return { t: e.t, u: e.u || 0 }; }); |
| 371 | } |
| 372 | |
| 373 | window.DaimondLedger = { |
| 374 | record: record, |
| 375 | totals: totals, |
| 376 | perModel: perModel, |
| 377 | perProvider: perProvider, |
| 378 | repriced: repriced, |
| 379 | series: series, |
| 380 | samples: samples, |
| 381 | clear: clear, |
| 382 | }; |
| 383 | })(); |