@vibe-cafe/vibe-usage 0.12.0 → 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 +4 -3
- package/package.json +1 -1
- package/src/daemon-service.js +23 -2
- package/src/parsers/dsh.js +10 -5
- package/src/parsers/opencode.js +57 -8
- package/src/quotas/providers/kimi-code.js +51 -4
- package/src/quotas/providers/opencode-go.js +222 -0
- package/src/quotas/registry.js +8 -0
- package/src/quotas/schema.js +62 -1
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
|
|
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.
|
|
@@ -85,7 +86,7 @@ Quota results are never uploaded or added to incremental sync state. Credential-
|
|
|
85
86
|
| Cursor | `state.vscdb` (SQLite, reads `cursorAuth/accessToken`, fetches CSV from `cursor.com`); cloud data is stamped with a fixed `cursor-cloud` hostname so multi-machine setups don't double-count |
|
|
86
87
|
| DimAgent | `$DIMCODE_HOME/dimcode.sqlite` (default `~/.dimcode/v2/dimcode.sqlite`); exact usage from `usage_ledger`, with forked ledger/history copies deduplicated |
|
|
87
88
|
| Gemini CLI | `~/.gemini/tmp/<project_hash>/chats/session-*.jsonl` (current line-delimited format) and legacy `session-*.json`; recurses into nested subagent sessions |
|
|
88
|
-
| OpenCode | `~/.local/share/opencode/opencode.db` (SQLite), with `storage/message/` as the legacy alternative; supports explicitly added data roots |
|
|
89
|
+
| OpenCode | `~/.local/share/opencode/opencode.db` (SQLite) — both the legacy `message` table and the 2.x `session_message` projection are read, merged by message id — with `storage/message/` as the legacy alternative; cache writes are counted as 5m cache creation; supports explicitly added data roots |
|
|
89
90
|
| OpenClaw | `~/.openclaw/agents/`, `~/.openclaw-<profile>/agents/` (profile deployments); cache-creation/cache-write tokens are included in input usage |
|
|
90
91
|
| Oh My Pi | `~/.omp/agent/sessions/`, `~/.omp/profiles/*/agent/sessions/`, and `$XDG_DATA_HOME/omp/{sessions,profiles/*/sessions}`; recognizes OMP's `$PI_CODING_AGENT_DIR`, current v3 title slots and path/hashed session directories, deduplicates copied records, includes cache writes in input, and splits reasoning from OMP's inclusive output count |
|
|
91
92
|
| pi | `~/.pi/agent/sessions/` or `$PI_CODING_AGENT_DIR/sessions/`, plus the session directory Pi itself was pointed at via `PI_CODING_AGENT_SESSION_DIR` or `sessionDir` in `~/.pi/agent/settings.json`, plus explicitly added `pi-coding-agent` roots for stores only reachable through `pi --session <file>` (fixture/relocation override: `VIBE_USAGE_PI_SESSION_DIRS`). Cache writes are included in input usage; reasoning is read from Pi's `usage.reasoning` (legacy `usage.reasoningTokens` still accepted) and split out of the inclusive output total |
|
|
@@ -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–
|
|
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
package/src/daemon-service.js
CHANGED
|
@@ -114,19 +114,40 @@ function escapeXml(value) {
|
|
|
114
114
|
|
|
115
115
|
// Variables that relocate a tool's on-disk store. The service runs from a
|
|
116
116
|
// launchd/systemd unit that inherits nothing, so anything the parsers read for
|
|
117
|
-
// discovery has to be captured into the unit at install time.
|
|
117
|
+
// discovery has to be captured into the unit at install time. Keep this list in
|
|
118
|
+
// sync with the store-locating variables the parsers and roots modules read
|
|
119
|
+
// (`grep -rho 'process\.env\.[A-Z_]*' src/parsers src/*-roots.js`): a variable
|
|
120
|
+
// missing here makes the background service sync a different store than the
|
|
121
|
+
// foreground CLI (issue #112). Test-only overrides (`VIBE_USAGE_*`) and
|
|
122
|
+
// accounting knobs are deliberately absent -- they do not relocate a store.
|
|
118
123
|
const PRESERVED_SERVICE_ENV = [
|
|
124
|
+
'AMP_DATA_DIR',
|
|
119
125
|
'CLINE_DIR',
|
|
120
126
|
'CLINE_DATA_DIR',
|
|
121
127
|
'CLINE_SESSION_DATA_DIR',
|
|
128
|
+
'CODEBUDDY_CONFIG_DIR',
|
|
129
|
+
'CODEX_HOME',
|
|
122
130
|
'COLA_DATA_DIR',
|
|
131
|
+
'CRAFT_AGENT_DIR',
|
|
132
|
+
'CRAFTAGENT_DIR',
|
|
133
|
+
'CURSOR_CONFIG_DIR',
|
|
134
|
+
'DIMCODE_HOME',
|
|
135
|
+
'DSH_HOME',
|
|
136
|
+
'GROK_HOME',
|
|
123
137
|
'HERMES_HOME',
|
|
138
|
+
'KIMI_CODE_HOME',
|
|
139
|
+
'KIRO_BASE_PATH',
|
|
140
|
+
'KIRO_CLI_DB_PATH',
|
|
141
|
+
'KIRO_CLI_SESSIONS_DIR',
|
|
142
|
+
'KIRO_SESSIONS_DIR',
|
|
143
|
+
'KIRO_USER_PATH',
|
|
124
144
|
'MCODE_HOME',
|
|
125
145
|
'MIMOCODE_HOME',
|
|
126
146
|
'MIMOCODE_DB',
|
|
127
|
-
'
|
|
147
|
+
'PI_CONFIG_DIR',
|
|
128
148
|
'PI_CODING_AGENT_DIR',
|
|
129
149
|
'PI_CODING_AGENT_SESSION_DIR',
|
|
150
|
+
'XDG_DATA_HOME',
|
|
130
151
|
];
|
|
131
152
|
|
|
132
153
|
function serviceEnvironment(claudeConfigDir, env) {
|
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
|
*
|
package/src/parsers/opencode.js
CHANGED
|
@@ -4,18 +4,57 @@ import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
|
4
4
|
import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
|
|
5
5
|
import { getOpenCodeStores } from '../opencode-roots.js';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const query = `SELECT id, session_id AS sessionID,
|
|
7
|
+
// Select only accounting/timing metadata, never message text or tool inputs.
|
|
8
|
+
// Keep the existing top-level model/project precedence for old uploads.
|
|
9
|
+
const V1_QUERY = `SELECT id, session_id AS sessionID,
|
|
11
10
|
json_extract(data, '$.role') AS role,
|
|
12
11
|
json_extract(data, '$.time.created') AS created,
|
|
13
12
|
coalesce(json_extract(data, '$.modelID'), json_extract(data, '$.model.modelID')) AS modelID,
|
|
14
13
|
json_extract(data, '$.tokens') AS tokens,
|
|
15
14
|
json_extract(data, '$.path.root') AS rootPath
|
|
16
15
|
FROM message ORDER BY id`;
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
|
|
17
|
+
// OpenCode 2.x stores the same accounting in the event-sourced projection
|
|
18
|
+
// `session_message`: the row's `type` is the role, `data.model.id` the model,
|
|
19
|
+
// `data.tokens` the counters. Message data carries no `path`, so the project
|
|
20
|
+
// comes from the session row's `directory`; the session table is `session_v2`
|
|
21
|
+
// in 2.x and `session` before that (both names in the wild, issue #114). Only
|
|
22
|
+
// column/JSON expressions are selected -- never message text, tool payloads,
|
|
23
|
+
// or costs.
|
|
24
|
+
function v2Query(sessionTable) {
|
|
25
|
+
return `SELECT m.id AS id, m.session_id AS sessionID,
|
|
26
|
+
m.type AS role,
|
|
27
|
+
coalesce(json_extract(m.data, '$.time.created'), m.time_created) AS created,
|
|
28
|
+
coalesce(json_extract(m.data, '$.model.id'), json_extract(m.data, '$.modelID')) AS modelID,
|
|
29
|
+
json_extract(m.data, '$.tokens') AS tokens,
|
|
30
|
+
s.directory AS directory
|
|
31
|
+
FROM session_message m
|
|
32
|
+
${sessionTable ? `LEFT JOIN ${sessionTable} s ON s.id = m.session_id` : ''}
|
|
33
|
+
WHERE m.type IN ('user', 'assistant')
|
|
34
|
+
ORDER BY m.id`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readSqlite(path) {
|
|
38
|
+
try {
|
|
39
|
+
const tables = new Set(queryDbJson(path,
|
|
40
|
+
`SELECT name FROM sqlite_master WHERE type = 'table'
|
|
41
|
+
AND name IN ('message', 'session_message', 'session', 'session_v2')`)
|
|
42
|
+
.map(row => row.name));
|
|
43
|
+
if (!tables.has('message') && !tables.has('session_message')) {
|
|
44
|
+
throw new Error(`不认识的表结构(没有 message / session_message 表): ${path}`);
|
|
45
|
+
}
|
|
46
|
+
// Read both shapes when a migrated store keeps both: copies are merged by
|
|
47
|
+
// (session, message id), and the legacy row is seen first so an equal copy
|
|
48
|
+
// never renames a project that earlier uploads already used.
|
|
49
|
+
const rows = [];
|
|
50
|
+
if (tables.has('message')) rows.push(...queryDbJson(path, V1_QUERY));
|
|
51
|
+
if (tables.has('session_message')) {
|
|
52
|
+
const sessionTable = tables.has('session_v2') ? 'session_v2'
|
|
53
|
+
: (tables.has('session') ? 'session' : null);
|
|
54
|
+
rows.push(...queryDbJson(path, v2Query(sessionTable)));
|
|
55
|
+
}
|
|
56
|
+
return rows;
|
|
57
|
+
} catch (err) {
|
|
19
58
|
if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('OpenCode');
|
|
20
59
|
throw err;
|
|
21
60
|
}
|
|
@@ -71,14 +110,24 @@ export async function parse({ extraRoots = [] } = {}) {
|
|
|
71
110
|
for (const row of records.values()) {
|
|
72
111
|
// Keep the existing project derivation and token semantics. Additional roots
|
|
73
112
|
// must not rename previously uploaded projects/models or alter their counts.
|
|
74
|
-
|
|
113
|
+
// V2 message rows carry no `path`, so they fall back to the session
|
|
114
|
+
// directory; they come from a store the parser could not read before, so
|
|
115
|
+
// nothing previously uploaded is relabelled by that fallback.
|
|
116
|
+
const project = row.rootPath ? basename(row.rootPath)
|
|
117
|
+
: (row.directory ? basename(row.directory) : 'unknown');
|
|
75
118
|
const base = { source: 'opencode', project, timestamp: row.timestamp };
|
|
76
119
|
events.push({ ...base, sessionId: row.sessionId, role: row.role === 'user' ? 'user' : 'assistant' });
|
|
77
120
|
const tokens = row.tokens;
|
|
78
121
|
if (!row.modelID || !tokens || (!tokens.input && !tokens.output)) continue;
|
|
79
122
|
entries.push({ ...base, model: row.modelID,
|
|
80
123
|
inputTokens: tokens.input || 0, outputTokens: tokens.output || 0,
|
|
81
|
-
cachedInputTokens: tokens.cache?.read || 0, reasoningOutputTokens: tokens.reasoning || 0
|
|
124
|
+
cachedInputTokens: tokens.cache?.read || 0, reasoningOutputTokens: tokens.reasoning || 0,
|
|
125
|
+
// The store writes one cache-write total with no per-TTL breakdown and no
|
|
126
|
+
// separate `tokens.total` column to reconcile against, so it goes to the
|
|
127
|
+
// cheaper 5m cache-creation bucket -- the same rule as CodeArts Agent,
|
|
128
|
+
// which reads this exact layout. Dropping it (the behaviour until
|
|
129
|
+
// 2026-09-26) under-billed every Claude/Anthropic run through OpenCode.
|
|
130
|
+
cacheCreation5mTokens: tokens.cache?.write || 0 });
|
|
82
131
|
}
|
|
83
132
|
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(events) };
|
|
84
133
|
}
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
import { homedir } from 'node:os';
|
|
16
16
|
import { dirname, join } from 'node:path';
|
|
17
17
|
import { attachCacheScope } from '../cache.js';
|
|
18
|
-
import { quotaResult } from '../schema.js';
|
|
18
|
+
import { canonicalizeMeters, quotaResult } from '../schema.js';
|
|
19
19
|
|
|
20
20
|
const PRODUCT_ID = 'kimi-code';
|
|
21
21
|
const DEFAULT_USAGE_URL = 'https://api.kimi.com/coding/v1/usages';
|
|
@@ -110,6 +110,29 @@ export function parseKimiUsage(payload, now = new Date()) {
|
|
|
110
110
|
throw new Error('Kimi usage response is not an object');
|
|
111
111
|
}
|
|
112
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
|
+
}
|
|
113
136
|
if (payload.usage && typeof payload.usage === 'object' && !Array.isArray(payload.usage)) {
|
|
114
137
|
const summary = meterFrom(payload.usage, payload.usage, 0, 'Weekly', now);
|
|
115
138
|
if (summary) meters.push(summary);
|
|
@@ -126,7 +149,7 @@ export function parseKimiUsage(payload, now = new Date()) {
|
|
|
126
149
|
}
|
|
127
150
|
}
|
|
128
151
|
const seen = new Set();
|
|
129
|
-
return meters.filter(meter => {
|
|
152
|
+
return canonicalizeMeters(meters).filter(meter => {
|
|
130
153
|
const key = `${meter.label}\0${meter.windowSeconds || ''}`;
|
|
131
154
|
if (seen.has(key)) return false;
|
|
132
155
|
seen.add(key);
|
|
@@ -134,9 +157,33 @@ export function parseKimiUsage(payload, now = new Date()) {
|
|
|
134
157
|
});
|
|
135
158
|
}
|
|
136
159
|
|
|
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
|
|
165
|
+
// every 2.x user whose login sits in the new home (issue #112), while their
|
|
166
|
+
// usage parsed fine, so the quota card contradicted the token tables.
|
|
167
|
+
export function kimiCredentialPaths(environment = process.env, home = homedir()) {
|
|
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')))];
|
|
172
|
+
}
|
|
173
|
+
|
|
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.
|
|
137
179
|
export function kimiCredentialPath(environment = process.env, home = homedir()) {
|
|
138
|
-
const
|
|
139
|
-
|
|
180
|
+
const paths = kimiCredentialPaths(environment, home);
|
|
181
|
+
for (const path of paths) {
|
|
182
|
+
try {
|
|
183
|
+
if (statSync(path).isFile()) return path;
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
return paths[0];
|
|
140
187
|
}
|
|
141
188
|
|
|
142
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
|
+
}
|
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,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
|
|
142
|
+
meters: canonicalizeMeters(meters),
|
|
82
143
|
fetchedAt: new Date(fetchedAt).toISOString(),
|
|
83
144
|
source,
|
|
84
145
|
};
|