@cortexkit/antigravity-auth-core 2.2.1 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -2
- package/dist/abort-forwarding.d.ts +3 -0
- package/dist/abort-forwarding.d.ts.map +1 -0
- package/dist/abort-forwarding.js +19 -0
- package/dist/abort-forwarding.js.map +1 -0
- package/dist/account-identity.d.ts +52 -0
- package/dist/account-identity.d.ts.map +1 -0
- package/dist/account-identity.js +66 -0
- package/dist/account-identity.js.map +1 -0
- package/dist/account-manager.d.ts +228 -43
- package/dist/account-manager.d.ts.map +1 -1
- package/dist/account-manager.js +942 -903
- package/dist/account-manager.js.map +1 -1
- package/dist/account-migration.d.ts +309 -0
- package/dist/account-migration.d.ts.map +1 -0
- package/dist/account-migration.js +2284 -0
- package/dist/account-migration.js.map +1 -0
- package/dist/account-repository-codecs.d.ts +82 -0
- package/dist/account-repository-codecs.d.ts.map +1 -0
- package/dist/account-repository-codecs.js +609 -0
- package/dist/account-repository-codecs.js.map +1 -0
- package/dist/account-repository-types.d.ts +831 -0
- package/dist/account-repository-types.d.ts.map +1 -0
- package/dist/account-repository-types.js +137 -0
- package/dist/account-repository-types.js.map +1 -0
- package/dist/account-repository.d.ts +442 -0
- package/dist/account-repository.d.ts.map +1 -0
- package/dist/account-repository.js +2253 -0
- package/dist/account-repository.js.map +1 -0
- package/dist/account-selector.d.ts +470 -0
- package/dist/account-selector.d.ts.map +1 -0
- package/dist/account-selector.js +1383 -0
- package/dist/account-selector.js.map +1 -0
- package/dist/account-storage.d.ts.map +1 -1
- package/dist/account-storage.js +5 -0
- package/dist/account-storage.js.map +1 -1
- package/dist/agy-request-metadata.d.ts.map +1 -1
- package/dist/agy-request-metadata.js +8 -21
- package/dist/agy-request-metadata.js.map +1 -1
- package/dist/agy-transport.d.ts.map +1 -1
- package/dist/agy-transport.js +21 -35
- package/dist/agy-transport.js.map +1 -1
- package/dist/antigravity-command-menu.d.ts +171 -0
- package/dist/antigravity-command-menu.d.ts.map +1 -0
- package/dist/antigravity-command-menu.js +523 -0
- package/dist/antigravity-command-menu.js.map +1 -0
- package/dist/atomic-write.d.ts +18 -1
- package/dist/atomic-write.d.ts.map +1 -1
- package/dist/atomic-write.js +15 -3
- package/dist/atomic-write.js.map +1 -1
- package/dist/common-auth-embedded/NOTICE.txt +21 -0
- package/dist/common-auth-embedded/auth-menu/accounts.d.ts +97 -0
- package/dist/common-auth-embedded/auth-menu/accounts.js +387 -0
- package/dist/common-auth-embedded/auth-menu/ansi.d.ts +26 -0
- package/dist/common-auth-embedded/auth-menu/ansi.js +98 -0
- package/dist/common-auth-embedded/auth-menu/confirm.d.ts +7 -0
- package/dist/common-auth-embedded/auth-menu/confirm.js +21 -0
- package/dist/common-auth-embedded/auth-menu/doctor.d.ts +52 -0
- package/dist/common-auth-embedded/auth-menu/doctor.js +81 -0
- package/dist/common-auth-embedded/auth-menu/index.d.ts +17 -0
- package/dist/common-auth-embedded/auth-menu/index.js +12 -0
- package/dist/common-auth-embedded/auth-menu/login.d.ts +55 -0
- package/dist/common-auth-embedded/auth-menu/login.js +83 -0
- package/dist/common-auth-embedded/auth-menu/menu.d.ts +59 -0
- package/dist/common-auth-embedded/auth-menu/menu.js +73 -0
- package/dist/common-auth-embedded/auth-menu/opencode-v1.d.ts +49 -0
- package/dist/common-auth-embedded/auth-menu/opencode-v1.js +47 -0
- package/dist/common-auth-embedded/auth-menu/select.d.ts +21 -0
- package/dist/common-auth-embedded/auth-menu/select.js +159 -0
- package/dist/common-auth-embedded/auth-menu/terminal.d.ts +43 -0
- package/dist/common-auth-embedded/auth-menu/terminal.js +19 -0
- package/dist/common-auth-embedded/claustrum/consumer.d.ts +102 -0
- package/dist/common-auth-embedded/claustrum/consumer.js +298 -0
- package/dist/common-auth-embedded/claustrum/custody.d.ts +175 -0
- package/dist/common-auth-embedded/claustrum/custody.js +354 -0
- package/dist/common-auth-embedded/claustrum/enrollment.d.ts +158 -0
- package/dist/common-auth-embedded/claustrum/enrollment.js +759 -0
- package/dist/common-auth-embedded/claustrum/errors.d.ts +16 -0
- package/dist/common-auth-embedded/claustrum/errors.js +8 -0
- package/dist/common-auth-embedded/claustrum/host-slot.d.ts +39 -0
- package/dist/common-auth-embedded/claustrum/host-slot.js +72 -0
- package/dist/common-auth-embedded/claustrum/index.d.ts +18 -0
- package/dist/common-auth-embedded/claustrum/index.js +22 -0
- package/dist/common-auth-embedded/claustrum/interlock.d.ts +27 -0
- package/dist/common-auth-embedded/claustrum/interlock.js +29 -0
- package/dist/common-auth-embedded/claustrum/roster.d.ts +208 -0
- package/dist/common-auth-embedded/claustrum/roster.js +536 -0
- package/dist/common-auth-embedded/commands/builtins.d.ts +71 -0
- package/dist/common-auth-embedded/commands/builtins.js +534 -0
- package/dist/common-auth-embedded/commands/index.d.ts +10 -0
- package/dist/common-auth-embedded/commands/index.js +5 -0
- package/dist/common-auth-embedded/commands/menu.d.ts +61 -0
- package/dist/common-auth-embedded/commands/menu.js +300 -0
- package/dist/common-auth-embedded/commands/model.d.ts +238 -0
- package/dist/common-auth-embedded/commands/model.js +16 -0
- package/dist/common-auth-embedded/commands/pi.d.ts +23 -0
- package/dist/common-auth-embedded/commands/pi.js +206 -0
- package/dist/common-auth-embedded/commands/seam.d.ts +72 -0
- package/dist/common-auth-embedded/commands/seam.js +301 -0
- package/dist/common-auth-embedded/fs/atomic-write.d.ts +20 -0
- package/dist/common-auth-embedded/fs/atomic-write.js +80 -0
- package/dist/common-auth-embedded/fs/index.d.ts +7 -0
- package/dist/common-auth-embedded/fs/index.js +6 -0
- package/dist/common-auth-embedded/fs/lock-constants.d.ts +14 -0
- package/dist/common-auth-embedded/fs/lock-constants.js +14 -0
- package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +33 -0
- package/dist/common-auth-embedded/fs/refresh-file-lock.js +501 -0
- package/dist/common-auth-embedded/fs/with-lock.d.ts +33 -0
- package/dist/common-auth-embedded/fs/with-lock.js +48 -0
- package/dist/common-auth-embedded/logger/capture-sink.d.ts +14 -0
- package/dist/common-auth-embedded/logger/capture-sink.js +13 -0
- package/dist/common-auth-embedded/logger/engine.d.ts +100 -0
- package/dist/common-auth-embedded/logger/engine.js +246 -0
- package/dist/common-auth-embedded/logger/index.d.ts +6 -0
- package/dist/common-auth-embedded/logger/index.js +3 -0
- package/dist/common-auth-embedded/logger/redact.d.ts +11 -0
- package/dist/common-auth-embedded/logger/redact.js +83 -0
- package/dist/common-auth-embedded/pi-slot/index.d.ts +3 -0
- package/dist/common-auth-embedded/pi-slot/index.js +2 -0
- package/dist/common-auth-embedded/pi-slot/lock.js +212 -0
- package/dist/common-auth-embedded/pi-slot/slot.d.ts +49 -0
- package/dist/common-auth-embedded/pi-slot/slot.js +383 -0
- package/dist/common-auth-embedded/quota/codec.d.ts +11 -0
- package/dist/common-auth-embedded/quota/codec.js +12 -0
- package/dist/common-auth-embedded/quota/format.d.ts +46 -0
- package/dist/common-auth-embedded/quota/format.js +138 -0
- package/dist/common-auth-embedded/quota/index.d.ts +10 -0
- package/dist/common-auth-embedded/quota/index.js +5 -0
- package/dist/common-auth-embedded/quota/map.d.ts +63 -0
- package/dist/common-auth-embedded/quota/map.js +105 -0
- package/dist/common-auth-embedded/quota/merge.d.ts +48 -0
- package/dist/common-auth-embedded/quota/merge.js +162 -0
- package/dist/common-auth-embedded/quota/projection.d.ts +64 -0
- package/dist/common-auth-embedded/quota/projection.js +134 -0
- package/dist/common-auth-embedded/routing/admission.d.ts +93 -0
- package/dist/common-auth-embedded/routing/admission.js +142 -0
- package/dist/common-auth-embedded/routing/index.d.ts +8 -0
- package/dist/common-auth-embedded/routing/index.js +4 -0
- package/dist/common-auth-embedded/routing/ordered.d.ts +47 -0
- package/dist/common-auth-embedded/routing/ordered.js +58 -0
- package/dist/common-auth-embedded/routing/pins.d.ts +22 -0
- package/dist/common-auth-embedded/routing/pins.js +34 -0
- package/dist/common-auth-embedded/routing/sticky.d.ts +158 -0
- package/dist/common-auth-embedded/routing/sticky.js +339 -0
- package/dist/common-auth-embedded/source-output.json +1090 -0
- package/dist/common-auth-embedded/store/attribution.d.ts +28 -0
- package/dist/common-auth-embedded/store/attribution.js +60 -0
- package/dist/common-auth-embedded/store/errors.d.ts +55 -0
- package/dist/common-auth-embedded/store/errors.js +38 -0
- package/dist/common-auth-embedded/store/hooks.d.ts +12 -0
- package/dist/common-auth-embedded/store/hooks.js +34 -0
- package/dist/common-auth-embedded/store/identity.d.ts +39 -0
- package/dist/common-auth-embedded/store/identity.js +79 -0
- package/dist/common-auth-embedded/store/index.d.ts +21 -0
- package/dist/common-auth-embedded/store/index.js +7 -0
- package/dist/common-auth-embedded/store/mutate.d.ts +189 -0
- package/dist/common-auth-embedded/store/mutate.js +500 -0
- package/dist/common-auth-embedded/store/pool.d.ts +196 -0
- package/dist/common-auth-embedded/store/pool.js +120 -0
- package/dist/common-auth-embedded/store/provider-state.d.ts +131 -0
- package/dist/common-auth-embedded/store/provider-state.js +226 -0
- package/dist/common-auth-embedded/store/publication.d.ts +47 -0
- package/dist/common-auth-embedded/store/publication.js +304 -0
- package/dist/common-auth-embedded/store/pull.d.ts +36 -0
- package/dist/common-auth-embedded/store/pull.js +143 -0
- package/dist/common-auth-embedded/store/refresh-lock.d.ts +63 -0
- package/dist/common-auth-embedded/store/refresh-lock.js +136 -0
- package/dist/common-auth-embedded/store/refresh.d.ts +72 -0
- package/dist/common-auth-embedded/store/refresh.js +225 -0
- package/dist/common-auth-embedded/store/reserved.js +13 -0
- package/dist/common-auth-embedded/store/rows.d.ts +313 -0
- package/dist/common-auth-embedded/store/rows.js +1258 -0
- package/dist/common-auth-embedded/store/runtime.d.ts +36 -0
- package/dist/common-auth-embedded/store/runtime.js +68 -0
- package/dist/common-auth-embedded/store/schema.d.ts +443 -0
- package/dist/common-auth-embedded/store/schema.js +754 -0
- package/dist/common-auth-embedded/store/settings.d.ts +63 -0
- package/dist/common-auth-embedded/store/settings.js +120 -0
- package/dist/common-auth-embedded/store/torn.d.ts +78 -0
- package/dist/common-auth-embedded/store/torn.js +294 -0
- package/dist/common-auth-embedded/util/any-signal.js +42 -0
- package/dist/common-auth-runtime.d.ts +73 -0
- package/dist/common-auth-runtime.d.ts.map +1 -0
- package/dist/common-auth-runtime.js +65 -0
- package/dist/common-auth-runtime.js.map +1 -0
- package/dist/credential-redaction.d.ts +13 -0
- package/dist/credential-redaction.d.ts.map +1 -0
- package/dist/credential-redaction.js +230 -0
- package/dist/credential-redaction.js.map +1 -0
- package/dist/fetch-timeout.d.ts +4 -2
- package/dist/fetch-timeout.d.ts.map +1 -1
- package/dist/fetch-timeout.js +121 -14
- package/dist/fetch-timeout.js.map +1 -1
- package/dist/fingerprint.d.ts +3 -3
- package/dist/fingerprint.d.ts.map +1 -1
- package/dist/fingerprint.js +4 -4
- package/dist/fingerprint.js.map +1 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/model-registry.d.ts +16 -0
- package/dist/model-registry.d.ts.map +1 -1
- package/dist/model-registry.js +242 -30
- package/dist/model-registry.js.map +1 -1
- package/dist/quota-manager.d.ts.map +1 -1
- package/dist/quota-manager.js +35 -10
- package/dist/quota-manager.js.map +1 -1
- package/dist/quota-presentation.d.ts +10 -0
- package/dist/quota-presentation.d.ts.map +1 -0
- package/dist/quota-presentation.js +60 -0
- package/dist/quota-presentation.js.map +1 -0
- package/dist/quota-types.d.ts +2 -0
- package/dist/quota-types.d.ts.map +1 -1
- package/dist/rotation.d.ts +8 -0
- package/dist/rotation.d.ts.map +1 -1
- package/dist/rotation.js +24 -0
- package/dist/rotation.js.map +1 -1
- package/dist/transform/agy-generation.d.ts +3 -0
- package/dist/transform/agy-generation.d.ts.map +1 -0
- package/dist/transform/agy-generation.js +28 -0
- package/dist/transform/agy-generation.js.map +1 -0
- package/dist/transform/claude.d.ts +7 -3
- package/dist/transform/claude.d.ts.map +1 -1
- package/dist/transform/claude.js +8 -2
- package/dist/transform/claude.js.map +1 -1
- package/dist/transform/index.d.ts +1 -0
- package/dist/transform/index.d.ts.map +1 -1
- package/dist/transform/index.js +1 -0
- package/dist/transform/index.js.map +1 -1
- package/dist/transform/model-resolver.d.ts +2 -1
- package/dist/transform/model-resolver.d.ts.map +1 -1
- package/dist/transform/model-resolver.js +46 -5
- package/dist/transform/model-resolver.js.map +1 -1
- package/dist/transform/types.d.ts +1 -1
- package/dist/transform/types.d.ts.map +1 -1
- package/dist/vault-account-source.d.ts +183 -0
- package/dist/vault-account-source.d.ts.map +1 -0
- package/dist/vault-account-source.js +296 -0
- package/dist/vault-account-source.js.map +1 -0
- package/dist/vault-enrollment-authority.d.ts +16 -0
- package/dist/vault-enrollment-authority.d.ts.map +1 -0
- package/dist/vault-enrollment-authority.js +19 -0
- package/dist/vault-enrollment-authority.js.map +1 -0
- package/dist/vault-family.d.ts +11 -0
- package/dist/vault-family.d.ts.map +1 -0
- package/dist/vault-family.js +24 -0
- package/dist/vault-family.js.map +1 -0
- package/dist/vault-provider-state.d.ts +162 -0
- package/dist/vault-provider-state.d.ts.map +1 -0
- package/dist/vault-provider-state.js +422 -0
- package/dist/vault-provider-state.js.map +1 -0
- package/package.json +39 -2
package/dist/account-manager.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { rowRefKey, sameRowRef } from "./account-identity.js";
|
|
3
|
+
import { AccountRepositoryError, applyCooldownObservation, applyRateLimitObservation, applySwitch, } from "./account-repository.js";
|
|
4
|
+
import { AccountSelector, clampNonNegativeInt, } from "./account-selector.js";
|
|
2
5
|
import { AccountStorageLockContentionError } from "./account-storage.js";
|
|
3
6
|
import { formatRefreshParts, parseRefreshParts } from "./auth.js";
|
|
4
|
-
import { generateFingerprint,
|
|
5
|
-
import { getQuotaGroupForModel } from "./model-registry.js";
|
|
7
|
+
import { generateFingerprint, updateFingerprintVersion, } from "./fingerprint.js";
|
|
6
8
|
import { normalizeLegacyCachedQuota, } from "./quota-types.js";
|
|
7
|
-
import { getHealthTracker, getTokenTracker,
|
|
9
|
+
import { getHealthTracker, getTokenTracker, HealthScoreTracker, TokenBucketTracker, } from "./rotation.js";
|
|
8
10
|
function isStorageLockContention(error) {
|
|
9
11
|
if (error instanceof AccountStorageLockContentionError)
|
|
10
12
|
return true;
|
|
@@ -13,13 +15,7 @@ function isStorageLockContention(error) {
|
|
|
13
15
|
message.includes('ELOCKED'));
|
|
14
16
|
}
|
|
15
17
|
export { calculateBackoffMs, computeSoftQuotaCacheTtlMs, parseRateLimitReason, } from "./rotation.js";
|
|
16
|
-
|
|
17
|
-
function clampNonNegativeInt(value, fallback) {
|
|
18
|
-
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
19
|
-
return fallback;
|
|
20
|
-
}
|
|
21
|
-
return value < 0 ? 0 : Math.floor(value);
|
|
22
|
-
}
|
|
18
|
+
export { resolveQuotaGroup } from "./account-selector.js";
|
|
23
19
|
/**
|
|
24
20
|
* Opaque identity for a refresh token.
|
|
25
21
|
*
|
|
@@ -30,107 +26,150 @@ function clampNonNegativeInt(value, fallback) {
|
|
|
30
26
|
function quotaAccountIdentity(refreshToken) {
|
|
31
27
|
return createHash('sha256').update(refreshToken).digest('hex').slice(0, 16);
|
|
32
28
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
29
|
+
/** Applies a coalesced row write to the metadata read under the row lock. */
|
|
30
|
+
function applyPendingRowWrite(metadata, pending) {
|
|
31
|
+
let next = metadata;
|
|
32
|
+
const resets = {};
|
|
33
|
+
const cleared = [];
|
|
34
|
+
for (const [key, value] of pending.rateLimits) {
|
|
35
|
+
if (value === 'clear')
|
|
36
|
+
cleared.push(key);
|
|
37
|
+
else
|
|
38
|
+
resets[key] = value;
|
|
39
|
+
}
|
|
40
|
+
if (Object.keys(resets).length > 0) {
|
|
41
|
+
next = applyRateLimitObservation(next, resets);
|
|
42
|
+
}
|
|
43
|
+
if (cleared.length > 0 && next.rateLimitResetTimes != null) {
|
|
44
|
+
const rates = { ...next.rateLimitResetTimes };
|
|
45
|
+
for (const key of cleared)
|
|
46
|
+
delete rates[key];
|
|
47
|
+
next = { ...next, rateLimitResetTimes: rates };
|
|
48
|
+
}
|
|
49
|
+
if (pending.cooldown !== undefined) {
|
|
50
|
+
next = applyCooldownObservation(next, pending.cooldown.value);
|
|
51
|
+
}
|
|
52
|
+
if (pending.switchReason !== undefined) {
|
|
53
|
+
next = applySwitch(next, pending.switchReason);
|
|
54
|
+
}
|
|
55
|
+
if (pending.lastUsed !== undefined && pending.lastUsed > next.lastUsed) {
|
|
56
|
+
next = { ...next, lastUsed: pending.lastUsed };
|
|
57
|
+
}
|
|
58
|
+
return next;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Repository failures a save reports but does not fail on: the write was
|
|
62
|
+
* about a credential the row no longer holds (it is dropped, as intended),
|
|
63
|
+
* or it met lock contention and was queued again for the next save.
|
|
64
|
+
*/
|
|
65
|
+
const TOLERATED_FAILURES = new Set([
|
|
66
|
+
'attribution',
|
|
67
|
+
'unknown-row',
|
|
68
|
+
'lock-contention',
|
|
69
|
+
]);
|
|
70
|
+
/**
|
|
71
|
+
* Thrown by a save, flush or dispose of a repository-backed manager when a
|
|
72
|
+
* write failed. `message` and `failure` are the first failure; `report`
|
|
73
|
+
* holds every failure the repository reported while draining.
|
|
74
|
+
*/
|
|
75
|
+
export class AccountManagerPersistError extends Error {
|
|
76
|
+
failure;
|
|
77
|
+
report;
|
|
78
|
+
constructor(failure, report) {
|
|
79
|
+
super(failure.message);
|
|
80
|
+
this.name = 'AccountManagerPersistError';
|
|
81
|
+
this.failure = failure;
|
|
82
|
+
this.report = report;
|
|
40
83
|
}
|
|
41
|
-
return base;
|
|
42
84
|
}
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
-
return resetTime !== undefined && now() < resetTime;
|
|
85
|
+
function present(value) {
|
|
86
|
+
return value === null ? undefined : value;
|
|
46
87
|
}
|
|
47
|
-
function
|
|
48
|
-
if (
|
|
49
|
-
return
|
|
88
|
+
function quotaGroupsOf(quota) {
|
|
89
|
+
if (quota?.cachedQuota == null)
|
|
90
|
+
return undefined;
|
|
91
|
+
const groups = {};
|
|
92
|
+
for (const [name, group] of Object.entries(quota.cachedQuota)) {
|
|
93
|
+
groups[name] = {
|
|
94
|
+
modelCount: group.modelCount,
|
|
95
|
+
...(group.remainingFraction != null
|
|
96
|
+
? { remainingFraction: group.remainingFraction }
|
|
97
|
+
: {}),
|
|
98
|
+
...(group.resetTime != null ? { resetTime: group.resetTime } : {}),
|
|
99
|
+
...(group.windows != null
|
|
100
|
+
? {
|
|
101
|
+
windows: group.windows.map((window) => ({
|
|
102
|
+
window: window.window,
|
|
103
|
+
remainingFraction: window.remainingFraction,
|
|
104
|
+
resetTime: window.resetTime,
|
|
105
|
+
})),
|
|
106
|
+
}
|
|
107
|
+
: {}),
|
|
108
|
+
};
|
|
50
109
|
}
|
|
51
|
-
|
|
52
|
-
const cliIsLimited = isRateLimitedForHeaderStyle(account, family, 'gemini-cli', now, model);
|
|
53
|
-
return antigravityIsLimited && cliIsLimited;
|
|
110
|
+
return groups;
|
|
54
111
|
}
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
112
|
+
function storedQuotaGroups(groups) {
|
|
113
|
+
const stored = {};
|
|
114
|
+
for (const [name, group] of Object.entries(groups)) {
|
|
115
|
+
if (group === undefined)
|
|
116
|
+
continue;
|
|
117
|
+
stored[name] = {
|
|
118
|
+
modelCount: group.modelCount,
|
|
119
|
+
...(group.remainingFraction !== undefined
|
|
120
|
+
? { remainingFraction: group.remainingFraction }
|
|
121
|
+
: {}),
|
|
122
|
+
...(group.resetTime !== undefined ? { resetTime: group.resetTime } : {}),
|
|
123
|
+
...(group.windows !== undefined
|
|
124
|
+
? { windows: group.windows.map((window) => ({ ...window })) }
|
|
125
|
+
: {}),
|
|
126
|
+
};
|
|
66
127
|
}
|
|
67
|
-
|
|
68
|
-
const baseKey = getQuotaKey(family, headerStyle);
|
|
69
|
-
return isRateLimitedForQuotaKey(account, baseKey, now);
|
|
128
|
+
return stored;
|
|
70
129
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
130
|
+
/** Why a repository row cannot be routed to, or undefined when it can. */
|
|
131
|
+
function unroutableReason(row) {
|
|
132
|
+
if (row.invalid !== undefined)
|
|
133
|
+
return `invalid ${row.invalid}`;
|
|
134
|
+
if (row.torn)
|
|
135
|
+
return 'torn';
|
|
136
|
+
if (row.unbound)
|
|
137
|
+
return `unbound credential (${row.stamp})`;
|
|
138
|
+
if (row.credential === undefined)
|
|
139
|
+
return 'no credential';
|
|
140
|
+
if (row.metadata.status === 'dropped') {
|
|
141
|
+
return `metadata not shown (${row.metadata.reason})`;
|
|
142
|
+
}
|
|
143
|
+
if (row.metadata.status === 'absent')
|
|
144
|
+
return 'metadata absent';
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
/** When the account's newest access evidence was observed, if ever. */
|
|
148
|
+
function accessEvidenceTime(account) {
|
|
149
|
+
const times = [
|
|
150
|
+
account.verificationRequiredAt,
|
|
151
|
+
account.accountIneligibleAt,
|
|
152
|
+
account.eligibilityStateUpdatedAt,
|
|
153
|
+
].filter((time) => time !== undefined);
|
|
154
|
+
return times.length === 0 ? undefined : Math.max(...times);
|
|
80
155
|
}
|
|
81
156
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* When a model string is available we use the model-registry lookup first,
|
|
85
|
-
* then fall back to substring matching. When model is null/undefined we
|
|
86
|
-
* fall back based on family:
|
|
87
|
-
* - Claude → "non-gemini" quota group
|
|
88
|
-
* - Gemini → "gemini" quota group
|
|
89
|
-
*
|
|
90
|
-
* @param family - The model family ("claude" | "gemini")
|
|
91
|
-
* @param model - Optional model string for precise resolution
|
|
92
|
-
* @returns The QuotaGroup to use for soft quota checks
|
|
157
|
+
* The request counts of the later day; on the same day, the larger count of
|
|
158
|
+
* each family, since both only ever grow within a day.
|
|
93
159
|
*/
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return 'non-gemini';
|
|
107
|
-
}
|
|
108
|
-
if (lower.includes('gemini'))
|
|
109
|
-
return 'gemini';
|
|
110
|
-
}
|
|
111
|
-
return family === 'claude' ? 'non-gemini' : 'gemini';
|
|
112
|
-
}
|
|
113
|
-
function isOverSoftQuotaThreshold(account, family, thresholdPercent, cacheTtlMs, now, model) {
|
|
114
|
-
if (thresholdPercent >= 100)
|
|
115
|
-
return false;
|
|
116
|
-
if (!account.cachedQuota)
|
|
117
|
-
return false;
|
|
118
|
-
if (account.cachedQuotaUpdatedAt == null)
|
|
119
|
-
return false;
|
|
120
|
-
const age = now() - account.cachedQuotaUpdatedAt;
|
|
121
|
-
if (age > cacheTtlMs)
|
|
122
|
-
return false;
|
|
123
|
-
const quotaGroup = resolveQuotaGroup(family, model);
|
|
124
|
-
const groupData = account.cachedQuota[quotaGroup];
|
|
125
|
-
if (groupData?.remainingFraction == null)
|
|
126
|
-
return false;
|
|
127
|
-
const remainingFraction = Math.max(0, Math.min(1, groupData.remainingFraction));
|
|
128
|
-
const usedPercent = (1 - remainingFraction) * 100;
|
|
129
|
-
const isOverThreshold = usedPercent >= thresholdPercent;
|
|
130
|
-
return isOverThreshold;
|
|
160
|
+
function laterCounts(a, b) {
|
|
161
|
+
if (a === undefined)
|
|
162
|
+
return b;
|
|
163
|
+
if (b === undefined)
|
|
164
|
+
return a;
|
|
165
|
+
if (a.date !== b.date)
|
|
166
|
+
return a.date > b.date ? a : b;
|
|
167
|
+
return {
|
|
168
|
+
date: a.date,
|
|
169
|
+
claude: Math.max(a.claude, b.claude),
|
|
170
|
+
gemini: Math.max(a.gemini, b.gemini),
|
|
171
|
+
};
|
|
131
172
|
}
|
|
132
|
-
const ACCOUNT_SESSION_STATE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
133
|
-
const MAX_ACCOUNT_SESSION_STATES = 256;
|
|
134
173
|
/**
|
|
135
174
|
* In-memory multi-account manager with sticky account selection.
|
|
136
175
|
*
|
|
@@ -138,55 +177,83 @@ const MAX_ACCOUNT_SESSION_STATES = 256;
|
|
|
138
177
|
* Rate limits are tracked per-model-family (claude/gemini) so an account
|
|
139
178
|
* rate-limited for Claude can still be used for Gemini.
|
|
140
179
|
*
|
|
141
|
-
*
|
|
180
|
+
* Persists either to the pre-store pool file `antigravity-accounts.json`
|
|
181
|
+
* (whole-pool snapshots through an `AccountStorageStore`) or, when built with
|
|
182
|
+
* `fromRepository`, to the account repository, where every change is an
|
|
183
|
+
* attributed write on the row and credential the account was loaded from.
|
|
142
184
|
*/
|
|
143
185
|
export class AccountManager {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Owns all of this manager's selection state: its accounts, the
|
|
188
|
+
* selection, cursors, session pins, trackers, toast and usage counts.
|
|
189
|
+
* The manager itself handles credentials and persistence.
|
|
190
|
+
*/
|
|
191
|
+
selection;
|
|
192
|
+
/**
|
|
193
|
+
* The selector's accounts in pool order: a view of its array, not a copy
|
|
194
|
+
* and not a second list.
|
|
195
|
+
*/
|
|
196
|
+
get accounts() {
|
|
197
|
+
return this.selection.rows;
|
|
198
|
+
}
|
|
156
199
|
savePending = false;
|
|
157
200
|
saveTimeout = null;
|
|
158
201
|
saveInFlight = null;
|
|
159
202
|
disposed = false;
|
|
160
203
|
savePromiseResolvers = [];
|
|
161
|
-
sessionStartTime;
|
|
162
|
-
sessionRequestCounts = new Map();
|
|
163
|
-
sessionUsedAccounts = new Set();
|
|
164
|
-
requestSessionStates = new Map();
|
|
165
204
|
store;
|
|
205
|
+
repository;
|
|
206
|
+
/** Coalesced absolute-state writes per repository credential. */
|
|
207
|
+
pendingRowWrites = new Map();
|
|
208
|
+
/** Latest selection per routing target, written at the next save. */
|
|
209
|
+
pendingSelections = new Map();
|
|
166
210
|
storagePath;
|
|
211
|
+
/** A promise chain that applies reloads in request order. */
|
|
212
|
+
reloading = Promise.resolve();
|
|
167
213
|
onDiagnostic;
|
|
168
214
|
now;
|
|
169
|
-
random;
|
|
170
|
-
pid;
|
|
171
215
|
constructor(authFallback, stored, options) {
|
|
216
|
+
if ((options.store === undefined) === (options.repository === undefined)) {
|
|
217
|
+
throw new Error('AccountManager needs exactly one of a pool-file store and an account repository');
|
|
218
|
+
}
|
|
219
|
+
if (options.repository !== undefined && (stored || authFallback)) {
|
|
220
|
+
throw new Error('a repository-backed AccountManager is loaded with AccountManager.fromRepository');
|
|
221
|
+
}
|
|
172
222
|
this.store = options.store;
|
|
223
|
+
this.repository = options.repository;
|
|
173
224
|
this.storagePath = options.storagePath ?? '';
|
|
174
225
|
this.onDiagnostic = options.onDiagnostic;
|
|
175
226
|
this.now = options.now ?? (() => Date.now());
|
|
176
|
-
|
|
177
|
-
this.
|
|
178
|
-
|
|
227
|
+
const repositoryBacked = options.repository !== undefined;
|
|
228
|
+
this.selection = new AccountSelector({
|
|
229
|
+
sink: repositoryBacked
|
|
230
|
+
? this.repositorySink()
|
|
231
|
+
: {
|
|
232
|
+
requestSave: () => this.requestSaveToDisk(),
|
|
233
|
+
usage: () => this.requestSaveToDisk(),
|
|
234
|
+
stale: (account, label) => this.ignoreStale(account, label),
|
|
235
|
+
},
|
|
236
|
+
// A repository-backed manager gets its own new trackers. A pool-file
|
|
237
|
+
// manager without supplied trackers shares the process-wide ones
|
|
238
|
+
// (`getHealthTracker`, `getTokenTracker`), as it always has.
|
|
239
|
+
healthTracker: options.healthTracker ??
|
|
240
|
+
(repositoryBacked ? new HealthScoreTracker() : getHealthTracker),
|
|
241
|
+
tokenTracker: options.tokenTracker ??
|
|
242
|
+
(repositoryBacked ? new TokenBucketTracker() : getTokenTracker),
|
|
243
|
+
now: this.now,
|
|
244
|
+
random: options.random ?? (() => Math.random()),
|
|
245
|
+
pid: options.pid ?? process.pid,
|
|
246
|
+
onDiagnostic: options.onDiagnostic,
|
|
247
|
+
});
|
|
179
248
|
const authParts = authFallback
|
|
180
249
|
? parseRefreshParts(authFallback.refresh)
|
|
181
250
|
: null;
|
|
182
251
|
if (stored && stored.accounts.length === 0) {
|
|
183
|
-
this.accounts = [];
|
|
184
|
-
this.cursorByFamily = { claude: 0, gemini: 0 };
|
|
185
252
|
return;
|
|
186
253
|
}
|
|
187
254
|
if (stored && stored.accounts.length > 0) {
|
|
188
255
|
const baseNow = this.now();
|
|
189
|
-
|
|
256
|
+
const accounts = stored.accounts
|
|
190
257
|
.map((acc, index) => {
|
|
191
258
|
if (!acc.refreshToken || typeof acc.refreshToken !== 'string') {
|
|
192
259
|
return null;
|
|
@@ -245,20 +312,21 @@ export class AccountManager {
|
|
|
245
312
|
// Saved fingerprints may carry an older version string; this ensures
|
|
246
313
|
// they always reflect the latest fetched (or fallback) version.
|
|
247
314
|
let fingerprintVersionChanged = false;
|
|
248
|
-
for (const acc of
|
|
315
|
+
for (const acc of accounts) {
|
|
249
316
|
if (acc.fingerprint && updateFingerprintVersion(acc.fingerprint)) {
|
|
250
317
|
fingerprintVersionChanged = true;
|
|
251
318
|
}
|
|
252
319
|
}
|
|
320
|
+
// The stored selection is resolved against the stored accounts, before
|
|
321
|
+
// an auth-fallback account is added.
|
|
253
322
|
const legacyCursor = clampNonNegativeInt(stored.activeIndex, 0);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
this.cursorByFamily.gemini = this.currentAccountIndexByFamily.gemini;
|
|
323
|
+
const selection = {};
|
|
324
|
+
if (accounts.length > 0) {
|
|
325
|
+
const defaultIndex = legacyCursor % accounts.length;
|
|
326
|
+
for (const family of ['claude', 'gemini']) {
|
|
327
|
+
selection[family] =
|
|
328
|
+
clampNonNegativeInt(stored.activeIndexByFamily?.[family], defaultIndex) % accounts.length;
|
|
329
|
+
}
|
|
262
330
|
}
|
|
263
331
|
// Persist updated fingerprint versions to disk
|
|
264
332
|
if (fingerprintVersionChanged) {
|
|
@@ -266,11 +334,11 @@ export class AccountManager {
|
|
|
266
334
|
}
|
|
267
335
|
// If current auth isn't in the loaded accounts, add it to the pool
|
|
268
336
|
if (authFallback && authParts?.refreshToken) {
|
|
269
|
-
const hasMatching =
|
|
337
|
+
const hasMatching = accounts.some((acc) => acc.parts.refreshToken === authParts.refreshToken);
|
|
270
338
|
if (!hasMatching) {
|
|
271
339
|
const now = this.now();
|
|
272
340
|
const newAccount = {
|
|
273
|
-
index:
|
|
341
|
+
index: accounts.length,
|
|
274
342
|
email: undefined,
|
|
275
343
|
addedAt: now,
|
|
276
344
|
lastUsed: 0,
|
|
@@ -283,16 +351,17 @@ export class AccountManager {
|
|
|
283
351
|
fingerprint: generateFingerprint(),
|
|
284
352
|
fingerprintHistory: [],
|
|
285
353
|
};
|
|
286
|
-
|
|
354
|
+
accounts.push(newAccount);
|
|
287
355
|
}
|
|
288
356
|
}
|
|
357
|
+
this.selection.resetAccounts(accounts, selection);
|
|
289
358
|
return;
|
|
290
359
|
}
|
|
291
360
|
if (authFallback) {
|
|
292
361
|
const parts = parseRefreshParts(authFallback.refresh);
|
|
293
362
|
if (parts.refreshToken) {
|
|
294
363
|
const now = this.now();
|
|
295
|
-
this.
|
|
364
|
+
this.selection.resetAccounts([
|
|
296
365
|
{
|
|
297
366
|
index: 0,
|
|
298
367
|
email: undefined,
|
|
@@ -305,142 +374,573 @@ export class AccountManager {
|
|
|
305
374
|
rateLimitResetTimes: {},
|
|
306
375
|
touchedForQuota: {},
|
|
307
376
|
},
|
|
308
|
-
];
|
|
309
|
-
this.cursorByFamily = { claude: 0, gemini: 0 };
|
|
310
|
-
this.currentAccountIndexByFamily.claude = 0;
|
|
311
|
-
this.currentAccountIndexByFamily.gemini = 0;
|
|
377
|
+
], { claude: 0, gemini: 0 });
|
|
312
378
|
}
|
|
313
379
|
}
|
|
314
380
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
381
|
+
/**
|
|
382
|
+
* Where a repository-backed manager's selector reports transitions. Each
|
|
383
|
+
* becomes a write attributed to the exact account it was decided for.
|
|
384
|
+
* Rate limits, cooldowns, switches, `lastUsed` and selections are queued
|
|
385
|
+
* and coalesced until the next save; request counts, enabled flags,
|
|
386
|
+
* access verdicts and fingerprints are written immediately.
|
|
387
|
+
*/
|
|
388
|
+
repositorySink() {
|
|
389
|
+
return {
|
|
390
|
+
rateLimit: (account, key, resetAt) => this.queueRowWrite(account, (pending) => {
|
|
391
|
+
pending.rateLimits.set(key, resetAt);
|
|
392
|
+
}),
|
|
393
|
+
cooldown: (account, value) => this.queueRowWrite(account, (pending) => {
|
|
394
|
+
pending.cooldown = { value };
|
|
395
|
+
}),
|
|
396
|
+
switched: (account, reason) => this.queueRowWrite(account, (pending) => {
|
|
397
|
+
pending.switchReason = reason;
|
|
398
|
+
}),
|
|
399
|
+
lastUsed: (account, at) => this.queueRowWrite(account, (pending) => {
|
|
400
|
+
pending.lastUsed = Math.max(pending.lastUsed ?? 0, at);
|
|
401
|
+
}),
|
|
402
|
+
selection: (family, account) => this.queueSelection(family, account),
|
|
403
|
+
usage: (account, family, at) => this.dispatch(account, 'request count', (repository, ref) => repository.recordUsage(ref, { family, at })),
|
|
404
|
+
enabled: (account, enabled) => this.dispatch(account, 'enabled flag', (repository, ref) => repository.setEnabled(ref, { enabled, actor: 'user' })),
|
|
405
|
+
accessVerdict: (account, verdict) => this.persistAccessVerdict(account, verdict),
|
|
406
|
+
fingerprint: (account) => this.persistFingerprint(account),
|
|
407
|
+
requestSave: () => this.requestSaveToDisk(),
|
|
408
|
+
stale: (account, label) => this.ignoreStale(account, label),
|
|
409
|
+
};
|
|
328
410
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
411
|
+
/**
|
|
412
|
+
* A manager over a ready repository read. Rows with missing or unsafe
|
|
413
|
+
* credentials, or absent/dropped provider metadata, are excluded from
|
|
414
|
+
* serving and reported through `onDiagnostic`. Stored row references
|
|
415
|
+
* choose the active account; legacy numeric positions in the account list
|
|
416
|
+
* are used only when no stored reference selects an admitted row.
|
|
417
|
+
*/
|
|
418
|
+
static fromRepository(read, options) {
|
|
419
|
+
if (read.status !== 'ready') {
|
|
420
|
+
throw new Error(`the account repository is not ready (${read.status}); resolve it before routing`);
|
|
421
|
+
}
|
|
422
|
+
const manager = new AccountManager(undefined, null, options);
|
|
423
|
+
manager.loadRepositoryRows(read);
|
|
424
|
+
return manager;
|
|
335
425
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
426
|
+
/**
|
|
427
|
+
* The account a ready repository row describes, or undefined (reported
|
|
428
|
+
* through `onDiagnostic`) for a row that cannot be routed to. `fingerprint`
|
|
429
|
+
* says whether the stored fingerprint was kept, generated because the row
|
|
430
|
+
* had none, or brought to the current runtime version.
|
|
431
|
+
*/
|
|
432
|
+
accountFromRow(row, index, baseNow) {
|
|
433
|
+
const reason = unroutableReason(row);
|
|
434
|
+
const credential = row.credential;
|
|
435
|
+
if (reason !== undefined || credential === undefined) {
|
|
436
|
+
this.onDiagnostic?.('Skipped an account row that cannot be routed to', {
|
|
437
|
+
rowId: row.ref.id,
|
|
438
|
+
reason: reason ?? 'no credential',
|
|
439
|
+
});
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
const meta = row.metadata.status === 'present' ? row.metadata.metadata : undefined;
|
|
443
|
+
const quota = row.quota.status === 'present' ? row.quota.quota : undefined;
|
|
444
|
+
const rateLimitResetTimes = {};
|
|
445
|
+
for (const [key, value] of Object.entries(meta?.rateLimitResetTimes ?? {})) {
|
|
446
|
+
if (typeof value === 'number')
|
|
447
|
+
rateLimitResetTimes[key] = value;
|
|
448
|
+
}
|
|
449
|
+
const projectId = present(meta?.projectId);
|
|
450
|
+
const managedProjectId = present(meta?.managedProjectId);
|
|
451
|
+
const counts = present(meta?.dailyRequestCounts);
|
|
452
|
+
const account = {
|
|
453
|
+
index,
|
|
454
|
+
ref: row.ref,
|
|
455
|
+
email: present(meta?.email),
|
|
456
|
+
label: present(meta?.label),
|
|
457
|
+
addedAt: meta?.addedAt ?? row.storeAddedAt ?? baseNow,
|
|
458
|
+
lastUsed: meta?.lastUsed ?? 0,
|
|
459
|
+
parts: {
|
|
460
|
+
refreshToken: credential.refreshToken,
|
|
461
|
+
projectId,
|
|
462
|
+
managedProjectId,
|
|
463
|
+
},
|
|
464
|
+
projectId,
|
|
465
|
+
managedProjectId,
|
|
466
|
+
access: credential.accessToken,
|
|
467
|
+
expires: credential.expiresAt,
|
|
468
|
+
enabled: row.enabled,
|
|
469
|
+
rateLimitResetTimes,
|
|
470
|
+
lastSwitchReason: present(meta?.lastSwitchReason),
|
|
471
|
+
coolingDownUntil: present(meta?.coolingDownUntil),
|
|
472
|
+
cooldownReason: present(meta?.cooldownReason),
|
|
473
|
+
touchedForQuota: {},
|
|
474
|
+
// Copy the fingerprint before storing it: the manager updates it in
|
|
475
|
+
// place, and the caller owns the read.
|
|
476
|
+
fingerprint: structuredClone(present(meta?.fingerprint)),
|
|
477
|
+
fingerprintHistory: structuredClone(present(meta?.fingerprintHistory)) ?? [],
|
|
478
|
+
cachedQuota: normalizeLegacyCachedQuota(quotaGroupsOf(quota)),
|
|
479
|
+
cachedQuotaAccountId: present(quota?.cachedQuotaAccountId),
|
|
480
|
+
cachedQuotaUpdatedAt: present(quota?.cachedQuotaUpdatedAt),
|
|
481
|
+
capturedTierId: present(meta?.capturedTierId),
|
|
482
|
+
capturedPaidTierId: present(meta?.capturedPaidTierId),
|
|
483
|
+
capturedTierAt: present(meta?.capturedTierAt),
|
|
484
|
+
capturedTierSchemaVersion: present(meta?.capturedTierSchemaVersion),
|
|
485
|
+
dailyRequestCounts: counts === undefined
|
|
486
|
+
? undefined
|
|
487
|
+
: {
|
|
488
|
+
date: counts.date,
|
|
489
|
+
claude: counts.claude,
|
|
490
|
+
gemini: counts.gemini,
|
|
491
|
+
},
|
|
492
|
+
verificationRequired: present(meta?.verificationRequired),
|
|
493
|
+
verificationRequiredAt: present(meta?.verificationRequiredAt),
|
|
494
|
+
verificationRequiredReason: present(meta?.verificationRequiredReason),
|
|
495
|
+
verificationUrl: present(meta?.verificationUrl),
|
|
496
|
+
accountIneligible: present(meta?.accountIneligible),
|
|
497
|
+
accountIneligibleAt: present(meta?.accountIneligibleAt),
|
|
498
|
+
accountIneligibleReason: present(meta?.accountIneligibleReason),
|
|
499
|
+
eligibilityStateUpdatedAt: present(meta?.eligibilityStateUpdatedAt),
|
|
500
|
+
};
|
|
501
|
+
if (account.fingerprint === undefined) {
|
|
502
|
+
account.fingerprint = generateFingerprint();
|
|
503
|
+
return { account, fingerprint: 'generated' };
|
|
504
|
+
}
|
|
505
|
+
if (updateFingerprintVersion(account.fingerprint)) {
|
|
506
|
+
return { account, fingerprint: 'updated' };
|
|
507
|
+
}
|
|
508
|
+
return { account, fingerprint: 'kept' };
|
|
509
|
+
}
|
|
510
|
+
/** The stored selection's index for `family` among `accounts`. */
|
|
511
|
+
routedIndex(accounts, routing, family) {
|
|
512
|
+
const count = accounts.length;
|
|
513
|
+
const indexOf = (ref) => {
|
|
514
|
+
if (ref == null)
|
|
515
|
+
return undefined;
|
|
516
|
+
const found = accounts.findIndex((account) => account.ref !== undefined && sameRowRef(account.ref, ref));
|
|
517
|
+
return found < 0 ? undefined : found;
|
|
354
518
|
};
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
519
|
+
const defaultIndex = indexOf(routing?.activeRow) ??
|
|
520
|
+
clampNonNegativeInt(routing?.activeIndex, 0) % count;
|
|
521
|
+
return (indexOf(routing?.activeRowByFamily?.[family]) ??
|
|
522
|
+
clampNonNegativeInt(routing?.activeIndexByFamily?.[family], defaultIndex) % count);
|
|
523
|
+
}
|
|
524
|
+
loadRepositoryRows(read) {
|
|
525
|
+
const baseNow = this.now();
|
|
526
|
+
const accounts = [];
|
|
527
|
+
const generated = [];
|
|
528
|
+
const versionUpdated = [];
|
|
529
|
+
for (const row of read.rows) {
|
|
530
|
+
const built = this.accountFromRow(row, accounts.length, baseNow);
|
|
531
|
+
if (built === undefined)
|
|
532
|
+
continue;
|
|
533
|
+
if (built.fingerprint === 'generated')
|
|
534
|
+
generated.push(built.account);
|
|
535
|
+
if (built.fingerprint === 'updated')
|
|
536
|
+
versionUpdated.push(built.account);
|
|
537
|
+
accounts.push(built.account);
|
|
538
|
+
}
|
|
539
|
+
const selection = {};
|
|
540
|
+
if (accounts.length > 0) {
|
|
541
|
+
for (const family of ['claude', 'gemini']) {
|
|
542
|
+
selection[family] = this.routedIndex(accounts, read.routing, family);
|
|
363
543
|
}
|
|
364
544
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
545
|
+
this.selection.resetAccounts(accounts, selection);
|
|
546
|
+
// Persist each generated or version-updated fingerprint.
|
|
547
|
+
for (const account of [...generated, ...versionUpdated]) {
|
|
548
|
+
this.persistFingerprint(account);
|
|
368
549
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Brings a repository-backed manager up to date with a newer ready read.
|
|
553
|
+
*
|
|
554
|
+
* Accounts are matched by their exact ref (row id, credential epoch and
|
|
555
|
+
* identity, absence included), never by position. An account whose ref is
|
|
556
|
+
* unchanged stays the same object, with its ref object, its session pins,
|
|
557
|
+
* health, token balance and the other in-memory state; only what the read
|
|
558
|
+
* stores is refreshed, newest evidence winning (see `refreshFromRow`). The
|
|
559
|
+
* ref an account is matched under is the one it holds now, so an account
|
|
560
|
+
* this manager refreshed through `refreshAccount` is matched under its
|
|
561
|
+
* successor ref and keeps its pins. A row whose credential was replaced or
|
|
562
|
+
* re-added by anything else, or that is new, becomes a new account. An
|
|
563
|
+
* account the read no longer holds under its ref leaves the manager: pins
|
|
564
|
+
* and selections on it are dropped, writes still queued for it are
|
|
565
|
+
* discarded, and any later write about the old object is ignored rather
|
|
566
|
+
* than sent. A read that is not ready throws and leaves the manager as it
|
|
567
|
+
* was.
|
|
568
|
+
*
|
|
569
|
+
* This method applies the read it is given and does not flush or read
|
|
570
|
+
* itself. The caller must flush this manager's queued writes before
|
|
571
|
+
* taking the read, and must apply reads in the order they were taken: an
|
|
572
|
+
* older read applied after a newer one would bring back accounts and refs
|
|
573
|
+
* the pool no longer holds (the store's ref fences still refuse their
|
|
574
|
+
* writes). `refreshFromRepository` does all of this.
|
|
575
|
+
*/
|
|
576
|
+
reloadFromRepository(read) {
|
|
577
|
+
if (this.repository === undefined) {
|
|
578
|
+
throw new Error('reloadFromRepository needs a repository-backed manager');
|
|
579
|
+
}
|
|
580
|
+
if (read.status !== 'ready') {
|
|
581
|
+
throw new Error(`the account repository is not ready (${read.status}); the manager keeps its accounts`);
|
|
582
|
+
}
|
|
583
|
+
const baseNow = this.now();
|
|
584
|
+
const fresh = [];
|
|
585
|
+
const fingerprintOf = new Map();
|
|
586
|
+
for (const row of read.rows) {
|
|
587
|
+
const built = this.accountFromRow(row, fresh.length, baseNow);
|
|
588
|
+
if (built === undefined)
|
|
589
|
+
continue;
|
|
590
|
+
fingerprintOf.set(built.account, built.fingerprint);
|
|
591
|
+
fresh.push(built.account);
|
|
592
|
+
}
|
|
593
|
+
const { added } = this.selection.replaceAccounts(fresh, {
|
|
594
|
+
keyOf: (account) => account.ref === undefined ? undefined : rowRefKey(account.ref),
|
|
595
|
+
refresh: (prior, account) => this.refreshFromRow(prior, account, fingerprintOf.get(account) ?? 'kept'),
|
|
596
|
+
// A selection on an account that left falls back to the stored one,
|
|
597
|
+
// as at load; it is not written back.
|
|
598
|
+
selectionFallback: (family, accounts) => this.routedIndex(accounts, read.routing, family),
|
|
599
|
+
});
|
|
600
|
+
const live = new Set(this.accounts.flatMap((account) => account.ref === undefined ? [] : [rowRefKey(account.ref)]));
|
|
601
|
+
for (const key of [...this.pendingRowWrites.keys()]) {
|
|
602
|
+
if (!live.has(key))
|
|
603
|
+
this.pendingRowWrites.delete(key);
|
|
604
|
+
}
|
|
605
|
+
for (const account of added) {
|
|
606
|
+
if (fingerprintOf.get(account) !== 'kept')
|
|
607
|
+
this.persistFingerprint(account);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Flushes this manager's queued writes, takes a fresh read of the
|
|
612
|
+
* repository and applies it with `reloadFromRepository`. Calls run one at
|
|
613
|
+
* a time in the order they were made, so a later call always applies a
|
|
614
|
+
* read taken after the earlier one's. A flush failure is thrown before
|
|
615
|
+
* anything is read; a read that is not ready throws and changes nothing.
|
|
616
|
+
*/
|
|
617
|
+
refreshFromRepository() {
|
|
618
|
+
const repository = this.repository;
|
|
619
|
+
if (repository === undefined) {
|
|
620
|
+
return Promise.reject(new Error('refreshFromRepository needs a repository-backed manager'));
|
|
621
|
+
}
|
|
622
|
+
const run = this.reloading.then(async () => {
|
|
623
|
+
await this.flushSaveToDisk();
|
|
624
|
+
this.reloadFromRepository(await repository.read());
|
|
625
|
+
});
|
|
626
|
+
this.reloading = run.catch(() => { });
|
|
627
|
+
return run;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Refreshes an unchanged account's stored fields from a newer read,
|
|
631
|
+
* keeping what this manager knows to be newer: the later `lastUsed`,
|
|
632
|
+
* access token and evidence times (quota, tier, access verdicts), the
|
|
633
|
+
* later reset of each rate limit, a cooldown or switch it has queued but
|
|
634
|
+
* not yet written, a rate-limit clear it has queued, and request counts
|
|
635
|
+
* of the later day (the larger count per family on the same day). A
|
|
636
|
+
* fingerprint the read lacks keeps the one this manager generated.
|
|
637
|
+
*/
|
|
638
|
+
refreshFromRow(prior, fresh, fingerprint) {
|
|
639
|
+
const pending = prior.ref === undefined
|
|
640
|
+
? undefined
|
|
641
|
+
: this.pendingRowWrites.get(rowRefKey(prior.ref));
|
|
642
|
+
const newer = (a, b) => (a ?? Number.NEGATIVE_INFINITY) >= (b ?? Number.NEGATIVE_INFINITY);
|
|
643
|
+
prior.email = fresh.email;
|
|
644
|
+
prior.label = fresh.label;
|
|
645
|
+
prior.addedAt = fresh.addedAt;
|
|
646
|
+
prior.lastUsed = Math.max(prior.lastUsed, fresh.lastUsed);
|
|
647
|
+
prior.parts = fresh.parts;
|
|
648
|
+
prior.projectId = fresh.projectId;
|
|
649
|
+
prior.managedProjectId = fresh.managedProjectId;
|
|
650
|
+
if (newer(fresh.expires, prior.expires)) {
|
|
651
|
+
prior.access = fresh.access;
|
|
652
|
+
prior.expires = fresh.expires;
|
|
653
|
+
}
|
|
654
|
+
prior.enabled = fresh.enabled;
|
|
655
|
+
const rates = { ...fresh.rateLimitResetTimes };
|
|
656
|
+
for (const [key, value] of Object.entries(prior.rateLimitResetTimes)) {
|
|
657
|
+
const stored = rates[key];
|
|
658
|
+
if (value !== undefined && (stored === undefined || value > stored)) {
|
|
659
|
+
rates[key] = value;
|
|
375
660
|
}
|
|
376
661
|
}
|
|
377
|
-
|
|
378
|
-
|
|
662
|
+
for (const [key, value] of pending?.rateLimits ?? []) {
|
|
663
|
+
if (value === 'clear')
|
|
664
|
+
delete rates[key];
|
|
665
|
+
}
|
|
666
|
+
prior.rateLimitResetTimes = rates;
|
|
667
|
+
if (pending?.cooldown === undefined) {
|
|
668
|
+
prior.coolingDownUntil = fresh.coolingDownUntil;
|
|
669
|
+
prior.cooldownReason = fresh.cooldownReason;
|
|
670
|
+
}
|
|
671
|
+
if (pending?.switchReason === undefined) {
|
|
672
|
+
prior.lastSwitchReason = fresh.lastSwitchReason;
|
|
673
|
+
}
|
|
674
|
+
if (fingerprint !== 'generated') {
|
|
675
|
+
prior.fingerprint = fresh.fingerprint;
|
|
676
|
+
prior.fingerprintHistory = fresh.fingerprintHistory;
|
|
677
|
+
}
|
|
678
|
+
if (newer(fresh.cachedQuotaUpdatedAt, prior.cachedQuotaUpdatedAt)) {
|
|
679
|
+
prior.cachedQuota = fresh.cachedQuota;
|
|
680
|
+
prior.cachedQuotaAccountId = fresh.cachedQuotaAccountId;
|
|
681
|
+
prior.cachedQuotaUpdatedAt = fresh.cachedQuotaUpdatedAt;
|
|
682
|
+
}
|
|
683
|
+
if (newer(fresh.capturedTierAt, prior.capturedTierAt)) {
|
|
684
|
+
prior.capturedTierId = fresh.capturedTierId;
|
|
685
|
+
prior.capturedPaidTierId = fresh.capturedPaidTierId;
|
|
686
|
+
prior.capturedTierAt = fresh.capturedTierAt;
|
|
687
|
+
prior.capturedTierSchemaVersion = fresh.capturedTierSchemaVersion;
|
|
688
|
+
}
|
|
689
|
+
if (newer(accessEvidenceTime(fresh), accessEvidenceTime(prior))) {
|
|
690
|
+
prior.verificationRequired = fresh.verificationRequired;
|
|
691
|
+
prior.verificationRequiredAt = fresh.verificationRequiredAt;
|
|
692
|
+
prior.verificationRequiredReason = fresh.verificationRequiredReason;
|
|
693
|
+
prior.verificationUrl = fresh.verificationUrl;
|
|
694
|
+
prior.accountIneligible = fresh.accountIneligible;
|
|
695
|
+
prior.accountIneligibleAt = fresh.accountIneligibleAt;
|
|
696
|
+
prior.accountIneligibleReason = fresh.accountIneligibleReason;
|
|
697
|
+
prior.eligibilityStateUpdatedAt = fresh.eligibilityStateUpdatedAt;
|
|
698
|
+
}
|
|
699
|
+
prior.dailyRequestCounts = laterCounts(prior.dailyRequestCounts, fresh.dailyRequestCounts);
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Adopt one current row with the selected account's exact row ID,
|
|
703
|
+
* credential epoch and authenticated identity after its bearer changes.
|
|
704
|
+
* General reloads keep the token with the later expiry; here the stored
|
|
705
|
+
* token and expiry always win. Keep selection, request counts, and pending
|
|
706
|
+
* rate-limit/cooldown observations attributed to that unchanged row ref.
|
|
707
|
+
* This writes nothing and cannot restore missing provider metadata.
|
|
708
|
+
*/
|
|
709
|
+
adoptCurrentRow(account, row) {
|
|
710
|
+
if (this.repository === undefined ||
|
|
711
|
+
!this.accounts.includes(account) ||
|
|
712
|
+
account.ref === undefined ||
|
|
713
|
+
!sameRowRef(account.ref, row.ref) ||
|
|
714
|
+
!account.enabled ||
|
|
715
|
+
!row.enabled ||
|
|
716
|
+
!row.usable ||
|
|
717
|
+
unroutableReason(row) !== undefined)
|
|
718
|
+
return false;
|
|
719
|
+
const fresh = this.accountFromRow(row, account.index, this.now());
|
|
720
|
+
if (fresh === undefined)
|
|
721
|
+
return false;
|
|
722
|
+
this.refreshFromRow(account, fresh.account, fresh.fingerprint);
|
|
723
|
+
account.access = fresh.account.access;
|
|
724
|
+
account.expires = fresh.account.expires;
|
|
725
|
+
return true;
|
|
726
|
+
}
|
|
727
|
+
// ========== Repository writes ==========
|
|
728
|
+
/**
|
|
729
|
+
* Starts one attributed repository write about `account`. Its failure is
|
|
730
|
+
* reported here and kept by the repository for the next flush, which
|
|
731
|
+
* decides whether a save fails.
|
|
732
|
+
*/
|
|
733
|
+
dispatch(account, label, write) {
|
|
734
|
+
const repository = this.repository;
|
|
735
|
+
const ref = account.ref;
|
|
736
|
+
if (repository === undefined || ref === undefined)
|
|
737
|
+
return;
|
|
738
|
+
if (!this.selection.has(account)) {
|
|
739
|
+
this.ignoreStale(account, label);
|
|
740
|
+
return;
|
|
379
741
|
}
|
|
742
|
+
write(repository, ref).catch((error) => {
|
|
743
|
+
this.onDiagnostic?.(`Account ${label} was not persisted`, {
|
|
744
|
+
rowId: ref.id,
|
|
745
|
+
error: error instanceof Error ? error.message : String(error),
|
|
746
|
+
});
|
|
747
|
+
});
|
|
380
748
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
749
|
+
/**
|
|
750
|
+
* A write about an account that left the manager at a reload (its
|
|
751
|
+
* credential was replaced or its row removed) is not sent: it was decided
|
|
752
|
+
* for a credential the pool no longer holds under this manager.
|
|
753
|
+
*/
|
|
754
|
+
ignoreStale(account, label) {
|
|
755
|
+
this.onDiagnostic?.(`Account ${label} ignored: the account is no longer loaded`, account.ref === undefined ? undefined : { rowId: account.ref.id });
|
|
385
756
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
757
|
+
/** Queues an absolute-state change of `account` for the next save. */
|
|
758
|
+
queueRowWrite(account, change) {
|
|
759
|
+
if (this.repository === undefined || account.ref === undefined)
|
|
760
|
+
return;
|
|
761
|
+
if (!this.selection.has(account)) {
|
|
762
|
+
this.ignoreStale(account, 'state change');
|
|
389
763
|
return;
|
|
390
764
|
}
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
if (
|
|
394
|
-
|
|
395
|
-
this.
|
|
765
|
+
const key = rowRefKey(account.ref);
|
|
766
|
+
let pending = this.pendingRowWrites.get(key);
|
|
767
|
+
if (pending === undefined) {
|
|
768
|
+
pending = { ref: account.ref, rateLimits: new Map() };
|
|
769
|
+
this.pendingRowWrites.set(key, pending);
|
|
396
770
|
}
|
|
771
|
+
change(pending);
|
|
772
|
+
this.requestSaveToDisk();
|
|
397
773
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const nextGlobalCursor = this.cursorByFamily[family] + 1;
|
|
405
|
-
this.cursorByFamily[family] = nextGlobalCursor;
|
|
406
|
-
if (identity) {
|
|
407
|
-
this.getRequestSessionState(identity).cursorByFamily[family] += 1;
|
|
774
|
+
/** Merges a write that met lock contention back into the queue. */
|
|
775
|
+
requeueRowWrite(failed) {
|
|
776
|
+
const key = rowRefKey(failed.ref);
|
|
777
|
+
const newer = this.pendingRowWrites.get(key);
|
|
778
|
+
if (newer === undefined) {
|
|
779
|
+
this.pendingRowWrites.set(key, failed);
|
|
408
780
|
}
|
|
781
|
+
else {
|
|
782
|
+
for (const [rateKey, value] of failed.rateLimits) {
|
|
783
|
+
if (!newer.rateLimits.has(rateKey))
|
|
784
|
+
newer.rateLimits.set(rateKey, value);
|
|
785
|
+
}
|
|
786
|
+
newer.cooldown ??= failed.cooldown;
|
|
787
|
+
newer.switchReason ??= failed.switchReason;
|
|
788
|
+
if (failed.lastUsed !== undefined &&
|
|
789
|
+
(newer.lastUsed === undefined || failed.lastUsed > newer.lastUsed)) {
|
|
790
|
+
newer.lastUsed = failed.lastUsed;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
this.requestSaveToDisk();
|
|
409
794
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
795
|
+
/**
|
|
796
|
+
* Queues the pool-wide selection of `family` for the next save. Claude's
|
|
797
|
+
* selection is also recorded as the `active` target, the single active
|
|
798
|
+
* account the pool file kept before it tracked families separately.
|
|
799
|
+
*/
|
|
800
|
+
queueSelection(family, account) {
|
|
801
|
+
if (this.repository === undefined)
|
|
802
|
+
return;
|
|
803
|
+
const ref = account?.ref ?? null;
|
|
804
|
+
this.pendingSelections.set(family, ref);
|
|
805
|
+
if (family === 'claude')
|
|
806
|
+
this.pendingSelections.set('active', ref);
|
|
807
|
+
this.requestSaveToDisk();
|
|
414
808
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
809
|
+
persistFingerprint(account) {
|
|
810
|
+
const fingerprint = account.fingerprint;
|
|
811
|
+
if (fingerprint === undefined)
|
|
812
|
+
return;
|
|
813
|
+
const history = [...(account.fingerprintHistory ?? [])];
|
|
814
|
+
this.dispatch(account, 'fingerprint', (repository, ref) => repository.recordFingerprint(ref, { fingerprint, history }));
|
|
815
|
+
}
|
|
816
|
+
persistAccessVerdict(account, verdict) {
|
|
817
|
+
this.dispatch(account, 'access verdict', (repository, ref) => repository.recordAccessVerdict(ref, verdict));
|
|
818
|
+
}
|
|
819
|
+
/** Starts every queued coalesced write. */
|
|
820
|
+
dispatchQueuedWrites() {
|
|
821
|
+
const repository = this.repository;
|
|
822
|
+
if (repository === undefined)
|
|
823
|
+
return;
|
|
824
|
+
const rows = [...this.pendingRowWrites.values()];
|
|
825
|
+
const selections = [...this.pendingSelections];
|
|
826
|
+
this.pendingRowWrites = new Map();
|
|
827
|
+
this.pendingSelections = new Map();
|
|
828
|
+
for (const pending of rows) {
|
|
829
|
+
repository
|
|
830
|
+
.updateMetadata(pending.ref, (current, row) => ({
|
|
831
|
+
kind: 'set',
|
|
832
|
+
metadata: applyPendingRowWrite(current ?? { addedAt: row.storeAddedAt ?? this.now(), lastUsed: 0 }, pending),
|
|
833
|
+
}))
|
|
834
|
+
.catch((error) => {
|
|
835
|
+
if (error instanceof AccountRepositoryError &&
|
|
836
|
+
error.failure.kind === 'lock-contention' &&
|
|
837
|
+
!error.failure.ambiguous) {
|
|
838
|
+
this.requeueRowWrite(pending);
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
for (const [target, ref] of selections) {
|
|
843
|
+
repository.selectAccount(target, ref).catch(() => {
|
|
844
|
+
// Reported by the flush that follows.
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Starts the queued writes and waits until the repository has drained
|
|
850
|
+
* every write, including the ones started immediately (usage, access,
|
|
851
|
+
* removal). Throws `AccountManagerPersistError` for the first failure that
|
|
852
|
+
* is not tolerated; every failure is reported through `onDiagnostic`.
|
|
853
|
+
*/
|
|
854
|
+
async flushRepositoryWrites() {
|
|
855
|
+
const repository = this.repository;
|
|
856
|
+
if (repository === undefined)
|
|
857
|
+
return;
|
|
858
|
+
this.dispatchQueuedWrites();
|
|
859
|
+
const report = await repository.flush();
|
|
860
|
+
for (const failure of report.failures) {
|
|
861
|
+
this.onDiagnostic?.('Account repository write failed', {
|
|
862
|
+
operation: failure.operation,
|
|
863
|
+
kind: failure.kind,
|
|
864
|
+
rowId: failure.rowId,
|
|
865
|
+
ambiguous: failure.ambiguous,
|
|
866
|
+
message: failure.message,
|
|
867
|
+
});
|
|
418
868
|
}
|
|
419
|
-
const
|
|
420
|
-
if (
|
|
421
|
-
|
|
869
|
+
const primary = report.failures.find((failure) => !TOLERATED_FAILURES.has(failure.kind));
|
|
870
|
+
if (primary !== undefined)
|
|
871
|
+
throw new AccountManagerPersistError(primary, report);
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Refreshes the account's credential through the repository: the only
|
|
875
|
+
* path by which a successor token is stored. A rotation updates the
|
|
876
|
+
* account's bearer and ref (an identity learnt by the refresh is part of
|
|
877
|
+
* it) and reads the committed refresh token back; a contradicted identity
|
|
878
|
+
* disables the account in memory, as the store disabled its row.
|
|
879
|
+
*/
|
|
880
|
+
async refreshAccount(account, options) {
|
|
881
|
+
const repository = this.repository;
|
|
882
|
+
const ref = account.ref;
|
|
883
|
+
if (repository === undefined || ref === undefined) {
|
|
884
|
+
throw new Error('refreshAccount needs a repository-backed account');
|
|
885
|
+
}
|
|
886
|
+
const outcome = await repository.refresh(ref, options);
|
|
887
|
+
if (outcome.status === 'rotated') {
|
|
888
|
+
account.ref = outcome.ref;
|
|
889
|
+
account.access = outcome.accessToken;
|
|
890
|
+
account.expires = outcome.expiresAt;
|
|
891
|
+
const read = await repository.read();
|
|
892
|
+
if (read.status === 'ready') {
|
|
893
|
+
const row = read.rows.find((candidate) => sameRowRef(candidate.ref, outcome.ref));
|
|
894
|
+
if (row?.credential !== undefined) {
|
|
895
|
+
account.parts = {
|
|
896
|
+
...account.parts,
|
|
897
|
+
refreshToken: row.credential.refreshToken,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
}
|
|
422
901
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
902
|
+
else if (outcome.status === 'identity-contradicted') {
|
|
903
|
+
account.access = undefined;
|
|
904
|
+
account.expires = undefined;
|
|
905
|
+
this.selection.setEnabledInMemory(account, false);
|
|
427
906
|
}
|
|
428
|
-
|
|
429
|
-
|
|
907
|
+
return outcome;
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* The health scores this manager selects with, keyed by its current
|
|
911
|
+
* account indexes. Record request outcomes here, not in the process-wide
|
|
912
|
+
* tracker, for a repository-backed manager.
|
|
913
|
+
*/
|
|
914
|
+
get healthTracker() {
|
|
915
|
+
return this.selection.healthTracker;
|
|
916
|
+
}
|
|
917
|
+
/** The token balances this manager selects with; see `healthTracker`. */
|
|
918
|
+
get tokenTracker() {
|
|
919
|
+
return this.selection.tokenTracker;
|
|
920
|
+
}
|
|
921
|
+
// ========== Selection, owned by `this.selection` ==========
|
|
922
|
+
getAccountCount() {
|
|
923
|
+
return this.selection.getAccountCount();
|
|
924
|
+
}
|
|
925
|
+
getTotalAccountCount() {
|
|
926
|
+
return this.selection.getTotalAccountCount();
|
|
927
|
+
}
|
|
928
|
+
getEnabledAccounts() {
|
|
929
|
+
return this.selection.getEnabledAccounts();
|
|
930
|
+
}
|
|
931
|
+
/** Copies of the accounts, each with its own credential parts and rate limits. */
|
|
932
|
+
getAccountsSnapshot() {
|
|
933
|
+
return this.accounts.map((a) => ({
|
|
934
|
+
...a,
|
|
935
|
+
parts: { ...a.parts },
|
|
936
|
+
rateLimitResetTimes: { ...a.rateLimitResetTimes },
|
|
937
|
+
}));
|
|
430
938
|
}
|
|
431
939
|
deleteSessionState(sessionId) {
|
|
432
|
-
this.
|
|
940
|
+
this.selection.deleteSessionState(sessionId);
|
|
433
941
|
}
|
|
434
942
|
getCurrentAccountForFamily(family, identity) {
|
|
435
|
-
|
|
436
|
-
if (currentIndex >= 0 && currentIndex < this.accounts.length) {
|
|
437
|
-
const account = this.accounts[currentIndex] ?? null;
|
|
438
|
-
// Only return account if it's enabled - disabled accounts should not be selected
|
|
439
|
-
if (account && account.enabled !== false) {
|
|
440
|
-
return account;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
return null;
|
|
943
|
+
return this.selection.getCurrentAccountForFamily(family, identity);
|
|
444
944
|
}
|
|
445
945
|
/**
|
|
446
946
|
* Numeric active indexes for each model family. Exposed so callers
|
|
@@ -449,29 +949,20 @@ export class AccountManager {
|
|
|
449
949
|
* through the account-lookup layer.
|
|
450
950
|
*/
|
|
451
951
|
getActiveIndexByFamily(identity) {
|
|
452
|
-
return
|
|
453
|
-
claude: this.getActiveIndex('claude', identity),
|
|
454
|
-
gemini: this.getActiveIndex('gemini', identity),
|
|
455
|
-
};
|
|
952
|
+
return this.selection.getActiveIndexByFamily(identity);
|
|
456
953
|
}
|
|
457
954
|
markSwitched(account, reason, family, identity) {
|
|
458
|
-
account
|
|
459
|
-
this.setActiveIndex(family, account.index, identity);
|
|
955
|
+
this.selection.markSwitched(account, reason, family, identity);
|
|
460
956
|
}
|
|
461
957
|
/**
|
|
462
958
|
* Check if we should show an account switch toast.
|
|
463
959
|
* Debounces repeated toasts for the same account.
|
|
464
960
|
*/
|
|
465
961
|
shouldShowAccountToast(accountIndex, debounceMs = 30000) {
|
|
466
|
-
|
|
467
|
-
if (accountIndex !== this.lastToastAccountIndex) {
|
|
468
|
-
return true;
|
|
469
|
-
}
|
|
470
|
-
return now - this.lastToastTime >= debounceMs;
|
|
962
|
+
return this.selection.shouldShowAccountToast(accountIndex, debounceMs);
|
|
471
963
|
}
|
|
472
964
|
markToastShown(accountIndex) {
|
|
473
|
-
this.
|
|
474
|
-
this.lastToastTime = this.now();
|
|
965
|
+
this.selection.markToastShown(accountIndex);
|
|
475
966
|
}
|
|
476
967
|
getCurrentOrNextForFamily(family, model, strategy = 'sticky', headerStyle = 'antigravity', pidOffsetEnabled = false, softQuotaThresholdPercent = 100, softQuotaCacheTtlMs = 10 * 60 * 1000, identity,
|
|
477
968
|
/**
|
|
@@ -483,132 +974,15 @@ export class AccountManager {
|
|
|
483
974
|
* wait path.
|
|
484
975
|
*/
|
|
485
976
|
excludeIndexes) {
|
|
486
|
-
|
|
487
|
-
const effectiveSoftQuotaThreshold = this.getEffectiveSoftQuotaThreshold(softQuotaThresholdPercent);
|
|
488
|
-
// OpenCode may run many root and child sessions concurrently in one plugin
|
|
489
|
-
// process. Pin each exact session until its account becomes unavailable.
|
|
490
|
-
if (identity) {
|
|
491
|
-
const pinned = this.getCurrentAccountForFamily(family, identity);
|
|
492
|
-
if (pinned) {
|
|
493
|
-
clearExpiredRateLimits(pinned, this.now);
|
|
494
|
-
const unavailable = (excludeIndexes?.has(pinned.index) ?? false) ||
|
|
495
|
-
isRateLimitedForHeaderStyle(pinned, family, headerStyle, this.now, model) ||
|
|
496
|
-
isOverSoftQuotaThreshold(pinned, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model) ||
|
|
497
|
-
this.isAccountCoolingDown(pinned);
|
|
498
|
-
if (!unavailable) {
|
|
499
|
-
this.markTouchedForQuota(pinned, quotaKey);
|
|
500
|
-
return pinned;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
if (strategy === 'round-robin') {
|
|
505
|
-
const next = this.getNextForFamily(family, model, headerStyle, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, identity, excludeIndexes);
|
|
506
|
-
if (next) {
|
|
507
|
-
this.markTouchedForQuota(next, quotaKey);
|
|
508
|
-
this.setActiveIndex(family, next.index, identity);
|
|
509
|
-
}
|
|
510
|
-
return next;
|
|
511
|
-
}
|
|
512
|
-
if (strategy === 'hybrid') {
|
|
513
|
-
const healthTracker = getHealthTracker();
|
|
514
|
-
const tokenTracker = getTokenTracker();
|
|
515
|
-
const eligibleAccounts = this.preferAccountOutsideParent(this.accounts.filter((acc) => acc.enabled !== false && !excludeIndexes?.has(acc.index)), family, identity);
|
|
516
|
-
const accountsWithMetrics = eligibleAccounts.map((acc) => {
|
|
517
|
-
clearExpiredRateLimits(acc, this.now);
|
|
518
|
-
return {
|
|
519
|
-
index: acc.index,
|
|
520
|
-
lastUsed: acc.lastUsed,
|
|
521
|
-
healthScore: healthTracker.getScore(acc.index),
|
|
522
|
-
isRateLimited: isRateLimitedForHeaderStyle(acc, family, headerStyle, this.now, model) ||
|
|
523
|
-
isOverSoftQuotaThreshold(acc, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model),
|
|
524
|
-
isCoolingDown: this.isAccountCoolingDown(acc),
|
|
525
|
-
};
|
|
526
|
-
});
|
|
527
|
-
// Get current account index for stickiness
|
|
528
|
-
const currentIndex = this.getActiveIndex(family, identity);
|
|
529
|
-
const selectedIndex = selectHybridAccount(accountsWithMetrics, tokenTracker, currentIndex, 50, this.now);
|
|
530
|
-
if (selectedIndex !== null) {
|
|
531
|
-
const selected = this.accounts[selectedIndex];
|
|
532
|
-
if (selected) {
|
|
533
|
-
selected.lastUsed = this.now();
|
|
534
|
-
this.markTouchedForQuota(selected, quotaKey);
|
|
535
|
-
this.setActiveIndex(family, selected.index, identity);
|
|
536
|
-
return selected;
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
// Fallback: sticky selection (used when hybrid finds no candidates)
|
|
541
|
-
// PID-based offset for multi-session distribution (opt-in)
|
|
542
|
-
// Different sessions (PIDs) will prefer different starting accounts
|
|
543
|
-
const offsetApplied = identity
|
|
544
|
-
? this.getRequestSessionState(identity).offsetAppliedByFamily
|
|
545
|
-
: this.sessionOffsetApplied;
|
|
546
|
-
if (pidOffsetEnabled &&
|
|
547
|
-
!offsetApplied[family] &&
|
|
548
|
-
this.accounts.length > 1) {
|
|
549
|
-
const pidOffset = this.pid % this.accounts.length;
|
|
550
|
-
const activeIndex = this.getActiveIndex(family, identity);
|
|
551
|
-
const baseIndex = activeIndex >= 0 ? activeIndex : this.getCursor(family, identity);
|
|
552
|
-
const newIndex = (baseIndex + pidOffset) % this.accounts.length;
|
|
553
|
-
this.onDiagnostic?.('Applying PID account offset', {
|
|
554
|
-
pid: this.pid,
|
|
555
|
-
offset: pidOffset,
|
|
556
|
-
family,
|
|
557
|
-
fromIndex: baseIndex,
|
|
558
|
-
toIndex: newIndex,
|
|
559
|
-
});
|
|
560
|
-
this.setActiveIndex(family, newIndex, identity);
|
|
561
|
-
offsetApplied[family] = true;
|
|
562
|
-
}
|
|
563
|
-
const current = this.getCurrentAccountForFamily(family, identity);
|
|
564
|
-
if (current && !excludeIndexes?.has(current.index)) {
|
|
565
|
-
clearExpiredRateLimits(current, this.now);
|
|
566
|
-
const isLimitedForRequestedStyle = isRateLimitedForHeaderStyle(current, family, headerStyle, this.now, model);
|
|
567
|
-
const isOverThreshold = isOverSoftQuotaThreshold(current, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model);
|
|
568
|
-
if (!isLimitedForRequestedStyle &&
|
|
569
|
-
!isOverThreshold &&
|
|
570
|
-
!this.isAccountCoolingDown(current)) {
|
|
571
|
-
this.markTouchedForQuota(current, quotaKey);
|
|
572
|
-
return current;
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
const next = this.getNextForFamily(family, model, headerStyle, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, identity, excludeIndexes);
|
|
576
|
-
if (next) {
|
|
577
|
-
this.markTouchedForQuota(next, quotaKey);
|
|
578
|
-
this.setActiveIndex(family, next.index, identity);
|
|
579
|
-
}
|
|
580
|
-
return next;
|
|
977
|
+
return this.selection.getCurrentOrNextForFamily(family, model, strategy, headerStyle, pidOffsetEnabled, softQuotaThresholdPercent, softQuotaCacheTtlMs, identity, excludeIndexes);
|
|
581
978
|
}
|
|
582
979
|
getNextForFamily(family, model, headerStyle = 'antigravity', softQuotaThresholdPercent = 100, softQuotaCacheTtlMs = 10 * 60 * 1000, identity,
|
|
583
980
|
/** Indexes ruled out by the caller (e.g. killswitch pre-filter). */
|
|
584
981
|
excludeIndexes) {
|
|
585
|
-
|
|
586
|
-
const allAvailable = this.accounts.filter((account) => {
|
|
587
|
-
clearExpiredRateLimits(account, this.now);
|
|
588
|
-
return (account.enabled !== false &&
|
|
589
|
-
!excludeIndexes?.has(account.index) &&
|
|
590
|
-
!isRateLimitedForHeaderStyle(account, family, headerStyle, this.now, model) &&
|
|
591
|
-
!isOverSoftQuotaThreshold(account, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model) &&
|
|
592
|
-
!this.isAccountCoolingDown(account));
|
|
593
|
-
});
|
|
594
|
-
const available = this.preferAccountOutsideParent(allAvailable, family, identity);
|
|
595
|
-
if (available.length === 0) {
|
|
596
|
-
return null;
|
|
597
|
-
}
|
|
598
|
-
const usedAccounts = this.getUsedAccounts(identity);
|
|
599
|
-
const sessionUsed = available.filter((account) => usedAccounts.has(account.index));
|
|
600
|
-
const candidates = sessionUsed.length > 0 ? sessionUsed : available;
|
|
601
|
-
const cursor = this.getCursor(family, identity);
|
|
602
|
-
const account = candidates[cursor % candidates.length];
|
|
603
|
-
if (!account) {
|
|
604
|
-
return null;
|
|
605
|
-
}
|
|
606
|
-
this.advanceCursor(family, identity);
|
|
607
|
-
return account;
|
|
982
|
+
return this.selection.getNextForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs, identity, excludeIndexes);
|
|
608
983
|
}
|
|
609
984
|
markRateLimited(account, retryAfterMs, family, headerStyle = 'antigravity', model) {
|
|
610
|
-
|
|
611
|
-
account.rateLimitResetTimes[key] = this.now() + retryAfterMs;
|
|
985
|
+
this.selection.markRateLimited(account, retryAfterMs, family, headerStyle, model);
|
|
612
986
|
}
|
|
613
987
|
/**
|
|
614
988
|
* Mark an account as used after a successful API request.
|
|
@@ -616,167 +990,58 @@ export class AccountManager {
|
|
|
616
990
|
* Should be called AFTER request completion, not during account selection.
|
|
617
991
|
*/
|
|
618
992
|
markAccountUsed(accountIndex) {
|
|
619
|
-
|
|
620
|
-
if (account) {
|
|
621
|
-
account.lastUsed = this.now();
|
|
622
|
-
}
|
|
993
|
+
this.selection.markAccountUsed(accountIndex);
|
|
623
994
|
}
|
|
624
995
|
recordSessionUsage(accountIndex, identity) {
|
|
625
|
-
this.
|
|
996
|
+
this.selection.recordSessionUsage(accountIndex, identity);
|
|
626
997
|
}
|
|
627
998
|
wasUsedInSession(accountIndex, identity) {
|
|
628
|
-
return this.
|
|
999
|
+
return this.selection.wasUsedInSession(accountIndex, identity);
|
|
629
1000
|
}
|
|
630
1001
|
shouldProactivelyRotate(family, model, thresholdPercent, cacheTtlMs, identity) {
|
|
631
|
-
|
|
632
|
-
return false;
|
|
633
|
-
const current = this.getCurrentAccountForFamily(family, identity);
|
|
634
|
-
if (!current?.cachedQuota || current.cachedQuotaUpdatedAt == null)
|
|
635
|
-
return false;
|
|
636
|
-
const age = this.now() - current.cachedQuotaUpdatedAt;
|
|
637
|
-
if (age > cacheTtlMs)
|
|
638
|
-
return false;
|
|
639
|
-
const quotaGroup = resolveQuotaGroup(family, model);
|
|
640
|
-
const groupData = current.cachedQuota[quotaGroup];
|
|
641
|
-
if (groupData?.remainingFraction == null)
|
|
642
|
-
return false;
|
|
643
|
-
const remainingPercent = Math.max(0, Math.min(100, groupData.remainingFraction * 100));
|
|
644
|
-
return remainingPercent < thresholdPercent;
|
|
1002
|
+
return this.selection.shouldProactivelyRotate(family, model, thresholdPercent, cacheTtlMs, identity);
|
|
645
1003
|
}
|
|
646
1004
|
proactivelyRotateForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs, identity) {
|
|
647
|
-
|
|
648
|
-
const candidates = this.preferAccountOutsideParent(this.accounts.filter((acc) => {
|
|
649
|
-
if (acc.enabled === false)
|
|
650
|
-
return false;
|
|
651
|
-
if (acc.index === currentIndex)
|
|
652
|
-
return false;
|
|
653
|
-
clearExpiredRateLimits(acc, this.now);
|
|
654
|
-
if (isRateLimitedForHeaderStyle(acc, family, headerStyle, this.now, model))
|
|
655
|
-
return false;
|
|
656
|
-
if (isOverSoftQuotaThreshold(acc, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, this.now, model))
|
|
657
|
-
return false;
|
|
658
|
-
if (this.isAccountCoolingDown(acc))
|
|
659
|
-
return false;
|
|
660
|
-
return true;
|
|
661
|
-
}), family, identity);
|
|
662
|
-
if (candidates.length === 0)
|
|
663
|
-
return null;
|
|
664
|
-
const usedAccounts = this.getUsedAccounts(identity);
|
|
665
|
-
const warmCandidates = candidates.filter((account) => usedAccounts.has(account.index));
|
|
666
|
-
const pool = warmCandidates.length > 0 ? warmCandidates : candidates;
|
|
667
|
-
const quotaGroup = resolveQuotaGroup(family, model);
|
|
668
|
-
pool.sort((a, b) => {
|
|
669
|
-
const aRemaining = a.cachedQuota?.[quotaGroup]?.remainingFraction ?? 0;
|
|
670
|
-
const bRemaining = b.cachedQuota?.[quotaGroup]?.remainingFraction ?? 0;
|
|
671
|
-
return bRemaining - aRemaining;
|
|
672
|
-
});
|
|
673
|
-
const selected = pool[0];
|
|
674
|
-
if (!selected)
|
|
675
|
-
return null;
|
|
676
|
-
const quotaKey = getQuotaKey(family, headerStyle, model);
|
|
677
|
-
this.markTouchedForQuota(selected, quotaKey);
|
|
678
|
-
this.setActiveIndex(family, selected.index, identity);
|
|
679
|
-
return selected;
|
|
1005
|
+
return this.selection.proactivelyRotateForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs, identity);
|
|
680
1006
|
}
|
|
681
1007
|
markRateLimitedWithReason(account, family, headerStyle, model, reason, retryAfterMs, failureTtlMs = 3600_000) {
|
|
682
|
-
|
|
683
|
-
// TTL-based reset: if last failure was more than failureTtlMs ago, reset count
|
|
684
|
-
if (account.lastFailureTime !== undefined &&
|
|
685
|
-
now - account.lastFailureTime > failureTtlMs) {
|
|
686
|
-
account.consecutiveFailures = 0;
|
|
687
|
-
}
|
|
688
|
-
const failures = (account.consecutiveFailures ?? 0) + 1;
|
|
689
|
-
account.consecutiveFailures = failures;
|
|
690
|
-
account.lastFailureTime = now;
|
|
691
|
-
const backoffMs = calculateBackoffMs(reason, failures - 1, retryAfterMs, this.random);
|
|
692
|
-
const key = getQuotaKey(family, headerStyle, model);
|
|
693
|
-
account.rateLimitResetTimes[key] = now + backoffMs;
|
|
694
|
-
return backoffMs;
|
|
1008
|
+
return this.selection.markRateLimitedWithReason(account, family, headerStyle, model, reason, retryAfterMs, failureTtlMs);
|
|
695
1009
|
}
|
|
696
1010
|
markRequestSuccess(account) {
|
|
697
|
-
|
|
698
|
-
account.consecutiveFailures = 0;
|
|
699
|
-
}
|
|
1011
|
+
this.selection.markRequestSuccess(account);
|
|
700
1012
|
}
|
|
701
1013
|
clearAllRateLimitsForFamily(family, model) {
|
|
702
|
-
|
|
703
|
-
if (family === 'claude') {
|
|
704
|
-
delete account.rateLimitResetTimes.claude;
|
|
705
|
-
}
|
|
706
|
-
else {
|
|
707
|
-
const antigravityKey = getQuotaKey(family, 'antigravity', model);
|
|
708
|
-
const cliKey = getQuotaKey(family, 'gemini-cli', model);
|
|
709
|
-
delete account.rateLimitResetTimes[antigravityKey];
|
|
710
|
-
delete account.rateLimitResetTimes[cliKey];
|
|
711
|
-
}
|
|
712
|
-
account.consecutiveFailures = 0;
|
|
713
|
-
}
|
|
1014
|
+
this.selection.clearAllRateLimitsForFamily(family, model);
|
|
714
1015
|
}
|
|
715
1016
|
shouldTryOptimisticReset(family, model) {
|
|
716
|
-
|
|
717
|
-
return minWaitMs > 0 && minWaitMs <= 2_000;
|
|
1017
|
+
return this.selection.shouldTryOptimisticReset(family, model);
|
|
718
1018
|
}
|
|
719
1019
|
markAccountCoolingDown(account, cooldownMs, reason) {
|
|
720
|
-
|
|
721
|
-
account.cooldownReason = reason;
|
|
1020
|
+
this.selection.markAccountCoolingDown(account, cooldownMs, reason);
|
|
722
1021
|
}
|
|
723
1022
|
isAccountCoolingDown(account) {
|
|
724
|
-
|
|
725
|
-
return false;
|
|
726
|
-
}
|
|
727
|
-
if (this.now() >= account.coolingDownUntil) {
|
|
728
|
-
this.clearAccountCooldown(account);
|
|
729
|
-
return false;
|
|
730
|
-
}
|
|
731
|
-
return true;
|
|
1023
|
+
return this.selection.isAccountCoolingDown(account);
|
|
732
1024
|
}
|
|
733
1025
|
clearAccountCooldown(account) {
|
|
734
|
-
|
|
735
|
-
delete account.cooldownReason;
|
|
1026
|
+
this.selection.clearAccountCooldown(account);
|
|
736
1027
|
}
|
|
737
1028
|
getAccountCooldownReason(account) {
|
|
738
|
-
return this.
|
|
739
|
-
? account.cooldownReason
|
|
740
|
-
: undefined;
|
|
1029
|
+
return this.selection.getAccountCooldownReason(account);
|
|
741
1030
|
}
|
|
742
1031
|
markTouchedForQuota(account, quotaKey) {
|
|
743
|
-
|
|
1032
|
+
this.selection.markTouchedForQuota(account, quotaKey);
|
|
744
1033
|
}
|
|
745
1034
|
isFreshForQuota(account, quotaKey) {
|
|
746
|
-
|
|
747
|
-
if (!touchedAt)
|
|
748
|
-
return true;
|
|
749
|
-
const resetTime = account.rateLimitResetTimes[quotaKey];
|
|
750
|
-
if (resetTime && touchedAt < resetTime)
|
|
751
|
-
return true;
|
|
752
|
-
return false;
|
|
1035
|
+
return this.selection.isFreshForQuota(account, quotaKey);
|
|
753
1036
|
}
|
|
754
1037
|
getFreshAccountsForQuota(quotaKey, family, model) {
|
|
755
|
-
return this.
|
|
756
|
-
clearExpiredRateLimits(acc, this.now);
|
|
757
|
-
return (acc.enabled !== false &&
|
|
758
|
-
this.isFreshForQuota(acc, quotaKey) &&
|
|
759
|
-
!isRateLimitedForFamily(acc, family, this.now, model) &&
|
|
760
|
-
!this.isAccountCoolingDown(acc));
|
|
761
|
-
});
|
|
1038
|
+
return this.selection.getFreshAccountsForQuota(quotaKey, family, model);
|
|
762
1039
|
}
|
|
763
1040
|
isRateLimitedForHeaderStyle(account, family, headerStyle, model) {
|
|
764
|
-
return isRateLimitedForHeaderStyle(account, family, headerStyle,
|
|
1041
|
+
return this.selection.isRateLimitedForHeaderStyle(account, family, headerStyle, model);
|
|
765
1042
|
}
|
|
766
1043
|
getAvailableHeaderStyle(account, family, model) {
|
|
767
|
-
|
|
768
|
-
if (family === 'claude') {
|
|
769
|
-
return isRateLimitedForHeaderStyle(account, family, 'antigravity', this.now)
|
|
770
|
-
? null
|
|
771
|
-
: 'antigravity';
|
|
772
|
-
}
|
|
773
|
-
if (!isRateLimitedForHeaderStyle(account, family, 'antigravity', this.now, model)) {
|
|
774
|
-
return 'antigravity';
|
|
775
|
-
}
|
|
776
|
-
if (!isRateLimitedForHeaderStyle(account, family, 'gemini-cli', this.now, model)) {
|
|
777
|
-
return 'gemini-cli';
|
|
778
|
-
}
|
|
779
|
-
return null;
|
|
1044
|
+
return this.selection.getAvailableHeaderStyle(account, family, model);
|
|
780
1045
|
}
|
|
781
1046
|
/**
|
|
782
1047
|
* Check if any OTHER account has antigravity quota available for the given family/model.
|
|
@@ -791,198 +1056,62 @@ export class AccountManager {
|
|
|
791
1056
|
* @returns true if any other enabled, non-cooling-down account has antigravity available
|
|
792
1057
|
*/
|
|
793
1058
|
hasOtherAccountWithAntigravityAvailable(currentAccountIndex, family, model) {
|
|
794
|
-
|
|
795
|
-
// (This method is only relevant for Gemini's dual quota pools)
|
|
796
|
-
if (family === 'claude') {
|
|
797
|
-
return false;
|
|
798
|
-
}
|
|
799
|
-
return this.accounts.some((acc) => {
|
|
800
|
-
// Skip current account
|
|
801
|
-
if (acc.index === currentAccountIndex) {
|
|
802
|
-
return false;
|
|
803
|
-
}
|
|
804
|
-
// Skip disabled accounts
|
|
805
|
-
if (acc.enabled === false) {
|
|
806
|
-
return false;
|
|
807
|
-
}
|
|
808
|
-
// Skip cooling down accounts
|
|
809
|
-
if (this.isAccountCoolingDown(acc)) {
|
|
810
|
-
return false;
|
|
811
|
-
}
|
|
812
|
-
// Clear expired rate limits before checking
|
|
813
|
-
clearExpiredRateLimits(acc, this.now);
|
|
814
|
-
// Check if antigravity is available for this account
|
|
815
|
-
return !isRateLimitedForHeaderStyle(acc, family, 'antigravity', this.now, model);
|
|
816
|
-
});
|
|
1059
|
+
return this.selection.hasOtherAccountWithAntigravityAvailable(currentAccountIndex, family, model);
|
|
817
1060
|
}
|
|
818
1061
|
setAccountEnabled(accountIndex, enabled) {
|
|
819
|
-
|
|
820
|
-
if (!account) {
|
|
821
|
-
return false;
|
|
822
|
-
}
|
|
823
|
-
if (enabled && account.accountIneligible) {
|
|
824
|
-
return false;
|
|
825
|
-
}
|
|
826
|
-
account.enabled = enabled;
|
|
827
|
-
if (!enabled) {
|
|
828
|
-
for (const family of Object.keys(this.currentAccountIndexByFamily)) {
|
|
829
|
-
if (this.currentAccountIndexByFamily[family] === accountIndex) {
|
|
830
|
-
const next = this.accounts.find((a, i) => i !== accountIndex && a.enabled !== false);
|
|
831
|
-
this.currentAccountIndexByFamily[family] = next?.index ?? -1;
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
this.requestSaveToDisk();
|
|
836
|
-
return true;
|
|
1062
|
+
return this.selection.setAccountEnabled(accountIndex, enabled);
|
|
837
1063
|
}
|
|
838
1064
|
markAccountVerificationRequired(accountIndex, reason, verifyUrl) {
|
|
839
|
-
|
|
840
|
-
if (!account) {
|
|
841
|
-
return false;
|
|
842
|
-
}
|
|
843
|
-
const timestamp = this.now();
|
|
844
|
-
account.verificationRequired = true;
|
|
845
|
-
account.verificationRequiredAt = timestamp;
|
|
846
|
-
account.verificationRequiredReason = reason?.trim() || undefined;
|
|
847
|
-
if (account.accountIneligible === true ||
|
|
848
|
-
account.accountIneligibleAt !== undefined ||
|
|
849
|
-
account.accountIneligibleReason !== undefined) {
|
|
850
|
-
account.accountIneligible = false;
|
|
851
|
-
account.accountIneligibleAt = undefined;
|
|
852
|
-
account.accountIneligibleReason = undefined;
|
|
853
|
-
account.eligibilityStateUpdatedAt = timestamp;
|
|
854
|
-
}
|
|
855
|
-
const normalizedVerifyUrl = verifyUrl?.trim();
|
|
856
|
-
if (normalizedVerifyUrl) {
|
|
857
|
-
account.verificationUrl = normalizedVerifyUrl;
|
|
858
|
-
}
|
|
859
|
-
if (account.enabled !== false) {
|
|
860
|
-
this.setAccountEnabled(accountIndex, false);
|
|
861
|
-
}
|
|
862
|
-
else {
|
|
863
|
-
this.requestSaveToDisk();
|
|
864
|
-
}
|
|
865
|
-
return true;
|
|
1065
|
+
return this.selection.markAccountVerificationRequired(accountIndex, reason, verifyUrl);
|
|
866
1066
|
}
|
|
867
1067
|
markAccountIneligible(accountIndex, reason) {
|
|
868
|
-
|
|
869
|
-
if (!account) {
|
|
870
|
-
return false;
|
|
871
|
-
}
|
|
872
|
-
const timestamp = this.now();
|
|
873
|
-
account.accountIneligible = true;
|
|
874
|
-
account.accountIneligibleAt = timestamp;
|
|
875
|
-
account.accountIneligibleReason =
|
|
876
|
-
reason?.trim() || 'Google marked this account as ineligible.';
|
|
877
|
-
account.eligibilityStateUpdatedAt = timestamp;
|
|
878
|
-
account.verificationRequired = false;
|
|
879
|
-
account.verificationRequiredAt = undefined;
|
|
880
|
-
account.verificationRequiredReason = undefined;
|
|
881
|
-
account.verificationUrl = undefined;
|
|
882
|
-
if (account.enabled !== false) {
|
|
883
|
-
this.setAccountEnabled(accountIndex, false);
|
|
884
|
-
}
|
|
885
|
-
else {
|
|
886
|
-
this.requestSaveToDisk();
|
|
887
|
-
}
|
|
888
|
-
return true;
|
|
1068
|
+
return this.selection.markAccountIneligible(accountIndex, reason);
|
|
889
1069
|
}
|
|
890
1070
|
clearAccountAccessBlocks(accountIndex, enableAccount = false) {
|
|
891
|
-
|
|
892
|
-
if (!account) {
|
|
893
|
-
return false;
|
|
894
|
-
}
|
|
895
|
-
const wasVerificationRequired = account.verificationRequired === true;
|
|
896
|
-
const wasIneligible = account.accountIneligible === true;
|
|
897
|
-
const hadMetadata = wasVerificationRequired ||
|
|
898
|
-
wasIneligible ||
|
|
899
|
-
account.verificationRequiredAt !== undefined ||
|
|
900
|
-
account.verificationRequiredReason !== undefined ||
|
|
901
|
-
account.verificationUrl !== undefined ||
|
|
902
|
-
account.accountIneligibleAt !== undefined ||
|
|
903
|
-
account.accountIneligibleReason !== undefined ||
|
|
904
|
-
account.eligibilityStateUpdatedAt !== undefined;
|
|
905
|
-
account.verificationRequired = false;
|
|
906
|
-
account.verificationRequiredAt = undefined;
|
|
907
|
-
account.verificationRequiredReason = undefined;
|
|
908
|
-
account.verificationUrl = undefined;
|
|
909
|
-
account.accountIneligible = false;
|
|
910
|
-
account.accountIneligibleAt = undefined;
|
|
911
|
-
account.accountIneligibleReason = undefined;
|
|
912
|
-
if (wasIneligible || account.eligibilityStateUpdatedAt !== undefined) {
|
|
913
|
-
account.eligibilityStateUpdatedAt = this.now();
|
|
914
|
-
}
|
|
915
|
-
if (enableAccount &&
|
|
916
|
-
(wasVerificationRequired || wasIneligible) &&
|
|
917
|
-
account.enabled === false) {
|
|
918
|
-
this.setAccountEnabled(accountIndex, true);
|
|
919
|
-
}
|
|
920
|
-
else if (hadMetadata) {
|
|
921
|
-
this.requestSaveToDisk();
|
|
922
|
-
}
|
|
923
|
-
return true;
|
|
1071
|
+
return this.selection.clearAccountAccessBlocks(accountIndex, enableAccount);
|
|
924
1072
|
}
|
|
925
1073
|
removeAccountByIndex(accountIndex) {
|
|
926
|
-
|
|
1074
|
+
const accounts = this.accounts;
|
|
1075
|
+
if (accountIndex < 0 || accountIndex >= accounts.length) {
|
|
927
1076
|
return false;
|
|
928
1077
|
}
|
|
929
|
-
const account =
|
|
1078
|
+
const account = accounts[accountIndex];
|
|
930
1079
|
if (!account) {
|
|
931
1080
|
return false;
|
|
932
1081
|
}
|
|
933
1082
|
return this.removeAccount(account);
|
|
934
1083
|
}
|
|
935
1084
|
removeAccount(account) {
|
|
936
|
-
|
|
937
|
-
if (idx < 0) {
|
|
1085
|
+
if (!this.selection.has(account)) {
|
|
938
1086
|
return false;
|
|
939
1087
|
}
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
this.currentAccountIndexByFamily.claude = -1;
|
|
947
|
-
this.currentAccountIndexByFamily.gemini = -1;
|
|
948
|
-
this.requestSessionStates.clear();
|
|
949
|
-
return true;
|
|
950
|
-
}
|
|
951
|
-
for (const family of ['claude', 'gemini']) {
|
|
952
|
-
if (this.cursorByFamily[family] > idx) {
|
|
953
|
-
this.cursorByFamily[family] -= 1;
|
|
1088
|
+
// Fenced on the account's credential: a row replaced or re-added since
|
|
1089
|
+
// it was loaded is refused rather than removed.
|
|
1090
|
+
this.dispatch(account, 'removal', (repository, ref) => repository.remove(ref));
|
|
1091
|
+
for (const [key, pending] of this.pendingRowWrites) {
|
|
1092
|
+
if (account.ref !== undefined && sameRowRef(pending.ref, account.ref)) {
|
|
1093
|
+
this.pendingRowWrites.delete(key);
|
|
954
1094
|
}
|
|
955
|
-
this.cursorByFamily[family] =
|
|
956
|
-
this.cursorByFamily[family] % this.accounts.length;
|
|
957
|
-
if (this.currentAccountIndexByFamily[family] > idx) {
|
|
958
|
-
this.currentAccountIndexByFamily[family] -= 1;
|
|
959
|
-
}
|
|
960
|
-
if (this.currentAccountIndexByFamily[family] >= this.accounts.length) {
|
|
961
|
-
this.currentAccountIndexByFamily[family] = -1;
|
|
962
|
-
}
|
|
963
|
-
for (const state of this.requestSessionStates.values()) {
|
|
964
|
-
const currentIndex = state.currentAccountIndexByFamily[family];
|
|
965
|
-
if (currentIndex === idx) {
|
|
966
|
-
state.currentAccountIndexByFamily[family] = -1;
|
|
967
|
-
}
|
|
968
|
-
else if (currentIndex > idx) {
|
|
969
|
-
state.currentAccountIndexByFamily[family] -= 1;
|
|
970
|
-
}
|
|
971
|
-
if (state.cursorByFamily[family] > idx) {
|
|
972
|
-
state.cursorByFamily[family] -= 1;
|
|
973
|
-
}
|
|
974
|
-
state.cursorByFamily[family] %= this.accounts.length;
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
for (const state of this.requestSessionStates.values()) {
|
|
978
|
-
state.usedAccounts = new Set([...state.usedAccounts]
|
|
979
|
-
.filter((accountIndex) => accountIndex !== idx)
|
|
980
|
-
.map((accountIndex) => accountIndex > idx ? accountIndex - 1 : accountIndex));
|
|
981
1095
|
}
|
|
982
|
-
return
|
|
1096
|
+
return this.selection.removeAccount(account);
|
|
983
1097
|
}
|
|
984
1098
|
updateFromAuth(account, auth) {
|
|
985
1099
|
const parts = parseRefreshParts(auth.refresh);
|
|
1100
|
+
// Only the projects packed into the host's refresh string are recorded;
|
|
1101
|
+
// the refresh token itself reaches the repository only through
|
|
1102
|
+
// `refreshAccount`.
|
|
1103
|
+
const projectChange = {
|
|
1104
|
+
...(parts.projectId !== undefined && parts.projectId !== account.projectId
|
|
1105
|
+
? { projectId: parts.projectId }
|
|
1106
|
+
: {}),
|
|
1107
|
+
...(parts.managedProjectId !== undefined &&
|
|
1108
|
+
parts.managedProjectId !== account.managedProjectId
|
|
1109
|
+
? { managedProjectId: parts.managedProjectId }
|
|
1110
|
+
: {}),
|
|
1111
|
+
};
|
|
1112
|
+
if (Object.keys(projectChange).length > 0) {
|
|
1113
|
+
this.dispatch(account, 'project', (repository, ref) => repository.recordProject(ref, projectChange));
|
|
1114
|
+
}
|
|
986
1115
|
// Preserve existing projectId/managedProjectId if not in the new parts
|
|
987
1116
|
account.parts = {
|
|
988
1117
|
...parts,
|
|
@@ -1005,48 +1134,15 @@ export class AccountManager {
|
|
|
1005
1134
|
};
|
|
1006
1135
|
}
|
|
1007
1136
|
getMinWaitTimeForFamily(family, model, headerStyle, strict) {
|
|
1008
|
-
|
|
1009
|
-
clearExpiredRateLimits(a, this.now);
|
|
1010
|
-
return (a.enabled !== false &&
|
|
1011
|
-
(strict && headerStyle
|
|
1012
|
-
? !isRateLimitedForHeaderStyle(a, family, headerStyle, this.now, model)
|
|
1013
|
-
: !isRateLimitedForFamily(a, family, this.now, model)));
|
|
1014
|
-
});
|
|
1015
|
-
if (available.length > 0) {
|
|
1016
|
-
return 0;
|
|
1017
|
-
}
|
|
1018
|
-
const waitTimes = [];
|
|
1019
|
-
for (const a of this.accounts) {
|
|
1020
|
-
if (family === 'claude') {
|
|
1021
|
-
const t = a.rateLimitResetTimes.claude;
|
|
1022
|
-
if (t !== undefined)
|
|
1023
|
-
waitTimes.push(Math.max(0, t - this.now()));
|
|
1024
|
-
}
|
|
1025
|
-
else if (strict && headerStyle) {
|
|
1026
|
-
const key = getQuotaKey(family, headerStyle, model);
|
|
1027
|
-
const t = a.rateLimitResetTimes[key];
|
|
1028
|
-
if (t !== undefined)
|
|
1029
|
-
waitTimes.push(Math.max(0, t - this.now()));
|
|
1030
|
-
}
|
|
1031
|
-
else {
|
|
1032
|
-
// For Gemini, account becomes available when EITHER pool expires for this model/family
|
|
1033
|
-
const antigravityKey = getQuotaKey(family, 'antigravity', model);
|
|
1034
|
-
const cliKey = getQuotaKey(family, 'gemini-cli', model);
|
|
1035
|
-
const t1 = a.rateLimitResetTimes[antigravityKey];
|
|
1036
|
-
const t2 = a.rateLimitResetTimes[cliKey];
|
|
1037
|
-
const accountWait = Math.min(t1 !== undefined ? Math.max(0, t1 - this.now()) : Infinity, t2 !== undefined ? Math.max(0, t2 - this.now()) : Infinity);
|
|
1038
|
-
if (accountWait !== Infinity)
|
|
1039
|
-
waitTimes.push(accountWait);
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
return waitTimes.length > 0 ? Math.min(...waitTimes) : 0;
|
|
1137
|
+
return this.selection.getMinWaitTimeForFamily(family, model, headerStyle, strict);
|
|
1043
1138
|
}
|
|
1044
1139
|
getAccounts() {
|
|
1045
|
-
return
|
|
1140
|
+
return this.selection.getAccounts();
|
|
1046
1141
|
}
|
|
1047
1142
|
buildStorageSnapshot() {
|
|
1048
|
-
const
|
|
1049
|
-
const
|
|
1143
|
+
const active = this.selection.getActiveIndexByFamily();
|
|
1144
|
+
const claudeIndex = Math.max(0, active.claude);
|
|
1145
|
+
const geminiIndex = Math.max(0, active.gemini);
|
|
1050
1146
|
return {
|
|
1051
1147
|
version: 4,
|
|
1052
1148
|
accounts: this.accounts.map((a) => ({
|
|
@@ -1095,7 +1191,17 @@ export class AccountManager {
|
|
|
1095
1191
|
};
|
|
1096
1192
|
}
|
|
1097
1193
|
async saveToDisk() {
|
|
1098
|
-
|
|
1194
|
+
if (this.repository !== undefined) {
|
|
1195
|
+
await this.flushRepositoryWrites();
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
await this.requireStore().saveMerged(this.storagePath, this.buildStorageSnapshot());
|
|
1199
|
+
}
|
|
1200
|
+
requireStore() {
|
|
1201
|
+
if (this.store === undefined) {
|
|
1202
|
+
throw new Error('this AccountManager persists through its repository');
|
|
1203
|
+
}
|
|
1204
|
+
return this.store;
|
|
1099
1205
|
}
|
|
1100
1206
|
/**
|
|
1101
1207
|
* Persist via full-file replace (no merge). Required after destructive
|
|
@@ -1103,8 +1209,14 @@ export class AccountManager {
|
|
|
1103
1209
|
* mergeAccountStorage re-reading it from disk.
|
|
1104
1210
|
*/
|
|
1105
1211
|
async saveToDiskReplace() {
|
|
1212
|
+
// A repository-backed manager removes rows by attributed `remove`
|
|
1213
|
+
// writes, so nothing can bring a removed row back; draining suffices.
|
|
1214
|
+
if (this.repository !== undefined) {
|
|
1215
|
+
await this.flushRepositoryWrites();
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1106
1218
|
const snapshot = this.buildStorageSnapshot();
|
|
1107
|
-
await this.
|
|
1219
|
+
await this.requireStore().mutate(this.storagePath, () => snapshot);
|
|
1108
1220
|
}
|
|
1109
1221
|
requestSaveToDisk() {
|
|
1110
1222
|
if (this.disposed || this.savePending) {
|
|
@@ -1118,6 +1230,12 @@ export class AccountManager {
|
|
|
1118
1230
|
}, 1000);
|
|
1119
1231
|
}
|
|
1120
1232
|
async flushSaveToDisk() {
|
|
1233
|
+
// Writes such as request counts start immediately rather than waiting
|
|
1234
|
+
// for a scheduled save, so a repository-backed flush always drains.
|
|
1235
|
+
if (this.repository !== undefined) {
|
|
1236
|
+
await this.drainRepository();
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1121
1239
|
if (!this.savePending) {
|
|
1122
1240
|
await this.saveInFlight;
|
|
1123
1241
|
return;
|
|
@@ -1134,16 +1252,53 @@ export class AccountManager {
|
|
|
1134
1252
|
clearTimeout(this.saveTimeout);
|
|
1135
1253
|
this.saveTimeout = null;
|
|
1136
1254
|
}
|
|
1255
|
+
if (this.repository !== undefined) {
|
|
1256
|
+
await this.drainRepository();
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1137
1259
|
if (this.savePending) {
|
|
1138
1260
|
await this.executeSave();
|
|
1139
1261
|
}
|
|
1140
1262
|
await this.saveInFlight;
|
|
1141
1263
|
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Cancels a scheduled save and drains every repository write now. A
|
|
1266
|
+
* failure rejects every waiting flush and is thrown to the caller.
|
|
1267
|
+
*/
|
|
1268
|
+
async drainRepository() {
|
|
1269
|
+
if (this.saveTimeout) {
|
|
1270
|
+
clearTimeout(this.saveTimeout);
|
|
1271
|
+
this.saveTimeout = null;
|
|
1272
|
+
}
|
|
1273
|
+
this.savePending = false;
|
|
1274
|
+
const resolvers = this.savePromiseResolvers;
|
|
1275
|
+
this.savePromiseResolvers = [];
|
|
1276
|
+
await this.saveInFlight;
|
|
1277
|
+
await this.settleRepositoryDrain(resolvers);
|
|
1278
|
+
}
|
|
1279
|
+
async settleRepositoryDrain(resolvers) {
|
|
1280
|
+
try {
|
|
1281
|
+
await this.flushRepositoryWrites();
|
|
1282
|
+
}
|
|
1283
|
+
catch (error) {
|
|
1284
|
+
for (const { reject } of resolvers)
|
|
1285
|
+
reject(error);
|
|
1286
|
+
throw error;
|
|
1287
|
+
}
|
|
1288
|
+
for (const { resolve } of resolvers)
|
|
1289
|
+
resolve();
|
|
1290
|
+
}
|
|
1142
1291
|
async executeSave() {
|
|
1143
1292
|
this.savePending = false;
|
|
1144
1293
|
this.saveTimeout = null;
|
|
1145
1294
|
const resolvers = this.savePromiseResolvers;
|
|
1146
1295
|
this.savePromiseResolvers = [];
|
|
1296
|
+
if (this.repository !== undefined) {
|
|
1297
|
+
// A scheduled save has no caller to throw to: its failures reach
|
|
1298
|
+
// waiting flushes and the diagnostics.
|
|
1299
|
+
await this.settleRepositoryDrain(resolvers).catch(() => { });
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1147
1302
|
try {
|
|
1148
1303
|
await this.saveToDisk();
|
|
1149
1304
|
for (const { resolve } of resolvers) {
|
|
@@ -1175,30 +1330,7 @@ export class AccountManager {
|
|
|
1175
1330
|
* @returns The new fingerprint, or null if account not found
|
|
1176
1331
|
*/
|
|
1177
1332
|
regenerateAccountFingerprint(accountIndex) {
|
|
1178
|
-
|
|
1179
|
-
if (!account)
|
|
1180
|
-
return null;
|
|
1181
|
-
// Save current fingerprint to history if it exists
|
|
1182
|
-
if (account.fingerprint) {
|
|
1183
|
-
const historyEntry = {
|
|
1184
|
-
fingerprint: account.fingerprint,
|
|
1185
|
-
timestamp: this.now(),
|
|
1186
|
-
reason: 'regenerated',
|
|
1187
|
-
};
|
|
1188
|
-
if (!account.fingerprintHistory) {
|
|
1189
|
-
account.fingerprintHistory = [];
|
|
1190
|
-
}
|
|
1191
|
-
// Add to beginning of history (most recent first)
|
|
1192
|
-
account.fingerprintHistory.unshift(historyEntry);
|
|
1193
|
-
// Trim to max history size
|
|
1194
|
-
if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) {
|
|
1195
|
-
account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY);
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
// Generate and assign new fingerprint
|
|
1199
|
-
account.fingerprint = generateFingerprint();
|
|
1200
|
-
this.requestSaveToDisk();
|
|
1201
|
-
return account.fingerprint;
|
|
1333
|
+
return this.selection.regenerateAccountFingerprint(accountIndex);
|
|
1202
1334
|
}
|
|
1203
1335
|
/**
|
|
1204
1336
|
* Restore a fingerprint from history for an account.
|
|
@@ -1207,32 +1339,7 @@ export class AccountManager {
|
|
|
1207
1339
|
* @returns The restored fingerprint, or null if account/history not found
|
|
1208
1340
|
*/
|
|
1209
1341
|
restoreAccountFingerprint(accountIndex, historyIndex) {
|
|
1210
|
-
|
|
1211
|
-
if (!account)
|
|
1212
|
-
return null;
|
|
1213
|
-
const history = account.fingerprintHistory;
|
|
1214
|
-
if (!history || historyIndex < 0 || historyIndex >= history.length) {
|
|
1215
|
-
return null;
|
|
1216
|
-
}
|
|
1217
|
-
// Capture the fingerprint to restore BEFORE modifying history
|
|
1218
|
-
const fingerprintToRestore = history[historyIndex].fingerprint;
|
|
1219
|
-
// Save current fingerprint to history before restoring (if it exists)
|
|
1220
|
-
if (account.fingerprint) {
|
|
1221
|
-
const historyEntry = {
|
|
1222
|
-
fingerprint: account.fingerprint,
|
|
1223
|
-
timestamp: this.now(),
|
|
1224
|
-
reason: 'restored',
|
|
1225
|
-
};
|
|
1226
|
-
account.fingerprintHistory.unshift(historyEntry);
|
|
1227
|
-
// Trim to max history size
|
|
1228
|
-
if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) {
|
|
1229
|
-
account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY);
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
// Restore the fingerprint
|
|
1233
|
-
account.fingerprint = { ...fingerprintToRestore, createdAt: this.now() };
|
|
1234
|
-
this.requestSaveToDisk();
|
|
1235
|
-
return account.fingerprint;
|
|
1342
|
+
return this.selection.restoreAccountFingerprint(accountIndex, historyIndex);
|
|
1236
1343
|
}
|
|
1237
1344
|
/**
|
|
1238
1345
|
* Get fingerprint history for an account.
|
|
@@ -1240,11 +1347,7 @@ export class AccountManager {
|
|
|
1240
1347
|
* @returns Array of fingerprint versions, or empty array if not found
|
|
1241
1348
|
*/
|
|
1242
1349
|
getAccountFingerprintHistory(accountIndex) {
|
|
1243
|
-
|
|
1244
|
-
if (!account?.fingerprintHistory) {
|
|
1245
|
-
return [];
|
|
1246
|
-
}
|
|
1247
|
-
return [...account.fingerprintHistory];
|
|
1350
|
+
return this.selection.getAccountFingerprintHistory(accountIndex);
|
|
1248
1351
|
}
|
|
1249
1352
|
updateQuotaCache(accountIndex, quotaGroups, expectedRefreshToken) {
|
|
1250
1353
|
const account = this.accounts[accountIndex];
|
|
@@ -1258,6 +1361,15 @@ export class AccountManager {
|
|
|
1258
1361
|
// a different account after an index shift.
|
|
1259
1362
|
account.cachedQuotaAccountId = quotaAccountIdentity(account.parts.refreshToken);
|
|
1260
1363
|
account.cachedQuotaUpdatedAt = this.now();
|
|
1364
|
+
// A separate attributed write from the tier; the per-model readings the
|
|
1365
|
+
// store keeps are left as they are because this reading carries none.
|
|
1366
|
+
const reading = {
|
|
1367
|
+
schemaVersion: 1,
|
|
1368
|
+
cachedQuotaAccountId: account.cachedQuotaAccountId,
|
|
1369
|
+
cachedQuota: storedQuotaGroups(quotaGroups),
|
|
1370
|
+
cachedQuotaUpdatedAt: account.cachedQuotaUpdatedAt,
|
|
1371
|
+
};
|
|
1372
|
+
this.dispatch(account, 'quota reading', (repository, ref) => repository.recordQuota(ref, reading));
|
|
1261
1373
|
}
|
|
1262
1374
|
/**
|
|
1263
1375
|
* Apply a subset of fields from a quota-fetch `updatedAccount` result onto
|
|
@@ -1292,148 +1404,107 @@ export class AccountManager {
|
|
|
1292
1404
|
if (patch.capturedTierSchemaVersion !== undefined) {
|
|
1293
1405
|
account.capturedTierSchemaVersion = patch.capturedTierSchemaVersion;
|
|
1294
1406
|
}
|
|
1407
|
+
const tier = {};
|
|
1408
|
+
if (patch.capturedTierId !== undefined)
|
|
1409
|
+
tier.capturedTierId = patch.capturedTierId;
|
|
1410
|
+
if (patch.capturedPaidTierId !== undefined) {
|
|
1411
|
+
tier.capturedPaidTierId = patch.capturedPaidTierId;
|
|
1412
|
+
}
|
|
1413
|
+
if (patch.capturedTierAt !== undefined)
|
|
1414
|
+
tier.capturedTierAt = patch.capturedTierAt;
|
|
1415
|
+
if (patch.capturedTierSchemaVersion !== undefined) {
|
|
1416
|
+
tier.capturedTierSchemaVersion = patch.capturedTierSchemaVersion;
|
|
1417
|
+
}
|
|
1418
|
+
if (Object.keys(tier).length === 0)
|
|
1419
|
+
return;
|
|
1420
|
+
this.dispatch(account, 'tier', (repository, ref) => repository.updateMetadata(ref, (current, row) => {
|
|
1421
|
+
const base = current ?? {
|
|
1422
|
+
addedAt: row.storeAddedAt ?? this.now(),
|
|
1423
|
+
lastUsed: 0,
|
|
1424
|
+
};
|
|
1425
|
+
// A capture older than the stored one does not replace it.
|
|
1426
|
+
if (patch.capturedTierAt !== undefined &&
|
|
1427
|
+
typeof base.capturedTierAt === 'number' &&
|
|
1428
|
+
patch.capturedTierAt < base.capturedTierAt) {
|
|
1429
|
+
return { kind: 'keep' };
|
|
1430
|
+
}
|
|
1431
|
+
return { kind: 'set', metadata: { ...base, ...tier } };
|
|
1432
|
+
}));
|
|
1295
1433
|
}
|
|
1296
1434
|
/**
|
|
1297
1435
|
* Record a successful API request for an account.
|
|
1298
1436
|
* Tracks per model family with daily reset.
|
|
1299
1437
|
*/
|
|
1300
1438
|
recordRequest(accountIndex, family) {
|
|
1301
|
-
|
|
1302
|
-
if (!account)
|
|
1303
|
-
return;
|
|
1304
|
-
const today = new Date(this.now()).toISOString().slice(0, 10);
|
|
1305
|
-
if (!account.dailyRequestCounts ||
|
|
1306
|
-
account.dailyRequestCounts.date !== today) {
|
|
1307
|
-
account.dailyRequestCounts = { date: today, claude: 0, gemini: 0 };
|
|
1308
|
-
}
|
|
1309
|
-
account.dailyRequestCounts[family]++;
|
|
1310
|
-
account.lastUsed = this.now();
|
|
1311
|
-
// Also track for session
|
|
1312
|
-
this.recordSessionRequest(accountIndex, family);
|
|
1439
|
+
this.selection.recordRequest(accountIndex, family);
|
|
1313
1440
|
}
|
|
1314
1441
|
/**
|
|
1315
1442
|
* Get request counts for an account for today.
|
|
1316
1443
|
*/
|
|
1317
1444
|
getDailyRequestCounts(accountIndex) {
|
|
1318
|
-
|
|
1319
|
-
if (!account?.dailyRequestCounts)
|
|
1320
|
-
return null;
|
|
1321
|
-
const today = new Date(this.now()).toISOString().slice(0, 10);
|
|
1322
|
-
if (account.dailyRequestCounts.date !== today)
|
|
1323
|
-
return null;
|
|
1324
|
-
return { ...account.dailyRequestCounts };
|
|
1445
|
+
return this.selection.getDailyRequestCounts(accountIndex);
|
|
1325
1446
|
}
|
|
1326
1447
|
/**
|
|
1327
1448
|
* Get total daily request counts across all accounts for a model family.
|
|
1328
1449
|
*/
|
|
1329
1450
|
getTotalDailyRequests(family) {
|
|
1330
|
-
|
|
1331
|
-
let total = 0;
|
|
1332
|
-
for (const account of this.accounts) {
|
|
1333
|
-
if (account.dailyRequestCounts?.date === today) {
|
|
1334
|
-
total += account.dailyRequestCounts[family];
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
return total;
|
|
1451
|
+
return this.selection.getTotalDailyRequests(family);
|
|
1338
1452
|
}
|
|
1339
1453
|
/**
|
|
1340
1454
|
* Get a summary of daily request distribution across accounts.
|
|
1341
1455
|
* Returns accounts sorted by request count (descending).
|
|
1342
1456
|
*/
|
|
1343
1457
|
getDailyRequestSummary(family) {
|
|
1344
|
-
|
|
1345
|
-
const result = [];
|
|
1346
|
-
for (const account of this.accounts) {
|
|
1347
|
-
const count = account.dailyRequestCounts?.date === today
|
|
1348
|
-
? account.dailyRequestCounts[family]
|
|
1349
|
-
: 0;
|
|
1350
|
-
if (count > 0) {
|
|
1351
|
-
result.push({ index: account.index, email: account.email, count });
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
return result.sort((a, b) => b.count - a.count);
|
|
1458
|
+
return this.selection.getDailyRequestSummary(family);
|
|
1355
1459
|
}
|
|
1356
1460
|
/**
|
|
1357
1461
|
* Record a request for the current session (in-memory only).
|
|
1358
1462
|
*/
|
|
1359
1463
|
recordSessionRequest(accountIndex, family) {
|
|
1360
|
-
|
|
1361
|
-
const current = this.sessionRequestCounts.get(key) ?? {
|
|
1362
|
-
claude: 0,
|
|
1363
|
-
gemini: 0,
|
|
1364
|
-
};
|
|
1365
|
-
current[family]++;
|
|
1366
|
-
this.sessionRequestCounts.set(key, current);
|
|
1464
|
+
this.selection.recordSessionRequest(accountIndex, family);
|
|
1367
1465
|
}
|
|
1368
1466
|
/**
|
|
1369
1467
|
* Get a summary of the current session's request usage.
|
|
1370
1468
|
*/
|
|
1371
1469
|
getSessionSummary() {
|
|
1372
|
-
|
|
1373
|
-
const durationMinutes = Math.round(durationMs / 60000);
|
|
1374
|
-
const durationHours = durationMs / 3600000;
|
|
1375
|
-
let totalClaude = 0;
|
|
1376
|
-
let totalGemini = 0;
|
|
1377
|
-
const perAccount = [];
|
|
1378
|
-
for (const [key, counts] of this.sessionRequestCounts) {
|
|
1379
|
-
const idx = Number(key);
|
|
1380
|
-
const account = this.accounts[idx];
|
|
1381
|
-
totalClaude += counts.claude;
|
|
1382
|
-
totalGemini += counts.gemini;
|
|
1383
|
-
if (counts.claude > 0 || counts.gemini > 0) {
|
|
1384
|
-
perAccount.push({
|
|
1385
|
-
index: idx,
|
|
1386
|
-
email: account?.email,
|
|
1387
|
-
claude: counts.claude,
|
|
1388
|
-
gemini: counts.gemini,
|
|
1389
|
-
});
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
const totalRequests = totalClaude + totalGemini;
|
|
1393
|
-
const requestsPerHour = durationHours > 0 ? Math.round(totalRequests / durationHours) : 0;
|
|
1394
|
-
return {
|
|
1395
|
-
durationMinutes,
|
|
1396
|
-
totalClaude,
|
|
1397
|
-
totalGemini,
|
|
1398
|
-
requestsPerHour,
|
|
1399
|
-
accountsUsed: perAccount.length,
|
|
1400
|
-
perAccount: perAccount.sort((a, b) => b.claude + b.gemini - (a.claude + a.gemini)),
|
|
1401
|
-
};
|
|
1470
|
+
return this.selection.getSessionSummary();
|
|
1402
1471
|
}
|
|
1403
1472
|
isAccountOverSoftQuota(account, family, thresholdPercent, cacheTtlMs, model) {
|
|
1404
|
-
return
|
|
1473
|
+
return this.selection.isAccountOverSoftQuota(account, family, thresholdPercent, cacheTtlMs, model);
|
|
1405
1474
|
}
|
|
1475
|
+
/**
|
|
1476
|
+
* The accounts a quota check should cover. For a repository-backed
|
|
1477
|
+
* manager each target carries the ref its account is loaded under,
|
|
1478
|
+
* captured now, before any quota work starts; an account without a ref
|
|
1479
|
+
* has no row to attribute a reading to and is left out rather than
|
|
1480
|
+
* matched by position, email or token.
|
|
1481
|
+
*/
|
|
1406
1482
|
getAccountsForQuotaCheck() {
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1483
|
+
const targets = [];
|
|
1484
|
+
for (const a of this.accounts) {
|
|
1485
|
+
const target = {
|
|
1486
|
+
email: a.email,
|
|
1487
|
+
refreshToken: a.parts.refreshToken,
|
|
1488
|
+
projectId: a.parts.projectId ?? a.projectId,
|
|
1489
|
+
managedProjectId: a.parts.managedProjectId ?? a.managedProjectId,
|
|
1490
|
+
addedAt: a.addedAt,
|
|
1491
|
+
lastUsed: a.lastUsed,
|
|
1492
|
+
enabled: a.enabled,
|
|
1493
|
+
};
|
|
1494
|
+
if (this.repository === undefined) {
|
|
1495
|
+
targets.push(target);
|
|
1496
|
+
}
|
|
1497
|
+
else if (a.ref !== undefined) {
|
|
1498
|
+
targets.push({ ...target, rowRef: a.ref });
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
return targets;
|
|
1416
1502
|
}
|
|
1417
1503
|
getOldestQuotaCacheAge() {
|
|
1418
|
-
|
|
1419
|
-
for (const acc of this.accounts) {
|
|
1420
|
-
if (acc.enabled === false)
|
|
1421
|
-
continue;
|
|
1422
|
-
if (acc.cachedQuotaUpdatedAt == null)
|
|
1423
|
-
return null;
|
|
1424
|
-
const age = this.now() - acc.cachedQuotaUpdatedAt;
|
|
1425
|
-
if (oldest === null || age > oldest)
|
|
1426
|
-
oldest = age;
|
|
1427
|
-
}
|
|
1428
|
-
return oldest;
|
|
1504
|
+
return this.selection.getOldestQuotaCacheAge();
|
|
1429
1505
|
}
|
|
1430
1506
|
areAllAccountsOverSoftQuota(family, thresholdPercent, cacheTtlMs, model) {
|
|
1431
|
-
|
|
1432
|
-
return false;
|
|
1433
|
-
const enabled = this.accounts.filter((a) => a.enabled !== false);
|
|
1434
|
-
if (enabled.length <= 1)
|
|
1435
|
-
return false;
|
|
1436
|
-
return enabled.every((a) => isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, this.now, model));
|
|
1507
|
+
return this.selection.areAllAccountsOverSoftQuota(family, thresholdPercent, cacheTtlMs, model);
|
|
1437
1508
|
}
|
|
1438
1509
|
/**
|
|
1439
1510
|
* Get minimum wait time until any account's soft quota resets.
|
|
@@ -1442,39 +1513,7 @@ export class AccountManager {
|
|
|
1442
1513
|
* Returns null if no resetTime data is available.
|
|
1443
1514
|
*/
|
|
1444
1515
|
getMinWaitTimeForSoftQuota(family, thresholdPercent, cacheTtlMs, model) {
|
|
1445
|
-
|
|
1446
|
-
return 0;
|
|
1447
|
-
const enabled = this.accounts.filter((a) => a.enabled !== false);
|
|
1448
|
-
if (enabled.length === 0)
|
|
1449
|
-
return null;
|
|
1450
|
-
if (enabled.length === 1)
|
|
1451
|
-
return 0;
|
|
1452
|
-
// If any account is available (not over threshold), no wait needed
|
|
1453
|
-
const available = enabled.filter((a) => !isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, this.now, model));
|
|
1454
|
-
if (available.length > 0)
|
|
1455
|
-
return 0;
|
|
1456
|
-
// All accounts are over threshold - find earliest reset time
|
|
1457
|
-
// For gemini family, we MUST have the model to distinguish pro vs flash quotas.
|
|
1458
|
-
// Fail-open (return null = no wait info) if model is missing to avoid blocking on wrong quota.
|
|
1459
|
-
if (!model && family !== 'claude')
|
|
1460
|
-
return null;
|
|
1461
|
-
const quotaGroup = resolveQuotaGroup(family, model);
|
|
1462
|
-
const now = this.now();
|
|
1463
|
-
const waitTimes = [];
|
|
1464
|
-
for (const acc of enabled) {
|
|
1465
|
-
const groupData = acc.cachedQuota?.[quotaGroup];
|
|
1466
|
-
if (groupData?.resetTime) {
|
|
1467
|
-
const resetTimestamp = Date.parse(groupData.resetTime);
|
|
1468
|
-
if (Number.isFinite(resetTimestamp)) {
|
|
1469
|
-
waitTimes.push(Math.max(0, resetTimestamp - now));
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
if (waitTimes.length === 0)
|
|
1474
|
-
return null;
|
|
1475
|
-
const minWait = Math.min(...waitTimes);
|
|
1476
|
-
// Treat 0 as stale cache (resetTime in the past) → fail-open to avoid spin loop
|
|
1477
|
-
return minWait === 0 ? null : minWait;
|
|
1516
|
+
return this.selection.getMinWaitTimeForSoftQuota(family, thresholdPercent, cacheTtlMs, model);
|
|
1478
1517
|
}
|
|
1479
1518
|
}
|
|
1480
1519
|
//# sourceMappingURL=account-manager.js.map
|