dsh-agy 0.2.2 → 0.2.3
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 +23 -4
- package/lib/{session-KoIh3xdB.mjs → accounts-BvX5zmPa.mjs} +1048 -527
- package/lib/accounts-BvX5zmPa.mjs.map +1 -0
- package/lib/cli/index.d.mts.map +1 -1
- package/lib/cli/index.mjs +38 -5
- package/lib/cli/index.mjs.map +1 -1
- package/lib/{exchange-DBRzR2M-.mjs → exchange-CteHnRzO.mjs} +7 -5
- package/lib/exchange-CteHnRzO.mjs.map +1 -0
- package/lib/{import-C4CRBTbi.mjs → import-C7EV_tne.mjs} +14 -6
- package/lib/import-C7EV_tne.mjs.map +1 -0
- package/lib/index.d.mts.map +1 -1
- package/lib/index.mjs +6 -1
- package/lib/index.mjs.map +1 -1
- package/lib/{models-1rf_Wfo2.mjs → models-I1UAEk9x.mjs} +3 -3
- package/lib/{models-1rf_Wfo2.mjs.map → models-I1UAEk9x.mjs.map} +1 -1
- package/lib/{plugin-common-CQoDUeqE.mjs → plugin-common-Bmz4Ife-.mjs} +37 -9
- package/lib/plugin-common-Bmz4Ife-.mjs.map +1 -0
- package/lib/{translate-BQuSQW35.mjs → translate-C1SMdm5J.mjs} +2 -2
- package/lib/{translate-BQuSQW35.mjs.map → translate-C1SMdm5J.mjs.map} +1 -1
- package/lib/web/plugin.d.mts.map +1 -1
- package/lib/web/plugin.mjs +26 -7
- package/lib/web/plugin.mjs.map +1 -1
- package/package.json +1 -1
- package/lib/exchange-DBRzR2M-.mjs.map +0 -1
- package/lib/import-C4CRBTbi.mjs.map +0 -1
- package/lib/plugin-common-CQoDUeqE.mjs.map +0 -1
- package/lib/session-KoIh3xdB.mjs.map +0 -1
|
@@ -4,6 +4,34 @@ import { EnvHttpProxyAgent } from "undici";
|
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
6
|
import lockfile from "proper-lockfile";
|
|
7
|
+
//#region src/types.ts
|
|
8
|
+
/**
|
|
9
|
+
* Host-independent authentication error. The adapter maps `kind` to the DSH
|
|
10
|
+
* error protocol without coupling the session or CLI layers to dsh-llm.
|
|
11
|
+
*/
|
|
12
|
+
var AgyAuthError = class extends Error {
|
|
13
|
+
kind;
|
|
14
|
+
constructor(kind, message, options) {
|
|
15
|
+
super(message, options);
|
|
16
|
+
this.name = "AgyAuthError";
|
|
17
|
+
this.kind = kind;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Enabled accounts exist, but every candidate is temporarily blocked. Kept
|
|
22
|
+
* independent of dsh-llm so CLI and web entry points do not gain a host import.
|
|
23
|
+
*/
|
|
24
|
+
var AgyPoolBlockedError = class extends Error {
|
|
25
|
+
kind;
|
|
26
|
+
blockedUntil;
|
|
27
|
+
constructor(kind, blockedUntil) {
|
|
28
|
+
super(kind === "quota-exhausted" ? "All agy accounts have exhausted quota for the requested model family." : "All agy accounts are temporarily blocked for the requested model family.");
|
|
29
|
+
this.name = "AgyPoolBlockedError";
|
|
30
|
+
this.kind = kind;
|
|
31
|
+
this.blockedUntil = blockedUntil;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
7
35
|
//#region src/proxy.ts
|
|
8
36
|
/**
|
|
9
37
|
* Proxy-aware fetch: respects the standard HTTP_PROXY / HTTPS_PROXY / NO_PROXY
|
|
@@ -45,10 +73,32 @@ var constants_exports = /* @__PURE__ */ __exportAll({
|
|
|
45
73
|
OAUTH_USERINFO_URL: () => OAUTH_USERINFO_URL,
|
|
46
74
|
fetchAgyFirstOk: () => fetchAgyFirstOk,
|
|
47
75
|
getAgyBootstrapClientMetadata: () => getAgyBootstrapClientMetadata,
|
|
48
|
-
getAgyBootstrapUserAgent: () => getAgyBootstrapUserAgent
|
|
76
|
+
getAgyBootstrapUserAgent: () => getAgyBootstrapUserAgent,
|
|
77
|
+
resolveAgyClientCredentials: () => resolveAgyClientCredentials
|
|
49
78
|
});
|
|
50
79
|
const AGY_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
|
|
51
80
|
const AGY_CLIENT_SECRET = "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";
|
|
81
|
+
/**
|
|
82
|
+
* Effective OAuth client credentials: AGY_CLIENT_ID / AGY_CLIENT_SECRET env
|
|
83
|
+
* overrides win when set (BYO OAuth app escape hatch, mirrors pi-antigravity);
|
|
84
|
+
* otherwise the embedded public Antigravity credentials are used.
|
|
85
|
+
*/
|
|
86
|
+
function resolveAgyClientCredentials(overrideClientId) {
|
|
87
|
+
if (overrideClientId) {
|
|
88
|
+
if (overrideClientId === "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com") return {
|
|
89
|
+
clientId: AGY_CLIENT_ID,
|
|
90
|
+
clientSecret: AGY_CLIENT_SECRET
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
clientId: overrideClientId,
|
|
94
|
+
clientSecret: process.env.AGY_CLIENT_SECRET || "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"
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
clientId: process.env.AGY_CLIENT_ID || "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
|
99
|
+
clientSecret: process.env.AGY_CLIENT_SECRET || "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"
|
|
100
|
+
};
|
|
101
|
+
}
|
|
52
102
|
/** Required scopes. `openid` must NOT be added: it routes Google into the hanging
|
|
53
103
|
* `firstparty/nativeapp` consent for this client (verified by OmniRoute). */
|
|
54
104
|
const AGY_SCOPES = [
|
|
@@ -332,8 +382,10 @@ function parseOAuthErrorPayload(text) {
|
|
|
332
382
|
/**
|
|
333
383
|
* Refresh the access token for an account. `revoked` means Google rejected the
|
|
334
384
|
* refresh token (`invalid_grant`) — the account must be re-authenticated.
|
|
385
|
+
* For legacy accounts without a stored clientId, embedded credentials are tried
|
|
386
|
+
* first with an env-override fallback before treating invalid_grant as fatal.
|
|
335
387
|
*/
|
|
336
|
-
async function refreshAccessToken(auth) {
|
|
388
|
+
async function refreshAccessToken(auth, options) {
|
|
337
389
|
const parts = parseRefreshParts(auth.refresh);
|
|
338
390
|
if (!parts.refreshToken) return {
|
|
339
391
|
type: "failed",
|
|
@@ -343,16 +395,20 @@ async function refreshAccessToken(auth) {
|
|
|
343
395
|
statusText: "Bad Request"
|
|
344
396
|
})
|
|
345
397
|
};
|
|
346
|
-
|
|
398
|
+
const candidateIds = options?.clientId ? [options.clientId] : Array.from(new Set([AGY_CLIENT_ID, process.env.AGY_CLIENT_ID].filter((id) => Boolean(id && id.length > 0))));
|
|
399
|
+
let revokedCount = 0;
|
|
400
|
+
let transientFailure;
|
|
401
|
+
for (const currentClientId of candidateIds) try {
|
|
347
402
|
const startTime = Date.now();
|
|
403
|
+
const { clientId, clientSecret } = resolveAgyClientCredentials(currentClientId);
|
|
348
404
|
const response = await proxiedFetch(OAUTH_TOKEN_URL, {
|
|
349
405
|
method: "POST",
|
|
350
406
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
351
407
|
body: new URLSearchParams({
|
|
352
408
|
grant_type: "refresh_token",
|
|
353
409
|
refresh_token: parts.refreshToken,
|
|
354
|
-
client_id:
|
|
355
|
-
client_secret:
|
|
410
|
+
client_id: clientId,
|
|
411
|
+
client_secret: clientSecret
|
|
356
412
|
})
|
|
357
413
|
});
|
|
358
414
|
if (!response.ok) {
|
|
@@ -361,8 +417,11 @@ async function refreshAccessToken(auth) {
|
|
|
361
417
|
const details = [code, description ?? errorText].filter(Boolean).join(": ");
|
|
362
418
|
const baseMessage = `Agy token refresh failed (${response.status} ${response.statusText})`;
|
|
363
419
|
const message = details ? `${baseMessage} - ${details}` : baseMessage;
|
|
364
|
-
if (code === "invalid_grant")
|
|
365
|
-
|
|
420
|
+
if (code === "invalid_grant") {
|
|
421
|
+
revokedCount++;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
transientFailure = {
|
|
366
425
|
type: "failed",
|
|
367
426
|
error: new AgyTokenRefreshError({
|
|
368
427
|
message,
|
|
@@ -372,6 +431,7 @@ async function refreshAccessToken(auth) {
|
|
|
372
431
|
statusText: response.statusText
|
|
373
432
|
})
|
|
374
433
|
};
|
|
434
|
+
continue;
|
|
375
435
|
}
|
|
376
436
|
const payload = await response.json();
|
|
377
437
|
const refreshedParts = {
|
|
@@ -385,421 +445,324 @@ async function refreshAccessToken(auth) {
|
|
|
385
445
|
access: payload.access_token,
|
|
386
446
|
expires: calculateTokenExpiry(startTime, payload.expires_in),
|
|
387
447
|
refresh: formatRefreshParts(refreshedParts)
|
|
388
|
-
}
|
|
448
|
+
},
|
|
449
|
+
clientId
|
|
389
450
|
};
|
|
390
451
|
} catch (error) {
|
|
391
|
-
|
|
392
|
-
return {
|
|
452
|
+
transientFailure = {
|
|
393
453
|
type: "failed",
|
|
394
454
|
error: new AgyTokenRefreshError({
|
|
395
455
|
message: error instanceof Error ? error.message : "Unknown refresh error",
|
|
396
456
|
status: 0,
|
|
397
|
-
statusText: ""
|
|
457
|
+
statusText: "Network Error"
|
|
398
458
|
})
|
|
399
459
|
};
|
|
400
460
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
//#region src/store/keyring.ts
|
|
404
|
-
/**
|
|
405
|
-
* Master-key management and AES-256-GCM secret codec for the account store.
|
|
406
|
-
*
|
|
407
|
-
* The master key lives in the DSH credentials document (`~/.dsh/.credentials.yaml`,
|
|
408
|
-
* 0600) under `AGY_MASTER_KEY` so both the in-harness plugin (via
|
|
409
|
-
* `ctx.credentials`) and the standalone `dsh-agy` CLI (direct file read) can
|
|
410
|
-
* encrypt and decrypt the same account store.
|
|
411
|
-
*/
|
|
412
|
-
const MASTER_KEY_REF = "AGY_MASTER_KEY";
|
|
413
|
-
const ENC_PREFIX$1 = "enc:v1:";
|
|
414
|
-
/** AES-256-GCM codec; ciphertext format `enc:v1:<iv-b64>:<tag-b64>:<data-b64>`. */
|
|
415
|
-
function createAesGcmCodec(key) {
|
|
416
|
-
if (key.length !== 32) throw new Error(`createAesGcmCodec: master key must be 32 bytes, got ${key.length}`);
|
|
461
|
+
if (transientFailure) return transientFailure;
|
|
462
|
+
if (revokedCount === candidateIds.length) return { type: "revoked" };
|
|
417
463
|
return {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
},
|
|
425
|
-
decrypt(payload) {
|
|
426
|
-
if (!payload.startsWith(ENC_PREFIX$1)) throw new Error("decrypt: payload is not in encrypted format");
|
|
427
|
-
const [, , ivB64 = "", tagB64 = "", dataB64 = ""] = payload.split(":");
|
|
428
|
-
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivB64, "base64url"));
|
|
429
|
-
decipher.setAuthTag(Buffer.from(tagB64, "base64url"));
|
|
430
|
-
return Buffer.concat([decipher.update(Buffer.from(dataB64, "base64url")), decipher.final()]).toString("utf8");
|
|
431
|
-
}
|
|
464
|
+
type: "failed",
|
|
465
|
+
error: new AgyTokenRefreshError({
|
|
466
|
+
message: "Token refresh failed on all candidate client IDs",
|
|
467
|
+
status: 400,
|
|
468
|
+
statusText: "Bad Request"
|
|
469
|
+
})
|
|
432
470
|
};
|
|
433
471
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
472
|
+
//#endregion
|
|
473
|
+
//#region src/runtime/rotation.ts
|
|
474
|
+
const BACKOFF_TIERS_MS = [
|
|
475
|
+
5e3,
|
|
476
|
+
1e4,
|
|
477
|
+
2e4,
|
|
478
|
+
3e4,
|
|
479
|
+
6e4
|
|
480
|
+
];
|
|
481
|
+
/** Below this remaining fraction the account is treated as soft-quota-exhausted. */
|
|
482
|
+
const SOFT_QUOTA_THRESHOLD = .15;
|
|
483
|
+
function backoffFor(consecutiveFailures, maxJitterMs = 1e3) {
|
|
484
|
+
const index = Math.min(Math.max(consecutiveFailures, 0), BACKOFF_TIERS_MS.length - 1);
|
|
485
|
+
return (BACKOFF_TIERS_MS[index] ?? BACKOFF_TIERS_MS[BACKOFF_TIERS_MS.length - 1]) + Math.floor(Math.random() * maxJitterMs);
|
|
437
486
|
}
|
|
438
|
-
/**
|
|
439
|
-
function
|
|
440
|
-
return
|
|
487
|
+
/** Whether the account is currently in a cooldown window. */
|
|
488
|
+
function isCoolingDown(account, now = Date.now()) {
|
|
489
|
+
return (account.coolingDownUntil ?? 0) > now;
|
|
441
490
|
}
|
|
442
|
-
|
|
443
|
-
|
|
491
|
+
/** Whether the requested model family on this account is rate-limited. */
|
|
492
|
+
function isFamilyRateLimited(account, family, now = Date.now()) {
|
|
493
|
+
if (!family) return false;
|
|
494
|
+
const resetAt = account.rateLimitResetTimes?.[family];
|
|
495
|
+
return typeof resetAt === "number" && resetAt > now;
|
|
496
|
+
}
|
|
497
|
+
/** Record a rate-limit reset for one model key, retaining the latest reset time. */
|
|
498
|
+
function recordRateLimit(account, modelKey, resetAtMs) {
|
|
499
|
+
const current = account.rateLimitResetTimes?.[modelKey] ?? 0;
|
|
500
|
+
account.rateLimitResetTimes = {
|
|
501
|
+
...account.rateLimitResetTimes ?? {},
|
|
502
|
+
[modelKey]: Math.max(current, resetAtMs)
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/** Clear expired rate limits and cooldowns in place. */
|
|
506
|
+
function clearExpiredState(account, now = Date.now()) {
|
|
507
|
+
if (account.rateLimitResetTimes) {
|
|
508
|
+
const fresh = Object.fromEntries(Object.entries(account.rateLimitResetTimes).filter(([, reset]) => reset > now));
|
|
509
|
+
account.rateLimitResetTimes = Object.keys(fresh).length > 0 ? fresh : void 0;
|
|
510
|
+
}
|
|
511
|
+
if (account.coolingDownUntil && account.coolingDownUntil <= now) {
|
|
512
|
+
account.coolingDownUntil = void 0;
|
|
513
|
+
account.cooldownReason = void 0;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/** 24h cooldown for a fully exhausted daily quota (single-account: stop hitting the wall). */
|
|
517
|
+
const FULL_QUOTA_COOLDOWN_MS = 864e5;
|
|
518
|
+
/** Cap for a server-reported reset time on per-minute limits (guards against bogus far-future values). */
|
|
519
|
+
const MAX_RATE_LIMIT_COOLDOWN_MS = 18e5;
|
|
520
|
+
/** Absolute server-reported reset in ms when it lies in the future, else undefined. */
|
|
521
|
+
function parseFutureResetMs$1(resetTime, now = Date.now()) {
|
|
522
|
+
if (!resetTime) return void 0;
|
|
523
|
+
const reset = Date.parse(resetTime);
|
|
524
|
+
if (Number.isNaN(reset) || reset <= now) return void 0;
|
|
525
|
+
return reset;
|
|
444
526
|
}
|
|
445
527
|
/**
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
528
|
+
* Decide what to do after one failed attempt.
|
|
529
|
+
* @param kind - classified failure kind.
|
|
530
|
+
* @param category - 429 sub-category when kind is rate-limit.
|
|
531
|
+
* @param account - the account that failed (mutated with cooldown/rate-limit state).
|
|
532
|
+
* @param consecutiveFailures - consecutive failures on this account.
|
|
533
|
+
* @param retryAfterMs - server-provided retry delay when present.
|
|
534
|
+
* @param resetTime - server-provided absolute reset time; cooldowns use it (capped) instead of fixed windows.
|
|
449
535
|
*/
|
|
450
|
-
function
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
536
|
+
function decideRotation(kind, account, consecutiveFailures, retryAfterMs, category = "unknown", resetTime) {
|
|
537
|
+
const now = Date.now();
|
|
538
|
+
const backoffMs = backoffFor(consecutiveFailures);
|
|
539
|
+
switch (kind) {
|
|
540
|
+
case "rate-limit": {
|
|
541
|
+
if (category === "soft_rate_limit") return {
|
|
542
|
+
action: "retry",
|
|
543
|
+
backoffMs: Math.min(retryAfterMs ?? backoffMs, 3e3)
|
|
544
|
+
};
|
|
545
|
+
if (category === "quota_exhausted") {
|
|
546
|
+
const resetMs = parseFutureResetMs$1(resetTime, now);
|
|
547
|
+
const cooldownMs = resetMs !== void 0 ? Math.min(resetMs - now, FULL_QUOTA_COOLDOWN_MS) : FULL_QUOTA_COOLDOWN_MS;
|
|
548
|
+
account.coolingDownUntil = now + Math.max(cooldownMs, 6e4);
|
|
549
|
+
account.cooldownReason = "quota-exhausted";
|
|
550
|
+
return {
|
|
551
|
+
action: "cool",
|
|
552
|
+
backoffMs: Math.max(cooldownMs, 6e4)
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
const resetMs = parseFutureResetMs$1(resetTime, now);
|
|
556
|
+
const cooldownMs = resetMs !== void 0 ? Math.min(resetMs - now, MAX_RATE_LIMIT_COOLDOWN_MS) : retryAfterMs ?? 3e5;
|
|
557
|
+
return {
|
|
558
|
+
action: "rotate",
|
|
559
|
+
backoffMs: Math.max(cooldownMs, 1e3)
|
|
560
|
+
};
|
|
465
561
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
562
|
+
case "auth-failure":
|
|
563
|
+
account.verificationRequired = true;
|
|
564
|
+
account.verificationRequiredAt = now;
|
|
565
|
+
account.verificationRequiredReason = "auth-failure";
|
|
566
|
+
account.enabled = false;
|
|
567
|
+
return { action: "revoke" };
|
|
568
|
+
case "network-error":
|
|
569
|
+
account.coolingDownUntil = now + backoffMs;
|
|
570
|
+
account.cooldownReason = "network-error";
|
|
571
|
+
return {
|
|
572
|
+
action: "rotate",
|
|
573
|
+
backoffMs
|
|
574
|
+
};
|
|
575
|
+
case "project-error":
|
|
576
|
+
account.coolingDownUntil = now + backoffMs;
|
|
577
|
+
account.cooldownReason = "project-error";
|
|
578
|
+
return {
|
|
579
|
+
action: "cool",
|
|
580
|
+
backoffMs
|
|
581
|
+
};
|
|
582
|
+
case "request-error": return { action: "noop" };
|
|
583
|
+
case "transient": return {
|
|
584
|
+
action: "retry",
|
|
585
|
+
backoffMs
|
|
586
|
+
};
|
|
472
587
|
}
|
|
473
|
-
return entries;
|
|
474
588
|
}
|
|
475
589
|
/**
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
* account file and credentials document remain the only defense-in-depth
|
|
479
|
-
* layer on that platform.
|
|
590
|
+
* Pick the next account index for rotation (round-robin across enabled,
|
|
591
|
+
* non-cooling accounts; falls back to the active one when all are cooling).
|
|
480
592
|
*/
|
|
481
|
-
function
|
|
482
|
-
if (
|
|
483
|
-
const
|
|
484
|
-
|
|
593
|
+
function pickNextAccountIndex(accounts, currentIndex, now = Date.now(), modelOrFamily) {
|
|
594
|
+
if (accounts.length <= 1) return currentIndex;
|
|
595
|
+
const enabled = accounts.map((a, i) => ({
|
|
596
|
+
account: a,
|
|
597
|
+
index: i
|
|
598
|
+
})).filter(({ account, index }) => {
|
|
599
|
+
if (index === currentIndex || account.enabled === false) return false;
|
|
600
|
+
if (isCoolingDown(account, now)) return false;
|
|
601
|
+
if (modelOrFamily && isFamilyRateLimited(account, modelOrFamily, now)) return false;
|
|
602
|
+
return true;
|
|
603
|
+
});
|
|
604
|
+
if (enabled.length === 0) return currentIndex;
|
|
605
|
+
return (enabled.find((e) => e.index > currentIndex) ?? enabled[0]).index;
|
|
485
606
|
}
|
|
486
|
-
/**
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
607
|
+
/** Build the soft-quota cache TTL: short when low, long when healthy. */
|
|
608
|
+
function computeSoftQuotaCacheTtlMs(remainingFraction, now = Date.now()) {
|
|
609
|
+
if (typeof remainingFraction !== "number") return 6e5;
|
|
610
|
+
if (remainingFraction < .15) return 6e4;
|
|
611
|
+
if (remainingFraction < .5) return 3e5;
|
|
612
|
+
return 9e5;
|
|
613
|
+
}
|
|
614
|
+
const DAY_MS = 864e5;
|
|
615
|
+
/** Floor for remaining-time in drain-urgency scores (mirrors AuthStorage; a stale reset must not explode the score). */
|
|
616
|
+
const DRAIN_FLOOR_MS = 6e4;
|
|
617
|
+
/** Map a model id to its backend quota counter family (OMP getAntigravityCounterKeyForModel). */
|
|
618
|
+
function modelFamilyOf(modelId) {
|
|
619
|
+
if (!modelId) return void 0;
|
|
620
|
+
const id = modelId.toLowerCase();
|
|
621
|
+
if (id.startsWith("claude-")) return "anthropic";
|
|
622
|
+
if (id.startsWith("gemini-") || id.startsWith("gemma-")) return "google";
|
|
623
|
+
if (id.startsWith("gpt-") || id.startsWith("openai/")) return "openai";
|
|
624
|
+
}
|
|
625
|
+
/** Quota-cache key for a request: the model's family, or the unknown bucket. */
|
|
626
|
+
function familyKeyOf(modelId) {
|
|
627
|
+
return modelFamilyOf(modelId) ?? "unknown";
|
|
628
|
+
}
|
|
629
|
+
function earliestResetTime(a, b) {
|
|
630
|
+
if (!a) return b;
|
|
631
|
+
if (!b) return a;
|
|
632
|
+
const ta = Date.parse(a);
|
|
633
|
+
const tb = Date.parse(b);
|
|
634
|
+
if (Number.isNaN(ta)) return b;
|
|
635
|
+
if (Number.isNaN(tb)) return a;
|
|
636
|
+
return ta <= tb ? a : b;
|
|
495
637
|
}
|
|
496
638
|
/**
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
* preserves comments and the formatting of untouched entries (and may hold
|
|
501
|
-
* YAML constructs this minimal reader cannot parse). Rewriting the whole file
|
|
502
|
-
* from our parsed view would silently drop those; appending keeps them.
|
|
639
|
+
* Aggregate a fetchAvailableModels response into per-family quota records:
|
|
640
|
+
* the family's remaining fraction is its most-pressured model's, and the
|
|
641
|
+
* reset time is the earliest across the family (the bottleneck resets first).
|
|
503
642
|
*/
|
|
504
|
-
function
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
643
|
+
function ingestFamilyQuotas(discovered) {
|
|
644
|
+
const families = /* @__PURE__ */ new Map();
|
|
645
|
+
for (const [modelId, entry] of Object.entries(discovered.models ?? {})) {
|
|
646
|
+
const remaining = entry.quotaInfo?.remainingFraction;
|
|
647
|
+
if (typeof remaining !== "number" || !Number.isFinite(remaining)) continue;
|
|
648
|
+
const key = familyKeyOf(modelId);
|
|
649
|
+
const current = families.get(key);
|
|
650
|
+
const resetTime = earliestResetTime(current?.resetTime, entry.quotaInfo?.resetTime);
|
|
651
|
+
families.set(key, {
|
|
652
|
+
remainingFraction: current ? Math.min(current.remainingFraction ?? 1, remaining) : remaining,
|
|
653
|
+
...resetTime ? { resetTime } : {},
|
|
654
|
+
modelCount: (current?.modelCount ?? 0) + 1
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
return Object.fromEntries(families);
|
|
658
|
+
}
|
|
659
|
+
/** The quota record for one family, or the most-pressured family when the model is unknown. */
|
|
660
|
+
function familyQuotaFor(account, family) {
|
|
661
|
+
const cache = account.cachedQuota ?? {};
|
|
662
|
+
if (family) return cache[family];
|
|
663
|
+
let worst;
|
|
664
|
+
for (const entry of Object.values(cache)) {
|
|
665
|
+
if (typeof entry.remainingFraction !== "number") continue;
|
|
666
|
+
if (!worst || entry.remainingFraction < (worst.remainingFraction ?? 1)) worst = entry;
|
|
667
|
+
}
|
|
668
|
+
return worst;
|
|
669
|
+
}
|
|
670
|
+
/** Whether the account's quota cache needs a refresh (missing, or past its health-based TTL). */
|
|
671
|
+
function isQuotaStale(account, now = Date.now()) {
|
|
672
|
+
if (!account.cachedQuota || !account.cachedQuotaUpdatedAt) return true;
|
|
673
|
+
const ttl = computeSoftQuotaCacheTtlMs(familyQuotaFor(account)?.remainingFraction);
|
|
674
|
+
return now - account.cachedQuotaUpdatedAt > ttl;
|
|
675
|
+
}
|
|
676
|
+
/** Whether the requested family on this account is soft-quota-exhausted (below the pre-check threshold). */
|
|
677
|
+
function isFamilyDrained(account, family, now = Date.now()) {
|
|
678
|
+
const quota = familyQuotaFor(account, family);
|
|
679
|
+
if (!quota || typeof quota.remainingFraction !== "number") return false;
|
|
680
|
+
if (quota.resetTime) {
|
|
681
|
+
const reset = Date.parse(quota.resetTime);
|
|
682
|
+
if (!Number.isNaN(reset) && reset <= now) return false;
|
|
683
|
+
}
|
|
684
|
+
return quota.remainingFraction < SOFT_QUOTA_THRESHOLD;
|
|
514
685
|
}
|
|
515
686
|
/**
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
687
|
+
* Required drain rate: headroomFraction / remainingHours — how fast the
|
|
688
|
+
* family's remaining quota must be consumed to avoid expiring unused at its
|
|
689
|
+
* reset (mirrors AuthStorage.#computeWindowRequiredDrain with a daily window).
|
|
519
690
|
*/
|
|
520
|
-
function
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
691
|
+
function requiredDrainFor(quota, now = Date.now()) {
|
|
692
|
+
const remaining = quota?.remainingFraction;
|
|
693
|
+
if (typeof remaining !== "number" || !Number.isFinite(remaining)) return 0;
|
|
694
|
+
const headroom = Math.min(Math.max(remaining, 0), 1);
|
|
695
|
+
if (headroom <= 0) return 0;
|
|
696
|
+
let remainingMs = DAY_MS;
|
|
697
|
+
const resetTime = quota?.resetTime;
|
|
698
|
+
if (resetTime) {
|
|
699
|
+
const resetAt = Date.parse(resetTime);
|
|
700
|
+
if (!Number.isNaN(resetAt)) remainingMs = Math.min(remainingMs, Math.max(resetAt - now, 0));
|
|
527
701
|
}
|
|
528
|
-
return
|
|
529
|
-
codec: createAesGcmCodec(deriveKey(masterKey)),
|
|
530
|
-
created
|
|
531
|
-
};
|
|
702
|
+
return headroom / (Math.max(remainingMs, DRAIN_FLOOR_MS) / 36e5);
|
|
532
703
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
704
|
+
function parseFutureResetMs(resetTime, now) {
|
|
705
|
+
if (!resetTime) return void 0;
|
|
706
|
+
const reset = Date.parse(resetTime);
|
|
707
|
+
if (Number.isNaN(reset) || reset <= now) return void 0;
|
|
708
|
+
return reset;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Rank pool candidates for one request, mirroring AuthStorage's antigravity
|
|
712
|
+
* ordering: unblocked first (earliest unblock time among blocked), hot windows
|
|
713
|
+
* last, measured usage before unmeasured, required-drain descending, then
|
|
714
|
+
* used-fraction ascending. Ties preserve the rotation order seeded from
|
|
715
|
+
* `startIndex` so an unmeasured pool keeps the active-account bias.
|
|
716
|
+
*/
|
|
717
|
+
function rankPoolCandidates(entries, modelId, now = Date.now(), startIndex = 0) {
|
|
718
|
+
const family = modelFamilyOf(modelId);
|
|
719
|
+
const activePos = entries.findIndex((e) => e.index === startIndex);
|
|
720
|
+
const clampedStart = activePos >= 0 ? activePos : 0;
|
|
721
|
+
const candidates = (entries.length === 0 ? [] : [...entries.slice(clampedStart), ...entries.slice(0, clampedStart)]).map(({ account, index }, orderPos) => {
|
|
722
|
+
const quota = familyQuotaFor(account, family);
|
|
723
|
+
const remaining = quota?.remainingFraction;
|
|
724
|
+
const used = typeof remaining === "number" ? Math.min(Math.max(1 - remaining, 0), 1) : void 0;
|
|
725
|
+
let blockedUntil = null;
|
|
726
|
+
if (account.coolingDownUntil && account.coolingDownUntil > now) blockedUntil = account.coolingDownUntil;
|
|
727
|
+
const familyLimit = account.rateLimitResetTimes?.[familyKeyOf(modelId)];
|
|
728
|
+
if (familyLimit !== void 0 && familyLimit > now) blockedUntil = blockedUntil === null ? familyLimit : Math.max(blockedUntil, familyLimit);
|
|
729
|
+
if (blockedUntil === null && quota && typeof remaining === "number" && remaining <= 0) {
|
|
730
|
+
const resetMs = parseFutureResetMs(quota.resetTime, now);
|
|
731
|
+
if (resetMs !== void 0) blockedUntil = resetMs;
|
|
543
732
|
}
|
|
733
|
+
return {
|
|
734
|
+
account,
|
|
735
|
+
index,
|
|
736
|
+
orderPos,
|
|
737
|
+
blockedUntil,
|
|
738
|
+
usedFraction: used,
|
|
739
|
+
requiredDrain: requiredDrainFor(quota, now),
|
|
740
|
+
hot: used !== void 0 && used >= .85,
|
|
741
|
+
measured: used !== void 0
|
|
742
|
+
};
|
|
544
743
|
});
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
};
|
|
568
|
-
return account;
|
|
569
|
-
}
|
|
570
|
-
function decryptStorage(storage, codec) {
|
|
571
|
-
return {
|
|
572
|
-
...storage,
|
|
573
|
-
accounts: storage.accounts.map((a) => decryptAccount(a, codec))
|
|
574
|
-
};
|
|
575
|
-
}
|
|
576
|
-
function encryptStorage(storage, codec) {
|
|
577
|
-
return {
|
|
578
|
-
...storage,
|
|
579
|
-
accounts: storage.accounts.map((a) => encryptAccount(a, codec))
|
|
580
|
-
};
|
|
581
|
-
}
|
|
582
|
-
function migrateV1ToV2(v1) {
|
|
583
|
-
return {
|
|
584
|
-
version: 2,
|
|
585
|
-
accounts: v1.accounts.map((acc) => ({
|
|
586
|
-
email: acc.email,
|
|
587
|
-
refreshToken: acc.refreshToken,
|
|
588
|
-
projectId: acc.projectId,
|
|
589
|
-
managedProjectId: acc.managedProjectId,
|
|
590
|
-
addedAt: acc.addedAt,
|
|
591
|
-
lastUsed: acc.lastUsed,
|
|
592
|
-
lastSwitchReason: acc.lastSwitchReason,
|
|
593
|
-
rateLimitResetTimes: acc.isRateLimited && acc.rateLimitResetTime ? { default: acc.rateLimitResetTime } : void 0
|
|
594
|
-
})),
|
|
595
|
-
activeIndex: v1.activeIndex
|
|
596
|
-
};
|
|
597
|
-
}
|
|
598
|
-
function migrateV2ToV3(v2) {
|
|
599
|
-
return {
|
|
600
|
-
version: 3,
|
|
601
|
-
accounts: v2.accounts.map((acc) => ({
|
|
602
|
-
email: acc.email,
|
|
603
|
-
refresh: `${acc.refreshToken}|${acc.projectId ?? ""}|${acc.managedProjectId ?? ""}`,
|
|
604
|
-
projectId: acc.projectId,
|
|
605
|
-
managedProjectId: acc.managedProjectId,
|
|
606
|
-
addedAt: acc.addedAt,
|
|
607
|
-
lastUsed: acc.lastUsed,
|
|
608
|
-
enabled: true,
|
|
609
|
-
lastSwitchReason: acc.lastSwitchReason,
|
|
610
|
-
rateLimitResetTimes: acc.rateLimitResetTimes
|
|
611
|
-
})),
|
|
612
|
-
activeIndex: v2.activeIndex
|
|
613
|
-
};
|
|
614
|
-
}
|
|
615
|
-
function migrateV3ToV4(v3) {
|
|
616
|
-
return {
|
|
617
|
-
version: 4,
|
|
618
|
-
accounts: v3.accounts,
|
|
619
|
-
activeIndex: v3.activeIndex
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
function migrateStorage(raw) {
|
|
623
|
-
switch (raw.version) {
|
|
624
|
-
case 1: return migrateStorage(migrateV1ToV2(raw));
|
|
625
|
-
case 2: return migrateStorage(migrateV2ToV3(raw));
|
|
626
|
-
case 3: return migrateStorage(migrateV3ToV4(raw));
|
|
627
|
-
case 4: return raw;
|
|
628
|
-
default: throw new Error(`migrateStorage: unsupported storage version ${raw.version}`);
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
var JsonAccountStore = class {
|
|
632
|
-
file;
|
|
633
|
-
codec;
|
|
634
|
-
lock;
|
|
635
|
-
constructor(options) {
|
|
636
|
-
this.file = options.file;
|
|
637
|
-
this.codec = options.codec;
|
|
638
|
-
this.lock = options.lock ?? properFileLock;
|
|
639
|
-
}
|
|
640
|
-
/**
|
|
641
|
-
* Ensure the store document exists before locking: proper-lockfile resolves
|
|
642
|
-
* the target's realpath and fails with ENOENT when it is absent.
|
|
643
|
-
*/
|
|
644
|
-
ensureFile() {
|
|
645
|
-
if (existsSync(this.file)) return;
|
|
646
|
-
mkdirSync(dirname(this.file), { recursive: true });
|
|
647
|
-
const tmp = `${this.file}.tmp-init`;
|
|
648
|
-
writeFileSync(tmp, JSON.stringify({
|
|
649
|
-
version: 4,
|
|
650
|
-
accounts: [],
|
|
651
|
-
activeIndex: 0
|
|
652
|
-
}) + "\n", { mode: 384 });
|
|
653
|
-
renameSync(tmp, this.file);
|
|
654
|
-
}
|
|
655
|
-
async load() {
|
|
656
|
-
let text;
|
|
657
|
-
try {
|
|
658
|
-
text = readFileSync(this.file, "utf8");
|
|
659
|
-
} catch (error) {
|
|
660
|
-
if (error.code === "ENOENT") return {
|
|
661
|
-
version: 4,
|
|
662
|
-
accounts: [],
|
|
663
|
-
activeIndex: 0
|
|
664
|
-
};
|
|
665
|
-
throw error;
|
|
666
|
-
}
|
|
667
|
-
assertOwnerOnly(this.file);
|
|
668
|
-
return decryptStorage(migrateStorage(JSON.parse(text)), this.codec);
|
|
669
|
-
}
|
|
670
|
-
async save(storage) {
|
|
671
|
-
const encrypted = encryptStorage(storage, this.codec);
|
|
672
|
-
mkdirSync(dirname(this.file), { recursive: true });
|
|
673
|
-
const tmp = `${this.file}.tmp`;
|
|
674
|
-
writeFileSync(tmp, JSON.stringify(encrypted, null, 2) + "\n", { mode: 384 });
|
|
675
|
-
renameSync(tmp, this.file);
|
|
676
|
-
}
|
|
677
|
-
async mutate(fn) {
|
|
678
|
-
this.ensureFile();
|
|
679
|
-
return this.lock.withLock(this.file, async () => {
|
|
680
|
-
const storage = await this.load();
|
|
681
|
-
const result = await fn(storage);
|
|
682
|
-
await this.save(storage);
|
|
683
|
-
return result;
|
|
684
|
-
});
|
|
685
|
-
}
|
|
686
|
-
};
|
|
687
|
-
/** Pick the active account, falling back to the first enabled one. */
|
|
688
|
-
function resolveActiveAccount(storage) {
|
|
689
|
-
const accounts = storage.accounts;
|
|
690
|
-
if (accounts.length === 0) return void 0;
|
|
691
|
-
const active = accounts[storage.activeIndex];
|
|
692
|
-
if (active && active.enabled !== false) return {
|
|
693
|
-
account: active,
|
|
694
|
-
index: storage.activeIndex
|
|
695
|
-
};
|
|
696
|
-
const fallbackIndex = accounts.findIndex((a) => a.enabled !== false);
|
|
697
|
-
if (fallbackIndex === -1) return void 0;
|
|
698
|
-
return {
|
|
699
|
-
account: accounts[fallbackIndex],
|
|
700
|
-
index: fallbackIndex
|
|
701
|
-
};
|
|
702
|
-
}
|
|
703
|
-
//#endregion
|
|
704
|
-
//#region src/runtime/rotation.ts
|
|
705
|
-
const BACKOFF_TIERS_MS = [
|
|
706
|
-
5e3,
|
|
707
|
-
1e4,
|
|
708
|
-
2e4,
|
|
709
|
-
3e4,
|
|
710
|
-
6e4
|
|
711
|
-
];
|
|
712
|
-
function backoffFor(consecutiveFailures, maxJitterMs = 1e3) {
|
|
713
|
-
const index = Math.min(Math.max(consecutiveFailures, 0), BACKOFF_TIERS_MS.length - 1);
|
|
714
|
-
return (BACKOFF_TIERS_MS[index] ?? BACKOFF_TIERS_MS[BACKOFF_TIERS_MS.length - 1]) + Math.floor(Math.random() * maxJitterMs);
|
|
715
|
-
}
|
|
716
|
-
/** Whether the account is currently in a cooldown window. */
|
|
717
|
-
function isCoolingDown(account, now = Date.now()) {
|
|
718
|
-
return (account.coolingDownUntil ?? 0) > now;
|
|
719
|
-
}
|
|
720
|
-
/** Clear expired rate limits and cooldowns in place. */
|
|
721
|
-
function clearExpiredState(account, now = Date.now()) {
|
|
722
|
-
if (account.rateLimitResetTimes) {
|
|
723
|
-
const fresh = Object.fromEntries(Object.entries(account.rateLimitResetTimes).filter(([, reset]) => reset > now));
|
|
724
|
-
account.rateLimitResetTimes = Object.keys(fresh).length > 0 ? fresh : void 0;
|
|
725
|
-
}
|
|
726
|
-
if (account.coolingDownUntil && account.coolingDownUntil <= now) {
|
|
727
|
-
account.coolingDownUntil = void 0;
|
|
728
|
-
account.cooldownReason = void 0;
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
/** 24h cooldown for a fully exhausted daily quota (single-account: stop hitting the wall). */
|
|
732
|
-
const FULL_QUOTA_COOLDOWN_MS = 864e5;
|
|
733
|
-
/**
|
|
734
|
-
* Decide what to do after one failed attempt.
|
|
735
|
-
* @param kind - classified failure kind.
|
|
736
|
-
* @param category - 429 sub-category when kind is rate-limit.
|
|
737
|
-
* @param account - the account that failed (mutated with cooldown/rate-limit state).
|
|
738
|
-
* @param consecutiveFailures - consecutive failures on this account.
|
|
739
|
-
* @param retryAfterMs - server-provided retry delay when present.
|
|
740
|
-
*/
|
|
741
|
-
function decideRotation(kind, account, consecutiveFailures, retryAfterMs, category = "unknown") {
|
|
742
|
-
const now = Date.now();
|
|
743
|
-
const backoffMs = backoffFor(consecutiveFailures);
|
|
744
|
-
switch (kind) {
|
|
745
|
-
case "rate-limit":
|
|
746
|
-
if (category === "soft_rate_limit") return {
|
|
747
|
-
action: "retry",
|
|
748
|
-
backoffMs: Math.min(retryAfterMs ?? backoffMs, 3e3)
|
|
749
|
-
};
|
|
750
|
-
if (category === "quota_exhausted") {
|
|
751
|
-
account.coolingDownUntil = now + FULL_QUOTA_COOLDOWN_MS;
|
|
752
|
-
account.cooldownReason = void 0;
|
|
753
|
-
return {
|
|
754
|
-
action: "cool",
|
|
755
|
-
backoffMs: FULL_QUOTA_COOLDOWN_MS
|
|
756
|
-
};
|
|
757
|
-
}
|
|
758
|
-
account.coolingDownUntil = now + (retryAfterMs ?? 3e5);
|
|
759
|
-
account.cooldownReason = void 0;
|
|
760
|
-
return {
|
|
761
|
-
action: "rotate",
|
|
762
|
-
backoffMs
|
|
763
|
-
};
|
|
764
|
-
case "auth-failure":
|
|
765
|
-
account.verificationRequired = true;
|
|
766
|
-
account.verificationRequiredAt = now;
|
|
767
|
-
account.verificationRequiredReason = "auth-failure";
|
|
768
|
-
account.enabled = false;
|
|
769
|
-
return { action: "revoke" };
|
|
770
|
-
case "network-error":
|
|
771
|
-
account.coolingDownUntil = now + backoffMs;
|
|
772
|
-
account.cooldownReason = "network-error";
|
|
773
|
-
return {
|
|
774
|
-
action: "rotate",
|
|
775
|
-
backoffMs
|
|
776
|
-
};
|
|
777
|
-
case "project-error":
|
|
778
|
-
account.coolingDownUntil = now + backoffMs;
|
|
779
|
-
account.cooldownReason = "project-error";
|
|
780
|
-
return {
|
|
781
|
-
action: "cool",
|
|
782
|
-
backoffMs
|
|
783
|
-
};
|
|
784
|
-
case "request-error": return { action: "noop" };
|
|
785
|
-
case "transient": return {
|
|
786
|
-
action: "retry",
|
|
787
|
-
backoffMs
|
|
788
|
-
};
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
/**
|
|
792
|
-
* Pick the next account index for rotation (round-robin across enabled,
|
|
793
|
-
* non-cooling accounts; falls back to the active one when all are cooling).
|
|
794
|
-
*/
|
|
795
|
-
function pickNextAccountIndex(accounts, currentIndex, now = Date.now()) {
|
|
796
|
-
if (accounts.length <= 1) return currentIndex;
|
|
797
|
-
const enabled = accounts.map((a, i) => ({
|
|
798
|
-
account: a,
|
|
799
|
-
index: i
|
|
800
|
-
})).filter(({ account, index }) => index !== currentIndex && account.enabled !== false && !isCoolingDown(account, now));
|
|
801
|
-
if (enabled.length === 0) return currentIndex;
|
|
802
|
-
return (enabled.find((e) => e.index > currentIndex) ?? enabled[0]).index;
|
|
744
|
+
candidates.sort((left, right) => {
|
|
745
|
+
const leftBlocked = left.blockedUntil !== null;
|
|
746
|
+
const rightBlocked = right.blockedUntil !== null;
|
|
747
|
+
if (leftBlocked !== rightBlocked) return leftBlocked ? 1 : -1;
|
|
748
|
+
if (leftBlocked && rightBlocked) return (left.blockedUntil ?? 0) - (right.blockedUntil ?? 0);
|
|
749
|
+
if (left.hot !== right.hot) return left.hot ? 1 : -1;
|
|
750
|
+
if (left.measured !== right.measured) return left.measured ? -1 : 1;
|
|
751
|
+
const drain = right.requiredDrain - left.requiredDrain;
|
|
752
|
+
if (drain !== 0) return drain;
|
|
753
|
+
const usedDiff = (left.usedFraction ?? .5) - (right.usedFraction ?? .5);
|
|
754
|
+
if (usedDiff !== 0) return usedDiff;
|
|
755
|
+
return left.orderPos - right.orderPos;
|
|
756
|
+
});
|
|
757
|
+
return candidates.map(({ account, index, blockedUntil, usedFraction, requiredDrain, hot, measured }) => ({
|
|
758
|
+
account,
|
|
759
|
+
index,
|
|
760
|
+
blockedUntil,
|
|
761
|
+
usedFraction,
|
|
762
|
+
requiredDrain,
|
|
763
|
+
hot,
|
|
764
|
+
measured
|
|
765
|
+
}));
|
|
803
766
|
}
|
|
804
767
|
//#endregion
|
|
805
768
|
//#region src/runtime/fingerprint.ts
|
|
@@ -909,6 +872,19 @@ function getRandomizedHeaders(data = getFingerprintData(), version = randomFrom(
|
|
|
909
872
|
"Client-Metadata": JSON.stringify({ ideType: randomFrom(data.ideTypes) })
|
|
910
873
|
};
|
|
911
874
|
}
|
|
875
|
+
/**
|
|
876
|
+
* Deterministic fallback headers for the `stable` fingerprint mode: the first
|
|
877
|
+
* entry of each pool, every call — one fixed client identity instead of
|
|
878
|
+
* per-request randomization (OMP-style fixed-client posture).
|
|
879
|
+
*/
|
|
880
|
+
function getStableHeaders(data = getFingerprintData(), version = data.versionPool[0] ?? "") {
|
|
881
|
+
const platform = data.platforms[0] ?? "windows/amd64";
|
|
882
|
+
return {
|
|
883
|
+
"User-Agent": `antigravity/${version || "1.18.3"} ${platform}`,
|
|
884
|
+
"X-Goog-Api-Client": data.sdkClients[0] ?? "",
|
|
885
|
+
"Client-Metadata": JSON.stringify({ ideType: data.ideTypes[0] ?? "ANTIGRAVITY" })
|
|
886
|
+
};
|
|
887
|
+
}
|
|
912
888
|
/** Rewrite the version inside a fingerprint UA; reports whether it changed. */
|
|
913
889
|
function updateFingerprintVersion(fingerprint, version) {
|
|
914
890
|
const pattern = /^(antigravity\/)([\d.]+)/;
|
|
@@ -925,10 +901,35 @@ function recordFingerprintVersion(history, fingerprint, reason) {
|
|
|
925
901
|
reason
|
|
926
902
|
}].slice(-5);
|
|
927
903
|
}
|
|
904
|
+
//#endregion
|
|
905
|
+
//#region src/runtime/risk.ts
|
|
906
|
+
const TRUE_VALUES = /* @__PURE__ */ new Set([
|
|
907
|
+
"1",
|
|
908
|
+
"true",
|
|
909
|
+
"yes",
|
|
910
|
+
"on"
|
|
911
|
+
]);
|
|
912
|
+
function envFlag(name) {
|
|
913
|
+
const value = process.env[name];
|
|
914
|
+
return value !== void 0 && TRUE_VALUES.has(value.trim().toLowerCase());
|
|
915
|
+
}
|
|
916
|
+
/** Global kill switch: DSH_AGY_DISABLE=1 keeps the plugin from registering anything. */
|
|
917
|
+
function isAgyDisabled() {
|
|
918
|
+
return envFlag("DSH_AGY_DISABLE");
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Fingerprint strategy: `dynamic` (default, upstream behavior: per-request
|
|
922
|
+
* header randomization + regeneration on repeated rate-limits) or `stable`
|
|
923
|
+
* (one identity per account, never regenerated, deterministic fallback
|
|
924
|
+
* headers — mirrors OMP's fixed-client posture).
|
|
925
|
+
*/
|
|
926
|
+
function fingerprintMode() {
|
|
927
|
+
return process.env.DSH_AGY_FINGERPRINT_MODE === "stable" ? "stable" : "dynamic";
|
|
928
|
+
}
|
|
928
929
|
/**
|
|
929
930
|
* Resolve the impersonation headers for one request from the account's
|
|
930
|
-
* persistent fingerprint (stable identity)
|
|
931
|
-
*
|
|
931
|
+
* persistent fingerprint (stable identity). The fallback randomizes per
|
|
932
|
+
* request in `dynamic` mode and pins one identity in `stable` mode.
|
|
932
933
|
*/
|
|
933
934
|
function impersonationHeadersFor(account) {
|
|
934
935
|
const fingerprint = account.fingerprint;
|
|
@@ -937,22 +938,29 @@ function impersonationHeadersFor(account) {
|
|
|
937
938
|
"X-Goog-Api-Client": fingerprint.apiClient,
|
|
938
939
|
"Client-Metadata": JSON.stringify(fingerprint.clientMetadata)
|
|
939
940
|
};
|
|
940
|
-
const
|
|
941
|
+
const headers = fingerprintMode() === "stable" ? getStableHeaders(DEFAULT_FINGERPRINT_DATA) : getRandomizedHeaders(DEFAULT_FINGERPRINT_DATA);
|
|
941
942
|
return {
|
|
942
|
-
"User-Agent":
|
|
943
|
-
"X-Goog-Api-Client":
|
|
944
|
-
"Client-Metadata":
|
|
943
|
+
"User-Agent": headers["User-Agent"],
|
|
944
|
+
"X-Goog-Api-Client": headers["X-Goog-Api-Client"],
|
|
945
|
+
"Client-Metadata": headers["Client-Metadata"]
|
|
945
946
|
};
|
|
946
947
|
}
|
|
947
|
-
var AgySessionManager = class {
|
|
948
|
+
var AgySessionManager = class AgySessionManager {
|
|
948
949
|
store;
|
|
949
950
|
onRotate;
|
|
951
|
+
onHealthReport;
|
|
950
952
|
tokenCache = /* @__PURE__ */ new Map();
|
|
951
953
|
/** In-flight refresh promises keyed by account: concurrent requests share one refresh. */
|
|
952
954
|
refreshInFlight = /* @__PURE__ */ new Map();
|
|
955
|
+
/** In-flight quota fetches keyed by account: concurrent selections share one fetchAvailableModels call. */
|
|
956
|
+
quotaRefreshInFlight = /* @__PURE__ */ new Map();
|
|
953
957
|
failureCounts = /* @__PURE__ */ new Map();
|
|
954
958
|
/** Accounts whose request-time project discovery already failed (no retry per request). */
|
|
955
959
|
projectRetryFailed = /* @__PURE__ */ new Set();
|
|
960
|
+
/** Bound for one quota poll so selection never stalls on a hung endpoint. */
|
|
961
|
+
static QUOTA_FETCH_TIMEOUT_MS = 3e3;
|
|
962
|
+
/** Refresh the token this far ahead of expiry so a request never blocks on the token endpoint. */
|
|
963
|
+
static REFRESH_SKEW_MS = 12e4;
|
|
956
964
|
/**
|
|
957
965
|
* Session affinity (time-window approximation): DSH exposes no conversation
|
|
958
966
|
* id, so instead of pinning per session we reuse the last-used account while
|
|
@@ -964,23 +972,18 @@ var AgySessionManager = class {
|
|
|
964
972
|
constructor(options) {
|
|
965
973
|
this.store = options.store;
|
|
966
974
|
this.onRotate = options.onRotate;
|
|
975
|
+
this.onHealthReport = options.onHealthReport;
|
|
967
976
|
}
|
|
968
977
|
accountKey(account) {
|
|
969
|
-
return account.email ?? `idx-${account.refresh}`;
|
|
978
|
+
return account.id ?? account.email ?? `idx-${account.refresh}`;
|
|
970
979
|
}
|
|
971
|
-
/**
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
refresh: account.refresh
|
|
979
|
-
})) return {
|
|
980
|
-
access: cached.access,
|
|
981
|
-
expires: cached.expires,
|
|
982
|
-
refresh: account.refresh
|
|
983
|
-
};
|
|
980
|
+
/**
|
|
981
|
+
* Refresh the account's access token (single-flight per account). A transient
|
|
982
|
+
* refresh failure keeps the cached token in place (retain-last-good): the old
|
|
983
|
+
* token stays valid until its own expiry and a later request retries the
|
|
984
|
+
* refresh. Only `invalid_grant` drops the cache.
|
|
985
|
+
*/
|
|
986
|
+
refreshToken(key, account, cached) {
|
|
984
987
|
const inFlight = this.refreshInFlight.get(key);
|
|
985
988
|
if (inFlight) return inFlight;
|
|
986
989
|
const refreshing = (async () => {
|
|
@@ -988,130 +991,287 @@ var AgySessionManager = class {
|
|
|
988
991
|
access: cached?.access ?? "",
|
|
989
992
|
expires: cached?.expires ?? 0,
|
|
990
993
|
refresh: account.refresh
|
|
991
|
-
});
|
|
994
|
+
}, { clientId: account.clientId });
|
|
992
995
|
if (result.type === "success") {
|
|
996
|
+
if (!account.clientId && result.clientId) {
|
|
997
|
+
await this.store.mutate((s) => {
|
|
998
|
+
const target = s.accounts.find((candidate) => this.accountKey(candidate) === key);
|
|
999
|
+
if (!target) throw new Error(`Account ${key} not found during clientId migration`);
|
|
1000
|
+
target.clientId = result.clientId;
|
|
1001
|
+
});
|
|
1002
|
+
account.clientId = result.clientId;
|
|
1003
|
+
}
|
|
993
1004
|
this.tokenCache.set(key, {
|
|
994
1005
|
access: result.auth.access,
|
|
995
1006
|
expires: result.auth.expires
|
|
996
1007
|
});
|
|
997
1008
|
return result.auth;
|
|
998
1009
|
}
|
|
1010
|
+
if (result.type === "failed") {
|
|
1011
|
+
if (cached && !accessTokenExpired({
|
|
1012
|
+
access: cached.access,
|
|
1013
|
+
expires: cached.expires,
|
|
1014
|
+
refresh: account.refresh
|
|
1015
|
+
})) return {
|
|
1016
|
+
access: cached.access,
|
|
1017
|
+
expires: cached.expires,
|
|
1018
|
+
refresh: account.refresh
|
|
1019
|
+
};
|
|
1020
|
+
throw new AgyAuthError(result.error.status === 429 ? "rate-limit" : result.error.status === 0 || result.error.status === 408 || result.error.status >= 500 ? "transport" : "invalid-credential", result.error.message, { cause: result.error });
|
|
1021
|
+
}
|
|
999
1022
|
if (result.type === "revoked") {
|
|
1000
1023
|
this.tokenCache.delete(key);
|
|
1024
|
+
await this.store.mutate((s) => {
|
|
1025
|
+
const target = s.accounts.find((candidate) => this.accountKey(candidate) === key);
|
|
1026
|
+
if (target) {
|
|
1027
|
+
target.enabled = false;
|
|
1028
|
+
target.verificationRequired = true;
|
|
1029
|
+
target.verificationRequiredAt = Date.now();
|
|
1030
|
+
target.verificationRequiredReason = "auth-failure";
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
account.enabled = false;
|
|
1034
|
+
account.verificationRequired = true;
|
|
1035
|
+
account.verificationRequiredAt = Date.now();
|
|
1036
|
+
account.verificationRequiredReason = "auth-failure";
|
|
1001
1037
|
return;
|
|
1002
1038
|
}
|
|
1003
1039
|
})();
|
|
1004
1040
|
this.refreshInFlight.set(key, refreshing);
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
} finally {
|
|
1008
|
-
this.refreshInFlight.delete(key);
|
|
1009
|
-
}
|
|
1041
|
+
refreshing.then(() => this.refreshInFlight.delete(key), () => this.refreshInFlight.delete(key));
|
|
1042
|
+
return refreshing;
|
|
1010
1043
|
}
|
|
1011
|
-
|
|
1044
|
+
/** Resolve a usable access token for the account, pre-emptively refreshing near expiry. */
|
|
1045
|
+
async accessTokenFor(account) {
|
|
1046
|
+
const key = this.accountKey(account);
|
|
1047
|
+
const cached = this.tokenCache.get(key);
|
|
1048
|
+
const now = Date.now();
|
|
1049
|
+
if (cached && !accessTokenExpired({
|
|
1050
|
+
access: cached.access,
|
|
1051
|
+
expires: cached.expires,
|
|
1052
|
+
refresh: account.refresh
|
|
1053
|
+
})) {
|
|
1054
|
+
if (cached.expires <= now + AgySessionManager.REFRESH_SKEW_MS) this.refreshToken(key, account, cached);
|
|
1055
|
+
return {
|
|
1056
|
+
access: cached.access,
|
|
1057
|
+
expires: cached.expires,
|
|
1058
|
+
refresh: account.refresh
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
return this.refreshToken(key, account, cached);
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Refresh stale per-account quota caches (family-scoped, health-based TTL).
|
|
1065
|
+
* Failures leave the account unmeasured: ranking treats it as a fallback
|
|
1066
|
+
* instead of blocking selection on a hung endpoint.
|
|
1067
|
+
*/
|
|
1068
|
+
async refreshQuotaCache(storage) {
|
|
1069
|
+
const now = Date.now();
|
|
1070
|
+
const stale = storage.accounts.filter((account) => account.enabled !== false && isQuotaStale(account, now));
|
|
1071
|
+
if (stale.length === 0) return;
|
|
1072
|
+
const updates = (await Promise.all(stale.map(async (account) => {
|
|
1073
|
+
const key = this.accountKey(account);
|
|
1074
|
+
if (this.quotaRefreshInFlight.has(key)) return this.quotaRefreshInFlight.get(key);
|
|
1075
|
+
const refresh = (async () => {
|
|
1076
|
+
try {
|
|
1077
|
+
const auth = await this.accessTokenFor(account);
|
|
1078
|
+
if (!auth) return null;
|
|
1079
|
+
const { fetchAvailableModels } = await import("./models-I1UAEk9x.mjs").then((n) => n.r);
|
|
1080
|
+
const boundedFetch = (input, init) => {
|
|
1081
|
+
const timeout = AbortSignal.timeout(AgySessionManager.QUOTA_FETCH_TIMEOUT_MS);
|
|
1082
|
+
const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
|
|
1083
|
+
return fetch(input, {
|
|
1084
|
+
...init,
|
|
1085
|
+
signal
|
|
1086
|
+
});
|
|
1087
|
+
};
|
|
1088
|
+
const quotas = ingestFamilyQuotas(await fetchAvailableModels(auth.access, account.projectId, boundedFetch));
|
|
1089
|
+
return {
|
|
1090
|
+
key,
|
|
1091
|
+
quotas,
|
|
1092
|
+
updatedAt: Date.now()
|
|
1093
|
+
};
|
|
1094
|
+
} catch {
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
})();
|
|
1098
|
+
this.quotaRefreshInFlight.set(key, refresh);
|
|
1099
|
+
try {
|
|
1100
|
+
return await refresh;
|
|
1101
|
+
} finally {
|
|
1102
|
+
this.quotaRefreshInFlight.delete(key);
|
|
1103
|
+
}
|
|
1104
|
+
}))).filter((r) => Boolean(r && Object.keys(r.quotas).length > 0));
|
|
1105
|
+
if (updates.length > 0) {
|
|
1106
|
+
for (const update of updates) {
|
|
1107
|
+
const target = storage.accounts.find((candidate) => this.accountKey(candidate) === update.key);
|
|
1108
|
+
if (target) {
|
|
1109
|
+
target.cachedQuota = update.quotas;
|
|
1110
|
+
target.cachedQuotaUpdatedAt = update.updatedAt;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
try {
|
|
1114
|
+
await this.store.mutate((s) => {
|
|
1115
|
+
for (const update of updates) {
|
|
1116
|
+
const target = s.accounts.find((candidate) => this.accountKey(candidate) === update.key);
|
|
1117
|
+
if (target) {
|
|
1118
|
+
target.cachedQuota = update.quotas;
|
|
1119
|
+
target.cachedQuotaUpdatedAt = update.updatedAt;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
} catch {}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Pick the account for one request: the affinity pin wins while it is fresh,
|
|
1128
|
+
* healthy, and not drained for the requested model; otherwise the pool is
|
|
1129
|
+
* ranked by family-scoped usage (OMP-aligned) and the best candidate wins.
|
|
1130
|
+
*/
|
|
1131
|
+
async pickAccount(storage, model) {
|
|
1012
1132
|
const now = Date.now();
|
|
1013
1133
|
for (const account of storage.accounts) clearExpiredState(account, now);
|
|
1014
|
-
|
|
1015
|
-
|
|
1134
|
+
const family = modelFamilyOf(model);
|
|
1135
|
+
const familyKey = familyKeyOf(model);
|
|
1016
1136
|
if (this.lastUsed && now - this.lastUsed.at < 6e5) {
|
|
1017
|
-
const
|
|
1018
|
-
if (
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1137
|
+
const lastIndex = storage.accounts.findIndex((a) => this.accountKey(a) === this.lastUsed.key);
|
|
1138
|
+
if (lastIndex !== -1) {
|
|
1139
|
+
const last = storage.accounts[lastIndex];
|
|
1140
|
+
if (last.enabled !== false && !isCoolingDown(last, now) && !isFamilyRateLimited(last, familyKey, now) && !isFamilyDrained(last, family, now)) return {
|
|
1141
|
+
account: last,
|
|
1142
|
+
index: lastIndex
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1022
1145
|
}
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1146
|
+
const eligible = storage.accounts.map((account, index) => ({
|
|
1147
|
+
account,
|
|
1148
|
+
index
|
|
1149
|
+
})).filter(({ account }) => account.enabled !== false);
|
|
1150
|
+
if (eligible.length === 0) return void 0;
|
|
1151
|
+
const ranked = rankPoolCandidates(eligible, model, now, storage.activeIndex);
|
|
1152
|
+
const picked = ranked.find((candidate) => candidate.blockedUntil === null);
|
|
1153
|
+
if (!picked) {
|
|
1154
|
+
const quotaExhausted = (account) => {
|
|
1155
|
+
if (account.cooldownReason === "quota-exhausted" && (account.coolingDownUntil ?? 0) > now) return true;
|
|
1156
|
+
const quota = familyQuotaFor(account, family);
|
|
1157
|
+
if ((quota?.remainingFraction ?? 1) > 0 || !quota?.resetTime) return false;
|
|
1158
|
+
const resetAt = Date.parse(quota.resetTime);
|
|
1159
|
+
return !Number.isNaN(resetAt) && resetAt > now;
|
|
1028
1160
|
};
|
|
1161
|
+
const retryable = ranked.filter((candidate) => !quotaExhausted(candidate.account));
|
|
1162
|
+
const blocked = retryable.length > 0 ? retryable : ranked;
|
|
1163
|
+
const blockedUntil = Math.min(...blocked.map((candidate) => candidate.blockedUntil ?? now));
|
|
1164
|
+
throw new AgyPoolBlockedError(retryable.length > 0 ? "retryable" : "quota-exhausted", blockedUntil);
|
|
1165
|
+
}
|
|
1166
|
+
if (picked.index !== storage.activeIndex) {
|
|
1167
|
+
storage.activeIndex = picked.index;
|
|
1168
|
+
await this.store.mutate((s) => {
|
|
1169
|
+
s.activeIndex = picked.index;
|
|
1170
|
+
});
|
|
1029
1171
|
}
|
|
1030
|
-
return
|
|
1172
|
+
return {
|
|
1173
|
+
account: picked.account,
|
|
1174
|
+
index: picked.index
|
|
1175
|
+
};
|
|
1031
1176
|
}
|
|
1032
1177
|
/**
|
|
1033
1178
|
* Adapter hook: resolve the active session (refresh if needed), healing a
|
|
1034
1179
|
* missing projectId at request time — the OAuth-time loadCodeAssist may have
|
|
1035
1180
|
* transiently failed even when the Google account owns a Cloud Code project
|
|
1036
1181
|
* (mirrors OmniRoute's ensureAntigravityProjectAssigned + persistence).
|
|
1182
|
+
* @param model - requested model id; drives family-scoped quota ranking.
|
|
1037
1183
|
*/
|
|
1038
|
-
async getSession() {
|
|
1039
|
-
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1184
|
+
async getSession(model) {
|
|
1185
|
+
let storage = await this.store.load();
|
|
1186
|
+
const maxAttempts = storage.accounts.filter((account) => account.enabled !== false).length;
|
|
1187
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1188
|
+
if (storage.accounts.filter((account) => account.enabled !== false).length > 1) await this.refreshQuotaCache(storage);
|
|
1189
|
+
const picked = await this.pickAccount(storage, model);
|
|
1190
|
+
if (!picked) return void 0;
|
|
1191
|
+
const auth = await this.accessTokenFor(picked.account);
|
|
1192
|
+
if (!auth) {
|
|
1193
|
+
this.lastUsed = null;
|
|
1194
|
+
storage = await this.store.load();
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
const key = this.accountKey(picked.account);
|
|
1198
|
+
if (!picked.account.projectId && !this.projectRetryFailed.has(key)) try {
|
|
1199
|
+
const { loadCodeAssist } = await import("./exchange-CteHnRzO.mjs").then((n) => n.n);
|
|
1200
|
+
const { projectId } = await loadCodeAssist(auth.access);
|
|
1201
|
+
if (projectId) {
|
|
1202
|
+
await this.store.mutate((s) => {
|
|
1203
|
+
const account = s.accounts.find((candidate) => this.accountKey(candidate) === key);
|
|
1204
|
+
if (account) {
|
|
1205
|
+
account.projectId = projectId;
|
|
1206
|
+
const parts = parseRefreshParts(account.refresh);
|
|
1207
|
+
account.refresh = formatRefreshParts({
|
|
1208
|
+
refreshToken: parts.refreshToken,
|
|
1209
|
+
projectId,
|
|
1210
|
+
managedProjectId: parts.managedProjectId
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
picked.account.projectId = projectId;
|
|
1215
|
+
} else this.projectRetryFailed.add(key);
|
|
1216
|
+
} catch {
|
|
1217
|
+
this.projectRetryFailed.add(key);
|
|
1218
|
+
}
|
|
1219
|
+
this.lastUsed = {
|
|
1220
|
+
key,
|
|
1221
|
+
at: Date.now()
|
|
1222
|
+
};
|
|
1223
|
+
return {
|
|
1224
|
+
auth,
|
|
1225
|
+
account: picked.account,
|
|
1226
|
+
index: picked.index,
|
|
1227
|
+
impersonation: impersonationHeadersFor(picked.account)
|
|
1228
|
+
};
|
|
1065
1229
|
}
|
|
1066
|
-
this.lastUsed = {
|
|
1067
|
-
index: picked.index,
|
|
1068
|
-
at: Date.now()
|
|
1069
|
-
};
|
|
1070
|
-
return {
|
|
1071
|
-
auth,
|
|
1072
|
-
account: picked.account,
|
|
1073
|
-
index: picked.index,
|
|
1074
|
-
impersonation: impersonationHeadersFor(picked.account)
|
|
1075
|
-
};
|
|
1076
1230
|
}
|
|
1077
1231
|
/** Adapter hook: apply rotation decisions and fingerprint regeneration. */
|
|
1078
1232
|
async reportFailure(kind, session, info) {
|
|
1079
|
-
|
|
1080
|
-
const
|
|
1081
|
-
if (!account) return;
|
|
1082
|
-
const key = this.accountKey(account);
|
|
1233
|
+
if (!session?.account) return;
|
|
1234
|
+
const key = this.accountKey(session.account);
|
|
1083
1235
|
const consecutive = (this.failureCounts.get(key) ?? 0) + 1;
|
|
1084
1236
|
this.failureCounts.set(key, consecutive);
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
return;
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
if (
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
if (
|
|
1100
|
-
|
|
1101
|
-
account.fingerprintHistory = recordFingerprintVersion(account.fingerprintHistory,
|
|
1102
|
-
|
|
1237
|
+
let nextIndexToRotate = null;
|
|
1238
|
+
const fpCachedVersion = kind === "rate-limit" ? peekCachedAntigravityVersion() : null;
|
|
1239
|
+
const fpResolvedVersion = kind === "rate-limit" && !fpCachedVersion ? await resolveAntigravityVersionBounded() : fpCachedVersion ?? "1.18.3";
|
|
1240
|
+
await this.store.mutate((storage) => {
|
|
1241
|
+
const account = storage.accounts.find((a) => this.accountKey(a) === key);
|
|
1242
|
+
if (!account) return;
|
|
1243
|
+
const decision = decideRotation(kind, account, consecutive, info?.retryAfterMs, info?.rateLimitCategory, info?.resetTime);
|
|
1244
|
+
if (kind === "rate-limit" && info?.rateLimitCategory !== "soft_rate_limit") recordRateLimit(account, familyKeyOf(info?.model), parseFutureResetMs$1(info?.resetTime, Date.now()) ?? Date.now() + Math.min(info?.retryAfterMs ?? 3e5, 18e5));
|
|
1245
|
+
if (decision.action === "revoke") {
|
|
1246
|
+
this.tokenCache.delete(key);
|
|
1247
|
+
this.failureCounts.delete(key);
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
if (kind === "rate-limit" && info?.rateLimitCategory !== "soft_rate_limit") {
|
|
1251
|
+
if (!account.fingerprint) {
|
|
1252
|
+
account.fingerprint = generateFingerprint(void 0, fpResolvedVersion);
|
|
1253
|
+
account.fingerprintHistory = recordFingerprintVersion(account.fingerprintHistory, account.fingerprint, "initial");
|
|
1254
|
+
} else {
|
|
1255
|
+
if (fpCachedVersion) updateFingerprintVersion(account.fingerprint, fpCachedVersion);
|
|
1256
|
+
if (fingerprintMode() !== "stable" && consecutive >= 2) {
|
|
1257
|
+
const fresh = generateFingerprint(void 0, fpResolvedVersion);
|
|
1258
|
+
account.fingerprintHistory = recordFingerprintVersion(account.fingerprintHistory, fresh, "regenerated");
|
|
1259
|
+
account.fingerprint = fresh;
|
|
1260
|
+
}
|
|
1103
1261
|
}
|
|
1104
1262
|
}
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
storage.activeIndex
|
|
1110
|
-
|
|
1263
|
+
if (decision.action === "rotate") {
|
|
1264
|
+
const currentIndex = storage.accounts.findIndex((a) => this.accountKey(a) === key);
|
|
1265
|
+
const familyKey = familyKeyOf(info?.model);
|
|
1266
|
+
const nextIndex = pickNextAccountIndex(storage.accounts, currentIndex >= 0 ? currentIndex : storage.activeIndex, Date.now(), familyKey);
|
|
1267
|
+
if (nextIndex !== storage.activeIndex) {
|
|
1268
|
+
storage.activeIndex = nextIndex;
|
|
1269
|
+
nextIndexToRotate = nextIndex;
|
|
1270
|
+
}
|
|
1271
|
+
this.lastUsed = null;
|
|
1111
1272
|
}
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
await this.store.save(storage);
|
|
1273
|
+
});
|
|
1274
|
+
if (nextIndexToRotate !== null) this.onRotate?.(session.index, nextIndexToRotate, kind);
|
|
1115
1275
|
}
|
|
1116
1276
|
/** Adapter hook: reset the failure counter after a clean completion. */
|
|
1117
1277
|
async markSuccess(session) {
|
|
@@ -1124,13 +1284,13 @@ var AgySessionManager = class {
|
|
|
1124
1284
|
* Returns the collected text or a structured error message.
|
|
1125
1285
|
*/
|
|
1126
1286
|
async testCall(model, prompt = "Reply with exactly: OK", maxTokens = 1024) {
|
|
1127
|
-
const session = await this.getSession();
|
|
1128
|
-
if (!session) return {
|
|
1129
|
-
ok: false,
|
|
1130
|
-
error: "No agy account configured — run `dsh-agy login` first."
|
|
1131
|
-
};
|
|
1132
1287
|
try {
|
|
1133
|
-
const
|
|
1288
|
+
const session = await this.getSession(model);
|
|
1289
|
+
if (!session) return {
|
|
1290
|
+
ok: false,
|
|
1291
|
+
error: "No agy account configured — run `dsh-agy login` first."
|
|
1292
|
+
};
|
|
1293
|
+
const { toAgyRequestBody } = await import("./translate-C1SMdm5J.mjs").then((n) => n.n);
|
|
1134
1294
|
const { fetchAgyFirstOk } = await Promise.resolve().then(() => constants_exports);
|
|
1135
1295
|
const { parseAgySse } = await import("./parse-D8bV2yqa.mjs").then((n) => n.n);
|
|
1136
1296
|
const body = toAgyRequestBody({
|
|
@@ -1188,29 +1348,28 @@ var AgySessionManager = class {
|
|
|
1188
1348
|
async exportBlob(index) {
|
|
1189
1349
|
const account = (await this.store.load()).accounts[index];
|
|
1190
1350
|
if (!account) return { error: "account not found" };
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1351
|
+
try {
|
|
1352
|
+
const auth = await this.accessTokenFor(account);
|
|
1353
|
+
if (!auth) return { error: "refresh failed (revoked?)" };
|
|
1354
|
+
const { encodeCredentialBlob } = await import("./blob-D1e7_uT1.mjs").then((n) => n.t);
|
|
1355
|
+
const parts = parseRefreshParts(account.refresh);
|
|
1356
|
+
return { blob: encodeCredentialBlob("agy", {
|
|
1357
|
+
access_token: auth.access,
|
|
1358
|
+
refresh_token: parts.refreshToken,
|
|
1359
|
+
expires_in: Math.max(0, Math.round((auth.expires - Date.now()) / 1e3))
|
|
1360
|
+
}) };
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
1363
|
+
}
|
|
1200
1364
|
}
|
|
1201
|
-
/**
|
|
1202
|
-
async
|
|
1203
|
-
const account = (await this.store.load()).accounts[index];
|
|
1204
|
-
if (!account) return {
|
|
1205
|
-
ok: false,
|
|
1206
|
-
error: "account not found"
|
|
1207
|
-
};
|
|
1208
|
-
const auth = await this.accessTokenFor(account);
|
|
1209
|
-
if (!auth) return {
|
|
1210
|
-
ok: false,
|
|
1211
|
-
error: "refresh failed (revoked?)"
|
|
1212
|
-
};
|
|
1365
|
+
/** Probe one account: refresh + userinfo; a live credential re-enables the account. */
|
|
1366
|
+
async probeAccount(index, account) {
|
|
1213
1367
|
try {
|
|
1368
|
+
const auth = await this.accessTokenFor(account);
|
|
1369
|
+
if (!auth) return {
|
|
1370
|
+
ok: false,
|
|
1371
|
+
error: "refresh failed (revoked?)"
|
|
1372
|
+
};
|
|
1214
1373
|
const response = await proxiedFetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { headers: { Authorization: `Bearer ${auth.access}` } });
|
|
1215
1374
|
if (!response.ok) return {
|
|
1216
1375
|
ok: false,
|
|
@@ -1238,8 +1397,370 @@ var AgySessionManager = class {
|
|
|
1238
1397
|
};
|
|
1239
1398
|
}
|
|
1240
1399
|
}
|
|
1400
|
+
/** CLI/web helper: verify an account's credentials (refresh + userinfo). */
|
|
1401
|
+
async verifyAccount(index) {
|
|
1402
|
+
const account = (await this.store.load()).accounts[index];
|
|
1403
|
+
if (!account) return {
|
|
1404
|
+
ok: false,
|
|
1405
|
+
error: "account not found"
|
|
1406
|
+
};
|
|
1407
|
+
return this.probeAccount(index, account);
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Batch health check over all enabled accounts (or the given indices):
|
|
1411
|
+
* refresh + userinfo per account, live credentials re-enable the account.
|
|
1412
|
+
* Reports results through onHealthReport when a listener is registered.
|
|
1413
|
+
*/
|
|
1414
|
+
async checkAccounts(indices) {
|
|
1415
|
+
const storage = await this.store.load();
|
|
1416
|
+
const targets = indices !== void 0 ? indices.filter((index) => storage.accounts[index]) : storage.accounts.map((_, index) => index).filter((index) => storage.accounts[index].enabled !== false);
|
|
1417
|
+
const results = await Promise.all(targets.map(async (index) => {
|
|
1418
|
+
return {
|
|
1419
|
+
index,
|
|
1420
|
+
...await this.probeAccount(index, storage.accounts[index])
|
|
1421
|
+
};
|
|
1422
|
+
}));
|
|
1423
|
+
this.onHealthReport?.(results);
|
|
1424
|
+
return results;
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Start a background health probe on an interval (disposable stop handle).
|
|
1428
|
+
* The timer is unref'd unless told otherwise so harness processes can still
|
|
1429
|
+
* exit; the CLI loop mode passes `unref: false`.
|
|
1430
|
+
*/
|
|
1431
|
+
startHealthProbe(intervalMs, options = {}) {
|
|
1432
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) return () => {};
|
|
1433
|
+
const timer = setInterval(() => {
|
|
1434
|
+
this.checkAccounts().catch(() => {});
|
|
1435
|
+
}, intervalMs);
|
|
1436
|
+
if (options.unref !== false) timer.unref?.();
|
|
1437
|
+
return () => clearInterval(timer);
|
|
1438
|
+
}
|
|
1439
|
+
};
|
|
1440
|
+
//#endregion
|
|
1441
|
+
//#region src/store/keyring.ts
|
|
1442
|
+
/**
|
|
1443
|
+
* Master-key management and AES-256-GCM secret codec for the account store.
|
|
1444
|
+
*
|
|
1445
|
+
* The master key lives in the DSH credentials document (`~/.dsh/.credentials.yaml`,
|
|
1446
|
+
* 0600) under `AGY_MASTER_KEY` so both the in-harness plugin (via
|
|
1447
|
+
* `ctx.credentials`) and the standalone `dsh-agy` CLI (direct file read) can
|
|
1448
|
+
* encrypt and decrypt the same account store.
|
|
1449
|
+
*/
|
|
1450
|
+
const MASTER_KEY_REF = "AGY_MASTER_KEY";
|
|
1451
|
+
const ENC_PREFIX$1 = "enc:v1:";
|
|
1452
|
+
/** AES-256-GCM codec; ciphertext format `enc:v1:<iv-b64>:<tag-b64>:<data-b64>`. */
|
|
1453
|
+
function createAesGcmCodec(key) {
|
|
1454
|
+
if (key.length !== 32) throw new Error(`createAesGcmCodec: master key must be 32 bytes, got ${key.length}`);
|
|
1455
|
+
return {
|
|
1456
|
+
encrypt(plaintext) {
|
|
1457
|
+
const iv = randomBytes(12);
|
|
1458
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
1459
|
+
const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
1460
|
+
const tag = cipher.getAuthTag();
|
|
1461
|
+
return `${ENC_PREFIX$1}${iv.toString("base64url")}:${tag.toString("base64url")}:${data.toString("base64url")}`;
|
|
1462
|
+
},
|
|
1463
|
+
decrypt(payload) {
|
|
1464
|
+
if (!payload.startsWith(ENC_PREFIX$1)) throw new Error("decrypt: payload is not in encrypted format");
|
|
1465
|
+
const [, , ivB64 = "", tagB64 = "", dataB64 = ""] = payload.split(":");
|
|
1466
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivB64, "base64url"));
|
|
1467
|
+
decipher.setAuthTag(Buffer.from(tagB64, "base64url"));
|
|
1468
|
+
return Buffer.concat([decipher.update(Buffer.from(dataB64, "base64url")), decipher.final()]).toString("utf8");
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
/** Derive a 32-byte key from an arbitrary master-key string (SHA-256). */
|
|
1473
|
+
function deriveKey(masterKey) {
|
|
1474
|
+
return createHash("sha256").update(masterKey, "utf8").digest();
|
|
1475
|
+
}
|
|
1476
|
+
/** Default DSH home (`~/.dsh`), honoring `$DSH_HOME`. */
|
|
1477
|
+
function resolveDshHome() {
|
|
1478
|
+
return process.env.DSH_HOME ? resolve(process.env.DSH_HOME) : join(homedir(), ".dsh");
|
|
1479
|
+
}
|
|
1480
|
+
function homedir() {
|
|
1481
|
+
return process.env.HOME ?? process.env.USERPROFILE ?? ".";
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Minimal reader for the DSH credentials document: a flat YAML mapping of
|
|
1485
|
+
* `KEY: "value"` / `KEY: value` lines. Values are unquoted with JSON-style
|
|
1486
|
+
* escaping for double-quoted scalars; anything more complex fails loudly.
|
|
1487
|
+
*/
|
|
1488
|
+
function readCredentialsDocument(file) {
|
|
1489
|
+
if (!existsSync(file)) return /* @__PURE__ */ new Map();
|
|
1490
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1491
|
+
const text = readFileSync(file, "utf8");
|
|
1492
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1493
|
+
const trimmed = line.trim();
|
|
1494
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("---")) continue;
|
|
1495
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(trimmed);
|
|
1496
|
+
if (!match) continue;
|
|
1497
|
+
const [, key = "", rawValue = ""] = match;
|
|
1498
|
+
let value;
|
|
1499
|
+
if (rawValue.startsWith("\"")) try {
|
|
1500
|
+
value = JSON.parse(rawValue);
|
|
1501
|
+
} catch {
|
|
1502
|
+
throw new Error(`readCredentialsDocument: invalid quoted value for "${key}" in ${file}`);
|
|
1503
|
+
}
|
|
1504
|
+
else if (rawValue.startsWith("'")) {
|
|
1505
|
+
if (!rawValue.endsWith("'") || rawValue.length < 2) throw new Error(`readCredentialsDocument: unterminated single-quoted value for "${key}" in ${file}`);
|
|
1506
|
+
value = rawValue.slice(1, -1).replace(/''/g, "'");
|
|
1507
|
+
} else value = rawValue;
|
|
1508
|
+
if (value.length === 0) continue;
|
|
1509
|
+
entries.set(key, value);
|
|
1510
|
+
}
|
|
1511
|
+
return entries;
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* POSIX owner-only enforcement. Windows mode bits never report 0600 (and
|
|
1515
|
+
* chmod is a no-op there), so the check is skipped on win32; the encrypted
|
|
1516
|
+
* account file and credentials document remain the only defense-in-depth
|
|
1517
|
+
* layer on that platform.
|
|
1518
|
+
*/
|
|
1519
|
+
function assertOwnerOnly(file) {
|
|
1520
|
+
if (process.platform === "win32") return;
|
|
1521
|
+
const mode = statSync(file).mode & 511;
|
|
1522
|
+
if (mode !== 384) throw new Error(`dsh-agy: ${file} is readable beyond its owner (mode ${mode.toString(8)}); run "chmod 600" before starting again`);
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Load the master key from the DSH credentials document. Returns undefined when
|
|
1526
|
+
* the document or the reference is absent.
|
|
1527
|
+
*/
|
|
1528
|
+
function loadMasterKey(dshHome) {
|
|
1529
|
+
const file = join(dshHome, ".credentials.yaml");
|
|
1530
|
+
if (!existsSync(file)) return void 0;
|
|
1531
|
+
assertOwnerOnly(file);
|
|
1532
|
+
return readCredentialsDocument(file).get(MASTER_KEY_REF);
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Generate and persist a fresh master key (0600) in the DSH credentials document.
|
|
1536
|
+
*
|
|
1537
|
+
* Append-only + atomic rename: the DSH credentials service owns this file and
|
|
1538
|
+
* preserves comments and the formatting of untouched entries (and may hold
|
|
1539
|
+
* YAML constructs this minimal reader cannot parse). Rewriting the whole file
|
|
1540
|
+
* from our parsed view would silently drop those; appending keeps them.
|
|
1541
|
+
*/
|
|
1542
|
+
function persistMasterKey(dshHome, masterKey) {
|
|
1543
|
+
const file = join(dshHome, ".credentials.yaml");
|
|
1544
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1545
|
+
const existingText = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
1546
|
+
if (existingText.length > 0 && readCredentialsDocument(file).has("AGY_MASTER_KEY")) throw new Error(`persistMasterKey: ${MASTER_KEY_REF} already exists in ${file}`);
|
|
1547
|
+
const entry = `${MASTER_KEY_REF}: ${JSON.stringify(masterKey)}`;
|
|
1548
|
+
const next = existingText.length > 0 && !existingText.endsWith("\n") ? `${existingText}\n${entry}\n` : `${existingText}${entry}\n`;
|
|
1549
|
+
const tmp = `${file}.tmp-masterkey`;
|
|
1550
|
+
writeFileSync(tmp, next, { mode: 384 });
|
|
1551
|
+
renameSync(tmp, file);
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Resolve (load or create) the master key for a dsh home, then build the codec.
|
|
1555
|
+
* Creating a key writes the credentials document; read-only setups should call
|
|
1556
|
+
* {@link loadMasterKey} first and surface a friendly error instead.
|
|
1557
|
+
*/
|
|
1558
|
+
function resolveMasterKeyCodec(dshHome) {
|
|
1559
|
+
let masterKey = loadMasterKey(dshHome);
|
|
1560
|
+
let created = false;
|
|
1561
|
+
if (!masterKey) {
|
|
1562
|
+
masterKey = randomBytes(32).toString("hex");
|
|
1563
|
+
persistMasterKey(dshHome, masterKey);
|
|
1564
|
+
created = true;
|
|
1565
|
+
}
|
|
1566
|
+
return {
|
|
1567
|
+
codec: createAesGcmCodec(deriveKey(masterKey)),
|
|
1568
|
+
created
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
const ENC_PREFIX = "enc:v1:";
|
|
1572
|
+
const properFileLock = { async withLock(file, fn) {
|
|
1573
|
+
const release = await lockfile.lock(file, {
|
|
1574
|
+
stale: 3e4,
|
|
1575
|
+
update: 1e4,
|
|
1576
|
+
retries: {
|
|
1577
|
+
retries: 10,
|
|
1578
|
+
factor: 1.5,
|
|
1579
|
+
minTimeout: 50,
|
|
1580
|
+
maxTimeout: 2e3
|
|
1581
|
+
}
|
|
1582
|
+
});
|
|
1583
|
+
try {
|
|
1584
|
+
return await fn();
|
|
1585
|
+
} finally {
|
|
1586
|
+
await release();
|
|
1587
|
+
}
|
|
1588
|
+
} };
|
|
1589
|
+
function isEncrypted(value) {
|
|
1590
|
+
return value.startsWith(ENC_PREFIX);
|
|
1591
|
+
}
|
|
1592
|
+
/** Decrypt one account's refresh field when encrypted; plaintext passes through (legacy). */
|
|
1593
|
+
function decryptAccount(account, codec) {
|
|
1594
|
+
if (isEncrypted(account.refresh)) return {
|
|
1595
|
+
...account,
|
|
1596
|
+
refresh: codec.decrypt(account.refresh)
|
|
1597
|
+
};
|
|
1598
|
+
return account;
|
|
1599
|
+
}
|
|
1600
|
+
/** Encrypt one account's refresh field (plaintext stays plaintext if no codec change). */
|
|
1601
|
+
function encryptAccount(account, codec) {
|
|
1602
|
+
if (!isEncrypted(account.refresh)) return {
|
|
1603
|
+
...account,
|
|
1604
|
+
refresh: codec.encrypt(account.refresh)
|
|
1605
|
+
};
|
|
1606
|
+
return account;
|
|
1607
|
+
}
|
|
1608
|
+
function ensureAccountIds(storage) {
|
|
1609
|
+
let mutated = false;
|
|
1610
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
1611
|
+
for (const account of storage.accounts) {
|
|
1612
|
+
if (!account.id || seenIds.has(account.id)) {
|
|
1613
|
+
account.id = randomUUID();
|
|
1614
|
+
mutated = true;
|
|
1615
|
+
}
|
|
1616
|
+
seenIds.add(account.id);
|
|
1617
|
+
}
|
|
1618
|
+
return {
|
|
1619
|
+
storage,
|
|
1620
|
+
mutated
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
function decryptStorage(storage, codec) {
|
|
1624
|
+
return {
|
|
1625
|
+
...storage,
|
|
1626
|
+
accounts: storage.accounts.map((a) => decryptAccount(a, codec))
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
function encryptStorage(storage, codec) {
|
|
1630
|
+
return {
|
|
1631
|
+
...storage,
|
|
1632
|
+
accounts: storage.accounts.map((a) => encryptAccount(a, codec))
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
function migrateV1ToV2(v1) {
|
|
1636
|
+
return {
|
|
1637
|
+
version: 2,
|
|
1638
|
+
accounts: v1.accounts.map((acc) => ({
|
|
1639
|
+
email: acc.email,
|
|
1640
|
+
refreshToken: acc.refreshToken,
|
|
1641
|
+
projectId: acc.projectId,
|
|
1642
|
+
managedProjectId: acc.managedProjectId,
|
|
1643
|
+
addedAt: acc.addedAt,
|
|
1644
|
+
lastUsed: acc.lastUsed,
|
|
1645
|
+
lastSwitchReason: acc.lastSwitchReason,
|
|
1646
|
+
rateLimitResetTimes: acc.isRateLimited && acc.rateLimitResetTime ? { default: acc.rateLimitResetTime } : void 0
|
|
1647
|
+
})),
|
|
1648
|
+
activeIndex: v1.activeIndex
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
function migrateV2ToV3(v2) {
|
|
1652
|
+
return {
|
|
1653
|
+
version: 3,
|
|
1654
|
+
accounts: v2.accounts.map((acc) => ({
|
|
1655
|
+
email: acc.email,
|
|
1656
|
+
refresh: `${acc.refreshToken}|${acc.projectId ?? ""}|${acc.managedProjectId ?? ""}`,
|
|
1657
|
+
projectId: acc.projectId,
|
|
1658
|
+
managedProjectId: acc.managedProjectId,
|
|
1659
|
+
addedAt: acc.addedAt,
|
|
1660
|
+
lastUsed: acc.lastUsed,
|
|
1661
|
+
enabled: true,
|
|
1662
|
+
lastSwitchReason: acc.lastSwitchReason,
|
|
1663
|
+
rateLimitResetTimes: acc.rateLimitResetTimes
|
|
1664
|
+
})),
|
|
1665
|
+
activeIndex: v2.activeIndex
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
function migrateV3ToV4(v3) {
|
|
1669
|
+
return {
|
|
1670
|
+
version: 4,
|
|
1671
|
+
accounts: v3.accounts,
|
|
1672
|
+
activeIndex: v3.activeIndex
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
function migrateStorage(raw) {
|
|
1676
|
+
switch (raw.version) {
|
|
1677
|
+
case 1: return migrateStorage(migrateV1ToV2(raw));
|
|
1678
|
+
case 2: return migrateStorage(migrateV2ToV3(raw));
|
|
1679
|
+
case 3: return migrateStorage(migrateV3ToV4(raw));
|
|
1680
|
+
case 4: return raw;
|
|
1681
|
+
default: throw new Error(`migrateStorage: unsupported storage version ${raw.version}`);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
var JsonAccountStore = class {
|
|
1685
|
+
file;
|
|
1686
|
+
codec;
|
|
1687
|
+
lock;
|
|
1688
|
+
constructor(options) {
|
|
1689
|
+
this.file = options.file;
|
|
1690
|
+
this.codec = options.codec;
|
|
1691
|
+
this.lock = options.lock ?? properFileLock;
|
|
1692
|
+
}
|
|
1693
|
+
ensureFile() {
|
|
1694
|
+
if (existsSync(this.file)) return;
|
|
1695
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
1696
|
+
const tmp = `${this.file}.tmp-init`;
|
|
1697
|
+
writeFileSync(tmp, JSON.stringify({
|
|
1698
|
+
version: 4,
|
|
1699
|
+
accounts: [],
|
|
1700
|
+
activeIndex: 0
|
|
1701
|
+
}) + "\n", { mode: 384 });
|
|
1702
|
+
renameSync(tmp, this.file);
|
|
1703
|
+
}
|
|
1704
|
+
readAndMigrateUnlocked() {
|
|
1705
|
+
let text;
|
|
1706
|
+
try {
|
|
1707
|
+
text = readFileSync(this.file, "utf8");
|
|
1708
|
+
} catch (error) {
|
|
1709
|
+
if (error.code === "ENOENT") return {
|
|
1710
|
+
storage: {
|
|
1711
|
+
version: 4,
|
|
1712
|
+
accounts: [],
|
|
1713
|
+
activeIndex: 0
|
|
1714
|
+
},
|
|
1715
|
+
mutated: false
|
|
1716
|
+
};
|
|
1717
|
+
throw error;
|
|
1718
|
+
}
|
|
1719
|
+
assertOwnerOnly(this.file);
|
|
1720
|
+
return ensureAccountIds(decryptStorage(migrateStorage(JSON.parse(text)), this.codec));
|
|
1721
|
+
}
|
|
1722
|
+
writeUnlocked(storage) {
|
|
1723
|
+
const encrypted = encryptStorage(ensureAccountIds(storage).storage, this.codec);
|
|
1724
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
1725
|
+
const tmp = `${this.file}.tmp`;
|
|
1726
|
+
writeFileSync(tmp, JSON.stringify(encrypted, null, 2) + "\n", { mode: 384 });
|
|
1727
|
+
renameSync(tmp, this.file);
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* Materialize and persist generated/repaired UUIDs under the file lock:
|
|
1731
|
+
* re-reads the fresh on-disk state under the lock so concurrent mutations
|
|
1732
|
+
* are never clobbered by a stale pre-lock snapshot.
|
|
1733
|
+
*/
|
|
1734
|
+
async materializeIdsWithLock() {
|
|
1735
|
+
this.ensureFile();
|
|
1736
|
+
return this.lock.withLock(this.file, async () => {
|
|
1737
|
+
const { storage, mutated } = this.readAndMigrateUnlocked();
|
|
1738
|
+
if (mutated) this.writeUnlocked(storage);
|
|
1739
|
+
return storage;
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
async load() {
|
|
1743
|
+
const { storage, mutated } = this.readAndMigrateUnlocked();
|
|
1744
|
+
if (mutated) return this.materializeIdsWithLock();
|
|
1745
|
+
return storage;
|
|
1746
|
+
}
|
|
1747
|
+
async save(storage) {
|
|
1748
|
+
this.ensureFile();
|
|
1749
|
+
await this.lock.withLock(this.file, async () => {
|
|
1750
|
+
this.writeUnlocked(storage);
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
async mutate(fn) {
|
|
1754
|
+
this.ensureFile();
|
|
1755
|
+
return this.lock.withLock(this.file, async () => {
|
|
1756
|
+
const { storage } = this.readAndMigrateUnlocked();
|
|
1757
|
+
const result = await fn(storage);
|
|
1758
|
+
this.writeUnlocked(storage);
|
|
1759
|
+
return result;
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1241
1762
|
};
|
|
1242
1763
|
//#endregion
|
|
1243
|
-
export {
|
|
1764
|
+
export { AgyPoolBlockedError as A, OAUTH_USERINFO_URL as C, resolveAgyClientCredentials as D, getAgyBootstrapUserAgent as E, proxiedFetch as O, OAUTH_TOKEN_URL as S, getAgyBootstrapClientMetadata as T, generateAntigravityRequestId as _, loadMasterKey as a, AGY_SCOPES as b, resolveMasterKeyCodec as c, generateFingerprint as d, recordFingerprintVersion as f, deriveAntigravitySessionId as g, resolveAntigravityVersionBounded as h, deriveKey as i, AgyAuthError as k, AgySessionManager as l, resolveAntigravityVersion as m, MASTER_KEY_REF as n, persistMasterKey as o, calculateTokenExpiry as p, createAesGcmCodec as r, resolveDshHome as s, JsonAccountStore as t, isAgyDisabled as u, AGY_DEFAULT_REDIRECT_URI as v, fetchAgyFirstOk as w, OAUTH_AUTHORIZE_URL as x, AGY_ENDPOINT_FALLBACKS as y };
|
|
1244
1765
|
|
|
1245
|
-
//# sourceMappingURL=
|
|
1766
|
+
//# sourceMappingURL=accounts-BvX5zmPa.mjs.map
|