@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.
- package/README.md +9 -2
- 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 +29 -0
- package/src/extensions/command-quotas/components/quotas-display.ts +8 -2
- 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 +36 -14
- 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 +104 -40
- 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 +31 -0
- package/src/providers/fetch.ts +167 -22
- 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.ts +14 -2
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
|
}
|
|
@@ -194,13 +254,22 @@ async function fetchGitHubCopilotQuotasWithGitHubToken(
|
|
|
194
254
|
githubToken: string | undefined,
|
|
195
255
|
signal?: AbortSignal,
|
|
196
256
|
): Promise<QuotasResult> {
|
|
197
|
-
if (!githubToken)
|
|
257
|
+
if (!githubToken)
|
|
258
|
+
return failure("No GitHub Copilot OAuth token found", "config");
|
|
198
259
|
|
|
199
|
-
const bearerUsage = await tryGitHubUserEndpoint(
|
|
200
|
-
|
|
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(
|
|
203
|
-
|
|
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)
|
|
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(
|
|
223
|
-
|
|
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(
|
|
228
|
-
|
|
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)
|
|
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(
|
|
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)
|
|
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
|
+
}
|