pleumrouter 0.5.30 → 0.5.31

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pleumrouter",
3
- "version": "0.5.30",
3
+ "version": "0.5.31",
4
4
  "description": "Official PleumRouter terminal CLI — launch any coding agent against PleumRouter.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/ansi.mjs CHANGED
@@ -102,6 +102,10 @@ export function terminalWidth(fallback = 80) {
102
102
  return process.stdout.columns || fallback;
103
103
  }
104
104
 
105
+ export function terminalHeight(fallback = 24) {
106
+ return process.stdout.rows || fallback;
107
+ }
108
+
105
109
  export function divider(width = Math.min(terminalWidth(), 60), styler = style) {
106
110
  return styler.gray("─".repeat(Math.max(1, width)));
107
111
  }
@@ -109,6 +109,7 @@ export async function select(items, {
109
109
  return new Promise((resolve) => {
110
110
  let query = "";
111
111
  let cursor = Math.max(0, Math.min(initialCursor, Math.max(0, (items?.length ?? 1) - 1)));
112
+ let windowStart = 0;
112
113
  let lastRows = 0;
113
114
 
114
115
  const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
@@ -121,15 +122,33 @@ export async function select(items, {
121
122
  const render = () => {
122
123
  const pool = matches(items, query);
123
124
  if (cursor >= pool.length) cursor = Math.max(0, pool.length - 1);
124
- const shown = pool.slice(0, limit);
125
+ if (cursor < windowStart) {
126
+ windowStart = cursor;
127
+ } else if (cursor >= windowStart + limit) {
128
+ windowStart = cursor - limit + 1;
129
+ }
130
+ windowStart = Math.max(0, Math.min(windowStart, Math.max(0, pool.length - limit)));
131
+
132
+ const shown = pool.slice(windowStart, windowStart + limit);
125
133
  const lines = [
126
134
  `${style.plum("?")} ${style.bold(message)} ${style.dim(t("type to filter · ↑↓ move · Enter select · Esc cancel"))}`,
127
135
  `${style.gray("›")} ${query}`,
128
- ...shown.map((it, i) =>
129
- i === cursor ? `${style.plum("❯")} ${style.bold(formatItem(it))}` : ` ${style.dim(formatItem(it))}`,
130
- ),
131
136
  ];
132
- if (pool.length > shown.length) lines.push(style.dim(` ${t("… {n} more — type to narrow down", { n: pool.length - shown.length })}`));
137
+ if (windowStart > 0) {
138
+ lines.push(style.dim(` ${t("↑ {n} more above", { n: windowStart })}`));
139
+ }
140
+ lines.push(
141
+ ...shown.map((it, idx) => {
142
+ const itemIndex = windowStart + idx;
143
+ return itemIndex === cursor
144
+ ? `${style.plum("❯")} ${style.bold(formatItem(it))}`
145
+ : ` ${style.dim(formatItem(it))}`;
146
+ }),
147
+ );
148
+ const remainingBelow = pool.length - (windowStart + shown.length);
149
+ if (remainingBelow > 0) {
150
+ lines.push(style.dim(` ${t("… {n} more — type to narrow down", { n: remainingBelow })}`));
151
+ }
133
152
  if (!pool.length) lines.push(style.dim(` ${t("No matches")}`));
134
153
  erase();
135
154
  const frame = lines.join("\n");
@@ -158,12 +177,19 @@ export async function select(items, {
158
177
  const safeCursor = Math.max(0, Math.min(cursor, pool.length - 1));
159
178
  return finish(pool[safeCursor] ?? null);
160
179
  }
161
- if (key.type === "up") cursor = Math.max(0, cursor - 1);
162
- else if (key.type === "down") cursor = Math.min(limit - 1, cursor + 1);
163
- else if (key.type === "backspace") query = query.slice(0, -1);
164
- else if (key.type === "char") {
180
+ if (key.type === "up") {
181
+ cursor = Math.max(0, cursor - 1);
182
+ } else if (key.type === "down") {
183
+ const pool = matches(items, query);
184
+ cursor = Math.min(Math.max(0, pool.length - 1), cursor + 1);
185
+ } else if (key.type === "backspace") {
186
+ query = query.slice(0, -1);
187
+ cursor = 0;
188
+ windowStart = 0;
189
+ } else if (key.type === "char") {
165
190
  query += key.value;
166
191
  cursor = 0;
192
+ windowStart = 0;
167
193
  } else continue;
168
194
  dirty = true;
169
195
  }
@@ -71,8 +71,16 @@ export const ES = {
71
71
  "Necesita GitHub Copilot CLI 2026-04 o posterior para el enrutamiento de proveedores BYOK.",
72
72
  "Gemini CLI speaks Google's native generateContent format — set the model after launch with `/model google/<id>`.":
73
73
  "Gemini CLI usa el formato nativo generateContent de Google — establece el modelo tras el inicio con `/model google/<id>`.",
74
- "Writes modelProvider=gemini into ~/.gemini/antigravity-cli/settings.json. GEMINI_API_KEY alone is ignored. Gemini-family models only.":
75
- "Escribe modelProvider=gemini en ~/.gemini/antigravity-cli/settings.json. GEMINI_API_KEY sola se ignora. Solo familia Gemini.",
74
+ "Writes modelProvider=gemini into ~/.gemini/antigravity-cli/settings.json. Routes any coding model via PleumRouter.":
75
+ "Escribe modelProvider=gemini en ~/.gemini/antigravity-cli/settings.json. Enruta cualquier modelo de codificación a través de PleumRouter.",
76
+ "↑ {n} more above": "↑ {n} más arriba",
77
+ "↓ {n} more below": "↓ {n} más abajo",
78
+ "Auto (best route)": "Auto (mejor ruta)",
79
+ "PleumRouter auto-routing — lowest price, lowest latency, automatic failover":
80
+ "Enrutamiento automático de PleumRouter: mejor precio, menor latencia y conmutación por error",
81
+ "Pin to {provider} (@{provider})": "Fijar al proveedor {provider} (@{provider})",
82
+ "Select routing provider": "Seleccionar proveedor de enrutamiento",
83
+ "... view all 200+ models": "... ver todos los 200+ modelos",
76
84
  "Uses an isolated ~/.pleum/pi-home (PI_CODING_AGENT_DIR). Your ~/.pi is untouched.":
77
85
  "Usa un ~/.pleum/pi-home aislado (PI_CODING_AGENT_DIR). No toca ~/.pi.",
78
86
  "Uses an isolated ~/.pleum/crush-home (CRUSH_GLOBAL_CONFIG). Your crush.json is untouched.":
@@ -72,8 +72,16 @@ export const JA = {
72
72
  "BYOK プロバイダールーティングに対応した 2026-04 以降の GitHub Copilot CLI が必要です。",
73
73
  "Gemini CLI speaks Google's native generateContent format — set the model after launch with `/model google/<id>`.":
74
74
  "Gemini CLI は Google のネイティブ generateContent 形式を使用します — 起動後に `/model google/<id>` でモデルを設定してください。",
75
- "Writes modelProvider=gemini into ~/.gemini/antigravity-cli/settings.json. GEMINI_API_KEY alone is ignored. Gemini-family models only.":
76
- "~/.gemini/antigravity-cli/settings.json に modelProvider=gemini を書き込みます。GEMINI_API_KEY だけでは無視されます。Gemini 系のみです。",
75
+ "Writes modelProvider=gemini into ~/.gemini/antigravity-cli/settings.json. Routes any coding model via PleumRouter.":
76
+ "~/.gemini/antigravity-cli/settings.json に modelProvider=gemini を書き込みます。あらゆるコーディングモデルを PleumRouter でルーティングします。",
77
+ "↑ {n} more above": "↑ 上にあと {n} 件",
78
+ "↓ {n} more below": "↓ 下にあと {n} 件",
79
+ "Auto (best route)": "Auto (最適な自動ルーティング)",
80
+ "PleumRouter auto-routing — lowest price, lowest latency, automatic failover":
81
+ "PleumRouter 自動ルーティング — 最安値・低遅延・自動フェイルオーバー",
82
+ "Pin to {provider} (@{provider})": "{provider} プロバイダーに固定 (@{provider})",
83
+ "Select routing provider": "ルーティングプロバイダーの選択",
84
+ "... view all 200+ models": "... 全 200+ モデルを表示",
77
85
  "Uses an isolated ~/.pleum/pi-home (PI_CODING_AGENT_DIR). Your ~/.pi is untouched.":
78
86
  "隔離した ~/.pleum/pi-home (PI_CODING_AGENT_DIR) だけを使います。~/.pi は触れません。",
79
87
  "Uses an isolated ~/.pleum/crush-home (CRUSH_GLOBAL_CONFIG). Your crush.json is untouched.":
@@ -52,8 +52,16 @@ export const KO = {
52
52
  "BYOK provider 라우팅을 지원하는 2026-04 이후 버전의 GitHub Copilot CLI가 필요합니다.",
53
53
  "Gemini CLI speaks Google's native generateContent format — set the model after launch with `/model google/<id>`.":
54
54
  "Gemini CLI는 Google의 네이티브 generateContent 포맷을 사용합니다 — 실행 후 `/model google/<id>`로 모델을 설정하세요.",
55
- "Writes modelProvider=gemini into ~/.gemini/antigravity-cli/settings.json. GEMINI_API_KEY alone is ignored. Gemini-family models only.":
56
- "~/.gemini/antigravity-cli/settings.json 에 modelProvider=gemini 를 씁니다. GEMINI_API_KEY만 있으면 무시됩니다. Gemini 계열만 됩니다.",
55
+ "Writes modelProvider=gemini into ~/.gemini/antigravity-cli/settings.json. Routes any coding model via PleumRouter.":
56
+ "~/.gemini/antigravity-cli/settings.json 에 modelProvider=gemini 를 씁니다. 모든 코딩 모델을 PleumRouter로 라우팅합니다.",
57
+ "↑ {n} more above": "↑ 위에 {n}개 더",
58
+ "↓ {n} more below": "↓ 아래에 {n}개 더",
59
+ "Auto (best route)": "Auto (최적 자동 라우팅)",
60
+ "PleumRouter auto-routing — lowest price, lowest latency, automatic failover":
61
+ "PleumRouter 최적 자동 라우팅 — 최저가·최저지연·자동 페일오버",
62
+ "Pin to {provider} (@{provider})": "{provider} 제공자로 고정 (@{provider})",
63
+ "Select routing provider": "라우팅 제공자 선택",
64
+ "... view all 200+ models": "... 전체 200+ 모델 보기",
57
65
  "Uses an isolated ~/.pleum/pi-home (PI_CODING_AGENT_DIR). Your ~/.pi is untouched.":
58
66
  "격리된 ~/.pleum/pi-home (PI_CODING_AGENT_DIR)만 씁니다. ~/.pi는 건드리지 않습니다.",
59
67
  "Uses an isolated ~/.pleum/crush-home (CRUSH_GLOBAL_CONFIG). Your crush.json is untouched.":
@@ -71,8 +71,16 @@ export const ZH = {
71
71
  "需要 2026-04 或更新版本的 GitHub Copilot CLI 才支持 BYOK provider 路由。",
72
72
  "Gemini CLI speaks Google's native generateContent format — set the model after launch with `/model google/<id>`.":
73
73
  "Gemini CLI 使用 Google 原生 generateContent 格式 — 启动后用 `/model google/<id>` 设置模型。",
74
- "Writes modelProvider=gemini into ~/.gemini/antigravity-cli/settings.json. GEMINI_API_KEY alone is ignored. Gemini-family models only.":
75
- "会把 modelProvider=gemini 写入 ~/.gemini/antigravity-cli/settings.json。只设 GEMINI_API_KEY 会被忽略。仅 Gemini 系列。",
74
+ "Writes modelProvider=gemini into ~/.gemini/antigravity-cli/settings.json. Routes any coding model via PleumRouter.":
75
+ "将 modelProvider=gemini 写入 ~/.gemini/antigravity-cli/settings.json。通过 PleumRouter 路由所有编码模型。",
76
+ "↑ {n} more above": "↑ 上方还有 {n} 项",
77
+ "↓ {n} more below": "↓ 下方还有 {n} 项",
78
+ "Auto (best route)": "Auto (最佳自动路由)",
79
+ "PleumRouter auto-routing — lowest price, lowest latency, automatic failover":
80
+ "PleumRouter 自动路由 — 最优价格·超低延迟·自动故障转移",
81
+ "Pin to {provider} (@{provider})": "固定到 {provider} 提供商 (@{provider})",
82
+ "Select routing provider": "选择路由提供商",
83
+ "... view all 200+ models": "... 查看全部 200+ 个模型",
76
84
  "Uses an isolated ~/.pleum/pi-home (PI_CODING_AGENT_DIR). Your ~/.pi is untouched.":
77
85
  "只用隔离的 ~/.pleum/pi-home(PI_CODING_AGENT_DIR),不改 ~/.pi。",
78
86
  "Uses an isolated ~/.pleum/crush-home (CRUSH_GLOBAL_CONFIG). Your crush.json is untouched.":
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
16
  import { readConfig, writeConfig, isLoggedIn, touchSession, getActiveContext } 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";
@@ -123,24 +123,81 @@ function saveLastSelection(id) {
123
123
  writeConfig(cfg);
124
124
  }
125
125
 
126
+ export async function pickProviderForModel(modelId, { input = process.stdin, output = process.stdout } = {}) {
127
+ if (!modelId || modelId === "__view_all__") return modelId;
128
+ const providers = CORE_MODEL_PROVIDERS[modelId] || [];
129
+ if (!providers.length) return modelId;
130
+
131
+ const options = [
132
+ {
133
+ id: null,
134
+ title: `⚡ ${t("Auto (best route)")}`,
135
+ desc: t("PleumRouter auto-routing — lowest price, lowest latency, automatic failover"),
136
+ },
137
+ ...providers.map((p) => ({
138
+ id: p.id,
139
+ title: p.name,
140
+ desc: t("Pin to {provider} (@{provider})", { provider: p.id }),
141
+ })),
142
+ ];
143
+
144
+ const chosen = await select(options, {
145
+ message: `${modelId} — ${t("Select routing provider")}`,
146
+ formatItem: (x) => `${x.title} ${style.dim(x.desc || "")}`,
147
+ limit: 8,
148
+ input,
149
+ output,
150
+ });
151
+
152
+ if (!chosen) return null;
153
+ if (!chosen.id) return modelId;
154
+ return `${modelId}@${chosen.id}`;
155
+ }
156
+
126
157
  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;
158
+ for (;;) {
159
+ const core = fetchCoreCodingModels();
160
+ const items = [
161
+ ...core,
162
+ {
163
+ id: "__view_all__",
164
+ display_name: t("... view all 200+ models"),
165
+ },
166
+ ];
167
+ const targetId = defaultModel || DEFAULT_MODEL;
168
+ const initialIndex = items.findIndex((x) => x.id === targetId || targetId.startsWith(`${x.id}@`));
169
+ const m = await select(items, {
170
+ message: `${item.title} — ${t("select a model")}`,
171
+ formatItem: (x) => (x.id === "__view_all__" ? style.dim(x.display_name) : formatModelLabel(x)),
172
+ matches: matchModels,
173
+ limit: 11,
174
+ initialCursor: initialIndex >= 0 ? initialIndex : 0,
175
+ });
176
+ if (!m) return undefined;
177
+
178
+ let pickedModelId = m.id;
179
+ 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
+ const allPicked = await select(allModels, {
188
+ message: `${item.title} — ${t("select a model")}`,
189
+ formatItem: (x) => formatModelLabel(x),
190
+ matches: matchModels,
191
+ limit: 10,
192
+ });
193
+ if (!allPicked) continue;
194
+ pickedModelId = allPicked.id;
195
+ }
196
+
197
+ const withProvider = await pickProviderForModel(pickedModelId);
198
+ if (!withProvider) continue;
199
+ return withProvider;
133
200
  }
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,
141
- initialCursor: initialIndex >= 0 ? initialIndex : 0,
142
- });
143
- return m?.id;
144
201
  }
145
202
 
146
203
  // 미설치 도구: ollama처럼 확인 후 그 자리에서 설치(stdio 상속). 성공 시 true.
@@ -184,6 +241,7 @@ export async function runList(items, { header, footer, initialCursor = 0 } = {})
184
241
  if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return undefined;
185
242
 
186
243
  let cursor = Math.max(0, Math.min(initialCursor, items.length - 1));
244
+ let windowStart = 0;
187
245
  let lastRows = 0;
188
246
 
189
247
  const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
@@ -192,28 +250,68 @@ export async function runList(items, { header, footer, initialCursor = 0 } = {})
192
250
  output.write("\r" + (lastRows > 1 ? `\x1b[${lastRows - 1}A` : "") + "\x1b[0J");
193
251
  lastRows = 0;
194
252
  };
253
+
254
+ const getMaxVisible = () => {
255
+ const h = terminalHeight();
256
+ return Math.max(3, Math.min(9, Math.floor((h - 6) / 2)));
257
+ };
258
+
195
259
  const render = () => {
260
+ const maxVisible = getMaxVisible();
261
+ if (cursor < windowStart) {
262
+ windowStart = cursor;
263
+ } else if (cursor >= windowStart + maxVisible) {
264
+ windowStart = cursor - maxVisible + 1;
265
+ }
266
+ windowStart = Math.max(0, Math.min(windowStart, Math.max(0, items.length - maxVisible)));
267
+
268
+ const visibleItems = items.slice(windowStart, windowStart + maxVisible);
196
269
  const lines = [
197
270
  header,
198
271
  "",
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
272
  ];
273
+
274
+ if (windowStart > 0) {
275
+ lines.push(style.dim(` ${t("↑ {n} more above", { n: windowStart })}`));
276
+ }
277
+
278
+ for (let idx = 0; idx < visibleItems.length; idx++) {
279
+ const i = windowStart + idx;
280
+ const it = visibleItems[idx];
281
+ if (i === cursor) {
282
+ lines.push(`${style.plum("▸")} ${style.bold(it.title)}`);
283
+ lines.push(` ${style.dim(it.desc)}`);
284
+ } else {
285
+ lines.push(` ${it.title}`);
286
+ lines.push(` ${style.dim(it.desc)}`);
287
+ }
288
+ }
289
+
290
+ const remainingBelow = items.length - (windowStart + visibleItems.length);
291
+ if (remainingBelow > 0) {
292
+ lines.push(style.dim(` ${t("↓ {n} more below", { n: remainingBelow })}`));
293
+ }
294
+
295
+ lines.push("");
296
+ lines.push(style.dim(footer));
297
+
207
298
  erase();
208
299
  output.write(lines.join("\n"));
209
300
  lastRows = lines.reduce((n, l) => n + rowsFor(l), 0);
210
301
  };
302
+
211
303
  const move = (dir) => {
212
304
  cursor = Math.max(0, Math.min(items.length - 1, cursor + dir));
213
305
  };
214
306
 
215
307
  return new Promise((resolve) => {
308
+ const onResize = () => {
309
+ render();
310
+ };
311
+ output.on("resize", onResize);
312
+
216
313
  const suspend = () => {
314
+ output.off("resize", onResize);
217
315
  input.off("data", onData);
218
316
  input.setRawMode(false);
219
317
  output.write("\x1b[?25h");
@@ -223,10 +321,12 @@ export async function runList(items, { header, footer, initialCursor = 0 } = {})
223
321
  input.setRawMode(true);
224
322
  input.resume();
225
323
  output.write("\x1b[?25l");
324
+ output.on("resize", onResize);
226
325
  input.on("data", onData);
227
326
  render();
228
327
  };
229
328
  const finish = (value) => {
329
+ output.off("resize", onResize);
230
330
  suspend();
231
331
  input.pause();
232
332
  resolve(value);
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·목록이 공유하는 단일 구현.
package/src/recipes.mjs CHANGED
@@ -7,12 +7,12 @@ export const CORE_MODELS = [
7
7
  "claude-sonnet-5",
8
8
  "claude-opus-5-5",
9
9
  "gpt-6-astra",
10
- "claude-fable-5-1",
10
+ "gpt-6-sol",
11
+ "gpt-6-luna",
11
12
  "gemini-3.8-flash",
13
+ "qwen3.8-max",
12
14
  "glm-5.3-flash",
13
15
  "kimi-k3",
14
- "gpt-6-sol",
15
- "gpt-6-luna",
16
16
  ];
17
17
 
18
18
  // 도구별 설정의 진실 원천. 출처: frontend .../docs/cookbook/{codex,claude-code,...} + 에이전트별 공식 문서.
@@ -48,6 +48,8 @@ export const RECIPES = {
48
48
  ANTHROPIC_AUTH_TOKEN: key,
49
49
  // /model 이 GET {ANTHROPIC_BASE_URL}/v1/models 를 치게 한다. 기본값은 꺼져 있다.
50
50
  CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
51
+ ANTHROPIC_SMALL_MODEL: "anthropic/deepseek-ai/DeepSeek-V4.1-Flash",
52
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: "anthropic/deepseek-ai/DeepSeek-V4.1-Flash",
51
53
  }),
52
54
  args: (m) => ["--model", (m || DEFAULT_MODEL).startsWith("anthropic/") ? (m || DEFAULT_MODEL) : `anthropic/${m || DEFAULT_MODEL}`],
53
55
  },
@@ -63,6 +65,55 @@ export const RECIPES = {
63
65
  env: (key) => ({ PLEUM_API_KEY: key }),
64
66
  args: () => [],
65
67
  },
68
+ agy: {
69
+ label: "Antigravity CLI",
70
+ bin: "agy",
71
+ type: "agy-settings",
72
+ install: "https://antigravity.google/cli/install.sh",
73
+ note: "Writes modelProvider=gemini into ~/.gemini/antigravity-cli/settings.json. Routes any coding model via PleumRouter.",
74
+ attribution: {
75
+ title: "Antigravity CLI",
76
+ referer: "https://www.antigravity.google/docs/cli/install",
77
+ },
78
+ env: (key) => ({
79
+ GOOGLE_GEMINI_BASE_URL: ROOT,
80
+ GEMINI_API_KEY: key,
81
+ GOOGLE_API_KEY: key,
82
+ }),
83
+ args: (m) => ["--model", m || DEFAULT_MODEL],
84
+ },
85
+ opencode: {
86
+ label: "opencode",
87
+ bin: "opencode",
88
+ type: "env",
89
+ install: "npm i -g opencode-ai@latest",
90
+ // opencode는 커스텀 엔드포인트를 opencode.json provider 블록으로만 읽는다 — env var는 없다.
91
+ // 대신 OPENCODE_CONFIG_CONTENT(파일 대신 JSON 문자열을 그대로 읽는 env var)로 파일 없이 주입한다.
92
+ note: "Generates a temporary PleumRouter provider config for this run only — your own opencode.json is untouched.",
93
+ attribution: {
94
+ title: "opencode",
95
+ referer: "https://opencode.ai",
96
+ },
97
+ env: (key, model) => {
98
+ const active = model || DEFAULT_MODEL;
99
+ const models = Object.fromEntries(CORE_MODELS.map((id) => [id, {}]));
100
+ models[active] = {};
101
+ return {
102
+ PLEUM_API_KEY: key,
103
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
104
+ provider: {
105
+ pleum: {
106
+ npm: "@ai-sdk/openai-compatible",
107
+ name: "PleumRouter",
108
+ options: { baseURL: V1, apiKey: "{env:PLEUM_API_KEY}" },
109
+ models,
110
+ },
111
+ },
112
+ }),
113
+ };
114
+ },
115
+ args: (m) => ["--model", `pleum/${m || DEFAULT_MODEL}`],
116
+ },
66
117
  aider: {
67
118
  label: "Aider",
68
119
  bin: "aider",
@@ -101,38 +152,6 @@ export const RECIPES = {
101
152
  env: (key) => ({ OPENAI_API_BASE: V1, OPENAI_API_KEY: key }),
102
153
  args: () => [],
103
154
  },
104
- opencode: {
105
- label: "opencode",
106
- bin: "opencode",
107
- type: "env",
108
- install: "npm i -g opencode-ai@latest",
109
- // opencode는 커스텀 엔드포인트를 opencode.json provider 블록으로만 읽는다 — env var는 없다.
110
- // 대신 OPENCODE_CONFIG_CONTENT(파일 대신 JSON 문자열을 그대로 읽는 env var)로 파일 없이 주입한다.
111
- note: "Generates a temporary PleumRouter provider config for this run only — your own opencode.json is untouched.",
112
- attribution: {
113
- title: "opencode",
114
- referer: "https://opencode.ai",
115
- },
116
- env: (key, model) => {
117
- const active = model || DEFAULT_MODEL;
118
- const models = Object.fromEntries(CORE_MODELS.map((id) => [id, {}]));
119
- models[active] = {};
120
- return {
121
- PLEUM_API_KEY: key,
122
- OPENCODE_CONFIG_CONTENT: JSON.stringify({
123
- provider: {
124
- pleum: {
125
- npm: "@ai-sdk/openai-compatible",
126
- name: "PleumRouter",
127
- options: { baseURL: V1, apiKey: "{env:PLEUM_API_KEY}" },
128
- models,
129
- },
130
- },
131
- }),
132
- };
133
- },
134
- args: (m) => ["--model", `pleum/${m || DEFAULT_MODEL}`],
135
- },
136
155
  omp: {
137
156
  label: "omp (Oh My Pi)",
138
157
  bin: "omp",
@@ -211,23 +230,6 @@ export const RECIPES = {
211
230
  }),
212
231
  args: (m) => ["--model", (m || DEFAULT_MODEL).startsWith("google/") ? (m || DEFAULT_MODEL) : `google/${m || DEFAULT_MODEL}`],
213
232
  },
214
- agy: {
215
- label: "Antigravity CLI",
216
- bin: "agy",
217
- type: "agy-settings",
218
- install: "https://antigravity.google/cli/install.sh",
219
- note: "Writes modelProvider=gemini into ~/.gemini/antigravity-cli/settings.json. GEMINI_API_KEY alone is ignored. Gemini-family models only.",
220
- attribution: {
221
- title: "Antigravity CLI",
222
- referer: "https://www.antigravity.google/docs/cli/install",
223
- },
224
- env: (key) => ({
225
- GOOGLE_GEMINI_BASE_URL: ROOT,
226
- GEMINI_API_KEY: key,
227
- GOOGLE_API_KEY: key,
228
- }),
229
- args: (m) => ["--model", (m || DEFAULT_MODEL).startsWith("google/") ? (m || DEFAULT_MODEL) : `google/${m || DEFAULT_MODEL}`],
230
- },
231
233
  pi: {
232
234
  label: "Pi",
233
235
  bin: "pi",