pleumrouter 0.3.0 → 0.4.0
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 +2 -0
- package/bin/pleum.mjs +51 -32
- package/package.json +1 -1
- package/src/ansi.mjs +9 -2
- package/src/balance.mjs +5 -4
- package/src/completion.mjs +8 -6
- package/src/config.mjs +2 -1
- package/src/health.mjs +9 -8
- package/src/i18n.mjs +37 -0
- package/src/interactive.mjs +28 -9
- package/src/lang.mjs +33 -0
- package/src/launch.mjs +40 -30
- package/src/locales/es.mjs +201 -0
- package/src/locales/ja.mjs +201 -0
- package/src/locales/ko.mjs +261 -0
- package/src/locales/zh.mjs +201 -0
- package/src/login.mjs +13 -12
- package/src/menu.mjs +327 -0
- package/src/models.mjs +12 -11
- package/src/recipes.mjs +81 -23
- package/src/run.mjs +41 -40
- package/src/top.mjs +7 -6
- package/src/usage.mjs +10 -9
package/src/run.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { fetchModels, formatTable, matchModels } from "./models.mjs";
|
|
|
6
6
|
import { style, divider } from "./ansi.mjs";
|
|
7
7
|
import { createMarkdownStream } from "./markdown.mjs";
|
|
8
8
|
import { spinner, select } from "./interactive.mjs";
|
|
9
|
+
import { t } from "./i18n.mjs";
|
|
9
10
|
|
|
10
11
|
const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
|
11
12
|
|
|
@@ -67,14 +68,14 @@ export async function run(rest) {
|
|
|
67
68
|
if (!model && !oneShot) {
|
|
68
69
|
if (!process.stdin.isTTY) {
|
|
69
70
|
// 비대화형(non-TTY) stdin이면 인터랙티브 모델 선택이 멈춰버리므로 명확히 안내하고 종료(L10).
|
|
70
|
-
console.error('
|
|
71
|
+
console.error(t('Specify a model: pleum run <model> ["prompt"] (model selection isn\'t available on non-interactive stdin)'));
|
|
71
72
|
process.exit(1);
|
|
72
73
|
}
|
|
73
74
|
model = await pickModel(); // 인터랙티브 모델 선택
|
|
74
75
|
if (!model) process.exit(1);
|
|
75
76
|
}
|
|
76
77
|
if (!model) {
|
|
77
|
-
console.error('
|
|
78
|
+
console.error(t('Usage: pleum run <model> ["prompt"]\nModel list: pleum models'));
|
|
78
79
|
process.exit(1);
|
|
79
80
|
}
|
|
80
81
|
|
|
@@ -84,7 +85,7 @@ export async function run(rest) {
|
|
|
84
85
|
if (Array.isArray(content)) {
|
|
85
86
|
const n = content.filter((p) => p.type === "image_url").length;
|
|
86
87
|
// 프롬프트 내 로컬 이미지 경로를 자동 첨부·업로드하므로 조용히 올리지 않고 명시 고지한다(M8).
|
|
87
|
-
console.error(`ℹ
|
|
88
|
+
console.error(`ℹ ${t("Attaching and uploading {n} local image(s) from the prompt.", { n })}`);
|
|
88
89
|
}
|
|
89
90
|
const messages = [{ role: "user", content }];
|
|
90
91
|
try {
|
|
@@ -204,7 +205,7 @@ async function repl(key, initialModel, initialVerbose) {
|
|
|
204
205
|
const onClose = () => {
|
|
205
206
|
if (pickerActive) return;
|
|
206
207
|
saveHistory(rl.history);
|
|
207
|
-
console.log(`\n${style.dim("
|
|
208
|
+
console.log(`\n${style.dim(t("Goodbye."))}`);
|
|
208
209
|
process.exit(0);
|
|
209
210
|
};
|
|
210
211
|
|
|
@@ -220,7 +221,7 @@ async function repl(key, initialModel, initialVerbose) {
|
|
|
220
221
|
return;
|
|
221
222
|
}
|
|
222
223
|
lastSigint = now;
|
|
223
|
-
console.log(`\n${style.dim("
|
|
224
|
+
console.log(`\n${style.dim(t("Ctrl+C again to quit (or /exit)"))}`);
|
|
224
225
|
rl.prompt();
|
|
225
226
|
};
|
|
226
227
|
|
|
@@ -240,23 +241,23 @@ async function repl(key, initialModel, initialVerbose) {
|
|
|
240
241
|
|
|
241
242
|
makeRl(loadHistory());
|
|
242
243
|
console.log(`${style.bold(style.plum("PleumRouter"))} ${style.dim("·")} ${model}`);
|
|
243
|
-
console.log(style.dim('/help
|
|
244
|
+
console.log(style.dim(t('/help for commands · /exit to quit · wrap multi-line input in """ · Ctrl+C to stop generation')));
|
|
244
245
|
console.log(divider());
|
|
245
246
|
rl.prompt();
|
|
246
247
|
}
|
|
247
248
|
|
|
248
|
-
const SLASH_HELP =
|
|
249
|
-
/help, /?
|
|
250
|
-
/clear
|
|
251
|
-
/system [
|
|
252
|
-
/model [id]
|
|
253
|
-
/models [
|
|
254
|
-
/save [
|
|
255
|
-
/load [
|
|
256
|
-
/verbose
|
|
257
|
-
/exit, /quit
|
|
258
|
-
|
|
259
|
-
|
|
249
|
+
const SLASH_HELP = t(`Commands:
|
|
250
|
+
/help, /? this help
|
|
251
|
+
/clear reset the conversation context (keeps the system prompt)
|
|
252
|
+
/system [text] set the system prompt (no arg shows the current value)
|
|
253
|
+
/model [id] switch models mid-session (no arg = arrow-key picker)
|
|
254
|
+
/models [query] browse the model list
|
|
255
|
+
/save [name] save the current session (default: last)
|
|
256
|
+
/load [name] load a saved session
|
|
257
|
+
/verbose toggle token/speed stats
|
|
258
|
+
/exit, /quit quit
|
|
259
|
+
Multi-line input: a line with """ → body → a line with """
|
|
260
|
+
Images: include an image file path right in the message (e.g. describe this ./shot.png)`);
|
|
260
261
|
|
|
261
262
|
async function handleSlash(text, ctx) {
|
|
262
263
|
const [cmd] = text.slice(1).split(/\s+/);
|
|
@@ -268,37 +269,37 @@ async function handleSlash(text, ctx) {
|
|
|
268
269
|
break;
|
|
269
270
|
case "clear":
|
|
270
271
|
ctx.clear();
|
|
271
|
-
console.log(`${style.green("✓")}
|
|
272
|
+
console.log(`${style.green("✓")} ${t("Context cleared")}`);
|
|
272
273
|
break;
|
|
273
274
|
case "system":
|
|
274
275
|
if (arg) {
|
|
275
276
|
ctx.system = arg;
|
|
276
|
-
console.log(`${style.green("✓")}
|
|
277
|
+
console.log(`${style.green("✓")} ${t("System prompt set")}`);
|
|
277
278
|
} else {
|
|
278
|
-
console.log(ctx.system ?
|
|
279
|
+
console.log(ctx.system ? `${t("Current system prompt:")}\n${ctx.system}` : t("No system prompt"));
|
|
279
280
|
}
|
|
280
281
|
break;
|
|
281
282
|
case "model":
|
|
282
283
|
if (arg) {
|
|
283
284
|
ctx.model = arg;
|
|
284
|
-
console.log(`${style.green("✓")}
|
|
285
|
+
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(arg)}`);
|
|
285
286
|
} else if (ctx.pickModel) {
|
|
286
287
|
const picked = await ctx.pickModel();
|
|
287
288
|
if (picked) {
|
|
288
289
|
ctx.model = picked;
|
|
289
|
-
console.log(`${style.green("✓")}
|
|
290
|
+
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(picked)}`);
|
|
290
291
|
} else {
|
|
291
|
-
console.log(
|
|
292
|
+
console.log(`${t("Current model:")} ${style.plum(ctx.model)}`);
|
|
292
293
|
}
|
|
293
294
|
} else {
|
|
294
|
-
console.log(
|
|
295
|
+
console.log(`${t("Current model:")} ${ctx.model}\n${t("switch: /model <id> · list: /models")}`);
|
|
295
296
|
}
|
|
296
297
|
break;
|
|
297
298
|
case "models":
|
|
298
299
|
try {
|
|
299
300
|
const models = await fetchModels();
|
|
300
301
|
const rows = matchModels(models, arg);
|
|
301
|
-
console.log(rows.length ? formatTable(rows) : "
|
|
302
|
+
console.log(rows.length ? formatTable(rows) : t("No matching models"));
|
|
302
303
|
} catch (e) {
|
|
303
304
|
console.error(`${style.red("✗")} ${e.message}`);
|
|
304
305
|
}
|
|
@@ -306,25 +307,25 @@ async function handleSlash(text, ctx) {
|
|
|
306
307
|
case "save":
|
|
307
308
|
try {
|
|
308
309
|
const name = saveSession(arg || "last", ctx.snapshot());
|
|
309
|
-
console.log(`${style.green("✓")}
|
|
310
|
+
console.log(`${style.green("✓")} ${t("Session saved:")} ${name}`);
|
|
310
311
|
} catch (e) {
|
|
311
|
-
console.error(`${style.red("✗")}
|
|
312
|
+
console.error(`${style.red("✗")} ${t("Save failed: {error}", { error: e.message })}`);
|
|
312
313
|
}
|
|
313
314
|
break;
|
|
314
315
|
case "load":
|
|
315
316
|
try {
|
|
316
317
|
ctx.restore(loadSession(arg || "last"));
|
|
317
|
-
console.log(`${style.green("✓")}
|
|
318
|
+
console.log(`${style.green("✓")} ${t("Session loaded (model: {model})", { model: ctx.model })}`);
|
|
318
319
|
} catch {
|
|
319
|
-
console.error(`${style.red("✗")}
|
|
320
|
+
console.error(`${style.red("✗")} ${t("No such session: {name}", { name: arg || "last" })}`);
|
|
320
321
|
}
|
|
321
322
|
break;
|
|
322
323
|
case "verbose":
|
|
323
324
|
ctx.verbose = !ctx.verbose;
|
|
324
|
-
console.log(`${style.green("✓")}
|
|
325
|
+
console.log(`${style.green("✓")} ${t("Stats {state}", { state: ctx.verbose ? t("on") : t("off") })}`);
|
|
325
326
|
break;
|
|
326
327
|
default:
|
|
327
|
-
console.log(
|
|
328
|
+
console.log(t("Unknown command: /{cmd} (/help)", { cmd }));
|
|
328
329
|
}
|
|
329
330
|
}
|
|
330
331
|
|
|
@@ -332,7 +333,7 @@ async function handleSlash(text, ctx) {
|
|
|
332
333
|
// 비용은 마지막 SSE 청크의 cost.krw — 스트리밍 응답엔 X-Cost-Krw 헤더가 없다(백엔드 계약).
|
|
333
334
|
export async function streamChat(key, model, messages, { verbose = false, quiet = false, signal, fetchImpl = fetch } = {}) {
|
|
334
335
|
const t0 = Date.now();
|
|
335
|
-
const sp = spinner(
|
|
336
|
+
const sp = spinner(t("{model} thinking…", { model }));
|
|
336
337
|
let res;
|
|
337
338
|
try {
|
|
338
339
|
res = await fetchImpl(`${V1}/chat/completions`, {
|
|
@@ -344,7 +345,7 @@ export async function streamChat(key, model, messages, { verbose = false, quiet
|
|
|
344
345
|
} catch (e) {
|
|
345
346
|
sp.stop();
|
|
346
347
|
if (isAbort(e)) {
|
|
347
|
-
console.error(style.dim("⏹
|
|
348
|
+
console.error(style.dim(t("⏹ aborted")));
|
|
348
349
|
return "";
|
|
349
350
|
}
|
|
350
351
|
throw e;
|
|
@@ -400,7 +401,7 @@ export async function streamChat(key, model, messages, { verbose = false, quiet
|
|
|
400
401
|
}
|
|
401
402
|
sp.stop();
|
|
402
403
|
md.finish();
|
|
403
|
-
if (aborted) console.error(style.dim("⏹
|
|
404
|
+
if (aborted) console.error(style.dim(t("⏹ generation aborted — the partial reply stays in context")));
|
|
404
405
|
|
|
405
406
|
const ms = Date.now() - t0;
|
|
406
407
|
const genSec = (Date.now() - (firstAt || t0)) / 1000;
|
|
@@ -421,13 +422,13 @@ async function pickModel() {
|
|
|
421
422
|
try {
|
|
422
423
|
models = await fetchModels();
|
|
423
424
|
} catch (e) {
|
|
424
|
-
console.error(`${style.red("✗")}
|
|
425
|
+
console.error(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
|
|
425
426
|
return null;
|
|
426
427
|
}
|
|
427
428
|
if (!models.length) return null;
|
|
428
429
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
429
430
|
return select(models, {
|
|
430
|
-
message: "
|
|
431
|
+
message: t("Select a model"),
|
|
431
432
|
formatItem: (m) => `${m.id}${m.display_name ? ` ${style.dim(m.display_name)}` : ""}`,
|
|
432
433
|
matches: matchModels,
|
|
433
434
|
limit: 12,
|
|
@@ -447,14 +448,14 @@ async function pickModelNumbered(models) {
|
|
|
447
448
|
shown.forEach((m, i) =>
|
|
448
449
|
console.log(` ${String(i + 1).padStart(2)}. ${m.id}${m.display_name ? ` (${m.display_name})` : ""}`),
|
|
449
450
|
);
|
|
450
|
-
if (pool.length > shown.length) console.log(` …
|
|
451
|
-
const ans = (await ask("
|
|
451
|
+
if (pool.length > shown.length) console.log(` ${t("… {n} more — narrow with a search term", { n: pool.length - shown.length })}`);
|
|
452
|
+
const ans = (await ask(`\n${t("Choose a number, or search (Enter to cancel): ")}`)).trim();
|
|
452
453
|
if (!ans) return null;
|
|
453
454
|
const n = Number(ans);
|
|
454
455
|
if (Number.isInteger(n) && n >= 1 && n <= shown.length) return shown[n - 1].id;
|
|
455
456
|
const next = matchModels(models, ans);
|
|
456
457
|
pool = next.length ? next : models;
|
|
457
|
-
if (!next.length) console.log("
|
|
458
|
+
if (!next.length) console.log(t("No matches — showing the full list."));
|
|
458
459
|
}
|
|
459
460
|
} finally {
|
|
460
461
|
rl.close();
|
package/src/top.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { V1 } from "./config.mjs";
|
|
5
5
|
import { style, padDisplay, barChart, divider } from "./ansi.mjs";
|
|
6
|
+
import { t } from "./i18n.mjs";
|
|
6
7
|
|
|
7
8
|
export function parseTopArgs(rest = []) {
|
|
8
9
|
let period = "weekly";
|
|
@@ -11,7 +12,7 @@ export function parseTopArgs(rest = []) {
|
|
|
11
12
|
else if (["daily", "weekly", "monthly"].includes(rest[i])) period = rest[i];
|
|
12
13
|
}
|
|
13
14
|
if (!["daily", "weekly", "monthly"].includes(period)) {
|
|
14
|
-
throw new Error(
|
|
15
|
+
throw new Error(t("period must be one of daily|weekly|monthly: {period}", { period }));
|
|
15
16
|
}
|
|
16
17
|
return { period };
|
|
17
18
|
}
|
|
@@ -25,14 +26,14 @@ const fmtTok = (n) => {
|
|
|
25
26
|
|
|
26
27
|
// rankings 응답 → 표시 문자열(순수 함수 — fixture 테스트 대상).
|
|
27
28
|
export function renderTop(data, styler = style) {
|
|
28
|
-
const
|
|
29
|
+
const periodLabel = { daily: t("daily"), weekly: t("weekly"), monthly: t("monthly") }[data.period] || data.period;
|
|
29
30
|
const lines = [];
|
|
30
|
-
lines.push(`${styler.bold(styler.plum("
|
|
31
|
+
lines.push(`${styler.bold(styler.plum(t("Model leaderboard")))} ${styler.dim(`· ${periodLabel} · ${t("tokens")} ${fmtTok(data.total_tokens ?? 0)}`)}`);
|
|
31
32
|
lines.push(divider(undefined, styler));
|
|
32
33
|
|
|
33
34
|
const board = data.leaderboard || [];
|
|
34
35
|
if (!board.length) {
|
|
35
|
-
lines.push(styler.dim("
|
|
36
|
+
lines.push(styler.dim(t("No data")));
|
|
36
37
|
} else {
|
|
37
38
|
const idW = Math.max(...board.map((r) => r.model.length), 5);
|
|
38
39
|
for (const r of board) {
|
|
@@ -53,7 +54,7 @@ export function renderTop(data, styler = style) {
|
|
|
53
54
|
const share = data.market_share || [];
|
|
54
55
|
if (share.length) {
|
|
55
56
|
lines.push("");
|
|
56
|
-
lines.push(styler.bold("
|
|
57
|
+
lines.push(styler.bold(t("Provider share")));
|
|
57
58
|
const nameW = Math.max(...share.map((s) => s.provider.length), 8);
|
|
58
59
|
for (const s of share) {
|
|
59
60
|
lines.push(
|
|
@@ -67,6 +68,6 @@ export function renderTop(data, styler = style) {
|
|
|
67
68
|
export async function top(rest) {
|
|
68
69
|
const { period } = parseTopArgs(rest);
|
|
69
70
|
const res = await fetch(`${V1}/stats/rankings?period=${period}`);
|
|
70
|
-
if (!res.ok) throw new Error(
|
|
71
|
+
if (!res.ok) throw new Error(t("Failed to fetch leaderboard ({status})", { status: res.status }));
|
|
71
72
|
console.log(renderTop(await res.json()));
|
|
72
73
|
}
|
package/src/usage.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { V1, requireKey } from "./config.mjs";
|
|
5
5
|
import { style, padDisplay, barChart, divider } from "./ansi.mjs";
|
|
6
|
+
import { t } from "./i18n.mjs";
|
|
6
7
|
|
|
7
8
|
const GROUPS = ["model", "provider", "api_key", "day"];
|
|
8
9
|
|
|
@@ -13,8 +14,8 @@ export function parseUsageArgs(rest = []) {
|
|
|
13
14
|
if (rest[i] === "--by") by = rest[++i];
|
|
14
15
|
else if (rest[i] === "--days") days = Number(rest[++i]);
|
|
15
16
|
}
|
|
16
|
-
if (!GROUPS.includes(by)) throw new Error(
|
|
17
|
-
if (!Number.isInteger(days) || days < 1 || days > 365) throw new Error(
|
|
17
|
+
if (!GROUPS.includes(by)) throw new Error(t("--by must be one of {groups}: {by}", { groups: GROUPS.join("|"), by }));
|
|
18
|
+
if (!Number.isInteger(days) || days < 1 || days > 365) throw new Error(t("--days must be an integer 1-365: {days}", { days }));
|
|
18
19
|
return { by, days };
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -23,12 +24,12 @@ const fmtKrw = (n) => `₩${Math.round(n).toLocaleString("en-US")}`;
|
|
|
23
24
|
// report 응답 → 표시 문자열(순수 함수 — fixture 테스트 대상).
|
|
24
25
|
export function renderUsage(data, styler = style) {
|
|
25
26
|
const lines = [];
|
|
26
|
-
const
|
|
27
|
-
lines.push(`${styler.bold(styler.plum("
|
|
27
|
+
const byLabel = { model: t("by model"), provider: t("by provider"), api_key: t("by key"), day: t("by day") }[data.group_by] || data.group_by;
|
|
28
|
+
lines.push(`${styler.bold(styler.plum(t("Usage")))} ${styler.dim(`· ${byLabel} · ${t("last {days} days", { days: data.days })}`)}`);
|
|
28
29
|
lines.push(divider(undefined, styler));
|
|
29
30
|
const items = data.items || [];
|
|
30
31
|
if (!items.length) {
|
|
31
|
-
lines.push(styler.dim("
|
|
32
|
+
lines.push(styler.dim(t("No usage history")));
|
|
32
33
|
return lines.join("\n");
|
|
33
34
|
}
|
|
34
35
|
const max = Math.max(...items.map((i) => i.cost_krw), 1);
|
|
@@ -43,7 +44,7 @@ export function renderUsage(data, styler = style) {
|
|
|
43
44
|
);
|
|
44
45
|
}
|
|
45
46
|
lines.push(divider(undefined, styler));
|
|
46
|
-
lines.push(`${padDisplay("
|
|
47
|
+
lines.push(`${padDisplay(t("Total"), keyW)} ${" ".repeat(20)} ${styler.bold(fmtKrw(total.cost))}${styler.dim(` ${total.calls} calls`)}`);
|
|
47
48
|
return lines.join("\n");
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -56,14 +57,14 @@ export async function usage(rest) {
|
|
|
56
57
|
if (res.status === 403) {
|
|
57
58
|
// 구버전 로그인 키(usage:read 스코프 없음) — 재로그인으로 새 키를 받으면 해결.
|
|
58
59
|
console.error(
|
|
59
|
-
`${style.red("✗")}
|
|
60
|
-
` ${style.dim("pleum login
|
|
60
|
+
`${style.red("✗")} ${t("This API key doesn't have usage:read permission.")}\n` +
|
|
61
|
+
` ${style.dim(t("Run pleum login again to get a new key (recent keys include it by default)."))}`,
|
|
61
62
|
);
|
|
62
63
|
process.exit(1);
|
|
63
64
|
}
|
|
64
65
|
if (!res.ok) {
|
|
65
66
|
const body = await res.text().catch(() => "");
|
|
66
|
-
throw new Error(
|
|
67
|
+
throw new Error(`${t("Failed to fetch usage ({status})", { status: res.status })}${body ? `: ${body.slice(0, 200)}` : ""}`);
|
|
67
68
|
}
|
|
68
69
|
console.log(renderUsage(await res.json()));
|
|
69
70
|
}
|