pleumrouter 0.5.30 → 0.5.32
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/bin/pleum.mjs +10 -3
- package/package.json +1 -1
- package/src/ansi.mjs +4 -0
- package/src/completion.mjs +1 -1
- package/src/config.mjs +13 -0
- package/src/interactive.mjs +35 -9
- package/src/launch.mjs +24 -5
- package/src/locales/es.mjs +54 -2
- package/src/locales/ja.mjs +54 -2
- package/src/locales/ko.mjs +54 -2
- package/src/locales/zh.mjs +54 -2
- package/src/menu.mjs +315 -35
- package/src/models.mjs +191 -5
- package/src/recipes.mjs +96 -65
- package/src/settings.mjs +34 -8
- package/src/update.mjs +157 -0
- package/src/upgrade.mjs +8 -14
package/src/menu.mjs
CHANGED
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
import { readFileSync } from "node:fs";
|
|
12
12
|
import { spawn } from "node:child_process";
|
|
13
13
|
import { createInterface } from "node:readline";
|
|
14
|
-
import { style, displayWidth, terminalWidth, hyperlink, sanitizeTerminalText } from "./ansi.mjs";
|
|
14
|
+
import { style, displayWidth, terminalWidth, terminalHeight, hyperlink, sanitizeTerminalText } from "./ansi.mjs";
|
|
15
15
|
import { keyFeeder, select } from "./interactive.mjs";
|
|
16
|
-
import { readConfig, writeConfig, isLoggedIn, touchSession, getActiveContext } from "./config.mjs";
|
|
16
|
+
import { readConfig, writeConfig, isLoggedIn, touchSession, getActiveContext, isAutoUpdateEnabled } from "./config.mjs";
|
|
17
17
|
import { RECIPES, MANUAL, DEFAULT_MODEL } from "./recipes.mjs";
|
|
18
18
|
import { onPath, launch } from "./launch.mjs";
|
|
19
|
-
import { fetchModels, matchModels, listModels, formatModelLabel } from "./models.mjs";
|
|
19
|
+
import { fetchModels, matchModels, listModels, formatModelLabel, fetchCoreCodingModels, CORE_MODEL_PROVIDERS } from "./models.mjs";
|
|
20
20
|
import { run } from "./run.mjs";
|
|
21
21
|
import { login } from "./login.mjs";
|
|
22
22
|
import { top } from "./top.mjs";
|
|
@@ -24,6 +24,7 @@ import { usage } from "./usage.mjs";
|
|
|
24
24
|
import { balance } from "./balance.mjs";
|
|
25
25
|
import { health } from "./health.mjs";
|
|
26
26
|
import { runSettings } from "./settings.mjs";
|
|
27
|
+
import { checkLatestVersion, performUpdate } from "./update.mjs";
|
|
27
28
|
import { t } from "./i18n.mjs";
|
|
28
29
|
|
|
29
30
|
// 값은 t() 키(영어 원문)만 담는다 — 여기서 t()를 호출하면 모듈 로드 시점(import 1회)에
|
|
@@ -123,24 +124,195 @@ function saveLastSelection(id) {
|
|
|
123
124
|
writeConfig(cfg);
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
export async function pickProviderForModel(modelId, { input = process.stdin, output = process.stdout } = {}) {
|
|
128
|
+
if (!modelId || modelId === "__view_all__") return modelId;
|
|
129
|
+
const providers = CORE_MODEL_PROVIDERS[modelId] || [];
|
|
130
|
+
if (!providers.length) return modelId;
|
|
131
|
+
|
|
132
|
+
const options = [
|
|
133
|
+
{
|
|
134
|
+
id: null,
|
|
135
|
+
title: `⚡ ${t("Auto (best route)")}`,
|
|
136
|
+
desc: t("PleumRouter auto-routing — lowest price, lowest latency, automatic failover"),
|
|
137
|
+
},
|
|
138
|
+
...providers.map((p) => ({
|
|
139
|
+
id: p.id,
|
|
140
|
+
title: p.name,
|
|
141
|
+
desc: t("Pin to {provider} (@{provider})", { provider: p.id }),
|
|
142
|
+
})),
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
const chosen = await select(options, {
|
|
146
|
+
message: `${modelId} — ${t("Select routing provider")}`,
|
|
147
|
+
formatItem: (x) => `${x.title} ${style.dim(x.desc || "")}`,
|
|
148
|
+
limit: 8,
|
|
149
|
+
input,
|
|
150
|
+
output,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (!chosen) return null;
|
|
154
|
+
if (!chosen.id) return modelId;
|
|
155
|
+
return `${modelId}@${chosen.id}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
126
158
|
export async function pickModelFor(item, defaultModel) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
159
|
+
for (;;) {
|
|
160
|
+
const core = fetchCoreCodingModels();
|
|
161
|
+
const items = [
|
|
162
|
+
...core,
|
|
163
|
+
{
|
|
164
|
+
id: "__view_all__",
|
|
165
|
+
display_name: t("... view all 200+ models"),
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
const targetId = defaultModel || DEFAULT_MODEL;
|
|
169
|
+
const initialIndex = items.findIndex((x) => x.id === targetId || targetId.startsWith(`${x.id}@`));
|
|
170
|
+
const m = await select(items, {
|
|
171
|
+
message: `${item.title} — ${t("select a model")}`,
|
|
172
|
+
formatItem: (x) => (x.id === "__view_all__" ? style.dim(x.display_name) : formatModelLabel(x)),
|
|
173
|
+
matches: matchModels,
|
|
174
|
+
limit: 11,
|
|
175
|
+
initialCursor: initialIndex >= 0 ? initialIndex : 0,
|
|
176
|
+
});
|
|
177
|
+
if (!m) return undefined;
|
|
178
|
+
|
|
179
|
+
let pickedModelId = m.id;
|
|
180
|
+
if (pickedModelId === "__view_all__") {
|
|
181
|
+
let allModels;
|
|
182
|
+
try {
|
|
183
|
+
allModels = await fetchModels();
|
|
184
|
+
} catch (e) {
|
|
185
|
+
console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
const allPicked = await select(allModels, {
|
|
189
|
+
message: `${item.title} — ${t("select a model")}`,
|
|
190
|
+
formatItem: (x) => formatModelLabel(x),
|
|
191
|
+
matches: matchModels,
|
|
192
|
+
limit: 10,
|
|
193
|
+
});
|
|
194
|
+
if (!allPicked) continue;
|
|
195
|
+
pickedModelId = allPicked.id;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const withProvider = await pickProviderForModel(pickedModelId);
|
|
199
|
+
if (!withProvider) continue;
|
|
200
|
+
return withProvider;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function pickClaudeCodeTiers(agentItem) {
|
|
205
|
+
const cfg = readConfig() ?? {};
|
|
206
|
+
const savedTiers = cfg.claude_tiers;
|
|
207
|
+
const defaultTiers = {
|
|
208
|
+
sonnet: "claude-sonnet-5",
|
|
209
|
+
opus: "claude-opus-5-5",
|
|
210
|
+
haiku: DEFAULT_MODEL,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const options = [
|
|
214
|
+
{
|
|
215
|
+
id: "preset:recommended",
|
|
216
|
+
title: `⚡ ${t("Recommended Preset")}`,
|
|
217
|
+
desc: `Main: ${defaultTiers.sonnet} · Thinker: ${defaultTiers.opus} · Subagent: ${defaultTiers.haiku}`,
|
|
218
|
+
tiers: defaultTiers,
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
if (
|
|
223
|
+
savedTiers &&
|
|
224
|
+
(savedTiers.sonnet !== defaultTiers.sonnet ||
|
|
225
|
+
savedTiers.opus !== defaultTiers.opus ||
|
|
226
|
+
savedTiers.haiku !== defaultTiers.haiku)
|
|
227
|
+
) {
|
|
228
|
+
options.push({
|
|
229
|
+
id: "preset:previous",
|
|
230
|
+
title: `↻ ${t("Use Previous Configuration")}`,
|
|
231
|
+
desc: `Main: ${savedTiers.sonnet} · Thinker: ${savedTiers.opus} · Subagent: ${savedTiers.haiku}`,
|
|
232
|
+
tiers: savedTiers,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
options.push({
|
|
237
|
+
id: "preset:custom",
|
|
238
|
+
title: `⚙️ ${t("Configure Tiers Manually")}`,
|
|
239
|
+
desc: t("Choose specific models for Sonnet (main), Opus (thinker), and Haiku (subagent)"),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const picked = await select(options, {
|
|
243
|
+
message: `${agentItem.title} — ${t("Select model tier mapping")}`,
|
|
244
|
+
formatItem: (x) => `${x.title}\n ${style.dim(x.desc)}`,
|
|
245
|
+
limit: 5,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (!picked) return null;
|
|
249
|
+
|
|
250
|
+
if (picked.tiers) {
|
|
251
|
+
cfg.claude_tiers = picked.tiers;
|
|
252
|
+
writeConfig(cfg);
|
|
253
|
+
return picked.tiers;
|
|
133
254
|
}
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
255
|
+
|
|
256
|
+
const baseTiers = savedTiers || defaultTiers;
|
|
257
|
+
|
|
258
|
+
// Step 1: Sonnet (Main)
|
|
259
|
+
const sonnet = await pickModelFor(
|
|
260
|
+
{ title: `${agentItem.title} — ${t("Main model (Sonnet tier)")}` },
|
|
261
|
+
baseTiers.sonnet,
|
|
262
|
+
);
|
|
263
|
+
if (!sonnet) return null;
|
|
264
|
+
|
|
265
|
+
// Step 2: Opus (Thinker)
|
|
266
|
+
const opus = await pickModelFor(
|
|
267
|
+
{ title: `${agentItem.title} — ${t("Thinker model (Opus tier)")}` },
|
|
268
|
+
baseTiers.opus,
|
|
269
|
+
);
|
|
270
|
+
if (!opus) return null;
|
|
271
|
+
|
|
272
|
+
// Step 3: Haiku (Subagent)
|
|
273
|
+
const haiku = await pickModelFor(
|
|
274
|
+
{ title: `${agentItem.title} — ${t("Subagent model (Haiku tier)")}` },
|
|
275
|
+
baseTiers.haiku,
|
|
276
|
+
);
|
|
277
|
+
if (!haiku) return null;
|
|
278
|
+
|
|
279
|
+
const resultTiers = { sonnet, opus, haiku };
|
|
280
|
+
cfg.claude_tiers = resultTiers;
|
|
281
|
+
writeConfig(cfg);
|
|
282
|
+
return resultTiers;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function pickCodexEffort(defaultEffort = "medium") {
|
|
286
|
+
const options = [
|
|
287
|
+
{ id: "medium", title: `⚡ ${t("Medium (Recommended)")}`, desc: t("Balanced reasoning speed and depth") },
|
|
288
|
+
{ id: "high", title: `🔥 ${t("High (Deep reasoning)")}`, desc: t("Maximum reasoning depth for complex tasks") },
|
|
289
|
+
{ id: "low", title: `⚡ ${t("Low (Fast response)")}`, desc: t("Lightweight reasoning for quick answers") },
|
|
290
|
+
];
|
|
291
|
+
const initialIndex = options.findIndex((x) => x.id === defaultEffort);
|
|
292
|
+
const picked = await select(options, {
|
|
293
|
+
message: `Codex — ${t("Select reasoning effort")}`,
|
|
294
|
+
formatItem: (x) => `${x.title} ${style.dim(x.desc)}`,
|
|
295
|
+
limit: 5,
|
|
296
|
+
initialCursor: initialIndex >= 0 ? initialIndex : 0,
|
|
297
|
+
});
|
|
298
|
+
return picked ? picked.id : null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function pickAgyEffort(defaultEffort = "high") {
|
|
302
|
+
const options = [
|
|
303
|
+
{ id: "high", title: `⚡ ${t("High (Recommended)")}`, desc: t("High reasoning effort for standard tasks") },
|
|
304
|
+
{ id: "max", title: `🚀 ${t("Max (Maximum reasoning)")}`, desc: t("Full reasoning effort for difficult problems") },
|
|
305
|
+
{ id: "medium", title: `⚖️ ${t("Medium (Standard)")}`, desc: t("Balanced reasoning effort") },
|
|
306
|
+
{ id: "low", title: `⚡ ${t("Low (Fast response)")}`, desc: t("Low reasoning effort for rapid iteration") },
|
|
307
|
+
];
|
|
308
|
+
const initialIndex = options.findIndex((x) => x.id === defaultEffort);
|
|
309
|
+
const picked = await select(options, {
|
|
310
|
+
message: `Antigravity CLI (agy) — ${t("Select reasoning effort")}`,
|
|
311
|
+
formatItem: (x) => `${x.title} ${style.dim(x.desc)}`,
|
|
312
|
+
limit: 6,
|
|
141
313
|
initialCursor: initialIndex >= 0 ? initialIndex : 0,
|
|
142
314
|
});
|
|
143
|
-
return
|
|
315
|
+
return picked ? picked.id : null;
|
|
144
316
|
}
|
|
145
317
|
|
|
146
318
|
// 미설치 도구: ollama처럼 확인 후 그 자리에서 설치(stdio 상속). 성공 시 true.
|
|
@@ -184,6 +356,7 @@ export async function runList(items, { header, footer, initialCursor = 0 } = {})
|
|
|
184
356
|
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return undefined;
|
|
185
357
|
|
|
186
358
|
let cursor = Math.max(0, Math.min(initialCursor, items.length - 1));
|
|
359
|
+
let windowStart = 0;
|
|
187
360
|
let lastRows = 0;
|
|
188
361
|
|
|
189
362
|
const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
|
|
@@ -192,28 +365,68 @@ export async function runList(items, { header, footer, initialCursor = 0 } = {})
|
|
|
192
365
|
output.write("\r" + (lastRows > 1 ? `\x1b[${lastRows - 1}A` : "") + "\x1b[0J");
|
|
193
366
|
lastRows = 0;
|
|
194
367
|
};
|
|
368
|
+
|
|
369
|
+
const getMaxVisible = () => {
|
|
370
|
+
const h = terminalHeight();
|
|
371
|
+
return Math.max(3, Math.min(9, Math.floor((h - 6) / 2)));
|
|
372
|
+
};
|
|
373
|
+
|
|
195
374
|
const render = () => {
|
|
375
|
+
const maxVisible = getMaxVisible();
|
|
376
|
+
if (cursor < windowStart) {
|
|
377
|
+
windowStart = cursor;
|
|
378
|
+
} else if (cursor >= windowStart + maxVisible) {
|
|
379
|
+
windowStart = cursor - maxVisible + 1;
|
|
380
|
+
}
|
|
381
|
+
windowStart = Math.max(0, Math.min(windowStart, Math.max(0, items.length - maxVisible)));
|
|
382
|
+
|
|
383
|
+
const visibleItems = items.slice(windowStart, windowStart + maxVisible);
|
|
196
384
|
const lines = [
|
|
197
385
|
header,
|
|
198
386
|
"",
|
|
199
|
-
...items.flatMap((it, i) =>
|
|
200
|
-
i === cursor
|
|
201
|
-
? [`${style.plum("▸")} ${style.bold(it.title)}`, ` ${style.dim(it.desc)}`]
|
|
202
|
-
: [` ${it.title}`, ` ${style.dim(it.desc)}`],
|
|
203
|
-
),
|
|
204
|
-
"",
|
|
205
|
-
style.dim(footer),
|
|
206
387
|
];
|
|
388
|
+
|
|
389
|
+
if (windowStart > 0) {
|
|
390
|
+
lines.push(style.dim(` ${t("↑ {n} more above", { n: windowStart })}`));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
for (let idx = 0; idx < visibleItems.length; idx++) {
|
|
394
|
+
const i = windowStart + idx;
|
|
395
|
+
const it = visibleItems[idx];
|
|
396
|
+
if (i === cursor) {
|
|
397
|
+
lines.push(`${style.plum("▸")} ${style.bold(it.title)}`);
|
|
398
|
+
lines.push(` ${style.dim(it.desc)}`);
|
|
399
|
+
} else {
|
|
400
|
+
lines.push(` ${it.title}`);
|
|
401
|
+
lines.push(` ${style.dim(it.desc)}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const remainingBelow = items.length - (windowStart + visibleItems.length);
|
|
406
|
+
if (remainingBelow > 0) {
|
|
407
|
+
lines.push(style.dim(` ${t("↓ {n} more below", { n: remainingBelow })}`));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
lines.push("");
|
|
411
|
+
lines.push(style.dim(footer));
|
|
412
|
+
|
|
207
413
|
erase();
|
|
208
414
|
output.write(lines.join("\n"));
|
|
209
415
|
lastRows = lines.reduce((n, l) => n + rowsFor(l), 0);
|
|
210
416
|
};
|
|
417
|
+
|
|
211
418
|
const move = (dir) => {
|
|
212
419
|
cursor = Math.max(0, Math.min(items.length - 1, cursor + dir));
|
|
213
420
|
};
|
|
214
421
|
|
|
215
422
|
return new Promise((resolve) => {
|
|
423
|
+
const onResize = () => {
|
|
424
|
+
render();
|
|
425
|
+
};
|
|
426
|
+
output.on("resize", onResize);
|
|
427
|
+
|
|
216
428
|
const suspend = () => {
|
|
429
|
+
output.off("resize", onResize);
|
|
217
430
|
input.off("data", onData);
|
|
218
431
|
input.setRawMode(false);
|
|
219
432
|
output.write("\x1b[?25h");
|
|
@@ -223,10 +436,12 @@ export async function runList(items, { header, footer, initialCursor = 0 } = {})
|
|
|
223
436
|
input.setRawMode(true);
|
|
224
437
|
input.resume();
|
|
225
438
|
output.write("\x1b[?25l");
|
|
439
|
+
output.on("resize", onResize);
|
|
226
440
|
input.on("data", onData);
|
|
227
441
|
render();
|
|
228
442
|
};
|
|
229
443
|
const finish = (value) => {
|
|
444
|
+
output.off("resize", onResize);
|
|
230
445
|
suspend();
|
|
231
446
|
input.pause();
|
|
232
447
|
resolve(value);
|
|
@@ -275,7 +490,7 @@ export async function runList(items, { header, footer, initialCursor = 0 } = {})
|
|
|
275
490
|
});
|
|
276
491
|
}
|
|
277
492
|
|
|
278
|
-
async function menuSelect() {
|
|
493
|
+
async function menuSelect(updateInfo = null) {
|
|
279
494
|
const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
280
495
|
const cfg = readConfig();
|
|
281
496
|
const items = buildItems({
|
|
@@ -284,8 +499,19 @@ async function menuSelect() {
|
|
|
284
499
|
});
|
|
285
500
|
const last = cfg?.last_selection;
|
|
286
501
|
const initialCursor = last ? Math.max(0, items.findIndex((it) => it.id === last)) : 0;
|
|
502
|
+
const headerLines = [`${style.bold(style.plum("PleumRouter"))} ${style.dim(`pleum ${version}`)}`];
|
|
503
|
+
if (updateInfo?.isOutdated) {
|
|
504
|
+
headerLines.push(
|
|
505
|
+
style.yellow(
|
|
506
|
+
`💡 ${t("Update available: v{current} → v{latest}. Run 'pleum update' to update.", {
|
|
507
|
+
current: updateInfo.currentVersion,
|
|
508
|
+
latest: updateInfo.latestVersion,
|
|
509
|
+
})}`,
|
|
510
|
+
),
|
|
511
|
+
);
|
|
512
|
+
}
|
|
287
513
|
return runList(items, {
|
|
288
|
-
header:
|
|
514
|
+
header: headerLines.join("\n"),
|
|
289
515
|
footer: t("↑/↓ move • enter run • → set model • esc quit"),
|
|
290
516
|
initialCursor,
|
|
291
517
|
});
|
|
@@ -303,7 +529,7 @@ async function agentSelect() {
|
|
|
303
529
|
}
|
|
304
530
|
|
|
305
531
|
// 에이전트 실행 메뉴 — bare pleum의 "에이전트 실행"과 pleum launch (인자 없음)가 공유.
|
|
306
|
-
// 에이전트를 고르면 곧바로
|
|
532
|
+
// 에이전트를 고르면 곧바로 모델/티어/추론 피커를 띄워 기본 모델 또는
|
|
307
533
|
// 선택한 우리 모델로 코딩 에이전트를 실행한다.
|
|
308
534
|
export async function runAgentMenu({ loop = false } = {}) {
|
|
309
535
|
const io = process.stdin;
|
|
@@ -330,15 +556,53 @@ export async function runAgentMenu({ loop = false } = {}) {
|
|
|
330
556
|
}
|
|
331
557
|
|
|
332
558
|
const agentSaved = readConfig()?.launch_models ?? {};
|
|
333
|
-
let
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
if (!
|
|
559
|
+
let launchModelPayload = null;
|
|
560
|
+
|
|
561
|
+
if (agentItem.tool === "claude") {
|
|
562
|
+
const tiers = await pickClaudeCodeTiers(agentItem);
|
|
563
|
+
if (!tiers) continue;
|
|
564
|
+
launchModelPayload = tiers;
|
|
565
|
+
saveModel("claude", tiers.sonnet);
|
|
566
|
+
} else if (agentItem.tool === "codex") {
|
|
567
|
+
let chosenModel = agentPicked.model;
|
|
568
|
+
if (!chosenModel) {
|
|
569
|
+
const defaultForTool = agentSaved[agentItem.tool] || DEFAULT_MODEL;
|
|
570
|
+
chosenModel = await pickModelFor(agentItem, defaultForTool);
|
|
571
|
+
if (!chosenModel) continue;
|
|
572
|
+
}
|
|
573
|
+
saveModel("codex", chosenModel);
|
|
574
|
+
const cfg = readConfig() ?? {};
|
|
575
|
+
const effort = await pickCodexEffort(cfg.codex_effort || "medium");
|
|
576
|
+
if (!effort) continue;
|
|
577
|
+
cfg.codex_effort = effort;
|
|
578
|
+
writeConfig(cfg);
|
|
579
|
+
launchModelPayload = { model: chosenModel, effort };
|
|
580
|
+
} else if (agentItem.tool === "agy") {
|
|
581
|
+
let chosenModel = agentPicked.model;
|
|
582
|
+
if (!chosenModel) {
|
|
583
|
+
const defaultForTool = agentSaved[agentItem.tool] || DEFAULT_MODEL;
|
|
584
|
+
chosenModel = await pickModelFor(agentItem, defaultForTool);
|
|
585
|
+
if (!chosenModel) continue;
|
|
586
|
+
}
|
|
587
|
+
saveModel("agy", chosenModel);
|
|
588
|
+
const cfg = readConfig() ?? {};
|
|
589
|
+
const effort = await pickAgyEffort(cfg.agy_effort || "high");
|
|
590
|
+
if (!effort) continue;
|
|
591
|
+
cfg.agy_effort = effort;
|
|
592
|
+
writeConfig(cfg);
|
|
593
|
+
launchModelPayload = { model: chosenModel, effort };
|
|
594
|
+
} else {
|
|
595
|
+
let chosenModel = agentPicked.model;
|
|
596
|
+
if (!chosenModel) {
|
|
597
|
+
const defaultForTool = agentSaved[agentItem.tool] || DEFAULT_MODEL;
|
|
598
|
+
chosenModel = await pickModelFor(agentItem, defaultForTool);
|
|
599
|
+
if (!chosenModel) continue;
|
|
600
|
+
}
|
|
601
|
+
saveModel(agentItem.tool, chosenModel);
|
|
602
|
+
launchModelPayload = chosenModel;
|
|
338
603
|
}
|
|
339
604
|
|
|
340
|
-
|
|
341
|
-
await launch(agentItem.tool, chosenModel, [], { exit: false });
|
|
605
|
+
await launch(agentItem.tool, launchModelPayload, [], { exit: false });
|
|
342
606
|
console.log();
|
|
343
607
|
if (!loop) return true;
|
|
344
608
|
}
|
|
@@ -357,8 +621,24 @@ export async function mainMenu() {
|
|
|
357
621
|
if (!isLoggedIn()) await login();
|
|
358
622
|
else await touchSession().catch(() => {});
|
|
359
623
|
|
|
624
|
+
let updateInfo = null;
|
|
625
|
+
try {
|
|
626
|
+
updateInfo = await checkLatestVersion();
|
|
627
|
+
if (updateInfo?.isOutdated) {
|
|
628
|
+
if (isAutoUpdateEnabled()) {
|
|
629
|
+
console.log(`💡 ${t("Update available: v{current} → v{latest}", { current: updateInfo.currentVersion, latest: updateInfo.latestVersion })}`);
|
|
630
|
+
console.log(`▶ ${t("Auto-updating PleumRouter CLI...")}\n`);
|
|
631
|
+
const code = await performUpdate();
|
|
632
|
+
if (code === 0) {
|
|
633
|
+
console.log(`\n✓ ${t("Update completed! Please run `pleum` again.")}`);
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
} catch {}
|
|
639
|
+
|
|
360
640
|
for (;;) {
|
|
361
|
-
const picked = await menuSelect();
|
|
641
|
+
const picked = await menuSelect(updateInfo);
|
|
362
642
|
if (picked === undefined) return false; // 비TTY
|
|
363
643
|
if (picked === null || picked === "quit") return true; // esc/q/ctrl+c — ollama처럼 조용히 종료
|
|
364
644
|
|
package/src/models.mjs
CHANGED
|
@@ -1,13 +1,199 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
|
|
1
4
|
import { V1 } from "./config.mjs";
|
|
2
5
|
import { style, padDisplay, divider, sanitizeTerminalText } from "./ansi.mjs";
|
|
3
6
|
import { t } from "./i18n.mjs";
|
|
4
7
|
|
|
8
|
+
export const CORE_MODEL_PROVIDERS = {
|
|
9
|
+
"deepseek-ai/DeepSeek-V4.1-Flash": [
|
|
10
|
+
{ id: "deepseek", name: "DeepSeek (Official)" },
|
|
11
|
+
{ id: "together", name: "Together AI" },
|
|
12
|
+
{ id: "fireworks", name: "Fireworks AI" },
|
|
13
|
+
{ id: "siliconflow", name: "SiliconFlow" },
|
|
14
|
+
],
|
|
15
|
+
"claude-sonnet-5": [
|
|
16
|
+
{ id: "anthropic", name: "Anthropic (Official)" },
|
|
17
|
+
{ id: "aws_bedrock", name: "AWS Bedrock" },
|
|
18
|
+
{ id: "gcp_vertex", name: "Google Cloud Vertex AI" },
|
|
19
|
+
],
|
|
20
|
+
"claude-opus-5-5": [
|
|
21
|
+
{ id: "anthropic", name: "Anthropic (Official)" },
|
|
22
|
+
{ id: "aws_bedrock", name: "AWS Bedrock" },
|
|
23
|
+
{ id: "gcp_vertex", name: "Google Cloud Vertex AI" },
|
|
24
|
+
],
|
|
25
|
+
"gpt-6-astra": [
|
|
26
|
+
{ id: "openai", name: "OpenAI (Official)" },
|
|
27
|
+
{ id: "azure", name: "Microsoft Azure OpenAI" },
|
|
28
|
+
],
|
|
29
|
+
"gpt-6-sol": [
|
|
30
|
+
{ id: "openai", name: "OpenAI (Official)" },
|
|
31
|
+
{ id: "azure", name: "Microsoft Azure OpenAI" },
|
|
32
|
+
],
|
|
33
|
+
"gpt-6-luna": [
|
|
34
|
+
{ id: "openai", name: "OpenAI (Official)" },
|
|
35
|
+
{ id: "azure", name: "Microsoft Azure OpenAI" },
|
|
36
|
+
],
|
|
37
|
+
"gemini-3.8-flash": [
|
|
38
|
+
{ id: "google", name: "Google AI Studio (Official)" },
|
|
39
|
+
{ id: "vertex", name: "Google Cloud Vertex AI" },
|
|
40
|
+
],
|
|
41
|
+
"qwen3.8-max": [
|
|
42
|
+
{ id: "alibaba", name: "Alibaba DashScope (Official)" },
|
|
43
|
+
{ id: "together", name: "Together AI" },
|
|
44
|
+
{ id: "siliconflow", name: "SiliconFlow" },
|
|
45
|
+
],
|
|
46
|
+
"glm-5.3-flash": [
|
|
47
|
+
{ id: "zhipu", name: "Zhipu AI BigModel (Official)" },
|
|
48
|
+
{ id: "modelark", name: "ByteDance ModelArk" },
|
|
49
|
+
],
|
|
50
|
+
"kimi-k3": [
|
|
51
|
+
{ id: "moonshot", name: "Moonshot AI (Official)" },
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const CORE_MODEL_DEFAULTS = [
|
|
56
|
+
{
|
|
57
|
+
id: "deepseek-ai/DeepSeek-V4.1-Flash",
|
|
58
|
+
display_name: "DeepSeek V4.1 Flash (Ultra-fast coding)",
|
|
59
|
+
maker: "DeepSeek",
|
|
60
|
+
context_length: 131072,
|
|
61
|
+
pricing: { prompt: "0.00000014", completion: "0.00000028" },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "claude-sonnet-5",
|
|
65
|
+
display_name: "Claude Sonnet 5 (Anthropic flagship coding)",
|
|
66
|
+
maker: "Anthropic",
|
|
67
|
+
context_length: 200000,
|
|
68
|
+
pricing: { prompt: "0.000003", completion: "0.000015" },
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "claude-opus-5-5",
|
|
72
|
+
display_name: "Claude Opus 5.5 (Deep reasoning & architecture)",
|
|
73
|
+
maker: "Anthropic",
|
|
74
|
+
context_length: 200000,
|
|
75
|
+
pricing: { prompt: "0.000005", completion: "0.000025" },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "gpt-6-astra",
|
|
79
|
+
display_name: "GPT-6 Astra (OpenAI flagship reasoning)",
|
|
80
|
+
maker: "OpenAI",
|
|
81
|
+
context_length: 128000,
|
|
82
|
+
pricing: { prompt: "0.0000025", completion: "0.00001" },
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "gpt-6-sol",
|
|
86
|
+
display_name: "GPT-6 Sol (Fast coding & logic)",
|
|
87
|
+
maker: "OpenAI",
|
|
88
|
+
context_length: 128000,
|
|
89
|
+
pricing: { prompt: "0.000001", completion: "0.000004" },
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "gpt-6-luna",
|
|
93
|
+
display_name: "GPT-6 Luna (Lightweight coding)",
|
|
94
|
+
maker: "OpenAI",
|
|
95
|
+
context_length: 128000,
|
|
96
|
+
pricing: { prompt: "0.0000005", completion: "0.000002" },
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "gemini-3.8-flash",
|
|
100
|
+
display_name: "Gemini 3.8 Flash (1M context fast multimodal)",
|
|
101
|
+
maker: "Google",
|
|
102
|
+
context_length: 1048576,
|
|
103
|
+
pricing: { prompt: "0.00000035", completion: "0.0000014" },
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "qwen3.8-max",
|
|
107
|
+
display_name: "Qwen 3.8 Max (Alibaba flagship coding)",
|
|
108
|
+
maker: "Alibaba",
|
|
109
|
+
context_length: 131072,
|
|
110
|
+
pricing: { prompt: "0.0000016", completion: "0.0000064" },
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "glm-5.3-flash",
|
|
114
|
+
display_name: "GLM 5.3 Flash (Zhipu AI high-speed coding)",
|
|
115
|
+
maker: "Zhipu AI",
|
|
116
|
+
context_length: 128000,
|
|
117
|
+
pricing: { prompt: "0.0000001", completion: "0.0000002" },
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: "kimi-k3",
|
|
121
|
+
display_name: "Kimi K3 (Moonshot 256K long-context coding)",
|
|
122
|
+
maker: "Moonshot",
|
|
123
|
+
context_length: 262144,
|
|
124
|
+
pricing: { prompt: "0.0000006", completion: "0.0000024" },
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
let memoryModelsCache = null;
|
|
129
|
+
let memoryCacheExpiry = 0;
|
|
130
|
+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
131
|
+
|
|
132
|
+
function getCacheFilePath() {
|
|
133
|
+
return join(homedir(), ".pleum", "models-cache.json");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function fetchCoreCodingModels() {
|
|
137
|
+
const cacheFile = getCacheFilePath();
|
|
138
|
+
let liveList = memoryModelsCache;
|
|
139
|
+
if (!liveList) {
|
|
140
|
+
try {
|
|
141
|
+
liveList = JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|
|
144
|
+
const byId = new Map((liveList || []).map((m) => [m.id, m]));
|
|
145
|
+
return CORE_MODEL_DEFAULTS.map((def) => {
|
|
146
|
+
const live = byId.get(def.id);
|
|
147
|
+
return live ? { ...def, ...live } : { ...def };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
5
151
|
// GET /v1/models 는 공개(인증 불필요). 슬러그는 provider/model 형식.
|
|
6
|
-
export async function fetchModels() {
|
|
7
|
-
const
|
|
8
|
-
if (!
|
|
9
|
-
|
|
10
|
-
|
|
152
|
+
export async function fetchModels({ forceRefresh = false } = {}) {
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
if (!forceRefresh && memoryModelsCache && now < memoryCacheExpiry) {
|
|
155
|
+
return memoryModelsCache;
|
|
156
|
+
}
|
|
157
|
+
const cacheFile = getCacheFilePath();
|
|
158
|
+
if (!forceRefresh && !memoryModelsCache) {
|
|
159
|
+
try {
|
|
160
|
+
const stats = statSync(cacheFile);
|
|
161
|
+
if (now - stats.mtimeMs < CACHE_TTL_MS) {
|
|
162
|
+
const cached = JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
163
|
+
if (Array.isArray(cached) && cached.length > 0) {
|
|
164
|
+
memoryModelsCache = cached;
|
|
165
|
+
memoryCacheExpiry = stats.mtimeMs + CACHE_TTL_MS;
|
|
166
|
+
return cached;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const res = await fetch(`${V1}/models`);
|
|
174
|
+
if (!res.ok) throw new Error(t("Failed to fetch model list ({status})", { status: res.status }));
|
|
175
|
+
const data = await res.json();
|
|
176
|
+
const list = data.data ?? [];
|
|
177
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
178
|
+
memoryModelsCache = list;
|
|
179
|
+
memoryCacheExpiry = now + CACHE_TTL_MS;
|
|
180
|
+
try {
|
|
181
|
+
mkdirSync(join(homedir(), ".pleum"), { recursive: true, mode: 0o700 });
|
|
182
|
+
writeFileSync(cacheFile, JSON.stringify(list), { mode: 0o600 });
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
return list;
|
|
186
|
+
} catch (err) {
|
|
187
|
+
try {
|
|
188
|
+
const cached = JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
189
|
+
if (Array.isArray(cached) && cached.length > 0) {
|
|
190
|
+
memoryModelsCache = cached;
|
|
191
|
+
memoryCacheExpiry = now + 60 * 1000;
|
|
192
|
+
return cached;
|
|
193
|
+
}
|
|
194
|
+
} catch {}
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
11
197
|
}
|
|
12
198
|
|
|
13
199
|
// id/표시명 부분일치 필터 — run 피커·REPL /models·목록이 공유하는 단일 구현.
|