@vibe-cafe/vibe-usage 0.10.34 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,11 +51,24 @@ npx @vibe-cafe/vibe-usage reset --local # Delete this host's data only and re-u
51
51
  npx @vibe-cafe/vibe-usage skill # Install skill for AI coding assistants
52
52
  npx @vibe-cafe/vibe-usage skill --remove # Remove installed skills
53
53
  npx @vibe-cafe/vibe-usage status # Config, detected tools, and what each tool has uploaded so far
54
+ npx @vibe-cafe/vibe-usage quota discover --json # Detect subscription-quota products locally
55
+ npx @vibe-cafe/vibe-usage quota fetch --product kimi-code --product zcode --product grok --json # Fetch only selected quotas
54
56
  npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short version)
55
57
  ```
56
58
 
57
59
  </details>
58
60
 
61
+ ## Subscription Quotas
62
+
63
+ The versioned `quota` JSON contract is designed for local desktop clients. `discover` only checks ordinary app, config-directory, and executable presence signals: it does not open credentials or use the network. `fetch` invokes only products explicitly named with `--product`, and one provider failure does not prevent results for the others.
64
+
65
+ - **Kimi Code** reads the official Kimi CLI OAuth file (`$KIMI_SHARE_DIR/credentials/kimi-code.json`, otherwise `~/.kimi/credentials/kimi-code.json`) and calls `https://api.kimi.com/coding/v1/usages`. When the short-lived access token is close to expiry, it uses Kimi's standard OAuth refresh flow and atomically rotates the official credential with owner-only permissions. Refreshes are serialized across Vibe Usage processes and re-check the file before writing so a concurrent Kimi CLI refresh wins safely.
66
+ - **ZCode / GLM Coding Plan** accepts only a caller-supplied regional key: `BIGMODEL_API_KEY` calls the domestic `https://open.bigmodel.cn/api/monitor/usage/quota/limit`, while the existing `Z_AI_API_KEY` keeps using `https://api.z.ai/api/monitor/usage/quota/limit`. If both are present, the explicitly named BigModel key wins. It does not read ZCode's private OAuth state.
67
+ - **Grok** reads at most the final 2 MiB of the official CLI's ordinary `$GROK_HOME/logs/unified.jsonl` (default `~/.grok/logs/unified.jsonl`). It accepts only the structured `billing: fetched credits config` event and projects the current utilization, period bounds, subscription tier, and event timestamp. It performs no network request, reads no credentials, and never returns or retains other log fields.
68
+ - **Cursor** is detected independently but remains non-fetchable until an official or stable quota protocol is available. Quota monitoring does not read Cursor's login token/database, browser cookies, another app's Keychain, network traffic, or UI.
69
+
70
+ Quota results are never uploaded or added to incremental sync state. Credential-backed providers may use the disposable cache at `~/.vibe-usage/quota-cache.json`; it contains normalized meters only, is scoped to a one-way hash of the active credential, rejects expired windows, and has a seven-day hard expiry. Grok's local-log result is not cached. No credential is stored in that cache, logs, or command output; the only credential write is Kimi's standard token rotation back to Kimi's own credential file.
71
+
59
72
  ## Supported Tools
60
73
 
61
74
  | Tool | Data Location |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.34",
3
+ "version": "0.11.0",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { writeFileSync, readFileSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
3
- import { join, dirname, win32 as winPath } from 'node:path';
3
+ import { join, win32 as winPath, posix as posixPath } from 'node:path';
4
4
  import { homedir, platform } from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { success, failure, warn, dim } from './output.js';
@@ -41,8 +41,9 @@ export function isNpxCachePath(binPath) {
41
41
  * falls back to pinning the path and warning, as before).
42
42
  */
43
43
  export function npxLauncher(nodePath, exists = existsSync, os = platform()) {
44
- const nodeDir = dirname(nodePath);
45
- const npxPath = join(nodeDir, os === 'win32' ? 'npx.cmd' : 'npx');
44
+ const paths = os === 'win32' ? winPath : posixPath;
45
+ const nodeDir = paths.dirname(nodePath);
46
+ const npxPath = paths.join(nodeDir, os === 'win32' ? 'npx.cmd' : 'npx');
46
47
  return exists(npxPath) ? { mode: 'npx', npxPath, nodeDir } : null;
47
48
  }
48
49
 
package/src/index.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import { dim as dimText, failure, hint, smallHeader, warn } from './output.js';
12
12
  import { loadState } from './state.js';
13
13
  import { fetchAccount } from './api.js';
14
+ import { COLLECTOR_VERSION } from './client-meta.js';
14
15
 
15
16
  function printSmallHeader() {
16
17
  console.log();
@@ -298,6 +299,8 @@ const FULL_HELP = `
298
299
  ${BARE} skill Install skill for AI coding tools
299
300
  ${BARE} skill --remove Remove installed skills
300
301
  ${BARE} status Show config and detected tools
302
+ ${BARE} quota discover --json Detect subscription-quota products locally
303
+ ${BARE} quota fetch --product <id> --json Fetch only selected subscription quotas
301
304
  ${BARE} config show Show full config as JSON
302
305
  ${BARE} config get <key> Get a config value
303
306
  ${BARE} config set <key> <value> Set a config value
@@ -307,6 +310,7 @@ const FULL_HELP = `
307
310
  ${BARE} config roots Show added data roots as JSON
308
311
  ${BARE} help Show the short help
309
312
  ${BARE} help --all Show this full list
313
+ ${BARE} --version Print the installed CLI version
310
314
  `;
311
315
 
312
316
  export async function run(rawArgs) {
@@ -358,6 +362,16 @@ export async function run(rawArgs) {
358
362
  await runSummary(args.slice(1));
359
363
  break;
360
364
  }
365
+ case 'quota': {
366
+ const { runQuota } = await import('./quotas/index.js');
367
+ try {
368
+ await runQuota(args.slice(1));
369
+ } catch (error) {
370
+ console.error(error?.message || String(error));
371
+ process.exitCode = 1;
372
+ }
373
+ break;
374
+ }
361
375
  case 'reset': {
362
376
  printSmallHeader();
363
377
  if (args.includes('--host')) hint('reset --host 已改名 reset --local,旧写法仍可用');
@@ -399,6 +413,11 @@ export async function run(rawArgs) {
399
413
  handleConfig(args.slice(1));
400
414
  break;
401
415
  }
416
+ case '--version':
417
+ case '-v': {
418
+ console.log(COLLECTOR_VERSION);
419
+ break;
420
+ }
402
421
  case 'status': {
403
422
  await showStatus();
404
423
  break;
@@ -384,6 +384,19 @@ export async function parse({ extraRoots = [] } = {}) {
384
384
  const roots = getClaudeRoots({
385
385
  onWarning: (message) => addWarning(ctx, message),
386
386
  extraRoots,
387
+ }).filter((root) => {
388
+ // On Windows readdir("file/projects") can return ENOENT rather than
389
+ // ENOTDIR. Validate the root first so that an invalid store cannot look
390
+ // like a successful empty scan and allow incremental state to be pruned.
391
+ try {
392
+ if (statSync(root).isDirectory()) return true;
393
+ addWarning(ctx, `Claude Code: cannot read directory ${root}: not a directory`);
394
+ } catch (err) {
395
+ if (err?.code !== 'ENOENT') {
396
+ addWarning(ctx, `Claude Code: cannot read directory ${root}: ${err.message}`);
397
+ }
398
+ }
399
+ return false;
387
400
  });
388
401
  const projectGroups = collectCandidates(roots, 'projects', ctx);
389
402
  const projectSessionIds = new Set();
@@ -0,0 +1,91 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { normalizeMeter, quotaResult } from './schema.js';
6
+
7
+ const CACHE_VERSION = 1;
8
+ const MAX_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
9
+
10
+ function cachePath(environment = process.env) {
11
+ const root = environment.VIBE_USAGE_QUOTA_CACHE_DIR?.trim()
12
+ || join(homedir(), '.vibe-usage');
13
+ return join(root, 'quota-cache.json');
14
+ }
15
+
16
+ function loadDocument(environment) {
17
+ try {
18
+ const parsed = JSON.parse(readFileSync(cachePath(environment), 'utf8'));
19
+ if (!isRecord(parsed) || parsed.version !== CACHE_VERSION || !isRecord(parsed.products)) return null;
20
+ return parsed;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function isRecord(value) {
27
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
28
+ }
29
+
30
+ export function attachCacheScope(result, secret) {
31
+ const scope = createHash('sha256').update(`${result.id}\0${secret}`).digest('hex');
32
+ Object.defineProperty(result, 'cacheScope', { value: scope, enumerable: false });
33
+ return result;
34
+ }
35
+
36
+ export function loadCachedQuota(id, scope, environment = process.env, now = new Date()) {
37
+ if (!scope) return null;
38
+ const raw = loadDocument(environment)?.products?.[id];
39
+ if (!raw || raw.scope !== scope || raw.status !== 'ok' || !Array.isArray(raw.meters)) return null;
40
+ try {
41
+ const dataAsOf = new Date(raw.dataAsOf || raw.fetchedAt);
42
+ if (Number.isNaN(dataAsOf.getTime()) || now.getTime() - dataAsOf.getTime() > MAX_CACHE_AGE_MS) {
43
+ return null;
44
+ }
45
+ const meters = raw.meters.map(normalizeMeter).filter(meter => {
46
+ if (meter.resetsAt) return new Date(meter.resetsAt) > now;
47
+ if (meter.windowSeconds) {
48
+ return dataAsOf.getTime() + meter.windowSeconds * 1000 > now.getTime();
49
+ }
50
+ return true;
51
+ });
52
+ if (!meters.length) return null;
53
+ return quotaResult({
54
+ ...raw,
55
+ id,
56
+ status: 'ok',
57
+ meters,
58
+ source: 'cache',
59
+ fetchedAt: raw.fetchedAt,
60
+ dataAsOf: raw.dataAsOf || raw.fetchedAt,
61
+ });
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export function saveCachedQuota(result, scope, environment = process.env) {
68
+ if (result?.status !== 'ok' || !scope) return;
69
+ try {
70
+ // All disposable-cache work belongs inside the failure boundary, including
71
+ // path resolution and updating a document recovered from disk.
72
+ const path = cachePath(environment);
73
+ const document = loadDocument(environment) || { version: CACHE_VERSION, products: {} };
74
+ document.products[result.id] = {
75
+ id: result.id,
76
+ status: 'ok',
77
+ meters: result.meters,
78
+ planLabel: result.planLabel,
79
+ fetchedAt: result.fetchedAt,
80
+ dataAsOf: result.dataAsOf,
81
+ scope,
82
+ };
83
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
84
+ const temporary = `${path}.${process.pid}.tmp`;
85
+ writeFileSync(temporary, `${JSON.stringify(document)}\n`, { encoding: 'utf8', mode: 0o600 });
86
+ renameSync(temporary, path);
87
+ } catch {
88
+ // The cache is disposable. A read-only home must not turn a successful
89
+ // provider response into a CLI failure.
90
+ }
91
+ }
@@ -0,0 +1,35 @@
1
+ import { discoverQuotaProducts, fetchQuotaProducts } from './registry.js';
2
+
3
+ function fail(message) {
4
+ throw new Error(message);
5
+ }
6
+
7
+ function parseFetchArguments(args) {
8
+ const products = [];
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const argument = args[index];
11
+ if (argument === '--json') continue;
12
+ if (argument !== '--product') fail(`Unknown quota fetch option: ${argument}`);
13
+ const value = args[index + 1];
14
+ if (!value || value.startsWith('--')) fail('Option --product requires a value.');
15
+ products.push(value);
16
+ index += 1;
17
+ }
18
+ if (!products.length) fail('quota fetch requires at least one --product.');
19
+ return products;
20
+ }
21
+
22
+ export async function runQuota(args) {
23
+ const subcommand = args[0];
24
+ if (subcommand === 'discover') {
25
+ const unknown = args.slice(1).filter(argument => argument !== '--json');
26
+ if (unknown.length) fail(`Unknown quota discover option: ${unknown[0]}`);
27
+ console.log(JSON.stringify(discoverQuotaProducts()));
28
+ return;
29
+ }
30
+ if (subcommand === 'fetch') {
31
+ console.log(JSON.stringify(await fetchQuotaProducts(parseFetchArguments(args.slice(1)))));
32
+ return;
33
+ }
34
+ fail(`Unknown quota subcommand: ${subcommand || '(none)'}`);
35
+ }
@@ -0,0 +1,151 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { quotaResult } from '../schema.js';
5
+
6
+ const PRODUCT_ID = 'grok';
7
+ const DEFAULT_MAX_LOG_BYTES = 2 * 1024 * 1024;
8
+
9
+ export function grokBillingLogPath(environment = process.env, home = homedir()) {
10
+ const configured = environment.GROK_HOME?.trim();
11
+ let root = configured || join(home, '.grok');
12
+ if (root === '~') root = home;
13
+ else if (root.startsWith('~/') || root.startsWith('~\\')) root = join(home, root.slice(2));
14
+ return join(root, 'logs', 'unified.jsonl');
15
+ }
16
+
17
+ function readLogTail(path, maxBytes = DEFAULT_MAX_LOG_BYTES) {
18
+ const descriptor = openSync(path, 'r');
19
+ try {
20
+ const size = fstatSync(descriptor).size;
21
+ const length = Math.min(size, maxBytes);
22
+ if (length <= 0) return '';
23
+ const buffer = Buffer.allocUnsafe(length);
24
+ const offset = size - length;
25
+ let total = 0;
26
+ while (total < length) {
27
+ const count = readSync(descriptor, buffer, total, length - total, offset + total);
28
+ if (count <= 0) break;
29
+ total += count;
30
+ }
31
+ let text = buffer.subarray(0, total).toString('utf8');
32
+ // A bounded tail may begin in the middle of a UTF-8 JSON line. Drop only
33
+ // that incomplete line; the CLI emits this billing snapshot repeatedly.
34
+ if (offset > 0) {
35
+ const newline = text.indexOf('\n');
36
+ text = newline < 0 ? '' : text.slice(newline + 1);
37
+ }
38
+ return text;
39
+ } finally {
40
+ closeSync(descriptor);
41
+ }
42
+ }
43
+
44
+ function finiteNumber(value) {
45
+ const number = typeof value === 'number' ? value : Number(value);
46
+ return Number.isFinite(number) ? number : null;
47
+ }
48
+
49
+ function date(value) {
50
+ if (typeof value !== 'string' || !value.trim()) return null;
51
+ const parsed = new Date(value);
52
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
53
+ }
54
+
55
+ function periodLabel(type, seconds) {
56
+ if (type === 'USAGE_PERIOD_TYPE_DAILY') return '1d';
57
+ if (type === 'USAGE_PERIOD_TYPE_WEEKLY') return '7d';
58
+ if (type === 'USAGE_PERIOD_TYPE_MONTHLY') return 'Month';
59
+ const days = seconds / 86_400;
60
+ return Number.isInteger(days) && days > 0 ? `${days}d` : 'Credits';
61
+ }
62
+
63
+ /**
64
+ * Reads only Grok CLI's structured, non-secret billing status event. Other log
65
+ * messages are parsed only far enough to reject them and are never returned,
66
+ * cached, logged, or uploaded by Vibe Usage.
67
+ */
68
+ export function parseGrokBillingLog(text, now = new Date()) {
69
+ if (typeof text !== 'string' || !text) return null;
70
+ const lines = text.split(/\r?\n/);
71
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
72
+ const line = lines[index].trim();
73
+ if (!line) continue;
74
+ let event;
75
+ try {
76
+ event = JSON.parse(line);
77
+ } catch {
78
+ continue;
79
+ }
80
+ if (event?.msg !== 'billing: fetched credits config') continue;
81
+ const config = event?.ctx?.config;
82
+ if (!config || typeof config !== 'object' || Array.isArray(config)) continue;
83
+ const utilization = finiteNumber(config.creditUsagePercent);
84
+ const dataAsOf = date(event.ts);
85
+ const startsAt = date(config.currentPeriod?.start || config.billingPeriodStart);
86
+ const resetsAt = date(config.currentPeriod?.end || config.billingPeriodEnd);
87
+ if (utilization === null || !dataAsOf || !startsAt || !resetsAt) continue;
88
+ const windowSeconds = (resetsAt.getTime() - startsAt.getTime()) / 1000;
89
+ if (windowSeconds <= 0) continue;
90
+ const planLabel = [event.ctx?.subscriptionTier, config.subscriptionTier]
91
+ .find(value => typeof value === 'string' && value.trim())?.trim();
92
+ return {
93
+ active: resetsAt > now,
94
+ dataAsOf,
95
+ meters: [{
96
+ id: 'subscription-credits',
97
+ label: periodLabel(config.currentPeriod?.type, windowSeconds),
98
+ utilization,
99
+ resetsAt: resetsAt.toISOString(),
100
+ windowSeconds,
101
+ }],
102
+ planLabel,
103
+ };
104
+ }
105
+ return null;
106
+ }
107
+
108
+ export function fetchGrokQuota({
109
+ environment = process.env,
110
+ home = homedir(),
111
+ now = new Date(),
112
+ maxLogBytes = DEFAULT_MAX_LOG_BYTES,
113
+ } = {}) {
114
+ let parsed;
115
+ try {
116
+ parsed = parseGrokBillingLog(
117
+ readLogTail(grokBillingLogPath(environment, home), maxLogBytes),
118
+ now
119
+ );
120
+ } catch (error) {
121
+ const status = error?.code === 'ENOENT' ? 'no_data' : 'retryable_error';
122
+ return quotaResult({
123
+ id: PRODUCT_ID,
124
+ status,
125
+ message: status === 'no_data'
126
+ ? 'Grok billing status is not available yet'
127
+ : 'Grok billing status could not be read',
128
+ fetchedAt: now,
129
+ source: 'local',
130
+ });
131
+ }
132
+ if (!parsed || !parsed.active) {
133
+ return quotaResult({
134
+ id: PRODUCT_ID,
135
+ status: 'no_data',
136
+ message: 'Grok billing status is not available yet',
137
+ fetchedAt: now,
138
+ dataAsOf: parsed?.dataAsOf,
139
+ source: 'local',
140
+ });
141
+ }
142
+ return quotaResult({
143
+ id: PRODUCT_ID,
144
+ status: 'ok',
145
+ meters: parsed.meters,
146
+ planLabel: parsed.planLabel,
147
+ fetchedAt: now,
148
+ dataAsOf: parsed.dataAsOf,
149
+ source: 'local',
150
+ });
151
+ }
@@ -0,0 +1,504 @@
1
+ import {
2
+ accessSync,
3
+ closeSync,
4
+ constants as fsConstants,
5
+ fsyncSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ renameSync,
10
+ rmdirSync,
11
+ statSync,
12
+ unlinkSync,
13
+ writeFileSync,
14
+ } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { dirname, join } from 'node:path';
17
+ import { attachCacheScope } from '../cache.js';
18
+ import { quotaResult } from '../schema.js';
19
+
20
+ const PRODUCT_ID = 'kimi-code';
21
+ const DEFAULT_USAGE_URL = 'https://api.kimi.com/coding/v1/usages';
22
+ const DEFAULT_OAUTH_HOST = 'https://auth.kimi.com';
23
+ const KIMI_CODE_CLIENT_ID = '17e5f671-d194-4dfb-9706-5516cb48c098';
24
+ const MIN_REFRESH_THRESHOLD_SECONDS = 300;
25
+ const REFRESH_THRESHOLD_RATIO = 0.5;
26
+ const RETRYABLE_REFRESH_STATUSES = new Set([429, 500, 502, 503, 504]);
27
+ const REFRESH_LOCK_RETRIES = 50;
28
+ const REFRESH_LOCK_RETRY_MS = 100;
29
+ const REFRESH_LOCK_STALE_MS = 120_000;
30
+
31
+ class RefreshUnauthorizedError extends Error {}
32
+ class RefreshRetryableError extends Error {}
33
+ class RefreshNonRetryableError extends Error {}
34
+ class RefreshPersistenceError extends Error {}
35
+
36
+ const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
37
+
38
+ function number(value) {
39
+ if (value === null || value === undefined || value === '') return null;
40
+ const parsed = Number(value);
41
+ return Number.isFinite(parsed) ? parsed : null;
42
+ }
43
+
44
+ function resetDate(data, now = new Date()) {
45
+ for (const key of ['reset_at', 'resetAt', 'reset_time', 'resetTime']) {
46
+ const value = data?.[key];
47
+ if (value === null || value === undefined || value === '') continue;
48
+ if (typeof value === 'number') {
49
+ const millis = value > 10_000_000_000 ? value : value * 1000;
50
+ if (Number.isFinite(millis)) return new Date(millis);
51
+ }
52
+ const millis = Date.parse(String(value));
53
+ if (!Number.isNaN(millis)) return new Date(millis);
54
+ }
55
+ const seconds = number(data?.reset_in ?? data?.resetIn ?? data?.ttl);
56
+ return seconds !== null && seconds > 0 ? new Date(now.getTime() + seconds * 1000) : null;
57
+ }
58
+
59
+ function durationSeconds(item, detail) {
60
+ const window = item?.window && typeof item.window === 'object' ? item.window : {};
61
+ const duration = number(window.duration ?? item?.duration ?? detail?.duration);
62
+ if (duration === null || duration <= 0) return null;
63
+ const unit = String(window.timeUnit ?? item?.timeUnit ?? detail?.timeUnit ?? '').toUpperCase();
64
+ if (unit.includes('MINUTE')) return duration * 60;
65
+ if (unit.includes('HOUR')) return duration * 3600;
66
+ if (unit.includes('DAY')) return duration * 86400;
67
+ if (unit.includes('WEEK')) return duration * 7 * 86400;
68
+ return duration;
69
+ }
70
+
71
+ function labelFor(item, detail, index) {
72
+ for (const key of ['name', 'title', 'scope']) {
73
+ const value = item?.[key] ?? detail?.[key];
74
+ if (typeof value === 'string' && value.trim()) return value.trim();
75
+ }
76
+ const seconds = durationSeconds(item, detail);
77
+ if (seconds && seconds % (7 * 86400) === 0) return `${seconds / (7 * 86400)}w`;
78
+ if (seconds && seconds % 86400 === 0) return `${seconds / 86400}d`;
79
+ if (seconds && seconds % 3600 === 0) return `${seconds / 3600}h`;
80
+ if (seconds && seconds % 60 === 0) return `${seconds / 60}m`;
81
+ return `Quota ${index + 1}`;
82
+ }
83
+
84
+ function meterFrom(data, item, index, defaultLabel, now) {
85
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
86
+ const limit = number(data.limit);
87
+ let used = number(data.used);
88
+ if (used === null && limit !== null) {
89
+ const remaining = number(data.remaining);
90
+ if (remaining !== null) used = limit - remaining;
91
+ }
92
+ if (limit === null || limit <= 0 || used === null) return null;
93
+ const label = String(data.name || data.title || defaultLabel).trim();
94
+ const rawIdentifier = String(data.id || item?.id || label)
95
+ .trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
96
+ const meter = {
97
+ id: `${index}-${rawIdentifier || 'quota'}`,
98
+ label,
99
+ utilization: Math.max(0, Math.min(100, used / limit * 100)),
100
+ };
101
+ const resetsAt = resetDate(data, now) || resetDate(item, now);
102
+ if (resetsAt) meter.resetsAt = resetsAt.toISOString();
103
+ const seconds = durationSeconds(item, data);
104
+ if (seconds) meter.windowSeconds = seconds;
105
+ return meter;
106
+ }
107
+
108
+ export function parseKimiUsage(payload, now = new Date()) {
109
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
110
+ throw new Error('Kimi usage response is not an object');
111
+ }
112
+ const meters = [];
113
+ if (payload.usage && typeof payload.usage === 'object' && !Array.isArray(payload.usage)) {
114
+ const summary = meterFrom(payload.usage, payload.usage, 0, 'Weekly', now);
115
+ if (summary) meters.push(summary);
116
+ }
117
+ if (Array.isArray(payload.limits)) {
118
+ const offset = meters.length;
119
+ for (const [index, item] of payload.limits.entries()) {
120
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
121
+ const detail = item.detail && typeof item.detail === 'object' && !Array.isArray(item.detail)
122
+ ? item.detail : item;
123
+ const meter = meterFrom(detail, item, index + offset,
124
+ labelFor(item, detail, index), now);
125
+ if (meter) meters.push(meter);
126
+ }
127
+ }
128
+ const seen = new Set();
129
+ return meters.filter(meter => {
130
+ const key = `${meter.label}\0${meter.windowSeconds || ''}`;
131
+ if (seen.has(key)) return false;
132
+ seen.add(key);
133
+ return true;
134
+ });
135
+ }
136
+
137
+ export function kimiCredentialPath(environment = process.env, home = homedir()) {
138
+ const shareDirectory = environment.KIMI_SHARE_DIR?.trim() || join(home, '.kimi');
139
+ return join(shareDirectory, 'credentials', 'kimi-code.json');
140
+ }
141
+
142
+ function readCredentials(path) {
143
+ try {
144
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
145
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ function accessToken(credentials) {
152
+ return typeof credentials?.access_token === 'string' ? credentials.access_token.trim() : '';
153
+ }
154
+
155
+ function refreshToken(credentials) {
156
+ return typeof credentials?.refresh_token === 'string' ? credentials.refresh_token.trim() : '';
157
+ }
158
+
159
+ function refreshThreshold(credentials) {
160
+ const expiresIn = number(credentials?.expires_in);
161
+ return Math.max(
162
+ MIN_REFRESH_THRESHOLD_SECONDS,
163
+ expiresIn !== null && expiresIn > 0 ? expiresIn * REFRESH_THRESHOLD_RATIO : 0,
164
+ );
165
+ }
166
+
167
+ function shouldRefresh(credentials, now, force = false) {
168
+ if (force) return true;
169
+ if (!accessToken(credentials)) return true;
170
+ const expiresAt = number(credentials?.expires_at);
171
+ if (expiresAt === null || expiresAt <= 0) return false;
172
+ return expiresAt * 1000 - now.getTime() <= refreshThreshold(credentials) * 1000;
173
+ }
174
+
175
+ function credentialsWereRotated(latest, previous) {
176
+ const latestRefresh = refreshToken(latest);
177
+ const previousRefresh = refreshToken(previous);
178
+ if (latestRefresh && latestRefresh !== previousRefresh) return true;
179
+ return Boolean(accessToken(latest) && accessToken(latest) !== accessToken(previous));
180
+ }
181
+
182
+ function atomicWriteCredentials(path, credentials) {
183
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
184
+ let descriptor = -1;
185
+ try {
186
+ descriptor = openSync(temporary, 'wx', 0o600);
187
+ writeFileSync(descriptor, `${JSON.stringify(credentials)}\n`, 'utf8');
188
+ fsyncSync(descriptor);
189
+ closeSync(descriptor);
190
+ descriptor = -1;
191
+ renameSync(temporary, path);
192
+ } catch (error) {
193
+ if (descriptor >= 0) {
194
+ try { closeSync(descriptor); } catch {}
195
+ }
196
+ try { unlinkSync(temporary); } catch {}
197
+ throw error;
198
+ }
199
+ }
200
+
201
+ function assertCredentialStoreWritable(path) {
202
+ // Check this before rotating a refresh token. A successful refresh can
203
+ // invalidate the old token, so discovering a read-only credential store only
204
+ // after the network request could log the user out of Kimi Code.
205
+ try {
206
+ accessSync(dirname(path), fsConstants.W_OK);
207
+ } catch {
208
+ throw new RefreshPersistenceError();
209
+ }
210
+ }
211
+
212
+ function lockPathFor(credentialsPath) {
213
+ return `${credentialsPath}.vibe-usage-refresh-lock`;
214
+ }
215
+
216
+ function processIsAlive(pid) {
217
+ if (!Number.isInteger(pid) || pid <= 0) return false;
218
+ try {
219
+ process.kill(pid, 0);
220
+ return true;
221
+ } catch (error) {
222
+ return error?.code === 'EPERM';
223
+ }
224
+ }
225
+
226
+ function removeAbandonedLock(path, nowMs) {
227
+ try {
228
+ let owner = null;
229
+ try { owner = JSON.parse(readFileSync(join(path, 'owner.json'), 'utf8')); } catch {}
230
+ const isOrphan = owner?.pid && !processIsAlive(Number(owner.pid));
231
+ const isStale = nowMs - statSync(path).mtimeMs > REFRESH_LOCK_STALE_MS;
232
+ if (!isOrphan && !isStale) return false;
233
+ try { unlinkSync(join(path, 'owner.json')); } catch {}
234
+ rmdirSync(path);
235
+ return true;
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+
241
+ async function acquireRefreshLock(credentialsPath, sleepImpl) {
242
+ const path = lockPathFor(credentialsPath);
243
+ for (let attempt = 0; attempt <= REFRESH_LOCK_RETRIES; attempt += 1) {
244
+ try {
245
+ mkdirSync(path, { mode: 0o700 });
246
+ try {
247
+ writeFileSync(join(path, 'owner.json'), JSON.stringify({ pid: process.pid }), {
248
+ encoding: 'utf8',
249
+ mode: 0o600,
250
+ });
251
+ } catch {
252
+ try { unlinkSync(join(path, 'owner.json')); } catch {}
253
+ try { rmdirSync(path); } catch {}
254
+ throw new RefreshPersistenceError();
255
+ }
256
+ return () => {
257
+ try { unlinkSync(join(path, 'owner.json')); } catch {}
258
+ try { rmdirSync(path); } catch {}
259
+ };
260
+ } catch (error) {
261
+ if (error instanceof RefreshPersistenceError) throw error;
262
+ if (error?.code !== 'EEXIST') throw new RefreshPersistenceError();
263
+ if (removeAbandonedLock(path, Date.now())) continue;
264
+ if (attempt < REFRESH_LOCK_RETRIES) await sleepImpl(REFRESH_LOCK_RETRY_MS);
265
+ }
266
+ }
267
+ throw new RefreshRetryableError();
268
+ }
269
+
270
+ function refreshedCredentials(payload, previous, now) {
271
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
272
+ throw new RefreshRetryableError();
273
+ }
274
+ const nextAccessToken = typeof payload.access_token === 'string' ? payload.access_token.trim() : '';
275
+ const nextRefreshToken = typeof payload.refresh_token === 'string'
276
+ ? payload.refresh_token.trim() : refreshToken(previous);
277
+ const expiresIn = number(payload.expires_in);
278
+ if (!nextAccessToken || !nextRefreshToken || expiresIn === null || expiresIn <= 0) {
279
+ throw new RefreshRetryableError();
280
+ }
281
+ return {
282
+ access_token: nextAccessToken,
283
+ refresh_token: nextRefreshToken,
284
+ expires_at: now.getTime() / 1000 + expiresIn,
285
+ scope: typeof payload.scope === 'string' ? payload.scope : String(previous.scope || ''),
286
+ token_type: typeof payload.token_type === 'string'
287
+ ? payload.token_type : String(previous.token_type || 'Bearer'),
288
+ expires_in: expiresIn,
289
+ };
290
+ }
291
+
292
+ async function requestTokenRefresh({
293
+ credentials,
294
+ fetchImpl,
295
+ oauthURL,
296
+ now,
297
+ timeoutMs,
298
+ sleepImpl,
299
+ }) {
300
+ let lastError;
301
+ for (let attempt = 0; attempt < 3; attempt += 1) {
302
+ try {
303
+ const response = await fetchImpl(oauthURL, {
304
+ method: 'POST',
305
+ headers: {
306
+ Accept: 'application/json',
307
+ 'Content-Type': 'application/x-www-form-urlencoded',
308
+ },
309
+ body: new URLSearchParams({
310
+ client_id: KIMI_CODE_CLIENT_ID,
311
+ grant_type: 'refresh_token',
312
+ refresh_token: refreshToken(credentials),
313
+ }),
314
+ signal: AbortSignal.timeout(timeoutMs),
315
+ });
316
+ let payload = {};
317
+ try { payload = await response.json(); } catch {}
318
+ if (response.status === 401 || response.status === 403) {
319
+ throw new RefreshUnauthorizedError();
320
+ }
321
+ if (!response.ok) {
322
+ if (payload?.error === 'invalid_grant') throw new RefreshUnauthorizedError();
323
+ if (!RETRYABLE_REFRESH_STATUSES.has(response.status)) throw new RefreshNonRetryableError();
324
+ lastError = new RefreshRetryableError();
325
+ } else {
326
+ return refreshedCredentials(payload, credentials, now);
327
+ }
328
+ } catch (error) {
329
+ if (error instanceof RefreshUnauthorizedError) throw error;
330
+ if (error instanceof RefreshNonRetryableError) throw new RefreshRetryableError();
331
+ lastError = error;
332
+ }
333
+ if (attempt < 2) await sleepImpl(2 ** attempt * 1000);
334
+ }
335
+ throw new RefreshRetryableError(undefined, { cause: lastError });
336
+ }
337
+
338
+ async function ensureFreshCredentials({
339
+ credentialsPath,
340
+ credentials,
341
+ fetchImpl,
342
+ oauthURL,
343
+ now,
344
+ timeoutMs,
345
+ sleepImpl,
346
+ force = false,
347
+ }) {
348
+ if (!shouldRefresh(credentials, now, force)) return credentials;
349
+ if (!refreshToken(credentials)) return credentials;
350
+
351
+ let release;
352
+ try {
353
+ release = await acquireRefreshLock(credentialsPath, sleepImpl);
354
+ const latest = readCredentials(credentialsPath) || credentials;
355
+ if (credentialsWereRotated(latest, credentials)) return latest;
356
+ if (!shouldRefresh(latest, now, force)) return latest;
357
+ assertCredentialStoreWritable(credentialsPath);
358
+
359
+ let refreshed;
360
+ try {
361
+ refreshed = await requestTokenRefresh({
362
+ credentials: latest,
363
+ fetchImpl,
364
+ oauthURL,
365
+ now,
366
+ timeoutMs,
367
+ sleepImpl,
368
+ });
369
+ } catch (error) {
370
+ if (error instanceof RefreshUnauthorizedError) {
371
+ // A Kimi process may have rotated and persisted the token while our
372
+ // request was in flight. Re-read once before reporting a stale refresh
373
+ // token as rejected.
374
+ await sleepImpl(1000);
375
+ const concurrent = readCredentials(credentialsPath);
376
+ if (concurrent && credentialsWereRotated(concurrent, latest)) return concurrent;
377
+ }
378
+ throw error;
379
+ }
380
+
381
+ const concurrent = readCredentials(credentialsPath);
382
+ if (concurrent && credentialsWereRotated(concurrent, latest)) return concurrent;
383
+ try {
384
+ atomicWriteCredentials(credentialsPath, refreshed);
385
+ } catch {
386
+ throw new RefreshPersistenceError();
387
+ }
388
+ return refreshed;
389
+ } finally {
390
+ release?.();
391
+ }
392
+ }
393
+
394
+ export async function fetchKimiCodeQuota({
395
+ environment = process.env,
396
+ home = homedir(),
397
+ fetchImpl = globalThis.fetch,
398
+ usageURL = DEFAULT_USAGE_URL,
399
+ oauthURL = `${(environment.KIMI_CODE_OAUTH_HOST || environment.KIMI_OAUTH_HOST
400
+ || DEFAULT_OAUTH_HOST).replace(/\/$/, '')}/api/oauth/token`,
401
+ now = new Date(),
402
+ timeoutMs = 10_000,
403
+ sleepImpl = sleep,
404
+ } = {}) {
405
+ const credentialsPath = kimiCredentialPath(environment, home);
406
+ let credentials = readCredentials(credentialsPath);
407
+ if (!credentials) {
408
+ return quotaResult({ id: PRODUCT_ID, status: 'missing_credentials',
409
+ message: 'Kimi Code is not logged in', fetchedAt: now });
410
+ }
411
+
412
+ try {
413
+ credentials = await ensureFreshCredentials({
414
+ credentialsPath,
415
+ credentials,
416
+ fetchImpl,
417
+ oauthURL,
418
+ now,
419
+ timeoutMs,
420
+ sleepImpl,
421
+ });
422
+ } catch (error) {
423
+ const token = accessToken(credentials);
424
+ if (error instanceof RefreshUnauthorizedError) {
425
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'unauthorized',
426
+ message: 'Kimi Code login refresh was rejected', fetchedAt: now }), token);
427
+ }
428
+ const message = error instanceof RefreshPersistenceError
429
+ ? 'Kimi Code could not securely save the refreshed login'
430
+ : 'Kimi Code login refresh failed';
431
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'retryable_error',
432
+ message, fetchedAt: now }), token);
433
+ }
434
+
435
+ let token = accessToken(credentials);
436
+ if (!token) {
437
+ return quotaResult({ id: PRODUCT_ID, status: 'missing_credentials',
438
+ message: 'Kimi Code access token is missing', fetchedAt: now });
439
+ }
440
+ const expiresAt = number(credentials.expires_at);
441
+ if (expiresAt !== null && expiresAt > 0 && expiresAt * 1000 <= now.getTime()) {
442
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'expired_credentials',
443
+ message: 'Kimi Code access token is expired and no refresh token is available',
444
+ fetchedAt: now }), token);
445
+ }
446
+
447
+ try {
448
+ let response = await fetchImpl(usageURL, {
449
+ method: 'GET',
450
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
451
+ signal: AbortSignal.timeout(timeoutMs),
452
+ });
453
+ if (response.status === 401 && refreshToken(credentials)) {
454
+ try {
455
+ credentials = await ensureFreshCredentials({
456
+ credentialsPath,
457
+ credentials,
458
+ fetchImpl,
459
+ oauthURL,
460
+ now,
461
+ timeoutMs,
462
+ sleepImpl,
463
+ force: true,
464
+ });
465
+ token = accessToken(credentials);
466
+ response = await fetchImpl(usageURL, {
467
+ method: 'GET',
468
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
469
+ signal: AbortSignal.timeout(timeoutMs),
470
+ });
471
+ } catch (error) {
472
+ if (error instanceof RefreshUnauthorizedError) {
473
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'unauthorized',
474
+ message: 'Kimi Code login refresh was rejected', fetchedAt: now }), token);
475
+ }
476
+ const message = error instanceof RefreshPersistenceError
477
+ ? 'Kimi Code could not securely save the refreshed login'
478
+ : 'Kimi Code login refresh failed';
479
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'retryable_error',
480
+ message, fetchedAt: now }), token);
481
+ }
482
+ }
483
+ if (response.status === 401 || response.status === 403) {
484
+ return quotaResult({ id: PRODUCT_ID, status: 'unauthorized',
485
+ message: 'Kimi Code rejected the saved login', fetchedAt: now });
486
+ }
487
+ if (!response.ok) {
488
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'retryable_error',
489
+ message: `Kimi usage API returned HTTP ${response.status}`, fetchedAt: now }), token);
490
+ }
491
+ const meters = parseKimiUsage(await response.json(), now);
492
+ return attachCacheScope(quotaResult({
493
+ id: PRODUCT_ID,
494
+ status: meters.length ? 'ok' : 'no_data',
495
+ meters,
496
+ fetchedAt: now,
497
+ dataAsOf: now,
498
+ }), token);
499
+ } catch (error) {
500
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'retryable_error',
501
+ message: error?.name === 'TimeoutError' ? 'Kimi usage request timed out' : 'Kimi usage request failed',
502
+ fetchedAt: now }), token);
503
+ }
504
+ }
@@ -0,0 +1,143 @@
1
+ import { attachCacheScope } from '../cache.js';
2
+ import { quotaResult } from '../schema.js';
3
+
4
+ const PRODUCT_ID = 'zcode';
5
+ const BIGMODEL_USAGE_URL = 'https://open.bigmodel.cn/api/monitor/usage/quota/limit';
6
+ const ZAI_USAGE_URL = 'https://api.z.ai/api/monitor/usage/quota/limit';
7
+
8
+ function credential(environment) {
9
+ const bigModelToken = environment.BIGMODEL_API_KEY?.trim();
10
+ if (bigModelToken) {
11
+ return {
12
+ token: bigModelToken,
13
+ region: 'bigmodel',
14
+ providerName: 'BigModel',
15
+ usageURL: BIGMODEL_USAGE_URL,
16
+ };
17
+ }
18
+ const zaiToken = environment.Z_AI_API_KEY?.trim();
19
+ if (zaiToken) {
20
+ return {
21
+ token: zaiToken,
22
+ region: 'zai',
23
+ providerName: 'Z.ai',
24
+ usageURL: ZAI_USAGE_URL,
25
+ };
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function integer(value) {
31
+ return Number.isInteger(value) ? value : null;
32
+ }
33
+
34
+ function parseLimit(raw, now, index) {
35
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
36
+ if (!['TOKENS_LIMIT', 'CREDIT_LIMIT', 'TIME_LIMIT'].includes(raw.type)) return null;
37
+ const percentage = integer(raw.percentage);
38
+ const unit = integer(raw.unit);
39
+ const count = integer(raw.number);
40
+ if (percentage === null || unit === null || count === null) return null;
41
+
42
+ const usage = integer(raw.usage);
43
+ const current = integer(raw.currentValue);
44
+ const remaining = integer(raw.remaining);
45
+ let utilization = percentage;
46
+ if (usage !== null && usage > 0) {
47
+ let used = current;
48
+ if (remaining !== null) used = Math.max(usage - remaining, current ?? usage - remaining);
49
+ if (used !== null) utilization = used * 100 / usage;
50
+ }
51
+ utilization = Math.max(0, Math.min(100, utilization));
52
+
53
+ const minutesPerUnit = { 1: 1440, 3: 60, 5: 1, 6: 10080 };
54
+ let windowMinutes = count > 0 ? count * (minutesPerUnit[unit] || 0) : 0;
55
+ if (raw.type === 'TIME_LIMIT' && unit === 5 && count === 1) {
56
+ // The API uses this marker for the monthly MCP pool.
57
+ windowMinutes = 30 * 24 * 60;
58
+ }
59
+ const isFiveHour = raw.type !== 'TIME_LIMIT' && windowMinutes === 300;
60
+ const resetMillis = integer(raw.nextResetTime);
61
+ const plausibleReset = resetMillis !== null
62
+ && (!isFiveHour || resetMillis <= now.getTime() + (5 * 3600 + 60) * 1000);
63
+ const typeName = raw.type === 'TIME_LIMIT'
64
+ ? 'MCP'
65
+ : raw.type === 'CREDIT_LIMIT' ? 'Credits' : 'Tokens';
66
+ let label = typeName;
67
+ if (raw.type !== 'TIME_LIMIT' && windowMinutes === 300) label = '5h';
68
+ else if (raw.type !== 'TIME_LIMIT' && windowMinutes === 10080) label = '7d';
69
+ else if (raw.type !== 'TIME_LIMIT' && windowMinutes && windowMinutes % 1440 === 0) {
70
+ label = `${windowMinutes / 1440}d`;
71
+ }
72
+ const meter = {
73
+ id: `${index}-${raw.type.toLowerCase()}-${unit}-${count}`,
74
+ label,
75
+ utilization,
76
+ };
77
+ if (windowMinutes > 0) meter.windowSeconds = windowMinutes * 60;
78
+ if (plausibleReset) meter.resetsAt = new Date(resetMillis).toISOString();
79
+ return { meter, windowMinutes, type: raw.type };
80
+ }
81
+
82
+ export function parseZaiQuota(payload, now = new Date()) {
83
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)
84
+ || payload.success !== true || payload.code !== 200
85
+ || !payload.data || typeof payload.data !== 'object'
86
+ || !Array.isArray(payload.data.limits)) {
87
+ throw new Error('Invalid Z.ai quota response');
88
+ }
89
+ const parsed = payload.data.limits.map((raw, index) => parseLimit(raw, now, index)).filter(Boolean);
90
+ const planLimits = parsed
91
+ .filter(item => item.type === 'TOKENS_LIMIT' || item.type === 'CREDIT_LIMIT')
92
+ .sort((a, b) => (a.windowMinutes || Number.MAX_SAFE_INTEGER)
93
+ - (b.windowMinutes || Number.MAX_SAFE_INTEGER));
94
+ const mcp = parsed.filter(item => item.type === 'TIME_LIMIT').pop();
95
+ const ordered = [...planLimits];
96
+ if (mcp) ordered.push(mcp);
97
+ const planLabel = ['planName', 'plan', 'plan_type', 'packageName', 'level']
98
+ .map(key => payload.data[key])
99
+ .find(value => typeof value === 'string' && value.trim());
100
+ return { meters: ordered.map(item => item.meter), planLabel: planLabel?.trim() };
101
+ }
102
+
103
+ export async function fetchZaiQuota({
104
+ environment = process.env,
105
+ fetchImpl = globalThis.fetch,
106
+ usageURL,
107
+ now = new Date(),
108
+ timeoutMs = 10_000,
109
+ } = {}) {
110
+ const selected = credential(environment);
111
+ if (!selected) {
112
+ return quotaResult({ id: PRODUCT_ID, status: 'missing_credentials',
113
+ message: 'ZCode API key is not configured', fetchedAt: now });
114
+ }
115
+ const { token, region, providerName } = selected;
116
+ const endpoint = usageURL || selected.usageURL;
117
+ const cacheCredential = `${region}:${token}`;
118
+ try {
119
+ const response = await fetchImpl(endpoint, {
120
+ method: 'GET',
121
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
122
+ signal: AbortSignal.timeout(timeoutMs),
123
+ });
124
+ if (response.status === 401 || response.status === 403) {
125
+ return quotaResult({ id: PRODUCT_ID, status: 'unauthorized',
126
+ message: `${providerName} rejected the API key`, fetchedAt: now });
127
+ }
128
+ if (!response.ok) {
129
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'retryable_error',
130
+ message: `${providerName} quota API returned HTTP ${response.status}`, fetchedAt: now }),
131
+ cacheCredential);
132
+ }
133
+ const { meters, planLabel } = parseZaiQuota(await response.json(), now);
134
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: meters.length ? 'ok' : 'no_data',
135
+ meters, planLabel, fetchedAt: now, dataAsOf: now }), cacheCredential);
136
+ } catch (error) {
137
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'retryable_error',
138
+ message: error?.name === 'TimeoutError'
139
+ ? `${providerName} quota request timed out`
140
+ : `${providerName} quota request failed`,
141
+ fetchedAt: now }), cacheCredential);
142
+ }
143
+ }
@@ -0,0 +1,114 @@
1
+ import { accessSync, constants, existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { delimiter, join } from 'node:path';
4
+ import { loadCachedQuota, saveCachedQuota } from './cache.js';
5
+ import { fetchGrokQuota } from './providers/grok.js';
6
+ import { fetchKimiCodeQuota } from './providers/kimi-code.js';
7
+ import { fetchZaiQuota } from './providers/zai.js';
8
+ import { FETCHABLE_QUOTA_PRODUCT_IDS, quotaEnvelope, quotaResult } from './schema.js';
9
+
10
+ const providers = new Map([
11
+ ['kimi-code', fetchKimiCodeQuota],
12
+ ['zcode', fetchZaiQuota],
13
+ ['grok', fetchGrokQuota],
14
+ ]);
15
+
16
+ function executableExists(name, environment, platform) {
17
+ const pathDelimiter = platform === 'win32' ? ';' : delimiter;
18
+ const candidateNames = [name];
19
+ if (platform === 'win32') {
20
+ const extensions = (environment.PATHEXT || '.COM;.EXE;.BAT;.CMD')
21
+ .split(';')
22
+ .map(value => value.trim())
23
+ .filter(Boolean)
24
+ .map(value => value.startsWith('.') ? value : `.${value}`);
25
+ for (const extension of extensions) {
26
+ candidateNames.push(`${name}${extension}`, `${name}${extension.toLowerCase()}`);
27
+ }
28
+ }
29
+
30
+ return (environment.PATH || '').split(pathDelimiter).filter(Boolean).some(directory => (
31
+ candidateNames.some(candidate => {
32
+ const path = join(directory, candidate);
33
+ try {
34
+ // Windows does not expose POSIX execute bits; file presence plus a
35
+ // PATHEXT executable suffix is its ordinary command-discovery rule.
36
+ accessSync(path, platform === 'win32' ? constants.F_OK : constants.X_OK);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ })
42
+ ));
43
+ }
44
+
45
+ export function discoverQuotaProducts({
46
+ environment = process.env,
47
+ home = homedir(),
48
+ platform = process.platform,
49
+ } = {}) {
50
+ const applications = platform === 'darwin'
51
+ ? ['/Applications', join(home, 'Applications')] : [];
52
+ const existsAny = paths => paths.some(path => existsSync(path));
53
+ const configuredGrokHome = environment.GROK_HOME?.trim();
54
+ const grokHome = configuredGrokHome
55
+ ? configuredGrokHome.replace(/^~(?=$|[\\/])/, home)
56
+ : join(home, '.grok');
57
+ return quotaEnvelope([
58
+ {
59
+ id: 'kimi-code',
60
+ detected: existsAny([join(home, '.kimi'), join(home, '.kimi-code')])
61
+ || executableExists('kimi', environment, platform),
62
+ fetchable: true,
63
+ },
64
+ {
65
+ id: 'zcode',
66
+ detected: existsAny([join(home, '.zcode'), join(home, '.config', 'zcode'),
67
+ ...applications.map(path => join(path, 'ZCode.app'))])
68
+ || executableExists('zcode', environment, platform),
69
+ fetchable: true,
70
+ },
71
+ {
72
+ id: 'grok',
73
+ detected: existsAny([grokHome]) || executableExists('grok', environment, platform),
74
+ fetchable: true,
75
+ },
76
+ {
77
+ id: 'cursor',
78
+ detected: existsAny([join(home, '.cursor'),
79
+ ...applications.map(path => join(path, 'Cursor.app'))])
80
+ || executableExists('cursor', environment, platform),
81
+ fetchable: false,
82
+ },
83
+ ]);
84
+ }
85
+
86
+ export async function fetchQuotaProducts(ids, options = {}) {
87
+ const unique = [...new Set(ids)];
88
+ const invalid = unique.filter(id => !FETCHABLE_QUOTA_PRODUCT_IDS.includes(id));
89
+ if (invalid.length) throw new Error(`Unsupported quota product: ${invalid.join(', ')}`);
90
+
91
+ const fetched = await Promise.all(unique.map(async id => {
92
+ try {
93
+ return await providers.get(id)(options);
94
+ } catch {
95
+ return quotaResult({ id, status: 'retryable_error', message: 'Provider failed unexpectedly' });
96
+ }
97
+ }));
98
+ const results = fetched.map(result => {
99
+ if (result.status === 'ok') {
100
+ saveCachedQuota(result, result.cacheScope, options.environment);
101
+ return result;
102
+ }
103
+ if (result.status === 'retryable_error') {
104
+ return loadCachedQuota(
105
+ result.id,
106
+ result.cacheScope,
107
+ options.environment,
108
+ options.now || new Date()
109
+ ) || result;
110
+ }
111
+ return result;
112
+ });
113
+ return quotaEnvelope(results);
114
+ }
@@ -0,0 +1,94 @@
1
+ export const QUOTA_SCHEMA_VERSION = 1;
2
+
3
+ export const QUOTA_PRODUCT_IDS = Object.freeze([
4
+ 'kimi-code',
5
+ 'zcode',
6
+ 'grok',
7
+ 'cursor',
8
+ ]);
9
+
10
+ export const FETCHABLE_QUOTA_PRODUCT_IDS = Object.freeze([
11
+ 'kimi-code',
12
+ 'zcode',
13
+ 'grok',
14
+ ]);
15
+
16
+ const FETCH_STATUSES = new Set([
17
+ 'ok',
18
+ 'no_data',
19
+ 'missing_credentials',
20
+ 'expired_credentials',
21
+ 'unauthorized',
22
+ 'retryable_error',
23
+ 'unsupported',
24
+ ]);
25
+
26
+ function finiteNumber(value, name) {
27
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
28
+ throw new TypeError(`${name} must be a finite number`);
29
+ }
30
+ return value;
31
+ }
32
+
33
+ function optionalISODate(value, name) {
34
+ if (value === undefined || value === null) return undefined;
35
+ const date = value instanceof Date ? value : new Date(value);
36
+ if (Number.isNaN(date.getTime())) {
37
+ throw new TypeError(`${name} must be an ISO date string`);
38
+ }
39
+ return date.toISOString();
40
+ }
41
+
42
+ export function normalizeMeter(raw, index = 0) {
43
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
44
+ throw new TypeError(`meters[${index}] must be an object`);
45
+ }
46
+ const id = String(raw.id || '').trim();
47
+ const label = String(raw.label || '').trim();
48
+ if (!id || !label) throw new TypeError(`meters[${index}] needs id and label`);
49
+
50
+ const utilization = Math.max(0, Math.min(100,
51
+ finiteNumber(raw.utilization, `meters[${index}].utilization`)));
52
+ const meter = { id, label, utilization };
53
+ const resetsAt = optionalISODate(raw.resetsAt, `meters[${index}].resetsAt`);
54
+ if (resetsAt) meter.resetsAt = resetsAt;
55
+ if (raw.windowSeconds !== undefined && raw.windowSeconds !== null) {
56
+ const seconds = finiteNumber(raw.windowSeconds, `meters[${index}].windowSeconds`);
57
+ if (seconds > 0) meter.windowSeconds = seconds;
58
+ }
59
+ return meter;
60
+ }
61
+
62
+ export function quotaResult({
63
+ id,
64
+ status,
65
+ meters = [],
66
+ planLabel,
67
+ fetchedAt = new Date(),
68
+ dataAsOf = fetchedAt,
69
+ message,
70
+ source = 'live',
71
+ }) {
72
+ if (!FETCHABLE_QUOTA_PRODUCT_IDS.includes(id)) {
73
+ throw new TypeError(`unsupported quota product: ${id}`);
74
+ }
75
+ if (!FETCH_STATUSES.has(status)) {
76
+ throw new TypeError(`invalid quota status: ${status}`);
77
+ }
78
+ const result = {
79
+ id,
80
+ status,
81
+ meters: meters.map(normalizeMeter),
82
+ fetchedAt: new Date(fetchedAt).toISOString(),
83
+ source,
84
+ };
85
+ const normalizedDataAsOf = optionalISODate(dataAsOf, 'dataAsOf');
86
+ if (normalizedDataAsOf) result.dataAsOf = normalizedDataAsOf;
87
+ if (typeof planLabel === 'string' && planLabel.trim()) result.planLabel = planLabel.trim();
88
+ if (typeof message === 'string' && message.trim()) result.message = message.trim();
89
+ return result;
90
+ }
91
+
92
+ export function quotaEnvelope(products) {
93
+ return { schemaVersion: QUOTA_SCHEMA_VERSION, products };
94
+ }