@latentminds/pi-quotas 0.2.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,14 +3,18 @@ import { readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import type { AuthStorage } from "@mariozechner/pi-coding-agent";
6
- import type { QuotasResult, SupportedQuotaProvider } from "../types/quotas.js";
6
+ import type { QuotasErrorKind, QuotasResult, SupportedQuotaProvider } from "../types/quotas.js";
7
7
  import {
8
8
  parseAnthropicUsage,
9
9
  parseCodexUsage,
10
10
  parseGitHubCopilotUsage,
11
11
  parseOpenRouterUsage,
12
12
  parseSyntheticUsage,
13
+ parseZaiUsage,
14
+ parseOpenCodeGoUsage,
13
15
  } from "./providers.js";
16
+ import { resolveOpenCodeGoConfigCached } from "./opencode-go-config.js";
17
+ import { queryOpenCodeGoQuota } from "./opencode-go.js";
14
18
 
15
19
  const FETCH_TIMEOUT_MS = 15_000;
16
20
  const COPILOT_VERSION = "0.35.0";
@@ -30,6 +34,16 @@ async function providerAccessToken(
30
34
  return authStorage.getApiKey(provider);
31
35
  }
32
36
 
37
+ /**
38
+ * Detect a raw Anthropic API key (`sk-ant-...`). OAuth subscription tokens
39
+ * issued by `pi /login` are JWT-shaped (`eyJ...`) or opaque and never carry
40
+ * the `sk-ant-` prefix, so this reliably distinguishes a direct API key that
41
+ * has no OAuth subscription usage to report.
42
+ */
43
+ function isDirectAnthropicApiKey(token: string): boolean {
44
+ return token.startsWith("sk-ant-");
45
+ }
46
+
33
47
  function codexAccountId(authStorage: AuthStorage): string | undefined {
34
48
  const credential = authStorage.get("openai-codex") as any;
35
49
  if (typeof credential?.accountId === "string") return credential.accountId;
@@ -51,6 +65,34 @@ type FetchJsonResult =
51
65
  kind: "timeout" | "cancelled" | "http" | "network";
52
66
  };
53
67
 
68
+ /**
69
+ * Reduce a raw HTTP error body to a short, human-readable message.
70
+ *
71
+ * Many APIs return a JSON error object (e.g. Anthropic's
72
+ * `{"error":{"type":"authentication_error","message":"..."}}` or a
73
+ * bare `{"message":"..."}`). Surfacing the raw blob in the dashboard or
74
+ * footer is ugly and confusing, so extract the inner message field when the
75
+ * body parses as JSON; otherwise return the body unchanged.
76
+ */
77
+ function cleanHttpErrorMessage(body: string): string {
78
+ const trimmed = body.trim();
79
+ if (!trimmed) return "";
80
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return trimmed;
81
+ try {
82
+ const parsed = JSON.parse(trimmed) as any;
83
+ const message =
84
+ parsed?.error?.message ??
85
+ parsed?.message ??
86
+ parsed?.error ??
87
+ parsed?.detail ??
88
+ parsed?.error_description;
89
+ if (typeof message === "string" && message.trim()) return message.trim();
90
+ } catch {
91
+ // Not valid JSON — fall through to the raw body.
92
+ }
93
+ return trimmed;
94
+ }
95
+
54
96
  async function fetchJson(
55
97
  url: string,
56
98
  init: RequestInit,
@@ -67,7 +109,7 @@ async function fetchJson(
67
109
  return {
68
110
  ok: false,
69
111
  status: response.status,
70
- message: body || response.statusText || `HTTP ${response.status}`,
112
+ message: cleanHttpErrorMessage(body) || response.statusText || `HTTP ${response.status}`,
71
113
  kind: "http",
72
114
  };
73
115
  }
@@ -87,11 +129,17 @@ async function fetchJson(
87
129
  }
88
130
  }
89
131
 
90
- function success(provider: SupportedQuotaProvider, windows: ReturnType<typeof parseAnthropicUsage>): QuotasResult {
132
+ function success(
133
+ provider: SupportedQuotaProvider,
134
+ windows: ReturnType<typeof parseAnthropicUsage>,
135
+ ): QuotasResult {
91
136
  return { success: true, data: { provider, windows } };
92
137
  }
93
138
 
94
- function failure(message: string, kind: "cancelled" | "timeout" | "config" | "http" | "network"): QuotasResult {
139
+ function failure(
140
+ message: string,
141
+ kind: QuotasErrorKind,
142
+ ): QuotasResult {
95
143
  return { success: false, error: { message, kind } };
96
144
  }
97
145
 
@@ -100,6 +148,16 @@ export async function fetchAnthropicQuotasWithToken(
100
148
  signal?: AbortSignal,
101
149
  ): Promise<QuotasResult> {
102
150
  if (!accessToken) return failure("No Anthropic OAuth token found", "config");
151
+ // The /api/oauth/usage endpoint requires OAuth subscription credentials.
152
+ // A direct API key (e.g. `sk-ant-api03-...` registered via `pi /login`) is
153
+ // not an OAuth token, so the call would always fail. Detect it up front and
154
+ // return a silent "not applicable" result rather than a warning.
155
+ if (isDirectAnthropicApiKey(accessToken)) {
156
+ return failure(
157
+ "Direct Anthropic API key — no subscription usage to report",
158
+ "not_applicable",
159
+ );
160
+ }
103
161
  const result = await fetchJson(
104
162
  "https://api.anthropic.com/api/oauth/usage",
105
163
  {
@@ -158,11 +216,13 @@ function copilotHeaders(authHeader: string): Record<string, string> {
158
216
  */
159
217
  function ghCliToken(): string | undefined {
160
218
  try {
161
- return execFileSync("gh", ["auth", "token"], {
162
- timeout: 5000,
163
- encoding: "utf8",
164
- stdio: ["ignore", "pipe", "ignore"],
165
- }).trim() || undefined;
219
+ return (
220
+ execFileSync("gh", ["auth", "token"], {
221
+ timeout: 5000,
222
+ encoding: "utf8",
223
+ stdio: ["ignore", "pipe", "ignore"],
224
+ }).trim() || undefined
225
+ );
166
226
  } catch {
167
227
  return undefined;
168
228
  }
@@ -194,13 +254,22 @@ async function fetchGitHubCopilotQuotasWithGitHubToken(
194
254
  githubToken: string | undefined,
195
255
  signal?: AbortSignal,
196
256
  ): Promise<QuotasResult> {
197
- if (!githubToken) return failure("No GitHub Copilot OAuth token found", "config");
257
+ if (!githubToken)
258
+ return failure("No GitHub Copilot OAuth token found", "config");
198
259
 
199
- const bearerUsage = await tryGitHubUserEndpoint(`Bearer ${githubToken}`, signal);
200
- if (bearerUsage.ok) return success("github-copilot", parseGitHubCopilotUsage(bearerUsage.data));
260
+ const bearerUsage = await tryGitHubUserEndpoint(
261
+ `Bearer ${githubToken}`,
262
+ signal,
263
+ );
264
+ if (bearerUsage.ok)
265
+ return success("github-copilot", parseGitHubCopilotUsage(bearerUsage.data));
201
266
 
202
- const tokenUsage = await tryGitHubUserEndpoint(`token ${githubToken}`, signal);
203
- if (tokenUsage.ok) return success("github-copilot", parseGitHubCopilotUsage(tokenUsage.data));
267
+ const tokenUsage = await tryGitHubUserEndpoint(
268
+ `token ${githubToken}`,
269
+ signal,
270
+ );
271
+ if (tokenUsage.ok)
272
+ return success("github-copilot", parseGitHubCopilotUsage(tokenUsage.data));
204
273
 
205
274
  return failure(bearerUsage.message, bearerUsage.kind);
206
275
  }
@@ -209,7 +278,8 @@ export async function fetchGitHubCopilotQuotasWithToken(
209
278
  accessToken: string | undefined,
210
279
  signal?: AbortSignal,
211
280
  ): Promise<QuotasResult> {
212
- if (!accessToken) return failure("No GitHub Copilot OAuth token found", "config");
281
+ if (!accessToken)
282
+ return failure("No GitHub Copilot OAuth token found", "config");
213
283
 
214
284
  // 1) Try Copilot token exchange with stored Pi token
215
285
  const exchange = await fetchJson(
@@ -219,19 +289,28 @@ export async function fetchGitHubCopilotQuotasWithToken(
219
289
  );
220
290
 
221
291
  if (exchange.ok && exchange.data?.token) {
222
- const usage = await tryGitHubUserEndpoint(`Bearer ${exchange.data.token}`, signal);
223
- if (usage.ok) return success("github-copilot", parseGitHubCopilotUsage(usage.data));
292
+ const usage = await tryGitHubUserEndpoint(
293
+ `Bearer ${exchange.data.token}`,
294
+ signal,
295
+ );
296
+ if (usage.ok)
297
+ return success("github-copilot", parseGitHubCopilotUsage(usage.data));
224
298
  }
225
299
 
226
300
  // 2) Try stored token directly
227
- const directUsage = await tryGitHubUserEndpoint(`token ${accessToken}`, signal);
228
- if (directUsage.ok) return success("github-copilot", parseGitHubCopilotUsage(directUsage.data));
301
+ const directUsage = await tryGitHubUserEndpoint(
302
+ `token ${accessToken}`,
303
+ signal,
304
+ );
305
+ if (directUsage.ok)
306
+ return success("github-copilot", parseGitHubCopilotUsage(directUsage.data));
229
307
 
230
308
  // 3) Fallback: gh CLI token
231
309
  const cliToken = ghCliToken();
232
310
  if (cliToken && cliToken !== accessToken) {
233
311
  const cliUsage = await tryGitHubUserEndpoint(`token ${cliToken}`, signal);
234
- if (cliUsage.ok) return success("github-copilot", parseGitHubCopilotUsage(cliUsage.data));
312
+ if (cliUsage.ok)
313
+ return success("github-copilot", parseGitHubCopilotUsage(cliUsage.data));
235
314
  return failure(cliUsage.message, cliUsage.kind);
236
315
  }
237
316
 
@@ -242,7 +321,10 @@ export async function fetchAnthropicQuotas(
242
321
  authStorage: AuthStorage,
243
322
  signal?: AbortSignal,
244
323
  ): Promise<QuotasResult> {
245
- return fetchAnthropicQuotasWithToken(await providerAccessToken(authStorage, "anthropic"), signal);
324
+ return fetchAnthropicQuotasWithToken(
325
+ await providerAccessToken(authStorage, "anthropic"),
326
+ signal,
327
+ );
246
328
  }
247
329
 
248
330
  export async function fetchCodexQuotas(
@@ -312,7 +394,11 @@ export async function fetchSyntheticQuotas(
312
394
  signal?: AbortSignal,
313
395
  ): Promise<QuotasResult> {
314
396
  const apiKey = process.env.SYNTHETIC_API_KEY;
315
- if (!apiKey) return failure("No Synthetic API key found (set SYNTHETIC_API_KEY)", "config");
397
+ if (!apiKey)
398
+ return failure(
399
+ "No Synthetic API key found (set SYNTHETIC_API_KEY)",
400
+ "config",
401
+ );
316
402
 
317
403
  const result = await fetchJson(
318
404
  "https://api.synthetic.new/v2/quotas",
@@ -325,10 +411,69 @@ export async function fetchSyntheticQuotas(
325
411
  return success("synthetic", parseSyntheticUsage(result.data));
326
412
  }
327
413
 
414
+ export async function fetchOpenCodeGoQuotas(
415
+ _authStorage: AuthStorage,
416
+ signal?: AbortSignal,
417
+ ): Promise<QuotasResult> {
418
+ const configResult = await resolveOpenCodeGoConfigCached();
419
+ if (configResult.state === "none") {
420
+ return failure(
421
+ "No OpenCode Go config. Set OPENCODE_GO_WORKSPACE_ID +" +
422
+ " OPENCODE_GO_AUTH_COOKIE, or create" +
423
+ " ~/.config/opencode/opencode-quota/opencode-go.json",
424
+ "config",
425
+ );
426
+ }
427
+ if (configResult.state === "incomplete") {
428
+ return failure(
429
+ `OpenCode Go config incomplete: missing ${configResult.missing}`,
430
+ "config",
431
+ );
432
+ }
433
+ if (configResult.state === "invalid") {
434
+ return failure(
435
+ `OpenCode Go config invalid: ${configResult.error}`,
436
+ "config",
437
+ );
438
+ }
439
+
440
+ const result = await queryOpenCodeGoQuota(configResult.config, signal);
441
+ if (!result.success) return failure(result.error, "http");
442
+ return success("opencode-go", parseOpenCodeGoUsage(result));
443
+ }
444
+
445
+ export async function fetchZaiQuotasWithToken(
446
+ apiKey: string | undefined,
447
+ signal?: AbortSignal,
448
+ ): Promise<QuotasResult> {
449
+ if (!apiKey) return failure("No Z.ai API key found", "config");
450
+ const result = await fetchJson(
451
+ "https://api.z.ai/api/monitor/usage/quota/limit",
452
+ {
453
+ headers: {
454
+ Authorization: `Bearer ${apiKey}`,
455
+ Accept: "application/json",
456
+ },
457
+ },
458
+ signal,
459
+ );
460
+ if (!result.ok) return failure(result.message, result.kind);
461
+ return success("zai", parseZaiUsage(result.data));
462
+ }
463
+
464
+ export async function fetchZaiQuotas(
465
+ authStorage: AuthStorage,
466
+ signal?: AbortSignal,
467
+ ): Promise<QuotasResult> {
468
+ return fetchZaiQuotasWithToken(await providerAccessToken(authStorage, "zai"), signal);
469
+ }
470
+
328
471
  export const PROVIDER_FETCHERS = {
329
472
  anthropic: fetchAnthropicQuotas,
330
473
  "openai-codex": fetchCodexQuotas,
331
474
  "github-copilot": fetchGitHubCopilotQuotas,
332
475
  openrouter: fetchOpenRouterQuotas,
333
476
  synthetic: fetchSyntheticQuotas,
477
+ zai: fetchZaiQuotas,
478
+ "opencode-go": fetchOpenCodeGoQuotas,
334
479
  } as const;
@@ -0,0 +1,127 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ export interface OpenCodeGoConfig {
6
+ workspaceId: string;
7
+ authCookie: string;
8
+ }
9
+
10
+ export type ResolvedOpenCodeGoConfig =
11
+ | { state: "none" }
12
+ | { state: "configured"; config: OpenCodeGoConfig; source: string }
13
+ | { state: "incomplete"; source: string; missing: string }
14
+ | { state: "invalid"; source: string; error: string };
15
+
16
+ function getConfigCandidatePaths(): string[] {
17
+ const home = homedir();
18
+ return [
19
+ join(home, ".config", "opencode", "opencode-quota", "opencode-go.json"),
20
+ join(home, ".config", "opencode-go", "config.json"),
21
+ ];
22
+ }
23
+
24
+ async function readConfigFile(
25
+ path: string,
26
+ ): Promise<
27
+ | { state: "missing" }
28
+ | { state: "loaded"; config: Partial<OpenCodeGoConfig> }
29
+ | { state: "invalid"; error: string }
30
+ > {
31
+ try {
32
+ const data = await readFile(path, "utf-8");
33
+ const parsed = JSON.parse(data) as Record<string, unknown>;
34
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
35
+ return {
36
+ state: "invalid",
37
+ error: "Config file must contain a JSON object",
38
+ };
39
+ }
40
+ return { state: "loaded", config: parsed as Partial<OpenCodeGoConfig> };
41
+ } catch (error) {
42
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
43
+ return { state: "missing" };
44
+ }
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ return {
47
+ state: "invalid",
48
+ error: `Failed to read config file: ${message}`,
49
+ };
50
+ }
51
+ }
52
+
53
+ export function resolveOpenCodeGoConfigFromEnv(
54
+ env: NodeJS.ProcessEnv = process.env,
55
+ ): ResolvedOpenCodeGoConfig | null {
56
+ const workspaceId = env.OPENCODE_GO_WORKSPACE_ID?.trim();
57
+ const authCookie = env.OPENCODE_GO_AUTH_COOKIE?.trim();
58
+
59
+ if (!workspaceId && !authCookie) return null;
60
+
61
+ if (workspaceId && authCookie) {
62
+ return {
63
+ state: "configured",
64
+ config: { workspaceId, authCookie },
65
+ source: "env",
66
+ };
67
+ }
68
+
69
+ return {
70
+ state: "incomplete",
71
+ source: "env",
72
+ missing: workspaceId
73
+ ? "OPENCODE_GO_AUTH_COOKIE"
74
+ : "OPENCODE_GO_WORKSPACE_ID",
75
+ };
76
+ }
77
+
78
+ export async function resolveOpenCodeGoConfig(): Promise<ResolvedOpenCodeGoConfig> {
79
+ const envResult = resolveOpenCodeGoConfigFromEnv();
80
+ if (envResult) return envResult;
81
+
82
+ const candidates = getConfigCandidatePaths();
83
+ for (const path of candidates) {
84
+ const fileResult = await readConfigFile(path);
85
+ if (fileResult.state === "missing") continue;
86
+ if (fileResult.state === "invalid") {
87
+ return { state: "invalid", source: path, error: fileResult.error };
88
+ }
89
+
90
+ const config = fileResult.config;
91
+ const workspaceId =
92
+ typeof config.workspaceId === "string" ? config.workspaceId.trim() : "";
93
+ const authCookie =
94
+ typeof config.authCookie === "string" ? config.authCookie.trim() : "";
95
+
96
+ if (workspaceId && authCookie) {
97
+ return {
98
+ state: "configured",
99
+ config: { workspaceId, authCookie },
100
+ source: path,
101
+ };
102
+ }
103
+
104
+ const missing = !workspaceId ? "workspaceId" : "authCookie";
105
+ return { state: "incomplete", source: path, missing };
106
+ }
107
+
108
+ return { state: "none" };
109
+ }
110
+
111
+ let cachedConfig: ResolvedOpenCodeGoConfig | null = null;
112
+ let cachedAt = 0;
113
+
114
+ const CACHE_MAX_AGE_MS = 30_000;
115
+
116
+ export async function resolveOpenCodeGoConfigCached(params?: {
117
+ maxAgeMs?: number;
118
+ }): Promise<ResolvedOpenCodeGoConfig> {
119
+ const maxAgeMs = Math.max(0, params?.maxAgeMs ?? CACHE_MAX_AGE_MS);
120
+ const now = Date.now();
121
+ if (cachedConfig && now - cachedAt < maxAgeMs) {
122
+ return cachedConfig;
123
+ }
124
+ cachedConfig = await resolveOpenCodeGoConfig();
125
+ cachedAt = now;
126
+ return cachedConfig;
127
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * OpenCode Go client.
3
+ *
4
+ * Fetches usage data from OpenCode Go dashboard using workspace ID and auth cookie.
5
+ * Scrapes SolidJS SSR hydration output for usage windows.
6
+ *
7
+ * Configuration:
8
+ * - Environment: OPENCODE_GO_WORKSPACE_ID + OPENCODE_GO_AUTH_COOKIE
9
+ * - Config file: ~/.config/opencode/opencode-quota/opencode-go.json
10
+ */
11
+
12
+ const DASHBOARD_URL_PREFIX = "https://opencode.ai/workspace/";
13
+ const DASHBOARD_URL_SUFFIX = "/go";
14
+ const USER_AGENT =
15
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0";
16
+ const REQUEST_TIMEOUT_MS = 10_000;
17
+
18
+ const SCRAPED_NUMBER_PATTERN = String.raw`(-?\d+(?:\.\d+)?)`;
19
+
20
+ const RE_ROLLING_PCT_FIRST = new RegExp(
21
+ String.raw`rollingUsage:\$R\[\d+\]=\{[^}]*usagePercent:${SCRAPED_NUMBER_PATTERN}[^}]*resetInSec:${SCRAPED_NUMBER_PATTERN}[^}]*\}`,
22
+ );
23
+ const RE_ROLLING_RESET_FIRST = new RegExp(
24
+ String.raw`rollingUsage:\$R\[\d+\]=\{[^}]*resetInSec:${SCRAPED_NUMBER_PATTERN}[^}]*usagePercent:${SCRAPED_NUMBER_PATTERN}[^}]*\}`,
25
+ );
26
+
27
+ const RE_WEEKLY_PCT_FIRST = new RegExp(
28
+ String.raw`weeklyUsage:\$R\[\d+\]=\{[^}]*usagePercent:${SCRAPED_NUMBER_PATTERN}[^}]*resetInSec:${SCRAPED_NUMBER_PATTERN}[^}]*\}`,
29
+ );
30
+ const RE_WEEKLY_RESET_FIRST = new RegExp(
31
+ String.raw`weeklyUsage:\$R\[\d+\]=\{[^}]*resetInSec:${SCRAPED_NUMBER_PATTERN}[^}]*usagePercent:${SCRAPED_NUMBER_PATTERN}[^}]*\}`,
32
+ );
33
+
34
+ const RE_MONTHLY_PCT_FIRST = new RegExp(
35
+ String.raw`monthlyUsage:\$R\[\d+\]=\{[^}]*usagePercent:${SCRAPED_NUMBER_PATTERN}[^}]*resetInSec:${SCRAPED_NUMBER_PATTERN}[^}]*\}`,
36
+ );
37
+ const RE_MONTHLY_RESET_FIRST = new RegExp(
38
+ String.raw`monthlyUsage:\$R\[\d+\]=\{[^}]*resetInSec:${SCRAPED_NUMBER_PATTERN}[^}]*usagePercent:${SCRAPED_NUMBER_PATTERN}[^}]*\}`,
39
+ );
40
+
41
+ interface ScrapedWindowUsage {
42
+ usagePercent: number;
43
+ resetInSec: number;
44
+ }
45
+
46
+ export interface OpenCodeGoWindow {
47
+ usagePercent: number;
48
+ resetInSec: number;
49
+ percentRemaining: number;
50
+ resetTimeIso: string;
51
+ }
52
+
53
+ export interface OpenCodeGoQuotaResult {
54
+ success: true;
55
+ rolling?: OpenCodeGoWindow;
56
+ weekly?: OpenCodeGoWindow;
57
+ monthly?: OpenCodeGoWindow;
58
+ }
59
+
60
+ export interface OpenCodeGoQuotaError {
61
+ success: false;
62
+ error: string;
63
+ }
64
+
65
+ export type OpenCodeGoResult = OpenCodeGoQuotaResult | OpenCodeGoQuotaError;
66
+
67
+ export interface OpenCodeGoConfig {
68
+ workspaceId: string;
69
+ authCookie: string;
70
+ }
71
+
72
+ function parseWindowUsage(
73
+ html: string,
74
+ rePctFirst: RegExp,
75
+ reResetFirst: RegExp,
76
+ ): ScrapedWindowUsage | null {
77
+ const pctFirstMatch = rePctFirst.exec(html);
78
+ if (pctFirstMatch) {
79
+ const usagePercent = Number(pctFirstMatch[1]);
80
+ const resetInSec = Number(pctFirstMatch[2]);
81
+ if (Number.isFinite(usagePercent) && Number.isFinite(resetInSec)) {
82
+ return { usagePercent, resetInSec };
83
+ }
84
+ }
85
+
86
+ const resetFirstMatch = reResetFirst.exec(html);
87
+ if (resetFirstMatch) {
88
+ const resetInSec = Number(resetFirstMatch[1]);
89
+ const usagePercent = Number(resetFirstMatch[2]);
90
+ if (Number.isFinite(usagePercent) && Number.isFinite(resetInSec)) {
91
+ return { usagePercent, resetInSec };
92
+ }
93
+ }
94
+
95
+ return null;
96
+ }
97
+
98
+ function normalizeWindowUsage(
99
+ window: ScrapedWindowUsage,
100
+ now: number,
101
+ ): OpenCodeGoWindow {
102
+ const usagePercent = Math.max(0, window.usagePercent);
103
+ const resetInSec = Math.max(0, window.resetInSec);
104
+ return {
105
+ usagePercent,
106
+ resetInSec,
107
+ percentRemaining: 100 - usagePercent,
108
+ resetTimeIso: new Date(now + resetInSec * 1000).toISOString(),
109
+ };
110
+ }
111
+
112
+ export async function queryOpenCodeGoQuota(
113
+ config: OpenCodeGoConfig,
114
+ signal?: AbortSignal,
115
+ ): Promise<OpenCodeGoResult> {
116
+ try {
117
+ const url = `${DASHBOARD_URL_PREFIX}${encodeURIComponent(config.workspaceId)}${DASHBOARD_URL_SUFFIX}`;
118
+ const signals: AbortSignal[] = [AbortSignal.timeout(REQUEST_TIMEOUT_MS)];
119
+ if (signal) signals.push(signal);
120
+ const combined = AbortSignal.any(signals);
121
+
122
+ const response = await fetch(url, {
123
+ method: "GET",
124
+ headers: {
125
+ "User-Agent": USER_AGENT,
126
+ Accept: "text/html",
127
+ Cookie: `auth=${config.authCookie}`,
128
+ },
129
+ signal: combined,
130
+ });
131
+
132
+ if (!response.ok) {
133
+ const text = await response.text().catch(() => "");
134
+ return {
135
+ success: false,
136
+ error: `OpenCode Go dashboard error ${response.status}: ${text.slice(0, 120)}`,
137
+ };
138
+ }
139
+
140
+ const html = await response.text();
141
+ const rolling = parseWindowUsage(
142
+ html,
143
+ RE_ROLLING_PCT_FIRST,
144
+ RE_ROLLING_RESET_FIRST,
145
+ );
146
+ const weekly = parseWindowUsage(
147
+ html,
148
+ RE_WEEKLY_PCT_FIRST,
149
+ RE_WEEKLY_RESET_FIRST,
150
+ );
151
+ const monthly = parseWindowUsage(
152
+ html,
153
+ RE_MONTHLY_PCT_FIRST,
154
+ RE_MONTHLY_RESET_FIRST,
155
+ );
156
+
157
+ if (!rolling && !weekly && !monthly) {
158
+ return {
159
+ success: false,
160
+ error: "Could not parse OpenCode Go dashboard usage windows",
161
+ };
162
+ }
163
+
164
+ const now = Date.now();
165
+ return {
166
+ success: true,
167
+ ...(rolling ? { rolling: normalizeWindowUsage(rolling, now) } : {}),
168
+ ...(weekly ? { weekly: normalizeWindowUsage(weekly, now) } : {}),
169
+ ...(monthly ? { monthly: normalizeWindowUsage(monthly, now) } : {}),
170
+ };
171
+ } catch (err) {
172
+ if (err instanceof Error && err.name === "TimeoutError") {
173
+ return { success: false, error: "Request timed out" };
174
+ }
175
+ if (err instanceof Error && err.name === "AbortError") {
176
+ return { success: false, error: "Request cancelled" };
177
+ }
178
+ return {
179
+ success: false,
180
+ error: err instanceof Error ? err.message : "Unknown error",
181
+ };
182
+ }
183
+ }