@cortexkit/antigravity-auth-core 2.2.1 → 3.0.0
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 +2282 -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-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/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/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/claude.d.ts +7 -3
- package/dist/transform/claude.d.ts.map +1 -1
- package/dist/transform/claude.js +6 -2
- package/dist/transform/claude.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
|
@@ -0,0 +1,1383 @@
|
|
|
1
|
+
import { generateFingerprint, MAX_FINGERPRINT_HISTORY, } from "./fingerprint.js";
|
|
2
|
+
import { getQuotaGroupForModel } from "./model-registry.js";
|
|
3
|
+
import { calculateBackoffMs, HealthScoreTracker, selectHybridAccount, TokenBucketTracker, } from "./rotation.js";
|
|
4
|
+
export function clampNonNegativeInt(value, fallback) {
|
|
5
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
6
|
+
return fallback;
|
|
7
|
+
}
|
|
8
|
+
return value < 0 ? 0 : Math.floor(value);
|
|
9
|
+
}
|
|
10
|
+
export function getQuotaKey(family, headerStyle, model) {
|
|
11
|
+
if (family === 'claude') {
|
|
12
|
+
return 'claude';
|
|
13
|
+
}
|
|
14
|
+
const base = headerStyle === 'gemini-cli' ? 'gemini-cli' : 'gemini-antigravity';
|
|
15
|
+
if (model) {
|
|
16
|
+
return `${base}:${model}`;
|
|
17
|
+
}
|
|
18
|
+
return base;
|
|
19
|
+
}
|
|
20
|
+
function isRateLimitedForQuotaKey(account, key, now) {
|
|
21
|
+
const resetTime = account.rateLimitResetTimes[key];
|
|
22
|
+
return resetTime !== undefined && now() < resetTime;
|
|
23
|
+
}
|
|
24
|
+
function isRateLimitedForFamily(account, family, now, model) {
|
|
25
|
+
if (family === 'claude') {
|
|
26
|
+
return isRateLimitedForQuotaKey(account, 'claude', now);
|
|
27
|
+
}
|
|
28
|
+
const antigravityIsLimited = isRateLimitedForHeaderStyle(account, family, 'antigravity', now, model);
|
|
29
|
+
const cliIsLimited = isRateLimitedForHeaderStyle(account, family, 'gemini-cli', now, model);
|
|
30
|
+
return antigravityIsLimited && cliIsLimited;
|
|
31
|
+
}
|
|
32
|
+
function isRateLimitedForHeaderStyle(account, family, headerStyle, now, model) {
|
|
33
|
+
clearExpiredRateLimits(account, now);
|
|
34
|
+
if (family === 'claude') {
|
|
35
|
+
return isRateLimitedForQuotaKey(account, 'claude', now);
|
|
36
|
+
}
|
|
37
|
+
// Check model-specific quota first if provided
|
|
38
|
+
if (model) {
|
|
39
|
+
const modelKey = getQuotaKey(family, headerStyle, model);
|
|
40
|
+
if (isRateLimitedForQuotaKey(account, modelKey, now)) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Then check base family quota
|
|
45
|
+
const baseKey = getQuotaKey(family, headerStyle);
|
|
46
|
+
return isRateLimitedForQuotaKey(account, baseKey, now);
|
|
47
|
+
}
|
|
48
|
+
function clearExpiredRateLimits(account, clock) {
|
|
49
|
+
const now = clock();
|
|
50
|
+
const keys = Object.keys(account.rateLimitResetTimes);
|
|
51
|
+
for (const key of keys) {
|
|
52
|
+
const resetTime = account.rateLimitResetTimes[key];
|
|
53
|
+
if (resetTime !== undefined && now >= resetTime) {
|
|
54
|
+
delete account.rateLimitResetTimes[key];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the quota group for soft quota checks.
|
|
60
|
+
*
|
|
61
|
+
* When a model string is available we use the model-registry lookup first,
|
|
62
|
+
* then fall back to substring matching. When model is null/undefined we
|
|
63
|
+
* fall back based on family:
|
|
64
|
+
* - Claude → "non-gemini" quota group
|
|
65
|
+
* - Gemini → "gemini" quota group
|
|
66
|
+
*
|
|
67
|
+
* @param family - The model family ("claude" | "gemini")
|
|
68
|
+
* @param model - Optional model string for precise resolution
|
|
69
|
+
* @returns The QuotaGroup to use for soft quota checks
|
|
70
|
+
*/
|
|
71
|
+
export function resolveQuotaGroup(family, model) {
|
|
72
|
+
if (model) {
|
|
73
|
+
const registryGroup = getQuotaGroupForModel(model);
|
|
74
|
+
if (registryGroup)
|
|
75
|
+
return registryGroup;
|
|
76
|
+
const lower = model.toLowerCase();
|
|
77
|
+
// Check Claude / GPT-OSS substrings BEFORE the `gemini` substring so
|
|
78
|
+
// a `gemini-claude-*` alias (Claude route exposed under a `gemini-`
|
|
79
|
+
// namespace) attributes to the non-gemini pool. The model-registry
|
|
80
|
+
// check above already handles registered aliases; this substring
|
|
81
|
+
// fallback mirrors the same precedence rule for unregistered models.
|
|
82
|
+
if (lower.includes('claude') || lower.includes('gpt-oss')) {
|
|
83
|
+
return 'non-gemini';
|
|
84
|
+
}
|
|
85
|
+
if (lower.includes('gemini'))
|
|
86
|
+
return 'gemini';
|
|
87
|
+
}
|
|
88
|
+
return family === 'claude' ? 'non-gemini' : 'gemini';
|
|
89
|
+
}
|
|
90
|
+
function isOverSoftQuotaThreshold(account, family, thresholdPercent, cacheTtlMs, now, model) {
|
|
91
|
+
if (thresholdPercent >= 100)
|
|
92
|
+
return false;
|
|
93
|
+
if (!account.cachedQuota)
|
|
94
|
+
return false;
|
|
95
|
+
if (account.cachedQuotaUpdatedAt == null)
|
|
96
|
+
return false;
|
|
97
|
+
const age = now() - account.cachedQuotaUpdatedAt;
|
|
98
|
+
if (age > cacheTtlMs)
|
|
99
|
+
return false;
|
|
100
|
+
const quotaGroup = resolveQuotaGroup(family, model);
|
|
101
|
+
const groupData = account.cachedQuota[quotaGroup];
|
|
102
|
+
if (groupData?.remainingFraction == null)
|
|
103
|
+
return false;
|
|
104
|
+
const remainingFraction = Math.max(0, Math.min(1, groupData.remainingFraction));
|
|
105
|
+
const usedPercent = (1 - remainingFraction) * 100;
|
|
106
|
+
const isOverThreshold = usedPercent >= thresholdPercent;
|
|
107
|
+
return isOverThreshold;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Row fields that only this selector maintains, in memory: a roster read
|
|
111
|
+
* never carries them, so `refreshSelectableRow` leaves them on the kept row.
|
|
112
|
+
*/
|
|
113
|
+
const IN_MEMORY_FIELDS = new Set([
|
|
114
|
+
'index',
|
|
115
|
+
'touchedForQuota',
|
|
116
|
+
'consecutiveFailures',
|
|
117
|
+
'lastFailureTime',
|
|
118
|
+
]);
|
|
119
|
+
/**
|
|
120
|
+
* The default refresh of a kept row from its fresh roster row: every field
|
|
121
|
+
* the fresh row has replaces the kept one, and every field it lacks is
|
|
122
|
+
* removed, so no stale enabled flag, cooldown, quota reading, verdict or
|
|
123
|
+
* other roster metadata survives. Only in-memory bookkeeping is kept
|
|
124
|
+
* (quota-key touches, consecutive failures, the row's index), plus two
|
|
125
|
+
* observations this pool may hold before its roster shows them: the later
|
|
126
|
+
* `lastUsed`, and per rate-limit key the later reset time.
|
|
127
|
+
*
|
|
128
|
+
* It copies every field of the fresh row, whatever it is, so it is meant
|
|
129
|
+
* for rows without credentials (a vault roster's). A row type that carries
|
|
130
|
+
* credentials refreshes them under its own rules instead.
|
|
131
|
+
*/
|
|
132
|
+
export function refreshSelectableRow(prior, fresh) {
|
|
133
|
+
const lastUsed = Math.max(prior.lastUsed, fresh.lastUsed);
|
|
134
|
+
const rates = { ...fresh.rateLimitResetTimes };
|
|
135
|
+
for (const [key, value] of Object.entries(prior.rateLimitResetTimes)) {
|
|
136
|
+
const stored = rates[key];
|
|
137
|
+
if (value !== undefined && (stored === undefined || value > stored)) {
|
|
138
|
+
rates[key] = value;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const key of Object.keys(prior)) {
|
|
142
|
+
if (!IN_MEMORY_FIELDS.has(key) && !Object.hasOwn(fresh, key)) {
|
|
143
|
+
Reflect.deleteProperty(prior, key);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const [key, value] of Object.entries(fresh)) {
|
|
147
|
+
if (!IN_MEMORY_FIELDS.has(key))
|
|
148
|
+
Reflect.set(prior, key, value);
|
|
149
|
+
}
|
|
150
|
+
prior.lastUsed = lastUsed;
|
|
151
|
+
prior.rateLimitResetTimes = rates;
|
|
152
|
+
}
|
|
153
|
+
const ACCOUNT_SESSION_STATE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
154
|
+
const MAX_ACCOUNT_SESSION_STATES = 256;
|
|
155
|
+
const FAMILIES = ['claude', 'gemini'];
|
|
156
|
+
/**
|
|
157
|
+
* The one owner of a pool's selection state: its rows, the per-family
|
|
158
|
+
* selection and round-robin cursors, per-session pins (with parent ids,
|
|
159
|
+
* used rows, cursors and PID-offset flags), session usage, toast debounce,
|
|
160
|
+
* health and token trackers, and the clock, random source and pid they use.
|
|
161
|
+
*
|
|
162
|
+
* Uses the same account until it hits a rate limit (429), then switches.
|
|
163
|
+
* Rate limits are tracked per quota key (family, model and header style),
|
|
164
|
+
* so an account rate-limited for Claude can still be used for Gemini.
|
|
165
|
+
*
|
|
166
|
+
* Rows are held by reference, never copied, and their selection fields are
|
|
167
|
+
* updated in place. Index-keyed state follows a row across a membership
|
|
168
|
+
* change only through `replaceAccounts` or `removeAccount`, which match rows
|
|
169
|
+
* by identity, never by position.
|
|
170
|
+
*/
|
|
171
|
+
export class AccountSelector {
|
|
172
|
+
accounts = [];
|
|
173
|
+
cursorByFamily = { claude: 0, gemini: 0 };
|
|
174
|
+
currentAccountIndexByFamily = {
|
|
175
|
+
claude: -1,
|
|
176
|
+
gemini: -1,
|
|
177
|
+
};
|
|
178
|
+
sessionOffsetApplied = {
|
|
179
|
+
claude: false,
|
|
180
|
+
gemini: false,
|
|
181
|
+
};
|
|
182
|
+
lastToastAccountIndex = -1;
|
|
183
|
+
lastToastTime = 0;
|
|
184
|
+
sessionStartTime;
|
|
185
|
+
sessionRequestCounts = new Map();
|
|
186
|
+
sessionUsedAccounts = new Set();
|
|
187
|
+
requestSessionStates = new Map();
|
|
188
|
+
sink;
|
|
189
|
+
healthSource;
|
|
190
|
+
tokenSource;
|
|
191
|
+
onDiagnostic;
|
|
192
|
+
now;
|
|
193
|
+
random;
|
|
194
|
+
pid;
|
|
195
|
+
constructor(options = {}) {
|
|
196
|
+
this.sink = options.sink ?? {};
|
|
197
|
+
this.onDiagnostic = options.onDiagnostic;
|
|
198
|
+
this.now = options.now ?? (() => Date.now());
|
|
199
|
+
this.random = options.random ?? (() => Math.random());
|
|
200
|
+
this.pid = options.pid ?? process.pid;
|
|
201
|
+
this.healthSource = options.healthTracker ?? new HealthScoreTracker();
|
|
202
|
+
this.tokenSource = options.tokenTracker ?? new TokenBucketTracker();
|
|
203
|
+
this.sessionStartTime = this.now();
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The health tracker this selector selects with, keyed by its current
|
|
207
|
+
* row indexes. Callers record request outcomes on this tracker.
|
|
208
|
+
*/
|
|
209
|
+
get healthTracker() {
|
|
210
|
+
const source = this.healthSource;
|
|
211
|
+
return typeof source === 'function' ? source() : source;
|
|
212
|
+
}
|
|
213
|
+
/** The token balances this selector selects with; see `healthTracker`. */
|
|
214
|
+
get tokenTracker() {
|
|
215
|
+
const source = this.tokenSource;
|
|
216
|
+
return typeof source === 'function' ? source() : source;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The rows in pool order: the selector's own array, not a copy. Callers
|
|
220
|
+
* must not change it; membership changes go through `replaceAccounts`
|
|
221
|
+
* and `removeAccount`.
|
|
222
|
+
*/
|
|
223
|
+
get rows() {
|
|
224
|
+
return this.accounts;
|
|
225
|
+
}
|
|
226
|
+
/** Asks the sink to persist the pool soon (`SelectionSink.requestSave`). */
|
|
227
|
+
requestSaveToDisk() {
|
|
228
|
+
this.sink.requestSave?.();
|
|
229
|
+
}
|
|
230
|
+
/** Whether `row` is (by object identity) one of the pool's rows. */
|
|
231
|
+
has(row) {
|
|
232
|
+
return this.accounts.includes(row);
|
|
233
|
+
}
|
|
234
|
+
// ========== Membership ==========
|
|
235
|
+
/**
|
|
236
|
+
* Installs the rows of an initial load, without matching them to earlier
|
|
237
|
+
* rows, and sets each given family's selection and round-robin cursor to
|
|
238
|
+
* the given index. The selector takes the array; each row's `index` must
|
|
239
|
+
* be its position. For later changes use `replaceAccounts`.
|
|
240
|
+
*/
|
|
241
|
+
resetAccounts(accounts, selection) {
|
|
242
|
+
this.accounts = accounts;
|
|
243
|
+
for (const family of FAMILIES) {
|
|
244
|
+
const index = selection?.[family];
|
|
245
|
+
if (index === undefined)
|
|
246
|
+
continue;
|
|
247
|
+
this.currentAccountIndexByFamily[family] = index;
|
|
248
|
+
this.cursorByFamily[family] = index;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Replaces the pool's rows with `next`, matched by `options.keyOf`, never
|
|
253
|
+
* by position or by any credential.
|
|
254
|
+
*
|
|
255
|
+
* A row whose key is unchanged stays the same object, refreshed from its
|
|
256
|
+
* fresh row (see `ReplaceAccountsOptions.refresh`), and keeps its session
|
|
257
|
+
* pins, used marks, health and token entries, session request counts and
|
|
258
|
+
* toast state at its new index. A row with a new key, a replaced
|
|
259
|
+
* credential included, is a new member that inherits nothing, even at the
|
|
260
|
+
* position its predecessor held. A row whose key is missing leaves: its
|
|
261
|
+
* pins and marks are dropped, a pool-wide selection on it falls back to
|
|
262
|
+
* `options.selectionFallback`, and any later transition about its object
|
|
263
|
+
* is ignored rather than applied to the row now at its old index. A row
|
|
264
|
+
* whose key is `undefined` is never matched. Round-robin cursors are
|
|
265
|
+
* counters and are kept.
|
|
266
|
+
*/
|
|
267
|
+
replaceAccounts(next, options) {
|
|
268
|
+
const fallback = options.selectionFallback;
|
|
269
|
+
return this.reconcile(next, options.keyOf, options.refresh ?? refreshSelectableRow, (family, _former, accounts) => fallback?.(family, accounts) ?? -1);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Removes one row. Rows after it move up one place, and their pins, used
|
|
273
|
+
* marks, trackers and counts move with them; the removed row's are
|
|
274
|
+
* dropped. A pool-wide selection on the removed row passes to the row
|
|
275
|
+
* that takes its place, and round-robin cursors past it step back one,
|
|
276
|
+
* as the pool-file manager always did. False when `row` is not loaded.
|
|
277
|
+
*/
|
|
278
|
+
removeAccount(row) {
|
|
279
|
+
const removedIndex = this.accounts.indexOf(row);
|
|
280
|
+
if (removedIndex < 0) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
const next = this.accounts.filter((account) => account !== row);
|
|
284
|
+
this.reconcile(next, (account) => account, () => { }, (_family, former, accounts) => (former < accounts.length ? former : -1));
|
|
285
|
+
if (next.length === 0) {
|
|
286
|
+
this.cursorByFamily = { claude: 0, gemini: 0 };
|
|
287
|
+
this.requestSessionStates.clear();
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
const shift = (cursor) => (cursor > removedIndex ? cursor - 1 : cursor) % next.length;
|
|
291
|
+
for (const family of FAMILIES) {
|
|
292
|
+
this.cursorByFamily[family] = shift(this.cursorByFamily[family]);
|
|
293
|
+
for (const state of this.requestSessionStates.values()) {
|
|
294
|
+
state.cursorByFamily[family] = shift(state.cursorByFamily[family]);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* The shared part of every membership change: matches `next` to the
|
|
301
|
+
* current rows by key, then moves all index-keyed state to the kept rows'
|
|
302
|
+
* new indexes and drops the state of rows that left. `fallback` gives the
|
|
303
|
+
* pool-wide selection of a family whose selected row left.
|
|
304
|
+
*/
|
|
305
|
+
reconcile(next, keyOf, refresh, fallback) {
|
|
306
|
+
const previous = new Map();
|
|
307
|
+
for (const account of this.accounts) {
|
|
308
|
+
const key = keyOf(account);
|
|
309
|
+
if (key !== undefined)
|
|
310
|
+
previous.set(key, account);
|
|
311
|
+
}
|
|
312
|
+
const accounts = [];
|
|
313
|
+
const moved = new Map();
|
|
314
|
+
const added = [];
|
|
315
|
+
for (const fresh of next) {
|
|
316
|
+
const key = keyOf(fresh);
|
|
317
|
+
const prior = key === undefined ? undefined : previous.get(key);
|
|
318
|
+
if (prior === undefined) {
|
|
319
|
+
fresh.index = accounts.length;
|
|
320
|
+
accounts.push(fresh);
|
|
321
|
+
added.push(fresh);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
previous.delete(key);
|
|
325
|
+
moved.set(prior.index, accounts.length);
|
|
326
|
+
if (prior !== fresh)
|
|
327
|
+
refresh(prior, fresh);
|
|
328
|
+
prior.index = accounts.length;
|
|
329
|
+
accounts.push(prior);
|
|
330
|
+
}
|
|
331
|
+
const kept = new Set(accounts);
|
|
332
|
+
const removed = this.accounts.filter((account) => !kept.has(account));
|
|
333
|
+
const remap = (index) => moved.get(index) ?? -1;
|
|
334
|
+
const remapSet = (indexes) => new Set([...indexes].map(remap).filter((index) => index >= 0));
|
|
335
|
+
this.accounts = accounts;
|
|
336
|
+
for (const family of FAMILIES) {
|
|
337
|
+
const former = this.currentAccountIndexByFamily[family];
|
|
338
|
+
const index = remap(former);
|
|
339
|
+
this.currentAccountIndexByFamily[family] =
|
|
340
|
+
index >= 0 || accounts.length === 0
|
|
341
|
+
? index
|
|
342
|
+
: fallback(family, former, accounts);
|
|
343
|
+
for (const state of this.requestSessionStates.values()) {
|
|
344
|
+
state.currentAccountIndexByFamily[family] = remap(state.currentAccountIndexByFamily[family]);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
for (const state of this.requestSessionStates.values()) {
|
|
348
|
+
state.usedAccounts = remapSet(state.usedAccounts);
|
|
349
|
+
}
|
|
350
|
+
this.sessionUsedAccounts = remapSet(this.sessionUsedAccounts);
|
|
351
|
+
const counts = new Map();
|
|
352
|
+
for (const [key, value] of this.sessionRequestCounts) {
|
|
353
|
+
const index = remap(Number(key));
|
|
354
|
+
if (index >= 0)
|
|
355
|
+
counts.set(String(index), value);
|
|
356
|
+
}
|
|
357
|
+
this.sessionRequestCounts = counts;
|
|
358
|
+
this.lastToastAccountIndex = remap(this.lastToastAccountIndex);
|
|
359
|
+
// A shared tracker is keyed by other pools' indexes too; only a tracker
|
|
360
|
+
// this selector owns is moved.
|
|
361
|
+
if (typeof this.healthSource !== 'function') {
|
|
362
|
+
this.healthSource.reindex(moved);
|
|
363
|
+
}
|
|
364
|
+
if (typeof this.tokenSource !== 'function') {
|
|
365
|
+
this.tokenSource.reindex(moved);
|
|
366
|
+
}
|
|
367
|
+
return { added, removed };
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Whether `row` is still loaded. A transition about a row that left is
|
|
371
|
+
* reported as ignored and must not be applied through its old index,
|
|
372
|
+
* which may now name another row.
|
|
373
|
+
*/
|
|
374
|
+
isLoaded(row, label) {
|
|
375
|
+
if (this.accounts.includes(row))
|
|
376
|
+
return true;
|
|
377
|
+
if (this.sink.stale !== undefined) {
|
|
378
|
+
this.sink.stale(row, label);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
this.onDiagnostic?.(`Account ${label} ignored: the account is no longer loaded`);
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
// ========== Selection ==========
|
|
386
|
+
getAccountCount() {
|
|
387
|
+
return this.getEnabledAccounts().length;
|
|
388
|
+
}
|
|
389
|
+
getTotalAccountCount() {
|
|
390
|
+
return this.accounts.length;
|
|
391
|
+
}
|
|
392
|
+
getEnabledAccounts() {
|
|
393
|
+
return this.accounts.filter((account) => account.enabled !== false);
|
|
394
|
+
}
|
|
395
|
+
getAccounts() {
|
|
396
|
+
return [...this.accounts];
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Copies of the rows with their own rate-limit maps. Every field of a row
|
|
400
|
+
* is copied, so this suits rows without credentials; a pool whose rows
|
|
401
|
+
* carry credentials (the local `AccountManager`) takes its own snapshot.
|
|
402
|
+
*/
|
|
403
|
+
getAccountsSnapshot() {
|
|
404
|
+
return this.accounts.map((a) => ({
|
|
405
|
+
...a,
|
|
406
|
+
rateLimitResetTimes: { ...a.rateLimitResetTimes },
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
getEffectiveSoftQuotaThreshold(thresholdPercent) {
|
|
410
|
+
// Soft-quota protection only has a purpose when another enabled account
|
|
411
|
+
// exists to rotate to. Never block the sole usable account.
|
|
412
|
+
return this.getEnabledAccounts().length > 1 ? thresholdPercent : 100;
|
|
413
|
+
}
|
|
414
|
+
getRequestSessionState(identity) {
|
|
415
|
+
const now = this.now();
|
|
416
|
+
this.pruneRequestSessionStates(now, identity.id);
|
|
417
|
+
const existing = this.requestSessionStates.get(identity.id);
|
|
418
|
+
if (existing) {
|
|
419
|
+
existing.lastAccessedAt = now;
|
|
420
|
+
if (identity.parentId) {
|
|
421
|
+
existing.parentId = identity.parentId;
|
|
422
|
+
}
|
|
423
|
+
return existing;
|
|
424
|
+
}
|
|
425
|
+
const state = {
|
|
426
|
+
parentId: identity.parentId ?? null,
|
|
427
|
+
currentAccountIndexByFamily: { claude: -1, gemini: -1 },
|
|
428
|
+
cursorByFamily: { ...this.cursorByFamily },
|
|
429
|
+
offsetAppliedByFamily: { claude: false, gemini: false },
|
|
430
|
+
usedAccounts: new Set(),
|
|
431
|
+
lastAccessedAt: now,
|
|
432
|
+
};
|
|
433
|
+
this.requestSessionStates.set(identity.id, state);
|
|
434
|
+
return state;
|
|
435
|
+
}
|
|
436
|
+
pruneRequestSessionStates(now, preservedId) {
|
|
437
|
+
const expiry = now - ACCOUNT_SESSION_STATE_TTL_MS;
|
|
438
|
+
for (const [id, state] of this.requestSessionStates) {
|
|
439
|
+
if (id !== preservedId && state.lastAccessedAt < expiry) {
|
|
440
|
+
this.requestSessionStates.delete(id);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (this.requestSessionStates.size < MAX_ACCOUNT_SESSION_STATES ||
|
|
444
|
+
this.requestSessionStates.has(preservedId)) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
let oldestId = null;
|
|
448
|
+
let oldestAccess = Number.POSITIVE_INFINITY;
|
|
449
|
+
for (const [id, state] of this.requestSessionStates) {
|
|
450
|
+
if (id !== preservedId && state.lastAccessedAt < oldestAccess) {
|
|
451
|
+
oldestId = id;
|
|
452
|
+
oldestAccess = state.lastAccessedAt;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (oldestId) {
|
|
456
|
+
this.requestSessionStates.delete(oldestId);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
getActiveIndex(family, identity) {
|
|
460
|
+
return identity
|
|
461
|
+
? this.getRequestSessionState(identity).currentAccountIndexByFamily[family]
|
|
462
|
+
: this.currentAccountIndexByFamily[family];
|
|
463
|
+
}
|
|
464
|
+
setActiveIndex(family, index, identity) {
|
|
465
|
+
if (!identity) {
|
|
466
|
+
this.setGlobalActiveIndex(family, index);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const state = this.getRequestSessionState(identity);
|
|
470
|
+
state.currentAccountIndexByFamily[family] = index;
|
|
471
|
+
if (!state.parentId) {
|
|
472
|
+
// Preserve a useful persisted starting point without coupling active root sessions.
|
|
473
|
+
this.setGlobalActiveIndex(family, index);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/** Sets the pool-wide selection of a family; a change is reported. */
|
|
477
|
+
setGlobalActiveIndex(family, index) {
|
|
478
|
+
if (this.currentAccountIndexByFamily[family] === index)
|
|
479
|
+
return;
|
|
480
|
+
this.currentAccountIndexByFamily[family] = index;
|
|
481
|
+
this.sink.selection?.(family, this.accounts[index] ?? null);
|
|
482
|
+
}
|
|
483
|
+
getCursor(family, identity) {
|
|
484
|
+
return identity
|
|
485
|
+
? this.getRequestSessionState(identity).cursorByFamily[family]
|
|
486
|
+
: this.cursorByFamily[family];
|
|
487
|
+
}
|
|
488
|
+
advanceCursor(family, identity) {
|
|
489
|
+
const nextGlobalCursor = this.cursorByFamily[family] + 1;
|
|
490
|
+
this.cursorByFamily[family] = nextGlobalCursor;
|
|
491
|
+
if (identity) {
|
|
492
|
+
this.getRequestSessionState(identity).cursorByFamily[family] += 1;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
getUsedAccounts(identity) {
|
|
496
|
+
return identity
|
|
497
|
+
? this.getRequestSessionState(identity).usedAccounts
|
|
498
|
+
: this.sessionUsedAccounts;
|
|
499
|
+
}
|
|
500
|
+
preferAccountOutsideParent(accounts, family, identity) {
|
|
501
|
+
if (!identity) {
|
|
502
|
+
return accounts;
|
|
503
|
+
}
|
|
504
|
+
const parentId = this.getRequestSessionState(identity).parentId;
|
|
505
|
+
if (!parentId) {
|
|
506
|
+
return accounts;
|
|
507
|
+
}
|
|
508
|
+
const parentState = this.requestSessionStates.get(parentId);
|
|
509
|
+
const parentIndex = parentState?.currentAccountIndexByFamily[family] ?? -1;
|
|
510
|
+
if (parentIndex < 0) {
|
|
511
|
+
return accounts;
|
|
512
|
+
}
|
|
513
|
+
const isolated = accounts.filter((account) => account.index !== parentIndex);
|
|
514
|
+
return isolated.length > 0 ? isolated : accounts;
|
|
515
|
+
}
|
|
516
|
+
deleteSessionState(sessionId) {
|
|
517
|
+
this.requestSessionStates.delete(sessionId);
|
|
518
|
+
}
|
|
519
|
+
getCurrentAccountForFamily(family, identity) {
|
|
520
|
+
const currentIndex = this.getActiveIndex(family, identity);
|
|
521
|
+
if (currentIndex >= 0 && currentIndex < this.accounts.length) {
|
|
522
|
+
const account = this.accounts[currentIndex] ?? null;
|
|
523
|
+
// Only return account if it's enabled - disabled accounts should not be selected
|
|
524
|
+
if (account && account.enabled !== false) {
|
|
525
|
+
return account;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Numeric active indexes for each model family. Exposed so callers
|
|
532
|
+
* that persist `activeIndexByFamily` (e.g. command-data's remove
|
|
533
|
+
* path) can capture the live cursor per family without going
|
|
534
|
+
* through the account-lookup layer.
|
|
535
|
+
*/
|
|
536
|
+
getActiveIndexByFamily(identity) {
|
|
537
|
+
return {
|
|
538
|
+
claude: this.getActiveIndex('claude', identity),
|
|
539
|
+
gemini: this.getActiveIndex('gemini', identity),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
markSwitched(account, reason, family, identity) {
|
|
543
|
+
account.lastSwitchReason = reason;
|
|
544
|
+
// An account that left the pool no longer has a position here; its
|
|
545
|
+
// old index may name another account now.
|
|
546
|
+
if (!this.isLoaded(account, 'switch')) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
this.sink.switched?.(account, reason);
|
|
550
|
+
this.setActiveIndex(family, account.index, identity);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Check if we should show an account switch toast.
|
|
554
|
+
* Debounces repeated toasts for the same account.
|
|
555
|
+
*/
|
|
556
|
+
shouldShowAccountToast(accountIndex, debounceMs = 30000) {
|
|
557
|
+
const now = this.now();
|
|
558
|
+
if (accountIndex !== this.lastToastAccountIndex) {
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
return now - this.lastToastTime >= debounceMs;
|
|
562
|
+
}
|
|
563
|
+
markToastShown(accountIndex) {
|
|
564
|
+
this.lastToastAccountIndex = accountIndex;
|
|
565
|
+
this.lastToastTime = this.now();
|
|
566
|
+
}
|
|
567
|
+
getCurrentOrNextForFamily(family, model, strategy = 'sticky', headerStyle = 'antigravity', pidOffsetEnabled = false, softQuotaThresholdPercent = 100, softQuotaCacheTtlMs = 10 * 60 * 1000, identity,
|
|
568
|
+
/**
|
|
569
|
+
* Account indexes the caller has ruled out (e.g. the operator
|
|
570
|
+
* killswitch pre-filter). Every selection path — pinned session,
|
|
571
|
+
* round-robin, hybrid, and sticky fallback — skips these indexes
|
|
572
|
+
* so a killed current account falls through to the next eligible
|
|
573
|
+
* account instead of collapsing the request into the rate-limit
|
|
574
|
+
* wait path.
|
|
575
|
+
*/
|
|
576
|
+
excludeIndexes) {
|
|
577
|
+
const quotaKey = getQuotaKey(family, headerStyle, model);
|
|
578
|
+
const effectiveSoftQuotaThreshold = this.getEffectiveSoftQuotaThreshold(softQuotaThresholdPercent);
|
|
579
|
+
// OpenCode may run many root and child sessions concurrently in one plugin
|
|
580
|
+
// process. Pin each exact session until its account becomes unavailable.
|
|
581
|
+
if (identity) {
|
|
582
|
+
const pinned = this.getCurrentAccountForFamily(family, identity);
|
|
583
|
+
if (pinned) {
|
|
584
|
+
clearExpiredRateLimits(pinned, this.now);
|
|
585
|
+
const unavailable = (excludeIndexes?.has(pinned.index) ?? false) ||
|
|
586
|
+
isRateLimitedForHeaderStyle(pinned, family, headerStyle, this.now, model) ||
|
|
587
|
+
isOverSoftQuotaThreshold(pinned, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model) ||
|
|
588
|
+
this.isAccountCoolingDown(pinned);
|
|
589
|
+
if (!unavailable) {
|
|
590
|
+
this.markTouchedForQuota(pinned, quotaKey);
|
|
591
|
+
return pinned;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (strategy === 'round-robin') {
|
|
596
|
+
const next = this.getNextForFamily(family, model, headerStyle, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, identity, excludeIndexes);
|
|
597
|
+
if (next) {
|
|
598
|
+
this.markTouchedForQuota(next, quotaKey);
|
|
599
|
+
this.setActiveIndex(family, next.index, identity);
|
|
600
|
+
}
|
|
601
|
+
return next;
|
|
602
|
+
}
|
|
603
|
+
if (strategy === 'hybrid') {
|
|
604
|
+
const healthTracker = this.healthTracker;
|
|
605
|
+
const tokenTracker = this.tokenTracker;
|
|
606
|
+
const eligibleAccounts = this.preferAccountOutsideParent(this.accounts.filter((acc) => acc.enabled !== false && !excludeIndexes?.has(acc.index)), family, identity);
|
|
607
|
+
const accountsWithMetrics = eligibleAccounts.map((acc) => {
|
|
608
|
+
clearExpiredRateLimits(acc, this.now);
|
|
609
|
+
return {
|
|
610
|
+
index: acc.index,
|
|
611
|
+
lastUsed: acc.lastUsed,
|
|
612
|
+
healthScore: healthTracker.getScore(acc.index),
|
|
613
|
+
isRateLimited: isRateLimitedForHeaderStyle(acc, family, headerStyle, this.now, model) ||
|
|
614
|
+
isOverSoftQuotaThreshold(acc, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model),
|
|
615
|
+
isCoolingDown: this.isAccountCoolingDown(acc),
|
|
616
|
+
};
|
|
617
|
+
});
|
|
618
|
+
// Get current account index for stickiness
|
|
619
|
+
const currentIndex = this.getActiveIndex(family, identity);
|
|
620
|
+
const selectedIndex = selectHybridAccount(accountsWithMetrics, tokenTracker, currentIndex, 50, this.now);
|
|
621
|
+
if (selectedIndex !== null) {
|
|
622
|
+
const selected = this.accounts[selectedIndex];
|
|
623
|
+
if (selected) {
|
|
624
|
+
this.touchLastUsed(selected);
|
|
625
|
+
this.markTouchedForQuota(selected, quotaKey);
|
|
626
|
+
this.setActiveIndex(family, selected.index, identity);
|
|
627
|
+
return selected;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Fallback: sticky selection (used when hybrid finds no candidates)
|
|
632
|
+
// PID-based offset for multi-session distribution (opt-in)
|
|
633
|
+
// Different sessions (PIDs) will prefer different starting accounts
|
|
634
|
+
const offsetApplied = identity
|
|
635
|
+
? this.getRequestSessionState(identity).offsetAppliedByFamily
|
|
636
|
+
: this.sessionOffsetApplied;
|
|
637
|
+
if (pidOffsetEnabled &&
|
|
638
|
+
!offsetApplied[family] &&
|
|
639
|
+
this.accounts.length > 1) {
|
|
640
|
+
const pidOffset = this.pid % this.accounts.length;
|
|
641
|
+
const activeIndex = this.getActiveIndex(family, identity);
|
|
642
|
+
const baseIndex = activeIndex >= 0 ? activeIndex : this.getCursor(family, identity);
|
|
643
|
+
const newIndex = (baseIndex + pidOffset) % this.accounts.length;
|
|
644
|
+
this.onDiagnostic?.('Applying PID account offset', {
|
|
645
|
+
pid: this.pid,
|
|
646
|
+
offset: pidOffset,
|
|
647
|
+
family,
|
|
648
|
+
fromIndex: baseIndex,
|
|
649
|
+
toIndex: newIndex,
|
|
650
|
+
});
|
|
651
|
+
this.setActiveIndex(family, newIndex, identity);
|
|
652
|
+
offsetApplied[family] = true;
|
|
653
|
+
}
|
|
654
|
+
const current = this.getCurrentAccountForFamily(family, identity);
|
|
655
|
+
if (current && !excludeIndexes?.has(current.index)) {
|
|
656
|
+
clearExpiredRateLimits(current, this.now);
|
|
657
|
+
const isLimitedForRequestedStyle = isRateLimitedForHeaderStyle(current, family, headerStyle, this.now, model);
|
|
658
|
+
const isOverThreshold = isOverSoftQuotaThreshold(current, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model);
|
|
659
|
+
if (!isLimitedForRequestedStyle &&
|
|
660
|
+
!isOverThreshold &&
|
|
661
|
+
!this.isAccountCoolingDown(current)) {
|
|
662
|
+
this.markTouchedForQuota(current, quotaKey);
|
|
663
|
+
return current;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const next = this.getNextForFamily(family, model, headerStyle, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, identity, excludeIndexes);
|
|
667
|
+
if (next) {
|
|
668
|
+
this.markTouchedForQuota(next, quotaKey);
|
|
669
|
+
this.setActiveIndex(family, next.index, identity);
|
|
670
|
+
}
|
|
671
|
+
return next;
|
|
672
|
+
}
|
|
673
|
+
getNextForFamily(family, model, headerStyle = 'antigravity', softQuotaThresholdPercent = 100, softQuotaCacheTtlMs = 10 * 60 * 1000, identity,
|
|
674
|
+
/** Indexes ruled out by the caller (e.g. killswitch pre-filter). */
|
|
675
|
+
excludeIndexes) {
|
|
676
|
+
const effectiveSoftQuotaThreshold = this.getEffectiveSoftQuotaThreshold(softQuotaThresholdPercent);
|
|
677
|
+
const allAvailable = this.accounts.filter((account) => {
|
|
678
|
+
clearExpiredRateLimits(account, this.now);
|
|
679
|
+
return (account.enabled !== false &&
|
|
680
|
+
!excludeIndexes?.has(account.index) &&
|
|
681
|
+
!isRateLimitedForHeaderStyle(account, family, headerStyle, this.now, model) &&
|
|
682
|
+
!isOverSoftQuotaThreshold(account, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model) &&
|
|
683
|
+
!this.isAccountCoolingDown(account));
|
|
684
|
+
});
|
|
685
|
+
const available = this.preferAccountOutsideParent(allAvailable, family, identity);
|
|
686
|
+
if (available.length === 0) {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
const usedAccounts = this.getUsedAccounts(identity);
|
|
690
|
+
const sessionUsed = available.filter((account) => usedAccounts.has(account.index));
|
|
691
|
+
const candidates = sessionUsed.length > 0 ? sessionUsed : available;
|
|
692
|
+
const cursor = this.getCursor(family, identity);
|
|
693
|
+
const account = candidates[cursor % candidates.length];
|
|
694
|
+
if (!account) {
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
this.advanceCursor(family, identity);
|
|
698
|
+
return account;
|
|
699
|
+
}
|
|
700
|
+
markRateLimited(account, retryAfterMs, family, headerStyle = 'antigravity', model) {
|
|
701
|
+
const key = getQuotaKey(family, headerStyle, model);
|
|
702
|
+
this.setRateLimit(account, key, this.now() + retryAfterMs);
|
|
703
|
+
}
|
|
704
|
+
setRateLimit(account, key, resetAt) {
|
|
705
|
+
account.rateLimitResetTimes[key] = resetAt;
|
|
706
|
+
const report = this.sink.rateLimit;
|
|
707
|
+
if (report !== undefined && this.isLoaded(account, 'state change')) {
|
|
708
|
+
report.call(this.sink, account, key, resetAt);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
clearRateLimit(account, key) {
|
|
712
|
+
delete account.rateLimitResetTimes[key];
|
|
713
|
+
const report = this.sink.rateLimit;
|
|
714
|
+
if (report !== undefined && this.isLoaded(account, 'state change')) {
|
|
715
|
+
report.call(this.sink, account, key, 'clear');
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
touchLastUsed(account) {
|
|
719
|
+
const now = this.now();
|
|
720
|
+
account.lastUsed = now;
|
|
721
|
+
const report = this.sink.lastUsed;
|
|
722
|
+
if (report !== undefined && this.isLoaded(account, 'state change')) {
|
|
723
|
+
report.call(this.sink, account, now);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Mark an account as used after a successful API request.
|
|
728
|
+
* This updates the lastUsed timestamp for freshness calculations.
|
|
729
|
+
* Should be called AFTER request completion, not during account selection.
|
|
730
|
+
*/
|
|
731
|
+
markAccountUsed(accountIndex) {
|
|
732
|
+
const account = this.accounts.find((a) => a.index === accountIndex);
|
|
733
|
+
if (account) {
|
|
734
|
+
this.touchLastUsed(account);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
recordSessionUsage(accountIndex, identity) {
|
|
738
|
+
this.getUsedAccounts(identity).add(accountIndex);
|
|
739
|
+
}
|
|
740
|
+
wasUsedInSession(accountIndex, identity) {
|
|
741
|
+
return this.getUsedAccounts(identity).has(accountIndex);
|
|
742
|
+
}
|
|
743
|
+
shouldProactivelyRotate(family, model, thresholdPercent, cacheTtlMs, identity) {
|
|
744
|
+
if (thresholdPercent <= 0)
|
|
745
|
+
return false;
|
|
746
|
+
const current = this.getCurrentAccountForFamily(family, identity);
|
|
747
|
+
if (!current?.cachedQuota || current.cachedQuotaUpdatedAt == null)
|
|
748
|
+
return false;
|
|
749
|
+
const age = this.now() - current.cachedQuotaUpdatedAt;
|
|
750
|
+
if (age > cacheTtlMs)
|
|
751
|
+
return false;
|
|
752
|
+
const quotaGroup = resolveQuotaGroup(family, model);
|
|
753
|
+
const groupData = current.cachedQuota[quotaGroup];
|
|
754
|
+
if (groupData?.remainingFraction == null)
|
|
755
|
+
return false;
|
|
756
|
+
const remainingPercent = Math.max(0, Math.min(100, groupData.remainingFraction * 100));
|
|
757
|
+
return remainingPercent < thresholdPercent;
|
|
758
|
+
}
|
|
759
|
+
proactivelyRotateForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs, identity) {
|
|
760
|
+
const currentIndex = this.getActiveIndex(family, identity);
|
|
761
|
+
const candidates = this.preferAccountOutsideParent(this.accounts.filter((acc) => {
|
|
762
|
+
if (acc.enabled === false)
|
|
763
|
+
return false;
|
|
764
|
+
if (acc.index === currentIndex)
|
|
765
|
+
return false;
|
|
766
|
+
clearExpiredRateLimits(acc, this.now);
|
|
767
|
+
if (isRateLimitedForHeaderStyle(acc, family, headerStyle, this.now, model))
|
|
768
|
+
return false;
|
|
769
|
+
if (isOverSoftQuotaThreshold(acc, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, this.now, model))
|
|
770
|
+
return false;
|
|
771
|
+
if (this.isAccountCoolingDown(acc))
|
|
772
|
+
return false;
|
|
773
|
+
return true;
|
|
774
|
+
}), family, identity);
|
|
775
|
+
if (candidates.length === 0)
|
|
776
|
+
return null;
|
|
777
|
+
const usedAccounts = this.getUsedAccounts(identity);
|
|
778
|
+
const warmCandidates = candidates.filter((account) => usedAccounts.has(account.index));
|
|
779
|
+
const pool = warmCandidates.length > 0 ? warmCandidates : candidates;
|
|
780
|
+
const quotaGroup = resolveQuotaGroup(family, model);
|
|
781
|
+
pool.sort((a, b) => {
|
|
782
|
+
const aRemaining = a.cachedQuota?.[quotaGroup]?.remainingFraction ?? 0;
|
|
783
|
+
const bRemaining = b.cachedQuota?.[quotaGroup]?.remainingFraction ?? 0;
|
|
784
|
+
return bRemaining - aRemaining;
|
|
785
|
+
});
|
|
786
|
+
const selected = pool[0];
|
|
787
|
+
if (!selected)
|
|
788
|
+
return null;
|
|
789
|
+
const quotaKey = getQuotaKey(family, headerStyle, model);
|
|
790
|
+
this.markTouchedForQuota(selected, quotaKey);
|
|
791
|
+
this.setActiveIndex(family, selected.index, identity);
|
|
792
|
+
return selected;
|
|
793
|
+
}
|
|
794
|
+
markRateLimitedWithReason(account, family, headerStyle, model, reason, retryAfterMs, failureTtlMs = 3600_000) {
|
|
795
|
+
const now = this.now();
|
|
796
|
+
// TTL-based reset: if last failure was more than failureTtlMs ago, reset count
|
|
797
|
+
if (account.lastFailureTime !== undefined &&
|
|
798
|
+
now - account.lastFailureTime > failureTtlMs) {
|
|
799
|
+
account.consecutiveFailures = 0;
|
|
800
|
+
}
|
|
801
|
+
const failures = (account.consecutiveFailures ?? 0) + 1;
|
|
802
|
+
account.consecutiveFailures = failures;
|
|
803
|
+
account.lastFailureTime = now;
|
|
804
|
+
const backoffMs = calculateBackoffMs(reason, failures - 1, retryAfterMs, this.random);
|
|
805
|
+
const key = getQuotaKey(family, headerStyle, model);
|
|
806
|
+
this.setRateLimit(account, key, now + backoffMs);
|
|
807
|
+
return backoffMs;
|
|
808
|
+
}
|
|
809
|
+
markRequestSuccess(account) {
|
|
810
|
+
if (account.consecutiveFailures) {
|
|
811
|
+
account.consecutiveFailures = 0;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
clearAllRateLimitsForFamily(family, model) {
|
|
815
|
+
for (const account of this.accounts) {
|
|
816
|
+
if (family === 'claude') {
|
|
817
|
+
this.clearRateLimit(account, 'claude');
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
const antigravityKey = getQuotaKey(family, 'antigravity', model);
|
|
821
|
+
const cliKey = getQuotaKey(family, 'gemini-cli', model);
|
|
822
|
+
this.clearRateLimit(account, antigravityKey);
|
|
823
|
+
this.clearRateLimit(account, cliKey);
|
|
824
|
+
}
|
|
825
|
+
account.consecutiveFailures = 0;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
shouldTryOptimisticReset(family, model) {
|
|
829
|
+
const minWaitMs = this.getMinWaitTimeForFamily(family, model);
|
|
830
|
+
return minWaitMs > 0 && minWaitMs <= 2_000;
|
|
831
|
+
}
|
|
832
|
+
markAccountCoolingDown(account, cooldownMs, reason) {
|
|
833
|
+
const until = this.now() + cooldownMs;
|
|
834
|
+
account.coolingDownUntil = until;
|
|
835
|
+
account.cooldownReason = reason;
|
|
836
|
+
const report = this.sink.cooldown;
|
|
837
|
+
if (report !== undefined && this.isLoaded(account, 'state change')) {
|
|
838
|
+
report.call(this.sink, account, { until, reason });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
isAccountCoolingDown(account) {
|
|
842
|
+
if (account.coolingDownUntil === undefined) {
|
|
843
|
+
return false;
|
|
844
|
+
}
|
|
845
|
+
if (this.now() >= account.coolingDownUntil) {
|
|
846
|
+
this.clearAccountCooldown(account);
|
|
847
|
+
return false;
|
|
848
|
+
}
|
|
849
|
+
return true;
|
|
850
|
+
}
|
|
851
|
+
clearAccountCooldown(account) {
|
|
852
|
+
const hadCooldown = account.coolingDownUntil !== undefined ||
|
|
853
|
+
account.cooldownReason !== undefined;
|
|
854
|
+
delete account.coolingDownUntil;
|
|
855
|
+
delete account.cooldownReason;
|
|
856
|
+
const report = this.sink.cooldown;
|
|
857
|
+
if (hadCooldown &&
|
|
858
|
+
report !== undefined &&
|
|
859
|
+
this.isLoaded(account, 'state change')) {
|
|
860
|
+
report.call(this.sink, account, null);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
getAccountCooldownReason(account) {
|
|
864
|
+
return this.isAccountCoolingDown(account)
|
|
865
|
+
? account.cooldownReason
|
|
866
|
+
: undefined;
|
|
867
|
+
}
|
|
868
|
+
markTouchedForQuota(account, quotaKey) {
|
|
869
|
+
account.touchedForQuota[quotaKey] = this.now();
|
|
870
|
+
}
|
|
871
|
+
isFreshForQuota(account, quotaKey) {
|
|
872
|
+
const touchedAt = account.touchedForQuota[quotaKey];
|
|
873
|
+
if (!touchedAt)
|
|
874
|
+
return true;
|
|
875
|
+
const resetTime = account.rateLimitResetTimes[quotaKey];
|
|
876
|
+
if (resetTime && touchedAt < resetTime)
|
|
877
|
+
return true;
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
getFreshAccountsForQuota(quotaKey, family, model) {
|
|
881
|
+
return this.accounts.filter((acc) => {
|
|
882
|
+
clearExpiredRateLimits(acc, this.now);
|
|
883
|
+
return (acc.enabled !== false &&
|
|
884
|
+
this.isFreshForQuota(acc, quotaKey) &&
|
|
885
|
+
!isRateLimitedForFamily(acc, family, this.now, model) &&
|
|
886
|
+
!this.isAccountCoolingDown(acc));
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
isRateLimitedForHeaderStyle(account, family, headerStyle, model) {
|
|
890
|
+
return isRateLimitedForHeaderStyle(account, family, headerStyle, this.now, model);
|
|
891
|
+
}
|
|
892
|
+
getAvailableHeaderStyle(account, family, model) {
|
|
893
|
+
clearExpiredRateLimits(account, this.now);
|
|
894
|
+
if (family === 'claude') {
|
|
895
|
+
return isRateLimitedForHeaderStyle(account, family, 'antigravity', this.now)
|
|
896
|
+
? null
|
|
897
|
+
: 'antigravity';
|
|
898
|
+
}
|
|
899
|
+
if (!isRateLimitedForHeaderStyle(account, family, 'antigravity', this.now, model)) {
|
|
900
|
+
return 'antigravity';
|
|
901
|
+
}
|
|
902
|
+
if (!isRateLimitedForHeaderStyle(account, family, 'gemini-cli', this.now, model)) {
|
|
903
|
+
return 'gemini-cli';
|
|
904
|
+
}
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Check if any OTHER account has antigravity quota available for the given family/model.
|
|
909
|
+
*
|
|
910
|
+
* Used to determine whether to switch accounts vs fall back to gemini-cli:
|
|
911
|
+
* - If true: Switch to another account (preserve antigravity priority)
|
|
912
|
+
* - If false: All accounts exhausted antigravity, safe to fall back to gemini-cli
|
|
913
|
+
*
|
|
914
|
+
* @param currentAccountIndex - Index of the current account (will be excluded from check)
|
|
915
|
+
* @param family - Model family ("gemini" or "claude")
|
|
916
|
+
* @param model - Optional model name for model-specific rate limits
|
|
917
|
+
* @returns true if any other enabled, non-cooling-down account has antigravity available
|
|
918
|
+
*/
|
|
919
|
+
hasOtherAccountWithAntigravityAvailable(currentAccountIndex, family, model) {
|
|
920
|
+
// Claude has no gemini-cli fallback - always return false
|
|
921
|
+
// (This method is only relevant for Gemini's dual quota pools)
|
|
922
|
+
if (family === 'claude') {
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
return this.accounts.some((acc) => {
|
|
926
|
+
// Skip current account
|
|
927
|
+
if (acc.index === currentAccountIndex) {
|
|
928
|
+
return false;
|
|
929
|
+
}
|
|
930
|
+
// Skip disabled accounts
|
|
931
|
+
if (acc.enabled === false) {
|
|
932
|
+
return false;
|
|
933
|
+
}
|
|
934
|
+
// Skip cooling down accounts
|
|
935
|
+
if (this.isAccountCoolingDown(acc)) {
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
938
|
+
// Clear expired rate limits before checking
|
|
939
|
+
clearExpiredRateLimits(acc, this.now);
|
|
940
|
+
// Check if antigravity is available for this account
|
|
941
|
+
return !isRateLimitedForHeaderStyle(acc, family, 'antigravity', this.now, model);
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
setAccountEnabled(accountIndex, enabled) {
|
|
945
|
+
const account = this.accounts[accountIndex];
|
|
946
|
+
if (!account) {
|
|
947
|
+
return false;
|
|
948
|
+
}
|
|
949
|
+
if (enabled && account.accountIneligible) {
|
|
950
|
+
return false;
|
|
951
|
+
}
|
|
952
|
+
this.applyEnabled(accountIndex, enabled);
|
|
953
|
+
this.sink.enabled?.(account, enabled);
|
|
954
|
+
this.sink.requestSave?.();
|
|
955
|
+
return true;
|
|
956
|
+
}
|
|
957
|
+
/** Changes the in-memory flag and moves a selection off a disabled account. */
|
|
958
|
+
applyEnabled(accountIndex, enabled) {
|
|
959
|
+
const account = this.accounts[accountIndex];
|
|
960
|
+
if (!account)
|
|
961
|
+
return;
|
|
962
|
+
account.enabled = enabled;
|
|
963
|
+
if (!enabled) {
|
|
964
|
+
for (const family of Object.keys(this.currentAccountIndexByFamily)) {
|
|
965
|
+
if (this.currentAccountIndexByFamily[family] === accountIndex) {
|
|
966
|
+
const next = this.accounts.find((a, i) => i !== accountIndex && a.enabled !== false);
|
|
967
|
+
this.setGlobalActiveIndex(family, next?.index ?? -1);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Sets `row`'s enabled flag without reporting it, as when its store
|
|
974
|
+
* already changed the flag, and moves a selection off it when it is
|
|
975
|
+
* disabled. A row that left the pool changes only its own flag, never the
|
|
976
|
+
* row now at its old index.
|
|
977
|
+
*/
|
|
978
|
+
setEnabledInMemory(row, enabled) {
|
|
979
|
+
if (this.accounts.includes(row)) {
|
|
980
|
+
this.applyEnabled(row.index, enabled);
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
row.enabled = enabled;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
markAccountVerificationRequired(accountIndex, reason, verifyUrl) {
|
|
987
|
+
const account = this.accounts[accountIndex];
|
|
988
|
+
if (!account) {
|
|
989
|
+
return false;
|
|
990
|
+
}
|
|
991
|
+
const timestamp = this.now();
|
|
992
|
+
account.verificationRequired = true;
|
|
993
|
+
account.verificationRequiredAt = timestamp;
|
|
994
|
+
account.verificationRequiredReason = reason?.trim() || undefined;
|
|
995
|
+
if (account.accountIneligible === true ||
|
|
996
|
+
account.accountIneligibleAt !== undefined ||
|
|
997
|
+
account.accountIneligibleReason !== undefined) {
|
|
998
|
+
account.accountIneligible = false;
|
|
999
|
+
account.accountIneligibleAt = undefined;
|
|
1000
|
+
account.accountIneligibleReason = undefined;
|
|
1001
|
+
account.eligibilityStateUpdatedAt = timestamp;
|
|
1002
|
+
}
|
|
1003
|
+
const normalizedVerifyUrl = verifyUrl?.trim();
|
|
1004
|
+
if (normalizedVerifyUrl) {
|
|
1005
|
+
account.verificationUrl = normalizedVerifyUrl;
|
|
1006
|
+
}
|
|
1007
|
+
if (account.enabled !== false) {
|
|
1008
|
+
this.applyEnabled(accountIndex, false);
|
|
1009
|
+
}
|
|
1010
|
+
// The verdict, its metadata and the disabled flag land in one
|
|
1011
|
+
// attributed transition.
|
|
1012
|
+
this.sink.accessVerdict?.(account, {
|
|
1013
|
+
kind: 'verification-required',
|
|
1014
|
+
observedAt: timestamp,
|
|
1015
|
+
...(account.verificationRequiredReason !== undefined
|
|
1016
|
+
? { reason: account.verificationRequiredReason }
|
|
1017
|
+
: {}),
|
|
1018
|
+
...(normalizedVerifyUrl ? { verificationUrl: normalizedVerifyUrl } : {}),
|
|
1019
|
+
});
|
|
1020
|
+
this.sink.requestSave?.();
|
|
1021
|
+
return true;
|
|
1022
|
+
}
|
|
1023
|
+
markAccountIneligible(accountIndex, reason) {
|
|
1024
|
+
const account = this.accounts[accountIndex];
|
|
1025
|
+
if (!account) {
|
|
1026
|
+
return false;
|
|
1027
|
+
}
|
|
1028
|
+
const timestamp = this.now();
|
|
1029
|
+
account.accountIneligible = true;
|
|
1030
|
+
account.accountIneligibleAt = timestamp;
|
|
1031
|
+
account.accountIneligibleReason =
|
|
1032
|
+
reason?.trim() || 'Google marked this account as ineligible.';
|
|
1033
|
+
account.eligibilityStateUpdatedAt = timestamp;
|
|
1034
|
+
account.verificationRequired = false;
|
|
1035
|
+
account.verificationRequiredAt = undefined;
|
|
1036
|
+
account.verificationRequiredReason = undefined;
|
|
1037
|
+
account.verificationUrl = undefined;
|
|
1038
|
+
if (account.enabled !== false) {
|
|
1039
|
+
this.applyEnabled(accountIndex, false);
|
|
1040
|
+
}
|
|
1041
|
+
this.sink.accessVerdict?.(account, {
|
|
1042
|
+
kind: 'ineligible',
|
|
1043
|
+
observedAt: timestamp,
|
|
1044
|
+
reason: account.accountIneligibleReason,
|
|
1045
|
+
});
|
|
1046
|
+
this.sink.requestSave?.();
|
|
1047
|
+
return true;
|
|
1048
|
+
}
|
|
1049
|
+
clearAccountAccessBlocks(accountIndex, enableAccount = false) {
|
|
1050
|
+
const account = this.accounts[accountIndex];
|
|
1051
|
+
if (!account) {
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
const wasVerificationRequired = account.verificationRequired === true;
|
|
1055
|
+
const wasIneligible = account.accountIneligible === true;
|
|
1056
|
+
const hadMetadata = wasVerificationRequired ||
|
|
1057
|
+
wasIneligible ||
|
|
1058
|
+
account.verificationRequiredAt !== undefined ||
|
|
1059
|
+
account.verificationRequiredReason !== undefined ||
|
|
1060
|
+
account.verificationUrl !== undefined ||
|
|
1061
|
+
account.accountIneligibleAt !== undefined ||
|
|
1062
|
+
account.accountIneligibleReason !== undefined ||
|
|
1063
|
+
account.eligibilityStateUpdatedAt !== undefined;
|
|
1064
|
+
account.verificationRequired = false;
|
|
1065
|
+
account.verificationRequiredAt = undefined;
|
|
1066
|
+
account.verificationRequiredReason = undefined;
|
|
1067
|
+
account.verificationUrl = undefined;
|
|
1068
|
+
account.accountIneligible = false;
|
|
1069
|
+
account.accountIneligibleAt = undefined;
|
|
1070
|
+
account.accountIneligibleReason = undefined;
|
|
1071
|
+
const observedAt = this.now();
|
|
1072
|
+
if (wasIneligible || account.eligibilityStateUpdatedAt !== undefined) {
|
|
1073
|
+
account.eligibilityStateUpdatedAt = observedAt;
|
|
1074
|
+
}
|
|
1075
|
+
const reenable = enableAccount &&
|
|
1076
|
+
(wasVerificationRequired || wasIneligible) &&
|
|
1077
|
+
account.enabled === false;
|
|
1078
|
+
if (reenable) {
|
|
1079
|
+
this.applyEnabled(accountIndex, true);
|
|
1080
|
+
}
|
|
1081
|
+
if (reenable || hadMetadata) {
|
|
1082
|
+
this.sink.accessVerdict?.(account, {
|
|
1083
|
+
kind: 'cleared',
|
|
1084
|
+
observedAt,
|
|
1085
|
+
enable: enableAccount,
|
|
1086
|
+
});
|
|
1087
|
+
this.sink.requestSave?.();
|
|
1088
|
+
}
|
|
1089
|
+
return true;
|
|
1090
|
+
}
|
|
1091
|
+
getMinWaitTimeForFamily(family, model, headerStyle, strict) {
|
|
1092
|
+
const available = this.accounts.filter((a) => {
|
|
1093
|
+
clearExpiredRateLimits(a, this.now);
|
|
1094
|
+
return (a.enabled !== false &&
|
|
1095
|
+
(strict && headerStyle
|
|
1096
|
+
? !isRateLimitedForHeaderStyle(a, family, headerStyle, this.now, model)
|
|
1097
|
+
: !isRateLimitedForFamily(a, family, this.now, model)));
|
|
1098
|
+
});
|
|
1099
|
+
if (available.length > 0) {
|
|
1100
|
+
return 0;
|
|
1101
|
+
}
|
|
1102
|
+
const waitTimes = [];
|
|
1103
|
+
for (const a of this.accounts) {
|
|
1104
|
+
if (family === 'claude') {
|
|
1105
|
+
const t = a.rateLimitResetTimes.claude;
|
|
1106
|
+
if (t !== undefined)
|
|
1107
|
+
waitTimes.push(Math.max(0, t - this.now()));
|
|
1108
|
+
}
|
|
1109
|
+
else if (strict && headerStyle) {
|
|
1110
|
+
const key = getQuotaKey(family, headerStyle, model);
|
|
1111
|
+
const t = a.rateLimitResetTimes[key];
|
|
1112
|
+
if (t !== undefined)
|
|
1113
|
+
waitTimes.push(Math.max(0, t - this.now()));
|
|
1114
|
+
}
|
|
1115
|
+
else {
|
|
1116
|
+
// For Gemini, account becomes available when EITHER pool expires for this model/family
|
|
1117
|
+
const antigravityKey = getQuotaKey(family, 'antigravity', model);
|
|
1118
|
+
const cliKey = getQuotaKey(family, 'gemini-cli', model);
|
|
1119
|
+
const t1 = a.rateLimitResetTimes[antigravityKey];
|
|
1120
|
+
const t2 = a.rateLimitResetTimes[cliKey];
|
|
1121
|
+
const accountWait = Math.min(t1 !== undefined ? Math.max(0, t1 - this.now()) : Infinity, t2 !== undefined ? Math.max(0, t2 - this.now()) : Infinity);
|
|
1122
|
+
if (accountWait !== Infinity)
|
|
1123
|
+
waitTimes.push(accountWait);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
return waitTimes.length > 0 ? Math.min(...waitTimes) : 0;
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Regenerate fingerprint for an account, saving the old one to history.
|
|
1130
|
+
* @param accountIndex - Index of the account to regenerate fingerprint for
|
|
1131
|
+
* @returns The new fingerprint, or null if account not found
|
|
1132
|
+
*/
|
|
1133
|
+
regenerateAccountFingerprint(accountIndex) {
|
|
1134
|
+
const account = this.accounts[accountIndex];
|
|
1135
|
+
if (!account)
|
|
1136
|
+
return null;
|
|
1137
|
+
// Save current fingerprint to history if it exists
|
|
1138
|
+
if (account.fingerprint) {
|
|
1139
|
+
const historyEntry = {
|
|
1140
|
+
fingerprint: account.fingerprint,
|
|
1141
|
+
timestamp: this.now(),
|
|
1142
|
+
reason: 'regenerated',
|
|
1143
|
+
};
|
|
1144
|
+
if (!account.fingerprintHistory) {
|
|
1145
|
+
account.fingerprintHistory = [];
|
|
1146
|
+
}
|
|
1147
|
+
// Add to beginning of history (most recent first)
|
|
1148
|
+
account.fingerprintHistory.unshift(historyEntry);
|
|
1149
|
+
// Trim to max history size
|
|
1150
|
+
if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) {
|
|
1151
|
+
account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
// Generate and assign new fingerprint
|
|
1155
|
+
account.fingerprint = generateFingerprint();
|
|
1156
|
+
this.sink.fingerprint?.(account);
|
|
1157
|
+
this.sink.requestSave?.();
|
|
1158
|
+
return account.fingerprint;
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Restore a fingerprint from history for an account.
|
|
1162
|
+
* @param accountIndex - Index of the account
|
|
1163
|
+
* @param historyIndex - Index in the fingerprint history to restore from (0 = most recent)
|
|
1164
|
+
* @returns The restored fingerprint, or null if account/history not found
|
|
1165
|
+
*/
|
|
1166
|
+
restoreAccountFingerprint(accountIndex, historyIndex) {
|
|
1167
|
+
const account = this.accounts[accountIndex];
|
|
1168
|
+
if (!account)
|
|
1169
|
+
return null;
|
|
1170
|
+
const history = account.fingerprintHistory;
|
|
1171
|
+
if (!history || historyIndex < 0 || historyIndex >= history.length) {
|
|
1172
|
+
return null;
|
|
1173
|
+
}
|
|
1174
|
+
// Capture the fingerprint to restore BEFORE modifying history
|
|
1175
|
+
const fingerprintToRestore = history[historyIndex].fingerprint;
|
|
1176
|
+
// Save current fingerprint to history before restoring (if it exists)
|
|
1177
|
+
if (account.fingerprint) {
|
|
1178
|
+
const historyEntry = {
|
|
1179
|
+
fingerprint: account.fingerprint,
|
|
1180
|
+
timestamp: this.now(),
|
|
1181
|
+
reason: 'restored',
|
|
1182
|
+
};
|
|
1183
|
+
account.fingerprintHistory.unshift(historyEntry);
|
|
1184
|
+
// Trim to max history size
|
|
1185
|
+
if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) {
|
|
1186
|
+
account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
// Restore the fingerprint
|
|
1190
|
+
account.fingerprint = { ...fingerprintToRestore, createdAt: this.now() };
|
|
1191
|
+
this.sink.fingerprint?.(account);
|
|
1192
|
+
this.sink.requestSave?.();
|
|
1193
|
+
return account.fingerprint;
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* Get fingerprint history for an account.
|
|
1197
|
+
* @param accountIndex - Index of the account
|
|
1198
|
+
* @returns Array of fingerprint versions, or empty array if not found
|
|
1199
|
+
*/
|
|
1200
|
+
getAccountFingerprintHistory(accountIndex) {
|
|
1201
|
+
const account = this.accounts[accountIndex];
|
|
1202
|
+
if (!account?.fingerprintHistory) {
|
|
1203
|
+
return [];
|
|
1204
|
+
}
|
|
1205
|
+
return [...account.fingerprintHistory];
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Record a successful API request for an account.
|
|
1209
|
+
* Tracks per model family with daily reset.
|
|
1210
|
+
*/
|
|
1211
|
+
recordRequest(accountIndex, family) {
|
|
1212
|
+
const account = this.accounts[accountIndex];
|
|
1213
|
+
if (!account)
|
|
1214
|
+
return;
|
|
1215
|
+
const today = new Date(this.now()).toISOString().slice(0, 10);
|
|
1216
|
+
if (!account.dailyRequestCounts ||
|
|
1217
|
+
account.dailyRequestCounts.date !== today) {
|
|
1218
|
+
account.dailyRequestCounts = { date: today, claude: 0, gemini: 0 };
|
|
1219
|
+
}
|
|
1220
|
+
account.dailyRequestCounts[family]++;
|
|
1221
|
+
account.lastUsed = this.now();
|
|
1222
|
+
// Each request is its own increment, computed under the row lock from
|
|
1223
|
+
// the stored count, so no request is lost to a concurrent writer.
|
|
1224
|
+
this.sink.usage?.(account, family, account.lastUsed);
|
|
1225
|
+
// Also track for session
|
|
1226
|
+
this.recordSessionRequest(accountIndex, family);
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Get request counts for an account for today.
|
|
1230
|
+
*/
|
|
1231
|
+
getDailyRequestCounts(accountIndex) {
|
|
1232
|
+
const account = this.accounts[accountIndex];
|
|
1233
|
+
if (!account?.dailyRequestCounts)
|
|
1234
|
+
return null;
|
|
1235
|
+
const today = new Date(this.now()).toISOString().slice(0, 10);
|
|
1236
|
+
if (account.dailyRequestCounts.date !== today)
|
|
1237
|
+
return null;
|
|
1238
|
+
return { ...account.dailyRequestCounts };
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Get total daily request counts across all accounts for a model family.
|
|
1242
|
+
*/
|
|
1243
|
+
getTotalDailyRequests(family) {
|
|
1244
|
+
const today = new Date(this.now()).toISOString().slice(0, 10);
|
|
1245
|
+
let total = 0;
|
|
1246
|
+
for (const account of this.accounts) {
|
|
1247
|
+
if (account.dailyRequestCounts?.date === today) {
|
|
1248
|
+
total += account.dailyRequestCounts[family];
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
return total;
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Get a summary of daily request distribution across accounts.
|
|
1255
|
+
* Returns accounts sorted by request count (descending).
|
|
1256
|
+
*/
|
|
1257
|
+
getDailyRequestSummary(family) {
|
|
1258
|
+
const today = new Date(this.now()).toISOString().slice(0, 10);
|
|
1259
|
+
const result = [];
|
|
1260
|
+
for (const account of this.accounts) {
|
|
1261
|
+
const count = account.dailyRequestCounts?.date === today
|
|
1262
|
+
? account.dailyRequestCounts[family]
|
|
1263
|
+
: 0;
|
|
1264
|
+
if (count > 0) {
|
|
1265
|
+
result.push({ index: account.index, email: account.email, count });
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return result.sort((a, b) => b.count - a.count);
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Record a request for the current session (in-memory only).
|
|
1272
|
+
*/
|
|
1273
|
+
recordSessionRequest(accountIndex, family) {
|
|
1274
|
+
const key = String(accountIndex);
|
|
1275
|
+
const current = this.sessionRequestCounts.get(key) ?? {
|
|
1276
|
+
claude: 0,
|
|
1277
|
+
gemini: 0,
|
|
1278
|
+
};
|
|
1279
|
+
current[family]++;
|
|
1280
|
+
this.sessionRequestCounts.set(key, current);
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Get a summary of the current session's request usage.
|
|
1284
|
+
*/
|
|
1285
|
+
getSessionSummary() {
|
|
1286
|
+
const durationMs = this.now() - this.sessionStartTime;
|
|
1287
|
+
const durationMinutes = Math.round(durationMs / 60000);
|
|
1288
|
+
const durationHours = durationMs / 3600000;
|
|
1289
|
+
let totalClaude = 0;
|
|
1290
|
+
let totalGemini = 0;
|
|
1291
|
+
const perAccount = [];
|
|
1292
|
+
for (const [key, counts] of this.sessionRequestCounts) {
|
|
1293
|
+
const idx = Number(key);
|
|
1294
|
+
const account = this.accounts[idx];
|
|
1295
|
+
totalClaude += counts.claude;
|
|
1296
|
+
totalGemini += counts.gemini;
|
|
1297
|
+
if (counts.claude > 0 || counts.gemini > 0) {
|
|
1298
|
+
perAccount.push({
|
|
1299
|
+
index: idx,
|
|
1300
|
+
email: account?.email,
|
|
1301
|
+
claude: counts.claude,
|
|
1302
|
+
gemini: counts.gemini,
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
const totalRequests = totalClaude + totalGemini;
|
|
1307
|
+
const requestsPerHour = durationHours > 0 ? Math.round(totalRequests / durationHours) : 0;
|
|
1308
|
+
return {
|
|
1309
|
+
durationMinutes,
|
|
1310
|
+
totalClaude,
|
|
1311
|
+
totalGemini,
|
|
1312
|
+
requestsPerHour,
|
|
1313
|
+
accountsUsed: perAccount.length,
|
|
1314
|
+
perAccount: perAccount.sort((a, b) => b.claude + b.gemini - (a.claude + a.gemini)),
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
isAccountOverSoftQuota(account, family, thresholdPercent, cacheTtlMs, model) {
|
|
1318
|
+
return isOverSoftQuotaThreshold(account, family, this.getEffectiveSoftQuotaThreshold(thresholdPercent), cacheTtlMs, this.now, model);
|
|
1319
|
+
}
|
|
1320
|
+
getOldestQuotaCacheAge() {
|
|
1321
|
+
let oldest = null;
|
|
1322
|
+
for (const acc of this.accounts) {
|
|
1323
|
+
if (acc.enabled === false)
|
|
1324
|
+
continue;
|
|
1325
|
+
if (acc.cachedQuotaUpdatedAt == null)
|
|
1326
|
+
return null;
|
|
1327
|
+
const age = this.now() - acc.cachedQuotaUpdatedAt;
|
|
1328
|
+
if (oldest === null || age > oldest)
|
|
1329
|
+
oldest = age;
|
|
1330
|
+
}
|
|
1331
|
+
return oldest;
|
|
1332
|
+
}
|
|
1333
|
+
areAllAccountsOverSoftQuota(family, thresholdPercent, cacheTtlMs, model) {
|
|
1334
|
+
if (thresholdPercent >= 100)
|
|
1335
|
+
return false;
|
|
1336
|
+
const enabled = this.accounts.filter((a) => a.enabled !== false);
|
|
1337
|
+
if (enabled.length <= 1)
|
|
1338
|
+
return false;
|
|
1339
|
+
return enabled.every((a) => isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, this.now, model));
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Get minimum wait time until any account's soft quota resets.
|
|
1343
|
+
* Returns 0 if any account is available (not over threshold).
|
|
1344
|
+
* Returns the minimum resetTime across all over-threshold accounts.
|
|
1345
|
+
* Returns null if no resetTime data is available.
|
|
1346
|
+
*/
|
|
1347
|
+
getMinWaitTimeForSoftQuota(family, thresholdPercent, cacheTtlMs, model) {
|
|
1348
|
+
if (thresholdPercent >= 100)
|
|
1349
|
+
return 0;
|
|
1350
|
+
const enabled = this.accounts.filter((a) => a.enabled !== false);
|
|
1351
|
+
if (enabled.length === 0)
|
|
1352
|
+
return null;
|
|
1353
|
+
if (enabled.length === 1)
|
|
1354
|
+
return 0;
|
|
1355
|
+
// If any account is available (not over threshold), no wait needed
|
|
1356
|
+
const available = enabled.filter((a) => !isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, this.now, model));
|
|
1357
|
+
if (available.length > 0)
|
|
1358
|
+
return 0;
|
|
1359
|
+
// All accounts are over threshold - find earliest reset time
|
|
1360
|
+
// For gemini family, we MUST have the model to distinguish pro vs flash quotas.
|
|
1361
|
+
// Fail-open (return null = no wait info) if model is missing to avoid blocking on wrong quota.
|
|
1362
|
+
if (!model && family !== 'claude')
|
|
1363
|
+
return null;
|
|
1364
|
+
const quotaGroup = resolveQuotaGroup(family, model);
|
|
1365
|
+
const now = this.now();
|
|
1366
|
+
const waitTimes = [];
|
|
1367
|
+
for (const acc of enabled) {
|
|
1368
|
+
const groupData = acc.cachedQuota?.[quotaGroup];
|
|
1369
|
+
if (groupData?.resetTime) {
|
|
1370
|
+
const resetTimestamp = Date.parse(groupData.resetTime);
|
|
1371
|
+
if (Number.isFinite(resetTimestamp)) {
|
|
1372
|
+
waitTimes.push(Math.max(0, resetTimestamp - now));
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
if (waitTimes.length === 0)
|
|
1377
|
+
return null;
|
|
1378
|
+
const minWait = Math.min(...waitTimes);
|
|
1379
|
+
// Treat 0 as stale cache (resetTime in the past) → fail-open to avoid spin loop
|
|
1380
|
+
return minWait === 0 ? null : minWait;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
//# sourceMappingURL=account-selector.js.map
|