pi-multikey 1.7.1 → 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/config.ts CHANGED
@@ -11,6 +11,7 @@ import { homedir } from "node:os";
11
11
  import { dirname, join } from "node:path";
12
12
  import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
13
13
  import type { AuthStyle } from "./probe.ts";
14
+ import { currentDeviceId, currentSessionId, currentTaskId, setDeviceId } from "./identity.ts";
14
15
 
15
16
  /** Safe model defaults applied when a spec doesn't say otherwise (edit in multikey.json). */
16
17
  export const DEFAULT_CONTEXT_WINDOW = 128_000;
@@ -37,6 +38,36 @@ export interface PoolKeyConfig {
37
38
  label?: string;
38
39
  /** Disabled keys are never selected. Default: true. */
39
40
  enabled?: boolean;
41
+ /**
42
+ * Credential-backed key (Cline account OAuth). When present, `key` holds the
43
+ * current access token and is refreshed transparently from `credential`
44
+ * before requests and again on 401 — see cline-auth.ts.
45
+ */
46
+ credential?: KeyCredential;
47
+ }
48
+
49
+ /** OAuth-style credential for endpoints without static API keys (Cline free tier). */
50
+ export interface KeyCredential {
51
+ kind: "cline-oauth";
52
+ /** Long-lived refresh token; access tokens are minted from it. */
53
+ refreshToken: string;
54
+ /** Last known access token (mirrors `key`). */
55
+ accessToken?: string;
56
+ /** Access token expiry (epoch ms), when known. */
57
+ expiresAt?: number;
58
+ }
59
+
60
+ /**
61
+ * Tracks which shipped preset a pool was created from / last aligned with.
62
+ * Lives in multikey.json as `_preset`. The fingerprint (see presetFingerprint
63
+ * in presets.ts) is persisted the moment an update prompt is shown — that is
64
+ * what guarantees each preset version is asked about at most once.
65
+ */
66
+ export interface PresetMarker {
67
+ /** Preset id in presets.ts, e.g. "b-ai". */
68
+ id: string;
69
+ /** Fingerprint of the preset's model list at pool creation / last sync. */
70
+ fingerprint: string;
40
71
  }
41
72
 
42
73
  export interface PoolModelConfig {
@@ -70,12 +101,20 @@ export interface PoolConfig {
70
101
  cooldownMs?: number;
71
102
  /** Cooldown after a 401/403 (bad key). Default 600000ms. */
72
103
  invalidKeyCooldownMs?: number;
104
+ /** Present when the pool was created from (or adopted) a shipped preset. */
105
+ _preset?: PresetMarker;
73
106
  keys: PoolKeyConfig[];
74
107
  models: PoolModelConfig[];
75
108
  }
76
109
 
77
110
  export interface KeypoolConfig {
78
111
  pools: PoolConfig[];
112
+ /**
113
+ * Stable per-install UUID sent as `x-opencode-request` to OpenCode Zen.
114
+ * Generated once and reused forever, so every request from this machine
115
+ * carries the same device identity.
116
+ */
117
+ deviceId?: string;
79
118
  }
80
119
 
81
120
  export function configPath(): string {
@@ -260,7 +299,26 @@ export function loadConfig(): { config: KeypoolConfig; created: boolean; migrate
260
299
  return { config, created: true };
261
300
  }
262
301
  const raw = JSON.parse(readFileSync(path, "utf-8")) as KeypoolConfig;
263
- return { config: normalize(raw), created: false };
302
+ const config = normalize(raw);
303
+ // Adopt any stored device id into the identity module; a config predating
304
+ // deviceId gets one assigned by ensureDeviceId() in the caller.
305
+ if (config.deviceId) setDeviceId(config.deviceId);
306
+ return { config, created: false };
307
+ }
308
+
309
+ /**
310
+ * Make sure a persisted device id exists for configs that expose an OpenCode
311
+ * Zen pool, saving only when it actually changed anything. Pools that never
312
+ * talk to Zen leave the file untouched.
313
+ */
314
+ export function ensureDeviceId(config: KeypoolConfig): void {
315
+ if (config.deviceId) {
316
+ setDeviceId(config.deviceId);
317
+ return;
318
+ }
319
+ if (!config.pools.some((pool) => isOpenCodeZenEndpoint(pool.baseUrl))) return;
320
+ config.deviceId = currentDeviceId();
321
+ saveConfig(config);
264
322
  }
265
323
 
266
324
  export function saveConfig(config: KeypoolConfig): void {
@@ -277,7 +335,19 @@ export function normalize(config: KeypoolConfig): KeypoolConfig {
277
335
  if (!pool.baseUrl || typeof pool.baseUrl !== "string") continue;
278
336
  normalized.push(normalizePool(pool));
279
337
  }
280
- return { pools: normalized };
338
+ const deviceId = typeof config.deviceId === "string" && config.deviceId.trim() ? config.deviceId.trim() : undefined;
339
+ return { pools: normalized, deviceId };
340
+ }
341
+
342
+ /** Structural check so a malformed credential in the JSON file can't break a pool. */
343
+ function isValidCredential(credential: unknown): credential is KeyCredential {
344
+ return (
345
+ !!credential &&
346
+ typeof credential === "object" &&
347
+ (credential as KeyCredential).kind === "cline-oauth" &&
348
+ typeof (credential as KeyCredential).refreshToken === "string" &&
349
+ (credential as KeyCredential).refreshToken.trim().length > 0
350
+ );
281
351
  }
282
352
 
283
353
  function normalizePool(pool: PoolConfig): PoolConfig {
@@ -287,8 +357,14 @@ function normalizePool(pool: PoolConfig): PoolConfig {
287
357
  key: k.key.trim(),
288
358
  label: typeof k.label === "string" && k.label.trim() ? k.label.trim() : undefined,
289
359
  enabled: k.enabled !== false,
360
+ // OAuth-backed keys keep their credential; only well-formed ones survive.
361
+ ...(isValidCredential(k.credential) ? { credential: k.credential } : {}),
290
362
  }));
291
363
  const models = (Array.isArray(pool.models) ? pool.models : []).filter((m) => m && typeof m.id === "string" && m.id.trim());
364
+ const preset =
365
+ pool._preset && typeof pool._preset.id === "string" && typeof pool._preset.fingerprint === "string"
366
+ ? { id: pool._preset.id, fingerprint: pool._preset.fingerprint }
367
+ : undefined;
292
368
  return {
293
369
  id: pool.id.trim(),
294
370
  name: pool.name ?? pool.id,
@@ -302,6 +378,7 @@ function normalizePool(pool: PoolConfig): PoolConfig {
302
378
  typeof pool.invalidKeyCooldownMs === "number" && pool.invalidKeyCooldownMs >= 0
303
379
  ? pool.invalidKeyCooldownMs
304
380
  : DEFAULT_INVALID_KEY_COOLDOWN_MS,
381
+ _preset: preset,
305
382
  keys,
306
383
  models,
307
384
  };
@@ -342,21 +419,73 @@ export function maskKey(key: string): string {
342
419
 
343
420
  // ── Endpoint-required headers ────────────────────────────────────────────────
344
421
 
345
- /** OpenCode Zen free tier endpoint that requires a specific User-Agent header. */
422
+ /** OpenCode Zen free tier endpoint that mimics the official OpenCode client. */
346
423
  const OPENCODE_ZEN_BASE_URL = "https://opencode.ai/zen/v1";
347
- const OPENCODE_ZEN_USER_AGENT =
348
- "opencode/1.15.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.13";
424
+ const OPENCODE_ZEN_USER_AGENT = "opencode/0.1.50 ai-sdk/openai-compatible/3.0.41";
425
+ const OPENCODE_ZEN_CLIENT = "tui";
426
+
427
+ /** True when a baseUrl points at OpenCode Zen (case-insensitive, trailing slash ok). */
428
+ export function isOpenCodeZenEndpoint(baseUrl: string | undefined): boolean {
429
+ if (!baseUrl) return false;
430
+ return baseUrl.replace(/\/+$/, "").toLowerCase() === OPENCODE_ZEN_BASE_URL;
431
+ }
349
432
 
350
433
  /**
351
- * Headers that must be sent to known endpoints.
352
- *
353
- * Currently: OpenCode Zen's free tier endpoint requires this exact User-Agent;
354
- * anything else returns an empty object.
434
+ * Constant headers a known endpoint expects on every request.
355
435
  *
356
- * The comparison is case-insensitive and tolerates a trailing slash.
436
+ * OpenCode Zen's free tier wants the full OpenCode client header set; the
437
+ * per-conversation / per-device halves live in {@link endpointIdentityHeaders}.
438
+ * Cline's API gates on its official client's header set (versioned client
439
+ * identity + platform metadata), so we send the same shape the Cline CLI
440
+ * sends (mirroring sdk request-headers.ts). Anything else returns an empty object.
357
441
  */
358
442
  export function endpointHeaders(baseUrl: string): Record<string, string> {
359
- const normalized = baseUrl.replace(/\/+$/, "").toLowerCase();
360
- if (normalized === OPENCODE_ZEN_BASE_URL) return { "User-Agent": OPENCODE_ZEN_USER_AGENT };
361
- return {};
443
+ if (isClineEndpoint(baseUrl)) return clineClientHeaders();
444
+ if (!isOpenCodeZenEndpoint(baseUrl)) return {};
445
+ return { "x-opencode-client": OPENCODE_ZEN_CLIENT, "User-Agent": OPENCODE_ZEN_USER_AGENT };
446
+ }
447
+
448
+ /**
449
+ * Per-request identity headers a known endpoint expects.
450
+ *
451
+ * OpenCode Zen reads `x-opencode-session` for per-conversation routing and
452
+ * `x-opencode-request` as the caller's device id, so these must be generated
453
+ * at request time rather than baked into the provider registration.
454
+ * Cline reads `X-Task-ID` as a per-conversation correlation id.
455
+ */
456
+ export function endpointIdentityHeaders(baseUrl: string | undefined): Record<string, string> {
457
+ if (isClineEndpoint(baseUrl ?? "")) return { "X-Task-ID": currentTaskId() };
458
+ if (!isOpenCodeZenEndpoint(baseUrl ?? "")) return {};
459
+ return { "x-opencode-session": currentSessionId(), "x-opencode-request": currentDeviceId() };
460
+ }
461
+
462
+ // ── Cline (api.cline.bot) ────────────────────────────────────────────────────
463
+
464
+ const CLINE_API_BASE_URL = "https://api.cline.bot";
465
+
466
+ /**
467
+ * Header set the official Cline CLI sends to api.cline.bot (mirrors
468
+ * cline sdk request-headers.ts buildClineRequestHeaders with source "cli").
469
+ * The values track the cline repo versions: CLI 3.0.61, SDK core 0.0.82.
470
+ */
471
+ const CLINE_CLIENT_HEADERS: Record<string, string> = {
472
+ "HTTP-Referer": "https://cline.bot",
473
+ "X-Title": "Cline",
474
+ "X-IS-MULTIROOT": "false",
475
+ "X-CLIENT-TYPE": "cline-cli",
476
+ "User-Agent": "Cline/3.0.61",
477
+ "X-CLIENT-VERSION": "3.0.61",
478
+ "X-PLATFORM": "cli",
479
+ "X-PLATFORM-VERSION": "3.0.61",
480
+ "X-CORE-VERSION": "0.0.82",
481
+ };
482
+
483
+ function clineClientHeaders(): Record<string, string> {
484
+ return { ...CLINE_CLIENT_HEADERS };
485
+ }
486
+
487
+ /** True when a baseUrl points at Cline's API (any path depth, trailing slash ok). */
488
+ export function isClineEndpoint(baseUrl: string | undefined): boolean {
489
+ if (!baseUrl) return false;
490
+ return baseUrl.replace(/\/+$/, "").toLowerCase().startsWith(CLINE_API_BASE_URL);
362
491
  }
package/identity.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Client identity for endpoints that expect an OpenCode-style client.
3
+ *
4
+ * OpenCode Zen keys its per-conversation routing/telemetry off two headers we
5
+ * have to synthesize: `x-opencode-session` (one id per conversation, reused for
6
+ * its whole lifetime) and `x-opencode-request` (one UUID per device, persisted
7
+ * in multikey.json). Both are generated here; persistence stays in config.ts.
8
+ */
9
+
10
+ import { randomBytes, randomUUID } from "node:crypto";
11
+
12
+ /** OpenCode's identifier alphabet: 12 hex chars of time, then 14 base62 chars. */
13
+ const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
14
+ const ID_LENGTH = 26;
15
+ const TIME_HEX_LENGTH = 12;
16
+
17
+ // Monotonic counter within a millisecond, reset when the clock ticks — same
18
+ // rule OpenCode uses so ids sort correctly by creation time.
19
+ let lastTimestamp = 0;
20
+ let counter = 0;
21
+
22
+ /**
23
+ * Reproduce OpenCode's `Identifier.create()`:
24
+ * `timestamp_ms * 0x1000 + counter`, bitwise-NOTed for descending order, as
25
+ * 6 big-endian bytes of hex, followed by 14 random base62 characters.
26
+ *
27
+ * The 48-bit window truncates today's millisecond timestamps, which is exactly
28
+ * what OpenCode ships — the low bits still order ids newest-first.
29
+ */
30
+ export function createIdentifier(descending = true, timestamp = Date.now()): string {
31
+ if (timestamp !== lastTimestamp) {
32
+ lastTimestamp = timestamp;
33
+ counter = 0;
34
+ }
35
+ counter++;
36
+
37
+ const current = BigInt(timestamp) * 0x1000n + BigInt(counter);
38
+ const value = descending ? ~current : current;
39
+ let time = "";
40
+ for (let index = 0; index < 6; index++) {
41
+ const byte = Number((value >> BigInt(40 - 8 * index)) & 0xffn);
42
+ time += byte.toString(16).padStart(2, "0");
43
+ }
44
+
45
+ const bytes = randomBytes(ID_LENGTH - TIME_HEX_LENGTH);
46
+ let rest = "";
47
+ for (const byte of bytes) rest += BASE62[byte % 62];
48
+ return `${time}${rest}`;
49
+ }
50
+
51
+ /** A session id in OpenCode's format: `ses_` + 26 descending-sortable chars. */
52
+ export function createSessionId(): string {
53
+ return `ses_${createIdentifier(true)}`;
54
+ }
55
+
56
+ /** A fresh device identifier (plain v4 UUID, like OpenCode's client). */
57
+ export function createDeviceId(): string {
58
+ return randomUUID();
59
+ }
60
+
61
+ /** A per-conversation task id (plain v4 UUID, like Cline's X-Task-ID). */
62
+ export function createTaskId(): string {
63
+ return randomUUID();
64
+ }
65
+
66
+ let sessionId: string | undefined;
67
+ let deviceId: string | undefined;
68
+ let taskId: string | undefined;
69
+
70
+ /** Current conversation's session id, created on first use. */
71
+ export function currentSessionId(): string {
72
+ if (!sessionId) sessionId = createSessionId();
73
+ return sessionId;
74
+ }
75
+
76
+ /** Drop the cached session id so the next request starts a new conversation. */
77
+ export function resetSessionId(): void {
78
+ sessionId = undefined;
79
+ }
80
+
81
+ /** Seed the device id from config (call once at startup). */
82
+ export function setDeviceId(id: string): void {
83
+ if (id) deviceId = id;
84
+ }
85
+
86
+ /** Current device id, generated on first use if config had none. */
87
+ export function currentDeviceId(): string {
88
+ if (!deviceId) deviceId = createDeviceId();
89
+ return deviceId;
90
+ }
91
+
92
+ /** Current conversation's task id (Cline X-Task-ID), created on first use. */
93
+ export function currentTaskId(): string {
94
+ if (!taskId) taskId = createTaskId();
95
+ return taskId;
96
+ }
97
+
98
+ /** Drop the cached task id so the next request starts a new conversation. */
99
+ export function resetTaskId(): void {
100
+ taskId = undefined;
101
+ }
package/index.ts CHANGED
@@ -11,10 +11,11 @@
11
11
 
12
12
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
13
  import { getApiProvider, type Api } from "@earendil-works/pi-ai";
14
- import { configPath, endpointHeaders, loadConfig, saveConfig, toProviderModels, type KeypoolConfig, type PoolConfig } from "./config.ts";
14
+ import { configPath, endpointHeaders, ensureDeviceId, loadConfig, saveConfig, toProviderModels, type KeypoolConfig, type PoolConfig } from "./config.ts";
15
+ import { resetSessionId, resetTaskId } 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
 
@@ -35,6 +36,8 @@ export default function multikey(pi: ExtensionAPI) {
35
36
 
36
37
  const pools = new Map<string, KeyPool>();
37
38
  for (const pool of config.pools) pools.set(pool.id, new KeyPool(pool));
39
+ // OpenCode Zen needs a stable per-device id; persist one when a Zen pool exists.
40
+ ensureDeviceId(config);
38
41
 
39
42
  let ui: ExtensionContext["ui"] | undefined;
40
43
  const notify = (message: string) => {
@@ -62,6 +65,9 @@ export default function multikey(pi: ExtensionAPI) {
62
65
  const keyPool = pools.get(pool.id) ?? new KeyPool(pool);
63
66
  keyPool.updateConfig(pool);
64
67
  pools.set(pool.id, keyPool);
68
+ // A Zen pool needs a durable device identity; adopt one before the first
69
+ // request goes out (no-op once multikey.json has a deviceId).
70
+ ensureDeviceId(config);
65
71
 
66
72
  pi.registerProvider(pool.id, {
67
73
  name: pool.name ?? pool.id,
@@ -71,7 +77,7 @@ export default function multikey(pi: ExtensionAPI) {
71
77
  api,
72
78
  headers: { ...pool.headers, ...endpointHeaders(pool.baseUrl) },
73
79
  models: toProviderModels(pool),
74
- streamSimple: createRotatingStreamSimple(keyPool, api, notify),
80
+ streamSimple: createRotatingStreamSimple(keyPool, api, notify, () => saveConfig(config)),
75
81
  });
76
82
  return undefined;
77
83
  }
@@ -131,8 +137,15 @@ export default function multikey(pi: ExtensionAPI) {
131
137
  }
132
138
  }
133
139
 
134
- pi.on("session_start", async (_event, ctx) => {
140
+ pi.on("session_start", async (event, ctx) => {
135
141
  ui = ctx.ui;
142
+ // One OpenCode session id / Cline task id per conversation: /new, resume and
143
+ // fork switch to a different conversation, so drop the cached ids for those.
144
+ if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
145
+ resetSessionId();
146
+ resetTaskId();
147
+ }
148
+ ensureDeviceId(config);
136
149
  for (const { id, reason } of skipped) {
137
150
  notify(`multikey: provider "${id}" not available — ${reason}. Fix it via /multikey → Manage pools.`);
138
151
  }
@@ -149,6 +162,22 @@ export default function multikey(pi: ExtensionAPI) {
149
162
  notify(`multikey: no multi-key providers found in models.json — run /multikey → Add pool to create one at ${path}.`);
150
163
  }
151
164
  }
165
+ if (ctx.hasUI) {
166
+ const hooks: ManagerHooks = {
167
+ get config() {
168
+ return config;
169
+ },
170
+ pools,
171
+ saveAndReregister,
172
+ removePool,
173
+ reloadFromDisk,
174
+ saveConfig: () => saveConfig(config),
175
+ notify,
176
+ };
177
+ // Ask (at most once per preset version) whether to align preset-created
178
+ // pools with updated built-in presets. Never blocks or breaks startup.
179
+ await maybeOfferPresetUpdates(hooks, ctx as CommandContext);
180
+ }
152
181
  });
153
182
 
154
183
  const managerHandler = async (_args: unknown, ctx: CommandContext) => {
@@ -164,6 +193,7 @@ export default function multikey(pi: ExtensionAPI) {
164
193
  saveAndReregister,
165
194
  removePool,
166
195
  reloadFromDisk,
196
+ saveConfig: () => saveConfig(config),
167
197
  notify,
168
198
  };
169
199
  await runManager(pi, ctx, hooks);