Oregami
Repositories/oxedyne/daimond

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})();