@vibe-cafe/vibe-usage 0.10.34 → 0.11.1
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 +17 -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/src/state.js +66 -7
- package/src/sync.js +19 -6
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 |
|
|
@@ -236,6 +249,10 @@ For OpenCode, each root uses its SQLite database when present; only roots withou
|
|
|
236
249
|
|
|
237
250
|
The first `npx @vibe-cafe/vibe-usage` run installs a user-level service (systemd on Linux, launchd on macOS, Task Scheduler on Windows — no admin rights needed) that syncs every 30 minutes and starts automatically on login. Nothing else to do.
|
|
238
251
|
|
|
252
|
+
Switching the CLI to a different account (running `init` again, or `config set apiKey`) rebinds the upload state, so the next sync re-uploads your full local history to the new account instead of treating it as already sent.
|
|
253
|
+
|
|
254
|
+
换绑账号后(重新 `init` 或 `config set apiKey`),下一次同步会自动全量重传本地历史,不会因为旧账号的同步记录而漏传。
|
|
255
|
+
|
|
239
256
|
<details>
|
|
240
257
|
<summary>Managing the service, and how it is launched</summary>
|
|
241
258
|
|
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
|
+
}
|