@vibe-cafe/vibe-usage 0.12.1 → 0.13.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 +4 -2
- package/package.json +1 -1
- package/src/parsers/dsh.js +10 -5
- package/src/quotas/providers/kimi-code.js +45 -25
- package/src/quotas/providers/opencode-go.js +225 -0
- package/src/quotas/registry.js +8 -0
- package/src/quotas/schema.js +82 -1
package/README.md
CHANGED
|
@@ -62,9 +62,11 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
|
|
|
62
62
|
|
|
63
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
64
|
|
|
65
|
-
-
|
|
65
|
+
- A quota result may carry a machine-readable `emptyReason` (`limitReached` / `noWindow` / `notEntitled` / `sessionWithoutPlanLimits`) alongside the human `message`; it is additive within schema v1, so clients that do not know it ignore it, and it says *why* there was no window instead of leaving the caller to parse prose. OpenCode Go sets `notEntitled` for a key without the Go subscription.
|
|
66
|
+
- **Kimi Code** reads the official OAuth file from `$KIMI_CODE_HOME/credentials/kimi-code.json` (otherwise `~/.kimi-code/credentials/kimi-code.json`) and falls back to the legacy `$KIMI_SHARE_DIR` / `~/.kimi` location — the first login that exists wins. It calls `https://api.kimi.com/coding/v1/usages` and supports both the current 2.x response and the legacy quota response. 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
67
|
- **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
68
|
- **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.
|
|
69
|
+
- **OpenCode Go** reads only the `opencode-go` integration key from OpenCode's own credential table (`~/.local/share/opencode/opencode.db`, read-only via the shared SQLite helper), falling back to the CLI's `auth.json` `opencode.key` for the pre-2.x layout that keeps its login there (`VIBE_USAGE_OPENCODE_DIRS` replaces discovery for fixtures/relocated data), and calls the official `https://opencode.ai/zen/go/v1/usage` endpoint with it. It reports the three windows OpenCode Go enforces (5-hour rolling, weekly, monthly) as normalized meters. A valid OpenCode key without an active Go subscription (HTTP 403) is a quiet "no data" rather than an error. The key is never written back, logged, or included in output; only a one-way hash of it scopes the disposable cache.
|
|
68
70
|
- **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
71
|
|
|
70
72
|
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.
|
|
@@ -95,7 +97,7 @@ Quota results are never uploaded or added to incremental sync state. Credential-
|
|
|
95
97
|
| MiMoCode | `$MIMOCODE_HOME/data/mimocode.db`, `$XDG_DATA_HOME/mimocode/mimocode.db`, or `~/.local/share/mimocode/mimocode.db` (SQLite; exact input, output, reasoning, and cache-read tokens from assistant messages; honors `MIMOCODE_DB`; cache-write tokens are included in input usage) |
|
|
96
98
|
| Amp | `~/.local/share/amp/threads/`; cache-creation tokens are included in input usage |
|
|
97
99
|
| Droid | `~/.factory/sessions/` |
|
|
98
|
-
| DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads V0–
|
|
100
|
+
| DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads V0–V4 logs, including `session.v4.jsonl.zstd` from DSH's V4 integration format, with multi-frame Zstandard support (Node ≥ 22.15 built-in, `zstd` CLI fallback) and plain JSONL support. Each session uses its highest `session[.vN].jsonl[.zstd]` generation once, so frozen pre-migration logs are not double-counted. Usage comes from `assistant/message`: cache writes join uncached input, cache reads remain separate, and reasoning is split out of inclusive output. Fork history uses V0/V1 `seedLength` or V2–V4's last `session/end-seed` tagged `inherited: true`, and is skipped only when the parent copy confirms it; missing parents retain the sole local history. Unknown versions warn and protect sync state. |
|
|
99
101
|
| Hermes (CLI / Desktop) | `<home>/state.db` + `<home>/profiles/<name>/state.db` (SQLite, multi-profile). Home: `$HERMES_HOME`, otherwise `~/.hermes` on macOS/Linux or `%LOCALAPPDATA%\hermes` on Windows (falls back to an existing `~/.hermes` only when the Windows native root is absent). Cache writes join input; reasoning is separated from inclusive output. Usage is currently a cumulative session total attributed to session start: a session spanning several days does **not** yet provide an accurate daily breakdown. |
|
|
100
102
|
| Kiro | Kiro CLI native event streams `~/.kiro/sessions/cli/*.jsonl` (estimated tokens from message text: input = prompt + tool results, output = reply + tool calls, reasoning = thinking, cacheRead = re-sent context; thinking-block signatures excluded). Falls back to `~/Library/Application Support/kiro-cli/data.sqlite3` / `~/.local/share/kiro-cli/data.sqlite3` + optional `~/.kiro_sessions/*.json` archives, then IDE `q-client.log` whole-credit deltas as `kiro-credits` (floored cumulative diff — the server stores token counts as bigint); legacy IDE `dev_data/devdata.sqlite` token telemetry is opt-in with `VIBE_USAGE_KIRO_LEGACY_TOKENS=1` |
|
|
101
103
|
| Cline (CLI / SDK / Desktop app) | All variants write the same `~/.cline/data/sessions/*/*.messages.json` per-call metrics (the desktop app's manifest says `source: "desktop"`; its Electron userData holds only WebView caches), plus legacy `~/.cline/{,data/}state/taskHistory.json` and editor extension stores. Honors `CLINE_DIR`, `CLINE_DATA_DIR`, and `CLINE_SESSION_DATA_DIR`; copied history is deduplicated |
|
package/package.json
CHANGED
package/src/parsers/dsh.js
CHANGED
|
@@ -7,13 +7,18 @@ import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
|
7
7
|
|
|
8
8
|
const SOURCE = 'dsh';
|
|
9
9
|
|
|
10
|
-
// Verified against deepseek-ai/deepseek-harness
|
|
10
|
+
// Verified against deepseek-ai/deepseek-harness, released formats V0-V4:
|
|
11
11
|
// packages/session/session-format/src/filename.ts, session-format-v0-to-v1,
|
|
12
|
-
// session-format-v1-to-v2, session-format-v2-to-v3,
|
|
13
|
-
//
|
|
12
|
+
// session-format-v1-to-v2, session-format-v2-to-v3, core/session/src/types.ts,
|
|
13
|
+
// and docs/persistence-changes/2026-09-16-session-format-v4.md.
|
|
14
|
+
// V0/V1 use header.seedLength; V2-V4 use a tagged inherited end-seed marker.
|
|
15
|
+
// V4 (SESSION_FORMAT_VERSION = 4, commit 669b724a78) keeps the filename, header
|
|
16
|
+
// fields, assistant usage block, and inherited cut; it lifts user-role tool
|
|
17
|
+
// results into tool-role messages, adds forked to turn/end.reason, and adds
|
|
18
|
+
// developer/message, none of which carry billable usage.
|
|
14
19
|
// Re-check the format before accepting another version; never silently read a
|
|
15
20
|
// frozen predecessor when a newer generation is present.
|
|
16
|
-
const MAX_SESSION_FORMAT_VERSION =
|
|
21
|
+
const MAX_SESSION_FORMAT_VERSION = 4;
|
|
17
22
|
const SESSION_FILENAME = /^session(?:\.v([1-9][0-9]*))?\.jsonl(\.zstd)?$/;
|
|
18
23
|
|
|
19
24
|
// Safety cap for a single session log. DSH stores many small zstd frames per
|
|
@@ -200,7 +205,7 @@ function isUserMessageRecord(rec) {
|
|
|
200
205
|
* {"type":"user/message"|"assistant/message","time":...,"data":{...}}
|
|
201
206
|
*
|
|
202
207
|
* V0/V1 use header.seedLength. Their untagged end-seed markers can appear
|
|
203
|
-
* after real history and must never be treated as a replay boundary. V2
|
|
208
|
+
* after real history and must never be treated as a replay boundary. V2-V4
|
|
204
209
|
* instead require isSeeded and use the LAST end-seed with data.inherited=true.
|
|
205
210
|
* Only a proven inherited prefix also present in the parent is skipped.
|
|
206
211
|
*
|
|
@@ -2,7 +2,6 @@ import {
|
|
|
2
2
|
accessSync,
|
|
3
3
|
closeSync,
|
|
4
4
|
constants as fsConstants,
|
|
5
|
-
existsSync,
|
|
6
5
|
fsyncSync,
|
|
7
6
|
mkdirSync,
|
|
8
7
|
openSync,
|
|
@@ -16,7 +15,7 @@ import {
|
|
|
16
15
|
import { homedir } from 'node:os';
|
|
17
16
|
import { dirname, join } from 'node:path';
|
|
18
17
|
import { attachCacheScope } from '../cache.js';
|
|
19
|
-
import { quotaResult } from '../schema.js';
|
|
18
|
+
import { canonicalizeMeters, quotaResult } from '../schema.js';
|
|
20
19
|
|
|
21
20
|
const PRODUCT_ID = 'kimi-code';
|
|
22
21
|
const DEFAULT_USAGE_URL = 'https://api.kimi.com/coding/v1/usages';
|
|
@@ -111,6 +110,29 @@ export function parseKimiUsage(payload, now = new Date()) {
|
|
|
111
110
|
throw new Error('Kimi usage response is not an object');
|
|
112
111
|
}
|
|
113
112
|
const meters = [];
|
|
113
|
+
if (payload.usages && typeof payload.usages === 'object' && !Array.isArray(payload.usages)) {
|
|
114
|
+
const currentMeters = [
|
|
115
|
+
['limit_5h', '5h', 5 * 3600],
|
|
116
|
+
['limit_7d', '7d', 7 * 86400],
|
|
117
|
+
['limit_month_total', 'Monthly', null],
|
|
118
|
+
['limit_month_code', 'Monthly Code', null],
|
|
119
|
+
];
|
|
120
|
+
for (const [key, label, windowSeconds] of currentMeters) {
|
|
121
|
+
const entry = payload.usages[key];
|
|
122
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
123
|
+
const ratio = number(entry.used_ratio ?? entry.usedRatio);
|
|
124
|
+
if (ratio === null) continue;
|
|
125
|
+
const meter = {
|
|
126
|
+
id: key.replaceAll('_', '-'),
|
|
127
|
+
label,
|
|
128
|
+
utilization: Math.max(0, Math.min(100, ratio * 100)),
|
|
129
|
+
};
|
|
130
|
+
const resetsAt = resetDate(entry, now);
|
|
131
|
+
if (resetsAt) meter.resetsAt = resetsAt.toISOString();
|
|
132
|
+
if (windowSeconds) meter.windowSeconds = windowSeconds;
|
|
133
|
+
meters.push(meter);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
114
136
|
if (payload.usage && typeof payload.usage === 'object' && !Array.isArray(payload.usage)) {
|
|
115
137
|
const summary = meterFrom(payload.usage, payload.usage, 0, 'Weekly', now);
|
|
116
138
|
if (summary) meters.push(summary);
|
|
@@ -127,7 +149,7 @@ export function parseKimiUsage(payload, now = new Date()) {
|
|
|
127
149
|
}
|
|
128
150
|
}
|
|
129
151
|
const seen = new Set();
|
|
130
|
-
return meters.filter(meter => {
|
|
152
|
+
return canonicalizeMeters(meters).filter(meter => {
|
|
131
153
|
const key = `${meter.label}\0${meter.windowSeconds || ''}`;
|
|
132
154
|
if (seen.has(key)) return false;
|
|
133
155
|
seen.add(key);
|
|
@@ -135,35 +157,33 @@ export function parseKimiUsage(payload, now = new Date()) {
|
|
|
135
157
|
});
|
|
136
158
|
}
|
|
137
159
|
|
|
138
|
-
// Kimi Code has two credential locations in the wild
|
|
139
|
-
// `$
|
|
140
|
-
// the
|
|
141
|
-
//
|
|
142
|
-
//
|
|
160
|
+
// Kimi Code has two credential locations in the wild: the current CLI (2.x)
|
|
161
|
+
// writes `$KIMI_CODE_HOME/credentials/kimi-code.json`, defaulting to
|
|
162
|
+
// `~/.kimi-code` -- the same home the session parser already resolves -- while
|
|
163
|
+
// kimi-cli (1.x) keeps `$KIMI_SHARE_DIR/credentials/kimi-code.json`, defaulting
|
|
164
|
+
// to `~/.kimi`. Reading only the legacy path reported `missing_credentials` for
|
|
143
165
|
// every 2.x user whose login sits in the new home (issue #112), while their
|
|
144
166
|
// usage parsed fine, so the quota card contradicted the token tables.
|
|
145
167
|
export function kimiCredentialPaths(environment = process.env, home = homedir()) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
join(
|
|
150
|
-
join(home, '.kimi'),
|
|
151
|
-
];
|
|
152
|
-
return directories.filter(Boolean).map(directory => join(directory, 'credentials', 'kimi-code.json'));
|
|
168
|
+
const codeHome = environment.KIMI_CODE_HOME?.trim() || join(home, '.kimi-code');
|
|
169
|
+
const legacyHome = environment.KIMI_SHARE_DIR?.trim() || join(home, '.kimi');
|
|
170
|
+
return [...new Set([codeHome, legacyHome]
|
|
171
|
+
.map(directory => join(directory, 'credentials', 'kimi-code.json')))];
|
|
153
172
|
}
|
|
154
173
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
* home outranks the legacy one. With no login anywhere the current CLI's path
|
|
161
|
-
* is returned so `missing_credentials` (and any rotation that follows a later
|
|
162
|
-
* login) names the file the installed CLI actually writes.
|
|
163
|
-
*/
|
|
174
|
+
// The file to read and, on refresh, to rotate atomically: the first existing
|
|
175
|
+
// login, current home before legacy, so a stale `KIMI_SHARE_DIR` cannot shadow
|
|
176
|
+
// the CLI the user actually runs. With no login anywhere the current CLI's path
|
|
177
|
+
// is returned, so `missing_credentials` (and any rotation after a later login)
|
|
178
|
+
// names the file the installed CLI writes.
|
|
164
179
|
export function kimiCredentialPath(environment = process.env, home = homedir()) {
|
|
165
180
|
const paths = kimiCredentialPaths(environment, home);
|
|
166
|
-
|
|
181
|
+
for (const path of paths) {
|
|
182
|
+
try {
|
|
183
|
+
if (statSync(path).isFile()) return path;
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
return paths[0];
|
|
167
187
|
}
|
|
168
188
|
|
|
169
189
|
function readCredentials(path) {
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { delimiter, join } from 'node:path';
|
|
4
|
+
import { attachCacheScope } from '../cache.js';
|
|
5
|
+
import { quotaResult } from '../schema.js';
|
|
6
|
+
import { isSqliteUnavailableError, queryDbJson, sqliteUnavailableError } from '../../parsers/sqlite.js';
|
|
7
|
+
|
|
8
|
+
const PRODUCT_ID = 'opencode-go';
|
|
9
|
+
const INTEGRATION_ID = 'opencode-go';
|
|
10
|
+
const DEFAULT_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage';
|
|
11
|
+
|
|
12
|
+
// The endpoint reports the three windows OpenCode Go enforces (rolling /
|
|
13
|
+
// weekly / monthly). It does not return the rolling window length, but the
|
|
14
|
+
// official console labels the rolling window "5 hours", so the meter reuses
|
|
15
|
+
// that window name; weekly/monthly are calendar windows whose reset instant
|
|
16
|
+
// travels with the response.
|
|
17
|
+
const METER_ORDER = [
|
|
18
|
+
['rolling', '5h'],
|
|
19
|
+
['weekly', 'Weekly'],
|
|
20
|
+
['monthly', 'Monthly'],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** Data roots scanned for the OpenCode credential store, in order. */
|
|
24
|
+
export function openCodeDataRoots(environment = process.env, home = homedir()) {
|
|
25
|
+
const override = environment.VIBE_USAGE_OPENCODE_DIRS?.trim();
|
|
26
|
+
if (override) {
|
|
27
|
+
return override.split(delimiter).map(value => value.trim()).filter(Boolean);
|
|
28
|
+
}
|
|
29
|
+
return [join(home, '.local', 'share', 'opencode')];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function openCodeDbPath(root) {
|
|
33
|
+
return join(root, 'opencode.db');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function date(value) {
|
|
37
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
38
|
+
const parsed = new Date(value);
|
|
39
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reads only the `opencode-go` integration key from OpenCode's credential
|
|
44
|
+
* table. The value is the client's own JSON blob (`{ type: 'key', key }`);
|
|
45
|
+
* nothing else in the row or database is selected, logged, cached, or
|
|
46
|
+
* uploaded.
|
|
47
|
+
*/
|
|
48
|
+
function readCredentialTable(root) {
|
|
49
|
+
const dbPath = openCodeDbPath(root);
|
|
50
|
+
if (!existsSync(dbPath)) return { status: 'missing' };
|
|
51
|
+
try {
|
|
52
|
+
const rows = queryDbJson(dbPath, `SELECT value FROM credential
|
|
53
|
+
WHERE integration_id = '${INTEGRATION_ID}'
|
|
54
|
+
ORDER BY coalesce(active, 1) DESC, time_updated DESC
|
|
55
|
+
LIMIT 1`);
|
|
56
|
+
const raw = rows[0]?.value;
|
|
57
|
+
if (typeof raw !== 'string' || !raw.trim()) return { status: 'missing' };
|
|
58
|
+
let key = '';
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
key = typeof parsed?.key === 'string' ? parsed.key.trim() : '';
|
|
62
|
+
} catch {
|
|
63
|
+
key = '';
|
|
64
|
+
}
|
|
65
|
+
return key ? { status: 'ok', key } : { status: 'missing' };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (isSqliteUnavailableError(error)) return { status: 'unavailable' };
|
|
68
|
+
// An older/newer schema without the credential table simply has no login.
|
|
69
|
+
if (/no such table|no such column/i.test(error?.message || '')) return { status: 'missing' };
|
|
70
|
+
return { status: 'error' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fallback for the pre-2.x layout, which keeps the same key as plain JSON in
|
|
76
|
+
* `auth.json` (`{"opencode": {"type": "api", "key": "sk-..."}}`). Only that one
|
|
77
|
+
* entry is read; sibling provider credentials in the file are never inspected,
|
|
78
|
+
* and the key is returned to the caller without ever being logged or cached.
|
|
79
|
+
* The credential table stays authoritative when it has a Go row — it is the
|
|
80
|
+
* Go-specific record, while `auth.json` also holds non-Go keys.
|
|
81
|
+
*/
|
|
82
|
+
function readAuthFile(root) {
|
|
83
|
+
let parsed = null;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(readFileSync(join(root, 'auth.json'), 'utf8'));
|
|
86
|
+
} catch {
|
|
87
|
+
return { status: 'missing' };
|
|
88
|
+
}
|
|
89
|
+
const key = parsed?.opencode?.key;
|
|
90
|
+
const trimmed = typeof key === 'string' ? key.trim() : '';
|
|
91
|
+
return trimmed ? { status: 'ok', key: trimmed } : { status: 'missing' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The Go key for one data root, or a failure to report. The credential table
|
|
96
|
+
* wins; `auth.json` covers stores that never migrated. A concrete table read
|
|
97
|
+
* failure is preserved only when the fallback has no key either, so an
|
|
98
|
+
* unreadable database cannot hide a perfectly good login.
|
|
99
|
+
*/
|
|
100
|
+
function readCredential(root) {
|
|
101
|
+
const table = readCredentialTable(root);
|
|
102
|
+
if (table.status === 'ok') return table;
|
|
103
|
+
const authFile = readAuthFile(root);
|
|
104
|
+
if (authFile.status === 'ok') return authFile;
|
|
105
|
+
return table;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function parseOpenCodeGoUsage(payload) {
|
|
109
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
110
|
+
throw new Error('OpenCode usage response is not an object');
|
|
111
|
+
}
|
|
112
|
+
const usage = payload.usage;
|
|
113
|
+
const meters = [];
|
|
114
|
+
for (const [id, label] of METER_ORDER) {
|
|
115
|
+
const item = usage?.[id];
|
|
116
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
|
117
|
+
const percent = Number(item.percent);
|
|
118
|
+
if (!Number.isFinite(percent)) continue;
|
|
119
|
+
const meter = { id, label, utilization: Math.max(0, Math.min(100, percent)) };
|
|
120
|
+
const resetsAt = date(item.resetsAt);
|
|
121
|
+
if (resetsAt) meter.resetsAt = resetsAt.toISOString();
|
|
122
|
+
meters.push(meter);
|
|
123
|
+
}
|
|
124
|
+
return meters;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function fetchOpenCodeGoQuota({
|
|
128
|
+
environment = process.env,
|
|
129
|
+
home = homedir(),
|
|
130
|
+
fetchImpl = globalThis.fetch,
|
|
131
|
+
usageURL = DEFAULT_USAGE_URL,
|
|
132
|
+
now = new Date(),
|
|
133
|
+
timeoutMs = 10_000,
|
|
134
|
+
} = {}) {
|
|
135
|
+
let credential = { status: 'missing' };
|
|
136
|
+
for (const root of openCodeDataRoots(environment, home)) {
|
|
137
|
+
const found = readCredential(root);
|
|
138
|
+
if (found.status === 'ok') {
|
|
139
|
+
credential = found;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
// Keep the first concrete read failure; a missing database just means the
|
|
143
|
+
// next configured root may hold the login.
|
|
144
|
+
if (found.status !== 'missing' && credential.status === 'missing') credential = found;
|
|
145
|
+
}
|
|
146
|
+
if (credential.status === 'unavailable') {
|
|
147
|
+
return quotaResult({
|
|
148
|
+
id: PRODUCT_ID,
|
|
149
|
+
status: 'retryable_error',
|
|
150
|
+
message: sqliteUnavailableError('OpenCode Go').message,
|
|
151
|
+
fetchedAt: now,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (credential.status === 'error') {
|
|
155
|
+
return quotaResult({
|
|
156
|
+
id: PRODUCT_ID,
|
|
157
|
+
status: 'retryable_error',
|
|
158
|
+
message: 'OpenCode credential store could not be read',
|
|
159
|
+
fetchedAt: now,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (credential.status !== 'ok') {
|
|
163
|
+
return quotaResult({
|
|
164
|
+
id: PRODUCT_ID,
|
|
165
|
+
status: 'missing_credentials',
|
|
166
|
+
message: 'OpenCode Go is not logged in',
|
|
167
|
+
fetchedAt: now,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const token = credential.key;
|
|
172
|
+
try {
|
|
173
|
+
const response = await fetchImpl(usageURL, {
|
|
174
|
+
method: 'GET',
|
|
175
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
176
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
177
|
+
});
|
|
178
|
+
if (response.status === 401) {
|
|
179
|
+
return attachCacheScope(quotaResult({
|
|
180
|
+
id: PRODUCT_ID,
|
|
181
|
+
status: 'unauthorized',
|
|
182
|
+
message: 'OpenCode Go rejected the saved API key',
|
|
183
|
+
fetchedAt: now,
|
|
184
|
+
}), token);
|
|
185
|
+
}
|
|
186
|
+
if (response.status === 403) {
|
|
187
|
+
// A valid OpenCode key without an active Go subscription. The reason
|
|
188
|
+
// travels machine-readably so a client can say "not subscribed" instead
|
|
189
|
+
// of showing a neutral empty state.
|
|
190
|
+
return attachCacheScope(quotaResult({
|
|
191
|
+
id: PRODUCT_ID,
|
|
192
|
+
status: 'no_data',
|
|
193
|
+
emptyReason: 'notEntitled',
|
|
194
|
+
message: 'OpenCode Go subscription is not active',
|
|
195
|
+
fetchedAt: now,
|
|
196
|
+
}), token);
|
|
197
|
+
}
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
return attachCacheScope(quotaResult({
|
|
200
|
+
id: PRODUCT_ID,
|
|
201
|
+
status: 'retryable_error',
|
|
202
|
+
message: `OpenCode usage API returned HTTP ${response.status}`,
|
|
203
|
+
fetchedAt: now,
|
|
204
|
+
}), token);
|
|
205
|
+
}
|
|
206
|
+
const meters = parseOpenCodeGoUsage(await response.json());
|
|
207
|
+
return attachCacheScope(quotaResult({
|
|
208
|
+
id: PRODUCT_ID,
|
|
209
|
+
status: meters.length ? 'ok' : 'no_data',
|
|
210
|
+
meters,
|
|
211
|
+
message: meters.length ? undefined : 'OpenCode usage API returned no window',
|
|
212
|
+
fetchedAt: now,
|
|
213
|
+
dataAsOf: now,
|
|
214
|
+
}), token);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
return attachCacheScope(quotaResult({
|
|
217
|
+
id: PRODUCT_ID,
|
|
218
|
+
status: 'retryable_error',
|
|
219
|
+
message: error?.name === 'TimeoutError'
|
|
220
|
+
? 'OpenCode usage request timed out'
|
|
221
|
+
: 'OpenCode usage request failed',
|
|
222
|
+
fetchedAt: now,
|
|
223
|
+
}), token);
|
|
224
|
+
}
|
|
225
|
+
}
|
package/src/quotas/registry.js
CHANGED
|
@@ -4,6 +4,7 @@ import { delimiter, join } from 'node:path';
|
|
|
4
4
|
import { loadCachedQuota, saveCachedQuota } from './cache.js';
|
|
5
5
|
import { fetchGrokQuota } from './providers/grok.js';
|
|
6
6
|
import { fetchKimiCodeQuota } from './providers/kimi-code.js';
|
|
7
|
+
import { fetchOpenCodeGoQuota } from './providers/opencode-go.js';
|
|
7
8
|
import { fetchZaiQuota } from './providers/zai.js';
|
|
8
9
|
import { FETCHABLE_QUOTA_PRODUCT_IDS, quotaEnvelope, quotaResult } from './schema.js';
|
|
9
10
|
|
|
@@ -11,6 +12,7 @@ const providers = new Map([
|
|
|
11
12
|
['kimi-code', fetchKimiCodeQuota],
|
|
12
13
|
['zcode', fetchZaiQuota],
|
|
13
14
|
['grok', fetchGrokQuota],
|
|
15
|
+
['opencode-go', fetchOpenCodeGoQuota],
|
|
14
16
|
]);
|
|
15
17
|
|
|
16
18
|
function executableExists(name, environment, platform) {
|
|
@@ -73,6 +75,12 @@ export function discoverQuotaProducts({
|
|
|
73
75
|
detected: existsAny([grokHome]) || executableExists('grok', environment, platform),
|
|
74
76
|
fetchable: true,
|
|
75
77
|
},
|
|
78
|
+
{
|
|
79
|
+
id: 'opencode-go',
|
|
80
|
+
detected: existsAny([join(home, '.local', 'share', 'opencode')])
|
|
81
|
+
|| executableExists('opencode', environment, platform),
|
|
82
|
+
fetchable: true,
|
|
83
|
+
},
|
|
76
84
|
{
|
|
77
85
|
id: 'cursor',
|
|
78
86
|
detected: existsAny([join(home, '.cursor'),
|
package/src/quotas/schema.js
CHANGED
|
@@ -4,6 +4,7 @@ export const QUOTA_PRODUCT_IDS = Object.freeze([
|
|
|
4
4
|
'kimi-code',
|
|
5
5
|
'zcode',
|
|
6
6
|
'grok',
|
|
7
|
+
'opencode-go',
|
|
7
8
|
'cursor',
|
|
8
9
|
]);
|
|
9
10
|
|
|
@@ -11,6 +12,20 @@ export const FETCHABLE_QUOTA_PRODUCT_IDS = Object.freeze([
|
|
|
11
12
|
'kimi-code',
|
|
12
13
|
'zcode',
|
|
13
14
|
'grok',
|
|
15
|
+
'opencode-go',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Machine-readable "why there was no window" values, shared with the desktop
|
|
20
|
+
* clients' `EmptyReason` (macOS `RateLimit.swift`) and additive within schema
|
|
21
|
+
* v1: a client that does not know the field ignores it, and a client that does
|
|
22
|
+
* can render the reason instead of a neutral empty state.
|
|
23
|
+
*/
|
|
24
|
+
export const QUOTA_EMPTY_REASONS = Object.freeze([
|
|
25
|
+
'limitReached',
|
|
26
|
+
'noWindow',
|
|
27
|
+
'notEntitled',
|
|
28
|
+
'sessionWithoutPlanLimits',
|
|
14
29
|
]);
|
|
15
30
|
|
|
16
31
|
const FETCH_STATUSES = new Set([
|
|
@@ -59,6 +74,65 @@ export function normalizeMeter(raw, index = 0) {
|
|
|
59
74
|
return meter;
|
|
60
75
|
}
|
|
61
76
|
|
|
77
|
+
const PERIOD_LABELS = new Map([
|
|
78
|
+
['daily', { label: '1d', seconds: 24 * 60 * 60, exact: true }],
|
|
79
|
+
['day', { label: '1d', seconds: 24 * 60 * 60, exact: true }],
|
|
80
|
+
['weekly', { label: '7d', seconds: 7 * 24 * 60 * 60, exact: true }],
|
|
81
|
+
['week', { label: '7d', seconds: 7 * 24 * 60 * 60, exact: true }],
|
|
82
|
+
['monthly', { label: 'Month', seconds: 30 * 24 * 60 * 60, exact: false }],
|
|
83
|
+
['month', { label: 'Month', seconds: 30 * 24 * 60 * 60, exact: false }],
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
function periodPresentation(meter) {
|
|
87
|
+
const compact = meter.label.trim().toLowerCase().replace(/\s+/g, '');
|
|
88
|
+
const alias = PERIOD_LABELS.get(compact);
|
|
89
|
+
if (alias) {
|
|
90
|
+
return {
|
|
91
|
+
label: alias.label,
|
|
92
|
+
seconds: meter.windowSeconds || alias.seconds,
|
|
93
|
+
inferredWindowSeconds: alias.exact ? alias.seconds : undefined,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const match = /^(\d+(?:\.\d+)?)(m|h|d|w)$/.exec(compact);
|
|
98
|
+
if (!match) return null;
|
|
99
|
+
const multipliers = { m: 60, h: 3600, d: 86400, w: 7 * 86400 };
|
|
100
|
+
const seconds = meter.windowSeconds || Number(match[1]) * multipliers[match[2]];
|
|
101
|
+
const label = compact === '1w' ? '7d' : compact;
|
|
102
|
+
return { label, seconds, inferredWindowSeconds: seconds };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Keep the compact desktop cards predictable across providers: generic time
|
|
107
|
+
* windows come first from shortest to longest, followed by model-specific and
|
|
108
|
+
* feature meters in their provider-defined order. The original index is the
|
|
109
|
+
* final comparison key so the sort remains deterministic on every JS runtime.
|
|
110
|
+
*/
|
|
111
|
+
export function canonicalizeMeters(rawMeters) {
|
|
112
|
+
return rawMeters
|
|
113
|
+
.map((raw, index) => {
|
|
114
|
+
const meter = normalizeMeter(raw, index);
|
|
115
|
+
const period = periodPresentation(meter);
|
|
116
|
+
if (period) {
|
|
117
|
+
meter.label = period.label;
|
|
118
|
+
if (!meter.windowSeconds && period.inferredWindowSeconds) {
|
|
119
|
+
meter.windowSeconds = period.inferredWindowSeconds;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { meter, periodSeconds: period?.seconds, index };
|
|
123
|
+
})
|
|
124
|
+
.sort((left, right) => {
|
|
125
|
+
const leftIsPeriod = left.periodSeconds !== undefined;
|
|
126
|
+
const rightIsPeriod = right.periodSeconds !== undefined;
|
|
127
|
+
if (leftIsPeriod !== rightIsPeriod) return leftIsPeriod ? -1 : 1;
|
|
128
|
+
if (leftIsPeriod && left.periodSeconds !== right.periodSeconds) {
|
|
129
|
+
return left.periodSeconds - right.periodSeconds;
|
|
130
|
+
}
|
|
131
|
+
return left.index - right.index;
|
|
132
|
+
})
|
|
133
|
+
.map(item => item.meter);
|
|
134
|
+
}
|
|
135
|
+
|
|
62
136
|
export function quotaResult({
|
|
63
137
|
id,
|
|
64
138
|
status,
|
|
@@ -68,6 +142,7 @@ export function quotaResult({
|
|
|
68
142
|
dataAsOf = fetchedAt,
|
|
69
143
|
message,
|
|
70
144
|
source = 'live',
|
|
145
|
+
emptyReason,
|
|
71
146
|
}) {
|
|
72
147
|
if (!FETCHABLE_QUOTA_PRODUCT_IDS.includes(id)) {
|
|
73
148
|
throw new TypeError(`unsupported quota product: ${id}`);
|
|
@@ -78,7 +153,7 @@ export function quotaResult({
|
|
|
78
153
|
const result = {
|
|
79
154
|
id,
|
|
80
155
|
status,
|
|
81
|
-
meters: meters
|
|
156
|
+
meters: canonicalizeMeters(meters),
|
|
82
157
|
fetchedAt: new Date(fetchedAt).toISOString(),
|
|
83
158
|
source,
|
|
84
159
|
};
|
|
@@ -86,6 +161,12 @@ export function quotaResult({
|
|
|
86
161
|
if (normalizedDataAsOf) result.dataAsOf = normalizedDataAsOf;
|
|
87
162
|
if (typeof planLabel === 'string' && planLabel.trim()) result.planLabel = planLabel.trim();
|
|
88
163
|
if (typeof message === 'string' && message.trim()) result.message = message.trim();
|
|
164
|
+
if (emptyReason !== undefined && emptyReason !== null) {
|
|
165
|
+
if (!QUOTA_EMPTY_REASONS.includes(emptyReason)) {
|
|
166
|
+
throw new TypeError(`invalid quota emptyReason: ${emptyReason}`);
|
|
167
|
+
}
|
|
168
|
+
result.emptyReason = emptyReason;
|
|
169
|
+
}
|
|
89
170
|
return result;
|
|
90
171
|
}
|
|
91
172
|
|