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/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('모델을 지정하세요: pleum run <model> ["프롬프트"] (비대화형 stdin에선 모델 선택 불가)');
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('사용법: pleum run <model> ["프롬프트"]\n모델 목록: pleum models');
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(`ℹ 프롬프트의 로컬 이미지 ${n}개를 첨부해 업로드합니다.`);
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("한 번 더 Ctrl+C 하면 종료 (/exit 도 가능)")}`);
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 명령 · /exit 종료 · 여러 줄은 """ 감싸기 · 생성 중 Ctrl+C=중단'));
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 [이름] 현재 세션 저장(기본 last)
255
- /load [이름] 세션 불러오기
256
- /verbose 토큰·속도 통계 표시 토글
257
- /exit, /quit 종료
258
- 여러 줄 입력: """ 한 줄 → 본문 → """ 한 줄
259
- 이미지: 메시지에 이미지 파일 경로를 그대로 포함(예: 이 그림 설명해줘 ./shot.png)`;
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 ? `현재 시스템 프롬프트:\n${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("✓")} 모델 전환 → ${style.plum(arg)}`);
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("✓")} 모델 전환 → ${style.plum(picked)}`);
290
+ console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(picked)}`);
290
291
  } else {
291
- console.log(`현재 모델: ${style.plum(ctx.model)}`);
292
+ console.log(`${t("Current model:")} ${style.plum(ctx.model)}`);
292
293
  }
293
294
  } else {
294
- console.log(`현재 모델: ${ctx.model}\n전환: /model <id> · 목록: /models`);
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("✓")} 세션 저장: ${name}`);
310
+ console.log(`${style.green("✓")} ${t("Session saved:")} ${name}`);
310
311
  } catch (e) {
311
- console.error(`${style.red("✗")} 저장 실패: ${e.message}`);
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("✓")} 세션 불러옴 (모델: ${ctx.model})`);
318
+ console.log(`${style.green("✓")} ${t("Session loaded (model: {model})", { model: ctx.model })}`);
318
319
  } catch {
319
- console.error(`${style.red("✗")} 세션 없음: ${arg || "last"}`);
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("✓")} 통계 ${ctx.verbose ? "켜짐" : "꺼짐"}`);
325
+ console.log(`${style.green("✓")} ${t("Stats {state}", { state: ctx.verbose ? t("on") : t("off") })}`);
325
326
  break;
326
327
  default:
327
- console.log(`알 수 없는 명령: /${cmd} (/help)`);
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(`${model} 생각 중…`);
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("✗")} 모델 목록 조회 실패: ${e.message}`);
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(` … ${pool.length - shown.length}개 더 — 검색어로 좁히기`);
451
- const ans = (await ask("\n번호 선택 또는 검색어 (Enter=취소): ")).trim();
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(`period는 daily|weekly|monthly 중 하나: ${period}`);
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 periodKo = { daily: "일간", weekly: "주간", monthly: "월간" }[data.period] || data.period;
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("모델 리더보드"))} ${styler.dim(`· ${periodKo} · 토큰 ${fmtTok(data.total_tokens ?? 0)}`)}`);
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(`리더보드 조회 실패 (${res.status})`);
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(`--by 는 ${GROUPS.join("|")} 중 하나: ${by}`);
17
- if (!Number.isInteger(days) || days < 1 || days > 365) throw new Error(`--days 는 1~365 정수: ${days}`);
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 byKo = { model: "모델별", provider: "제공자별", api_key: "키별", day: "일별" }[data.group_by] || data.group_by;
27
- lines.push(`${styler.bold(styler.plum("사용량"))} ${styler.dim(`· ${byKo} · 최근 ${data.days}일`)}`);
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("합계", keyW)} ${" ".repeat(20)} ${styler.bold(fmtKrw(total.cost))}${styler.dim(` ${total.calls} calls`)}`);
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("✗")} 이 API 키에는 usage:read 권한이 없습니다.\n` +
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(`사용량 조회 실패 (${res.status})${body ? `: ${body.slice(0, 200)}` : ""}`);
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
  }