@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 +13 -0
- package/package.json +1 -1
- package/src/daemon-service.js +4 -3
- package/src/index.js +19 -0
- package/src/parsers/claude-code.js +13 -0
- package/src/quotas/cache.js +91 -0
- package/src/quotas/index.js +35 -0
- package/src/quotas/providers/grok.js +151 -0
- package/src/quotas/providers/kimi-code.js +504 -0
- package/src/quotas/providers/zai.js +143 -0
- package/src/quotas/registry.js +114 -0
- package/src/quotas/schema.js +94 -0
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
package/src/daemon-service.js
CHANGED
|
@@ -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,
|
|
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
|
|
45
|
-
const
|
|
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
|
+
}
|