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/README.md +6 -4
- package/bin/pleum.mjs +5 -2
- package/package.json +1 -1
- package/src/config.mjs +11 -14
- package/src/epipe.mjs +10 -0
- package/src/install.mjs +65 -0
- package/src/launch.mjs +125 -9
- package/src/locales/es.mjs +17 -55
- package/src/locales/ja.mjs +17 -55
- package/src/locales/ko.mjs +17 -59
- package/src/locales/zh.mjs +17 -55
- package/src/login.mjs +24 -12
- package/src/menu.mjs +80 -167
- package/src/models.mjs +10 -134
- package/src/recipes.mjs +40 -42
- package/src/settings.mjs +0 -1
- package/src/update.mjs +8 -3
- package/src/wire.mjs +13 -3
- package/src/upgrade.mjs +0 -10
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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" }
|
|
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(
|
|
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 =
|
|
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((
|
|
138
|
-
id:
|
|
139
|
-
title:
|
|
140
|
-
desc: t("Pin to {provider} (@{provider})", { provider
|
|
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 =
|
|
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
|
|
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
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
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
|
|
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
|
|
407
|
+
footer: t("↑/↓ move • enter select • esc back"),
|
|
511
408
|
});
|
|
512
409
|
}
|
|
513
410
|
|
|
514
|
-
//
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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();
|