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/README.md +8 -43
- package/README.zh.md +3 -45
- package/cline-auth.ts +293 -0
- package/config.ts +121 -12
- package/identity.ts +113 -0
- package/index.ts +25 -3
- package/manage.ts +337 -13
- package/package.json +5 -1
- package/pool.ts +49 -6
- package/presets.ts +114 -55
- package/probe.ts +17 -6
- package/stream.ts +105 -6
- package/tui.ts +63 -0
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 (
|
|
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 {
|
|
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 ${
|
|
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(
|
|
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 ${
|
|
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:
|
|
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
|
-
|
|
626
|
-
|
|
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,
|
|
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,
|
|
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.
|
|
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",
|