@rikcodes/teamclaude 1.1.19-rik.3 → 1.1.20-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 +7 -3
- package/src/account-id.js +1 -0
- package/src/account-manager.js +277 -61
- package/src/account-uuid-rewrite.js +29 -4
- package/src/admission-gate.js +12 -1
- package/src/alias.js +10 -1
- package/src/backend-quota.js +16 -4
- package/src/band-decision.js +8 -0
- package/src/cache-control-sanitize.js +17 -1
- package/src/classification-path.js +8 -0
- package/src/claude-env.js +26 -2
- package/src/codex-auth.js +3 -2
- package/src/codex-usage.js +87 -0
- package/src/crash-log.js +3 -1
- package/src/dashboard.js +31 -0
- package/src/egress-guard.js +24 -5
- package/src/event-loop-monitor.js +2 -1
- package/src/forward-target.js +2 -1
- package/src/identity.js +1 -0
- package/src/index.js +37 -2
- package/src/json-format-stream.js +6 -0
- package/src/mitm.js +13 -2
- package/src/oauth.js +5 -4
- package/src/prober.js +39 -6
- package/src/quota-projection.js +3 -0
- package/src/request-log.js +11 -2
- package/src/resolve-accounts.js +2 -1
- package/src/rollover.js +59 -8
- package/src/safe-text.js +7 -1
- package/src/server.js +51 -8
- package/src/session-titles.js +26 -1
- package/src/session-tracker.js +63 -14
- package/src/sidecar.js +1 -0
- package/src/sx.js +1 -0
- package/src/sync-accounts.js +8 -4
- package/src/terminal-title.js +7 -0
- package/src/tool-pair-sanitize.js +23 -0
- package/src/tui-remote.js +4 -2
- package/src/tui.js +1 -0
- package/src/types.js +14 -0
- package/src/updater.js +24 -2
- package/src/upstream-fetch.js +4 -3
- package/src/upstream-proxy.js +2 -2
- package/src/warmer.js +18 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rikcodes/teamclaude",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.20-rik.1",
|
|
4
4
|
"description": "Multi-account proxy for Claude Code and Codex: pools Claude Max, ChatGPT/Codex, API-key and third-party backend accounts, and rotates on quota",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
"scripts": {
|
|
14
14
|
"start": "node src/index.js",
|
|
15
15
|
"test": "node --test --test-timeout=120000",
|
|
16
|
-
"lint": "eslint ."
|
|
16
|
+
"lint": "eslint .",
|
|
17
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
18
|
+
"typecheck:strict": "node scripts/typecheck-strict.mjs"
|
|
17
19
|
},
|
|
18
20
|
"keywords": [
|
|
19
21
|
"claude",
|
|
@@ -43,7 +45,9 @@
|
|
|
43
45
|
"node": ">=20.0.0"
|
|
44
46
|
},
|
|
45
47
|
"devDependencies": {
|
|
46
|
-
"
|
|
48
|
+
"@types/node": "20.19.43",
|
|
49
|
+
"eslint": "^9.0.0",
|
|
50
|
+
"typescript": "5.9.2"
|
|
47
51
|
},
|
|
48
52
|
"publishConfig": {
|
|
49
53
|
"access": "public"
|
package/src/account-id.js
CHANGED
|
@@ -40,6 +40,7 @@ export function mintAccountId() {
|
|
|
40
40
|
* copies its id along with it, and two entries answering to one id collapse
|
|
41
41
|
* onto whichever comes first — the later one would be handed the earlier one's
|
|
42
42
|
* credential, which is the crossing this field exists to prevent.
|
|
43
|
+
* @param {Array<Record<string, any>>} accounts
|
|
43
44
|
*/
|
|
44
45
|
export function ensureAccountIds(accounts) {
|
|
45
46
|
const seen = new Set();
|
package/src/account-manager.js
CHANGED
|
@@ -7,10 +7,11 @@ import { weeklyBucketForModel, modelGlobMatches, modelFamily, gatingUtilization,
|
|
|
7
7
|
import { SessionTracker } from './session-tracker.js';
|
|
8
8
|
import { buildQuotaSummary, quotaTier } from './quota-summary.js';
|
|
9
9
|
import { QuotaProjection, PROJECTED_BUCKETS } from './quota-projection.js';
|
|
10
|
-
import { ROLLOVER_MIN_JUMP_MS, remapHeld } from './rollover.js';
|
|
10
|
+
import { ROLLOVER_MIN_JUMP_MS, remapHeld, findHeld, dropHeld, newObservation } from './rollover.js';
|
|
11
11
|
import { decideBand, pressureOf, pressureRank, assertNever } from './band-decision.js';
|
|
12
12
|
import { BurnRateLearner, ConcurrencyLearner, scoreCandidate } from './adaptive-distribution.js';
|
|
13
13
|
import { safeLine } from './safe-text.js';
|
|
14
|
+
/** @typedef {import('./session-tracker.js').Observation} Observation */
|
|
14
15
|
|
|
15
16
|
// Re-exported for callers that import these model helpers from here.
|
|
16
17
|
export { isFableModel, parseRequestModel, parseAdvisorModel } from './model.js';
|
|
@@ -292,6 +293,24 @@ function sampleModelFor(route) {
|
|
|
292
293
|
}
|
|
293
294
|
|
|
294
295
|
export class AccountManager {
|
|
296
|
+
/**
|
|
297
|
+
* @param {Array<Object>} accounts config entries, credentials resolved
|
|
298
|
+
* @param {number|Object<string, number>} [switchThreshold] one number, or per bucket with a `default`
|
|
299
|
+
* @param {Object} [opts]
|
|
300
|
+
* @param {Function} [opts.refreshFn]
|
|
301
|
+
* @param {Function} [opts.codexRefreshFn]
|
|
302
|
+
* @param {number} [opts.throttleProbeFloorMs]
|
|
303
|
+
* @param {number} [opts.familyStaleMs]
|
|
304
|
+
* @param {number} [opts.statusStaleMs]
|
|
305
|
+
* @param {number} [opts.forcedRefreshFloorMs]
|
|
306
|
+
* @param {Array<Object>} [opts.routes]
|
|
307
|
+
* @param {Object} [opts.ramp]
|
|
308
|
+
* @param {boolean|string} [opts.distributeSessions]
|
|
309
|
+
* @param {Object} [opts.adaptive]
|
|
310
|
+
* @param {Object} [opts.projection]
|
|
311
|
+
* @param {Object} [opts.sessionTracker]
|
|
312
|
+
* @param {Object} [opts.expiryRouting]
|
|
313
|
+
*/
|
|
295
314
|
constructor(accounts, switchThreshold = 0.98, { refreshFn = refreshAccessToken, codexRefreshFn = refreshCodexToken, throttleProbeFloorMs, familyStaleMs, statusStaleMs, forcedRefreshFloorMs = FORCED_REFRESH_FLOOR_MS, routes, ramp, distributeSessions = false, adaptive, projection, sessionTracker, expiryRouting } = {}) {
|
|
296
315
|
// How long a just-minted token is trusted against a forced refresh.
|
|
297
316
|
this._forcedRefreshFloorMs = forcedRefreshFloorMs;
|
|
@@ -356,6 +375,7 @@ export class AccountManager {
|
|
|
356
375
|
// observation, { idx, windows: name → reset }, of the account traffic was
|
|
357
376
|
// resting on when a request last arrived to find it there. Null while the
|
|
358
377
|
// knob is off: nothing writes one then and none survives the transition.
|
|
378
|
+
/** @type {Observation|null} */
|
|
359
379
|
this._currentObs = null;
|
|
360
380
|
// Throttle for the held-rollover line, keyed by (account index, WINDOW,
|
|
361
381
|
// reason). Keying by the request bucket would let two windows that share one
|
|
@@ -534,9 +554,14 @@ export class AccountManager {
|
|
|
534
554
|
*/
|
|
535
555
|
_setCurrent(account) {
|
|
536
556
|
this.currentIndex = account.index;
|
|
537
|
-
|
|
557
|
+
// A move made by an operator or a poll names the provider cursor of the
|
|
558
|
+
// account it lands on. A move inside a selection walk does not: the walk
|
|
559
|
+
// records where it left the slot once it is done (see getActiveAccount),
|
|
560
|
+
// and a borrowed walk that picks a shared API key — which reads as the
|
|
561
|
+
// default provider — would otherwise overwrite the OWNER's cursor here.
|
|
562
|
+
if (this._selectingProvider == null) this.providerCursors.set(providerOf(account), account.index);
|
|
538
563
|
if (!this.expiryRouting.enabled || !this.expiryRouting.preempt) return;
|
|
539
|
-
this._firstSightOn(this._currentObs ??=
|
|
564
|
+
this._firstSightOn(this._currentObs ??= newObservation(), account);
|
|
540
565
|
}
|
|
541
566
|
|
|
542
567
|
/**
|
|
@@ -689,8 +714,10 @@ export class AccountManager {
|
|
|
689
714
|
// one provider that is a single slot for several fleets, so a request whose
|
|
690
715
|
// provider does not own it borrows the slot for the walk and hands it back.
|
|
691
716
|
//
|
|
692
|
-
// With one provider
|
|
693
|
-
//
|
|
717
|
+
// With one provider `borrowed` is always false, so the `providerCursors`
|
|
718
|
+
// entry this method writes has no reader at all. Its one reader is the
|
|
719
|
+
// `providerCursors.get(provider)` inside `if (borrowed)`, which only a
|
|
720
|
+
// borrowed walk reaches.
|
|
694
721
|
const owner = providerOf(this.accounts[this.currentIndex]);
|
|
695
722
|
const borrowed = !!this.accounts.length && owner !== provider;
|
|
696
723
|
const saved = this.currentIndex;
|
|
@@ -700,6 +727,7 @@ export class AccountManager {
|
|
|
700
727
|
}
|
|
701
728
|
|
|
702
729
|
let account;
|
|
730
|
+
let walked;
|
|
703
731
|
// Scoped rather than threaded through _select/_selectNext/_divertedFor: the
|
|
704
732
|
// whole walk is synchronous, so nothing can interleave and observe it, and
|
|
705
733
|
// the alternative is a provider argument on six private methods that exist
|
|
@@ -716,6 +744,7 @@ export class AccountManager {
|
|
|
716
744
|
} finally {
|
|
717
745
|
this._selectingProvider = null;
|
|
718
746
|
this._selectionDecision = null;
|
|
747
|
+
walked = this.currentIndex;
|
|
719
748
|
// Hand the slot back before anything can observe it moved. Only the
|
|
720
749
|
// provider that owns currentIndex gets to change it.
|
|
721
750
|
if (borrowed) this.currentIndex = saved;
|
|
@@ -727,7 +756,16 @@ export class AccountManager {
|
|
|
727
756
|
// the next real failover unpaced.
|
|
728
757
|
if (account) {
|
|
729
758
|
this.routeCursors.set(this._cursorKey(model, advisorModel, provider), account.index);
|
|
730
|
-
this
|
|
759
|
+
// `walked` names where this walk left the shared slot, captured in the
|
|
760
|
+
// `finally` while it still held it. When it names one of this provider's
|
|
761
|
+
// own accounts — including a borrow that re-seeded and then held its
|
|
762
|
+
// slot — the cursor takes it. Which one it names turns on where the walk
|
|
763
|
+
// left the slot, not on what it picked. Either way the fallback is the
|
|
764
|
+
// account that served — and when that account is another provider's, as
|
|
765
|
+
// a shared key is, this provider records nothing rather than a cursor
|
|
766
|
+
// its own re-seed would refuse.
|
|
767
|
+
const rest = providerOf(this.accounts[walked]) === provider ? walked : account.index;
|
|
768
|
+
if (providerOf(this.accounts[rest]) === provider) this.providerCursors.set(provider, rest);
|
|
731
769
|
}
|
|
732
770
|
return account;
|
|
733
771
|
}
|
|
@@ -795,7 +833,7 @@ export class AccountManager {
|
|
|
795
833
|
// on every request so the behaviour holds without the TUI render loop.
|
|
796
834
|
// The empty set marks this call as a request's, since a poll hands none.
|
|
797
835
|
// Allocated fresh: a set handed out once is one a later reader could add to.
|
|
798
|
-
this.refreshExpiredQuotas(model, this.expiryRouting.enabled ? (exclude ?? new Set()) : exclude);
|
|
836
|
+
this.refreshExpiredQuotas(model, this.expiryRouting.enabled ? (exclude ?? new Set()) : exclude, advisorModel);
|
|
799
837
|
// Session-affinity distribution (opt-in): keep a session on its pinned
|
|
800
838
|
// account for cache reuse, and route a new session to the least-loaded
|
|
801
839
|
// account. Only when enabled, only for a real session, and only outside a
|
|
@@ -1994,7 +2032,7 @@ export class AccountManager {
|
|
|
1994
2032
|
// the transition seeds" and "an aim never overwrites".
|
|
1995
2033
|
if (wasWatching) return;
|
|
1996
2034
|
const current = this.accounts[this.currentIndex];
|
|
1997
|
-
if (current) this._firstSightOn(this._currentObs ??=
|
|
2035
|
+
if (current) this._firstSightOn(this._currentObs ??= newObservation(), current);
|
|
1998
2036
|
for (const { sessionId, bucket, idx } of this.sessionTracker.livePins()) {
|
|
1999
2037
|
this._firstSightOn(this.sessionTracker.refsFor(sessionId, bucket, true), this.accounts[idx]);
|
|
2000
2038
|
}
|
|
@@ -2199,6 +2237,8 @@ export class AccountManager {
|
|
|
2199
2237
|
* writes only where nothing is lost, since the aimed request may never arrive.
|
|
2200
2238
|
* Nothing here releases a held roll: arriving is not being served, and only a
|
|
2201
2239
|
* served attempt carrying this observation's stamp releases one.
|
|
2240
|
+
*
|
|
2241
|
+
* @param {Observation} obs
|
|
2202
2242
|
*/
|
|
2203
2243
|
_restOn(obs, account, model) {
|
|
2204
2244
|
if (obs.idx !== account.index) {
|
|
@@ -2207,13 +2247,39 @@ export class AccountManager {
|
|
|
2207
2247
|
// `_firstSightOn` hands that back, and every cursor or pin move goes
|
|
2208
2248
|
// through `_setCurrent` or `recordSession`, so a fail-back has been
|
|
2209
2249
|
// offered it before any request reaches here.
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2250
|
+
// A reading that names NO account was offered nothing on the way here: the
|
|
2251
|
+
// rebuild a removal leaves behind names nobody, and no cursor moved for
|
|
2252
|
+
// `_firstSightOn` to run on. So this rest is the arrival, and a roll the
|
|
2253
|
+
// chain still owes the account it arrives at is handed back rather than
|
|
2254
|
+
// first-sighted away with the week that account has already gained.
|
|
2255
|
+
if (obs.idx == null) {
|
|
2256
|
+
const back = findHeld(obs.unescaped, h => h.idx === account.index);
|
|
2257
|
+
if (back) {
|
|
2258
|
+
this._moveObs(obs, account.index);
|
|
2259
|
+
obs.windows = back.windows;
|
|
2260
|
+
obs.handedBack = new Map(Object.entries(this._accountWindows(account)));
|
|
2261
|
+
obs.unescaped = dropHeld(obs.unescaped, account.index);
|
|
2262
|
+
return;
|
|
2263
|
+
}
|
|
2213
2264
|
}
|
|
2265
|
+
const leaving = obs.idx == null ? null : this.accounts[obs.idx];
|
|
2266
|
+
// The hold belongs to the fleet whose READING it preserves, the one that
|
|
2267
|
+
// last moved this observation, rather than to the fleet that writes it.
|
|
2268
|
+
// A borrower resting on the owner's cursor displaces the owner's reading.
|
|
2269
|
+
// A reading no walk has moved names no fleet, so the fleet that observes
|
|
2270
|
+
// the roll takes it, and a single-provider fleet can still settle it.
|
|
2271
|
+
const owed = leaving && this._newRoll(obs, leaving)
|
|
2272
|
+
? { idx: obs.idx, windows: obs.windows, provider: obs.provider ?? this._selectingProvider }
|
|
2273
|
+
: null;
|
|
2274
|
+
this._moveObs(obs, account.index);
|
|
2275
|
+
// Every account the fleet is still away from keeps its OWN roll, so a
|
|
2276
|
+
// second escape is chained onto the first instead of forgetting it. The
|
|
2277
|
+
// push takes the stamp of the move that escaped the roll, and only a stay
|
|
2278
|
+
// under that stamp releases it. At most one roll per account, the newest:
|
|
2279
|
+
// a later escape was read after the earlier one's window had rolled.
|
|
2280
|
+
if (owed) obs.unescaped = { ...owed, gen: obs.gen, prev: dropHeld(obs.unescaped, owed.idx) };
|
|
2214
2281
|
// Established whole, from every window the account presents, so a window
|
|
2215
2282
|
// that comes back is a first sight rather than a stale value read as a jump.
|
|
2216
|
-
this._moveObs(obs, account.index);
|
|
2217
2283
|
obs.windows = new Map(Object.entries(this._accountWindows(account)));
|
|
2218
2284
|
return;
|
|
2219
2285
|
}
|
|
@@ -2247,18 +2313,48 @@ export class AccountManager {
|
|
|
2247
2313
|
* design turns on — a reading whose account HAS rolled while its traffic has
|
|
2248
2314
|
* not come to rest elsewhere, the fail-back's only protection. The test is
|
|
2249
2315
|
* whether ANY window rolled, since an aim discards the whole reading at once.
|
|
2316
|
+
*
|
|
2317
|
+
* @param {Observation} obs
|
|
2250
2318
|
*/
|
|
2251
2319
|
_firstSightOn(obs, account) {
|
|
2252
2320
|
if (!obs || !account) return;
|
|
2253
|
-
if (obs.idx
|
|
2321
|
+
if (obs.idx !== account.index) {
|
|
2254
2322
|
// A fail-back reaches its origin through a cursor move rather than a rest:
|
|
2255
2323
|
// the pass returning the traffic finds the cursor still on the account
|
|
2256
2324
|
// that refused it, so _restOn never sees the arrival. The held roll is
|
|
2257
2325
|
// given back here, to the account that still owes it.
|
|
2258
|
-
|
|
2326
|
+
// Matched on index alone, whatever fleet holds it and whatever move
|
|
2327
|
+
// escaped it: handing a roll back to the account that owes it is a
|
|
2328
|
+
// restoration and not a settlement by anyone. Only that account's roll is
|
|
2329
|
+
// given back, so every other escape still outstanding stands. A reading
|
|
2330
|
+
// that names no account arrives here too, and one holding nothing for this
|
|
2331
|
+
// account takes the same fresh reading it always did: there is no account
|
|
2332
|
+
// at a null index for _anyJumped to have rolled.
|
|
2333
|
+
const owed = findHeld(obs.unescaped, h => h.idx === account.index);
|
|
2334
|
+
if (owed) {
|
|
2335
|
+
// The account the hand-back LEAVES may have rolled under the traffic that
|
|
2336
|
+
// rested on it, and the restore below replaces its reading. That roll is
|
|
2337
|
+
// an escape like any other, so it is chained rather than discarded.
|
|
2338
|
+
// A reading no walk established names no fleet, so the roll it loses is
|
|
2339
|
+
// held for the fleet the chain belongs to: the hold being handed back is
|
|
2340
|
+
// that fleet's, and a hold naming nobody can be settled by nobody.
|
|
2341
|
+
const leaving = obs.idx == null ? null : this.accounts[obs.idx];
|
|
2342
|
+
const displaced = leaving && this._newRoll(obs, leaving)
|
|
2343
|
+
? { idx: obs.idx, windows: obs.windows, provider: obs.provider ?? owed.provider ?? this._selectingProvider }
|
|
2344
|
+
: null;
|
|
2259
2345
|
this._moveObs(obs, account.index);
|
|
2260
|
-
obs.windows =
|
|
2261
|
-
obs.
|
|
2346
|
+
obs.windows = owed.windows;
|
|
2347
|
+
obs.handedBack = new Map(Object.entries(this._accountWindows(account)));
|
|
2348
|
+
// A restore outside a selection walk reads no fleet from one. The reading
|
|
2349
|
+
// handed back is the one the hold's fleet established, so it keeps that
|
|
2350
|
+
// fleet, and the next hand-back has one to stamp the roll it leaves with.
|
|
2351
|
+
obs.provider ??= owed.provider;
|
|
2352
|
+
obs.unescaped = dropHeld(obs.unescaped, account.index);
|
|
2353
|
+
// No stamp: the move that leaves this roll is a move BACK, and a stay
|
|
2354
|
+
// served at the account it returns to is no evidence about the one it
|
|
2355
|
+
// left. Only a stay served on that account releases it, or a return that
|
|
2356
|
+
// restores it, which is matched on index and needs no stamp.
|
|
2357
|
+
if (displaced) obs.unescaped = { ...displaced, gen: null, prev: dropHeld(obs.unescaped, displaced.idx) };
|
|
2262
2358
|
return;
|
|
2263
2359
|
}
|
|
2264
2360
|
if (this._anyJumped(obs.windows, this.accounts[obs.idx])) return;
|
|
@@ -2272,10 +2368,19 @@ export class AccountManager {
|
|
|
2272
2368
|
/**
|
|
2273
2369
|
* The stamp scopes a confirmation: a request that selected before this move,
|
|
2274
2370
|
* or after a later one, carries a different one and is no evidence here.
|
|
2371
|
+
*
|
|
2372
|
+
* @param {Observation} obs
|
|
2373
|
+
* @param {number} index
|
|
2275
2374
|
*/
|
|
2276
2375
|
_moveObs(obs, index) {
|
|
2277
2376
|
obs.idx = index;
|
|
2278
2377
|
obs.gen = ++this._obsGen;
|
|
2378
|
+
obs.handedBack = null;
|
|
2379
|
+
// A move inside a selection walk makes the reading that fleet's, so its own
|
|
2380
|
+
// stay is what settles a roll pushed off it. The same-index stay in `_restOn`
|
|
2381
|
+
// does not come through here: a borrowed walk advances a reading the resting
|
|
2382
|
+
// fleet never established, and stamping there hands it the owner's roll.
|
|
2383
|
+
obs.provider = this._selectingProvider;
|
|
2279
2384
|
}
|
|
2280
2385
|
|
|
2281
2386
|
/** Has ANY window this reading holds rolled over on the account it was taken
|
|
@@ -2290,6 +2395,24 @@ export class AccountManager {
|
|
|
2290
2395
|
return false;
|
|
2291
2396
|
}
|
|
2292
2397
|
|
|
2398
|
+
/** Has a window rolled on this account that the reading was NOT handed back
|
|
2399
|
+
* for? A hand-back restores the reading from before a roll and records the
|
|
2400
|
+
* account's windows as they stood then, so that roll is a jump against the
|
|
2401
|
+
* reading but not against the record. A window that has moved since the
|
|
2402
|
+
* hand-back, or one the record never saw, is a roll of its own, and the
|
|
2403
|
+
* departure that finds it holds it. Per window, so a rest governed by one
|
|
2404
|
+
* window says nothing about another's roll, and a second fleet's window
|
|
2405
|
+
* rolling on the same reading is still seen. */
|
|
2406
|
+
_newRoll(obs, account) {
|
|
2407
|
+
if (!obs.handedBack) return this._anyJumped(obs.windows, account);
|
|
2408
|
+
for (const [window, resetAt] of Object.entries(this._accountWindows(account))) {
|
|
2409
|
+
const win = { window, resetAt };
|
|
2410
|
+
if (!this._jumped(obs.windows, win)) continue;
|
|
2411
|
+
if (!obs.handedBack.has(window) || this._jumped(obs.handedBack, win)) return true;
|
|
2412
|
+
}
|
|
2413
|
+
return false;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2293
2416
|
/**
|
|
2294
2417
|
* The generation stamps a request selects under. Read BEFORE the walk: a move
|
|
2295
2418
|
* the walk makes is the request arriving, not finding traffic already at rest,
|
|
@@ -2310,29 +2433,47 @@ export class AccountManager {
|
|
|
2310
2433
|
confirmStay(account, carried, sessionId = null, provider = null) {
|
|
2311
2434
|
if (!this.expiryRouting.enabled || !this.expiryRouting.preempt) return;
|
|
2312
2435
|
if (!account || !carried) return;
|
|
2313
|
-
|
|
2314
|
-
// the success offered for it can belong to different fleets. The REQUEST's
|
|
2315
|
-
// provider settles it, and a confirmation naming no fleet settles nothing.
|
|
2316
|
-
const held = this._currentObs?.unescaped;
|
|
2317
|
-
if (held && provider && providerOf(this.accounts[held.idx]) === provider) {
|
|
2318
|
-
this._releaseHeld(this._currentObs, account, carried.current);
|
|
2319
|
-
}
|
|
2436
|
+
this._releaseHeld(this._currentObs, account, carried.current, provider);
|
|
2320
2437
|
if (sessionId) {
|
|
2321
2438
|
// The bucket comes from the stamp, since a route edit reaches the running
|
|
2322
|
-
// table before the response does.
|
|
2323
|
-
|
|
2324
|
-
this._releaseHeld(this.sessionTracker.refsFor(sessionId, carried.bucket), account, carried.pin);
|
|
2439
|
+
// table before the response does.
|
|
2440
|
+
this._releaseHeld(this.sessionTracker.refsFor(sessionId, carried.bucket), account, carried.pin, provider);
|
|
2325
2441
|
}
|
|
2326
2442
|
}
|
|
2327
2443
|
|
|
2328
2444
|
/**
|
|
2329
|
-
*
|
|
2330
|
-
*
|
|
2445
|
+
* Settle the roll the confirmed move escaped, on the evidence that the
|
|
2446
|
+
* destination SERVED a request selecting under that move. Another account, or
|
|
2447
|
+
* any move since, is a different stay. A confirmation naming no fleet settles
|
|
2448
|
+
* nothing. Every other roll on the chain waits for its own fail-back or for
|
|
2449
|
+
* the stay of its own move.
|
|
2450
|
+
*
|
|
2451
|
+
* @param {Observation} obs
|
|
2452
|
+
* @param {string|null} provider
|
|
2331
2453
|
*/
|
|
2332
|
-
_releaseHeld(obs, account, carried) {
|
|
2454
|
+
_releaseHeld(obs, account, carried, provider) {
|
|
2333
2455
|
if (!obs || carried == null) return;
|
|
2334
2456
|
if (obs.idx !== account.index || obs.gen !== carried) return;
|
|
2335
|
-
obs.unescaped
|
|
2457
|
+
const owed = findHeld(obs.unescaped, h => h.gen === carried);
|
|
2458
|
+
// A serve settles a roll held against the very account it was served at,
|
|
2459
|
+
// whatever move stamped it: a borrowed walk can leave the reading resting on
|
|
2460
|
+
// an account the chain still owes without any move of ours, and being served
|
|
2461
|
+
// there is the arrival the hold was waiting for. Its own fleet only, so the
|
|
2462
|
+
// shared-key rule is untouched, and only that account's roll, so every
|
|
2463
|
+
// other escape on the chain still stands. One confirmation can qualify that
|
|
2464
|
+
// roll and the one this move escaped, and each is settled on its own evidence.
|
|
2465
|
+
const own = findHeld(obs.unescaped, h => h.idx === account.index && h.provider === provider);
|
|
2466
|
+
if (!provider) return;
|
|
2467
|
+
if (own) obs.unescaped = dropHeld(obs.unescaped, own.idx);
|
|
2468
|
+
if (!owed) return;
|
|
2469
|
+
// A hold has a reading to itself only where the destination is its own
|
|
2470
|
+
// fleet's subscription, which no other fleet is ever served at. Anywhere
|
|
2471
|
+
// else the destination has one reading for whoever it serves, so a success
|
|
2472
|
+
// by the fleet served there settles the roll held against it.
|
|
2473
|
+
const settledByAnyServed = owed.provider != null
|
|
2474
|
+
&& !(isSubscriptionAccount(account) && providerOf(account) === owed.provider);
|
|
2475
|
+
if (owed.provider !== provider && !settledByAnyServed) return;
|
|
2476
|
+
obs.unescaped = dropHeld(obs.unescaped, owed.idx);
|
|
2336
2477
|
}
|
|
2337
2478
|
|
|
2338
2479
|
/** The reading for the sticky CURRENT account, taken at the top of a selection
|
|
@@ -2341,7 +2482,7 @@ export class AccountManager {
|
|
|
2341
2482
|
if (!this.expiryRouting.enabled || !this.expiryRouting.preempt) return;
|
|
2342
2483
|
const resting = this.accounts[this.currentIndex];
|
|
2343
2484
|
if (!resting || exclude?.has(resting.index)) return;
|
|
2344
|
-
this._currentObs ??=
|
|
2485
|
+
this._currentObs ??= newObservation();
|
|
2345
2486
|
this._restOn(this._currentObs, resting, model);
|
|
2346
2487
|
}
|
|
2347
2488
|
|
|
@@ -2409,7 +2550,10 @@ export class AccountManager {
|
|
|
2409
2550
|
console.log(`[TeamClaude] Account "${account.name}" rolled over its ${window} window ${this._heldRolloverReason(reason)}`);
|
|
2410
2551
|
}
|
|
2411
2552
|
|
|
2412
|
-
/**
|
|
2553
|
+
/**
|
|
2554
|
+
* The half of the held-rollover line that says which of the two cases it is.
|
|
2555
|
+
* @param {'none-eligible'|'ranks-best'} reason
|
|
2556
|
+
*/
|
|
2413
2557
|
_heldRolloverReason(reason) {
|
|
2414
2558
|
switch (reason) {
|
|
2415
2559
|
case 'none-eligible': return 'but no eligible account can take that traffic — still routing there';
|
|
@@ -2445,8 +2589,11 @@ export class AccountManager {
|
|
|
2445
2589
|
* are recorded whether or not they currently bind, because one that starts
|
|
2446
2590
|
* binding later would otherwise be first-sighted on the very request that
|
|
2447
2591
|
* should have caught it rolling.
|
|
2592
|
+
*
|
|
2593
|
+
* @returns {Object<string, number>}
|
|
2448
2594
|
*/
|
|
2449
2595
|
_accountWindows(account) {
|
|
2596
|
+
/** @type {Object<string, number>} */
|
|
2450
2597
|
const out = {};
|
|
2451
2598
|
for (const bucket of this._windowKeys()) {
|
|
2452
2599
|
const window = this._windowForBucket(account, bucket);
|
|
@@ -2768,16 +2915,25 @@ export class AccountManager {
|
|
|
2768
2915
|
for (const account of this.accounts) this._clearExpiredQuotas(account);
|
|
2769
2916
|
}
|
|
2770
2917
|
|
|
2771
|
-
refreshExpiredQuotas(model = null, exclude = null) {
|
|
2918
|
+
refreshExpiredQuotas(model = null, exclude = null, advisorModel = null) {
|
|
2772
2919
|
let changed = false;
|
|
2773
2920
|
// Gated here rather than at the switch call, because the pending flag is read
|
|
2774
2921
|
// against it too: with the feature off every reset is consumed on sight.
|
|
2775
2922
|
const scope = this.expiryRouting.enabled ? exclude : null;
|
|
2923
|
+
// Selection admits an advisor request on BOTH its models, so the switch is
|
|
2924
|
+
// drawn on both too. Never more strictly than the pass that decides the
|
|
2925
|
+
// request, though: where no reachable account can serve the advisor model,
|
|
2926
|
+
// selection routes on the main model alone and the event is spent on that
|
|
2927
|
+
// basis. Ordered so that this scan reads no account with the knob off or
|
|
2928
|
+
// with no advisor model in hand: reading one clears its expired windows.
|
|
2929
|
+
const adv = (scope != null && advisorModel
|
|
2930
|
+
&& this.accounts.some(a => !scope.has(a.index) && this._isAvailable(a, model, advisorModel)))
|
|
2931
|
+
? advisorModel : null;
|
|
2776
2932
|
// THE EXCLUSION SET IS WHAT MARKS A CALL AS A REQUEST'S: the request path
|
|
2777
2933
|
// hands one on every call, empty included, and the TUI loop, getQuotaSummary
|
|
2778
2934
|
// and selectActiveAccount hand none. The tests below are the switch's own.
|
|
2779
2935
|
const canRouteTo = account => account != null
|
|
2780
|
-
&& !scope.has(account.index) && this._isAvailable(account, model);
|
|
2936
|
+
&& !scope.has(account.index) && this._isAvailable(account, model, adv);
|
|
2781
2937
|
// The cursor's account is one end of every comparison the switch makes, so a
|
|
2782
2938
|
// request that cannot be sent there settles nothing and leaves the event too.
|
|
2783
2939
|
const spends = !this.expiryRouting.enabled
|
|
@@ -2800,12 +2956,12 @@ export class AccountManager {
|
|
|
2800
2956
|
sessionReset.push(account);
|
|
2801
2957
|
}
|
|
2802
2958
|
}
|
|
2803
|
-
//
|
|
2804
|
-
//
|
|
2805
|
-
//
|
|
2806
|
-
//
|
|
2959
|
+
// Neither model reaches the switch unless the feature is on. Handed none,
|
|
2960
|
+
// the switch runs the same `_isAvailable(acc)` the knob-off path runs; a
|
|
2961
|
+
// model-scoped filter there is a live routing change on the path that
|
|
2962
|
+
// promises none.
|
|
2807
2963
|
if (sessionReset.length) {
|
|
2808
|
-
this._switchOnSessionReset(sessionReset, this.expiryRouting.enabled ? model : null, scope);
|
|
2964
|
+
this._switchOnSessionReset(sessionReset, this.expiryRouting.enabled ? model : null, scope, adv);
|
|
2809
2965
|
}
|
|
2810
2966
|
return changed;
|
|
2811
2967
|
}
|
|
@@ -2815,7 +2971,7 @@ export class AccountManager {
|
|
|
2815
2971
|
* weekly limit expires soonest — but only if that is sooner than the current
|
|
2816
2972
|
* account's weekly limit and the account still has weekly quota to spend.
|
|
2817
2973
|
*/
|
|
2818
|
-
_switchOnSessionReset(candidates, model = null, exclude = null) {
|
|
2974
|
+
_switchOnSessionReset(candidates, model = null, exclude = null, advisorModel = null) {
|
|
2819
2975
|
const current = this.accounts[this.currentIndex];
|
|
2820
2976
|
// Need a known weekly reset on the current account to compare against;
|
|
2821
2977
|
// if it is unknown we are still probing it, so leave it alone. Read through
|
|
@@ -2835,12 +2991,13 @@ export class AccountManager {
|
|
|
2835
2991
|
// goes. Kept here as well as in refreshExpiredQuotas, so a caller that does
|
|
2836
2992
|
// not filter first gets the same answer.
|
|
2837
2993
|
if (exclude?.has(acc.index)) continue;
|
|
2838
|
-
//
|
|
2839
|
-
//
|
|
2840
|
-
//
|
|
2841
|
-
//
|
|
2842
|
-
//
|
|
2843
|
-
|
|
2994
|
+
// Scoped to the models this switch is handed: the caller drops the advisor
|
|
2995
|
+
// model when no reachable account serves it, so the switch is never
|
|
2996
|
+
// stricter than the pass that decides the request. An account whose Fable
|
|
2997
|
+
// weekly is spent is fully usable for Opus, and a switch that ignores
|
|
2998
|
+
// either model installs one the request's own picker refuses. With the
|
|
2999
|
+
// feature off this line alone keeps an account whose weekly is spent out.
|
|
3000
|
+
if (!this._isAvailable(acc, model, advisorModel)) continue; // enough session & weekly quota left
|
|
2844
3001
|
// Don't demote to a lower-priority (higher value) account on a reset.
|
|
2845
3002
|
if ((acc.priority || 0) > (current.priority || 0)) continue;
|
|
2846
3003
|
const weekly = this._rankingReset(acc, model);
|
|
@@ -2868,12 +3025,27 @@ export class AccountManager {
|
|
|
2868
3025
|
|| (mine === theirs && this._rankedReset(acc, model) < this._rankedReset(best, model))) best = acc;
|
|
2869
3026
|
}
|
|
2870
3027
|
|
|
2871
|
-
// TWO guards, different properties, neither implying the other
|
|
2872
|
-
//
|
|
2873
|
-
//
|
|
2874
|
-
//
|
|
2875
|
-
//
|
|
2876
|
-
|
|
3028
|
+
// TWO guards, different properties, neither implying the other, and NOT
|
|
3029
|
+
// drawn over the same fleet. The rank comparison weighs `best` against the
|
|
3030
|
+
// account the cursor is on, both of which THIS REQUEST reached, so it is
|
|
3031
|
+
// measured on what this request can be sent to; and it says this switch
|
|
3032
|
+
// leaves no strictly better account behind, which membership does not claim
|
|
3033
|
+
// once a lower tier passes through unbanded. Band membership says the
|
|
3034
|
+
// account is worth spending at all, and the only thing the answer does here
|
|
3035
|
+
// is move the CURSOR, which serves every request behind this one. So it is
|
|
3036
|
+
// drawn over the fleet that cursor serves: the request's provider partition,
|
|
3037
|
+
// narrowed by the model filter _bandedCandidates already applies, and never
|
|
3038
|
+
// by the accounts this one attempt has tried. An account a 429 pushed this
|
|
3039
|
+
// request off holds whatever band it holds for everybody else.
|
|
3040
|
+
// Both models, because that filter is per-account and reads the
|
|
3041
|
+
// already-degraded argument: the band re-evaluates no degradation, so the
|
|
3042
|
+
// advisor term narrows the set and re-imposes nothing the caller dropped.
|
|
3043
|
+
// The provider comes from the walk in progress, as it does in _cursorKey; a
|
|
3044
|
+
// direct caller has none and means the default fleet. Kept inside the `&&`
|
|
3045
|
+
// rather than hoisted, so the knob-off path evaluates none of it.
|
|
3046
|
+
if (this.expiryRouting.enabled && !this._bandedCandidates(
|
|
3047
|
+
this._excludeOtherProviders(null, this._selectingProvider || DEFAULT_PROVIDER),
|
|
3048
|
+
model, advisorModel).includes(best)) return;
|
|
2877
3049
|
// Strictly worse than what we are on: stay. Equal keeps the reset tiebreak
|
|
2878
3050
|
// that got us here, and with expiry routing off every rank is absent and
|
|
2879
3051
|
// equal, so this cannot fire at all.
|
|
@@ -3455,6 +3627,36 @@ export class AccountManager {
|
|
|
3455
3627
|
}
|
|
3456
3628
|
}
|
|
3457
3629
|
|
|
3630
|
+
/** Apply a read-only Codex `/wham/usage` reading without counting traffic. */
|
|
3631
|
+
applyCodexUsageData(accountIndex, usage) {
|
|
3632
|
+
const account = this.accounts[accountIndex];
|
|
3633
|
+
if (!account || !usage || usage.error) return;
|
|
3634
|
+
const q = account.quota;
|
|
3635
|
+
if (usage.fiveHour) {
|
|
3636
|
+
q.unified5h = usage.fiveHour.utilization;
|
|
3637
|
+
q.unified5hReset = usage.fiveHour.resetAt ?? null;
|
|
3638
|
+
}
|
|
3639
|
+
if (usage.sevenDay) {
|
|
3640
|
+
q.unified7d = usage.sevenDay.utilization;
|
|
3641
|
+
q.unified7dReset = usage.sevenDay.resetAt ?? null;
|
|
3642
|
+
}
|
|
3643
|
+
if (usage.planType) q.planType = safeLine(usage.planType, 64);
|
|
3644
|
+
if (Array.isArray(usage.modelBuckets)) {
|
|
3645
|
+
q.codexModelBuckets = Object.fromEntries(usage.modelBuckets.slice(0, MAX_CODEX_MODEL_BUCKETS)
|
|
3646
|
+
.filter(bucket => bucket?.slug)
|
|
3647
|
+
.map(bucket => [safeLine(bucket.slug, 64), {
|
|
3648
|
+
name: safeLine(bucket.name || bucket.slug, 64),
|
|
3649
|
+
utilization: bucket.utilization,
|
|
3650
|
+
resetAt: bucket.resetAt ?? null,
|
|
3651
|
+
seenAt: Date.now(),
|
|
3652
|
+
}]));
|
|
3653
|
+
}
|
|
3654
|
+
if (account.probing && q.unified7dReset != null) {
|
|
3655
|
+
account.probing = false;
|
|
3656
|
+
account.requalify = true;
|
|
3657
|
+
}
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3458
3660
|
/** Apply subscription metadata learned from the OAuth profile endpoint. */
|
|
3459
3661
|
applyProfileData(accountIndex, profile) {
|
|
3460
3662
|
const account = this.accounts[accountIndex];
|
|
@@ -3634,6 +3836,8 @@ export class AccountManager {
|
|
|
3634
3836
|
|
|
3635
3837
|
/**
|
|
3636
3838
|
* Update a specific account's OAuth tokens (e.g. after intercepting a token refresh).
|
|
3839
|
+
* @param {number} accountIndex
|
|
3840
|
+
* @param {{ accessToken?: string, refreshToken?: string, expiresAt?: number }} tokens
|
|
3637
3841
|
*/
|
|
3638
3842
|
updateAccountTokens(accountIndex, { accessToken, refreshToken, expiresAt }) {
|
|
3639
3843
|
const account = this.accounts[accountIndex];
|
|
@@ -3665,6 +3869,7 @@ export class AccountManager {
|
|
|
3665
3869
|
*/
|
|
3666
3870
|
removeAccount(index) {
|
|
3667
3871
|
if (index < 0 || index >= this.accounts.length) return;
|
|
3872
|
+
const before = this.accounts[this.currentIndex] ?? null;
|
|
3668
3873
|
this.accounts.splice(index, 1);
|
|
3669
3874
|
this.accounts.forEach((a, i) => a.index = i);
|
|
3670
3875
|
if (this.currentIndex >= this.accounts.length) {
|
|
@@ -3695,15 +3900,26 @@ export class AccountManager {
|
|
|
3695
3900
|
// against whichever account inherited the slot; its held roll the same.
|
|
3696
3901
|
const moved = this._currentObs?.idx == null ? null : remap(this._currentObs.idx);
|
|
3697
3902
|
if (this._currentObs) {
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3903
|
+
// Renumbering is nobody's success, and it names the same account by a new
|
|
3904
|
+
// index, so everything but the two indices survives the shift: the stamp
|
|
3905
|
+
// saying whose reading this is, and the gen a request already in flight
|
|
3906
|
+
// against that account still confirms its stay on. The account that went
|
|
3907
|
+
// away is the only one whose roll the removal settles, so what it was
|
|
3908
|
+
// holding for the others moves onto a fresh reading. That reading names no
|
|
3909
|
+
// account and has no windows, which is evidence about nobody, while every
|
|
3910
|
+
// hold under it keeps its own stamp and gen.
|
|
3911
|
+
const held = remapHeld(this._currentObs.unescaped, remap);
|
|
3912
|
+
this._currentObs = moved != null
|
|
3913
|
+
? { ...this._currentObs, idx: moved, unescaped: held }
|
|
3914
|
+
: held ? { ...newObservation(), unescaped: held } : null;
|
|
3915
|
+
}
|
|
3916
|
+
// A removal that takes the account the cursor rests on leaves the cursor at
|
|
3917
|
+
// a neighbour, with a reading the rebuild left nameless. A reading the
|
|
3918
|
+
// removal merely renumbered keeps its own account, so it is offered
|
|
3919
|
+
// nothing.
|
|
3920
|
+
const landed = this.accounts[this.currentIndex] ?? null;
|
|
3921
|
+
if (landed && landed !== before && this._currentObs && this._currentObs.idx == null) {
|
|
3922
|
+
this._firstSightOn(this._currentObs, landed);
|
|
3707
3923
|
}
|
|
3708
3924
|
// A throttle key names an account by index, so the shift would point a live
|
|
3709
3925
|
// entry at a different account. Not worth renumbering: the entries expire in
|