quotacap 0.0.51 → 0.0.52
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 +2 -2
- package/dist/adapters/agy.js +3 -0
- package/dist/adapters/claude.js +3 -0
- package/dist/adapters/codex.js +4 -2
- package/dist/adapters/kimi.js +14 -0
- package/dist/adapters/manual.js +4 -0
- package/dist/adapters/muse.js +19 -5
- package/dist/adapters/opencode-go.d.ts +12 -2
- package/dist/adapters/opencode-go.js +156 -31
- package/dist/adapters/types.d.ts +2 -0
- package/dist/advisory/snapshot.d.ts +1 -0
- package/dist/advisory/snapshot.js +7 -1
- package/dist/advisory/types.d.ts +3 -0
- package/dist/cli/models.js +5 -4
- package/dist/format/rows.d.ts +30 -1
- package/dist/format/rows.js +109 -24
- package/dist/format/terminal.js +5 -3
- package/dist/http/server.d.ts +3 -0
- package/dist/http/server.js +7 -3
- package/dist/runtime/service.js +14 -3
- package/dist/service/macos.d.ts +5 -0
- package/dist/service/macos.js +6 -2
- package/dist/store/db.js +2 -1
- package/dist/store/quotas.d.ts +1 -1
- package/dist/store/quotas.js +65 -2
- package/dist/version.js +1 -1
- package/dist/web/src/App.js +10 -4
- package/dist/web/src/components/AdviceDrawer.d.ts +2 -1
- package/dist/web/src/components/AdviceDrawer.js +27 -16
- package/dist/web/src/components/BurnDown.d.ts +37 -0
- package/dist/web/src/components/BurnDown.js +182 -0
- package/dist/web/src/components/ClosedWeeks.d.ts +6 -5
- package/dist/web/src/components/ClosedWeeks.js +25 -37
- package/dist/web/src/components/CopyButton.d.ts +4 -0
- package/dist/web/src/components/CopyButton.js +27 -0
- package/dist/web/src/components/ErrorBoundary.d.ts +37 -0
- package/dist/web/src/components/ErrorBoundary.js +32 -0
- package/dist/web/src/components/FaultBanner.d.ts +3 -2
- package/dist/web/src/components/FaultBanner.js +7 -4
- package/dist/web/src/components/OpencodeGoBanner.d.ts +3 -1
- package/dist/web/src/components/OpencodeGoBanner.js +16 -10
- package/dist/web/src/components/PaceBar.d.ts +80 -3
- package/dist/web/src/components/PaceBar.js +225 -34
- package/dist/web/src/components/PacingLegend.js +1 -1
- package/dist/web/src/components/ProviderCard.js +6 -9
- package/dist/web/src/components/ProviderDrawer.d.ts +56 -10
- package/dist/web/src/components/ProviderDrawer.js +263 -143
- package/dist/web/src/components/ProviderRow.js +3 -3
- package/dist/web/src/components/Recommendation.js +11 -7
- package/dist/web/src/components/ResetRail.js +13 -9
- package/dist/web/src/components/SubscriptionList.js +11 -4
- package/dist/web/src/components/WindowsRow.d.ts +13 -3
- package/dist/web/src/components/WindowsRow.js +27 -5
- package/dist/web/src/components/settings/CliIntegrationPanel.js +7 -45
- package/dist/web/src/components/settings/DaemonPanel.js +1 -28
- package/dist/web/src/components/settings/ProvidersPanel.d.ts +2 -2
- package/dist/web/src/components/settings/ProvidersPanel.js +10 -24
- package/dist/web/src/components/settings/SettingsDrawer.d.ts +1 -2
- package/dist/web/src/components/settings/SettingsDrawer.js +2 -2
- package/dist/web/src/state.d.ts +6 -1
- package/dist/web/src/state.js +19 -10
- package/dist/webAssets.js +1 -1
- package/dist/webHtml.js +1 -1
- package/package.json +1 -1
- package/web/dist/assets/index-Co33DTl5.css +1 -0
- package/web/dist/assets/index-DqK6QVaw.js +13 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BSQW0C_t.css +0 -1
- package/web/dist/assets/index-CkfnMcPS.js +0 -13
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ QuotaCap helps you get more from the AI coding subscriptions you already pay for
|
|
|
28
28
|
|
|
29
29
|
Exec adapters run via `execFile` with an argv list. PTY adapters run via `node-pty` (`src/adapters/pty.ts`). They are TUI-fragile: a vendor text change breaks the parser and the row degrades fail-closed until the regex is fixed. Poll latency is 2–10 s per PTY provider (settle plus completion); `muse` can reach about 75 s when it has to warm an unavailable subscription. It dominates `POST /api/refresh` and the first poll, not the steady-state 15 m timer.
|
|
30
30
|
|
|
31
|
-
Live adapters invoke the CLIs you already logged into — no API keys, no token files read. The one opt-in exception is OpenCode Go: after you run `quotacap providers enable opencode-go`, each poll reads the OpenCode
|
|
31
|
+
Live adapters invoke the CLIs you already logged into — no API keys, no token files read. The one opt-in exception is OpenCode Go: after you run `quotacap providers enable opencode-go`, each poll reads the bearer OpenCode already stored (v2 `~/.local/share/opencode/opencode.db` active credential, else legacy `auth.json`; `opencode-go` then `opencode`, or `OPENCODE_API_KEY`) and sends it only to `opencode.ai/zen/go/v1/usage`. It is never stored, logged, or returned by the API or MCP. Disable any time with `quotacap providers disable opencode-go`.
|
|
32
32
|
|
|
33
33
|
## Install
|
|
34
34
|
|
|
@@ -121,7 +121,7 @@ curl -X PATCH http://localhost:8787/api/providers/claude \
|
|
|
121
121
|
|
|
122
122
|
QuotaCap is a local daemon. It binds to `127.0.0.1` only (`src/runtime/service.ts` `app.listen`). It does not listen on `0.0.0.0`. There is no LAN surface.
|
|
123
123
|
|
|
124
|
-
It owns no tokens. It never reads `~/.codex/auth.json`, `~/.kimi-code/credentials/kimi-code.json`, `~/.kimi/credentials/kimi-code.json`, `~/.grok/auth.json`, or `~/.gemini/oauth_creds.json`. It never uses `refresh_token` or `grant_type=refresh_token`. It has no hardcoded client ids. Those OAuth paths and the `.qc-bak` and `.qc-lock` helpers were removed in #14. This is asserted by `tests/adapters/credential-free.test.ts`. Each CLI owns its own session. It never reads `~/.config/muse/auth.json`, `~/.local/share/muse/sessions/`, or `~/.config/muse/tui-history.jsonl`. Each CLI owns its own session. The one opt-in exception is OpenCode Go: when you enable it, QuotaCap reads `~/.local/share/opencode/auth.json` in-memory, read-only, once per poll, and sends
|
|
124
|
+
It owns no tokens. It never reads `~/.codex/auth.json`, `~/.kimi-code/credentials/kimi-code.json`, `~/.kimi/credentials/kimi-code.json`, `~/.grok/auth.json`, or `~/.gemini/oauth_creds.json`. It never uses `refresh_token` or `grant_type=refresh_token`. It has no hardcoded client ids. Those OAuth paths and the `.qc-bak` and `.qc-lock` helpers were removed in #14. This is asserted by `tests/adapters/credential-free.test.ts`. Each CLI owns its own session. It never reads `~/.config/muse/auth.json`, `~/.local/share/muse/sessions/`, or `~/.config/muse/tui-history.jsonl`. Each CLI owns its own session. The one opt-in exception is OpenCode Go: when you enable it, QuotaCap reads the bearer OpenCode stored — the active credential in `~/.local/share/opencode/opencode.db`, or the legacy `auth.json` key — in-memory, read-only, once per poll, and sends it only as `Authorization: Bearer` to `https://opencode.ai/zen/go/v1/usage`. The read is consent-gated (`opencodeGoConsentAt` in config), asserted read-only by `tests/adapters/credential-free.test.ts`, and revoked by `quotacap providers disable opencode-go`. QuotaCap only spawns the CLI and reads its stdout via `exec` (`claude`, `agy`) or PTY (`codex`, `kimi`, `grok`, `muse`).
|
|
125
125
|
|
|
126
126
|
It stores no `raw` provider payload. The `raw` column was dropped and migrated in `src/store/db.ts` `migrate`. `GET /api/quotas` and MCP `get_quotas` never return `raw` (`tests/http/api.test.ts`). History and the token live under `~/.quotacap/` with `0700` on the directory and `0600` on files.
|
|
127
127
|
|
package/dist/adapters/agy.js
CHANGED
|
@@ -26,11 +26,14 @@ function parseGroup(g, provider, rawJsonString, now) {
|
|
|
26
26
|
const sessionPct = fiveHour
|
|
27
27
|
? usedPctFromRemaining(fiveHour.remaining_fraction) ?? undefined
|
|
28
28
|
: undefined;
|
|
29
|
+
const fiveResetDate = fiveHour?.reset_time ? new Date(fiveHour.reset_time) : null;
|
|
30
|
+
const fiveHourResetsAt = fiveResetDate && !Number.isNaN(fiveResetDate.getTime()) ? fiveResetDate.toISOString() : undefined;
|
|
29
31
|
return {
|
|
30
32
|
provider,
|
|
31
33
|
plan: "unknown",
|
|
32
34
|
weeklyPct: usedPct,
|
|
33
35
|
fiveHourPct: sessionPct,
|
|
36
|
+
...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
|
|
34
37
|
resetsAt,
|
|
35
38
|
periodStart,
|
|
36
39
|
source: "cli",
|
package/dist/adapters/claude.js
CHANGED
|
@@ -12,6 +12,8 @@ export function parseClaudeUsage(result, now = new Date()) {
|
|
|
12
12
|
// and whole-text farthest-wins would pick it instead (muse precedent:
|
|
13
13
|
// scope to the weekly section, then degrade to +7d when it won't parse).
|
|
14
14
|
const weeklyLine = result.match(/^.*current week.*$/gim)?.[0];
|
|
15
|
+
const sessionLine = result.match(/^.*current session.*$/gim)?.[0];
|
|
16
|
+
const fiveHourResetsAt = sessionLine ? parseResetText(sessionLine, now) ?? undefined : undefined;
|
|
15
17
|
let resetsAt = parseResetText(weeklyLine ?? result, now);
|
|
16
18
|
const parsedReset = !!resetsAt;
|
|
17
19
|
if (!resetsAt)
|
|
@@ -20,6 +22,7 @@ export function parseClaudeUsage(result, now = new Date()) {
|
|
|
20
22
|
const periodStart = (parsedReset ? new Date(resets - 7 * 86400000) : new Date(now.getTime() - 7 * 86400000)).toISOString();
|
|
21
23
|
return {
|
|
22
24
|
provider: "claude", plan: "unknown", weeklyPct: usedPct, fiveHourPct: sessionPct,
|
|
25
|
+
...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
|
|
23
26
|
resetsAt, periodStart, source: "cli", fetchedAt: now.toISOString(), raw: result,
|
|
24
27
|
};
|
|
25
28
|
}
|
package/dist/adapters/codex.js
CHANGED
|
@@ -121,8 +121,9 @@ function parseCodexCleaned(cleaned, now) {
|
|
|
121
121
|
weeklyIso = new Date(now.getTime() + 7 * 86400000).toISOString();
|
|
122
122
|
estimated = true;
|
|
123
123
|
}
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
// The 5h reset is display-only: expose it only when actually parsed,
|
|
125
|
+
// never the synthesized fallback a garbled timestamp would produce.
|
|
126
|
+
const fiveHourResetsAt = fiveIso ?? undefined;
|
|
126
127
|
const usedPct = 100 - weeklyLeft;
|
|
127
128
|
const sessionPct = 100 - fiveLeft;
|
|
128
129
|
// The /status panel carries "Account: <email> (<plan>)" above the limits.
|
|
@@ -137,6 +138,7 @@ function parseCodexCleaned(cleaned, now) {
|
|
|
137
138
|
plan,
|
|
138
139
|
weeklyPct: usedPct,
|
|
139
140
|
fiveHourPct: sessionPct,
|
|
141
|
+
...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
|
|
140
142
|
resetsAt: weeklyIso,
|
|
141
143
|
periodStart,
|
|
142
144
|
source: "tui",
|
package/dist/adapters/kimi.js
CHANGED
|
@@ -43,6 +43,7 @@ function parseKimiCleaned(cleaned, now) {
|
|
|
43
43
|
// Degrade, don't fail (grok precedent): the percents are the critical
|
|
44
44
|
// fields; an unparseable timestamp degrades to an estimate.
|
|
45
45
|
let weeklyIso = parseResetText(`resets ${weeklyRaw}`, now);
|
|
46
|
+
const fiveHourResetsAt = f ? parseResetText(`resets ${f[2].trim()}`, now) ?? undefined : undefined;
|
|
46
47
|
let estimated = false;
|
|
47
48
|
if (!weeklyIso) {
|
|
48
49
|
weeklyIso = new Date(now.getTime() + 7 * 86400000).toISOString();
|
|
@@ -86,6 +87,7 @@ function parseKimiCleaned(cleaned, now) {
|
|
|
86
87
|
plan,
|
|
87
88
|
weeklyPct: usedPct,
|
|
88
89
|
fiveHourPct: sessionPct,
|
|
90
|
+
...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
|
|
89
91
|
resetsAt: weeklyIso,
|
|
90
92
|
periodStart,
|
|
91
93
|
source: "tui",
|
|
@@ -184,6 +186,7 @@ export function parseKimiApiUsage(usagesBody, meBody, now = new Date()) {
|
|
|
184
186
|
}
|
|
185
187
|
// 2. 5-hour window (from limits array)
|
|
186
188
|
let sessionPct = null;
|
|
189
|
+
let fiveHourResetsAt;
|
|
187
190
|
if (Array.isArray(u.limits)) {
|
|
188
191
|
for (const item of u.limits) {
|
|
189
192
|
if (item && typeof item === "object") {
|
|
@@ -193,6 +196,11 @@ export function parseKimiApiUsage(usagesBody, meBody, now = new Date()) {
|
|
|
193
196
|
const limit = Number(detail.limit);
|
|
194
197
|
if (Number.isFinite(used) && Number.isFinite(limit) && limit > 0) {
|
|
195
198
|
sessionPct = Math.round((used / limit) * 100);
|
|
199
|
+
if (typeof detail.resetTime === "string") {
|
|
200
|
+
const parsed = Date.parse(detail.resetTime);
|
|
201
|
+
if (Number.isFinite(parsed))
|
|
202
|
+
fiveHourResetsAt = new Date(parsed).toISOString();
|
|
203
|
+
}
|
|
196
204
|
break;
|
|
197
205
|
}
|
|
198
206
|
}
|
|
@@ -218,6 +226,11 @@ export function parseKimiApiUsage(usagesBody, meBody, now = new Date()) {
|
|
|
218
226
|
if (l5h && typeof l5h.used_ratio === "number") {
|
|
219
227
|
sessionPct = Math.round(l5h.used_ratio * 100);
|
|
220
228
|
}
|
|
229
|
+
if (l5h && typeof l5h.reset_time === "string" && !fiveHourResetsAt) {
|
|
230
|
+
const parsed = Date.parse(l5h.reset_time);
|
|
231
|
+
if (Number.isFinite(parsed))
|
|
232
|
+
fiveHourResetsAt = new Date(parsed).toISOString();
|
|
233
|
+
}
|
|
221
234
|
}
|
|
222
235
|
}
|
|
223
236
|
// 3. Plan identity (from meBody), before the field throws: an empty
|
|
@@ -252,6 +265,7 @@ export function parseKimiApiUsage(usagesBody, meBody, now = new Date()) {
|
|
|
252
265
|
plan,
|
|
253
266
|
weeklyPct,
|
|
254
267
|
fiveHourPct: sessionPct,
|
|
268
|
+
...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
|
|
255
269
|
resetsAt,
|
|
256
270
|
periodStart,
|
|
257
271
|
source: "api",
|
package/dist/adapters/manual.js
CHANGED
|
@@ -20,11 +20,15 @@ export function parseManualUsage(provider, text, now = new Date()) {
|
|
|
20
20
|
if (Number.isFinite(v) && v >= 0 && v <= 100)
|
|
21
21
|
sessionPct = v;
|
|
22
22
|
}
|
|
23
|
+
// The short window's own reset lives on its line, never the weekly one.
|
|
24
|
+
const fiveLine = text.match(/^.*(?:5h|5-hour).*$/gim)?.[0];
|
|
25
|
+
const fiveHourResetsAt = fiveLine ? parseResetText(fiveLine, now) ?? undefined : undefined;
|
|
23
26
|
return {
|
|
24
27
|
provider,
|
|
25
28
|
plan: "unknown",
|
|
26
29
|
weeklyPct: usedPct,
|
|
27
30
|
fiveHourPct: sessionPct,
|
|
31
|
+
...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
|
|
28
32
|
resetsAt,
|
|
29
33
|
periodStart,
|
|
30
34
|
source: "manual",
|
package/dist/adapters/muse.js
CHANGED
|
@@ -105,11 +105,24 @@ function parseMuseCleaned(cleaned, now) {
|
|
|
105
105
|
const planMatch = cleaned.match(/Subscription\s*·\s*Muse Code\s+(.+?)\s{2,}/i);
|
|
106
106
|
if (planMatch)
|
|
107
107
|
plan = planMatch[1].trim();
|
|
108
|
-
// The Current-window reset ("Resets at 3:51 PM") is a bare clock time
|
|
109
|
-
// no date
|
|
110
|
-
//
|
|
111
|
-
// estimate (grok precedent): the
|
|
112
|
-
// estimate flag keeps advisory
|
|
108
|
+
// The Current-window reset ("Resets at 3:51 PM") is a bare clock time
|
|
109
|
+
// with no date: resolve it against the panel's Brisbane date, rolling to
|
|
110
|
+
// tomorrow once today's time has passed. Display-only; a missing or
|
|
111
|
+
// unparseable Weekly reset degrades to an estimate (grok precedent): the
|
|
112
|
+
// percents are the critical fields, and the estimate flag keeps advisory
|
|
113
|
+
// and scheduler honest.
|
|
114
|
+
const currentReset = cleaned.match(/Current\s+\d+%\s+used[\s\S]{0,120}?Resets at\s+(\d{1,2}):(\d{2})\s*([AP]M)/i);
|
|
115
|
+
let fiveHourResetsAt;
|
|
116
|
+
if (currentReset) {
|
|
117
|
+
let hour = parseInt(currentReset[1], 10) % 12;
|
|
118
|
+
if (/pm/i.test(currentReset[3]))
|
|
119
|
+
hour += 12;
|
|
120
|
+
const brisbaneNow = new Date(now.getTime() + 10 * 3600000);
|
|
121
|
+
let candidate = Date.UTC(brisbaneNow.getUTCFullYear(), brisbaneNow.getUTCMonth(), brisbaneNow.getUTCDate(), hour - 10, parseInt(currentReset[2], 10));
|
|
122
|
+
if (candidate <= now.getTime())
|
|
123
|
+
candidate += 86400000;
|
|
124
|
+
fiveHourResetsAt = new Date(candidate).toISOString();
|
|
125
|
+
}
|
|
113
126
|
const resetMatch = cleaned.match(/Weekly[\s\S]{0,400}?Resets\s+([A-Za-z]{3}\s+\d{1,2}\s+at\s+\d{1,2}:\d{2}\s*[AP]M)/i);
|
|
114
127
|
let resetsAt = resetMatch ? parseResetText(`resets ${resetMatch[1].trim()}`, now) : null;
|
|
115
128
|
let estimated = false;
|
|
@@ -123,6 +136,7 @@ function parseMuseCleaned(cleaned, now) {
|
|
|
123
136
|
plan,
|
|
124
137
|
weeklyPct: usedPct,
|
|
125
138
|
fiveHourPct: sessionPct,
|
|
139
|
+
...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
|
|
126
140
|
resetsAt,
|
|
127
141
|
periodStart,
|
|
128
142
|
source: "tui",
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import type { ParsedQuota } from "./types.js";
|
|
2
|
+
export declare const OPENCODE_LOGIN_COMMAND_V2 = "opencode auth login opencode";
|
|
3
|
+
export declare const OPENCODE_LOGIN_COMMAND_V1 = "opencode console login";
|
|
4
|
+
/** Login command for the installed OpenCode CLI major version, or null
|
|
5
|
+
* when the version text carries no readable major. */
|
|
6
|
+
export declare function openCodeLoginCommandForVersion(versionText: string | null | undefined): string | null;
|
|
7
|
+
/** Best-effort local detection: `<opencode> --version`, never throws. */
|
|
8
|
+
export declare function detectOpenCodeLoginCommand(versionReader?: () => Promise<string | null>): Promise<string | null>;
|
|
9
|
+
export declare function openCodeGoConsentNotice(loginCommand?: string | null): string;
|
|
2
10
|
export declare const OPENCODE_GO_CONSENT_NOTICE: string;
|
|
3
|
-
export declare function openCodeGoAuthPath(): string;
|
|
11
|
+
export declare function openCodeGoAuthPath(env?: NodeJS.Dict<string>, home?: string): string;
|
|
12
|
+
/** v2 database. `OPENCODE_DB` wins, otherwise the OpenCode data directory. */
|
|
13
|
+
export declare function openCodeGoDbPath(env?: NodeJS.Dict<string>, home?: string): string;
|
|
4
14
|
/** Detection is existence only — no content read before enablement. */
|
|
5
|
-
export declare function opencodeGoDetected(): boolean;
|
|
15
|
+
export declare function opencodeGoDetected(env?: NodeJS.Dict<string>, home?: string): boolean;
|
|
6
16
|
export declare function parseOpencodeGoUsage(body: unknown, now?: Date): ParsedQuota;
|
|
7
17
|
export declare const opencodeGoAdapter: {
|
|
8
18
|
id: string;
|
|
@@ -1,57 +1,177 @@
|
|
|
1
|
-
// Consented exception to credential-free polling: reads the OpenCode
|
|
2
|
-
//
|
|
1
|
+
// Consented exception to credential-free polling: reads the OpenCode bearer
|
|
2
|
+
// in-memory, only after `providers enable opencode-go` records consent.
|
|
3
|
+
// v2 stores it in opencode.db. v1 stored an API key in auth.json.
|
|
3
4
|
// The static gate pins the auth-file literal to this adapter plus the
|
|
4
5
|
// generated dashboard bundle — see tests/adapters/credential-free.test.ts.
|
|
6
|
+
import { createRequire } from "node:module";
|
|
5
7
|
import fsSync from "node:fs";
|
|
6
8
|
import os from "node:os";
|
|
7
9
|
import path from "node:path";
|
|
8
10
|
import { adapterSignal } from "../runtime/spawn.js";
|
|
11
|
+
import { getCliVersion } from "../diagnostics/bundle.js";
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
9
13
|
const USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
|
|
10
14
|
const POLL_TIMEOUT_MS = 8000;
|
|
11
|
-
export const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
export const OPENCODE_LOGIN_COMMAND_V2 = "opencode auth login opencode";
|
|
16
|
+
export const OPENCODE_LOGIN_COMMAND_V1 = "opencode console login";
|
|
17
|
+
/** Login command for the installed OpenCode CLI major version, or null
|
|
18
|
+
* when the version text carries no readable major. */
|
|
19
|
+
export function openCodeLoginCommandForVersion(versionText) {
|
|
20
|
+
if (!versionText)
|
|
21
|
+
return null;
|
|
22
|
+
const m = versionText.match(/(?:^|\s)v?(\d+)\.\d+/);
|
|
23
|
+
if (!m)
|
|
24
|
+
return null;
|
|
25
|
+
const major = parseInt(m[1], 10);
|
|
26
|
+
if (major >= 2)
|
|
27
|
+
return OPENCODE_LOGIN_COMMAND_V2;
|
|
28
|
+
if (major === 1)
|
|
29
|
+
return OPENCODE_LOGIN_COMMAND_V1;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/** Best-effort local detection: `<opencode> --version`, never throws. */
|
|
33
|
+
export async function detectOpenCodeLoginCommand(versionReader = () => getCliVersion("opencode")) {
|
|
34
|
+
try {
|
|
35
|
+
return openCodeLoginCommandForVersion(await versionReader());
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function openCodeGoConsentNotice(loginCommand) {
|
|
42
|
+
const loginClause = loginCommand
|
|
43
|
+
? `when you ran \`${loginCommand}\``
|
|
44
|
+
: `with the command for your CLI version: v2 \`${OPENCODE_LOGIN_COMMAND_V2}\` or v1 \`${OPENCODE_LOGIN_COMMAND_V1}\``;
|
|
45
|
+
return [
|
|
46
|
+
`• What is read: Bearer [REDACTED] already stored ${loginClause} (v2 \`~/.local/share/opencode/opencode.db\` active credential, else legacy \`~/.local/share/opencode/auth.json\`; \`opencode-go\` then \`opencode\`).`,
|
|
47
|
+
"• Read frequency: in-memory, read-only once per poll (every 15 minutes by default).",
|
|
48
|
+
"• Request destination: sent only as `Authorization: Bearer` to `https://opencode.ai/zen/go/v1/usage` to fetch `rolling/weekly/monthly` `percent` + `resetsAt`.",
|
|
49
|
+
"• Never-list: never used to make chat or model requests, never written or rotated, never stored in the `quotacap` DB, never returned by the API or MCP, and never logged (errors show `[redacted]`).",
|
|
50
|
+
"• Revocation instructions: revoke at `https://opencode.ai/auth` or `opencode providers logout opencode-go` and run `quotacap providers disable opencode-go` to stop polling.",
|
|
51
|
+
"• Optional environment override: if you prefer no file access, set `OPENCODE_API_KEY` instead.",
|
|
52
|
+
].join("\n");
|
|
53
|
+
}
|
|
54
|
+
export const OPENCODE_GO_CONSENT_NOTICE = openCodeGoConsentNotice();
|
|
55
|
+
export function openCodeGoAuthPath(env = process.env, home = os.homedir()) {
|
|
56
|
+
return path.join(openCodeDataDir(env, home), "auth.json");
|
|
57
|
+
}
|
|
58
|
+
/** v2 database. `OPENCODE_DB` wins, otherwise the OpenCode data directory. */
|
|
59
|
+
export function openCodeGoDbPath(env = process.env, home = os.homedir()) {
|
|
60
|
+
const override = env.OPENCODE_DB;
|
|
61
|
+
if (typeof override === "string" && override.length > 0)
|
|
62
|
+
return override;
|
|
63
|
+
return path.join(openCodeDataDir(env, home), "opencode.db");
|
|
64
|
+
}
|
|
65
|
+
function openCodeDataDir(env, home) {
|
|
66
|
+
const xdg = env.XDG_DATA_HOME;
|
|
67
|
+
if (typeof xdg === "string" && xdg.length > 0)
|
|
68
|
+
return path.join(xdg, "opencode");
|
|
69
|
+
return path.join(home, ".local", "share", "opencode");
|
|
21
70
|
}
|
|
22
71
|
/** Detection is existence only — no content read before enablement. */
|
|
23
|
-
export function opencodeGoDetected() {
|
|
72
|
+
export function opencodeGoDetected(env = process.env, home = os.homedir()) {
|
|
24
73
|
try {
|
|
25
|
-
return fsSync.existsSync(openCodeGoAuthPath());
|
|
74
|
+
return fsSync.existsSync(openCodeGoDbPath(env, home)) || fsSync.existsSync(openCodeGoAuthPath(env, home));
|
|
26
75
|
}
|
|
27
76
|
catch {
|
|
28
77
|
return false;
|
|
29
78
|
}
|
|
30
79
|
}
|
|
80
|
+
function openReadonlySqlite(dbPath) {
|
|
81
|
+
try {
|
|
82
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
83
|
+
return new DatabaseSync(dbPath, { readOnly: true });
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
const code = err.code;
|
|
87
|
+
if (code !== "ERR_UNKNOWN_BUILTIN_MODULE" && code !== "MODULE_NOT_FOUND")
|
|
88
|
+
throw err;
|
|
89
|
+
const { Database } = require("bun:sqlite");
|
|
90
|
+
return new Database(dbPath, { readonly: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** API key, or the OAuth access token. Refresh material is never read. */
|
|
94
|
+
function bearerFromStoredValue(raw) {
|
|
95
|
+
if (typeof raw !== "string" || raw.length === 0)
|
|
96
|
+
return null;
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(raw);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
105
|
+
return null;
|
|
106
|
+
const entry = parsed;
|
|
107
|
+
if (entry.type === "api" && typeof entry.key === "string" && entry.key.length > 0)
|
|
108
|
+
return entry.key;
|
|
109
|
+
if (entry.type === "oauth" && typeof entry.access === "string" && entry.access.length > 0)
|
|
110
|
+
return entry.access;
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Active v2 credential. `opencode-go` wins over `opencode`. Newest row wins
|
|
115
|
+
* within one integration. A missing or unreadable database is no credential.
|
|
116
|
+
*/
|
|
117
|
+
function readV2Bearer(env, home) {
|
|
118
|
+
const dbPath = openCodeGoDbPath(env, home);
|
|
119
|
+
if (!fsSync.existsSync(dbPath))
|
|
120
|
+
return null;
|
|
121
|
+
let db;
|
|
122
|
+
try {
|
|
123
|
+
db = openReadonlySqlite(dbPath);
|
|
124
|
+
const rows = db.prepare("SELECT integration_id, value FROM credential WHERE active = 1 AND integration_id IN ('opencode-go', 'opencode') ORDER BY rowid DESC").all();
|
|
125
|
+
const chosen = new Map();
|
|
126
|
+
for (const row of rows) {
|
|
127
|
+
const id = row.integration_id;
|
|
128
|
+
if (typeof id !== "string" || chosen.has(id))
|
|
129
|
+
continue;
|
|
130
|
+
const bearer = bearerFromStoredValue(row.value);
|
|
131
|
+
if (bearer)
|
|
132
|
+
chosen.set(id, bearer);
|
|
133
|
+
}
|
|
134
|
+
return chosen.get("opencode-go") ?? chosen.get("opencode") ?? null;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
try {
|
|
141
|
+
db?.close();
|
|
142
|
+
}
|
|
143
|
+
catch { /* a failed open has nothing to close */ }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function readLegacyBearer(env, home) {
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(fsSync.readFileSync(openCodeGoAuthPath(env, home), "utf8"));
|
|
149
|
+
const entry = (parsed["opencode-go"] ?? parsed["opencode"]);
|
|
150
|
+
if (!entry || entry.type !== "api" || typeof entry.key !== "string" || entry.key.length === 0)
|
|
151
|
+
return null;
|
|
152
|
+
return entry.key;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
31
158
|
/**
|
|
32
159
|
* Consent-gated: poll() runs only when the provider is in enabledProviders,
|
|
33
160
|
* which the enable flow records as explicit consent. One read, in-memory
|
|
34
161
|
* only, never written or logged. Env wins so a host can avoid file access.
|
|
162
|
+
* v2's database wins over the legacy file, which v2 no longer updates.
|
|
35
163
|
*/
|
|
36
|
-
function readBearerToken(env = process.env) {
|
|
164
|
+
function readBearerToken(env = process.env, home = os.homedir()) {
|
|
37
165
|
const fromEnv = env.OPENCODE_API_KEY;
|
|
38
166
|
if (typeof fromEnv === "string" && fromEnv.length > 0)
|
|
39
167
|
return fromEnv;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const entry = (parsed["opencode-go"] ?? parsed["opencode"]);
|
|
48
|
-
if (!entry ||
|
|
49
|
-
entry.type !== "api" ||
|
|
50
|
-
typeof entry.key !== "string" ||
|
|
51
|
-
!entry.key.startsWith("sk-")) {
|
|
52
|
-
throw new Error("opencode-go: missing API key in auth file");
|
|
53
|
-
}
|
|
54
|
-
return entry.key;
|
|
168
|
+
const fromV2 = readV2Bearer(env, home);
|
|
169
|
+
if (fromV2)
|
|
170
|
+
return fromV2;
|
|
171
|
+
const fromLegacy = readLegacyBearer(env, home);
|
|
172
|
+
if (fromLegacy)
|
|
173
|
+
return fromLegacy;
|
|
174
|
+
throw new Error("opencode-go: missing API key in auth file");
|
|
55
175
|
}
|
|
56
176
|
// `rate-limited` is the binding state, not a failure: it is exactly the
|
|
57
177
|
// reading issue 109 needed. Anything unreadable fails closed.
|
|
@@ -110,12 +230,17 @@ export function parseOpencodeGoUsage(body, now = new Date()) {
|
|
|
110
230
|
if (typeof sessionPct !== "number" || !Number.isFinite(sessionPct) || sessionPct < 0 || sessionPct > 100) {
|
|
111
231
|
throw new Error("opencode-go: bad rolling pct");
|
|
112
232
|
}
|
|
233
|
+
const rollingResetsMs = Date.parse(String(rolling.resetsAt ?? ""));
|
|
234
|
+
const fiveHourResetsAt = Number.isFinite(rollingResetsMs)
|
|
235
|
+
? new Date(rollingResetsMs).toISOString()
|
|
236
|
+
: undefined;
|
|
113
237
|
const monthly = parseMonthly(usage);
|
|
114
238
|
return {
|
|
115
239
|
provider: "opencode-go",
|
|
116
240
|
plan: "unknown",
|
|
117
241
|
weeklyPct: usedPct,
|
|
118
242
|
fiveHourPct: sessionPct,
|
|
243
|
+
...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
|
|
119
244
|
resetsAt: new Date(resetsMs).toISOString(),
|
|
120
245
|
periodStart: new Date(resetsMs - 7 * 86400000).toISOString(),
|
|
121
246
|
source: "api",
|
|
@@ -125,7 +250,7 @@ export function parseOpencodeGoUsage(body, now = new Date()) {
|
|
|
125
250
|
}
|
|
126
251
|
export const opencodeGoAdapter = {
|
|
127
252
|
id: "opencode-go",
|
|
128
|
-
requiresAuth: "
|
|
253
|
+
requiresAuth: "OpenCode CLI login for your version (read only after you enable opencode-go)",
|
|
129
254
|
async poll() {
|
|
130
255
|
const bearer = readBearerToken();
|
|
131
256
|
const adapter = adapterSignal("opencode-go");
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface Quota {
|
|
|
5
5
|
weeklyPct: number;
|
|
6
6
|
/** 5-hour rolling window. 0..100. Display only — never ranks. Was `sessionPct`. */
|
|
7
7
|
fiveHourPct?: number;
|
|
8
|
+
/** When the 5-hour window resets, when the provider reports it. Display only. */
|
|
9
|
+
fiveHourResetsAt?: string;
|
|
8
10
|
/** Included monthly envelope. 0..100. Absent means the provider has no monthly window. */
|
|
9
11
|
monthlyPct?: number;
|
|
10
12
|
monthlyResetsAt?: string;
|
|
@@ -151,6 +151,12 @@ export function buildSnapshot(db, opts) {
|
|
|
151
151
|
paceByProvider.set(id, pace);
|
|
152
152
|
}
|
|
153
153
|
const catalog = catalogMap.get(id) ?? emptyCatalog();
|
|
154
|
+
const closeWindow = advisory?.bindingWindow === "monthly" &&
|
|
155
|
+
quota?.monthlyKind === "included" &&
|
|
156
|
+
typeof quota?.monthlyPct === "number" &&
|
|
157
|
+
!!quota?.monthlyResetsAt
|
|
158
|
+
? "monthly"
|
|
159
|
+
: "weekly";
|
|
154
160
|
providerSnapshots.push({
|
|
155
161
|
id,
|
|
156
162
|
...providerIdentity(id, opts.providerNames),
|
|
@@ -165,7 +171,7 @@ export function buildSnapshot(db, opts) {
|
|
|
165
171
|
evidence,
|
|
166
172
|
exclusionReason,
|
|
167
173
|
advisory,
|
|
168
|
-
lastCloses: getWindowCloses(db, id, 4),
|
|
174
|
+
lastCloses: getWindowCloses(db, id, 4, closeWindow),
|
|
169
175
|
catalog,
|
|
170
176
|
});
|
|
171
177
|
}
|
package/dist/advisory/types.d.ts
CHANGED
|
@@ -113,6 +113,9 @@ export interface StateSnapshot {
|
|
|
113
113
|
/** Auth detected for these provider ids (detection-only; consumers intersect with the enabled set).
|
|
114
114
|
* Empty on daemons predating the field. */
|
|
115
115
|
detectedProviders?: string[];
|
|
116
|
+
/** Login command for the locally detected OpenCode CLI version, or
|
|
117
|
+
* null when the version could not be read. */
|
|
118
|
+
opencodeLoginCommand?: string | null;
|
|
116
119
|
};
|
|
117
120
|
providers: ProviderSnapshot[];
|
|
118
121
|
recommendation: Recommendation;
|
package/dist/cli/models.js
CHANGED
|
@@ -5,21 +5,22 @@ import { getAllLatest } from "../store/quotas.js";
|
|
|
5
5
|
import { providerIdentity } from "../advisory/provider-names.js";
|
|
6
6
|
import { readToken } from "../runtime/token.js";
|
|
7
7
|
import { createServiceClient } from "../runtime/client.js";
|
|
8
|
+
import { bindingLeftover } from "../format/rows.js";
|
|
8
9
|
import { OFFLINE_LABEL } from "./snapshot-source.js";
|
|
9
10
|
import { phase } from "./progress.js";
|
|
10
|
-
function projectModels(catalogs, quotaMap, providerNames) {
|
|
11
|
+
function projectModels(catalogs, quotaMap, providerNames, now = new Date()) {
|
|
11
12
|
const out = [];
|
|
12
13
|
for (const [id, catalog] of catalogs) {
|
|
13
14
|
const identity = providerIdentity(id, providerNames);
|
|
14
15
|
const quota = quotaMap.get(id);
|
|
15
|
-
const
|
|
16
|
+
const binding = bindingLeftover(quota, now);
|
|
16
17
|
out.push({
|
|
17
18
|
id,
|
|
18
19
|
displayName: identity.displayName,
|
|
19
20
|
harness: identity.harness,
|
|
20
21
|
vendor: identity.vendor,
|
|
21
|
-
leftoverPct,
|
|
22
|
-
resetsAt:
|
|
22
|
+
leftoverPct: binding.leftoverPct,
|
|
23
|
+
resetsAt: binding.resetsAt,
|
|
23
24
|
catalog,
|
|
24
25
|
});
|
|
25
26
|
}
|
package/dist/format/rows.d.ts
CHANGED
|
@@ -16,6 +16,35 @@ export interface Row {
|
|
|
16
16
|
}
|
|
17
17
|
export declare const RAIL_CELLS = 20;
|
|
18
18
|
export declare const SORT_KEYS: SortKey[];
|
|
19
|
+
/**
|
|
20
|
+
* Quota-window resolution, mirroring the dashboard helpers: the advisory
|
|
21
|
+
* binding window rations the account, so standalone resets, usage,
|
|
22
|
+
* elapsed, and pace read it. Weekly-bound rows fall back to the weekly
|
|
23
|
+
* fields unchanged; rate-limit windows never drive these figures.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isMonthlyBound(ps: ProviderSnapshot): boolean;
|
|
26
|
+
/** Reset instant of the quota window, or null without a quota reading. */
|
|
27
|
+
export declare function bindingResetsAt(ps: ProviderSnapshot): string | null;
|
|
28
|
+
/** Used percent of the quota window. Null without a quota reading. */
|
|
29
|
+
export declare function bindingUsedPct(ps: ProviderSnapshot): number | null;
|
|
30
|
+
/**
|
|
31
|
+
* Binding leftover for a raw quota without an advisory (model catalogs):
|
|
32
|
+
* the same scarcer-headroom rule the engine applies, so the catalog
|
|
33
|
+
* leftover and reset name the quota window, not always the week.
|
|
34
|
+
*/
|
|
35
|
+
export declare function bindingLeftover(quota: {
|
|
36
|
+
weeklyPct?: number;
|
|
37
|
+
resetsAt?: string;
|
|
38
|
+
monthlyKind?: string;
|
|
39
|
+
monthlyPct?: number;
|
|
40
|
+
monthlyResetsAt?: string;
|
|
41
|
+
monthlyStatus?: string;
|
|
42
|
+
} | null | undefined, now: Date): {
|
|
43
|
+
leftoverPct: number | null;
|
|
44
|
+
resetsAt: string | null;
|
|
45
|
+
};
|
|
46
|
+
/** Window-average pace of the quota window; monthly divides monthly used by elapsed monthly days. */
|
|
47
|
+
export declare function bindingAvgPace(ps: ProviderSnapshot, now: Date): number | null;
|
|
19
48
|
export declare function weeklyPctOf(q: {
|
|
20
49
|
weeklyPct?: number;
|
|
21
50
|
usedPct?: number;
|
|
@@ -31,5 +60,5 @@ export declare function forecastText(ps: ProviderSnapshot, now: Date): string;
|
|
|
31
60
|
export declare function compactSuffix(ps: ProviderSnapshot): CompactSuffix;
|
|
32
61
|
export declare function assertValidSortKey(key: string): asserts key is SortKey;
|
|
33
62
|
export declare function sortProviders(providers: ProviderSnapshot[], key: string, use: string): ProviderSnapshot[];
|
|
34
|
-
export declare function paceText(ps: ProviderSnapshot): string;
|
|
63
|
+
export declare function paceText(ps: ProviderSnapshot, now?: Date): string;
|
|
35
64
|
export declare function buildRow(ps: ProviderSnapshot, now: Date): Row;
|