maxpool 1.5.86 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/account-manager.js +278 -35
- package/src/config.js +21 -1
- package/src/peak-window.js +177 -0
- package/src/server.js +24 -0
- package/src/tui.js +39 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "maxpool",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Multi-account Claude Code proxy with adaptive, rate-aware load balancing across Claude accounts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -44,4 +44,4 @@
|
|
|
44
44
|
"eslint": "^9.39.5",
|
|
45
45
|
"git-cliff": "2.13.1"
|
|
46
46
|
}
|
|
47
|
-
}
|
|
47
|
+
}
|
package/src/account-manager.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { refreshAccessToken, isTokenExpiringSoon, modelFamily, tokenFingerprint } from './oauth.js';
|
|
2
|
+
import { peakWindowState, DEFAULT_PEAK_CAP } from './peak-window.js';
|
|
2
3
|
|
|
3
4
|
// Bounded re-poll hold for an account blocked ONLY by a transient, self-clearing
|
|
4
5
|
// condition whose exact recovery time is unknown: (a) a weekly-critical account
|
|
@@ -194,6 +195,14 @@ const MAX_FAILED_PROBES = 4;
|
|
|
194
195
|
// day rather than never.
|
|
195
196
|
const PROBE_FAILURE_ALERT_AT = 20;
|
|
196
197
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
198
|
+
// Peak-hour governance (2026-08-18). A peak TIER, added to _effectivePriority after the
|
|
199
|
+
// mode layer: tier 1 = provider inside its peak window (de-prefer), tier 2 = also over
|
|
200
|
+
// its peakCap (last-resort). A STRIDE, not an additive constant, so a peak account ranks
|
|
201
|
+
// below every non-peak account BY CONSTRUCTION even against a hand-set `priority: 500`,
|
|
202
|
+
// while intra-tier ordering (the mode layer) is preserved. Not Infinity — the selector's
|
|
203
|
+
// `priority < bestPriority` is false for Infinity vs Infinity and an all-peaked pool
|
|
204
|
+
// would select nothing.
|
|
205
|
+
const PEAK_TIER_STRIDE = 1_000_000;
|
|
197
206
|
const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
|
|
198
207
|
|
|
199
208
|
// Quota fields that survive a restart: utilization levels and their reset
|
|
@@ -322,6 +331,7 @@ export class AccountManager {
|
|
|
322
331
|
this.routingMode = 'automatic';
|
|
323
332
|
this.preferredAccountName = null;
|
|
324
333
|
this.sessionBindings = new Map();
|
|
334
|
+
this._peakCache = null; // per-UTC-minute peak-window memo (see _peakStateFor)
|
|
325
335
|
this.sessionPolicies = new Map();
|
|
326
336
|
this.upstreamThrottle = {
|
|
327
337
|
until: null,
|
|
@@ -362,10 +372,10 @@ export class AccountManager {
|
|
|
362
372
|
*/
|
|
363
373
|
getActiveAccount(requestInfo = {}, excludedIndexes = new Set()) {
|
|
364
374
|
this.refreshExpiredQuotas();
|
|
365
|
-
return this._selectNext(requestInfo, excludedIndexes);
|
|
375
|
+
return this._selectNext(requestInfo, excludedIndexes, requestInfo.now);
|
|
366
376
|
}
|
|
367
377
|
|
|
368
|
-
nextRetryForRequest(requestInfo = {}, excludedIndexes = new Set()) {
|
|
378
|
+
nextRetryForRequest(requestInfo = {}, excludedIndexes = new Set(), now = Date.now()) {
|
|
369
379
|
this.refreshExpiredQuotas();
|
|
370
380
|
const upstreamRetry = this._upstreamThrottleRetry();
|
|
371
381
|
if (upstreamRetry && !this._hasAvailableProvider(requestInfo, excludedIndexes)) {
|
|
@@ -434,7 +444,7 @@ export class AccountManager {
|
|
|
434
444
|
// finite hold. It must NEVER contribute the WEEKLY reset (days) — that is the
|
|
435
445
|
// multi-day-hang the bounded path fences off; see the !fairnessOnlyBlock
|
|
436
446
|
// guards on the weekly branches below.
|
|
437
|
-
if (!fairnessOnlyBlock && this._isAvailable(account, { allowWeeklyReserve: true, model: requestInfo.model })) {
|
|
447
|
+
if (!fairnessOnlyBlock && this._isAvailable(account, { allowWeeklyReserve: true, model: requestInfo.model, now })) {
|
|
438
448
|
return {
|
|
439
449
|
available: true,
|
|
440
450
|
retryAfterMs: 0,
|
|
@@ -443,13 +453,13 @@ export class AccountManager {
|
|
|
443
453
|
matchingRoutes,
|
|
444
454
|
};
|
|
445
455
|
}
|
|
446
|
-
if (fairnessOnlyBlock && this._isAvailable(account, { allowWeeklyReserve: true, allowWeeklyCritical: true, model: requestInfo.model })) {
|
|
456
|
+
if (fairnessOnlyBlock && this._isAvailable(account, { allowWeeklyReserve: true, allowWeeklyCritical: true, model: requestInfo.model, now })) {
|
|
447
457
|
soonestBoundedHold = Math.min(soonestBoundedHold, BOUNDED_REPOLL_HOLD_MS);
|
|
448
458
|
if (!boundedHoldCause) boundedHoldCause = 'queued_behind_fairness';
|
|
449
459
|
continue;
|
|
450
460
|
}
|
|
451
461
|
|
|
452
|
-
const retry = this._retryInfo(account, requestInfo.model);
|
|
462
|
+
const retry = this._retryInfo(account, requestInfo.model, now);
|
|
453
463
|
note(retry.cause);
|
|
454
464
|
if (!fairnessOnlyBlock && retry.weeklyCritical && this._isAvailable(account, { allowWeeklyReserve: true, allowWeeklyCritical: true, model: requestInfo.model })) {
|
|
455
465
|
return {
|
|
@@ -464,7 +474,7 @@ export class AccountManager {
|
|
|
464
474
|
// A known, soon short-term reset (5h cap / rate-limit / cooldown) — even on
|
|
465
475
|
// a weekly-critical account, this is the REAL near-term recovery time, so
|
|
466
476
|
// it holds here with the true cause rather than the distant weekly reset.
|
|
467
|
-
const ms = retry.retryAt -
|
|
477
|
+
const ms = retry.retryAt - now;
|
|
468
478
|
if (ms < soonestTemporary) {
|
|
469
479
|
soonestTemporary = ms;
|
|
470
480
|
temporaryCause = retry.cause;
|
|
@@ -488,7 +498,7 @@ export class AccountManager {
|
|
|
488
498
|
// avoid (a weekly-exhausted gated fleet stays terminal → honest error,
|
|
489
499
|
// matching the no-newcomer-vs-queue distinction). Only its short-term reset
|
|
490
500
|
// (above) or the bounded hold may fire.
|
|
491
|
-
const ms = retry.retryAt -
|
|
501
|
+
const ms = retry.retryAt - now;
|
|
492
502
|
if (ms < soonestWeekly) soonestWeekly = ms;
|
|
493
503
|
} else if (!fairnessOnlyBlock && retry.cause === 'weekly_exhausted' && !retry.retryAt) {
|
|
494
504
|
// Weekly-capped but we haven't learned the reset time (cold start /
|
|
@@ -540,7 +550,7 @@ export class AccountManager {
|
|
|
540
550
|
};
|
|
541
551
|
}
|
|
542
552
|
|
|
543
|
-
hasAvailableRoute(requestInfo = {}, excludedIndexes = new Set()) {
|
|
553
|
+
hasAvailableRoute(requestInfo = {}, excludedIndexes = new Set(), now = Date.now()) {
|
|
544
554
|
this.refreshExpiredQuotas();
|
|
545
555
|
const profile = requestInfo.profile || 'claude';
|
|
546
556
|
// Route-EXISTENCE check only (order-independent `.some`): unlike the acquire
|
|
@@ -562,7 +572,7 @@ export class AccountManager {
|
|
|
562
572
|
return weeklyPasses.some(options => this.accounts.some(account => {
|
|
563
573
|
if (excludedIndexes.has(account.index)) return false;
|
|
564
574
|
if (!this._matchesRequest(account, profile, requestInfo)) return false;
|
|
565
|
-
return this._isAvailable(account, { ...options, model: requestInfo.model });
|
|
575
|
+
return this._isAvailable(account, { ...options, model: requestInfo.model, now });
|
|
566
576
|
}));
|
|
567
577
|
}
|
|
568
578
|
|
|
@@ -580,7 +590,9 @@ export class AccountManager {
|
|
|
580
590
|
const weight = Math.max(1, Number(requestInfo.weight) || 1);
|
|
581
591
|
const upstreamThrottleProbe = account.type !== 'provider' && this._claimUpstreamThrottleProbe();
|
|
582
592
|
if (requestInfo.sessionKey) {
|
|
583
|
-
|
|
593
|
+
// Same clock the selection above used, so the peak-failover guard can never
|
|
594
|
+
// disagree with the peak tier that caused the move (defaults to real time).
|
|
595
|
+
this._bindSession(requestInfo.sessionKey, account, requestInfo.model, requestInfo.now);
|
|
584
596
|
}
|
|
585
597
|
account.inFlight++;
|
|
586
598
|
account.activeWeight += weight;
|
|
@@ -715,7 +727,7 @@ export class AccountManager {
|
|
|
715
727
|
_isAvailable(account, options = {}) {
|
|
716
728
|
if (!account) return false;
|
|
717
729
|
if (!account.enabled) return false;
|
|
718
|
-
const now = Date.now();
|
|
730
|
+
const now = options.now ?? Date.now();
|
|
719
731
|
|
|
720
732
|
// Check rate limit expiry
|
|
721
733
|
if (account.status === 'throttled' && account.rateLimitedUntil) {
|
|
@@ -765,6 +777,12 @@ export class AccountManager {
|
|
|
765
777
|
// by request-path callers; model-agnostic call sites skip this gate.
|
|
766
778
|
if (options.model && this._scopedExhausted(account, options.model)) return false;
|
|
767
779
|
|
|
780
|
+
// PEAK HARD BAR (peakCap: 0.0 — "never during peak"). The ONLY peak gate on
|
|
781
|
+
// ELIGIBILITY; the soft cap is a priority tier and can never strand a request.
|
|
782
|
+
// No allow* opt-out exists on purpose: zero means zero. The request parks on the
|
|
783
|
+
// finite hold the _retryInfo peak branch hands the oracle — it must never die.
|
|
784
|
+
if (this._peakHardBarred(account, now)) return false;
|
|
785
|
+
|
|
768
786
|
return true;
|
|
769
787
|
}
|
|
770
788
|
|
|
@@ -1251,7 +1269,7 @@ export class AccountManager {
|
|
|
1251
1269
|
* near-reset session (whose pace stays normal) never triggers → no churn. Does
|
|
1252
1270
|
* NOT flip the instant a request migrates (unlike live in-flight, left to the
|
|
1253
1271
|
* score loop), so a healthy bound account never ping-pongs. */
|
|
1254
|
-
_isBoundAccountHot(account) {
|
|
1272
|
+
_isBoundAccountHot(account, now = Date.now()) {
|
|
1255
1273
|
return this._isSessionQuotaUnavailable(account)
|
|
1256
1274
|
|| ['reserve', 'critical', 'exhausted'].includes(this._weeklyPaceState(account))
|
|
1257
1275
|
// SESSION-window pressure counts too. _isSessionQuotaUnavailable only fires at
|
|
@@ -1263,7 +1281,11 @@ export class AccountManager {
|
|
|
1263
1281
|
// there is still headroom, not at the cliff edge. _shouldRebalanceBoundSession
|
|
1264
1282
|
// still requires a clearly-cheaper, strictly-healthier target, so a hot account
|
|
1265
1283
|
// with no better alternative keeps its sessions — this only opens the question.
|
|
1266
|
-
|| this._sessionWindowUsage(account) >= this.scheduler.weeklySoftThreshold
|
|
1284
|
+
|| this._sessionWindowUsage(account) >= this.scheduler.weeklySoftThreshold
|
|
1285
|
+
// PEAK (2026-08-18): a provider inside its peak window is "hot" in the COST
|
|
1286
|
+
// sense — staying bound burns at 2x for hours. Peak-only ⇒ inert off-peak
|
|
1287
|
+
// (SC2). This only OPENS the question; the rebalance gates still decide.
|
|
1288
|
+
|| this._peakTier(account, now) > 0;
|
|
1267
1289
|
}
|
|
1268
1290
|
|
|
1269
1291
|
/** Fraction of the SESSION (5h) window consumed, across both quota shapes.
|
|
@@ -1320,7 +1342,7 @@ export class AccountManager {
|
|
|
1320
1342
|
if (!this._isWarming(account, now)) continue;
|
|
1321
1343
|
if (!this._matchesRequest(account, profile, requestInfo)) continue;
|
|
1322
1344
|
// Genuinely-healthy target only (same bar as the hot-rebalance candidate scan).
|
|
1323
|
-
if (!this._isAvailable(account, { allowWeeklyReserve: false, allowWeeklyCritical: false, model: requestInfo.model })) continue;
|
|
1345
|
+
if (!this._isAvailable(account, { allowWeeklyReserve: false, allowWeeklyCritical: false, model: requestInfo.model, now })) continue;
|
|
1324
1346
|
const score = this._scoreAccount(account, requestInfo, ctx);
|
|
1325
1347
|
if (score < bestScore) {
|
|
1326
1348
|
bestScore = score;
|
|
@@ -1330,10 +1352,10 @@ export class AccountManager {
|
|
|
1330
1352
|
return best;
|
|
1331
1353
|
}
|
|
1332
1354
|
|
|
1333
|
-
_shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx) {
|
|
1355
|
+
_shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx, now = Date.now()) {
|
|
1334
1356
|
if (!this._migrationSafeForRequest(requestInfo)) return false;
|
|
1335
1357
|
if (requestInfo.queueTicket || requestInfo.queueAdmitted) return false;
|
|
1336
|
-
if (!this._isBoundAccountHot(bound)) return false;
|
|
1358
|
+
if (!this._isBoundAccountHot(bound, now)) return false;
|
|
1337
1359
|
|
|
1338
1360
|
const boundScore = this._scoreAccount(bound, requestInfo, scoringCtx);
|
|
1339
1361
|
// Tier guard on the SAME axis as the trigger (pace, not raw). If the trigger is
|
|
@@ -1357,8 +1379,11 @@ export class AccountManager {
|
|
|
1357
1379
|
// remove assuming the fall-through protects signed thinking — it does not.
|
|
1358
1380
|
if (requestInfo.requiresAnthropicThinkingIntegrity === true && account.type === 'provider') continue;
|
|
1359
1381
|
if (!this._matchesRequest(account, profile, requestInfo)) continue;
|
|
1382
|
+
// PEAK (2026-08-18): never migrate ONTO a peak-suppressed account — the move
|
|
1383
|
+
// would immediately be a 2x-burn destination. Peak-only ⇒ inert off-peak.
|
|
1384
|
+
if (this._peakTier(account, now) > 0) continue;
|
|
1360
1385
|
// Genuinely-healthy alternatives only (normal/soft/unknown weekly + model headroom).
|
|
1361
|
-
if (!this._isAvailable(account, { allowWeeklyReserve: false, allowWeeklyCritical: false, model: requestInfo.model })) continue;
|
|
1386
|
+
if (!this._isAvailable(account, { allowWeeklyReserve: false, allowWeeklyCritical: false, model: requestInfo.model, now })) continue;
|
|
1362
1387
|
const score = this._scoreAccount(account, requestInfo, scoringCtx);
|
|
1363
1388
|
if (score < bestScore) {
|
|
1364
1389
|
bestScore = score;
|
|
@@ -1391,6 +1416,14 @@ export class AccountManager {
|
|
|
1391
1416
|
// and preserving the last of a 5h window beats concurrency spread.
|
|
1392
1417
|
if (this._sessionWindowUsage(bound) >= this.scheduler.weeklyReserveThreshold) return true;
|
|
1393
1418
|
|
|
1419
|
+
// PEAK (2026-08-18) — absolute escape. The score margins below are calibrated for
|
|
1420
|
+
// LOAD relief and are UNSATISFIABLE for a healthy peak account: peak tier 1/2 with
|
|
1421
|
+
// weekly pace `normal` means bestTier < boundTier can never hold, so without this
|
|
1422
|
+
// escape the exact sessions causing the 2x spend would never move. Flap-stable:
|
|
1423
|
+
// clause (b) guarantees every candidate is non-peak, and within the window nothing
|
|
1424
|
+
// pulls the session back. Peak-only ⇒ inert off-peak (SC2).
|
|
1425
|
+
if (this._peakTier(bound, now) > 0) return true;
|
|
1426
|
+
|
|
1394
1427
|
// Otherwise the trigger was PACE-only on a RAW-healthy account — a fast-burner
|
|
1395
1428
|
// that still has real absolute headroom (RAW soft but pace reserve/critical,
|
|
1396
1429
|
// e.g. 79% used resetting in ~3.5d). Keep the conservative gate so it isn't
|
|
@@ -1402,8 +1435,7 @@ export class AccountManager {
|
|
|
1402
1435
|
&& bestTier < boundTier;
|
|
1403
1436
|
}
|
|
1404
1437
|
|
|
1405
|
-
_retryInfo(account, model = null) {
|
|
1406
|
-
const now = Date.now();
|
|
1438
|
+
_retryInfo(account, model = null, now = Date.now()) {
|
|
1407
1439
|
const q = account.quota || {};
|
|
1408
1440
|
|
|
1409
1441
|
// TERMINAL (non-recoverable) states FIRST — before any weekly/short-term
|
|
@@ -1435,10 +1467,54 @@ export class AccountManager {
|
|
|
1435
1467
|
// account reports its REAL near-term recovery, not the distant weekly reset.
|
|
1436
1468
|
const shortTerm = this._shortTermRetry(account, now, q);
|
|
1437
1469
|
|
|
1470
|
+
// PEAK HARD BAR (peakCap 0.0) — barred until a KNOWN wall-clock time, so the oracle
|
|
1471
|
+
// must hold FINITE. Placement is pinned: AFTER the terminal checks above, BEFORE
|
|
1472
|
+
// the weekly branches. A LATE placement falls through to the terminal
|
|
1473
|
+
// `{cause:'unavailable'}` return, contributes to NO recovery bucket, collapses
|
|
1474
|
+
// nextRetryForRequest to Infinity, and server.js error-fasts — a session KILL at
|
|
1475
|
+
// 06:00 UTC. Precedence: weekly_exhausted (below) outranks peak when the weekly
|
|
1476
|
+
// reset dominates; here the max() merge with shortTerm means BOTH must clear.
|
|
1477
|
+
if (this._peakHardBarred(account, now)) {
|
|
1478
|
+
const { endsAt } = this._peakStateFor(account.provider, now);
|
|
1479
|
+
// Only a weekly reset that is ACTUALLY BLOCKING may dominate. providerWkReset is
|
|
1480
|
+
// set on every successfully-PROBED provider account — i.e. the steady state, not
|
|
1481
|
+
// an exceptional one — so reading it unconditionally made a HEALTHY account at 5%
|
|
1482
|
+
// weekly report a 72h "weekly_exhausted" hold instead of the real 3h peak hold.
|
|
1483
|
+
// That is the multi-day-hang class this branch exists to prevent, inverted.
|
|
1484
|
+
// (Caught by red-team probe 2026-08-18; the D5b control passed only because its
|
|
1485
|
+
// fixture left quota={}, a state a probed account is never in.)
|
|
1486
|
+
const weeklyBlocked = weeklyState === 'exhausted';
|
|
1487
|
+
const weeklyReset = weeklyBlocked ? (q.providerWkReset || q.unified7dReset || 0) : 0;
|
|
1488
|
+
// CAUSE follows the DOMINANT blocker, not merely the branch we are in. A
|
|
1489
|
+
// peak-barred account that is ALSO weekly-exhausted clears in DAYS, not at the
|
|
1490
|
+
// window end — labelling that `peak_window` would tell the user "peak ends in
|
|
1491
|
+
// 3h" while the real wait is 3 days (the misleading-message class this codebase
|
|
1492
|
+
// has been bitten by before). The TIME was always right via the max-merge; this
|
|
1493
|
+
// makes the LABEL agree with it.
|
|
1494
|
+
const retryAt = Math.max(endsAt || 0, shortTerm?.retryAt || 0, weeklyReset);
|
|
1495
|
+
const weeklyDominates = weeklyReset > 0 && weeklyReset >= (endsAt || 0);
|
|
1496
|
+
return {
|
|
1497
|
+
cause: weeklyDominates ? 'weekly_exhausted' : 'peak_window',
|
|
1498
|
+
retryAt,
|
|
1499
|
+
queueable: true,
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1438
1502
|
if (weeklyState === 'exhausted') {
|
|
1439
1503
|
// Hard block: only a weekly reset unblocks it — a sooner short-term clear
|
|
1440
1504
|
// does not help — so key the hold on the weekly reset.
|
|
1441
|
-
|
|
1505
|
+
//
|
|
1506
|
+
// Read the PROVIDER reset too. `unified7dReset` is an Anthropic-only field;
|
|
1507
|
+
// a GLM/Kimi account stores its weekly reset in `providerWkReset`
|
|
1508
|
+
// (applyProviderUsage). Reading only the Anthropic field returned retryAt:null
|
|
1509
|
+
// for every weekly-exhausted PROVIDER, which lands on `weeklyUnknownReset` in
|
|
1510
|
+
// nextRetryForRequest → retryAfterMs: Infinity → server.js error-fasts → the
|
|
1511
|
+
// live session is KILLED, despite the real reset time being known all along.
|
|
1512
|
+
// Reproduced 2026-08-18 with providerWk=0.9995 + a known providerWkReset.
|
|
1513
|
+
return {
|
|
1514
|
+
cause: 'weekly_exhausted',
|
|
1515
|
+
retryAt: q.unified7dReset || q.providerWkReset || null,
|
|
1516
|
+
queueable: false,
|
|
1517
|
+
};
|
|
1442
1518
|
}
|
|
1443
1519
|
|
|
1444
1520
|
if (weeklyState === 'critical') {
|
|
@@ -1556,7 +1632,9 @@ export class AccountManager {
|
|
|
1556
1632
|
return null;
|
|
1557
1633
|
}
|
|
1558
1634
|
|
|
1559
|
-
|
|
1635
|
+
// `now` is the injected clock for every time-varying predicate on the selection
|
|
1636
|
+
// path (peak tier, sticky escape). Defaults to the real clock; tests pass it.
|
|
1637
|
+
_selectNext(requestInfo = {}, excludedIndexes = new Set(), now = Date.now()) {
|
|
1560
1638
|
// Adaptive least-loaded balancing: spread requests across every healthy
|
|
1561
1639
|
// account immediately, and let live load, quota pressure, and recent errors
|
|
1562
1640
|
// push traffic away from weaker accounts.
|
|
@@ -1576,7 +1654,7 @@ export class AccountManager {
|
|
|
1576
1654
|
const pinned = this.accounts.find(a => a.name === requestInfo.pinnedAccountName);
|
|
1577
1655
|
if (pinned && !excludedIndexes.has(pinned.index)
|
|
1578
1656
|
&& this._matchesRequest(pinned, profile, requestInfo)
|
|
1579
|
-
&& this._isAvailable(pinned, { allowWeeklyReserve: true, allowWeeklyCritical: true, model: requestInfo.model })) {
|
|
1657
|
+
&& this._isAvailable(pinned, { allowWeeklyReserve: true, allowWeeklyCritical: true, model: requestInfo.model, now })) {
|
|
1580
1658
|
this.currentIndex = pinned.index;
|
|
1581
1659
|
return pinned;
|
|
1582
1660
|
}
|
|
@@ -1588,7 +1666,7 @@ export class AccountManager {
|
|
|
1588
1666
|
{ allowWeeklyReserve: true, allowWeeklyCritical: false },
|
|
1589
1667
|
{ allowWeeklyReserve: true, allowWeeklyCritical: true },
|
|
1590
1668
|
];
|
|
1591
|
-
if (preferredPasses.some(options => this._isAvailable(preferred, { ...options, model: requestInfo.model }))) {
|
|
1669
|
+
if (preferredPasses.some(options => this._isAvailable(preferred, { ...options, model: requestInfo.model, now }))) {
|
|
1592
1670
|
this.currentIndex = preferred.index;
|
|
1593
1671
|
return preferred;
|
|
1594
1672
|
}
|
|
@@ -1600,7 +1678,7 @@ export class AccountManager {
|
|
|
1600
1678
|
// `_isWarming`, not on a binding.
|
|
1601
1679
|
const isStickyMode = this.scheduler.routingMode === 'sticky';
|
|
1602
1680
|
const bound = isStickyMode
|
|
1603
|
-
? this._boundAccount(requestInfo.sessionKey, profile, excludedIndexes, requestInfo)
|
|
1681
|
+
? this._boundAccount(requestInfo.sessionKey, profile, excludedIndexes, requestInfo, now)
|
|
1604
1682
|
: null;
|
|
1605
1683
|
if (bound) {
|
|
1606
1684
|
// Warmup-pull: onboard a freshly-ADDED account (added mid-session, no reload)
|
|
@@ -1617,7 +1695,7 @@ export class AccountManager {
|
|
|
1617
1695
|
return warmupTarget; // _bindSession re-homes the session on acquire
|
|
1618
1696
|
}
|
|
1619
1697
|
if (!this._hasHigherPriorityAvailable(bound, profile, excludedIndexes, requestInfo)
|
|
1620
|
-
&& !this._shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx)) {
|
|
1698
|
+
&& !this._shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx, now)) {
|
|
1621
1699
|
return bound;
|
|
1622
1700
|
}
|
|
1623
1701
|
}
|
|
@@ -1648,9 +1726,9 @@ export class AccountManager {
|
|
|
1648
1726
|
const account = this.accounts[idx];
|
|
1649
1727
|
if (excludedIndexes.has(account.index)) continue;
|
|
1650
1728
|
if (!this._matchesRequest(account, profile, requestInfo)) continue;
|
|
1651
|
-
if (!this._isAvailable(account, { ...weeklyOptions, model: requestInfo.model })) continue;
|
|
1729
|
+
if (!this._isAvailable(account, { ...weeklyOptions, model: requestInfo.model, now })) continue;
|
|
1652
1730
|
|
|
1653
|
-
const priority = this._effectivePriority(account, requestInfo);
|
|
1731
|
+
const priority = this._effectivePriority(account, requestInfo, now);
|
|
1654
1732
|
const score = this._scoreAccount(account, requestInfo, scoringCtx);
|
|
1655
1733
|
if (priority < bestPriority || (priority === bestPriority && score < bestScore)) {
|
|
1656
1734
|
bestPriority = priority;
|
|
@@ -1702,15 +1780,15 @@ export class AccountManager {
|
|
|
1702
1780
|
return null;
|
|
1703
1781
|
}
|
|
1704
1782
|
|
|
1705
|
-
_boundAccount(sessionKey, profile, excludedIndexes = new Set(), requestInfo = {}) {
|
|
1783
|
+
_boundAccount(sessionKey, profile, excludedIndexes = new Set(), requestInfo = {}, now = Date.now()) {
|
|
1706
1784
|
if (!sessionKey) return null;
|
|
1707
1785
|
const binding = this._sessionBinding(sessionKey);
|
|
1708
1786
|
if (!binding) return null;
|
|
1709
1787
|
|
|
1710
|
-
const home = this._eligibleBoundAccount(binding.homeName, profile, excludedIndexes, { allowWeeklyReserve: true }, requestInfo);
|
|
1788
|
+
const home = this._eligibleBoundAccount(binding.homeName, profile, excludedIndexes, { allowWeeklyReserve: true, now }, requestInfo);
|
|
1711
1789
|
if (home) return home;
|
|
1712
1790
|
|
|
1713
|
-
const current = this._eligibleBoundAccount(binding.currentName, profile, excludedIndexes, { allowWeeklyReserve: true }, requestInfo);
|
|
1791
|
+
const current = this._eligibleBoundAccount(binding.currentName, profile, excludedIndexes, { allowWeeklyReserve: true, now }, requestInfo);
|
|
1714
1792
|
if (current) return current;
|
|
1715
1793
|
|
|
1716
1794
|
const homeExists = binding.homeName && this.accounts.some(a => a.name === binding.homeName);
|
|
@@ -1731,7 +1809,7 @@ export class AccountManager {
|
|
|
1731
1809
|
return account;
|
|
1732
1810
|
}
|
|
1733
1811
|
|
|
1734
|
-
_bindSession(sessionKey, account, model = null) {
|
|
1812
|
+
_bindSession(sessionKey, account, model = null, now = Date.now()) {
|
|
1735
1813
|
const priority = this._priority(account);
|
|
1736
1814
|
const binding = this._sessionBinding(sessionKey) || {
|
|
1737
1815
|
homeName: account.name,
|
|
@@ -1739,7 +1817,19 @@ export class AccountManager {
|
|
|
1739
1817
|
currentName: account.name,
|
|
1740
1818
|
};
|
|
1741
1819
|
|
|
1742
|
-
|
|
1820
|
+
// PEAK FAILOVER GUARD (2026-08-18): a move forced by peak suppression is a
|
|
1821
|
+
// FAILOVER, not a by-choice rebalance — keep homeName so the session returns to
|
|
1822
|
+
// its home once the window closes. Without this, `priority < homePriority`
|
|
1823
|
+
// (oauth 0 < provider 10) permanently re-homes every sticky GLM session live at
|
|
1824
|
+
// 06:00 UTC, and it never returns after 10:00 — an SC2 violation. Mirrors the
|
|
1825
|
+
// CHOICE-vs-FAILOVER distinction the equal-priority branch below already encodes.
|
|
1826
|
+
// Both legs need the previous home, so it is resolved once here.
|
|
1827
|
+
const oldHome = binding.homeName ? this.accounts.find(a => a.name === binding.homeName) : null;
|
|
1828
|
+
const oldHomeInPeak = oldHome != null && this._peakTier(oldHome, now) > 0;
|
|
1829
|
+
// Lower-priority leg: only a move ONTO a non-peak account is peak-driven.
|
|
1830
|
+
const peakFailover = oldHomeInPeak && this._peakTier(account, now) === 0;
|
|
1831
|
+
|
|
1832
|
+
if (!binding.homeName || (priority < binding.homePriority && !peakFailover)) {
|
|
1743
1833
|
binding.homeName = account.name;
|
|
1744
1834
|
binding.homePriority = priority;
|
|
1745
1835
|
} else if (priority === binding.homePriority && account.name !== binding.homeName) {
|
|
@@ -1751,8 +1841,12 @@ export class AccountManager {
|
|
|
1751
1841
|
// Model-aware: a move off a home that's capped for THIS model (but healthy
|
|
1752
1842
|
// for others) is a FAILOVER, not a by-choice rebalance — keep homeName so the
|
|
1753
1843
|
// session snaps back once the model's scoped cap resets.
|
|
1754
|
-
|
|
1755
|
-
|
|
1844
|
+
//
|
|
1845
|
+
// PEAK FAILOVER GUARD (equal-priority leg): an in-peak old home reads AVAILABLE
|
|
1846
|
+
// (tier 1/2 is a ranking, not an eligibility bar) — so without this check the
|
|
1847
|
+
// CHOICE branch would treat a peak-driven move as by-choice and permanently
|
|
1848
|
+
// re-home the session. A peak home is a FAILOVER cause: keep homeName.
|
|
1849
|
+
if (oldHome && !oldHomeInPeak && this._isAvailable(oldHome, { allowWeeklyReserve: true, model })) {
|
|
1756
1850
|
binding.homeName = account.name;
|
|
1757
1851
|
}
|
|
1758
1852
|
}
|
|
@@ -1855,7 +1949,17 @@ export class AccountManager {
|
|
|
1855
1949
|
// Claude session load-balances across Claude+GLM+Kimi rather than using providers
|
|
1856
1950
|
// only as last-resort. 'never'/'when-exhausted' keep the provider's own priority
|
|
1857
1951
|
// (10/20) → fallback-only. A foreign session is provider-only regardless.
|
|
1858
|
-
_effectivePriority(account, requestInfo = {}) {
|
|
1952
|
+
_effectivePriority(account, requestInfo = {}, now = Date.now()) {
|
|
1953
|
+
// PEAK TIER (2026-08-18): a stride added AFTER the mode layer, so a peak provider
|
|
1954
|
+
// ranks strictly below every non-peak account in EVERY routing mode with no
|
|
1955
|
+
// per-mode branch. Tier 0 returns base IDENTICALLY — off-peak behaviour is
|
|
1956
|
+
// byte-identical to the pre-peak implementation (SC2 by construction).
|
|
1957
|
+
const base = this._basePriority(account, requestInfo);
|
|
1958
|
+
const tier = this._peakTier(account, now);
|
|
1959
|
+
return tier === 0 ? base : base + tier * PEAK_TIER_STRIDE;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
_basePriority(account, requestInfo = {}) {
|
|
1859
1963
|
const base = Number.isFinite(account.priority) ? account.priority : 0;
|
|
1860
1964
|
const mode = this.scheduler.routingMode;
|
|
1861
1965
|
const incompatible = this._effectiveIncompatible(requestInfo).incompatible;
|
|
@@ -1934,9 +2038,147 @@ export class AccountManager {
|
|
|
1934
2038
|
const providers = { ...(this.scheduler.providers || {}) };
|
|
1935
2039
|
providers[providerKey] = { ...(providers[providerKey] || {}), claudeFallback: policy };
|
|
1936
2040
|
this.scheduler.providers = providers;
|
|
2041
|
+
this._peakCache = null; // provider settings changed → peak memo is stale (MINOR 10)
|
|
1937
2042
|
return true;
|
|
1938
2043
|
}
|
|
1939
2044
|
|
|
2045
|
+
/** Peak settings setter (TUI + config writes). Validates, spread-preserves sibling
|
|
2046
|
+
* keys, clears the per-minute memo. Returns false on invalid input (no change). */
|
|
2047
|
+
setPeakSettingsForProvider(providerKey, { peakWindows, peakCap, peakDepreference, peakTimezone } = {}) {
|
|
2048
|
+
if (!providerKey) return false;
|
|
2049
|
+
const existing = this.scheduler.providers?.[providerKey] || {};
|
|
2050
|
+
const next = { ...existing };
|
|
2051
|
+
if (peakWindows !== undefined) {
|
|
2052
|
+
if (!Array.isArray(peakWindows)) return false;
|
|
2053
|
+
next.peakWindows = peakWindows;
|
|
2054
|
+
}
|
|
2055
|
+
if (peakCap !== undefined) {
|
|
2056
|
+
const c = Number(peakCap);
|
|
2057
|
+
if (!Number.isFinite(c) || c < 0 || c > 1) return false;
|
|
2058
|
+
next.peakCap = c;
|
|
2059
|
+
}
|
|
2060
|
+
if (peakDepreference !== undefined) next.peakDepreference = Boolean(peakDepreference);
|
|
2061
|
+
if (peakTimezone !== undefined) {
|
|
2062
|
+
// null = follow the machine's zone. A string must be a zone Intl accepts —
|
|
2063
|
+
// validate by construction so a typo is rejected here rather than silently
|
|
2064
|
+
// falling back at every routing decision.
|
|
2065
|
+
if (peakTimezone !== null) {
|
|
2066
|
+
if (typeof peakTimezone !== 'string') return false;
|
|
2067
|
+
try { new Intl.DateTimeFormat('en-US', { timeZone: peakTimezone }); } catch { return false; }
|
|
2068
|
+
}
|
|
2069
|
+
next.peakTimezone = peakTimezone;
|
|
2070
|
+
}
|
|
2071
|
+
const providers = { ...(this.scheduler.providers || {}) };
|
|
2072
|
+
providers[providerKey] = next;
|
|
2073
|
+
this.scheduler.providers = providers;
|
|
2074
|
+
this._peakCache = null;
|
|
2075
|
+
return true;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
/** Machine-readable peak state for the status endpoint (SC9). Per provider:
|
|
2079
|
+
* inPeak/endsAt/cap/depreference; plus which accounts are tier-1/2/barred. */
|
|
2080
|
+
peakSummary(now = Date.now()) {
|
|
2081
|
+
const providers = {};
|
|
2082
|
+
for (const a of this.accounts) {
|
|
2083
|
+
if (a.type !== 'provider' || providers[a.provider]) continue;
|
|
2084
|
+
const { inPeak, endsAt, settings } = this._peakStateFor(a.provider, now);
|
|
2085
|
+
providers[a.provider] = { inPeak, endsAt, cap: settings.cap, depreference: settings.depreference };
|
|
2086
|
+
}
|
|
2087
|
+
return { providers };
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// ── Peak-hour governance (2026-08-18) ─────────────────────────────────────────
|
|
2091
|
+
// Peak is a DERIVED, STATELESS overlay: a pure function of now + config. Never
|
|
2092
|
+
// persisted, never latched, never timer-driven — so sleep/wake, restart and the
|
|
2093
|
+
// zero-downtime reload need no recovery code. All peak predicates route through
|
|
2094
|
+
// these four helpers so the selector, the oracle and the TUI can never disagree.
|
|
2095
|
+
|
|
2096
|
+
/** Merged peak settings for one provider family. A malformed cap falls back to the
|
|
2097
|
+
* shipped default rather than throwing — but a malformed WINDOW yields `[]`, i.e.
|
|
2098
|
+
* never peak, so a config typo degrades to today's behaviour instead of benching an
|
|
2099
|
+
* account. Defaults resolve ONLY from scheduler.providers (seeded by the loadConfig
|
|
2100
|
+
* migration / createDefaultConfig — see peak-window.js for why they must not live
|
|
2101
|
+
* in DEFAULT_SCHEDULER). Resolved once per provider per minute via _peakStateFor. */
|
|
2102
|
+
_peakSettingsFor(providerKey) {
|
|
2103
|
+
const p = this.scheduler.providers?.[providerKey] || {};
|
|
2104
|
+
// Accept ONLY a real number. `Number(null)`/`Number('')`/`Number(false)`/`Number([])`
|
|
2105
|
+
// are all 0 — which is the HARD BAR — so a config carrying `peakCap: null` (the
|
|
2106
|
+
// natural JSON spelling of "no cap", and the very convention `peakTimezone: null`
|
|
2107
|
+
// uses in this same object) would silently bench every GLM account for 4h every
|
|
2108
|
+
// weekday. The window path already documents "a typo degrades to today's
|
|
2109
|
+
// behaviour"; the cap path must match it.
|
|
2110
|
+
const cap = typeof p.peakCap === 'number' && Number.isFinite(p.peakCap) ? p.peakCap : undefined;
|
|
2111
|
+
return {
|
|
2112
|
+
windows: Array.isArray(p.peakWindows) ? p.peakWindows : [],
|
|
2113
|
+
// null/absent ⇒ follow the MACHINE's local zone (wallClockIn's own default).
|
|
2114
|
+
// A string pins an IANA zone. Both are user-settable (2026-08-18).
|
|
2115
|
+
timezone: typeof p.peakTimezone === 'string' && p.peakTimezone ? p.peakTimezone : null,
|
|
2116
|
+
cap: cap === undefined ? DEFAULT_PEAK_CAP : Math.max(0, Math.min(1, cap)),
|
|
2117
|
+
depreference: p.peakDepreference !== false,
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/** {inPeak, endsAt, settings} for a provider family, memoized per UTC MINUTE (the
|
|
2122
|
+
* evaluation is minute-stable by construction, so the memo is exact). Carrying the
|
|
2123
|
+
* resolved settings in the same entry is what lets _peakTier / _peakHardBarred /
|
|
2124
|
+
* peakSummary read them without re-resolving. Invalidated by the peak/provider
|
|
2125
|
+
* setters, which are the only things that can change settings mid-minute. */
|
|
2126
|
+
_peakStateFor(providerKey, now = Date.now()) {
|
|
2127
|
+
const minute = Math.floor(now / 60_000);
|
|
2128
|
+
if (!this._peakCache || this._peakCache.minute !== minute) {
|
|
2129
|
+
this._peakCache = { minute, byProvider: new Map() };
|
|
2130
|
+
}
|
|
2131
|
+
let st = this._peakCache.byProvider.get(providerKey);
|
|
2132
|
+
if (!st) {
|
|
2133
|
+
const settings = this._peakSettingsFor(providerKey);
|
|
2134
|
+
st = { ...peakWindowState(settings.windows, now, settings.timezone), settings };
|
|
2135
|
+
this._peakCache.byProvider.set(providerKey, st);
|
|
2136
|
+
}
|
|
2137
|
+
return st;
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
/** WEEKLY utilization 0..1, or null when unreadable. The cap's basis (D2): TOTAL
|
|
2141
|
+
* WEEKLY — deliberately NOT _weeklyRawState's max(providerSes, providerWk), which
|
|
2142
|
+
* conflates the 5h session window with the weekly one. null (legacy TOKENS_LIMIT
|
|
2143
|
+
* plan / weeklyAbsent) FAILS OPEN: unknown must never mean over-cap. Provider-only,
|
|
2144
|
+
* like every peak predicate — OAuth accounts have no peak concept. */
|
|
2145
|
+
_peakWeeklyUtilization(account) {
|
|
2146
|
+
const wk = account?.quota?.providerWk;
|
|
2147
|
+
return wk != null ? clamp01(wk) : null;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
/** SOFT cap: weekly utilization at/over the cap during peak ⇒ tier 2 (last-resort).
|
|
2151
|
+
* cap >= 1 is "feature off" (SC4) — the strict < 1 guard also stops cap 1.0 firing
|
|
2152
|
+
* at exactly util 1.0. cap === 0 is NOT handled here; it is the hard bar, a
|
|
2153
|
+
* different mechanism entirely (utilization-independent). */
|
|
2154
|
+
_peakCapExceeded(account, settings) {
|
|
2155
|
+
if (!(settings.cap > 0 && settings.cap < 1)) return false;
|
|
2156
|
+
const util = this._peakWeeklyUtilization(account);
|
|
2157
|
+
if (util == null) return false; // fail open
|
|
2158
|
+
return util >= settings.cap;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
/** HARD bar: peakCap === 0.0 means "never use this provider during peak" (D4).
|
|
2162
|
+
* Utilization is irrelevant — zero means zero. The ONLY peak predicate that gates
|
|
2163
|
+
* ELIGIBILITY (_isAvailable); everything else is ranking. */
|
|
2164
|
+
_peakHardBarred(account, now = Date.now()) {
|
|
2165
|
+
if (account?.type !== 'provider') return false;
|
|
2166
|
+
const { inPeak, settings } = this._peakStateFor(account.provider, now);
|
|
2167
|
+
return inPeak && settings.cap === 0;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/** 0 = unaffected · 1 = peak, de-preferred · 2 = peak + over the soft cap.
|
|
2171
|
+
* OAuth accounts are 0 always (no peak concept). A provider with no window is 0
|
|
2172
|
+
* always (Kimi by default — SC5). The cap is independent of depreference: turning
|
|
2173
|
+
* depreference off must not disable the cap (both knobs exist, D7). */
|
|
2174
|
+
_peakTier(account, now = Date.now()) {
|
|
2175
|
+
if (account?.type !== 'provider') return 0;
|
|
2176
|
+
const { inPeak, settings } = this._peakStateFor(account.provider, now);
|
|
2177
|
+
if (!inPeak) return 0;
|
|
2178
|
+
if (this._peakCapExceeded(account, settings)) return 2;
|
|
2179
|
+
return settings.depreference ? 1 : 0;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
1940
2182
|
_isRequestCompatible(account, profile, requestInfo = {}) {
|
|
1941
2183
|
if (!this._matchesProfile(account, profile)) return false;
|
|
1942
2184
|
|
|
@@ -3373,6 +3615,7 @@ export class AccountManager {
|
|
|
3373
3615
|
admissionPaused: this.admissionPaused,
|
|
3374
3616
|
safetyMaxActivePerAccount: this.scheduler.safetyMaxActivePerAccount,
|
|
3375
3617
|
safetyMaxGlobalActive: this.scheduler.safetyMaxGlobalActive,
|
|
3618
|
+
peak: this.peakSummary(),
|
|
3376
3619
|
},
|
|
3377
3620
|
upstreamThrottle: {
|
|
3378
3621
|
active: this._isUpstreamThrottleBlocking(),
|
package/src/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir, rename, chmod, unlink } from 'node:fs/promises';
|
|
2
|
+
import { DEFAULT_PEAK_PROVIDERS, mergePeakDefaults } from './peak-window.js';
|
|
2
3
|
import { existsSync } from 'node:fs';
|
|
3
4
|
import { join, dirname } from 'node:path';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
@@ -117,7 +118,14 @@ export function createDefaultConfig() {
|
|
|
117
118
|
// Left EMPTY: an unset provider inherits the policy above, so off-by-default holds
|
|
118
119
|
// without shadowing it. The TUI writes an entry here only when you steer one
|
|
119
120
|
// provider differently from the other.
|
|
120
|
-
|
|
121
|
+
// Peak-hour governance defaults (2026-08-18). Fresh installs get the block ON
|
|
122
|
+
// DISK; existing installs inherit it at load (see the migration in loadConfig).
|
|
123
|
+
// NEVER in account-manager's DEFAULT_SCHEDULER.providers: the shallow spread
|
|
124
|
+
// there wipes it for any config that already has `providers`, and a default in
|
|
125
|
+
// that object reaches every directly-constructed AccountManager in the test
|
|
126
|
+
// suite, making it time-dependent. Both proven by probe 2026-08-18.
|
|
127
|
+
providers: structuredClone(DEFAULT_PEAK_PROVIDERS),
|
|
128
|
+
peakDefaultsVersion: 1,
|
|
121
129
|
},
|
|
122
130
|
retry: {
|
|
123
131
|
maxAttemptsPerRequest: 0,
|
|
@@ -191,6 +199,18 @@ export async function loadConfig() {
|
|
|
191
199
|
for (const key of Object.keys(defaults)) {
|
|
192
200
|
if (!(key in parsed)) parsed[key] = defaults[key];
|
|
193
201
|
}
|
|
202
|
+
// ── Peak-hours migration (2026-08-18) ──
|
|
203
|
+
// The top-level backfill fills ABSENT top-level keys only, and every real config
|
|
204
|
+
// already HAS `scheduler` — the nested provider defaults never reach an existing
|
|
205
|
+
// install without this pass. Merges by key-PRESENCE (an explicit user value wins;
|
|
206
|
+
// `peakWindows: []` = never-peak and survives), gated on peakDefaultsVersion so
|
|
207
|
+
// deliberately-emptied windows are not re-seeded. CLONES — config.scheduler is
|
|
208
|
+
// reference-shared with the TUI persist path, and mutating it would write the
|
|
209
|
+
// defaults to disk on the next toggle (pre-mortem MINOR 8).
|
|
210
|
+
if (parsed.scheduler && typeof parsed.scheduler === 'object') {
|
|
211
|
+
parsed.scheduler.providers = mergePeakDefaults(parsed.scheduler.providers, parsed.scheduler.peakDefaultsVersion);
|
|
212
|
+
parsed.scheduler.peakDefaultsVersion = 1;
|
|
213
|
+
}
|
|
194
214
|
}
|
|
195
215
|
return parsed;
|
|
196
216
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peak-hour window evaluation — PURE, zero imports, zero deps.
|
|
3
|
+
*
|
|
4
|
+
* z.ai charges 50% of the standard credit rate OFF-peak; peak is full rate, so peak
|
|
5
|
+
* spend costs 2x. Peak = Mon-Fri 14:00-18:00 SGT (UTC+8, no DST) = 06:00-10:00 UTC,
|
|
6
|
+
* 20h of 168h. Kimi (Moonshot) publishes no peak multiplier — it ships with NO
|
|
7
|
+
* window = never peak = unaffected.
|
|
8
|
+
*
|
|
9
|
+
* Design invariants (task-2026-08-18-maxpool-peak-hours-glm-governance):
|
|
10
|
+
* - UTC ONLY. days use Date#getUTCDay() numbering (0=Sun .. 6=Sat). No timezone
|
|
11
|
+
* math, no tz library — SGT has no DST so the fixed UTC window is exact year-round.
|
|
12
|
+
* - Start INCLUSIVE, end EXCLUSIVE: m >= start && m < end.
|
|
13
|
+
* - A day names the day the window STARTS. A wrapping window (end <= start) spans
|
|
14
|
+
* the UTC day boundary: segment A on each listed day, segment B on the NEXT day.
|
|
15
|
+
* - A MALFORMED window NEVER matches. The feature is on by default; a config typo
|
|
16
|
+
* must degrade to today's behaviour (never bench GLM), never to 24/7 de-preference.
|
|
17
|
+
* - Overlapping windows merge: endsAt is the LATEST matching end, so a hold never
|
|
18
|
+
* wakes into a second contiguous window.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const MINUTES_PER_DAY = 1440;
|
|
22
|
+
export const DEFAULT_PEAK_CAP = 0.5;
|
|
23
|
+
|
|
24
|
+
/** The shipped defaults. ONE literal, imported by config.js (fresh installs get it on
|
|
25
|
+
* disk) AND the loadConfig migration (existing installs inherit it at load). Never
|
|
26
|
+
* DEFAULT_SCHEDULER — see the task research for the probe that proved that placement
|
|
27
|
+
* makes the test suite time-dependent and is wiped by the shallow provider spread. */
|
|
28
|
+
export const DEFAULT_PEAK_PROVIDERS = {
|
|
29
|
+
zai: {
|
|
30
|
+
// The VENDOR's window, expressed in the vendor's own zone. z.ai states
|
|
31
|
+
// Mon-Fri 14:00-18:00 SGT; pinning peakTimezone to Asia/Singapore means the
|
|
32
|
+
// window tracks what z.ai actually bills no matter where the laptop thinks it
|
|
33
|
+
// is, AND survives a DST change if the vendor ever restates it in a DST zone.
|
|
34
|
+
//
|
|
35
|
+
// USER-ADJUSTABLE (2026-08-18): both the hours and the zone are config. Set
|
|
36
|
+
// `peakTimezone` to any IANA zone, or to null to follow the MACHINE's local
|
|
37
|
+
// zone — the right choice if you'd rather reason in your own wall clock.
|
|
38
|
+
// startMin/endMin are minutes-from-midnight IN THAT ZONE.
|
|
39
|
+
peakTimezone: 'Asia/Singapore',
|
|
40
|
+
peakWindows: [{ days: [1, 2, 3, 4, 5], startMin: 14 * 60, endMin: 18 * 60 }],
|
|
41
|
+
peakCap: DEFAULT_PEAK_CAP,
|
|
42
|
+
peakDepreference: true,
|
|
43
|
+
},
|
|
44
|
+
kimi: { peakWindows: [], peakCap: DEFAULT_PEAK_CAP, peakDepreference: true },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Validate + normalize one window row. Returns {days:Set, start, end} or null.
|
|
48
|
+
* null ⇒ this row never matches (see invariants). */
|
|
49
|
+
export function normalizePeakWindow(w) {
|
|
50
|
+
if (!w || typeof w !== 'object') return null;
|
|
51
|
+
// `startMin`/`endMin` are the current names (minutes-from-midnight in the window's
|
|
52
|
+
// timezone). `startUtcMin`/`endUtcMin` are accepted as legacy aliases so a config
|
|
53
|
+
// written before the timezone knob keeps working unchanged.
|
|
54
|
+
const start = Number(w.startMin ?? w.startUtcMin);
|
|
55
|
+
const end = Number(w.endMin ?? w.endUtcMin);
|
|
56
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
|
57
|
+
if (start < 0 || start >= MINUTES_PER_DAY) return null; // start is a minute-of-day
|
|
58
|
+
if (end <= 0 || end > MINUTES_PER_DAY) return null; // end EXCLUSIVE; 1440 == midnight
|
|
59
|
+
if (end === start) return null; // zero-length ⇒ never
|
|
60
|
+
const days = Array.isArray(w.days)
|
|
61
|
+
? [...new Set(w.days.map(Number).filter(n => Number.isInteger(n) && n >= 0 && n <= 6))]
|
|
62
|
+
: [];
|
|
63
|
+
if (!days.length) return null; // no days ⇒ never, not "every day"
|
|
64
|
+
return { days: new Set(days), start, end };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Wall-clock parts (weekday + minute-of-day) for `now` in an IANA timezone, using
|
|
68
|
+
* Node's built-in Intl — zero dependencies, and DST-correct by construction (the
|
|
69
|
+
* offset is resolved for THAT instant, not a fixed number).
|
|
70
|
+
*
|
|
71
|
+
* `tz` resolution order, per the user requirement (2026-08-18):
|
|
72
|
+
* 1. an explicit IANA zone in config (`peakTimezone`) — for a laptop whose clock
|
|
73
|
+
* is set to somewhere the user is not,
|
|
74
|
+
* 2. else the MACHINE's local zone (the default: maxpool follows the laptop),
|
|
75
|
+
* 3. else UTC.
|
|
76
|
+
* An invalid/unknown zone falls back to the machine zone rather than throwing —
|
|
77
|
+
* a typo must never take routing down.
|
|
78
|
+
*/
|
|
79
|
+
const DAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
80
|
+
const _dtfCache = new Map();
|
|
81
|
+
|
|
82
|
+
export function wallClockIn(now, tz) {
|
|
83
|
+
const zone = tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
84
|
+
let dtf = _dtfCache.get(zone);
|
|
85
|
+
if (!dtf) {
|
|
86
|
+
try {
|
|
87
|
+
dtf = new Intl.DateTimeFormat('en-US', {
|
|
88
|
+
timeZone: zone, hour12: false, weekday: 'short', hour: '2-digit', minute: '2-digit',
|
|
89
|
+
});
|
|
90
|
+
} catch {
|
|
91
|
+
// Unknown zone → machine local. Never throw from the routing hot path.
|
|
92
|
+
dtf = new Intl.DateTimeFormat('en-US', { hour12: false, weekday: 'short', hour: '2-digit', minute: '2-digit' });
|
|
93
|
+
}
|
|
94
|
+
_dtfCache.set(zone, dtf);
|
|
95
|
+
}
|
|
96
|
+
const parts = Object.fromEntries(dtf.formatToParts(new Date(now)).map(p => [p.type, p.value]));
|
|
97
|
+
const day = DAY_INDEX[parts.weekday] ?? 0;
|
|
98
|
+
// hour '24' appears at midnight in some locales' hour12:false output — normalize.
|
|
99
|
+
const hour = Number(parts.hour) % 24;
|
|
100
|
+
return { day, min: hour * 60 + Number(parts.minute) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Is `now` inside any window, and when does the peak end?
|
|
104
|
+
* @returns {{inPeak: boolean, endsAt: number|null}} endsAt is an ms epoch; null when
|
|
105
|
+
* not in peak. Pure in `now` — the caller injects it; no ambient clock reads here. */
|
|
106
|
+
export function peakWindowState(windows, now, tz = null) {
|
|
107
|
+
if (!Array.isArray(windows) || !Number.isFinite(now)) return { inPeak: false, endsAt: null };
|
|
108
|
+
// Normalize ONCE — both the match scan and the contiguity chain below read this.
|
|
109
|
+
const norm = [];
|
|
110
|
+
for (const raw of windows) {
|
|
111
|
+
const w = normalizePeakWindow(raw);
|
|
112
|
+
if (w) norm.push(w);
|
|
113
|
+
}
|
|
114
|
+
if (!norm.length) return { inPeak: false, endsAt: null };
|
|
115
|
+
|
|
116
|
+
// Window times are WALL-CLOCK in `tz` — the config's `peakTimezone` when set, else the
|
|
117
|
+
// MACHINE's own zone (2026-08-18 user requirement: follow the laptop by default, but
|
|
118
|
+
// let the user pin a zone, because a laptop clock is often set to somewhere they aren't).
|
|
119
|
+
// `endsAt` stays an absolute epoch, derived by projecting the remaining wall-clock
|
|
120
|
+
// minutes from local midnight.
|
|
121
|
+
const { day, min } = wallClockIn(now, tz);
|
|
122
|
+
const midnightUtc = now - min * 60_000 - (now % 60_000); // local midnight as an epoch
|
|
123
|
+
const yesterday = (day + 6) % 7;
|
|
124
|
+
let endsAt = null;
|
|
125
|
+
const note = (mins) => {
|
|
126
|
+
const t = midnightUtc + mins * 60_000;
|
|
127
|
+
if (endsAt == null || t > endsAt) endsAt = t;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
for (const w of norm) {
|
|
131
|
+
const wraps = w.end < w.start;
|
|
132
|
+
// Segment A — the window's own UTC day (its full length, or to midnight when wrapping).
|
|
133
|
+
if (w.days.has(day)) {
|
|
134
|
+
const segEnd = wraps ? MINUTES_PER_DAY : w.end;
|
|
135
|
+
if (min >= w.start && min < segEnd) note(segEnd);
|
|
136
|
+
}
|
|
137
|
+
// Segment B — the spill onto the NEXT UTC day, for a wrapping window only.
|
|
138
|
+
// Keyed on the START day (yesterday from now), so a Fri 22:00-02:00 window is
|
|
139
|
+
// peak early Saturday but never early Sunday.
|
|
140
|
+
if (wraps && w.days.has(yesterday) && min < w.end) note(w.end);
|
|
141
|
+
}
|
|
142
|
+
if (endsAt == null) return { inPeak: false, endsAt: null };
|
|
143
|
+
// CONTIGUITY EXTENSION: a hold that wakes exactly at endsAt must not land inside a
|
|
144
|
+
// FOLLOW-ON window. Extend endsAt across any window that starts at/before the
|
|
145
|
+
// current end on the same day (chain until no extension). Simple O(n·k); n is tiny.
|
|
146
|
+
let extended = true;
|
|
147
|
+
while (extended) {
|
|
148
|
+
extended = false;
|
|
149
|
+
for (const w of norm) {
|
|
150
|
+
if (!w.days.has(day) || w.end <= w.start) continue; // wrapping windows never extend
|
|
151
|
+
const curMin = (endsAt - midnightUtc) / 60_000;
|
|
152
|
+
if (w.start <= curMin && w.end > curMin) {
|
|
153
|
+
const t = midnightUtc + w.end * 60_000;
|
|
154
|
+
if (t > endsAt) { endsAt = t; extended = true; }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { inPeak: true, endsAt };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Merge shipped defaults into a provider's settings by key-PRESENCE (an explicit
|
|
162
|
+
* user value always survives; only an absent key inherits). CLONES — never mutates
|
|
163
|
+
* the input (the config object is reference-shared with the TUI persist path). */
|
|
164
|
+
export function mergePeakDefaults(providers, version) {
|
|
165
|
+
if (Number(version) >= 1) return providers; // already seeded — respect user edits
|
|
166
|
+
const out = {};
|
|
167
|
+
for (const key of Object.keys(providers || {})) out[key] = { ...(providers[key] || {}) };
|
|
168
|
+
for (const [key, seed] of Object.entries(DEFAULT_PEAK_PROVIDERS)) {
|
|
169
|
+
const existing = out[key] ||= {};
|
|
170
|
+
// Fields come from the seed itself, so a new peak setting added to
|
|
171
|
+
// DEFAULT_PEAK_PROVIDERS is migrated without editing a parallel list here.
|
|
172
|
+
for (const field of Object.keys(seed)) {
|
|
173
|
+
if (!(field in existing)) existing[field] = seed[field];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
package/src/server.js
CHANGED
|
@@ -1555,6 +1555,30 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
|
|
|
1555
1555
|
return `This session is too large for the GLM/Kimi fallbacks (their ~256K limit) — it needs a 1M-context Claude account, and they're all busy right now.${eta} It sends as soon as one frees; /compact shortens the session if you'd rather not wait.`;
|
|
1556
1556
|
}
|
|
1557
1557
|
|
|
1558
|
+
// PEAK (2026-08-18): a provider family hard-barred by peakCap:0 inside its window.
|
|
1559
|
+
// Placed AFTER the incompat + large-context branches on purpose — both have strictly
|
|
1560
|
+
// better explanations for THEIR requests, and this preempting them told a >256K
|
|
1561
|
+
// session to raise peakCap (which cannot help it). ENABLED accounts only, and the ETA
|
|
1562
|
+
// comes from the peak window end — never the caller's retryAfter, which may reflect
|
|
1563
|
+
// an entirely different blocker (red-team 2026-08-18).
|
|
1564
|
+
{
|
|
1565
|
+
const peakBarred = [];
|
|
1566
|
+
const seenPeakProviders = new Set();
|
|
1567
|
+
for (const a of accountManager.accounts || []) {
|
|
1568
|
+
if (a.type !== 'provider' || a.enabled === false || seenPeakProviders.has(a.provider)) continue;
|
|
1569
|
+
seenPeakProviders.add(a.provider);
|
|
1570
|
+
if (accountManager._peakHardBarred?.(a)) peakBarred.push(a);
|
|
1571
|
+
}
|
|
1572
|
+
if (peakBarred.length) {
|
|
1573
|
+
const names = [...new Set(peakBarred.map(a => (a.provider === 'zai' ? 'GLM' : a.provider === 'kimi' ? 'Kimi' : a.provider)))];
|
|
1574
|
+
const endsAt = Math.max(...peakBarred.map(a => accountManager._peakStateFor?.(a.provider)?.endsAt || 0));
|
|
1575
|
+
const leftMs = endsAt - Date.now();
|
|
1576
|
+
const eta = leftMs > 0 ? ` Peak ends in ~${formatRetryDuration(Math.round(leftMs / 1000))}.` : '';
|
|
1577
|
+
return `${names.join(' and ')} ${names.length === 1 ? 'is' : 'are'} switched off during peak hours (a setting: peakCap 0), and no other account can take this request.${eta} Set scheduler.providers.<provider>.peakCap above 0 to let it cover peak hours.`;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
|
|
1558
1582
|
// ENABLED only — a disabled account is not "at its limit", it is off; counting it
|
|
1559
1583
|
// made a 1-enabled-account pool report "all 9 accounts at their limit".
|
|
1560
1584
|
const claudeCount = accountManager.accounts.filter(a => a.type !== 'provider' && a.enabled !== false).length;
|
package/src/tui.js
CHANGED
|
@@ -520,6 +520,25 @@ export class TUI {
|
|
|
520
520
|
}
|
|
521
521
|
}
|
|
522
522
|
|
|
523
|
+
/** The peak fragment for the routing header: `GLM peak · ends in 2h05m` while any
|
|
524
|
+
* provider is inside its window, '' otherwise. Names the window end so the 4h
|
|
525
|
+
* shape is legible; per-account detail lives in the row tags. `now` is injectable
|
|
526
|
+
* so a test can assert the rendered string without waiting for 06:00 UTC. */
|
|
527
|
+
_peakHeaderNote(now = Date.now()) {
|
|
528
|
+
const parts = [];
|
|
529
|
+
const seen = new Set();
|
|
530
|
+
for (const a of this.am.accounts) {
|
|
531
|
+
if (a.type !== 'provider' || seen.has(a.provider)) continue;
|
|
532
|
+
seen.add(a.provider);
|
|
533
|
+
const st = this.am._peakStateFor?.(a.provider, now);
|
|
534
|
+
if (!st?.inPeak || !st.endsAt) continue;
|
|
535
|
+
const label = a.provider === 'zai' ? 'GLM' : a.provider === 'kimi' ? 'Kimi' : a.provider;
|
|
536
|
+
const mins = Math.max(0, Math.ceil((st.endsAt - now) / 60_000));
|
|
537
|
+
parts.push(`${yellow(label + ' peak')} ${dim(`ends in ${Math.floor(mins / 60)}h${String(mins % 60).padStart(2, '0')}m`)}`);
|
|
538
|
+
}
|
|
539
|
+
return parts.join(' ');
|
|
540
|
+
}
|
|
541
|
+
|
|
523
542
|
_routingLine(routing, xpText) {
|
|
524
543
|
return ` Routing ${cyan(routing)}${xpText}`;
|
|
525
544
|
}
|
|
@@ -1485,9 +1504,18 @@ export class TUI {
|
|
|
1485
1504
|
// header read "Balance all · Cross-provider: always" which looked like two
|
|
1486
1505
|
// conflicting settings when only the first one does anything.
|
|
1487
1506
|
if (hasProviders) {
|
|
1507
|
+
// PEAK (SC9): name an active window — the feature must be visible, not silent.
|
|
1508
|
+
// Only while a provider is genuinely in-window, so an off-peak screen is
|
|
1509
|
+
// byte-identical to today (render parity, TEST PLAN H1).
|
|
1488
1510
|
// Cross-provider fragment is sticky-only — under other modes it is inert and
|
|
1489
1511
|
// reading "always" next to "Balance all" looked like two conflicting controls.
|
|
1490
1512
|
if (mode === 'sticky') xpText = this._crossProviderText();
|
|
1513
|
+
// PEAK note is APPENDED after, never before: the sticky assignment above
|
|
1514
|
+
// overwrites xpText wholesale, so prepending silently discarded the peak note
|
|
1515
|
+
// in sticky — which is DEFAULT_SCHEDULER.routingMode, the TUI's own fallback,
|
|
1516
|
+
// AND the legacy-policy migration target (red-team 2026-08-18).
|
|
1517
|
+
const peakNote = this._peakHeaderNote();
|
|
1518
|
+
if (peakNote) xpText += (xpText ? ' ' : '') + peakNote;
|
|
1491
1519
|
// Overflow visibility: when GLM/Kimi actually served requests recently, surface the
|
|
1492
1520
|
// volume so provider traffic isn't a mystery. This is DATA, not a control — shown
|
|
1493
1521
|
// in every mode. Reuses the SAME 15m load window as the per-row "15m Nr" column.
|
|
@@ -1832,6 +1860,17 @@ export class TUI {
|
|
|
1832
1860
|
wkCell = q.providerWk != null ? bar(q.providerWk, bw, q.providerWkReset)
|
|
1833
1861
|
: emptyBar(q.weeklyAbsent ? 'none' : '—', bw);
|
|
1834
1862
|
note = this._probeHealthNote(a);
|
|
1863
|
+
// PEAK row tag (SC9): mirrors the router's own predicates — hard-barred = off,
|
|
1864
|
+
// tier 2 = over cap, tier 1 = peak (or "cap n/a" when the weekly is unreadable,
|
|
1865
|
+
// so the soft cap is inert). The label can never disagree with routing because
|
|
1866
|
+
// it reads the same predicates.
|
|
1867
|
+
if (this.am._peakHardBarred?.(a)) note += ` ${red('peak·off')}`;
|
|
1868
|
+
else {
|
|
1869
|
+
const tier = this.am._peakTier?.(a) || 0;
|
|
1870
|
+
const wu = this.am._peakWeeklyUtilization?.(a);
|
|
1871
|
+
if (tier === 2) note += ` ${yellow(`peak·capped ${Math.round((wu ?? 0) * 100)}%`)}`;
|
|
1872
|
+
else if (tier === 1) note += wu == null ? ` ${dim('peak·cap n/a')}` : ` ${yellow('peak')}`;
|
|
1873
|
+
}
|
|
1835
1874
|
} else if (q.providerQuotaSource === 'console-only') {
|
|
1836
1875
|
sesCell = emptyBar('n/a', bw);
|
|
1837
1876
|
wkCell = emptyBar('n/a', bw);
|