@thehammer/danx-session-telemetry 0.1.8 → 0.1.10

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.
package/dist/pricing.d.ts CHANGED
@@ -12,6 +12,25 @@
12
12
  * Sonnet 5 at $3/$15 and Haiku 4.5 at $0.80/$4 where the page says $2/$10 and
13
13
  * $1/$5, and Opus 5 / Fable 5.1 / Opus 5.5 were missing).
14
14
  *
15
+ * DX-3261 — re-checked the WHOLE table row-by-row against the same page
16
+ * again, 2026-09-24, https://platform.claude.com/docs/en/about-claude/pricing
17
+ * (the "Model pricing" table). Found: `claude-opus-4-7` and `claude-opus-4-5`
18
+ * were both missing (page: $5 in / $25 out / $0.50 cache hit, same row shape
19
+ * as Opus 4.8 / Opus 4.6 — added below). Every other standard-speed row
20
+ * matched exactly: Fable 5.1 ($10/$50, 0.025x hit), Fable 5 ($10/$50, hit
21
+ * $1), Opus 5.5 ($4/$20, 0.05x hit), Opus 5 / Opus 4.8 / Opus 4.6 ($5/$25,
22
+ * $0.50 hit), Opus 4 retired ($15/$75, $1.50 hit), Sonnet 5 ($2/$10, $0.20
23
+ * hit), Sonnet 4.6 / Sonnet 4.5 / Sonnet 4 ($3/$15, $0.30 hit), Haiku 4.5
24
+ * ($1/$5, $0.10 hit), and the derived 1.25x/2h cache-write multipliers on
25
+ * every row. Fast-mode rows unchanged: Opus 5.5 ($8/$40), Opus 5 / Opus 4.8
26
+ * ($10/$50) — Opus 4.7 fast mode returns an error per the page (never
27
+ * priced), and Opus 4.6 fast mode is handled separately below (the page
28
+ * states it runs at standard speed, billed at standard rates — not an
29
+ * unpriced fast row). Claude Mythos 5.1/5 (limited-availability, same rates
30
+ * as their Fable counterparts) and the two retired-elsewhere rows (Opus 4.1,
31
+ * Haiku 3.5) are not in Claude Code's own model-tag surface and are left out
32
+ * on the same basis the table already excludes them.
33
+ *
15
34
  * DX-2963 — cache writes are TWO prices, never one: a 5-minute write is
16
35
  * 1.25x base input and a 1-hour write is 2x base input. Claude Code reports
17
36
  * each lifetime separately (`usage.cache_creation.ephemeral_5m_input_tokens` /
@@ -58,6 +77,16 @@ export type PriceResult = {
58
77
  * and records a visible `unpriced_model` event, instead of one new model id
59
78
  * failing every write (the live tick used to fail every 30 seconds on it).
60
79
  * `<synthetic>` is never passed here: callers exclude it before pricing.
80
+ *
81
+ * DX-3261 — both tables are looked up with `Object.hasOwn`, never bracket
82
+ * access + truthiness (`table[model]`): a model id that collides with an
83
+ * inherited `Object.prototype` key (`"toString"`, `"constructor"`, …) would
84
+ * otherwise resolve to that inherited FUNCTION — truthy, so the `!p` guard
85
+ * never fires — and every subsequent `p.input` / `p.output` / … reads
86
+ * `undefined` off it, so the whole cost total becomes `NaN`. `Object.hasOwn`
87
+ * answers "does the row itself have this key" and can never be fooled by
88
+ * the prototype chain, so an unpriced model always returns `unpriced`
89
+ * cleanly, whatever its id looks like.
61
90
  */
62
91
  export declare function priceUsage(model: string, speed: string, tokens: PricedTokens): PriceResult;
63
92
  /** True when `model` has a standard-speed price (used by tests + guards). */
package/dist/pricing.js CHANGED
@@ -12,6 +12,25 @@
12
12
  * Sonnet 5 at $3/$15 and Haiku 4.5 at $0.80/$4 where the page says $2/$10 and
13
13
  * $1/$5, and Opus 5 / Fable 5.1 / Opus 5.5 were missing).
14
14
  *
15
+ * DX-3261 — re-checked the WHOLE table row-by-row against the same page
16
+ * again, 2026-09-24, https://platform.claude.com/docs/en/about-claude/pricing
17
+ * (the "Model pricing" table). Found: `claude-opus-4-7` and `claude-opus-4-5`
18
+ * were both missing (page: $5 in / $25 out / $0.50 cache hit, same row shape
19
+ * as Opus 4.8 / Opus 4.6 — added below). Every other standard-speed row
20
+ * matched exactly: Fable 5.1 ($10/$50, 0.025x hit), Fable 5 ($10/$50, hit
21
+ * $1), Opus 5.5 ($4/$20, 0.05x hit), Opus 5 / Opus 4.8 / Opus 4.6 ($5/$25,
22
+ * $0.50 hit), Opus 4 retired ($15/$75, $1.50 hit), Sonnet 5 ($2/$10, $0.20
23
+ * hit), Sonnet 4.6 / Sonnet 4.5 / Sonnet 4 ($3/$15, $0.30 hit), Haiku 4.5
24
+ * ($1/$5, $0.10 hit), and the derived 1.25x/2h cache-write multipliers on
25
+ * every row. Fast-mode rows unchanged: Opus 5.5 ($8/$40), Opus 5 / Opus 4.8
26
+ * ($10/$50) — Opus 4.7 fast mode returns an error per the page (never
27
+ * priced), and Opus 4.6 fast mode is handled separately below (the page
28
+ * states it runs at standard speed, billed at standard rates — not an
29
+ * unpriced fast row). Claude Mythos 5.1/5 (limited-availability, same rates
30
+ * as their Fable counterparts) and the two retired-elsewhere rows (Opus 4.1,
31
+ * Haiku 3.5) are not in Claude Code's own model-tag surface and are left out
32
+ * on the same basis the table already excludes them.
33
+ *
15
34
  * DX-2963 — cache writes are TWO prices, never one: a 5-minute write is
16
35
  * 1.25x base input and a 1-hour write is 2x base input. Claude Code reports
17
36
  * each lifetime separately (`usage.cache_creation.ephemeral_5m_input_tokens` /
@@ -51,8 +70,10 @@ const PRICING = {
51
70
  "claude-opus-5-5": price(4, 20, 0.2),
52
71
  "claude-opus-5": price(5, 25, 0.5),
53
72
  "claude-opus-4-8": price(5, 25, 0.5),
73
+ "claude-opus-4-7": price(5, 25, 0.5),
54
74
  "claude-opus-4-6": price(5, 25, 0.5),
55
75
  "claude-opus-4-6-20250916": price(5, 25, 0.5),
76
+ "claude-opus-4-5": price(5, 25, 0.5),
56
77
  "claude-opus-4-20250514": price(15, 75, 1.5),
57
78
  "claude-sonnet-5": price(2, 10, 0.2),
58
79
  "claude-sonnet-4-6": price(3, 15, 0.3),
@@ -78,6 +99,22 @@ const FAST_PRICING = {
78
99
  "claude-opus-5": price(10, 50, 1),
79
100
  "claude-opus-4-8": price(10, 50, 1),
80
101
  };
102
+ /**
103
+ * DX-3261 — models whose `speed:"fast"` requests are billed at STANDARD
104
+ * rates, per the page's "Fast mode pricing" section: *"Fast mode is not
105
+ * available on Claude Opus 4.7 (requests with `speed: "fast"` return an
106
+ * error) or Claude Opus 4.6 (requests run at standard speed and are billed
107
+ * at standard rates)."* Opus 4.7 correctly falls through to `unpriced` below
108
+ * (it has no row in either table — matches the page's "returns an error").
109
+ * Opus 4.6 does NOT: DX-2963's own wording sent it down the same unpriced
110
+ * path as Opus 4.7, contradicting the page it pins — a fast-mode Opus 4.6
111
+ * reply silently lost its cost. The page wins: look it up in the STANDARD
112
+ * table instead of `unpriced`, never invent a separate fast row for it.
113
+ */
114
+ const FAST_BILLS_AT_STANDARD_RATE = new Set([
115
+ "claude-opus-4-6",
116
+ "claude-opus-4-6-20250916",
117
+ ]);
81
118
  /**
82
119
  * Price one unit of usage. NEVER throws (DX-2963 replaces DX-1720's throw):
83
120
  * an unknown model — or a fast reply on a model with no fast price — returns
@@ -85,12 +122,23 @@ const FAST_PRICING = {
85
122
  * and records a visible `unpriced_model` event, instead of one new model id
86
123
  * failing every write (the live tick used to fail every 30 seconds on it).
87
124
  * `<synthetic>` is never passed here: callers exclude it before pricing.
125
+ *
126
+ * DX-3261 — both tables are looked up with `Object.hasOwn`, never bracket
127
+ * access + truthiness (`table[model]`): a model id that collides with an
128
+ * inherited `Object.prototype` key (`"toString"`, `"constructor"`, …) would
129
+ * otherwise resolve to that inherited FUNCTION — truthy, so the `!p` guard
130
+ * never fires — and every subsequent `p.input` / `p.output` / … reads
131
+ * `undefined` off it, so the whole cost total becomes `NaN`. `Object.hasOwn`
132
+ * answers "does the row itself have this key" and can never be fooled by
133
+ * the prototype chain, so an unpriced model always returns `unpriced`
134
+ * cleanly, whatever its id looks like.
88
135
  */
89
136
  export function priceUsage(model, speed, tokens) {
90
- const table = speed === FAST_SPEED ? FAST_PRICING : PRICING;
91
- const p = table[model];
92
- if (!p)
137
+ const useFastTable = speed === FAST_SPEED && !FAST_BILLS_AT_STANDARD_RATE.has(model);
138
+ const table = useFastTable ? FAST_PRICING : PRICING;
139
+ if (!Object.hasOwn(table, model))
93
140
  return { kind: "unpriced", model, speed };
141
+ const p = table[model];
94
142
  return {
95
143
  kind: "priced",
96
144
  costUsd: (tokens.input * p.input +
@@ -103,5 +151,7 @@ export function priceUsage(model, speed, tokens) {
103
151
  }
104
152
  /** True when `model` has a standard-speed price (used by tests + guards). */
105
153
  export function isPricedModel(model) {
106
- return model in PRICING;
154
+ // DX-3261 — `in` walks the prototype chain (`"toString" in {}` is `true`);
155
+ // `Object.hasOwn` can't be fooled by it. See `priceUsage`'s own comment.
156
+ return Object.hasOwn(PRICING, model);
107
157
  }
@@ -55,6 +55,18 @@ export interface LedgerLine {
55
55
  toolUseIds: readonly string[];
56
56
  }
57
57
  export declare const MAIN_SOURCE = "main";
58
+ /**
59
+ * DX-3261 — a producer handed this module a line it cannot safely key or
60
+ * date: no valid `byte_offset` on a no-`message.id` line (would collapse
61
+ * every such line in the source onto the same synthetic key, crediting only
62
+ * the largest — see {@link syntheticMessageKey}), or a `timestamp` that
63
+ * doesn't parse (would read as `repliedAt: 0`, invisible to every
64
+ * account-window lookback). Both were silent `0` defaults before this card;
65
+ * thrown here instead, so a broken producer fails immediately and visibly
66
+ * rather than quietly losing or mis-crediting usage.
67
+ */
68
+ export declare class LedgerLineDataError extends Error {
69
+ }
58
70
  /**
59
71
  * DX-2963 — a usage line with no `message.id` (never observed in the 22,087
60
72
  * usage lines of the 20 newest local transcripts, 2026-09-18) is keyed by its
@@ -1,4 +1,16 @@
1
1
  export const MAIN_SOURCE = "main";
2
+ /**
3
+ * DX-3261 — a producer handed this module a line it cannot safely key or
4
+ * date: no valid `byte_offset` on a no-`message.id` line (would collapse
5
+ * every such line in the source onto the same synthetic key, crediting only
6
+ * the largest — see {@link syntheticMessageKey}), or a `timestamp` that
7
+ * doesn't parse (would read as `repliedAt: 0`, invisible to every
8
+ * account-window lookback). Both were silent `0` defaults before this card;
9
+ * thrown here instead, so a broken producer fails immediately and visibly
10
+ * rather than quietly losing or mis-crediting usage.
11
+ */
12
+ export class LedgerLineDataError extends Error {
13
+ }
2
14
  /**
3
15
  * DX-2963 — a usage line with no `message.id` (never observed in the 22,087
4
16
  * usage lines of the 20 newest local transcripts, 2026-09-18) is keyed by its
@@ -12,13 +24,25 @@ export function isSyntheticMessageKey(key) {
12
24
  return key.startsWith("nomsg:");
13
25
  }
14
26
  /**
15
- * A usage field as a non-negative count. A numeric STRING is read as its
16
- * number (drift-safe: a transcript that ever quotes a count is still counted,
17
- * never silently zeroed); anything else absent or non-numeric is 0.
27
+ * A usage field as a non-negative INTEGER count. A numeric STRING is read as
28
+ * its number (drift-safe: a transcript that ever quotes a count is still
29
+ * counted, never silently zeroed); anything else absent or non-numeric is 0.
30
+ *
31
+ * DX-3261 — rounded to the nearest integer, never passed through fractional.
32
+ * A token count is an integer by definition, so a fractional value here is
33
+ * itself the anomaly (a malformed/synthetic producer), not a legitimate
34
+ * reading to preserve verbatim — and the wire contract this feeds
35
+ * (`parseStatsPayload`'s `isNonNegInt`, `usage-ledger-wire.ts`) REQUIRES an
36
+ * integer and fails loud on anything else. Before this fix a fractional
37
+ * value here would sail through the counter, get queued as a changed entry,
38
+ * then 400 on every single stats-write attempt for that dispatch FOREVER —
39
+ * the worker's `flush()` re-queues a failed batch unconditionally, so a
40
+ * value that never changes never stops failing. Rounding at the source
41
+ * closes the gap without loosening the wire's own integer contract.
18
42
  */
19
43
  function num(v) {
20
44
  const n = typeof v === "string" && v.trim() !== "" ? Number(v) : v;
21
- return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : 0;
45
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
22
46
  }
23
47
  /**
24
48
  * Read a raw `message.usage` block into {@link LedgerUsage}. The two
@@ -202,6 +226,13 @@ export function ledgerLineFromRaw(raw, source, byteOffset) {
202
226
  ? message.content
203
227
  : [];
204
228
  const ts = typeof raw.timestamp === "string" ? Date.parse(raw.timestamp) : NaN;
229
+ // DX-3261 — an unparseable/missing timestamp used to silently become
230
+ // `repliedAt: 0` (epoch 0), which falls outside every account-window
231
+ // lookback and made that reply's tokens permanently invisible to the
232
+ // gate — with no event, no log, nothing. Fail loud instead.
233
+ if (!Number.isFinite(ts)) {
234
+ throw new LedgerLineDataError(`ledgerLineFromRaw: unparseable/missing timestamp (raw.timestamp=${JSON.stringify(raw.timestamp)})`);
235
+ }
205
236
  return {
206
237
  messageId: typeof message.id === "string" && message.id.length > 0 ? message.id : undefined,
207
238
  source,
@@ -212,7 +243,7 @@ export function ledgerLineFromRaw(raw, source, byteOffset) {
212
243
  usage: message.usage && typeof message.usage === "object"
213
244
  ? message.usage
214
245
  : undefined,
215
- timestamp: Number.isFinite(ts) ? ts : 0,
246
+ timestamp: ts,
216
247
  toolUseIds: content
217
248
  .filter((b) => b.type === "tool_use" && typeof b.id === "string")
218
249
  .map((b) => b.id),
@@ -260,17 +291,32 @@ export function ledgerLineFromLogEntry(entry) {
260
291
  const content = Array.isArray(d.content)
261
292
  ? d.content
262
293
  : [];
294
+ const hasMessageId = typeof d.messageId === "string" && d.messageId.length > 0;
295
+ const hasUsage = Boolean(d.usage && typeof d.usage === "object");
296
+ const toolUseIds = content
297
+ .filter((b) => b.type === "tool_use" && typeof b.id === "string")
298
+ .map((b) => b.id);
299
+ // DX-3261 — `byteOffset` only matters as the synthetic key for a line with
300
+ // no `message.id` (`syntheticMessageKey`); a missing/invalid one used to
301
+ // silently default to `0`, collapsing EVERY no-id reply from this source
302
+ // onto one key so only the largest single reply was ever credited. Fail
303
+ // loud rather than repeat that silent collapse — but ONLY when the line
304
+ // will actually reach that keying step: `UsageLedgerCounter.observe()`
305
+ // discards a no-id line with neither usage nor a tool-use id before ever
306
+ // building a key (a tool-call block with no reported `id`, an empty
307
+ // wrapper entry), so requiring a valid `byteOffset` on THOSE too would
308
+ // reject lines the counter was always going to ignore. Harmless when a
309
+ // real `messageId` is present (byteOffset is never used to key that line).
310
+ if (!hasMessageId && (hasUsage || toolUseIds.length > 0) && typeof d.byte_offset !== "number") {
311
+ throw new LedgerLineDataError(`ledgerLineFromLogEntry: missing/invalid byte_offset on a no-message-id line carrying usage or a tool-use id (source=${typeof d.subagent_id === "string" ? d.subagent_id : MAIN_SOURCE})`);
312
+ }
263
313
  return {
264
- messageId: typeof d.messageId === "string" && d.messageId.length > 0 ? d.messageId : undefined,
314
+ messageId: hasMessageId ? d.messageId : undefined,
265
315
  source: typeof d.subagent_id === "string" ? d.subagent_id : MAIN_SOURCE,
266
316
  byteOffset: typeof d.byte_offset === "number" ? d.byte_offset : 0,
267
317
  model: typeof d.model === "string" && d.model.length > 0 ? d.model : undefined,
268
- usage: d.usage && typeof d.usage === "object"
269
- ? d.usage
270
- : undefined,
318
+ usage: hasUsage ? d.usage : undefined,
271
319
  timestamp: entry.timestamp,
272
- toolUseIds: content
273
- .filter((b) => b.type === "tool_use" && typeof b.id === "string")
274
- .map((b) => b.id),
320
+ toolUseIds,
275
321
  };
276
322
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-session-telemetry",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Shared Claude Code transcript collector (DX-2962) — incremental JSONL tailing for the danxbot worker and the danx-dashboard-mcp desktop bridge, so both run identical collector code.",
5
5
  "license": "MIT",
6
6
  "type": "module",