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 +58 -2
- package/dist/index.cjs +82 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +65 -2
- package/dist/index.d.ts +65 -2
- package/dist/index.js +82 -14
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
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.
|
|
126
|
-
if (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.
|
|
157
|
+
const canonical = registry.filter(
|
|
155
158
|
(d) => d.provider === provider && d.canonicalId === requestedId
|
|
156
159
|
);
|
|
157
|
-
if (canonical
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
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",
|