maxpool 1.5.78 → 1.5.80
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 +165 -30
- package/src/tui.js +87 -140
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -111,6 +111,7 @@ const DEFAULT_SCHEDULER = {
|
|
|
111
111
|
perAccountConcurrencyTarget: 3, // D: soft per-account in-flight target; past it, capPenalty bites
|
|
112
112
|
capPenaltyWeight: 10, // steep penalty per unit of in-flight depth past D (throttle safety floor)
|
|
113
113
|
paceCostWeight: 1.5, // soft de-preference of accounts burning ahead of pace (was the ×6 term)
|
|
114
|
+
utilizationWeight: 3, // RAW utilization cost — drives load balancing in the mid-range
|
|
114
115
|
scarcityWeight: 6, // legacy; superseded by paceCostWeight (kept so old configs don't error)
|
|
115
116
|
// Reserve-account OVERFLOW model. A weekly-RESERVE account (util 0.85-0.95) used to
|
|
116
117
|
// sit idle behind a healthy-only first pass; now it's eligible in the first pass but
|
|
@@ -140,22 +141,30 @@ const DEFAULT_SCHEDULER = {
|
|
|
140
141
|
crossAccountThinkingMigration: true,
|
|
141
142
|
// Cross-PROVIDER fallback policy for 'cc all' (profile=all), i.e. whether a session
|
|
142
143
|
// may be served by a provider FAMILY other than its home (Claude ↔ GLM ↔ Kimi).
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
// 'always' — providers peer with Claude for a Claude/unknown session
|
|
150
|
-
// (load-balanced, not last-resort).
|
|
151
|
-
// DEFAULT is 'never' (2026-07-24): a Claude Code session stays on Anthropic. Routing
|
|
152
|
-
// Claude→GLM/Kimi proved unreliable (the coding legs 403/429 + ignore the model id),
|
|
153
|
-
// so cross-routing OUT of Claude is OFF by default; re-enable via the TUI routing
|
|
154
|
-
// cycle or config. This governs ONLY the Claude→provider direction.
|
|
155
|
-
// INVARIANT (all policies): a GLM/Kimi-origin session NEVER routes to an Anthropic
|
|
156
|
-
// account — Anthropic 400s on a non-`srvtoolu_` server_tool_use id; that direction
|
|
157
|
-
// is unfixable, not policy-tunable.
|
|
144
|
+
//
|
|
145
|
+
// SUPERSEDED by `routingMode` below — kept for backward-compat migration only. An
|
|
146
|
+
// old config carrying `crossProviderFallbackPolicy` but no `routingMode` is upgraded
|
|
147
|
+
// at boot: 'always' → 'balance', 'when-exhausted'/'never' → 'prefer-claude'. The
|
|
148
|
+
// per-provider `providers[<key>].claudeFallback` field still controls the same thing
|
|
149
|
+
// for its one provider under the legacy modes.
|
|
158
150
|
crossProviderFallbackPolicy: 'never',
|
|
151
|
+
// ROUTING MODE — the single knob that governs how sessions are distributed across
|
|
152
|
+
// the account pool. Replaces the hidden per-session binding that made 'always' not
|
|
153
|
+
// actually balance. Reported 2026-08-10: with cross-provider 'always' set, 20
|
|
154
|
+
// long-lived sessions that happened to start on the same Anthropic account hammered
|
|
155
|
+
// it at 82% while two GLM accounts at 2%/9% sat idle — because 'always' only governed
|
|
156
|
+
// the FIRST request; after that the session was pinned to one account.
|
|
157
|
+
//
|
|
158
|
+
// 'balance' — score EVERY request across the full pool. No session binding.
|
|
159
|
+
// Accounts drain evenly. The behaviour 'always' should always
|
|
160
|
+
// have been.
|
|
161
|
+
// 'prefer-claude' — score every request, but Anthropic accounts outrank providers
|
|
162
|
+
// unless they are all loaded/exhausted.
|
|
163
|
+
// 'prefer-zai' — GLM accounts preferred; Claude/Kimi fill overflow.
|
|
164
|
+
// 'prefer-kimi' — Kimi preferred; Claude/GLM fill overflow.
|
|
165
|
+
// 'sticky' — the old behaviour, made explicit. Sessions stay on the account
|
|
166
|
+
// they first land on until it goes hot, then rebalance.
|
|
167
|
+
routingMode: 'sticky',
|
|
159
168
|
// The OTHER cross direction, independent of the policy above: may a provider-origin
|
|
160
169
|
// (GLM/Kimi) session cross to the OTHER provider (GLM↔Kimi)? Default ON — both legs are
|
|
161
170
|
// lenient and accept each other's ids, and it's the reliable direction the user wants
|
|
@@ -244,6 +253,20 @@ function parseResetHeader(value) {
|
|
|
244
253
|
export class AccountManager {
|
|
245
254
|
constructor(accounts, switchThreshold = 0.90, schedulerOptions = {}, dependencies = {}) {
|
|
246
255
|
this.scheduler = { ...DEFAULT_SCHEDULER, ...schedulerOptions };
|
|
256
|
+
// Migrate the legacy single-value policy to the new mode if the caller (config)
|
|
257
|
+
// set `crossProviderFallbackPolicy` but not `routingMode`. The per-provider
|
|
258
|
+
// `providers[<key>].claudeFallback` still works under the prefer-* modes.
|
|
259
|
+
// IMPORTANT: when schedulerOptions is empty (no explicit crossProviderFallbackPolicy),
|
|
260
|
+
// the DEFAULT_SCHEDULER value 'never' triggers migration. But the old behaviour
|
|
261
|
+
// under 'never' WAS sticky pinning — so the default must map to 'sticky', not
|
|
262
|
+
// 'prefer-claude'. Only an EXPLICIT 'always' in the caller's config changes the mode.
|
|
263
|
+
if (!schedulerOptions.routingMode) {
|
|
264
|
+
const leg = schedulerOptions.crossProviderFallbackPolicy;
|
|
265
|
+
if (leg === 'always') this.scheduler.routingMode = 'balance';
|
|
266
|
+
else if (leg === 'when-exhausted') this.scheduler.routingMode = 'prefer-claude';
|
|
267
|
+
// 'never' or unset → sticky (the historical default: sessions pin to one account)
|
|
268
|
+
else this.scheduler.routingMode = 'sticky';
|
|
269
|
+
}
|
|
247
270
|
this._refreshAccessToken = dependencies.refreshAccessToken || refreshAccessToken;
|
|
248
271
|
this.accounts = accounts.map((acct, index) => ({
|
|
249
272
|
index,
|
|
@@ -1230,7 +1253,29 @@ export class AccountManager {
|
|
|
1230
1253
|
* score loop), so a healthy bound account never ping-pongs. */
|
|
1231
1254
|
_isBoundAccountHot(account) {
|
|
1232
1255
|
return this._isSessionQuotaUnavailable(account)
|
|
1233
|
-
|| ['reserve', 'critical', 'exhausted'].includes(this._weeklyPaceState(account))
|
|
1256
|
+
|| ['reserve', 'critical', 'exhausted'].includes(this._weeklyPaceState(account))
|
|
1257
|
+
// SESSION-window pressure counts too. _isSessionQuotaUnavailable only fires at
|
|
1258
|
+
// switchThreshold (0.90), and the weekly bands don't see the 5h window at all —
|
|
1259
|
+
// so an account at 82% of a 5h window was "not hot" and every bound session
|
|
1260
|
+
// stayed on it while idle accounts sat at 2%. Measured 2026-08-10: Anthropic at
|
|
1261
|
+
// Ses 82% / Wk 66% hammered flat-out beside two GLM accounts at 2% and 9%.
|
|
1262
|
+
// Uses the SOFT band (0.65), not reserve (0.85): the point is to shed load while
|
|
1263
|
+
// there is still headroom, not at the cliff edge. _shouldRebalanceBoundSession
|
|
1264
|
+
// still requires a clearly-cheaper, strictly-healthier target, so a hot account
|
|
1265
|
+
// with no better alternative keeps its sessions — this only opens the question.
|
|
1266
|
+
|| this._sessionWindowUsage(account) >= this.scheduler.weeklySoftThreshold;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/** Fraction of the SESSION (5h) window consumed, across both quota shapes.
|
|
1270
|
+
* Anthropic reports unified5h; a provider reports providerSes. Returns 0 when
|
|
1271
|
+
* unknown — an unreadable window must never make an account look hot. */
|
|
1272
|
+
_sessionWindowUsage(account) {
|
|
1273
|
+
const q = account?.quota;
|
|
1274
|
+
if (!q) return 0;
|
|
1275
|
+
const vals = [];
|
|
1276
|
+
if (q.unified5h != null) vals.push(clamp01(q.unified5h));
|
|
1277
|
+
if (q.providerSes != null) vals.push(clamp01(q.providerSes));
|
|
1278
|
+
return vals.length ? Math.max(...vals) : 0;
|
|
1234
1279
|
}
|
|
1235
1280
|
|
|
1236
1281
|
/** Decide whether a bound session should leave its (hot) account THIS request.
|
|
@@ -1334,6 +1379,18 @@ export class AccountManager {
|
|
|
1334
1379
|
// candidate loop lands on the least-loaded healthy account, spreading the move.
|
|
1335
1380
|
if ((WEEKLY_TIER[this._weeklyRawState(bound)] ?? 0) >= WEEKLY_TIER.reserve) return true;
|
|
1336
1381
|
|
|
1382
|
+
// Same absolute escape for the SESSION window. The tier test below compares WEEKLY
|
|
1383
|
+
// tiers, so a bound account burning down its 5h window can never satisfy it while
|
|
1384
|
+
// its weekly is merely soft — which is exactly how an account at Ses 82% kept every
|
|
1385
|
+
// session while idle accounts sat at 2%. Requires a MATERIALLY cheaper target so
|
|
1386
|
+
// this can't churn between two similarly-loaded accounts.
|
|
1387
|
+
// No score margin here, for the reason the weekly escape documents directly above:
|
|
1388
|
+
// boundScore*0.5 on an IDLE near-cap account sits below every candidate's minimum
|
|
1389
|
+
// score, so the margin is unsatisfiable and pins the session to the account it is
|
|
1390
|
+
// meant to relieve. The candidate loop has already kept only RAW-healthy targets,
|
|
1391
|
+
// and preserving the last of a 5h window beats concurrency spread.
|
|
1392
|
+
if (this._sessionWindowUsage(bound) >= this.scheduler.weeklyReserveThreshold) return true;
|
|
1393
|
+
|
|
1337
1394
|
// Otherwise the trigger was PACE-only on a RAW-healthy account — a fast-burner
|
|
1338
1395
|
// that still has real absolute headroom (RAW soft but pace reserve/critical,
|
|
1339
1396
|
// e.g. 79% used resetting in ~3.5d). Keep the conservative gate so it isn't
|
|
@@ -1531,7 +1588,15 @@ export class AccountManager {
|
|
|
1531
1588
|
return preferred;
|
|
1532
1589
|
}
|
|
1533
1590
|
}
|
|
1534
|
-
|
|
1591
|
+
// Session binding only applies in 'sticky' mode. Every other mode scores each
|
|
1592
|
+
// request independently — the whole point of 'balance' is that 20 sessions that
|
|
1593
|
+
// happened to start on the same account do NOT keep hammering it while others
|
|
1594
|
+
// idle. Warmup-pull still works under the non-sticky modes because it keys on
|
|
1595
|
+
// `_isWarming`, not on a binding.
|
|
1596
|
+
const isStickyMode = this.scheduler.routingMode === 'sticky';
|
|
1597
|
+
const bound = isStickyMode
|
|
1598
|
+
? this._boundAccount(requestInfo.sessionKey, profile, excludedIndexes, requestInfo)
|
|
1599
|
+
: null;
|
|
1535
1600
|
if (bound) {
|
|
1536
1601
|
// Warmup-pull: onboard a freshly-ADDED account (added mid-session, no reload)
|
|
1537
1602
|
// by DIRECTLY re-homing this migration-safe session onto the warming account,
|
|
@@ -1787,18 +1852,52 @@ export class AccountManager {
|
|
|
1787
1852
|
// (10/20) → fallback-only. A foreign session is provider-only regardless.
|
|
1788
1853
|
_effectivePriority(account, requestInfo = {}) {
|
|
1789
1854
|
const base = Number.isFinite(account.priority) ? account.priority : 0;
|
|
1855
|
+
const mode = this.scheduler.routingMode;
|
|
1856
|
+
const incompatible = this._effectiveIncompatible(requestInfo).incompatible;
|
|
1857
|
+
// An incompatible session is pinned to its provider family — no mode overrides that.
|
|
1858
|
+
if (incompatible) {
|
|
1859
|
+
const isHome = account.type === 'provider'
|
|
1860
|
+
&& (mode === 'prefer-zai' ? account.provider === 'zai'
|
|
1861
|
+
: mode === 'prefer-kimi' ? account.provider === 'kimi' : true);
|
|
1862
|
+
return isHome ? 0 : base;
|
|
1863
|
+
}
|
|
1864
|
+
// Balance: every account peers at priority 0. Accounts are ranked by score alone.
|
|
1865
|
+
if (mode === 'balance') return 0;
|
|
1866
|
+
// Prefer-* modes: the preferred family sits at 0, everything else at its base
|
|
1867
|
+
// priority (10 for GLM, 20 for Kimi). So the preferred family is chosen first and
|
|
1868
|
+
// the others only when every preferred account is unavailable (the score loop's
|
|
1869
|
+
// pass-1 admits reserve accounts, so a loaded preferred account DOES give way).
|
|
1870
|
+
if (mode === 'prefer-claude') {
|
|
1871
|
+
return account.type === 'provider' ? base : 0;
|
|
1872
|
+
}
|
|
1873
|
+
if (mode === 'prefer-zai') {
|
|
1874
|
+
return account.provider === 'zai' ? 0 : base;
|
|
1875
|
+
}
|
|
1876
|
+
if (mode === 'prefer-kimi') {
|
|
1877
|
+
return account.provider === 'kimi' ? 0 : base;
|
|
1878
|
+
}
|
|
1879
|
+
// Sticky: legacy behaviour — the 'always' policy promoted providers to 0.
|
|
1790
1880
|
if (account.type === 'provider'
|
|
1791
|
-
&& this._claudeFallbackFor(account.provider) === 'always'
|
|
1792
|
-
&& !this._effectiveIncompatible(requestInfo).incompatible) {
|
|
1881
|
+
&& this._claudeFallbackFor(account.provider) === 'always') {
|
|
1793
1882
|
return 0;
|
|
1794
1883
|
}
|
|
1795
1884
|
return base;
|
|
1796
1885
|
}
|
|
1797
1886
|
|
|
1887
|
+
setProviderRoutingMode(mode) {
|
|
1888
|
+
if (!['balance', 'prefer-claude', 'prefer-zai', 'prefer-kimi', 'sticky'].includes(mode)) return false;
|
|
1889
|
+
this.scheduler.routingMode = mode;
|
|
1890
|
+
console.log(`[Maxpool] Routing mode set to "${mode}"`);
|
|
1891
|
+
return true;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// Legacy shim — the TUI routing screen still cycles this. Maps to the new mode.
|
|
1798
1895
|
setCrossProviderFallbackPolicy(policy) {
|
|
1799
1896
|
if (!['never', 'when-exhausted', 'always'].includes(policy)) return false;
|
|
1800
1897
|
this.scheduler.crossProviderFallbackPolicy = policy;
|
|
1801
|
-
|
|
1898
|
+
// Map to the new mode so the binding/priority logic agrees.
|
|
1899
|
+
this.scheduler.routingMode = policy === 'always' ? 'balance' : policy === 'when-exhausted' ? 'prefer-claude' : 'sticky';
|
|
1900
|
+
console.log(`[Maxpool] Cross-provider fallback policy set to "${policy}" (routing mode: ${this.scheduler.routingMode})`);
|
|
1802
1901
|
return true;
|
|
1803
1902
|
}
|
|
1804
1903
|
|
|
@@ -1872,15 +1971,20 @@ export class AccountManager {
|
|
|
1872
1971
|
|
|
1873
1972
|
// Compatible session — includes Kimi and GLM-without-server-tools, whose regular
|
|
1874
1973
|
// tool_use ids pass Anthropic's loose validation, AND ordinary Claude sessions.
|
|
1875
|
-
//
|
|
1876
|
-
//
|
|
1877
|
-
//
|
|
1878
|
-
//
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1974
|
+
// Under the new routing modes, providers are eligible whenever the mode allows
|
|
1975
|
+
// them to peer (balance + prefer-{zai,kimi}) or serve as fallback
|
|
1976
|
+
// (prefer-claude + sticky). The legacy per-provider `claudeFallback: 'never'`
|
|
1977
|
+
// gate only applies under 'sticky' — the old behaviour it was written for.
|
|
1978
|
+
if (account.type === 'provider') {
|
|
1979
|
+
const mode = this.scheduler.routingMode;
|
|
1980
|
+
// Balance and prefer-{zai,kimi}: providers are always eligible (scored, not gated).
|
|
1981
|
+
if (mode === 'balance' || mode === 'prefer-zai' || mode === 'prefer-kimi') return true;
|
|
1982
|
+
// Prefer-claude: providers serve as overflow. Still eligible — priority handles the
|
|
1983
|
+
// preference; an available Claude account always wins on priority.
|
|
1984
|
+
if (mode === 'prefer-claude') return true;
|
|
1985
|
+
// Sticky: the legacy per-provider gate applies — this is the mode it was written for.
|
|
1986
|
+
if (this._claudeFallbackFor(account.provider) === 'never') return false;
|
|
1987
|
+
}
|
|
1884
1988
|
return true;
|
|
1885
1989
|
}
|
|
1886
1990
|
|
|
@@ -2057,6 +2161,14 @@ export class AccountManager {
|
|
|
2057
2161
|
// soft de-preference of accounts burning ahead of an even pace. Never a bench.
|
|
2058
2162
|
const paceCost = this._accountScarcity(account, now) * this.scheduler.paceCostWeight;
|
|
2059
2163
|
|
|
2164
|
+
// RAW utilization cost — direct, not pace-adjusted. The pace cost above discounts
|
|
2165
|
+
// by how far into the window you are, so an account at 80% with 2h left is only
|
|
2166
|
+
// "slightly ahead of pace" → tiny cost. That's right for avoiding premature
|
|
2167
|
+
// benching, but wrong for load balancing: an account at 80% should be clearly less
|
|
2168
|
+
// attractive than one at 10% even if both are "on pace". Measured 2026-08-10: cc at
|
|
2169
|
+
// 80% scored 52.30 vs glm at 10% at 52.15 — a 0.15 gap drowned by round-robin.
|
|
2170
|
+
const utilizationCost = this._rawUtilization(account) * this.scheduler.utilizationWeight;
|
|
2171
|
+
|
|
2060
2172
|
// Per-model weekly de-preference: an account whose scoped weekly for THIS
|
|
2061
2173
|
// request's model (e.g. Fable) is high-but-not-exhausted is a poor pick for
|
|
2062
2174
|
// that model — shed its load toward healthier accounts BEFORE the hard bench
|
|
@@ -2083,7 +2195,7 @@ export class AccountManager {
|
|
|
2083
2195
|
// default) learns the real number within a cycle. `probing`/requalify still
|
|
2084
2196
|
// flags a never-seen account for learning — that path is unchanged.
|
|
2085
2197
|
|
|
2086
|
-
return concurrency + capPenalty + paceCost + scopedPace + spread + ramp + reserveCost + failurePenalty;
|
|
2198
|
+
return concurrency + capPenalty + paceCost + utilizationCost + scopedPace + spread + ramp + reserveCost + failurePenalty;
|
|
2087
2199
|
}
|
|
2088
2200
|
|
|
2089
2201
|
/**
|
|
@@ -2137,6 +2249,29 @@ export class AccountManager {
|
|
|
2137
2249
|
return scarcity;
|
|
2138
2250
|
}
|
|
2139
2251
|
|
|
2252
|
+
/** RAW utilization (0..1) — not pace-adjusted. Reads the same fields as
|
|
2253
|
+
* _accountScarcity but WITHOUT the elapsed-fraction discount. This is the signal
|
|
2254
|
+
* the load balancer needs: an account at 80% is more expensive than one at 10%,
|
|
2255
|
+
* full stop. */
|
|
2256
|
+
_rawUtilization(account) {
|
|
2257
|
+
const q = account?.quota;
|
|
2258
|
+
if (!q) return 0;
|
|
2259
|
+
// SESSION windows use raw utilization — headroom is consumed immediately and an
|
|
2260
|
+
// 80%-used 5h window is genuinely more expensive than a 10%-used one right now.
|
|
2261
|
+
let util = 0;
|
|
2262
|
+
if (q.unified5h != null) util = Math.max(util, clamp01(q.unified5h));
|
|
2263
|
+
if (q.providerSes != null) util = Math.max(util, clamp01(q.providerSes));
|
|
2264
|
+
// WEEKLY windows use PACE-ADJUSTED utilization (via _windowScarcity), not raw —
|
|
2265
|
+
// a 79% account resetting in 2h has plenty of headroom and should be cheap to
|
|
2266
|
+
// spend (the use-it-or-lose-it principle). Using raw weekly would break that.
|
|
2267
|
+
// The pace cost already carries this signal; here we add only the session signal
|
|
2268
|
+
// the pace cost was too weak to express.
|
|
2269
|
+
if (q.tokensLimit != null && q.tokensLimit > 0 && q.tokensRemaining != null) {
|
|
2270
|
+
util = Math.max(util, 1 - q.tokensRemaining / q.tokensLimit);
|
|
2271
|
+
}
|
|
2272
|
+
return util;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2140
2275
|
_windowScarcity(util, resetMs, windowLen, now = Date.now()) {
|
|
2141
2276
|
const used = clamp01(util);
|
|
2142
2277
|
if (!resetMs || resetMs <= now) return used; // unknown / just-reset → face value
|
package/src/tui.js
CHANGED
|
@@ -408,7 +408,6 @@ export class TUI {
|
|
|
408
408
|
case 'accounts': this._keyAccounts(k); break;
|
|
409
409
|
case 'routing': this._keyRouting(k); break;
|
|
410
410
|
case 'updates': this._keyUpdates(k); break;
|
|
411
|
-
case 'providers': this._keyProviders(k); break;
|
|
412
411
|
case 'addtype': this._keyAddType(k); break;
|
|
413
412
|
case 'select': this._keySelect(k); break;
|
|
414
413
|
case 'input': this._keyInput(k); break;
|
|
@@ -447,8 +446,6 @@ export class TUI {
|
|
|
447
446
|
);
|
|
448
447
|
} else if (k === 'u') {
|
|
449
448
|
this.mode = 'updates';
|
|
450
|
-
} else if (k === 'p') {
|
|
451
|
-
this.mode = 'providers';
|
|
452
449
|
} else if (k === 'h') {
|
|
453
450
|
this.hideDisabled = !this.hideDisabled;
|
|
454
451
|
this._addLog(this.hideDisabled ? 'Hiding disabled accounts' : 'Showing all accounts');
|
|
@@ -592,10 +589,10 @@ export class TUI {
|
|
|
592
589
|
// Four account types, two credential sources. Every path lands here so there is
|
|
593
590
|
// exactly ONE thing to learn: press `a`, pick a type, supply a credential.
|
|
594
591
|
static ADD_TYPES = [
|
|
595
|
-
{ key: '1', id: 'anthropic-oauth', label: 'Anthropic subscription
|
|
596
|
-
{ key: '2', id: 'anthropic-key', label: 'Anthropic API key' },
|
|
597
|
-
{ key: '3', id: 'zai', label: 'GLM
|
|
598
|
-
{ key: '4', id: 'kimi', label: 'Kimi
|
|
592
|
+
{ key: '1', id: 'anthropic-oauth', label: 'Anthropic subscription', hint: 'log in with a browser (Max / Pro plan)' },
|
|
593
|
+
{ key: '2', id: 'anthropic-key', label: 'Anthropic API key', hint: 'pay-per-token key from console.anthropic.com' },
|
|
594
|
+
{ key: '3', id: 'zai', label: 'GLM (z.ai) API key', hint: '' },
|
|
595
|
+
{ key: '4', id: 'kimi', label: 'Kimi (Moonshot) API key', hint: '' },
|
|
599
596
|
];
|
|
600
597
|
|
|
601
598
|
_keyAddType(k) {
|
|
@@ -660,15 +657,23 @@ export class TUI {
|
|
|
660
657
|
}
|
|
661
658
|
|
|
662
659
|
_renderAddType(buf) {
|
|
663
|
-
buf.push(`${bold('Add an account')}
|
|
660
|
+
buf.push(`${bold('Add an account')} ${dim('pick what you have')}`);
|
|
664
661
|
buf.push('');
|
|
665
662
|
for (const t of TUI.ADD_TYPES) {
|
|
666
|
-
buf.push(` ${bold(t.key)} ${t.label}`);
|
|
663
|
+
buf.push(` ${bold(t.key)} ${t.label.padEnd(26)}${dim(t.hint || '')}`);
|
|
667
664
|
}
|
|
668
665
|
buf.push('');
|
|
669
|
-
buf.push(dim('
|
|
670
|
-
buf.push(dim('
|
|
671
|
-
buf.push(
|
|
666
|
+
buf.push(dim(' Fixing an account that says "reauth"? Pick 1 and log in as that account.'));
|
|
667
|
+
buf.push(dim(' It repairs the existing row, it does not add a second one.'));
|
|
668
|
+
buf.push('');
|
|
669
|
+
// Kept verbatim from the deleted Providers panel — the only place that explained
|
|
670
|
+
// BOTH credential sources and the gcloud command a non-owner needs.
|
|
671
|
+
buf.push(dim(' For 2-4, two ways to supply the key:'));
|
|
672
|
+
buf.push(dim(' A) Paste it — stored in your config (0600). No cloud setup.'));
|
|
673
|
+
buf.push(dim(' B) Type a GCP Secret Manager name — the key never touches disk.'));
|
|
674
|
+
buf.push(dim(' Store it: ') + 'gcloud secrets create MY_KEY --data-file=-');
|
|
675
|
+
buf.push(dim(' Maxpool reads it as YOU: ') + 'gcloud auth application-default login');
|
|
676
|
+
buf.push(dim(' Delete the secret and the account stops working everywhere.'));
|
|
672
677
|
}
|
|
673
678
|
|
|
674
679
|
// Derive a human-readable account name from a GCP secret name.
|
|
@@ -683,17 +688,6 @@ export class TUI {
|
|
|
683
688
|
return user ? `${provLabel} ${user}` : `${provLabel} primary`;
|
|
684
689
|
}
|
|
685
690
|
|
|
686
|
-
_keyProviders(k) {
|
|
687
|
-
if (k === 'a') {
|
|
688
|
-
this._providerAddStep('type');
|
|
689
|
-
} else if (k === 'd') {
|
|
690
|
-
this._startProviderSelection('delete');
|
|
691
|
-
} else if (k === 't') {
|
|
692
|
-
this._startProviderSelection('toggle');
|
|
693
|
-
} else if (k === 'esc' || k === 'q') {
|
|
694
|
-
this.mode = 'normal';
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
691
|
|
|
698
692
|
// Multi-step input for adding a provider. Steps: type → secret/key → name.
|
|
699
693
|
// Name LAST so it can be pre-filled from the secret (RESTRICTED_AL_MAXPOOL_ZAI →
|
|
@@ -707,17 +701,17 @@ export class TUI {
|
|
|
707
701
|
this.inputSensitive = false;
|
|
708
702
|
this.inputCb = value => {
|
|
709
703
|
const provider = String(value || '').trim().toLowerCase();
|
|
710
|
-
if (provider !== 'zai' && provider !== 'kimi') { this._addLog('Type must be zai or kimi'); this.mode = '
|
|
704
|
+
if (provider !== 'zai' && provider !== 'kimi') { this._addLog('Type must be zai or kimi'); this.mode = 'accounts'; return; }
|
|
711
705
|
this._providerAddStep('secret', { ...prev, provider });
|
|
712
706
|
};
|
|
713
707
|
} else if (step === 'secret') {
|
|
714
708
|
this.mode = 'input';
|
|
715
|
-
this.inputPrompt = `${prev.
|
|
709
|
+
this.inputPrompt = `${prev.provider === 'kimi' ? 'Kimi' : 'GLM'} key — paste it, or type a GCP secret name`;
|
|
716
710
|
this.inputBuf = '';
|
|
717
711
|
this.inputSensitive = false;
|
|
718
712
|
this.inputCb = async value => {
|
|
719
713
|
const input = String(value || '').trim();
|
|
720
|
-
if (!input) { this.mode = '
|
|
714
|
+
if (!input) { this.mode = 'accounts'; return; }
|
|
721
715
|
// Heuristic: a GCP secret name is uppercase/dashes/underscores and short.
|
|
722
716
|
// An API key is long and contains dots/mixed-case/alphanumeric.
|
|
723
717
|
const looksLikeSecretName = /^[A-Z][A-Z0-9_-]{2,60}$/.test(input) && !input.includes('.');
|
|
@@ -735,7 +729,7 @@ export class TUI {
|
|
|
735
729
|
this.inputCb = async value => {
|
|
736
730
|
const name = String(value || '').trim() || TUI.deriveProviderName(prev.provider, prev.secretName || '');
|
|
737
731
|
if (this.am.accounts.some(a => a.name === name)) {
|
|
738
|
-
this._addLog(`Account "${name}" already exists`); this.mode = '
|
|
732
|
+
this._addLog(`Account "${name}" already exists`); this.mode = 'accounts'; return;
|
|
739
733
|
}
|
|
740
734
|
await this._doAddProvider({ ...prev, name });
|
|
741
735
|
};
|
|
@@ -743,7 +737,7 @@ export class TUI {
|
|
|
743
737
|
}
|
|
744
738
|
|
|
745
739
|
async _doAddProvider({ name, provider, secretName, apiKey }) {
|
|
746
|
-
this.mode = '
|
|
740
|
+
this.mode = 'accounts';
|
|
747
741
|
if (secretName) this._addLog(`Resolving secret "${secretName}" from GCP…`);
|
|
748
742
|
else this._addLog(`Adding "${name}" with direct API key…`);
|
|
749
743
|
this.render();
|
|
@@ -778,50 +772,7 @@ export class TUI {
|
|
|
778
772
|
}
|
|
779
773
|
}
|
|
780
774
|
|
|
781
|
-
_startProviderSelection(action) {
|
|
782
|
-
const providers = this.am.accounts.filter(a => a.type === 'provider');
|
|
783
|
-
if (!providers.length) { this._addLog('No providers to manage'); return; }
|
|
784
|
-
this._selOptions = providers.map(a => a.index);
|
|
785
|
-
this._selLabels = providers.map(a => {
|
|
786
|
-
const enabled = a.enabled !== false;
|
|
787
|
-
const tag = a.configSourced ? ' (GCP)' : ' (header)';
|
|
788
|
-
const secret = a.secretName ? ` [${a.secretName}]` : '';
|
|
789
|
-
return `${a.name}${tag}${secret}${enabled ? '' : ' ✕'}`;
|
|
790
|
-
});
|
|
791
|
-
this.selAction = action;
|
|
792
|
-
this.mode = 'select';
|
|
793
|
-
}
|
|
794
775
|
|
|
795
|
-
_renderProviders(buf, _width) {
|
|
796
|
-
const providers = this.am.accounts.filter(a => a.type === 'provider');
|
|
797
|
-
buf.push(`${bold('Providers (GLM / Kimi)')} ${dim('— managed via GCP Secret Manager')}`);
|
|
798
|
-
buf.push('');
|
|
799
|
-
if (!providers.length) {
|
|
800
|
-
buf.push(dim(' No providers configured.'));
|
|
801
|
-
buf.push('');
|
|
802
|
-
buf.push(dim(' Press ') + bold('a') + dim(' to add one. Two ways to supply the key:'));
|
|
803
|
-
buf.push('');
|
|
804
|
-
buf.push(dim(' A) Paste the API key directly — stored in your config (0600).'));
|
|
805
|
-
buf.push(dim(' Simplest; works with no cloud setup.'));
|
|
806
|
-
buf.push('');
|
|
807
|
-
buf.push(dim(' B) Point at a GCP Secret Manager name — the key never touches disk.'));
|
|
808
|
-
buf.push(dim(' Store it: ') + 'gcloud secrets create MY_KEY --data-file=-');
|
|
809
|
-
buf.push(dim(' Maxpool resolves it via YOUR gcloud login:'));
|
|
810
|
-
buf.push(dim(' ') + 'gcloud auth application-default login');
|
|
811
|
-
buf.push(dim(' Delete the secret and the provider stops working everywhere.'));
|
|
812
|
-
return;
|
|
813
|
-
}
|
|
814
|
-
for (const a of providers) {
|
|
815
|
-
const enabled = a.enabled !== false;
|
|
816
|
-
const tag = a.configSourced ? dim(' (GCP)') : dim(' (header)');
|
|
817
|
-
const secret = a.secretName ? dim(` [${a.secretName}]`) : '';
|
|
818
|
-
const status = a.status === 'error' ? red(a.lastError || 'error')
|
|
819
|
-
: enabled ? green('active') : red('✕');
|
|
820
|
-
const q = a.quota;
|
|
821
|
-
const ses = q?.providerSes != null ? ` Ses ${Math.round(q.providerSes * 100)}%` : '';
|
|
822
|
-
buf.push(` ${enabled ? '' : dim('')} ${bold(a.name)} ${a.provider}${tag}${secret} ${status}${ses}`);
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
776
|
|
|
826
777
|
_keyRouting(k) {
|
|
827
778
|
if (k === 'a') {
|
|
@@ -882,51 +833,6 @@ export class TUI {
|
|
|
882
833
|
}
|
|
883
834
|
|
|
884
835
|
_keySelect(k) {
|
|
885
|
-
// Provider selection (from the providers screen) uses its own option list.
|
|
886
|
-
if (this._selOptions && (this.selAction === 'delete' || this.selAction === 'toggle')
|
|
887
|
-
&& this.mode === 'select' && this.am.accounts[this._selOptions[0]]?.type === 'provider') {
|
|
888
|
-
const opts = this._selOptions;
|
|
889
|
-
const position = Math.max(0, opts.indexOf(this.selIdx));
|
|
890
|
-
if (k === 'up' || k === 'k') this.selIdx = opts[Math.max(0, position - 1)] ?? this.selIdx;
|
|
891
|
-
else if (k === 'down' || k === 'j') this.selIdx = opts[Math.min(opts.length - 1, position + 1)] ?? this.selIdx;
|
|
892
|
-
else if (k === 'enter') {
|
|
893
|
-
const account = this.am.accounts[this.selIdx];
|
|
894
|
-
if (!account) { this.mode = 'providers'; return; }
|
|
895
|
-
if (this.selAction === 'toggle') {
|
|
896
|
-
const enable = !account.enabled;
|
|
897
|
-
this._confirm(
|
|
898
|
-
`${enable ? 'Enable' : 'Disable'} "${account.name}"?`,
|
|
899
|
-
enable ? 'Allow this provider to receive requests again.' : 'Stop routing to it. Active requests continue.',
|
|
900
|
-
() => { this._doToggle(this.selIdx, enable); this.mode = 'providers'; },
|
|
901
|
-
);
|
|
902
|
-
} else if (this.selAction === 'delete') {
|
|
903
|
-
this._confirm(
|
|
904
|
-
`Delete provider "${account.name}"?`,
|
|
905
|
-
account.configSourced
|
|
906
|
-
? 'Removes it from config and GCP reference. The GCP secret itself stays — delete it separately if needed.'
|
|
907
|
-
: 'Removes the runtime provider. It returns on the next request that sends its token.',
|
|
908
|
-
async () => {
|
|
909
|
-
if (account.configSourced) {
|
|
910
|
-
try {
|
|
911
|
-
const { atomicConfigUpdate } = await import('../config.js');
|
|
912
|
-
await atomicConfigUpdate(cfg => {
|
|
913
|
-
if (Array.isArray(cfg.providers)) {
|
|
914
|
-
cfg.providers = cfg.providers.filter(p => p.name !== account.name);
|
|
915
|
-
}
|
|
916
|
-
});
|
|
917
|
-
} catch (err) { this._addLog(`Config update failed: ${err.message}`); }
|
|
918
|
-
}
|
|
919
|
-
this.am.removeAccount(this.selIdx);
|
|
920
|
-
this._addLog(`Deleted provider "${account.name}"`);
|
|
921
|
-
this.mode = 'providers';
|
|
922
|
-
},
|
|
923
|
-
);
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
else if (k === 'esc' || k === 'q') { this.mode = 'providers'; }
|
|
927
|
-
return;
|
|
928
|
-
}
|
|
929
|
-
|
|
930
836
|
const selectable = this._selectableIndexes(this.selAction);
|
|
931
837
|
const position = Math.max(0, selectable.indexOf(this.selIdx));
|
|
932
838
|
if (k === 'up' || k === 'k') this.selIdx = selectable[Math.max(0, position - 1)] ?? this.selIdx;
|
|
@@ -961,9 +867,14 @@ export class TUI {
|
|
|
961
867
|
() => this._doToggle(this.selIdx, enable),
|
|
962
868
|
);
|
|
963
869
|
} else if (this.selAction === 'delete') {
|
|
870
|
+
// A GCP-backed row: say what deleting does NOT do, so nobody thinks the key
|
|
871
|
+
// is gone from the cloud.
|
|
872
|
+
const secret = account.secretName
|
|
873
|
+
? ` The GCP secret "${account.secretName}" is left alone — delete it separately if you want the key gone.`
|
|
874
|
+
: '';
|
|
964
875
|
this._confirm(
|
|
965
876
|
`Delete "${account.name}"?`,
|
|
966
|
-
|
|
877
|
+
`Permanently remove it from Maxpool config. Deletion is blocked while it has active requests.${secret}`,
|
|
967
878
|
() => this._doDelete(this.selIdx),
|
|
968
879
|
);
|
|
969
880
|
} else if (this.selAction === 'rename') {
|
|
@@ -1013,10 +924,16 @@ export class TUI {
|
|
|
1013
924
|
.map(index => ({ account: this.am.accounts[index], index }))
|
|
1014
925
|
.filter(({ account }) => {
|
|
1015
926
|
if (action === 'prefer') return account.type !== 'provider' && account.enabled;
|
|
1016
|
-
// Enable/disable
|
|
1017
|
-
//
|
|
1018
|
-
|
|
1019
|
-
|
|
927
|
+
// Enable/disable works on EVERY row — including a session-created provider,
|
|
928
|
+
// where it is the DURABLE action (exportRuntimeProviders persists `enabled`,
|
|
929
|
+
// and the header path never re-enables a row the user benched).
|
|
930
|
+
if (action === 'toggle') return true;
|
|
931
|
+
// Rename/delete need a config entry to act on. That now includes config
|
|
932
|
+
// PROVIDERS (via _isConfigBacked), which the old accounts-only lookup excluded.
|
|
933
|
+
// A SESSION row is still barred: renaming it forks a duplicate (upsertRuntime
|
|
934
|
+
// Account matches by NAME, so the next header request recreates the original
|
|
935
|
+
// and the same key lands on two accounts), and deleting it undoes itself.
|
|
936
|
+
return this._isConfigBacked(account);
|
|
1020
937
|
})
|
|
1021
938
|
.map(({ index }) => index);
|
|
1022
939
|
}
|
|
@@ -1133,16 +1050,24 @@ export class TUI {
|
|
|
1133
1050
|
if (this.am.accounts.some((a, i) => i !== idx && a.name === newName)) {
|
|
1134
1051
|
this._addLog(`An account named "${newName}" already exists`); return;
|
|
1135
1052
|
}
|
|
1136
|
-
const
|
|
1137
|
-
if (
|
|
1053
|
+
const loc = this._configLocation(account);
|
|
1054
|
+
if (!loc) {
|
|
1055
|
+
// Renaming a SESSION row forks it: upsertRuntimeAccount matches by NAME, so the
|
|
1056
|
+
// next `cc all` request recreates the original and the same key ends up on two
|
|
1057
|
+
// accounts — double-counted quota and routing weight.
|
|
1058
|
+
this._addLog(`Cannot rename "${account.name}" — it comes from a running cc session, not your config`);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const cfgIdx = loc.index;
|
|
1062
|
+
const cfgArray = loc.array;
|
|
1138
1063
|
const old = account.name;
|
|
1139
|
-
const prev = this.config
|
|
1140
|
-
this.config
|
|
1064
|
+
const prev = this.config[cfgArray][cfgIdx].name;
|
|
1065
|
+
this.config[cfgArray][cfgIdx].name = newName;
|
|
1141
1066
|
if (this.config.routing?.preferredAccount === old) this.config.routing.preferredAccount = newName;
|
|
1142
1067
|
try {
|
|
1143
1068
|
await this.saveConfig(this.config);
|
|
1144
1069
|
} catch (error) {
|
|
1145
|
-
this.config
|
|
1070
|
+
this.config[cfgArray][cfgIdx].name = prev;
|
|
1146
1071
|
throw error;
|
|
1147
1072
|
}
|
|
1148
1073
|
account.name = newName; // update the running account manager
|
|
@@ -1297,6 +1222,30 @@ export class TUI {
|
|
|
1297
1222
|
return this.config.accounts.findIndex(candidate => candidate.name === account.name);
|
|
1298
1223
|
}
|
|
1299
1224
|
|
|
1225
|
+
/** Where does this account live in config — `accounts` or `providers`?
|
|
1226
|
+
* A config PROVIDER (GLM/Kimi from config.providers) is just as durable as an OAuth
|
|
1227
|
+
* account, but _configAccountIndex only ever searched config.accounts, so providers
|
|
1228
|
+
* reported -1 and were excluded from rename/delete. With the Providers screen gone,
|
|
1229
|
+
* that would strand them in config forever. Returns { array, index } or null. */
|
|
1230
|
+
_configLocation(account) {
|
|
1231
|
+
if (!account) return null;
|
|
1232
|
+
const accIdx = this._configAccountIndex(account);
|
|
1233
|
+
if (accIdx >= 0) return { array: 'accounts', index: accIdx };
|
|
1234
|
+
const provs = this.config.providers;
|
|
1235
|
+
if (Array.isArray(provs)) {
|
|
1236
|
+
const pIdx = provs.findIndex(p => p.name === account.name);
|
|
1237
|
+
if (pIdx >= 0) return { array: 'providers', index: pIdx };
|
|
1238
|
+
}
|
|
1239
|
+
return null;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** True when this row can be permanently removed/renamed — i.e. it is backed by a
|
|
1243
|
+
* config entry. A SESSION row (created from `cc all` headers, not in config) is not:
|
|
1244
|
+
* deleting it is a lie because the next request recreates it. */
|
|
1245
|
+
_isConfigBacked(account) {
|
|
1246
|
+
return this._configLocation(account) !== null;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1300
1249
|
async _doToggle(idx, enabled) {
|
|
1301
1250
|
const account = this.am.accounts[idx];
|
|
1302
1251
|
if (!account) return;
|
|
@@ -1342,11 +1291,16 @@ export class TUI {
|
|
|
1342
1291
|
this._addLog(`Cannot delete "${name}" while ${account.inFlight} request(s) are active; disable it and retry when idle`);
|
|
1343
1292
|
return;
|
|
1344
1293
|
}
|
|
1345
|
-
const
|
|
1346
|
-
if (
|
|
1347
|
-
|
|
1294
|
+
const loc = this._configLocation(account);
|
|
1295
|
+
if (!loc) {
|
|
1296
|
+
// A SESSION row: a running `cc all` is handing maxpool this key, so deleting it
|
|
1297
|
+
// would be undone by the next request. Disabling IS durable here — the header
|
|
1298
|
+
// path never re-enables a benched row — so point the user at the action that works.
|
|
1299
|
+
this._addLog(`"${name}" comes from a running cc session, so deleting it would not stick — press t to switch it off instead (that survives restarts)`);
|
|
1348
1300
|
return;
|
|
1349
1301
|
}
|
|
1302
|
+
const configIndex = loc.index;
|
|
1303
|
+
const configArray = loc.array;
|
|
1350
1304
|
const wasEnabled = account.enabled;
|
|
1351
1305
|
this.am.setAccountEnabled(idx, false);
|
|
1352
1306
|
if (account.inFlight > 0) {
|
|
@@ -1355,7 +1309,7 @@ export class TUI {
|
|
|
1355
1309
|
return;
|
|
1356
1310
|
}
|
|
1357
1311
|
|
|
1358
|
-
const [removedConfig] = this.config.
|
|
1312
|
+
const [removedConfig] = this.config[configArray].splice(configIndex, 1);
|
|
1359
1313
|
const previousRouting = this.config.routing;
|
|
1360
1314
|
if (this.config.routing?.preferredAccount === name) {
|
|
1361
1315
|
this.config.routing = { mode: 'automatic', preferredAccount: null };
|
|
@@ -1363,13 +1317,13 @@ export class TUI {
|
|
|
1363
1317
|
try {
|
|
1364
1318
|
await this.saveConfig(this.config);
|
|
1365
1319
|
} catch (error) {
|
|
1366
|
-
this.config.
|
|
1320
|
+
this.config[configArray].splice(configIndex, 0, removedConfig);
|
|
1367
1321
|
this.config.routing = previousRouting;
|
|
1368
1322
|
this.am.setAccountEnabled(idx, wasEnabled);
|
|
1369
1323
|
throw error;
|
|
1370
1324
|
}
|
|
1371
1325
|
if (!this.am.removeAccount(idx)) {
|
|
1372
|
-
this.config.
|
|
1326
|
+
this.config[configArray].splice(configIndex, 0, removedConfig);
|
|
1373
1327
|
this.config.routing = previousRouting;
|
|
1374
1328
|
this.am.setAccountEnabled(idx, wasEnabled);
|
|
1375
1329
|
await this.saveConfig(this.config);
|
|
@@ -1565,11 +1519,6 @@ export class TUI {
|
|
|
1565
1519
|
// to be pushed after the pad-to-full-height loop, so every line fell past the
|
|
1566
1520
|
// bottom edge and the screen rendered only its header (reported 2026-08-10:
|
|
1567
1521
|
// "when I click on providers I just see the title — how is that helpful?").
|
|
1568
|
-
if (this.mode === 'providers') {
|
|
1569
|
-
const pLines = [];
|
|
1570
|
-
this._renderProviders(pLines, W);
|
|
1571
|
-
lines.push('', ...pLines);
|
|
1572
|
-
}
|
|
1573
1522
|
if (this.mode === 'addtype') {
|
|
1574
1523
|
const aLines = [];
|
|
1575
1524
|
this._renderAddType(aLines);
|
|
@@ -1849,7 +1798,7 @@ export class TUI {
|
|
|
1849
1798
|
_renderFooter() {
|
|
1850
1799
|
switch (this.mode) {
|
|
1851
1800
|
case 'normal':
|
|
1852
|
-
return ` ${bold('a')} Accounts ${bold('
|
|
1801
|
+
return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('h')} Hide disabled ${dim('│')} ${bold('u')} Updates ${bold('r')} Restart ${bold('q')} Stop server`;
|
|
1853
1802
|
case 'updates': {
|
|
1854
1803
|
const state = this._autoUpdateOn() ? green('on') : dim('off');
|
|
1855
1804
|
return ` ${bold('c')} Check & apply now ${bold('t')} Automatic updates: ${state} ↻ ${bold('Esc')} Back`;
|
|
@@ -1858,8 +1807,6 @@ export class TUI {
|
|
|
1858
1807
|
return ` ${bold('a')} Add account ${bold('l')} Re-auth (browser) ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
|
|
1859
1808
|
case 'addtype':
|
|
1860
1809
|
return ` ${bold('1')}-${bold('4')} pick a type ${bold('Esc')} Back`;
|
|
1861
|
-
case 'providers':
|
|
1862
|
-
return ` ${bold('a')} Add provider ${bold('d')} Delete ${bold('t')} Enable/disable ${bold('Esc')} Back`;
|
|
1863
1810
|
case 'routing': {
|
|
1864
1811
|
// Show the CURRENT cross-provider policy inline so pressing f visibly changes it
|
|
1865
1812
|
// right here at the footer (the policy also renders in the header, far from the
|