@latentminds/pi-quotas 0.2.4 → 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.
- package/README.md +34 -23
- package/package.json +25 -5
- package/src/config.ts +43 -13
- package/src/extensions/command-quotas/command.ts +23 -1
- package/src/extensions/command-quotas/components/quotas-display.test.ts +154 -0
- package/src/extensions/command-quotas/components/quotas-display.ts +13 -10
- package/src/extensions/command-quotas/provider-commands.test.ts +9 -0
- package/src/extensions/command-quotas/provider-commands.ts +12 -0
- package/src/extensions/command-tokens/command.ts +102 -0
- package/src/extensions/command-tokens/index.ts +1 -0
- package/src/extensions/command-tokens/tokens-display.ts +357 -0
- package/src/extensions/quota-warnings/index.ts +47 -19
- package/src/extensions/token-status/index.ts +241 -0
- package/src/extensions/token-status/provider-detection.test.ts +30 -0
- package/src/extensions/usage-status/index.test.ts +182 -0
- package/src/extensions/usage-status/index.ts +114 -44
- package/src/lib/quotas.ts +12 -1
- package/src/lib/session-tokens.test.ts +137 -0
- package/src/lib/session-tokens.ts +399 -0
- package/src/providers/fetch.test.ts +71 -0
- package/src/providers/fetch.ts +200 -17
- package/src/providers/opencode-go-config.ts +127 -0
- package/src/providers/opencode-go.ts +183 -0
- package/src/providers/parse.test.ts +185 -7
- package/src/providers/providers.ts +229 -21
- package/src/types/quotas.ts +12 -3
- package/src/utils/quotas-severity.test.ts +22 -2
- package/src/utils/quotas-severity.ts +28 -6
package/src/providers/fetch.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
}
|
|
@@ -179,11 +239,47 @@ async function tryGitHubUserEndpoint(
|
|
|
179
239
|
);
|
|
180
240
|
}
|
|
181
241
|
|
|
242
|
+
function githubOAuthToken(authStorage: AuthStorage): string | undefined {
|
|
243
|
+
// Pi's GitHub Copilot OAuth credential stores the GitHub OAuth token in
|
|
244
|
+
// `refresh`; `access` is a Copilot proxy token (tid=...;proxy-ep=...) that
|
|
245
|
+
// is valid for model calls but rejected by api.github.com quota endpoints.
|
|
246
|
+
const credential = authStorage.get("github-copilot") as any;
|
|
247
|
+
if (credential?.type !== "oauth") return undefined;
|
|
248
|
+
return typeof credential.refresh === "string" && credential.refresh.length > 0
|
|
249
|
+
? credential.refresh
|
|
250
|
+
: undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function fetchGitHubCopilotQuotasWithGitHubToken(
|
|
254
|
+
githubToken: string | undefined,
|
|
255
|
+
signal?: AbortSignal,
|
|
256
|
+
): Promise<QuotasResult> {
|
|
257
|
+
if (!githubToken)
|
|
258
|
+
return failure("No GitHub Copilot OAuth token found", "config");
|
|
259
|
+
|
|
260
|
+
const bearerUsage = await tryGitHubUserEndpoint(
|
|
261
|
+
`Bearer ${githubToken}`,
|
|
262
|
+
signal,
|
|
263
|
+
);
|
|
264
|
+
if (bearerUsage.ok)
|
|
265
|
+
return success("github-copilot", parseGitHubCopilotUsage(bearerUsage.data));
|
|
266
|
+
|
|
267
|
+
const tokenUsage = await tryGitHubUserEndpoint(
|
|
268
|
+
`token ${githubToken}`,
|
|
269
|
+
signal,
|
|
270
|
+
);
|
|
271
|
+
if (tokenUsage.ok)
|
|
272
|
+
return success("github-copilot", parseGitHubCopilotUsage(tokenUsage.data));
|
|
273
|
+
|
|
274
|
+
return failure(bearerUsage.message, bearerUsage.kind);
|
|
275
|
+
}
|
|
276
|
+
|
|
182
277
|
export async function fetchGitHubCopilotQuotasWithToken(
|
|
183
278
|
accessToken: string | undefined,
|
|
184
279
|
signal?: AbortSignal,
|
|
185
280
|
): Promise<QuotasResult> {
|
|
186
|
-
if (!accessToken)
|
|
281
|
+
if (!accessToken)
|
|
282
|
+
return failure("No GitHub Copilot OAuth token found", "config");
|
|
187
283
|
|
|
188
284
|
// 1) Try Copilot token exchange with stored Pi token
|
|
189
285
|
const exchange = await fetchJson(
|
|
@@ -193,19 +289,28 @@ export async function fetchGitHubCopilotQuotasWithToken(
|
|
|
193
289
|
);
|
|
194
290
|
|
|
195
291
|
if (exchange.ok && exchange.data?.token) {
|
|
196
|
-
const usage = await tryGitHubUserEndpoint(
|
|
197
|
-
|
|
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));
|
|
198
298
|
}
|
|
199
299
|
|
|
200
300
|
// 2) Try stored token directly
|
|
201
|
-
const directUsage = await tryGitHubUserEndpoint(
|
|
202
|
-
|
|
301
|
+
const directUsage = await tryGitHubUserEndpoint(
|
|
302
|
+
`token ${accessToken}`,
|
|
303
|
+
signal,
|
|
304
|
+
);
|
|
305
|
+
if (directUsage.ok)
|
|
306
|
+
return success("github-copilot", parseGitHubCopilotUsage(directUsage.data));
|
|
203
307
|
|
|
204
308
|
// 3) Fallback: gh CLI token
|
|
205
309
|
const cliToken = ghCliToken();
|
|
206
310
|
if (cliToken && cliToken !== accessToken) {
|
|
207
311
|
const cliUsage = await tryGitHubUserEndpoint(`token ${cliToken}`, signal);
|
|
208
|
-
if (cliUsage.ok)
|
|
312
|
+
if (cliUsage.ok)
|
|
313
|
+
return success("github-copilot", parseGitHubCopilotUsage(cliUsage.data));
|
|
209
314
|
return failure(cliUsage.message, cliUsage.kind);
|
|
210
315
|
}
|
|
211
316
|
|
|
@@ -216,7 +321,10 @@ export async function fetchAnthropicQuotas(
|
|
|
216
321
|
authStorage: AuthStorage,
|
|
217
322
|
signal?: AbortSignal,
|
|
218
323
|
): Promise<QuotasResult> {
|
|
219
|
-
return fetchAnthropicQuotasWithToken(
|
|
324
|
+
return fetchAnthropicQuotasWithToken(
|
|
325
|
+
await providerAccessToken(authStorage, "anthropic"),
|
|
326
|
+
signal,
|
|
327
|
+
);
|
|
220
328
|
}
|
|
221
329
|
|
|
222
330
|
export async function fetchCodexQuotas(
|
|
@@ -234,6 +342,18 @@ export async function fetchGitHubCopilotQuotas(
|
|
|
234
342
|
authStorage: AuthStorage,
|
|
235
343
|
signal?: AbortSignal,
|
|
236
344
|
): Promise<QuotasResult> {
|
|
345
|
+
const oauthResult = await fetchGitHubCopilotQuotasWithGitHubToken(
|
|
346
|
+
githubOAuthToken(authStorage),
|
|
347
|
+
signal,
|
|
348
|
+
);
|
|
349
|
+
if (
|
|
350
|
+
oauthResult.success ||
|
|
351
|
+
oauthResult.error.kind === "cancelled" ||
|
|
352
|
+
oauthResult.error.kind === "timeout"
|
|
353
|
+
) {
|
|
354
|
+
return oauthResult;
|
|
355
|
+
}
|
|
356
|
+
|
|
237
357
|
return fetchGitHubCopilotQuotasWithToken(
|
|
238
358
|
await providerAccessToken(authStorage, "github-copilot"),
|
|
239
359
|
signal,
|
|
@@ -274,7 +394,11 @@ export async function fetchSyntheticQuotas(
|
|
|
274
394
|
signal?: AbortSignal,
|
|
275
395
|
): Promise<QuotasResult> {
|
|
276
396
|
const apiKey = process.env.SYNTHETIC_API_KEY;
|
|
277
|
-
if (!apiKey)
|
|
397
|
+
if (!apiKey)
|
|
398
|
+
return failure(
|
|
399
|
+
"No Synthetic API key found (set SYNTHETIC_API_KEY)",
|
|
400
|
+
"config",
|
|
401
|
+
);
|
|
278
402
|
|
|
279
403
|
const result = await fetchJson(
|
|
280
404
|
"https://api.synthetic.new/v2/quotas",
|
|
@@ -287,10 +411,69 @@ export async function fetchSyntheticQuotas(
|
|
|
287
411
|
return success("synthetic", parseSyntheticUsage(result.data));
|
|
288
412
|
}
|
|
289
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
|
+
|
|
290
471
|
export const PROVIDER_FETCHERS = {
|
|
291
472
|
anthropic: fetchAnthropicQuotas,
|
|
292
473
|
"openai-codex": fetchCodexQuotas,
|
|
293
474
|
"github-copilot": fetchGitHubCopilotQuotas,
|
|
294
475
|
openrouter: fetchOpenRouterQuotas,
|
|
295
476
|
synthetic: fetchSyntheticQuotas,
|
|
477
|
+
zai: fetchZaiQuotas,
|
|
478
|
+
"opencode-go": fetchOpenCodeGoQuotas,
|
|
296
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
|
+
}
|