quotacap 0.0.18 → 0.0.20
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 +53 -69
- package/dist/adapters/codex.d.ts +7 -0
- package/dist/adapters/codex.js +61 -0
- package/dist/adapters/core.d.ts +9 -0
- package/dist/adapters/core.js +90 -0
- package/dist/adapters/grok.d.ts +7 -0
- package/dist/adapters/grok.js +82 -0
- package/dist/adapters/index.js +6 -0
- package/dist/adapters/kimi.d.ts +7 -0
- package/dist/adapters/kimi.js +98 -0
- package/dist/config.js +1 -1
- package/dist/src/adapters/codex.d.ts +7 -0
- package/dist/src/adapters/codex.js +61 -0
- package/dist/src/adapters/core.d.ts +9 -0
- package/dist/src/adapters/core.js +90 -0
- package/dist/src/adapters/grok.d.ts +7 -0
- package/dist/src/adapters/grok.js +82 -0
- package/dist/src/adapters/index.js +6 -0
- package/dist/src/adapters/kimi.d.ts +7 -0
- package/dist/src/adapters/kimi.js +98 -0
- package/dist/src/config.js +1 -1
- package/dist/src/version.js +1 -1
- package/dist/tests/adapters/codex.test.d.ts +1 -0
- package/dist/tests/adapters/codex.test.js +106 -0
- package/dist/tests/adapters/core.test.d.ts +1 -0
- package/dist/tests/adapters/core.test.js +110 -0
- package/dist/tests/adapters/grok.test.d.ts +1 -0
- package/dist/tests/adapters/grok.test.js +155 -0
- package/dist/tests/adapters/kimi.test.d.ts +1 -0
- package/dist/tests/adapters/kimi.test.js +120 -0
- package/dist/tests/adapters/registry.test.d.ts +1 -0
- package/dist/tests/adapters/registry.test.js +25 -0
- package/dist/version.js +1 -1
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -1,69 +1,63 @@
|
|
|
1
1
|
# QuotaCap
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
QuotaCap helps you get more from the AI coding subscriptions you already pay for: Claude Code, Codex, Kimi Code, and Grok. It tracks one current usage window and reset time for each plan, then estimates which plan to use next from remaining usage and recent pace when available.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/quotacap)
|
|
6
|
+
[](https://github.com/carlosboeing/quotacap/actions)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](package.json)
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
## Features
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
- **Visibility**: remaining usage and reset time for one current window per connected plan
|
|
13
|
+
- **Pacing**: recent usage when history is available, or an estimated pace while QuotaCap collects it
|
|
14
|
+
- **Advice**: an estimate of which plan to use next so you can use more of each allowance without exhausting one early
|
|
15
|
+
- **Dashboard, CLI, and MCP**: the same data and advice on every surface
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
## Providers
|
|
18
|
+
|
|
19
|
+
| Provider | Source |
|
|
20
|
+
|---|---|
|
|
21
|
+
| Claude Code | Live |
|
|
22
|
+
| Codex | Live |
|
|
23
|
+
| Kimi Code | Live |
|
|
24
|
+
| Grok | Live |
|
|
25
|
+
| Antigravity / Gemini | Manual (`quotacap ingest`) |
|
|
26
|
+
|
|
27
|
+
Live adapters reuse the matching CLI login.
|
|
14
28
|
|
|
15
29
|
## Install
|
|
16
30
|
|
|
17
31
|
```bash
|
|
18
|
-
# Binary
|
|
32
|
+
# Binary: macOS and Linux, arm64 and x64. No Node.
|
|
19
33
|
curl -fsSL https://raw.githubusercontent.com/carlosboeing/quotacap/main/install.sh | sh
|
|
20
34
|
|
|
21
|
-
# Or
|
|
22
|
-
npm install -g quotacap
|
|
23
|
-
npx quotacap # run without install
|
|
35
|
+
# Or install with npm (Node 22.13+)
|
|
36
|
+
npm install -g quotacap
|
|
24
37
|
```
|
|
25
38
|
|
|
26
|
-
The binary is a self-contained
|
|
27
|
-
The npm package runs the same CLI on Node.
|
|
39
|
+
The binary is a self-contained executable from GitHub Releases.
|
|
40
|
+
The npm package runs the same CLI on Node. To run without a global install, replace `quotacap` with `npx quotacap` in any command, for example `npx quotacap web`.
|
|
28
41
|
|
|
29
42
|
## Quick start
|
|
30
43
|
|
|
31
44
|
```bash
|
|
32
|
-
|
|
33
|
-
quotacap
|
|
34
|
-
quotacap
|
|
35
|
-
quotacap web # http://localhost:8787
|
|
36
|
-
quotacap advise --json # tries HTTP, falls back to local DB
|
|
37
|
-
quotacap advise --json --task heavy
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
## HTTP API
|
|
45
|
+
# Terminal 1: leave this running
|
|
46
|
+
quotacap init # writes ~/.quotacap/config.json
|
|
47
|
+
quotacap web # dashboard at http://localhost:8787
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
curl "http://localhost:8787/api/recommendation?task=any"
|
|
46
|
-
curl -X POST http://localhost:8787/api/refresh
|
|
49
|
+
# Terminal 2: after the dashboard table fills
|
|
50
|
+
quotacap status
|
|
51
|
+
quotacap advise
|
|
47
52
|
```
|
|
48
53
|
|
|
49
|
-
|
|
50
|
-
* `GET /api/quotas` → `Quota[]` with `stale`, `ageMs`
|
|
51
|
-
* `GET /api/recommendation?task=any` → `{use, reason, advisories}`
|
|
52
|
-
* `POST /api/refresh` → `{fulfilled, rejected, lastPollAt, degraded}` — debounced 60s, always 200
|
|
53
|
-
* `GET /` → `web/dist/index.html` if built, else `web/index.html`
|
|
54
|
+
`web` stays in the foreground and starts the daemon.
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
For a provider without a live adapter:
|
|
56
57
|
|
|
58
|
+
```bash
|
|
59
|
+
quotacap ingest --provider agy --text "65% used · resets Sep 1"
|
|
57
60
|
```
|
|
58
|
-
quotacap status [--json]
|
|
59
|
-
quotacap advise [--json] [--task any|heavy|light]
|
|
60
|
-
quotacap ingest --provider <id> --text "..."
|
|
61
|
-
quotacap web [--port 8787]
|
|
62
|
-
quotacap daemon [--foreground]
|
|
63
|
-
quotacap init
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
`advise` fetches `http://localhost:$port/api/recommendation` with 2s timeout. Falls back to local `recommend()` when daemon is down.
|
|
67
61
|
|
|
68
62
|
## MCP
|
|
69
63
|
|
|
@@ -76,44 +70,34 @@ quotacap init
|
|
|
76
70
|
```
|
|
77
71
|
|
|
78
72
|
Use `"command": "npx", "args": ["quotacap", "mcp"]` when installed via npm only.
|
|
79
|
-
Tools: `get_quotas`, `get_recommendation`, `forecast`. Wrapper over HTTP. Set `QUOTACAP_URL` to override.
|
|
80
73
|
|
|
81
|
-
|
|
74
|
+
Tools: `get_quotas`, `get_recommendation`, `forecast`.
|
|
82
75
|
|
|
83
|
-
|
|
84
|
-
Adapters (claude-cli ✓, manual ✓, others → manual-paste)
|
|
85
|
-
→ daemon (poll 15m + jitter, SQLite ~/.quotacap/quotacap.db)
|
|
86
|
-
→ HTTP :8787
|
|
87
|
-
→ dashboard + CLI + MCP (same handler)
|
|
88
|
-
```
|
|
76
|
+
## Privacy
|
|
89
77
|
|
|
90
|
-
|
|
78
|
+
Bound to `127.0.0.1`, with usage history stored under `~/.quotacap/`. Live adapters contact provider usage endpoints or invoke the provider CLI using your existing login. Codex, Kimi, and Grok adapters may refresh expired OAuth tokens and update the CLI-owned credential file. QuotaCap stores no API keys.
|
|
91
79
|
|
|
92
|
-
##
|
|
80
|
+
## Docs
|
|
93
81
|
|
|
94
|
-
|
|
82
|
+
- [Architecture](docs/architecture.md)
|
|
83
|
+
- [Changelog](docs/CHANGELOG.md)
|
|
84
|
+
- [Roadmap](docs/ROADMAP.md)
|
|
95
85
|
|
|
96
|
-
|
|
97
|
-
{ "port": 8787, "pollMinutes": 15, "enabledProviders": ["claude"] }
|
|
98
|
-
```
|
|
86
|
+
## License
|
|
99
87
|
|
|
100
|
-
|
|
88
|
+
MIT. See [LICENSE](LICENSE).
|
|
101
89
|
|
|
102
|
-
|
|
90
|
+
<details>
|
|
91
|
+
<summary>Development</summary>
|
|
103
92
|
|
|
104
93
|
```bash
|
|
105
|
-
npm test # vitest
|
|
94
|
+
npm test # vitest (builds first via pretest)
|
|
106
95
|
bun test tests/bun/ # bun-runtime tests (sqlite adapter, MCP translation)
|
|
107
|
-
npm run build
|
|
108
|
-
npm run build:bin
|
|
109
|
-
npx tsc --noEmit
|
|
96
|
+
npm run build
|
|
97
|
+
npm run build:bin
|
|
110
98
|
```
|
|
111
99
|
|
|
112
|
-
The
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
See `.workbench/2-design/2026-08-28-quotacap-design.md` for spec. See `docs/ROADMAP.md` for next steps.
|
|
116
|
-
|
|
117
|
-
## License
|
|
100
|
+
The store uses `node:sqlite` on Node and `bun:sqlite` on Bun.
|
|
101
|
+
The same code is an npm package and a compiled binary.
|
|
118
102
|
|
|
119
|
-
|
|
103
|
+
</details>
|
|
@@ -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,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
|
+
};
|
package/dist/adapters/index.js
CHANGED
|
@@ -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,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 {
|
|
@@ -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>;
|