pleumrouter 0.5.35 → 0.5.37

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/src/menu.mjs CHANGED
@@ -1,30 +1,18 @@
1
- // Ollama 0.31 런처풍 시작 메뉴 — bare `pleum`(TTY)에서 뜬다. 비TTY면 false 반환(호출자가 HELP 폴백).
2
- // 동작 패리티(ollama cmd/tui/tui.go·cmd/launch 소스 분석 기반):
3
- // · 인라인 렌더(대체 화면 아님), ▸ 커서 + dim 설명 2줄 항목, 하단 키 안내
4
- // · →(configure) = 모델 피커를 강제로 띄우고 선택 즉시 실행(+ launch_models에 저장)
5
- // · Enter = 저장된 모델(launch_models)로 바로 실행, 미설치면 Y/n 확인 후 자동 설치
6
- // · 실행 종료 후 메뉴로 복귀(ollama runInteractiveTUI 루프), last_selection 커서 복원
7
- // · 키보드 전용: ↑/k ↓/j 이동 · enter/space 실행 · →/l 모델 설정 · esc/q 뒤로(최상위에선 종료) · ctrl+c 즉시 종료
8
- // 에이전트는 최상위에 나열하지 않고 "에이전트 실행" 한 항목으로 묶어 서브메뉴(agentSelect)로 진입한다 —
9
- // 지원 에이전트가 많아질수록 최상위 목록이 늘어지는 것을 막기 위함(runList는 두 메뉴가 공유하는 렌더 루프).
1
+ // bare `pleum` 인라인 런처. 첫 화면의 에이전트 실행 → 도구 → 실행 모드를 공유한다.
10
2
 
11
3
  import { readFileSync } from "node:fs";
12
4
  import { spawn } from "node:child_process";
13
5
  import { createInterface } from "node:readline";
14
6
  import { style, displayWidth, terminalWidth, terminalHeight, hyperlink, sanitizeTerminalText } from "./ansi.mjs";
15
7
  import { keyFeeder, select } from "./interactive.mjs";
16
- import { readConfig, writeConfig, isLoggedIn, touchSession, getActiveContext, isAutoUpdateEnabled } from "./config.mjs";
8
+ import { readConfig, writeConfig, isLoggedIn, getActiveContext } from "./config.mjs";
17
9
  import { RECIPES, MANUAL, DEFAULT_MODEL } from "./recipes.mjs";
18
- import { onPath, launch } from "./launch.mjs";
19
- import { fetchModels, matchModels, listModels, formatModelLabel, fetchCoreCodingModels, CORE_MODEL_PROVIDERS } from "./models.mjs";
10
+ import { onPath, launch, listAgyModelIds, supportsAgyModel } from "./launch.mjs";
11
+ import { fetchModels, matchModels, listModels, formatModelLabel, codingModels, coreCodingModels } from "./models.mjs";
20
12
  import { run } from "./run.mjs";
21
13
  import { login } from "./login.mjs";
22
- import { top } from "./top.mjs";
23
- import { usage } from "./usage.mjs";
24
14
  import { balance } from "./balance.mjs";
25
- import { health } from "./health.mjs";
26
15
  import { runSettings } from "./settings.mjs";
27
- import { checkLatestVersion, performUpdate } from "./update.mjs";
28
16
  import { t } from "./i18n.mjs";
29
17
 
30
18
  // 값은 t() 키(영어 원문)만 담는다 — 여기서 t()를 호출하면 모듈 로드 시점(import 1회)에
@@ -54,27 +42,21 @@ const AGENT_DESC = {
54
42
 
55
43
  const autoInstallable = (r) => !/^https?:/.test(r.install); // URL이면 수동 설치 안내만
56
44
 
57
- // 최상위 메뉴 항목(순수 함수 — 테스트 대상). 에이전트는 없음 — "agents" 한 항목으로 서브메뉴 진입.
58
- // 로그인 항목도 없다 — CLI는 로그인 사용자용이라 mainMenu()가 진입 시점에 게이트로 처리하고,
59
- // 로그인/로그아웃 전환은 설정>계정이 소유한다.
45
+ // 최상위 메뉴 항목(순수 함수 — 테스트 대상). 로그인은 첫 에이전트 실행 시 시작한다.
60
46
  export function buildItems({ savedModels = {}, activeContext = { type: "personal" } }) {
61
- const items = [];
47
+ const items = [{ id: "agents", title: t("Run an agent"), desc: t("Pick a coding agent to run through PleumRouter") }];
62
48
  items.push({
63
49
  id: "chat",
64
50
  tool: "chat",
65
51
  title: t("Chat with a model"),
66
52
  desc: `${t("Start an interactive chat")}${savedModels.chat ? ` · ${t("model")}: ${savedModels.chat}` : ""}`,
67
53
  });
68
- items.push({ id: "agents", title: t("Run an agent"), desc: t("Pick a coding agent to run through PleumRouter") });
69
54
  const contextDesc = activeContext?.type === "team" && activeContext.org_name
70
55
  ? `${t("Team")}: ${activeContext.org_name}`
71
56
  : t("Personal");
72
57
  items.push(
73
58
  { id: "models", title: t("Model list"), desc: t("All models — price and context included") },
74
- { id: "top", title: t("Leaderboard"), desc: t("Model rankings · provider share") },
75
- { id: "usage", title: t("Usage report"), desc: t("Spend over the last 30 days") },
76
59
  { id: "balance", title: t("Balance"), desc: `${t("Check your credit balance")} · ${contextDesc}` },
77
- { id: "health", title: t("Health check"), desc: t("Gateway connectivity · login status") },
78
60
  { id: "settings", title: t("Settings"), desc: t("Language, default models, account, updates") },
79
61
  );
80
62
  return items;
@@ -89,7 +71,7 @@ export function buildAgentItems({ installed, savedModels = {} }) {
89
71
  tool,
90
72
  title: `${r.label}${installed[tool] ? "" : autoInstallable(r) ? ` (${t("install")})` : ` (${t("not installed")})`}`,
91
73
  desc: installed[tool]
92
- ? `${t(AGENT_DESC[tool] ?? "")}${savedModels[tool] ? ` · ${t("model")}: ${savedModels[tool]}` : ""}`
74
+ ? `${t(AGENT_DESC[tool] ?? "")}${savedModels[tool] && !["claude", "codex", "agy"].includes(tool) ? ` · ${t("model")}: ${savedModels[tool]}` : ""}`
93
75
  : autoInstallable(r)
94
76
  ? `${t("Press Enter to install now")} — ${r.install}`
95
77
  : `${t("Manual install:")} ${hyperlink(r.install)}`,
@@ -124,9 +106,10 @@ function saveLastSelection(id) {
124
106
  writeConfig(cfg);
125
107
  }
126
108
 
127
- export async function pickProviderForModel(modelId, { input = process.stdin, output = process.stdout } = {}) {
109
+ export async function pickProviderForModel(model, { input = process.stdin, output = process.stdout } = {}) {
110
+ const modelId = typeof model === "string" ? model : model?.id;
128
111
  if (!modelId || modelId === "__view_all__") return modelId;
129
- const providers = CORE_MODEL_PROVIDERS[modelId] || [];
112
+ const providers = [...new Set((typeof model === "string" ? [] : model.providers || []).map((p) => p.provider).filter(Boolean))];
130
113
  if (!providers.length) return modelId;
131
114
 
132
115
  const options = [
@@ -135,10 +118,10 @@ export async function pickProviderForModel(modelId, { input = process.stdin, out
135
118
  title: `⚡ ${t("Auto (best route)")}`,
136
119
  desc: t("PleumRouter auto-routing — lowest price, lowest latency, automatic failover"),
137
120
  },
138
- ...providers.map((p) => ({
139
- id: p.id,
140
- title: p.name,
141
- desc: t("Pin to {provider} (@{provider})", { provider: p.id }),
121
+ ...providers.map((provider) => ({
122
+ id: provider,
123
+ title: provider,
124
+ desc: t("Pin to {provider} (@{provider})", { provider }),
142
125
  })),
143
126
  ];
144
127
 
@@ -155,9 +138,20 @@ export async function pickProviderForModel(modelId, { input = process.stdin, out
155
138
  return `${modelId}@${chosen.id}`;
156
139
  }
157
140
 
158
- export async function pickModelFor(item, defaultModel) {
141
+ export async function pickModelFor(item, defaultModel, { supported = () => true, allowProvider = true } = {}) {
142
+ let allModels;
143
+ try {
144
+ allModels = codingModels(await fetchModels()).filter((m) => supported(m.id));
145
+ } catch (e) {
146
+ console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
147
+ return undefined;
148
+ }
149
+ if (!allModels.length) {
150
+ console.log(`${style.red("✗")} ${t("No compatible coding models available")}`);
151
+ return undefined;
152
+ }
159
153
  for (;;) {
160
- const core = fetchCoreCodingModels();
154
+ const core = coreCodingModels(allModels);
161
155
  const items = [
162
156
  ...core,
163
157
  {
@@ -178,13 +172,6 @@ export async function pickModelFor(item, defaultModel) {
178
172
 
179
173
  let pickedModelId = m.id;
180
174
  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
175
  const allPicked = await select(allModels, {
189
176
  message: `${item.title} — ${t("select a model")}`,
190
177
  formatItem: (x) => formatModelLabel(x),
@@ -195,113 +182,20 @@ export async function pickModelFor(item, defaultModel) {
195
182
  pickedModelId = allPicked.id;
196
183
  }
197
184
 
198
- const withProvider = await pickProviderForModel(pickedModelId);
185
+ const selected = allModels.find((x) => x.id === pickedModelId);
186
+ const withProvider = allowProvider ? await pickProviderForModel(selected) : pickedModelId;
199
187
  if (!withProvider) continue;
200
188
  return withProvider;
201
189
  }
202
190
  }
203
191
 
204
- export async function pickClaudeCodeTiers(agentItem, { input = process.stdin, output = process.stdout } = {}) {
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
- const baseTiers = {
213
- sonnet: savedTiers?.sonnet || defaultTiers.sonnet,
214
- opus: savedTiers?.opus || defaultTiers.opus,
215
- haiku: savedTiers?.haiku || defaultTiers.haiku,
216
- };
217
-
218
- // 기존 설정이 존재하면 1-클릭 확인 메뉴: Enter 1회로 기존 설정대로 즉시 실행
219
- if (savedTiers && savedTiers.sonnet && savedTiers.opus && savedTiers.haiku) {
220
- const keepChoice = await select(
221
- [
222
- {
223
- id: "keep",
224
- title: t("Keep existing configuration"),
225
- desc: `Sonnet: ${savedTiers.sonnet} · Opus: ${savedTiers.opus} · Haiku: ${savedTiers.haiku}`,
226
- },
227
- {
228
- id: "change",
229
- title: t("Reconfigure tiers"),
230
- desc: t("Choose Sonnet, Opus, and Haiku models again"),
231
- },
232
- ],
233
- {
234
- message: `${agentItem.title} — ${t("Use existing model configuration?")}`,
235
- formatItem: (x) => `${x.title} ${style.dim(x.desc)}`,
236
- limit: 5,
237
- input,
238
- output,
239
- },
240
- );
241
- if (!keepChoice) return null;
242
- if (keepChoice.id === "keep") {
243
- return savedTiers;
244
- }
245
- }
246
-
247
- // 1/3단계: Sonnet 모델 지정 (10개 핵심 추천 + 200+ 전체 검색 + 프로바이더 라우팅)
248
- const sonnet = await pickModelFor(
249
- { title: `${agentItem.title} (1/3) — ${t("Sonnet model")}` },
250
- baseTiers.sonnet,
251
- );
252
- if (!sonnet) return null;
253
-
254
- // 2/3단계: Opus 모델 지정 (10개 핵심 추천 + 200+ 전체 검색 + 프로바이더 라우팅)
255
- const opus = await pickModelFor(
256
- { title: `${agentItem.title} (2/3) — ${t("Opus model")}` },
257
- baseTiers.opus,
258
- );
259
- if (!opus) return null;
260
-
261
- // 3/3단계: Haiku 모델 지정 (10개 핵심 추천 + 200+ 전체 검색 + 프로바이더 라우팅)
262
- const haiku = await pickModelFor(
263
- { title: `${agentItem.title} (3/3) — ${t("Haiku model")}` },
264
- baseTiers.haiku,
265
- );
266
- if (!haiku) return null;
267
-
268
- const resultTiers = { sonnet, opus, haiku };
269
- cfg.claude_tiers = resultTiers;
270
- writeConfig(cfg);
271
- return resultTiers;
272
- }
273
-
274
- export async function pickCodexEffort(defaultEffort = "medium") {
275
- const options = [
276
- { id: "medium", title: `⚡ ${t("Medium (Recommended)")}`, desc: t("Balanced reasoning speed and depth") },
277
- { id: "high", title: `🔥 ${t("High (Deep reasoning)")}`, desc: t("Maximum reasoning depth for complex tasks") },
278
- { id: "low", title: `⚡ ${t("Low (Fast response)")}`, desc: t("Lightweight reasoning for quick answers") },
192
+ export function agentModeItems(tool) {
193
+ const native = tool === "claude" ? "Anthropic official models"
194
+ : tool === "codex" ? "OpenAI official models" : "Antigravity default models";
195
+ return [
196
+ { id: "native", title: t(native), desc: t("Choose models inside the agent") },
197
+ { id: "custom", title: t("Choose my model (beta)"), desc: t("Select a PleumRouter coding model") },
279
198
  ];
280
- const initialIndex = options.findIndex((x) => x.id === defaultEffort);
281
- const picked = await select(options, {
282
- message: `Codex — ${t("Select reasoning effort")}`,
283
- formatItem: (x) => `${x.title} ${style.dim(x.desc)}`,
284
- limit: 5,
285
- initialCursor: initialIndex >= 0 ? initialIndex : 0,
286
- });
287
- return picked ? picked.id : null;
288
- }
289
-
290
- export async function pickAgyEffort(defaultEffort = "high") {
291
- const options = [
292
- { id: "high", title: `⚡ ${t("High (Recommended)")}`, desc: t("High reasoning effort for standard tasks") },
293
- { id: "max", title: `🚀 ${t("Max (Maximum reasoning)")}`, desc: t("Full reasoning effort for difficult problems") },
294
- { id: "medium", title: `⚖️ ${t("Medium (Standard)")}`, desc: t("Balanced reasoning effort") },
295
- { id: "low", title: `⚡ ${t("Low (Fast response)")}`, desc: t("Low reasoning effort for rapid iteration") },
296
- ];
297
- const initialIndex = options.findIndex((x) => x.id === defaultEffort);
298
- const picked = await select(options, {
299
- message: `Antigravity CLI (agy) — ${t("Select reasoning effort")}`,
300
- formatItem: (x) => `${x.title} ${style.dim(x.desc)}`,
301
- limit: 6,
302
- initialCursor: initialIndex >= 0 ? initialIndex : 0,
303
- });
304
- return picked ? picked.id : null;
305
199
  }
306
200
 
307
201
  // 미설치 도구: ollama처럼 확인 후 그 자리에서 설치(stdio 상속). 성공 시 true.
@@ -466,7 +360,7 @@ export async function runList(items, { header, footer, initialCursor = 0 } = {})
466
360
  else if (key.type === "down" || (key.type === "char" && key.value === "j")) move(1);
467
361
  else if (key.type === "right" || (key.type === "char" && key.value === "l")) {
468
362
  const it = items[cursor];
469
- if (!it.tool || it.manual) continue; // 도구 없는 항목·수동 설정 항목은 모델 설정 불가
363
+ if (!it.tool || it.manual || ["claude", "codex", "agy", "opencode"].includes(it.tool)) continue;
470
364
  return void (await configure(it));
471
365
  } else continue;
472
366
  dirty = true;
@@ -483,7 +377,7 @@ export async function runList(items, { header, footer, initialCursor = 0 } = {})
483
377
  });
484
378
  }
485
379
 
486
- async function menuSelect(updateInfo = null) {
380
+ async function menuSelect() {
487
381
  const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
488
382
  const cfg = readConfig();
489
383
  const items = buildItems({
@@ -493,19 +387,9 @@ async function menuSelect(updateInfo = null) {
493
387
  const last = cfg?.last_selection;
494
388
  const initialCursor = last ? Math.max(0, items.findIndex((it) => it.id === last)) : 0;
495
389
  const headerLines = [`${style.bold(style.plum("PleumRouter"))} ${style.dim(`pleum ${version}`)}`];
496
- if (updateInfo?.isOutdated) {
497
- headerLines.push(
498
- style.yellow(
499
- `💡 ${t("Update available: v{current} → v{latest}. Run 'pleum update' to update.", {
500
- current: updateInfo.currentVersion,
501
- latest: updateInfo.latestVersion,
502
- })}`,
503
- ),
504
- );
505
- }
506
390
  return runList(items, {
507
391
  header: headerLines.join("\n"),
508
- footer: t("↑/↓ move • enter run • → set model • esc quit"),
392
+ footer: t("↑/↓ move • enter select • esc quit"),
509
393
  initialCursor,
510
394
  });
511
395
  }
@@ -517,18 +401,14 @@ async function agentSelect() {
517
401
  const items = buildAgentItems({ installed, savedModels: readConfig()?.launch_models ?? {} });
518
402
  return runList(items, {
519
403
  header: style.bold(style.plum(t("Run an agent via PleumRouter"))),
520
- footer: t("↑/↓ move • enter run • → set model • esc back"),
404
+ footer: t("↑/↓ move • enter select • esc back"),
521
405
  });
522
406
  }
523
407
 
524
- // 에이전트 실행 메뉴 — bare pleum의 "에이전트 실행"과 pleum launch (인자 없음)가 공유.
525
- // 에이전트를 고르면 곧바로 모델/티어/추론 피커를 띄워 기본 모델 또는
526
- // 선택한 우리 모델로 코딩 에이전트를 실행한다.
408
+ // bare pleum의 "에이전트 실행"과 pleum launch (인자 없음)가 공유한다.
527
409
  export async function runAgentMenu({ loop = false } = {}) {
528
410
  const io = process.stdin;
529
411
  if (!io.isTTY || !process.stdout.isTTY || typeof io.setRawMode !== "function") return false;
530
- if (!isLoggedIn()) await login();
531
- else await touchSession().catch(() => {});
532
412
 
533
413
  for (;;) {
534
414
  const agentPicked = await agentSelect();
@@ -548,54 +428,34 @@ export async function runAgentMenu({ loop = false } = {}) {
548
428
  continue;
549
429
  }
550
430
 
551
- const agentSaved = readConfig()?.launch_models ?? {};
552
- let launchModelPayload = null;
553
-
554
- if (agentItem.tool === "claude") {
555
- const tiers = await pickClaudeCodeTiers(agentItem);
556
- if (!tiers) continue;
557
- launchModelPayload = tiers;
558
- saveModel("claude", tiers.sonnet);
559
- } else if (agentItem.tool === "codex") {
560
- let chosenModel = agentPicked.model;
561
- if (!chosenModel) {
562
- const defaultForTool = agentSaved[agentItem.tool] || DEFAULT_MODEL;
563
- chosenModel = await pickModelFor(agentItem, defaultForTool);
564
- if (!chosenModel) continue;
565
- }
566
- saveModel("codex", chosenModel);
567
- const cfg = readConfig() ?? {};
568
- const effort = await pickCodexEffort(cfg.codex_effort || "medium");
569
- if (!effort) continue;
570
- cfg.codex_effort = effort;
571
- writeConfig(cfg);
572
- launchModelPayload = { model: chosenModel, effort };
573
- } else if (agentItem.tool === "agy") {
574
- let chosenModel = agentPicked.model;
575
- if (!chosenModel) {
576
- const defaultForTool = agentSaved[agentItem.tool] || DEFAULT_MODEL;
577
- chosenModel = await pickModelFor(agentItem, defaultForTool);
578
- if (!chosenModel) continue;
579
- }
580
- saveModel("agy", chosenModel);
581
- const cfg = readConfig() ?? {};
582
- const effort = await pickAgyEffort(cfg.agy_effort || "high");
583
- if (!effort) continue;
584
- cfg.agy_effort = effort;
585
- writeConfig(cfg);
586
- launchModelPayload = { model: chosenModel, effort };
587
- } else {
588
- let chosenModel = agentPicked.model;
589
- if (!chosenModel) {
590
- const defaultForTool = agentSaved[agentItem.tool] || DEFAULT_MODEL;
591
- chosenModel = await pickModelFor(agentItem, defaultForTool);
592
- if (!chosenModel) continue;
593
- }
594
- saveModel(agentItem.tool, chosenModel);
595
- launchModelPayload = chosenModel;
596
- }
597
-
598
- await launch(agentItem.tool, launchModelPayload, [], { exit: false });
431
+ let model = agentPicked.model;
432
+ if (["claude", "codex", "agy"].includes(agentItem.tool)) {
433
+ const mode = await select(agentModeItems(agentItem.tool), {
434
+ message: agentItem.title,
435
+ formatItem: (x) => `${x.title} ${style.dim(x.desc)}`,
436
+ limit: 4,
437
+ });
438
+ if (!mode) continue;
439
+ if (mode.id === "custom") {
440
+ let supported = () => true;
441
+ if (agentItem.tool === "agy") {
442
+ try {
443
+ const ids = listAgyModelIds();
444
+ supported = (id) => supportsAgyModel(id, ids);
445
+ console.log(style.dim(t("Antigravity custom supports only verified Gemini models in API-key mode.")));
446
+ } catch (e) {
447
+ console.log(`${style.red("✗")} ${t("Antigravity model discovery failed: {error}", { error: sanitizeTerminalText(e.message) })}`);
448
+ continue;
449
+ }
450
+ }
451
+ const saved = readConfig()?.launch_models?.[agentItem.tool];
452
+ const selected = await pickModelFor(agentItem, saved, { supported, allowProvider: agentItem.tool !== "agy" });
453
+ if (!selected) continue;
454
+ saveModel(agentItem.tool, selected);
455
+ model = { mode: "custom", model: selected };
456
+ } else model = { mode: "native" };
457
+ } else if (agentPicked.model) saveModel(agentItem.tool, agentPicked.model);
458
+ await launch(agentItem.tool, model, [], { exit: false });
599
459
  console.log();
600
460
  if (!loop) return true;
601
461
  }
@@ -608,30 +468,8 @@ export async function mainMenu() {
608
468
  // runList()와 같은 3조건을 쓴다. 이 가드가 없으면 `pleum | cat`이 브라우저를 열고 2분 매달린다.
609
469
  const io = process.stdin;
610
470
  if (!io.isTTY || !process.stdout.isTTY || typeof io.setRawMode !== "function") return false;
611
- // CLI는 로그인 사용자용이다 — 미로그인 진입이면 여기서 auth 로그인을 태운다.
612
- // login()의 실패(취소·2분 타임아웃·state 불일치)는 그대로 던져 bin/pleum.mjs의
613
- // 최상위 catch가 `✗ <메시지>` + exit 1로 처리하게 둔다(메뉴를 띄우지 않는다).
614
- if (!isLoggedIn()) await login();
615
- else await touchSession().catch(() => {});
616
-
617
- let updateInfo = null;
618
- try {
619
- updateInfo = await checkLatestVersion();
620
- if (updateInfo?.isOutdated) {
621
- if (isAutoUpdateEnabled()) {
622
- console.log(`💡 ${t("Update available: v{current} → v{latest}", { current: updateInfo.currentVersion, latest: updateInfo.latestVersion })}`);
623
- console.log(`▶ ${t("Auto-updating PleumRouter CLI...")}\n`);
624
- const code = await performUpdate();
625
- if (code === 0) {
626
- console.log(`\n✓ ${t("Update completed! Please run `pleum` again.")}`);
627
- return true;
628
- }
629
- }
630
- }
631
- } catch {}
632
-
633
471
  for (;;) {
634
- const picked = await menuSelect(updateInfo);
472
+ const picked = await menuSelect();
635
473
  if (picked === undefined) return false; // 비TTY
636
474
  if (picked === null || picked === "quit") return true; // esc/q/ctrl+c — ollama처럼 조용히 종료
637
475
 
@@ -642,6 +480,7 @@ export async function mainMenu() {
642
480
 
643
481
  switch (item.id) {
644
482
  case "chat": {
483
+ if (!isLoggedIn()) await login();
645
484
  const m = model ?? saved.chat;
646
485
  await run(m ? [m] : []); // REPL 종료는 run 내부 process.exit — 메뉴 복귀 없음
647
486
  return true;
@@ -653,20 +492,6 @@ export async function mainMenu() {
653
492
  console.error(`✗ ${sanitizeTerminalText(e.message)}`);
654
493
  }
655
494
  continue;
656
- case "top":
657
- try {
658
- await top([]);
659
- } catch (e) {
660
- console.error(`✗ ${sanitizeTerminalText(e.message)}`);
661
- }
662
- continue;
663
- case "usage":
664
- try {
665
- await usage([]);
666
- } catch (e) {
667
- console.error(`✗ ${sanitizeTerminalText(e.message)}`);
668
- }
669
- continue;
670
495
  case "balance":
671
496
  try {
672
497
  await balance({ interactive: true, exitOnError: false });
@@ -674,13 +499,6 @@ export async function mainMenu() {
674
499
  console.error(`✗ ${sanitizeTerminalText(e.message)}`);
675
500
  }
676
501
  continue;
677
- case "health":
678
- try {
679
- await health({ exitOnError: false });
680
- } catch (e) {
681
- console.error(`✗ ${sanitizeTerminalText(e.message)}`);
682
- }
683
- continue;
684
502
  case "settings": {
685
503
  const r = await runSettings();
686
504
  if (r === "quit") return true; // ctrl+c 또는 재설치(upgrade) 시작 — 전체 종료
package/src/models.mjs CHANGED
@@ -2,129 +2,10 @@ import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
4
4
  import { V1 } from "./config.mjs";
5
+ import { CORE_MODELS } from "./recipes.mjs";
5
6
  import { style, padDisplay, divider, sanitizeTerminalText } from "./ansi.mjs";
6
7
  import { t } from "./i18n.mjs";
7
8
 
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
9
  let memoryModelsCache = null;
129
10
  let memoryCacheExpiry = 0;
130
11
  const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -133,19 +14,14 @@ function getCacheFilePath() {
133
14
  return join(homedir(), ".pleum", "models-cache.json");
134
15
  }
135
16
 
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
- });
17
+ // 코딩 에이전트는 텍스트 대화 API를 호출한다. 공개 카탈로그의 다른 생성 방식은 피커에서 제외한다.
18
+ export function codingModels(models) {
19
+ return models.filter((m) => m.is_active !== false && m.modality === "text" && m.operations?.includes("chat.generate"));
20
+ }
21
+
22
+ export function coreCodingModels(models) {
23
+ const byId = new Map(codingModels(models).map((m) => [m.id, m]));
24
+ return CORE_MODELS.map((id) => byId.get(id)).filter(Boolean);
149
25
  }
150
26
 
151
27
  // GET /v1/models 는 공개(인증 불필요). 슬러그는 provider/model 형식.