maxpool 1.5.64 → 1.5.66
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 +122 -2
- package/src/config.js +5 -0
- package/src/index.js +34 -0
- package/src/oauth.js +9 -1
- package/src/secret-resolver.js +49 -0
- package/src/server.js +26 -2
- package/src/tui.js +202 -3
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -50,6 +50,10 @@ function emptyQuota() {
|
|
|
50
50
|
// { fable:{utilization,resetAt,severity,isActive}, opus:{...}, sonnet:{...} }.
|
|
51
51
|
scopedWeekly: {},
|
|
52
52
|
unifiedStatus: null, // allowed | allowed_warning | rejected
|
|
53
|
+
// TRUE when a SUCCESSFUL probe reported no weekly cap for this account — i.e.
|
|
54
|
+
// "this plan has no weekly limit", NOT "we couldn't read one". Without it both
|
|
55
|
+
// states render as an empty bar and a healthy account reads as broken.
|
|
56
|
+
weeklyAbsent: false,
|
|
53
57
|
resetsAt: null,
|
|
54
58
|
// Provider (z.ai / Kimi) quota — kept SEPARATE from unified* so a provider
|
|
55
59
|
// reading never leaks into the OAuth quota gates (_isAvailable / _weeklyRawState
|
|
@@ -1396,6 +1400,41 @@ export class AccountManager {
|
|
|
1396
1400
|
return { cause: 'unavailable', retryAt: null, queueable: false };
|
|
1397
1401
|
}
|
|
1398
1402
|
|
|
1403
|
+
/**
|
|
1404
|
+
* Why is every Claude account unavailable RIGHT NOW? Counts the per-account causes
|
|
1405
|
+
* the retry oracle already computes, so the 429 can name the real one.
|
|
1406
|
+
*
|
|
1407
|
+
* "All N accounts are at their limit" was wrong whenever the true cause was a short
|
|
1408
|
+
* NETWORK cooldown (5s each, applied on a connection drop): the user reads a quota
|
|
1409
|
+
* problem, waits, and considers adding accounts — when the fleet is actually fine and
|
|
1410
|
+
* recovers in seconds. Returns { total, quota, transient, network, dominant }.
|
|
1411
|
+
*/
|
|
1412
|
+
unavailabilityCensus(model = null) {
|
|
1413
|
+
const QUOTA = new Set(['exhausted', 'weekly_exhausted', 'session_limit', 'token_limit', 'request_limit', 'rate_limited']);
|
|
1414
|
+
const TRANSIENT = new Set(['cooldown', 'upstream_failure', 'concurrency_cap', 'weekly_critical']);
|
|
1415
|
+
const c = { total: 0, quota: 0, transient: 0, network: 0, disabled: 0, error: 0, other: 0 };
|
|
1416
|
+
for (const a of this.accounts) {
|
|
1417
|
+
if (a.type === 'provider') continue;
|
|
1418
|
+
c.total++;
|
|
1419
|
+
const cause = this._retryInfo(a, model)?.cause || 'unavailable';
|
|
1420
|
+
if (cause === 'disabled') c.disabled++;
|
|
1421
|
+
else if (cause === 'error') c.error++;
|
|
1422
|
+
else if (QUOTA.has(cause)) c.quota++;
|
|
1423
|
+
else if (TRANSIENT.has(cause)) {
|
|
1424
|
+
c.transient++;
|
|
1425
|
+
// A cooldown whose window matches the NETWORK cooldown is a connectivity blip,
|
|
1426
|
+
// not congestion — the distinction the user's report was about.
|
|
1427
|
+
if (cause === 'cooldown' && a.cooldownUntil
|
|
1428
|
+
&& (a.cooldownUntil - Date.now()) <= this.scheduler.networkCooldownMs) c.network++;
|
|
1429
|
+
} else c.other++;
|
|
1430
|
+
}
|
|
1431
|
+
const eligible = c.total - c.disabled - c.error;
|
|
1432
|
+
c.dominant = eligible <= 0 ? 'none'
|
|
1433
|
+
: c.quota >= Math.max(1, Math.ceil(eligible / 2)) ? 'quota'
|
|
1434
|
+
: c.transient > 0 ? 'transient' : 'other';
|
|
1435
|
+
return c;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1399
1438
|
// The soonest active short-term (non-weekly) blocker for an account, or null if
|
|
1400
1439
|
// none is active. Ordered most-specific-first; each entry is a {cause, retryAt,
|
|
1401
1440
|
// queueable} the retry oracle can hold on. Kept separate from the weekly state
|
|
@@ -2212,6 +2251,11 @@ export class AccountManager {
|
|
|
2212
2251
|
if (usage.sevenDay.utilization != null) q.unified7d = clamp01(usage.sevenDay.utilization);
|
|
2213
2252
|
if (usage.sevenDay.resetAt != null) q.unified7dReset = usage.sevenDay.resetAt;
|
|
2214
2253
|
}
|
|
2254
|
+
// Only a SUCCESSFUL probe carrying the flag speaks to this. A header-driven update
|
|
2255
|
+
// can't see the limits[] array, and a FAILED read knows nothing about the account's
|
|
2256
|
+
// caps — either one claiming "uncapped" would mislabel a capped account as having no
|
|
2257
|
+
// weekly limit, which is the exact wrong direction (it reads as free capacity).
|
|
2258
|
+
if (!usage.error && usage.weeklyAbsent !== undefined) q.weeklyAbsent = Boolean(usage.weeklyAbsent);
|
|
2215
2259
|
// Per-model weekly sub-limits (Fable, Opus, ...). Replace wholesale with the
|
|
2216
2260
|
// fresh probe set so a family that dropped out of the response doesn't linger
|
|
2217
2261
|
// stale; expiry on reset is a backstop for the between-probe window. EXCEPTION:
|
|
@@ -2320,11 +2364,16 @@ export class AccountManager {
|
|
|
2320
2364
|
if (usage.wk) {
|
|
2321
2365
|
if (usage.wk.utilization != null) q.providerWk = clamp01(usage.wk.utilization);
|
|
2322
2366
|
if (usage.wk.resetAt != null) q.providerWkReset = usage.wk.resetAt;
|
|
2367
|
+
q.weeklyAbsent = false;
|
|
2323
2368
|
} else {
|
|
2324
2369
|
// Weekly window absent from this plan/response — clear so a stale weekly
|
|
2325
|
-
// reading doesn't linger after a plan/window change.
|
|
2370
|
+
// reading doesn't linger after a plan/window change. A SUCCESSFUL poll that
|
|
2371
|
+
// carries no weekly is positive knowledge that the plan has none: measured
|
|
2372
|
+
// 2026-08-06, z.ai `max` returns exactly one TOKENS_LIMIT (unit 3 = the 5h
|
|
2373
|
+
// session) and no unit-6 weekly, so GLM's blank Wk is correct, not a gap.
|
|
2326
2374
|
q.providerWk = null;
|
|
2327
2375
|
q.providerWkReset = null;
|
|
2376
|
+
q.weeklyAbsent = true;
|
|
2328
2377
|
}
|
|
2329
2378
|
q.lastProbeOkAt = Date.now();
|
|
2330
2379
|
}
|
|
@@ -2773,6 +2822,8 @@ export class AccountManager {
|
|
|
2773
2822
|
modelMap: acctData.modelMap || null,
|
|
2774
2823
|
stripBetaHeaders: Boolean(acctData.stripBetaHeaders),
|
|
2775
2824
|
runtime: Boolean(acctData.runtime),
|
|
2825
|
+
configSourced: Boolean(acctData.configSourced),
|
|
2826
|
+
secretName: acctData.secretName || null,
|
|
2776
2827
|
enabled: acctData.enabled !== false,
|
|
2777
2828
|
refreshToken: acctData.refreshToken || null,
|
|
2778
2829
|
expiresAt: acctData.expiresAt || null,
|
|
@@ -2827,6 +2878,8 @@ export class AccountManager {
|
|
|
2827
2878
|
account.modelMap = acctData.modelMap || account.modelMap;
|
|
2828
2879
|
account.stripBetaHeaders = Boolean(acctData.stripBetaHeaders);
|
|
2829
2880
|
account.runtime = true;
|
|
2881
|
+
if (acctData.configSourced !== undefined) account.configSourced = acctData.configSourced;
|
|
2882
|
+
if (acctData.secretName !== undefined) account.secretName = acctData.secretName;
|
|
2830
2883
|
// Restore path carries an explicit enabled (persisted disable); honor it. The `cc
|
|
2831
2884
|
// all` header path (prepareRuntimeProviders) omits enabled, so a re-sent token
|
|
2832
2885
|
// NEVER silently re-enables a provider the user benched in the TUI.
|
|
@@ -2851,7 +2904,7 @@ export class AccountManager {
|
|
|
2851
2904
|
*/
|
|
2852
2905
|
exportRuntimeProviders() {
|
|
2853
2906
|
return this.accounts
|
|
2854
|
-
.filter(a => a.runtime && a.type === 'provider' && a.credential)
|
|
2907
|
+
.filter(a => a.runtime && a.type === 'provider' && a.credential && !a.configSourced)
|
|
2855
2908
|
.map(a => ({
|
|
2856
2909
|
name: a.name,
|
|
2857
2910
|
type: a.type,
|
|
@@ -2877,6 +2930,10 @@ export class AccountManager {
|
|
|
2877
2930
|
* live `cc all` request refreshes the token before routing (prepareRuntimeProviders
|
|
2878
2931
|
* runs ahead of account selection), so a stale restored token never serves a
|
|
2879
2932
|
* request. Idempotent — upsertRuntimeAccount matches by name.
|
|
2933
|
+
*
|
|
2934
|
+
* Config-sourced providers (resolved from GCP Secret Manager at startup) are
|
|
2935
|
+
* EXCLUDED — their source of truth is config + GCP, not state.json, so persisting
|
|
2936
|
+
* their token to disk would duplicate the secret and survive a GCP deletion.
|
|
2880
2937
|
*/
|
|
2881
2938
|
restoreRuntimeProviders(list) {
|
|
2882
2939
|
if (!Array.isArray(list)) return;
|
|
@@ -2886,6 +2943,69 @@ export class AccountManager {
|
|
|
2886
2943
|
}
|
|
2887
2944
|
}
|
|
2888
2945
|
|
|
2946
|
+
/**
|
|
2947
|
+
* Load config-sourced provider accounts — resolved from GCP Secret Manager at
|
|
2948
|
+
* startup, NOT from per-request headers. Each config entry is { name, provider,
|
|
2949
|
+
* secretName, upstream?, priority?, modelMap? }. The secret is resolved by the
|
|
2950
|
+
* caller (index.js) and passed as `token`; if null the provider is created but
|
|
2951
|
+
* marked error so the TUI shows WHY it's broken.
|
|
2952
|
+
*
|
|
2953
|
+
* These are marked configSourced so they're excluded from state.json persistence
|
|
2954
|
+
* (their source of truth is config + GCP, not disk) and so the header path can
|
|
2955
|
+
* dedup against them (same token → skip creating a duplicate runtime provider).
|
|
2956
|
+
*/
|
|
2957
|
+
loadConfigProviders(entries) {
|
|
2958
|
+
if (!Array.isArray(entries)) return;
|
|
2959
|
+
// Remove existing config-sourced providers that are no longer in the config
|
|
2960
|
+
// (handles a config edit that removes an entry).
|
|
2961
|
+
const wantedNames = new Set(entries.map(e => e.name).filter(Boolean));
|
|
2962
|
+
for (const a of this.accounts) {
|
|
2963
|
+
if (a.configSourced && !wantedNames.has(a.name)) {
|
|
2964
|
+
const idx = this.accounts.indexOf(a);
|
|
2965
|
+
if (idx >= 0 && this.accounts[idx].inFlight === 0) this.removeAccount(idx);
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
for (const entry of entries) {
|
|
2969
|
+
if (!entry || !entry.name || !entry.provider) continue;
|
|
2970
|
+
this.upsertRuntimeAccount({
|
|
2971
|
+
name: entry.name,
|
|
2972
|
+
type: 'provider',
|
|
2973
|
+
provider: entry.provider,
|
|
2974
|
+
authToken: entry.token || null,
|
|
2975
|
+
upstream: entry.upstream || (entry.provider === 'kimi'
|
|
2976
|
+
? 'https://api.kimi.com/coding'
|
|
2977
|
+
: 'https://api.z.ai/api/anthropic'),
|
|
2978
|
+
authHeader: 'authorization',
|
|
2979
|
+
profiles: ['all'],
|
|
2980
|
+
priority: Number.isFinite(entry.priority) ? entry.priority : 10,
|
|
2981
|
+
modelMap: entry.modelMap,
|
|
2982
|
+
model: entry.model,
|
|
2983
|
+
stripBetaHeaders: true,
|
|
2984
|
+
configSourced: true,
|
|
2985
|
+
});
|
|
2986
|
+
if (!entry.token) {
|
|
2987
|
+
const a = this.accounts.find(a => a.name === entry.name);
|
|
2988
|
+
if (a) { a.status = 'error'; a.lastError = 'secret-unresolved'; }
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
/**
|
|
2994
|
+
* Return the config-sourced provider definitions (name + provider + secretName)
|
|
2995
|
+
* for the TUI and for config serialization. Tokens are NEVER included.
|
|
2996
|
+
*/
|
|
2997
|
+
configProviderDefs() {
|
|
2998
|
+
return this.accounts
|
|
2999
|
+
.filter(a => a.configSourced && a.type === 'provider')
|
|
3000
|
+
.map(a => ({
|
|
3001
|
+
name: a.name,
|
|
3002
|
+
provider: a.provider,
|
|
3003
|
+
secretName: a.secretName || null,
|
|
3004
|
+
upstream: a.upstream,
|
|
3005
|
+
priority: a.priority,
|
|
3006
|
+
}));
|
|
3007
|
+
}
|
|
3008
|
+
|
|
2889
3009
|
/**
|
|
2890
3010
|
* Remove an account by index.
|
|
2891
3011
|
*/
|
package/src/config.js
CHANGED
|
@@ -154,6 +154,11 @@ export function createDefaultConfig() {
|
|
|
154
154
|
heartbeatMs: 10_000,
|
|
155
155
|
},
|
|
156
156
|
accounts: [],
|
|
157
|
+
// Config-sourced provider accounts (GLM/Kimi). Each entry references a GCP
|
|
158
|
+
// Secret Manager secret by NAME — the key is resolved at startup and held in
|
|
159
|
+
// memory only. See src/secret-resolver.js.
|
|
160
|
+
// [{ name, provider:'zai'|'kimi', secretName, upstream?, priority? }]
|
|
161
|
+
providers: [],
|
|
157
162
|
};
|
|
158
163
|
}
|
|
159
164
|
|
package/src/index.js
CHANGED
|
@@ -593,6 +593,40 @@ async function serverWorkerCommand() {
|
|
|
593
593
|
// self-healing window as an abrupt SIGKILL. A cold restart reads the clean-shutdown
|
|
594
594
|
// final flush and restores fully (the reported zero-request case).
|
|
595
595
|
if (savedState?.runtimeProviders) accountManager.restoreRuntimeProviders(savedState.runtimeProviders);
|
|
596
|
+
|
|
597
|
+
// ── config-sourced providers (GCP Secret Manager) ──────────────────────────
|
|
598
|
+
// Provider keys (GLM, Kimi) defined in the config's `providers` section, each
|
|
599
|
+
// referencing a GCP Secret Manager secret by NAME — the key is resolved at
|
|
600
|
+
// startup and held in memory only, never written to config or state.json.
|
|
601
|
+
// Team members add a provider by storing the key in GCP, then pointing maxpool
|
|
602
|
+
// at the secret name via the TUI. Deleting the GCP secret disables the provider
|
|
603
|
+
// on the next restart — no key to hunt down in config files.
|
|
604
|
+
if (Array.isArray(config.providers) && config.providers.length > 0) {
|
|
605
|
+
try {
|
|
606
|
+
const { resolveSecrets } = await import('./secret-resolver.js');
|
|
607
|
+
// Split: GCP-sourced (secretName) vs direct (apiKey). Both produce a token
|
|
608
|
+
// the same way — the resolution path is the only difference.
|
|
609
|
+
const gcpEntries = config.providers.filter(p => p.secretName);
|
|
610
|
+
const directEntries = config.providers.filter(p => p.apiKey && !p.secretName);
|
|
611
|
+
const secretNames = gcpEntries.map(p => p.secretName);
|
|
612
|
+
const resolved = await resolveSecrets(secretNames);
|
|
613
|
+
const entries = config.providers.map(p => ({
|
|
614
|
+
...p,
|
|
615
|
+
token: p.secretName ? (resolved[p.secretName] || null) : (p.apiKey || null),
|
|
616
|
+
}));
|
|
617
|
+
accountManager.loadConfigProviders(entries);
|
|
618
|
+
const ok = entries.filter(e => e.token).length;
|
|
619
|
+
const fail = entries.length - ok;
|
|
620
|
+
console.log(`[Maxpool] Config providers: ${ok} active${fail ? `, ${fail} unresolved` : ''}`);
|
|
621
|
+
for (const p of config.providers) {
|
|
622
|
+
const a = accountManager.accounts.find(a => a.name === p.name);
|
|
623
|
+
if (a && p.secretName) a.secretName = p.secretName;
|
|
624
|
+
}
|
|
625
|
+
} catch (err) {
|
|
626
|
+
console.error(`[Maxpool] Config provider resolution failed: ${err.message}`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
596
630
|
// Track the state-file generation we last observed so a stale flush is refused.
|
|
597
631
|
let stateGeneration = Number(savedState?._generation) || 0;
|
|
598
632
|
|
package/src/oauth.js
CHANGED
|
@@ -267,10 +267,18 @@ export async function fetchUsage(accessToken) {
|
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
const sevenDay = limitsWeeklyAll || normalizeUsageBucket(data?.seven_day);
|
|
270
271
|
return {
|
|
271
272
|
fiveHour: limitsSession || normalizeUsageBucket(data?.five_hour),
|
|
272
|
-
sevenDay
|
|
273
|
+
sevenDay,
|
|
273
274
|
scopedWeekly,
|
|
275
|
+
// A SUCCESSFUL read that carries no weekly bucket is positive knowledge: this
|
|
276
|
+
// account HAS no weekly cap, as opposed to "we haven't managed to read one yet".
|
|
277
|
+
// Both render as a blank bar otherwise, so a perfectly healthy account looks
|
|
278
|
+
// broken. Measured 2026-08-06: privacy@gomokka.com returns limits=[session,
|
|
279
|
+
// weekly_scoped(inactive)] and `seven_day: null`, while every other account
|
|
280
|
+
// returns a `weekly_all` — it is genuinely uncapped, not unread.
|
|
281
|
+
weeklyAbsent: sevenDay == null || sevenDay.utilization == null,
|
|
274
282
|
};
|
|
275
283
|
} catch (err) {
|
|
276
284
|
return { error: err.message || String(err), status: null };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a GCP Secret Manager secret name to its plaintext value.
|
|
3
|
+
*
|
|
4
|
+
* Uses `gcloud secrets versions access` — the same path load-secrets.sh uses.
|
|
5
|
+
* The key is resolved ONCE at startup (or when a provider is added via the TUI)
|
|
6
|
+
* and held in maxpool's process memory, never written to disk or logs.
|
|
7
|
+
*
|
|
8
|
+
* Returns null on any failure (missing secret, no gcloud, auth error) so a
|
|
9
|
+
* broken provider degrades to "disabled" instead of crashing the proxy.
|
|
10
|
+
*/
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
import { promisify } from 'node:util';
|
|
13
|
+
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
const DEFAULT_PROJECT = 'mokka-business-automations';
|
|
17
|
+
|
|
18
|
+
export async function resolveSecret(secretName, { project = DEFAULT_PROJECT, timeoutMs = 45_000 } = {}) {
|
|
19
|
+
if (!secretName || typeof secretName !== 'string') return null;
|
|
20
|
+
try {
|
|
21
|
+
const { stdout } = await execFileAsync(
|
|
22
|
+
'gcloud',
|
|
23
|
+
['secrets', 'versions', 'access', 'latest', '--secret', secretName, '--project', project],
|
|
24
|
+
{ timeout: timeoutMs, maxBuffer: 1024 * 1024 },
|
|
25
|
+
);
|
|
26
|
+
const value = stdout.trim();
|
|
27
|
+
return value || null;
|
|
28
|
+
} catch {
|
|
29
|
+
// Missing secret, no gcloud, auth expired, no network — all degrade to null.
|
|
30
|
+
// The TUI shows the provider as "error: secret-unresolved" so the operator
|
|
31
|
+
// knows which secret failed, not just that "the provider is broken".
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve multiple secrets in parallel. Returns { name → value } for the ones
|
|
38
|
+
* that resolved; failed ones are simply absent (caller treats absence as
|
|
39
|
+
* "provider can't activate"). Parallel because N secrets at ~500ms each
|
|
40
|
+
* serialized would add seconds to startup.
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveSecrets(secretNames, opts = {}) {
|
|
43
|
+
const unique = [...new Set(secretNames.filter(Boolean))];
|
|
44
|
+
if (!unique.length) return {};
|
|
45
|
+
const entries = await Promise.all(
|
|
46
|
+
unique.map(async name => [name, await resolveSecret(name, opts)]),
|
|
47
|
+
);
|
|
48
|
+
return Object.fromEntries(entries.filter(([, v]) => v != null));
|
|
49
|
+
}
|
package/src/server.js
CHANGED
|
@@ -1562,6 +1562,20 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
|
|
|
1562
1562
|
// MINUTES (not momentary), and raw seconds are unreadable. Scale the wording to the
|
|
1563
1563
|
// actual wait and render it in human units, the way every other branch already does.
|
|
1564
1564
|
const waitLong = Number.isFinite(retryAfter) && retryAfter >= 120;
|
|
1565
|
+
|
|
1566
|
+
// "at their limit" is a QUOTA claim. When the accounts are actually in short network
|
|
1567
|
+
// cooldowns (a connectivity blip drops every in-flight connection and cools each
|
|
1568
|
+
// account for ~5s), that claim sends the user to check quota and add accounts while
|
|
1569
|
+
// the fleet is healthy and recovers in seconds. Name the real cause.
|
|
1570
|
+
const census = accountManager.unavailabilityCensus?.(requestInfo.model);
|
|
1571
|
+
if (census && census.dominant === 'transient') {
|
|
1572
|
+
const netly = census.network > 0;
|
|
1573
|
+
const what = netly
|
|
1574
|
+
? `${census.network} of ${census.total} Claude accounts are in a brief reconnect cooldown`
|
|
1575
|
+
: `all ${census.total} Claude accounts are momentarily busy`;
|
|
1576
|
+
return `No account can take this request right now — ${what}, not out of quota. Retry in ~${formatRetryDuration(retryAfter)}; it clears on its own.${providersBarredHint}`;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1565
1579
|
return `No account can take this request right now — all ${claudeCount} Claude accounts${providersClause} are ${waitLong ? 'at their limit' : 'momentarily at their limit'}. Retry in ~${formatRetryDuration(retryAfter)}.${providersBarredHint}`;
|
|
1566
1580
|
}
|
|
1567
1581
|
|
|
@@ -2559,8 +2573,18 @@ function getMaxpoolProfile(headers) {
|
|
|
2559
2573
|
function prepareRuntimeProviders(accountManager, headers) {
|
|
2560
2574
|
if (getMaxpoolProfile(headers) !== 'all') return;
|
|
2561
2575
|
|
|
2576
|
+
// Config-sourced providers already exist (resolved from GCP at startup). A `cc all`
|
|
2577
|
+
// session sends the SAME token as a header → dedup by token so we don't create a
|
|
2578
|
+
// duplicate provider for every request. The config provider wins (it persists, has
|
|
2579
|
+
// the right name, and carries quota state from earlier requests).
|
|
2580
|
+
const configTokens = new Set(
|
|
2581
|
+
(accountManager.accounts || [])
|
|
2582
|
+
.filter(a => a.configSourced && a.credential)
|
|
2583
|
+
.map(a => a.credential),
|
|
2584
|
+
);
|
|
2585
|
+
|
|
2562
2586
|
const zaiToken = headerValue(headers, 'x-maxpool-zai-token');
|
|
2563
|
-
if (zaiToken) {
|
|
2587
|
+
if (zaiToken && !configTokens.has(zaiToken)) {
|
|
2564
2588
|
const opus = headerValue(headers, 'x-maxpool-zai-opus-model') || headerValue(headers, 'x-maxpool-zai-model') || 'glm-5.2';
|
|
2565
2589
|
const sonnet = headerValue(headers, 'x-maxpool-zai-sonnet-model') || headerValue(headers, 'x-maxpool-zai-model') || opus;
|
|
2566
2590
|
const haiku = headerValue(headers, 'x-maxpool-zai-haiku-model') || 'glm-5.1';
|
|
@@ -2579,7 +2603,7 @@ function prepareRuntimeProviders(accountManager, headers) {
|
|
|
2579
2603
|
}
|
|
2580
2604
|
|
|
2581
2605
|
const kimiToken = headerValue(headers, 'x-maxpool-kimi-token');
|
|
2582
|
-
if (kimiToken) {
|
|
2606
|
+
if (kimiToken && !configTokens.has(kimiToken)) {
|
|
2583
2607
|
// Fallback only — `cc all` always sends x-maxpool-kimi-model from the llm_config SSOT,
|
|
2584
2608
|
// so this is what a bare/older client gets. Kept current deliberately: it read
|
|
2585
2609
|
// 'kimi-k2.7' while the fleet had moved to k3.
|
package/src/tui.js
CHANGED
|
@@ -405,6 +405,7 @@ export class TUI {
|
|
|
405
405
|
case 'accounts': this._keyAccounts(k); break;
|
|
406
406
|
case 'routing': this._keyRouting(k); break;
|
|
407
407
|
case 'updates': this._keyUpdates(k); break;
|
|
408
|
+
case 'providers': this._keyProviders(k); break;
|
|
408
409
|
case 'select': this._keySelect(k); break;
|
|
409
410
|
case 'input': this._keyInput(k); break;
|
|
410
411
|
case 'confirm': this._keyConfirm(k); break;
|
|
@@ -442,6 +443,8 @@ export class TUI {
|
|
|
442
443
|
);
|
|
443
444
|
} else if (k === 'u') {
|
|
444
445
|
this.mode = 'updates';
|
|
446
|
+
} else if (k === 'p') {
|
|
447
|
+
this.mode = 'providers';
|
|
445
448
|
}
|
|
446
449
|
// Enable/disable lives ONLY under [a] Accounts now (with rename/delete/login) —
|
|
447
450
|
// one home for every account mutation, instead of a duplicate top-level toggle.
|
|
@@ -582,6 +585,140 @@ export class TUI {
|
|
|
582
585
|
}
|
|
583
586
|
}
|
|
584
587
|
|
|
588
|
+
_keyProviders(k) {
|
|
589
|
+
if (k === 'a') {
|
|
590
|
+
this._providerAddStep('name');
|
|
591
|
+
} else if (k === 'd') {
|
|
592
|
+
this._startProviderSelection('delete');
|
|
593
|
+
} else if (k === 't') {
|
|
594
|
+
this._startProviderSelection('toggle');
|
|
595
|
+
} else if (k === 'esc' || k === 'q') {
|
|
596
|
+
this.mode = 'normal';
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Multi-step input for adding a provider. Steps: name → type → secret name.
|
|
601
|
+
_providerAddStep(step, prev) {
|
|
602
|
+
if (step === 'name') {
|
|
603
|
+
this.mode = 'input';
|
|
604
|
+
this.inputPrompt = 'Provider display name (e.g. glm-ahmed)';
|
|
605
|
+
this.inputBuf = '';
|
|
606
|
+
this.inputSensitive = false;
|
|
607
|
+
this.inputCb = value => {
|
|
608
|
+
const name = String(value || '').trim();
|
|
609
|
+
if (!name) { this.mode = 'providers'; return; }
|
|
610
|
+
if (this.am.accounts.some(a => a.name === name)) {
|
|
611
|
+
this._addLog(`Account "${name}" already exists`); this.mode = 'providers'; return;
|
|
612
|
+
}
|
|
613
|
+
this._providerAddStep('type', { name });
|
|
614
|
+
};
|
|
615
|
+
} else if (step === 'type') {
|
|
616
|
+
this.mode = 'input';
|
|
617
|
+
this.inputPrompt = `Type for ${prev.name} (zai or kimi)`;
|
|
618
|
+
this.inputBuf = 'zai';
|
|
619
|
+
this.inputSensitive = false;
|
|
620
|
+
this.inputCb = value => {
|
|
621
|
+
const provider = String(value || '').trim().toLowerCase();
|
|
622
|
+
if (provider !== 'zai' && provider !== 'kimi') { this._addLog('Type must be zai or kimi'); this.mode = 'providers'; return; }
|
|
623
|
+
this._providerAddStep('secret', { ...prev, provider });
|
|
624
|
+
};
|
|
625
|
+
} else if (step === 'secret') {
|
|
626
|
+
this.mode = 'input';
|
|
627
|
+
this.inputPrompt = `${prev.name}: GCP secret name OR paste API key directly`;
|
|
628
|
+
this.inputBuf = '';
|
|
629
|
+
this.inputSensitive = false;
|
|
630
|
+
this.inputCb = async value => {
|
|
631
|
+
const input = String(value || '').trim();
|
|
632
|
+
if (!input) { this.mode = 'providers'; return; }
|
|
633
|
+
// Heuristic: a GCP secret name is uppercase/dashes/underscores and short.
|
|
634
|
+
// An API key is long and contains dots/mixed-case/alphanumeric.
|
|
635
|
+
const looksLikeSecretName = /^[A-Z][A-Z0-9_-]{2,60}$/.test(input) && !input.includes('.');
|
|
636
|
+
if (looksLikeSecretName) {
|
|
637
|
+
await this._doAddProvider({ ...prev, secretName: input });
|
|
638
|
+
} else {
|
|
639
|
+
// Direct key paste — store in config (0600, same protection as OAuth tokens).
|
|
640
|
+
await this._doAddProvider({ ...prev, apiKey: input });
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async _doAddProvider({ name, provider, secretName, apiKey }) {
|
|
647
|
+
this.mode = 'providers';
|
|
648
|
+
const isDirect = !secretName && apiKey;
|
|
649
|
+
if (secretName) this._addLog(`Resolving secret "${secretName}" from GCP…`);
|
|
650
|
+
else this._addLog(`Adding "${name}" with direct API key…`);
|
|
651
|
+
this.render();
|
|
652
|
+
try {
|
|
653
|
+
let token = null;
|
|
654
|
+
if (secretName) {
|
|
655
|
+
const { resolveSecret } = await import('../secret-resolver.js');
|
|
656
|
+
token = await resolveSecret(secretName);
|
|
657
|
+
if (!token) {
|
|
658
|
+
this._addLog(`✗ Secret "${secretName}" not found in GCP — create it first: gcloud secrets create ${secretName} --data-file=-`);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
} else {
|
|
662
|
+
token = apiKey;
|
|
663
|
+
}
|
|
664
|
+
// Add to config (persistence) and activate immediately.
|
|
665
|
+
const { atomicConfigUpdate } = await import('../config.js');
|
|
666
|
+
await atomicConfigUpdate(cfg => {
|
|
667
|
+
if (!Array.isArray(cfg.providers)) cfg.providers = [];
|
|
668
|
+
const entry = { name, provider };
|
|
669
|
+
if (secretName) entry.secretName = secretName;
|
|
670
|
+
else entry.apiKey = apiKey;
|
|
671
|
+
cfg.providers.push(entry);
|
|
672
|
+
});
|
|
673
|
+
// Activate in the running process.
|
|
674
|
+
this.am.loadConfigProviders([{ name, provider, secretName, token }]);
|
|
675
|
+
const a = this.am.accounts.find(a => a.name === name);
|
|
676
|
+
if (a && secretName) a.secretName = secretName;
|
|
677
|
+
this._addLog(`✓ Added "${name}" (${provider})${secretName ? ` — GCP secret "${secretName}"` : ' — direct key'}`);
|
|
678
|
+
} catch (err) {
|
|
679
|
+
this._addLog(`✗ Failed to add provider: ${err.message}`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
_startProviderSelection(action) {
|
|
684
|
+
const providers = this.am.accounts.filter(a => a.type === 'provider');
|
|
685
|
+
if (!providers.length) { this._addLog('No providers to manage'); return; }
|
|
686
|
+
this._selOptions = providers.map(a => a.index);
|
|
687
|
+
this._selLabels = providers.map(a => {
|
|
688
|
+
const enabled = a.enabled !== false;
|
|
689
|
+
const tag = a.configSourced ? ' (GCP)' : ' (header)';
|
|
690
|
+
const secret = a.secretName ? ` [${a.secretName}]` : '';
|
|
691
|
+
return `${a.name}${tag}${secret}${enabled ? '' : ' ✕'}`;
|
|
692
|
+
});
|
|
693
|
+
this.selAction = action;
|
|
694
|
+
this.mode = 'select';
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
_renderProviders(buf, width) {
|
|
698
|
+
const providers = this.am.accounts.filter(a => a.type === 'provider');
|
|
699
|
+
buf.push(`${bold('Providers (GLM / Kimi)')} ${dim('— managed via GCP Secret Manager')}`);
|
|
700
|
+
buf.push('');
|
|
701
|
+
if (!providers.length) {
|
|
702
|
+
buf.push(dim(' No providers configured.'));
|
|
703
|
+
buf.push('');
|
|
704
|
+
buf.push(dim(' Press ') + bold('a') + dim(' to add one. You\'ll need:'));
|
|
705
|
+
buf.push(dim(' 1. An API key from z.ai (GLM) or Moonshot (Kimi)'));
|
|
706
|
+
buf.push(dim(' 2. The key stored in GCP: ') + 'gcloud secrets create <name> --data-file=-');
|
|
707
|
+
buf.push(dim(' 3. The GCP secret name (e.g. RESTRICTED_MAXPOOL_ZAI_NEW)'));
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
for (const a of providers) {
|
|
711
|
+
const enabled = a.enabled !== false;
|
|
712
|
+
const tag = a.configSourced ? dim(' (GCP)') : dim(' (header)');
|
|
713
|
+
const secret = a.secretName ? dim(` [${a.secretName}]`) : '';
|
|
714
|
+
const status = a.status === 'error' ? red(a.lastError || 'error')
|
|
715
|
+
: enabled ? green('active') : red('✕');
|
|
716
|
+
const q = a.quota;
|
|
717
|
+
const ses = q?.providerSes != null ? ` Ses ${Math.round(q.providerSes * 100)}%` : '';
|
|
718
|
+
buf.push(` ${enabled ? '' : dim('')} ${bold(a.name)} ${a.provider}${tag}${secret} ${status}${ses}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
585
722
|
_keyRouting(k) {
|
|
586
723
|
if (k === 'a') {
|
|
587
724
|
this._confirm(
|
|
@@ -641,6 +778,51 @@ export class TUI {
|
|
|
641
778
|
}
|
|
642
779
|
|
|
643
780
|
_keySelect(k) {
|
|
781
|
+
// Provider selection (from the providers screen) uses its own option list.
|
|
782
|
+
if (this._selOptions && (this.selAction === 'delete' || this.selAction === 'toggle')
|
|
783
|
+
&& this.mode === 'select' && this.am.accounts[this._selOptions[0]]?.type === 'provider') {
|
|
784
|
+
const opts = this._selOptions;
|
|
785
|
+
const position = Math.max(0, opts.indexOf(this.selIdx));
|
|
786
|
+
if (k === 'up' || k === 'k') this.selIdx = opts[Math.max(0, position - 1)] ?? this.selIdx;
|
|
787
|
+
else if (k === 'down' || k === 'j') this.selIdx = opts[Math.min(opts.length - 1, position + 1)] ?? this.selIdx;
|
|
788
|
+
else if (k === 'enter') {
|
|
789
|
+
const account = this.am.accounts[this.selIdx];
|
|
790
|
+
if (!account) { this.mode = 'providers'; return; }
|
|
791
|
+
if (this.selAction === 'toggle') {
|
|
792
|
+
const enable = !account.enabled;
|
|
793
|
+
this._confirm(
|
|
794
|
+
`${enable ? 'Enable' : 'Disable'} "${account.name}"?`,
|
|
795
|
+
enable ? 'Allow this provider to receive requests again.' : 'Stop routing to it. Active requests continue.',
|
|
796
|
+
() => { this._doToggle(this.selIdx, enable); this.mode = 'providers'; },
|
|
797
|
+
);
|
|
798
|
+
} else if (this.selAction === 'delete') {
|
|
799
|
+
this._confirm(
|
|
800
|
+
`Delete provider "${account.name}"?`,
|
|
801
|
+
account.configSourced
|
|
802
|
+
? 'Removes it from config and GCP reference. The GCP secret itself stays — delete it separately if needed.'
|
|
803
|
+
: 'Removes the runtime provider. It returns on the next request that sends its token.',
|
|
804
|
+
async () => {
|
|
805
|
+
if (account.configSourced) {
|
|
806
|
+
try {
|
|
807
|
+
const { atomicConfigUpdate } = await import('../config.js');
|
|
808
|
+
await atomicConfigUpdate(cfg => {
|
|
809
|
+
if (Array.isArray(cfg.providers)) {
|
|
810
|
+
cfg.providers = cfg.providers.filter(p => p.name !== account.name);
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
} catch (err) { this._addLog(`Config update failed: ${err.message}`); }
|
|
814
|
+
}
|
|
815
|
+
this.am.removeAccount(this.selIdx);
|
|
816
|
+
this._addLog(`Deleted provider "${account.name}"`);
|
|
817
|
+
this.mode = 'providers';
|
|
818
|
+
},
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
else if (k === 'esc' || k === 'q') { this.mode = 'providers'; }
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
644
826
|
const selectable = this._selectableIndexes(this.selAction);
|
|
645
827
|
const position = Math.max(0, selectable.indexOf(this.selIdx));
|
|
646
828
|
if (k === 'up' || k === 'k') this.selIdx = selectable[Math.max(0, position - 1)] ?? this.selIdx;
|
|
@@ -1283,6 +1465,13 @@ export class TUI {
|
|
|
1283
1465
|
// Pad to fill
|
|
1284
1466
|
while (lines.length < H - footerH) lines.push('');
|
|
1285
1467
|
|
|
1468
|
+
// Providers panel — shown when the user is on the providers screen
|
|
1469
|
+
if (this.mode === 'providers' || this.mode === 'select') {
|
|
1470
|
+
const pLines = [];
|
|
1471
|
+
this._renderProviders(pLines, W);
|
|
1472
|
+
lines.push(...pLines);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1286
1475
|
// ── Footer
|
|
1287
1476
|
lines.push(' ' + dim('─'.repeat(W - 2)));
|
|
1288
1477
|
if (this.mode === 'confirm') lines.push(` ${this.confirmDetail}`);
|
|
@@ -1405,7 +1594,12 @@ export class TUI {
|
|
|
1405
1594
|
|
|
1406
1595
|
let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw, t1)}`;
|
|
1407
1596
|
if (showBoth) {
|
|
1408
|
-
|
|
1597
|
+
// "no weekly cap on this plan" is a DIFFERENT state from "not read yet", and
|
|
1598
|
+
// both render as an empty bar. Say which, so a healthy uncapped account
|
|
1599
|
+
// (privacy@gomokka.com, measured 2026-08-06) doesn't read as broken.
|
|
1600
|
+
line += (r2 == null && q.weeklyAbsent)
|
|
1601
|
+
? ` ${l2} ${emptyBar('none', bw)}`
|
|
1602
|
+
: ` ${l2} ${bar(r2, bw, t2)}`;
|
|
1409
1603
|
}
|
|
1410
1604
|
const weekly = weeklyPolicyText(this.am, a);
|
|
1411
1605
|
if (weekly) line += ` ${weekly}`;
|
|
@@ -1481,7 +1675,10 @@ export class TUI {
|
|
|
1481
1675
|
let sesCell, wkCell, note = '';
|
|
1482
1676
|
if (q.providerSes != null || q.providerWk != null) {
|
|
1483
1677
|
sesCell = q.providerSes != null ? bar(q.providerSes, bw, q.providerSesReset) : emptyBar('—', bw);
|
|
1484
|
-
|
|
1678
|
+
// z.ai's `max` plan genuinely has NO weekly token window (measured 2026-08-06:
|
|
1679
|
+
// one TOKENS_LIMIT, unit 3 = 5h). "none" says that; "—" read as a broken probe.
|
|
1680
|
+
wkCell = q.providerWk != null ? bar(q.providerWk, bw, q.providerWkReset)
|
|
1681
|
+
: emptyBar(q.weeklyAbsent ? 'none' : '—', bw);
|
|
1485
1682
|
note = this._probeHealthNote(a);
|
|
1486
1683
|
} else if (q.providerQuotaSource === 'console-only') {
|
|
1487
1684
|
sesCell = emptyBar('n/a', bw);
|
|
@@ -1527,13 +1724,15 @@ export class TUI {
|
|
|
1527
1724
|
_renderFooter() {
|
|
1528
1725
|
switch (this.mode) {
|
|
1529
1726
|
case 'normal':
|
|
1530
|
-
return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('s')} Sync ${bold('u')} Updates ${bold('r')} Restart ${bold('q')} Stop`;
|
|
1727
|
+
return ` ${bold('a')} Accounts ${bold('p')} Providers ${bold('m')} Routing ${bold('s')} Sync ${bold('u')} Updates ${bold('r')} Restart ${bold('q')} Stop`;
|
|
1531
1728
|
case 'updates': {
|
|
1532
1729
|
const state = this._autoUpdateOn() ? green('on') : dim('off');
|
|
1533
1730
|
return ` ${bold('c')} Check & apply now ${bold('t')} Automatic updates: ${state} ↻ ${bold('Esc')} Back`;
|
|
1534
1731
|
}
|
|
1535
1732
|
case 'accounts':
|
|
1536
1733
|
return ` ${bold('l')} Login/re-auth (browser) ${bold('k')} API key ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
|
|
1734
|
+
case 'providers':
|
|
1735
|
+
return ` ${bold('a')} Add provider ${bold('d')} Delete ${bold('t')} Enable/disable ${bold('Esc')} Back`;
|
|
1537
1736
|
case 'routing': {
|
|
1538
1737
|
// Show the CURRENT cross-provider policy inline so pressing f visibly changes it
|
|
1539
1738
|
// right here at the footer (the policy also renders in the header, far from the
|