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