@byokit/usage 0.1.0 → 0.2.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/CHANGELOG.md +10 -0
- package/README.md +198 -11
- package/dist/backoff.d.ts +7 -0
- package/dist/backoff.js +26 -0
- package/dist/calls.d.ts +68 -0
- package/dist/calls.js +131 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +154 -50
- package/dist/ledger.d.ts +49 -0
- package/dist/ledger.js +87 -0
- package/dist/providers.d.ts +23 -4
- package/dist/providers.js +91 -13
- package/dist/quota.d.ts +7 -0
- package/dist/quota.js +87 -0
- package/dist/room.d.ts +3 -0
- package/dist/room.js +11 -0
- package/dist/store.d.ts +10 -9
- package/dist/store.js +31 -7
- package/dist/types.d.ts +71 -8
- package/dist/windows.d.ts +2 -1
- package/dist/windows.js +5 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.2.0 (2026-09-30)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
- Export `fingerprint`, `fileUsageStore`, `memoryBackoffPolicy`, `retryAfterMs` and `backoffDelayMs` for host subscription usage adapters.
|
|
10
|
+
- Add token quota sources for Claude, Codex, Copilot, Grok, MiniMax, Gemini and Kimi, with BYOKit's own user agent and no credential discovery.
|
|
11
|
+
- Add `callLedger`, `normalizeTokens` and `priceCall` for attributed model calls, reported/partial/unknown counts and estimates from app price tables only, on the member ledger store seam.
|
|
12
|
+
- Add a member-scoped token ledger with local-day totals, trailing seven-day caps and an injectable store (in-memory by default).
|
|
13
|
+
- Add `roomOf`; reset timestamps now use epoch milliseconds.
|
|
14
|
+
- Add host Claude reader, last-good store and 429 backoff hooks. Persist only normalized quota fields and fingerprint non-secret identities.
|
|
5
15
|
- Read subscription usage windows per provider and per account, with last-good readings and bounded provider reads.
|
|
6
16
|
|
|
7
17
|
## 0.1.0
|
package/README.md
CHANGED
|
@@ -1,25 +1,212 @@
|
|
|
1
1
|
# @byokit/usage
|
|
2
2
|
|
|
3
|
-
Read subscription
|
|
3
|
+
Read subscription quota windows per provider and per account on Node 22.18 or later.
|
|
4
|
+
The app owns sign-in, token renewal, account labels and selection. The kit reads room
|
|
5
|
+
left, estimates no cost and never rotates an account.
|
|
4
6
|
|
|
5
7
|
```ts
|
|
6
|
-
import { usage } from '@byokit/usage';
|
|
8
|
+
import { usage, roomOf } from '@byokit/usage';
|
|
9
|
+
// Supplied by the host's signed-in account.
|
|
10
|
+
declare const token: string;
|
|
11
|
+
declare const account: { id: string };
|
|
7
12
|
const reader = usage({ stateDir: '/app/state/usage' });
|
|
8
|
-
const source = { provider: 'codex' as const,
|
|
13
|
+
const source = { provider: 'codex' as const, access: token, accountId: account.id };
|
|
9
14
|
const reading = await reader.read(source);
|
|
10
|
-
|
|
15
|
+
const room = roomOf(reading, Date.now());
|
|
11
16
|
```
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
Sources:
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
- `{ provider: 'codex', access, accountId }` reads ChatGPT's `wham/usage`.
|
|
21
|
+
- `{ provider: 'claude', access, accountUuid?, accountId? }` reads Claude plan usage.
|
|
22
|
+
- `{ provider: 'copilot' | 'grok' | 'minimax' | 'kimi', access, accountId? }` reads
|
|
23
|
+
the corresponding subscription quota. MiniMax's `access` is the plan key.
|
|
24
|
+
- `{ provider: 'gemini', access, project?, accountId? }` reads Code Assist quota.
|
|
25
|
+
Without a project, the kit discovers it through `loadCodeAssist` first.
|
|
26
|
+
- `{ provider: 'opencode' | 'zai', key, accountId? }` reads plan-key quota.
|
|
27
|
+
- `{ provider: 'codex', bin, home, env? }` retains the explicit local app-server source.
|
|
28
|
+
- `{ provider: 'claude', credentialsFile, configFile?, statuslineFile? }` is a
|
|
29
|
+
read-only adapter for files the app explicitly supplies. It reads `claudeAiOauth`
|
|
30
|
+
and optionally `oauthAccount.accountUuid`. A valid statusline snapshot younger
|
|
31
|
+
than five minutes precedes the endpoint. An expired token is never sent.
|
|
32
|
+
- `{ provider: 'claude', accountUuid, read, connected? }` delegates to the app's
|
|
33
|
+
reader. `read({ nowMs, signal })` returns `{ raw?, code?, retryAfterMs? }` using
|
|
34
|
+
the same Claude payload dialect. It has a ten-second deadline, with the signal
|
|
35
|
+
aborted at expiry. The optional synchronous `connected()` hook controls whether
|
|
36
|
+
last-good readings remain visible; exceptions count as disconnected.
|
|
16
37
|
|
|
17
|
-
`read(source, { nowMs? })`
|
|
38
|
+
`read(source, { nowMs? })` returns `{ provider, windows, at, code? }`; `at` and all
|
|
39
|
+
`resetsAt` fields are **epoch milliseconds** in 0.2.0. This changes 0.1.0's seconds
|
|
40
|
+
reset convention. Windows include kind, used percent, optional duration in minutes,
|
|
41
|
+
reset time, limit label and limited flag. Parsers are exported for host integrations:
|
|
42
|
+
`claudeWindows`, `codexWindows` (app-server), `codexTokenWindows`, `goWindows`,
|
|
43
|
+
`zaiWindows`, `copilotWindows`, `grokWindows`, `minimaxWindows`, `geminiWindows`,
|
|
44
|
+
`kimiWindows(raw, nowMs)`.
|
|
18
45
|
|
|
19
|
-
|
|
46
|
+
`roomOf(reading, nowMs)` returns `{ left, span, resetsAt?, at }` using the tightest
|
|
47
|
+
window. Span maps session/week/month and maps rolling/custom to `tightest`. Missing
|
|
48
|
+
windows, readings older than 24 hours, future readings, or disconnected/expired/auth/
|
|
49
|
+
no-plan readings give `{ left: 'unknown', at }`. A temporary rate limit or failed
|
|
50
|
+
update can still show the last-good room and its original timestamp. Auto selection
|
|
51
|
+
belongs to the accounts kit; usage only reports room.
|
|
20
52
|
|
|
21
|
-
|
|
53
|
+
`connected(source)` and `account(source)` are synchronous. `account` returns a salted
|
|
54
|
+
fingerprint of a non-secret host id, credential account UUID, token subject, or explicit
|
|
55
|
+
local folder identity. Token subjects provide cache identity only, never authentication.
|
|
56
|
+
For opaque tokens and plan keys, pass `accountId` to keep quota history across renewal.
|
|
57
|
+
Without it, reads still work in memory, but `account()` is undefined and no public or
|
|
58
|
+
disk store is used. Tokens never become persisted fingerprint inputs.
|
|
22
59
|
|
|
23
|
-
|
|
60
|
+
Good reads have no code. Bad sources throw `UsageError` (`code: 'bad-source'`); other
|
|
61
|
+
failures resolve codes without bodies or secrets. `lastKnown(source, { nowMs? })`
|
|
62
|
+
returns a connected account's last-good reading for up to 24 hours. Reads have a
|
|
63
|
+
60-second floor and concurrent deduplication per provider/account. The default 429
|
|
64
|
+
backoff honors Retry-After with a five-minute minimum. `UsageOptions.now` supplies
|
|
65
|
+
the default clock; a per-call clock overrides it. An injected `fetch` wins; otherwise
|
|
66
|
+
global fetch is resolved on each read.
|
|
24
67
|
|
|
25
|
-
|
|
68
|
+
The host may supply public synchronous persistence and backoff hooks:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { usage, type UsageStore, type BackoffPolicy } from '@byokit/usage';
|
|
72
|
+
declare const appStore: UsageStore;
|
|
73
|
+
declare const appBackoff: BackoffPolicy;
|
|
74
|
+
const reader = usage({
|
|
75
|
+
store: {
|
|
76
|
+
get(provider, fingerprint) { return appStore.get(provider, fingerprint); },
|
|
77
|
+
put(provider, fingerprint, reading) { appStore.put(provider, fingerprint, reading); },
|
|
78
|
+
},
|
|
79
|
+
backoff: {
|
|
80
|
+
get(provider, fingerprint) { return appBackoff.get(provider, fingerprint); },
|
|
81
|
+
set(provider, fingerprint, untilMs) { appBackoff.set(provider, fingerprint, untilMs); },
|
|
82
|
+
delayMs(retryAfterMs) { return Math.max(300_000, retryAfterMs ?? 0); },
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`UsageStore` holds only `{ at, windows }`; only whitelisted normalized fields cross
|
|
88
|
+
this boundary. Exceptions from host hooks do not expose data or fail a provider read.
|
|
89
|
+
Internal 429 backoff remains effective if a host backoff hook fails. The 60-second
|
|
90
|
+
minimum retry interval applies even if a policy selects a shorter delay. By default,
|
|
91
|
+
`stateDir` selects an atomic disk store (0700 directory, 0600 file, 256 KB cap), or
|
|
92
|
+
without `stateDir` an in-memory store is used. `memoryUsageStore()` is exported.
|
|
93
|
+
`store` overrides `stateDir`. Disk storage uses `plans-v2.json`, deliberately ignoring
|
|
94
|
+
old raw-payload stores so second-based and millisecond-based readings never mix.
|
|
95
|
+
The default salt is `byokit/usage/account`.
|
|
96
|
+
|
|
97
|
+
To replace a host's Claude quota adapter, pass the exact files the host already
|
|
98
|
+
selected. Reuse the backoff policy across collector instances:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { usage, fileUsageStore, memoryBackoffPolicy, fingerprint } from '@byokit/usage';
|
|
102
|
+
const salt = 'my-app/usage/account';
|
|
103
|
+
const store = fileUsageStore('/app/state/usage');
|
|
104
|
+
const backoff = memoryBackoffPolicy();
|
|
105
|
+
const source = {
|
|
106
|
+
provider: 'claude' as const,
|
|
107
|
+
credentialsFile: '/app/sign-ins/claude/.credentials.json',
|
|
108
|
+
configFile: '/app/sign-ins/claude/.claude.json',
|
|
109
|
+
statuslineFile: '/app/sign-ins/claude/statusline.json',
|
|
110
|
+
};
|
|
111
|
+
const reader = usage({ store, backoff, salt });
|
|
112
|
+
const reading = await reader.read(source);
|
|
113
|
+
const lastGood = reader.lastKnown(source);
|
|
114
|
+
// For a host-owned non-secret account UUID, this matches reader.account(source).
|
|
115
|
+
declare const accountUuid: string;
|
|
116
|
+
const accountKey = fingerprint(salt)('claude', accountUuid);
|
|
117
|
+
const saved = store.get('claude', accountKey);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The kit replaces the credential/snapshot read, quota request, payload parsing,
|
|
121
|
+
account fingerprint, last-good disk writes and 429 rest tracking. The host selects
|
|
122
|
+
paths and displays the returned windows. A valid recent snapshot precedes the
|
|
123
|
+
request; the request uses the kit's own user agent. No credential refresh occurs.
|
|
124
|
+
Legacy raw stores require host migration into normalized millisecond readings;
|
|
125
|
+
they are never loaded automatically.
|
|
126
|
+
|
|
127
|
+
`fileUsageStore(absoluteStateDir)` exposes the same bounded atomic disk store as
|
|
128
|
+
`stateDir`; invalid directory paths throw `UsageError`. Its keys must be 64-character
|
|
129
|
+
hex fingerprints, and it persists only `{ at, windows }` with normalized fields.
|
|
130
|
+
`fingerprint(salt)` returns `(provider, nonSecretIdentity) => string`; use the same
|
|
131
|
+
salt as the reader and never supply a token as the identity.
|
|
132
|
+
`memoryBackoffPolicy()` supplies shared per-provider/account rests, keeps the later
|
|
133
|
+
rest when updated, and writes no files. The host can use `retryAfterMs(header, nowMs)`
|
|
134
|
+
to parse Retry-After seconds or an HTTP date (invalid/absent values give `undefined`,
|
|
135
|
+
past dates clamp to zero), and `backoffDelayMs(retryAfterMs)` to apply the default
|
|
136
|
+
five-minute minimum. These helpers also support an app-owned Claude `read` hook
|
|
137
|
+
without duplicating fingerprint, persistence or retry logic.
|
|
138
|
+
|
|
139
|
+
Isolation: there is no home/path discovery or environment read. Only absolute files
|
|
140
|
+
and the Codex binary explicitly supplied by the app are opened/run. Credential files
|
|
141
|
+
are bounded regular files, with final symlinks rejected. The spawn uses argv and an
|
|
142
|
+
environment built from the host's explicit `env` plus `CODEX_HOME=home`; pass PATH
|
|
143
|
+
and HOME explicitly when needed. No tokens in logs, errors, readings or public hooks;
|
|
144
|
+
no credential write-back, telemetry, automatic refresh or reset-credit spend.
|
|
145
|
+
|
|
146
|
+
Built-in requests send `User-Agent: byokit/usage/0.2.0`, never another app's identity.
|
|
147
|
+
A refusal returns a code; with no last-good quota, room is unknown. Fixed endpoints
|
|
148
|
+
are Anthropic `api/oauth/usage`, ChatGPT `backend-api/wham/usage`, GitHub
|
|
149
|
+
`copilot_internal/user`, Grok `v1/billing` (weekly credits then monthly when needed),
|
|
150
|
+
MiniMax `v1/token_plan/remains`, Gemini `v1internal:retrieveUserQuota`, Kimi
|
|
151
|
+
`coding/v1/usages`, OpenCode `zen/go/v1/usage`, and z.ai `monitor/usage/quota/limit`.
|
|
152
|
+
HTTP requests reject redirects, have a ten-second deadline and a 64 KB body cap.
|
|
153
|
+
Claude includes `anthropic-beta: oauth-2025-04-20`; Codex includes the host's
|
|
154
|
+
`ChatGPT-Account-Id`. Codex app-server reads have a twenty-second deadline, a 64 KB
|
|
155
|
+
stdout cap and SIGTERM followed by SIGKILL after one second.
|
|
156
|
+
|
|
157
|
+
The Node-only main entry also exports `UsageError`, types, `words` and `usageWords`.
|
|
158
|
+
`./testing` exports `fakeFetch`, `fakeCodex` and `usageContract(make, { test? })`.
|
|
159
|
+
Tests use synthetic recorded protocol shapes and fakes behind the repository's
|
|
160
|
+
network guard. They never open real sign-ins or call provider endpoints.
|
|
161
|
+
|
|
162
|
+
`tokenLedger({ store?, cap? })` records measured token counts for host member ids.
|
|
163
|
+
`record(member, tokens, time)` accepts a nonnegative safe integer and epoch milliseconds;
|
|
164
|
+
`query(member, from, to)` uses `[from, to)` bounds and returns total `tokens`, sorted
|
|
165
|
+
local-calendar `days: [{ date, tokens }]`, and a `week` ending at `to`. The week spans
|
|
166
|
+
seven local calendar days (including DST), independent of `from`, and includes
|
|
167
|
+
`{ from, to, tokens, cap?, remaining? }`. Remaining allowance is clamped to zero.
|
|
168
|
+
`cap` is a seven-day token count or a synchronous member-to-cap function; omitted
|
|
169
|
+
means uncapped. `store` implements `record(member, { tokens, time })` and
|
|
170
|
+
`query(member, from, to)`, with `memoryTokenLedgerStore()` as the default. Reuse a
|
|
171
|
+
store to keep history across reader instances. The host owns durable storage and
|
|
172
|
+
retention; the default ledger never writes files. Invalid inputs and store failures
|
|
173
|
+
throw `TokenLedgerError` with `code: 'invalid' | 'store'`, without exposing member ids
|
|
174
|
+
or store exception text. Entries are counts only, never sign-in tokens.
|
|
175
|
+
|
|
176
|
+
`callLedger({ store?, prices? })` records runtime model calls through the same
|
|
177
|
+
`TokenLedgerStore` seam. `record(member, { provider, account, model, runId, time,
|
|
178
|
+
billing, usage?, payer?, durationMs?, state?, limits? })` returns and stores one
|
|
179
|
+
`CallRecord`. `billing` is `subscription` or `api`; `payer` defaults to the member.
|
|
180
|
+
`state` is `completed` (default), `cancelled` or `failed`. The host records each
|
|
181
|
+
actual model call, including retries, and supplies the provider's final usage when
|
|
182
|
+
available. No missing counts are inferred from words or decision sub-answers.
|
|
183
|
+
|
|
184
|
+
`normalizeTokens(provider, usage)` accepts native usage or its response envelope,
|
|
185
|
+
and normalized `{ input?, output?, cachedInput?, cacheWrite?, total? }` counts.
|
|
186
|
+
It returns only safe nonnegative integer counts with
|
|
187
|
+
`provenance: 'reported' | 'partial' | 'unknown'`. Input includes cache reads/writes,
|
|
188
|
+
which are subsets, so total is input plus output once. Claude's distinct cache
|
|
189
|
+
buckets are added to ordinary input ([Claude usage fields](https://platform.claude.com/docs/en/build-with-claude/prompt-caching));
|
|
190
|
+
OpenAI-compatible input already includes cache ([OpenAI caching](https://developers.openai.com/api/docs/guides/prompt-caching)).
|
|
191
|
+
Gemini output includes candidates and thinking tokens ([Gemini UsageMetadata](https://ai.google.dev/api/generate-content#UsageMetadata)).
|
|
192
|
+
Absent, invalid, explicitly estimated or inconsistent counts remain unknown; raw
|
|
193
|
+
response objects, prompts and credential fields are discarded.
|
|
194
|
+
|
|
195
|
+
Prices are host data keyed by provider then model: `{ billing, currency,
|
|
196
|
+
inputPerMillion, outputPerMillion, cachedInputPerMillion?, cacheWritePerMillion? }`.
|
|
197
|
+
`priceCall(tokens, price, billing)` and the ledger return a cost only when reported
|
|
198
|
+
counts and the matching app price row suffice. No vendor prices are bundled or
|
|
199
|
+
fetched. Costs carry `basis: 'app-prices'`, `estimated: true`, and billing labels
|
|
200
|
+
`Person's own plan` or `Person's API bill`; an estimate is not an invoice or an
|
|
201
|
+
extra charge against a subscription. Missing separate cache rates use the host's
|
|
202
|
+
input rate. If a separate rate requires an unknown cache count, cost is unknown.
|
|
203
|
+
|
|
204
|
+
`query(member, from, to)` returns time-sorted `calls`, aggregate `tokens`, `costs`
|
|
205
|
+
separated by currency and billing, and `unpricedCalls`. An aggregate field is
|
|
206
|
+
unknown if any call lacks that field. Store implementations must preserve the
|
|
207
|
+
entry's optional `call` metadata to replay calls; the default in-memory store does.
|
|
208
|
+
Sharing the store lets `tokenLedger.query` count these calls automatically. For
|
|
209
|
+
calls with unknown total counts, member/day/week results expose `unknownCalls`,
|
|
210
|
+
`tokens` is the known subtotal, and week `remaining` is omitted. Cap and price
|
|
211
|
+
policy remain the host's. All times, durations and quota reset timestamps are
|
|
212
|
+
milliseconds. There is no transport, credential discovery or automatic rotation.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BackoffPolicy } from './types.ts';
|
|
2
|
+
/** Parse Retry-After seconds or HTTP-date into a duration in milliseconds. */
|
|
3
|
+
export declare function retryAfterMs(header: string | null | undefined, nowMs: number): number | undefined;
|
|
4
|
+
/** Default quota rest: honor a longer server delay, otherwise wait five minutes. */
|
|
5
|
+
export declare function backoffDelayMs(retryAfter: number | undefined): number;
|
|
6
|
+
/** Share this policy between readers to preserve per-account 429 rests in memory. */
|
|
7
|
+
export declare function memoryBackoffPolicy(): BackoffPolicy;
|
package/dist/backoff.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Parse Retry-After seconds or HTTP-date into a duration in milliseconds. */
|
|
2
|
+
export function retryAfterMs(header, nowMs) {
|
|
3
|
+
if (!header?.trim())
|
|
4
|
+
return undefined;
|
|
5
|
+
const value = header.trim();
|
|
6
|
+
const delay = /^\d+(?:\.\d+)?$/.test(value) ? Number(value) * 1000 : Date.parse(value) - nowMs;
|
|
7
|
+
return Number.isFinite(delay) ? Math.max(0, delay) : undefined;
|
|
8
|
+
}
|
|
9
|
+
/** Default quota rest: honor a longer server delay, otherwise wait five minutes. */
|
|
10
|
+
export function backoffDelayMs(retryAfter) {
|
|
11
|
+
return Math.max(300_000, typeof retryAfter === 'number' && Number.isFinite(retryAfter) ? retryAfter : 0);
|
|
12
|
+
}
|
|
13
|
+
/** Share this policy between readers to preserve per-account 429 rests in memory. */
|
|
14
|
+
export function memoryBackoffPolicy() {
|
|
15
|
+
const rests = new Map();
|
|
16
|
+
return {
|
|
17
|
+
get: (provider, account) => rests.get(`${provider}\0${account}`),
|
|
18
|
+
set: (provider, account, untilMs) => {
|
|
19
|
+
if (!Number.isFinite(untilMs))
|
|
20
|
+
return;
|
|
21
|
+
const key = `${provider}\0${account}`;
|
|
22
|
+
rests.set(key, Math.max(rests.get(key) ?? 0, untilMs));
|
|
23
|
+
},
|
|
24
|
+
delayMs: backoffDelayMs,
|
|
25
|
+
};
|
|
26
|
+
}
|
package/dist/calls.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type TokenLedgerStore } from './ledger.ts';
|
|
2
|
+
import type { Window } from './types.ts';
|
|
3
|
+
export interface NormalizedTokens {
|
|
4
|
+
/** Input includes cache reads and cache writes; cache counts are subsets. */
|
|
5
|
+
input?: number;
|
|
6
|
+
output?: number;
|
|
7
|
+
cachedInput?: number;
|
|
8
|
+
cacheWrite?: number;
|
|
9
|
+
total?: number;
|
|
10
|
+
provenance: 'reported' | 'partial' | 'unknown';
|
|
11
|
+
}
|
|
12
|
+
export interface ModelPrice {
|
|
13
|
+
billing: 'subscription' | 'api';
|
|
14
|
+
currency: string;
|
|
15
|
+
inputPerMillion: number;
|
|
16
|
+
outputPerMillion: number;
|
|
17
|
+
cachedInputPerMillion?: number;
|
|
18
|
+
cacheWritePerMillion?: number;
|
|
19
|
+
}
|
|
20
|
+
export type PriceTable = Readonly<Record<string, Readonly<Record<string, ModelPrice>>>>;
|
|
21
|
+
export interface CallCost {
|
|
22
|
+
amount: number;
|
|
23
|
+
currency: string;
|
|
24
|
+
billing: ModelPrice['billing'];
|
|
25
|
+
label: "Person's own plan" | "Person's API bill";
|
|
26
|
+
basis: 'app-prices';
|
|
27
|
+
estimated: true;
|
|
28
|
+
}
|
|
29
|
+
export interface CallInput {
|
|
30
|
+
provider: string;
|
|
31
|
+
account: string;
|
|
32
|
+
model: string;
|
|
33
|
+
runId: string;
|
|
34
|
+
time: number;
|
|
35
|
+
billing: 'subscription' | 'api';
|
|
36
|
+
/** Native provider usage/envelope, or normalized input/output/cachedInput/cacheWrite/total counts. */
|
|
37
|
+
usage?: unknown;
|
|
38
|
+
payer?: string;
|
|
39
|
+
durationMs?: number;
|
|
40
|
+
state?: 'completed' | 'cancelled' | 'failed';
|
|
41
|
+
limits?: readonly Window[];
|
|
42
|
+
}
|
|
43
|
+
export interface CallRecord extends Omit<CallInput, 'usage' | 'limits'> {
|
|
44
|
+
tokens: NormalizedTokens;
|
|
45
|
+
state: 'completed' | 'cancelled' | 'failed';
|
|
46
|
+
cost?: CallCost;
|
|
47
|
+
limits?: Window[];
|
|
48
|
+
}
|
|
49
|
+
export interface CallQuery {
|
|
50
|
+
calls: CallRecord[];
|
|
51
|
+
tokens: NormalizedTokens;
|
|
52
|
+
/** Separate subtotals by currency and billing; unpriced calls are counted explicitly. */
|
|
53
|
+
costs: CallCost[];
|
|
54
|
+
unpricedCalls: number;
|
|
55
|
+
}
|
|
56
|
+
export interface CallLedger {
|
|
57
|
+
record(member: string, call: CallInput): CallRecord;
|
|
58
|
+
query(member: string, from: number, to: number): CallQuery;
|
|
59
|
+
}
|
|
60
|
+
/** Pure normalization of reported counts, never estimates from text or shared decision invocations. */
|
|
61
|
+
export declare function normalizeTokens(provider: string, raw: unknown): NormalizedTokens;
|
|
62
|
+
/** App-supplied price estimates only, including explicit billing attribution. */
|
|
63
|
+
export declare function priceCall(tokens: NormalizedTokens, price: ModelPrice | undefined, billing: ModelPrice['billing']): CallCost | undefined;
|
|
64
|
+
/** Every recorded attempt is a call; the host records retries separately under its run id. */
|
|
65
|
+
export declare function callLedger(options?: {
|
|
66
|
+
store?: TokenLedgerStore;
|
|
67
|
+
prices?: PriceTable;
|
|
68
|
+
}): CallLedger;
|
package/dist/calls.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { record as isRecord } from "./windows.js";
|
|
2
|
+
import { memoryTokenLedgerStore, TokenLedgerError } from "./ledger.js";
|
|
3
|
+
import { safeWindows } from "./store.js";
|
|
4
|
+
const object = (value) => isRecord(value) ? value : {};
|
|
5
|
+
const count = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
6
|
+
const text = (value) => typeof value === 'string' && !!value && value.length <= 1024 && !/[\0\r\n]/.test(value);
|
|
7
|
+
const time = (value) => typeof value === 'number' && Number.isFinite(value) && !Number.isNaN(new Date(value).getTime());
|
|
8
|
+
const sum = (...values) => count(values.reduce((a, b) => a + b, 0));
|
|
9
|
+
function normalized(input, output, cachedInput, cacheWrite, total) {
|
|
10
|
+
if (input !== undefined && ((cachedInput ?? 0) + (cacheWrite ?? 0) > input))
|
|
11
|
+
return { provenance: 'unknown' };
|
|
12
|
+
const derived = input !== undefined && output !== undefined ? sum(input, output) : undefined;
|
|
13
|
+
// Inconsistent reported totals are not a reliable measurement.
|
|
14
|
+
if (derived !== undefined && total !== undefined && derived !== total)
|
|
15
|
+
return { provenance: 'unknown' };
|
|
16
|
+
const measured = derived ?? total;
|
|
17
|
+
return { ...(input === undefined ? {} : { input }), ...(output === undefined ? {} : { output }),
|
|
18
|
+
...(cachedInput === undefined ? {} : { cachedInput }), ...(cacheWrite === undefined ? {} : { cacheWrite }),
|
|
19
|
+
...(measured === undefined ? {} : { total: measured }),
|
|
20
|
+
provenance: input !== undefined && output !== undefined && derived !== undefined ? 'reported' : [input, output, cachedInput, cacheWrite, measured].some((v) => v !== undefined) ? 'partial' : 'unknown' };
|
|
21
|
+
}
|
|
22
|
+
/** Pure normalization of reported counts, never estimates from text or shared decision invocations. */
|
|
23
|
+
export function normalizeTokens(provider, raw) {
|
|
24
|
+
const envelope = object(raw);
|
|
25
|
+
const usage = object(envelope.usage ?? envelope.usageMetadata ?? raw);
|
|
26
|
+
if (usage.provenance === 'unknown' || usage.provenance === 'estimated')
|
|
27
|
+
return { provenance: 'unknown' };
|
|
28
|
+
if (['input', 'output', 'total'].some((key) => key in usage))
|
|
29
|
+
return normalized(count(usage.input), count(usage.output), count(usage.cachedInput), count(usage.cacheWrite), count(usage.total));
|
|
30
|
+
if (provider === 'claude' || provider === 'anthropic') {
|
|
31
|
+
if (!['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'].some((key) => count(usage[key]) !== undefined))
|
|
32
|
+
return { provenance: 'unknown' };
|
|
33
|
+
const uncached = count(usage.input_tokens);
|
|
34
|
+
const cached = count(usage.cache_read_input_tokens) ?? (usage.cache_read_input_tokens === undefined ? 0 : undefined);
|
|
35
|
+
const written = count(usage.cache_creation_input_tokens) ?? (usage.cache_creation_input_tokens === undefined ? 0 : undefined);
|
|
36
|
+
const input = uncached !== undefined && cached !== undefined && written !== undefined ? sum(uncached, cached, written) : undefined;
|
|
37
|
+
return normalized(input, count(usage.output_tokens), cached, written);
|
|
38
|
+
}
|
|
39
|
+
if (provider === 'gemini' || provider === 'google') {
|
|
40
|
+
const output = count(usage.candidatesTokenCount);
|
|
41
|
+
const thoughts = count(usage.thoughtsTokenCount) ?? (usage.thoughtsTokenCount === undefined ? 0 : undefined);
|
|
42
|
+
const input = count(usage.promptTokenCount);
|
|
43
|
+
return normalized(input, output !== undefined && thoughts !== undefined ? sum(output, thoughts) : undefined, count(usage.cachedContentTokenCount), undefined, count(usage.totalTokenCount));
|
|
44
|
+
}
|
|
45
|
+
const input = count(usage.input_tokens ?? usage.prompt_tokens);
|
|
46
|
+
const output = count(usage.output_tokens ?? usage.completion_tokens);
|
|
47
|
+
const details = object(usage.input_tokens_details ?? usage.prompt_tokens_details);
|
|
48
|
+
return normalized(input, output, count(details.cached_tokens), count(details.cache_write_tokens), count(usage.total_tokens));
|
|
49
|
+
}
|
|
50
|
+
/** App-supplied price estimates only, including explicit billing attribution. */
|
|
51
|
+
export function priceCall(tokens, price, billing) {
|
|
52
|
+
if (!price || price.billing !== billing || tokens.input === undefined || tokens.output === undefined || tokens.provenance !== 'reported' || !text(price.currency))
|
|
53
|
+
return undefined;
|
|
54
|
+
if ([tokens.input, tokens.output, tokens.cachedInput, tokens.cacheWrite, tokens.total].some((value) => value !== undefined && count(value) === undefined) || tokens.total !== undefined && tokens.input + tokens.output !== tokens.total)
|
|
55
|
+
return undefined;
|
|
56
|
+
const rates = [price.inputPerMillion, price.outputPerMillion, price.cachedInputPerMillion, price.cacheWritePerMillion];
|
|
57
|
+
if (rates.some((rate) => rate !== undefined && (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 0)))
|
|
58
|
+
return undefined;
|
|
59
|
+
if (typeof price.inputPerMillion !== 'number' || typeof price.outputPerMillion !== 'number')
|
|
60
|
+
return undefined;
|
|
61
|
+
if (tokens.cachedInput === undefined && price.cachedInputPerMillion !== undefined && price.cachedInputPerMillion !== price.inputPerMillion
|
|
62
|
+
|| tokens.cacheWrite === undefined && price.cacheWritePerMillion !== undefined && price.cacheWritePerMillion !== price.inputPerMillion)
|
|
63
|
+
return undefined;
|
|
64
|
+
const cached = tokens.cachedInput ?? 0;
|
|
65
|
+
const written = tokens.cacheWrite ?? 0;
|
|
66
|
+
const ordinary = tokens.input - cached - written;
|
|
67
|
+
if (ordinary < 0)
|
|
68
|
+
return undefined;
|
|
69
|
+
const amount = (ordinary * price.inputPerMillion + cached * (price.cachedInputPerMillion ?? price.inputPerMillion)
|
|
70
|
+
+ written * (price.cacheWritePerMillion ?? price.inputPerMillion) + tokens.output * price.outputPerMillion) / 1_000_000;
|
|
71
|
+
if (!Number.isFinite(amount))
|
|
72
|
+
return undefined;
|
|
73
|
+
return { amount, currency: price.currency, billing, label: billing === 'subscription' ? "Person's own plan" : "Person's API bill", basis: 'app-prices', estimated: true };
|
|
74
|
+
}
|
|
75
|
+
function aggregate(calls) {
|
|
76
|
+
const field = (key) => calls.every((call) => call.tokens[key] !== undefined) ? count(calls.reduce((total, call) => total + call.tokens[key], 0)) : undefined;
|
|
77
|
+
return normalized(field('input'), field('output'), field('cachedInput'), field('cacheWrite'), field('total'));
|
|
78
|
+
}
|
|
79
|
+
/** Every recorded attempt is a call; the host records retries separately under its run id. */
|
|
80
|
+
export function callLedger(options = {}) {
|
|
81
|
+
const store = options.store ?? memoryTokenLedgerStore();
|
|
82
|
+
return {
|
|
83
|
+
record(member, call) {
|
|
84
|
+
if (!text(member) || !call || ![call.provider, call.account, call.model, call.runId].every(text) || !time(call.time)
|
|
85
|
+
|| !['subscription', 'api'].includes(call.billing) || call.payer !== undefined && !text(call.payer)
|
|
86
|
+
|| call.durationMs !== undefined && (typeof call.durationMs !== 'number' || !Number.isFinite(call.durationMs) || call.durationMs < 0)
|
|
87
|
+
|| call.state !== undefined && !['completed', 'cancelled', 'failed'].includes(call.state) || call.limits !== undefined && !Array.isArray(call.limits))
|
|
88
|
+
throw new TokenLedgerError('invalid');
|
|
89
|
+
const tokens = normalizeTokens(call.provider, call.usage);
|
|
90
|
+
const cost = priceCall(tokens, options.prices?.[call.provider]?.[call.model], call.billing);
|
|
91
|
+
const limits = call.limits?.flatMap((window) => safeWindows(window.provider, [window]));
|
|
92
|
+
const result = { provider: call.provider, account: call.account, model: call.model, runId: call.runId, time: call.time,
|
|
93
|
+
billing: call.billing, payer: call.payer ?? member, state: call.state ?? 'completed', tokens,
|
|
94
|
+
...(call.durationMs === undefined ? {} : { durationMs: call.durationMs }), ...(cost ? { cost } : {}), ...(limits ? { limits } : {}) };
|
|
95
|
+
try {
|
|
96
|
+
store.record(member, { time: call.time, tokens: tokens.total ?? 0, call: copyCall(result) });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
throw new TokenLedgerError('store');
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
},
|
|
103
|
+
query(member, from, to) {
|
|
104
|
+
if (!text(member) || !time(from) || !time(to) || to < from)
|
|
105
|
+
throw new TokenLedgerError('invalid');
|
|
106
|
+
let entries;
|
|
107
|
+
try {
|
|
108
|
+
entries = store.query(member, from, to);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new TokenLedgerError('store');
|
|
112
|
+
}
|
|
113
|
+
if (!Array.isArray(entries))
|
|
114
|
+
throw new TokenLedgerError('invalid');
|
|
115
|
+
const calls = entries.filter((entry) => entry.call && entry.time >= from && entry.time < to).map((entry) => copyCall(entry.call)).sort((a, b) => a.time - b.time);
|
|
116
|
+
const costs = new Map();
|
|
117
|
+
for (const call of calls) {
|
|
118
|
+
if (!call.cost)
|
|
119
|
+
continue;
|
|
120
|
+
const key = `${call.cost.currency}\0${call.cost.billing}`;
|
|
121
|
+
const previous = costs.get(key);
|
|
122
|
+
costs.set(key, { ...call.cost, amount: (previous?.amount ?? 0) + call.cost.amount });
|
|
123
|
+
}
|
|
124
|
+
return { calls, tokens: aggregate(calls), costs: [...costs.values()], unpricedCalls: calls.filter((call) => !call.cost).length };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function copyCall(call) {
|
|
129
|
+
return { ...call, tokens: { ...call.tokens }, ...(call.cost ? { cost: { ...call.cost } } : {}),
|
|
130
|
+
...(call.limits ? { limits: call.limits.map((window) => ({ ...window })) } : {}) };
|
|
131
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { type Usage, type UsageOptions } from './types.ts';
|
|
2
2
|
export * from './types.ts';
|
|
3
|
+
export { callLedger, normalizeTokens, priceCall, type CallLedger, type CallInput, type CallRecord, type CallQuery, type NormalizedTokens, type ModelPrice, type PriceTable, type CallCost } from './calls.ts';
|
|
4
|
+
export { tokenLedger, memoryTokenLedgerStore, TokenLedgerError, type TokenLedger, type TokenLedgerStore, type TokenLedgerOptions, type TokenEntry, type TokenQuery } from './ledger.ts';
|
|
5
|
+
export { roomOf } from './room.ts';
|
|
6
|
+
export { fingerprint, store as fileUsageStore, memoryUsageStore } from './store.ts';
|
|
7
|
+
export { retryAfterMs, backoffDelayMs, memoryBackoffPolicy } from './backoff.ts';
|
|
3
8
|
export { claudeWindows, codexWindows, goWindows, zaiWindows, type CodexRateLimitResult } from './windows.ts';
|
|
9
|
+
export { codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from './quota.ts';
|
|
4
10
|
export { WORDS, words, usageWords, type WordKey } from './words.ts';
|
|
5
11
|
/** One reader owns per-account backoff and concurrent-read deduplication. */
|
|
6
12
|
export declare function usage(options: UsageOptions): Usage;
|