@vibe-cafe/vibe-usage 0.12.1 → 0.13.0

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 CHANGED
@@ -62,9 +62,10 @@ 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
- - **Kimi Code** reads the official Kimi CLI OAuth file — `$KIMI_SHARE_DIR/credentials/kimi-code.json` when configured, otherwise the current CLI home (`$KIMI_CODE_HOME`, else `~/.kimi-code`) and then the legacy `~/.kimi` home, whichever holds a login — and calls `https://api.kimi.com/coding/v1/usages`. When the short-lived access token is close to expiry, it uses Kimi's standard OAuth refresh flow and atomically rotates the official credential with owner-only permissions. Refreshes are serialized across Vibe Usage processes and re-check the file before writing so a concurrent Kimi CLI refresh wins safely.
65
+ - **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
66
  - **ZCode / GLM Coding Plan** accepts only a caller-supplied regional key: `BIGMODEL_API_KEY` calls the domestic `https://open.bigmodel.cn/api/monitor/usage/quota/limit`, while the existing `Z_AI_API_KEY` keeps using `https://api.z.ai/api/monitor/usage/quota/limit`. If both are present, the explicitly named BigModel key wins. It does not read ZCode's private OAuth state.
67
67
  - **Grok** reads at most the final 2 MiB of the official CLI's ordinary `$GROK_HOME/logs/unified.jsonl` (default `~/.grok/logs/unified.jsonl`). It accepts only the structured `billing: fetched credits config` event and projects the current utilization, period bounds, subscription tier, and event timestamp. It performs no network request, reads no credentials, and never returns or retains other log fields.
68
+ - **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
69
  - **Cursor** is detected independently but remains non-fetchable until an official or stable quota protocol is available. Quota monitoring does not read Cursor's login token/database, browser cookies, another app's Keychain, network traffic, or UI.
69
70
 
70
71
  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 +96,7 @@ Quota results are never uploaded or added to incremental sync state. Credential-
95
96
  | 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
97
  | Amp | `~/.local/share/amp/threads/`; cache-creation tokens are included in input usage |
97
98
  | Droid | `~/.factory/sessions/` |
98
- | DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads V0–V3 logs, including `session.v3.jsonl.zstd` from DSH `0.1.5-alpha.2`, 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/V3'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
+ | 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
100
  | 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
101
  | 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
102
  | 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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 tag dsh-v0.1.5-alpha.2:
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, and core/session/src/types.ts.
13
- // V0/V1 use header.seedLength; V2/V3 use a tagged inherited end-seed marker.
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 = 3;
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/V3
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. kimi-cli (1.x) keeps
139
- // `$KIMI_SHARE_DIR/credentials/kimi-code.json`, defaulting to `~/.kimi`, while
140
- // the current CLI (2.x) writes `$KIMI_CODE_HOME/credentials/kimi-code.json`,
141
- // defaulting to `~/.kimi-code` -- the same home the session parser already
142
- // resolves. Reading only the legacy path reported `missing_credentials` for
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 directories = [
147
- environment.KIMI_SHARE_DIR?.trim(),
148
- environment.KIMI_CODE_HOME?.trim(),
149
- join(home, '.kimi-code'),
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
- * The credential file to read and, on refresh, to rotate atomically.
157
- *
158
- * A login that exists wins over one that does not, in the order above: an
159
- * explicitly configured home outranks the CLI default, and the current CLI
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
- return paths.find(path => existsSync(path)) || paths[0];
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,222 @@
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.
188
+ return attachCacheScope(quotaResult({
189
+ id: PRODUCT_ID,
190
+ status: 'no_data',
191
+ message: 'OpenCode Go subscription is not active',
192
+ fetchedAt: now,
193
+ }), token);
194
+ }
195
+ if (!response.ok) {
196
+ return attachCacheScope(quotaResult({
197
+ id: PRODUCT_ID,
198
+ status: 'retryable_error',
199
+ message: `OpenCode usage API returned HTTP ${response.status}`,
200
+ fetchedAt: now,
201
+ }), token);
202
+ }
203
+ const meters = parseOpenCodeGoUsage(await response.json());
204
+ return attachCacheScope(quotaResult({
205
+ id: PRODUCT_ID,
206
+ status: meters.length ? 'ok' : 'no_data',
207
+ meters,
208
+ message: meters.length ? undefined : 'OpenCode usage API returned no window',
209
+ fetchedAt: now,
210
+ dataAsOf: now,
211
+ }), token);
212
+ } catch (error) {
213
+ return attachCacheScope(quotaResult({
214
+ id: PRODUCT_ID,
215
+ status: 'retryable_error',
216
+ message: error?.name === 'TimeoutError'
217
+ ? 'OpenCode usage request timed out'
218
+ : 'OpenCode usage request failed',
219
+ fetchedAt: now,
220
+ }), token);
221
+ }
222
+ }
@@ -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'),
@@ -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,7 @@ export const FETCHABLE_QUOTA_PRODUCT_IDS = Object.freeze([
11
12
  'kimi-code',
12
13
  'zcode',
13
14
  'grok',
15
+ 'opencode-go',
14
16
  ]);
15
17
 
16
18
  const FETCH_STATUSES = new Set([
@@ -59,6 +61,65 @@ export function normalizeMeter(raw, index = 0) {
59
61
  return meter;
60
62
  }
61
63
 
64
+ const PERIOD_LABELS = new Map([
65
+ ['daily', { label: '1d', seconds: 24 * 60 * 60, exact: true }],
66
+ ['day', { label: '1d', seconds: 24 * 60 * 60, exact: true }],
67
+ ['weekly', { label: '7d', seconds: 7 * 24 * 60 * 60, exact: true }],
68
+ ['week', { label: '7d', seconds: 7 * 24 * 60 * 60, exact: true }],
69
+ ['monthly', { label: 'Month', seconds: 30 * 24 * 60 * 60, exact: false }],
70
+ ['month', { label: 'Month', seconds: 30 * 24 * 60 * 60, exact: false }],
71
+ ]);
72
+
73
+ function periodPresentation(meter) {
74
+ const compact = meter.label.trim().toLowerCase().replace(/\s+/g, '');
75
+ const alias = PERIOD_LABELS.get(compact);
76
+ if (alias) {
77
+ return {
78
+ label: alias.label,
79
+ seconds: meter.windowSeconds || alias.seconds,
80
+ inferredWindowSeconds: alias.exact ? alias.seconds : undefined,
81
+ };
82
+ }
83
+
84
+ const match = /^(\d+(?:\.\d+)?)(m|h|d|w)$/.exec(compact);
85
+ if (!match) return null;
86
+ const multipliers = { m: 60, h: 3600, d: 86400, w: 7 * 86400 };
87
+ const seconds = meter.windowSeconds || Number(match[1]) * multipliers[match[2]];
88
+ const label = compact === '1w' ? '7d' : compact;
89
+ return { label, seconds, inferredWindowSeconds: seconds };
90
+ }
91
+
92
+ /**
93
+ * Keep the compact desktop cards predictable across providers: generic time
94
+ * windows come first from shortest to longest, followed by model-specific and
95
+ * feature meters in their provider-defined order. The original index is the
96
+ * final comparison key so the sort remains deterministic on every JS runtime.
97
+ */
98
+ export function canonicalizeMeters(rawMeters) {
99
+ return rawMeters
100
+ .map((raw, index) => {
101
+ const meter = normalizeMeter(raw, index);
102
+ const period = periodPresentation(meter);
103
+ if (period) {
104
+ meter.label = period.label;
105
+ if (!meter.windowSeconds && period.inferredWindowSeconds) {
106
+ meter.windowSeconds = period.inferredWindowSeconds;
107
+ }
108
+ }
109
+ return { meter, periodSeconds: period?.seconds, index };
110
+ })
111
+ .sort((left, right) => {
112
+ const leftIsPeriod = left.periodSeconds !== undefined;
113
+ const rightIsPeriod = right.periodSeconds !== undefined;
114
+ if (leftIsPeriod !== rightIsPeriod) return leftIsPeriod ? -1 : 1;
115
+ if (leftIsPeriod && left.periodSeconds !== right.periodSeconds) {
116
+ return left.periodSeconds - right.periodSeconds;
117
+ }
118
+ return left.index - right.index;
119
+ })
120
+ .map(item => item.meter);
121
+ }
122
+
62
123
  export function quotaResult({
63
124
  id,
64
125
  status,
@@ -78,7 +139,7 @@ export function quotaResult({
78
139
  const result = {
79
140
  id,
80
141
  status,
81
- meters: meters.map(normalizeMeter),
142
+ meters: canonicalizeMeters(meters),
82
143
  fetchedAt: new Date(fetchedAt).toISOString(),
83
144
  source,
84
145
  };