@rikcodes/teamclaude 1.1.14-rik.2 → 1.1.15-rik.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/account-manager.js +214 -15
- package/src/account-pairing.js +81 -3
- package/src/dashboard.js +168 -0
- package/src/identity.js +14 -0
- package/src/index.js +98 -14
- package/src/mitm.js +28 -1
- package/src/oauth.js +17 -10
- package/src/prober.js +8 -1
- package/src/provider.js +18 -0
- package/src/quota-summary.js +123 -0
- package/src/server.js +143 -5
- package/src/status-renderer.js +10 -2
- package/src/sync-accounts.js +31 -10
- package/src/tui.js +55 -12
- package/src/warmer.js +196 -14
- package/src/warmup-schedule.js +236 -0
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { refreshAccessToken, isTokenExpiringSoon, isTokenExpired, formatMoney } from './oauth.js';
|
|
2
|
-
import { providerOf, DEFAULT_PROVIDER } from './provider.js';
|
|
2
|
+
import { providerOf, DEFAULT_PROVIDER, isSubscriptionAccount } from './provider.js';
|
|
3
3
|
import { refreshCodexToken } from './codex-auth.js';
|
|
4
4
|
import { parseCodexQuota, parseCodexPlanType } from './codex-quota.js';
|
|
5
5
|
import { sameIdentity } from './identity.js';
|
|
6
6
|
import { weeklyBucketForModel, modelGlobMatches, modelFamily, gatingUtilization, resolveMaxUsage } from './model.js';
|
|
7
7
|
import { SessionTracker } from './session-tracker.js';
|
|
8
|
+
import { buildQuotaSummary } from './quota-summary.js';
|
|
8
9
|
import { QuotaProjection, PROJECTED_BUCKETS } from './quota-projection.js';
|
|
9
10
|
|
|
10
11
|
// Re-exported for callers that import these model helpers from here.
|
|
@@ -129,6 +130,11 @@ function makeAccount(acct, index) {
|
|
|
129
130
|
accountUuid: acct.accountUuid || null,
|
|
130
131
|
orgUuid: acct.orgUuid || null,
|
|
131
132
|
orgName: acct.orgName || null,
|
|
133
|
+
organizationType: acct.organizationType || null,
|
|
134
|
+
rateLimitTier: acct.rateLimitTier || null,
|
|
135
|
+
seatTier: acct.seatTier || null,
|
|
136
|
+
hasClaudeMax: acct.hasClaudeMax ?? null,
|
|
137
|
+
hasClaudePro: acct.hasClaudePro ?? null,
|
|
132
138
|
priority: acct.priority || 0,
|
|
133
139
|
disabled: acct.disabled || false,
|
|
134
140
|
maxUsage: acct.maxUsage ?? null,
|
|
@@ -237,6 +243,15 @@ export class AccountManager {
|
|
|
237
243
|
// it. Each such switch arms the ramp below, so steady interleaved traffic
|
|
238
244
|
// holds both accounts at the ramp floor while nothing has failed over.
|
|
239
245
|
this.routeCursors = new Map();
|
|
246
|
+
// One cursor per provider. `currentIndex` is a single slot, and a request
|
|
247
|
+
// only another provider can serve would otherwise drag it across: a Codex
|
|
248
|
+
// request moved it onto a Codex account and the next Anthropic request moved
|
|
249
|
+
// it straight back, flapping the TUI's current-account marker and re-arming
|
|
250
|
+
// storm control on every alternation. A provider partition is the purest
|
|
251
|
+
// form of "barred for THIS request only" — an Anthropic account serves every
|
|
252
|
+
// Anthropic request perfectly well — so it must not move the fleet, exactly
|
|
253
|
+
// as a spent family bucket does not (#276).
|
|
254
|
+
this.providerCursors = new Map();
|
|
240
255
|
this.switchThreshold = switchThreshold;
|
|
241
256
|
this.setRoutes(routes);
|
|
242
257
|
this.setSoonestWeekly(soonestWeekly);
|
|
@@ -459,6 +474,14 @@ export class AccountManager {
|
|
|
459
474
|
* window (storm control) so they trickle out rather than flood. Extends an
|
|
460
475
|
* existing pause rather than shortening it.
|
|
461
476
|
*/
|
|
477
|
+
/** True while an account is inside a rate-limit pause (see pauseAccount). The
|
|
478
|
+
* pause deliberately does NOT mark the account throttled, so selection still
|
|
479
|
+
* offers it — a caller choosing a failover target has to ask. */
|
|
480
|
+
isPaused(index, now = Date.now()) {
|
|
481
|
+
const account = this.accounts[index];
|
|
482
|
+
return !!(account?.pausedUntil && now < account.pausedUntil);
|
|
483
|
+
}
|
|
484
|
+
|
|
462
485
|
pauseAccount(index, seconds) {
|
|
463
486
|
const account = this.accounts[index];
|
|
464
487
|
if (!account) return;
|
|
@@ -511,12 +534,43 @@ export class AccountManager {
|
|
|
511
534
|
* request keeps flowing (upstream then fails just the advisor call).
|
|
512
535
|
*/
|
|
513
536
|
getActiveAccount(exclude = null, model = null, advisorModel = null, sessionId = null, provider = DEFAULT_PROVIDER) {
|
|
514
|
-
|
|
537
|
+
// Selection reads this.currentIndex as "where the fleet is". With more than
|
|
538
|
+
// one provider that is a single slot for several fleets, so a request whose
|
|
539
|
+
// provider does not own it borrows the slot for the walk and hands it back.
|
|
540
|
+
//
|
|
541
|
+
// With one provider — every config that predates #246 — `borrowed` is always
|
|
542
|
+
// false and this is the same code it was.
|
|
543
|
+
const owner = providerOf(this.accounts[this.currentIndex]);
|
|
544
|
+
const borrowed = !!this.accounts.length && owner !== provider;
|
|
545
|
+
const saved = this.currentIndex;
|
|
546
|
+
if (borrowed) {
|
|
547
|
+
const own = this.providerCursors.get(provider);
|
|
548
|
+
if (own != null && this.accounts[own] && providerOf(this.accounts[own]) === provider) this.currentIndex = own;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
let account;
|
|
552
|
+
// Scoped rather than threaded through _select/_selectNext/_divertedFor: the
|
|
553
|
+
// whole walk is synchronous, so nothing can interleave and observe it, and
|
|
554
|
+
// the alternative is a provider argument on six private methods that exist
|
|
555
|
+
// to answer a different question.
|
|
556
|
+
this._selectingProvider = provider;
|
|
557
|
+
try {
|
|
558
|
+
account = this._pickActiveAccount(this._excludeOtherProviders(exclude, provider), model, advisorModel, sessionId);
|
|
559
|
+
} finally {
|
|
560
|
+
this._selectingProvider = null;
|
|
561
|
+
// Hand the slot back before anything can observe it moved. Only the
|
|
562
|
+
// provider that owns currentIndex gets to change it.
|
|
563
|
+
if (borrowed) this.currentIndex = saved;
|
|
564
|
+
}
|
|
565
|
+
|
|
515
566
|
// Record where this route now sits, whatever path chose it — the steady-state
|
|
516
567
|
// path returns the account the cursor already names and never reaches the
|
|
517
568
|
// rotation code, so recording there alone would leave the cursor unset and
|
|
518
569
|
// the next real failover unpaced.
|
|
519
|
-
if (account)
|
|
570
|
+
if (account) {
|
|
571
|
+
this.routeCursors.set(this._cursorKey(model, advisorModel, provider), account.index);
|
|
572
|
+
this.providerCursors.set(providerOf(account), account.index);
|
|
573
|
+
}
|
|
520
574
|
return account;
|
|
521
575
|
}
|
|
522
576
|
|
|
@@ -535,7 +589,13 @@ export class AccountManager {
|
|
|
535
589
|
* the common single-provider case allocates nothing.
|
|
536
590
|
*/
|
|
537
591
|
_excludeOtherProviders(exclude, provider) {
|
|
538
|
-
|
|
592
|
+
// Only SUBSCRIPTION accounts are partitioned. A Claude Max token is issued
|
|
593
|
+
// to Claude and a ChatGPT token to Codex; neither plan can be spent by the
|
|
594
|
+
// other's client, so crossing them is never right. An API key carries no
|
|
595
|
+
// such tie — it is metered capacity, not a seat — so it stays eligible for
|
|
596
|
+
// whichever app is asking, which is what keeps a third-party backend usable
|
|
597
|
+
// from both.
|
|
598
|
+
const foreign = this.accounts.filter(a => providerOf(a) !== provider && isSubscriptionAccount(a));
|
|
539
599
|
if (foreign.length === 0) return exclude;
|
|
540
600
|
const combined = new Set(exclude || []);
|
|
541
601
|
for (const account of foreign) combined.add(account.index);
|
|
@@ -607,6 +667,11 @@ export class AccountManager {
|
|
|
607
667
|
const betterExists = this._preemptedBy(current, model, advisorModel, exclude);
|
|
608
668
|
return betterExists ? this._selectNext(exclude, model, advisorModel) : current;
|
|
609
669
|
}
|
|
670
|
+
// Barred for this request only: keep the family where it was diverted.
|
|
671
|
+
// Barred outright (disabled, throttled, spent): rotate, as before.
|
|
672
|
+
const diverted = this._currentBarredOnlyFor(model, advisorModel, exclude)
|
|
673
|
+
? this._divertedFor(model, advisorModel, exclude) : null;
|
|
674
|
+
if (diverted) return diverted;
|
|
610
675
|
const next = this._selectNext(exclude, model, advisorModel);
|
|
611
676
|
if (next) return next;
|
|
612
677
|
// No account is under the switch threshold. Before refusing locally, allow a
|
|
@@ -776,6 +841,13 @@ export class AccountManager {
|
|
|
776
841
|
this._isAvailable(a, model) && (a.priority || 0) < (current.priority || 0));
|
|
777
842
|
if (!better) return current.index;
|
|
778
843
|
}
|
|
844
|
+
// Mirror _select's diversion cursor, so the preview names the account a
|
|
845
|
+
// diverted family will actually land on rather than the one a fresh walk
|
|
846
|
+
// would pick.
|
|
847
|
+
if (this._currentBarredOnlyFor(model)) {
|
|
848
|
+
const diverted = this._divertedFor(model);
|
|
849
|
+
if (diverted) return diverted.index;
|
|
850
|
+
}
|
|
779
851
|
const best = this._pickBestAvailable(null, model);
|
|
780
852
|
return best ? best.index : null;
|
|
781
853
|
}
|
|
@@ -978,7 +1050,13 @@ export class AccountManager {
|
|
|
978
1050
|
|
|
979
1051
|
// A structured organization-policy 403 means this account cannot serve OAuth
|
|
980
1052
|
// requests right now. Skip it across requests until the short cooldown ends.
|
|
981
|
-
|
|
1053
|
+
//
|
|
1054
|
+
// A reason string, not `false`: this function's contract is "a reason, or
|
|
1055
|
+
// null when it can serve", and getStatus surfaces the value verbatim. `false`
|
|
1056
|
+
// read as available against that contract and, being falsy, made
|
|
1057
|
+
// unavailableLine drop the row — so the one state added to make a refusal
|
|
1058
|
+
// explainable was the only one that printed no explanation (#258).
|
|
1059
|
+
if (this._entitlementDenied(account)) return 'entitlement';
|
|
982
1060
|
|
|
983
1061
|
// Check rate limit expiry
|
|
984
1062
|
if (account.status === 'throttled' && account.rateLimitedUntil) {
|
|
@@ -1257,9 +1335,59 @@ export class AccountManager {
|
|
|
1257
1335
|
this.routeCursors?.clear();
|
|
1258
1336
|
}
|
|
1259
1337
|
|
|
1260
|
-
/**
|
|
1261
|
-
|
|
1262
|
-
|
|
1338
|
+
/**
|
|
1339
|
+
* Cursor key for a request: its route's name, or — with no route configured —
|
|
1340
|
+
* the weekly bucket the model spends, so Fable and Opus keep separate cursors
|
|
1341
|
+
* in the default config too (one key for everything let a Fable diversion be
|
|
1342
|
+
* read back as the Opus cursor, #276). An advisor request is keyed by both
|
|
1343
|
+
* families it needs: it is diverted for the advisor's sake as often as the
|
|
1344
|
+
* executor's, and sharing a cursor with plain requests for the executor's
|
|
1345
|
+
* model would have each overwrite the other's and re-log the diversion.
|
|
1346
|
+
*/
|
|
1347
|
+
_cursorKey(model, advisorModel = null, provider = this._selectingProvider || DEFAULT_PROVIDER) {
|
|
1348
|
+
const own = this._routeForModel(model)?.name || (model ? this._weeklyBucketFor(model) : '');
|
|
1349
|
+
const base = (() => {
|
|
1350
|
+
if (!advisorModel) return own;
|
|
1351
|
+
const adv = this._routeForModel(advisorModel)?.name || this._weeklyBucketFor(advisorModel);
|
|
1352
|
+
return adv === own ? own : `${own}+${adv}`;
|
|
1353
|
+
})();
|
|
1354
|
+
// Namespaced by provider so a diversion recorded for one provider is never
|
|
1355
|
+
// read back as another's. Model names do not have to differ between
|
|
1356
|
+
// providers, and a bucket key certainly does not — an unprefixed key would
|
|
1357
|
+
// hand a Codex request the account an Anthropic request was diverted to.
|
|
1358
|
+
// The default provider keeps the bare key, so existing cursors and every
|
|
1359
|
+
// single-provider config are unchanged.
|
|
1360
|
+
return provider === DEFAULT_PROVIDER ? base : `${provider}\u0000${base}`;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* True when the current account can serve in general but not THIS request:
|
|
1365
|
+
* its family bucket or cap is spent, a route excludes it, or the advisor
|
|
1366
|
+
* model's family is spent. That is a per-request diversion, not a rotation,
|
|
1367
|
+
* and the fleet stays where it is. False for disabled / error / throttled /
|
|
1368
|
+
* 5h or shared weekly over threshold, which bar every request and must
|
|
1369
|
+
* rotate the fleet as before.
|
|
1370
|
+
*/
|
|
1371
|
+
_currentBarredOnlyFor(model, advisorModel = null, exclude = null) {
|
|
1372
|
+
const current = this.accounts[this.currentIndex];
|
|
1373
|
+
return !!model && !!current && !exclude?.has(current.index)
|
|
1374
|
+
&& this._isAvailable(current, null) && !this._isAvailable(current, model, advisorModel);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Where this request's family was last diverted to, if that account can still
|
|
1379
|
+
* serve it. Consulted only once the current account has been found barred
|
|
1380
|
+
* for this request alone, so a family returns to the current account the
|
|
1381
|
+
* moment it can serve it again rather than staying diverted out of habit.
|
|
1382
|
+
* Yields to a higher-priority account the same way the current account does.
|
|
1383
|
+
*/
|
|
1384
|
+
_divertedFor(model, advisorModel = null, exclude = null) {
|
|
1385
|
+
if (!model) return null;
|
|
1386
|
+
const idx = this.routeCursors.get(this._cursorKey(model, advisorModel));
|
|
1387
|
+
const account = idx != null ? this.accounts[idx] : null;
|
|
1388
|
+
if (!account || account.index === this.currentIndex || exclude?.has(account.index)) return null;
|
|
1389
|
+
if (!this._isAvailable(account, model, advisorModel)) return null;
|
|
1390
|
+
return this._preemptedBy(account, model, advisorModel, exclude) ? null : account;
|
|
1263
1391
|
}
|
|
1264
1392
|
|
|
1265
1393
|
/** The account this route was serving from before the current selection, or
|
|
@@ -1269,8 +1397,8 @@ export class AccountManager {
|
|
|
1269
1397
|
* it names an account the route could have used: a cursor left on another
|
|
1270
1398
|
* route's account was never this route's position, so moving off it is not a
|
|
1271
1399
|
* rotation. */
|
|
1272
|
-
_previousCursor(model) {
|
|
1273
|
-
const recorded = this.routeCursors.get(this._cursorKey(model));
|
|
1400
|
+
_previousCursor(model, advisorModel = null) {
|
|
1401
|
+
const recorded = this.routeCursors.get(this._cursorKey(model, advisorModel));
|
|
1274
1402
|
if (recorded != null) return recorded;
|
|
1275
1403
|
const current = this.accounts[this.currentIndex];
|
|
1276
1404
|
return current && this._routeAllows(current, model) ? current.index : null;
|
|
@@ -1449,6 +1577,14 @@ export class AccountManager {
|
|
|
1449
1577
|
// Clear expired unified quotas
|
|
1450
1578
|
if (q.unified5h != null && q.unified5hReset && now >= q.unified5hReset) {
|
|
1451
1579
|
console.log(`[TeamClaude] Account "${account.name}" session quota reset`);
|
|
1580
|
+
// Recorded on the account, not just returned. _clearExpiredQuotas is
|
|
1581
|
+
// reached from two directions — refreshExpiredQuotas on the request path,
|
|
1582
|
+
// which runs the session-reset switch rule, and _isNearQuota via
|
|
1583
|
+
// unavailableReason, which getStatus calls for every account on every
|
|
1584
|
+
// read. Whichever noticed first used to consume the event, so with a
|
|
1585
|
+
// dashboard polling every 5s the rule almost never ran (#275). The flag
|
|
1586
|
+
// outlives the observation; only the request path clears it.
|
|
1587
|
+
account.sessionResetPending = true;
|
|
1452
1588
|
q.unified5h = null;
|
|
1453
1589
|
q.unified5hReset = null;
|
|
1454
1590
|
// `rejected` describes the shared buckets and this is one of them: a
|
|
@@ -1557,13 +1693,31 @@ export class AccountManager {
|
|
|
1557
1693
|
* account's (and it still has weekly quota), so we spend the quota closest to
|
|
1558
1694
|
* refreshing first.
|
|
1559
1695
|
*/
|
|
1696
|
+
/**
|
|
1697
|
+
* Clear windows whose reset has passed, WITHOUT the session-reset switch.
|
|
1698
|
+
*
|
|
1699
|
+
* For read paths. refreshExpiredQuotas() also runs _switchOnSessionReset,
|
|
1700
|
+
* which moves currentIndex — so calling that from getStatus would let a GET
|
|
1701
|
+
* on /teamclaude/status rotate the fleet as a side effect of being read, and
|
|
1702
|
+
* a polling dashboard would quietly drive routing.
|
|
1703
|
+
*/
|
|
1704
|
+
sweepExpiredQuotas() {
|
|
1705
|
+
for (const account of this.accounts) this._clearExpiredQuotas(account);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1560
1708
|
refreshExpiredQuotas() {
|
|
1561
1709
|
let changed = false;
|
|
1562
1710
|
const sessionReset = [];
|
|
1563
1711
|
for (const account of this.accounts) {
|
|
1564
1712
|
const r = this._clearExpiredQuotas(account);
|
|
1565
1713
|
if (r.changed) changed = true;
|
|
1566
|
-
|
|
1714
|
+
// The flag, not r.session: a status read may have cleared the window
|
|
1715
|
+
// seconds earlier, and the rule still has to run. Cleared here because
|
|
1716
|
+
// this is the only path that acts on it.
|
|
1717
|
+
if (account.sessionResetPending) {
|
|
1718
|
+
account.sessionResetPending = false;
|
|
1719
|
+
sessionReset.push(account);
|
|
1720
|
+
}
|
|
1567
1721
|
}
|
|
1568
1722
|
if (sessionReset.length) this._switchOnSessionReset(sessionReset);
|
|
1569
1723
|
return changed;
|
|
@@ -1697,15 +1851,25 @@ export class AccountManager {
|
|
|
1697
1851
|
_selectNext(exclude = null, model = null, advisorModel = null) {
|
|
1698
1852
|
const best = this._pickBestAvailable(exclude, model, advisorModel);
|
|
1699
1853
|
if (best) {
|
|
1700
|
-
const previous = this._previousCursor(model);
|
|
1854
|
+
const previous = this._previousCursor(model, advisorModel);
|
|
1701
1855
|
const switched = previous != null && previous !== best.index;
|
|
1702
|
-
|
|
1856
|
+
// A model-scoped exclusion — the current account serves everything but
|
|
1857
|
+
// this request (family bucket, cap, route, or the advisor's family) —
|
|
1858
|
+
// diverts this request alone. The global cursor stays: the account was
|
|
1859
|
+
// chosen, often by hand for a weekly window about to lapse, to be spent by
|
|
1860
|
+
// what it can serve, and moving the whole fleet off it for one family's
|
|
1861
|
+
// sake undid that on the next request (#276).
|
|
1862
|
+
const current = this.accounts[this.currentIndex];
|
|
1863
|
+
const scoped = this._currentBarredOnlyFor(model, advisorModel, exclude);
|
|
1864
|
+
if (!scoped) this.currentIndex = best.index;
|
|
1703
1865
|
// If we switched to an account whose weekly quota is still unknown, flag
|
|
1704
1866
|
// it so we re-evaluate once that quota is learned (see updateQuota).
|
|
1705
1867
|
best.probing = best.quota.unified7dReset == null;
|
|
1706
1868
|
if (switched) {
|
|
1707
1869
|
this._beginRamp(best);
|
|
1708
|
-
console.log(
|
|
1870
|
+
console.log(scoped
|
|
1871
|
+
? `[TeamClaude] Diverting "${model}" to "${best.name}" — "${current.name}" cannot serve it`
|
|
1872
|
+
: `[TeamClaude] Switched to account "${best.name}"`);
|
|
1709
1873
|
}
|
|
1710
1874
|
return best;
|
|
1711
1875
|
}
|
|
@@ -2063,6 +2227,15 @@ export class AccountManager {
|
|
|
2063
2227
|
}
|
|
2064
2228
|
}
|
|
2065
2229
|
|
|
2230
|
+
/** Apply subscription metadata learned from the OAuth profile endpoint. */
|
|
2231
|
+
applyProfileData(accountIndex, profile) {
|
|
2232
|
+
const account = this.accounts[accountIndex];
|
|
2233
|
+
if (!account || !profile || profile.error) return;
|
|
2234
|
+
for (const field of ['organizationType', 'rateLimitTier', 'seatTier', 'hasClaudeMax', 'hasClaudePro']) {
|
|
2235
|
+
if (profile[field] != null) account[field] = profile[field];
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2066
2239
|
/**
|
|
2067
2240
|
* Mark an account as rate-limited for a given duration.
|
|
2068
2241
|
*/
|
|
@@ -2260,7 +2433,14 @@ export class AccountManager {
|
|
|
2260
2433
|
return this.accounts.map(a => {
|
|
2261
2434
|
const quota = {};
|
|
2262
2435
|
for (const f of PERSISTED_QUOTA_FIELDS) quota[f] = a.quota[f];
|
|
2263
|
-
|
|
2436
|
+
const profile = {
|
|
2437
|
+
organizationType: a.organizationType,
|
|
2438
|
+
rateLimitTier: a.rateLimitTier,
|
|
2439
|
+
seatTier: a.seatTier,
|
|
2440
|
+
hasClaudeMax: a.hasClaudeMax,
|
|
2441
|
+
hasClaudePro: a.hasClaudePro,
|
|
2442
|
+
};
|
|
2443
|
+
return { accountUuid: a.accountUuid, orgUuid: a.orgUuid, orgName: a.orgName, name: a.name, profile, quota };
|
|
2264
2444
|
});
|
|
2265
2445
|
}
|
|
2266
2446
|
|
|
@@ -2277,6 +2457,9 @@ export class AccountManager {
|
|
|
2277
2457
|
for (const f of PERSISTED_QUOTA_FIELDS) {
|
|
2278
2458
|
if (match.quota[f] != null) account.quota[f] = match.quota[f];
|
|
2279
2459
|
}
|
|
2460
|
+
for (const field of ['organizationType', 'rateLimitTier', 'seatTier', 'hasClaudeMax', 'hasClaudePro']) {
|
|
2461
|
+
if (match.profile?.[field] != null) account[field] = match.profile[field];
|
|
2462
|
+
}
|
|
2280
2463
|
// We already know this account's weekly window, so it isn't "probing".
|
|
2281
2464
|
if (account.quota.unified7dReset != null) account.probing = false;
|
|
2282
2465
|
}
|
|
@@ -2290,9 +2473,19 @@ export class AccountManager {
|
|
|
2290
2473
|
// client and dimension value to anyone who can read status, and on a shared
|
|
2291
2474
|
// proxy that is every key holder.
|
|
2292
2475
|
getStatus({ sessionDetail = false } = {}) {
|
|
2476
|
+
// Sweep first: nothing else on the read path does, so an idle server kept
|
|
2477
|
+
// reporting a window whose reset had already passed — at its old percentage,
|
|
2478
|
+
// with a timestamp in the past — to `status --json`, anything scripted
|
|
2479
|
+
// against the endpoint, and the attached TUI, whose own refresh is a no-op
|
|
2480
|
+
// precisely because it trusts the server to have done this (#237).
|
|
2481
|
+
this.sweepExpiredQuotas();
|
|
2293
2482
|
const sessions = this.sessionTracker.stats(undefined, { detail: sessionDetail });
|
|
2294
2483
|
return {
|
|
2295
2484
|
currentAccount: this.accounts[this.currentIndex]?.name,
|
|
2485
|
+
// Where a request no route claims lands right now — the same derivation
|
|
2486
|
+
// as each route's `target`, so a status reader need not assume "the
|
|
2487
|
+
// current account" when that account is blocked or outranked.
|
|
2488
|
+
defaultTarget: this._routeTarget(null),
|
|
2296
2489
|
switchThreshold: this.effectiveThreshold,
|
|
2297
2490
|
// The full table when one is configured, so status output can show the
|
|
2298
2491
|
// per-bucket values rather than only the representative number.
|
|
@@ -2340,4 +2533,10 @@ export class AccountManager {
|
|
|
2340
2533
|
})),
|
|
2341
2534
|
};
|
|
2342
2535
|
}
|
|
2536
|
+
|
|
2537
|
+
/** Return per-account quota and fleet aggregates for status clients. */
|
|
2538
|
+
getQuotaSummary() {
|
|
2539
|
+
this.refreshExpiredQuotas();
|
|
2540
|
+
return buildQuotaSummary(this.accounts);
|
|
2541
|
+
}
|
|
2343
2542
|
}
|
package/src/account-pairing.js
CHANGED
|
@@ -58,10 +58,70 @@ export function configIndexFor(configAccounts, managerAccounts, mgrIdx) {
|
|
|
58
58
|
* its own and read another's credential a restart later. Pairing by id does not
|
|
59
59
|
* reach this axis.
|
|
60
60
|
*/
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Whether this entry's OAuth token belongs in `config.json` at all.
|
|
63
|
+
*
|
|
64
|
+
* The save used to write `accessToken: am.credential` onto EVERY entry, which
|
|
65
|
+
* is right for an ordinary oauth account and wrong for the other two kinds:
|
|
66
|
+
*
|
|
67
|
+
* - an API-KEY account's credential is its `apiKey`, so the save mirrored the
|
|
68
|
+
* same secret into `accessToken` — and makeAccount reads
|
|
69
|
+
* `acct.accessToken || acct.apiKey`, preferring the mirror. Rotating the key
|
|
70
|
+
* in config.json was then ignored on the next cold start, while a running
|
|
71
|
+
* server picked it up, so the failure looked like the provider rejecting a
|
|
72
|
+
* key that reads correctly in the file (#202).
|
|
73
|
+
* - an `importFrom` account delegates to a credentials file on purpose. One
|
|
74
|
+
* save materialised the token into config.json, and from then on the copy
|
|
75
|
+
* won over re-reading the file, so the entry stopped delegating in practice
|
|
76
|
+
* (#204).
|
|
77
|
+
*
|
|
78
|
+
* Refreshed tokens for ordinary oauth entries still persist — that is the case
|
|
79
|
+
* this write exists for.
|
|
80
|
+
*/
|
|
81
|
+
function ownsInlineToken(entry) {
|
|
82
|
+
return entry?.type === 'oauth' && !entry.importFrom;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Entries the operator removed in this process, by id.
|
|
86
|
+
//
|
|
87
|
+
// The save has to tell two things apart that look identical on disk: a row that
|
|
88
|
+
// appeared since the last reload (`teamclaude login` while the server runs —
|
|
89
|
+
// keep it) and a row the operator just deleted (drop it). Both are "on disk but
|
|
90
|
+
// not in memory", and removal is itself a save, so adopting disk-only rows
|
|
91
|
+
// without this would resurrect the account the operator was in the middle of
|
|
92
|
+
// deleting.
|
|
93
|
+
//
|
|
94
|
+
// Held on the config object and non-enumerable, so it never reaches JSON.
|
|
95
|
+
const REMOVED = Symbol('removedAccountIds');
|
|
96
|
+
|
|
97
|
+
/** Record that `id` was deliberately removed, so a save does not adopt it back. */
|
|
98
|
+
export function markAccountRemoved(config, id) {
|
|
99
|
+
if (!config || !id) return;
|
|
100
|
+
if (!config[REMOVED]) {
|
|
101
|
+
Object.defineProperty(config, REMOVED, { value: new Set(), enumerable: false, writable: true });
|
|
102
|
+
}
|
|
103
|
+
config[REMOVED].add(id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The ids removed so far. */
|
|
107
|
+
export function removedAccountIds(config) {
|
|
108
|
+
return config?.[REMOVED] || new Set();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Forget the removals, once a save has written a list that omits them. After
|
|
113
|
+
* that the rows are gone from disk too, so there is nothing left to re-adopt
|
|
114
|
+
* and keeping the ids would only strand them if the operator re-added the same
|
|
115
|
+
* account later.
|
|
116
|
+
*/
|
|
117
|
+
export function clearRemovedAccountIds(config) {
|
|
118
|
+
if (config?.[REMOVED]) config[REMOVED].clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function mergeAccountsForSave(configAccounts, managerAccounts, diskAccounts, removedIds = new Set()) {
|
|
122
|
+
const merged = configAccounts.map(a => {
|
|
63
123
|
const am = managerAccountFor(managerAccounts, a);
|
|
64
|
-
const live = am ? {
|
|
124
|
+
const live = am && ownsInlineToken(a) ? {
|
|
65
125
|
...a,
|
|
66
126
|
accessToken: am.credential,
|
|
67
127
|
refreshToken: am.refreshToken,
|
|
@@ -70,6 +130,24 @@ export function mergeAccountsForSave(configAccounts, managerAccounts, diskAccoun
|
|
|
70
130
|
const diskAcct = diskAccounts.find(d => sameIdentity(d, a));
|
|
71
131
|
return diskAcct ? { ...diskAcct, ...live } : live;
|
|
72
132
|
});
|
|
133
|
+
|
|
134
|
+
// Carry over rows that exist on disk and not in memory. The list used to be
|
|
135
|
+
// exactly as long as the in-memory one, so an account added by another
|
|
136
|
+
// process since the last reload — `teamclaude login` or `import` while the
|
|
137
|
+
// server runs — was silently dropped by the next save (#205). The refresh
|
|
138
|
+
// handler already re-reads disk for this reason; the save had no equivalent.
|
|
139
|
+
//
|
|
140
|
+
// Except the ones the operator removed: removal is itself a save, so without
|
|
141
|
+
// that check this would resurrect the account being deleted, on the very
|
|
142
|
+
// write that was meant to delete it.
|
|
143
|
+
const keptIds = new Set(merged.map(a => a?.id).filter(Boolean));
|
|
144
|
+
for (const diskAcct of diskAccounts) {
|
|
145
|
+
if (!diskAcct?.id) continue; // pre-id row; identity above covers it
|
|
146
|
+
if (keptIds.has(diskAcct.id)) continue;
|
|
147
|
+
if (removedIds.has(diskAcct.id)) continue;
|
|
148
|
+
merged.push(diskAcct);
|
|
149
|
+
}
|
|
150
|
+
return merged;
|
|
73
151
|
}
|
|
74
152
|
|
|
75
153
|
/**
|