syndes 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,13 @@
4
4
  * Four questions, in the order two people sharing a plan actually ask them:
5
5
  * 1. Whose share is it? the split, and which way it moved
6
6
  * 2. What is the pool doing? totals, and whether the numbers are live
7
- * 3. Were we in each other's way? the contention grid — the one thing
7
+ * 3. Were we in each other's way? the contention chart — the one thing
8
8
  * neither person can see from their own machine
9
- * 4. How did each day break down? the daily split, and the people table
9
+ * 4. How did it break down? the split, and who is in the pool
10
+ *
11
+ * The page defaults to one day, like the rest of the dashboard, because that is
12
+ * the question a shared plan actually raises: not "who used more this month" but
13
+ * "are we throttling each other right now". A week's average cannot answer it.
10
14
  *
11
15
  * The page is live while it is open: it holds one event stream, and the server
12
16
  * polls the pool only while that stream exists. When the view is replaced the
@@ -16,21 +20,22 @@
16
20
 
17
21
  import { api } from '../api.js';
18
22
  import {
19
- h, card, figure, trend, chip, dot, meter, legend, empty, table, modal,
20
- compact, usd, duration, percent, when, DASH,
23
+ h, card, figure, trend, chip, dot, meter, legend, empty, modal,
24
+ compact, duration, percent, perHour, when, dayLabel, DASH,
21
25
  } from '../ui.js';
22
- import { dotMatrix, laneSplit, splitBar } from '../charts.js';
26
+ import { dotMatrix, hourMatrix, laneSplit, splitBar } from '../charts.js';
23
27
 
24
28
  export async function render({ range }) {
25
29
  const data = await api.team(range);
26
30
 
27
- const outlet = h('div.content--fit', {
28
- style: 'display:grid;grid-template-rows:auto minmax(0,1fr);gap:var(--s4);min-height:0',
29
- });
31
+ // Not `content--fit`. That pins the page to the viewport and divides what is
32
+ // left between two rows, which is right for a dense screen and wrong here:
33
+ // with one person in the pool the cards have little in them, and forcing them
34
+ // to fill a tall window leaves most of it empty. Letting the page size to its
35
+ // content and scroll adapts to any window instead of assuming one.
36
+ const outlet = h('div.content.team', {});
30
37
 
31
38
  if (!data.enabled) {
32
- outlet.style.display = 'grid';
33
- outlet.style.gridTemplateRows = 'minmax(0,1fr)';
34
39
  outlet.appendChild(setup(range, outlet));
35
40
  return outlet;
36
41
  }
@@ -79,12 +84,12 @@ function connect(outlet, range) {
79
84
  function paint(outlet, data, range, { live = false } = {}) {
80
85
  const me = data.peers.find((peer) => peer.isMe) ?? null;
81
86
 
87
+ // One auto-fitting grid rather than two fixed rows. Cards claim a column when
88
+ // there is room for one and wrap when there is not, so the same markup works
89
+ // on a laptop and on a wide monitor without a breakpoint deciding for it.
82
90
  outlet.replaceChildren(
83
- h('div.grid.g-3', {}, [shareCard(data, me), poolCard(data, live), contentionCard(data)]),
84
- h('div.grid.fill', { style: 'grid-template-columns:minmax(0,1.1fr) minmax(0,1fr)' }, [
85
- splitCard(data),
86
- peopleCard(data, outlet, range),
87
- ]),
91
+ h('div.autogrid', {}, [shareCard(data, me), poolCard(data, live), contentionCard(data)]),
92
+ h('div.autogrid.autogrid--wide', {}, [splitCard(data), peopleCard(data, outlet, range)]),
88
93
  );
89
94
  }
90
95
 
@@ -106,9 +111,13 @@ function shareCard(data, me) {
106
111
  }) : null,
107
112
  }),
108
113
  h('div', { style: 'display:grid;gap:4px' }, [
109
- h('span.card__note', { text: `Even split would be ${percent(fair)}` }),
114
+ // Meaningless while you are the only one here — "an even split would be
115
+ // 100%" is true and says nothing.
116
+ data.peers.length > 1 ? h('span.card__note', { text: `Even split would be ${percent(fair)}` }) : null,
110
117
  h('span.card__note', {
111
- text: `${compact(me?.totals.tokens ?? 0)} of ${compact(data.totals.tokens)} tokens`,
118
+ text: data.peers.length > 1
119
+ ? `${compact(me?.totals.tokens ?? 0)} of ${compact(data.totals.tokens)} tokens`
120
+ : `${compact(data.totals.tokens)} tokens · nobody else has joined yet`,
112
121
  }),
113
122
  ]),
114
123
  ]),
@@ -124,7 +133,18 @@ function shareCard(data, me) {
124
133
  ]);
125
134
  }
126
135
 
127
- // 2. What is the pool doing
136
+ /**
137
+ * 2. What is the pool doing
138
+ *
139
+ * The fourth figure used to be a notional dollar amount with a paragraph under
140
+ * it apologising for being notional. Both are gone. On a shared plan nobody is
141
+ * billed per token, so the number was an invention — and a figure that needs a
142
+ * disclaimer to be read correctly has already failed.
143
+ *
144
+ * What replaces it is the rate the SHARED window is being spent at, which is the
145
+ * actual scarcity two people on one account are splitting. See
146
+ * analytics/metrics/window.mjs.
147
+ */
128
148
  function poolCard(data, live) {
129
149
  const sync = data.sync ?? {};
130
150
  const failed = sync.ok === false && sync.error;
@@ -132,12 +152,12 @@ function poolCard(data, live) {
132
152
  return card('The pool', [
133
153
  h('div.grid', { style: 'grid-template-columns:1fr 1fr;gap:var(--s4)' }, [
134
154
  figure('people', String(data.totals.people), { small: true }),
135
- figure('tokens', compact(data.totals.tokens), {
155
+ figure('tokens read', compact(data.totals.tokens), {
136
156
  small: true,
137
157
  trend: trend(data.totals.tokens, data.totals.tokens - (data.totals.deltaTokens ?? 0), { format: compact }),
138
158
  }),
139
159
  figure('active time', duration(data.totals.activeMs), { small: true }),
140
- figure('notional cost', usd(data.totals.usd), { small: true }),
160
+ figure('new tokens / active hour', perHour(data.rates?.freshPerHour), { small: true }),
141
161
  ]),
142
162
 
143
163
  h('div', { style: 'display:grid;gap:6px;margin-top:auto' }, [
@@ -152,44 +172,69 @@ function poolCard(data, live) {
152
172
  h('span.card__note', { text: `Last exchange ${sync.at ? when(sync.at) : DASH}` }),
153
173
  failed ? h('span.card__note', { style: 'color:var(--orange)', text: String(sync.error).slice(0, 120) }) : null,
154
174
  h('span.card__note', {
155
- // Said plainly on the page rather than buried in a doc: on a shared plan
156
- // the dollars are what these tokens WOULD have cost at API rates, and
157
- // presenting an estimate as a bill would be the wrong kind of confident.
158
- text: 'Cost is notional — a shared plan bills a subscription, not tokens.',
175
+ // Said plainly on the page rather than left to be inferred: the scarce
176
+ // thing here is the window, so that is what the rate measures. Cache
177
+ // reads are excluded because they are context nobody had to re-send.
178
+ text: 'No prices: a shared plan bills a subscription. The rate counts new tokens only.',
159
179
  }),
160
180
  ]),
161
181
  ]);
162
182
  }
163
183
 
164
- // 3. Were we in each other's way
184
+ /**
185
+ * 3. Were we in each other's way
186
+ *
187
+ * Two pictures of one question, because a day and a fortnight are not the same
188
+ * shape. Over a range: days across, three-hour bands down, one dot per band —
189
+ * good for spotting a pattern. On a single day: hours across, a lane per person
190
+ * down — good for seeing that the two of you were both on it at four, and which
191
+ * two. The server decides which (analytics/team.mjs sets `grid.hourly`), because
192
+ * it is the side that resolved the range.
193
+ */
165
194
  function contentionCard(data) {
166
195
  const grid = data.grid;
167
196
  const overlapping = grid.overlapBands > 0;
197
+ const unit = grid.hourly ? 'hour' : 'band';
168
198
 
169
- return card('When each of you worked', [
199
+ return card(grid.hourly ? 'Who worked when' : 'When each of you worked', [
170
200
  h('div', { style: 'display:flex;align-items:baseline;gap:var(--s3)' }, [
171
- figure('of working hours overlapped', percent(grid.overlapRate), { small: true }),
172
- overlapping ? chip(`${grid.overlapBands} bands`, 'orange') : chip('no overlap', 'lime'),
201
+ figure(`of working ${unit}s overlapped`, percent(grid.overlapRate), { small: true }),
202
+ overlapping
203
+ ? chip(`${grid.overlapBands} ${unit}${grid.overlapBands === 1 ? '' : 's'}`, 'orange')
204
+ : chip('no overlap', 'lime'),
173
205
  ]),
174
- dotMatrix(grid),
206
+ h('div.scroll-x', {}, [grid.hourly ? hourMatrix(grid) : dotMatrix(grid)]),
175
207
  legend([
176
- { tone: 'lime', label: 'one person had the window' },
177
- { tone: 'orange', label: 'two or more at once' },
208
+ { tone: 'lime', label: 'had the window alone' },
209
+ { tone: 'orange', label: 'shared it with someone' },
178
210
  ]),
179
211
  h('span.card__note', {
180
212
  text: overlapping
181
- ? 'Orange bands are where you were both spending the same rate limit.'
213
+ ? `Orange ${unit}s are where you were both spending the same rate limit.`
182
214
  : 'Nobody has collided in this period.',
183
215
  }),
184
- ], { note: `${grid.bandHours}-hour bands` });
216
+ ], { note: grid.hourly ? 'one day, by the hour' : `${grid.bandHours}-hour bands` });
185
217
  }
186
218
 
187
- // 4a. How did each day break down
219
+ /**
220
+ * 4a. How each day broke down.
221
+ *
222
+ * One row per day with a capsule per person. A single selected day is one row,
223
+ * which is small but not degraded — it still answers who took the day, and the
224
+ * contention chart beside it is the one that spreads a single day out.
225
+ */
188
226
  function splitCard(data) {
227
+ const oneDay = data.range.days.length === 1;
228
+ const title = oneDay ? 'The split' : 'Daily split';
189
229
  const rows = data.lanes.filter((row) => row.total > 0);
190
- if (!rows.length) return card('Daily split', [empty('No shared activity in this range yet.')]);
191
230
 
192
- return card('Daily split', [
231
+ if (!rows.length) {
232
+ return card(title, [empty(oneDay
233
+ ? `Nobody in the pool recorded anything on ${dayLabel(data.range.days[0])}.`
234
+ : 'No shared activity in this range yet.')]);
235
+ }
236
+
237
+ return card(title, [
193
238
  h('div.card__scroll', {}, [
194
239
  laneSplit(rows, { format: compact }),
195
240
  ]),
@@ -197,60 +242,78 @@ function splitCard(data) {
197
242
  { tone: 'lime', label: 'you' },
198
243
  { tone: 'white', label: 'everyone else' },
199
244
  ], compact(data.totals.tokens)),
200
- ], { note: 'newest first' });
245
+ ], { note: oneDay ? dayLabel(data.range.days[0]) : 'newest first' });
201
246
  }
202
247
 
203
248
  // 4b. The people
249
+ /**
250
+ * One block per person, not a table.
251
+ *
252
+ * This was a seven-column table, and it did not survive contact with a real
253
+ * card: the name truncated to "Gurupra…", "3h 21m" wrapped and clipped, and the
254
+ * remove control was pushed off the right edge behind a scrollbar. A table needs
255
+ * a width it can rely on, and a card in a reflowing grid cannot promise one.
256
+ *
257
+ * Rows fix that by stacking within each person rather than across them — the
258
+ * name gets the width, the numbers wrap as a group, and nothing is ever cut off.
259
+ */
204
260
  function peopleCard(data, outlet, range) {
205
- const rows = data.peers.map((peer) => [
206
- h('div', { style: 'display:flex;align-items:center;gap:9px;min-width:0' }, [
207
- h('span.avatar', { text: peer.initials, title: peer.host ?? peer.deviceId }),
208
- h('div', { style: 'display:grid;min-width:0' }, [
209
- h('span', {
210
- style: 'font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap',
211
- text: peer.name,
212
- }),
213
- h('span.card__note', { text: peer.isMe ? 'this machine' : (peer.host ?? DASH) }),
214
- ]),
215
- peer.stale ? chip('stale', 'orange') : null,
261
+ const actions = h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap' }, [
262
+ h('button.btn.btn--lime.btn--sm', { text: 'Invite', onclick: () => invite() }),
263
+ h('button.btn.btn--ghost.btn--sm', { text: 'Rename', onclick: () => rename(data, outlet, range) }),
264
+ h('button.btn.btn--ghost.btn--sm', { text: 'Sync now', onclick: () => syncNow(outlet, range) }),
265
+ ]);
266
+
267
+ return card('People', [
268
+ h('div.people', {}, data.peers.map((peer) => personRow(peer, outlet, range))),
269
+
270
+ data.peers.length === 1
271
+ ? h('span.card__note', { text: 'Only this machine so far. Invite gives you a line to send someone.' })
272
+ : h('span.card__note', { text: 'A stale shelf has not synced in six hours — a machine that stopped reporting, not a quiet day.' }),
273
+
274
+ h('div', { style: 'margin-top:auto;display:flex;gap:8px;flex-wrap:wrap;padding-top:var(--s2)' }, [
275
+ h('button.btn.btn--ghost.btn--sm', { text: `Sharing: ${data.config.scope}`, onclick: () => changeScope(data, outlet, range) }),
276
+ h('button.btn.btn--ghost.btn--sm', { text: 'Leave pool', onclick: () => leavePool(outlet, range) }),
216
277
  ]),
217
- h('div', { style: 'display:grid;gap:5px;min-width:90px' }, [
218
- h('span.tnum', { style: 'font-size:12px', text: percent(peer.share) }),
278
+ ], { actions });
279
+ }
280
+
281
+ function personRow(peer, outlet, range) {
282
+ return h('div.person', {}, [
283
+ h('span.avatar', { text: peer.initials, title: peer.host ?? peer.deviceId }),
284
+
285
+ h('div', { style: 'min-width:0;display:grid;gap:6px' }, [
286
+ h('div.person__head', {}, [
287
+ h('span.person__name', { text: peer.name, title: peer.name }),
288
+ peer.isMe ? h('span.person__tag', { text: 'this machine' }) : null,
289
+ peer.stale ? chip('stale', 'orange') : null,
290
+ h('span.person__share', { text: percent(peer.share) }),
291
+ ]),
292
+
219
293
  meter(peer.share, peer.isMe ? 'lime' : 'white'),
294
+
295
+ // Wraps as a group rather than being squeezed into columns, so a narrow
296
+ // card loses a line instead of losing a number.
297
+ h('div.person__stats', {}, [
298
+ stat(compact(peer.totals.tokens), 'tokens'),
299
+ stat(String(peer.totals.sessions), peer.totals.sessions === 1 ? 'session' : 'sessions'),
300
+ stat(duration(peer.totals.activeMs), 'active'),
301
+ stat(peer.score === null ? DASH : String(peer.score), 'score'),
302
+ ]),
220
303
  ]),
221
- compact(peer.totals.tokens),
222
- String(peer.totals.sessions),
223
- duration(peer.totals.activeMs),
224
- peer.score === null ? DASH : String(peer.score),
304
+
225
305
  peer.isMe
226
- ? h('span', { style: 'color:var(--ink-3)', text: DASH })
306
+ ? null
227
307
  : h('button.btn.btn--ghost.btn--sm', {
228
308
  text: 'Remove',
229
309
  title: `Remove ${peer.name} from the pool`,
230
310
  onclick: () => remove(peer, outlet, range),
231
311
  }),
232
312
  ]);
313
+ }
233
314
 
234
- const actions = h('div', { style: 'display:flex;gap:8px' }, [
235
- h('button.btn.btn--lime', { text: 'Invite', onclick: () => invite() }),
236
- h('button.btn.btn--ghost', { text: 'Rename', onclick: () => rename(data, outlet, range) }),
237
- h('button.btn.btn--ghost', { text: 'Sync now', onclick: () => syncNow(outlet, range) }),
238
- ]);
239
-
240
- return card('People', [
241
- table(
242
- ['Person', 'Share', 'Tokens', 'Sessions', 'Active', 'Score', ''],
243
- rows,
244
- { align: ['left', 'left', 'right', 'right', 'right', 'right', 'right'] },
245
- ),
246
- h('span.card__note', {
247
- text: 'A stale shelf has not synced in six hours — that is a machine that stopped reporting, not a quiet day.',
248
- }),
249
- h('div', { style: 'margin-top:auto;display:flex;gap:8px;flex-wrap:wrap' }, [
250
- h('button.btn.btn--ghost', { text: `Sharing: ${data.config.scope}`, onclick: () => changeScope(data, outlet, range) }),
251
- h('button.btn.btn--ghost', { text: 'Leave pool', onclick: () => leavePool(outlet, range) }),
252
- ]),
253
- ], { actions });
315
+ function stat(value, label) {
316
+ return h('span.person__stat', {}, [h('b', { text: value }), h('span', { text: ` ${label}` })]);
254
317
  }
255
318
 
256
319
  // ── Actions ─────────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "syndes",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "SynDes — a tamper-evident ledger of everything you do in Claude Code, an efficiency score built from it, and a local dashboard that shows you how you actually work. Splits one shared account between the people on it. macOS, Windows and Linux. Zero dependencies.",
5
5
  "keywords": [
6
6
  "syndes",
@@ -16,7 +16,6 @@
16
16
  "dashboard",
17
17
  "hooks",
18
18
  "tokens",
19
- "cost",
20
19
  "macos",
21
20
  "windows",
22
21
  "linux",
@@ -29,7 +29,7 @@ export const CATALOG = {
29
29
  },
30
30
  'prompt-specificity': {
31
31
  title: 'A lot of prompts are corrections',
32
- why: 'A correction means the previous turn missed. The cost is not the correction — it is the whole turn that was thrown away, at full token price.',
32
+ why: 'A correction means the previous turn missed. What it costs you is not the correction — it is the whole turn that was thrown away, and every token that went into it.',
33
33
  fix: 'Name the file, the function and the expected outcome in the first prompt.',
34
34
  },
35
35
  'delegate-wide-search': {
@@ -22,7 +22,7 @@ export const DEFAULTS = {
22
22
  tools: true, // PreToolUse + PostToolUse — the expensive pair, on by default
23
23
  prompts: true,
24
24
  permissions: true,
25
- transcript: true, // token, cost and cache numbers
25
+ transcript: true, // token and cache numbers
26
26
  git: true,
27
27
  },
28
28
 
@@ -162,7 +162,7 @@ function count(session, event, last) {
162
162
  if (last?.kind === KIND.TOOL_POST && last.data.failed) counts.errors += 1;
163
163
  }
164
164
 
165
- /** Token, cost and cache numbers, read incrementally from each transcript. */
165
+ /** Token and cache numbers, read incrementally from each transcript. */
166
166
  function collectUsage(touched) {
167
167
  const drafts = [];
168
168
 
package/src/report.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import {
9
9
  bold, grey, cyan, green, red, yellow, write, rule, table, bar, sparkline,
10
- compact, usd, duration, percent, width, DOT,
10
+ compact, perHour, times, duration, percent, width, DOT,
11
11
  } from './term.mjs';
12
12
  import { overview } from '../analytics/index.mjs';
13
13
  import { evaluate } from '../practices/engine.mjs';
@@ -15,7 +15,7 @@ import { nameFor } from '../collect/projects.mjs';
15
15
 
16
16
  export async function report(spec = '7d', { practices = true } = {}) {
17
17
  const data = await overview(spec);
18
- const { headline, previousHeadline: before, score, metrics, cost } = data;
18
+ const { headline, previousHeadline: before, score, metrics } = data;
19
19
 
20
20
  write();
21
21
  write(`${bold('syndes')} ${grey(DOT)} ${data.range.label}`);
@@ -42,14 +42,16 @@ export async function report(spec = '7d', { practices = true } = {}) {
42
42
  [grey('prompts'), String(headline.prompts), delta(headline.prompts, before.prompts)],
43
43
  [grey('tool calls'), compact(headline.toolCalls), delta(headline.toolCalls, before.toolCalls)],
44
44
  [grey('active time'), duration(headline.activeMs), grey(`of ${duration(headline.wallMs)} open`)],
45
- [grey('tokens'), compact(headline.tokens), grey(`${percent(headline.cacheHitRate)} from cache`)],
46
- [grey('cost'), usd(headline.usd) + (cost.estimated ? grey('*') : ''), delta(headline.usd, before.usd, usd)],
45
+ [grey('tokens read'), compact(headline.tokens), grey(`${percent(headline.cacheHitRate)} from cache`)],
46
+ // No dollars. A Claude plan bills a month, so the scarce thing is the window
47
+ // and that is what these two measure. analytics/metrics/window.mjs says why.
48
+ [grey('new tokens'), compact(headline.fresh), grey(`${times(headline.cacheLeverage)} context reuse`)],
49
+ [grey('burn rate'), perHour(headline.freshPerHour), delta(headline.freshPerHour, before.freshPerHour, perHour)],
50
+ [grey('per prompt'), compact(headline.freshPerPrompt), grey(`${(headline.callsPerWindow ?? 0).toFixed(1)} calls per 100k`)],
47
51
  [grey('files changed'), String(headline.files), headline.commits ? grey(`${headline.commits} commits`) : ''],
48
52
  [grey('interruptions'), String(headline.blocks), headline.compacts ? grey(`${headline.compacts} compactions`) : ''],
49
53
  ], { align: ['left', 'right', 'left'] }).map((line) => ` ${line}`).join('\n'));
50
54
 
51
- if (cost.estimated) write(grey(` * priced from the ${cost.tableVersion} table by model family`));
52
-
53
55
  // ── Trend ─────────────────────────────────────────────────────────────────
54
56
  if (data.series.length > 1) {
55
57
  write();
package/src/term.mjs CHANGED
@@ -291,9 +291,21 @@ export function compact(value) {
291
291
  return String(Math.round(value));
292
292
  }
293
293
 
294
- export function usd(value) {
294
+ /**
295
+ * A per-hour rate, and a multiplier. Mirrors dashboard/web/ui.js so the terminal
296
+ * report and the dashboard never disagree about what "1.2M/h" means.
297
+ *
298
+ * An em dash for null, never 0: a rate we could not measure must not read as a
299
+ * rate of nothing. See analytics/metrics/window.mjs.
300
+ */
301
+ export function perHour(value) {
302
+ if (value === null || value === undefined) return '—';
303
+ return `${compact(value)}/h`;
304
+ }
305
+
306
+ export function times(value, digits = 0) {
295
307
  if (value === null || value === undefined) return '—';
296
- return value >= 100 ? `$${Math.round(value)}` : `$${value.toFixed(2)}`;
308
+ return `${value.toFixed(digits)}×`;
297
309
  }
298
310
 
299
311
  export function duration(ms) {
@@ -1,83 +0,0 @@
1
- /**
2
- * Cost from token counts and a dated price table.
3
- *
4
- * The table is versioned and the UI states which one produced a number. Prices
5
- * change; a cost history silently recomputed under new prices is worse than no
6
- * cost history, because it looks authoritative and is not.
7
- *
8
- * A model we do not recognise is priced by its family and marked `estimated`.
9
- * A guess labelled as a guess is useful; a guess presented as a fact is not.
10
- */
11
-
12
- export const name = 'cost';
13
-
14
- /** USD per million tokens. Override in syndes.json under `prices`. */
15
- export const TABLE_VERSION = '2026-09';
16
-
17
- const TABLE = [
18
- [/opus/i, { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 }],
19
- [/sonnet/i, { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 }],
20
- [/haiku/i, { input: 0.8, output: 4, cacheWrite: 1, cacheRead: 0.08 }],
21
- ];
22
-
23
- /** This metric folds nothing: it is a pure function of the token summary. */
24
- export function create() { return {}; }
25
- export function add() {}
26
- export function done() { return {}; }
27
- export function merge() { return {}; }
28
-
29
- export function priceFor(model, overrides = {}) {
30
- for (const [key, price] of Object.entries(overrides)) {
31
- if (model.includes(key)) return { price, exact: true };
32
- }
33
- for (const [pattern, price] of TABLE) {
34
- if (pattern.test(model)) return { price, exact: false };
35
- }
36
- return { price: null, exact: false };
37
- }
38
-
39
- /**
40
- * @param {object} tokenSummary from metrics/tokens.mjs merge()
41
- * @returns {{usd, byModel, estimated, unpriced, tableVersion}}
42
- */
43
- export function costOf(tokenSummary, overrides = {}) {
44
- const byModel = {};
45
- let usd = 0;
46
- let estimated = false;
47
- const unpriced = [];
48
-
49
- for (const [model, bucket] of Object.entries(tokenSummary.byModel ?? {})) {
50
- const { price, exact } = priceFor(model, overrides);
51
- if (!price) {
52
- unpriced.push(model);
53
- byModel[model] = { usd: null, tokens: bucket };
54
- continue;
55
- }
56
- if (!exact) estimated = true;
57
-
58
- const amount =
59
- (bucket.input * price.input +
60
- bucket.output * price.output +
61
- bucket.cacheWrite * price.cacheWrite +
62
- bucket.cacheRead * price.cacheRead) / 1_000_000;
63
-
64
- byModel[model] = { usd: amount, tokens: bucket, estimated: !exact };
65
- usd += amount;
66
- }
67
-
68
- return { usd, byModel, estimated, unpriced, tableVersion: TABLE_VERSION };
69
- }
70
-
71
- /**
72
- * What the same tokens would have cost with no cache reads — the number that
73
- * turns "95% cache hit rate" from a statistic into a dollar figure.
74
- */
75
- export function savingsFromCache(tokenSummary, overrides = {}) {
76
- let saved = 0;
77
- for (const [model, bucket] of Object.entries(tokenSummary.byModel ?? {})) {
78
- const { price } = priceFor(model, overrides);
79
- if (!price) continue;
80
- saved += (bucket.cacheRead * (price.input - price.cacheRead)) / 1_000_000;
81
- }
82
- return saved;
83
- }