maxpool 1.5.40 → 1.5.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/account-manager.js +117 -17
- package/src/config.js +12 -6
- package/src/index.js +57 -14
- package/src/prober.js +5 -1
- package/src/server.js +88 -16
- package/src/tui.js +81 -11
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -103,6 +103,18 @@ const DEFAULT_SCHEDULER = {
|
|
|
103
103
|
capPenaltyWeight: 10, // steep penalty per unit of in-flight depth past D (throttle safety floor)
|
|
104
104
|
paceCostWeight: 1.5, // soft de-preference of accounts burning ahead of pace (was the ×6 term)
|
|
105
105
|
scarcityWeight: 6, // legacy; superseded by paceCostWeight (kept so old configs don't error)
|
|
106
|
+
// Reserve-account OVERFLOW model. A weekly-RESERVE account (util 0.85-0.95) used to
|
|
107
|
+
// sit idle behind a healthy-only first pass; now it's eligible in the first pass but
|
|
108
|
+
// ranked BELOW healthy accounts by _reserveCost (so healthy stays first-pick). Among
|
|
109
|
+
// reserve accounts the SOONEST-to-reset is cheapest (use-it-or-lose-it); the further
|
|
110
|
+
// into the band, the costlier (preserve). Critical (≥0.95)/exhausted stay hard-benched
|
|
111
|
+
// in the second pass, never softened. See _reserveCost + _selectNext's 2-pass ladder.
|
|
112
|
+
reserveFloorCost: 5, // base overflow cost — keeps any reserve account behind an idle healthy one (~2)
|
|
113
|
+
reserveBandWeight: 8, // ×(util-0.85)/0.10: deeper into the reserve band ⇒ costlier ⇒ consumed later
|
|
114
|
+
reserveScarcityWeight: 6, // ×weekly _windowScarcity: reset-timing weight, ON TOP of the global paceCost, so
|
|
115
|
+
// a near-reset reserve account is used freely and a far-from-reset one is used last
|
|
116
|
+
reserveConcurrencyTarget: 2, // tighter in-flight cap for reserve (capPenalty bites at inflight>2 ⇒ load fans out
|
|
117
|
+
// across the fleet before any single reserve account is dogpiled toward a 429)
|
|
106
118
|
spreadShareWeight: 3, // multiplies an account's share of recent fleet load (0..1)
|
|
107
119
|
recoveryRampWeight: 4, // decaying penalty applied to a just-recovered account
|
|
108
120
|
recoveryRampMs: 5 * 60_000, // how long the post-recovery ramp lasts
|
|
@@ -121,16 +133,25 @@ const DEFAULT_SCHEDULER = {
|
|
|
121
133
|
// may be served by a provider FAMILY other than its home (Claude ↔ GLM ↔ Kimi).
|
|
122
134
|
// 'never' — strict pin: a Claude session uses Claude only; a GLM session
|
|
123
135
|
// uses GLM only; a Kimi session uses Kimi only.
|
|
124
|
-
// 'when-exhausted'—
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
// session may fall to Kimi once GLM is exhausted.
|
|
136
|
+
// 'when-exhausted'— home family preferred; a Claude session falls back to GLM/Kimi
|
|
137
|
+
// only once all Claude accounts are unavailable, and a GLM session
|
|
138
|
+
// may fall to Kimi once GLM is exhausted.
|
|
128
139
|
// 'always' — providers peer with Claude for a Claude/unknown session
|
|
129
140
|
// (load-balanced, not last-resort).
|
|
141
|
+
// DEFAULT is 'never' (2026-07-24): a Claude Code session stays on Anthropic. Routing
|
|
142
|
+
// Claude→GLM/Kimi proved unreliable (the coding legs 403/429 + ignore the model id),
|
|
143
|
+
// so cross-routing OUT of Claude is OFF by default; re-enable via the TUI routing
|
|
144
|
+
// cycle or config. This governs ONLY the Claude→provider direction.
|
|
130
145
|
// INVARIANT (all policies): a GLM/Kimi-origin session NEVER routes to an Anthropic
|
|
131
146
|
// account — Anthropic 400s on a non-`srvtoolu_` server_tool_use id; that direction
|
|
132
147
|
// is unfixable, not policy-tunable.
|
|
133
|
-
crossProviderFallbackPolicy: '
|
|
148
|
+
crossProviderFallbackPolicy: 'never',
|
|
149
|
+
// The OTHER cross direction, independent of the policy above: may a provider-origin
|
|
150
|
+
// (GLM/Kimi) session cross to the OTHER provider (GLM↔Kimi)? Default ON — both legs are
|
|
151
|
+
// lenient and accept each other's ids, and it's the reliable direction the user wants
|
|
152
|
+
// kept even while Claude→provider is 'never'. Only has effect under policy:'never' (the
|
|
153
|
+
// 'when-exhausted'/'always' paths never pinned to home). Set false for a strict home-pin.
|
|
154
|
+
providerCrossFallback: true,
|
|
134
155
|
};
|
|
135
156
|
const LOAD_EVENT_MAX_AGE_MS = 60 * 60 * 1000;
|
|
136
157
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -1429,14 +1450,16 @@ export class AccountManager {
|
|
|
1429
1450
|
// Else fall through to the candidate score loop, which re-homes the session
|
|
1430
1451
|
// onto the best healthy account via _bindSession on acquire.
|
|
1431
1452
|
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
//
|
|
1453
|
+
// Two-pass ladder for BOTH bound and unbound sessions. Pass 1 admits healthy AND
|
|
1454
|
+
// reserve accounts together; the reserve accounts carry _reserveCost so a healthy
|
|
1455
|
+
// account is preferred whenever one is comparably loaded — but a reserve account
|
|
1456
|
+
// CAN outrank a badly-slammed healthy one (its capPenalty is unbounded), which is
|
|
1457
|
+
// intended load-spread, not a regression. Pass 2 adds critical only when pass 1 is
|
|
1458
|
+
// empty (the untouched hard fallback). A bound session reaches this loop only once
|
|
1459
|
+
// we've decided to LEAVE its account (the sticky "stay put" path returns above); it
|
|
1460
|
+
// may now re-home onto a lightly-loaded reserve account rather than a slammed
|
|
1461
|
+
// healthy one — deliberate, since the whole point is to USE reserve accounts.
|
|
1438
1462
|
const weeklyPasses = [
|
|
1439
|
-
{ allowWeeklyReserve: false, allowWeeklyCritical: false },
|
|
1440
1463
|
{ allowWeeklyReserve: true, allowWeeklyCritical: false },
|
|
1441
1464
|
{ allowWeeklyReserve: true, allowWeeklyCritical: true },
|
|
1442
1465
|
];
|
|
@@ -1699,6 +1722,15 @@ export class AccountManager {
|
|
|
1699
1722
|
// are all unavailable the request HOLDS/queues (recoverable) rather than 400ing.
|
|
1700
1723
|
if (requestInfo.hasImage && account.provider === 'kimi') return false;
|
|
1701
1724
|
|
|
1725
|
+
// A large-context session: a provider already rejected this request with a
|
|
1726
|
+
// context-length 400. The GLM/Kimi *coding* endpoints serve a fixed model capped
|
|
1727
|
+
// at ~256K and IGNORE the model id (so a K3/GLM 1M PLAN doesn't lift the coding
|
|
1728
|
+
// leg's ceiling). Only a 1M-context Claude account can hold it — bench the
|
|
1729
|
+
// providers for this session so it routes to Claude, or HOLDS for one, instead of
|
|
1730
|
+
// re-404ing on a too-small leg. Sticky per session (context only grows turn over
|
|
1731
|
+
// turn), so no follow-up turn re-pays the wasted attempt.
|
|
1732
|
+
if (account.type === 'provider' && this._isSessionLargeContext(requestInfo)) return false;
|
|
1733
|
+
|
|
1702
1734
|
const { incompatible, homeProvider } = this._effectiveIncompatible(requestInfo);
|
|
1703
1735
|
const policy = this._crossProviderFallbackPolicy();
|
|
1704
1736
|
|
|
@@ -1706,9 +1738,13 @@ export class AccountManager {
|
|
|
1706
1738
|
// The transcript can't replay to Claude (a foreign server_tool_use id, or
|
|
1707
1739
|
// content Anthropic rejected on replay) — provider accounts ONLY, regardless
|
|
1708
1740
|
// of policy. Providers are lenient and accept each other's ids (GLM↔Kimi is
|
|
1709
|
-
// fine)
|
|
1741
|
+
// fine). Under 'never' we keep the session on its home provider ONLY when
|
|
1742
|
+
// providerCrossFallback is explicitly off; by default GLM↔Kimi crossing stays
|
|
1743
|
+
// allowed even under 'never' (that reliable direction is governed separately
|
|
1744
|
+
// from the Claude→provider policy).
|
|
1710
1745
|
if (account.type !== 'provider') return false;
|
|
1711
|
-
if (policy === 'never' && homeProvider && account.provider !== homeProvider
|
|
1746
|
+
if (policy === 'never' && homeProvider && account.provider !== homeProvider
|
|
1747
|
+
&& this.scheduler.providerCrossFallback === false) return false;
|
|
1712
1748
|
return true;
|
|
1713
1749
|
}
|
|
1714
1750
|
|
|
@@ -1732,6 +1768,9 @@ export class AccountManager {
|
|
|
1732
1768
|
if (requestInfo.anthropicIncompatible) {
|
|
1733
1769
|
this.markSessionIncompatible(requestInfo.sessionKey, requestInfo.homeProvider);
|
|
1734
1770
|
}
|
|
1771
|
+
if (requestInfo.largeContext) {
|
|
1772
|
+
this.markSessionLargeContext(requestInfo.sessionKey);
|
|
1773
|
+
}
|
|
1735
1774
|
}
|
|
1736
1775
|
|
|
1737
1776
|
// Latch a session as Anthropic-incompatible (a foreign server_tool_use id, or a
|
|
@@ -1750,6 +1789,25 @@ export class AccountManager {
|
|
|
1750
1789
|
});
|
|
1751
1790
|
}
|
|
1752
1791
|
|
|
1792
|
+
// Latch a session as large-context: a provider (the GLM/Kimi coding endpoint, fixed
|
|
1793
|
+
// ~256K) rejected a request with a context-length 400 that only a 1M Claude can hold.
|
|
1794
|
+
// Sticky + never-downgrades so every follow-up turn (context only grows) skips the
|
|
1795
|
+
// too-small providers instead of re-paying a wasted 400. Cleared only by a new session.
|
|
1796
|
+
markSessionLargeContext(sessionKey) {
|
|
1797
|
+
if (!sessionKey) return;
|
|
1798
|
+
const existing = this.sessionPolicies.get(sessionKey) || {};
|
|
1799
|
+
if (!existing.largeContext) {
|
|
1800
|
+
console.log(`[Maxpool] Session "${sessionKey}" exceeds provider context limits — pinned to Claude (GLM/Kimi benched for this session)`);
|
|
1801
|
+
}
|
|
1802
|
+
this.sessionPolicies.set(sessionKey, { ...existing, largeContext: true });
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
_isSessionLargeContext(requestInfo = {}) {
|
|
1806
|
+
if (requestInfo.largeContext) return true;
|
|
1807
|
+
if (!requestInfo.sessionKey) return false;
|
|
1808
|
+
return Boolean(this.sessionPolicies.get(requestInfo.sessionKey)?.largeContext);
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1753
1811
|
// Marks a session as containing Anthropic signed thinking. This no longer bars
|
|
1754
1812
|
// provider fallback (a lenient provider accepts an Anthropic signature) — it only
|
|
1755
1813
|
// keeps the session's live cross-account MIGRATION on Claude (the rebalance guard),
|
|
@@ -1812,9 +1870,16 @@ export class AccountManager {
|
|
|
1812
1870
|
const concurrency = inflight * this.scheduler.concurrencyWeight;
|
|
1813
1871
|
|
|
1814
1872
|
// Steep soft cap past depth D — the throttle safety floor. No single
|
|
1815
|
-
// account absorbs a deep concurrent burst no matter how "cheap" it looks.
|
|
1873
|
+
// account absorbs a deep concurrent burst no matter how "cheap" it looks. A
|
|
1874
|
+
// RESERVE account gets a tighter D (reserveConcurrencyTarget) so its capPenalty
|
|
1875
|
+
// bites sooner — load fans out across the fleet before a low-quota account is
|
|
1876
|
+
// dogpiled toward a 429.
|
|
1877
|
+
const weeklyState = this._weeklyRawState(account);
|
|
1878
|
+
const concTarget = weeklyState === 'reserve'
|
|
1879
|
+
? this.scheduler.reserveConcurrencyTarget
|
|
1880
|
+
: this.scheduler.perAccountConcurrencyTarget;
|
|
1816
1881
|
const capPenalty = this.scheduler.capPenaltyWeight
|
|
1817
|
-
* Math.max(0, inflight -
|
|
1882
|
+
* Math.max(0, inflight - concTarget);
|
|
1818
1883
|
|
|
1819
1884
|
// Burn-pace COST only (demoted from the old dominant scarcity×6 term): a
|
|
1820
1885
|
// soft de-preference of accounts burning ahead of an even pace. Never a bench.
|
|
@@ -1836,6 +1901,7 @@ export class AccountManager {
|
|
|
1836
1901
|
const spread = share * this.scheduler.spreadShareWeight;
|
|
1837
1902
|
|
|
1838
1903
|
const ramp = this._recoveryRamp(account, now);
|
|
1904
|
+
const reserveCost = this._reserveCost(account, now, weeklyState);
|
|
1839
1905
|
const failurePenalty = account.consecutiveFailures * 5;
|
|
1840
1906
|
// NO unknown-quota bonus. An account whose quota we cannot see must never be
|
|
1841
1907
|
// MORE attractive than a known-healthy one — the old -0.5 nudge (safe only
|
|
@@ -1845,7 +1911,7 @@ export class AccountManager {
|
|
|
1845
1911
|
// default) learns the real number within a cycle. `probing`/requalify still
|
|
1846
1912
|
// flags a never-seen account for learning — that path is unchanged.
|
|
1847
1913
|
|
|
1848
|
-
return concurrency + capPenalty + paceCost + scopedPace + spread + ramp + failurePenalty;
|
|
1914
|
+
return concurrency + capPenalty + paceCost + scopedPace + spread + ramp + reserveCost + failurePenalty;
|
|
1849
1915
|
}
|
|
1850
1916
|
|
|
1851
1917
|
/**
|
|
@@ -1898,6 +1964,31 @@ export class AccountManager {
|
|
|
1898
1964
|
return Math.max(0, used - elapsedFrac);
|
|
1899
1965
|
}
|
|
1900
1966
|
|
|
1967
|
+
/**
|
|
1968
|
+
* Overflow cost for a weekly-RESERVE account (util 0.85-0.95). 0 for every other
|
|
1969
|
+
* tier — normal/soft aren't softened, and critical/exhausted are hard-benched by the
|
|
1970
|
+
* pass gate (never made cheaper here). This is what lets a reserve account be eligible
|
|
1971
|
+
* in the FIRST selection pass yet still rank below healthy accounts: floor keeps it
|
|
1972
|
+
* strictly behind an idle healthy pick, the band term makes deeper-into-reserve costlier
|
|
1973
|
+
* (consumed later / preserved), and the reset-timing term makes the soonest-to-reset the
|
|
1974
|
+
* cheapest reserve pick (use-it-or-lose-it). A slammed HEALTHY account can still score
|
|
1975
|
+
* above a lightly-loaded reserve one (its capPenalty is unbounded) — that's intended
|
|
1976
|
+
* load-spread, not a violation of "healthy first".
|
|
1977
|
+
*/
|
|
1978
|
+
_reserveCost(account, now = Date.now(), weeklyState = this._weeklyRawState(account)) {
|
|
1979
|
+
if (weeklyState !== 'reserve') return 0;
|
|
1980
|
+
const q = account.quota;
|
|
1981
|
+
const util = clamp01(q.unified7d);
|
|
1982
|
+
const band = clamp01(
|
|
1983
|
+
(util - this.scheduler.weeklyReserveThreshold)
|
|
1984
|
+
/ Math.max(1e-6, this.scheduler.weeklyCriticalThreshold - this.scheduler.weeklyReserveThreshold),
|
|
1985
|
+
);
|
|
1986
|
+
const pace = this._windowScarcity(q.unified7d, q.unified7dReset, WEEK_MS, now);
|
|
1987
|
+
return this.scheduler.reserveFloorCost
|
|
1988
|
+
+ this.scheduler.reserveBandWeight * band
|
|
1989
|
+
+ this.scheduler.reserveScarcityWeight * pace;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1901
1992
|
/**
|
|
1902
1993
|
* Decaying penalty applied for `recoveryRampMs` after an account un-parks,
|
|
1903
1994
|
* so a freshly-recovered account (which has ~0 recent load and may look most
|
|
@@ -2587,6 +2678,10 @@ export class AccountManager {
|
|
|
2587
2678
|
account.modelMap = acctData.modelMap || account.modelMap;
|
|
2588
2679
|
account.stripBetaHeaders = Boolean(acctData.stripBetaHeaders);
|
|
2589
2680
|
account.runtime = true;
|
|
2681
|
+
// Restore path carries an explicit enabled (persisted disable); honor it. The `cc
|
|
2682
|
+
// all` header path (prepareRuntimeProviders) omits enabled, so a re-sent token
|
|
2683
|
+
// NEVER silently re-enables a provider the user benched in the TUI.
|
|
2684
|
+
if (acctData.enabled !== undefined) account.enabled = acctData.enabled !== false;
|
|
2590
2685
|
if (account.status === 'error' && changed) {
|
|
2591
2686
|
account.status = 'active';
|
|
2592
2687
|
account.lastError = null;
|
|
@@ -2620,6 +2715,10 @@ export class AccountManager {
|
|
|
2620
2715
|
model: a.model,
|
|
2621
2716
|
modelMap: a.modelMap,
|
|
2622
2717
|
stripBetaHeaders: a.stripBetaHeaders,
|
|
2718
|
+
// Persist the user's enable/disable so a provider they benched in the TUI stays
|
|
2719
|
+
// benched across a restart — without this an intentionally-disabled GLM/Kimi
|
|
2720
|
+
// silently comes back enabled on the next boot (restore defaults enabled:true).
|
|
2721
|
+
enabled: a.enabled,
|
|
2623
2722
|
}));
|
|
2624
2723
|
}
|
|
2625
2724
|
|
|
@@ -2797,6 +2896,7 @@ export class AccountManager {
|
|
|
2797
2896
|
stickyBindings: this.sessionBindings.size,
|
|
2798
2897
|
thinkingProtected: [...this.sessionPolicies.values()].filter(p => p.requiresAnthropicThinkingIntegrity).length,
|
|
2799
2898
|
providerPinned: [...this.sessionPolicies.values()].filter(p => p.anthropicIncompatible).length,
|
|
2899
|
+
largeContextPinned: [...this.sessionPolicies.values()].filter(p => p.largeContext).length,
|
|
2800
2900
|
},
|
|
2801
2901
|
};
|
|
2802
2902
|
}
|
package/src/config.js
CHANGED
|
@@ -92,16 +92,22 @@ export function createDefaultConfig() {
|
|
|
92
92
|
weeklyCriticalThreshold: 0.95,
|
|
93
93
|
weeklyExhaustedThreshold: 0.985,
|
|
94
94
|
// Cross-PROVIDER fallback policy for 'cc all' (profile=all): whether a session
|
|
95
|
-
// may be served by a provider family other than its home (Claude
|
|
96
|
-
// 'never' —
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
95
|
+
// may be served by a provider family other than its home (Claude → GLM/Kimi).
|
|
96
|
+
// 'never' — (DEFAULT) a Claude Code session stays on Anthropic; no
|
|
97
|
+
// GLM/Kimi fallback. Routing Claude→providers proved unreliable
|
|
98
|
+
// (the coding legs 403/429 + ignore the model id), so it's OFF
|
|
99
|
+
// by default — re-enable here or via the TUI 'f' key.
|
|
100
|
+
// 'when-exhausted'— home family preferred; a Claude session falls to GLM/Kimi
|
|
101
|
+
// only once all Claude accounts are unavailable.
|
|
100
102
|
// 'always' — providers peer with Claude for a Claude/unknown session.
|
|
101
103
|
// A GLM/Kimi-origin session NEVER routes to Anthropic under ANY policy — that
|
|
102
104
|
// direction 400s on the non-`srvtoolu_` tool-use id and is unfixable. Toggle
|
|
103
105
|
// live in the TUI (Routing sub-mode, 'f' key).
|
|
104
|
-
crossProviderFallbackPolicy: '
|
|
106
|
+
crossProviderFallbackPolicy: 'never',
|
|
107
|
+
// The OTHER cross direction (independent of the policy above): may a provider-origin
|
|
108
|
+
// (GLM/Kimi) session cross to the OTHER provider (GLM↔Kimi)? Default ON — reliable,
|
|
109
|
+
// both legs accept each other's ids. Only has effect under policy:'never'.
|
|
110
|
+
providerCrossFallback: true,
|
|
105
111
|
},
|
|
106
112
|
retry: {
|
|
107
113
|
maxAttemptsPerRequest: 0,
|
package/src/index.js
CHANGED
|
@@ -966,6 +966,13 @@ async function serverWorkerCommand() {
|
|
|
966
966
|
crossProviderFallbackPolicy: config.scheduler.crossProviderFallbackPolicy,
|
|
967
967
|
};
|
|
968
968
|
}
|
|
969
|
+
// Persist live-toggled automatic-update flags (the TUI 'u' Updates menu). Same
|
|
970
|
+
// reason as the policy above: without this the toggle takes effect in memory but
|
|
971
|
+
// silently reverts on the next config write / restart. Only write keys defined
|
|
972
|
+
// in-memory so a disk value is never clobbered with undefined.
|
|
973
|
+
for (const key of ['updateCheck', 'autoUpdate', 'autoApply']) {
|
|
974
|
+
if (config[key] !== undefined) diskConfig[key] = config[key];
|
|
975
|
+
}
|
|
969
976
|
// Write in-memory accounts as the authoritative state, preserving
|
|
970
977
|
// extra disk-only fields (e.g. importFrom) where the account still exists.
|
|
971
978
|
// Use live tokens from AccountManager (not the stale config.accounts copy).
|
|
@@ -1038,18 +1045,55 @@ async function serverWorkerCommand() {
|
|
|
1038
1045
|
restartController.requestRestart();
|
|
1039
1046
|
}
|
|
1040
1047
|
};
|
|
1048
|
+
// Manual apply (the TUI 'u' → check & apply now): reload into a freshly-installed
|
|
1049
|
+
// newer version REGARDLESS of autoApply — the user asked for it explicitly, so it must
|
|
1050
|
+
// NOT no-op just because the AUTOMATIC path is off (that no-op is exactly the "relaunch
|
|
1051
|
+
// still shows the old version" pain this menu exists to kill).
|
|
1052
|
+
const applyNow = r => {
|
|
1053
|
+
if (r?.applicable && restartController) {
|
|
1054
|
+
markApplied(r.installedVersion);
|
|
1055
|
+
notifyUpdate('Applying update — seamless reload…');
|
|
1056
|
+
restartController.requestRestart();
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
// ONE in-flight latch shared by the periodic timer, the cold-start probe, AND the
|
|
1060
|
+
// manual action, so two `npm i -g` can never race (global-package corruption). hasLease
|
|
1061
|
+
// already blocks CROSS-worker races; this blocks same-worker timer-vs-manual overlap.
|
|
1062
|
+
let updateInFlight = false;
|
|
1063
|
+
const runUpdateCheck = async ({ announce, apply, forceInstall = false }) => {
|
|
1064
|
+
if (updateInFlight || !hasLease || config?.updateCheck === false) return undefined;
|
|
1065
|
+
updateInFlight = true;
|
|
1066
|
+
try {
|
|
1067
|
+
// The MANUAL action forces the install (autoUpdate:true) so pressing 'u' → 'c'
|
|
1068
|
+
// actually downloads + reloads even when AUTOMATIC updates are off. Without this,
|
|
1069
|
+
// maybeCheckForUpdate short-circuits before installing when config.autoUpdate is
|
|
1070
|
+
// false (the default) and just tells the user to run `npm i -g` by hand — the exact
|
|
1071
|
+
// quit/relaunch dance this menu exists to kill. The periodic + cold-start paths
|
|
1072
|
+
// pass the real config so they still respect the user's autoUpdate choice.
|
|
1073
|
+
const cfg = forceInstall ? { ...config, autoUpdate: true } : config;
|
|
1074
|
+
const r = await maybeCheckForUpdate(cfg, notifyUpdate, info => { accountManager.versionInfo = info; }, { announce });
|
|
1075
|
+
apply(r);
|
|
1076
|
+
return r;
|
|
1077
|
+
} catch { return undefined; /* update path is best-effort; never break the proxy */ }
|
|
1078
|
+
finally { updateInFlight = false; }
|
|
1079
|
+
};
|
|
1080
|
+
// 'u' → check & apply now: force an immediate check + install + seamless reload so the
|
|
1081
|
+
// user never has to quit/relaunch to pick up a release. Reports the up-to-date case too.
|
|
1082
|
+
const checkForUpdatesNow = async () => {
|
|
1083
|
+
if (updateInFlight) { notifyUpdate('Update check already running'); return; }
|
|
1084
|
+
if (!hasLease) { notifyUpdate('Updates run on the primary worker only'); return; }
|
|
1085
|
+
if (config?.updateCheck === false) { notifyUpdate('Update checks are disabled in config'); return; }
|
|
1086
|
+
notifyUpdate('Checking for updates…');
|
|
1087
|
+
const r = await runUpdateCheck({ announce: true, apply: applyNow, forceInstall: true });
|
|
1088
|
+
if (r && !r.hasUpdate) notifyUpdate('Already on the latest version');
|
|
1089
|
+
};
|
|
1090
|
+
if (tui) tui.checkNow = checkForUpdatesNow;
|
|
1041
1091
|
const updateIntervalMs = Math.max(60_000, Number(process.env.MAXPOOL_UPDATE_CHECK_INTERVAL_MS) || 6 * 60 * 60 * 1000);
|
|
1042
1092
|
updateTimer = setInterval(() => {
|
|
1043
|
-
// ONLY the lease-holding primary self-installs — the timer is unconditional across
|
|
1044
|
-
// EVERY worker (incl. a headless reload worker), so gate on hasLease here to avoid
|
|
1045
|
-
// two `npm i -g` racing during a reload overlap (global-package corruption). A
|
|
1046
|
-
// headless reload worker never holds the lease, so it never installs/auto-applies.
|
|
1047
|
-
if (config?.updateCheck === false || !hasLease) return;
|
|
1048
1093
|
// announce:false — the persistent TUI banner is the passive reminder; only real
|
|
1049
|
-
// actions (installing / applying) log
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
.catch(() => {});
|
|
1094
|
+
// actions (installing / applying) log. runUpdateCheck's latch + hasLease gate prevent
|
|
1095
|
+
// racing installs (a headless reload worker never holds the lease, so never installs).
|
|
1096
|
+
runUpdateCheck({ announce: false, apply: applyUpdateIfReady });
|
|
1053
1097
|
}, updateIntervalMs);
|
|
1054
1098
|
updateTimer.unref();
|
|
1055
1099
|
|
|
@@ -1099,11 +1143,10 @@ async function serverWorkerCommand() {
|
|
|
1099
1143
|
// A reload-spawned/takeover worker must NEVER re-probe (1x not 2x traffic).
|
|
1100
1144
|
if (!viaTakeover && !isReloadWorker) {
|
|
1101
1145
|
if (process.env.MAXPOOL_TEST_LOG_UPDATE_CHECK === '1') console.log('[Maxpool] UPDATE_CHECK_FIRED');
|
|
1102
|
-
// Cold-start-behind: download AND (autoApply) self-apply via the SAME helper
|
|
1103
|
-
// periodic path — so the version is applied, not marked-attempted-then-stranded
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
.catch(() => {});
|
|
1146
|
+
// Cold-start-behind: download AND (autoApply) self-apply via the SAME latched helper
|
|
1147
|
+
// as the periodic path — so the version is applied, not marked-attempted-then-stranded,
|
|
1148
|
+
// and it can't race the periodic timer's install.
|
|
1149
|
+
runUpdateCheck({ announce: true, apply: applyUpdateIfReady });
|
|
1107
1150
|
} else if (process.env.MAXPOOL_TEST_LOG_UPDATE_CHECK === '1') {
|
|
1108
1151
|
console.log('[Maxpool] UPDATE_CHECK_SKIPPED (reload)');
|
|
1109
1152
|
}
|
package/src/prober.js
CHANGED
|
@@ -70,7 +70,11 @@ export class Prober {
|
|
|
70
70
|
this._stopping = false;
|
|
71
71
|
this._inflight = (async () => {
|
|
72
72
|
try {
|
|
73
|
-
//
|
|
73
|
+
// DISABLED accounts are INTENTIONALLY still probed (no `a.enabled` filter):
|
|
74
|
+
// a user often disables an account precisely BECAUSE it's exhausted, and still
|
|
75
|
+
// wants to see its quota recover — so keep refreshing its usage for visibility
|
|
76
|
+
// even though routing skips it. Do NOT add an enabled gate here.
|
|
77
|
+
// Skip only auth-dead accounts (dead refresh token): probing them just re-POSTs
|
|
74
78
|
// the rejected token every cycle — a 400 storm. They recover only on re-auth.
|
|
75
79
|
const oauth = this.am.accounts.filter(a => a.type === 'oauth' && a.credential && !a.refreshDead);
|
|
76
80
|
const providers = this.am.accounts.filter(a => a.type === 'provider' && a.credential);
|
package/src/server.js
CHANGED
|
@@ -53,6 +53,16 @@ const DEFAULT_QUEUE = {
|
|
|
53
53
|
// resets idle-gap client timeouts; if a client uses a wall-clock total-request
|
|
54
54
|
// deadline, lower this to just under it.
|
|
55
55
|
streamHoldMaxMs: 7 * 24 * 60 * 60 * 1000,
|
|
56
|
+
// HARD ceiling on EVERY streaming hold (capacity/quota/throttle/concurrency alike).
|
|
57
|
+
// maxpool CANNOT keep a client alive past its own stream watchdog — Claude Code aborts
|
|
58
|
+
// "Stream idle timeout - no chunks received" after CLAUDE_STREAM_IDLE_TIMEOUT_MS of no
|
|
59
|
+
// real content EVENTS, and it drops SSE ping/comment keep-alives before the watchdog
|
|
60
|
+
// sees them (anthropic-sdk-typescript#998). So a 7-day/24h server-side hold only parks
|
|
61
|
+
// a request the client already abandoned. Bound it to the wait the user actually wants
|
|
62
|
+
// (front-loaded work waiting for a free account ≈ a few hours) so beyond that the
|
|
63
|
+
// request error-fasts with an honest retryable 429 instead of a silent multi-day park.
|
|
64
|
+
// Pair with a raised client watchdog (the cc launch sets CLAUDE_STREAM_IDLE_TIMEOUT_MS).
|
|
65
|
+
streamClientToleranceMs: Math.max(60_000, Number(process.env.MAXPOOL_STREAM_CLIENT_TOLERANCE_MS) || 3 * 60 * 60 * 1000),
|
|
56
66
|
// Non-streaming requests have no SSE heartbeat to keep them alive, so a long
|
|
57
67
|
// hold would die on the client timeout anyway. Cap their wait conservatively.
|
|
58
68
|
nonStreamMaxWaitMs: 5 * 60 * 1000,
|
|
@@ -235,9 +245,10 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
235
245
|
}
|
|
236
246
|
requestInfo.profile = getMaxpoolProfile(req.headers);
|
|
237
247
|
requestInfo.sessionKey = headerValue(req.headers, 'x-maxpool-session');
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
248
|
+
// (Removed a FALSE "provider fallback disabled for signed thinking" log here: it
|
|
249
|
+
// fired on every thinking `all` request but was untrue under the default
|
|
250
|
+
// when-exhausted/always policies — providers DO serve thinking requests — and it
|
|
251
|
+
// repeatedly misdirected diagnosis of the "Stream idle timeout" reports.)
|
|
241
252
|
prepareRuntimeProviders(accountManager, req.headers);
|
|
242
253
|
|
|
243
254
|
await forwardRequest(
|
|
@@ -856,9 +867,15 @@ async function forwardRequest(
|
|
|
856
867
|
// signature it can't validate). Detect it on an Anthropic account so we can
|
|
857
868
|
// self-heal onto a provider instead of surfacing the 400.
|
|
858
869
|
const anthropicIncompat = account.type !== 'provider' && isAnthropicIncompatBody(errorBody);
|
|
870
|
+
// A provider (GLM ~200K / Kimi 256K context) rejecting an oversized request that
|
|
871
|
+
// only a large-context Claude (1M) can hold — e.g. "exceeded model token limit:
|
|
872
|
+
// 262144". Detect it ONLY on a provider (a Claude account's context-length 400 is
|
|
873
|
+
// terminal — nothing bigger to fall to) so we can pin the session to Claude.
|
|
874
|
+
const providerTooSmall = account.type === 'provider' && isContextLengthError(errorBody);
|
|
859
875
|
const errorType = errorBody.includes('Invalid `signature` in `thinking` block')
|
|
860
876
|
? 'invalid_thinking_signature'
|
|
861
877
|
: anthropicIncompat ? 'anthropic_incompatible_transcript'
|
|
878
|
+
: providerTooSmall ? 'provider_context_too_small'
|
|
862
879
|
: `HTTP ${upstreamRes.status}`;
|
|
863
880
|
accountManager.releaseAccount(lease, { status: upstreamRes.status, error: errorType });
|
|
864
881
|
|
|
@@ -907,6 +924,33 @@ async function forwardRequest(
|
|
|
907
924
|
);
|
|
908
925
|
}
|
|
909
926
|
|
|
927
|
+
// React-and-heal: a PROVIDER (GLM/Kimi coding endpoint, fixed ~256K context)
|
|
928
|
+
// rejected an oversized request only a 1M-context Claude can hold — e.g. Kimi's
|
|
929
|
+
// "exceeded model token limit: 262144 (requested: 643557)". The coding leg IGNORES
|
|
930
|
+
// the model id, so a K3/GLM-1M plan doesn't lift its ceiling. Latch the session
|
|
931
|
+
// large-context (so its follow-up turns skip the too-small providers) and retry
|
|
932
|
+
// EXCLUDING every provider → routes to Claude, or HOLDS for a Claude account,
|
|
933
|
+
// instead of surfacing a 400 the client just retry-loops on. Only worth it when a
|
|
934
|
+
// Claude account exists to serve it; with none, the 400 surfaces (nothing bigger).
|
|
935
|
+
const claudeAvailable = accountManager.accounts?.some(a => a.type !== 'provider' && a.enabled !== false);
|
|
936
|
+
if (providerTooSmall && requestInfo.sessionKey && claudeAvailable
|
|
937
|
+
&& canRetryBufferedBody && retryCount + 1 < maxAttempts && !res.headersSent) {
|
|
938
|
+
accountManager.markSessionLargeContext?.(requestInfo.sessionKey);
|
|
939
|
+
// Bench EVERY provider: today's GLM + Kimi coding legs both cap at ~256K, so once
|
|
940
|
+
// one 400s on size the others can't hold it either. If a genuine 1M-context
|
|
941
|
+
// provider is ever added, make this exclusion context-limit-aware instead of
|
|
942
|
+
// type-wide (the _isRequestCompatible gate would need the same treatment).
|
|
943
|
+
for (const a of (accountManager.accounts || [])) {
|
|
944
|
+
if (a.type === 'provider') excludedIndexes.add(a.index);
|
|
945
|
+
}
|
|
946
|
+
console.log(`[Maxpool] Provider "${account.name}" context too small for this request; pinning session to Claude and retrying`);
|
|
947
|
+
return forwardRequest(
|
|
948
|
+
req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir,
|
|
949
|
+
retryConfig, queueConfig, { ...requestInfo, largeContext: true },
|
|
950
|
+
canRetryBufferedBody, canQueueBufferedBody, excludedIndexes,
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
910
954
|
ctx.status = upstreamRes.status;
|
|
911
955
|
sendErrorBody(res, requestInfo, upstreamRes.status, errorBody, upstreamRes.headers);
|
|
912
956
|
return;
|
|
@@ -1135,7 +1179,7 @@ function formatRetryDuration(seconds) {
|
|
|
1135
1179
|
*/
|
|
1136
1180
|
function computeQueueWindowMs({
|
|
1137
1181
|
cause, stream, retryPlanCause,
|
|
1138
|
-
maxWaitMs, capacityMaxWaitMs, nonStreamMaxWaitMs, streamHoldMaxMs,
|
|
1182
|
+
maxWaitMs, capacityMaxWaitMs, nonStreamMaxWaitMs, streamHoldMaxMs, streamClientToleranceMs,
|
|
1139
1183
|
isCountTokens, countTokensMaxWaitMs,
|
|
1140
1184
|
}) {
|
|
1141
1185
|
let windowMs;
|
|
@@ -1147,6 +1191,12 @@ function computeQueueWindowMs({
|
|
|
1147
1191
|
windowMs = streamHoldMaxMs;
|
|
1148
1192
|
}
|
|
1149
1193
|
if (retryPlanCause === 'concurrency_cap') windowMs = Math.min(windowMs, capacityMaxWaitMs);
|
|
1194
|
+
// Bound EVERY streaming cause to the client-tolerance ceiling — the client's own
|
|
1195
|
+
// watchdog kills the stream well before a 24h/7d server hold, so anything past this
|
|
1196
|
+
// just parks an abandoned request. A finite reset WITHIN the ceiling still holds +
|
|
1197
|
+
// resumes (via the nextRetryForRequest oracle); a reset beyond it error-fasts (a real
|
|
1198
|
+
// retryable 429 at the pre-heartbeat gate) instead of hanging.
|
|
1199
|
+
if (stream && streamClientToleranceMs != null) windowMs = Math.min(windowMs, streamClientToleranceMs);
|
|
1150
1200
|
// count_tokens: cap the QUEUE wait low (bounds only the wait-for-an-account, never
|
|
1151
1201
|
// the upstream processing once acquired) so a non-heartbeated metadata call fast-
|
|
1152
1202
|
// fails with a retryable 429 instead of hanging past the client's idle window.
|
|
@@ -1166,9 +1216,22 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
|
|
|
1166
1216
|
return `This session's transcript can only run on ${fam} — Claude rejects its server-tool ids/thinking on replay. No ${fam} provider is available right now.${eta} Check the x-maxpool-zai-token / x-maxpool-kimi-token headers, or resume with 'cc ${incompat.homeProvider === 'kimi' ? 'kimi' : 'glm'}'.`;
|
|
1167
1217
|
}
|
|
1168
1218
|
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1219
|
+
// A large-context session (a provider already 400'd it as too big for its ~256K leg):
|
|
1220
|
+
// only a 1M-context Claude can hold it — the providers are structurally BARRED, not
|
|
1221
|
+
// merely "at their limit". Say the oversized truth + the two real ways out (wait for a
|
|
1222
|
+
// Claude account, or /compact), instead of the misleading "providers at limit" line.
|
|
1223
|
+
if (accountManager._isSessionLargeContext?.(requestInfo)) {
|
|
1224
|
+
const eta = Number.isFinite(retryAfter) && retryAfter > 0
|
|
1225
|
+
? ` A Claude account should free in ~${formatRetryDuration(retryAfter)}.` : '';
|
|
1226
|
+
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.`;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const claudeCount = accountManager.accounts.filter(a => a.type !== 'provider').length;
|
|
1230
|
+
// Only name the providers when this pool actually HAS them (`cc all`). On `cc ma`
|
|
1231
|
+
// (Claude-only) there are none, so the old hardcoded "and the GLM/Kimi providers"
|
|
1232
|
+
// was a lie. When present they DO serve (not barred), so they're saturated too.
|
|
1233
|
+
const providersClause = accountManager.accounts.some(a => a.type === 'provider')
|
|
1234
|
+
? ' and the GLM/Kimi providers' : '';
|
|
1172
1235
|
|
|
1173
1236
|
// No route is expected to recover within the queue window — i.e. every Claude
|
|
1174
1237
|
// account is at its own 5h/weekly limit. A short "retry in Ns" would be a lie;
|
|
@@ -1177,19 +1240,24 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
|
|
|
1177
1240
|
const eta = Number.isFinite(retryAfter) && retryAfter > 0
|
|
1178
1241
|
? ` Soonest reset in ~${formatRetryDuration(retryAfter)}, beyond the hold window.`
|
|
1179
1242
|
: '';
|
|
1180
|
-
|
|
1181
|
-
return thinking
|
|
1182
|
-
? `${base} GLM/Kimi fallback is unavailable because this session contains Anthropic signed thinking blocks; start a fresh non-thinking session to use them.`
|
|
1183
|
-
: base;
|
|
1243
|
+
return `No account can take this request — all ${claudeCount} Claude accounts${providersClause} are at their limit.${eta} Add another Claude account or wait for a quota reset.`;
|
|
1184
1244
|
}
|
|
1185
1245
|
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1246
|
+
return `No account can take this request right now — all ${claudeCount} Claude accounts${providersClause} are momentarily at their limit. Retry in ${retryAfter}s.`;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// A provider (GLM/Kimi) rejecting a request whose token count exceeds its context
|
|
1250
|
+
// window — Kimi's coding leg ("exceeded model token limit: 262144"), GLM's, or an
|
|
1251
|
+
// OpenAI-style "maximum context length". Kept narrow (specific context-overflow
|
|
1252
|
+
// phrasings, NOT a bare "token limit" which a rate-limit body also carries) so the
|
|
1253
|
+
// pin-to-Claude heal only fires on a genuine size overflow, not any 400. Rate-limit
|
|
1254
|
+
// 429s are intercepted earlier (classifyRateLimit) and never reach this check.
|
|
1255
|
+
function isContextLengthError(errorBody) {
|
|
1256
|
+
if (!errorBody) return false;
|
|
1257
|
+
return /exceeded model token limit|maximum context length|context length exceeded|context window (?:size )?(?:exceeded|too)|prompt is too long|input is too long|reduce the length of|too many (?:input )?tokens|request too large/i.test(errorBody);
|
|
1190
1258
|
}
|
|
1191
1259
|
|
|
1192
|
-
export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, streamResponse, startIdleRequestReaper };
|
|
1260
|
+
export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, isContextLengthError, streamResponse, startIdleRequestReaper };
|
|
1193
1261
|
|
|
1194
1262
|
async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
|
|
1195
1263
|
if (!upstreamRes.body) return '';
|
|
@@ -1467,6 +1535,9 @@ async function queueAndRetry(
|
|
|
1467
1535
|
const streamHoldMaxMs = queueConfig.streamHoldMaxMs == null
|
|
1468
1536
|
? 7 * 24 * 60 * 60 * 1000
|
|
1469
1537
|
: Math.max(0, Number(queueConfig.streamHoldMaxMs) || 0);
|
|
1538
|
+
const streamClientToleranceMs = queueConfig.streamClientToleranceMs == null
|
|
1539
|
+
? 3 * 60 * 60 * 1000
|
|
1540
|
+
: Math.max(0, Number(queueConfig.streamClientToleranceMs) || 0);
|
|
1470
1541
|
const queueWindowMs = computeQueueWindowMs({
|
|
1471
1542
|
cause,
|
|
1472
1543
|
stream: Boolean(requestInfo.stream),
|
|
@@ -1475,6 +1546,7 @@ async function queueAndRetry(
|
|
|
1475
1546
|
capacityMaxWaitMs,
|
|
1476
1547
|
nonStreamMaxWaitMs,
|
|
1477
1548
|
streamHoldMaxMs,
|
|
1549
|
+
streamClientToleranceMs,
|
|
1478
1550
|
isCountTokens: Boolean(requestInfo.isCountTokens),
|
|
1479
1551
|
countTokensMaxWaitMs,
|
|
1480
1552
|
});
|
package/src/tui.js
CHANGED
|
@@ -27,9 +27,9 @@ const vw = s => strip(s).length;
|
|
|
27
27
|
// ── Accounts-table columns ───────────────────────────────────
|
|
28
28
|
// Fixed column widths shared by the header row (acctHeader) AND every data row, so
|
|
29
29
|
// the header labels stay aligned with the columns they name. The Account/Provider/
|
|
30
|
-
// Status/Quota start offsets (4/
|
|
30
|
+
// Status/Quota start offsets (4/25/35/49) are pure functions of these widths + the
|
|
31
31
|
// 4-col row prefix, independent of the quota-bar width.
|
|
32
|
-
const NAME_W =
|
|
32
|
+
const NAME_W = 20; // a.name.slice(0, NAME_W).padEnd(NAME_W) — fits a full email like 2solarmax@gmail.com (19)
|
|
33
33
|
const PROVIDER_W = 9; // providerLabel(a).padEnd(PROVIDER_W) — fits "Anthropic"
|
|
34
34
|
const STATUS_W = 13; // rpad(status, STATUS_W) — fits "throttled 59s"
|
|
35
35
|
const ROW_PREFIX = ' '; // ' ' + sel(1) + cur(1) + ' ' — 4 cols before the name
|
|
@@ -250,6 +250,9 @@ export class TUI {
|
|
|
250
250
|
this.syncAccounts = syncAccounts;
|
|
251
251
|
this.onQuit = onQuit;
|
|
252
252
|
this.onRestart = onRestart;
|
|
253
|
+
// Set by index.js after construction (a deferred closure, not a constructor literal —
|
|
254
|
+
// avoids the const TDZ on applyUpdateIfReady, which is defined after `new TUI`).
|
|
255
|
+
this.checkNow = null;
|
|
253
256
|
|
|
254
257
|
this.log = []; // completed activity entries
|
|
255
258
|
this.active = new Map(); // in-flight requests
|
|
@@ -400,6 +403,7 @@ export class TUI {
|
|
|
400
403
|
case 'normal': this._keyNormal(k); break;
|
|
401
404
|
case 'accounts': this._keyAccounts(k); break;
|
|
402
405
|
case 'routing': this._keyRouting(k); break;
|
|
406
|
+
case 'updates': this._keyUpdates(k); break;
|
|
403
407
|
case 'select': this._keySelect(k); break;
|
|
404
408
|
case 'input': this._keyInput(k); break;
|
|
405
409
|
case 'confirm': this._keyConfirm(k); break;
|
|
@@ -435,11 +439,52 @@ export class TUI {
|
|
|
435
439
|
'Reload account credentials and newly added accounts from the config file.',
|
|
436
440
|
() => this._doSync(),
|
|
437
441
|
);
|
|
442
|
+
} else if (k === 'u') {
|
|
443
|
+
this.mode = 'updates';
|
|
438
444
|
}
|
|
439
445
|
// Enable/disable lives ONLY under [a] Accounts now (with rename/delete/login) —
|
|
440
446
|
// one home for every account mutation, instead of a duplicate top-level toggle.
|
|
441
447
|
}
|
|
442
448
|
|
|
449
|
+
// Automatic updates are "on" only when the whole chain is enabled: check npm →
|
|
450
|
+
// install to disk → seamlessly reload. Any one off means a release won't land hands-free.
|
|
451
|
+
_autoUpdateOn() {
|
|
452
|
+
const c = this.config || {};
|
|
453
|
+
return c.updateCheck !== false && c.autoUpdate === true && c.autoApply === true;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
_keyUpdates(k) {
|
|
457
|
+
if (k === 'c') {
|
|
458
|
+
// Check & apply now — the dance-killer: pull the latest + seamless-reload in place,
|
|
459
|
+
// no quit/relaunch. index.js wires this.checkNow (applies regardless of autoApply).
|
|
460
|
+
this.mode = 'normal';
|
|
461
|
+
if (this.checkNow) this.checkNow();
|
|
462
|
+
else this._addLog('Update check unavailable on this worker');
|
|
463
|
+
} else if (k === 't') {
|
|
464
|
+
this._toggleAutoUpdate();
|
|
465
|
+
} else if (k === 'esc' || k === 'q') {
|
|
466
|
+
this.mode = 'normal';
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async _toggleAutoUpdate() {
|
|
471
|
+
const turnOn = !this._autoUpdateOn();
|
|
472
|
+
// ON = the full hands-free chain; OFF = keep checking (banner still shows) but never
|
|
473
|
+
// install/reload without the user. Mutating this.config (=== the object index.js's
|
|
474
|
+
// update timer reads) takes effect live; saveConfig persists it (index.js writes these
|
|
475
|
+
// three keys) so it survives a restart.
|
|
476
|
+
this.config.updateCheck = true;
|
|
477
|
+
this.config.autoUpdate = turnOn;
|
|
478
|
+
this.config.autoApply = turnOn;
|
|
479
|
+
try {
|
|
480
|
+
await this.saveConfig(this.config);
|
|
481
|
+
this._addLog(`Automatic updates ${turnOn ? 'on' : 'off'}`);
|
|
482
|
+
} catch (error) {
|
|
483
|
+
this._addLog(`Could not save update setting: ${error.message}`);
|
|
484
|
+
}
|
|
485
|
+
this.mode = 'normal';
|
|
486
|
+
}
|
|
487
|
+
|
|
443
488
|
_keyAccounts(k) {
|
|
444
489
|
if (k === 'k') {
|
|
445
490
|
this.mode = 'input';
|
|
@@ -586,6 +631,9 @@ export class TUI {
|
|
|
586
631
|
.map(index => ({ account: this.am.accounts[index], index }))
|
|
587
632
|
.filter(({ account }) => {
|
|
588
633
|
if (action === 'prefer') return account.type !== 'provider' && account.enabled;
|
|
634
|
+
// Enable/disable also works on runtime providers (GLM/Kimi) — a session-only
|
|
635
|
+
// toggle, since they're not in config. Rename/delete stay config-account-only.
|
|
636
|
+
if (action === 'toggle') return this._configAccountIndex(account) >= 0 || account.type === 'provider';
|
|
589
637
|
return this._configAccountIndex(account) >= 0;
|
|
590
638
|
})
|
|
591
639
|
.map(({ index }) => index);
|
|
@@ -872,7 +920,18 @@ export class TUI {
|
|
|
872
920
|
if (!account) return;
|
|
873
921
|
const configIndex = this._configAccountIndex(account);
|
|
874
922
|
if (configIndex < 0) {
|
|
875
|
-
|
|
923
|
+
// A runtime provider (GLM/Kimi) isn't in config — it's re-created from the `cc all`
|
|
924
|
+
// request headers — but its enable/disable IS durable: the flag persists in memory
|
|
925
|
+
// across `cc all` requests (the header upsert never re-enables it) and to state.json
|
|
926
|
+
// on the next save, so it stays benched across a restart too. Re-enable it here the
|
|
927
|
+
// same way whenever the user wants it back — there's no "removed forever" state.
|
|
928
|
+
if (account.type === 'provider') {
|
|
929
|
+
this.am.setAccountEnabled(idx, enabled);
|
|
930
|
+
if (!enabled && this.am.preferredAccountName === account.name) this.am.setRoutingMode?.('automatic');
|
|
931
|
+
this._addLog(`${enabled ? 'Enabled' : 'Disabled'} provider "${account.name}" — ${enabled ? 'routing resumed' : 'benched (stays off across cc all + restart; re-enable here anytime)'}`);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
this._addLog(`Cannot ${enabled ? 'enable' : 'disable'} "${account.name}" here (not in config)`);
|
|
876
935
|
return;
|
|
877
936
|
}
|
|
878
937
|
const previous = this.config.accounts[configIndex].enabled;
|
|
@@ -970,7 +1029,12 @@ export class TUI {
|
|
|
970
1029
|
// ── Header
|
|
971
1030
|
const v = this.am.versionInfo;
|
|
972
1031
|
const verStr = v?.current ? ` ${dim('v' + v.current)}` : '';
|
|
973
|
-
|
|
1032
|
+
// Auto-update state next to the version, so it's obvious whether a published release
|
|
1033
|
+
// lands hands-free (green "auto-update on") or needs a manual 'u' (dim "off").
|
|
1034
|
+
const auto = this._autoUpdateOn()
|
|
1035
|
+
? green('· auto-update on')
|
|
1036
|
+
: dim('· auto-update off');
|
|
1037
|
+
const left = bold(' Maxpool') + verStr + ' ' + auto;
|
|
974
1038
|
const port = this.config.proxy?.port || 3456;
|
|
975
1039
|
const right = `Port ${port} ${green('▲')} `;
|
|
976
1040
|
lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
|
|
@@ -995,8 +1059,10 @@ export class TUI {
|
|
|
995
1059
|
// blank line). The persistent banner IS the reminder; a long-lived session's
|
|
996
1060
|
// periodic re-check keeps it current (index.js updateTimer refreshes versionInfo).
|
|
997
1061
|
if (v?.hasUpdate && v?.latest) {
|
|
998
|
-
|
|
999
|
-
|
|
1062
|
+
// No more "run npm i -g, then press r" — that manual dance is what the Updates menu
|
|
1063
|
+
// kills. Auto-update ON: it applies itself; either way 'u' pulls + reloads in place.
|
|
1064
|
+
const how = this._autoUpdateOn() ? 'applying automatically · or press u now' : 'press u to update now';
|
|
1065
|
+
lines.push(' ' + yellow(`↑ Update available: v${v.current} → v${v.latest}`) + dim(` · ${how}`));
|
|
1000
1066
|
}
|
|
1001
1067
|
const routing = this.am.routingMode === 'preferred'
|
|
1002
1068
|
? `Manual preference: ${this.am.preferredAccountName} (automatic failover)`
|
|
@@ -1048,11 +1114,11 @@ export class TUI {
|
|
|
1048
1114
|
// misaligned second header).
|
|
1049
1115
|
lines.push(dimUnderline(acctHeader(W)));
|
|
1050
1116
|
const showBoth = W >= 70;
|
|
1051
|
-
//
|
|
1052
|
-
// gaps); grew by +4 with the wider
|
|
1117
|
+
// 65/54 = the fixed pre-bar column span (prefix + Account + Provider + Status +
|
|
1118
|
+
// gaps); grew by +4 with the wider 20-col Account column (was 61/50 at NAME_W 16).
|
|
1053
1119
|
const bw = showBoth
|
|
1054
|
-
? Math.max(5, Math.min(20, Math.floor((W -
|
|
1055
|
-
: Math.max(5, Math.min(20, W -
|
|
1120
|
+
? Math.max(5, Math.min(20, Math.floor((W - 65) / 2)))
|
|
1121
|
+
: Math.max(5, Math.min(20, W - 54));
|
|
1056
1122
|
|
|
1057
1123
|
// Claude/OAuth accounts first, providers (GLM/Kimi fallback) last — a stable
|
|
1058
1124
|
// display order over the canonical am.accounts array (which stays untouched so
|
|
@@ -1341,7 +1407,11 @@ export class TUI {
|
|
|
1341
1407
|
_renderFooter() {
|
|
1342
1408
|
switch (this.mode) {
|
|
1343
1409
|
case 'normal':
|
|
1344
|
-
return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('s')} Sync ${bold('r')} Restart ${bold('q')} Stop`;
|
|
1410
|
+
return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('s')} Sync ${bold('u')} Updates ${bold('r')} Restart ${bold('q')} Stop`;
|
|
1411
|
+
case 'updates': {
|
|
1412
|
+
const state = this._autoUpdateOn() ? green('on') : dim('off');
|
|
1413
|
+
return ` ${bold('c')} Check & apply now ${bold('t')} Automatic updates: ${state} ↻ ${bold('Esc')} Back`;
|
|
1414
|
+
}
|
|
1345
1415
|
case 'accounts':
|
|
1346
1416
|
return ` ${bold('l')} Login/re-auth (browser) ${bold('k')} API key ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
|
|
1347
1417
|
case 'routing': {
|