@karpeleslab/teamclaude 1.0.6 → 1.0.8
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/LICENSE +21 -0
- package/README.md +176 -16
- package/package.json +7 -2
- package/src/account-manager.js +382 -24
- package/src/account-uuid-rewrite.js +115 -0
- package/src/alias.js +123 -0
- package/src/config.js +26 -0
- package/src/h2/frames.js +83 -0
- package/src/h2/hpack.js +314 -0
- package/src/h2/relay.js +417 -0
- package/src/identity.js +65 -0
- package/src/index.js +521 -91
- package/src/json-format-stream.js +65 -0
- package/src/mitm.js +387 -0
- package/src/oauth.js +77 -1
- package/src/prober.js +82 -0
- package/src/request-log.js +194 -0
- package/src/server.js +166 -88
- package/src/sx.js +218 -0
- package/src/tui.js +231 -17
- package/src/upstream-fetch.js +85 -0
- package/src/x509.js +166 -0
package/src/account-manager.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
|
|
2
|
+
import { sameIdentity } from './identity.js';
|
|
3
|
+
|
|
4
|
+
// Quota fields that survive a restart: utilization levels and their reset
|
|
5
|
+
// windows, learned passively from upstream responses. Transient/derived state
|
|
6
|
+
// (probing, requalify, rateLimitedUntil) is intentionally excluded.
|
|
7
|
+
const PERSISTED_QUOTA_FIELDS = [
|
|
8
|
+
'unified5h', 'unified7d', 'unified7dSonnet',
|
|
9
|
+
'unified5hReset', 'unified7dReset', 'unified7dSonnetReset', 'unifiedStatus',
|
|
10
|
+
'tokensLimit', 'tokensRemaining', 'requestsLimit', 'requestsRemaining', 'resetsAt',
|
|
11
|
+
];
|
|
2
12
|
|
|
3
13
|
function emptyQuota() {
|
|
4
14
|
return {
|
|
@@ -8,26 +18,38 @@ function emptyQuota() {
|
|
|
8
18
|
requestsLimit: null,
|
|
9
19
|
requestsRemaining: null,
|
|
10
20
|
// Unified rate limits (Claude Max accounts)
|
|
11
|
-
unified5h: null,
|
|
12
|
-
unified7d: null,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
unified5h: null, // utilization 0-1
|
|
22
|
+
unified7d: null, // utilization 0-1
|
|
23
|
+
unified7dSonnet: null, // utilization 0-1 (Sonnet-specific weekly bucket)
|
|
24
|
+
unified5hReset: null, // ms timestamp
|
|
25
|
+
unified7dReset: null, // ms timestamp
|
|
26
|
+
unified7dSonnetReset: null, // ms timestamp
|
|
27
|
+
unifiedStatus: null, // allowed | allowed_warning | rejected
|
|
16
28
|
resetsAt: null,
|
|
17
29
|
};
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
export class AccountManager {
|
|
21
|
-
constructor(accounts, switchThreshold = 0.98) {
|
|
33
|
+
constructor(accounts, switchThreshold = 0.98, { refreshFn = refreshAccessToken } = {}) {
|
|
34
|
+
// Injectable for tests (mirrors Prober's probeFn); defaults to the real
|
|
35
|
+
// OAuth token refresh.
|
|
36
|
+
this._refreshFn = refreshFn;
|
|
22
37
|
this.accounts = accounts.map((acct, index) => ({
|
|
23
38
|
index,
|
|
24
39
|
name: acct.name,
|
|
25
40
|
type: acct.type,
|
|
26
41
|
accountUuid: acct.accountUuid || null,
|
|
42
|
+
orgUuid: acct.orgUuid || null,
|
|
43
|
+
orgName: acct.orgName || null,
|
|
44
|
+
priority: acct.priority || 0,
|
|
45
|
+
disabled: acct.disabled || false,
|
|
27
46
|
credential: acct.accessToken || acct.apiKey,
|
|
28
47
|
refreshToken: acct.refreshToken || null,
|
|
29
48
|
expiresAt: acct.expiresAt || null,
|
|
30
49
|
status: 'active',
|
|
50
|
+
// No quota is known at startup, so start probing: the first response for
|
|
51
|
+
// an account reveals its weekly limit and triggers re-evaluation.
|
|
52
|
+
probing: true,
|
|
31
53
|
quota: emptyQuota(),
|
|
32
54
|
usage: {
|
|
33
55
|
totalInputTokens: 0,
|
|
@@ -39,23 +61,122 @@ export class AccountManager {
|
|
|
39
61
|
}));
|
|
40
62
|
this.currentIndex = 0;
|
|
41
63
|
this.switchThreshold = switchThreshold;
|
|
64
|
+
// When every account reads as over-quota we would otherwise refuse locally
|
|
65
|
+
// forever (a stale cached utilization is never re-validated because no
|
|
66
|
+
// request is ever sent). Instead, allow one real upstream probe at most this
|
|
67
|
+
// often to refresh the cached quota. See _selectProbe.
|
|
68
|
+
this.probeIntervalMs = 60_000;
|
|
69
|
+
this._nextProbeAt = 0;
|
|
42
70
|
}
|
|
43
71
|
|
|
44
72
|
/**
|
|
45
73
|
* Get the best available account, rotating if the current one is near quota.
|
|
46
74
|
* Returns null if all accounts are exhausted.
|
|
47
75
|
*/
|
|
48
|
-
getActiveAccount() {
|
|
76
|
+
getActiveAccount(exclude = null) {
|
|
77
|
+
// Clear expired quotas across all accounts and switch proactively if a
|
|
78
|
+
// session reset made a sooner-expiring account the better choice. This runs
|
|
79
|
+
// on every request so the behaviour holds without the TUI render loop.
|
|
80
|
+
this.refreshExpiredQuotas();
|
|
49
81
|
const current = this.accounts[this.currentIndex];
|
|
50
|
-
|
|
51
|
-
|
|
82
|
+
// `exclude` is a per-request set of indices already tried this request (e.g.
|
|
83
|
+
// an account that just threw a transport error). It is never a persistent
|
|
84
|
+
// status change — the account stays healthy for the next request.
|
|
85
|
+
// We just learned a probed account's weekly quota — re-evaluate which
|
|
86
|
+
// account is best now that its limit is known.
|
|
87
|
+
if (current && current.requalify) {
|
|
88
|
+
current.requalify = false;
|
|
89
|
+
const next = this._selectNext(exclude);
|
|
90
|
+
if (next) return next;
|
|
91
|
+
}
|
|
92
|
+
if (this._isAvailable(current) && !exclude?.has(current.index)) {
|
|
93
|
+
// A strictly higher-priority (lower value) available account preempts a
|
|
94
|
+
// healthy current one. Within the same priority tier we stay put, so the
|
|
95
|
+
// common case (all accounts at the default priority 0) is unchanged and
|
|
96
|
+
// never thrashes — preemption only triggers when priorities differ.
|
|
97
|
+
const betterExists = this.accounts.some(a =>
|
|
98
|
+
this._isAvailable(a) && !exclude?.has(a.index) && (a.priority || 0) < (current.priority || 0));
|
|
99
|
+
return betterExists ? this._selectNext(exclude) : current;
|
|
100
|
+
}
|
|
101
|
+
const next = this._selectNext(exclude);
|
|
102
|
+
if (next) return next;
|
|
103
|
+
// No account is under the switch threshold. Before refusing locally, allow a
|
|
104
|
+
// throttled probe so a stale/poisoned cached quota can't pin us in a
|
|
105
|
+
// permanent "all exhausted" state — the probe's real response refreshes the
|
|
106
|
+
// quota (or upstream's own 429 converts soft exhaustion into a hard
|
|
107
|
+
// rate-limit hold). null here means the caller emits the synthetic 429.
|
|
108
|
+
return this._selectProbe(exclude);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_isProbeable(account) {
|
|
112
|
+
if (!account) return false;
|
|
113
|
+
// Never probe an account the operator has taken out of rotation, one whose
|
|
114
|
+
// token is broken, or one upstream has explicitly rate-limited — those are
|
|
115
|
+
// hard states, not stale soft-quota guesses.
|
|
116
|
+
if (account.disabled) return false;
|
|
117
|
+
if (account.status === 'error' || account.status === 'exhausted') return false;
|
|
118
|
+
if (account.status === 'throttled' && account.rateLimitedUntil
|
|
119
|
+
&& Date.now() < account.rateLimitedUntil) return false;
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Highest utilization across all known quota dimensions (0-1), used to pick
|
|
124
|
+
* the least-exhausted probe target. Mirrors the ratios in _isNearQuota. */
|
|
125
|
+
_maxUtilization(account) {
|
|
126
|
+
const q = account.quota;
|
|
127
|
+
let max = 0;
|
|
128
|
+
if (q.unified5h != null) max = Math.max(max, q.unified5h);
|
|
129
|
+
if (q.unified7d != null) max = Math.max(max, q.unified7d);
|
|
130
|
+
if (q.tokensLimit != null && q.tokensRemaining != null) {
|
|
131
|
+
max = Math.max(max, 1 - q.tokensRemaining / q.tokensLimit);
|
|
52
132
|
}
|
|
53
|
-
|
|
133
|
+
if (q.requestsLimit != null && q.requestsRemaining != null) {
|
|
134
|
+
max = Math.max(max, 1 - q.requestsRemaining / q.requestsLimit);
|
|
135
|
+
}
|
|
136
|
+
return max;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Pick an account to send a single revalidation probe upstream when every
|
|
141
|
+
* account reads as over the switch threshold. Throttled to one probe per
|
|
142
|
+
* probeIntervalMs so a genuinely-exhausted fleet isn't hammered — between
|
|
143
|
+
* probes this returns null and the caller falls back to the synthetic 429.
|
|
144
|
+
* The chosen account is the least-utilized probeable one (most likely to have
|
|
145
|
+
* stale headroom), so the refreshed quota corrects the cache fastest.
|
|
146
|
+
*/
|
|
147
|
+
_selectProbe(exclude = null) {
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
if (now < this._nextProbeAt) return null;
|
|
150
|
+
|
|
151
|
+
let best = null;
|
|
152
|
+
let bestPriority = Infinity;
|
|
153
|
+
let bestUsage = Infinity;
|
|
154
|
+
for (const account of this.accounts) {
|
|
155
|
+
if (exclude?.has(account.index)) continue;
|
|
156
|
+
if (!this._isProbeable(account)) continue;
|
|
157
|
+
const priority = account.priority || 0;
|
|
158
|
+
const usage = this._maxUtilization(account);
|
|
159
|
+
if (priority < bestPriority ||
|
|
160
|
+
(priority === bestPriority && usage < bestUsage)) {
|
|
161
|
+
bestPriority = priority;
|
|
162
|
+
bestUsage = usage;
|
|
163
|
+
best = account;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!best) return null;
|
|
167
|
+
|
|
168
|
+
this._nextProbeAt = now + this.probeIntervalMs;
|
|
169
|
+
this.currentIndex = best.index;
|
|
170
|
+
console.log(`[TeamClaude] All accounts over threshold — probing "${best.name}" to refresh quota`);
|
|
171
|
+
return best;
|
|
54
172
|
}
|
|
55
173
|
|
|
56
174
|
_isAvailable(account) {
|
|
57
175
|
if (!account) return false;
|
|
58
176
|
|
|
177
|
+
// Manually disabled accounts are skipped entirely until re-enabled.
|
|
178
|
+
if (account.disabled) return false;
|
|
179
|
+
|
|
59
180
|
// Check rate limit expiry
|
|
60
181
|
if (account.status === 'throttled' && account.rateLimitedUntil) {
|
|
61
182
|
if (Date.now() < account.rateLimitedUntil) return false;
|
|
@@ -70,21 +191,38 @@ export class AccountManager {
|
|
|
70
191
|
return true;
|
|
71
192
|
}
|
|
72
193
|
|
|
73
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Clear any quota counters whose reset time has passed. Cheap and safe to
|
|
196
|
+
* call frequently (e.g. from the TUI render loop) — once a counter is cleared
|
|
197
|
+
* it stays null until the next upstream response repopulates it, so the
|
|
198
|
+
* "reset" log fires at most once per window.
|
|
199
|
+
* @returns {{changed: boolean, session: boolean}} what was cleared.
|
|
200
|
+
*/
|
|
201
|
+
_clearExpiredQuotas(account) {
|
|
74
202
|
const q = account.quota;
|
|
75
203
|
const now = Date.now();
|
|
204
|
+
let changed = false;
|
|
205
|
+
let session = false;
|
|
76
206
|
|
|
77
207
|
// Clear expired unified quotas
|
|
78
208
|
if (q.unified5h != null && q.unified5hReset && now >= q.unified5hReset) {
|
|
79
209
|
console.log(`[TeamClaude] Account "${account.name}" session quota reset`);
|
|
80
210
|
q.unified5h = null;
|
|
81
211
|
q.unified5hReset = null;
|
|
212
|
+
changed = true;
|
|
213
|
+
session = true;
|
|
82
214
|
}
|
|
83
215
|
if (q.unified7d != null && q.unified7dReset && now >= q.unified7dReset) {
|
|
84
216
|
console.log(`[TeamClaude] Account "${account.name}" weekly quota reset`);
|
|
85
217
|
q.unified7d = null;
|
|
86
218
|
q.unified7dReset = null;
|
|
87
219
|
q.unifiedStatus = null;
|
|
220
|
+
changed = true;
|
|
221
|
+
}
|
|
222
|
+
if (q.unified7dSonnet != null && q.unified7dSonnetReset && now >= q.unified7dSonnetReset) {
|
|
223
|
+
q.unified7dSonnet = null;
|
|
224
|
+
q.unified7dSonnetReset = null;
|
|
225
|
+
changed = true;
|
|
88
226
|
}
|
|
89
227
|
|
|
90
228
|
// Clear expired standard quotas
|
|
@@ -94,8 +232,70 @@ export class AccountManager {
|
|
|
94
232
|
q.requestsRemaining = null;
|
|
95
233
|
q.requestsLimit = null;
|
|
96
234
|
q.resetsAt = null;
|
|
235
|
+
changed = true;
|
|
97
236
|
}
|
|
98
237
|
|
|
238
|
+
return { changed, session };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Clear expired quotas across all accounts. Called from the display loop and
|
|
243
|
+
* the request path so a window expiry (e.g. the 5-hour session quota) resets
|
|
244
|
+
* the view instantly rather than waiting for the next request.
|
|
245
|
+
*
|
|
246
|
+
* When an account's session quota resets, it may have become the better
|
|
247
|
+
* choice — switch to it if its weekly limit expires sooner than the current
|
|
248
|
+
* account's (and it still has weekly quota), so we spend the quota closest to
|
|
249
|
+
* refreshing first.
|
|
250
|
+
*/
|
|
251
|
+
refreshExpiredQuotas() {
|
|
252
|
+
let changed = false;
|
|
253
|
+
const sessionReset = [];
|
|
254
|
+
for (const account of this.accounts) {
|
|
255
|
+
const r = this._clearExpiredQuotas(account);
|
|
256
|
+
if (r.changed) changed = true;
|
|
257
|
+
if (r.session) sessionReset.push(account);
|
|
258
|
+
}
|
|
259
|
+
if (sessionReset.length) this._switchOnSessionReset(sessionReset);
|
|
260
|
+
return changed;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Given accounts whose session quota just reset, switch to the one whose
|
|
265
|
+
* weekly limit expires soonest — but only if that is sooner than the current
|
|
266
|
+
* account's weekly limit and the account still has weekly quota to spend.
|
|
267
|
+
*/
|
|
268
|
+
_switchOnSessionReset(candidates) {
|
|
269
|
+
const current = this.accounts[this.currentIndex];
|
|
270
|
+
// Need a known weekly reset on the current account to compare against;
|
|
271
|
+
// if it is unknown we are still probing it, so leave it alone.
|
|
272
|
+
if (!current || current.quota.unified7dReset == null) return;
|
|
273
|
+
|
|
274
|
+
let best = null;
|
|
275
|
+
let bestWeekly = current.quota.unified7dReset;
|
|
276
|
+
for (const acc of candidates) {
|
|
277
|
+
if (acc.index === this.currentIndex) continue;
|
|
278
|
+
if (!this._isAvailable(acc)) continue; // enough session & weekly quota left
|
|
279
|
+
// Don't demote to a lower-priority (higher value) account on a reset.
|
|
280
|
+
if ((acc.priority || 0) > (current.priority || 0)) continue;
|
|
281
|
+
const weekly = acc.quota.unified7dReset;
|
|
282
|
+
if (weekly == null) continue; // need a known weekly to compare
|
|
283
|
+
if (weekly < bestWeekly) {
|
|
284
|
+
bestWeekly = weekly;
|
|
285
|
+
best = acc;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (best) {
|
|
290
|
+
this.currentIndex = best.index;
|
|
291
|
+
console.log(`[TeamClaude] Account "${best.name}" session quota reset and weekly expires sooner — switching to it`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
_isNearQuota(account) {
|
|
296
|
+
const q = account.quota;
|
|
297
|
+
this._clearExpiredQuotas(account);
|
|
298
|
+
|
|
99
299
|
// Unified quotas (Claude Max) — utilization is already 0-1
|
|
100
300
|
if (q.unified5h != null && q.unified5h >= this.switchThreshold) return true;
|
|
101
301
|
if (q.unified7d != null && q.unified7d >= this.switchThreshold) return true;
|
|
@@ -114,18 +314,75 @@ export class AccountManager {
|
|
|
114
314
|
return false;
|
|
115
315
|
}
|
|
116
316
|
|
|
117
|
-
|
|
118
|
-
|
|
317
|
+
/**
|
|
318
|
+
* Pick the best available account by selection order, WITHOUT mutating state:
|
|
319
|
+
* 1. lowest `priority` value (operator-controlled; default 0, lower = preferred)
|
|
320
|
+
* 2. then the account with no known weekly limit — using it lets us
|
|
321
|
+
* discover its quota
|
|
322
|
+
* 3. then the account whose weekly limit expires soonest: that quota is
|
|
323
|
+
* closest to refreshing, so spending it first preserves accounts whose
|
|
324
|
+
* weekly window resets further out.
|
|
325
|
+
* With all priorities at the default 0, this reduces to the weekly-reset
|
|
326
|
+
* heuristic. Returns the account or null if none are available.
|
|
327
|
+
*/
|
|
328
|
+
_pickBestAvailable(exclude = null) {
|
|
329
|
+
let best = null;
|
|
330
|
+
let bestPriority = Infinity;
|
|
331
|
+
let bestReset = Infinity;
|
|
332
|
+
|
|
333
|
+
for (let i = 0; i < this.accounts.length; i++) {
|
|
334
|
+
const account = this.accounts[i];
|
|
335
|
+
if (exclude?.has(account.index)) continue;
|
|
336
|
+
// _isAvailable filters out accounts at/above the switch threshold, so the
|
|
337
|
+
// soonest-expiring pick only ever lands on an account whose 5-hour quota
|
|
338
|
+
// is still below 98%.
|
|
339
|
+
if (!this._isAvailable(account)) continue;
|
|
340
|
+
|
|
341
|
+
const priority = account.priority || 0;
|
|
342
|
+
// Unknown weekly reset sorts first so we fill it in.
|
|
343
|
+
const weeklyReset = account.quota.unified7dReset || -Infinity;
|
|
344
|
+
if (priority < bestPriority ||
|
|
345
|
+
(priority === bestPriority && weeklyReset < bestReset)) {
|
|
346
|
+
bestPriority = priority;
|
|
347
|
+
bestReset = weeklyReset;
|
|
348
|
+
best = account;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return best;
|
|
352
|
+
}
|
|
119
353
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
354
|
+
/**
|
|
355
|
+
* Select the active account up front (e.g. on daemon launch, once persisted
|
|
356
|
+
* quota has been restored) so we start on the highest-priority / soonest-
|
|
357
|
+
* resetting account instead of blindly on index 0. Mirrors rotation order.
|
|
358
|
+
* Returns the chosen account, or the existing current one if none are
|
|
359
|
+
* available (the server still starts; requests 429 until a window resets).
|
|
360
|
+
*/
|
|
361
|
+
selectActiveAccount() {
|
|
362
|
+
this.refreshExpiredQuotas(); // drop any restored windows that already expired
|
|
363
|
+
const best = this._pickBestAvailable();
|
|
364
|
+
if (!best) return this.accounts[this.currentIndex] || null;
|
|
365
|
+
this.currentIndex = best.index;
|
|
366
|
+
best.probing = best.quota.unified7dReset == null;
|
|
367
|
+
const wk = best.quota.unified7d != null
|
|
368
|
+
? `${(best.quota.unified7d * 100).toFixed(1)}% weekly used`
|
|
369
|
+
: 'weekly quota unknown';
|
|
370
|
+
console.log(`[TeamClaude] Starting on account "${best.name}" (priority ${best.priority || 0}, ${wk})`);
|
|
371
|
+
return best;
|
|
372
|
+
}
|
|
123
373
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
374
|
+
_selectNext(exclude = null) {
|
|
375
|
+
const best = this._pickBestAvailable(exclude);
|
|
376
|
+
if (best) {
|
|
377
|
+
const switched = best.index !== this.currentIndex;
|
|
378
|
+
this.currentIndex = best.index;
|
|
379
|
+
// If we switched to an account whose weekly quota is still unknown, flag
|
|
380
|
+
// it so we re-evaluate once that quota is learned (see updateQuota).
|
|
381
|
+
best.probing = best.quota.unified7dReset == null;
|
|
382
|
+
if (switched) {
|
|
383
|
+
console.log(`[TeamClaude] Switched to account "${best.name}"`);
|
|
128
384
|
}
|
|
385
|
+
return best;
|
|
129
386
|
}
|
|
130
387
|
|
|
131
388
|
// All accounts unavailable — find the one that resets soonest
|
|
@@ -133,6 +390,7 @@ export class AccountManager {
|
|
|
133
390
|
let soonestTime = Infinity;
|
|
134
391
|
|
|
135
392
|
for (const account of this.accounts) {
|
|
393
|
+
if (exclude?.has(account.index)) continue;
|
|
136
394
|
const resetTime = account.rateLimitedUntil
|
|
137
395
|
|| account.quota.unified5hReset
|
|
138
396
|
|| account.quota.unified7dReset
|
|
@@ -173,6 +431,14 @@ export class AccountManager {
|
|
|
173
431
|
if (r5h) account.quota.unified5hReset = parseInt(r5h, 10) * 1000;
|
|
174
432
|
if (r7d) account.quota.unified7dReset = parseInt(r7d, 10) * 1000;
|
|
175
433
|
|
|
434
|
+
// We switched to this account to discover its weekly quota; now that we
|
|
435
|
+
// know it, flag for re-evaluation so selection can pick the best account.
|
|
436
|
+
if (account.probing && account.quota.unified7dReset != null) {
|
|
437
|
+
account.probing = false;
|
|
438
|
+
account.requalify = true;
|
|
439
|
+
console.log(`[TeamClaude] Learned weekly quota for "${account.name}", re-evaluating selection`);
|
|
440
|
+
}
|
|
441
|
+
|
|
176
442
|
const uStatus = headers['anthropic-ratelimit-unified-status'];
|
|
177
443
|
if (uStatus) account.quota.unifiedStatus = uStatus;
|
|
178
444
|
|
|
@@ -216,6 +482,53 @@ export class AccountManager {
|
|
|
216
482
|
if (outputTokens) account.usage.totalOutputTokens += outputTokens;
|
|
217
483
|
}
|
|
218
484
|
|
|
485
|
+
/**
|
|
486
|
+
* Enable or disable an account. A disabled account is skipped by rotation
|
|
487
|
+
* until re-enabled. Re-enabling also clears a stuck 'error' state (and any
|
|
488
|
+
* lingering rate-limit hold) so the account is retried immediately.
|
|
489
|
+
*/
|
|
490
|
+
setDisabled(accountIndex, disabled) {
|
|
491
|
+
const account = this.accounts[accountIndex];
|
|
492
|
+
if (!account) return;
|
|
493
|
+
account.disabled = disabled;
|
|
494
|
+
if (!disabled && account.status === 'error') {
|
|
495
|
+
account.status = 'active';
|
|
496
|
+
account.rateLimitedUntil = null;
|
|
497
|
+
console.log(`[TeamClaude] Account "${account.name}" re-enabled — clearing error state`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Apply quota learned from the OAuth usage endpoint (the background probe).
|
|
503
|
+
* Updates utilization/reset for the 5h, 7d, and Sonnet-7d buckets WITHOUT
|
|
504
|
+
* touching usage counters — a probe is not real client traffic.
|
|
505
|
+
*/
|
|
506
|
+
applyUsageData(accountIndex, usage) {
|
|
507
|
+
const account = this.accounts[accountIndex];
|
|
508
|
+
if (!account || !usage) return;
|
|
509
|
+
const q = account.quota;
|
|
510
|
+
|
|
511
|
+
if (usage.fiveHour) {
|
|
512
|
+
if (usage.fiveHour.utilization != null) q.unified5h = usage.fiveHour.utilization;
|
|
513
|
+
if (usage.fiveHour.resetAt != null) q.unified5hReset = usage.fiveHour.resetAt;
|
|
514
|
+
}
|
|
515
|
+
if (usage.sevenDay) {
|
|
516
|
+
if (usage.sevenDay.utilization != null) q.unified7d = usage.sevenDay.utilization;
|
|
517
|
+
if (usage.sevenDay.resetAt != null) q.unified7dReset = usage.sevenDay.resetAt;
|
|
518
|
+
}
|
|
519
|
+
if (usage.sevenDaySonnet) {
|
|
520
|
+
if (usage.sevenDaySonnet.utilization != null) q.unified7dSonnet = usage.sevenDaySonnet.utilization;
|
|
521
|
+
if (usage.sevenDaySonnet.resetAt != null) q.unified7dSonnetReset = usage.sevenDaySonnet.resetAt;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// If we just learned this account's weekly window while probing, re-evaluate
|
|
525
|
+
// selection (same path as learning it from a live response).
|
|
526
|
+
if (account.probing && q.unified7dReset != null) {
|
|
527
|
+
account.probing = false;
|
|
528
|
+
account.requalify = true;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
219
532
|
/**
|
|
220
533
|
* Mark an account as rate-limited for a given duration.
|
|
221
534
|
*/
|
|
@@ -244,7 +557,7 @@ export class AccountManager {
|
|
|
244
557
|
account._refreshPromise = (async () => {
|
|
245
558
|
console.log(`[TeamClaude] Refreshing token for account "${account.name}"...`);
|
|
246
559
|
try {
|
|
247
|
-
const newTokens = await
|
|
560
|
+
const newTokens = await this._refreshFn(account.refreshToken);
|
|
248
561
|
account.credential = newTokens.accessToken;
|
|
249
562
|
account.refreshToken = newTokens.refreshToken;
|
|
250
563
|
account.expiresAt = newTokens.expiresAt;
|
|
@@ -252,10 +565,16 @@ export class AccountManager {
|
|
|
252
565
|
this._onTokenRefresh?.(accountIndex, newTokens);
|
|
253
566
|
} catch (err) {
|
|
254
567
|
console.error(`[TeamClaude] Token refresh failed for "${account.name}": ${err.message}`);
|
|
255
|
-
//
|
|
256
|
-
// a
|
|
257
|
-
|
|
568
|
+
// Reserve 'error' (which drops the account from rotation until re-login)
|
|
569
|
+
// for a GENUINE auth rejection: the refresh token itself is no longer
|
|
570
|
+
// valid — revoked, or invalidated by an account/plan migration. A
|
|
571
|
+
// transient failure (network, 5xx, timeout) must NOT sideline a healthy
|
|
572
|
+
// account: keep its current token and retry on the next request. This is
|
|
573
|
+
// what kept accounts wrongly "errored" after a momentary refresh blip.
|
|
574
|
+
const isAuthRejection = err.status === 400 || err.status === 401 || err.status === 403;
|
|
575
|
+
if (isAuthRejection) {
|
|
258
576
|
account.status = 'error';
|
|
577
|
+
console.error(`[TeamClaude] Account "${account.name}" needs re-login (refresh token rejected) — run: teamclaude login`);
|
|
259
578
|
}
|
|
260
579
|
} finally {
|
|
261
580
|
account._refreshPromise = null;
|
|
@@ -301,10 +620,16 @@ export class AccountManager {
|
|
|
301
620
|
name: acctData.name,
|
|
302
621
|
type: acctData.type,
|
|
303
622
|
accountUuid: acctData.accountUuid || null,
|
|
623
|
+
orgUuid: acctData.orgUuid || null,
|
|
624
|
+
orgName: acctData.orgName || null,
|
|
625
|
+
priority: acctData.priority || 0,
|
|
626
|
+
disabled: acctData.disabled || false,
|
|
304
627
|
credential: acctData.accessToken || acctData.apiKey,
|
|
305
628
|
refreshToken: acctData.refreshToken || null,
|
|
306
629
|
expiresAt: acctData.expiresAt || null,
|
|
307
630
|
status: 'active',
|
|
631
|
+
// Unknown quota until the first response — probe it like startup accounts.
|
|
632
|
+
probing: true,
|
|
308
633
|
quota: emptyQuota(),
|
|
309
634
|
usage: { totalInputTokens: 0, totalOutputTokens: 0, totalRequests: 0, lastUsed: null },
|
|
310
635
|
rateLimitedUntil: null,
|
|
@@ -326,6 +651,36 @@ export class AccountManager {
|
|
|
326
651
|
}
|
|
327
652
|
}
|
|
328
653
|
|
|
654
|
+
/**
|
|
655
|
+
* Serialize persistable quota state for all accounts (no credentials), keyed
|
|
656
|
+
* by account identity so it can be matched back after a restart.
|
|
657
|
+
*/
|
|
658
|
+
exportQuotaState() {
|
|
659
|
+
return this.accounts.map(a => {
|
|
660
|
+
const quota = {};
|
|
661
|
+
for (const f of PERSISTED_QUOTA_FIELDS) quota[f] = a.quota[f];
|
|
662
|
+
return { accountUuid: a.accountUuid, orgUuid: a.orgUuid, orgName: a.orgName, name: a.name, quota };
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Restore quota learned in a previous run. Matches saved entries to accounts
|
|
668
|
+
* by identity. Stale windows are not special-cased here — _clearExpiredQuotas
|
|
669
|
+
* wipes any restored window whose reset time has already passed on first use.
|
|
670
|
+
*/
|
|
671
|
+
restoreQuotaState(saved) {
|
|
672
|
+
if (!Array.isArray(saved)) return;
|
|
673
|
+
for (const account of this.accounts) {
|
|
674
|
+
const match = saved.find(s => sameIdentity(s, account));
|
|
675
|
+
if (!match || !match.quota) continue;
|
|
676
|
+
for (const f of PERSISTED_QUOTA_FIELDS) {
|
|
677
|
+
if (match.quota[f] != null) account.quota[f] = match.quota[f];
|
|
678
|
+
}
|
|
679
|
+
// We already know this account's weekly window, so it isn't "probing".
|
|
680
|
+
if (account.quota.unified7dReset != null) account.probing = false;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
329
684
|
/**
|
|
330
685
|
* Return a status summary of all accounts (safe to expose, no credentials).
|
|
331
686
|
*/
|
|
@@ -336,6 +691,9 @@ export class AccountManager {
|
|
|
336
691
|
accounts: this.accounts.map(a => ({
|
|
337
692
|
name: a.name,
|
|
338
693
|
type: a.type,
|
|
694
|
+
orgName: a.orgName || null,
|
|
695
|
+
priority: a.priority || 0,
|
|
696
|
+
disabled: a.disabled || false,
|
|
339
697
|
status: a.status,
|
|
340
698
|
quota: { ...a.quota },
|
|
341
699
|
usage: { ...a.usage },
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Rewrite the request body's account_uuid to match the account whose token we
|
|
2
|
+
// inject. Claude Code puts the logged-in account's UUID inside `metadata.user_id`
|
|
3
|
+
// (a stringified JSON) of /v1/messages; under rotation that would disagree with
|
|
4
|
+
// the injected token.
|
|
5
|
+
//
|
|
6
|
+
// This is a STREAMING, byte-exact JSON state machine — no regex, no whole-body
|
|
7
|
+
// buffering — so it handles arbitrarily large bodies fed in chunks. It tracks
|
|
8
|
+
// JSON structure (container stack, current key, in-string/escape) to find the
|
|
9
|
+
// `metadata.user_id` string value, and only inside that value does it look for
|
|
10
|
+
// the `account_uuid` field and overwrite its 36-char value with the new UUID
|
|
11
|
+
// (same length → no content-length/flow-control changes). A stray `account_uuid`
|
|
12
|
+
// elsewhere in the body (user content, tool results) is never touched.
|
|
13
|
+
|
|
14
|
+
// Byte sequence of `account_uuid":"` as it appears INSIDE the (escaped) user_id
|
|
15
|
+
// string: account_uuid \ " : \ "
|
|
16
|
+
const PREFIX = Buffer.from('account_uuid\\":\\"', 'latin1');
|
|
17
|
+
|
|
18
|
+
export class AccountUuidPatcher {
|
|
19
|
+
constructor(newUuid) {
|
|
20
|
+
this.newUuid = (typeof newUuid === 'string' && newUuid.length === 36) ? Buffer.from(newUuid, 'latin1') : null;
|
|
21
|
+
this.frames = []; // container stack: { container:'obj'|'arr', name, key, awaitingKey }
|
|
22
|
+
this.inStr = false;
|
|
23
|
+
this.esc = false;
|
|
24
|
+
this.readingKey = false;
|
|
25
|
+
this.keyBuf = [];
|
|
26
|
+
this.target = false; // inside the metadata.user_id string value
|
|
27
|
+
this.matchPos = 0; // PREFIX match progress (within target)
|
|
28
|
+
this.uuidRemaining = 0; // value bytes left to overwrite
|
|
29
|
+
this.done = false; // patched the one account_uuid already
|
|
30
|
+
this.changed = false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Feed a chunk; returns a same-length chunk (patched in place). */
|
|
34
|
+
push(chunk) {
|
|
35
|
+
if (!this.newUuid || this.done) return chunk;
|
|
36
|
+
const out = Buffer.from(chunk);
|
|
37
|
+
for (let i = 0; i < out.length; i++) {
|
|
38
|
+
out[i] = this.#byte(out[i]);
|
|
39
|
+
if (this.done) break; // rest passes through unchanged
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#top() { return this.frames[this.frames.length - 1]; }
|
|
45
|
+
|
|
46
|
+
#byte(b) {
|
|
47
|
+
if (this.target) return this.#targetByte(b);
|
|
48
|
+
|
|
49
|
+
if (this.inStr) {
|
|
50
|
+
if (this.esc) { this.esc = false; if (this.readingKey) this.keyBuf.push(b); return b; }
|
|
51
|
+
if (b === 0x5c) { this.esc = true; return b; } // backslash
|
|
52
|
+
if (b === 0x22) { // end of string
|
|
53
|
+
this.inStr = false;
|
|
54
|
+
if (this.readingKey) { this.#top().key = Buffer.from(this.keyBuf).toString('latin1'); this.keyBuf = []; this.readingKey = false; }
|
|
55
|
+
return b;
|
|
56
|
+
}
|
|
57
|
+
if (this.readingKey) this.keyBuf.push(b);
|
|
58
|
+
return b;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const top = this.#top();
|
|
62
|
+
switch (b) {
|
|
63
|
+
case 0x7b: this.frames.push({ container: 'obj', name: top ? top.key : null, key: null, awaitingKey: true }); break; // {
|
|
64
|
+
case 0x5b: this.frames.push({ container: 'arr', name: top ? top.key : null, key: null, awaitingKey: false }); break; // [
|
|
65
|
+
case 0x7d: case 0x5d: this.frames.pop(); break; // } ]
|
|
66
|
+
case 0x3a: if (top) top.awaitingKey = false; break; // : (key → value)
|
|
67
|
+
case 0x2c: if (top && top.container === 'obj') top.awaitingKey = true; break; // ,
|
|
68
|
+
case 0x22: // string start
|
|
69
|
+
if (top && top.container === 'obj' && top.awaitingKey) {
|
|
70
|
+
this.readingKey = true; this.keyBuf = []; this.inStr = true; this.esc = false;
|
|
71
|
+
} else {
|
|
72
|
+
this.inStr = true; this.esc = false; this.readingKey = false;
|
|
73
|
+
if (top && top.container === 'obj' && top.name === 'metadata' && top.key === 'user_id' && this.frames.length === 2) {
|
|
74
|
+
this.target = true; this.matchPos = 0; this.uuidRemaining = 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
default: break; // scalars / whitespace
|
|
79
|
+
}
|
|
80
|
+
return b;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Inside the metadata.user_id string value: stream-match the account_uuid key
|
|
84
|
+
// and overwrite its 36-byte value. Detect the (unescaped) closing quote to exit.
|
|
85
|
+
#targetByte(b) {
|
|
86
|
+
if (this.uuidRemaining > 0) {
|
|
87
|
+
const outByte = this.newUuid[this.newUuid.length - this.uuidRemaining];
|
|
88
|
+
this.uuidRemaining--;
|
|
89
|
+
if (outByte !== b) this.changed = true;
|
|
90
|
+
if (this.uuidRemaining === 0) this.done = true; // only one account_uuid per body
|
|
91
|
+
return outByte;
|
|
92
|
+
}
|
|
93
|
+
if (this.esc) { this.esc = false; this.#match(b); return b; }
|
|
94
|
+
if (b === 0x5c) { this.esc = true; this.#match(b); return b; }
|
|
95
|
+
if (b === 0x22) { this.target = false; this.matchPos = 0; return b; } // end of user_id value
|
|
96
|
+
this.#match(b);
|
|
97
|
+
return b;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#match(b) {
|
|
101
|
+
if (b === PREFIX[this.matchPos]) {
|
|
102
|
+
this.matchPos++;
|
|
103
|
+
if (this.matchPos === PREFIX.length) { this.uuidRemaining = 36; this.matchPos = 0; }
|
|
104
|
+
} else {
|
|
105
|
+
this.matchPos = (b === PREFIX[0]) ? 1 : 0; // PREFIX has no internal repeat of its first byte
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One-shot convenience (whole-buffer); returns the same instance if unchanged. */
|
|
111
|
+
export function patchAccountUuid(buf, newUuid) {
|
|
112
|
+
const p = new AccountUuidPatcher(newUuid);
|
|
113
|
+
const out = p.push(buf);
|
|
114
|
+
return p.changed ? out : buf;
|
|
115
|
+
}
|