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/README.md +68 -14
- 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/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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
360
|
-
if (
|
|
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 (
|
|
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);
|