@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/dist/quota.js ADDED
@@ -0,0 +1,87 @@
1
+ // Protocol shapes informed by can1357/oh-my-pi (MIT), pinned at 2b023d1.
2
+ // This module normalizes quota fields only; it does not import upstream runtime code.
3
+ import { record, window, codexWindows } from "./windows.js";
4
+ const obj = (value) => record(value) ? value : {};
5
+ const rows = (value) => Array.isArray(value) ? value : [];
6
+ const number = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
7
+ const label = (value) => typeof value === 'string' ? value.replace(/[^\x20-\x7e]/g, ' ').trim().slice(0, 80) || undefined : undefined;
8
+ const time = (value) => typeof value === 'string' ? Date.parse(value) : number(value);
9
+ const ratio = (used, limit, remaining) => {
10
+ const cap = number(limit);
11
+ const consumed = number(used);
12
+ const left = number(remaining);
13
+ return cap !== undefined && cap > 0 && (consumed !== undefined || left !== undefined) ? 100 * (consumed ?? cap - left) / cap : undefined;
14
+ };
15
+ export function codexTokenWindows(raw) {
16
+ const source = obj(raw);
17
+ const plan = obj(source.rate_limit);
18
+ const group = (value, name) => ({ limitName: name,
19
+ ...Object.fromEntries(['primary', 'secondary'].map((key) => {
20
+ const w = obj(value[`${key}_window`]);
21
+ return [key, { usedPercent: w.used_percent, windowDurationMins: typeof w.limit_window_seconds === 'number' ? w.limit_window_seconds / 60 : undefined, resetsAt: w.reset_at }];
22
+ })) });
23
+ return codexWindows({ rateLimitsByLimitId: Object.fromEntries([
24
+ ['plan', group(plan, 'Codex')], ...rows(source.additional_rate_limits).map((v, i) => { const extra = obj(v); return [String(i), group(obj(extra.rate_limit), label(extra.limit_name))]; }),
25
+ ]) });
26
+ }
27
+ export function copilotWindows(raw) {
28
+ const source = obj(raw);
29
+ return Object.entries(obj(source.quota_snapshots)).flatMap(([key, value]) => {
30
+ const quota = obj(value);
31
+ if (quota.unlimited === true)
32
+ return [];
33
+ const left = number(quota.percent_remaining);
34
+ return window('copilot', 'monthly', left === undefined ? ratio(undefined, quota.entitlement, quota.remaining) : 100 - left, undefined, time(source.quota_reset_date), false, label(key));
35
+ });
36
+ }
37
+ export function grokWindows(raw) {
38
+ const config = obj(obj(raw).config);
39
+ const period = obj(config.currentPeriod);
40
+ const weekly = number(config.creditUsagePercent);
41
+ if (weekly !== undefined)
42
+ return window('grok', 'weekly', weekly, 10080, time(period.end));
43
+ return window('grok', 'monthly', ratio(config.used, config.monthlyLimit), undefined, time(config.periodEnd));
44
+ }
45
+ export function minimaxWindows(raw) {
46
+ const source = obj(raw);
47
+ if (obj(source.base_resp).status_code !== 0)
48
+ return [];
49
+ return rows(source.model_remains).flatMap((value) => {
50
+ const bucket = obj(value);
51
+ if (bucket.current_interval_status === 3 && bucket.current_weekly_status === 3 && bucket.current_interval_total_count === 0 && bucket.current_weekly_total_count === 0)
52
+ return [];
53
+ return ['interval', 'weekly'].flatMap((key) => {
54
+ const left = number(bucket[`current_${key}_remaining_percent`]);
55
+ const exhausted = bucket[`current_${key}_status`] === 2;
56
+ const rawReset = bucket[key === 'interval' ? 'end_time' : 'weekly_end_time'];
57
+ const reset = typeof rawReset === 'number' && rawReset > 0 ? rawReset < 1e12 ? rawReset * 1000 : rawReset : time(rawReset);
58
+ return window('minimax', key === 'weekly' ? 'weekly' : 'rolling', exhausted ? 100 : left === undefined ? undefined : 100 - left, key === 'weekly' ? 10080 : undefined, reset, exhausted, label(bucket.model_name));
59
+ });
60
+ });
61
+ }
62
+ export function geminiWindows(raw) {
63
+ return rows(obj(raw).buckets).flatMap((value) => {
64
+ const bucket = obj(value);
65
+ const left = number(bucket.remainingFraction);
66
+ return window('gemini', 'custom', left === undefined ? undefined : 100 - left * 100, undefined, time(bucket.resetTime), false, label(bucket.modelId));
67
+ });
68
+ }
69
+ export function kimiWindows(raw, nowMs) {
70
+ const source = obj(raw);
71
+ const read = (value, minutes, reset) => {
72
+ const detail = obj(value);
73
+ const units = { SECOND: 1 / 60, MINUTE: 1, HOUR: 60, DAY: 1440, WEEK: 10080 };
74
+ const w = obj(detail.window);
75
+ const duration = number(w.duration);
76
+ const m = minutes ?? (duration === undefined ? undefined : duration * (units[String(w.timeUnit).toUpperCase().replace(/S$/, '')] ?? NaN));
77
+ const row = record(detail.detail) ? detail.detail : detail;
78
+ const resetValue = reset ?? w.resetTime ?? row.resetTime ?? row.reset_at ?? row.resetAt;
79
+ let resetMs = time(resetValue);
80
+ if (typeof resetValue === 'number')
81
+ resetMs = resetValue > 1e12 ? resetValue : resetValue * 1000;
82
+ if (resetValue === undefined && number(row.reset_in) !== undefined)
83
+ resetMs = nowMs + number(row.reset_in) * 1000;
84
+ return window('kimi', m === 300 ? 'session' : m === 10080 ? 'weekly' : m === 43200 ? 'monthly' : 'custom', ratio(row.used, row.limit, row.remaining), m, resetMs);
85
+ };
86
+ return [...read(source.usage, 10080), ...read(source.totalQuota), ...rows(source.limits).flatMap((row) => read(row))];
87
+ }
package/dist/room.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { Reading, Room } from './types.ts';
2
+ /** Tightest known quota, valid for 24 hours. Refused/authless sources report unknown room. */
3
+ export declare function roomOf(reading: Reading, nowMs: number): Room;
package/dist/room.js ADDED
@@ -0,0 +1,11 @@
1
+ /** Tightest known quota, valid for 24 hours. Refused/authless sources report unknown room. */
2
+ export function roomOf(reading, nowMs) {
3
+ if (!Number.isFinite(reading.at) || !Number.isFinite(nowMs) || nowMs < reading.at || nowMs - reading.at > 86_400_000 || ['not-connected', 'expired', 'auth', 'no-plan'].includes(reading.code ?? ''))
4
+ return { left: 'unknown', at: reading.at };
5
+ const tight = reading.windows.reduce((worst, window) => Number.isFinite(window.usedPercent) && (!worst || window.usedPercent > worst.usedPercent) ? window : worst, undefined);
6
+ if (!tight)
7
+ return { left: 'unknown', at: reading.at };
8
+ const span = tight.kind === 'weekly' ? 'week' : tight.kind === 'monthly' ? 'month' : tight.kind === 'session' ? 'session' : 'tightest';
9
+ return { left: Math.max(0, Math.min(100, 100 - tight.usedPercent)), span, at: reading.at,
10
+ ...(Number.isFinite(tight.resetsAt) ? { resetsAt: tight.resetsAt } : {}) };
11
+ }
package/dist/store.d.ts CHANGED
@@ -1,12 +1,13 @@
1
- import type { Provider } from './types.ts';
2
- export interface Stored {
3
- at: number;
4
- raw: unknown;
5
- }
1
+ import type { Provider, StoredReading, UsageStore, Window } from './types.ts';
2
+ export type Stored = StoredReading;
6
3
  /** Bounded regular files only; do not follow credential or state symlinks. */
4
+ export declare function readJsonSnapshot(file: string, cap: number): {
5
+ value: unknown;
6
+ modified: number;
7
+ } | undefined;
7
8
  export declare function readJson(file: string, cap: number): unknown;
8
9
  export declare function fingerprint(salt: string): (provider: Provider, value: string) => string;
9
- export declare function store(stateDir: string): {
10
- get: (id: Provider, fp: string) => Stored | undefined;
11
- put(id: Provider, fp: string, reading: Stored): void;
12
- };
10
+ export declare function store(stateDir: string): UsageStore;
11
+ /** Public stores receive only these quota fields, never raw provider payloads. */
12
+ export declare function safeWindows(provider: Provider, raw: unknown): Window[];
13
+ export declare function memoryUsageStore(): UsageStore;
package/dist/store.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { createHash, randomUUID, scryptSync } from 'node:crypto';
2
2
  import { chmodSync, closeSync, fstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync, constants } from 'node:fs';
3
- import { join } from 'node:path';
3
+ import { isAbsolute, join } from 'node:path';
4
+ import { UsageError } from "./types.js";
4
5
  import { record } from "./windows.js";
5
6
  /** Bounded regular files only; do not follow credential or state symlinks. */
6
- export function readJson(file, cap) {
7
+ export function readJsonSnapshot(file, cap) {
7
8
  let fd;
8
9
  try {
9
10
  fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
@@ -18,7 +19,7 @@ export function readJson(file, cap) {
18
19
  break;
19
20
  length += count;
20
21
  }
21
- return length <= cap ? JSON.parse(body.subarray(0, length).toString('utf8')) : undefined;
22
+ return length <= cap ? { value: JSON.parse(body.subarray(0, length).toString('utf8')), modified: stat.mtimeMs } : undefined;
22
23
  }
23
24
  catch {
24
25
  return undefined;
@@ -28,6 +29,7 @@ export function readJson(file, cap) {
28
29
  closeSync(fd);
29
30
  }
30
31
  }
32
+ export function readJson(file, cap) { return readJsonSnapshot(file, cap)?.value; }
31
33
  export function fingerprint(salt) {
32
34
  const memo = new Map();
33
35
  return (provider, value) => {
@@ -43,20 +45,22 @@ export function fingerprint(salt) {
43
45
  };
44
46
  }
45
47
  export function store(stateDir) {
46
- const path = join(stateDir, 'plans-v1.json');
48
+ if (typeof stateDir !== 'string' || !isAbsolute(stateDir) || /[\0\r\n]/.test(stateDir))
49
+ throw new UsageError();
50
+ const path = join(stateDir, 'plans-v2.json');
47
51
  function load() {
48
52
  const saved = readJson(path, 256 * 1024);
49
53
  const plans = {};
50
54
  if (!record(saved) || !record(saved.plans))
51
55
  return plans;
52
- for (const id of ['claude', 'codex', 'opencode', 'zai']) {
56
+ for (const id of ['claude', 'codex', 'opencode', 'zai', 'copilot', 'grok', 'minimax', 'gemini', 'kimi']) {
53
57
  const entries = saved.plans[id];
54
58
  if (!record(entries))
55
59
  continue;
56
60
  const readings = Object.create(null);
57
61
  for (const [fp, r] of Object.entries(entries)) {
58
62
  if (/^[a-f0-9]{64}$/.test(fp) && record(r) && typeof r.at === 'number' && Number.isFinite(r.at))
59
- readings[fp] = { at: r.at, raw: r.raw };
63
+ readings[fp] = { at: r.at, windows: safeWindows(id, r.windows) };
60
64
  }
61
65
  plans[id] = readings;
62
66
  }
@@ -65,13 +69,15 @@ export function store(stateDir) {
65
69
  return {
66
70
  get: (id, fp) => load()[id]?.[fp],
67
71
  put(id, fp, reading) {
72
+ if (!/^[a-f0-9]{64}$/.test(fp) || !Number.isFinite(reading.at))
73
+ return;
68
74
  const temporary = `${path}.${randomUUID()}.tmp`;
69
75
  try {
70
76
  const plans = load();
71
77
  const entries = plans[id] ?? {};
72
78
  if (reading.at < (entries[fp]?.at ?? -Infinity))
73
79
  return;
74
- entries[fp] = reading;
80
+ entries[fp] = { at: reading.at, windows: safeWindows(id, reading.windows) };
75
81
  plans[id] = entries;
76
82
  const body = JSON.stringify({ plans });
77
83
  if (Buffer.byteLength(body) > 256 * 1024)
@@ -91,3 +97,21 @@ export function store(stateDir) {
91
97
  },
92
98
  };
93
99
  }
100
+ /** Public stores receive only these quota fields, never raw provider payloads. */
101
+ export function safeWindows(provider, raw) {
102
+ return (Array.isArray(raw) ? raw : []).slice(0, 64).flatMap((value) => {
103
+ if (!record(value) || !['session', 'weekly', 'monthly', 'rolling', 'custom'].includes(String(value.kind)) || typeof value.usedPercent !== 'number' || !Number.isFinite(value.usedPercent))
104
+ return [];
105
+ return [{ provider, kind: value.kind, usedPercent: Math.max(0, Math.min(100, value.usedPercent)),
106
+ ...(typeof value.minutes === 'number' && Number.isFinite(value.minutes) && value.minutes > 0 ? { minutes: value.minutes } : {}),
107
+ ...(typeof value.resetsAt === 'number' && Number.isFinite(value.resetsAt) ? { resetsAt: value.resetsAt } : {}),
108
+ ...(value.limited === true ? { limited: true } : {}),
109
+ ...(typeof value.limit === 'string' && value.limit.length <= 80 ? { limit: value.limit } : {}) }];
110
+ });
111
+ }
112
+ export function memoryUsageStore() {
113
+ const readings = new Map();
114
+ return { get: (provider, account) => { const r = readings.get(`${provider}\0${account}`); return r ? { at: r.at, windows: safeWindows(provider, r.windows) } : undefined; },
115
+ put: (provider, account, reading) => { const key = `${provider}\0${account}`; if (reading.at >= (readings.get(key)?.at ?? -Infinity))
116
+ readings.set(key, { at: reading.at, windows: safeWindows(provider, reading.windows) }); } };
117
+ }
package/dist/types.d.ts CHANGED
@@ -1,18 +1,44 @@
1
- export type Provider = 'claude' | 'codex' | 'opencode' | 'zai';
2
- /** Claude's credential adapter belongs to the host; only its parser ships here. */
1
+ export type Provider = 'claude' | 'codex' | 'opencode' | 'zai' | 'copilot' | 'grok' | 'minimax' | 'gemini' | 'kimi';
2
+ /** Host-owned identity makes readings survive token renewal. Tokens never become stored identities. */
3
+ type Identity = {
4
+ accountId?: string;
5
+ };
3
6
  export type Source = {
4
7
  provider: 'codex';
5
8
  bin: string;
6
9
  home: string;
7
10
  env?: Record<string, string>;
8
11
  } | {
9
- provider: 'opencode';
10
- key: string;
11
- } | {
12
- provider: 'zai';
12
+ provider: 'codex';
13
+ access: string;
14
+ accountId: string;
15
+ } | ({
16
+ provider: 'claude';
17
+ access: string;
18
+ accountUuid?: string;
19
+ } & Identity) | ({
20
+ provider: 'claude';
21
+ accountUuid: string;
22
+ read: ClaudeReader;
23
+ connected?: () => boolean;
24
+ }) | {
25
+ provider: 'claude';
26
+ credentialsFile: string;
27
+ configFile?: string;
28
+ statuslineFile?: string;
29
+ } | ({
30
+ provider: 'opencode' | 'zai';
13
31
  key: string;
14
- };
32
+ } & Identity) | ({
33
+ provider: 'copilot' | 'grok' | 'minimax' | 'kimi';
34
+ access: string;
35
+ } & Identity) | ({
36
+ provider: 'gemini';
37
+ access: string;
38
+ project?: string;
39
+ } & Identity);
15
40
  export type Kind = 'session' | 'weekly' | 'monthly' | 'rolling' | 'custom';
41
+ /** All reset times are epoch milliseconds in 0.2.0. */
16
42
  export type Window = {
17
43
  provider: Provider;
18
44
  kind: Kind;
@@ -29,11 +55,46 @@ export type Reading = {
29
55
  at: number;
30
56
  code?: Code;
31
57
  };
58
+ export type Room = {
59
+ left: number;
60
+ span: 'session' | 'week' | 'month' | 'tightest';
61
+ resetsAt?: number;
62
+ at: number;
63
+ } | {
64
+ left: 'unknown';
65
+ at?: number;
66
+ };
32
67
  export type ReadOptions = {
33
68
  nowMs?: number;
34
69
  };
70
+ export type SourceAnswer = {
71
+ raw?: unknown;
72
+ code?: Code;
73
+ retryAfterMs?: number;
74
+ };
75
+ /** The app owns credential reads/refresh and sends its own requests through this seam. */
76
+ export type ClaudeReader = (options: {
77
+ nowMs: number;
78
+ signal: AbortSignal;
79
+ }) => Promise<SourceAnswer>;
80
+ export type StoredReading = {
81
+ at: number;
82
+ windows: Window[];
83
+ };
84
+ export interface UsageStore {
85
+ get(provider: Provider, account: string): StoredReading | undefined;
86
+ put(provider: Provider, account: string, reading: StoredReading): void;
87
+ }
88
+ export interface BackoffPolicy {
89
+ get(provider: Provider, account: string): number | undefined;
90
+ set(provider: Provider, account: string, untilMs: number): void;
91
+ /** Retry-After duration to wait; default at least five minutes. */
92
+ delayMs?(retryAfterMs: number | undefined): number;
93
+ }
35
94
  export type UsageOptions = {
36
- stateDir: string;
95
+ stateDir?: string;
96
+ store?: UsageStore;
97
+ backoff?: BackoffPolicy;
37
98
  salt?: string;
38
99
  fetch?: typeof fetch;
39
100
  now?: () => number;
@@ -42,6 +103,7 @@ export interface Usage {
42
103
  read(source: Source, options?: ReadOptions): Promise<Reading>;
43
104
  lastKnown(source: Source, options?: ReadOptions): Reading | undefined;
44
105
  connected(source: Source): boolean;
106
+ /** Stable identity fingerprint, absent for an opaque token without a host-supplied id. */
45
107
  account(source: Source): string | undefined;
46
108
  }
47
109
  export declare class UsageError extends Error {
@@ -49,3 +111,4 @@ export declare class UsageError extends Error {
49
111
  name: string;
50
112
  constructor();
51
113
  }
114
+ export {};
package/dist/windows.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import type { Window } from './types.ts';
1
+ import type { Kind, Provider, Window } from './types.ts';
2
2
  export declare const record: (v: unknown) => v is Record<string, unknown>;
3
+ export declare function window(provider: Provider, kind: Kind, used: unknown, minutes?: number, resetsAt?: number, limited?: boolean, limit?: string): Window[];
3
4
  /** Accepts both the statusline envelope and the provider's usage payload. */
4
5
  export declare function claudeWindows(raw: unknown): Window[];
5
6
  /** Accepts the `usage` member; monthly length is deliberately absent. */
package/dist/windows.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export const record = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
2
2
  const obj = (v) => record(v) ? v : {};
3
- function window(provider, kind, used, minutes, resetsAt, limited = false, limit) {
3
+ export function window(provider, kind, used, minutes, resetsAt, limited = false, limit) {
4
4
  if (typeof used !== 'number' || !Number.isFinite(used))
5
5
  return [];
6
6
  return [{ provider, kind, usedPercent: Math.max(0, Math.min(100, used)),
@@ -12,7 +12,7 @@ export function claudeWindows(raw) {
12
12
  const source = obj(obj(raw).rate_limits ?? raw);
13
13
  return ['five_hour', 'seven_day'].flatMap((id) => {
14
14
  const w = obj(source[id]);
15
- return window('claude', id === 'five_hour' ? 'session' : 'weekly', Number.isFinite(w.utilization) ? w.utilization : w.used_percentage, id === 'five_hour' ? 300 : 10080, typeof w.resets_at === 'number' ? w.resets_at : Date.parse(String(w.resets_at)) / 1000);
15
+ return window('claude', id === 'five_hour' ? 'session' : 'weekly', Number.isFinite(w.utilization) ? w.utilization : w.used_percentage, id === 'five_hour' ? 300 : 10080, typeof w.resets_at === 'number' ? w.resets_at * 1000 : Date.parse(String(w.resets_at)));
16
16
  });
17
17
  }
18
18
  /** Accepts the `usage` member; monthly length is deliberately absent. */
@@ -21,7 +21,7 @@ export function goWindows(raw) {
21
21
  const w = obj(obj(raw)[kind]);
22
22
  if (typeof w.percent !== 'number' || w.percent < 0 || !['ok', 'rate-limited'].includes(String(w.status)))
23
23
  return [];
24
- return window('opencode', kind, w.percent, kind === 'monthly' ? undefined : kind === 'weekly' ? 10080 : 300, Date.parse(String(w.resetsAt)) / 1000, w.status === 'rate-limited');
24
+ return window('opencode', kind, w.percent, kind === 'monthly' ? undefined : kind === 'weekly' ? 10080 : 300, Date.parse(String(w.resetsAt)), w.status === 'rate-limited');
25
25
  });
26
26
  }
27
27
  /** Accepts the `data.limits` member; unknown bucket sizes are skipped. */
@@ -31,7 +31,7 @@ export function zaiWindows(raw) {
31
31
  const key = `${w.unit}:${w.number}`;
32
32
  if (key !== '3:5' && key !== '6:1')
33
33
  return [];
34
- return window('zai', key === '3:5' ? 'session' : 'weekly', w.percentage, key === '3:5' ? 300 : 10080, Number(w.nextResetTime) / 1000);
34
+ return window('zai', key === '3:5' ? 'session' : 'weekly', w.percentage, key === '3:5' ? 300 : 10080, typeof w.nextResetTime === 'number' ? w.nextResetTime : undefined);
35
35
  });
36
36
  }
37
37
  export function codexWindows(result) {
@@ -45,7 +45,7 @@ export function codexWindows(result) {
45
45
  const rows = ['primary', 'secondary'].flatMap((key) => {
46
46
  const w = obj(raw[key]);
47
47
  const m = typeof w.windowDurationMins === 'number' && w.windowDurationMins > 0 && Number.isFinite(w.windowDurationMins) ? w.windowDurationMins : undefined;
48
- return window('codex', m === 300 ? 'session' : m === 10080 ? 'weekly' : m === 43200 ? 'monthly' : 'custom', w.usedPercent, m, typeof w.resetsAt === 'number' ? w.resetsAt : undefined, false, name.toLowerCase() === 'codex' ? undefined : name);
48
+ return window('codex', m === 300 ? 'session' : m === 10080 ? 'weekly' : m === 43200 ? 'monthly' : 'custom', w.usedPercent, m, typeof w.resetsAt === 'number' ? w.resetsAt * 1000 : undefined, false, name.toLowerCase() === 'codex' ? undefined : name);
49
49
  }).sort((a, b) => (a.minutes ?? 0) - (b.minutes ?? 0));
50
50
  return rows.length ? [rows] : [];
51
51
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/usage",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Read subscription usage windows per provider and account.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",