pleumrouter 0.5.36 → 0.5.39

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/login.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { spawn } from "node:child_process";
4
- import { ROOT, SITE_ROOT, V1, readConfig, writeConfig, mergeLoginConfig, clearCredentials, setActiveContext } from "./config.mjs";
4
+ import { ROOT, SITE_ROOT, V1, readConfig, writeConfig, mergeLoginConfig, clearCredentials, setActiveContext, refreshAccessToken, accessTokenIsStale } from "./config.mjs";
5
5
  import { t } from "./i18n.mjs";
6
6
  import { sanitizeTerminalText } from "./ansi.mjs";
7
7
 
@@ -99,14 +99,17 @@ export async function login() {
99
99
  payload.refresh_token;
100
100
  res.writeHead(ok ? 200 : 400, { "Content-Type": "application/json" });
101
101
  res.end(JSON.stringify({ ok: Boolean(ok) }));
102
+ if (!ok) {
103
+ // 오청 1회가 전체 플로우를 죽이지 않는다(#116) — 브라우저의 사족
104
+ // 요청(favicon 등)이 오면 400로 답하고 다음(정상) 콜백을 계속 기다린다.
105
+ return;
106
+ }
102
107
  server.close();
103
- if (ok) {
104
- resolve({
105
- access_token: payload.access_token,
106
- refresh_token: payload.refresh_token,
107
- expires_in: payload.expires_in,
108
- });
109
- } else reject(new Error(t("State mismatch or missing key")));
108
+ resolve({
109
+ access_token: payload.access_token,
110
+ refresh_token: payload.refresh_token,
111
+ expires_in: payload.expires_in,
112
+ });
110
113
  });
111
114
  });
112
115
 
@@ -186,10 +189,19 @@ export async function logout() {
186
189
  const cfg = readConfig();
187
190
  if (cfg?.access_token) {
188
191
  // best-effort — 서버가 죽어 있어도 로컬 자격증명은 반드시 지운다.
189
- await fetch(`${V1}/auth/logout`, {
190
- method: "POST",
191
- headers: { Authorization: `Bearer ${cfg.access_token}` },
192
- }).catch(() => {});
192
+ // #153: access_token이 만료된 채 두면 폐기가 401로 조용히 생략된다 —
193
+ // requireAuth/authFetch는 미로그인 시 process.exit하므로 로그아웃 경로에
194
+ // 못 쓴다. 순수 refresh 갱신 후 유효한 토큰으로 폐기한다(실패는 무시).
195
+ let token = cfg.access_token;
196
+ if (accessTokenIsStale(token, 0)) {
197
+ token = (await refreshAccessToken(cfg).catch(() => null)) || token;
198
+ }
199
+ if (token) {
200
+ await fetch(`${V1}/auth/logout`, {
201
+ method: "POST",
202
+ headers: { Authorization: `Bearer ${token}` },
203
+ }).catch(() => {});
204
+ }
193
205
  }
194
206
  if (cfg) writeConfig(clearCredentials(cfg));
195
207
  }
package/src/menu.mjs CHANGED
@@ -1,26 +1,20 @@
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
- // 4대 에이전트는 최상위에서 바로 실행하고, 나머지는 "에이전트 실행" 서브메뉴에서 고른다.
1
+ // bare `pleum` 인라인 런처. 첫 화면의 에이전트 실행 → 도구 → 실행 모드를 공유한다.
9
2
 
10
3
  import { readFileSync } from "node:fs";
11
- import { spawn } from "node:child_process";
4
+
12
5
  import { createInterface } from "node:readline";
13
6
  import { style, displayWidth, terminalWidth, terminalHeight, hyperlink, sanitizeTerminalText } from "./ansi.mjs";
14
7
  import { keyFeeder, select } from "./interactive.mjs";
15
8
  import { readConfig, writeConfig, isLoggedIn, getActiveContext } from "./config.mjs";
16
9
  import { RECIPES, MANUAL, DEFAULT_MODEL } from "./recipes.mjs";
17
- import { onPath, launch } from "./launch.mjs";
18
- 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";
19
12
  import { run } from "./run.mjs";
20
13
  import { login } from "./login.mjs";
21
14
  import { balance } from "./balance.mjs";
22
15
  import { runSettings } from "./settings.mjs";
23
16
  import { t } from "./i18n.mjs";
17
+ import { parseInstallCommand, runInstallCommand, printManualInstall } from "./install.mjs";
24
18
 
25
19
  // 값은 t() 키(영어 원문)만 담는다 — 여기서 t()를 호출하면 모듈 로드 시점(import 1회)에
26
20
  // 번역이 확정돼, 같은 프로세스 안에서 언어를 바꿔도(Settings) 이 설명들만 예전 언어로 남는다.
@@ -47,27 +41,19 @@ const AGENT_DESC = {
47
41
  interpreter: "Local code execution & pair programmer",
48
42
  };
49
43
 
50
- const autoInstallable = (r) => !/^https?:/.test(r.install); // URL이면 수동 설치 안내만
44
+ // 실행 가능한(셸 문법 없는 argv 단계) 설치만 자동 설치 — URL·파이프·리다이렉트는
45
+ // 수동 안내한다(#40: naive split이 "|"·"&&" 를 인자로 넘겨 전부 실패하던 결함).
46
+ const autoInstallable = (r) => !parseInstallCommand(r.install).manual;
51
47
 
52
48
  // 최상위 메뉴 항목(순수 함수 — 테스트 대상). 로그인은 첫 에이전트 실행 시 시작한다.
53
- export function buildItems({ savedModels = {}, activeContext = { type: "personal" }, installed = {} }) {
54
- const items = [];
55
- for (const tool of ["claude", "codex", "agy", "opencode"]) {
56
- const r = RECIPES[tool];
57
- items.push({
58
- id: `launch:${tool}`,
59
- tool,
60
- title: `${r.label}${installed[tool] === false ? ` (${t("install")})` : ""}`,
61
- desc: `${t(AGENT_DESC[tool])}${savedModels[tool] ? ` · ${t("model")}: ${savedModels[tool]}` : ""}`,
62
- });
63
- }
49
+ export function buildItems({ savedModels = {}, activeContext = { type: "personal" } }) {
50
+ const items = [{ id: "agents", title: t("Run an agent"), desc: t("Pick a coding agent to run through PleumRouter") }];
64
51
  items.push({
65
52
  id: "chat",
66
53
  tool: "chat",
67
54
  title: t("Chat with a model"),
68
55
  desc: `${t("Start an interactive chat")}${savedModels.chat ? ` · ${t("model")}: ${savedModels.chat}` : ""}`,
69
56
  });
70
- items.push({ id: "agents", title: t("Run an agent"), desc: t("Pick a coding agent to run through PleumRouter") });
71
57
  const contextDesc = activeContext?.type === "team" && activeContext.org_name
72
58
  ? `${t("Team")}: ${activeContext.org_name}`
73
59
  : t("Personal");
@@ -88,7 +74,7 @@ export function buildAgentItems({ installed, savedModels = {} }) {
88
74
  tool,
89
75
  title: `${r.label}${installed[tool] ? "" : autoInstallable(r) ? ` (${t("install")})` : ` (${t("not installed")})`}`,
90
76
  desc: installed[tool]
91
- ? `${t(AGENT_DESC[tool] ?? "")}${savedModels[tool] ? ` · ${t("model")}: ${savedModels[tool]}` : ""}`
77
+ ? `${t(AGENT_DESC[tool] ?? "")}${savedModels[tool] && !["claude", "codex", "agy"].includes(tool) ? ` · ${t("model")}: ${savedModels[tool]}` : ""}`
92
78
  : autoInstallable(r)
93
79
  ? `${t("Press Enter to install now")} — ${r.install}`
94
80
  : `${t("Manual install:")} ${hyperlink(r.install)}`,
@@ -123,9 +109,10 @@ function saveLastSelection(id) {
123
109
  writeConfig(cfg);
124
110
  }
125
111
 
126
- export async function pickProviderForModel(modelId, { input = process.stdin, output = process.stdout } = {}) {
112
+ export async function pickProviderForModel(model, { input = process.stdin, output = process.stdout } = {}) {
113
+ const modelId = typeof model === "string" ? model : model?.id;
127
114
  if (!modelId || modelId === "__view_all__") return modelId;
128
- const providers = CORE_MODEL_PROVIDERS[modelId] || [];
115
+ const providers = [...new Set((typeof model === "string" ? [] : model.providers || []).map((p) => p.provider).filter(Boolean))];
129
116
  if (!providers.length) return modelId;
130
117
 
131
118
  const options = [
@@ -134,10 +121,10 @@ export async function pickProviderForModel(modelId, { input = process.stdin, out
134
121
  title: `⚡ ${t("Auto (best route)")}`,
135
122
  desc: t("PleumRouter auto-routing — lowest price, lowest latency, automatic failover"),
136
123
  },
137
- ...providers.map((p) => ({
138
- id: p.id,
139
- title: p.name,
140
- desc: t("Pin to {provider} (@{provider})", { provider: p.id }),
124
+ ...providers.map((provider) => ({
125
+ id: provider,
126
+ title: provider,
127
+ desc: t("Pin to {provider} (@{provider})", { provider }),
141
128
  })),
142
129
  ];
143
130
 
@@ -154,9 +141,20 @@ export async function pickProviderForModel(modelId, { input = process.stdin, out
154
141
  return `${modelId}@${chosen.id}`;
155
142
  }
156
143
 
157
- export async function pickModelFor(item, defaultModel) {
144
+ export async function pickModelFor(item, defaultModel, { supported = () => true, allowProvider = true } = {}) {
145
+ let allModels;
146
+ try {
147
+ allModels = codingModels(await fetchModels()).filter((m) => supported(m.id));
148
+ } catch (e) {
149
+ console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
150
+ return undefined;
151
+ }
152
+ if (!allModels.length) {
153
+ console.log(`${style.red("✗")} ${t("No compatible coding models available")}`);
154
+ return undefined;
155
+ }
158
156
  for (;;) {
159
- const core = fetchCoreCodingModels();
157
+ const core = coreCodingModels(allModels);
160
158
  const items = [
161
159
  ...core,
162
160
  {
@@ -177,13 +175,6 @@ export async function pickModelFor(item, defaultModel) {
177
175
 
178
176
  let pickedModelId = m.id;
179
177
  if (pickedModelId === "__view_all__") {
180
- let allModels;
181
- try {
182
- allModels = await fetchModels();
183
- } catch (e) {
184
- console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
185
- return undefined;
186
- }
187
178
  const allPicked = await select(allModels, {
188
179
  message: `${item.title} — ${t("select a model")}`,
189
180
  formatItem: (x) => formatModelLabel(x),
@@ -194,113 +185,20 @@ export async function pickModelFor(item, defaultModel) {
194
185
  pickedModelId = allPicked.id;
195
186
  }
196
187
 
197
- const withProvider = await pickProviderForModel(pickedModelId);
188
+ const selected = allModels.find((x) => x.id === pickedModelId);
189
+ const withProvider = allowProvider ? await pickProviderForModel(selected) : pickedModelId;
198
190
  if (!withProvider) continue;
199
191
  return withProvider;
200
192
  }
201
193
  }
202
194
 
203
- export async function pickClaudeCodeTiers(agentItem, { input = process.stdin, output = process.stdout } = {}) {
204
- const cfg = readConfig() ?? {};
205
- const savedTiers = cfg.claude_tiers;
206
- const defaultTiers = {
207
- sonnet: "claude-sonnet-5",
208
- opus: "claude-opus-5-5",
209
- haiku: "claude-haiku-4-5",
210
- };
211
- const baseTiers = {
212
- sonnet: savedTiers?.sonnet || defaultTiers.sonnet,
213
- opus: savedTiers?.opus || defaultTiers.opus,
214
- haiku: savedTiers?.haiku || defaultTiers.haiku,
215
- };
216
-
217
- // 기존 설정이 존재하면 1-클릭 확인 메뉴: Enter 1회로 기존 설정대로 즉시 실행
218
- if (savedTiers && savedTiers.sonnet && savedTiers.opus && savedTiers.haiku) {
219
- const keepChoice = await select(
220
- [
221
- {
222
- id: "keep",
223
- title: t("Keep existing configuration"),
224
- desc: `Sonnet: ${savedTiers.sonnet} · Opus: ${savedTiers.opus} · Haiku: ${savedTiers.haiku}`,
225
- },
226
- {
227
- id: "change",
228
- title: t("Reconfigure tiers"),
229
- desc: t("Choose Sonnet, Opus, and Haiku models again"),
230
- },
231
- ],
232
- {
233
- message: `${agentItem.title} — ${t("Use existing model configuration?")}`,
234
- formatItem: (x) => `${x.title} ${style.dim(x.desc)}`,
235
- limit: 5,
236
- input,
237
- output,
238
- },
239
- );
240
- if (!keepChoice) return null;
241
- if (keepChoice.id === "keep") {
242
- return savedTiers;
243
- }
244
- }
245
-
246
- // 1/3단계: Sonnet 모델 지정 (10개 핵심 추천 + 200+ 전체 검색 + 프로바이더 라우팅)
247
- const sonnet = await pickModelFor(
248
- { title: `${agentItem.title} (1/3) — ${t("Sonnet model")}` },
249
- baseTiers.sonnet,
250
- );
251
- if (!sonnet) return null;
252
-
253
- // 2/3단계: Opus 모델 지정 (10개 핵심 추천 + 200+ 전체 검색 + 프로바이더 라우팅)
254
- const opus = await pickModelFor(
255
- { title: `${agentItem.title} (2/3) — ${t("Opus model")}` },
256
- baseTiers.opus,
257
- );
258
- if (!opus) return null;
259
-
260
- // 3/3단계: Haiku 모델 지정 (10개 핵심 추천 + 200+ 전체 검색 + 프로바이더 라우팅)
261
- const haiku = await pickModelFor(
262
- { title: `${agentItem.title} (3/3) — ${t("Haiku model")}` },
263
- baseTiers.haiku,
264
- );
265
- if (!haiku) return null;
266
-
267
- const resultTiers = { sonnet, opus, haiku };
268
- cfg.claude_tiers = resultTiers;
269
- writeConfig(cfg);
270
- return resultTiers;
271
- }
272
-
273
- export async function pickCodexEffort(defaultEffort = "medium") {
274
- const options = [
275
- { id: "medium", title: `⚡ ${t("Medium (Recommended)")}`, desc: t("Balanced reasoning speed and depth") },
276
- { id: "high", title: `🔥 ${t("High (Deep reasoning)")}`, desc: t("Maximum reasoning depth for complex tasks") },
277
- { id: "low", title: `⚡ ${t("Low (Fast response)")}`, desc: t("Lightweight reasoning for quick answers") },
278
- ];
279
- const initialIndex = options.findIndex((x) => x.id === defaultEffort);
280
- const picked = await select(options, {
281
- message: `Codex — ${t("Select reasoning effort")}`,
282
- formatItem: (x) => `${x.title} ${style.dim(x.desc)}`,
283
- limit: 5,
284
- initialCursor: initialIndex >= 0 ? initialIndex : 0,
285
- });
286
- return picked ? picked.id : null;
287
- }
288
-
289
- export async function pickAgyEffort(defaultEffort = "high") {
290
- const options = [
291
- { id: "high", title: `⚡ ${t("High (Recommended)")}`, desc: t("High reasoning effort for standard tasks") },
292
- { id: "max", title: `🚀 ${t("Max (Maximum reasoning)")}`, desc: t("Full reasoning effort for difficult problems") },
293
- { id: "medium", title: `⚖️ ${t("Medium (Standard)")}`, desc: t("Balanced reasoning effort") },
294
- { id: "low", title: `⚡ ${t("Low (Fast response)")}`, desc: t("Low reasoning effort for rapid iteration") },
195
+ export function agentModeItems(tool) {
196
+ const native = tool === "claude" ? "Anthropic official models"
197
+ : tool === "codex" ? "OpenAI official models" : "Antigravity default models";
198
+ return [
199
+ { id: "native", title: t(native), desc: t("Choose models inside the agent") },
200
+ { id: "custom", title: t("Choose my model (beta)"), desc: t("Select a PleumRouter coding model") },
295
201
  ];
296
- const initialIndex = options.findIndex((x) => x.id === defaultEffort);
297
- const picked = await select(options, {
298
- message: `Antigravity CLI (agy) — ${t("Select reasoning effort")}`,
299
- formatItem: (x) => `${x.title} ${style.dim(x.desc)}`,
300
- limit: 6,
301
- initialCursor: initialIndex >= 0 ? initialIndex : 0,
302
- });
303
- return picked ? picked.id : null;
304
202
  }
305
203
 
306
204
  // 미설치 도구: ollama처럼 확인 후 그 자리에서 설치(stdio 상속). 성공 시 true.
@@ -317,15 +215,15 @@ async function ensureInstalled(r) {
317
215
  return false;
318
216
  }
319
217
  console.log(style.dim(`$ ${r.install}`));
320
- const code = await new Promise((res) => {
321
- const [cmd, ...args] = r.install.trim().split(/\s+/);
322
- const bin = process.platform === "win32" && (cmd === "npm" || cmd === "npx" || cmd === "yarn" || cmd === "pnpm") ? `${cmd}.cmd` : cmd;
323
- const c = spawn(bin, args, { stdio: "inherit" });
324
- c.on("exit", (n) => res(n ?? 1));
325
- c.on("error", () => res(1));
326
- });
327
- if (code !== 0) {
328
- console.log(`${style.red("✗")} ${t("Install failed (exit {code})", { code })}`);
218
+ // 공용 실행기: "&&" 순차 단계 지원, Windows .cmd는 resolveLaunchCommand 우회,
219
+ // 셸 문법(파이프 등)은 절대 임의 실행하지 않고 수동 안내로 명시한다.
220
+ const result = await runInstallCommand(r.install);
221
+ if (result.manual) {
222
+ printManualInstall(r.install);
223
+ return false;
224
+ }
225
+ if (result.code !== 0) {
226
+ console.log(`${style.red("✗")} ${t("Install failed (exit {code})", { code: result.code })}`);
329
227
  return false;
330
228
  }
331
229
  console.log(`${style.green("✓")} ${t("{label} installed", { label: r.label })}`);
@@ -465,7 +363,7 @@ export async function runList(items, { header, footer, initialCursor = 0 } = {})
465
363
  else if (key.type === "down" || (key.type === "char" && key.value === "j")) move(1);
466
364
  else if (key.type === "right" || (key.type === "char" && key.value === "l")) {
467
365
  const it = items[cursor];
468
- if (!it.tool || it.manual) continue; // 도구 없는 항목·수동 설정 항목은 모델 설정 불가
366
+ if (!it.tool || it.manual || ["claude", "codex", "agy", "opencode"].includes(it.tool)) continue;
469
367
  return void (await configure(it));
470
368
  } else continue;
471
369
  dirty = true;
@@ -488,14 +386,13 @@ async function menuSelect() {
488
386
  const items = buildItems({
489
387
  savedModels: cfg?.launch_models ?? {},
490
388
  activeContext: getActiveContext(cfg),
491
- installed: Object.fromEntries(["claude", "codex", "agy", "opencode"].map((tool) => [tool, onPath(RECIPES[tool].bin)])),
492
389
  });
493
390
  const last = cfg?.last_selection;
494
391
  const initialCursor = last ? Math.max(0, items.findIndex((it) => it.id === last)) : 0;
495
392
  const headerLines = [`${style.bold(style.plum("PleumRouter"))} ${style.dim(`pleum ${version}`)}`];
496
393
  return runList(items, {
497
394
  header: headerLines.join("\n"),
498
- footer: t("↑/↓ move • enter run • → set model • esc quit"),
395
+ footer: t("↑/↓ move • enter select • esc quit"),
499
396
  initialCursor,
500
397
  });
501
398
  }
@@ -507,12 +404,11 @@ async function agentSelect() {
507
404
  const items = buildAgentItems({ installed, savedModels: readConfig()?.launch_models ?? {} });
508
405
  return runList(items, {
509
406
  header: style.bold(style.plum(t("Run an agent via PleumRouter"))),
510
- footer: t("↑/↓ move • enter run • → set model • esc back"),
407
+ footer: t("↑/↓ move • enter select • esc back"),
511
408
  });
512
409
  }
513
410
 
514
- // 에이전트 실행 메뉴 — bare pleum의 "에이전트 실행"과 pleum launch (인자 없음)가 공유.
515
- // Enter는 저장 모델로 즉시 실행하고, →는 모델 선택 후 실행한다.
411
+ // bare pleum의 "에이전트 실행"과 pleum launch (인자 없음)가 공유한다.
516
412
  export async function runAgentMenu({ loop = false } = {}) {
517
413
  const io = process.stdin;
518
414
  if (!io.isTTY || !process.stdout.isTTY || typeof io.setRawMode !== "function") return false;
@@ -535,8 +431,34 @@ export async function runAgentMenu({ loop = false } = {}) {
535
431
  continue;
536
432
  }
537
433
 
538
- if (agentPicked.model) saveModel(agentItem.tool, agentPicked.model);
539
- await launch(agentItem.tool, agentPicked.model, [], { exit: false });
434
+ let model = agentPicked.model;
435
+ if (["claude", "codex", "agy"].includes(agentItem.tool)) {
436
+ const mode = await select(agentModeItems(agentItem.tool), {
437
+ message: agentItem.title,
438
+ formatItem: (x) => `${x.title} ${style.dim(x.desc)}`,
439
+ limit: 4,
440
+ });
441
+ if (!mode) continue;
442
+ if (mode.id === "custom") {
443
+ let supported = () => true;
444
+ if (agentItem.tool === "agy") {
445
+ try {
446
+ const ids = listAgyModelIds();
447
+ supported = (id) => supportsAgyModel(id, ids);
448
+ console.log(style.dim(t("Antigravity custom supports only verified Gemini models in API-key mode.")));
449
+ } catch (e) {
450
+ console.log(`${style.red("✗")} ${t("Antigravity model discovery failed: {error}", { error: sanitizeTerminalText(e.message) })}`);
451
+ continue;
452
+ }
453
+ }
454
+ const saved = readConfig()?.launch_models?.[agentItem.tool];
455
+ const selected = await pickModelFor(agentItem, saved, { supported, allowProvider: agentItem.tool !== "agy" });
456
+ if (!selected) continue;
457
+ saveModel(agentItem.tool, selected);
458
+ model = { mode: "custom", model: selected };
459
+ } else model = { mode: "native" };
460
+ } else if (agentPicked.model) saveModel(agentItem.tool, agentPicked.model);
461
+ await launch(agentItem.tool, model, [], { exit: false });
540
462
  console.log();
541
463
  if (!loop) return true;
542
464
  }
@@ -590,15 +512,6 @@ export async function mainMenu() {
590
512
  if (r === "quit") return true;
591
513
  continue;
592
514
  }
593
- case "launch:claude":
594
- case "launch:codex":
595
- case "launch:agy":
596
- case "launch:opencode": {
597
- const tool = item.tool;
598
- if (!onPath(RECIPES[tool].bin) && !(await ensureInstalled(RECIPES[tool]))) continue;
599
- await launch(tool, model, [], { exit: false });
600
- continue;
601
- }
602
515
  }
603
516
  }
604
517
  }
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 형식.
@@ -305,7 +181,7 @@ export async function listModels(rest) {
305
181
  // ── 모델 상세(ollama show 대응) — GET /v1/models/{id} ────────────────────
306
182
  // id는 provider/model 형식이라 슬래시를 그대로 둔다(백엔드 :path 컨버터).
307
183
  export async function fetchModelDetail(id) {
308
- const res = await fetch(`${V1}/models/${id}`);
184
+ const res = await fetch(`${V1}/models/${encodeURIComponent(id)}`);
309
185
  if (res.status === 404) throw new Error(t("Model not found: {id} (list: pleum models)", { id }));
310
186
  if (!res.ok) throw new Error(t("Failed to fetch model detail ({status})", { status: res.status }));
311
187
  return res.json();