quotacap 0.0.17 → 0.0.19

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.
Files changed (36) hide show
  1. package/dist/adapters/codex.d.ts +7 -0
  2. package/dist/adapters/codex.js +61 -0
  3. package/dist/adapters/core.d.ts +9 -0
  4. package/dist/adapters/core.js +90 -0
  5. package/dist/adapters/grok.d.ts +7 -0
  6. package/dist/adapters/grok.js +82 -0
  7. package/dist/adapters/index.js +6 -0
  8. package/dist/adapters/kimi.d.ts +7 -0
  9. package/dist/adapters/kimi.js +98 -0
  10. package/dist/config.js +1 -1
  11. package/dist/format/table.js +5 -5
  12. package/dist/src/adapters/codex.d.ts +7 -0
  13. package/dist/src/adapters/codex.js +61 -0
  14. package/dist/src/adapters/core.d.ts +9 -0
  15. package/dist/src/adapters/core.js +90 -0
  16. package/dist/src/adapters/grok.d.ts +7 -0
  17. package/dist/src/adapters/grok.js +82 -0
  18. package/dist/src/adapters/index.js +6 -0
  19. package/dist/src/adapters/kimi.d.ts +7 -0
  20. package/dist/src/adapters/kimi.js +98 -0
  21. package/dist/src/config.js +1 -1
  22. package/dist/src/format/table.js +5 -5
  23. package/dist/src/version.js +1 -1
  24. package/dist/tests/adapters/codex.test.d.ts +1 -0
  25. package/dist/tests/adapters/codex.test.js +106 -0
  26. package/dist/tests/adapters/core.test.d.ts +1 -0
  27. package/dist/tests/adapters/core.test.js +110 -0
  28. package/dist/tests/adapters/grok.test.d.ts +1 -0
  29. package/dist/tests/adapters/grok.test.js +155 -0
  30. package/dist/tests/adapters/kimi.test.d.ts +1 -0
  31. package/dist/tests/adapters/kimi.test.js +120 -0
  32. package/dist/tests/adapters/registry.test.d.ts +1 -0
  33. package/dist/tests/adapters/registry.test.js +25 -0
  34. package/dist/tests/format/table.test.js +6 -5
  35. package/dist/version.js +1 -1
  36. package/package.json +1 -1
@@ -0,0 +1,7 @@
1
+ import type { Quota } from "./types.js";
2
+ export declare function parseCodexUsage(body: any, now?: Date): Quota;
3
+ export declare const codexAdapter: {
4
+ id: string;
5
+ requiresAuth: string;
6
+ poll(): Promise<Quota>;
7
+ };
@@ -0,0 +1,61 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { readJsonFile, getJson, postForm, persistCreds } from "./core.js";
4
+ export function parseCodexUsage(body, now = new Date()) {
5
+ const rl = body?.rate_limit ?? {};
6
+ const win = rl.secondary_window ?? rl.primary_window;
7
+ if (!win)
8
+ throw new Error("codex: no rate-limit window in response");
9
+ const resetsAt = new Date(win.reset_at * 1000).toISOString();
10
+ const periodStart = new Date((win.reset_at - win.limit_window_seconds) * 1000).toISOString();
11
+ return {
12
+ provider: "codex",
13
+ plan: body.plan_type ?? "unknown",
14
+ usedPct: win.used_percent ?? 0,
15
+ resetsAt,
16
+ periodStart,
17
+ raw: JSON.stringify(body),
18
+ source: "api",
19
+ fetchedAt: now.toISOString(),
20
+ };
21
+ }
22
+ const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
23
+ const REFRESH_URL = "https://auth.openai.com/oauth/token";
24
+ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
25
+ function codexHome() {
26
+ return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
27
+ }
28
+ export const codexAdapter = {
29
+ id: "codex",
30
+ requiresAuth: "~/.codex/auth.json (codex login)",
31
+ async poll() {
32
+ const authFile = path.join(codexHome(), "auth.json");
33
+ const auth = await readJsonFile(authFile);
34
+ const at = auth.tokens?.access_token;
35
+ if (!at)
36
+ throw new Error("codex: no access_token in auth.json — run codex login");
37
+ const headers = { Authorization: `Bearer ${at}`, "User-Agent": "quotacap" };
38
+ if (auth.tokens?.account_id)
39
+ headers["ChatGPT-Account-Id"] = auth.tokens.account_id;
40
+ try {
41
+ return parseCodexUsage(await getJson(USAGE_URL, headers));
42
+ }
43
+ catch (e) {
44
+ if (e.status !== 401)
45
+ throw e;
46
+ const rf = auth.tokens?.refresh_token;
47
+ if (!rf)
48
+ throw e;
49
+ const tok = await postForm(REFRESH_URL, { grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: rf });
50
+ await persistCreds(authFile, (cur) => {
51
+ const tokens = { ...(cur.tokens ?? {}), };
52
+ if (tok.access_token)
53
+ tokens.access_token = tok.access_token;
54
+ if (tok.refresh_token)
55
+ tokens.refresh_token = tok.refresh_token;
56
+ return { ...cur, tokens, last_refresh: new Date().toISOString() };
57
+ });
58
+ return parseCodexUsage(await getJson(USAGE_URL, { ...headers, Authorization: `Bearer ${tok.access_token}` }));
59
+ }
60
+ },
61
+ };
@@ -0,0 +1,9 @@
1
+ export declare class HttpError extends Error {
2
+ status: number;
3
+ body: string;
4
+ constructor(status: number, body: string);
5
+ }
6
+ export declare function readJsonFile<T>(file: string): Promise<T>;
7
+ export declare function postForm(url: string, fields: Record<string, string>, extraHeaders?: Record<string, string>, timeoutMs?: number): Promise<any>;
8
+ export declare function getJson(url: string, headers: Record<string, string>, timeoutMs?: number): Promise<any>;
9
+ export declare function persistCreds<T>(file: string, update: (cur: T) => T, backupSuffix?: string): Promise<boolean>;
@@ -0,0 +1,90 @@
1
+ import fs from "node:fs/promises";
2
+ export class HttpError extends Error {
3
+ status;
4
+ body;
5
+ constructor(status, body) {
6
+ super(`HTTP ${status}: ${String(body).slice(0, 120)}`);
7
+ this.status = status;
8
+ this.body = body;
9
+ }
10
+ }
11
+ export async function readJsonFile(file) {
12
+ const raw = await fs.readFile(file, "utf8");
13
+ return JSON.parse(raw);
14
+ }
15
+ async function parseResponse(res) {
16
+ const text = await res.text();
17
+ if (!res.ok)
18
+ throw new HttpError(res.status, text);
19
+ try {
20
+ return JSON.parse(text);
21
+ }
22
+ catch {
23
+ return text;
24
+ }
25
+ }
26
+ export async function postForm(url, fields, extraHeaders = {}, timeoutMs = 8000) {
27
+ const res = await fetch(url, {
28
+ method: "POST",
29
+ headers: { "content-type": "application/x-www-form-urlencoded", ...extraHeaders },
30
+ body: new URLSearchParams(fields).toString(),
31
+ signal: AbortSignal.timeout(timeoutMs),
32
+ });
33
+ return parseResponse(res);
34
+ }
35
+ export async function getJson(url, headers, timeoutMs = 8000) {
36
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
37
+ return parseResponse(res);
38
+ }
39
+ async function acquireLock(lockPath, timeoutMs = 2000) {
40
+ const deadline = Date.now() + timeoutMs;
41
+ for (;;) {
42
+ try {
43
+ const fh = await fs.open(lockPath, "wx");
44
+ await fh.close();
45
+ return;
46
+ }
47
+ catch (e) {
48
+ if (e.code !== "EEXIST")
49
+ throw e;
50
+ try {
51
+ const st = await fs.stat(lockPath);
52
+ if (Date.now() - st.mtimeMs > 5000) {
53
+ await fs.rm(lockPath, { force: true });
54
+ continue;
55
+ }
56
+ }
57
+ catch {
58
+ /* lock vanished between open and stat — retry */
59
+ }
60
+ if (Date.now() > deadline)
61
+ throw new Error(`persistCreds: lock busy: ${lockPath}`);
62
+ await new Promise((r) => setTimeout(r, 25));
63
+ }
64
+ }
65
+ }
66
+ export async function persistCreds(file, update, backupSuffix = ".qc-bak") {
67
+ const lock = file + ".qc-lock";
68
+ await acquireLock(lock);
69
+ try {
70
+ const cur = await readJsonFile(file);
71
+ const next = update(cur);
72
+ const bak = file + backupSuffix;
73
+ let first = false;
74
+ try {
75
+ await fs.access(bak);
76
+ }
77
+ catch {
78
+ await fs.copyFile(file, bak);
79
+ first = true;
80
+ }
81
+ const st = await fs.stat(file);
82
+ const tmp = `${file}.qc-tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
83
+ await fs.writeFile(tmp, JSON.stringify(next, null, 2), { mode: st.mode & 0o777 });
84
+ await fs.rename(tmp, file);
85
+ return first;
86
+ }
87
+ finally {
88
+ await fs.rm(lock, { force: true });
89
+ }
90
+ }
@@ -0,0 +1,7 @@
1
+ import type { Quota } from "./types.js";
2
+ export declare function parseGrokUsage(body: any, now?: Date): Quota;
3
+ export declare const grokAdapter: {
4
+ id: string;
5
+ requiresAuth: string;
6
+ poll(): Promise<Quota>;
7
+ };
@@ -0,0 +1,82 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { readJsonFile, getJson, postForm, persistCreds } from "./core.js";
4
+ const BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
5
+ const REFRESH_URL = "https://auth.x.ai/oauth2/token";
6
+ const CLIENT_VERSION = "1.0.0";
7
+ export function parseGrokUsage(body, now = new Date()) {
8
+ const cfg = body?.config;
9
+ if (!cfg)
10
+ throw new Error("grok: no config in billing response");
11
+ const pct = cfg.creditUsagePercent;
12
+ if (typeof pct !== "number")
13
+ throw new Error("grok: creditUsagePercent missing");
14
+ const period = cfg.currentPeriod ?? {};
15
+ return {
16
+ provider: "grok",
17
+ plan: body?.subscriptionTier ?? "unknown",
18
+ usedPct: pct,
19
+ resetsAt: period.end ?? new Date(now.getTime() + 7 * 86400000).toISOString(),
20
+ periodStart: period.start ?? new Date(now.getTime() - 7 * 86400000).toISOString(),
21
+ raw: JSON.stringify(body),
22
+ source: "api",
23
+ fetchedAt: now.toISOString(),
24
+ };
25
+ }
26
+ function grokHome() {
27
+ return process.env.GROK_HOME ?? path.join(os.homedir(), ".grok");
28
+ }
29
+ async function loadEntry() {
30
+ const file = path.join(grokHome(), "auth.json");
31
+ const auth = await readJsonFile(file);
32
+ for (const [entryKey, entry] of Object.entries(auth)) {
33
+ if (entry.refresh_token)
34
+ return { file, entryKey, entry };
35
+ }
36
+ throw new Error("grok: no refresh_token in auth.json — run `grok login`");
37
+ }
38
+ export const grokAdapter = {
39
+ id: "grok",
40
+ requiresAuth: "~/.grok/auth.json (grok login)",
41
+ async poll() {
42
+ const { file, entryKey, entry } = await loadEntry();
43
+ const expiryMs = entry.expires_at ? Date.parse(String(entry.expires_at)) : NaN;
44
+ const reuseToken = Boolean(entry.key) && Number.isFinite(expiryMs) && expiryMs - 60000 > Date.now();
45
+ let token;
46
+ if (reuseToken) {
47
+ token = entry.key;
48
+ }
49
+ else {
50
+ const tok = await postForm(REFRESH_URL, {
51
+ grant_type: "refresh_token",
52
+ client_id: entry.oidc_client_id ?? "",
53
+ refresh_token: entry.refresh_token ?? "",
54
+ });
55
+ if (!tok.access_token)
56
+ throw new Error("grok: refresh returned no access_token");
57
+ await persistCreds(file, (cur) => {
58
+ const next = { ...cur };
59
+ const target = next[entryKey];
60
+ if (target) {
61
+ next[entryKey] = {
62
+ ...target,
63
+ key: tok.access_token ?? target.key,
64
+ refresh_token: tok.refresh_token ?? target.refresh_token,
65
+ expires_at: tok.expires_at ??
66
+ (tok.expires_in ? new Date(Date.now() + Number(tok.expires_in) * 1000).toISOString() : target.expires_at),
67
+ };
68
+ }
69
+ return next;
70
+ });
71
+ token = String(tok.access_token);
72
+ }
73
+ const body = await getJson(BILLING_URL, {
74
+ Authorization: `Bearer ${token}`,
75
+ Accept: "application/json",
76
+ "x-grok-client-version": CLIENT_VERSION,
77
+ "x-grok-client-surface": "grok-build",
78
+ "X-XAI-Token-Auth": "xai-grok-cli",
79
+ });
80
+ return parseGrokUsage(body);
81
+ },
82
+ };
@@ -1,8 +1,14 @@
1
1
  import { claudeAdapter } from "./claude.js";
2
2
  import { manualAdapter } from "./manual.js";
3
+ import { codexAdapter } from "./codex.js";
4
+ import { kimiAdapter } from "./kimi.js";
5
+ import { grokAdapter } from "./grok.js";
3
6
  export const adapters = {
4
7
  claude: claudeAdapter,
5
8
  manual: manualAdapter,
9
+ codex: codexAdapter,
10
+ kimi: kimiAdapter,
11
+ grok: grokAdapter,
6
12
  };
7
13
  function withTimeout(p, ms) {
8
14
  return new Promise((resolve, reject) => {
@@ -0,0 +1,7 @@
1
+ import type { Quota } from "./types.js";
2
+ export declare function parseKimiUsage(body: any, now?: Date): Quota;
3
+ export declare const kimiAdapter: {
4
+ id: string;
5
+ requiresAuth: string;
6
+ poll(): Promise<Quota>;
7
+ };
@@ -0,0 +1,98 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { readJsonFile, getJson, postForm, persistCreds } from "./core.js";
4
+ const USAGE_URL = "https://api.kimi.com/coding/v1/usages";
5
+ const REFRESH_URL = "https://auth.kimi.com/api/oauth/token";
6
+ const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
7
+ export function parseKimiUsage(body, now = new Date()) {
8
+ const usage = body?.usage;
9
+ if (!usage)
10
+ throw new Error("kimi: no usage in response");
11
+ const limit = Number(usage.limit);
12
+ const used = Number(usage.used);
13
+ if (!(limit > 0) || Number.isNaN(used))
14
+ throw new Error("kimi: bad usage numbers");
15
+ const resetsAt = usage.resetTime;
16
+ if (Number.isNaN(new Date(resetsAt).getTime()))
17
+ throw new Error("kimi: bad resetTime");
18
+ const periodStart = new Date(new Date(resetsAt).getTime() - 7 * 86400000).toISOString();
19
+ const level = body?.user?.membership?.level;
20
+ return {
21
+ provider: "kimi",
22
+ plan: level ? String(level).replace(/^LEVEL_/, "").toLowerCase() : "unknown",
23
+ usedPct: Math.round((used / limit) * 100),
24
+ resetsAt,
25
+ periodStart,
26
+ raw: JSON.stringify(body),
27
+ source: "api",
28
+ fetchedAt: now.toISOString(),
29
+ };
30
+ }
31
+ function credsCandidates() {
32
+ const home = process.env.KIMI_CODE_HOME ?? path.join(os.homedir(), ".kimi-code");
33
+ const legacy = path.join(os.homedir(), ".kimi", "credentials", "kimi-code.json");
34
+ return [path.join(home, "credentials", "kimi-code.json"), legacy];
35
+ }
36
+ async function loadCreds() {
37
+ for (const file of credsCandidates()) {
38
+ try {
39
+ return { file, creds: await readJsonFile(file) };
40
+ }
41
+ catch {
42
+ continue;
43
+ }
44
+ }
45
+ throw new Error("kimi: no credentials file — run `kimi login`");
46
+ }
47
+ function isFreshExpiry(raw) {
48
+ const v = Number(raw);
49
+ if (!Number.isFinite(v) || v <= 0)
50
+ return false;
51
+ const ms = v < 1e12 ? v * 1000 : v; // CLI stores seconds; legacy rows from earlier quotas are ms
52
+ return ms > Date.now();
53
+ }
54
+ async function refreshAndPersist(file, creds) {
55
+ if (!creds.refresh_token)
56
+ throw new Error("kimi: no refresh_token — run `kimi login`");
57
+ const tok = await postForm(REFRESH_URL, {
58
+ grant_type: "refresh_token",
59
+ client_id: CLIENT_ID,
60
+ refresh_token: creds.refresh_token,
61
+ });
62
+ if (!tok.access_token)
63
+ throw new Error("kimi: refresh returned no access_token");
64
+ await persistCreds(file, (cur) => ({
65
+ ...cur,
66
+ access_token: tok.access_token,
67
+ refresh_token: tok.refresh_token ?? cur.refresh_token,
68
+ expires_at: Math.floor(Date.now() / 1000) + (Number(tok.expires_in) || 3600),
69
+ token_type: tok.token_type ?? cur.token_type,
70
+ scope: tok.scope ?? cur.scope,
71
+ }));
72
+ return String(tok.access_token);
73
+ }
74
+ export const kimiAdapter = {
75
+ id: "kimi",
76
+ requiresAuth: "~/.kimi-code/credentials/kimi-code.json (kimi login)",
77
+ async poll() {
78
+ const { file, creds } = await loadCreds();
79
+ let token = creds.access_token ?? "";
80
+ let justRefreshed = false;
81
+ if (!token)
82
+ throw new Error("kimi: no access_token — run `kimi login`");
83
+ if (!isFreshExpiry(creds.expires_at)) {
84
+ token = await refreshAndPersist(file, creds);
85
+ justRefreshed = true;
86
+ }
87
+ const call = (t) => getJson(USAGE_URL, { Authorization: `Bearer ${t}` }).then(parseKimiUsage);
88
+ try {
89
+ return await call(token);
90
+ }
91
+ catch (e) {
92
+ if (e.status !== 401 || justRefreshed)
93
+ throw e;
94
+ const fresh = await refreshAndPersist(file, creds);
95
+ return await call(fresh);
96
+ }
97
+ },
98
+ };
package/dist/config.js CHANGED
@@ -15,7 +15,7 @@ export function getDbPath(p) {
15
15
  const ConfigSchema = z.object({
16
16
  port: z.number().default(8787),
17
17
  pollMinutes: z.number().default(15),
18
- enabledProviders: z.array(z.string()).default(["claude"]),
18
+ enabledProviders: z.array(z.string()).default(["claude", "codex", "kimi", "grok"]),
19
19
  });
20
20
  export async function readConfig(p) {
21
21
  try {
@@ -28,15 +28,15 @@ export function renderQuotasTable(quotas, advisories = []) {
28
28
  : null;
29
29
  if (!rate)
30
30
  return "—";
31
- // Pace glyphs are single-width text-presentation (no VS16), so they
32
- // cannot shift pipes in renderers that miscount emoji width.
31
+ // Verdict against the ideal rate: outside the +-20% band the cell says
32
+ // (fast)/(slow); inside it a single-width check mark.
33
33
  const value = a?.burnMeasured ? a.burnRate : q.periodStart ? (q.usedPct ?? 0) / Math.max(0.1, (Date.now() - new Date(q.periodStart).getTime()) / 86400000) : null;
34
34
  const ideal = a?.idealRate;
35
35
  if (ideal != null && value != null) {
36
- if (value > ideal)
37
- return `${rate} ⚠`;
36
+ if (value > ideal * 1.2)
37
+ return `${rate} (fast)`;
38
38
  if (value < ideal * 0.8)
39
- return `${rate} ↓`;
39
+ return `${rate} (slow)`;
40
40
  return `${rate} ✔`;
41
41
  }
42
42
  return rate;
@@ -0,0 +1,7 @@
1
+ import type { Quota } from "./types.js";
2
+ export declare function parseCodexUsage(body: any, now?: Date): Quota;
3
+ export declare const codexAdapter: {
4
+ id: string;
5
+ requiresAuth: string;
6
+ poll(): Promise<Quota>;
7
+ };
@@ -0,0 +1,61 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { readJsonFile, getJson, postForm, persistCreds } from "./core.js";
4
+ export function parseCodexUsage(body, now = new Date()) {
5
+ const rl = body?.rate_limit ?? {};
6
+ const win = rl.secondary_window ?? rl.primary_window;
7
+ if (!win)
8
+ throw new Error("codex: no rate-limit window in response");
9
+ const resetsAt = new Date(win.reset_at * 1000).toISOString();
10
+ const periodStart = new Date((win.reset_at - win.limit_window_seconds) * 1000).toISOString();
11
+ return {
12
+ provider: "codex",
13
+ plan: body.plan_type ?? "unknown",
14
+ usedPct: win.used_percent ?? 0,
15
+ resetsAt,
16
+ periodStart,
17
+ raw: JSON.stringify(body),
18
+ source: "api",
19
+ fetchedAt: now.toISOString(),
20
+ };
21
+ }
22
+ const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
23
+ const REFRESH_URL = "https://auth.openai.com/oauth/token";
24
+ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
25
+ function codexHome() {
26
+ return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
27
+ }
28
+ export const codexAdapter = {
29
+ id: "codex",
30
+ requiresAuth: "~/.codex/auth.json (codex login)",
31
+ async poll() {
32
+ const authFile = path.join(codexHome(), "auth.json");
33
+ const auth = await readJsonFile(authFile);
34
+ const at = auth.tokens?.access_token;
35
+ if (!at)
36
+ throw new Error("codex: no access_token in auth.json — run codex login");
37
+ const headers = { Authorization: `Bearer ${at}`, "User-Agent": "quotacap" };
38
+ if (auth.tokens?.account_id)
39
+ headers["ChatGPT-Account-Id"] = auth.tokens.account_id;
40
+ try {
41
+ return parseCodexUsage(await getJson(USAGE_URL, headers));
42
+ }
43
+ catch (e) {
44
+ if (e.status !== 401)
45
+ throw e;
46
+ const rf = auth.tokens?.refresh_token;
47
+ if (!rf)
48
+ throw e;
49
+ const tok = await postForm(REFRESH_URL, { grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: rf });
50
+ await persistCreds(authFile, (cur) => {
51
+ const tokens = { ...(cur.tokens ?? {}), };
52
+ if (tok.access_token)
53
+ tokens.access_token = tok.access_token;
54
+ if (tok.refresh_token)
55
+ tokens.refresh_token = tok.refresh_token;
56
+ return { ...cur, tokens, last_refresh: new Date().toISOString() };
57
+ });
58
+ return parseCodexUsage(await getJson(USAGE_URL, { ...headers, Authorization: `Bearer ${tok.access_token}` }));
59
+ }
60
+ },
61
+ };
@@ -0,0 +1,9 @@
1
+ export declare class HttpError extends Error {
2
+ status: number;
3
+ body: string;
4
+ constructor(status: number, body: string);
5
+ }
6
+ export declare function readJsonFile<T>(file: string): Promise<T>;
7
+ export declare function postForm(url: string, fields: Record<string, string>, extraHeaders?: Record<string, string>, timeoutMs?: number): Promise<any>;
8
+ export declare function getJson(url: string, headers: Record<string, string>, timeoutMs?: number): Promise<any>;
9
+ export declare function persistCreds<T>(file: string, update: (cur: T) => T, backupSuffix?: string): Promise<boolean>;
@@ -0,0 +1,90 @@
1
+ import fs from "node:fs/promises";
2
+ export class HttpError extends Error {
3
+ status;
4
+ body;
5
+ constructor(status, body) {
6
+ super(`HTTP ${status}: ${String(body).slice(0, 120)}`);
7
+ this.status = status;
8
+ this.body = body;
9
+ }
10
+ }
11
+ export async function readJsonFile(file) {
12
+ const raw = await fs.readFile(file, "utf8");
13
+ return JSON.parse(raw);
14
+ }
15
+ async function parseResponse(res) {
16
+ const text = await res.text();
17
+ if (!res.ok)
18
+ throw new HttpError(res.status, text);
19
+ try {
20
+ return JSON.parse(text);
21
+ }
22
+ catch {
23
+ return text;
24
+ }
25
+ }
26
+ export async function postForm(url, fields, extraHeaders = {}, timeoutMs = 8000) {
27
+ const res = await fetch(url, {
28
+ method: "POST",
29
+ headers: { "content-type": "application/x-www-form-urlencoded", ...extraHeaders },
30
+ body: new URLSearchParams(fields).toString(),
31
+ signal: AbortSignal.timeout(timeoutMs),
32
+ });
33
+ return parseResponse(res);
34
+ }
35
+ export async function getJson(url, headers, timeoutMs = 8000) {
36
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
37
+ return parseResponse(res);
38
+ }
39
+ async function acquireLock(lockPath, timeoutMs = 2000) {
40
+ const deadline = Date.now() + timeoutMs;
41
+ for (;;) {
42
+ try {
43
+ const fh = await fs.open(lockPath, "wx");
44
+ await fh.close();
45
+ return;
46
+ }
47
+ catch (e) {
48
+ if (e.code !== "EEXIST")
49
+ throw e;
50
+ try {
51
+ const st = await fs.stat(lockPath);
52
+ if (Date.now() - st.mtimeMs > 5000) {
53
+ await fs.rm(lockPath, { force: true });
54
+ continue;
55
+ }
56
+ }
57
+ catch {
58
+ /* lock vanished between open and stat — retry */
59
+ }
60
+ if (Date.now() > deadline)
61
+ throw new Error(`persistCreds: lock busy: ${lockPath}`);
62
+ await new Promise((r) => setTimeout(r, 25));
63
+ }
64
+ }
65
+ }
66
+ export async function persistCreds(file, update, backupSuffix = ".qc-bak") {
67
+ const lock = file + ".qc-lock";
68
+ await acquireLock(lock);
69
+ try {
70
+ const cur = await readJsonFile(file);
71
+ const next = update(cur);
72
+ const bak = file + backupSuffix;
73
+ let first = false;
74
+ try {
75
+ await fs.access(bak);
76
+ }
77
+ catch {
78
+ await fs.copyFile(file, bak);
79
+ first = true;
80
+ }
81
+ const st = await fs.stat(file);
82
+ const tmp = `${file}.qc-tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
83
+ await fs.writeFile(tmp, JSON.stringify(next, null, 2), { mode: st.mode & 0o777 });
84
+ await fs.rename(tmp, file);
85
+ return first;
86
+ }
87
+ finally {
88
+ await fs.rm(lock, { force: true });
89
+ }
90
+ }
@@ -0,0 +1,7 @@
1
+ import type { Quota } from "./types.js";
2
+ export declare function parseGrokUsage(body: any, now?: Date): Quota;
3
+ export declare const grokAdapter: {
4
+ id: string;
5
+ requiresAuth: string;
6
+ poll(): Promise<Quota>;
7
+ };