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/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 ${Math.ceil(row.cooldownRemainingMs / 1000)}s (${row.cooldownReason ?? "?"})`
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(24)} ok:${row.ok} 429:${row.rateLimited} bad:${row.invalid} err:${row.errors}`,
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 ${Math.ceil(row.cooldownRemainingMs / 1000)}s`
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: `ok:${row?.ok ?? 0} 429:${row?.rateLimited ?? 0} bad:${row?.invalid ?? 0}`,
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
- const keys = await askKeys(ctx, hooks, preset.keyHint);
626
- if (keys === undefined) return;
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, keys[0]!, {
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, keys);
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.7.0",
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
  }