pi-multikey 1.2.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/LICENSE +21 -0
- package/README.md +163 -0
- package/README.zh.md +146 -0
- package/config.ts +341 -0
- package/index.ts +181 -0
- package/manage.ts +871 -0
- package/package.json +44 -0
- package/pool.ts +180 -0
- package/presets.ts +143 -0
- package/probe.ts +264 -0
- package/stream.ts +237 -0
- package/tui.ts +349 -0
package/manage.ts
ADDED
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /multikey management menus: pools, keys, models, settings — better-custom style.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { getApiProvider, type Api } from "@earendil-works/pi-ai";
|
|
7
|
+
import {
|
|
8
|
+
configPath,
|
|
9
|
+
DEFAULT_CONTEXT_WINDOW,
|
|
10
|
+
DEFAULT_INPUT,
|
|
11
|
+
DEFAULT_MAX_TOKENS,
|
|
12
|
+
KNOWN_API_TYPES,
|
|
13
|
+
maskKey,
|
|
14
|
+
type KeypoolConfig,
|
|
15
|
+
type PoolConfig,
|
|
16
|
+
type PoolKeyConfig,
|
|
17
|
+
type PoolModelConfig,
|
|
18
|
+
} from "./config.ts";
|
|
19
|
+
import type { KeyPool } from "./pool.ts";
|
|
20
|
+
import { PRESETS, findPreset, poolFromPreset, type Preset } from "./presets.ts";
|
|
21
|
+
import { probeEndpoint, type ProbeResult, type RemoteModel } from "./probe.ts";
|
|
22
|
+
import { inputNumber, pickMany, selectOne, showInfo, withProgress } from "./tui.ts";
|
|
23
|
+
|
|
24
|
+
type CommandContext = Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
|
|
25
|
+
|
|
26
|
+
export interface ManagerHooks {
|
|
27
|
+
config: KeypoolConfig;
|
|
28
|
+
pools: Map<string, KeyPool>;
|
|
29
|
+
saveAndReregister(poolId: string): void;
|
|
30
|
+
removePool(poolId: string): void;
|
|
31
|
+
reloadFromDisk(): void;
|
|
32
|
+
notify(message: string): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_MODEL_TEMPLATE: PoolModelConfig = {
|
|
36
|
+
id: "new-model",
|
|
37
|
+
name: "new-model",
|
|
38
|
+
reasoning: true,
|
|
39
|
+
input: DEFAULT_INPUT,
|
|
40
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
41
|
+
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
|
42
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** True when the pool's api names a registered pi-ai streaming implementation. */
|
|
46
|
+
function isKnownApi(api: string | undefined): boolean {
|
|
47
|
+
return getApiProvider((api ?? "openai-completions") as Api) !== undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function firstEnabledKey(pool: PoolConfig): string | undefined {
|
|
51
|
+
return pool.keys.find((k) => k.enabled !== false)?.key;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Convert a probed remote model into a pool model, filling safe defaults. */
|
|
55
|
+
function remoteToPoolModel(remote: RemoteModel): PoolModelConfig {
|
|
56
|
+
return {
|
|
57
|
+
...DEFAULT_MODEL_TEMPLATE,
|
|
58
|
+
id: remote.id,
|
|
59
|
+
name: remote.name ?? remote.id,
|
|
60
|
+
contextWindow: remote.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
61
|
+
maxTokens: remote.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
62
|
+
input: remote.input ?? DEFAULT_INPUT,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function describeRemote(m: RemoteModel): string {
|
|
67
|
+
const bits: string[] = [];
|
|
68
|
+
if (m.contextWindow) bits.push(`ctx ${(m.contextWindow / 1000).toLocaleString()}k`);
|
|
69
|
+
if (m.input?.includes("image")) bits.push("image in");
|
|
70
|
+
if (m.reasoning === true) bits.push("reasoning");
|
|
71
|
+
return bits.join(" · ");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function describeAuth(probe: ProbeResult): string {
|
|
75
|
+
if (probe.authStatus === "confirmed") {
|
|
76
|
+
return probe.auth === "bearer" ? "key verified via Authorization: Bearer ✓" : "key verified via x-api-key ✓";
|
|
77
|
+
}
|
|
78
|
+
if (probe.authStatus === "rejected") return "⚠ endpoint rejected the key (401/403) — double-check it";
|
|
79
|
+
return probe.auth === "bearer"
|
|
80
|
+
? "endpoint did not verify the key (open endpoint) — using default Bearer auth"
|
|
81
|
+
: "using x-api-key auth (could not fully verify)";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function runManager(pi: ExtensionAPI, ctx: CommandContext, hooks: ManagerHooks): Promise<void> {
|
|
85
|
+
for (;;) {
|
|
86
|
+
const pools = hooks.config.pools;
|
|
87
|
+
const action = await selectOne(ctx, "Multikey", [
|
|
88
|
+
{ value: "status", label: "Status", description: "Live per-key state: in-flight, cooldowns, 429 counts" },
|
|
89
|
+
{ value: "manage", label: "Manage pools…", description: "Keys, models, endpoints, cooldowns" },
|
|
90
|
+
{ value: "add", label: "Add pool…", description: "Register another provider (b.ai, nvidia, opencode, …)" },
|
|
91
|
+
{ value: "reload", label: "Reload config from disk", description: "Re-read multikey.json and re-register providers" },
|
|
92
|
+
{ value: "usage", label: "Usage tips" },
|
|
93
|
+
{ value: "exit", label: "Close" },
|
|
94
|
+
]);
|
|
95
|
+
if (action === null || action === "exit") return;
|
|
96
|
+
|
|
97
|
+
if (action === "reload") {
|
|
98
|
+
reloadFromDiskSafe(hooks);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (action === "status") {
|
|
102
|
+
await showInfo(ctx, "Multikey Status", renderStatus(hooks));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (action === "usage") {
|
|
106
|
+
await showInfo(ctx, "Usage tips", [
|
|
107
|
+
"• Concurrency: every in-flight request (main agent or subagents) picks the least-loaded key,",
|
|
108
|
+
" so parallel subagents automatically land on different keys.",
|
|
109
|
+
"• 429: the key gets a cooldown (default 20s, retry-after honored) and the request instantly",
|
|
110
|
+
" retries on the next key — no error reaches the agent unless every key is exhausted.",
|
|
111
|
+
"• Point subagents at e.g. <pool-id>/<model-id> in settings.json agentOverrides (pool id = provider name).",
|
|
112
|
+
`• Config file: ${configPath()}`,
|
|
113
|
+
]);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (action === "add") {
|
|
117
|
+
await addPoolWizard(ctx, hooks);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (action === "manage") {
|
|
121
|
+
const poolId = await selectOne(
|
|
122
|
+
ctx,
|
|
123
|
+
"Select pool",
|
|
124
|
+
pools.map((p) => ({
|
|
125
|
+
value: p.id,
|
|
126
|
+
label: p.id,
|
|
127
|
+
suffix:
|
|
128
|
+
(p.name && p.name !== p.id ? ` — ${p.name}` : "") +
|
|
129
|
+
(!isKnownApi(p.api) ? " ⚠ broken api" : p.keys.length === 0 || p.models.length === 0 ? " (incomplete)" : ""),
|
|
130
|
+
description: `${p.baseUrl} · ${p.keys.length} keys · ${p.models.length} models`,
|
|
131
|
+
})),
|
|
132
|
+
);
|
|
133
|
+
if (poolId) await poolMenu(ctx, hooks, poolId);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function reloadFromDiskSafe(hooks: ManagerHooks): void {
|
|
139
|
+
try {
|
|
140
|
+
hooks.reloadFromDisk();
|
|
141
|
+
hooks.notify("multikey: reloaded config from disk");
|
|
142
|
+
} catch (error) {
|
|
143
|
+
hooks.notify(`multikey: reload failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function renderStatus(hooks: ManagerHooks): string[] {
|
|
148
|
+
const lines: string[] = [];
|
|
149
|
+
for (const pool of hooks.config.pools) {
|
|
150
|
+
const kp = hooks.pools.get(pool.id);
|
|
151
|
+
lines.push(`▶ ${pool.id} — ${pool.name ?? ""} (${pool.baseUrl})`);
|
|
152
|
+
if (!kp) {
|
|
153
|
+
lines.push(" (not registered)");
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const rows = kp.statusRows();
|
|
157
|
+
if (rows.length === 0) lines.push(" (no keys configured)");
|
|
158
|
+
for (const row of rows) {
|
|
159
|
+
const state = !row.enabled
|
|
160
|
+
? "disabled"
|
|
161
|
+
: row.cooldownRemainingMs > 0
|
|
162
|
+
? `cooldown ${Math.ceil(row.cooldownRemainingMs / 1000)}s (${row.cooldownReason ?? "?"})`
|
|
163
|
+
: row.inflight > 0
|
|
164
|
+
? `active ×${row.inflight}`
|
|
165
|
+
: "idle";
|
|
166
|
+
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}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
lines.push("");
|
|
171
|
+
}
|
|
172
|
+
return lines.length > 0 ? lines : ["(no pools configured)"];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Pool menu
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
async function poolMenu(ctx: CommandContext, hooks: ManagerHooks, poolId: string): Promise<void> {
|
|
180
|
+
for (;;) {
|
|
181
|
+
const pool = hooks.config.pools.find((p) => p.id === poolId);
|
|
182
|
+
if (!pool) return;
|
|
183
|
+
const incomplete = pool.keys.length === 0 || pool.models.length === 0;
|
|
184
|
+
const apiBroken = !isKnownApi(pool.api);
|
|
185
|
+
const items: { value: string; label: string; suffix?: string; description?: string }[] = [];
|
|
186
|
+
if (apiBroken) {
|
|
187
|
+
items.push({
|
|
188
|
+
value: "fixapi",
|
|
189
|
+
label: "⚠ Fix API type…",
|
|
190
|
+
description: `"${pool.api}" is not a known pi API — this provider can't register until fixed`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
items.push(
|
|
194
|
+
{ value: "keys", label: "Keys…", description: pool.keys.map((k) => `${k.label ?? maskKey(k.key)}${k.enabled === false ? " (disabled)" : ""}`).join(", ") || "none" },
|
|
195
|
+
{ value: "models", label: "Models…", description: `${pool.models.length} models${pool.models.length === 0 ? " — provider stays hidden until it has models" : ""}` },
|
|
196
|
+
{ value: "settings", label: "Endpoint & settings…", description: `${pool.baseUrl} · api: ${pool.api ?? "openai-completions"}${incomplete ? " · ⚠ incomplete" : ""}` },
|
|
197
|
+
{ value: "delete", label: "Delete pool", description: "Removes the provider from pi and the config file" },
|
|
198
|
+
{ value: "back", label: "Back" },
|
|
199
|
+
);
|
|
200
|
+
const action = await selectOne(ctx, `Pool: ${pool.id}${apiBroken ? " ⚠ broken api" : incomplete ? " (incomplete)" : ""}`, items);
|
|
201
|
+
if (action === null || action === "back") return;
|
|
202
|
+
if (action === "fixapi") {
|
|
203
|
+
const api = await selectOne(ctx, "API type (streaming protocol)", [
|
|
204
|
+
...KNOWN_API_TYPES.map((t) => ({ value: t, label: t })),
|
|
205
|
+
{ value: "__other", label: "Other (type it)…", description: "Free text, for custom-registered pi APIs" },
|
|
206
|
+
]);
|
|
207
|
+
if (api && api !== "__other") {
|
|
208
|
+
pool.api = api;
|
|
209
|
+
hooks.saveAndReregister(pool.id);
|
|
210
|
+
hooks.notify(`multikey[${pool.id}]: api set to ${api}`);
|
|
211
|
+
}
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (action === "keys") await keysMenu(ctx, hooks, pool);
|
|
215
|
+
else if (action === "models") await modelsMenu(ctx, hooks, pool);
|
|
216
|
+
else if (action === "settings") await settingsMenu(ctx, hooks, pool);
|
|
217
|
+
else if (action === "delete") {
|
|
218
|
+
const ok = await ctx.ui.confirm("Delete pool", `Remove provider "${pool.id}" and its ${pool.keys.length} key(s)?`);
|
|
219
|
+
if (ok) {
|
|
220
|
+
hooks.removePool(pool.id);
|
|
221
|
+
hooks.notify(`multikey: removed provider "${pool.id}"`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Keys
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
async function keysMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConfig): Promise<void> {
|
|
233
|
+
for (;;) {
|
|
234
|
+
const kp = hooks.pools.get(pool.id);
|
|
235
|
+
const rows = kp?.statusRows() ?? [];
|
|
236
|
+
const items = pool.keys.map((k, i) => {
|
|
237
|
+
const row = rows[i];
|
|
238
|
+
const state = !k.enabled
|
|
239
|
+
? "disabled"
|
|
240
|
+
: row && row.cooldownRemainingMs > 0
|
|
241
|
+
? `cooldown ${Math.ceil(row.cooldownRemainingMs / 1000)}s`
|
|
242
|
+
: row && row.inflight > 0
|
|
243
|
+
? `active ×${row.inflight}`
|
|
244
|
+
: "idle";
|
|
245
|
+
return {
|
|
246
|
+
value: String(i),
|
|
247
|
+
label: k.label ?? maskKey(k.key),
|
|
248
|
+
suffix: ` ${maskKey(k.key)} ${state}`,
|
|
249
|
+
description: `ok:${row?.ok ?? 0} 429:${row?.rateLimited ?? 0} bad:${row?.invalid ?? 0}`,
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
const action = await selectOne(ctx, `Keys: ${pool.id}`, [...items, { value: "__add", label: "+ Add key…" }, { value: "__back", label: "Back" }]);
|
|
253
|
+
if (action === null || action === "__back") return;
|
|
254
|
+
if (action === "__add") {
|
|
255
|
+
const raw = await ctx.ui.input(`Add API key #${pool.keys.length + 1}`, "paste an API key");
|
|
256
|
+
const value = raw?.trim();
|
|
257
|
+
if (value) {
|
|
258
|
+
if (pool.keys.some((k) => k.key === value)) {
|
|
259
|
+
hooks.notify(`multikey[${pool.id}]: key already in pool`);
|
|
260
|
+
} else {
|
|
261
|
+
pool.keys.push({ key: value, label: `key-${pool.keys.length + 1}`, enabled: true });
|
|
262
|
+
hooks.saveAndReregister(pool.id);
|
|
263
|
+
hooks.notify(`multikey[${pool.id}]: added key ${value.length > 10 ? value.slice(0, 6) + "…" + value.slice(-4) : value}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const index = Number(action);
|
|
269
|
+
const key = pool.keys[index];
|
|
270
|
+
if (!key) continue;
|
|
271
|
+
const keyAction = await selectOne(ctx, `Key: ${key.label ?? maskKey(key.key)}`, [
|
|
272
|
+
{ value: "toggle", label: key.enabled === false ? "Enable" : "Disable" },
|
|
273
|
+
{ value: "label", label: "Edit label…" },
|
|
274
|
+
{ value: "replace", label: "Replace value…" },
|
|
275
|
+
{ value: "remove", label: "Remove key" },
|
|
276
|
+
{ value: "back", label: "Back" },
|
|
277
|
+
]);
|
|
278
|
+
if (keyAction === null || keyAction === "back") continue;
|
|
279
|
+
if (keyAction === "toggle") {
|
|
280
|
+
key.enabled = key.enabled === false;
|
|
281
|
+
hooks.saveAndReregister(pool.id);
|
|
282
|
+
} else if (keyAction === "label") {
|
|
283
|
+
const label = await ctx.ui.input("Key label", key.label ?? "");
|
|
284
|
+
if (label !== undefined && label.trim()) {
|
|
285
|
+
key.label = label.trim();
|
|
286
|
+
hooks.saveAndReregister(pool.id);
|
|
287
|
+
}
|
|
288
|
+
} else if (keyAction === "replace") {
|
|
289
|
+
const value = await ctx.ui.input("Replace API key value", maskKey(key.key));
|
|
290
|
+
if (value !== undefined && value.trim()) {
|
|
291
|
+
key.key = value.trim();
|
|
292
|
+
hooks.saveAndReregister(pool.id);
|
|
293
|
+
}
|
|
294
|
+
} else if (keyAction === "remove") {
|
|
295
|
+
pool.keys.splice(index, 1);
|
|
296
|
+
hooks.saveAndReregister(pool.id);
|
|
297
|
+
hooks.notify(`multikey[${pool.id}]: removed key`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Models
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
async function modelsMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConfig): Promise<void> {
|
|
307
|
+
for (;;) {
|
|
308
|
+
const items = pool.models.map((m) => ({
|
|
309
|
+
value: m.id,
|
|
310
|
+
label: m.id,
|
|
311
|
+
suffix: m.reasoning ? " 🧠" : "",
|
|
312
|
+
description: `ctx: ${m.contextWindow ?? 128000} · max: ${m.maxTokens ?? 16384} · in: ${(m.input ?? ["text"]).join("+")}`,
|
|
313
|
+
}));
|
|
314
|
+
const action = await selectOne(ctx, `Models: ${pool.id}`, [
|
|
315
|
+
...items,
|
|
316
|
+
{ value: "__add", label: "+ Add model…" },
|
|
317
|
+
{ value: "__back", label: "Back" },
|
|
318
|
+
]);
|
|
319
|
+
if (action === null || action === "__back") return;
|
|
320
|
+
if (action === "__add") {
|
|
321
|
+
const how = await selectOne(ctx, "Add model", [
|
|
322
|
+
{ value: "fetch", label: "Fetch from endpoint (/models)…", description: `Pick from models ${pool.baseUrl} offers` },
|
|
323
|
+
{ value: "manual", label: "Enter manually (JSON)…", description: "Type an id, edit the full spec" },
|
|
324
|
+
]);
|
|
325
|
+
if (how === "fetch") {
|
|
326
|
+
const added = await fetchAndPickModels(ctx, hooks, pool);
|
|
327
|
+
if (added && added.length > 0) {
|
|
328
|
+
pool.models.push(...added);
|
|
329
|
+
hooks.saveAndReregister(pool.id);
|
|
330
|
+
hooks.notify(`multikey[${pool.id}]: added ${added.length} model(s): ${added.map((m) => m.id).join(", ")}`);
|
|
331
|
+
}
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (how === null) continue;
|
|
335
|
+
const id = await ctx.ui.input("Model id", "e.g. deepseek-v4-flash");
|
|
336
|
+
if (id === undefined || !id.trim()) continue;
|
|
337
|
+
if (pool.models.some((m) => m.id === id.trim())) {
|
|
338
|
+
hooks.notify(`multikey[${pool.id}]: model "${id}" already exists`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const model = { ...DEFAULT_MODEL_TEMPLATE, id: id.trim(), name: id.trim() };
|
|
342
|
+
const edited = await editModelJson(ctx, model);
|
|
343
|
+
if (edited) {
|
|
344
|
+
pool.models.push(edited);
|
|
345
|
+
hooks.saveAndReregister(pool.id);
|
|
346
|
+
hooks.notify(`multikey[${pool.id}]: added model ${edited.id}`);
|
|
347
|
+
}
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const model = pool.models.find((m) => m.id === action);
|
|
351
|
+
if (model) await modelMenu(ctx, hooks, pool, model);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function modelMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConfig, model: PoolModelConfig): Promise<void> {
|
|
356
|
+
for (;;) {
|
|
357
|
+
const action = await selectOne(ctx, `Model: ${pool.id}/${model.id}`, [
|
|
358
|
+
{ value: "id", label: "Edit id…", description: model.id },
|
|
359
|
+
{ value: "contextWindow", label: "Edit contextWindow…", description: String(model.contextWindow ?? 128_000) },
|
|
360
|
+
{ value: "maxTokens", label: "Edit maxTokens…", description: String(model.maxTokens ?? 16_384) },
|
|
361
|
+
{ value: "reasoning", label: "Toggle reasoning", description: String(model.reasoning ?? true) },
|
|
362
|
+
{ value: "input", label: "Input modalities…", description: (model.input ?? ["text"]).join(", ") },
|
|
363
|
+
{ value: "thinkingLevelMap", label: "Edit thinkingLevelMap (JSON)…", description: summarizeJson(model.thinkingLevelMap) },
|
|
364
|
+
{ value: "compat", label: "Edit compat (JSON)…", description: summarizeJson(model.compat) },
|
|
365
|
+
{ value: "cost", label: "Edit cost (JSON)…", description: summarizeJson(model.cost) },
|
|
366
|
+
{ value: "raw", label: "Edit raw JSON…" },
|
|
367
|
+
{ value: "remove", label: "Remove model" },
|
|
368
|
+
{ value: "back", label: "Back" },
|
|
369
|
+
]);
|
|
370
|
+
if (action === null || action === "back") return;
|
|
371
|
+
|
|
372
|
+
if (action === "id") {
|
|
373
|
+
const id = await ctx.ui.input("Model id", model.id);
|
|
374
|
+
if (id !== undefined && id.trim()) {
|
|
375
|
+
model.id = id.trim();
|
|
376
|
+
hooks.saveAndReregister(pool.id);
|
|
377
|
+
}
|
|
378
|
+
} else if (action === "contextWindow") {
|
|
379
|
+
const value = await inputNumber(ctx, "Context window (tokens)", model.contextWindow ?? 128_000);
|
|
380
|
+
if (value !== undefined) {
|
|
381
|
+
model.contextWindow = value;
|
|
382
|
+
hooks.saveAndReregister(pool.id);
|
|
383
|
+
}
|
|
384
|
+
} else if (action === "maxTokens") {
|
|
385
|
+
const value = await inputNumber(ctx, "Max output tokens", model.maxTokens ?? 16_384);
|
|
386
|
+
if (value !== undefined) {
|
|
387
|
+
model.maxTokens = value;
|
|
388
|
+
hooks.saveAndReregister(pool.id);
|
|
389
|
+
}
|
|
390
|
+
} else if (action === "reasoning") {
|
|
391
|
+
model.reasoning = !(model.reasoning ?? true);
|
|
392
|
+
hooks.saveAndReregister(pool.id);
|
|
393
|
+
} else if (action === "input") {
|
|
394
|
+
const chosen = await pickMany(
|
|
395
|
+
ctx,
|
|
396
|
+
"Input modalities",
|
|
397
|
+
[
|
|
398
|
+
{ value: "text", label: "text" },
|
|
399
|
+
{ value: "image", label: "image" },
|
|
400
|
+
],
|
|
401
|
+
);
|
|
402
|
+
if (chosen && chosen.length > 0) {
|
|
403
|
+
model.input = chosen as ("text" | "image")[];
|
|
404
|
+
hooks.saveAndReregister(pool.id);
|
|
405
|
+
}
|
|
406
|
+
} else if (action === "thinkingLevelMap" || action === "compat" || action === "cost") {
|
|
407
|
+
const field = action as "thinkingLevelMap" | "compat" | "cost";
|
|
408
|
+
const edited = await editJsonField(ctx, field, model[field]);
|
|
409
|
+
if (edited !== undefined) {
|
|
410
|
+
if (edited === null) delete model[field];
|
|
411
|
+
else model[field] = edited as never;
|
|
412
|
+
hooks.saveAndReregister(pool.id);
|
|
413
|
+
}
|
|
414
|
+
} else if (action === "raw") {
|
|
415
|
+
const edited = await editModelJson(ctx, model);
|
|
416
|
+
if (edited) {
|
|
417
|
+
Object.assign(model, edited);
|
|
418
|
+
hooks.saveAndReregister(pool.id);
|
|
419
|
+
}
|
|
420
|
+
} else if (action === "remove") {
|
|
421
|
+
const index = pool.models.indexOf(model);
|
|
422
|
+
if (index >= 0) pool.models.splice(index, 1);
|
|
423
|
+
hooks.saveAndReregister(pool.id);
|
|
424
|
+
hooks.notify(`multikey[${pool.id}]: removed model`);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function summarizeJson(value: unknown): string {
|
|
431
|
+
if (value === undefined) return "(not set)";
|
|
432
|
+
const text = JSON.stringify(value);
|
|
433
|
+
return text.length > 80 ? `${text.slice(0, 77)}…` : text;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function editJsonField(
|
|
437
|
+
ctx: CommandContext,
|
|
438
|
+
name: string,
|
|
439
|
+
current: unknown,
|
|
440
|
+
): Promise<Record<string, unknown> | null | undefined> {
|
|
441
|
+
const prefilled = current === undefined ? "{}" : JSON.stringify(current, null, 2);
|
|
442
|
+
const text = await ctx.ui.editor(`Edit ${name} (JSON)`, prefilled);
|
|
443
|
+
if (text === undefined) return undefined;
|
|
444
|
+
const trimmed = text.trim();
|
|
445
|
+
if (trimmed === "" || trimmed === "{}") return null;
|
|
446
|
+
try {
|
|
447
|
+
return JSON.parse(trimmed) as Record<string, unknown>;
|
|
448
|
+
} catch (error) {
|
|
449
|
+
await ctx.ui.notify(`Invalid JSON: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function editModelJson(ctx: CommandContext, model: PoolModelConfig): Promise<PoolModelConfig | undefined> {
|
|
455
|
+
const text = await ctx.ui.editor("Edit model (JSON)", JSON.stringify(model, null, 2));
|
|
456
|
+
if (text === undefined) return undefined;
|
|
457
|
+
try {
|
|
458
|
+
const parsed = JSON.parse(text) as PoolModelConfig;
|
|
459
|
+
if (!parsed.id || typeof parsed.id !== "string") {
|
|
460
|
+
await ctx.ui.notify("Model JSON must include a string \"id\"", "error");
|
|
461
|
+
return undefined;
|
|
462
|
+
}
|
|
463
|
+
return parsed;
|
|
464
|
+
} catch (error) {
|
|
465
|
+
await ctx.ui.notify(`Invalid JSON: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// Settings / add pool
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
async function settingsMenu(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConfig): Promise<void> {
|
|
475
|
+
for (;;) {
|
|
476
|
+
const action = await selectOne(ctx, `Settings: ${pool.id}`, [
|
|
477
|
+
{ value: "name", label: "Display name…", description: pool.name ?? pool.id },
|
|
478
|
+
{ value: "baseUrl", label: "Base URL…", description: pool.baseUrl },
|
|
479
|
+
{ value: "api", label: "API type…", description: pool.api ?? "openai-completions" },
|
|
480
|
+
{ value: "auth", label: "Auth style…", description: pool.auth === "api-key" ? "x-api-key header" : "Authorization: Bearer (default)" },
|
|
481
|
+
{ value: "cooldownMs", label: "429 cooldown (ms)…", description: String(pool.cooldownMs ?? 20_000) },
|
|
482
|
+
{ value: "invalidKeyCooldownMs", label: "Invalid-key cooldown (ms)…", description: String(pool.invalidKeyCooldownMs ?? 600_000) },
|
|
483
|
+
{ value: "compat", label: "Provider compat (JSON)…", description: summarizeJson(pool.compat) },
|
|
484
|
+
{ value: "headers", label: "Headers (JSON)…", description: summarizeJson(pool.headers) },
|
|
485
|
+
{ value: "back", label: "Back" },
|
|
486
|
+
]);
|
|
487
|
+
if (action === null || action === "back") return;
|
|
488
|
+
|
|
489
|
+
if (action === "name") {
|
|
490
|
+
const name = await ctx.ui.input("Display name", pool.name ?? pool.id);
|
|
491
|
+
if (name !== undefined && name.trim()) {
|
|
492
|
+
pool.name = name.trim();
|
|
493
|
+
hooks.saveAndReregister(pool.id);
|
|
494
|
+
}
|
|
495
|
+
} else if (action === "baseUrl") {
|
|
496
|
+
const url = await ctx.ui.input("Base URL", pool.baseUrl);
|
|
497
|
+
if (url !== undefined && url.trim()) {
|
|
498
|
+
pool.baseUrl = url.trim();
|
|
499
|
+
hooks.saveAndReregister(pool.id);
|
|
500
|
+
}
|
|
501
|
+
} else if (action === "api") {
|
|
502
|
+
const api = await selectOne(ctx, "API type (streaming protocol)", [
|
|
503
|
+
...KNOWN_API_TYPES.map((t) => ({ value: t, label: t })),
|
|
504
|
+
{ value: "__other", label: "Other (type it)…", description: "Free text, for custom-registered pi APIs" },
|
|
505
|
+
]);
|
|
506
|
+
if (api === "__other") {
|
|
507
|
+
const custom = await ctx.ui.input("API type", pool.api ?? "openai-completions");
|
|
508
|
+
if (custom !== undefined && custom.trim()) {
|
|
509
|
+
pool.api = custom.trim();
|
|
510
|
+
hooks.saveAndReregister(pool.id);
|
|
511
|
+
}
|
|
512
|
+
} else if (api !== null) {
|
|
513
|
+
pool.api = api;
|
|
514
|
+
hooks.saveAndReregister(pool.id);
|
|
515
|
+
}
|
|
516
|
+
} else if (action === "auth") {
|
|
517
|
+
const auth = await selectOne(ctx, "Auth style (how the API key is sent)", [
|
|
518
|
+
{ value: "bearer", label: "Authorization: Bearer (default)", description: "Used by most OpenAI-compatible endpoints" },
|
|
519
|
+
{ value: "api-key", label: "x-api-key header", description: "Used by some gateways (Anthropic-style)" },
|
|
520
|
+
]);
|
|
521
|
+
if (auth !== null) {
|
|
522
|
+
pool.auth = auth === "api-key" ? "api-key" : undefined;
|
|
523
|
+
hooks.saveAndReregister(pool.id);
|
|
524
|
+
}
|
|
525
|
+
} else if (action === "cooldownMs") {
|
|
526
|
+
const value = await inputNumber(ctx, "429 cooldown (ms)", pool.cooldownMs ?? 20_000);
|
|
527
|
+
if (value !== undefined) {
|
|
528
|
+
pool.cooldownMs = value;
|
|
529
|
+
hooks.saveAndReregister(pool.id);
|
|
530
|
+
}
|
|
531
|
+
} else if (action === "invalidKeyCooldownMs") {
|
|
532
|
+
const value = await inputNumber(ctx, "Invalid-key cooldown (ms)", pool.invalidKeyCooldownMs ?? 600_000);
|
|
533
|
+
if (value !== undefined) {
|
|
534
|
+
pool.invalidKeyCooldownMs = value;
|
|
535
|
+
hooks.saveAndReregister(pool.id);
|
|
536
|
+
}
|
|
537
|
+
} else if (action === "compat" || action === "headers") {
|
|
538
|
+
const field = action as "compat" | "headers";
|
|
539
|
+
const edited = await editJsonField(ctx, field, pool[field]);
|
|
540
|
+
if (edited !== undefined) {
|
|
541
|
+
if (edited === null) delete pool[field];
|
|
542
|
+
else pool[field] = edited as never;
|
|
543
|
+
hooks.saveAndReregister(pool.id);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function addPoolWizard(ctx: CommandContext, hooks: ManagerHooks): Promise<void> {
|
|
550
|
+
const choice = await selectOne(ctx, "Add provider: choose setup", [
|
|
551
|
+
...PRESETS.map((p) => ({
|
|
552
|
+
value: `preset:${p.id}`,
|
|
553
|
+
label: `Preset: ${p.name}`,
|
|
554
|
+
description: `${p.description}\n${p.baseUrl}`,
|
|
555
|
+
})),
|
|
556
|
+
{ value: "custom", label: "Custom…", description: "Enter endpoint + keys; auth is auto-probed and models fetched" },
|
|
557
|
+
]);
|
|
558
|
+
if (choice === null) return;
|
|
559
|
+
if (choice === "custom") {
|
|
560
|
+
await addCustomPool(ctx, hooks);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const preset = findPreset(choice.slice("preset:".length));
|
|
564
|
+
if (preset) await addPresetPool(ctx, hooks, preset);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Ask for a pool id exactly once. If it collides with an existing pool, offer
|
|
569
|
+
* to open that pool instead of silently re-prompting (the old double-prompt).
|
|
570
|
+
*/
|
|
571
|
+
async function askPoolId(
|
|
572
|
+
ctx: CommandContext,
|
|
573
|
+
hooks: ManagerHooks,
|
|
574
|
+
suggested: string,
|
|
575
|
+
): Promise<{ kind: "new"; id: string } | { kind: "existing"; id: string } | undefined> {
|
|
576
|
+
for (;;) {
|
|
577
|
+
const id = await ctx.ui.input("Provider id in pi", suggested || "e.g. bai, nvidia, opencode");
|
|
578
|
+
if (id === undefined) return undefined;
|
|
579
|
+
const poolId = (id.trim() || suggested).trim();
|
|
580
|
+
if (!poolId) continue;
|
|
581
|
+
if (!hooks.config.pools.some((p) => p.id === poolId)) return { kind: "new", id: poolId };
|
|
582
|
+
|
|
583
|
+
const choice = await selectOne(ctx, `Pool "${poolId}" already exists`, [
|
|
584
|
+
{ value: "manage", label: `Open existing pool "${poolId}"…`, description: "Keys, models, endpoint settings" },
|
|
585
|
+
{ value: "rename", label: "Use a different id…" },
|
|
586
|
+
{ value: "cancel", label: "Cancel" },
|
|
587
|
+
]);
|
|
588
|
+
if (choice === "manage") return { kind: "existing", id: poolId };
|
|
589
|
+
if (choice !== "rename") return undefined;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Collect keys one per line (user's preference). Esc finishes with whatever is
|
|
595
|
+
* collected; empty input on the first prompt cancels the whole wizard.
|
|
596
|
+
*/
|
|
597
|
+
async function askKeys(ctx: CommandContext, hooks: ManagerHooks, hint?: string): Promise<string[] | undefined> {
|
|
598
|
+
const keys: string[] = [];
|
|
599
|
+
const placeholder = hint ?? "paste an API key";
|
|
600
|
+
for (;;) {
|
|
601
|
+
const prompt = keys.length === 0 ? "API key (empty = cancel)" : `API key #${keys.length + 1} (empty = done)`;
|
|
602
|
+
const raw = await ctx.ui.input(prompt, placeholder);
|
|
603
|
+
const value = raw === undefined ? "" : raw.trim();
|
|
604
|
+
if (!value) {
|
|
605
|
+
if (keys.length === 0) return undefined;
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
if (keys.includes(value)) {
|
|
609
|
+
hooks.notify("multikey: duplicate key ignored");
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
keys.push(value);
|
|
613
|
+
}
|
|
614
|
+
return keys;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function addPresetPool(ctx: CommandContext, hooks: ManagerHooks, preset: Preset): Promise<void> {
|
|
618
|
+
const idChoice = await askPoolId(ctx, hooks, preset.defaultPoolId);
|
|
619
|
+
if (idChoice === undefined) return;
|
|
620
|
+
if (idChoice.kind === "existing") {
|
|
621
|
+
await poolMenu(ctx, hooks, idChoice.id);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const poolId = idChoice.id;
|
|
625
|
+
const keys = await askKeys(ctx, hooks, preset.keyHint);
|
|
626
|
+
if (keys === undefined) return;
|
|
627
|
+
|
|
628
|
+
// Light-touch verification: probe the preset endpoint with the first key.
|
|
629
|
+
// The model specs are curated, so this is only a key sanity check.
|
|
630
|
+
let probe: ProbeResult | undefined;
|
|
631
|
+
let authNote = "keys not verified (offline?)";
|
|
632
|
+
try {
|
|
633
|
+
probe = await withProgress(ctx, `Verifying key against ${preset.baseUrl}…`, (update) =>
|
|
634
|
+
probeEndpoint(preset.baseUrl, keys[0]!, {
|
|
635
|
+
chatModelId: preset.models[0]?.id,
|
|
636
|
+
onLog: (line) => update(line.trimEnd()),
|
|
637
|
+
}),
|
|
638
|
+
);
|
|
639
|
+
authNote = describeAuth(probe);
|
|
640
|
+
if (probe.authStatus === "rejected") {
|
|
641
|
+
const proceed = await ctx.ui.confirm(
|
|
642
|
+
"Key rejected",
|
|
643
|
+
`The endpoint answered 401/403 for your key. Save the pool anyway (you can fix keys later)?`,
|
|
644
|
+
);
|
|
645
|
+
if (!proceed) return;
|
|
646
|
+
}
|
|
647
|
+
} catch {
|
|
648
|
+
// Probe must never block pool creation.
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const pool = poolFromPreset(preset, poolId, keys);
|
|
652
|
+
if (probe?.authStatus === "confirmed" && probe.auth === "api-key") pool.auth = "api-key";
|
|
653
|
+
hooks.config.pools.push(pool);
|
|
654
|
+
hooks.saveAndReregister(poolId);
|
|
655
|
+
hooks.notify(
|
|
656
|
+
`multikey: created "${poolId}" from ${preset.name} preset — ${pool.keys.length} key(s), ${pool.models.length} models ready`,
|
|
657
|
+
);
|
|
658
|
+
await showInfo(ctx, `Preset applied: ${preset.name}`, [
|
|
659
|
+
`Provider: ${poolId}/<model-id> (e.g. ${poolId}/${pool.models[0]?.id ?? "..."})`,
|
|
660
|
+
`Endpoint: ${pool.baseUrl}`,
|
|
661
|
+
`Keys: ${pool.keys.length} loaded — ${authNote}`,
|
|
662
|
+
`Models: ${pool.models.map((m) => m.id).join(", ")}`,
|
|
663
|
+
"",
|
|
664
|
+
"Add more keys anytime: /multikey → Manage pools → Keys.",
|
|
665
|
+
"Model specs (thinking tiers, context, modalities) are preconfigured.",
|
|
666
|
+
]);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function addCustomPool(ctx: CommandContext, hooks: ManagerHooks): Promise<void> {
|
|
670
|
+
// 1. Provider id — asked exactly once; collisions offer to open the pool.
|
|
671
|
+
const idChoice = await askPoolId(ctx, hooks, "");
|
|
672
|
+
if (idChoice === undefined) return;
|
|
673
|
+
if (idChoice.kind === "existing") {
|
|
674
|
+
await poolMenu(ctx, hooks, idChoice.id);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const poolId = idChoice.id;
|
|
678
|
+
|
|
679
|
+
// 2. Base URL.
|
|
680
|
+
const baseUrl = await ctx.ui.input("Base URL", "https://api.example.com/v1");
|
|
681
|
+
if (baseUrl === undefined || !baseUrl.trim()) return;
|
|
682
|
+
|
|
683
|
+
// 3. API key(s). No "API type" question: auth is auto-probed next.
|
|
684
|
+
const keys = await askKeys(ctx, hooks);
|
|
685
|
+
if (keys === undefined) return;
|
|
686
|
+
|
|
687
|
+
// 4. Probe: detect Bearer vs x-api-key, verify the key, list /models.
|
|
688
|
+
const probe = await withProgress(ctx, `Probing ${baseUrl.trim()}…`, (update) =>
|
|
689
|
+
probeEndpoint(baseUrl.trim(), keys[0]!, { onLog: (line) => update(line.trimEnd()) }),
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
// 5. Pick models (multi-select straight from the server's list).
|
|
693
|
+
const models = await pickModelsForNewPool(ctx, hooks, poolId, baseUrl.trim(), keys[0]!, probe);
|
|
694
|
+
if (models === undefined) return; // user cancelled — nothing was saved (atomic wizard)
|
|
695
|
+
|
|
696
|
+
// 6. Safe defaults immediately; common params (context, input modes,
|
|
697
|
+
// max tokens) optionally tuned here; everything else stays editable in
|
|
698
|
+
// multikey.json.
|
|
699
|
+
let tuned = false;
|
|
700
|
+
if (models.length > 0) {
|
|
701
|
+
const params = await selectOne(ctx, "Model parameters", [
|
|
702
|
+
{
|
|
703
|
+
value: "defaults",
|
|
704
|
+
label: "Use safe defaults (recommended)",
|
|
705
|
+
description: `${DEFAULT_CONTEXT_WINDOW / 1000}k context · text input · ${DEFAULT_MAX_TOKENS / 1000}k max output — edit later via Models menu or multikey.json`,
|
|
706
|
+
},
|
|
707
|
+
{ value: "tune", label: "Tune common params now…", description: "Context size, input modes, max output — per model" },
|
|
708
|
+
]);
|
|
709
|
+
// null (esc) falls back to safe defaults rather than cancelling the wizard.
|
|
710
|
+
if (params === "tune") {
|
|
711
|
+
await tuneCommonParams(ctx, models);
|
|
712
|
+
tuned = true;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// 7. Save atomically — only now does the pool enter the config.
|
|
717
|
+
const pool: PoolConfig = {
|
|
718
|
+
id: poolId,
|
|
719
|
+
name: poolId,
|
|
720
|
+
baseUrl: baseUrl.trim(),
|
|
721
|
+
api: "openai-completions",
|
|
722
|
+
auth: probe.auth === "api-key" ? "api-key" : undefined,
|
|
723
|
+
cooldownMs: 20_000,
|
|
724
|
+
invalidKeyCooldownMs: 600_000,
|
|
725
|
+
keys: keys.map((key, i) => ({ key, label: `key-${i + 1}`, enabled: true }) satisfies PoolKeyConfig),
|
|
726
|
+
models,
|
|
727
|
+
};
|
|
728
|
+
hooks.config.pools.push(pool);
|
|
729
|
+
hooks.saveAndReregister(poolId);
|
|
730
|
+
|
|
731
|
+
const modelSummary = models.length > 0 ? models.map((m) => m.id).join(", ") : "(none yet — add via Models menu)";
|
|
732
|
+
await showInfo(ctx, `Pool created: ${poolId}`, [
|
|
733
|
+
`Endpoint: ${pool.baseUrl}`,
|
|
734
|
+
`Auth: ${describeAuth(probe)}`,
|
|
735
|
+
`Keys: ${pool.keys.length} loaded`,
|
|
736
|
+
`Models: ${modelSummary}${tuned ? " (tuned)" : models.length > 0 ? " (safe defaults)" : ""}`,
|
|
737
|
+
"",
|
|
738
|
+
"Use it as: /model → " + poolId + "/<model-id>",
|
|
739
|
+
`Advanced params (thinking maps, compat, cost): edit ${configPath()},`,
|
|
740
|
+
"then /multikey → Reload config from disk.",
|
|
741
|
+
]);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Model selection step of the add wizard. Returns pool models, or undefined
|
|
746
|
+
* when the user cancelled (so the wizard stays atomic — nothing saved). An
|
|
747
|
+
* empty array means "save the pool without models" (explicit choice).
|
|
748
|
+
*/
|
|
749
|
+
async function pickModelsForNewPool(
|
|
750
|
+
ctx: CommandContext,
|
|
751
|
+
hooks: ManagerHooks,
|
|
752
|
+
poolId: string,
|
|
753
|
+
baseUrl: string,
|
|
754
|
+
key: string,
|
|
755
|
+
probe: ProbeResult,
|
|
756
|
+
): Promise<PoolModelConfig[] | undefined> {
|
|
757
|
+
for (;;) {
|
|
758
|
+
let choice: string | null;
|
|
759
|
+
if (probe.ok && probe.models && probe.models.length > 0) {
|
|
760
|
+
// Straight to multi-select: every offered model preselected.
|
|
761
|
+
const selectedIds = await pickMany(
|
|
762
|
+
ctx,
|
|
763
|
+
`Add models from ${probe.modelsUrl} — space to toggle, enter to confirm`,
|
|
764
|
+
probe.models.map((m) => ({ value: m.id, label: m.id, description: describeRemote(m) })),
|
|
765
|
+
{ preselected: probe.models.map((m) => m.id) },
|
|
766
|
+
);
|
|
767
|
+
if (selectedIds === null) return undefined;
|
|
768
|
+
const byId = new Map(probe.models.map((m) => [m.id, m]));
|
|
769
|
+
return selectedIds.map((id) => remoteToPoolModel(byId.get(id)!));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// No model list available — offer paths instead of a dead end.
|
|
773
|
+
choice = await selectOne(ctx, "Could not list models from the endpoint", [
|
|
774
|
+
{ value: "manual", label: "Enter model ids manually…", description: "Type ids, edit specs as JSON" },
|
|
775
|
+
{ value: "retry", label: "Retry probe" },
|
|
776
|
+
{ value: "empty", label: `Create "${poolId}" without models`, description: "Add models later via /multikey → Models" },
|
|
777
|
+
{ value: "cancel", label: "Cancel (nothing saved)" },
|
|
778
|
+
]);
|
|
779
|
+
if (choice === null || choice === "cancel") return undefined;
|
|
780
|
+
if (choice === "retry") {
|
|
781
|
+
const retried = await withProgress(ctx, "Probing again…", (update) =>
|
|
782
|
+
probeEndpoint(baseUrl, key, { onLog: (line) => update(line.trimEnd()) }),
|
|
783
|
+
);
|
|
784
|
+
// Merge results, keeping the original probe's key/auth findings.
|
|
785
|
+
if (retried.ok) {
|
|
786
|
+
probe.ok = true;
|
|
787
|
+
probe.models = retried.models;
|
|
788
|
+
probe.modelsUrl = retried.modelsUrl;
|
|
789
|
+
}
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (choice === "empty") return [];
|
|
793
|
+
if (choice === "manual") {
|
|
794
|
+
const manual = await collectManualModels(ctx, hooks);
|
|
795
|
+
if (manual === undefined) continue; // back to this menu
|
|
796
|
+
return manual;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Loop of manual model entries (id prompt + JSON editor). undefined = user backed out. */
|
|
802
|
+
async function collectManualModels(ctx: CommandContext, hooks: ManagerHooks): Promise<PoolModelConfig[] | undefined> {
|
|
803
|
+
const models: PoolModelConfig[] = [];
|
|
804
|
+
for (;;) {
|
|
805
|
+
const id = await ctx.ui.input(`Model id #${models.length + 1}`, "e.g. deepseek-v4-flash (empty = done)");
|
|
806
|
+
if (id === undefined) return undefined;
|
|
807
|
+
if (!id.trim()) return models;
|
|
808
|
+
const model = { ...DEFAULT_MODEL_TEMPLATE, id: id.trim(), name: id.trim() };
|
|
809
|
+
const edited = await editModelJson(ctx, model);
|
|
810
|
+
if (edited) models.push(edited);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Per-model quick tune of the common params: context size, input modes, max output. */
|
|
815
|
+
async function tuneCommonParams(ctx: CommandContext, models: PoolModelConfig[]): Promise<void> {
|
|
816
|
+
for (let i = 0; i < models.length; i++) {
|
|
817
|
+
const model = models[i]!;
|
|
818
|
+
const tag = `[${i + 1}/${models.length}] ${model.id}`;
|
|
819
|
+
const contextWindow = await inputNumber(ctx, `${tag} — context window (tokens)`, model.contextWindow ?? DEFAULT_CONTEXT_WINDOW);
|
|
820
|
+
if (contextWindow !== undefined) model.contextWindow = contextWindow;
|
|
821
|
+
const input = await pickMany(
|
|
822
|
+
ctx,
|
|
823
|
+
`${tag} — input modalities`,
|
|
824
|
+
[
|
|
825
|
+
{ value: "text", label: "text" },
|
|
826
|
+
{ value: "image", label: "image" },
|
|
827
|
+
],
|
|
828
|
+
{ preselected: model.input ?? DEFAULT_INPUT },
|
|
829
|
+
);
|
|
830
|
+
if (input && input.length > 0) model.input = input as ("text" | "image")[];
|
|
831
|
+
const maxTokens = await inputNumber(ctx, `${tag} — max output tokens`, model.maxTokens ?? DEFAULT_MAX_TOKENS);
|
|
832
|
+
if (maxTokens !== undefined) model.maxTokens = maxTokens;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Fetch the live /models list for an existing pool and let the user pick new
|
|
838
|
+
* models to add (duplicates filtered). Returns added models, or undefined on cancel.
|
|
839
|
+
*/
|
|
840
|
+
async function fetchAndPickModels(ctx: CommandContext, hooks: ManagerHooks, pool: PoolConfig): Promise<PoolModelConfig[] | undefined> {
|
|
841
|
+
const key = firstEnabledKey(pool);
|
|
842
|
+
if (!key) {
|
|
843
|
+
hooks.notify(`multikey[${pool.id}]: add a key first — the probe needs one`);
|
|
844
|
+
return undefined;
|
|
845
|
+
}
|
|
846
|
+
const probe = await withProgress(ctx, `Fetching models from ${pool.baseUrl}…`, (update) =>
|
|
847
|
+
probeEndpoint(pool.baseUrl, key, {
|
|
848
|
+
authHint: pool.auth,
|
|
849
|
+
chatModelId: pool.models[0]?.id,
|
|
850
|
+
onLog: (line) => update(line.trimEnd()),
|
|
851
|
+
}),
|
|
852
|
+
);
|
|
853
|
+
if (!probe.ok || !probe.models || probe.models.length === 0) {
|
|
854
|
+
await showInfo(ctx, "No models found", probe.log.length > 0 ? probe.log : ["The endpoint did not return a usable model list."]);
|
|
855
|
+
return undefined;
|
|
856
|
+
}
|
|
857
|
+
const existing = new Set(pool.models.map((m) => m.id));
|
|
858
|
+
const fresh = probe.models.filter((m) => !existing.has(m.id));
|
|
859
|
+
if (fresh.length === 0) {
|
|
860
|
+
hooks.notify(`multikey[${pool.id}]: all ${probe.models.length} listed models are already added`);
|
|
861
|
+
return undefined;
|
|
862
|
+
}
|
|
863
|
+
const selectedIds = await pickMany(
|
|
864
|
+
ctx,
|
|
865
|
+
`Add models from ${probe.modelsUrl} — ${fresh.length} new (${probe.models.length - fresh.length} already added)`,
|
|
866
|
+
fresh.map((m) => ({ value: m.id, label: m.id, description: describeRemote(m) })),
|
|
867
|
+
);
|
|
868
|
+
if (selectedIds === null) return undefined;
|
|
869
|
+
const byId = new Map(fresh.map((m) => [m.id, m]));
|
|
870
|
+
return selectedIds.map((id) => remoteToPoolModel(byId.get(id)!));
|
|
871
|
+
}
|