@byokit/usage 0.4.0 → 0.6.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,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.6.0 (2026-10-01)
6
+
7
+ - FIX: Claude subscription quota snapshots can now be read through an identity-free ephemeral host callback without credentials or a fabricated account UUID; readings never enter a cache or shared store, and retry state stays local to the source.
8
+
9
+ ## 0.5.0 (2026-10-01)
10
+
11
+ - Add a React Native entry for local call/token ledgers and pure quota helpers; subscription and API key (billed per use) attribution is preserved, with estimates only from app prices.
12
+
5
13
  ## 0.4.0 (2026-10-01)
6
14
 
7
15
 
@@ -9,6 +17,8 @@
9
17
  - SECURITY: Claude subscription usage may read credentials only from app-managed folders under the passed stateDir, without refresh or credential writes; default logins and folder escapes are refused, tokens stay in one request and never enter output, errors, logs or stored readings.
10
18
  - Share the bounded Codex app-server client between identity and subscription usage reads.
11
19
  - Apply shared poll health, scoped quota and hard-limit semantics to managed Claude usage, including cancellable host pacing.
20
+ - Add host lane/route attribution and member-scoped per-run token queries to `callLedger`, sharing existing limits and app-owned cap policy.
21
+ - Accept OpenClaw run usage with separate cache buckets alongside accounts and decide results, without provider calls or logging. Subscription attribution defaults on; API key (billed per use) attribution stays explicit and labelled.
12
22
 
13
23
  ## 0.3.0 (2026-10-01)
14
24
 
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # @byokit/usage
2
2
 
3
3
  Read subscription quota windows per provider and per account on Node 22.18 or later.
4
+ React Native also supports local call/token accounting and pure quota parsing.
4
5
  The app owns sign-in, token renewal, account labels and selection. The kit reads room
5
6
  left, estimates no cost and never rotates an account.
6
7
 
@@ -38,6 +39,26 @@ Sources:
38
39
  host reader's optional HTTP origin for pacing. It has a ten-second deadline, with the signal
39
40
  aborted at expiry. The optional synchronous `connected()` hook controls whether
40
41
  last-good readings remain visible; exceptions count as disconnected.
42
+ - `{ provider: 'claude', ephemeral: true, read, connected? }` supports a host-owned
43
+ opaque snapshot stream without an account UUID, email, folder or token. The same
44
+ `ClaudeReader` answer and ten-second cancellable deadline apply. For a statusline
45
+ body, return `{ raw: snapshot, at: snapshot.fetched_at }` when the host knows that
46
+ `fetched_at` is epoch milliseconds; otherwise omit `at` for unknown age. The kit
47
+ normalizes `rate_limits` and quota rows; it never opens a snapshot or credential
48
+ file, discovers credentials, copies tokens or makes a fallback request.
49
+ `connected()` should reflect the host's sign-in status; false or a thrown error
50
+ returns `not-connected`. An absent/invalid snapshot returns `incomplete`, while
51
+ timeout/cancellation returns `unavailable`; the host can report other safe codes.
52
+ `account()` and `lastKnown()` always return undefined. Successful readings are
53
+ never retained, so each subsequent read invokes the callback again. Only retry
54
+ metadata and concurrent operations are weakly held per source object in this
55
+ reader: reuse an immutable source for one stream, and replace it when the host
56
+ changes sign-in or stream. Failures never return earlier quota figures.
57
+ Rate limits honor Retry-After with a five-minute default; transient failures use
58
+ exponential backoff from one minute, capped at one hour. `backoff.delayMs` may
59
+ customize these delays with a one-minute minimum. Store and backoff get/set
60
+ hooks and account-based pacing are never called; the host owns any pacing in
61
+ its callback. No state is shared between source objects or reader instances.
41
62
 
42
63
  `read(source, { nowMs?, signal? })` returns `{ provider, windows, at?, limited?, poll?, code? }`.
43
64
  `at` is source observation time; it is absent when unavailable. `poll` contains
@@ -242,8 +263,11 @@ or store exception text. Entries are counts only, never sign-in tokens.
242
263
 
243
264
  `callLedger({ store?, prices? })` records runtime model calls through the same
244
265
  `TokenLedgerStore` seam. `record(member, { provider, account, model, runId, time,
245
- billing, usage?, payer?, durationMs?, state?, limits? })` returns and stores one
246
- `CallRecord`. `billing` is `subscription` or `api`; `payer` defaults to the member.
266
+ billing?, usage?, usageFormat?, lane?, route?, payer?, durationMs?, state?, limits? })`
267
+ returns and stores one `CallRecord`. `billing` defaults to `subscription`; passing
268
+ `api` explicitly attributes an API key (billed per use) call. Every record carries
269
+ `billingLabel: "Person's own plan" | "Person's API bill"`, even without a price
270
+ estimate. `payer` defaults to the member.
247
271
  `state` is `completed` (default), `cancelled` or `failed`. The host records each
248
272
  actual model call, including retries, and supplies the provider's final usage when
249
273
  available. No missing counts are inferred from words or decision sub-answers.
@@ -278,6 +302,73 @@ calls with unknown total counts, member/day/week results expose `unknownCalls`,
278
302
  policy remain the host's. All times, durations and quota reset timestamps are
279
303
  milliseconds. There is no transport, credential discovery or automatic rotation.
280
304
 
305
+ Record host lanes and routes with optional app-supplied `lane` and `route` fields.
306
+ `runs(member, from, to)` returns a `RunQuery` per run in first-call order;
307
+ `queryRun(member, runId, from, to)` returns one run. Each result contains `runId`,
308
+ time-sorted `calls` (including lane, route, model and limits), aggregate `tokens`,
309
+ `costs` and `unpricedCalls`, using the same `[from, to)` bounds as `query`.
310
+ An absent run returns no calls and zero tokens. A run's totals cover only calls
311
+ inside the requested range; pass the run's full time range for its complete total.
312
+ Retries and multiple routes/models are added under the app's run id. Members remain
313
+ separate even when run ids match. Missing counts stay unknown in run totals, and
314
+ the shared member ledger continues to withhold remaining allowance when needed.
315
+
316
+ Pass a result with a `usage` field directly, or pass just its usage. For accounts'
317
+ Messages result and decide's reported answer usage, the default provider format
318
+ handles native `input_tokens`/`output_tokens` counts. For OpenClaw `RunEnd`, pass
319
+ `usageFormat: 'openclaw'`: its `input` excludes `cacheRead`/`cacheWrite`, so the kit
320
+ adds those buckets once and preserves reported output and total. Reasoning is
321
+ already part of output and never added again. Missing/inconsistent usage stays
322
+ partial/unknown; engine cost estimates, raw answers and secrets are discarded.
323
+ `normalizeTokens(provider, result, 'openclaw')` exposes the same pure conversion.
324
+
325
+ ```ts
326
+ import { callLedger, memoryTokenLedgerStore, tokenLedger } from '@byokit/usage';
327
+ import type { RunEnd } from '@byokit/openclaw';
328
+ import type { AnthropicResult } from '@byokit/accounts';
329
+ import type { Answer } from '@byokit/decide';
330
+
331
+ const store = memoryTokenLedgerStore();
332
+ const calls = callLedger({ store });
333
+ // The app supplies its member policy, run identity and selected lane/route/model.
334
+ const limits = tokenLedger({ store, cap: (member) => member === 'member-one' ? 50_000 : undefined });
335
+ const context = {
336
+ provider: 'anthropic', account: 'non-secret-account-id', model: 'selected-model',
337
+ lane: 'host', route: 'anthropic-cli', runId: 'run-one',
338
+ };
339
+ declare const end: RunEnd; // Returned by the kit's existing run; no extra request.
340
+ if (end.ok) {
341
+ calls.record('member-one', { ...context, time: Date.now(), usage: end, usageFormat: 'openclaw' });
342
+ }
343
+
344
+ declare const answer: Answer;
345
+ // Record once per actual backend invocation, not once per question or cache hit.
346
+ // The host selected this API-billed backend only after the person's opt-in.
347
+ if (answer.source === 'api') {
348
+ calls.record('member-one', { ...context, route: 'decision', billing: 'api',
349
+ time: Date.now(), usage: answer });
350
+ }
351
+ declare const messages: AnthropicResult;
352
+ // API key (billed per use); consent and the original request belong to the app.
353
+ calls.record('member-one', { ...context, route: 'anthropic', billing: 'api',
354
+ time: Date.now(), usage: messages });
355
+
356
+ declare const runStartedAt: number;
357
+ const run = calls.queryRun('member-one', 'run-one', runStartedAt, Date.now() + 1);
358
+ const history = calls.runs('member-one', runStartedAt, Date.now() + 1);
359
+ const allowance = limits.query('member-one', runStartedAt, Date.now() + 1);
360
+ ```
361
+
362
+ The host records either an OpenClaw aggregate result or its individual calls, never
363
+ both. Decide can attach one invocation's usage to several answers; record it once,
364
+ and skip `source: 'cache'` answers. String-only accounts/answerer results carry no
365
+ counts and remain unknown; the ledger makes no recovery requests. Model, provider,
366
+ lane and route describe the actual execution and are supplied by the app; there is
367
+ no fallback to an API-billed route. Per-run counts stay on device, in memory by
368
+ default. A custom store must keep them on device and apply the app's retention
369
+ policy. The kit never logs counts, sends telemetry or stores the raw result.
370
+ Iteration budgets, stopping rules, consent and presentation belong to the app.
371
+
281
372
  When passing normalized windows to `@byokit/accounts`' structural helper, use
282
373
  `roomOf(reading.windows, reading.at, 'milliseconds')`. Its two-argument form is for
283
374
  legacy reset seconds; normalized usage windows in 0.2.0+ already use milliseconds.
@@ -287,3 +378,56 @@ that the accounts chooser accepts directly. Preserve the original measurement ti
287
378
  `identity(codexSource)` shares the app-server transport, calls `account/read` with a 15-second deadline, never opens a credential file, and returns only `{signedIn,email?,plan?}`. Managed-folder HTTP usage carries only the app-passed headers plus Bearer authorization and JSON accept; it uses the same bounded HTTP transport.
288
379
 
289
380
  Managed-folder Claude usage uses the shared poll-health and normalized quota pipeline, including scoped hard blocks, unknown usage, last-good observation times, account retry policies and cancellable host origin pacing.
381
+
382
+ The token and call ledgers accept host-supplied entries/results; they are not
383
+ harness-log or ccusage readers. A bounded incremental log source (including file
384
+ offsets, rotation and bounded work per update) remains outstanding. This snapshot
385
+ source does not implement it or scan real logs.
386
+
387
+ ## React Native
388
+
389
+ The `react-native` condition of `@byokit/usage` selects a portable entry. The explicit
390
+ `@byokit/usage/react-native` subpath selects the same API when a bundler does not
391
+ use export conditions. It needs no native module, Node shim, credentials or network.
392
+ The default Node entry and browser resolution are unchanged.
393
+
394
+ ```ts
395
+ import { callLedger, tokenLedger, memoryTokenLedgerStore } from '@byokit/usage/react-native';
396
+ const store = memoryTokenLedgerStore(); // Replace with an app-owned synchronous durable store.
397
+ const calls = callLedger({ store });
398
+ const tokens = tokenLedger({ store, cap: 10_000 });
399
+ const time = Date.now();
400
+ calls.record('member-1', {
401
+ provider: 'openai', account: 'app-account', model: 'app-model', runId: 'run-1',
402
+ time, billing: 'api', lane: 'host-lane', route: 'host-route',
403
+ usage: { input_tokens: 12, output_tokens: 8 },
404
+ });
405
+ const daily = tokens.query('member-1', time, time + 1);
406
+ const history = calls.query('member-1', time, time + 1);
407
+ const runs = calls.runs('member-1', time, time + 1);
408
+ const run = calls.queryRun('member-1', 'run-1', time, time + 1);
409
+ ```
410
+
411
+ This entry exports `callLedger`, `tokenLedger`, `memoryTokenLedgerStore`,
412
+ `TokenLedgerError`, `normalizeTokens`, `priceCall`, all quota parsers listed above,
413
+ `codexHardLimit`, `roomOf` and the words helpers, with their corresponding types
414
+ (including `RunQuery`). `callLedger` supports the same `runs`/`queryRun` methods and
415
+ host-supplied lane/route attribution as the Node entry.
416
+ The app supplies provider usage and quota payloads; `usage()`, credential/file
417
+ adapters, `identity()`, fingerprints and disk quota stores remain Node-only.
418
+
419
+ Counts retain reported/partial/unknown provenance. Missing counts remain unknown;
420
+ unknown calls suppress a positive remaining allowance. Subscription and API key
421
+ (billed per use) calls retain their separate billing attribution. Cost is absent
422
+ unless a matching app-owned price table supplies an estimate, labelled
423
+ “Person's own plan” or “Person's API bill”; no provider prices are invented and a
424
+ subscription quota is never converted into an API charge.
425
+
426
+ The offline consumer fixture in `test/rn-fixture.ts` exercises the built package's
427
+ React Native export. After building, run
428
+ `BYOKIT_HERMES=/absolute/path/to/hermes sh scripts/test.sh 'packages/usage/test/react-native.test.ts'`
429
+ from the repository root to execute it in a Hermes CLI VM. Without that optional
430
+ binary, the same contract runs in a sandbox without Node globals; the Hermes check
431
+ is skipped. The standalone fixture uses the locked Expo Babel preset's `hermes-v0`
432
+ profile to lower classes for legacy Hermes CLI VMs. This is VM qualification, not
433
+ an Expo SDK runtime, emulator or native UI test.
package/dist/calls.d.ts CHANGED
@@ -31,8 +31,14 @@ export interface CallInput {
31
31
  account: string;
32
32
  model: string;
33
33
  runId: string;
34
+ /** App-selected host lane and runtime route; never inferred from a model id. */
35
+ lane?: string;
36
+ route?: string;
34
37
  time: number;
35
- billing: 'subscription' | 'api';
38
+ /** Subscription by default; API key (billed per use) attribution is explicit. */
39
+ billing?: 'subscription' | 'api';
40
+ /** OpenClaw reports input separately from cache reads/writes. */
41
+ usageFormat?: 'provider' | 'openclaw';
36
42
  /** Native provider usage/envelope, or normalized input/output/cachedInput/cacheWrite/total counts. */
37
43
  usage?: unknown;
38
44
  payer?: string;
@@ -40,7 +46,9 @@ export interface CallInput {
40
46
  state?: 'completed' | 'cancelled' | 'failed';
41
47
  limits?: readonly Window[];
42
48
  }
43
- export interface CallRecord extends Omit<CallInput, 'usage' | 'limits'> {
49
+ export interface CallRecord extends Omit<CallInput, 'usage' | 'limits' | 'billing' | 'usageFormat'> {
50
+ billing: 'subscription' | 'api';
51
+ billingLabel: "Person's own plan" | "Person's API bill";
44
52
  tokens: NormalizedTokens;
45
53
  state: 'completed' | 'cancelled' | 'failed';
46
54
  cost?: CallCost;
@@ -56,9 +64,16 @@ export interface CallQuery {
56
64
  export interface CallLedger {
57
65
  record(member: string, call: CallInput): CallRecord;
58
66
  query(member: string, from: number, to: number): CallQuery;
67
+ /** All runs with recorded calls in [from, to), in first-call order. */
68
+ runs(member: string, from: number, to: number): RunQuery[];
69
+ /** One member's run, restricted to [from, to). Empty runs have zero totals. */
70
+ queryRun(member: string, runId: string, from: number, to: number): RunQuery;
71
+ }
72
+ export interface RunQuery extends CallQuery {
73
+ runId: string;
59
74
  }
60
75
  /** Pure normalization of reported counts, never estimates from text or shared decision invocations. */
61
- export declare function normalizeTokens(provider: string, raw: unknown): NormalizedTokens;
76
+ export declare function normalizeTokens(provider: string, raw: unknown, format?: 'provider' | 'openclaw'): NormalizedTokens;
62
77
  /** App-supplied price estimates only, including explicit billing attribution. */
63
78
  export declare function priceCall(tokens: NormalizedTokens, price: ModelPrice | undefined, billing: ModelPrice['billing']): CallCost | undefined;
64
79
  /** Every recorded attempt is a call; the host records retries separately under its run id. */
package/dist/calls.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { record as isRecord } from "./windows.js";
2
2
  import { memoryTokenLedgerStore, TokenLedgerError } from "./ledger.js";
3
- import { safeWindows } from "./store.js";
3
+ import { safeWindows } from "./safe-windows.js";
4
4
  const object = (value) => isRecord(value) ? value : {};
5
5
  const count = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
6
6
  const text = (value) => typeof value === 'string' && !!value && value.length <= 1024 && !/[\0\r\n]/.test(value);
@@ -20,11 +20,20 @@ function normalized(input, output, cachedInput, cacheWrite, total) {
20
20
  provenance: input !== undefined && output !== undefined && derived !== undefined ? 'reported' : [input, output, cachedInput, cacheWrite, measured].some((v) => v !== undefined) ? 'partial' : 'unknown' };
21
21
  }
22
22
  /** Pure normalization of reported counts, never estimates from text or shared decision invocations. */
23
- export function normalizeTokens(provider, raw) {
23
+ export function normalizeTokens(provider, raw, format = 'provider') {
24
24
  const envelope = object(raw);
25
25
  const usage = object(envelope.usage ?? envelope.usageMetadata ?? raw);
26
26
  if (usage.provenance === 'unknown' || usage.provenance === 'estimated')
27
27
  return { provenance: 'unknown' };
28
+ if (format === 'openclaw') {
29
+ if (!['input', 'output', 'cacheRead', 'cacheWrite', 'total'].some((key) => count(usage[key]) !== undefined))
30
+ return { provenance: 'unknown' };
31
+ const uncached = count(usage.input);
32
+ const cached = count(usage.cacheRead) ?? (usage.cacheRead === undefined ? 0 : undefined);
33
+ const written = count(usage.cacheWrite) ?? (usage.cacheWrite === undefined ? 0 : undefined);
34
+ const input = uncached !== undefined && cached !== undefined && written !== undefined ? sum(uncached, cached, written) : undefined;
35
+ return normalized(input, count(usage.output), cached, written, count(usage.total));
36
+ }
28
37
  if (['input', 'output', 'total'].some((key) => key in usage))
29
38
  return normalized(count(usage.input), count(usage.output), count(usage.cachedInput), count(usage.cacheWrite), count(usage.total));
30
39
  if (provider === 'claude' || provider === 'anthropic') {
@@ -76,21 +85,37 @@ function aggregate(calls) {
76
85
  const field = (key) => calls.every((call) => call.tokens[key] !== undefined) ? count(calls.reduce((total, call) => total + call.tokens[key], 0)) : undefined;
77
86
  return normalized(field('input'), field('output'), field('cachedInput'), field('cacheWrite'), field('total'));
78
87
  }
88
+ function summarize(calls) {
89
+ const costs = new Map();
90
+ for (const call of calls) {
91
+ if (!call.cost)
92
+ continue;
93
+ const key = `${call.cost.currency}\0${call.cost.billing}`;
94
+ const previous = costs.get(key);
95
+ costs.set(key, { ...call.cost, amount: (previous?.amount ?? 0) + call.cost.amount });
96
+ }
97
+ return { calls, tokens: aggregate(calls), costs: [...costs.values()], unpricedCalls: calls.filter((call) => !call.cost).length };
98
+ }
79
99
  /** Every recorded attempt is a call; the host records retries separately under its run id. */
80
100
  export function callLedger(options = {}) {
81
101
  const store = options.store ?? memoryTokenLedgerStore();
82
- return {
102
+ const ledger = {
83
103
  record(member, call) {
84
104
  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)
105
+ || call.billing !== undefined && !['subscription', 'api'].includes(call.billing) || call.payer !== undefined && !text(call.payer)
106
+ || call.lane !== undefined && !text(call.lane) || call.route !== undefined && !text(call.route)
107
+ || call.usageFormat !== undefined && !['provider', 'openclaw'].includes(call.usageFormat)
86
108
  || call.durationMs !== undefined && (typeof call.durationMs !== 'number' || !Number.isFinite(call.durationMs) || call.durationMs < 0)
87
109
  || call.state !== undefined && !['completed', 'cancelled', 'failed'].includes(call.state) || call.limits !== undefined && !Array.isArray(call.limits))
88
110
  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);
111
+ const billing = call.billing ?? 'subscription';
112
+ const tokens = normalizeTokens(call.provider, call.usage, call.usageFormat);
113
+ const cost = priceCall(tokens, options.prices?.[call.provider]?.[call.model], billing);
91
114
  const limits = call.limits?.flatMap((window) => safeWindows(window.provider, [window]));
92
115
  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,
116
+ billing, billingLabel: billing === 'subscription' ? "Person's own plan" : "Person's API bill",
117
+ payer: call.payer ?? member, state: call.state ?? 'completed', tokens,
118
+ ...(call.lane === undefined ? {} : { lane: call.lane }), ...(call.route === undefined ? {} : { route: call.route }),
94
119
  ...(call.durationMs === undefined ? {} : { durationMs: call.durationMs }), ...(cost ? { cost } : {}), ...(limits ? { limits } : {}) };
95
120
  try {
96
121
  store.record(member, { time: call.time, tokens: tokens.total ?? 0, call: copyCall(result) });
@@ -113,19 +138,27 @@ export function callLedger(options = {}) {
113
138
  if (!Array.isArray(entries))
114
139
  throw new TokenLedgerError('invalid');
115
140
  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 });
141
+ return summarize(calls);
142
+ },
143
+ runs(member, from, to) {
144
+ const groups = new Map();
145
+ for (const call of ledger.query(member, from, to).calls) {
146
+ const calls = groups.get(call.runId) ?? [];
147
+ calls.push(call);
148
+ groups.set(call.runId, calls);
123
149
  }
124
- return { calls, tokens: aggregate(calls), costs: [...costs.values()], unpricedCalls: calls.filter((call) => !call.cost).length };
150
+ return [...groups].map(([runId, calls]) => ({ runId, ...summarize(calls) }));
151
+ },
152
+ queryRun(member, runId, from, to) {
153
+ if (!text(runId))
154
+ throw new TokenLedgerError('invalid');
155
+ return { runId, ...summarize(ledger.query(member, from, to).calls.filter((call) => call.runId === runId)) };
125
156
  },
126
157
  };
158
+ return ledger;
127
159
  }
128
160
  function copyCall(call) {
129
- return { ...call, tokens: { ...call.tokens }, ...(call.cost ? { cost: { ...call.cost } } : {}),
161
+ return { ...call, billingLabel: call.billing === 'subscription' ? "Person's own plan" : "Person's API bill",
162
+ tokens: { ...call.tokens }, ...(call.cost ? { cost: { ...call.cost } } : {}),
130
163
  ...(call.limits ? { limits: call.limits.map((window) => ({ ...window })) } : {}) };
131
164
  }
@@ -0,0 +1,3 @@
1
+ import type { EphemeralClaudeSource, Reading, ReadOptions, UsageOptions } from './types.ts';
2
+ /** Only retry/concurrency metadata survives a call; source objects are weakly held. */
3
+ export declare function ephemeralClaude(options: UsageOptions): (source: EphemeralClaudeSource, clock: number, opts?: ReadOptions) => Promise<Reading>;
@@ -0,0 +1,64 @@
1
+ import { customClaude } from "./providers.js";
2
+ import { safeWindows } from "./safe-windows.js";
3
+ import { claudeWindows } from "./windows.js";
4
+ /** Only retry/concurrency metadata survives a call; source objects are weakly held. */
5
+ export function ephemeralClaude(options) {
6
+ const states = new WeakMap();
7
+ return async (source, clock, opts) => {
8
+ const empty = (poll) => ({ provider: 'claude', windows: [], code: poll.outcome === 'ok' ? 'unavailable' : poll.outcome, poll });
9
+ let connected = true;
10
+ try {
11
+ connected = source.connected?.() ?? true;
12
+ }
13
+ catch {
14
+ connected = false;
15
+ }
16
+ if (!connected) {
17
+ states.delete(source);
18
+ return empty({ at: clock, outcome: 'not-connected' });
19
+ }
20
+ let state = states.get(source);
21
+ if (!state) {
22
+ state = { failures: 0 };
23
+ states.set(source, state);
24
+ }
25
+ if (state.pending)
26
+ return state.pending;
27
+ if (state.poll?.retryAt !== undefined && clock < state.poll.retryAt)
28
+ return empty(state.poll);
29
+ const current = state;
30
+ const task = (async () => {
31
+ const answer = await customClaude(source, clock, { signal: opts?.signal });
32
+ const rows = safeWindows('claude', claudeWindows(answer.raw));
33
+ const limited = !answer.code && answer.limited === true;
34
+ const code = answer.code ?? (!rows.length && !limited ? 'incomplete' : undefined);
35
+ const poll = { at: clock, outcome: code ?? 'ok' };
36
+ if (code && ['rate-limited', 'refresh-failed', 'unavailable', 'incomplete'].includes(code)) {
37
+ current.failures = current.poll?.outcome === code ? current.failures + 1 : 1;
38
+ const retry = typeof answer.retryAfterMs === 'number' && Number.isFinite(answer.retryAfterMs) && answer.retryAfterMs >= 0 ? answer.retryAfterMs : undefined;
39
+ let delay = code === 'rate-limited' ? 300_000 : Math.min(3_600_000, 60_000 * 2 ** Math.min(current.failures - 1, 6));
40
+ try {
41
+ const selected = options.backoff?.delayMs?.(retry, { outcome: code, failures: current.failures });
42
+ if (selected !== undefined && Number.isFinite(selected) && selected >= 0)
43
+ delay = selected;
44
+ }
45
+ catch { /* internal retry policy stands */ }
46
+ poll.retryAt = clock + Math.max(60_000, delay, retry ?? 0);
47
+ }
48
+ else
49
+ current.failures = 0;
50
+ current.poll = poll;
51
+ if (code)
52
+ return empty(poll);
53
+ const at = typeof answer.at === 'number' && Number.isFinite(answer.at) ? answer.at : undefined;
54
+ return { provider: 'claude', windows: rows, ...(at !== undefined ? { at } : {}), ...(limited ? { limited: true } : {}), poll };
55
+ })();
56
+ current.pending = task;
57
+ try {
58
+ return await task;
59
+ }
60
+ finally {
61
+ current.pending = undefined;
62
+ }
63
+ };
64
+ }
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export type { Identity } from './identity.ts';
6
6
  export declare function identity(source: Extract<Source, {
7
7
  bin: string;
8
8
  }>): Promise<Identity>;
9
- export { callLedger, normalizeTokens, priceCall, type CallLedger, type CallInput, type CallRecord, type CallQuery, type NormalizedTokens, type ModelPrice, type PriceTable, type CallCost } from './calls.ts';
9
+ export { callLedger, normalizeTokens, priceCall, type CallLedger, type CallInput, type CallRecord, type CallQuery, type RunQuery, type NormalizedTokens, type ModelPrice, type PriceTable, type CallCost } from './calls.ts';
10
10
  export { tokenLedger, memoryTokenLedgerStore, TokenLedgerError, type TokenLedger, type TokenLedgerStore, type TokenLedgerOptions, type TokenEntry, type TokenQuery } from './ledger.ts';
11
11
  export { roomOf } from './room.ts';
12
12
  export { fingerprint, store as fileUsageStore, memoryUsageStore } from './store.ts';
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { codexHardLimit, codexTokenWindows, copilotWindows, grokWindows, minimax
8
8
  import { UsageError } from "./types.js";
9
9
  import { codexIdentity } from "./identity.js";
10
10
  import { claudeUsage as managedClaudeUsage, claudeCredential, managedClaudeFolder } from "./claude.js";
11
+ import { ephemeralClaude } from "./ephemeral.js";
11
12
  export * from "./types.js";
12
13
  /** Identity runs only the named binary, never opens a credential file. */
13
14
  export async function identity(source) {
@@ -29,6 +30,8 @@ const validText = (v) => typeof v === 'string' && !v.includes('\0') && !/[\r\n]/
29
30
  function validate(source, stateDir) {
30
31
  if (!record(source) || !providers.includes(source.provider))
31
32
  throw new UsageError();
33
+ if ('ephemeral' in source && (source.ephemeral !== true || source.provider !== 'claude' || typeof source.read !== 'function' || ['accountUuid', 'accountId', 'origin', 'folder', 'credentialsFile', 'access', 'key', 'bin', 'home', 'configFile', 'statuslineFile'].some((field) => field in source)))
34
+ throw new UsageError();
32
35
  if ('folder' in source) {
33
36
  if (source.provider !== 'claude' || !validText(source.folder) || !isAbsolute(source.folder) || !stateDir || !managedClaudeFolder(source.folder, stateDir))
34
37
  throw new UsageError();
@@ -40,7 +43,7 @@ function validate(source, stateDir) {
40
43
  throw new UsageError();
41
44
  }
42
45
  else if ('read' in source) {
43
- if (source.provider !== 'claude' || typeof source.read !== 'function' || !validText(source.accountUuid) || !source.accountUuid || source.connected !== undefined && typeof source.connected !== 'function')
46
+ if (source.provider !== 'claude' || typeof source.read !== 'function' || !('ephemeral' in source) && (!validText(source.accountUuid) || !source.accountUuid) || source.connected !== undefined && typeof source.connected !== 'function')
44
47
  throw new UsageError();
45
48
  }
46
49
  else if ('bin' in source) {
@@ -98,6 +101,7 @@ export function usage(options) {
98
101
  const attempts = new Map();
99
102
  const failures = new Map();
100
103
  const now = (opts) => opts?.nowMs ?? (options.now ?? Date.now)();
104
+ const readEphemeral = ephemeralClaude(options);
101
105
  function connected(source) {
102
106
  validate(source, options.stateDir);
103
107
  if ('folder' in source)
@@ -133,7 +137,7 @@ export function usage(options) {
133
137
  else if ('credentialsFile' in source)
134
138
  id = claudeAuth(source)?.account;
135
139
  else if ('read' in source)
136
- id = source.accountUuid;
140
+ id = 'ephemeral' in source ? undefined : source.accountUuid;
137
141
  else if ('bin' in source) {
138
142
  const raw = readJson(join(source.home, 'auth.json'), 64 * 1024);
139
143
  const tokens = record(raw) && record(raw.tokens) ? raw.tokens : undefined;
@@ -165,6 +169,9 @@ export function usage(options) {
165
169
  }
166
170
  }
167
171
  function lastKnown(source, opts) {
172
+ validate(source, options.stateDir);
173
+ if ('ephemeral' in source)
174
+ return undefined;
168
175
  if (!connected(source))
169
176
  return undefined;
170
177
  const id = identity(source);
@@ -187,6 +194,8 @@ export function usage(options) {
187
194
  async function read(source, opts) {
188
195
  validate(source, options.stateDir);
189
196
  const clock = now(opts);
197
+ if ('ephemeral' in source)
198
+ return readEphemeral(source, clock, opts);
190
199
  const empty = (code) => ({ provider: source.provider, windows: [], code, poll: { at: clock, outcome: code ?? 'unavailable' } });
191
200
  if (!connected(source))
192
201
  return empty('not-connected');
@@ -26,7 +26,7 @@ export declare function customClaude(source: Extract<Source, {
26
26
  read: unknown;
27
27
  }>, nowMs: number, pacing?: {
28
28
  hook?: PacingHook;
29
- account: string;
29
+ account?: string;
30
30
  signal?: AbortSignal;
31
31
  }): Promise<Answer>;
32
32
  export declare function codexUsage(source: Extract<Source, {
package/dist/providers.js CHANGED
@@ -114,7 +114,7 @@ export async function customClaude(source, nowMs, pacing) {
114
114
  const timer = setTimeout(abort, 10_000);
115
115
  try {
116
116
  const operation = async () => {
117
- if (source.origin && pacing?.hook)
117
+ if ('origin' in source && source.origin && pacing?.hook && pacing.account !== undefined)
118
118
  await pacing.hook({ provider: source.provider, account: pacing.account, origin: source.origin, signal: controller.signal });
119
119
  if (controller.signal.aborted)
120
120
  return { code: 'unavailable' };
package/dist/rn.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /** Local usage accounting and host-supplied quota parsing; no credential or network access. */
2
+ export { callLedger, normalizeTokens, priceCall, type CallLedger, type CallInput, type CallRecord, type CallQuery, type RunQuery, type NormalizedTokens, type ModelPrice, type PriceTable, type CallCost } from './calls.ts';
3
+ export { tokenLedger, memoryTokenLedgerStore, TokenLedgerError, type TokenLedger, type TokenLedgerStore, type TokenLedgerOptions, type TokenEntry, type TokenQuery } from './ledger.ts';
4
+ export { roomOf } from './room.ts';
5
+ export { claudeWindows, codexWindows, goWindows, zaiWindows, type CodexRateLimitResult } from './windows.ts';
6
+ export { codexHardLimit, codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from './quota.ts';
7
+ export type { Provider, Kind, Window, Scope, Poll, Freshness, Code, Reading, Room } from './types.ts';
8
+ export { WORDS, words, usageWords, type WordKey } from './words.ts';
package/dist/rn.js ADDED
@@ -0,0 +1,7 @@
1
+ /** Local usage accounting and host-supplied quota parsing; no credential or network access. */
2
+ export { callLedger, normalizeTokens, priceCall } from "./calls.js";
3
+ export { tokenLedger, memoryTokenLedgerStore, TokenLedgerError } from "./ledger.js";
4
+ export { roomOf } from "./room.js";
5
+ export { claudeWindows, codexWindows, goWindows, zaiWindows } from "./windows.js";
6
+ export { codexHardLimit, codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from "./quota.js";
7
+ export { WORDS, words, usageWords } from "./words.js";
@@ -0,0 +1,3 @@
1
+ import type { Provider, Window } from './types.ts';
2
+ /** Public stores receive only these quota fields, never raw provider payloads. */
3
+ export declare function safeWindows(provider: Provider, raw: unknown): Window[];
@@ -0,0 +1,14 @@
1
+ import { record, quotaScope } from "./windows.js";
2
+ /** Public stores receive only these quota fields, never raw provider payloads. */
3
+ export function safeWindows(provider, raw) {
4
+ return (Array.isArray(raw) ? raw : []).slice(0, 64).flatMap((value) => {
5
+ if (!record(value) || !['session', 'weekly', 'monthly', 'rolling', 'custom'].includes(String(value.kind)) || value.usedPercent !== undefined && (typeof value.usedPercent !== 'number' || !Number.isFinite(value.usedPercent)))
6
+ return [];
7
+ return [{ provider, kind: value.kind, ...(typeof value.usedPercent === 'number' ? { usedPercent: Math.max(0, Math.min(100, value.usedPercent)) } : {}),
8
+ ...(quotaScope(value.scope) ? { scope: quotaScope(value.scope) } : {}),
9
+ ...(typeof value.minutes === 'number' && Number.isFinite(value.minutes) && value.minutes > 0 ? { minutes: value.minutes } : {}),
10
+ ...(typeof value.resetsAt === 'number' && Number.isFinite(value.resetsAt) ? { resetsAt: value.resetsAt } : {}),
11
+ ...(value.limited === true ? { limited: true } : {}),
12
+ ...(typeof value.limit === 'string' && value.limit.length <= 80 ? { limit: value.limit } : {}) }];
13
+ });
14
+ }
package/dist/store.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Provider, StoredReading, UsageStore, Window, Poll } from './types.ts';
1
+ import type { Provider, StoredReading, UsageStore, Poll } from './types.ts';
2
+ export { safeWindows } from './safe-windows.ts';
2
3
  export type Stored = StoredReading;
3
4
  /** Bounded regular files only; do not follow credential or state symlinks. */
4
5
  export declare function readJsonSnapshot(file: string, cap: number): {
@@ -10,6 +11,4 @@ export declare function fingerprint(salt: string): (provider: Provider, value: s
10
11
  /** Poll metadata is distinct from the observation timestamp. */
11
12
  export declare function safePoll(raw: unknown): Poll | undefined;
12
13
  export declare function store(stateDir: string): UsageStore;
13
- /** Public stores receive only these quota fields, never raw provider payloads. */
14
- export declare function safeWindows(provider: Provider, raw: unknown): Window[];
15
14
  export declare function memoryUsageStore(): UsageStore;
package/dist/store.js CHANGED
@@ -2,7 +2,9 @@ import { createHash, randomUUID, scryptSync } from 'node:crypto';
2
2
  import { chmodSync, closeSync, fstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync, constants } from 'node:fs';
3
3
  import { isAbsolute, join } from 'node:path';
4
4
  import { UsageError } from "./types.js";
5
- import { record, quotaScope } from "./windows.js";
5
+ import { record } from "./windows.js";
6
+ import { safeWindows } from "./safe-windows.js";
7
+ export { safeWindows } from "./safe-windows.js";
6
8
  /** Bounded regular files only; do not follow credential or state symlinks. */
7
9
  export function readJsonSnapshot(file, cap) {
8
10
  let fd;
@@ -103,19 +105,6 @@ export function store(stateDir) {
103
105
  },
104
106
  };
105
107
  }
106
- /** Public stores receive only these quota fields, never raw provider payloads. */
107
- export function safeWindows(provider, raw) {
108
- return (Array.isArray(raw) ? raw : []).slice(0, 64).flatMap((value) => {
109
- if (!record(value) || !['session', 'weekly', 'monthly', 'rolling', 'custom'].includes(String(value.kind)) || value.usedPercent !== undefined && (typeof value.usedPercent !== 'number' || !Number.isFinite(value.usedPercent)))
110
- return [];
111
- return [{ provider, kind: value.kind, ...(typeof value.usedPercent === 'number' ? { usedPercent: Math.max(0, Math.min(100, value.usedPercent)) } : {}),
112
- ...(quotaScope(value.scope) ? { scope: quotaScope(value.scope) } : {}),
113
- ...(typeof value.minutes === 'number' && Number.isFinite(value.minutes) && value.minutes > 0 ? { minutes: value.minutes } : {}),
114
- ...(typeof value.resetsAt === 'number' && Number.isFinite(value.resetsAt) ? { resetsAt: value.resetsAt } : {}),
115
- ...(value.limited === true ? { limited: true } : {}),
116
- ...(typeof value.limit === 'string' && value.limit.length <= 80 ? { limit: value.limit } : {}) }];
117
- });
118
- }
119
108
  export function memoryUsageStore() {
120
109
  const readings = new Map();
121
110
  return { get: (provider, account) => { const r = readings.get(`${provider}\0${account}`); return r ? { at: r.at, windows: safeWindows(provider, r.windows), ...(r.limited ? { limited: true } : {}), ...(safePoll(r.poll) ? { poll: safePoll(r.poll) } : {}) } : undefined; },
package/dist/types.d.ts CHANGED
@@ -29,7 +29,7 @@ export type Source = {
29
29
  read: ClaudeReader;
30
30
  origin?: string;
31
31
  connected?: () => boolean;
32
- }) | {
32
+ }) | EphemeralClaudeSource | {
33
33
  provider: 'claude';
34
34
  credentialsFile: string;
35
35
  configFile?: string;
@@ -106,6 +106,13 @@ export type ClaudeReader = (options: {
106
106
  nowMs: number;
107
107
  signal: AbortSignal;
108
108
  }) => Promise<SourceAnswer>;
109
+ /** Reuse one source object per host-owned snapshot stream; no identity or last-good cache. */
110
+ export type EphemeralClaudeSource = {
111
+ provider: 'claude';
112
+ ephemeral: true;
113
+ read: ClaudeReader;
114
+ connected?: () => boolean;
115
+ };
109
116
  export type StoredReading = {
110
117
  at?: number;
111
118
  windows: Window[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/usage",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Read subscription usage windows per provider and account.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -14,12 +14,20 @@
14
14
  },
15
15
  "exports": {
16
16
  ".": {
17
+ "react-native": {
18
+ "types": "./dist/rn.d.ts",
19
+ "default": "./dist/rn.js"
20
+ },
17
21
  "types": "./dist/index.d.ts",
18
22
  "default": "./dist/index.js"
19
23
  },
20
24
  "./testing": {
21
25
  "types": "./dist/testing/index.d.ts",
22
26
  "default": "./dist/testing/index.js"
27
+ },
28
+ "./react-native": {
29
+ "types": "./dist/rn.d.ts",
30
+ "default": "./dist/rn.js"
23
31
  }
24
32
  },
25
33
  "files": [