pi-multikey 1.7.1 → 1.8.1

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/identity.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Client identity headers for endpoints that gate on them.
3
+ *
4
+ * OpenCode Zen — opencode.ai/zen identifies the calling client with four headers,
5
+ * set in packages/opencode/src/session/llm/request.ts (LLMRequestPrep.prepare)
6
+ * whenever the provider id starts with "opencode":
7
+ *
8
+ * x-opencode-client flags.client ("tui" for the terminal client)
9
+ * User-Agent opencode/<InstallationVersion>
10
+ * x-opencode-session input.sessionID ("ses_" + Identifier.create(descending))
11
+ * x-opencode-request input.user.id, the id of the user message being answered
12
+ * ("msg_" + Identifier.create(ascending))
13
+ *
14
+ * prompt.ts resolves `lastUser` once per turn and passes that same message into
15
+ * every step of the agentic loop, so one user turn — including retries — reuses
16
+ * one request id. pi exposes neither a session id nor message ids to a custom
17
+ * stream function (UserMessage is just { role, content, timestamp }), so we mint
18
+ * ids in the same format and keep them alive for the same span opencode would.
19
+ *
20
+ * Cline — api.cline.bot correlates a conversation with `X-Task-ID`, a v4 uuid.
21
+ */
22
+
23
+ import { createHash, randomBytes } from "node:crypto";
24
+
25
+ /** Alphabet used by opencode's Identifier.create (packages/schema/src/identifier.ts). */
26
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
27
+
28
+ /**
29
+ * Port of opencode's Identifier.create(): a sortable, KSUID-like 26-char suffix.
30
+ * The first 12 chars are the hex of `(timestamp_ms * 0x1000 + counter)`, bitwise
31
+ * NOT'd for descending ids so newer ids sort first; the last 14 are random base62.
32
+ * The counter resets whenever the millisecond changes.
33
+ */
34
+ let lastTs = 0;
35
+ let count = 0;
36
+ export function createIdentifier(descending: boolean, ts: number = Date.now()): string {
37
+ if (ts !== lastTs) {
38
+ lastTs = ts;
39
+ count = 0;
40
+ }
41
+ const time = BigInt(ts) * 0x1000n + BigInt(count++);
42
+ const value = descending ? ~time & 0xffffffffffffn : time;
43
+ const hex = value.toString(16).padStart(12, "0");
44
+ let id = hex;
45
+ for (const byte of randomBytes(14)) id += ALPHABET[byte % ALPHABET.length];
46
+ return id;
47
+ }
48
+
49
+ /** New session identifier, matching opencode's `SessionID.ascending()` ("ses_" + 26). */
50
+ export function createSessionId(): string {
51
+ return `ses_${createIdentifier(true)}`;
52
+ }
53
+
54
+ /** New message identifier, matching opencode's `MessageID.ascending()` ("msg_" + 26). */
55
+ export function createMessageId(): string {
56
+ return `msg_${createIdentifier(false)}`;
57
+ }
58
+
59
+ /**
60
+ * Stable key for "the turn we are currently answering" — opencode's `lastUser`.
61
+ * pi rebuilds message objects between requests, so object identity is useless;
62
+ * the last user message's timestamp plus a digest of its content stays equal
63
+ * across every step of the same turn.
64
+ */
65
+ export function turnKeyOf(messages: readonly unknown[]): string | undefined {
66
+ for (let i = messages.length - 1; i >= 0; i--) {
67
+ const m = messages[i] as { role?: string; timestamp?: number; content?: unknown } | undefined;
68
+ if (m?.role !== "user") continue;
69
+ const digest = createHash("sha1").update(JSON.stringify(m.content ?? "")).digest("hex");
70
+ return `${m.timestamp ?? 0}:${digest}`;
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ let sessionId: string | undefined;
76
+ let requestId: string | undefined;
77
+ let turnKey: string | undefined;
78
+ let taskId: string | undefined;
79
+
80
+ /** Forget the current conversation (called on session_start for new/resume/fork). */
81
+ export function resetConversation(): void {
82
+ sessionId = undefined;
83
+ requestId = undefined;
84
+ turnKey = undefined;
85
+ taskId = undefined;
86
+ }
87
+
88
+ /** The session id for the current conversation, created on first use. */
89
+ export function currentSessionId(): string {
90
+ if (!sessionId) sessionId = createSessionId();
91
+ return sessionId;
92
+ }
93
+
94
+ /**
95
+ * The request id for the turn identified by `key` (opencode's `input.user.id`
96
+ * equivalent). A new key mints a fresh "msg_" id and the same key keeps returning
97
+ * it; a missing key means there is no conversation context to key on (a probe,
98
+ * say), so it mints a fresh id without disturbing the cached turn.
99
+ */
100
+ export function currentRequestId(key: string | undefined): string {
101
+ if (key === undefined) return createMessageId();
102
+ if (!requestId || key !== turnKey) {
103
+ requestId = createMessageId();
104
+ turnKey = key;
105
+ }
106
+ return requestId;
107
+ }
108
+
109
+ /** Cline's X-Task-ID: one v4 uuid per conversation. */
110
+ export function currentTaskId(): string {
111
+ if (!taskId) taskId = crypto.randomUUID();
112
+ return taskId;
113
+ }
package/index.ts CHANGED
@@ -12,9 +12,10 @@
12
12
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
13
  import { getApiProvider, type Api } from "@earendil-works/pi-ai";
14
14
  import { configPath, endpointHeaders, loadConfig, saveConfig, toProviderModels, type KeypoolConfig, type PoolConfig } from "./config.ts";
15
+ import { resetConversation } from "./identity.ts";
15
16
  import { KeyPool } from "./pool.ts";
16
17
  import { createRotatingStreamSimple } from "./stream.ts";
17
- import { runManager, type ManagerHooks } from "./manage.ts";
18
+ import { runManager, maybeOfferPresetUpdates, type ManagerHooks } from "./manage.ts";
18
19
 
19
20
  type CommandContext = Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
20
21
 
@@ -71,7 +72,7 @@ export default function multikey(pi: ExtensionAPI) {
71
72
  api,
72
73
  headers: { ...pool.headers, ...endpointHeaders(pool.baseUrl) },
73
74
  models: toProviderModels(pool),
74
- streamSimple: createRotatingStreamSimple(keyPool, api, notify),
75
+ streamSimple: createRotatingStreamSimple(keyPool, api, notify, () => saveConfig(config)),
75
76
  });
76
77
  return undefined;
77
78
  }
@@ -131,8 +132,12 @@ export default function multikey(pi: ExtensionAPI) {
131
132
  }
132
133
  }
133
134
 
134
- pi.on("session_start", async (_event, ctx) => {
135
+ pi.on("session_start", async (event, ctx) => {
135
136
  ui = ctx.ui;
137
+ // One OpenCode session id / request id and one Cline task id per
138
+ // conversation: /new, resume and fork switch conversations, so drop the
139
+ // cached ids for those (but not on plain startup or an extension reload).
140
+ if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") resetConversation();
136
141
  for (const { id, reason } of skipped) {
137
142
  notify(`multikey: provider "${id}" not available — ${reason}. Fix it via /multikey → Manage pools.`);
138
143
  }
@@ -149,6 +154,22 @@ export default function multikey(pi: ExtensionAPI) {
149
154
  notify(`multikey: no multi-key providers found in models.json — run /multikey → Add pool to create one at ${path}.`);
150
155
  }
151
156
  }
157
+ if (ctx.hasUI) {
158
+ const hooks: ManagerHooks = {
159
+ get config() {
160
+ return config;
161
+ },
162
+ pools,
163
+ saveAndReregister,
164
+ removePool,
165
+ reloadFromDisk,
166
+ saveConfig: () => saveConfig(config),
167
+ notify,
168
+ };
169
+ // Ask (at most once per preset version) whether to align preset-created
170
+ // pools with updated built-in presets. Never blocks or breaks startup.
171
+ await maybeOfferPresetUpdates(hooks, ctx as CommandContext);
172
+ }
152
173
  });
153
174
 
154
175
  const managerHandler = async (_args: unknown, ctx: CommandContext) => {
@@ -164,6 +185,7 @@ export default function multikey(pi: ExtensionAPI) {
164
185
  saveAndReregister,
165
186
  removePool,
166
187
  reloadFromDisk,
188
+ saveConfig: () => saveConfig(config),
167
189
  notify,
168
190
  };
169
191
  await runManager(pi, ctx, hooks);
package/manage.ts CHANGED
@@ -9,17 +9,21 @@ 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";
22
- import { inputNumber, pickMany, selectOne, showInfo, withProgress } from "./tui.ts";
24
+ import { spawn } from "node:child_process";
25
+ import { describeClineCredential, ensureClineAccessToken, formatClineAccessToken, loginClineDeviceFlow } from "./cline-auth.ts";
26
+ import { inputNumber, pickMany, selectOne, showInfo, showInfoWithHandle, withProgress, type InfoPanelHandle } from "./tui.ts";
23
27
 
24
28
  type CommandContext = Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
25
29
 
@@ -29,6 +33,8 @@ export interface ManagerHooks {
29
33
  saveAndReregister(poolId: string): void;
30
34
  removePool(poolId: string): void;
31
35
  reloadFromDisk(): void;
36
+ /** Persist the current config without re-registering (preset sync bookkeeping). */
37
+ saveConfig(): void;
32
38
  notify(message: string): void;
33
39
  }
34
40
 
@@ -81,6 +87,261 @@ function describeAuth(probe: ProbeResult): string {
81
87
  : "using x-api-key auth (could not fully verify)";
82
88
  }
83
89
 
90
+ // ---------------------------------------------------------------------------
91
+ // Cline account credentials (device flow sign-in + pasted tokens)
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /** Humanize a duration for display: 90s+ → minutes, 90m+ → hours. */
95
+ function formatCooldown(ms: number): string {
96
+ if (ms < 90_000) return `${Math.ceil(ms / 1000)}s`;
97
+ if (ms < 90 * 60_000) return `${Math.ceil(ms / 60_000)}m`;
98
+ return `${Math.ceil(ms / 3_600_000)}h`;
99
+ }
100
+
101
+ /**
102
+ * Best-effort browser open for device-flow verification URLs. Never throws;
103
+ * a missing opener only means the user has to paste the URL manually (the
104
+ * auth panel always shows the URL as a fallback).
105
+ */
106
+ function tryOpenBrowser(url: string): boolean {
107
+ const platform = process.platform;
108
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
109
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
110
+ try {
111
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
112
+ child.on("error", () => {}); // opener missing — the panel shows the URL anyway
113
+ child.unref();
114
+ return true;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Collect a Cline account credential: the WorkOS device flow (recommended —
122
+ * refresh tokens keep the access token alive) or a manually pasted access
123
+ * token (works until it expires). Returns one key entry, or undefined on cancel.
124
+ */
125
+ async function collectClineKeys(ctx: CommandContext, hooks: ManagerHooks): Promise<PoolKeyConfig[] | undefined> {
126
+ const how = await selectOne(ctx, "Cline credentials", [
127
+ {
128
+ value: "device",
129
+ label: "Sign in with Cline (device flow)…",
130
+ description: "Authorize your Cline account in the browser; the token refreshes automatically",
131
+ },
132
+ {
133
+ value: "paste",
134
+ label: "Paste a Cline access token…",
135
+ description: "From ~/.cline/data/secrets.json — stops working when it expires",
136
+ },
137
+ ]);
138
+ if (how === "device") {
139
+ let credential: KeyCredential;
140
+ let panel: InfoPanelHandle | undefined;
141
+ try {
142
+ // withProgress swallows task errors and resolves undefined, so capture
143
+ // the real error here and rethrow it below — `undefined` tokens used to
144
+ // crash with "cannot read properties of undefined" and mask the failure.
145
+ let loginError: unknown;
146
+ const tokens = await withProgress(ctx, "Cline sign-in", async (update) => {
147
+ try {
148
+ return await loginClineDeviceFlow({
149
+ onAuthInfo: async ({ url, userCode }) => {
150
+ const opened = tryOpenBrowser(url);
151
+ panel = showInfoWithHandle(ctx, "Authorize Cline", [
152
+ opened
153
+ ? "A browser window should have opened — approve the sign-in there."
154
+ : "Open this URL in your browser and approve the sign-in:",
155
+ "",
156
+ ...(opened ? ["If no window opened, paste this URL:", url] : [url]),
157
+ "",
158
+ `User code: ${userCode}`,
159
+ "",
160
+ "Sign-in continues automatically once you approve — you can dismiss this panel any time.",
161
+ ]);
162
+ await panel.closed;
163
+ },
164
+ onAuthorized: () => {
165
+ update("Browser authorization confirmed — exchanging tokens…");
166
+ panel?.close();
167
+ },
168
+ onProgress: (message) => update(message),
169
+ });
170
+ } catch (error) {
171
+ loginError = error;
172
+ throw error;
173
+ }
174
+ });
175
+ if (!tokens) throw loginError ?? new Error("Cline sign-in failed");
176
+ // Store the access token with Cline's required "workos:" prefix (same
177
+ // convention as the official CLI's providers.json); formatting is
178
+ // idempotent, so the request path can safely format again.
179
+ const accessToken = formatClineAccessToken(tokens.accessToken);
180
+ credential = {
181
+ kind: "cline-oauth",
182
+ refreshToken: tokens.refreshToken,
183
+ accessToken,
184
+ expiresAt: tokens.expiresAt,
185
+ };
186
+ } catch (error) {
187
+ await showInfo(ctx, "Cline sign-in failed", [error instanceof Error ? error.message : String(error)]);
188
+ return undefined;
189
+ } finally {
190
+ panel?.close();
191
+ }
192
+ return [{ key: formatClineAccessToken(credential.accessToken ?? credential.refreshToken), label: "cline-account", enabled: true, credential }];
193
+ }
194
+ if (how === "paste") {
195
+ const raw = await ctx.ui.input("Cline access token", "paste the token");
196
+ const value = raw?.trim();
197
+ if (!value) return undefined;
198
+ // Cline's API rejects raw JWTs — the access token must carry the
199
+ // "workos:" prefix (formatClineAccessToken is idempotent).
200
+ return [{ key: formatClineAccessToken(value), label: "cline-account", enabled: true }];
201
+ }
202
+ return undefined;
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Preset sync: offer to align preset-created pools with updated presets
207
+ // ---------------------------------------------------------------------------
208
+
209
+ interface PresetDrift {
210
+ pool: PoolConfig;
211
+ preset: Preset;
212
+ diff: PresetModelDiff;
213
+ /** Stored fingerprint differs from the shipped preset — the ask-once trigger. */
214
+ fingerprintStale: boolean;
215
+ /** Matched by baseUrl only (pool created before preset tracking existed). */
216
+ legacy: boolean;
217
+ }
218
+
219
+ function hasModelDiff(diff: PresetModelDiff | undefined): boolean {
220
+ return !!diff && (diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0);
221
+ }
222
+
223
+ /**
224
+ * Resolve the shipped preset a pool belongs to (via the `_preset` marker, or
225
+ * by baseUrl for legacy pools created before tracking existed) and diff its
226
+ * current models against the preset.
227
+ *
228
+ * The marker's fingerprint is written at pool creation and again the moment a
229
+ * startup prompt is displayed — so each preset version is asked about at most
230
+ * once, and declining only mutes the current version (a later preset change
231
+ * prompts again). Hand-tuning models never triggers the automatic prompt;
232
+ * those pools stay reachable via /multikey → Check preset updates.
233
+ */
234
+ function presetDrift(pool: PoolConfig): PresetDrift | undefined {
235
+ const preset = pool._preset ? findPreset(pool._preset.id) : PRESETS.find((p) => p.baseUrl === pool.baseUrl);
236
+ if (!preset) return undefined;
237
+ const fingerprintStale = !pool._preset || pool._preset.fingerprint !== presetFingerprint(preset);
238
+ return { pool, preset, diff: diffPresetModels(pool.models, preset.models), fingerprintStale, legacy: !pool._preset };
239
+ }
240
+
241
+ function presetDriftPools(config: KeypoolConfig): PresetDrift[] {
242
+ return config.pools.map(presetDrift).filter((d): d is PresetDrift => !!d);
243
+ }
244
+
245
+ /**
246
+ * Replace the pool's models with the preset's (deep copy) and stamp the
247
+ * marker with the preset's current fingerprint. Keys, cooldowns, id, and
248
+ * endpoint are untouched; afterwards the provider is re-registered.
249
+ */
250
+ function applyPresetModels(hooks: ManagerHooks, pool: PoolConfig, preset: Preset): void {
251
+ pool.models = JSON.parse(JSON.stringify(preset.models)) as PoolModelConfig[];
252
+ pool._preset = { id: preset.id, fingerprint: presetFingerprint(preset) };
253
+ hooks.saveAndReregister(pool.id);
254
+ }
255
+
256
+ /**
257
+ * Startup hook: asks once per preset version when shipped presets moved on
258
+ * from what a pool was created/aligned with. The offered fingerprint is
259
+ * persisted before the dialog shows, so declining — or the process dying
260
+ * mid-prompt — never causes a repeat for the same version.
261
+ */
262
+ export async function maybeOfferPresetUpdates(hooks: ManagerHooks, ctx: CommandContext): Promise<void> {
263
+ try {
264
+ const stale = presetDriftPools(hooks.config).filter((d) => d.fingerprintStale);
265
+ if (stale.length === 0) return;
266
+ // Persist-on-display: mute this exact preset version before any dialog.
267
+ for (const { pool, preset } of stale) pool._preset = { id: preset.id, fingerprint: presetFingerprint(preset) };
268
+ hooks.saveConfig();
269
+ // Reorder-only drift (no model differences): silently refreshed above.
270
+ const actionable = stale.filter((d) => hasModelDiff(d.diff));
271
+ if (actionable.length === 0) return;
272
+ const summary = actionable.map((d) => `"${d.pool.id}" (${d.preset.name})`).join(", ");
273
+ const ok = await ctx.ui.confirm(
274
+ "Preset update available",
275
+ `Built-in presets changed for pool(s): ${summary}. Review and align now? ` +
276
+ "(Asked once per preset version; /multikey → Check preset updates always works.)",
277
+ );
278
+ if (ok) await checkPresetUpdatesMenu(ctx, hooks);
279
+ } catch {
280
+ // Never break session startup over the sync hint.
281
+ }
282
+ }
283
+
284
+ /**
285
+ * /multikey → Check preset updates: lists every pool whose model list differs
286
+ * from its (tracked or baseUrl-matched) preset — always visible, regardless
287
+ * of muting, so declined updates stay reachable.
288
+ */
289
+ export async function checkPresetUpdatesMenu(ctx: CommandContext, hooks: ManagerHooks): Promise<void> {
290
+ for (;;) {
291
+ const drifts = presetDriftPools(hooks.config).filter((d) => hasModelDiff(d.diff));
292
+ if (drifts.length === 0) {
293
+ await showInfo(ctx, "Preset updates", [
294
+ "All pools match the current built-in presets.",
295
+ "",
296
+ "Pools created from a preset are tracked; pools that predate tracking",
297
+ "are matched by baseUrl.",
298
+ ]);
299
+ return;
300
+ }
301
+ const action = await selectOne(
302
+ ctx,
303
+ "Pools differing from their preset",
304
+ drifts.map(({ pool, preset, diff }) => ({
305
+ value: pool.id,
306
+ label: `${pool.id} ← ${preset.name}`,
307
+ suffix: " (differs)",
308
+ description: describePresetDiff(diff).join("\n"),
309
+ })),
310
+ );
311
+ if (action === null) return;
312
+ const drift = drifts.find((d) => d.pool.id === action);
313
+ if (drift) await offerPoolAlignment(ctx, hooks, drift);
314
+ }
315
+ }
316
+
317
+ /** Show the diff for one pool and ask whether to align. */
318
+ async function offerPoolAlignment(ctx: CommandContext, hooks: ManagerHooks, drift: PresetDrift): Promise<void> {
319
+ const { pool, preset, diff } = drift;
320
+ const choice = await selectOne(ctx, `Align "${pool.id}" with ${preset.name} preset?`, [
321
+ {
322
+ value: "align",
323
+ label: "Align — apply preset models",
324
+ description:
325
+ [...describePresetDiff(diff), "", `Result: ${preset.models.map((m) => m.id).join(", ")}`, "Keys, endpoint and settings are untouched."].join("\n"),
326
+ },
327
+ {
328
+ value: "keep",
329
+ label: "Keep my models",
330
+ description: drift.fingerprintStale
331
+ ? "No automatic prompt for this preset version; revisit via Check preset updates."
332
+ : "Your models are kept (preset hasn't changed since the last sync).",
333
+ },
334
+ ]);
335
+ if (choice === "align") {
336
+ applyPresetModels(hooks, pool, preset);
337
+ hooks.notify(`multikey[${pool.id}]: aligned with ${preset.name} preset — ${pool.models.length} models`);
338
+ } else if (choice === "keep" && drift.fingerprintStale) {
339
+ // Seen-and-kept: mute this preset version's automatic prompt.
340
+ pool._preset = { id: preset.id, fingerprint: presetFingerprint(preset) };
341
+ hooks.saveConfig();
342
+ }
343
+ }
344
+
84
345
  export async function runManager(pi: ExtensionAPI, ctx: CommandContext, hooks: ManagerHooks): Promise<void> {
85
346
  for (;;) {
86
347
  const pools = hooks.config.pools;
@@ -88,6 +349,7 @@ export async function runManager(pi: ExtensionAPI, ctx: CommandContext, hooks: M
88
349
  { value: "status", label: "Status", description: "Live per-key state: in-flight, cooldowns, 429 counts" },
89
350
  { value: "manage", label: "Manage pools…", description: "Keys, models, endpoints, cooldowns" },
90
351
  { value: "add", label: "Add pool…", description: "Register another provider (b.ai, nvidia, opencode, …)" },
352
+ { value: "presetsync", label: "Check preset updates…", description: "Align preset-created pools with updated built-in presets" },
91
353
  { value: "reload", label: "Reload config from disk", description: "Re-read multikey.json and re-register providers" },
92
354
  { value: "usage", label: "Usage tips" },
93
355
  { value: "exit", label: "Close" },
@@ -117,6 +379,10 @@ export async function runManager(pi: ExtensionAPI, ctx: CommandContext, hooks: M
117
379
  await addPoolWizard(ctx, hooks);
118
380
  continue;
119
381
  }
382
+ if (action === "presetsync") {
383
+ await checkPresetUpdatesMenu(ctx, hooks);
384
+ continue;
385
+ }
120
386
  if (action === "manage") {
121
387
  const poolId = await selectOne(
122
388
  ctx,
@@ -159,12 +425,13 @@ function renderStatus(hooks: ManagerHooks): string[] {
159
425
  const state = !row.enabled
160
426
  ? "disabled"
161
427
  : row.cooldownRemainingMs > 0
162
- ? `cooldown ${Math.ceil(row.cooldownRemainingMs / 1000)}s (${row.cooldownReason ?? "?"})`
428
+ ? `cooldown ${formatCooldown(row.cooldownRemainingMs)} (${row.cooldownReason ?? "?"})`
163
429
  : row.inflight > 0
164
430
  ? `active ×${row.inflight}`
165
431
  : "idle";
432
+ const credentialNote = row.credential ? ` · ${describeClineCredential(row.credential)}` : "";
166
433
  lines.push(
167
- ` ${row.label.padEnd(12)} ${row.masked.padEnd(16)} ${state.padEnd(24)} ok:${row.ok} 429:${row.rateLimited} bad:${row.invalid} err:${row.errors}`,
434
+ ` ${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
435
  );
169
436
  }
170
437
  lines.push("");
@@ -238,7 +505,7 @@ async function keysMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConf
238
505
  const state = !k.enabled
239
506
  ? "disabled"
240
507
  : row && row.cooldownRemainingMs > 0
241
- ? `cooldown ${Math.ceil(row.cooldownRemainingMs / 1000)}s`
508
+ ? `cooldown ${formatCooldown(row.cooldownRemainingMs)}${row.cooldownReason ? ` (${row.cooldownReason})` : ""}`
242
509
  : row && row.inflight > 0
243
510
  ? `active ×${row.inflight}`
244
511
  : "idle";
@@ -246,12 +513,32 @@ async function keysMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConf
246
513
  value: String(i),
247
514
  label: k.label ?? maskKey(k.key),
248
515
  suffix: ` ${maskKey(k.key)} ${state}`,
249
- description: `ok:${row?.ok ?? 0} 429:${row?.rateLimited ?? 0} bad:${row?.invalid ?? 0}`,
516
+ description: [
517
+ `ok:${row?.ok ?? 0} 429:${row?.rateLimited ?? 0} quota:${row?.quotaLimited ?? 0} bad:${row?.invalid ?? 0}`,
518
+ k.credential ? describeClineCredential(k.credential) : undefined,
519
+ ]
520
+ .filter(Boolean)
521
+ .join(" · "),
250
522
  };
251
523
  });
252
- const action = await selectOne(ctx, `Keys: ${pool.id}`, [...items, { value: "__add", label: "+ Add key…" }, { value: "__back", label: "Back" }]);
524
+ 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
525
  if (action === null || action === "__back") return;
254
526
  if (action === "__add") {
527
+ // Cline accounts authenticate via OAuth instead of static API keys.
528
+ if (isClineEndpoint(pool.baseUrl)) {
529
+ const collected = await collectClineKeys(ctx, hooks);
530
+ if (collected && collected.length > 0) {
531
+ const entry = collected[0]!;
532
+ if (pool.keys.some((k) => k.credential?.refreshToken === entry.credential?.refreshToken || k.key === entry.key)) {
533
+ hooks.notify(`multikey[${pool.id}]: this Cline credential is already in the pool`);
534
+ } else {
535
+ pool.keys.push(entry);
536
+ hooks.saveAndReregister(pool.id);
537
+ hooks.notify(`multikey[${pool.id}]: added Cline credential ${entry.label ?? entry.key.slice(0, 6)}…`);
538
+ }
539
+ }
540
+ continue;
541
+ }
255
542
  const raw = await ctx.ui.input(`Add API key #${pool.keys.length + 1}`, "paste an API key");
256
543
  const value = raw?.trim();
257
544
  if (value) {
@@ -269,6 +556,15 @@ async function keysMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConf
269
556
  const key = pool.keys[index];
270
557
  if (!key) continue;
271
558
  const keyAction = await selectOne(ctx, `Key: ${key.label ?? maskKey(key.key)}`, [
559
+ ...(key.credential
560
+ ? [
561
+ {
562
+ value: "refresh",
563
+ label: "Refresh Cline token now…",
564
+ description: describeClineCredential(key.credential),
565
+ },
566
+ ]
567
+ : []),
272
568
  { value: "toggle", label: key.enabled === false ? "Enable" : "Disable" },
273
569
  { value: "label", label: "Edit label…" },
274
570
  { value: "replace", label: "Replace value…" },
@@ -276,6 +572,22 @@ async function keysMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConf
276
572
  { value: "back", label: "Back" },
277
573
  ]);
278
574
  if (keyAction === null || keyAction === "back") continue;
575
+ if (keyAction === "refresh" && key.credential) {
576
+ try {
577
+ const fresh = await withProgress(ctx, "Refreshing Cline token…", () =>
578
+ ensureClineAccessToken(key.credential!, { force: true }),
579
+ );
580
+ key.credential.refreshToken = fresh.refreshToken;
581
+ key.credential.accessToken = fresh.accessToken;
582
+ key.credential.expiresAt = fresh.expiresAt;
583
+ key.key = fresh.accessToken;
584
+ hooks.saveAndReregister(pool.id);
585
+ hooks.notify(`multikey[${pool.id}]: Cline token refreshed for ${key.label ?? maskKey(key.key)}`);
586
+ } catch (error) {
587
+ hooks.notify(`multikey[${pool.id}]: Cline token refresh failed: ${error instanceof Error ? error.message : String(error)}`);
588
+ }
589
+ continue;
590
+ }
279
591
  if (keyAction === "toggle") {
280
592
  key.enabled = key.enabled === false;
281
593
  hooks.saveAndReregister(pool.id);
@@ -305,13 +617,14 @@ async function keysMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConf
305
617
 
306
618
  async function modelsMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConfig): Promise<void> {
307
619
  for (;;) {
620
+ const stale = hasModelDiff(presetDrift(pool)?.diff);
308
621
  const items = pool.models.map((m) => ({
309
622
  value: m.id,
310
623
  label: m.id,
311
624
  suffix: m.reasoning ? " 🧠" : "",
312
625
  description: `ctx: ${m.contextWindow ?? 128000} · max: ${m.maxTokens ?? 16384} · in: ${(m.input ?? ["text"]).join("+")}`,
313
626
  }));
314
- const action = await selectOne(ctx, `Models: ${pool.id}`, [
627
+ const action = await selectOne(ctx, `Models: ${pool.id}${stale ? " (preset outdated)" : ""}`, [
315
628
  ...items,
316
629
  { value: "__add", label: "+ Add model…" },
317
630
  { value: "__back", label: "Back" },
@@ -622,8 +935,18 @@ async function addPresetPool(ctx: CommandContext, hooks: ManagerHooks, preset: P
622
935
  return;
623
936
  }
624
937
  const poolId = idChoice.id;
625
- const keys = await askKeys(ctx, hooks, preset.keyHint);
626
- if (keys === undefined) return;
938
+ // Cline accounts have no static API keys — collect an OAuth credential
939
+ // (device flow) or a pasted access token instead of the generic key prompt.
940
+ let keyConfigs: PoolKeyConfig[];
941
+ if (preset.id === "cline-free") {
942
+ const collected = await collectClineKeys(ctx, hooks);
943
+ if (collected === undefined) return;
944
+ keyConfigs = collected;
945
+ } else {
946
+ const keys = await askKeys(ctx, hooks, preset.keyHint);
947
+ if (keys === undefined) return;
948
+ keyConfigs = keys.map((key, i) => ({ key, label: `key-${i + 1}`, enabled: true }));
949
+ }
627
950
 
628
951
  // Light-touch verification: probe the preset endpoint with the first key.
629
952
  // The model specs are curated, so this is only a key sanity check.
@@ -631,12 +954,12 @@ async function addPresetPool(ctx: CommandContext, hooks: ManagerHooks, preset: P
631
954
  let authNote = "keys not verified (offline?)";
632
955
  try {
633
956
  probe = await withProgress(ctx, `Verifying key against ${preset.baseUrl}…`, (update) =>
634
- probeEndpoint(preset.baseUrl, keys[0]!, {
957
+ probeEndpoint(preset.baseUrl, keyConfigs[0]!.key, {
635
958
  chatModelId: preset.models[0]?.id,
636
959
  onLog: (line) => update(line.trimEnd()),
637
960
  }),
638
961
  );
639
- authNote = describeAuth(probe);
962
+ authNote = keyConfigs[0]!.credential ? `Cline account credential (${describeClineCredential(keyConfigs[0]!.credential)})` : describeAuth(probe);
640
963
  if (probe.authStatus === "rejected") {
641
964
  const proceed = await ctx.ui.confirm(
642
965
  "Key rejected",
@@ -648,7 +971,8 @@ async function addPresetPool(ctx: CommandContext, hooks: ManagerHooks, preset: P
648
971
  // Probe must never block pool creation.
649
972
  }
650
973
 
651
- const pool = poolFromPreset(preset, poolId, keys);
974
+ const pool = poolFromPreset(preset, poolId, keyConfigs.map((k) => k.key));
975
+ pool.keys = keyConfigs;
652
976
  if (probe?.authStatus === "confirmed" && probe.auth === "api-key") pool.auth = "api-key";
653
977
  hooks.config.pools.push(pool);
654
978
  hooks.saveAndReregister(poolId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multikey",
3
- "version": "1.7.1",
3
+ "version": "1.8.1",
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",
@@ -10,6 +10,8 @@
10
10
  "api-keys",
11
11
  "rate-limit",
12
12
  "429",
13
+ "cline",
14
+ "oauth",
13
15
  "provider",
14
16
  "key-pool",
15
17
  "bai",
@@ -24,7 +26,9 @@
24
26
  },
25
27
  "files": [
26
28
  "index.ts",
29
+ "cline-auth.ts",
27
30
  "config.ts",
31
+ "identity.ts",
28
32
  "presets.ts",
29
33
  "probe.ts",
30
34
  "pool.ts",