@zhangferry-dev/tokendash 1.6.0 → 1.6.2

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 (60) hide show
  1. package/README.md +146 -83
  2. package/dist/client/assets/index-Bw503sNp.css +1 -0
  3. package/dist/client/index.html +2 -2
  4. package/dist/client/popover.html +4 -3
  5. package/dist/daemon.cjs +3306 -0
  6. package/dist/daemon.cjs.map +7 -0
  7. package/dist/electron-server.cjs +1043 -27
  8. package/dist/electron-server.cjs.map +4 -4
  9. package/dist/server/daemon.d.ts +12 -0
  10. package/dist/server/daemon.js +176 -0
  11. package/dist/server/index.js +39 -13
  12. package/dist/server/insightsCalculator.d.ts +15 -0
  13. package/dist/server/insightsCalculator.js +276 -0
  14. package/dist/server/quota/adapter.d.ts +47 -0
  15. package/dist/server/quota/adapter.js +41 -0
  16. package/dist/server/quota/adapters/claude.d.ts +2 -0
  17. package/dist/server/quota/adapters/claude.js +124 -0
  18. package/dist/server/quota/adapters/codex.d.ts +2 -0
  19. package/dist/server/quota/adapters/codex.js +188 -0
  20. package/dist/server/quota/adapters/glm.d.ts +2 -0
  21. package/dist/server/quota/adapters/glm.js +133 -0
  22. package/dist/server/quota/adapters/kimi.d.ts +2 -0
  23. package/dist/server/quota/adapters/kimi.js +184 -0
  24. package/dist/server/quota/adapters/minimax.d.ts +2 -0
  25. package/dist/server/quota/adapters/minimax.js +77 -0
  26. package/dist/server/quota/cache.d.ts +20 -0
  27. package/dist/server/quota/cache.js +44 -0
  28. package/dist/server/quota/credentialsFile.d.ts +13 -0
  29. package/dist/server/quota/credentialsFile.js +23 -0
  30. package/dist/server/quota/helpers.d.ts +39 -0
  31. package/dist/server/quota/helpers.js +93 -0
  32. package/dist/server/quota/index.d.ts +5 -0
  33. package/dist/server/quota/index.js +23 -0
  34. package/dist/server/quota/quotaService.d.ts +37 -0
  35. package/dist/server/quota/quotaService.js +141 -0
  36. package/dist/server/quota/schemas.d.ts +358 -0
  37. package/dist/server/quota/schemas.js +53 -0
  38. package/dist/server/quota/types.d.ts +65 -0
  39. package/dist/server/quota/types.js +10 -0
  40. package/dist/server/routes/api.d.ts +6 -1
  41. package/dist/server/routes/api.js +26 -1
  42. package/dist/server/routes/insights.d.ts +2 -0
  43. package/dist/server/routes/insights.js +155 -0
  44. package/package.json +6 -10
  45. package/resources/icon-1024.png +0 -0
  46. package/resources/icon.icns +0 -0
  47. package/resources/icon.png +0 -0
  48. package/resources/product_menu.png +0 -0
  49. package/resources/readme-hero.png +0 -0
  50. package/dist/client/assets/index-_yA9tOzZ.css +0 -1
  51. package/electron/main.cjs +0 -490
  52. package/electron/main.js +0 -291
  53. package/electron/preload.cjs +0 -36
  54. package/electron/trayBadge.cjs +0 -27
  55. package/electron/trayBadge.js +0 -30
  56. package/electron/trayHelper +0 -0
  57. package/electron/trayHelper.swift +0 -152
  58. package/electron/updateService.cjs +0 -148
  59. package/electron-builder.yml +0 -20
  60. /package/dist/client/assets/{index-CY4G_b0x.js → index-C913wKtU.js} +0 -0
@@ -0,0 +1,124 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { QuotaError, baseSnapshot } from '../adapter.js';
6
+ import { fetchJsonWithTimeout, HttpError, classifyHttpError, windowFromPercent } from '../helpers.js';
7
+ export const claudeAdapter = {
8
+ provider: 'claude',
9
+ displayName: 'Claude Code',
10
+ async isConfigured() {
11
+ const token = readClaudeToken();
12
+ return !!token;
13
+ },
14
+ async fetch() {
15
+ const token = readClaudeToken();
16
+ if (!token) {
17
+ throw new QuotaError({ state: 'not_configured', message: 'no Claude Code OAuth credential found' });
18
+ }
19
+ let data;
20
+ try {
21
+ data = (await fetchJsonWithTimeout('https://api.anthropic.com/api/oauth/usage', {
22
+ headers: {
23
+ Authorization: `Bearer ${token}`,
24
+ 'anthropic-beta': 'oauth-2025-04-20',
25
+ 'Content-Type': 'application/json',
26
+ },
27
+ }));
28
+ }
29
+ catch (err) {
30
+ throw classifyFetchError(err);
31
+ }
32
+ const windows = [];
33
+ if (data.five_hour) {
34
+ windows.push(windowFromPercent('five_hour', '5-Hour Window', data.five_hour.utilization ?? 0, {
35
+ durationMins: 300,
36
+ resetsAt: normalizeIso(data.five_hour.resets_at),
37
+ }));
38
+ }
39
+ if (data.seven_day) {
40
+ windows.push(windowFromPercent('seven_day', 'Weekly', data.seven_day.utilization ?? 0, {
41
+ durationMins: 10080,
42
+ resetsAt: normalizeIso(data.seven_day.resets_at),
43
+ }));
44
+ }
45
+ if (data.seven_day_opus?.utilization !== undefined && data.seven_day_opus?.utilization !== null) {
46
+ windows.push(windowFromPercent('seven_day_opus', 'Weekly · Opus', data.seven_day_opus.utilization, {
47
+ durationMins: 10080,
48
+ resetsAt: normalizeIso(data.seven_day_opus.resets_at),
49
+ }));
50
+ }
51
+ const snap = baseSnapshot('claude', 'Claude Code', { windows });
52
+ return { ...snap, status: { state: 'ok' } };
53
+ },
54
+ };
55
+ function classifyFetchError(err) {
56
+ if (err instanceof HttpError) {
57
+ const c = classifyHttpError(err);
58
+ return new QuotaError(c);
59
+ }
60
+ const msg = err instanceof Error ? err.message : String(err);
61
+ return new QuotaError({ state: 'upstream_unavailable', message: msg.slice(0, 200) });
62
+ }
63
+ /** The OAuth response returns ISO strings; pass through (normalize Z suffix). */
64
+ function normalizeIso(s) {
65
+ return s ? new Date(s).toISOString() : undefined;
66
+ }
67
+ /** Read the Claude Code OAuth access token from keychain (macOS) or file. */
68
+ function readClaudeToken() {
69
+ if (process.platform === 'darwin') {
70
+ const token = readFromKeychain();
71
+ if (token)
72
+ return token;
73
+ // Fall through to file for headless/SSH sessions where keychain is locked.
74
+ }
75
+ const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
76
+ const credPath = join(configDir, '.credentials.json');
77
+ if (existsSync(credPath)) {
78
+ try {
79
+ const parsed = JSON.parse(readFileSync(credPath, 'utf8'));
80
+ return parsed?.claudeAiOauth?.accessToken ?? null;
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ function readFromKeychain() {
89
+ // Keychain entries may carry a per-installation GUID suffix; try the base
90
+ // name first, then any matching suffix.
91
+ const candidates = ['Claude Code-credentials'];
92
+ try {
93
+ const list = execFileSync('security', ['dump-keychain'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' });
94
+ for (const m of list.matchAll(/"srvname"<blob>="([^"]*Claude Code-credentials[^"]*)"/g)) {
95
+ if (m[1] && !candidates.includes(m[1]))
96
+ candidates.push(m[1]);
97
+ }
98
+ }
99
+ catch {
100
+ // dump-keychain may prompt or fail; base name is the common case.
101
+ }
102
+ for (const name of candidates) {
103
+ try {
104
+ const raw = execFileSync('security', ['find-generic-password', '-s', name, '-w'], {
105
+ stdio: ['ignore', 'pipe', 'ignore'],
106
+ encoding: 'utf8',
107
+ }).trim();
108
+ if (!raw)
109
+ continue;
110
+ // The stored value is itself a JSON blob holding the OAuth tokens.
111
+ try {
112
+ const parsed = JSON.parse(raw);
113
+ return parsed?.claudeAiOauth?.accessToken ?? null;
114
+ }
115
+ catch {
116
+ return raw; // already a bare token in some setups
117
+ }
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ }
123
+ return null;
124
+ }
@@ -0,0 +1,2 @@
1
+ import type { QuotaAdapter } from '../adapter.js';
2
+ export declare const codexAdapter: QuotaAdapter;
@@ -0,0 +1,188 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { spawn } from 'node:child_process';
5
+ import { QuotaError, baseSnapshot } from '../adapter.js';
6
+ import { unixToIso, windowFromPercent } from '../helpers.js';
7
+ function codexHome() {
8
+ return process.env.CODEX_HOME || join(homedir(), '.codex');
9
+ }
10
+ export const codexAdapter = {
11
+ provider: 'codex',
12
+ displayName: 'OpenAI Codex',
13
+ async isConfigured() {
14
+ // auth.json present signals the user has logged in (file credential store).
15
+ // Keyring-stored creds won't show a file, but the app-server itself is the
16
+ // authority there — so also treat the codex binary being runnable as configured.
17
+ if (existsSync(join(codexHome(), 'auth.json')))
18
+ return true;
19
+ return await codexBinaryAvailable();
20
+ },
21
+ async fetch() {
22
+ const result = await queryRateLimits();
23
+ const buckets = result.rateLimitsByLimitId ?? (result.rateLimits ? { primary: result.rateLimits } : {});
24
+ const windows = [];
25
+ for (const [key, bucket] of Object.entries(buckets)) {
26
+ if (bucket.primary) {
27
+ windows.push(windowFromPercent(`codex_${key}_primary`, labelForBucket(key, 'primary', bucket), bucket.primary.usedPercent ?? 0, {
28
+ durationMins: bucket.primary.windowDurationMins,
29
+ resetsAt: unixToIso(bucket.primary.resetsAt),
30
+ }));
31
+ }
32
+ if (bucket.secondary) {
33
+ windows.push(windowFromPercent(`codex_${key}_secondary`, labelForBucket(key, 'secondary', bucket), bucket.secondary.usedPercent ?? 0, {
34
+ durationMins: bucket.secondary.windowDurationMins,
35
+ resetsAt: unixToIso(bucket.secondary.resetsAt),
36
+ }));
37
+ }
38
+ }
39
+ const snap = baseSnapshot('codex', 'OpenAI Codex', {
40
+ planName: result.planType ? capitalize(result.planType) : undefined,
41
+ windows,
42
+ });
43
+ return { ...snap, status: { state: 'ok' } };
44
+ },
45
+ };
46
+ function labelForBucket(key, tier, bucket) {
47
+ const dur = tier === 'primary' ? bucket.primary?.windowDurationMins : bucket.secondary?.windowDurationMins;
48
+ const durLabel = dur ? durationLabel(dur) : capitalize(tier);
49
+ // Prefer an explicit limit name; fall back to the bucket key, then the tier.
50
+ const who = bucket.limitName || (key && key !== 'primary' ? key : '');
51
+ return who ? `${capitalize(who)} · ${durLabel}` : durLabel;
52
+ }
53
+ function durationLabel(mins) {
54
+ if (mins >= 10080)
55
+ return 'Weekly';
56
+ if (mins >= 1440)
57
+ return `${Math.round(mins / 1440)}-Day`;
58
+ if (mins >= 60)
59
+ return `${Math.round(mins / 60)}-Hour`;
60
+ return `${mins}m`;
61
+ }
62
+ function capitalize(s) {
63
+ return s.charAt(0).toUpperCase() + s.slice(1);
64
+ }
65
+ // --- JSON-RPC over the codex app-server ---
66
+ async function queryRateLimits() {
67
+ if (!(await codexBinaryAvailable())) {
68
+ throw new QuotaError({ state: 'not_configured', message: 'codex CLI not found on PATH' });
69
+ }
70
+ const proc = spawn('codex', ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'] });
71
+ const client = new JsonRpcClient(proc);
72
+ try {
73
+ await client.request('initialize', {
74
+ protocolVersion: '2025-03-26',
75
+ clientInfo: { name: 'tokendash', version: '1.0.0' },
76
+ });
77
+ client.notify('initialized', {});
78
+ const res = await client.request('account/rateLimits/read', {});
79
+ return res;
80
+ }
81
+ catch (err) {
82
+ throw toQuotaError(err);
83
+ }
84
+ finally {
85
+ client.dispose();
86
+ try {
87
+ proc.kill('SIGKILL');
88
+ }
89
+ catch { /* already gone */ }
90
+ }
91
+ }
92
+ function toQuotaError(err) {
93
+ const msg = err instanceof Error ? err.message : String(err);
94
+ if (/not found|ENOENT|spawn/i.test(msg)) {
95
+ return new QuotaError({ state: 'not_configured', message: 'codex app-server unavailable' });
96
+ }
97
+ if (/401|403|unauthor|auth/i.test(msg)) {
98
+ return new QuotaError({ state: 'auth_failed', message: 'codex session not authenticated' });
99
+ }
100
+ return new QuotaError({ state: 'upstream_unavailable', message: msg.slice(0, 200) });
101
+ }
102
+ /** Minimal line-delimited JSON-RPC 2.0 client over a child process stdio. */
103
+ class JsonRpcClient {
104
+ proc;
105
+ id = 0;
106
+ buffer = '';
107
+ pending = new Map();
108
+ disposed = false;
109
+ constructor(proc) {
110
+ this.proc = proc;
111
+ proc.stdout.setEncoding('utf8');
112
+ proc.stdout.on('data', (chunk) => this.onData(chunk));
113
+ proc.on('error', (err) => this.failAll(err));
114
+ proc.on('close', () => this.failAll(new Error('codex app-server closed unexpectedly')));
115
+ }
116
+ request(method, params) {
117
+ if (this.disposed)
118
+ return Promise.reject(new Error('client disposed'));
119
+ const id = ++this.id;
120
+ const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
121
+ return new Promise((resolve, reject) => {
122
+ this.pending.set(id, { resolve: resolve, reject });
123
+ this.proc.stdin.write(msg, (err) => {
124
+ if (err)
125
+ reject(err instanceof Error ? err : new Error(String(err)));
126
+ });
127
+ });
128
+ }
129
+ notify(method, params) {
130
+ if (this.disposed)
131
+ return;
132
+ const msg = JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n';
133
+ this.proc.stdin.write(msg, () => { });
134
+ }
135
+ dispose() {
136
+ this.disposed = true;
137
+ this.failAll(new Error('disposed'));
138
+ }
139
+ onData(chunk) {
140
+ this.buffer += chunk;
141
+ let idx;
142
+ while ((idx = this.buffer.indexOf('\n')) >= 0) {
143
+ const line = this.buffer.slice(0, idx).trim();
144
+ this.buffer = this.buffer.slice(idx + 1);
145
+ if (!line)
146
+ continue;
147
+ let msg;
148
+ try {
149
+ msg = JSON.parse(line);
150
+ }
151
+ catch {
152
+ continue; // skip non-JSON framing lines
153
+ }
154
+ if (msg.id === undefined)
155
+ continue; // notifications
156
+ const entry = this.pending.get(msg.id);
157
+ if (!entry)
158
+ continue;
159
+ this.pending.delete(msg.id);
160
+ if (msg.error)
161
+ entry.reject(new Error(msg.error.message || 'codex JSON-RPC error'));
162
+ else
163
+ entry.resolve(msg.result);
164
+ }
165
+ }
166
+ failAll(err) {
167
+ for (const [, entry] of this.pending)
168
+ entry.reject(err);
169
+ this.pending.clear();
170
+ }
171
+ }
172
+ let cachedCodexPath;
173
+ async function codexBinaryAvailable() {
174
+ if (cachedCodexPath !== undefined)
175
+ return cachedCodexPath !== null;
176
+ return new Promise((resolve) => {
177
+ const proc = spawn('which', ['codex'], { stdio: ['ignore', 'pipe', 'ignore'] });
178
+ let out = '';
179
+ proc.stdout.on('data', (c) => { out += c; });
180
+ proc.on('close', () => {
181
+ cachedCodexPath = out.trim() || null;
182
+ resolve(cachedCodexPath !== null);
183
+ });
184
+ proc.on('error', () => { cachedCodexPath = null; resolve(false); });
185
+ });
186
+ }
187
+ // Keep readFileSync referenced for potential future auth.json inspection without network.
188
+ void readFileSync;
@@ -0,0 +1,2 @@
1
+ import type { QuotaAdapter } from '../adapter.js';
2
+ export declare const glmAdapter: QuotaAdapter;
@@ -0,0 +1,133 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { QuotaError, baseSnapshot } from '../adapter.js';
5
+ import { fetchJsonWithTimeout, HttpError, classifyHttpError, windowFromPercent, windowFromCounts, unixToIso } from '../helpers.js';
6
+ import { readStoredCredential } from '../credentialsFile.js';
7
+ export const glmAdapter = {
8
+ provider: 'glm',
9
+ displayName: 'GLM Coding Plan',
10
+ async isConfigured() {
11
+ return !!resolveCredential();
12
+ },
13
+ async fetch() {
14
+ const cred = resolveCredential();
15
+ if (!cred) {
16
+ throw new QuotaError({ state: 'not_configured', message: 'set ZAI_API_KEY or ZHIPU_API_KEY' });
17
+ }
18
+ let data;
19
+ try {
20
+ data = (await fetchJsonWithTimeout(`${cred.base}/api/monitor/usage/quota/limit`, {
21
+ headers: {
22
+ // GLM wants the raw key, NOT "Bearer <key>".
23
+ Authorization: cred.key,
24
+ 'Accept-Language': 'en-US,en',
25
+ 'Content-Type': 'application/json',
26
+ },
27
+ }));
28
+ }
29
+ catch (err) {
30
+ throw classifyFetchError(err);
31
+ }
32
+ if (!data?.success && data?.code !== 200) {
33
+ throw new QuotaError({ state: 'upstream_unavailable', message: data?.msg || 'GLM quota request failed' });
34
+ }
35
+ const limits = data.data?.limits ?? [];
36
+ const windows = [];
37
+ // TOKENS_LIMIT: two entries (5h + weekly). Sort by reset time so the
38
+ // shorter window is labeled first.
39
+ const tokenLimits = limits
40
+ .filter((l) => l.type === 'TOKENS_LIMIT' && typeof l.percentage === 'number')
41
+ .sort((a, b) => (a.nextResetTime ?? 0) - (b.nextResetTime ?? 0));
42
+ tokenLimits.forEach((l, i) => {
43
+ const isShort = i === 0; // first after sort = nearer reset = 5h
44
+ windows.push(windowFromPercent(isShort ? 'glm_5h' : 'glm_weekly', isShort ? '5-Hour Window' : 'Weekly', l.percentage ?? 0, { durationMins: isShort ? 300 : 10080, resetsAt: unixToIso(l.nextResetTime) }));
45
+ });
46
+ // TIME_LIMIT: monthly MCP usage, reported with absolute counts.
47
+ const timeLimit = limits.find((l) => l.type === 'TIME_LIMIT');
48
+ if (timeLimit && typeof timeLimit.usage === 'number') {
49
+ windows.push(windowFromCounts('glm_mcp_monthly', 'MCP · Monthly', timeLimit.currentValue ?? 0, timeLimit.usage));
50
+ }
51
+ const snap = baseSnapshot('glm', 'GLM Coding Plan', {
52
+ planName: data.data?.level ? capitalize(data.data.level) : undefined,
53
+ windows,
54
+ });
55
+ return { ...snap, status: { state: 'ok' } };
56
+ },
57
+ };
58
+ function classifyFetchError(err) {
59
+ if (err instanceof HttpError) {
60
+ const c = classifyHttpError(err);
61
+ return new QuotaError(c);
62
+ }
63
+ const msg = err instanceof Error ? err.message : String(err);
64
+ return new QuotaError({ state: 'upstream_unavailable', message: msg.slice(0, 200) });
65
+ }
66
+ function resolveCredential() {
67
+ // 0. Key entered in-app (via the credential sheet) — highest priority.
68
+ const stored = readStoredCredential('glm');
69
+ if (stored)
70
+ return { key: stored.apiKey, base: stored.baseUrl || 'https://open.bigmodel.cn' };
71
+ // 1. Explicit GLM Coding Plan API keys.
72
+ const zai = envOrConfig('ZAI_API_KEY');
73
+ if (zai)
74
+ return { key: zai, base: envOrConfig('ZAI_BASE_URL') || 'https://api.z.ai' };
75
+ const zhipu = envOrConfig('ZHIPU_API_KEY');
76
+ if (zhipu)
77
+ return { key: zhipu, base: envOrConfig('ZHIPU_BASE_URL') || 'https://open.bigmodel.cn' };
78
+ // 2. cc-switch / Claude Code scenario: ANTHROPIC_BASE_URL is pointed at a
79
+ // GLM domain (open.bigmodel.cn or api.z.ai) and the plan token lives in
80
+ // ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY. The monitor endpoint shares the
81
+ // same token; we just swap the path from /api/anthropic to /api/monitor/...
82
+ const anthropicBase = envOrConfig('ANTHROPIC_BASE_URL');
83
+ if (anthropicBase && isGlmHost(anthropicBase)) {
84
+ const origin = originOf(anthropicBase);
85
+ const token = envOrConfig('ANTHROPIC_AUTH_TOKEN') || envOrConfig('ANTHROPIC_API_KEY');
86
+ if (origin && token)
87
+ return { key: token, base: origin };
88
+ }
89
+ return null;
90
+ }
91
+ function isGlmHost(url) {
92
+ const h = url.toLowerCase();
93
+ return h.includes('bigmodel.cn') || h.includes('z.ai');
94
+ }
95
+ function originOf(url) {
96
+ try {
97
+ const u = new URL(url);
98
+ return `${u.protocol}//${u.host}`;
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ /**
105
+ * Read an env var, falling back to the Claude Code settings.json `env` block.
106
+ * Needed because the Swift app launches the daemon from Finder, where the
107
+ * ANTHROPIC_* vars Claude Code injects into its own process are absent — but
108
+ * they're persisted in ~/.claude/settings.json (how cc-switch writes them).
109
+ */
110
+ let claudeSettingsEnv;
111
+ function envOrConfig(key) {
112
+ if (process.env[key])
113
+ return process.env[key];
114
+ if (claudeSettingsEnv === undefined)
115
+ claudeSettingsEnv = loadClaudeSettingsEnv();
116
+ return claudeSettingsEnv?.[key];
117
+ }
118
+ function loadClaudeSettingsEnv() {
119
+ const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
120
+ try {
121
+ const path = join(configDir, 'settings.json');
122
+ if (!existsSync(path))
123
+ return null;
124
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
125
+ return parsed?.env && typeof parsed.env === 'object' ? parsed.env : null;
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ function capitalize(s) {
132
+ return s.charAt(0).toUpperCase() + s.slice(1);
133
+ }
@@ -0,0 +1,2 @@
1
+ import type { QuotaAdapter } from '../adapter.js';
2
+ export declare const kimiAdapter: QuotaAdapter;
@@ -0,0 +1,184 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { readStoredCredential } from '../credentialsFile.js';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { QuotaError, baseSnapshot } from '../adapter.js';
6
+ import { fetchJsonWithTimeout, HttpError, classifyHttpError, windowFromCounts } from '../helpers.js';
7
+ /**
8
+ * Kimi Code adapter.
9
+ *
10
+ * Source: `GET https://api.kimi.com/coding/v1/usages`, Bearer auth with the
11
+ * OAuth token stored by the Kimi CLI at ~/.kimi/credentials/kimi-code.json.
12
+ * Returns a primary weekly `usage` block plus an arbitrary-length `limits[]`
13
+ * array of windows (e.g. a 5-hour rolling window).
14
+ *
15
+ * Token lifetime is ~1h, so we refresh it from auth.kimi.com before it
16
+ * expires and persist the new tokens back to the same file (the Kimi CLI
17
+ * reads the same file, so both stay in sync).
18
+ */
19
+ const KIMI_CLIENT_ID = '17e5f671-d194-4dfb-9706-5516cb48c098';
20
+ const KIMI_BASE = 'https://api.kimi.com/coding/v1';
21
+ const KIMI_AUTH = 'https://auth.kimi.com/api/oauth/token';
22
+ export const kimiAdapter = {
23
+ provider: 'kimi',
24
+ displayName: 'Kimi Code',
25
+ async isConfigured() {
26
+ const cred = readCredentials();
27
+ return !!cred && !!cred.access_token;
28
+ },
29
+ async fetch() {
30
+ const credPath = credentialsPath();
31
+ let cred = readCredentials();
32
+ if (!cred || !cred.access_token) {
33
+ throw new QuotaError({ state: 'not_configured', message: 'run `kimi` to log in first' });
34
+ }
35
+ // Refresh proactively if within 5 min of expiry.
36
+ const nowSec = Math.floor(Date.now() / 1000);
37
+ if (cred.expires_at && cred.expires_at - nowSec < 300 && cred.refresh_token) {
38
+ cred = await refreshToken(credPath, cred.refresh_token).catch(() => cred);
39
+ }
40
+ let data;
41
+ try {
42
+ data = (await fetchJsonWithTimeout(`${KIMI_BASE}/usages`, {
43
+ headers: {
44
+ Authorization: `Bearer ${cred.access_token}`,
45
+ Accept: 'application/json',
46
+ },
47
+ }));
48
+ }
49
+ catch (err) {
50
+ throw classifyFetchError(err);
51
+ }
52
+ const windows = [];
53
+ // Primary weekly usage.
54
+ if (data.usage) {
55
+ const { used, limit } = toCounts(data.usage);
56
+ if (limit > 0) {
57
+ windows.push(windowFromCounts('kimi_weekly', 'Weekly', used, limit, {
58
+ durationMins: 10080,
59
+ resetsAt: normalizeIso(data.usage.resetTime),
60
+ }));
61
+ }
62
+ }
63
+ // Arbitrary-length limits[] (e.g. 5-hour rolling window).
64
+ for (let i = 0; i < (data.limits?.length ?? 0); i++) {
65
+ const entry = data.limits[i];
66
+ const detail = entry?.detail;
67
+ if (!detail)
68
+ continue;
69
+ const { used, limit } = toCounts(detail);
70
+ if (limit <= 0)
71
+ continue;
72
+ const mins = minutesForWindow(entry?.window);
73
+ windows.push(windowFromCounts(`kimi_limit_${i}`, mins ? `${durationLabel(mins)} Window` : `Window ${i + 1}`, used, limit, {
74
+ durationMins: mins,
75
+ resetsAt: normalizeIso(detail.resetTime),
76
+ }));
77
+ }
78
+ const snap = baseSnapshot('kimi', 'Kimi Code', {
79
+ planName: data.user?.membership?.level ? prettifyLevel(data.user.membership.level) : undefined,
80
+ windows,
81
+ });
82
+ return { ...snap, status: { state: 'ok' } };
83
+ },
84
+ };
85
+ function classifyFetchError(err) {
86
+ if (err instanceof HttpError) {
87
+ const c = classifyHttpError(err);
88
+ return new QuotaError(c);
89
+ }
90
+ const msg = err instanceof Error ? err.message : String(err);
91
+ return new QuotaError({ state: 'upstream_unavailable', message: msg.slice(0, 200) });
92
+ }
93
+ /** Kimi returns limit/remaining as STRINGS — coerce and invert to used. */
94
+ function toCounts(detail) {
95
+ const limit = toNumber(detail.limit);
96
+ const remaining = toNumber(detail.remaining);
97
+ if (limit <= 0)
98
+ return { used: 0, limit: 0 };
99
+ return { used: Math.max(0, limit - remaining), limit };
100
+ }
101
+ function toNumber(v) {
102
+ if (v === undefined || v === null)
103
+ return 0;
104
+ const n = typeof v === 'string' ? parseFloat(v) : v;
105
+ return Number.isFinite(n) ? n : 0;
106
+ }
107
+ function minutesForWindow(window) {
108
+ if (!window?.duration)
109
+ return undefined;
110
+ const unit = (window.timeUnit || '').toUpperCase();
111
+ if (unit.includes('HOUR'))
112
+ return window.duration * 60;
113
+ if (unit.includes('DAY'))
114
+ return window.duration * 1440;
115
+ return window.duration; // default minutes
116
+ }
117
+ function durationLabel(mins) {
118
+ if (mins >= 10080)
119
+ return 'Weekly';
120
+ if (mins >= 1440)
121
+ return `${Math.round(mins / 1440)}-Day`;
122
+ if (mins >= 60)
123
+ return `${Math.round(mins / 60)}-Hour`;
124
+ return `${mins}m`;
125
+ }
126
+ function normalizeIso(s) {
127
+ return s ? new Date(s).toISOString() : undefined;
128
+ }
129
+ function prettifyLevel(level) {
130
+ // "LEVEL_INTERMEDIATE" -> "Intermediate"
131
+ return level.replace(/^LEVEL_/, '').toLowerCase().replace(/(^|_)(\w)/g, (_, __, c) => ' ' + c.toUpperCase()).trim();
132
+ }
133
+ function kimiDataDir() {
134
+ return process.env.KIMI_DATA_DIR || join(homedir(), '.kimi');
135
+ }
136
+ function credentialsPath() {
137
+ return join(kimiDataDir(), 'credentials', 'kimi-code.json');
138
+ }
139
+ function readCredentials() {
140
+ // 0. Token entered in-app (via the credential sheet) — highest priority.
141
+ // Treated as a bare access token; no refresh token, so it's used as-is.
142
+ const stored = readStoredCredential('kimi');
143
+ if (stored?.apiKey) {
144
+ return { access_token: stored.apiKey, token_type: 'Bearer' };
145
+ }
146
+ const path = credentialsPath();
147
+ if (!existsSync(path))
148
+ return null;
149
+ try {
150
+ return JSON.parse(readFileSync(path, 'utf8'));
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ async function refreshToken(credPath, refreshToken) {
157
+ const body = new URLSearchParams({
158
+ client_id: KIMI_CLIENT_ID,
159
+ grant_type: 'refresh_token',
160
+ refresh_token: refreshToken,
161
+ });
162
+ const res = await fetch(KIMI_AUTH, {
163
+ method: 'POST',
164
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
165
+ body,
166
+ });
167
+ if (!res.ok)
168
+ throw new Error(`token refresh failed: HTTP ${res.status}`);
169
+ const tokens = (await res.json());
170
+ const updated = {
171
+ access_token: tokens.access_token,
172
+ refresh_token: tokens.refresh_token || refreshToken,
173
+ expires_at: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600),
174
+ token_type: tokens.token_type || 'Bearer',
175
+ };
176
+ // Persist back so the Kimi CLI and this adapter share the refreshed token.
177
+ try {
178
+ writeFileSync(credPath, JSON.stringify(updated, null, 2), 'utf8');
179
+ }
180
+ catch {
181
+ // Best-effort; the in-memory token still works for this request.
182
+ }
183
+ return updated;
184
+ }
@@ -0,0 +1,2 @@
1
+ import type { QuotaAdapter } from '../adapter.js';
2
+ export declare const minimaxAdapter: QuotaAdapter;