@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 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 usage windows per provider and per account on Node 22.18 or later. Crewhouse can show each member's room, v1 design can meter a payer's plan, and fleet apps can compare account readings. The app chooses an account and supplies labels; this kit makes no choice and estimates no cost.
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, bin: '/app/bin/codex', home: '/app/sign-ins/alice' };
13
+ const source = { provider: 'codex' as const, access: token, accountId: account.id };
9
14
  const reading = await reader.read(source);
10
- for (const window of reading.windows) console.log(window.kind, 100 - window.usedPercent);
15
+ const room = roomOf(reading, Date.now());
11
16
  ```
12
17
 
13
- The Node-only `.` entry exports `usage`, `UsageError`, types, `claudeWindows`, `codexWindows`, `goWindows`, `zaiWindows`, `words`, and `usageWords`. There is no portable entry and no dependency on accounts. `./testing` exports `fakeFetch`, `fakeCodex`, and `usageContract(make, { test? })` (or a test function directly).
18
+ Sources:
14
19
 
15
- Sources are `{ provider: 'codex', bin, home, env? }`, `{ provider: 'opencode', key }`, or `{ provider: 'zai', key }`. Paths must be absolute; removed or empty keys mean disconnected. Claude plan-token adapters stay in the host: the host's own Source reads its payload and calls `claudeWindows(raw)`. This package opens no Claude files, sends no Claude headers, and refreshes no sign-in.
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? })` resolves `{ provider, windows, at, code? }`. Remaining room is `100 - usedPercent`; reset times are provider epoch seconds, even outside a plausible clock-display range. `lastKnown(source, { nowMs? })` returns the stored reading only within 24 hours while connected. `connected(source)` and `account(source)` are synchronous; the latter is a salted fingerprint for a host's collection memo. Only bad sources reject with `UsageError` (`code: 'bad-source'`). Failures retain a last-good reading and its original timestamp, with a code explaining the failed update. A good read has no code. Reads have a 60-second floor, per-account concurrent deduplication and a 429 backoff of at least five minutes. `UsageOptions.now` supplies the default clock; a per-call clock overrides it. An injected `fetch` wins; otherwise global fetch is resolved on each read.
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
- Parsers consume Claude's usage/statusline payload, the Go `usage` member, the Z.ai `data.limits` member, or the Codex app-server result (`CodexRateLimitResult`). They carry provider names, normalized kind, percent used, optional duration, reset time, separate limit name and limited flag. Codex windows preserve critical-limit ordering and the eight-window cap. Raw endpoint dialects stay behind this typed surface.
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
- Isolation: the kit reads only the sign-in folder the app passes and spawns only the Codex binary the app passes by absolute path. It discovers no paths and reads no environment variables. The spawn uses an argv array and an environment built from nothing plus the host's `env` and `CODEX_HOME=home`; pass PATH and HOME explicitly when the binary needs them. Tokens never enter logs, errors or readings. Maps hold fingerprints, never raw secrets. The kit reads only `home/auth.json` for Codex identity and writes only `stateDir/plans-v1.json` (0700 directory, 0600 file, atomic rename, 256 KB cap). `salt` defaults to `byokit/usage/account`; a migrating host may preserve its previous salt and stateDir. Legacy stores from before per-account storage are not supported.
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
- The undocumented provider endpoints are fixed to `https://opencode.ai/zen/go/v1/usage` and `https://api.z.ai/api/monitor/usage/quota/limit`, with Bearer keys passed by the host, `accept: application/json`, and redirects rejected. HTTP reads have a 10-second deadline and 64 KB body cap. Codex runs `app-server`, initializes the client and calls `account/rateLimits/read`, with a 20-second deadline, 64 KB stdout cap and SIGTERM followed by SIGKILL after one second. No telemetry or credential write-back.
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
- Tests run the contract with recorded, sanitized payloads and fakes only, behind the repository's egress guard. Real endpoints and real sign-in folders are never run by the suite. `usageContract` takes a bench with `usage`, `source`, `expected`, `nowMs`, `restart`, optional `cleanup`, and optional scripted `fake` (`fail`, `disconnect`, `calls`). Scripted-failure cases skip without the fake seam.
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;
@@ -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
+ }
@@ -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;