opencode-auth-load-balancer 0.9.0 → 0.9.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 +8 -0
- package/dist/index.js +136 -23
- package/dist/providers/anthropic/usage.d.ts +2 -2
- package/dist/providers/kimi/usage.d.ts +1 -1
- package/dist/providers/openai/usage.d.ts +2 -1
- package/dist/providers/usage-http.d.ts +5 -8
- package/dist/usage-merge.d.ts +5 -1
- package/dist/usage-poll.d.ts +11 -0
- package/dist/usage-refresh.d.ts +1 -12
- package/dist/v1.js +136 -23
- package/dist/v2-entry.js +136 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -287,6 +287,14 @@ Pending turns
|
|
|
287
287
|
|
|
288
288
|
`▶` marks the in-use account; `#` is the rank the scheduler would pick next.
|
|
289
289
|
|
|
290
|
+
While OpenCode is running, stale usage is checked every five minutes even without inference traffic or a status-tool call. Periodic checks share a machine-wide lock and a next-attempt timestamp in `auth-load-balancer.json.usage-poll.json`, so multiple windows do not multiply periodic requests, including failed attempts. The file contains scheduling metadata only, never credentials.
|
|
291
|
+
|
|
292
|
+
A fresh usage measurement during re-login or a periodic check clears an obsolete, long quota cooldown when both usage windows have headroom. A successful re-login alone is not enough: exhausted or malformed usage, authentication/transient backoffs, and a newer rejection received during a usage check retain their cooldowns. Provider-side resets are picked up on the next successful check; a failed endpoint request never assumes recovery.
|
|
293
|
+
|
|
294
|
+
If a usage endpoint rejects an OAuth access token with HTTP 401, the poll uses the same refresh lock and rejected-token recovery as inference, then retries the usage lookup once, even when the advertised access expiry is still in the future. A concurrently refreshed credential is adopted instead of spending its predecessor again. Static API keys are never sent to OAuth refresh, and 403/429 failures do not trigger authentication retries.
|
|
295
|
+
|
|
296
|
+
A 403 caused by a missing `user:profile` scope requires a fresh OAuth login with browser approval. Restarting OpenCode or refreshing the same grant cannot restore that missing permission; an authoritative usage result is still required before clearing the old quota cooldown.
|
|
297
|
+
|
|
290
298
|
### Persistent bottom status bar (TUI)
|
|
291
299
|
|
|
292
300
|
A SolidJS TUI plugin renders a persistent bottom status bar (opencode's always-visible `app_bottom` slot) showing both the in-use account(s) per provider (polled from the pool, e.g. `Claude anthropic-1 58% · 5h 12%`) **and** the current session usage — tokens, % of the model context window, and `$` cost — computed the same way opencode's own footer does. It is **four files**:
|
package/dist/index.js
CHANGED
|
@@ -13385,6 +13385,14 @@ var init_score_core = __esm(() => {
|
|
|
13385
13385
|
});
|
|
13386
13386
|
|
|
13387
13387
|
// src/usage-merge.ts
|
|
13388
|
+
function reconcileQuotaCooldown(account, cfg, now) {
|
|
13389
|
+
if (account.cooldownKind === "auth" || account.cooldownKind === "transient")
|
|
13390
|
+
return;
|
|
13391
|
+
if (account.cooldownUntil > now + MAX_TRANSIENT_COOLDOWN_MS && !isExhausted(account, cfg, now)) {
|
|
13392
|
+
account.cooldownUntil = 0;
|
|
13393
|
+
delete account.cooldownKind;
|
|
13394
|
+
}
|
|
13395
|
+
}
|
|
13388
13396
|
function rollWeeklyAnchorForward(anchor, now) {
|
|
13389
13397
|
if (!Number.isFinite(anchor) || anchor <= 0)
|
|
13390
13398
|
return 0;
|
|
@@ -13401,8 +13409,10 @@ function preserveWeeklyAnchor(incoming, stored, now) {
|
|
|
13401
13409
|
return incoming;
|
|
13402
13410
|
return { utilization: incoming.utilization, resetAt: anchor };
|
|
13403
13411
|
}
|
|
13412
|
+
var MAX_TRANSIENT_COOLDOWN_MS;
|
|
13404
13413
|
var init_usage_merge = __esm(() => {
|
|
13405
13414
|
init_score_core();
|
|
13415
|
+
MAX_TRANSIENT_COOLDOWN_MS = 5 * 60 * 1000;
|
|
13406
13416
|
});
|
|
13407
13417
|
|
|
13408
13418
|
// src/accounts.ts
|
|
@@ -13454,6 +13464,8 @@ function mergeLoginUsage(row, usage) {
|
|
|
13454
13464
|
weekly: usage.weekly ? preserveWeeklyAnchor(usage.weekly, row.usage.weekly, usage.capturedAt) : row.usage.weekly,
|
|
13455
13465
|
capturedAt: usage.weekly ? usage.capturedAt : row.usage.capturedAt
|
|
13456
13466
|
};
|
|
13467
|
+
if (usage.weekly)
|
|
13468
|
+
reconcileQuotaCooldown(row, loadScoreConfig(), usage.capturedAt);
|
|
13457
13469
|
}
|
|
13458
13470
|
function applyTokens(row, tokens) {
|
|
13459
13471
|
if (tokens.refresh) {
|
|
@@ -13547,6 +13559,7 @@ async function bootstrapFromOpencodeAuth(providerID, getAuth, tokensFromApiKey)
|
|
|
13547
13559
|
var init_accounts = __esm(() => {
|
|
13548
13560
|
init_pairing();
|
|
13549
13561
|
init_store();
|
|
13562
|
+
init_score_core();
|
|
13550
13563
|
init_types();
|
|
13551
13564
|
init_usage_merge();
|
|
13552
13565
|
});
|
|
@@ -14488,24 +14501,46 @@ var init_usage_headers = __esm(() => {
|
|
|
14488
14501
|
});
|
|
14489
14502
|
|
|
14490
14503
|
// src/providers/usage-http.ts
|
|
14491
|
-
async function
|
|
14492
|
-
let response;
|
|
14504
|
+
async function request(url, headers, timeoutMs) {
|
|
14493
14505
|
try {
|
|
14494
|
-
|
|
14506
|
+
return await fetch(url, {
|
|
14495
14507
|
headers,
|
|
14496
14508
|
signal: AbortSignal.timeout(timeoutMs)
|
|
14497
14509
|
});
|
|
14498
14510
|
} catch {
|
|
14499
14511
|
return null;
|
|
14500
14512
|
}
|
|
14513
|
+
}
|
|
14514
|
+
async function readJson(response) {
|
|
14515
|
+
if (!response)
|
|
14516
|
+
return null;
|
|
14501
14517
|
if (!response.ok) {
|
|
14502
14518
|
await response.body?.cancel().catch(ignore);
|
|
14503
14519
|
return null;
|
|
14504
14520
|
}
|
|
14505
14521
|
return await response.json().catch(() => null);
|
|
14506
14522
|
}
|
|
14523
|
+
async function fetchJson(url, headers, timeoutMs) {
|
|
14524
|
+
return readJson(await request(url, headers, timeoutMs));
|
|
14525
|
+
}
|
|
14526
|
+
async function fetchUsageJson(url, headers, timeoutMs) {
|
|
14527
|
+
const response = await request(url, headers, timeoutMs);
|
|
14528
|
+
if (response?.status === 401) {
|
|
14529
|
+
await response.body?.cancel().catch(ignore);
|
|
14530
|
+
throw new UsageAuthenticationError;
|
|
14531
|
+
}
|
|
14532
|
+
return readJson(response);
|
|
14533
|
+
}
|
|
14534
|
+
var UsageAuthenticationError;
|
|
14507
14535
|
var init_usage_http = __esm(() => {
|
|
14508
14536
|
init_util2();
|
|
14537
|
+
UsageAuthenticationError = class UsageAuthenticationError extends Error {
|
|
14538
|
+
status = 401;
|
|
14539
|
+
constructor() {
|
|
14540
|
+
super("Usage endpoint rejected the access token (401)");
|
|
14541
|
+
this.name = "UsageAuthenticationError";
|
|
14542
|
+
}
|
|
14543
|
+
};
|
|
14509
14544
|
});
|
|
14510
14545
|
|
|
14511
14546
|
// src/providers/anthropic/version.ts
|
|
@@ -14945,7 +14980,7 @@ async function probeUsage(access, now) {
|
|
|
14945
14980
|
async function fetchUsage(account, now) {
|
|
14946
14981
|
if (!account.refresh)
|
|
14947
14982
|
return probeUsage(account.access, now);
|
|
14948
|
-
const json = await
|
|
14983
|
+
const json = await fetchUsageJson(USAGE_URL, {
|
|
14949
14984
|
authorization: `Bearer ${account.access}`,
|
|
14950
14985
|
"anthropic-beta": "oauth-2025-04-20",
|
|
14951
14986
|
"user-agent": usageUserAgent(),
|
|
@@ -15277,7 +15312,7 @@ function parseUsages(json, now) {
|
|
|
15277
15312
|
return hourly || weekly ? { hourly, weekly, capturedAt: now } : null;
|
|
15278
15313
|
}
|
|
15279
15314
|
function getUsages(baseUrl, key) {
|
|
15280
|
-
return
|
|
15315
|
+
return fetchUsageJson(`${baseUrl}/usages`, { authorization: `Bearer ${key}`, accept: "application/json" }, USAGE_HTTP_TIMEOUT_MS);
|
|
15281
15316
|
}
|
|
15282
15317
|
function usageFetcher(baseUrl) {
|
|
15283
15318
|
return async (account, now) => parseUsages(await getUsages(baseUrl, account.access), now);
|
|
@@ -15551,7 +15586,7 @@ async function fetchUsage2(account, now) {
|
|
|
15551
15586
|
const accountId = resolveAccountId(account);
|
|
15552
15587
|
if (accountId)
|
|
15553
15588
|
headers["chatgpt-account-id"] = accountId;
|
|
15554
|
-
const json = await
|
|
15589
|
+
const json = await fetchUsageJson(USAGE_URL2, headers, USAGE_HTTP_TIMEOUT_MS);
|
|
15555
15590
|
const rateLimit = json?.rate_limit;
|
|
15556
15591
|
if (!isPlainObject2(rateLimit))
|
|
15557
15592
|
return null;
|
|
@@ -15640,7 +15675,7 @@ async function refreshUsage(adapters, now, poolSnapshot) {
|
|
|
15640
15675
|
aliveIds?.add(account.id);
|
|
15641
15676
|
if (account.disabledReason && account.disabledReason !== MANUAL_DISABLED_REASON)
|
|
15642
15677
|
continue;
|
|
15643
|
-
if (account.usage.capturedAt !== 0 && now - account.usage.capturedAt
|
|
15678
|
+
if (account.usage.capturedAt !== 0 && now - account.usage.capturedAt < USAGE_REFRESH_TTL_MS)
|
|
15644
15679
|
continue;
|
|
15645
15680
|
const adapter = adapterFor(adapters, account.providerID);
|
|
15646
15681
|
if (!adapter)
|
|
@@ -15662,7 +15697,16 @@ async function refreshUsage(adapters, now, poolSnapshot) {
|
|
|
15662
15697
|
await Promise.all(stale.map(async ({ adapter, account }) => {
|
|
15663
15698
|
try {
|
|
15664
15699
|
await ensureAccessToken(adapter, account, now);
|
|
15665
|
-
const
|
|
15700
|
+
const rejectedAccess = account.access;
|
|
15701
|
+
let snapshot;
|
|
15702
|
+
try {
|
|
15703
|
+
snapshot = await adapter.fetchUsage(account, now);
|
|
15704
|
+
} catch (error) {
|
|
15705
|
+
if (!(error instanceof UsageAuthenticationError) || !account.refresh)
|
|
15706
|
+
throw error;
|
|
15707
|
+
await ensureAccessToken(adapter, account, now, rejectedAccess);
|
|
15708
|
+
snapshot = await adapter.fetchUsage(account, now);
|
|
15709
|
+
}
|
|
15666
15710
|
if (snapshot) {
|
|
15667
15711
|
await mutatePool((p) => {
|
|
15668
15712
|
const stored = findAccount(p, account.id);
|
|
@@ -15672,10 +15716,8 @@ async function refreshUsage(adapters, now, poolSnapshot) {
|
|
|
15672
15716
|
weekly: snapshot.weekly === null ? stored.usage.weekly : preserveWeeklyAnchor(snapshot.weekly, stored.usage.weekly, now),
|
|
15673
15717
|
capturedAt: snapshot.weekly === null ? stored.usage.capturedAt : now
|
|
15674
15718
|
};
|
|
15675
|
-
if (snapshot.weekly !== null && stored.cooldownUntil
|
|
15676
|
-
stored
|
|
15677
|
-
delete stored.cooldownKind;
|
|
15678
|
-
}
|
|
15719
|
+
if (snapshot.weekly !== null && stored.cooldownUntil === account.cooldownUntil && stored.cooldownKind === account.cooldownKind)
|
|
15720
|
+
reconcileQuotaCooldown(stored, scoreCfg, now);
|
|
15679
15721
|
}
|
|
15680
15722
|
});
|
|
15681
15723
|
}
|
|
@@ -15688,16 +15730,17 @@ function refreshUsageInBackground(adapter, now, poolSnapshot) {
|
|
|
15688
15730
|
function refreshAllUsageInBackground(now, poolSnapshot) {
|
|
15689
15731
|
return refreshUsage(ADAPTERS, now, poolSnapshot);
|
|
15690
15732
|
}
|
|
15691
|
-
var USAGE_REFRESH_TTL_MS,
|
|
15733
|
+
var USAGE_REFRESH_TTL_MS, lastPoll;
|
|
15692
15734
|
var init_usage_refresh = __esm(() => {
|
|
15693
15735
|
init_store();
|
|
15694
15736
|
init_registry();
|
|
15737
|
+
init_usage_http();
|
|
15695
15738
|
init_refresh();
|
|
15696
15739
|
init_score_core();
|
|
15697
15740
|
init_types();
|
|
15698
15741
|
init_usage_merge();
|
|
15742
|
+
init_usage_merge();
|
|
15699
15743
|
USAGE_REFRESH_TTL_MS = 5 * 60 * 1000;
|
|
15700
|
-
MAX_TRANSIENT_COOLDOWN_MS = 5 * 60 * 1000;
|
|
15701
15744
|
lastPoll = new Map;
|
|
15702
15745
|
});
|
|
15703
15746
|
|
|
@@ -17329,7 +17372,7 @@ function backoffFrom(res, now) {
|
|
|
17329
17372
|
function isRateLimited(res) {
|
|
17330
17373
|
return res.status === 429 || res.status === 403 && (res.headers.get("x-ratelimit-remaining") === "0" || res.headers.has("retry-after"));
|
|
17331
17374
|
}
|
|
17332
|
-
async function
|
|
17375
|
+
async function request2(url, init, now) {
|
|
17333
17376
|
let res;
|
|
17334
17377
|
try {
|
|
17335
17378
|
res = await globalThis.fetch(url, {
|
|
@@ -17381,7 +17424,7 @@ async function boundedText(res) {
|
|
|
17381
17424
|
}
|
|
17382
17425
|
return Buffer.concat(chunks).toString("utf8");
|
|
17383
17426
|
}
|
|
17384
|
-
async function
|
|
17427
|
+
async function readJson2(res) {
|
|
17385
17428
|
if (!res.ok)
|
|
17386
17429
|
throw new SyncError("http");
|
|
17387
17430
|
const text = await boundedText(res);
|
|
@@ -17396,7 +17439,7 @@ async function readJson(res) {
|
|
|
17396
17439
|
return json;
|
|
17397
17440
|
}
|
|
17398
17441
|
async function createGist(token, content, now = Date.now()) {
|
|
17399
|
-
const res = await
|
|
17442
|
+
const res = await request2(API, {
|
|
17400
17443
|
method: "POST",
|
|
17401
17444
|
headers: { ...headers(token), "content-type": "application/json" },
|
|
17402
17445
|
body: JSON.stringify({
|
|
@@ -17405,7 +17448,7 @@ async function createGist(token, content, now = Date.now()) {
|
|
|
17405
17448
|
files: { [GIST_FILE]: { content } }
|
|
17406
17449
|
})
|
|
17407
17450
|
}, now);
|
|
17408
|
-
const json = await
|
|
17451
|
+
const json = await readJson2(res);
|
|
17409
17452
|
const owner = isPlainObject2(json.owner) ? json.owner.login : undefined;
|
|
17410
17453
|
if (typeof json.id !== "string" || !GIST_ID_RE.test(json.id))
|
|
17411
17454
|
throw new SyncError("http");
|
|
@@ -17417,7 +17460,7 @@ async function createGist(token, content, now = Date.now()) {
|
|
|
17417
17460
|
async function updateGist(token, id, content, now = Date.now()) {
|
|
17418
17461
|
let res;
|
|
17419
17462
|
try {
|
|
17420
|
-
res = await
|
|
17463
|
+
res = await request2(`${API}/${id}`, {
|
|
17421
17464
|
method: "PATCH",
|
|
17422
17465
|
headers: { ...headers(token), "content-type": "application/json" },
|
|
17423
17466
|
body: JSON.stringify({ files: { [GIST_FILE]: { content } } })
|
|
@@ -17429,10 +17472,10 @@ async function updateGist(token, id, content, now = Date.now()) {
|
|
|
17429
17472
|
}
|
|
17430
17473
|
if (res.status === 401 || res.status === 403)
|
|
17431
17474
|
throw new SyncError("write-denied");
|
|
17432
|
-
await
|
|
17475
|
+
await readJson2(res);
|
|
17433
17476
|
}
|
|
17434
17477
|
async function readGist(id, etag, now = Date.now(), token) {
|
|
17435
|
-
const ask = (auth) =>
|
|
17478
|
+
const ask = (auth) => request2(`${API}/${id}`, {
|
|
17436
17479
|
headers: {
|
|
17437
17480
|
...headers(auth),
|
|
17438
17481
|
...etag ? { "if-none-match": etag } : {}
|
|
@@ -17443,7 +17486,7 @@ async function readGist(id, etag, now = Date.now(), token) {
|
|
|
17443
17486
|
res = await ask();
|
|
17444
17487
|
if (res.status === 304)
|
|
17445
17488
|
return { changed: false };
|
|
17446
|
-
const json = await
|
|
17489
|
+
const json = await readJson2(res);
|
|
17447
17490
|
const file = isPlainObject2(json.files) ? json.files[GIST_FILE] : undefined;
|
|
17448
17491
|
if (!isPlainObject2(file) || typeof file.content !== "string")
|
|
17449
17492
|
throw new SyncError("bad-blob");
|
|
@@ -18585,6 +18628,71 @@ var init_start = __esm(() => {
|
|
|
18585
18628
|
init_state();
|
|
18586
18629
|
});
|
|
18587
18630
|
|
|
18631
|
+
// src/usage-poll.ts
|
|
18632
|
+
import { readFile as readFile8 } from "node:fs/promises";
|
|
18633
|
+
async function nextPollAt(path) {
|
|
18634
|
+
try {
|
|
18635
|
+
const value = JSON.parse(await readFile8(path, "utf8"));
|
|
18636
|
+
return isPlainObject2(value) && isFiniteNumber(value.nextPollAt) ? value.nextPollAt : 0;
|
|
18637
|
+
} catch (error) {
|
|
18638
|
+
if (error instanceof SyntaxError || error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
18639
|
+
return 0;
|
|
18640
|
+
throw error;
|
|
18641
|
+
}
|
|
18642
|
+
}
|
|
18643
|
+
function startUsagePoll(deps = {}) {
|
|
18644
|
+
const now = deps.now ?? Date.now;
|
|
18645
|
+
const refresh = deps.refresh ?? refreshAllUsageInBackground;
|
|
18646
|
+
const timers = deps.timers ?? realTimers;
|
|
18647
|
+
let disposed = false;
|
|
18648
|
+
let inflight;
|
|
18649
|
+
async function poll() {
|
|
18650
|
+
const path = `${poolFilePath()}.usage-poll.json`;
|
|
18651
|
+
try {
|
|
18652
|
+
await withLock(`${path}.lock`, {
|
|
18653
|
+
staleMs: 120000,
|
|
18654
|
+
timeoutMs: 0,
|
|
18655
|
+
retryMs: 25,
|
|
18656
|
+
heartbeatMs: 5000
|
|
18657
|
+
}, async () => {
|
|
18658
|
+
const at = now();
|
|
18659
|
+
if (at < await nextPollAt(path))
|
|
18660
|
+
return;
|
|
18661
|
+
await writeJsonAtomic(path, JSON.stringify({ nextPollAt: at + USAGE_REFRESH_TTL_MS }));
|
|
18662
|
+
await refresh(at);
|
|
18663
|
+
});
|
|
18664
|
+
} catch (error) {
|
|
18665
|
+
if (!(error instanceof LockTimeoutError))
|
|
18666
|
+
throw error;
|
|
18667
|
+
}
|
|
18668
|
+
}
|
|
18669
|
+
function tick() {
|
|
18670
|
+
if (disposed)
|
|
18671
|
+
return Promise.resolve();
|
|
18672
|
+
inflight ??= poll().finally(() => {
|
|
18673
|
+
inflight = undefined;
|
|
18674
|
+
});
|
|
18675
|
+
return inflight;
|
|
18676
|
+
}
|
|
18677
|
+
const timer = timers.set(() => void tick().catch(ignore), USAGE_REFRESH_TTL_MS);
|
|
18678
|
+
return {
|
|
18679
|
+
tick,
|
|
18680
|
+
async dispose() {
|
|
18681
|
+
disposed = true;
|
|
18682
|
+
timers.clear(timer);
|
|
18683
|
+
await inflight;
|
|
18684
|
+
}
|
|
18685
|
+
};
|
|
18686
|
+
}
|
|
18687
|
+
var init_usage_poll = __esm(() => {
|
|
18688
|
+
init_lock();
|
|
18689
|
+
init_paths();
|
|
18690
|
+
init_store();
|
|
18691
|
+
init_loop();
|
|
18692
|
+
init_usage_refresh();
|
|
18693
|
+
init_util2();
|
|
18694
|
+
});
|
|
18695
|
+
|
|
18588
18696
|
// src/legacy.ts
|
|
18589
18697
|
function zeroOutCost(provider) {
|
|
18590
18698
|
for (const model of Object.values(provider.models)) {
|
|
@@ -18739,8 +18847,12 @@ function createProviderPlugin(adapter) {
|
|
|
18739
18847
|
}
|
|
18740
18848
|
var PROVIDER_LABELS, AnthropicLoadBalancerPlugin, OpenAILoadBalancerPlugin, KimiCodeLoadBalancerPlugin, KimiCodeGlobalLoadBalancerPlugin, lbResult = (output) => ({ title: "Auth Load Balancer", output }), AuthLoadBalancerStatusPlugin = async (input) => {
|
|
18741
18849
|
const sync = startSync();
|
|
18850
|
+
const usage = startUsagePoll();
|
|
18742
18851
|
return {
|
|
18743
|
-
dispose: async () =>
|
|
18852
|
+
dispose: async () => {
|
|
18853
|
+
await usage.dispose();
|
|
18854
|
+
await sync?.dispose();
|
|
18855
|
+
},
|
|
18744
18856
|
tool: {
|
|
18745
18857
|
auth_lb_status: tool({
|
|
18746
18858
|
description: "Show the auth load-balancer pool: the in-use account per provider, each account's weekly and 5h usage, cooldowns, and the ranked next-candidate accounts. Refreshes stale usage from the provider usage endpoints (throttled) before rendering.",
|
|
@@ -18829,6 +18941,7 @@ var init_legacy = __esm(() => {
|
|
|
18829
18941
|
init_start();
|
|
18830
18942
|
init_token_mint();
|
|
18831
18943
|
init_types();
|
|
18944
|
+
init_usage_poll();
|
|
18832
18945
|
init_usage_refresh();
|
|
18833
18946
|
init_util2();
|
|
18834
18947
|
PROVIDER_LABELS = {
|
|
@@ -17,8 +17,8 @@ export declare function sendUsageProbe(access: string): Promise<Response>;
|
|
|
17
17
|
export declare function probeSnapshot(headers: Headers, now: number): UsageSnapshot | null;
|
|
18
18
|
/**
|
|
19
19
|
* Poll the dedicated usage endpoint for authoritative 5h + 7d utilization without
|
|
20
|
-
* consuming inference quota.
|
|
21
|
-
*
|
|
20
|
+
* consuming inference quota. A 401 is surfaced for bounded OAuth recovery;
|
|
21
|
+
* other failures, including an invalid 200 body, keep the last-known snapshot.
|
|
22
22
|
*
|
|
23
23
|
* A row without a refresh token is a `claude setup-token` bearer, scoped to
|
|
24
24
|
* `user:inference` alone; the endpoint demands `user:profile` and answers it
|
|
@@ -6,7 +6,7 @@ import type { PoolAccount, UsageSnapshot } from '../../types';
|
|
|
6
6
|
* broken body keeps the last-known snapshot instead of reading as 0% used.
|
|
7
7
|
*/
|
|
8
8
|
export declare function parseUsages(json: unknown, now: number): UsageSnapshot | null;
|
|
9
|
-
/** `GET {baseUrl}/usages
|
|
9
|
+
/** `GET {baseUrl}/usages`: surface 401 for OAuth recovery; other failures return null. */
|
|
10
10
|
export declare function getUsages(baseUrl: string, key: string): Promise<unknown>;
|
|
11
11
|
/** The adapter's `fetchUsage` for one deployment. */
|
|
12
12
|
export declare function usageFetcher(baseUrl: string): (account: PoolAccount, now: number) => Promise<UsageSnapshot | null>;
|
|
@@ -14,7 +14,8 @@ export declare function parseUsageHeaders(headers: Headers): Partial<UsageSnapsh
|
|
|
14
14
|
/**
|
|
15
15
|
* Poll GET /wham/usage for authoritative 5h + weekly utilization without
|
|
16
16
|
* consuming inference quota, assigning each returned window to its role BY
|
|
17
|
-
* DURATION (see `isWeeklyMinutes`) rather than by position.
|
|
17
|
+
* DURATION (see `isWeeklyMinutes`) rather than by position. A 401 is surfaced
|
|
18
|
+
* for bounded OAuth recovery; other failures return null.
|
|
18
19
|
* Sends the Codex CLI's `codex-cli` UA and the `chatgpt-account-id` (from the
|
|
19
20
|
* stored id or, as a fallback, decoded from the access-token JWT) — required
|
|
20
21
|
* for team/workspace accounts.
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared HTTP shell for the plugin's read-only JSON GETs — the provider usage
|
|
3
|
-
* endpoints (Anthropic `/usage`, OpenAI `/wham/usage`) and the npm dist-tags
|
|
4
|
-
* lookup behind `providers/anthropic/version.ts`. GET with a timeout, null on
|
|
5
|
-
* ANY failure — network throw/timeout, non-ok status (body cancelled so the
|
|
6
|
-
* connection is released), or a 200 whose body is not JSON. Callers keep only
|
|
7
|
-
* their own headers and shape guards; the transport contract lives here ONCE.
|
|
8
|
-
*/
|
|
9
1
|
export declare function fetchJson<T>(url: string, headers: Record<string, string>, timeoutMs: number): Promise<T | null>;
|
|
2
|
+
export declare class UsageAuthenticationError extends Error {
|
|
3
|
+
readonly status = 401;
|
|
4
|
+
constructor();
|
|
5
|
+
}
|
|
6
|
+
export declare function fetchUsageJson<T>(url: string, headers: Record<string, string>, timeoutMs: number): Promise<T | null>;
|
package/dist/usage-merge.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ScoreConfig } from './scheduler/score-core';
|
|
2
|
+
import type { PoolAccount, UsageWindow } from './types';
|
|
3
|
+
export declare const MAX_TRANSIENT_COOLDOWN_MS: number;
|
|
4
|
+
/** Call only after fresh usage was measured; cached headroom cannot disprove a later 429. */
|
|
5
|
+
export declare function reconcileQuotaCooldown(account: PoolAccount, cfg: ScoreConfig, now: number): void;
|
|
2
6
|
/**
|
|
3
7
|
* Roll a FIXED weekly reset anchor forward to its next occurrence strictly after
|
|
4
8
|
* `now`. Weekly windows are anchored per account (verified live: Anthropic's
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type LoopTimers } from './sync/loop';
|
|
2
|
+
interface UsagePollDeps {
|
|
3
|
+
now?: () => number;
|
|
4
|
+
refresh?: (now: number) => Promise<void>;
|
|
5
|
+
timers?: LoopTimers;
|
|
6
|
+
}
|
|
7
|
+
export declare function startUsagePoll(deps?: UsagePollDeps): {
|
|
8
|
+
tick: () => Promise<void>;
|
|
9
|
+
dispose(): Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
export {};
|
package/dist/usage-refresh.d.ts
CHANGED
|
@@ -1,18 +1,7 @@
|
|
|
1
1
|
import type { ProviderAdapter } from './providers/types';
|
|
2
2
|
import { type PoolFile } from './types';
|
|
3
3
|
export declare const USAGE_REFRESH_TTL_MS: number;
|
|
4
|
-
|
|
5
|
-
* Max lifetime of a TRANSIENT (non-quota) cooldown. fetch.ts cools a thrown
|
|
6
|
-
* network error or a header-less 429/402 for at most `ACCOUNT_COOLDOWN_MS`
|
|
7
|
-
* (5min) and an auth 401/403 for `AUTH_COOLDOWN_MS` (2min); ONLY a quota 429's
|
|
8
|
-
* `Retry-After` writes a cooldown beyond this. So a cooldown whose remainder
|
|
9
|
-
* still exceeds this bound is necessarily quota-derived — the one class that can
|
|
10
|
-
* outlive its own usage window after an out-of-band reset (see the stale-cooldown
|
|
11
|
-
* reconciliation below). Kept `=== ACCOUNT_COOLDOWN_MS` by a drift-guard test;
|
|
12
|
-
* defined locally because importing it from fetch.ts (which imports THIS module)
|
|
13
|
-
* would form a cycle.
|
|
14
|
-
*/
|
|
15
|
-
export declare const MAX_TRANSIENT_COOLDOWN_MS: number;
|
|
4
|
+
export { MAX_TRANSIENT_COOLDOWN_MS } from './usage-merge';
|
|
16
5
|
/**
|
|
17
6
|
* Test-only window into the module-private `lastPoll` throttle map. Never
|
|
18
7
|
* imported by production code — only by usage-refresh tests that need to
|
package/dist/v1.js
CHANGED
|
@@ -13385,6 +13385,14 @@ var init_score_core = __esm(() => {
|
|
|
13385
13385
|
});
|
|
13386
13386
|
|
|
13387
13387
|
// src/usage-merge.ts
|
|
13388
|
+
function reconcileQuotaCooldown(account, cfg, now) {
|
|
13389
|
+
if (account.cooldownKind === "auth" || account.cooldownKind === "transient")
|
|
13390
|
+
return;
|
|
13391
|
+
if (account.cooldownUntil > now + MAX_TRANSIENT_COOLDOWN_MS && !isExhausted(account, cfg, now)) {
|
|
13392
|
+
account.cooldownUntil = 0;
|
|
13393
|
+
delete account.cooldownKind;
|
|
13394
|
+
}
|
|
13395
|
+
}
|
|
13388
13396
|
function rollWeeklyAnchorForward(anchor, now) {
|
|
13389
13397
|
if (!Number.isFinite(anchor) || anchor <= 0)
|
|
13390
13398
|
return 0;
|
|
@@ -13401,8 +13409,10 @@ function preserveWeeklyAnchor(incoming, stored, now) {
|
|
|
13401
13409
|
return incoming;
|
|
13402
13410
|
return { utilization: incoming.utilization, resetAt: anchor };
|
|
13403
13411
|
}
|
|
13412
|
+
var MAX_TRANSIENT_COOLDOWN_MS;
|
|
13404
13413
|
var init_usage_merge = __esm(() => {
|
|
13405
13414
|
init_score_core();
|
|
13415
|
+
MAX_TRANSIENT_COOLDOWN_MS = 5 * 60 * 1000;
|
|
13406
13416
|
});
|
|
13407
13417
|
|
|
13408
13418
|
// src/accounts.ts
|
|
@@ -13454,6 +13464,8 @@ function mergeLoginUsage(row, usage) {
|
|
|
13454
13464
|
weekly: usage.weekly ? preserveWeeklyAnchor(usage.weekly, row.usage.weekly, usage.capturedAt) : row.usage.weekly,
|
|
13455
13465
|
capturedAt: usage.weekly ? usage.capturedAt : row.usage.capturedAt
|
|
13456
13466
|
};
|
|
13467
|
+
if (usage.weekly)
|
|
13468
|
+
reconcileQuotaCooldown(row, loadScoreConfig(), usage.capturedAt);
|
|
13457
13469
|
}
|
|
13458
13470
|
function applyTokens(row, tokens) {
|
|
13459
13471
|
if (tokens.refresh) {
|
|
@@ -13547,6 +13559,7 @@ async function bootstrapFromOpencodeAuth(providerID, getAuth, tokensFromApiKey)
|
|
|
13547
13559
|
var init_accounts = __esm(() => {
|
|
13548
13560
|
init_pairing();
|
|
13549
13561
|
init_store();
|
|
13562
|
+
init_score_core();
|
|
13550
13563
|
init_types();
|
|
13551
13564
|
init_usage_merge();
|
|
13552
13565
|
});
|
|
@@ -14488,24 +14501,46 @@ var init_usage_headers = __esm(() => {
|
|
|
14488
14501
|
});
|
|
14489
14502
|
|
|
14490
14503
|
// src/providers/usage-http.ts
|
|
14491
|
-
async function
|
|
14492
|
-
let response;
|
|
14504
|
+
async function request(url, headers, timeoutMs) {
|
|
14493
14505
|
try {
|
|
14494
|
-
|
|
14506
|
+
return await fetch(url, {
|
|
14495
14507
|
headers,
|
|
14496
14508
|
signal: AbortSignal.timeout(timeoutMs)
|
|
14497
14509
|
});
|
|
14498
14510
|
} catch {
|
|
14499
14511
|
return null;
|
|
14500
14512
|
}
|
|
14513
|
+
}
|
|
14514
|
+
async function readJson(response) {
|
|
14515
|
+
if (!response)
|
|
14516
|
+
return null;
|
|
14501
14517
|
if (!response.ok) {
|
|
14502
14518
|
await response.body?.cancel().catch(ignore);
|
|
14503
14519
|
return null;
|
|
14504
14520
|
}
|
|
14505
14521
|
return await response.json().catch(() => null);
|
|
14506
14522
|
}
|
|
14523
|
+
async function fetchJson(url, headers, timeoutMs) {
|
|
14524
|
+
return readJson(await request(url, headers, timeoutMs));
|
|
14525
|
+
}
|
|
14526
|
+
async function fetchUsageJson(url, headers, timeoutMs) {
|
|
14527
|
+
const response = await request(url, headers, timeoutMs);
|
|
14528
|
+
if (response?.status === 401) {
|
|
14529
|
+
await response.body?.cancel().catch(ignore);
|
|
14530
|
+
throw new UsageAuthenticationError;
|
|
14531
|
+
}
|
|
14532
|
+
return readJson(response);
|
|
14533
|
+
}
|
|
14534
|
+
var UsageAuthenticationError;
|
|
14507
14535
|
var init_usage_http = __esm(() => {
|
|
14508
14536
|
init_util2();
|
|
14537
|
+
UsageAuthenticationError = class UsageAuthenticationError extends Error {
|
|
14538
|
+
status = 401;
|
|
14539
|
+
constructor() {
|
|
14540
|
+
super("Usage endpoint rejected the access token (401)");
|
|
14541
|
+
this.name = "UsageAuthenticationError";
|
|
14542
|
+
}
|
|
14543
|
+
};
|
|
14509
14544
|
});
|
|
14510
14545
|
|
|
14511
14546
|
// src/providers/anthropic/version.ts
|
|
@@ -14945,7 +14980,7 @@ async function probeUsage(access, now) {
|
|
|
14945
14980
|
async function fetchUsage(account, now) {
|
|
14946
14981
|
if (!account.refresh)
|
|
14947
14982
|
return probeUsage(account.access, now);
|
|
14948
|
-
const json = await
|
|
14983
|
+
const json = await fetchUsageJson(USAGE_URL, {
|
|
14949
14984
|
authorization: `Bearer ${account.access}`,
|
|
14950
14985
|
"anthropic-beta": "oauth-2025-04-20",
|
|
14951
14986
|
"user-agent": usageUserAgent(),
|
|
@@ -15277,7 +15312,7 @@ function parseUsages(json, now) {
|
|
|
15277
15312
|
return hourly || weekly ? { hourly, weekly, capturedAt: now } : null;
|
|
15278
15313
|
}
|
|
15279
15314
|
function getUsages(baseUrl, key) {
|
|
15280
|
-
return
|
|
15315
|
+
return fetchUsageJson(`${baseUrl}/usages`, { authorization: `Bearer ${key}`, accept: "application/json" }, USAGE_HTTP_TIMEOUT_MS);
|
|
15281
15316
|
}
|
|
15282
15317
|
function usageFetcher(baseUrl) {
|
|
15283
15318
|
return async (account, now) => parseUsages(await getUsages(baseUrl, account.access), now);
|
|
@@ -15551,7 +15586,7 @@ async function fetchUsage2(account, now) {
|
|
|
15551
15586
|
const accountId = resolveAccountId(account);
|
|
15552
15587
|
if (accountId)
|
|
15553
15588
|
headers["chatgpt-account-id"] = accountId;
|
|
15554
|
-
const json = await
|
|
15589
|
+
const json = await fetchUsageJson(USAGE_URL2, headers, USAGE_HTTP_TIMEOUT_MS);
|
|
15555
15590
|
const rateLimit = json?.rate_limit;
|
|
15556
15591
|
if (!isPlainObject2(rateLimit))
|
|
15557
15592
|
return null;
|
|
@@ -15640,7 +15675,7 @@ async function refreshUsage(adapters, now, poolSnapshot) {
|
|
|
15640
15675
|
aliveIds?.add(account.id);
|
|
15641
15676
|
if (account.disabledReason && account.disabledReason !== MANUAL_DISABLED_REASON)
|
|
15642
15677
|
continue;
|
|
15643
|
-
if (account.usage.capturedAt !== 0 && now - account.usage.capturedAt
|
|
15678
|
+
if (account.usage.capturedAt !== 0 && now - account.usage.capturedAt < USAGE_REFRESH_TTL_MS)
|
|
15644
15679
|
continue;
|
|
15645
15680
|
const adapter = adapterFor(adapters, account.providerID);
|
|
15646
15681
|
if (!adapter)
|
|
@@ -15662,7 +15697,16 @@ async function refreshUsage(adapters, now, poolSnapshot) {
|
|
|
15662
15697
|
await Promise.all(stale.map(async ({ adapter, account }) => {
|
|
15663
15698
|
try {
|
|
15664
15699
|
await ensureAccessToken(adapter, account, now);
|
|
15665
|
-
const
|
|
15700
|
+
const rejectedAccess = account.access;
|
|
15701
|
+
let snapshot;
|
|
15702
|
+
try {
|
|
15703
|
+
snapshot = await adapter.fetchUsage(account, now);
|
|
15704
|
+
} catch (error) {
|
|
15705
|
+
if (!(error instanceof UsageAuthenticationError) || !account.refresh)
|
|
15706
|
+
throw error;
|
|
15707
|
+
await ensureAccessToken(adapter, account, now, rejectedAccess);
|
|
15708
|
+
snapshot = await adapter.fetchUsage(account, now);
|
|
15709
|
+
}
|
|
15666
15710
|
if (snapshot) {
|
|
15667
15711
|
await mutatePool((p) => {
|
|
15668
15712
|
const stored = findAccount(p, account.id);
|
|
@@ -15672,10 +15716,8 @@ async function refreshUsage(adapters, now, poolSnapshot) {
|
|
|
15672
15716
|
weekly: snapshot.weekly === null ? stored.usage.weekly : preserveWeeklyAnchor(snapshot.weekly, stored.usage.weekly, now),
|
|
15673
15717
|
capturedAt: snapshot.weekly === null ? stored.usage.capturedAt : now
|
|
15674
15718
|
};
|
|
15675
|
-
if (snapshot.weekly !== null && stored.cooldownUntil
|
|
15676
|
-
stored
|
|
15677
|
-
delete stored.cooldownKind;
|
|
15678
|
-
}
|
|
15719
|
+
if (snapshot.weekly !== null && stored.cooldownUntil === account.cooldownUntil && stored.cooldownKind === account.cooldownKind)
|
|
15720
|
+
reconcileQuotaCooldown(stored, scoreCfg, now);
|
|
15679
15721
|
}
|
|
15680
15722
|
});
|
|
15681
15723
|
}
|
|
@@ -15688,16 +15730,17 @@ function refreshUsageInBackground(adapter, now, poolSnapshot) {
|
|
|
15688
15730
|
function refreshAllUsageInBackground(now, poolSnapshot) {
|
|
15689
15731
|
return refreshUsage(ADAPTERS, now, poolSnapshot);
|
|
15690
15732
|
}
|
|
15691
|
-
var USAGE_REFRESH_TTL_MS,
|
|
15733
|
+
var USAGE_REFRESH_TTL_MS, lastPoll;
|
|
15692
15734
|
var init_usage_refresh = __esm(() => {
|
|
15693
15735
|
init_store();
|
|
15694
15736
|
init_registry();
|
|
15737
|
+
init_usage_http();
|
|
15695
15738
|
init_refresh();
|
|
15696
15739
|
init_score_core();
|
|
15697
15740
|
init_types();
|
|
15698
15741
|
init_usage_merge();
|
|
15742
|
+
init_usage_merge();
|
|
15699
15743
|
USAGE_REFRESH_TTL_MS = 5 * 60 * 1000;
|
|
15700
|
-
MAX_TRANSIENT_COOLDOWN_MS = 5 * 60 * 1000;
|
|
15701
15744
|
lastPoll = new Map;
|
|
15702
15745
|
});
|
|
15703
15746
|
|
|
@@ -17329,7 +17372,7 @@ function backoffFrom(res, now) {
|
|
|
17329
17372
|
function isRateLimited(res) {
|
|
17330
17373
|
return res.status === 429 || res.status === 403 && (res.headers.get("x-ratelimit-remaining") === "0" || res.headers.has("retry-after"));
|
|
17331
17374
|
}
|
|
17332
|
-
async function
|
|
17375
|
+
async function request2(url, init, now) {
|
|
17333
17376
|
let res;
|
|
17334
17377
|
try {
|
|
17335
17378
|
res = await globalThis.fetch(url, {
|
|
@@ -17381,7 +17424,7 @@ async function boundedText(res) {
|
|
|
17381
17424
|
}
|
|
17382
17425
|
return Buffer.concat(chunks).toString("utf8");
|
|
17383
17426
|
}
|
|
17384
|
-
async function
|
|
17427
|
+
async function readJson2(res) {
|
|
17385
17428
|
if (!res.ok)
|
|
17386
17429
|
throw new SyncError("http");
|
|
17387
17430
|
const text = await boundedText(res);
|
|
@@ -17396,7 +17439,7 @@ async function readJson(res) {
|
|
|
17396
17439
|
return json;
|
|
17397
17440
|
}
|
|
17398
17441
|
async function createGist(token, content, now = Date.now()) {
|
|
17399
|
-
const res = await
|
|
17442
|
+
const res = await request2(API, {
|
|
17400
17443
|
method: "POST",
|
|
17401
17444
|
headers: { ...headers(token), "content-type": "application/json" },
|
|
17402
17445
|
body: JSON.stringify({
|
|
@@ -17405,7 +17448,7 @@ async function createGist(token, content, now = Date.now()) {
|
|
|
17405
17448
|
files: { [GIST_FILE]: { content } }
|
|
17406
17449
|
})
|
|
17407
17450
|
}, now);
|
|
17408
|
-
const json = await
|
|
17451
|
+
const json = await readJson2(res);
|
|
17409
17452
|
const owner = isPlainObject2(json.owner) ? json.owner.login : undefined;
|
|
17410
17453
|
if (typeof json.id !== "string" || !GIST_ID_RE.test(json.id))
|
|
17411
17454
|
throw new SyncError("http");
|
|
@@ -17417,7 +17460,7 @@ async function createGist(token, content, now = Date.now()) {
|
|
|
17417
17460
|
async function updateGist(token, id, content, now = Date.now()) {
|
|
17418
17461
|
let res;
|
|
17419
17462
|
try {
|
|
17420
|
-
res = await
|
|
17463
|
+
res = await request2(`${API}/${id}`, {
|
|
17421
17464
|
method: "PATCH",
|
|
17422
17465
|
headers: { ...headers(token), "content-type": "application/json" },
|
|
17423
17466
|
body: JSON.stringify({ files: { [GIST_FILE]: { content } } })
|
|
@@ -17429,10 +17472,10 @@ async function updateGist(token, id, content, now = Date.now()) {
|
|
|
17429
17472
|
}
|
|
17430
17473
|
if (res.status === 401 || res.status === 403)
|
|
17431
17474
|
throw new SyncError("write-denied");
|
|
17432
|
-
await
|
|
17475
|
+
await readJson2(res);
|
|
17433
17476
|
}
|
|
17434
17477
|
async function readGist(id, etag, now = Date.now(), token) {
|
|
17435
|
-
const ask = (auth) =>
|
|
17478
|
+
const ask = (auth) => request2(`${API}/${id}`, {
|
|
17436
17479
|
headers: {
|
|
17437
17480
|
...headers(auth),
|
|
17438
17481
|
...etag ? { "if-none-match": etag } : {}
|
|
@@ -17443,7 +17486,7 @@ async function readGist(id, etag, now = Date.now(), token) {
|
|
|
17443
17486
|
res = await ask();
|
|
17444
17487
|
if (res.status === 304)
|
|
17445
17488
|
return { changed: false };
|
|
17446
|
-
const json = await
|
|
17489
|
+
const json = await readJson2(res);
|
|
17447
17490
|
const file = isPlainObject2(json.files) ? json.files[GIST_FILE] : undefined;
|
|
17448
17491
|
if (!isPlainObject2(file) || typeof file.content !== "string")
|
|
17449
17492
|
throw new SyncError("bad-blob");
|
|
@@ -18585,6 +18628,71 @@ var init_start = __esm(() => {
|
|
|
18585
18628
|
init_state();
|
|
18586
18629
|
});
|
|
18587
18630
|
|
|
18631
|
+
// src/usage-poll.ts
|
|
18632
|
+
import { readFile as readFile8 } from "node:fs/promises";
|
|
18633
|
+
async function nextPollAt(path) {
|
|
18634
|
+
try {
|
|
18635
|
+
const value = JSON.parse(await readFile8(path, "utf8"));
|
|
18636
|
+
return isPlainObject2(value) && isFiniteNumber(value.nextPollAt) ? value.nextPollAt : 0;
|
|
18637
|
+
} catch (error) {
|
|
18638
|
+
if (error instanceof SyntaxError || error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
18639
|
+
return 0;
|
|
18640
|
+
throw error;
|
|
18641
|
+
}
|
|
18642
|
+
}
|
|
18643
|
+
function startUsagePoll(deps = {}) {
|
|
18644
|
+
const now = deps.now ?? Date.now;
|
|
18645
|
+
const refresh = deps.refresh ?? refreshAllUsageInBackground;
|
|
18646
|
+
const timers = deps.timers ?? realTimers;
|
|
18647
|
+
let disposed = false;
|
|
18648
|
+
let inflight;
|
|
18649
|
+
async function poll() {
|
|
18650
|
+
const path = `${poolFilePath()}.usage-poll.json`;
|
|
18651
|
+
try {
|
|
18652
|
+
await withLock(`${path}.lock`, {
|
|
18653
|
+
staleMs: 120000,
|
|
18654
|
+
timeoutMs: 0,
|
|
18655
|
+
retryMs: 25,
|
|
18656
|
+
heartbeatMs: 5000
|
|
18657
|
+
}, async () => {
|
|
18658
|
+
const at = now();
|
|
18659
|
+
if (at < await nextPollAt(path))
|
|
18660
|
+
return;
|
|
18661
|
+
await writeJsonAtomic(path, JSON.stringify({ nextPollAt: at + USAGE_REFRESH_TTL_MS }));
|
|
18662
|
+
await refresh(at);
|
|
18663
|
+
});
|
|
18664
|
+
} catch (error) {
|
|
18665
|
+
if (!(error instanceof LockTimeoutError))
|
|
18666
|
+
throw error;
|
|
18667
|
+
}
|
|
18668
|
+
}
|
|
18669
|
+
function tick() {
|
|
18670
|
+
if (disposed)
|
|
18671
|
+
return Promise.resolve();
|
|
18672
|
+
inflight ??= poll().finally(() => {
|
|
18673
|
+
inflight = undefined;
|
|
18674
|
+
});
|
|
18675
|
+
return inflight;
|
|
18676
|
+
}
|
|
18677
|
+
const timer = timers.set(() => void tick().catch(ignore), USAGE_REFRESH_TTL_MS);
|
|
18678
|
+
return {
|
|
18679
|
+
tick,
|
|
18680
|
+
async dispose() {
|
|
18681
|
+
disposed = true;
|
|
18682
|
+
timers.clear(timer);
|
|
18683
|
+
await inflight;
|
|
18684
|
+
}
|
|
18685
|
+
};
|
|
18686
|
+
}
|
|
18687
|
+
var init_usage_poll = __esm(() => {
|
|
18688
|
+
init_lock();
|
|
18689
|
+
init_paths();
|
|
18690
|
+
init_store();
|
|
18691
|
+
init_loop();
|
|
18692
|
+
init_usage_refresh();
|
|
18693
|
+
init_util2();
|
|
18694
|
+
});
|
|
18695
|
+
|
|
18588
18696
|
// src/legacy.ts
|
|
18589
18697
|
function zeroOutCost(provider) {
|
|
18590
18698
|
for (const model of Object.values(provider.models)) {
|
|
@@ -18739,8 +18847,12 @@ function createProviderPlugin(adapter) {
|
|
|
18739
18847
|
}
|
|
18740
18848
|
var PROVIDER_LABELS, AnthropicLoadBalancerPlugin, OpenAILoadBalancerPlugin, KimiCodeLoadBalancerPlugin, KimiCodeGlobalLoadBalancerPlugin, lbResult = (output) => ({ title: "Auth Load Balancer", output }), AuthLoadBalancerStatusPlugin = async (input) => {
|
|
18741
18849
|
const sync = startSync();
|
|
18850
|
+
const usage = startUsagePoll();
|
|
18742
18851
|
return {
|
|
18743
|
-
dispose: async () =>
|
|
18852
|
+
dispose: async () => {
|
|
18853
|
+
await usage.dispose();
|
|
18854
|
+
await sync?.dispose();
|
|
18855
|
+
},
|
|
18744
18856
|
tool: {
|
|
18745
18857
|
auth_lb_status: tool({
|
|
18746
18858
|
description: "Show the auth load-balancer pool: the in-use account per provider, each account's weekly and 5h usage, cooldowns, and the ranked next-candidate accounts. Refreshes stale usage from the provider usage endpoints (throttled) before rendering.",
|
|
@@ -18829,6 +18941,7 @@ var init_legacy = __esm(() => {
|
|
|
18829
18941
|
init_start();
|
|
18830
18942
|
init_token_mint();
|
|
18831
18943
|
init_types();
|
|
18944
|
+
init_usage_poll();
|
|
18832
18945
|
init_usage_refresh();
|
|
18833
18946
|
init_util2();
|
|
18834
18947
|
PROVIDER_LABELS = {
|
package/dist/v2-entry.js
CHANGED
|
@@ -42143,6 +42143,14 @@ var init_score_core = __esm(() => {
|
|
|
42143
42143
|
});
|
|
42144
42144
|
|
|
42145
42145
|
// src/usage-merge.ts
|
|
42146
|
+
function reconcileQuotaCooldown(account, cfg, now) {
|
|
42147
|
+
if (account.cooldownKind === "auth" || account.cooldownKind === "transient")
|
|
42148
|
+
return;
|
|
42149
|
+
if (account.cooldownUntil > now + MAX_TRANSIENT_COOLDOWN_MS && !isExhausted(account, cfg, now)) {
|
|
42150
|
+
account.cooldownUntil = 0;
|
|
42151
|
+
delete account.cooldownKind;
|
|
42152
|
+
}
|
|
42153
|
+
}
|
|
42146
42154
|
function rollWeeklyAnchorForward(anchor, now) {
|
|
42147
42155
|
if (!Number.isFinite(anchor) || anchor <= 0)
|
|
42148
42156
|
return 0;
|
|
@@ -42159,8 +42167,10 @@ function preserveWeeklyAnchor(incoming, stored, now) {
|
|
|
42159
42167
|
return incoming;
|
|
42160
42168
|
return { utilization: incoming.utilization, resetAt: anchor };
|
|
42161
42169
|
}
|
|
42170
|
+
var MAX_TRANSIENT_COOLDOWN_MS;
|
|
42162
42171
|
var init_usage_merge = __esm(() => {
|
|
42163
42172
|
init_score_core();
|
|
42173
|
+
MAX_TRANSIENT_COOLDOWN_MS = 5 * 60 * 1000;
|
|
42164
42174
|
});
|
|
42165
42175
|
|
|
42166
42176
|
// src/accounts.ts
|
|
@@ -42212,6 +42222,8 @@ function mergeLoginUsage(row, usage) {
|
|
|
42212
42222
|
weekly: usage.weekly ? preserveWeeklyAnchor(usage.weekly, row.usage.weekly, usage.capturedAt) : row.usage.weekly,
|
|
42213
42223
|
capturedAt: usage.weekly ? usage.capturedAt : row.usage.capturedAt
|
|
42214
42224
|
};
|
|
42225
|
+
if (usage.weekly)
|
|
42226
|
+
reconcileQuotaCooldown(row, loadScoreConfig(), usage.capturedAt);
|
|
42215
42227
|
}
|
|
42216
42228
|
function applyTokens(row, tokens) {
|
|
42217
42229
|
if (tokens.refresh) {
|
|
@@ -42305,6 +42317,7 @@ async function bootstrapFromOpencodeAuth(providerID, getAuth, tokensFromApiKey)
|
|
|
42305
42317
|
var init_accounts = __esm(() => {
|
|
42306
42318
|
init_pairing();
|
|
42307
42319
|
init_store();
|
|
42320
|
+
init_score_core();
|
|
42308
42321
|
init_types2();
|
|
42309
42322
|
init_usage_merge();
|
|
42310
42323
|
});
|
|
@@ -43246,24 +43259,46 @@ var init_usage_headers = __esm(() => {
|
|
|
43246
43259
|
});
|
|
43247
43260
|
|
|
43248
43261
|
// src/providers/usage-http.ts
|
|
43249
|
-
async function
|
|
43250
|
-
let response;
|
|
43262
|
+
async function request(url, headers, timeoutMs) {
|
|
43251
43263
|
try {
|
|
43252
|
-
|
|
43264
|
+
return await fetch(url, {
|
|
43253
43265
|
headers,
|
|
43254
43266
|
signal: AbortSignal.timeout(timeoutMs)
|
|
43255
43267
|
});
|
|
43256
43268
|
} catch {
|
|
43257
43269
|
return null;
|
|
43258
43270
|
}
|
|
43271
|
+
}
|
|
43272
|
+
async function readJson(response) {
|
|
43273
|
+
if (!response)
|
|
43274
|
+
return null;
|
|
43259
43275
|
if (!response.ok) {
|
|
43260
43276
|
await response.body?.cancel().catch(ignore);
|
|
43261
43277
|
return null;
|
|
43262
43278
|
}
|
|
43263
43279
|
return await response.json().catch(() => null);
|
|
43264
43280
|
}
|
|
43281
|
+
async function fetchJson(url, headers, timeoutMs) {
|
|
43282
|
+
return readJson(await request(url, headers, timeoutMs));
|
|
43283
|
+
}
|
|
43284
|
+
async function fetchUsageJson(url, headers, timeoutMs) {
|
|
43285
|
+
const response = await request(url, headers, timeoutMs);
|
|
43286
|
+
if (response?.status === 401) {
|
|
43287
|
+
await response.body?.cancel().catch(ignore);
|
|
43288
|
+
throw new UsageAuthenticationError;
|
|
43289
|
+
}
|
|
43290
|
+
return readJson(response);
|
|
43291
|
+
}
|
|
43292
|
+
var UsageAuthenticationError;
|
|
43265
43293
|
var init_usage_http = __esm(() => {
|
|
43266
43294
|
init_util4();
|
|
43295
|
+
UsageAuthenticationError = class UsageAuthenticationError extends Error {
|
|
43296
|
+
status = 401;
|
|
43297
|
+
constructor() {
|
|
43298
|
+
super("Usage endpoint rejected the access token (401)");
|
|
43299
|
+
this.name = "UsageAuthenticationError";
|
|
43300
|
+
}
|
|
43301
|
+
};
|
|
43267
43302
|
});
|
|
43268
43303
|
|
|
43269
43304
|
// src/providers/anthropic/version.ts
|
|
@@ -43703,7 +43738,7 @@ async function probeUsage(access, now) {
|
|
|
43703
43738
|
async function fetchUsage(account, now) {
|
|
43704
43739
|
if (!account.refresh)
|
|
43705
43740
|
return probeUsage(account.access, now);
|
|
43706
|
-
const json = await
|
|
43741
|
+
const json = await fetchUsageJson(USAGE_URL, {
|
|
43707
43742
|
authorization: `Bearer ${account.access}`,
|
|
43708
43743
|
"anthropic-beta": "oauth-2025-04-20",
|
|
43709
43744
|
"user-agent": usageUserAgent(),
|
|
@@ -44035,7 +44070,7 @@ function parseUsages(json, now) {
|
|
|
44035
44070
|
return hourly || weekly ? { hourly, weekly, capturedAt: now } : null;
|
|
44036
44071
|
}
|
|
44037
44072
|
function getUsages(baseUrl, key) {
|
|
44038
|
-
return
|
|
44073
|
+
return fetchUsageJson(`${baseUrl}/usages`, { authorization: `Bearer ${key}`, accept: "application/json" }, USAGE_HTTP_TIMEOUT_MS);
|
|
44039
44074
|
}
|
|
44040
44075
|
function usageFetcher(baseUrl) {
|
|
44041
44076
|
return async (account, now) => parseUsages(await getUsages(baseUrl, account.access), now);
|
|
@@ -44309,7 +44344,7 @@ async function fetchUsage2(account, now) {
|
|
|
44309
44344
|
const accountId = resolveAccountId(account);
|
|
44310
44345
|
if (accountId)
|
|
44311
44346
|
headers["chatgpt-account-id"] = accountId;
|
|
44312
|
-
const json = await
|
|
44347
|
+
const json = await fetchUsageJson(USAGE_URL2, headers, USAGE_HTTP_TIMEOUT_MS);
|
|
44313
44348
|
const rateLimit = json?.rate_limit;
|
|
44314
44349
|
if (!isPlainObject4(rateLimit))
|
|
44315
44350
|
return null;
|
|
@@ -44398,7 +44433,7 @@ async function refreshUsage(adapters, now, poolSnapshot) {
|
|
|
44398
44433
|
aliveIds?.add(account.id);
|
|
44399
44434
|
if (account.disabledReason && account.disabledReason !== MANUAL_DISABLED_REASON)
|
|
44400
44435
|
continue;
|
|
44401
|
-
if (account.usage.capturedAt !== 0 && now - account.usage.capturedAt
|
|
44436
|
+
if (account.usage.capturedAt !== 0 && now - account.usage.capturedAt < USAGE_REFRESH_TTL_MS)
|
|
44402
44437
|
continue;
|
|
44403
44438
|
const adapter = adapterFor(adapters, account.providerID);
|
|
44404
44439
|
if (!adapter)
|
|
@@ -44420,7 +44455,16 @@ async function refreshUsage(adapters, now, poolSnapshot) {
|
|
|
44420
44455
|
await Promise.all(stale.map(async ({ adapter, account }) => {
|
|
44421
44456
|
try {
|
|
44422
44457
|
await ensureAccessToken(adapter, account, now);
|
|
44423
|
-
const
|
|
44458
|
+
const rejectedAccess = account.access;
|
|
44459
|
+
let snapshot;
|
|
44460
|
+
try {
|
|
44461
|
+
snapshot = await adapter.fetchUsage(account, now);
|
|
44462
|
+
} catch (error) {
|
|
44463
|
+
if (!(error instanceof UsageAuthenticationError) || !account.refresh)
|
|
44464
|
+
throw error;
|
|
44465
|
+
await ensureAccessToken(adapter, account, now, rejectedAccess);
|
|
44466
|
+
snapshot = await adapter.fetchUsage(account, now);
|
|
44467
|
+
}
|
|
44424
44468
|
if (snapshot) {
|
|
44425
44469
|
await mutatePool((p) => {
|
|
44426
44470
|
const stored = findAccount(p, account.id);
|
|
@@ -44430,10 +44474,8 @@ async function refreshUsage(adapters, now, poolSnapshot) {
|
|
|
44430
44474
|
weekly: snapshot.weekly === null ? stored.usage.weekly : preserveWeeklyAnchor(snapshot.weekly, stored.usage.weekly, now),
|
|
44431
44475
|
capturedAt: snapshot.weekly === null ? stored.usage.capturedAt : now
|
|
44432
44476
|
};
|
|
44433
|
-
if (snapshot.weekly !== null && stored.cooldownUntil
|
|
44434
|
-
stored
|
|
44435
|
-
delete stored.cooldownKind;
|
|
44436
|
-
}
|
|
44477
|
+
if (snapshot.weekly !== null && stored.cooldownUntil === account.cooldownUntil && stored.cooldownKind === account.cooldownKind)
|
|
44478
|
+
reconcileQuotaCooldown(stored, scoreCfg, now);
|
|
44437
44479
|
}
|
|
44438
44480
|
});
|
|
44439
44481
|
}
|
|
@@ -44446,16 +44488,17 @@ function refreshUsageInBackground(adapter, now, poolSnapshot) {
|
|
|
44446
44488
|
function refreshAllUsageInBackground(now, poolSnapshot) {
|
|
44447
44489
|
return refreshUsage(ADAPTERS, now, poolSnapshot);
|
|
44448
44490
|
}
|
|
44449
|
-
var USAGE_REFRESH_TTL_MS,
|
|
44491
|
+
var USAGE_REFRESH_TTL_MS, lastPoll;
|
|
44450
44492
|
var init_usage_refresh = __esm(() => {
|
|
44451
44493
|
init_store();
|
|
44452
44494
|
init_registry();
|
|
44495
|
+
init_usage_http();
|
|
44453
44496
|
init_refresh();
|
|
44454
44497
|
init_score_core();
|
|
44455
44498
|
init_types2();
|
|
44456
44499
|
init_usage_merge();
|
|
44500
|
+
init_usage_merge();
|
|
44457
44501
|
USAGE_REFRESH_TTL_MS = 5 * 60 * 1000;
|
|
44458
|
-
MAX_TRANSIENT_COOLDOWN_MS = 5 * 60 * 1000;
|
|
44459
44502
|
lastPoll = new Map;
|
|
44460
44503
|
});
|
|
44461
44504
|
|
|
@@ -46087,7 +46130,7 @@ function backoffFrom(res, now) {
|
|
|
46087
46130
|
function isRateLimited(res) {
|
|
46088
46131
|
return res.status === 429 || res.status === 403 && (res.headers.get("x-ratelimit-remaining") === "0" || res.headers.has("retry-after"));
|
|
46089
46132
|
}
|
|
46090
|
-
async function
|
|
46133
|
+
async function request2(url, init, now) {
|
|
46091
46134
|
let res;
|
|
46092
46135
|
try {
|
|
46093
46136
|
res = await globalThis.fetch(url, {
|
|
@@ -46139,7 +46182,7 @@ async function boundedText(res) {
|
|
|
46139
46182
|
}
|
|
46140
46183
|
return Buffer.concat(chunks).toString("utf8");
|
|
46141
46184
|
}
|
|
46142
|
-
async function
|
|
46185
|
+
async function readJson2(res) {
|
|
46143
46186
|
if (!res.ok)
|
|
46144
46187
|
throw new SyncError("http");
|
|
46145
46188
|
const text = await boundedText(res);
|
|
@@ -46154,7 +46197,7 @@ async function readJson(res) {
|
|
|
46154
46197
|
return json;
|
|
46155
46198
|
}
|
|
46156
46199
|
async function createGist(token, content, now = Date.now()) {
|
|
46157
|
-
const res = await
|
|
46200
|
+
const res = await request2(API, {
|
|
46158
46201
|
method: "POST",
|
|
46159
46202
|
headers: { ...headers(token), "content-type": "application/json" },
|
|
46160
46203
|
body: JSON.stringify({
|
|
@@ -46163,7 +46206,7 @@ async function createGist(token, content, now = Date.now()) {
|
|
|
46163
46206
|
files: { [GIST_FILE]: { content } }
|
|
46164
46207
|
})
|
|
46165
46208
|
}, now);
|
|
46166
|
-
const json = await
|
|
46209
|
+
const json = await readJson2(res);
|
|
46167
46210
|
const owner = isPlainObject4(json.owner) ? json.owner.login : undefined;
|
|
46168
46211
|
if (typeof json.id !== "string" || !GIST_ID_RE.test(json.id))
|
|
46169
46212
|
throw new SyncError("http");
|
|
@@ -46175,7 +46218,7 @@ async function createGist(token, content, now = Date.now()) {
|
|
|
46175
46218
|
async function updateGist(token, id, content, now = Date.now()) {
|
|
46176
46219
|
let res;
|
|
46177
46220
|
try {
|
|
46178
|
-
res = await
|
|
46221
|
+
res = await request2(`${API}/${id}`, {
|
|
46179
46222
|
method: "PATCH",
|
|
46180
46223
|
headers: { ...headers(token), "content-type": "application/json" },
|
|
46181
46224
|
body: JSON.stringify({ files: { [GIST_FILE]: { content } } })
|
|
@@ -46187,10 +46230,10 @@ async function updateGist(token, id, content, now = Date.now()) {
|
|
|
46187
46230
|
}
|
|
46188
46231
|
if (res.status === 401 || res.status === 403)
|
|
46189
46232
|
throw new SyncError("write-denied");
|
|
46190
|
-
await
|
|
46233
|
+
await readJson2(res);
|
|
46191
46234
|
}
|
|
46192
46235
|
async function readGist(id, etag, now = Date.now(), token) {
|
|
46193
|
-
const ask = (auth) =>
|
|
46236
|
+
const ask = (auth) => request2(`${API}/${id}`, {
|
|
46194
46237
|
headers: {
|
|
46195
46238
|
...headers(auth),
|
|
46196
46239
|
...etag ? { "if-none-match": etag } : {}
|
|
@@ -46201,7 +46244,7 @@ async function readGist(id, etag, now = Date.now(), token) {
|
|
|
46201
46244
|
res = await ask();
|
|
46202
46245
|
if (res.status === 304)
|
|
46203
46246
|
return { changed: false };
|
|
46204
|
-
const json = await
|
|
46247
|
+
const json = await readJson2(res);
|
|
46205
46248
|
const file = isPlainObject4(json.files) ? json.files[GIST_FILE] : undefined;
|
|
46206
46249
|
if (!isPlainObject4(file) || typeof file.content !== "string")
|
|
46207
46250
|
throw new SyncError("bad-blob");
|
|
@@ -47343,6 +47386,71 @@ var init_start = __esm(() => {
|
|
|
47343
47386
|
init_state();
|
|
47344
47387
|
});
|
|
47345
47388
|
|
|
47389
|
+
// src/usage-poll.ts
|
|
47390
|
+
import { readFile as readFile8 } from "node:fs/promises";
|
|
47391
|
+
async function nextPollAt(path) {
|
|
47392
|
+
try {
|
|
47393
|
+
const value = JSON.parse(await readFile8(path, "utf8"));
|
|
47394
|
+
return isPlainObject4(value) && isFiniteNumber(value.nextPollAt) ? value.nextPollAt : 0;
|
|
47395
|
+
} catch (error) {
|
|
47396
|
+
if (error instanceof SyntaxError || error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
47397
|
+
return 0;
|
|
47398
|
+
throw error;
|
|
47399
|
+
}
|
|
47400
|
+
}
|
|
47401
|
+
function startUsagePoll(deps = {}) {
|
|
47402
|
+
const now = deps.now ?? Date.now;
|
|
47403
|
+
const refresh = deps.refresh ?? refreshAllUsageInBackground;
|
|
47404
|
+
const timers = deps.timers ?? realTimers;
|
|
47405
|
+
let disposed = false;
|
|
47406
|
+
let inflight;
|
|
47407
|
+
async function poll() {
|
|
47408
|
+
const path = `${poolFilePath()}.usage-poll.json`;
|
|
47409
|
+
try {
|
|
47410
|
+
await withLock(`${path}.lock`, {
|
|
47411
|
+
staleMs: 120000,
|
|
47412
|
+
timeoutMs: 0,
|
|
47413
|
+
retryMs: 25,
|
|
47414
|
+
heartbeatMs: 5000
|
|
47415
|
+
}, async () => {
|
|
47416
|
+
const at = now();
|
|
47417
|
+
if (at < await nextPollAt(path))
|
|
47418
|
+
return;
|
|
47419
|
+
await writeJsonAtomic(path, JSON.stringify({ nextPollAt: at + USAGE_REFRESH_TTL_MS }));
|
|
47420
|
+
await refresh(at);
|
|
47421
|
+
});
|
|
47422
|
+
} catch (error) {
|
|
47423
|
+
if (!(error instanceof LockTimeoutError))
|
|
47424
|
+
throw error;
|
|
47425
|
+
}
|
|
47426
|
+
}
|
|
47427
|
+
function tick() {
|
|
47428
|
+
if (disposed)
|
|
47429
|
+
return Promise.resolve();
|
|
47430
|
+
inflight ??= poll().finally(() => {
|
|
47431
|
+
inflight = undefined;
|
|
47432
|
+
});
|
|
47433
|
+
return inflight;
|
|
47434
|
+
}
|
|
47435
|
+
const timer = timers.set(() => void tick().catch(ignore), USAGE_REFRESH_TTL_MS);
|
|
47436
|
+
return {
|
|
47437
|
+
tick,
|
|
47438
|
+
async dispose() {
|
|
47439
|
+
disposed = true;
|
|
47440
|
+
timers.clear(timer);
|
|
47441
|
+
await inflight;
|
|
47442
|
+
}
|
|
47443
|
+
};
|
|
47444
|
+
}
|
|
47445
|
+
var init_usage_poll = __esm(() => {
|
|
47446
|
+
init_lock();
|
|
47447
|
+
init_paths();
|
|
47448
|
+
init_store();
|
|
47449
|
+
init_loop();
|
|
47450
|
+
init_usage_refresh();
|
|
47451
|
+
init_util4();
|
|
47452
|
+
});
|
|
47453
|
+
|
|
47346
47454
|
// src/legacy.ts
|
|
47347
47455
|
function zeroOutCost(provider) {
|
|
47348
47456
|
for (const model of Object.values(provider.models)) {
|
|
@@ -47497,8 +47605,12 @@ function createProviderPlugin(adapter) {
|
|
|
47497
47605
|
}
|
|
47498
47606
|
var PROVIDER_LABELS, AnthropicLoadBalancerPlugin, OpenAILoadBalancerPlugin, KimiCodeLoadBalancerPlugin, KimiCodeGlobalLoadBalancerPlugin, lbResult = (output) => ({ title: "Auth Load Balancer", output }), AuthLoadBalancerStatusPlugin = async (input) => {
|
|
47499
47607
|
const sync = startSync();
|
|
47608
|
+
const usage = startUsagePoll();
|
|
47500
47609
|
return {
|
|
47501
|
-
dispose: async () =>
|
|
47610
|
+
dispose: async () => {
|
|
47611
|
+
await usage.dispose();
|
|
47612
|
+
await sync?.dispose();
|
|
47613
|
+
},
|
|
47502
47614
|
tool: {
|
|
47503
47615
|
auth_lb_status: tool2({
|
|
47504
47616
|
description: "Show the auth load-balancer pool: the in-use account per provider, each account's weekly and 5h usage, cooldowns, and the ranked next-candidate accounts. Refreshes stale usage from the provider usage endpoints (throttled) before rendering.",
|
|
@@ -47587,6 +47699,7 @@ var init_legacy = __esm(() => {
|
|
|
47587
47699
|
init_start();
|
|
47588
47700
|
init_token_mint();
|
|
47589
47701
|
init_types2();
|
|
47702
|
+
init_usage_poll();
|
|
47590
47703
|
init_usage_refresh();
|
|
47591
47704
|
init_util4();
|
|
47592
47705
|
PROVIDER_LABELS = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-auth-load-balancer",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Load-balance opencode requests across multiple Anthropic (Claude) and OpenAI (Codex) OAuth accounts and Kimi Code subscriptions, weighted primarily by weekly usage with drain-before-reset scheduling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|