pi-multikey 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -37
- package/README.zh.md +32 -15
- package/cline-auth.ts +242 -0
- package/config.ts +142 -13
- package/identity.ts +101 -0
- package/index.ts +34 -4
- package/manage.ts +289 -12
- package/package.json +3 -1
- package/pool.ts +49 -6
- package/presets.ts +114 -55
- package/probe.ts +8 -3
- package/stream.ts +98 -6
package/manage.ts
CHANGED
|
@@ -9,16 +9,19 @@ import {
|
|
|
9
9
|
DEFAULT_CONTEXT_WINDOW,
|
|
10
10
|
DEFAULT_INPUT,
|
|
11
11
|
DEFAULT_MAX_TOKENS,
|
|
12
|
+
isClineEndpoint,
|
|
12
13
|
KNOWN_API_TYPES,
|
|
13
14
|
maskKey,
|
|
14
15
|
type KeypoolConfig,
|
|
16
|
+
type KeyCredential,
|
|
15
17
|
type PoolConfig,
|
|
16
18
|
type PoolKeyConfig,
|
|
17
19
|
type PoolModelConfig,
|
|
18
20
|
} from "./config.ts";
|
|
19
21
|
import type { KeyPool } from "./pool.ts";
|
|
20
|
-
import { PRESETS, findPreset, poolFromPreset, type Preset } from "./presets.ts";
|
|
22
|
+
import { PRESETS, findPreset, poolFromPreset, presetFingerprint, diffPresetModels, describePresetDiff, type Preset, type PresetModelDiff } from "./presets.ts";
|
|
21
23
|
import { probeEndpoint, type ProbeResult, type RemoteModel } from "./probe.ts";
|
|
24
|
+
import { describeClineCredential, ensureClineAccessToken, loginClineDeviceFlow } from "./cline-auth.ts";
|
|
22
25
|
import { inputNumber, pickMany, selectOne, showInfo, withProgress } from "./tui.ts";
|
|
23
26
|
|
|
24
27
|
type CommandContext = Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
|
|
@@ -29,6 +32,8 @@ export interface ManagerHooks {
|
|
|
29
32
|
saveAndReregister(poolId: string): void;
|
|
30
33
|
removePool(poolId: string): void;
|
|
31
34
|
reloadFromDisk(): void;
|
|
35
|
+
/** Persist the current config without re-registering (preset sync bookkeeping). */
|
|
36
|
+
saveConfig(): void;
|
|
32
37
|
notify(message: string): void;
|
|
33
38
|
}
|
|
34
39
|
|
|
@@ -81,6 +86,215 @@ function describeAuth(probe: ProbeResult): string {
|
|
|
81
86
|
: "using x-api-key auth (could not fully verify)";
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Cline account credentials (device flow sign-in + pasted tokens)
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/** Humanize a duration for display: 90s+ → minutes, 90m+ → hours. */
|
|
94
|
+
function formatCooldown(ms: number): string {
|
|
95
|
+
if (ms < 90_000) return `${Math.ceil(ms / 1000)}s`;
|
|
96
|
+
if (ms < 90 * 60_000) return `${Math.ceil(ms / 60_000)}m`;
|
|
97
|
+
return `${Math.ceil(ms / 3_600_000)}h`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Collect a Cline account credential: the WorkOS device flow (recommended —
|
|
102
|
+
* refresh tokens keep the access token alive) or a manually pasted access
|
|
103
|
+
* token (works until it expires). Returns one key entry, or undefined on cancel.
|
|
104
|
+
*/
|
|
105
|
+
async function collectClineKeys(ctx: CommandContext, hooks: ManagerHooks): Promise<PoolKeyConfig[] | undefined> {
|
|
106
|
+
const how = await selectOne(ctx, "Cline credentials", [
|
|
107
|
+
{
|
|
108
|
+
value: "device",
|
|
109
|
+
label: "Sign in with Cline (device flow)…",
|
|
110
|
+
description: "Authorize your Cline account in the browser; the token refreshes automatically",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
value: "paste",
|
|
114
|
+
label: "Paste a Cline access token…",
|
|
115
|
+
description: "From ~/.cline/data/secrets.json — stops working when it expires",
|
|
116
|
+
},
|
|
117
|
+
]);
|
|
118
|
+
if (how === "device") {
|
|
119
|
+
let credential: KeyCredential;
|
|
120
|
+
try {
|
|
121
|
+
const tokens = await withProgress(ctx, "Cline sign-in", async (update) =>
|
|
122
|
+
loginClineDeviceFlow({
|
|
123
|
+
onAuthInfo: async ({ url, userCode }) => {
|
|
124
|
+
await showInfo(ctx, "Authorize Cline", [
|
|
125
|
+
"Open this URL in your browser and approve the sign-in:",
|
|
126
|
+
"",
|
|
127
|
+
url,
|
|
128
|
+
"",
|
|
129
|
+
`User code: ${userCode}`,
|
|
130
|
+
"",
|
|
131
|
+
"Sign-in continues automatically once you approve.",
|
|
132
|
+
]);
|
|
133
|
+
},
|
|
134
|
+
onProgress: (message) => update(message),
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
credential = {
|
|
138
|
+
kind: "cline-oauth",
|
|
139
|
+
refreshToken: tokens.refreshToken,
|
|
140
|
+
accessToken: tokens.accessToken,
|
|
141
|
+
expiresAt: tokens.expiresAt,
|
|
142
|
+
};
|
|
143
|
+
} catch (error) {
|
|
144
|
+
await showInfo(ctx, "Cline sign-in failed", [error instanceof Error ? error.message : String(error)]);
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
return [{ key: credential.accessToken ?? credential.refreshToken, label: "cline-account", enabled: true, credential }];
|
|
148
|
+
}
|
|
149
|
+
if (how === "paste") {
|
|
150
|
+
const raw = await ctx.ui.input("Cline access token", "paste the token");
|
|
151
|
+
const value = raw?.trim();
|
|
152
|
+
if (!value) return undefined;
|
|
153
|
+
return [{ key: value, label: "cline-account", enabled: true }];
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Preset sync: offer to align preset-created pools with updated presets
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
interface PresetDrift {
|
|
163
|
+
pool: PoolConfig;
|
|
164
|
+
preset: Preset;
|
|
165
|
+
diff: PresetModelDiff;
|
|
166
|
+
/** Stored fingerprint differs from the shipped preset — the ask-once trigger. */
|
|
167
|
+
fingerprintStale: boolean;
|
|
168
|
+
/** Matched by baseUrl only (pool created before preset tracking existed). */
|
|
169
|
+
legacy: boolean;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function hasModelDiff(diff: PresetModelDiff | undefined): boolean {
|
|
173
|
+
return !!diff && (diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Resolve the shipped preset a pool belongs to (via the `_preset` marker, or
|
|
178
|
+
* by baseUrl for legacy pools created before tracking existed) and diff its
|
|
179
|
+
* current models against the preset.
|
|
180
|
+
*
|
|
181
|
+
* The marker's fingerprint is written at pool creation and again the moment a
|
|
182
|
+
* startup prompt is displayed — so each preset version is asked about at most
|
|
183
|
+
* once, and declining only mutes the current version (a later preset change
|
|
184
|
+
* prompts again). Hand-tuning models never triggers the automatic prompt;
|
|
185
|
+
* those pools stay reachable via /multikey → Check preset updates.
|
|
186
|
+
*/
|
|
187
|
+
function presetDrift(pool: PoolConfig): PresetDrift | undefined {
|
|
188
|
+
const preset = pool._preset ? findPreset(pool._preset.id) : PRESETS.find((p) => p.baseUrl === pool.baseUrl);
|
|
189
|
+
if (!preset) return undefined;
|
|
190
|
+
const fingerprintStale = !pool._preset || pool._preset.fingerprint !== presetFingerprint(preset);
|
|
191
|
+
return { pool, preset, diff: diffPresetModels(pool.models, preset.models), fingerprintStale, legacy: !pool._preset };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function presetDriftPools(config: KeypoolConfig): PresetDrift[] {
|
|
195
|
+
return config.pools.map(presetDrift).filter((d): d is PresetDrift => !!d);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Replace the pool's models with the preset's (deep copy) and stamp the
|
|
200
|
+
* marker with the preset's current fingerprint. Keys, cooldowns, id, and
|
|
201
|
+
* endpoint are untouched; afterwards the provider is re-registered.
|
|
202
|
+
*/
|
|
203
|
+
function applyPresetModels(hooks: ManagerHooks, pool: PoolConfig, preset: Preset): void {
|
|
204
|
+
pool.models = JSON.parse(JSON.stringify(preset.models)) as PoolModelConfig[];
|
|
205
|
+
pool._preset = { id: preset.id, fingerprint: presetFingerprint(preset) };
|
|
206
|
+
hooks.saveAndReregister(pool.id);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Startup hook: asks once per preset version when shipped presets moved on
|
|
211
|
+
* from what a pool was created/aligned with. The offered fingerprint is
|
|
212
|
+
* persisted before the dialog shows, so declining — or the process dying
|
|
213
|
+
* mid-prompt — never causes a repeat for the same version.
|
|
214
|
+
*/
|
|
215
|
+
export async function maybeOfferPresetUpdates(hooks: ManagerHooks, ctx: CommandContext): Promise<void> {
|
|
216
|
+
try {
|
|
217
|
+
const stale = presetDriftPools(hooks.config).filter((d) => d.fingerprintStale);
|
|
218
|
+
if (stale.length === 0) return;
|
|
219
|
+
// Persist-on-display: mute this exact preset version before any dialog.
|
|
220
|
+
for (const { pool, preset } of stale) pool._preset = { id: preset.id, fingerprint: presetFingerprint(preset) };
|
|
221
|
+
hooks.saveConfig();
|
|
222
|
+
// Reorder-only drift (no model differences): silently refreshed above.
|
|
223
|
+
const actionable = stale.filter((d) => hasModelDiff(d.diff));
|
|
224
|
+
if (actionable.length === 0) return;
|
|
225
|
+
const summary = actionable.map((d) => `"${d.pool.id}" (${d.preset.name})`).join(", ");
|
|
226
|
+
const ok = await ctx.ui.confirm(
|
|
227
|
+
"Preset update available",
|
|
228
|
+
`Built-in presets changed for pool(s): ${summary}. Review and align now? ` +
|
|
229
|
+
"(Asked once per preset version; /multikey → Check preset updates always works.)",
|
|
230
|
+
);
|
|
231
|
+
if (ok) await checkPresetUpdatesMenu(ctx, hooks);
|
|
232
|
+
} catch {
|
|
233
|
+
// Never break session startup over the sync hint.
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* /multikey → Check preset updates: lists every pool whose model list differs
|
|
239
|
+
* from its (tracked or baseUrl-matched) preset — always visible, regardless
|
|
240
|
+
* of muting, so declined updates stay reachable.
|
|
241
|
+
*/
|
|
242
|
+
export async function checkPresetUpdatesMenu(ctx: CommandContext, hooks: ManagerHooks): Promise<void> {
|
|
243
|
+
for (;;) {
|
|
244
|
+
const drifts = presetDriftPools(hooks.config).filter((d) => hasModelDiff(d.diff));
|
|
245
|
+
if (drifts.length === 0) {
|
|
246
|
+
await showInfo(ctx, "Preset updates", [
|
|
247
|
+
"All pools match the current built-in presets.",
|
|
248
|
+
"",
|
|
249
|
+
"Pools created from a preset are tracked; pools that predate tracking",
|
|
250
|
+
"are matched by baseUrl.",
|
|
251
|
+
]);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const action = await selectOne(
|
|
255
|
+
ctx,
|
|
256
|
+
"Pools differing from their preset",
|
|
257
|
+
drifts.map(({ pool, preset, diff }) => ({
|
|
258
|
+
value: pool.id,
|
|
259
|
+
label: `${pool.id} ← ${preset.name}`,
|
|
260
|
+
suffix: " (differs)",
|
|
261
|
+
description: describePresetDiff(diff).join("\n"),
|
|
262
|
+
})),
|
|
263
|
+
);
|
|
264
|
+
if (action === null) return;
|
|
265
|
+
const drift = drifts.find((d) => d.pool.id === action);
|
|
266
|
+
if (drift) await offerPoolAlignment(ctx, hooks, drift);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Show the diff for one pool and ask whether to align. */
|
|
271
|
+
async function offerPoolAlignment(ctx: CommandContext, hooks: ManagerHooks, drift: PresetDrift): Promise<void> {
|
|
272
|
+
const { pool, preset, diff } = drift;
|
|
273
|
+
const choice = await selectOne(ctx, `Align "${pool.id}" with ${preset.name} preset?`, [
|
|
274
|
+
{
|
|
275
|
+
value: "align",
|
|
276
|
+
label: "Align — apply preset models",
|
|
277
|
+
description:
|
|
278
|
+
[...describePresetDiff(diff), "", `Result: ${preset.models.map((m) => m.id).join(", ")}`, "Keys, endpoint and settings are untouched."].join("\n"),
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
value: "keep",
|
|
282
|
+
label: "Keep my models",
|
|
283
|
+
description: drift.fingerprintStale
|
|
284
|
+
? "No automatic prompt for this preset version; revisit via Check preset updates."
|
|
285
|
+
: "Your models are kept (preset hasn't changed since the last sync).",
|
|
286
|
+
},
|
|
287
|
+
]);
|
|
288
|
+
if (choice === "align") {
|
|
289
|
+
applyPresetModels(hooks, pool, preset);
|
|
290
|
+
hooks.notify(`multikey[${pool.id}]: aligned with ${preset.name} preset — ${pool.models.length} models`);
|
|
291
|
+
} else if (choice === "keep" && drift.fingerprintStale) {
|
|
292
|
+
// Seen-and-kept: mute this preset version's automatic prompt.
|
|
293
|
+
pool._preset = { id: preset.id, fingerprint: presetFingerprint(preset) };
|
|
294
|
+
hooks.saveConfig();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
84
298
|
export async function runManager(pi: ExtensionAPI, ctx: CommandContext, hooks: ManagerHooks): Promise<void> {
|
|
85
299
|
for (;;) {
|
|
86
300
|
const pools = hooks.config.pools;
|
|
@@ -88,6 +302,7 @@ export async function runManager(pi: ExtensionAPI, ctx: CommandContext, hooks: M
|
|
|
88
302
|
{ value: "status", label: "Status", description: "Live per-key state: in-flight, cooldowns, 429 counts" },
|
|
89
303
|
{ value: "manage", label: "Manage pools…", description: "Keys, models, endpoints, cooldowns" },
|
|
90
304
|
{ value: "add", label: "Add pool…", description: "Register another provider (b.ai, nvidia, opencode, …)" },
|
|
305
|
+
{ value: "presetsync", label: "Check preset updates…", description: "Align preset-created pools with updated built-in presets" },
|
|
91
306
|
{ value: "reload", label: "Reload config from disk", description: "Re-read multikey.json and re-register providers" },
|
|
92
307
|
{ value: "usage", label: "Usage tips" },
|
|
93
308
|
{ value: "exit", label: "Close" },
|
|
@@ -117,6 +332,10 @@ export async function runManager(pi: ExtensionAPI, ctx: CommandContext, hooks: M
|
|
|
117
332
|
await addPoolWizard(ctx, hooks);
|
|
118
333
|
continue;
|
|
119
334
|
}
|
|
335
|
+
if (action === "presetsync") {
|
|
336
|
+
await checkPresetUpdatesMenu(ctx, hooks);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
120
339
|
if (action === "manage") {
|
|
121
340
|
const poolId = await selectOne(
|
|
122
341
|
ctx,
|
|
@@ -159,12 +378,13 @@ function renderStatus(hooks: ManagerHooks): string[] {
|
|
|
159
378
|
const state = !row.enabled
|
|
160
379
|
? "disabled"
|
|
161
380
|
: row.cooldownRemainingMs > 0
|
|
162
|
-
? `cooldown ${
|
|
381
|
+
? `cooldown ${formatCooldown(row.cooldownRemainingMs)} (${row.cooldownReason ?? "?"})`
|
|
163
382
|
: row.inflight > 0
|
|
164
383
|
? `active ×${row.inflight}`
|
|
165
384
|
: "idle";
|
|
385
|
+
const credentialNote = row.credential ? ` · ${describeClineCredential(row.credential)}` : "";
|
|
166
386
|
lines.push(
|
|
167
|
-
` ${row.label.padEnd(12)} ${row.masked.padEnd(16)} ${state.padEnd(
|
|
387
|
+
` ${row.label.padEnd(12)} ${row.masked.padEnd(16)} ${state.padEnd(28)} ok:${row.ok} 429:${row.rateLimited} quota:${row.quotaLimited} bad:${row.invalid} err:${row.errors}${credentialNote}`,
|
|
168
388
|
);
|
|
169
389
|
}
|
|
170
390
|
lines.push("");
|
|
@@ -238,7 +458,7 @@ async function keysMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConf
|
|
|
238
458
|
const state = !k.enabled
|
|
239
459
|
? "disabled"
|
|
240
460
|
: row && row.cooldownRemainingMs > 0
|
|
241
|
-
? `cooldown ${
|
|
461
|
+
? `cooldown ${formatCooldown(row.cooldownRemainingMs)}${row.cooldownReason ? ` (${row.cooldownReason})` : ""}`
|
|
242
462
|
: row && row.inflight > 0
|
|
243
463
|
? `active ×${row.inflight}`
|
|
244
464
|
: "idle";
|
|
@@ -246,12 +466,32 @@ async function keysMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConf
|
|
|
246
466
|
value: String(i),
|
|
247
467
|
label: k.label ?? maskKey(k.key),
|
|
248
468
|
suffix: ` ${maskKey(k.key)} ${state}`,
|
|
249
|
-
description:
|
|
469
|
+
description: [
|
|
470
|
+
`ok:${row?.ok ?? 0} 429:${row?.rateLimited ?? 0} quota:${row?.quotaLimited ?? 0} bad:${row?.invalid ?? 0}`,
|
|
471
|
+
k.credential ? describeClineCredential(k.credential) : undefined,
|
|
472
|
+
]
|
|
473
|
+
.filter(Boolean)
|
|
474
|
+
.join(" · "),
|
|
250
475
|
};
|
|
251
476
|
});
|
|
252
|
-
const action = await selectOne(ctx, `Keys: ${pool.id}`, [...items, { value: "__add", label: "+ Add key…" }, { value: "__back", label: "Back" }]);
|
|
477
|
+
const action = await selectOne(ctx, `Keys: ${pool.id}`, [...items, { value: "__add", label: "+ Add key…", description: isClineEndpoint(pool.baseUrl) ? "Sign in with Cline (device flow) or paste a token" : undefined }, { value: "__back", label: "Back" }]);
|
|
253
478
|
if (action === null || action === "__back") return;
|
|
254
479
|
if (action === "__add") {
|
|
480
|
+
// Cline accounts authenticate via OAuth instead of static API keys.
|
|
481
|
+
if (isClineEndpoint(pool.baseUrl)) {
|
|
482
|
+
const collected = await collectClineKeys(ctx, hooks);
|
|
483
|
+
if (collected && collected.length > 0) {
|
|
484
|
+
const entry = collected[0]!;
|
|
485
|
+
if (pool.keys.some((k) => k.credential?.refreshToken === entry.credential?.refreshToken || k.key === entry.key)) {
|
|
486
|
+
hooks.notify(`multikey[${pool.id}]: this Cline credential is already in the pool`);
|
|
487
|
+
} else {
|
|
488
|
+
pool.keys.push(entry);
|
|
489
|
+
hooks.saveAndReregister(pool.id);
|
|
490
|
+
hooks.notify(`multikey[${pool.id}]: added Cline credential ${entry.label ?? entry.key.slice(0, 6)}…`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
255
495
|
const raw = await ctx.ui.input(`Add API key #${pool.keys.length + 1}`, "paste an API key");
|
|
256
496
|
const value = raw?.trim();
|
|
257
497
|
if (value) {
|
|
@@ -269,6 +509,15 @@ async function keysMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConf
|
|
|
269
509
|
const key = pool.keys[index];
|
|
270
510
|
if (!key) continue;
|
|
271
511
|
const keyAction = await selectOne(ctx, `Key: ${key.label ?? maskKey(key.key)}`, [
|
|
512
|
+
...(key.credential
|
|
513
|
+
? [
|
|
514
|
+
{
|
|
515
|
+
value: "refresh",
|
|
516
|
+
label: "Refresh Cline token now…",
|
|
517
|
+
description: describeClineCredential(key.credential),
|
|
518
|
+
},
|
|
519
|
+
]
|
|
520
|
+
: []),
|
|
272
521
|
{ value: "toggle", label: key.enabled === false ? "Enable" : "Disable" },
|
|
273
522
|
{ value: "label", label: "Edit label…" },
|
|
274
523
|
{ value: "replace", label: "Replace value…" },
|
|
@@ -276,6 +525,22 @@ async function keysMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConf
|
|
|
276
525
|
{ value: "back", label: "Back" },
|
|
277
526
|
]);
|
|
278
527
|
if (keyAction === null || keyAction === "back") continue;
|
|
528
|
+
if (keyAction === "refresh" && key.credential) {
|
|
529
|
+
try {
|
|
530
|
+
const fresh = await withProgress(ctx, "Refreshing Cline token…", () =>
|
|
531
|
+
ensureClineAccessToken(key.credential!, { force: true }),
|
|
532
|
+
);
|
|
533
|
+
key.credential.refreshToken = fresh.refreshToken;
|
|
534
|
+
key.credential.accessToken = fresh.accessToken;
|
|
535
|
+
key.credential.expiresAt = fresh.expiresAt;
|
|
536
|
+
key.key = fresh.accessToken;
|
|
537
|
+
hooks.saveAndReregister(pool.id);
|
|
538
|
+
hooks.notify(`multikey[${pool.id}]: Cline token refreshed for ${key.label ?? maskKey(key.key)}`);
|
|
539
|
+
} catch (error) {
|
|
540
|
+
hooks.notify(`multikey[${pool.id}]: Cline token refresh failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
541
|
+
}
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
279
544
|
if (keyAction === "toggle") {
|
|
280
545
|
key.enabled = key.enabled === false;
|
|
281
546
|
hooks.saveAndReregister(pool.id);
|
|
@@ -305,13 +570,14 @@ async function keysMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConf
|
|
|
305
570
|
|
|
306
571
|
async function modelsMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConfig): Promise<void> {
|
|
307
572
|
for (;;) {
|
|
573
|
+
const stale = hasModelDiff(presetDrift(pool)?.diff);
|
|
308
574
|
const items = pool.models.map((m) => ({
|
|
309
575
|
value: m.id,
|
|
310
576
|
label: m.id,
|
|
311
577
|
suffix: m.reasoning ? " 🧠" : "",
|
|
312
578
|
description: `ctx: ${m.contextWindow ?? 128000} · max: ${m.maxTokens ?? 16384} · in: ${(m.input ?? ["text"]).join("+")}`,
|
|
313
579
|
}));
|
|
314
|
-
const action = await selectOne(ctx, `Models: ${pool.id}`, [
|
|
580
|
+
const action = await selectOne(ctx, `Models: ${pool.id}${stale ? " (preset outdated)" : ""}`, [
|
|
315
581
|
...items,
|
|
316
582
|
{ value: "__add", label: "+ Add model…" },
|
|
317
583
|
{ value: "__back", label: "Back" },
|
|
@@ -622,8 +888,18 @@ async function addPresetPool(ctx: CommandContext, hooks: ManagerHooks, preset: P
|
|
|
622
888
|
return;
|
|
623
889
|
}
|
|
624
890
|
const poolId = idChoice.id;
|
|
625
|
-
|
|
626
|
-
|
|
891
|
+
// Cline accounts have no static API keys — collect an OAuth credential
|
|
892
|
+
// (device flow) or a pasted access token instead of the generic key prompt.
|
|
893
|
+
let keyConfigs: PoolKeyConfig[];
|
|
894
|
+
if (preset.id === "cline-free") {
|
|
895
|
+
const collected = await collectClineKeys(ctx, hooks);
|
|
896
|
+
if (collected === undefined) return;
|
|
897
|
+
keyConfigs = collected;
|
|
898
|
+
} else {
|
|
899
|
+
const keys = await askKeys(ctx, hooks, preset.keyHint);
|
|
900
|
+
if (keys === undefined) return;
|
|
901
|
+
keyConfigs = keys.map((key, i) => ({ key, label: `key-${i + 1}`, enabled: true }));
|
|
902
|
+
}
|
|
627
903
|
|
|
628
904
|
// Light-touch verification: probe the preset endpoint with the first key.
|
|
629
905
|
// The model specs are curated, so this is only a key sanity check.
|
|
@@ -631,12 +907,12 @@ async function addPresetPool(ctx: CommandContext, hooks: ManagerHooks, preset: P
|
|
|
631
907
|
let authNote = "keys not verified (offline?)";
|
|
632
908
|
try {
|
|
633
909
|
probe = await withProgress(ctx, `Verifying key against ${preset.baseUrl}…`, (update) =>
|
|
634
|
-
probeEndpoint(preset.baseUrl,
|
|
910
|
+
probeEndpoint(preset.baseUrl, keyConfigs[0]!.key, {
|
|
635
911
|
chatModelId: preset.models[0]?.id,
|
|
636
912
|
onLog: (line) => update(line.trimEnd()),
|
|
637
913
|
}),
|
|
638
914
|
);
|
|
639
|
-
authNote = describeAuth(probe);
|
|
915
|
+
authNote = keyConfigs[0]!.credential ? `Cline account credential (${describeClineCredential(keyConfigs[0]!.credential)})` : describeAuth(probe);
|
|
640
916
|
if (probe.authStatus === "rejected") {
|
|
641
917
|
const proceed = await ctx.ui.confirm(
|
|
642
918
|
"Key rejected",
|
|
@@ -648,7 +924,8 @@ async function addPresetPool(ctx: CommandContext, hooks: ManagerHooks, preset: P
|
|
|
648
924
|
// Probe must never block pool creation.
|
|
649
925
|
}
|
|
650
926
|
|
|
651
|
-
const pool = poolFromPreset(preset, poolId,
|
|
927
|
+
const pool = poolFromPreset(preset, poolId, keyConfigs.map((k) => k.key));
|
|
928
|
+
pool.keys = keyConfigs;
|
|
652
929
|
if (probe?.authStatus === "confirmed" && probe.auth === "api-key") pool.auth = "api-key";
|
|
653
930
|
hooks.config.pools.push(pool);
|
|
654
931
|
hooks.saveAndReregister(poolId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-multikey",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "One pi provider backed by many API keys: automatic 429 rotation, per-request key leases for concurrent subagents, and a /multikey management TUI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"index.ts",
|
|
27
|
+
"cline-auth.ts",
|
|
27
28
|
"config.ts",
|
|
29
|
+
"identity.ts",
|
|
28
30
|
"presets.ts",
|
|
29
31
|
"probe.ts",
|
|
30
32
|
"pool.ts",
|
package/pool.ts
CHANGED
|
@@ -6,20 +6,23 @@
|
|
|
6
6
|
* concurrent subagents across different keys automatically.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { PoolConfig } from "./config.ts";
|
|
9
|
+
import type { KeyCredential, PoolConfig } from "./config.ts";
|
|
10
10
|
import { maskKey } from "./config.ts";
|
|
11
11
|
|
|
12
|
-
export type KeyOutcome = "ok" | "rate_limited" | "invalid" | "error";
|
|
12
|
+
export type KeyOutcome = "ok" | "rate_limited" | "quota_exhausted" | "invalid" | "error";
|
|
13
13
|
|
|
14
14
|
export interface Lease {
|
|
15
15
|
key: string;
|
|
16
16
|
label: string;
|
|
17
17
|
acquiredAt: number;
|
|
18
|
+
/** OAuth credential behind this key, when it has one (Cline accounts). */
|
|
19
|
+
credential?: KeyCredential;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
interface KeyStat {
|
|
21
23
|
ok: number;
|
|
22
24
|
rateLimited: number;
|
|
25
|
+
quotaLimited: number;
|
|
23
26
|
invalid: number;
|
|
24
27
|
errors: number;
|
|
25
28
|
inflight: number;
|
|
@@ -29,7 +32,7 @@ interface KeyStat {
|
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
function newStat(): KeyStat {
|
|
32
|
-
return { ok: 0, rateLimited: 0, invalid: 0, errors: 0, inflight: 0, cooldownUntil: 0, lastUsed: 0 };
|
|
35
|
+
return { ok: 0, rateLimited: 0, quotaLimited: 0, invalid: 0, errors: 0, inflight: 0, cooldownUntil: 0, lastUsed: 0 };
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export class KeyPool {
|
|
@@ -49,10 +52,10 @@ export class KeyPool {
|
|
|
49
52
|
return s;
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
private enabledKeys(): { key: string; label: string }[] {
|
|
55
|
+
private enabledKeys(): { key: string; label: string; credential?: KeyCredential }[] {
|
|
53
56
|
return this.config.keys
|
|
54
57
|
.filter((k) => k.enabled !== false)
|
|
55
|
-
.map((k) => ({ key: k.key, label: k.label ?? maskKey(k.key) }));
|
|
58
|
+
.map((k) => ({ key: k.key, label: k.label ?? maskKey(k.key), credential: k.credential }));
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
get size(): number {
|
|
@@ -85,7 +88,7 @@ export class KeyPool {
|
|
|
85
88
|
const chosen = candidates[0]!;
|
|
86
89
|
chosen.stat.inflight++;
|
|
87
90
|
chosen.stat.lastUsed = now;
|
|
88
|
-
return { key: chosen.key, label: chosen.label, acquiredAt: now };
|
|
91
|
+
return { key: chosen.key, label: chosen.label, acquiredAt: now, credential: chosen.credential };
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
// All keys cooling: wait for the earliest recovery (bounded to 60s).
|
|
@@ -115,6 +118,14 @@ export class KeyPool {
|
|
|
115
118
|
s.cooldownUntil = Math.max(s.cooldownUntil, now + (cooldownMs ?? this.config.cooldownMs ?? 20_000));
|
|
116
119
|
s.cooldownReason = "429";
|
|
117
120
|
break;
|
|
121
|
+
case "quota_exhausted":
|
|
122
|
+
// Daily per-account quota (Cline free models): the server tells us when
|
|
123
|
+
// it resets ("Try again in 23h 59m"), so the cooldown is hours, not the
|
|
124
|
+
// 20s rate-limit rotation. A sane fallback if the parse ever fails.
|
|
125
|
+
s.quotaLimited++;
|
|
126
|
+
s.cooldownUntil = Math.max(s.cooldownUntil, now + (cooldownMs ?? 30 * 60_000));
|
|
127
|
+
s.cooldownReason = "daily limit";
|
|
128
|
+
break;
|
|
118
129
|
case "invalid":
|
|
119
130
|
s.invalid++;
|
|
120
131
|
s.cooldownUntil = Math.max(s.cooldownUntil, now + (this.config.invalidKeyCooldownMs ?? 600_000));
|
|
@@ -147,6 +158,34 @@ export class KeyPool {
|
|
|
147
158
|
});
|
|
148
159
|
}
|
|
149
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Persist a refreshed OAuth credential onto the key's config entry (the
|
|
163
|
+
* access token also becomes the key value so every consumer sees the same
|
|
164
|
+
* token). Live stats move with the rotation — they are keyed by the key
|
|
165
|
+
* string, so the entry's counters (including its in-flight count) must be
|
|
166
|
+
* re-keyed to the new token. The caller saves the config to disk.
|
|
167
|
+
*/
|
|
168
|
+
applyClineCredential(lease: Lease, update: { accessToken: string; refreshToken: string; expiresAt?: number }): void {
|
|
169
|
+
const entry = this.config.keys.find(
|
|
170
|
+
(k) => k.credential === lease.credential || (k.credential && lease.credential && k.credential.refreshToken === lease.credential.refreshToken),
|
|
171
|
+
);
|
|
172
|
+
if (!entry?.credential) return;
|
|
173
|
+
const oldKey = entry.key;
|
|
174
|
+
entry.credential.refreshToken = update.refreshToken;
|
|
175
|
+
entry.credential.accessToken = update.accessToken;
|
|
176
|
+
entry.credential.expiresAt = update.expiresAt;
|
|
177
|
+
entry.key = update.accessToken;
|
|
178
|
+
if (oldKey !== update.accessToken) {
|
|
179
|
+
const stat = this.stats.get(oldKey);
|
|
180
|
+
if (stat) {
|
|
181
|
+
this.stats.delete(oldKey);
|
|
182
|
+
this.stats.set(update.accessToken, stat);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
lease.key = update.accessToken;
|
|
186
|
+
lease.credential = entry.credential;
|
|
187
|
+
}
|
|
188
|
+
|
|
150
189
|
/** One row per configured key, for TUI status display. */
|
|
151
190
|
statusRows(): {
|
|
152
191
|
label: string;
|
|
@@ -155,10 +194,12 @@ export class KeyPool {
|
|
|
155
194
|
inflight: number;
|
|
156
195
|
ok: number;
|
|
157
196
|
rateLimited: number;
|
|
197
|
+
quotaLimited: number;
|
|
158
198
|
invalid: number;
|
|
159
199
|
errors: number;
|
|
160
200
|
cooldownRemainingMs: number;
|
|
161
201
|
cooldownReason?: string;
|
|
202
|
+
credential?: KeyCredential;
|
|
162
203
|
}[] {
|
|
163
204
|
const now = Date.now();
|
|
164
205
|
return this.config.keys.map((k) => {
|
|
@@ -170,10 +211,12 @@ export class KeyPool {
|
|
|
170
211
|
inflight: s.inflight,
|
|
171
212
|
ok: s.ok,
|
|
172
213
|
rateLimited: s.rateLimited,
|
|
214
|
+
quotaLimited: s.quotaLimited,
|
|
173
215
|
invalid: s.invalid,
|
|
174
216
|
errors: s.errors,
|
|
175
217
|
cooldownRemainingMs: Math.max(0, s.cooldownUntil - now),
|
|
176
218
|
cooldownReason: s.cooldownReason,
|
|
219
|
+
credential: k.credential,
|
|
177
220
|
};
|
|
178
221
|
});
|
|
179
222
|
}
|