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/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
- let models;
128
- try {
129
- models = await fetchModels();
130
- } catch (e) {
131
- console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
132
- return undefined;
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
- const targetId = defaultModel || DEFAULT_MODEL;
135
- const initialIndex = models.findIndex((x) => x.id === targetId);
136
- const m = await select(models, {
137
- message: `${item.title} — ${t("select a model")}`,
138
- formatItem: (x) => formatModelLabel(x),
139
- matches: matchModels,
140
- limit: 10,
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 m?.id;
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: `${style.bold(style.plum("PleumRouter"))} ${style.dim(`pleum ${version}`)}`,
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
- // 에이전트를 고르면 곧바로 핵심 10개 모델 피커를 띄워 기본 모델(deepseek-v4.1-flash) 또는
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 chosenModel = agentPicked.model;
334
- if (!chosenModel) {
335
- const defaultForTool = agentSaved[agentItem.tool] || DEFAULT_MODEL;
336
- chosenModel = await pickModelFor(agentItem, defaultForTool);
337
- if (!chosenModel) continue; // Esc 취소 시 에이전트 목록으로 복귀
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
- saveModel(agentItem.tool, chosenModel);
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 res = await fetch(`${V1}/models`);
8
- if (!res.ok) throw new Error(t("Failed to fetch model list ({status})", { status: res.status }));
9
- const data = await res.json();
10
- return data.data ?? [];
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·목록이 공유하는 단일 구현.