usage-tab 1.0.0 → 1.1.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.
package/README.md CHANGED
@@ -112,7 +112,10 @@ result.warnings; // readonly PriceWarning[] — empty here, never silently dropp
112
112
  version) proves two calculations used byte-identical pricing data.
113
113
  - **Historical lookups are exact and reproducible.** A cost computed for a
114
114
  specific `at` date always resolves the same pricing period, regardless of
115
- when you run it.
115
+ when — or on which machine — you run it: every ISO `at` string is read as
116
+ UTC, including the offset-less form `Date.parse` would otherwise read in
117
+ the host's local timezone. See
118
+ [Historical lookup](#historical-lookup-and-the-data-freshnesseffective-date-policy).
116
119
  - **Zero runtime dependencies, browser-safe.** No `node:` import in `src/`.
117
120
 
118
121
  ## API
@@ -127,7 +130,7 @@ interface PriceRequest {
127
130
  provider?: string; // qualifies resolution to one provider — see "two channels, one rule" below
128
131
  usage: LlmUsage | unknown; // normalize a raw provider response first — see below
129
132
  mode?: 'standard' | 'batch';
130
- at?: Date | string; // defaults to `new Date()`
133
+ at?: Date | string; // defaults to `new Date()`; an ISO string is read as UTC
131
134
  }
132
135
 
133
136
  interface CostBreakdown {
@@ -429,6 +432,45 @@ result.totalUsdExact; // "0.666666" — exact
429
432
  result.totalUsd; // 0.666666 — the same value, as a number
430
433
  ```
431
434
 
435
+ ### Totalling many requests exactly
436
+
437
+ `sum += breakdown.totalUsd` puts binary floating point back exactly where
438
+ `totalUsdExact` removed it. `createCostAggregator` keeps the running totals
439
+ on the same exact `bigint` path, overall and per model:
440
+
441
+ ```ts
442
+ import { calculateCost, createCostAggregator, sumExactUsd } from 'usage-tab';
443
+
444
+ const aggregator = createCostAggregator();
445
+ for (const call of [
446
+ { model: 'gpt-4o', provider: 'openai', usage: { inputTokens: 40_000, outputTokens: 4_000 } },
447
+ {
448
+ model: 'claude-sonnet-5',
449
+ provider: 'anthropic',
450
+ usage: { inputTokens: 40_000, outputTokens: 4_000 },
451
+ },
452
+ ]) {
453
+ aggregator.add(calculateCost({ ...call, at: '2026-08-15' }));
454
+ }
455
+
456
+ aggregator.total().totalUsdExact; // exact decimal string — the value to store
457
+ aggregator.total().count; // 2
458
+ aggregator.byModel(); // Map keyed "openai:gpt-4o", "anthropic:claude-sonnet-5"
459
+
460
+ // Already have the strings — from a database column, say:
461
+ sumExactUsd(['0.10', '0.20']); // "0.30", where 0.1 + 0.2 gives 0.30000000000000004
462
+ ```
463
+
464
+ `total().registryVersions` lists every pricing snapshot the aggregate drew
465
+ on. More than one is expected for a report spanning a registry update, and a
466
+ bug for a total meant to be reproducible against a single one — this package
467
+ reports it rather than guessing which you meant.
468
+
469
+ Both throw `InvalidRateError` on a value that is not a non-negative decimal
470
+ string, rather than coercing it: a stringified `NaN` or an exponent-notation
471
+ `"1e-7"` silently summing to something wrong is the failure this exists to
472
+ prevent.
473
+
432
474
  ### Historical lookup and the data-freshness/effective-date policy
433
475
 
434
476
  Every price carries an `effectiveFrom` (and, when superseded, an
@@ -450,6 +492,20 @@ calculateCost({ model: 'claude-sonnet-5', provider: 'anthropic', usage, at: '202
450
492
  // "18.00" — the standard rate ($3.00/$15.00), effective 2026-09-01
451
493
  ```
452
494
 
495
+ Period boundaries are UTC midnights, so **every ISO `at` string is read as
496
+ UTC**. An ISO date (`'2026-09-01'`) and an offset-carrying datetime
497
+ (`'2026-09-01T05:00:00Z'`, `'...+02:00'`) already are by specification; one
498
+ _without_ an offset — `'2026-09-01T05:00:00'`, the shape a log timestamp, a
499
+ `datetime-local` input, and several DB drivers produce — is `Date.parse`'s
500
+ local-time case, and is read as UTC here too. Otherwise the same lookup
501
+ prices at $12.00 in one deployment and $18.00 in another, purely from the
502
+ host's `TZ`.
503
+
504
+ A non-ISO string (`'2026/09/01'`, `'September 1, 2026'`) is
505
+ implementation-defined rather than specified, so there is no single reading
506
+ to normalize it to and it keeps whatever `Date.parse` does with it — local
507
+ time, in practice. Pass a `Date` or an ISO form.
508
+
453
509
  Pricing data is committed, not fetched — there is no runtime network call,
454
510
  ever. That means it can go stale between releases: a provider can change a
455
511
  price the day after `usage-tab` ships, and calculations will use the old
package/dist/index.cjs CHANGED
@@ -30,13 +30,15 @@ __export(index_exports, {
30
30
  REGISTRY_VERSION: () => REGISTRY_VERSION,
31
31
  UnknownModelError: () => UnknownModelError,
32
32
  calculateCost: () => calculateCost,
33
+ createCostAggregator: () => createCostAggregator,
33
34
  createPriceCalculator: () => createPriceCalculator,
34
35
  createPriceOverride: () => createPriceOverride,
35
36
  normalizeAnthropicUsage: () => normalizeAnthropicUsage,
36
37
  normalizeGoogleUsage: () => normalizeGoogleUsage,
37
38
  normalizeOpenAICompatibleUsage: () => normalizeOpenAICompatibleUsage,
38
39
  normalizeOpenAIUsage: () => normalizeOpenAIUsage,
39
- resolveModel: () => resolveModel2
40
+ resolveModel: () => resolveModel2,
41
+ sumExactUsd: () => sumExactUsd
40
42
  });
41
43
  module.exports = __toCommonJS(index_exports);
42
44
 
@@ -122,8 +124,9 @@ function toCandidate(descriptor) {
122
124
  }
123
125
  function matchExact(pool, id, provider) {
124
126
  if (provider !== void 0) {
125
- const canonical = pool.find((d) => d.provider === provider && d.canonicalId === id);
126
- if (canonical !== void 0) return { unique: canonical };
127
+ const canonical = pool.filter((d) => d.provider === provider && d.canonicalId === id);
128
+ if (canonical.length === 1) return { unique: canonical[0] };
129
+ if (canonical.length > 1) return { ambiguous: canonical };
127
130
  const scoped = pool.filter((d) => d.provider === provider && d.aliases.includes(id));
128
131
  if (scoped.length === 1) return { unique: scoped[0] };
129
132
  if (scoped.length > 1) return { ambiguous: scoped };
@@ -151,16 +154,22 @@ function resolveModel(requestedId, registry, options = {}) {
151
154
  }
152
155
  }
153
156
  if (provider !== void 0) {
154
- const canonical = registry.find(
157
+ const canonical = registry.filter(
155
158
  (d) => d.provider === provider && d.canonicalId === requestedId
156
159
  );
157
- if (canonical !== void 0) {
158
- return {
159
- descriptor: canonical,
160
- matchedBy: "canonical-qualified",
161
- requestedId,
162
- requestedProvider: provider
163
- };
160
+ if (canonical.length === 1) {
161
+ const descriptor = canonical[0];
162
+ if (descriptor !== void 0) {
163
+ return {
164
+ descriptor,
165
+ matchedBy: "canonical-qualified",
166
+ requestedId,
167
+ requestedProvider: provider
168
+ };
169
+ }
170
+ }
171
+ if (canonical.length > 1) {
172
+ throw new AmbiguousAliasError(requestedId, canonical.map(toCandidate));
164
173
  }
165
174
  const scoped = registry.filter(
166
175
  (d) => d.provider === provider && d.aliases.includes(requestedId)
@@ -214,10 +223,16 @@ function resolveModel(requestedId, registry, options = {}) {
214
223
  }
215
224
 
216
225
  // ../../internal/model-registry/src/pricing-period.ts
226
+ var OFFSETLESS_ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
217
227
  function toTimestamp(value) {
218
- const ms = value instanceof Date ? value.getTime() : Date.parse(value);
228
+ if (value instanceof Date) {
229
+ const ms2 = value.getTime();
230
+ if (Number.isNaN(ms2)) throw new InvalidLookupDateError(String(value));
231
+ return ms2;
232
+ }
233
+ const ms = Date.parse(OFFSETLESS_ISO_DATETIME.test(value) ? `${value}Z` : value);
219
234
  if (Number.isNaN(ms)) {
220
- throw new InvalidLookupDateError(value instanceof Date ? value.toISOString() : value);
235
+ throw new InvalidLookupDateError(value);
221
236
  }
222
237
  return ms;
223
238
  }
@@ -1549,7 +1564,7 @@ var InvalidRateError = class extends Error {
1549
1564
  var ZERO = { numerator: 0n, scale: 0 };
1550
1565
  var DECIMAL_PATTERN = /^\d+(\.\d+)?$/;
1551
1566
  function parseDecimalRate(value, field) {
1552
- if (!DECIMAL_PATTERN.test(value)) {
1567
+ if (typeof value !== "string" || !DECIMAL_PATTERN.test(value)) {
1553
1568
  throw new InvalidRateError(field, value);
1554
1569
  }
1555
1570
  const dot = value.indexOf(".");
@@ -1983,6 +1998,59 @@ function createPriceOverride(input) {
1983
1998
  };
1984
1999
  }
1985
2000
 
2001
+ // src/aggregate.ts
2002
+ function sumExactUsd(values) {
2003
+ const amounts = [];
2004
+ for (const [index, value] of values.entries()) {
2005
+ amounts.push(parseDecimalRate(value, `values[${String(index)}]`));
2006
+ }
2007
+ return formatExact(addExact(amounts));
2008
+ }
2009
+ function modelKey(breakdown) {
2010
+ return `${breakdown.provider}:${breakdown.canonicalModel}`;
2011
+ }
2012
+ function newBucket() {
2013
+ return { count: 0, sum: ZERO, registryVersions: /* @__PURE__ */ new Set() };
2014
+ }
2015
+ function accumulate(bucket, amount, registryVersion) {
2016
+ bucket.count += 1;
2017
+ bucket.sum = addExact([bucket.sum, amount]);
2018
+ bucket.registryVersions.add(registryVersion);
2019
+ }
2020
+ function toTotal(bucket) {
2021
+ return {
2022
+ count: bucket.count,
2023
+ totalUsd: toDisplayNumber(bucket.sum),
2024
+ totalUsdExact: formatExact(bucket.sum),
2025
+ registryVersions: [...bucket.registryVersions].sort()
2026
+ };
2027
+ }
2028
+ function createCostAggregator() {
2029
+ const overall = newBucket();
2030
+ const byModelBuckets = /* @__PURE__ */ new Map();
2031
+ return {
2032
+ add(breakdown) {
2033
+ const amount = parseDecimalRate(breakdown.totalUsdExact, "totalUsdExact");
2034
+ const key = modelKey(breakdown);
2035
+ let bucket = byModelBuckets.get(key);
2036
+ if (bucket === void 0) {
2037
+ bucket = newBucket();
2038
+ byModelBuckets.set(key, bucket);
2039
+ }
2040
+ accumulate(bucket, amount, breakdown.registryVersion);
2041
+ accumulate(overall, amount, breakdown.registryVersion);
2042
+ },
2043
+ total() {
2044
+ return toTotal(overall);
2045
+ },
2046
+ byModel() {
2047
+ const result = /* @__PURE__ */ new Map();
2048
+ for (const [key, bucket] of byModelBuckets) result.set(key, toTotal(bucket));
2049
+ return result;
2050
+ }
2051
+ };
2052
+ }
2053
+
1986
2054
  // src/normalize/openai.ts
1987
2055
  var KNOWN_TOP_LEVEL = /* @__PURE__ */ new Set([
1988
2056
  "prompt_tokens",